runner.py 30 KB

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