Arijit-07 Claude Sonnet 4.6 commited on
Commit
e004aac
Β·
1 Parent(s): 9c9b989

feat: replay system, human challenge, progress viz, 2 new generator failure modes

Browse files

- Feature 1: Episode Replay System
- replay_store dict + replay_counter track completed episodes
- /step endpoint accumulates per-step data (action, reward, obs summary)
- On done=True, full replay saved with timeline steps
- GET /replays β€” JSON list of replays, newest first
- GET /replay/{id} β€” full replay data with steps
- GET /replay/{id}/html β€” dark-theme HTML timeline visualization
- GET /replays/html β€” HTML index page

- Feature 2: Human vs Agent Challenge
- GET /challenge β€” fully interactive HTML page to play incidents
- Auto-resets to easy task seed 1234 on load
- Action form with all 14 action types, context-sensitive fields
- Live reward feedback per action, running score display
- End-of-episode comparison panel: human score vs agent avg from /metrics

- Feature 3: Progress Visualization
- GET /progress β€” dark-theme HTML progress dashboard
- Section 1: Live task vs random baseline bar chart (CSS-only, no JS)
- Section 2: Hardcoded training results table (baseline β†’ fine-tuned)
- Section 3: Live episode score timeline via canvas + 30s auto-refresh

- Feature 4: Two new IncidentFactory failure modes
- network_latency: latency-only degradation, difficulty=0.55
distractor noise: maintenance window INFO alert
- config_drift: healthy-looking service with wrong config, difficulty=0.75
distractor noise: unrelated high-memory alert
- Both use rollback as optimal fix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (2) hide show
  1. generator/incident_factory.py +30 -6
  2. server/app.py +738 -0
generator/incident_factory.py CHANGED
@@ -1,7 +1,7 @@
1
  import random
2
 
3
  class IncidentFactory:
4
- FAILURE_MODES = ["oom", "cascade", "corruption", "security", "database", "network_partition"]
5
  SERVICES = ["payment-service", "order-service", "user-service",
6
  "inventory-service", "api-gateway", "notification-service",
7
  "data-pipeline", "ml-inference-service"]
@@ -13,6 +13,15 @@ class IncidentFactory:
13
  "CDN cache warming after deployment",
14
  "Routine health check latency spike"
15
  ]
 
 
 
 
 
 
 
 
 
16
 
17
  def generate(self, seed: int) -> dict:
18
  rng = random.Random(seed)
@@ -20,7 +29,15 @@ class IncidentFactory:
20
  affected_service = rng.choice(self.SERVICES)
21
  severity = rng.choice(self.SEVERITIES)
22
  noise_count = rng.randint(0, 3)
23
- noise_alerts = rng.sample(self.NOISE_ALERTS, min(noise_count, len(self.NOISE_ALERTS)))
 
 
 
 
 
 
 
 
24
 
25
  # Templates for description
26
  descriptions = {
@@ -29,7 +46,9 @@ class IncidentFactory:
29
  "corruption": f"Silent data corruption in {affected_service}. Invalid records being written. No error-rate alerts β€” signal buried in business metrics.",
30
  "security": f"Credential stuffing botnet targeting {affected_service} from IP range 185.220.x.x.",
31
  "database": f"Missing index on {affected_service} database after migration. Full table scans degrading all queries.",
32
- "network_partition": f"Network partition isolating {affected_service} in us-east-1. Failover decision required."
 
 
33
  }
34
  description = descriptions[failure_mode]
35
 
@@ -40,7 +59,9 @@ class IncidentFactory:
40
  "corruption": f"Data corruption in {affected_service} writing invalid records",
41
  "security": f"DDoS credential stuffing from 185.220.0.0/16 targeting {affected_service}",
42
  "database": f"Missing index causing sequential scans on {affected_service}",
43
- "network_partition": f"Network partition in us-east-1 affecting {affected_service}"
 
 
44
  }
45
  ground_truth_root_cause = root_causes[failure_mode]
46
 
@@ -51,14 +72,17 @@ class IncidentFactory:
51
  "corruption": "rollback",
52
  "security": "block_ip_range",
53
  "database": "create_index",
54
- "network_partition": "failover"
 
 
55
  }
56
  ground_truth_fix = fixes[failure_mode]
57
 
58
  # Difficulty score
59
  base_scores = {
60
  "oom": 0.2, "cascade": 0.5, "corruption": 0.8,
61
- "security": 0.6, "database": 0.6, "network_partition": 0.7
 
62
  }
63
  score = base_scores[failure_mode] + (noise_count * 0.05)
64
  score = min(score, 1.0)
 
1
  import random
2
 
3
  class IncidentFactory:
4
+ FAILURE_MODES = ["oom", "cascade", "corruption", "security", "database", "network_partition", "network_latency", "config_drift"]
5
  SERVICES = ["payment-service", "order-service", "user-service",
6
  "inventory-service", "api-gateway", "notification-service",
7
  "data-pipeline", "ml-inference-service"]
 
13
  "CDN cache warming after deployment",
14
  "Routine health check latency spike"
15
  ]
16
+ NOISE_ALERTS_NETWORK_LATENCY = [
17
+ "INFO: Scheduled maintenance window active β€” some latency variation expected",
18
+ "CDN edge node rotation in progress",
19
+ "Routine health check latency spike",
20
+ ]
21
+ NOISE_ALERTS_CONFIG_DRIFT = [
22
+ "WARNING: data-pipeline memory usage elevated (85%) β€” not yet critical",
23
+ "INFO: Scheduled batch job running β€” high CPU expected",
24
+ ]
25
 
26
  def generate(self, seed: int) -> dict:
27
  rng = random.Random(seed)
 
29
  affected_service = rng.choice(self.SERVICES)
30
  severity = rng.choice(self.SEVERITIES)
31
  noise_count = rng.randint(0, 3)
32
+ if failure_mode == "network_latency":
33
+ noise_pool = self.NOISE_ALERTS_NETWORK_LATENCY
34
+ noise_count = min(noise_count + 1, len(noise_pool))
35
+ elif failure_mode == "config_drift":
36
+ noise_pool = self.NOISE_ALERTS_CONFIG_DRIFT
37
+ noise_count = min(noise_count + 1, len(noise_pool))
38
+ else:
39
+ noise_pool = self.NOISE_ALERTS
40
+ noise_alerts = rng.sample(noise_pool, min(noise_count, len(noise_pool)))
41
 
42
  # Templates for description
43
  descriptions = {
 
46
  "corruption": f"Silent data corruption in {affected_service}. Invalid records being written. No error-rate alerts β€” signal buried in business metrics.",
47
  "security": f"Credential stuffing botnet targeting {affected_service} from IP range 185.220.x.x.",
48
  "database": f"Missing index on {affected_service} database after migration. Full table scans degrading all queries.",
49
+ "network_partition": f"Network partition isolating {affected_service} in us-east-1. Failover decision required.",
50
+ "network_latency": f"Gradual latency degradation in {affected_service}. All services remain running but P99 latency is 10–50x normal. No crashes, no OOM β€” signal is purely in latency metrics.",
51
+ "config_drift": f"{affected_service} deployed with incorrect environment variables. Service appears healthy β€” error rate is low β€” but outputs are wrong. Signal buried in CONFIG_ERROR log warnings and downstream data validation failures.",
52
  }
53
  description = descriptions[failure_mode]
54
 
 
59
  "corruption": f"Data corruption in {affected_service} writing invalid records",
60
  "security": f"DDoS credential stuffing from 185.220.0.0/16 targeting {affected_service}",
61
  "database": f"Missing index causing sequential scans on {affected_service}",
62
+ "network_partition": f"Network partition in us-east-1 affecting {affected_service}",
63
+ "network_latency": f"Network latency degradation in {affected_service}",
64
+ "config_drift": f"Config drift in {affected_service} writing incorrect output",
65
  }
66
  ground_truth_root_cause = root_causes[failure_mode]
67
 
 
72
  "corruption": "rollback",
73
  "security": "block_ip_range",
74
  "database": "create_index",
75
+ "network_partition": "failover",
76
+ "network_latency": "rollback",
77
+ "config_drift": "rollback",
78
  }
79
  ground_truth_fix = fixes[failure_mode]
80
 
81
  # Difficulty score
82
  base_scores = {
83
  "oom": 0.2, "cascade": 0.5, "corruption": 0.8,
84
+ "security": 0.6, "database": 0.6, "network_partition": 0.7,
85
+ "network_latency": 0.55, "config_drift": 0.75,
86
  }
87
  score = base_scores[failure_mode] + (noise_count * 0.05)
88
  score = min(score, 1.0)
server/app.py CHANGED
@@ -20,6 +20,10 @@ multi_agent_sessions: dict = {}
20
 
21
  episode_history = deque(maxlen=1000)
22
 
 
 
 
 
23
  def track_episode(state_obj: State):
24
  from graders.grader import grade_episode
25
  score = grade_episode(
@@ -1436,6 +1440,7 @@ async def reset(req: Optional[ResetRequest] = None):
1436
  status_code=400,
1437
  detail=f"task_id must be one of {VALID_TASKS} or 'generated'. Got: {req.task_id}",
1438
  )
 
1439
  return await _env.reset(seed=req.seed, task_id=req.task_id)
1440
 
1441
 
@@ -1469,11 +1474,41 @@ async def step(action: Action):
1469
  Side effects:
1470
  On done=True, records the episode in the leaderboard and metrics history.
1471
  """
 
1472
  if _env._logic is None:
1473
  raise HTTPException(status_code=400, detail="Call /reset before /step")
1474
  res = await _env.step(action)
 
 
 
 
 
 
 
 
 
 
 
 
 
1475
  if res.done:
1476
  track_episode(_env.state)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1477
  return res
1478
 
1479
 
@@ -2048,3 +2083,706 @@ def get_curriculum_hint(task_id: str):
2048
  }
2049
  except ValueError as exc:
2050
  raise HTTPException(status_code=400, detail=str(exc))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  episode_history = deque(maxlen=1000)
22
 
23
+ replay_store: dict = {}
24
+ replay_counter: int = 0
25
+ current_episode_steps: list = []
26
+
27
  def track_episode(state_obj: State):
28
  from graders.grader import grade_episode
29
  score = grade_episode(
 
1440
  status_code=400,
1441
  detail=f"task_id must be one of {VALID_TASKS} or 'generated'. Got: {req.task_id}",
1442
  )
1443
+ current_episode_steps.clear()
1444
  return await _env.reset(seed=req.seed, task_id=req.task_id)
1445
 
1446
 
 
1474
  Side effects:
1475
  On done=True, records the episode in the leaderboard and metrics history.
1476
  """
1477
+ global replay_counter
1478
  if _env._logic is None:
1479
  raise HTTPException(status_code=400, detail="Call /reset before /step")
1480
  res = await _env.step(action)
1481
+
1482
+ step_data = {
1483
+ "step": len(current_episode_steps),
1484
+ "action": action.dict(),
1485
+ "reward": res.reward,
1486
+ "observation_summary": {
1487
+ "failing_services": [s.name for s in res.observation.services if s.status in ("down", "degraded")],
1488
+ "alert_count": len(res.observation.active_alerts),
1489
+ "evidence_count": len(res.observation.evidence_log),
1490
+ },
1491
+ }
1492
+ current_episode_steps.append(step_data)
1493
+
1494
  if res.done:
1495
  track_episode(_env.state)
1496
+ state = _env.state
1497
+ replay_store[str(replay_counter)] = {
1498
+ "episode_id": str(replay_counter),
1499
+ "task_id": state.task_id,
1500
+ "seed": state.info.get("seed", 0),
1501
+ "final_score": round(state.total_reward, 3),
1502
+ "resolved": state.incident_resolved,
1503
+ "total_steps": state.step,
1504
+ "timestamp": datetime.utcnow().isoformat(),
1505
+ "steps": list(current_episode_steps),
1506
+ }
1507
+ replay_counter += 1
1508
+ if len(replay_store) > 20:
1509
+ oldest = min(replay_store.keys(), key=int)
1510
+ del replay_store[oldest]
1511
+
1512
  return res
1513
 
1514
 
 
2083
  }
2084
  except ValueError as exc:
2085
  raise HTTPException(status_code=400, detail=str(exc))
2086
+
2087
+
2088
+ # ─── Feature 1: Episode Replay System ────────────────────────────────────────
2089
+
2090
+ @app.get("/replays")
2091
+ def list_replays():
2092
+ """List available episode replays, newest first."""
2093
+ items = []
2094
+ for ep_id, r in replay_store.items():
2095
+ items.append({
2096
+ "episode_id": r["episode_id"],
2097
+ "task_id": r["task_id"],
2098
+ "score": r["final_score"],
2099
+ "resolved": r["resolved"],
2100
+ "total_steps": r["total_steps"],
2101
+ "timestamp": r["timestamp"],
2102
+ })
2103
+ items.sort(key=lambda x: int(x["episode_id"]), reverse=True)
2104
+ return items
2105
+
2106
+
2107
+ @app.get("/replay/{episode_id}")
2108
+ def get_replay(episode_id: str):
2109
+ """Return full replay data for an episode."""
2110
+ if episode_id not in replay_store:
2111
+ raise HTTPException(status_code=404, detail="Episode not found")
2112
+ return replay_store[episode_id]
2113
+
2114
+
2115
+ @app.get("/replay/{episode_id}/html", response_class=HTMLResponse)
2116
+ def get_replay_html(episode_id: str):
2117
+ """Return an HTML timeline visualization of an episode replay."""
2118
+ if episode_id not in replay_store:
2119
+ raise HTTPException(status_code=404, detail="Episode not found")
2120
+ r = replay_store[episode_id]
2121
+
2122
+ def reward_color(reward: float) -> str:
2123
+ if reward > 0.2:
2124
+ return "#00ff88"
2125
+ if reward > 0:
2126
+ return "#ffaa00"
2127
+ if reward == 0:
2128
+ return "#ff3355"
2129
+ return "#ff3355"
2130
+
2131
+ def reward_bg(reward: float) -> str:
2132
+ if reward > 0.2:
2133
+ return "rgba(0,255,136,0.08)"
2134
+ if reward > 0:
2135
+ return "rgba(255,170,0,0.08)"
2136
+ return "rgba(255,51,85,0.08)"
2137
+
2138
+ steps_html = ""
2139
+ running_score = 0.0
2140
+ for s in r["steps"]:
2141
+ running_score += s["reward"]
2142
+ act = s["action"]
2143
+ act_type = act.get("action_type", "unknown")
2144
+ service = act.get("service") or ""
2145
+ rc = act.get("root_cause") or ""
2146
+ obs = s["observation_summary"]
2147
+ col = reward_color(s["reward"])
2148
+ bg = reward_bg(s["reward"])
2149
+ reward_sign = "+" if s["reward"] > 0 else ""
2150
+ failing = ", ".join(obs["failing_services"]) if obs["failing_services"] else "none"
2151
+ steps_html += f"""
2152
+ <div class="step-card" style="border-left-color:{col}; background:{bg}">
2153
+ <div class="step-header">
2154
+ <span class="step-num mono">STEP {s['step'] + 1}</span>
2155
+ <span class="step-action mono">{act_type} {service}</span>
2156
+ <span class="step-reward mono" style="color:{col}">{reward_sign}{s['reward']:.3f}</span>
2157
+ <span class="step-running mono" style="color:#4d9fff">&sum; {running_score:.3f}</span>
2158
+ </div>
2159
+ {"<div class='step-rc'>&rarr; " + rc + "</div>" if rc and act_type == "diagnose" else ""}
2160
+ <div class="step-obs mono">
2161
+ failing: {failing} &nbsp;|&nbsp;
2162
+ alerts: {obs['alert_count']} &nbsp;|&nbsp;
2163
+ evidence: {obs['evidence_count']}
2164
+ </div>
2165
+ </div>"""
2166
+
2167
+ resolved_color = "#00ff88" if r["resolved"] else "#ff3355"
2168
+ resolved_text = "INCIDENT RESOLVED" if r["resolved"] else "INCIDENT ESCALATED"
2169
+ resolved_icon = "&#10003;" if r["resolved"] else "&#10007;"
2170
+ score_col = "#00ff88" if r["final_score"] >= 0.7 else "#ffaa00" if r["final_score"] >= 0.4 else "#ff3355"
2171
+
2172
+ html = f"""<!DOCTYPE html>
2173
+ <html lang="en">
2174
+ <head>
2175
+ <meta charset="UTF-8">
2176
+ <title>ARIA Replay #{episode_id}</title>
2177
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
2178
+ <style>
2179
+ :root {{--bg:#060914;--surface:#0a0f1e;--surface2:#0d1628;--border:#1a2744;--blue:#4d9fff;--cyan:#00d4ff;--green:#00ff88;--yellow:#ffaa00;--red:#ff3355;--text:#c8d8f0;--text-dim:#4a6080;}}
2180
+ *{{box-sizing:border-box;margin:0;padding:0;}}
2181
+ body{{background:var(--bg);color:var(--text);font-family:'Inter',sans-serif;padding:24px;max-width:900px;margin:0 auto;}}
2182
+ .mono{{font-family:'Share Tech Mono',monospace;}}
2183
+ .header{{background:var(--surface);border:1px solid var(--border);padding:24px;margin-bottom:24px;}}
2184
+ .header-title{{font-size:22px;color:var(--blue);font-weight:700;margin-bottom:8px;}}
2185
+ .header-meta{{display:flex;gap:24px;flex-wrap:wrap;}}
2186
+ .meta-item{{font-size:13px;color:var(--text-dim);}}
2187
+ .meta-value{{color:var(--text);font-weight:600;}}
2188
+ .step-card{{border-left:3px solid;padding:12px 16px;margin-bottom:8px;border-radius:0 4px 4px 0;}}
2189
+ .step-header{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:4px;}}
2190
+ .step-num{{font-size:11px;color:var(--text-dim);min-width:60px;}}
2191
+ .step-action{{font-size:13px;color:var(--cyan);flex:1;}}
2192
+ .step-reward{{font-size:14px;font-weight:700;}}
2193
+ .step-running{{font-size:11px;}}
2194
+ .step-rc{{font-size:12px;color:var(--yellow);padding:4px 0;}}
2195
+ .step-obs{{font-size:11px;color:var(--text-dim);margin-top:4px;}}
2196
+ .resolution-banner{{text-align:center;padding:32px;margin-top:24px;border:2px solid;font-size:24px;font-weight:700;}}
2197
+ .nav-links{{display:flex;gap:16px;margin-bottom:24px;font-size:13px;}}
2198
+ .nav-links a{{color:var(--blue);text-decoration:none;}}
2199
+ .nav-links a:hover{{color:var(--cyan);}}
2200
+ h2{{font-size:14px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.1em;margin-bottom:16px;}}
2201
+ </style>
2202
+ </head>
2203
+ <body>
2204
+ <div class="nav-links">
2205
+ <a href="/replays/html">&larr; All Replays</a>
2206
+ <a href="/live">Live NOC</a>
2207
+ <a href="/progress">Progress</a>
2208
+ <a href="/docs">API Docs</a>
2209
+ </div>
2210
+ <div class="header">
2211
+ <div class="header-title">&#9635; Episode Replay #{episode_id}</div>
2212
+ <div class="header-meta">
2213
+ <div class="meta-item">TASK <span class="meta-value mono">{r['task_id'].upper()}</span></div>
2214
+ <div class="meta-item">SEED <span class="meta-value mono">{r['seed']}</span></div>
2215
+ <div class="meta-item">SCORE <span class="meta-value mono" style="color:{score_col}">{r['final_score']:.3f}</span></div>
2216
+ <div class="meta-item">STEPS <span class="meta-value mono">{r['total_steps']}</span></div>
2217
+ <div class="meta-item">TIME <span class="meta-value mono">{r['timestamp'][:19]}</span></div>
2218
+ </div>
2219
+ </div>
2220
+ <h2>Step Timeline</h2>
2221
+ {steps_html if steps_html else '<div style="color:var(--text-dim);padding:24px">No steps recorded.</div>'}
2222
+ <div class="resolution-banner mono" style="color:{resolved_color};border-color:{resolved_color};background:{'rgba(0,255,136,0.05)' if r['resolved'] else 'rgba(255,51,85,0.05)'}">
2223
+ {resolved_icon} {resolved_text} &nbsp;|&nbsp; Final Score: {r['final_score']:.3f}
2224
+ </div>
2225
+ </body>
2226
+ </html>"""
2227
+ return HTMLResponse(html)
2228
+
2229
+
2230
+ @app.get("/replays/html", response_class=HTMLResponse)
2231
+ def list_replays_html():
2232
+ """HTML index of all replays."""
2233
+ items = sorted(replay_store.values(), key=lambda x: int(x["episode_id"]), reverse=True)
2234
+ rows = ""
2235
+ for r in items:
2236
+ score_col = "#00ff88" if r["final_score"] >= 0.7 else "#ffaa00" if r["final_score"] >= 0.4 else "#ff3355"
2237
+ resolved_icon = "&#10003;" if r["resolved"] else "&#10007;"
2238
+ rows += f"""<tr>
2239
+ <td class="mono"><a href="/replay/{r['episode_id']}/html" style="color:#4d9fff">#{r['episode_id']}</a></td>
2240
+ <td class="mono">{r['task_id'].upper()}</td>
2241
+ <td class="mono" style="color:{score_col}">{r['final_score']:.3f}</td>
2242
+ <td class="mono" style="color:{'#00ff88' if r['resolved'] else '#ff3355'}">{resolved_icon}</td>
2243
+ <td class="mono">{r['total_steps']}</td>
2244
+ <td class="mono" style="color:#4a6080">{r['timestamp'][:19]}</td>
2245
+ </tr>"""
2246
+ if not rows:
2247
+ rows = '<tr><td colspan="6" style="text-align:center;color:#4a6080;padding:32px">No replays yet. Complete an episode to generate one.</td></tr>'
2248
+ html = f"""<!DOCTYPE html>
2249
+ <html lang="en">
2250
+ <head>
2251
+ <meta charset="UTF-8">
2252
+ <title>ARIA Episode Replays</title>
2253
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
2254
+ <style>
2255
+ :root{{--bg:#060914;--surface:#0a0f1e;--border:#1a2744;--blue:#4d9fff;--text:#c8d8f0;--text-dim:#4a6080;}}
2256
+ *{{box-sizing:border-box;margin:0;padding:0;}}
2257
+ body{{background:var(--bg);color:var(--text);font-family:'Inter',sans-serif;padding:32px;max-width:900px;margin:0 auto;}}
2258
+ .mono{{font-family:'Share Tech Mono',monospace;}}
2259
+ h1{{font-size:22px;color:var(--blue);margin-bottom:24px;}}
2260
+ table{{width:100%;border-collapse:collapse;}}
2261
+ th{{text-align:left;font-size:11px;color:var(--text-dim);text-transform:uppercase;padding:8px 12px;border-bottom:1px solid var(--border);}}
2262
+ td{{padding:10px 12px;border-bottom:1px solid rgba(26,39,68,0.5);font-size:13px;}}
2263
+ tr:hover td{{background:rgba(77,159,255,0.04);}}
2264
+ .nav-links{{display:flex;gap:16px;margin-bottom:24px;font-size:13px;}}
2265
+ .nav-links a{{color:var(--blue);text-decoration:none;}}
2266
+ </style>
2267
+ </head>
2268
+ <body>
2269
+ <div class="nav-links"><a href="/live">Live NOC</a><a href="/progress">Progress</a><a href="/challenge">Challenge</a></div>
2270
+ <h1>&#9635; Episode Replays</h1>
2271
+ <table>
2272
+ <thead><tr><th>ID</th><th>Task</th><th>Score</th><th>Resolved</th><th>Steps</th><th>Time</th></tr></thead>
2273
+ <tbody>{rows}</tbody>
2274
+ </table>
2275
+ </body>
2276
+ </html>"""
2277
+ return HTMLResponse(html)
2278
+
2279
+
2280
+ # ─── Feature 2: Human vs Agent Challenge ─────────────────────────────────────
2281
+
2282
+ @app.get("/challenge", response_class=HTMLResponse)
2283
+ async def challenge_page():
2284
+ """Human-playable incident response challenge."""
2285
+ html = """<!DOCTYPE html>
2286
+ <html lang="en">
2287
+ <head>
2288
+ <meta charset="UTF-8">
2289
+ <title>ARIA Human Challenge</title>
2290
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
2291
+ <style>
2292
+ :root {
2293
+ --bg:#060914;--surface:#0a0f1e;--surface2:#0d1628;--border:#1a2744;
2294
+ --border-bright:#2a4080;--blue:#4d9fff;--cyan:#00d4ff;--green:#00ff88;
2295
+ --yellow:#ffaa00;--red:#ff3355;--purple:#9d4edd;--text:#c8d8f0;--text-dim:#4a6080;
2296
+ }
2297
+ *{box-sizing:border-box;margin:0;padding:0;}
2298
+ body{background:var(--bg);color:var(--text);font-family:'Inter',sans-serif;min-height:100vh;}
2299
+ .mono{font-family:'Share Tech Mono',monospace;}
2300
+ .nav{background:#000;border-bottom:1px solid var(--border);padding:0 24px;height:48px;display:flex;align-items:center;justify-content:space-between;}
2301
+ .nav-logo{font-size:16px;color:var(--blue);font-weight:700;}
2302
+ .nav-links{display:flex;gap:16px;font-size:12px;}
2303
+ .nav-links a{color:var(--text-dim);text-decoration:none;}
2304
+ .nav-links a:hover{color:var(--blue);}
2305
+ .warning{background:rgba(255,170,0,0.1);border-bottom:1px solid var(--yellow);padding:8px 24px;font-size:12px;color:var(--yellow);text-align:center;}
2306
+ .main{display:grid;grid-template-columns:1fr 380px;height:calc(100vh - 80px);}
2307
+ .left{padding:20px;overflow-y:auto;border-right:1px solid var(--border);}
2308
+ .right{padding:20px;overflow-y:auto;background:var(--surface);}
2309
+ .panel{background:var(--surface2);border:1px solid var(--border);padding:16px;margin-bottom:16px;}
2310
+ .panel-title{font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.1em;margin-bottom:12px;}
2311
+ table{width:100%;border-collapse:collapse;font-size:12px;}
2312
+ th{text-align:left;color:var(--text-dim);font-size:10px;text-transform:uppercase;padding:4px 8px;border-bottom:1px solid var(--border);}
2313
+ td{padding:6px 8px;font-family:'Share Tech Mono',monospace;font-size:12px;border-bottom:1px solid rgba(26,39,68,0.5);}
2314
+ .status-down{color:var(--red);}
2315
+ .status-degraded{color:var(--yellow);}
2316
+ .status-healthy{color:var(--green);}
2317
+ .alert-item{padding:6px 10px;border-left:3px solid;margin-bottom:6px;font-size:12px;}
2318
+ .sev-critical{border-color:var(--red);background:rgba(255,51,85,0.08);}
2319
+ .sev-high{border-color:#ff6600;background:rgba(255,102,0,0.08);}
2320
+ .sev-warning{border-color:var(--yellow);background:rgba(255,170,0,0.08);}
2321
+ .sev-info{border-color:var(--blue);background:rgba(77,159,255,0.08);}
2322
+ .evidence-item{font-size:11px;color:var(--text-dim);padding:4px 0;border-bottom:1px solid rgba(26,39,68,0.3);}
2323
+ .form-row{margin-bottom:12px;}
2324
+ label{display:block;font-size:11px;color:var(--text-dim);text-transform:uppercase;margin-bottom:4px;}
2325
+ select,input[type=text]{width:100%;background:var(--surface);border:1px solid var(--border-bright);color:var(--text);font-family:'Share Tech Mono',monospace;font-size:13px;padding:8px 10px;outline:none;}
2326
+ select:focus,input[type=text]:focus{border-color:var(--blue);}
2327
+ .btn-action{width:100%;background:rgba(77,159,255,0.15);border:1px solid var(--blue);color:var(--blue);font-family:'Share Tech Mono',monospace;font-size:14px;padding:12px;cursor:pointer;transition:0.2s;margin-top:8px;text-transform:uppercase;letter-spacing:0.1em;}
2328
+ .btn-action:hover{background:var(--blue);color:#000;}
2329
+ .btn-action:disabled{opacity:0.4;cursor:not-allowed;}
2330
+ .btn-reset{background:rgba(255,51,85,0.1);border:1px solid var(--red);color:var(--red);}
2331
+ .btn-reset:hover{background:var(--red);color:#000;}
2332
+ .score-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;}
2333
+ .big-score{font-size:36px;font-weight:700;font-family:'Share Tech Mono',monospace;}
2334
+ .step-info{font-size:12px;color:var(--text-dim);}
2335
+ .reward-flash{padding:8px 12px;margin-bottom:8px;font-size:14px;font-family:'Share Tech Mono',monospace;border-left:3px solid;}
2336
+ .result-log{max-height:150px;overflow-y:auto;font-size:11px;color:var(--text-dim);font-family:'Share Tech Mono',monospace;background:var(--surface);padding:8px;margin-top:8px;white-space:pre-wrap;}
2337
+ .comparison{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px;}
2338
+ .cmp-card{background:var(--surface2);border:1px solid var(--border);padding:20px;text-align:center;}
2339
+ .cmp-label{font-size:11px;color:var(--text-dim);text-transform:uppercase;margin-bottom:8px;}
2340
+ .cmp-score{font-size:32px;font-weight:700;font-family:'Share Tech Mono',monospace;}
2341
+ .cmp-steps{font-size:12px;color:var(--text-dim);margin-top:4px;}
2342
+ .done-banner{text-align:center;padding:24px;border:2px solid;font-size:20px;font-weight:700;margin-bottom:16px;}
2343
+ ::-webkit-scrollbar{width:4px;}::-webkit-scrollbar-thumb{background:var(--border-bright);}
2344
+ </style>
2345
+ </head>
2346
+ <body>
2347
+ <div class="nav">
2348
+ <div class="nav-logo mono">&#9635; ARIA HUMAN CHALLENGE</div>
2349
+ <div class="nav-links">
2350
+ <a href="/live">Live NOC</a>
2351
+ <a href="/progress">Progress</a>
2352
+ <a href="/replays/html">Replays</a>
2353
+ <a href="/docs">API</a>
2354
+ </div>
2355
+ </div>
2356
+ <div class="warning">&#9888; Note: This resets the shared environment &mdash; agent runs will be interrupted while you play</div>
2357
+ <div class="main">
2358
+ <div class="left">
2359
+ <div class="panel" id="task-desc-panel" style="display:none">
2360
+ <div class="panel-title">&#9658; Task Description</div>
2361
+ <div id="task-desc" style="font-size:13px;color:#8ab4d4"></div>
2362
+ </div>
2363
+ <div class="panel">
2364
+ <div class="panel-title">&#9658; Infrastructure Status</div>
2365
+ <table>
2366
+ <thead><tr><th>Service</th><th>Status</th><th>CPU%</th><th>Mem%</th><th>Err/s</th><th>P99ms</th></tr></thead>
2367
+ <tbody id="svc-tbody"><tr><td colspan="6" style="color:var(--text-dim);padding:16px">Loading...</td></tr></tbody>
2368
+ </table>
2369
+ </div>
2370
+ <div class="panel">
2371
+ <div class="panel-title">&#9658; Active Alerts <span id="alert-count" style="color:var(--red)"></span></div>
2372
+ <div id="alerts-container"><div style="color:var(--text-dim);font-size:12px">No alerts</div></div>
2373
+ </div>
2374
+ <div class="panel">
2375
+ <div class="panel-title">&#9658; Evidence Gathered <span id="evidence-count" style="color:var(--blue)"></span></div>
2376
+ <div id="evidence-container"><div style="color:var(--text-dim);font-size:12px">No evidence yet</div></div>
2377
+ </div>
2378
+ <div id="done-panel" style="display:none">
2379
+ <div class="done-banner mono" id="done-banner"></div>
2380
+ <div class="comparison">
2381
+ <div class="cmp-card">
2382
+ <div class="cmp-label">Your Score</div>
2383
+ <div class="cmp-score" id="human-score">&mdash;</div>
2384
+ <div class="cmp-steps" id="human-steps"></div>
2385
+ </div>
2386
+ <div class="cmp-card">
2387
+ <div class="cmp-label">Trained Agent (Llama-3.1-8B)</div>
2388
+ <div class="cmp-score" id="agent-score">&mdash;</div>
2389
+ <div class="cmp-steps">from /metrics avg</div>
2390
+ </div>
2391
+ </div>
2392
+ <button class="btn-action" onclick="startGame(true)" style="margin-top:16px;width:100%">&#9654; Play Again (Random Seed)</button>
2393
+ </div>
2394
+ </div>
2395
+ <div class="right">
2396
+ <div class="score-header">
2397
+ <div>
2398
+ <div class="panel-title">Episode Progress</div>
2399
+ <div class="big-score mono" id="score-display" style="color:var(--text-dim)">0.000</div>
2400
+ </div>
2401
+ <div style="text-align:right">
2402
+ <div class="step-info mono" id="step-display">Step 0 / 15</div>
2403
+ <div class="step-info mono" id="task-display" style="color:var(--blue)">EASY</div>
2404
+ </div>
2405
+ </div>
2406
+ <div class="panel">
2407
+ <div class="panel-title">&#9658; Take Action</div>
2408
+ <div class="form-row">
2409
+ <label>Action Type</label>
2410
+ <select id="action-type" onchange="onActionTypeChange()">
2411
+ <option value="read_logs">read_logs &mdash; Read full log output</option>
2412
+ <option value="search_logs">search_logs &mdash; Search logs for keyword</option>
2413
+ <option value="read_metrics">read_metrics &mdash; Read CPU/mem/latency</option>
2414
+ <option value="read_runbook">read_runbook &mdash; Open a runbook</option>
2415
+ <option value="diagnose">diagnose &mdash; Submit root cause diagnosis</option>
2416
+ <option value="restart_service">restart_service &mdash; Restart a crashed service</option>
2417
+ <option value="rollback">rollback &mdash; Roll back a deployment</option>
2418
+ <option value="scale_up">scale_up &mdash; Scale up replicas</option>
2419
+ <option value="alert_oncall">alert_oncall &mdash; Page on-call engineer</option>
2420
+ <option value="acknowledge">acknowledge &mdash; Acknowledge an alert</option>
2421
+ <option value="block_ip_range">block_ip_range &mdash; Block a CIDR range</option>
2422
+ <option value="create_index">create_index &mdash; Create a DB index</option>
2423
+ <option value="failover">failover &mdash; Regional failover</option>
2424
+ <option value="noop">noop &mdash; No action</option>
2425
+ </select>
2426
+ </div>
2427
+ <div class="form-row" id="svc-row">
2428
+ <label>Service Name</label>
2429
+ <input type="text" id="service-input" placeholder="e.g. payment-service">
2430
+ </div>
2431
+ <div class="form-row" id="rc-row" style="display:none">
2432
+ <label>Root Cause (for diagnose)</label>
2433
+ <input type="text" id="rc-input" placeholder="e.g. Memory leak in payment-service causing OOM">
2434
+ </div>
2435
+ <div class="form-row" id="query-row" style="display:none">
2436
+ <label>Search Query</label>
2437
+ <input type="text" id="query-input" placeholder="e.g. OOM">
2438
+ </div>
2439
+ <div class="form-row" id="rb-row" style="display:none">
2440
+ <label>Runbook File</label>
2441
+ <input type="text" id="rb-input" placeholder="e.g. memory_leak.md">
2442
+ </div>
2443
+ <div class="form-row" id="ip-row" style="display:none">
2444
+ <label>IP Range</label>
2445
+ <input type="text" id="ip-input" placeholder="e.g. 185.220.0.0/16">
2446
+ </div>
2447
+ <div class="form-row" id="reason-row" style="display:none">
2448
+ <label>Reason (for alert_oncall)</label>
2449
+ <input type="text" id="reason-input" placeholder="e.g. Data corruption detected">
2450
+ </div>
2451
+ <button class="btn-action" id="action-btn" onclick="takeAction()">&#9654; TAKE ACTION</button>
2452
+ <div id="reward-flash" style="display:none" class="reward-flash"></div>
2453
+ <div id="result-log" class="result-log" style="display:none"></div>
2454
+ </div>
2455
+ <div class="panel">
2456
+ <div class="panel-title">&#9658; Reward History</div>
2457
+ <div id="reward-history" class="mono" style="font-size:11px;max-height:120px;overflow-y:auto"></div>
2458
+ </div>
2459
+ <button class="btn-action btn-reset" onclick="startGame(true)" style="margin-top:8px">&#8635; New Incident (Random Seed)</button>
2460
+ </div>
2461
+ </div>
2462
+ <script>
2463
+ let isDone = false;
2464
+ let currentSeed = 1234;
2465
+ let stepCount = 0;
2466
+ let totalScore = 0;
2467
+ let maxSteps = 15;
2468
+
2469
+ async function startGame(randomSeed) {
2470
+ currentSeed = randomSeed ? Math.floor(Math.random() * 99999) : 1234;
2471
+ stepCount = 0; totalScore = 0; isDone = false;
2472
+ document.getElementById('done-panel').style.display = 'none';
2473
+ document.getElementById('reward-history').innerHTML = '';
2474
+ document.getElementById('result-log').style.display = 'none';
2475
+ document.getElementById('reward-flash').style.display = 'none';
2476
+ document.getElementById('action-btn').disabled = false;
2477
+ const res = await fetch('/reset', {
2478
+ method: 'POST', headers: {'Content-Type': 'application/json'},
2479
+ body: JSON.stringify({task_id: 'easy', seed: currentSeed})
2480
+ });
2481
+ const obs = await res.json();
2482
+ maxSteps = obs.max_steps || 15;
2483
+ renderObs(obs);
2484
+ document.getElementById('step-display').textContent = 'Step 0 / ' + maxSteps;
2485
+ document.getElementById('task-display').textContent = (obs.task_id || 'easy').toUpperCase() + ' \\u00b7 SEED ' + currentSeed;
2486
+ if (obs.task_description) {
2487
+ document.getElementById('task-desc').textContent = obs.task_description;
2488
+ document.getElementById('task-desc-panel').style.display = 'block';
2489
+ }
2490
+ updateScoreDisplay(0);
2491
+ }
2492
+
2493
+ function onActionTypeChange() {
2494
+ const at = document.getElementById('action-type').value;
2495
+ document.getElementById('rc-row').style.display = at === 'diagnose' ? 'block' : 'none';
2496
+ document.getElementById('query-row').style.display = at === 'search_logs' ? 'block' : 'none';
2497
+ document.getElementById('rb-row').style.display = at === 'read_runbook' ? 'block' : 'none';
2498
+ document.getElementById('ip-row').style.display = at === 'block_ip_range' ? 'block' : 'none';
2499
+ document.getElementById('reason-row').style.display = at === 'alert_oncall' ? 'block' : 'none';
2500
+ document.getElementById('svc-row').style.display = ['read_runbook','block_ip_range','noop'].includes(at) ? 'none' : 'block';
2501
+ }
2502
+
2503
+ async function takeAction() {
2504
+ if (isDone) return;
2505
+ const at = document.getElementById('action-type').value;
2506
+ const svc = document.getElementById('service-input').value.trim() || null;
2507
+ const body = {action_type: at};
2508
+ if (svc) body.service = svc;
2509
+ if (at === 'diagnose') body.root_cause = document.getElementById('rc-input').value.trim();
2510
+ if (at === 'search_logs') body.query = document.getElementById('query-input').value.trim();
2511
+ if (at === 'read_runbook') body.runbook = document.getElementById('rb-input').value.trim();
2512
+ if (at === 'block_ip_range') body.ip_range = document.getElementById('ip-input').value.trim();
2513
+ if (at === 'alert_oncall') body.reason = document.getElementById('reason-input').value.trim();
2514
+ document.getElementById('action-btn').disabled = true;
2515
+ const res = await fetch('/step', {
2516
+ method: 'POST', headers: {'Content-Type': 'application/json'},
2517
+ body: JSON.stringify(body)
2518
+ });
2519
+ const data = await res.json();
2520
+ document.getElementById('action-btn').disabled = isDone ? true : false;
2521
+ const reward = data.reward || 0;
2522
+ totalScore += reward; stepCount += 1;
2523
+ renderObs(data.observation);
2524
+ updateScoreDisplay(totalScore);
2525
+ document.getElementById('step-display').textContent = 'Step ' + stepCount + ' / ' + maxSteps;
2526
+ showReward(reward, data.observation && data.observation.last_action_result);
2527
+ appendRewardHistory(stepCount, at, reward);
2528
+ if (data.done) { isDone = true; document.getElementById('action-btn').disabled = true; showDonePanel(totalScore, stepCount, data.observation && data.observation.incident_resolved); }
2529
+ }
2530
+
2531
+ function renderObs(obs) {
2532
+ if (!obs) return;
2533
+ if (obs.services) {
2534
+ document.getElementById('svc-tbody').innerHTML = obs.services.map(s => {
2535
+ const sc = s.status === 'down' ? 'status-down' : s.status === 'degraded' ? 'status-degraded' : 'status-healthy';
2536
+ return '<tr><td>' + s.name + '</td><td class="' + sc + '">' + s.status.toUpperCase() + '</td><td>' + (s.cpu_percent||0).toFixed(1) + '</td><td>' + (s.memory_percent||0).toFixed(1) + '</td><td>' + (s.error_rate||0).toFixed(3) + '</td><td>' + (s.latency_p99_ms||0).toFixed(0) + '</td></tr>';
2537
+ }).join('');
2538
+ }
2539
+ const ac = document.getElementById('alerts-container');
2540
+ if (obs.active_alerts && obs.active_alerts.length > 0) {
2541
+ document.getElementById('alert-count').textContent = '(' + obs.active_alerts.length + ')';
2542
+ ac.innerHTML = obs.active_alerts.map(a => {
2543
+ const sev = (a.severity||'INFO').toLowerCase();
2544
+ const cls = sev === 'critical' ? 'sev-critical' : sev === 'high' ? 'sev-high' : sev === 'warning' ? 'sev-warning' : 'sev-info';
2545
+ return '<div class="alert-item ' + cls + ' mono"><strong>' + a.severity + '</strong> [' + a.service + '] ' + a.message + '</div>';
2546
+ }).join('');
2547
+ } else { document.getElementById('alert-count').textContent = ''; ac.innerHTML = '<div style="color:var(--text-dim);font-size:12px">No active alerts</div>'; }
2548
+ const ec = document.getElementById('evidence-container');
2549
+ if (obs.evidence_log && obs.evidence_log.length > 0) {
2550
+ document.getElementById('evidence-count').textContent = '(' + obs.evidence_log.length + ')';
2551
+ ec.innerHTML = obs.evidence_log.slice(-5).map(e => '<div class="evidence-item">&#9658; [step ' + e.step + '] ' + e.source + ': ' + e.summary + '</div>').join('');
2552
+ } else { document.getElementById('evidence-count').textContent = ''; ec.innerHTML = '<div style="color:var(--text-dim);font-size:12px">No evidence yet &mdash; use read_logs, read_metrics, or search_logs</div>'; }
2553
+ }
2554
+
2555
+ function updateScoreDisplay(score) {
2556
+ const el = document.getElementById('score-display');
2557
+ el.textContent = score.toFixed(3);
2558
+ el.style.color = score >= 0.7 ? 'var(--green)' : score >= 0.4 ? 'var(--yellow)' : score > 0 ? 'var(--red)' : 'var(--text-dim)';
2559
+ }
2560
+
2561
+ function showReward(reward, resultText) {
2562
+ const el = document.getElementById('reward-flash');
2563
+ const sign = reward > 0 ? '+' : '';
2564
+ el.style.display = 'block'; el.style.borderColor = reward > 0 ? 'var(--green)' : 'var(--red)';
2565
+ el.style.color = reward > 0 ? 'var(--green)' : 'var(--red)';
2566
+ el.style.background = reward > 0 ? 'rgba(0,255,136,0.08)' : 'rgba(255,51,85,0.08)';
2567
+ el.textContent = 'Reward: ' + sign + reward.toFixed(3);
2568
+ if (resultText) { const rl = document.getElementById('result-log'); rl.style.display = 'block'; rl.textContent = resultText.substring(0, 500); }
2569
+ }
2570
+
2571
+ function appendRewardHistory(step, action, reward) {
2572
+ const el = document.getElementById('reward-history');
2573
+ const sign = reward > 0 ? '+' : '';
2574
+ const col = reward > 0 ? 'var(--green)' : 'var(--red)';
2575
+ el.innerHTML += '<div style="color:' + col + '">S' + step + ' ' + action + ' &rarr; ' + sign + reward.toFixed(3) + '</div>';
2576
+ el.scrollTop = el.scrollHeight;
2577
+ }
2578
+
2579
+ async function showDonePanel(score, steps, resolved) {
2580
+ const banner = document.getElementById('done-banner');
2581
+ banner.style.color = resolved ? 'var(--green)' : 'var(--red)';
2582
+ banner.style.borderColor = resolved ? 'var(--green)' : 'var(--red)';
2583
+ banner.style.background = resolved ? 'rgba(0,255,136,0.05)' : 'rgba(255,51,85,0.05)';
2584
+ banner.textContent = (resolved ? '\\u2713 INCIDENT RESOLVED' : '\\u2717 INCIDENT ESCALATED') + ' \\u2014 Score: ' + score.toFixed(3);
2585
+ document.getElementById('human-score').textContent = score.toFixed(3);
2586
+ document.getElementById('human-score').style.color = score >= 0.7 ? 'var(--green)' : score >= 0.4 ? 'var(--yellow)' : 'var(--red)';
2587
+ document.getElementById('human-steps').textContent = steps + ' steps taken';
2588
+ try {
2589
+ const m = await fetch('/metrics'); const mdata = await m.json();
2590
+ const easyData = mdata.by_task && mdata.by_task['easy'];
2591
+ if (easyData) { document.getElementById('agent-score').textContent = easyData.avg_score.toFixed(3); document.getElementById('agent-score').style.color = easyData.avg_score >= 0.7 ? 'var(--green)' : easyData.avg_score >= 0.4 ? 'var(--yellow)' : 'var(--red)'; }
2592
+ else { document.getElementById('agent-score').textContent = 'N/A'; }
2593
+ } catch(e) { document.getElementById('agent-score').textContent = 'N/A'; }
2594
+ document.getElementById('done-panel').style.display = 'block';
2595
+ }
2596
+
2597
+ startGame(false);
2598
+ </script>
2599
+ </body>
2600
+ </html>"""
2601
+ return HTMLResponse(html)
2602
+
2603
+
2604
+ # ─── Feature 3: Progress Visualization ───────────────────────────────────────
2605
+
2606
+ @app.get("/progress", response_class=HTMLResponse)
2607
+ def progress_page():
2608
+ """Training progress and live performance visualization."""
2609
+ metrics = get_metrics()
2610
+ by_task = metrics.get("by_task", {})
2611
+
2612
+ BASELINES = {
2613
+ "easy": 0.05, "medium": 0.03, "hard": 0.01,
2614
+ "bonus": 0.01, "security": 0.01, "database": 0.01, "failover": 0.01,
2615
+ }
2616
+ TRAINING_RESULTS = [
2617
+ ("easy", 0.320, 0.685),
2618
+ ("medium", 0.050, 0.378),
2619
+ ("hard", 0.190, 0.869),
2620
+ ("bonus", 0.152, 0.682),
2621
+ ]
2622
+ ALL_TASKS = ["easy", "medium", "hard", "bonus", "security", "database", "failover"]
2623
+
2624
+ def bar_row(task: str, score: float, baseline: float) -> str:
2625
+ pct = round(score * 100, 1)
2626
+ bpct = round(baseline * 100, 1)
2627
+ col = "#00ff88" if score >= 0.7 else "#ffaa00" if score >= 0.4 else "#ff3355"
2628
+ return f"""
2629
+ <div class="bar-group">
2630
+ <div class="bar-label mono">{task.upper()}</div>
2631
+ <div class="bar-tracks">
2632
+ <div class="bar-track">
2633
+ <span class="bar-tag">RANDOM</span>
2634
+ <div class="bar-bg"><div class="bar-fill" style="width:{bpct}%;background:#4a6080"></div></div>
2635
+ <span class="bar-val mono" style="color:#4a6080">{baseline:.3f}</span>
2636
+ </div>
2637
+ <div class="bar-track">
2638
+ <span class="bar-tag">ARIA</span>
2639
+ <div class="bar-bg"><div class="bar-fill" style="width:{pct}%;background:{col}"></div></div>
2640
+ <span class="bar-val mono" style="color:{col}">{score:.3f}</span>
2641
+ </div>
2642
+ </div>
2643
+ </div>"""
2644
+
2645
+ bars = "".join(
2646
+ bar_row(task, by_task.get(task, {}).get("avg_score", 0.0), BASELINES.get(task, 0.01))
2647
+ for task in ALL_TASKS
2648
+ )
2649
+
2650
+ training_rows = "".join(
2651
+ f"""<tr>
2652
+ <td class="mono">{task.upper()}</td>
2653
+ <td class="mono" style="color:#4a6080">{base:.3f}</td>
2654
+ <td class="mono" style="color:#4d9fff">{finetuned:.3f}</td>
2655
+ <td class="mono" style="color:#00ff88">+{finetuned - base:.3f} &#10003;</td>
2656
+ </tr>"""
2657
+ for task, base, finetuned in TRAINING_RESULTS
2658
+ )
2659
+
2660
+ recent = list(episode_history)[-20:] if episode_history else []
2661
+ recent_scores_js = str([round(r["final_score"], 3) for r in recent])
2662
+ total_eps = metrics.get("total_episodes", 0)
2663
+ overall = metrics.get("overall_avg_score", 0.0)
2664
+ overall_col = "#00ff88" if overall >= 0.7 else "#ffaa00" if overall >= 0.4 else "#4d9fff"
2665
+
2666
+ html = f"""<!DOCTYPE html>
2667
+ <html lang="en">
2668
+ <head>
2669
+ <meta charset="UTF-8">
2670
+ <title>ARIA Progress</title>
2671
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
2672
+ <style>
2673
+ :root{{--bg:#060914;--surface:#0a0f1e;--surface2:#0d1628;--border:#1a2744;--blue:#4d9fff;--cyan:#00d4ff;--green:#00ff88;--yellow:#ffaa00;--red:#ff3355;--text:#c8d8f0;--text-dim:#4a6080;}}
2674
+ *{{box-sizing:border-box;margin:0;padding:0;}}
2675
+ body{{background:var(--bg);color:var(--text);font-family:'Inter',sans-serif;}}
2676
+ .mono{{font-family:'Share Tech Mono',monospace;}}
2677
+ .nav{{background:#000;border-bottom:1px solid var(--border);padding:0 32px;height:48px;display:flex;align-items:center;justify-content:space-between;}}
2678
+ .nav-logo{{font-size:16px;color:var(--blue);font-weight:700;}}
2679
+ .nav-links{{display:flex;gap:16px;font-size:12px;}}
2680
+ .nav-links a{{color:var(--text-dim);text-decoration:none;}}
2681
+ .nav-links a:hover{{color:var(--blue);}}
2682
+ .page{{max-width:960px;margin:0 auto;padding:32px;}}
2683
+ h1{{font-size:22px;color:var(--blue);margin-bottom:8px;}}
2684
+ .subtitle{{font-size:13px;color:var(--text-dim);margin-bottom:40px;}}
2685
+ h2{{font-size:13px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.1em;margin-bottom:20px;padding-bottom:8px;border-bottom:1px solid var(--border);}}
2686
+ section{{margin-bottom:48px;}}
2687
+ .stats-row{{display:flex;gap:24px;margin-bottom:32px;flex-wrap:wrap;}}
2688
+ .stat-card{{background:var(--surface);border:1px solid var(--border);padding:20px 24px;flex:1;min-width:140px;}}
2689
+ .stat-label{{font-size:10px;color:var(--text-dim);text-transform:uppercase;margin-bottom:6px;}}
2690
+ .stat-value{{font-size:28px;font-weight:700;font-family:'Share Tech Mono',monospace;}}
2691
+ .bar-group{{margin-bottom:20px;}}
2692
+ .bar-label{{font-size:11px;color:var(--text-dim);text-transform:uppercase;margin-bottom:8px;letter-spacing:0.08em;}}
2693
+ .bar-tracks{{display:flex;flex-direction:column;gap:6px;}}
2694
+ .bar-track{{display:flex;align-items:center;gap:10px;}}
2695
+ .bar-tag{{font-size:9px;color:var(--text-dim);width:50px;text-align:right;font-family:'Share Tech Mono',monospace;}}
2696
+ .bar-bg{{flex:1;height:12px;background:var(--surface);border:1px solid var(--border);overflow:hidden;}}
2697
+ .bar-fill{{height:100%;transition:width 0.6s ease;}}
2698
+ .bar-val{{font-size:12px;width:50px;text-align:right;}}
2699
+ table{{width:100%;border-collapse:collapse;}}
2700
+ th{{text-align:left;font-size:11px;color:var(--text-dim);text-transform:uppercase;padding:8px 12px;border-bottom:1px solid var(--border);}}
2701
+ td{{padding:10px 12px;border-bottom:1px solid rgba(26,39,68,0.5);}}
2702
+ .canvas-wrap{{background:var(--surface);border:1px solid var(--border);padding:16px;}}
2703
+ canvas{{display:block;width:100%;max-width:880px;}}
2704
+ .canvas-label{{font-size:11px;color:var(--text-dim);margin-top:8px;text-align:center;font-family:'Share Tech Mono',monospace;}}
2705
+ </style>
2706
+ </head>
2707
+ <body>
2708
+ <div class="nav">
2709
+ <div class="nav-logo mono">&#9635; ARIA PROGRESS</div>
2710
+ <div class="nav-links">
2711
+ <a href="/live">Live NOC</a>
2712
+ <a href="/challenge">Challenge</a>
2713
+ <a href="/replays/html">Replays</a>
2714
+ <a href="/docs">API Docs</a>
2715
+ </div>
2716
+ </div>
2717
+ <div class="page">
2718
+ <h1>Training Progress &amp; Live Performance</h1>
2719
+ <div class="subtitle">Llama-3.1-8B fine-tuned with GRPO on 7 task types &middot; 14 actions &middot; Dense reward shaping</div>
2720
+ <div class="stats-row">
2721
+ <div class="stat-card"><div class="stat-label">Live Episodes</div><div class="stat-value" id="live-eps" style="color:var(--blue)">{total_eps}</div></div>
2722
+ <div class="stat-card"><div class="stat-label">Avg Live Score</div><div class="stat-value" id="live-avg" style="color:{overall_col}">{overall:.3f}</div></div>
2723
+ <div class="stat-card"><div class="stat-label">Training Episodes</div><div class="stat-value" style="color:var(--blue)">160</div></div>
2724
+ <div class="stat-card"><div class="stat-label">Model</div><div class="stat-value" style="font-size:14px;color:var(--cyan)">8B GRPO</div></div>
2725
+ </div>
2726
+ <section>
2727
+ <h2>Section 1 &mdash; Live Task Performance vs Random Baseline</h2>
2728
+ {bars}
2729
+ </section>
2730
+ <section>
2731
+ <h2>Section 2 &mdash; Training Improvement (Llama-3.1-8B, GRPO)</h2>
2732
+ <table>
2733
+ <thead><tr><th>Task</th><th>Baseline</th><th>Fine-tuned</th><th>Improvement</th></tr></thead>
2734
+ <tbody>{training_rows}</tbody>
2735
+ </table>
2736
+ </section>
2737
+ <section>
2738
+ <h2>Section 3 &mdash; Live Episode Score Timeline</h2>
2739
+ <div class="canvas-wrap">
2740
+ <canvas id="timeline-canvas" width="880" height="120"></canvas>
2741
+ <div class="canvas-label mono" id="canvas-label">Last 20 episodes &middot; updates every 30s</div>
2742
+ </div>
2743
+ </section>
2744
+ </div>
2745
+ <script>
2746
+ const SCORES = {recent_scores_js};
2747
+ function drawTimeline(scores) {{
2748
+ const canvas = document.getElementById('timeline-canvas');
2749
+ canvas.width = canvas.parentElement.clientWidth - 32;
2750
+ const ctx = canvas.getContext('2d');
2751
+ const W = canvas.width, H = 120;
2752
+ ctx.clearRect(0, 0, W, H);
2753
+ if (scores.length === 0) {{
2754
+ ctx.fillStyle = '#4a6080'; ctx.font = '13px Share Tech Mono, monospace';
2755
+ ctx.textAlign = 'center'; ctx.fillText('No episodes yet', W/2, H/2); return;
2756
+ }}
2757
+ const pad = 30, plotW = W - pad * 2, plotH = H - pad * 2;
2758
+ ctx.strokeStyle = '#1a2744'; ctx.lineWidth = 1;
2759
+ [0, 0.25, 0.5, 0.75, 1.0].forEach(v => {{
2760
+ const y = pad + plotH - v * plotH;
2761
+ ctx.beginPath(); ctx.moveTo(pad, y); ctx.lineTo(W - pad, y); ctx.stroke();
2762
+ ctx.fillStyle = '#4a6080'; ctx.font = '10px Share Tech Mono, monospace';
2763
+ ctx.textAlign = 'right'; ctx.fillText(v.toFixed(2), pad - 4, y + 3);
2764
+ }});
2765
+ const n = scores.length;
2766
+ const pts = scores.map((s, i) => [pad + (i / Math.max(n-1, 1)) * plotW, pad + plotH - s * plotH]);
2767
+ ctx.strokeStyle = '#4d9fff'; ctx.lineWidth = 2; ctx.lineJoin = 'round';
2768
+ ctx.beginPath(); pts.forEach(([x,y], i) => i === 0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y)); ctx.stroke();
2769
+ pts.forEach(([x,y], i) => {{
2770
+ const s = scores[i];
2771
+ ctx.fillStyle = s >= 0.7 ? '#00ff88' : s >= 0.4 ? '#ffaa00' : '#ff3355';
2772
+ ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI*2); ctx.fill();
2773
+ }});
2774
+ }}
2775
+ drawTimeline(SCORES);
2776
+ async function refresh() {{
2777
+ try {{
2778
+ const res = await fetch('/metrics'); const data = await res.json();
2779
+ document.getElementById('live-eps').textContent = data.total_episodes || 0;
2780
+ const avg = data.overall_avg_score || 0;
2781
+ document.getElementById('live-avg').textContent = avg.toFixed(3);
2782
+ }} catch(e) {{}}
2783
+ }}
2784
+ setInterval(refresh, 30000);
2785
+ </script>
2786
+ </body>
2787
+ </html>"""
2788
+ return HTMLResponse(html)