Files
UmbraTrans/tools/translation/review.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

298 lines
12 KiB
Python

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Build a bilingual side-by-side review page for the translation.
The output is a single self-contained HTML file. It contains the English source
and the Chinese draft, so it must stay inside the git-ignored production
directory. It is meant for a human editor: every paragraph is on one row, the
critic and mechanical issues are attached to the row, and a filter hides the
paragraphs that have no issues.
"""
from __future__ import annotations
import argparse
import html
import json
import re
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from qa import check_chapter # noqa: E402
def _load(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def _severity_rank(sev: str) -> int:
return {"block": 0, "major": 1, "minor": 2, "info": 3}.get(sev, 4)
def _critic_issues(data: dict) -> list[dict]:
out = []
for raw in data.get("issues", []) or []:
if not isinstance(raw, dict):
continue
out.append(
{
"id": str(raw.get("id") or ""),
"severity": str(raw.get("severity") or "major"),
"type": str(raw.get("type") or "meaning"),
"problem": str(raw.get("problem") or ""),
"source_excerpt": str(raw.get("source_excerpt") or ""),
"target_excerpt": str(raw.get("target_excerpt") or ""),
"fix": str(raw.get("fix") or ""),
"origin": "critic",
}
)
return out
def _mechanical_issues(chapter: dict, data: dict) -> list[dict]:
issues, _metrics = check_chapter(chapter, data)
out = []
for raw in issues:
out.append(
{
"id": str(raw.get("id") or ""),
"severity": str(raw.get("level") or "major"),
"type": str(raw.get("kind") or "mechanical"),
"problem": str(raw.get("detail") or ""),
"source_excerpt": "",
"target_excerpt": "",
"fix": "",
"origin": "qa",
}
)
return out
def _badge(sev: str) -> str:
label = {"block": "BLOCK", "major": "MAJOR", "minor": "minor", "info": "info"}.get(sev, sev)
return f'<span class="badge {html.escape(sev)}">{html.escape(label)}</span>'
def _issue_html(issue: dict) -> str:
parts = [
f'<div class="issue {html.escape(issue["severity"])}">',
_badge(issue["severity"]),
f' <span class="kind">{html.escape(issue["type"])}</span> ',
f'<span class="origin">[{html.escape(issue["origin"])}]</span>',
]
if issue["problem"]:
parts.append(f'<div class="problem">{html.escape(issue["problem"])}</div>')
if issue["source_excerpt"]:
parts.append(f'<div class="excerpt">EN: {html.escape(issue["source_excerpt"])}</div>')
if issue["target_excerpt"]:
parts.append(f'<div class="excerpt">ZH: {html.escape(issue["target_excerpt"])}</div>')
if issue["fix"]:
parts.append(f'<div class="fix">建议:{html.escape(issue["fix"])}</div>')
parts.append("</div>")
return "".join(parts)
def build_html(book: dict, run_dir: Path, out_path: Path, only: set[str] | None = None) -> None:
sections: list[str] = []
stats = {"chapters": 0, "paragraphs": 0, "flagged": 0, "chars": 0}
total_issues = {"block": 0, "major": 0, "minor": 0, "info": 0}
for chapter in book["chapters"]:
if only and chapter["id"] not in only:
continue
path = run_dir / chapter["id"] / "translation.json"
if not path.exists():
continue
data = _load(path)
drafts: dict[str, str] = data.get("paragraphs", {})
if not drafts:
continue
issues = _critic_issues(data) + _mechanical_issues(chapter, data)
by_id: dict[str, list[dict]] = {}
unmatched: list[dict] = []
for issue in issues:
total_issues[issue["severity"]] = total_issues.get(issue["severity"], 0) + 1
if issue["id"] and issue["id"] in drafts:
by_id.setdefault(issue["id"], []).append(issue)
else:
unmatched.append(issue)
rows: list[str] = []
chapter_chars = 0
flagged = 0
heading = chapter.get("heading_zh") or chapter["title_zh"]
for p in chapter["paragraphs"]:
pid = p["id"]
if p.get("type") == "scene_break":
rows.append('<tr class="scene"><td colspan="3">* * *</td></tr>')
continue
zh = drafts.get(pid)
if zh is None:
rows.append(
f'<tr id="{pid}" class="row missing" data-flagged="1">'
f'<td class="pid">{pid}</td>'
f'<td class="en">{html.escape(p.get("text") or "")}</td>'
f'<td class="zh"><em>(未译)</em></td></tr>'
)
stats["flagged"] += 1
continue
zh = str(zh)
chapter_chars += len(re.findall(r"[\u4e00-\u9fff]", zh))
row_issues = sorted(by_id.get(pid, []), key=lambda i: _severity_rank(i["severity"]))
if row_issues:
flagged += 1
row_class = "row flagged" if row_issues else "row"
detail = "".join(_issue_html(i) for i in row_issues)
rows.append(
f'<tr id="{pid}" class="{row_class}" data-flagged="{1 if row_issues else 0}">'
f'<td class="pid">{html.escape(pid)}</td>'
f'<td class="en">{html.escape(p.get("text") or "")}</td>'
f'<td class="zh"><div class="zh-text">{html.escape(zh)}</div>{detail}</td>'
f"</tr>"
)
stats["chapters"] += 1
stats["paragraphs"] += len(drafts)
stats["chars"] += chapter_chars
stats["flagged"] += flagged
unmatched_html = ""
if unmatched:
unmatched_html = (
'<div class="unmatched"><h4>未能定位到段落的审校意见</h4>'
+ "".join(_issue_html(i) for i in unmatched)
+ "</div>"
)
sections.append(
f"""
<section class="chapter" id="{html.escape(chapter['id'])}">
<h2>{html.escape(heading)} <span class="meta">{html.escape(chapter['title'])} · {chapter['id']}</span></h2>
<p class="chapstats">{len(drafts)} 段 · {chapter_chars} 汉字 · {flagged} 段有意见</p>
{unmatched_html}
<table>
<thead><tr><th class="pid">ID</th><th class="en">English source</th><th class="zh">中文译文 / 意见</th></tr></thead>
<tbody>
{''.join(rows)}
</tbody>
</table>
</section>"""
)
doc = f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Deal Hunter 中译审校表</title>
<style>
:root {{ --bg:#fbfaf7; --ink:#222; --muted:#666; --line:#d9d4c8; --flag:#fff4e5; --block:#c62828; --major:#b26a00; --minor:#5c6bc0; --info:#607d8b; }}
* {{ box-sizing: border-box; }}
body {{ margin:0; background:var(--bg); color:var(--ink); font:15px/1.65 "Noto Serif CJK SC","Source Han Serif SC",serif; }}
header {{ position:sticky; top:0; z-index:10; background:rgba(251,250,247,.96); border-bottom:1px solid var(--line); padding:14px 22px; backdrop-filter:blur(6px); }}
header h1 {{ margin:0 0 4px; font-size:22px; }}
header .sub {{ color:var(--muted); font-size:13px; }}
header .controls {{ margin-top:8px; display:flex; gap:16px; align-items:center; flex-wrap:wrap; font-size:13px; }}
main {{ max-width:1500px; margin:0 auto; padding:20px 22px 80px; }}
.chapter {{ margin:28px 0 40px; }}
.chapter h2 {{ font-size:20px; border-bottom:2px solid var(--ink); padding-bottom:6px; margin:0 0 4px; }}
.chapter h2 .meta {{ font-size:12px; color:var(--muted); font-weight:normal; margin-left:8px; }}
.chapstats {{ margin:0 0 10px; color:var(--muted); font-size:12px; }}
table {{ width:100%; border-collapse:collapse; table-layout:fixed; background:#fff; }}
th,td {{ border:1px solid var(--line); vertical-align:top; padding:8px 10px; }}
thead th {{ position:sticky; top:87px; background:#efe9dc; z-index:5; text-align:left; font-size:12px; letter-spacing:.04em; }}
col.pid, th.pid, td.pid {{ width:92px; }}
th.en, td.en {{ width:48%; color:#333; font-family:"Noto Serif","Times New Roman",serif; font-size:14px; }}
th.zh, td.zh {{ width:calc(52% - 92px); }}
td.pid {{ font:11px/1.4 ui-monospace,monospace; color:var(--muted); }}
td.zh-text {{ white-space:pre-wrap; }}
tr.flagged {{ background:var(--flag); }}
tr.missing td.zh {{ color:var(--block); }}
tr.scene td {{ text-align:center; color:var(--muted); letter-spacing:.5em; background:#f6f2e9; }}
.issue {{ margin-top:8px; padding:6px 8px; border-left:4px solid var(--line); background:#fffdf8; font-size:12.5px; }}
.issue.block {{ border-color:var(--block); }}
.issue.major {{ border-color:var(--major); }}
.issue.minor {{ border-color:var(--minor); }}
.issue.info {{ border-color:var(--info); }}
.badge {{ display:inline-block; padding:0 5px; border-radius:3px; color:#fff; font:10px/1.6 ui-monospace,monospace; }}
.badge.block {{ background:var(--block); }} .badge.major {{ background:var(--major); }}
.badge.minor {{ background:var(--minor); }} .badge.info {{ background:var(--info); }}
.kind {{ font:11px/1.4 ui-monospace,monospace; color:var(--muted); }}
.origin {{ font-size:10px; color:#999; }}
.problem {{ margin:3px 0 0; }}
.excerpt {{ color:#555; font-size:11.5px; }}
.fix {{ color:#1b5e20; font-size:11.5px; }}
.unmatched {{ border:1px dashed var(--line); padding:8px 10px; margin:8px 0; background:#fff; }}
.unmatched h4 {{ margin:0 0 6px; font-size:13px; }}
@media print {{
header {{ position:static; }}
thead th {{ position:static; }}
tr.flagged {{ background:#fff; }}
.chapter {{ page-break-before:always; }}
}}
</style>
</head>
<body>
<header>
<h1>Deal Hunter · 交易猎人 — 中译审校表</h1>
<div class="sub">生成 {time.strftime('%Y-%m-%d %H:%M')} · {stats['chapters']} 章 · {stats['paragraphs']} 段 · {stats['chars']} 汉字 · {stats['flagged']} 段有意见 · block {total_issues['block']} / major {total_issues['major']} / minor {total_issues['minor']}</div>
<div class="controls">
<label><input type="checkbox" id="onlyFlagged"> 只看有意见的段落</label>
<label><input type="checkbox" id="hideEnglish"> 隐藏英文</label>
<span id="count"></span>
</div>
</header>
<main>
{''.join(sections)}
</main>
<script>
const rows = [...document.querySelectorAll('tr.row')];
const only = document.getElementById('onlyFlagged');
const hideEn = document.getElementById('hideEnglish');
const count = document.getElementById('count');
function apply() {{
let shown = 0;
for (const r of rows) {{
const flagged = r.dataset.flagged === '1';
const visible = !only.checked || flagged;
r.style.display = visible ? '' : 'none';
if (visible) shown++;
}}
document.querySelectorAll('td.en, th.en').forEach(el => el.style.display = hideEn.checked ? 'none' : '');
count.textContent = `显示 ${{shown}} / ${{rows.length}} 段`;
}}
only.addEventListener('change', apply);
hideEn.addEventListener('change', apply);
apply();
</script>
</body>
</html>
"""
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(doc, encoding="utf-8")
print(f"html {out_path}")
print(
f" {stats['chapters']} chapters, {stats['paragraphs']} paragraphs, "
f"{stats['chars']} chars, {stats['flagged']} flagged"
)
def main() -> None:
ap = argparse.ArgumentParser(description="Build a bilingual HTML review page.")
ap.add_argument("--book", type=Path, required=True)
ap.add_argument("--run-dir", type=Path, required=True)
ap.add_argument("--out", type=Path, required=True)
ap.add_argument("--chapters", help="comma-separated chapter ids (default: all translated)")
args = ap.parse_args()
book = _load(args.book)
only = {c.strip() for c in args.chapters.split(",")} if args.chapters else None
build_html(book, args.run_dir, args.out, only)
if __name__ == "__main__":
main()