File size: 5,713 Bytes
eed1cab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
791c076
eed1cab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
791c076
eed1cab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
791c076
eed1cab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""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(),
    }