The export bundle is the input to this port, not a sketch: the curriculum, the tutor prompt and the five logic modules are finished and tested. They land here byte-identical and stay that way. diff -r export/data data && diff -r export/lib lib diff -r export/prompt prompt && diff export/validate.mjs validate.mjs data/, lib/, prompt/ and validate.mjs sit at the repo root so validate.mjs runs verbatim with no path edits. All four are excluded from lint and formatting — they are not ours to restyle. Types for lib/ live alongside in types/ rather than as sibling .d.ts files, so the verbatim check stays a plain directory diff. CI runs the curriculum gate first, before anything else can pass: node validate.mjs PASS — 0 blocking, 0 advisory Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
87 lines
3.8 KiB
JavaScript
87 lines
3.8 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|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]*))?/,
|
|
};
|
|
const rows = s => s.split("\n").map(l => l.trim()).filter(l => l && !/^::/.test(l));
|
|
const cols = l => l.split("|").map(x => x.trim());
|
|
|
|
export function parse(text) {
|
|
const w = text.match(RE.words), t = text.match(RE.task);
|
|
const g = text.match(RE.gloss), p = text.match(RE.progress);
|
|
|
|
const words = w ? rows(w[1]).map(l => { const c = cols(l);
|
|
return { ko: c[0], gloss: c[1] || "", note: c[2] || "" }; }) : null;
|
|
|
|
let task = null;
|
|
if (t) {
|
|
const r = rows(t[2]);
|
|
if (t[1] === "translate") task = { type: "translate", items: r.map(q => ({ 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) };
|
|
}
|
|
|
|
let gloss = null;
|
|
if (g) {
|
|
const blocks = []; 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(); 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;
|
|
}
|
|
|
|
let body = text;
|
|
if (p) body = body.replace(p[0], "");
|
|
if (g) body = body.replace(g[0], "");
|
|
if (w) body = body.slice(0, body.indexOf("::words") >= 0 ? body.indexOf("::words") : body.length);
|
|
if (t) body = body.replace(t[0], "");
|
|
|
|
return {
|
|
body: body.replace(/\n{3,}/g, "\n\n").trim(),
|
|
words, task, gloss,
|
|
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. */
|
|
export function answerText(task, state, lookups = []) {
|
|
let body;
|
|
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.)");
|
|
}
|