UNPKG

mcp-quiz-server

Version:

🧠 AI-Powered Quiz Management via Model Context Protocol (MCP) - Create, manage, and take quizzes directly from VS Code, Claude, and other AI agents.

253 lines (252 loc) • 10 kB
"use strict"; /** * @fileoverview Quiz Scoring Domain Service * @version 1.0.0 * @since 2025-07-29 * @lastUpdated 2025-07-29 * @module QuizScoringService Domain Service * @description Pure domain service for quiz scoring logic and calculations, * free from infrastructure concerns and framework dependencies. * @contributors Claude Code Agent * @dependencies Quiz, Question domain entities * @requirements REQ-ARCH-001 (Clean Architecture Domain Layer) * @testCoverage Unit tests for scoring algorithms and business rules */ Object.defineProperty(exports, "__esModule", { value: true }); exports.QuizScoringService = void 0; /** * Quiz Scoring Domain Service * * @description Encapsulates all business logic related to quiz scoring, * grade calculation, and performance evaluation. This service * operates on pure domain entities without external dependencies. * * @example * ```typescript * const scorer = new QuizScoringService(); * const score = scorer.calculateScore(quiz, userAnswers, { * passingThreshold: 80, * difficultyWeighting: true * }); * ``` * * @since 2025-07-29 * @author Claude Code Agent * @requirements REQ-ARCH-001 (Clean Architecture Domain Layer) */ class QuizScoringService { constructor() { this.DEFAULT_PASSING_THRESHOLD = 70; } /** * Calculate comprehensive quiz score */ calculateScore(quiz, userAnswers, options = {}) { const { penalizeUnanswered = false, partialCreditEnabled = false, difficultyWeighting = false, passingThreshold = this.DEFAULT_PASSING_THRESHOLD, } = options; const questionScores = this.calculateQuestionScores(quiz.questions, userAnswers, { partialCreditEnabled, difficultyWeighting, }); const totalQuestions = quiz.questionCount; const correctAnswers = questionScores.filter(q => q.isCorrect).length; const incorrectAnswers = questionScores.filter(q => q.wasAnswered && !q.isCorrect).length; const unansweredQuestions = questionScores.filter(q => !q.wasAnswered).length; let totalPointsEarned = questionScores.reduce((sum, q) => sum + q.pointsEarned, 0); const totalPointsPossible = questionScores.reduce((sum, q) => sum + q.pointsPossible, 0); // Apply penalty for unanswered questions if enabled if (penalizeUnanswered && unansweredQuestions > 0) { const penalty = this.calculateUnansweredPenalty(unansweredQuestions, totalPointsPossible); totalPointsEarned = Math.max(0, totalPointsEarned - penalty); } const percentageScore = totalPointsPossible > 0 ? Math.round((totalPointsEarned / totalPointsPossible) * 10000) / 100 : 0; return { totalQuestions, correctAnswers, incorrectAnswers, unansweredQuestions, totalPointsEarned, totalPointsPossible, percentageScore, letterGrade: this.calculateLetterGrade(percentageScore), isPassing: percentageScore >= passingThreshold, performanceLevel: this.calculatePerformanceLevel(percentageScore), questionScores, }; } /** * Calculate scores for individual questions */ calculateQuestionScores(questions, userAnswers, options) { return questions.map(question => { const userAnswer = userAnswers[question.id.value]; const wasAnswered = userAnswer !== undefined && userAnswer !== null && userAnswer.trim() !== ''; const isCorrect = wasAnswered && question.isCorrectAnswer(userAnswer); let pointsPossible = question.points; // Apply difficulty weighting if enabled if (options.difficultyWeighting) { pointsPossible = question.calculatePoints(); } let pointsEarned = 0; if (isCorrect) { pointsEarned = pointsPossible; } else if (options.partialCreditEnabled && wasAnswered) { pointsEarned = this.calculatePartialCredit(question, userAnswer, pointsPossible); } return { questionId: question.id.value, userAnswer: wasAnswered ? userAnswer : undefined, correctAnswer: question.correctAnswer, isCorrect, pointsEarned, pointsPossible, wasAnswered, }; }); } /** * Calculate partial credit for incorrect but related answers * Business rule: Only for multiple choice questions with similar answers */ calculatePartialCredit(question, userAnswer, pointsPossible) { if (!question.isMultipleChoice) { return 0; // No partial credit for non-multiple choice } // Business rule: Award 25% credit if answer is among the options but wrong if (question.options.includes(userAnswer.trim())) { return Math.round(pointsPossible * 0.25); } return 0; } /** * Calculate penalty for unanswered questions */ calculateUnansweredPenalty(unansweredCount, totalPoints) { // Business rule: 5% penalty per unanswered question, max 50% penalty const penaltyRate = Math.min(unansweredCount * 0.05, 0.5); return Math.round(totalPoints * penaltyRate); } /** * Calculate letter grade based on percentage */ calculateLetterGrade(percentage) { if (percentage >= 90) return 'A'; if (percentage >= 80) return 'B'; if (percentage >= 70) return 'C'; if (percentage >= 60) return 'D'; return 'F'; } /** * Calculate performance level based on percentage */ calculatePerformanceLevel(percentage) { if (percentage >= 90) return 'excellent'; if (percentage >= 80) return 'good'; if (percentage >= 70) return 'average'; return 'needs_improvement'; } /** * Calculate quiz difficulty score based on questions */ calculateQuizDifficulty(quiz) { const difficulties = quiz.questions.map(q => q.difficulty); const difficultyScores = { easy: 1, medium: 2, hard: 3 }; const totalScore = difficulties.reduce((sum, diff) => sum + difficultyScores[diff], 0); const averageDifficulty = totalScore / quiz.questionCount; // Count distribution const distribution = difficulties.reduce((acc, diff) => { acc[diff] = (acc[diff] || 0) + 1; return acc; }, {}); let overallLevel; if (averageDifficulty < 1.5) overallLevel = 'easy'; else if (averageDifficulty < 2.5) overallLevel = 'medium'; else if (averageDifficulty < 3.5) overallLevel = 'hard'; else overallLevel = 'expert'; return { averageDifficulty: Math.round(averageDifficulty * 100) / 100, difficultyLevel: overallLevel, difficultyDistribution: distribution, }; } /** * Validate quiz scoring configuration */ validateScoringConfiguration(quiz, options) { const errors = []; const warnings = []; // Validate quiz has questions if (quiz.questionCount === 0) { errors.push('Quiz must have at least one question for scoring'); } // Validate passing threshold if (options.passingThreshold !== undefined) { if (options.passingThreshold < 0 || options.passingThreshold > 100) { errors.push('Passing threshold must be between 0 and 100'); } if (options.passingThreshold < 50) { warnings.push('Passing threshold below 50% may not meet educational standards'); } } // Check for questions with zero points const zeroPointQuestions = quiz.questions.filter(q => q.points === 0); if (zeroPointQuestions.length > 0) { warnings.push(`${zeroPointQuestions.length} questions have zero points and won't contribute to score`); } // Check question configuration const invalidQuestions = quiz.questions.filter(q => !q.isValidConfiguration()); if (invalidQuestions.length > 0) { errors.push(`${invalidQuestions.length} questions have invalid configurations`); } return { isValid: errors.length === 0, errors, warnings, }; } /** * Compare two quiz scores for improvement analysis */ compareScores(previousScore, currentScore) { const percentageChange = currentScore.percentageScore - previousScore.percentageScore; const correctAnswersChange = currentScore.correctAnswers - previousScore.correctAnswers; const performanceImproved = percentageChange > 0; const significantImprovement = percentageChange >= 10; const areasOfImprovement = []; const areasOfDecline = []; if (currentScore.unansweredQuestions < previousScore.unansweredQuestions) { areasOfImprovement.push('completion_rate'); } else if (currentScore.unansweredQuestions > previousScore.unansweredQuestions) { areasOfDecline.push('completion_rate'); } if (correctAnswersChange > 0) { areasOfImprovement.push('accuracy'); } else if (correctAnswersChange < 0) { areasOfDecline.push('accuracy'); } return { percentageChange: Math.round(percentageChange * 100) / 100, correctAnswersChange, performanceImproved, significantImprovement, areasOfImprovement, areasOfDecline, }; } } exports.QuizScoringService = QuizScoringService;