test_goal_coverage.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. from __future__ import annotations
  2. from dataclasses import replace
  3. from datetime import UTC, datetime
  4. from inspect import signature
  5. from types import SimpleNamespace
  6. from typing import Any, cast
  7. import pytest
  8. from agent.orchestration import ArtifactRef, AttemptSubmission
  9. from script_build_host.agents.prompts.contracts import ROOT_VALIDATOR_PROMPT
  10. from script_build_host.application.phase_two_candidates import (
  11. PhaseTwoCandidateError,
  12. PhaseTwoCandidateService,
  13. _require_paragraph_structures,
  14. )
  15. from script_build_host.application.phase_two_planning import (
  16. PhasePolicyGuard,
  17. PhaseTwoPlanningService,
  18. )
  19. from script_build_host.application.root_delivery import (
  20. RootDeliveryService,
  21. _closure_digest,
  22. _RootClosure,
  23. )
  24. from script_build_host.domain.artifacts import (
  25. ArtifactKind,
  26. ArtifactState,
  27. ArtifactVersion,
  28. DirectionArtifact,
  29. DirectionConstraint,
  30. DirectionGoal,
  31. DirectionPreference,
  32. )
  33. from script_build_host.domain.goal_coverage import (
  34. GoalCoverage,
  35. GoalPolicyError,
  36. build_goal_coverage,
  37. validate_goal_coverage,
  38. )
  39. from script_build_host.domain.phase_three_artifacts import (
  40. RootDeliveryManifestV1,
  41. canonicalize_legacy_projection,
  42. )
  43. from script_build_host.domain.phase_two_artifacts import (
  44. CandidateLineageV1,
  45. CandidatePortfolioArtifactV1,
  46. ElementSetArtifactV1,
  47. ParagraphArtifactV1,
  48. ScriptElementV1,
  49. ScriptParagraphElementLinkV1,
  50. ScriptParagraphV1,
  51. StructureArtifactV1,
  52. StructuredScriptArtifactV1,
  53. hydrate_phase_two_artifact,
  54. )
  55. from script_build_host.domain.task_contracts import (
  56. AcceptedDecisionRef,
  57. ScriptCriterion,
  58. ScriptIntentClass,
  59. ScriptTaskBudget,
  60. ScriptTaskContractV1,
  61. ScriptTaskKind,
  62. TaskContractError,
  63. )
  64. _DIGEST = "sha256:" + "a" * 64
  65. def _complete_paragraph() -> ScriptParagraphV1:
  66. atom = ({"原子点": "行动", "维度": "主题", "维度类型": "主维度"},)
  67. return ScriptParagraphV1(
  68. 1,
  69. 1,
  70. 1,
  71. None,
  72. "body",
  73. {"scope": "body"},
  74. theme="主动行动",
  75. form="直接陈述",
  76. function="给出方法",
  77. feeling="坚定",
  78. theme_elements=atom,
  79. form_elements=atom,
  80. function_elements=atom,
  81. feeling_elements=atom,
  82. description="给出一个当下可执行的动作。",
  83. full_description="今天选择一件最小但具体的事。在十分钟内开始执行。",
  84. )
  85. def _realized_sources(goal_ids: tuple[str, ...]) -> dict[int, ArtifactVersion]:
  86. now = datetime.now(UTC)
  87. paragraph = _complete_paragraph()
  88. element = ScriptElementV1(1, "立即行动", "实质", "行动建议")
  89. link = ScriptParagraphElementLinkV1(1, 1)
  90. lineage = CandidateLineageV1(
  91. scope_ref="script-build://scopes/full",
  92. input_snapshot_ref="script-build://inputs/11",
  93. input_closure_digest=_DIGEST,
  94. write_scope=("script-build://writes/paragraphs",),
  95. goal_ids=goal_ids,
  96. )
  97. structure = StructureArtifactV1(lineage, (paragraph,))
  98. paragraph_artifact = ParagraphArtifactV1(
  99. lineage,
  100. (paragraph,),
  101. (element,),
  102. (link,),
  103. {"created": {"paragraph_ids": [1]}},
  104. )
  105. element_artifact = ElementSetArtifactV1(
  106. replace(lineage, write_scope=("script-build://writes/elements",)),
  107. (element,),
  108. (link,),
  109. (paragraph,),
  110. {"created": {"element_ids": [1]}},
  111. )
  112. return {
  113. 5: _version(5, ArtifactKind.STRUCTURE, structure, now),
  114. 6: _version(6, ArtifactKind.PARAGRAPH, paragraph_artifact, now),
  115. 7: _version(7, ArtifactKind.ELEMENT_SET, element_artifact, now),
  116. }
  117. def test_goal_coverage_supports_parent_child_and_many_to_many_sources() -> None:
  118. coverage = build_goal_coverage(
  119. direction_goal_ids=("parent", "child", "tone"),
  120. creative_sources=(
  121. ("script-build://artifact-versions/11", ("parent", "tone")),
  122. ("script-build://artifact-versions/12", ("parent", "child")),
  123. ),
  124. )
  125. assert tuple(item.goal_id for item in coverage) == ("parent", "child", "tone")
  126. assert coverage[0].source_artifact_refs == (
  127. "script-build://artifact-versions/11",
  128. "script-build://artifact-versions/12",
  129. )
  130. validate_goal_coverage(
  131. direction_goal_ids=("parent", "child", "tone"),
  132. coverage=coverage,
  133. adopted_source_refs=(
  134. "script-build://artifact-versions/11",
  135. "script-build://artifact-versions/12",
  136. ),
  137. )
  138. @pytest.mark.parametrize(
  139. ("sources", "code"),
  140. [
  141. ((("script-build://artifact-versions/11", ("parent",)),), "GOAL_COVERAGE_INCOMPLETE"),
  142. ((("script-build://artifact-versions/11", ("unknown",)),), "GOAL_SCOPE_INVALID"),
  143. ],
  144. )
  145. def test_goal_coverage_rejects_missing_and_unknown_goals(
  146. sources: tuple[tuple[str, tuple[str, ...]], ...], code: str
  147. ) -> None:
  148. with pytest.raises(GoalPolicyError) as caught:
  149. build_goal_coverage(
  150. direction_goal_ids=("parent", "child"),
  151. creative_sources=sources,
  152. )
  153. assert caught.value.code == code
  154. def test_goal_coverage_rejects_dangling_source_and_old_payloads() -> None:
  155. with pytest.raises(GoalPolicyError, match="non-adopted"):
  156. validate_goal_coverage(
  157. direction_goal_ids=("goal-1",),
  158. coverage=(
  159. GoalCoverage("goal-1", ("script-build://artifact-versions/12",)),
  160. ),
  161. adopted_source_refs=("script-build://artifact-versions/11",),
  162. )
  163. with pytest.raises(GoalPolicyError) as missing_coverage:
  164. hydrate_phase_two_artifact({"schema_version": "structured-script/v1"})
  165. assert missing_coverage.value.code == "GOAL_COVERAGE_INCOMPLETE"
  166. payload = _contract(ScriptTaskKind.STRUCTURE).to_payload()
  167. payload.pop("goal_ids")
  168. with pytest.raises(TaskContractError) as missing_goals:
  169. ScriptTaskContractV1.from_payload(payload)
  170. assert missing_goals.value.code == "GOAL_SCOPE_INVALID"
  171. def test_paragraph_first_is_allowed_but_structure_patch_remains_pinned() -> None:
  172. paragraph = _contract(ScriptTaskKind.PARAGRAPH)
  173. PhasePolicyGuard._phase_two_contracts((paragraph,))
  174. patched = replace(
  175. paragraph,
  176. base_artifact_ref=ArtifactRef(
  177. "script-build://artifact-versions/11", "structure", "11", _DIGEST
  178. ),
  179. )
  180. with pytest.raises(TaskContractError, match="Paragraph patch requires"):
  181. PhasePolicyGuard._phase_two_contracts((patched,))
  182. def test_duplicate_goal_ids_are_rejected_by_the_frozen_contract() -> None:
  183. with pytest.raises(TaskContractError) as caught:
  184. replace(_contract(ScriptTaskKind.STRUCTURE), goal_ids=("goal-1", "goal-1"))
  185. assert caught.value.code == "GOAL_SCOPE_INVALID"
  186. def test_compose_derives_coverage_and_requires_structure_for_adopted_paragraph() -> None:
  187. assert set(signature(PhaseTwoCandidateService.save_structured_script_candidate).parameters) == {
  188. "self",
  189. "acceptance_notes",
  190. "context",
  191. }
  192. lineage = CandidateLineageV1(
  193. scope_ref="script-build://scopes/full/opening",
  194. input_snapshot_ref="script-build://inputs/11",
  195. input_closure_digest=_DIGEST,
  196. write_scope=("script-build://writes/paragraphs/full/opening",),
  197. goal_ids=("goal-1",),
  198. )
  199. paragraph = ParagraphArtifactV1(
  200. lineage=lineage,
  201. paragraphs=(ScriptParagraphV1(1, 1, 1, None, "opening", {}),),
  202. )
  203. with pytest.raises(PhaseTwoCandidateError, match="covering Structure"):
  204. _require_paragraph_structures(
  205. (_version(11, ArtifactKind.PARAGRAPH, paragraph, datetime.now(UTC)),)
  206. )
  207. @pytest.mark.asyncio
  208. @pytest.mark.parametrize("goal_ids", [(), ("unknown",)])
  209. async def test_phase_two_goal_guard_rejects_invalid_scope_before_dispatch(
  210. goal_ids: tuple[str, ...],
  211. ) -> None:
  212. direction = DirectionArtifact(
  213. goals=(
  214. DirectionGoal("goal-1", "Complete", "needed", None, ("complete",)),
  215. DirectionGoal("goal-2", "Specific", "needed", "goal-1", ("specific",)),
  216. ),
  217. evidence_refs=("script-build://artifact-versions/90",),
  218. )
  219. service = PhaseTwoPlanningService(
  220. coordinator=cast(Any, SimpleNamespace()),
  221. bindings=cast(Any, SimpleNamespace()),
  222. contracts=cast(Any, SimpleNamespace()),
  223. artifacts=cast(Any, _DirectionArtifacts(direction)),
  224. )
  225. direction_ref = AcceptedDecisionRef(
  226. "direction-accept",
  227. ArtifactRef("script-build://artifact-versions/10", "direction", "10", _DIGEST),
  228. "script-build://scopes/full",
  229. ScriptTaskKind.DIRECTION,
  230. )
  231. contract = replace(
  232. _contract(ScriptTaskKind.STRUCTURE),
  233. goal_ids=goal_ids,
  234. input_decision_refs=(direction_ref,),
  235. )
  236. with pytest.raises(GoalPolicyError) as caught:
  237. await service._guard_phase_two_goals(
  238. context={"phase": 2},
  239. binding=SimpleNamespace(
  240. active_direction_artifact_version_id=10,
  241. script_build_id=7,
  242. ),
  243. root_trace_id="root",
  244. ledger=SimpleNamespace(root_task_id="root"),
  245. contracts=(contract,),
  246. parent=SimpleNamespace(task_id="root"),
  247. )
  248. assert caught.value.code == "GOAL_SCOPE_INVALID"
  249. @pytest.mark.asyncio
  250. async def test_root_preflight_checks_all_goal_nodes_and_frozen_direction_contract() -> None:
  251. now = datetime.now(UTC)
  252. direction = DirectionArtifact(
  253. goals=(
  254. DirectionGoal(
  255. "parent", "Make a useful script", "business goal", None, ("clear outcome",)
  256. ),
  257. DirectionGoal(
  258. "child", "Give one concrete action", "supports parent", "parent", ("action exists",)
  259. ),
  260. ),
  261. constraints=(DirectionConstraint("constraint-1", "No fabricated facts"),),
  262. preferences=(DirectionPreference("preference-1", "Prefer a brisk tone"),),
  263. evidence_refs=("script-build://artifact-versions/90",),
  264. )
  265. structured = StructuredScriptArtifactV1(
  266. direction_ref="script-build://artifact-versions/1",
  267. input_closure_digest=_DIGEST,
  268. paragraphs=(_complete_paragraph(),),
  269. elements=(ScriptElementV1(1, "立即行动", "实质", "行动建议"),),
  270. paragraph_element_links=(ScriptParagraphElementLinkV1(1, 1),),
  271. source_artifact_refs=(
  272. "script-build://artifact-versions/5",
  273. "script-build://artifact-versions/6",
  274. "script-build://artifact-versions/7",
  275. ),
  276. goal_coverage=(
  277. GoalCoverage(
  278. "parent",
  279. (
  280. "script-build://artifact-versions/6",
  281. "script-build://artifact-versions/7",
  282. ),
  283. ),
  284. GoalCoverage(
  285. "child",
  286. (
  287. "script-build://artifact-versions/6",
  288. "script-build://artifact-versions/7",
  289. ),
  290. ),
  291. ),
  292. evidence_refs=(),
  293. acceptance_notes=(),
  294. )
  295. portfolio = CandidatePortfolioArtifactV1(
  296. adopted_structured_script_ref="script-build://artifact-versions/3",
  297. candidate_structured_script_refs=("script-build://artifact-versions/3",),
  298. accepted_decision_ids=("compose-accept",),
  299. superseded_decision_ids=(),
  300. rejected_or_held_decision_ids=(),
  301. input_closure_digest=_DIGEST,
  302. unresolved_defects=(),
  303. compose_order=("compose-accept",),
  304. )
  305. closure = _RootClosure(
  306. _version(1, ArtifactKind.DIRECTION, direction, now),
  307. _version(2, ArtifactKind.CANDIDATE_PORTFOLIO, portfolio, now),
  308. _version(3, ArtifactKind.STRUCTURED_SCRIPT, structured, now),
  309. )
  310. projection = canonicalize_legacy_projection(
  311. structured, direction=direction.legacy_markdown, summary="complete"
  312. )
  313. manifest = RootDeliveryManifestV1(
  314. direction_ref="script-build://artifact-versions/1",
  315. candidate_portfolio_ref="script-build://artifact-versions/2",
  316. structured_script_ref="script-build://artifact-versions/3",
  317. input_closure_digest=_closure_digest(closure),
  318. legacy_projection_digest=projection.canonical_sha256,
  319. build_summary="complete",
  320. )
  321. manifest_version = _version(4, ArtifactKind.ROOT_DELIVERY_MANIFEST, manifest, now)
  322. manifest_ref = ArtifactRef(
  323. "script-build://artifact-versions/4", "root_delivery_manifest", "4", _DIGEST
  324. )
  325. ledger = SimpleNamespace(
  326. attempts={
  327. "attempt-root": SimpleNamespace(
  328. attempt_id="attempt-root",
  329. submission=AttemptSubmission("complete", artifact_refs=[manifest_ref]),
  330. task_id="root",
  331. )
  332. }
  333. )
  334. service = RootDeliveryService(
  335. bindings=cast(Any, _Bindings()),
  336. task_store=cast(Any, _TaskStore(ledger)),
  337. artifacts=cast(
  338. Any,
  339. _Artifacts(
  340. manifest_version,
  341. _realized_sources(("parent", "child")),
  342. ),
  343. ),
  344. )
  345. async def closure_override(_: Any) -> _RootClosure:
  346. return closure
  347. service._closure = closure_override # type: ignore[method-assign]
  348. results = await service.deterministic_precheck(
  349. context={"root_trace_id": "root", "attempt_id": "attempt-root"}
  350. )
  351. by_id = {item["rule_id"]: item["verdict"] for item in results}
  352. assert by_id["root-goal-coverage"] == "passed"
  353. assert by_id["root-direction-contract"] == "passed"
  354. root_validator_prompt = " ".join(ROOT_VALIDATOR_PROMPT.split())
  355. assert "子 Goal 不能 自动证明父 Goal" in root_validator_prompt
  356. assert "不因可选偏好不足单独拒绝" in root_validator_prompt
  357. def _contract(kind: ScriptTaskKind) -> ScriptTaskContractV1:
  358. output = {
  359. ScriptTaskKind.STRUCTURE: "structure-artifact/v1",
  360. ScriptTaskKind.PARAGRAPH: "paragraph-artifact/v1",
  361. }[kind]
  362. return ScriptTaskContractV1(
  363. task_kind=kind,
  364. scope_ref="script-build://scopes/full",
  365. intent_class=ScriptIntentClass.EXPLORE,
  366. objective="produce one concrete increment",
  367. input_decision_refs=(),
  368. base_artifact_ref=None,
  369. write_scope=("script-build://writes/paragraphs/full",),
  370. gap_ref=None,
  371. output_schema=output,
  372. criteria=(ScriptCriterion("closed", "complete"),),
  373. budget=ScriptTaskBudget(),
  374. goal_ids=("goal-1",),
  375. )
  376. def _version(
  377. identifier: int, kind: ArtifactKind, artifact: Any, now: datetime
  378. ) -> ArtifactVersion:
  379. return ArtifactVersion(
  380. identifier,
  381. 7,
  382. f"task-{identifier}",
  383. f"attempt-{identifier}",
  384. 1,
  385. kind,
  386. _DIGEST,
  387. ArtifactState.FROZEN,
  388. artifact,
  389. now,
  390. now,
  391. )
  392. class _Bindings:
  393. async def get_by_root(self, _: str) -> Any:
  394. return SimpleNamespace(script_build_id=7, root_trace_id="root")
  395. class _TaskStore:
  396. def __init__(self, ledger: Any) -> None:
  397. self.ledger = ledger
  398. async def load(self, _: str) -> Any:
  399. return self.ledger
  400. class _Artifacts:
  401. def __init__(
  402. self,
  403. manifest: ArtifactVersion,
  404. versions: dict[int, ArtifactVersion] | None = None,
  405. ) -> None:
  406. self.manifest = manifest
  407. self.versions = versions or {}
  408. async def read_by_ref(self, *_: Any, **__: Any) -> ArtifactVersion:
  409. return self.manifest
  410. async def get_by_id(self, identifier: int, **__: Any) -> ArtifactVersion:
  411. return self.versions[identifier]
  412. class _DirectionArtifacts:
  413. def __init__(self, direction: DirectionArtifact) -> None:
  414. self.direction = direction
  415. async def get_by_id(self, *_: Any, **__: Any) -> Any:
  416. return SimpleNamespace(artifact=self.direction)