from __future__ import annotations import logging import time import httpx from app.models.agent import AgentPersona from app.models.world import WorldState from app.services.llm import LLMClient logger = logging.getLogger(__name__) RESEARCH_SUMMARIZE_SYSTEM = """You are {name}, a {age}-year-old {role}. Personality: honesty={honesty:.1f}, ambition={ambition:.1f}, empathy={empathy:.1f}, conformity={conformity:.1f} {market_personality} Your current beliefs: {beliefs} You just searched the internet for: "{query}" Reason: {reason} Here are the search results: {search_results} Based on YOUR personality and beliefs, what do you take away from this? Write 2-3 sentences of what you learned and how it affects your thinking. Be specific — cite numbers, names, or facts you found. If the results contradict your beliefs, note the tension. If they confirm your beliefs, note the reinforcement.""" class ResearchService: def __init__( self, llm: LLMClient, enabled: bool = True, max_per_round: int = 5, searxng_base_url: str = "", searxng_token: str = "", searxng_timeout: float = 15.0, searxng_language: str = "auto", searxng_safesearch: int = 1, cache_ttl_seconds: int = 300, ): self.llm = llm self.enabled = enabled self.max_per_round = max_per_round self.searxng_base_url = searxng_base_url.rstrip("/") self.searxng_token = searxng_token self.searxng_timeout = max(1.0, float(searxng_timeout)) self.searxng_language = searxng_language or "auto" self.searxng_safesearch = max(0, min(2, int(searxng_safesearch))) self.cache_ttl_seconds = max(0, int(cache_ttl_seconds)) self._client: httpx.AsyncClient | None = None self._cache: dict[str, tuple[float, list[dict]]] = {} async def _get_client(self) -> httpx.AsyncClient: if self._client is None or self._client.is_closed: timeout = httpx.Timeout(self.searxng_timeout, connect=min(5.0, self.searxng_timeout)) self._client = httpx.AsyncClient(timeout=timeout, follow_redirects=True) return self._client async def aclose(self) -> None: if self._client is not None: await self._client.aclose() self._client = None def _cache_key(self, query: str, max_results: int) -> str: return "|".join((query.strip().casefold(), str(max_results), self.searxng_language, str(self.searxng_safesearch))) async def _raw_search(self, query: str, max_results: int = 5) -> list[dict]: """Search SearXNG and normalize its response to the legacy shape. Search failures intentionally return an empty list. Research is an optional action and must not abort an otherwise healthy simulation. """ query = (query or "").strip() if not query: return [] if not self.searxng_base_url: logger.warning("SearXNG 未配置,跳过搜索(query=%s)", query[:120]) return [] max_results = max(1, min(20, int(max_results))) cache_key = self._cache_key(query, max_results) now = time.monotonic() cached = self._cache.get(cache_key) if self.cache_ttl_seconds > 0 and cached and now - cached[0] < self.cache_ttl_seconds: return [dict(item) for item in cached[1]] if cached and self.cache_ttl_seconds > 0: self._cache.pop(cache_key, None) try: client = await self._get_client() params = { "q": query, "format": "json", "language": self.searxng_language, "safesearch": self.searxng_safesearch, } if self.searxng_token: params["token"] = self.searxng_token response = await client.get(f"{self.searxng_base_url}/search", params=params) response.raise_for_status() payload = response.json() raw_results = payload.get("results", []) if isinstance(payload, dict) else [] results = [] for result in raw_results[:max_results]: if not isinstance(result, dict): continue results.append({ "title": str(result.get("title") or ""), "body": str(result.get("content") or ""), "href": str(result.get("url") or ""), }) if self.cache_ttl_seconds > 0: self._cache[cache_key] = (now, results) if len(self._cache) > 256: oldest = min(self._cache, key=lambda key: self._cache[key][0]) self._cache.pop(oldest, None) return results except (httpx.HTTPError, ValueError) as e: logger.warning("SearXNG search failed for '%s': %s", query[:120], e) return [] async def search_and_summarize( self, query: str, reason: str, agent: AgentPersona, world_state: WorldState, ) -> str: if not self.enabled: return "" results = await self._raw_search(query) if not results: return "" snippets = [] for i, r in enumerate(results[:5], 1): title = r.get("title", "") body = r.get("body", "") href = r.get("href", "") snippets.append(f"{i}. {title}\n {body}\n Source: {href}") search_results_text = "\n\n".join(snippets) p = agent.personality market_personality = "" has_market_traits = ( p.brand_loyalty != 0.5 or p.price_sensitivity != 0.5 or p.social_proof != 0.5 or p.novelty_seeking != 0.5 ) if has_market_traits: market_personality = ( f"Consumer traits: brand_loyalty={p.brand_loyalty:.1f}, " f"price_sensitivity={p.price_sensitivity:.1f}, " f"social_proof={p.social_proof:.1f}, " f"novelty_seeking={p.novelty_seeking:.1f}" ) beliefs = "\n".join(f"- {b}" for b in agent.beliefs[:5]) or "Still forming opinions." system = RESEARCH_SUMMARIZE_SYSTEM.format( name=agent.name, age=agent.age, role=agent.role, honesty=p.honesty, ambition=p.ambition, empathy=p.empathy, conformity=p.conformity, market_personality=market_personality, beliefs=beliefs, query=query, reason=reason, search_results=search_results_text, ) try: digest = await self.llm.generate( system=system, user="What did you learn? Summarize in 2-3 sentences.", json_mode=False, max_tokens=200, ) return digest.strip() except Exception as e: logger.warning("Research summarization failed for agent %s: %s", agent.name, e) return ""