feat(tutor): the turn enforced — retries, evidence, the 다지기 checklist, earned progress
"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) <noreply@anthropic.com>
This commit is contained in:
27
test/helpers/dict-db.ts
Normal file
27
test/helpers/dict-db.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/* A migrated in-memory database with the shipped dictionary loaded — the
|
||||
same band files, through the same loader, as the app. */
|
||||
|
||||
import { vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { SqliteWasmDb } from "@app/db/sqlite-wasm-core.js";
|
||||
import { migrate } from "@app/db/migrate.js";
|
||||
import type { Db } from "@app/db/types.js";
|
||||
|
||||
const DICT = new URL("../../app/public/dict/", import.meta.url);
|
||||
|
||||
/** Serve app/public/dict/ to the loader's fetch. Undo with vi.unstubAllGlobals(). */
|
||||
export function serveDictionary(): void {
|
||||
vi.stubGlobal("fetch", async (input: string) => {
|
||||
const name = String(input).split("/dict/")[1] ?? "";
|
||||
return new Response(readFileSync(new URL(name, DICT)));
|
||||
});
|
||||
}
|
||||
|
||||
export async function dictionaryDb(upToBand = 1): Promise<Db> {
|
||||
serveDictionary();
|
||||
const db = await SqliteWasmDb.open({ memory: true });
|
||||
await migrate(db);
|
||||
const { ensureBands } = await import("@app/domain/dictionary.js");
|
||||
await ensureBands(db, upToBand);
|
||||
return db;
|
||||
}
|
||||
Reference in New Issue
Block a user