test_progressive_screening.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  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_records_batch_timing_event(tmp_path):
  86. context = _context(tmp_path)
  87. client = FakeProgressivePlatformClient({"": _page(["c1", "c2", "c3"], has_more=True, cursor="next")})
  88. archive_dispatcher = BatchArchiveDispatcher()
  89. gemini = FakeGeminiVideoClient(default_result=fake_gemini_pool())
  90. _run_screening(context, client, gemini, archive_dispatcher=archive_dispatcher)
  91. events = context["runtime"].read_jsonl(context["run_id"], "run_events.jsonl")
  92. timing_events = [row for row in events if row.get("event_type") == "progressive_batch_timing"]
  93. assert len(timing_events) == 1
  94. payload = timing_events[0]["raw_payload"]
  95. assert payload["batch_kind"] == "initial_top3"
  96. assert payload["search_timing_source"] == "page_fetch"
  97. for field in (
  98. "search_duration_ms",
  99. "oss_batch_duration_ms",
  100. "qwen_batch_duration_ms",
  101. "portrait_duration_ms",
  102. "rule_duration_ms",
  103. ):
  104. assert isinstance(payload[field], int)
  105. assert payload[field] >= 0
  106. def test_progressive_screening_uses_actual_page_size_without_hardcoded_ten(tmp_path):
  107. context = _context(tmp_path)
  108. ids = [f"c{i}" for i in range(15)]
  109. client = FakeProgressivePlatformClient({"": _page(ids, has_more=True, cursor="next")})
  110. gemini = FakeGeminiVideoClient(default_result=fake_gemini_review())
  111. result = _run_screening(context, client, gemini)
  112. assert len(client.calls) == 1
  113. assert [item["platform_content_id"] for item in result["platform_results"]] == ["c0", "c1", "c2"]
  114. assert {item["progressive_page_item_count"] for item in result["discovered_content_items"]} == {15}
  115. def test_progressive_screening_pool_in_first_batch_processes_remainder_only(tmp_path):
  116. context = _context(tmp_path)
  117. ids = [f"c{i}" for i in range(10)]
  118. client = FakeProgressivePlatformClient({"": _page(ids, has_more=True, cursor="next")})
  119. gemini = FakeGeminiVideoClient(
  120. result_by_content_id={"c0": fake_gemini_pool()},
  121. default_result=fake_gemini_review(),
  122. )
  123. result = _run_screening(context, client, gemini)
  124. assert len(client.calls) == 1
  125. assert len(result["platform_results"]) == 10
  126. assert {decision["decision_action"] for decision in result["rule_decisions"]} == {
  127. "ADD_TO_CONTENT_POOL",
  128. "KEEP_CONTENT_FOR_REVIEW",
  129. }
  130. def test_progressive_screening_remainder_and_page_two_gate_page_three(tmp_path):
  131. context = _context(tmp_path)
  132. pages = {
  133. "": _page([f"p1_{i}" for i in range(10)], has_more=True, cursor="cursor_2"),
  134. "cursor_2": _page([f"p2_{i}" for i in range(9)], has_more=True, cursor="cursor_3"),
  135. "cursor_3": _page([f"p3_{i}" for i in range(11)], has_more=True, cursor="cursor_4"),
  136. }
  137. client = FakeProgressivePlatformClient(pages)
  138. gemini = FakeGeminiVideoClient(
  139. result_by_content_id={
  140. "p1_0": fake_gemini_pool(),
  141. "p1_3": fake_gemini_pool(),
  142. "p2_0": fake_gemini_pool(),
  143. "p3_0": fake_gemini_pool(),
  144. },
  145. default_result=fake_gemini_review(),
  146. )
  147. clock = FakeClock()
  148. limiter = progressive_screening.SearchCallLimiter(
  149. now_fn=clock.monotonic,
  150. sleep_fn=clock.sleep,
  151. )
  152. result = _run_screening(context, client, gemini, limiter=limiter)
  153. assert [call.get("page_cursor", "") for call in client.calls] == ["", "cursor_2", "cursor_3"]
  154. assert len(result["platform_results"]) == 30
  155. assert clock.sleeps == [30.0, 30.0]
  156. def test_progressive_screening_keep_for_review_does_not_pass(tmp_path):
  157. context = _context(tmp_path)
  158. client = FakeProgressivePlatformClient({"": _page([f"c{i}" for i in range(10)], has_more=True, cursor="next")})
  159. gemini = FakeGeminiVideoClient(default_result=fake_gemini_review())
  160. result = _run_screening(context, client, gemini)
  161. assert len(client.calls) == 1
  162. assert len(result["platform_results"]) == 3
  163. assert {decision["decision_action"] for decision in result["rule_decisions"]} == {
  164. "KEEP_CONTENT_FOR_REVIEW"
  165. }
  166. def test_progressive_screening_hydrates_only_initial_batch_without_pass(tmp_path):
  167. context = _context(tmp_path)
  168. client = FakeHydratingProgressivePlatformClient(
  169. {"": _page([f"c{i}" for i in range(10)], has_more=True, cursor="next")}
  170. )
  171. gemini = FakeGeminiVideoClient(default_result=fake_gemini_review())
  172. result = _run_screening(context, client, gemini)
  173. assert client.metadata_calls == [context["search_queries"][0]]
  174. assert client.calls == []
  175. assert client.hydrate_calls == ["c0", "c1", "c2"]
  176. assert len(result["platform_results"]) == 3
  177. assert all("_platform_search_item" not in row for row in result["platform_results"])
  178. def test_progressive_screening_hydrates_remainder_only_after_first_batch_pass(tmp_path):
  179. context = _context(tmp_path)
  180. client = FakeHydratingProgressivePlatformClient(
  181. {"": _page([f"c{i}" for i in range(10)], has_more=True, cursor="next")}
  182. )
  183. gemini = FakeGeminiVideoClient(
  184. result_by_content_id={"c0": fake_gemini_pool()},
  185. default_result=fake_gemini_review(),
  186. )
  187. result = _run_screening(context, client, gemini)
  188. assert client.hydrate_calls == [f"c{i}" for i in range(10)]
  189. assert len(result["platform_results"]) == 10
  190. def test_progressive_screening_does_not_hydrate_duplicate_again(tmp_path):
  191. context = _context(tmp_path)
  192. client = FakeHydratingProgressivePlatformClient(
  193. {"": _page(["dup", "c1", "c2", "dup", "c4"], has_more=True, cursor="next")}
  194. )
  195. gemini = FakeGeminiVideoClient(
  196. result_by_content_id={"dup": fake_gemini_pool()},
  197. default_result=fake_gemini_review(),
  198. )
  199. result = _run_screening(context, client, gemini)
  200. assert client.hydrate_calls == ["dup", "c1", "c2", "c4"]
  201. assert [item["platform_content_id"] for item in result["platform_results"]] == [
  202. "dup",
  203. "c1",
  204. "c2",
  205. "c4",
  206. ]
  207. def test_progressive_screening_deduplicates_across_batches(tmp_path):
  208. context = _context(tmp_path)
  209. client = FakeProgressivePlatformClient(
  210. {
  211. "": _page(["dup", "c1", "c2", "dup", "c4"], has_more=True, cursor="next"),
  212. }
  213. )
  214. gemini = FakeGeminiVideoClient(
  215. result_by_content_id={"dup": fake_gemini_pool()},
  216. default_result=fake_gemini_review(),
  217. )
  218. result = _run_screening(context, client, gemini)
  219. assert [item["platform_content_id"] for item in result["platform_results"]] == [
  220. "dup",
  221. "c1",
  222. "c2",
  223. "c4",
  224. ]
  225. assert len(result["rule_decisions"]) == 4
  226. def test_progressive_screening_runtime_validates_and_does_not_duplicate_old_paging(tmp_path):
  227. context = _context(tmp_path)
  228. pages = {
  229. "": _page([f"p1_{i}" for i in range(10)], has_more=True, cursor="cursor_2"),
  230. "cursor_2": _page([f"p2_{i}" for i in range(10)], has_more=True, cursor="cursor_3"),
  231. }
  232. client = FakeProgressivePlatformClient(pages)
  233. gemini = FakeGeminiVideoClient(
  234. result_by_content_id={"p1_0": fake_gemini_pool(), "p1_3": fake_gemini_pool()},
  235. default_result=fake_gemini_review(),
  236. )
  237. clock = FakeClock()
  238. limiter = progressive_screening.SearchCallLimiter(
  239. now_fn=clock.monotonic,
  240. sleep_fn=clock.sleep,
  241. )
  242. result = _run_screening(context, client, gemini, limiter=limiter)
  243. walk_result = walk_engine.run_bounded_walk(
  244. run_id=context["run_id"],
  245. policy_run_id=context["policy_run_id"],
  246. pattern_seed_pack=context["pattern_seed_pack"],
  247. source_context=context["source_context"],
  248. search_queries=context["search_queries"],
  249. discovered_content_items=result["discovered_content_items"],
  250. content_media_records=result["content_media_records"],
  251. evidence_bundles=result["evidence_bundles"],
  252. rule_decisions=result["rule_decisions"],
  253. policy_bundle=context["policy_bundle"],
  254. platform_client=client,
  255. runtime=context["runtime"],
  256. gemini_video_client=gemini,
  257. )
  258. record = run_record.run(
  259. context["run_id"],
  260. context["policy_run_id"],
  261. context["search_queries"],
  262. walk_result["discovered_content_items"],
  263. walk_result["rule_decisions"],
  264. walk_result["source_path_record_basis"],
  265. context["policy_bundle"],
  266. context["runtime"],
  267. walk_actions=walk_result["walk_actions"],
  268. query_failures=result["query_failures"],
  269. )
  270. result_source_lookup.run(
  271. context["run_id"],
  272. context["policy_run_id"],
  273. context["policy_bundle"],
  274. walk_result["discovered_content_items"],
  275. walk_result["content_media_records"],
  276. walk_result["rule_decisions"],
  277. record["source_path_records"],
  278. record["search_clues"],
  279. context["runtime"],
  280. )
  281. learning_review.run(context["run_id"], context["policy_run_id"], context["runtime"])
  282. validation = validate_run(context["run_id"], context["runtime"])
  283. page_calls = [call for call in client.calls if call.get("page_cursor") == "cursor_2"]
  284. assert len(page_calls) == 1
  285. assert validation["status"] == "pass", validation["findings"]
  286. def test_progressive_screening_keeps_cursor_when_next_page_was_not_consumed(tmp_path):
  287. context = _context(tmp_path)
  288. client = FakeProgressivePlatformClient({"": _page([f"c{i}" for i in range(10)], has_more=True, cursor="next")})
  289. gemini = FakeGeminiVideoClient(
  290. result_by_content_id={"c0": fake_gemini_pool()},
  291. default_result=fake_gemini_review(),
  292. )
  293. result = _run_screening(context, client, gemini)
  294. assert len(client.calls) == 1
  295. assert {item["next_cursor"] for item in result["discovered_content_items"]} == {"next"}
  296. assert {item["has_more"] for item in result["discovered_content_items"]} == {True}
  297. def _context(tmp_path: Path) -> dict[str, Any]:
  298. run_id = "run_progressive"
  299. policy_run_id = "policy_progressive"
  300. runtime = LocalRuntimeFileStore(tmp_path / "runtime")
  301. runtime.prepare_run(run_id)
  302. seed = source_seed.run(run_id, policy_run_id, real_source_payload(), runtime)
  303. query = with_raw_payload(
  304. {
  305. "record_schema_version": RUNTIME_RECORD_SCHEMA_VERSION,
  306. "run_id": run_id,
  307. "policy_run_id": policy_run_id,
  308. "search_query_id": "q_001",
  309. "search_query": "早上好",
  310. "search_query_generation_method": "item_single",
  311. "discovery_start_source": "pattern_itemset",
  312. "previous_discovery_step": "search_query_generated",
  313. "pattern_seed_ref": {"seed_term": "早上好"},
  314. }
  315. )
  316. runtime.append_jsonl(run_id, "search_queries.jsonl", [query])
  317. return {
  318. "run_id": run_id,
  319. "policy_run_id": policy_run_id,
  320. "search_queries": [query],
  321. "source_context": seed["source_context"],
  322. "pattern_seed_pack": seed["pattern_seed_pack"],
  323. "policy_bundle": JsonPolicyBundleStore(Path(".")).load_policy_bundle("V4"),
  324. "runtime": runtime,
  325. }
  326. def _run_screening(
  327. context: dict[str, Any],
  328. client: FakeProgressivePlatformClient,
  329. gemini: FakeGeminiVideoClient,
  330. *,
  331. limiter: progressive_screening.SearchCallLimiter | None = None,
  332. archive_dispatcher: Any | None = None,
  333. ) -> dict[str, Any]:
  334. kwargs = {
  335. "run_id": context["run_id"],
  336. "policy_run_id": context["policy_run_id"],
  337. "search_queries": context["search_queries"],
  338. "source_context": context["source_context"],
  339. "policy_bundle": context["policy_bundle"],
  340. "platform_client": client,
  341. "runtime": context["runtime"],
  342. "gemini_video_client": gemini,
  343. }
  344. if limiter is not None:
  345. kwargs["limiter"] = limiter
  346. if archive_dispatcher is not None:
  347. kwargs["archive_dispatcher"] = archive_dispatcher
  348. return progressive_screening.run(**kwargs)
  349. class BatchArchiveDispatcher:
  350. def __init__(self) -> None:
  351. self.archived_content_ids: list[str] = []
  352. self.closed = False
  353. def archive_records(self, records: list[dict[str, Any]]) -> list[dict[str, Any]]:
  354. archived = []
  355. for row in records:
  356. content_id = row["platform_content_id"]
  357. self.archived_content_ids.append(content_id)
  358. raw_payload = {
  359. **(row.get("raw_payload") or {}),
  360. "content_media_status": "oss_uploaded",
  361. "oss_url": f"https://res.example/{content_id}.mp4",
  362. "oss_archive_status": "uploaded",
  363. }
  364. archived.append(
  365. {
  366. **row,
  367. "content_media_status": "oss_uploaded",
  368. "oss_url": f"https://res.example/{content_id}.mp4",
  369. "raw_payload": raw_payload,
  370. }
  371. )
  372. return archived
  373. def drain_completed(self) -> list[dict[str, Any]]:
  374. return []
  375. def enable_runtime_writes(self) -> None:
  376. return None
  377. def shutdown(self, *, wait: bool = False) -> None:
  378. self.closed = True
  379. class OssRequiredGeminiVideoClient(FakeGeminiVideoClient):
  380. def analyze(
  381. self,
  382. content: dict[str, Any],
  383. media: dict[str, Any],
  384. source_context: dict[str, Any],
  385. ) -> dict[str, Any]:
  386. assert media["content_media_status"] == "oss_uploaded"
  387. assert media["oss_url"] == f"https://res.example/{content['platform_content_id']}.mp4"
  388. return super().analyze(content, media, source_context)
  389. def _page(ids: list[str], *, has_more: bool, cursor: str) -> list[dict[str, Any]]:
  390. return [_item(content_id, has_more=has_more, cursor=cursor) for content_id in ids]
  391. def _item(content_id: str, *, has_more: bool, cursor: str) -> dict[str, Any]:
  392. return {
  393. "content_discovery_id": f"q_001_content_{content_id}",
  394. "search_query_id": "q_001",
  395. "platform": "douyin",
  396. "platform_content_id": content_id,
  397. "platform_content_format": "video",
  398. "description": f"内容 {content_id}",
  399. "platform_author_id": f"author_{content_id}",
  400. "author_display_name": "作者",
  401. "statistics": {
  402. # M11 re-baseline:平台分改"量级+收缩比例"。原 digg=1M/低占比新算只得 57.36,
  403. # 使 pool 桩(q=80)总分 68.68<70 与 ADD 动作冲突(validate_run 报 threshold_mismatch)。
  404. # 改为中等共鸣占比 → 平台分 ~66:pool 桩总分 ~73≥70 真入池;review 桩(q=60)总分 ~63 仍复看。
  405. "digg_count": 500_000,
  406. "comment_count": 24_000,
  407. "share_count": 40_000,
  408. "collect_count": 120_000,
  409. "play_count": 0,
  410. },
  411. "tags": [],
  412. "play_url": f"https://video.test/{content_id}.mp4",
  413. "has_more": has_more,
  414. "next_cursor": cursor,
  415. "score": 72,
  416. "risk_level": "unknown",
  417. "discovery_relation": "derived_from_pattern_demand",
  418. "discovery_start_source": "pattern_itemset",
  419. "previous_discovery_step": "search_query_direct",
  420. "content_metadata_source": "fake_progressive_search",
  421. "platform_auth_mode": "no_bearer",
  422. "platform_raw_payload": {"channel_content_id": content_id},
  423. }