Skip to content

Translate only changed segments and protect code from the translation service - #436

Merged
mlonsk merged 5 commits into
mainfrom
feature/segment-diff-translation
Oct 27, 2026
Merged

mlonsk merged 5 commits into
mainfrom
feature/segment-diff-translation

Conversation

@mlonsk

@mlonsk mlonsk commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Why

  • Every English edit re-translated (and re-billed) the whole page in every language, and overwrote reviewer edits on translated pages.
  • The first production run (New translations (Translated) #432) showed that Translated's AI rewrites Markdown backticks as quotation marks (`DisableAi` → «DisableAi»). Pages with code fences were rejected (code fences 4 -> 2), and pages that only lost inline code were written broken, because that was only a warning.
  • The review of the product translation pipeline (TE3 PR 4758) found ways to lose or block paid work that also applied here: a job timeout or a rejected push discarded billed translations, a stale translation branch could overwrite newer status entries, and a failed request blocked its file as if the delivery had been bad.

What changes

Segment-level updates. When the English of a translated file changes, the run diffs the English the translation was made from against the current English and sends only what changed.

File Sent Kept from the translation Taken from English
Markdown changed and new blocks (paragraphs, headings, list items, table rows, alerts), changed title/description unchanged and moved blocks; blocks whose only change is inline code, a marker, a link target, the list number or the heading level (those parts updated, no request) fenced code, HTML comments, table separators, include and xref-only lines
_ui-strings.json changed and new keys unchanged values removed keys are dropped
toc.yml changed and new names unchanged entries (matched by name and href) hrefs, structure
  • Source of the diff. The status entry records sourceBlob, the git blob of the English the translation was made from. Its content is checked against sourceHash; when the blob is missing, the version is looked up by sourceHash in git history.
  • Fallback to the whole file, with the reason shown in --plan and in the run report, when:
    • the translation doesn't align with that English;
    • the translation doesn't verify against that English;
    • the translation is the English text (for example a local --sync copy);
    • more than segmentDiff.maxChangedRatio (0.8) of the text changed;
    • --retry-failed retries a failed segment update.
  • Every delivered block must come back as one block of the same kind. The rebuilt page must keep the English fence and heading counts and align with the current English, so the next diff works. A rejected file keeps its old sourceHash/sourceBlob.
  • Context. Unchanged blocks around a change are sent as translatable: false context. Translated confirmed context is not billed. The default is 2 blocks per side (segmentDiff.contextSegments); the repository variable TRANSLATED_CONTEXT_SEGMENTS overrides it (-1 = whole page, 0 = none).

Code placeholders.

  • In Markdown, fenced blocks and inline code spans are sent as {{fN}}/{{cN}} placeholders, in both whole-file and segment orders, and put back on delivery.
  • Quotation marks the service puts around a placeholder are dropped.
  • A missing, repeated or unknown placeholder rejects the delivery.
  • Code is no longer sent, so it is no longer billed.
  • Losing inline code spans is now a problem; extra spans stay a warning.

Repair fix. restore_positionally no longer swaps inline code or link targets that the translation reordered. Before, it did; for example, Chinese features/code-actions.md had TRUE()/SWITCH and the IN/OR links swapped.

Paid work is kept, and failures that cost nothing don't block.

  • A rejected key (401/403) stops the run and records nothing; the message names the TRANSLATED_API_KEY secret. Other failed requests are recorded with "request": true and planned again by the next run. Only a billed delivery that fails verification waits for --retry-failed.
  • Retries: 408 is retried, Retry-After is honoured (at most 60 s), and the backoff is jittered.
  • --keep-rejected DIR saves each billed delivery that fails verification (order, response, problems) for a manual repair.
  • --merge-status OURS THEIRS rebuilds the status files entry by entry after the open translation branch is folded in: an entry comes from the branch only if the branch changed it since the merge base. The -X theirs text merge alone resolved conflicting JSON hunks from the branch, which dropped sourceBlob for about 60 entries per language in a simulation with New translations (Translated) #432.
  • Workflow: the translate step has a 35-minute timeout, below the job's 45, so a slow run fails the step and the push still runs. The push and PR steps also run on cancel. This run's translations (translations.patch, apply with git apply), the rejected deliveries and the report are uploaded as an artifact, so a rejected push loses nothing.

Trigger. The push trigger is replaced by a nightly schedule (03:00 UTC, production only) plus workflow_dispatch. One run per night collects the day's English changes, so a paragraph edited in several PRs is billed once.

Translation data (third commit; no request, recorded through the same rebuild and verification as a delivery):

  • downloads, release-history, user-options (es, zh): version, date and URL updates, "six" → "seven" and a new table row, built from the existing translated siblings.
  • Structurally stale translations marked untranslated by the baseline are repaired and recorded against the English they were made from (Crowdin sync 5c994d1): security-privacy (es, zh; link spacing matched to the English; next run sends the 3 table rows changed in Release 27.1.0 #428), kb/DR002 (es; duplicated clause removed), release notes 3_0_3, 3_1_2, 3_3_4 (zh; extra or duplicated onboarding sentence removed), personalizing-te3 (es; mistranslated heading rewritten by hand).

Other changes.

  • New command --migrate-blobs. The second commit is its output: sourceBlob for es 421 and zh 420 entries, with 0 not found.
  • The dry_run job checks out full history.
  • translation_segments.py is added to the workflow path filters and to the ruff/mypy lists.
  • README > Translating Content is updated, including a new Segment-level updates section.
  • main is merged in (Release 27.1.0).

Verification

  • translate-content.py --self-test: 239 passed, 0 failed (up from 141). New coverage:
    • segmentation fixtures;
    • segmentation round-trip over all English pages;
    • end-to-end runs in a temporary git repository for Markdown, JSON and toc;
    • fallbacks, context, both response shapes, the source-version lookup, --migrate-blobs;
    • code placeholders and heading-level repair;
    • HTTP client against fake responses: Retry-After, cap, jitter, 408, 401, 400;
    • failed requests re-planned, a rejected key stopping the run, --keep-rejected, --merge-status (three-way, including a branch delete and both-sides change);
    • a regression test for each finding of an independent review.
  • Mutation checks: disabling each safeguard makes the self-test fail.
  • Corpus check: with nothing changed, every aligned page (es 419, zh 416) rebuilds to exactly what the existing repair step produces.
  • History replay of the last 80 English commits (487 page changes per language): 0 rebuild problems. Segmented pages send 8.8% of their translatable characters.
  • Real page (features/advanced-refresh.md, one sentence changed): 110 of 5,702 characters billed, and the translated diff shows only the changed block.
  • --plan on this branch, with New translations (Translated) #432 closed: es 38 and zh 38 files, about 114k characters each (229k in total, down from 314k before the translation-data commit). About half is references/preferences.md (restructured in Release 27.1.0 #428), sent whole.
  • ruff check, ruff format --check, mypy --strict, actionlint: clean.
  • Not tested against the real API: the response shape for multi-segment orders. Both shapes are handled (one entry per segment, or per translatable segment; an echoed id must match).
  • Not tested in Actions: the scheduled run, --merge-status in the fold step, the patch artifact and the always() steps. Scheduled runs use the workflow on main, so the first nightly run after the merge is the first real run of these steps.

Notes

  • Close New translations (Translated) #432 before the first run. It contains files whose inline code was replaced by quotation marks (es/zh security/security-privacy.md, zh release notes 3_0_3, 3_1_2, 3_3_4); the run then deletes the localization branch and starts from main.
  • TRANSLATED_ENV is production. Merging this PR does not start a run; the next 03:00 UTC run sends the plan above. To control the timing, dispatch translate.yml after merging, or set TRANSLATED_ENV=sandbox until then.
  • The first segment update of a page also repairs reused blocks (code spans restored from English) and resets translated applies_to notes to English, like whole-file translation does. Expect that noise in the first translation PRs.

… service

When the English of a translated file changes, diff the English the translation
was made from (status sourceBlob, verified against sourceHash, else looked up in
git history) against the current English and send only what changed:
- Markdown: changed blocks (paragraphs, headings, list items, table rows, alerts)
  and frontmatter title/description; unchanged and moved blocks are kept from the
  translation; blocks whose only change is inline code, a marker, a link target or
  the list number are repaired without a request; fenced code comes from English.
- _ui-strings.json: changed keys. toc.yml: changed names (matched by name + href).
Files are sent whole when the translation does not align with or verify against
its English, is the English text, mostly changed (segmentDiff.maxChangedRatio) or
when --retry-failed retries a failed segment update.

Unchanged blocks are sent as untranslatable context (not billed):
segmentDiff.contextSegments (default 2 per side), overridable with the
TRANSLATED_CONTEXT_SEGMENTS repository variable.

Code placeholders: the service rewrites backticks as quotation marks, which
broke code fences and inline code in the first production run (#432). Fenced
blocks and inline code spans are now sent as {{fN}}/{{cN}} placeholders and put
back on delivery; a missing placeholder rejects the delivery. Losing inline code
spans is now a problem instead of a warning.

Also: restore_positionally leaves spans the translation reordered alone,
--migrate-blobs backfills sourceBlob, the dry-run job checks out full history.
Output of translate-content.py --migrate-blobs: the git blob of the English
version each translation was made from (es 421, zh 420 entries).
@github-actions

Copy link
Copy Markdown

Azure Static Web Apps: Your stage site is ready! Visit it here: https://lemon-wave-075f3af03-436.westeurope.5.azurestaticapps.net

- Repair heading-level-only changes from the existing translation, without a request.
- A rejected key (401/403) stops the run and records nothing; other failed requests
  are recorded but planned again, since they were not billed.
- Retry 408, honour Retry-After (capped at 60 s) and jitter the backoff.
- --keep-rejected saves billed deliveries that fail verification.
- --merge-status rebuilds the status files entry by entry after the translation
  branch is folded in, so older branch entries never overwrite newer ones from main.
- Workflow: nightly schedule instead of push; 35-minute step timeout below the job's
  45; push and PR steps run on cancel; this run's translations, rejected deliveries
  and the report are uploaded as an artifact.
… the change

- downloads, release-history, user-options (es, zh): version, date and URL updates,
  "six" -> "seven" and a new table row, built from the existing translated siblings
  through the segment rebuild and verification; recorded at the current English.
- Structurally stale translations repaired and recorded against the English they
  were made from (Crowdin sync 5c994d1): security-privacy (es, zh: link spacing
  matched to the English), DR002 (es: duplicated clause removed), release notes
  3_0_3, 3_1_2, 3_3_4 (zh: extra or duplicated onboarding sentence removed),
  personalizing-te3 (es: mistranslated heading rewritten).
@github-actions

Copy link
Copy Markdown

Azure Static Web Apps: Your stage site is ready! Visit it here: https://blue-island-085db6f03-436.westeurope.6.azurestaticapps.net

@mlonsk
mlonsk requested a review from Metallist1 October 23, 2026 09:09
@mlonsk
mlonsk enabled auto-merge (squash) October 23, 2026 09:09
@github-actions

Copy link
Copy Markdown

Azure Static Web Apps: Your stage site is ready! Visit it here: https://blue-island-085db6f03-436.westeurope.6.azurestaticapps.net

@mlonsk
mlonsk merged commit ac17764 into main Oct 27, 2026
18 checks passed
@mlonsk
mlonsk deleted the feature/segment-diff-translation branch October 27, 2026 08:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants