feat(app): design system, shell, and the six tabs

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>
This commit is contained in:
MechaCat02
2026-09-08 19:13:53 +02:00
parent 7bd8507909
commit d44bc80098
45 changed files with 7012 additions and 0 deletions

185
app/src/domain/cards.ts Normal file
View File

@@ -0,0 +1,185 @@
/* SRS cards, over the database.
lib/srs.js owns the scheduling; this owns the queries. Note the
vocabulary: statusOf() calls a mature card "secure". The artifact said
"known" in its CSS and its filter and "secure" in the library — the app
uses the library's word everywhere. */
import type { Db } from "../db/types.js";
import { editCard, editCardReset, editStudyLog, seedCard } from "../db/writes.js";
import { grade, markKnown, newCard, statusOf, type Card, type CardStatus, type Grade } from "@lib/srs.js";
import { GOOD } from "@lib/srs.js";
export interface CardRow extends Card {
lemma_id: number;
}
export interface DeckEntry {
lemmaId: number;
headword: string;
pos: string;
glossEn: string;
source: string;
card: Card | null;
status: CardStatus;
}
const CARD_COLUMNS = "c.state, c.ease, c.interval, c.due, c.reps, c.lapses";
function toCard(row: Record<string, unknown>): Card | null {
if (row.state == null) return null;
return {
state: row.state as Card["state"],
ease: row.ease as number,
interval: row.interval as number,
due: row.due as number,
reps: row.reps as number,
lapses: row.lapses as number,
};
}
/**
* The reviewable deck: curated words and sentence chunks, not the whole
* dictionary. Reviewing 30,000 dictionary entries is not a study plan.
*/
const REVIEWABLE = "('curated', 'sfx', 'grammar')";
const SENTENCE_SOURCE = "('sentence')";
export interface DeckOptions {
/** Mix glossed sentence chunks in, per the session preference. */
sentences?: boolean;
/** Only this source — used by "practise these sentences". */
only?: "sentences";
}
function sourceClause(opts: DeckOptions): string {
if (opts.only === "sentences") return `l.source IN ${SENTENCE_SOURCE}`;
return opts.sentences
? `l.source IN ${REVIEWABLE} OR l.source IN ${SENTENCE_SOURCE}`
: `l.source IN ${REVIEWABLE}`;
}
export async function deck(db: Db, opts: DeckOptions = {}): Promise<DeckEntry[]> {
const rows = await db.all<Record<string, unknown>>(
`SELECT l.id AS lemmaId, l.headword, l.pos, l.gloss_en AS glossEn, l.source, ${CARD_COLUMNS}
FROM lemma l LEFT JOIN card c ON c.lemma_id = l.id
WHERE ${sourceClause(opts)}
ORDER BY l.headword`,
);
return rows.map((r) => {
const card = toCard(r);
return {
lemmaId: r.lemmaId as number,
headword: r.headword as string,
pos: r.pos as string,
glossEn: r.glossEn as string,
source: r.source as string,
card,
status: statusOf(card),
};
});
}
export interface Counts {
due: number;
fresh: number;
learning: number;
review: number;
secure: number;
total: number;
}
export async function counts(db: Db, today: number, opts: DeckOptions = {}): Promise<Counts> {
const entries = await deck(db, opts);
const out: Counts = { due: 0, fresh: 0, learning: 0, review: 0, secure: 0, total: entries.length };
for (const e of entries) {
if (e.status === "new") out.fresh++;
else {
out[e.status]++;
if (e.card && e.card.due <= today) out.due++;
}
}
return out;
}
/**
* The queue for a session: everything due, oldest first, then up to
* `newPerDay` unseen cards.
*/
export async function buildQueue(
db: Db,
today: number,
newPerDay: number,
opts: DeckOptions = {},
): Promise<DeckEntry[]> {
const entries = await deck(db, opts);
const due = entries
.filter((e) => e.card && e.card.due <= today && e.status !== "new")
.sort((a, b) => (a.card!.due ?? 0) - (b.card!.due ?? 0));
const fresh = entries.filter((e) => e.status === "new");
// Rotate the fresh pool by the day so it is not the same alphabetical
// prefix every morning, but is stable within a day.
const offset = fresh.length ? today % fresh.length : 0;
const rotated = [...fresh.slice(offset), ...fresh.slice(0, offset)].slice(0, Math.max(0, newPerDay));
return [...due, ...rotated];
}
/** Answer a card. The one write that stamps the clock for a review. */
export async function answer(
db: Db,
entry: DeckEntry,
g: Grade,
today: number,
): Promise<Card> {
const next = grade(entry.card ?? newCard(), g, today);
await editCard(db, entry.lemmaId, next);
await editStudyLog(db, today, { reviews: 1, correct: g >= GOOD ? 1 : 0 });
return next;
}
/** "I already know this" — jump straight to a secure interval. */
export async function markAsKnown(db: Db, lemmaId: number, today: number): Promise<void> {
await editCard(db, lemmaId, markKnown(today));
}
export async function forget(db: Db, lemmaId: number): Promise<void> {
await editCardReset(db, lemmaId);
}
/** Pre-schedule a card without it counting as something the learner did. */
export async function seed(db: Db, lemmaId: number, card = newCard()): Promise<void> {
await seedCard(db, lemmaId, card);
}
/* ── the study log ───────────────────────────────────────────────── */
export interface DayRow {
day: number;
reviews: number;
correct: number;
drills: number;
}
export async function studyLog(db: Db, sinceDay: number): Promise<DayRow[]> {
return db.all<DayRow>("SELECT * FROM study_log WHERE day >= ? ORDER BY day", [sinceDay]);
}
/** Consecutive days with any activity, counting back from today. */
export function streakFrom(rows: DayRow[], today: number): number {
const active = new Set(rows.filter((r) => r.reviews + r.drills > 0).map((r) => r.day));
let n = 0;
// Today not yet studied does not break a streak that ran to yesterday.
let day = active.has(today) ? today : today - 1;
while (active.has(day)) {
n++;
day--;
}
return n;
}
export type { Card, CardStatus, Grade };

View File

@@ -0,0 +1,188 @@
/* Loading the shipped dictionary into the database.
The build emits one gzipped row dump per band (tools/dict/build.mjs). They
are static assets in the app bundle — and in the APK's assets on Android —
so they are fetched from the app's own origin and NO SERVER IS INVOLVED.
When the sync layer lands, only the base URL changes.
Why row dumps rather than a .sqlite3 file: adopting a binary database
needs sqlite3_deserialize on web and copyFromAssets on native, which is
two code paths for one result. A row dump is one path on both, which is
what "same schema, same queries" has to mean. The build still emits
seed.sqlite3 for inspection with an ordinary client.
Bands load as the learner reaches them. The reference band — everything
the dictionary knows that no band admits — is loaded on demand, and is
never returned by the gate's vocabQuery. */
import type { Db } from "../db/types.js";
import { insertBand, seedMeta, type LemmaRow, type SurfaceRow } from "../db/writes.js";
import { REFERENCE_BAND } from "@shared/bands.mjs";
const BASE = `${import.meta.env.BASE_URL ?? "/"}dict/`;
export interface BandInfo {
band: number;
file: string;
lemmas: number;
surfaces: number;
bytes: number;
sha256: string;
reference: boolean;
}
export interface DictManifest {
builtWith: { dictionary: string; dictionaryEntries: number; frequencyForms: number };
totals: { lemmas: number; surfaces: number };
bands: BandInfo[];
attribution: string[];
notice: string;
}
interface BandPayload {
band: number;
columns: { lemma: string[]; surface: string[] };
lemmas: unknown[][];
surfaces: unknown[][];
}
let manifestPromise: Promise<DictManifest> | null = null;
export function loadManifest(): Promise<DictManifest> {
manifestPromise ??= fetch(`${BASE}manifest.json`).then((r) => {
if (!r.ok) throw new Error(`dictionary manifest: HTTP ${r.status}`);
return r.json() as Promise<DictManifest>;
});
return manifestPromise;
}
/** gzip's magic number. */
const GZIP_MAGIC = [0x1f, 0x8b];
/**
* Fetch and decode a band file.
*
* Whether the bytes arrive compressed depends on the host, and this app has
* three: the Vite dev server and most static hosts label a .gz file with
* `Content-Encoding: gzip`, so the browser has already decompressed it by
* the time we see it; Android's asset handler hands over the raw file. So
* sniff the magic number rather than trusting either the extension or the
* headers, and decompress only when it is actually still compressed.
*/
async function fetchBand(file: string): Promise<BandPayload> {
const res = await fetch(BASE + file);
if (!res.ok) throw new Error(`${file}: HTTP ${res.status}`);
const buffer = await res.arrayBuffer();
const head = new Uint8Array(buffer, 0, Math.min(2, buffer.byteLength));
const compressed = head[0] === GZIP_MAGIC[0] && head[1] === GZIP_MAGIC[1];
const text = compressed
? await new Response(
new Blob([buffer]).stream().pipeThrough(new DecompressionStream("gzip")),
).text()
: new TextDecoder().decode(buffer);
return JSON.parse(text) as BandPayload;
}
/** Positional rows back into objects, driven by the file's own column list. */
function toRows<T>(columns: string[], rows: unknown[][]): T[] {
return rows.map((row) => {
const out: Record<string, unknown> = {};
columns.forEach((c, i) => (out[c] = row[i] ?? null));
return out as T;
});
}
const LOADED_KEY = "dict.loadedBands";
/**
* Band loading is a read-modify-write on one meta row, and it runs from two
* places at once: the store pulls the learner's bands at boot while the word
* rail can ask for the reference band the moment someone searches. Without a
* lock the later write drops the earlier one's record and that band gets
* downloaded and re-inserted on every launch.
*/
let bandLock: Promise<unknown> = Promise.resolve();
function serialise<T>(fn: () => Promise<T>): Promise<T> {
const run = bandLock.then(fn, fn);
bandLock = run.then(
() => undefined,
() => undefined,
);
return run;
}
async function loadedBands(db: Db): Promise<Set<number>> {
const row = await db.get<{ v: string }>("SELECT v FROM meta WHERE k = ?", [LOADED_KEY]);
if (!row) return new Set();
try {
return new Set(JSON.parse(row.v) as number[]);
} catch {
return new Set();
}
}
async function rememberBand(db: Db, band: number): Promise<void> {
const loaded = await loadedBands(db);
loaded.add(band);
// Bookkeeping, not a user edit — it must never carry a write timestamp.
await db.run("INSERT OR REPLACE INTO meta (k, v, updated_at) VALUES (?, ?, 0)", [
LOADED_KEY,
JSON.stringify([...loaded].sort((a, b) => a - b)),
]);
}
/** Load one band if it is not already in the database. */
export function loadBand(db: Db, band: number): Promise<boolean> {
return serialise(async () => {
if ((await loadedBands(db)).has(band)) return false;
const manifest = await loadManifest();
const info = manifest.bands.find((b) => b.band === band);
if (!info) return false;
const payload = await fetchBand(info.file);
await insertBand(
db,
toRows<LemmaRow>(payload.columns.lemma, payload.lemmas),
toRows<SurfaceRow>(payload.columns.surface, payload.surfaces),
);
await rememberBand(db, band);
return true;
});
}
/**
* Everything up to and including `band`. Called at boot with the learner's
* current band, and again whenever advancing a unit widens it.
*/
export async function ensureBands(db: Db, upto: number): Promise<number[]> {
const added: number[] = [];
for (let b = 0; b <= upto; b++) {
if (await loadBand(db, b)) added.push(b);
}
return added;
}
/**
* The reference band: words the dictionary knows that no band admits. Kept
* out of the gate entirely — this exists so the word rail can gloss
* something met in the wild, not so the tutor can teach it.
*/
export async function ensureReferenceBand(db: Db): Promise<boolean> {
return loadBand(db, REFERENCE_BAND);
}
export async function isReferenceLoaded(db: Db): Promise<boolean> {
return (await loadedBands(db)).has(REFERENCE_BAND);
}
/** Record which dictionary produced the shipped data, for the About panel. */
export async function recordProvenance(db: Db): Promise<void> {
const m = await loadManifest();
await seedMeta(db, "dict.source", m.builtWith.dictionary);
await seedMeta(db, "dict.attribution", m.attribution.join("\n"));
}

165
app/src/domain/gate.ts Normal file
View File

@@ -0,0 +1,165 @@
/* 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.
*/
export function assemblePrompt({ template, gate, recent, focus }: PromptInputs): string {
return template
.replace("{{GATE}}", renderGate(gate))
.replace("{{VARIETY}}", varietyLine(recent))
.replace("{{FOCUS}}", focusLine(focus));
}
export { renderGate, REFERENCE_BAND };
export type { Gate, ProgressState, FlatUnit };

65
app/src/domain/gloss.ts Normal file
View File

@@ -0,0 +1,65 @@
/* 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 };

143
app/src/domain/lexicon.ts Normal file
View File

@@ -0,0 +1,143 @@
/* Word lookup, against the database.
In the artifact this was an in-memory Map plus a fallback that stripped
one of 24 particles off a token and tried again — a hand-rolled stand-in
for a morphological analyser. It is gone. Every conjugated form the
learner will meet was generated by lib/conjugation.js surfaceForms() at
BUILD time and stored in `surface`, so a lookup is now an index hit. */
import type { Db } from "../db/types.js";
import { REFERENCE_BAND } from "@shared/bands.mjs";
export interface Entry {
lemmaId: number;
headword: string;
pos: string;
glossEn: string;
glossKo: string;
freqRank: number | null;
level: string | null;
unitBand: number;
source: string;
/** How the searched form relates to the headword, when they differ. */
analysis?: string;
/** The form actually looked up, which may be a conjugation. */
form?: string;
}
const COLUMNS = `
l.id AS lemmaId, l.headword, l.pos, l.gloss_en AS glossEn, l.gloss_ko AS glossKo,
l.freq_rank AS freqRank, l.level, l.unit_band AS unitBand, l.source`;
const SELECT = `SELECT ${COLUMNS} FROM lemma l`;
/** The same columns, joined through the surface index. */
const SELECT_VIA_SURFACE = `
SELECT ${COLUMNS}, s.form AS form, s.analysis AS analysis
FROM lemma l JOIN surface s ON s.lemma_id = l.id`;
/** Prefer a curated gloss, then a common word, then anything. */
const RANKED = `
ORDER BY CASE l.source WHEN 'curated' THEN 0 WHEN 'grammar' THEN 1
WHEN 'sentence' THEN 2 WHEN 'sfx' THEN 3 ELSE 4 END,
l.freq_rank IS NULL, l.freq_rank`;
/**
* Look one written form up. Tries the headword first, then the surface
* index, so 먹었어 resolves to 먹다 with "반말 past" as its analysis.
*/
export async function lookup(db: Db, form: string): Promise<Entry | undefined> {
const direct = await db.get<Entry>(`${SELECT} WHERE l.headword = ? ${RANKED} LIMIT 1`, [form]);
if (direct) return { ...direct, form };
const viaSurface = await db.get<Entry & { analysis: string }>(
`${SELECT_VIA_SURFACE} WHERE s.form = ? ${RANKED} LIMIT 1`,
[form],
);
return viaSurface ? { ...viaSurface, form } : undefined;
}
/**
* Android's SQLite caps bound parameters at 999, so an IN (...) built from
* however many words a message happens to contain has to be batched. See the
* note on MAX_PARAMS in db/writes.ts.
*/
const MAX_IN = 900;
function batches<T>(items: T[], size: number): T[][] {
const out: T[][] = [];
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
return out;
}
/** Look up many forms at once — the word rail does this per message. */
export async function lookupMany(db: Db, forms: string[]): Promise<Map<string, Entry>> {
const out = new Map<string, Entry>();
const wanted = [...new Set(forms)].filter(Boolean);
if (!wanted.length) return out;
for (const batch of batches(wanted, MAX_IN)) {
const holes = batch.map(() => "?").join(",");
for (const row of await db.all<Entry>(
`${SELECT} WHERE l.headword IN (${holes}) ${RANKED}`,
batch,
)) {
if (!out.has(row.headword)) out.set(row.headword, { ...row, form: row.headword });
}
}
const missing = wanted.filter((w) => !out.has(w));
for (const batch of batches(missing, MAX_IN)) {
const holes = batch.map(() => "?").join(",");
for (const row of await db.all<Entry & { form: string; analysis: string }>(
`${SELECT_VIA_SURFACE} WHERE s.form IN (${holes}) ${RANKED}`,
batch,
)) {
if (!out.has(row.form)) out.set(row.form, row);
}
}
return out;
}
/** Every Korean run in a message — what the rail scans for. */
export const koreanTokens = (text: string): string[] => text.match(/[가-힣]+/g) ?? [];
export interface SearchOptions {
limit?: number;
/** Include the reference band. The rail's search does; the gate never. */
includeReference?: boolean;
}
/**
* `%` and `_` are LIKE wildcards. Typing either into the search box would
* otherwise match everything rather than searching for the character.
*/
const escapeLike = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
/** Substring search over 한글 and English, for the rail and the vocab tab. */
export async function search(db: Db, query: string, opts: SearchOptions = {}): Promise<Entry[]> {
const q = query.trim();
if (!q) return [];
const limit = opts.limit ?? 60;
const bandClause = opts.includeReference ? "" : `AND l.unit_band < ${REFERENCE_BAND}`;
const safe = escapeLike(q);
const like = `%${safe}%`;
return db.all<Entry>(
`${SELECT}
WHERE (l.headword LIKE ? ESCAPE '\\' OR l.gloss_en LIKE ? ESCAPE '\\') ${bandClause}
ORDER BY l.headword = ? DESC,
l.headword LIKE ? ESCAPE '\\' DESC,
l.freq_rank IS NULL, l.freq_rank
LIMIT ?`,
[like, like, q, `${safe}%`, limit],
);
}
/** How many rows are actually loaded — shown in the About panel. */
export async function stats(db: Db): Promise<{ lemmas: number; surfaces: number }> {
const l = await db.get<{ n: number }>("SELECT count(*) AS n FROM lemma");
const s = await db.get<{ n: number }>("SELECT count(*) AS n FROM surface");
return { lemmas: l?.n ?? 0, surfaces: s?.n ?? 0 };
}

124
app/src/domain/progress.ts Normal file
View File

@@ -0,0 +1,124 @@
/* Where the learner is on the roadmap.
The confidence number is the whole advancement mechanism: the tutor
reports it in a ::progress block, and at 85 the app offers the next unit.
The prompt forbids the tutor from offering advancement in prose — the app
owns that affordance.
ONE THING THE ARTIFACT DID NOT DO: clamp the per-turn delta. Confidence
was written straight from a model-emitted number, so a single hallucinated
`::progress 95` on turn two advanced the unit. The prompt asks for "a few
points, not thirty", but asking is not enforcing. applyProgressReport()
enforces it. */
import type { Db } from "../db/types.js";
import { editUnitConfidence, editUnitState, seedProgress } from "../db/writes.js";
import { UNITS, unitIndex } from "./gate.js";
import type { FlatUnit, ProgressState } from "@lib/gate.js";
/** Confidence at which the app offers the next unit. */
export const READY_AT = 85;
/** Where "not yet" parks it — below the threshold, so the banner goes away. */
export const NOT_YET = 70;
/**
* The most confidence a single turn may add. The tutor is told to move it
* "a few points, not thirty"; this is what makes that true.
*/
export const MAX_DELTA_PER_TURN = 12;
export const FIRST_UNIT = UNITS[0]!.id;
export interface ProgressRow {
unit_id: string;
state: "todo" | "now" | "done";
confidence: number;
updated_at: number;
}
export async function readProgress(db: Db): Promise<ProgressState> {
const rows = await db.all<ProgressRow>("SELECT * FROM progress");
const done: Record<string, boolean> = {};
const confidence: Record<string, number> = {};
let current = FIRST_UNIT;
for (const r of rows) {
if (r.state === "done") done[r.unit_id] = true;
if (r.state === "now") current = r.unit_id;
confidence[r.unit_id] = r.confidence;
}
// A stored unit that no longer exists (curriculum v3 → v4) falls back to
// the furthest finished unit rather than stranding him.
if (unitIndex(current) < 0) {
const finished = UNITS.filter((u) => done[u.id]);
const last = finished[finished.length - 1];
current = last ? (UNITS[unitIndex(last.id) + 1]?.id ?? last.id) : FIRST_UNIT;
}
return { current, done, confidence };
}
/** First run. Seeded, so it must not carry a write timestamp. */
export async function initProgress(db: Db): Promise<void> {
await seedProgress(db, FIRST_UNIT);
}
export const currentUnit = (p: ProgressState): FlatUnit =>
UNITS.find((u) => u.id === p.current) ?? UNITS[0]!;
export const nextUnit = (p: ProgressState): FlatUnit | null =>
UNITS[unitIndex(p.current) + 1] ?? null;
export const isReady = (p: ProgressState): boolean =>
(p.confidence?.[p.current] ?? 0) >= READY_AT && nextUnit(p) !== null;
/**
* Apply a ::progress report, clamped. Returns what was actually stored, so
* the caller can tell the difference between the tutor's claim and reality.
*/
export async function applyProgressReport(
db: Db,
progress: ProgressState,
reported: number,
): Promise<{ stored: number; clamped: boolean }> {
const unit = progress.current;
const before = progress.confidence?.[unit] ?? 0;
const asked = Math.max(0, Math.min(100, Math.round(reported)));
// Downward corrections are always honoured — the tutor noticing he has
// NOT got it is information worth keeping. Only the climb is rate-limited.
const stored = asked <= before ? asked : Math.min(asked, before + MAX_DELTA_PER_TURN);
await editUnitConfidence(db, unit, stored);
return { stored, clamped: stored !== asked };
}
/** Move on: finish the current unit and make the next one current. */
export async function advanceUnit(db: Db, progress: ProgressState): Promise<string | null> {
const next = nextUnit(progress);
if (!next) return null;
await db.tx(async (tx) => {
await editUnitState(tx, progress.current, "done");
await editUnitState(tx, next.id, "now");
});
return next.id;
}
/** Jump to a unit from the roadmap panel, without marking anything done. */
export async function goToUnit(db: Db, progress: ProgressState, unitId: string): Promise<void> {
if (unitId === progress.current) return;
await db.tx(async (tx) => {
// The unit being left keeps whatever state it had, unless it was current.
const wasDone = progress.done[progress.current];
await editUnitState(tx, progress.current, wasDone ? "done" : "todo");
await editUnitState(tx, unitId, "now");
});
}
/** "Not yet" — park confidence below the threshold to dismiss the banner. */
export async function stayOnUnit(db: Db, progress: ProgressState): Promise<void> {
await editUnitConfidence(db, progress.current, NOT_YET);
}

View File

@@ -0,0 +1,192 @@
/* The stub responder.
No server in this pass, so 선생님 is a local stand-in. What matters is
that it implements the SAME contract the real model will, so swapping in
the Pi's SSE endpoint later touches nothing above this file:
sample(messages, { signal, onText }) -> Promise<{ text }>
Two details of that contract are easy to get wrong and are honoured here:
onText receives CUMULATIVE text rather than deltas, and an aborted turn
rejects with { code: "cancelled" } while keeping whatever it had streamed.
The stub is not a toy. It rotates all four exercise types, builds its
::words block from the current unit's real vocabulary looked up in the
database, emits a ::gloss from data/sentences.json, and climbs
::progress a few points a turn — so every render path in the tutor tab,
including the 85% advancement banner, can be reached without a model. */
import type { Gate } from "@lib/gate.js";
import sentencesJson from "@data/sentences.json";
export interface SampleMessage {
role: "user" | "assistant";
content: string;
}
export interface SampleOptions {
signal?: AbortSignal;
/** Called with the WHOLE text so far, not the delta. */
onText?: (update: { text: string }) => void;
}
export interface SampleResult {
text: string;
truncated?: boolean;
}
export type Sample = (messages: SampleMessage[], opts?: SampleOptions) => Promise<SampleResult>;
export class SampleError extends Error {
code: string;
text?: string;
constructor(code: string, message?: string, text?: string) {
super(message ?? code);
this.code = code;
this.text = text;
}
}
interface Sentences {
sentences: { lvl: string; ko: string; en: string; parts: [string, string][] }[];
}
const SENTENCES = (sentencesJson as unknown as Sentences).sentences;
const TASK_ORDER = ["translate", "match", "build", "choice"] as const;
export type StubTaskType = (typeof TASK_ORDER)[number];
/** A word the stub may use, with the gloss the rail will show. */
export interface StubWord {
ko: string;
gloss: string;
note?: string;
}
export interface StubContext {
gate: Gate;
/** Vocabulary for this unit, already looked up. */
words: StubWord[];
/** How many exercises have been answered in this unit so far. */
turn: number;
confidence: number;
}
/* ── block builders ──────────────────────────────────────────────── */
const wordsBlock = (words: StubWord[]): string =>
["::words", ...words.map((w) => `${w.ko} | ${w.gloss}${w.note ? ` | ${w.note}` : ""}`), "::"].join(
"\n",
);
/** Roles are assigned by position: last chunk is the predicate. */
function glossBlock(): string {
const s = SENTENCES[Math.floor(SENTENCES.length / 3)];
if (!s) return "";
const rows = s.parts.map(([ko, en], i) => {
const role = i === s.parts.length - 1 ? "V" : i === 0 ? "S" : "O";
return `${ko} | ${role} | ${en}`;
});
return ["::gloss", ...rows, `= ${s.en}`, "::"].join("\n");
}
function taskBlock(type: StubTaskType, words: StubWord[]): string {
const pick = words.slice(0, 6);
if (!pick.length) return "";
switch (type) {
case "translate":
return ["::task translate", ...pick.slice(0, 4).map((w) => w.ko), "::"].join("\n");
case "match":
return ["::task match", ...pick.map((w) => `${w.ko} | ${w.gloss}`), "::"].join("\n");
case "build": {
const s = SENTENCES[0];
if (!s) return "";
return ["::task build", `${s.en} | ${s.parts.map((p) => p[0]).join(" | ")}`, "::"].join("\n");
}
case "choice": {
const [a, b, c] = pick;
if (!a || !b) return "";
const options = [a.ko, b.ko, c?.ko].filter(Boolean).join(" | ");
return ["::task choice", `Which one means "${a.gloss}"? | ${options}`, "::"].join("\n");
}
}
}
/* ── the reply ───────────────────────────────────────────────────── */
function composeReply(ctx: StubContext): string {
const { gate, words, turn, confidence } = ctx;
const type = TASK_ORDER[turn % TASK_ORDER.length]!;
const opening = turn === 0;
const prose = opening
? [
`**${gate.unit.id} · ${gate.unit.ko}** — ${gate.unit.name}.`,
"",
gate.unit.goal,
"",
gate.unit.teaches.map((t) => `This unit adds: ${t}`).join("\n"),
"",
"(선생님 is not connected in this build — this is the local stand-in, so the",
"exercises are generated from your current unit rather than written for you.",
"Every block the real tutor emits is rendered the same way.)",
].join("\n")
: [
"Good — that is the shape of it. Two landed cleanly; keep an eye on the last one.",
"",
"Here is the next set.",
].join("\n");
const parts = [prose];
if (opening) {
const g = glossBlock();
if (g) parts.push("", g);
}
const task = taskBlock(type, words);
if (task) parts.push("", task);
if (words.length) parts.push("", wordsBlock(words));
// Climbs slowly, so the 85% banner is reachable but not on turn two.
const next = Math.min(100, confidence + (opening ? 4 : 9));
parts.push(
"",
`::progress ${next} | ${next < 60 ? "still bedding in" : next < 85 ? "mostly there" : "ready to move on"}`,
);
return parts.join("\n");
}
export interface StubOptions {
/** Milliseconds between streamed chunks. 0 replies at once. */
chunkDelay?: number;
}
/**
* Build a Sample that streams a generated reply. `context()` is called per
* turn so the stub always sees the current gate rather than a stale one.
*/
export function makeStubTutor(context: () => StubContext, opts: StubOptions = {}): Sample {
const delay = opts.chunkDelay ?? 18;
return async (_messages, options = {}) => {
const { signal, onText } = options;
const full = composeReply(context());
if (signal?.aborted) throw new SampleError("cancelled");
if (!onText || delay <= 0) return { text: full };
// Stream by line, cumulatively — the contract the real endpoint has.
const lines = full.split("\n");
let sent = "";
for (const line of lines) {
if (signal?.aborted) throw new SampleError("cancelled", "stopped", sent);
sent += (sent ? "\n" : "") + line;
onText({ text: sent });
await new Promise((r) => setTimeout(r, delay));
}
return { text: full };
};
}