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
361
components/teach/TeachRecap.tsx
Normal file
361
components/teach/TeachRecap.tsx
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { AiDisclaimer } from "@/components/AiDisclaimer";
|
||||
import { Button, Spinner } from "@/components/ui";
|
||||
import { RefreshIcon, SparklesIcon } from "@/components/icons";
|
||||
import { ApiError, generateDeck } from "@/lib/client-api";
|
||||
import { deckHasNotes, generatedDeckText } from "@/lib/chat-context";
|
||||
import { materialiseActivities, activityTypes } from "@/lib/deck-ops";
|
||||
import { scheduleForReview } from "@/lib/history";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { Deck, TeachCardRef, TeachDifficulty, TeachSummary } from "@/lib/schemas";
|
||||
import { upsertDeck } from "@/lib/storage";
|
||||
import { gapCardFrom, gapFocus } from "@/lib/teach";
|
||||
import { showToast } from "@/lib/toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ProposedCard = { id: string; front: string; back: string; include: boolean };
|
||||
|
||||
/**
|
||||
* The recap of a Teach it back session: what was explained well, what was
|
||||
* missed or unclear (linked to the user's own cards when one covers it), and
|
||||
* the next steps — plus the two follow-ups the recap feeds: putting the missed
|
||||
* cards on the review schedule, and turning the gaps into new flashcards
|
||||
* (generated from the deck's own notes, editable before saving).
|
||||
*/
|
||||
export function TeachRecap({
|
||||
topic,
|
||||
difficulty,
|
||||
cards,
|
||||
decks,
|
||||
summary,
|
||||
busy,
|
||||
error,
|
||||
onRetry,
|
||||
onBackToSession,
|
||||
}: {
|
||||
topic: string;
|
||||
difficulty: TeachDifficulty;
|
||||
cards: TeachCardRef[];
|
||||
decks: Deck[];
|
||||
summary: TeachSummary | null;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
onRetry: () => void;
|
||||
onBackToSession: () => void;
|
||||
}) {
|
||||
const { t, plural } = useI18n();
|
||||
|
||||
/* Cards the recap linked a gap to — the ones "add to review" schedules. */
|
||||
const linked = useMemo(() => {
|
||||
const refs = new Set((summary?.gaps ?? []).map((gap) => gap.ref).filter((ref): ref is number => ref != null));
|
||||
return cards.filter((card) => refs.has(card.ref));
|
||||
}, [summary, cards]);
|
||||
|
||||
const addToReview = () => {
|
||||
const byDeck = new Map<string, string[]>();
|
||||
for (const card of linked) {
|
||||
const list = byDeck.get(card.deckId) ?? [];
|
||||
list.push(card.activityId);
|
||||
byDeck.set(card.deckId, list);
|
||||
}
|
||||
let count = 0;
|
||||
for (const [deckId, activityIds] of byDeck) count += scheduleForReview(deckId, activityIds);
|
||||
showToast({ message: count > 0 ? plural(count, "teach.reviewAdded") : t("teach.gapsEmpty") });
|
||||
};
|
||||
|
||||
const defaultDeckId = linked[0]?.deckId ?? cards[0]?.deckId ?? decks[0]?.id ?? "";
|
||||
|
||||
return (
|
||||
<div className="chat-scroll relative min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
<div className="mx-auto w-full max-w-3xl space-y-4 px-4 py-4 lg:px-0 lg:py-6" data-testid="teach-recap">
|
||||
<p className="px-1 text-[12.5px] text-[var(--text-subtle)]">
|
||||
{topic} · {difficulty === "friendly" ? t("teach.friendly") : t("teach.skeptical")}
|
||||
</p>
|
||||
|
||||
{busy ? (
|
||||
<section className="card flex items-center gap-3 p-5" role="status">
|
||||
<Spinner className="size-5 text-accent-600 dark:text-accent-200" />
|
||||
<span className="text-[13.5px] text-[var(--text-muted)]">{t("teach.summaryBusy")}</span>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{!busy && error ? (
|
||||
<section className="card space-y-3 p-5">
|
||||
<p className="text-[13.5px] leading-relaxed text-rose-700 dark:text-rose-300">{error}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={onRetry}>
|
||||
<RefreshIcon className="size-4" /> {t("teach.retry")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onBackToSession}>
|
||||
{t("teach.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{!busy && summary ? (
|
||||
<>
|
||||
<section className="card space-y-5 p-4 sm:p-5">
|
||||
<RecapList
|
||||
title={t("teach.well")}
|
||||
tone="good"
|
||||
items={summary.well}
|
||||
empty={t("teach.wellEmpty")}
|
||||
/>
|
||||
<div>
|
||||
<h3 className="mb-2 text-[12px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
|
||||
{t("teach.gaps")}
|
||||
</h3>
|
||||
{summary.gaps.length === 0 ? (
|
||||
<p className="text-[13.5px] text-[var(--text-muted)]">{t("teach.gapsEmpty")}</p>
|
||||
) : (
|
||||
<ul className="space-y-2.5">
|
||||
{summary.gaps.map((gap, index) => {
|
||||
const card = gap.ref != null ? cards.find((entry) => entry.ref === gap.ref) : undefined;
|
||||
return (
|
||||
<li key={index} className="text-[13.5px] leading-relaxed text-[var(--text)]">
|
||||
<span>{gap.what}</span>
|
||||
{card ? (
|
||||
<Link
|
||||
href={`/deck/${card.deckId}`}
|
||||
title={t("teach.cardLinkTitle", { deck: card.deckTitle })}
|
||||
className="mt-1 flex w-fit max-w-full items-center gap-1.5 truncate rounded-[var(--radius-chip)] border border-[var(--glass-border)] bg-[var(--surface-strong)] px-2 py-1 text-[11.5px] text-[var(--text-muted)] transition hover:text-[var(--text)]"
|
||||
>
|
||||
<span className="shrink-0 font-semibold">{card.deckTitle}</span>
|
||||
<span className="truncate opacity-80">{card.label}</span>
|
||||
</Link>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<RecapList title={t("teach.next")} tone="next" items={summary.next} empty={t("teach.wellEmpty")} />
|
||||
</section>
|
||||
|
||||
<section className="flex flex-wrap gap-2">
|
||||
{linked.length > 0 ? (
|
||||
<Button variant="secondary" className="touch-target" onClick={addToReview} data-testid="teach-review">
|
||||
<RefreshIcon className="size-4" /> {t("teach.addToReview")}
|
||||
</Button>
|
||||
) : null}
|
||||
{summary.gaps.length > 0 ? (
|
||||
<GapCards topic={topic} gaps={summary.gaps} decks={decks} defaultDeckId={defaultDeckId} />
|
||||
) : null}
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecapList({ title, tone, items, empty }: { title: string; tone: "good" | "next"; items: string[]; empty: string }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 text-[12px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">{title}</h3>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-[13.5px] text-[var(--text-muted)]">{empty}</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{items.map((item, index) => (
|
||||
<li key={index} className="flex gap-2 text-[13.5px] leading-relaxed text-[var(--text)]">
|
||||
<span aria-hidden className={cn("mt-[7px] size-1.5 shrink-0 rounded-full", tone === "good" ? "bg-emerald-500" : "bg-accent-500")} />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Turn gaps into cards": one flashcard per gap, generated from the chosen
|
||||
* deck's own notes, reviewed and edited here before anything is saved.
|
||||
*/
|
||||
function GapCards({
|
||||
topic,
|
||||
gaps,
|
||||
decks,
|
||||
defaultDeckId,
|
||||
}: {
|
||||
topic: string;
|
||||
gaps: { what: string }[];
|
||||
decks: Deck[];
|
||||
defaultDeckId: string;
|
||||
}) {
|
||||
const { t, plural } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [deckId, setDeckId] = useState(defaultDeckId);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [generateError, setGenerateError] = useState<string | null>(null);
|
||||
const [proposed, setProposed] = useState<ProposedCard[] | null>(null);
|
||||
const [savedCount, setSavedCount] = useState<number | null>(null);
|
||||
|
||||
const deck = decks.find((entry) => entry.id === deckId);
|
||||
const chosen = (proposed ?? []).filter((card) => card.include && card.front.trim() && card.back.trim());
|
||||
|
||||
const patch = (id: string, change: Partial<ProposedCard>) =>
|
||||
setProposed((current) => current?.map((card) => (card.id === id ? { ...card, ...change } : card)) ?? null);
|
||||
|
||||
const generate = async () => {
|
||||
if (!deck || generating) return;
|
||||
setGenerating(true);
|
||||
setGenerateError(null);
|
||||
try {
|
||||
const { deck: generated } = await generateDeck({
|
||||
text: (deckHasNotes(deck) ? deck.sourceText : generatedDeckText(deck)).slice(0, 48_000),
|
||||
difficulty: deck.difficulty,
|
||||
count: Math.max(4, Math.min(8, gaps.length * 2)),
|
||||
focus: gapFocus(gaps),
|
||||
language: deck.language,
|
||||
mode: "more",
|
||||
existing: { title: deck.title, types: activityTypes(deck.activities), count: deck.activities.length },
|
||||
});
|
||||
const cards = generated.activities
|
||||
.map((activity, index) => {
|
||||
const pair = gapCardFrom(activity);
|
||||
return { id: `gap-${index}`, ...pair, include: true };
|
||||
})
|
||||
.filter((card) => card.front.length > 0 && card.back.length > 0);
|
||||
if (cards.length === 0) {
|
||||
setGenerateError(t("teach.generateFailed"));
|
||||
return;
|
||||
}
|
||||
setProposed(cards);
|
||||
} catch (caught) {
|
||||
setGenerateError(caught instanceof ApiError ? caught.message : t("teach.generateFailed"));
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
if (!deck || chosen.length === 0) return;
|
||||
const activities = materialiseActivities(
|
||||
chosen.map((card) => ({
|
||||
type: "flashcard" as const,
|
||||
topic: topic.slice(0, 120) || undefined,
|
||||
front: card.front.trim().slice(0, 400),
|
||||
back: card.back.trim().slice(0, 1200),
|
||||
explanation: gaps[0]?.what.slice(0, 600) ?? t("teach.gapsToCards"),
|
||||
})),
|
||||
deck.difficulty,
|
||||
);
|
||||
upsertDeck({ ...deck, activities: [...deck.activities, ...activities], updatedAt: Date.now() });
|
||||
setProposed(null);
|
||||
setSavedCount(activities.length);
|
||||
showToast({ message: plural(activities.length, "teach.cardsSaved", { deck: deck.title }) });
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="touch-target"
|
||||
onClick={() => setOpen(true)}
|
||||
data-testid="teach-gap-cards"
|
||||
>
|
||||
<SparklesIcon className="size-4" /> {t("teach.gapsToCards")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card w-full space-y-3 p-4 sm:p-5" data-testid="teach-gap-panel">
|
||||
<h3 className="text-[13.5px] font-semibold text-[var(--text)]">{t("teach.gapsToCards")}</h3>
|
||||
|
||||
{savedCount !== null ? (
|
||||
<p className="text-[13px] leading-relaxed text-emerald-700 dark:text-emerald-300">
|
||||
{plural(savedCount, "teach.cardsSaved", { deck: deck?.title ?? "" })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!proposed ? (
|
||||
<>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[12.5px] font-semibold text-[var(--text)]">{t("teach.gapDeck")}</span>
|
||||
<select
|
||||
value={deckId}
|
||||
onChange={(event) => setDeckId(event.target.value)}
|
||||
className="w-full rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3 py-2.5 text-[16px] text-[var(--text)] outline-none focus:border-accent-400/60 sm:text-[13.5px]"
|
||||
>
|
||||
{decks.map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{generateError ? (
|
||||
<p role="alert" className="text-[12.5px] leading-relaxed text-rose-700 dark:text-rose-300">
|
||||
{generateError}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" onClick={() => void generate()} loading={generating} disabled={generating || !deck}>
|
||||
{generating ? t("teach.generating") : t("teach.generate")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setOpen(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
<AiDisclaimer />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">{t("teach.reviewTitle")}</h4>
|
||||
<p className="text-[12px] leading-relaxed text-[var(--text-subtle)]">{t("teach.reviewHint")}</p>
|
||||
<ul className="space-y-2">
|
||||
{proposed.map((card) => (
|
||||
<li key={card.id} className="space-y-1.5 rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] p-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={card.include}
|
||||
onChange={(event) => patch(card.id, { include: event.target.checked })}
|
||||
aria-label={t("teach.reviewTitle")}
|
||||
className="size-4 accent-sky-500"
|
||||
/>
|
||||
<span className="flex-1 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
{t("teach.cardFront")}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
value={card.front}
|
||||
onChange={(event) => patch(card.id, { front: event.target.value })}
|
||||
aria-label={t("teach.cardFront")}
|
||||
maxLength={400}
|
||||
className="w-full rounded-[var(--radius-chip)] border border-[var(--glass-border)] bg-[var(--surface)] px-2.5 py-2 text-[16px] text-[var(--text)] outline-none focus:border-accent-400/60 sm:text-[13px]"
|
||||
/>
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">{t("teach.cardBack")}</span>
|
||||
<textarea
|
||||
value={card.back}
|
||||
onChange={(event) => patch(card.id, { back: event.target.value })}
|
||||
aria-label={t("teach.cardBack")}
|
||||
rows={2}
|
||||
maxLength={1200}
|
||||
className="w-full resize-none rounded-[var(--radius-chip)] border border-[var(--glass-border)] bg-[var(--surface)] px-2.5 py-2 text-[16px] text-[var(--text)] outline-none focus:border-accent-400/60 sm:text-[13px]"
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" onClick={save} disabled={chosen.length === 0} data-testid="teach-save-cards">
|
||||
{plural(chosen.length, "teach.saveCards")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setProposed(null)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue