"""OpenEnv-compatible server for the Crime Investigation Environment. Exposes the CrimeInvestigationEnv via OpenEnv's HTTP/WebSocket interface. Usage: # Development (with auto-reload): uvicorn server.app:app --reload --host 0.0.0.0 --port 8000 # Or run directly: python -m server.app """ import os import sys import json import base64 from typing import Any, Dict, List, Optional from pydantic import Field # Add project root to path so crime_env is importable sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from fastapi.responses import HTMLResponse from openenv.core.env_server.http_server import create_app from openenv.core.env_server.interfaces import Environment from openenv.core.env_server.types import ( Action, EnvironmentMetadata, Observation, State, ) from crime_env.environment import CrimeInvestigationEnv # ── Pydantic types for OpenEnv ────────────────────────────────────────────── class CrimeAction(Action): """Action schema for the Crime Investigation environment.""" action_string: str = Field( ..., description=( "Action in one of the following formats:\n" " ACTION: ask_question | TARGET: | CONTENT: \n" " ACTION: request_evidence | ITEM: \n" " ACTION: accuse | TARGET: " ), examples=[ "ACTION: ask_question | TARGET: Suspect_A | CONTENT: Where were you?", "ACTION: request_evidence | ITEM: keycard_log", "ACTION: accuse | TARGET: Suspect_A", ], ) class CrimeObservation(Observation): """Observation schema returned by the Crime Investigation environment.""" role: str = Field(default="detective", description="Agent role") briefing: str = Field(default="", description="Case briefing for the detective") turn: int = Field(default=0, description="Current turn number") conversation_history: List[Dict[str, Any]] = Field( default_factory=list, description="Full conversation history" ) evidence_log: List[Dict[str, Any]] = Field( default_factory=list, description="Revealed evidence items" ) message: str = Field(default="", description="System message for the current step") class CrimeState(State): """State schema for the Crime Investigation environment.""" turn: int = Field(default=0, description="Current turn number") is_done: bool = Field(default=False, description="Whether the episode is over") max_turns: int = Field(default=15, description="Maximum turns per episode") evidence_revealed: int = Field(default=0, description="Number of evidence items revealed") contradictions_found: int = Field(default=0, description="Number of contradictions detected") # ── OpenEnv-compatible wrapper ────────────────────────────────────────────── class CrimeInvestigationOpenEnv(Environment[CrimeAction, CrimeObservation, CrimeState]): """OpenEnv wrapper around CrimeInvestigationEnv.""" def __init__(self, **kwargs): super().__init__(**kwargs) self._env = CrimeInvestigationEnv() self._current_obs: Optional[dict] = None def reset( self, seed: Optional[int] = None, episode_id: Optional[str] = None, **kwargs, ) -> CrimeObservation: if hasattr(self, "_reset_rubric"): self._reset_rubric() obs = self._env.reset(**kwargs) self._current_obs = obs return CrimeObservation( role=obs.get("role", "detective"), briefing=obs.get("briefing", ""), turn=obs.get("turn", 0), conversation_history=obs.get("conversation_history", []), evidence_log=obs.get("evidence_log", []), message=obs.get("message", ""), done=False, reward=None, ) def step( self, action: CrimeAction, timeout_s: Optional[float] = None, **kwargs, ) -> CrimeObservation: obs_dict, reward, done, info = self._env.step(action.action_string) self._current_obs = obs_dict return CrimeObservation( role=obs_dict.get("role", "detective"), briefing=obs_dict.get("briefing", ""), turn=obs_dict.get("turn", 0), conversation_history=obs_dict.get("conversation_history", []), evidence_log=obs_dict.get("evidence_log", []), message=obs_dict.get("message", ""), done=done, reward=reward, ) @property def state(self) -> CrimeState: env_state = self._env.state() return CrimeState( turn=env_state.get("turn", 0), is_done=env_state.get("done", False), max_turns=env_state.get("max_turns", 15), evidence_revealed=env_state.get("evidence_revealed", 0), contradictions_found=env_state.get("contradictions_found", 0), ) def get_metadata(self) -> EnvironmentMetadata: return EnvironmentMetadata( name="CrimeInvestigationEnv", description=( "AI Crime Investigation World — a multi-agent RL environment " "where a detective agent interrogates suspects and a witness, " "reviews evidence, and makes an accusation." ), version="1.0.0", ) def close(self) -> None: pass # ── App creation ──────────────────────────────────────────────────────────── app = create_app( CrimeInvestigationOpenEnv, CrimeAction, CrimeObservation, env_name="crime_investigation", max_concurrent_envs=1, ) # ── Custom Endpoints for Dashboard ────────────────────────────────────────── @app.get("/", response_class=HTMLResponse) async def serve_dashboard(): """Serves the dashboard.html interface.""" script_dir = os.path.dirname(os.path.abspath(__file__)) project_root = os.path.dirname(script_dir) dashboard_path = os.path.join(project_root, "dashboard.html") with open(dashboard_path, "r", encoding="utf-8") as f: return f.read() @app.get("/api/run_episode") async def run_episode_api(): """Runs a single test episode and returns the trace. Import is lazy (Issue 9) and execution is offloaded to a thread so the FastAPI event loop isn't blocked (Issue 11). """ import asyncio from test_one_episode import run_test_episode rewards, info, trace = await asyncio.to_thread(run_test_episode) return { "status": "ok", "rewards": rewards, "info": info, "trace": trace } def _moving_average(values: List[float], window: int) -> List[float]: if not values: return [] if window <= 1: return values[:] averaged: List[float] = [] running_sum = 0.0 queue: List[float] = [] for v in values: queue.append(float(v)) running_sum += float(v) if len(queue) > window: running_sum -= queue.pop(0) averaged.append(running_sum / len(queue)) return averaged @app.get("/api/reward_curve") async def reward_curve_api(): """Return training reward history for dashboard/HF demo visibility.""" script_dir = os.path.dirname(os.path.abspath(__file__)) project_root = os.path.dirname(script_dir) rewards_path = os.path.join(project_root, "rewards.json") reward_curve_path = os.path.join(project_root, "reward_curve.png") rewards: List[float] = [] results: List[str] = [] difficulty: List[str] = [] model_name = "unknown" num_episodes = 0 rewards_file_found = os.path.exists(rewards_path) if rewards_file_found: with open(rewards_path, "r", encoding="utf-8") as f: payload = json.load(f) rewards = [float(x) for x in payload.get("rewards", [])] results = [str(x) for x in payload.get("results", [])] difficulty = [str(x) for x in payload.get("difficulty", [])] model_name = str(payload.get("model", "unknown")) num_episodes = int(payload.get("num_episodes", len(rewards))) window = min(20, max(1, len(rewards) // 4)) smoothed = _moving_average(rewards, window) mean_first = sum(rewards[:50]) / max(1, min(50, len(rewards))) if rewards else 0.0 mean_last = sum(rewards[-50:]) / max(1, min(50, len(rewards))) if rewards else 0.0 image_data_url = None if os.path.exists(reward_curve_path): with open(reward_curve_path, "rb") as f: encoded = base64.b64encode(f.read()).decode("ascii") image_data_url = f"data:image/png;base64,{encoded}" n_correct = results.count("correct") n_wrong = results.count("wrong") n_timeout = results.count("timeout") accuracy_pct = round(n_correct / max(1, len(results)) * 100, 1) if results else 0.0 current_difficulty = difficulty[-1] if difficulty else "unknown" difficulty_counts = { "easy": difficulty.count("easy"), "medium": difficulty.count("medium"), "hard": difficulty.count("hard"), } return { "status": "ok", "has_data": bool(rewards), "rewards_file_found": rewards_file_found, "message": ( "Training data loaded" if rewards else "No rewards.json found on server yet. Commit and push training artifacts to update this panel." ), "model": model_name, "num_episodes": num_episodes, "rewards": rewards, "smoothed": smoothed, "smooth_window": window, "mean_first_50": round(mean_first, 4), "mean_last_50": round(mean_last, 4), "improvement": round(mean_last - mean_first, 4), "accuracy_pct": accuracy_pct, "current_difficulty": current_difficulty, "difficulty": difficulty, "difficulty_counts": difficulty_counts, "results": { "correct": n_correct, "wrong": n_wrong, "timeout": n_timeout, }, "image_data_url": image_data_url, } @app.get("/api/health") async def health_api(): """Simple endpoint used for deployment sanity checks.""" return {"status": "ok", "service": "crime-investigation"} def main(host: str = "0.0.0.0", port: int = 8000): """Entry point for direct execution.""" import uvicorn uvicorn.run(app, host=host, port=port) if __name__ == "__main__": import argparse parser = argparse.ArgumentParser() parser.add_argument("--port", type=int, default=8000) args = parser.parse_args() main(port=args.port)