Files
Hankan/README.md
MechaCat02 f73582913b docs: README and server placeholder
README covers how the pieces fit: the storage interface, the inverted
frequency join, the gate's three refinements, and the checks that guard
each one.

server/ holds a README only. Steps 1-3 give a fully working offline app with
no server at all, and that is the version that gets used first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 19:13:53 +02:00

160 lines
7.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Hankan · 한칸
A Korean reading tutor for manhwa. One codebase, two shells: a web app and an
Android app. **Works entirely offline — there is no server.**
Ported from a single-file Claude artifact. The curriculum, the tutor prompt and
the five logic modules came finished and tested; this repo is the application
built around them.
---
## What is here
```
data/ lib/ prompt/ validate.mjs verbatim from the export bundle — do not edit
shared/ band table + phonological ladder, shared by app and build
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
src/domain/ the gate, the lexicon, SRS, the stub tutor
src/ui/ six tabs, the review overlay, the 한글 keyboard
public/dict/ generated dictionary — committed, shipped
test/ golden tests for lib/, driver conformance, domain logic
server/ placeholder; PORT.md steps 4-6
```
`data/`, `lib/`, `prompt/` and `validate.mjs` are byte-identical to the export
and are excluded from lint and formatting. `lib/conjugation.js` encodes the seven
Korean irregular classes and is the reason no runtime morphological analyser is
needed; `lib/hangul.js` implements 두벌식 composition. Types are supplied
alongside in `types/`, so neither file had to be touched.
## Running it
```bash
npm install
npm run dict:build # only needed if app/public/dict/ is missing or stale
npm run dev # http://localhost:5173
```
```bash
npm run check # validate.mjs, 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:
```bash
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/` — including the dictionary — 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 migration array 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. Three layers stop that from being expressible:
every syncable table declares `updated_at INTEGER NOT NULL DEFAULT 0`, so
forgetting the column is the safe failure; `db/writes.ts` splits every mutation
into `seedX()` (never stamps) and `editX()` (always stamps) and is the only file
allowed to read the clock; an ESLint rule enforces that, and the conformance
suite asserts a freshly seeded database has no non-zero `updated_at` anywhere.
### 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. Lookup of a conjugated form is therefore an index hit, not an
analysis — **no runtime morphological analyser ships**.
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 it cannot be fetched by a script — see the comment at
the top of `tools/dict/fetch.mjs`.
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 — which
recovers 하다 from 118 to 89,041.
Output is one gzipped row dump per band, committed under `app/public/dict/`.
They are static assets in the bundle and in the APK's assets, so no server is
involved. Attribution is in [NOTICE.md](NOTICE.md); share-alike attaches to the
dictionary data, not to this code.
### The gate
`lib/gate.js` computes what the tutor may teach from the curriculum plus
progress, and `renderGate()` renders it into `{{GATE}}` in
`prompt/tutor-system.md`, which ships unchanged. `buildGate()` takes a
`vocabQuery` hook; `app/src/domain/gate.ts` fills it with a frequency-band query
so vocabulary grows as units are finished. 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
band ceiling cannot smuggle 3.4's material into 2.1;
2. during Phase 1 the results are filtered by the same phonological ladder
`validate.mjs` checks, so a band cannot hand the learner a 겹받침 at 1.4;
3. the list is capped at 800 by frequency, because `renderGate()` inlines it
into the prompt. The cap is strictly more restrictive than the band.
Confidence is clamped to `MAX_DELTA_PER_TURN` per turn. The artifact wrote the
model's number straight into the advancement gate, so one hallucinated
`::progress 95` could skip a unit.
### The tutor
`app/src/domain/stub-tutor.ts` stands in for the model and implements the exact
contract the real endpoint will (`onText` receives cumulative text; an aborted
turn keeps what it streamed). It rotates all four exercise types, builds its
`::words` from the current unit's real vocabulary, and climbs `::progress`
gradually, so every render path — including the 85% advancement banner — is
reachable with no server. Swapping in the Pi's SSE endpoint later touches only
that file.
## Checks
| Command | What it guards |
|---|---|
| `node validate.mjs` | the curriculum. **PASS — 0 blocking, 0 advisory** |
| `npm test` | lib golden tests, driver conformance, the seeded-timestamp rule, the gate |
| `npm run dict:assert` | **371/371 roadmap words resolve** (was 167 unglossable) |
| `npm run typecheck` · `npm run lint` | types, and the clock guard on `src/db/` |
CI runs them in that order, `validate.mjs` first.
## Not in this pass
No sync layer, no tutor server endpoint, no Play Store packaging — PORT.md steps
46. The schema carries `updated_at` on every syncable row and the dictionary is
fetched by URL, so both land without rework.
## Known limitation
`lib/blocks.js` `parse()` does not close a `::gloss` block on its `=` line, so a
multi-sentence gloss collapses into one run-on line. Since `lib/` ships
unchanged, `app/src/domain/gloss.ts` splits the block and parses each sentence
separately. The behaviour is pinned in `test/lib/blocks.test.ts`; if the library
is ever revised, the one-line fix is `cur = null` and both workarounds go.