Download src/components/tuition/chapter-dashboard/answerCorrection.ts from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 14.5 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/tuition/chapter-dashboard/answerCorrection.ts
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/src/components/tuition/chapter-dashboard/answerCorrection.ts
-
curl -L -o answerCorrection.ts https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/tuition/chapter-dashboard/answerCorrection.ts
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."; | |
| } | |