"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[] }; 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); }