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
JavaScript
;
/**
* @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;