feat: add scientific subtitle review workflow
This commit is contained in:
@@ -1127,6 +1127,7 @@ def _advance_bilingual_stage(download_manifest: Path) -> int:
|
||||
"next_stage": "translation_required",
|
||||
"subtitle_manifest": str(subtitle_manifest),
|
||||
"translation_batch_count": len(batch_paths),
|
||||
"scientific_review_required": True,
|
||||
}
|
||||
)
|
||||
_write_manifest(output_dir, manifest)
|
||||
@@ -1146,7 +1147,9 @@ def _advance_bilingual_stage(download_manifest: Path) -> int:
|
||||
),
|
||||
"instruction": (
|
||||
"Run subtitle_pipeline.py next-batch repeatedly, translating each "
|
||||
"pending batch in order until done, then render and verify; "
|
||||
"pending batch in order until done; run scientific_review.py through "
|
||||
"profile, review, and finalize; then render the reviewed translations "
|
||||
"with its bound report and verify; "
|
||||
"burn only for the full deliverable."
|
||||
),
|
||||
},
|
||||
|
||||
+487
@@ -0,0 +1,487 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Source-bound AI review gate for biomedical and life-science subtitles.
|
||||
|
||||
The active session model supplies a compact domain profile and reviews one
|
||||
bounded batch at a time. This script validates every decision, preserves the
|
||||
initial translation, applies only evidence-backed corrections, and emits a
|
||||
checksum-bound reviewed translation set plus an auditable report.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections import Counter
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
from typing import Any, Sequence
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
PIPELINE_PATH = SCRIPT_DIR / "subtitle_pipeline.py"
|
||||
SPEC = importlib.util.spec_from_file_location("materialsub_subtitle_pipeline", PIPELINE_PATH)
|
||||
if SPEC is None or SPEC.loader is None: # pragma: no cover - installation failure
|
||||
raise RuntimeError("Could not load subtitle_pipeline.py")
|
||||
pipeline = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(pipeline)
|
||||
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
REVIEW_CONTRACT_VERSION = 1
|
||||
REVIEW_BATCH_SIZE = 40
|
||||
CONTEXT_SEGMENTS = 2
|
||||
PROFILE_NAME = "domain-profile.json"
|
||||
PROFILE_INPUT_NAME = "domain-profile-input.json"
|
||||
REPORT_NAME = "report.json"
|
||||
REPORT_MARKDOWN_NAME = "report.md"
|
||||
REVIEWED_DIR_NAME = "reviewed-translations"
|
||||
INPUT_DIR_NAME = "input"
|
||||
OUTPUT_DIR_NAME = "output"
|
||||
RELEVANCE = {"primary", "secondary"}
|
||||
STATUSES = {"approved", "corrected", "flagged"}
|
||||
SEVERITIES = {"none", "low", "medium", "high"}
|
||||
CATEGORIES = {
|
||||
"none",
|
||||
"terminology",
|
||||
"anatomy",
|
||||
"procedure",
|
||||
"experimental_animal",
|
||||
"drug_dose_route",
|
||||
"number_unit",
|
||||
"gene_protein_vector",
|
||||
"cell_molecular",
|
||||
"imaging_instrument",
|
||||
"statistics_results",
|
||||
"logic_negation_sequence",
|
||||
"source_text_suspected",
|
||||
"language_clarity",
|
||||
"other_scientific",
|
||||
}
|
||||
DISCLOSURE = (
|
||||
"本字幕经过 AI 辅助医学与生命科学术语、语义及实验参数一致性审校,"
|
||||
"未经相关专业人员人工审核。"
|
||||
)
|
||||
|
||||
|
||||
class ReviewError(RuntimeError):
|
||||
"""A malformed profile, review decision, or review job."""
|
||||
|
||||
|
||||
def _canonical(value: Any) -> bytes:
|
||||
return json.dumps(
|
||||
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256_bytes(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _sha256_json(value: Any) -> str:
|
||||
return _sha256_bytes(_canonical(value))
|
||||
|
||||
|
||||
def _read_json(path: Path) -> Any:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as exc:
|
||||
raise ReviewError(f"file not found: {path}") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ReviewError(f"invalid JSON in {path}: {exc}") from exc
|
||||
|
||||
|
||||
def _write_json(path: Path, value: Any) -> None:
|
||||
pipeline._atomic_write(path, pipeline._json_bytes(value))
|
||||
|
||||
|
||||
def _manifest_title(manifest_path: Path) -> str:
|
||||
download_manifest = manifest_path.parent.parent / "download-manifest.json"
|
||||
if not download_manifest.is_file():
|
||||
return "Untitled scientific video"
|
||||
value = _read_json(download_manifest)
|
||||
source = value.get("source") if isinstance(value, dict) else None
|
||||
title = source.get("title") if isinstance(source, dict) else None
|
||||
return title.strip() if isinstance(title, str) and title.strip() else "Untitled scientific video"
|
||||
|
||||
|
||||
def _segment_items(manifest: dict[str, Any], translations: dict[str, str]) -> list[dict[str, str]]:
|
||||
cue_by_id = {cue["id"]: cue for cue in manifest["cues"]}
|
||||
return [
|
||||
{
|
||||
"id": segment["id"],
|
||||
"source": pipeline._segment_source_text(segment, cue_by_id),
|
||||
"translation": translations[segment["id"]],
|
||||
}
|
||||
for segment in manifest["segments"]
|
||||
]
|
||||
|
||||
|
||||
def _profile_samples(items: list[dict[str, str]]) -> list[dict[str, str]]:
|
||||
if len(items) <= 18:
|
||||
return items
|
||||
indices = list(range(6))
|
||||
middle = len(items) // 2
|
||||
indices.extend(range(max(6, middle - 3), min(len(items) - 6, middle + 3)))
|
||||
indices.extend(range(len(items) - 6, len(items)))
|
||||
return [items[index] for index in sorted(set(indices))]
|
||||
|
||||
|
||||
def _profile_input(
|
||||
manifest_path: Path, manifest: dict[str, Any], items: list[dict[str, str]]
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"scientific_review_contract_version": REVIEW_CONTRACT_VERSION,
|
||||
"task": "classify_biomedical_life_science_domains",
|
||||
"title": _manifest_title(manifest_path),
|
||||
"source_language": manifest["source_language"],
|
||||
"target_language": manifest["target_language"],
|
||||
"representative_samples": _profile_samples(items),
|
||||
"output_fields": ["domains", "review_focus", "terminology"],
|
||||
}
|
||||
|
||||
|
||||
def _validate_profile(value: Any, path: Path) -> dict[str, Any]:
|
||||
if not isinstance(value, dict) or set(value) != {"domains", "review_focus", "terminology"}:
|
||||
raise ReviewError(f"domain profile has forbidden or missing fields: {path}")
|
||||
domains = value["domains"]
|
||||
if not isinstance(domains, list) or not 1 <= len(domains) <= 8:
|
||||
raise ReviewError("domain profile must contain 1-8 domains")
|
||||
seen: set[str] = set()
|
||||
for domain in domains:
|
||||
if not isinstance(domain, dict) or set(domain) != {"id", "label", "relevance"}:
|
||||
raise ReviewError("each domain must contain only id, label, and relevance")
|
||||
if not all(isinstance(item, str) and item.strip() for item in domain.values()):
|
||||
raise ReviewError("domain fields must be non-empty strings")
|
||||
if not re.fullmatch(r"[a-z][a-z0-9-]{1,47}", domain["id"]):
|
||||
raise ReviewError(f"invalid domain id: {domain['id']!r}")
|
||||
if domain["id"] in seen:
|
||||
raise ReviewError(f"duplicate domain id: {domain['id']}")
|
||||
seen.add(domain["id"])
|
||||
if domain["relevance"] not in RELEVANCE:
|
||||
raise ReviewError(f"invalid domain relevance: {domain['relevance']}")
|
||||
focus = value["review_focus"]
|
||||
if not isinstance(focus, list) or not 1 <= len(focus) <= 16:
|
||||
raise ReviewError("review_focus must contain 1-16 strings")
|
||||
if any(not isinstance(item, str) or not item.strip() for item in focus):
|
||||
raise ReviewError("review_focus entries must be non-empty strings")
|
||||
terminology = value["terminology"]
|
||||
if not isinstance(terminology, list) or len(terminology) > 80:
|
||||
raise ReviewError("terminology must be an array of at most 80 entries")
|
||||
for term in terminology:
|
||||
if not isinstance(term, dict) or set(term) != {"source", "preferred", "category", "note"}:
|
||||
raise ReviewError("terminology entries have forbidden or missing fields")
|
||||
if not all(isinstance(item, str) for item in term.values()):
|
||||
raise ReviewError("terminology fields must be strings")
|
||||
if not term["source"].strip() or not term["preferred"].strip():
|
||||
raise ReviewError("terminology source and preferred fields cannot be empty")
|
||||
return value
|
||||
|
||||
|
||||
_NUMBER_TOKEN = re.compile(r"\d+(?:[.,:/-]\d+)*(?:\s?%|\s?°[CF])?")
|
||||
_SCIENTIFIC_TOKEN = re.compile(
|
||||
r"\b(?=[A-Za-z0-9+_.-]{2,}\b)(?=[A-Za-z0-9+_.-]*(?:[A-Z]{2}|\d))"
|
||||
r"[A-Za-z][A-Za-z0-9+_.-]*\b"
|
||||
)
|
||||
|
||||
|
||||
def _protected_tokens(text: str) -> Counter[str]:
|
||||
return Counter([*_NUMBER_TOKEN.findall(text), *_SCIENTIFIC_TOKEN.findall(text)])
|
||||
|
||||
|
||||
def _review_records(value: Any, path: Path) -> list[dict[str, str]]:
|
||||
if not isinstance(value, dict) or set(value) != {"reviews"} or not isinstance(value["reviews"], list):
|
||||
raise ReviewError(f"{path} must contain only a reviews array")
|
||||
records: list[dict[str, str]] = []
|
||||
required = {"id", "status", "translation", "severity", "category", "reason"}
|
||||
for index, record in enumerate(value["reviews"], start=1):
|
||||
if not isinstance(record, dict) or set(record) != required:
|
||||
raise ReviewError(f"review {index} in {path} has forbidden or missing fields")
|
||||
if not all(isinstance(item, str) for item in record.values()):
|
||||
raise ReviewError(f"review {index} in {path} fields must be strings")
|
||||
if any("\n" in item or "\r" in item for item in record.values()):
|
||||
raise ReviewError(f"review {index} in {path} fields must be single-line strings")
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
def _validate_review_output(
|
||||
path: Path, batch: dict[str, Any]
|
||||
) -> list[dict[str, str]]:
|
||||
records = _review_records(_read_json(path), path)
|
||||
expected = batch["items"]
|
||||
if [record["id"] for record in records] != [item["id"] for item in expected]:
|
||||
raise ReviewError(f"review output IDs mismatch: {path}")
|
||||
validated: list[dict[str, str]] = []
|
||||
for record, item in zip(records, expected):
|
||||
status = record["status"]
|
||||
severity = record["severity"]
|
||||
category = record["category"]
|
||||
reason = record["reason"].strip()
|
||||
translation = record["translation"].strip()
|
||||
initial = item["translation"].strip()
|
||||
if status not in STATUSES or severity not in SEVERITIES or category not in CATEGORIES:
|
||||
raise ReviewError(f"invalid review status, severity, or category for {record['id']}")
|
||||
if not translation:
|
||||
raise ReviewError(f"reviewed translation is empty for {record['id']}")
|
||||
if status == "approved":
|
||||
if translation != initial or severity != "none" or category != "none" or reason:
|
||||
raise ReviewError(f"approved review must preserve the initial translation: {record['id']}")
|
||||
elif status == "corrected":
|
||||
if translation == initial or severity == "none" or category == "none" or not reason:
|
||||
raise ReviewError(f"corrected review lacks a justified change: {record['id']}")
|
||||
if _protected_tokens(translation) != _protected_tokens(initial):
|
||||
raise ReviewError(
|
||||
f"correction changes protected numbers or scientific names; flag it instead: {record['id']}"
|
||||
)
|
||||
else:
|
||||
if translation != initial or severity not in {"medium", "high"} or category == "none" or not reason:
|
||||
raise ReviewError(f"flagged review must conservatively preserve its initial translation: {record['id']}")
|
||||
validated.append({**record, "translation": translation, "reason": reason})
|
||||
return validated
|
||||
|
||||
|
||||
def _review_batch_payload(
|
||||
items: list[dict[str, str]], start: int, profile: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
selected = items[start : start + REVIEW_BATCH_SIZE]
|
||||
end = start + len(selected)
|
||||
return {
|
||||
"scientific_review_contract_version": REVIEW_CONTRACT_VERSION,
|
||||
"task": "source_bound_biomedical_life_science_review",
|
||||
"domain_profile": profile,
|
||||
"context": {
|
||||
"before": items[max(0, start - CONTEXT_SEGMENTS) : start],
|
||||
"after": items[end : end + CONTEXT_SEGMENTS],
|
||||
},
|
||||
"items": selected,
|
||||
"output_fields": ["id", "status", "translation", "severity", "category", "reason"],
|
||||
"allowed_statuses": sorted(STATUSES),
|
||||
"allowed_severities": sorted(SEVERITIES),
|
||||
"allowed_categories": sorted(CATEGORIES),
|
||||
}
|
||||
|
||||
|
||||
def _prepare_batches(
|
||||
review_dir: Path, items: list[dict[str, str]], profile: dict[str, Any]
|
||||
) -> list[tuple[Path, Path, dict[str, Any]]]:
|
||||
input_dir = review_dir / INPUT_DIR_NAME
|
||||
output_dir = review_dir / OUTPUT_DIR_NAME
|
||||
input_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
batches: list[tuple[Path, Path, dict[str, Any]]] = []
|
||||
for number, start in enumerate(range(0, len(items), REVIEW_BATCH_SIZE), start=1):
|
||||
payload = _review_batch_payload(items, start, profile)
|
||||
input_path = input_dir / f"batch-{number:04d}.json"
|
||||
output_path = output_dir / input_path.name
|
||||
encoded = _canonical(payload) + b"\n"
|
||||
if input_path.exists() and input_path.read_bytes() != encoded:
|
||||
raise ReviewError(f"existing scientific review input changed: {input_path}")
|
||||
if not input_path.exists():
|
||||
pipeline._atomic_write(input_path, encoded)
|
||||
batches.append((input_path, output_path, payload))
|
||||
return batches
|
||||
|
||||
|
||||
def next_batch(
|
||||
manifest_path: Path, translations_dir: Path, review_dir: Path
|
||||
) -> dict[str, Any]:
|
||||
manifest_path = manifest_path.expanduser().resolve()
|
||||
review_dir = review_dir.expanduser().resolve()
|
||||
manifest = pipeline.validate_manifest(manifest_path)
|
||||
translations = pipeline.load_translations(manifest, translations_dir)
|
||||
items = _segment_items(manifest, translations)
|
||||
review_dir.mkdir(parents=True, exist_ok=True)
|
||||
profile_input_path = review_dir / PROFILE_INPUT_NAME
|
||||
profile_payload = _profile_input(manifest_path, manifest, items)
|
||||
encoded_profile_input = _canonical(profile_payload) + b"\n"
|
||||
if profile_input_path.exists() and profile_input_path.read_bytes() != encoded_profile_input:
|
||||
raise ReviewError("existing scientific domain profile input changed")
|
||||
if not profile_input_path.exists():
|
||||
pipeline._atomic_write(profile_input_path, encoded_profile_input)
|
||||
profile_path = review_dir / PROFILE_NAME
|
||||
if not profile_path.exists():
|
||||
return {
|
||||
"done": False,
|
||||
"stage": "domain_profile_required",
|
||||
"input_path": str(profile_input_path),
|
||||
"output_path": str(profile_path),
|
||||
"profile": profile_payload,
|
||||
}
|
||||
profile = _validate_profile(_read_json(profile_path), profile_path)
|
||||
batches = _prepare_batches(review_dir, items, profile)
|
||||
pending: list[tuple[Path, Path, dict[str, Any]]] = []
|
||||
for input_path, output_path, payload in batches:
|
||||
if not output_path.exists():
|
||||
pending.append((input_path, output_path, payload))
|
||||
else:
|
||||
_validate_review_output(output_path, payload)
|
||||
if not pending:
|
||||
return {
|
||||
"done": True,
|
||||
"stage": "finalize_required",
|
||||
"remaining": 0,
|
||||
"review_dir": str(review_dir),
|
||||
}
|
||||
input_path, output_path, payload = pending[0]
|
||||
return {
|
||||
"done": False,
|
||||
"stage": "scientific_review_required",
|
||||
"remaining": len(pending),
|
||||
"input_path": str(input_path),
|
||||
"output_path": str(output_path),
|
||||
"batch": payload,
|
||||
}
|
||||
|
||||
|
||||
def _markdown_report(report: dict[str, Any]) -> str:
|
||||
lines = [
|
||||
"# 科研专业审校报告",
|
||||
"",
|
||||
report["disclosure"],
|
||||
"",
|
||||
f"- 审校条目:{report['counts']['total']}",
|
||||
f"- 自动修订:{report['counts']['corrected']}",
|
||||
f"- 保守标记:{report['counts']['flagged']}",
|
||||
f"- 未解决高风险:{report['counts']['unresolved_high']}",
|
||||
"",
|
||||
"## 领域标签",
|
||||
"",
|
||||
]
|
||||
lines.extend(
|
||||
f"- {domain['label']}({domain['relevance']})"
|
||||
for domain in report["profile"]["domains"]
|
||||
)
|
||||
lines.extend(["", "## 修改与疑点", ""])
|
||||
if not report["issues"]:
|
||||
lines.append("未发现需要修改或保守标记的专业问题。")
|
||||
for issue in report["issues"]:
|
||||
lines.extend(
|
||||
[
|
||||
f"### {issue['id']} · {issue['status']} · {issue['severity']}",
|
||||
"",
|
||||
f"- 类别:{issue['category']}",
|
||||
f"- 原译:{issue['initial_translation']}",
|
||||
f"- 审校后:{issue['translation']}",
|
||||
f"- 理由:{issue['reason']}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def finalize(
|
||||
manifest_path: Path, translations_dir: Path, review_dir: Path
|
||||
) -> dict[str, Any]:
|
||||
manifest_path = manifest_path.expanduser().resolve()
|
||||
translations_dir = translations_dir.expanduser().resolve()
|
||||
review_dir = review_dir.expanduser().resolve()
|
||||
manifest = pipeline.validate_manifest(manifest_path)
|
||||
initial = pipeline.load_translations(manifest, translations_dir)
|
||||
items = _segment_items(manifest, initial)
|
||||
profile_path = review_dir / PROFILE_NAME
|
||||
profile = _validate_profile(_read_json(profile_path), profile_path)
|
||||
batches = _prepare_batches(review_dir, items, profile)
|
||||
all_reviews: list[dict[str, str]] = []
|
||||
for _, output_path, payload in batches:
|
||||
if not output_path.is_file():
|
||||
raise ReviewError(f"scientific review output is missing: {output_path}")
|
||||
all_reviews.extend(_validate_review_output(output_path, payload))
|
||||
if [item["id"] for item in all_reviews] != [item["id"] for item in items]:
|
||||
raise ReviewError("scientific reviews do not cover every subtitle segment exactly once")
|
||||
reviewed = {record["id"]: record["translation"] for record in all_reviews}
|
||||
reviewed_dir = review_dir / REVIEWED_DIR_NAME
|
||||
reviewed_dir.mkdir(parents=True, exist_ok=True)
|
||||
reviewed_path = reviewed_dir / "translations.json"
|
||||
reviewed_payload = {
|
||||
"translations": [
|
||||
{"id": item["id"], "translation": reviewed[item["id"]]}
|
||||
for item in items
|
||||
]
|
||||
}
|
||||
_write_json(reviewed_path, reviewed_payload)
|
||||
initial_by_id = {item["id"]: item["translation"] for item in items}
|
||||
issues = [
|
||||
{
|
||||
**record,
|
||||
"initial_translation": initial_by_id[record["id"]],
|
||||
}
|
||||
for record in all_reviews
|
||||
if record["status"] != "approved"
|
||||
]
|
||||
counts = Counter(record["status"] for record in all_reviews)
|
||||
report = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"scientific_review_contract_version": REVIEW_CONTRACT_VERSION,
|
||||
"status": "complete",
|
||||
"review_method": "active_session_model_source_bound",
|
||||
"human_expert_reviewed": False,
|
||||
"disclosure": DISCLOSURE,
|
||||
"subtitle_manifest_sha256": _sha256_bytes(manifest_path.read_bytes()),
|
||||
"initial_translation_sha256": _sha256_json(initial),
|
||||
"reviewed_translation_sha256": _sha256_json(reviewed),
|
||||
"reviewed_translations_dir": str(reviewed_dir.resolve()),
|
||||
"profile": profile,
|
||||
"counts": {
|
||||
"total": len(all_reviews),
|
||||
"approved": counts["approved"],
|
||||
"corrected": counts["corrected"],
|
||||
"flagged": counts["flagged"],
|
||||
"unresolved_high": sum(
|
||||
record["status"] == "flagged" and record["severity"] == "high"
|
||||
for record in all_reviews
|
||||
),
|
||||
},
|
||||
"issues": issues,
|
||||
}
|
||||
report_path = review_dir / REPORT_NAME
|
||||
_write_json(report_path, report)
|
||||
pipeline._atomic_write(
|
||||
review_dir / REPORT_MARKDOWN_NAME, _markdown_report(report).encode("utf-8")
|
||||
)
|
||||
return {
|
||||
"report": str(report_path),
|
||||
"report_markdown": str(review_dir / REPORT_MARKDOWN_NAME),
|
||||
"reviewed_translations_dir": str(reviewed_dir),
|
||||
"counts": report["counts"],
|
||||
}
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run source-bound AI scientific review over completed subtitle translations."
|
||||
)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
for name, help_text in (
|
||||
("next-batch", "return the next domain-profile or scientific-review batch"),
|
||||
("finalize", "validate all reviews and build reviewed translations and reports"),
|
||||
):
|
||||
command = commands.add_parser(name, help=help_text)
|
||||
command.add_argument("--manifest", type=Path, required=True)
|
||||
command.add_argument("--translations-dir", type=Path, required=True)
|
||||
command.add_argument("--review-dir", type=Path, required=True)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = _parser().parse_args(argv)
|
||||
try:
|
||||
if args.command == "next-batch":
|
||||
payload = next_batch(args.manifest, args.translations_dir, args.review_dir)
|
||||
else:
|
||||
payload = {
|
||||
"done": True,
|
||||
"stage": "scientific_review_complete",
|
||||
**finalize(args.manifest, args.translations_dir, args.review_dir),
|
||||
}
|
||||
print(json.dumps({"ok": True, **payload}, ensure_ascii=False, sort_keys=True))
|
||||
return 0
|
||||
except (ReviewError, pipeline.PipelineError, OSError, UnicodeError) as exc:
|
||||
print(f"scientific review error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1131,14 +1131,52 @@ def _check_outputs(
|
||||
return checksums
|
||||
|
||||
|
||||
def _scientific_review_binding(
|
||||
review_report: Path | None,
|
||||
manifest_path: Path,
|
||||
translations_dir: Path,
|
||||
translations: dict[str, str],
|
||||
) -> dict[str, Any] | None:
|
||||
if review_report is None:
|
||||
return None
|
||||
review_report = review_report.expanduser().resolve()
|
||||
value = _read_json(review_report)
|
||||
if not isinstance(value, dict) or value.get("status") != "complete":
|
||||
raise PipelineError("scientific review report is incomplete or malformed")
|
||||
if value.get("human_expert_reviewed") is not False:
|
||||
raise PipelineError("scientific review report must not claim human expert review")
|
||||
if value.get("subtitle_manifest_sha256") != _sha256_bytes(manifest_path.read_bytes()):
|
||||
raise PipelineError("scientific review report is bound to a different subtitle manifest")
|
||||
declared_dir = value.get("reviewed_translations_dir")
|
||||
if not isinstance(declared_dir, str) or Path(declared_dir).expanduser().resolve() != translations_dir.expanduser().resolve():
|
||||
raise PipelineError("render translations do not match the scientific review report")
|
||||
if value.get("reviewed_translation_sha256") != _sha256_json(translations):
|
||||
raise PipelineError("reviewed translations changed after scientific review")
|
||||
counts = value.get("counts")
|
||||
if not isinstance(counts, dict) or counts.get("total") != len(translations):
|
||||
raise PipelineError("scientific review report segment count is invalid")
|
||||
return {
|
||||
"report_path": str(review_report),
|
||||
"report_sha256": _sha256_bytes(review_report.read_bytes()),
|
||||
"method": value.get("review_method"),
|
||||
"human_expert_reviewed": False,
|
||||
"counts": counts,
|
||||
"disclosure": value.get("disclosure"),
|
||||
}
|
||||
|
||||
|
||||
def _validation_report(
|
||||
manifest: dict[str, Any], checksums: dict[str, str], font: str
|
||||
manifest: dict[str, Any],
|
||||
checksums: dict[str, str],
|
||||
font: str,
|
||||
scientific_review: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"structurally_valid": True,
|
||||
"validation_scope": "structural_source_integrity",
|
||||
"translation_quality_reviewed": False,
|
||||
"translation_quality_reviewed": scientific_review is not None,
|
||||
"scientific_review": scientific_review,
|
||||
"target_language": manifest.get("target_language") or DEFAULT_TARGET_LANGUAGE,
|
||||
"source_sha256": manifest["source"]["sha256"],
|
||||
"source_ledger_sha256": manifest["source_ledger_sha256"],
|
||||
@@ -1164,11 +1202,15 @@ def render(
|
||||
translations_dir: Path,
|
||||
output_dir: Path,
|
||||
font: str = DEFAULT_FONT,
|
||||
scientific_review_report: Path | None = None,
|
||||
) -> Path:
|
||||
manifest_path = manifest_path.expanduser().resolve()
|
||||
output_dir = output_dir.expanduser().resolve()
|
||||
manifest = validate_manifest(manifest_path)
|
||||
translations = load_translations(manifest, translations_dir)
|
||||
scientific_review = _scientific_review_binding(
|
||||
scientific_review_report, manifest_path, translations_dir, translations
|
||||
)
|
||||
expected = _expected_outputs(manifest, translations, font)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
for name, data in expected.items():
|
||||
@@ -1177,7 +1219,7 @@ def render(
|
||||
if destination_manifest.resolve() != manifest_path:
|
||||
_atomic_write(destination_manifest, manifest_path.read_bytes())
|
||||
checksums = _check_outputs(output_dir, expected, manifest_path)
|
||||
report = _validation_report(manifest, checksums, font)
|
||||
report = _validation_report(manifest, checksums, font, scientific_review)
|
||||
report_path = output_dir / VALIDATION_NAME
|
||||
_atomic_write(report_path, _json_bytes(report))
|
||||
return report_path
|
||||
@@ -1188,14 +1230,18 @@ def validate(
|
||||
translations_dir: Path,
|
||||
output_dir: Path,
|
||||
font: str = DEFAULT_FONT,
|
||||
scientific_review_report: Path | None = None,
|
||||
) -> Path:
|
||||
manifest_path = manifest_path.expanduser().resolve()
|
||||
output_dir = output_dir.expanduser().resolve()
|
||||
manifest = validate_manifest(manifest_path)
|
||||
translations = load_translations(manifest, translations_dir)
|
||||
scientific_review = _scientific_review_binding(
|
||||
scientific_review_report, manifest_path, translations_dir, translations
|
||||
)
|
||||
expected = _expected_outputs(manifest, translations, font)
|
||||
checksums = _check_outputs(output_dir, expected, manifest_path)
|
||||
report = _validation_report(manifest, checksums, font)
|
||||
report = _validation_report(manifest, checksums, font, scientific_review)
|
||||
report_path = output_dir / VALIDATION_NAME
|
||||
_atomic_write(report_path, _json_bytes(report))
|
||||
return report_path
|
||||
@@ -1243,6 +1289,11 @@ def _parser() -> argparse.ArgumentParser:
|
||||
default=DEFAULT_FONT,
|
||||
help="ASS font family (default: MiSans; subtitle styles use weight 700/Bold)",
|
||||
)
|
||||
command.add_argument(
|
||||
"--scientific-review-report",
|
||||
type=Path,
|
||||
help="checksum-bound scientific review report for the reviewed translations",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
@@ -1272,12 +1323,20 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
payload = {"ok": True, **next_translation_batch(args.manifest)}
|
||||
elif args.command == "render":
|
||||
result = render(
|
||||
args.manifest, args.translations_dir, args.output_dir, args.font
|
||||
args.manifest,
|
||||
args.translations_dir,
|
||||
args.output_dir,
|
||||
args.font,
|
||||
args.scientific_review_report,
|
||||
)
|
||||
payload = {"ok": True, "validation": str(result)}
|
||||
else:
|
||||
result = validate(
|
||||
args.manifest, args.translations_dir, args.output_dir, args.font
|
||||
args.manifest,
|
||||
args.translations_dir,
|
||||
args.output_dir,
|
||||
args.font,
|
||||
args.scientific_review_report,
|
||||
)
|
||||
payload = {"ok": True, "validation": str(result)}
|
||||
print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
|
||||
|
||||
@@ -177,6 +177,42 @@ def assess_delivery(download_manifest: Path) -> dict[str, Any]:
|
||||
"missing": missing_batches,
|
||||
}
|
||||
|
||||
execution = download.get("execution")
|
||||
scientific_review_required = (
|
||||
isinstance(execution, dict)
|
||||
and execution.get("scientific_review_required") is True
|
||||
)
|
||||
scientific_review_report_path = subtitle_dir / "scientific-review" / "report.json"
|
||||
scientific_review: dict[str, Any] | None = None
|
||||
if scientific_review_required:
|
||||
reviewed_translations = (
|
||||
subtitle_dir
|
||||
/ "scientific-review"
|
||||
/ "reviewed-translations"
|
||||
/ "translations.json"
|
||||
)
|
||||
missing_review = [
|
||||
str(path)
|
||||
for path in (scientific_review_report_path, reviewed_translations)
|
||||
if not path.is_file()
|
||||
]
|
||||
if missing_review:
|
||||
return {
|
||||
"complete": False,
|
||||
"stage": "scientific_review_required",
|
||||
"job_dir": str(job_dir),
|
||||
"missing": missing_review,
|
||||
}
|
||||
scientific_review = _read_json(scientific_review_report_path)
|
||||
if scientific_review.get("status") != "complete":
|
||||
raise DeliveryError("scientific review report is incomplete")
|
||||
if scientific_review.get("human_expert_reviewed") is not False:
|
||||
raise DeliveryError("scientific review report makes an invalid human-review claim")
|
||||
if scientific_review.get("subtitle_manifest_sha256") != _sha256_file(
|
||||
subtitle_manifest_path
|
||||
):
|
||||
raise DeliveryError("scientific review report is bound to a different subtitle manifest")
|
||||
|
||||
rendered_dir = subtitle_dir / "rendered"
|
||||
required_rendered = [rendered_dir / "bilingual.ass", rendered_dir / "validation.json"]
|
||||
missing_rendered = [str(path) for path in required_rendered if not path.is_file()]
|
||||
@@ -187,6 +223,13 @@ def assess_delivery(download_manifest: Path) -> dict[str, Any]:
|
||||
"job_dir": str(job_dir),
|
||||
"missing": missing_rendered,
|
||||
}
|
||||
if scientific_review_required:
|
||||
validation = _read_json(rendered_dir / "validation.json")
|
||||
binding = validation.get("scientific_review")
|
||||
if validation.get("translation_quality_reviewed") is not True or not isinstance(binding, dict):
|
||||
raise DeliveryError("rendered subtitles did not use the scientific-review gate")
|
||||
if binding.get("report_sha256") != _sha256_file(scientific_review_report_path):
|
||||
raise DeliveryError("rendered subtitles use a stale scientific review report")
|
||||
if deliverable == "bilingual-subs":
|
||||
return complete("bilingual_subs_complete", rendered_dir=str(rendered_dir))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user