40858e0025
Deterministic, on-device, no model: - src/lib/learn pure modules (tokenize, summary [TextRank-ish], glossary [definition-pattern + frequency], flashcards [cloze/Q-A], srs [SM-2], quiz [MCQ with distractors]) — 37 unit tests. - Flashcard persistence: Dexie v4 + native v4 `flashcards` table; create/list/ listDue/updateSrs/delete/counts; cascades (transcript delete, course->Unsorted). - UI: transcript "Study aids" (generate summary+glossary, click-to-seek; create flashcards), Study screen (SM-2 review + Anki CSV export), per-lecture Quiz, library Study link with due-count badge. 215 tests green, 0 tsc errors, web export builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
89 lines
3.0 KiB
TypeScript
89 lines
3.0 KiB
TypeScript
// Deterministic multiple-choice quiz generation from glossary entries. No model,
|
|
// NO randomness — given the same entries, the same quiz is produced every time
|
|
// (important for reproducible tests and stable UI across re-renders).
|
|
//
|
|
// IMPORTANT: relative imports only inside src/lib (vitest has no '@/*' alias).
|
|
|
|
import type { GlossaryEntry } from './glossary';
|
|
|
|
/** One MCQ: a definition prompt, term options, and the correct option index. */
|
|
export interface QuizQuestion {
|
|
question: string;
|
|
options: string[];
|
|
answerIndex: number;
|
|
/** Source start time (seconds). */
|
|
start?: number;
|
|
/** Source segment id. */
|
|
segmentId?: string;
|
|
}
|
|
|
|
const OPTION_COUNT = 4;
|
|
|
|
/**
|
|
* Generate up to `count` questions from `entries`.
|
|
*
|
|
* Requires at least {@link OPTION_COUNT} (4) distinct terms so every question
|
|
* can have one correct answer + 3 distinct distractors; with fewer entries we
|
|
* return [].
|
|
*
|
|
* Determinism: for question `i`, the correct answer is placed at index
|
|
* `i % OPTION_COUNT`, and the remaining slots are filled with distractor terms
|
|
* drawn from the OTHER entries by rotating through the list (offset by `i`), so
|
|
* the layout is a pure function of the input order.
|
|
*/
|
|
export function generateQuiz(
|
|
entries: GlossaryEntry[],
|
|
opts?: { count?: number },
|
|
): QuizQuestion[] {
|
|
const count = opts?.count ?? 8;
|
|
if (entries.length < OPTION_COUNT || count <= 0) return [];
|
|
|
|
const questions: QuizQuestion[] = [];
|
|
const n = entries.length;
|
|
const limit = Math.min(count, n);
|
|
|
|
for (let i = 0; i < limit; i++) {
|
|
const correct = entries[i];
|
|
if (!correct) continue; // unreachable (i < limit <= n), satisfies the checker
|
|
|
|
// Collect 3 distinct distractor terms from other entries, rotating the
|
|
// start point by `i` so different questions get different wrong answers.
|
|
const distractors: string[] = [];
|
|
const seen = new Set<string>([correct.term.toLowerCase()]);
|
|
let j = 1;
|
|
while (distractors.length < OPTION_COUNT - 1 && j <= n) {
|
|
const cand = entries[(i + j) % n];
|
|
j++;
|
|
if (!cand) continue;
|
|
const key = cand.term.toLowerCase();
|
|
if (!seen.has(key)) {
|
|
seen.add(key);
|
|
distractors.push(cand.term);
|
|
}
|
|
}
|
|
|
|
// Defensive: if duplicate terms left us short, skip this question rather
|
|
// than emit one with fewer than 4 options. (glossary() dedupes, so this is
|
|
// effectively unreachable for real input.)
|
|
if (distractors.length < OPTION_COUNT - 1) continue;
|
|
|
|
// Place the answer at a deterministic index; fill the rest with distractors.
|
|
const answerIndex = i % OPTION_COUNT;
|
|
const options: string[] = [];
|
|
let d = 0;
|
|
for (let slot = 0; slot < OPTION_COUNT; slot++) {
|
|
options.push(slot === answerIndex ? correct.term : (distractors[d++] as string));
|
|
}
|
|
|
|
questions.push({
|
|
question: `Which term means: "${correct.definition}"?`,
|
|
options,
|
|
answerIndex,
|
|
start: correct.start,
|
|
segmentId: correct.segmentId,
|
|
});
|
|
}
|
|
|
|
return questions;
|
|
}
|