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:
parent
3fb491c54d
commit
6036238bf0
25 changed files with 2821 additions and 181 deletions
194
lib/teach.ts
Normal file
194
lib/teach.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue