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.

Context over cards

Every review shows an expression inside a sentence, and the context gets less familiar as the expression gets more familiar.

Never wait on a model

An LLM writes new sentences in the background. A tap is always answered from explanations that already exist.

Nothing untrusted reaches the learner

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.
Home: choose between expression learning and kana practice. The interface is in Korean because the app is built for Korean speakers learning Japanese.
Tapping 知っ opens the explanation sheet: reading, dictionary form, meaning, meaning in this sentence, nuance and an example. Rating it is optional.
Furigana on and translation revealed. The readings were computed when the sentence was saved, not when it is shown.
Hiragana table with seven ranges; each entry shows romaji and a Korean sound approximation.
Kana quiz, choose-the-romaji mode. Missed characters come back once at the end of the round.
Kana quiz, read-aloud mode: reveal the answer, then mark yourself right or wrong.

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.

1ReadThe learner sees a Japanese sentence with the translation hidden. Tappable expressions already carry their explanations.
2SignalTaps, reveals and optional self-reports are stored as idempotent events. Only explicit answers become FSRS ratings; silence defers the item for 12 hours instead.
3ScheduleFSRS decides when an item is due. The context ladder decides whether it returns in its original sentence, a near-copy or a new sentence.
4GenerateWhen no suitable sentence exists, a job is queued. The worker claims it with SKIP LOCKED and asks the LLM for a structured batch.
5ValidateThirteen checks run before any write. Accepted sentences get furigana and join the learner's ready pool.

04Engineering highlights

Separating when from which context

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.

LLM output treated as untrusted input

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.

A job queue inside PostgreSQL

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.

Furigana at save time, not request time

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.

Login-free screens that cannot reach the API

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.

Correct under retries and double taps

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

1,273
backend unit and integration tests, run against a real PostgreSQL 16
750
frontend unit tests, including import-graph and bundle checks
55
end-to-end tests in real Chrome covering study, login-free mode, kana and furigana
0
API calls from the login-free screens, checked on the live site
  • 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.
Looking back The product idea is simple; the work was in the boundaries. Deciding that the model never sits in a request, that its output is validated like user input, and that every rule about which code may touch what is a test, is what let a learning engine, an LLM pipeline and a login-free mode live in one codebase without stepping on each other.

07Stack & links