test_validator.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  1. from __future__ import annotations
  2. import json
  3. import tempfile
  4. import unittest
  5. from contextlib import contextmanager
  6. from pathlib import Path
  7. from types import SimpleNamespace
  8. from unittest.mock import MagicMock, patch
  9. from langchain_core.messages import AIMessage
  10. from production_build_agents.production.validator import (
  11. ProductionValidatorOutputError,
  12. _build_candidate,
  13. _collect_evidence,
  14. _parse_assessment,
  15. _validate_candidate,
  16. build_production_validation_report,
  17. run_production_validator,
  18. )
  19. from production_build_agents.contracts.production_models import (
  20. ProductionInput,
  21. ProductionInputCatalog,
  22. )
  23. from production_build_agents.contracts.production_planning_models import (
  24. PlannedSegment,
  25. ProductionPlan,
  26. )
  27. from production_build_agents.production.segment_acceptance import (
  28. immutable_json_ref,
  29. )
  30. from tests.support.production_fixtures import (
  31. ProductionScenario,
  32. immutable_ref,
  33. production_input_id,
  34. readiness_records,
  35. text_sha256,
  36. validation_records,
  37. )
  38. from tests.support.fake_models import ToolAwareFakeChatModel
  39. class ProductionValidatorRuntimeTest(unittest.TestCase):
  40. def setUp(self) -> None:
  41. case = ProductionScenario()
  42. self.plan = case.plan
  43. self.assembly = case.assembly
  44. self.delivery = case.assembly_delivery
  45. self.inspection, self.readiness = readiness_records(
  46. self.delivery,
  47. self.assembly,
  48. )
  49. self.catalog = ProductionInputCatalog(
  50. production_brief_uri="/runs/production-brief.json",
  51. production_brief_sha256="0" * 64,
  52. source_segment_ids=[
  53. item.source_segment_id for item in self.plan.segments
  54. ],
  55. inputs=[
  56. production_input
  57. for package in case.packages
  58. for production_input in package.production_inputs
  59. ],
  60. )
  61. self.candidate, _ = validation_records(self.delivery)
  62. def test_failed_readiness_stops_before_model_or_evidence_tools(
  63. self,
  64. ) -> None:
  65. failed = self.readiness.model_copy(update={"verdict": "FAIL"})
  66. with (
  67. tempfile.TemporaryDirectory() as temp_dir,
  68. patch(
  69. "production_build_agents.production.validator."
  70. "create_real_production_validator_model"
  71. ) as model_factory,
  72. patch(
  73. "production_build_agents.production.validator."
  74. "_collect_evidence"
  75. ) as collect,
  76. ):
  77. with self.assertRaisesRegex(
  78. ProductionValidatorOutputError,
  79. "Readiness 未通过",
  80. ):
  81. run_production_validator(
  82. self.plan,
  83. self.catalog,
  84. self.assembly,
  85. self.delivery,
  86. self.inspection,
  87. failed,
  88. run_dir=Path(temp_dir),
  89. assembly_delivery_ref=immutable_ref(
  90. "assembly-delivery"
  91. ),
  92. preflight_report_ref=immutable_ref(
  93. "readiness-report"
  94. ),
  95. )
  96. model_factory.assert_not_called()
  97. collect.assert_not_called()
  98. def test_candidate_must_cover_exact_semantic_criteria_and_boundaries(
  99. self,
  100. ) -> None:
  101. missing = self.candidate.model_copy(
  102. update={"criterion_results": self.candidate.criterion_results[:-1]}
  103. )
  104. with self.assertRaisesRegex(
  105. ProductionValidatorOutputError,
  106. "criterion_results",
  107. ):
  108. _validate_candidate(
  109. missing,
  110. self.delivery,
  111. assembly_delivery_ref=immutable_ref("assembly-delivery"),
  112. preflight_report_ref=immutable_ref("readiness-report"),
  113. )
  114. def test_host_builds_candidate_identity_criteria_and_boundaries(
  115. self,
  116. ) -> None:
  117. assessment = {
  118. "criterion_results": [
  119. {
  120. "verdict": item.verdict,
  121. "evidence": item.evidence,
  122. "reason": item.reason,
  123. }
  124. for item in self.candidate.criterion_results
  125. ],
  126. "boundary_results": [
  127. {
  128. "verdict": item.verdict,
  129. "evidence": item.evidence,
  130. "reason": item.reason,
  131. }
  132. for item in self.candidate.boundary_results
  133. ],
  134. "summary": self.candidate.summary,
  135. }
  136. model = ToolAwareFakeChatModel(
  137. responses=[
  138. AIMessage(
  139. content=json.dumps(assessment, ensure_ascii=False)
  140. )
  141. ]
  142. )
  143. assembly_ref = immutable_ref("assembly-delivery")
  144. readiness_ref = immutable_ref("readiness-report")
  145. with (
  146. tempfile.TemporaryDirectory() as temp_dir,
  147. patch(
  148. "production_build_agents.production.validator."
  149. "_collect_evidence",
  150. return_value=({}, []),
  151. ),
  152. ):
  153. result = run_production_validator(
  154. self.plan,
  155. self.catalog,
  156. self.assembly,
  157. self.delivery,
  158. self.inspection,
  159. self.readiness,
  160. run_dir=Path(temp_dir),
  161. assembly_delivery_ref=assembly_ref,
  162. preflight_report_ref=readiness_ref,
  163. model=model,
  164. tool_registry=MagicMock(),
  165. )
  166. self.assertEqual(result.run_id, self.delivery.run_id)
  167. self.assertEqual(result.plan_id, self.delivery.plan_id)
  168. self.assertEqual(result.plan_version, self.delivery.plan_version)
  169. self.assertEqual(result.assembly_delivery_ref, assembly_ref)
  170. self.assertEqual(result.preflight_report_ref, readiness_ref)
  171. self.assertEqual(
  172. [item.criterion for item in result.criterion_results],
  173. [
  174. "timeline_continuity",
  175. "audio_continuity",
  176. "subtitle_continuity",
  177. "visual_continuity",
  178. "semantic_content",
  179. ],
  180. )
  181. self.assertEqual(
  182. [
  183. (item.previous_segment_id, item.next_segment_id)
  184. for item in result.boundary_results
  185. ],
  186. [("Segment1", "Segment2")],
  187. )
  188. def test_legacy_checkpoint_candidate_is_rebound_by_host(self) -> None:
  189. legacy = self.candidate.model_copy(
  190. update={
  191. "run_id": "legacy-run",
  192. "plan_id": "LegacyPlan",
  193. "plan_version": 99,
  194. "assembly_delivery_ref": immutable_ref("legacy-assembly"),
  195. "preflight_report_ref": immutable_ref("legacy-readiness"),
  196. }
  197. )
  198. assessment = _parse_assessment(
  199. legacy.model_dump_json(),
  200. self.delivery,
  201. )
  202. result = _build_candidate(
  203. assessment,
  204. self.delivery,
  205. assembly_delivery_ref=immutable_ref("assembly-delivery"),
  206. preflight_report_ref=immutable_ref("readiness-report"),
  207. )
  208. self.assertEqual(result.run_id, self.delivery.run_id)
  209. self.assertEqual(result.plan_id, self.delivery.plan_id)
  210. self.assertEqual(result.plan_version, self.delivery.plan_version)
  211. self.assertEqual(
  212. result.assembly_delivery_ref,
  213. immutable_ref("assembly-delivery"),
  214. )
  215. self.assertEqual(
  216. result.preflight_report_ref,
  217. immutable_ref("readiness-report"),
  218. )
  219. def test_report_references_candidate_and_code_recomputes_verdict(
  220. self,
  221. ) -> None:
  222. failed_result = self.candidate.criterion_results[0].model_copy(
  223. update={"verdict": "FAIL", "reason": "语义失败"}
  224. )
  225. candidate = self.candidate.model_copy(
  226. update={
  227. "criterion_results": [
  228. failed_result,
  229. *self.candidate.criterion_results[1:],
  230. ]
  231. }
  232. )
  233. report = build_production_validation_report(
  234. self.assembly,
  235. self.delivery,
  236. self.inspection,
  237. self.readiness,
  238. candidate,
  239. assembly_delivery_ref=immutable_ref("assembly-delivery"),
  240. media_inspection_ref=immutable_ref("media-inspection"),
  241. preflight_report_ref=immutable_ref("readiness-report"),
  242. validator_candidate_ref=immutable_ref("validator-candidate"),
  243. )
  244. self.assertEqual(report.verdict, "FAIL")
  245. self.assertEqual(
  246. report.validator_candidate_ref,
  247. immutable_ref("validator-candidate"),
  248. )
  249. self.assertFalse(hasattr(report, "criterion_results"))
  250. def test_validator_uses_plan_version_in_agent_identity(
  251. self,
  252. ) -> None:
  253. plan = ProductionPlan(
  254. plan_id=self.plan.plan_id,
  255. plan_version=self.plan.plan_version,
  256. planning_package_ref=immutable_ref("planning-v2"),
  257. segments=[
  258. PlannedSegment(
  259. **segment.model_dump(),
  260. )
  261. for segment in self.plan.segments
  262. ],
  263. timeline=self.plan.timeline,
  264. regenerate_segment_ids=[],
  265. summary="0.7 DAG",
  266. )
  267. @contextmanager
  268. def checkpointer():
  269. yield MagicMock()
  270. with (
  271. tempfile.TemporaryDirectory() as temp_dir,
  272. patch(
  273. "production_build_agents.production.validator."
  274. "create_default_tool_registry",
  275. return_value=MagicMock(),
  276. ) as create_registry,
  277. patch(
  278. "production_build_agents.production.validator."
  279. "_collect_evidence",
  280. return_value=({}, []),
  281. ),
  282. patch(
  283. "production_build_agents.production.validator."
  284. "create_sqlite_checkpointer",
  285. side_effect=lambda *_args, **_kwargs: checkpointer(),
  286. ),
  287. patch(
  288. "production_build_agents.production.validator.create_agent",
  289. return_value=MagicMock(),
  290. ),
  291. patch(
  292. "production_build_agents.production.validator."
  293. "_candidate_from_agent",
  294. return_value=(self.candidate, {}),
  295. ) as candidate_from_agent,
  296. patch(
  297. "production_build_agents.production.validator."
  298. "record_agent_messages",
  299. ),
  300. ):
  301. result = run_production_validator(
  302. plan,
  303. self.catalog,
  304. self.assembly,
  305. self.delivery,
  306. self.inspection,
  307. self.readiness,
  308. run_dir=Path(temp_dir),
  309. assembly_delivery_ref=immutable_ref(
  310. "assembly-delivery"
  311. ),
  312. preflight_report_ref=immutable_ref(
  313. "readiness-report"
  314. ),
  315. model=MagicMock(),
  316. )
  317. self.assertEqual(result, self.candidate)
  318. expected_run_id = (
  319. "production-run:production-validator:"
  320. f"v{self.delivery.plan_version}"
  321. )
  322. self.assertEqual(
  323. candidate_from_agent.call_args.kwargs["validator_run_id"],
  324. expected_run_id,
  325. )
  326. self.assertEqual(
  327. create_registry.call_args.kwargs["output_dir"],
  328. Path(temp_dir).resolve()
  329. / "production_tool_outputs"
  330. / f"v{self.delivery.plan_version}",
  331. )
  332. self.assertEqual(
  333. create_registry.call_args.kwargs["executor_run_id"],
  334. f"{expected_run_id}:evidence",
  335. )
  336. def test_evidence_includes_full_inputs_unassigned_requirements_reports_and_shot_frames(
  337. self,
  338. ) -> None:
  339. unassigned = ProductionInput(
  340. input_id=production_input_id(99),
  341. source_path="$.制作表.全局要求.未分配",
  342. content_sha256=text_sha256("未分配要求"),
  343. value={"要求": "片尾必须出现完整行动号召"},
  344. applies_to_source_segment_ids=["段落1"],
  345. )
  346. catalog = self.catalog.model_copy(
  347. update={"inputs": [*self.catalog.inputs, unassigned]}
  348. )
  349. first_segment = self.plan.segments[0].model_copy(
  350. update={
  351. "production_input_ids": [
  352. *self.plan.segments[0].production_input_ids,
  353. unassigned.input_id,
  354. ]
  355. }
  356. )
  357. plan = self.plan.model_copy(
  358. update={"segments": [first_segment, *self.plan.segments[1:]]}
  359. )
  360. with tempfile.TemporaryDirectory() as temporary:
  361. report_dir = Path(temporary)
  362. accepted = []
  363. for report, reference in zip(
  364. ProductionScenario().reports,
  365. self.assembly.accepted_segments,
  366. ):
  367. path = report_dir / f"{report.segment_id}.json"
  368. path.write_text(report.model_dump_json(), encoding="utf-8")
  369. accepted.append(
  370. reference.model_copy(
  371. update={
  372. "validation_report_ref": immutable_json_ref(path)
  373. }
  374. )
  375. )
  376. assembly = self.assembly.model_copy(
  377. update={"accepted_segments": accepted}
  378. )
  379. registry = MagicMock()
  380. registry.resolve.return_value = [
  381. SimpleNamespace(name=name)
  382. for name in (
  383. "transcribe_audio",
  384. "inspect_ass_subtitles",
  385. "extract_frames",
  386. "view_images",
  387. )
  388. ]
  389. def evidence(tool, arguments):
  390. if tool.name == "transcribe_audio":
  391. return {"text": "完整旁白"}
  392. if tool.name == "inspect_ass_subtitles":
  393. return {"cues": [{"text": "完整字幕"}]}
  394. if tool.name == "extract_frames":
  395. return {
  396. "frames": [
  397. {
  398. "timestamp_sec": timestamp,
  399. "local_path": (
  400. f"/tmp/frame-{index}.png"
  401. ),
  402. }
  403. for index, timestamp in enumerate(
  404. arguments["timestamps"]
  405. )
  406. ]
  407. }
  408. raise AssertionError(tool.name)
  409. with (
  410. patch(
  411. "production_build_agents.production.validator."
  412. "invoke_evidence_tool",
  413. side_effect=evidence,
  414. ) as invoke,
  415. patch(
  416. "production_build_agents.production.validator."
  417. "view_image_evidence",
  418. return_value=[{"type": "image_url"}],
  419. ),
  420. ):
  421. payload, image_blocks = _collect_evidence(
  422. plan,
  423. catalog,
  424. assembly,
  425. self.delivery,
  426. self.inspection,
  427. self.readiness,
  428. registry=registry,
  429. assembly_delivery_ref=immutable_ref(
  430. "assembly-delivery"
  431. ),
  432. preflight_report_ref=immutable_ref(
  433. "readiness-report"
  434. ),
  435. )
  436. input_context = payload["production_input_context"]
  437. self.assertEqual(
  438. input_context["unassigned_to_shots"],
  439. [unassigned.input_id],
  440. )
  441. self.assertEqual(
  442. input_context["inputs"][-1]["value"],
  443. unassigned.value,
  444. )
  445. self.assertEqual(
  446. [item["segment_id"] for item in payload[
  447. "segment_validation_summaries"
  448. ]],
  449. ["Segment1", "Segment2"],
  450. )
  451. extract_call = next(
  452. call
  453. for call in invoke.call_args_list
  454. if call.args[0].name == "extract_frames"
  455. )
  456. self.assertEqual(
  457. extract_call.args[1]["timestamps"],
  458. [0.0, 2.5, 4.9, 5.1, 7.5, 9.95],
  459. )
  460. frame_purposes = [
  461. purpose
  462. for frame in payload["semantic_evidence"]["timeline_frames"]
  463. for purpose in frame["purposes"]
  464. ]
  465. self.assertEqual(
  466. [
  467. item["shot_id"]
  468. for item in frame_purposes
  469. if item["kind"] == "shot_midpoint"
  470. ],
  471. ["Segment1-Shot1", "Segment2-Shot1"],
  472. )
  473. self.assertEqual(image_blocks, [{"type": "image_url"}])
  474. def test_report_persists_same_versioned_validator_identity(
  475. self,
  476. ) -> None:
  477. validator_run_id = (
  478. "production-run:production-validator:"
  479. f"v{self.delivery.plan_version}"
  480. )
  481. report = build_production_validation_report(
  482. self.assembly,
  483. self.delivery,
  484. self.inspection,
  485. self.readiness,
  486. self.candidate,
  487. assembly_delivery_ref=immutable_ref("assembly-delivery"),
  488. media_inspection_ref=immutable_ref("media-inspection"),
  489. preflight_report_ref=immutable_ref("readiness-report"),
  490. validator_candidate_ref=immutable_ref("validator-candidate"),
  491. validator_run_id=validator_run_id,
  492. )
  493. self.assertEqual(report.validator_run_id, validator_run_id)
  494. if __name__ == "__main__":
  495. unittest.main()