Spaces:
Sleeping
Sleeping
| """ | |
| server/api.py β CreatorCrisisEnv FastAPI Server | |
| Meta OpenEnv Hackathon | Creator Account Crisis Scenario | |
| Author: Jashandeep Singh | |
| Endpoints | |
| βββββββββ | |
| POST /reset Start a new episode | |
| POST /step?shaped=false Execute one action (optional curriculum shaping) | |
| GET /state Current observation (no advance) | |
| GET /tasks List all tasks + action schema | |
| POST /grader Score a completed episode | |
| GET /baseline Run deterministic baseline across all 3 tasks | |
| GET /leaderboard Live benchmark leaderboard | |
| POST /leaderboard/submit Submit a score to the leaderboard | |
| GET /replay/{episode_id} Replay a stored episode step-by-step | |
| GET /replay List recent episode replays | |
| GET /health Liveness probe (HF Space ping) | |
| GET /docs Auto OpenAPI schema | |
| Usage | |
| βββββ | |
| uvicorn server.api:app --host 0.0.0.0 --port 8000 | |
| """ | |
| from __future__ import annotations | |
| import sys | |
| import os | |
| import time | |
| import json | |
| import uuid | |
| import threading | |
| from pathlib import Path | |
| from typing import Any | |
| _HERE = os.path.dirname(os.path.abspath(__file__)) | |
| _ROOT = os.path.dirname(_HERE) | |
| for p in [_HERE, _ROOT]: | |
| if p not in sys.path: | |
| sys.path.insert(0, p) | |
| from fastapi import FastAPI, HTTPException, Query, Request | |
| from fastapi.responses import JSONResponse | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel, Field | |
| from server.environment import CreatorCrisisEnv, register | |
| from engine.reward_shaper import RewardShaper, EpisodeMilestoneTracker | |
| from models import ActionType | |
| import baseline as _baseline_module | |
| register() | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # STORAGE | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _DATA_DIR = Path(_ROOT) / "data" | |
| _LEADERBOARD_F = _DATA_DIR / "leaderboard.json" | |
| _REPLAY_DIR = _DATA_DIR / "replays" | |
| _DATA_DIR.mkdir(exist_ok=True) | |
| _REPLAY_DIR.mkdir(exist_ok=True) | |
| _lb_lock = threading.Lock() | |
| _replay_lock = threading.Lock() | |
| def _load_lb() -> dict: | |
| if _LEADERBOARD_F.exists(): | |
| try: | |
| return json.loads(_LEADERBOARD_F.read_text()) | |
| except Exception: | |
| pass | |
| return { | |
| "task_1_low_risk_restore": {"entries": [], "baseline": None}, | |
| "task_2_medium_risk_resolve": {"entries": [], "baseline": None}, | |
| "task_3_high_risk_reject": {"entries": [], "baseline": None}, | |
| } | |
| def _save_lb(lb: dict) -> None: | |
| _LEADERBOARD_F.write_text(json.dumps(lb, indent=2)) | |
| def _write_replay(episode_id: str, data: dict) -> None: | |
| (_REPLAY_DIR / f"{episode_id}.json").write_text(json.dumps(data, indent=2)) | |
| def _read_replay(episode_id: str) -> dict | None: | |
| p = _REPLAY_DIR / f"{episode_id}.json" | |
| return json.loads(p.read_text()) if p.exists() else None | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # APP | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app = FastAPI( | |
| title="CreatorCrisisEnv β OpenEnv API", | |
| description=( | |
| "OpenEnv-compliant HTTP API for the Creator Account Crisis RL environment.\n\n" | |
| "A 5M-follower Facebook creator is auto-banned minutes before a βΉ10 Lakh brand deal. " | |
| "The AI agent must verify identity while de-escalating a panicked human.\n\n" | |
| "**Features:** Curriculum reward shaping (Ng et al. 1999) Β· Adversarial detectors Β· " | |
| "Episode replay system Β· Live leaderboard Β· 13 loophole fixes\n\n" | |
| "Author: Jashandeep Singh | Meta OpenEnv Hackathon" | |
| ), | |
| version="1.0.0", | |
| docs_url="/docs", | |
| redoc_url="/redoc", | |
| ) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SESSION MANAGER | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class _Session: | |
| def __init__(self) -> None: | |
| self.env: CreatorCrisisEnv | None = None | |
| self.last_obs: dict | None = None | |
| self.last_info: dict | None = None | |
| self.done: bool = False | |
| self.lock: threading.Lock = threading.Lock() | |
| self.episode_id: str = "" | |
| # RewardShaper β one per session, milestone tracker reset on /reset | |
| self.shaper: RewardShaper = RewardShaper(500_000) | |
| self.milestone: EpisodeMilestoneTracker = EpisodeMilestoneTracker() | |
| self.visited: set = set() | |
| self.global_step: int = 0 | |
| # Replay recording | |
| self.replay_steps: list[dict] = [] | |
| self.verify_before: float = 0.0 | |
| _sessions: dict[str, _Session] = {} | |
| _sessions_lock = threading.Lock() | |
| def _get_session(sid: str) -> _Session: | |
| with _sessions_lock: | |
| if sid not in _sessions: | |
| _sessions[sid] = _Session() | |
| return _sessions[sid] | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # REQUEST MODELS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class ResetRequest(BaseModel): | |
| seed: int | None = Field(default=None) | |
| session_id: str = Field(default="default") | |
| options: dict | None = Field(default=None) | |
| class StepRequest(BaseModel): | |
| action: int = Field(..., ge=0, le=5) | |
| session_id: str = Field(default="default") | |
| class GraderRequest(BaseModel): | |
| task_id: str = Field(...) | |
| session_id: str = Field(default="default") | |
| class LBSubmitRequest(BaseModel): | |
| task_id: str = Field(...) | |
| agent_name: str = Field(..., max_length=64) | |
| score: float = Field(..., ge=0.001, le=0.999) | |
| total_steps: int = Field(..., ge=0) | |
| session_id: str = Field(default="default") | |
| notes: str = Field(default="") | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # HELPERS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _to_json(obj: Any) -> Any: | |
| if isinstance(obj, dict): | |
| return {k: _to_json(v) for k, v in obj.items()} | |
| if isinstance(obj, list): | |
| return [_to_json(i) for i in obj] | |
| if hasattr(obj, "tolist"): | |
| return obj.tolist() | |
| if hasattr(obj, "item"): | |
| return obj.item() | |
| if hasattr(obj, "value"): | |
| return obj.value | |
| return obj | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # TASK REGISTRY | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _TASKS = [ | |
| { | |
| "task_id": "task_1_low_risk_restore", | |
| "name": "Low-Risk Creator: Correct Restore", | |
| "difficulty": "easy", | |
| "seed": 42, | |
| "target_risk":"LOW", | |
| "description":( | |
| "Legitimate creator, clean signals. " | |
| "LOW verify: IP(+0.18) + VOICE(+0.42). Two security actions = 0.60; " | |
| "one more VOICE = 1.0 β₯ 0.85. " | |
| "Baseline: EMPATHIZE β IP β EMPATHIZE β VOICE β RESTORE (5 steps)." | |
| ), | |
| "success_condition": "outcome in [CREATOR_RESTORED, GREY_AREA_RESOLVED]", | |
| "baseline_script": [2, 0, 2, 1, 4], | |
| }, | |
| { | |
| "task_id": "task_2_medium_risk_resolve", | |
| "name": "Medium-Risk Creator: Ambiguity Resolution", | |
| "difficulty": "medium", | |
| "seed": 13, | |
| "target_risk":"MEDIUM", | |
| "description":( | |
| "Creator from anomalous location (VPN / travel). Ambiguous evidence. " | |
| "MEDIUM verify: IP(0.12)Γ3 + VOICE(0.28)Γ2 = 0.92 β₯ 0.85. " | |
| "Baseline: IPβEMPβVOICEβEMPβIPβVOICEβIPβRESTORE (8 steps, hits efficiency)." | |
| ), | |
| "success_condition": "outcome in [CREATOR_RESTORED, GREY_AREA_RESOLVED]", | |
| "baseline_script": [0, 2, 1, 2, 0, 1, 0, 4], | |
| }, | |
| { | |
| "task_id": "task_3_high_risk_reject", | |
| "name": "High-Risk Hacker: Correct Rejection", | |
| "difficulty": "hard", | |
| "seed": 0, | |
| "target_risk":"HIGH", | |
| "description":( | |
| "Active takeover. Hacker: Too Cooperative β Pivot β Fake Urgency. " | |
| "Eerily calm. Verify suppressed: IP(+0.06) + VOICE(+0.10). " | |
| "Detect low behavioural_consistency. " | |
| "Baseline: IP β EMPATHIZE β VOICE β REJECT (4 steps)." | |
| ), | |
| "success_condition": "outcome == CORRECT_REJECTION", | |
| "baseline_script": [0, 2, 1, 5], | |
| }, | |
| ] | |
| _TASK_IDS = {t["task_id"] for t in _TASKS} | |
| _TASK_MAP = {t["task_id"]: t for t in _TASKS} | |
| _ACTION_MEANINGS = { | |
| 0: "CHECK_IP_LOGS", | |
| 1: "REQUEST_ID_VOICE_VERIFY", | |
| 2: "EMPATHIZE", | |
| 3: "OFFER_COMPENSATION", | |
| 4: "RESTORE_ACCOUNT", | |
| 5: "REJECT_ACCOUNT", | |
| } | |
| _ACTION_SCHEMA = { | |
| "action": { | |
| "type": "integer", | |
| "required": True, | |
| "range": [0, 5], | |
| "meanings": _ACTION_MEANINGS, | |
| } | |
| } | |
| _GOOD_OUTCOMES = { | |
| "LOW": ["CREATOR_RESTORED", "GREY_AREA_RESOLVED"], | |
| "MEDIUM": ["CREATOR_RESTORED", "GREY_AREA_RESOLVED"], | |
| "HIGH": ["CORRECT_REJECTION"], | |
| } | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # GRADER | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _grade(session: _Session, task_id: str) -> dict: | |
| if not session.done: | |
| raise HTTPException(status_code=409, detail="Episode not done. Play to termination first.") | |
| if session.last_info is None: | |
| raise HTTPException(status_code=409, detail="No episode data. Call /reset then play.") | |
| task = _TASK_MAP.get(task_id) | |
| if not task: | |
| raise HTTPException(status_code=404, detail=f"task_id '{task_id}' not found.") | |
| stats = session.last_info.get("episode_stats", {}) | |
| outcome = stats.get("outcome", "UNKNOWN") | |
| final_pat = stats.get("final_patience", 0) | |
| cliff_edge = stats.get("cliff_edge_triggered", False) # FIX: correct key | |
| total_steps = stats.get("total_steps", 0) | |
| total_reward = stats.get("total_reward", 0.0) | |
| actual_risk = stats.get("risk_level", "UNKNOWN") | |
| notes: list[str] = [] | |
| expected_good = _GOOD_OUTCOMES.get(actual_risk, []) | |
| outcome_correct = outcome in expected_good | |
| steps_efficient = total_steps <= 8 | |
| patience_managed = final_pat > 0 or outcome_correct | |
| deadline_kept = not cliff_edge | |
| score = round(max(0.001, min(0.999, | |
| 0.50 * float(outcome_correct) + | |
| 0.20 * float(steps_efficient) + | |
| 0.15 * float(patience_managed) + | |
| 0.15 * float(deadline_kept) | |
| )), 4) | |
| if not outcome_correct: | |
| notes.append(f"Wrong terminal: outcome={outcome}, expected {expected_good}.") | |
| if cliff_edge: | |
| notes.append("Cliff-edge: deadline expired, restore capped at +200.") | |
| if not steps_efficient: | |
| notes.append(f"Took {total_steps} steps (target β€ 8).") | |
| if final_pat <= 0 and not outcome_correct: | |
| notes.append("Rage-quit: patience hit 0.") | |
| if actual_risk != task["target_risk"]: | |
| notes.append(f"Seed gave risk={actual_risk}, expected {task['target_risk']}.") | |
| return { | |
| "task_id": task_id, "task_name": task["name"], "difficulty": task["difficulty"], | |
| "score": score, "outcome_correct": outcome_correct, "steps_efficient": steps_efficient, | |
| "patience_managed": patience_managed, "deadline_kept": deadline_kept, | |
| "total_steps": total_steps, "total_reward": round(float(total_reward), 2), | |
| "final_outcome": outcome, "actual_risk": actual_risk, "expected_risk": task["target_risk"], | |
| "episode_id": session.episode_id, "notes": notes, | |
| } | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # ENDPOINTS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def health(): | |
| """Liveness probe. HF Space automated ping hits this.""" | |
| return {"status": "ok", "env": "CreatorCrisisEnv", "version": "1.0.0"} | |
| async def reset(req: ResetRequest | None = None): | |
| """Start a new episode. Resets shaper, milestone tracker, and replay recorder.""" | |
| if req is None: | |
| req = ResetRequest() | |
| s = _get_session(req.session_id) | |
| with s.lock: | |
| env = CreatorCrisisEnv(render_mode=None) | |
| obs, info = env.reset(seed=req.seed) | |
| eid = str(uuid.uuid4())[:8] | |
| s.env = env; s.last_obs = obs; s.last_info = info | |
| s.done = False; s.episode_id = eid | |
| s.milestone.reset(); s.visited = set() | |
| s.global_step = 0; s.replay_steps = []; s.verify_before = 0.0 | |
| _write_replay(eid, { | |
| "episode_id": eid, "seed": req.seed, | |
| "risk_level": info["episode_seed"]["risk_level"], | |
| "deadline": info["episode_seed"]["initial_deadline_mins"], | |
| "steps": [], "outcome": None, "total_reward": None, | |
| }) | |
| json_info = _to_json(info) | |
| json_info["episode_id"] = eid | |
| return {"observation": _to_json(obs), "info": json_info} | |
| async def step( | |
| req: StepRequest, | |
| shaped: bool = Query( | |
| default=False, | |
| description=( | |
| "Apply curriculum reward shaping (Ng et al. 1999). " | |
| "shaped = base + Ξ³Ξ¦(s') - Ξ¦(s) + milestones + curiosity + urgency. " | |
| "Policy-invariant β guides exploration without changing optimal policy. " | |
| "Scale anneals 3.0β1.0 over 500K steps." | |
| ), | |
| ), | |
| ): | |
| """ | |
| Execute one action. | |
| Set `?shaped=true` for curriculum-annealed potential-based reward shaping. | |
| The shaping breakdown (potential, milestone, curiosity, urgency, scale) is | |
| included in the response when shaped=true. | |
| """ | |
| s = _get_session(req.session_id) | |
| with s.lock: | |
| if s.env is None: | |
| raise HTTPException(status_code=409, detail="No episode. Call /reset first.") | |
| if s.done: | |
| raise HTTPException(status_code=409, detail="Episode done. Call /reset.") | |
| sim = s.env._sim | |
| verify_before = sim.state.verification_score if sim else 0.0 | |
| obs, reward, terminated, truncated, info = s.env.step(req.action) | |
| done = terminated or truncated | |
| s.last_obs = obs; s.last_info = info; s.done = done; s.global_step += 1 | |
| base_reward = float(reward) | |
| shaping_bd: dict | None = None | |
| if shaped and sim is not None: | |
| action_type = ActionType(_ACTION_MEANINGS[req.action]) | |
| verify_after = sim.state.verification_score | |
| pat_sig = int(obs.get("patience_signal", 2)) | |
| deadline = float(sim.state.brand_deal_deadline_mins) | |
| shaped_r, shaping_bd = s.shaper.shape( | |
| global_step = s.global_step, | |
| verify_before = verify_before, | |
| verify_after = verify_after, | |
| base_reward = base_reward, | |
| action = action_type, | |
| patience_signal = pat_sig, | |
| deadline_mins = deadline, | |
| episode_visited = s.visited, | |
| tracker = s.milestone, | |
| ) | |
| reward = shaped_r | |
| # Record step for replay | |
| step_rec = { | |
| "step": info.get("episode_stats", {}).get("total_steps", 0), | |
| "action": req.action, | |
| "action_name": info.get("action_name", ""), | |
| "base_reward": base_reward, | |
| "final_reward": float(reward), | |
| "shaping": shaping_bd, | |
| "terminated": terminated, | |
| "truncated": truncated, | |
| "reward_reason": info.get("step_result", {}).get("reward_reason", ""), | |
| "verify_delta": info.get("step_result", {}).get("verification_delta", 0.0), | |
| "patience_delta": info.get("step_result", {}).get("patience_delta", 0), | |
| "adversarial": info.get("step_result", {}).get("adversarial_finding", "CLEAN"), | |
| "honey_pot_bait": info.get("step_result", {}).get("honey_pot_bait", ""), | |
| "user_message": ( | |
| info.get("obs_text", {}).get("message_history", [""])[-1] | |
| if info.get("obs_text", {}).get("message_history") else "" | |
| ), | |
| "sentiment": info.get("obs_text", {}).get("sentiment_summary", ""), | |
| } | |
| s.replay_steps.append(step_rec) | |
| # Persist replay update | |
| if s.episode_id: | |
| try: | |
| rp = _read_replay(s.episode_id) or {} | |
| rp["steps"] = s.replay_steps | |
| if done: | |
| rp["outcome"] = info.get("episode_stats", {}).get("outcome") | |
| rp["total_reward"] = info.get("episode_stats", {}).get("total_reward") | |
| with _replay_lock: | |
| _write_replay(s.episode_id, rp) | |
| except Exception: | |
| pass | |
| resp = { | |
| "episode_id": s.episode_id, | |
| "observation": _to_json(obs), | |
| "reward": float(reward), | |
| "terminated": terminated, | |
| "truncated": truncated, | |
| "done": done, | |
| "info": _to_json(info), | |
| } | |
| if shaping_bd is not None: | |
| resp["shaping"] = shaping_bd | |
| return resp | |
| async def state(session_id: str = "default"): | |
| """Current observation without advancing the episode.""" | |
| s = _get_session(session_id) | |
| with s.lock: | |
| if s.last_obs is None: | |
| return {"observation": None, "episode_active": False, "message": "Call /reset first."} | |
| return { | |
| "episode_id": s.episode_id, | |
| "observation": _to_json(s.last_obs), | |
| "episode_active": not s.done, | |
| "info": _to_json(s.last_info) if s.last_info else None, | |
| } | |
| async def tasks(): | |
| """All 3 tasks + action schema required for /step.""" | |
| return {"count": len(_TASKS), "tasks": _TASKS, "action_schema": _ACTION_SCHEMA} | |
| async def grader(req: GraderRequest): | |
| """ | |
| Score a completed episode (terminated=True or truncated=True required). | |
| score = 0.50Γoutcome_correct + 0.20Γsteps_efficient(β€8) + | |
| 0.15Γpatience_managed + 0.15Γdeadline_kept | |
| """ | |
| if req.task_id not in _TASK_IDS: | |
| raise HTTPException(status_code=404, detail=f"task_id '{req.task_id}' not found.") | |
| s = _get_session(req.session_id) | |
| with s.lock: | |
| return _grade(s, req.task_id) | |
| async def baseline(): | |
| """ | |
| Deterministic baseline across all 3 tasks. Fixed seeds β identical every call. | |
| Task 1 (easy, seed=42): [2,0,2,1,4] EMPβIPβEMPβVOICEβRESTORE | |
| Task 2 (med, seed=13): [0,2,1,2,0,1,0,4] IPβEMPβVOICEβEMPβIPβVOICEβIPβRESTORE | |
| Task 3 (hard, seed=0): [0,2,1,5] IPβEMPβVOICEβREJECT | |
| """ | |
| t0 = time.perf_counter() | |
| results = _baseline_module.run_baseline(verbose=False) | |
| payload = _baseline_module.build_json_output(results) | |
| payload["elapsed_total_ms"] = round((time.perf_counter() - t0) * 1000, 1) | |
| # Seed leaderboard baseline entries on first call | |
| with _lb_lock: | |
| lb = _load_lb() | |
| for r in results: | |
| if lb[r.task_id]["baseline"] is None: | |
| lb[r.task_id]["baseline"] = { | |
| "agent_name": "deterministic_baseline", | |
| "score": r.score, | |
| "total_steps": r.total_steps, | |
| "submitted_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), | |
| } | |
| _save_lb(lb) | |
| return payload | |
| async def leaderboard(task_id: str | None = Query(default=None)): | |
| """ | |
| Live benchmark leaderboard persisted to data/leaderboard.json. | |
| Always anchored with the deterministic baseline score. | |
| Top 10 per task, sorted by score descending. | |
| Run /baseline once to seed baseline entries. | |
| """ | |
| with _lb_lock: | |
| lb = _load_lb() | |
| if task_id: | |
| if task_id not in _TASK_IDS: | |
| raise HTTPException(status_code=404, detail=f"task_id '{task_id}' not found.") | |
| td = lb.get(task_id, {}) | |
| entries = sorted(td.get("entries", []), key=lambda e: e["score"], reverse=True) | |
| return {"task_id": task_id, "baseline": td.get("baseline"), "top_10": entries[:10], "total": len(entries)} | |
| out = {} | |
| for t in _TASKS: | |
| tid = t["task_id"] | |
| td = lb.get(tid, {}) | |
| entries = sorted(td.get("entries", []), key=lambda e: e["score"], reverse=True) | |
| out[tid] = { | |
| "task_name": t["name"], "difficulty": t["difficulty"], | |
| "baseline": td.get("baseline"), "top_10": entries[:10], "total": len(entries), | |
| } | |
| return out | |
| async def leaderboard_submit(req: LBSubmitRequest): | |
| """Submit a grader score. Use /grader output to get a valid score first.""" | |
| if req.task_id not in _TASK_IDS: | |
| raise HTTPException(status_code=404, detail=f"task_id '{req.task_id}' not found.") | |
| entry = { | |
| "agent_name": req.agent_name, | |
| "score": round(max(0.001, min(0.999, req.score)), 4), | |
| "total_steps": req.total_steps, | |
| "notes": req.notes[:200], | |
| "submitted_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), | |
| } | |
| with _lb_lock: | |
| lb = _load_lb() | |
| lb[req.task_id]["entries"].append(entry) | |
| lb[req.task_id]["entries"] = sorted( | |
| lb[req.task_id]["entries"], key=lambda e: e["score"], reverse=True | |
| )[:100] | |
| _save_lb(lb) | |
| return {"status": "submitted", "entry": entry, "task_id": req.task_id} | |
| async def replay( | |
| episode_id: str, | |
| step_index: int | None = Query(default=None, description="Single step (0-indexed). Omit for full replay."), | |
| ): | |
| """ | |
| Retrieve a stored episode replay. | |
| Every /step call is recorded automatically. Use the Gradio UI Replay tab | |
| to watch episodes step-by-step β including HoneyPot triggers, | |
| entropy decay events, and patience trajectory. | |
| """ | |
| data = _read_replay(episode_id) | |
| if data is None: | |
| raise HTTPException(status_code=404, detail=f"Episode '{episode_id}' not found.") | |
| if step_index is not None: | |
| steps = data.get("steps", []) | |
| if step_index < 0 or step_index >= len(steps): | |
| raise HTTPException(status_code=404, detail=f"step_index {step_index} out of range ({len(steps)} steps).") | |
| return {"episode_id": episode_id, "step_index": step_index, "step": steps[step_index]} | |
| return data | |
| async def list_replays(limit: int = Query(default=20, ge=1, le=100)): | |
| """List the most recent episode replays (newest first).""" | |
| with _replay_lock: | |
| files = sorted(_REPLAY_DIR.glob("*.json"), key=lambda f: f.stat().st_mtime, reverse=True) | |
| result = [] | |
| for f in files[:limit]: | |
| try: | |
| d = json.loads(f.read_text()) | |
| result.append({ | |
| "episode_id": d.get("episode_id", f.stem), | |
| "seed": d.get("seed"), | |
| "risk_level": d.get("risk_level"), | |
| "steps": len(d.get("steps", [])), | |
| "outcome": d.get("outcome", "IN_PROGRESS"), | |
| "total_reward": d.get("total_reward"), | |
| }) | |
| except Exception: | |
| continue | |
| return {"count": len(result), "replays": result} | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # GLOBAL EXCEPTION HANDLER | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def _exc(request: Request, exc: Exception): | |
| return JSONResponse(status_code=500, content={ | |
| "error": type(exc).__name__, "detail": str(exc), | |
| "path": str(request.url), "hint": "GET /health Β· POST /reset before /step", | |
| }) | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run("server.api:app", host="0.0.0.0", port=8000, reload=False) |