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>
73 lines
4.0 KiB
JavaScript
73 lines
4.0 KiB
JavaScript
/* 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");
|
|
}
|