import type { AnswerCorrectionRuleData, QuizOptionData, SourceRef, } from "./sessionData"; /** * Deterministic answer correction engine for the /tuition mission quiz flow. * * No AI generation. Every output is derived only from the source-backed * question data (answer_outline, keywords) and the answer_correction_rules. * When the answer cannot be scored deterministically (no keywords, empty * answer) the engine returns a `needs_review` style result instead of guessing. */ export type AnswerCorrectionInput = { question: string; studentAnswer: string | null | undefined; answer_outline?: string; keywords?: string[]; answer_correction_rules?: AnswerCorrectionRuleData[]; source_refs?: SourceRef[]; /** Optional MCQ context. When `options` is present the engine uses exact-match scoring. */ options?: QuizOptionData[]; correctOption?: QuizOptionData["key"]; /** Knowledge-base question type, e.g. "mcq", "definition", "two_mark", "source_based". */ questionType?: string; /** Marks for the question; used as `maxMarks` when provided. */ marks?: number; /** Optional source-backed marking rubric for the current written question. */ rubric?: { maxMarks: number; scoringPoints: Array<{ point: string; marks: number; requiredKeywords: string[]; minimumMatches?: number; }>; }; }; export type AnswerCorrectionStatus = "scored" | "needs_review"; export type AnswerCorrectionResult = { isCorrect: boolean; scoreEstimate: number; maxMarks: number; missingKeywords: string[]; matchedKeywords: string[]; feedbackTitle: string; feedbackExplanation: string; boardAnswerRewrite: string; weakAreaTags: string[]; source_refs: SourceRef[]; status: AnswerCorrectionStatus; }; type QuestionKind = "mcq" | "definition" | "list" | "source" | "short"; const MARKS_BY_TYPE: Record = { mcq: 1, one_mark: 1, definition: 2, two_mark: 2, table_recall: 2, numerical: 2, reason: 2, three_mark: 3, matching: 3, timeline: 3, source_based: 3, comparison: 4, long_answer: 5, }; const COVERAGE_PASS = 0.6; const SOURCE_PASS = 0.5; function normalize(value: string | null | undefined): string { return (value ?? "") .toLowerCase() .normalize("NFKD") .replace(/[^a-z0-9\s]/g, " ") .replace(/\s+/g, " ") .trim(); } function tokenize(normalized: string): Set { return new Set(normalized.split(" ").filter(Boolean)); } /** True when every significant token of `keyword` appears in the student's answer. */ function answerHasKeyword(answerNorm: string, answerTokens: Set, keyword: string): boolean { const keywordNorm = normalize(keyword); if (!keywordNorm) return false; if (keywordNorm.includes(" ") && answerNorm.includes(keywordNorm)) return true; if (!keywordNorm.includes(" ") && keywordNorm.length <= 2) { return answerTokens.has(keywordNorm); } if (answerNorm.includes(keywordNorm)) return true; const tokens = keywordNorm.split(" ").filter((token) => token.length >= 3 || /\d/.test(token)); if (tokens.length === 0) return answerTokens.has(keywordNorm); return tokens.every((token) => answerTokens.has(token) || answerNorm.includes(token)); } function dedupeKeywords(keywords: string[]): string[] { const seen = new Set(); const result: string[] = []; for (const keyword of keywords) { const key = normalize(keyword); if (!key || seen.has(key)) continue; seen.add(key); result.push(keyword.trim()); } return result; } function classifyQuestion(input: AnswerCorrectionInput): QuestionKind { const type = normalize(input.questionType); const hasOptions = (input.options?.length ?? 0) > 0; if (type === "mcq" || (hasOptions && type === "")) return "mcq"; if (type.includes("definition")) return "definition"; if (type.includes("source")) return "source"; if (type.includes("matching") || type.includes("table") || type.includes("timeline") || type.includes("list")) { return "list"; } if (hasOptions) return "mcq"; return "short"; } function inferMaxMarks(input: AnswerCorrectionInput, kind: QuestionKind): number { if (typeof input.rubric?.maxMarks === "number" && input.rubric.maxMarks > 0) return input.rubric.maxMarks; if (typeof input.marks === "number" && input.marks > 0) return input.marks; const type = normalize(input.questionType).replace(/\s+/g, "_"); if (type && MARKS_BY_TYPE[type]) return MARKS_BY_TYPE[type]; if (kind === "mcq") return 1; if (kind === "definition") return 2; const keywordCount = input.keywords?.length ?? 0; return keywordCount > 0 ? Math.max(1, Math.min(5, keywordCount)) : 1; } /** * The required "core concept" keywords. Prefers a rule keyword_map entry whose * term appears in the question (e.g. "Define mercantilism" -> Mercantilism's * core keywords). Falls back to the question keywords. */ function pickCoreKeywords(input: AnswerCorrectionInput): string[] { const questionNorm = normalize(input.question); for (const rule of input.answer_correction_rules ?? []) { if (!rule.keyword_map) continue; for (const [term, core] of Object.entries(rule.keyword_map)) { if (core.length > 0 && questionNorm.includes(normalize(term))) { return dedupeKeywords(core); } } } return dedupeKeywords(input.keywords ?? []); } function clampScore(score: number, maxMarks: number): number { if (!Number.isFinite(score)) return 0; return Math.max(0, Math.min(maxMarks, Math.round(score))); } function correctOptionText(input: AnswerCorrectionInput): string { return input.options?.find((option) => option.key === input.correctOption)?.text?.trim() ?? ""; } function buildResult(partial: Omit & { source_refs?: SourceRef[] }, input: AnswerCorrectionInput): AnswerCorrectionResult { // source_refs are always preserved exactly as provided by the question data. return { ...partial, source_refs: input.source_refs ?? [] }; } export function correctAnswer(input: AnswerCorrectionInput): AnswerCorrectionResult { const kind = classifyQuestion(input); const maxMarks = inferMaxMarks(input, kind); const answerNorm = normalize(input.studentAnswer); const keywords = dedupeKeywords(input.keywords ?? []); // --- No answer captured -> needs review, never guessed correct. --- if (answerNorm === "") { return buildResult( { isCorrect: false, scoreEstimate: 0, maxMarks, matchedKeywords: [], missingKeywords: keywords, feedbackTitle: "Needs review", feedbackExplanation: "No answer was captured. Compare your written answer with the answer outline and the source pages.", boardAnswerRewrite: boardAnswer(input, keywords), weakAreaTags: keywords, status: "needs_review", }, input, ); } if (kind === "mcq") { return correctMcq(input, maxMarks, answerNorm, keywords); } if (input.rubric?.scoringPoints.length) { return correctWithRubric(input, maxMarks, answerNorm); } return correctWritten(input, kind, maxMarks, answerNorm, keywords); } function correctWithRubric( input: AnswerCorrectionInput, maxMarks: number, answerNorm: string, ): AnswerCorrectionResult { const answerTokens = tokenize(answerNorm); const rubric = input.rubric; if (!rubric) return correctWritten(input, "short", maxMarks, answerNorm, input.keywords ?? []); const matchedKeywords: string[] = []; const missingKeywords: string[] = []; let rawScore = 0; for (const scoringPoint of rubric.scoringPoints) { const required = dedupeKeywords(scoringPoint.requiredKeywords); const matched = required.filter((keyword) => answerHasKeyword(answerNorm, answerTokens, keyword)); matchedKeywords.push(...matched); const minimumMatches = Math.max( 1, Math.min(required.length, scoringPoint.minimumMatches ?? required.length), ); const pointCovered = required.length > 0 && matched.length >= minimumMatches; if (pointCovered) { rawScore += Math.max(0, scoringPoint.marks); } else { missingKeywords.push(...required.filter((keyword) => !matched.includes(keyword))); } } const scoreEstimate = clampScore(rawScore, maxMarks); const isCorrect = scoreEstimate >= maxMarks; const missing = dedupeKeywords(missingKeywords); const matched = dedupeKeywords(matchedKeywords); const feedbackTitle = isCorrect ? "Correct" : scoreEstimate > 0 ? "Partially correct" : "Needs more detail"; const feedbackExplanation = isCorrect ? `Strong answer - every scoring point in the ${maxMarks}-mark rubric is covered.` : `${scoreEstimate} of ${maxMarks} marks covered.${missing.length ? ` Add: ${missing.join(", ")}.` : ""}`; return buildResult( { isCorrect, scoreEstimate, maxMarks, matchedKeywords: matched, missingKeywords: missing, feedbackTitle, feedbackExplanation, boardAnswerRewrite: boardAnswer(input, dedupeKeywords(input.keywords ?? [])), weakAreaTags: isCorrect ? [] : missing, status: "scored", }, input, ); } function correctMcq( input: AnswerCorrectionInput, maxMarks: number, answerNorm: string, keywords: string[], ): AnswerCorrectionResult { const correctKey = normalize(input.correctOption); const correctText = normalize(correctOptionText(input)); const isCorrect = answerNorm.length > 0 && (answerNorm === correctKey || (correctText !== "" && (answerNorm === correctText || answerNorm.includes(correctText)))); const matchedKeywords = isCorrect ? keywords : []; const missingKeywords = isCorrect ? [] : keywords; const correctLabel = correctOptionText(input) || (input.correctOption ?? ""); const outline = input.answer_outline?.trim(); const boardAnswerRewrite = outline ? outline : correctLabel ? `Correct answer: ${correctLabel}.` : boardAnswer(input, keywords); return buildResult( { isCorrect, scoreEstimate: isCorrect ? maxMarks : 0, maxMarks, matchedKeywords, missingKeywords, feedbackTitle: isCorrect ? "Correct" : "Incorrect", feedbackExplanation: isCorrect ? "Correct choice — it matches the source-backed answer." : `Incorrect choice. The source-backed answer is ${correctLabel || "shown in the outline"}.`, boardAnswerRewrite, weakAreaTags: isCorrect ? [] : keywords, status: "scored", }, input, ); } function correctWritten( input: AnswerCorrectionInput, kind: Exclude, maxMarks: number, answerNorm: string, keywords: string[], ): AnswerCorrectionResult { const answerTokens = tokenize(answerNorm); const core = pickCoreKeywords(input); const allKeywords = keywords.length > 0 ? keywords : core; // No keywords to score against -> cannot deterministically grade. if (allKeywords.length === 0) { return buildResult( { isCorrect: false, scoreEstimate: 0, maxMarks, matchedKeywords: [], missingKeywords: [], feedbackTitle: "Needs review", feedbackExplanation: "This question has no keyword checklist, so it could not be auto-scored. Self-check against the answer outline and source pages.", boardAnswerRewrite: boardAnswer(input, keywords), weakAreaTags: [], status: "needs_review", }, input, ); } const matchedKeywords: string[] = []; const missingKeywords: string[] = []; for (const keyword of allKeywords) { if (answerHasKeyword(answerNorm, answerTokens, keyword)) matchedKeywords.push(keyword); else missingKeywords.push(keyword); } const coreMatched = core.filter((keyword) => answerHasKeyword(answerNorm, answerTokens, keyword)); const coverage = matchedKeywords.length / allKeywords.length; const coreCoverage = core.length > 0 ? coreMatched.length / core.length : coverage; let isCorrect: boolean; switch (kind) { case "definition": // Core definition keywords must be present. isCorrect = core.length > 0 ? coreCoverage >= COVERAGE_PASS && coreMatched.length > 0 : coverage >= COVERAGE_PASS; break; case "list": { // Check the required count where possible. const required = Math.max(1, Math.ceil(allKeywords.length * COVERAGE_PASS)); isCorrect = matchedKeywords.length >= required; break; } case "source": // Named source/person/event keywords must be present. isCorrect = matchedKeywords.length >= 1 && coverage >= SOURCE_PASS && (core.length === 0 || coreMatched.length > 0); break; default: isCorrect = matchedKeywords.length >= 1 && coverage >= COVERAGE_PASS; } const isPartial = !isCorrect && matchedKeywords.length > 0; let scoreEstimate = clampScore(coverage * maxMarks, maxMarks); if (isCorrect && scoreEstimate === 0) scoreEstimate = 1; const feedbackTitle = isCorrect ? "Correct" : isPartial ? "Partially correct" : "Needs more detail"; const feedbackExplanation = buildExplanation( feedbackTitle, matchedKeywords.length, allKeywords.length, missingKeywords, ); return buildResult( { isCorrect, scoreEstimate, maxMarks, matchedKeywords, missingKeywords, feedbackTitle, feedbackExplanation, boardAnswerRewrite: boardAnswer(input, keywords), weakAreaTags: missingKeywords, status: "scored", }, input, ); } function buildExplanation( title: string, matchedCount: number, totalCount: number, missingKeywords: string[], ): string { const coverageText = `You covered ${matchedCount} of ${totalCount} key point${totalCount === 1 ? "" : "s"}.`; if (title === "Correct") { return `Strong answer — ${coverageText}`; } const missingText = missingKeywords.length > 0 ? ` Add: ${missingKeywords.join(", ")}.` : ""; if (title === "Partially correct") { return `Partly there — ${coverageText}${missingText}`; } return `Key concepts are missing.${missingText}`.trim(); } /** Board-style model answer: the source-backed answer outline (never invented). */ function boardAnswer(input: AnswerCorrectionInput, keywords: string[]): string { const outline = input.answer_outline?.trim(); if (outline) return outline; if (keywords.length > 0) return `Include these points: ${keywords.join(", ")}.`; return "Refer to the answer outline on the cited source pages."; }