feat: add scientific subtitle review workflow

This commit is contained in:
2026-08-25 14:22:30 +08:00
parent 555ed525f6
commit fb5a4017f6
11 changed files with 1054 additions and 14 deletions
@@ -0,0 +1,171 @@
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
import tempfile
import unittest
SCRIPT_DIR = Path(__file__).resolve().parents[1] / "scripts"
def load(name: str, filename: str):
spec = importlib.util.spec_from_file_location(name, SCRIPT_DIR / filename)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
pipeline = load("review_test_pipeline", "subtitle_pipeline.py")
review = load("scientific_review", "scientific_review.py")
class ScientificReviewTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name)
source = self.root / "source.srt"
source.write_text(
"1\n00:00:00,000 --> 00:00:01,000\nInject 50 microliters.\n\n"
"2\n00:00:01,100 --> 00:00:02,000\nExpose the superior sclera.\n\n"
"3\n00:00:02,100 --> 00:00:03,000\nAAV8-GFP expression was detected.\n",
encoding="utf-8",
)
self.manifest_path = pipeline.prepare(source, self.root / "subtitles", "en")
self.manifest = json.loads(self.manifest_path.read_text(encoding="utf-8"))
self.translations = self.root / "translations"
self.translations.mkdir()
records = [
{"id": self.manifest["segments"][0]["id"], "translation": "注射50微升"},
{"id": self.manifest["segments"][1]["id"], "translation": "暴露上方巩膜组织"},
{"id": self.manifest["segments"][2]["id"], "translation": "检测到AAV8-GFP表达"},
]
(self.translations / "batch-0001.json").write_text(
json.dumps({"translations": records}, ensure_ascii=False), encoding="utf-8"
)
self.review_dir = self.root / "subtitles" / "scientific-review"
def tearDown(self) -> None:
self.temporary.cleanup()
def write_profile(self) -> None:
self.review_dir.mkdir(parents=True, exist_ok=True)
(self.review_dir / review.PROFILE_NAME).write_text(
json.dumps(
{
"domains": [
{
"id": "experimental-animal-science",
"label": "实验动物学",
"relevance": "primary",
}
],
"review_focus": ["剂量、解剖方向和载体名称"],
"terminology": [
{
"source": "superior sclera",
"preferred": "上方巩膜",
"category": "anatomy",
"note": "保持方向信息",
}
],
},
ensure_ascii=False,
),
encoding="utf-8",
)
def test_profile_review_finalize_and_bound_render(self) -> None:
first = review.next_batch(self.manifest_path, self.translations, self.review_dir)
self.assertEqual(first["stage"], "domain_profile_required")
self.assertLessEqual(len(first["profile"]["representative_samples"]), 18)
self.write_profile()
pending = review.next_batch(self.manifest_path, self.translations, self.review_dir)
self.assertEqual(pending["stage"], "scientific_review_required")
items = pending["batch"]["items"]
records = [
{
"id": items[0]["id"],
"status": "approved",
"translation": items[0]["translation"],
"severity": "none",
"category": "none",
"reason": "",
},
{
"id": items[1]["id"],
"status": "corrected",
"translation": "暴露上方巩膜",
"severity": "low",
"category": "anatomy",
"reason": "删除原文没有的“组织”",
},
{
"id": items[2]["id"],
"status": "flagged",
"translation": items[2]["translation"],
"severity": "high",
"category": "gene_protein_vector",
"reason": "载体名称需保守保留,无法仅凭字幕确认",
},
]
Path(pending["output_path"]).write_text(
json.dumps({"reviews": records}, ensure_ascii=False), encoding="utf-8"
)
complete = review.next_batch(self.manifest_path, self.translations, self.review_dir)
self.assertTrue(complete["done"])
finalized = review.finalize(self.manifest_path, self.translations, self.review_dir)
self.assertEqual(finalized["counts"]["corrected"], 1)
self.assertEqual(finalized["counts"]["unresolved_high"], 1)
validation_path = pipeline.render(
self.manifest_path,
Path(finalized["reviewed_translations_dir"]),
self.root / "rendered",
scientific_review_report=Path(finalized["report"]),
)
validation = json.loads(validation_path.read_text(encoding="utf-8"))
self.assertTrue(validation["translation_quality_reviewed"])
self.assertFalse(validation["scientific_review"]["human_expert_reviewed"])
target = (self.root / "rendered" / "zh-CN.srt").read_text(encoding="utf-8")
self.assertIn("暴露上方巩膜", target)
self.assertNotIn("暴露上方巩膜组织", target)
def test_correction_cannot_change_protected_number_or_scientific_name(self) -> None:
self.write_profile()
pending = review.next_batch(self.manifest_path, self.translations, self.review_dir)
items = pending["batch"]["items"]
records = []
for item in items:
translation = item["translation"]
status = "approved"
severity = "none"
category = "none"
reason = ""
if "50" in translation:
translation = translation.replace("50", "500")
status = "corrected"
severity = "high"
category = "number_unit"
reason = "unsafe numerical rewrite"
records.append(
{
"id": item["id"],
"status": status,
"translation": translation,
"severity": severity,
"category": category,
"reason": reason,
}
)
output = Path(pending["output_path"])
output.write_text(json.dumps({"reviews": records}, ensure_ascii=False), encoding="utf-8")
with self.assertRaisesRegex(review.ReviewError, "protected numbers"):
review.next_batch(self.manifest_path, self.translations, self.review_dir)
if __name__ == "__main__":
unittest.main()
@@ -217,6 +217,80 @@ class VerifyDeliveryTests(unittest.TestCase):
self.assertTrue(result["complete"])
self.assertEqual(result["burned_video"], str(expected.resolve()))
def test_scientific_review_gate_is_required_and_checksum_bound(self) -> None:
inputs = self.root / "subtitles" / "translation-input"
inputs.mkdir(parents=True)
batch = inputs / "batch-0001.json"
batch.write_text("{}", encoding="utf-8")
outputs = self.root / "subtitles" / "translation-output"
outputs.mkdir()
(outputs / "batch-0001.json").write_text("{}", encoding="utf-8")
subtitle_manifest = self.root / "subtitles" / "subtitle-manifest.json"
subtitle_manifest.write_text(
json.dumps(
{
"translation_batches": [{"path": str(batch)}],
"translation_output_dir": str(outputs),
}
),
encoding="utf-8",
)
self.manifest.write_text(
json.dumps(
{
"deliverable": "bilingual-subs",
"output_directory": str(self.root),
"execution": {"scientific_review_required": True},
"artifacts": {
"intermediate": None,
"subtitle": {"source_srt": {"path": self.subtitle.name}},
},
}
),
encoding="utf-8",
)
pending = delivery.assess_delivery(self.manifest)
self.assertEqual(pending["stage"], "scientific_review_required")
review_dir = self.root / "subtitles" / "scientific-review"
reviewed = review_dir / "reviewed-translations"
reviewed.mkdir(parents=True)
(reviewed / "translations.json").write_text("{}", encoding="utf-8")
report = review_dir / "report.json"
report.write_text(
json.dumps(
{
"status": "complete",
"human_expert_reviewed": False,
"subtitle_manifest_sha256": hashlib.sha256(
subtitle_manifest.read_bytes()
).hexdigest(),
}
),
encoding="utf-8",
)
rendered = self.root / "subtitles" / "rendered"
rendered.mkdir()
(rendered / "bilingual.ass").write_text("[Script Info]\n", encoding="utf-8")
(rendered / "validation.json").write_text(
json.dumps(
{
"translation_quality_reviewed": True,
"scientific_review": {
"report_sha256": hashlib.sha256(report.read_bytes()).hexdigest()
},
}
),
encoding="utf-8",
)
result = delivery.assess_delivery(self.manifest)
self.assertTrue(result["complete"])
report.write_text("{}", encoding="utf-8")
with self.assertRaisesRegex(delivery.DeliveryError, "incomplete"):
delivery.assess_delivery(self.manifest)
def test_declared_citation_requires_matching_burn_receipt(self) -> None:
inputs = self.root / "subtitles" / "translation-input"
inputs.mkdir(parents=True)