React + Vite + TypeScript, PWA, offline-first. Six tabs: 수업 오늘 단어 문장
문법 한글, plus the full-screen SRS review overlay, the reading drill, the
conjugation trainer and the 두벌식 keyboard.
The visual language is carried over deliberately: two hand-tuned palettes,
three type stacks, about a dozen component classes, zero border-radius and
no icons anywhere — Korean glyphs do the work icons would.
THE GATE is the reason this app exists. buildGate() already took a
vocabQuery hook; filling it with a band query is what turns 371 hand-typed
words into something that scales. Three refinements sit inside that hook,
all of them narrowing:
1. words a not-yet-finished unit is the first to introduce are excluded,
so a frequency ceiling cannot smuggle 3.4's material into 2.1;
2. Phase 1 is filtered by the phonological ladder;
3. the list is capped at 800 by frequency, because renderGate() inlines
it into the prompt — strictly more restrictive than the band, so it
cannot leak.
prompt/tutor-system.md ships unchanged with {{GATE}} filled by renderGate().
Confidence is clamped per turn. The artifact wrote the model's ::progress
number straight into the sole gate on advancement, so one hallucinated 95
skipped a unit.
stub-tutor.ts stands in for the model on the artifact's exact contract —
onText receives cumulative text, an aborted turn keeps what it streamed —
so the real endpoint drops in without touching the UI. It rotates all four
task types and climbs progress gradually, which makes every render path
reachable with no server.
Two artifact bugs are not ported: task state lived in the full-page
re-render, so anything arriving mid-answer wiped typed text and placed
chips; and the day number was computed once at module load, so a session
left open overnight scheduled against yesterday.
Verified in a browser: all six tabs work, and after a hard reload with the
network cut every tab still works — including dictionary search out of OPFS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
66 lines
2.2 KiB
TypeScript
66 lines
2.2 KiB
TypeScript
/* Multi-sentence ::gloss blocks.
|
|
|
|
The system prompt tells the tutor it may put several sentences in one
|
|
gloss block, each closed by its own "=" line. lib/blocks.js parse() sets
|
|
`en` when it meets "=" but never closes the block, so every sentence's
|
|
parts pile into one run-on line and only the last translation survives.
|
|
|
|
lib/ ships unchanged, so the fix lives here, at the call site: split the
|
|
block on its "=" lines and parse each sentence as its own single-sentence
|
|
block. The output is exactly what parse() would have produced if it closed
|
|
the block, so nothing downstream has to know.
|
|
|
|
(If lib/blocks.js is ever revised, the one-line fix there is `cur = null`
|
|
after setting `en`, and this module can go. test/lib/blocks.test.ts pins
|
|
the current behaviour so the change is visible when it happens.) */
|
|
|
|
import { parse } from "@lib/blocks.js";
|
|
import type { GlossBlock, ParsedMessage } from "@lib/blocks.js";
|
|
|
|
const GLOSS_BLOCK = /::gloss\s*\n([\s\S]*?)(?:\n::|$)/;
|
|
|
|
/** Split a gloss block's body into one chunk per "=" line. */
|
|
function splitSentences(body: string): string[] {
|
|
const out: string[] = [];
|
|
let current: string[] = [];
|
|
|
|
for (const line of body.split("\n")) {
|
|
const l = line.trim();
|
|
if (!l || l.startsWith("::")) continue;
|
|
current.push(l);
|
|
if (l.startsWith("=")) {
|
|
out.push(current.join("\n"));
|
|
current = [];
|
|
}
|
|
}
|
|
// A trailing sentence with no "=" is still worth rendering.
|
|
if (current.length) out.push(current.join("\n"));
|
|
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* parse(), with multi-sentence gloss blocks split correctly.
|
|
* Use this everywhere instead of calling parse() directly.
|
|
*/
|
|
export function parseMessage(text: string): ParsedMessage {
|
|
const parsed = parse(text);
|
|
if (!parsed.gloss) return parsed;
|
|
|
|
const match = text.match(GLOSS_BLOCK);
|
|
if (!match?.[1]) return parsed;
|
|
|
|
const sentences = splitSentences(match[1]);
|
|
if (sentences.length < 2) return parsed; // the common case; nothing to fix
|
|
|
|
const blocks: GlossBlock[] = [];
|
|
for (const s of sentences) {
|
|
const one = parse(`::gloss\n${s}\n::`);
|
|
if (one.gloss) blocks.push(...one.gloss);
|
|
}
|
|
|
|
return blocks.length ? { ...parsed, gloss: blocks } : parsed;
|
|
}
|
|
|
|
export type { GlossBlock, ParsedMessage };
|