Spaces:
Sleeping
Sleeping
| """ | |
| FastAPI server for the Support Ticket Triage Environment. | |
| This server exposes the OpenEnv API endpoints for reset, step, and state. | |
| """ | |
| import os | |
| import json | |
| import uvicorn | |
| from datetime import datetime | |
| from typing import Any, Optional, Dict, List | |
| from fastapi import FastAPI, HTTPException, Request | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import JSONResponse | |
| from pydantic import BaseModel | |
| from env import ( | |
| SupportTicketEnv, | |
| SupportAction, | |
| SupportObservation, | |
| SupportState, | |
| get_task_metadata, | |
| GRADER_FUNCTIONS, | |
| TASK_CONFIGS, | |
| TicketCategory, | |
| PriorityLevel, | |
| ActionType, | |
| ) | |
| class DateTimeEncoder(json.JSONEncoder): | |
| """Custom JSON encoder that handles datetime objects""" | |
| def default(self, o): | |
| if isinstance(o, datetime): | |
| return o.isoformat() | |
| return super().default(o) | |
| # ============================================================================ | |
| # FastAPI Application | |
| # ============================================================================ | |
| app = FastAPI( | |
| title="Support Ticket Triage Environment", | |
| description="A real-world customer support ticket management environment for AI agents", | |
| version="1.0.0" | |
| ) | |
| # Enable CORS for all origins (needed for HF Spaces) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # Initialize environment | |
| env = SupportTicketEnv() | |
| # ============================================================================ | |
| # Request/Response Models | |
| # ============================================================================ | |
| class ResetRequest(BaseModel): | |
| task_id: Optional[str] = "categorize_ticket" | |
| class HealthResponse(BaseModel): | |
| status: str | |
| environment: str | |
| version: str | |
| class GraderMetadata(BaseModel): | |
| id: str | |
| name: str | |
| description: str | |
| class TaskMetadata(BaseModel): | |
| id: str | |
| name: str | |
| description: str | |
| difficulty: Optional[str] = None | |
| max_steps: Optional[int] = None | |
| success_threshold: Optional[float] = None | |
| graders: List[GraderMetadata] = [] | |
| class MetadataResponse(BaseModel): | |
| name: str | |
| description: str | |
| version: str | |
| mode: str | |
| tasks: List[TaskMetadata] | |
| class GradeRequest(BaseModel): | |
| grader_id: str | |
| sample: Dict[str, Any] | |
| class TaskGradeRequest(BaseModel): | |
| task_id: str | |
| input: Dict[str, Any] | |
| grader_id: Optional[str] = None # Optional; uses first grader of task if not specified | |
| class GradeResponse(BaseModel): | |
| grader_id: str | |
| score: float | |
| status: str = "success" | |
| message: str = "" | |
| task_id: Optional[str] = None | |
| # ============================================================================ | |
| # API Endpoints | |
| # ============================================================================ | |
| async def health_check(): | |
| """Health check endpoint""" | |
| return HealthResponse( | |
| status="healthy", | |
| environment="support-ticket-triage", | |
| version="1.0.0" | |
| ) | |
| async def get_metadata(): | |
| """Return OpenEnv runtime metadata required by validators.""" | |
| return MetadataResponse( | |
| name="support-ticket-triage", | |
| description="A real-world customer support ticket management environment for AI agents", | |
| version="1.0.0", | |
| mode="simulation", | |
| tasks=[TaskMetadata(**task) for task in get_task_metadata()], | |
| ) | |
| async def get_tasks(): | |
| """Return list of tasks with grader information.""" | |
| tasks_list = get_task_metadata() | |
| return { | |
| "tasks": [ | |
| { | |
| "id": task["id"], | |
| "name": task["name"], | |
| "description": task["description"], | |
| "difficulty": task.get("difficulty"), | |
| "max_steps": task.get("max_steps"), | |
| "success_threshold": task.get("success_threshold"), | |
| "has_grader": True, | |
| "graders": task.get("graders", []) | |
| } | |
| for task in tasks_list | |
| ] | |
| } | |
| async def get_graders(): | |
| """Return grader metadata for all tasks.""" | |
| tasks_list = get_task_metadata() | |
| graders_map = {} | |
| for task in tasks_list: | |
| task_graders = task.get("graders", []) | |
| for grader in task_graders: | |
| grader_id = grader.get("id") | |
| if grader_id: | |
| graders_map[grader_id] = { | |
| "id": grader_id, | |
| "name": grader.get("name"), | |
| "description": grader.get("description"), | |
| "task": task["id"] | |
| } | |
| return {"graders": list(graders_map.values())} | |
| async def get_schema(): | |
| """Return action/observation/state schemas required by validators.""" | |
| return { | |
| "action": SupportAction.model_json_schema(), | |
| "observation": SupportObservation.model_json_schema(), | |
| "state": SupportState.model_json_schema(), | |
| } | |
| async def mcp_endpoint(request: Request): | |
| """Minimal JSON-RPC compatible endpoint for validator reachability checks.""" | |
| payload: dict[str, Any] = {} | |
| try: | |
| payload = await request.json() | |
| except Exception: | |
| payload = {} | |
| request_id = payload.get("id") | |
| method = payload.get("method", "unknown") | |
| if method == "initialize": | |
| result: dict[str, Any] = { | |
| "protocolVersion": "2024-11-05", | |
| "serverInfo": { | |
| "name": "support-ticket-triage", | |
| "version": "1.0.0", | |
| }, | |
| "capabilities": {}, | |
| } | |
| else: | |
| result = { | |
| "status": "ok", | |
| "message": "MCP endpoint is reachable", | |
| } | |
| return JSONResponse( | |
| content={ | |
| "jsonrpc": "2.0", | |
| "id": request_id, | |
| "result": result, | |
| }, | |
| media_type="application/json", | |
| ) | |
| async def reset_environment(request: ResetRequest = ResetRequest()): | |
| """ | |
| Reset the environment to initial state. | |
| Args: | |
| task_id: The task to run (categorize_ticket, prioritize_and_route, full_workflow) | |
| Returns: | |
| EnvResult with initial observation | |
| """ | |
| try: | |
| result = env.reset(request.task_id if request.task_id else "categorize_ticket") | |
| return JSONResponse(content=result, media_type="application/json") | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def step_environment(action: SupportAction): | |
| """ | |
| Execute an action in the environment. | |
| Args: | |
| action: The action to execute (SupportAction model) | |
| Returns: | |
| EnvResult with observation, reward, done flag, and info | |
| """ | |
| try: | |
| result = env.step(action) | |
| return JSONResponse(content=result, media_type="application/json") | |
| except ValueError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def get_state(): | |
| """ | |
| Get the current environment state. | |
| Returns: | |
| Current SupportState | |
| """ | |
| state = env.get_state() | |
| if state is None: | |
| raise HTTPException(status_code=404, detail="Environment not initialized. Call /reset first.") | |
| return JSONResponse(content=state.model_dump()) | |
| async def grade_sample(request: Dict[str, Any]): | |
| """ | |
| Grade a sample using a grader. Supports two formats: | |
| Format 1 (Direct grader): | |
| {"grader_id": "categorization_accuracy", "sample": {...}} | |
| Format 2 (Task-based): | |
| {"task_id": "categorize_ticket", "input": {...}, "grader_id": "optional"} | |
| Returns: | |
| GradeResponse with score (0.0-1.0) | |
| """ | |
| # Determine which format is being used | |
| if "grader_id" in request and "sample" in request: | |
| # Format 1: Direct grader format | |
| grader_id = request["grader_id"] | |
| sample = request["sample"] | |
| task_id = None | |
| elif "task_id" in request and "input" in request: | |
| # Format 2: Task-based format | |
| task_id = request["task_id"] | |
| input_data = request["input"] | |
| # Get task config to find graders | |
| if task_id not in TASK_CONFIGS: | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"Unknown task: {task_id}. Available tasks: {', '.join(TASK_CONFIGS.keys())}" | |
| ) | |
| task_config = TASK_CONFIGS[task_id] | |
| graders = task_config.get("graders", []) | |
| if not graders: | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"No graders available for task {task_id}" | |
| ) | |
| # Use specified grader or first available | |
| requested_grader_id = request.get("grader_id") | |
| if requested_grader_id: | |
| grader_id = requested_grader_id | |
| # Verify it's valid for this task | |
| valid_grader_ids = [g["id"] for g in graders] | |
| if grader_id not in valid_grader_ids: | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"Grader {grader_id} not available for task {task_id}. Valid graders: {valid_grader_ids}" | |
| ) | |
| else: | |
| grader_id = graders[0]["id"] | |
| # Convert task input to grader sample format | |
| sample = _convert_input_to_sample(task_id, input_data, grader_id) | |
| else: | |
| raise HTTPException( | |
| status_code=400, | |
| detail="Request must include either (grader_id + sample) or (task_id + input)" | |
| ) | |
| # Check grader exists | |
| if grader_id not in GRADER_FUNCTIONS: | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"Unknown grader: {grader_id}. Available graders: {', '.join(GRADER_FUNCTIONS.keys())}" | |
| ) | |
| try: | |
| grader_fn = GRADER_FUNCTIONS[grader_id] | |
| score = grader_fn(sample) | |
| # Ensure score is in valid range | |
| score = max(0.0, min(1.0, float(score))) | |
| return GradeResponse( | |
| grader_id=grader_id, | |
| score=score, | |
| status="success", | |
| message=f"Successfully graded sample with {grader_id}", | |
| task_id=task_id | |
| ) | |
| except Exception as e: | |
| raise HTTPException( | |
| status_code=500, | |
| detail=f"Error running grader {grader_id}: {str(e)}" | |
| ) | |
| def _convert_input_to_sample(task_id: str, input_data: Dict[str, Any], grader_id: str) -> Dict[str, Any]: | |
| """Convert task input format to grader sample format.""" | |
| sample = {} | |
| if task_id == "categorize_ticket": | |
| # For categorization task, extract ticket info and expected values | |
| if "ticket_text" in input_data: | |
| # This is raw input - use default expected values or parse | |
| sample["agent_category"] = input_data.get("agent_category", TicketCategory.GENERAL) | |
| sample["expected_category"] = input_data.get("expected_category", TicketCategory.GENERAL) | |
| sample["agent_priority"] = input_data.get("agent_priority", PriorityLevel.MEDIUM) | |
| sample["expected_priority"] = input_data.get("expected_priority", PriorityLevel.MEDIUM) | |
| else: | |
| # Structured input expected | |
| sample.update(input_data) | |
| elif task_id == "prioritize_and_route": | |
| # For prioritization task | |
| sample["priority_scores"] = input_data.get("priority_scores", []) | |
| elif task_id == "full_workflow": | |
| # For full workflow task | |
| sample["categorization_score"] = input_data.get("categorization_score", 0.0) | |
| sample["response_quality"] = input_data.get("response_quality", 0.0) | |
| sample["escalation_score"] = input_data.get("escalation_score", 0.0) | |
| sample["completion_ratio"] = input_data.get("completion_ratio", 0.0) | |
| elif task_id == "escalation_specialist": | |
| # For escalation task | |
| sample["expected_escalation"] = input_data.get("expected_escalation", False) | |
| sample["agent_escalated"] = input_data.get("agent_escalated", False) | |
| sample["expected_team"] = input_data.get("expected_team", "") | |
| sample["agent_team"] = input_data.get("agent_team", "") | |
| return sample | |
| async def root(): | |
| """Root endpoint with API information""" | |
| return { | |
| "name": "Support Ticket Triage Environment", | |
| "version": "1.0.0", | |
| "description": "A real-world customer support ticket management environment for AI agents", | |
| "endpoints": { | |
| "health": "GET /health", | |
| "metadata": "GET /metadata", | |
| "tasks": "GET /tasks", | |
| "graders": "GET /graders", | |
| "reset": "POST /reset", | |
| "step": "POST /step", | |
| "state": "GET /state", | |
| "grade": "POST /grade" | |
| }, | |
| "tasks": get_task_metadata() | |
| } | |
| # ============================================================================ | |
| # Main Entry Point | |
| # ============================================================================ | |
| def main(): | |
| """Run the FastAPI server.""" | |
| port = int(os.getenv("PORT", 8000)) | |
| host = os.getenv("HOST", "0.0.0.0") | |
| print(f"Starting Support Ticket Triage Environment server on {host}:{port}") | |
| uvicorn.run(app, host=host, port=port) | |
| if __name__ == "__main__": | |
| main() |