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
|
|
@ -24,6 +24,17 @@ export type StreamChatInput = {
|
|||
provider?: string;
|
||||
messages: { role: "user" | "assistant"; content: string }[];
|
||||
context: (ChatSource & { text: string })[];
|
||||
/** "teach" runs a Teach it back turn; "teach_summary" asks for the recap JSON. */
|
||||
mode?: "teach" | "teach_summary";
|
||||
teach?: {
|
||||
topic: string;
|
||||
difficulty: "friendly" | "skeptical";
|
||||
/** Exchanges answered so far, and how many the session plans. */
|
||||
exchange: number;
|
||||
limit: number;
|
||||
/** "[n] question → answer" checklist lines (lib/teach.ts). */
|
||||
cards: string[];
|
||||
};
|
||||
signal?: AbortSignal;
|
||||
onToken: (text: string) => void;
|
||||
onThinking: (text: string) => void;
|
||||
|
|
@ -40,6 +51,8 @@ export async function streamChat(input: StreamChatInput): Promise<void> {
|
|||
model: input.model || undefined,
|
||||
messages: input.messages,
|
||||
context: input.context,
|
||||
mode: input.mode,
|
||||
teach: input.teach,
|
||||
}),
|
||||
signal: input.signal,
|
||||
});
|
||||
|
|
|
|||
96
lib/chat-models.ts
Normal file
96
lib/chat-models.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* The chat model list and the remembered choice, shared by the Chat screen and
|
||||
* Teach it back so both offer exactly the same models and remember the same
|
||||
* pick. The server's health check lists the Ollama models; every enabled cloud
|
||||
* provider contributes its own (live listing first, built-in seeds after).
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { readStoredChatModel, readStoredChatProvider, storeChatModel, storeChatProvider, type ChatModelChoice } from "./chat";
|
||||
import { PROVIDERS } from "./providers";
|
||||
import { modelsFor, useProviders } from "./providers-client";
|
||||
import { useHealth } from "./use-health";
|
||||
|
||||
export function useChatModels(): {
|
||||
/** Everything the picker may offer. */
|
||||
models: ChatModelChoice[];
|
||||
/** The model a request should use (falls back to the first available one). */
|
||||
model: string;
|
||||
/** The provider that owns `model` ("ollama" when none). */
|
||||
provider: string;
|
||||
/** Picks a model and remembers it across visits. */
|
||||
choose: (model: string, provider?: string) => void;
|
||||
/** Restores a saved conversation's choice without remembering it. */
|
||||
restore: (model?: string, provider?: string) => void;
|
||||
} {
|
||||
const { health } = useHealth();
|
||||
const providerState = useProviders();
|
||||
const [model, setModel] = useState("");
|
||||
const [provider, setProvider] = useState("ollama");
|
||||
|
||||
useEffect(() => {
|
||||
setModel(readStoredChatModel() ?? "");
|
||||
setProvider(readStoredChatProvider());
|
||||
}, []);
|
||||
|
||||
const models = useMemo<ChatModelChoice[]>(() => {
|
||||
const fromHealth = health?.chatModels ?? [];
|
||||
const list =
|
||||
fromHealth.length > 0
|
||||
? fromHealth
|
||||
: [
|
||||
{ name: health?.text?.configured ?? "", badge: "text" as const, available: health?.text?.available ?? null },
|
||||
{ name: health?.vision?.configured ?? "", badge: "vision" as const, available: health?.vision?.available ?? null },
|
||||
];
|
||||
const ollama = list.filter((entry) => entry.name);
|
||||
const cloud: ChatModelChoice[] = [];
|
||||
for (const info of PROVIDERS) {
|
||||
if (!providerState.keys[info.id]) continue;
|
||||
const models = modelsFor(providerState, info.id).slice(0, 400);
|
||||
if (models.length === 0) continue;
|
||||
// Only a live /models listing proves the key can use them; the built-in
|
||||
// seeds stay badge-less until the first successful test.
|
||||
const listed = (providerState.models[info.id]?.length ?? 0) > 0;
|
||||
for (const name of models) {
|
||||
cloud.push({ name, badge: "text", available: listed ? true : null, provider: info.id });
|
||||
}
|
||||
}
|
||||
return [...ollama, ...cloud];
|
||||
}, [health, providerState]);
|
||||
|
||||
const activeProvider = provider !== "ollama" && !providerState.keys[provider] ? "ollama" : provider;
|
||||
|
||||
const activeModel = useMemo(() => {
|
||||
if (model && models.some((entry) => entry.name === model && (entry.provider ?? "ollama") === activeProvider)) {
|
||||
return model;
|
||||
}
|
||||
// The server puts the default first, but a different server may not have
|
||||
// it — fall back to the first model that is actually available.
|
||||
const firstAvailable = models.find(
|
||||
(entry) => (entry.provider ?? "ollama") === activeProvider && entry.available !== false,
|
||||
);
|
||||
return (
|
||||
firstAvailable ??
|
||||
models.find((entry) => (entry.provider ?? "ollama") === activeProvider) ??
|
||||
models[0]
|
||||
)?.name ?? model;
|
||||
}, [model, models, activeProvider]);
|
||||
|
||||
const choose = useCallback((next: string, nextProvider?: string) => {
|
||||
const chosenProvider = nextProvider ?? "ollama";
|
||||
setProvider(chosenProvider);
|
||||
storeChatProvider(chosenProvider);
|
||||
setModel(next);
|
||||
storeChatModel(next);
|
||||
}, []);
|
||||
|
||||
const restore = useCallback((nextModel?: string, nextProvider?: string) => {
|
||||
if (nextModel) setModel(nextModel);
|
||||
if (nextProvider) setProvider(nextProvider);
|
||||
}, []);
|
||||
|
||||
return { models, model: activeModel, provider: activeProvider, choose, restore };
|
||||
}
|
||||
|
|
@ -226,6 +226,53 @@ export function recordAnswer(input: AnswerInput, at = Date.now()): void {
|
|||
}
|
||||
|
||||
/** Used by backup import and sync pulls. */
|
||||
/**
|
||||
* Puts specific cards on the review schedule, due right now — Teach it back's
|
||||
* "add the missed cards to review". A card that was already scheduled keeps
|
||||
* its ease and lapse count; only the due date is pulled forward. Returns how
|
||||
* many cards were scheduled.
|
||||
*/
|
||||
export function scheduleForReview(deckId: string, activityIds: string[], at = Date.now()): number {
|
||||
const changed: SrsRecord[] = [];
|
||||
for (const activityId of activityIds) {
|
||||
const key = `${deckId}:${activityId}`;
|
||||
const previous = schedules.get(key);
|
||||
changed.push({
|
||||
key,
|
||||
deckId,
|
||||
activityId,
|
||||
ease: previous?.ease ?? 2.5,
|
||||
reps: previous?.reps ?? 0,
|
||||
intervalDays: 0,
|
||||
lapses: previous?.lapses ?? 0,
|
||||
dueAt: at,
|
||||
lastAt: at,
|
||||
lastCorrect: previous?.lastCorrect ?? false,
|
||||
});
|
||||
}
|
||||
if (changed.length === 0) return 0;
|
||||
|
||||
schedules = new Map(schedules);
|
||||
for (const schedule of changed) schedules.set(schedule.key, schedule);
|
||||
notify();
|
||||
markSyncDirty();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await ensureLearningHydrated();
|
||||
await withDb("readwrite", (db) => {
|
||||
const transaction = db.transaction(SRS, "readwrite");
|
||||
const store = transaction.objectStore(SRS);
|
||||
for (const schedule of changed) store.put(schedule);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[freezmo] could not schedule the review cards", error);
|
||||
}
|
||||
})();
|
||||
|
||||
return changed.length;
|
||||
}
|
||||
|
||||
export async function replaceLearning(nextAnswers: AnswerRecord[], nextSrs: SrsRecord[]): Promise<void> {
|
||||
const sorted = [...nextAnswers].sort((a, b) => b.at - a.at).slice(0, MAX_ANSWERS);
|
||||
answers = sorted;
|
||||
|
|
|
|||
134
lib/i18n-dict.ts
134
lib/i18n-dict.ts
|
|
@ -1107,6 +1107,73 @@ const en: Record<string, string> = {
|
|||
"chat.savedToNotes": "Saved to {deck}'s notes",
|
||||
"chat.savedToNotesTrimmed": "Saved to {deck}'s notes (trimmed to fit)",
|
||||
"chat.savedFromChat": "Saved from chat",
|
||||
"chat.voiceStart": "Dictate",
|
||||
"chat.voiceStop": "Stop dictating",
|
||||
"teach.nav": "Teach it back",
|
||||
"teach.title": "Teach it back",
|
||||
"teach.subtitle": "You explain. The AI plays your student.",
|
||||
"teach.intro": "Explain the topic in your own words and the student asks short follow-up questions. Where it keeps digging, that is your gap.",
|
||||
"teach.topicLabel": "What will you explain?",
|
||||
"teach.topicPlaceholder": "e.g. Why the sky is blue",
|
||||
"teach.studentLabel": "What may the student know?",
|
||||
"teach.difficultyLabel": "Pick a student",
|
||||
"teach.friendly": "Friendly student",
|
||||
"teach.friendlyNote": "Patient questions, wants it to click.",
|
||||
"teach.skeptical": "Skeptical student",
|
||||
"teach.skepticalNote": "Asks why, looks for the catch and the edges.",
|
||||
"teach.start": "Start teaching",
|
||||
"teach.recent": "Recent sessions",
|
||||
"teach.noSessions": "Nothing here yet — finished sessions and their recaps appear here.",
|
||||
"teach.inProgress": "In progress",
|
||||
"teach.recap": "Recap",
|
||||
"teach.newSession": "New session",
|
||||
"teach.open": "Open",
|
||||
"teach.untitled": "Untitled session",
|
||||
"teach.deleteSession": "Delete session",
|
||||
"teach.deleted": "Session deleted",
|
||||
"teach.back": "Back",
|
||||
"teach.finish": "Finish",
|
||||
"teach.finishHint": "End the session and get your recap.",
|
||||
"teach.exchangeCount": "{n}/{max}",
|
||||
"teach.youExplain": "You explain",
|
||||
"teach.emptyTitle": "You are the teacher now",
|
||||
"teach.emptyHint": "Explain the topic the way you would tell a classmate. The student asks one short follow-up at a time — no grading, no lectures.",
|
||||
"teach.placeholder": "Explain it in your own words…",
|
||||
"teach.voiceStart": "Dictate your explanation",
|
||||
"teach.voiceStop": "Stop dictating",
|
||||
"teach.listening": "Listening…",
|
||||
"teach.limitReached": "Good place to wrap up — here is your recap.",
|
||||
"teach.summaryTitle": "Recap",
|
||||
"teach.summaryBusy": "Writing your recap…",
|
||||
"teach.summaryFailed": "The recap could not be written. Try again.",
|
||||
"teach.retry": "Write it again",
|
||||
"teach.well": "Explained well",
|
||||
"teach.wellEmpty": "Nothing stood out yet.",
|
||||
"teach.gaps": "Missed or unclear",
|
||||
"teach.gapsEmpty": "Nothing was missed.",
|
||||
"teach.next": "Next steps",
|
||||
"teach.cardLinkTitle": "Open in {deck}",
|
||||
"teach.addToReview": "Add missed cards to review",
|
||||
"teach.reviewAdded.one": "{n} card added to review",
|
||||
"teach.reviewAdded.few": "{n} cards added to review",
|
||||
"teach.reviewAdded.many": "{n} cards added to review",
|
||||
"teach.gapsToCards": "Turn gaps into cards",
|
||||
"teach.gapDeck": "Add the cards to",
|
||||
"teach.generate": "Create the cards",
|
||||
"teach.generating": "Creating the cards…",
|
||||
"teach.reviewTitle": "Review the cards",
|
||||
"teach.reviewHint": "Edit anything before saving; uncheck what you do not want.",
|
||||
"teach.cardFront": "Front",
|
||||
"teach.cardBack": "Back",
|
||||
"teach.saveCards.one": "Save {n} card",
|
||||
"teach.saveCards.few": "Save {n} cards",
|
||||
"teach.saveCards.many": "Save {n} cards",
|
||||
"teach.cardsSaved.one": "{n} card added to {deck}",
|
||||
"teach.cardsSaved.few": "{n} cards added to {deck}",
|
||||
"teach.cardsSaved.many": "{n} cards added to {deck}",
|
||||
"teach.generateFailed": "The cards could not be created.",
|
||||
"teach.noGapDeck": "Pick a deck with notes for the cards.",
|
||||
"teach.noDecksHint": "No decks are selected — the student has nothing to check against.",
|
||||
"legal.updated": "Last updated {when}",
|
||||
"legal.privacy": "Privacy policy",
|
||||
"legal.terms": "Terms of service",
|
||||
|
|
@ -2197,6 +2264,73 @@ const cs: Record<string, string> = {
|
|||
"chat.savedToNotes": "Uloženo do poznámek sady {deck}",
|
||||
"chat.savedToNotesTrimmed": "Uloženo do poznámek sady {deck} (zkráceno, aby se vešlo)",
|
||||
"chat.savedFromChat": "Uloženo z chatu",
|
||||
"chat.voiceStart": "Diktovat",
|
||||
"chat.voiceStop": "Zastavit diktování",
|
||||
"teach.nav": "Vysvětli to",
|
||||
"teach.title": "Vysvětli to",
|
||||
"teach.subtitle": "Ty vysvětluješ. AI hraje studenta.",
|
||||
"teach.intro": "Vysvětli téma vlastními slovy a student se bude krátce doptávat. Kde bude kopat hlouběji, tam máš mezeru.",
|
||||
"teach.topicLabel": "Co budeš vysvětlovat?",
|
||||
"teach.topicPlaceholder": "např. Proč je nebe modré",
|
||||
"teach.studentLabel": "Co smí student znát?",
|
||||
"teach.difficultyLabel": "Vyber si studenta",
|
||||
"teach.friendly": "Přátelský student",
|
||||
"teach.friendlyNote": "Trpělivé otázky, chce to pochopit.",
|
||||
"teach.skeptical": "Nedůvěřivý student",
|
||||
"teach.skepticalNote": "Ptá se proč, hledá háček a okrajové případy.",
|
||||
"teach.start": "Začít učit",
|
||||
"teach.recent": "Nedávné výuky",
|
||||
"teach.noSessions": "Zatím tu nic není — dokončené výuky a jejich přehledy se objeví tady.",
|
||||
"teach.inProgress": "Probíhá",
|
||||
"teach.recap": "Přehled",
|
||||
"teach.newSession": "Nová výuka",
|
||||
"teach.open": "Otevřít",
|
||||
"teach.untitled": "Výuka bez názvu",
|
||||
"teach.deleteSession": "Smazat výuku",
|
||||
"teach.deleted": "Výuka smazána",
|
||||
"teach.back": "Zpět",
|
||||
"teach.finish": "Dokončit",
|
||||
"teach.finishHint": "Ukončit výuku a zobrazit přehled.",
|
||||
"teach.exchangeCount": "{n}/{max}",
|
||||
"teach.youExplain": "Ty vysvětluješ",
|
||||
"teach.emptyTitle": "Teď učíš ty",
|
||||
"teach.emptyHint": "Vysvětli téma, jako bys ho vyprávěl spolužákovi. Student se vždy zeptá na jednu krátkou věc — žádné známkování, žádné přednášky.",
|
||||
"teach.placeholder": "Vysvětli to vlastními slovy…",
|
||||
"teach.voiceStart": "Nadiktovat vysvětlení",
|
||||
"teach.voiceStop": "Zastavit diktování",
|
||||
"teach.listening": "Poslouchám…",
|
||||
"teach.limitReached": "Tady je dobré místo skončit — tady je přehled.",
|
||||
"teach.summaryTitle": "Přehled",
|
||||
"teach.summaryBusy": "Píšu přehled…",
|
||||
"teach.summaryFailed": "Přehled se nepovedlo napsat. Zkus to znovu.",
|
||||
"teach.retry": "Napsat znovu",
|
||||
"teach.well": "Dobře vysvětlené",
|
||||
"teach.wellEmpty": "Zatím nic nevyniklo.",
|
||||
"teach.gaps": "Chybělo nebo bylo nejasné",
|
||||
"teach.gapsEmpty": "Nic nechybělo.",
|
||||
"teach.next": "Další kroky",
|
||||
"teach.cardLinkTitle": "Otevřít v sadě {deck}",
|
||||
"teach.addToReview": "Přidat vynechané kartičky k opakování",
|
||||
"teach.reviewAdded.one": "{n} kartička přidána k opakování",
|
||||
"teach.reviewAdded.few": "{n} kartičky přidány k opakování",
|
||||
"teach.reviewAdded.many": "{n} kartiček přidáno k opakování",
|
||||
"teach.gapsToCards": "Udělat z mezer kartičky",
|
||||
"teach.gapDeck": "Přidat kartičky do",
|
||||
"teach.generate": "Vytvořit kartičky",
|
||||
"teach.generating": "Vytvářím kartičky…",
|
||||
"teach.reviewTitle": "Zkontroluj kartičky",
|
||||
"teach.reviewHint": "Před uložením cokoli uprav; nechtěné odškrtni.",
|
||||
"teach.cardFront": "Přední strana",
|
||||
"teach.cardBack": "Zadní strana",
|
||||
"teach.saveCards.one": "Uložit {n} kartičku",
|
||||
"teach.saveCards.few": "Uložit {n} kartičky",
|
||||
"teach.saveCards.many": "Uložit {n} kartiček",
|
||||
"teach.cardsSaved.one": "{n} kartička přidána do sady {deck}",
|
||||
"teach.cardsSaved.few": "{n} kartičky přidány do sady {deck}",
|
||||
"teach.cardsSaved.many": "{n} kartiček přidáno do sady {deck}",
|
||||
"teach.generateFailed": "Kartičky se nepovedlo vytvořit.",
|
||||
"teach.noGapDeck": "Vyber sadu s poznámkami pro kartičky.",
|
||||
"teach.noDecksHint": "Nejsou vybrané žádné sady — student nemá proti čemu ověřovat.",
|
||||
"legal.updated": "Naposledy upraveno {when}",
|
||||
"legal.privacy": "Zásady soukromí",
|
||||
"legal.terms": "Podmínky použití",
|
||||
|
|
|
|||
139
lib/prompts.ts
139
lib/prompts.ts
|
|
@ -469,6 +469,145 @@ export function buildChatSystemPrompt(input: {
|
|||
return [CHAT_SYSTEM_PROMPT, "", "The student's notes (primary source):", "", blocks].join("\n");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Teach it back */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* The AI plays a curious classmate — never a teacher. Every student turn is a
|
||||
* short reaction plus exactly one follow-up question, and everything it knows
|
||||
* comes from the cards and notes attached below. This is the personality of
|
||||
* the whole mode: tune it here, not in the routes or the UI.
|
||||
*/
|
||||
export const TEACH_STUDENT_RULES = `ROLE (critical):
|
||||
- You are the STUDENT: a classmate of the person explaining, who is the teacher. You know nothing about the topic yet.
|
||||
- Every reply is one short natural reaction plus exactly ONE question. Never two questions, never a list, never an explanation.
|
||||
- Ask about one concrete thing at a time, the way a person actually asks: "wait, why does that step work?", "what happens if the value is negative?", "how do you know it's that one?". Keep the whole reply under ~40 words.
|
||||
- NEVER judge the explanation. No "correct", "right", "wrong", "exactly", "good", "not quite", no scores, no feedback words of any kind. If something seems off, sound genuinely unsure and ask ("hmm, I thought the other one did that — can you walk me through why?").
|
||||
- NEVER lecture and never explain the topic yourself. You may briefly say what you took from their words ("so the pressure drops first —") and then ask.
|
||||
- NEVER state facts of your own. The material below is everything you are allowed to know. If the teacher says something it does not cover, say plainly that you have nothing about it in the notes and ask them to say more — never guess, never agree with a fact you cannot check.
|
||||
- Write in the language the teacher writes in. Never switch languages.`;
|
||||
|
||||
export function buildTeachSystemPrompt(input: {
|
||||
topic: string;
|
||||
difficulty: "friendly" | "skeptical";
|
||||
/** "[n] prompt → answer" checklist lines (see lib/teach.ts). */
|
||||
cards: string[];
|
||||
context: { deckTitle: string; section: string; text: string }[];
|
||||
/** Exchanges the teacher has already answered, and how many are planned. */
|
||||
exchange: number;
|
||||
limit: number;
|
||||
}): string {
|
||||
const tone =
|
||||
input.difficulty === "friendly"
|
||||
? `YOUR CHARACTER: the friendly student. You like the topic, you are on the teacher's side and you want it to click; your questions are simple and patient. You are still not a pushover — if something is missing, you ask about it.`
|
||||
: `YOUR CHARACTER: the skeptical student. You are not hostile, but you are not convinced easily: you ask why, how they know, what happens at the edges and in special cases, and whether it still works when something changes. You want the explanation to hold up.`;
|
||||
const budget =
|
||||
input.limit > 0
|
||||
? `There are about ${input.limit} exchanges planned and ${input.exchange} answered so far. Spend the remaining ones on ideas from the cards that have not been covered yet, most important first.`
|
||||
: "";
|
||||
|
||||
const lines = [
|
||||
`You are rehearsing with a classmate who is teaching you about: ${input.topic}`,
|
||||
"",
|
||||
TEACH_STUDENT_RULES,
|
||||
"",
|
||||
tone,
|
||||
];
|
||||
if (budget) lines.push(budget);
|
||||
|
||||
lines.push("");
|
||||
if (input.cards.length > 0) {
|
||||
lines.push("THE CARDS the teacher is expected to be able to explain ([n] is their reference number):");
|
||||
lines.push(...input.cards);
|
||||
} else {
|
||||
lines.push(
|
||||
"NO CARDS were attached: you cannot check anything against the notes. Ask follow-up questions about the explanation itself, and say so when you cannot verify something.",
|
||||
);
|
||||
}
|
||||
|
||||
if (input.context.length > 0) {
|
||||
lines.push(
|
||||
"",
|
||||
"THE TEACHER'S NOTES (the only material the two of you share):",
|
||||
"",
|
||||
input.context
|
||||
.map((chunk) => `### ${chunk.deckTitle} — ${chunk.section}\n${chunk.text}`)
|
||||
.join("\n\n"),
|
||||
);
|
||||
} else {
|
||||
lines.push("", "NO NOTES were attached. Nothing about the topic may come from you.");
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"",
|
||||
"Now answer the teacher's latest message as this student. Plain conversational text only (no markdown, no lists): one short reaction and one question.",
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** The JSON shape the recap must follow — referenced by prompt and parser. */
|
||||
export const TEACH_SUMMARY_SHAPE = `{
|
||||
"well": ["what the teacher explained clearly, concretely — 1 to 4 bullets"],
|
||||
"gaps": [{ "what": "what was missed, vague or left out — up to 6 bullets", "card": 3 }],
|
||||
"next": ["one or two realistic next steps"]
|
||||
}`;
|
||||
|
||||
export const TEACH_SUMMARY_RULES = `You are still the same student, but the practice is over and you now write the app's recap of it. Nobody talks to you here; you describe the session.
|
||||
Ground rules:
|
||||
- Be concrete and kind, never a grader: no scores, no "correct"/"wrong", no praise words. "Vague", "not covered" and "missed" are fine.
|
||||
- The material below is the only truth. Never invent facts and never add outside knowledge.
|
||||
- Write in the language of the session (the language the teacher used).`;
|
||||
|
||||
export function buildTeachSummarySystemPrompt(input: {
|
||||
topic: string;
|
||||
difficulty: "friendly" | "skeptical";
|
||||
cards: string[];
|
||||
context: { deckTitle: string; section: string; text: string }[];
|
||||
}): string {
|
||||
const lines = [
|
||||
input.difficulty === "friendly"
|
||||
? `You (the friendly student) were just taught about: ${input.topic}`
|
||||
: `You (the skeptical student) were just taught about: ${input.topic}`,
|
||||
"",
|
||||
TEACH_SUMMARY_RULES,
|
||||
"",
|
||||
"Return JSON only, exactly this shape:",
|
||||
TEACH_SUMMARY_SHAPE,
|
||||
"",
|
||||
"Field rules:",
|
||||
`- "well": name what the teacher actually explained well, concretely ("why violet scatters most"), never general praise.`,
|
||||
`- "gaps": ideas from the cards that were never mentioned, ideas that stayed vague, and things their notes do not cover at all (say that plainly). "card" is the [n] reference of the card this gap belongs to, or null when no card covers it.`,
|
||||
`- "next": 1-2 actions the teacher can really do next, aimed at the gaps.`,
|
||||
];
|
||||
|
||||
if (input.cards.length > 0) {
|
||||
lines.push("", "THE CARDS ([n] is their reference number):", ...input.cards);
|
||||
} else {
|
||||
lines.push("", "NO CARDS were attached — judge only how clear the explanation itself was.");
|
||||
}
|
||||
|
||||
if (input.context.length > 0) {
|
||||
lines.push(
|
||||
"",
|
||||
"THE TEACHER'S NOTES (the only material you share):",
|
||||
"",
|
||||
input.context
|
||||
.map((chunk) => `### ${chunk.deckTitle} — ${chunk.section}\n${chunk.text}`)
|
||||
.join("\n\n"),
|
||||
);
|
||||
}
|
||||
|
||||
lines.push("", "Reply with the JSON object only. No code fences, no commentary.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Sent as the final user turn when the session ends. */
|
||||
export const TEACH_SUMMARY_USER_INSTRUCTION = `The practice is finished. Write the recap now — JSON only, in the language of the session.`;
|
||||
|
||||
/** One repair attempt when the recap did not parse as JSON. */
|
||||
export const TEACH_SUMMARY_REPAIR_INSTRUCTION = `Your previous reply was not valid JSON matching the required shape. Reply with the JSON object only — no code fences, no commentary, same content and language.`;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Retry helper */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
|
|||
|
|
@ -440,6 +440,48 @@ export const chatScopeSchema = z.discriminatedUnion("kind", [
|
|||
]);
|
||||
export type ChatScope = z.infer<typeof chatScopeSchema>;
|
||||
|
||||
/* Teach it back: sessions are chat conversations with a "teach" marker and
|
||||
the session's own record (which cards were checked, the recap). Kept inside
|
||||
the chat schema so history, backup and encrypted sync carry them for free. */
|
||||
|
||||
export const teachDifficultySchema = z.enum(["friendly", "skeptical"]);
|
||||
export type TeachDifficulty = z.infer<typeof teachDifficultySchema>;
|
||||
|
||||
/** One checklist card the student checks the explanation against. */
|
||||
export const teachCardRefSchema = z.object({
|
||||
ref: z.number().int().min(1).max(999),
|
||||
deckId: z.string().min(1).max(80),
|
||||
activityId: z.string().min(1).max(80),
|
||||
deckTitle: z.string().max(200),
|
||||
/** The prompt side of the card, for the recap's links. */
|
||||
label: z.string().max(300),
|
||||
});
|
||||
export type TeachCardRef = z.infer<typeof teachCardRefSchema>;
|
||||
|
||||
export const teachSummarySchema = z.object({
|
||||
well: z.array(z.string().max(800)).max(12),
|
||||
gaps: z
|
||||
.array(
|
||||
z.object({
|
||||
what: z.string().max(800),
|
||||
/** [n] reference into the session's cards, when one covers the gap. */
|
||||
ref: z.number().int().min(1).max(999).nullable().optional(),
|
||||
}),
|
||||
)
|
||||
.max(20),
|
||||
next: z.array(z.string().max(800)).max(6),
|
||||
});
|
||||
export type TeachSummary = z.infer<typeof teachSummarySchema>;
|
||||
|
||||
export const teachSessionSchema = z.object({
|
||||
difficulty: teachDifficultySchema,
|
||||
topic: z.string().max(200),
|
||||
scope: chatScopeSchema,
|
||||
cards: z.array(teachCardRefSchema).max(80),
|
||||
summary: teachSummarySchema.optional(),
|
||||
});
|
||||
export type TeachSession = z.infer<typeof teachSessionSchema>;
|
||||
|
||||
export const chatConversationSchema = z.object({
|
||||
id: z.string().min(1).max(80),
|
||||
title: z.string().max(200),
|
||||
|
|
@ -447,6 +489,9 @@ export const chatConversationSchema = z.object({
|
|||
scope: chatScopeSchema,
|
||||
model: z.string().max(120).optional(),
|
||||
provider: z.string().max(60).optional(),
|
||||
/** "chat" (or absent, for old records) / "teach". */
|
||||
mode: z.enum(["chat", "teach"]).optional(),
|
||||
teach: teachSessionSchema.optional(),
|
||||
createdAt: z.number(),
|
||||
updatedAt: z.number(),
|
||||
});
|
||||
|
|
|
|||
103
lib/speech-input.ts
Normal file
103
lib/speech-input.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Browser dictation for "teach it back" (and anything else that wants it):
|
||||
* the Web Speech API's recognition side. Chrome, Edge and some Safari builds
|
||||
* expose it as `SpeechRecognition` / `webkitSpeechRecognition`; other engines
|
||||
* do not, so the UI asks `speechInputAvailable()` first and simply hides the
|
||||
* mic when the answer is no.
|
||||
*
|
||||
* Nothing leaves the app ourselves: the audio goes to whatever recogniser the
|
||||
* browser uses, exactly like the system dictation key on a phone keyboard.
|
||||
* The API types are not in TypeScript's DOM lib, so the minimal shapes live
|
||||
* here rather than leaking `any`.
|
||||
*/
|
||||
|
||||
type RecognitionResultLike = { isFinal: boolean; 0: { transcript: string } };
|
||||
type RecognitionEventLike = { resultIndex: number; results: ArrayLike<RecognitionResultLike> };
|
||||
type RecognitionLike = {
|
||||
lang: string;
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
maxAlternatives: number;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
abort(): void;
|
||||
onresult: ((event: RecognitionEventLike) => void) | null;
|
||||
onerror: (() => void) | null;
|
||||
onend: (() => void) | null;
|
||||
};
|
||||
type RecognitionCtor = new () => RecognitionLike;
|
||||
|
||||
function recognitionCtor(): RecognitionCtor | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const scope = window as unknown as { SpeechRecognition?: RecognitionCtor; webkitSpeechRecognition?: RecognitionCtor };
|
||||
return scope.SpeechRecognition ?? scope.webkitSpeechRecognition ?? null;
|
||||
}
|
||||
|
||||
export function speechInputAvailable(): boolean {
|
||||
return recognitionCtor() !== null;
|
||||
}
|
||||
|
||||
export type SpeechInput = { stop: () => void };
|
||||
|
||||
/**
|
||||
* Starts listening. Final phrases arrive through `onText` (append them to the
|
||||
* draft); `onEnd` fires exactly once when listening stops, however it stops.
|
||||
* Returns null when the browser cannot listen or `start()` fails.
|
||||
*/
|
||||
export function startSpeechInput(options: {
|
||||
/** BCP-47 tag, usually `speechLocale(deck.language, getLocale())`. */
|
||||
lang: string;
|
||||
onText: (text: string) => void;
|
||||
onEnd: () => void;
|
||||
}): SpeechInput | null {
|
||||
const Ctor = recognitionCtor();
|
||||
if (!Ctor) return null;
|
||||
let recognition: RecognitionLike;
|
||||
try {
|
||||
recognition = new Ctor();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let finished = false;
|
||||
const finish = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
options.onEnd();
|
||||
};
|
||||
|
||||
recognition.lang = options.lang;
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = true;
|
||||
recognition.maxAlternatives = 1;
|
||||
recognition.onresult = (event) => {
|
||||
for (let index = event.resultIndex; index < event.results.length; index += 1) {
|
||||
const result = event.results[index];
|
||||
if (!result?.isFinal) continue;
|
||||
const transcript = result[0]?.transcript?.trim();
|
||||
if (transcript) options.onText(transcript);
|
||||
}
|
||||
};
|
||||
recognition.onerror = finish;
|
||||
recognition.onend = finish;
|
||||
|
||||
try {
|
||||
recognition.start();
|
||||
} catch {
|
||||
finish();
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
stop() {
|
||||
try {
|
||||
recognition.stop();
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
finish();
|
||||
},
|
||||
};
|
||||
}
|
||||
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);
|
||||
}
|
||||
28
lib/use-keyboard-inset.ts
Normal file
28
lib/use-keyboard-inset.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Keeps a chat-style composer above the on-screen keyboard. The visual
|
||||
* viewport tells us how much of the window the keyboard covers; the screen
|
||||
* reads it as bottom padding via `--chat-keyboard` (WebKit does not resize the
|
||||
* layout viewport, so `bottom: 0` alone slides under the keyboard there).
|
||||
*/
|
||||
export function useKeyboardInset(): void {
|
||||
useEffect(() => {
|
||||
const viewport = window.visualViewport;
|
||||
if (!viewport) return;
|
||||
const apply = () => {
|
||||
const inset = Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop);
|
||||
document.documentElement.style.setProperty("--chat-keyboard", `${Math.round(inset)}px`);
|
||||
};
|
||||
apply();
|
||||
viewport.addEventListener("resize", apply);
|
||||
viewport.addEventListener("scroll", apply);
|
||||
return () => {
|
||||
viewport.removeEventListener("resize", apply);
|
||||
viewport.removeEventListener("scroll", apply);
|
||||
document.documentElement.style.removeProperty("--chat-keyboard");
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue