runner.py 28 KB

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