test_api.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. from fastapi.testclient import TestClient
  2. from content_agent import api
  3. from content_agent.integrations.mock_platform import MockPlatformClient
  4. from content_agent.run_service import RunService
  5. from tests.p1_helpers import FakeDemandSource, FakeQueryVariantClient
  6. def test_api_runs_and_queries_mock_chain(tmp_path, monkeypatch):
  7. monkeypatch.setattr(
  8. api,
  9. "service",
  10. RunService(
  11. runtime_root=tmp_path / "runtime" / "v1",
  12. demand_source=FakeDemandSource(),
  13. query_variant_client=FakeQueryVariantClient(),
  14. ),
  15. )
  16. client = TestClient(api.app)
  17. response = client.post("/runs", json={"platform": "douyin", "platform_mode": "mock"})
  18. assert response.status_code == 200
  19. payload = response.json()
  20. run_id = payload["run_id"]
  21. assert payload["platform_mode"] == "mock"
  22. assert payload["policy_run_id"].startswith("policy_run_")
  23. assert payload["policy_bundle_id"] == "douyin_policy_bundle_v1"
  24. assert payload["strategy_version"] == "V4"
  25. for path in [
  26. f"/runs/{run_id}",
  27. f"/runs/{run_id}/discovered-content-items",
  28. f"/runs/{run_id}/rule-decisions",
  29. f"/runs/{run_id}/source-path-records",
  30. f"/runs/{run_id}/final-output",
  31. f"/runs/{run_id}/strategy-review",
  32. f"/runs/{run_id}/validation",
  33. ]:
  34. get_response = client.get(path)
  35. assert get_response.status_code == 200, path
  36. review = client.get(f"/runs/{run_id}/strategy-review").json()["data"]
  37. # V4-M3: mock 链路用 query relevance + 平台可观测表现 50/50 打分。
  38. # M11 re-baseline:平台分改"量级+收缩比例"后,mock 首条进复看带 → 0 入池 / 1 复看 / 2 淘汰。
  39. assert review["summary"]["pooled_content_count"] == 0
  40. assert review["summary"]["review_content_count"] == 1
  41. assert review["summary"]["rejected_content_count"] == 2
  42. assert review["suggestions"]
  43. validation = client.get(f"/runs/{run_id}/validation").json()
  44. assert validation["status"] == "pass"
  45. summary = client.get(f"/runs/{run_id}").json()
  46. assert summary["validation_status"] == "pass"
  47. run_list = client.get("/runs").json()
  48. assert run_list["total"] == 1
  49. assert run_list["items"][0]["run_id"] == run_id
  50. assert run_list["data_origin"] == "runtime_export"
  51. dashboard = client.get(f"/runs/{run_id}/dashboard").json()
  52. assert dashboard["run_id"] == run_id
  53. assert dashboard["data_origin"] == "runtime_export"
  54. assert dashboard["counts"]["queries"] >= 1
  55. assert dashboard["runtime_files"]
  56. assert dashboard["business_summary"]["query_count"] >= 1
  57. assert {stage["stage_id"] for stage in dashboard["stage_conclusions"]} >= {
  58. "source",
  59. "query",
  60. "platform",
  61. "judge",
  62. "walk",
  63. "asset",
  64. "learning",
  65. }
  66. query_stage = next(stage for stage in dashboard["stage_conclusions"] if stage["stage_id"] == "query")
  67. assert "生成成功" in query_stage["metric"]
  68. assert "llm_variant" not in query_stage["metric"]
  69. source_stage = next(stage for stage in dashboard["stage_conclusions"] if stage["stage_id"] == "source")
  70. assert source_stage["detail"] == "需求池 ID:1"
  71. assert isinstance(dashboard["rule_application_summary"], list)
  72. v4_rule_rows = [
  73. row
  74. for row in dashboard["rule_application_summary"]
  75. if row.get("scorecard_schema_version") == "v4_scorecard.v1"
  76. ]
  77. assert v4_rule_rows
  78. assert all(row["v4_explanation"]["scorecard_schema_version"] == "v4_scorecard.v1" for row in v4_rule_rows)
  79. assert "nodes" in dashboard["walk_graph"]
  80. v4_gate_edges = [
  81. edge for edge in dashboard["walk_graph"]["edges"] if edge.get("v4_gate")
  82. ]
  83. assert all(isinstance(edge["v4_gate"], dict) for edge in v4_gate_edges)
  84. assert dashboard["technical_refs"]["runtime_files_url"].endswith("/runtime-files")
  85. queries = client.get(f"/runs/{run_id}/queries").json()
  86. assert queries["total"] >= 1
  87. assert queries["items"][0]["search_query_id"]
  88. content_items = client.get(f"/runs/{run_id}/content-items").json()
  89. assert content_items["total"] >= 1
  90. assert "rule_decision" in content_items["items"][0]
  91. timeline = client.get(f"/runs/{run_id}/timeline").json()
  92. assert timeline["total"] >= 1
  93. assert any(item["source"] == "run_events.jsonl" for item in timeline["items"])
  94. runtime_files = client.get(f"/runs/{run_id}/runtime-files").json()
  95. filenames = {item["filename"] for item in runtime_files["files"]}
  96. assert "search_queries.jsonl" in filenames
  97. runtime_file = client.get(f"/runs/{run_id}/runtime-files/search_queries.jsonl").json()
  98. assert runtime_file["records"]
  99. assert runtime_file["data_origin"] == "runtime_export"
  100. bad_runtime_file = client.get(f"/runs/{run_id}/runtime-files/not_allowed.jsonl")
  101. assert bad_runtime_file.status_code == 400
  102. assert bad_runtime_file.json()["detail"]["error_code"] == "INVALID_REQUEST"
  103. missing_dashboard = client.get("/runs/not-a-run/dashboard")
  104. assert missing_dashboard.status_code == 404
  105. assert missing_dashboard.json()["detail"]["error_code"] == "RUN_NOT_FOUND"
  106. def test_api_defaults_to_real_platform_mode_but_can_select_mock(tmp_path, monkeypatch):
  107. selected_modes = []
  108. def fake_platform_client(self, platform, platform_mode):
  109. selected_modes.append((platform, platform_mode))
  110. return MockPlatformClient()
  111. monkeypatch.setattr(RunService, "_platform_client", fake_platform_client)
  112. monkeypatch.setattr(
  113. api,
  114. "service",
  115. RunService(
  116. runtime_root=tmp_path / "runtime" / "v1",
  117. demand_source=FakeDemandSource(),
  118. query_variant_client=FakeQueryVariantClient(),
  119. ),
  120. )
  121. client = TestClient(api.app)
  122. default_response = client.post("/runs", json={})
  123. mock_response = client.post("/runs", json={"platform_mode": "mock"})
  124. assert default_response.status_code == 200
  125. assert default_response.json()["platform_mode"] == "real"
  126. assert mock_response.status_code == 200
  127. assert mock_response.json()["platform_mode"] == "mock"
  128. assert selected_modes == [("douyin", "real"), ("douyin", "mock")]
  129. def test_api_rejects_non_douyin_real_platform(tmp_path, monkeypatch):
  130. monkeypatch.setattr(
  131. api,
  132. "service",
  133. RunService(
  134. runtime_root=tmp_path / "runtime" / "v1",
  135. demand_source=FakeDemandSource(),
  136. query_variant_client=FakeQueryVariantClient(),
  137. ),
  138. )
  139. client = TestClient(api.app)
  140. response = client.post(
  141. "/runs", json={"platform": "other_platform", "platform_mode": "real"}
  142. )
  143. assert response.status_code == 400
  144. assert response.json()["detail"]["error_code"] == "INVALID_REQUEST"
  145. def test_api_returns_partial_success_as_successful_response(tmp_path, monkeypatch):
  146. service = RunService(
  147. runtime_root=tmp_path / "runtime" / "v1",
  148. demand_source=FakeDemandSource(),
  149. query_variant_client=FakeQueryVariantClient(),
  150. )
  151. service._platform_client = lambda platform, platform_mode: _PartialFailurePlatformClient()
  152. monkeypatch.setattr(api, "service", service)
  153. client = TestClient(api.app)
  154. response = client.post("/runs", json={"platform": "douyin", "platform_mode": "real"})
  155. assert response.status_code == 200
  156. payload = response.json()
  157. assert payload["status"] == "partial_success"
  158. summary = client.get(f"/runs/{payload['run_id']}").json()
  159. assert summary["status"] == "partial_success"
  160. def test_api_rejects_legacy_run_identifier_field(tmp_path, monkeypatch):
  161. monkeypatch.setattr(api, "service", RunService(runtime_root=tmp_path / "runtime" / "v1"))
  162. client = TestClient(api.app)
  163. legacy_run_key = "tr" + "ace_id"
  164. response = client.post(
  165. "/runs",
  166. json={"platform": "douyin", "platform_mode": "mock", legacy_run_key: "legacy_run_001"},
  167. )
  168. assert response.status_code == 422
  169. def test_api_lists_aigc_push_records(monkeypatch):
  170. class FakeRegistry:
  171. def list_records(self, **_kwargs):
  172. return {
  173. "items": [
  174. {
  175. "push_record_id": "aigc_push_run_001",
  176. "run_id": "run_001",
  177. "platform": "douyin",
  178. "push_status": "pushed",
  179. "plans": [],
  180. "items": [],
  181. }
  182. ],
  183. "total": 1,
  184. "data_origin": "production_db",
  185. }
  186. monkeypatch.setattr(api.AigcPushRegistry, "from_env", classmethod(lambda _cls: FakeRegistry()))
  187. client = TestClient(api.app)
  188. response = client.get("/aigc-push-records")
  189. assert response.status_code == 200
  190. payload = response.json()
  191. assert payload["total"] == 1
  192. assert payload["items"][0]["push_record_id"] == "aigc_push_run_001"
  193. class _PartialFailurePlatformClient:
  194. def __init__(self) -> None:
  195. self.mock = MockPlatformClient()
  196. def search(self, search_query: dict) -> list[dict]:
  197. if search_query["search_query_id"] == "q_002":
  198. raise RuntimeError("temporary platform failure")
  199. return self.mock.search(search_query)
  200. def test_api_timeline_includes_summary(tmp_path, monkeypatch):
  201. monkeypatch.setattr(
  202. api,
  203. "service",
  204. RunService(
  205. runtime_root=tmp_path / "runtime" / "v1",
  206. demand_source=FakeDemandSource(),
  207. query_variant_client=FakeQueryVariantClient(),
  208. ),
  209. )
  210. client = TestClient(api.app)
  211. run_id = client.post("/runs", json={"platform": "douyin", "platform_mode": "mock"}).json()["run_id"]
  212. timeline = client.get(f"/runs/{run_id}/timeline").json()
  213. summary = timeline["summary"]
  214. assert set(summary) == {
  215. "total_duration_ms",
  216. "stage_duration_ms",
  217. "query_failure_count",
  218. "platform_rate_limited_count",
  219. "decode_status_counts",
  220. "error_counts",
  221. "walk_status_counts",
  222. }
  223. assert summary["stage_duration_ms"]
  224. assert "stalled" not in timeline and "is_blocked" not in timeline
  225. def test_api_config_readonly_endpoints():
  226. client = TestClient(api.app)
  227. rule_packs = client.get("/config/rule-packs").json()
  228. assert rule_packs["source_file"].endswith("douyin_rule_packs.v1.json")
  229. dispatches = rule_packs["data"]["rule_pack_dispatch"]
  230. assert {
  231. (dispatch["dispatch_id"], dispatch["platform"], dispatch["target_entity"])
  232. for dispatch in dispatches
  233. } == {
  234. ("dispatch_content", "douyin", "Content"),
  235. ("dispatch_content_kuaishou", "kuaishou", "Content"),
  236. ("dispatch_content_shipinhao", "shipinhao", "Content"),
  237. }
  238. assert len(rule_packs["data"]["effect_status_mapping"]) == 7
  239. walk = client.get("/config/walk-strategy").json()
  240. assert len(walk["data"]["walk_rule_pack_binding"]) == 1
  241. assert len(walk["data"]["walk_edge_catalog"]) == 9
  242. prompts = client.get("/config/query-prompts").json()
  243. assert "douyin/V1" in prompts["data"]["profiles"]
  244. policy = client.get("/config/walk-policy").json()
  245. assert policy["source_file"].endswith("walk_policy.json")
  246. assert "edge_permissions" in policy["data"]
  247. assert "edge_budgets" in policy["data"]
  248. def test_api_dashboard_rule_summary_effect_status_not_none(tmp_path, monkeypatch):
  249. monkeypatch.setattr(
  250. api,
  251. "service",
  252. RunService(
  253. runtime_root=tmp_path / "runtime" / "v1",
  254. demand_source=FakeDemandSource(),
  255. query_variant_client=FakeQueryVariantClient(),
  256. ),
  257. )
  258. client = TestClient(api.app)
  259. run_id = client.post("/runs", json={"platform": "douyin", "platform_mode": "mock"}).json()["run_id"]
  260. dashboard = client.get(f"/runs/{run_id}/dashboard").json()
  261. summaries = dashboard["rule_application_summary"]
  262. assert summaries
  263. # 修复前 content_effect_status 恒为 None(decision 记录无该字段)。
  264. assert all(row["content_effect_status"] is not None for row in summaries)
  265. # walk_graph edge 带归属/执行分离字段。
  266. walk_edges = [e for e in dashboard["walk_graph"]["edges"] if e.get("rule_pack")]
  267. assert any("rule_pack_executed" in e for e in walk_edges)