// 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([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; }