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
173 lines
6.3 KiB
Python
173 lines
6.3 KiB
Python
# 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()
|