test_evaluation.py 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317
  1. from __future__ import annotations
  2. import unittest
  3. from pydantic import ValidationError
  4. from production_build_agents.capabilities import CAPABILITIES
  5. from production_build_agents.contracts.evaluation import (
  6. evaluate_plan,
  7. evaluate_stage,
  8. evaluate_task_delivery,
  9. evaluate_validation_report,
  10. planned_task_for,
  11. task_expectation_pairs,
  12. validate_replan,
  13. )
  14. from production_build_agents.contracts.identifiers import (
  15. source_asset_id_for,
  16. validator_run_id_for,
  17. )
  18. from production_build_agents.contracts.models import (
  19. Artifact,
  20. ArtifactBindingClaim,
  21. ArtifactExpectation,
  22. ArtifactExpectationBinding,
  23. ArtifactRejection,
  24. DependencyDeliveryRef,
  25. ExecutorCandidate,
  26. ExecutorDelivery,
  27. GlobalDataDeliveryTask,
  28. GlobalDataPlan,
  29. GlobalDataRequirement,
  30. GlobalDataStageCandidate,
  31. GlobalDataStageDelivery,
  32. GlobalDataStageValidationReport,
  33. GlobalDataStageValidatorCandidate,
  34. PlannedTask,
  35. ProductionBrief,
  36. ProductionTable,
  37. SourceAsset,
  38. TaskPackage,
  39. ToolArtifactMapping,
  40. ToolCallRecord,
  41. ValidationCriterionResult,
  42. ValidationReport,
  43. ValidatorCandidate,
  44. )
  45. from production_build_agents.preprocess.source_assets import build_source_assets
  46. def _brief(*uris: str) -> ProductionBrief:
  47. brief = ProductionBrief(
  48. post_type="video",
  49. core_production_points=[{"已有图片": list(uris), "用途": "人物身份参考"}],
  50. production_table=ProductionTable(
  51. segment_structure=[],
  52. form_results=[],
  53. relationship_results=[],
  54. ),
  55. )
  56. return brief.model_copy(update={"source_assets": build_source_assets(brief)})
  57. def _requirement(
  58. number: int,
  59. *,
  60. source_asset_ids: list[str],
  61. ) -> GlobalDataRequirement:
  62. return GlobalDataRequirement(
  63. requirement_id=f"Requirement{number}",
  64. description=f"采纳第 {number} 组已有来源",
  65. importance="critical",
  66. source_paths=["$.帖子类型", "$.核心制作点[0]"],
  67. artifact_expectations=[
  68. ArtifactExpectation(
  69. expectation_id=f"Requirement{number}-Expectation1",
  70. artifact_type="image",
  71. minimum_count=len(source_asset_ids),
  72. usage_scope=f"来源组 {number}",
  73. verification_capabilities=[
  74. "source_identity",
  75. "technical_integrity",
  76. ],
  77. source_asset_ids=source_asset_ids,
  78. )
  79. ],
  80. )
  81. def _generated_requirement(
  82. number: int = 1,
  83. *,
  84. artifact_types: tuple[str, ...] = ("image", "image"),
  85. capabilities: tuple[str, ...] = ("visual_content",),
  86. importance: str = "critical",
  87. ) -> GlobalDataRequirement:
  88. return GlobalDataRequirement(
  89. requirement_id=f"Requirement{number}",
  90. description="生成可同时服务多个明确用途的媒体",
  91. importance=importance,
  92. source_paths=["$.帖子类型", "$.核心制作点[0]"],
  93. artifact_expectations=[
  94. ArtifactExpectation(
  95. expectation_id=f"Requirement{number}-Expectation{index}",
  96. artifact_type=artifact_type,
  97. minimum_count=1,
  98. usage_scope=f"生成用途 {index}",
  99. verification_capabilities=list(capabilities),
  100. )
  101. for index, artifact_type in enumerate(artifact_types, start=1)
  102. ],
  103. )
  104. def _plan(
  105. requirements: list[GlobalDataRequirement],
  106. *,
  107. skill_id: str = "reference-inspection",
  108. plan_version: int = 1,
  109. tasks: list[PlannedTask] | None = None,
  110. ) -> GlobalDataPlan:
  111. expectation_ids = [
  112. expectation.expectation_id
  113. for requirement in requirements
  114. for expectation in requirement.artifact_expectations
  115. ]
  116. return GlobalDataPlan(
  117. plan_id="GlobalDataPlan",
  118. plan_version=plan_version,
  119. goal="采纳正式生产需要的已有来源",
  120. stage_requirements=requirements,
  121. tasks=tasks
  122. or [
  123. PlannedTask(
  124. task_id="Task1",
  125. objective="检查并采纳已有来源",
  126. reason="后续生产需要精确来源身份",
  127. expectation_ids=expectation_ids,
  128. skill_id=skill_id,
  129. source_paths=["$.帖子类型", "$.核心制作点[0]"],
  130. deliverable_type=(
  131. "reference_collection"
  132. if skill_id == "reference-inspection"
  133. else "image"
  134. ),
  135. )
  136. ],
  137. revision_summary="显式绑定来源、期待和交付。",
  138. )
  139. def _package(
  140. plan: GlobalDataPlan,
  141. *,
  142. task_id: str = "Task1",
  143. dependencies: list[DependencyDeliveryRef] | None = None,
  144. ) -> TaskPackage:
  145. return TaskPackage(
  146. run_id="Run-policy",
  147. plan_id=plan.plan_id,
  148. plan_version=plan.plan_version,
  149. task_id=task_id,
  150. production_brief_uri="/fixture/production_brief.json",
  151. plan_uri=f"/fixture/global_data_dag.v{plan.plan_version}.json",
  152. dependency_deliveries=dependencies or [],
  153. )
  154. def _delivery(
  155. brief: ProductionBrief,
  156. plan: GlobalDataPlan,
  157. package: TaskPackage,
  158. *,
  159. bindings: list[tuple[str, str, str | None]],
  160. artifact_type: str = "image",
  161. skill_id: str | None = None,
  162. deliverable_type: str | None = None,
  163. extra_unbound: bool = False,
  164. evidence: bool = True,
  165. ) -> ExecutorDelivery:
  166. planned = planned_task_for(package, plan)
  167. source_by_id = {item.source_asset_id: item for item in brief.source_assets}
  168. artifact_ids = list(dict.fromkeys(artifact_id for _, artifact_id, _ in bindings))
  169. source_id_by_artifact = {
  170. artifact_id: source_asset_id
  171. for _, artifact_id, source_asset_id in bindings
  172. if source_asset_id is not None
  173. }
  174. artifacts = [
  175. Artifact(
  176. artifact_id=artifact_id,
  177. artifact_type=artifact_type,
  178. uri=f"/cache/{artifact_id}.png",
  179. content_sha256="0" * 64,
  180. size_bytes=1,
  181. source_uri=(
  182. source_by_id[source_id_by_artifact[artifact_id]].source_uri
  183. if artifact_id in source_id_by_artifact
  184. else None
  185. ),
  186. description="已检查来源",
  187. )
  188. for artifact_id in artifact_ids
  189. ]
  190. if extra_unbound:
  191. artifacts.append(
  192. Artifact(
  193. artifact_id=(
  194. f"{package.task_id}-v{package.plan_version}-"
  195. f"artifact-{len(artifacts) + 1}"
  196. ),
  197. artifact_type=artifact_type,
  198. uri="/cache/unbound.png",
  199. content_sha256="0" * 64,
  200. size_bytes=1,
  201. description="辅助产物",
  202. )
  203. )
  204. tool_calls = [
  205. ToolCallRecord(
  206. tool_call_id=f"evidence-{index}",
  207. tool_name="probe_media",
  208. success=True,
  209. output_refs=[
  210. artifact.uri,
  211. *([artifact.source_uri] if artifact.source_uri is not None else []),
  212. ],
  213. artifact_mappings=[
  214. ToolArtifactMapping(
  215. source=artifact.source_uri or artifact.uri,
  216. local_path=artifact.uri,
  217. )
  218. ],
  219. )
  220. for index, artifact in enumerate(artifacts, start=1)
  221. ]
  222. evidence_id_by_artifact = {
  223. artifact.artifact_id: tool_calls[index].tool_call_id
  224. for index, artifact in enumerate(artifacts)
  225. }
  226. formal_bindings = [
  227. ArtifactExpectationBinding(
  228. expectation_id=expectation_id,
  229. artifact_id=artifact_id,
  230. source_asset_id=source_asset_id,
  231. evidence_tool_call_ids=(
  232. [evidence_id_by_artifact[artifact_id]] if evidence else []
  233. ),
  234. )
  235. for expectation_id, artifact_id, source_asset_id in bindings
  236. ]
  237. return ExecutorDelivery(
  238. run_id=package.run_id,
  239. plan_id=package.plan_id,
  240. task_id=package.task_id,
  241. plan_version=package.plan_version,
  242. executor_run_id=(
  243. f"{package.run_id}-executor-{package.task_id}-" f"v{package.plan_version}"
  244. ),
  245. skill_id=skill_id or planned.skill_id,
  246. deliverable_type=deliverable_type or planned.deliverable_type,
  247. manifest_uri="/fixture/executor_candidate.json",
  248. artifacts=artifacts,
  249. artifact_expectation_bindings=formal_bindings,
  250. tool_calls=tool_calls,
  251. attempt_count=1,
  252. summary="完成",
  253. )
  254. def _report(
  255. plan: GlobalDataPlan,
  256. package: TaskPackage,
  257. delivery: ExecutorDelivery,
  258. *,
  259. failed_targets: set[tuple[str, str]] | None = None,
  260. ) -> ValidationReport:
  261. failed = failed_targets or set()
  262. planned = planned_task_for(package, plan)
  263. pairs = task_expectation_pairs(plan, planned)
  264. results = [
  265. ValidationCriterionResult(
  266. expectation_id=expectation.expectation_id,
  267. verification_capability=capability,
  268. verdict=(
  269. "FAIL" if (expectation.expectation_id, capability) in failed else "PASS"
  270. ),
  271. evidence=["fixture"],
  272. reason="已核对",
  273. )
  274. for _, expectation in pairs
  275. for capability in expectation.verification_capabilities
  276. ]
  277. importance = {
  278. expectation.expectation_id: requirement.importance
  279. for requirement, expectation in pairs
  280. }
  281. has_critical_failure = any(
  282. result.verdict == "FAIL" and importance[result.expectation_id] == "critical"
  283. for result in results
  284. )
  285. return ValidationReport(
  286. run_id=package.run_id,
  287. plan_id=package.plan_id,
  288. plan_version=package.plan_version,
  289. task_id=package.task_id,
  290. executor_run_id=delivery.executor_run_id,
  291. validator_run_id=validator_run_id_for(delivery),
  292. criterion_results=results,
  293. verdict="FAIL" if has_critical_failure else "PASS",
  294. summary="失败" if has_critical_failure else "通过",
  295. )
  296. class ProtocolShapeTest(unittest.TestCase):
  297. def test_top_level_documents_are_0_3_and_old_task_fields_are_forbidden(
  298. self,
  299. ) -> None:
  300. plan = _plan([_generated_requirement()])
  301. package = _package(plan)
  302. top_level_models = (
  303. ProductionBrief,
  304. GlobalDataPlan,
  305. TaskPackage,
  306. ExecutorCandidate,
  307. ExecutorDelivery,
  308. ValidatorCandidate,
  309. ValidationReport,
  310. GlobalDataStageCandidate,
  311. GlobalDataStageValidatorCandidate,
  312. GlobalDataStageValidationReport,
  313. GlobalDataStageDelivery,
  314. )
  315. for model in top_level_models:
  316. with self.subTest(model=model.__name__):
  317. self.assertEqual(model.model_fields["schema_version"].default, "0.3")
  318. nested_models = (
  319. SourceAsset,
  320. ArtifactExpectation,
  321. DependencyDeliveryRef,
  322. ArtifactBindingClaim,
  323. Artifact,
  324. ArtifactExpectationBinding,
  325. ValidationCriterionResult,
  326. )
  327. for model in nested_models:
  328. with self.subTest(nested=model.__name__):
  329. self.assertNotIn("schema_version", model.model_fields)
  330. for old_field in (
  331. "expected_output",
  332. "acceptance_criteria",
  333. "validation_plan",
  334. "requirements",
  335. "source_assets",
  336. ):
  337. with self.subTest(old_task_field=old_field):
  338. payload = package.model_dump(mode="json")
  339. payload[old_field] = []
  340. with self.assertRaises(ValidationError):
  341. TaskPackage.model_validate(payload)
  342. def test_stage_delivery_task_is_a_compact_reference(self) -> None:
  343. self.assertEqual(
  344. set(GlobalDataDeliveryTask.model_fields),
  345. {
  346. "task_id",
  347. "accepted_plan_version",
  348. "executor_delivery_uri",
  349. "validation_report_uri",
  350. },
  351. )
  352. def test_validation_result_identity_is_expectation_and_capability(
  353. self,
  354. ) -> None:
  355. result = ValidationCriterionResult(
  356. expectation_id="Requirement1-Expectation1",
  357. verification_capability="visual_content",
  358. verdict="PASS",
  359. evidence=["查看了最终图片"],
  360. reason="匹配",
  361. )
  362. self.assertEqual(
  363. result.model_dump(mode="json"),
  364. {
  365. "expectation_id": "Requirement1-Expectation1",
  366. "verification_capability": "visual_content",
  367. "verdict": "PASS",
  368. "evidence": ["查看了最终图片"],
  369. "reason": "匹配",
  370. },
  371. )
  372. with self.assertRaises(ValidationError):
  373. ValidationCriterionResult(
  374. expectation_id="Requirement1-Expectation1",
  375. verification_capability="visual_content",
  376. verdict="PASS",
  377. evidence=[],
  378. reason="没有证据",
  379. )
  380. for old_field in (
  381. "requirement_id",
  382. "artifact_type",
  383. "importance",
  384. "criterion_id",
  385. "dimension",
  386. ):
  387. with self.subTest(old_criterion_field=old_field):
  388. payload = result.model_dump(mode="json")
  389. payload[old_field] = "forged"
  390. with self.assertRaises(ValidationError):
  391. ValidationCriterionResult.model_validate(payload)
  392. def test_artifact_expectation_rejects_duplicate_capabilities(self) -> None:
  393. with self.assertRaises(ValidationError):
  394. ArtifactExpectation(
  395. expectation_id="Requirement1-Expectation1",
  396. artifact_type="image",
  397. usage_scope="人物基准",
  398. verification_capabilities=["visual_content", "visual_content"],
  399. )
  400. def test_source_asset_id_helper_is_the_canonical_hash(self) -> None:
  401. brief = _brief("https://example.test/a.png")
  402. self.assertEqual(
  403. brief.source_assets[0].source_asset_id,
  404. source_asset_id_for(
  405. "image",
  406. "https://example.test/a.png",
  407. ),
  408. )
  409. def test_task_helpers_preserve_task_expectation_order(self) -> None:
  410. requirement = _generated_requirement()
  411. plan = _plan([requirement])
  412. package = _package(plan)
  413. planned = planned_task_for(package, plan)
  414. pairs = task_expectation_pairs(plan, planned)
  415. self.assertEqual(
  416. [item.expectation_id for _, item in pairs],
  417. planned.expectation_ids,
  418. )
  419. class PlanEvaluationTest(unittest.TestCase):
  420. def test_plan_rejects_source_asset_outside_requirement_scope(
  421. self,
  422. ) -> None:
  423. brief = ProductionBrief(
  424. post_type="video",
  425. core_production_points=[
  426. {"已有图片": ["https://example.test/person.png"]},
  427. {"已有图片": ["https://example.test/curtain.png"]},
  428. ],
  429. production_table=ProductionTable(
  430. segment_structure=[],
  431. form_results=[],
  432. relationship_results=[],
  433. ),
  434. )
  435. brief = brief.model_copy(
  436. update={"source_assets": build_source_assets(brief)}
  437. )
  438. assets_by_uri = {
  439. item.source_uri: item for item in brief.source_assets
  440. }
  441. person = assets_by_uri["https://example.test/person.png"]
  442. curtain = assets_by_uri["https://example.test/curtain.png"]
  443. person_requirement = GlobalDataRequirement(
  444. requirement_id="Requirement1",
  445. description="采纳出镜女生角色基准图",
  446. importance="critical",
  447. source_paths=["$.核心制作点[0]"],
  448. artifact_expectations=[
  449. ArtifactExpectation(
  450. expectation_id="Requirement1-Expectation1",
  451. artifact_type="image",
  452. minimum_count=2,
  453. usage_scope="人物身份参考",
  454. verification_capabilities=[
  455. "source_identity",
  456. "technical_integrity",
  457. ],
  458. source_asset_ids=[
  459. person.source_asset_id,
  460. curtain.source_asset_id,
  461. ],
  462. )
  463. ],
  464. )
  465. curtain_requirement = GlobalDataRequirement(
  466. requirement_id="Requirement2",
  467. description="采纳窗帘场景参考图",
  468. importance="critical",
  469. source_paths=["$.帖子类型", "$.核心制作点[1]"],
  470. artifact_expectations=[
  471. ArtifactExpectation(
  472. expectation_id="Requirement2-Expectation1",
  473. artifact_type="image",
  474. minimum_count=1,
  475. usage_scope="场景参考",
  476. verification_capabilities=[
  477. "source_identity",
  478. "technical_integrity",
  479. ],
  480. source_asset_ids=[curtain.source_asset_id],
  481. )
  482. ],
  483. )
  484. plan = _plan([person_requirement, curtain_requirement])
  485. scope_issues = [
  486. item
  487. for item in evaluate_plan(brief, plan, CAPABILITIES)
  488. if item.code == "source_asset_outside_requirement_scope"
  489. ]
  490. self.assertEqual(len(scope_issues), 1)
  491. self.assertEqual(scope_issues[0].requirement_id, "Requirement1")
  492. self.assertEqual(
  493. scope_issues[0].expectation_id,
  494. "Requirement1-Expectation1",
  495. )
  496. self.assertEqual(
  497. scope_issues[0].source_asset_id,
  498. curtain.source_asset_id,
  499. )
  500. def test_plan_accepts_source_asset_with_any_path_in_requirement_scope(
  501. self,
  502. ) -> None:
  503. brief = _brief("https://example.test/person.png")
  504. asset = brief.source_assets[0].model_copy(
  505. update={
  506. "source_paths": [
  507. "$.核心制作点[0]",
  508. "$.制作表.形式结果[0]",
  509. ]
  510. }
  511. )
  512. brief = brief.model_copy(update={"source_assets": [asset]})
  513. plan = _plan(
  514. [_requirement(1, source_asset_ids=[asset.source_asset_id])]
  515. )
  516. codes = {
  517. item.code for item in evaluate_plan(brief, plan, CAPABILITIES)
  518. }
  519. self.assertNotIn(
  520. "source_asset_outside_requirement_scope",
  521. codes,
  522. )
  523. def test_plan_rejects_undispositioned_and_forged_source_asset(self) -> None:
  524. brief = _brief("https://example.test/a.png", "https://example.test/b.png")
  525. forged = brief.source_assets[0].model_copy(
  526. update={"source_asset_id": "SourceAsset-" + "0" * 64}
  527. )
  528. brief = brief.model_copy(
  529. update={"source_assets": [forged, brief.source_assets[1]]}
  530. )
  531. plan = _plan([_requirement(1, source_asset_ids=[forged.source_asset_id])])
  532. codes = {item.code for item in evaluate_plan(brief, plan, CAPABILITIES)}
  533. self.assertIn("source_asset_id_mismatch", codes)
  534. self.assertIn("undispositioned_source_asset", codes)
  535. def test_plan_business_rules_live_in_evaluate_plan(self) -> None:
  536. brief = _brief()
  537. requirement = _generated_requirement()
  538. plan = _plan([requirement])
  539. payload = plan.model_dump(mode="json")
  540. payload["tasks"][0]["expectation_ids"] = ["Requirement9-Expectation1"]
  541. payload["tasks"][0]["depends_on"] = ["Task9"]
  542. payload["tasks"][0]["source_paths"].append("$.不存在")
  543. payload["stage_requirements"][0]["source_paths"] = ["$.不存在"]
  544. invalid = GlobalDataPlan.model_validate(payload)
  545. codes = {
  546. item.code
  547. for item in evaluate_plan(
  548. brief,
  549. invalid,
  550. CAPABILITIES,
  551. )
  552. }
  553. self.assertIn("unknown_task_expectation", codes)
  554. self.assertIn("unknown_task_dependency", codes)
  555. self.assertIn("unassigned_critical_expectation", codes)
  556. self.assertIn("unknown_requirement_source_path", codes)
  557. self.assertIn("missing_critical_audit_path", codes)
  558. self.assertIn("unknown_task_source_path", codes)
  559. def test_plan_detects_cycle_and_cross_task_replacement_collision(
  560. self,
  561. ) -> None:
  562. brief = _brief()
  563. requirement = _generated_requirement()
  564. artifact_id = "Task9-v1-artifact-1"
  565. tasks = [
  566. PlannedTask(
  567. task_id="Task1",
  568. objective="一",
  569. reason="一",
  570. depends_on=["Task2"],
  571. expectation_ids=["Requirement1-Expectation1"],
  572. replaces_artifact_ids=[artifact_id],
  573. skill_id="image-production",
  574. source_paths=["$.核心制作点[0]"],
  575. deliverable_type="image",
  576. ),
  577. PlannedTask(
  578. task_id="Task2",
  579. objective="二",
  580. reason="二",
  581. depends_on=["Task1"],
  582. expectation_ids=["Requirement1-Expectation2"],
  583. replaces_artifact_ids=[artifact_id],
  584. skill_id="image-production",
  585. source_paths=["$.核心制作点[0]"],
  586. deliverable_type="image",
  587. ),
  588. ]
  589. plan = _plan([requirement], skill_id="image-production", tasks=tasks)
  590. codes = {item.code for item in evaluate_plan(brief, plan, CAPABILITIES)}
  591. self.assertIn("task_dependency_cycle", codes)
  592. self.assertIn("duplicate_artifact_replacement", codes)
  593. self.assertIn("initial_plan_has_replacements", codes)
  594. class TaskDeliveryEvaluationTest(unittest.TestCase):
  595. def test_one_artifact_may_bind_multiple_expectations(self) -> None:
  596. brief = _brief()
  597. plan = _plan(
  598. [_generated_requirement()],
  599. skill_id="image-production",
  600. )
  601. package = _package(plan)
  602. artifact_id = "Task1-v1-artifact-1"
  603. delivery = _delivery(
  604. brief,
  605. plan,
  606. package,
  607. bindings=[
  608. (expectation_id, artifact_id, None)
  609. for expectation_id in plan.tasks[0].expectation_ids
  610. ],
  611. )
  612. self.assertEqual(
  613. evaluate_task_delivery(
  614. brief,
  615. plan,
  616. package,
  617. delivery,
  618. CAPABILITIES,
  619. ),
  620. [],
  621. )
  622. evaluation = evaluate_stage(
  623. plan,
  624. [delivery],
  625. [_report(plan, package, delivery)],
  626. )
  627. self.assertEqual(evaluation.contract_issues, [])
  628. self.assertEqual(evaluation.expectation_gaps, [])
  629. self.assertEqual(
  630. [
  631. item.artifact_ids
  632. for item in evaluation.requirement_evaluations[
  633. 0
  634. ].expectation_evaluations
  635. ],
  636. [[artifact_id], [artifact_id]],
  637. )
  638. def test_each_shared_artifact_binding_is_independently_typed(self) -> None:
  639. brief = _brief()
  640. plan = _plan(
  641. [_generated_requirement(artifact_types=("image", "video"))],
  642. skill_id="image-production",
  643. )
  644. package = _package(plan)
  645. artifact_id = "Task1-v1-artifact-1"
  646. delivery = _delivery(
  647. brief,
  648. plan,
  649. package,
  650. bindings=[
  651. (expectation_id, artifact_id, None)
  652. for expectation_id in plan.tasks[0].expectation_ids
  653. ],
  654. )
  655. issues = evaluate_task_delivery(
  656. brief,
  657. plan,
  658. package,
  659. delivery,
  660. CAPABILITIES,
  661. )
  662. self.assertEqual(
  663. [
  664. item.expectation_id
  665. for item in issues
  666. if item.code == "binding_artifact_type_mismatch"
  667. ],
  668. ["Requirement1-Expectation2"],
  669. )
  670. def test_one_artifact_cannot_cover_two_distinct_sources(self) -> None:
  671. brief = _brief("https://example.test/a.png", "https://example.test/b.png")
  672. first, second = brief.source_assets
  673. plan = _plan(
  674. [
  675. _requirement(1, source_asset_ids=[first.source_asset_id]),
  676. _requirement(2, source_asset_ids=[second.source_asset_id]),
  677. ]
  678. )
  679. package = _package(plan)
  680. delivery = _delivery(
  681. brief,
  682. plan,
  683. package,
  684. bindings=[
  685. (
  686. "Requirement1-Expectation1",
  687. "Task1-v1-artifact-1",
  688. first.source_asset_id,
  689. )
  690. ],
  691. )
  692. codes = {
  693. item.code
  694. for item in evaluate_task_delivery(
  695. brief,
  696. plan,
  697. package,
  698. delivery,
  699. CAPABILITIES,
  700. )
  701. }
  702. self.assertIn("insufficient_artifact_bindings", codes)
  703. evaluation = evaluate_stage(
  704. plan,
  705. [delivery],
  706. [_report(plan, package, delivery)],
  707. )
  708. self.assertEqual(
  709. [gap.expectation_id for gap in evaluation.expectation_gaps],
  710. ["Requirement2-Expectation1"],
  711. )
  712. def test_same_path_uris_remain_separate_source_identities(self) -> None:
  713. brief = _brief("https://example.test/a.png", "https://example.test/b.png")
  714. first, second = brief.source_assets
  715. requirement = GlobalDataRequirement(
  716. requirement_id="Requirement1",
  717. description="分别采纳同一路径里的两张图",
  718. importance="critical",
  719. source_paths=["$.核心制作点[0]"],
  720. artifact_expectations=[
  721. ArtifactExpectation(
  722. expectation_id="Requirement1-Expectation1",
  723. artifact_type="image",
  724. minimum_count=1,
  725. usage_scope="人物 A",
  726. verification_capabilities=[
  727. "source_identity",
  728. "technical_integrity",
  729. ],
  730. source_asset_ids=[first.source_asset_id],
  731. ),
  732. ArtifactExpectation(
  733. expectation_id="Requirement1-Expectation2",
  734. artifact_type="image",
  735. minimum_count=1,
  736. usage_scope="人物 B",
  737. verification_capabilities=[
  738. "source_identity",
  739. "technical_integrity",
  740. ],
  741. source_asset_ids=[second.source_asset_id],
  742. ),
  743. ],
  744. )
  745. plan = _plan([requirement])
  746. package = _package(plan)
  747. delivery = _delivery(
  748. brief,
  749. plan,
  750. package,
  751. bindings=[
  752. (
  753. "Requirement1-Expectation1",
  754. "Task1-v1-artifact-1",
  755. first.source_asset_id,
  756. ),
  757. (
  758. "Requirement1-Expectation2",
  759. "Task1-v1-artifact-2",
  760. second.source_asset_id,
  761. ),
  762. ],
  763. )
  764. self.assertEqual(
  765. evaluate_task_delivery(
  766. brief,
  767. plan,
  768. package,
  769. delivery,
  770. CAPABILITIES,
  771. ),
  772. [],
  773. )
  774. evaluation = evaluate_stage(
  775. plan,
  776. [delivery],
  777. [_report(plan, package, delivery)],
  778. )
  779. self.assertEqual(evaluation.expectation_gaps, [])
  780. self.assertEqual(
  781. [
  782. item.artifact_ids
  783. for item in evaluation.requirement_evaluations[
  784. 0
  785. ].expectation_evaluations
  786. ],
  787. [
  788. ["Task1-v1-artifact-1"],
  789. ["Task1-v1-artifact-2"],
  790. ],
  791. )
  792. def test_unbound_artifact_satisfies_nothing(self) -> None:
  793. brief = _brief()
  794. plan = _plan(
  795. [_generated_requirement(artifact_types=("image",))],
  796. skill_id="image-production",
  797. )
  798. package = _package(plan)
  799. delivery = _delivery(
  800. brief,
  801. plan,
  802. package,
  803. bindings=[],
  804. extra_unbound=True,
  805. )
  806. evaluation = evaluate_stage(
  807. plan,
  808. [delivery],
  809. [_report(plan, package, delivery)],
  810. )
  811. self.assertEqual(
  812. evaluation.requirement_evaluations[0].actual_artifact_ids,
  813. [],
  814. )
  815. self.assertEqual(len(evaluation.expectation_gaps), 1)
  816. def test_generated_skill_cannot_adopt_source_identity(self) -> None:
  817. brief = _brief("https://example.test/a.png")
  818. source = brief.source_assets[0]
  819. plan = _plan(
  820. [_requirement(1, source_asset_ids=[source.source_asset_id])],
  821. skill_id="image-production",
  822. )
  823. package = _package(plan)
  824. delivery = _delivery(
  825. brief,
  826. plan,
  827. package,
  828. bindings=[
  829. (
  830. "Requirement1-Expectation1",
  831. "Task1-v1-artifact-1",
  832. source.source_asset_id,
  833. )
  834. ],
  835. )
  836. codes = {
  837. item.code
  838. for item in evaluate_task_delivery(
  839. brief,
  840. plan,
  841. package,
  842. delivery,
  843. CAPABILITIES,
  844. )
  845. }
  846. self.assertIn("generated_artifact_cannot_adopt_source", codes)
  847. def test_evidence_must_exist_succeed_and_link_to_artifact(self) -> None:
  848. brief = _brief()
  849. plan = _plan(
  850. [_generated_requirement(artifact_types=("image",))],
  851. skill_id="image-production",
  852. )
  853. package = _package(plan)
  854. valid = _delivery(
  855. brief,
  856. plan,
  857. package,
  858. bindings=[
  859. (
  860. "Requirement1-Expectation1",
  861. "Task1-v1-artifact-1",
  862. None,
  863. )
  864. ],
  865. )
  866. binding = valid.artifact_expectation_bindings[0]
  867. missing = valid.model_copy(
  868. update={
  869. "artifact_expectation_bindings": [
  870. binding.model_copy(update={"evidence_tool_call_ids": ["missing"]})
  871. ]
  872. }
  873. )
  874. failed = valid.model_copy(
  875. update={
  876. "tool_calls": [
  877. valid.tool_calls[0].model_copy(update={"success": False})
  878. ]
  879. }
  880. )
  881. unlinked = valid.model_copy(
  882. update={
  883. "tool_calls": [
  884. valid.tool_calls[0].model_copy(
  885. update={
  886. "output_refs": ["/not/the/artifact"],
  887. "artifact_mappings": [],
  888. }
  889. )
  890. ]
  891. }
  892. )
  893. absent = valid.model_copy(
  894. update={
  895. "artifact_expectation_bindings": [
  896. binding.model_copy(update={"evidence_tool_call_ids": []})
  897. ]
  898. }
  899. )
  900. cases = {
  901. "unknown_binding_evidence": missing,
  902. "unsuccessful_binding_evidence": failed,
  903. "binding_evidence_not_linked": unlinked,
  904. "missing_binding_evidence": absent,
  905. }
  906. for expected_code, delivery in cases.items():
  907. with self.subTest(expected_code=expected_code):
  908. codes = {
  909. item.code
  910. for item in evaluate_task_delivery(
  911. brief,
  912. plan,
  913. package,
  914. delivery,
  915. CAPABILITIES,
  916. )
  917. }
  918. self.assertIn(expected_code, codes)
  919. def test_semantic_only_binding_may_omit_tool_evidence(self) -> None:
  920. brief = _brief()
  921. plan = _plan(
  922. [
  923. _generated_requirement(
  924. artifact_types=("image",),
  925. capabilities=("semantic_content",),
  926. )
  927. ],
  928. skill_id="image-production",
  929. )
  930. package = _package(plan)
  931. delivery = _delivery(
  932. brief,
  933. plan,
  934. package,
  935. bindings=[
  936. (
  937. "Requirement1-Expectation1",
  938. "Task1-v1-artifact-1",
  939. None,
  940. )
  941. ],
  942. evidence=False,
  943. )
  944. self.assertEqual(
  945. evaluate_task_delivery(
  946. brief,
  947. plan,
  948. package,
  949. delivery,
  950. CAPABILITIES,
  951. ),
  952. [],
  953. )
  954. def test_task_package_and_artifact_identity_are_checked(self) -> None:
  955. brief = _brief()
  956. plan = _plan(
  957. [_generated_requirement(artifact_types=("image",))],
  958. skill_id="image-production",
  959. )
  960. package = _package(plan)
  961. valid = _delivery(
  962. brief,
  963. plan,
  964. package,
  965. bindings=[
  966. (
  967. "Requirement1-Expectation1",
  968. "Task1-v1-artifact-1",
  969. None,
  970. )
  971. ],
  972. )
  973. wrong_package = package.model_copy(update={"plan_version": 2})
  974. wrong_artifact = valid.artifacts[0].model_copy(
  975. update={"artifact_id": "Task2-v1-artifact-1"}
  976. )
  977. wrong_binding = valid.artifact_expectation_bindings[0].model_copy(
  978. update={"artifact_id": wrong_artifact.artifact_id}
  979. )
  980. wrong_delivery = valid.model_copy(
  981. update={
  982. "artifacts": [wrong_artifact],
  983. "artifact_expectation_bindings": [wrong_binding],
  984. }
  985. )
  986. self.assertIn(
  987. "task_package_plan_mismatch",
  988. {
  989. item.code
  990. for item in evaluate_task_delivery(
  991. brief,
  992. plan,
  993. wrong_package,
  994. valid,
  995. CAPABILITIES,
  996. )
  997. },
  998. )
  999. self.assertIn(
  1000. "artifact_id_identity_mismatch",
  1001. {
  1002. item.code
  1003. for item in evaluate_task_delivery(
  1004. brief,
  1005. plan,
  1006. package,
  1007. wrong_delivery,
  1008. CAPABILITIES,
  1009. )
  1010. },
  1011. )
  1012. class StageAndReplanEvaluationTest(unittest.TestCase):
  1013. def test_stage_rejects_duplicate_artifact_ids_globally(self) -> None:
  1014. brief = _brief()
  1015. requirements = [
  1016. _generated_requirement(
  1017. 1,
  1018. artifact_types=("image",),
  1019. ),
  1020. _generated_requirement(
  1021. 2,
  1022. artifact_types=("image",),
  1023. ),
  1024. ]
  1025. tasks = [
  1026. PlannedTask(
  1027. task_id="Task1",
  1028. objective="一",
  1029. reason="一",
  1030. expectation_ids=["Requirement1-Expectation1"],
  1031. skill_id="image-production",
  1032. source_paths=["$.核心制作点[0]"],
  1033. deliverable_type="image",
  1034. ),
  1035. PlannedTask(
  1036. task_id="Task2",
  1037. objective="二",
  1038. reason="二",
  1039. expectation_ids=["Requirement2-Expectation1"],
  1040. skill_id="image-production",
  1041. source_paths=["$.核心制作点[0]"],
  1042. deliverable_type="image",
  1043. ),
  1044. ]
  1045. plan = _plan(
  1046. requirements,
  1047. skill_id="image-production",
  1048. tasks=tasks,
  1049. )
  1050. first_package = _package(plan)
  1051. second_package = _package(plan, task_id="Task2")
  1052. duplicate_id = "Task1-v1-artifact-1"
  1053. first = _delivery(
  1054. brief,
  1055. plan,
  1056. first_package,
  1057. bindings=[("Requirement1-Expectation1", duplicate_id, None)],
  1058. )
  1059. second = _delivery(
  1060. brief,
  1061. plan,
  1062. second_package,
  1063. bindings=[
  1064. (
  1065. "Requirement2-Expectation1",
  1066. "Task2-v1-artifact-1",
  1067. None,
  1068. )
  1069. ],
  1070. )
  1071. second_artifact = second.artifacts[0].model_copy(
  1072. update={"artifact_id": duplicate_id}
  1073. )
  1074. second_binding = second.artifact_expectation_bindings[0].model_copy(
  1075. update={"artifact_id": duplicate_id}
  1076. )
  1077. second = second.model_copy(
  1078. update={
  1079. "artifacts": [second_artifact],
  1080. "artifact_expectation_bindings": [second_binding],
  1081. }
  1082. )
  1083. evaluation = evaluate_stage(
  1084. plan,
  1085. [first, second],
  1086. [
  1087. _report(plan, first_package, first),
  1088. _report(plan, second_package, second),
  1089. ],
  1090. )
  1091. self.assertEqual(
  1092. [
  1093. issue.artifact_id
  1094. for issue in evaluation.contract_issues
  1095. if issue.code == "duplicate_global_artifact_id"
  1096. ],
  1097. [duplicate_id],
  1098. )
  1099. self.assertEqual(evaluation.active_artifacts, [])
  1100. self.assertTrue(
  1101. all(
  1102. item.actual_artifact_ids == []
  1103. for item in evaluation.requirement_evaluations
  1104. )
  1105. )
  1106. def test_stage_checks_validation_target_and_verdict_identity(self) -> None:
  1107. brief = _brief()
  1108. plan = _plan(
  1109. [_generated_requirement(artifact_types=("image",))],
  1110. skill_id="image-production",
  1111. )
  1112. package = _package(plan)
  1113. delivery = _delivery(
  1114. brief,
  1115. plan,
  1116. package,
  1117. bindings=[
  1118. (
  1119. "Requirement1-Expectation1",
  1120. "Task1-v1-artifact-1",
  1121. None,
  1122. )
  1123. ],
  1124. )
  1125. valid_report = _report(plan, package, delivery)
  1126. bad_report = valid_report.model_copy(update={"criterion_results": []})
  1127. evaluation = evaluate_stage(plan, [delivery], [bad_report])
  1128. self.assertIn(
  1129. "validation_targets_mismatch",
  1130. {item.code for item in evaluation.contract_issues},
  1131. )
  1132. self.assertEqual(evaluation.active_artifacts, [])
  1133. def test_validation_report_evaluation_does_not_require_fake_task_package(
  1134. self,
  1135. ) -> None:
  1136. brief = _brief()
  1137. plan = _plan(
  1138. [_generated_requirement(artifact_types=("image",))],
  1139. skill_id="image-production",
  1140. )
  1141. package = _package(plan)
  1142. delivery = _delivery(
  1143. brief,
  1144. plan,
  1145. package,
  1146. bindings=[
  1147. (
  1148. "Requirement1-Expectation1",
  1149. "Task1-v1-artifact-1",
  1150. None,
  1151. )
  1152. ],
  1153. )
  1154. report = _report(plan, package, delivery)
  1155. self.assertEqual(
  1156. evaluate_validation_report(plan, delivery, report),
  1157. [],
  1158. )
  1159. def test_replan_consumes_structured_artifact_rejection(self) -> None:
  1160. requirement = _generated_requirement(artifact_types=("image",))
  1161. previous = _plan(
  1162. [requirement],
  1163. skill_id="image-production",
  1164. )
  1165. rejected_id = "Task1-v1-artifact-1"
  1166. replacement_task = PlannedTask(
  1167. task_id="Task2",
  1168. objective="替换被拒绝产物",
  1169. reason="Stage 语义拒绝",
  1170. expectation_ids=["Requirement1-Expectation1"],
  1171. replaces_artifact_ids=[rejected_id],
  1172. skill_id="image-production",
  1173. source_paths=["$.核心制作点[0]"],
  1174. deliverable_type="image",
  1175. )
  1176. next_plan = previous.model_copy(
  1177. update={
  1178. "plan_version": 2,
  1179. "tasks": [*previous.tasks, replacement_task],
  1180. "revision_summary": "增加替换 Task",
  1181. }
  1182. )
  1183. rejection = ArtifactRejection(
  1184. artifact_id=rejected_id,
  1185. reason_code="semantic_rejection",
  1186. reason="内容不满足正式生产要求",
  1187. )
  1188. self.assertEqual(
  1189. validate_replan(
  1190. previous,
  1191. next_plan,
  1192. [],
  1193. [rejection],
  1194. [],
  1195. ),
  1196. [],
  1197. )
  1198. missing = next_plan.model_copy(
  1199. update={
  1200. "tasks": previous.tasks,
  1201. "revision_summary": "错误地忽略拒绝",
  1202. }
  1203. )
  1204. self.assertIn(
  1205. "required_replacement_missing",
  1206. {
  1207. item.code
  1208. for item in validate_replan(
  1209. previous,
  1210. missing,
  1211. [],
  1212. [rejection],
  1213. [],
  1214. )
  1215. },
  1216. )
  1217. def test_replan_cannot_change_goal_or_passed_task(self) -> None:
  1218. previous = _plan(
  1219. [_generated_requirement(artifact_types=("image",))],
  1220. skill_id="image-production",
  1221. )
  1222. changed_task = previous.tasks[0].model_copy(
  1223. update={"objective": "改写已通过工作"}
  1224. )
  1225. next_plan = previous.model_copy(
  1226. update={
  1227. "plan_version": 2,
  1228. "goal": "另一个目标",
  1229. "tasks": [changed_task],
  1230. "revision_summary": "错误改写",
  1231. }
  1232. )
  1233. codes = {
  1234. item.code
  1235. for item in validate_replan(
  1236. previous,
  1237. next_plan,
  1238. {"Task1"},
  1239. [],
  1240. [],
  1241. )
  1242. }
  1243. self.assertIn("replan_goal_changed", codes)
  1244. self.assertIn("passed_task_changed", codes)
  1245. if __name__ == "__main__":
  1246. unittest.main()