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
+8 -3
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from fastapi import APIRouter, Request, HTTPException
from fastapi import APIRouter, Request, HTTPException
from app.services.narrator import REPORT_SCHEMA_VERSION
router = APIRouter(prefix="/api")
@@ -18,8 +19,12 @@ async def get_gallery_item(sim_id: str, request: Request):
raise HTTPException(404, "Simulation not found in gallery")
await store.increment_view_count(sim_id)
cached = await store.get_report(sim_id)
if cached and cached.get("status") == "ready":
return cached["report"]
if (
cached
and cached.get("status") == "ready"
and cached.get("report", {}).get("report_schema_version") == REPORT_SCHEMA_VERSION
):
return cached["report"]
narrator = request.app.state.narrator
report = await narrator.generate_report(sim_id, store)
if "error" not in report:
+37 -14
View File
@@ -17,6 +17,7 @@ from app.services.census import CensusService
from app.services.i18n import locale_from_request, translate
from app.services.sim_logger import SimulationLogger
from app.services.simulation_logging import bind_simulation, unbind_simulation
from app.services.narrator import REPORT_SCHEMA_VERSION
from enum import Enum
@@ -213,8 +214,11 @@ async def simulate(req: SimulateRequest, request: Request):
"模拟 %s:居民生成完成,耗时=%.2f秒,实际数量=%d",
sim_id, time.monotonic() - t_stage, len(agents),
)
fallback_count = sum(1 for agent in agents if agent.generation_source != "llm")
await store.set_meta(sim_id, "citizen_generation_fallback_count", fallback_count)
sim_log.log_event("citizens_generated", {
"count": len(agents),
"fallback_count": fallback_count,
"elapsed_s": round(time.monotonic() - t_stage, 3),
})
t_stage = time.monotonic()
@@ -449,10 +453,10 @@ async def get_report(sim_id: str, request: Request):
# Cancelled: client should not keep polling, but partial data (if any) is
# still in the report table. Try to return it before bailing.
if sim.get("status") == "cancelled":
result = await store.get_report(sim_id)
if result.get("status") == "ready":
return result["report"]
if sim.get("status") == "cancelled":
result = await store.get_report(sim_id)
if result.get("status") == "ready":
return result["report"]
return {"status": "cancelled", "message": translate("simulation_cancelled", locale)}
# Interrupted (typically because the backend restarted and the pipeline
@@ -468,12 +472,27 @@ async def get_report(sim_id: str, request: Request):
"message": translate("simulation_interrupted_no_report", locale),
}
result = await store.get_report(sim_id)
if result.get("status") == "ready":
report = result["report"]
report["rules_text"] = sim.get("rules_text", "") or ""
return report
return result
result = await store.get_report(sim_id)
if (
result.get("status") == "ready"
and result.get("report", {}).get("report_schema_version") == REPORT_SCHEMA_VERSION
):
report = result["report"]
report["rules_text"] = sim.get("rules_text", "") or ""
return report
if result.get("status") == "ready":
# A completed simulation may have a cached report from an older
# schema. Regenerate it once so report changes are not invisible to
# users who already ran the simulation.
narrator = getattr(request.app.state, "narrator", None)
if narrator is not None and hasattr(narrator, "set_locale"):
narrator.set_locale(locale)
report = await request.app.state.narrator.generate_report(sim_id, store)
if "error" not in report:
await store.save_report(sim_id, report)
report["rules_text"] = sim.get("rules_text", "") or ""
return report
return result
@router.post("/simulation/{sim_id}/publish")
@@ -513,10 +532,14 @@ async def compare(sim_id: str, fork_id: str, request: Request):
source_agents = await store.get_all_agents(sim_id)
fork_agents = await store.get_all_agents(fork_id)
async def _get_or_generate_report(sid):
cached = await store.get_report(sid)
if cached and cached.get("status") == "ready":
return cached["report"]
async def _get_or_generate_report(sid):
cached = await store.get_report(sid)
if (
cached
and cached.get("status") == "ready"
and cached.get("report", {}).get("report_schema_version") == REPORT_SCHEMA_VERSION
):
return cached["report"]
rpt = await narrator.generate_report(sid, store)
if "error" not in rpt:
await store.save_report(sid, rpt)
+9 -3
View File
@@ -12,9 +12,15 @@ class Settings(BaseSettings):
database_dir: str = str(_PROJECT_ROOT / "data")
host: str = "0.0.0.0"
port: int = 8000
max_concurrent_llm_calls: int = 10
search_enabled: bool = True
max_searches_per_round: int = 5
max_concurrent_llm_calls: int = 10
search_enabled: bool = True
max_searches_per_round: int = 5
searxng_base_url: str = "https://searxng.erhe.top"
searxng_token: str = ""
searxng_timeout: float = 15.0
searxng_language: str = "auto"
searxng_safesearch: int = 1
searxng_cache_ttl_seconds: int = 300
model_config = {
"env_file": [".env", str(_PROJECT_ROOT / ".env")],
+15 -5
View File
@@ -84,11 +84,17 @@ async def lifespan(app: FastAPI):
resolver = ActionResolver()
forecast = ForecastService()
narrator = Narrator(llm, forecast=forecast)
research = ResearchService(
llm,
enabled=settings.search_enabled,
max_per_round=settings.max_searches_per_round,
)
research = ResearchService(
llm,
enabled=settings.search_enabled,
max_per_round=settings.max_searches_per_round,
searxng_base_url=settings.searxng_base_url,
searxng_token=settings.searxng_token,
searxng_timeout=settings.searxng_timeout,
searxng_language=settings.searxng_language,
searxng_safesearch=settings.searxng_safesearch,
cache_ttl_seconds=settings.searxng_cache_ttl_seconds,
)
gossip = GossipEngine()
life_engine = LifeEngine(llm=llm)
engine = SimulationEngine(llm, store, tension, resolver, narrator, research=research, gossip=gossip, life_engine=life_engine, forecast=forecast)
@@ -106,6 +112,10 @@ async def lifespan(app: FastAPI):
logger.info("MiroSociety 已启动:模型=%s,地址=%s", settings.llm_model, settings.llm_base_url)
yield
logger.info("MiroSociety 正在关闭")
try:
await research.aclose()
except Exception as e:
logger.warning("关闭搜索客户端失败:%s", e)
app = FastAPI(title="MiroSociety", version="0.1.0", lifespan=lifespan)
+5 -2
View File
@@ -67,7 +67,7 @@ class LifeState(BaseModel):
life_log: list[str] = Field(default_factory=list)
class AgentPersona(BaseModel):
class AgentPersona(BaseModel):
id: int
name: str
role: str
@@ -88,4 +88,7 @@ class AgentPersona(BaseModel):
knowledge_level: str = Field(default="full")
life_state: LifeState | None = None
abandoned_products: set[str] = Field(default_factory=set)
has_defected: bool = False
has_defected: bool = False
# Keeps degraded citizen generation observable without changing the
# existing persona contract. Normal LLM-generated citizens use "llm".
generation_source: str = "llm"
+16 -4
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
class Location(BaseModel):
@@ -10,11 +10,23 @@ class Location(BaseModel):
description: str
class TimeConfig(BaseModel):
class TimeConfig(BaseModel):
total_days: int = 365
rounds_per_day: int = 3
active_agents_per_round_min: int = 3
active_agents_per_round_max: int = 10
active_agents_per_round_min: int = 3
active_agents_per_round_max: int = 10
@model_validator(mode="after")
def validate_ranges(self) -> "TimeConfig":
if self.total_days < 1:
raise ValueError("total_days must be positive")
if self.rounds_per_day < 1:
raise ValueError("rounds_per_day must be positive")
if self.active_agents_per_round_min < 0 or self.active_agents_per_round_max < 0:
raise ValueError("active agent counts cannot be negative")
if self.active_agents_per_round_min > self.active_agents_per_round_max:
raise ValueError("active_agents_per_round_min cannot exceed max")
return self
class WorldBlueprint(BaseModel):
+109 -50
View File
@@ -387,12 +387,18 @@ class CitizenGenerator:
)
data = parse_json(response, purpose="居民名单生成")
if isinstance(data, dict) and "citizens" in data:
cast = data["citizens"]
elif isinstance(data, list):
cast = data
else:
cast = []
if isinstance(data, dict) and "citizens" in data:
cast = data["citizens"]
elif isinstance(data, list):
cast = data
else:
cast = []
cast = [
member for member in cast
if isinstance(member, dict)
and str(member.get("name", "")).strip()
and str(member.get("role", "")).strip()
]
# If the user picked a Chinese locale but the LLM ignored our language
# directive and returned English names, retry once with a stronger nudge.
@@ -414,33 +420,74 @@ class CitizenGenerator:
purpose="cast_generate_retry",
)
data = parse_json(response, purpose="居民名单生成重试")
if isinstance(data, dict) and "citizens" in data:
cast = data["citizens"]
elif isinstance(data, list):
cast = data
if len(cast) < count:
logger.warning("居民生成仅返回 %d/%d 人,将使用默认居民补齐", len(cast), count)
while len(cast) < count:
idx = len(cast)
if self._locale == "zh":
cast.append({
"name": f"居民{idx + 1}号",
"role": "劳动者",
"age": 30 + (idx % 40),
"personality_hook": "一个安静过日子的人",
"stance": "对规则漠不关心",
})
else:
cast.append({
"name": f"Citizen {idx + 1}",
"role": "Laborer",
"age": 30 + (idx % 40),
"personality_hook": "A quiet person trying to get by",
"stance": "Indifferent to the rules",
})
return cast[:count]
if isinstance(data, dict) and "citizens" in data:
cast = data["citizens"]
elif isinstance(data, list):
cast = data
cast = [
member for member in cast
if isinstance(member, dict)
and str(member.get("name", "")).strip()
and str(member.get("role", "")).strip()
]
if len(cast) < count:
missing = count - len(cast)
refill_user = user_text + (
f"\n\n[REFILL] You returned {len(cast)} citizens. Generate exactly {missing} additional "
"citizens only. They must be distinct, neutral, and contain all required fields."
)
if self._locale == "zh":
refill_user += " Use Chinese names, roles, hooks, and stances."
try:
refill_response = await self.llm.generate(
system=system_prompt,
user=refill_user,
json_mode=True,
max_tokens=min(3000, max(1000, missing * 220)),
purpose="cast_generate_refill",
)
refill_data = parse_json(refill_response, purpose="居民名单补生成")
if isinstance(refill_data, dict) and isinstance(refill_data.get("citizens"), list):
refill_cast = refill_data["citizens"]
elif isinstance(refill_data, list):
refill_cast = refill_data
else:
refill_cast = []
cast.extend(
member for member in refill_cast
if isinstance(member, dict)
and str(member.get("name", "")).strip()
and str(member.get("role", "")).strip()
)
cast = cast[:count]
except Exception as e:
logger.warning("居民名单补生成失败:%s", e)
if len(cast) < count:
logger.error("居民生成仍不足 %d/%d 人,将进入带标记的中性降级模式", len(cast), count)
while len(cast) < count:
idx = len(cast)
if self._locale == "zh":
cast.append({
"name": f"待生成居民{idx + 1}",
"role": "未指定",
"age": 30 + (idx % 40),
"personality_hook": "个体化背景尚未生成",
"stance": "暂无明确立场",
"_generation_source": "fallback",
})
else:
cast.append({
"name": f"Pending Citizen {idx + 1}",
"role": "Unspecified",
"age": 30 + (idx % 40),
"personality_hook": "Individual background was not generated",
"stance": "No stated position yet",
"_generation_source": "fallback",
})
return cast[:count]
async def _generate_personas(
self,
@@ -461,10 +508,10 @@ class CitizenGenerator:
f"Resources: {', '.join(blueprint.resources)}\n"
f"Tensions: {'; '.join(blueprint.initial_tensions)}"
)
citizen_ctx = (
f"Name: {member['name']}\n"
f"Role: {member['role']}\n"
f"Age: {member['age']}\n"
citizen_ctx = (
f"Name: {member.get('name', 'Unnamed Citizen')}\n"
f"Role: {member.get('role', 'Unspecified')}\n"
f"Age: {member.get('age', 30)}\n"
f"Personality hook: {member.get('personality_hook', '')}\n"
f"Stance on tensions: {member.get('stance', 'unknown')}"
)
@@ -495,9 +542,13 @@ class CitizenGenerator:
idx = batch_start + i
member = cast[idx]
data = parse_json(response, purpose=f"居民画像生成(批次索引={batch_start + i})")
personality_data = data.get("personality", {})
personality = Personality(
if not isinstance(data, dict):
data = {}
personality_data = data.get("personality", {})
if not isinstance(personality_data, dict):
personality_data = {}
personality = Personality(
honesty=self._clamp(personality_data.get("honesty", 0.5)),
ambition=self._clamp(personality_data.get("ambition", 0.5)),
empathy=self._clamp(personality_data.get("empathy", 0.5)),
@@ -509,19 +560,26 @@ class CitizenGenerator:
novelty_seeking=self._clamp(personality_data.get("novelty_seeking", 0.5)),
)
resources = data.get("resources", {})
for res in blueprint.resources:
resources = data.get("resources", {})
if not isinstance(resources, dict):
resources = {}
for res in blueprint.resources:
if res not in resources:
resources[res] = 50
raw_style = member.get("communication_style", "").lower().strip()
raw_style = str(member.get("communication_style", "") or "").lower().strip()
comm_style = raw_style if raw_style in COMMUNICATION_STYLES else random.choice(list(COMMUNICATION_STYLES.keys()))
agent = AgentPersona(
id=idx,
name=member["name"],
role=member["role"],
age=member.get("age", 30),
try:
age = max(18, min(75, int(member.get("age", 30))))
except (TypeError, ValueError):
age = 30
agent = AgentPersona(
id=idx,
name=str(member.get("name", "Unnamed Citizen")),
role=str(member.get("role", "Unspecified")),
age=age,
personality=personality,
background=data.get("background", member.get("personality_hook", "")),
goals=data.get("goals", ["Survive", "Find purpose"]),
@@ -533,8 +591,9 @@ class CitizenGenerator:
location="community",
faction=None,
emotional_state="curious",
communication_style=comm_style,
)
communication_style=comm_style,
generation_source=str(member.get("_generation_source", "llm")),
)
agents.append(agent)
if on_citizen:
+44 -15
View File
@@ -318,7 +318,8 @@ class SimulationEngine:
self._last_narrative: dict[str, str] = {}
self._enriched_agents: dict[str, list[AgentPersona]] = {}
self._enrichment_ready: dict[str, asyncio.Event] = {}
self._last_active_round: dict[str, dict[int, int]] = {}
self._last_active_round: dict[str, dict[int, int]] = {}
self._selection_snapshot: dict[str, dict] = {}
async def _wait_if_paused(self, simulation_id: str) -> bool:
"""Block while paused. Returns False if the simulation was stopped."""
@@ -384,6 +385,7 @@ class SimulationEngine:
HEARTBEAT_EVERY = 25
t_engine_start = time.monotonic()
tension_event_count = 0
tension_event_records: list[dict] = []
logger.info(
"模拟 %s:引擎开始运行,总轮数=%d,居民=%d,起始轮次=%d",
simulation_id, total_rounds, len(agents), start_round,
@@ -602,14 +604,15 @@ class SimulationEngine:
if sample_agent:
cross_rep_str = self._detect_cross_agent_repetition(sample_agent, agents)
sim_log.log_round(
round_num=round_num,
day=world_state.day,
time_of_day=TIMES_OF_DAY[world_state.round_in_day % 3],
agents=agents,
entries=resolved_entries,
metrics=world_state.metrics,
coherence_scores=coh,
sim_log.log_round(
round_num=round_num,
day=world_state.day,
time_of_day=TIMES_OF_DAY[world_state.round_in_day % 3],
agents=agents,
entries=resolved_entries,
metrics=world_state.metrics,
selection=self._selection_snapshot.get(simulation_id),
coherence_scores=coh,
personality_mandates=mandates_map,
cross_agent_repeated=cross_rep_str,
)
@@ -619,6 +622,11 @@ class SimulationEngine:
significance = self._count_significant(resolved_entries)
if tension_event:
tension_event_count += 1
tension_event_records.append({
"round": round_num,
"day": world_state.day,
"description": tension_event,
})
# Heartbeat + milestone. Only every HEARTBEAT_EVERY rounds so
# this stays out of the hot path. Round 1 also gets a line so
@@ -712,6 +720,11 @@ class SimulationEngine:
logger.info("模拟 %s:已自动发布到画廊", simulation_id)
except Exception as e:
logger.warning("模拟 %s:自动发布到画廊失败:%s", simulation_id, e)
try:
await self.store.set_meta(simulation_id, "tension_event_count", tension_event_count)
await self.store.set_meta(simulation_id, "tension_event_records", tension_event_records)
except Exception as e:
logger.warning("模拟 %s:保存张力事件统计失败:%s", simulation_id, e)
asyncio.create_task(self._generate_report_background(simulation_id))
logger.info(
"模拟 %s:引擎运行完成,轮数=%d,天数=%d,行动数=%d,张力事件=%d,耗时=%.1f秒",
@@ -757,6 +770,7 @@ class SimulationEngine:
self._enriched_agents.pop(simulation_id, None)
self._enrichment_ready.pop(simulation_id, None)
self._last_active_round.pop(simulation_id, None)
self._selection_snapshot.pop(simulation_id, None)
self.tension.cleanup(simulation_id)
self.gossip.cleanup(simulation_id)
if self.forecast:
@@ -771,9 +785,15 @@ class SimulationEngine:
simulation_id: str = "",
) -> list[AgentPersona]:
tc = world_state.blueprint.time_config
base_min = tc.active_agents_per_round_min
base_max = tc.active_agents_per_round_max
base_count = random.randint(base_min, base_max)
base_min = max(0, int(tc.active_agents_per_round_min))
base_max = max(0, int(tc.active_agents_per_round_max))
if base_min > base_max:
base_min, base_max = base_max, base_min
# Keep very small populations usable, but make 60% a real cap once
# the population is large enough for sampling to be meaningful.
population_cap = len(agents) if len(agents) < 5 else max(1, int(len(agents) * 0.6))
base_count = random.randint(base_min, base_max) if base_max else 0
base_count = min(base_count, population_cap)
bridge_node_ids = set(TensionEngine.find_bridge_nodes(agents))
@@ -816,8 +836,10 @@ class SimulationEngine:
selected: list[AgentPersona] = []
pool = list(agents)
max_active = max(base_count, int(len(agents) * 0.6))
target = min(base_count + (3 if event_occurred else 0), max_active)
# Keep the event bonus from exceeding a real population cap. The old
# max(base_count, 60%) expression was not a cap when base_count itself
# exceeded 60% of the population.
target = min(base_count + (3 if event_occurred else 0), population_cap)
for _ in range(target):
if not pool:
@@ -830,7 +852,14 @@ class SimulationEngine:
selected.append(pick)
pool.remove(pick)
return selected
self._selection_snapshot[simulation_id] = {
"base_count": base_count,
"target": target,
"selected_count": len(selected),
"selected_agent_ids": [agent.id for agent in selected],
"event_occurred": event_occurred,
}
return selected
async def _batch_decisions(
self,
+15
View File
@@ -139,6 +139,21 @@ class LLMClient:
)
return content.strip()
except Exception as e:
# Provider validation/policy responses are deterministic for
# the same payload. Retrying them only adds latency and can
# multiply the provider-side rejection count. Keep retries
# for transient network, timeout, and 5xx failures.
status_code = getattr(e, "status_code", None)
if status_code in {400, 401, 403, 404, 422}:
await self._record_purpose(purpose, time.monotonic() - t0, ok=False)
logger.error(
"LLM 请求不可重试(用途=%s,HTTP %s,耗时=%.2f秒):%s",
purpose or "-",
status_code,
time.monotonic() - t0,
e,
)
raise
if attempt == retries - 1:
await self._record_purpose(purpose, time.monotonic() - t0, ok=False)
logger.error(
+250 -61
View File
@@ -12,7 +12,9 @@ from app.constants import TIMES_OF_DAY
if TYPE_CHECKING:
from app.db.store import SimulationStore
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
REPORT_SCHEMA_VERSION = 2
NARRATE_SYSTEM = """You are a dry, observational narrator documenting life in {world_name}.
Rules of this world: {rules}
@@ -51,7 +53,11 @@ Given structured metric stats, epoch summaries, and agent data, produce:
1. Per-metric explanation: One sentence explaining WHY each metric reached its final value. Reference specific events or behaviors.
2. Executive verdict: Should the user proceed (go), proceed with caution (caution), or fundamentally rethink (rethink)?
3. Confidence assessment: Based on simulation length and agent count.
4. 3-5 emergent insights: Things that were NOT predictable from the simulation setup alone. Each must reference specific agents, days, or action patterns as evidence.
4. 3-5 emergent insights: Things that were NOT predictable from the simulation setup alone. Each must reference specific agents, days, or action patterns as evidence.
An insight without at least one concrete agent, day, action, or metric value is
not an insight. Omit it rather than guessing. Do not present a causal claim
when the supplied data only shows correlation.
Return JSON:
{{
@@ -102,7 +108,7 @@ Return JSON:
]
}}"""
ACTION_SYNTHESIS_SYSTEM = """You are a strategy consultant synthesizing findings from a simulation of {world_name}.
ACTION_SYNTHESIS_SYSTEM = """You are a strategy consultant synthesizing findings from a simulation of {world_name}.
Rules: {rules}
Given the executive brief, metric interpretations, insights, and segment analysis, produce:
@@ -124,18 +130,77 @@ Return JSON:
"risks": [
{{"risk": "Description", "severity": "high" | "medium" | "low"}}
],
"second_order_effects": ["Effect description"]
}}"""
class Narrator:
"second_order_effects": ["Effect description"]
}}"""
SYSTEMATIC_SUMMARY_SYSTEM = """You are the senior historian and audit writer for {world_name}.
Rules of this world: {rules}
Write a traceable summary of the completed simulation. The supplied material
is the complete evidence available; do not invent events, motivations, people,
or metric changes that are not present in it.
Return JSON with exactly these fields:
{{
"summary": "A concise 2-4 paragraph account of the main story",
"chapters": [
{{"title": "time period", "body": "what happened, why it mattered", "evidence_days": [1, 2]}}
],
"key_figures": [
{{"name": "existing agent name", "arc": "choice and consequence", "evidence_days": [1, 2]}}
],
"contradictions": [
{{"surface": "observable reading", "underlying": "supported interpretation", "evidence": "specific metric/action/day"}}
]
}}
Requirements:
- Every claim must cite a supplied metric value, existing agent name, action, or day.
- Distinguish observed facts from interpretation.
- If the data does not answer a question, say that it is not covered.
- Keep the prose concrete and avoid atmospheric filler."""
class Narrator:
def __init__(self, llm: LLMClient, forecast=None, locale: str = DEFAULT_LOCALE):
self.llm = llm
self._forecast = forecast
self._locale = locale
def set_locale(self, locale: str) -> None:
self._locale = locale
def set_locale(self, locale: str) -> None:
self._locale = locale
async def _generate_report_json(
self,
*,
system: str,
user: str,
max_tokens: int,
purpose: str,
degraded_sections: list[str],
) -> dict:
"""Run an optional report pass without making it a single point of failure.
A report is a post-processing artifact. Provider policy errors (for
example MiniMax 422 content screening) or malformed JSON should
degrade one section, not discard an otherwise complete simulation.
"""
try:
response = await self.llm.generate(
system=system,
user=user,
json_mode=True,
max_tokens=max_tokens,
purpose=purpose,
)
parsed = parse_json(response, purpose=purpose)
if isinstance(parsed, dict) and parsed:
return parsed
except Exception as exc:
logger.warning("报告子分析失败,将使用确定性结果(用途=%s):%s", purpose, exc)
if purpose not in degraded_sections:
degraded_sections.append(purpose)
return {}
async def narrate_round(
self,
@@ -273,13 +338,28 @@ class Narrator:
# event loop — we don't need an extra gather limit. Running 12 epochs
# serially could take 4+ minutes for a 365-day sim; concurrent runs
# collapse this to ~30s (the wall time of the slowest call).
epoch_summaries = await asyncio.gather(*(
self.summarize_epoch(
blueprint.name, blueprint.rules, ep["narrs"],
ep["m_start"], ep["m_end"], ep["start_day"], ep["end_day"],
)
for ep in pending_epochs
))
epoch_results = await asyncio.gather(*(
self.summarize_epoch(
blueprint.name, blueprint.rules, ep["narrs"],
ep["m_start"], ep["m_end"], ep["start_day"], ep["end_day"],
)
for ep in pending_epochs
), return_exceptions=True)
degraded_sections: list[str] = []
epoch_summaries = []
for ep, result in zip(pending_epochs, epoch_results):
if isinstance(result, Exception):
section = f"报告时期总结 Day {ep['start_day']}-{ep['end_day']}"
degraded_sections.append(section)
logger.warning("%s失败,将使用指标摘要:%s", section, result)
result = (
f"Days {ep['start_day']}-{ep['end_day']}: "
"LLM summary unavailable; the period is represented by the recorded metrics. "
f"Stability {ep['m_start'].stability:.2f}->{ep['m_end'].stability:.2f}, "
f"trust {ep['m_start'].trust:.2f}->{ep['m_end'].trust:.2f}, "
f"conflict {ep['m_start'].conflict:.2f}->{ep['m_end'].conflict:.2f}."
)
epoch_summaries.append(result)
epochs = [
{"days": f"{ep['start_day']}-{ep['end_day']}", "summary": summary}
for ep, summary in zip(pending_epochs, epoch_summaries)
@@ -291,10 +371,20 @@ class Narrator:
from app.services.report_analyzer import ReportAnalyzer
metric_stats = ReportAnalyzer.compute_metric_stats(metrics_history, is_market)
agent_stats = ReportAnalyzer.compute_agent_stats(agents, actions)
segment_stats = ReportAnalyzer.compute_segment_stats(agent_stats)
inflection_points = ReportAnalyzer.detect_inflection_points(metrics_history)
confidence = ReportAnalyzer.compute_confidence(metrics_history, len(agents))
agent_stats = ReportAnalyzer.compute_agent_stats(agents, actions)
segment_stats = ReportAnalyzer.compute_segment_stats(agent_stats)
inflection_points = ReportAnalyzer.detect_inflection_points(metrics_history)
confidence = ReportAnalyzer.compute_confidence(metrics_history, len(agents))
tension_event_count = int(await store.get_meta(simulation_id, "tension_event_count") or 0)
tension_event_records = await store.get_meta(simulation_id, "tension_event_records") or []
fallback_count = int(await store.get_meta(simulation_id, "citizen_generation_fallback_count") or 0)
data_gaps = ReportAnalyzer.detect_data_gaps(
metrics_history,
actions,
is_market,
tension_event_count=tension_event_count,
fallback_count=fallback_count,
)
# --- Pass 1b: Darts-based analysis ---
forecast_analysis = {}
@@ -363,10 +453,17 @@ class Narrator:
+ (f"\n\nCausal analysis:\n{causal_summary}" if causal_summary else "")
)
response_p2 = await self.llm.generate(system=system_p2, user=user_p2, json_mode=True, max_tokens=2000, purpose="报告指标分析")
pass2 = parse_json(response_p2, purpose="报告指标分析")
explanations = pass2.get("metric_explanations", {})
pass2 = await self._generate_report_json(
system=system_p2,
user=user_p2,
max_tokens=2000,
purpose="报告指标分析",
degraded_sections=degraded_sections,
)
explanations = pass2.get("metric_explanations", {})
if not isinstance(explanations, dict):
explanations = {}
scorecard = []
for s in metric_stats:
scorecard.append({
@@ -397,26 +494,53 @@ class Narrator:
) + f"\n\n{tail}"
user_p3 = f"Segment data:\n{segment_input}"
response_p3 = await self.llm.generate(system=system_p3, user=user_p3, json_mode=True, max_tokens=1500, purpose="报告分群分析")
pass3 = parse_json(response_p3, purpose="报告分群分析")
llm_segments = {s["name"]: s for s in pass3.get("segments", [])}
segments_final = []
for seg in segment_stats:
llm_seg = llm_segments.get(seg["name"], {})
segments_final.append({
"name": seg["name"],
"adoption_pct": seg["adoption_pct"],
"funnel": seg["funnel"],
"reaction": llm_seg.get("reaction", ""),
"top_objection": llm_seg.get("top_objection", ""),
"champion": llm_seg.get("champion", {"name": "", "why": ""}),
"representative_quote": llm_seg.get("representative_quote", ""),
})
# --- Pass 4: Action synthesis ---
executive_brief = pass2.get("executive_brief", {})
insights = pass2.get("insights", [])
pass3 = await self._generate_report_json(
system=system_p3,
user=user_p3,
max_tokens=1500,
purpose="报告分群分析",
degraded_sections=degraded_sections,
)
llm_segments = {
s["name"]: s for s in pass3.get("segments", [])
if isinstance(s, dict) and s.get("name")
}
segments_final = []
for seg in segment_stats:
llm_seg = llm_segments.get(seg["name"], {})
if llm_seg:
reaction = llm_seg.get("reaction", "")
top_objection = llm_seg.get("top_objection", "")
champion = llm_seg.get("champion", {"name": "", "why": ""})
representative_quote = llm_seg.get("representative_quote", "")
else:
reaction = (
f"结构化数据记录该分群最终采用率为 {seg['adoption_pct']}%,"
f"倡导者 {seg['advocate_count']} 人;未生成可靠的语义层解释。"
)
top_objection = "未能从结构化数据可靠识别主要异议。"
champion = {"name": "", "why": "未生成分群语义分析。"}
representative_quote = ""
segments_final.append({
"name": seg["name"],
"adoption_pct": seg["adoption_pct"],
"funnel": seg["funnel"],
"reaction": reaction,
"top_objection": top_objection,
"champion": champion,
"representative_quote": representative_quote,
})
# --- Pass 4: Action synthesis ---
executive_brief_raw = pass2.get("executive_brief", {})
executive_brief = executive_brief_raw if isinstance(executive_brief_raw, dict) else {}
insights = [
insight for insight in pass2.get("insights", [])
if isinstance(insight, dict)
and isinstance(insight.get("evidence"), dict)
and any(insight["evidence"].get(key) for key in ("agents", "days", "actions", "metrics"))
]
synthesis_context = (
f"Executive verdict: {executive_brief.get('verdict', 'unknown')} — {executive_brief.get('headline', '')}\n\n"
@@ -437,10 +561,68 @@ class Narrator:
rules="; ".join(blueprint.rules),
) + f"\n\n{tail}"
response_p4 = await self.llm.generate(system=system_p4, user=synthesis_context, json_mode=True, max_tokens=1500, purpose="报告行动建议")
pass4 = parse_json(response_p4, purpose="报告行动建议")
# --- Assemble final report ---
pass4 = await self._generate_report_json(
system=system_p4,
user=synthesis_context,
max_tokens=1500,
purpose="报告行动建议",
degraded_sections=degraded_sections,
)
# --- Pass 5: Systematic, evidence-linked summary ---
top_agents = sorted(agent_stats, key=lambda item: item["total_actions"], reverse=True)[:8]
systematic_context = (
f"Timeline epochs:\n{epoch_text}\n\n"
f"Metric scorecard:\n{metrics_summary}\n\n"
f"Inflection points:\n{inflection_summary or '(none)'}\n\n"
f"Top agents:\n" + "\n".join(
f"- {a['name']} ({a['role']}): {a['total_actions']} actions; "
f"speeches={a['speeches'][-2:]}; thoughts={a['thoughts'][-2:]}"
for a in top_agents
) + "\n\n"
f"Segments:\n" + "\n".join(
f"- {s['name']}: {s['adoption_pct']}% adoption; reaction={s.get('reaction', '')}; "
f"objection={s.get('top_objection', '')}"
for s in segments_final
) + "\n\n"
f"Insights:\n" + "\n".join(
f"- {i.get('title', '')}: {i.get('description', '')}; evidence={i.get('evidence', {})}"
for i in insights
) + "\n\n"
f"Risks and actions:\n{pass4.get('risks', [])}\n{pass4.get('action_items', [])}\n\n"
f"Tension events:\n{tension_event_records}\n\n"
f"Deterministic data gaps:\n{data_gaps}\n\n"
f"Simulation metadata: days={total_days}, agents={len(agents)}, actions={len(actions)}, "
f"tension_events={tension_event_count}, fallback_citizens={fallback_count}"
)
head, tail = _language_directive(self._locale)
system_p5 = head + "\n\n" + SYSTEMATIC_SUMMARY_SYSTEM.format(
world_name=blueprint.name,
rules="; ".join(blueprint.rules),
) + f"\n\n{tail}"
systematic_summary = {
"summary": executive_brief.get("summary", ""),
"chapters": [],
"key_figures": [],
"contradictions": [],
}
try:
response_p5 = await self.llm.generate(
system=system_p5,
user=systematic_context,
json_mode=True,
max_tokens=2200,
purpose="报告系统性总结",
)
pass5_raw = parse_json(response_p5, purpose="报告系统性总结")
if isinstance(pass5_raw, dict):
for key in ("summary", "chapters", "key_figures", "contradictions"):
if key in pass5_raw:
systematic_summary[key] = pass5_raw[key]
except Exception as e:
logger.warning("报告系统性总结生成失败,将使用已有摘要:%s", e)
# --- Assemble final report ---
key_moments = []
for p in inflection_points[:10]:
key_moments.append({
@@ -449,28 +631,35 @@ class Narrator:
"description": f"{p['label']} shifted from {p['value_before']:.2f} to {p['value_after']:.2f}",
})
report = {
"executive_brief": executive_brief,
report = {
"report_schema_version": REPORT_SCHEMA_VERSION,
"executive_brief": executive_brief,
"scorecard": scorecard,
"insights": insights,
"segments": segments_final,
"action_items": pass4.get("action_items", []),
"risks": pass4.get("risks", []),
"second_order_effects": pass4.get("second_order_effects", []),
"narrative": {
"summary": executive_brief.get("summary", ""),
"action_items": pass4.get("action_items", []),
"risks": pass4.get("risks", []),
"second_order_effects": pass4.get("second_order_effects", []),
"systematic_summary": systematic_summary,
"data_gaps": data_gaps,
"tension_events": tension_event_records,
"narrative": {
"summary": systematic_summary.get("summary") or executive_brief.get("summary", ""),
"key_moments": key_moments,
"surprise": next((i["description"] for i in insights if i.get("type") == "surprise"), ""),
"factions": [],
},
"metrics_history": metrics_history,
"meta": {
"meta": {
"agent_count": len(agents),
"total_days": total_days,
"total_actions": len(actions),
"rules": blueprint.rules,
"world_name": blueprint.name,
},
"total_actions": len(actions),
"fallback_citizens": fallback_count,
"rules": blueprint.rules,
"world_name": blueprint.name,
"locale": self._locale,
"degraded_sections": degraded_sections,
},
"forecasts": forecast_analysis,
"causal_map": causal_map,
"counterfactuals": counterfactual_data,
+97 -7
View File
@@ -17,12 +17,16 @@ METRIC_LABELS = {
"purchase_intent": "Purchase Intent",
"word_of_mouth": "Word of Mouth",
"churn_risk": "Churn Risk",
"adoption_rate": "Adoption Rate",
}
"adoption_rate": "Adoption Rate",
"information_spread": "Information Spread",
"echo_chamber_index": "Echo Chamber Index",
"rumor_distortion": "Rumor Distortion",
}
INVERSE_METRICS = {"conflict", "churn_risk"}
MARKET_METRICS = {"brand_sentiment", "purchase_intent", "word_of_mouth", "churn_risk", "adoption_rate"}
MARKET_METRICS = {"brand_sentiment", "purchase_intent", "word_of_mouth", "churn_risk", "adoption_rate"}
INFORMATION_METRICS = {"information_spread", "echo_chamber_index", "rumor_distortion"}
SOCIAL_METRICS = {"stability", "prosperity", "trust", "freedom", "conflict"}
@@ -56,9 +60,11 @@ class ReportAnalyzer:
if not metrics_history:
return []
active_keys = set(SOCIAL_METRICS)
if is_market:
active_keys |= MARKET_METRICS
# Metrics are persisted as a complete WorldMetrics snapshot. Keep the
# report scorecard complete as well; hiding market or information
# fields based on the scenario made readers mistake omitted fields for
# missing data.
active_keys = set(METRIC_LABELS)
results = []
for key in active_keys:
@@ -86,7 +92,91 @@ class ReportAnalyzer:
"inverse": inverse,
})
return sorted(results, key=lambda x: x["metric"])
return sorted(results, key=lambda x: x["metric"])
@staticmethod
def detect_data_gaps(
metrics_history: list[dict],
actions: list[ActionEntry],
is_market: bool,
tension_event_count: int = 0,
fallback_count: int = 0,
) -> list[dict]:
"""Find deterministic limitations worth surfacing in a report.
These are observations about this run, not claims that the simulator
is universally incapable of producing the signal. LLM prose can then
explain the finding without inventing a missing data source.
"""
gaps: list[dict] = []
if not metrics_history:
return [{
"key": "metrics_history",
"gap": "No metric history was recorded.",
"why_it_matters": "The report cannot establish trends or inflection points.",
"suggested_fix": "Check persistence and rerun the simulation.",
}]
action_types = {a.action_type.value for a in actions}
values_by_key = {
key: [float(row.get(key, 0.5)) for row in metrics_history]
for key in METRIC_LABELS
}
def add(key: str, gap: str, why: str, fix: str) -> None:
gaps.append({"key": key, "gap": gap, "why_it_matters": why, "suggested_fix": fix})
if fallback_count > 0:
add(
"citizen_generation",
f"{fallback_count} citizen(s) entered through degraded generation.",
"Their initial identity fields may be less representative than normally generated citizens.",
"Retry cast generation and inspect the affected residents before treating their behavior as evidence.",
)
if is_market and all(abs(v) < 1e-9 for v in values_by_key["adoption_rate"]):
add(
"adoption_rate",
"Adoption rate stayed at 0.00 for the whole run.",
"The report cannot infer an adoption trajectory from this simulation.",
"Increase the horizon or verify that purchase/proposal actions can occur.",
)
if "DEFECT" not in action_types and "ABANDON" not in action_types:
add(
"defection_or_abandonment",
"No DEFECT or ABANDON action occurred.",
"Churn, defection, and trust-loss conclusions are weakly evidenced.",
"Run a longer or more disruptive scenario, or report this as a limitation.",
)
if "PROPOSE_RULE" not in action_types and not is_market:
add(
"rule_evolution",
"No PROPOSE_RULE action occurred.",
"Institutional or rule-evolution claims are not supported by observed actions.",
"Include a rule-change pressure or extend the simulation horizon.",
)
if tension_event_count > 0:
add(
"tension_events",
f"{tension_event_count} tension event(s) occurred but are not represented as a first-class report section.",
"Readers may miss event-driven causes of later metric changes.",
"Include event descriptions and affected days in the report timeline.",
)
flat_keys = ["trust", "conflict", "information_spread"]
if is_market:
flat_keys.append("purchase_intent")
for key in flat_keys:
values = values_by_key[key]
if len(values) >= 3 and max(values) - min(values) < 0.005:
add(
key,
f"{METRIC_LABELS[key]} stayed effectively flat ({values[0]:.2f} → {values[-1]:.2f}).",
"A flat series does not show whether the mechanism is stable or simply inactive at this horizon.",
"Inspect the triggering actions and compare with a longer or counterfactual run.",
)
return gaps
@staticmethod
def compute_agent_stats(agents: list[AgentPersona], actions: list[ActionEntry]) -> list[dict]:
+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,
+17 -17
View File
@@ -65,9 +65,10 @@ class SimulationLogger:
day: int,
time_of_day: str,
agents: list[AgentPersona],
entries: list[ActionEntry],
metrics: WorldMetrics,
coherence_scores: dict[int, Any] | None = None,
entries: list[ActionEntry],
metrics: WorldMetrics,
selection: dict[str, Any] | None = None,
coherence_scores: dict[int, Any] | None = None,
personality_mandates: dict[int, str] | None = None,
cross_agent_repeated: str = "",
):
@@ -144,20 +145,19 @@ class SimulationLogger:
"day": day,
"time_of_day": time_of_day,
"agents": agent_records,
"summary": {
"abandon_count": abandon_count,
"defect_count": defect_count,
"sentiment_distribution": sentiment_dist,
"cross_agent_repeated_phrases": cross_agent_repeated[:300],
},
"metrics": {
"brand_sentiment": round(metrics.brand_sentiment, 3),
"purchase_intent": round(metrics.purchase_intent, 3),
"churn_risk": round(metrics.churn_risk, 3),
"adoption_rate": round(metrics.adoption_rate, 3),
"stability": round(metrics.stability, 3),
"trust": round(metrics.trust, 3),
},
"summary": {
"abandon_count": abandon_count,
"defect_count": defect_count,
"sentiment_distribution": sentiment_dist,
"cross_agent_repeated_phrases": cross_agent_repeated[:300],
},
"selection": selection or {},
# Keep JSONL observability aligned with the complete metrics
# snapshot stored in the database.
"metrics": {
key: round(float(getattr(metrics, key)), 3)
for key in WorldMetrics.model_fields
},
}
self._write(record)