feat(gate): one resolver, the words he has met, and the audit through the port
The bundle's audit-gate.mjs measures the word gate against 54 real tutor
messages. Scored the same way, the port's gate was not the gate that was
measured:
port's allowed set 13 · 2 over-fires on words he had met
port's dictionary resolver 4 · 2 too weak: 마셔 → 마시다 again
lib/lexicon.js, DB behind it 7 · 2 the shipped gate
domain/resolver.ts makes lib/lexicon.js the one resolver for word taps and
the gate, built from the shipped data in the audit's order — roadmap words
first, so a scheduled form cannot inherit its stem's permission — with the
dictionary consulted only where lexicon.js has no route. That fallback is
what the port adds over the artifact: a word the curated data does not know
is still recognised as a real word.
The allowed set gains every word he has met (any card out of "new"), as
the shipped gate has it; the frequency band stays on top from Phase 2, as
PORT.md specifies. The words the tutor is TOLD it may use and the words it
is CHECKED against are one set, so it is never refused for a word it was
offered.
test/domain/gate-audit.test.ts reproduces the audit through the app's own
code — database, loader, resolver — and gets the shipped 149-word allowed
set and the same 7 and 2 messages, word for word.
Also: {{VARIETY}} and {{FOCUS}} take the artifact's wording and know the
fifth exercise type, recall; and the prompt's maintainer header, which
explains the placeholders, is no longer sent to the model.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,8 +26,9 @@
|
||||
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 { buildGate, buildScaffold, flatten, renderGate } from "@lib/gate.js";
|
||||
import type { Curriculum, FlatUnit, Gate, ProgressState } from "@lib/gate.js";
|
||||
import type { Db } from "../db/types.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";
|
||||
@@ -62,10 +63,28 @@ export interface VocabRow {
|
||||
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.
|
||||
* Every word he has met: the headword of each card that has left "new".
|
||||
*
|
||||
* The reworked app's gate allows these whatever unit schedules them — he
|
||||
* has reviewed them, so a line built from them is reading practice, not a
|
||||
* word from nowhere. Measured, leaving them out makes the gate over-fire on
|
||||
* real teaching: 13 flagged messages where the shipped gate flags 7, every
|
||||
* extra one a word like 몰라 or 커 that the student had in fact studied.
|
||||
*/
|
||||
export function makeVocabQuery(query: BandQuery) {
|
||||
export async function metWords(db: Db): Promise<string[]> {
|
||||
const rows = await db.all<{ headword: string }>(
|
||||
`SELECT DISTINCT l.headword FROM card c JOIN lemma l ON l.id = c.lemma_id
|
||||
WHERE c.state <> 0 ORDER BY l.headword`,
|
||||
);
|
||||
return rows.map((r) => r.headword);
|
||||
}
|
||||
|
||||
/**
|
||||
* The vocabQuery hook. Curriculum words of finished units are always
|
||||
* allowed, and so is every word he has met; the band adds frequency-ranked
|
||||
* vocabulary on top from Phase 2.
|
||||
*/
|
||||
export function makeVocabQuery(query: BandQuery, met: string[] = []) {
|
||||
return (unit: FlatUnit, done: FlatUnit[]): string[] => {
|
||||
const doneIds = new Set(done.map((u) => u.id));
|
||||
const band = bandForUnit(unit.id);
|
||||
@@ -88,6 +107,15 @@ export function makeVocabQuery(query: BandQuery) {
|
||||
for (const u of done) for (const w of u.words ?? []) push(w);
|
||||
for (const w of unit.revisits ?? []) push(w.word);
|
||||
|
||||
// Then every word he has met. Not filtered by future ownership or by
|
||||
// sound: a met word is allowed because he has seen it, whichever unit
|
||||
// will teach it formally — the rule the shipped gate was measured with.
|
||||
for (const w of met) {
|
||||
if (!w || seen.has(w)) continue;
|
||||
seen.add(w);
|
||||
allowed.push(w);
|
||||
}
|
||||
|
||||
// Then the band, most frequent first. Ask for extra because the filters
|
||||
// above will reject some of what comes back.
|
||||
if (band > 0) {
|
||||
@@ -104,10 +132,48 @@ export function makeVocabQuery(query: BandQuery) {
|
||||
export interface GateInputs {
|
||||
progress: ProgressState;
|
||||
bandQuery?: BandQuery;
|
||||
/** Headwords of every card that has left "new" — see metWords(). */
|
||||
met?: string[];
|
||||
}
|
||||
|
||||
export function gateFor({ progress, bandQuery }: GateInputs): Gate {
|
||||
return buildGate(curriculum, progress, bandQuery ? { vocabQuery: makeVocabQuery(bandQuery) } : {});
|
||||
export function gateFor({ progress, bandQuery, met = [] }: GateInputs): Gate {
|
||||
return buildGate(curriculum, progress, {
|
||||
vocabQuery: makeVocabQuery(bandQuery ?? (() => []), met),
|
||||
});
|
||||
}
|
||||
|
||||
/* ── enforcing it ────────────────────────────────────────────────── */
|
||||
|
||||
let scaffold: Set<string> | null = null;
|
||||
|
||||
/** Unit names and the course's metalanguage — see lib/gate.js buildScaffold. */
|
||||
export const courseScaffold = (): Set<string> => (scaffold ??= buildScaffold(curriculum));
|
||||
|
||||
/**
|
||||
* What the tutor may put in front of him: everything the prompt's
|
||||
* VOCABULARY list names, this unit's own new words, and its revisits.
|
||||
*
|
||||
* One set, deliberately. The list the tutor is TOLD and the list it is
|
||||
* CHECKED against are the same words, so it is never refused for a word it
|
||||
* was offered. A multi-word entry also admits its parts (몇 명 → 몇, 명),
|
||||
* as the audited gate does.
|
||||
*/
|
||||
export function allowedSet(gate: Gate): Set<string> {
|
||||
const out = new Set<string>();
|
||||
const add = (w: string | undefined) => {
|
||||
if (!w) return;
|
||||
out.add(w);
|
||||
if (/\s/.test(w)) for (const part of w.split(/\s+/)) if (part) out.add(part);
|
||||
};
|
||||
gate.vocabulary.forEach(add);
|
||||
(gate.unit.words ?? []).forEach(add);
|
||||
(gate.unit.revisits ?? []).forEach((r) => add(r.word));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The unit that introduces a word, for the rejection note — or "". */
|
||||
export function unitOf(word: string): string {
|
||||
return UNITS.find((u) => (u.words ?? []).includes(word) || (u.revisits ?? []).some((r) => r.word === word))?.id ?? "";
|
||||
}
|
||||
|
||||
/* ── the prompt ──────────────────────────────────────────────────── */
|
||||
@@ -116,32 +182,46 @@ export function gateFor({ progress, bandQuery }: GateInputs): Gate {
|
||||
const TASK_TYPES = ["translate", "recall", "match", "build", "choice"] as const;
|
||||
export type TaskKind = (typeof TASK_TYPES)[number];
|
||||
|
||||
/**
|
||||
* The seven focus modes, in the artifact's words. "auto" says nothing: the
|
||||
* unit decides. The rest bias the session — never past the gate, which the
|
||||
* shipped prompt makes absolute.
|
||||
*/
|
||||
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.",
|
||||
sentence:
|
||||
"Sentence logic and the ending word. Strings of words with no particles — subject/object + ending word — for him to decode, in the 반말 register manhwa uses.",
|
||||
vocab:
|
||||
"Vocabulary. Drill recall and recognition, mixing in words from earlier exercises so they stick. Matching tasks work well here.",
|
||||
particles:
|
||||
"Particles — 은/는, 이/가, 을/를. Introduce ONE at a time, show the same sentence with and without it, and build up slowly.",
|
||||
sound:
|
||||
"Sound changes while reading. Words written one way and pronounced another; ask for the spoken form in 한글 in brackets, and name the rule (연음, 비음화, 격음화, 경음화, 구개음화, 유음화, ㅎ 탈락).",
|
||||
manhwa:
|
||||
"Real manhwa lines — short 반말 speech-bubble lines, interjections, sound words. Set the scene in one clause where it helps.",
|
||||
free: "Follow his lead. Answer what he asks, and steer back to reading practice when the thread runs out.",
|
||||
} 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.")
|
||||
);
|
||||
const last = recent.slice(-8);
|
||||
const cold = TASK_TYPES.filter((t) => !last.includes(t));
|
||||
return [
|
||||
`Your last exercises were, most recent first: ${last.length ? [...last].reverse().join(", ") : "none yet"}.`,
|
||||
"The five types are: translate (read 한글, type the English) · recall (read English, WRITE the 한글) · match · build · choice.",
|
||||
cold.length
|
||||
? `You have not used ${cold.join(" or ")} recently — use one of those now unless he asked for something specific.`
|
||||
: "Pick a different type from the last one.",
|
||||
"recall is the one that proves memory rather than recognition: he cannot guess from a list. Use it regularly, and never run the same type three times in a row.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** {{FOCUS}} — one line, or nothing at all on auto. */
|
||||
export const focusLine = (mode: FocusMode): string => FOCUS_MODES[mode] ?? "";
|
||||
export const focusLine = (mode: FocusMode): string =>
|
||||
FOCUS_MODES[mode]
|
||||
? `He has manually set the focus to this, so work on it rather than the unit above: ${FOCUS_MODES[mode]}`
|
||||
: "";
|
||||
|
||||
export interface PromptInputs {
|
||||
template: string;
|
||||
@@ -183,8 +263,21 @@ function fill(template: string, name: string, value: string): string {
|
||||
* Assemble the system prompt. The template is prompt/tutor-system.md,
|
||||
* shipped unchanged — this fills its three placeholders and nothing else.
|
||||
*/
|
||||
/**
|
||||
* The prompt's body: everything below its first `---` line.
|
||||
*
|
||||
* tutor-system.md opens with a paragraph for whoever maintains the app —
|
||||
* which placeholder is filled by what, and a note on the Agent SDK path. It
|
||||
* was being sent to the model too. The file ships unchanged; the header is
|
||||
* simply not part of what the tutor reads, and the artifact never sent it.
|
||||
*/
|
||||
export function promptBody(template: string): string {
|
||||
const rule = template.match(/^---[ \t]*$/m);
|
||||
return rule?.index === undefined ? template : template.slice(rule.index + rule[0].length).replace(/^\s+/, "");
|
||||
}
|
||||
|
||||
export function assemblePrompt({ template, gate, recent, focus }: PromptInputs): string {
|
||||
let out = fill(template, "GATE", renderGate(gate));
|
||||
let out = fill(promptBody(template), "GATE", renderGate(gate));
|
||||
out = fill(out, "VARIETY", varietyLine(recent));
|
||||
out = fill(out, "FOCUS", focusLine(focus));
|
||||
return out;
|
||||
|
||||
170
app/src/domain/resolver.ts
Normal file
170
app/src/domain/resolver.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/* One resolver for word taps and for the gate.
|
||||
|
||||
"Which dictionary word is this surface form?" is asked twice — when the
|
||||
learner taps a word, and when the gate decides whether the tutor was
|
||||
allowed to use it — and the bundle's lib/lexicon.js exists so the two can
|
||||
never drift. Get the first wrong and he is told a taught word is "not in
|
||||
the word list"; get the second wrong and 닭이 is flagged as unknown when it
|
||||
is 닭 with a particle.
|
||||
|
||||
lib/lexicon.js is the resolver; the dictionary is only its fallback. The
|
||||
order was measured on the bundle's 54 real tutor messages (audit-gate.mjs
|
||||
expects 7 and 2 flagged):
|
||||
|
||||
the dictionary's surface table alone → 4 · 2 too weak
|
||||
lib/lexicon.js, dictionary behind it → 7 · 2 the shipped gate
|
||||
|
||||
The surface table routes 마셔 to 마시다, a card he has met, so a unit-2.3
|
||||
form becomes legal in Phase 1 — exactly the ordering bug lexicon.js warns
|
||||
about. lexicon.js enters the roadmap's own words first, so it does not.
|
||||
The dictionary answers only when lexicon.js has no route at all. */
|
||||
|
||||
import { buildLexicon, type LexEntry, type Lexicon } from "@lib/lexicon.js";
|
||||
import { haeche, past } from "@lib/conjugation.js";
|
||||
import type { WordEntry } from "@lib/blocks.js";
|
||||
import deckJson from "@data/deck.json";
|
||||
import glossExtraJson from "@data/gloss-extra.json";
|
||||
import sentencesJson from "@data/sentences.json";
|
||||
import sfxJson from "@data/sfx.json";
|
||||
import type { Db } from "../db/types.js";
|
||||
import { UNITS } from "./gate.js";
|
||||
|
||||
interface DeckFile {
|
||||
topics: Record<string, [string, string, string, string][]>;
|
||||
}
|
||||
interface GlossExtraFile {
|
||||
entries: { ko: string; en: string; note?: string }[];
|
||||
}
|
||||
interface SentencesFile {
|
||||
sentences: { parts: [string, string][] }[];
|
||||
}
|
||||
interface SfxFile {
|
||||
items: { ko: string; en: string }[];
|
||||
}
|
||||
|
||||
let built: Lexicon | null = null;
|
||||
|
||||
/** The shipped data as lib/lexicon.js reads it — in audit-gate.mjs's order. */
|
||||
export function lexicon(): Lexicon {
|
||||
built ??= buildLexicon(
|
||||
{
|
||||
// Scheduled words first, with no base: see the warning in lexicon.js.
|
||||
roadmapWords: UNITS.flatMap((u) => u.words ?? []),
|
||||
deck: Object.values((deckJson as unknown as DeckFile).topics)
|
||||
.flat()
|
||||
.map(([ko, , en, pos]) => ({ ko, en, pos })),
|
||||
glossExtra: (glossExtraJson as unknown as GlossExtraFile).entries.map((g) => [g.ko, g.en, g.note] as const),
|
||||
sentences: (sentencesJson as unknown as SentencesFile).sentences,
|
||||
sfx: (sfxJson as unknown as SfxFile).items.map((i) => [i.ko, i.en] as const),
|
||||
},
|
||||
{ haeche, past },
|
||||
);
|
||||
return built;
|
||||
}
|
||||
|
||||
const MAX_IN = 900; // Android's bound-parameter ceiling; see db/writes.ts.
|
||||
|
||||
/** Every dictionary headword each form is a surface of, in one pass. */
|
||||
async function dictionaryHeads(db: Db, forms: string[]): Promise<Map<string, string[]>> {
|
||||
const out = new Map<string, string[]>();
|
||||
for (let i = 0; i < forms.length; i += MAX_IN) {
|
||||
const batch = forms.slice(i, i + MAX_IN);
|
||||
const rows = await db.all<{ form: string; head: string }>(
|
||||
`SELECT DISTINCT s.form AS form, l.headword AS head
|
||||
FROM surface s JOIN lemma l ON l.id = s.lemma_id
|
||||
WHERE s.form IN (${batch.map(() => "?").join(",")})`,
|
||||
batch,
|
||||
);
|
||||
for (const r of rows) out.set(r.form, [...(out.get(r.form) ?? []), r.head]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A synchronous heads() for a set of tokens — lib/gate.js scanTask() calls
|
||||
* it inline, so the dictionary is consulted up front for exactly the tokens
|
||||
* lexicon.js cannot place.
|
||||
*/
|
||||
export async function headsFor(db: Db, tokens: Iterable<string>): Promise<(token: string) => string[]> {
|
||||
const lex = lexicon();
|
||||
const known = new Map<string, string[]>();
|
||||
const missing: string[] = [];
|
||||
for (const t of new Set(tokens)) {
|
||||
const h = lex.heads(t);
|
||||
if (h.length) known.set(t, h);
|
||||
else missing.push(t);
|
||||
}
|
||||
if (missing.length) {
|
||||
for (const [form, heads] of await dictionaryHeads(db, missing)) known.set(form, heads);
|
||||
}
|
||||
return (token) => known.get(token) ?? lex.heads(token);
|
||||
}
|
||||
|
||||
/** What a tap shows. */
|
||||
export interface Gloss {
|
||||
ko: string;
|
||||
gloss: string;
|
||||
note: string;
|
||||
/** Where it came from: the shipped lexicon, the dictionary, or the tutor. */
|
||||
from: "lexicon" | "dictionary" | "tutor";
|
||||
}
|
||||
|
||||
const fromLexicon = (token: string, e: LexEntry): Gloss => ({
|
||||
ko: token,
|
||||
// A roadmap word is entered with its own spelling as a placeholder gloss;
|
||||
// that is no gloss at all, so the dictionary is asked instead.
|
||||
gloss: e.gloss === e.ko && e.src === "roadmap" ? "" : e.gloss,
|
||||
note: e.note,
|
||||
from: "lexicon",
|
||||
});
|
||||
|
||||
/**
|
||||
* Gloss many forms: lexicon.js first, then the dictionary, then — for taps
|
||||
* only — whatever the tutor declared in ::words. A declared gloss explains a
|
||||
* word; it never makes the word allowed, which is the gate's business.
|
||||
*/
|
||||
export async function glossMany(
|
||||
db: Db,
|
||||
forms: string[],
|
||||
declared: WordEntry[] | null = null,
|
||||
): Promise<Map<string, Gloss>> {
|
||||
const lex = lexicon();
|
||||
const out = new Map<string, Gloss>();
|
||||
const ask: string[] = [];
|
||||
|
||||
for (const f of new Set(forms)) {
|
||||
const e = lex.lookup(f);
|
||||
const g = e ? fromLexicon(f, e) : null;
|
||||
if (g?.gloss) out.set(f, g);
|
||||
else ask.push(f);
|
||||
}
|
||||
|
||||
if (ask.length) {
|
||||
for (let i = 0; i < ask.length; i += MAX_IN) {
|
||||
const batch = ask.slice(i, i + MAX_IN);
|
||||
const rows = await db.all<{ form: string; gloss: string; analysis: string; headword: string }>(
|
||||
`SELECT s.form AS form, l.gloss_en AS gloss, s.analysis AS analysis, l.headword AS headword
|
||||
FROM surface s JOIN lemma l ON l.id = s.lemma_id
|
||||
WHERE s.form IN (${batch.map(() => "?").join(",")}) AND l.gloss_en <> ''
|
||||
ORDER BY CASE l.source WHEN 'curated' THEN 0 WHEN 'grammar' THEN 1 WHEN 'curriculum' THEN 2
|
||||
WHEN 'sentence' THEN 3 WHEN 'sfx' THEN 4 ELSE 5 END,
|
||||
l.freq_rank IS NULL, l.freq_rank`,
|
||||
batch,
|
||||
);
|
||||
for (const r of rows) {
|
||||
if (out.has(r.form)) continue;
|
||||
out.set(r.form, {
|
||||
ko: r.form,
|
||||
gloss: r.gloss,
|
||||
note: r.headword !== r.form ? r.analysis : "",
|
||||
from: "dictionary",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const w of declared ?? []) {
|
||||
if (w.ko && w.gloss && !out.has(w.ko)) out.set(w.ko, { ko: w.ko, gloss: w.gloss, note: w.note, from: "tutor" });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user