recall_context.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. """Plan → RecallContext:查询词、约束 kwargs、召回源。"""
  2. from __future__ import annotations
  3. import re
  4. from dataclasses import dataclass, field
  5. from typing import Any
  6. from ...core.search import sanitize_pinned_topic_keywords
  7. from ...utils.author_query_match import pick_primary_english_author_for_query
  8. from .plan_helpers import (
  9. is_pinned_single_year,
  10. is_strict_venue_match,
  11. is_venue_browse_plan,
  12. method_acronym_for,
  13. primary_venue,
  14. use_venue_proceedings_journal,
  15. )
  16. from .search_plan import ResolvedSearchPlan
  17. from .search_recipe import SearchRecipe
  18. _ACADEMIC_SOURCES = frozenset({"arxiv", "dblp", "openalex"})
  19. @dataclass
  20. class RecallContext:
  21. effective_query: str
  22. rank_query: str
  23. merged_keywords: list[str] = field(default_factory=list)
  24. search_kwargs: dict[str, Any] = field(default_factory=dict)
  25. ranking_profile: str = "accuracy"
  26. recall_sources: list[str] = field(default_factory=list)
  27. intent_source_message: str = ""
  28. pinned_arxiv_ids: list[str] = field(default_factory=list)
  29. tavily_keywords: list[str] = field(default_factory=list)
  30. canonical_titles: list[str] = field(default_factory=list)
  31. source_plan: dict[str, Any] = field(default_factory=dict)
  32. def _has_cjk(text: str) -> bool:
  33. return bool(re.search(r"[\u4e00-\u9fff]", text or ""))
  34. def _latin_keywords(keywords: list[str]) -> list[str]:
  35. out: list[str] = []
  36. for kw in keywords:
  37. t = str(kw).strip()
  38. if not t or not re.search(r"[A-Za-z]", t):
  39. continue
  40. if _has_cjk(t) and sum(1 for ch in t if ord(ch) > 127) > max(2, len(t) // 3):
  41. continue
  42. out.append(t)
  43. return out[:8]
  44. def _resolve_query_terms(plan: ResolvedSearchPlan) -> tuple[str, str, list[str], list[str], list[str]]:
  45. raw_msg = (plan.raw_user_message or plan.query or "").strip()
  46. query = (plan.query or "").strip()
  47. target_titles = [str(t).strip() for t in (plan.target_titles or []) if str(t).strip()][:6]
  48. authors = [str(a).strip() for a in (plan.authors or []) if str(a).strip()][:8]
  49. author_low = {a.lower() for a in authors}
  50. keywords: list[str] = []
  51. for k in plan.keywords or []:
  52. t = str(k).strip()
  53. if not t:
  54. continue
  55. tl = t.lower()
  56. if tl in author_low or any(a in tl for a in author_low if len(a) > 2):
  57. continue
  58. keywords.append(t)
  59. effective_query = query
  60. rank_query = query or raw_msg
  61. merged_keywords = list(keywords)
  62. if target_titles:
  63. effective_query = target_titles[0]
  64. rank_query = target_titles[0]
  65. seen: set[str] = set()
  66. merged_keywords = []
  67. for t in target_titles + keywords:
  68. tl = t.lower()
  69. if tl not in seen:
  70. merged_keywords.append(t)
  71. seen.add(tl)
  72. merged_keywords = merged_keywords[:16]
  73. elif authors and not target_titles:
  74. eng = pick_primary_english_author_for_query(authors) or query
  75. effective_query = (eng or query).strip()
  76. rank_query = raw_msg or effective_query
  77. elif _has_cjk(query):
  78. latin = _latin_keywords(keywords)
  79. effective_query = (" ".join(latin)[:200].strip() if latin else (keywords[0] if keywords else query))
  80. rank_query = raw_msg or query
  81. elif not effective_query and keywords:
  82. effective_query = keywords[0]
  83. rank_query = raw_msg or effective_query
  84. effective_query, rank_query, merged_keywords = _apply_venue_browse_query_defaults(
  85. plan,
  86. effective_query=effective_query,
  87. rank_query=rank_query,
  88. merged_keywords=merged_keywords,
  89. )
  90. return effective_query, rank_query, merged_keywords, target_titles, authors
  91. def _apply_venue_browse_query_defaults(
  92. plan: ResolvedSearchPlan,
  93. *,
  94. effective_query: str,
  95. rank_query: str,
  96. merged_keywords: list[str],
  97. ) -> tuple[str, str, list[str]]:
  98. if not is_venue_browse_plan(plan):
  99. return effective_query, rank_query, merged_keywords
  100. venue = primary_venue(plan) or ""
  101. year = plan.year_from
  102. rank_query = (plan.raw_user_message or "").strip() or f"{venue} {year or ''}".strip()
  103. return "", rank_query, []
  104. def build_recall_context(plan: ResolvedSearchPlan) -> RecallContext:
  105. raw_msg = (plan.raw_user_message or plan.query or "").strip()
  106. effective_query, rank_query, merged_keywords, target_titles, authors = _resolve_query_terms(plan)
  107. ma = method_acronym_for(plan)
  108. if ma and plan.recipe == SearchRecipe.METHOD and not target_titles:
  109. merged_keywords = [ma]
  110. effective_query = ma
  111. rank_query = raw_msg or f"{ma} {primary_venue(plan) or ''}".strip()
  112. ranking_profile = str(plan.ranking_profile or "accuracy").strip().lower()
  113. if ranking_profile not in ("accuracy", "novelty", "classic"):
  114. ranking_profile = "accuracy"
  115. if target_titles or (ma and plan.recipe == SearchRecipe.METHOD):
  116. ranking_profile = "classic"
  117. recall_sources = [
  118. str(s).strip().lower()
  119. for s in (plan.sources or [])
  120. if str(s).strip().lower() in _ACADEMIC_SOURCES
  121. ] or ["arxiv", "dblp", "openalex"]
  122. venue = primary_venue(plan)
  123. search_kwargs: dict[str, Any] = {
  124. "llm_keywords": merged_keywords[:8],
  125. "target_titles": target_titles,
  126. "authors": authors,
  127. "venue": venue,
  128. "year_from": plan.year_from,
  129. "year_to": plan.year_to,
  130. "main_conference_proceedings_only": bool(plan.main_conference_proceedings_only),
  131. "venue_proceedings_journal": use_venue_proceedings_journal(plan),
  132. "strict_venue_match": is_strict_venue_match(plan),
  133. "wants_recent": bool(plan.wants_recent),
  134. "sort": plan.sort or "relevance",
  135. "arxiv_id_list": list(plan.arxiv_id_list or [])[:16],
  136. }
  137. if is_pinned_single_year(plan) and venue:
  138. search_kwargs["pinned_topic_terms"] = sanitize_pinned_topic_keywords(
  139. list(merged_keywords) + list(plan.keywords or [])
  140. )
  141. if is_pinned_single_year(plan) and plan.main_conference_proceedings_only and venue:
  142. search_kwargs["venue_fallback_if_empty"] = False
  143. search_kwargs["openalex_relax_host_venue_on_empty"] = False
  144. if is_venue_browse_plan(plan):
  145. search_kwargs["venue_browse"] = True
  146. if ma:
  147. search_kwargs.update(method_acronym=ma, llm_keywords=[ma], dblp_use_llm_keywords=False)
  148. pinned = [str(x).strip() for x in (plan.arxiv_id_list or []) if str(x).strip()]
  149. return RecallContext(
  150. effective_query=effective_query,
  151. rank_query=rank_query,
  152. merged_keywords=merged_keywords,
  153. search_kwargs=search_kwargs,
  154. ranking_profile=ranking_profile,
  155. recall_sources=recall_sources,
  156. intent_source_message=raw_msg,
  157. pinned_arxiv_ids=pinned,
  158. source_plan={
  159. "sources": recall_sources,
  160. "effective_query": effective_query[:200],
  161. "rank_query": rank_query[:200],
  162. "ranking_profile": ranking_profile,
  163. "recipe": plan.recipe.value,
  164. },
  165. )
  166. async def enrich_recall_context_from_tavily(ctx: RecallContext, plan: ResolvedSearchPlan) -> RecallContext:
  167. if not plan.use_tavily:
  168. return ctx
  169. try:
  170. from ...settings import get_settings
  171. from .web_presearch import extract_anchor_ids, pick_anchor_title, tavily_search_async
  172. api_key = str(getattr(get_settings(), "tavily_api_key", "") or "").strip()
  173. if not api_key:
  174. return ctx
  175. ma = method_acronym_for(plan, ctx)
  176. venue = primary_venue(plan) or ""
  177. tq = (f"{ma} {venue} paper".strip() if ma and venue else (ctx.effective_query or ctx.rank_query or plan.query or "")).strip()
  178. if not tq:
  179. return ctx
  180. items = await tavily_search_async(api_key=api_key, query=tq[:400], max_results=5)
  181. anchors = extract_anchor_ids(items or [])
  182. arxiv_ids = list(ctx.pinned_arxiv_ids)
  183. for aid in anchors.get("arxiv_ids") or []:
  184. if aid and aid not in arxiv_ids:
  185. arxiv_ids.append(aid)
  186. ctx.pinned_arxiv_ids = arxiv_ids[:16]
  187. if title := pick_anchor_title(items or []):
  188. if len(title) >= 12:
  189. ctx.canonical_titles.insert(0, title[:240])
  190. for it in items or []:
  191. t = str(it.get("title") or "").strip()
  192. if len(t) >= 12 and t.lower() not in {x.lower() for x in ctx.canonical_titles}:
  193. if not any(x in t.lower() for x in ("home", "login", "index of", "schedule")):
  194. ctx.canonical_titles.append(t[:240])
  195. ctx.search_kwargs["arxiv_id_list"] = ctx.pinned_arxiv_ids
  196. tt = list(ctx.search_kwargs.get("target_titles") or [])
  197. for title in ctx.canonical_titles:
  198. if title and title not in tt:
  199. tt.append(title)
  200. if tt:
  201. ctx.search_kwargs["target_titles"] = tt[:6]
  202. if dois := anchors.get("dois"):
  203. ctx.search_kwargs["dois"] = dois[:5]
  204. ctx.tavily_keywords = list(ctx.canonical_titles)[:4]
  205. except Exception:
  206. pass
  207. return ctx