search_recipe.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. """SearchRecipe — RECIPE_RULES 表统一判定与应用。"""
  2. from __future__ import annotations
  3. from enum import Enum
  4. from typing import TYPE_CHECKING, Callable
  5. from .method_acronym import resolve_method_acronym
  6. from .plan_helpers import _keywords, _venues, is_pinned_single_year
  7. if TYPE_CHECKING:
  8. from .search_plan import ResolvedSearchPlan
  9. class SearchRecipe(str, Enum):
  10. GENERAL = "general"
  11. TITLE = "title"
  12. AUTHOR = "author"
  13. VENUE_YEAR = "venue_year"
  14. METHOD = "method"
  15. def _is_venue_method(plan: "ResolvedSearchPlan") -> bool:
  16. return bool(_venues(plan) and resolve_method_acronym(plan.query or "", _keywords(plan)))
  17. def _is_venue_year(plan: "ResolvedSearchPlan") -> bool:
  18. return bool(_venues(plan))
  19. def _is_method(plan: "ResolvedSearchPlan") -> bool:
  20. return bool(resolve_method_acronym(plan.query or "", _keywords(plan)))
  21. def _normalize_venue_years(plan: "ResolvedSearchPlan") -> None:
  22. venues = _venues(plan)
  23. if not venues:
  24. return
  25. yf, yt = plan.year_from, plan.year_to
  26. if plan.wants_recent and not plan.wants_classic:
  27. plan.main_conference_proceedings_only = True
  28. plan.sort = "date"
  29. from ..search_intent.parsing import infer_target_edition_year_for_recent
  30. pin_y = infer_target_edition_year_for_recent(is_latest=True)
  31. if yf is None and yt is None:
  32. yf = yt = pin_y
  33. elif yf is not None and (yt is None or int(yt) - int(yf) > 1):
  34. yf = yt = pin_y
  35. elif yf is not None and yt is not None and int(yf) != int(yt):
  36. yf = yt = pin_y
  37. if yf is not None and yt is None:
  38. yt = yf
  39. plan.year_from, plan.year_to = yf, yt
  40. pinned_single_year = isinstance(yf, int) and isinstance(yt, int) and yf == yt
  41. has_topic = bool(_keywords(plan) or (plan.query or "").strip())
  42. if pinned_single_year and not has_topic and not plan.authors and not plan.target_titles:
  43. plan.main_conference_proceedings_only = True
  44. if not (plan.sort or "").strip() or plan.sort == "relevance":
  45. plan.sort = "date"
  46. def _apply_venue_year(plan: "ResolvedSearchPlan", *, skip_browse_limits: bool = False) -> None:
  47. _normalize_venue_years(plan)
  48. if not (is_pinned_single_year(plan) and plan.main_conference_proceedings_only):
  49. return
  50. # Keep all sources (arxiv + dblp + openalex) — let relevance guard and LLM ranker filter,
  51. # instead of hardcoding source exclusion.
  52. if "arxiv" not in plan.sources:
  53. plan.sources = ["arxiv"] + plan.sources
  54. try:
  55. from ...settings import get_settings
  56. recall_cap = int(get_settings().papergraph_recall_max_candidates)
  57. except Exception:
  58. recall_cap = 24
  59. plan.recall_max_candidates = min(max(int(plan.recall_max_candidates or 24), 8), recall_cap)
  60. venues = _venues(plan)
  61. venue = venues[0] if venues else ""
  62. year = plan.year_from if isinstance(plan.year_from, int) else None
  63. orig_q = (plan.query or "").strip()
  64. v_blob = " ".join(v.lower() for v in venues)
  65. if not orig_q or any(v.lower() in orig_q.lower() for v in venues):
  66. plan.query = ""
  67. plan.keywords = [
  68. k
  69. for k in (plan.keywords or [])
  70. if str(k).strip()
  71. and str(k).strip().lower() not in v_blob
  72. and "computer vision" not in str(k).lower()
  73. ][:8]
  74. from ...core.search.normalize import extract_pinned_topic_terms
  75. query_only_topic = extract_pinned_topic_terms(
  76. query=orig_q, merged_kw=[], venue=venue, year=year
  77. ).strip()
  78. if not (plan.query or "").strip():
  79. if query_only_topic:
  80. plan.query = query_only_topic[:200]
  81. else:
  82. # 用户句子里无独立主题(如「CVPR 最新论文」);丢弃 LLM 附带的 latest/papers 等
  83. plan.keywords = []
  84. if skip_browse_limits:
  85. return
  86. from .plan_helpers import effective_max_results, effective_recall_max_candidates, is_venue_browse_plan
  87. if is_venue_browse_plan(plan):
  88. plan.max_results = effective_max_results(plan, plan.max_results)
  89. plan.recall_max_candidates = effective_recall_max_candidates(plan, plan.recall_max_candidates)
  90. def _apply_method(plan: "ResolvedSearchPlan") -> None:
  91. ma = resolve_method_acronym(plan.query or "", _keywords(plan))
  92. if ma:
  93. plan.method_acronym = ma
  94. def _apply_method_at_venue(plan: "ResolvedSearchPlan") -> None:
  95. _apply_method(plan)
  96. _apply_venue_year(plan, skip_browse_limits=True)
  97. RecipeApplyFn = Callable[["ResolvedSearchPlan"], None]
  98. RecipeRule = tuple[Callable[["ResolvedSearchPlan"], bool], SearchRecipe, RecipeApplyFn]
  99. RECIPE_RULES: list[RecipeRule] = [
  100. (_is_venue_method, SearchRecipe.METHOD, _apply_method_at_venue),
  101. (_is_venue_year, SearchRecipe.VENUE_YEAR, _apply_venue_year),
  102. (_is_method, SearchRecipe.METHOD, _apply_method),
  103. ]
  104. def finalize_plan_recipe(plan: "ResolvedSearchPlan") -> "ResolvedSearchPlan":
  105. for predicate, recipe, apply_fn in RECIPE_RULES:
  106. if predicate(plan):
  107. plan.recipe = recipe
  108. apply_fn(plan)
  109. return plan
  110. plan.recipe = SearchRecipe.GENERAL
  111. return plan