Spaces:
Sleeping
Sleeping
Upload main.py
Browse files- server/main.py +413 -154
server/main.py
CHANGED
|
@@ -9,8 +9,9 @@ import tempfile
|
|
| 9 |
from typing import Any
|
| 10 |
from difflib import get_close_matches
|
| 11 |
|
| 12 |
-
from fastapi import FastAPI, HTTPException, UploadFile, File, Form
|
| 13 |
from fastapi.responses import HTMLResponse, JSONResponse
|
|
|
|
| 14 |
|
| 15 |
try:
|
| 16 |
from dotenv import load_dotenv
|
|
@@ -62,6 +63,69 @@ _uploaded_db_tasks: list[str] = [] # Which tasks are compatible
|
|
| 62 |
# HTTP-level request lock to serialize concurrent requests
|
| 63 |
_request_lock = asyncio.Lock()
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
def open_sqlite_db(db_path: str) -> sqlite3.Connection:
|
| 67 |
"""Open a SQLite database with row access tuned for API usage."""
|
|
@@ -188,6 +252,240 @@ def load_static_html() -> str:
|
|
| 188 |
</html>"""
|
| 189 |
|
| 190 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
# Validate LLM configuration at startup
|
| 192 |
runtime = _resolve_llm_runtime()
|
| 193 |
LLM_AVAILABLE = runtime["configured"]
|
|
@@ -219,7 +517,7 @@ async def health() -> dict:
|
|
| 219 |
message = runtime["provider"]
|
| 220 |
|
| 221 |
return {
|
| 222 |
-
"status": "
|
| 223 |
"llm": "mock_mode" if mock_mode else "connected",
|
| 224 |
"llm_available": llm_available,
|
| 225 |
"mock_mode": mock_mode,
|
|
@@ -240,45 +538,108 @@ async def get_metadata() -> dict:
|
|
| 240 |
"version": "0.1.0",
|
| 241 |
"description": "A multi-step SQL data quality environment for training and evaluating data engineering agents",
|
| 242 |
"author": "Tharak",
|
| 243 |
-
"documentation":
|
| 244 |
-
"
|
| 245 |
-
|
| 246 |
-
"id": "null_audit",
|
| 247 |
-
"has_grader": True
|
| 248 |
-
},
|
| 249 |
-
{
|
| 250 |
-
"id": "type_repair",
|
| 251 |
-
"has_grader": True
|
| 252 |
-
},
|
| 253 |
-
{
|
| 254 |
-
"id": "dedup_bug",
|
| 255 |
-
"has_grader": True
|
| 256 |
-
},
|
| 257 |
-
{
|
| 258 |
-
"id": "fk_integrity",
|
| 259 |
-
"has_grader": True
|
| 260 |
-
},
|
| 261 |
-
{
|
| 262 |
-
"id": "schema_drift",
|
| 263 |
-
"has_grader": True
|
| 264 |
-
}
|
| 265 |
-
]
|
| 266 |
}
|
| 267 |
|
| 268 |
|
| 269 |
@app.get("/tasks")
|
| 270 |
async def list_tasks():
|
| 271 |
-
return
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
|
| 283 |
|
| 284 |
@app.get("/status")
|
|
@@ -352,67 +713,19 @@ async def initialize(request: ResetRequest) -> dict:
|
|
| 352 |
Returns:
|
| 353 |
dict: Full episode state with schema_info for Table Schema panel
|
| 354 |
"""
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
status_code=422,
|
| 369 |
-
detail=f"Invalid task_id '{task_id}'. Must be one of: {VALID_TASKS}",
|
| 370 |
-
)
|
| 371 |
-
conn = None
|
| 372 |
-
try:
|
| 373 |
-
if _uploaded_db_path is not None and task_id in _uploaded_db_tasks:
|
| 374 |
-
conn = open_sqlite_db(_uploaded_db_path)
|
| 375 |
-
obs = _env.reset_with_connection(
|
| 376 |
-
task_id,
|
| 377 |
-
conn,
|
| 378 |
-
_uploaded_db_schema,
|
| 379 |
-
_custom_db_analysis,
|
| 380 |
-
)
|
| 381 |
-
elif _uploaded_db_path is not None and _custom_db_analysis.get("task_candidates", {}).get(task_id):
|
| 382 |
-
reason = _custom_db_analysis["task_candidates"][task_id].get("reason", f"Task '{task_id}' is not supported for the uploaded dataset.")
|
| 383 |
-
raise ValueError(reason)
|
| 384 |
-
else:
|
| 385 |
-
obs = _env.reset(task_id, seed=request.seed)
|
| 386 |
-
except ValueError as e:
|
| 387 |
-
raise HTTPException(status_code=422, detail=str(e))
|
| 388 |
-
except Exception as e:
|
| 389 |
-
raise HTTPException(status_code=500, detail=str(e))
|
| 390 |
-
finally:
|
| 391 |
-
if conn is not None:
|
| 392 |
-
conn.close()
|
| 393 |
-
|
| 394 |
-
# Extract schema for frontend
|
| 395 |
-
schema_data = []
|
| 396 |
-
if obs.schema_info:
|
| 397 |
-
for table_info in obs.schema_info:
|
| 398 |
-
schema_data.append({
|
| 399 |
-
"table_name": table_info.table_name,
|
| 400 |
-
"columns": table_info.columns,
|
| 401 |
-
"row_count": table_info.row_count
|
| 402 |
-
})
|
| 403 |
-
|
| 404 |
-
return {
|
| 405 |
-
"status": "ok",
|
| 406 |
-
"episode_id": obs.episode_id,
|
| 407 |
-
"task_id": obs.task_id,
|
| 408 |
-
"task_description": obs.task_description,
|
| 409 |
-
"schema": schema_data,
|
| 410 |
-
"sample_rows": obs.sample_rows,
|
| 411 |
-
"attempt_number": obs.attempt_number,
|
| 412 |
-
"max_attempts": obs.max_attempts,
|
| 413 |
-
"investigation_hint": obs.investigation_hint,
|
| 414 |
-
"task_context": _env.task_context(),
|
| 415 |
-
}
|
| 416 |
|
| 417 |
|
| 418 |
@app.post("/reset", response_model=SQLObservation)
|
|
@@ -429,50 +742,7 @@ async def reset(request: ResetRequest = None) -> SQLObservation:
|
|
| 429 |
HTTPException 422: If task_id is not a valid task identifier
|
| 430 |
HTTPException 500: If environment initialization fails
|
| 431 |
"""
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
async with _request_lock:
|
| 435 |
-
VALID_TASKS = {
|
| 436 |
-
"null_audit",
|
| 437 |
-
"type_repair",
|
| 438 |
-
"dedup_bug",
|
| 439 |
-
"fk_integrity",
|
| 440 |
-
"schema_drift",
|
| 441 |
-
}
|
| 442 |
-
|
| 443 |
-
# Handle None request (validator sends empty body)
|
| 444 |
-
if request is None:
|
| 445 |
-
request = ResetRequest()
|
| 446 |
-
|
| 447 |
-
task_id = request.task_id or "null_audit"
|
| 448 |
-
if task_id not in VALID_TASKS:
|
| 449 |
-
raise HTTPException(
|
| 450 |
-
status_code=422,
|
| 451 |
-
detail=f"Invalid task_id '{task_id}'. Must be one of: {VALID_TASKS}",
|
| 452 |
-
)
|
| 453 |
-
conn = None
|
| 454 |
-
try:
|
| 455 |
-
if _uploaded_db_path is not None and task_id in _uploaded_db_tasks:
|
| 456 |
-
conn = open_sqlite_db(_uploaded_db_path)
|
| 457 |
-
obs = _env.reset_with_connection(
|
| 458 |
-
task_id,
|
| 459 |
-
conn,
|
| 460 |
-
_uploaded_db_schema,
|
| 461 |
-
_custom_db_analysis,
|
| 462 |
-
)
|
| 463 |
-
return obs
|
| 464 |
-
elif _uploaded_db_path is not None and _custom_db_analysis.get("task_candidates", {}).get(task_id):
|
| 465 |
-
reason = _custom_db_analysis["task_candidates"][task_id].get("reason", f"Task '{task_id}' is not supported for the uploaded dataset.")
|
| 466 |
-
raise ValueError(reason)
|
| 467 |
-
else:
|
| 468 |
-
return _env.reset(task_id, seed=request.seed)
|
| 469 |
-
except ValueError as e:
|
| 470 |
-
raise HTTPException(status_code=422, detail=str(e))
|
| 471 |
-
except Exception as e:
|
| 472 |
-
raise HTTPException(status_code=500, detail=str(e))
|
| 473 |
-
finally:
|
| 474 |
-
if conn is not None:
|
| 475 |
-
conn.close()
|
| 476 |
|
| 477 |
|
| 478 |
@app.post("/step", response_model=StepResponse)
|
|
@@ -489,15 +759,7 @@ async def step(action: SQLAction) -> StepResponse:
|
|
| 489 |
HTTPException 422: If action is invalid or episode is already done
|
| 490 |
HTTPException 500: If environment step execution fails
|
| 491 |
"""
|
| 492 |
-
|
| 493 |
-
try:
|
| 494 |
-
return _env.step(action)
|
| 495 |
-
except HTTPException:
|
| 496 |
-
raise
|
| 497 |
-
except ValueError as e:
|
| 498 |
-
raise HTTPException(status_code=422, detail=str(e))
|
| 499 |
-
except Exception as e:
|
| 500 |
-
raise HTTPException(status_code=500, detail=str(e))
|
| 501 |
|
| 502 |
|
| 503 |
@app.get("/state", response_model=SQLState)
|
|
@@ -510,10 +772,7 @@ async def state() -> SQLState:
|
|
| 510 |
Raises:
|
| 511 |
HTTPException 422: If no active episode exists
|
| 512 |
"""
|
| 513 |
-
|
| 514 |
-
return _env.state()
|
| 515 |
-
except ValueError as e:
|
| 516 |
-
raise HTTPException(status_code=422, detail=str(e))
|
| 517 |
|
| 518 |
|
| 519 |
# βββ CUSTOM DATABASE ENDPOINTS βββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 9 |
from typing import Any
|
| 10 |
from difflib import get_close_matches
|
| 11 |
|
| 12 |
+
from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Request
|
| 13 |
from fastapi.responses import HTMLResponse, JSONResponse
|
| 14 |
+
from pydantic import ValidationError
|
| 15 |
|
| 16 |
try:
|
| 17 |
from dotenv import load_dotenv
|
|
|
|
| 63 |
# HTTP-level request lock to serialize concurrent requests
|
| 64 |
_request_lock = asyncio.Lock()
|
| 65 |
|
| 66 |
+
TASK_CATALOG = [
|
| 67 |
+
{
|
| 68 |
+
"id": "null_audit",
|
| 69 |
+
"difficulty": "easy",
|
| 70 |
+
"description": "Identify the column with null rate exceeding 50 percent",
|
| 71 |
+
"max_attempts": 1,
|
| 72 |
+
"grader": True,
|
| 73 |
+
"has_grader": True,
|
| 74 |
+
"scored": True,
|
| 75 |
+
"grader_fn": "server.graders:grade_null_audit",
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
"id": "type_repair",
|
| 79 |
+
"difficulty": "medium",
|
| 80 |
+
"description": "Fix numeric type mismatch in transaction records",
|
| 81 |
+
"max_attempts": 3,
|
| 82 |
+
"grader": True,
|
| 83 |
+
"has_grader": True,
|
| 84 |
+
"scored": True,
|
| 85 |
+
"grader_fn": "server.graders:grade_type_repair",
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"id": "dedup_bug",
|
| 89 |
+
"difficulty": "hard",
|
| 90 |
+
"description": "Find and repair duplication bug inflating join volumes",
|
| 91 |
+
"max_attempts": 5,
|
| 92 |
+
"grader": True,
|
| 93 |
+
"has_grader": True,
|
| 94 |
+
"scored": True,
|
| 95 |
+
"grader_fn": "server.graders:grade_dedup_bug",
|
| 96 |
+
},
|
| 97 |
+
{
|
| 98 |
+
"id": "fk_integrity",
|
| 99 |
+
"difficulty": "hard",
|
| 100 |
+
"description": "Remove orphan child records violating referential integrity",
|
| 101 |
+
"max_attempts": 4,
|
| 102 |
+
"grader": True,
|
| 103 |
+
"has_grader": True,
|
| 104 |
+
"scored": True,
|
| 105 |
+
"grader_fn": "server.graders:grade_fk_integrity",
|
| 106 |
+
},
|
| 107 |
+
{
|
| 108 |
+
"id": "schema_drift",
|
| 109 |
+
"difficulty": "medium",
|
| 110 |
+
"description": "Normalize drifted schemas into canonical analytics artifact",
|
| 111 |
+
"max_attempts": 4,
|
| 112 |
+
"grader": True,
|
| 113 |
+
"has_grader": True,
|
| 114 |
+
"scored": True,
|
| 115 |
+
"grader_fn": "server.graders:grade_schema_drift",
|
| 116 |
+
},
|
| 117 |
+
]
|
| 118 |
+
TASK_IDS = tuple(task["id"] for task in TASK_CATALOG)
|
| 119 |
+
TASK_CATALOG_BY_ID = {task["id"]: task for task in TASK_CATALOG}
|
| 120 |
+
DEFAULT_TASK_ID = TASK_IDS[0]
|
| 121 |
+
DOCUMENTATION_URL = "https://huggingface.co/spaces/Deadman922/sql-quality-env"
|
| 122 |
+
|
| 123 |
+
JSONRPC_PARSE_ERROR = -32700
|
| 124 |
+
JSONRPC_INVALID_REQUEST = -32600
|
| 125 |
+
JSONRPC_METHOD_NOT_FOUND = -32601
|
| 126 |
+
JSONRPC_INVALID_PARAMS = -32602
|
| 127 |
+
JSONRPC_INTERNAL_ERROR = -32603
|
| 128 |
+
|
| 129 |
|
| 130 |
def open_sqlite_db(db_path: str) -> sqlite3.Connection:
|
| 131 |
"""Open a SQLite database with row access tuned for API usage."""
|
|
|
|
| 252 |
</html>"""
|
| 253 |
|
| 254 |
|
| 255 |
+
def _task_catalog_records() -> list[dict[str, Any]]:
|
| 256 |
+
"""Return shallow copies of task records for response payloads."""
|
| 257 |
+
return [dict(task) for task in TASK_CATALOG]
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def _valid_task_ids_display() -> list[str]:
|
| 261 |
+
"""Return task IDs in canonical order for errors and schemas."""
|
| 262 |
+
return list(TASK_IDS)
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def _validate_task_id_or_raise(task_id: str | None) -> str:
|
| 266 |
+
"""Resolve and validate task IDs against the canonical catalog."""
|
| 267 |
+
resolved_task_id = task_id or DEFAULT_TASK_ID
|
| 268 |
+
if resolved_task_id not in TASK_CATALOG_BY_ID:
|
| 269 |
+
raise HTTPException(
|
| 270 |
+
status_code=422,
|
| 271 |
+
detail=(
|
| 272 |
+
f"Invalid task_id '{resolved_task_id}'. "
|
| 273 |
+
f"Must be one of: {_valid_task_ids_display()}"
|
| 274 |
+
),
|
| 275 |
+
)
|
| 276 |
+
return resolved_task_id
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def _patch_task_id_field_schema(field_schema: dict[str, Any]) -> None:
|
| 280 |
+
"""Inject machine-readable task enums into a single schema field."""
|
| 281 |
+
if "anyOf" in field_schema:
|
| 282 |
+
for variant in field_schema.get("anyOf", []):
|
| 283 |
+
if isinstance(variant, dict) and variant.get("type") == "string":
|
| 284 |
+
variant["enum"] = _valid_task_ids_display()
|
| 285 |
+
elif field_schema.get("type") == "string":
|
| 286 |
+
field_schema["enum"] = _valid_task_ids_display()
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def _patch_task_id_enums(schema: Any) -> None:
|
| 290 |
+
"""Recursively patch schema objects so task_id is an explicit enum."""
|
| 291 |
+
if isinstance(schema, dict):
|
| 292 |
+
properties = schema.get("properties")
|
| 293 |
+
if isinstance(properties, dict) and isinstance(properties.get("task_id"), dict):
|
| 294 |
+
_patch_task_id_field_schema(properties["task_id"])
|
| 295 |
+
for value in schema.values():
|
| 296 |
+
_patch_task_id_enums(value)
|
| 297 |
+
elif isinstance(schema, list):
|
| 298 |
+
for item in schema:
|
| 299 |
+
_patch_task_id_enums(item)
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def _patched_model_schema(model_cls: Any) -> dict[str, Any]:
|
| 303 |
+
"""Return a model JSON schema with task enums patched in."""
|
| 304 |
+
schema = model_cls.model_json_schema()
|
| 305 |
+
_patch_task_id_enums(schema)
|
| 306 |
+
return schema
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def _build_schema_payload() -> dict[str, Any]:
|
| 310 |
+
"""Build OpenEnv-style action/observation/state schema payload."""
|
| 311 |
+
return {
|
| 312 |
+
"action": _patched_model_schema(SQLAction),
|
| 313 |
+
"observation": _patched_model_schema(SQLObservation),
|
| 314 |
+
"state": _patched_model_schema(SQLState),
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def _build_tasks_payload() -> dict[str, Any]:
|
| 319 |
+
"""Build the task listing payload from the canonical catalog."""
|
| 320 |
+
tasks = _task_catalog_records()
|
| 321 |
+
return {
|
| 322 |
+
"tasks": tasks,
|
| 323 |
+
"total": len(tasks),
|
| 324 |
+
"graded": sum(1 for task in tasks if task.get("has_grader")),
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def _jsonrpc_success(result: Any, request_id: Any = None) -> dict[str, Any]:
|
| 329 |
+
"""Build a JSON-RPC 2.0 success response."""
|
| 330 |
+
return {
|
| 331 |
+
"jsonrpc": "2.0",
|
| 332 |
+
"result": result,
|
| 333 |
+
"id": request_id,
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def _jsonrpc_error(
|
| 338 |
+
code: int,
|
| 339 |
+
message: str,
|
| 340 |
+
request_id: Any = None,
|
| 341 |
+
data: Any = None,
|
| 342 |
+
) -> dict[str, Any]:
|
| 343 |
+
"""Build a JSON-RPC 2.0 error response."""
|
| 344 |
+
error: dict[str, Any] = {
|
| 345 |
+
"code": code,
|
| 346 |
+
"message": message,
|
| 347 |
+
}
|
| 348 |
+
if data is not None:
|
| 349 |
+
error["data"] = data
|
| 350 |
+
return {
|
| 351 |
+
"jsonrpc": "2.0",
|
| 352 |
+
"error": error,
|
| 353 |
+
"id": request_id,
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def _mcp_tool_specs() -> list[dict[str, Any]]:
|
| 358 |
+
"""Return MCP tool specs for task discovery and episode control."""
|
| 359 |
+
reset_schema = ResetRequest.model_json_schema()
|
| 360 |
+
_patch_task_id_enums(reset_schema)
|
| 361 |
+
action_schema = _patched_model_schema(SQLAction)
|
| 362 |
+
return [
|
| 363 |
+
{
|
| 364 |
+
"name": "list_tasks",
|
| 365 |
+
"description": "Return the registered task catalog for this environment.",
|
| 366 |
+
"inputSchema": {
|
| 367 |
+
"type": "object",
|
| 368 |
+
"properties": {},
|
| 369 |
+
"additionalProperties": False,
|
| 370 |
+
},
|
| 371 |
+
},
|
| 372 |
+
{
|
| 373 |
+
"name": "episode_reset",
|
| 374 |
+
"description": "Reset the environment for a specific task and return the initial observation.",
|
| 375 |
+
"inputSchema": reset_schema,
|
| 376 |
+
},
|
| 377 |
+
{
|
| 378 |
+
"name": "episode_step",
|
| 379 |
+
"description": "Execute one environment step using the SQL action payload.",
|
| 380 |
+
"inputSchema": action_schema,
|
| 381 |
+
},
|
| 382 |
+
{
|
| 383 |
+
"name": "episode_state",
|
| 384 |
+
"description": "Return the current state for the active environment episode.",
|
| 385 |
+
"inputSchema": {
|
| 386 |
+
"type": "object",
|
| 387 |
+
"properties": {},
|
| 388 |
+
"additionalProperties": False,
|
| 389 |
+
},
|
| 390 |
+
},
|
| 391 |
+
]
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def _schema_rows_from_observation(obs: SQLObservation) -> list[dict[str, Any]]:
|
| 395 |
+
"""Convert observation schema info to frontend-friendly rows."""
|
| 396 |
+
schema_rows: list[dict[str, Any]] = []
|
| 397 |
+
if obs.schema_info:
|
| 398 |
+
for table_info in obs.schema_info:
|
| 399 |
+
schema_rows.append(
|
| 400 |
+
{
|
| 401 |
+
"table_name": table_info.table_name,
|
| 402 |
+
"columns": table_info.columns,
|
| 403 |
+
"row_count": table_info.row_count,
|
| 404 |
+
}
|
| 405 |
+
)
|
| 406 |
+
return schema_rows
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
async def _reset_observation(request: ResetRequest | None = None) -> SQLObservation:
|
| 410 |
+
"""Shared reset helper used by HTTP routes and MCP tools."""
|
| 411 |
+
global _uploaded_db_path, _uploaded_db_tasks, _custom_db_analysis
|
| 412 |
+
|
| 413 |
+
if request is None:
|
| 414 |
+
request = ResetRequest()
|
| 415 |
+
|
| 416 |
+
async with _request_lock:
|
| 417 |
+
task_id = _validate_task_id_or_raise(request.task_id)
|
| 418 |
+
conn = None
|
| 419 |
+
try:
|
| 420 |
+
if _uploaded_db_path is not None and task_id in _uploaded_db_tasks:
|
| 421 |
+
conn = open_sqlite_db(_uploaded_db_path)
|
| 422 |
+
return _env.reset_with_connection(
|
| 423 |
+
task_id,
|
| 424 |
+
conn,
|
| 425 |
+
_uploaded_db_schema,
|
| 426 |
+
_custom_db_analysis,
|
| 427 |
+
)
|
| 428 |
+
if _uploaded_db_path is not None and _custom_db_analysis.get("task_candidates", {}).get(task_id):
|
| 429 |
+
reason = _custom_db_analysis["task_candidates"][task_id].get(
|
| 430 |
+
"reason",
|
| 431 |
+
f"Task '{task_id}' is not supported for the uploaded dataset.",
|
| 432 |
+
)
|
| 433 |
+
raise ValueError(reason)
|
| 434 |
+
return _env.reset(task_id, seed=request.seed)
|
| 435 |
+
except ValueError as e:
|
| 436 |
+
raise HTTPException(status_code=422, detail=str(e)) from e
|
| 437 |
+
except HTTPException:
|
| 438 |
+
raise
|
| 439 |
+
except Exception as e:
|
| 440 |
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
| 441 |
+
finally:
|
| 442 |
+
if conn is not None:
|
| 443 |
+
conn.close()
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
async def _execute_step(action: SQLAction) -> StepResponse:
|
| 447 |
+
"""Shared step helper used by HTTP routes and MCP tools."""
|
| 448 |
+
async with _request_lock:
|
| 449 |
+
try:
|
| 450 |
+
return _env.step(action)
|
| 451 |
+
except HTTPException:
|
| 452 |
+
raise
|
| 453 |
+
except ValueError as e:
|
| 454 |
+
raise HTTPException(status_code=422, detail=str(e)) from e
|
| 455 |
+
except Exception as e:
|
| 456 |
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
async def _current_state() -> SQLState:
|
| 460 |
+
"""Shared state helper used by HTTP routes and MCP tools."""
|
| 461 |
+
try:
|
| 462 |
+
return _env.state()
|
| 463 |
+
except ValueError as e:
|
| 464 |
+
raise HTTPException(status_code=422, detail=str(e)) from e
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
_original_openapi = app.openapi
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
def _custom_openapi() -> dict[str, Any]:
|
| 471 |
+
"""Patch OpenAPI component schemas so task_id becomes a real enum."""
|
| 472 |
+
if app.openapi_schema:
|
| 473 |
+
return app.openapi_schema
|
| 474 |
+
|
| 475 |
+
openapi_schema = _original_openapi()
|
| 476 |
+
if isinstance(openapi_schema, dict):
|
| 477 |
+
components = openapi_schema.get("components", {}).get("schemas", {})
|
| 478 |
+
for schema_name in ("ResetRequest", "SQLAction", "SQLObservation", "SQLState"):
|
| 479 |
+
schema = components.get(schema_name)
|
| 480 |
+
if isinstance(schema, dict):
|
| 481 |
+
_patch_task_id_enums(schema)
|
| 482 |
+
app.openapi_schema = openapi_schema
|
| 483 |
+
return openapi_schema
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
app.openapi = _custom_openapi
|
| 487 |
+
|
| 488 |
+
|
| 489 |
# Validate LLM configuration at startup
|
| 490 |
runtime = _resolve_llm_runtime()
|
| 491 |
LLM_AVAILABLE = runtime["configured"]
|
|
|
|
| 517 |
message = runtime["provider"]
|
| 518 |
|
| 519 |
return {
|
| 520 |
+
"status": "healthy",
|
| 521 |
"llm": "mock_mode" if mock_mode else "connected",
|
| 522 |
"llm_available": llm_available,
|
| 523 |
"mock_mode": mock_mode,
|
|
|
|
| 538 |
"version": "0.1.0",
|
| 539 |
"description": "A multi-step SQL data quality environment for training and evaluating data engineering agents",
|
| 540 |
"author": "Tharak",
|
| 541 |
+
"documentation": DOCUMENTATION_URL,
|
| 542 |
+
"documentation_url": DOCUMENTATION_URL,
|
| 543 |
+
"tasks": _task_catalog_records(),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 544 |
}
|
| 545 |
|
| 546 |
|
| 547 |
@app.get("/tasks")
|
| 548 |
async def list_tasks():
|
| 549 |
+
return _build_tasks_payload()
|
| 550 |
+
|
| 551 |
+
|
| 552 |
+
@app.get("/schema")
|
| 553 |
+
async def get_schema() -> dict:
|
| 554 |
+
"""Return action, observation, and state schemas for OpenEnv clients."""
|
| 555 |
+
return _build_schema_payload()
|
| 556 |
+
|
| 557 |
+
|
| 558 |
+
@app.post("/mcp")
|
| 559 |
+
async def mcp_endpoint(request_raw: Request) -> dict[str, Any]:
|
| 560 |
+
"""Minimal MCP JSON-RPC surface for task discovery and episode control."""
|
| 561 |
+
try:
|
| 562 |
+
body = await request_raw.body()
|
| 563 |
+
request_dict = json.loads(body or b"{}")
|
| 564 |
+
except json.JSONDecodeError:
|
| 565 |
+
return _jsonrpc_error(JSONRPC_PARSE_ERROR, "Parse error")
|
| 566 |
+
|
| 567 |
+
if not isinstance(request_dict, dict):
|
| 568 |
+
return _jsonrpc_error(JSONRPC_INVALID_REQUEST, "Invalid Request")
|
| 569 |
+
|
| 570 |
+
request_id = request_dict.get("id")
|
| 571 |
+
if request_dict.get("jsonrpc") != "2.0" or not isinstance(request_dict.get("method"), str):
|
| 572 |
+
return _jsonrpc_error(JSONRPC_INVALID_REQUEST, "Invalid Request", request_id=request_id)
|
| 573 |
+
|
| 574 |
+
method = request_dict["method"]
|
| 575 |
+
params = request_dict.get("params", {})
|
| 576 |
+
if params is None:
|
| 577 |
+
params = {}
|
| 578 |
+
if not isinstance(params, dict):
|
| 579 |
+
return _jsonrpc_error(
|
| 580 |
+
JSONRPC_INVALID_PARAMS,
|
| 581 |
+
"Invalid params: 'params' must be an object",
|
| 582 |
+
request_id=request_id,
|
| 583 |
+
)
|
| 584 |
+
|
| 585 |
+
if method == "tools/list":
|
| 586 |
+
return _jsonrpc_success({"tools": _mcp_tool_specs()}, request_id=request_id)
|
| 587 |
+
|
| 588 |
+
if method != "tools/call":
|
| 589 |
+
return _jsonrpc_error(
|
| 590 |
+
JSONRPC_METHOD_NOT_FOUND,
|
| 591 |
+
f"Method not found: {method}",
|
| 592 |
+
request_id=request_id,
|
| 593 |
+
)
|
| 594 |
+
|
| 595 |
+
tool_name = params.get("name")
|
| 596 |
+
arguments = params.get("arguments", {})
|
| 597 |
+
if arguments is None:
|
| 598 |
+
arguments = {}
|
| 599 |
+
if not tool_name:
|
| 600 |
+
return _jsonrpc_error(
|
| 601 |
+
JSONRPC_INVALID_PARAMS,
|
| 602 |
+
"Missing 'name' in params",
|
| 603 |
+
request_id=request_id,
|
| 604 |
+
)
|
| 605 |
+
if not isinstance(arguments, dict):
|
| 606 |
+
return _jsonrpc_error(
|
| 607 |
+
JSONRPC_INVALID_PARAMS,
|
| 608 |
+
"Invalid params: 'arguments' must be an object",
|
| 609 |
+
request_id=request_id,
|
| 610 |
+
)
|
| 611 |
+
|
| 612 |
+
try:
|
| 613 |
+
if tool_name == "list_tasks":
|
| 614 |
+
result: Any = _build_tasks_payload()
|
| 615 |
+
elif tool_name == "episode_reset":
|
| 616 |
+
reset_request = ResetRequest.model_validate(arguments)
|
| 617 |
+
result = (await _reset_observation(reset_request)).model_dump()
|
| 618 |
+
elif tool_name == "episode_step":
|
| 619 |
+
action = SQLAction.model_validate(arguments)
|
| 620 |
+
result = (await _execute_step(action)).model_dump()
|
| 621 |
+
elif tool_name == "episode_state":
|
| 622 |
+
result = (await _current_state()).model_dump()
|
| 623 |
+
else:
|
| 624 |
+
return _jsonrpc_error(
|
| 625 |
+
JSONRPC_INVALID_PARAMS,
|
| 626 |
+
f"Tool not found: {tool_name}",
|
| 627 |
+
request_id=request_id,
|
| 628 |
+
)
|
| 629 |
+
except ValidationError as e:
|
| 630 |
+
return _jsonrpc_error(
|
| 631 |
+
JSONRPC_INVALID_PARAMS,
|
| 632 |
+
"Invalid params",
|
| 633 |
+
request_id=request_id,
|
| 634 |
+
data=e.errors(),
|
| 635 |
+
)
|
| 636 |
+
except HTTPException as e:
|
| 637 |
+
code = JSONRPC_INVALID_PARAMS if e.status_code == 422 else JSONRPC_INTERNAL_ERROR
|
| 638 |
+
return _jsonrpc_error(code, str(e.detail), request_id=request_id)
|
| 639 |
+
except Exception as e:
|
| 640 |
+
return _jsonrpc_error(JSONRPC_INTERNAL_ERROR, str(e), request_id=request_id)
|
| 641 |
+
|
| 642 |
+
return _jsonrpc_success(result, request_id=request_id)
|
| 643 |
|
| 644 |
|
| 645 |
@app.get("/status")
|
|
|
|
| 713 |
Returns:
|
| 714 |
dict: Full episode state with schema_info for Table Schema panel
|
| 715 |
"""
|
| 716 |
+
obs = await _reset_observation(request)
|
| 717 |
+
return {
|
| 718 |
+
"status": "ok",
|
| 719 |
+
"episode_id": obs.episode_id,
|
| 720 |
+
"task_id": obs.task_id,
|
| 721 |
+
"task_description": obs.task_description,
|
| 722 |
+
"schema": _schema_rows_from_observation(obs),
|
| 723 |
+
"sample_rows": obs.sample_rows,
|
| 724 |
+
"attempt_number": obs.attempt_number,
|
| 725 |
+
"max_attempts": obs.max_attempts,
|
| 726 |
+
"investigation_hint": obs.investigation_hint,
|
| 727 |
+
"task_context": _env.task_context(),
|
| 728 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 729 |
|
| 730 |
|
| 731 |
@app.post("/reset", response_model=SQLObservation)
|
|
|
|
| 742 |
HTTPException 422: If task_id is not a valid task identifier
|
| 743 |
HTTPException 500: If environment initialization fails
|
| 744 |
"""
|
| 745 |
+
return await _reset_observation(request)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 746 |
|
| 747 |
|
| 748 |
@app.post("/step", response_model=StepResponse)
|
|
|
|
| 759 |
HTTPException 422: If action is invalid or episode is already done
|
| 760 |
HTTPException 500: If environment step execution fails
|
| 761 |
"""
|
| 762 |
+
return await _execute_step(action)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 763 |
|
| 764 |
|
| 765 |
@app.get("/state", response_model=SQLState)
|
|
|
|
| 772 |
Raises:
|
| 773 |
HTTPException 422: If no active episode exists
|
| 774 |
"""
|
| 775 |
+
return await _current_state()
|
|
|
|
|
|
|
|
|
|
| 776 |
|
| 777 |
|
| 778 |
# βββ CUSTOM DATABASE ENDPOINTS βββββββββββββββββββββββββββββββββββββββββ
|