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.
194 lines
7.2 KiB
TypeScript
194 lines
7.2 KiB
TypeScript
"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);
|
|
}
|