feat(phase3): learning helpers — summary, glossary, flashcards (SM-2), quizzes
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>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user