search_agent.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. """搜索智能体 —— 自然语言意图解析 + 多源论文检索编排."""
  2. from __future__ import annotations
  3. import logging
  4. import time
  5. from typing import Any, Optional
  6. from ..core.search.paper_searcher import _sanitize_author_list_for_query
  7. from ..models.schemas import Paper
  8. from ..services.llm.llm_service import coerce_hello_agents_llm_output_to_str
  9. from ..services.llm.agent_config import papergraph_agent_config
  10. from ..services.search_intent import (
  11. apply_llm_intent_hygiene,
  12. extract_json_object,
  13. finalize_llm_intent,
  14. search_intent_from_dict,
  15. )
  16. from ..services.search_intent.parsing import _ensure_intent_year_window_ordered
  17. from ..settings import get_settings
  18. from .base import BaseAgent
  19. from .prompts.search import INTENT_LLM_PROMPT as _INTENT_LLM_PROMPT_TEMPLATE
  20. from .support import SearchExplainer as _SearchExplainer
  21. from .support.search_models import SearchIntent
  22. logger = logging.getLogger(__name__)
  23. _INTENT_CACHE: dict[tuple[str, str], tuple[float, SearchIntent]] = {}
  24. _INTENT_CACHE_TTL = 300.0
  25. class SearchAgent(BaseAgent):
  26. INTENT_LLM_PROMPT = _INTENT_LLM_PROMPT_TEMPLATE
  27. def __init__(self) -> None:
  28. super().__init__()
  29. self._intent_parser_agent: Optional[Any] = None
  30. self.intent_parser = IntentParser(self)
  31. self.explainer = _SearchExplainer()
  32. def _parse_intent_from_llm_step(
  33. self,
  34. *,
  35. message: str,
  36. profile: str,
  37. prompt_template: Optional[str] = None,
  38. correction_hint: Optional[str] = None,
  39. ) -> SearchIntent:
  40. msg = (message or "").strip()
  41. if not msg:
  42. raise ValueError("intent_parse_empty_message")
  43. if not self.llm:
  44. raise RuntimeError("intent_parse_llm_unavailable")
  45. from ..services.search_intent.parsing import format_intent_llm_prompt
  46. tmpl = prompt_template or self.INTENT_LLM_PROMPT
  47. prompt = format_intent_llm_prompt(
  48. tmpl, msg, profile, correction_hint=(correction_hint or "").strip() or None
  49. )
  50. from hello_agents import SimpleAgent
  51. parser_agent = self._intent_parser_agent
  52. if parser_agent is None:
  53. parser_agent = SimpleAgent(
  54. name="intent_parser",
  55. llm=self.llm,
  56. system_prompt="你是学术检索意图解析器。只输出 JSON,不要解释。",
  57. config=papergraph_agent_config(),
  58. )
  59. self._intent_parser_agent = parser_agent
  60. resp = parser_agent.run(prompt)
  61. text = coerce_hello_agents_llm_output_to_str(resp).strip()
  62. payload = extract_json_object(text)
  63. if not payload:
  64. err: ValueError = ValueError("LLM 未返回有效 JSON")
  65. setattr(err, "last_llm_output", text)
  66. raise err
  67. intent = search_intent_from_dict(payload)
  68. return finalize_llm_intent(intent, profile)
  69. def understand_intent(self, message: str, profile: str = "accuracy") -> SearchIntent:
  70. return self.intent_parser.parse(message, profile=profile)
  71. def explain_results(self, intent: SearchIntent, papers: list[Paper], mode: str = "accuracy") -> str:
  72. _ = mode
  73. return self.explainer.format_search_explanation(intent, papers)
  74. @staticmethod
  75. def _normalize_profile(profile: Optional[str]) -> str:
  76. prof = (profile or "accuracy").strip().lower()
  77. return prof if prof in ("accuracy", "novelty") else "accuracy"
  78. class IntentParser:
  79. def __init__(self, agent: SearchAgent) -> None:
  80. self._agent = agent
  81. def parse(self, message: str, profile: str = "accuracy") -> SearchIntent:
  82. """解析用户自然语言查询为结构化 SearchIntent,带 LRU 缓存."""
  83. msg = (message or "").strip()
  84. if not msg:
  85. return SearchIntent()
  86. # 5 分钟内相同查询命中缓存,避免重复调用 LLM
  87. cache_key = (msg.lower()[:200], (profile or "accuracy").strip().lower())
  88. now = time.time()
  89. if cache_key in _INTENT_CACHE:
  90. ts, cached = _INTENT_CACHE[cache_key]
  91. if now - ts < _INTENT_CACHE_TTL:
  92. return cached
  93. intent = self._parse_with_retry(msg, profile)
  94. _INTENT_CACHE[cache_key] = (now, intent)
  95. # LRU 淘汰:缓存超过 200 条时删除最旧条目
  96. if len(_INTENT_CACHE) > 200:
  97. oldest = min(_INTENT_CACHE, key=lambda k: _INTENT_CACHE[k][0])
  98. del _INTENT_CACHE[oldest]
  99. return intent
  100. def _parse_with_retry(self, msg: str, profile: str) -> SearchIntent:
  101. from ..services.search_intent.parsing import build_intent_retry_correction_hint
  102. prof = self._agent._normalize_profile(profile)
  103. if not self._agent.llm:
  104. raise RuntimeError("search_agent_llm_unavailable")
  105. s = get_settings()
  106. outer_retries = max(0, min(5, int(getattr(s, "papergraph_intent_parse_max_retries", 2) or 2)))
  107. correction: str | None = None
  108. last_exc: Exception | None = None
  109. last_output: str | None = None
  110. for attempt in range(outer_retries + 1):
  111. try:
  112. return self._parse_llm_primary(msg, prof, correction_hint=correction)
  113. except Exception as e:
  114. last_exc = e
  115. last_output = getattr(e, "last_llm_output", None) or last_output
  116. logger.warning(
  117. "[SearchAgent] intent parse failed (attempt %d/%d): %s",
  118. attempt + 1,
  119. outer_retries + 1,
  120. e,
  121. )
  122. if attempt >= outer_retries:
  123. break
  124. if "connection error" in str(e or "").lower() or "timed out" in str(e or "").lower():
  125. break
  126. correction = build_intent_retry_correction_hint(
  127. e, user_message=msg, last_output=last_output
  128. )
  129. logger.warning("[SearchAgent] LLM intent parse exhausted retries: %s", last_exc)
  130. raise RuntimeError("search_agent_intent_failed") from last_exc
  131. def _parse_llm_primary(
  132. self,
  133. msg: str,
  134. prof: str,
  135. *,
  136. correction_hint: str | None = None,
  137. ) -> SearchIntent:
  138. llm_intent = self._agent._parse_intent_from_llm_step(
  139. message=msg,
  140. profile=prof,
  141. prompt_template=self._agent.INTENT_LLM_PROMPT,
  142. correction_hint=correction_hint,
  143. )
  144. out = finalize_llm_intent(llm_intent, prof)
  145. if not (out.query or "").strip() and (out.keywords or []):
  146. out.query = (out.keywords[0] or "")[:500]
  147. if not (out.query or "").strip() and (out.authors or []):
  148. out.query = str(out.authors[0]).strip()[:500]
  149. if (
  150. not (out.query or "").strip()
  151. and not (out.venues or [])
  152. and not (out.authors or [])
  153. and not (out.arxiv_id_list or [])
  154. and not (out.target_titles or [])
  155. ):
  156. raise ValueError("intent_parse_empty_query")
  157. apply_llm_intent_hygiene(out, msg)
  158. _ensure_intent_year_window_ordered(out)
  159. out.raw_user_message = msg.strip()[:3200]
  160. out.authors = _sanitize_author_list_for_query(out.query or "", out.authors or [])
  161. return out
  162. _search_agent_singleton: Optional[SearchAgent] = None
  163. def get_search_agent() -> SearchAgent:
  164. global _search_agent_singleton
  165. if _search_agent_singleton is None:
  166. _search_agent_singleton = SearchAgent()
  167. return _search_agent_singleton