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
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue