lebiraja's picture
feat: wire trained GGUF model (customer-support-grpo-v5) into inference
601a10d
Raw
History Blame Contribute Delete
27.3 kB
"""
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()