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.
783 lines
30 KiB
783 lines
30 KiB
# Analyst Report Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Replace the current single-LLM-call report with a multi-pass analyst report that produces actionable recommendations, interpreted metrics, emergent insights, and segment deep-dives — generated in the background as soon as simulation completes.
|
|
|
|
**Architecture:** 4-pass pipeline (1 computation pass + 3 LLM passes) in `narrator.py`. Report stored in sim DB. Background generation triggered from `engine.py`. New ReportView with 7 layered sections.
|
|
|
|
**Tech Stack:** Python/FastAPI (backend), Vue 3 + Tailwind (frontend), aiosqlite (storage), existing LLMClient
|
|
|
|
---
|
|
|
|
### Task 1: Add Report Storage to Store
|
|
|
|
**Files:**
|
|
- Modify: `backend/app/db/store.py:45-57` (add report table to `_init_sim_db`)
|
|
- Modify: `backend/app/db/store.py` (add new methods at end of file)
|
|
|
|
**Step 1: Add `report` table to `_init_sim_db`**
|
|
|
|
In `backend/app/db/store.py`, inside `_init_sim_db()`, after the existing `CREATE TABLE` and `CREATE INDEX` statements (around line 56), add:
|
|
|
|
```python
|
|
await db.execute("CREATE TABLE IF NOT EXISTS report (id INTEGER PRIMARY KEY CHECK (id = 1), status TEXT DEFAULT 'pending', report_json TEXT, error TEXT)")
|
|
```
|
|
|
|
**Step 2: Add report storage methods**
|
|
|
|
Add these methods to the `SimulationStore` class at the end of the file:
|
|
|
|
```python
|
|
async def set_report_status(self, sim_id: str, status: str, error: str | None = None) -> None:
|
|
async with aiosqlite.connect(self._sim_db_path(sim_id)) as db:
|
|
await db.execute(
|
|
"INSERT OR REPLACE INTO report (id, status, error) VALUES (1, ?, ?)",
|
|
(status, error),
|
|
)
|
|
await db.commit()
|
|
|
|
async def save_report(self, sim_id: str, report: dict) -> None:
|
|
async with aiosqlite.connect(self._sim_db_path(sim_id)) as db:
|
|
await db.execute(
|
|
"INSERT OR REPLACE INTO report (id, status, report_json) VALUES (1, 'ready', ?)",
|
|
(json.dumps(report, default=str),),
|
|
)
|
|
await db.commit()
|
|
|
|
async def get_report(self, sim_id: str) -> dict | None:
|
|
try:
|
|
async with aiosqlite.connect(self._sim_db_path(sim_id)) as db:
|
|
cursor = await db.execute("SELECT status, report_json, error FROM report WHERE id = 1")
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
return {"status": "pending"}
|
|
status, report_json, error = row
|
|
if status == "ready" and report_json:
|
|
return {"status": "ready", "report": json.loads(report_json)}
|
|
return {"status": status, "error": error}
|
|
except Exception:
|
|
return {"status": "pending"}
|
|
```
|
|
|
|
**Step 3: Verify**
|
|
|
|
Run: `cd backend && python -c "from app.db.store import SimulationStore; print('OK')"`
|
|
Expected: `OK`
|
|
|
|
---
|
|
|
|
### Task 2: Build Pass 1 — Data Aggregation (Pure Computation)
|
|
|
|
**Files:**
|
|
- Create: `backend/app/services/report_analyzer.py`
|
|
|
|
**Step 1: Create the ReportAnalyzer class**
|
|
|
|
Create `backend/app/services/report_analyzer.py`:
|
|
|
|
```python
|
|
from __future__ import annotations
|
|
import statistics
|
|
from collections import defaultdict
|
|
|
|
from app.models.action import ActionEntry, ActionType
|
|
from app.models.agent import AgentPersona
|
|
from app.models.world import WorldMetrics
|
|
|
|
|
|
METRIC_LABELS = {
|
|
"stability": "Stability",
|
|
"prosperity": "Prosperity",
|
|
"trust": "Trust",
|
|
"freedom": "Freedom",
|
|
"conflict": "Conflict",
|
|
"brand_sentiment": "Brand Sentiment",
|
|
"purchase_intent": "Purchase Intent",
|
|
"word_of_mouth": "Word of Mouth",
|
|
"churn_risk": "Churn Risk",
|
|
"adoption_rate": "Adoption Rate",
|
|
}
|
|
|
|
INVERSE_METRICS = {"conflict", "churn_risk"}
|
|
|
|
MARKET_METRICS = {"brand_sentiment", "purchase_intent", "word_of_mouth", "churn_risk", "adoption_rate"}
|
|
|
|
SOCIAL_METRICS = {"stability", "prosperity", "trust", "freedom", "conflict"}
|
|
|
|
|
|
def _rate(value_100: float, inverse: bool) -> str:
|
|
v = (100 - value_100) if inverse else value_100
|
|
if v >= 81:
|
|
return "excellent"
|
|
if v >= 61:
|
|
return "strong"
|
|
if v >= 41:
|
|
return "moderate"
|
|
if v >= 21:
|
|
return "weak"
|
|
return "critical"
|
|
|
|
|
|
def _trend(start: float, end: float) -> str:
|
|
delta = end - start
|
|
if delta > 0.05:
|
|
return "up"
|
|
if delta < -0.05:
|
|
return "down"
|
|
return "flat"
|
|
|
|
|
|
class ReportAnalyzer:
|
|
|
|
@staticmethod
|
|
def compute_metric_stats(metrics_history: list[dict], is_market: bool) -> list[dict]:
|
|
if not metrics_history:
|
|
return []
|
|
|
|
active_keys = set(SOCIAL_METRICS)
|
|
if is_market:
|
|
active_keys |= MARKET_METRICS
|
|
|
|
results = []
|
|
for key in active_keys:
|
|
values = [m.get(key, 0.5) for m in metrics_history]
|
|
start_val = values[0]
|
|
end_val = values[-1]
|
|
volatility = statistics.stdev(values) if len(values) > 1 else 0.0
|
|
|
|
value_100 = round(end_val * 100)
|
|
start_100 = round(start_val * 100)
|
|
inverse = key in INVERSE_METRICS
|
|
|
|
results.append({
|
|
"metric": key,
|
|
"label": METRIC_LABELS[key],
|
|
"value": value_100,
|
|
"start_value": start_100,
|
|
"end_raw": end_val,
|
|
"start_raw": start_val,
|
|
"min": round(min(values) * 100),
|
|
"max": round(max(values) * 100),
|
|
"trend": _trend(start_val, end_val),
|
|
"volatility": round(volatility, 4),
|
|
"rating": _rate(value_100, inverse),
|
|
"inverse": inverse,
|
|
})
|
|
|
|
return sorted(results, key=lambda x: x["metric"])
|
|
|
|
@staticmethod
|
|
def compute_agent_stats(agents: list[AgentPersona], actions: list[ActionEntry]) -> list[dict]:
|
|
action_counts: dict[int, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
speeches: dict[int, list[str]] = defaultdict(list)
|
|
thoughts: dict[int, list[str]] = defaultdict(list)
|
|
|
|
for a in actions:
|
|
action_counts[a.agent_id][a.action_type.value] += 1
|
|
if a.speech:
|
|
speeches[a.agent_id].append(a.speech)
|
|
if a.internal_thought:
|
|
thoughts[a.agent_id].append(a.internal_thought)
|
|
|
|
results = []
|
|
for agent in agents:
|
|
results.append({
|
|
"id": agent.id,
|
|
"name": agent.name,
|
|
"role": agent.role,
|
|
"faction": agent.faction,
|
|
"emotional_state": agent.emotional_state,
|
|
"personality": agent.personality.model_dump(),
|
|
"action_counts": dict(action_counts.get(agent.id, {})),
|
|
"speeches": speeches.get(agent.id, [])[-10:],
|
|
"thoughts": thoughts.get(agent.id, [])[-10:],
|
|
"total_actions": sum(action_counts.get(agent.id, {}).values()),
|
|
})
|
|
return results
|
|
|
|
@staticmethod
|
|
def compute_segment_stats(agent_stats: list[dict]) -> list[dict]:
|
|
segments: dict[str, list[dict]] = defaultdict(list)
|
|
for a in agent_stats:
|
|
seg_key = a["role"] or "General"
|
|
segments[seg_key].append(a)
|
|
|
|
results = []
|
|
for seg_name, seg_agents in segments.items():
|
|
total = len(seg_agents)
|
|
purchased = sum(1 for a in seg_agents if a["action_counts"].get("PURCHASE", 0) > 0)
|
|
abandoned = sum(1 for a in seg_agents if a["action_counts"].get("ABANDON", 0) > 0)
|
|
recommended = sum(1 for a in seg_agents if a["action_counts"].get("RECOMMEND", 0) > 0)
|
|
compared = sum(1 for a in seg_agents if a["action_counts"].get("COMPARE", 0) > 0)
|
|
|
|
adopted = purchased - abandoned if purchased > abandoned else 0
|
|
churned = abandoned
|
|
|
|
results.append({
|
|
"name": seg_name,
|
|
"agent_count": total,
|
|
"funnel": {
|
|
"aware": total,
|
|
"interested": purchased + compared + recommended,
|
|
"tried": purchased,
|
|
"adopted": adopted,
|
|
"churned": churned,
|
|
},
|
|
"adoption_pct": round((adopted / total) * 100) if total > 0 else 0,
|
|
"advocate_count": recommended,
|
|
"top_speeches": [],
|
|
"top_thoughts": [],
|
|
})
|
|
|
|
all_speeches = []
|
|
all_thoughts = []
|
|
for a in seg_agents:
|
|
all_speeches.extend(a["speeches"][-3:])
|
|
all_thoughts.extend(a["thoughts"][-3:])
|
|
results[-1]["top_speeches"] = all_speeches[-15:]
|
|
results[-1]["top_thoughts"] = all_thoughts[-15:]
|
|
|
|
return results
|
|
|
|
@staticmethod
|
|
def detect_inflection_points(metrics_history: list[dict], threshold: float = 0.05) -> list[dict]:
|
|
if len(metrics_history) < 2:
|
|
return []
|
|
|
|
points = []
|
|
metric_keys = list(METRIC_LABELS.keys())
|
|
|
|
for i in range(1, len(metrics_history)):
|
|
curr = metrics_history[i]
|
|
prev = metrics_history[i - 1]
|
|
round_num = curr.get("round", i)
|
|
day = round_num // 3 + 1
|
|
|
|
for key in metric_keys:
|
|
delta = abs(curr.get(key, 0.5) - prev.get(key, 0.5))
|
|
if delta >= threshold:
|
|
direction = "spike" if curr.get(key, 0.5) > prev.get(key, 0.5) else "drop"
|
|
points.append({
|
|
"day": day,
|
|
"round": round_num,
|
|
"metric": key,
|
|
"label": METRIC_LABELS[key],
|
|
"delta": round(delta, 4),
|
|
"direction": direction,
|
|
"value_before": round(prev.get(key, 0.5), 4),
|
|
"value_after": round(curr.get(key, 0.5), 4),
|
|
})
|
|
|
|
points.sort(key=lambda x: x["delta"], reverse=True)
|
|
return points[:20]
|
|
|
|
@staticmethod
|
|
def compute_confidence(metrics_history: list[dict], agent_count: int) -> str:
|
|
rounds = len(metrics_history)
|
|
if rounds >= 30 and agent_count >= 15:
|
|
return "high"
|
|
if rounds >= 15 and agent_count >= 8:
|
|
return "medium"
|
|
return "low"
|
|
```
|
|
|
|
**Step 2: Verify**
|
|
|
|
Run: `cd backend && python -c "from app.services.report_analyzer import ReportAnalyzer; print('OK')"`
|
|
Expected: `OK`
|
|
|
|
---
|
|
|
|
### Task 3: Build Passes 2-4 — LLM Analysis in Narrator
|
|
|
|
**Files:**
|
|
- Modify: `backend/app/services/narrator.py:44-71` (replace REPORT_TRENDS_SYSTEM)
|
|
- Modify: `backend/app/services/narrator.py:162-266` (replace generate_report)
|
|
|
|
**Step 1: Add new prompt templates**
|
|
|
|
In `backend/app/services/narrator.py`, replace the `REPORT_TRENDS_SYSTEM` prompt (lines 44-71) with these three new prompts:
|
|
|
|
```python
|
|
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"]
|
|
}}"""
|
|
```
|
|
|
|
**Step 2: Replace `generate_report` with the new multi-pass pipeline**
|
|
|
|
Replace the entire `generate_report` method (lines 162-266) with:
|
|
|
|
```python
|
|
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)
|
|
|
|
is_market = any(
|
|
a.action_type in (ActionType.PURCHASE, ActionType.ABANDON, ActionType.RECOMMEND, ActionType.COMPARE)
|
|
for a in actions
|
|
)
|
|
|
|
# --- Pass 0: Epoch summaries (existing logic) ---
|
|
epoch_size = 30
|
|
total_days = world_state.day
|
|
epochs = []
|
|
|
|
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})
|
|
|
|
summary = await self.summarize_epoch(
|
|
blueprint.name, blueprint.rules, epoch_narrs, m_start, m_end, start_day, end_day
|
|
)
|
|
epochs.append({"days": f"{start_day}-{end_day}", "summary": summary})
|
|
|
|
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 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]
|
|
)
|
|
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
|
|
)
|
|
|
|
system_p2 = INTERPRET_METRICS_SYSTEM.format(
|
|
world_name=blueprint.name,
|
|
rules="; ".join(blueprint.rules),
|
|
confidence=confidence,
|
|
)
|
|
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)}"
|
|
)
|
|
|
|
response_p2 = await self.llm.generate(system=system_p2, user=user_p2, json_mode=True, max_tokens=2000)
|
|
pass2 = parse_json(response_p2)
|
|
|
|
# Build scorecard by merging computed stats with LLM explanations
|
|
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"
|
|
|
|
system_p3 = SEGMENT_ANALYSIS_SYSTEM.format(
|
|
world_name=blueprint.name,
|
|
rules="; ".join(blueprint.rules),
|
|
)
|
|
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)
|
|
|
|
# Merge LLM segment analysis with computed funnel data
|
|
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
|
|
)
|
|
)
|
|
|
|
system_p4 = ACTION_SYNTHESIS_SYSTEM.format(
|
|
world_name=blueprint.name,
|
|
rules="; ".join(blueprint.rules),
|
|
)
|
|
|
|
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 ---
|
|
# Build narrative section from epoch summaries
|
|
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}",
|
|
})
|
|
|
|
return {
|
|
"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,
|
|
},
|
|
}
|
|
```
|
|
|
|
**Step 3: Add missing import at top of narrator.py**
|
|
|
|
Add `from app.models.action import ActionEntry, ReactiveResponse, ActionType` — update the existing import to include `ActionType`.
|
|
|
|
**Step 4: Verify**
|
|
|
|
Run: `cd backend && python -c "from app.services.narrator import Narrator; print('OK')"`
|
|
Expected: `OK`
|
|
|
|
---
|
|
|
|
### Task 4: Trigger Background Report Generation from Engine
|
|
|
|
**Files:**
|
|
- Modify: `backend/app/services/engine.py:305-313` (after simulation_complete event)
|
|
- Modify: `backend/app/api/simulate.py:186-191` (update report endpoint)
|
|
|
|
**Step 1: Add background report trigger in engine.py**
|
|
|
|
In `backend/app/services/engine.py`, after line 313 (`await self.store.update_status(simulation_id, SimulationStatus.COMPLETED.value)`), add:
|
|
|
|
```python
|
|
asyncio.create_task(self._generate_report_background(simulation_id))
|
|
```
|
|
|
|
Then add this method to the `SimulationEngine` class:
|
|
|
|
```python
|
|
async def _generate_report_background(self, simulation_id: str):
|
|
try:
|
|
await self.store.set_report_status(simulation_id, "generating")
|
|
report = await self.narrator.generate_report(simulation_id, self.store)
|
|
if "error" in report:
|
|
await self.store.set_report_status(simulation_id, "failed", error=report["error"])
|
|
else:
|
|
await self.store.save_report(simulation_id, report)
|
|
except Exception as e:
|
|
logger.error("Report generation failed for %s: %s", simulation_id, e, exc_info=True)
|
|
await self.store.set_report_status(simulation_id, "failed", error=str(e))
|
|
```
|
|
|
|
Note: The engine needs a reference to `self.narrator`. Check how narrator is initialized in the app — it may need to be passed to the engine constructor or accessed via `app.state`.
|
|
|
|
**Step 2: Update the report endpoint in simulate.py**
|
|
|
|
Replace the `get_report` endpoint (lines 186-191) with:
|
|
|
|
```python
|
|
@router.get("/simulation/{sim_id}/report")
|
|
async def get_report(sim_id: str, request: Request):
|
|
store = request.app.state.store
|
|
result = await store.get_report(sim_id)
|
|
if result.get("status") == "ready":
|
|
return result["report"]
|
|
return result
|
|
```
|
|
|
|
This way:
|
|
- If report is ready: returns the full report JSON (same shape as before, backwards compatible)
|
|
- If generating: returns `{"status": "generating"}`
|
|
- If failed: returns `{"status": "failed", "error": "..."}`
|
|
- If pending: returns `{"status": "pending"}`
|
|
|
|
**Step 3: Ensure engine has narrator access**
|
|
|
|
Check `backend/app/services/engine.py` constructor. If it doesn't have `self.narrator`, add it. Look at how the engine is instantiated (likely in `main.py` or app startup). The narrator is already on `app.state.narrator`, so either:
|
|
- Pass narrator to engine constructor, or
|
|
- Pass the narrator when creating the background task
|
|
|
|
**Step 4: Verify**
|
|
|
|
Run the backend server and confirm no import errors.
|
|
|
|
---
|
|
|
|
### Task 5: Rewrite ReportView.vue Frontend
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/views/ReportView.vue` (complete rewrite)
|
|
- Modify: `frontend/src/api/client.js:74-77` (update getReport for polling)
|
|
|
|
**Step 1: Update API client for polling**
|
|
|
|
In `frontend/src/api/client.js`, replace the `getReport` method:
|
|
|
|
```javascript
|
|
async getReport(simulationId) {
|
|
const { data } = await api.get(`/simulation/${simulationId}/report`)
|
|
return data
|
|
},
|
|
|
|
async pollReport(simulationId, interval = 3000, maxAttempts = 60) {
|
|
for (let i = 0; i < maxAttempts; i++) {
|
|
const data = await this.getReport(simulationId)
|
|
if (data.status === 'ready' || data.executive_brief) {
|
|
return data.executive_brief ? data : data.report
|
|
}
|
|
if (data.status === 'failed') {
|
|
throw new Error(data.error || 'Report generation failed')
|
|
}
|
|
await new Promise(r => setTimeout(r, interval))
|
|
}
|
|
throw new Error('Report generation timed out')
|
|
},
|
|
```
|
|
|
|
**Step 2: Rewrite ReportView.vue**
|
|
|
|
Replace the entire contents of `frontend/src/views/ReportView.vue` with the new analyst report layout. The view should have these sections in order:
|
|
|
|
1. **Loading state** — skeleton loader with "Analyzing simulation results..." when polling
|
|
2. **Executive Brief** — colored banner (green/amber/red), headline, summary, confidence badge, meta stats
|
|
3. **Scorecard** — 2-3 column grid of metric cards with rating pills, values as X/100, trend arrows, explanations
|
|
4. **Key Insights** — cards with colored left borders by type (opportunity=blue, risk=red, surprise=amber), with collapsible evidence tags
|
|
5. **Segment Deep-Dive** — per-segment cards with mini funnel bars, reaction, objection, champion, quote
|
|
6. **Action Items** — numbered priority cards (High=red, Medium=amber, Low=gray) with what/why/impact
|
|
7. **Risks & Effects** — risk cards with severity badges, second-order effects list
|
|
8. **Evidence Trail** — collapsible section with narrative, key moments timeline, metric sparklines
|
|
9. **Footer actions** — Run Again, Share Link, Publish to Gallery
|
|
|
|
Key implementation notes:
|
|
- Use `onMounted` to call `api.pollReport(route.params.id)` — this handles the polling automatically
|
|
- Verdict colors: go = emerald, caution = amber, rethink = red
|
|
- Rating pill colors: excellent/strong = emerald, moderate = amber, weak = orange, critical = red
|
|
- Funnel visualization: horizontal stacked bar using div widths proportional to funnel counts
|
|
- Evidence tags: small pills showing agent names and day numbers, collapsed by default
|
|
- Metric sparklines: simple div-based (like current but cleaner — tiny bars in a flex row)
|
|
- The view should handle both old report format (for backwards compat with existing sims) and new format (check for `executive_brief` key)
|
|
|
|
The full template, script, and styles should be written as a single-file Vue component using `<script setup>`, Composition API, and Tailwind classes consistent with the existing app style (dark theme, slate colors, emerald accents).
|
|
|
|
**Step 3: Verify**
|
|
|
|
Run: `cd frontend && npm run build`
|
|
Expected: Build succeeds with no errors.
|
|
|
|
---
|
|
|
|
### Task 6: Wire Up Engine → Narrator Dependency
|
|
|
|
**Files:**
|
|
- Modify: Backend app startup file (likely `backend/app/main.py` or wherever engine is instantiated)
|
|
|
|
**Step 1: Find where engine is created**
|
|
|
|
Search for where `SimulationEngine` is instantiated and ensure it has access to both `store` and `narrator`. The engine needs narrator for background report generation.
|
|
|
|
**Step 2: Pass narrator to engine**
|
|
|
|
If engine constructor doesn't accept narrator, add it:
|
|
|
|
```python
|
|
class SimulationEngine:
|
|
def __init__(self, store, narrator, llm, ...):
|
|
self.store = store
|
|
self.narrator = narrator
|
|
...
|
|
```
|
|
|
|
Update the instantiation site to pass narrator.
|
|
|
|
**Step 3: Verify**
|
|
|
|
Run: `cd backend && python -c "from app.services.engine import SimulationEngine; print('OK')"`
|
|
Expected: `OK`
|
|
|
|
---
|
|
|
|
### Task 7: End-to-End Verification
|
|
|
|
**Step 1: Start backend**
|
|
|
|
Run: `cd backend && python -m uvicorn app.main:app --reload`
|
|
Verify: Server starts without errors
|
|
|
|
**Step 2: Start frontend**
|
|
|
|
Run: `cd frontend && npm run dev`
|
|
Verify: Dev server starts without errors
|
|
|
|
**Step 3: Run a test simulation**
|
|
|
|
1. Create a market simulation with segments
|
|
2. Wait for completion
|
|
3. Verify report generates in background (check logs for "generating" → "ready" status)
|
|
4. Click "View Report" and verify new layout renders
|
|
5. Verify: Executive brief shows with verdict color
|
|
6. Verify: Scorecard shows metrics as X/100 with ratings and explanations
|
|
7. Verify: Insights section appears with typed cards
|
|
8. Verify: Segments show funnel data and quotes
|
|
9. Verify: Action items are numbered and prioritized
|
|
10. Verify: Evidence trail is collapsible
|
|
|
|
**Step 4: Test backwards compatibility**
|
|
|
|
Navigate to `/report/{old_sim_id}` for an existing simulation. The view should gracefully handle the old report format (fallback to showing what's available).
|
|
|