← Journal
Jiva Studio

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.

Listen to SadhuSakhaEngineeringi18n

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:

  1. 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.
  2. Translate — it does not exist in your language, so hand the original text to the translation layer and get it back in your language.
  3. 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

Listen to Sadhu

Open project