| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318 |
- """RecallJob — capability 驱动;build + execute 合一模块。"""
- from __future__ import annotations
- from dataclasses import dataclass, field, replace
- from typing import Any, Callable, Literal
- import anyio
- from ...core.paper import Paper as LitPaper
- from ...core.search import PaperSearcher
- from .method_acronym import derive_full_title_from_named_method
- from .plan_helpers import (
- is_venue_browse_plan,
- method_acronym_for,
- primary_venue,
- should_supplement_from_proceedings_site,
- )
- from .pipeline_runtime import SearchRuntimeConfig
- from .proceedings_recall import recall_from_proceedings_site
- from .recall_context import RecallContext
- from .search_plan import ResolvedSearchPlan
- from .search_recipe import SearchRecipe
- MergeStrategy = Literal["prepend", "replace", "append"]
- RunWhen = Literal["always", "empty_candidates", "sparse_or_venue_browse"]
- SideEffect = Literal["none", "derive_method_title", "record_arxiv_fallback"]
- Runner = Literal["search", "proceedings"]
- _SKIP_KW = frozenset({"sources", "max_results"})
- @dataclass
- class RecallJob:
- name: str
- query: str
- sources: list[str]
- max_results: int
- kwargs: dict[str, Any] = field(default_factory=dict)
- runner: Runner = "search"
- merge_strategy: MergeStrategy = "prepend"
- run_when: RunWhen = "always"
- side_effect: SideEffect = "none"
- required: bool = False
- needs_derived_query: bool = False
- timeout_sec: float | None = None
- def dedupe_papers(
- papers: list[LitPaper],
- *,
- identity_fn: Callable[[LitPaper], str] | None = None,
- ) -> list[LitPaper]:
- if not papers:
- return []
- if identity_fn is not None:
- seen, out = set(), []
- for p in papers:
- k = identity_fn(p) or f"untitled:{id(p)}"
- if k in seen:
- continue
- seen.add(k)
- out.append(p)
- papers = out
- searcher = PaperSearcher.__new__(PaperSearcher)
- return PaperSearcher._smart_deduplicate(searcher, papers)
- def merge_candidates(
- current: list[LitPaper],
- batch: list[LitPaper],
- strategy: MergeStrategy,
- ) -> list[LitPaper]:
- if not batch:
- return current
- if strategy == "replace":
- return dedupe_papers(list(batch))
- if strategy == "append":
- return dedupe_papers(current + batch)
- return dedupe_papers(batch + current)
- def should_run_job(
- job: RecallJob,
- candidates: list[LitPaper],
- *,
- plan: ResolvedSearchPlan,
- runtime: SearchRuntimeConfig,
- ) -> bool:
- if job.run_when == "empty_candidates":
- return not candidates
- if job.run_when == "sparse_or_venue_browse":
- # Always run proceedings when venue is specified — topic+venue searches
- # like "nips 异常检测" need venue-filtered papers from proceedings site
- return runtime.proc_enabled and should_supplement_from_proceedings_site(plan) and (
- bool(plan.venues)
- or is_venue_browse_plan(plan)
- or len(candidates) < runtime.proc_min
- )
- return True
- def _job_wall(job: RecallJob, runtime: SearchRuntimeConfig) -> float:
- if job.timeout_sec is not None:
- return float(job.timeout_sec)
- if job.run_when == "empty_candidates":
- return runtime.arxiv_fallback_wall
- if job.required:
- return runtime.recall_wall
- return 12.0
- def _constraint_kwargs(constraint_kwargs: dict[str, Any], plan: ResolvedSearchPlan, runtime: SearchRuntimeConfig) -> dict[str, Any]:
- sk = {k: v for k, v in constraint_kwargs.items() if k not in _SKIP_KW}
- sk["sort"] = plan.sort or sk.get("sort") or "relevance"
- sk.update(runtime.execution_kwargs())
- return sk
- def build_recall_jobs(
- plan: ResolvedSearchPlan,
- ctx: RecallContext,
- *,
- runtime: SearchRuntimeConfig,
- constraint_kwargs: dict[str, Any],
- ) -> list[RecallJob]:
- sk = _constraint_kwargs(constraint_kwargs, plan, runtime)
- jobs: list[RecallJob] = [
- RecallJob(
- "primary",
- ctx.effective_query,
- list(ctx.recall_sources),
- runtime.recall_cap,
- kwargs=dict(sk),
- required=True,
- )
- ]
- ma = method_acronym_for(plan, ctx)
- if ma and plan.recipe in (SearchRecipe.METHOD, SearchRecipe.VENUE_YEAR):
- ax_sk = {**sk, "llm_keywords": [ma]}
- jobs.append(
- RecallJob("method_arxiv_boost", ma, ["arxiv"], 16, kwargs=ax_sk, side_effect="derive_method_title", timeout_sec=12.0)
- )
- if plan.venues:
- jobs.append(
- RecallJob(
- "method_venue_recall",
- "",
- ["dblp", "openalex"],
- runtime.recall_cap,
- kwargs={k: v for k, v in sk.items() if k != "venue_browse"},
- needs_derived_query=True,
- timeout_sec=18.0,
- )
- )
- if plan.fallback.allow_arxiv_only and "arxiv" not in ctx.recall_sources:
- q = (ctx.effective_query or ctx.rank_query or plan.query or "")[:100]
- jobs.append(
- RecallJob(
- "arxiv_fallback",
- q,
- ["arxiv"],
- 20,
- kwargs={k: v for k, v in sk.items() if k != "venue_fallback_if_empty"}
- | {"http_timeout_sec": 8, "http_max_attempts": 1},
- merge_strategy="replace",
- run_when="empty_candidates",
- side_effect="record_arxiv_fallback",
- )
- )
- if should_supplement_from_proceedings_site(plan):
- jobs.append(
- RecallJob(
- "proceedings",
- ctx.effective_query,
- ["proceedings"],
- runtime.recall_cap,
- runner="proceedings",
- run_when="sparse_or_venue_browse",
- )
- )
- return jobs
- def enrich_method_context_from_boost(ax_papers: list[LitPaper], method_acronym: str, ctx: RecallContext) -> str | None:
- derived: str | None = None
- for p in ax_papers:
- if full := derive_full_title_from_named_method(p, method_acronym):
- if full not in ctx.canonical_titles:
- ctx.canonical_titles.append(full)
- derived = derived or full
- if derived:
- tt = list(ctx.search_kwargs.get("target_titles") or [])
- if derived not in tt:
- ctx.search_kwargs["target_titles"] = (tt + [derived])[:6]
- return derived
- async def _run_search_job(searcher: Any, job: RecallJob, runtime: SearchRuntimeConfig) -> tuple[list[LitPaper], str | None]:
- if not searcher:
- return [], None
- wall = _job_wall(job, runtime)
- sk = {k: v for k, v in job.kwargs.items() if k not in _SKIP_KW}
- sk.setdefault("sort", "relevance")
- try:
- with anyio.fail_after(wall):
- if hasattr(searcher, "search_async"):
- papers = await searcher.search_async(job.query, sources=job.sources, max_results=job.max_results, **sk)
- else:
- papers = await anyio.to_thread.run_sync(
- lambda: searcher.search(job.query, sources=job.sources, max_results=job.max_results, **sk)
- )
- return list(papers or []), None
- except TimeoutError:
- return ([], f"多源召回超时({wall:.0f}秒)") if job.required else ([], None)
- except Exception as e:
- return ([], f"搜索异常: {str(e)[:100]}") if job.required else ([], None)
- async def execute_recall_jobs(
- searcher: Any,
- jobs: list[RecallJob],
- *,
- plan: ResolvedSearchPlan,
- ctx: RecallContext,
- runtime: SearchRuntimeConfig,
- meta: dict[str, Any],
- fallbacks: list[dict[str, Any]],
- ) -> list[LitPaper]:
- candidates: list[LitPaper] = []
- search_error: str | None = None
- pending_derived = next((j for j in jobs if j.needs_derived_query), None)
- jobs_executed: list[str] = []
- venue = primary_venue(plan)
- for job in jobs:
- if job.needs_derived_query or not should_run_job(job, candidates, plan=plan, runtime=runtime):
- continue
- batch: list[LitPaper] = []
- try:
- if job.runner == "proceedings":
- wall = max(8.0, min(45.0, runtime.recall_wall * 0.6))
- with anyio.fail_after(wall):
- batch = list(
- await recall_from_proceedings_site(
- searcher, plan=plan, ctx=ctx, max_results=job.max_results
- )
- or []
- )
- else:
- batch, err = await _run_search_job(searcher, job, runtime)
- if job.required:
- search_error = err
- except TimeoutError:
- if job.runner == "proceedings":
- meta["proceedings_supplement"] = {"error": "timeout"}
- continue
- except Exception as e:
- if job.runner == "proceedings":
- meta["proceedings_supplement"] = {"error": str(e)[:120]}
- continue
- if not batch and job.runner != "proceedings":
- continue
- if job.runner == "proceedings":
- before = len(candidates)
- candidates = merge_candidates(candidates, batch, job.merge_strategy)
- meta["proceedings_supplement"] = {
- "venue": venue,
- "year": plan.year_from,
- "added": len(candidates) - before,
- "source": "openaccess_proceedings",
- }
- fallbacks.append(
- {"type": "proceedings_site", "reason": "sparse_dblp_openalex_main_track", "count": len(batch)}
- )
- else:
- candidates = merge_candidates(candidates, batch, job.merge_strategy)
- if job.side_effect == "derive_method_title" and batch:
- ma = method_acronym_for(plan, ctx)
- if ma:
- meta["method_acronym_arxiv_boost"] = len(batch)
- derived = enrich_method_context_from_boost(batch, ma, ctx)
- if pending_derived and derived:
- if resolved := _resolve_derived_job(pending_derived, derived):
- extra, _ = await _run_search_job(searcher, resolved, runtime)
- if extra:
- meta["method_acronym_venue_recall"] = len(extra)
- candidates = merge_candidates(candidates, extra, "prepend")
- pending_derived = None
- if job.side_effect == "record_arxiv_fallback":
- fallbacks.append({"type": "arxiv_only", "reason": "no_candidates_from_primary"})
- meta.setdefault("search_debug", {})["fallback"] = "arxiv_only"
- jobs_executed.append(job.name)
- meta["search_debug"] = {
- "effective_query": ctx.effective_query[:200],
- "rank_query": ctx.rank_query[:200],
- "candidates_raw_count": len(candidates),
- "search_error": search_error,
- "recall_sources": list(ctx.recall_sources),
- "recipe": plan.recipe.value,
- "jobs_executed": jobs_executed,
- }
- return candidates
- def _resolve_derived_job(job: RecallJob, derived_query: str) -> RecallJob | None:
- if len(derived_query) < 12:
- return None
- kwargs = {**job.kwargs, "target_titles": [derived_query]}
- return replace(job, query=derived_query, kwargs=kwargs, needs_derived_query=False)
|