fix simulation reporting and research resilience
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from duckduckgo_search import DDGS
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.models.agent import AgentPersona
|
||||
from app.models.world import WorldState
|
||||
@@ -30,24 +30,99 @@ 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):
|
||||
self.llm = llm
|
||||
self.enabled = enabled
|
||||
self.max_per_round = max_per_round
|
||||
|
||||
async def _raw_search(self, query: str, max_results: int = 5) -> list[dict]:
|
||||
"""Run a DuckDuckGo search in a thread pool to avoid blocking the event loop."""
|
||||
def _search():
|
||||
try:
|
||||
with DDGS() as ddgs:
|
||||
return list(ddgs.text(query, max_results=max_results))
|
||||
except Exception as e:
|
||||
logger.warning("DuckDuckGo search failed for '%s': %s", query, e)
|
||||
return []
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, _search)
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user