From a1c86d9550b444606d122e50459b9989e1ef3bea Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 16 Sep 2026 21:09:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(tutor):=20the=20turn=20enforced=20?= =?UTF-8?q?=E2=80=94=20retries,=20evidence,=20the=20=EB=8B=A4=EC=A7=80?= =?UTF-8?q?=EA=B8=B0=20checklist,=20earned=20progress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "The client enforces; the prompt only explains." Every rule the artifact's tutor was merely asked to follow, it broke: it certified words on one correct answer, scored a unit before anything was answered, used a word from three phases ahead, answered in Korean, and invented spelling diagnoses. The reworked app fixed each by making the client refuse. This ports those refusals; domain/turn.ts holds the turn, testable without React. The gate. A reply is scanned before he sees it — the side of the exercise he must decode, through the one resolver, and its prose for Korean. A refused draft is never stored, shown or applied: the tutor is asked again and told exactly why. After two retries the reply is shown with its words flagged, and the next turn names them. (The artifact's follow-up told the tutor it could declare such a word in ::words; that contradicts the gate and is left out.) Marking. ::result feeds recall evidence per word. lib/srs.js is looser than PORT.md, so the call site tightens it: one outcome per word per round, and "learned" also needs five rounds between the first and last CORRECT answer — lib alone counted a wrong answer as the start of the span. A lookup is never recall. What he mistook a word for is kept. The schedule takes at most one good grade a day from marking; in the artifact five good rounds in one afternoon made a word "secure" by interval alone. Phase reviews. The client holds the 다지기 checklist — each unit's rule and every word the phase introduced, 132 items for Phase 1 — worked in batches of ten. ::confirmed ticks a rule on the tutor's word but a word only on evidence; "-item" puts one back; anything off the list is ignored. Progress is earned: ignored until the unit has an answer, +25 at most per message, a fall honoured in full, and the next unit only at 85% with three answers — plus, in a review, nothing open. advanceUnit() enforces it too, not only the banner. The prompt gains a per-round tail after the shipped prompt — the practice set (scored on the evidence, round-robin by word class, each word with the words one letter away), the checklist, retry notes — sent as a second, uncached system block so the stable prefix still caches. Also: recall answers carry the letter-level jamo comparison (kept out of his own bubble, since it is written to the model); match chips are keyed by pair index, the bug PORT.md names; and the stand-in tutor exercises every path offline — recall, ::result, ::confirmed, progress only after answers. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/db/writes.ts | 94 +++++++++++++ app/src/domain/evidence.ts | 229 ++++++++++++++++++++++++++++++ app/src/domain/ledger.ts | 162 +++++++++++++++++++++ app/src/domain/letters.ts | 51 +++++++ app/src/domain/practice.ts | 162 +++++++++++++++++++++ app/src/domain/progress.ts | 160 +++++++++++++++------ app/src/domain/prompt-tail.ts | 169 ++++++++++++++++++++++ app/src/domain/stub-tutor.ts | 61 ++++++-- app/src/domain/turn.ts | 189 +++++++++++++++++++++++++ app/src/domain/tutor-client.ts | 1 + app/src/state/store.tsx | 7 +- app/src/ui/tutor/RoadStrip.tsx | 84 ++++++++--- app/src/ui/tutor/TaskHost.tsx | 94 ++++++++----- app/src/ui/tutor/TutorTab.tsx | 207 +++++++++++++++++++-------- app/src/ui/tutor/tutor.css | 13 ++ server/src/backends/anthropic.ts | 10 +- server/src/backends/echo.ts | 1 + server/src/backends/openai.ts | 9 +- server/src/backends/types.ts | 7 +- server/src/tutor.ts | 2 + test/db/migration-8.test.ts | 8 +- test/domain/learner.test.ts | 235 +++++++++++++++++++++++++++++++ test/domain/turn.test.ts | 142 +++++++++++++++++++ test/helpers/dict-db.ts | 27 ++++ 24 files changed, 1941 insertions(+), 183 deletions(-) create mode 100644 app/src/domain/evidence.ts create mode 100644 app/src/domain/ledger.ts create mode 100644 app/src/domain/letters.ts create mode 100644 app/src/domain/practice.ts create mode 100644 app/src/domain/prompt-tail.ts create mode 100644 app/src/domain/turn.ts create mode 100644 test/domain/learner.test.ts create mode 100644 test/domain/turn.test.ts create mode 100644 test/helpers/dict-db.ts diff --git a/app/src/db/writes.ts b/app/src/db/writes.ts index 41f36d7..249acb3 100644 --- a/app/src/db/writes.ts +++ b/app/src/db/writes.ts @@ -265,6 +265,100 @@ export async function editPeek(db: Db, form: string): Promise { ); } +/* ═════════════════════════════════════════════════════════════════════ + THE LEARNER MODEL — what marking proves, not what the tutor asserts. + ═════════════════════════════════════════════════════════════════════ */ + +/** An exercise answered in a unit. Earned progress counts these. */ +export async function editUnitAnswer(db: Db, unitId: string): Promise { + await db.run( + `INSERT INTO progress (unit_id, answers, updated_at, dirty, rev) VALUES (?, 1, ?, 1, 1) + ON CONFLICT(unit_id) DO UPDATE SET answers = progress.answers + 1, + updated_at = excluded.updated_at, dirty = 1, rev = progress.rev + 1`, + [unitId, now()], + ); +} + +/** The tutor's read of a unit, after the client has clamped it. */ +export async function editUnitProgress( + db: Db, + unitId: string, + confidence: number, + note: string, +): Promise { + await db.run( + `INSERT INTO progress (unit_id, confidence, note, updated_at, dirty, rev) VALUES (?, ?, ?, ?, 1, 1) + ON CONFLICT(unit_id) DO UPDATE SET confidence = excluded.confidence, note = excluded.note, + updated_at = excluded.updated_at, dirty = 1, rev = progress.rev + 1`, + [unitId, Math.max(0, Math.min(100, Math.round(confidence))), note, now()], + ); +} + +export interface EvidenceRow { + word: string; + ok: number; + wrong: number; + lookups: number; + streak: number; + first_round: number; + last_round: number; + last_seen: number; + rounds: number; + first_ok_round: number; + last_ok_round: number; +} + +const EVIDENCE_COLS = [ + "ok", + "wrong", + "lookups", + "streak", + "first_round", + "last_round", + "last_seen", + "rounds", + "first_ok_round", + "last_ok_round", +] as const; + +/** One word's recall record, after a marked answer. */ +export async function editEvidence(db: Db, e: EvidenceRow): Promise { + await db.run( + `INSERT INTO evidence (word, ${EVIDENCE_COLS.join(", ")}, updated_at, dirty, rev) + VALUES (?, ${EVIDENCE_COLS.map(() => "?").join(", ")}, ?, 1, 1) + ON CONFLICT(word) DO UPDATE SET + ${EVIDENCE_COLS.map((c) => `${c} = excluded.${c}`).join(", ")}, + updated_at = excluded.updated_at, dirty = 1, rev = evidence.rev + 1`, + [e.word, ...EVIDENCE_COLS.map((c) => e[c]), now()], + ); +} + +/** What the tutor says he mistook a word for. */ +export async function editConfusion(db: Db, word: string, mistook: string): Promise { + await db.run( + `INSERT INTO confusion (word, mistook, updated_at, dirty, rev) VALUES (?, ?, ?, 1, 1) + ON CONFLICT(word) DO UPDATE SET mistook = excluded.mistook, + updated_at = excluded.updated_at, dirty = 1, rev = confusion.rev + 1`, + [word, mistook, now()], + ); +} + +/** A 다지기 checklist item proven — or put back, with confirmed = false. */ +export async function editLedger( + db: Db, + phase: number, + kind: "rule" | "word", + item: string, + confirmed: boolean, +): Promise { + await db.run( + `INSERT INTO phase_ledger (phase, kind, item, confirmed, updated_at, dirty, rev) VALUES (?, ?, ?, ?, ?, 1, 1) + ON CONFLICT(phase, kind, item) DO UPDATE SET confirmed = excluded.confirmed, + updated_at = excluded.updated_at, dirty = 1, rev = phase_ledger.rev + 1`, + [phase, kind, item, confirmed ? 1 : 0, now()], + ); +} + /* ═════════════════════════════════════════════════════════════════════ CUSTOM WORDS — the learner's own additions. diff --git a/app/src/domain/evidence.ts b/app/src/domain/evidence.ts new file mode 100644 index 0000000..aa16827 --- /dev/null +++ b/app/src/domain/evidence.ts @@ -0,0 +1,229 @@ +/* Recall evidence — whether he KNOWS a word, kept apart from when to show it. + + The artifact's tutor kept certifying words on a single correct answer, so + the client keeps the count and the tutor's marking only feeds it. What + counts as learned is PORT.md's bar: three corrects, in three separate + rounds, at least five rounds between the first correct and the last, none + of them after a lookup. + + lib/srs.js holds that model, a little looser than PORT.md words it: it + credits several corrects inside one round, and measures the span from + the first outcome of any kind. Both are tightened here, at the call site — + lib/ ships unchanged: + + · a message's marking is one round, and each word gets one outcome in + it (the first time it is listed); + · evidence keeps the rounds of its first and last CORRECT answer, and + learned requires lib's isLearned() AND that span. */ + +import type { Db } from "../db/types.js"; +import type { ResultRow } from "@lib/blocks.js"; +import { + AGAIN, + GOOD, + LEARNED_SPAN, + grade, + isLearned as libLearned, + markKnown, + newCard, + newEvidence, + noteOutcome, + type Card, + type Evidence, +} from "@lib/srs.js"; +import { + editCard, + editConfusion, + editEvidence, + editMeta, + type EvidenceRow, +} from "../db/writes.js"; + +export const ROUND_KEY = "learner.round"; + +/** Sources a marked word may grade a card from — the review deck's. */ +const REVIEWABLE = "('curated', 'grammar', 'sfx', 'curriculum', 'custom')"; + +export async function currentRound(db: Db): Promise { + const v = (await db.get<{ v: string }>("SELECT v FROM meta WHERE k = ?", [ROUND_KEY]))?.v; + const n = Number(v ?? 0); + return Number.isFinite(n) ? n : 0; +} + +export const emptyEvidence = (word: string): EvidenceRow => ({ + word, + ok: 0, + wrong: 0, + lookups: 0, + streak: 0, + first_round: 0, + last_round: 0, + last_seen: 0, + rounds: 0, + first_ok_round: 0, + last_ok_round: 0, +}); + +const toLib = (e: EvidenceRow): Evidence => ({ + ok: e.ok, + wrong: e.wrong, + lookups: e.lookups, + streak: e.streak, + firstRound: e.first_round, + lastRound: e.last_round, + lastSeen: e.last_seen, + rounds: e.rounds, +}); + +/** lib's bar, and the span between the first and last CORRECT answer. */ +export function isLearned(e: EvidenceRow): boolean { + return libLearned(toLib(e)) && e.first_ok_round > 0 && e.last_ok_round - e.first_ok_round >= LEARNED_SPAN; +} + +/** Evidence for these words, or for every word with any. */ +export async function readEvidence(db: Db, words?: string[]): Promise> { + const rows = words + ? words.length + ? await db.all( + `SELECT * FROM evidence WHERE word IN (${words.map(() => "?").join(",")})`, + words, + ) + : [] + : await db.all("SELECT * FROM evidence"); + return new Map(rows.map((r) => [r.word, r])); +} + +/** + * The card a word's marking grades: the curriculum's own card for a roadmap + * word, else the most curated reviewable lemma with that headword. + */ +export async function cardLemmaFor(db: Db, word: string): Promise { + const row = await db.get<{ id: number }>( + `SELECT id FROM lemma WHERE headword = ? AND source IN ${REVIEWABLE} + ORDER BY unit_id IS NULL, + CASE source WHEN 'curated' THEN 0 WHEN 'curriculum' THEN 1 WHEN 'grammar' THEN 2 + WHEN 'sfx' THEN 3 ELSE 4 END + LIMIT 1`, + [word], + ); + return row?.id ?? null; +} + +export interface WordCard { + lemmaId: number; + pos: string; + gloss: string; +} + +/** cardLemmaFor() for many words at once, with the lemma's pos and gloss. */ +export async function cardsFor(db: Db, words: string[]): Promise> { + const out = new Map(); + for (let i = 0; i < words.length; i += 900) { + const batch = words.slice(i, i + 900); + const rows = await db.all<{ id: number; headword: string; pos: string; gloss_en: string }>( + `SELECT id, headword, pos, gloss_en FROM lemma + WHERE headword IN (${batch.map(() => "?").join(",")}) AND source IN ${REVIEWABLE} + ORDER BY unit_id IS NULL, + CASE source WHEN 'curated' THEN 0 WHEN 'curriculum' THEN 1 WHEN 'grammar' THEN 2 + WHEN 'sfx' THEN 3 ELSE 4 END`, + batch, + ); + for (const r of rows) { + if (!out.has(r.headword)) out.set(r.headword, { lemmaId: r.id, pos: r.pos, gloss: r.gloss_en }); + } + } + return out; +} + +async function readCard(db: Db, lemmaId: number): Promise { + const row = await db.get( + "SELECT state, ease, interval, due, reps, lapses FROM card WHERE lemma_id = ?", + [lemmaId], + ); + return row ?? null; +} + +export interface ResultOutcome { + round: number; + /** Words whose evidence was updated. */ + recorded: string[]; + /** Items that named no studiable word — dropped, as the artifact does. */ + ignored: string[]; + /** Words that crossed the learned bar with this message. */ + learned: string[]; +} + +/** + * Apply one message's ::result block. + * + * `lookups` are the words he looked up while answering — a correct answer + * after a lookup is not recall: it resets the streak and earns nothing. + * The schedule takes the grade too, but a GOOD at most once per card per day: + * a card already graded today is no longer due, and in the artifact five + * good rounds in one afternoon made a word "secure" by interval alone. + */ +export async function applyResults( + db: Db, + results: ResultRow[] | null, + lookups: Iterable, + today: number, +): Promise { + if (!results?.length) return null; + + const round = (await currentRound(db)) + 1; + await editMeta(db, ROUND_KEY, String(round)); + + const looked = new Set(lookups); + const seen = new Set(); + const out: ResultOutcome = { round, recorded: [], ignored: [], learned: [] }; + + for (const r of results) { + const word = r.item.trim(); + if (!word || seen.has(word)) continue; // one outcome per word per round + seen.add(word); + + const lemmaId = await cardLemmaFor(db, word); + if (lemmaId === null) { + out.ignored.push(word); + continue; + } + + const before = (await readEvidence(db, [word])).get(word) ?? emptyEvidence(word); + const lookedUp = looked.has(word); + const noted = noteOutcome(toLib(before), r.ok ? "ok" : "wrong", round, lookedUp); + const recall = r.ok && !lookedUp; + + const after: EvidenceRow = { + word, + ok: noted.ok, + wrong: noted.wrong, + lookups: noted.lookups, + streak: noted.streak, + first_round: noted.firstRound, + last_round: noted.lastRound, + last_seen: noted.lastSeen, + rounds: noted.rounds, + first_ok_round: recall && !before.first_ok_round ? round : before.first_ok_round, + last_ok_round: recall ? round : before.last_ok_round, + }; + await editEvidence(db, after); + out.recorded.push(word); + if (!isLearned(before) && isLearned(after)) out.learned.push(word); + + if (!r.ok && r.mistakenFor) await editConfusion(db, word, r.mistakenFor); + + const card = await readCard(db, lemmaId); + if (recall) { + if (!card || card.due <= today) { + await editCard(db, lemmaId, isLearned(after) ? markKnown(today) : grade(card ?? newCard(), GOOD, today)); + } + } else if (!r.ok) { + await editCard(db, lemmaId, grade(card ?? newCard(), AGAIN, today)); + } + } + + return out; +} + +/** A fresh record, for callers that need lib's shape. */ +export { newEvidence }; diff --git a/app/src/domain/ledger.ts b/app/src/domain/ledger.ts new file mode 100644 index 0000000..9f5bfaf --- /dev/null +++ b/app/src/domain/ledger.ts @@ -0,0 +1,162 @@ +/* The phase review — a checklist the tutor cannot skip. + + Every phase ends with a 다지기 unit that introduces nothing. The CLIENT + holds the list — one entry per unit of the phase, named by its 한글 title, + plus every word the phase introduced — and the tutor ticks items off with + ::confirmed. Items not on the list are ignored, so the tutor cannot invent + progress; a word is ticked only on evidence; and the next phase is not + offered while anything is open, however high the confidence. + + A word already secure in review (interval ≥ 21 days) counts without being + re-drilled: existing evidence is still evidence. */ + +import type { Db } from "../db/types.js"; +import type { FlatUnit } from "@lib/gate.js"; +import { SECURE_INTERVAL } from "@lib/srs.js"; +import { editLedger } from "../db/writes.js"; +import { UNITS } from "./gate.js"; +import { cardLemmaFor, isLearned, readEvidence } from "./evidence.js"; + +/** Words are worked in batches of ten related words, in unit order. */ +export const BATCH = 10; + +export interface Checklist { + phase: number; + /** The phase's non-review units, by their 한글 names. */ + rules: string[]; + /** Every word those units introduced, once each, in order. */ + words: string[]; +} + +export function checklist(phase: number): Checklist { + const units = UNITS.filter((u) => u.phase === phase && !u.review); + return { + phase, + rules: units.map((u) => u.ko), + words: [...new Set(units.flatMap((u) => u.words ?? []))], + }; +} + +export const batches = (phase: number): string[][] => { + const words = checklist(phase).words; + const out: string[][] = []; + for (let i = 0; i < words.length; i += BATCH) out.push(words.slice(i, i + BATCH)); + return out; +}; + +/** Words he holds already: learned on evidence, or secure in review. */ +export async function secureWords(db: Db, words: string[]): Promise> { + const out = new Set(); + const evidence = await readEvidence(db, words); + for (const w of words) { + const e = evidence.get(w); + if (e && isLearned(e)) { + out.add(w); + continue; + } + const id = await cardLemmaFor(db, w); + if (id === null) continue; + const card = await db.get<{ state: number; interval: number }>( + "SELECT state, interval FROM card WHERE lemma_id = ?", + [id], + ); + if (card && card.state === 2 && card.interval >= SECURE_INTERVAL) out.add(w); + } + return out; +} + +export interface Coverage { + phase: number; + total: number; + done: number; + pct: number; + openRules: string[]; + openWords: string[]; +} + +export async function coverage(db: Db, phase: number): Promise { + const list = checklist(phase); + const ticked = await db.all<{ kind: string; item: string }>( + "SELECT kind, item FROM phase_ledger WHERE phase = ? AND confirmed = 1", + [phase], + ); + const rules = new Set(ticked.filter((t) => t.kind === "rule").map((t) => t.item)); + const words = new Set(ticked.filter((t) => t.kind === "word").map((t) => t.item)); + const secure = await secureWords(db, list.words.filter((w) => !words.has(w))); + + const openRules = list.rules.filter((r) => !rules.has(r)); + const openWords = list.words.filter((w) => !words.has(w) && !secure.has(w)); + const total = list.rules.length + list.words.length; + const done = total - openRules.length - openWords.length; + return { phase, total, done, pct: total ? Math.round((done * 100) / total) : 100, openRules, openWords }; +} + +export interface BatchState { + /** Zero-based; -1 when every batch is held. */ + index: number; + total: number; + /** The active batch's words not yet held. */ + open: string[]; + /** Every word of the batches before it — resampled for retention. */ + earlier: string[]; +} + +/** The first batch with anything not yet held is the one being worked. */ +export async function batchState(db: Db, phase: number): Promise { + const all = batches(phase); + const secure = await secureWords(db, all.flat()); + const index = all.findIndex((b) => b.some((w) => !secure.has(w))); + return { + index, + total: all.length, + open: index >= 0 ? all[index]!.filter((w) => !secure.has(w)) : [], + earlier: all.slice(0, index >= 0 ? index : all.length).flat(), + }; +} + +export interface Confirmation { + ticked: string[]; + reopened: string[]; + /** Words the tutor certified that the evidence does not support. */ + refused: string[]; +} + +/** + * Apply a ::confirmed block. Acts only inside a 다지기 unit, only on items on + * that phase's list, spelled exactly. A rule is ticked on the tutor's word; + * a word only when its evidence meets the bar. A leading "-" puts an item + * back, when he gets it wrong later. + */ +export async function applyConfirmed(db: Db, unit: FlatUnit, lines: string[] | null): Promise { + const out: Confirmation = { ticked: [], reopened: [], refused: [] }; + if (!unit.review || !lines?.length) return out; + + const list = checklist(unit.phase); + const rules = new Set(list.rules); + const words = new Set(list.words); + + for (const raw of lines) { + const line = raw.trim(); + const minus = line.startsWith("-"); + const item = minus ? line.slice(1).trim() : line; + const kind = rules.has(item) ? "rule" : words.has(item) ? "word" : null; + if (!kind) continue; + + if (minus) { + await editLedger(db, unit.phase, kind, item, false); + out.reopened.push(item); + } else if (kind === "rule") { + await editLedger(db, unit.phase, kind, item, true); + out.ticked.push(item); + } else { + const e = (await readEvidence(db, [item])).get(item); + if (e && isLearned(e)) { + await editLedger(db, unit.phase, kind, item, true); + out.ticked.push(item); + } else { + out.refused.push(item); + } + } + } + return out; +} diff --git a/app/src/domain/letters.ts b/app/src/domain/letters.ts new file mode 100644 index 0000000..712a16f --- /dev/null +++ b/app/src/domain/letters.ts @@ -0,0 +1,51 @@ +/* The letter-level check for a recall answer. + + A Hangul syllable reaches the model as one character, so it cannot see + the letters inside it — and asked which letter a student got wrong, it + invents a plausible answer. It told a student 빫다 was wrong in its ㄼ, + which was identical in both words; the slip was the initial ㅉ→ㅃ. + + So the app computes the comparison (lib/hangul.js letterCheck) and the + prompt forbids the tutor from working one out. A recall prompt is + English, and the spelling it expects comes from the same message's + ::words block — the artifact's own rule for finding it. */ + +import type { RecallTask, WordEntry } from "@lib/blocks.js"; +import { letterCheck, type LetterRow } from "@lib/hangul.js"; + +/** "The Sea (noun)" → "the sea" */ +const normal = (s: string): string => + s + .toLowerCase() + .replace(/\([^)]*\)/g, " ") + .replace(/[^a-z]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + +/** + * The Korean a recall prompt is asking for: the ::words entry whose meaning + * matches the prompt exactly, else one whose meaning contains it or is + * contained by it. "" when nothing matches — and then no check is sent, which + * the prompt tells the tutor how to handle. + */ +export function expectedFor(prompt: string, words: WordEntry[] | null): string { + const q = normal(prompt); + if (!q || !words?.length) return ""; + const exact = words.find((w) => normal(w.gloss) === q); + if (exact) return exact.ko; + const near = words.find((w) => { + const g = normal(w.gloss); + return g && (g.includes(q) || q.includes(g)); + }); + return near?.ko ?? ""; +} + +/** The LETTER-LEVEL CHECK block for a recall answer, or "". */ +export function recallLetterBlock(task: RecallTask, answers: string[], words: WordEntry[] | null): string { + const rows: LetterRow[] = task.items.map((item, i) => ({ + prompt: item.q, + expected: expectedFor(item.q, words), + written: (answers[i] ?? "").trim(), + })); + return letterCheck(rows); +} diff --git a/app/src/domain/practice.ts b/app/src/domain/practice.ts new file mode 100644 index 0000000..ad7cd34 --- /dev/null +++ b/app/src/domain/practice.ts @@ -0,0 +1,162 @@ +/* Which words to practise — chosen by the app, not the tutor. + + Left to itself the tutor used two of the five exercise types, swapped in + "fully secure words" to get a clean pass, and confirmed words it had + shown one line earlier. So the app picks the words, scored on the + evidence, and hands them over on EVERY unit: vocabulary is re-tested as + the course goes rather than saved up for the phase review. + + Plus minimal pairs: words one letter apart (바쁘다/나쁘다, 앉다/알다) are + named beside each practice word, so the tutor can put them side by side + and force actual reading rather than shape-recognition. */ + +import type { Db } from "../db/types.js"; +import type { FlatUnit, ProgressState } from "@lib/gate.js"; +import { decompose } from "@lib/hangul.js"; +import deckJson from "@data/deck.json"; +import type { EvidenceRow } from "../db/writes.js"; +import { UNITS } from "./gate.js"; +import { cardsFor, isLearned, readEvidence } from "./evidence.js"; + +/* ── minimal pairs ───────────────────────────────────────────────────── */ + +let pairs: Map | null = null; + +/** Every studiable Hangul word: the deck, then the roadmap, once each. */ +function vocabulary(): string[] { + const deck = Object.values((deckJson as unknown as { topics: Record }).topics) + .flat() + .map((row) => row[0] ?? ""); + return [...new Set([...deck, ...UNITS.flatMap((u) => u.words ?? [])])].filter((w) => /^[가-힣]+$/.test(w)); +} + +/** Two syllables are one letter apart when two of their three slots match. */ +function oneLetterApart(a: string, b: string): boolean { + const x = decompose(a); + const y = decompose(b); + if (!x || !y) return false; + return (x[0] === y[0] ? 1 : 0) + (x[1] === y[1] ? 1 : 0) + (x[2] === y[2] ? 1 : 0) >= 2; +} + +function buildPairs(): Map { + const index = new Map(); + const byLength = new Map(); + for (const w of vocabulary()) byLength.set(w.length, [...(byLength.get(w.length) ?? []), w]); + + for (const group of byLength.values()) { + for (let i = 0; i < group.length; i++) { + for (let j = i + 1; j < group.length; j++) { + const a = group[i]!; + const b = group[j]!; + let diff = -1; + let apart = true; + for (let k = 0; k < a.length; k++) { + if (a[k] === b[k]) continue; + if (diff !== -1) { + apart = false; + break; + } + diff = k; + } + if (!apart || diff === -1 || !oneLetterApart(a[diff]!, b[diff]!)) continue; + index.set(a, [...(index.get(a) ?? []), b]); + index.set(b, [...(index.get(b) ?? []), a]); + } + } + } + return index; +} + +/** Up to four words one letter away from this one. */ +export function confusable(word: string): string[] { + pairs ??= buildPairs(); + return [...new Set(pairs.get(word) ?? [])].filter((w) => w !== word).slice(0, 4); +} + +/* ── the practice set ────────────────────────────────────────────────── */ + +export interface PracticeWord { + ko: string; + en: string; + pos: string; + evidence: EvidenceRow | null; +} + +/** Word classes, so a round is never six nouns. */ +function bucketOf(pos: string): string { + if (pos === "verb" || pos === "form") return "verb"; + if (pos === "adj") return "adj"; + if (pos === "noun" || pos === "pron") return "noun"; + if (pos === "num" || pos === "counter") return "num"; + return "other"; +} + +/** + * The artifact's score: most often wrong or looked up first, then longest + * unseen, less what he gets right. A word never tested counts as overdue; a + * learned word sinks to the bottom rather than vanishing. + */ +function score(e: EvidenceRow | null, round: number): number { + if (e && isLearned(e)) return 1; + const ok = e?.ok ?? 0; + const since = e?.last_seen ? Math.min(30, round - e.last_seen) : 20; + return 40 * (e?.wrong ?? 0) + 25 * (e?.lookups ?? 0) + 3 * since - 12 * ok + (ok === 0 ? 30 : 0); +} + +/** Pick `n` words from `pool`, scored, then taken round-robin by class. */ +export async function practiceSet(db: Db, pool: string[], n: number, round: number): Promise { + const words = [...new Set(pool)]; + const cards = await cardsFor(db, words); + const evidence = await readEvidence(db, words); + + const scored = words + .filter((w) => cards.has(w)) + .map((w) => { + const card = cards.get(w)!; + const e = evidence.get(w) ?? null; + return { ko: w, en: card.gloss, pos: card.pos, evidence: e, score: score(e, round) }; + }) + .sort((a, b) => b.score - a.score); // stable: ties keep unit order + + const buckets = new Map(); + for (const w of scored) { + const b = bucketOf(w.pos); + buckets.set(b, [...(buckets.get(b) ?? []), w]); + } + const order = [...buckets.values()]; + const out: PracticeWord[] = []; + let i = 0; + while (out.length < n && order.length) { + const bucket = order[i % order.length]!; + const next = bucket.shift(); + if (next) { + out.push({ ko: next.ko, en: next.en, pos: next.pos, evidence: next.evidence }); + i++; + } + if (!bucket.length) order.splice(order.indexOf(bucket), 1); + } + return out; +} + +/** The words a unit draws practice from: earlier phases, and this phase so far. */ +export function practicePool(unit: FlatUnit, progress: ProgressState): string[] { + return [ + ...new Set( + UNITS.filter( + (u) => u.phase < unit.phase || (u.phase === unit.phase && (progress.done[u.id] || u.id === unit.id)), + ).flatMap((u) => u.words ?? []), + ), + ]; +} + +/** "닭 (chicken) [2✗, 1 looked up, 1✓, 3 rounds ago] — confusable with 달, 닥" */ +export function statLine(w: { ko: string; en: string; evidence: EvidenceRow | null }, round: number): string { + const e = w.evidence; + const bits: string[] = []; + if (e?.wrong) bits.push(`${e.wrong}✗`); + if (e?.lookups) bits.push(`${e.lookups} looked up`); + if (e?.ok) bits.push(`${e.ok}✓`); + bits.push(e?.last_seen ? `${round - e.last_seen} rounds ago` : "never tested"); + const c = confusable(w.ko); + return `${w.ko} (${w.en}) [${bits.join(", ")}]${c.length ? ` — confusable with ${c.join(", ")}` : ""}`; +} diff --git a/app/src/domain/progress.ts b/app/src/domain/progress.ts index 6a57d70..c0a4526 100644 --- a/app/src/domain/progress.ts +++ b/app/src/domain/progress.ts @@ -1,37 +1,43 @@ -/* Where the learner is on the roadmap. +/* Where the learner is on the roadmap — and progress that is earned. - The confidence number is the whole advancement mechanism: the tutor - reports it in a ::progress block, and at 85 the app offers the next unit. - The prompt forbids the tutor from offering advancement in prose — the app - owns that affordance. + The tutor reports a 0–100 read of the unit in a ::progress line. The + artifact stored it verbatim, and once scored 85% on the INTRODUCTION to a + unit, before a single exercise, carrying the previous unit's number across + — and the app offered to move on. A model's self-report is a signal, not + a measurement. So, as PORT.md requires: - ONE THING THE ARTIFACT DID NOT DO: clamp the per-turn delta. Confidence - was written straight from a model-emitted number, so a single hallucinated - `::progress 95` on turn two advanced the unit. The prompt asks for "a few - points, not thirty", but asking is not enforcing. applyProgressReport() - enforces it. */ + · a report is ignored until the unit has at least one answer; + · a rise is capped at +25 per message; a fall is honoured in full; + · the next unit is offered only at 85% AND three answers — and in a + 다지기 review, only once nothing on its checklist is open; + · a unit's score starts at zero. + + The prompt asks the tutor for all of this. The client enforces it. */ import type { Db } from "../db/types.js"; import { editCurrentUnit, + editUnitAnswer, editUnitConfidence, editUnitDone, + editUnitProgress, seedProgress, } from "../db/writes.js"; import { UNITS, unitIndex } from "./gate.js"; import type { FlatUnit, ProgressState } from "@lib/gate.js"; +import { coverage, type Coverage } from "./ledger.js"; /** Confidence at which the app offers the next unit. */ export const READY_AT = 85; +/** Answers a unit needs, as well as READY_AT, before the next is offered. */ +export const READY_MIN_ANSWERS = 3; + /** Where "not yet" parks it — below the threshold, so the banner goes away. */ export const NOT_YET = 70; -/** - * The most confidence a single turn may add. The tutor is told to move it - * "a few points, not thirty"; this is what makes that true. - */ -export const MAX_DELTA_PER_TURN = 12; +/** The most confidence a single message may add. */ +export const CONF_STEP = 25; export const FIRST_UNIT = UNITS[0]!.id; @@ -44,15 +50,26 @@ export interface ProgressRow { updated_at: number; } -export async function readProgress(db: Db): Promise { +/** lib/gate.js's ProgressState, plus what earned progress keeps per unit. */ +export interface Progress extends ProgressState { + confidence: Record; + answers: Record; + notes: Record; +} + +export async function readProgress(db: Db): Promise { const rows = await db.all("SELECT * FROM progress"); const done: Record = {}; const confidence: Record = {}; + const answers: Record = {}; + const notes: Record = {}; for (const r of rows) { if (r.done) done[r.unit_id] = true; confidence[r.unit_id] = r.confidence; + answers[r.unit_id] = r.answers; + notes[r.unit_id] = r.note; } // Where he is lives in one place — see migration 8. @@ -67,7 +84,7 @@ export async function readProgress(db: Db): Promise { current = last ? (UNITS[unitIndex(last.id) + 1]?.id ?? last.id) : FIRST_UNIT; } - return { current, done, confidence }; + return { current, done, confidence, answers, notes }; } /** First run. Seeded, so it must not carry a write timestamp. */ @@ -81,36 +98,95 @@ export const currentUnit = (p: ProgressState): FlatUnit => export const nextUnit = (p: ProgressState): FlatUnit | null => UNITS[unitIndex(p.current) + 1] ?? null; -export const isReady = (p: ProgressState): boolean => - (p.confidence?.[p.current] ?? 0) >= READY_AT && nextUnit(p) !== null; - /** - * Apply a ::progress report, clamped. Returns what was actually stored, so - * the caller can tell the difference between the tutor's claim and reality. + * Whether the next unit may be offered. `cover` is the current unit's + * checklist coverage, required when it is a 다지기 review. */ -export async function applyProgressReport( - db: Db, - progress: ProgressState, - reported: number, -): Promise<{ stored: number; clamped: boolean }> { - const unit = progress.current; - const before = progress.confidence?.[unit] ?? 0; - const asked = Math.max(0, Math.min(100, Math.round(reported))); - - // Downward corrections are always honoured — the tutor noticing he has - // NOT got it is information worth keeping. Only the climb is rate-limited. - const stored = asked <= before ? asked : Math.min(asked, before + MAX_DELTA_PER_TURN); - - await editUnitConfidence(db, unit, stored); - return { stored, clamped: stored !== asked }; +export function isReady(p: Progress, cover: Coverage | null = null): boolean { + const unit = currentUnit(p); + if (!nextUnit(p)) return false; + if ((p.confidence[unit.id] ?? 0) < READY_AT) return false; + if ((p.answers[unit.id] ?? 0) < READY_MIN_ANSWERS) return false; + if (unit.review && (!cover || cover.openRules.length + cover.openWords.length > 0)) return false; + return true; } -/** Move on: finish the current unit and make the next one current. */ -export async function advanceUnit(db: Db, progress: ProgressState): Promise { - const next = nextUnit(progress); - if (!next) return null; +/** The current unit's checklist coverage, when it is a review; else null. */ +export async function reviewCoverage(db: Db, p: ProgressState): Promise { + const unit = currentUnit(p); + return unit.review ? coverage(db, unit.phase) : null; +} + +/** + * Messages that are not an answer to anything. Everything else a learner + * types counts — a typed reply is an answer too. + */ +const NOT_AN_ANSWER = [ + /^Let's start unit /, + /^Let's work on unit /, + /^좋아 — I'm ready/, + /^I'm starting the roadmap again/, + /^Let's continue the lesson/, + /^Let's skip that one/, + /^새 문제/, + /^Explain that again/, + /^That was too hard/, + /^That was easy/, + /^Where am I making mistakes/, + /^Give me a /, + /^You used \S+ in that exercise/, +]; + +export function isExerciseAnswer(text: string): boolean { + const t = text.trim(); + if (!t) return false; + if (/^(My answers:|My written answers:|My pairings:|My sentences:|My choices:)/.test(t)) return true; + return !NOT_AN_ANSWER.some((re) => re.test(t)); +} + +/** Count what he just sent against the current unit, if it is an answer. */ +export async function noteAnswer(db: Db, p: ProgressState, text: string): Promise { + if (!isExerciseAnswer(text)) return false; + await editUnitAnswer(db, p.current); + return true; +} + +export interface ProgressReport { + stored: number; + /** The report was dropped: nothing in this unit had been answered yet. */ + ignored: boolean; + clamped: boolean; +} + +/** Apply a ::progress report, earned: see the head of this file. */ +export async function applyProgressReport( + db: Db, + p: Progress, + reported: number, + note = "", +): Promise { + const unit = p.current; + const before = p.confidence[unit] ?? 0; + if ((p.answers[unit] ?? 0) < 1) return { stored: before, ignored: true, clamped: false }; + + const asked = Math.max(0, Math.min(100, Math.round(reported))); + // A fall is honoured in full — the tutor noticing he has NOT got it is + // information worth keeping. Only the climb is rate-limited. + const stored = asked <= before ? asked : Math.min(asked, before + CONF_STEP); + + await editUnitProgress(db, unit, stored, note); + return { stored, ignored: false, clamped: stored !== asked }; +} + +/** + * Move on: finish the current unit and make the next one current — only if + * it has been earned. The banner is not the only thing that asks. + */ +export async function advanceUnit(db: Db, p: Progress): Promise { + const next = nextUnit(p); + if (!next || !isReady(p, await reviewCoverage(db, p))) return null; await db.tx(async (tx) => { - await editUnitDone(tx, progress.current); + await editUnitDone(tx, p.current); await editCurrentUnit(tx, next.id); }); return next.id; diff --git a/app/src/domain/prompt-tail.ts b/app/src/domain/prompt-tail.ts new file mode 100644 index 0000000..8c4e838 --- /dev/null +++ b/app/src/domain/prompt-tail.ts @@ -0,0 +1,169 @@ +/* The per-round part of the system prompt. + + prompt/tutor-system.md ships unchanged, with {{GATE}}, {{VARIETY}} and + {{FOCUS}} filled. Everything else the reworked app tells the tutor changes + from round to round — the words due for practice with their counts, the + 다지기 checklist, a note that the last draft was refused — and it travels + here, as a second system block after the shipped prompt. + + The text is the artifact's. Only its position differs, and for a reason: + a prefix that does not change is what Anthropic's prompt cache and a local + model's KV cache can reuse. Per-round lines inside the gate would change + the prefix on every turn. */ + +import type { Gate, GateFinding, ProgressState } from "@lib/gate.js"; +import { rejectionNote } from "@lib/gate.js"; +import { UNITS } from "./gate.js"; +import type { BatchState, Checklist, Coverage } from "./ledger.js"; +import { statLine, type PracticeWord } from "./practice.js"; + +export interface ReviewState { + coverage: Coverage; + list: Checklist; + batch: BatchState; + /** Earlier batches, resampled for retention. */ + back: PracticeWord[]; + /** The active batch's open words, with their evidence. */ + open: PracticeWord[]; +} + +export interface Retry { + findings: GateFinding[]; + korean: boolean; +} + +export interface TailInputs { + gate: Gate; + progress: ProgressState; + round: number; + practice: PracticeWord[]; + /** Recent confusions, oldest first. */ + mix: { word: string; mistook: string }[]; + review: ReviewState | null; + /** The draft just refused, when this is a retry. */ + retry: Retry | null; + /** Words flagged in the last reply that was shown anyway. */ + strays: string[]; +} + +function whereHeIs({ gate, progress }: TailInputs): string { + const done = UNITS.filter((u) => progress.done[u.id]); + const conf = progress.confidence?.[gate.unit.id] ?? 0; + return [ + "════ WHERE HE IS ════", + "THE ROADMAP — you own it. He is a beginner and should not have to choose what to study; you decide, and you tell him where he is when it helps. He has restarted from the beginning deliberately: assume NOTHING beyond what the gate above lists, whatever he may have picked up before.", + `Finished so far: ${done.length ? done.map((u) => u.id).join(", ") : "nothing yet"}.`, + gate.next ? `Next after this: ${gate.next.id} ${gate.next.ko} (${gate.next.name}).` : "This is the last unit.", + conf ? `Your last reading of his grasp of this unit was ${conf}%.` : "", + "The first scored message of a unit starts low — single digits or low teens — however well he did in the unit before.", + ] + .filter(Boolean) + .join("\n"); +} + +function reviewSection(t: TailInputs, r: ReviewState): string { + const { coverage: c, list, batch } = r; + const L: string[] = []; + L.push("════ THIS IS A 다지기 UNIT — A PHASE REVIEW ════"); + L.push( + `Nothing new is introduced here. Phase ${t.gate.unit.phase} is not finished until he has proven EVERY rule and EVERY word it taught, and the app — not you — keeps that list. It will not offer him the next phase while anything is still open, however well he is doing.`, + ); + L.push(""); + L.push(`CONFIRMED SO FAR: ${c.done} of ${c.total} (${c.pct}%).`); + L.push( + c.openRules.length + ? `RULES STILL OPEN (${c.openRules.length} of ${list.rules.length}): ${c.openRules.join(" · ")}` + : `RULES: all ${list.rules.length} confirmed.`, + ); + if (c.openWords.length) { + const show = c.openWords.slice(0, 60); + L.push( + `WORDS STILL OPEN (${c.openWords.length} of ${list.words.length}): ${show.join(" · ")}` + + (c.openWords.length > show.length ? ` …and ${c.openWords.length - show.length} more` : ""), + ); + } else { + L.push(`WORDS: all ${list.words.length} confirmed.`); + } + L.push(""); + if (batch.index >= 0) { + L.push(`BATCH ${batch.index + 1} OF ${batch.total} — work this batch until it is learned, then the next:`); + L.push(r.open.length ? r.open.map((w) => `• ${statLine(w, t.round)}`).join("\n") : "(this batch is done)"); + } else { + L.push(`BATCHES: all ${batch.total} are held.`); + } + if (r.back.length) { + L.push( + `AND BRING BACK FROM EARLIER BATCHES (retention — it must survive, not just pass once): ${r.back.map((w) => w.ko).join(" · ")}`, + ); + } + L.push(""); + L.push("Do not drill what is already confirmed unless he gets something wrong. A word he already holds is not on the open list at all."); + L.push(""); + L.push( + "TICKING ITEMS OFF — after marking his answers, list what he has just PROVEN, one per line, spelled exactly as it appears on the open list above:", + ); + L.push("::confirmed", "연음", "닭", "::"); + L.push( + "The app will REFUSE to confirm a word that has not yet met the evidence bar — three corrects, in three different rounds, at least five rounds apart. Listing it early does nothing. Keep testing it instead; the open list tells you what still counts.", + ); + L.push("If he gets a confirmed item wrong later, put it back with a leading minus: -닭."); + L.push("Never claim the phase is done in your prose. When the list empties the app offers him the next phase by itself."); + return L.join("\n"); +} + +function practiceSection(t: TailInputs): string { + if (!t.practice.length) return ""; + const L = [ + "════ VOCABULARY DUE — WORK THESE IN ════", + "The app picks these, not you: most often wrong or looked up first, then longest unseen. Build part of this round around them, whatever else the unit is about. Do not substitute easier words to get a clean pass.", + ...t.practice.map((w) => `• ${statLine(w, t.round)}`), + ]; + if (t.mix.length) { + L.push( + `HE HAS MIXED THESE UP BEFORE: ${t.mix.map((m) => `${m.word} ↔ ${m.mistook}`).join(" · ")} — put the pair side by side and make him choose.`, + ); + } + return L.join("\n"); +} + +function retrySections({ retry }: TailInputs): string { + if (!retry) return ""; + const L: string[] = []; + if (retry.korean) { + L.push( + "════ THAT MESSAGE WAS NOT DELIVERED — WRITE IT AGAIN ════", + "You wrote your explanation in Korean. He cannot read a Korean sentence yet; an explanation in Korean is not a harder lesson, it is no lesson at all. The app stopped that message and he never saw it.", + "Write the whole message again. Explain in ENGLISH. Korean appears only as material — the words and lines of an exercise, a form you are quoting, a unit name.", + ); + } + if (retry.findings.length) { + if (L.length) L.push(""); + L.push( + "════ THAT MESSAGE WAS NOT DELIVERED — WRITE IT AGAIN ════", + `Your exercise put ${retry.findings.length > 1 ? "words" : "a word"} in front of him that he is not allowed to meet yet, so the app stopped the message and he never saw it:`, + rejectionNote(retry.findings), + "Write the exercise again using only the VOCABULARY YOU MAY USE list above, plus this unit's own new words. A reading you are quoting (궁물 for 국물) stays fine as long as the word it comes from is allowed. If you think the roadmap genuinely needs one of these words earlier, say so in your prose — but build this exercise without it.", + ); + } + return L.join("\n"); +} + +/* The artifact's version of this told the tutor it could "declare it in a + ::words block" to make a stray word acceptable — the exact move the gate + refuses, and the prompt forbids. That alternative is left out. */ +function straysSection({ strays, retry }: TailInputs): string { + if (!strays.length || retry) return ""; + const many = strays.length > 1; + return [ + "════ YOU USED A WORD YOU HAD NOT INTRODUCED ════", + `Your last exercise contained ${strays.join(" · ")}. ${many ? "None of these are" : "That is not"} on his allowed-vocabulary list above, so he was left looking at ${many ? "words" : "a word"} he has never been taught.`, + "Fix it in this message: rebuild the exercise using only allowed words. If you think the roadmap genuinely needs one of them earlier, say so in your prose — declaring a word in a ::words block does not make it allowed.", + ].join("\n"); +} + +/** The second system block, for this round. */ +export function renderTail(t: TailInputs): string { + return [whereHeIs(t), t.review ? reviewSection(t, t.review) : "", practiceSection(t), retrySections(t), straysSection(t)] + .filter(Boolean) + .join("\n\n"); +} diff --git a/app/src/domain/stub-tutor.ts b/app/src/domain/stub-tutor.ts index ed82b4e..c19e971 100644 --- a/app/src/domain/stub-tutor.ts +++ b/app/src/domain/stub-tutor.ts @@ -10,11 +10,12 @@ onText receives CUMULATIVE text rather than deltas, and an aborted turn rejects with { code: "cancelled" } while keeping whatever it had streamed. - The stub is not a toy. It rotates all four exercise types, builds its + The stub is not a toy. It rotates all five exercise types, builds its ::words block from the current unit's real vocabulary looked up in the - database, emits a ::gloss from data/sentences.json, and climbs - ::progress a few points a turn — so every render path in the tutor tab, - including the 85% advancement banner, can be reached without a model. */ + database, emits a ::gloss from data/sentences.json, marks answers with + ::result, ticks a 다지기 checklist item with ::confirmed, and reports + ::progress only after an answer — so every path the client enforces, + including the advancement banner, can be reached without a model. */ import type { Gate } from "@lib/gate.js"; import sentencesJson from "@data/sentences.json"; @@ -41,7 +42,10 @@ export interface SampleOptions { * the server. */ export interface SampleRequest { + /** The shipped prompt, filled. Stable for a unit, so it caches. */ system: string; + /** This round's additions — practice set, checklist, retry notes. */ + systemTail?: string; messages: SampleMessage[]; } @@ -67,7 +71,7 @@ interface Sentences { } const SENTENCES = (sentencesJson as unknown as Sentences).sentences; -const TASK_ORDER = ["translate", "match", "build", "choice"] as const; +const TASK_ORDER = ["translate", "recall", "match", "build", "choice"] as const; export type StubTaskType = (typeof TASK_ORDER)[number]; /** A word the stub may use, with the gloss the rail will show. */ @@ -84,6 +88,8 @@ export interface StubContext { /** How many exercises have been answered in this unit so far. */ turn: number; confidence: number; + /** In a 다지기 unit, the checklist rules still open. */ + openRules?: string[]; } /* ── block builders ──────────────────────────────────────────────── */ @@ -111,6 +117,8 @@ function taskBlock(type: StubTaskType, words: StubWord[]): string { switch (type) { case "translate": return ["::task translate", ...pick.slice(0, 4).map((w) => w.ko), "::"].join("\n"); + case "recall": + return ["::task recall", ...pick.slice(0, 3).map((w) => w.gloss), "::"].join("\n"); case "match": return ["::task match", ...pick.map((w) => `${w.ko} | ${w.gloss}`), "::"].join("\n"); case "build": { @@ -129,10 +137,26 @@ function taskBlock(type: StubTaskType, words: StubWord[]): string { /* ── the reply ───────────────────────────────────────────────────── */ -function composeReply(ctx: StubContext): string { +/** The learner's message, if it answers an exercise — marking only follows an answer. */ +const ANSWER = /^(My answers:|My written answers:|My pairings:|My sentences:|My choices:)/; + +/* Marks what the learner answered with: every unit word his message + contains, all correct but the last, which is "mistaken" for its neighbour + — so the evidence, the schedule and the confusion list all move. */ +function resultBlock(words: StubWord[], answer: string): string { + const seen = words.filter((w) => answer.includes(w.ko)); + if (!seen.length) return ""; + const rows = seen.map((w, i) => + i === seen.length - 1 && seen.length > 1 ? `${w.ko} | wrong | ${seen[0]!.ko}` : `${w.ko} | ok`, + ); + return ["::result", ...rows, "::"].join("\n"); +} + +function composeReply(ctx: StubContext, lastMessage: string): string { const { gate, words, turn, confidence } = ctx; const type = TASK_ORDER[turn % TASK_ORDER.length]!; const opening = turn === 0; + const answered = ANSWER.test(lastMessage.trim()); const prose = opening ? [ @@ -162,12 +186,20 @@ function composeReply(ctx: StubContext): string { if (task) parts.push("", task); if (words.length) parts.push("", wordsBlock(words)); - // Climbs slowly, so the 85% banner is reachable but not on turn two. - const next = Math.min(100, confidence + (opening ? 4 : 9)); - parts.push( - "", - `::progress ${next} | ${next < 60 ? "still bedding in" : next < 85 ? "mostly there" : "ready to move on"}`, - ); + if (answered) { + const marks = resultBlock(words, lastMessage); + if (marks) parts.push("", marks); + const rule = ctx.openRules?.[0]; + if (gate.unit.review && rule) parts.push("", ["::confirmed", rule, "::"].join("\n")); + + // Climbs by the clamp's own step, so the banner is reachable in a few + // honest rounds — and never before an answer, which the client ignores. + const next = Math.min(100, confidence + 25); + parts.push( + "", + `::progress ${next} | ${next < 60 ? "still bedding in" : next < 85 ? "mostly there" : "ready to move on"}`, + ); + } return parts.join("\n"); } @@ -184,9 +216,10 @@ export interface StubOptions { export function makeStubTutor(context: () => StubContext, opts: StubOptions = {}): Sample { const delay = opts.chunkDelay ?? 18; - return async (_req, options = {}) => { + return async (req, options = {}) => { const { signal, onText } = options; - const full = composeReply(context()); + const last = req.messages[req.messages.length - 1]; + const full = composeReply(context(), last?.role === "user" ? last.content : ""); if (signal?.aborted) throw new SampleError("cancelled"); if (!onText || delay <= 0) return { text: full }; diff --git a/app/src/domain/turn.ts b/app/src/domain/turn.ts new file mode 100644 index 0000000..736ffd5 --- /dev/null +++ b/app/src/domain/turn.ts @@ -0,0 +1,189 @@ +/* One tutor turn, enforced. + + The client enforces; the prompt only explains. Every rule the tutor is + merely asked to follow, it eventually broke — so a reply is checked + before the student sees it: + + · the side of the exercise he must decode may use only allowed words + (lib/gate.js scanTask, through the one shared resolver); + · the explanation around it must be English (proseIsKorean). + + A refused reply is never stored, never shown and never applied: the + tutor is asked again, told why. After GATE_TRIES retries the reply is + shown anyway with its stray words flagged — a student stuck behind a + tutor that cannot satisfy a checker is worse than a bad word — and the + next turn names them. */ + +import type { Db } from "../db/types.js"; +import type { ParsedMessage } from "@lib/blocks.js"; +import type { Gate, GateFinding } from "@lib/gate.js"; +import { GATE_TRIES, proseIsKorean, scanTask, taskMaterial } from "@lib/gate.js"; +import { editMeta } from "../db/writes.js"; +import { allowedSet, assemblePrompt, courseScaffold, unitOf, type FocusMode } from "./gate.js"; +import { parseMessage } from "./gloss.js"; +import { headsFor } from "./resolver.js"; +import { renderTail, type Retry } from "./prompt-tail.js"; +import { practicePool, practiceSet } from "./practice.js"; +import { applyConfirmed, batchState, checklist, coverage, type Confirmation } from "./ledger.js"; +import { applyResults, currentRound, type ResultOutcome } from "./evidence.js"; +import { + applyProgressReport, + currentUnit, + readProgress, + type Progress, + type ProgressReport, +} from "./progress.js"; +import type { Sample, SampleMessage } from "./stub-tutor.js"; + +/** Characters of transcript sent per turn, newest first. */ +export const HISTORY_BUDGET = 40_000; + +const RECENT_KEY = "road.recent"; + +/** + * The newest turns that fit the budget. A transcript that now starts with + * the tutor gets a leading line from the learner: a conversation opens with + * him — the artifact's fix for its own app-written opening. + */ +export function trimHistory(turns: SampleMessage[], budget = HISTORY_BUDGET): SampleMessage[] { + const out: SampleMessage[] = []; + let used = 0; + for (let i = turns.length - 1; i >= 0; i--) { + const t = turns[i]!; + if (used + t.content.length > budget) break; + used += t.content.length; + out.unshift(t); + } + if (out[0]?.role === "assistant") out.unshift({ role: "user", content: "Let's continue the lesson." }); + return out; +} + +/** The second system block for this round. */ +export async function buildTail( + db: Db, + gate: Gate, + progress: Progress, + strays: string[], + retry: Retry | null, +): Promise { + const round = await currentRound(db); + const practice = await practiceSet(db, practicePool(gate.unit, progress), 10, round); + const mix = ( + await db.all<{ word: string; mistook: string }>( + "SELECT word, mistook FROM confusion ORDER BY updated_at DESC LIMIT 6", + ) + ).reverse(); + + let review = null; + if (gate.unit.review) { + const phase = gate.unit.phase; + const batch = await batchState(db, phase); + review = { + coverage: await coverage(db, phase), + list: checklist(phase), + batch, + open: batch.open.length ? await practiceSet(db, batch.open, batch.open.length, round) : [], + back: batch.earlier.length ? await practiceSet(db, batch.earlier, 4, round) : [], + }; + } + + return renderTail({ gate, progress, round, practice, mix, review, retry, strays }); +} + +export interface TurnRequest { + db: Db; + sample: Sample; + template: string; + gate: Gate; + progress: Progress; + focus: FocusMode; + recent: string[]; + history: SampleMessage[]; + message: string; + /** Words flagged in the last reply that was shown anyway. */ + strays: string[]; + signal?: AbortSignal; + onText?: (text: string) => void; + /** A draft was refused and the tutor is being asked again. */ + onRetry?: (retry: Retry) => void; +} + +export interface TurnResult { + text: string; + parsed: ParsedMessage; + /** What is still wrong with the reply that is shown — empty when it passed. */ + findings: GateFinding[]; + korean: boolean; + retries: number; +} + +export async function runTurn(req: TurnRequest): Promise { + const system = assemblePrompt({ template: req.template, gate: req.gate, recent: req.recent, focus: req.focus }); + const allowed = allowedSet(req.gate); + const scaffold = courseScaffold(); + const messages: SampleMessage[] = [...trimHistory(req.history), { role: "user", content: req.message }]; + + let retry: Retry | null = null; + for (let tries = 0; ; tries++) { + const systemTail = await buildTail(req.db, req.gate, req.progress, req.strays, retry); + const { text } = await req.sample( + { system, systemTail, messages }, + { signal: req.signal, onText: req.onText ? (u) => req.onText!(u.text) : undefined }, + ); + + const parsed = parseMessage(text); + const tokens = taskMaterial(parsed.task).join(" ").match(/[가-힣]+/g) ?? []; + const heads = await headsFor(req.db, tokens); + const findings = scanTask(parsed, { allowed, scaffold, heads, unitOf }).slice(0, 10); + const korean = proseIsKorean(text); + + if ((!findings.length && !korean) || tries >= GATE_TRIES) { + return { text, parsed, findings, korean, retries: tries }; + } + retry = { findings, korean }; + req.onRetry?.(retry); + } +} + +export async function readRecent(db: Db): Promise { + const v = (await db.get<{ v: string }>("SELECT v FROM meta WHERE k = ?", [RECENT_KEY]))?.v; + try { + const parsed = JSON.parse(v ?? "[]"); + return Array.isArray(parsed) ? parsed.map(String) : []; + } catch { + return []; + } +} + +export interface Applied { + progress: ProgressReport | null; + results: ResultOutcome | null; + confirmation: Confirmation; + recent: string[]; +} + +/** + * Apply an accepted reply's blocks, in the artifact's order: progress, then + * marking, then the checklist, then the exercise type for {{VARIETY}}. + * Progress is re-read first — the answer that prompted this reply has just + * been counted, and the clamp depends on that count. + */ +export async function applyReply( + db: Db, + parsed: ParsedMessage, + { lookups, today }: { lookups: Iterable; today: number }, +): Promise { + const progress = await readProgress(db); + const report = parsed.progress + ? await applyProgressReport(db, progress, parsed.progress.score, parsed.progress.note) + : null; + const results = await applyResults(db, parsed.results, lookups, today); + const confirmation = await applyConfirmed(db, currentUnit(progress), parsed.confirmed); + + let recent = await readRecent(db); + if (parsed.task) { + recent = [...recent, parsed.task.type].slice(-6); + await editMeta(db, RECENT_KEY, JSON.stringify(recent)); + } + return { progress: report, results, confirmation, recent }; +} diff --git a/app/src/domain/tutor-client.ts b/app/src/domain/tutor-client.ts index b16eb7e..a21e21e 100644 --- a/app/src/domain/tutor-client.ts +++ b/app/src/domain/tutor-client.ts @@ -90,6 +90,7 @@ export function makeRemoteTutor(endpoint: TutorEndpoint): Sample { }, body: JSON.stringify({ system: req.system, + systemTail: req.systemTail ?? "", history, message: last?.content ?? "", }), diff --git a/app/src/state/store.tsx b/app/src/state/store.tsx index 507087d..2323873 100644 --- a/app/src/state/store.tsx +++ b/app/src/state/store.tsx @@ -23,7 +23,7 @@ import { dayNumber } from "@lib/srs.js"; import { bandForUnit } from "@shared/bands.mjs"; import { ensureBands, loadManifest, recordProvenance, type DictManifest } from "../domain/dictionary.js"; -import { initProgress, readProgress } from "../domain/progress.js"; +import { initProgress, readProgress, type Progress } from "../domain/progress.js"; import { clearServerConfig, readServerConfig, @@ -32,7 +32,6 @@ import { } from "../domain/server-config.js"; import { forgetServerPosition, trySync, type SyncResult } from "../sync/client.js"; import { unitIndex } from "../domain/gate.js"; -import type { ProgressState } from "@lib/gate.js"; import type { FocusMode } from "../domain/gate.js"; /* ── preferences ─────────────────────────────────────────────────── */ @@ -83,7 +82,7 @@ export interface Store { dbInfo: DbInfo; manifest: DictManifest | null; - progress: ProgressState; + progress: Progress; /** Re-read progress from the database after a write. */ refreshProgress: () => Promise; @@ -140,7 +139,7 @@ export function StoreProvider({ // assembled below from React state. type Core = Pick; const [store, setStore] = useState(null); - const [progress, setProgress] = useState(null); + const [progress, setProgress] = useState(null); const [prefs, setPrefs] = useState(DEFAULT_PREFS); const [revision, setRevision] = useState(0); const [today, setToday] = useState(() => dayNumber()); diff --git a/app/src/ui/tutor/RoadStrip.tsx b/app/src/ui/tutor/RoadStrip.tsx index b583942..3729632 100644 --- a/app/src/ui/tutor/RoadStrip.tsx +++ b/app/src/ui/tutor/RoadStrip.tsx @@ -2,12 +2,17 @@ The prompt forbids 선생님 from offering advancement in prose — the app owns that affordance, and this is it. The bar shows the confidence the - tutor reported; at 85 the banner appears; "not yet" parks it below the - threshold rather than arguing with the model. */ + tutor reported, earned and clamped; the banner appears only when the + unit is ready by the client's own rules (see domain/progress.ts); "not + yet" parks it below the threshold rather than arguing with the model. -import { useState } from "react"; + In a 다지기 review the bar is the checklist instead: rules and words + confirmed out of all the phase introduced. */ + +import { useEffect, useState } from "react"; import { useStore } from "../../state/store.js"; import { curriculum } from "../../domain/gate.js"; +import type { Coverage } from "../../domain/ledger.js"; import { READY_AT, advanceUnit, @@ -15,23 +20,38 @@ import { isReady, nextUnit, goToUnit, + reviewCoverage, stayOnUnit, } from "../../domain/progress.js"; import "./road.css"; -export function RoadStrip({ onUnitChange }: { onUnitChange: (unitId: string) => void }) { - const { db, progress, refreshProgress } = useStore(); +export type UnitChange = "advance" | "jump"; + +export function RoadStrip({ onUnitChange }: { onUnitChange: (unitId: string, how: UnitChange) => void }) { + const { db, progress, refreshProgress, revision } = useStore(); const [open, setOpen] = useState(false); + const [cover, setCover] = useState(null); const unit = currentUnit(progress); const next = nextUnit(progress); - const confidence = progress.confidence?.[unit.id] ?? 0; - const ready = isReady(progress); + const confidence = progress.confidence[unit.id] ?? 0; + const note = progress.notes[unit.id] ?? ""; + const ready = isReady(progress, cover); + + useEffect(() => { + let cancelled = false; + void reviewCoverage(db, progress).then((c) => { + if (!cancelled) setCover(c); + }); + return () => { + cancelled = true; + }; + }, [db, progress, revision]); const move = async () => { const id = await advanceUnit(db, progress); await refreshProgress(); - if (id) onUnitChange(id); + if (id) onUnitChange(id, "advance"); }; const stay = async () => { @@ -43,9 +63,11 @@ export function RoadStrip({ onUnitChange }: { onUnitChange: (unitId: string) => await goToUnit(db, progress, id); await refreshProgress(); setOpen(false); - onUnitChange(id); + onUnitChange(id, "jump"); }; + const review = unit.review && cover; + return (
@@ -57,10 +79,27 @@ export function RoadStrip({ onUnitChange }: { onUnitChange: (unitId: string) => {unit.name}
-
- -
- {confidence}% + {review ? ( + <> +
+ +
+ + {cover.done} / {cover.total} + + + ) : ( + <> +
+ +
+ {confidence}% + + )}
{phase.units.map((u) => { const state = progress.done[u.id] ? "done" : u.id === unit.id ? "now" : "todo"; - const conf = progress.confidence?.[u.id] ?? 0; + const conf = progress.confidence[u.id] ?? 0; return ( ))}
- {right.map((gloss) => ( + {right.map((i) => ( ))}
@@ -210,10 +222,10 @@ function Match({ task, turnId, done, setDone, selected, setSelected, disabled }: {done.length > 0 && (
{done.map((p, i) => ( - - {p.ko} = {p.gloss} + + {task.pairs[p.left]!.ko} = {task.pairs[p.right]!.gloss}