jeevan2717's picture
Add expert scenario - security breach, 4-level difficulty, updated README and openenv.yaml
f099817
Raw
History Blame Contribute Delete
9.87 kB
"""
FastAPI server for the Incident Post-Mortem Writer environment.
Exposes all required OpenEnv endpoints:
REST: /health /reset /step /state /tasks /grade /docs
WebSocket: /ws (persistent session — required by OpenEnv spec)
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import uuid
from typing import Any, Dict
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from env.models import Action, ActionType, SectionName
from server.environment import PostMortemEnvironment
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(
title="Incident Post-Mortem Writer",
description=(
"An OpenEnv environment where an AI agent learns to write structured "
"incident post-mortems from raw alert logs and Slack threads. "
"3 difficulty levels: easy, medium, hard."
),
version="1.0.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
MAX_CONCURRENT_ENVS = int(os.environ.get("MAX_CONCURRENT_ENVS", "100"))
_sessions: Dict[str, PostMortemEnvironment] = {}
_http_envs: Dict[str, PostMortemEnvironment] = {
"easy": PostMortemEnvironment(difficulty="easy"),
"medium": PostMortemEnvironment(difficulty="medium"),
"hard": PostMortemEnvironment(difficulty="hard"),
"expert": PostMortemEnvironment(difficulty="expert"),
}
_active_difficulty = os.environ.get("DIFFICULTY", "easy")
def _get_http_env() -> PostMortemEnvironment:
return _http_envs[_active_difficulty]
class ResetRequest(BaseModel):
difficulty: str = "easy"
class ActionRequest(BaseModel):
action_type: str
section_name: str | None = None
section_content: str | None = None
query_service: str | None = None
query_from: str | None = None
query_to: str | None = None
action_item_description: str | None = None
action_item_owner: str | None = None
action_item_due_date: str | None = None
def _parse_action(req: ActionRequest) -> Action:
try:
action_type = ActionType(req.action_type)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid action_type '{req.action_type}'. Valid: {[a.value for a in ActionType]}"
)
section_name = None
if req.section_name:
try:
section_name = SectionName(req.section_name)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid section_name '{req.section_name}'. Valid: {[s.value for s in SectionName]}"
)
return Action(
action_type=action_type,
section_name=section_name,
section_content=req.section_content,
query_service=req.query_service,
query_from=req.query_from,
query_to=req.query_to,
action_item_description=req.action_item_description,
action_item_owner=req.action_item_owner,
action_item_due_date=req.action_item_due_date,
)
def _parse_action_dict(data: dict) -> Action:
req = ActionRequest(**{k: v for k, v in data.items() if k != "command"})
return _parse_action(req)
@app.on_event("startup")
async def startup():
logger.info("Incident Post-Mortem Writer environment starting...")
logger.info(f"MAX_CONCURRENT_ENVS={MAX_CONCURRENT_ENVS}")
logger.info("Scenarios loaded: easy, medium, hard. Server ready.")
@app.on_event("shutdown")
async def shutdown():
logger.info(f"Shutting down. Cleaning {len(_sessions)} sessions.")
_sessions.clear()
@app.get("/health")
def health() -> Dict[str, Any]:
"""Health check — required by OpenEnv validator."""
return {
"status": "healthy",
"version": "1.0.0",
"active_sessions": len(_sessions),
"max_sessions": MAX_CONCURRENT_ENVS,
}
@app.post("/reset")
def reset(req: ResetRequest = ResetRequest()) -> Dict[str, Any]:
"""Reset environment. difficulty: easy | medium | hard"""
global _active_difficulty
if req.difficulty not in _http_envs:
raise HTTPException(status_code=400, detail=f"difficulty must be one of {list(_http_envs.keys())}")
_active_difficulty = req.difficulty
result = _get_http_env().reset()
return result.dict()
@app.post("/step")
def step(req: ActionRequest) -> Dict[str, Any]:
"""Execute one action in the environment."""
action = _parse_action(req)
result = _get_http_env().step(action)
return result.dict()
@app.get("/state")
def state() -> Dict[str, Any]:
"""Return current episode state."""
return _get_http_env().state
@app.get("/tasks")
def list_tasks() -> Dict[str, Any]:
"""List all available tasks."""
return {
"tasks": [
{"id": "easy", "name": "Single-Service Database Outage", "difficulty": "easy", "max_steps": 25, "max_queries": 8},
{"id": "medium", "name": "Cascading Microservices Failure", "difficulty": "medium", "max_steps": 25, "max_queries": 8},
{"id": "hard", "name": "Multi-Service Degradation with False Causes","difficulty": "hard", "max_steps": 25, "max_queries": 8},
{"id": "expert", "name": "Security Breach — Compromised API Credential", "difficulty": "expert", "max_steps": 25, "max_queries": 6},
]
}
@app.post("/grade")
def grade_current() -> Dict[str, Any]:
"""Return current grade without ending the episode."""
env = _get_http_env()
if env._grade_result:
return env._grade_result.dict()
return {"message": "No grade yet. Call SUBMIT action to trigger grading."}
@app.get("/")
def root() -> Dict[str, str]:
return {"name": "Incident Post-Mortem Writer", "version": "1.0.0",
"docs": "/docs", "health": "/health", "tasks": "/tasks", "websocket": "/ws"}
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""
Persistent WebSocket session — each connection gets its own isolated env.
Client sends JSON:
{"command": "reset", "difficulty": "easy"}
{"command": "step", "action_type": "QUERY_LOGS", "query_service": "payments", ...}
{"command": "state"}
{"command": "close"}
Server responds with JSON:
{"type": "reset_result", "data": {...}}
{"type": "step_result", "data": {...}}
{"type": "state_result", "data": {...}}
{"type": "error", "message": "..."}
"""
if len(_sessions) >= MAX_CONCURRENT_ENVS:
await websocket.close(code=1013, reason="Max concurrent sessions reached")
return
await websocket.accept()
session_id = str(uuid.uuid4())[:8]
session_env = PostMortemEnvironment(difficulty="easy")
_sessions[session_id] = session_env
logger.info(f"WS session {session_id} opened. Active: {len(_sessions)}")
try:
await websocket.send_json({
"type": "connected",
"session_id": session_id,
"message": "Send {\"command\": \"reset\", \"difficulty\": \"easy\"} to start.",
})
while True:
try:
raw = await asyncio.wait_for(websocket.receive_text(), timeout=300)
except asyncio.TimeoutError:
await websocket.send_json({"type": "error", "message": "Session timeout."})
break
try:
msg = json.loads(raw)
except json.JSONDecodeError:
await websocket.send_json({"type": "error", "message": "Invalid JSON."})
continue
command = msg.get("command", "")
if command == "reset":
difficulty = msg.get("difficulty", "easy")
if difficulty not in ["easy", "medium", "hard"]:
await websocket.send_json({"type": "error", "message": f"Invalid difficulty '{difficulty}'."})
continue
session_env.difficulty = difficulty
result = session_env.reset()
await websocket.send_json({"type": "reset_result", "data": result.dict()})
elif command == "step":
try:
action = _parse_action_dict(msg)
result = session_env.step(action)
await websocket.send_json({"type": "step_result", "data": result.dict()})
except HTTPException as e:
await websocket.send_json({"type": "error", "message": e.detail})
except Exception as e:
await websocket.send_json({"type": "error", "message": str(e)})
elif command == "state":
await websocket.send_json({"type": "state_result", "data": session_env.state})
elif command == "close":
await websocket.send_json({"type": "closed", "message": "Session closed."})
break
else:
await websocket.send_json({"type": "error", "message": f"Unknown command '{command}'. Valid: reset, step, state, close"})
except WebSocketDisconnect:
logger.info(f"WS session {session_id} disconnected.")
except Exception as e:
logger.error(f"WS session {session_id} error: {e}")
finally:
_sessions.pop(session_id, None)
logger.info(f"Session {session_id} cleaned up. Active: {len(_sessions)}")
def main():
"""Entry point for the OpenEnv server — callable via project.scripts."""
import os
import uvicorn
uvicorn.run(
"server.app:app",
host=os.environ.get("HOST", "0.0.0.0"),
port=int(os.environ.get("PORT", "7860")),
workers=int(os.environ.get("WORKERS", "1")),
)
if __name__ == "__main__":
main()