"""FastAPI server for the DataForge RL environment. Provides OpenEnv-compatible HTTP endpoints: POST /reset — Start a new episode POST /step — Execute an action GET /state — Return current state snapshot POST /close — No-op shutdown GET /health — Liveness check GET /metadata — Environment metadata GET /schema — Action/observation JSON schemas """ from __future__ import annotations import logging import os from threading import RLock from typing import Any from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from pydantic import TypeAdapter from dataforge import __version__ from dataforge.agent.tool_actions import Action from dataforge.env.environment import DataForgeEnv, EnvState from dataforge.env.observation import DataForgeObservation from dataforge.http.problem import problem_exception_handler from dataforge.observability import configure_fastapi_observability logger = logging.getLogger("dataforge.env.server") def _build_cors_origins() -> list[str]: """Build the explicit OpenEnv CORS allowlist from the environment.""" raw_origins = os.environ.get("DATAFORGE_OPENENV_ORIGINS", "") return [origin.strip() for origin in raw_origins.split(",") if origin.strip()] def _build_cors_origin_regex() -> str | None: """Allow local browser development only when explicitly enabled.""" if os.environ.get("DATAFORGE_OPENENV_DEV") != "1": return None return r"^http://(?:localhost|127(?:\.\d{1,3}){3})(?::\d+)?$" app = FastAPI( title="DataForge Environment", description="OpenEnv-compatible RL environment for data-quality repair.", version=__version__, ) app.add_middleware( CORSMiddleware, allow_origins=_build_cors_origins(), allow_origin_regex=_build_cors_origin_regex(), allow_credentials=False, allow_methods=["GET", "POST", "OPTIONS"], allow_headers=["*"], ) app.add_exception_handler(HTTPException, problem_exception_handler) configure_fastapi_observability(app, service_name="dataforge-openenv") _registry_lock = RLock() _default_env = DataForgeEnv() _sessions: dict[str, DataForgeEnv] = {} def _get_env(episode_id: str | None) -> DataForgeEnv: """Resolve an environment by episode id, preserving legacy no-id behavior.""" if not episode_id: return _default_env with _registry_lock: try: return _sessions[episode_id] except KeyError as exc: raise HTTPException( status_code=404, detail={"error": "episode_not_found", "episode_id": episode_id}, ) from exc def _remember_env(env: DataForgeEnv, episode_id: str) -> None: """Register a session and update the legacy default environment.""" global _default_env with _registry_lock: _sessions[episode_id] = env _default_env = env @app.post("/reset") async def reset(seed: int | None = None) -> dict[str, Any]: """Reset the environment for a new episode.""" env = DataForgeEnv() result = env.reset(seed=seed) episode_id = str(result.info["episode_id"]) _remember_env(env, episode_id) return result.model_dump(mode="json") @app.post("/step") async def step(action: dict[str, Any]) -> dict[str, Any]: """Execute one agent action.""" action_payload = dict(action) raw_episode_id = action_payload.pop("episode_id", None) episode_id = str(raw_episode_id) if raw_episode_id else None result = _get_env(episode_id).step(action_payload) return result.model_dump(mode="json") @app.get("/state") async def state(episode_id: str | None = None) -> dict[str, Any]: """Return current environment state snapshot.""" result = _get_env(episode_id).state() return result.model_dump(mode="json") @app.post("/close") async def close(request: Request, episode_id: str | None = None) -> dict[str, Any]: """No-op close endpoint for OpenEnv compatibility.""" body_episode_id: str | None = None if episode_id is None: try: payload = await request.json() except Exception: payload = None if isinstance(payload, dict) and payload.get("episode_id"): body_episode_id = str(payload["episode_id"]) target_episode_id = episode_id or body_episode_id env = _get_env(target_episode_id) env.close() if target_episode_id: with _registry_lock: _sessions.pop(target_episode_id, None) return {"status": "closed", "episode_id": target_episode_id} @app.get("/health") async def health() -> dict[str, Any]: """Liveness check.""" return {"status": "healthy", "environment": "dataforge-env"} @app.get("/metadata") async def metadata() -> dict[str, Any]: """Environment metadata for OpenEnv discovery.""" return { "name": "dataforge-env", "version": __version__, "description": ( "DataForge RL Environment — agents learn to detect, diagnose, " "and repair data-quality issues in tabular datasets." ), "action_types": [ "INSPECT_ROWS", "SQL_QUERY", "STAT_TEST", "PATTERN_MATCH", "HYPOTHESIS", "ROOT_CAUSE", "DIAGNOSE", "FIX", ], } @app.get("/schema") async def schema() -> dict[str, Any]: """Return JSON schemas for action and observation models.""" action_adapter: TypeAdapter[Action] = TypeAdapter(Action) return { "action": action_adapter.json_schema(), "observation": DataForgeObservation.model_json_schema(), "state": EnvState.model_json_schema(), }