DocDoeAI / src /components /tuition /chapter-dashboard /answerCorrection.ts
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
14.5 kB
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<string, number> = {
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<string> {
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<string>, 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<string>();
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<AnswerCorrectionResult, "source_refs"> & { 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<QuestionKind, "mcq">,
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.";
}