web_presearch.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. """Web 预搜索:在多源学术检索之前先做"锚点"召回。
  2. 目标:
  3. - 解决短词/术语(如 patchcore)导致的多源召回噪声与歧义
  4. - 先从 Web 搜索拿到最可信的论文标题/DOI/arXiv,再由 Agent 生成更精确的检索单元
  5. 说明:
  6. - Settings 默认 ``tavily_presearch_enabled=true``;未配置 ``TAVILY_API_KEY`` 时不会发外呼。
  7. - Tavily 会场→域名映射见 ``tavily_venue_domains.json``(``tavily_venue_config``),勿在此文件堆业务映射。
  8. """
  9. from __future__ import annotations
  10. import logging
  11. import re
  12. from typing import Any, Dict, List, Optional
  13. import httpx
  14. logger = logging.getLogger(__name__)
  15. from .tavily_venue_config import ( # noqa: E402
  16. get_official_proceedings_hosts,
  17. )
  18. # Tavily:query 超过 400 字符会返回 400(见官方文档与常见报错)
  19. TAVILY_MAX_QUERY_CHARS = 400
  20. def _normalize_tavily_query(query: str, *, max_chars: int = TAVILY_MAX_QUERY_CHARS) -> str:
  21. q = (query or "").strip()
  22. if not q:
  23. return ""
  24. if len(q) <= max_chars:
  25. return q
  26. clipped = q[:max_chars].rstrip()
  27. logger.warning(
  28. "tavily: query 过长已截断 (%d -> %d 字符),避免 Tavily 400",
  29. len(q),
  30. len(clipped),
  31. )
  32. return clipped
  33. async def tavily_search_async(
  34. *,
  35. api_key: str,
  36. query: str,
  37. max_results: int = 5,
  38. timeout_sec: int = 20,
  39. include_domains: Optional[List[str]] = None,
  40. httpx_client: Optional[httpx.AsyncClient] = None,
  41. ) -> List[Dict[str, Any]]:
  42. """Async Tavily Search API call. Reuses shared httpx client when available."""
  43. q = _normalize_tavily_query(query)
  44. if not q:
  45. return []
  46. if not (api_key or "").strip():
  47. return []
  48. n = max(1, min(10, int(max_results or 5)))
  49. url = "https://api.tavily.com/search"
  50. payload = {
  51. "api_key": api_key,
  52. "query": q,
  53. "max_results": n,
  54. "include_answer": True,
  55. "include_raw_content": True,
  56. }
  57. dom = [str(x).strip() for x in (include_domains or []) if str(x).strip()][:3]
  58. if dom:
  59. payload["include_domains"] = dom
  60. timeout = httpx.Timeout(timeout_sec)
  61. async def _do_post(client):
  62. resp = await client.post(url, json=payload)
  63. if resp.status_code >= 400:
  64. payload2 = dict(payload)
  65. payload2["include_raw_content"] = False
  66. resp = await client.post(url, json=payload2)
  67. resp.raise_for_status()
  68. return resp.json() or {}
  69. if httpx_client is not None:
  70. data = await _do_post(httpx_client)
  71. else:
  72. async with httpx.AsyncClient(timeout=timeout) as client:
  73. data = await _do_post(client)
  74. out: List[Dict[str, Any]] = []
  75. ans = str(data.get("answer") or "").strip()
  76. if ans:
  77. out.append({"title": ans[:180], "link": "", "snippet": ans})
  78. for it in (data.get("results") or [])[:n]:
  79. if not isinstance(it, dict):
  80. continue
  81. title = str(it.get("title") or "").strip()
  82. link = str(it.get("url") or "").strip()
  83. snippet = str(it.get("content") or "").strip()
  84. if not title and not link and not snippet:
  85. continue
  86. out.append({
  87. "title": title[:200] if title else "",
  88. "link": link,
  89. "snippet": snippet[:300] if snippet else "",
  90. "raw_content": str(it.get("raw_content") or "")[:20000],
  91. })
  92. return out
  93. def pick_anchor_title(items: List[Dict[str, Any]]) -> Optional[str]:
  94. """Pick the best paper title from Tavily results. Prefer trusted academic sources."""
  95. if not items:
  96. return None
  97. trusted = ("arxiv.org", "doi.org", "neurips.cc", "openreview.net", "proceedings.")
  98. def _score(it: Dict[str, Any]) -> float:
  99. title = str(it.get("title") or "").strip()
  100. if not title or len(title) < 8:
  101. return -1e9
  102. link = str(it.get("link") or it.get("url") or "").lower()
  103. score = float(len(title))
  104. if any(h in link for h in trusted):
  105. score += 200.0
  106. if any(h in title.lower() for h in ("github", "repo", "awesome-")):
  107. score -= 500.0
  108. return score
  109. best = max(items, key=_score)
  110. title = str(best.get("title") or "").strip()
  111. title = re.sub(r"^\s*(\[PDF\]|\(PDF\))\s*", "", title, flags=re.I)
  112. return title or None
  113. _ARXIV_ID_RE = re.compile(r"(?:arxiv\.org/(?:abs|pdf)/|arxiv:)\s*([0-9]{4}\.[0-9]{4,5})(?:v\d+)?", re.I)
  114. _DOI_RE = re.compile(r"\b10\.\d{4,9}/[^\s\"'<>]+", re.I)
  115. def extract_anchor_ids(items: List[Dict[str, Any]]) -> Dict[str, List[str]]:
  116. """从 Tavily 返回里提取高置信 ID(arXiv / DOI)。
  117. 用途:当 query 是短词/术语时,用这些 ID 作为"最匹配"的强证据加入候选集,
  118. 但不绑定到某个具体 query(避免硬编码)。
  119. """
  120. arxiv_ids: List[str] = []
  121. dois: List[str] = []
  122. def _push_unique(buf: List[str], x: str, limit: int):
  123. t = (x or "").strip()
  124. if not t:
  125. return
  126. tl = t.lower()
  127. if any(y.lower() == tl for y in buf):
  128. return
  129. buf.append(t)
  130. if len(buf) > limit:
  131. del buf[limit:]
  132. for it in (items or [])[:10]:
  133. if not isinstance(it, dict):
  134. continue
  135. hay = " ".join(
  136. [
  137. str(it.get("title") or ""),
  138. str(it.get("link") or it.get("url") or ""),
  139. str(it.get("snippet") or it.get("content") or ""),
  140. str(it.get("raw_content") or ""),
  141. ]
  142. )
  143. for m in _ARXIV_ID_RE.finditer(hay):
  144. _push_unique(arxiv_ids, m.group(1), 5)
  145. for m in _DOI_RE.finditer(hay):
  146. doi = m.group(0).rstrip(").,;]")
  147. _push_unique(dois, doi, 5)
  148. return {"arxiv_ids": arxiv_ids, "dois": dois}
  149. _NON_PAPER_HOSTS = ("youtube.com", "youtu.be", "reddit.com", "twitter.com", "x.com", "facebook.com", "instagram.com")
  150. def _clean_keyword_phrase(s: str, max_len: int = 100) -> str:
  151. t = (s or "").strip()
  152. if not t:
  153. return ""
  154. t = re.sub(r"^\s*(\[\s*pdf\s*\]|\(\s*pdf\s*\)|【\s*pdf\s*】)\s*", "", t, flags=re.I)
  155. t = re.sub(r"^\s*pdf\s*[::]\s*", "", t, flags=re.I)
  156. t = re.sub(r"\s*[·|\-]\s*GitHub\s*$", "", t, flags=re.I)
  157. t = re.sub(r"\.pdf\s+at\s+main.*$", "", t, flags=re.I)
  158. t = re.sub(r"\s*\.\.\.$", "", t).strip()
  159. t = re.sub(r"^(?:[A-Z]{2,10})\s*[::]\s+", "", t).strip()
  160. # 去掉多余空白与换行
  161. t = re.sub(r"\s+", " ", t).strip()
  162. # 限制长度
  163. if len(t) > max_len:
  164. t = t[:max_len-1].rstrip() + "…"
  165. return t
  166. def _snippet_as_keyword(snippet: str, max_len: int = 140) -> str:
  167. s = (snippet or "").strip().replace("\n", " ")
  168. if not s:
  169. return ""
  170. s = re.sub(r"\s+", " ", s).strip()
  171. if len(s) > max_len:
  172. s = s[: max_len - 1].rstrip() + "…"
  173. return s
  174. def _tavily_item_keyword_priority(it: Dict[str, Any]) -> int:
  175. """排序:优先无 URL 的 answer 摘要,其次 arXiv/DOI 等学术落地页,降低论坛/博客噪声顺序。"""
  176. if not isinstance(it, dict):
  177. return 0
  178. link = str(it.get("link") or it.get("url") or "").strip().lower()
  179. if not link:
  180. return 110
  181. if "arxiv.org" in link:
  182. return 100
  183. if "doi.org" in link or "openreview.net" in link:
  184. return 95
  185. if any(h in link for h in get_official_proceedings_hosts()):
  186. return 88
  187. if any(h in link for h in ("cv-foundation.org", "aclweb.org")):
  188. return 88
  189. if any(h in link for h in ("ieee.org", "acm.org", "springer", "nature.com", "science.org")):
  190. return 82
  191. if any(h in link for h in _NON_PAPER_HOSTS):
  192. return 0
  193. return 40
  194. def tavily_items_to_llm_keywords(
  195. items: List[Dict[str, Any]],
  196. user_query: str,
  197. *,
  198. max_phrases: int = 16,
  199. ) -> List[str]:
  200. """把 Tavily 返回的论文标题/摘要片段转成后续学术检索用的 llm_keywords(去重、限长)。
  201. 设计目标:用户希望「Tavily 搜到的论文名/内容」**直接**参与 arXiv/OpenAlex 等 OR 检索,
  202. 而不是只选一个启发式锚点标题。
  203. """
  204. uq = (user_query or "").strip()
  205. out: List[str] = []
  206. seen: set[str] = set()
  207. def push(x: str) -> None:
  208. t = _clean_keyword_phrase(x)
  209. if not t or len(t) < 8:
  210. return
  211. low = t.lower()
  212. if low in seen:
  213. return
  214. # 过滤明显非论文页标题
  215. if any(h in low for h in ("github", "repo", "awesome-", "arxiv-sanity", "paperswithcode")):
  216. return
  217. out.append(t)
  218. seen.add(low)
  219. if len(out) >= max_phrases:
  220. return
  221. if uq:
  222. push(uq)
  223. pool = [x for x in (items or [])[:16] if isinstance(x, dict)]
  224. pool.sort(key=_tavily_item_keyword_priority, reverse=True)
  225. for it in pool[:12]:
  226. link = str(it.get("link") or it.get("url") or "").lower()
  227. if any(h in link for h in _NON_PAPER_HOSTS):
  228. continue
  229. title = str(it.get("title") or "").strip()
  230. if title:
  231. push(title)
  232. if len(out) >= max_phrases:
  233. break
  234. sn = str(it.get("snippet") or it.get("content") or "").strip()
  235. sk = _snippet_as_keyword(sn)
  236. if sk and sk.lower() not in seen and sk.lower() != (title or "").lower():
  237. push(sk)
  238. if len(out) >= max_phrases:
  239. break
  240. return out[:max_phrases]