Deadman922 commited on
Commit
e6f46b5
·
verified ·
1 Parent(s): 7be12ad

Upload main.py

Browse files
Files changed (1) hide show
  1. server/main.py +526 -0
server/main.py ADDED
@@ -0,0 +1,526 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import csv
3
+ import io
4
+ import os
5
+ import re
6
+ import sqlite3
7
+ from typing import Any
8
+
9
+ from fastapi import FastAPI, HTTPException, UploadFile, File
10
+ from fastapi.responses import HTMLResponse
11
+
12
+ from .environment import SQLQualityEnvironment
13
+ from .models import (
14
+ HealthResponse,
15
+ ResetRequest,
16
+ SQLAction,
17
+ SQLObservation,
18
+ SQLState,
19
+ StepResponse,
20
+ )
21
+
22
+ app = FastAPI(
23
+ title="SQL Quality Audit Agent Environment",
24
+ description="OpenEnv-compatible environment for training AI agents on SQL data quality tasks",
25
+ version="0.1.0",
26
+ )
27
+
28
+ # Module-level singleton — ONE instance shared across all requests
29
+ _env = SQLQualityEnvironment()
30
+
31
+ # Module-level custom database connection (separate from RL environment)
32
+ _custom_db_conn: sqlite3.Connection | None = None
33
+ _custom_db_schema: list[dict] = []
34
+ _custom_db_filename: str = ""
35
+
36
+ # NEW: RL task support on uploaded database
37
+ _uploaded_db_for_rl: sqlite3.Connection | None = None
38
+ _uploaded_db_schema: dict = {}
39
+ _uploaded_db_tasks: list[str] = [] # Which tasks are compatible
40
+
41
+ # HTTP-level request lock to serialize concurrent requests
42
+ _request_lock = asyncio.Lock()
43
+
44
+
45
+ def load_static_html() -> str:
46
+ """Load the static HTML interface for the App section."""
47
+ # Look for static/index.html in various locations
48
+ possible_paths = [
49
+ os.path.join(os.path.dirname(__file__), "..", "static", "index.html"),
50
+ os.path.join(os.path.dirname(__file__), "static", "index.html"),
51
+ "/app/static/index.html",
52
+ "static/index.html",
53
+ ]
54
+ for path in possible_paths:
55
+ if os.path.exists(path):
56
+ with open(path, "r", encoding="utf-8") as f:
57
+ return f.read()
58
+ # Fallback simple HTML if file not found
59
+ return """<!DOCTYPE html>
60
+ <html>
61
+ <head><title>SQL Quality Environment</title></head>
62
+ <body>
63
+ <h1>SQL Quality Audit Agent Environment</h1>
64
+ <p>Environment is running. Use API endpoints directly:</p>
65
+ <ul>
66
+ <li>GET /health - Health check</li>
67
+ <li>POST /reset - Reset task</li>
68
+ <li>POST /step - Execute action</li>
69
+ <li>GET /state - Get current state</li>
70
+ </ul>
71
+ </body>
72
+ </html>"""
73
+
74
+
75
+ @app.get("/", response_class=HTMLResponse)
76
+ async def root():
77
+ """Root endpoint for HF Space App section - provides interactive testing interface."""
78
+ return load_static_html()
79
+
80
+
81
+ @app.get("/health", response_model=HealthResponse)
82
+ async def health() -> HealthResponse:
83
+ """Health check endpoint for monitoring and load balancers.
84
+
85
+ Returns:
86
+ dict: Status response indicating the server is running
87
+ """
88
+ # Check LLM availability
89
+ llm_available = False
90
+ try:
91
+ import os
92
+ hf_token = os.getenv("HF_TOKEN", "")
93
+ api_base_url = os.getenv("API_BASE_URL", "")
94
+ model_name = os.getenv("MODEL_NAME", "")
95
+
96
+ if hf_token and api_base_url and model_name:
97
+ llm_available = True
98
+ except Exception:
99
+ pass
100
+
101
+ return HealthResponse(status="ok", llm_available=llm_available)
102
+
103
+
104
+ @app.post("/ping", response_model=HealthResponse)
105
+ async def ping() -> HealthResponse:
106
+ """Ping endpoint for submission validator — accepts empty body.
107
+
108
+ Returns:
109
+ dict: Status response indicating the server is responsive
110
+ """
111
+ return HealthResponse(status="ok")
112
+
113
+
114
+ @app.post("/reset", response_model=SQLObservation)
115
+ async def reset(request: ResetRequest) -> SQLObservation:
116
+ """Reset environment and return initial observation for specified task.
117
+
118
+ Args:
119
+ request: ResetRequest containing task_id and optional deterministic seed
120
+
121
+ Returns:
122
+ SQLObservation: Initial state of database for requested task
123
+
124
+ Raises:
125
+ HTTPException 422: If task_id is not a valid task identifier
126
+ HTTPException 500: If environment initialization fails
127
+ """
128
+ global _uploaded_db_for_rl, _uploaded_db_tasks
129
+
130
+ async with _request_lock:
131
+ VALID_TASKS = {
132
+ "null_audit",
133
+ "type_repair",
134
+ "dedup_bug",
135
+ "fk_integrity",
136
+ "schema_drift",
137
+ }
138
+ if request.task_id not in VALID_TASKS:
139
+ raise HTTPException(
140
+ status_code=422,
141
+ detail=f"Invalid task_id '{request.task_id}'. Must be one of: {VALID_TASKS}",
142
+ )
143
+ try:
144
+ # NEW: Check if we have an uploaded DB compatible with this task
145
+ if _uploaded_db_for_rl is not None and request.task_id in _uploaded_db_tasks:
146
+ # Use uploaded database for this task
147
+ return _env.reset_with_connection(
148
+ request.task_id,
149
+ _uploaded_db_for_rl,
150
+ _uploaded_db_schema
151
+ )
152
+ else:
153
+ # Use synthetic data (original behavior)
154
+ return _env.reset(request.task_id, seed=request.seed)
155
+ except Exception as e:
156
+ raise HTTPException(status_code=500, detail=str(e))
157
+
158
+
159
+ @app.post("/step", response_model=StepResponse)
160
+ async def step(action: SQLAction) -> StepResponse:
161
+ """Submit an action and receive observation, reward, done flag, and info.
162
+
163
+ Args:
164
+ action: SQLAction containing task_id and fix_query or answer
165
+
166
+ Returns:
167
+ StepResponse: Updated observation, reward, done status, and info
168
+
169
+ Raises:
170
+ HTTPException 422: If action is invalid or episode is already done
171
+ HTTPException 500: If environment step execution fails
172
+ """
173
+ async with _request_lock:
174
+ try:
175
+ return _env.step(action)
176
+ except HTTPException:
177
+ raise
178
+ except ValueError as e:
179
+ raise HTTPException(status_code=422, detail=str(e))
180
+ except Exception as e:
181
+ raise HTTPException(status_code=500, detail=str(e))
182
+
183
+
184
+ @app.get("/state", response_model=SQLState)
185
+ async def state() -> SQLState:
186
+ """Get current episode state.
187
+
188
+ Returns:
189
+ SQLState: Current episode information including task_id, episode_id, attempt_number, cumulative_reward, and done status
190
+
191
+ Raises:
192
+ HTTPException 422: If no active episode exists
193
+ """
194
+ try:
195
+ return _env.state()
196
+ except ValueError as e:
197
+ raise HTTPException(status_code=422, detail=str(e))
198
+
199
+
200
+ # ─── CUSTOM DATABASE ENDPOINTS ─────────────────────────────────────────
201
+
202
+ def infer_sqlite_type(values: list[str]) -> str:
203
+ """Infer SQLite column type from a list of string values."""
204
+ non_empty = [v for v in values if v and v.strip()]
205
+ if not non_empty:
206
+ return 'TEXT'
207
+ try:
208
+ for v in non_empty:
209
+ int(v)
210
+ return 'INTEGER'
211
+ except ValueError:
212
+ pass
213
+ try:
214
+ for v in non_empty:
215
+ float(v)
216
+ return 'REAL'
217
+ except ValueError:
218
+ pass
219
+ return 'TEXT'
220
+
221
+
222
+ def get_db_schema(conn: sqlite3.Connection) -> list[dict]:
223
+ """Extract schema information from a SQLite connection."""
224
+ schema = []
225
+ cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
226
+ tables = cursor.fetchall()
227
+
228
+ for (table_name,) in tables:
229
+ # Get row count
230
+ count_cursor = conn.execute(f'SELECT COUNT(*) FROM "{table_name}"')
231
+ row_count = count_cursor.fetchone()[0]
232
+
233
+ # Get columns
234
+ pragma_cursor = conn.execute(f'PRAGMA table_info("{table_name}")')
235
+ columns = []
236
+ for row in pragma_cursor.fetchall():
237
+ col_name = row[1]
238
+ col_type = row[2]
239
+ columns.append(f"{col_name} ({col_type})")
240
+
241
+ schema.append({
242
+ "table_name": table_name,
243
+ "row_count": row_count,
244
+ "columns": columns
245
+ })
246
+
247
+ return schema
248
+
249
+
250
+ def detect_tasks_from_schema(schema: list[dict]) -> list[str]:
251
+ """Detect which RL tasks are compatible with the uploaded database schema.
252
+
253
+ Args:
254
+ schema: List of table info dicts with table_name and columns
255
+
256
+ Returns:
257
+ List of task IDs that can run on this database
258
+ """
259
+ compatible_tasks = []
260
+ table_names = {t['table_name'].lower() for t in schema}
261
+
262
+ # null_audit: requires 'users' table
263
+ if 'users' in table_names:
264
+ compatible_tasks.append('null_audit')
265
+
266
+ # type_repair: requires 'transactions' table
267
+ if 'transactions' in table_names:
268
+ compatible_tasks.append('type_repair')
269
+
270
+ # dedup_bug: requires 'order_items' table
271
+ if 'order_items' in table_names:
272
+ compatible_tasks.append('dedup_bug')
273
+
274
+ # fk_integrity: requires 'invoices' and 'customers' tables
275
+ if 'invoices' in table_names and 'customers' in table_names:
276
+ compatible_tasks.append('fk_integrity')
277
+
278
+ # schema_drift: requires 'events_v1' and 'events_v2' tables
279
+ if 'events_v1' in table_names and 'events_v2' in table_names:
280
+ compatible_tasks.append('schema_drift')
281
+
282
+ return compatible_tasks
283
+
284
+
285
+ @app.post("/upload-db")
286
+ async def upload_db(file: UploadFile = File(...), use_for_tasks: bool = True) -> dict:
287
+ """Upload a .sqlite, .db, or .csv file for custom exploration and RL tasks.
288
+
289
+ Args:
290
+ file: The uploaded file (.sqlite, .db, or .csv)
291
+ use_for_tasks: If True, enables RL tasks on this database
292
+
293
+ Returns:
294
+ dict: Status, schema info, and compatible RL tasks
295
+
296
+ Raises:
297
+ HTTPException 400: If file extension is invalid
298
+ HTTPException 413: If file is too large (>15MB)
299
+ HTTPException 500: If processing fails
300
+ """
301
+ global _custom_db_conn, _custom_db_schema, _custom_db_filename
302
+ global _uploaded_db_for_rl, _uploaded_db_schema, _uploaded_db_tasks
303
+
304
+ # Validate file extension
305
+ filename = file.filename or ""
306
+ ext = os.path.splitext(filename)[1].lower()
307
+ valid_extensions = {'.sqlite', '.db', '.csv'}
308
+
309
+ if ext not in valid_extensions:
310
+ raise HTTPException(
311
+ status_code=400,
312
+ detail=f"Invalid file extension: {ext}. Must be one of: {valid_extensions}"
313
+ )
314
+
315
+ # Read file content
316
+ content = await file.read()
317
+
318
+ # Validate file size (15MB limit)
319
+ max_size = 15 * 1024 * 1024
320
+ if len(content) > max_size:
321
+ raise HTTPException(
322
+ status_code=413,
323
+ detail=f"File too large: {len(content)} bytes. Maximum is {max_size} bytes (15MB)"
324
+ )
325
+
326
+ try:
327
+ # Close existing connection if any
328
+ if _custom_db_conn is not None:
329
+ try:
330
+ _custom_db_conn.close()
331
+ except Exception:
332
+ pass
333
+ _custom_db_conn = None
334
+
335
+ if ext == '.csv':
336
+ # Parse CSV and create in-memory SQLite database
337
+ conn = sqlite3.connect(":memory:", check_same_thread=False)
338
+
339
+ # Read CSV content
340
+ csv_text = content.decode('utf-8')
341
+ csv_file = io.StringIO(csv_text)
342
+ reader = csv.DictReader(csv_file)
343
+
344
+ if not reader.fieldnames:
345
+ raise HTTPException(status_code=400, detail="CSV file has no headers")
346
+
347
+ # Infer types from first 100 rows
348
+ rows = list(reader)
349
+ if not rows:
350
+ raise HTTPException(status_code=400, detail="CSV file is empty")
351
+
352
+ # Create table
353
+ table_name = os.path.splitext(os.path.basename(filename))[0]
354
+ # Sanitize table name
355
+ table_name = re.sub(r'[^a-zA-Z0-9_]', '_', table_name)
356
+ if not table_name[0].isalpha() and table_name[0] != '_':
357
+ table_name = '_' + table_name
358
+
359
+ columns = []
360
+ for col_name in reader.fieldnames:
361
+ # Sanitize column name
362
+ safe_col = re.sub(r'[^a-zA-Z0-9_]', '_', col_name)
363
+ if not safe_col[0].isalpha() and safe_col[0] != '_':
364
+ safe_col = '_' + safe_col
365
+
366
+ values = [row.get(col_name, '') for row in rows[:100]]
367
+ col_type = infer_sqlite_type(values)
368
+ columns.append(f'"{safe_col}" {col_type}')
369
+
370
+ cols_joined = ", ".join(columns)
371
+ create_sql = f'CREATE TABLE "{table_name}" ({cols_joined})'
372
+ conn.execute(create_sql)
373
+
374
+ # Insert rows
375
+ placeholders = ', '.join(['?' for _ in reader.fieldnames])
376
+ insert_sql = f'INSERT INTO "{table_name}" VALUES ({placeholders})'
377
+
378
+ for row in rows:
379
+ values = [row.get(col_name, None) for col_name in reader.fieldnames]
380
+ conn.execute(insert_sql, values)
381
+
382
+ conn.commit()
383
+ _custom_db_conn = conn
384
+
385
+ else:
386
+ # .sqlite or .db file - write to temp and open
387
+ import tempfile
388
+ tmp = tempfile.NamedTemporaryFile(suffix=ext, delete=False)
389
+ tmp.write(content)
390
+ tmp.close()
391
+
392
+ conn = sqlite3.connect(tmp.name, check_same_thread=False, uri=True)
393
+ conn.execute("PRAGMA query_only = OFF") # Allow modifications for RL tasks
394
+ _custom_db_conn = conn
395
+
396
+ # Get schema
397
+ _custom_db_schema = get_db_schema(_custom_db_conn)
398
+ _custom_db_filename = filename
399
+
400
+ # NEW: Detect compatible RL tasks
401
+ _uploaded_db_tasks = detect_tasks_from_schema(_custom_db_schema)
402
+
403
+ # NEW: Store for RL task use (copy connection reference)
404
+ if use_for_tasks and _uploaded_db_tasks:
405
+ _uploaded_db_for_rl = _custom_db_conn
406
+ _uploaded_db_schema = {t['table_name']: t for t in _custom_db_schema}
407
+
408
+ return {
409
+ "status": "ok",
410
+ "filename": filename,
411
+ "tables": _custom_db_schema,
412
+ "compatible_tasks": _uploaded_db_tasks,
413
+ "use_for_tasks": use_for_tasks and len(_uploaded_db_tasks) > 0
414
+ }
415
+
416
+ except HTTPException:
417
+ raise
418
+ except Exception as e:
419
+ raise HTTPException(status_code=500, detail=f"Failed to process file: {str(e)}")
420
+
421
+
422
+ @app.post("/query-custom")
423
+ async def query_custom(body: dict) -> dict:
424
+ """Execute a SELECT query against the uploaded custom database.
425
+
426
+ Args:
427
+ body: JSON body with {"sql": "SELECT ..."}
428
+
429
+ Returns:
430
+ dict: Query results with rows, columns, and row_count
431
+
432
+ Raises:
433
+ HTTPException 422: If no database uploaded or SQL is invalid
434
+ """
435
+ global _custom_db_conn
436
+
437
+ if _custom_db_conn is None:
438
+ raise HTTPException(
439
+ status_code=422,
440
+ detail="No database uploaded yet. Use /upload-db first."
441
+ )
442
+
443
+ sql = body.get("sql", "").strip()
444
+ if not sql:
445
+ raise HTTPException(status_code=422, detail="No SQL provided")
446
+
447
+ # Validate SQL starts with SELECT (same rule as QUERY action)
448
+ sql_upper = sql.upper()
449
+ if not (sql_upper.startswith("SELECT") or sql_upper.startswith("WITH") or
450
+ sql_upper.startswith("EXPLAIN") or sql_upper.startswith("PRAGMA")):
451
+ return {
452
+ "error": "QUERY action is read-only. This action only accepts SELECT, WITH, EXPLAIN, or PRAGMA.",
453
+ "message": "SQL Error — check your query. Only SELECT queries are allowed in custom mode."
454
+ }
455
+
456
+ try:
457
+ cursor = _custom_db_conn.execute(sql)
458
+
459
+ if cursor.description:
460
+ columns = [d[0] for d in cursor.description]
461
+ rows = cursor.fetchall()
462
+
463
+ # Convert rows to dicts
464
+ row_dicts = []
465
+ for row in rows:
466
+ row_dict = {}
467
+ for i, col in enumerate(columns):
468
+ row_dict[col] = row[i]
469
+ row_dicts.append(row_dict)
470
+
471
+ return {
472
+ "rows": row_dicts,
473
+ "columns": columns,
474
+ "row_count": len(row_dicts)
475
+ }
476
+ else:
477
+ return {
478
+ "rows": [],
479
+ "columns": [],
480
+ "row_count": 0,
481
+ "message": "Query executed successfully but returned no rows"
482
+ }
483
+
484
+ except sqlite3.Error as e:
485
+ return {
486
+ "error": str(e),
487
+ "message": "SQL Error — check your query. Check table and column names in the schema panel."
488
+ }
489
+
490
+
491
+ @app.get("/custom-db-status")
492
+ async def custom_db_status() -> dict:
493
+ """Check if a custom database is loaded and return its schema.
494
+
495
+ Returns:
496
+ dict: {loaded: true/false, tables: [...]}
497
+ """
498
+ if _custom_db_conn is None:
499
+ return {"loaded": False}
500
+
501
+ return {
502
+ "loaded": True,
503
+ "filename": _custom_db_filename,
504
+ "tables": _custom_db_schema
505
+ }
506
+
507
+
508
+ def run_server() -> None:
509
+ """
510
+ Entry point for 'uv run server' command.
511
+
512
+ Uses PORT environment variable if set, otherwise defaults to 7860
513
+ (matching the Dockerfile EXPOSE and HF Spaces convention).
514
+ Override locally: PORT=8000 uv run server
515
+ """
516
+ import os
517
+
518
+ import uvicorn
519
+
520
+ port = int(os.getenv("PORT", "7860"))
521
+ uvicorn.run("server.main:app", host="0.0.0.0", port=port, reload=False)
522
+
523
+
524
+ def main() -> None:
525
+ """Primary CLI entrypoint expected by OpenEnv validators."""
526
+ run_server()