test_goal_coverage.py 15 KB

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