492 lines
20 KiB
Python
Executable File
492 lines
20 KiB
Python
Executable File
#!/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}
|
||
suppressed_segment_ids = [
|
||
record["id"] for record in all_reviews if record["status"] == "flagged"
|
||
]
|
||
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()),
|
||
"suppressed_segment_ids": suppressed_segment_ids,
|
||
"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())
|