Files
B.D. Murphy 3f05489e25 Parameterise the translation pipeline by target language
The pipeline was hard-coded to Simplified Chinese in three places: the
drafter was told to translate into Chinese, the critic was told to compare
English with Chinese, and the glossary's translated forms were read from a
"zh" key. A production that declared target_language: es-ES was therefore
translated into Chinese and filed under a Spanish name - measured on oldbox,
deal-hunter-es held 3,038 paragraphs of Chinese and was reported complete.

A new registry, tools/translation/languages.py, is the single source of
truth for what a target language is: its names, its script, its punctuation,
its glossary key, its typography and its chapter-numbering convention. Ten
languages are registered. The prompt scaffolding stays in Chinese - that is
what the models read best - and the target is named with its Chinese, English
and native names so there is no room to misread it.

The registry now reaches every stage:

- prompts.py: the analyst, drafter, critic and glossary blocks take the
  target. The hard rules about this book - Deal Hunter is the ship's name,
  the AI's name and the book's title, so it stays in English - are the
  language's own table for Chinese and the global rules for everything else,
  so a language with no table does not lose them. The Chinese rules are
  byte-for-byte what they were.
- pipeline.py: --target-language, a run-directory guard that refuses to
  reuse a directory belonging to another language (from the manifest, or by
  measuring the text it already holds), a draft-keyed-by-target reader, and
  a refusal of a draft that came back in the wrong script.
- book.py: per-language chapter-title and subtitle tables. A language with no
  table falls back to the English title, never to another language's; a
  language with a table and a missing chapter gets a visible marker in its
  own language.
- qa.py: the target's quote marks, punctuation, script count and forbidden
  title forms. The Latin-residue check is skipped for a Latin target, where
  it would flag the whole book.
- align.py: the aligner and verifier prompts name the target, and the
  sentence and clause splitting uses the target's own terminators.
- render.py: the fonts, the language and country codes, the writing
  direction, the chapter headings and the fixed editorial strings around the
  translation are the target's. The Latin-run span is skipped for a Latin
  target, where marking the text as English would tell the renderer it is
  English.
- project_status.py: the dashboard measures a sample of a production's text
  and reports a production whose text is not in the language it declares as
  "mismatched", not "complete". A chapter count is a claim about the files;
  the script check is a measurement of the text.

Also fixes: the dashboard gated every language's production on the source
production existing, so a Spanish production could never be reported even
when it was there.

The Chinese render is byte-identical to before on the real book, and the
Chinese rules are unchanged. Suite: 620 passed, the same 68 pre-existing
environment failures, +49 new tests.
2026-11-07 05:53:17 -08:00

216 lines
8.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Mechanical QA for a translated chapter.
This does not judge literary quality. It catches the defects that can be
detected without a human: missing paragraphs, leaked prompt metadata, stray
English, unbalanced quotes, duplicated punctuation and forbidden title forms.
The checks are the target language's own. A Spanish draft is full of Latin
words, so the "stray English" check cannot run there - it would flag the
whole book. The quote characters, the punctuation and the script count come
from the same registry the prompts and the renderer use.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from book import SUBTITLES
from languages import SCRIPT_LATIN, TargetLanguage, resolve, script_counts # noqa: E402
from pipeline import latin_residue # noqa: E402
CHINESE = resolve("zh-Hans")
def forbidden_titles(target: TargetLanguage) -> list[str]:
"""The translated book title, in the target's own book-title marks.
The rule is "the book title stays in English", so the form that must not
appear in the text is the *translated* title. The Chinese table is the
one the pipeline has always used; every other language derives its own
from the subtitle table, and a language with no subtitle has nothing to
forbid.
"""
if target.base == "zh":
return ["《交易猎人》"]
subtitle = SUBTITLES.get(target.base, "")
if not subtitle:
return []
return [f"{target.title_open}{subtitle}{target.title_close}"]
def _load(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def check_chapter(
chapter: dict,
data: dict,
target: TargetLanguage | None = None,
) -> tuple[list[dict], dict]:
target = target or CHINESE
drafts: dict[str, str] = data.get("paragraphs", {})
by_id = {p["id"]: p for p in chapter["paragraphs"] if p.get("type") != "scene_break"}
expected = list(by_id)
issues: list[dict] = []
for pid in expected:
if pid not in drafts or not str(drafts[pid]).strip():
issues.append({"id": pid, "level": "block", "kind": "missing", "detail": "no translation"})
for pid in drafts:
if pid not in by_id:
issues.append({"id": pid, "level": "major", "kind": "unknown_id", "detail": "id not in source"})
# A target written in Latin script has Latin words by definition, so the
# residue check only applies where Latin script is foreign.
check_latin = SCRIPT_LATIN not in target.scripts
forbidden = forbidden_titles(target)
double_punctuation = r"[。,!?;:]{2,}" if target.base == "zh" else r"[.!?;]{2,}"
chars = 0
for pid, zh in drafts.items():
zh = str(zh)
counts = script_counts(zh)
chars += sum(counts.get(script, 0) for script in target.scripts)
if re.search(r"^\[ch-[^\]]+\]", zh) or re.search(r"((?:对话|旁白|叙述)(?:;[^)]*)?)", zh):
issues.append({"id": pid, "level": "block", "kind": "metadata_leak", "detail": zh[:40]})
if '"' in zh:
issues.append({"id": pid, "level": "major", "kind": "straight_quote", "detail": zh[:60]})
opens = zh.count(target.quote_open)
closes = zh.count(target.quote_close)
if opens != closes:
issues.append(
{
"id": pid,
"level": "major",
"kind": "quote_balance",
"detail": f"{opens} open / {closes} close",
}
)
if re.search(double_punctuation, zh):
issues.append({"id": pid, "level": "minor", "kind": "double_punctuation", "detail": zh[:60]})
for form in forbidden:
if form in zh:
issues.append({"id": pid, "level": "major", "kind": "title_translated", "detail": zh[:60]})
break
if check_latin:
common, proper = latin_residue(zh)
if common:
issues.append({"id": pid, "level": "major", "kind": "english_residue", "detail": ", ".join(common)})
if proper:
issues.append({"id": pid, "level": "info", "kind": "proper_name_review", "detail": ", ".join(proper)})
source = by_id.get(pid, {}).get("text", "")
star_system = re.search(r"\bstar system\b|\b(?!The\b)[A-Z][a-z]+\s+system\b|\b\d+\s+systems\b", source)
if star_system:
phrase = star_system.group(0).lower()
if any(
x in phrase
for x in (
"audio system",
"ship system",
"computer system",
"operating system",
"weapon system",
"security system",
"navigation system",
"power system",
"life support system",
)
):
star_system = None
# The mistranslation this catches is a Chinese one: a star system
# rendered with the word for an installed system. In another language
# the wrong word is a different word and the critic, not this check,
# is what finds it.
if star_system and target.base == "zh" and "系统" in zh:
issues.append(
{"id": pid, "level": "major", "kind": "system_mistranslated", "detail": "star system -> 系统"}
)
metrics = {
"chapter_id": chapter["id"],
"chapter_index": chapter["index"],
"title": chapter["title"],
"target_language": target.code,
"expected": len(expected),
"translated": len(drafts),
"target_chars": chars,
"issues": len(issues),
}
return issues, metrics
def main() -> None:
ap = argparse.ArgumentParser(description="Mechanical QA for a translated chapter.")
ap.add_argument("--book", type=Path, required=True)
ap.add_argument("--run-dir", type=Path, required=True)
ap.add_argument("--target-language", default=None)
ap.add_argument("--json-out", type=Path)
ap.add_argument("--strict", action="store_true", help="exit 1 on any block/major issue")
args = ap.parse_args()
book = _load(args.book)
target = resolve(args.target_language or book.get("target_language") or "zh-Hans")
all_issues: list[dict] = []
metrics: list[dict] = []
for chapter in book["chapters"]:
path = args.run_dir / chapter["id"] / "translation.json"
if not path.exists():
continue
data = _load(path)
issues, m = check_chapter(chapter, data, target)
for issue in issues:
issue["chapter_id"] = chapter["id"]
issue["chapter_title"] = chapter["title"]
all_issues.extend(issues)
metrics.append(m)
by_level: dict[str, int] = {}
by_kind: dict[str, int] = {}
for issue in all_issues:
by_level[issue["level"]] = by_level.get(issue["level"], 0) + 1
by_kind[issue["kind"]] = by_kind.get(issue["kind"], 0) + 1
print(f"TARGET: {target.label}")
print("CHAPTERS")
for m in metrics:
print(
f" {m['chapter_id']:6} {m['title'][:28]:28} "
f"{m['translated']:>4}/{m['expected']:<4} paras {m['target_chars']:>6} chars {m['issues']:>3} issues"
)
print()
print("ISSUES BY LEVEL:", dict(sorted(by_level.items())))
print("ISSUES BY KIND: ", dict(sorted(by_kind.items())))
print()
for issue in all_issues:
if issue["level"] in {"block", "major"}:
print(f" [{issue['level']:5}] {issue['kind']:20} {issue['chapter_id']}/{issue['id']}: {issue['detail']}")
elif issue["level"] == "minor":
print(f" [minor] {issue['kind']:20} {issue['chapter_id']}/{issue['id']}: {issue['detail']}")
else:
print(f" [info ] {issue['kind']:20} {issue['chapter_id']}/{issue['id']}: {issue['detail']}")
if args.json_out:
args.json_out.parent.mkdir(parents=True, exist_ok=True)
args.json_out.write_text(
json.dumps({"metrics": metrics, "issues": all_issues}, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(f"\nwrote {args.json_out}")
if args.strict and (by_level.get("block", 0) or by_level.get("major", 0)):
raise SystemExit(1)
if __name__ == "__main__":
main()