relevance_guard.py 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. """Score-based relevance guard before LLM rank (only when candidate pool is large)."""
  2. from __future__ import annotations
  3. from ...core.paper import Paper as LitPaper
  4. from ...core.search.paper_searcher import PaperSearcher, _has_any_author
  5. from ...utils.author_query_match import normalize_author_names
  6. from .method_acronym import is_method_acronym_token, title_matches_method_acronym
  7. from .search_plan import ResolvedSearchPlan
  8. _DEFAULT_THRESHOLD = 40
  9. _MIN_KEEP = 8
  10. _SCORE_TITLE = 5
  11. _SCORE_AUTHOR = 4
  12. _SCORE_VENUE = 3
  13. _SCORE_KEYWORD = 2
  14. _SCORE_YEAR = 1
  15. _SCORE_METHOD_ACRONYM = 6
  16. def apply_relevance_guard(
  17. candidates: list[LitPaper],
  18. *,
  19. plan: ResolvedSearchPlan,
  20. guard_threshold: int = _DEFAULT_THRESHOLD,
  21. min_keep: int = _MIN_KEEP,
  22. ) -> tuple[list[LitPaper], bool]:
  23. """候选过多时按相关性打分软过滤;过滤后过少则回退原列表。"""
  24. if len(candidates) <= guard_threshold:
  25. return candidates, False
  26. target_titles = [t.lower() for t in (plan.target_titles or []) if t.strip()]
  27. keywords = [
  28. k.lower()
  29. for k in (plan.keywords or [])
  30. if len(str(k).strip()) >= 2
  31. ]
  32. venues = [v for v in (plan.venues or []) if v.strip()]
  33. author_phrases = normalize_author_names(plan.authors or [])
  34. yf, yt = plan.year_from, plan.year_to
  35. method_acronym = (getattr(plan, "method_acronym", None) or "").strip() or None
  36. if not method_acronym and len(keywords) == 1 and is_method_acronym_token(keywords[0]):
  37. method_acronym = keywords[0]
  38. kept: list[LitPaper] = []
  39. for p in candidates:
  40. score = _relevance_score(
  41. p,
  42. target_titles=target_titles,
  43. keywords=keywords,
  44. venues=venues,
  45. author_phrases=author_phrases,
  46. year_from=yf,
  47. year_to=yt,
  48. method_acronym=method_acronym,
  49. )
  50. if _passes_guard_threshold(
  51. score,
  52. plan=plan,
  53. has_target_titles=bool(target_titles),
  54. has_strong_constraints=bool(venues or author_phrases or yf is not None),
  55. method_acronym=method_acronym,
  56. ):
  57. kept.append(p)
  58. if len(kept) < min_keep:
  59. return candidates, False
  60. return kept, True
  61. def _passes_guard_threshold(
  62. score: int,
  63. *,
  64. plan: ResolvedSearchPlan,
  65. has_target_titles: bool,
  66. has_strong_constraints: bool,
  67. method_acronym: str | None = None,
  68. ) -> bool:
  69. if method_acronym:
  70. return score >= _SCORE_METHOD_ACRONYM
  71. if has_target_titles:
  72. return score >= _SCORE_TITLE
  73. if has_strong_constraints:
  74. return score >= (_SCORE_VENUE + _SCORE_KEYWORD - 2) # >= 3
  75. return score >= (_SCORE_KEYWORD) # >= 2 for broad keyword queries
  76. def _relevance_score(
  77. p: LitPaper,
  78. *,
  79. target_titles: list[str],
  80. keywords: list[str],
  81. venues: list[str],
  82. author_phrases: list[str],
  83. year_from: int | None,
  84. year_to: int | None,
  85. method_acronym: str | None = None,
  86. ) -> int:
  87. score = 0
  88. title = (getattr(p, "title", None) or "").lower()
  89. abstract = (getattr(p, "abstract", None) or "").lower()
  90. journal = (getattr(p, "journal", None) or getattr(p, "venue", None) or "").lower()
  91. blob = f"{title} {abstract} {journal}"
  92. if target_titles and any(
  93. (len(tt) > 8 and (tt in title or title in tt)) for tt in target_titles
  94. ):
  95. score += _SCORE_TITLE
  96. if method_acronym:
  97. if title_matches_method_acronym(f"{title} {abstract}", method_acronym):
  98. score += _SCORE_METHOD_ACRONYM
  99. elif target_titles and any(
  100. len(tt) >= 12 and (tt in title or title in tt) for tt in target_titles
  101. ):
  102. score += _SCORE_METHOD_ACRONYM
  103. if author_phrases and _has_any_author(p, author_phrases):
  104. score += _SCORE_AUTHOR
  105. if venues:
  106. for v in venues:
  107. if PaperSearcher._paper_matches_venue_proceedings(p, v) or v.lower() in blob:
  108. score += _SCORE_VENUE
  109. break
  110. if keywords:
  111. kw_hits = sum(1 for kw in keywords if kw in blob)
  112. if kw_hits >= 2 or (kw_hits >= 1 and len(keywords) <= 3):
  113. score += _SCORE_KEYWORD
  114. elif kw_hits == 1:
  115. score += 1
  116. if year_from is not None or year_to is not None:
  117. try:
  118. py = int(getattr(p, "year", 0) or 0)
  119. except (TypeError, ValueError):
  120. py = 0
  121. if py:
  122. in_range = True
  123. if year_from is not None and py < int(year_from):
  124. in_range = False
  125. if year_to is not None and py > int(year_to):
  126. in_range = False
  127. if in_range:
  128. score += _SCORE_YEAR
  129. return score