runner.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650
  1. """Formal acquisition runner backed by the repository boundary."""
  2. from __future__ import annotations
  3. from dataclasses import dataclass
  4. from typing import Any, Callable, Iterable
  5. from uuid import UUID
  6. from acquisition.classification.coarse import ClassificationResult, coarse_classify_item
  7. from acquisition.content_mode import guard_content_for_processing, infer_content_mode
  8. from acquisition.crawler import RateLimiter
  9. from acquisition.domain import AcquisitionJob, Query
  10. from acquisition.media.service import StabilizedMedia, stabilize_media_urls
  11. from acquisition.platforms import PlatformAdapter, get_platform_adapter
  12. from acquisition.platforms.base import PlatformCandidate, PlatformSearchPage
  13. from acquisition.repositories.base import AcquisitionRepository
  14. from acquisition.unique_key import build_unique_key
  15. from core.config import Settings
  16. from core.text_limits import (
  17. BODY_TEXT_MAX_CHARS,
  18. ERROR_MESSAGE_MAX_CHARS,
  19. RAW_SUMMARY_MAX_CHARS,
  20. clip_text,
  21. )
  22. DEFAULT_PLATFORMS = ("xiaohongshu", "weixin", "douyin")
  23. PAGINATION_STRATEGY = "creation_ratio_two_page_v1"
  24. PAGINATION_CREATION_THRESHOLD = 0.5
  25. PAGINATION_MAX_PAGES = 2
  26. AdapterFactory = Callable[[str], PlatformAdapter]
  27. Classifier = Callable[..., ClassificationResult]
  28. MediaStabilizer = Callable[..., list[StabilizedMedia]]
  29. RateLimiterFactory = Callable[[str], Any]
  30. @dataclass(frozen=True)
  31. class RunBatchResult:
  32. run_id: UUID
  33. total_jobs: int
  34. done: int
  35. partial: int
  36. failed: int
  37. skipped: int
  38. @dataclass(frozen=True)
  39. class RecordCandidateResult:
  40. displayed: bool
  41. skipped_processing: bool = False
  42. classified: bool = False
  43. is_creation_knowledge: bool | None = None
  44. class PlatformRateLimiter:
  45. """Share one rate bucket per platform across search and detail calls."""
  46. def __init__(self, platform: str, delegate: RateLimiter | None = None) -> None:
  47. self.platform = platform
  48. self.delegate = delegate or RateLimiter(
  49. min_interval_seconds=10.0,
  50. max_interval_seconds=12.0,
  51. )
  52. def wait(self, bucket: str) -> None:
  53. self.delegate.wait(self.platform)
  54. def _query_id(query: Query) -> UUID:
  55. if query.id is None:
  56. raise RuntimeError("formal acquisition queries must have id before running")
  57. return query.id
  58. def _job_id(job: AcquisitionJob) -> UUID:
  59. if job.id is None:
  60. raise RuntimeError("formal acquisition jobs must have id before running")
  61. return job.id
  62. def _best_video_url(media: list[StabilizedMedia]) -> str:
  63. for row in media:
  64. if row.media_type == "video" and row.cdn_url:
  65. return row.cdn_url
  66. return ""
  67. def _image_urls(media: list[StabilizedMedia]) -> list[str]:
  68. return [row.cdn_url for row in media if row.media_type == "image" and row.cdn_url]
  69. def _source_payload(candidate: Any, detail: Any) -> dict[str, Any]:
  70. return {
  71. "candidate": candidate.model_dump() if hasattr(candidate, "model_dump") else {},
  72. "detail": detail.raw if isinstance(getattr(detail, "raw", None), dict) else {},
  73. }
  74. def _candidate_provider(candidate: Any) -> str:
  75. provider = getattr(candidate, "provider", "") or ""
  76. raw = getattr(candidate, "raw", None)
  77. if not provider and isinstance(raw, dict):
  78. provider = raw.get("search_provider") or ""
  79. return provider
  80. def _detail_provider(detail: Any, candidate: Any) -> str:
  81. provider = getattr(detail, "provider", "") or ""
  82. if not provider:
  83. raw = getattr(detail, "raw", None)
  84. if isinstance(raw, dict):
  85. provider = raw.get("detail_provider") or ""
  86. return provider or _candidate_provider(candidate)
  87. def _candidate_page_metadata(candidate: Any) -> dict[str, Any]:
  88. raw = getattr(candidate, "raw", None)
  89. if not isinstance(raw, dict):
  90. return {}
  91. out: dict[str, Any] = {}
  92. for key in ("page_index", "page_rank", "source_cursor"):
  93. value = raw.get(key)
  94. if value not in (None, ""):
  95. out[key] = value
  96. return out
  97. def _content_mode(platform: str, detail: Any) -> str:
  98. explicit = getattr(detail, "content_mode", "") or ""
  99. if explicit:
  100. return explicit
  101. return infer_content_mode(
  102. platform=platform,
  103. content_type=getattr(detail, "content_type", "") or "",
  104. body_text=getattr(detail, "body_text", "") or "",
  105. image_urls=getattr(detail, "image_urls", []) or [],
  106. video_urls=getattr(detail, "video_urls", []) or [],
  107. raw=getattr(detail, "raw", {}) if isinstance(getattr(detail, "raw", None), dict) else {},
  108. )
  109. def _skip_reason_message(reason: str) -> str:
  110. if reason == "video_url_missing":
  111. return "Video post has no usable video URL; skipped before classification."
  112. if reason == "unsupported_content_mode":
  113. return "Unsupported content mode; skipped before classification."
  114. return reason or "Skipped before classification."
  115. def _record_candidate(
  116. repo: AcquisitionRepository,
  117. *,
  118. job: AcquisitionJob,
  119. query: Query,
  120. platform: str,
  121. candidate: Any,
  122. detail: Any,
  123. settings: Settings,
  124. media_stabilizer: MediaStabilizer,
  125. classifier: Classifier,
  126. classify: bool,
  127. ) -> RecordCandidateResult:
  128. source_payload = _source_payload(candidate, detail)
  129. platform_item_id = detail.source_id or candidate.source_id or None
  130. canonical_url = detail.url or candidate.url or None
  131. content_mode = _content_mode(platform, detail)
  132. guard = guard_content_for_processing(
  133. content_mode=content_mode,
  134. video_urls=detail.video_urls,
  135. )
  136. metadata = {
  137. "candidate_rank": candidate.rank,
  138. "acquisition_match_status": "created",
  139. "content_mode": content_mode,
  140. **_candidate_page_metadata(candidate),
  141. }
  142. search_provider = _candidate_provider(candidate)
  143. detail_provider = _detail_provider(detail, candidate)
  144. if search_provider:
  145. metadata["search_provider"] = search_provider
  146. if detail_provider:
  147. metadata["detail_provider"] = detail_provider
  148. if guard.metadata:
  149. metadata.update(guard.metadata)
  150. body_text = clip_text(detail.body_text or "", BODY_TEXT_MAX_CHARS)
  151. item = repo.upsert_candidate_item(
  152. platform=platform,
  153. job_id=_job_id(job),
  154. query_id=_query_id(query),
  155. platform_item_id=platform_item_id,
  156. unique_key=build_unique_key(
  157. platform,
  158. platform_item_id=platform_item_id,
  159. url=canonical_url,
  160. raw_payload=source_payload,
  161. ),
  162. canonical_url=canonical_url,
  163. content_type=detail.content_type or None,
  164. content_mode=content_mode,
  165. title=detail.title or None,
  166. author_name=detail.author or None,
  167. body_text=body_text or None,
  168. raw_summary=clip_text(body_text, RAW_SUMMARY_MAX_CHARS) or None,
  169. status="candidate" if guard.can_process else "skipped",
  170. source_payload=source_payload,
  171. metadata=metadata,
  172. error_message=None if guard.can_process else guard.reason,
  173. )
  174. if item.id is None:
  175. raise RuntimeError("repository returned candidate without id")
  176. if not guard.can_process:
  177. repo.add_item_classification(
  178. item_id=item.id,
  179. is_creation_knowledge=None,
  180. label=guard.label,
  181. confidence=None,
  182. reason=_skip_reason_message(guard.reason),
  183. model_name=None,
  184. prompt_version="content_mode_guard",
  185. result_payload={
  186. "content_mode": content_mode,
  187. "skip_reason": guard.reason,
  188. **(guard.metadata or {}),
  189. },
  190. status="skipped",
  191. error_message=guard.reason,
  192. )
  193. return RecordCandidateResult(displayed=True, skipped_processing=True)
  194. media_rows = media_stabilizer(
  195. image_urls=detail.image_urls,
  196. video_urls=detail.video_urls,
  197. settings=settings,
  198. )
  199. for row in media_rows:
  200. repo.add_media_asset(
  201. item_id=item.id,
  202. media_type=row.media_type,
  203. source_url=row.source_url,
  204. oss_url=row.cdn_url,
  205. cdn_url=row.cdn_url,
  206. position=row.position,
  207. status=row.status,
  208. source_payload={},
  209. metadata={},
  210. )
  211. if classify:
  212. result = classifier(
  213. platform=platform,
  214. content_mode=content_mode,
  215. title=detail.title,
  216. body_text=detail.body_text,
  217. image_urls=_image_urls(media_rows),
  218. video_url=_best_video_url(media_rows),
  219. settings=settings,
  220. )
  221. repo.add_item_classification(
  222. item_id=item.id,
  223. is_creation_knowledge=result.is_creation_knowledge,
  224. label=result.label,
  225. confidence=result.confidence,
  226. reason=result.reason,
  227. model_name=settings.video_model,
  228. prompt_version=result.prompt_version,
  229. result_payload=result.result_payload,
  230. status=result.status,
  231. error_message=result.error_message,
  232. )
  233. return RecordCandidateResult(
  234. displayed=True,
  235. classified=result.status == "classified" and result.is_creation_knowledge is not None,
  236. is_creation_knowledge=result.is_creation_knowledge,
  237. )
  238. return RecordCandidateResult(displayed=True)
  239. def _candidate_unique_key(platform: str, candidate: PlatformCandidate) -> str | None:
  240. return build_unique_key(
  241. platform,
  242. platform_item_id=candidate.source_id or None,
  243. url=candidate.url or None,
  244. raw_payload={"candidate": candidate.model_dump()},
  245. )
  246. def _attach_existing_candidate(
  247. repo: AcquisitionRepository,
  248. *,
  249. job: AcquisitionJob,
  250. query: Query,
  251. candidate: PlatformCandidate,
  252. unique_key: str,
  253. ) -> bool:
  254. get_by_key = getattr(repo, "get_candidate_item_by_unique_key", None)
  255. attach = getattr(repo, "attach_existing_candidate_item", None)
  256. if get_by_key is None or attach is None:
  257. return False
  258. existing = get_by_key(unique_key)
  259. if existing is None or existing.id is None:
  260. return False
  261. attach(
  262. existing.id,
  263. job_id=_job_id(job),
  264. query_id=_query_id(query),
  265. metadata={
  266. "acquisition_match_status": "existing",
  267. "matched_unique_key": unique_key,
  268. "matched_candidate": candidate.model_dump(),
  269. "candidate_rank": candidate.rank,
  270. **_candidate_page_metadata(candidate),
  271. **({"search_provider": _candidate_provider(candidate)} if _candidate_provider(candidate) else {}),
  272. },
  273. )
  274. return True
  275. def _pagination_config(threshold: float, max_pages: int) -> dict[str, Any]:
  276. return {
  277. "strategy": PAGINATION_STRATEGY,
  278. "creation_threshold": threshold,
  279. "max_pages": max_pages,
  280. }
  281. def _ratio(numerator: int, denominator: int) -> float | None:
  282. if denominator <= 0:
  283. return None
  284. return numerator / denominator
  285. def _search_pages(
  286. adapter: PlatformAdapter,
  287. query_text: str,
  288. *,
  289. settings: Settings,
  290. limit: int,
  291. rate_limiter: Any,
  292. max_pages: int,
  293. ) -> Iterable[PlatformSearchPage]:
  294. search_pages = getattr(adapter, "search_pages", None)
  295. if callable(search_pages):
  296. return search_pages(
  297. query_text,
  298. settings=settings,
  299. limit=limit,
  300. rate_limiter=rate_limiter,
  301. max_pages=max_pages,
  302. )
  303. candidates = adapter.search(
  304. query_text,
  305. settings=settings,
  306. limit=limit,
  307. rate_limiter=rate_limiter,
  308. )
  309. return [
  310. PlatformSearchPage(
  311. page_index=1,
  312. candidates=candidates,
  313. raw_count=len(candidates),
  314. has_more=False,
  315. )
  316. ]
  317. def run_batch(
  318. repo: AcquisitionRepository,
  319. *,
  320. batch_id: UUID,
  321. settings: Settings,
  322. platforms: tuple[str, ...] | list[str] = DEFAULT_PLATFORMS,
  323. search_limit: int = 10,
  324. display_limit: int = 5,
  325. classify: bool = True,
  326. resume: bool = True,
  327. skip_done: bool = True,
  328. run_key: str | None = None,
  329. adapter_factory: AdapterFactory = get_platform_adapter,
  330. media_stabilizer: MediaStabilizer = stabilize_media_urls,
  331. classifier: Classifier = coarse_classify_item,
  332. rate_limiter_factory: RateLimiterFactory | None = None,
  333. pagination_creation_threshold: float = PAGINATION_CREATION_THRESHOLD,
  334. pagination_max_pages: int = PAGINATION_MAX_PAGES,
  335. ) -> RunBatchResult:
  336. """Run query x platform acquisition and write formal cloud-state rows."""
  337. queries = repo.list_queries_for_batch(batch_id, keep=True)
  338. run = repo.create_acquisition_run(
  339. batch_id=batch_id,
  340. run_key=run_key or f"acquisition:{batch_id}",
  341. status="running",
  342. metadata={
  343. "platforms": list(platforms),
  344. "search_limit": search_limit,
  345. "display_limit": display_limit,
  346. "classify": classify,
  347. "resume": resume,
  348. "pagination": _pagination_config(
  349. pagination_creation_threshold,
  350. pagination_max_pages,
  351. ),
  352. },
  353. )
  354. if run.id is None:
  355. raise RuntimeError("repository returned acquisition run without id")
  356. total_jobs = len(queries) * len(platforms)
  357. done = partial = failed = skipped = 0
  358. gates: dict[str, Any] = {}
  359. for query in queries:
  360. query_id = _query_id(query)
  361. for platform in platforms:
  362. job = repo.ensure_acquisition_job(
  363. run_id=run.id,
  364. query_id=query_id,
  365. platform=platform,
  366. search_limit=search_limit,
  367. display_limit=display_limit,
  368. status="pending",
  369. metadata={"query_text": query.query_text},
  370. )
  371. if skip_done and job.status == "done":
  372. skipped += 1
  373. continue
  374. attempts = job.attempt_count + 1
  375. job = repo.update_acquisition_job(
  376. _job_id(job),
  377. status="running",
  378. attempt_count=attempts,
  379. error_message=None,
  380. )
  381. errors: list[str] = []
  382. display_count = 0
  383. searched_count = 0
  384. raw_searched_count = 0
  385. skipped_item_count = 0
  386. search_page_count = 0
  387. page_summaries: list[dict[str, Any]] = []
  388. first_page_classified_count = 0
  389. first_page_creation_count = 0
  390. first_page_creation_ratio: float | None = None
  391. requested_second_page = False
  392. pagination_stop_reason = ""
  393. try:
  394. adapter = adapter_factory(platform)
  395. gate = gates.get(platform)
  396. if gate is None:
  397. gate = (
  398. rate_limiter_factory(platform)
  399. if rate_limiter_factory
  400. else PlatformRateLimiter(platform)
  401. )
  402. gates[platform] = gate
  403. pages = _search_pages(
  404. adapter,
  405. query.query_text,
  406. settings=settings,
  407. limit=search_limit,
  408. rate_limiter=gate,
  409. max_pages=pagination_max_pages,
  410. )
  411. for page in pages:
  412. search_page_count += 1
  413. raw_searched_count += page.raw_count
  414. searched_count += len(page.candidates)
  415. page_classified_count = 0
  416. page_creation_count = 0
  417. for candidate in page.candidates[:search_limit]:
  418. try:
  419. unique_key = _candidate_unique_key(platform, candidate)
  420. if unique_key and _attach_existing_candidate(
  421. repo,
  422. job=job,
  423. query=query,
  424. candidate=candidate,
  425. unique_key=unique_key,
  426. ):
  427. display_count += 1
  428. continue
  429. detail = adapter.fetch_detail(
  430. candidate,
  431. settings=settings,
  432. rate_limiter=gate,
  433. )
  434. recorded = _record_candidate(
  435. repo,
  436. job=job,
  437. query=query,
  438. platform=platform,
  439. candidate=candidate,
  440. detail=detail,
  441. settings=settings,
  442. media_stabilizer=media_stabilizer,
  443. classifier=classifier,
  444. classify=classify,
  445. )
  446. if recorded.displayed:
  447. display_count += 1
  448. if recorded.skipped_processing:
  449. skipped_item_count += 1
  450. if recorded.classified:
  451. page_classified_count += 1
  452. if recorded.is_creation_knowledge is True:
  453. page_creation_count += 1
  454. except Exception as exc:
  455. errors.append(clip_text(exc, ERROR_MESSAGE_MAX_CHARS))
  456. continue
  457. page_ratio = _ratio(page_creation_count, page_classified_count)
  458. page_summaries.append(
  459. {
  460. "page_index": page.page_index,
  461. "raw_count": page.raw_count,
  462. "candidate_count": len(page.candidates),
  463. "classified_count": page_classified_count,
  464. "creation_count": page_creation_count,
  465. "creation_ratio": page_ratio,
  466. "has_more": page.has_more,
  467. "next_cursor": page.next_cursor,
  468. "provider": page.provider,
  469. }
  470. )
  471. if page.page_index == 1:
  472. first_page_classified_count = page_classified_count
  473. first_page_creation_count = page_creation_count
  474. first_page_creation_ratio = page_ratio
  475. if pagination_max_pages <= 1:
  476. pagination_stop_reason = "max_pages_reached"
  477. break
  478. if not page.has_more or not page.next_cursor:
  479. pagination_stop_reason = "no_more_pages"
  480. break
  481. if page_classified_count <= 0:
  482. pagination_stop_reason = "no_classified_candidates"
  483. break
  484. if page_ratio is None or page_ratio < pagination_creation_threshold:
  485. pagination_stop_reason = "below_threshold"
  486. break
  487. requested_second_page = True
  488. continue
  489. pagination_stop_reason = "max_pages_reached"
  490. break
  491. if not pagination_stop_reason:
  492. pagination_stop_reason = "pages_exhausted"
  493. status = "done" if display_count >= display_limit else (
  494. "partial" if display_count else "failed"
  495. )
  496. if status == "done":
  497. done += 1
  498. elif status == "partial":
  499. partial += 1
  500. else:
  501. failed += 1
  502. repo.update_acquisition_job(
  503. _job_id(job),
  504. status=status,
  505. attempt_count=attempts,
  506. error_message=None if status != "failed" else "; ".join(errors[-3:]),
  507. metadata={
  508. "query_text": query.query_text,
  509. "searched_count": searched_count,
  510. "raw_searched_count": raw_searched_count,
  511. "display_count": display_count,
  512. "skipped_item_count": skipped_item_count,
  513. "search_page_count": search_page_count,
  514. "pagination": {
  515. **_pagination_config(
  516. pagination_creation_threshold,
  517. pagination_max_pages,
  518. ),
  519. "first_page_classified_count": first_page_classified_count,
  520. "first_page_creation_count": first_page_creation_count,
  521. "first_page_creation_ratio": first_page_creation_ratio,
  522. "requested_second_page": requested_second_page,
  523. "stop_reason": pagination_stop_reason,
  524. "pages": page_summaries,
  525. },
  526. "errors": errors[-3:],
  527. },
  528. )
  529. except Exception as exc:
  530. failed += 1
  531. repo.update_acquisition_job(
  532. _job_id(job),
  533. status="failed",
  534. attempt_count=attempts,
  535. error_message=clip_text(exc, ERROR_MESSAGE_MAX_CHARS),
  536. metadata={
  537. "query_text": query.query_text,
  538. "searched_count": searched_count,
  539. "raw_searched_count": raw_searched_count,
  540. "display_count": display_count,
  541. "skipped_item_count": skipped_item_count,
  542. "search_page_count": search_page_count,
  543. "pagination": {
  544. **_pagination_config(
  545. pagination_creation_threshold,
  546. pagination_max_pages,
  547. ),
  548. "first_page_classified_count": first_page_classified_count,
  549. "first_page_creation_count": first_page_creation_count,
  550. "first_page_creation_ratio": first_page_creation_ratio,
  551. "requested_second_page": requested_second_page,
  552. "stop_reason": pagination_stop_reason or "failed",
  553. "pages": page_summaries,
  554. },
  555. "errors": errors[-3:],
  556. },
  557. )
  558. run_status = (
  559. "done"
  560. if failed == 0 and partial == 0
  561. else "partial"
  562. if done > 0 or partial > 0
  563. else "failed"
  564. )
  565. update_run = getattr(repo, "update_acquisition_run", None)
  566. if update_run:
  567. update_run(
  568. run.id,
  569. status=run_status,
  570. metadata={
  571. "total_jobs": total_jobs,
  572. "done": done,
  573. "partial": partial,
  574. "failed": failed,
  575. "skipped": skipped,
  576. "pagination": _pagination_config(
  577. pagination_creation_threshold,
  578. pagination_max_pages,
  579. ),
  580. },
  581. )
  582. return RunBatchResult(
  583. run_id=run.id,
  584. total_jobs=total_jobs,
  585. done=done,
  586. partial=partial,
  587. failed=failed,
  588. skipped=skipped,
  589. )