test_progressive_screening.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. from __future__ import annotations
  2. from pathlib import Path
  3. from typing import Any
  4. from content_agent.business_modules import (
  5. learning_review,
  6. progressive_screening,
  7. result_source_lookup,
  8. run_record,
  9. source_seed,
  10. walk_engine,
  11. )
  12. from content_agent.business_modules.run_record.validation import validate_run
  13. from content_agent.constants import RUNTIME_RECORD_SCHEMA_VERSION
  14. from content_agent.integrations.policy_json import JsonPolicyBundleStore
  15. from content_agent.integrations.runtime_files import LocalRuntimeFileStore
  16. from content_agent.record_payload import with_raw_payload
  17. from tests.gemini_helpers import FakeGeminiVideoClient, fake_gemini_pool, fake_gemini_review
  18. from tests.p1_helpers import real_source_payload
  19. class FakeProgressivePlatformClient:
  20. def __init__(self, pages: dict[str, list[dict[str, Any]]]) -> None:
  21. self.pages = pages
  22. self.calls: list[dict[str, Any]] = []
  23. def search_full_page(self, query: dict[str, Any]) -> list[dict[str, Any]]:
  24. self.calls.append(dict(query))
  25. cursor = str(query.get("page_cursor") or "")
  26. return [dict(item) for item in self.pages.get(cursor, [])]
  27. def search(self, query: dict[str, Any]) -> list[dict[str, Any]]:
  28. return self.search_full_page(query)
  29. class FakeHydratingProgressivePlatformClient(FakeProgressivePlatformClient):
  30. def __init__(self, pages: dict[str, list[dict[str, Any]]]) -> None:
  31. super().__init__(pages)
  32. self.metadata_calls: list[dict[str, Any]] = []
  33. self.hydrate_calls: list[str] = []
  34. def search_full_page_metadata(self, query: dict[str, Any]) -> list[dict[str, Any]]:
  35. self.metadata_calls.append(dict(query))
  36. cursor = str(query.get("page_cursor") or "")
  37. return [
  38. {**dict(item), "_platform_search_item": dict(item)}
  39. for item in self.pages.get(cursor, [])
  40. ]
  41. def hydrate_search_result_media(
  42. self,
  43. result: dict[str, Any],
  44. query: dict[str, Any],
  45. ) -> dict[str, Any]:
  46. content_id = str(result.get("platform_content_id") or "")
  47. self.hydrate_calls.append(content_id)
  48. hydrated = {
  49. key: value
  50. for key, value in result.items()
  51. if not str(key).startswith("_platform_")
  52. }
  53. hydrated["play_url"] = f"https://video.example/{content_id}.mp4"
  54. return hydrated
  55. class FakeClock:
  56. def __init__(self) -> None:
  57. self.now = 0.0
  58. self.sleeps: list[float] = []
  59. def monotonic(self) -> float:
  60. return self.now
  61. def sleep(self, seconds: float) -> None:
  62. self.sleeps.append(seconds)
  63. self.now += seconds
  64. def test_progressive_screening_two_item_page_has_no_remainder(tmp_path):
  65. context = _context(tmp_path)
  66. client = FakeProgressivePlatformClient({"": _page(["c1", "c2"], has_more=True, cursor="next")})
  67. gemini = FakeGeminiVideoClient(default_result=fake_gemini_pool())
  68. result = _run_screening(context, client, gemini)
  69. assert len(client.calls) == 1
  70. assert [item["platform_content_id"] for item in result["platform_results"]] == ["c1", "c2"]
  71. assert len(gemini.calls) == 2
  72. def test_progressive_screening_archives_batch_before_gemini_judgment(tmp_path):
  73. context = _context(tmp_path)
  74. client = FakeProgressivePlatformClient({"": _page(["c1", "c2", "c3"], has_more=True, cursor="next")})
  75. archive_dispatcher = BatchArchiveDispatcher()
  76. gemini = OssRequiredGeminiVideoClient()
  77. result = _run_screening(context, client, gemini, archive_dispatcher=archive_dispatcher)
  78. assert archive_dispatcher.archived_content_ids == ["c1", "c2", "c3"]
  79. assert [call["media"]["oss_url"] for call in gemini.calls] == [
  80. "https://res.example/c1.mp4",
  81. "https://res.example/c2.mp4",
  82. "https://res.example/c3.mp4",
  83. ]
  84. assert {row["content_media_status"] for row in result["content_media_records"]} == {"oss_uploaded"}
  85. def test_progressive_screening_uses_actual_page_size_without_hardcoded_ten(tmp_path):
  86. context = _context(tmp_path)
  87. ids = [f"c{i}" for i in range(15)]
  88. client = FakeProgressivePlatformClient({"": _page(ids, has_more=True, cursor="next")})
  89. gemini = FakeGeminiVideoClient(default_result=fake_gemini_review())
  90. result = _run_screening(context, client, gemini)
  91. assert len(client.calls) == 1
  92. assert [item["platform_content_id"] for item in result["platform_results"]] == ["c0", "c1", "c2"]
  93. assert {item["progressive_page_item_count"] for item in result["discovered_content_items"]} == {15}
  94. def test_progressive_screening_pool_in_first_batch_processes_remainder_only(tmp_path):
  95. context = _context(tmp_path)
  96. ids = [f"c{i}" for i in range(10)]
  97. client = FakeProgressivePlatformClient({"": _page(ids, has_more=True, cursor="next")})
  98. gemini = FakeGeminiVideoClient(
  99. result_by_content_id={"c0": fake_gemini_pool()},
  100. default_result=fake_gemini_review(),
  101. )
  102. result = _run_screening(context, client, gemini)
  103. assert len(client.calls) == 1
  104. assert len(result["platform_results"]) == 10
  105. assert {decision["decision_action"] for decision in result["rule_decisions"]} == {
  106. "ADD_TO_CONTENT_POOL",
  107. "KEEP_CONTENT_FOR_REVIEW",
  108. }
  109. def test_progressive_screening_remainder_and_page_two_gate_page_three(tmp_path):
  110. context = _context(tmp_path)
  111. pages = {
  112. "": _page([f"p1_{i}" for i in range(10)], has_more=True, cursor="cursor_2"),
  113. "cursor_2": _page([f"p2_{i}" for i in range(9)], has_more=True, cursor="cursor_3"),
  114. "cursor_3": _page([f"p3_{i}" for i in range(11)], has_more=True, cursor="cursor_4"),
  115. }
  116. client = FakeProgressivePlatformClient(pages)
  117. gemini = FakeGeminiVideoClient(
  118. result_by_content_id={
  119. "p1_0": fake_gemini_pool(),
  120. "p1_3": fake_gemini_pool(),
  121. "p2_0": fake_gemini_pool(),
  122. "p3_0": fake_gemini_pool(),
  123. },
  124. default_result=fake_gemini_review(),
  125. )
  126. clock = FakeClock()
  127. limiter = progressive_screening.SearchCallLimiter(
  128. now_fn=clock.monotonic,
  129. sleep_fn=clock.sleep,
  130. )
  131. result = _run_screening(context, client, gemini, limiter=limiter)
  132. assert [call.get("page_cursor", "") for call in client.calls] == ["", "cursor_2", "cursor_3"]
  133. assert len(result["platform_results"]) == 30
  134. assert clock.sleeps == [30.0, 30.0]
  135. def test_progressive_screening_keep_for_review_does_not_pass(tmp_path):
  136. context = _context(tmp_path)
  137. client = FakeProgressivePlatformClient({"": _page([f"c{i}" for i in range(10)], has_more=True, cursor="next")})
  138. gemini = FakeGeminiVideoClient(default_result=fake_gemini_review())
  139. result = _run_screening(context, client, gemini)
  140. assert len(client.calls) == 1
  141. assert len(result["platform_results"]) == 3
  142. assert {decision["decision_action"] for decision in result["rule_decisions"]} == {
  143. "KEEP_CONTENT_FOR_REVIEW"
  144. }
  145. def test_progressive_screening_hydrates_only_initial_batch_without_pass(tmp_path):
  146. context = _context(tmp_path)
  147. client = FakeHydratingProgressivePlatformClient(
  148. {"": _page([f"c{i}" for i in range(10)], has_more=True, cursor="next")}
  149. )
  150. gemini = FakeGeminiVideoClient(default_result=fake_gemini_review())
  151. result = _run_screening(context, client, gemini)
  152. assert client.metadata_calls == [context["search_queries"][0]]
  153. assert client.calls == []
  154. assert client.hydrate_calls == ["c0", "c1", "c2"]
  155. assert len(result["platform_results"]) == 3
  156. assert all("_platform_search_item" not in row for row in result["platform_results"])
  157. def test_progressive_screening_hydrates_remainder_only_after_first_batch_pass(tmp_path):
  158. context = _context(tmp_path)
  159. client = FakeHydratingProgressivePlatformClient(
  160. {"": _page([f"c{i}" for i in range(10)], has_more=True, cursor="next")}
  161. )
  162. gemini = FakeGeminiVideoClient(
  163. result_by_content_id={"c0": fake_gemini_pool()},
  164. default_result=fake_gemini_review(),
  165. )
  166. result = _run_screening(context, client, gemini)
  167. assert client.hydrate_calls == [f"c{i}" for i in range(10)]
  168. assert len(result["platform_results"]) == 10
  169. def test_progressive_screening_does_not_hydrate_duplicate_again(tmp_path):
  170. context = _context(tmp_path)
  171. client = FakeHydratingProgressivePlatformClient(
  172. {"": _page(["dup", "c1", "c2", "dup", "c4"], has_more=True, cursor="next")}
  173. )
  174. gemini = FakeGeminiVideoClient(
  175. result_by_content_id={"dup": fake_gemini_pool()},
  176. default_result=fake_gemini_review(),
  177. )
  178. result = _run_screening(context, client, gemini)
  179. assert client.hydrate_calls == ["dup", "c1", "c2", "c4"]
  180. assert [item["platform_content_id"] for item in result["platform_results"]] == [
  181. "dup",
  182. "c1",
  183. "c2",
  184. "c4",
  185. ]
  186. def test_progressive_screening_deduplicates_across_batches(tmp_path):
  187. context = _context(tmp_path)
  188. client = FakeProgressivePlatformClient(
  189. {
  190. "": _page(["dup", "c1", "c2", "dup", "c4"], has_more=True, cursor="next"),
  191. }
  192. )
  193. gemini = FakeGeminiVideoClient(
  194. result_by_content_id={"dup": fake_gemini_pool()},
  195. default_result=fake_gemini_review(),
  196. )
  197. result = _run_screening(context, client, gemini)
  198. assert [item["platform_content_id"] for item in result["platform_results"]] == [
  199. "dup",
  200. "c1",
  201. "c2",
  202. "c4",
  203. ]
  204. assert len(result["rule_decisions"]) == 4
  205. def test_progressive_screening_runtime_validates_and_does_not_duplicate_old_paging(tmp_path):
  206. context = _context(tmp_path)
  207. pages = {
  208. "": _page([f"p1_{i}" for i in range(10)], has_more=True, cursor="cursor_2"),
  209. "cursor_2": _page([f"p2_{i}" for i in range(10)], has_more=True, cursor="cursor_3"),
  210. }
  211. client = FakeProgressivePlatformClient(pages)
  212. gemini = FakeGeminiVideoClient(
  213. result_by_content_id={"p1_0": fake_gemini_pool(), "p1_3": fake_gemini_pool()},
  214. default_result=fake_gemini_review(),
  215. )
  216. clock = FakeClock()
  217. limiter = progressive_screening.SearchCallLimiter(
  218. now_fn=clock.monotonic,
  219. sleep_fn=clock.sleep,
  220. )
  221. result = _run_screening(context, client, gemini, limiter=limiter)
  222. walk_result = walk_engine.run_bounded_walk(
  223. run_id=context["run_id"],
  224. policy_run_id=context["policy_run_id"],
  225. pattern_seed_pack=context["pattern_seed_pack"],
  226. source_context=context["source_context"],
  227. search_queries=context["search_queries"],
  228. discovered_content_items=result["discovered_content_items"],
  229. content_media_records=result["content_media_records"],
  230. evidence_bundles=result["evidence_bundles"],
  231. rule_decisions=result["rule_decisions"],
  232. policy_bundle=context["policy_bundle"],
  233. platform_client=client,
  234. runtime=context["runtime"],
  235. gemini_video_client=gemini,
  236. )
  237. record = run_record.run(
  238. context["run_id"],
  239. context["policy_run_id"],
  240. context["search_queries"],
  241. walk_result["discovered_content_items"],
  242. walk_result["rule_decisions"],
  243. walk_result["source_path_record_basis"],
  244. context["policy_bundle"],
  245. context["runtime"],
  246. walk_actions=walk_result["walk_actions"],
  247. query_failures=result["query_failures"],
  248. )
  249. result_source_lookup.run(
  250. context["run_id"],
  251. context["policy_run_id"],
  252. context["policy_bundle"],
  253. walk_result["discovered_content_items"],
  254. walk_result["content_media_records"],
  255. walk_result["rule_decisions"],
  256. record["source_path_records"],
  257. record["search_clues"],
  258. context["runtime"],
  259. )
  260. learning_review.run(context["run_id"], context["policy_run_id"], context["runtime"])
  261. validation = validate_run(context["run_id"], context["runtime"])
  262. page_calls = [call for call in client.calls if call.get("page_cursor") == "cursor_2"]
  263. assert len(page_calls) == 1
  264. assert validation["status"] == "pass", validation["findings"]
  265. def test_progressive_screening_keeps_cursor_when_next_page_was_not_consumed(tmp_path):
  266. context = _context(tmp_path)
  267. client = FakeProgressivePlatformClient({"": _page([f"c{i}" for i in range(10)], has_more=True, cursor="next")})
  268. gemini = FakeGeminiVideoClient(
  269. result_by_content_id={"c0": fake_gemini_pool()},
  270. default_result=fake_gemini_review(),
  271. )
  272. result = _run_screening(context, client, gemini)
  273. assert len(client.calls) == 1
  274. assert {item["next_cursor"] for item in result["discovered_content_items"]} == {"next"}
  275. assert {item["has_more"] for item in result["discovered_content_items"]} == {True}
  276. def _context(tmp_path: Path) -> dict[str, Any]:
  277. run_id = "run_progressive"
  278. policy_run_id = "policy_progressive"
  279. runtime = LocalRuntimeFileStore(tmp_path / "runtime")
  280. runtime.prepare_run(run_id)
  281. seed = source_seed.run(run_id, policy_run_id, real_source_payload(), runtime)
  282. query = with_raw_payload(
  283. {
  284. "record_schema_version": RUNTIME_RECORD_SCHEMA_VERSION,
  285. "run_id": run_id,
  286. "policy_run_id": policy_run_id,
  287. "search_query_id": "q_001",
  288. "search_query": "早上好",
  289. "search_query_generation_method": "item_single",
  290. "discovery_start_source": "pattern_itemset",
  291. "previous_discovery_step": "search_query_generated",
  292. "pattern_seed_ref": {"seed_term": "早上好"},
  293. }
  294. )
  295. runtime.append_jsonl(run_id, "search_queries.jsonl", [query])
  296. return {
  297. "run_id": run_id,
  298. "policy_run_id": policy_run_id,
  299. "search_queries": [query],
  300. "source_context": seed["source_context"],
  301. "pattern_seed_pack": seed["pattern_seed_pack"],
  302. "policy_bundle": JsonPolicyBundleStore(Path(".")).load_policy_bundle("V4"),
  303. "runtime": runtime,
  304. }
  305. def _run_screening(
  306. context: dict[str, Any],
  307. client: FakeProgressivePlatformClient,
  308. gemini: FakeGeminiVideoClient,
  309. *,
  310. limiter: progressive_screening.SearchCallLimiter | None = None,
  311. archive_dispatcher: Any | None = None,
  312. ) -> dict[str, Any]:
  313. kwargs = {
  314. "run_id": context["run_id"],
  315. "policy_run_id": context["policy_run_id"],
  316. "search_queries": context["search_queries"],
  317. "source_context": context["source_context"],
  318. "policy_bundle": context["policy_bundle"],
  319. "platform_client": client,
  320. "runtime": context["runtime"],
  321. "gemini_video_client": gemini,
  322. }
  323. if limiter is not None:
  324. kwargs["limiter"] = limiter
  325. if archive_dispatcher is not None:
  326. kwargs["archive_dispatcher"] = archive_dispatcher
  327. return progressive_screening.run(**kwargs)
  328. class BatchArchiveDispatcher:
  329. def __init__(self) -> None:
  330. self.archived_content_ids: list[str] = []
  331. self.closed = False
  332. def archive_records(self, records: list[dict[str, Any]]) -> list[dict[str, Any]]:
  333. archived = []
  334. for row in records:
  335. content_id = row["platform_content_id"]
  336. self.archived_content_ids.append(content_id)
  337. raw_payload = {
  338. **(row.get("raw_payload") or {}),
  339. "content_media_status": "oss_uploaded",
  340. "oss_url": f"https://res.example/{content_id}.mp4",
  341. "oss_archive_status": "uploaded",
  342. }
  343. archived.append(
  344. {
  345. **row,
  346. "content_media_status": "oss_uploaded",
  347. "oss_url": f"https://res.example/{content_id}.mp4",
  348. "raw_payload": raw_payload,
  349. }
  350. )
  351. return archived
  352. def drain_completed(self) -> list[dict[str, Any]]:
  353. return []
  354. def enable_runtime_writes(self) -> None:
  355. return None
  356. def shutdown(self, *, wait: bool = False) -> None:
  357. self.closed = True
  358. class OssRequiredGeminiVideoClient(FakeGeminiVideoClient):
  359. def analyze(
  360. self,
  361. content: dict[str, Any],
  362. media: dict[str, Any],
  363. source_context: dict[str, Any],
  364. ) -> dict[str, Any]:
  365. assert media["content_media_status"] == "oss_uploaded"
  366. assert media["oss_url"] == f"https://res.example/{content['platform_content_id']}.mp4"
  367. return super().analyze(content, media, source_context)
  368. def _page(ids: list[str], *, has_more: bool, cursor: str) -> list[dict[str, Any]]:
  369. return [_item(content_id, has_more=has_more, cursor=cursor) for content_id in ids]
  370. def _item(content_id: str, *, has_more: bool, cursor: str) -> dict[str, Any]:
  371. return {
  372. "content_discovery_id": f"q_001_content_{content_id}",
  373. "search_query_id": "q_001",
  374. "platform": "douyin",
  375. "platform_content_id": content_id,
  376. "platform_content_format": "video",
  377. "description": f"内容 {content_id}",
  378. "platform_author_id": f"author_{content_id}",
  379. "author_display_name": "作者",
  380. "statistics": {
  381. "digg_count": 1_000_000,
  382. "comment_count": 50_000,
  383. "share_count": 20_000,
  384. "collect_count": 100_000,
  385. "play_count": 0,
  386. },
  387. "tags": [],
  388. "play_url": f"https://video.test/{content_id}.mp4",
  389. "has_more": has_more,
  390. "next_cursor": cursor,
  391. "score": 72,
  392. "risk_level": "unknown",
  393. "discovery_relation": "derived_from_pattern_demand",
  394. "discovery_start_source": "pattern_itemset",
  395. "previous_discovery_step": "search_query_direct",
  396. "content_metadata_source": "fake_progressive_search",
  397. "platform_auth_mode": "no_bearer",
  398. "platform_raw_payload": {"channel_content_id": content_id},
  399. }