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

199 lines
8.9 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
"""Collect proper-name and term candidates for the glossary.
Two sources are merged:
1. Terms proposed by the scene analyst (``proposed_terms`` in every
``*.analysis.json``).
2. Capitalised words in the English source that appear mid-sentence, which is
a decent signal for a name but filters out sentence-initial function words.
Nothing is written into the glossary automatically. The output is a proposal
list for the operator to accept, edit or reject, because an unapproved
transliteration is worse than an English name left in place.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from collections import defaultdict
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from pipeline import LATIN_ALLOW # noqa: E402
STOPWORDS = {
"a", "about", "after", "again", "against", "all", "also", "am", "an", "and",
"any", "are", "aren", "as", "at", "back", "be", "because", "been", "before",
"being", "below", "between", "both", "but", "by", "can", "could", "did",
"didn", "do", "does", "doesn", "doing", "don", "down", "during", "each",
"even", "ever", "every", "few", "for", "from", "get", "gets", "getting",
"give", "go", "going", "gone", "good", "got", "had", "has", "have",
"having", "he", "her", "here", "hers", "herself", "him", "himself", "his",
"how", "however", "i", "if", "in", "into", "is", "isn", "it", "its",
"itself", "just", "know", "let", "like", "look", "looking", "made", "make",
"many", "may", "maybe", "me", "might", "mine", "more", "most", "much",
"must", "my", "myself", "need", "no", "nor", "not", "now", "of", "off",
"on", "once", "one", "only", "or", "other", "ought", "our", "ours",
"ourselves", "out", "over", "own", "perhaps", "please", "put", "quite",
"rather", "really", "right", "said", "same", "say", "saying", "see",
"seen", "she", "should", "since", "so", "some", "something", "still",
"such", "sure", "take", "than", "thank", "thanks", "that", "the", "their",
"theirs", "them", "themselves", "then", "there", "these", "they", "thing",
"things", "think", "this", "those", "though", "through", "to", "too",
"under", "until", "up", "upon", "us", "very", "was", "wasn", "we", "well",
"were", "weren", "what", "when", "where", "whether", "which", "while",
"who", "whom", "why", "will", "with", "within", "without", "would",
"yes", "yet", "you", "your", "yours", "yourself", "yourselves",
"okay", "ok", "hi", "hello", "hey", "oh", "ah", "um", "uh", "hmm",
"mr", "mrs", "ms", "dr", "sir", "ma", "am", "captain", "princess",
"doctor", "aunt", "uncle", "nanny", "hunter",
"everyone", "everything", "someone", "something", "nothing", "anyone",
"anything", "people", "one", "two", "three", "four", "five", "six",
"seven", "eight", "nine", "ten", "eleven", "twelve", "first", "second",
"third", "fourth", "fifth", "next", "last", "inside", "outside", "based",
"turning", "checking", "looking", "going", "trying", "waiting",
"standing", "sitting", "walking", "talking", "several", "tell",
"additionally", "besides", "however", "therefore", "meanwhile",
"suddenly", "finally", "actually", "apparently", "obviously",
"unfortunately", "luckily", "instead", "as", "at", "if", "is", "it",
"its", "be", "by", "do", "does", "did", "for", "from", "had", "has",
"he", "her", "him", "his", "how", "into", "me", "my", "of", "on", "or",
"our", "she", "so", "than", "that", "the", "their", "them", "then",
"there", "these", "they", "this", "those", "to", "up", "us", "was",
"we", "were", "what", "when", "where", "which", "who", "why", "will",
"with", "would", "you", "your",
}
SENTENCE_BOUNDARY = set('.!?…"“”\n')
def _load(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def _glossary_keys(glossary: dict) -> set[str]:
keys: set[str] = set()
for section in ("characters", "roles", "places", "terms"):
for key in glossary.get(section, {}) or {}:
keys.add(key.lower())
for extra in (glossary.get("title") or {}).values():
if isinstance(extra, str):
keys.add(extra.lower())
return keys
def _analyst_terms(run_dir: Path) -> dict[str, dict]:
out: dict[str, dict] = {}
for path in sorted(run_dir.glob("*/*.analysis.json")):
try:
data = _load(path)
except (OSError, json.JSONDecodeError):
continue
for term in data.get("proposed_terms", []) or []:
if not isinstance(term, dict):
continue
en = str(term.get("en") or "").strip().lower()
if not en:
continue
entry = out.setdefault(en, {"en": term.get("en"), "zh": term.get("zh"), "why": [], "count": 0})
entry["count"] += 1
why = term.get("why")
if why and why not in entry["why"]:
entry["why"].append(why)
if term.get("zh") and not entry.get("zh"):
entry["zh"] = term.get("zh")
return out
def _source_names(book: dict, only: set[str] | None) -> dict[str, dict]:
counts: dict[str, dict] = defaultdict(lambda: {"count": 0, "contexts": []})
pattern = re.compile(r"\b([A-Z][a-zA-Z'’-]+(?:\s+[A-Z][a-zA-Z'’-]+){0,2})\b")
for chapter in book["chapters"]:
if only and chapter["id"] not in only:
continue
for p in chapter["paragraphs"]:
text = p.get("text") or ""
for m in pattern.finditer(text):
name = m.group(1).strip()
name = re.sub(r"[’']s$", "", name).strip("-'")
if not name or "’" in name or "'" in name:
continue
words = name.split()
if all(w.lower() in STOPWORDS for w in words):
continue
if len(words) == 1 and words[0].lower().endswith("ly"):
continue
first = words[0].lower()
if first in STOPWORDS and len(words) == 1:
continue
start = m.start()
prev = (text[:start].rstrip() or " ")[-1]
mid_sentence = prev not in SENTENCE_BOUNDARY
if not mid_sentence and len(words) == 1:
# A single capitalised word at the start of a sentence is
# too weak a signal; require at least one mid-sentence use.
counts[name]["_initial"] = counts[name].get("_initial", 0) + 1
continue
counts[name]["count"] += 1
if len(counts[name]["contexts"]) < 3:
lo = max(0, start - 40)
hi = min(len(text), m.end() + 40)
counts[name]["contexts"].append("…" + text[lo:hi].replace("\n", " ") + "…")
return {k: v for k, v in counts.items() if v["count"] > 0}
def main() -> None:
ap = argparse.ArgumentParser(description="Propose glossary entries from the source and analyses.")
ap.add_argument("--book", type=Path, required=True)
ap.add_argument("--run-dir", type=Path, required=True)
ap.add_argument("--glossary", type=Path, required=True)
ap.add_argument("--out", type=Path, required=True)
ap.add_argument("--chapters", help="comma-separated chapter ids (default: all)")
args = ap.parse_args()
book = _load(args.book)
glossary = _load(args.glossary)
only = {c.strip() for c in args.chapters.split(",")} if args.chapters else None
known = _glossary_keys(glossary)
analyst = _analyst_terms(args.run_dir)
names = _source_names(book, only)
lines = ["# Deal Hunter glossary candidates", "", "未自动写入 glossary.json;请人工批准。", ""]
lines.append("## 分析器提议的术语 (proposed_terms)")
lines.append("")
lines.append("| en | zh | 次数 | 说明 |")
lines.append("|---|---|---|---|")
for en, entry in sorted(analyst.items()):
if en in known:
continue
why = " / ".join(entry["why"])[:120]
lines.append(f"| {entry['en']} | {entry.get('zh') or ''} | {entry['count']} | {why} |")
lines.append("")
lines.append("## 源码中的专名候选 (mid-sentence capitalised)")
lines.append("")
lines.append("| name | 次数 | 上下文 |")
lines.append("|---|---|---|")
for name, entry in sorted(names.items(), key=lambda kv: (-kv[1]["count"], kv[0])):
low = name.lower()
if low in known or low in LATIN_ALLOW:
continue
ctx = " <br> ".join(entry["contexts"][:2])
lines.append(f"| {name} | {entry['count']} | {ctx} |")
lines.append("")
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"wrote {args.out}")
print(f" analyst proposals: {len(analyst)} source candidates: {len(names)}")
if __name__ == "__main__":
main()