diff --git a/README.md b/README.md
index bcfb6d7..6165de6 100644
--- a/README.md
+++ b/README.md
@@ -1492,6 +1492,31 @@ Things I chose while building, with the reasoning:
into view as you type. No strings, so no i18n; the additive change sits on top of the merged
scene-heat rework unchanged.
+109. **Teach it back: the AI stops being the tutor and becomes the student.** The mode where the user
+ explains a topic and an AI classmate probes the gaps — never a grader, never lecturing.
+ `/teach` (a sidebar row next to Exam mode, plus *Teach it back* in every deck's actions)
+ starts from a deck — which prefills the topic and checks against that deck's own cards — or
+ from a typed topic with the same context and model chips Chat uses. The student is a prompt
+ variant of the chat route (`mode: "teach"`), so model resolution, allowlists, rate limits and
+ the streaming client are literally the same code; the personality lives in `lib/prompts.ts`
+ (`TEACH_*`): friendly or skeptical, one short reaction plus exactly one follow-up question,
+ never "correct"/"wrong", never its own facts, and it says so instead of guessing when the
+ notes cover nothing. It receives the deck's cards as a numbered checklist ("[3] Why …? → …")
+ and is told to ask about the ideas that were skipped. After `TEACH_MAX_EXCHANGES` (10, one
+ constant) replies — or whenever Finish is pressed — a second `teach_summary` call turns the
+ transcript into the recap JSON (`well` / `gaps` with card references / `next`), with one
+ repair retry, and each gap links back to the exact card it refers to. The recap's two
+ follow-ups: *Add missed cards to review* puts those activities on the SRS schedule due now,
+ and *Turn gaps into cards* generates one flashcard per gap from the chosen deck's own notes
+ and shows them in an editable review list (checkbox, front, back) before anything is saved.
+ Sessions are chat conversations with a `mode: "teach"` marker plus the session record, so they
+ inherit the IndexedDB history, backup and encrypted sync for free while staying out of the
+ Chat screens; the recent list on the start screen reopens a recap or continues an unfinished
+ session. The screen reuses MessageRow, the Composer (now able to drop its attach/scope chips
+ and take a mic), Markdown + KaTeX, AiDisclaimer and the pickers; dictation uses the Web Speech
+ API's recognition side, and the mic simply does not render where the browser cannot listen.
+ Verified by the teach suite (44 checks).
+
108. **The clock that learned the engine's pace.** "The tts dispatcher thing and the text highlight is
completely out of sync on desktop" — Firefox + speech-dispatcher (like iOS) reports **no
`onboundary` events**, so the highlight rides the estimated clock, and that estimate drifts
diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts
index 738710c..74948a6 100644
--- a/app/api/chat/route.ts
+++ b/app/api/chat/route.ts
@@ -8,7 +8,7 @@ import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/htt
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
import { acquireModelGate } from "@/lib/model-guard";
import { translate } from "@/lib/i18n-dict";
-import { buildChatSystemPrompt } from "@/lib/prompts";
+import { buildChatSystemPrompt, buildTeachSummarySystemPrompt, buildTeachSystemPrompt } from "@/lib/prompts";
import { estimateTokens, trimHistory } from "@/lib/chat-trim";
export const runtime = "nodejs";
@@ -43,6 +43,17 @@ const requestSchema = z.object({
)
.max(40)
.optional(),
+ /** Teach it back: "teach" is one student turn, "teach_summary" the recap. */
+ mode: z.enum(["teach", "teach_summary"]).optional(),
+ teach: z
+ .object({
+ topic: z.string().min(1).max(200),
+ difficulty: z.enum(["friendly", "skeptical"]),
+ exchange: z.number().int().min(0).max(60),
+ limit: z.number().int().min(1).max(60),
+ cards: z.array(z.string().max(1000)).max(80),
+ })
+ .optional(),
});
type StreamEvent =
@@ -97,7 +108,13 @@ export async function POST(request: Request) {
);
}
- const { messages, context } = parsed.data;
+ const { messages, context, mode, teach } = parsed.data;
+ if (mode && !teach) {
+ return Response.json(
+ { error: translate(locale, "server.invalidRequest", { detail: "teach mode needs the teach payload" }) },
+ { status: 400 },
+ );
+ }
// The model must be one the server actually offers — the configured
// favourites, or anything the user's Ollama lists. The picker shows exactly
@@ -142,13 +159,29 @@ export async function POST(request: Request) {
}
const history = trimHistory(messages);
- const system = buildChatSystemPrompt({
- context: keptContext.map((chunk) => ({
- deckTitle: chunk.deckTitle,
- section: chunk.section,
- text: chunk.text,
- })),
- });
+ const notesContext = keptContext.map((chunk) => ({
+ deckTitle: chunk.deckTitle,
+ section: chunk.section,
+ text: chunk.text,
+ }));
+ const system =
+ mode === "teach" && teach
+ ? buildTeachSystemPrompt({
+ topic: teach.topic,
+ difficulty: teach.difficulty,
+ cards: teach.cards,
+ context: notesContext,
+ exchange: teach.exchange,
+ limit: teach.limit,
+ })
+ : mode === "teach_summary" && teach
+ ? buildTeachSummarySystemPrompt({
+ topic: teach.topic,
+ difficulty: teach.difficulty,
+ cards: teach.cards,
+ context: notesContext,
+ })
+ : buildChatSystemPrompt({ context: notesContext });
// Concurrency + the account-wide model budget (lib/model-guard), taken
// after every cheap rejection so a 400 can never leak a slot.
diff --git a/app/teach/page.tsx b/app/teach/page.tsx
new file mode 100644
index 0000000..ea4b567
--- /dev/null
+++ b/app/teach/page.tsx
@@ -0,0 +1,32 @@
+import { Suspense } from "react";
+
+import { ApiKeyGate } from "@/components/ApiKeyGate";
+import { AppHeader } from "@/components/AppHeader";
+import { Sidebar, SidebarOffset } from "@/components/Sidebar";
+import { TeachScreen } from "@/components/teach/TeachScreen";
+
+export const metadata = {
+ title: "Teach it back",
+};
+
+/**
+ * Teach it back. Phones get a full-screen session (the tab bar steps aside,
+ * see TabBar); desktop keeps the sidebar. The hidden AppHeader is only there
+ * to mount the settings dialog the sidebar opens.
+ */
+export default function TeachPage() {
+ return (
+
+