File size: 11,286 Bytes
e9ce6e9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 | """
server/app.py β FastAPI wrapper for the Unified Fintech Risk Gateway
====================================================================
OpenEnv / Meta PyTorch Hackathon compliant server.
Endpoints
---------
GET / β health check (Hugging Face / automated grader probe)
GET /reset β health check (grader pings before issuing POST /reset)
POST /reset β re-initialise the environment for a given task
POST /step β advance one step with a typed UFRGAction
GET /state β inspect current observation without side-effects
Design decisions
----------------
* env is a **module-level singleton** kept alive across episodes so that
curriculum_level and adversary Q-table persist (re-instantiating on
every /reset would wipe those cross-episode accumulators).
* _env_lock (asyncio.Lock) serialises all env mutations. FastAPI uses an
async event loop β without the lock, a concurrent /step coroutine can
interleave between the 'await request.json()' and 'env.reset()' calls
in /reset, silently corrupting mid-episode state.
* _episode_active tracks whether the client has called POST /reset in this
session. /step and /state return 400 until the first explicit reset.
* Actions are validated through AEPOAction Pydantic model before they
reach env.step(), so malformed payloads return HTTP 422 automatically.
* Observations are serialised with .model_dump() for OpenEnv clients.
"""
import asyncio
import os
import uvicorn
from fastapi import FastAPI, HTTPException, Request
from fastapi.staticfiles import StaticFiles
from pydantic import ValidationError
from unified_gateway import AEPOAction, AEPOObservation, UFRGAction, UFRGObservation, UFRGReward, UnifiedFintechEnv
# ---------------------------------------------------------------------------
# Application bootstrap
# ---------------------------------------------------------------------------
app = FastAPI(
title="Autonomous Enterprise Payment Orchestrator (AEPO)",
description=(
"OpenEnv-compliant causally-structured simulation of a UPI payment risk gateway. "
"Supports three difficulty tiers: easy, medium, hard."
),
version="0.2.0",
)
# ---------------------------------------------------------------------------
# Module-level singleton state
# ---------------------------------------------------------------------------
# Single env instance β kept alive so curriculum_level and adversary Q-table
# accumulate across episodes (re-instantiating would wipe them).
env = UnifiedFintechEnv()
env.reset(options={"task": "easy"}) # prime env to a valid state on startup
# asyncio.Lock β serialises all env mutations against event-loop interleaving.
# Must be created at module level (not inside an async function) so it is
# shared across all coroutines running in the same event loop.
_env_lock: asyncio.Lock = asyncio.Lock()
# True only after the client has called POST /reset at least once in this
# session. /step and /state return HTTP 400 until this flag is set.
# Note: the module-level env.reset() above does NOT set this flag β that
# call primes the env but the client has not yet started an episode.
_episode_active: bool = False
# ---------------------------------------------------------------------------
# Health checks (GET probes β must return 200 OK, never 405)
# ---------------------------------------------------------------------------
@app.get("/", tags=["health"])
async def root_health_check():
"""
Root health-check.
Hugging Face Spaces and many automated graders issue a GET / to verify
the container is responsive before running evaluation. This endpoint
must exist and return 200 OK.
"""
return {
"status": "healthy",
"message": "AEPO is live. Use POST /reset to initialise a task.",
}
@app.get("/reset", tags=["health"])
async def reset_health_check():
"""
Pre-flight health-check for /reset.
Some evaluation harnesses issue GET /reset to confirm the route is
registered before sending POST /reset. Returning 200 OK satisfies that
probe without having any side-effects on the running environment.
"""
return {
"status": "healthy",
"message": "Route /reset is live. Send POST /reset with {\"task\": \"easy|medium|hard\"} to begin.",
}
@app.get("/contract", tags=["health"])
async def contract_info():
"""
OpenEnv 4-tuple contract declaration (Fix 9.4 β Gymnasium 4-tuple bridge).
Advertises the AEPO step() return format so judges and automated
graders can verify the tuple arity without reading source code.
AEPO uses the OpenEnv 4-tuple contract, NOT Gymnasium's 5-tuple:
POST /step β { observation, reward, done, info } β 4 fields
POST /reset β { observation, info } β 2 fields
Gymnasium's 5-tuple (terminated + truncated separate) is only exposed
via GymnasiumCompatWrapper for check_env CI validation. All submission
evaluation paths (graders, inference, this server) use the 4-tuple.
"""
return {
"step_tuple": "4-tuple",
"step_format": env.STEP_TUPLE_FORMAT,
"openenv_compliant": env.IS_OPENENV_COMPLIANT,
"gymnasium_compat_wrapper": "GymnasiumCompatWrapper (5-tuple, CI only)",
"note": (
"AEPO never truncates β episodes end via crash, fraud, or 100-step limit. "
"Hence Gymnasium's 'truncated' field is always False in the wrapper."
),
}
# ---------------------------------------------------------------------------
# POST /reset β task-driven environment initialisation
# ---------------------------------------------------------------------------
@app.post("/reset", tags=["env"])
async def reset_env(request: Request):
"""
Re-initialise the environment for a new episode.
Request body (JSON, optional)
------------------------------
``task`` : str, default ``"easy"``
Difficulty tier β one of ``"easy"``, ``"medium"``, ``"hard"``.
Returns
-------
JSON object with an ``observation`` key containing the initial
``UFRGObservation`` dict.
"""
global _episode_active
# Parse task before acquiring the lock β I/O (JSON decode) outside critical section.
try:
body = await request.json()
task_name: str = body.get("task", "easy")
except Exception:
task_name = "easy"
if task_name not in {"easy", "medium", "hard"}:
raise HTTPException(
status_code=422,
detail=f"Invalid task '{task_name}'. Must be one of: easy, medium, hard.",
)
# Acquire lock before touching env β prevents a concurrent /step coroutine
# from interleaving between this point and env.reset() below.
async with _env_lock:
# NOT re-instantiation: keep singleton alive so curriculum_level and
# adversary Q-table persist across episodes.
obs, _info = env.reset(options={"task": task_name})
_episode_active = True
return {"observation": obs.model_dump(), "info": _info}
# ---------------------------------------------------------------------------
# POST /step β advance one time-step
# ---------------------------------------------------------------------------
@app.post("/step", tags=["env"])
async def step_env(request: Request):
"""
Advance the environment by one step.
Request body (JSON)
--------------------
``action`` : dict
A JSON object with keys matching AEPOAction fields. Required: ``risk_decision``,
``infra_routing``, ``crypto_verify``. Optional (safe defaults provided):
``db_retry_policy``, ``settlement_policy``, ``app_priority``.
Returns
-------
JSON object conforming to the OpenEnv step response spec:
``{ observation, reward, done, info }``.
"""
# Guard: client must call POST /reset before stepping.
if not _episode_active:
raise HTTPException(
status_code=400,
detail="No active episode. Call POST /reset with a task before stepping.",
)
# Parse and validate action outside the lock β CPU work, no env mutation.
try:
body = await request.json()
action_dict = body.get("action")
if action_dict is None:
raise HTTPException(
status_code=422,
detail="Request body must contain an 'action' key.",
)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Malformed JSON body: {exc}") from exc
try:
action = AEPOAction(**action_dict)
except (ValidationError, TypeError) as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
async with _env_lock:
obs, typed_reward, done, info = env.step(action)
return {
"observation": obs.model_dump(),
"reward": typed_reward.value,
"reward_breakdown": typed_reward.breakdown,
"done": bool(done),
"info": info,
}
# ---------------------------------------------------------------------------
# GET /state β non-destructive observation peek
# ---------------------------------------------------------------------------
@app.get("/state", tags=["env"])
async def get_state():
"""
Return the most-recent observation without advancing the clock.
Satisfies the OpenEnv ``state()`` contract: any evaluation harness can
inspect the current environment state without triggering side-effects.
Returns HTTP 400 if called before POST /reset.
"""
if not _episode_active:
raise HTTPException(
status_code=400,
detail="No active episode. Call POST /reset with a task first.",
)
async with _env_lock:
current_obs = env.state()
return {"observation": current_obs.model_dump()}
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None: # pragma: no cover
uvicorn.run("server.app:app", host="0.0.0.0", port=7860)
# ---------------------------------------------------------------------------
# Static frontend β mounted LAST so explicit API routes take priority
# ---------------------------------------------------------------------------
# When deployed in Docker / HF Spaces the Next.js dashboard is built to a
# static export at `frontend/out/`. FastAPI serves it at "/" so the Space
# shows the interactive dashboard by default.
#
# Explicit routes (/reset, /step, /state, /contract, /health) are resolved by
# Starlette's router BEFORE it falls through to this mounted sub-app, so there
# is no collision between the API and the static files.
#
# In local development (no Docker), this directory may not exist; the guard
# prevents a startup crash while keeping the server fully functional for
# openenv validate and pytest.
_FRONTEND_OUT = os.path.join(os.path.dirname(__file__), "..", "frontend", "out")
if os.path.isdir(_FRONTEND_OUT):
app.mount(
"/",
StaticFiles(directory=_FRONTEND_OUT, html=True),
name="frontend",
)
if __name__ == "__main__":
main() |