test_metrics.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. from __future__ import annotations
  2. import json
  3. import tempfile
  4. import unittest
  5. from pathlib import Path
  6. from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
  7. from production_build_agents.run.metrics import (
  8. record_agent_messages,
  9. record_phase_duration,
  10. record_replan_reason,
  11. record_run_invocation,
  12. )
  13. class _NamedModel:
  14. model_name = "test-model"
  15. class RunMetricsTest(unittest.TestCase):
  16. def test_contract_correction_diffs_are_idempotent(self) -> None:
  17. before = {
  18. "payload": {"rule": "保持暖咖色"},
  19. "summary": "保持业务摘要",
  20. "artifact_binding_claims": [],
  21. "removed_field": "旧字段",
  22. }
  23. after = {
  24. "payload": {"rule": "保持暖咖色"},
  25. "summary": "保持业务摘要",
  26. "artifact_binding_claims": [{"expectation_id": "Expectation1"}],
  27. "added_field": "新字段",
  28. }
  29. media_review = {
  30. **after,
  31. "summary": "媒体自检允许修改的摘要",
  32. }
  33. messages = [
  34. AIMessage(id="candidate-1", content=json.dumps(before)),
  35. HumanMessage(content="上一版未通过运行时校验。错误:Binding 无效。"),
  36. AIMessage(
  37. id="candidate-2",
  38. content="```json\n" + json.dumps(after) + "\n```",
  39. ),
  40. HumanMessage(content="最终媒体证据自检:请查看图片。"),
  41. AIMessage(id="candidate-3", content=json.dumps(media_review)),
  42. ]
  43. with tempfile.TemporaryDirectory() as temp_dir:
  44. run_dir = Path(temp_dir)
  45. for _ in range(2):
  46. record_agent_messages(
  47. run_dir,
  48. run_id="Run-correction-metrics",
  49. role="executor",
  50. agent_run_id="Run-correction-executor-Task1-v1",
  51. model=_NamedModel(),
  52. messages=messages,
  53. )
  54. metrics = json.loads(
  55. (run_dir / "run_metrics.json").read_text(encoding="utf-8")
  56. )
  57. self.assertEqual(
  58. metrics["corrections"],
  59. [
  60. {
  61. "role": "executor",
  62. "agent_run_id": "Run-correction-executor-Task1-v1",
  63. "attempt": 2,
  64. "changed_fields": ["artifact_binding_claims"],
  65. "added_fields": ["added_field"],
  66. "removed_fields": ["removed_field"],
  67. }
  68. ],
  69. )
  70. def test_unparseable_correction_does_not_break_metrics(self) -> None:
  71. messages = [
  72. AIMessage(id="invalid-1", content="不是 JSON"),
  73. HumanMessage(content="上一版未通过运行时格式校验。"),
  74. AIMessage(id="valid-2", content='{"summary": "已修正"}'),
  75. ]
  76. with tempfile.TemporaryDirectory() as temp_dir:
  77. run_dir = Path(temp_dir)
  78. record_agent_messages(
  79. run_dir,
  80. run_id="Run-invalid-correction",
  81. role="validator",
  82. agent_run_id="validator-Task1-v1",
  83. model=_NamedModel(),
  84. messages=messages,
  85. )
  86. metrics = json.loads(
  87. (run_dir / "run_metrics.json").read_text(encoding="utf-8")
  88. )
  89. self.assertEqual(metrics["corrections"], [])
  90. def test_agent_messages_are_aggregated_idempotently(self) -> None:
  91. with tempfile.TemporaryDirectory() as temp_dir:
  92. run_dir = Path(temp_dir)
  93. messages = [
  94. AIMessage(
  95. id="ai-1",
  96. content="done",
  97. usage_metadata={
  98. "input_tokens": 11,
  99. "output_tokens": 7,
  100. "total_tokens": 18,
  101. },
  102. response_metadata={
  103. "model_name": "reported-model",
  104. "cost_usd": 0.0125,
  105. },
  106. ),
  107. ToolMessage(
  108. name="probe_media",
  109. tool_call_id="tool-1",
  110. content=json.dumps(
  111. {
  112. "success": True,
  113. "_duration_ms": 42,
  114. "_operation_replayed": True,
  115. }
  116. ),
  117. ),
  118. ]
  119. for _ in range(2):
  120. record_agent_messages(
  121. run_dir,
  122. run_id="Run-metrics",
  123. role="executor",
  124. agent_run_id="Run-metrics-executor-Task1-v1",
  125. model=_NamedModel(),
  126. messages=messages,
  127. )
  128. metrics = json.loads(
  129. (run_dir / "run_metrics.json").read_text(encoding="utf-8")
  130. )
  131. self.assertEqual(metrics["model_totals"]["calls"], 1)
  132. self.assertEqual(metrics["model_totals"]["total_tokens"], 18)
  133. self.assertEqual(
  134. metrics["model_totals"]["reported_cost_usd"],
  135. 0.0125,
  136. )
  137. self.assertEqual(
  138. metrics["model_totals"]["cost_status"],
  139. "reported",
  140. )
  141. self.assertEqual(
  142. metrics["models"]["executor"]["models"],
  143. ["reported-model"],
  144. )
  145. self.assertEqual(metrics["tools"]["calls"], 1)
  146. self.assertEqual(metrics["tools"]["successful_calls"], 1)
  147. self.assertEqual(metrics["tools"]["replayed_calls"], 1)
  148. self.assertEqual(metrics["tools"]["total_duration_ms"], 42)
  149. def test_phase_invocation_and_replan_reason_are_recorded(self) -> None:
  150. with tempfile.TemporaryDirectory() as temp_dir:
  151. run_dir = Path(temp_dir)
  152. record_phase_duration(
  153. run_dir,
  154. run_id="Run-metrics",
  155. phase="execute_task",
  156. duration_ms=51,
  157. outcome="RUNNING",
  158. )
  159. record_run_invocation(
  160. run_dir,
  161. run_id="Run-metrics",
  162. duration_ms=90,
  163. status="COMPLETED",
  164. )
  165. for _ in range(2):
  166. record_replan_reason(
  167. run_dir,
  168. run_id="Run-metrics",
  169. plan_version=1,
  170. failure_scope="task",
  171. details={
  172. "task_id": "Task1",
  173. "verdict": "FAIL",
  174. },
  175. )
  176. metrics = json.loads(
  177. (run_dir / "run_metrics.json").read_text(encoding="utf-8")
  178. )
  179. self.assertEqual(
  180. metrics["phases"]["execute_task"]["total_duration_ms"],
  181. 51,
  182. )
  183. self.assertEqual(metrics["run_invocations"]["count"], 1)
  184. self.assertEqual(
  185. metrics["run_invocations"]["last_status"],
  186. "COMPLETED",
  187. )
  188. self.assertEqual(len(metrics["replans"]), 1)
  189. self.assertEqual(
  190. metrics["replans"][0]["failure_scope"],
  191. "task",
  192. )
  193. def test_stable_phase_and_invocation_events_do_not_double_count(self) -> None:
  194. with tempfile.TemporaryDirectory() as temp_dir:
  195. run_dir = Path(temp_dir)
  196. for _ in range(2):
  197. record_phase_duration(
  198. run_dir,
  199. run_id="Run-resume-metrics",
  200. phase="SEGMENT_VALIDATOR",
  201. duration_ms=20,
  202. outcome="PASS",
  203. event_key="Segment2:v1:validator",
  204. )
  205. record_run_invocation(
  206. run_dir,
  207. run_id="Run-resume-metrics",
  208. duration_ms=30,
  209. status="COMPLETED",
  210. event_key="Segment2:v1:terminal",
  211. )
  212. metrics = json.loads(
  213. (run_dir / "run_metrics.json").read_text(encoding="utf-8")
  214. )
  215. self.assertEqual(
  216. metrics["phases"]["SEGMENT_VALIDATOR"]["calls"],
  217. 1,
  218. )
  219. self.assertEqual(metrics["run_invocations"]["count"], 1)
  220. if __name__ == "__main__":
  221. unittest.main()