/* 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.)"); }