From 6036238bf0e1cd441bdc7c29f42373a306c64321 Mon Sep 17 00:00:00 2001 From: Martin Dvorak Date: Fri, 16 Oct 2026 23:05:24 +0000 Subject: [PATCH] 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= 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. --- README.md | 25 + app/api/chat/route.ts | 51 +- app/teach/page.tsx | 32 ++ components/DeckCard.tsx | 9 +- components/Sidebar.tsx | 9 + components/TabBar.tsx | 6 +- components/chat/ChatScreen.tsx | 126 +---- components/chat/Composer.tsx | 192 ++++--- components/chat/HistoryPanel.tsx | 6 +- components/icons.tsx | 23 + components/teach/TeachRecap.tsx | 361 +++++++++++++ components/teach/TeachScreen.tsx | 885 +++++++++++++++++++++++++++++++ lib/chat-client.ts | 13 + lib/chat-models.ts | 96 ++++ lib/history.ts | 47 ++ lib/i18n-dict.ts | 134 +++++ lib/prompts.ts | 139 +++++ lib/schemas.ts | 45 ++ lib/speech-input.ts | 103 ++++ lib/teach.ts | 194 +++++++ lib/use-keyboard-inset.ts | 28 + package.json | 2 +- public/sw.js | 2 +- tests/run-all.mjs | 1 + tests/teach-test.mjs | 473 +++++++++++++++++ 25 files changed, 2821 insertions(+), 181 deletions(-) create mode 100644 app/teach/page.tsx create mode 100644 components/teach/TeachRecap.tsx create mode 100644 components/teach/TeachScreen.tsx create mode 100644 lib/chat-models.ts create mode 100644 lib/speech-input.ts create mode 100644 lib/teach.ts create mode 100644 lib/use-keyboard-inset.ts create mode 100644 tests/teach-test.mjs 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 ( + +
+ + +
+ + + + +
+
+
+ ); +} diff --git a/components/DeckCard.tsx b/components/DeckCard.tsx index bde96f4..3d676dd 100644 --- a/components/DeckCard.tsx +++ b/components/DeckCard.tsx @@ -17,7 +17,7 @@ import { showToast } from "@/lib/toast"; import { useBackClose } from "@/lib/use-back-close"; import { useIsPhone } from "@/lib/use-phone"; import { cn, formatDate, truncate } from "@/lib/utils"; -import { ChatIcon, ChevronDownIcon, DownloadIcon, EllipsisIcon, FolderIcon, PlayIcon, RefreshIcon, ShareIcon, XCircleIcon } from "@/components/icons"; +import { ChatIcon, ChevronDownIcon, DownloadIcon, EllipsisIcon, FolderIcon, PlayIcon, RefreshIcon, ShareIcon, TeacherIcon, XCircleIcon } from "@/components/icons"; import { Button, ProgressBar } from "@/components/ui"; type Busy = null | "more" | "regenerate"; @@ -395,6 +395,13 @@ export function DeckCard({ {t("chat.askAboutDeck")} + + + {t("teach.nav")} + - { - const file = event.target.files?.[0]; - if (file) onAttachFile(file); - event.target.value = ""; - }} - /> + {onAttachFile ? ( + <> + + { + const file = event.target.files?.[0]; + if (file) onAttachFile(file); + event.target.value = ""; + }} + /> + + ) : null} + + {onVoice ? ( + + ) : null}