Deadman922 commited on
Commit
5fd7741
Β·
verified Β·
1 Parent(s): 4383a20

Upload main.py

Browse files
Files changed (1) hide show
  1. 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": "ok",
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": "https://huggingface.co/spaces/Deadman922/sql-quality-env",
244
- "tasks": [
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
- "tasks": [
273
- {"id": "null_audit", "has_grader": True},
274
- {"id": "type_repair", "has_grader": True},
275
- {"id": "dedup_bug", "has_grader": True},
276
- {"id": "fk_integrity", "has_grader": True},
277
- {"id": "schema_drift", "has_grader": True},
278
- ],
279
- "total": 5,
280
- "graded": 5,
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
- global _uploaded_db_path, _uploaded_db_tasks, _custom_db_analysis
356
-
357
- async with _request_lock:
358
- VALID_TASKS = {
359
- "null_audit",
360
- "type_repair",
361
- "dedup_bug",
362
- "fk_integrity",
363
- "schema_drift",
364
- }
365
- task_id = request.task_id or "null_audit"
366
- if task_id not in VALID_TASKS:
367
- raise HTTPException(
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
- global _uploaded_db_path, _uploaded_db_tasks, _custom_db_analysis
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
- async with _request_lock:
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
- try:
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 ─────────────────────────────────────────