Add translation check gate and herdr batch workflow docs

check-translation.py: 11-check deterministic gate (line/paragraph/heading
parity, emphasis preservation, CJK and Chinese-punctuation leakage, bold
leakage, 万/亿-aware digit fidelity, terminology vs term map, bilingual
freshness). Built and calibrated on the 9-book review batch (2026-08-07).

AGENTS.md: add Workflow C (batch translate/review with herdr) — pane setup,
the three canonical prompts (translate/review/apply), and gotchas learned on
the 9-book run.

readme.md: document the gate in common tasks and point to Workflow C.
This commit is contained in:
iacore
2026-08-08 12:53:48 +08:00
parent da0ee57834
commit 6144d63b68
3 changed files with 490 additions and 1 deletions
+77
View File
@@ -159,6 +159,82 @@ else?" after a direct-edit pass, do one more full systematic read and batch agai
--- ---
## Workflow C: Batch Translate / Review with Herdr(批量翻译/审阅)
Run one book/article per omp session in its own herdr pane. Proven on the
9-book batch (2026-08-07): 9 review panes (`omp --model slow`), each moved to
its own tab, then one apply pass per pane, all 9 green on the check suite.
### Setup
1. One herdr workspace; the main omp session in the root pane orchestrates.
2. Rename each book dir with a shared batch prefix so they sort and zip
together: `batch0-21【《心经》的人生智慧】`, `batch0-55【…】`, …
3. Split one pane per book. `herdr pane split --cwd <dir>` does NOT stick
(panes launch in the workspace root) — pass the absolute book dir to omp's
own `--cwd` at launch instead.
```fish
# 5 right of the main pane, then 4 below it
r=$(herdr pane split --current --direction right --no-focus)
p=$(echo "$r" | jq -r '.result.pane.pane_id')
# repeat: herdr pane split --pane $prev --direction down --no-focus
herdr pane rename <pane> review-<book> # label each pane
```
4. Launch the slow model in every pane (background all, then `wait`):
```fish
herdr pane run w7:p2 "omp --model slow --cwd /abs/path/to/book-dir" &
# ... one line per book
wait
```
Verify each pane landed in its book dir:
`herdr pane read <pane> --source recent-unwrapped --lines 8` — the TUI title
shows the dir.
5. Submit the review prompt (B below) to all panes at once. Poll
`herdr pane get <pane> | jq -r '.result.pane.agent_status'` until every
pane is `idle`/`done` (allow ~1 h for long books).
6. Move each pane to its own tab so the batch is watchable while it runs:
`herdr pane move <pane> --new-tab --label <name> --no-focus`.
7. After all reviews finish, submit the apply prompt (C below) to every pane
again, poll to completion, then gate with `check-translation.py` and
spot-check that fixes actually landed in `target.dj`.
8. Package: `zip -r <batch>-reviewed.zip batch0-*/` and verify the entry count
(9 books × 6 files each = 63 entries).
### The three prompts
**A — Translate a book** (one session per book, Workflow A):
> Translate the book <NAME> (file: <NAME>.docx) from Chinese to English for the MPI translation project. Follow Workflow A in ../../toolkit/AGENTS.md: (1) load the mpi-translation and mpi-terms-search skills from ../../toolkit/skills/; (2) extract the Chinese source with ../../toolkit/scripts/docx2dj.fish '<NAME>.docx' into source.dj; (3) translate the ENTIRE book into target.dj (English; line count matches source; you ARE the model — no external translation APIs; look up key Buddhist terms with ../../toolkit/terms-database/search.py); (4) generate bilingual.dj: ../../toolkit/scripts/gen-bilingual.py source.dj target.dj > bilingual.dj; (5) self-review with mpi-translation-review (self mode), edit target.dj, and write edit-suggestions.dj for terminology issues; (6) regenerate bilingual.dj and verify source/target line counts match. Deliverables in this folder: source.dj, target.dj, bilingual.dj, edit-suggestions.dj. Do not commit binaries or bilingual.dj. Report when done.
**B — Review a book** (herdr pane, slow model; writes `review-findings.dj` only):
> Review the translation in this directory (your cwd is the book dir). Files: source.dj (Chinese source), target.dj (English translation), bilingual.dj (bilingual), edit-suggestions.dj (prior edit suggestions, may be stale). Read source and target fully and review the English translation for: (1) accuracy vs source — mistranslations, omissions, additions, meaning drift; (2) Buddhist terminology — consistent, standard renderings; (3) fluency and register — natural, idiomatic English appropriate to the genre; (4) completeness — every source section covered. Write findings to review-findings.dj in this directory, organized by severity (critical/major/minor), each with location and a concrete fix. Do NOT modify source.dj, target.dj, or bilingual.dj. End your final message with a one-paragraph summary.
**C — Apply findings** (same pane, direct-edit mode):
> Apply your review findings now. This is direct-edit mode per project convention. 1) Read review-findings.dj and target.dj fully. 2) Apply EVERY actionable finding (all must-fix and considerations) to target.dj with exact-string replacements, batched in one pass. 3) CRITICAL: do not add or remove any line — source.dj and target.dj line counts must remain identical. 4) Regenerate bilingual.dj: <abs path>/gen-bilingual.py source.dj target.dj > bilingual.dj 5) Run <abs path>/check-translation.py . and report which checks pass/fail. Report what you changed and the check result.
### Gotchas(踩过的坑)
- `herdr pane split --cwd <dir>` doesn't stick — launch omp with `--cwd <absolute path>` instead.
- Write the poll loop carefully: wait for `agent_status` to reach `idle`/`done` with a deadline. The naive first version inverted the logic and reported "done" instantly.
- Line-count parity is load-bearing: `gen-bilingual.py` pairs lines by index and the check gate FAILs on drift. Apply fixes with exact-string replacements; never insert or delete lines (a blank-line fix was deliberately skipped in the batch for exactly this reason).
- `check-translation.py` calibration facts (all learned on the 9-book run):
- CJK leakage ignores djot anchors/links (`{#...}`, `(...)`) — structural markup legitimately contains Chinese.
- Only strictly-Chinese punctuation flags (`,。、;:?!《》【】()`); `—` `“”` `` `·` are legitimate English.
- Emphasis = preservation on the same line (source `*…*` must survive in target), not count parity — targets legitimately add italics for titles/Sanskrit.
- Digit fidelity understands 万/亿 scaling, 多, word and comma forms (180亿 → "18 billion", 1300多万 → "13+ million"), and excludes TOC page numbers (`[N](#...)`), which the convention drops.
- Use `--allow-cjk 人` for intentional Chinese (e.g. a character whose strokes the text explains) and `--term-map term-map.md` to check terminology fidelity; without a term map that check is skipped.
- `--model slow` is omp's model-role flag for the slow/reasoning model; confirm the exact flag with `omp --help` if unsure.
- Reviews are the quality gate: the apply pass is what lands findings in `target.dj` (book 21 alone took 26 exact-string replacements). The gate proves mechanics, not quality.
---
## Djot ## Djot
- Comments: `{% ... %}` - Comments: `{% ... %}`
@@ -190,6 +266,7 @@ regenerating the same Python in execute_code each turn.
- `toolkit/scripts/dj2docx.fish <target.dj>` — pandoc .dj → .docx in `/tmp/` - `toolkit/scripts/dj2docx.fish <target.dj>` — pandoc .dj → .docx in `/tmp/`
- `toolkit/scripts/proofread-pdf.py <docx> <pdf>` — word-level diff between manuscript and typeset PDF - `toolkit/scripts/proofread-pdf.py <docx> <pdf>` — word-level diff between manuscript and typeset PDF
- `toolkit/scripts/gen-bilingual.py <source.dj> <target.dj>` — produce `bilingual.dj` on stdout; run as `gen-bilingual.py source.dj target.dj > bilingual.dj` - `toolkit/scripts/gen-bilingual.py <source.dj> <target.dj>` — produce `bilingual.dj` on stdout; run as `gen-bilingual.py source.dj target.dj > bilingual.dj`
- `toolkit/scripts/check-translation.py <book_dir>` — deterministic translation gate: line/paragraph/heading parity, emphasis preservation, CJK & Chinese-punctuation leakage, digit fidelity (万/亿-aware), terminology vs term map, bilingual freshness. Exit 1 on any FAIL. Run before delivering a translation; keep green as a regression suite.
- `toolkit/scripts/gen-bilingual-<name>-<hash>.py` — article-specific extraction from DOCX or source/target pairing - `toolkit/scripts/gen-bilingual-<name>-<hash>.py` — article-specific extraction from DOCX or source/target pairing
- `toolkit/scripts/compile-typst.fish <typ>` — compile a Typst file to PDF - `toolkit/scripts/compile-typst.fish <typ>` — compile a Typst file to PDF
+2 -1
View File
@@ -99,6 +99,7 @@ generated; do not edit it by hand or commit it.
| Convert `.docx` to `.dj` | `./scripts/docx2dj.fish input.docx output.dj` | | Convert `.docx` to `.dj` | `./scripts/docx2dj.fish input.docx output.dj` |
| Convert `target.dj` to `.docx` | `./scripts/dj2docx.fish ../translate-files/展示/target.dj` | | Convert `target.dj` to `.docx` | `./scripts/dj2docx.fish ../translate-files/展示/target.dj` |
| Compile a Typst file to PDF | `./scripts/compile-typst.fish ../translate-files/my-article/my-article.typ` | | Compile a Typst file to PDF | `./scripts/compile-typst.fish ../translate-files/my-article/my-article.typ` |
| Run the translation gate | `./scripts/check-translation.py ../translate-files/my-article/` — 11 deterministic checks; exit 1 on any FAIL |
| Search the term database | `./terms-database/search.py 空性` | | Search the term database | `./terms-database/search.py 空性` |
| Run the term database UI | `./terms-database/server.py` then open <http://127.0.0.1:8910> | | Run the term database UI | `./terms-database/server.py` then open <http://127.0.0.1:8910> |
@@ -110,7 +111,7 @@ Docs, as arranged by your project coordinator.
## Learn more ## Learn more
- `AGENTS.md` — full MPI project conventions, translation workflows, and review rules. - `AGENTS.md` — full MPI project conventions, translation workflows, review rules, and Workflow C: the herdr batch workflow for translating or reviewing many books in parallel (one omp pane per book).
- `skills/readme.dj` — how the skills are organized. - `skills/readme.dj` — how the skills are organized.
- `references/` — design notes, formatting guides, and other reference materials. - `references/` — design notes, formatting guides, and other reference materials.
+411
View File
@@ -0,0 +1,411 @@
#!/usr/bin/env python3
"""Deterministic translation checks for the MPI project (TDD-style gate).
Checks the mechanical invariants of a Chinese->English djot translation.
Semantic quality (fluency, register, tone) is NOT checked here — that is the
LLM review layer. This script is the hard gate: it must go green before a
translation is delivered, and it stays in toolkit/scripts/ as a regression
suite so later edits cannot silently break parity.
Severity:
FAIL — hard invariant broken; gate is red.
WARN — possible drift worth a reviewer's eye; does not fail the gate.
PASS — clean.
Checks:
1. line-count parity — source.dj and target.dj must have equal lines
2. paragraph parity — equal number of blank-separated blocks
3. heading parity — equal # of lines starting with each heading level
4. emphasis preservation — every *...* in a source line survives on the
same-index target line (D5). Target may ADD
italics (titles, Sanskrit) — that is fine.
5. comment parity — equal {% ... %} blocks (D5)
6. CJK leakage — no Chinese characters in target (whitelistable)
7. Chinese punctuation — no strictly-Chinese punctuation in target
(,。、;:?!《》【】()). Em dash, curly
quotes, middot are legal English — not flagged.
8. bold leakage — no Markdown ** in target (D5)
9. digit fidelity — every arabic number in source appears in target
10. terminology — source terms from a term map must appear in target
with an allowed English rendering (A3 / terms DB).
FAIL: term present in source, none of its
renderings found in target. WARN: renderings
found but fewer times than the source term.
11. bilingual freshness — bilingual.dj, if present, equals a regeneration
from source+target
Usage:
check-translation.py <book_dir>
Uses <book_dir>/source.dj and <book_dir>/target.dj; auto-detects
bilingual.dj and term-map.md in the same directory.
check-translation.py <source.dj> <target.dj> [--bilingual FILE]
Explicit files.
Options:
--term-map FILE Term map (default: <book_dir>/term-map.md if present).
Accepted formats:
- markdown table rows: | 菩提心 | bodhicitta |
- plain lines: CN<TAB>EN or CN|EN1|EN2
Multiple Chinese terms separated by "/" or "" share
one English side; English renderings separated by "/"
are alternatives, any of which satisfies the check.
--allow-cjk LIST Comma-separated CJK strings permitted in target
(e.g. quoted book titles like 《心经》).
--json Emit machine-readable JSON results.
Exit code: 0 when no check FAILs (WARNs allowed), 1 otherwise.
"""
import argparse
import json
import re
import sys
from pathlib import Path
CJK_RE = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]")
# Strictly-Chinese punctuation only. Em dash (—), curly quotes (“” ‘’),
# middot (·), and ellipsis are legitimate in English prose.
CN_PUNCT_RE = re.compile(r"[,。、;:?!《》【】()]")
EMPHASIS_RE = re.compile(r"\*[^*\n]+\*")
COMMENT_RE = re.compile(r"\{%[\s\S]*?%\}")
HEADING_RE = re.compile(r"^(#{1,6})(?:\s|$)")
BOLD_RE = re.compile(r"\*\*")
DIGIT_RE = re.compile(r"\d+")
PASS, WARN, FAIL = "PASS", "WARN", "FAIL"
def read_lines(path):
return Path(path).read_text(encoding="utf-8").splitlines()
def count_paras(lines):
"""Blank-separated blocks; leading/trailing blanks ignored."""
count = 0
in_block = False
for line in lines:
if line.strip():
if not in_block:
count += 1
in_block = True
else:
in_block = False
return count
def heading_counts(lines):
counts = {i: 0 for i in range(1, 7)}
for line in lines:
m = HEADING_RE.match(line)
if m:
counts[len(m.group(1))] += 1
return counts
def term_map_from_markdown(text):
"""Parse a term map into {chinese_term: [english_renderings]}."""
terms = {}
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if "\t" in line:
cn, _, en = line.partition("\t")
terms[cn.strip()] = [r.strip() for r in en.split("/") if r.strip()]
continue
if "|" not in line:
continue
cells = [c.strip() for c in line.strip("|").split("|")]
if len(cells) < 2:
continue
cn_cell, en_cell = cells[0], cells[1]
if not cn_cell or not en_cell or set(cn_cell) <= {"-", " "}:
continue
for cn in (c.strip() for c in re.split(r"[/、]", cn_cell) if c.strip()):
terms[cn] = [r.strip() for r in en_cell.split("/") if r.strip()]
return terms
def check_line_count(src, tgt):
ok = len(src) == len(tgt)
return (PASS if ok else FAIL,
f"source={len(src)} target={len(tgt)}", [])
def check_paras(src, tgt):
s, t = count_paras(src), count_paras(tgt)
return (PASS if s == t else FAIL,
f"source={s} target={t}", [])
def check_headings(src, tgt):
s, t = heading_counts(src), heading_counts(tgt)
diffs = [f"H{i}: {s[i]} vs {t[i]}" for i in range(1, 7) if s[i] != t[i]]
return (PASS if not diffs else FAIL,
"; ".join(diffs) if diffs else "all levels match", diffs)
def check_emphasis(src, tgt):
"""D5 preservation: source emphasis must survive on the same line.
Target may add emphasis for titles/Sanskrit, so counts need not match.
"""
missing = []
for i, (s, t) in enumerate(zip(src, tgt), 1):
if EMPHASIS_RE.search(s) and not EMPHASIS_RE.search(t):
missing.append((i, s, t))
return (PASS if not missing else FAIL,
"all source emphasis preserved"
if not missing else
f"{len(missing)} source line(s) lost emphasis: "
+ ", ".join(f"S{i}" for i, _, _ in missing[:10]),
missing)
def check_comments(src, tgt):
s, t = len(COMMENT_RE.findall("\n".join(src))), len(COMMENT_RE.findall("\n".join(tgt)))
return (PASS if s == t else FAIL,
f"source={s} target={t}", [])
ANCHOR_RE = re.compile(r"\{#[^{}]*\}|\(\s*#?[^{}\n]*\)")
LINK_TGT_RE = re.compile(r"\[\d+\]\(\s*#")
def strip_structural(text):
"""Remove djot anchors {#...}, link destinations (...), and image paths —
structural markup that may legitimately contain Chinese."""
return ANCHOR_RE.sub("", text)
def check_cjk(tgt, allow=()):
bad = []
for i, line in enumerate(tgt, 1):
stripped = strip_structural(line)
for token in allow:
stripped = stripped.replace(token, "")
if CJK_RE.search(stripped):
bad.append((i, line))
return (PASS if not bad else FAIL,
"clean" if not bad else f"{len(bad)} line(s) contain CJK outside anchors/links: "
+ ", ".join(f"L{i}" for i, _ in bad[:10]),
bad)
def check_cn_punct(tgt):
bad = []
for i, line in enumerate(tgt, 1):
if CN_PUNCT_RE.search(line):
bad.append((i, line))
return (PASS if not bad else FAIL,
"clean" if not bad else f"{len(bad)} line(s) contain Chinese punctuation: "
+ ", ".join(f"L{i}" for i, _ in bad[:10]),
bad)
def check_bold(tgt):
bad = []
for i, line in enumerate(tgt, 1):
if BOLD_RE.search(line):
bad.append((i, line))
return (PASS if not bad else FAIL,
"clean" if not bad else f"{len(bad)} line(s) contain ** : "
+ ", ".join(f"L{i}" for i, _ in bad[:10]),
bad)
_ONES = ["", "one", "two", "three", "four", "five", "six", "seven", "eight",
"nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen",
"sixteen", "seventeen", "eighteen", "nineteen"]
_TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy",
"eighty", "ninety"]
def number_to_words(n):
if n < 20:
return _ONES[n]
if n < 100:
return (_TENS[n // 10] + ("-" + _ONES[n % 10] if n % 10 else ""))
if n < 1000:
return _ONES[n // 100] + " hundred" + (
(" " + number_to_words(n % 100)) if n % 100 else "")
if n < 1000000:
return number_to_words(n // 1000) + " thousand" + (
(" " + number_to_words(n % 1000)) if n % 1000 else "")
return number_to_words(n // 1000000) + " million" + (
(" " + number_to_words(n % 1000000)) if n % 1000000 else "")
def accepted_number_spellings(n, unit):
"""All English spellings that legitimately render source number n.
unit: "" (plain), "" (×10^4), or "亿" (×10^8). The target may keep the
digits ("1,200"), spell them ("twelve hundred"), or scale the unit
("13 million" for 1300万, "18 billion" for 180亿).
"""
cands = {str(n), number_to_words(n)}
if 100 <= n < 10000 and n % 100 == 0: # "twelve hundred"
cands.add(f"{n // 100} hundred")
cands.add(number_to_words(n // 100) + " hundred")
value = n * (10 ** 4 if unit == "" else 10 ** 8 if unit == "亿" else 1)
if value != n:
cands.add(str(value))
cands.add(number_to_words(value))
for divisor, suffix in ((10 ** 9, "billion"), (10 ** 6, "million"),
(10 ** 3, "thousand")):
if value % divisor == 0 and value // divisor > 0:
cands.add(f"{value // divisor} {suffix}")
cands.add(number_to_words(value // divisor) + " " + suffix)
return cands
def source_content_nums(src_text):
"""(number, unit) pairs from the source, excluding TOC page numbers
([N](#...)) that the project convention intentionally drops."""
stripped = LINK_TGT_RE.sub("", src_text)
out = []
for m in re.finditer(r"\d+(?:\s*(?:多\s*)?[万亿])?", stripped):
tok = m.group(0)
unit = tok[-1] if tok[-1] in "万亿" else ""
out.append((int(re.sub(r"\D", "", tok)), unit))
return out
def check_digits(src, tgt):
src_nums = source_content_nums("\n".join(src))
tgt_text = " ".join(tgt).lower().replace(",", "")
missing = []
for n, unit in src_nums:
if any(s.lower() in tgt_text
for s in accepted_number_spellings(n, unit)):
continue
missing.append(str(n) + unit)
return (PASS if not missing else FAIL,
"all present" if not missing else f"missing in target: {', '.join(missing)}",
missing)
def check_terminology(src, tgt, terms):
"""A3: source term present -> some allowed rendering present in target.
FAIL when no rendering is found at all; WARN when found but under-counted
(inflections, line wraps, or a genuine drift the reviewer should verify).
"""
if not terms:
return PASS, "no term map provided; skipped", []
src_text = "\n".join(src)
tgt_text = " ".join(tgt).lower()
fails, warns = [], []
checked = 0
for cn, renderings in sorted(terms.items()):
n = src_text.count(cn)
if n == 0:
continue
checked += 1
hits = sum(tgt_text.count(r.lower()) for r in renderings)
if hits == 0:
fails.append(f"{cn} ({n}× in source) — none of {renderings} found in target")
elif hits < n:
warns.append(f"{cn} ({n}× in source, {hits}× rendered) — verify")
if fails:
status, detail = FAIL, f"{checked} term(s) checked; " + "; ".join(fails)
elif warns:
status, detail = WARN, f"{checked} term(s) checked; " + "; ".join(warns)
else:
status, detail = PASS, f"{checked} term(s) checked; all consistent"
return status, detail, fails + warns
def check_bilingual(src, tgt, bilingual_path):
if bilingual_path is None or not Path(bilingual_path).exists():
return PASS, "no bilingual.dj present; skipped", []
actual = Path(bilingual_path).read_text(encoding="utf-8").splitlines()
expected = []
for s, t in zip(src, tgt):
if s == "":
expected.append("")
else:
expected.extend([s, t, ""])
if expected and expected[-1] != "":
expected.append("")
if actual == expected:
return PASS, f"{len(actual)} lines match a regeneration", []
return FAIL, f"stale: {Path(bilingual_path)} differs from source+target regeneration", []
def run_checks(src, tgt, bilingual=None, term_map=None, allow_cjk=()):
return [
("line-count parity", *check_line_count(src, tgt)),
("paragraph parity", *check_paras(src, tgt)),
("heading parity", *check_headings(src, tgt)),
("emphasis preservation", *check_emphasis(src, tgt)),
("comment parity", *check_comments(src, tgt)),
("CJK leakage", *check_cjk(tgt, allow_cjk)),
("Chinese punctuation", *check_cn_punct(tgt)),
("bold leakage", *check_bold(tgt)),
("digit fidelity", *check_digits(src, tgt)),
("terminology", *check_terminology(src, tgt, term_map)),
("bilingual freshness", *check_bilingual(src, tgt, bilingual)),
]
def main():
ap = argparse.ArgumentParser(description="Deterministic translation checks")
ap.add_argument("paths", nargs="+", help="book dir, or source.dj target.dj")
ap.add_argument("--bilingual", default=None, help="bilingual.dj to verify")
ap.add_argument("--term-map", default=None, help="term map file")
ap.add_argument("--allow-cjk", default="", help="comma-separated CJK whitelist")
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
if len(args.paths) == 1 and Path(args.paths[0]).is_dir():
d = Path(args.paths[0])
src, tgt = d / "source.dj", d / "target.dj"
bilingual = args.bilingual or d / "bilingual.dj"
term_map = args.term_map or d / "term-map.md"
label = str(d)
elif len(args.paths) == 2:
src, tgt = Path(args.paths[0]), Path(args.paths[1])
bilingual = Path(args.bilingual) if args.bilingual else None
term_map = Path(args.term_map) if args.term_map else None
label = f"{src} -> {tgt}"
else:
ap.error("pass a book directory, or source.dj target.dj")
if not src.exists() or not tgt.exists():
ap.error(f"missing source or target: {src} / {tgt}")
src_lines = read_lines(src)
tgt_lines = read_lines(tgt)
allow = [t for t in args.allow_cjk.split(",") if t.strip()]
terms = {}
if term_map and Path(term_map).exists():
terms = term_map_from_markdown(Path(term_map).read_text(encoding="utf-8"))
checks = run_checks(src_lines, tgt_lines, bilingual, terms, allow)
failed = [name for name, status, *_ in checks if status == FAIL]
warned = [name for name, status, *_ in checks if status == WARN]
if args.json:
print(json.dumps({
"target": label,
"passed": [c[0] for c in checks if c[1] == PASS],
"warned": warned,
"failed": failed,
"details": {c[0]: {"status": c[1], "detail": c[2]} for c in checks},
}, ensure_ascii=False, indent=2))
else:
print(f"check-translation.py — {label}\n")
for name, status, detail, *_ in checks:
print(f"[{status:4}] {name}: {detail}")
summary = "ALL CHECKS PASSED"
if warned:
summary = f"PASSED with warnings: {', '.join(warned)}"
if failed:
summary = f"FAILED: {', '.join(failed)}"
print(f"\n{summary}")
sys.exit(0 if not failed else 1)
if __name__ == "__main__":
main()