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>
This commit is contained in:
159
README.md
Normal file
159
README.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# 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
|
||||||
|
4–6. 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.
|
||||||
25
server/README.md
Normal file
25
server/README.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# server/ — not in this pass
|
||||||
|
|
||||||
|
PORT.md steps 4–6 land here: the sync API, the tutor SSE endpoint, and the
|
||||||
|
Docker Compose deployment beside the Pi's existing Postgres and Caddy.
|
||||||
|
|
||||||
|
Nothing here yet, on purpose. Steps 1–3 give a fully working offline app with
|
||||||
|
no server at all, and that is the version that gets used first.
|
||||||
|
|
||||||
|
What is already shaped for it:
|
||||||
|
|
||||||
|
- every syncable table carries `updated_at`, and seeded rows are pinned to 0
|
||||||
|
(see `app/src/db/writes.ts`) — the artifact's clobbering bug cannot be
|
||||||
|
expressed;
|
||||||
|
- `change_seq` is deliberately absent: it is server-assigned and arrives with
|
||||||
|
the sync layer;
|
||||||
|
- the dictionary is fetched by URL from `app/src/domain/dictionary.ts`, so
|
||||||
|
per-band deltas can come from the Pi instead of the bundle by changing a base
|
||||||
|
URL;
|
||||||
|
- the tutor goes through one `Sample` function
|
||||||
|
(`app/src/domain/stub-tutor.ts`). The real endpoint implements the same
|
||||||
|
contract — `onText` receives cumulative text, and an aborted turn stops
|
||||||
|
billing — so only that file changes.
|
||||||
|
|
||||||
|
When the SSE endpoint lands, Caddy needs `flush_interval -1` on the proxy or
|
||||||
|
the stream buffers and the tutor appears to hang.
|
||||||
Reference in New Issue
Block a user