Deadman922 commited on
Commit
c4ff806
·
verified ·
1 Parent(s): ff42900

Delete server/main.py

Browse files
Files changed (1) hide show
  1. server/main.py +0 -526
server/main.py DELETED
@@ -1,526 +0,0 @@
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()