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.
216 lines
8.4 KiB
Python
216 lines
8.4 KiB
Python
# 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()
|