test_pattern_recall_archive.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576
  1. from __future__ import annotations
  2. import threading
  3. import time
  4. from content_agent.business_modules.content_discovery.pattern_recall import recall_decision
  5. from content_agent.business_modules.content_discovery.pattern_recall import media_pipeline
  6. from content_agent.integrations.runtime_files import LocalRuntimeFileStore
  7. from tests.gemini_helpers import FakeGeminiVideoClient, fake_gemini_fail, fake_gemini_pool, fake_gemini_review
  8. def test_pattern_recall_marks_all_judged_videos_for_oss_archive(tmp_path):
  9. run_id = "run_001"
  10. policy_run_id = "policy_run_001"
  11. runtime = LocalRuntimeFileStore(tmp_path / "runtime")
  12. runtime.prepare_run(run_id)
  13. items = [
  14. _item(run_id, policy_run_id, "content_pool", "d_001"),
  15. _item(run_id, policy_run_id, "content_review", "d_002"),
  16. _item(run_id, policy_run_id, "content_failed", "d_003"),
  17. ]
  18. media = [_media(run_id, policy_run_id, item["platform_content_id"]) for item in items]
  19. bundles = [_bundle(item) for item in items]
  20. pool_result = fake_gemini_pool()
  21. pool_result["timing_metrics"] = {
  22. "video_fetch": {"download_duration_ms": 100, "ffmpeg_duration_ms": 200},
  23. "gemini_request": {"total_duration_ms": 300},
  24. }
  25. gemini = FakeGeminiVideoClient(
  26. result_by_content_id={
  27. "content_pool": pool_result,
  28. "content_review": fake_gemini_review(),
  29. "content_failed": fake_gemini_fail("gemini_client_timeout"),
  30. }
  31. )
  32. result = recall_decision.run(
  33. run_id,
  34. policy_run_id,
  35. items,
  36. media,
  37. bundles,
  38. {"ext_data": {"evidence_pack": {"seed_terms": ["养生"]}}},
  39. runtime,
  40. gemini,
  41. )
  42. assert {
  43. row["platform_content_id"]: row["content_media_status"]
  44. for row in result["content_media_records"]
  45. } == {
  46. "content_pool": "oss_upload_pending",
  47. "content_review": "oss_upload_pending",
  48. "content_failed": "oss_upload_pending",
  49. }
  50. for row in result["content_media_records"]:
  51. assert row["raw_payload"]["oss_archive_status"] == "pending"
  52. media_by_id = {row["platform_content_id"]: row for row in result["content_media_records"]}
  53. assert media_by_id["content_pool"]["raw_payload"]["gemini_timing_metrics"]["video_fetch"] == {
  54. "download_duration_ms": 100,
  55. "ffmpeg_duration_ms": 200,
  56. }
  57. evidence_by_id = {row["platform_content_id"]: row for row in result["pattern_recall_evidence"]}
  58. assert evidence_by_id["content_pool"]["evidence_summary"]["timing_metrics"]["gemini_request"] == {
  59. "total_duration_ms": 300,
  60. }
  61. def test_pattern_recall_submits_oss_archive_before_gemini_judgment(tmp_path):
  62. run_id = "run_001"
  63. policy_run_id = "policy_run_001"
  64. runtime = LocalRuntimeFileStore(tmp_path / "runtime")
  65. runtime.prepare_run(run_id)
  66. item = _item(run_id, policy_run_id, "content_pool", "d_001")
  67. media = _media(run_id, policy_run_id, item["platform_content_id"])
  68. bundle = _bundle(item)
  69. dispatcher = RecordingArchiveDispatcher()
  70. gemini = ArchiveAwareGeminiVideoClient(dispatcher)
  71. recall_decision.run(
  72. run_id,
  73. policy_run_id,
  74. [item],
  75. [media],
  76. [bundle],
  77. {"ext_data": {"evidence_pack": {"seed_terms": ["养生"]}}},
  78. runtime,
  79. gemini,
  80. archive_dispatcher=dispatcher,
  81. )
  82. assert dispatcher.submitted_content_ids == ["content_pool"]
  83. assert gemini.calls
  84. def test_pattern_recall_waits_for_oss_archive_before_gemini_judgment(tmp_path):
  85. run_id = "run_001"
  86. policy_run_id = "policy_run_001"
  87. runtime = LocalRuntimeFileStore(tmp_path / "runtime")
  88. runtime.prepare_run(run_id)
  89. item = _item(run_id, policy_run_id, "content_pool", "d_001")
  90. media = _media(run_id, policy_run_id, item["platform_content_id"])
  91. bundle = _bundle(item)
  92. dispatcher = SynchronousArchiveDispatcher()
  93. gemini = OssAwareGeminiVideoClient()
  94. result = recall_decision.run(
  95. run_id,
  96. policy_run_id,
  97. [item],
  98. [media],
  99. [bundle],
  100. {"ext_data": {"evidence_pack": {"seed_terms": ["养生"]}}},
  101. runtime,
  102. gemini,
  103. archive_dispatcher=dispatcher,
  104. )
  105. assert dispatcher.archived_content_ids == ["content_pool"]
  106. assert gemini.calls[0]["media"]["oss_url"] == "https://res.example/content_pool.mp4"
  107. [row] = result["content_media_records"]
  108. assert row["content_media_status"] == "oss_uploaded"
  109. assert row["oss_url"] == "https://res.example/content_pool.mp4"
  110. assert row["raw_payload"]["gemini_timing_metrics"]["video_fetch"]["gemini_video_source"] == "oss_url"
  111. def test_pattern_recall_pipelines_qwen_after_each_oss_completion(tmp_path, monkeypatch):
  112. monkeypatch.setattr(recall_decision, "_resolve_max_workers", lambda: 4)
  113. run_id = "run_001"
  114. policy_run_id = "policy_run_001"
  115. runtime = LocalRuntimeFileStore(tmp_path / "runtime")
  116. runtime.prepare_run(run_id)
  117. items = [
  118. _item(run_id, policy_run_id, "slow", "d_001"),
  119. _item(run_id, policy_run_id, "fast", "d_002"),
  120. ]
  121. media = [_media(run_id, policy_run_id, item["platform_content_id"]) for item in items]
  122. bundles = [_bundle(item) for item in items]
  123. events: list[str] = []
  124. lock = threading.Lock()
  125. dispatcher = DelayedArchiveDispatcher(events, lock)
  126. gemini = EventGeminiVideoClient(events, lock)
  127. recall_decision.run(
  128. run_id,
  129. policy_run_id,
  130. items,
  131. media,
  132. bundles,
  133. {"ext_data": {"evidence_pack": {"seed_terms": ["养生"]}}},
  134. runtime,
  135. gemini,
  136. archive_dispatcher=dispatcher,
  137. )
  138. assert events.index("qwen:fast") < events.index("oss_end:slow")
  139. tasks = runtime.read_media_pipeline_tasks(run_id)
  140. assert {task["task_type"] for task in tasks} == {"oss_upload", "qwen_judgment"}
  141. assert len(tasks) == 4
  142. def test_qwen_retry_wait_does_not_occupy_worker(tmp_path, monkeypatch):
  143. monkeypatch.setattr(recall_decision, "_resolve_max_workers", lambda: 1)
  144. monkeypatch.setattr(media_pipeline, "_base_qwen_retry_delay", lambda judgment, attempt_count: 0.03)
  145. run_id = "run_001"
  146. policy_run_id = "policy_run_001"
  147. runtime = LocalRuntimeFileStore(tmp_path / "runtime")
  148. runtime.prepare_run(run_id)
  149. items = [
  150. _item(run_id, policy_run_id, "retry", "d_001"),
  151. _item(run_id, policy_run_id, "other", "d_002"),
  152. ]
  153. media = [_media(run_id, policy_run_id, item["platform_content_id"]) for item in items]
  154. bundles = [_bundle(item) for item in items]
  155. events: list[str] = []
  156. dispatcher = SynchronousArchiveDispatcher()
  157. qwen = RetryOnceQwenClient(events)
  158. result = recall_decision.run(
  159. run_id,
  160. policy_run_id,
  161. items,
  162. media,
  163. bundles,
  164. {"ext_data": {"evidence_pack": {"seed_terms": ["养生"]}}},
  165. runtime,
  166. qwen,
  167. archive_dispatcher=dispatcher,
  168. )
  169. assert events[:3] == ["qwen:retry:1", "qwen:other:1", "qwen:retry:2"]
  170. evidence = {row["platform_content_id"]: row for row in result["pattern_recall_evidence"]}
  171. assert evidence["retry"]["recall_status"] == "judged"
  172. tasks = [
  173. task
  174. for task in runtime.read_media_pipeline_tasks(run_id)
  175. if task["task_type"] == "qwen_judgment"
  176. ]
  177. retry_task = next(task for task in tasks if task["platform_content_id"] == "retry")
  178. assert retry_task["attempt_count"] == 2
  179. assert retry_task["next_retry_at"] is None
  180. assert retry_task["input_payload"]["qwen_retry_policy_version"] == media_pipeline.QWEN_RETRY_POLICY_VERSION
  181. def test_qwen_rate_limit_lowers_dynamic_worker_limit():
  182. now = time.monotonic()
  183. assert media_pipeline._dynamic_qwen_limit(5, now + 30, now) == 2
  184. assert media_pipeline._dynamic_qwen_limit(5, now - 1, now) == 5
  185. def test_pattern_recall_skips_qwen_when_oss_archive_is_pending(tmp_path):
  186. run_id = "run_001"
  187. policy_run_id = "policy_run_001"
  188. runtime = LocalRuntimeFileStore(tmp_path / "runtime")
  189. runtime.prepare_run(run_id)
  190. item = _item(run_id, policy_run_id, "content_pool", "d_001")
  191. media = _media(run_id, policy_run_id, item["platform_content_id"])
  192. bundle = _bundle(item)
  193. dispatcher = PendingArchiveDispatcher()
  194. gemini = PlayUrlAwareGeminiVideoClient()
  195. result = recall_decision.run(
  196. run_id,
  197. policy_run_id,
  198. [item],
  199. [media],
  200. [bundle],
  201. {"ext_data": {"evidence_pack": {"seed_terms": ["养生"]}}},
  202. runtime,
  203. gemini,
  204. archive_dispatcher=dispatcher,
  205. )
  206. assert gemini.calls == []
  207. [row] = result["content_media_records"]
  208. assert row["content_media_status"] == "oss_upload_pending"
  209. assert row["raw_payload"]["oss_archive_last_error"] == "oss_upload_http_error"
  210. [evidence] = result["pattern_recall_evidence"]
  211. assert evidence["recall_status"] == "failed"
  212. assert evidence["evidence_summary"]["failure_type"] == "oss_upload_http_error"
  213. def test_pattern_recall_enqueues_qwen_after_oss_fallback_candidate_succeeds(tmp_path):
  214. run_id = "run_001"
  215. policy_run_id = "policy_run_001"
  216. runtime = LocalRuntimeFileStore(tmp_path / "runtime")
  217. runtime.prepare_run(run_id)
  218. item = _item(run_id, policy_run_id, "content_pool", "d_001")
  219. media = _media(run_id, policy_run_id, item["platform_content_id"])
  220. media["raw_payload"]["video_url_candidates"] = [
  221. {
  222. "url": "https://source.example/content_pool.mp4",
  223. "host": "source.example",
  224. "path": "$.search.video_url_list[0].video_url",
  225. "source": "search",
  226. "candidate_index": 0,
  227. },
  228. {
  229. "url": "https://backup.example/content_pool.mp4",
  230. "host": "backup.example",
  231. "path": "$.detail.video_url_list[0].video_url",
  232. "source": "detail",
  233. "candidate_index": 1,
  234. },
  235. ]
  236. bundle = _bundle(item)
  237. dispatcher = FallbackUploadDispatcher()
  238. gemini = OssAwareGeminiVideoClient()
  239. result = recall_decision.run(
  240. run_id,
  241. policy_run_id,
  242. [item],
  243. [media],
  244. [bundle],
  245. {"ext_data": {"evidence_pack": {"seed_terms": ["养生"]}}},
  246. runtime,
  247. gemini,
  248. archive_dispatcher=dispatcher,
  249. )
  250. assert dispatcher.calls == [
  251. "https://source.example/content_pool.mp4",
  252. "https://backup.example/content_pool.mp4",
  253. ]
  254. assert gemini.calls
  255. [row] = result["content_media_records"]
  256. assert row["content_media_status"] == "oss_uploaded"
  257. assert row["raw_payload"]["oss_archive_selected_video_url_host"] == "backup.example"
  258. assert row["raw_payload"]["oss_url_attempts"][0]["failure_type"] == "oss_upload_http_error"
  259. tasks = runtime.read_media_pipeline_tasks(run_id)
  260. assert {task["task_type"] for task in tasks} == {"oss_upload", "qwen_judgment"}
  261. class RecordingArchiveDispatcher:
  262. def __init__(self) -> None:
  263. self.submitted_content_ids: list[str] = []
  264. def archive_records(self, records: list[dict]) -> list[dict]:
  265. self.submitted_content_ids.extend(row["platform_content_id"] for row in records)
  266. archived = []
  267. for row in records:
  268. content_id = row["platform_content_id"]
  269. raw_payload = {
  270. **(row.get("raw_payload") or {}),
  271. "content_media_status": "oss_uploaded",
  272. "oss_url": f"https://res.example/{content_id}.mp4",
  273. "oss_archive_status": "uploaded",
  274. }
  275. archived.append(
  276. {
  277. **row,
  278. "content_media_status": "oss_uploaded",
  279. "oss_url": f"https://res.example/{content_id}.mp4",
  280. "raw_payload": raw_payload,
  281. }
  282. )
  283. return archived
  284. class SynchronousArchiveDispatcher:
  285. def __init__(self) -> None:
  286. self.archived_content_ids: list[str] = []
  287. def archive_records(self, records: list[dict]) -> list[dict]:
  288. archived = []
  289. for row in records:
  290. content_id = row["platform_content_id"]
  291. self.archived_content_ids.append(content_id)
  292. raw_payload = {
  293. **(row.get("raw_payload") or {}),
  294. "content_media_status": "oss_uploaded",
  295. "oss_url": f"https://res.example/{content_id}.mp4",
  296. "oss_archive_status": "uploaded",
  297. "oss_object_key": f"crawler/video/{content_id}.mp4",
  298. }
  299. archived.append(
  300. {
  301. **row,
  302. "content_media_status": "oss_uploaded",
  303. "oss_url": f"https://res.example/{content_id}.mp4",
  304. "raw_payload": raw_payload,
  305. }
  306. )
  307. return archived
  308. class PendingArchiveDispatcher:
  309. def archive_records(self, records: list[dict]) -> list[dict]:
  310. archived = []
  311. for row in records:
  312. raw_payload = {
  313. **(row.get("raw_payload") or {}),
  314. "content_media_status": "oss_upload_pending",
  315. "oss_archive_status": "pending",
  316. "oss_archive_last_error": "oss_upload_http_error",
  317. "oss_archive_last_exception_type": "HTTPStatusError",
  318. }
  319. archived.append(
  320. {
  321. **row,
  322. "content_media_status": "oss_upload_pending",
  323. "oss_url": None,
  324. "raw_payload": raw_payload,
  325. }
  326. )
  327. return archived
  328. class FallbackUploadDispatcher:
  329. def __init__(self) -> None:
  330. self.calls: list[str] = []
  331. def upload_fn(self, src_url: str, **kwargs) -> dict:
  332. self.calls.append(src_url)
  333. if src_url.startswith("https://source.example/"):
  334. return {
  335. "status": "failed",
  336. "failure_type": "oss_upload_http_error",
  337. "exception_type": "ReadTimeout",
  338. "response_absent": True,
  339. }
  340. return {
  341. "status": "ok",
  342. "oss_url": "https://res.example/content_pool.mp4",
  343. "oss_object_key": "crawler/video/content_pool.mp4",
  344. }
  345. class DelayedArchiveDispatcher:
  346. def __init__(self, events: list[str], lock: threading.Lock) -> None:
  347. self.events = events
  348. self.lock = lock
  349. def archive_records(self, records: list[dict]) -> list[dict]:
  350. archived = []
  351. for row in records:
  352. content_id = row["platform_content_id"]
  353. with self.lock:
  354. self.events.append(f"oss_start:{content_id}")
  355. if content_id == "slow":
  356. time.sleep(0.08)
  357. else:
  358. time.sleep(0.01)
  359. with self.lock:
  360. self.events.append(f"oss_end:{content_id}")
  361. raw_payload = {
  362. **(row.get("raw_payload") or {}),
  363. "content_media_status": "oss_uploaded",
  364. "oss_url": f"https://res.example/{content_id}.mp4",
  365. "oss_archive_status": "uploaded",
  366. }
  367. archived.append(
  368. {
  369. **row,
  370. "content_media_status": "oss_uploaded",
  371. "oss_url": f"https://res.example/{content_id}.mp4",
  372. "raw_payload": raw_payload,
  373. }
  374. )
  375. return archived
  376. class ArchiveAwareGeminiVideoClient(FakeGeminiVideoClient):
  377. def __init__(self, dispatcher: RecordingArchiveDispatcher) -> None:
  378. super().__init__(default_result=fake_gemini_pool())
  379. self.dispatcher = dispatcher
  380. def analyze(self, content: dict, media: dict, source_context: dict) -> dict:
  381. assert self.dispatcher.submitted_content_ids == [content["platform_content_id"]]
  382. return super().analyze(content, media, source_context)
  383. class PlayUrlAwareGeminiVideoClient(FakeGeminiVideoClient):
  384. def __init__(self) -> None:
  385. result = fake_gemini_pool()
  386. result["timing_metrics"] = {
  387. "video_fetch": {"gemini_video_source": "play_url"},
  388. }
  389. super().__init__(default_result=result)
  390. def analyze(self, content: dict, media: dict, source_context: dict) -> dict:
  391. assert media.get("oss_url") is None
  392. assert media["play_url"].startswith("https://source.example/")
  393. return super().analyze(content, media, source_context)
  394. class OssAwareGeminiVideoClient(FakeGeminiVideoClient):
  395. def __init__(self) -> None:
  396. result = fake_gemini_pool()
  397. result["timing_metrics"] = {
  398. "video_fetch": {"gemini_video_source": "oss_url"},
  399. }
  400. super().__init__(default_result=result)
  401. def analyze(self, content: dict, media: dict, source_context: dict) -> dict:
  402. assert media["oss_url"] == f"https://res.example/{content['platform_content_id']}.mp4"
  403. assert media["content_media_status"] == "oss_uploaded"
  404. return super().analyze(content, media, source_context)
  405. class EventGeminiVideoClient(OssAwareGeminiVideoClient):
  406. def __init__(self, events: list[str], lock: threading.Lock) -> None:
  407. super().__init__()
  408. self.events = events
  409. self.lock = lock
  410. def analyze(self, content: dict, media: dict, source_context: dict) -> dict:
  411. with self.lock:
  412. self.events.append(f"qwen:{content['platform_content_id']}")
  413. return super().analyze(content, media, source_context)
  414. class RetryOnceQwenClient:
  415. def __init__(self, events: list[str]) -> None:
  416. self.events = events
  417. self.attempts_by_content_id: dict[str, int] = {}
  418. def analyze_once(
  419. self,
  420. content: dict,
  421. media: dict,
  422. source_context: dict,
  423. *,
  424. attempt_number: int = 1,
  425. previous_attempts: list[dict] | None = None,
  426. request_started_at=None,
  427. ) -> dict:
  428. content_id = content["platform_content_id"]
  429. self.attempts_by_content_id[content_id] = self.attempts_by_content_id.get(content_id, 0) + 1
  430. attempt = self.attempts_by_content_id[content_id]
  431. self.events.append(f"qwen:{content_id}:{attempt}")
  432. attempts = [
  433. *(previous_attempts or []),
  434. {
  435. "attempt": attempt_number,
  436. "status": "failed" if content_id == "retry" and attempt == 1 else "ok",
  437. "failure_type": "qwen_http_error" if content_id == "retry" and attempt == 1 else None,
  438. "http_status_code": 429 if content_id == "retry" and attempt == 1 else 200,
  439. },
  440. ]
  441. if content_id == "retry" and attempt == 1:
  442. result = fake_gemini_fail("qwen_http_error")
  443. return {
  444. **result,
  445. "http_status_code": 429,
  446. "qwen_retryable": True,
  447. "qwen_retry_after_seconds": 0.03,
  448. "timing_metrics": {"qwen_request": {"attempts": attempts}},
  449. }
  450. result = fake_gemini_pool()
  451. return {
  452. **result,
  453. "timing_metrics": {"qwen_request": {"attempts": attempts}},
  454. }
  455. def analyze(self, content: dict, media: dict, source_context: dict) -> dict:
  456. return self.analyze_once(content, media, source_context)
  457. def _item(run_id: str, policy_run_id: str, content_id: str, discovery_id: str) -> dict:
  458. return {
  459. "record_schema_version": "runtime_record.v1",
  460. "run_id": run_id,
  461. "policy_run_id": policy_run_id,
  462. "content_discovery_id": discovery_id,
  463. "search_query_id": "q_001",
  464. "platform": "douyin",
  465. "platform_content_id": content_id,
  466. "platform_content_format": "video",
  467. "description": "stub",
  468. "platform_author_id": "author_001",
  469. "author_display_name": "author",
  470. "statistics": {"digg_count": 100, "comment_count": 10, "share_count": 5},
  471. "tags": [],
  472. "discovery_start_source": "pattern_itemset",
  473. "previous_discovery_step": "query_direct",
  474. "raw_payload": {"platform_content_id": content_id},
  475. }
  476. def _media(run_id: str, policy_run_id: str, content_id: str) -> dict:
  477. return {
  478. "record_schema_version": "runtime_record.v1",
  479. "run_id": run_id,
  480. "policy_run_id": policy_run_id,
  481. "platform": "douyin",
  482. "platform_content_id": content_id,
  483. "content_media_status": "metadata_only",
  484. "content_metadata_source": "douyin_keyword_search",
  485. "play_url": f"https://source.example/{content_id}.mp4",
  486. "local_path": None,
  487. "oss_url": None,
  488. "raw_payload": {"platform_content_id": content_id},
  489. }
  490. def _bundle(item: dict) -> dict:
  491. return {
  492. "source_evidence": {"discovered_platform_content_id": item["platform_content_id"]},
  493. "content": {
  494. "decision_target_type": "content",
  495. "decision_target_id": item["platform_content_id"],
  496. "content_discovery_id": item["content_discovery_id"],
  497. "search_query_id": item["search_query_id"],
  498. "platform": item["platform"],
  499. "platform_content_id": item["platform_content_id"],
  500. "platform_content_format": "video",
  501. "description": item["description"],
  502. },
  503. "pattern_match_result": {},
  504. "content_engagement_metrics": {},
  505. "run_context": {
  506. "decision_input_snapshot_id": f"evidence_bundle:{item['platform_content_id']}",
  507. },
  508. }