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>
This commit is contained in:
@@ -154,11 +154,64 @@ export interface PromptInputs {
|
||||
* 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 {
|
||||
return template
|
||||
.replace("{{GATE}}", renderGate(gate))
|
||||
.replace("{{VARIETY}}", varietyLine(recent))
|
||||
.replace("{{FOCUS}}", focusLine(focus));
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user