META-OPEN-ENV / server.py
jarvisemitra's picture
Upload folder using huggingface_hub
65651e5 verified
Raw
History Blame Contribute Delete
14.1 kB
"""
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
# ============================================================================
@app.get("/health", response_model=HealthResponse)
async def health_check():
"""Health check endpoint"""
return HealthResponse(
status="healthy",
environment="support-ticket-triage",
version="1.0.0"
)
@app.get("/metadata", response_model=MetadataResponse)
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()],
)
@app.get("/tasks")
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
]
}
@app.get("/graders")
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())}
@app.get("/schema")
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(),
}
@app.post("/mcp")
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",
)
@app.post("/reset")
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))
@app.post("/step")
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))
@app.get("/state")
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())
@app.post("/grade", response_model=GradeResponse)
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
@app.get("/")
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()