From a428214c5346f9ecba648f9b90ee5aada555be40 Mon Sep 17 00:00:00 2001 From: Jack Date: Mon, 28 Sep 2026 15:43:23 +0800 Subject: [PATCH] fix simulation reporting and research resilience --- .env.example | 12 +- backend/app/api/gallery.py | 11 +- backend/app/api/simulate.py | 51 ++- backend/app/config.py | 12 +- backend/app/main.py | 20 +- backend/app/models/agent.py | 7 +- backend/app/models/world.py | 20 +- backend/app/services/citizen_generator.py | 159 ++++++--- backend/app/services/engine.py | 59 +++- backend/app/services/llm.py | 15 + backend/app/services/narrator.py | 311 ++++++++++++++---- backend/app/services/report_analyzer.py | 104 +++++- backend/app/services/research.py | 121 +++++-- backend/app/services/sim_logger.py | 34 +- backend/pyproject.toml | 5 +- .../问题记录-2026-09-27-硬编码与搜索替换.md | 5 - .../问题记录-2026-09-28-居民生成兜底硬编码.md | 150 +++++++++ .../问题记录-2026-09-28-报告缺失系统性总结.md | 270 +++++++++++++++ docs/问题记录/问题记录-汇总-2026-09-28.md | 231 +++++++++++++ .../问题记录-硬编码与千人模拟.md | 0 frontend/src/components/AgentDetailPanel.vue | 8 +- frontend/src/components/ScorecardGrid.vue | 11 +- frontend/src/locales/en.json | 22 +- frontend/src/locales/zh.json | 22 +- frontend/src/views/ReportView.vue | 72 +++- 25 files changed, 1488 insertions(+), 244 deletions(-) rename docs/{ => 问题记录}/问题记录-2026-09-27-硬编码与搜索替换.md (94%) create mode 100644 docs/问题记录/问题记录-2026-09-28-居民生成兜底硬编码.md create mode 100644 docs/问题记录/问题记录-2026-09-28-报告缺失系统性总结.md create mode 100644 docs/问题记录/问题记录-汇总-2026-09-28.md rename docs/{ => 问题记录}/问题记录-硬编码与千人模拟.md (100%) diff --git a/.env.example b/.env.example index 829debb..e2a5453 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,9 @@ -LLM_API_KEY=your-api-key-here -LLM_BASE_URL=https://api.openai.com/v1 -LLM_MODEL=gpt-4o-mini +LLM_API_KEY=your-api-key-here +LLM_BASE_URL=https://api.openai.com/v1 +LLM_MODEL=gpt-4o-mini +SEARXNG_BASE_URL=https://searxng.erhe.top +SEARXNG_TOKEN=your-searxng-token-here +SEARXNG_TIMEOUT=15 +SEARXNG_LANGUAGE=auto +SEARXNG_SAFESEARCH=1 +SEARXNG_CACHE_TTL_SECONDS=300 diff --git a/backend/app/api/gallery.py b/backend/app/api/gallery.py index 93dff5f..114429a 100644 --- a/backend/app/api/gallery.py +++ b/backend/app/api/gallery.py @@ -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: diff --git a/backend/app/api/simulate.py b/backend/app/api/simulate.py index 34a6c1b..d9e5569 100644 --- a/backend/app/api/simulate.py +++ b/backend/app/api/simulate.py @@ -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) diff --git a/backend/app/config.py b/backend/app/config.py index 80c6fc1..af94ee3 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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")], diff --git a/backend/app/main.py b/backend/app/main.py index e1a0281..bdd77d2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models/agent.py b/backend/app/models/agent.py index 2221407..e6fd862 100644 --- a/backend/app/models/agent.py +++ b/backend/app/models/agent.py @@ -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" diff --git a/backend/app/models/world.py b/backend/app/models/world.py index 8bc6ea0..c867b29 100644 --- a/backend/app/models/world.py +++ b/backend/app/models/world.py @@ -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): diff --git a/backend/app/services/citizen_generator.py b/backend/app/services/citizen_generator.py index b6f1175..62bc6ee 100644 --- a/backend/app/services/citizen_generator.py +++ b/backend/app/services/citizen_generator.py @@ -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: diff --git a/backend/app/services/engine.py b/backend/app/services/engine.py index d5e0525..315959b 100644 --- a/backend/app/services/engine.py +++ b/backend/app/services/engine.py @@ -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, diff --git a/backend/app/services/llm.py b/backend/app/services/llm.py index 445ee12..daaa97d 100644 --- a/backend/app/services/llm.py +++ b/backend/app/services/llm.py @@ -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( diff --git a/backend/app/services/narrator.py b/backend/app/services/narrator.py index ba4ffc9..5cb5185 100644 --- a/backend/app/services/narrator.py +++ b/backend/app/services/narrator.py @@ -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, diff --git a/backend/app/services/report_analyzer.py b/backend/app/services/report_analyzer.py index 4ea32ab..f9c56cf 100644 --- a/backend/app/services/report_analyzer.py +++ b/backend/app/services/report_analyzer.py @@ -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]: diff --git a/backend/app/services/research.py b/backend/app/services/research.py index 4c60cad..5ce102c 100644 --- a/backend/app/services/research.py +++ b/backend/app/services/research.py @@ -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, diff --git a/backend/app/services/sim_logger.py b/backend/app/services/sim_logger.py index f600d24..4082653 100644 --- a/backend/app/services/sim_logger.py +++ b/backend/app/services/sim_logger.py @@ -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) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 6f1f0e9..efb6642 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -14,9 +14,8 @@ dependencies = [ "python-dotenv>=1.0.0", "sse-starlette>=2.0.0", "eval_type_backport>=0.2.0", - "duckduckgo-search>=6.0.0", - "darts>=0.42.0", -] + "darts>=0.42.0", +] [project.optional-dependencies] test = ["pytest>=7.0", "pytest-asyncio>=0.21"] diff --git a/docs/问题记录-2026-09-27-硬编码与搜索替换.md b/docs/问题记录/问题记录-2026-09-27-硬编码与搜索替换.md similarity index 94% rename from docs/问题记录-2026-09-27-硬编码与搜索替换.md rename to docs/问题记录/问题记录-2026-09-27-硬编码与搜索替换.md index 8c340ca..3369dfd 100644 --- a/docs/问题记录-2026-09-27-硬编码与搜索替换.md +++ b/docs/问题记录/问题记录-2026-09-27-硬编码与搜索替换.md @@ -107,8 +107,3 @@ with DDGS() as ddgs: ## 五、与上一份记录的关系 这是第二份问题记录(第一份是 [问题记录-硬编码与千人模拟.md](./问题记录-硬编码与千人模拟.md))。 - -两者的区别: - -- **第一份** 只聚焦硬编码 / 千人模拟的业务问题(问题一),是完整提问人视角的论述。 -- **这份** 把搜索替换作为独立问题追加进来,并标注"今天已修 / 明天待办"的拆分。 \ No newline at end of file diff --git a/docs/问题记录/问题记录-2026-09-28-居民生成兜底硬编码.md b/docs/问题记录/问题记录-2026-09-28-居民生成兜底硬编码.md new file mode 100644 index 0000000..fa8821f --- /dev/null +++ b/docs/问题记录/问题记录-2026-09-28-居民生成兜底硬编码.md @@ -0,0 +1,150 @@ +# 问题记录:2026-09-28 — 居民生成兜底分支的硬编码污染 + +> 状态:仅记录问题,不附解决方案 +> 记录人:项目相关方 +> 与上一份记录的关系:第三份问题记录(之前两份:[硬编码与千人模拟](./问题记录-硬编码与千人模拟.md)、[2026-09-27 硬编码与搜索替换](./问题记录-2026-09-27-硬编码与搜索替换.md)) + +--- + +## 一、背景 + +在排查居民命名逻辑时发现,[`backend/app/services/citizen_generator.py`](../backend/app/services/citizen_generator.py) 的 `_generate_cast` 末尾有一段"L1 数量兜底"分支:当 LLM 返回的居民数少于 `count` 时,会用一份**硬编码模板**把剩余名额补齐。 + +这条分支只在 LLM 翻车时才触发,正常路径下不会执行。但它一旦触发,会**完整流过后续所有生成阶段**,并且会污染最终模拟。 + +--- + +## 二、现象 + +在 [`citizen_generator.py` L422–L441](../backend/app/services/citizen_generator.py#L422-L441): + +```python +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", + }) +``` + +触发条件只有一条:`len(cast) < count`。可能的成因: + +1. LLM 真的只生成了少于 `count` 个(截断 / token 超限) +2. LLM 返回的不是 list(比如返回了 dict 包 citizens 字段但缺失),被外层容错吞掉 +3. JSON 解析失败后 `parse_json` 返回默认 dict,`cast = []`,整批都走兜底 + +--- + +## 三、我观察到的问题 + +### 问题 1:兜底居民会完整流过后续所有阶段 + +兜底 dict 不是终态。它会进入 `_generate_personas`([L445–L543](../backend/app/services/citizen_generator.py#L445-L543)),在那里: + +- `member["name"]` → 写入 `agent.name`([L522](../backend/app/services/citizen_generator.py#L522)) +- `member["personality_hook"]` → 在 persona 阶段被当作 `background` 兜底([L526](../backend/app/services/citizen_generator.py#L526)) +- `member["stance"]` → 进入 persona prompt,**直接影响 LLM 生成的 `beliefs`** +- `communication_style` 字段缺失 → 走 `random.choice(...)`([L518](../backend/app/services/citizen_generator.py#L518)) +- personality 各维度 → 因为 dict 里没有该字段,走 `_clamp(..., 0.5)`,**全部中位** + +后续 `_generate_life_histories` / `_generate_relationships` 完全依赖这些字段**二次调 LLM 生成**——也就是说兜底居民**不会**因为字段缺失而崩,但会带着一套"模板种子"反向编造一份合理履历。 + +### 问题 2:污染路径有放大效应 + +兜底居民的四个硬编码值不是独立字段,它们会**耦合放大**: + +- `personality_hook = "一个安静过日子的人"` → 提示 LLM 该居民"安静/内向" +- `stance = "对规则漠不关心"` → 提示 LLM 该居民"政治冷漠" +- personality 全 0.5 中位 → LLM 接收"无显著倾向"信号 +- `role = "劳动者"` + `age = 30 + idx%40` → 提示 LLM 该居民是普通劳动者 + +最终产出大概率是一群"性格中位、政治冷漠、安于现状的劳动者",**同质化严重**。 + +### 问题 3:会人为制造"政治冷漠者"集群 + +`stance = "对规则漠不关心"` 这个种子会被 LLM 在 persona 阶段放大成 beliefs: + +- "我对规则不感兴趣" +- "我只想过自己的日子" +- "别来烦我" + +如果兜底补了 5–10 个(比如 LLM 完全没返回),社会派系分布里会**凭空出现一整批"冷漠者"**——而这个派系在原始 world blueprint 里可能根本不是预设张力。**世界张力被人为扭曲了。** + +### 问题 4:兜底数量没有熔断 + +`while len(cast) < count` 是无条件循环。**没有上限保护、没有重试、没有错误抛出**。 + +最坏情况: + +- `count = 25`,LLM 返回 `[]` +- 循环 25 次,全部生成 `居民1号` … `居民25号` +- 全部走 persona → life_history → relationship +- 整个社会的居民全是"居民N号 + 安静 + 漠不关心 + 中位 personality" + +**没有 API 错误、没有日志 ERROR、没有任何给前端的信号**。只剩一条 `logger.warning`,调用方默认看不到。 + +### 问题 5:兜底名会直接暴露给用户 + +`"居民{idx+1}号"` 和 `"Citizen {idx+1}"` 最终会出现在: + +- 前端 AgentSwarmCanvas / AgentDetailPanel 的姓名展示 +- 叙事生成(narrator)的人名引用 +- 关系网中的 target 名称 +- 报告分析里的称呼 + +用户会看到"居民3号"这种程序化名字出现在叙事里——这不只是不美观,而是**暴露了系统的内部失败模式**,对产品可信度是直接打击。 + +### 问题 6:兜底逻辑掩盖了上游问题 + +`_generate_cast` 上游已经有三层容错: + +- `parse_json` 容错(如果失败返回默认 dict) +- dict/list 容错([L390–L395](../backend/app/services/citizen_generator.py#L390-L395)) +- zh 模式重试([L399–L420](../backend/app/services/citizen_generator.py#L399-L420)) + +到了末尾还要兜底,说明上游任何一层吞掉错误都**静默变成兜底数据**,没有 signal 上浮。运维视角下,**没法区分"LLM 健康但数量不够"和"LLM 完全翻车"**——这两种情况走的代码路径不同,但落到调用方是同一个结果。 + +--- + +## 四、待澄清的几个点 + +- LLM 返回 < count 的实际频率是多少?是否有线上数据? +- 触发兜底后,用户实际看到的体验是什么样?) +- 兜底数据应该让前端感知到吗?(比如标灰、标"系统生成"、给个 retry 按钮) +- persona 阶段是否应该检测兜底居民并**主动反向纠正**其"冷漠"立场? +- 兜底策略本身合理吗?还是应该**直接报错让用户重试**? +- 当前 `logger.warning` 之外,是否应该有结构化告警(metrics / trace)? + +--- + +## 五、相关代码位置 + +- 兜底分支:[`backend/app/services/citizen_generator.py` L422–L441](../backend/app/services/citizen_generator.py#L422-L441) +- 上游 LLM 调用:[`backend/app/services/citizen_generator.py` L381–L387](../backend/app/services/citizen_generator.py#L381-L387) +- 中文重试:[`backend/app/services/citizen_generator.py` L399–L420](../backend/app/services/citizen_generator.py#L399-L420) +- persona 阶段对兜底字段的消费:[`backend/app/services/citizen_generator.py` L501–L537](../backend/app/services/citizen_generator.py#L501-L537) +- LLM 客户端 / `parse_json`:[`backend/app/services/llm.py`](../backend/app/services/llm.py) + +--- + +## 六、跟前面两份记录的关系 + +- 第一份([硬编码与千人模拟](./问题记录-硬编码与千人模拟.md))关注的是**运行时**的硬编码抽样参数 +- 第二份([2026-09-27 硬编码与搜索替换](./问题记录-2026-09-27-硬编码与搜索替换.md))关注的是**搜索后端**的硬编码依赖 +- 这份(2026-09-28)关注的是**生成时**的硬编码兜底 + +三份都指向同一个母题:**业务承诺("千人社会涌现" / "千人社会模拟")和实际实现(少数人表态 / LLM 翻车时静默补齐 / 搜索依赖不稳定)之间的不一致**。建议未来评估时一起看。 \ No newline at end of file diff --git a/docs/问题记录/问题记录-2026-09-28-报告缺失系统性总结.md b/docs/问题记录/问题记录-2026-09-28-报告缺失系统性总结.md new file mode 100644 index 0000000..b4cdab8 --- /dev/null +++ b/docs/问题记录/问题记录-2026-09-28-报告缺失系统性总结.md @@ -0,0 +1,270 @@ +# 问题记录:2026-09-28 — 报告缺失"系统性总结",概念无数据来源 + +> 状态:待处理 +> 记录人:项目相关方 +> 触发场景:跑完一次 30 天模拟(`sim_id=8a2c6953-224`,10 人 / 90 轮 / 2238 秒 / 无报错),拿到报告 PDF 后**读不懂**。 +> 与已有记录的关系:第四份问题记录。前三份见 [目录](./) + +--- + +## 一、背景与现象 + +30 天模拟正常跑完(`engine_finished` + `simulation_run_complete` 都在日志里,无 `pipeline_failed`),但生成的报告**读起来"不明不白"**: + +- 概念讲得含糊:"低冲突与高信任掩盖了深层不稳定"——读完不知道具体指哪个字段 +- 怀疑有数据没被记录/没被带出来:"可能是不完整,或者有些该说的东西没说或者没记录" + +本文件拆解这个"看不懂",并给出改造方案。 + +--- + +## 二、先纠正一个误判:数据其实是全的 + +我最初怀疑"日志只存了 6 个指标,导致报告里其他概念找不到数据来源"。**这个判断是错的**,实际排查结果: + +### 2.1 DB 里 14 个指标全存 + +[`WorldMetrics`](../backend/app/models/world.py#L30-L43) 定义了 14 个字段: + +```python +stability, prosperity, trust, freedom, conflict, +brand_sentiment, purchase_intent, word_of_mouth, churn_risk, adoption_rate, +information_spread, echo_chamber_index, rumor_distortion +``` + +[`store.save_metrics`](../backend/app/db/store.py#L231-L237) 用 `metrics.model_dump_json()` **全量序列化**,`get_metrics_history` 原样反序列化返回。 + +**所以 `conflict`(冲突)、`freedom`(自由度)这些"报告里提到但日志里没看到"的指标,DB 里一直有。** + +### 2.2 只有 JSONL 日志是 6 个 + +[`sim_logger.log_round`](../backend/app/services/sim_logger.py#L153-L160) 只写 6 个 market/social 指标: + +```python +"metrics": { + "brand_sentiment": ..., "purchase_intent": ..., "churn_risk": ..., + "adoption_rate": ..., "stability": ..., "trust": ..., +} +``` + +**结论**:`logs/*.jsonl` 缺 `conflict / prosperity / freedom / information_spread / echo_chamber_index / rumor_distortion` 是**日志抽样**行为,不是数据丢失。报告的数据源是 DB,**数据完整**。 + +### 2.3 真正的问题定位 + +既然数据是全的,那"看不懂"只能是**报告生成 pipeline 的加工方式**问题。这才是本记录的核心。 + +--- + +## 三、报告生成 pipeline 现状 + +[`narrator.generate_report`](../backend/app/services/narrator.py#L226-L486) 现在是 5 段式: + +| Pass | 输入 | 输出 | 实现位置 | +|------|------|------|---------| +| 0 | 全部叙事文本 | 每 30 天一个 epoch 摘要(历史学家口吻) | [L240-L288](../backend/app/services/narrator.py#L240-L288) | +| 1 | 指标历史 + 行动 + 居民 | `metric_stats` / `agent_stats` / `segment_stats` / `inflection_points` / `confidence` | [L290-L297](../backend/app/services/narrator.py#L290-L297) | +| 1b | metrics_history | forecast 因果 / 反事实 / 一致性 | [L299-L326](../backend/app/services/narrator.py#L299-L326) | +| 2 | epoch 摘要 + metric_stats + inflection + agents | `executive_brief`(headline+summary)、`metric_explanations`、`insights`(3-5 条) | [L328-L380](../backend/app/services/narrator.py#L328-L380) | +| 3 | segment_stats | 每 segment 的 reaction / objection / champion / 代表语 | [L382-L415](../backend/app/services/narrator.py#L382-L415) | +| 4 | brief + insights + segments + scorecard | `action_items` / `risks` / `second_order_effects` | [L417-L441](../backend/app/services/narrator.py#L417-L441) | + +最终 `report` 字典在 [L452-L478](../backend/app/services/narrator.py#L452-L478) 组装。 + +--- + +## 四、"看不懂"的四个具体成因 + +### 4.1 缺少"时间轴串联",产物是拼盘不是故事 + +Pass 0 产出了按时间顺序的 epoch 摘要,Pass 2/3/4 又产出了洞察、分群、建议,**但没有任何一个 pass 把这些按时间轴串成一篇完整叙述**。 + +最终结构是: + +``` +executive_brief.summary ← 1 段话 +scorecard ← 9 行「指标: 评级 (值) — 解释」 +insights ← 3-5 条 +segments ← N 段 +action_items / risks ← 各 3-5 条 +``` + +读者要自己在脑子里把这些拼回去,所以"每个部分都懂,合起来不知道在讲什么"。 + +### 4.2 `metric_explanations` 是逐指标独立生成,没有交叉引用 + +[`INTERPRET_METRICS_SYSTEM`](../backend/app/services/narrator.py#L46-L79) 要求"每个指标一句解释 WHY"。这导致: + +- 每条解释**孤立**,不说"trust 高是因为 conflict 低"这类**指标间关系** +- 读者看到 trust=100/100 和 conflict=低/xx 两行,**不会自动联想到"这俩矛盾"** +- 于是报告只能靠 Pass 2 的 `insights` 补一句文学化描述(如"掩盖了深层不稳定")——**这句在替数据做结论,但没给出推导过程** + +### 4.3 `insights` 允许"不可追溯",且有 `surprise` 类型 + +`INTERPRET_METRICS_SYSTEM` 要求 insights "Things that were NOT predictable from the simulation setup alone",且 `evidence` 是**可选字段**([L71-L75](../backend/app/services/narrator.py#L71-L75))。 + +后果:LLM 可以写一条听起来很有洞察、但**没有具体 agent / day / action 支撑**的结论。这正是"讲得不明不白"的直接来源——**读者无法判断哪句有据、哪句是猜的**。 + +### 4.4 没有任何地方标注"数据盲区" + +`report` 字典里([L452-L478](../backend/app/services/narrator.py#L452-L478))**没有 data gaps / limitations 字段**。 + +本次 30 天模拟的真实盲区(从 [`logs/8a2c6953-224.jsonl`](../../backend/logs/8a2c6953-224.jsonl) 尾部实测): + +| 盲区 | 实测值 | 说明 | +|------|--------|------| +| `adoption_rate` | 全程 0.0 | 90 轮无人 `PROPOSE_RULE` → "制度演化"叙事无数据支撑 | +| `defect_count` | 每轮 0 | `has_defected` 一次性标记 + 无回退机制(见 [resolver.py:178](../backend/app/services/resolver.py#L178-L179))→ 叛徒事件结构性稀缺 | +| `trust` | 恒定 1.0 | 衰减只由 `DEFECT` 触发([resolver.py:545-L549](../backend/app/services/resolver.py#L545-L549)),DEFECT=0 → trust 永不下滑 | +| `purchase_intent` | 0.458→0.458 | 30 天内几乎不动 → "经济演化"在 30 天尺度不可见 | +| 张力事件 | 15 次 | 但 `tension_event` 内容未进报告任何字段 | + +**报告完全没提这 5 点。** 读者只能靠自己去翻 JSONL 才发现——这就是"该说的东西没说"的实质。 + +--- + +## 五、方案:在现有 pipeline 后加 Pass 5 / Pass 6 + +### 5.1 Pass 5:系统性总结(核心) + +**目的**:把前面所有产物按时间轴 + 矛盾剖析 + 数据盲区串成一篇完整叙述。 + +**输入**(全部来自已有产物,不新增数据源): +- `epoch_summaries`(Pass 0) +- `scorecard` 含 `explanation`(Pass 2) +- `insights`(Pass 2) +- `segments_final`(Pass 3) +- `action_items` / `risks` / `second_order_effects`(Pass 4) +- `inflection_points` +- `metric_stats` 全量 14 字段(DB 已有) +- `meta`(agent_count / total_days / total_actions / rules / world_name) + +**建议 prompt 骨架**: + +``` +你是 {world_name} 的历史学家,刚刚目睹了一场为期 {total_days} 天、 +{agent_count} 人、{total_actions} 次行动的社会实验。 + +# 观测数据(全部) +## 时间轴摘要 +{epoch_text} + +## 指标全表(含 14 项原始值 + 评级 + 逐项解释) +{metrics_full_table} + +## 关键转折点 +{inflection_summary} + +## 居民快照 +{agent_summary} + +## 分群结果 +{segments_text} + +## 已有洞察 / 风险 / 建议 +{insights_text} + +请写一篇系统性总结,结构如下: + +1. 【开场】150 字:一句话点出这场模拟最核心的故事 +2. 【起承转合】按时间轴分段,每段 300-500 字:发生了什么、为什么、谁推动 +3. 【关键人物】3-5 个对结果影响最大的居民,各 200 字:性格 → 选择 → 后果 +4. 【矛盾剖析】300 字:把"表面读数"和"深层动力"分开,指出哪些是规则必然、哪些是涌现 +5. 【数据盲区】200 字:明确列出这份数据**不能**回答的问题 + +硬性要求: +- 每个断言必须能追溯到上面某个具体指标值、某个具名居民、或某个具体 day +- 不许用"也许/可能/或许";要么写"数据显示 X",要么写"数据未覆盖" +- 不要重复 executive_brief 的 verdict,但要呼应它 +- 指标必须带原始数值出现(如"trust 从 0.50 到 1.00"),不许只说"很高" +- 中文人名直接用原文 +``` + +**返回 JSON**: + +```json +{ + "narrative_summary": "完整正文(Markdown,1500-2500 字)", + "chapters": [ + {"title": "...", "body": "...", "evidence_days": [1, 15, 30]} + ], + "key_figures": [ + {"name": "...", "arc": "...", "evidence_days": [3, 20]} + ], + "contradictions": [ + {"surface": "...", "underlying": "...", "evidence": "..."} + ], + "data_gaps": [ + {"gap": "...", "why_it_matters": "...", "suggested_fix": "..."} + ] +} +``` + +`data_gaps` 字段是**这次改造的关键收益**——它把第四节列的 5 个盲区变成报告的正式输出,读者不用再自己去翻日志。 + +### 5.2 Pass 6:开场旁白(可选,营造"故事感") + +**目的**:你提到的"像旁白一样讲完这个故事"。 + +**输入**:`executive_brief.headline` + top3 居民 + top3 转折点 + +**prompt 骨架**: + +``` +你是 {world_name} 的旁白者,为一份模拟报告写开场词。 + +核心事件:{headline} +主线人物:{top3_agents_with_traits} +关键转折:{top3_inflections} + +用 300 字写一段旁白: +- 第一句必须是一个具体画面或场景(有人/有地方/有动作),不要"在这个社会中" +- 中间三句讲清起承转合 +- 最后一句留一个未解的问题 +- 不解释数据,不下结论,不出现指标名 +``` + +### 5.3 前端改动 + +[ReportView.vue](../frontend/src/views/ReportView.vue) 增加两个区块: + +- `narrative_summary`(全文渲染,支持 Markdown) +- `chapters`(时间轴折叠列表,点击跳转到对应 day) +- `data_gaps`(**高亮显示**,建议用警示色,因为这是"诚实度"卖点) + +`key_figures` 和 `contradictions` 可折叠,默认收起。 + +--- + +## 六、改动量评估 + +| 方案 | 改动 | LLM 调用增量 | 效果 | +|------|------|-------------|------| +| **A. 只加 Pass 5** | narrator.py +100 行、1 个新 prompt、ReportView.vue +40 行 | +1 次(约 10-20s) | 报告从拼盘变故事,且带数据盲区 | +| **B. A + Pass 6** | 再 +40 行 | +1 次 | 额外开场旁白,阅读体验更好 | +| **C. A + epoch 粒度细化** | epoch 从 30 天改 7 天 | +3~4 次 | 时间轴更细,但收益递减 | +| **D. 修 sim_logger 指标缺失** | sim_logger.py +4 行 | 0 | 只影响日志可观测性,**不影响报告**(报告读 DB) | + +**建议先做 A**,验证效果后再决定要不要 B/C。D 是独立的可观测性改进,成本极低可顺手做。 + +--- + +## 七、待澄清 + +- Pass 5 的 1500-2500 字对 30 天模拟够吗?长模拟(365 天)是不是要按 epoch 动态伸缩? +- `data_gaps` 要不要让 LLM 生成,还是**用代码硬规则检测**(如"adoption_rate 全程为 0 → 报为盲区")?后者更可靠但覆盖不全 +- `insights.evidence` 是否应改为**必填**,LLM 填不出证据就不让出该 insight?这样能根治"讲得不明不白" +- 报告 PDF 是渲染型(pypdf 抽不出文字),Pass 5 的 Markdown 需确认导出链路是否支持 + +--- + +## 八、相关代码位置 + +- 报告生成主流程:[`backend/app/services/narrator.py` `generate_report`](../backend/app/services/narrator.py#L226-L486) +- Pass 2 指标解释 prompt:[`backend/app/services/narrator.py` `INTERPRET_METRICS_SYSTEM`](../backend/app/services/narrator.py#L46-L79) +- Pass 3 分群 prompt:[`backend/app/services/narrator.py` `SEGMENT_ANALYSIS_SYSTEM`](../backend/app/services/narrator.py#L81-L110) +- 纯统计计算:[`backend/app/services/report_analyzer.py`](../backend/app/services/report_analyzer.py) +- 指标定义(14 项):[`backend/app/models/world.py` `WorldMetrics`](../backend/app/models/world.py#L30-L43) +- 指标持久化:[`backend/app/db/store.py` `save_metrics` / `get_metrics_history`](../backend/app/db/store.py#L231-L243) +- JSONL 日志(只 6 项):[`backend/app/services/sim_logger.py` `log_round`](../backend/app/services/sim_logger.py#L153-L160) +- 报告前端:[`frontend/src/views/ReportView.vue`](../frontend/src/views/ReportView.vue) +- 本次模拟日志:[`backend/logs/8a2c6953-224.jsonl`](../../backend/logs/8a2c6953-224.jsonl) diff --git a/docs/问题记录/问题记录-汇总-2026-09-28.md b/docs/问题记录/问题记录-汇总-2026-09-28.md new file mode 100644 index 0000000..c1e5dd9 --- /dev/null +++ b/docs/问题记录/问题记录-汇总-2026-09-28.md @@ -0,0 +1,231 @@ +# 问题记录汇总:模拟规模、居民兜底与搜索后端 + +> 调查日期:2026-09-28 +> 状态:首轮代码优化已完成,待实际运行验证 +> 本文同时保留问题证据、决策依据和后续未覆盖项。 + +## 一、文档用途 + +本文汇总以下三份原始记录,并根据当前代码重新核对结论: + +- [问题记录-硬编码与千人模拟](./问题记录-硬编码与千人模拟.md) +- [问题记录-2026-09-27-硬编码与搜索替换](./问题记录-2026-09-27-硬编码与搜索替换.md) +- [问题记录-2026-09-28-居民生成兜底硬编码](./问题记录-2026-09-28-居民生成兜底硬编码.md) +- [问题记录-2026-09-28-报告缺失系统性总结](./问题记录-2026-09-28-报告缺失系统性总结.md) + +原始记录保留作为发现过程和现场观察;本文作为当前阶段的统一判断,避免后续继续沿用已经过时的描述。 + +本轮已落地的范围:SearXNG 配置化调用与短缓存、完整指标进入报告和 JSONL 观测、确定性数据盲区检测、报告 Pass 5 系统性总结、居民兜底补生成与来源标记、抽样上限修正和抽样快照记录、时间配置校验,以及报告 schema 版本变更后的自动重生成。由于本轮之后尚未重新运行完整模拟,LLM 生成质量、报告耗时和真实搜索连通性仍需验证。 + +## 二、结论总览 + +| 问题 | 当前判断 | 说明 | +|---|---|---| +| 每轮只抽 3–10 人 | 部分成立,原描述已过时 | `TimeConfig` 默认值确实是 3–10,但正常世界生成会按人口提示约 40% 的上限;当前 API 人口上限又只有 50。 | +| “千人社会模拟”是否已实现 | 尚未成立 | 当前 API 不接受超过 50 人;即使放开人口上限,当前每轮 LLM 调用量、状态更新方式和成本模型也需要重新设计。 | +| 抽样是否有代表性 | 问题成立 | 当前是带情绪、派系、桥接节点、沉睡补偿等因素的加权抽样,不能直接等同于总体民意。 | +| 60% 是抽样硬上限 | 判断不准确 | 当前公式并不能保证最终人数不超过人口的 60%。 | +| semaphore=10 即 TPS 没问题 | 判断过强 | semaphore 只限制本地并发,不代表供应商 TPS、TPM、总耗时或总成本没有风险。 | +| 居民数量不足时的硬编码兜底 | 问题确认存在 | 解析失败、返回数量不足等情况都会静默补齐,并继续进入 persona 及后续生成流程。 | +| 兜底居民会污染模拟 | 问题确认存在 | 模板姓名、角色、性格钩子和立场会被继续送入 LLM,并可能影响 beliefs、生活史和关系。 | +| DuckDuckGo 搜索不稳定 | 工程风险成立 | 当前代码仍直接使用 `duckduckgo_search`,没有 SearXNG 配置或缓存。 | +| 换成 SearXNG 就能保证重复性 | 不能直接保证 | SearXNG 减少单一后端限流问题,但聚合搜索结果仍会随时间和上游引擎变化;需要缓存或结果快照。 | +| 报告“缺数据”或“读不懂” | 数据缺失判断不成立,报告解释层问题成立 | DB 会完整保存当前 `WorldMetrics` 的 13 个字段;JSONL 运行日志只记录 6 个指标。当前报告缺少时间线串联、证据约束和数据盲区说明。 | + +## 三、问题一:模拟规模与每轮居民抽样 + +### 3.1 当前实现事实 + +1. `TimeConfig` 默认值是:365 天、每天 3 轮、每轮最少 3 人、最多 10 人。 +2. `WorldGenerator` 会向 LLM 提示 `active_agents_per_round_max` 约为人口的 40%,缺省值也按这个比例计算。 +3. 如果 LLM 自己返回了 `time_config`,当前代码会优先接受 LLM 返回的值,没有对上下限关系做严格业务校验。 +4. API 当前明确限制人口为 2–50 人,因此目前产品入口并不支持 1000 人。 +5. `_select_active_agents` 采用加权、无放回抽样。情绪激动、属于派系、处于桥接位置、市场特征明显或沉睡较久的居民,权重会提高。 +6. “沉睡”补偿记录的是最近一次非 `DO_NOTHING` 行动,而不是最近一次被选中。被选中但没有行动的居民仍可能继续获得沉睡加成。 +7. 当前 `max_active = max(base_count, 60%人口)` 不是严格的 60% 上限。当 `base_count` 本身大于 60% 时,仍会保留这个更大的数量。 +8. 居民生成使用 `generate_fast` 先生成 cast/persona,生活史和关系在后台补充。因此当前也不是“所有居民的完整背景先生成完,再开始模拟”。 + +### 3.2 哪些原始判断需要修正 + +原记录把当前行为概括为“每轮固定 3–10 人”,这只对直接使用 `TimeConfig` 默认值的路径成立,不足以描述正常世界生成路径。 + +更准确的说法是:抽样参数同时受到模型默认值、世界生成结果和运行时人口规模影响;但产品入口目前最多 50 人,所以“千人社会”更多是产品叙事/未来目标,而不是当前 API 已支持的运行模式。 + +原记录把 `60%` 描述为上限也不准确,应改成“事件加成在特定范围内会受到一个不严格的 60% 参考值影响”。 + +### 3.3 问题仍然成立的部分 + +- 抽样数量属于决定模拟质量和成本的核心参数,却与世界时间配置放在同一模型里。 +- 加权抽样会主动放大高情绪、高参与倾向的小群体;这可能是设计意图,也可能造成民意代表性偏差。 +- 仅查看“每轮抽中了多少人”不足以证明千人社会产生了有效涌现,需要观察居民覆盖率、重复参与率、行动率、沉默率和群体分布。 +- semaphore=10 只能说明本地同时执行的 LLM 请求有上限,不能单独推出“供应商 TPS 没问题”。 + +### 3.4 建议方向与首轮实现 + +1. 将“世界时间配置”和“居民参与/LLM 预算配置”拆开。后者至少应包含抽样策略、每轮预算、最小/最大参与数、事件加成和是否允许全量参与。 +2. 明确“人口数量”的产品含义:是状态空间规模,还是每轮实际接受 LLM 决策的人数。两者不能用同一个数字直接代表。 +3. 如果目标确实是 1000 人,优先研究分层/代表性抽样、群体级状态更新、局部精细模拟和按需唤醒,而不是简单把人口上限和 `active_agents_per_round_max` 调大。 +4. 需要严格上限时,应重新定义抽样公式,并通过测试验证事件发生、人口较小、人口较大等边界情况。 +5. 增加运行指标:每轮选中数、实际行动数、居民最近参与间隔、每个居民累计参与次数、LLM 调用数、失败数、耗时和 token 成本。 + +首轮已实现其中的抽样快照、事件标记和 JSONL 完整指标记录;LLM 成本和每居民累计覆盖率仍需在新一轮模拟中补充统计。 + +## 四、问题二:居民生成兜底的硬编码污染 + +### 4.1 当前实现事实 + +当 `_generate_cast` 返回的居民少于目标数量时,代码会无条件补齐到 `count`。中文和英文分支分别使用程序化姓名、固定角色、固定年龄模式、固定性格钩子和固定立场。 + +触发来源包括: + +- LLM 截断或确实返回数量不足; +- 返回对象结构不符合预期; +- JSON 解析失败; +- 中文重试后仍没有得到足够居民。 + +`parse_json` 在最终失败时返回空字典,因此整批失败会直接转化为整批兜底居民。 + +### 4.2 已确认的影响 + +1. 兜底居民不是终态数据,会进入 persona 生成。 +2. 固定的 `personality_hook`、`stance`、角色和年龄会成为后续 LLM 的输入条件。 +3. 兜底居民缺失的字段会触发中位 personality、随机 communication style 等默认逻辑。 +4. `generate_fast` 会先把这些居民投入模拟,生活史和关系生成在后台继续进行;如果后台成功,它们还会继续进入后续阶段。 +5. 没有结构化的“本居民由兜底生成”标记,前端、叙事和报告无法区分正常居民与降级居民。 +6. warning 和 JSON 解析 error 会写入后端日志,但不会把“整批 LLM 失败”和“少返回几人”作为不同状态传递给调用方。 + +### 4.3 额外发现 + +中文姓名检查阶段直接对列表元素调用 `.get()`。如果 LLM 返回的数组中混入非字典元素,可能在兜底逻辑之前直接抛异常。居民条目的类型和必需字段目前也没有统一校验。 + +### 4.4 建议方向与首轮实现 + +1. 把“数量不足”“结构错误”“JSON 解析失败”“LLM 调用失败”分成不同错误类型并记录指标。 +2. 对数量不足进行一次有界重试,仍不足时由产品选择:让模拟失败并提示重试,或进入明确标记的降级模式。 +3. 如果保留降级模式,不应使用固定的政治立场和同质化模板;至少要带有来源标记,并避免把模板立场直接注入 beliefs。 +4. 对 cast 做逐项 schema/type 校验,避免非字典元素或缺失字段导致新的异常。 +5. 把兜底数量、触发原因、是否影响前端展示和是否允许继续运行纳入验收标准。 + +## 五、问题三:DuckDuckGo 替换为 SearXNG + +### 5.1 当前实现事实 + +当前 `ResearchService` 仍然直接导入并调用 `duckduckgo_search.DDGS`,配置中只有搜索开关和每轮最大搜索次数,没有 SearXNG 地址、token 或 timeout 字段。 + +当前研究流程已经具备每轮最多 5 个搜索请求的限制,并会并发执行这些请求;但没有查询短缓存,也没有记录搜索结果快照。 + +### 5.2 替换动机是否成立 + +成立:DuckDuckGo 依赖外部服务,存在反爬、CAPTCHA、限流和结果波动风险。自托管 SearXNG 可以把入口和认证控制收回来,但不能自动解决上游引擎波动,也不能单独提供严格重复性。 + +### 5.3 建议方案与首轮实现 + +1. 使用 skill 文档中确认的部署地址、GET 方法和 token 认证方式;token 只通过环境变量注入,不写入代码或提交文件。 +2. 将 `base_url`、token、timeout、language、safesearch 和可选的 time range 配置化。timeout 暂定 15 秒是较平衡的起点。 +3. 将 SearXNG 的 `title/content/url` 适配为当前内部使用的 `title/body/href`,保持后续摘要逻辑稳定。 +4. 复用 `httpx` 的异步客户端,设置明确的连接、读取和总超时,并对非 200、JSON 错误和空结果分别记录。 +5. 默认不保留 DDG fallback,以免搜索结果在失败时悄悄切换来源;若确实需要,应做成显式的降级开关。 +6. 为“可重复模拟”增加按模拟 ID 或查询键保存的结果快照。单纯换成 SearXNG 不足以保证同一 query 得到同一结果。 +7. safesearch 不应硬编码在业务代码中。普通产品默认可以取 1;需要完整社会议题研究时,再由明确配置选择 0,并记录该选择。 + +## 六、问题四:报告缺少系统性总结与数据盲区 + +### 6.1 先区分“数据缺失”和“报告没有解释” + +新增记录中最初的怀疑是“报告提到的冲突、自由度等指标可能没有被记录”。根据当前实现,这个判断需要修正: + +- `WorldMetrics` 当前实际定义了 13 个指标(原始记录写成 14 个,是数量统计误差); +- `store.save_metrics` 使用完整的 `model_dump_json()` 保存指标; +- `get_metrics_history` 会把完整 JSON 读回; +- 报告生成使用 `metrics_history` 和 `ReportAnalyzer`,并非以 JSONL 日志为主要指标来源。 + +因此,DB 层的指标数据并没有因为 JSONL 日志缺字段而丢失。真正存在的是两个不同层面的问题: + +1. JSONL 日志只记录 6 个指标,导致人工排查和审计时容易误以为其他指标不存在; +2. 报告生成 pipeline 没有把已有数据、时间线、人物行为、指标关系和数据盲区组织成一条可追溯的解释链。 + +### 6.2 当前报告 pipeline 的结构性缺口 + +当前报告大致包含 epoch 摘要、数据统计/预测分析、指标解释与洞察、分群分析、行动建议,最终组装为 executive brief、scorecard、insights、segments、action items、risks 等字段。 + +但最终报告没有专门的系统性总结字段,因此读者需要自行拼接: + +- epoch 摘要没有和后续洞察按时间轴串联; +- 每个指标的解释基本独立生成,缺少指标之间的因果/矛盾关系; +- `insights` 虽然在 prompt 中要求 evidence,但代码没有 schema 校验,LLM 仍可能返回没有具体 agent、day 或 action 支撑的结论; +- 报告没有 `data_gaps`、`limitations` 或“本次模拟未覆盖什么”的正式字段; +- 报告中的 `narrative.summary` 目前主要复用 executive brief,不等同于完整的历史叙事。 + +所以“报告读不懂”是一个真实的产品问题,但更准确的名称是“报告解释层和可追溯性不足”,不是数据库指标丢失。 + +### 6.3 数据盲区记录的使用边界 + +新增记录中列出的 adoption_rate、defect_count、trust、purchase_intent 和张力事件,是某次 30 天模拟的观测结果,适合成为该次报告的 `data_gaps` 或限制说明,但不能直接当作所有模拟都成立的系统规律。 + +还需要注意:`trust` 在 resolver 中并非只由 `DEFECT` 影响,其他行动也会对 trust 产生增减;因此“trust 只能因 DEFECT 衰减”的表述应改成“本次样本中没有出现足以让 trust 明显下降的有效行动,具体原因需要结合全部行动和指标轨迹判断”。 + +更稳妥的做法是将数据盲区分为两类: + +- **代码可检测盲区**:例如某指标全程不变、某类行动次数为 0、某类事件发生但没有进入报告; +- **LLM 解释盲区**:已有数据存在,但报告没有给出证据、时间点或指标间关系。 + +### 6.4 建议方向与首轮实现 + +1. 增加一个独立的“系统性总结”阶段,把 epoch 时间线、指标全表、转折点、居民行为、分群结果、洞察和风险统一输入,输出带章节的完整叙事。 +2. 增加 `data_gaps` 或 `limitations` 字段,明确列出本次模拟无法回答的问题、原因和建议补充数据。 +3. 将 `insights.evidence` 设为结构化必填项;缺少具体 agent、day、action 或指标值的洞察不应直接进入最终报告。 +4. 对不变量和盲区优先使用代码规则检测,例如指标全程为 0、全程不变、某类行动从未发生,再让 LLM 负责解释其意义。 +5. 把 `sim_logger` 的 6 指标限制视为独立的可观测性问题修复;它不等于修复报告数据源,但能降低人工排查时的误判。 +6. 前端报告可以增加时间线章节、关键人物、矛盾剖析和数据盲区区块;这些属于后续实现,不在本次文档整理中执行。 + +首轮已增加系统性总结、章节、数据盲区展示和 Markdown 导出;关键人物/矛盾字段已由后端预留,但尚未做完整的前端专门区块。 + +### 6.5 推荐实施顺序(未来) + +优先做“只增加 Pass 5 系统性总结 + data_gaps”,先验证报告是否从“指标拼盘”变成“有证据的故事”;再考虑开场旁白、缩短 epoch 粒度或扩展前端交互。不要先增加更多文学化文本,否则可能进一步掩盖证据不足的问题。 + +## 七、共同的业务问题 + +三个问题指向同一件事:产品对外描述的是“有大量居民、能产生社会涌现的模拟”,而当前实现中: + +- API 尚不支持千人规模; +- 每轮真正进行 LLM 决策的是一个经过偏置的子集; +- LLM 失败时可能静默制造模板居民; +- 外部搜索结果不稳定且无法复现。 + +因此不能只把这些问题分别当成“一个参数、一个 fallback、一个搜索库”的工程整理。需要先确定产品承诺:模拟追求的是全体居民的细粒度决策,还是大规模社会的统计/分层涌现。 + +## 八、建议的处理顺序 + +### 阶段 0:先定口径 + +- 明确支持的人口规模和“千人模拟”是否是当前能力还是未来目标。 +- 明确每轮居民参与率、可接受延迟、LLM 成本和结果重复性要求。 +- 决定居民生成失败时是阻断模拟,还是允许带标记的降级运行。 + +### 阶段 1:补观测,不先调参数 + +- 记录抽样覆盖率、行动率、居民重复参与和沉睡补偿效果。 +- 记录居民生成短缺、解析失败、兜底数量和各阶段耗时。 +- 记录搜索成功率、超时率、结果来源和缓存命中率。 + +### 阶段 2:分别处理三条技术线 + +1. 抽样策略与规模模型; +2. 居民生成错误边界与降级策略; +3. SearXNG 配置、适配、缓存和可重复性。 + +### 阶段 3:用小规模实验验证 + +先使用 25/50 人进行不同抽样策略和兜底策略的 A/B 对比,再决定是否扩大人口上限。不能先把人口直接放大到 1000,再用运行成本和结果噪声反推设计是否合理。 + +## 九、本轮尚未覆盖的事情 + +- 尚未把人口上限扩展到 1000,也没有实现分层/群体级模拟; +- 尚未根据真实供应商限流数据校准 LLM 成本和 TPS; +- 尚未实现搜索结果按模拟 ID 的持久化快照;当前只有进程内短缓存; +- 尚未实现报告 Pass 6 开场旁白、关键人物和矛盾区块的完整前端交互; +- 尚未安装 pytest 并执行完整自动化测试套件; +- 尚未重新跑一轮完整模拟验证新增 Pass 5、兜底补生成和 SearXNG 连通性。 + +本文用于后续决策和实施前对照。任何代码修改前,应先在本文对应章节补充具体决策、验收指标和回滚方案。 diff --git a/docs/问题记录-硬编码与千人模拟.md b/docs/问题记录/问题记录-硬编码与千人模拟.md similarity index 100% rename from docs/问题记录-硬编码与千人模拟.md rename to docs/问题记录/问题记录-硬编码与千人模拟.md diff --git a/frontend/src/components/AgentDetailPanel.vue b/frontend/src/components/AgentDetailPanel.vue index 96a3ef6..bcf6dc7 100644 --- a/frontend/src/components/AgentDetailPanel.vue +++ b/frontend/src/components/AgentDetailPanel.vue @@ -8,8 +8,12 @@

{{ agent.name }}

-
-

{{ agent.role }}, {{ agent.age }}

+ +

{{ agent.role }}, {{ agent.age }}

+ + {{ $t('agent.profile.generationFallback') }} + diff --git a/frontend/src/components/ScorecardGrid.vue b/frontend/src/components/ScorecardGrid.vue index cb1dc2c..b7ab60b 100644 --- a/frontend/src/components/ScorecardGrid.vue +++ b/frontend/src/components/ScorecardGrid.vue @@ -72,10 +72,13 @@ const { t, tm } = useI18n() // canonical English enum strings while we render localized labels. function _metricLabel(key) { const keyMap = { stability: 'report.metricLabels.stability', prosperity: 'report.metricLabels.prosperity', - trust: 'report.metricLabels.trust', freedom: 'report.metricLabels.freedom', - conflict: 'report.metricLabels.conflict', brand_sentiment: 'report.metricLabels.brandSentiment', - purchase_intent: 'report.metricLabels.purchaseIntent', word_of_mouth: 'report.metricLabels.wordOfMouth', - churn_risk: 'report.metricLabels.churnRisk', adoption_rate: 'report.metricLabels.adoptionRate' } + trust: 'report.metricLabels.trust', freedom: 'report.metricLabels.freedom', + conflict: 'report.metricLabels.conflict', brand_sentiment: 'report.metricLabels.brand_sentiment', + purchase_intent: 'report.metricLabels.purchase_intent', word_of_mouth: 'report.metricLabels.word_of_mouth', + churn_risk: 'report.metricLabels.churn_risk', adoption_rate: 'report.metricLabels.adoption_rate', + information_spread: 'report.metricLabels.information_spread', + echo_chamber_index: 'report.metricLabels.echo_chamber_index', + rumor_distortion: 'report.metricLabels.rumor_distortion' } const i18nKey = keyMap[key] return i18nKey ? t(i18nKey) : key } diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 500797f..c7e1aff 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -203,8 +203,9 @@ "noMemories": "No memories yet...", "beliefs": "Beliefs", "remembers": "Remembers", - "goals": "Goals", - "askPrefix": "Ask", + "goals": "Goals", + "generationFallback": "Degraded generation", + "askPrefix": "Ask", "thinking": "Thinking...", "chatPlaceholder": "Ask something...", "ageAtEvent": "Age {age}" @@ -317,8 +318,16 @@ "medium": "medium", "low": "low" }, - "confidenceSuffix": "confidence", - "metricLabels": { + "confidenceSuffix": "confidence", + "systematic": { + "title": "Systematic Summary", + "evidenceDays": "Evidence days" + }, + "dataGaps": { + "title": "Data Gaps & Limitations", + "subtitle": "These limitations were detected in this run; they are not necessarily universal simulator limitations." + }, + "metricLabels": { "stability": "Stability", "prosperity": "Prosperity", "trust": "Trust", @@ -328,7 +337,10 @@ "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" }, "ratings": { "excellent": "Excellent", diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index b51011a..120edbc 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -203,8 +203,9 @@ "noMemories": "暂无记忆...", "beliefs": "信念", "remembers": "铭记", - "goals": "目标", - "askPrefix": "询问", + "goals": "目标", + "generationFallback": "降级生成", + "askPrefix": "询问", "thinking": "思考中...", "chatPlaceholder": "问点什么...", "ageAtEvent": "{age} 岁时" @@ -317,8 +318,16 @@ "medium": "中", "low": "低" }, - "confidenceSuffix": "置信度", - "metricLabels": { + "confidenceSuffix": "置信度", + "systematic": { + "title": "系统性总结", + "evidenceDays": "证据日期" + }, + "dataGaps": { + "title": "数据盲区与限制", + "subtitle": "以下内容是本次模拟中可确定检测到的限制,不代表所有模拟都存在相同问题。" + }, + "metricLabels": { "stability": "稳定性", "prosperity": "繁荣度", "trust": "信任度", @@ -328,7 +337,10 @@ "purchase_intent": "购买意向", "word_of_mouth": "口碑传播", "churn_risk": "流失风险", - "adoption_rate": "采纳率" + "adoption_rate": "采纳率", + "information_spread": "信息传播", + "echo_chamber_index": "回音室指数", + "rumor_distortion": "谣言失真" }, "ratings": { "excellent": "极佳", diff --git a/frontend/src/views/ReportView.vue b/frontend/src/views/ReportView.vue index 8f19bfc..ef333dd 100644 --- a/frontend/src/views/ReportView.vue +++ b/frontend/src/views/ReportView.vue @@ -98,10 +98,40 @@ {{ (meta.rules || []).length }} {{ $t('report.rules') }} - - - - + + + +
+

{{ $t('report.systematic.title') }}

+
+

{{ report.systematic_summary.summary }}

+
+
+
+

{{ chapter.title }}

+

{{ chapter.body }}

+
+ {{ $t('report.systematic.evidenceDays') }}: {{ chapter.evidence_days.join(', ') }} +
+
+
+
+ +
+

{{ $t('report.dataGaps.title') }}

+

{{ $t('report.dataGaps.subtitle') }}

+
+
+
{{ gap.gap }}
+
{{ gap.why_it_matters }}
+
{{ gap.suggested_fix }}
+
+
+
+ + +
@@ -432,11 +462,15 @@ const confidenceText = computed(() => { }) const meta = computed(() => report.value?.meta || {}) -const METRIC_CONFIG = [ - { key: 'stability', label: 'Stability', color: 'bg-emerald-500' }, - { key: 'trust', label: 'Trust', color: 'bg-blue-500' }, - { key: 'conflict', label: 'Conflict', color: 'bg-red-500' }, - { key: 'freedom', label: 'Freedom', color: 'bg-amber-500' }, +const METRIC_CONFIG = [ + { key: 'stability', label: 'Stability', color: 'bg-emerald-500' }, + { key: 'prosperity', label: 'Prosperity', color: 'bg-sky-400' }, + { key: 'trust', label: 'Trust', color: 'bg-blue-500' }, + { key: 'conflict', label: 'Conflict', color: 'bg-red-500' }, + { key: 'freedom', label: 'Freedom', color: 'bg-amber-500' }, + { key: 'information_spread', label: 'Information Spread', color: 'bg-cyan-400' }, + { key: 'echo_chamber_index', label: 'Echo Chamber Index', color: 'bg-orange-400' }, + { key: 'rumor_distortion', label: 'Rumor Distortion', color: 'bg-rose-400' }, { key: 'brand_sentiment', label: 'Brand Sentiment', color: 'bg-emerald-400', market: true }, { key: 'purchase_intent', label: 'Purchase Intent', color: 'bg-blue-400', market: true }, { key: 'word_of_mouth', label: 'Word of Mouth', color: 'bg-violet-400', market: true }, @@ -571,7 +605,11 @@ function copyMarkdown() { let md = `# ${b.headline || m.world_name || 'Report'}\n\n` md += `**Verdict:** ${b.verdict || 'N/A'} | **Confidence:** ${b.confidence || 'N/A'}\n\n` md += `${b.summary || ''}\n\n` - md += `**Stats:** ${m.agent_count} agents, ${m.total_days} days, ${m.total_actions} actions\n\n` + md += `**Stats:** ${m.agent_count} agents, ${m.total_days} days, ${m.total_actions} actions\n\n` + + if (report.value.systematic_summary?.summary) { + md += `## Systematic Summary\n\n${report.value.systematic_summary.summary}\n\n` + } if (report.value.insights?.length) { md += `## Key Insights\n\n` @@ -581,13 +619,21 @@ function copyMarkdown() { md += '\n' } - if (report.value.action_items?.length) { + if (report.value.action_items?.length) { md += `## Action Items\n\n` for (const a of report.value.action_items) { md += `${a.priority === 'high' ? '!' : '-'} ${a.action}\n` } - md += '\n' - } + md += '\n' + } + + if (report.value.data_gaps?.length) { + md += `## Data Gaps & Limitations\n\n` + for (const gap of report.value.data_gaps) { + md += `- **${gap.gap}** ${gap.why_it_matters || ''} ${gap.suggested_fix || ''}\n` + } + md += '\n' + } navigator.clipboard.writeText(md) copiedMd.value = true