Files
mirosociety/backend/app/services/research.py
T

192 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ""