recall_jobs.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. """RecallJob — capability 驱动;build + execute 合一模块。"""
  2. from __future__ import annotations
  3. from dataclasses import dataclass, field, replace
  4. from typing import Any, Callable, Literal
  5. import anyio
  6. from ...core.paper import Paper as LitPaper
  7. from ...core.search import PaperSearcher
  8. from .method_acronym import derive_full_title_from_named_method
  9. from .plan_helpers import (
  10. is_venue_browse_plan,
  11. method_acronym_for,
  12. primary_venue,
  13. should_supplement_from_proceedings_site,
  14. )
  15. from .pipeline_runtime import SearchRuntimeConfig
  16. from .proceedings_recall import recall_from_proceedings_site
  17. from .recall_context import RecallContext
  18. from .search_plan import ResolvedSearchPlan
  19. from .search_recipe import SearchRecipe
  20. MergeStrategy = Literal["prepend", "replace", "append"]
  21. RunWhen = Literal["always", "empty_candidates", "sparse_or_venue_browse"]
  22. SideEffect = Literal["none", "derive_method_title", "record_arxiv_fallback"]
  23. Runner = Literal["search", "proceedings"]
  24. _SKIP_KW = frozenset({"sources", "max_results"})
  25. @dataclass
  26. class RecallJob:
  27. name: str
  28. query: str
  29. sources: list[str]
  30. max_results: int
  31. kwargs: dict[str, Any] = field(default_factory=dict)
  32. runner: Runner = "search"
  33. merge_strategy: MergeStrategy = "prepend"
  34. run_when: RunWhen = "always"
  35. side_effect: SideEffect = "none"
  36. required: bool = False
  37. needs_derived_query: bool = False
  38. timeout_sec: float | None = None
  39. def dedupe_papers(
  40. papers: list[LitPaper],
  41. *,
  42. identity_fn: Callable[[LitPaper], str] | None = None,
  43. ) -> list[LitPaper]:
  44. if not papers:
  45. return []
  46. if identity_fn is not None:
  47. seen, out = set(), []
  48. for p in papers:
  49. k = identity_fn(p) or f"untitled:{id(p)}"
  50. if k in seen:
  51. continue
  52. seen.add(k)
  53. out.append(p)
  54. papers = out
  55. searcher = PaperSearcher.__new__(PaperSearcher)
  56. return PaperSearcher._smart_deduplicate(searcher, papers)
  57. def merge_candidates(
  58. current: list[LitPaper],
  59. batch: list[LitPaper],
  60. strategy: MergeStrategy,
  61. ) -> list[LitPaper]:
  62. if not batch:
  63. return current
  64. if strategy == "replace":
  65. return dedupe_papers(list(batch))
  66. if strategy == "append":
  67. return dedupe_papers(current + batch)
  68. return dedupe_papers(batch + current)
  69. def should_run_job(
  70. job: RecallJob,
  71. candidates: list[LitPaper],
  72. *,
  73. plan: ResolvedSearchPlan,
  74. runtime: SearchRuntimeConfig,
  75. ) -> bool:
  76. if job.run_when == "empty_candidates":
  77. return not candidates
  78. if job.run_when == "sparse_or_venue_browse":
  79. # Always run proceedings when venue is specified — topic+venue searches
  80. # like "nips 异常检测" need venue-filtered papers from proceedings site
  81. return runtime.proc_enabled and should_supplement_from_proceedings_site(plan) and (
  82. bool(plan.venues)
  83. or is_venue_browse_plan(plan)
  84. or len(candidates) < runtime.proc_min
  85. )
  86. return True
  87. def _job_wall(job: RecallJob, runtime: SearchRuntimeConfig) -> float:
  88. if job.timeout_sec is not None:
  89. return float(job.timeout_sec)
  90. if job.run_when == "empty_candidates":
  91. return runtime.arxiv_fallback_wall
  92. if job.required:
  93. return runtime.recall_wall
  94. return 12.0
  95. def _constraint_kwargs(constraint_kwargs: dict[str, Any], plan: ResolvedSearchPlan, runtime: SearchRuntimeConfig) -> dict[str, Any]:
  96. sk = {k: v for k, v in constraint_kwargs.items() if k not in _SKIP_KW}
  97. sk["sort"] = plan.sort or sk.get("sort") or "relevance"
  98. sk.update(runtime.execution_kwargs())
  99. return sk
  100. def build_recall_jobs(
  101. plan: ResolvedSearchPlan,
  102. ctx: RecallContext,
  103. *,
  104. runtime: SearchRuntimeConfig,
  105. constraint_kwargs: dict[str, Any],
  106. ) -> list[RecallJob]:
  107. sk = _constraint_kwargs(constraint_kwargs, plan, runtime)
  108. jobs: list[RecallJob] = [
  109. RecallJob(
  110. "primary",
  111. ctx.effective_query,
  112. list(ctx.recall_sources),
  113. runtime.recall_cap,
  114. kwargs=dict(sk),
  115. required=True,
  116. )
  117. ]
  118. ma = method_acronym_for(plan, ctx)
  119. if ma and plan.recipe in (SearchRecipe.METHOD, SearchRecipe.VENUE_YEAR):
  120. ax_sk = {**sk, "llm_keywords": [ma]}
  121. jobs.append(
  122. RecallJob("method_arxiv_boost", ma, ["arxiv"], 16, kwargs=ax_sk, side_effect="derive_method_title", timeout_sec=12.0)
  123. )
  124. if plan.venues:
  125. jobs.append(
  126. RecallJob(
  127. "method_venue_recall",
  128. "",
  129. ["dblp", "openalex"],
  130. runtime.recall_cap,
  131. kwargs={k: v for k, v in sk.items() if k != "venue_browse"},
  132. needs_derived_query=True,
  133. timeout_sec=18.0,
  134. )
  135. )
  136. if plan.fallback.allow_arxiv_only and "arxiv" not in ctx.recall_sources:
  137. q = (ctx.effective_query or ctx.rank_query or plan.query or "")[:100]
  138. jobs.append(
  139. RecallJob(
  140. "arxiv_fallback",
  141. q,
  142. ["arxiv"],
  143. 20,
  144. kwargs={k: v for k, v in sk.items() if k != "venue_fallback_if_empty"}
  145. | {"http_timeout_sec": 8, "http_max_attempts": 1},
  146. merge_strategy="replace",
  147. run_when="empty_candidates",
  148. side_effect="record_arxiv_fallback",
  149. )
  150. )
  151. if should_supplement_from_proceedings_site(plan):
  152. jobs.append(
  153. RecallJob(
  154. "proceedings",
  155. ctx.effective_query,
  156. ["proceedings"],
  157. runtime.recall_cap,
  158. runner="proceedings",
  159. run_when="sparse_or_venue_browse",
  160. )
  161. )
  162. return jobs
  163. def enrich_method_context_from_boost(ax_papers: list[LitPaper], method_acronym: str, ctx: RecallContext) -> str | None:
  164. derived: str | None = None
  165. for p in ax_papers:
  166. if full := derive_full_title_from_named_method(p, method_acronym):
  167. if full not in ctx.canonical_titles:
  168. ctx.canonical_titles.append(full)
  169. derived = derived or full
  170. if derived:
  171. tt = list(ctx.search_kwargs.get("target_titles") or [])
  172. if derived not in tt:
  173. ctx.search_kwargs["target_titles"] = (tt + [derived])[:6]
  174. return derived
  175. async def _run_search_job(searcher: Any, job: RecallJob, runtime: SearchRuntimeConfig) -> tuple[list[LitPaper], str | None]:
  176. if not searcher:
  177. return [], None
  178. wall = _job_wall(job, runtime)
  179. sk = {k: v for k, v in job.kwargs.items() if k not in _SKIP_KW}
  180. sk.setdefault("sort", "relevance")
  181. try:
  182. with anyio.fail_after(wall):
  183. if hasattr(searcher, "search_async"):
  184. papers = await searcher.search_async(job.query, sources=job.sources, max_results=job.max_results, **sk)
  185. else:
  186. papers = await anyio.to_thread.run_sync(
  187. lambda: searcher.search(job.query, sources=job.sources, max_results=job.max_results, **sk)
  188. )
  189. return list(papers or []), None
  190. except TimeoutError:
  191. return ([], f"多源召回超时({wall:.0f}秒)") if job.required else ([], None)
  192. except Exception as e:
  193. return ([], f"搜索异常: {str(e)[:100]}") if job.required else ([], None)
  194. async def execute_recall_jobs(
  195. searcher: Any,
  196. jobs: list[RecallJob],
  197. *,
  198. plan: ResolvedSearchPlan,
  199. ctx: RecallContext,
  200. runtime: SearchRuntimeConfig,
  201. meta: dict[str, Any],
  202. fallbacks: list[dict[str, Any]],
  203. ) -> list[LitPaper]:
  204. candidates: list[LitPaper] = []
  205. search_error: str | None = None
  206. pending_derived = next((j for j in jobs if j.needs_derived_query), None)
  207. jobs_executed: list[str] = []
  208. venue = primary_venue(plan)
  209. for job in jobs:
  210. if job.needs_derived_query or not should_run_job(job, candidates, plan=plan, runtime=runtime):
  211. continue
  212. batch: list[LitPaper] = []
  213. try:
  214. if job.runner == "proceedings":
  215. wall = max(8.0, min(45.0, runtime.recall_wall * 0.6))
  216. with anyio.fail_after(wall):
  217. batch = list(
  218. await recall_from_proceedings_site(
  219. searcher, plan=plan, ctx=ctx, max_results=job.max_results
  220. )
  221. or []
  222. )
  223. else:
  224. batch, err = await _run_search_job(searcher, job, runtime)
  225. if job.required:
  226. search_error = err
  227. except TimeoutError:
  228. if job.runner == "proceedings":
  229. meta["proceedings_supplement"] = {"error": "timeout"}
  230. continue
  231. except Exception as e:
  232. if job.runner == "proceedings":
  233. meta["proceedings_supplement"] = {"error": str(e)[:120]}
  234. continue
  235. if not batch and job.runner != "proceedings":
  236. continue
  237. if job.runner == "proceedings":
  238. before = len(candidates)
  239. candidates = merge_candidates(candidates, batch, job.merge_strategy)
  240. meta["proceedings_supplement"] = {
  241. "venue": venue,
  242. "year": plan.year_from,
  243. "added": len(candidates) - before,
  244. "source": "openaccess_proceedings",
  245. }
  246. fallbacks.append(
  247. {"type": "proceedings_site", "reason": "sparse_dblp_openalex_main_track", "count": len(batch)}
  248. )
  249. else:
  250. candidates = merge_candidates(candidates, batch, job.merge_strategy)
  251. if job.side_effect == "derive_method_title" and batch:
  252. ma = method_acronym_for(plan, ctx)
  253. if ma:
  254. meta["method_acronym_arxiv_boost"] = len(batch)
  255. derived = enrich_method_context_from_boost(batch, ma, ctx)
  256. if pending_derived and derived:
  257. if resolved := _resolve_derived_job(pending_derived, derived):
  258. extra, _ = await _run_search_job(searcher, resolved, runtime)
  259. if extra:
  260. meta["method_acronym_venue_recall"] = len(extra)
  261. candidates = merge_candidates(candidates, extra, "prepend")
  262. pending_derived = None
  263. if job.side_effect == "record_arxiv_fallback":
  264. fallbacks.append({"type": "arxiv_only", "reason": "no_candidates_from_primary"})
  265. meta.setdefault("search_debug", {})["fallback"] = "arxiv_only"
  266. jobs_executed.append(job.name)
  267. meta["search_debug"] = {
  268. "effective_query": ctx.effective_query[:200],
  269. "rank_query": ctx.rank_query[:200],
  270. "candidates_raw_count": len(candidates),
  271. "search_error": search_error,
  272. "recall_sources": list(ctx.recall_sources),
  273. "recipe": plan.recipe.value,
  274. "jobs_executed": jobs_executed,
  275. }
  276. return candidates
  277. def _resolve_derived_job(job: RecallJob, derived_query: str) -> RecallJob | None:
  278. if len(derived_query) < 12:
  279. return None
  280. kwargs = {**job.kwargs, "target_titles": [derived_query]}
  281. return replace(job, query=derived_query, kwargs=kwargs, needs_derived_query=False)