Тихое усечение перевода при сбое разбора потока JSON (без выброса ошибки)

Резюме
DiscourseAi::Translation::PostLocalizer (локализация контента) иногда
выдает усеченные переводы без регистрации ошибок в любом из журналов (лог Rails,
лог Sidekiq, очереди мертвых/повторных задач Sidekiq). Усечение происходит посередине предложения,
тихо, и частичный результат сохраняется так, будто это полный, успешный
перевод.

Наблюдается на: Discourse core + discourse-ai 46bea6613 (2026-07-09), LLM:
Gemini 2.5 Flash через провайдера Google.

Данные для воспроизведения
Два реальных случая на нашем экземпляре:

  • Немецкий пост длиной ~2090 символов, переведенный на испанский, дал результат всего в 50 символов,
    обрезавшись непосредственно перед цитируемым словом («aufwendiger») в исходном
    тексте.
  • Немецкий пост длиной ~1830 символов, переведенный на английский (83 символа) и словацкий
    (97 символов) — оба были усечены в одной и той же точке предложения,
    непосредственно перед шорткодом эмодзи (:collision:) в исходном тексте.
    Все остальные локали (fr, it, pt, ru, he) для того же поста были переведены
    полностью (1600–1950 символов).

Причина (насколько я могу ее отследить)
В lib/completions/structured_output.rb, StructuredOutput#read_buffered_property
обращается к DiscourseAi::Utils::BestEffortJsonParser.extract_key, когда
@partial_json_tracker.broken? возвращает true. Этот метод возвращает все, что
было распарсено до того, как поток JSON прервался — без исключения, без предупреждения, без
любого указания на то, что вывод неполон. В
lib/translation/base_translator.rb#get_translation это значение используется как есть
в качестве финального перевода и сохраняется через PostLocalization#save!.

Обе точки усечения в наших данных приходятся непосредственно перед «специальным"
символом в исходном тексте (прямая кавычка, шорткод эмодзи),
что указывает на то, что трекер потокового JSON сбивается с определенных
символов/последовательностей в потоковом выводе модели и помечает поток
как сломанный преждевременно.

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

Предлагаемые исправления

  • Выводить предупреждение/ошибку (Rails.logger + Discourse-error-log) всякий раз,
    когда StructuredOutput#broken? возвращает true и используется резервный метод best-effort, чтобы
    администраторы могли обнаруживать это, вместо того чтобы тихо отправлять частичный контент.
  • Рассмотреть возможность проверки длины перевода относительно разумного порога по отношению
    к длине исходного текста перед сохранением PostLocalization.

Готов предоставить полные пары исходных/переведенных текстов в частном порядке, если это полезно для
отладки трекера JSON.

2 лайка

Добавлю еще один пример — мы наблюдаем то, что выглядит как тот же самый базовый механизм, но проявляющийся по-разному.

Наблюдается на: Discourse core + discourse-ai, LLM: GPT-5.1 через OpenAI (пользовательское API-соединение, не встроенная модель).

Подтверждение воспроизводимости

Один и тот же исходный пост (на русском, ~2000 символов, заголовки/жирный текст/маркированные списки/ссылки), переведенный на несколько языков через встроенный AI-переводчик:

  • Польский (pl): структура встроенного изображения в Markdown потеряна — из ![alt text|690x460](upload://...) превратился в некорректную ссылку без префикса ! и разделителя |, где альтернативный текст и размеры слились в обычный текст. Отображается как кликабельная ссылка вместо встроенного изображения.
  • Украинский (uk): буквенные последовательности \n\n отображаются как обычный текст по всему посту вместо разрывов абзацев. Markdown-разметка изображения в этом же переводе осталась целой — так что проблема не связана с одним фиксированным симптомом, она варьируется от запуска к запуску.

В отличие от описанного выше усечения, в нашем случае нет отсутствующего контента — полный текст присутствует, но с поврежденными escape-последовательностями / синтаксисом Markdown, а не с укороченным выводом. Другой провайдер (OpenAI против Google), другой симптом (повреждение против усечения), та же предполагаемая причина: StructuredOutput#read_buffered_property откатывается к BestEffortJsonParser#extract_key, который не раскодировывает JSON-строковые последовательности (\n остается буквальным) и, похоже, также неправильно обрабатывает специальные символы рядом с Markdown-синтаксисом, когда откат происходит во время парсинга.

Ни в логах Sidekiq, ни в логах Rails с нашей стороны ошибок нет — то же самое поведение тихого сбоя.