diff --git a/AGENTS.md b/AGENTS.md index ed3cd31..e98bff4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` 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 review- # 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 --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 | 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 --new-tab --label --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 -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 (file: .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 '.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: /gen-bilingual.py source.dj target.dj > bilingual.dj 5) Run /check-translation.py . and report which checks pass/fail. Report what you changed and the check result. + +### Gotchas(踩过的坑) + +- `herdr pane split --cwd ` doesn't stick — launch omp with `--cwd ` 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 - Comments: `{% ... %}` @@ -190,6 +266,7 @@ regenerating the same Python in execute_code each turn. - `toolkit/scripts/dj2docx.fish ` — pandoc .dj → .docx in `/tmp/` - `toolkit/scripts/proofread-pdf.py ` — word-level diff between manuscript and typeset PDF - `toolkit/scripts/gen-bilingual.py ` — produce `bilingual.dj` on stdout; run as `gen-bilingual.py source.dj target.dj > bilingual.dj` +- `toolkit/scripts/check-translation.py ` — 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--.py` — article-specific extraction from DOCX or source/target pairing - `toolkit/scripts/compile-typst.fish ` — compile a Typst file to PDF diff --git a/readme.md b/readme.md index 54dcbf7..6dd1cf6 100644 --- a/readme.md +++ b/readme.md @@ -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 `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` | +| 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 空性` | | Run the term database UI | `./terms-database/server.py` then open | @@ -110,7 +111,7 @@ Docs, as arranged by your project coordinator. ## 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. - `references/` — design notes, formatting guides, and other reference materials. diff --git a/scripts/check-translation.py b/scripts/check-translation.py new file mode 100755 index 0000000..74fa64a --- /dev/null +++ b/scripts/check-translation.py @@ -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 + Uses /source.dj and /target.dj; auto-detects + bilingual.dj and term-map.md in the same directory. + check-translation.py [--bilingual FILE] + Explicit files. + +Options: + --term-map FILE Term map (default: /term-map.md if present). + Accepted formats: + - markdown table rows: | 菩提心 | bodhicitta | + - plain lines: CNEN 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()