| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046 |
- from __future__ import annotations
- import json
- import tempfile
- import unittest
- from pathlib import Path
- from unittest.mock import patch
- from langchain_core.messages import AIMessage
- from langgraph.graph.state import CompiledStateGraph
- from production_build_agents.contracts.models import (
- ArtifactRejection,
- ArtifactExpectation,
- GlobalDataPlan,
- GlobalDataRequirement,
- )
- from production_build_agents.agents.executor.agent import create_executor_agent
- from production_build_agents.global_data.graph import create_global_data_graph
- from production_build_agents.global_data.nodes.finish_run import (
- finalize_global_data_node,
- )
- from production_build_agents.global_data.nodes.execute_task import execute_task_node
- from production_build_agents.global_data.nodes.prepare_task import (
- prepare_next_task_node,
- )
- from production_build_agents.run.langgraph_checkpointer import create_in_memory_checkpointer
- from production_build_agents.run.operation_journal import OperationOutcomeUnknownError
- from production_build_agents.agents.planner.agent import create_planner_agent
- from production_build_agents.agents.validator.task_agent import create_validator_agent
- from tests.support.executor_fixtures import (
- build_executor_candidate,
- build_executor_model,
- )
- from tests.support.fake_models import ToolAwareFakeChatModel
- from tests.support.planner_fixtures import (
- build_global_data_plan,
- build_planned_task,
- build_planner_model,
- )
- from tests.support.validator_fixtures import (
- build_stage_read_call,
- build_validator_candidate,
- build_validator_model,
- build_stage_validator_candidate,
- )
- INPUT_PATH = (
- Path(__file__).parents[1] / "fixtures" / "minimal_input.json"
- )
- def _initial_state(
- *,
- run_id: str,
- input_path: Path,
- output_dir: Path,
- ) -> dict:
- return {
- "run_id": run_id,
- "protocol_version": "0.3",
- "input_path": str(input_path.resolve()),
- "output_dir": str(output_dir.resolve()),
- "status": "PENDING",
- "phase": "PREPROCESS",
- "replan_scope": None,
- "failure_code": None,
- "error": None,
- "global_data_plan_path": None,
- "plan_history": {},
- "task_records": {},
- "current_task_path": None,
- "current_executor_delivery_path": None,
- "current_validation_report_path": None,
- "current_stage_candidate_path": None,
- "current_stage_validation_report_path": None,
- "global_data_delivery_path": None,
- "planner_calls": 0,
- "executor_calls": 0,
- "validator_calls": 0,
- "stage_validator_calls": 0,
- "replan_count": 0,
- "event_log": [],
- }
- def _invoke(
- *,
- run_id: str,
- output_dir: Path,
- plans,
- executor_candidates,
- validator_candidates,
- validator_model=None,
- ) -> dict:
- graph = create_global_data_graph(
- checkpointer=create_in_memory_checkpointer(),
- planner_model=build_planner_model(*plans),
- executor_model=build_executor_model(*executor_candidates),
- validator_model=(
- validator_model
- or build_validator_model(*validator_candidates)
- ),
- )
- return graph.invoke(
- _initial_state(
- run_id=run_id,
- input_path=INPUT_PATH,
- output_dir=output_dir,
- ),
- config={
- "configurable": {"thread_id": run_id},
- "recursion_limit": 200,
- },
- )
- class AgentConstructionTest(unittest.TestCase):
- def test_all_three_agent_roles_are_langchain_agents(self) -> None:
- model = build_planner_model(build_global_data_plan())
- agents = [
- create_planner_agent(model),
- create_executor_agent(model, system_prompt="测试 Executor"),
- create_validator_agent(
- model,
- system_prompt="测试 Validator",
- tools=[],
- ),
- ]
- for agent in agents:
- self.assertIsInstance(agent, CompiledStateGraph)
- self.assertIn("model", agent.get_graph().nodes)
- class GlobalDataGraphTest(unittest.TestCase):
- def test_graph_has_fixed_outer_loop_and_no_production_stage(self) -> None:
- graph = create_global_data_graph(
- checkpointer=create_in_memory_checkpointer()
- )
- self.assertEqual(
- set(graph.get_graph().nodes),
- {
- "__start__",
- "preprocess",
- "plan_global_data",
- "prepare_next_task",
- "execute_task",
- "validate_task",
- "validate_global_data_stage",
- "replan_global_data",
- "finalize_global_data",
- "__end__",
- },
- )
- self.assertNotIn("production_stage", graph.get_graph().draw_mermaid())
- def test_single_task_completes_and_writes_stage_delivery(self) -> None:
- run_id = "Run-single"
- with tempfile.TemporaryDirectory() as temp_dir:
- output_dir = Path(temp_dir)
- result = _invoke(
- run_id=run_id,
- output_dir=output_dir,
- plans=[build_global_data_plan()],
- executor_candidates=[
- build_executor_candidate(run_id=run_id)
- ],
- validator_candidates=[
- build_validator_candidate(run_id=run_id)
- ],
- )
- self.assertEqual(result["status"], "COMPLETED")
- self.assertEqual(result["phase"], "FINALIZE")
- self.assertEqual(result["planner_calls"], 1)
- self.assertEqual(result["executor_calls"], 1)
- self.assertEqual(result["validator_calls"], 1)
- self.assertEqual(result["stage_validator_calls"], 1)
- self.assertEqual(result["replan_count"], 0)
- self.assertEqual(
- result["task_records"]["Task1"]["status"],
- "passed",
- )
- delivery_path = Path(result["global_data_delivery_path"])
- self.assertTrue(delivery_path.is_file())
- delivery = json.loads(delivery_path.read_text(encoding="utf-8"))
- self.assertEqual(delivery["status"], "GLOBAL_DATA_COMPLETED")
- self.assertEqual(len(delivery["tasks"]), 1)
- self.assertTrue(
- (output_dir / "validation_results" / "Task1.v1.json").is_file()
- )
- saved = json.loads(
- (output_dir / "run_summary.json").read_text(encoding="utf-8")
- )
- self.assertEqual(saved["status"], "COMPLETED")
- self.assertNotIn("production_brief", saved)
- self.assertFalse((output_dir / "run_state.json").exists())
- for field in (
- "global_data_plan",
- "global_data_mermaid_path",
- "current_task",
- ):
- self.assertNotIn(field, result)
- self.assertNotIn(field, saved)
- def test_prepare_task_rejects_corrupt_plan_reference(self) -> None:
- with tempfile.TemporaryDirectory() as temp_dir:
- output_dir = Path(temp_dir)
- plan_path = output_dir / "plans" / "global_data_dag.v1.json"
- plan_path.parent.mkdir(parents=True)
- plan_path.write_text("{}\n", encoding="utf-8")
- state = {
- **_initial_state(
- run_id="Run-corrupt-plan",
- input_path=INPUT_PATH,
- output_dir=output_dir,
- ),
- "status": "RUNNING",
- "phase": "PREPARE_TASK",
- "global_data_plan_path": str(plan_path),
- }
- result = prepare_next_task_node(state)
- self.assertEqual(result["status"], "FAILED")
- self.assertEqual(result["phase"], "PREPARE_TASK")
- self.assertEqual(result["failure_code"], "DAG_STATE_INVALID")
- self.assertTrue((output_dir / "run_summary.json").is_file())
- def test_execute_task_rejects_missing_task_reference(self) -> None:
- with tempfile.TemporaryDirectory() as temp_dir:
- output_dir = Path(temp_dir)
- state = {
- **_initial_state(
- run_id="Run-missing-task",
- input_path=INPUT_PATH,
- output_dir=output_dir,
- ),
- "status": "RUNNING",
- "phase": "EXECUTE_TASK",
- "current_task_path": str(output_dir / "tasks" / "missing.json"),
- "task_records": {
- "Task1": {
- "task_id": "Task1",
- "status": "ready",
- "active_plan_version": 1,
- }
- },
- }
- result = execute_task_node(state)
- self.assertEqual(result["status"], "FAILED")
- self.assertEqual(result["phase"], "EXECUTE_TASK")
- self.assertEqual(
- result["failure_code"],
- "EXECUTOR_OUTPUT_INVALID",
- )
- def test_minor_stage_gap_is_preserved_as_unresolved(self) -> None:
- run_id = "Run-stage-minor-gap"
- stage_candidate = build_stage_validator_candidate(
- run_id=run_id,
- missing_minor=True,
- )
- validator_model = build_validator_model(
- build_validator_candidate(run_id=run_id),
- stage_candidate=stage_candidate,
- )
- with tempfile.TemporaryDirectory() as temp_dir:
- result = _invoke(
- run_id=run_id,
- output_dir=Path(temp_dir),
- plans=[build_global_data_plan()],
- executor_candidates=[
- build_executor_candidate(run_id=run_id)
- ],
- validator_candidates=[],
- validator_model=validator_model,
- )
- delivery = json.loads(
- Path(result["global_data_delivery_path"]).read_text(
- encoding="utf-8"
- )
- )
- self.assertEqual(result["status"], "COMPLETED")
- self.assertTrue(
- any("缺少非关键参考说明" in item for item in delivery["unresolved"])
- )
- def test_stage_missing_requirement_replans_without_rerunning_passed_task(
- self,
- ) -> None:
- run_id = "Run-stage-missing-replan"
- task1 = build_planned_task(task_id="Task1")
- plan1 = build_global_data_plan(tasks=[task1])
- requirement2 = GlobalDataRequirement(
- requirement_id="Requirement2",
- description="补齐阶段验收发现的共享说明",
- importance="critical",
- source_paths=["$.核心制作点[0]"],
- artifact_expectations=[
- ArtifactExpectation(
- expectation_id="Requirement2-Expectation1",
- artifact_type="structured_data",
- minimum_count=1,
- usage_scope="正式生产",
- verification_capabilities=["document_content"],
- )
- ],
- )
- task2 = build_planned_task(
- task_id="Task2",
- objective="补齐阶段遗漏的共享说明",
- expectation_ids=["Requirement2-Expectation1"],
- )
- plan2 = GlobalDataPlan(
- plan_id=plan1.plan_id,
- plan_version=2,
- goal=plan1.goal,
- stage_requirements=[
- *plan1.stage_requirements,
- requirement2,
- ],
- tasks=[task1, task2],
- revision_summary="保留已验收资料,并补齐阶段缺口。",
- )
- stage_fail = build_stage_validator_candidate(
- run_id=run_id,
- plan_version=1,
- missing_critical=True,
- )
- stage_pass = build_stage_validator_candidate(
- run_id=run_id,
- plan_version=2,
- requirement_ids=("Requirement1", "Requirement2"),
- )
- validator_model = ToolAwareFakeChatModel(
- responses=[
- AIMessage(
- content=build_validator_candidate(
- run_id=run_id,
- task_id="Task1",
- plan_version=1,
- ).model_dump_json()
- ),
- build_stage_read_call("stage-read-v1"),
- AIMessage(content=stage_fail.model_dump_json()),
- AIMessage(
- content=build_validator_candidate(
- run_id=run_id,
- task_id="Task2",
- plan_version=2,
- expectation_id="Requirement2-Expectation1",
- ).model_dump_json()
- ),
- build_stage_read_call("stage-read-v2"),
- AIMessage(content=stage_pass.model_dump_json()),
- ]
- )
- with tempfile.TemporaryDirectory() as temp_dir:
- result = _invoke(
- run_id=run_id,
- output_dir=Path(temp_dir),
- plans=[plan1, plan2],
- executor_candidates=[
- build_executor_candidate(
- run_id=run_id,
- task_id="Task1",
- plan_version=1,
- ),
- build_executor_candidate(
- run_id=run_id,
- task_id="Task2",
- plan_version=2,
- expectation_id="Requirement2-Expectation1",
- ),
- ],
- validator_candidates=[],
- validator_model=validator_model,
- )
- current_plan = GlobalDataPlan.model_validate_json(
- Path(result["global_data_plan_path"]).read_text(
- encoding="utf-8"
- )
- )
- self.assertEqual(result["status"], "COMPLETED")
- self.assertEqual(result["replan_count"], 1)
- self.assertEqual(result["executor_calls"], 2)
- self.assertEqual(result["validator_calls"], 2)
- self.assertEqual(result["stage_validator_calls"], 2)
- self.assertEqual(
- result["task_records"]["Task1"]["accepted_plan_version"],
- 1,
- )
- self.assertEqual(
- len(current_plan.stage_requirements),
- 2,
- )
- def test_stage_rejected_artifact_is_superseded_by_replan(self) -> None:
- run_id = "Run-stage-artifact-replacement"
- task1 = build_planned_task(task_id="Task1")
- plan1 = build_global_data_plan(tasks=[task1])
- task2 = build_planned_task(
- task_id="Task2",
- objective="替换阶段验收拒绝的全局资料",
- ).model_copy(
- update={"replaces_artifact_ids": ["Task1-v1-artifact-1"]}
- )
- plan2 = GlobalDataPlan(
- plan_id=plan1.plan_id,
- plan_version=2,
- goal=plan1.goal,
- stage_requirements=plan1.stage_requirements,
- tasks=[task1, task2],
- revision_summary="保留旧产物审计记录并交付替代版本。",
- )
- validator_model = ToolAwareFakeChatModel(
- responses=[
- AIMessage(
- content=build_validator_candidate(
- run_id=run_id,
- ).model_dump_json()
- ),
- build_stage_read_call("stage-replacement-read-v1"),
- AIMessage(
- content=build_stage_validator_candidate(
- run_id=run_id,
- artifact_rejections=[
- ArtifactRejection(
- artifact_id="Task1-v1-artifact-1",
- reason_code="semantic_rejection",
- reason="阶段语义验收拒绝该产物",
- )
- ],
- ).model_dump_json()
- ),
- AIMessage(
- content=build_validator_candidate(
- run_id=run_id,
- task_id="Task2",
- plan_version=2,
- ).model_dump_json()
- ),
- build_stage_read_call("stage-replacement-read-v2"),
- AIMessage(
- content=build_stage_validator_candidate(
- run_id=run_id,
- plan_version=2,
- ).model_dump_json()
- ),
- ]
- )
- with tempfile.TemporaryDirectory() as temp_dir:
- result = _invoke(
- run_id=run_id,
- output_dir=Path(temp_dir),
- plans=[plan1, plan2],
- executor_candidates=[
- build_executor_candidate(run_id=run_id),
- build_executor_candidate(
- run_id=run_id,
- task_id="Task2",
- plan_version=2,
- ),
- ],
- validator_candidates=[],
- validator_model=validator_model,
- )
- delivery = json.loads(
- Path(result["global_data_delivery_path"]).read_text(
- encoding="utf-8"
- )
- )
- self.assertEqual(result["status"], "COMPLETED")
- self.assertEqual(
- [item["artifact_id"] for item in delivery["active_artifacts"]],
- ["Task2-v2-artifact-1"],
- )
- self.assertEqual(
- [
- item["artifact_id"]
- for item in delivery["superseded_artifacts"]
- ],
- ["Task1-v1-artifact-1"],
- )
- def test_finalizer_rejects_non_passed_task(self) -> None:
- run_id = "Run-finalizer-guard"
- with tempfile.TemporaryDirectory() as temp_dir:
- output_dir = Path(temp_dir)
- result = _invoke(
- run_id=run_id,
- output_dir=output_dir,
- plans=[build_global_data_plan()],
- executor_candidates=[
- build_executor_candidate(run_id=run_id)
- ],
- validator_candidates=[
- build_validator_candidate(run_id=run_id)
- ],
- )
- (output_dir / "run_summary.json").unlink()
- result["task_records"]["Task1"]["status"] = "delivered"
- guarded = finalize_global_data_node(result)
- self.assertEqual(guarded["status"], "FAILED")
- self.assertEqual(
- guarded["failure_code"],
- "FINALIZATION_INVALID",
- )
- def test_finalizer_rejects_missing_plan_history_file(self) -> None:
- run_id = "Run-finalizer-plan-history"
- with tempfile.TemporaryDirectory() as temp_dir:
- output_dir = Path(temp_dir)
- result = _invoke(
- run_id=run_id,
- output_dir=output_dir,
- plans=[build_global_data_plan()],
- executor_candidates=[
- build_executor_candidate(run_id=run_id)
- ],
- validator_candidates=[
- build_validator_candidate(run_id=run_id)
- ],
- )
- (output_dir / "run_summary.json").unlink()
- Path(
- result["plan_history"]["1"]["json_uri"]
- ).unlink()
- guarded = finalize_global_data_node(result)
- self.assertEqual(guarded["status"], "FAILED")
- self.assertEqual(
- guarded["failure_code"],
- "FINALIZATION_INVALID",
- )
- def test_finalizer_recomputes_saved_validation_report(self) -> None:
- run_id = "Run-finalizer-report-check"
- with tempfile.TemporaryDirectory() as temp_dir:
- output_dir = Path(temp_dir)
- result = _invoke(
- run_id=run_id,
- output_dir=output_dir,
- plans=[build_global_data_plan()],
- executor_candidates=[
- build_executor_candidate(run_id=run_id)
- ],
- validator_candidates=[
- build_validator_candidate(run_id=run_id)
- ],
- )
- (output_dir / "run_summary.json").unlink()
- Path(result["global_data_delivery_path"]).unlink()
- report_path = Path(
- result["task_records"]["Task1"][
- "validation_report_path"
- ]
- )
- report = json.loads(report_path.read_text(encoding="utf-8"))
- report["criterion_results"][0]["verdict"] = "FAIL"
- report_path.write_text(
- json.dumps(report, ensure_ascii=False),
- encoding="utf-8",
- )
- guarded = finalize_global_data_node(result)
- self.assertEqual(guarded["status"], "FAILED")
- self.assertEqual(
- guarded["failure_code"],
- "FINALIZATION_INVALID",
- )
- def test_task_package_conflict_becomes_stable_terminal_failure(
- self,
- ) -> None:
- run_id = "Run-task-package-conflict"
- with tempfile.TemporaryDirectory() as temp_dir:
- output_dir = Path(temp_dir)
- conflict = output_dir / "tasks" / "Task1.v1.json"
- conflict.parent.mkdir(parents=True)
- conflict.write_text("{}\n", encoding="utf-8")
- result = _invoke(
- run_id=run_id,
- output_dir=output_dir,
- plans=[build_global_data_plan()],
- executor_candidates=[
- build_executor_candidate(run_id=run_id)
- ],
- validator_candidates=[
- build_validator_candidate(run_id=run_id)
- ],
- )
- self.assertEqual(result["status"], "FAILED")
- self.assertEqual(result["failure_code"], "VERSION_CONFLICT")
- def test_unknown_tool_outcome_terminates_without_validator(self) -> None:
- run_id = "Run-tool-unknown"
- with tempfile.TemporaryDirectory() as temp_dir:
- output_dir = Path(temp_dir)
- graph = create_global_data_graph(
- checkpointer=create_in_memory_checkpointer(),
- planner_model=build_planner_model(
- build_global_data_plan()
- ),
- )
- with patch(
- "production_build_agents.global_data.nodes.execute_task."
- "run_executor_agent",
- side_effect=OperationOutcomeUnknownError(
- "executor:operation-1"
- ),
- ):
- result = graph.invoke(
- _initial_state(
- run_id=run_id,
- input_path=INPUT_PATH,
- output_dir=output_dir,
- ),
- config={"configurable": {"thread_id": run_id}},
- )
- self.assertEqual(result["status"], "FAILED")
- self.assertEqual(result["phase"], "EXECUTE_TASK")
- self.assertIsNone(result["replan_scope"])
- self.assertEqual(
- result["failure_code"],
- "TOOL_OUTCOME_UNKNOWN",
- )
- self.assertEqual(result["validator_calls"], 0)
- def test_unknown_validator_tool_outcome_is_stable_terminal(self) -> None:
- run_id = "Run-validator-tool-unknown"
- with tempfile.TemporaryDirectory() as temp_dir:
- output_dir = Path(temp_dir)
- graph = create_global_data_graph(
- checkpointer=create_in_memory_checkpointer(),
- planner_model=build_planner_model(
- build_global_data_plan()
- ),
- executor_model=build_executor_model(
- build_executor_candidate(run_id=run_id)
- ),
- )
- with patch(
- "production_build_agents.global_data.nodes.validate_task."
- "run_validator_agent",
- side_effect=OperationOutcomeUnknownError(
- "validator:operation-1"
- ),
- ):
- result = graph.invoke(
- _initial_state(
- run_id=run_id,
- input_path=INPUT_PATH,
- output_dir=output_dir,
- ),
- config={"configurable": {"thread_id": run_id}},
- )
- self.assertEqual(result["status"], "FAILED")
- self.assertEqual(result["phase"], "VALIDATE_TASK")
- self.assertIsNone(result["replan_scope"])
- self.assertEqual(
- result["failure_code"],
- "TOOL_OUTCOME_UNKNOWN",
- )
- self.assertEqual(
- result["task_records"]["Task1"]["status"],
- "delivered",
- )
- def test_multi_task_dag_unlocks_only_after_pass(self) -> None:
- run_id = "Run-multi"
- tasks = [
- build_planned_task(task_id="Task1", priority=20),
- build_planned_task(
- task_id="Task2",
- objective="基于前置资料整理补充约束",
- depends_on=["Task1"],
- priority=10,
- ),
- ]
- with tempfile.TemporaryDirectory() as temp_dir:
- result = _invoke(
- run_id=run_id,
- output_dir=Path(temp_dir),
- plans=[build_global_data_plan(tasks=tasks)],
- executor_candidates=[
- build_executor_candidate(
- run_id=run_id,
- task_id="Task1",
- ),
- build_executor_candidate(
- run_id=run_id,
- task_id="Task2",
- ),
- ],
- validator_candidates=[
- build_validator_candidate(
- run_id=run_id,
- task_id="Task1",
- ),
- build_validator_candidate(
- run_id=run_id,
- task_id="Task2",
- ),
- ],
- )
- self.assertEqual(result["status"], "COMPLETED")
- self.assertEqual(result["executor_calls"], 2)
- self.assertEqual(result["validator_calls"], 2)
- self.assertTrue(
- all(
- record["status"] == "passed"
- for record in result["task_records"].values()
- )
- )
- task2_package = json.loads(
- (
- Path(temp_dir) / "tasks" / "Task2.v1.json"
- ).read_text(encoding="utf-8")
- )
- self.assertEqual(
- task2_package["dependency_deliveries"][0]["task_id"],
- "Task1",
- )
- self.assertTrue(
- task2_package["dependency_deliveries"][0][
- "delivery_uri"
- ].endswith("Task1.v1.json")
- )
- def test_failed_validation_replans_once_then_passes(self) -> None:
- run_id = "Run-replan-once"
- v1 = build_global_data_plan(plan_version=1)
- v2 = build_global_data_plan(
- tasks=[
- build_planned_task(
- objective="按失败证据修正全局约束"
- )
- ],
- plan_version=2,
- )
- with tempfile.TemporaryDirectory() as temp_dir:
- result = _invoke(
- run_id=run_id,
- output_dir=Path(temp_dir),
- plans=[v1, v2],
- executor_candidates=[
- build_executor_candidate(
- run_id=run_id,
- plan_version=1,
- ),
- build_executor_candidate(
- run_id=run_id,
- plan_version=2,
- ),
- ],
- validator_candidates=[
- build_validator_candidate(
- run_id=run_id,
- plan_version=1,
- verdict="FAIL",
- ),
- build_validator_candidate(
- run_id=run_id,
- plan_version=2,
- ),
- ],
- )
- self.assertEqual(result["status"], "COMPLETED")
- self.assertEqual(result["replan_count"], 1)
- self.assertEqual(result["planner_calls"], 2)
- self.assertEqual(result["executor_calls"], 2)
- self.assertEqual(set(result["plan_history"]), {"1", "2"})
- self.assertEqual(
- result["task_records"]["Task1"]["accepted_plan_version"],
- 2,
- )
- def test_two_replans_preserve_passed_task_and_change_remaining_dag(
- self,
- ) -> None:
- run_id = "Run-replan-twice"
- task1_v2 = build_planned_task(
- task_id="Task1",
- objective="修正后的第一项资料",
- )
- task2_v2 = build_planned_task(
- task_id="Task2",
- objective="使用第一项资料完成第二项",
- depends_on=["Task1"],
- )
- v1 = build_global_data_plan(plan_version=1)
- v2 = build_global_data_plan(
- tasks=[task1_v2, task2_v2],
- plan_version=2,
- )
- v3 = build_global_data_plan(
- tasks=[
- task1_v2,
- task2_v2.model_copy(
- update={"objective": "按第二次失败证据修正第二项"}
- ),
- build_planned_task(
- task_id="Task3",
- objective="汇总修正后的两项资料",
- depends_on=["Task1", "Task2"],
- ),
- ],
- plan_version=3,
- )
- with tempfile.TemporaryDirectory() as temp_dir:
- result = _invoke(
- run_id=run_id,
- output_dir=Path(temp_dir),
- plans=[v1, v2, v3],
- executor_candidates=[
- build_executor_candidate(
- run_id=run_id,
- task_id="Task1",
- plan_version=1,
- ),
- build_executor_candidate(
- run_id=run_id,
- task_id="Task1",
- plan_version=2,
- ),
- build_executor_candidate(
- run_id=run_id,
- task_id="Task2",
- plan_version=2,
- ),
- build_executor_candidate(
- run_id=run_id,
- task_id="Task2",
- plan_version=3,
- ),
- build_executor_candidate(
- run_id=run_id,
- task_id="Task3",
- plan_version=3,
- ),
- ],
- validator_candidates=[
- build_validator_candidate(
- run_id=run_id,
- task_id="Task1",
- plan_version=1,
- verdict="FAIL",
- ),
- build_validator_candidate(
- run_id=run_id,
- task_id="Task1",
- plan_version=2,
- ),
- build_validator_candidate(
- run_id=run_id,
- task_id="Task2",
- plan_version=2,
- verdict="FAIL",
- ),
- build_validator_candidate(
- run_id=run_id,
- task_id="Task2",
- plan_version=3,
- ),
- build_validator_candidate(
- run_id=run_id,
- task_id="Task3",
- plan_version=3,
- ),
- ],
- )
- self.assertEqual(result["status"], "COMPLETED")
- self.assertEqual(result["replan_count"], 2)
- self.assertEqual(result["executor_calls"], 5)
- self.assertEqual(
- result["task_records"]["Task1"]["accepted_plan_version"],
- 2,
- )
- self.assertFalse(
- (Path(temp_dir) / "tasks" / "Task1.v3.json").exists()
- )
- self.assertEqual(
- result["task_records"]["Task3"]["accepted_plan_version"],
- 3,
- )
- def test_sixth_failure_exhausts_replan_budget_without_v7(self) -> None:
- run_id = "Run-replan-budget"
- plans = [
- build_global_data_plan(
- tasks=[
- build_planned_task(
- objective=f"第 {version} 版待验收资料"
- )
- ],
- plan_version=version,
- )
- for version in range(1, 7)
- ]
- with tempfile.TemporaryDirectory() as temp_dir:
- result = _invoke(
- run_id=run_id,
- output_dir=Path(temp_dir),
- plans=plans,
- executor_candidates=[
- build_executor_candidate(
- run_id=run_id,
- plan_version=version,
- )
- for version in range(1, 7)
- ],
- validator_candidates=[
- build_validator_candidate(
- run_id=run_id,
- plan_version=version,
- verdict="FAIL",
- )
- for version in range(1, 7)
- ],
- )
- self.assertEqual(result["status"], "FAILED")
- self.assertEqual(result["phase"], "REPLAN")
- self.assertEqual(result["replan_scope"], "task")
- self.assertEqual(
- result["failure_code"],
- "REPLAN_BUDGET_EXHAUSTED",
- )
- self.assertEqual(result["replan_count"], 5)
- self.assertEqual(result["planner_calls"], 6)
- self.assertEqual(result["executor_calls"], 6)
- self.assertFalse(
- (Path(temp_dir) / "plans" / "global_data_dag.v7.json").exists()
- )
- def test_planner_failure_is_persisted(self) -> None:
- invalid_model = ToolAwareFakeChatModel(
- responses=[AIMessage(content="{}"), AIMessage(content="{}")]
- )
- with tempfile.TemporaryDirectory() as temp_dir:
- output_dir = Path(temp_dir)
- graph = create_global_data_graph(
- checkpointer=create_in_memory_checkpointer(),
- planner_model=invalid_model,
- )
- config = {"configurable": {"thread_id": "planner-failed"}}
- result = graph.invoke(
- _initial_state(
- run_id="Run-planner-failed",
- input_path=INPUT_PATH,
- output_dir=output_dir,
- ),
- config=config,
- )
- saved = dict(graph.get_state(config).values)
- summary = json.loads(
- (output_dir / "run_summary.json").read_text(encoding="utf-8")
- )
- self.assertEqual(result["status"], "FAILED")
- self.assertEqual(result["phase"], "PLAN")
- self.assertEqual(saved["status"], "FAILED")
- self.assertEqual(summary["status"], "FAILED")
- self.assertEqual(result["planner_calls"], 1)
- def test_executor_failure_is_persisted(self) -> None:
- run_id = "Run-executor-failed"
- invalid = build_executor_candidate(run_id=run_id).model_copy(
- update={"payload": {}},
- )
- executor_model = ToolAwareFakeChatModel(
- responses=[
- AIMessage(content=invalid.model_dump_json()),
- AIMessage(content=invalid.model_dump_json()),
- ]
- )
- with tempfile.TemporaryDirectory() as temp_dir:
- output_dir = Path(temp_dir)
- graph = create_global_data_graph(
- checkpointer=create_in_memory_checkpointer(),
- planner_model=build_planner_model(build_global_data_plan()),
- executor_model=executor_model,
- )
- result = graph.invoke(
- _initial_state(
- run_id=run_id,
- input_path=INPUT_PATH,
- output_dir=output_dir,
- ),
- config={"configurable": {"thread_id": run_id}},
- )
- self.assertEqual(result["status"], "FAILED")
- self.assertEqual(result["phase"], "EXECUTE_TASK")
- self.assertEqual(
- result["task_records"]["Task1"]["status"],
- "failed",
- )
- self.assertEqual(result["executor_calls"], 1)
- def test_preprocess_error_ends_before_agents(self) -> None:
- invalid_payload = {
- "帖子类型": "video",
- "制作表": {
- "段落结构": [],
- "形式结果": [],
- "关系结果": [],
- },
- }
- with tempfile.TemporaryDirectory() as temp_dir:
- root = Path(temp_dir)
- input_path = root / "invalid.json"
- output_dir = root / "output"
- input_path.write_text(
- json.dumps(invalid_payload, ensure_ascii=False),
- encoding="utf-8",
- )
- graph = create_global_data_graph(
- checkpointer=create_in_memory_checkpointer()
- )
- result = graph.invoke(
- _initial_state(
- run_id="Run-preprocess-failed",
- input_path=input_path,
- output_dir=output_dir,
- ),
- config={"configurable": {"thread_id": "preprocess-failed"}},
- )
- self.assertEqual(result["status"], "FAILED")
- self.assertEqual(result["phase"], "PREPROCESS")
- self.assertEqual(result["planner_calls"], 0)
- self.assertEqual(result["executor_calls"], 0)
- self.assertFalse((output_dir / "plans").exists())
- if __name__ == "__main__":
- unittest.main()
|