test_global_data_context.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  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.global_data import (
  8. GlobalDataObservation,
  9. start_graph_node_observation,
  10. )
  11. from production_build_agents.observability import global_data
  12. from production_build_agents.observability import collector as collector_module
  13. class _RunHandle:
  14. def __init__(self) -> None:
  15. self.finished: list[dict] = []
  16. def finish(self, **payload) -> None:
  17. self.finished.append(payload)
  18. class _RunManager:
  19. def __init__(self, handle: _RunHandle) -> None:
  20. self.handle = handle
  21. self.exits: list[tuple] = []
  22. def __enter__(self) -> _RunHandle:
  23. return self.handle
  24. def __exit__(self, *args):
  25. self.exits.append(args)
  26. if args[1] is not None:
  27. raise args[1]
  28. return False
  29. class _ModuleHandle:
  30. def __init__(self) -> None:
  31. self.declared: list[dict] = []
  32. self.outputs: list[tuple[object, dict]] = []
  33. self.notes: list[tuple[str, dict]] = []
  34. def declare(self, **payload) -> None:
  35. self.declared.append(payload)
  36. def set_output(self, output, **payload) -> None:
  37. self.outputs.append((output, payload))
  38. def record_note(self, text: str, **payload) -> None:
  39. self.notes.append((text, payload))
  40. def test_global_data_context_forces_sanitized_manual_collection(
  41. monkeypatch,
  42. tmp_path: Path,
  43. ) -> None:
  44. captured: dict = {}
  45. handle = _RunHandle()
  46. manager = _RunManager(handle)
  47. def fake_run(**kwargs):
  48. captured.update(kwargs)
  49. return manager
  50. monkeypatch.setattr(observe, "run", fake_run)
  51. monkeypatch.setattr(
  52. langgraph,
  53. "graph_spec",
  54. lambda _graph: {"nodes": [{"key": "preprocess"}], "edges": []},
  55. )
  56. settings = SimpleNamespace(
  57. configure_sdk=lambda: True,
  58. project="safe-project",
  59. project_name="安全项目",
  60. capture_mode="redacted",
  61. )
  62. result = {
  63. "run_id": "safe-run",
  64. "protocol_version": "0.3",
  65. "status": "COMPLETED",
  66. "phase": "FINALIZE",
  67. "input_path": "/private/客户标题.json",
  68. "output_dir": str(tmp_path),
  69. "event_log": ["客户正文"],
  70. }
  71. with GlobalDataObservation(
  72. project_root=tmp_path,
  73. thread_id="safe-run",
  74. input_sha256="a" * 64,
  75. protocol_version="0.3",
  76. execution_mode="fresh",
  77. graph=object(),
  78. settings=settings,
  79. ) as observation:
  80. observation.finish(result, graph_invoked=True)
  81. encoded = json.dumps(
  82. {"run": captured, "finish": handle.finished},
  83. ensure_ascii=False,
  84. )
  85. assert captured["auto_collect"] is False
  86. assert captured["round_anchor"] == {
  87. "in": "run",
  88. "on": ["prepare_next_task"],
  89. }
  90. assert captured["payload"]["input_sha256"] == "a" * 64
  91. assert captured["meta"]["capture_mode"] == "redacted_allowlist_v1"
  92. assert handle.finished[0]["ok"] is True
  93. assert "/private/客户标题.json" not in encoded
  94. assert "客户正文" not in encoded
  95. def test_global_data_context_full_capture_reports_real_records(
  96. monkeypatch,
  97. tmp_path: Path,
  98. ) -> None:
  99. captured: dict = {}
  100. collector_calls: list[tuple[str, object]] = []
  101. handle = _RunHandle()
  102. manager = _RunManager(handle)
  103. input_path = tmp_path / "production_final.json"
  104. delivery_path = tmp_path / "GlobalDataStageDelivery.v1.json"
  105. input_path.write_text(
  106. json.dumps(
  107. {
  108. "title": "客户真实标题",
  109. "reference_url": "https://internal.example/input.png",
  110. },
  111. ensure_ascii=False,
  112. ),
  113. encoding="utf-8",
  114. )
  115. delivery_path.write_text(
  116. json.dumps(
  117. {
  118. "summary": "完整交付正文",
  119. "media_url": "https://internal.example/output.mp4",
  120. },
  121. ensure_ascii=False,
  122. ),
  123. encoding="utf-8",
  124. )
  125. def fake_run(**kwargs):
  126. captured.update(kwargs)
  127. return manager
  128. monkeypatch.setattr(observe, "run", fake_run)
  129. monkeypatch.setattr(
  130. collector_module,
  131. "_start_business_collector",
  132. lambda run_handle: (
  133. collector_calls.append(("start", run_handle))
  134. or "collector-token"
  135. ),
  136. )
  137. monkeypatch.setattr(
  138. collector_module,
  139. "_stop_business_collector",
  140. lambda token: collector_calls.append(("stop", token)),
  141. )
  142. monkeypatch.setattr(
  143. langgraph,
  144. "graph_spec",
  145. lambda _graph: {"nodes": [{"key": "preprocess"}], "edges": []},
  146. )
  147. settings = SimpleNamespace(
  148. configure_sdk=lambda: True,
  149. project="full-project",
  150. project_name="完整观测项目",
  151. capture_mode="full",
  152. )
  153. result = {
  154. "run_id": "full-run",
  155. "protocol_version": "0.3",
  156. "status": "COMPLETED",
  157. "phase": "FINALIZE",
  158. "input_path": str(input_path),
  159. "output_dir": str(tmp_path),
  160. "global_data_delivery_path": str(delivery_path),
  161. "event_log": ["完整事件正文"],
  162. }
  163. with GlobalDataObservation(
  164. project_root=tmp_path,
  165. thread_id="full-run",
  166. input_sha256="b" * 64,
  167. protocol_version="0.3",
  168. execution_mode="fresh",
  169. graph=object(),
  170. input_path=input_path,
  171. parent_run_id="pipeline-run",
  172. parent_inst_id="global-stage",
  173. pipeline_round_id="round-1",
  174. settings=settings,
  175. ) as observation:
  176. observation.finish(result, graph_invoked=True)
  177. encoded = json.dumps(
  178. {"run": captured, "finish": handle.finished},
  179. ensure_ascii=False,
  180. )
  181. assert captured["auto_collect"] is False
  182. assert captured["round_anchor"] == {
  183. "in": "run",
  184. "on": ["prepare_next_task"],
  185. }
  186. assert captured["parent_run_id"] == "pipeline-run"
  187. assert captured["parent_inst_id"] == "global-stage"
  188. assert captured["payload"]["pipeline_round_id"] == "round-1"
  189. assert collector_calls == [
  190. ("start", handle),
  191. ("stop", "collector-token"),
  192. ]
  193. assert captured["meta"]["capture_mode"] == "full_v1"
  194. assert captured["payload"]["input"]["content"]["title"] == (
  195. "客户真实标题"
  196. )
  197. assert handle.finished[0]["ok"] is True
  198. assert "客户真实标题" in encoded
  199. assert "完整交付正文" not in encoded
  200. assert "https://internal.example/output.mp4" not in encoded
  201. assert handle.finished[0]["final_output"]["结论"] == "已完成"
  202. assert "state_after" not in encoded
  203. def test_full_node_capture_expands_records_and_enables_media_scan(
  204. monkeypatch,
  205. tmp_path: Path,
  206. ) -> None:
  207. task_path = tmp_path / "Task1.TaskPackage.v1.json"
  208. delivery_path = tmp_path / "Task1.ExecutorDelivery.v1.json"
  209. task_path.write_text(
  210. json.dumps(
  211. {"task_id": "Task1", "plan_version": 1},
  212. ensure_ascii=False,
  213. ),
  214. encoding="utf-8",
  215. )
  216. delivery_path.write_text(
  217. json.dumps(
  218. {
  219. "text": "完整工具交付",
  220. "image_url": "https://internal.example/generated.png",
  221. },
  222. ensure_ascii=False,
  223. ),
  224. encoding="utf-8",
  225. )
  226. handle = _ModuleHandle()
  227. manager = _RunManager(handle)
  228. monkeypatch.setattr(observe, "module", lambda *_args, **_kwargs: manager)
  229. owner = SimpleNamespace(
  230. active=True,
  231. full_capture=True,
  232. warn_once=lambda *_args, **_kwargs: None,
  233. )
  234. token = global_data._ACTIVE.set(owner)
  235. try:
  236. observation = start_graph_node_observation(
  237. "execute_task",
  238. {
  239. "run_id": "full-run",
  240. "current_task_path": str(task_path),
  241. },
  242. )
  243. observation.finish(
  244. {
  245. "current_executor_delivery_path": str(delivery_path),
  246. "status": "RUNNING",
  247. "phase": "VALIDATE_TASK",
  248. "event_log": ["Executor 完整事件"],
  249. }
  250. )
  251. finally:
  252. global_data._ACTIVE.reset(token)
  253. input_blocks = handle.declared[0]["blocks"]
  254. assert [block.key for block in input_blocks] == [
  255. "execute_task_identity",
  256. "task_package",
  257. ]
  258. assert "Task:Task1" in input_blocks[1].value
  259. assert not input_blocks[1].value.startswith("{")
  260. assert "详情" not in handle.outputs[0][0]
  261. assert "完整工具交付" in handle.notes[0][0]
  262. assert handle.notes[0][1]["label"] == "正式记录详情"
  263. assert handle.outputs[0][0]["执行结果"] == "Executor 完整事件"
  264. assert "state_after" not in handle.outputs[0][0]
  265. assert handle.outputs[0][1]["images"] == "auto"