feat(i18n): deterministic integrity/typography checks and real per-language style guides - #630
feat(i18n): deterministic integrity/typography checks and real per-language style guides#630Timur Tukaev (tym83) wants to merge 16 commits into
Conversation
Masking previously covered code, shortcodes and comments, so a URL, a bare CLI flag, a version number or a brand sitting in ordinary prose was defended by a prompt rule alone. Link destinations are now masked like any other protected span, and two deterministic checks feed the existing revise loop: - integrity_findings() compares versions, bare flags and do-not-translate terms between source and translation, catching a localized version separator or a transliterated brand. - check_typography() enforces the per-language rules the style guides state (Russian guillemets, German quotes, Spanish inverted marks, Chinese full-width punctuation, pt-PT vocabulary leaks, Devanagari digits). Both look at prose only; markup, code and link targets are exempt so the checks do not cry wolf on correct ASCII punctuation in an HTML attribute. lint_translations.py applies the same typography rules to already-published pages, where a hand edit is otherwise never re-checked. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <6355522@gmail.com>
Each guide was 3-6 lines, yet the whole fluency and typography strategy rests on injecting them into the translate and both reviewer prompts. They are now 80-100 lines each and work as an instruction set and a review rubric: register and address form, heading conventions, a decision rule for terms outside the glossary, typography, number/date formatting, the grammar traps of translating from English into that language, calque patterns with fixes, false friends, and a reviewer checklist of the MT failure modes specific to the language. Every original decision is preserved; the guides expand around them. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <6355522@gmail.com>
…te prompt Hard rule 5 said to keep numbers unchanged, while several style guides require a decimal comma, a different thousands separator or a different date order in prose. The model was reading two incompatible instructions. The rule now separates the two ideas it was conflating: a number's VALUE and any version or identifier are literal and must be reproduced exactly, while formatting in ordinary prose follows the language's style guide. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <6355522@gmail.com>
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Two gaps found in review by @lexfrei. protect() masked fences, shortcodes, comments and inline code, but not raw HTML tags — and .html pages are mostly markup (Goldmark also runs with unsafe: true, so Markdown pages carry raw HTML). content/en/docs/v1.5/ roadmap.html produced zero protected spans: its links, target="_blank" and <br /> all reached the model verbatim. Worse, the placeholder guard in _split_payload_response counts §§…§§ tokens, so a page with no tokens had nothing to fail closed on — a translated class= or a dropped </div> would ship silently, since the gate does not inspect markup, check-i18n.sh only compares digests, and Hugo renders broken HTML without complaint. Tags are now masked; the text between them stays exposed for translation. Autolinks are masked first so <https://…> is not swallowed by the tag pattern. translate_page also stamped l10n: mt unconditionally, after merge_target_only_keys — clobbering the very marker the README names as the human triage signal. Eight pages carry l10n: transcreate, including the four localized _index.html homepages, which are in translate_globs; one edit to the English homepage would have replaced a hand-written transcreation with machine output, and the banner is wired into the docs layout only, so the homepage would have carried it with no disclosure. Such pages are now kept out of the worklist, l10n is never downgraded, and pages whose English source has drifted are reported in the weekly PR so a human can refresh them deliberately. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: tym83 <6355522@gmail.com>
Aleksei Sviridkin (lexfrei)
left a comment
There was a problem hiding this comment.
NOT LGTM. The new masking passes make almost half of the in-scope docs untranslatable, and the deterministic checks contradict the style guides they are supposed to enforce.
Business context: the pipeline defended URLs, bare flags, versions and per-language typography with prompt rules only; this PR makes those guarantees deterministic (link/HTML masking plus integrity and typography checks feeding the revise loop) and turns the stub style guides into real per-language contracts.
Blockers
B1: pages with [text]({{< ref ... >}}) links can no longer be translated
protect() step 4 re-stashes placeholders created by earlier passes (hack/i18n/lib.py:58). In [text]({{% ref "/x" %}}) the shortcode pass stashes the ref as SC_0, then _LINKDEST_RE stashes that token again as URL_1 whose stored value is the SC_0 placeholder. The model only ever sees URL_1, so the placeholder guard counts SC_0 at 0 in every reply and raises ProtocolError on every attempt. restore() (lib.py:475) is a single pass in insertion order and cannot unwind the nesting either, and the back-translation path (lib.restore(back_en, tr_store)) restores with no guard at all. Same mechanism for [text](<https://...>) and [id]: <https://...>, legal CommonMark, none in the corpus today.
Reproduced: protect() on [install guide]({{% ref "/docs/install" %}}) yields a store where the URL token's value is itself a placeholder, and the guard on a token-preserving reply reports the inner token at 0. Running protect() over the whole configured scope: 82 of 181 pages produce nested placeholders (]({{< ref ... >}}) occurs about 279 times in scope). Each of those pages deterministically fails every protocol attempt on every run and burns the full retry budget of model calls first.
Fix in the same layer: skip destinations that contain the placeholder marker in the _LINKDEST_RE/_REFDEF_RE substitutions, iterate restore() in reverse insertion order (later stashes can only reference earlier tokens), and add a fail-closed residual-placeholder scan after restore for the unguarded back-translation path. Round-trip tests for [t]({{< ref "x" >}}) and [t](<https://x>) belong in TestLinkDestinationMasking.
B2: the version-integrity check makes the style guides' own mandated conversions gate-fatal
_VERSION_RE (lib.py:553) counts every bare decimal in prose and demands byte-for-byte survival as a major finding, while four style guides rewritten in this same PR mandate decimal comma in prose ("3.14" to "3,14", "0.5 vCPU" to "0,5 vCPU") and the prompt fix in this PR explicitly permits prose reformatting. The German thousands rule ("10,000" to "10.000") additionally trips the invented-token minor. gate_passed requires an empty findings list (translate.py:316) and deterministic checks refire identically every round, so a page with any prose decimal quantity can never pass: it burns all revise rounds on every source change and ships -with-findings forever. This re-creates at the checker level the exact contradiction the headline commit resolves in the prompt. Also, style-guides/pt-br.md contradicts itself on consecutive lines (37 and 38: "0.5 vCPU" must become "0,5 vCPU" and must also "stay exactly as written, including when quoted in prose").
Reproduced: integrity_findings("It is 2.5 times faster.", "Es ist 2,5-mal schneller.") returns a major demanding "2.5" verbatim; integrity_findings("It runs 10,000 pods.", "Es betreibt 10.000 Pods.") returns a minor "do not invent versions". Live in scope: docs/v1.5/getting-started/deploy-app.md carries "2.5 GB" and "1.5 GB" in bare prose.
Fix: restrict _VERSION_RE to unambiguous version tokens (v-prefixed and/or three-component), or accept the localized decimal form as equivalent for bare d.d while keeping v-prefixed and three-component tokens exact; that preserves the v1.5 to v1,5 catch the PR description advertises. Align pt-br.md lines 37-38 and the prompt's "resource quantities" wording with whichever rule wins. Tests in TestIntegrityFindings: ("It is 3.14 wide.", "Es ist 3,14 breit.") must be clean while v1.5 to v1,5 stays caught.
B3: the Spanish question/exclamation rules flag correct text by construction
The rule at lib.py:617 anchors at any capitalized word, not at sentence start, so a correctly opened question that contains a capitalized brand matches from the brand onward. This corpus capitalizes Cozystack/Kubernetes/Talos in nearly every sentence, and the exclamation rule has the same defect. Any finding blocks gate_passed, so most Spanish pages with a question can never clear the gate. There is no content/es/ yet; the first full es run hits this at scale.
Reproduced: check_typography() on the correct question "¿Qué es Cozystack?" returns a finding, matching from "Cozystack?".
Fix: anchor to sentence start (string/line start or after sentence-ending punctuation). Test: the exact string above must return [].
B4: the pipeline README no longer matches the gate it documents
The per-page pipeline diagram in hack/i18n/README.md lists every gate stage (translate, back-translate, two reviewers, revise) but not the new deterministic integrity/typography stage that feeds the same findings loop, and the file inventory table has no row for lint_translations.py. A maintainer triaging a weekly PR sees findings from: integrity-check / typography-check that the pipeline's own documentation says don't exist. Add the stage to the diagram and a lint_translations.py row to the table.
Non-blocking follow-ups
_REFDEF_REmasks the first word of footnote definitions:[^1]: Some notecomes back with the first word replaced by a placeholder (reproduced), leaving it untranslated. No footnotes in the corpus today; exclude[^...]labels.- The
lint_translations.pydocstring says "this makes them enforceable on every PR", but.github/workflows/i18n-lint.ymlruns onlycheck-i18n.shandtest_i18n.py. Wire it in as an advisory step or soften the claim. - The belt-and-braces path in
translate.py:362-363writes a machine-translated body while keepingl10n: transcreate. If that currently unreachable path ever fires, machine output ships labeled as human transcreation with no disclaimer. Raising instead of writing is strictly safer. integrity_findingsdo-not-translate counting is case-sensitive substring matching; a term that is also an ordinary English word will raise majors when a generic use is legitimately translated. Word-boundary matching would cut noise that blocks the gate via the B2 mechanism._LINKDEST_REstops a destination at the first), so a balanced-paren URL (.../Foo_(bar)) is partially masked with a stray)left in prose. None in the corpus today.
| # inline: [text](/docs/install "Optional title") -> destination only | ||
| # autolink: <https://example.com> | ||
| # refdef: [id]: https://example.com | ||
| _LINKDEST_RE = re.compile(r'(?<=\])\((?P<dest><[^>]*>|[^)\s]*)(?P<title>\s+"[^"]*")?\)') |
There was a problem hiding this comment.
B1 (nested placeholders): this pass re-stashes tokens created by the shortcode and autolink passes. [text]({{% ref "/x" %}}) becomes a URL placeholder wrapping the shortcode placeholder; the model never sees the inner token, so the placeholder guard raises ProtocolError on every attempt. 82 of 181 in-scope pages hit this. restore() cannot unwind the nesting either (single pass, insertion order), and the back-translation path restores unguarded. Fix: skip destinations that contain the placeholder marker here and in _REFDEF_RE, reverse the restore() iteration order, add a residual-placeholder check after restore. Details in the review body.
|
|
||
| # Version-ish tokens: v1.5, 1.2.3, v1.2.5. Localizing the separator (1,2,3) or | ||
| # bumping a digit changes documented behaviour, so counts must match the source. | ||
| _VERSION_RE = re.compile(r"\bv?\d+\.\d+(?:\.\d+)?\b") |
There was a problem hiding this comment.
B2 (gate-fatal contradiction): this counts every bare d.d decimal in prose and demands it byte-for-byte, while the style guides in this same PR mandate "0.5 vCPU" to "0,5 vCPU" and the prompt fix permits prose reformatting. Deterministic findings refire every round and gate_passed needs an empty list, so a page with any prose decimal can never pass (docs/v1.5/getting-started/deploy-app.md has two). Restrict this to v-prefixed and/or three-component tokens, or treat the localized decimal form as equivalent for bare d.d. Details in the review body.
| (r'[“][^\n]{0,80}[”]', 'English curly quotes in German prose — use „…“'), | ||
| ], | ||
| "es": [ | ||
| (r'(?<![¿])\b[A-ZÁÉÍÓÚÑ][^.!?\n]{5,120}\?', 'question without an opening ¿'), |
There was a problem hiding this comment.
B3 (false-positive anchor): the pattern matches at any capitalized word, so the correct question "¿Qué es Cozystack?" is flagged from "Cozystack?" onward (reproduced). With brands capitalized in nearly every sentence, most es pages with a question can never clear the gate. Anchor to sentence start (string/line start or after sentence-ending punctuation); same fix for the exclamation rule.
A masking pass could stash a span that already contained a placeholder
from an earlier pass: the link-destination pass swallowed masked
{{< ref >}} shortcodes and autolinks, and inline code wrapping a
shortcode nested the same way. The model only ever sees the outer
token, so the reply guard demanded a token that could never come back
and failed the page on every attempt — 82 of 181 in-scope pages hit
this via ref-shortcode links alone.
Skip re-stashing destinations that already carry a placeholder, restore
in reverse insertion order (a later stash can only reference earlier
tokens), require only tokens actually present in the masked payload
from the model reply, and refuse to write a page with a residual
placeholder in the body. Footnote definitions ([^1]: prose) are
excluded from reference-link masking: their first word is translatable
prose, not a URL.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The version-integrity check counted every bare decimal in prose and
demanded byte-for-byte survival, while the style guides mandate the
decimal comma there ("0.5 vCPU" -> "0,5 vCPU") and the translate
prompt explicitly permits prose reformatting. Deterministic findings
refire identically every revise round and the gate requires an empty
findings list, so a page with any prose decimal could never pass: it
burned all revise rounds on every source change and shipped
-with-findings forever. The German thousands rule (10,000 -> 10.000)
additionally tripped the invented-token check.
Enforce only unambiguous version shapes (v-prefixed or three
components); bare two-part decimals are the reviewers' job. Count
do-not-translate terms on word boundaries so a term is not demanded
back for occurrences inside larger words. Anchor the Spanish inverted
punctuation rules to sentence start: matching at any capitalized word
flagged every correctly opened question containing a brand name. Align
the translate prompt and the pt-BR guide on the same rule: quantities
in code stay literal, bare decimals in prose follow the language.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The worklist filter already skips pages marked l10n: transcreate, but translate_page itself would still happily regenerate one if reached another way, replacing a human transcreation with machine output while the marker kept promising a human wrote it. Refuse up front, before spending a single model call. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
…y lint The pipeline diagram listed every gate stage except the deterministic integrity/typography checks that feed the same findings loop, and the file table had no row for lint_translations.py — a maintainer triaging a weekly PR saw findings from checks the docs said did not exist. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
lint_translations.py promised enforcement on every PR but nothing invoked it. Advisory for now; flip to --strict per language once its backlog is clean. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
Three checks contradicted the ✓ forms the guides themselves give. The Spanish inverted-mark rules matched through a mid-sentence ¿/¡, so the guide's mandated 'Si el nodo falla, ¿qué pasa?' form was flagged — and a revise round would most plausibly insert the mark at sentence start, producing exactly the form the guide marks ✗. The Russian curly-quote rule counted U+201C alone, which also CLOSES the mandated nested „лапки“, so two nested pairs on one line read as an English pair. The bare three-component version branch read localized numeric dates (24.07.2026) and period-grouped thousands (10.000.000) as invented versions, demanding the source format back in violation of the guides. Stop the Spanish span at a mid-sentence mark, require the full English “…” pair for Russian, and exempt date/thousands shapes from the invented-token report. Also note the known multi-line-tag ceiling on the HTML tag mask. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
The drift report ran only on the main path, but the empty-worklist early return — the pipeline's normal steady state once the backlog drains — deleted the report and returned first. That is exactly the state in which drifted transcreations must KEEP being surfaced: the pipeline will never regenerate them, so a silent run hides the drift until a human happens to look. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
The drift report for hand-localized pages is built on a stale source_digest, but the surrounding tooling destroyed that signal and contradicted the contract. check-i18n.sh hard-failed CI on any digest mismatch, so a drifted transcreation made every PR touching content red — and its documented fix, a wholesale update-digests, re-stamped transcreate pages too, silencing the drift report forever while the drift persisted. The report also called the refresh optional while CI treated it as a hard failure. Drifted transcreations now produce a ::warning:: instead of an error (drift is a report for a human, not a build failure), a bare update-digests skips them so the signal survives, and passing the file explicitly re-stamps one that was genuinely refreshed by hand. Report and docstring wording now match that contract. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
The prose filter stripped an image's destination but left the leading exclamation mark, so a heading ending in a CJK ideograph followed by a figure read as half-width punctuation after a Chinese character, and an inline image in Spanish prose read as an exclamation missing its opening mark. Both findings refire identically every revise round (the model cannot remove image syntax), so any affected page burned its full revise budget and shipped stamped -with-findings. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
Feeding the file list through a pipe put the enumerating grep under pipefail: on a checkout with zero stamped translations it exits 1 and silently kills the script, where the previous process-substitution form exited 0. First-language bootstrap and fresh forks hit this. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
The prose filter stripped only space-free link destinations, so a titled link's "..." survived into the typography view. The Russian quote rule then flagged it, and the revise loop, following the style guide, would localize the ASCII quotes into guillemets — an invalid CommonMark title delimiter that stops the link parsing at all. The title stays deliberately translatable in the masked text, so the fix belongs in the prose filter, not the masking. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
… to update The explicit-file mode of update-digests made a new input class reachable: a hand-authored page without a source_digest line. The awk only rewrites an existing line, so such a page passed through untouched while the script still printed "updated" — and the drift report would keep listing the page forever while its printed remedy kept lying that it worked. Check for the line first and print a warning naming what is missing. Also match the transcreate marker exactly (not as a substring) and note the balanced-paren ceiling on the link-destination mask. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
Summary
Three quality improvements to the translation pipeline. No architectural change — the glossary + style-guide + back-translation + two-reviewer + digest design is kept and extended.
Stacked on
feat/i18n-pipeline, since that is where the pipeline lives.What
Link destinations are masked. Masking previously covered code, shortcodes and comments, so a URL sitting in ordinary prose (
[text](https://…)) was defended by a prompt rule alone — the model could mutate or localize it and nothing would catch it. URLs are now placeholders like any other protected span, while link text stays exposed and still gets translated.Two deterministic checks feed the existing revise loop.
integrity_findings()compares versions, bare CLI flags and do-not-translate terms between source and translation, catching a localized version separator (v1.5→v1,5), a dropped--flag, or a transliterated brand.check_typography()enforces the rules the style guides state: Russian guillemets, German„…“, Spanish inverted marks, Chinese full-width punctuation, European-Portuguese vocabulary leaking into pt-BR, Devanagari digits.Both look at prose only — markup, code, link targets and list markers are exempt, so the checks do not fire on ASCII punctuation that is correct inside an HTML attribute or a path.
The six per-language style guides were 3–6 lines each, yet the entire fluency and typography strategy rests on injecting them into the translate prompt and both reviewer prompts. They are now 80–100 lines each and work as an instruction set and a review rubric: register and address form, heading conventions, a decision rule for terms outside the glossary, typography, number/date formatting, the grammar traps of translating from English into that language, calque patterns with fixes, false friends, and a per-language checklist of MT failure modes. Every original decision is preserved and expanded around.
Fixed a live contradiction. Hard rule 5 in
prompts/translate.mdsaid to keep numbers unchanged, while several style guides require a decimal comma or a different date order in prose — the model was reading two incompatible instructions. The rule now separates a number's value (and any version or identifier: literal, reproduced character-for-character) from its formatting in prose (follows the language).lint_translations.pyapplies the typography rules to already-published pages, where a hand edit is otherwise never re-checked. Advisory by default,--strictto gate.Why
Only back-ticked code was structurally guaranteed; bare-prose commands, identifiers and link URLs were soft. On a docs site this dense with code, a silently mutated URL or flag is the most expensive failure mode available. The style guides were the second gap: the architecture already routes them into three prompts, but there was almost nothing in them to route.
Validation
Deliberately left as follow-ups
Glossary growth loop (harvesting recurring terminology findings into
glossary.yaml), aneeds_humanreviewer flag distinct fromrevise, and a severity-based soft hold on theauto-reviewedstamp.