test_graph.py 38 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046
  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 langgraph.graph.state import CompiledStateGraph
  9. from production_build_agents.contracts.models import (
  10. ArtifactRejection,
  11. ArtifactExpectation,
  12. GlobalDataPlan,
  13. GlobalDataRequirement,
  14. )
  15. from production_build_agents.agents.executor.agent import create_executor_agent
  16. from production_build_agents.graph import create_production_graph
  17. from production_build_agents.graph_nodes.finish_run import (
  18. finalize_global_data_node,
  19. )
  20. from production_build_agents.graph_nodes.execute_task import execute_task_node
  21. from production_build_agents.graph_nodes.prepare_task import (
  22. prepare_next_task_node,
  23. )
  24. from production_build_agents.run.langgraph_checkpointer import create_in_memory_checkpointer
  25. from production_build_agents.run.operation_journal import OperationOutcomeUnknownError
  26. from production_build_agents.agents.planner.agent import create_planner_agent
  27. from production_build_agents.agents.validator.task_agent import create_validator_agent
  28. from tests.support.executor_fixtures import (
  29. build_executor_candidate,
  30. build_executor_model,
  31. )
  32. from tests.support.fake_models import ToolAwareFakeChatModel
  33. from tests.support.planner_fixtures import (
  34. build_global_data_plan,
  35. build_planned_task,
  36. build_planner_model,
  37. )
  38. from tests.support.validator_fixtures import (
  39. build_stage_read_call,
  40. build_validator_candidate,
  41. build_validator_model,
  42. build_stage_validator_candidate,
  43. )
  44. INPUT_PATH = (
  45. Path(__file__).parents[1] / "fixtures" / "minimal_input.json"
  46. )
  47. def _initial_state(
  48. *,
  49. run_id: str,
  50. input_path: Path,
  51. output_dir: Path,
  52. ) -> dict:
  53. return {
  54. "run_id": run_id,
  55. "protocol_version": "0.3",
  56. "input_path": str(input_path.resolve()),
  57. "output_dir": str(output_dir.resolve()),
  58. "status": "PENDING",
  59. "phase": "PREPROCESS",
  60. "replan_scope": None,
  61. "failure_code": None,
  62. "error": None,
  63. "global_data_plan_path": None,
  64. "plan_history": {},
  65. "task_records": {},
  66. "current_task_path": None,
  67. "current_executor_delivery_path": None,
  68. "current_validation_report_path": None,
  69. "current_stage_candidate_path": None,
  70. "current_stage_validation_report_path": None,
  71. "global_data_delivery_path": None,
  72. "planner_calls": 0,
  73. "executor_calls": 0,
  74. "validator_calls": 0,
  75. "stage_validator_calls": 0,
  76. "replan_count": 0,
  77. "event_log": [],
  78. }
  79. def _invoke(
  80. *,
  81. run_id: str,
  82. output_dir: Path,
  83. plans,
  84. executor_candidates,
  85. validator_candidates,
  86. validator_model=None,
  87. ) -> dict:
  88. graph = create_production_graph(
  89. checkpointer=create_in_memory_checkpointer(),
  90. planner_model=build_planner_model(*plans),
  91. executor_model=build_executor_model(*executor_candidates),
  92. validator_model=(
  93. validator_model
  94. or build_validator_model(*validator_candidates)
  95. ),
  96. )
  97. return graph.invoke(
  98. _initial_state(
  99. run_id=run_id,
  100. input_path=INPUT_PATH,
  101. output_dir=output_dir,
  102. ),
  103. config={
  104. "configurable": {"thread_id": run_id},
  105. "recursion_limit": 200,
  106. },
  107. )
  108. class AgentConstructionTest(unittest.TestCase):
  109. def test_all_three_agent_roles_are_langchain_agents(self) -> None:
  110. model = build_planner_model(build_global_data_plan())
  111. agents = [
  112. create_planner_agent(model),
  113. create_executor_agent(model, system_prompt="测试 Executor"),
  114. create_validator_agent(
  115. model,
  116. system_prompt="测试 Validator",
  117. tools=[],
  118. ),
  119. ]
  120. for agent in agents:
  121. self.assertIsInstance(agent, CompiledStateGraph)
  122. self.assertIn("model", agent.get_graph().nodes)
  123. class ProductionGraphTest(unittest.TestCase):
  124. def test_graph_has_fixed_outer_loop_and_no_production_stage(self) -> None:
  125. graph = create_production_graph(
  126. checkpointer=create_in_memory_checkpointer()
  127. )
  128. self.assertEqual(
  129. set(graph.get_graph().nodes),
  130. {
  131. "__start__",
  132. "preprocess",
  133. "plan_global_data",
  134. "prepare_next_task",
  135. "execute_task",
  136. "validate_task",
  137. "validate_global_data_stage",
  138. "replan_global_data",
  139. "finalize_global_data",
  140. "__end__",
  141. },
  142. )
  143. self.assertNotIn("production_stage", graph.get_graph().draw_mermaid())
  144. def test_single_task_completes_and_writes_stage_delivery(self) -> None:
  145. run_id = "Run-single"
  146. with tempfile.TemporaryDirectory() as temp_dir:
  147. output_dir = Path(temp_dir)
  148. result = _invoke(
  149. run_id=run_id,
  150. output_dir=output_dir,
  151. plans=[build_global_data_plan()],
  152. executor_candidates=[
  153. build_executor_candidate(run_id=run_id)
  154. ],
  155. validator_candidates=[
  156. build_validator_candidate(run_id=run_id)
  157. ],
  158. )
  159. self.assertEqual(result["status"], "COMPLETED")
  160. self.assertEqual(result["phase"], "FINALIZE")
  161. self.assertEqual(result["planner_calls"], 1)
  162. self.assertEqual(result["executor_calls"], 1)
  163. self.assertEqual(result["validator_calls"], 1)
  164. self.assertEqual(result["stage_validator_calls"], 1)
  165. self.assertEqual(result["replan_count"], 0)
  166. self.assertEqual(
  167. result["task_records"]["Task1"]["status"],
  168. "passed",
  169. )
  170. delivery_path = Path(result["global_data_delivery_path"])
  171. self.assertTrue(delivery_path.is_file())
  172. delivery = json.loads(delivery_path.read_text(encoding="utf-8"))
  173. self.assertEqual(delivery["status"], "GLOBAL_DATA_COMPLETED")
  174. self.assertEqual(len(delivery["tasks"]), 1)
  175. self.assertTrue(
  176. (output_dir / "validation_results" / "Task1.v1.json").is_file()
  177. )
  178. saved = json.loads(
  179. (output_dir / "run_summary.json").read_text(encoding="utf-8")
  180. )
  181. self.assertEqual(saved["status"], "COMPLETED")
  182. self.assertNotIn("production_brief", saved)
  183. self.assertFalse((output_dir / "run_state.json").exists())
  184. for field in (
  185. "global_data_plan",
  186. "global_data_mermaid_path",
  187. "current_task",
  188. ):
  189. self.assertNotIn(field, result)
  190. self.assertNotIn(field, saved)
  191. def test_prepare_task_rejects_corrupt_plan_reference(self) -> None:
  192. with tempfile.TemporaryDirectory() as temp_dir:
  193. output_dir = Path(temp_dir)
  194. plan_path = output_dir / "plans" / "global_data_dag.v1.json"
  195. plan_path.parent.mkdir(parents=True)
  196. plan_path.write_text("{}\n", encoding="utf-8")
  197. state = {
  198. **_initial_state(
  199. run_id="Run-corrupt-plan",
  200. input_path=INPUT_PATH,
  201. output_dir=output_dir,
  202. ),
  203. "status": "RUNNING",
  204. "phase": "PREPARE_TASK",
  205. "global_data_plan_path": str(plan_path),
  206. }
  207. result = prepare_next_task_node(state)
  208. self.assertEqual(result["status"], "FAILED")
  209. self.assertEqual(result["phase"], "PREPARE_TASK")
  210. self.assertEqual(result["failure_code"], "DAG_STATE_INVALID")
  211. self.assertTrue((output_dir / "run_summary.json").is_file())
  212. def test_execute_task_rejects_missing_task_reference(self) -> None:
  213. with tempfile.TemporaryDirectory() as temp_dir:
  214. output_dir = Path(temp_dir)
  215. state = {
  216. **_initial_state(
  217. run_id="Run-missing-task",
  218. input_path=INPUT_PATH,
  219. output_dir=output_dir,
  220. ),
  221. "status": "RUNNING",
  222. "phase": "EXECUTE_TASK",
  223. "current_task_path": str(output_dir / "tasks" / "missing.json"),
  224. "task_records": {
  225. "Task1": {
  226. "task_id": "Task1",
  227. "status": "ready",
  228. "active_plan_version": 1,
  229. }
  230. },
  231. }
  232. result = execute_task_node(state)
  233. self.assertEqual(result["status"], "FAILED")
  234. self.assertEqual(result["phase"], "EXECUTE_TASK")
  235. self.assertEqual(
  236. result["failure_code"],
  237. "EXECUTOR_OUTPUT_INVALID",
  238. )
  239. def test_minor_stage_gap_is_preserved_as_unresolved(self) -> None:
  240. run_id = "Run-stage-minor-gap"
  241. stage_candidate = build_stage_validator_candidate(
  242. run_id=run_id,
  243. missing_minor=True,
  244. )
  245. validator_model = build_validator_model(
  246. build_validator_candidate(run_id=run_id),
  247. stage_candidate=stage_candidate,
  248. )
  249. with tempfile.TemporaryDirectory() as temp_dir:
  250. result = _invoke(
  251. run_id=run_id,
  252. output_dir=Path(temp_dir),
  253. plans=[build_global_data_plan()],
  254. executor_candidates=[
  255. build_executor_candidate(run_id=run_id)
  256. ],
  257. validator_candidates=[],
  258. validator_model=validator_model,
  259. )
  260. delivery = json.loads(
  261. Path(result["global_data_delivery_path"]).read_text(
  262. encoding="utf-8"
  263. )
  264. )
  265. self.assertEqual(result["status"], "COMPLETED")
  266. self.assertTrue(
  267. any("缺少非关键参考说明" in item for item in delivery["unresolved"])
  268. )
  269. def test_stage_missing_requirement_replans_without_rerunning_passed_task(
  270. self,
  271. ) -> None:
  272. run_id = "Run-stage-missing-replan"
  273. task1 = build_planned_task(task_id="Task1")
  274. plan1 = build_global_data_plan(tasks=[task1])
  275. requirement2 = GlobalDataRequirement(
  276. requirement_id="Requirement2",
  277. description="补齐阶段验收发现的共享说明",
  278. importance="critical",
  279. source_paths=["$.核心制作点[0]"],
  280. artifact_expectations=[
  281. ArtifactExpectation(
  282. expectation_id="Requirement2-Expectation1",
  283. artifact_type="structured_data",
  284. minimum_count=1,
  285. usage_scope="正式生产",
  286. verification_capabilities=["document_content"],
  287. )
  288. ],
  289. )
  290. task2 = build_planned_task(
  291. task_id="Task2",
  292. objective="补齐阶段遗漏的共享说明",
  293. expectation_ids=["Requirement2-Expectation1"],
  294. )
  295. plan2 = GlobalDataPlan(
  296. plan_id=plan1.plan_id,
  297. plan_version=2,
  298. goal=plan1.goal,
  299. stage_requirements=[
  300. *plan1.stage_requirements,
  301. requirement2,
  302. ],
  303. tasks=[task1, task2],
  304. revision_summary="保留已验收资料,并补齐阶段缺口。",
  305. )
  306. stage_fail = build_stage_validator_candidate(
  307. run_id=run_id,
  308. plan_version=1,
  309. missing_critical=True,
  310. )
  311. stage_pass = build_stage_validator_candidate(
  312. run_id=run_id,
  313. plan_version=2,
  314. requirement_ids=("Requirement1", "Requirement2"),
  315. )
  316. validator_model = ToolAwareFakeChatModel(
  317. responses=[
  318. AIMessage(
  319. content=build_validator_candidate(
  320. run_id=run_id,
  321. task_id="Task1",
  322. plan_version=1,
  323. ).model_dump_json()
  324. ),
  325. build_stage_read_call("stage-read-v1"),
  326. AIMessage(content=stage_fail.model_dump_json()),
  327. AIMessage(
  328. content=build_validator_candidate(
  329. run_id=run_id,
  330. task_id="Task2",
  331. plan_version=2,
  332. expectation_id="Requirement2-Expectation1",
  333. ).model_dump_json()
  334. ),
  335. build_stage_read_call("stage-read-v2"),
  336. AIMessage(content=stage_pass.model_dump_json()),
  337. ]
  338. )
  339. with tempfile.TemporaryDirectory() as temp_dir:
  340. result = _invoke(
  341. run_id=run_id,
  342. output_dir=Path(temp_dir),
  343. plans=[plan1, plan2],
  344. executor_candidates=[
  345. build_executor_candidate(
  346. run_id=run_id,
  347. task_id="Task1",
  348. plan_version=1,
  349. ),
  350. build_executor_candidate(
  351. run_id=run_id,
  352. task_id="Task2",
  353. plan_version=2,
  354. expectation_id="Requirement2-Expectation1",
  355. ),
  356. ],
  357. validator_candidates=[],
  358. validator_model=validator_model,
  359. )
  360. current_plan = GlobalDataPlan.model_validate_json(
  361. Path(result["global_data_plan_path"]).read_text(
  362. encoding="utf-8"
  363. )
  364. )
  365. self.assertEqual(result["status"], "COMPLETED")
  366. self.assertEqual(result["replan_count"], 1)
  367. self.assertEqual(result["executor_calls"], 2)
  368. self.assertEqual(result["validator_calls"], 2)
  369. self.assertEqual(result["stage_validator_calls"], 2)
  370. self.assertEqual(
  371. result["task_records"]["Task1"]["accepted_plan_version"],
  372. 1,
  373. )
  374. self.assertEqual(
  375. len(current_plan.stage_requirements),
  376. 2,
  377. )
  378. def test_stage_rejected_artifact_is_superseded_by_replan(self) -> None:
  379. run_id = "Run-stage-artifact-replacement"
  380. task1 = build_planned_task(task_id="Task1")
  381. plan1 = build_global_data_plan(tasks=[task1])
  382. task2 = build_planned_task(
  383. task_id="Task2",
  384. objective="替换阶段验收拒绝的全局资料",
  385. ).model_copy(
  386. update={"replaces_artifact_ids": ["Task1-v1-artifact-1"]}
  387. )
  388. plan2 = GlobalDataPlan(
  389. plan_id=plan1.plan_id,
  390. plan_version=2,
  391. goal=plan1.goal,
  392. stage_requirements=plan1.stage_requirements,
  393. tasks=[task1, task2],
  394. revision_summary="保留旧产物审计记录并交付替代版本。",
  395. )
  396. validator_model = ToolAwareFakeChatModel(
  397. responses=[
  398. AIMessage(
  399. content=build_validator_candidate(
  400. run_id=run_id,
  401. ).model_dump_json()
  402. ),
  403. build_stage_read_call("stage-replacement-read-v1"),
  404. AIMessage(
  405. content=build_stage_validator_candidate(
  406. run_id=run_id,
  407. artifact_rejections=[
  408. ArtifactRejection(
  409. artifact_id="Task1-v1-artifact-1",
  410. reason_code="semantic_rejection",
  411. reason="阶段语义验收拒绝该产物",
  412. )
  413. ],
  414. ).model_dump_json()
  415. ),
  416. AIMessage(
  417. content=build_validator_candidate(
  418. run_id=run_id,
  419. task_id="Task2",
  420. plan_version=2,
  421. ).model_dump_json()
  422. ),
  423. build_stage_read_call("stage-replacement-read-v2"),
  424. AIMessage(
  425. content=build_stage_validator_candidate(
  426. run_id=run_id,
  427. plan_version=2,
  428. ).model_dump_json()
  429. ),
  430. ]
  431. )
  432. with tempfile.TemporaryDirectory() as temp_dir:
  433. result = _invoke(
  434. run_id=run_id,
  435. output_dir=Path(temp_dir),
  436. plans=[plan1, plan2],
  437. executor_candidates=[
  438. build_executor_candidate(run_id=run_id),
  439. build_executor_candidate(
  440. run_id=run_id,
  441. task_id="Task2",
  442. plan_version=2,
  443. ),
  444. ],
  445. validator_candidates=[],
  446. validator_model=validator_model,
  447. )
  448. delivery = json.loads(
  449. Path(result["global_data_delivery_path"]).read_text(
  450. encoding="utf-8"
  451. )
  452. )
  453. self.assertEqual(result["status"], "COMPLETED")
  454. self.assertEqual(
  455. [item["artifact_id"] for item in delivery["active_artifacts"]],
  456. ["Task2-v2-artifact-1"],
  457. )
  458. self.assertEqual(
  459. [
  460. item["artifact_id"]
  461. for item in delivery["superseded_artifacts"]
  462. ],
  463. ["Task1-v1-artifact-1"],
  464. )
  465. def test_finalizer_rejects_non_passed_task(self) -> None:
  466. run_id = "Run-finalizer-guard"
  467. with tempfile.TemporaryDirectory() as temp_dir:
  468. output_dir = Path(temp_dir)
  469. result = _invoke(
  470. run_id=run_id,
  471. output_dir=output_dir,
  472. plans=[build_global_data_plan()],
  473. executor_candidates=[
  474. build_executor_candidate(run_id=run_id)
  475. ],
  476. validator_candidates=[
  477. build_validator_candidate(run_id=run_id)
  478. ],
  479. )
  480. (output_dir / "run_summary.json").unlink()
  481. result["task_records"]["Task1"]["status"] = "delivered"
  482. guarded = finalize_global_data_node(result)
  483. self.assertEqual(guarded["status"], "FAILED")
  484. self.assertEqual(
  485. guarded["failure_code"],
  486. "FINALIZATION_INVALID",
  487. )
  488. def test_finalizer_rejects_missing_plan_history_file(self) -> None:
  489. run_id = "Run-finalizer-plan-history"
  490. with tempfile.TemporaryDirectory() as temp_dir:
  491. output_dir = Path(temp_dir)
  492. result = _invoke(
  493. run_id=run_id,
  494. output_dir=output_dir,
  495. plans=[build_global_data_plan()],
  496. executor_candidates=[
  497. build_executor_candidate(run_id=run_id)
  498. ],
  499. validator_candidates=[
  500. build_validator_candidate(run_id=run_id)
  501. ],
  502. )
  503. (output_dir / "run_summary.json").unlink()
  504. Path(
  505. result["plan_history"]["1"]["json_uri"]
  506. ).unlink()
  507. guarded = finalize_global_data_node(result)
  508. self.assertEqual(guarded["status"], "FAILED")
  509. self.assertEqual(
  510. guarded["failure_code"],
  511. "FINALIZATION_INVALID",
  512. )
  513. def test_finalizer_recomputes_saved_validation_report(self) -> None:
  514. run_id = "Run-finalizer-report-check"
  515. with tempfile.TemporaryDirectory() as temp_dir:
  516. output_dir = Path(temp_dir)
  517. result = _invoke(
  518. run_id=run_id,
  519. output_dir=output_dir,
  520. plans=[build_global_data_plan()],
  521. executor_candidates=[
  522. build_executor_candidate(run_id=run_id)
  523. ],
  524. validator_candidates=[
  525. build_validator_candidate(run_id=run_id)
  526. ],
  527. )
  528. (output_dir / "run_summary.json").unlink()
  529. Path(result["global_data_delivery_path"]).unlink()
  530. report_path = Path(
  531. result["task_records"]["Task1"][
  532. "validation_report_path"
  533. ]
  534. )
  535. report = json.loads(report_path.read_text(encoding="utf-8"))
  536. report["criterion_results"][0]["verdict"] = "FAIL"
  537. report_path.write_text(
  538. json.dumps(report, ensure_ascii=False),
  539. encoding="utf-8",
  540. )
  541. guarded = finalize_global_data_node(result)
  542. self.assertEqual(guarded["status"], "FAILED")
  543. self.assertEqual(
  544. guarded["failure_code"],
  545. "FINALIZATION_INVALID",
  546. )
  547. def test_task_package_conflict_becomes_stable_terminal_failure(
  548. self,
  549. ) -> None:
  550. run_id = "Run-task-package-conflict"
  551. with tempfile.TemporaryDirectory() as temp_dir:
  552. output_dir = Path(temp_dir)
  553. conflict = output_dir / "tasks" / "Task1.v1.json"
  554. conflict.parent.mkdir(parents=True)
  555. conflict.write_text("{}\n", encoding="utf-8")
  556. result = _invoke(
  557. run_id=run_id,
  558. output_dir=output_dir,
  559. plans=[build_global_data_plan()],
  560. executor_candidates=[
  561. build_executor_candidate(run_id=run_id)
  562. ],
  563. validator_candidates=[
  564. build_validator_candidate(run_id=run_id)
  565. ],
  566. )
  567. self.assertEqual(result["status"], "FAILED")
  568. self.assertEqual(result["failure_code"], "VERSION_CONFLICT")
  569. def test_unknown_tool_outcome_terminates_without_validator(self) -> None:
  570. run_id = "Run-tool-unknown"
  571. with tempfile.TemporaryDirectory() as temp_dir:
  572. output_dir = Path(temp_dir)
  573. graph = create_production_graph(
  574. checkpointer=create_in_memory_checkpointer(),
  575. planner_model=build_planner_model(
  576. build_global_data_plan()
  577. ),
  578. )
  579. with patch(
  580. "production_build_agents.graph_nodes.execute_task."
  581. "run_executor_agent",
  582. side_effect=OperationOutcomeUnknownError(
  583. "executor:operation-1"
  584. ),
  585. ):
  586. result = graph.invoke(
  587. _initial_state(
  588. run_id=run_id,
  589. input_path=INPUT_PATH,
  590. output_dir=output_dir,
  591. ),
  592. config={"configurable": {"thread_id": run_id}},
  593. )
  594. self.assertEqual(result["status"], "FAILED")
  595. self.assertEqual(result["phase"], "EXECUTE_TASK")
  596. self.assertIsNone(result["replan_scope"])
  597. self.assertEqual(
  598. result["failure_code"],
  599. "TOOL_OUTCOME_UNKNOWN",
  600. )
  601. self.assertEqual(result["validator_calls"], 0)
  602. def test_unknown_validator_tool_outcome_is_stable_terminal(self) -> None:
  603. run_id = "Run-validator-tool-unknown"
  604. with tempfile.TemporaryDirectory() as temp_dir:
  605. output_dir = Path(temp_dir)
  606. graph = create_production_graph(
  607. checkpointer=create_in_memory_checkpointer(),
  608. planner_model=build_planner_model(
  609. build_global_data_plan()
  610. ),
  611. executor_model=build_executor_model(
  612. build_executor_candidate(run_id=run_id)
  613. ),
  614. )
  615. with patch(
  616. "production_build_agents.graph_nodes.validate_task."
  617. "run_validator_agent",
  618. side_effect=OperationOutcomeUnknownError(
  619. "validator:operation-1"
  620. ),
  621. ):
  622. result = graph.invoke(
  623. _initial_state(
  624. run_id=run_id,
  625. input_path=INPUT_PATH,
  626. output_dir=output_dir,
  627. ),
  628. config={"configurable": {"thread_id": run_id}},
  629. )
  630. self.assertEqual(result["status"], "FAILED")
  631. self.assertEqual(result["phase"], "VALIDATE_TASK")
  632. self.assertIsNone(result["replan_scope"])
  633. self.assertEqual(
  634. result["failure_code"],
  635. "TOOL_OUTCOME_UNKNOWN",
  636. )
  637. self.assertEqual(
  638. result["task_records"]["Task1"]["status"],
  639. "delivered",
  640. )
  641. def test_multi_task_dag_unlocks_only_after_pass(self) -> None:
  642. run_id = "Run-multi"
  643. tasks = [
  644. build_planned_task(task_id="Task1", priority=20),
  645. build_planned_task(
  646. task_id="Task2",
  647. objective="基于前置资料整理补充约束",
  648. depends_on=["Task1"],
  649. priority=10,
  650. ),
  651. ]
  652. with tempfile.TemporaryDirectory() as temp_dir:
  653. result = _invoke(
  654. run_id=run_id,
  655. output_dir=Path(temp_dir),
  656. plans=[build_global_data_plan(tasks=tasks)],
  657. executor_candidates=[
  658. build_executor_candidate(
  659. run_id=run_id,
  660. task_id="Task1",
  661. ),
  662. build_executor_candidate(
  663. run_id=run_id,
  664. task_id="Task2",
  665. ),
  666. ],
  667. validator_candidates=[
  668. build_validator_candidate(
  669. run_id=run_id,
  670. task_id="Task1",
  671. ),
  672. build_validator_candidate(
  673. run_id=run_id,
  674. task_id="Task2",
  675. ),
  676. ],
  677. )
  678. self.assertEqual(result["status"], "COMPLETED")
  679. self.assertEqual(result["executor_calls"], 2)
  680. self.assertEqual(result["validator_calls"], 2)
  681. self.assertTrue(
  682. all(
  683. record["status"] == "passed"
  684. for record in result["task_records"].values()
  685. )
  686. )
  687. task2_package = json.loads(
  688. (
  689. Path(temp_dir) / "tasks" / "Task2.v1.json"
  690. ).read_text(encoding="utf-8")
  691. )
  692. self.assertEqual(
  693. task2_package["dependency_deliveries"][0]["task_id"],
  694. "Task1",
  695. )
  696. self.assertTrue(
  697. task2_package["dependency_deliveries"][0][
  698. "delivery_uri"
  699. ].endswith("Task1.v1.json")
  700. )
  701. def test_failed_validation_replans_once_then_passes(self) -> None:
  702. run_id = "Run-replan-once"
  703. v1 = build_global_data_plan(plan_version=1)
  704. v2 = build_global_data_plan(
  705. tasks=[
  706. build_planned_task(
  707. objective="按失败证据修正全局约束"
  708. )
  709. ],
  710. plan_version=2,
  711. )
  712. with tempfile.TemporaryDirectory() as temp_dir:
  713. result = _invoke(
  714. run_id=run_id,
  715. output_dir=Path(temp_dir),
  716. plans=[v1, v2],
  717. executor_candidates=[
  718. build_executor_candidate(
  719. run_id=run_id,
  720. plan_version=1,
  721. ),
  722. build_executor_candidate(
  723. run_id=run_id,
  724. plan_version=2,
  725. ),
  726. ],
  727. validator_candidates=[
  728. build_validator_candidate(
  729. run_id=run_id,
  730. plan_version=1,
  731. verdict="FAIL",
  732. ),
  733. build_validator_candidate(
  734. run_id=run_id,
  735. plan_version=2,
  736. ),
  737. ],
  738. )
  739. self.assertEqual(result["status"], "COMPLETED")
  740. self.assertEqual(result["replan_count"], 1)
  741. self.assertEqual(result["planner_calls"], 2)
  742. self.assertEqual(result["executor_calls"], 2)
  743. self.assertEqual(set(result["plan_history"]), {"1", "2"})
  744. self.assertEqual(
  745. result["task_records"]["Task1"]["accepted_plan_version"],
  746. 2,
  747. )
  748. def test_two_replans_preserve_passed_task_and_change_remaining_dag(
  749. self,
  750. ) -> None:
  751. run_id = "Run-replan-twice"
  752. task1_v2 = build_planned_task(
  753. task_id="Task1",
  754. objective="修正后的第一项资料",
  755. )
  756. task2_v2 = build_planned_task(
  757. task_id="Task2",
  758. objective="使用第一项资料完成第二项",
  759. depends_on=["Task1"],
  760. )
  761. v1 = build_global_data_plan(plan_version=1)
  762. v2 = build_global_data_plan(
  763. tasks=[task1_v2, task2_v2],
  764. plan_version=2,
  765. )
  766. v3 = build_global_data_plan(
  767. tasks=[
  768. task1_v2,
  769. task2_v2.model_copy(
  770. update={"objective": "按第二次失败证据修正第二项"}
  771. ),
  772. build_planned_task(
  773. task_id="Task3",
  774. objective="汇总修正后的两项资料",
  775. depends_on=["Task1", "Task2"],
  776. ),
  777. ],
  778. plan_version=3,
  779. )
  780. with tempfile.TemporaryDirectory() as temp_dir:
  781. result = _invoke(
  782. run_id=run_id,
  783. output_dir=Path(temp_dir),
  784. plans=[v1, v2, v3],
  785. executor_candidates=[
  786. build_executor_candidate(
  787. run_id=run_id,
  788. task_id="Task1",
  789. plan_version=1,
  790. ),
  791. build_executor_candidate(
  792. run_id=run_id,
  793. task_id="Task1",
  794. plan_version=2,
  795. ),
  796. build_executor_candidate(
  797. run_id=run_id,
  798. task_id="Task2",
  799. plan_version=2,
  800. ),
  801. build_executor_candidate(
  802. run_id=run_id,
  803. task_id="Task2",
  804. plan_version=3,
  805. ),
  806. build_executor_candidate(
  807. run_id=run_id,
  808. task_id="Task3",
  809. plan_version=3,
  810. ),
  811. ],
  812. validator_candidates=[
  813. build_validator_candidate(
  814. run_id=run_id,
  815. task_id="Task1",
  816. plan_version=1,
  817. verdict="FAIL",
  818. ),
  819. build_validator_candidate(
  820. run_id=run_id,
  821. task_id="Task1",
  822. plan_version=2,
  823. ),
  824. build_validator_candidate(
  825. run_id=run_id,
  826. task_id="Task2",
  827. plan_version=2,
  828. verdict="FAIL",
  829. ),
  830. build_validator_candidate(
  831. run_id=run_id,
  832. task_id="Task2",
  833. plan_version=3,
  834. ),
  835. build_validator_candidate(
  836. run_id=run_id,
  837. task_id="Task3",
  838. plan_version=3,
  839. ),
  840. ],
  841. )
  842. self.assertEqual(result["status"], "COMPLETED")
  843. self.assertEqual(result["replan_count"], 2)
  844. self.assertEqual(result["executor_calls"], 5)
  845. self.assertEqual(
  846. result["task_records"]["Task1"]["accepted_plan_version"],
  847. 2,
  848. )
  849. self.assertFalse(
  850. (Path(temp_dir) / "tasks" / "Task1.v3.json").exists()
  851. )
  852. self.assertEqual(
  853. result["task_records"]["Task3"]["accepted_plan_version"],
  854. 3,
  855. )
  856. def test_sixth_failure_exhausts_replan_budget_without_v7(self) -> None:
  857. run_id = "Run-replan-budget"
  858. plans = [
  859. build_global_data_plan(
  860. tasks=[
  861. build_planned_task(
  862. objective=f"第 {version} 版待验收资料"
  863. )
  864. ],
  865. plan_version=version,
  866. )
  867. for version in range(1, 7)
  868. ]
  869. with tempfile.TemporaryDirectory() as temp_dir:
  870. result = _invoke(
  871. run_id=run_id,
  872. output_dir=Path(temp_dir),
  873. plans=plans,
  874. executor_candidates=[
  875. build_executor_candidate(
  876. run_id=run_id,
  877. plan_version=version,
  878. )
  879. for version in range(1, 7)
  880. ],
  881. validator_candidates=[
  882. build_validator_candidate(
  883. run_id=run_id,
  884. plan_version=version,
  885. verdict="FAIL",
  886. )
  887. for version in range(1, 7)
  888. ],
  889. )
  890. self.assertEqual(result["status"], "FAILED")
  891. self.assertEqual(result["phase"], "REPLAN")
  892. self.assertEqual(result["replan_scope"], "task")
  893. self.assertEqual(
  894. result["failure_code"],
  895. "REPLAN_BUDGET_EXHAUSTED",
  896. )
  897. self.assertEqual(result["replan_count"], 5)
  898. self.assertEqual(result["planner_calls"], 6)
  899. self.assertEqual(result["executor_calls"], 6)
  900. self.assertFalse(
  901. (Path(temp_dir) / "plans" / "global_data_dag.v7.json").exists()
  902. )
  903. def test_planner_failure_is_persisted(self) -> None:
  904. invalid_model = ToolAwareFakeChatModel(
  905. responses=[AIMessage(content="{}"), AIMessage(content="{}")]
  906. )
  907. with tempfile.TemporaryDirectory() as temp_dir:
  908. output_dir = Path(temp_dir)
  909. graph = create_production_graph(
  910. checkpointer=create_in_memory_checkpointer(),
  911. planner_model=invalid_model,
  912. )
  913. config = {"configurable": {"thread_id": "planner-failed"}}
  914. result = graph.invoke(
  915. _initial_state(
  916. run_id="Run-planner-failed",
  917. input_path=INPUT_PATH,
  918. output_dir=output_dir,
  919. ),
  920. config=config,
  921. )
  922. saved = dict(graph.get_state(config).values)
  923. summary = json.loads(
  924. (output_dir / "run_summary.json").read_text(encoding="utf-8")
  925. )
  926. self.assertEqual(result["status"], "FAILED")
  927. self.assertEqual(result["phase"], "PLAN")
  928. self.assertEqual(saved["status"], "FAILED")
  929. self.assertEqual(summary["status"], "FAILED")
  930. self.assertEqual(result["planner_calls"], 1)
  931. def test_executor_failure_is_persisted(self) -> None:
  932. run_id = "Run-executor-failed"
  933. invalid = build_executor_candidate(run_id=run_id).model_copy(
  934. update={"payload": {}},
  935. )
  936. executor_model = ToolAwareFakeChatModel(
  937. responses=[
  938. AIMessage(content=invalid.model_dump_json()),
  939. AIMessage(content=invalid.model_dump_json()),
  940. ]
  941. )
  942. with tempfile.TemporaryDirectory() as temp_dir:
  943. output_dir = Path(temp_dir)
  944. graph = create_production_graph(
  945. checkpointer=create_in_memory_checkpointer(),
  946. planner_model=build_planner_model(build_global_data_plan()),
  947. executor_model=executor_model,
  948. )
  949. result = graph.invoke(
  950. _initial_state(
  951. run_id=run_id,
  952. input_path=INPUT_PATH,
  953. output_dir=output_dir,
  954. ),
  955. config={"configurable": {"thread_id": run_id}},
  956. )
  957. self.assertEqual(result["status"], "FAILED")
  958. self.assertEqual(result["phase"], "EXECUTE_TASK")
  959. self.assertEqual(
  960. result["task_records"]["Task1"]["status"],
  961. "failed",
  962. )
  963. self.assertEqual(result["executor_calls"], 1)
  964. def test_preprocess_error_ends_before_agents(self) -> None:
  965. invalid_payload = {
  966. "帖子类型": "video",
  967. "制作表": {
  968. "段落结构": [],
  969. "形式结果": [],
  970. "关系结果": [],
  971. },
  972. }
  973. with tempfile.TemporaryDirectory() as temp_dir:
  974. root = Path(temp_dir)
  975. input_path = root / "invalid.json"
  976. output_dir = root / "output"
  977. input_path.write_text(
  978. json.dumps(invalid_payload, ensure_ascii=False),
  979. encoding="utf-8",
  980. )
  981. graph = create_production_graph(
  982. checkpointer=create_in_memory_checkpointer()
  983. )
  984. result = graph.invoke(
  985. _initial_state(
  986. run_id="Run-preprocess-failed",
  987. input_path=input_path,
  988. output_dir=output_dir,
  989. ),
  990. config={"configurable": {"thread_id": "preprocess-failed"}},
  991. )
  992. self.assertEqual(result["status"], "FAILED")
  993. self.assertEqual(result["phase"], "PREPROCESS")
  994. self.assertEqual(result["planner_calls"], 0)
  995. self.assertEqual(result["executor_calls"], 0)
  996. self.assertFalse((output_dir / "plans").exists())
  997. if __name__ == "__main__":
  998. unittest.main()