test_task_validator.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. from __future__ import annotations
  2. import json
  3. import tempfile
  4. import unittest
  5. from pathlib import Path
  6. from unittest.mock import patch
  7. from langchain_core.messages import AIMessage
  8. from production_build_agents.capabilities import CAPABILITIES
  9. from production_build_agents.agents.executor.context import load_task_contract
  10. from production_build_agents.agents.validator.skills.registry import (
  11. ValidatorSkillError,
  12. load_validator_skill,
  13. )
  14. from production_build_agents.agents.validator.task_agent import (
  15. run_validator_agent,
  16. )
  17. from production_build_agents.agents.validator.task_context import (
  18. ValidatorContextError,
  19. build_validator_user_message,
  20. )
  21. from production_build_agents.contracts.evaluation import (
  22. task_expectation_pairs,
  23. )
  24. from production_build_agents.contracts.identifiers import validator_run_id_for
  25. from production_build_agents.contracts.models import (
  26. Artifact,
  27. ArtifactExpectation,
  28. ArtifactExpectationBinding,
  29. CandidateArtifact,
  30. ExecutorCandidate,
  31. ExecutorDelivery,
  32. ToolCallRecord,
  33. ValidationCriterionResult,
  34. ValidatorCandidate,
  35. )
  36. from production_build_agents.tools.registry import ToolRegistry
  37. from production_build_agents.run.artifacts import content_identity_for_path
  38. from tests.support.fake_models import ToolAwareFakeChatModel
  39. from tests.support.planner_fixtures import (
  40. build_planned_task,
  41. build_task_package,
  42. )
  43. BRIEF_PATH = Path(__file__).parents[1] / "fixtures" / "minimal_brief.json"
  44. def _task(
  45. root: Path,
  46. *,
  47. skill_id: str = "structured-analysis",
  48. deliverable_type: str = "structured_data",
  49. importance: str = "critical",
  50. ):
  51. task = build_task_package(
  52. BRIEF_PATH,
  53. run_id="validator-test-run",
  54. planned_task=build_planned_task(
  55. skill_id=skill_id,
  56. deliverable_type=deliverable_type,
  57. ),
  58. plan_path=root / "plan.json",
  59. )
  60. if importance != "critical":
  61. _, plan, _ = load_task_contract(task)
  62. requirement = plan.stage_requirements[0].model_copy(
  63. update={"importance": importance}
  64. )
  65. Path(task.plan_uri).write_text(
  66. plan.model_copy(
  67. update={"stage_requirements": [requirement]}
  68. ).model_dump_json(indent=2),
  69. encoding="utf-8",
  70. )
  71. return task
  72. def _delivery(
  73. root: Path,
  74. task,
  75. *,
  76. artifact_uri: str | None = None,
  77. ) -> ExecutorDelivery:
  78. _, plan, planned_task = load_task_contract(task)
  79. expectation = task_expectation_pairs(plan, planned_task)[0][1]
  80. manifest = root / "executor_candidate.json"
  81. materialized_artifact = root / "delivery_artifact.json"
  82. media = expectation.artifact_type in {"image", "video", "audio"}
  83. final_uri = artifact_uri or (
  84. f"https://example.test/final.{expectation.artifact_type}"
  85. if media
  86. else str(materialized_artifact)
  87. )
  88. candidate = ExecutorCandidate(
  89. run_id=task.run_id,
  90. plan_id=task.plan_id,
  91. task_id=task.task_id,
  92. plan_version=task.plan_version,
  93. deliverable_type=planned_task.deliverable_type,
  94. payload=(
  95. {"constraints": ["统一视觉"]}
  96. if planned_task.deliverable_type == "structured_data"
  97. else {}
  98. ),
  99. artifacts=(
  100. [
  101. CandidateArtifact(
  102. artifact_type=expectation.artifact_type,
  103. uri=final_uri,
  104. description="最终测试产物",
  105. )
  106. ]
  107. if media
  108. else []
  109. ),
  110. findings=[],
  111. unresolved=[],
  112. summary="已完成当前 Task",
  113. )
  114. manifest.write_text(candidate.model_dump_json(), encoding="utf-8")
  115. if not media:
  116. materialized_artifact.write_text(
  117. json.dumps(
  118. {
  119. "deliverable_type": planned_task.deliverable_type,
  120. "payload": candidate.payload,
  121. "findings": [],
  122. "summary": candidate.summary,
  123. },
  124. ensure_ascii=False,
  125. ),
  126. encoding="utf-8",
  127. )
  128. artifact_id = (
  129. f"{task.task_id}-v{task.plan_version}-artifact-1"
  130. )
  131. evidence_required = bool(
  132. set(expectation.verification_capabilities).intersection(
  133. {
  134. "source_identity",
  135. "technical_integrity",
  136. "visual_content",
  137. "external_source",
  138. }
  139. )
  140. )
  141. tool_calls = (
  142. [
  143. ToolCallRecord(
  144. tool_call_id="evidence-call-1",
  145. tool_name="view_images",
  146. success=True,
  147. output_refs=[final_uri],
  148. )
  149. ]
  150. if evidence_required
  151. else []
  152. )
  153. return ExecutorDelivery(
  154. run_id=task.run_id,
  155. plan_id=task.plan_id,
  156. task_id=task.task_id,
  157. plan_version=task.plan_version,
  158. executor_run_id=f"executor-{task.task_id}-v{task.plan_version}-stable",
  159. skill_id=planned_task.skill_id,
  160. deliverable_type=planned_task.deliverable_type,
  161. manifest_uri=str(manifest),
  162. artifacts=[
  163. Artifact(
  164. artifact_id=artifact_id,
  165. artifact_type=expectation.artifact_type,
  166. uri=final_uri,
  167. **(
  168. {}
  169. if media
  170. else content_identity_for_path(materialized_artifact)
  171. ),
  172. description="最终测试产物",
  173. )
  174. ],
  175. artifact_expectation_bindings=[
  176. ArtifactExpectationBinding(
  177. expectation_id=expectation.expectation_id,
  178. artifact_id=artifact_id,
  179. evidence_tool_call_ids=(
  180. ["evidence-call-1"] if evidence_required else []
  181. ),
  182. )
  183. ],
  184. findings=[],
  185. unresolved=[],
  186. tool_calls=tool_calls,
  187. attempt_count=1,
  188. summary="已完成当前 Task",
  189. )
  190. def _candidate(
  191. task,
  192. delivery: ExecutorDelivery,
  193. *,
  194. verdict: str = "PASS",
  195. expectation_id: str | None = None,
  196. ) -> ValidatorCandidate:
  197. _, plan, planned_task = load_task_contract(task)
  198. results = [
  199. ValidationCriterionResult(
  200. expectation_id=expectation_id or expectation.expectation_id,
  201. verification_capability=capability,
  202. verdict=verdict,
  203. evidence=[f"{expectation.expectation_id} 的可复查证据"],
  204. reason=f"{expectation.expectation_id} 检查完成",
  205. )
  206. for _, expectation in task_expectation_pairs(plan, planned_task)
  207. for capability in expectation.verification_capabilities
  208. ]
  209. return ValidatorCandidate(
  210. run_id=task.run_id,
  211. plan_id=task.plan_id,
  212. plan_version=task.plan_version,
  213. task_id=task.task_id,
  214. executor_run_id=delivery.executor_run_id,
  215. criterion_results=results,
  216. summary="逐项检查完成",
  217. )
  218. def _model(*candidates: ValidatorCandidate) -> ToolAwareFakeChatModel:
  219. return ToolAwareFakeChatModel(
  220. responses=[
  221. AIMessage(content=candidate.model_dump_json())
  222. for candidate in candidates
  223. ]
  224. )
  225. class ValidatorSkillTest(unittest.TestCase):
  226. def test_executor_skill_and_deliverable_map_to_one_validator(self) -> None:
  227. cases = (
  228. ("structured-analysis", "structured_data", "structured-validation"),
  229. ("external-research", "research_result", "research-validation"),
  230. ("image-production", "image", "image-validation"),
  231. ("video-production", "video", "video-validation"),
  232. (
  233. "reference-inspection",
  234. "reference_collection",
  235. "reference-validation",
  236. ),
  237. )
  238. for executor_skill, deliverable, validator_skill in cases:
  239. with self.subTest(executor_skill=executor_skill):
  240. skill = load_validator_skill(executor_skill, deliverable)
  241. self.assertEqual(skill.skill_id, validator_skill)
  242. def test_invalid_skill_deliverable_pair_is_rejected(self) -> None:
  243. with self.assertRaises(ValidatorSkillError):
  244. load_validator_skill("structured-analysis", "image")
  245. with self.assertRaises(ValidatorSkillError):
  246. load_validator_skill("unknown-skill", "document")
  247. class GeneralValidatorTest(unittest.TestCase):
  248. def test_context_uses_plan_expectations_and_compact_output_contract(
  249. self,
  250. ) -> None:
  251. with tempfile.TemporaryDirectory() as temp_dir:
  252. root = Path(temp_dir)
  253. task = _task(root)
  254. delivery = _delivery(root, task)
  255. _, original_plan, _ = load_task_contract(task)
  256. requirement = original_plan.stage_requirements[0]
  257. sibling = ArtifactExpectation(
  258. expectation_id="Requirement1-Expectation2",
  259. artifact_type="document",
  260. minimum_count=1,
  261. usage_scope="兄弟 Task 的说明文档",
  262. verification_capabilities=["document_content"],
  263. )
  264. Path(task.plan_uri).write_text(
  265. original_plan.model_copy(
  266. update={
  267. "stage_requirements": [
  268. requirement.model_copy(
  269. update={
  270. "artifact_expectations": [
  271. *requirement.artifact_expectations,
  272. sibling,
  273. ]
  274. }
  275. )
  276. ]
  277. }
  278. ).model_dump_json(indent=2),
  279. encoding="utf-8",
  280. )
  281. brief, plan, planned_task = load_task_contract(task)
  282. Path(delivery.manifest_uri).unlink()
  283. message = build_validator_user_message(
  284. validator_run_id=validator_run_id_for(delivery),
  285. task=task,
  286. delivery=delivery,
  287. brief=brief,
  288. plan=plan,
  289. planned_task=planned_task,
  290. skill=load_validator_skill(
  291. delivery.skill_id,
  292. delivery.deliverable_type,
  293. ),
  294. tools=[],
  295. )
  296. payload = json.loads(message["content"][0]["text"])
  297. self.assertEqual(
  298. payload["validation_targets"][0]["expectation_id"],
  299. "Requirement1-Expectation1",
  300. )
  301. self.assertEqual(len(payload["validation_targets"]), 1)
  302. self.assertNotIn(
  303. "Requirement1-Expectation2",
  304. json.dumps(payload, ensure_ascii=False),
  305. )
  306. self.assertNotIn("executor_candidate", payload)
  307. self.assertEqual(
  308. payload["materialized_artifacts"][0]["content"]["payload"],
  309. {"constraints": ["统一视觉"]},
  310. )
  311. self.assertNotIn("requirements", payload["task_package"])
  312. self.assertNotIn("acceptance_criteria", payload["task_package"])
  313. self.assertEqual(
  314. payload["output_json_schema"],
  315. ValidatorCandidate.model_json_schema(),
  316. )
  317. def test_critical_failure_deterministically_fails_report(self) -> None:
  318. with tempfile.TemporaryDirectory() as temp_dir:
  319. root = Path(temp_dir)
  320. task = _task(root)
  321. delivery = _delivery(root, task)
  322. report = run_validator_agent(
  323. task,
  324. delivery,
  325. run_dir=root,
  326. model=_model(_candidate(task, delivery, verdict="FAIL")),
  327. tool_registry=ToolRegistry({}),
  328. )
  329. self.assertEqual(report.verdict, "FAIL")
  330. def test_minor_failure_does_not_fail_report(self) -> None:
  331. with tempfile.TemporaryDirectory() as temp_dir:
  332. root = Path(temp_dir)
  333. task = _task(root, importance="minor")
  334. delivery = _delivery(root, task)
  335. report = run_validator_agent(
  336. task,
  337. delivery,
  338. run_dir=root,
  339. model=_model(_candidate(task, delivery, verdict="FAIL")),
  340. tool_registry=ToolRegistry({}),
  341. )
  342. self.assertEqual(report.verdict, "PASS")
  343. def test_stale_delivery_is_rejected_before_model_execution(self) -> None:
  344. with tempfile.TemporaryDirectory() as temp_dir:
  345. root = Path(temp_dir)
  346. task = _task(root)
  347. delivery = _delivery(root, task).model_copy(
  348. update={"plan_version": 2}
  349. )
  350. with self.assertRaisesRegex(
  351. ValidatorContextError,
  352. "plan_version",
  353. ):
  354. run_validator_agent(
  355. task,
  356. delivery,
  357. run_dir=root,
  358. model=ToolAwareFakeChatModel(
  359. responses=[AIMessage(content="不应被调用")]
  360. ),
  361. tool_registry=ToolRegistry({}),
  362. )
  363. def test_missing_structured_artifact_is_rejected_before_model_execution(
  364. self,
  365. ) -> None:
  366. with tempfile.TemporaryDirectory() as temp_dir:
  367. root = Path(temp_dir)
  368. task = _task(root)
  369. delivery = _delivery(root, task)
  370. Path(delivery.artifacts[0].uri).unlink()
  371. model = _model(_candidate(task, delivery))
  372. with (
  373. patch.object(
  374. model,
  375. "_generate",
  376. side_effect=AssertionError("Validator 模型不应被调用"),
  377. ),
  378. self.assertRaisesRegex(
  379. ValidatorContextError,
  380. "正式 Artifact 不存在",
  381. ),
  382. ):
  383. run_validator_agent(
  384. task,
  385. delivery,
  386. run_dir=root,
  387. model=model,
  388. tool_registry=ToolRegistry({}),
  389. )
  390. def test_invalid_target_retries_inside_same_validator_run(self) -> None:
  391. with tempfile.TemporaryDirectory() as temp_dir:
  392. root = Path(temp_dir)
  393. task = _task(root)
  394. delivery = _delivery(root, task)
  395. report = run_validator_agent(
  396. task,
  397. delivery,
  398. run_dir=root,
  399. model=_model(
  400. _candidate(
  401. task,
  402. delivery,
  403. expectation_id="Requirement9-Expectation1",
  404. ),
  405. _candidate(task, delivery),
  406. ),
  407. tool_registry=ToolRegistry({}),
  408. )
  409. self.assertEqual(report.verdict, "PASS")
  410. self.assertEqual(
  411. report.validator_run_id,
  412. validator_run_id_for(delivery),
  413. )
  414. if __name__ == "__main__":
  415. unittest.main()