test_progressive_screening.py 20 KB

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