← Sijie Wang

what you're reading

Lucerna

Reading that remembers for you.

Lucerna is a reading app for language learners. Upload a book, read it, tap any word — and the gloss you get is saved as a flashcard and drilled with spaced repetition. The act of reading quietly builds the deck; you never sit down to make one.

— the bet

An e-reader that populates its own SRS deck.

Ordinary e-readers give you a dumb dictionary and no memory: you look a word up, understand it for a minute, and lose it. Lucerna makes the lookup the input to a spaced-repetition system — every word you tap while reading becomes a vocabulary card with its context sentence, its part of speech, and, for a verb, a full conjugation table.

So the deck is a byproduct of reading, not a second chore. You never sit down to "make flashcards"; you just read, and the cards accumulate, each scheduled for review by a simplified SM-2 that was deliberately ported out of the client and into the Go backend so the algorithm has one home.

— the cascade

Three tiers to a word, and every miss warms the well.

A lookup falls through three tiers. First the local cache (wa-sqlite in the browser, better-sqlite3 on desktop). Miss, and it hits the shared server dictionary — a Postgres table matched three ways at once: the surface word, its inflected forms[], or its base_form. Miss again, and it falls to the reader's own LLM: the user brings their key (BYOAI), and pays only for their own miss.

The bet is in what happens next. Every LLM result is written back into the shared dictionary_entries table, and a verb schedules a follow-up that merges its conjugated forms into forms[]. One reader's paid lookup of a rare word warms the cache for everyone who reads that book after them. The client owns the cost; the fleet owns the benefit; marginal cost falls toward zero as the dictionary fills.

Honest note: there are two "brains" here. A full FastAPI NLP service (spaCy German morphology, a local NLLB translator, PDF OCR) sits behind /api/nlp/*, yet the headline lookup routes its third tier to the general LLM, not to NLP-API — the two share the dictionary table but no code path. Which is canonical for a given language isn't resolvable from the code alone.

Word lookup — three tiers, and every miss warms the well
Reader@lucerna/coreL1 cacheL2 dictionaryL3 LLMlookupWordPipeline(word)get(word)missGET /api/v1/dictionarymatch word / forms[] / base_formmissllmLookupBasic(...)glosscacheSetupsert — warms the wellgloss + save vocab card

The reader brings their own key and pays for their own miss — but every LLM result is written back to the shared dictionary_entries table, so one paid lookup warms the cache for everyone who reads that book next.

— one brain

One brain, three shells.

@lucerna/core is a shell-agnostic hexagonal package: it holds the entities, the use-cases, the zustand stores, i18n, even the React UI. Web (Next.js), desktop (Electron) and the landing page all import the same brain and differ only in the adapters they inject at boot — setRepositories, setStorageProviders, setPlatformAdapter.

The invariant is enforced, not hoped for. Core resolves each port through a registry proxy, and an unregistered port throws at first use rather than silently no-op'ing; a build script keeps shell-specific imports out of the shared package. Even i18n parity is compile-time: a recursive mapped type over the English catalog forces all ten locales to match its key shape, and a missing key throws rather than falling back.

One brain, three shells
injectWeb shellNext.js 15LandingNext.js → CFDesktop shellElectron 33@lucerna/coreentities · use-cases · zustand · i18n · UIDI registrysetRepositories · setStorageProviders · setPlatformAdapterHttp*RepositoryHTTPlocal SQLitewa-sqlite / better-sqlite3BYOAI LLMsetHostLlmTransport

Web, desktop and landing import the same shell-agnostic core; they differ only in the adapters injected at boot. An unregistered port throws at first use, so the seam can never silently no-op.

— one origin

Three services, one origin.

To the browser everything is same-origin; Next.js rewrites() forwards server-side. /api/v1/auth/* goes to a stateless Go auth service (identity is Appwrite-backed); the catch-all /api/v1/* goes to MainBackend (Go, echo, pgx, goose); /api/nlp/* goes to a Python FastAPI service running spaCy and a local NLLB translation model — no external LLM on that path.

MainBackend owns Postgres, reaches Appwrite for cover binaries and Meilisearch for the public-domain catalog. The migration model is deliberately boring: an endpoint lives in the old Next.js API while an app/api/X/route.ts file exists, and moves to Go the day that file is deleted — so the cutover is a diff, not a flag.

Three services, one origin
/api/v1/auth/*/api/v1/*/api/nlp/*Browser — same originNext.js rewrites()web/next.config.tsAuthGo · statelessMainBackendGo · echo · pgxNLP-APIFastAPI · spaCyAppwritestorage + identityPostgresMeilisearchcatalog

To the browser everything is one origin; Next.js rewrites() fan out server-side to three services, each owning its own store. An endpoint moves from the old Next API to Go the day its route.ts is deleted.

— offline

Write locally, replay when online.

The desktop app must work on a plane. Each write commits the domain row and an outbox row in a single better-sqlite3 transaction, so a note is never saved without its intent-to-sync. A drainer replays the outbox as HTTP when the machine comes online — on the online event, after each write, and a couple of seconds after boot — with a bounded retry.

Books marked local-only carry a local_-prefixed id and a null server_id; they short-circuit sync entirely and never leave the device. The transactional outbox is the whole reason a flaky connection can't leave the local store and the server quietly disagreeing.

Offline desktop — write locally, replay when online
UIHybridRepobetter-sqlite3OutboxProcessorMainBackendcreate book / add notetx { row + outbox row }committedok (local_ id)— later: window.online —read pendingreplay HTTP (MAX_ATTEMPTS=5)server_idmark synced

Every write commits its domain row and its intent-to-sync in one transaction, so a note is never saved without its replay record. Local-only books (local_ id, null server_id) short-circuit sync entirely.

— the data

A book, its marks, and a shared well of words.

The model is small. books holds the text and a content_hash that survives re-upload; reading_progress, bookmarks and reading_notes hang off it. vocabulary_entries carries both the gloss and the SRS state (ease_factor, interval_days, next_review_at) — one row is simultaneously a dictionary entry and a flashcard, and forms[] holds every inflected surface so marking one form underlines them all.

dictionary_entries is the one shared, user-independent table — the well every reader's misses fill. Notice there are no foreign keys on user_id: it's a bare Appwrite session string, and cascade deletes are done in application code on purpose, so the data-purge contract stays visible in code rather than hidden in DDL. A lint, check-no-fk.sh, forbids new foreign keys.

Domain model
1..1 · cascade1..N1..N · set null1..Nwarmsbooksid UUID, user_idcontent_hash, languagetoc JSONBreading_progress(book_id,user_id) PKprogress 0..1anchor_text, raw_startbookmarksid, book_idpage, anchor_textreading_notesid, book_idhighlighted_textnote, offsetsvocabulary_entriesid, user_id, book_idword, base_formforms[], verb_tableease_factor, interval_daysnext_review_atdictionary_entriesword, language, target_langforms[] GIN, base_formmeaning, verb_tablehit_count · shared

One vocabulary_entries row is at once a dictionary entry and an SRS flashcard. user_id is a bare Appwrite session string — no foreign keys; cascade deletes live in application code on purpose (a lint, check-no-fk.sh, forbids new FKs), keeping the data-purge contract visible.

— the surface

Pages that reflow without losing your place.

Reading is where the polish goes. Pagination is dynamic — change the font and the text reflows, but a raw character cursor (raw_start) keeps your place across the reflow, across devices, and across a re-upload of the same file. Full-text search runs over the whole book locally via a FlexSearch index.

Ingestion is client-side: EPUBs are parsed in the browser with JSZip; scanned PDFs fall back to the NLP-API's PyMuPDF + Tesseract OCR path. And a TTS mode reads the book aloud — the same tap-to-gloss surface, now an audiobook.