fix simulation reporting and research resilience

This commit is contained in:
2026-09-28 15:43:23 +08:00
parent 23297be2d7
commit a428214c53
25 changed files with 1488 additions and 244 deletions
+98 -23
View File
@@ -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,