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>
147 lines
6.5 KiB
JavaScript
147 lines
6.5 KiB
JavaScript
/* The block protocol — how 선생님 drives the UI.
|
|
The tutor writes prose plus fenced blocks; the client renders them
|
|
as real interface and sends structured answers back. */
|
|
|
|
const RE = {
|
|
task: /::task\s+(translate|recall|match|build|choice)\s*\n([\s\S]*?)(?:\n::|$)/,
|
|
words: /::words\s*\n([\s\S]*?)(?:\n::|$)/,
|
|
gloss: /::gloss\s*\n([\s\S]*?)(?:\n::|$)/,
|
|
progress: /::progress\s+(\d{1,3})\s*(?:\|\s*([^\n]*))?/,
|
|
result: /::result\s*\n([\s\S]*?)(?:\n::|$)/,
|
|
confirmed:/::confirmed\s*\n([\s\S]*?)(?:\n::|$)/,
|
|
};
|
|
const rows = s => s.split("\n").map(l => l.trim()).filter(l => l && !/^::/.test(l));
|
|
const cols = l => l.split("|").map(x => x.trim());
|
|
|
|
/* 선생님 sometimes writes an exercise, notices mid-message that it broke the
|
|
gate, and rewrites it below. Matching the FIRST block served the draft it
|
|
had just retracted — a real bug seen in production, where the student was
|
|
handed and answered an exercise the tutor had already withdrawn.
|
|
So: collect every block; the LAST task, words and progress win, and gloss
|
|
blocks accumulate (a message may legitimately gloss several sentences). */
|
|
const all = (text, re) => {
|
|
const r = new RegExp(re.source, "g");
|
|
const out = [];
|
|
let m;
|
|
while ((m = r.exec(text)) !== null) {
|
|
out.push(m);
|
|
if (m.index === r.lastIndex) r.lastIndex++;
|
|
}
|
|
return out;
|
|
};
|
|
|
|
export function parse(text) {
|
|
const ws = all(text, RE.words), ts = all(text, RE.task);
|
|
const gs = all(text, RE.gloss), ps = all(text, RE.progress);
|
|
const t = ts.length ? ts[ts.length - 1] : null;
|
|
const p = ps.length ? ps[ps.length - 1] : null;
|
|
|
|
/* keep every word listed anywhere, first gloss of a term wins */
|
|
let words = null;
|
|
if (ws.length) {
|
|
const seen = new Set(), acc = [];
|
|
ws.forEach(w => rows(w[1]).forEach(l => {
|
|
const c = cols(l);
|
|
if (!c[0] || seen.has(c[0])) return;
|
|
seen.add(c[0]);
|
|
acc.push({ ko: c[0], gloss: c[1] || "", note: c[2] || "" });
|
|
}));
|
|
if (acc.length) words = acc;
|
|
}
|
|
|
|
let task = null;
|
|
if (t) {
|
|
const r = rows(t[2]);
|
|
if (t[1] === "translate") task = { type: "translate", items: r.map(q => ({ q })) };
|
|
/* recall: English prompt, the student WRITES the 한글. The one task type
|
|
that proves memory rather than recognition — and the only one whose
|
|
answers need letter-level marking, see hangul.js letterCheck(). */
|
|
if (t[1] === "recall") task = { type: "recall", items: r.map(l => { const c = cols(l);
|
|
return { q: c[0], hint: c[1] || "" }; }).filter(x => x.q) };
|
|
if (t[1] === "match") task = { type: "match", pairs: r.map(l => { const c = cols(l);
|
|
return { ko: c[0], gloss: c[1] }; }).filter(x => x.ko && x.gloss) };
|
|
if (t[1] === "build") task = { type: "build", items: r.map(l => { const c = cols(l);
|
|
return { en: c[0], chips: c.slice(1).filter(Boolean) }; }).filter(x => x.en && x.chips.length) };
|
|
if (t[1] === "choice") task = { type: "choice", items: r.map(l => { const c = cols(l);
|
|
return { q: c[0], options: c.slice(1).filter(Boolean) }; }).filter(x => x.q && x.options.length > 1) };
|
|
if (task) { task.retracted = ts.length - 1; /* >0 means a draft was withdrawn */
|
|
task.rows = r; } /* raw rows, for the gate to read */
|
|
}
|
|
|
|
let gloss = null;
|
|
if (gs.length) {
|
|
const blocks = [];
|
|
gs.forEach(g => {
|
|
let cur = null;
|
|
g[1].split("\n").forEach(line => {
|
|
const l = line.trim();
|
|
if (!l || /^::/.test(l)) return;
|
|
if (l.startsWith("=")) { if (cur) { cur.en = l.slice(1).trim(); cur = null; } return; }
|
|
const c = cols(l);
|
|
if (!cur) { cur = { parts: [], en: "" }; blocks.push(cur); }
|
|
cur.parts.push({ ko: c[0], role: (c[1] || "N").toUpperCase()[0], gloss: c[2] || "", highlight: c[3] || "" });
|
|
});
|
|
});
|
|
const keep = blocks.filter(b => b.parts.length);
|
|
if (keep.length) gloss = keep;
|
|
}
|
|
|
|
/* marking the tutor sends back: per-item outcomes, and words he certifies */
|
|
const rs = all(text, RE.result), cs = all(text, RE.confirmed);
|
|
const results = rs.length ? rows(rs[rs.length - 1][1]).map(l => {
|
|
const c = cols(l);
|
|
return { item: c[0], ok: /^ok$/i.test(c[1] || ""), mistakenFor: c[2] || "" };
|
|
}).filter(x => x.item) : null;
|
|
const confirmed = cs.length ? rows(cs[cs.length - 1][1]).map(l => cols(l)[0]).filter(Boolean) : null;
|
|
|
|
/* strip every block, not only the matched one */
|
|
let body = text;
|
|
[RE.progress, RE.gloss, RE.task, RE.words, RE.result, RE.confirmed].forEach(re => {
|
|
body = body.replace(new RegExp(re.source, "g"), "");
|
|
});
|
|
body = body.replace(/^[ \t]*::[ \t]*$/gm, "");
|
|
|
|
return {
|
|
body: body.replace(/\n{3,}/g, "\n\n").trim(),
|
|
words, task, gloss, results, confirmed,
|
|
progress: p ? { score: Math.max(0, Math.min(100, +p[1])), note: (p[2] || "").trim() } : null,
|
|
};
|
|
}
|
|
|
|
/** Roles a gloss part can carry, and what the UI should do with each. */
|
|
export const ROLES = {
|
|
S: "주어 subject", T: "주제 topic", O: "목적어 object",
|
|
V: "서술어 predicate", P: "자리·때 place/time", C: "이음 connective",
|
|
Q: "인용 quotation", M: "수식 modifier", N: "",
|
|
};
|
|
|
|
/**
|
|
* Turn a completed task back into the message the student sends.
|
|
*
|
|
* `letterBlock` is the output of hangul.js letterCheck() for a recall task —
|
|
* pass it, always. The tutor cannot see inside a Hangul syllable and will
|
|
* invent a diagnosis if you leave him to it.
|
|
*/
|
|
export function answerText(task, state, lookups = [], letterBlock = "") {
|
|
let body;
|
|
if (task.type === "recall")
|
|
body = "My written answers:\n" + task.items.map((it, i) =>
|
|
`${it.q} → ${(state[i] || "").trim() || "(not sure)"}`).join("\n")
|
|
+ (letterBlock ? "\n\n" + letterBlock : "");
|
|
else if (task.type === "translate")
|
|
body = "My answers:\n" + task.items.map((it, i) =>
|
|
`${it.q} → ${(state[i] || "").trim() || "(not sure)"}`).join("\n");
|
|
else if (task.type === "match")
|
|
body = "My pairings:\n" + (state.pairs.map(p => `${p.ko} = ${p.gloss}`).join("\n") || "(none)");
|
|
else if (task.type === "build")
|
|
body = "My sentences:\n" + task.items.map((it, i) =>
|
|
`${it.en} → ${(state[i] || []).join(" ") || "(not sure)"}`).join("\n");
|
|
else
|
|
body = "My choices:\n" + task.items.map((it, i) =>
|
|
`${it.q} → ${state[i] == null ? "(not sure)" : it.options[state[i]]}`).join("\n");
|
|
|
|
return body + (lookups.length
|
|
? `\n\n(I had to look up: ${lookups.join(", ")})`
|
|
: "\n\n(No lookups.)");
|
|
}
|