test_segment_dependencies.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. from __future__ import annotations
  2. import tempfile
  3. import unittest
  4. from pathlib import Path
  5. from production_build_agents.run.segment_dependencies import (
  6. SegmentDependencyIntegrityError,
  7. load_verified_segment_dependencies,
  8. )
  9. from production_build_agents.run.segment_inputs import (
  10. load_verified_segment_inputs,
  11. )
  12. from production_build_agents.agents.segment.executor import _user_payload
  13. from production_build_agents.agents.segment.provenance import (
  14. normalize_candidate_lineage,
  15. )
  16. from production_build_agents.contracts.models import ToolCallRecord
  17. from production_build_agents.contracts.production_evaluation import (
  18. evaluate_segment_delivery,
  19. )
  20. from production_build_agents.contracts.document_identity import (
  21. exact_document_sha256,
  22. )
  23. from production_build_agents.contracts.production_execution_models import (
  24. ImmutableJsonRef,
  25. SegmentDependencyRef,
  26. SegmentPackage,
  27. )
  28. from production_build_agents.run.artifacts import (
  29. content_identity_for_path,
  30. file_sha256,
  31. seal_shared_visual_anchor,
  32. )
  33. from tests.support.production_fixtures import (
  34. output_profile,
  35. segment_bundle,
  36. )
  37. from tests.support.production_planner_fixtures import (
  38. build_production_planner_scenario,
  39. )
  40. def _ref(path: Path) -> ImmutableJsonRef:
  41. return ImmutableJsonRef(
  42. uri=str(path.resolve()),
  43. document_sha256=exact_document_sha256(path.read_bytes()),
  44. )
  45. class SegmentDependencyTest(unittest.TestCase):
  46. def _records(self, root: Path):
  47. (
  48. source_package,
  49. _source_candidate,
  50. source_delivery,
  51. _validator_candidate,
  52. source_report,
  53. _accepted,
  54. ) = segment_bundle(1)
  55. package_path = root / "upstream-package.json"
  56. package_path.write_text(
  57. source_package.model_dump_json(),
  58. encoding="utf-8",
  59. )
  60. sealed_artifacts = []
  61. for artifact in source_delivery.artifacts:
  62. path = root / f"{artifact.artifact_id}.bin"
  63. path.write_bytes(artifact.artifact_id.encode())
  64. sealed_artifacts.append(
  65. artifact.model_copy(
  66. update={
  67. "uri": str(path.resolve()),
  68. "source_uri": None,
  69. **content_identity_for_path(path),
  70. }
  71. )
  72. )
  73. source_delivery = source_delivery.model_copy(
  74. update={
  75. "segment_package_uri": str(package_path.resolve()),
  76. "segment_package_sha256": file_sha256(package_path),
  77. "artifacts": sealed_artifacts,
  78. }
  79. )
  80. delivery_path = root / "upstream-delivery.json"
  81. report_path = root / "upstream-report.json"
  82. delivery_path.write_text(
  83. source_delivery.model_dump_json(),
  84. encoding="utf-8",
  85. )
  86. report_path.write_text(
  87. source_report.model_dump_json(),
  88. encoding="utf-8",
  89. )
  90. dependency = SegmentDependencyRef(
  91. segment_id="Segment1",
  92. segment_delivery_ref=_ref(delivery_path),
  93. validation_report_ref=_ref(report_path),
  94. )
  95. (
  96. downstream_base,
  97. downstream_candidate,
  98. downstream_delivery,
  99. _validator_candidate,
  100. _report,
  101. _accepted,
  102. ) = segment_bundle(2)
  103. downstream = downstream_base.model_copy(
  104. update={"dependencies": [dependency]}
  105. )
  106. return (
  107. downstream,
  108. downstream_candidate,
  109. downstream_delivery,
  110. source_delivery,
  111. report_path,
  112. )
  113. def test_exact_refs_pass_report_and_full_agent_payload(self) -> None:
  114. with tempfile.TemporaryDirectory() as directory:
  115. root = Path(directory)
  116. package, _candidate, _delivery, source, _report_path = (
  117. self._records(root)
  118. )
  119. verified = load_verified_segment_dependencies(package)
  120. self.assertEqual(len(verified), 1)
  121. sealed_package_artifacts = []
  122. for artifact in package.artifacts:
  123. path = root / f"selected-{artifact.artifact_id}.bin"
  124. path.write_bytes(artifact.artifact_id.encode())
  125. sealed_package_artifacts.append(
  126. artifact.model_copy(
  127. update={
  128. "uri": str(path.resolve()),
  129. "source_uri": None,
  130. **content_identity_for_path(path),
  131. }
  132. )
  133. )
  134. global_delivery = build_production_planner_scenario(
  135. run_id="dependency-global"
  136. ).global_data_delivery.model_copy(
  137. update={"active_artifacts": sealed_package_artifacts}
  138. )
  139. global_delivery_path = root / "global-delivery.json"
  140. global_delivery_path.write_text(
  141. global_delivery.model_dump_json(),
  142. encoding="utf-8",
  143. )
  144. anchor_path = root / "shared-anchor.png"
  145. anchor_path.write_bytes(b"shared-anchor")
  146. package = package.model_copy(
  147. update={
  148. "artifacts": sealed_package_artifacts,
  149. "global_data_delivery_ref": _ref(global_delivery_path),
  150. "shared_visual_anchor": seal_shared_visual_anchor(
  151. anchor_path
  152. ),
  153. }
  154. )
  155. verified_inputs = load_verified_segment_inputs(package)
  156. payload = _user_payload(
  157. package,
  158. executor_run_id="run:Segment2:v1:executor",
  159. verified_inputs=verified_inputs,
  160. )
  161. dependency_payload = payload[
  162. "verified_segment_inputs"
  163. ]["accepted_segments"][0]
  164. self.assertEqual(
  165. [
  166. item["artifact_id"]
  167. for item in dependency_payload["artifacts"]
  168. ],
  169. [item.artifact_id for item in source.artifacts],
  170. )
  171. self.assertTrue(
  172. all(
  173. item["uri"] == source.artifacts[index].uri
  174. for index, item in enumerate(
  175. dependency_payload["artifacts"]
  176. )
  177. )
  178. )
  179. self.assertTrue(
  180. all(
  181. "source_uri" not in item
  182. for item in dependency_payload["artifacts"]
  183. )
  184. )
  185. self.assertNotIn("tool_calls", dependency_payload)
  186. def test_tampered_or_failed_report_is_rejected_before_execution(
  187. self,
  188. ) -> None:
  189. with tempfile.TemporaryDirectory() as directory:
  190. root = Path(directory)
  191. package, _candidate, _delivery, _source, report_path = (
  192. self._records(root)
  193. )
  194. report_path.write_text("{}", encoding="utf-8")
  195. with self.assertRaisesRegex(
  196. SegmentDependencyIntegrityError,
  197. "精确字节已变化",
  198. ):
  199. load_verified_segment_dependencies(package)
  200. def test_accepted_artifact_uri_cannot_conflict_with_global_input(
  201. self,
  202. ) -> None:
  203. with tempfile.TemporaryDirectory() as directory:
  204. root = Path(directory)
  205. package, _candidate, _delivery, source, _report_path = (
  206. self._records(root)
  207. )
  208. package = package.model_copy(
  209. update={
  210. "artifacts": [
  211. package.artifacts[0].model_copy(
  212. update={"uri": source.artifacts[0].uri}
  213. )
  214. ]
  215. }
  216. )
  217. with self.assertRaisesRegex(
  218. SegmentDependencyIntegrityError,
  219. "ID 和 URI 必须全局无歧义",
  220. ):
  221. load_verified_segment_dependencies(package)
  222. def test_failed_dependency_report_is_rejected_before_execution(
  223. self,
  224. ) -> None:
  225. with tempfile.TemporaryDirectory() as directory:
  226. root = Path(directory)
  227. package, _candidate, _delivery, _source, report_path = (
  228. self._records(root)
  229. )
  230. report = package.dependencies[0].validation_report_ref
  231. from production_build_agents.contracts.production_models import (
  232. SegmentValidationReport,
  233. )
  234. failed = SegmentValidationReport.model_validate_json(
  235. report_path.read_bytes()
  236. ).model_copy(
  237. update={
  238. "criterion_results": [
  239. SegmentValidationReport.model_validate_json(
  240. report_path.read_bytes()
  241. ).criterion_results[0].model_copy(
  242. update={"verdict": "FAIL"}
  243. ),
  244. *SegmentValidationReport.model_validate_json(
  245. report_path.read_bytes()
  246. ).criterion_results[1:],
  247. ],
  248. "verdict": "FAIL",
  249. }
  250. )
  251. report_path.write_text(
  252. failed.model_dump_json(),
  253. encoding="utf-8",
  254. )
  255. package = package.model_copy(
  256. update={
  257. "dependencies": [
  258. package.dependencies[0].model_copy(
  259. update={
  260. "validation_report_ref": _ref(report_path)
  261. }
  262. )
  263. ]
  264. }
  265. )
  266. with self.assertRaisesRegex(
  267. SegmentDependencyIntegrityError,
  268. "不是已验收 PASS",
  269. ):
  270. load_verified_segment_dependencies(package)
  271. def test_dependency_media_is_an_allowed_formal_lineage_input(
  272. self,
  273. ) -> None:
  274. with tempfile.TemporaryDirectory() as directory:
  275. root = Path(directory)
  276. package, candidate, delivery, source, _report_path = (
  277. self._records(root)
  278. )
  279. upstream = source.artifacts[-1]
  280. shot_uri = candidate.shot_outputs[0].artifact_uri
  281. candidate = candidate.model_copy(
  282. update={
  283. "lineage_claims": [
  284. (
  285. claim.model_copy(
  286. update={
  287. "input_artifact_uris": [
  288. *claim.input_artifact_uris,
  289. upstream.uri,
  290. ]
  291. }
  292. )
  293. if claim.artifact_uri == shot_uri
  294. else claim
  295. )
  296. for claim in candidate.lineage_claims
  297. ]
  298. }
  299. )
  300. shot_id = delivery.shot_outputs[0].artifact_id
  301. delivery = delivery.model_copy(
  302. update={
  303. "artifact_graph": delivery.artifact_graph.model_copy(
  304. update={
  305. "lineages": [
  306. (
  307. lineage.model_copy(
  308. update={
  309. "input_artifact_ids": [
  310. *lineage.input_artifact_ids,
  311. upstream.artifact_id,
  312. ]
  313. }
  314. )
  315. if lineage.artifact_id == shot_id
  316. else lineage
  317. )
  318. for lineage in (
  319. delivery.artifact_graph.lineages
  320. )
  321. ]
  322. }
  323. )
  324. }
  325. )
  326. without_context = {
  327. issue.code
  328. for issue in evaluate_segment_delivery(
  329. package,
  330. candidate,
  331. delivery,
  332. )
  333. }
  334. self.assertIn(
  335. "unknown_lineage_artifact_uri",
  336. without_context,
  337. )
  338. verified = load_verified_segment_dependencies(package)
  339. self.assertEqual(
  340. evaluate_segment_delivery(
  341. package,
  342. candidate,
  343. delivery,
  344. dependency_artifacts=tuple(
  345. artifact
  346. for dependency in verified
  347. for artifact in dependency.delivery.artifacts
  348. ),
  349. ),
  350. [],
  351. )
  352. def test_tool_argument_is_normalized_to_dependency_lineage(self) -> None:
  353. with tempfile.TemporaryDirectory() as directory:
  354. root = Path(directory)
  355. package, candidate, _delivery, source, _report_path = (
  356. self._records(root)
  357. )
  358. verified = load_verified_segment_dependencies(package)
  359. upstream = source.artifacts[-1]
  360. shot_uri = candidate.shot_outputs[0].artifact_uri
  361. narration_uri = candidate.narration_artifact_uri
  362. subtitle_uri = candidate.subtitle_artifact_uri
  363. primary_uri = candidate.primary_artifact_uri
  364. records = [
  365. ToolCallRecord(
  366. tool_call_id="make-shot",
  367. tool_name="make_video",
  368. arguments={
  369. "source": package.artifacts[0].uri,
  370. "anchor": package.shared_visual_anchor.uri,
  371. "previous_segment": upstream.uri,
  372. },
  373. success=True,
  374. output_refs=[shot_uri],
  375. ),
  376. ToolCallRecord(
  377. tool_call_id="make-narration",
  378. tool_name="make_audio",
  379. arguments={"source": package.artifacts[0].uri},
  380. success=True,
  381. output_refs=[narration_uri],
  382. ),
  383. ToolCallRecord(
  384. tool_call_id="make-subtitle",
  385. tool_name="make_subtitle",
  386. arguments={"audio": narration_uri},
  387. success=True,
  388. output_refs=[subtitle_uri],
  389. ),
  390. ToolCallRecord(
  391. tool_call_id="make-final",
  392. tool_name="make_final",
  393. arguments={
  394. "video": shot_uri,
  395. "audio": narration_uri,
  396. "subtitle": subtitle_uri,
  397. },
  398. success=True,
  399. output_refs=[primary_uri],
  400. ),
  401. ]
  402. normalized = normalize_candidate_lineage(
  403. candidate,
  404. package=package,
  405. records=records,
  406. dependency_artifacts=tuple(
  407. artifact
  408. for dependency in verified
  409. for artifact in dependency.delivery.artifacts
  410. ),
  411. )
  412. shot_claim = next(
  413. item
  414. for item in normalized.lineage_claims
  415. if item.artifact_uri == shot_uri
  416. )
  417. self.assertIn(upstream.uri, shot_claim.input_artifact_uris)
  418. if __name__ == "__main__":
  419. unittest.main()