# Translation silently truncated when JSON stream parsing breaks (no error raised)

**URL:** https://meta.discourse.org/t/translation-silently-truncated-when-json-stream-parsing-breaks-no-error-raised/407251
**Category:** Bug
**Tags:** ai, dynaloc
**Created:** [July 10, 2026, 9:31am UTC](https://meta.discourse.org/t/translation-silently-truncated-when-json-stream-parsing-breaks-no-error-raised/407251 "2026-07-10T09:31:25Z")
**Posts on this page:** 4
**Page:** 1

<div class="post-metadata">

### Author: ![dorina](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/dorina/32/490279_2.png) [@dorina](https://meta.discourse.org/u/dorina)
#### Post date: [July 10, 2026, 9:31am UTC](https://meta.discourse.org/t/translation-silently-truncated-when-json-stream-parsing-breaks-no-error-raised/407251/1 "2026-07-10T09:31:25Z")

</div>

**Summary**  
`DiscourseAi::Translation::PostLocalizer` (content localization) occasionally  
produces truncated translations with no error logged anywhere (Rails log,  
Sidekiq log, Sidekiq dead/retry queues). The truncation happens mid-sentence,  
silently, and the partial result is saved as if it were a complete, successful  
translation.

**Observed on:** Discourse core + discourse-ai `46bea6613` (2026-07-09), LLM:  
Gemini 2.5 Flash via Google provider.

**Reproduction evidence**  
Two real cases on our instance:

- A ~2090-char German post translated to Spanish produced only 50 chars,  
cutting off immediately before a quoted word (“aufwendiger”) in the source  
text.
- A ~1830-char German post translated to English (83 chars) and Slovak  
(97 chars) — both cut off at the _exact same point_ in the sentence,  
immediately before an emoji shortcode (💥) in the source text.  
All other locales (fr, it, pt, ru, he) for the same post translated  
completely (1600–1950 chars).

**Root cause (as far as I can trace it)**  
In lib/completions/structured\_output.rb, StructuredOutput#read\_buffered\_property  
falls back to DiscourseAi::Utils::BestEffortJsonParser.extract\_key whenever  
@partial\_json\_tracker.broken? is true. This fallback returns whatever was  
parsed before the JSON stream broke — with no exception, no warning, no  
indication anywhere that the output is incomplete. In  
lib/translation/base\_translator.rb#get\_translation, this value is used as-is  
as the final translation and persisted via PostLocalization#save!.

Both truncation points in our data land immediately before a “special”  
character in the source text (a straight quote, an emoji shortcode),  
suggesting the JSON streaming tracker becomes confused by certain  
characters/sequences in the model’s streamed output and marks the stream  
broken? prematurely.

**Impact**  
Content is published with visibly incomplete, sometimes nonsensical  
translations, and there’s no operational signal (log, dead job, admin  
notification) to catch it. We only discovered it because a community member  
spotted mid-sentence garbage in the UI.

**Suggested fixes**

- Surface a warning/error (Rails.logger + Discourse-error-log) whenever  
StructuredOutput#broken? is true and the best-effort fallback is used, so  
admins can detect this instead of silently shipping partial content.
- Consider validating translated length against a sanity threshold relative  
to source length before saving a PostLocalization.

Happy to provide full raw/translated text pairs privately if useful for  
debugging the JSON tracker.

---

<div class="post-metadata">

### Author: ![Denis\_Kovalenko](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/denis_kovalenko/32/549496_2.png) [@Denis\_Kovalenko](https://meta.discourse.org/u/Denis_Kovalenko)
#### Post date: [July 10, 2026, 6:20pm UTC](https://meta.discourse.org/t/translation-silently-truncated-when-json-stream-parsing-breaks-no-error-raised/407251/2 "2026-07-10T18:20:13Z")

</div>

Adding a data point to this — we’re seeing what looks like the same underlying  
mechanism, but manifesting differently.

**Observed on:** Discourse core + discourse-ai, LLM: GPT-5.1 via OpenAI (custom API connection, not a seeded model).

**Reproduction evidence**

Same source post (Russian, ~2000 chars, headers/bold/bullet lists/links),  
translated into multiple locales via native AI translation:

- Polish (pl): the embedded image markdown lost its structure — went from  
`![alt text|690x460](upload://...)` to a malformed link missing the `!`  
prefix and the `|` separator, with the alt text and dimensions run  
together as plain text. Rendered as a clickable link instead of an  
embedded image.
- Ukrainian (uk): literal `\n\n` sequences appear as plain text throughout  
the entire post, in place of paragraph breaks. The image markdown in this  
same translation was intact — so the corruption isn’t tied to one fixed  
symptom, it varies by run.

Unlike the truncation described above, our case shows no missing content —  
the full text is present, but with corrupted escape sequences / markdown  
syntax rather than a shortened output. Different provider (OpenAI vs Google  
here), different symptom (corruption vs truncation), same suspected root:  
StructuredOutput#read\_buffered\_property falling back to  
BestEffortJsonParser#extract\_key, which doesn’t unescape JSON string  
sequences (`\n` stays literal) and appears to also mishandle special  
characters adjacent to markdown syntax when the fallback triggers mid-parse.

No errors in Sidekiq or Rails logs on our side either — same silent-failure  
behavior.

---

<div class="post-metadata">

### Author: ![Falco](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/falco/32/179432_2.png) [@Falco](https://meta.discourse.org/u/Falco)
#### Post date: [July 14, 2026, 11:39pm UTC](https://meta.discourse.org/t/translation-silently-truncated-when-json-stream-parsing-breaks-no-error-raised/407251/4 "2026-07-14T23:39:19Z")

</div>

Started working on a possible fix at

> <https://github.com/discourse/discourse/pull/41716>
>
> Reported at https://meta.discourse.org/t/translation-silently-truncated-when-jso…n-stream-parsing-breaks-no-error-raised/407251
> 
> \## The bug
> 
> Some providers stream structured output whose string values were unescaped by an outer JSON parse, so real newlines appear inside string values. When that happened, \`JsonStreamingTracker\` had two failure modes:
> 
> \- It marked the stream broken and \`StructuredOutput\` fell back to \`BestEffortJsonParser\`, whose extraction regex (\`\[^"\]+\`) cut the value at the first escaped quote and left \`\\n\` sequences as literal text. A 2,000-char translation could come back as ~50 chars, cut right before the first quoted word — exactly what the report shows.
> \- Its escape-and-resume hack (\`String#dump\` + buffer-growth offset) miscomputed the resume index whenever the chunk contained non-ASCII, quotes, or backslashes, silently duplicating or corrupting content \*\*without ever marking the stream broken\*\*.
> 
> Testing a realistic corrupted payload across chunk sizes 1–60: 31 produced the truncated fallback, 27 produced silent corruption, 1 raised, and only 1 came out correct. Either way the result was persisted as a successful translation with nothing in the logs.
> 
> \## The fix
> 
> Replace the hand-rolled parsing with two gems and keep only glue:
> 
> \- \*\*json\_completer\*\* (pure Ruby): \`JsonStreamingTracker\` now feeds the cumulative buffer — with control characters re-escaped — to an incremental, truncation-tolerant parser and notifies consumers of changed keys. The corrupted payloads above stream correctly at every chunk size, so the broken-stream path is only reached for responses that aren't JSON at all.
> \- \*\*smarter\_json\*\*: \`BestEffortJsonParser\` becomes a three-attempt chain (strict-with-completion → control-chars re-escaped → lenient) covering the quirk shapes the old regexes handled: single quotes, unquoted keys, markdown fences, prose-wrapped JSON.
> 
> This deletes the vendored 668-line SAX parser, the resume hack, and all manual regex extraction (net −694 lines), and adds a log warning whenever a response falls back to best-effort parsing.
> 
> \## Behavior changes
> 
> \- Scalars now stream progressively: mid-stream \`read\_buffered\_property(:number)\` returns the digits buffered so far instead of \`nil\`. Consumers act on final values, so this only affects mid-stream reads.
> \- Arrays of objects stream partial objects mid-stream instead of returning \`nil\` until finish.
> \- A trailing comma in an array reads as a \`nil\` placeholder slot until the next element arrives.
> \- Partial tool calls surface a few more progressive updates (the openai endpoint spec count moved 128 → 134); values still only ever grow.
> 
> \## Tests
> 
> \- Regression specs for the report: unescaped control characters with escaped quotes/emoji streamed across chunk boundaries, fenced + unescaped responses, truncated JSON, numeric casting.
> \- 916 examples green across \`completions/\`, \`translation/\`, \`modules/ai\_helper/\`, and \`utils/\`.

---

<div class="post-metadata">

### Author: ![Falco](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/falco/32/179432_2.png) [@Falco](https://meta.discourse.org/u/Falco)
#### Post date: [July 27, 2026, 11:00am UTC](https://meta.discourse.org/t/translation-silently-truncated-when-json-stream-parsing-breaks-no-error-raised/407251/5 "2026-07-27T11:00:52Z")

</div>

This topic was automatically closed after 11 days. New replies are no longer allowed.
