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.
 
 
 
 
 
mirosociety/docs/plans/2026-03-17-analyst-report-i...

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:

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:

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:

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:

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:

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:

asyncio.create_task(self._generate_report_background(simulation_id))

Then add this method to the SimulationEngine class:

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:

@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:

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:

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).