test_readable_events.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. from __future__ import annotations
  2. import json
  3. from types import SimpleNamespace
  4. from langchain.agents import create_agent
  5. from langchain_core.language_models.fake_chat_models import (
  6. FakeMessagesListChatModel,
  7. )
  8. from langchain_core.messages import (
  9. AIMessage,
  10. HumanMessage,
  11. SystemMessage,
  12. ToolMessage,
  13. )
  14. from production_build_agents.observability.readable_events import (
  15. BusinessReadableFlowCollector,
  16. readable_value,
  17. tool_title,
  18. )
  19. from production_build_agents.observability.business_views import (
  20. business_response_title,
  21. )
  22. from production_build_agents.agents.invocation import build_agent_run_config
  23. from production_build_agents.run.langgraph_checkpointer import (
  24. create_in_memory_checkpointer,
  25. )
  26. class _ModuleContext:
  27. def __init__(self) -> None:
  28. self.events: list[tuple[int, str, dict]] = []
  29. self.messages: list[tuple[list, int]] = []
  30. def emit_at(self, idx: int, type_: str, **fields) -> None:
  31. self.events.append((idx, type_, fields))
  32. def record_messages(self, messages: list, *, idx: int) -> None:
  33. self.messages.append((messages, idx))
  34. class _Run:
  35. def __init__(self) -> None:
  36. self.usage = (0, 0)
  37. def account(self, input_tokens: int, output_tokens: int) -> None:
  38. self.usage = (input_tokens, output_tokens)
  39. def test_readable_value_turns_json_into_business_sentences() -> None:
  40. text = readable_value(
  41. '{"success": true, "task_id": "Task2", '
  42. '"status": "delivered", "output_path": "/run/result.json"}'
  43. )
  44. assert "结果:成功" in text
  45. assert "Task:Task2" in text
  46. assert "状态:delivered" in text
  47. assert not text.startswith("{")
  48. def test_fenced_global_data_plan_is_rendered_as_business_logic() -> None:
  49. plan = {
  50. "schema_version": "0.3",
  51. "plan_id": "global-data-plan",
  52. "plan_version": 1,
  53. "goal": "准备正式生产所需的共享素材与约束",
  54. "stage_requirements": [
  55. {
  56. "requirement_id": "Requirement1",
  57. "description": "确认人物基准图真实可用",
  58. "artifact_expectations": [
  59. {
  60. "artifact_type": "image",
  61. "minimum_count": 1,
  62. "source_asset_ids": ["SourceAsset-" + "a" * 64],
  63. }
  64. ],
  65. }
  66. ],
  67. "tasks": [
  68. {
  69. "task_id": "Task1",
  70. "objective": "检查并采纳人物基准图",
  71. "skill_id": "reference-inspection",
  72. "expectation_ids": ["Requirement1-Expectation1"],
  73. "depends_on": [],
  74. }
  75. ],
  76. "revision_summary": "先验收共享素材,再进入正式生产",
  77. }
  78. text = readable_value(
  79. "```json\n"
  80. + json.dumps(plan, ensure_ascii=False)
  81. + "\n```"
  82. )
  83. assert "规划目标:准备正式生产所需的共享素材与约束" in text
  84. assert "Requirement1|确认人物基准图真实可用" in text
  85. assert "Task1|检查并采纳人物基准图" in text
  86. assert "stage_requirements" not in text
  87. assert "```json" not in text
  88. def test_validator_candidate_without_verdict_has_business_title() -> None:
  89. candidate = {
  90. "task_id": "Task3",
  91. "criterion_results": [
  92. {
  93. "criterion_id": "Criterion1",
  94. "passed": True,
  95. "reason": "媒体证据完整",
  96. }
  97. ],
  98. "summary": "任务满足验收要求",
  99. }
  100. text = readable_value(json.dumps(candidate, ensure_ascii=False))
  101. assert business_response_title(candidate) == "模型形成 Task 验收结论"
  102. assert "业务摘要:任务满足验收要求" in text
  103. assert "criterion_results" not in text
  104. def test_tool_names_have_business_titles() -> None:
  105. assert tool_title("read_production_brief") == "读取 Production Brief"
  106. assert tool_title("assemble_segment_media") == "组装 Segment 媒体"
  107. assert tool_title("custom_business_action") == "Custom Business Action"
  108. def test_llm_tool_decision_card_is_readable_and_keeps_raw_messages() -> None:
  109. run = _Run()
  110. context = _ModuleContext()
  111. collector = BusinessReadableFlowCollector(run, auto_module=False)
  112. collector._frame = lambda *_args: (context, 3)
  113. collector._pending_msgs["llm-1"] = [
  114. AIMessage(content="上一步上下文")
  115. ]
  116. collector._model_of["llm-1"] = "test-model"
  117. message = AIMessage(
  118. content="",
  119. tool_calls=[
  120. {
  121. "id": "call-1",
  122. "name": "read_production_brief",
  123. "args": {"source_paths": ["subject.title", "shots"]},
  124. }
  125. ],
  126. usage_metadata={
  127. "input_tokens": 20,
  128. "output_tokens": 5,
  129. "total_tokens": 25,
  130. },
  131. )
  132. response = SimpleNamespace(
  133. generations=[[SimpleNamespace(message=message)]],
  134. llm_output={},
  135. )
  136. collector.on_llm_end(response, run_id="llm-1")
  137. _, type_, event = context.events[0]
  138. assert type_ == "llm"
  139. assert event["label"] == "模型决定调用:读取 Production Brief"
  140. assert "读取 Production Brief" in event["content"]
  141. assert event["payload"]["tool_calls"][0]["args_summary"] == (
  142. "来源:subject.title、shots"
  143. )
  144. assert context.messages[0][0][-1] is message
  145. assert run.usage == (20, 5)
  146. def test_llm_start_snapshot_and_terminal_are_joinable_without_raw_content() -> None:
  147. run = _Run()
  148. context = _ModuleContext()
  149. collector = BusinessReadableFlowCollector(run, auto_module=False)
  150. collector._frame = lambda *_args: (context, 3)
  151. collector.on_chat_model_start(
  152. {"name": "test-model"},
  153. [[
  154. SystemMessage(content="系统提示词秘密"),
  155. HumanMessage(content="用户输入秘密"),
  156. ]],
  157. run_id="llm-context",
  158. metadata={
  159. "business_run_id": "run-1",
  160. "agent_run_id": "agent-1",
  161. "domain": "production",
  162. "role": "segment_executor",
  163. "segment_id": "Segment1",
  164. "plan_version": 1,
  165. },
  166. invocation_params={"tools": []},
  167. )
  168. response_message = AIMessage(
  169. content="模型输出秘密",
  170. usage_metadata={
  171. "input_tokens": 30,
  172. "output_tokens": 5,
  173. "total_tokens": 35,
  174. },
  175. )
  176. collector.on_llm_end(
  177. SimpleNamespace(
  178. generations=[[SimpleNamespace(message=response_message)]],
  179. llm_output={},
  180. ),
  181. run_id="llm-context",
  182. )
  183. assert [event[1] for event in context.events] == ["note", "llm"]
  184. start = context.events[0][2]["payload"]
  185. terminal = context.events[1][2]["payload"]["context_metrics"]
  186. assert start["event_kind"] == "model_context_start"
  187. assert terminal["event_kind"] == "model_context_terminal"
  188. assert start["physical_call_id"] == terminal["physical_call_id"]
  189. assert start["identity"]["segment_id"] == "Segment1"
  190. assert terminal["usage"]["input_tokens"] == 30
  191. assert len(context.messages) == 1
  192. serialized_start = json.dumps(start, ensure_ascii=False)
  193. assert "系统提示词秘密" not in serialized_start
  194. assert "用户输入秘密" not in serialized_start
  195. def test_llm_failure_closes_started_context_and_keeps_input_once() -> None:
  196. context = _ModuleContext()
  197. collector = BusinessReadableFlowCollector(_Run(), auto_module=False)
  198. collector._frame = lambda *_args: (context, 4)
  199. collector.on_chat_model_start(
  200. {"name": "test-model"},
  201. [[HumanMessage(content="will fail")]],
  202. run_id="llm-failure",
  203. metadata={
  204. "business_run_id": "run-1",
  205. "agent_run_id": "agent-1",
  206. "role": "planner",
  207. },
  208. )
  209. collector.on_llm_error(
  210. TimeoutError("provider timeout"),
  211. run_id="llm-failure",
  212. )
  213. assert [event[1] for event in context.events] == ["note", "llm"]
  214. failure = context.events[1][2]
  215. assert failure["ok"] is False
  216. terminal = failure["payload"]["context_metrics"]
  217. assert terminal["status"] == "failure"
  218. assert terminal["error_type"] == "TimeoutError"
  219. assert len(context.messages) == 1
  220. assert len(context.messages[0][0]) == 1
  221. def test_langgraph_config_metadata_reaches_real_model_callback() -> None:
  222. context = _ModuleContext()
  223. collector = BusinessReadableFlowCollector(_Run(), auto_module=False)
  224. collector._frame = lambda *_args: (context, 2)
  225. model = FakeMessagesListChatModel(
  226. responses=[AIMessage(content="done")],
  227. callbacks=[collector],
  228. )
  229. agent = create_agent(
  230. model=model,
  231. tools=[],
  232. system_prompt="system",
  233. checkpointer=create_in_memory_checkpointer(),
  234. )
  235. config = build_agent_run_config(
  236. business_run_id="run-1",
  237. agent_run_id="run-1:Segment1:v1:validator",
  238. domain="production",
  239. role="segment_validator",
  240. invocation_mode="VALIDATE",
  241. segment_id="Segment1",
  242. plan_version=1,
  243. )
  244. agent.invoke(
  245. {"messages": [{"role": "user", "content": "validate"}]},
  246. config,
  247. )
  248. start = next(
  249. fields["payload"]
  250. for _, type_, fields in context.events
  251. if type_ == "note"
  252. )
  253. assert start["identity"] == config["metadata"]
  254. def test_llm_plan_card_has_business_title_and_keeps_raw_message() -> None:
  255. run = _Run()
  256. context = _ModuleContext()
  257. collector = BusinessReadableFlowCollector(run, auto_module=False)
  258. collector._frame = lambda *_args: (context, 4)
  259. collector._pending_msgs["llm-plan"] = [
  260. AIMessage(content="规划上下文")
  261. ]
  262. plan_json = (
  263. '```json\n{"schema_version":"0.3","plan_id":"plan-1",'
  264. '"plan_version":1,"goal":"准备共享素材",'
  265. '"stage_requirements":[],"tasks":[]}\n```'
  266. )
  267. message = AIMessage(content=plan_json)
  268. response = SimpleNamespace(
  269. generations=[[SimpleNamespace(message=message)]],
  270. llm_output={},
  271. )
  272. collector.on_llm_end(response, run_id="llm-plan")
  273. _, type_, event = context.events[0]
  274. assert type_ == "llm"
  275. assert event["label"] == "初版候选 · Global Data 规划"
  276. assert event["content"].startswith("规划目标:准备共享素材")
  277. assert "```json" not in event["content"]
  278. assert event["payload"]["business_phase"] == "initial_candidate"
  279. assert context.messages[0][0][-1] is message
  280. def test_contract_correction_is_labeled_before_upload() -> None:
  281. run = _Run()
  282. context = _ModuleContext()
  283. collector = BusinessReadableFlowCollector(run, auto_module=False)
  284. collector._frame = lambda *_args: (context, 5)
  285. collector._pending_msgs["llm-correction"] = [
  286. HumanMessage(content='{"task_id":"Task3"}'),
  287. AIMessage(content='{"summary":"初版"}'),
  288. HumanMessage(
  289. content=(
  290. "上一版未通过运行时校验。错误:"
  291. "missing_binding_evidence: 该 Expectation 必须声明工具证据。"
  292. )
  293. ),
  294. ]
  295. corrected = AIMessage(
  296. content=json.dumps(
  297. {
  298. "task_id": "Task3",
  299. "artifacts": [{"uri": "/run/image.png"}],
  300. "artifact_binding_claims": [
  301. {
  302. "expectation_id": "Expectation1",
  303. "evidence_tool_call_ids": ["call-1"],
  304. }
  305. ],
  306. "summary": "已补充工具证据",
  307. },
  308. ensure_ascii=False,
  309. ),
  310. usage_metadata={
  311. "input_tokens": 30,
  312. "output_tokens": 8,
  313. "total_tokens": 38,
  314. },
  315. )
  316. response = SimpleNamespace(
  317. generations=[[SimpleNamespace(message=corrected)]],
  318. llm_output={},
  319. )
  320. collector.on_llm_end(response, run_id="llm-correction")
  321. _, type_, event = context.events[0]
  322. assert type_ == "llm"
  323. assert event["label"] == "合同校正 · 第 1 次"
  324. assert event["content"].startswith(
  325. "校正原因:Artifact Binding 缺少工具调用证据\n校正结果:"
  326. )
  327. assert event["payload"]["business_phase"] == "contract_correction"
  328. assert event["payload"]["attempt"] == 2
  329. assert event["payload"]["previous_candidate_ok"] is False
  330. assert event["payload"]["correction_reason_codes"] == [
  331. "missing_binding_evidence"
  332. ]
  333. def test_media_review_is_labeled_and_reports_scope() -> None:
  334. run = _Run()
  335. context = _ModuleContext()
  336. collector = BusinessReadableFlowCollector(run, auto_module=False)
  337. collector._frame = lambda *_args: (context, 6)
  338. collector._pending_msgs["llm-media-review"] = [
  339. HumanMessage(content='{"task_id":"Task3"}'),
  340. HumanMessage(
  341. content=[
  342. {
  343. "type": "text",
  344. "text": (
  345. "最终媒体证据自检:"
  346. '{"artifact_uris":["/run/image.png","/run/video.mp4"]}'
  347. ),
  348. },
  349. {
  350. "type": "image_url",
  351. "image_url": {"url": "data:image/png;base64,AA=="},
  352. },
  353. ]
  354. ),
  355. ]
  356. reviewed = AIMessage(
  357. content=json.dumps(
  358. {
  359. "task_id": "Task3",
  360. "artifacts": [
  361. {"uri": "/run/image.png"},
  362. {"uri": "/run/video.mp4"},
  363. ],
  364. "artifact_binding_claims": [],
  365. "summary": "媒体内容与制作要求一致",
  366. },
  367. ensure_ascii=False,
  368. )
  369. )
  370. response = SimpleNamespace(
  371. generations=[[SimpleNamespace(message=reviewed)]],
  372. llm_output={},
  373. )
  374. collector.on_llm_end(response, run_id="llm-media-review")
  375. _, type_, event = context.events[0]
  376. assert type_ == "llm"
  377. assert event["label"] == "媒体证据复核"
  378. assert event["content"].startswith(
  379. "复核范围:2 个媒体产物\n复核结果:"
  380. )
  381. assert event["payload"]["business_phase"] == "media_evidence_review"
  382. assert event["payload"]["reviewed_media_count"] == 2
  383. def test_correction_tool_call_keeps_action_and_adds_phase() -> None:
  384. run = _Run()
  385. context = _ModuleContext()
  386. collector = BusinessReadableFlowCollector(run, auto_module=False)
  387. collector._frame = lambda *_args: (context, 7)
  388. collector._pending_msgs["llm-correction-tool"] = [
  389. HumanMessage(
  390. content="上一版未通过运行时格式校验。错误:缺少字段。"
  391. )
  392. ]
  393. message = AIMessage(
  394. content="重新检查图片",
  395. tool_calls=[
  396. {
  397. "id": "call-2",
  398. "name": "view_images",
  399. "args": {"image_sources": ["/run/image.png"]},
  400. }
  401. ],
  402. )
  403. response = SimpleNamespace(
  404. generations=[[SimpleNamespace(message=message)]],
  405. llm_output={},
  406. )
  407. collector.on_llm_end(response, run_id="llm-correction-tool")
  408. _, type_, event = context.events[0]
  409. assert type_ == "llm"
  410. assert event["label"] == "校正中 · 模型决定调用:查看图片"
  411. assert event["payload"]["business_phase"] == "contract_correction"
  412. assert event["payload"]["tool_calls"][0]["name"] == "view_images"
  413. def test_tool_result_card_uses_summary_instead_of_raw_json() -> None:
  414. context = _ModuleContext()
  415. collector = BusinessReadableFlowCollector(_Run(), auto_module=False)
  416. collector._frame = lambda *_args: (context, 2)
  417. collector._pending_tool["tool-1"] = {
  418. "name": "probe_media",
  419. "args": {"artifact_uri": "/run/segment.mp4"},
  420. "tok": None,
  421. }
  422. output = ToolMessage(
  423. content=(
  424. '{"success": true, "status": "ready", '
  425. '"duration_ms": 1250, "video_url": "https://media/segment.mp4"}'
  426. ),
  427. tool_call_id="call-1",
  428. name="probe_media",
  429. )
  430. collector.on_tool_end(output, run_id="tool-1")
  431. _, type_, event = context.events[0]
  432. assert type_ == "tool"
  433. assert event["label"] == "检查媒体技术信息 · 已返回"
  434. assert event["tool_args"] == "Artifact:/run/segment.mp4"
  435. assert "结果:成功" in event["tool_result"]
  436. assert "状态:ready" in event["tool_result"]
  437. assert not event["tool_result"].startswith("{")
  438. contribution = event["payload"]["context_contribution"]
  439. assert contribution["message_role"] == "tool"
  440. assert contribution["serialized_bytes"] > 0
  441. assert "segment.mp4" not in json.dumps(contribution)