assemblePrompt used template.replace("{{GATE}}", ...), and String.replace
with a string argument substitutes only the FIRST occurrence. All three
placeholders appear twice in tutor-system.md, because the document names
them in its own header paragraph before using them:
Assembled per turn. `{{GATE}}` is `renderGate()` from `lib/gate.js`;
`{{VARIETY}}` and `{{FOCUS}}` are one-liners built from recent state.
So the rendered gate replaced the backticked mention mid-sentence, and the
real slot further down was sent to the model as the literal text "{{GATE}}".
The mechanism that decides what the tutor is allowed to teach was delivered
in the wrong place, with a template token standing where it belonged, and
the same for VARIETY and FOCUS. Confirmed by capturing what the app
actually put on the wire: three unfilled placeholders at lines 60, 86 and
141.
Substitution is now anchored to a whole line, which is what distinguishes a
slot from a mention -- the header's are inline and backticked. The
replacement is a function because renderGate() output contains "$"
sequences that String.replace would otherwise interpret.
The existing tests could not have caught this. They ran against a synthetic
template naming each placeholder exactly once, which is precisely the
property the shipped file lacks. The new ones run against
prompt/tutor-system.md itself: no slot may survive unfilled, the header
must come through intact, and the gate must land between the profile and
the pre-flight check. Both fail against the old code.
Also appends a HOUSE STYLE section after the shipped prompt -- an addition
by the app, not an edit to the file, which still ships byte-identical.
It covers two things the prompt leaves to inference. The language of
explanation is never actually stated: "한글 and English only" is a rule
about retiring romanization, and everything else only implies English. A
strong model infers it; gpt-oss-20b delivered a full grammar lesson in
Korean to a student on unit 1.1 who cannot yet read it. And no-markdown is
stated outright at line 96 and was ignored anyway, so it is restated where
the consequence is visible: the app renders **bold** and nothing else, so a
table arrives as rows of literal pipes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
219 lines
9.4 KiB
TypeScript
219 lines
9.4 KiB
TypeScript
/* The gate — wiring lib/gate.js to the dictionary.
|
|
|
|
buildGate() already takes a `vocabQuery` hook for exactly this. Replacing
|
|
its default (the hand-listed words of finished units) with a band query is
|
|
what turns 371 typed words into something that scales, and it is the
|
|
mechanism that stops the tutor reaching for a word the learner has not
|
|
been given.
|
|
|
|
Three refinements sit inside the hook, all of them narrowing:
|
|
|
|
1. WORDS OWNED BY A LATER UNIT ARE EXCLUDED. A band ceiling knows about
|
|
frequency, not about pedagogy; without this, 3.4's 빨갛다 would leak
|
|
into 2.1 just because it is common.
|
|
|
|
2. PHASE 1 IS FILTERED BY SOUND. During the writing-system phase every
|
|
word must be phonologically legal for the unit reached, or a band
|
|
would hand him a 겹받침 during 1.4. Same ladder validate.mjs checks.
|
|
|
|
3. THE LIST IS CAPPED. renderGate() inlines the vocabulary into the
|
|
prompt joined by " · ", and an uncapped band is tens of thousands of
|
|
characters. The cap takes the most frequent first, so it is strictly
|
|
more restrictive than the band — it cannot leak anything the band
|
|
would not already have allowed. The full band stays in the database
|
|
for the word rail.
|
|
|
|
prompt/tutor-system.md ships unchanged. {{GATE}} is the only structural
|
|
substitution; {{VARIETY}} and {{FOCUS}} are the one-liners it expects. */
|
|
|
|
import { buildGate, flatten, renderGate } from "@lib/gate.js";
|
|
import type { Curriculum, FlatUnit, Gate, ProgressState } from "@lib/gate.js";
|
|
import { featureLevel, isReadableAt, LADDER_COMPLETE } from "@shared/phonology.mjs";
|
|
import { bandForUnit, REFERENCE_BAND, ceilingForBand } from "@shared/bands.mjs";
|
|
import curriculumJson from "@data/curriculum.json";
|
|
|
|
export const curriculum = curriculumJson as unknown as Curriculum;
|
|
export const UNITS: FlatUnit[] = flatten(curriculum);
|
|
export const unitIndex = (id: string): number => UNITS.findIndex((u) => u.id === id);
|
|
export const unitById = (id: string): FlatUnit | undefined => UNITS.find((u) => u.id === id);
|
|
|
|
/** How many words the prompt's vocabulary section may name. */
|
|
export const VOCAB_CAP = 800;
|
|
|
|
/** Every word a not-yet-finished unit is the first to introduce. */
|
|
function wordsOwnedByFutureUnits(done: Set<string>): Set<string> {
|
|
const owned = new Set<string>();
|
|
const introduced = new Set<string>();
|
|
for (const u of UNITS) {
|
|
for (const w of u.words ?? []) {
|
|
if (introduced.has(w)) continue;
|
|
introduced.add(w);
|
|
if (!done.has(u.id)) owned.add(w);
|
|
}
|
|
}
|
|
return owned;
|
|
}
|
|
|
|
export interface VocabRow {
|
|
headword: string;
|
|
}
|
|
|
|
/** Runs the band query. Injected so the gate can be built without a database. */
|
|
export type BandQuery = (band: number, ceiling: number, limit: number) => VocabRow[];
|
|
|
|
/**
|
|
* The vocabQuery hook. Curriculum words of finished units are always
|
|
* allowed; the band adds frequency-ranked vocabulary on top.
|
|
*/
|
|
export function makeVocabQuery(query: BandQuery) {
|
|
return (unit: FlatUnit, done: FlatUnit[]): string[] => {
|
|
const doneIds = new Set(done.map((u) => u.id));
|
|
const band = bandForUnit(unit.id);
|
|
const i = unitIndex(unit.id);
|
|
const level = featureLevel(i, unitIndex);
|
|
const soundGated = level < LADDER_COMPLETE;
|
|
const future = wordsOwnedByFutureUnits(doneIds);
|
|
|
|
const allowed: string[] = [];
|
|
const seen = new Set<string>();
|
|
const push = (w: string) => {
|
|
if (!w || seen.has(w)) return;
|
|
if (future.has(w)) return; // refinement 1
|
|
if (soundGated && !isReadableAt(w, level)) return; // refinement 2
|
|
seen.add(w);
|
|
allowed.push(w);
|
|
};
|
|
|
|
// The words he has actually been taught come first and are never cut.
|
|
for (const u of done) for (const w of u.words ?? []) push(w);
|
|
for (const w of unit.revisits ?? []) push(w.word);
|
|
|
|
// Then the band, most frequent first. Ask for extra because the filters
|
|
// above will reject some of what comes back.
|
|
if (band > 0) {
|
|
for (const row of query(band, ceilingForBand(band), VOCAB_CAP * 3)) {
|
|
if (allowed.length >= VOCAB_CAP) break; // refinement 3
|
|
push(row.headword);
|
|
}
|
|
}
|
|
|
|
return allowed;
|
|
};
|
|
}
|
|
|
|
export interface GateInputs {
|
|
progress: ProgressState;
|
|
bandQuery?: BandQuery;
|
|
}
|
|
|
|
export function gateFor({ progress, bandQuery }: GateInputs): Gate {
|
|
return buildGate(curriculum, progress, bandQuery ? { vocabQuery: makeVocabQuery(bandQuery) } : {});
|
|
}
|
|
|
|
/* ── the prompt ──────────────────────────────────────────────────── */
|
|
|
|
/** The four exercise types, so {{VARIETY}} can ask for a different one. */
|
|
const TASK_TYPES = ["translate", "match", "build", "choice"] as const;
|
|
export type TaskKind = (typeof TASK_TYPES)[number];
|
|
|
|
export const FOCUS_MODES = {
|
|
auto: "",
|
|
sentence: "Bias this session toward reading whole sentences.",
|
|
vocab: "Bias this session toward vocabulary breadth — more words, more matching.",
|
|
particles: "Bias this session toward particles and what they mark.",
|
|
sound: "Bias this session toward sound changes and reading aloud in your head.",
|
|
manhwa: "Bias this session toward manhwa dialogue: 반말, contractions, sound words.",
|
|
free: "He asked to just talk. Follow his lead, but stay inside the gate.",
|
|
} as const;
|
|
export type FocusMode = keyof typeof FOCUS_MODES;
|
|
|
|
/** {{VARIETY}} — the only anti-repetition mechanism the tutor has. */
|
|
export function varietyLine(recent: string[]): string {
|
|
const last = recent.slice(-4);
|
|
if (!last.length) return "Pick whichever exercise type suits the material.";
|
|
const unused = TASK_TYPES.filter((t) => !last.includes(t));
|
|
return (
|
|
`Your last exercises were: ${last.join(", ")}. ` +
|
|
(unused.length
|
|
? `Use a different type this time — ${unused.join(" or ")}.`
|
|
: "Vary the type from the last one.")
|
|
);
|
|
}
|
|
|
|
/** {{FOCUS}} — one line, or nothing at all on auto. */
|
|
export const focusLine = (mode: FocusMode): string => FOCUS_MODES[mode] ?? "";
|
|
|
|
export interface PromptInputs {
|
|
template: string;
|
|
gate: Gate;
|
|
recent: string[];
|
|
focus: FocusMode;
|
|
}
|
|
|
|
/**
|
|
* Assemble the system prompt. The template is prompt/tutor-system.md,
|
|
* shipped unchanged — this fills its three placeholders and nothing else.
|
|
*/
|
|
/* Appended by the app, after the shipped prompt — not an edit to it.
|
|
|
|
tutor-system.md ships unchanged. Two things it leaves to inference, which
|
|
a strong model supplies on its own and a small local one does not:
|
|
|
|
THE LANGUAGE OF EXPLANATION is never actually stated. The prompt says
|
|
"한글 and English only", but that rule is about retiring romanization, not
|
|
about which language to teach in. Everything else only implies it —
|
|
English translations, English glosses. gpt-oss-20b read the room
|
|
differently and delivered a whole grammar lesson in Korean, to a student
|
|
on unit 1.1 who cannot yet read it.
|
|
|
|
NO MARKDOWN is stated outright at line 96, and was ignored anyway. It is
|
|
restated here because the consequence is invisible to the model: the app
|
|
renders **bold** and nothing else, so a table arrives as rows of literal
|
|
pipes and a heading as literal hashes.
|
|
|
|
Both are reinforcement, never contradiction. If the shipped prompt and
|
|
this ever disagree, the shipped prompt is right and this should go. */
|
|
const HOUSE_STYLE = `
|
|
════ HOUSE STYLE ════
|
|
Explain in English. Korean is the material — words, examples, exercise lines, anything you are asking him to read. It is not the language you teach in. He is a beginner working through the writing system; an explanation he cannot read teaches him nothing.
|
|
Plain text only. No tables, headings, bullet characters or code fences — the app renders **bold** and nothing else, so anything else reaches him as literal pipes and hashes.`;
|
|
|
|
/**
|
|
* Substitute one placeholder — the one standing alone on its own line.
|
|
*
|
|
* This was `template.replace("{{GATE}}", …)`, which is wrong twice over.
|
|
* String.replace with a string argument replaces only the FIRST occurrence,
|
|
* and tutor-system.md names all three placeholders in its own header
|
|
* paragraph before using them:
|
|
*
|
|
* Assembled per turn. `{{GATE}}` is `renderGate()` from `lib/gate.js`;
|
|
* `{{VARIETY}}` and `{{FOCUS}}` are one-liners built from recent state.
|
|
*
|
|
* So the gate was spliced into the middle of that sentence and the real slot
|
|
* further down was shipped to the model as the literal text "{{GATE}}" —
|
|
* the curriculum gate, the whole mechanism that decides what may be taught,
|
|
* delivered in the wrong place with a template token where it belonged.
|
|
*
|
|
* Anchoring to a whole line fixes it: the header mentions are inline and
|
|
* backticked, the real slots are alone on a line. A function replacement is
|
|
* used because the rendered gate contains "$" sequences that String.replace
|
|
* would otherwise interpret.
|
|
*/
|
|
function fill(template: string, name: string, value: string): string {
|
|
const token = `{{${name}}}`;
|
|
const line = new RegExp(`^${token.replace(/[{}]/g, "\\$&")}[ \\t]*$`, "m");
|
|
if (line.test(template)) return template.replace(line, () => value);
|
|
// A template that inlines the placeholder still works.
|
|
return template.replace(token, () => value);
|
|
}
|
|
|
|
export function assemblePrompt({ template, gate, recent, focus }: PromptInputs): string {
|
|
let out = fill(template, "GATE", renderGate(gate));
|
|
out = fill(out, "VARIETY", varietyLine(recent));
|
|
out = fill(out, "FOCUS", focusLine(focus));
|
|
return out + HOUSE_STYLE;
|
|
}
|
|
|
|
export { renderGate, REFERENCE_BAND };
|
|
export type { Gate, ProgressState, FlatUnit };
|