test_p0d_p0g.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  1. from __future__ import annotations
  2. import json
  3. from pathlib import Path
  4. from typing import Any
  5. from fastapi.testclient import TestClient
  6. from content_agent import api
  7. from content_agent.errors import ErrorCode
  8. from content_agent.integrations.composite_runtime import CompositeRuntimeStore
  9. from content_agent.integrations.database_runtime import ContentSupplyDbConfig
  10. from content_agent.integrations.demand_source import DemandSourceService
  11. from content_agent.integrations.crawapi_http import CrawapiBusinessError
  12. from content_agent.integrations.mock_platform import MockPlatformClient
  13. from content_agent.integrations.runtime_files import LocalRuntimeFileStore
  14. from content_agent.run_service import RunService
  15. from content_agent.schemas import RunStartRequest
  16. from tests.p1_helpers import (
  17. FakeDemandSource,
  18. FakeQueryVariantClient,
  19. REAL_SOURCE_FIXTURE,
  20. real_source_payload,
  21. )
  22. def test_composite_runtime_writes_primary_before_local(tmp_path):
  23. primary = _FakeRuntimeStore()
  24. export = _FakeRuntimeStore(run_dir=tmp_path / "export")
  25. store = CompositeRuntimeStore(primary, export)
  26. store.write_json("run_001", "final_output.json", {"run_id": "run_001"})
  27. store.append_jsonl("run_001", "run_events.jsonl", [{"run_id": "run_001"}])
  28. assert primary.calls == [
  29. ("write_json", "final_output.json"),
  30. ("append_jsonl", "run_events.jsonl"),
  31. ]
  32. assert export.calls == [
  33. ("write_json", "final_output.json"),
  34. ("append_jsonl", "run_events.jsonl"),
  35. ]
  36. def test_composite_runtime_db_failure_blocks_local_export(tmp_path):
  37. primary = _FakeRuntimeStore(fail_writes=True)
  38. export = _FakeRuntimeStore(run_dir=tmp_path / "export")
  39. store = CompositeRuntimeStore(primary, export)
  40. try:
  41. store.write_json("run_001", "final_output.json", {"run_id": "run_001"})
  42. except RuntimeError as exc:
  43. assert "primary failed" in str(exc)
  44. else:
  45. raise AssertionError("expected primary write failure")
  46. assert primary.calls == [("write_json", "final_output.json")]
  47. assert export.calls == []
  48. def test_composite_runtime_reads_primary_before_local_export(tmp_path):
  49. primary = LocalRuntimeFileStore(tmp_path / "primary")
  50. export = LocalRuntimeFileStore(tmp_path / "export")
  51. primary.prepare_run("run_001")
  52. export.prepare_run("run_001")
  53. primary.write_json("run_001", "final_output.json", {"run_id": "run_001", "source": "db"})
  54. export.write_json("run_001", "final_output.json", {"run_id": "run_001", "source": "local"})
  55. primary.append_jsonl("run_001", "run_events.jsonl", [{"run_id": "run_001", "source": "db"}])
  56. export.append_jsonl("run_001", "run_events.jsonl", [{"run_id": "run_001", "source": "local"}])
  57. store = CompositeRuntimeStore(primary, export)
  58. assert store.read_json("run_001", "final_output.json")["source"] == "db"
  59. assert store.read_jsonl("run_001", "run_events.jsonl")[0]["source"] == "db"
  60. assert store.file_status("run_001")["final_output.json"] is True
  61. def test_composite_runtime_falls_back_to_local_export_on_primary_read_failure(tmp_path):
  62. primary = _ReadFailingRuntimeStore()
  63. export = LocalRuntimeFileStore(tmp_path / "export")
  64. export.prepare_run("run_001")
  65. export.write_json("run_001", "final_output.json", {"run_id": "run_001", "source": "local"})
  66. export.append_jsonl("run_001", "run_events.jsonl", [{"run_id": "run_001", "source": "local"}])
  67. store = CompositeRuntimeStore(primary, export)
  68. assert store.read_json("run_001", "final_output.json")["source"] == "local"
  69. assert store.read_jsonl("run_001", "run_events.jsonl")[0]["source"] == "local"
  70. assert store.file_status("run_001")["final_output.json"] is True
  71. def test_run_service_success_records_run_policy_and_lifecycle_events(tmp_path):
  72. runtime = _SpyRuntimeStore(tmp_path / "runtime")
  73. demand_source = FakeDemandSource(real_source_payload(demand_content_id=123))
  74. service = RunService(
  75. runtime=runtime,
  76. demand_source=demand_source,
  77. query_variant_client=FakeQueryVariantClient(),
  78. )
  79. state = service.start_run(RunStartRequest(platform_mode="mock"))
  80. assert state["status"] == "success"
  81. assert demand_source.calls == ["get_default_pg_pattern_source"]
  82. assert service.read_json(state["run_id"], "source_context.json")["demand_content_id"] == "123"
  83. assert runtime.run_records[0]["status"] == "running"
  84. assert runtime.run_records[0]["source_ref"]["source_type"] == "demand_content_default"
  85. assert runtime.run_updates[0]["updates"]["demand_content_id"] == 123
  86. assert runtime.run_updates[0]["updates"]["source_ref"]["demand_content_id"] == 123
  87. assert runtime.run_updates[-1]["updates"]["status"] == "success"
  88. assert runtime.policy_runs[0]["policy_bundle_id"] == "douyin_policy_bundle_v1"
  89. assert runtime.policy_runs[0]["decision_summary"]["decision_action_counts"]
  90. event_ids = [event["event_id"] for event in runtime.lifecycle_events]
  91. assert event_ids == ["lifecycle_start", "lifecycle_success"]
  92. assert not any(event_id.startswith("evt_") for event_id in event_ids)
  93. assert runtime.lifecycle_events[0]["raw_payload"]["source_ref"]["demand_content_id"] == 123
  94. def test_run_service_partial_platform_failure_records_partial_success(tmp_path):
  95. runtime = _SpyRuntimeStore(tmp_path / "runtime")
  96. demand_source = FakeDemandSource(real_source_payload(demand_content_id=123))
  97. service = RunService(
  98. runtime=runtime,
  99. demand_source=demand_source,
  100. query_variant_client=FakeQueryVariantClient(),
  101. )
  102. service._platform_client = lambda platform, platform_mode: _PartialFailurePlatformClient()
  103. state = service.start_run(RunStartRequest(platform_mode="real"))
  104. assert state["status"] == "partial_success"
  105. assert state["query_failures"][0]["search_query_id"] == "q_002"
  106. assert runtime.run_updates[-1]["updates"]["status"] == "partial_success"
  107. assert runtime.policy_runs[0]["status"] == "partial_success"
  108. assert runtime.lifecycle_events[-1]["event_id"] == "lifecycle_success"
  109. assert runtime.lifecycle_events[-1]["status"] == "partial_success"
  110. assert runtime.lifecycle_events[-1]["raw_payload"]["query_failures"]
  111. run_events = service.read_jsonl(state["run_id"], "run_events.jsonl")
  112. assert any(event["event_type"] == "platform_query_failed" for event in run_events)
  113. search_clues = service.read_jsonl(state["run_id"], "search_clues.jsonl")
  114. failed_clue = next(
  115. clue for clue in search_clues if clue["search_query_id"] == "q_002"
  116. )
  117. assert failed_clue["search_query_effect_status"] == "failed"
  118. def test_run_service_all_platform_queries_fail_records_failed_query_details(tmp_path):
  119. runtime = _SpyRuntimeStore(tmp_path / "runtime")
  120. demand_source = FakeDemandSource(real_source_payload(demand_content_id=123))
  121. service = RunService(
  122. runtime=runtime,
  123. demand_source=demand_source,
  124. query_variant_client=FakeQueryVariantClient(),
  125. )
  126. service._platform_client = lambda platform, platform_mode: _AllFailurePlatformClient()
  127. state = service.start_run(RunStartRequest(platform_mode="real"))
  128. assert state["status"] == "failed"
  129. assert state["error_code"] == ErrorCode.PLATFORM_REQUEST_FAILED.value
  130. failed_query_ids = [failure["search_query_id"] for failure in state["error_detail"]["query_failures"]]
  131. assert failed_query_ids == ["q_001", "q_002", "q_003", "q_004", "q_005", "q_006"]
  132. assert runtime.run_updates[-1]["updates"]["status"] == "failed"
  133. assert runtime.run_updates[-1]["updates"]["error_detail"]["query_failures"]
  134. assert runtime.lifecycle_events[-1]["event_id"] == "lifecycle_failed"
  135. assert runtime.lifecycle_events[-1]["raw_payload"]["error_detail"]["query_failures"]
  136. search_queries = service.read_jsonl(state["run_id"], "search_queries.jsonl")
  137. assert {query["search_query_effect_status"] for query in search_queries} == {"failed"}
  138. assert all(query["raw_payload"]["query_failure"]["status"] == "failed" for query in search_queries)
  139. search_clues = service.read_jsonl(state["run_id"], "search_clues.jsonl")
  140. assert [clue["search_query_id"] for clue in search_clues] == failed_query_ids
  141. assert {clue["search_query_effect_status"] for clue in search_clues} == {"failed"}
  142. assert {clue["walk_next_step"] for clue in search_clues} == {"stop_search_query"}
  143. run_events = service.read_jsonl(state["run_id"], "run_events.jsonl")
  144. platform_failures = [
  145. event for event in run_events if event["event_type"] == "platform_query_failed"
  146. ]
  147. assert [event["input_ref"] for event in platform_failures] == [
  148. f"search_queries.jsonl:{query_id}" for query_id in failed_query_ids
  149. ]
  150. assert {event["status"] for event in platform_failures} == {"failed"}
  151. def test_run_service_all_crawapi_business_failures_persist_full_error_detail(tmp_path):
  152. runtime = _SpyRuntimeStore(tmp_path / "runtime")
  153. demand_source = FakeDemandSource(real_source_payload(demand_content_id=123))
  154. service = RunService(
  155. runtime=runtime,
  156. demand_source=demand_source,
  157. query_variant_client=FakeQueryVariantClient(),
  158. )
  159. service._platform_client = (
  160. lambda platform, platform_mode: _AllStructuredCrawapiFailurePlatformClient()
  161. )
  162. state = service.start_run(RunStartRequest(platform_mode="real"))
  163. assert state["status"] == "failed"
  164. run_detail = runtime.run_updates[-1]["updates"]["error_detail"]
  165. first_run_failure = run_detail["query_failures"][0]
  166. assert first_run_failure["error_detail"]["business_code"] == "10000"
  167. assert first_run_failure["error_detail"]["business_data"] == {
  168. "trace_id": "trace-q_001",
  169. "upstream_reason": "opaque",
  170. }
  171. search_clues = service.read_jsonl(state["run_id"], "search_clues.jsonl")
  172. first_clue_detail = search_clues[0]["raw_payload"]["query_failure"]["error_detail"]
  173. assert first_clue_detail["business_code"] == "10000"
  174. assert first_clue_detail["business_data"]["trace_id"] == "trace-q_001"
  175. run_events = service.read_jsonl(state["run_id"], "run_events.jsonl")
  176. platform_failures = [
  177. event for event in run_events if event["event_type"] == "platform_query_failed"
  178. ]
  179. first_event_detail = platform_failures[0]["raw_payload"]["query_failure"]["error_detail"]
  180. assert first_event_detail["business_code"] == "10000"
  181. assert first_event_detail["trace_refs"] == {"x-request-id": "req-q_001"}
  182. assert runtime.lifecycle_events[-1]["raw_payload"]["error_detail"]["query_failures"][0][
  183. "error_detail"
  184. ]["business_data"]["trace_id"] == "trace-q_001"
  185. def test_run_service_query_generation_failure_records_error_code(tmp_path):
  186. runtime = _SpyRuntimeStore(tmp_path / "runtime")
  187. demand_source = FakeDemandSource(real_source_payload(demand_content_id=123))
  188. service = RunService(
  189. runtime=runtime,
  190. demand_source=demand_source,
  191. query_variant_client=FakeQueryVariantClient(error=RuntimeError("model unavailable")),
  192. )
  193. state = service.start_run(RunStartRequest(platform_mode="mock", strategy_version="V1"))
  194. assert state["status"] == "failed"
  195. assert state["error_code"] == ErrorCode.QUERY_GENERATION_FAILED.value
  196. assert state["error_detail"]["reason"] == "llm_variant_exception"
  197. assert runtime.run_updates[-1]["updates"]["status"] == "failed"
  198. assert (
  199. runtime.run_updates[-1]["updates"]["error_code"]
  200. == ErrorCode.QUERY_GENERATION_FAILED.value
  201. )
  202. assert runtime.lifecycle_events[-1]["event_id"] == "lifecycle_failed"
  203. assert runtime.lifecycle_events[-1]["error_code"] == ErrorCode.QUERY_GENERATION_FAILED.value
  204. def test_run_service_duplicate_query_variant_records_query_generation_failed(tmp_path):
  205. runtime = _SpyRuntimeStore(tmp_path / "runtime")
  206. demand_source = FakeDemandSource(real_source_payload(demand_content_id=123))
  207. service = RunService(
  208. runtime=runtime,
  209. demand_source=demand_source,
  210. query_variant_client=FakeQueryVariantClient({"爱国情感": "爱国情感"}),
  211. )
  212. state = service.start_run(RunStartRequest(platform_mode="mock", strategy_version="V1"))
  213. assert state["status"] == "failed"
  214. assert state["error_code"] == ErrorCode.QUERY_GENERATION_FAILED.value
  215. assert state["error_detail"]["reason"] == "llm_variant_same_as_seed"
  216. assert runtime.run_updates[-1]["updates"]["error_code"] == ErrorCode.QUERY_GENERATION_FAILED.value
  217. def test_run_service_generic_query_variant_records_query_generation_failed(tmp_path):
  218. runtime = _SpyRuntimeStore(tmp_path / "runtime")
  219. demand_source = FakeDemandSource(real_source_payload(demand_content_id=123))
  220. service = RunService(
  221. runtime=runtime,
  222. demand_source=demand_source,
  223. query_variant_client=FakeQueryVariantClient({"爱国情感": "热门视频"}),
  224. )
  225. state = service.start_run(RunStartRequest(platform_mode="mock", strategy_version="V1"))
  226. assert state["status"] == "failed"
  227. assert state["error_code"] == ErrorCode.QUERY_GENERATION_FAILED.value
  228. assert state["error_detail"]["reason"] == "llm_variant_generic"
  229. assert runtime.run_updates[-1]["updates"]["error_code"] == ErrorCode.QUERY_GENERATION_FAILED.value
  230. def test_run_service_malformed_query_variant_result_records_query_generation_failed(tmp_path):
  231. runtime = _SpyRuntimeStore(tmp_path / "runtime")
  232. demand_source = FakeDemandSource(real_source_payload(demand_content_id=123))
  233. service = RunService(
  234. runtime=runtime,
  235. demand_source=demand_source,
  236. query_variant_client=_MalformedQueryVariantClient(),
  237. )
  238. state = service.start_run(RunStartRequest(platform_mode="mock", strategy_version="V1"))
  239. assert state["status"] == "failed"
  240. assert state["error_code"] == ErrorCode.QUERY_GENERATION_FAILED.value
  241. assert state["error_detail"]["reason"] == "llm_variant_result_invalid"
  242. assert runtime.run_updates[-1]["updates"]["error_code"] == ErrorCode.QUERY_GENERATION_FAILED.value
  243. def test_run_service_missing_query_variant_client_fails_at_p2(tmp_path):
  244. runtime = _SpyRuntimeStore(tmp_path / "runtime")
  245. service = RunService(runtime=runtime)
  246. state = service.start_run(
  247. RunStartRequest(platform_mode="mock", source=str(REAL_SOURCE_FIXTURE), strategy_version="V1")
  248. )
  249. assert state["status"] == "failed"
  250. assert state["error_code"] == ErrorCode.QUERY_GENERATION_FAILED.value
  251. assert state["error_detail"]["reason"] == "query variant client is not configured"
  252. assert runtime.run_updates[-1]["updates"]["error_code"] == ErrorCode.QUERY_GENERATION_FAILED.value
  253. def test_run_service_without_selector_requires_configured_demand_source(tmp_path):
  254. runtime = _SpyRuntimeStore(tmp_path / "runtime")
  255. service = RunService(runtime=runtime)
  256. state = service.start_run(RunStartRequest(platform_mode="mock"))
  257. assert state["status"] == "failed"
  258. assert state["error_code"] == ErrorCode.DB_CONFIG_MISSING.value
  259. assert state["error_detail"]["selector"] == "default_pg_pattern_v2_passed"
  260. assert runtime.run_records[0]["source_ref"]["source_type"] == "demand_content_default"
  261. assert runtime.run_updates[-1]["updates"]["status"] == "failed"
  262. assert runtime.lifecycle_events[-1]["event_id"] == "lifecycle_failed"
  263. def test_run_service_failure_records_error_code_and_failed_lifecycle(tmp_path):
  264. runtime = _SpyRuntimeStore(tmp_path / "runtime")
  265. service = RunService(runtime=runtime)
  266. state = service.start_run(
  267. RunStartRequest(platform_mode="mock", source=str(tmp_path / "missing.json"))
  268. )
  269. assert state["status"] == "failed"
  270. assert state["error_code"] == ErrorCode.INVALID_SOURCE.value
  271. assert state["http_status_code"] == 400
  272. assert runtime.run_updates[-1]["updates"]["status"] == "failed"
  273. assert runtime.run_updates[-1]["updates"]["error_code"] == ErrorCode.INVALID_SOURCE.value
  274. assert runtime.lifecycle_events[-1]["event_id"] == "lifecycle_failed"
  275. assert runtime.lifecycle_events[-1]["error_code"] == ErrorCode.INVALID_SOURCE.value
  276. def test_demand_source_service_maps_demand_content_row_to_source_payload():
  277. connection = _DemandConnection(
  278. {
  279. "id": 123,
  280. "merge_leve2": "PG Pattern",
  281. "name": "爱国情感,人物故事",
  282. "reason": "reason",
  283. "suggestion": "suggestion",
  284. "score": 0.91,
  285. "dt": "2026-06-07",
  286. "ext_data": json.dumps({"evidence_pack": {"pattern_source_system": "pg_pattern_v2"}}),
  287. }
  288. )
  289. service = DemandSourceService(_config(), connection_factory=lambda: connection)
  290. payload = service.get_by_id(123)
  291. assert payload["demand_content_id"] == "123"
  292. assert payload["ext_data"]["evidence_pack"]["pattern_source_system"] == "pg_pattern_v2"
  293. assert payload["raw_demand_content"]["id"] == 123
  294. assert "FROM demand_content" in connection.statements[0][0]
  295. assert connection.statements[0][1] == [123]
  296. def test_demand_source_service_run_label_uses_deterministic_selector():
  297. connection = _DemandConnection(None)
  298. service = DemandSourceService(_config(), connection_factory=lambda: connection)
  299. try:
  300. service.get_by_run_label("smoke")
  301. except Exception:
  302. pass
  303. sql, params = connection.statements[0]
  304. assert "JSON_UNQUOTE(JSON_EXTRACT(ext_data, '$.run_label'))" in sql
  305. assert "ORDER BY id ASC" in sql
  306. assert "LIMIT 1" in sql
  307. assert params == ["smoke"]
  308. def test_demand_source_service_default_uses_real_pg_pattern_selector():
  309. connection = _DemandConnection(None)
  310. service = DemandSourceService(_config(), connection_factory=lambda: connection)
  311. try:
  312. service.get_default_pg_pattern_source()
  313. except Exception:
  314. pass
  315. sql, params = connection.statements[0]
  316. assert "$.evidence_pack.pattern_source_system" in sql
  317. assert "$.evidence_pack.validation_status" in sql
  318. assert "pg_pattern_v2" in sql
  319. assert "passed" in sql
  320. assert "ORDER BY id ASC" in sql
  321. assert "LIMIT 1" in sql
  322. assert params == []
  323. def test_api_mutual_exclusion_returns_invalid_request(tmp_path, monkeypatch):
  324. monkeypatch.setattr(api, "service", RunService(runtime_root=tmp_path / "runtime" / "v1"))
  325. client = TestClient(api.app)
  326. response = client.post(
  327. "/runs",
  328. json={
  329. "platform_mode": "mock",
  330. "source": "source.json",
  331. "demand_content_id": 123,
  332. },
  333. )
  334. assert response.status_code == 422
  335. assert response.json()["detail"]["error_code"] == ErrorCode.INVALID_REQUEST.value
  336. def test_api_missing_source_returns_invalid_source(tmp_path, monkeypatch):
  337. monkeypatch.setattr(api, "service", RunService(runtime_root=tmp_path / "runtime" / "v1"))
  338. client = TestClient(api.app)
  339. response = client.post(
  340. "/runs",
  341. json={"platform_mode": "mock", "source": str(tmp_path / "missing.json")},
  342. )
  343. assert response.status_code == 400
  344. assert response.json()["detail"]["error_code"] == ErrorCode.INVALID_SOURCE.value
  345. def test_api_404_returns_structured_run_not_found(tmp_path, monkeypatch):
  346. monkeypatch.setattr(api, "service", RunService(runtime_root=tmp_path / "runtime" / "v1"))
  347. client = TestClient(api.app)
  348. response = client.get("/runs/missing_run")
  349. assert response.status_code == 404
  350. assert response.json()["detail"]["error_code"] == ErrorCode.RUN_NOT_FOUND.value
  351. class _SpyRuntimeStore:
  352. def __init__(self, base_dir: Path) -> None:
  353. self.local = LocalRuntimeFileStore(base_dir)
  354. self.run_records: list[dict[str, Any]] = []
  355. self.run_updates: list[dict[str, Any]] = []
  356. self.policy_runs: list[dict[str, Any]] = []
  357. self.lifecycle_events: list[dict[str, Any]] = []
  358. self.publish_jobs: list[dict[str, Any]] = []
  359. self.author_assets: list[dict[str, Any]] = []
  360. self.author_asset_roles: list[dict[str, Any]] = []
  361. self.search_clue_assets: list[dict[str, Any]] = []
  362. self.search_clue_asset_evidence: list[dict[str, Any]] = []
  363. def prepare_run(self, run_id: str) -> Path:
  364. return self.local.prepare_run(run_id)
  365. def run_dir(self, run_id: str) -> Path:
  366. return self.local.run_dir(run_id)
  367. def write_json(self, run_id: str, filename: str, data: dict[str, Any]) -> Path:
  368. return self.local.write_json(run_id, filename, data)
  369. def update_json(self, run_id: str, filename: str, data: dict[str, Any]) -> Path:
  370. return self.local.update_json(run_id, filename, data)
  371. def append_jsonl(self, run_id: str, filename: str, rows: list[dict[str, Any]]) -> Path:
  372. return self.local.append_jsonl(run_id, filename, rows)
  373. def read_json(self, run_id: str, filename: str) -> dict[str, Any]:
  374. return self.local.read_json(run_id, filename)
  375. def read_jsonl(self, run_id: str, filename: str) -> list[dict[str, Any]]:
  376. return self.local.read_jsonl(run_id, filename)
  377. def file_status(self, run_id: str) -> dict[str, bool]:
  378. return self.local.file_status(run_id)
  379. def create_run_record(self, record: dict[str, Any]) -> None:
  380. self.run_records.append(dict(record))
  381. def update_run_record(self, run_id: str, updates: dict[str, Any]) -> None:
  382. self.run_updates.append({"run_id": run_id, "updates": dict(updates)})
  383. def record_policy_run(self, record: dict[str, Any]) -> None:
  384. self.policy_runs.append(dict(record))
  385. def append_run_event_records(
  386. self,
  387. run_id: str,
  388. policy_run_id: str,
  389. rows: list[dict[str, Any]],
  390. ) -> None:
  391. self.lifecycle_events.extend(dict(row) for row in rows)
  392. def write_publish_jobs(
  393. self,
  394. run_id: str,
  395. policy_run_id: str,
  396. rows: list[dict[str, Any]],
  397. ) -> None:
  398. self.publish_jobs.extend(dict(row) for row in rows)
  399. def write_author_assets(self, rows: list[dict[str, Any]]) -> None:
  400. self.author_assets.extend(dict(row) for row in rows)
  401. def write_author_asset_roles(self, rows: list[dict[str, Any]]) -> None:
  402. self.author_asset_roles.extend(dict(row) for row in rows)
  403. def write_search_clue_assets(self, rows: list[dict[str, Any]]) -> None:
  404. self.search_clue_assets.extend(dict(row) for row in rows)
  405. def write_search_clue_asset_evidence(self, rows: list[dict[str, Any]]) -> None:
  406. self.search_clue_asset_evidence.extend(dict(row) for row in rows)
  407. def read_performance_feedback(
  408. self,
  409. run_id: str,
  410. policy_run_id: str,
  411. ) -> list[dict[str, Any]]:
  412. return []
  413. class _FakeRuntimeStore(_SpyRuntimeStore):
  414. def __init__(self, run_dir: Path | None = None, fail_writes: bool = False) -> None:
  415. super().__init__(run_dir or Path("fake_runtime"))
  416. self.calls: list[tuple[str, str]] = []
  417. self.fail_writes = fail_writes
  418. def write_json(self, run_id: str, filename: str, data: dict[str, Any]) -> Path:
  419. self.calls.append(("write_json", filename))
  420. if self.fail_writes:
  421. raise RuntimeError("primary failed")
  422. return self.run_dir(run_id) / filename
  423. def update_json(self, run_id: str, filename: str, data: dict[str, Any]) -> Path:
  424. self.calls.append(("update_json", filename))
  425. if self.fail_writes:
  426. raise RuntimeError("primary failed")
  427. return self.run_dir(run_id) / filename
  428. def append_jsonl(self, run_id: str, filename: str, rows: list[dict[str, Any]]) -> Path:
  429. self.calls.append(("append_jsonl", filename))
  430. if self.fail_writes:
  431. raise RuntimeError("primary failed")
  432. return self.run_dir(run_id) / filename
  433. class _ReadFailingRuntimeStore(_FakeRuntimeStore):
  434. def read_json(self, run_id: str, filename: str) -> dict[str, Any]:
  435. raise FileNotFoundError(filename)
  436. def read_jsonl(self, run_id: str, filename: str) -> list[dict[str, Any]]:
  437. raise FileNotFoundError(filename)
  438. def file_status(self, run_id: str) -> dict[str, bool]:
  439. raise FileNotFoundError(run_id)
  440. class _MalformedQueryVariantClient:
  441. def generate_variant(self, *, seed_term: str, evidence_context: dict[str, Any]):
  442. return None
  443. class _PartialFailurePlatformClient:
  444. def __init__(self) -> None:
  445. self.mock = MockPlatformClient()
  446. def search(self, search_query: dict[str, Any]) -> list[dict[str, Any]]:
  447. if search_query["search_query_id"] == "q_002":
  448. raise RuntimeError("temporary platform failure")
  449. return self.mock.search(search_query)
  450. class _AllFailurePlatformClient:
  451. def search(self, search_query: dict[str, Any]) -> list[dict[str, Any]]:
  452. raise RuntimeError("platform unavailable")
  453. class _AllStructuredCrawapiFailurePlatformClient:
  454. def search(self, search_query: dict[str, Any]) -> list[dict[str, Any]]:
  455. query_id = search_query["search_query_id"]
  456. raise CrawapiBusinessError(
  457. "crawapi keyword_search failed: business_error code=10000 message=未知错误",
  458. {
  459. "operation": "keyword_search",
  460. "business_code": "10000",
  461. "business_message": "未知错误",
  462. "business_data": {
  463. "trace_id": f"trace-{query_id}",
  464. "upstream_reason": "opaque",
  465. },
  466. "trace_refs": {"x-request-id": f"req-{query_id}"},
  467. },
  468. )
  469. class _DemandCursor:
  470. def __init__(self, connection: "_DemandConnection") -> None:
  471. self.connection = connection
  472. def __enter__(self) -> "_DemandCursor":
  473. return self
  474. def __exit__(self, *_args) -> None:
  475. return None
  476. def execute(self, sql: str, params=None) -> None:
  477. self.connection.statements.append((sql, list(params or [])))
  478. def fetchone(self):
  479. return self.connection.row
  480. class _DemandConnection:
  481. def __init__(self, row: dict[str, Any] | None) -> None:
  482. self.row = row
  483. self.statements: list[tuple[str, list[Any]]] = []
  484. def __enter__(self) -> "_DemandConnection":
  485. return self
  486. def __exit__(self, *_args) -> None:
  487. return None
  488. def cursor(self) -> _DemandCursor:
  489. return _DemandCursor(self)
  490. def _config() -> ContentSupplyDbConfig:
  491. return ContentSupplyDbConfig(
  492. host="127.0.0.1",
  493. port=3306,
  494. user="content_rw",
  495. password="dummy_password",
  496. database="content-deconstruction-supply",
  497. )