Skip to content

feat(i18n): deterministic integrity/typography checks and real per-language style guides - #630

Open
Timur Tukaev (tym83) wants to merge 53 commits into
feat/i18n-pipelinefrom
feat/i18n-quality-improvements
Open

Timur Tukaev (tym83) wants to merge 53 commits into
feat/i18n-pipelinefrom
feat/i18n-quality-improvements

Conversation

@tym83

Copy link
Copy Markdown
Contributor

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.5v1,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.md said 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.py applies the typography rules to already-published pages, where a hand edit is otherwise never re-checked. Advisory by default, --strict to 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

  • 91 tests pass (74 existing + 17 new covering link masking, integrity findings and typography).
  • The typography lint reports 0 findings across the entire existing translated tree after false-positive tuning.

Deliberately left as follow-ups

Glossary growth loop (harvesting recurring terminology findings into glossary.yaml), a needs_human reviewer flag distinct from revise, and a severity-based soft hold on the auto-reviewed stamp.

Timur Tukaev (tym83) and others added 30 commits July 17, 2026 23:17
In-tree localization engine for cozystack.io (hack/i18n/), building on the
existing source_digest freshness convention (hack/check-i18n.sh):

- worklist.py: diff detector (missing/stale pages via source_digest)
- translate.py: Claude Opus translator with glossary, per-language style
  guides, protected code/shortcodes/URLs, SEO front-matter transcreation
- ahrefs_keywords.py: per-locale SEO keyword maps (optional, degrades
  gracefully without AHREFS_API_KEY)
- i18n-translate.yml: nightly + dispatch; PR then auto-merge (publish-then-review)
- config.yaml: languages, model routing (all Opus), scope, blog cutoff
  (last ~2 months), publish mode

Add Spanish (es) and Portuguese-BR (pt-br): hugo.yaml + production mounts,
i18n/es.toml + i18n/pt-br.toml (key parity verified).

hreflang alternates + x-default in head-end.html. CODEOWNERS exempts
content/<lang>/ so the pipeline auto-merges translations while code stays gated.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <timur.tukaev@aenix.io>

Signed-off-by: tym83 <6355522@gmail.com>
… OAuth, daily runner

Rework the pipeline per maintainer decisions:
- Auth: OAuth subscription (Max), not a metered API key — bare Anthropic()
  client resolves the logged-in credential; ANTHROPIC_API_KEY warns.
- Machine review gate on EVERY page before publish: translate -> back-translate
  + meaning-drift compare -> two native reviewers (technical editor for
  fluency, Cozystack maintainer for technical correctness) -> revise-if-needed,
  bounded by review.max_rounds. Pages written atomically only after passing.
- Daily-until-limit: run stops cleanly on 429 and resumes next day; adds
  hack/i18n/run-daily.sh (commit + daily PR + auto-merge).
- New prompts: back-translate(-compare), review-editor, review-maintainer,
  revise. config.yaml gains auth/rate_limit/back_translation/review sections
  and a translation_review front-matter stamp.
- Workflow switched off metered API key to optional CLAUDE_CODE_OAUTH_TOKEN;
  local daily runner is the primary path.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <timur.tukaev@aenix.io>

Signed-off-by: tym83 <6355522@gmail.com>
The base anthropic SDK cannot use a Claude subscription (metered API only), so
the model-call layer now uses the Claude Agent SDK (claude-agent-sdk), which
reads CLAUDE_CODE_OAUTH_TOKEN from `claude setup-token`.

- translate.py: call() runs a single-turn, tool-less Agent SDK query
  (allowed_tools=[], max_turns=1) via asyncio; hard-fails if ANTHROPIC_API_KEY
  is set (it would shadow the subscription); warns if CLAUDE_CODE_OAUTH_TOKEN
  is missing; rate-limit detection maps to a clean daily stop.
- run-daily.sh: require CLAUDE_CODE_OAUTH_TOKEN, forbid ANTHROPIC_API_KEY,
  install claude-agent-sdk.
- workflow: CLAUDE_CODE_OAUTH_TOKEN secret + install claude-agent-sdk and the
  claude-code CLI.
- config.yaml/README: document the Agent SDK subscription path + setup.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <timur.tukaev@aenix.io>

Signed-off-by: tym83 <6355522@gmail.com>
Verified on a real machine: the Agent SDK authenticates off an existing
`claude` CLI login — CLAUDE_CODE_OAUTH_TOKEN is only needed headless/CI. So:

- run-daily.sh no longer hard-fails without the token; it requires only that
  ANTHROPIC_API_KEY is unset (the money guard) and that some subscription
  credential exists (claude login or token).
- run-daily.sh bootstraps a venv (.venv-i18n) and runs the pipeline from it —
  distro Pythons are PEP 668 externally-managed, so plain pip install fails.
- translate.py: drop the misleading missing-token warning.
- gitignore the venv.

Smoke-tested end to end: claude-agent-sdk 0.2.121, single-turn tool-less query
against claude-opus-4-8 returns text over the subscription.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <timur.tukaev@aenix.io>

Signed-off-by: tym83 <6355522@gmail.com>
Ahrefs API v3 access is an expensive, separately-licensed add-on, and the
English source is already SEO-optimized during authoring — the translation
inherits that intent. The translate prompt still transcreates title/description
to read naturally per locale, so nothing is lost by removing the keyword step.

Removes ahrefs_keywords.py, keyword-maps/, the ahrefs config block, the
keyword-hint injection in translate.py, the workflow step, and the
AHREFS_API_KEY secret. No secrets are required for translation now beyond the
subscription credential.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <timur.tukaev@aenix.io>

Signed-off-by: tym83 <6355522@gmail.com>
…injection

Adversarial review found the pipeline did not actually work and that its
central claim was not enforced by code. Fixes:

P0 publish: run-daily.sh used "git diff --quiet", which ignores untracked
files — every new translation is untracked, so it reported "nothing to publish"
forever while source_digest on disk made the page look fresh the next day
(silent permanent no-op). Now uses "git status --porcelain" scoped to
content/<lang>, branches from a fresh origin/main (days no longer stack), and
honours publish_mode (pr_only was documented but never implemented).

P0 gate honesty: _parse_verdict failed OPEN — refusals, prose and truncated
JSON all silently passed, making the gate unfalsifiable; and the revise loop
stamped pages that never cleared it as auto-reviewed. Now fails CLOSED
(unparseable = revise), a missing prompt file hard-fails instead of sending a
reviewer out with no instructions, and pages are stamped honestly
(auto-reviewed vs auto-reviewed-with-findings) with per-run counters.

P0/P1 correctness: reject replies without the ===BODY=== protocol instead of
writing the model's preamble as page content; verify every protected
placeholder survived (a dropped fence silently deleted a code block);
hard-fail on unparseable front matter instead of dropping slug/date/aliases.

P1 scope: the docs version is now read from hugo.yaml (latest_version_id)
instead of hardcoded v1.4 while latest is v1.5 — 164 of 352 pages were being
translated into a noindex'd version. Scope drops 352 -> 188 pages
(2096 -> 1128 jobs). fnmatch replaced with pathlib semantics so the config no
longer lies about what it matches.

P1 security: the workflow interpolated github.event.inputs into the shell,
letting anyone with write access exfiltrate the subscription token; inputs now
go through env and are quoted.

P1 auth: auth is now a config choice (oauth-subscription | api-key) so the
project can move from a maintainer's subscription to an org-owned key without
code changes.

P2: read the whole front matter for source_digest (a long one made pages look
permanently stale and re-translate daily); unknown --lang errors instead of
silently doing nothing; stable YAML dump; drop dead config/code; stale v1.2
default in head-end.html.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <timur.tukaev@aenix.io>

Signed-off-by: tym83 <6355522@gmail.com>
Resolves the governance objection from review: nothing about CODEOWNERS or
branch protection changes any more, and no machine output reaches the
production site without a maintainer merging it.

- CODEOWNERS: reverted to the original single rule. The previous exemption for
  content/<lang>/ would have removed required review from those paths for ANY
  author, not just the bot — on a tree where goldmark renders unsafe HTML.
- publish_mode: pr_only is now the default and is actually honoured.
- Cadence: the runner still runs DAILY (each day's quota is used in full), but
  the week's output accumulates onto one i18n/week-<ISO week> branch, so
  maintainers review and merge a single translation PR per week instead of a
  stream. A new week branches fresh from origin/main.
- Docs: README documents the daily-run/weekly-PR split, the honest
  auto-reviewed vs auto-reviewed-with-findings stamps, and an "Ownership and
  continuity" section stating the intent to move from a maintainer's
  subscription to an organization-owned API key.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <timur.tukaev@aenix.io>

Signed-off-by: tym83 <6355522@gmail.com>
Second review pass over the pipeline. The findings that mattered were all
silent-failure modes: the run would look successful while producing wrong or
no output.

Nested front matter. The landing page renders its hero and cards from
taglines[], benefits[], and features[]; only top-level keys were translated,
so regenerating a localized homepage would copy the English hero back over a
hand-translated one and drop locale-only keys (seo:, l10n:) that natives had
added. Front-matter values are now addressed by path, translated at any depth,
and applied onto a deep copy, with target-only keys re-attached. The
front-matter wire format moves from "key: value" lines to JSON, which also
fixes multi-line descriptions being shredded by the line parser.

Fail closed. A "revise" verdict with an empty findings list no longer passes
the gate; latest_docs_version raises instead of returning None (which made the
scope filter a no-op and pulled in every old docs version); a duplicated
placeholder is rejected like a dropped one; a missing front-matter key is
rejected rather than shipping a page whose body is translated but whose hero
is still English.

run-daily.sh no longer stashes in the working tree — it requires a clean
dedicated clone and restores the starting branch on exit. Stashing wrote
conflict markers into translations and could pop someone else's stash. Also
fixes a BrokenPipeError that killed the run before the first page, and stops
treating normal check-i18n.sh staleness as a publish blocker.

Tests cover the pure functions. One of them corrected a docstring that claimed
path globbing narrowed `*.md` to the repo root; it does not, and the config
does not want it to.

The GitHub workflow is removed: the pipeline runs from a maintainer's clone,
and a workflow implied CI-hosted credentials we deliberately do not use.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <timur.tukaev@aenix.io>

Signed-off-by: tym83 <6355522@gmail.com>
Localized pages are indexed from day one so readers get the docs now instead of
after a native review that may take months. That trade is only honest if the
page says what it is, and until now nothing did.

Add a translation-banner partial that carries a machine-translation notice and
links to the English original. The rule is fail-safe: it shows on any
non-English page unless the front matter says `translation_review: ratified`.
Keying off the field's presence instead would have exempted every page from the
i18n PoC, which is machine output with no such field — precisely the pages the
notice exists for. Wired into the same four layouts as version-banner, so docs,
blog, and pages are covered; the homepage is deliberately left out, since
covering it means overriding the theme's home layout and a warning across a
marketing hero costs more than it buys. Strings are localized in all seven
locales.

Stop declaring es and pt-br in hugo.yaml. Declaring a language with no content
does not build nothing: Hugo emits /es/, /es/tags/, /es/categories/, /es/topics/,
/es/article_types/ and /es/404.html regardless, all `index, follow` and
self-canonical — twelve empty but indexable pages across the two, which is the
thin content the rest of this work is careful to avoid. They are commented out
in hugo.yaml and config.yaml together, with the enable order documented:
translate first, then declare, in the PR that carries the content.

The pipeline now writes `l10n: mt`, reusing the site's existing convention for
how a page was localized rather than inventing another field. README documents
what each of the three front-matter fields means and who reads it.

README also now says plainly what the review gate is not: both "native
reviewers" are the same model as the translator with different prompts, which
measures self-consistency, not native ratification. `auto-reviewed` must not be
read as "a human checked this" — only a human sets `ratified`, and only that
drops the banner. Adds a rollback section, since a pipeline that publishes to
production needs a documented way to stop.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <timur.tukaev@aenix.io>

Signed-off-by: tym83 <6355522@gmail.com>
… scope

The preferred-terms list covered de, ru, es, and pt-br. es and pt-br are not
enabled; zh-cn and hi are, and had zero entries — the two live languages with
nothing keeping their terminology consistent had the coverage the disabled ones
got. Adds all eight terms for both.

`Tenant` and `tenant` are in do_not_translate and preferred respectively, which
reads as a contradiction and is not one: the capitalized term is the API kind a
reader has to match against `kind: Tenant` in a manifest, the lower-case one is
an ordinary noun that has to be translated or the prose is unreadable. Both the
glossary and the translate prompt now say so, and a test asserts any other
overlap between the lists is a real contradiction.

Excludes oss-health/** — five pages that are front matter plus
`layout: oss-health-app`, a dashboard rendered client-side from live English
data. Translating them wraps localized chrome around an English dashboard.
They also carry the site's only `lede` field, a user-visible string that was
not in the translatable-key list and would have shipped in English on every
localized copy; dropping these pages is what makes that list true rather than
approximately true.

Tests now run against the real content tree: that config.yaml and hugo.yaml
agree on which languages are enabled, and that no unrecognized user-visible
front-matter key has appeared. The second is the one that would have caught
`lede`.

Scope is now 183 pages x 4 languages = 720 jobs.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <timur.tukaev@aenix.io>

Signed-off-by: tym83 <6355522@gmail.com>
run-daily.sh forwards the same "$@" to both scripts, so `--limit 3` made
worklist.py exit with a usage error. The preview was wrapped in
`|| true`, so it failed silently — and only when --limit was passed, which is
exactly the pilot-run case it exists to preview.

An unknown --lang printed "all languages up to date" and exited 0, since the
filter simply matched nothing. It is now an error.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <timur.tukaev@aenix.io>

Signed-off-by: tym83 <6355522@gmail.com>
A pilot run produced a page stamped `auto-reviewed-with-findings` and no
record anywhere of what the findings were. The stamp told a maintainer that
something was wrong and gave them nothing to act on, which makes the
distinction between it and `auto-reviewed` close to useless.

translate_page now returns the findings still open on the final round. The
runner prints them per page and writes a markdown report, which run-daily.sh
posts as a PR comment.

A comment, not the PR body: the report file holds only the last run, while the
PR accumulates a week of daily runs — rewriting the body each day would drop
the earlier days' findings. Comments accumulate on their own, so the thread
becomes the week's log.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <timur.tukaev@aenix.io>

Signed-off-by: tym83 <6355522@gmail.com>
Two pilot runs on the same release blog post: de cleared the gate in 2m54s, ru
took 11m24s through the revise loop. Per-page cost is dominated by whether the
revise loop runs, not by the page — which puts the 720-job backlog at roughly
35-140 hours of wall clock before daily limits are even considered, and is the
concrete reason the backlog needs an organization API key rather than a
personal subscription.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <timur.tukaev@aenix.io>

Signed-off-by: tym83 <6355522@gmail.com>
Commenting es and pt-br out of the pipeline config stopped them being
translated at all, which is not the goal — the goal is that they are not
*served* until they have content. The two lists answer different questions and
were wrongly kept identical.

config.yaml (what gets translated) now covers all six languages. hugo.yaml
(what gets built and served) still declares four; es and pt-br are declared in
the PR that lands their content.

The invariant is one-directional, and the test now says so: translated but not
declared is how a language starts; declared but not translated means the site
serves something nothing keeps fresh. A second test asserts every declared
language actually has content, which is the failure that started this — Hugo
emits ~6 indexable pages for a declared language with an empty content tree.

Backlog is now 183 pages x 6 languages = 1084 jobs.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <timur.tukaev@aenix.io>

Signed-off-by: tym83 <6355522@gmail.com>
A virtual reviewer on the ru pilot caught this, which is the gate doing exactly
its job: protect() masked each {{< figure >}} shortcode wholesale, so its
caption (rendered as visible text under the image) and alt (screen readers,
SEO) stayed English on every localized page — four English captions in a row on
a translated release post.

protect() now splits a shortcode into protected structure and exposed
visible-text attribute values (caption, alt, title). src, width, delimiters,
and param names stay byte-for-byte; the values translate like ordinary prose.
Shortcodes with no such attribute are still masked wholesale, unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <timur.tukaev@aenix.io>

Signed-off-by: tym83 <6355522@gmail.com>
Product decision: the machine-translation notice belongs where a wrong
technical detail is costly — an operator running a translated command — not on
the blog, marketing pages, or the homepage hero. Wired into docs/baseof.html
only; removed from page/single, blog/baseof, and resources/list. The partial's
guard is unchanged and layout-agnostic, so coverage is now purely a question of
which layouts call it.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <timur.tukaev@aenix.io>

Signed-off-by: tym83 <6355522@gmail.com>
A pilot on the v1.4 release blog post (63 opaque placeholders, 49 of them inline
code) had the model non-deterministically drop five of them. The gate correctly
refused to write the page — a silently deleted code block is worse than a
retry — but a page that fails this way on every daily run would never publish
while re-spending quota each day.

translate.py now retries a page a few times within the run on a protocol error,
since the loss is non-deterministic and usually clears on a fresh attempt. A
page that fails every attempt is skipped as before and stays in the worklist;
README documents that a reproducible failure means translate that page by hand.

Adds --path to translate one exact page (pilot runs, or re-translating a single
page after editing its source).

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <timur.tukaev@aenix.io>

Signed-off-by: tym83 <6355522@gmail.com>
Two pipeline outputs for reviewers to judge quality on, both of the same source
page (docs/v1.5/getting-started/install-kubernetes.md) so they can be compared
side by side. Both cleared the review gate (translation_review: auto-reviewed);
inline code, the {{% ref %}} shortcode, and front-matter structure are preserved
verbatim, and each carries the machine-translation banner shown on docs pages.

These are real, reviewable output — not fixtures. The English source is
untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <timur.tukaev@aenix.io>

Signed-off-by: tym83 <6355522@gmail.com>
…rklist

_docs_out_of_scope excluded every docs page not under docs/<latest>/, which
also dropped docs/_index.md — the version-picker landing that #593 translates
per language and records a source_digest for. The freshness lint would then
flag drift on that page with no pipeline path to refresh it.

Narrow only the versioned subtrees (docs/<ver>/...) to the latest version;
pages directly under docs/ are version-agnostic and stay in scope. Adjust the
leak-in guard test accordingly and cover the landing explicitly.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <6355522@gmail.com>
The docs version-picker landing strings were added to the active-language
i18n files on poc/i18n-multilang (#593). es and pt-br live only on this
pipeline branch, so the rebase onto that work left them without those keys —
the parity lint flags them as missing. Add the Spanish and Portuguese
translations so all seven language files stay in parity.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <6355522@gmail.com>
The runner translated on whatever branch the clone was on and only switched
to the week branch to publish, while the EXIT trap restored the starting
branch afterwards. Day 1's translations then vanished from the working tree
(they existed only on the week branch), so day 2 saw the same pages as
missing, re-translated them on a day's quota, and crashed on checkout: the
re-translated files were untracked and already present on origin's week
branch. Under set -e that left a dirty tree, and every later run stopped at
the clean-tree preflight.

Fetch and check out the week branch (or origin/main for a new week) before
running translate.py, and commit in place. The worklist now sees what is
already published, the untracked-file collision cannot happen, and content/en
is refreshed to the branch point in the same move.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <6355522@gmail.com>
On the final round of the revise loop a page with open findings was revised
once more and then written — so the text published was post-revision while
the findings stamped on it, returned to the caller, and posted to the weekly
PR described the pre-revision text. The last Opus call per failing page
produced output nothing ever checked.

Stop revising once the rounds are exhausted: publish the text the reviewers
last saw, stamped -with-findings. The findings report now always corresponds
to the pages actually shipped, and no unreviewed revision can reach the tree.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <6355522@gmail.com>
- Pin Python dependencies (requirements.txt): the runner pip-installs into
  its venv on every invocation on a machine holding maintainer credentials;
  unpinned installs would upgrade code in place next to those credentials.
- Drop || true on 'gh pr create' and 'gh pr comment': commits are already
  pushed at that point, so a failed gh call must fail the run — cron exit 0
  with pushed commits and no PR is indistinguishable from a good run.
- Remove the auto_merge code path entirely. 'Nothing reaches the production
  site without a maintainer merge' is meant as a property of the pipeline,
  not a default one config word away from off. Nothing used it.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <6355522@gmail.com>
The disclaimer banner assembled one sentence from four i18n fragments plus
link text, hardcoding English word order and punctuation into the layout.
Two shipped languages already read wrong: Hindi is verb-final (the natural
order is 'अंग्रेज़ी मूल देखें', not 'देखें अंग्रेज़ी मूल'), and German/Russian continued
in lowercase after a question mark. German also split the article from its
noun phrase across two keys.

Use one key per sentence with the link as a Go-template placeholder, so each
translator places the link and punctuation where the language needs them.
Chinese now uses full-width punctuation without a space before the link.
These UI strings never pass through the pipeline's review gate (only
content/ does) — they are reviewed by hand.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <6355522@gmail.com>
Deleting an English page left its translations behind forever: the worklist
only iterates English sources, so nothing ever revisited the leftovers, and
check-i18n.sh could only report them. translate.py now removes translations
whose English source is gone — only source_digest-stamped (pipeline-managed)
files; hand-authored locale-only pages are never touched — so the weekly PR
carries the deletion.

blog_since also accepts a rolling '<N>d' window (today minus N days,
resolved per run). The fixed ISO date would quietly rot into 'no blog post
is ever in scope' as the site ages; the config now uses '60d'. Aged-out
posts keep their existing translations and just stop being refreshed.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <6355522@gmail.com>
The README claimed a page is written 'only after it clears the gate', which
contradicted both the code and the rest of the document: pages that exhaust
the revise rounds are written and stamped -with-findings. Say plainly that
the gate triages rather than blocks, add the missing diagram edge for that
path, and note in Governance that it is a triage classifier, not a
gatekeeper.

Also: backlog math said 183 x 4 = 720 jobs while six languages are
configured (~1100 jobs, 55-220 h at the measured per-page rates); document
the rolling blog window and orphan removal; drop the duplicate .venv-i18n
gitignore entry and the last publish_mode reference.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <6355522@gmail.com>
Three operational holes found in review of the runner reordering:

- A new week's branch was always cut from origin/main. If last week's PR is
  not merged yet (maintainers do skip weeks), its translations are not on
  main: the worklist would re-translate all of it on a day's quota and open
  a second PR conflicting with the first. Base a new week on the newest
  still-existing week branch instead; once the older PR merges, the new PR
  shrinks to its own commits.
- 'checkout -B origin/...' silently discarded local commits that a failed
  push left behind (GitHub outage on day 1 cost day 1's work). Keep a local
  week branch that is ahead of — or unknown to — origin.
- Orphan removal trusted the English tree unconditionally: a missing or
  renamed content/en would mark every stamped translation orphaned and
  commit a massacre. Refuse to run against a missing/empty English root,
  and refuse to remove more than 10 orphans at once — a large batch means
  the tree moved, not that ten pages died.

Also spell out crash recovery in the dirty-tree preflight message.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <6355522@gmail.com>
Follow-ups from the second review round, all fail-safe refinements:

- Re-push any week branch that a failed push left stranded locally, at the
  start of every run. Waiting for new work to trigger the next push was not
  enough: with an empty backlog the run exits before publishing, and at a
  week boundary the new branch bases on origin refs — either way a
  locally-committed day sat unpublished and, across weeks, was re-translated.
  A branch missing on origin is only pushed when its PR state says it was
  never merged or closed: re-pushing a merged-and-deleted branch would
  resurrect it every morning and the new-week base selection would stack all
  later weeks on the zombie, freezing this clone's content/en at that week's
  snapshot. Merged/closed zombies are deleted locally instead.
- The mass-deletion floor now counts distinct English pages, not files: one
  deleted page fans out to one orphan per language, so a file-count floor
  tripped on two legitimately deleted pages across six languages.
- Tighten the week-branch glob to week-YYYY-WNN so a stray alphabetic branch
  (i18n/week-test) cannot sort above the dated ones and become a base.
- README: to discard a week, close the PR and delete the branch — the runner
  resumes on branch existence, not PR state.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <6355522@gmail.com>
Two non-blocking findings from review:

- A manual run overlapping a slow cron run passed the clean-tree preflight
  and then raced it on checkout/commit/push, silently until the second push
  failed. The script now takes a per-clone pidfile lock in TMPDIR and the
  second invocation fails fast and loudly. Pidfile rather than flock(1): the
  runner may be a macOS/launchd machine where flock does not exist. A lock
  whose pid is dead (crash, power loss) is taken over, not wedged on.
- The daily-quota stop and pages skipped on repeated protocol errors report
  only to stdout/stderr by design (no GitHub Action). README now shows the
  cron line with an append-only log and the two weekly health signals to
  glance at, so a page failing every day cannot stay invisible.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: tym83 <6355522@gmail.com>
…ranslations build

A translated page cross-linking a sibling not yet translated into the same
language carried the English {{% ref %}} shortcode verbatim; Hugo resolves ref
within the current language and hard-fails REF_NOT_FOUND, breaking the whole
build (the failing Netlify deploy-preview). Add lib.deref_shortcodes (positional
+ named-param + fragment + anchor-only + relative + .md) plus has_ref_shortcode,
and fix the two shipped sample pages to plain absolute links, which render-link
resolves in-language or falls back to English (the 5ca8e55 convention).

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
IvanHunters and others added 4 commits July 22, 2026 15:29
…th the JSON code path

Harden _ref_target: strip backtick raw-string quotes and return None for any
target that still carries a quote/backtick/=/space, so an unparseable shape trips
the fail-closed guard instead of shipping a silently-broken link; map a section
index target (/_index, /index) to the parent URL Hugo actually serves. Rewrite
the translate/revise prompt Output protocol to describe the FRONTMATTER as the
JSON object the code sends and parses (the prose previously said key: value
lines, contradicting translate.py and risking wasted retries).

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
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>
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c3359bb9-e336-4a1b-9f4e-51bc81ee8d45

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/i18n-quality-improvements

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. _REFDEF_RE masks the first word of footnote definitions: [^1]: Some note comes back with the first word replaced by a placeholder (reproduced), leaving it untranslated. No footnotes in the corpus today; exclude [^...] labels.
  2. The lint_translations.py docstring says "this makes them enforceable on every PR", but .github/workflows/i18n-lint.yml runs only check-i18n.sh and test_i18n.py. Wire it in as an advisory step or soften the claim.
  3. The belt-and-braces path in translate.py:362-363 writes a machine-translated body while keeping l10n: 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.
  4. integrity_findings do-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.
  5. _LINKDEST_RE stops 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.

Comment thread hack/i18n/lib.py
# 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+"[^"]*")?\)')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread hack/i18n/lib.py Outdated

# 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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread hack/i18n/lib.py Outdated
(r'[“][^\n]{0,80}[”]', 'English curly quotes in German prose — use „…“'),
],
"es": [
(r'(?<![¿])\b[A-ZÁÉÍÓÚÑ][^.!?\n]{5,120}\?', 'question without an opening ¿'),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOT LGTM — the round-1 fixes hold, but one of them rebuilt the placeholder guard so a hallucinated token now becomes silent content duplication, and the transcreate change made l10n machine-read without the README following.

Blockers

B1: weakened placeholder guard accepts tokens the model was never sent

File: hack/i18n/translate.py:223
Issue: The tok in masked_src filter correctly stopped requiring inner tokens of a nested stash, but it also stopped forbidding them.
Evidence: Reproduced on this tree: protect() on a backticked shortcode stores §§SC_0§§ inside §§INLINECODE_1§§'s value; a reply carrying the legit outer token plus a hallucinated §§SC_0§§ passes the guard, reverse restore expands both, the page ships with the shortcode twice, and the final residual §§ check sees nothing. At the previous head the same reply produced a visible literal token; this diff makes the outcome invisible.
Impact: silent content duplication in a published page — the exact failure class the protocol guard exists to refuse.
Fix: one line — body.count(tok) != (1 if tok in masked_src else 0): a token the model never saw must occur zero times. Plus a TestPayloadProtocol case: a stray token the model was never sent is rejected.

B2: the front-matter table no longer tells the truth about l10n

File: hack/i18n/README.md:191
Issue: The row says l10n is read by "humans triaging review". After this PR the marker is load-bearing for machines: build_worklist skips transcreate pages, translate_page refuses to write them, check-i18n.sh downgrades their drift to a warning, and update-digests skips them.
Evidence: hack/i18n/lib.py:250 (PRESERVED_L10N_VALUES), translate.py:258-262, and is_transcreate in check-i18n.sh — all introduced or rewired by this PR; the README row predates them unchanged.
Impact: a maintainer marking or unmarking transcreate cannot learn from the README that the marker changes pipeline behavior — which is the point of the feature.
Fix: update the "Read by" cell and add one sentence describing the cycle: mark transcreate → pipeline leaves the page alone → CI warns on drift → refresh by hand → update-digests <file>.

Non-blocking follow-ups

  1. is_transcreate in check-i18n.sh greps ^l10n: file-wide while recorded_l10n is bounded to front matter — same divergence class as the known file-wide ^source_digest: quirk, unreachable today. Neither accepts single-quoted YAML (l10n: 'transcreate'), and the failure direction there is overwriting a transcreation with machine output. Cheap fix: bound the shell grep to the front-matter block, parse via split_frontmatter in Python.
  2. check_digest_freshness prints "translation freshness: OK (N pages match)" immediately after a ::warning:: about a drifted transcreate page — the summary contradicts the warning it just printed.

Verified fixed from round 1, each with a repro on this tree: nested placeholder masking (protect/restore round-trips identity, 122 tests pass), decimal-comma and thousands formatting no longer trip the version check while v1.5v1,5 is still caught, the Spanish inverted-mark rule anchors to sentence start ("¿Qué es Cozystack?" is clean), the README documents the deterministic-checks stage and lint_translations.py, the typography lint runs in CI as an advisory step, footnote definitions are excluded from ref-def masking, and do-not-translate matching is word-bounded.

Comment thread hack/i18n/translate.py Outdated
body = body.strip()
bad = {tok: body.count(tok) for tok in store if body.count(tok) != 1}
bad = {tok: body.count(tok) for tok in store
if tok in masked_src and body.count(tok) != 1}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This filter correctly stopped requiring inner tokens of a nested stash, but it also stopped forbidding them: a reply carrying a hallucinated inner token (§§SC_0§§ when the model was only sent §§INLINECODE_1§§) passes the guard, reverse restore expands both, and the page ships the content twice — the residual §§ check sees nothing. One line closes it: body.count(tok) != (1 if tok in masked_src else 0) — a token the model never saw must occur zero times.

The round-1 fix that stopped requiring inner tokens of a nested stash also
stopped forbidding them: a reply carrying a legit outer token plus a
hallucinated inner token passed the guard, reverse restore expanded both, and
the page shipped the nested content twice — silent duplication the residual
check can no longer see. The guard now expects a sent token exactly once and an
unsent token zero times. README documents that l10n: transcreate drives the
pipeline, not just review triage.

Signed-off-by: tym83 <6355522@gmail.com>
@tym83

Copy link
Copy Markdown
Contributor Author

Aleksei Sviridkin (@lexfrei) both blockers from round 2 are addressed on feat/i18n-quality-improvements:

B1 — placeholder guard. The guard now enforces body.count(tok) != (1 if tok in masked_src else 0): a token the model was actually sent must survive exactly once, and a token it was never sent (an inner token of a nested stash, or a hallucination) must not appear at all. The error message no longer hardcodes expected 1 — it reports the real expected count and labels the case injected/lost/duplicated. Added TestPayloadProtocol.test_inner_token_the_model_never_saw_is_rejected, mutation-checked: it fails against the previous guard and passes now. 123 tests green.

B2 — README front-matter table. The l10n row's "Read by" cell now lists worklist.py, translate.py, hack/check-i18n.sh alongside the human triage, and a new sentence spells out the transcreate cycle (mark → pipeline leaves the page alone → CI warns on drift → refresh by hand → update-digests <file>).

The two non-blocking follow-ups (front-matter-bounded grep for is_transcreate, and the freshness summary that prints right after a drift warning) are noted for a separate pass so they don't hold this PR. Ready for re-review.

@myasnikovdaniil myasnikovdaniil left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checks themselves look good. One thing about the freshness guard, inline.

Comment thread hack/check-i18n.sh
echo " hack/check-i18n.sh update-digests $f"
else
rc=1
echo "::error::stale translation: $f"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error path knows nothing about translation scope, and that turns old translations into CI blocker. Any l10n: mt page with drifted digest fails the lint, lint runs on every PR touching content/**, but the pipeline only refreshes the latest version, so content/*/docs/v1.4/getting-started/_index.md cannot be fixed by rerunning it.

Digests match right now, I checked all four languages on main, so nothing is red yet. It goes red as soon as the upstream tags workflow pushes make update-all RELEASE_TAG=v1.4.x into content/en/docs/v1.4/, and from that moment every PR in this repo is blocked until somebody translates that page by hand or deletes it. Either downgrade out of scope pages to warning like transcreate ones, or remove them.

…advisory

check-i18n.sh no longer fails CI on a machine-translated page of a non-latest
docs version. The pipeline only refreshes the latest version and removes
superseded translations on its next run, so a drifted digest there is not
something a PR can fix by rerunning — failing the lint blocked every unrelated
PR in the repo once the upstream tags workflow pushed an old-version update.
Such pages now emit a ::warning:: (like transcreations) instead of a blocking
::error::; latest-version pages remain hard errors.

Signed-off-by: tym83 <6355522@gmail.com>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOT LGTM — both blockers from my last review are fixed, but the branch now conflicts with its base, and most of the conflicting code is the same fixes written twice, so the rebase decides what ships.

Blockers

B1: conflict with feat/i18n-pipeline

Files: hack/i18n/lib.py, hack/i18n/translate.py, hack/i18n/README.md

Issue: this branch forked the base at 12e90d5c9. The base then got cb0e2e7f4, which reimplements three things this PR already has:

  1. Skipping l10n: transcreate pages: recorded_l10n/is_hand_localized/find_hand_localized here, l10n_mode/find_transcreated there, same _L10N_RE. Git merges both skips into build_worklist without a conflict, and keeping both sides of the report hunk in translate.py gives two sections listing the same pages.
  2. Raw HTML tag masking, with a different design. Here the whole tag is masked, one line only. The base also matches multi-line tags and leaves alt/title/caption values open for translation. The two _HTMLTAG_RE definitions merge without a conflict marker, and the later one (this PR's) silently wins.
  3. Nested placeholder restore: reverse order here, a fixed-point loop there. Either works, keep one.

Evidence: git merge-tree --write-tree 8133286ac 2652726e9 reports content conflicts in exactly these three files. With a union resolution (both sides kept, one syntax break fixed) the suite runs 136 tests and fails one: the base's test_visible_attributes_still_translate, because this PR's tag pass masks alt="A running cluster" before the base's pass sees it.

Impact: an approval now would cover lib.py and translate.py in a form that won't be merged, and a mechanical resolution either keeps duplicate code or drops the base's attribute handling without any error.

Fix: rebase and pick one implementation of each. Keep this PR's _split_payload_response (it merges cleanly). The base version still requires every stored token, so there a correct reply for a backticked shortcode fails as "lost". Keep this PR's l10n row in the README table too.

Since the history gets rewritten anyway: this repo merges with merge commits, so commit messages land on main as written. df696750e, 97dd5fc31, c2ba45e88 and 41255d05a have Co-Authored-By: Claude <noreply@anthropic.com>, and my own commits a9af5f3c5 through 345f9983e have Assisted-By: Claude <noreply@anthropic.com>. Both should be Assisted-by: LLM. 41255d05a ("Two gaps found in review by Aleksei Sviridkin (@lexfrei)") and 005aa461a ("The round-1 fix that...") talk about the review, not the change. All of this was already there at my last review, I should have raised it then.

Checked

  • Placeholder guard: the reply from my last repro (the sent §§INLINECODE_1§§ plus an unsent §§SC_0§§) is rejected as "injected". At 345f9983e the same reply was accepted and the shortcode ended up in the page twice. Reverting the guard line fails test_inner_token_the_model_never_saw_is_rejected and nothing else.
  • l10n docs: the row and the new paragraph match the code. On a copy of the tree a drifted transcreate homepage warns, update-digests content/ru/_index.html clears only that warning, and bare update-digests skips all eight transcreate pages.
  • 8133286ac: the classifier matches _docs_out_of_scope in lib.py. With the real stamped pages, drift in v1.5 fails, drift in v1.4 gives four warnings and exit 0, drift in docs/_index.md fails, and a missing latest_version_id fails closed.
  • 123 tests pass, check-i18n.sh and lint_translations.py are clean.

Non-blocking

  1. hack/check-i18n.sh:105: latest_version_id: 'v1.5' parses as 'v1.5' with the quotes, so every versioned page, v1.5 included, counts as superseded. Current-version drift becomes a warning and the lint exits 0, while lib.py reads the same value correctly. register_version.sh writes double quotes, so it takes a hand edit, but nothing would show it. This line right after LATEST_DOCS="$(latest_docs_version)" fails closed on any bad parse (checked on the same copy): [ -d "$CONTENT_DIR/$DEFAULT_LANG/docs/$LATEST_DOCS" ] || LATEST_DOCS=""
  2. No test covers the classifier: with != flipped to = in is_superseded_docs all 123 tests stay green. The shell lint has no tests at all, so this is only a suggestion.
  3. hack/i18n/test_i18n.py:447: assertIn("injected", ...) passes with any label, because the fixed tail of the message ("dropped, duplicated or injected code") always has the word. With the label forced to "duplicated" the suite stays green. "injected by the model" would check it. Same for "duplicated" in test_duplicated_placeholder_is_rejected.
  4. The new warning says the pipeline removes superseded translations on its next run. At this PR's base find_orphan_translations keeps them on purpose, and only 18b140aa2 in the base changes that, so the message becomes true after the rebase. The same reason is also written in three comments, one is enough.
  5. Both non-blocking items from my last review are still there, and the summary one now also follows the new warnings: four superseded warnings, then "translation freshness: OK (22 translated pages match their English source)".
  6. The PR body becomes the merge commit message here. It still says "91 tests pass" and doesn't mention HTML masking, transcreate handling or the new check-i18n.sh warnings.

delete_branch_on_merge is on, so merging #623 retargets this PR to main.

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.

4 participants