A new mode where the user explains a topic in their own words and the AI plays a curious classmate: short reactions, exactly one follow-up question at a time, never "correct"/"wrong", never lecturing, and everything it knows comes from the user's own decks and notes. - Entry points: a sidebar row next to Exam mode and a "Teach it back" action in every deck's menu (which prefills the topic and scope). - Start screen: deck or typed topic, friendly/skeptical student, the same context and model chips as Chat (/teach?deck=<id> pre-selects the deck). - Session: the student prompt is a mode of the existing /api/chat route, so model resolution, allowlists, rate limits and the streaming client are the same code; the personality lives in lib/prompts.ts (TEACH_*). The deck's cards travel as a numbered checklist and the student is told to ask about the ideas that were skipped; it must say so rather than guess when the notes cover nothing. One constant (TEACH_MAX_EXCHANGES = 10) ends the session, or the Finish button does. - Recap: a teach_summary call (JSON, one repair retry) yields well / gaps with card references / next steps; each gap links to the card it means. "Add missed cards to review" schedules those activities due now; "Turn gaps into cards" generates one flashcard per gap from the deck's notes and shows an editable review list before saving. - Sessions are chat conversations with a mode:"teach" marker + session record: same IndexedDB history, backup and sync, filtered out of the Chat screens; the recent list reopens a recap or continues a session. - Reused pieces: MessageRow, Composer (now optional attach/scope chips and a mic), Markdown/KaTeX, AiDisclaimer, ContextPicker/ModelPicker, plus a shared useChatModels hook and useKeyboardInset extracted from Chat. - Dictation: Web Speech API recognition (lib/speech-input.ts); the mic only renders where the browser supports it, and dictated phrases land in the draft. - All strings in en + cs. teach-test: 44 checks (API prompts and guards, both entry points, the session, recap actions, history, exchange limit, 360/390px layouts, mic on/off). Version 0.8.1, SW v103.
299 lines
9.5 KiB
TypeScript
299 lines
9.5 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* Answer history and the spaced-repetition schedule, kept in IndexedDB
|
||
* (unbounded event log — localStorage would fill up). The in-memory mirror
|
||
* makes reads synchronous for React (`useSyncExternalStore`); writes update
|
||
* the mirror immediately and persist asynchronously.
|
||
*
|
||
* Versioned DB `freezmo-learning` v1 with stores:
|
||
* answers keyPath id (indexes: at, deckId)
|
||
* srs keyPath key (index: dueAt) key = `${deckId}:${activityId}`
|
||
*/
|
||
|
||
import { type AnswerRecord, type SrsRecord } from "./schemas";
|
||
import { markSyncDirty } from "./sync-state";
|
||
|
||
export type { AnswerRecord, SrsRecord };
|
||
|
||
const DB_NAME = "freezmo-learning";
|
||
const DB_VERSION = 1;
|
||
const ANSWERS = "answers";
|
||
const SRS = "srs";
|
||
/** The log is pruned beyond this many answers (oldest first). */
|
||
export const MAX_ANSWERS = 20_000;
|
||
/** A wrong answer comes back after this long, even inside a session. */
|
||
export const WRONG_RETRY_MS = 10 * 60_000;
|
||
|
||
const EMPTY_ANSWERS: AnswerRecord[] = [];
|
||
const EMPTY_SRS: SrsRecord[] = [];
|
||
|
||
type Listener = () => void;
|
||
|
||
let answers: AnswerRecord[] = [];
|
||
let schedules = new Map<string, SrsRecord>();
|
||
let cachedAnswers: AnswerRecord[] = EMPTY_ANSWERS;
|
||
let cachedSrs: SrsRecord[] = EMPTY_SRS;
|
||
const listeners = new Set<Listener>();
|
||
let hydrated = false;
|
||
let hydration: Promise<void> | null = null;
|
||
|
||
function notify(): void {
|
||
cachedAnswers = answers;
|
||
cachedSrs = [...schedules.values()];
|
||
listeners.forEach((listener) => listener());
|
||
}
|
||
|
||
function openDb(): Promise<IDBDatabase> {
|
||
return new Promise((resolve, reject) => {
|
||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||
request.onupgradeneeded = () => {
|
||
const db = request.result;
|
||
if (!db.objectStoreNames.contains(ANSWERS)) {
|
||
const store = db.createObjectStore(ANSWERS, { keyPath: "id" });
|
||
store.createIndex("at", "at");
|
||
store.createIndex("deckId", "deckId");
|
||
}
|
||
if (!db.objectStoreNames.contains(SRS)) {
|
||
const store = db.createObjectStore(SRS, { keyPath: "key" });
|
||
store.createIndex("dueAt", "dueAt");
|
||
}
|
||
};
|
||
request.onsuccess = () => resolve(request.result);
|
||
request.onerror = () => reject(request.error);
|
||
});
|
||
}
|
||
|
||
async function withDb<T>(_mode: IDBTransactionMode, run: (db: IDBDatabase) => T | Promise<T>): Promise<T> {
|
||
const db = await openDb();
|
||
try {
|
||
return await run(db);
|
||
} finally {
|
||
// Transactions keep the connection alive until they settle; close on the
|
||
// next task so pending requests finish first.
|
||
window.setTimeout(() => db.close(), 1000);
|
||
}
|
||
}
|
||
|
||
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
|
||
return new Promise((resolve, reject) => {
|
||
request.onsuccess = () => resolve(request.result);
|
||
request.onerror = () => reject(request.error);
|
||
});
|
||
}
|
||
|
||
/** Loads IndexedDB into the mirror exactly once. Safe to call repeatedly. */
|
||
export function ensureLearningHydrated(): Promise<void> {
|
||
if (hydrated) return Promise.resolve();
|
||
if (hydration) return hydration;
|
||
hydration = (async () => {
|
||
if (typeof indexedDB === "undefined") {
|
||
hydrated = true;
|
||
return;
|
||
}
|
||
try {
|
||
await withDb("readonly", async (db) => {
|
||
const transaction = db.transaction([ANSWERS, SRS], "readonly");
|
||
const [loadedAnswers, loadedSrs] = await Promise.all([
|
||
requestToPromise(transaction.objectStore(ANSWERS).getAll() as IDBRequest<AnswerRecord[]>),
|
||
requestToPromise(transaction.objectStore(SRS).getAll() as IDBRequest<SrsRecord[]>),
|
||
]);
|
||
// Writes that happened before hydration win (dedupe by id/key).
|
||
const known = new Set(answers.map((entry) => entry.id));
|
||
const merged = [...answers, ...loadedAnswers.filter((entry) => !known.has(entry.id))];
|
||
merged.sort((a, b) => b.at - a.at);
|
||
answers = merged.slice(0, MAX_ANSWERS);
|
||
for (const entry of loadedSrs) {
|
||
const current = schedules.get(entry.key);
|
||
if (!current || current.lastAt < entry.lastAt) schedules.set(entry.key, entry);
|
||
}
|
||
});
|
||
} catch (error) {
|
||
console.warn("[freezmo] could not read the learning history", error);
|
||
} finally {
|
||
hydrated = true;
|
||
notify();
|
||
}
|
||
})();
|
||
return hydration;
|
||
}
|
||
|
||
export const learningStore = {
|
||
subscribe(listener: Listener): () => void {
|
||
listeners.add(listener);
|
||
void ensureLearningHydrated();
|
||
return () => listeners.delete(listener);
|
||
},
|
||
getAnswersSnapshot(): AnswerRecord[] {
|
||
return cachedAnswers;
|
||
},
|
||
getSrsSnapshot(): SrsRecord[] {
|
||
return cachedSrs;
|
||
},
|
||
getServerAnswers(): AnswerRecord[] {
|
||
return EMPTY_ANSWERS;
|
||
},
|
||
getServerSrs(): SrsRecord[] {
|
||
return EMPTY_SRS;
|
||
},
|
||
};
|
||
|
||
export type AnswerInput = {
|
||
deckId: string;
|
||
deckTitle: string;
|
||
activityId: string;
|
||
type: string;
|
||
topic?: string;
|
||
correct: boolean;
|
||
};
|
||
|
||
/**
|
||
* Records one answer and updates its schedule. The schedule is a light SM-2:
|
||
* correct → 1 day, then 3 days, then interval × ease; wrong → back in ten
|
||
* minutes with the ease nudged down.
|
||
*/
|
||
export function recordAnswer(input: AnswerInput, at = Date.now()): void {
|
||
const record: AnswerRecord = {
|
||
id: `${at.toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||
deckId: input.deckId,
|
||
deckTitle: input.deckTitle,
|
||
activityId: input.activityId,
|
||
type: input.type,
|
||
topic: input.topic,
|
||
correct: input.correct,
|
||
at,
|
||
};
|
||
answers = [record, ...answers].slice(0, MAX_ANSWERS);
|
||
|
||
const key = `${input.deckId}:${input.activityId}`;
|
||
const previous = schedules.get(key);
|
||
let ease = previous?.ease ?? 2.5;
|
||
let reps = previous?.reps ?? 0;
|
||
let intervalDays = previous?.intervalDays ?? 0;
|
||
let lapses = previous?.lapses ?? 0;
|
||
|
||
if (input.correct) {
|
||
ease = Math.min(3, ease + 0.1);
|
||
reps += 1;
|
||
intervalDays = reps === 1 ? 1 : reps === 2 ? 3 : Math.min(365, Math.round(Math.max(1, intervalDays) * ease));
|
||
} else {
|
||
ease = Math.max(1.3, ease - 0.2);
|
||
reps = 0;
|
||
intervalDays = 0;
|
||
lapses += 1;
|
||
}
|
||
|
||
const schedule: SrsRecord = {
|
||
key,
|
||
deckId: input.deckId,
|
||
activityId: input.activityId,
|
||
ease,
|
||
reps,
|
||
intervalDays,
|
||
lapses,
|
||
dueAt: at + (input.correct ? intervalDays * 86_400_000 : WRONG_RETRY_MS),
|
||
lastAt: at,
|
||
lastCorrect: input.correct,
|
||
};
|
||
schedules = new Map(schedules);
|
||
schedules.set(key, schedule);
|
||
|
||
notify();
|
||
markSyncDirty();
|
||
|
||
void (async () => {
|
||
try {
|
||
await ensureLearningHydrated();
|
||
await withDb("readwrite", (db) => {
|
||
const transaction = db.transaction([ANSWERS, SRS], "readwrite");
|
||
transaction.objectStore(ANSWERS).put(record);
|
||
transaction.objectStore(SRS).put(schedule);
|
||
});
|
||
// Prune the log beyond the cap (oldest first) in a separate pass.
|
||
const overflow = answers.length - MAX_ANSWERS;
|
||
if (overflow > 0) {
|
||
const oldest = answers.slice(MAX_ANSWERS).map((entry) => entry.id);
|
||
await withDb("readwrite", (db) => {
|
||
const transaction = db.transaction(ANSWERS, "readwrite");
|
||
const store = transaction.objectStore(ANSWERS);
|
||
for (const id of oldest) store.delete(id);
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.warn("[freezmo] could not save the answer history", error);
|
||
}
|
||
})();
|
||
}
|
||
|
||
/** Used by backup import and sync pulls. */
|
||
/**
|
||
* Puts specific cards on the review schedule, due right now — Teach it back's
|
||
* "add the missed cards to review". A card that was already scheduled keeps
|
||
* its ease and lapse count; only the due date is pulled forward. Returns how
|
||
* many cards were scheduled.
|
||
*/
|
||
export function scheduleForReview(deckId: string, activityIds: string[], at = Date.now()): number {
|
||
const changed: SrsRecord[] = [];
|
||
for (const activityId of activityIds) {
|
||
const key = `${deckId}:${activityId}`;
|
||
const previous = schedules.get(key);
|
||
changed.push({
|
||
key,
|
||
deckId,
|
||
activityId,
|
||
ease: previous?.ease ?? 2.5,
|
||
reps: previous?.reps ?? 0,
|
||
intervalDays: 0,
|
||
lapses: previous?.lapses ?? 0,
|
||
dueAt: at,
|
||
lastAt: at,
|
||
lastCorrect: previous?.lastCorrect ?? false,
|
||
});
|
||
}
|
||
if (changed.length === 0) return 0;
|
||
|
||
schedules = new Map(schedules);
|
||
for (const schedule of changed) schedules.set(schedule.key, schedule);
|
||
notify();
|
||
markSyncDirty();
|
||
|
||
void (async () => {
|
||
try {
|
||
await ensureLearningHydrated();
|
||
await withDb("readwrite", (db) => {
|
||
const transaction = db.transaction(SRS, "readwrite");
|
||
const store = transaction.objectStore(SRS);
|
||
for (const schedule of changed) store.put(schedule);
|
||
});
|
||
} catch (error) {
|
||
console.warn("[freezmo] could not schedule the review cards", error);
|
||
}
|
||
})();
|
||
|
||
return changed.length;
|
||
}
|
||
|
||
export async function replaceLearning(nextAnswers: AnswerRecord[], nextSrs: SrsRecord[]): Promise<void> {
|
||
const sorted = [...nextAnswers].sort((a, b) => b.at - a.at).slice(0, MAX_ANSWERS);
|
||
answers = sorted;
|
||
schedules = new Map(nextSrs.map((entry) => [entry.key, entry]));
|
||
notify();
|
||
try {
|
||
await withDb("readwrite", (db) => {
|
||
const transaction = db.transaction([ANSWERS, SRS], "readwrite");
|
||
const answersStore = transaction.objectStore(ANSWERS);
|
||
const srsStore = transaction.objectStore(SRS);
|
||
answersStore.clear();
|
||
srsStore.clear();
|
||
for (const entry of sorted) answersStore.put(entry);
|
||
for (const entry of nextSrs) srsStore.put(entry);
|
||
});
|
||
} catch (error) {
|
||
console.warn("[freezmo] could not replace the learning history", error);
|
||
}
|
||
}
|
||
|
||
/** Wipes the log and every schedule (used by the stats screen). */
|
||
export async function clearLearning(): Promise<void> {
|
||
await replaceLearning([], []);
|
||
}
|