test_acquisition_runner.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975
  1. from __future__ import annotations
  2. from uuid import UUID, uuid4
  3. from acquisition.classification.coarse import ClassificationResult
  4. from acquisition.domain import (
  5. AcquisitionJob,
  6. AcquisitionRun,
  7. CandidateItem,
  8. ItemClassification,
  9. MediaAsset,
  10. Query,
  11. QueryBatch,
  12. )
  13. from acquisition.media.service import StabilizedMedia
  14. from acquisition.platforms.base import PlatformCandidate, PlatformItem, PlatformSearchPage
  15. from acquisition.runner import run_batch
  16. from core.config import PgConfig, Settings
  17. def _settings() -> Settings:
  18. return Settings(
  19. pg=PgConfig(host="h", port=5432, user="u", password="p", database="d"),
  20. aiddit_crawler_base_url="http://crawler.test",
  21. crawler_timeout=30,
  22. openrouter_timeout_seconds=90,
  23. openrouter_model="m",
  24. openrouter_base_url="http://openrouter.test",
  25. openrouter_api_key="k",
  26. llm_model="m",
  27. max_cards=12,
  28. frames_dir="f",
  29. douyin_ratio="540p",
  30. data_dir="data",
  31. )
  32. class FakeRepo:
  33. def __init__(self, *, job_status: str = "pending") -> None:
  34. self.batch_id = uuid4()
  35. self.query = Query(
  36. id=uuid4(),
  37. batch_id=self.batch_id,
  38. query_text="脚本 开头 怎么做",
  39. keep=True,
  40. status="ready",
  41. )
  42. self.job_status = job_status
  43. self.runs: list[AcquisitionRun] = []
  44. self.jobs: list[AcquisitionJob] = []
  45. self.updated_jobs: list[AcquisitionJob] = []
  46. self.items: list[CandidateItem] = []
  47. self.media: list[MediaAsset] = []
  48. self.classifications: list[ItemClassification] = []
  49. self.existing_by_unique_key: dict[str, CandidateItem] = {}
  50. self.attached_items: list[CandidateItem] = []
  51. def create_query_batch(self, **kwargs):
  52. return QueryBatch(id=self.batch_id, **kwargs)
  53. def add_query(self, **kwargs):
  54. return Query(id=uuid4(), **kwargs)
  55. def list_queries_for_batch(self, batch_id: UUID, *, keep: bool | None = None):
  56. assert batch_id == self.batch_id
  57. assert keep is True
  58. return [self.query]
  59. def create_acquisition_run(self, **kwargs):
  60. run = AcquisitionRun(id=uuid4(), **kwargs)
  61. self.runs.append(run)
  62. return run
  63. def ensure_acquisition_job(self, **kwargs):
  64. kwargs["status"] = self.job_status
  65. job = AcquisitionJob(id=uuid4(), **kwargs)
  66. self.jobs.append(job)
  67. return job
  68. def update_acquisition_job(self, job_id: UUID, **kwargs):
  69. original = next(job for job in self.jobs if job.id == job_id)
  70. updated = original.model_copy(update=kwargs)
  71. self.updated_jobs.append(updated)
  72. return updated
  73. def upsert_candidate_item(self, **kwargs):
  74. item = CandidateItem(id=uuid4(), **kwargs)
  75. self.items.append(item)
  76. return item
  77. def get_candidate_item_by_unique_key(self, unique_key: str):
  78. return self.existing_by_unique_key.get(unique_key)
  79. def attach_existing_candidate_item(self, item_id: UUID, *, job_id: UUID, query_id: UUID, metadata=None):
  80. item = next(row for row in self.existing_by_unique_key.values() if row.id == item_id)
  81. updated = item.model_copy(update={"job_id": job_id, "query_id": query_id, "metadata": metadata or {}})
  82. self.attached_items.append(updated)
  83. return updated
  84. def add_media_asset(self, **kwargs):
  85. row = MediaAsset(id=uuid4(), **kwargs)
  86. self.media.append(row)
  87. return row
  88. def add_item_classification(self, **kwargs):
  89. row = ItemClassification(id=uuid4(), **kwargs)
  90. self.classifications.append(row)
  91. return row
  92. def get_run_summary(self, run_id: UUID):
  93. return {}
  94. def get_query_detail(self, *, run_id: UUID, query_id: UUID):
  95. return {}
  96. class FakeAdapter:
  97. platform = "weixin"
  98. url = (
  99. "https://mp.weixin.qq.com/s?__biz=MzUxMDg4NDY1Ng=="
  100. "&mid=2247484898&idx=2&sn=5069e65919f414b96b7aec53671dc1b5"
  101. )
  102. def __init__(self) -> None:
  103. self.search_calls = 0
  104. self.detail_calls = 0
  105. def search(self, query, *, settings, limit, rate_limiter):
  106. self.search_calls += 1
  107. assert query == "脚本 开头 怎么做"
  108. assert limit == 2
  109. return [
  110. PlatformCandidate(
  111. rank=1,
  112. platform=self.platform,
  113. source_id="wx1",
  114. url=self.url,
  115. title="公众号脚本",
  116. author="作者",
  117. cover_url="https://img.test/a.jpg",
  118. )
  119. ]
  120. def fetch_detail(self, candidate, *, settings, rate_limiter):
  121. self.detail_calls += 1
  122. return PlatformItem(
  123. platform=self.platform,
  124. source_id=candidate.source_id,
  125. url=candidate.url,
  126. content_type="图文",
  127. title="公众号脚本",
  128. author="作者",
  129. body_text="先定受众再写开头",
  130. image_urls=["https://img.test/a.jpg"],
  131. raw={"ok": True},
  132. )
  133. class PartialAdapter(FakeAdapter):
  134. def search(self, query, *, settings, limit, rate_limiter):
  135. self.search_calls += 1
  136. return [
  137. PlatformCandidate(rank=1, platform=self.platform, source_id="bad", url="https://bad"),
  138. PlatformCandidate(rank=2, platform=self.platform, source_id="good", url="https://good"),
  139. ]
  140. def fetch_detail(self, candidate, *, settings, rate_limiter):
  141. self.detail_calls += 1
  142. if candidate.source_id == "bad":
  143. raise RuntimeError("detail boom")
  144. return PlatformItem(
  145. platform=self.platform,
  146. source_id=candidate.source_id,
  147. url=candidate.url,
  148. content_type="图文",
  149. title="可用详情",
  150. author="作者",
  151. body_text="先定受众",
  152. image_urls=[],
  153. raw={},
  154. )
  155. class FailingSearchAdapter(FakeAdapter):
  156. def search(self, query, *, settings, limit, rate_limiter):
  157. self.search_calls += 1
  158. raise RuntimeError("search boom")
  159. class UnsupportedModeAdapter(FakeAdapter):
  160. def fetch_detail(self, candidate, *, settings, rate_limiter):
  161. self.detail_calls += 1
  162. return PlatformItem(
  163. platform=self.platform,
  164. source_id=candidate.source_id,
  165. url=candidate.url,
  166. content_type="mystery",
  167. content_mode="unsupported",
  168. title="未知类型",
  169. body_text="",
  170. image_urls=[],
  171. video_urls=[],
  172. raw={},
  173. )
  174. class MissingVideoAdapter(FakeAdapter):
  175. def fetch_detail(self, candidate, *, settings, rate_limiter):
  176. self.detail_calls += 1
  177. return PlatformItem(
  178. platform="douyin",
  179. source_id="dy-video",
  180. url="https://douyin.test/v",
  181. content_type="video",
  182. content_mode="video_post",
  183. title="缺视频地址",
  184. body_text="",
  185. image_urls=[],
  186. video_urls=[],
  187. raw={},
  188. )
  189. class DouyinProviderAdapter(FakeAdapter):
  190. platform = "douyin"
  191. def search(self, query, *, settings, limit, rate_limiter):
  192. self.search_calls += 1
  193. return [
  194. PlatformCandidate(
  195. rank=1,
  196. platform=self.platform,
  197. provider="aiddit",
  198. source_id="761",
  199. raw={
  200. "id": "761",
  201. "search_provider": "aiddit",
  202. "original": {"aweme_id": "761"},
  203. "request_payload": {"keyword": query},
  204. },
  205. )
  206. ]
  207. def fetch_detail(self, candidate, *, settings, rate_limiter):
  208. self.detail_calls += 1
  209. return PlatformItem(
  210. platform=self.platform,
  211. provider=candidate.provider,
  212. source_id=candidate.source_id,
  213. url="https://douyin.test/video/761",
  214. content_type="video",
  215. content_mode="video_post",
  216. title="视频标题",
  217. author="作者",
  218. body_text="视频正文",
  219. video_urls=["https://video.test/761.mp4"],
  220. raw={
  221. "detail_provider": candidate.provider,
  222. "code": 0,
  223. "data": {"data": {"channel_content_id": candidate.source_id}},
  224. },
  225. )
  226. def _paged_candidate(source_id: str, *, rank: int, page_index: int, platform: str = "douyin") -> PlatformCandidate:
  227. return PlatformCandidate(
  228. rank=rank,
  229. platform=platform,
  230. provider="aiddit" if platform == "douyin" else "",
  231. source_id=source_id,
  232. url=f"https://{platform}.test/{source_id}",
  233. title=source_id,
  234. raw={
  235. "page_index": page_index,
  236. "page_rank": rank,
  237. "source_cursor": "" if page_index == 1 else str(page_index),
  238. },
  239. )
  240. def _paged_search_page(
  241. page_index: int,
  242. source_ids: list[str],
  243. *,
  244. has_more: bool = False,
  245. next_cursor: str = "",
  246. platform: str = "douyin",
  247. ) -> PlatformSearchPage:
  248. return PlatformSearchPage(
  249. page_index=page_index,
  250. candidates=[
  251. _paged_candidate(source_id, rank=i, page_index=page_index, platform=platform)
  252. for i, source_id in enumerate(source_ids, start=1)
  253. ],
  254. raw_count=len(source_ids),
  255. cursor="" if page_index == 1 else str(page_index),
  256. next_cursor=next_cursor,
  257. has_more=has_more,
  258. provider="aiddit" if platform == "douyin" else "",
  259. )
  260. class PagedAdapter:
  261. platform = "douyin"
  262. def __init__(
  263. self,
  264. pages: list[PlatformSearchPage],
  265. details: dict[str, PlatformItem | Exception],
  266. ) -> None:
  267. self.pages = pages
  268. self.details = details
  269. self.pages_requested = 0
  270. self.detail_calls = 0
  271. def search_pages(self, query, *, settings, limit, rate_limiter, max_pages=2):
  272. for page in self.pages[:max_pages]:
  273. self.pages_requested += 1
  274. yield page
  275. def search(self, query, *, settings, limit, rate_limiter):
  276. self.pages_requested += 1
  277. return self.pages[0].candidates if self.pages else []
  278. def fetch_detail(self, candidate, *, settings, rate_limiter):
  279. self.detail_calls += 1
  280. detail = self.details[candidate.source_id]
  281. if isinstance(detail, Exception):
  282. raise detail
  283. return detail
  284. class RetryPagedAdapter:
  285. platform = "douyin"
  286. def __init__(
  287. self,
  288. page_batches: list[list[PlatformSearchPage]],
  289. details: dict[str, PlatformItem | Exception],
  290. ) -> None:
  291. self.page_batches = page_batches
  292. self.details = details
  293. self.search_calls = 0
  294. self.pages_requested = 0
  295. self.detail_calls = 0
  296. self.limiter_ids: list[int] = []
  297. def search_pages(self, query, *, settings, limit, rate_limiter, max_pages=2):
  298. self.limiter_ids.append(id(rate_limiter))
  299. batch_index = min(self.search_calls, len(self.page_batches) - 1)
  300. self.search_calls += 1
  301. for page in self.page_batches[batch_index][:max_pages]:
  302. self.pages_requested += 1
  303. yield page
  304. def fetch_detail(self, candidate, *, settings, rate_limiter):
  305. self.limiter_ids.append(id(rate_limiter))
  306. self.detail_calls += 1
  307. detail = self.details[candidate.source_id]
  308. if isinstance(detail, Exception):
  309. raise detail
  310. return detail
  311. def _detail(
  312. source_id: str,
  313. *,
  314. content_mode: str = "image_post",
  315. image_urls: list[str] | None = None,
  316. video_urls: list[str] | None = None,
  317. ) -> PlatformItem:
  318. return PlatformItem(
  319. platform="douyin",
  320. provider="aiddit",
  321. source_id=source_id,
  322. url=f"https://douyin.test/{source_id}",
  323. content_type="image" if content_mode == "image_post" else "video",
  324. content_mode=content_mode,
  325. title=source_id,
  326. body_text=f"{source_id} 正文",
  327. image_urls=image_urls if image_urls is not None else ["https://img.test/a.jpg"],
  328. video_urls=video_urls or [],
  329. raw={},
  330. )
  331. def _classification_by_title(creation_titles: set[str]):
  332. def classifier(**kwargs):
  333. is_creation = kwargs["title"] in creation_titles
  334. return ClassificationResult(
  335. is_creation_knowledge=is_creation,
  336. label="creation" if is_creation else "non_creation",
  337. confidence=0.9,
  338. reason="ok",
  339. )
  340. return classifier
  341. def test_run_batch_writes_jobs_items_media_and_classification():
  342. repo = FakeRepo()
  343. adapter = FakeAdapter()
  344. def media_stabilizer(**kwargs):
  345. assert kwargs["image_urls"] == ["https://img.test/a.jpg"]
  346. return [
  347. StabilizedMedia(
  348. media_type="image",
  349. source_url="https://img.test/a.jpg",
  350. cdn_url="https://cdn.test/a.jpg",
  351. position=1,
  352. )
  353. ]
  354. def classifier(**kwargs):
  355. assert kwargs["content_mode"] == "article"
  356. assert kwargs["image_urls"] == ["https://cdn.test/a.jpg"]
  357. return ClassificationResult(
  358. is_creation_knowledge=True,
  359. label="creation",
  360. confidence=1.0,
  361. reason="是创作知识",
  362. knowledge="先定受众",
  363. prompt_version="pv1",
  364. result_payload={"knowledge": "先定受众"},
  365. )
  366. result = run_batch(
  367. repo,
  368. batch_id=repo.batch_id,
  369. settings=_settings(),
  370. platforms=("weixin",),
  371. search_limit=2,
  372. display_limit=1,
  373. adapter_factory=lambda platform: adapter,
  374. media_stabilizer=media_stabilizer,
  375. classifier=classifier,
  376. rate_limiter_factory=lambda platform: object(),
  377. )
  378. assert result.done == 1
  379. assert result.failed == 0
  380. assert result.total_jobs == 1
  381. assert adapter.search_calls == 1
  382. assert adapter.detail_calls == 1
  383. assert repo.jobs[0].query_id == repo.query.id
  384. assert repo.items[0].platform_item_id == "wx1"
  385. assert repo.items[0].content_mode == "article"
  386. assert repo.items[0].body_text == "先定受众再写开头"
  387. assert repo.items[0].raw_summary == "先定受众再写开头"
  388. assert (
  389. repo.items[0].unique_key
  390. == "wx:MzUxMDg4NDY1Ng==:2247484898:2:5069e65919f414b96b7aec53671dc1b5"
  391. )
  392. assert repo.media[0].cdn_url == "https://cdn.test/a.jpg"
  393. assert repo.classifications[0].is_creation_knowledge is True
  394. assert repo.updated_jobs[-1].status == "done"
  395. def test_run_batch_records_unsupported_item_as_skipped_without_processing():
  396. repo = FakeRepo()
  397. adapter = UnsupportedModeAdapter()
  398. result = run_batch(
  399. repo,
  400. batch_id=repo.batch_id,
  401. settings=_settings(),
  402. platforms=("weixin",),
  403. search_limit=2,
  404. display_limit=1,
  405. adapter_factory=lambda platform: adapter,
  406. media_stabilizer=lambda **kwargs: (_ for _ in ()).throw(AssertionError("media should be skipped")),
  407. classifier=lambda **kwargs: (_ for _ in ()).throw(AssertionError("classifier should be skipped")),
  408. rate_limiter_factory=lambda platform: object(),
  409. )
  410. assert result.done == 1
  411. assert repo.items[0].status == "skipped"
  412. assert repo.items[0].content_mode == "unsupported"
  413. assert repo.items[0].metadata["skip_reason"] == "unsupported_content_mode"
  414. assert repo.media == []
  415. assert repo.classifications[0].status == "skipped"
  416. assert repo.classifications[0].label == "unsupported_content_mode"
  417. assert repo.updated_jobs[-1].metadata["skipped_item_count"] == 1
  418. def test_run_batch_records_video_missing_as_skipped_without_processing():
  419. repo = FakeRepo()
  420. adapter = MissingVideoAdapter()
  421. result = run_batch(
  422. repo,
  423. batch_id=repo.batch_id,
  424. settings=_settings(),
  425. platforms=("douyin",),
  426. search_limit=2,
  427. display_limit=1,
  428. adapter_factory=lambda platform: adapter,
  429. media_stabilizer=lambda **kwargs: (_ for _ in ()).throw(AssertionError("media should be skipped")),
  430. classifier=lambda **kwargs: (_ for _ in ()).throw(AssertionError("classifier should be skipped")),
  431. rate_limiter_factory=lambda platform: object(),
  432. )
  433. assert result.done == 1
  434. assert repo.items[0].content_mode == "video_post"
  435. assert repo.items[0].metadata["video_url_missing"] is True
  436. assert repo.classifications[0].label == "video_missing"
  437. def test_run_batch_attaches_existing_unique_key_without_fetching_detail_or_classifying():
  438. repo = FakeRepo()
  439. adapter = FakeAdapter()
  440. unique_key = "wx:MzUxMDg4NDY1Ng==:2247484898:2:5069e65919f414b96b7aec53671dc1b5"
  441. repo.existing_by_unique_key[unique_key] = CandidateItem(
  442. id=uuid4(),
  443. platform="weixin",
  444. platform_item_id="wx1",
  445. unique_key=unique_key,
  446. title="历史帖子",
  447. status="candidate",
  448. )
  449. result = run_batch(
  450. repo,
  451. batch_id=repo.batch_id,
  452. settings=_settings(),
  453. platforms=("weixin",),
  454. search_limit=2,
  455. display_limit=1,
  456. adapter_factory=lambda platform: adapter,
  457. media_stabilizer=lambda **kwargs: (_ for _ in ()).throw(AssertionError("media should be skipped")),
  458. classifier=lambda **kwargs: (_ for _ in ()).throw(AssertionError("classifier should be skipped")),
  459. rate_limiter_factory=lambda platform: object(),
  460. )
  461. assert result.done == 1
  462. assert adapter.search_calls == 1
  463. assert adapter.detail_calls == 0
  464. assert repo.items == []
  465. assert repo.media == []
  466. assert repo.classifications == []
  467. assert repo.attached_items[0].job_id == repo.jobs[0].id
  468. assert repo.attached_items[0].query_id == repo.query.id
  469. assert repo.attached_items[0].metadata["acquisition_match_status"] == "existing"
  470. assert repo.attached_items[0].metadata["matched_unique_key"] == unique_key
  471. def test_run_batch_records_douyin_provider_in_metadata_and_source_payload():
  472. repo = FakeRepo()
  473. adapter = DouyinProviderAdapter()
  474. result = run_batch(
  475. repo,
  476. batch_id=repo.batch_id,
  477. settings=_settings(),
  478. platforms=("douyin",),
  479. search_limit=1,
  480. display_limit=1,
  481. adapter_factory=lambda platform: adapter,
  482. media_stabilizer=lambda **kwargs: [
  483. StabilizedMedia(
  484. media_type="video",
  485. source_url="https://video.test/761.mp4",
  486. cdn_url="https://cdn.test/761.mp4",
  487. position=1,
  488. )
  489. ],
  490. classifier=lambda **kwargs: ClassificationResult(
  491. is_creation_knowledge=True,
  492. label="creation",
  493. confidence=0.9,
  494. reason="ok",
  495. ),
  496. rate_limiter_factory=lambda platform: object(),
  497. )
  498. assert result.done == 1
  499. item = repo.items[0]
  500. assert item.metadata["search_provider"] == "aiddit"
  501. assert item.metadata["detail_provider"] == "aiddit"
  502. assert item.source_payload["candidate"]["provider"] == "aiddit"
  503. assert item.source_payload["candidate"]["raw"]["original"] == {"aweme_id": "761"}
  504. assert item.source_payload["detail"]["detail_provider"] == "aiddit"
  505. def test_run_batch_skips_douyin_video_when_oss_transfer_fails():
  506. repo = FakeRepo()
  507. adapter = DouyinProviderAdapter()
  508. def media_stabilizer(**kwargs):
  509. assert kwargs["video_fallback_to_source"] is False
  510. assert kwargs["video_retry_delays_seconds"] == (5.0, 10.0)
  511. return [
  512. StabilizedMedia(
  513. media_type="video",
  514. source_url="https://video.test/761.mp4",
  515. cdn_url="",
  516. position=1,
  517. status="failed",
  518. error_message="oss timeout",
  519. )
  520. ]
  521. result = run_batch(
  522. repo,
  523. batch_id=repo.batch_id,
  524. settings=_settings(),
  525. platforms=("douyin",),
  526. search_limit=1,
  527. display_limit=1,
  528. adapter_factory=lambda platform: adapter,
  529. media_stabilizer=media_stabilizer,
  530. classifier=lambda **kwargs: (_ for _ in ()).throw(AssertionError("classifier should be skipped")),
  531. rate_limiter_factory=lambda platform: object(),
  532. )
  533. assert result.done == 1
  534. assert repo.media[0].status == "failed"
  535. assert repo.media[0].metadata["error_message"] == "oss timeout"
  536. assert repo.classifications[0].status == "skipped"
  537. assert repo.classifications[0].label == "video_oss_failed"
  538. assert repo.classifications[0].result_payload["media_errors"][0]["error_message"] == "oss timeout"
  539. def test_run_batch_records_duplicate_search_provider_without_fetching_detail():
  540. repo = FakeRepo()
  541. adapter = DouyinProviderAdapter()
  542. unique_key = "dy:761"
  543. repo.existing_by_unique_key[unique_key] = CandidateItem(
  544. id=uuid4(),
  545. platform="douyin",
  546. platform_item_id="761",
  547. unique_key=unique_key,
  548. title="历史视频",
  549. status="candidate",
  550. )
  551. result = run_batch(
  552. repo,
  553. batch_id=repo.batch_id,
  554. settings=_settings(),
  555. platforms=("douyin",),
  556. search_limit=1,
  557. display_limit=1,
  558. adapter_factory=lambda platform: adapter,
  559. media_stabilizer=lambda **kwargs: (_ for _ in ()).throw(AssertionError("media should be skipped")),
  560. classifier=lambda **kwargs: (_ for _ in ()).throw(AssertionError("classifier should be skipped")),
  561. rate_limiter_factory=lambda platform: object(),
  562. )
  563. assert result.done == 1
  564. assert adapter.detail_calls == 0
  565. assert repo.attached_items[0].metadata["search_provider"] == "aiddit"
  566. assert repo.attached_items[0].metadata["matched_candidate"]["provider"] == "aiddit"
  567. def test_run_batch_requests_second_page_when_first_page_creation_ratio_reaches_threshold():
  568. repo = FakeRepo()
  569. adapter = PagedAdapter(
  570. pages=[
  571. _paged_search_page(1, ["a", "b"], has_more=True, next_cursor="2"),
  572. _paged_search_page(2, ["c"], has_more=True, next_cursor="3"),
  573. _paged_search_page(3, ["d"]),
  574. ],
  575. details={source_id: _detail(source_id) for source_id in ["a", "b", "c", "d"]},
  576. )
  577. result = run_batch(
  578. repo,
  579. batch_id=repo.batch_id,
  580. settings=_settings(),
  581. platforms=("douyin",),
  582. search_limit=10,
  583. display_limit=1,
  584. adapter_factory=lambda platform: adapter,
  585. media_stabilizer=lambda **kwargs: [],
  586. classifier=_classification_by_title({"a", "c"}),
  587. rate_limiter_factory=lambda platform: object(),
  588. low_result_max_retries=0,
  589. )
  590. pagination = repo.updated_jobs[-1].metadata["pagination"]
  591. assert result.done == 1
  592. assert adapter.pages_requested == 2
  593. assert pagination["first_page_classified_count"] == 2
  594. assert pagination["first_page_creation_count"] == 1
  595. assert pagination["first_page_creation_ratio"] == 0.5
  596. assert pagination["creation_threshold"] == 0.4
  597. assert pagination["requested_second_page"] is True
  598. assert pagination["stop_reason"] == "max_pages_reached"
  599. assert len(pagination["pages"]) == 2
  600. def test_run_batch_does_not_request_second_page_below_threshold():
  601. repo = FakeRepo()
  602. adapter = PagedAdapter(
  603. pages=[
  604. _paged_search_page(1, ["a", "b"], has_more=True, next_cursor="2"),
  605. _paged_search_page(2, ["c"]),
  606. ],
  607. details={source_id: _detail(source_id) for source_id in ["a", "b", "c"]},
  608. )
  609. run_batch(
  610. repo,
  611. batch_id=repo.batch_id,
  612. settings=_settings(),
  613. platforms=("douyin",),
  614. search_limit=10,
  615. display_limit=1,
  616. adapter_factory=lambda platform: adapter,
  617. media_stabilizer=lambda **kwargs: [],
  618. classifier=_classification_by_title(set()),
  619. rate_limiter_factory=lambda platform: object(),
  620. low_result_max_retries=0,
  621. )
  622. pagination = repo.updated_jobs[-1].metadata["pagination"]
  623. assert adapter.pages_requested == 1
  624. assert pagination["first_page_creation_ratio"] == 0.0
  625. assert pagination["requested_second_page"] is False
  626. assert pagination["stop_reason"] == "below_threshold"
  627. def test_run_batch_does_not_request_second_page_when_no_classified_candidates():
  628. repo = FakeRepo()
  629. adapter = PagedAdapter(
  630. pages=[
  631. _paged_search_page(1, ["bad"], has_more=True, next_cursor="2"),
  632. _paged_search_page(2, ["c"]),
  633. ],
  634. details={"bad": RuntimeError("detail failed"), "c": _detail("c")},
  635. )
  636. run_batch(
  637. repo,
  638. batch_id=repo.batch_id,
  639. settings=_settings(),
  640. platforms=("douyin",),
  641. search_limit=10,
  642. display_limit=1,
  643. adapter_factory=lambda platform: adapter,
  644. media_stabilizer=lambda **kwargs: [],
  645. classifier=_classification_by_title({"c"}),
  646. rate_limiter_factory=lambda platform: object(),
  647. low_result_max_retries=0,
  648. )
  649. pagination = repo.updated_jobs[-1].metadata["pagination"]
  650. assert adapter.pages_requested == 1
  651. assert pagination["first_page_classified_count"] == 0
  652. assert pagination["requested_second_page"] is False
  653. assert pagination["stop_reason"] == "no_classified_candidates"
  654. def test_run_batch_excludes_duplicate_and_skipped_items_from_pagination_denominator():
  655. repo = FakeRepo()
  656. repo.existing_by_unique_key["dy:dup"] = CandidateItem(
  657. id=uuid4(),
  658. platform="douyin",
  659. platform_item_id="dup",
  660. unique_key="dy:dup",
  661. status="candidate",
  662. )
  663. adapter = PagedAdapter(
  664. pages=[
  665. _paged_search_page(
  666. 1,
  667. ["dup", "unsupported", "missing_video", "good"],
  668. has_more=True,
  669. next_cursor="2",
  670. ),
  671. _paged_search_page(2, ["next"]),
  672. ],
  673. details={
  674. "unsupported": _detail("unsupported", content_mode="unsupported", image_urls=[]),
  675. "missing_video": _detail("missing_video", content_mode="video_post", image_urls=[]),
  676. "good": _detail("good"),
  677. "next": _detail("next"),
  678. },
  679. )
  680. run_batch(
  681. repo,
  682. batch_id=repo.batch_id,
  683. settings=_settings(),
  684. platforms=("douyin",),
  685. search_limit=10,
  686. display_limit=1,
  687. adapter_factory=lambda platform: adapter,
  688. media_stabilizer=lambda **kwargs: [],
  689. classifier=_classification_by_title({"good", "next"}),
  690. rate_limiter_factory=lambda platform: object(),
  691. low_result_max_retries=0,
  692. )
  693. pagination = repo.updated_jobs[-1].metadata["pagination"]
  694. assert adapter.pages_requested == 2
  695. assert pagination["first_page_classified_count"] == 1
  696. assert pagination["first_page_creation_count"] == 1
  697. assert pagination["first_page_creation_ratio"] == 1.0
  698. assert pagination["requested_second_page"] is True
  699. def test_run_batch_threshold_is_configurable():
  700. repo = FakeRepo()
  701. adapter = PagedAdapter(
  702. pages=[
  703. _paged_search_page(1, ["a", "b"], has_more=True, next_cursor="2"),
  704. _paged_search_page(2, ["c"]),
  705. ],
  706. details={source_id: _detail(source_id) for source_id in ["a", "b", "c"]},
  707. )
  708. run_batch(
  709. repo,
  710. batch_id=repo.batch_id,
  711. settings=_settings(),
  712. platforms=("douyin",),
  713. search_limit=10,
  714. display_limit=1,
  715. adapter_factory=lambda platform: adapter,
  716. media_stabilizer=lambda **kwargs: [],
  717. classifier=_classification_by_title({"a"}),
  718. rate_limiter_factory=lambda platform: object(),
  719. pagination_creation_threshold=0.75,
  720. low_result_max_retries=0,
  721. )
  722. pagination = repo.updated_jobs[-1].metadata["pagination"]
  723. assert adapter.pages_requested == 1
  724. assert pagination["creation_threshold"] == 0.75
  725. assert pagination["stop_reason"] == "below_threshold"
  726. def test_run_batch_skip_done_does_not_call_platform():
  727. repo = FakeRepo(job_status="done")
  728. adapter = FakeAdapter()
  729. result = run_batch(
  730. repo,
  731. batch_id=repo.batch_id,
  732. settings=_settings(),
  733. platforms=("weixin",),
  734. adapter_factory=lambda platform: adapter,
  735. )
  736. assert result.skipped == 1
  737. assert adapter.search_calls == 0
  738. assert repo.updated_jobs == []
  739. def test_run_batch_marks_done_when_some_details_fail_but_an_item_displays():
  740. repo = FakeRepo()
  741. adapter = PartialAdapter()
  742. result = run_batch(
  743. repo,
  744. batch_id=repo.batch_id,
  745. settings=_settings(),
  746. platforms=("weixin",),
  747. search_limit=2,
  748. display_limit=2,
  749. adapter_factory=lambda platform: adapter,
  750. media_stabilizer=lambda **kwargs: [],
  751. classifier=lambda **kwargs: ClassificationResult(
  752. is_creation_knowledge=True,
  753. label="creation",
  754. confidence=1.0,
  755. reason="ok",
  756. ),
  757. rate_limiter_factory=lambda platform: object(),
  758. )
  759. assert result.partial == 0
  760. assert result.done == 1
  761. assert result.failed == 0
  762. assert repo.updated_jobs[-1].status == "done"
  763. assert repo.updated_jobs[-1].metadata["display_count"] == 1
  764. assert repo.updated_jobs[-1].metadata["errors"] == ["detail boom"]
  765. def test_run_batch_retries_low_result_platform_once_and_merges_unique_results():
  766. repo = FakeRepo()
  767. adapter = RetryPagedAdapter(
  768. page_batches=[
  769. [_paged_search_page(1, ["a", "b", "dup"])],
  770. [_paged_search_page(1, ["dup", "c", "d", "e", "f", "g"])],
  771. ],
  772. details={source_id: _detail(source_id) for source_id in ["a", "b", "dup", "c", "d", "e", "f", "g"]},
  773. )
  774. limiter = object()
  775. result = run_batch(
  776. repo,
  777. batch_id=repo.batch_id,
  778. settings=_settings(),
  779. platforms=("douyin",),
  780. search_limit=10,
  781. display_limit=5,
  782. adapter_factory=lambda platform: adapter,
  783. media_stabilizer=lambda **kwargs: [],
  784. classifier=_classification_by_title(set()),
  785. rate_limiter_factory=lambda platform: limiter,
  786. )
  787. metadata = repo.updated_jobs[-1].metadata
  788. assert result.done == 1
  789. assert result.partial == 0
  790. assert repo.updated_jobs[-1].status == "done"
  791. assert adapter.search_calls == 2
  792. assert adapter.detail_calls == 8
  793. assert len(repo.items) == 8
  794. assert metadata["display_count"] == 8
  795. assert metadata["searched_count"] == 8
  796. assert metadata["low_result_retry"]["triggered"] is True
  797. assert metadata["low_result_retry"]["attempt_count"] == 2
  798. assert metadata["low_result_retry"]["attempts"][0]["display_count"] == 3
  799. assert metadata["low_result_retry"]["attempts"][1]["display_count"] == 5
  800. assert {item.platform_item_id for item in repo.items} == {"a", "b", "dup", "c", "d", "e", "f", "g"}
  801. assert set(adapter.limiter_ids) == {id(limiter)}
  802. def test_run_batch_does_not_retry_when_first_attempt_is_above_low_result_threshold():
  803. repo = FakeRepo()
  804. adapter = RetryPagedAdapter(
  805. page_batches=[
  806. [_paged_search_page(1, ["a", "b", "c", "d", "e", "f"])],
  807. [_paged_search_page(1, ["g"])],
  808. ],
  809. details={source_id: _detail(source_id) for source_id in ["a", "b", "c", "d", "e", "f", "g"]},
  810. )
  811. run_batch(
  812. repo,
  813. batch_id=repo.batch_id,
  814. settings=_settings(),
  815. platforms=("douyin",),
  816. search_limit=10,
  817. display_limit=5,
  818. adapter_factory=lambda platform: adapter,
  819. media_stabilizer=lambda **kwargs: [],
  820. classifier=_classification_by_title(set()),
  821. rate_limiter_factory=lambda platform: object(),
  822. )
  823. metadata = repo.updated_jobs[-1].metadata
  824. assert adapter.search_calls == 1
  825. assert metadata["display_count"] == 6
  826. assert metadata["low_result_retry"]["triggered"] is False
  827. def test_run_batch_marks_failed_when_search_fails_before_any_item():
  828. repo = FakeRepo()
  829. adapter = FailingSearchAdapter()
  830. result = run_batch(
  831. repo,
  832. batch_id=repo.batch_id,
  833. settings=_settings(),
  834. platforms=("weixin",),
  835. adapter_factory=lambda platform: adapter,
  836. rate_limiter_factory=lambda platform: object(),
  837. )
  838. assert result.failed == 1
  839. assert result.done == 0
  840. assert repo.updated_jobs[-1].status == "failed"
  841. assert "search boom" in (repo.updated_jobs[-1].error_message or "")
  842. def test_formal_runner_does_not_import_legacy_store():
  843. source = __import__("pathlib").Path("acquisition/runner.py").read_text(encoding="utf-8")
  844. assert "acquisition.store" not in source
  845. assert "creation_demo.json" not in source