ab9b75ff32ac0ec591bcd2c375aa20fb4d7ee3b0
23 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ab9b75ff32 |
feat(tutor): look any word up — the underline, the popover, the word list
The reworked artifact made every Korean word on the lesson screen a way in to the dictionary, in three tiers. This ports them. 1. The underline. Every Korean run in a message, a gloss or an exercise is a button whose underline says what the word is to him: amber new, blue learning, jade in review, faint when secure or merely explainable, dotted when nothing knows it. A form carries its dictionary word's state — 갔어 is 가다's — through the one resolver the gate uses (domain/words.ts). The artifact gave "in review" no colour at all. 2. The popover, beside the word: the meaning, "+ Add to deck" (the dictionary entry, so 먹었어 adds 먹다), "Ask 선생님" for a word he was shown without being taught, a search, the word list. English on an exercise's English side opens its Korean, from an index of the course's own material — never the frequency bands, whose thousands of glosses would bury the answer. An answer chip's tap belongs to the exercise, so a 420ms hold glosses it, or a tap with 힌트 on; the hold's click is swallowed before the exercise sees it. Pressing a word never takes focus from the answer being typed, and the popover no longer closes when the window resizes — on a phone that was the keyboard moving. 3. The word list gains + / ✓ on every row, search results included. Every lookup, whichever tier, goes into the answer's "I had to look up" and the peek tally — counted under the Korean looked at; the artifact counted an English lookup under the English word. A dictionary word added this way is studied: a card on an entry outside the curated sources now counts as a deck word, in review and in "my units". Before, it got a card that no review would ever show. The word list glosses through the same resolver as the popover and the gate, rather than the surface table alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3a22350c37 |
feat(ui): the mobile shell — routes with real history, five destinations, the lesson as one screen
The reworked artifact went mobile-first; this ports its shell, minus the
quirks it shipped with.
Routes. #lesson #today #settings #words #learn #sent #grammar #cj #hangul
#drill, each a history entry, so the phone's back gesture works. The word
sheet, a popover and the review screen are layers: each owns an entry while
open, and Back closes the topmost before it leaves a route — the artifact
read window.__onBack but never set it. shell/history.ts holds the rules,
free of React and tested against a history whose traversals land late, as a
browser's do. Unlike the artifact's router:
· re-tapping the current destination pushes nothing;
· leaving a route closes what is open on it, rewinding its entries first;
· a sub-page's ← goes back only when history leads to its parent, and
otherwise becomes the parent — history.length > 1 let ← leave the app.
Shell. The page never scrolls; each route owns one scroller, in svh. A
bottom bar under 600px, a 76px rail to 840px, a labelled rail beyond, with
the artifact's icons and a due badge on 복습. Routes mount on first visit and
stay mounted, so a draft or a drill survives a trip elsewhere. Settings,
the conjugation trainer and the reading drill are pages of their own; 학습
is a hub. The design tokens gain the layout set and a second register:
reference panels stay square, what a thumb works is rounded.
복습 is its own screen: tap anywhere to reveal, grades in the thumb zone,
and an empty queue says so instead of doing nothing. Its pool is "my
units" once there are twenty such words, as in the artifact — plus his own
words, which the artifact dropped from review at that point.
The lesson is a column that fits the screen: roadmap strip, conversation,
composer with the quick-reply chips, and a ··· menu for focus and clearing.
The word list is docked from 840px; below that it is a sheet with peek,
half and full detents and a drag handle, whose height comes off the shell
so the exercise above shrinks rather than being covered. Focusing an answer
drops it to peek; a new exercise takes it down from full.
Fixed on the way: the log now follows a reply by what the learner did, not
by distance — its own scroll event arrived after the stream had added more
than the threshold, which read as scrolling away and stopped the follow
mid-reply. Sending re-sticks. The keyboard's focus guard moves to
pointerdown; React 19 attaches touchstart passively, so preventDefault()
there was ignored.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
a1c86d9550 |
feat(tutor): the turn enforced — retries, evidence, the 다지기 checklist, earned progress
"The client enforces; the prompt only explains." Every rule the artifact's tutor was merely asked to follow, it broke: it certified words on one correct answer, scored a unit before anything was answered, used a word from three phases ahead, answered in Korean, and invented spelling diagnoses. The reworked app fixed each by making the client refuse. This ports those refusals; domain/turn.ts holds the turn, testable without React. The gate. A reply is scanned before he sees it — the side of the exercise he must decode, through the one resolver, and its prose for Korean. A refused draft is never stored, shown or applied: the tutor is asked again and told exactly why. After two retries the reply is shown with its words flagged, and the next turn names them. (The artifact's follow-up told the tutor it could declare such a word in ::words; that contradicts the gate and is left out.) Marking. ::result feeds recall evidence per word. lib/srs.js is looser than PORT.md, so the call site tightens it: one outcome per word per round, and "learned" also needs five rounds between the first and last CORRECT answer — lib alone counted a wrong answer as the start of the span. A lookup is never recall. What he mistook a word for is kept. 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. ::confirmed ticks a rule on the tutor's word but a word only on evidence; "-item" puts one back; anything off the list is ignored. Progress is earned: ignored until the unit has an answer, +25 at most per message, a fall honoured in full, and the next unit only at 85% with three answers — plus, in a review, nothing open. advanceUnit() enforces it too, not only the banner. The prompt gains a per-round tail after the shipped prompt — the practice set (scored on the evidence, round-robin by word class, each word with the words one letter away), the checklist, retry notes — sent as a second, uncached system block so the stable prefix still caches. Also: recall answers carry the letter-level jamo comparison (kept out of his own bubble, since it is written to the model); match chips are keyed by pair index, the bug PORT.md names; and the stand-in tutor exercises every path offline — recall, ::result, ::confirmed, progress only after answers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
089f303ff9 |
feat(gate): one resolver, the words he has met, and the audit through the port
The bundle's audit-gate.mjs measures the word gate against 54 real tutor
messages. Scored the same way, the port's gate was not the gate that was
measured:
port's allowed set 13 · 2 over-fires on words he had met
port's dictionary resolver 4 · 2 too weak: 마셔 → 마시다 again
lib/lexicon.js, DB behind it 7 · 2 the shipped gate
domain/resolver.ts makes lib/lexicon.js the one resolver for word taps and
the gate, built from the shipped data in the audit's order — roadmap words
first, so a scheduled form cannot inherit its stem's permission — with the
dictionary consulted only where lexicon.js has no route. That fallback is
what the port adds over the artifact: a word the curated data does not know
is still recognised as a real word.
The allowed set gains every word he has met (any card out of "new"), as
the shipped gate has it; the frequency band stays on top from Phase 2, as
PORT.md specifies. The words the tutor is TOLD it may use and the words it
is CHECKED against are one set, so it is never refused for a word it was
offered.
test/domain/gate-audit.test.ts reproduces the audit through the app's own
code — database, loader, resolver — and gets the shipped 149-word allowed
set and the same 7 and 2 messages, word for word.
Also: {{VARIETY}} and {{FOCUS}} take the artifact's wording and know the
fifth exercise type, recall; and the prompt's maintainer header, which
explains the placeholders, is no longer sent to the model.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
9ab5aba6b3 |
feat(sync): protocol 2 — hydration, a counter not a clock, no silent shrinking
The reworked bundle's PORT.md makes row-level last-write-wins conditional
on three gates, each learned by losing real data. The port's sync broke
all three, and had four more ways to lose or stall work. Both ends change,
so this is one protocol version, refused by the other side if mismatched.
Gate 1, hydration. A device now pulls every page the server holds before
it may push anything; it used to push first. The 14 Sep laptop — a
week-old copy re-stamped at boot and pushed over a week of phone work —
is now a test, and the phone's week survives it. Boot writes nothing
syncable either: the lesson opens with the app's own words (the artifact's
seeded turn) and waits for Start, instead of stamping a reply and a
progress edit before a server can even be configured.
Gate 2, a counter. Every row remembers the change_seq it last agreed with
(base_seq). The server applies a write only if that still matches —
compare-and-swap under an advisory lock — and otherwise returns its copy
as a conflict. No clock is compared anywhere: a device an hour fast used
to win every conflict for an hour, and a slow one's newer edit was
silently dropped with HTTP 200. dirty and rev replace the timestamp
watermark, which lost edits whenever a clock moved backwards.
Gate 3, no silent shrinking. A conflict is settled by what each copy
holds (sync/resolve.ts): more reviews, more evidence, a finished unit, the
further roadmap position, the union of learned grammar. A deliberate
shrink is explicit: a reset or a cleared lesson raises a marker every
device obeys, including its own unsynced edits, so a reset is not undone
by a device that had not heard of it. Trimming the transcript is local
and tombstones nothing — it used to delete the other device's turns.
Also fixed on the way:
· keys travel as JSON arrays — a space in 몇 명 used to stop every
device's pull at that row, permanently;
· pulls take a shared lock against pushes, so a change_seq committed
out of order can no longer be skipped;
· study_log and peek are per device and summed, so two devices' reviews
of one day both count;
· each user's data has an epoch; a server that lost it is detected,
and the device re-hydrates and offers its data back;
· a protocol-1 client is refused with 426 rather than half-understood.
Migration 9 adds the columns, re-keys the counters and tombstones; the
server drops protocol-1 rows once (none were deployed). Verified: 14
two-device scenarios against a real Postgres, and two browser profiles
syncing a lesson through the UI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
bf9b5950da |
feat(db): a roadmap without 'now' rows, chat ids two devices can share, the learner-model tables
Three storage changes the reworked app needs, as migration 8. Progress. "Which unit is current" was a 'now' state on each unit's row. Two devices that advanced could leave two of them, and leaving a finished unit through the roadmap panel wrote it back to 'todo' — goToUnit un-finished work. Where he is now lives in one place, meta road.unit; a unit's row records only what is true of that unit: done, confidence, and room for the answer count and the tutor's note that earned progress needs. The migration carries the most recently written 'now' row across with its own stamp. Chat. Turn ids were INTEGER PRIMARY KEY — max+1 on whichever device wrote them, restarting at 1 after a clear — so two devices continuing a lesson both wrote turn 201 and sync treated two different turns as one row. Ids are UUIDv7 now, and the transcript is ordered by (created_at, id). Existing turns become legacy:<device>:<n>, zero-padded so turns sharing a timestamp keep the order they were written in; their tombstones are renamed with them. The learner model gets its tables: evidence (lib/srs.js's record, plus the rounds of the first and last CORRECT answer, which PORT.md measures and lib does not), confusion, and phase_ledger for the 다지기 checklist, with a confirmed flag so putting an item back is an edit rather than a delete. A roadmap reset now clears the ledger and road.*; a full wipe also clears evidence, confusions and learner.* — the artifact's wipe left its round counter and confusion list behind. What a learner knows about words survives a roadmap-only reset, as it should. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
48987b96ae |
feat(dict): every roadmap word is a card, tagged with its unit
The reworked app's learner model hangs off cards: recall evidence, the phase-review checklist, the practice set, and the gate's "words he has met". So every word a unit introduces has to be studiable — and 16 of the 371 were not. Eleven existed only as sentence chunks or dictionary rows outside the review deck, and five (봐 읽어 갔어 봤어 먹었어) nowhere at all. The build now marks exactly one reviewable lemma per roadmap word with the unit that introduces it. Where the deck has the word, its row is chosen deterministically (deck order, then source, then part of speech) — the artifact tagged whichever card came last, which put the evidence for 이, 눈 and 저 on the wrong meaning. The sixteen get a `curriculum` lemma of their own, glossed from the curated verb they conjugate (자 is "sleep", the 반말 of 자다 — not the dictionary's "ruler"), else from the sentence that uses them, else the dictionary. Lemmas also carry their topic, which the vocabulary filters need. dict:assert gains the guarantee: 371/371 roadmap words as one card each. Migration 7 adds the two columns; the rows arrive with the dictionary reload a changed build now triggers on its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f8183d786f |
feat(dict): stable lemma ids — a card names its word, not a build position
Cards point at lemmas by id, and an id was the entry's position in the sorted build. One word added near the top of the dictionary would have moved every card below it onto a different word — silently, because loaded bands were recorded by number and a rebuilt dictionary never reached an existing install anyway. A custom word took max(id)+1 on whichever device added it, so the same id meant different words on a phone and a laptop. An id is now lemmaId(headword, pos), a 53-bit hash defined once in shared/ and used by the build, the loader, custom words and the migration. The build asserts all 30,520 entries are collision-free, and a test pins the function itself, since changing it re-keys every card. Band files are format 2: they carry no ids at all. The loader derives each id from the word, and a surface names its lemma by row index in the same file. Writing hashed ids out cost 0.5 MB of incompressible digits; leaving them out makes the files smaller than before (1.1 MB -> 1.0 MB). The loaded dictionary is now versioned by its band hashes, so a rebuild reloads on the next boot — safe only now that a reload cannot move a card. Migration 6 re-keys an existing install without stamping anything: cards and their tombstones move through the lemma rows still loaded, custom words become custom_word rows (the learner's data, which can travel) carrying the time their card was made, and the dictionary is dropped to reload. Sync is paused until the protocol that replaces it lands: the server still holds rows under the old ids, and exchanging them would plant cards that name no word. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ba3055912a |
fix(tutor): find gloss blocks in the raw text — the new parse() still leaks
The 16 Sep lib fixed both defects gloss.ts used to work around: a gloss
block closes at each "=" line, and every block is collected. So the
sentence splitting goes.
Three defects remain in how a block ENDS, measured on the new lib:
· deleting one block still deletes the "::" of the next, so a whole
::words block reaches the prose as "words" plus its rows;
· an unclosed gloss still swallows the following paragraph as parts;
· two gloss blocks written back to back share one terminator, and lib
never sees the second.
The old rebuild also only re-parsed the FIRST gloss block, which against
a lib that accumulates would have dropped every block after it.
One pass over the raw text now yields both the prose and each gloss
block's content, a gloss block ending where its rows end; lib parses each
block on its own, so the row format is still entirely lib's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
e72b77d6c2 |
chore: take in the 16 Sep bundle — lib, curriculum v5, prompt, gate audit
The artifact was reworked after real incidents: a week of lost data, a
student taught out of order, and spelling diagnoses the model invented.
This takes the new export in verbatim; the port catches up in the
commits that follow.
Copied byte-identical from the bundle:
lib/ lexicon.js and sync.js are new; gate.js gains enforcement,
hangul.js letter-level marking, srs.js recall evidence,
conjugation.js deconjugate(); blocks.js now takes the last
block, closes gloss at "=", and parses recall, ::result and
::confirmed
data/ curriculum.json v5 — six 다지기 phase reviews; the 371
roadmap words are unchanged and no band moves
prompt/ English-only rule, recall, LETTER-LEVEL CHECK, marking
audit-gate.mjs, run-checks.sh, fixtures/ — the word gate measured
against 54 real tutor messages
CI runs run-checks.sh in place of validate.mjs alone, and `npm run check`
gains the audit. Baselines: validate PASS 0/0; audit 7 of 41 and 2 of 13.
types/lib/ declares the new API, and test/lib/ pins it: letterCheck on
the prompt's own 짧다/빫다 case, deconjugation, the roadmap-first order
that keeps 마셔 out of Phase 1, sync's three gates, and recall evidence —
including the two ways lib's evidence is looser than PORT.md, pinned as
they are so the call site that tightens them is visibly needed.
TaskHost gains a plain recall renderer so the tree typechecks against the
wider Task union.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
75dd699f3e |
fix(tutor): read the message's prose from the raw text, not parse()'s body
Reported again: "::task translate" and its four lines showing as text above
the exercise they had been rendered into. The previous fix dropped
everything from the first line beginning "::", and could not see this one,
because by the time it ran the colons were gone:
**Example sentence**
*나 바다*
task translate <- the "::" removed, the items left as prose
나 바다
lib/blocks.js removes each block by string surgery, and a block's
terminator `(?:\n::|$)` is inside its own match, so removing one block
takes the two colons belonging to the NEXT one with it. Last time the
casualty was ::task before ::words; this reply put ::gloss first and the
casualty was ::task. Repairing that body cannot be made to work in general.
So the body is no longer repaired, it is derived: proseOf() reads the RAW
text, where the markers are always intact, and keeps every line outside a
block. The grammar is small -- a line of exactly "::" closes, any other
"::" line opens, a block runs until closed, until the next opens, or to the
end -- and nothing compares exact strings, because this model ends every
line with markdown's two trailing spaces.
An unclosed ::gloss is handled too, and every local model tested forgets
that closer. lib then reads the following paragraph as gloss parts, so two
sentences of English rendered as Korean example text inside the card. A
gloss row is `한글 | English | note` and a translation starts with "=";
anything else ends the block. The stranded prose goes back into the
message, and the gloss is now rebuilt unconditionally rather than only for
multi-sentence blocks, since the single-sentence case is exactly where this
bites.
Separately, MessageBody degrades gracefully on markdown instead of showing
it raw. The prompt forbids all of it and says so outright, but the backend
is pluggable now and a local model ignores the rule: a bulleted list of the
ten consonants arrived as lines starting with a hyphen, "*What we learn:*"
kept its asterisks, "---" showed as three dashes. Bullets, headings, rules
and *italic* now render quietly.
Verified by serving the exact reported reply through the real app: 10
bullets rendered as rows, no literal markup, no directive text, the gloss
card holding only 나 | 바다 = "I sea", and the exercise as real UI.
Also worth recording: three browser checks in this session were reading a
stub-generated turn synced down from Postgres, not the model. The app boots
on 수업, so the local stand-in writes the opening turn before a server can
be configured, and a fresh client then pulls the old transcript. Sending a
message after connecting is what actually exercises the remote path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
9a925a5e3f |
fix(gate): the gate was being spliced into the prompt's header sentence
assemblePrompt used template.replace("{{GATE}}", ...), and String.replace
with a string argument substitutes only the FIRST occurrence. All three
placeholders appear twice in tutor-system.md, because the document names
them in its own header paragraph before using them:
Assembled per turn. `{{GATE}}` is `renderGate()` from `lib/gate.js`;
`{{VARIETY}}` and `{{FOCUS}}` are one-liners built from recent state.
So the rendered gate replaced the backticked mention mid-sentence, and the
real slot further down was sent to the model as the literal text "{{GATE}}".
The mechanism that decides what the tutor is allowed to teach was delivered
in the wrong place, with a template token standing where it belonged, and
the same for VARIETY and FOCUS. Confirmed by capturing what the app
actually put on the wire: three unfilled placeholders at lines 60, 86 and
141.
Substitution is now anchored to a whole line, which is what distinguishes a
slot from a mention -- the header's are inline and backticked. The
replacement is a function because renderGate() output contains "$"
sequences that String.replace would otherwise interpret.
The existing tests could not have caught this. They ran against a synthetic
template naming each placeholder exactly once, which is precisely the
property the shipped file lacks. The new ones run against
prompt/tutor-system.md itself: no slot may survive unfilled, the header
must come through intact, and the gate must land between the profile and
the pre-flight check. Both fail against the old code.
Also appends a HOUSE STYLE section after the shipped prompt -- an addition
by the app, not an edit to the file, which still ships byte-identical.
It covers two things the prompt leaves to inference. The language of
explanation is never actually stated: "한글 and English only" is a rule
about retiring romanization, and everything else only implies English. A
strong model infers it; gpt-oss-20b delivered a full grammar lesson in
Korean to a student on unit 1.1 who cannot yet read it. And no-markdown is
stated outright at line 96 and was ignored anyway, so it is restated where
the consequence is visible: the app renders **bold** and nothing else, so a
table arrives as rows of literal pipes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
7b9c92eb98 |
fix(tutor): raw ::task markup rendered as prose above the exercise
Reported from the app: "::task translate" and its five sentences appeared
as text in the message, directly above the exercise those same lines had
been rendered into.
It is lib/blocks.js, not the model. parse() removes ::words by truncating
the body at its index, then removes ::task by substring:
if (w) body = body.slice(0, body.indexOf("::words"));
if (t) body = body.replace(t[0], "");
RE.task's terminator (?:\n::|$) is part of the match, so t[0] ends with the
"\n::" belonging to the ::words that follows -- the two colons the line
above just truncated away. The substring no longer occurs, replace() is a
no-op, and the whole task block stays in the body.
Order is the whole trigger. The stub tutor emits ::words before ::task and
is therefore fine; the local model emitted ::task first. Nothing in the
prompt requires either order, so this was always reachable -- Claude would
hit it too. It survived every test until a real model chose the other way.
lib/ ships unchanged, so the fix is at the call site, next to the ::gloss
workaround that is there for the same reason: parseMessage() drops the body
from the first surviving directive line on. Safe precisely because parse()
has already removed the blocks it handled correctly, so a "::" still in the
body is by definition one that leaked. That also subsumes the streaming
filter added earlier, which is now one rule instead of two.
A turn can also be nothing but blocks -- this model writes no prose around
an exercise at all -- which left an empty bubble above it. The bubble is
skipped when there is nothing to put in it, and the typing dots stay up
while the reply so far is only markup, since there is genuinely nothing to
read yet.
test/domain/block-leak.test.ts pins the lib behaviour as it is, and the
workaround against a verbatim capture of the model output that produced the
report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
074f602494 |
feat(server): an OpenAI-compatible backend, so the model is yours to pick
HANKAN_TUTOR_BACKEND=openai talks to anything serving /chat/completions -- LM Studio, Ollama, llama.cpp, vLLM, LiteLLM, OpenRouter, OpenAI. The TutorBackend seam already existed for this, so the model becomes a config line rather than a code change. Written against fetch rather than the openai package. The Anthropic SDK alone is 14MB in the image, this backend uses one endpoint with no tools and no retries, and local servers are the ones most likely to deviate from an SDK's expectations. The real risk in hand-rolling it is SSE reassembly, so that is where the tests are: a JSON payload split across two TCP reads, an event whose blank-line terminator lands in the next read, heartbeat comments, CRLF framing, and a stream that ends without [DONE]. The two split cases both fail against a naive per-read parser, which is what makes them worth having. <think> blocks are stripped from the stream, tags split across chunks included. Reasoning models served locally often emit chain-of-thought inline in `content` rather than in a separate field, and left in it lands in the lesson transcript where the block parser reads it as prose. WHAT THIS COSTS: prompt caching. The Anthropic backend marks the ~12k character gate as a cached prefix, so every turn after the first reads it at a fraction of the input price. There is no portable equivalent, so against a paid hosted endpoint the system prompt is re-billed every turn -- the biggest cost lever in the design, gone. Against a local model it costs nothing, and the shape still pays: llama.cpp and LM Studio reuse their KV cache for an unchanged prefix. Measured on a 6,948-character prompt against gpt-oss-20b, first token 1,563ms cold and 324ms warm, so the system prompt goes first and stays put here too. Verified against LM Studio running openai/gpt-oss-20b, not only a fake: a turn streams from the browser through this server to the model and back, rendered in the chat, no page errors. Also makes test/server/http.test.ts backend-agnostic. It asserted the echo backend's wording and so failed the moment the server was pointed at a real model -- precisely the case a transport test should survive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b48a5f8fb1 |
fix(server): CORS, without which the phone could never reach the Pi
The Android build always talks to the Pi cross-origin -- a Capacitor webview serves the app from http://localhost, not from your domain -- so the browser sends a preflight OPTIONS first. It is not permitted to attach an Authorization header to that. Auth ran before anything else, so the preflight came back 401 and the real request was never attempted. There were no Access-Control-Allow-* headers either, so even a successful preflight would not have helped. Verified from an actual page before the fix: GET /api/sync and POST /api/tutor both "Failed to fetch" -- an opaque network error that points at the network rather than at middleware order. CORS now runs first and answers OPTIONS itself. Any origin is allowed by default, which is not a hole: the gate is a bearer token rather than a cookie, so a hostile page gains nothing from being allowed to send a request it cannot authenticate. HANKAN_ALLOWED_ORIGINS narrows it. backends/echo.ts is a keyless backend that reflects the request back in chunks. Deploying involves a container, a reverse proxy, a token, CORS and an SSE stream that has to survive compression -- five things that break independently, none of which involve Anthropic. HANKAN_TUTOR_BACKEND=echo proves all five from the phone before a key exists and before anything is billed. CI now runs the server that way, so the tutor endpoint is exercised over real HTTP rather than only against an injected mock. test/server/http.test.ts covers the preflight, the allow-origin header on real responses, Vary: Origin, that a bad token is still refused, and that the SSE stream parses and terminates with a done event. Verified end to end in a browser: the Pi configured through the settings panel, sync pushing 2 rows and a second sync moving 0 (the pushedAt watermark holding), the header switching from "local stand-in" to "connected", and a turn streaming back over SSE with the 8,859-character system prompt intact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0b04931699 |
fix: a NUL byte was hiding two source files from grep, git and diff
TaskHost.tsx used a literal NUL as the delimiter in its drag-and-drop payload, and tools/dict/build.mjs used one to join headword and part of speech. Both work at runtime. Both also make the file *binary* to every text tool: git shows "Bin 12259 bytes" instead of a diff, and grep prints nothing at all for a match. That is not hypothetical. Searching TaskHost.tsx for "<input" came back empty three times while reviewing it, which is how its four exercise inputs came to be reported as absent -- and why the accessibility defect in them went unseen. Written as the escape \u0000 the value is identical and the file stays text. test/source-hygiene.test.ts fails on any control byte in a source file, so this cannot come back quietly. With the files readable again, the sweep the NUL had been hiding: eleven form controls had no accessible name. The exercise blanks announced only an ellipsis, and the part-of-speech select announced nothing. A placeholder is not a label -- it disappears the moment you type. All eleven now carry one, named after the thing they answer. `npm run lint` gains --max-warnings 0. exhaustive-deps is configured as a warning, so a hooks-dependency bug would have passed CI silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7275e156df |
feat(app): drop the known-words seed, and make a full wipe actually wipe
The seed pre-marked the artifact's 30 headwords secure on first run. Two reasons it is gone rather than merely disabled: It was never 30 cards. The match was on headword, and homographs each carry their own lemma, so 그 as pronoun and as determiner both matched — 47 rows for a 30-word list. Worse, "Reset everything" deleted the cards and then deleted the 'seed.known' guard along with the other meta keys, so the next boot re-seeded and the deck looked untouched. The one thing a wipe exists for, undone by the wipe itself. Migration 5 clears the seed from installs that already have it. seedCard() is the only writer that leaves updated_at = 0 on a card and the seed was its only caller, so `DELETE FROM card WHERE updated_at = 0` removes exactly the seeded rows and nothing the learner graded — the timestamp rule paying for itself a second time. test/domain/reset.test.ts pins both scopes: what a full wipe must leave empty, what a roadmap reset must keep, that the deletions are tombstoned so a sync cannot restore them, and that migration 5 really runs against a schema-4 database rather than the test performing the delete itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
66f92247d2 |
feat(tutor): the real endpoint, streamed, with the system prompt cached
POST /api/tutor takes the assembled system prompt, the transcript the
client owns, and the new message, and streams tokens back. It holds
nothing between requests, so a dropped connection costs one turn rather
than the conversation.
The seam moved first: Sample took the assembled prompt as messages[0] with
role user. It now has an explicit system field, which is what lets the
backend put it in the API's system parameter as a cached block. The gate
is ~12k characters and is byte-identical for as long as the learner stays
in one unit, so every turn after the first reads the prefix at a fraction
of the input price. That is the single biggest cost lever in the design,
and it was unreachable through the old shape.
prompt/tutor-system.md still ships unchanged; only where the string is
placed changed.
SSE has three rules that are silent when broken, and all three are
handled: every event ends with a blank line, payloads are JSON-encoded
because a raw newline in Korean text would break the framing, and a `:`
heartbeat every 15s keeps intermediaries from timing the stream out.
Cache-Control is set on the returned Response rather than inside
streamSSE, which writes its own and would overwrite it; `no-transform` is
there because compression, not buffering, is what usually makes SSE look
like it hangs behind a proxy.
The client uses fetch + getReader, not EventSource — EventSource cannot
POST, and the body is {system, history, message}. Aborting closes the
connection, the server aborts upstream, and a cancelled turn stops
billing. With no server configured the app falls back to the stub, so the
offline build is untouched.
backends/anthropic.ts is the default. backends/agent-sdk.ts is deliberately
unimplemented and documents why the plain API was chosen over PORT.md's
Agent SDK — chiefly that its prompt accepts only user-role messages, so
the transcript would have to be flattened into a single turn.
The endpoint's own tests use a mock backend and need no API key.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
c2e1fc23fe |
feat(server): the sync endpoints, on Node 22 with no build step
Hono and pg, run under --experimental-strip-types, so the deployed thing is the source. GET /api/sync?cursor=N pages rows above the cursor; POST /api/sync upserts last-write-wins. Bearer token on everything under /api; /health is open, for the container healthcheck. Rows are stored generically — primary key as text, body as JSONB — because the server never reads inside a row. It stores and orders them and the client interprets them, which keeps the two schemas from having to move in lockstep. change_seq is bumped by a BEFORE UPDATE trigger rather than by the write path. A row edited after a client last pulled would otherwise keep its old sequence, sit below that client's cursor, and never be delivered; putting it in the database means no future write path can forget. The last-write-wins comparison is in the ON CONFLICT clause itself, so a losing row is not written at all and does not bump change_seq — a conflict does not become traffic for every other device. test/sync/roundtrip.test.ts runs two clients against a real Postgres and asserts what actually goes wrong in sync: that a fresh client's seeded rows cannot overwrite the server's history (the artifact's bug, as an executable test), that a delete propagates, and that dict.loadedBands never crosses the wire. It skips without HANKAN_TEST_SERVER, so npm test still runs anywhere. POST /api/test/reset exists only when HANKAN_TEST_MODE=1, so it cannot be reached on the Pi even if the token leaks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d44bc80098 |
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>
|
||
|
|
7bd8507909 |
feat(dict): build pipeline, grammar lexicon, and the shipped band files
Closes REVIEW.md §2. 167 of 371 roadmap words had no lexicon entry, so the word rail silently showed nothing. Now: Roadmap words: 371/371 resolve Spiral targets: 35/35 resolve Deck words: 386/386 resolve and npm run dict:assert makes it a blocking build failure, not a silent empty rail. No runtime morphological analyser ships. lib/conjugation.js surfaceForms() runs at BUILD time over every verb and adjective, so looking up a conjugated form is an index hit on the surface table. The frequency join had to be inverted. A subtitle frequency list holds surface forms; a dictionary holds lemmas whose -다 citation form barely occurs in running text, so joining on headword gives verbs a frequency of roughly zero. Expanding each lemma into the forms it plausibly takes and summing recovers 하다 from 118 to 89,041. Forms claimed by more than one lemma are dropped rather than split, so homographs don't inherit each other's mass. Those expansions score frequency only — the surface table itself stays strictly surfaceForms() output plus the headword. Bands are one per curriculum phase. Phase 1 admits no frequency band at all: during the writing-system phase every word must be phonologically legal for the unit reached, and a rank ceiling would hand the learner a 겹받침 during unit 1.4. shared/phonology.mjs lifts validate.mjs's own feature ladder to enforce that; it agrees with the validator on all 371 words. Sources are chosen automatically — KRDICT when vendored, otherwise the kaikki.org extract. KRDICT's download is a JS form behind anti-bot protection, so it cannot be fetched by CI; the derived band files are committed instead, which the app needs offline regardless. Attribution and the share-alike terms are in NOTICE.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bbe6302a9b |
feat(db): one storage interface, two SQLite drivers
Nothing above db/ knows which driver it got. sqlite.web.ts runs
@sqlite.org/sqlite-wasm in a dedicated Worker; sqlite.native.ts uses the
Capacitor plugin. Both apply the same migration array.
The web driver uses the OPFS SAHPool VFS rather than the plain opfs VFS:
the latter needs COOP/COEP cross-origin isolation headers, which neither a
static host nor the Capacitor webview reliably provides. Same storage,
fewer deployment constraints.
THE TIMESTAMP RULE. 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 make that unrepresentable rather than merely
avoided:
1. updated_at INTEGER NOT NULL DEFAULT 0 on every syncable table, so
forgetting the column is the SAFE failure — a row that loses every
last-write-wins comparison, not one that wins them all.
2. writes.ts splits every mutation into seedX() (never stamps) and
editX() (always stamps), and is the only file allowed to read the clock.
3. An ESLint rule enforces that, and the conformance suite asserts a
freshly seeded database has no non-zero updated_at anywhere.
Two batching limits are sized for the platform we cannot test here: Android
links the system SQLite, historically capped at 999 bound parameters, while
sqlite-wasm allows 32766. A multi-row insert sized for the browser would
fail only on the phone, at first launch, loading the dictionary. Both the
band insert and the word-rail lookup batch under the smaller ceiling, and
test/db/limits.test.ts fails at 3600 if that regresses.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
966043ab3f |
feat(lib): type declarations and golden tests for the exported modules
lib/ ships unchanged, so its types live in types/lib/ and are wired up by a
tsconfig path mapping. Adding types costs nothing; reimplementing the logic
would cost the two things that make this port possible.
The tests exist so a later refactor cannot silently drift them:
hangul the nine Composer cases named in the export README —
먹어 · 왔어 · 읽어 · 괜찮아 · 값 · 의사 · 뭐야 and backspace
conjugation all seven irregular classes, and every form in
IRREGULAR_FORMS reachable through haeche()
srs the SM-2 transitions, ease and interval clamps
blocks parse → answerText round-trip for all four task types
101 tests.
One is a pinned defect rather than a guarantee. blocks.parse() never closes
a ::gloss block on its "=" line, so a multi-sentence gloss — which the tutor
prompt explicitly invites — collapses into one run-on line keeping only the
last translation. lib/ ships unchanged, so the test records the real
behaviour and the app works around it at the call site.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|