← Журнал
Jiva Studio

Один чат, все языки

Корпус почти весь на английском, а люди приходят на русском, украинском, сербском. Вместо того чтобы переводить всё заранее, в чате работает слой перевода: он переводит цитаты и фрагменты прямо по ходу ответа и хранит каждый перевод один раз, рядом с оригиналом. Поэтому чат остаётся быстрым, а до источника всегда одно касание.

Слушай СадхуSakhaEngineeringi18n

Наш чат отвечает на вопросы о лекциях и священных текстах. Но за ним стоит неудобный факт: бо́льшая часть корпуса — лекции, стихи, комментарии — существует только на английском, а люди приходят на русском, украинском, сербском. Мы поставили себе задачу: как дать человеку спросить на своём языке и ответить ему на нём же — не переводя весь корпус вручную и не теряя связи с первоисточником?

Простой путь — перевести всё заранее и сохранить. Мы отказались сразу: корпус живёт и растёт, и каждый новый стих или лекция тянули бы за собой ручной перевод на все языки. Поэтому мы сделали слой, который переводит по ходу разговора и помнит, что перевёл. Спрашивайте на любом языке — вам ответят на нём же, цитаты придут на нём же, и до оригинала всегда одно касание.

Разные языки — разный материал

Скажем честно: не на каждом языке у нас есть один и тот же материал. В индексированном корпусе всего два языка содержания — английский и русский (indexer_langs = "ru,en"). У украинских и сербских пользователей есть полноценный интерфейс и ответы, но сами комментарии и расшифровки лекций, на которые они ссылаются, бывают только на русском или английском. Делать вид, что это не так, — значит выдумывать источники.

Поэтому система строго разделяет две вещи: язык, на котором вопрос ищут, и язык, на котором на него отвечают.

Язык поиска — не язык ответа

Когда вы спрашиваете, система решает, из какого реального языка корпуса извлекать материал — и это не обязательно ваша локаль. Логика — небольшой ограничитель:

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

Цепочка запасных вариантов выстроена намеренно: язык ответа → близкий родственный язык → английский. Украинский вопрос ищет и цитирует русский комментарий, а не скатывается сразу к английскому: для такого читателя это ближайший реальный источник. Сербский сводится к английскому. (reduce_locale_to_content_lang — это Python-двойник той же карты, что и у клиента, так что сервер и приложение всегда согласны.) Есть и тонкость безопасности, которую закрепляют тесты: если живой запрос «какие языки есть в корпусе?» падает с ошибкой, мы ограничиваемся статическим запасным набором — и русский вопрос всё равно ищется на своём языке. До самого английского проваливается только по-настоящему пустой корпус.

Язык поиска ограничен тем, что реально есть. А сам ответ в любом случае пишется на вашем языке — за этим следит отдельная директива Language: в промпте. Нашли на русском или английском — рассказали вам на вашем.

Слой, который переводит — и помнит

Связующий текст модель пишет сама, так что он уже на вашем языке. Трудность — во всём, что цитируется: перевод стиха, фраза из комментария, расшифровка фрагмента лекции. Всё это лежит в корпусе на одном языке, а дойти до вас должно на другом — точно, без пересказа писания своими словами.

Каждый такой кусок проходит через одно узкое место — localize_citation. У него три ветви:

  1. Native — перевод на ваш язык уже есть в корпусе (сохранённый русский комментарий для русского ответа). Показываем его. Никакого машинного перевода.
  2. Translate — на вашем языке его нет, поэтому отдаём оригинал слою перевода и получаем обратно на вашем языке.
  3. English-preferred — откатываемся к английскому варианту, а если и его нет — к сырому источнику.

Сам переводчик (LlmTranslationService) — тонкая, строгая обёртка вокруг дешёвой модели, Gemini 2.5 Flash, при температуре 0. Его дело — перенести смысл, а не толковать его. Системный промпт прямо велит не лезть в текст:

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.

Последняя строка — несущая: переводчик не трогает санскрит. Имена и тела стихов в IAST остаются ровно как написаны — их обрабатывают отдельно и детерминированно (см. последний раздел). Перевод отвечает за смысл, транслитерация — за письменность. Это две разные задачи, и мы не даём им смешаться.

Перевести один раз — для всех

Опасность перевода на лету в том, что он незаметно превращается в перевод на каждый запрос. Один основательный ответ может нести тридцать цитат; делать эту работу на каждом ходу и для каждого пользователя было бы медленно и дорого. Поэтому один и тот же текст никогда не переводится дважды. Под переводчиком стоит трёхуровневый сквозной кэш: лестница поиска, которая при промахе записывает результат сразу во все уровни.

flowchart TD
  Q["Перевести: исходная цитата → ваш язык"] --> R{"Горячий кэш Redis"}
  R -->|"попадание"| OUT["Переведённая цитата, рядом с оригиналом"]
  R -->|"промах"| PG{"Postgres · chunk_translations"}
  PG -->|"попадание"| OUT
  PG -->|"промах"| LLM["Живой вызов модели · Gemini 2.5 Flash"]
  LLM -->|"сквозная запись"| PG
  LLM -->|"сквозная запись"| R
  LLM --> OUT

При промахе результат записывается сразу в оба уровня, так что следующий ход — для любого пользователя, не только этого — берётся уже из кэша. Хранилище в Postgres (chunk_translations) — сердце всего: одна строка на перевод, с составным ключом из четырёх частей —

(content_hash, language, model, prompt_version)

— где content_hash — короткий дайджест BLAKE2b исходного текста. Так одна исходная строка даёт ровно один перевод на каждый целевой язык, общий для всех пользователей, а сам исходный текст никогда не лежит в ключе кэша дословно. Запись идёт через INSERT … ON CONFLICT DO NOTHING: при двух одновременных ходах побеждает тот, кто записал первым, — значение всё равно одно и то же.

Если добавить в ключ версию модели и промпта, выходит неприметная, но ценная вещь: в день, когда мы сменим модель перевода или ужесточим промпт, новые строки создаются под новым ключом, а старые просто устаревают — никакой болезненной миграции и ни одного старого текста, выданного за свежий. А поскольку один ответ может разом развернуться в десятки цитат, живой перевод ограничен небольшим семафором: всплеск не упирается в лимит запросов провайдера и ни одна цитата не застревает не на том языке.

Весь слой к тому же мягко переносит сбои: зависший Redis размыкает предохранитель, и его пропускают; чтение или запись в Postgres с ошибкой попадают в лог, и их обходят. Проблема с кэшем может замедлить ход. Сорвать его она не может никогда.

Первый запрос к цитате на миг задумывается; все следующие — у всех пользователей — приходят мгновенно.

Оригинал — в одном касании

Переведённая цитата, потерявшая связь с источником, в священных текстах стоит немного: точность тут важна не меньше понимания. Поэтому перевод никогда не заменяет оригинал — он идёт рядом с ним, в той же посылке.

Каждая карточка, которую выдаёт чат, хранит обе строки и структурную идентичность источника. У стиха остаётся translation в виде карты языков (клиент по-прежнему считает канонической английскую запись), адресный ярлык (BG 2.13) и audio_url со ссылкой на запись чтения. Фрагмент лекции несёт переведённый text и оригинальный text_original с пометкой mt: true. Ничто из источника не выбрасывается ради места под перевод:

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

Раз оригинальная строка и её идентичность едут вместе, переключатель «показать оригинал» в приложении меняет текст — и транслитерацию — туда-обратно без нового запроса. Поэтому и аудиофрагменты устроены именно так: карточка привязывает url, говорящего и дату к оригинальной записи, а расшифровка переведена для чтения. Аудио никогда не переозвучивают — локализуют только слова на экране, и одно касание возвращает вас ровно к тому, что было сказано.

А санскрит

Остаётся транслитерация — та часть, которую модели трогать запрещено. Здесь санскрит стиха, записанный один раз чистой латиницей IAST, переводится в письменность читателя. Стоит уточнить, что это такое, а что нет.

Это не слой перевода, и ему не нужны ни модель, ни кэш. Это детерминированное посимвольное сопоставление по самому длинному совпадению. Источник истины один — IAST стиха, — а кириллические формы выводятся из него при чтении, по одной функции на каждую, а не дюжина заранее заготовленных таблиц:

transliteration = {
    "en":     iast,
    "ru":     iast_to_ru(iast),
    "uk":     iast_to_uk(iast),
    "sr-Latn": iast,
    "sr-Cyrl": iast_to_sr(iast),
}

Три кириллических отображения по-настоящему разные, потому что алфавиты расходятся: украинское берёт за основу русскую таблицу, но переопределяет буквы, что есть только у него (i → і, g → ґ, h → г); сербское — вообще отдельная таблица (c → ц, j → џ, ñ → њ). Есть одно удачное переиспользование: сербскую прозу, которую модель переводит на латиницу, мы храним одной строкой, а кириллицу выдаёт тот же транслитератор — бережно, токен за токеном, так что имя вроде Kṛṣṇa преобразуется целиком, а не размазывается наполовину. Но всё это — механическое преобразование письменности. А спросить на любом языке и получить ответ настоящим материалом с источником позволяет именно тот слой «перевести и запомнить» сверху; транслитерация лишь следит, чтобы санскрит выглядел правильно, когда вы до него доберётесь.

Итог

Чат говорит с каждым на его языке, но честно опирается на оригинальные источники. Он ищет там, где материал действительно живёт, отвечает на вашем языке и проводит цитаты через слой, который переводит каждый кусок ровно один раз и хранит его, — так что первый читатель платит мгновением, а все следующие получают ответ сразу. И ничто не оторвано от того, откуда пришло: до оригинального текста, декламации, аудиозаписи лекции всегда одно касание. В священных текстах точность источника важна не меньше, чем глубина понимания.


Часть проекта

Слушай Садху

Открыть проект