CLI tool that translates English Word documents (.docx) to Chinese using a Kimi 2.6 OpenAI-compatible endpoint. Preserves run-level formatting (bold / italic / color / size), respects a user-supplied bilingual glossary, and forces canonical Chinese terms when the LLM ignores the glossary.
Built for legal / financial due-diligence reports with hundreds of paragraphs, including three-column glossary tables, bold sub-headers, and mixed-format runs inside cells.
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
# Edit .env, set QWEN_API_KEY=<your real key>
export QWEN_API_KEY="$(grep ^QWEN_API_KEY .env | cut -d= -f2)"python translate.py INPUT.docx --glossary GLOSSARY.docxOutputs (next to the input):
INPUT_zh.docx— translated document, formatting preservedINPUT_translation.log— per-paragraph diagnostics
| Flag | Default |
|---|---|
--glossary PATH |
none |
--output PATH |
<input>_zh.docx |
--log PATH |
<input>_translation.log |
--concurrency N |
5 |
--model NAME |
kimi26 |
--base-url URL |
(private — see internal config) |
--verbose |
off (also tees the log to stderr on success) |
The API key is read only from the QWEN_API_KEY environment variable, never from the command line, so it does not appear in shell history.
A .docx containing one or more two-column tables. Left column = English term, right column = canonical Chinese translation.
- Recognized header rows (auto-skipped):
英文 / 中文,English / Chinese,en / zh,source / target. Comparison is case-insensitive. - Tables whose column count is not exactly 2 are ignored (with a warning in the log).
- Empty rows are skipped.
- Internal whitespace in keys is collapsed to single spaces.
- Duplicate keys: the last one wins, with a warning logged.
Identity-mapped entries like ATR → ATR or Setting-up Authorisation → Setting-up Authorisation are valid and tell the model to keep the term as-is.
The Word run model (a paragraph is a sequence of <w:r> runs, each with its own bold/italic/font) doesn't survive a naïve "translate the paragraph string" pipeline. This tool uses a tag-based encoding:
- Encode — adjacent runs with identical formatting are merged. Each run with non-default formatting gets wrapped in
<rN>...</rN>tags; plain runs are emitted bare. The format properties for eachrNare stored in a side map. - LLM — the system prompt instructs the model to keep the
<rN>tags verbatim while translating the surrounding text and the text inside the tags. - Decode — the translated tagged string is parsed back into runs. Each
<rN>...</rN>span becomes a run with the originalrNformatting; bare text inherits the paragraph's first-run format. Tag-mismatch fallback writes the whole paragraph as a single run with a warning, so output is never corrupted by malformed LLM responses.
Glossary handling is two-stage: the glossary is injected into the system prompt, and a post-processing step (src/term_enforcer.py) rewrites any English glossary term still present in the translation to its canonical Chinese form. This catches cases where the LLM ignores the glossary for short bold subheaders.
| Content | Status |
|---|---|
| Body paragraphs | ✅ |
| Table cells (any nesting) | ✅ |
| Section headers / footers (default, first-page, even-page) | ✅ |
| Bold / italic / underline / color / font-size / font-name | ✅ preserved |
| Already-Chinese paragraphs | ✅ skipped (avoids LLM back-translating to English) |
| URL-only paragraphs | ✅ skipped |
| Pure-numeric / pure-punctuation paragraphs | ✅ skipped |
Hyperlink text inside <w:hyperlink> |
|
Footnotes (<w:footnote>) |
|
Comments (<w:comment>) |
❌ skipped intentionally |
Tracked-changes (<w:ins> / <w:del>) |
❌ revision text skipped intentionally |
Each line is a tagged warning the user may want to act on:
[failed] paragraph N: <error> — three retries exhausted; paragraph kept as-is in source language
[tag-mismatch] paragraph N: ... — LLM dropped/added <rN> tags; whole paragraph written as single run
[term-enforce] paragraph N: 'en' → 'zh' (×K)
— enforcer rewrote K occurrences of an English term to its Chinese equivalent
[term-warning] paragraph N: 'en' appears in source but translation lacks 'zh'
— validator detected a term that wasn't translated AND wasn't auto-fixed
(often a false positive when the LLM uses a synonymous Chinese phrase)
If a run completes with no failures, no tag mismatches, no enforcement, and no warnings, the log file contains a single summary line:
# N paragraphs translated, no warnings.
QWEN_API_KEY=... .venv/bin/python scripts/smoke.pyBuilds a tiny in-memory docx with bold + italic runs and a 3-row glossary, runs the full pipeline against the real Qwen endpoint, and prints the translated paragraphs plus the log.
.venv/bin/pytest -v81 tests, all hermetic (mocked OpenAI client). No real network calls in the suite.
Several non-obvious failure modes surfaced during real-document testing on a 1080-paragraph DD report and are now defended against:
- LLM echo responses — the gateway occasionally returns the user message verbatim instead of a translation. Detected when output equals input and length ≥ 12; treated as a transient failure that retries.
- Walker dedup with
id()was unsafe — Python recycles memory addresses for short-lived lxml wrappers, so anid()-based "seen" set silently dropped ~80% of paragraphs. Walker now yields without dedup; the translator's content-keyed cache handles legitimate duplicates. - Already-translated source content — three-column glossary tables ship with the Chinese already in place. Sending Chinese-only paragraphs to an English→Chinese model causes back-translation. Walker now skips paragraphs without any Latin letters.
- Glossary ignored for bold subheaders —
term_enforcer.pypost-process rewrites English glossary terms remaining in the translation to their canonical Chinese form, with word-boundary matching that respects multi-word terms and skips identity entries. <w:hyperlink>cleanup on decode —_clear_runsremoves both<w:r>and<w:hyperlink>so paragraphs with hyperlinks don't end up with mixed leftover XML.- HTML entity round-trip — the encoder escapes
&along with</>, and the decoder useshtml.unescape, so source text containing literal<survives the trip without corruption.
LLMTranslation/
├── translate.py — CLI entry point
├── src/
│ ├── config.py — defaults + system prompt builder
│ ├── glossary.py — parse 2-column glossary docx → dict
│ ├── docx_walker.py — iterate translatable paragraphs (body / tables / headers / footers)
│ ├── format_encoder.py — runs ↔ <rN> tagged strings + format map
│ ├── translator.py — async OpenAI-compatible client with retry / cache / echo detection
│ ├── term_enforcer.py — rewrite remaining English glossary terms to canonical Chinese
│ ├── validator.py — post-translation glossary consistency warnings
│ └── pipeline.py — orchestrate walk → encode → translate → enforce → decode → save
├── tests/ — pytest, 81 tests, all mocked
├── scripts/smoke.py — real-API end-to-end smoke test
└── docs/superpowers/
├── specs/ — design doc
└── plans/ — implementation plan
See docs/superpowers/specs/2026-05-08-word-translator-design.md for the original spec, and docs/superpowers/plans/2026-05-08-word-translator.md for the task-by-task implementation plan that produced the initial cut.