fix(translate): stop shipping units the model returns untranslated - #2277
Merged
Merged
Conversation
martyanovandrey
requested review from
a team and
diplodoc-bot
as code owners
September 16, 2026 10:08
martyanovandrey
requested review from
Yurok868 and
goldserg
and removed request for
a team
September 16, 2026 10:08
|
Yurok868
approved these changes
Sep 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



yfm translateships pages in the source language. A fragment the model returns unchanged is detected, logged and then written to the output file as is, so a heading, a one-line include or a whole page can end up in the translated docs still in russian. This PR removes the cause and adds a retry for what is left.How it was measured
The eval harness (
npm run translate:eval --real) against Elizadeepseek-v4-flash, ru->en corpus, 16 pages, 500 units, default thresholds, 8 runs of the same configuration:syntax/links.mdwhole page in 3 runs, a one-line include in 4, theabout.mdheading in 6about.mdheading in 1 runBefore the fix the failures were structural, and every glossary violation in the series was a side effect of a page that was never translated. After it, one run still echoes the
about.mdheading (# О Diplodoc), which the model occasionally returns as is even in a request of its own. The second failure of the series has nothing to do with translation: the judge left one pair of 409 unscored.What changes
The glossary moves to the system prompt
The rendered glossary (
Use these required term translations:\n- заметка → note ...) used to sit in the user message directly before the fragments. A short fragment that follows a list ofsource → targetpairs is read as another entry of that list and echoed back:# О Diplodoc# О Diplodoc# О Diplodoc# О DiplodocPresence is what matters, not size or relevance: one unrelated pair is as bad as six. Long prose is nearly immune, which is why headings, table cells and one-line includes were the pages that failed.
buildMessages()already solves this for context files: they land in the system prompt because they are identical for every batch and play well with provider-side prompt caching, and a{{contextFiles}}placeholder in either template overrides the placement. The glossary now works the same way, so{{glossary}}in a custom system or user prompt still takes full control.Units returned untranslated are re-requested
retryUntranslated()sits next to the existingrepairDamaged()and reusesretryFragments(): fragments that came back byte-identical to their source while still carrying source-script text are re-requested once, in a request of their own, with the same prompt. Isolation alone helps - the same heading failed 6 runs out of 8 inside the full corpus and 1 run in 10 as a request of its own.The retry deliberately carries no instruction about the previous attempt. Telling the model that its answer came back unchanged makes it echo far more often: 15 of 20 against 2 of 20 with the plain prompt.
Acceptance mirrors the rule that triggered the retry. A stricter rule - rejecting any answer that still carries source-script text - would discard legitimate translations of pages that quote the source language on purpose and ship their source text instead.
Reporting
The run report gains
fixes.untranslatedRetriedandfixes.untranslatedKeptnext to the markup counters, and the run summary mentions them when they are non-zero.units.untranslatedkeeps its meaning and is now the residual after the retry.Cost and compatibility
{{glossary}}get their glossary in the system message from now on.Eval harness
The harness captures translation units through a local echo endpoint, and an echoed fragment is indistinguishable from a refused translation, so the retry re-requested every unit of the russian side and the capture recorded it twice. The capture server now skips a fragment that the previous request for the same file already recorded. The window is deliberately one request wide: the capture run is sequential, so a retry always arrives right after its batch, while
translate()never asks for the same unit twice on its own (it caches deferred units by text for the whole run), so a wider window could only drop units that are genuinely new.The reference stops expecting translated inline code
The system prompt tells the model not to translate text inside inline code, while the corpus reference translated it in ten spans (
`текст_ссылки`,`{wide-content title="название таблицы"}`), so a run failed whenever the model followed its own instructions. The reference now keeps the source text there, andtests/eval/README.mdrecords the rule together with the prompt line it mirrors. The untranslated check tolerates a line the reference keeps in the source language, so a model that does translate such a span still passes.Design notes and the full measurement log:
docs/specs/2026-09-16-translate-untranslated-units-design.md.