"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>
230 lines
7.3 KiB
TypeScript
230 lines
7.3 KiB
TypeScript
/* 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<number> {
|
|
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<Map<string, EvidenceRow>> {
|
|
const rows = words
|
|
? words.length
|
|
? await db.all<EvidenceRow>(
|
|
`SELECT * FROM evidence WHERE word IN (${words.map(() => "?").join(",")})`,
|
|
words,
|
|
)
|
|
: []
|
|
: await db.all<EvidenceRow>("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<number | null> {
|
|
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<Map<string, WordCard>> {
|
|
const out = new Map<string, WordCard>();
|
|
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<Card | null> {
|
|
const row = await db.get<Card>(
|
|
"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<string>,
|
|
today: number,
|
|
): Promise<ResultOutcome | null> {
|
|
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<string>();
|
|
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 };
|