/* 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, scaffold:Set, * 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;