Teach it back: the AI becomes the curious student

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.
This commit is contained in:
Martin Dvořák 2026-10-16 23:05:24 +00:00
commit 6036238bf0
25 changed files with 2821 additions and 181 deletions

194
lib/teach.ts Normal file
View file

@ -0,0 +1,194 @@
"use client";
/**
* Teach it back: session logic shared by the UI and the API client.
*
* A session is a "chat" with `mode: "teach"`: the user explains, the AI is a
* curious student. This module owns the three things that must agree between
* the start screen, the session and the recap:
*
* 1. how many exchanges a session lasts (`TEACH_MAX_EXCHANGES` — the single
* constant; the prompt and the header counter both read it),
* 2. the card checklist: which of the user's own cards the student may check
* the explanation against, rendered as "[n] question → answer" lines for
* the model and as `TeachCardRef`s for the recap's links,
* 3. parsing the recap JSON the model returns.
*/
import {
teachSummarySchema,
type Activity,
type ChatScope,
type Deck,
type TeachCardRef,
type TeachSummary,
} from "./schemas";
/**
* How many answers the teacher gives before the student wraps up and the
* recap is written. One place to tune the length of a session.
*/
export const TEACH_MAX_EXCHANGES = 10;
/** Cards per session and per deck: enough to check against, small enough for
* a prompt (and for the student to actually get through). */
export const TEACH_MAX_CARDS = 48;
export const TEACH_CARDS_PER_DECK = 6;
/** The question side of a card — also what the recap links show. */
export function cardPrompt(activity: Activity): string {
switch (activity.type) {
case "flashcard":
return activity.front;
case "multiple_choice":
return activity.question;
case "true_false":
return activity.statement;
case "fill_blank":
return activity.sentence;
case "matching":
case "ordering":
return activity.instruction;
case "short_answer":
return activity.question;
}
}
/** The answer side of a card, as plain text for the prompt. */
export function cardAnswer(activity: Activity): string {
switch (activity.type) {
case "flashcard":
return activity.back;
case "multiple_choice":
return activity.options[activity.correctIndex] ?? "";
case "true_false":
return activity.answer ? "true" : "false";
case "fill_blank":
return activity.answer;
case "matching":
return activity.pairs.map((pair) => `${pair.term} = ${pair.definition}`).join("; ");
case "ordering":
return activity.items.join(" → ");
case "short_answer":
return activity.referenceAnswer;
}
}
/** The decks a scope covers (the same rule chat uses, activities required). */
export function scopedDecks(decks: Deck[], scope: ChatScope): Deck[] {
if (scope.kind === "none") return [];
if (scope.kind === "all") return decks.filter((deck) => deck.activities.length > 0);
return decks.filter((deck) => scope.deckIds.includes(deck.id) && deck.activities.length > 0);
}
/**
* The checklist for one session: up to `TEACH_CARDS_PER_DECK` cards from every
* scoped deck, numbered in order. `lines` is what the prompt shows the model
* ("[3] What drives the flow? → A pressure difference"); `cards` keeps the
* same references for the recap's links and for continuing a saved session.
*/
export function buildTeachCards(input: {
decks: Deck[];
scope: ChatScope;
perDeck?: number;
limit?: number;
}): { cards: TeachCardRef[]; lines: string[] } {
const perDeck = input.perDeck ?? TEACH_CARDS_PER_DECK;
const limit = input.limit ?? TEACH_MAX_CARDS;
const cards: TeachCardRef[] = [];
const lines: string[] = [];
for (const deck of scopedDecks(input.decks, input.scope)) {
for (const activity of deck.activities.slice(0, perDeck)) {
if (cards.length >= limit) break;
const prompt = cardPrompt(activity).trim();
const answer = cardAnswer(activity).trim();
if (!prompt || !answer) continue;
const ref = cards.length + 1;
cards.push({
ref,
deckId: deck.id,
activityId: activity.id,
deckTitle: deck.title,
label: prompt.slice(0, 300),
});
lines.push(`[${ref}] ${prompt.slice(0, 400)} → ${answer.slice(0, 400)}`.slice(0, 900));
}
}
return { cards, lines };
}
/**
* Rebuilds the prompt lines for a saved session's cards (the answers are not
* stored with the session, they come from the deck). Cards whose activity has
* since gone fall back to their stored label.
*/
export function teachCardLines(cards: TeachCardRef[], decks: Deck[]): string[] {
return cards.map((card) => {
const deck = decks.find((entry) => entry.id === card.deckId);
const activity = deck?.activities.find((entry) => entry.id === card.activityId);
if (!activity) return `[${card.ref}] ${card.label} → (card no longer in the deck)`.slice(0, 900);
return `[${card.ref}] ${cardPrompt(activity).slice(0, 400)} → ${cardAnswer(activity).slice(0, 400)}`.slice(0, 900);
});
}
/**
* The recap JSON the model returns, cleaned up: fences stripped, the object
* located, strings trimmed, empty bullets dropped, card references clamped to
* the session's cards. Null when the reply cannot be used.
*/
export function parseTeachSummary(raw: string, cards: TeachCardRef[] = []): TeachSummary | null {
const text = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
const start = text.indexOf("{");
const end = text.lastIndexOf("}");
if (start < 0 || end <= start) return null;
let decoded: unknown;
try {
decoded = JSON.parse(text.slice(start, end + 1));
} catch {
return null;
}
/* The prompt asks for "card" (the [n] reference); the stored shape calls it
"ref" — accept both so a recap is never lost to wording. */
let candidate = decoded;
if (decoded && typeof decoded === "object" && Array.isArray((decoded as { gaps?: unknown }).gaps)) {
const record = decoded as { gaps: Record<string, unknown>[] };
candidate = {
...record,
gaps: record.gaps.map((gap) =>
gap && typeof gap === "object" && gap.ref == null ? { ...gap, ref: gap.card ?? null } : gap,
),
};
}
const parsed = teachSummarySchema.safeParse(candidate);
if (!parsed.success) return null;
const refs = new Set(cards.map((card) => card.ref));
const clean = (value: string) => value.replace(/\s+/g, " ").trim();
const summary: TeachSummary = {
well: parsed.data.well.map(clean).filter(Boolean).slice(0, 4),
gaps: parsed.data.gaps
.map((gap) => ({
what: clean(gap.what),
ref: gap.ref != null && refs.has(gap.ref) ? gap.ref : null,
}))
.filter((gap) => gap.what.length > 0)
.slice(0, 6),
next: parsed.data.next.map(clean).filter(Boolean).slice(0, 2),
};
return summary;
}
/** A recap gap as a flashcard, for the "turn gaps into cards" flow. */
export function gapCardFrom(activity: Activity): { front: string; back: string } {
return {
front: cardPrompt(activity).trim().slice(0, 600),
back: cardAnswer(activity).trim().slice(0, 600),
};
}
/** The generation focus for gap cards: one flashcard per gap, capped to what
* the route accepts (focus is max 300 chars). */
export function gapFocus(gaps: { what: string }[]): string {
const lines = gaps.map((gap, index) => `${index + 1}. ${gap.what.replace(/\s+/g, " ").trim()}`);
const header = "Turn each gap below into one flashcard (type: flashcard only, one card per gap, answers worded as complete sentences): ";
return `${header}${lines.join(" ")}`.slice(0, 300);
}