Files
wisp/src/lib/learn/quiz.ts
T
NilsBriggen 40858e0025
CI / test (push) Successful in 16s
CI / build-apk (push) Has been skipped
CI / deploy-web (push) Successful in 30s
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>
2026-06-14 15:37:42 +02:00

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;
}