Files
Hankan/app/src/domain/gate.ts
MechaCat02 9a925a5e3f fix(gate): the gate was being spliced into the prompt's header sentence
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>
2026-09-10 07:09:59 +02:00

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 };