Как мы автоматизировали перевод технической документации через инструменты вокруг модели. llm.. llm. markdown.. llm. markdown. Natural Language Processing.. llm. markdown. Natural Language Processing. python.. llm. markdown. Natural Language Processing. python. автоматизация.. llm. markdown. Natural Language Processing. python. автоматизация. Блог компании Битрикс24.. llm. markdown. Natural Language Processing. python. автоматизация. Блог компании Битрикс24. документация.. llm. markdown. Natural Language Processing. python. автоматизация. Блог компании Битрикс24. документация. локализация.. llm. markdown. Natural Language Processing. python. автоматизация. Блог компании Битрикс24. документация. локализация. Локализация продуктов.. llm. markdown. Natural Language Processing. python. автоматизация. Блог компании Битрикс24. документация. локализация. Локализация продуктов. машинный перевод.. llm. markdown. Natural Language Processing. python. автоматизация. Блог компании Битрикс24. документация. локализация. Локализация продуктов. машинный перевод. перевод документации.. llm. markdown. Natural Language Processing. python. автоматизация. Блог компании Битрикс24. документация. локализация. Локализация продуктов. машинный перевод. перевод документации. Подготовка технической документации.. llm. markdown. Natural Language Processing. python. автоматизация. Блог компании Битрикс24. документация. локализация. Локализация продуктов. машинный перевод. перевод документации. Подготовка технической документации. техническая документация.. llm. markdown. Natural Language Processing. python. автоматизация. Блог компании Битрикс24. документация. локализация. Локализация продуктов. машинный перевод. перевод документации. Подготовка технической документации. техническая документация. технический писатель.

Привет! Меня зовут Глеб Смольяков, я инженер-программист в DevRel-отделе Битрикс24.

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

В статье расскажу, как мы сделали сервис DocFlow, который автоматизирует перевод документации с русского на английский. Главная часть статьи — о том, как мы создавали инженерный слой для аккуратной точной работы.

Содержание

Зачем понадобился сервис перевода DocFlow

У нас была практичная проблема: переводить документацию с русского на английский.

До DocFlow автоматизация перевода была, но после неё оставалось много ручной работы. Технические писатели вычитывали результат, исправляли его и проверяли, что документ вообще нормально отображается на сайте. Именно эту ручную доводку хотелось убрать или хотя бы резко сократить.

Проект DocFlow стал попыткой превратить перевод документации в управляемый конвейер: система разбирает файл, переводит нужные части, собирает документ обратно и проверяет результат — сама.

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

Почему нельзя просто отправить markdown в LLM

Документация включает вещи, которые будут в центре нашей статьи: методы API, примеры кода, таблицы, ссылки, служебная разметка. Всё это хранится в формате markdown/YFM, и для модели перевод такого текста намного сложнее, чем работа с обычной прозой. Это из-за спецсимволов для обозначения заголовков, ссылок, таблиц и других вещей.

YFM (Yandex Flavored Markdown) — это расширение markdown с дополнительными возможностями.

В документации важен не только смысл слов, но и структура: таблицы должны остаться таблицами, ссылки должны сохранять адрес, служебные блоки не должны сломаться. Структура получается благодаря спецсимволам. И здесь начинается проблема.

LLM видит перед собой текст и старается сделать его лучше: перевести, выровнять, переименовать, иногда упростить или переформулировать. Для обычной статьи это может быть полезно. Для API-документации это опасно.

Например, ссылка в markdown выглядит так:

[подробнее](../data-types.md#catalog_product)

Если модель изменит адрес, лишнюю скобку или якорь после #, ссылка может перестать работать.

То же самое с таблицами и кодом. Трогать синтаксис или случайно удалять кавычки при переводе нельзя. Одна потерянная кавычка превращает пример из документации в невалидный код.

Поэтому нужно переводить только то, что можно переводить, и не трогать всё остальное.

Два источника проблем: модель и код

Если всю работу целиком оставить модели, рано или поздно она вмешивается в структуру документа.

Вот пример. Reasoning-модель gpt-oss-120b на структурно сложном файле оказалась хуже для нашей задачи, чем более простая bitrixgpt-5.5. Она раскрывала служебные заглушки независимо от инструкций в промпте, а на простом файле сломала структуру в двух местах: удалила ссылку на тип в таблице ответа и перепутала строки в таблице ошибок.

В чём проблема: модель сама по себе хорошо работает, но ведёт себя слишком активно для задачи, где нужна не инициатива, а аккуратность. «Почти такой же» markdown уже не работает так как надо.

Часто ошибки появляются не из-за модели, а из-за кода.

Пример — в документации были таблицы параметров внутри вкладок и списков. Из-за вложенности строки таблицы начинались с отступа, например так:

{% list tabs %}

- Параметры

    #|
    || Параметр | Описание ||
    || ID | Идентификатор сделки ||
    |#

{% endlist %}

Но код DocFlow, который разбирал таблицы, сначала ожидал, что строка таблицы начинается сразу с ||, без пробелов в начале:

#|
|| Параметр | Описание ||
|| ID | Идентификатор сделки ||
|#

Как выглядел первоначальный код:

# Было: парсер считал строкой таблицы только ту строку,
# где разделитель || стоит прямо в начале.
def _split_columns(self, row_text: str) -> tuple[TableColumnSpan, ...]:
    if not row_text.startswith("||"):
        return ()

    # Текст ячеек начинается сразу после первых двух символов: ||
    content_start = 2

Из-за этого строки с отступом не распознавались как таблица. Система не видела ячейки и не отправляла их на перевод.

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

После фикса код сначала считает отступ, а потом ищет || уже после него. Поэтому ячейки таких таблиц снова попадают на перевод:

# Стало: сначала считаем отступ в начале строки.
# Так парсер видит таблицы внутри вкладок и списков,
# где перед || стоят пробелы или табы.
def _split_columns(self, row_text: str) -> tuple[TableColumnSpan, ...]:
    indent = len(row_text) - len(row_text.lstrip(" t"))

    # Проверяем, что || стоит не обязательно в начале строки,
    # а сразу после отступа.
    if not row_text.startswith("||", indent):
        return ()

    # Текст ячеек начинается после отступа и двух символов: ||
    content_start = indent + 2

Основной принцип: модель должна переводить только нужные фрагменты

Промптом задачу полностью не решить.

Чем сложнее документ, тем выше шанс, что где-то модель поправит лишнее. А в нашем случае ошибка в один символ уже может быть видимой поломкой — в зависимости от того, где модель допустит эту ошибку.

Что сделали мы: перестали считать модель самостоятельным переводчиком всего файла и сделали её одним этапом внутри конвейера. Схема получалась такой:

  • Сначала обычный код разбирает документ.

  • Всё чувствительное для перевода прячется или обрабатывается отдельно.

  • Модель получает те фрагменты, которые она может перевести и не сломать: обычный текст, описания, отдельные значения.

  • После перевода сервис собирает документ обратно.

  • В конце результат проверяется и чинится автоматическими правилами.

Этот слой обычного кода вокруг модели можно назвать детерминированным слоем, который работает по заранее заданным правилам: на одном и том же входе даёт один и тот же результат.

Детерминированный слой вокруг модели

Что сюда вошло:

  • Защита структуры через плейсхолдеры.

  • Отдельный перевод кода и таблиц.

  • Кэш переводов и глоссарий.

  • Разбиение больших файлов на куски — чанки.

  • Повторные запросы при потере служебных элементов.

  • Фиксеры, валидаторы и метрики качества.

Плейсхолдеры

Первым шагом мы стали прятать от модели всё, что она не должна менять.

Для этого появились плейсхолдеры — временные заглушки вместо фрагмента документа. Например, если в тексте есть ссылка, код или служебная разметка, система заменяет этот кусок на токен вроде [[PROTECTED_0]], а оригинал кладёт в память.

Упрощённый пример:

def add_placeholder(self, original: str) -> str:
    placeholder = f"[[PROTECTED_{self.counter}]]"
    self.blocks[placeholder] = original
    self.counter += 1
    return placeholder

Модель видит не исходную ссылку или таблицу, а короткую заглушку. После перевода DocFlow возвращает оригинальный фрагмент на место. Вот пример процесса.

Что было до перевода:

См. [описание типа](../data-types.md#catalog_product)

Что получает модель:

См. [[PROTECTED_0]]

Что модель возвращает:

See [[PROTECTED_0]]

Что получается в итоге, когда DocFlow собирает результат:

See [описание типа](../data-types.md#catalog_product)

На этом этапе модель уже не может случайно испортить адрес ссылки, закрывающую скобку или якорь после #.

После перевода DocFlow собирает документ обратно и прогоняет результат через набор автоматических исправлений. Восстановление плейсхолдеров — первый шаг после ответа модели. Если до перевода ссылка была заменена на [[PROTECTED_0]], после перевода DocFlow возвращает на это место исходную ссылку. Это работает через словарь, который система собрала на этапе защиты.

Код

В документации часто есть примеры на JavaScript, PHP или в формате JSON. В коде тоже нельзя переводить всё подряд, потому что там важны кавычки, скобки, двоеточия и другие символы. Но можно переводить русские значения внутри строк. В таком примере:

const title = "Связаться с клиентом";

Здесь нужно перевести только Связаться с клиентом, но нельзя трогать const, title, кавычки и точку с запятой. Поэтому DocFlow ищет русские фрагменты внутри кода и отправляет их в модель отдельно маленькими порциями.

Таблицы

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

Вот упрощённый пример.

#|
|| Поле | Описание ||
|| ID | Идентификатор сделки ||
|#

DocFlow берёт на перевод только это:

Поле
Описание
Идентификатор сделки

А потом собирает таблицу обратно:

#|
|| Field | Description ||
|| ID | Deal identifier ||
|#

Так модель не может переставить строки, потерять разделитель или поменять закрытие таблицы.

Кэш переводов и глоссарий

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

Рядом с кэшем работает глоссарий — список терминов и правильных переводов. Например, в проекте принято переводить «портал», «коммерческое предложение», «смарт-процесс». DocFlow находит в документе нужные термины и добавляет их в подсказку модели, чтобы она не выбирала перевод заново. Это может выглядеть примерно так:

Переведи текст с учётом терминов:
Glossary:
- портал → account
- коммерческое предложение → estimate
- смарт-процесс → SPA

Текст:
Портал вернул коммерческое предложение из смарт-процесса.

Деление на чанки

Большие файлы пришлось резать на чанки — куски текста, которые отправляют в модель отдельным запросом. Здесь тоже нельзя просто отрезать каждые 3000 символов: можно попасть внутрь ссылки, таблицы или плейсхолдера. Поэтому DocFlow сначала пытается делить текст по разделам, потом по абзацам, потом по строкам, и отдельно проверяет, что граница не проходит внутри чего-то вроде [[PROTECTED_12]].

Температура

Это параметр случайности ответа. При высокой температуре модель чаще выбирает разные варианты формулировок. При нуле она старается выбирать самый вероятный вариант.

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

class Translator:
    def __init__(self, ..., temperature: float = 0.0) -> None:
        self._temperature = temperature

    def _build_payload(self, content: str, prompt: str) -> dict[str, object]:
        return {
            "model": self._model,
            "temperature": self._temperature,
            "messages": [
                {"role": "system", "content": prompt},
                {"role": "user", "content": content},
            ],
        }

Эффект для процесса получился значительный. До фиксации температуры один и тот же файл между прогонами мог отличаться на 1-4 пункта chrF (метрика машинного перевода, которая показывает близость текста к эталону). После установки temperature=0 перевод стал воспроизводимым, и сравнивать версии стало проще.

Повторные запросы

Модель может потерять плейсхолдер, продублировать его или убрать пустую строку между абзацем и заголовком. Поэтому DocFlow проверяет, совпадает ли количество плейсхолдеров до и после перевода.

Например, если в исходном куске было:

[[PROTECTED_0]] текст [[PROTECTED_1]]

А модель вернула только это:

[[PROTECTED_0]] text

Значит, [[PROTECTED_1]] потерялся. В этом случае DocFlow делает повторный запрос и прямо перечисляет модели, какие плейсхолдеры она потеряла.

Фиксеры

Фиксер — это маленький модуль, который исправляет один тип частой ошибки. Что делают разные фиксеры в DocFlow:

  • Восстанавливает закрывающие || в таблицах.

  • Возвращает правильный вид строк с типами данных.

  • Чинит отступы таблиц внутри вкладок.

  • Допереводит строки там, где после основного перевода осталась кириллица.

Ниже — один из фиксеров, CellTypeFixer. Он восстанавливает строку типа в YFM-ячейке по оригиналу. Сначала фиксер сопоставлял строку по имени поля и ошибался. Например, поле formatName встречалось и в таблице параметров, и в таблице ответа, а карта хранила одну запись на имя поля, и фиксер брал не тот оригинал. Потом мы научили его учитывать номер вхождения:

# Для каждого поля храним список строк типа в порядке появления в оригинале.
originals = self._original_type_lines.get(field_line)

# N-е вхождение поля в переводе берёт N-ю строку типа из оригинала.
idx = occurrence_count.get(field_line, 0)
occurrence_count[field_line] = idx + 1
correct = originals[min(idx, len(originals) - 1)]

Валидатор

Блок проверок, который сравнивает исходный документ и перевод. Валидатор смотрит, сохранилась ли структура документа, отслеживает остатки кириллицы и проверяет соблюдение проектных терминов.

Один из примеров — правило ArtifactsRule. Оно ищет следы того, что сборка документа прошла не до конца: заглушку, которая осталась в готовом тексте, или ссылку, которую модель дорисовала неправильно.

class ArtifactsRule(ValidationRule):
    # Служебная заглушка осталась в готовом тексте: [[PROTECTED_5]], [[TABLE_ITEM_2]]
    _LEAKED_SENTINEL_RE = re.compile(
        r'[[/?(?:PROTECTED_d+|TABLE_ITEM_d+)]]', re.IGNORECASE
    )
    # Обрывок ссылки: ]](url) вместо [текст](url)
    _LEAKED_PLACEHOLDER_RE = re.compile(r']]([^)]+)')
    # Двойные скобки: [текст]((url)), модель дорисовала лишние
    _DOUBLE_PAREN_LINK_RE = re.compile(r'](([^)n]+))')

Это работает как обычная автоматическая проверка: если в переводе остался такой фрагмент, система подсвечивает проблему в логах. Валидатор не блокирует сохранение файла. Если проверка ошиблась, человек всё равно сможет открыть перевод и посмотреть, что произошло.

Что получилось по цифрам

На заранее выбранном наборе файлов для контрольной проверки получились такие результаты:

Метрика

Значение

Что означает

structure

100.0%

сохранился скелет документа: заголовки, код, таблицы, якоря, служебные блоки

terminology

96.3%

соблюдены термины из словарей и глоссария

chrF / BLEU

89.1 / 79.2

перевод близок к человеческому эталону

localization

13/13 чисто

не осталось российских артефактов вроде .ru, +7, RUB

BLEU — ещё одна стандартная метрика машинного перевода, которая сравнивает результат модели с человеческим эталоном. Чем выше значения chrF и BLEU, тем ближе перевод к эталону.

Всего речь шла примерно о 2400 RU-файлах документации; детерминированную часть проверяли на 2417 файлах, а реальные LLM-прогоны — на 108.

Обратите внимание, что эти цифры не означают, что пайплайн «во всём лучше человека». Метрики измеряют конкретные вещи: структуру, термины, близость к эталону и локализационные следы.

Тем же измерителем человеческий перевод дал structure 97.4% и terminology 90.8%. То есть по структуре и проектным терминам DocFlow оказался строже человека. Это понятно. Человек может устать, не заметить один термин и поправить структуру на глаз. Код не устаёт и каждый раз применяет одни и те же правила.

Вывод в том, что модель не стала идеальной и сохранила риски. Но теперь вокруг неё появился слой, который ловит типовые ошибки и не даёт им пройти дальше незамеченными.

Что в итоге изменилось

В самом начале задача выглядела как «взять модель, дать ей файл и получить перевод».

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

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

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

Автор: GlebSmolyakov

Источник