| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196 |
- """搜索智能体 —— 自然语言意图解析 + 多源论文检索编排."""
- from __future__ import annotations
- import logging
- import time
- from typing import Any, Optional
- from ..core.search.paper_searcher import _sanitize_author_list_for_query
- from ..models.schemas import Paper
- from ..services.llm.llm_service import coerce_hello_agents_llm_output_to_str
- from ..services.llm.agent_config import papergraph_agent_config
- from ..services.search_intent import (
- apply_llm_intent_hygiene,
- extract_json_object,
- finalize_llm_intent,
- search_intent_from_dict,
- )
- from ..services.search_intent.parsing import _ensure_intent_year_window_ordered
- from ..settings import get_settings
- from .base import BaseAgent
- from .prompts.search import INTENT_LLM_PROMPT as _INTENT_LLM_PROMPT_TEMPLATE
- from .support import SearchExplainer as _SearchExplainer
- from .support.search_models import SearchIntent
- logger = logging.getLogger(__name__)
- _INTENT_CACHE: dict[tuple[str, str], tuple[float, SearchIntent]] = {}
- _INTENT_CACHE_TTL = 300.0
- class SearchAgent(BaseAgent):
- INTENT_LLM_PROMPT = _INTENT_LLM_PROMPT_TEMPLATE
- def __init__(self) -> None:
- super().__init__()
- self._intent_parser_agent: Optional[Any] = None
- self.intent_parser = IntentParser(self)
- self.explainer = _SearchExplainer()
- def _parse_intent_from_llm_step(
- self,
- *,
- message: str,
- profile: str,
- prompt_template: Optional[str] = None,
- correction_hint: Optional[str] = None,
- ) -> SearchIntent:
- msg = (message or "").strip()
- if not msg:
- raise ValueError("intent_parse_empty_message")
- if not self.llm:
- raise RuntimeError("intent_parse_llm_unavailable")
- from ..services.search_intent.parsing import format_intent_llm_prompt
- tmpl = prompt_template or self.INTENT_LLM_PROMPT
- prompt = format_intent_llm_prompt(
- tmpl, msg, profile, correction_hint=(correction_hint or "").strip() or None
- )
- from hello_agents import SimpleAgent
- parser_agent = self._intent_parser_agent
- if parser_agent is None:
- parser_agent = SimpleAgent(
- name="intent_parser",
- llm=self.llm,
- system_prompt="你是学术检索意图解析器。只输出 JSON,不要解释。",
- config=papergraph_agent_config(),
- )
- self._intent_parser_agent = parser_agent
- resp = parser_agent.run(prompt)
- text = coerce_hello_agents_llm_output_to_str(resp).strip()
- payload = extract_json_object(text)
- if not payload:
- err: ValueError = ValueError("LLM 未返回有效 JSON")
- setattr(err, "last_llm_output", text)
- raise err
- intent = search_intent_from_dict(payload)
- return finalize_llm_intent(intent, profile)
- def understand_intent(self, message: str, profile: str = "accuracy") -> SearchIntent:
- return self.intent_parser.parse(message, profile=profile)
- def explain_results(self, intent: SearchIntent, papers: list[Paper], mode: str = "accuracy") -> str:
- _ = mode
- return self.explainer.format_search_explanation(intent, papers)
- @staticmethod
- def _normalize_profile(profile: Optional[str]) -> str:
- prof = (profile or "accuracy").strip().lower()
- return prof if prof in ("accuracy", "novelty") else "accuracy"
- class IntentParser:
- def __init__(self, agent: SearchAgent) -> None:
- self._agent = agent
- def parse(self, message: str, profile: str = "accuracy") -> SearchIntent:
- """解析用户自然语言查询为结构化 SearchIntent,带 LRU 缓存."""
- msg = (message or "").strip()
- if not msg:
- return SearchIntent()
- # 5 分钟内相同查询命中缓存,避免重复调用 LLM
- cache_key = (msg.lower()[:200], (profile or "accuracy").strip().lower())
- now = time.time()
- if cache_key in _INTENT_CACHE:
- ts, cached = _INTENT_CACHE[cache_key]
- if now - ts < _INTENT_CACHE_TTL:
- return cached
- intent = self._parse_with_retry(msg, profile)
- _INTENT_CACHE[cache_key] = (now, intent)
- # LRU 淘汰:缓存超过 200 条时删除最旧条目
- if len(_INTENT_CACHE) > 200:
- oldest = min(_INTENT_CACHE, key=lambda k: _INTENT_CACHE[k][0])
- del _INTENT_CACHE[oldest]
- return intent
- def _parse_with_retry(self, msg: str, profile: str) -> SearchIntent:
- from ..services.search_intent.parsing import build_intent_retry_correction_hint
- prof = self._agent._normalize_profile(profile)
- if not self._agent.llm:
- raise RuntimeError("search_agent_llm_unavailable")
- s = get_settings()
- outer_retries = max(0, min(5, int(getattr(s, "papergraph_intent_parse_max_retries", 2) or 2)))
- correction: str | None = None
- last_exc: Exception | None = None
- last_output: str | None = None
- for attempt in range(outer_retries + 1):
- try:
- return self._parse_llm_primary(msg, prof, correction_hint=correction)
- except Exception as e:
- last_exc = e
- last_output = getattr(e, "last_llm_output", None) or last_output
- logger.warning(
- "[SearchAgent] intent parse failed (attempt %d/%d): %s",
- attempt + 1,
- outer_retries + 1,
- e,
- )
- if attempt >= outer_retries:
- break
- if "connection error" in str(e or "").lower() or "timed out" in str(e or "").lower():
- break
- correction = build_intent_retry_correction_hint(
- e, user_message=msg, last_output=last_output
- )
- logger.warning("[SearchAgent] LLM intent parse exhausted retries: %s", last_exc)
- raise RuntimeError("search_agent_intent_failed") from last_exc
- def _parse_llm_primary(
- self,
- msg: str,
- prof: str,
- *,
- correction_hint: str | None = None,
- ) -> SearchIntent:
- llm_intent = self._agent._parse_intent_from_llm_step(
- message=msg,
- profile=prof,
- prompt_template=self._agent.INTENT_LLM_PROMPT,
- correction_hint=correction_hint,
- )
- out = finalize_llm_intent(llm_intent, prof)
- if not (out.query or "").strip() and (out.keywords or []):
- out.query = (out.keywords[0] or "")[:500]
- if not (out.query or "").strip() and (out.authors or []):
- out.query = str(out.authors[0]).strip()[:500]
- if (
- not (out.query or "").strip()
- and not (out.venues or [])
- and not (out.authors or [])
- and not (out.arxiv_id_list or [])
- and not (out.target_titles or [])
- ):
- raise ValueError("intent_parse_empty_query")
- apply_llm_intent_hygiene(out, msg)
- _ensure_intent_year_window_ordered(out)
- out.raw_user_message = msg.strip()[:3200]
- out.authors = _sanitize_author_list_for_query(out.query or "", out.authors or [])
- return out
- _search_agent_singleton: Optional[SearchAgent] = None
- def get_search_agent() -> SearchAgent:
- global _search_agent_singleton
- if _search_agent_singleton is None:
- _search_agent_singleton = SearchAgent()
- return _search_agent_singleton
|