Files
UmbraTrans/tools/translation/qa.py
umbra audio 3e0568b9d1 Generate backend endpoints for language-project workflow
Add REST endpoints and frontend wiring for per-language project actions:
- POST /api/workspace/{project}/translation/{lang}/queue — queue translation
- POST /api/workspace/{project}/audio/{lang}/queue — queue audio generation
- GET/POST /api/workspace/{project}/format/{lang} — format metadata (EPUB/PDF)
- GET /api/workspace/{project}/review/{lang}/chapters/{chapter} — utterance review
- GET/POST /api/workspace/{project}/front-matter/{lang} — front matter editor
- GET /api/workspace/{project}/chapters/{lang} — chapter list
- GET /api/workspace/{project}/chapter-content/{lang}/{chapter} — chapter text

Frontend fixes:
- Review page shows per-language text (English source, Chinese translation, etc.)
- Non-translated languages show 'Queue translation' button only
- Speaker data from English source matched by paragraph ID
- UmbraAudio column layout: Speaker, Variation, Text, Actions
- Chapter tree in left sidebar: Front matter, Back matter, Body
- Audio Format label at top of sidebar
- Pandoc formatting toolbar for front matter text editing
2026-10-25 11:04:53 -07:00

173 lines
6.3 KiB
Python
Raw 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 the Deal Hunter Chinese translation.
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.
"""
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 pipeline import latin_residue # noqa: E402
def _load(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def check_chapter(chapter: dict, data: dict) -> tuple[list[dict], dict]:
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"})
chars = 0
for pid, zh in drafts.items():
zh = str(zh)
chars += len(re.findall(r"[\u4e00-\u9fff]", zh))
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]})
if zh.count("“") != zh.count("”"):
issues.append(
{
"id": pid,
"level": "major",
"kind": "quote_balance",
"detail": f"{zh.count('“')} open / {zh.count('”')} close",
}
)
if re.search(r"[。,!?;:]{2,}", zh):
issues.append({"id": pid, "level": "minor", "kind": "double_punctuation", "detail": zh[:60]})
if "《交易猎人》" in zh:
issues.append({"id": pid, "level": "major", "kind": "title_translated", "detail": zh[:60]})
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
if star_system 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"],
"expected": len(expected),
"translated": len(drafts),
"chinese_chars": chars,
"issues": len(issues),
}
return issues, metrics
def main() -> None:
ap = argparse.ArgumentParser(description="Mechanical QA for the Chinese translation.")
ap.add_argument("--book", type=Path, required=True)
ap.add_argument("--run-dir", type=Path, required=True)
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)
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)
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("CHAPTERS")
for m in metrics:
print(
f" {m['chapter_id']:6} {m['title'][:28]:28} "
f"{m['translated']:>4}/{m['expected']:<4} paras {m['chinese_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()