Three unit-1.1 lessons with gpt-oss-20b through LM Studio and the server —
the first real model on the reworked turn. The letter-level check went out
right, and the +25 clamp held: a reported 80 on the first answer was stored
as 25. What failed was how the model wrote its blocks, a different way each
session. All three transcripts are in test/fixtures/, verbatim, and each
failure below is a test against them.
Marks lost. The prompt asks for `여덟 | wrong | 여덜`. The first session wrote
`we | wrong | 우라 → 우리`, English prompt first; the second wrote no ::result
at all and marked only in prose, `✗ 나 | I (humble) → 저`. evidence.ts keys on
the first field of a ::result row, so nothing was ever recorded — no
evidence, no schedule, no confusions, and a 다지기 review that could never
close. The artifact would have lost them the same way. domain/marking.ts
attaches each mark to its word only where that is unambiguous: one Korean
word first, or through a prompt of the exercise he answered, read via that
exercise's ::words as the letter check reads it. With no ::result block the
✓/✗ lines are read on the same terms, so a mark can never name a word the
exercise did not ask for; a mark on a whole sentence is still dropped. What
he mistook a word for is taken from what he actually wrote whenever the mark
itself gives no other word — the third session put the right answer there.
Its third session, marked through all of this: 20 evidence rows, 20 cards.
Progress on requests. The prompt allows marks, ::confirmed and ::progress
only in reply to an answer. The model wrote ::progress on every message, and
three requests for a new exercise took the unit from 50% to 80% with nothing
answered. A reply to anything but an answer now changes none of them.
Feedback swallowed. The model closed no blocks, so lib read what followed
each one as rows: "your score is about 5%" became a result row the student
never saw, and a "---" became a recall item he was asked to write in 한글.
Another session fenced every block in ```. gloss.ts now decides every
block's extent from the raw text — at "::", the next block, a rule or fence
line, a blank line with no row after it, or for the piped blocks the first
line without a "|" — and hands lib the blocks properly closed. The gate
audit, now run through the parser the lesson uses, still flags 7 and 2.
Answers given away. Translate rows came with their meanings ("나 | I") and
recall hints were the answers ("two | 이"). A translate row keeps only its
Korean line, and a recall hint that is the expected word, or any word the
message declares, is dropped.
Also: the spelling a recall prompt expects now keeps its qualifiers. With 나
"I, me (casual)" and 저 "I, me (humble)" in one list, "I (humble)" matched
나: no letter check was sent, and the mark for 저 was filed under 나.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Hankan · 한칸
A Korean reading tutor for manhwa. One codebase, two shells: a web app and an Android app. It works entirely offline; a server is optional.
Ported from a single-file Claude artifact, and migrated to its reworked 16 Sep export. The curriculum, the tutor prompt, the logic modules and the checks that measure them came finished and tested; this repo is the application built around them.
What is here
data/ lib/ prompt/ fixtures/ verbatim from the export bundle — do not edit
validate.mjs audit-gate.mjs the bundle's own checks, and run-checks.sh to run both
shared/ band table, phonological ladder, lemma ids, sync protocol
types/ TypeScript declarations for lib/ and shared/
tools/dict/ the dictionary build pipeline
app/ Vite + React + TypeScript, and the Capacitor shell
src/db/ one storage interface, two SQLite drivers, the write rules
src/domain/ the gate, the resolver, the turn, the learner model, SRS
src/sync/ protocol 2: hydrate, compare-and-swap, resolve
src/ui/shell/ routes over history, the nav, layers, popovers
src/ui/tutor/ the lesson: conversation, exercises, answer mode, word lookup
src/ui/tabs/ 오늘, 설정, 단어, 문장, 문법, 활용 연습, 한글, 읽기 연습
src/ui/review/ the review screen
public/dict/ generated dictionary — committed, shipped
server/ sync + the tutor endpoint (optional)
tools/icons/ the 한칸 mark, generated
test/ lib goldens, drivers, domain, the gate audit, sync, UI history
data/, lib/, prompt/, fixtures/, validate.mjs and audit-gate.mjs
are byte-identical to the export and excluded from lint. Where the artifact's
inline code and lib/ disagree, the port follows lib/, because lib/ is
what audit-gate.mjs measures. Where lib/ is looser than PORT.md, the
stricter rule sits at the call site and lib/ stays untouched. Types for
lib/ live alongside in types/.
Running it
npm install
npm run dict:build # only needed if app/public/dict/ is missing or stale
npm run dev # http://localhost:5173
npm run check # validate.mjs, the gate audit, typecheck, tests, roadmap assertion
npm run build # production build + service worker
Android
The Capacitor project lives at app/android/ and is committed. It needs the
Android SDK, which this repo does not install:
export ANDROID_HOME=/path/to/Android/Sdk
npm run build
npm run cap:sync
cd app/android && ./gradlew assembleDebug
npx cap sync copies app/dist/, dictionary included, into the APK's assets,
so the phone build is as offline as the web one.
How it works
Storage: one interface, two drivers
app/src/db/ exposes a single Db interface. sqlite.web.ts runs
@sqlite.org/sqlite-wasm in a dedicated Worker over the OPFS SAHPool VFS
(the plain OPFS VFS needs COOP/COEP headers, which neither a static host nor the
Capacitor webview reliably provides). sqlite.native.ts uses the Capacitor
SQLite plugin. Both apply the same migrations from migrations.ts.
Seeded state never carries a write timestamp. The artifact had a sync bug
where a fresh device stamped its own empty defaults as newer than the server's
real history and clobbered it. db/writes.ts splits every mutation into
seedX() (never stamps, never dirty) and editX() (stamps, marks the row
dirty, bumps its revision), and it is the only file allowed to read the clock.
An ESLint rule enforces that, and the conformance suite asserts a freshly seeded
database has nothing to push.
The dictionary
npm run dict:build merges a dictionary source, a frequency list, the curated
data in data/ and a hand-written grammar lexicon, then runs
lib/conjugation.js → surfaceForms() over every verb and adjective to fill the
surface table. Looking up a conjugated form is an index hit, not an analysis.
No runtime morphological analyser ships.
A card names its word, not a build position. A lemma's id is a hash of its
headword and part of speech (shared/lemma-id.mjs), so rebuilding the
dictionary can never quietly move a card onto a different word. The band files
carry no ids at all, and a changed build reloads its rows without touching
cards. Every word the roadmap introduces is exactly one card, tagged with its
unit, including the sixteen forms (봐, 갔어, 먹었어 …) no dictionary lists.
Sources are chosen automatically: KRDICT if a download has been vendored (it
has curated learner glosses and a graded 초급/중급/고급 level), otherwise the
kaikki.org Korean extract. KRDICT's download page is a JavaScript form behind
anti-bot protection, so a script cannot fetch it. The comment at the top of
tools/dict/fetch.mjs explains.
Frequency needs care: a subtitle frequency list holds surface forms while a
dictionary holds lemmas, and a naive join on the headword gives every verb a
frequency of roughly zero. tools/dict/freq-forms.mjs inverts the join,
expanding each lemma into the forms it plausibly takes and summing. That
recovers 하다 from 118 to 89,041.
Output is one gzipped row dump per band, committed under app/public/dict/.
Attribution is in NOTICE.md; share-alike attaches to the
dictionary data, not to this code.
The gate: what the tutor may use
lib/gate.js computes what the tutor may teach from the curriculum and
progress, and renderGate() renders it into {{GATE}} in
prompt/tutor-system.md, which ships unchanged.
One resolver. "Which dictionary word is this form?" is asked when the
learner taps a word and when the gate checks the tutor. lib/lexicon.js
answers both, with the SQLite dictionary behind it only where it has no route
(domain/resolver.ts). The order was measured on the bundle's 54 real tutor
messages: the dictionary alone flags 4 and 2 (too weak; 마셔 becomes 마시다
again), lib/lexicon.js first flags 7 and 2, the shipped gate's numbers.
test/domain/gate-audit.test.ts reproduces the audit through the app's own
database, loader and resolver.
What is allowed: the words of finished units, the current unit's words and revisits, every word he has met (any card out of "new"), and, from Phase 2, the frequency band. The words the tutor is told it may use and the words it is checked against are one set.
The client enforces; the prompt only explains. Every reply is scanned before
he sees it (scanTask, proseIsKorean). A refused draft is never stored,
shown or applied: the tutor is asked again and told exactly why, twice at most.
After that the reply is shown with its words flagged, and the next turn names
them.
The turn: what marking may change
The turn lives in domain/turn.ts, where it can be tested without React.
- Progress is earned.
::progressis ignored until the unit has an answer. A rise is capped at +25 per message and a fall is honoured in full. The next unit is offered only at 85% with three answers behind it, and in a 다지기 review only once nothing on its checklist is open.advanceUnit()enforces this too, not only the banner. - Recall evidence.
::resultfeeds a per-word record kept apart from the schedule. A word is learned after three correct answers in separate rounds, at least five rounds apart, none after a lookup. The schedule takes at most one good grade a day from marking; in the artifact, five good rounds in one afternoon made a word "secure" by interval alone. - Phase reviews. The client holds the 다지기 checklist, each unit's rule
and every word the phase introduced (132 items for Phase 1), worked in
batches of ten.
::confirmedticks a rule on the tutor's word but a word only on evidence. - The letter-level check. The model cannot see jamo, so a recall answer
carries the app's own comparison (
letterCheck) for the tutor to quote. - The prompt keeps its cache. The per-round sections (the practice set, the checklist, retry notes) follow the shipped prompt as a second, uncached system block, so the unchanged prefix still hits Anthropic's prompt cache and a local server's KV cache.
The screen
Mobile-first, after the reworked artifact. Five destinations: 선생님, 복습 (review), 오늘, 단어, 학습. They sit in a bottom bar under 600px, a 76px rail to 840px and a labelled rail beyond. The page never scrolls; each route owns one scroller.
- Routes are history (
ui/shell/history.ts). The back gesture leaves a sub-page, and the word sheet, a popover and the review screen are layers whose entries Back closes first. The rules are tested against a history whose traversals land late, as a browser's do. - The lesson is one column: roadmap strip, conversation, quick replies, composer. The word list is docked from 840px and a sheet with three detents below it, taking its height from the shell so nothing being answered is covered.
- Answer mode. Below 840px, focusing an exercise's answer strips the screen back to the question, the field and an answer bar. The 한글 keyboard follows whichever field was touched last and comes up by itself for a recall exercise.
- Any word can be looked up, in three tiers: an underline on every Korean word showing what it is to him, a popover beside it (and English → 한국어 on an exercise's English), and the full word list. A chip glosses on a long press or with 힌트. Every lookup counts as a lookup in the answer.
The stand-in tutor
app/src/domain/stub-tutor.ts implements the same contract as the real
endpoint and exercises every path offline: all five exercise types, ::words
from the current unit, ::result, ::confirmed, and progress reported only
after answers. The app is complete with no server.
Checks
| Command | What it guards |
|---|---|
sh run-checks.sh |
validate.mjs (the curriculum, PASS — 0 blocking, 0 advisory) and audit-gate.mjs (the word gate against 54 real tutor messages, 7 of 41 and 2 of 13 flagged) |
npm test |
lib goldens, driver conformance, the seed rule, the gate audit through the port, the turn, sync against a real Postgres, UI history |
npm run dict:assert |
371/371 roadmap words resolve, each as exactly one card |
npm run typecheck · npm run lint |
types, hooks, and the clock guard on src/db/ |
CI runs run-checks.sh first, before anything is installed. The sync tests need
Postgres and a running server; .github/workflows/ci.yml starts both, and
server/README.md shows how locally.
Sync and the tutor: optional
The app is complete without a server: its own SQLite, the shipped dictionary, and the stand-in tutor. Pointing it at a Pi adds two things, the real 선생님 and syncing between devices. When the server is unreachable, everything falls back to offline behaviour, and a failed send puts the message back in the box.
Sync is built around three rules, each bought with lost work in the artifact:
- Hydrate before pushing. A device pulls everything first. A week-old laptop cannot overwrite a week of work on the phone.
- A counter, not a clock. A row is written only if the device last saw the server's current version of it. Clock skew decides nothing.
- No silent shrinking. When two copies disagree, the one that holds more wins: more reviews, more answers, the further unit, the union of notes. A deliberate deletion (a reset, a cleared lesson, a forgotten card) is declared and obeyed.
Setup, the Caddy config and the protocol: server/README.md.
Not in this pass
Android beyond the existing Capacitor scaffold, and Play Store packaging.
Known limitation
lib/blocks.js parse() builds a message's prose by deleting each block with a
pattern that also eats the :: opening the block after it. It also runs a block
on to the next ::, so when a gloss block is missing its closing :: (local
models forget it routinely) the paragraph after it is read as gloss rows. Since
lib/ ships unchanged, app/src/domain/gloss.ts reads the prose and the gloss
blocks from the raw text; the header of that file has the details.