Nihongo Context: Learning Japanese Expressions in Real Sentences
A full-stack study app that shows the sentence first, explains only what you tap, and brings each expression back in the same, then a similar, then a new context. New sentences are generated ahead of time by an LLM and validated before anyone sees them.
01Why I built it
Textbook study and exam preparation did not get me closer to following everyday Japanese on YouTube. That takes recognising many words, grammar patterns and set phrases inside real sentences, and flashcards strip them out of the sentences they live in. Nihongo Context turns this around. You read a Japanese sentence with the translation hidden, tap only what you do not know, and read what that expression means in this sentence. The app then decides when the expression should come back and in which context, starting with the sentence where you first met it and moving on to sentences you have never seen. The same interaction also runs on 171 fixed sentences without logging in, next to hiragana and katakana practice.
Every review shows an expression inside a sentence, and the context gets less familiar as the expression gets more familiar.
An LLM writes new sentences in the background. A tap is always answered from explanations that already exist.
Generated sentences pass deterministic checks before they are stored, and stored browser state is validated before it is used.
02What it does
- Sentence-first study: tap an expression to see its reading, dictionary form, core meaning, meaning in this sentence, nuance and an example, then optionally say whether you knew it.
- A 12-minute session that mixes review, new and exploration sentences at 70/20/10, and switches to a review-heavy mix when too many items are due.
- Review timing by FSRS, and review context by a ladder: the original sentence, a near-copy, then new sentences, stepping back after an explicit "didn't know".
- A login-free mode with 171 fixed sentences covering all 200 seed expressions, using the same renderer and interaction code as the full study screen.
- Hiragana and katakana tables across seven ranges (263 entries) with two quiz modes, and a furigana toggle that places readings over kanji.
- Login with Argon2id and a strict host-only session cookie, study history, and a background worker that keeps the sentence pool topped up.
03How it works
The system has four parts with strict boundaries. The frontend is static TypeScript on Cloudflare; the home, login-free study and kana screens run entirely on data bundled into it and make no API calls. Logged-in study goes to a FastAPI service reached through a Cloudflare Tunnel, which reads and writes learning state in PostgreSQL and picks the next sentence. It never talks to the LLM. A separate worker claims jobs from a PostgreSQL table, asks the LLM for batches of sentences with explanations, validates them, adds furigana with a morphological analyzer, and stores them. The API, the worker and PostgreSQL run in Docker Compose on one host, with the database bound to localhost.
04Engineering highlights
FSRS answers when an expression is due; a context ladder (anchor, near-original, varied, new context) answers which sentence shows it. The ladder climbs after each exposure and drops one step only on an explicit "didn't know", which also schedules a context repair. Reinforcement tops exposures up to five meaningful ones without shortening FSRS intervals, and a review with no answer neither rates FSRS nor loops.
The model never sees database IDs, only request-local labels that the worker maps back. One strict-schema call returns sentences, translations, spans and full explanations. Thirteen deterministic checks (span bounds and overlap, surface match, length, required explanations, exact and near-duplicate detection at 0.90 similarity, unknown references) run before anything is written, and each rejection keeps a reason code.
No Redis or Celery. Jobs are rows claimed with FOR UPDATE SKIP LOCKED, with a 300 s lease for crashed workers, retries with backoff, idempotency keys and a closed set of dead-letter reasons. The provider call happens outside any database transaction, and daily request and token ceilings are checked before a job is claimed, so a limit never burns retries.
A morphological analyzer adds about 200 MB to an image and gets common context-dependent readings wrong. It runs only in the worker when a sentence is saved: readings align to kanji runs, never cross a tappable span, prefer the reading from the item's own explanation, and fall back to a small table of context rules. The API just slices the stored ruby. On the 171 login-free sentences: 171 computed, 0 failures, 0 reading mismatches.
The login-free mode reuses the real renderer and interaction controller, with network operations swapped for a generated fixture in the API's payload shape. Isolation is enforced three ways: a TypeScript AST import-graph test bans API modules from public screens, a build check scans the bundle, and a Playwright test runs the screens with no API. Stored progress is validated as untrusted input on load.
One piece of evidence per exposure is enforced by a unique index, so a retried self-report gets 409 and cannot run the mastery update or the FSRS step twice. A partial unique index allows one open presentation per session, FSRS rows are locked while they change, and a boot-time audit refuses to start if any route outside a two-entry allowlist is reachable without authentication.
05Quality & numbers
- mypy in strict mode and ruff report no issues across 176 Python files, and the frontend type-checks with tsc --noEmit.
- Architecture rules are tests: 14 import and write guards keep the LLM SDK out of the API process, FSRS writes inside the scheduling module, and commits inside allowlisted functions.
- Operations are rehearsed: migrations run only after a verified backup, and a restore check rebuilds the database in a scratch copy and exercises login and history before dropping it.
06Decisions & trade-offs
- PostgreSQL as the job queue instead of Redis. One host means one datastore to back up; SKIP LOCKED and leases give enough concurrency for one worker pool, at the cost of polling.
- No UI framework on the frontend. Plain DOM with textContent only keeps the bundle small, and made it possible to prove with an import-graph test which screens can reach the API.
- No corrections to self-reports. Undoing a mastery or FSRS step needs history that is not stored, so a second answer is rejected rather than half-applied.
- Explanations generated together with each sentence, not on tap. Batches spend more tokens up front, but a tap never waits on a model and every explanation is validated before a learner sees it.