feat(i18n): deterministic integrity/typography checks and real per-language style guides - #630
Timur Tukaev (tym83) wants to merge 53 commits into
Conversation
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>
…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>
|
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>
Aleksei Sviridkin (lexfrei)
left a comment
There was a problem hiding this comment.
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
is_transcreateincheck-i18n.shgreps^l10n:file-wide whilerecorded_l10nis 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 viasplit_frontmatterin Python.check_digest_freshnessprints "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.5 → v1,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.
| 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} |
There was a problem hiding this comment.
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>
|
Aleksei Sviridkin (@lexfrei) both blockers from round 2 are addressed on B1 — placeholder guard. The guard now enforces B2 — README front-matter table. The The two non-blocking follow-ups (front-matter-bounded grep for |
myasnikovdaniil
left a comment
There was a problem hiding this comment.
Checks themselves look good. One thing about the freshness guard, inline.
| echo " hack/check-i18n.sh update-digests $f" | ||
| else | ||
| rc=1 | ||
| echo "::error::stale translation: $f" |
There was a problem hiding this comment.
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>
Aleksei Sviridkin (lexfrei)
left a comment
There was a problem hiding this comment.
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:
- Skipping
l10n: transcreatepages:recorded_l10n/is_hand_localized/find_hand_localizedhere,l10n_mode/find_transcreatedthere, same_L10N_RE. Git merges both skips intobuild_worklistwithout a conflict, and keeping both sides of the report hunk intranslate.pygives two sections listing the same pages. - 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/captionvalues open for translation. The two_HTMLTAG_REdefinitions merge without a conflict marker, and the later one (this PR's) silently wins. - 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". At345f9983ethe same reply was accepted and the shortcode ended up in the page twice. Reverting the guard line failstest_inner_token_the_model_never_saw_is_rejectedand nothing else. l10ndocs: 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.htmlclears only that warning, and bareupdate-digestsskips all eight transcreate pages.8133286ac: the classifier matches_docs_out_of_scopeinlib.py. With the real stamped pages, drift inv1.5fails, drift inv1.4gives four warnings and exit 0, drift indocs/_index.mdfails, and a missinglatest_version_idfails closed.- 123 tests pass,
check-i18n.shandlint_translations.pyare clean.
Non-blocking
hack/check-i18n.sh:105:latest_version_id: 'v1.5'parses as'v1.5'with the quotes, so every versioned page,v1.5included, counts as superseded. Current-version drift becomes a warning and the lint exits 0, whilelib.pyreads the same value correctly.register_version.shwrites double quotes, so it takes a hand edit, but nothing would show it. This line right afterLATEST_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=""- No test covers the classifier: with
!=flipped to=inis_superseded_docsall 123 tests stay green. The shell lint has no tests at all, so this is only a suggestion. 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"intest_duplicated_placeholder_is_rejected.- The new warning says the pipeline removes superseded translations on its next run. At this PR's base
find_orphan_translationskeeps them on purpose, and only18b140aa2in the base changes that, so the message becomes true after the rebase. The same reason is also written in three comments, one is enough. - 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)".
- The PR body becomes the merge commit message here. It still says "91 tests pass" and doesn't mention HTML masking,
transcreatehandling or the newcheck-i18n.shwarnings.
delete_branch_on_merge is on, so merging #623 retargets this PR to main.
3854890 to
d38db12
Compare
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.