One chat, every language
The corpus is mostly English; users arrive in Russian, Ukrainian, Serbian. Instead of pre-translating everything, the chat has a translation layer that translates quotes and fragments as it answers — and stores each translation once, beside its original, so it stays fast and the source is always one tap away.
Our chat answers questions about the lectures and the scriptures. Underneath it is an inconvenient fact: most of the corpus — lectures, verses, purports — exists only in English, while users arrive in Russian, Ukrainian, Serbian. The question we set ourselves: how do you let someone ask in their own language, and answer them in their own language, without translating the whole corpus by hand — and without losing the link back to the original source?
The easy path is to translate everything in advance and store it. We rejected it at once: the corpus lives and grows, and every new verse or lecture would drag a manual translation into each language behind it. So instead there is a layer that translates as the chat speaks — and remembers what it translated. Ask in any language; you are answered in yours, the quotes come across with you, and the original is always a tap away.
Different languages, different material
The first honest admission is that we do not have the same material in every
language. The indexed corpus carries two content languages — English and Russian
(indexer_langs = "ru,en"). Ukrainian and Serbian users have rich interface and
answers, but the underlying purports and lecture transcripts they cite may only
exist in Russian or in English. Pretending otherwise would mean inventing sources.
So the system keeps two ideas strictly apart: the language a question is searched in, and the language it is answered in.
Search language is not answer language
When you ask, the system decides which real corpus language to retrieve from — and that is not necessarily your locale. The logic is a small clamp:
def clamp_retrieval_lang(answer_lang, corpus_langs):
if answer_lang and answer_lang in corpus_langs:
return answer_lang # ru → ru, en → en
reduced = reduce_locale_to_content_lang(answer_lang)
if reduced in corpus_langs:
return reduced # uk → ru: cite the Russian purport
return "en" # sr-Latn, anything else → English
The fallback chain is deliberate: answer language → a reduced relative → English.
A Ukrainian question retrieves and cites the Russian purport rather than dropping
straight to English, because that is the nearest real source for that reader;
Serbian reduces to English. (reduce_locale_to_content_lang is the Python twin of
the same map the client uses, so server and app always agree.) And there is a
safety nuance the tests pin down: if the live “which languages does the corpus
have?” probe errors, we clamp against a static fallback set so a Russian turn
still retrieves natively — only a genuinely empty corpus falls all the way to
English.
The retrieval language is clamped to what really exists. The answer prose,
meanwhile, is written in your language no matter what — a separate Language:
directive in the prompt sees to that. Found in Russian or English; told to you in
yours.
A layer that translates — and remembers
The connecting narration is the model’s own prose, so it is already in your language. The hard part is everything quoted: a verse translation, a sentence of purport, the transcript of a lecture fragment. These exist in the corpus in one language, and they must reach you in another — faithfully, without the model paraphrasing scripture.
Every such piece funnels through a single chokepoint, localize_citation, with
three branches:
- Native — a translation in your language already exists in the corpus (a stored Russian purport for a Russian answer). Show it. No machine translation at all.
- Translate — it does not exist in your language, so hand the original text to the translation layer and get it back in your language.
- English-preferred — fall back to the English variant, else the raw source.
The translator itself (LlmTranslationService) is a thin, strict wrapper around a
cheap model — Gemini 2.5 Flash, at temperature 0. Its whole job is to move meaning
across, not to interpret it. The system prompt is blunt about staying out of the
way:
You are a precise translator of Vaiṣṇava scripture-related prose.
Translate the user's text into the target language LITERALLY,
preserving the exact meaning. Do NOT add, omit, explain, or
embellish anything. Keep Sanskrit terms, names, and any IAST
diacritic words EXACTLY as written — never translate or transliterate
them. Output ONLY the translation.
That last line is load-bearing: the translator never touches Sanskrit. Names and verse-bodies in IAST are left exactly as written, to be handled separately and deterministically (see the last section). Translation handles meaning; transliteration handles script. They are two different problems and we never let them blur.
Translate once — for everyone
The danger with translate on the fly is that it quietly becomes translate on every request. A single research-heavy answer can carry thirty quotes; doing that work on every turn, for every user, would be both slow and expensive. So the translation never happens twice for the same thing. Underneath the translator sits a three-tier read-through cache: a lookup ladder, with a write-through back to every tier on a miss.
flowchart TD
Q["Translate: source quote → your language"] --> R{"Redis hot cache"}
R -->|"hit"| OUT["Translated quote, beside its original"]
R -->|"miss"| PG{"Postgres · chunk_translations"}
PG -->|"hit"| OUT
PG -->|"miss"| LLM["Live model call · Gemini 2.5 Flash"]
LLM -->|"write through"| PG
LLM -->|"write through"| R
LLM --> OUT
On a miss the result is written through to both tiers, so the next turn — for
any user, not just this one — is served from cache. The Postgres store
(chunk_translations) is the heart of it: one row per translation, keyed by a
four-part identity —
(content_hash, language, model, prompt_version)
— where content_hash is a short BLAKE2b digest of the source text. So a given
source string yields exactly one translation per target language, shared across
the whole user base, and the source text itself never sits verbatim in a cache
key. Writes are INSERT … ON CONFLICT DO NOTHING: under two simultaneous turns,
the first writer wins and the value is identical anyway.
Folding the model and prompt version into the key buys something quietly valuable: the day we change the translation model or tighten the prompt, new rows are minted under the new identity and the old ones simply age out — no destructive migration, no stale text served as if it were fresh. And because a single answer can fan out to dozens of citations at once, live translation is bounded by a small semaphore so a burst never trips the provider’s rate limit and leaves a quote stranded in the wrong language.
The whole layer is also failure-soft: a wedged Redis trips a circuit breaker and is skipped; a Postgres read or write that errors is logged and stepped over. A cache problem can make a turn slower. It can never make a turn fail.
The first request for a given quote thinks for a moment; every one after it — across all users — is immediate.
The original, one tap away
A translated quote that has lost its link to the source is worth little in sacred texts, where precision matters as much as comprehension. So translation never replaces the original — it travels beside it, in the same payload.
Every card the chat emits keeps both strings and the structural identity of the
source. A verse keeps its translation as a language map (the client still reads
the English entry as the canonical one), plus its address label (BG 2.13) and an
audio_url to the recitation. A lecture fragment carries the translated text
and the original text_original, flagged mt: true. Nothing about the source is
discarded to make room for the translation:
shown, original, mt = await localize_citation(
ctx, variants={media_lang: fragment.text},
source_text=fragment.text, src_lang=media_lang)
if mt:
payload["text"] = shown # translated transcript
payload["text_original"] = original # original, kept alongside
payload["mt"] = True
Because the original string and its identity ride along, the app’s “show original”
toggle flips text — and transliteration — back and forth without a refetch. This is
also why audio fragments work the way they do: the card points url, speaker,
and date at the original recording, while the transcript is translated for
reading; the audio is never dubbed, only the words on screen are localized, and a
single tap returns you to exactly what was said.
And the Sanskrit
That leaves transliteration — the part the model is forbidden to touch. This is where a verse’s Sanskrit, written once in clean Latin IAST, is rendered into the reader’s script. It is worth being precise about what this is and is not.
It is not the translation layer, and it needs no model and no cache. It is a deterministic, longest-match character mapping. There is a single source of truth — the verse’s IAST — and the Cyrillic forms are derived from it on read, one function each instead of a dozen pre-built tables:
transliteration = {
"en": iast,
"ru": iast_to_ru(iast),
"uk": iast_to_uk(iast),
"sr-Latn": iast,
"sr-Cyrl": iast_to_sr(iast),
}
The three Cyrillic mappings are genuinely different, because the alphabets diverge:
Ukrainian starts from the Russian table but overrides the letters only it has
(i → і, g → ґ, h → г); Serbian is a separate table entirely (c → ц,
j → џ, ñ → њ). There is one neat reuse: Serbian prose the model translates into
Latin is stored as a single row, and the Cyrillic is produced by the same
transliterator — carefully, token by token, so a name like Kṛṣṇa is converted as
a unit rather than smeared half-and-half. But all of this is mechanical script
conversion. The work that lets you ask in any language and be answered with real,
sourced material is the translate-and-remember layer above — transliteration just
makes sure the Sanskrit looks right once you get there.
The result
The chat speaks to each person in their own language but rests honestly on the original sources. It searches in the language where the material actually lives, answers in yours, and brings the quotes across through a layer that translates each piece exactly once and keeps it — so the first reader pays a moment and everyone after is served instantly. And nothing is ever cut off from where it came: the original text, the recitation, the lecture audio are always one tap away. In sacred texts, the precision of the source matters no less than how well it is understood.
Part of
Lectorium