The artifact was reworked after real incidents: a week of lost data, a
student taught out of order, and spelling diagnoses the model invented.
This takes the new export in verbatim; the port catches up in the
commits that follow.
Copied byte-identical from the bundle:
lib/ lexicon.js and sync.js are new; gate.js gains enforcement,
hangul.js letter-level marking, srs.js recall evidence,
conjugation.js deconjugate(); blocks.js now takes the last
block, closes gloss at "=", and parses recall, ::result and
::confirmed
data/ curriculum.json v5 — six 다지기 phase reviews; the 371
roadmap words are unchanged and no band moves
prompt/ English-only rule, recall, LETTER-LEVEL CHECK, marking
audit-gate.mjs, run-checks.sh, fixtures/ — the word gate measured
against 54 real tutor messages
CI runs run-checks.sh in place of validate.mjs alone, and `npm run check`
gains the audit. Baselines: validate PASS 0/0; audit 7 of 41 and 2 of 13.
types/lib/ declares the new API, and test/lib/ pins it: letterCheck on
the prompt's own 짧다/빫다 case, deconjugation, the roadmap-first order
that keeps 마셔 out of Phase 1, sync's three gates, and recall evidence —
including the two ways lib's evidence is looser than PORT.md, pinned as
they are so the call site that tightens them is visibly needed.
TaskHost gains a plain recall renderer so the tree typechecks against the
wider Task union.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
205 lines
12 KiB
JavaScript
205 lines
12 KiB
JavaScript
/* The gate — what the tutor is allowed to know, say and use, right now.
|
|
Generated from the curriculum plus progress. This is the mechanism that
|
|
stops material being taught out of order; port it before improving it. */
|
|
|
|
export function flatten(curriculum) {
|
|
const units = [];
|
|
curriculum.phases.forEach(p => p.units.forEach(u => units.push({ ...u, phase: p.phase, phaseKo: p.ko, phaseName: p.name })));
|
|
return units;
|
|
}
|
|
|
|
/**
|
|
* @param curriculum curriculum.json
|
|
* @param progress { current: "1.4", done: {"1.1":true,...}, confidence: {"1.4":62} }
|
|
* @param opts { vocabQuery } — with a dictionary, replaces the hand-listed words
|
|
*/
|
|
export function buildGate(curriculum, progress, opts = {}) {
|
|
const units = flatten(curriculum);
|
|
const at = id => units.findIndex(u => u.id === id);
|
|
const i = Math.max(0, at(progress.current));
|
|
const unit = units[i];
|
|
const done = units.filter(u => progress.done[u.id]);
|
|
|
|
const taught = done.flatMap(u => u.teaches);
|
|
|
|
// everything a later unit teaches is, by construction, forbidden now
|
|
const future = units.filter((u, k) => k > i || (!progress.done[u.id] && k !== i));
|
|
const near = [...new Set(future.flatMap(u => u.teaches))].slice(0, 18);
|
|
const tail = future.length ? future[future.length - 1] : null;
|
|
|
|
const vocabulary = opts.vocabQuery
|
|
? opts.vocabQuery(unit, done) // e.g. freq_rank BETWEEN …
|
|
: [...new Set(done.flatMap(u => u.words))];
|
|
|
|
// spiral targets: words first met in an earlier unit that this unit should
|
|
// deliberately bring back. Only those whose home unit is actually finished.
|
|
const doneIds = new Set(done.map(u => u.id));
|
|
const revisits = (unit.revisits || []).filter(r => doneIds.has(r.from)).map(r => r.word);
|
|
|
|
return {
|
|
unit, phase: { n: unit.phase, ko: unit.phaseKo, name: unit.phaseName },
|
|
taught, forbidden: { near, tailUnit: tail, count: future.length },
|
|
vocabulary, newWords: unit.words, revisits,
|
|
confidence: progress.confidence?.[unit.id] ?? null,
|
|
next: units[i + 1] || null,
|
|
finished: done.map(u => u.id),
|
|
};
|
|
}
|
|
|
|
/** Render the gate into the system prompt section. Keep the headings — the
|
|
model keys off them, and the pre-flight check refers to them by name. */
|
|
export function renderGate(g) {
|
|
const L = [];
|
|
L.push(`He is on PHASE ${g.phase.n} · ${g.phase.ko} (${g.phase.name}), UNIT ${g.unit.id} · ${g.unit.ko} (${g.unit.name})${g.unit.vocabUnit ? " — a VOCABULARY unit" : ""}.`);
|
|
L.push(`THIS UNIT'S GOAL: ${g.unit.goal}`, "");
|
|
L.push("════ WHAT HE KNOWS — the complete list ════");
|
|
L.push(g.taught.length ? g.taught.map(t => "• " + t).join("\n") : "• nothing yet — this is the very first unit", "");
|
|
L.push("════ THIS UNIT ADDS ════", g.unit.teaches.map(t => "• " + t).join("\n"));
|
|
if (g.unit.avoid?.length)
|
|
L.push("\nAND EXPLICITLY EXCLUDES, even if it seems natural:\n" + g.unit.avoid.map(t => "✗ " + t).join("\n"));
|
|
L.push("", "════ NOT TAUGHT YET — MUST NOT APPEAR ════");
|
|
L.push("Every one of these belongs to a later unit. Using any of them, even in passing, even to be helpful, breaks the sequence:");
|
|
L.push(g.forbidden.near.length ? g.forbidden.near.map(t => "✗ " + t).join("\n") : "— nothing; this is the last unit");
|
|
if (g.forbidden.tailUnit)
|
|
L.push(`…and everything else on the roadmap through ${g.forbidden.tailUnit.id} ${g.forbidden.tailUnit.ko}. If a thing is not on the KNOWS list above, it is not taught. That is the whole test — you do not need to recognise it on this list to exclude it.`);
|
|
L.push("", "════ VOCABULARY YOU MAY USE ════", g.vocabulary.join(" · ") || "(none yet)");
|
|
L.push("NEW WORDS THIS UNIT MAY INTRODUCE — and no others:",
|
|
g.newWords.length ? g.newWords.join(" · ")
|
|
: "(none — this unit adds no new vocabulary on purpose. It is a contrast/synthesis unit: work it entirely with words he already has.)");
|
|
if (g.revisits.length)
|
|
L.push("BRING BACK ON PURPOSE — he met these earlier and they are due for reuse here:", g.revisits.join(" · "));
|
|
return L.join("\n");
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════════
|
|
ENFORCING THE GATE
|
|
══════════════════════════════════════════════════════════════════════
|
|
Everything above tells the tutor what he may use. This half checks
|
|
whether he listened, because he does not reliably. Two real failures
|
|
from the artifact's transcript:
|
|
|
|
· 좁아 appeared in a review exercise. The word is in no unit, no deck
|
|
and nowhere in the prompt. The student tapped it and got a blank.
|
|
· 안 (not) appeared in a Phase 1 exercise, declared in a ::words
|
|
block. It is a real word belonging to a later unit, and the old
|
|
check only asked "does the app know this word" — never "has he been
|
|
taught it". The tutor himself admitted, one message later, that it
|
|
was not on the roadmap yet.
|
|
|
|
THE SCOPE IS THE WHOLE DESIGN, AND IT WAS MEASURED, NOT REASONED.
|
|
Checking his prose against the allowed vocabulary was run over all 41 of
|
|
his real messages: it would have rejected 17 of them, 41%. Nearly every
|
|
hit was a PRONUNCIATION — "국물 is read as → 궁물". 궁물 is not a word and
|
|
never will be; it is how a word sounds, and sound is the whole of Phase
|
|
1. Others were misreadings quoted on purpose (감사함니다) to contrast
|
|
with the right form. Nothing separates those from a stray word by
|
|
spelling alone, and a tutor who cannot write 궁물 cannot teach 비음화.
|
|
|
|
So the gate reads only the side of an exercise the student must decode.
|
|
Re-measured with that scope: 1 of 41, and that one is a true positive.
|
|
KEEP A CORPUS OF REAL TUTOR MESSAGES AS A FIXTURE and re-run the audit
|
|
whenever this file changes (see fixtures/ and audit-gate.mjs). A
|
|
vocabulary gate that has not been measured against real output will be
|
|
far too aggressive; 41% is not a near miss. */
|
|
|
|
const HANGUL_RUN = /[가-힣]+/g;
|
|
|
|
/** Words that belong in a teacher's prose without being taught vocabulary:
|
|
* unit and phase names, and the metalanguage of the course itself.
|
|
* Rejecting a message for saying 받침 is worse than the problem solved. */
|
|
export function buildScaffold(curriculum) {
|
|
const set = new Set();
|
|
const add = s => (String(s || "").match(HANGUL_RUN) || []).forEach(w => set.add(w));
|
|
curriculum.phases.forEach(p => { add(p.ko); add(p.name); p.units.forEach(u => { add(u.ko); add(u.name); }); });
|
|
(`한글 한국어 한국말 선생님 학생 반말 존댓말 높임말 말투
|
|
자음 모음 받침 겹받침 음절 글자 낱말 단어 문장 어절 띄어쓰기
|
|
연음 비음화 격음화 경음화 구개음화 유음화 탈락 된소리 거센소리 예사소리 소리
|
|
조사 어미 어간 동사 형용사 명사 대명사 부사 관형사 수사 감탄사 조동사
|
|
의성어 의태어 수업 복습 다지기 힌트 제출 정답 오답 문제 연습 예문 보기
|
|
주어 목적어 서술어 주제 자리 이름 뜻 읽기 쓰기 듣기 말하기 때 것 거 수
|
|
네 아니요 그래 맞아 처럼 같이`).split(/\s+/).forEach(w => w && set.add(w));
|
|
return set;
|
|
}
|
|
|
|
/** Only the side of an exercise the student has to decode. The answer side
|
|
* is where readings live, and it is deliberately not gated. */
|
|
export function taskMaterial(task) {
|
|
if (!task) return [];
|
|
return (task.rows || []).map(r => {
|
|
const c = String(r).split("|");
|
|
if (task.type === "translate") return r; // the line to read
|
|
if (task.type === "build") return c.slice(1).join(" "); // the chips
|
|
if (task.type === "match" || task.type === "choice") return c[0] || "";
|
|
return ""; // recall prompts are English
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param parsed a parsed tutor message (see blocks.js)
|
|
* @param ctx { allowed:Set<string>, scaffold:Set<string>,
|
|
* heads:(token)=>string[] every dictionary word this
|
|
* surface could be, best first,
|
|
* unitOf:(word)=>string the unit that introduces it }
|
|
* @returns [{ word, unit, known }] known=false means no gloss exists at all
|
|
*/
|
|
export function scanTask(parsed, ctx) {
|
|
const body = taskMaterial(parsed && parsed.task).join(" ")
|
|
.replace(/\[[^\]]*\]/g, "") // 학교 [학꾜] — a sound, not a word
|
|
.replace(/\([^)]*\)/g, "");
|
|
// Hangul in the MEANING or NOTE of a ::words entry is a reading he is
|
|
// quoting ("국물 | soup broth | read 궁물"), not a word he is using.
|
|
// The headword itself is NOT exempt: declaring a word does not license it.
|
|
const readings = new Set();
|
|
((parsed && parsed.words) || []).forEach(w =>
|
|
((`${w.note || ""} ${w.gloss || w.m || ""}`).match(HANGUL_RUN) || []).forEach(t => readings.add(t)));
|
|
|
|
const seen = new Set(), out = [];
|
|
(body.match(HANGUL_RUN) || []).forEach(tok => {
|
|
if (seen.has(tok)) return;
|
|
seen.add(tok);
|
|
if (ctx.scaffold.has(tok) || readings.has(tok)) return;
|
|
const heads = ctx.heads(tok);
|
|
if (!heads.length) { out.push({ word: tok, unit: "", known: false }); return; }
|
|
// allowed if ANY route to a dictionary word is allowed: 닭이 is 닭 with
|
|
// a particle, 넓어 is 넓다. Stopping at the first route misreported both.
|
|
if (heads.some(h => ctx.scaffold.has(h) || ctx.allowed.has(h))) return;
|
|
out.push({ word: tok, unit: heads.map(ctx.unitOf).find(Boolean) || "", known: true });
|
|
});
|
|
return out;
|
|
}
|
|
|
|
/** The second rule, and far easier to be sure about: he must EXPLAIN in
|
|
* English. Korean inside a block or in brackets is material; Korean in the
|
|
* prose around it is the failure. Fires on exactly the one message in the
|
|
* 41-message corpus that deserved it. */
|
|
export function proseIsKorean(text) {
|
|
const prose = String(text || "")
|
|
.replace(/::(task|words|gloss|result|confirmed|progress)[\s\S]*?(?:\n::|$)/g, " ")
|
|
.replace(/\[[^\]]*\]/g, " ");
|
|
const ko = (prose.match(/[가-힣]/g) || []).length;
|
|
const la = (prose.match(/[A-Za-z]/g) || []).length;
|
|
return ko >= 40 && ko / ((ko + la) || 1) >= 0.5;
|
|
}
|
|
|
|
/** What to tell the tutor when a message is sent back. The two severities
|
|
* need different fixes, so they are reported separately. */
|
|
export function rejectionNote(findings) {
|
|
const unknown = findings.filter(f => !f.known).map(f => f.word);
|
|
const early = findings.filter(f => f.known);
|
|
const L = [];
|
|
if (unknown.length)
|
|
L.push(`• ${unknown.join(" · ")} — the app has no gloss for ${unknown.length > 1 ? "these" : "this"} at all, ` +
|
|
`so he taps the word and gets nothing. Either a typo, or a word that exists nowhere in his course.`);
|
|
if (early.length)
|
|
L.push(`• ${early.map(f => f.word + (f.unit ? ` (belongs to unit ${f.unit})` : " (not on his list)")).join(" · ")} — ` +
|
|
`real ${early.length > 1 ? "words" : "word"}, but from a unit he has not reached. Declaring ` +
|
|
`${early.length > 1 ? "them" : "it"} in a ::words block does NOT make ${early.length > 1 ? "them" : "it"} ` +
|
|
`allowed; the roadmap decides that, not you.`);
|
|
return L.join("\n");
|
|
}
|
|
|
|
/** Bounded retry. A student stuck behind a tutor that cannot satisfy the
|
|
* checker is worse than a bad word, so after GATE_TRIES the message is
|
|
* shown anyway with the words flagged in the UI. */
|
|
export const GATE_TRIES = 2;
|