fix simulation reporting and research resilience
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user