File size: 10,353 Bytes
a9eec0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fd1cd36
a9eec0e
fd1cd36
a9eec0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fd1cd36
 
 
 
 
 
 
a9eec0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
"""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: <agent> | CONTENT: <question>\n"
            "  ACTION: request_evidence | ITEM: <item>\n"
            "  ACTION: accuse | TARGET: <suspect>"
        ),
        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()
        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] = []
    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", [])]
        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}"

    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),
        "results": {
            "correct": results.count("correct"),
            "wrong": results.count("wrong"),
            "timeout": results.count("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)