test_planner.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860
  1. from __future__ import annotations
  2. import json
  3. import tempfile
  4. import unittest
  5. from pathlib import Path
  6. from langchain_core.messages import AIMessage
  7. from pydantic import ValidationError
  8. from production_build_agents.preprocess.brief_io import load_production_brief
  9. from production_build_agents.capabilities import (
  10. GLOBAL_DATA_SKILL_IDS,
  11. global_data_skill_catalog,
  12. )
  13. from production_build_agents.contracts.models import (
  14. ArtifactExpectation,
  15. ArtifactRejection,
  16. GlobalDataPlan,
  17. SourceAsset,
  18. )
  19. from production_build_agents.contracts.identifiers import source_asset_id_for
  20. from production_build_agents.agents.planner.agent import (
  21. PlanContractValidationError,
  22. PlannerOutputError,
  23. _failed_task_context,
  24. _planner_correction_content,
  25. _planner_failure_message,
  26. materialize_task_package,
  27. ready_tasks,
  28. run_planner_agent,
  29. run_replan_agent,
  30. validate_plan_runtime,
  31. validate_replan,
  32. )
  33. from tests.support.planner_fixtures import (
  34. GLOBAL_DATA_AUDIT_PATHS,
  35. build_global_data_plan,
  36. build_planned_task,
  37. build_planner_model,
  38. build_task_package,
  39. )
  40. from tests.support.fake_models import ToolAwareFakeChatModel
  41. SOURCE_BRIEF_PATH = (
  42. Path(__file__).parents[1] / "fixtures" / "minimal_brief.json"
  43. )
  44. _BRIEF_FIXTURE_DIR = tempfile.TemporaryDirectory(
  45. prefix="production-build-planner-brief-"
  46. )
  47. BRIEF_PATH = Path(_BRIEF_FIXTURE_DIR.name) / "minimal_brief.json"
  48. _brief_payload = json.loads(SOURCE_BRIEF_PATH.read_text(encoding="utf-8"))
  49. _brief_payload["schema_version"] = "0.3"
  50. BRIEF_PATH.write_text(
  51. json.dumps(_brief_payload, ensure_ascii=False, indent=2),
  52. encoding="utf-8",
  53. )
  54. PLAN_PATH = Path(_BRIEF_FIXTURE_DIR.name) / "plan.json"
  55. class PlannerDagTest(unittest.TestCase):
  56. def test_planner_core_is_generic_and_global_rules_live_in_skill(self) -> None:
  57. root = (
  58. Path(__file__).parents[2]
  59. / "production_build_agents"
  60. / "agents"
  61. / "planner"
  62. )
  63. core = (root / "prompt.md").read_text(encoding="utf-8")
  64. skill = (
  65. root / "skills" / "global-data-planning" / "SKILL.md"
  66. ).read_text(encoding="utf-8")
  67. self.assertNotIn("Global Data 完成", core)
  68. self.assertNotIn("reference-inspection", core)
  69. self.assertIn("Global Data 完成", skill)
  70. self.assertIn("reference-inspection", skill)
  71. def test_skill_catalog_contains_planner_facing_summary(self) -> None:
  72. catalog = global_data_skill_catalog()
  73. self.assertEqual(
  74. [item["skill_id"] for item in catalog],
  75. list(GLOBAL_DATA_SKILL_IDS),
  76. )
  77. self.assertTrue(all(item["summary"] for item in catalog))
  78. self.assertTrue(
  79. all("deliverable_types" in item for item in catalog)
  80. )
  81. self.assertTrue(
  82. all("allowed_tools" not in item for item in catalog)
  83. )
  84. self.assertTrue(
  85. all("allowed_remote_tool_ids" not in item for item in catalog)
  86. )
  87. def test_planner_can_create_one_free_form_task(self) -> None:
  88. expected = build_global_data_plan()
  89. plan = run_planner_agent(
  90. str(BRIEF_PATH),
  91. plan_version=1,
  92. run_id="Run-planner-one",
  93. model=build_planner_model(expected),
  94. )
  95. self.assertEqual(plan, expected)
  96. self.assertEqual(len(plan.tasks), 1)
  97. self.assertEqual(plan.tasks[0].skill_id, "structured-analysis")
  98. def test_planner_can_read_brief_on_demand_before_final_plan(self) -> None:
  99. expected = build_global_data_plan()
  100. model = ToolAwareFakeChatModel(
  101. responses=[
  102. AIMessage(
  103. content="",
  104. tool_calls=[
  105. {
  106. "name": "read_production_brief",
  107. "args": {
  108. "source_paths": GLOBAL_DATA_AUDIT_PATHS
  109. },
  110. "id": "read-1",
  111. "type": "tool_call",
  112. }
  113. ],
  114. ),
  115. AIMessage(content=expected.model_dump_json()),
  116. ]
  117. )
  118. plan = run_planner_agent(
  119. str(BRIEF_PATH),
  120. plan_version=1,
  121. run_id="Run-planner-read",
  122. model=model,
  123. )
  124. self.assertIn("$.核心制作点[0]", plan.tasks[0].source_paths)
  125. def test_planner_only_requires_shared_global_data_reads(self) -> None:
  126. plan = build_global_data_plan()
  127. model = build_planner_model(
  128. plan,
  129. read_paths=GLOBAL_DATA_AUDIT_PATHS,
  130. )
  131. actual = run_planner_agent(
  132. str(BRIEF_PATH),
  133. plan_version=1,
  134. run_id="Run-planner-shared-audit",
  135. model=model,
  136. )
  137. self.assertEqual(actual, plan)
  138. def test_multiple_tasks_and_dependencies_are_preserved(self) -> None:
  139. tasks = [
  140. build_planned_task(task_id="Task1", priority=20),
  141. build_planned_task(
  142. task_id="Task2",
  143. objective="生成视觉参考图",
  144. skill_id="image-production",
  145. deliverable_type="image",
  146. priority=10,
  147. ),
  148. build_planned_task(
  149. task_id="Task3",
  150. objective="整理前两项资料",
  151. depends_on=["Task1", "Task2"],
  152. ),
  153. ]
  154. plan = run_planner_agent(
  155. str(BRIEF_PATH),
  156. plan_version=1,
  157. run_id="Run-planner-many",
  158. model=build_planner_model(build_global_data_plan(tasks=tasks)),
  159. )
  160. self.assertEqual(ready_tasks(plan)[0].task_id, "Task2")
  161. def test_cycle_is_rejected_by_contract(self) -> None:
  162. payload = {
  163. "plan_id": "GlobalDataPlan",
  164. "plan_version": 1,
  165. "goal": "准备资料",
  166. "revision_summary": "循环图应被拒绝",
  167. "stage_requirements": [
  168. item.model_dump(mode="json")
  169. for item in build_global_data_plan().stage_requirements
  170. ],
  171. "tasks": [
  172. build_planned_task(
  173. task_id="Task1",
  174. depends_on=["Task2"],
  175. ).model_dump(mode="json"),
  176. build_planned_task(
  177. task_id="Task2",
  178. depends_on=["Task1"],
  179. ).model_dump(mode="json"),
  180. ],
  181. }
  182. cyclic = GlobalDataPlan.model_validate(payload)
  183. _, brief = load_production_brief(BRIEF_PATH)
  184. with self.assertRaisesRegex(PlannerOutputError, "循环依赖"):
  185. validate_plan_runtime(
  186. cyclic,
  187. brief=brief,
  188. plan_version=1,
  189. max_tasks=8,
  190. )
  191. def test_unknown_skill_is_rejected(self) -> None:
  192. payload = build_global_data_plan().model_dump(mode="json")
  193. payload["tasks"][0]["skill_id"] = "unknown-skill"
  194. model = ToolAwareFakeChatModel(
  195. responses=[AIMessage(content=json.dumps(payload, ensure_ascii=False))]
  196. )
  197. with self.assertRaisesRegex(PlannerOutputError, "未注册"):
  198. run_planner_agent(
  199. str(BRIEF_PATH),
  200. plan_version=1,
  201. run_id="Run-planner-unknown-skill",
  202. model=model,
  203. )
  204. def test_unknown_source_path_is_rejected(self) -> None:
  205. payload = build_global_data_plan().model_dump(mode="json")
  206. payload["tasks"][0]["source_paths"] = ["$.不存在"]
  207. payload["stage_requirements"][0]["source_paths"] = ["$.不存在"]
  208. model = ToolAwareFakeChatModel(
  209. responses=[AIMessage(content=json.dumps(payload, ensure_ascii=False))]
  210. )
  211. with self.assertRaisesRegex(PlannerOutputError, "不存在的来源路径"):
  212. run_planner_agent(
  213. str(BRIEF_PATH),
  214. plan_version=1,
  215. run_id="Run-planner-unknown-source",
  216. model=model,
  217. )
  218. def test_all_global_audit_paths_require_critical_disposition(
  219. self,
  220. ) -> None:
  221. plan = build_global_data_plan()
  222. reduced_task = plan.tasks[0].model_copy(
  223. update={"source_paths": ["$.核心制作点[0]"]}
  224. )
  225. reduced_requirement = plan.stage_requirements[0].model_copy(
  226. update={"source_paths": ["$.核心制作点[0]"]}
  227. )
  228. reduced = plan.model_copy(
  229. update={
  230. "tasks": [reduced_task],
  231. "stage_requirements": [reduced_requirement],
  232. }
  233. )
  234. _, brief = load_production_brief(BRIEF_PATH)
  235. with self.assertRaisesRegex(
  236. PlannerOutputError,
  237. "missing_critical_audit_path",
  238. ):
  239. validate_plan_runtime(
  240. reduced,
  241. brief=brief,
  242. plan_version=1,
  243. max_tasks=8,
  244. )
  245. def test_source_asset_issue_keeps_exact_identity_for_correction(
  246. self,
  247. ) -> None:
  248. _, brief = load_production_brief(BRIEF_PATH)
  249. source_uri = "https://example.test/reference/skeleton.mp4"
  250. source_asset = SourceAsset(
  251. source_asset_id=source_asset_id_for("video", source_uri),
  252. artifact_type="video",
  253. source_uri=source_uri,
  254. source_paths=["$.核心制作点[0]"],
  255. )
  256. brief = brief.model_copy(
  257. update={"source_assets": [source_asset]}
  258. )
  259. plan = build_global_data_plan()
  260. with self.assertRaises(
  261. PlanContractValidationError
  262. ) as caught:
  263. validate_plan_runtime(
  264. plan,
  265. brief=brief,
  266. plan_version=1,
  267. max_tasks=8,
  268. )
  269. missing = next(
  270. item
  271. for item in caught.exception.structured_errors
  272. if item["code"] == "undispositioned_source_asset"
  273. )
  274. self.assertEqual(
  275. missing,
  276. {
  277. "code": "undispositioned_source_asset",
  278. "message": (
  279. "Brief 中的 SourceAsset 未被 critical "
  280. "source_identity Expectation 处置"
  281. ),
  282. "source_asset_id": source_asset.source_asset_id,
  283. "artifact_type": "video",
  284. "source_uri": source_uri,
  285. "source_paths": ["$.核心制作点[0]"],
  286. },
  287. )
  288. correction = _planner_correction_content(
  289. caught.exception.structured_errors,
  290. plan_version=1,
  291. )
  292. self.assertIn(source_asset.source_asset_id, correction)
  293. self.assertIn('"artifact_type": "video"', correction)
  294. self.assertIn(
  295. '"source_paths": ["$.核心制作点[0]"]',
  296. correction,
  297. )
  298. self.assertIn(
  299. "对于 undispositioned_source_asset",
  300. correction,
  301. )
  302. def test_final_planner_failure_separates_attempts(self) -> None:
  303. first = [
  304. {
  305. "code": "source_asset_count_mismatch",
  306. "expectation_id": "Requirement3-Expectation1",
  307. }
  308. ]
  309. final = [
  310. {
  311. "code": "undispositioned_source_asset",
  312. "source_asset_id": "SourceAsset-" + "a" * 64,
  313. }
  314. ]
  315. message = _planner_failure_message([first, final])
  316. self.assertIn("final_issues=", message)
  317. self.assertIn("previous_attempt_issues=", message)
  318. final_section, previous_section = message.split(
  319. ";previous_attempt_issues=",
  320. maxsplit=1,
  321. )
  322. self.assertIn("undispositioned_source_asset", final_section)
  323. self.assertNotIn("source_asset_count_mismatch", final_section)
  324. self.assertIn("source_asset_count_mismatch", previous_section)
  325. def test_scope_issue_exposes_both_path_sets_for_correction(
  326. self,
  327. ) -> None:
  328. _, brief = load_production_brief(BRIEF_PATH)
  329. source_uri = "https://example.test/reference/curtain.png"
  330. source_asset = SourceAsset(
  331. source_asset_id=source_asset_id_for("image", source_uri),
  332. artifact_type="image",
  333. source_uri=source_uri,
  334. source_paths=["$.核心制作点[1]"],
  335. )
  336. brief = brief.model_copy(update={"source_assets": [source_asset]})
  337. plan = build_global_data_plan()
  338. requirement = plan.stage_requirements[0]
  339. expectation = requirement.artifact_expectations[0].model_copy(
  340. update={
  341. "artifact_type": source_asset.artifact_type,
  342. "minimum_count": 1,
  343. "verification_capabilities": [
  344. "source_identity",
  345. "technical_integrity",
  346. ],
  347. "source_asset_ids": [source_asset.source_asset_id],
  348. }
  349. )
  350. requirement = requirement.model_copy(
  351. update={
  352. "source_paths": ["$.核心制作点[0]"],
  353. "artifact_expectations": [expectation],
  354. }
  355. )
  356. task = plan.tasks[0].model_copy(
  357. update={
  358. "source_paths": ["$.核心制作点[0]"],
  359. "skill_id": "reference-inspection",
  360. "deliverable_type": "reference_collection",
  361. }
  362. )
  363. plan = plan.model_copy(
  364. update={
  365. "stage_requirements": [requirement],
  366. "tasks": [task],
  367. }
  368. )
  369. with self.assertRaises(
  370. PlanContractValidationError
  371. ) as caught:
  372. validate_plan_runtime(
  373. plan,
  374. brief=brief,
  375. plan_version=1,
  376. max_tasks=8,
  377. )
  378. scope_issue = next(
  379. item
  380. for item in caught.exception.structured_errors
  381. if item["code"]
  382. == "source_asset_outside_requirement_scope"
  383. )
  384. self.assertEqual(
  385. scope_issue["requirement_source_paths"],
  386. ["$.核心制作点[0]"],
  387. )
  388. self.assertEqual(
  389. scope_issue["source_asset_source_paths"],
  390. ["$.核心制作点[1]"],
  391. )
  392. correction = _planner_correction_content(
  393. [scope_issue],
  394. plan_version=1,
  395. )
  396. self.assertIn(
  397. "source_asset_outside_requirement_scope",
  398. correction,
  399. )
  400. self.assertIn(
  401. "requirement_source_paths",
  402. correction,
  403. )
  404. self.assertIn(
  405. "source_asset_source_paths",
  406. correction,
  407. )
  408. def test_structured_analysis_cannot_cover_required_image(self) -> None:
  409. plan = build_global_data_plan()
  410. payload = plan.model_dump(mode="json")
  411. payload["stage_requirements"][0]["artifact_expectations"] = [
  412. {
  413. "expectation_id": "Requirement1-Expectation1",
  414. "artifact_type": "image",
  415. "minimum_count": 1,
  416. "usage_scope": "人物一致性",
  417. "verification_capabilities": ["visual_content"],
  418. }
  419. ]
  420. incompatible = GlobalDataPlan.model_validate(payload)
  421. _, brief = load_production_brief(BRIEF_PATH)
  422. with self.assertRaisesRegex(
  423. PlannerOutputError,
  424. "无法交付",
  425. ):
  426. validate_plan_runtime(
  427. incompatible,
  428. brief=brief,
  429. plan_version=1,
  430. max_tasks=8,
  431. )
  432. def test_audio_reference_plan_repairs_subjective_acceptance(
  433. self,
  434. ) -> None:
  435. task = build_planned_task(
  436. skill_id="reference-inspection",
  437. deliverable_type="reference_collection",
  438. )
  439. invalid = build_global_data_plan(tasks=[task])
  440. valid = build_global_data_plan(tasks=[task])
  441. invalid = invalid.model_copy(
  442. update={
  443. "stage_requirements": [
  444. invalid.stage_requirements[0].model_copy(
  445. update={
  446. "description": (
  447. "确认音频为治愈系无人声器乐并可正常使用"
  448. ),
  449. "artifact_expectations": [
  450. ArtifactExpectation(
  451. expectation_id=(
  452. "Requirement1-Expectation1"
  453. ),
  454. artifact_type="audio",
  455. minimum_count=1,
  456. usage_scope="全片人声基准",
  457. verification_capabilities=[
  458. "semantic_content",
  459. ],
  460. )
  461. ]
  462. }
  463. )
  464. ]
  465. }
  466. )
  467. valid_requirement = invalid.stage_requirements[0].model_copy(
  468. update={
  469. "description": (
  470. "采纳制作表指定音频,并确认媒体类型和技术属性可用"
  471. ),
  472. "artifact_expectations": [
  473. invalid.stage_requirements[
  474. 0
  475. ].artifact_expectations[0].model_copy(
  476. update={
  477. "verification_capabilities": [
  478. "technical_integrity",
  479. ]
  480. }
  481. )
  482. ],
  483. }
  484. )
  485. valid = valid.model_copy(
  486. update={
  487. "stage_requirements": [valid_requirement],
  488. }
  489. )
  490. plan = run_planner_agent(
  491. str(BRIEF_PATH),
  492. plan_version=1,
  493. run_id="Run-planner-audio-repair",
  494. model=build_planner_model(invalid, valid),
  495. )
  496. self.assertEqual(plan, valid)
  497. def test_requirement_ids_must_be_unique_and_continuous(self) -> None:
  498. payload = build_global_data_plan().model_dump(mode="json")
  499. requirement = dict(payload["stage_requirements"][0])
  500. requirement["requirement_id"] = "Requirement3"
  501. payload["stage_requirements"].append(requirement)
  502. with self.assertRaisesRegex(ValidationError, "连续编号"):
  503. GlobalDataPlan.model_validate(payload)
  504. def test_initial_plan_cannot_replace_artifact(self) -> None:
  505. plan = build_global_data_plan()
  506. altered = plan.model_copy(
  507. update={
  508. "tasks": [
  509. plan.tasks[0].model_copy(
  510. update={
  511. "replaces_artifact_ids": [
  512. "Task1-v1-artifact-1"
  513. ]
  514. }
  515. )
  516. ]
  517. }
  518. )
  519. _, brief = load_production_brief(BRIEF_PATH)
  520. with self.assertRaisesRegex(PlannerOutputError, "初始"):
  521. validate_plan_runtime(
  522. altered,
  523. brief=brief,
  524. plan_version=1,
  525. max_tasks=8,
  526. )
  527. def test_task_budget_is_enforced_without_fixing_business_categories(self) -> None:
  528. plan = build_global_data_plan(
  529. tasks=[
  530. build_planned_task(task_id=f"Task{index}")
  531. for index in range(1, 4)
  532. ]
  533. )
  534. with self.assertRaisesRegex(PlannerOutputError, "超过预算"):
  535. run_planner_agent(
  536. str(BRIEF_PATH),
  537. plan_version=1,
  538. run_id="Run-planner-budget",
  539. max_tasks=2,
  540. model=build_planner_model(plan),
  541. )
  542. def test_ready_node_materializes_complete_task_package(self) -> None:
  543. plan = build_global_data_plan()
  544. task = materialize_task_package(
  545. plan,
  546. ready_tasks(plan)[0],
  547. run_id="Run-materialize",
  548. production_brief_path=BRIEF_PATH,
  549. plan_path=PLAN_PATH,
  550. )
  551. self.assertEqual(task.schema_version, "0.3")
  552. self.assertEqual(task.task_id, "Task1")
  553. self.assertEqual(task.run_id, "Run-materialize")
  554. self.assertEqual(task.plan_id, plan.plan_id)
  555. self.assertEqual(task.plan_version, plan.plan_version)
  556. self.assertEqual(
  557. task.production_brief_uri,
  558. str(BRIEF_PATH.resolve()),
  559. )
  560. self.assertEqual(task.plan_uri, str(PLAN_PATH.resolve()))
  561. self.assertEqual(task.dependency_deliveries, [])
  562. self.assertEqual(
  563. set(task.model_dump()),
  564. {
  565. "schema_version",
  566. "run_id",
  567. "plan_id",
  568. "plan_version",
  569. "task_id",
  570. "production_brief_uri",
  571. "plan_uri",
  572. "dependency_deliveries",
  573. },
  574. )
  575. def test_dependency_uses_upstream_delivery_manifest(self) -> None:
  576. planned = build_planned_task(
  577. task_id="Task2",
  578. depends_on=["Task1"],
  579. )
  580. manifest = "/tmp/Task1.executor-delivery.json"
  581. plan = build_global_data_plan(
  582. tasks=[
  583. build_planned_task(task_id="Task1"),
  584. planned,
  585. ]
  586. )
  587. task = materialize_task_package(
  588. plan,
  589. planned,
  590. run_id="Run-dependency",
  591. production_brief_path=BRIEF_PATH,
  592. plan_path=PLAN_PATH,
  593. dependency_artifacts={"Task1": manifest},
  594. )
  595. self.assertEqual(len(task.dependency_deliveries), 1)
  596. dependency = task.dependency_deliveries[0]
  597. self.assertEqual(dependency.task_id, "Task1")
  598. self.assertEqual(dependency.delivery_uri, manifest)
  599. def test_replan_requires_next_version_and_immutable_passed_tasks(self) -> None:
  600. task1 = build_planned_task(task_id="Task1")
  601. previous = build_global_data_plan(tasks=[task1], plan_version=1)
  602. valid = build_global_data_plan(tasks=[task1], plan_version=2)
  603. validate_replan(previous, valid, passed_task_ids={"Task1"})
  604. changed = build_global_data_plan(
  605. tasks=[
  606. task1.model_copy(update={"objective": "修改已经通过的任务"})
  607. ],
  608. plan_version=2,
  609. )
  610. with self.assertRaisesRegex(PlannerOutputError, "不得修改已 PASS"):
  611. validate_replan(
  612. previous,
  613. changed,
  614. passed_task_ids={"Task1"},
  615. )
  616. skipped = build_global_data_plan(tasks=[task1], plan_version=3)
  617. with self.assertRaisesRegex(PlannerOutputError, "只增加一个版本"):
  618. validate_replan(
  619. previous,
  620. skipped,
  621. passed_task_ids=set(),
  622. )
  623. def test_replan_contract_error_is_corrected_in_same_planner_run(
  624. self,
  625. ) -> None:
  626. task1 = build_planned_task(task_id="Task1")
  627. previous = build_global_data_plan(
  628. tasks=[task1],
  629. plan_version=1,
  630. )
  631. invalid = build_global_data_plan(
  632. tasks=[
  633. task1.model_copy(
  634. update={"objective": "错误修改已经通过的任务"}
  635. )
  636. ],
  637. plan_version=2,
  638. )
  639. corrected = build_global_data_plan(
  640. tasks=[task1],
  641. plan_version=2,
  642. )
  643. actual = run_replan_agent(
  644. str(BRIEF_PATH),
  645. previous,
  646. run_id="Run-replan-contract-correction",
  647. failure_scope="global_data_stage",
  648. failed_task=None,
  649. validation_report={"verdict": "FAIL"},
  650. passed_task_ids={"Task1"},
  651. remaining_replans=4,
  652. model=build_planner_model(invalid, corrected),
  653. )
  654. self.assertEqual(actual, corrected)
  655. def test_failed_task_context_is_resolved_from_previous_plan(
  656. self,
  657. ) -> None:
  658. previous = build_global_data_plan(plan_version=1)
  659. package = build_task_package(
  660. BRIEF_PATH,
  661. plan_version=1,
  662. )
  663. context = _failed_task_context(previous, package)
  664. self.assertIsNotNone(context)
  665. assert context is not None
  666. self.assertEqual(
  667. context["planned_task"],
  668. previous.tasks[0].model_dump(mode="json"),
  669. )
  670. self.assertEqual(
  671. context["selected_expectations"][0]["expectation"],
  672. previous.stage_requirements[
  673. 0
  674. ].artifact_expectations[0].model_dump(mode="json"),
  675. )
  676. self.assertNotIn(
  677. "artifact_expectations",
  678. context["selected_expectations"][0]["requirement"],
  679. )
  680. def test_replan_cannot_weaken_critical_acceptance(self) -> None:
  681. task = build_planned_task()
  682. previous = build_global_data_plan(
  683. tasks=[task],
  684. plan_version=1,
  685. )
  686. weakened_requirement = previous.stage_requirements[0].model_copy(
  687. update={
  688. "artifact_expectations": [
  689. previous.stage_requirements[
  690. 0
  691. ].artifact_expectations[0].model_copy(
  692. update={
  693. "verification_capabilities": [
  694. "source_identity"
  695. ]
  696. }
  697. )
  698. ]
  699. }
  700. )
  701. new_plan = previous.model_copy(
  702. update={
  703. "plan_version": 2,
  704. "stage_requirements": [weakened_requirement],
  705. }
  706. )
  707. with self.assertRaisesRegex(
  708. PlannerOutputError,
  709. "不得弱化",
  710. ):
  711. validate_replan(
  712. previous,
  713. new_plan,
  714. passed_task_ids=set(),
  715. )
  716. def test_replan_can_strengthen_unpassed_critical_requirement(
  717. self,
  718. ) -> None:
  719. previous = build_global_data_plan(plan_version=1)
  720. expectation = previous.stage_requirements[
  721. 0
  722. ].artifact_expectations[0]
  723. strengthened_requirement = previous.stage_requirements[0].model_copy(
  724. update={
  725. "artifact_expectations": [
  726. expectation.model_copy(
  727. update={"minimum_count": 2}
  728. )
  729. ]
  730. }
  731. )
  732. strengthened = previous.model_copy(
  733. update={
  734. "plan_version": 2,
  735. "stage_requirements": [strengthened_requirement],
  736. }
  737. )
  738. validate_replan(
  739. previous,
  740. strengthened,
  741. passed_task_ids=set(),
  742. )
  743. def test_requirement_can_repeat_same_artifact_type_by_identity(self) -> None:
  744. payload = build_global_data_plan().model_dump(mode="json")
  745. second = dict(
  746. payload["stage_requirements"][0]["artifact_expectations"][0]
  747. )
  748. second["expectation_id"] = "Requirement1-Expectation2"
  749. second["usage_scope"] = "另一独立用途"
  750. payload["stage_requirements"][0][
  751. "artifact_expectations"
  752. ].append(second)
  753. payload["tasks"][0]["expectation_ids"].append(
  754. "Requirement1-Expectation2"
  755. )
  756. plan = GlobalDataPlan.model_validate(payload)
  757. self.assertEqual(
  758. [item.expectation_id for item in plan.stage_requirements[0].artifact_expectations],
  759. ["Requirement1-Expectation1", "Requirement1-Expectation2"],
  760. )
  761. def test_replan_must_keep_all_required_artifact_replacements(
  762. self,
  763. ) -> None:
  764. previous = build_global_data_plan()
  765. previous = previous.model_copy(
  766. update={
  767. "tasks": [
  768. previous.tasks[0].model_copy(
  769. update={
  770. "replaces_artifact_ids": [
  771. "Task1-v1-artifact-1"
  772. ]
  773. }
  774. )
  775. ]
  776. }
  777. )
  778. current = previous.model_copy(
  779. update={
  780. "plan_version": 2,
  781. "tasks": [
  782. previous.tasks[0].model_copy(
  783. update={"replaces_artifact_ids": []}
  784. )
  785. ],
  786. }
  787. )
  788. with self.assertRaisesRegex(
  789. PlannerOutputError,
  790. "必须保留历史替换",
  791. ):
  792. validate_replan(
  793. previous,
  794. current,
  795. passed_task_ids=[],
  796. artifact_rejections=[
  797. ArtifactRejection(
  798. artifact_id="Task9-v1-artifact-1",
  799. reason_code="semantic_rejection",
  800. reason="Stage 语义验收未通过",
  801. )
  802. ],
  803. )
  804. if __name__ == "__main__":
  805. unittest.main()