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.
273 lines
11 KiB
Python
273 lines
11 KiB
Python
# Copyright 2026 Umbra Tools (umbratools.com)
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""Project-status adapter backed by the ``productions/`` directory.
|
|
|
|
The master store does not yet hold translation/audio/format state, so this
|
|
adapter reads ``productions/<book>-<lang>/`` directly. It is a seam: when the
|
|
store gains those rows, this module can be replaced without changing the API.
|
|
|
|
For each program, the adapter returns every configured target language plus the
|
|
English source row. Languages without a production directory are reported as
|
|
``not_started``; languages with a production directory report real chapter
|
|
progress from the runs directory.
|
|
|
|
The chapter count is a claim about the *files*, not about the *text*. A
|
|
directory full of Chinese can sit in a directory named for Spanish and be
|
|
reported as a complete Spanish translation - measured on oldbox, that is
|
|
exactly what ``deal-hunter-es`` was. So the text is measured too, and a
|
|
production whose text is not in the language it claims is reported as
|
|
``mismatched`` rather than ``complete``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
# slug -> productions directory name stem
|
|
_SLUG_TO_PROD = {
|
|
"dealhunter-trans": "deal-hunter-zh",
|
|
}
|
|
|
|
# Configured target languages per program slug.
|
|
# Each entry: language_tag -> (label, role)
|
|
_TARGET_LANGUAGES = {
|
|
"dealhunter-trans": {
|
|
"en": ("English (source)", "source"),
|
|
"zh-Hans": ("Simplified Chinese (zh-CN)", "translation"),
|
|
"es-ES": ("Spanish (es-ES)", "translation"),
|
|
"fr-FR": ("French (fr-FR)", "translation"),
|
|
"de-DE": ("German (de-DE)", "translation"),
|
|
"it-IT": ("Italian (it-IT)", "translation"),
|
|
"pt-BR": ("Portuguese (pt-BR)", "translation"),
|
|
"ru-RU": ("Russian (ru-RU)", "translation"),
|
|
"ja-JP": ("Japanese (ja-JP)", "translation"),
|
|
"ko-KR": ("Korean (ko-KR)", "translation"),
|
|
"ar-SA": ("Arabic (ar-SA)", "translation"),
|
|
},
|
|
}
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class LanguageStatus:
|
|
language_tag: str
|
|
language_label: str
|
|
source_present: bool
|
|
translation: dict[str, Any]
|
|
audio: dict[str, Any]
|
|
outputs: dict[str, Any]
|
|
review: dict[str, Any]
|
|
updated_at: str | None
|
|
|
|
|
|
def _load_json(path: Path) -> dict[str, Any] | None:
|
|
if not path.is_file():
|
|
return None
|
|
try:
|
|
with path.open("r", encoding="utf-8") as fh:
|
|
data = json.load(fh)
|
|
return data if isinstance(data, dict) else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _max_mtime(paths: list[Path]) -> str | None:
|
|
mtimes = [p.stat().st_mtime for p in paths if p.exists()]
|
|
if not mtimes:
|
|
return None
|
|
return datetime.fromtimestamp(max(mtimes)).isoformat()
|
|
|
|
|
|
# The translation language registry, loaded once. It lives in
|
|
# ``tools/translation/languages.py``, which is not a distributed package, so
|
|
# it is loaded from the repository by path. It is the same file the pipeline
|
|
# uses, so the dashboard cannot disagree with it about which script a
|
|
# language is written in.
|
|
_LANGUAGE_REGISTRY: Any | None = None
|
|
_LANGUAGE_REGISTRY_TRIED = False
|
|
|
|
|
|
def _language_registry() -> Any | None:
|
|
"""The translation language registry, or None if it cannot be loaded."""
|
|
global _LANGUAGE_REGISTRY, _LANGUAGE_REGISTRY_TRIED
|
|
if _LANGUAGE_REGISTRY_TRIED:
|
|
return _LANGUAGE_REGISTRY
|
|
_LANGUAGE_REGISTRY_TRIED = True
|
|
path = Path(__file__).resolve().parents[1] / "tools" / "translation" / "languages.py"
|
|
try:
|
|
if path.is_file():
|
|
spec = importlib.util.spec_from_file_location("umbratrans_languages", path)
|
|
if spec and spec.loader:
|
|
module = importlib.util.module_from_spec(spec)
|
|
# The module has to be in sys.modules before it is executed:
|
|
# @dataclass resolves its own module through sys.modules.
|
|
sys.modules[spec.name] = module
|
|
spec.loader.exec_module(module)
|
|
_LANGUAGE_REGISTRY = module
|
|
return module
|
|
from tools.translation import languages # type: ignore[import-not-found]
|
|
|
|
_LANGUAGE_REGISTRY = languages
|
|
return languages
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _language_problem(runs_dir: Path | None, tag: str) -> str | None:
|
|
"""Why the text in a production is not in the language it claims.
|
|
|
|
Returns None when the text is in the declared language. When the check
|
|
cannot be performed at all it says so, rather than passing silently: a
|
|
production that cannot be verified is not one that can be reported
|
|
complete.
|
|
"""
|
|
if runs_dir is None or not runs_dir.is_dir():
|
|
return None
|
|
registry = _language_registry()
|
|
if registry is None:
|
|
return "the language registry could not be loaded, so the text could not be checked"
|
|
try:
|
|
return registry.check_runs_language(runs_dir, tag)
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
return f"the language could not be checked: {exc}"
|
|
|
|
|
|
def _count_translated_chapters(runs_dir: Path) -> tuple[int, int]:
|
|
"""Return (done, total) chapter counts from the runs directory."""
|
|
done = 0
|
|
chapter_dirs: list[Path] = []
|
|
if runs_dir.is_dir():
|
|
for ch in sorted(runs_dir.iterdir()):
|
|
if ch.is_dir() and ch.name.startswith("ch-"):
|
|
chapter_dirs.append(ch)
|
|
if (ch / "translation.json").is_file():
|
|
done += 1
|
|
return done, len(chapter_dirs)
|
|
|
|
|
|
def _read_outputs(out_progress: Path, out_final: Path) -> dict[str, Any]:
|
|
"""Read available output files from progress and final directories."""
|
|
outputs: dict[str, Any] = {}
|
|
candidates: list[tuple[Path, str]] = []
|
|
if out_progress.is_dir():
|
|
candidates.append((out_progress, "output-progress"))
|
|
if out_final.is_dir():
|
|
candidates.append((out_final, "output"))
|
|
for subdir, key in candidates:
|
|
for p in subdir.iterdir():
|
|
if not p.is_file():
|
|
continue
|
|
name = p.name
|
|
if name.endswith(".pdf"):
|
|
outputs.setdefault("pdf-paperback", {"available": True, "path": str(p), "source": key})
|
|
elif name.endswith(".odt") or name.endswith(".asciidoc"):
|
|
outputs.setdefault("epub", {"available": True, "path": str(p), "source": key})
|
|
return outputs
|
|
|
|
|
|
class ProjectStatusAdapter:
|
|
def __init__(self, productions_root: Path | None = None) -> None:
|
|
if productions_root is not None:
|
|
self.productions_root = Path(productions_root)
|
|
else:
|
|
self.productions_root = Path(__file__).resolve().parents[1] / "productions"
|
|
|
|
def status_for(self, project) -> dict[str, Any]:
|
|
prod_name = _SLUG_TO_PROD.get(getattr(project, "slug", ""))
|
|
prod_dir = self.productions_root / prod_name if prod_name else None
|
|
runs_dir = prod_dir / "runs" if prod_dir else None
|
|
|
|
# Chapter totals come from the source book when available; fall back
|
|
# to the number of chapter directories found in runs.
|
|
done, total = _count_translated_chapters(runs_dir) if runs_dir else (0, 0)
|
|
|
|
languages: list[dict[str, Any]] = []
|
|
|
|
# English source row is always present when the project exists.
|
|
languages.append(
|
|
{
|
|
"language_tag": "en",
|
|
"language_label": "English (source)",
|
|
"role": "source",
|
|
"source_present": True,
|
|
"translation": {
|
|
"state": "complete",
|
|
"chapters_done": total,
|
|
"chapters_total": total,
|
|
"language_problem": None,
|
|
},
|
|
"audio": {"state": "unknown"},
|
|
"outputs": {},
|
|
"review": {"state": "ok"},
|
|
}
|
|
)
|
|
|
|
target_langs = _TARGET_LANGUAGES.get(getattr(project, "slug", ""), {})
|
|
|
|
for tag in sorted(target_langs.keys()):
|
|
if tag == "en":
|
|
continue # source row already added above
|
|
label, role = target_langs[tag]
|
|
|
|
# A language-specific production directory would be named
|
|
# deal-hunter-<tag> (e.g. deal-hunter-es for Spanish). It is
|
|
# looked up on its own: a Spanish production exists or it does
|
|
# not, regardless of whether the Chinese one is there.
|
|
lang_prod_name = f"deal-hunter-{tag.split('-')[0]}"
|
|
lang_prod_dir = self.productions_root / lang_prod_name
|
|
lang_runs = lang_prod_dir / "runs" if lang_prod_dir.is_dir() else None
|
|
lang_out_progress = lang_prod_dir / "output-progress" if lang_prod_dir.is_dir() else None
|
|
lang_out_final = lang_prod_dir / "output" if lang_prod_dir.is_dir() else None
|
|
|
|
if lang_runs and lang_runs.is_dir():
|
|
lang_done, lang_total = _count_translated_chapters(lang_runs)
|
|
state = (
|
|
"complete"
|
|
if lang_total and lang_done >= lang_total
|
|
else ("in_progress" if lang_done else "not_started")
|
|
)
|
|
outputs = _read_outputs(lang_out_progress or Path(), lang_out_final or Path())
|
|
updated_at = _max_mtime(
|
|
[lang_runs / f"ch-{i}" / "translation.json" for i in range(1, 40)] if lang_runs.is_dir() else []
|
|
)
|
|
else:
|
|
state = "not_started"
|
|
lang_done, lang_total = 0, 0
|
|
outputs = {}
|
|
updated_at = None
|
|
|
|
# The chapter count says the files are there. The script says
|
|
# which language they are in. A production that declares one
|
|
# language and holds another is not complete in the language
|
|
# it declares.
|
|
language_problem = _language_problem(lang_runs, tag)
|
|
if language_problem:
|
|
state = "mismatched"
|
|
|
|
languages.append(
|
|
{
|
|
"language_tag": tag,
|
|
"language_label": label,
|
|
"role": role,
|
|
"source_present": tag == "en",
|
|
"translation": {
|
|
"state": state,
|
|
"chapters_done": lang_done,
|
|
"chapters_total": lang_total,
|
|
"language_problem": language_problem,
|
|
},
|
|
"audio": {"state": "not_started"},
|
|
"outputs": outputs,
|
|
"review": {"state": "needs_review" if state in {"complete", "mismatched"} else "pending"},
|
|
"updated_at": updated_at,
|
|
}
|
|
)
|
|
|
|
return {"project_id": str(getattr(project, "id")), "languages": languages}
|