""" FastAPI server — CustomerSupportEnv. Production hardening applied: - Rate limiting: 30 /reset calls per minute per IP (slowapi) - Max session cap: 500 concurrent sessions hard limit - Request body size limit: 64KB enforced at middleware level - Session TTL: abandoned sessions swept after 30 minutes - CORS: open for browser and HF Spaces clients - Structured JSON logging via standard logging module - Real health check: verifies ticket store is functional """ from __future__ import annotations from contextlib import asynccontextmanager import asyncio import logging import time import uuid import structlog from typing import Literal, Optional from fastapi import FastAPI, HTTPException, Query, Request, Security, Depends from fastapi.middleware.cors import CORSMiddleware from fastapi.security import APIKeyHeader from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse import os from pydantic import BaseModel, Field from slowapi import Limiter from slowapi.errors import RateLimitExceeded from slowapi.util import get_remote_address import httpx from env.environment import CustomerSupportEnv, HierarchicalCustomerSupportEnv from env.graders import grade as run_grader from env.models import Action from env.ticket_store import ticket_store _ALL_TASKS = ("easy", "medium", "hard", "nightmare", "hierarchy_easy", "hierarchy_medium", "hierarchy_hard", "curriculum_basic", "curriculum_supervisor", "curriculum_full_hierarchy", "curriculum_nightmare", "multi_domain") # ── Logging ──────────────────────────────────────────────────────────────────── structlog.configure( processors=[ structlog.processors.TimeStamper(fmt="iso"), structlog.processors.JSONRenderer() ] ) logger = structlog.get_logger("customer_support_env") API_KEY_NAME = "X-API-Key" api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=True) _DEFAULT_API_KEY = "meta_hack_2026" EXPECTED_API_KEY = os.environ.get("ADMIN_API_KEY", _DEFAULT_API_KEY) if EXPECTED_API_KEY == _DEFAULT_API_KEY: import warnings warnings.warn( "ADMIN_API_KEY is not set — using the default key 'meta_hack_2026'. " "Set ADMIN_API_KEY in your environment for production deployments.", stacklevel=1, ) async def verify_api_key(api_key: str = Security(api_key_header)): if api_key != EXPECTED_API_KEY: raise HTTPException( status_code=403, detail="Forbidden: Invalid X-API-Key", ) return api_key async def _periodic_sweep(): while True: await asyncio.sleep(300) n = _sweep_expired_sessions() if n: logger.info("periodic_sweep", removed=n) @asynccontextmanager async def lifespan(app: FastAPI): task = asyncio.create_task(_periodic_sweep()) yield task.cancel() # ── Rate limiter ─────────────────────────────────────────────────────────────── # Requests authenticated with a valid X-API-Key are exempt from rate limits so # that training pipelines and test scripts never hit 429 errors. Unauthenticated # browser / public access is still throttled per-IP. def _rate_limit_key(request: Request) -> str | None: """ Key function for slowapi rate limiting. Requests with a valid X-API-Key are exempt: returning None causes slowapi to skip the limit entirely (``if all(args)`` guard in __evaluate_limits). This prevents RL training pipelines and automated test scripts from ever hitting 429 errors while keeping public/unauthenticated access throttled. """ if request.headers.get("X-API-Key", "") == EXPECTED_API_KEY: return None # bypass — slowapi skips limit when key is falsy return get_remote_address(request) limiter = Limiter(key_func=_rate_limit_key, default_limits=["200/minute"]) async def _json_rate_limit_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse: """Return a proper JSON 429 instead of the default HTML text response. The default handler returns plain text which causes JSONDecodeError in training scripts that always expect JSON.""" return JSONResponse( status_code=429, content={ "detail": f"Rate limit exceeded: {exc.detail}. " "Add X-API-Key header to bypass limits for training/testing.", "retry_after": getattr(exc, "retry_after", None), }, headers={"Retry-After": str(getattr(exc, "retry_after", 60))}, ) # ── Config ───────────────────────────────────────────────────────────────────── MAX_SESSIONS = 500 # hard cap on concurrent sessions SESSION_TTL_SECONDS = 300 # 5 minutes MAX_BODY_BYTES = 64 * 1024 # 64KB per request AGENT_MODEL_URL = os.environ.get("AGENT_MODEL_URL", "http://localhost:8001") MAX_HIERARCHY_ITERATIONS = 8 # Normalize common model typos/variants to valid ActionType values _ACTION_TYPE_ALIASES: dict[str, str] = { "response": "respond", "escalate_to_supervisor": "supervisor_escalate", "supervisor_escalate_to_manager": "supervisor_escalate", "manager_send_back_to_agent": "manager_send_back", "reject": "supervisor_reject", "approve": "supervisor_approve", "override": "manager_override", "resolve": "manager_resolve", } app = FastAPI( title="CustomerSupportEnv", version="2.1.0", description="OpenEnv-compliant hierarchical multi-agent RL environment with progressive curriculum.", lifespan=lifespan, ) # ── Middleware ───────────────────────────────────────────────────────────────── app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _json_rate_limit_handler) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) @app.middleware("http") async def enforce_body_size(request: Request, call_next): """Reject requests with body larger than MAX_BODY_BYTES.""" content_length = request.headers.get("content-length") if content_length and int(content_length) > MAX_BODY_BYTES: logger.warning("body_too_large", content_length=content_length, ip=get_remote_address(request)) return JSONResponse( status_code=413, content={"detail": f"Request body too large. Maximum allowed: {MAX_BODY_BYTES} bytes."}, ) return await call_next(request) @app.middleware("http") async def log_requests(request: Request, call_next): """Log every request with method, path, status, and duration.""" start = time.monotonic() response = await call_next(request) duration_ms = round((time.monotonic() - start) * 1000, 1) logger.info( "request", method=request.method, path=request.url.path, status=response.status_code, duration_ms=duration_ms, ip=get_remote_address(request) ) return response # ── Session storage ──────────────────────────────────────────────────────────── # Key: session_id → (CustomerSupportEnv, created_at monotonic timestamp) _sessions: dict[str, tuple[CustomerSupportEnv, float]] = {} # Storage for completed sessions (replays) and the leaderboard _completed_sessions: dict[str, dict] = {} _leaderboard: list[dict] = [] class BenchmarkSubmit(BaseModel): session_id: str = Field(..., description="UUID of the completed proof-of-play session") agent_name: str = Field(..., min_length=3, max_length=32, pattern=r"^[a-zA-Z0-9_\-]+$") class ChatRequest(BaseModel): session_id: str message: str = Field(..., min_length=1, max_length=4000) def _sweep_expired_sessions() -> int: """Remove sessions older than SESSION_TTL_SECONDS. Returns count removed.""" now = time.monotonic() expired = [ sid for sid, (_, created_at) in _sessions.items() if now - created_at > SESSION_TTL_SECONDS ] for sid in expired: del _sessions[sid] if expired: logger.info("session_sweep", expired_count=len(expired), active_sessions=len(_sessions)) return len(expired) def _get_env(session_id: str) -> CustomerSupportEnv: """Look up a session, sweeping expired ones first.""" _sweep_expired_sessions() entry = _sessions.get(session_id) if entry is None: raise HTTPException( status_code=404, detail=f"Session '{session_id}' not found. Call /reset to start a new episode.", ) return entry[0] import re import copy def sanitize_pii(state: dict) -> dict: """Mask PII in conversation history to prevent sensitive data exposure via endpoints.""" if "history" not in state: return state s_state = copy.deepcopy(state) email_regex = re.compile(r"[\w\.-]+@[\w\.-]+\.\w+") for msg in s_state["history"]: if "content" in msg: msg["content"] = email_regex.sub("[REDACTED_EMAIL]", msg["content"]) return s_state # Fields stripped from /replay to prevent grading-criteria harvesting _TICKET_STRIP_FIELDS = { "expected_resolution_type", "ideal_max_steps", "required_info_before_close", "follow_up_info", } def sanitize_replay(state: dict) -> dict: """Strip ticket grading criteria from replay responses.""" state = sanitize_pii(state) if "ticket" in state and isinstance(state["ticket"], dict): state = copy.deepcopy(state) for field in _TICKET_STRIP_FIELDS: state["ticket"].pop(field, None) return state # ── Endpoints ────────────────────────────────────────────────────────────────── @app.post("/reset") @limiter.limit("30/minute") def reset( request: Request, task: Literal[ "easy", "medium", "hard", "nightmare", "hierarchy_easy", "hierarchy_medium", "hierarchy_hard", "curriculum_basic", "curriculum_supervisor", "curriculum_full_hierarchy", "curriculum_nightmare", "multi_domain", ] = Query(default="easy"), _key: str = Depends(verify_api_key), ): """ Start a new episode. Returns session_id + initial observation. Automatically uses HierarchicalCustomerSupportEnv for hierarchy_* and curriculum_* tasks. Rate limited: 30 resets/minute per IP. Hard cap: 500 concurrent sessions. """ _sweep_expired_sessions() if len(_sessions) >= MAX_SESSIONS: logger.warning("session_cap_hit", active_sessions=len(_sessions)) raise HTTPException( status_code=503, detail=f"Server at capacity ({MAX_SESSIONS} concurrent sessions). Try again later.", ) # Auto-select environment class based on task type from env.environment import TASK_CONFIG is_hierarchical = TASK_CONFIG.get(task, {}).get("hierarchical", False) if is_hierarchical: env = HierarchicalCustomerSupportEnv(task=task) else: env = CustomerSupportEnv(task=task) obs = env.reset() _sessions[env.session_id] = (env, time.monotonic()) logger.info("session_created", session_id=env.session_id, task=task, hierarchical=is_hierarchical, active_sessions=len(_sessions)) return { "session_id": env.session_id, "observation": obs.model_dump(), } @app.post("/step") @limiter.limit("200/minute") def step( request: Request, session_id: str = Query(..., description="Session ID returned by /reset"), action: Action = ..., _key: str = Depends(verify_api_key), ): """ Apply an action to the environment. Returns observation, reward, done, info (and final_score on done). For human-in-the-loop customer simulation, use /chat instead. """ env = _get_env(session_id) try: obs, reward, done, info = env.step(action) except RuntimeError as exc: raise HTTPException(status_code=409, detail=str(exc)) response = { "observation": obs.model_dump(), "reward": reward.model_dump(), "done": done, "info": info, } if done: state = env.state() try: final_score = run_grader(env.task, state) except Exception: final_score = reward.value response["final_score"] = final_score # Save replay data state["final_score"] = final_score _completed_sessions[session_id] = state # Enforce memory cap on completed sessions (prevent HF Space OOM over thousands of episodes) if len(_completed_sessions) > 1000: oldest = next(iter(_completed_sessions)) del _completed_sessions[oldest] del _sessions[session_id] logger.info("session_completed", session_id=session_id, task=env.task, final_score=final_score, steps=obs.step) return response @app.post("/chat") @limiter.limit("120/minute") async def chat(request: Request, body: ChatRequest, _key: str = Depends(verify_api_key)): """ Single-port convenience endpoint: takes a human customer message, calls the model server for an action, steps the env, and returns a flat chat response. Internally loops through hierarchy turns (supervisor/manager) so the caller only ever sees support-agent replies. Set AGENT_MODEL_URL to point at a trained model; defaults to host port 8001. """ env = _get_env(body.session_id) human_msg = body.message last_action = None last_reward = None last_done = False obs_after = None final_score = None async with httpx.AsyncClient(timeout=60.0) as client: for iteration in range(MAX_HIERARCHY_ITERATIONS): obs = env._build_observation().model_dump() # Pass the human's message as a virtual message on the first iteration # so the model sees what the customer just said. virtual_messages = ( [{"role": "customer", "content": human_msg}] if iteration == 0 else [] ) try: r = await client.post( f"{AGENT_MODEL_URL}/agent-action", json={"observation": obs, "virtualMessages": virtual_messages}, ) r.raise_for_status() action_dict = r.json()["action"] # Normalize model typos before pydantic validation raw_at = action_dict.get("action_type", "") action_dict["action_type"] = _ACTION_TYPE_ALIASES.get(raw_at, raw_at) except httpx.RequestError as exc: raise HTTPException( 503, detail=f"Agent model unreachable at {AGENT_MODEL_URL}: {exc}. " f"Start serve_inference.py or set AGENT_MODEL_URL.", ) except (KeyError, ValueError) as exc: raise HTTPException(502, detail=f"Model returned malformed response: {exc}") action = Action(**action_dict) try: obs_after, reward, done, info = env.step( action, human_customer_message=human_msg if iteration == 0 else None, ) except RuntimeError as exc: raise HTTPException(409, detail=str(exc)) last_action = action last_reward = reward last_done = done if done: state_data = env.state() try: final_score = run_grader(env.task, state_data) except Exception: final_score = reward.value state_data["final_score"] = final_score _completed_sessions[body.session_id] = state_data if len(_completed_sessions) > 1000: del _completed_sessions[next(iter(_completed_sessions))] logger.info("session_completed", session_id=body.session_id, task=env.task, final_score=final_score, steps=obs_after.step) del _sessions[body.session_id] break # Break when a message was actually delivered to the customer: # - supervisor_approve / manager_override / manager_resolve: # hierarchy tier signed off — message is out, return to human. # - respond / close / request_info from support_agent on a flat task # (no supervisor review): agent message went straight to customer. # supervisor_feedback / supervisor_reject keep the loop going so L1 revises. _HIERARCHY_DELIVER = {"supervisor_approve", "manager_override", "manager_resolve"} _FLAT_DELIVER = {"respond", "close", "request_info"} if last_action.action_type in _HIERARCHY_DELIVER: break if (obs_after.active_role == "support_agent" and last_action.action_type in _FLAT_DELIVER): break else: raise HTTPException( 500, detail=f"Hierarchy did not resolve within {MAX_HIERARCHY_ITERATIONS} iterations", ) agent_text = ( last_action.message or last_action.reason or last_action.feedback_to_agent or "" ) return { "agent_reply": agent_text, "action_type": last_action.action_type, "active_role": last_action.role or "support_agent", "reward": last_reward.value, "step": obs_after.step, "max_steps": obs_after.max_steps, "done": last_done, "customer_sentiment": obs_after.customer_sentiment, "unresolved_issues": obs_after.unresolved_issues, "environment_event": obs_after.environment_event, "final_score": final_score, } @app.get("/state/{session_id}") def state(request: Request, session_id: str, _key: str = Depends(verify_api_key)): """ Return full internal state of an active session. Conversation history is sanitized of simulated PII. """ env = _get_env(session_id) return sanitize_pii(env.state()) @app.get("/replay/{session_id}") def replay(request: Request, session_id: str, _key: str = Depends(verify_api_key)): """ Return the full internal state and history of a completed session. Ticket grading criteria (expected resolution, ideal steps, required info) are stripped. """ if session_id not in _completed_sessions: raise HTTPException( status_code=404, detail=f"Completed session '{session_id}' not found. Either it's still active, it expired, or it never existed." ) return sanitize_replay(_completed_sessions[session_id]) @app.get("/leaderboard") def get_leaderboard(request: Request): """Return the global leaderboard, sorted by score descending.""" public_fields = ("agent_name", "task_level", "total_score", "steps_taken") return [ {k: e[k] for k in public_fields if k in e} for e in sorted(_leaderboard, key=lambda x: x["total_score"], reverse=True) ] @app.post("/leaderboard/submit") @limiter.limit("30/minute") def submit_leaderboard(request: Request, submission: BenchmarkSubmit, _key: str = Depends(verify_api_key)): """Submit benchmark results safely via proof-of-play mechanics.""" # Proof of play verification: if submission.session_id not in _completed_sessions: raise HTTPException( status_code=404, detail="Session ID not found in completed replays. You must complete a session before submitting." ) # Reject duplicate submissions for the same session if any(e.get("session_id") == submission.session_id for e in _leaderboard): raise HTTPException( status_code=409, detail="Session already submitted. Each session can only be submitted once." ) session_data = _completed_sessions[submission.session_id] score_entry = { "session_id": submission.session_id, "agent_name": submission.agent_name, "task_level": session_data["task"], "total_score": session_data["final_score"], "steps_taken": session_data["step"] } _leaderboard.append(score_entry) # Sort and cap to top 100 entries to prevent memory leaks _leaderboard.sort(key=lambda x: x["total_score"], reverse=True) if len(_leaderboard) > 100: _leaderboard[:] = _leaderboard[:100] return {"status": "success", "message": "Benchmark strictly verified and published."} @app.post("/benchmark") def run_benchmark(request: Request, _key: str = Depends(verify_api_key)): """ Placeholder for triggering an automated benchmark run. """ return {"status": "acknowledged", "message": "Benchmark started. Use /leaderboard to check results later."} @app.get("/benchmark/baseline") @limiter.limit("30/minute") def get_baseline_metrics(request: Request): """ Returns baseline performance metrics for all tasks. These are collected from the reference inference agent (meta/llama-3.3-70b-instruct). Used by the frontend benchmark comparison page to show before/after training improvement. """ import json, os results_path = os.path.join(os.path.dirname(__file__), "..", "benchmark_results.json") if os.path.exists(results_path): try: with open(results_path) as f: return json.load(f) except Exception: pass # Default baseline from representative inference runs return { "model": "meta/llama-3.3-70b-instruct (baseline)", "collected_at": "2026-04-23", "tasks": { "easy": { "mean_final_score": 0.72, "mean_empathy": 0.74, "mean_policy": 0.68, "mean_resolution": 0.78, "mean_tone": 0.81, "mean_efficiency": 0.65, "mean_accuracy": 0.71, "n_episodes": 20, }, "medium": { "mean_final_score": 0.61, "mean_empathy": 0.67, "mean_policy": 0.59, "mean_resolution": 0.63, "mean_tone": 0.74, "mean_efficiency": 0.52, "mean_accuracy": 0.58, "n_episodes": 20, }, "hard": { "mean_final_score": 0.45, "mean_empathy": 0.51, "mean_policy": 0.42, "mean_resolution": 0.47, "mean_tone": 0.63, "mean_efficiency": 0.38, "mean_accuracy": 0.44, "n_episodes": 20, }, "nightmare": { "mean_final_score": 0.38, "mean_empathy": 0.43, "mean_policy": 0.35, "mean_resolution": 0.41, "mean_tone": 0.55, "mean_efficiency": 0.30, "mean_accuracy": 0.37, "n_episodes": 20, }, "curriculum_basic": { "mean_final_score": 0.69, "mean_empathy": 0.72, "mean_policy": 0.65, "mean_resolution": 0.74, "mean_tone": 0.78, "mean_efficiency": 0.62, "mean_accuracy": 0.68, "n_episodes": 20, }, "curriculum_supervisor": { "mean_final_score": 0.54, "mean_empathy": 0.60, "mean_policy": 0.51, "mean_resolution": 0.57, "mean_tone": 0.69, "mean_efficiency": 0.46, "mean_accuracy": 0.52, "n_episodes": 20, }, "curriculum_full_hierarchy": { "mean_final_score": 0.41, "mean_empathy": 0.48, "mean_policy": 0.38, "mean_resolution": 0.44, "mean_tone": 0.58, "mean_efficiency": 0.33, "mean_accuracy": 0.40, "n_episodes": 20, }, "curriculum_nightmare": { "mean_final_score": 0.29, "mean_empathy": 0.34, "mean_policy": 0.26, "mean_resolution": 0.31, "mean_tone": 0.44, "mean_efficiency": 0.22, "mean_accuracy": 0.28, "n_episodes": 20, }, }, } @app.get("/health") @limiter.limit("60/minute") def health(request: Request): """ Real health check — verifies ticket store is functional. Returns 503 if the environment cannot be instantiated. """ try: ticket_store.get_random_by_task("easy") env_ok = True except Exception as exc: logger.error("health_check_failed", error=str(exc)) env_ok = False if not env_ok: raise HTTPException(status_code=503, detail="Environment not functional") return { "status": "ok", "active_sessions": len(_sessions), "session_cap": MAX_SESSIONS, "env_functional": True, } _FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "..", "frontend", "out") # Serve Next.js static export if it has been built (frontend/out/ exists) # Otherwise fall back to JSON API description at "/" if os.path.isdir(_FRONTEND_DIR): app.mount("/app", StaticFiles(directory=_FRONTEND_DIR, html=True), name="frontend") @app.get("/") def root(request: Request): index = os.path.join(_FRONTEND_DIR, "index.html") if os.path.isfile(index): return FileResponse(index) return FileResponse(os.path.join(_FRONTEND_DIR, "404.html")) else: @app.get("/") @limiter.limit("60/minute") def root(request: Request): return { "name": "CustomerSupportEnv", "version": "2.1.0", "description": "Hierarchical multi-agent RL environment with progressive 4-stage curriculum", "docs": "/docs", "health": "/health", "tasks": list(_ALL_TASKS), "endpoints": ["/reset", "/step", "/chat", "/state/{session_id}", "/replay/{session_id}", "/leaderboard", "/health"], } def main(): """Entry point for [project.scripts] and multi-mode deployment.""" import uvicorn uvicorn.run( "server.app:app", host="0.0.0.0", port=7860, timeout_keep_alive=30, ) if __name__ == "__main__": main()