Add a translation pipeline - #813
Conversation
coyotte508
left a comment
There was a problem hiding this comment.
🤖 Automated bot review — this review was generated by an AI agent (moon-coder). It may contain mistakes; please verify findings before acting on them.
This PR adds a doc-builder translate command — a nightly, cache-aware machine-translation pipeline (mask → segment → translate via continuous batching → validate → publish to a bucket) plus a sync step in the shared build workflow to swap translated pages into the docs build. The design is thoughtful, the comments explain real production incidents, and the non-GPU logic has genuinely strong test coverage; no outright bugs found, but several gaps worth addressing.
Findings
- 🟡 Deleted English pages are never pruned from the bucket —
run()insrc/doc_builder/commands/translate.pyonly writes pages (write_page); nothing removes files fromtranslations/<pkg>/<lang>when the English original disappears. The workflow comment ("clears the 20 stale orphans") only holds for repo-side orphans: future orphans accumulate in the bucket, get synced intodocs/source/<lang>wholesale, and are published unlisted forever. Consider deleting out-dir files not in the current page set. - 🟡 A
--pages-filesmoke run against the real bucket clobbers production —run()writes the pruned_toctree.yml(viaprune_toctree) to the sameout_dira full run uses. Someone pointing a 3-page smoke run at the production bucket replaces the full sidebar with a 3-entry one until the next full run. Guard it (e.g. refuse--pages-filewithout an explicit non-default--bucket, or write to a scratch prefix). - 🟡 Exit code ignores page-level failure rate — the command's own docstring says "the exit code is the only way anyone finds out something went wrong," yet
run()returns 0 even if e.g. 90% of pages fail validation and fall back to English;validate.summarize()only prints a warning above 2%. Consider a non-zero exit (or configurable threshold) so the nightly job goes red. - 🟡 Unpinned
transformersin thetranslateextra —pipeline.translate_segments()depends on v5 continuous-batching internals (ContinuousBatchingConfig,continuous_batching_context_manager,transformers.generation.continuous_batching.utils.WorkloadHints— a private-ish import path), but the extra declares bare"transformers". Pin a minimum version;kernelsgot one,transformersdeserves one more. - 🟡 Glossary
keep:terms never reach the model —build_prompt()only injectspinentries;keep(ja.yml: "Words to leave in English") is only checked after the fact as a warning incheck_glossary(). If that's intentional (rely on masking + warning), the comment inglossaries/ja.ymloversells whatkeepdoes; otherwise add them to the prompt. - 🟡 Language-list format mismatch in the workflow — the existing
languagesinput is parsed withIFS=', '(commas allowed,build_main_documentation.ymlL211), but the new sync loop does plain word-splitting on$TRANSLATED_LANGUAGES. A caller who copies"ja, ko"into both inputs gets a bogusja,bucket path (it fails loudly at the_toctree.ymlcheck, at least). Normalize commas, and consider enforcing the documented "must also appear inlanguages" invariant instead of just stating it. - 🟡 The "load-bearing" tests never run in CI —
test_translate_segment.py/test_translate_pipeline.pycall the corpus round-trip "the load-bearing test in this project," butEN_DOCSdefaults to a personal path (/Users/steven/hf/transformers/docs/source/en) and skips everywhere else. Vendor a small fixture corpus (a dozen representative pages) so a slice of the round-trip runs on every CI run, and drop the personal path default. - 🔵 Missing Apache license headers on
translate/cache.py,translate/pipeline.py,translate/validate.py, and all four new test files —segment.py,translate/__init__.py, andcommands/translate.pyhave them, and every existing module in the repo carries one. - 🔵
select_pages()filters comments withline.startswith("#")on the unstripped line, so an indented# commentin a pages file becomes a "missing page" warning. - 🔵
LANGUAGE_NAMESonly knows"ja"; any other--langsilently injects the raw code into the prompt ("from English into ko"), which will degrade quality with no warning. - 🔵 A swapped link pair (
¤1¤label¤0¤) passes the per-paragraph sorted marker check inassemble_page()and is only caught page-level bycheck_links()— so one such paragraph rejects the whole page, defeating the per-paragraph containment the code works hard for elsewhere. Checking pair order forlink_open/link_closemarkers per paragraph would keep the blast radius small. - 🔵
run()has no per-page try/except aroundassemble_page/validate_plan; an unexpected exception on one page after GPU spend aborts the write-out of every remaining page — at odds with the "one bad page costs itself" philosophy. - 🔵
test_translate_pipeline.pymodule docstring referencesplan_page, which doesn't exist (it'sPagePlan); the new workflow step also omitsshell: bashwhere sibling steps declare it.
Questions: is google/gemma-4-26B-A4B-it gated, and does the job's HF_TOKEN have access? Does hf sync (bucket → staged dir) delete extraneous destination files, i.e. does the workflow-side swap actually mirror? Is os.replace() atomic on the bucket FUSE mount that SegmentCache relies on for its tmp-then-rename writes?
Verdict: Well-engineered and unusually well-tested for a pipeline of this kind — no blockers, but the orphan-cleanup, smoke-run-toctree, and exit-code items should be resolved (or explicitly deferred) before merge.
mishig25
left a comment
There was a problem hiding this comment.
The overall design is promising, but the inline findings below identify cases that can silently publish stale, missing, or corrupted documentation. I reproduced the warm-cache stale-output path, silent paragraph deletion, and successful exit after total translation failure; corpus testing exposed the fence and link cases. Please address these before enabling the nightly pipeline.
Local verification: 1,687 tests passed with the current Transformers corpus, and lint/formatting passed. uv lock --check currently fails. The actual GPU model and bucket-to-build path were not exercised. Follow-ups worth covering include $...$ math, reference/bare URLs and image structure, safe --pages-file output, .mdx support, glossary keep: behavior, a minimum Transformers version plus regenerated lockfile, and running representative corpus tests in CI.
3cc37c5 to
0379972
Compare
|
Thanks, I pinned
|
mishig25
left a comment
There was a problem hiding this comment.
Thanks for the follow-up. The fence handling, source/output reconciliation, prompt marker fix, empty-output guard, balanced destinations, and full reference-link support are all materially improved. The focused translation suite passes (123 passed, 5 skipped), and the current GitHub checks are green. However, the inline cases below can still delete or expose an incomplete live tree, turn a failed update into a successful English fallback, or silently corrupt protected content. I reproduced each case against this head, so I am keeping the review at changes requested before the nightly pipeline is enabled.
mishig25
left a comment
There was a problem hiding this comment.
Thanks for the follow-up. The disclosure-aware fallback, direct nested-image/bare-URL/inline-math cases, empty and unreadable source guards, and normal versioned publish path are materially improved. The focused translation suite passes (162 passed, 5 skipped), and 3,772 current-corpus cases pass. However, I reproduced each inline issue below against this head; they can still publish an incomplete or English fallback tree, corrupt or delete the pointed generation under repair/overlap, or silently mishandle protected Markdown. I am therefore keeping this at changes requested. GitHub currently shows no check results for this head.
| # If we are only translating some pages, the sidebar has to be trimmed to match, or the | ||
| # result cannot be built. | ||
| subset = pages if preview else None | ||
| toc_tree, toc_keys, toctree_text = load_toctree(source_dir, args.lang, args.model, gloss_sha, subset) |
There was a problem hiding this comment.
[P1] Reject sidebar entries whose source page is missing. A full run still derives pages only from files that happen to exist and never checks those pages against the sidebar. If guide.md is absent from an incomplete checkout while _toctree.yml still contains local: guide, this publishes a generation without guide.md, preserves the dangling sidebar entry, and exits 0. Before publishing a full run, resolve every sidebar local to a planned Markdown page and fail closed if any are missing.
| if generation == current and not publish.verify_generation(root, generation, tree): | ||
| print(f"[translate] generation {generation} is already published and intact, nothing to do") | ||
| else: | ||
| bad = publish.write_generation(root, generation, tree) |
There was a problem hiding this comment.
[P1] Repair a damaged live generation under a fresh directory. When generation == current but verification finds damage, this writes directly into the directory named by CURRENT. Files are then replaced one by one, so readers can observe a partial repair; unexpected extra files are not removed either, leaving the generation permanently unverifiable. Build and verify a new unreferenced repair generation, then promote its pointer only after the entire tree is intact.
| """ | ||
| path = Path(path) | ||
| path.parent.mkdir(parents=True, exist_ok=True) | ||
| tmp = path.with_name(path.name + ".tmp") |
There was a problem hiding this comment.
[P1] Give each atomic writer a unique temporary path. Every process writing the same destination also writes the same path.tmp. In a deterministic two-writer reproduction, writer A returned success, writer B raised FileNotFoundError, and CURRENT contained B's bytes. Use a unique temporary file in the destination directory, opened exclusively, before os.replace; pointer/manifest publication also needs per-package/language serialization or compare-and-swap semantics.
| # checking the manifest against reality. None on a first run. | ||
| # The pointer is read once and carried, rather than re-read by each thing that needs it. | ||
| current = None if preview else publish.read_pointer(root) | ||
| read_dir = root if preview else publish.current_dir(root) |
There was a problem hiding this comment.
[P1] Derive the read directory from the captured pointer. The comment says the pointer is read once, but current_dir(root) reads it again. If another publisher promotes between these calls, current can name generation A while read_dir points to B, so reconciliation and fallback use inconsistent snapshots. Build read_dir from the already captured current, then recheck or serialize before the no-op/promotion decision.
| # The manifest goes last, and only records a generation that is actually published. If the | ||
| # run dies before this the manifest describes an older generation, disagrees with the | ||
| # pointer, and the next run rebuilds rather than trusting it. | ||
| publish.save_manifest(manifest_target, {**manifest, "generation": generation}) |
There was a problem hiding this comment.
[P1] Prevent stale publishers from overwriting the manifest. Promotion and manifest publication are not one serialized or conditional transaction: writer A can promote A, writer B can promote and record B, and then A can overwrite the manifest with A even though CURRENT names B. Serialize publishers, or make the manifest generation-specific and select it through the same atomic pointer.
| targets.append(("ref", match.group("dest").lower())) | ||
| for match in REF_DEF_RE.finditer(text): | ||
| targets.append(("ref-def", match.group(1).lower())) | ||
| targets.extend(("url", m.group(0)) for m in BARE_URL_RE.finditer(text)) |
There was a problem hiding this comment.
[P1] Do not rediscover bare URLs from restored Japanese prose. Japanese commonly attaches particles without spaces. A correct restoration such as https://github.com/deepseek-ai/DeepSeek-V3を参照してください is tokenized here as one longer URL, producing a false lost/gained error even though the protected URL placeholder survived exactly. Compare known URL values through placeholder provenance instead of rerunning this permissive regex over translated prose.
| """ | ||
| targets = list(scan_links(text)) | ||
| for match in REF_LINK_RE.finditer(text): | ||
| targets.append(("ref", match.group("dest").lower())) |
There was a problem hiding this comment.
[P2] Preserve the image kind for reference-style links. REF_LINK_RE captures whether the source used !, but every match is recorded as ref. Consequently ![Diagram][fig] can lose !, become [Diagram][fig], and still pass with identical targets. Mask (?:[^$\n]|\n(?![ \t]*\n)){0,200}?(?<![\s\\])[ \t]" | ||
| r")\$(?!\$)" |
There was a problem hiding this comment.
[P2] Avoid interpreting price ranges as inline math. The closing delimiter may be followed by a digit, so US$5 to US$10, $5-$10, and $5/$10 are matched as formulas such as $5 to US$. That silently shields ordinary price prose from translation, and round-trip checks cannot detect it. Tighten the closing-dollar context—at least disallow a following digit—and add price-range fixtures.
| if preview: | ||
| publish.write_tree(root, tree) | ||
| print(f"[translate] wrote {len(tree)} file(s) to the preview tree") | ||
| return 0 |
There was a problem hiding this comment.
[P2] Signal unsuccessful preview runs. Preview mode reaches this unconditional success return before the rejection-rate status, and the empty-page guard is disabled for previews. A pages file selecting nothing, or a preview with failed pages falling back to English, can therefore write an empty/degraded smoke tree and exit 0. Keep previews isolated, but return nonzero when no requested page exists or the warning threshold is exceeded.
| # then rewrites CURRENT to name the finished one. Read the pointer and take that | ||
| # folder, so we can only ever download a run that completed -- a job still writing, | ||
| # or one that died partway, is in a folder nothing points at. | ||
| uvx --from huggingface_hub hf sync "$BUCKET/$lang/CURRENT" "$staged.pointer" |
There was a problem hiding this comment.
[P1] Seed the generation layout before enabling this consumer. The existing Transformers/Japanese bucket still uses the flat layout (98 Markdown files plus _toctree.yml) and has neither CURRENT nor generations/. Enabling translated_languages therefore makes this sync fail until a producer has successfully seeded the new format. Please coordinate and verify that migration step before rollout, or retain a temporary legacy fallback.
82662a9 to
c2a0cea
Compare

This PR adds a
translatecommand to translate docs into other languages. There isn't very good coverage for the other languages, they fall out of sync with the English docs, and are difficult to keep up with and maintain because most times we can't review translation PRs.Design
A nightly scheduled Job under
hf-doc-buildrunsdoc-builder translate transformers --lang ja --bucket /bucketwhich clones Transformers and readsdocs/source/en/**. It masks things that shouldn't be translated - like specific doc-builder syntax - and segments the docs. Translated paragraphs are stored in a Bucket,hf-doc-build/doc-translate/cacheand reused nightly to compare whether existing changes have been made. If there are no changes, it exits.If something is new, it loads a model to only translate the prose that's changed using Transformers' native continuous batching. The finished
.mdfiles are written to a new Bucket,hf-doc-build/doc-translate/translations.On the Transformers side, write
build_ja_documentation.ymlto call on the doc-builder's shared build workflow to sync the translations. It downloads the finished files and swaps them in.doc-builder builduses the existing page-level caching to only build pages whose content changed and then push tohf-doc-build/doc-build.Preview