test_production_context.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. from __future__ import annotations
  2. import json
  3. from pathlib import Path
  4. from types import SimpleNamespace
  5. from obagent_sdk import observe
  6. from obagent_sdk.integrations import langgraph
  7. from production_build_agents.observability import (
  8. ProductionObservation,
  9. start_graph_node_observation,
  10. )
  11. from production_build_agents.observability import production
  12. from production_build_agents.observability import collector as collector_module
  13. from production_build_agents.production.graph import create_production_graph
  14. class _Handle:
  15. def __init__(self) -> None:
  16. self.finished: list[dict] = []
  17. self.declared: list[dict] = []
  18. self.outputs: list[tuple[object, dict]] = []
  19. self.notes: list[tuple[str, dict]] = []
  20. def finish(self, **payload) -> None:
  21. self.finished.append(payload)
  22. def declare(self, **payload) -> None:
  23. self.declared.append(payload)
  24. def set_output(self, output, **payload) -> None:
  25. self.outputs.append((output, payload))
  26. def record_note(self, text: str, **payload) -> None:
  27. self.notes.append((text, payload))
  28. class _Manager:
  29. def __init__(self, handle: _Handle) -> None:
  30. self.handle = handle
  31. self.exits: list[tuple] = []
  32. def __enter__(self) -> _Handle:
  33. return self.handle
  34. def __exit__(self, *args):
  35. self.exits.append(args)
  36. if args[1] is not None:
  37. raise args[1]
  38. return False
  39. def _settings(mode: str = "full"):
  40. return SimpleNamespace(
  41. configure_sdk=lambda: True,
  42. project="production-project",
  43. project_name="Production 完整观测",
  44. capture_mode=mode,
  45. )
  46. def test_production_observer_covers_every_real_graph_node() -> None:
  47. spec = langgraph.graph_spec(create_production_graph())
  48. graph_nodes = {node["key"] for node in spec["nodes"]}
  49. assert graph_nodes == set(production._NODE_SPECS)
  50. assert len(graph_nodes) == 13
  51. def test_production_planner_titles_distinguish_role_modes(
  52. tmp_path: Path,
  53. ) -> None:
  54. initial = tmp_path / "initial.json"
  55. adapt = tmp_path / "adapt.json"
  56. replan = tmp_path / "replan.json"
  57. for path, mode in (
  58. (initial, "INITIAL"),
  59. (adapt, "ADAPT"),
  60. (replan, "REPLAN"),
  61. ):
  62. path.write_text(json.dumps({"mode": mode}), encoding="utf-8")
  63. assert production._planning_mode_title(
  64. "plan_production",
  65. {"current_planning_package_path": str(initial)},
  66. "fallback",
  67. ) == "Production Planner · Initial Plan"
  68. assert production._planning_mode_title(
  69. "plan_production",
  70. {"current_planning_package_path": str(adapt)},
  71. "fallback",
  72. ) == "Production Planner · Plan Adaptation"
  73. assert production._planning_mode_title(
  74. "plan_production",
  75. {"current_planning_package_path": str(replan)},
  76. "fallback",
  77. ) == "Production Planner · Plan Revision"
  78. assert production._NODE_SPECS["review_production_progress"] == (
  79. "agent",
  80. "Production Planner · Progress Review",
  81. )
  82. assert production._NODE_SPECS["adapt_production"][0] == "code"
  83. def test_production_context_captures_root_and_all_run_records(
  84. monkeypatch,
  85. tmp_path: Path,
  86. ) -> None:
  87. captured: dict = {}
  88. collector_calls: list[tuple[str, object]] = []
  89. run_handle = _Handle()
  90. run_manager = _Manager(run_handle)
  91. delivery = tmp_path / "GlobalDataStageDelivery.v1.json"
  92. anchor = tmp_path / "anchor.png"
  93. run_dir = tmp_path / "production-run"
  94. final = run_dir / "final_production_delivery.json"
  95. delivery.write_text(
  96. json.dumps({"summary": "完整 Global Data 交付"}, ensure_ascii=False),
  97. encoding="utf-8",
  98. )
  99. anchor.write_bytes(b"\x89PNG")
  100. final.parent.mkdir()
  101. final.write_text(
  102. json.dumps(
  103. {
  104. "status": "PASS",
  105. "video_url": "https://internal.example/final.mp4",
  106. },
  107. ensure_ascii=False,
  108. ),
  109. encoding="utf-8",
  110. )
  111. def fake_run(**kwargs):
  112. captured.update(kwargs)
  113. return run_manager
  114. monkeypatch.setattr(observe, "run", fake_run)
  115. monkeypatch.setattr(
  116. collector_module,
  117. "_start_business_collector",
  118. lambda handle: (
  119. collector_calls.append(("start", handle))
  120. or "collector-token"
  121. ),
  122. )
  123. monkeypatch.setattr(
  124. collector_module,
  125. "_stop_business_collector",
  126. lambda token: collector_calls.append(("stop", token)),
  127. )
  128. monkeypatch.setattr(
  129. langgraph,
  130. "graph_spec",
  131. lambda _graph: {"nodes": [{"key": "prepare_inputs"}]},
  132. )
  133. result = {
  134. "run_id": "round-production",
  135. "protocol_version": "0.7",
  136. "status": "COMPLETED",
  137. "phase": "FINALIZE",
  138. "final_production_delivery_path": str(final),
  139. "event_log": ["Production 完整完成"],
  140. }
  141. with ProductionObservation(
  142. project_root=tmp_path,
  143. thread_id="round-production",
  144. input_sha256="c" * 64,
  145. protocol_version="0.7",
  146. execution_mode="fresh",
  147. graph=object(),
  148. input_path=delivery,
  149. shared_visual_anchor_path=anchor,
  150. run_dir=run_dir,
  151. parent_run_id="pipeline-run",
  152. parent_inst_id="production-stage",
  153. pipeline_round_id="round-1",
  154. upstream_global_data_thread_id="round-1-global-data",
  155. settings=_settings(),
  156. ) as observation:
  157. observation.finish(result, graph_invoked=True)
  158. encoded = json.dumps(
  159. {"run": captured, "finish": run_handle.finished},
  160. ensure_ascii=False,
  161. )
  162. assert captured["agent"] == "production"
  163. assert captured["auto_collect"] is False
  164. assert captured["round_anchor"] == {
  165. "in": "run",
  166. "on": [
  167. "prepare_inputs",
  168. "review_production_progress",
  169. ],
  170. }
  171. assert captured["parent_run_id"] == "pipeline-run"
  172. assert captured["parent_inst_id"] == "production-stage"
  173. assert captured["payload"]["Pipeline 轮次"] == "round-1"
  174. assert captured["payload"]["上游 Global Data Run"] == (
  175. "round-1-global-data"
  176. )
  177. assert collector_calls == [
  178. ("start", run_handle),
  179. ("stop", "collector-token"),
  180. ]
  181. assert captured["meta"]["capture_mode"] == "full_v1"
  182. assert captured["payload"]["Global Data 交付摘要"]["业务摘要"] == (
  183. "完整 Global Data 交付"
  184. )
  185. assert captured["payload"]["共享视觉锚点"] == str(anchor.resolve())
  186. assert captured["meta"]["audit_records"][
  187. "Global Data 正式交付原件"
  188. ]["content"]["summary"] == "完整 Global Data 交付"
  189. assert captured["meta"]["audit_records"]["共享视觉锚点原件"][
  190. "content_omitted"
  191. ] == "non_text_record"
  192. assert "https://internal.example/final.mp4" not in encoded
  193. assert run_handle.finished[0]["final_output"]["结论"] == "已完成"
  194. assert "state_after" not in encoded
  195. def test_shared_node_router_uses_production_context_and_segment_branch(
  196. monkeypatch,
  197. tmp_path: Path,
  198. ) -> None:
  199. run_handle = _Handle()
  200. run_manager = _Manager(run_handle)
  201. node_handle = _Handle()
  202. node_manager = _Manager(node_handle)
  203. run_dir = tmp_path / "production-run"
  204. run_dir.mkdir()
  205. delivery = tmp_path / "GlobalDataStageDelivery.v1.json"
  206. anchor = tmp_path / "anchor.png"
  207. delivery.write_text("{}", encoding="utf-8")
  208. anchor.write_bytes(b"\x89PNG")
  209. package = run_dir / "segment_packages" / "Segment2.v3.json"
  210. delivery_path = run_dir / "segment_deliveries" / "Segment2.v3.json"
  211. package.parent.mkdir()
  212. delivery_path.parent.mkdir()
  213. package.write_text(
  214. json.dumps(
  215. {"segment_id": "Segment2", "plan_version": 3},
  216. ensure_ascii=False,
  217. ),
  218. encoding="utf-8",
  219. )
  220. monkeypatch.setattr(observe, "run", lambda **_kwargs: run_manager)
  221. monkeypatch.setattr(
  222. observe,
  223. "module",
  224. lambda *_args, **kwargs: (
  225. setattr(node_manager, "kwargs", kwargs) or node_manager
  226. ),
  227. )
  228. monkeypatch.setattr(
  229. langgraph,
  230. "graph_spec",
  231. lambda _graph: {"nodes": [], "edges": []},
  232. )
  233. with ProductionObservation(
  234. project_root=tmp_path,
  235. thread_id="round-production",
  236. input_sha256="d" * 64,
  237. protocol_version="0.7",
  238. execution_mode="fresh",
  239. graph=object(),
  240. input_path=delivery,
  241. shared_visual_anchor_path=anchor,
  242. run_dir=run_dir,
  243. settings=_settings(),
  244. ):
  245. observation = start_graph_node_observation(
  246. "execute_segment",
  247. {
  248. "run_id": "round-production",
  249. "output_dir": str(run_dir),
  250. "status": "RUNNING",
  251. "current_segment_id": "Segment2",
  252. "current_segment_package_path": str(package),
  253. "segment_records": {
  254. "Segment2": {
  255. "status": "ready",
  256. "active_plan_version": 3,
  257. }
  258. },
  259. },
  260. )
  261. delivery_path.write_text(
  262. json.dumps(
  263. {
  264. "summary": "Segment2 完整 Candidate",
  265. "audio_url": "https://internal.example/audio.mp3",
  266. },
  267. ensure_ascii=False,
  268. ),
  269. encoding="utf-8",
  270. )
  271. observation.finish(
  272. {
  273. "current_segment_delivery_path": str(delivery_path),
  274. "status": "RUNNING",
  275. "phase": "VALIDATE_SEGMENT",
  276. }
  277. )
  278. assert node_manager.kwargs["module_key"] == "execute_segment"
  279. assert node_manager.kwargs["branch_key"] == "Segment2.v3"
  280. assert node_manager.kwargs["view"].summary == "结论"
  281. assert node_manager.kwargs["view"].hide == []
  282. assert "正式产物" in {
  283. field.key for field in node_manager.kwargs["view"].fields
  284. }
  285. input_blocks = node_handle.declared[0]["blocks"]
  286. assert [block.key for block in input_blocks] == [
  287. "execute_segment_identity",
  288. "segment_package",
  289. ]
  290. assert "Segment:Segment2" in input_blocks[1].value
  291. assert not input_blocks[1].value.startswith("{")
  292. output, options = node_handle.outputs[0]
  293. assert "详情" not in output
  294. assert "Segment2 完整 Candidate" in node_handle.notes[0][0]
  295. assert "https://internal.example/audio.mp3" in node_handle.notes[0][0]
  296. assert "state_after" not in output
  297. assert options["images"] == "auto"