You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
486 lines
21 KiB
486 lines
21 KiB
from __future__ import annotations
|
|
import asyncio
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
from app.models.action import ActionEntry, ReactiveResponse
|
|
from app.models.world import WorldState, WorldMetrics
|
|
from app.services.llm import LLMClient, parse_json
|
|
from app.services.i18n import DEFAULT_LOCALE, language_directive as _language_directive, localize_report
|
|
from app.constants import TIMES_OF_DAY
|
|
|
|
if TYPE_CHECKING:
|
|
from app.db.store import SimulationStore
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
NARRATE_SYSTEM = """You are a dry, observational narrator documenting life in {world_name}.
|
|
Rules of this world: {rules}
|
|
|
|
Your style is like a nature documentary about humans — factual, specific, occasionally wry:
|
|
- Report what people did and said, not what it "felt like"
|
|
- Use plain, direct sentences. No purple prose, no dramatic metaphors.
|
|
- Include actual quotes when someone spoke. Paraphrase internal thoughts briefly.
|
|
- Note contradictions between what people say and what they think.
|
|
- One dry observation or ironic aside per scene is fine. No more.
|
|
- Do NOT use phrases like "the air was thick with tension" or "a storm was brewing"
|
|
- 2-3 short paragraphs max. Shorter is better."""
|
|
|
|
NARRATE_USER = """Previous update:
|
|
{previous_narrative}
|
|
|
|
What happened during {time_of_day} on Day {day}:
|
|
{action_summaries}
|
|
|
|
{reactions_text}
|
|
|
|
{tension_event_text}
|
|
|
|
Summarize what happened. Be specific about who did what and where. Keep it grounded."""
|
|
|
|
EPOCH_SYSTEM = """You are summarizing a period in the history of {world_name}, a simulated society.
|
|
Rules: {rules}
|
|
|
|
Compress the events into a 2-3 paragraph summary capturing the key developments, shifts in power, emerging conflicts, and notable character moments. Write as a historian, not a novelist."""
|
|
|
|
INTERPRET_METRICS_SYSTEM = """You are a senior analyst interpreting simulation results from {world_name}.
|
|
Rules of this world: {rules}
|
|
|
|
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.
|
|
|
|
Return JSON:
|
|
{{
|
|
"executive_brief": {{
|
|
"verdict": "go" | "caution" | "rethink",
|
|
"confidence": "{confidence}",
|
|
"headline": "One sentence verdict",
|
|
"summary": "One paragraph expanding on the verdict"
|
|
}},
|
|
"metric_explanations": {{
|
|
"metric_key": "One sentence explanation"
|
|
}},
|
|
"insights": [
|
|
{{
|
|
"title": "Short hook",
|
|
"description": "2-3 sentences explaining what happened and why it matters for the user's decision",
|
|
"evidence": {{
|
|
"agents": ["agent names involved"],
|
|
"days": [day numbers],
|
|
"actions": ["ACTION_TYPES involved"]
|
|
}},
|
|
"type": "opportunity" | "risk" | "surprise"
|
|
}}
|
|
]
|
|
}}"""
|
|
|
|
SEGMENT_ANALYSIS_SYSTEM = """You are a market research analyst studying segment reactions in {world_name}.
|
|
Rules: {rules}
|
|
|
|
Given per-segment data (action counts, quotes, personality traits), produce a deep analysis of each segment.
|
|
|
|
For each segment, provide:
|
|
1. Reaction summary: 1-2 sentences on how this segment responded overall
|
|
2. Top objection: The most common reason agents in this segment rejected or abandoned (infer from their speeches and thoughts)
|
|
3. Champion: The most enthusiastic adopter — name and a short reason why
|
|
4. Representative quote: Pick the most insightful actual speech or thought from this segment
|
|
|
|
Return JSON:
|
|
{{
|
|
"segments": [
|
|
{{
|
|
"name": "segment name",
|
|
"reaction": "1-2 sentence reaction summary",
|
|
"top_objection": "The main barrier or complaint",
|
|
"champion": {{"name": "agent name", "why": "Short reason"}},
|
|
"representative_quote": "An actual quote from an agent"
|
|
}}
|
|
]
|
|
}}"""
|
|
|
|
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:
|
|
|
|
1. 3-7 prioritized action items. Each must be specific and actionable (not vague like "improve marketing"). Reference the simulation evidence that supports it. Include expected impact.
|
|
2. Key risks with severity levels.
|
|
3. Second-order effects: downstream consequences the user should anticipate.
|
|
|
|
Return JSON:
|
|
{{
|
|
"action_items": [
|
|
{{
|
|
"action": "Specific, concrete thing to do",
|
|
"reasoning": "Evidence from the simulation supporting this",
|
|
"expected_impact": "What changes if this is done",
|
|
"priority": "high" | "medium" | "low"
|
|
}}
|
|
],
|
|
"risks": [
|
|
{{"risk": "Description", "severity": "high" | "medium" | "low"}}
|
|
],
|
|
"second_order_effects": ["Effect description"]
|
|
}}"""
|
|
|
|
|
|
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
|
|
|
|
async def narrate_round(
|
|
self,
|
|
world_state: WorldState,
|
|
actions: list[ActionEntry],
|
|
reactions: list[ReactiveResponse] | None = None,
|
|
previous_narrative: str | None = None,
|
|
tension_event: str | None = None,
|
|
) -> str:
|
|
if not actions:
|
|
return ""
|
|
|
|
action_lines = []
|
|
for a in actions:
|
|
line = f"- {a.agent_name}: {a.action_type.value}"
|
|
if a.speech:
|
|
line += f' — said: "{a.speech}"'
|
|
if a.internal_thought:
|
|
line += f" [privately thinks: {a.internal_thought}]"
|
|
if a.action_args:
|
|
details = {k: v for k, v in a.action_args.items() if k != "content"}
|
|
if details:
|
|
line += f" ({details})"
|
|
action_lines.append(line)
|
|
|
|
reactions_text = ""
|
|
if reactions:
|
|
reaction_lines = []
|
|
for r in reactions:
|
|
if r.reaction_type != "silent" and r.content:
|
|
reaction_lines.append(f"- {r.agent_name} reacted: {r.content}")
|
|
if reaction_lines:
|
|
reactions_text = "Reactions:\n" + "\n".join(reaction_lines)
|
|
|
|
tension_text = ""
|
|
if tension_event:
|
|
tension_text = f"A significant event occurred: {tension_event}"
|
|
|
|
time_of_day = TIMES_OF_DAY[world_state.round_in_day % len(TIMES_OF_DAY)]
|
|
|
|
head, tail = _language_directive(self._locale)
|
|
system = head + "\n\n" + NARRATE_SYSTEM.format(
|
|
world_name=world_state.blueprint.name,
|
|
rules="; ".join(world_state.blueprint.rules),
|
|
) + f"\n\n{tail}"
|
|
user = NARRATE_USER.format(
|
|
previous_narrative=previous_narrative or "This is the beginning of the story.",
|
|
time_of_day=time_of_day,
|
|
day=world_state.day,
|
|
action_summaries="\n".join(action_lines),
|
|
reactions_text=reactions_text,
|
|
tension_event_text=tension_text,
|
|
)
|
|
|
|
return await self.llm.generate(system=system, user=user, max_tokens=600)
|
|
|
|
async def summarize_epoch(
|
|
self,
|
|
world_name: str,
|
|
rules: list[str],
|
|
narratives: list[str],
|
|
metrics_start: WorldMetrics,
|
|
metrics_end: WorldMetrics,
|
|
day_start: int,
|
|
day_end: int,
|
|
) -> str:
|
|
head, tail = _language_directive(self._locale)
|
|
system = head + "\n\n" + EPOCH_SYSTEM.format(world_name=world_name, rules="; ".join(rules)) + f"\n\n{tail}"
|
|
narrative_text = "\n\n---\n\n".join(narratives[:20])
|
|
|
|
metrics_delta = (
|
|
f"Metrics changed from Day {day_start} to Day {day_end}:\n"
|
|
f" Stability: {metrics_start.stability:.2f} → {metrics_end.stability:.2f}\n"
|
|
f" Prosperity: {metrics_start.prosperity:.2f} → {metrics_end.prosperity:.2f}\n"
|
|
f" Trust: {metrics_start.trust:.2f} → {metrics_end.trust:.2f}\n"
|
|
f" Freedom: {metrics_start.freedom:.2f} → {metrics_end.freedom:.2f}\n"
|
|
f" Conflict: {metrics_start.conflict:.2f} → {metrics_end.conflict:.2f}\n"
|
|
f" Brand Sentiment: {metrics_start.brand_sentiment:.2f} → {metrics_end.brand_sentiment:.2f}\n"
|
|
f" Purchase Intent: {metrics_start.purchase_intent:.2f} → {metrics_end.purchase_intent:.2f}\n"
|
|
f" Word of Mouth: {metrics_start.word_of_mouth:.2f} → {metrics_end.word_of_mouth:.2f}\n"
|
|
f" Churn Risk: {metrics_start.churn_risk:.2f} → {metrics_end.churn_risk:.2f}\n"
|
|
f" Adoption Rate: {metrics_start.adoption_rate:.2f} → {metrics_end.adoption_rate:.2f}"
|
|
)
|
|
|
|
user = f"Days {day_start}-{day_end}:\n\n{narrative_text}\n\n{metrics_delta}"
|
|
return await self.llm.generate(system=system, user=user, max_tokens=500)
|
|
|
|
async def generate_report(self, simulation_id: str, store: "SimulationStore") -> dict:
|
|
world_state = await store.get_world_state(simulation_id)
|
|
if not world_state:
|
|
return {"error": "No world state found"}
|
|
|
|
blueprint = world_state.blueprint
|
|
metrics_history = await store.get_metrics_history(simulation_id)
|
|
all_narratives = await store.get_narratives(simulation_id)
|
|
agents = await store.get_all_agents(simulation_id)
|
|
actions = await store.get_actions(simulation_id)
|
|
|
|
proposed_change = await store.get_meta(simulation_id, "proposed_change")
|
|
is_market = proposed_change is not None
|
|
|
|
# --- Pass 0: Epoch summaries (existing logic) ---
|
|
epoch_size = 30
|
|
total_days = world_state.day
|
|
|
|
# Build the list of epochs that have narratives to summarize, then
|
|
# call the LLM concurrently for all of them. Epochs without any
|
|
# narratives (gap days) are skipped entirely.
|
|
pending_epochs: list[dict] = []
|
|
for start_day in range(1, total_days + 1, epoch_size):
|
|
end_day = min(start_day + epoch_size - 1, total_days)
|
|
epoch_narrs = [n["text"] for n in all_narratives if start_day <= n.get("day", 0) <= end_day]
|
|
if not epoch_narrs:
|
|
continue
|
|
|
|
m_start = WorldMetrics()
|
|
m_end = WorldMetrics()
|
|
for m in metrics_history:
|
|
day_of_round = m.get("round", 0) // 3 + 1
|
|
if day_of_round <= start_day:
|
|
m_start = WorldMetrics(**{k: m.get(k, getattr(WorldMetrics(), k)) for k in WorldMetrics.model_fields})
|
|
if day_of_round <= end_day:
|
|
m_end = WorldMetrics(**{k: m.get(k, getattr(WorldMetrics(), k)) for k in WorldMetrics.model_fields})
|
|
|
|
pending_epochs.append({
|
|
"start_day": start_day,
|
|
"end_day": end_day,
|
|
"narrs": epoch_narrs,
|
|
"m_start": m_start,
|
|
"m_end": m_end,
|
|
})
|
|
|
|
# Run all epoch summaries concurrently. The LLMClient already caps
|
|
# concurrency at 10 via its semaphore, so this just unblocks the
|
|
# 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
|
|
))
|
|
epochs = [
|
|
{"days": f"{ep['start_day']}-{ep['end_day']}", "summary": summary}
|
|
for ep, summary in zip(pending_epochs, epoch_summaries)
|
|
]
|
|
|
|
epoch_text = "\n\n".join(f"Days {e['days']}:\n{e['summary']}" for e in epochs)
|
|
|
|
# --- Pass 1: Data aggregation (no LLM) ---
|
|
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))
|
|
|
|
# --- Pass 1b: Darts-based analysis ---
|
|
forecast_analysis = {}
|
|
causal_map = []
|
|
counterfactual_data = []
|
|
coherence_data = {}
|
|
forecast = getattr(self, '_forecast', None)
|
|
if forecast and forecast.available:
|
|
try:
|
|
forecast_analysis = forecast.analyze(metrics_history)
|
|
except Exception as e:
|
|
logger.debug("Forecast analysis failed: %s", e)
|
|
try:
|
|
causal_links = forecast.discover_causality(simulation_id)
|
|
causal_map = ReportAnalyzer.format_causal_links(causal_links)
|
|
except Exception as e:
|
|
logger.debug("Causal discovery failed: %s", e)
|
|
try:
|
|
counterfactual_results = forecast.compute_counterfactuals(simulation_id)
|
|
counterfactual_data = ReportAnalyzer.format_counterfactuals(counterfactual_results)
|
|
except Exception as e:
|
|
logger.debug("Counterfactual computation failed: %s", e)
|
|
try:
|
|
from app.services.forecast import _SimState
|
|
sim_state = forecast._sims.get(simulation_id)
|
|
if sim_state and sim_state.coherence_scores:
|
|
coherence_data = ReportAnalyzer.format_coherence_scores(sim_state.coherence_scores)
|
|
except Exception as e:
|
|
logger.debug("Coherence formatting failed: %s", e)
|
|
|
|
# --- Pass 2: Metric interpretation + insights ---
|
|
metrics_summary = "\n".join(
|
|
f"- {s['label']}: {s['rating']} ({s['value']}/100), "
|
|
f"started at {s['start_value']}, trend: {s['trend']}, volatility: {s['volatility']}"
|
|
for s in metric_stats
|
|
)
|
|
inflection_summary = "\n".join(
|
|
f"- Day {p['day']}: {p['label']} {p['direction']} by {p['delta']:.2f} "
|
|
f"({p['value_before']:.2f} → {p['value_after']:.2f})"
|
|
for p in inflection_points[:10]
|
|
)
|
|
causal_summary = ""
|
|
if causal_map:
|
|
causal_summary = "Causal relationships discovered:\n" + "\n".join(
|
|
f"- {c['description']}" for c in causal_map[:5]
|
|
)
|
|
agent_summary = "\n".join(
|
|
f"- {a['name']} ({a['role']}): {a['total_actions']} actions, "
|
|
f"state={a['emotional_state']}, faction={a['faction'] or 'none'}"
|
|
for a in agent_stats
|
|
)
|
|
|
|
head, tail = _language_directive(self._locale)
|
|
system_p2 = head + "\n\n" + INTERPRET_METRICS_SYSTEM.format(
|
|
world_name=blueprint.name,
|
|
rules="; ".join(blueprint.rules),
|
|
confidence=confidence,
|
|
) + f"\n\n{tail}"
|
|
user_p2 = (
|
|
f"Epoch summaries:\n{epoch_text}\n\n"
|
|
f"Metric stats:\n{metrics_summary}\n\n"
|
|
f"Key inflection points:\n{inflection_summary}\n\n"
|
|
f"Agent summary:\n{agent_summary}\n\n"
|
|
f"Total days: {total_days}, Total agents: {len(agents)}, "
|
|
f"Total actions: {len(actions)}"
|
|
+ (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)
|
|
pass2 = parse_json(response_p2)
|
|
|
|
explanations = pass2.get("metric_explanations", {})
|
|
scorecard = []
|
|
for s in metric_stats:
|
|
scorecard.append({
|
|
"metric": s["metric"],
|
|
"label": s["label"],
|
|
"rating": s["rating"],
|
|
"value": s["value"],
|
|
"start_value": s["start_value"],
|
|
"trend": s["trend"],
|
|
"explanation": explanations.get(s["metric"], ""),
|
|
})
|
|
|
|
# --- Pass 3: Segment analysis ---
|
|
segment_input = ""
|
|
for seg in segment_stats:
|
|
segment_input += f"\n## Segment: {seg['name']} ({seg['agent_count']} agents)\n"
|
|
segment_input += f"Adoption: {seg['adoption_pct']}%, Advocates: {seg['advocate_count']}\n"
|
|
segment_input += f"Funnel: {seg['funnel']}\n"
|
|
if seg["top_speeches"]:
|
|
segment_input += "Recent speeches:\n" + "\n".join(f' - "{s}"' for s in seg["top_speeches"][-8:]) + "\n"
|
|
if seg["top_thoughts"]:
|
|
segment_input += "Recent thoughts:\n" + "\n".join(f" - {t}" for t in seg["top_thoughts"][-8:]) + "\n"
|
|
|
|
head, tail = _language_directive(self._locale)
|
|
system_p3 = head + "\n\n" + SEGMENT_ANALYSIS_SYSTEM.format(
|
|
world_name=blueprint.name,
|
|
rules="; ".join(blueprint.rules),
|
|
) + 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)
|
|
pass3 = parse_json(response_p3)
|
|
|
|
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", [])
|
|
|
|
synthesis_context = (
|
|
f"Executive verdict: {executive_brief.get('verdict', 'unknown')} — {executive_brief.get('headline', '')}\n\n"
|
|
f"Key insights:\n" + "\n".join(f"- [{i.get('type', '')}] {i.get('title', '')}: {i.get('description', '')}" for i in insights) + "\n\n"
|
|
f"Segment outcomes:\n" + "\n".join(
|
|
f"- {s['name']}: {s['adoption_pct']}% adoption. {s.get('reaction', '')} Objection: {s.get('top_objection', '')}"
|
|
for s in segments_final
|
|
) + "\n\n"
|
|
f"Metric scorecard:\n" + "\n".join(
|
|
f"- {s['label']}: {s['rating']} ({s['value']}/100) — {s.get('explanation', '')}"
|
|
for s in scorecard
|
|
)
|
|
)
|
|
|
|
head, tail = _language_directive(self._locale)
|
|
system_p4 = head + "\n\n" + ACTION_SYNTHESIS_SYSTEM.format(
|
|
world_name=blueprint.name,
|
|
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)
|
|
pass4 = parse_json(response_p4)
|
|
|
|
# --- Assemble final report ---
|
|
key_moments = []
|
|
for p in inflection_points[:10]:
|
|
key_moments.append({
|
|
"day": p["day"],
|
|
"title": f"{p['label']} {p['direction']}",
|
|
"description": f"{p['label']} shifted from {p['value_before']:.2f} to {p['value_after']:.2f}",
|
|
})
|
|
|
|
report = {
|
|
"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", ""),
|
|
"key_moments": key_moments,
|
|
"surprise": next((i["description"] for i in insights if i.get("type") == "surprise"), ""),
|
|
"factions": [],
|
|
},
|
|
"metrics_history": metrics_history,
|
|
"meta": {
|
|
"agent_count": len(agents),
|
|
"total_days": total_days,
|
|
"total_actions": len(actions),
|
|
"rules": blueprint.rules,
|
|
"world_name": blueprint.name,
|
|
},
|
|
"forecasts": forecast_analysis,
|
|
"causal_map": causal_map,
|
|
"counterfactuals": counterfactual_data,
|
|
"agent_coherence": coherence_data,
|
|
}
|
|
# We keep canonical English enum strings (label/rating/trend/
|
|
# verdict/confidence) in the response so the frontend can both:
|
|
# 1) pick the right CSS class for styling, and
|
|
# 2) translate the label for display via vue-i18n.
|
|
# LLM-generated free-text fields (summary, headlines, insights,
|
|
# action items, quotes) stay as-is — the prompt-level language
|
|
# directive should already have made them Chinese for zh users.
|
|
return report
|
|
|