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