Deadman922 commited on
Commit
7e6d0cf
·
verified ·
1 Parent(s): c13ba4b

Delete server/environment.py

Browse files
Files changed (1) hide show
  1. server/environment.py +0 -619
server/environment.py DELETED
@@ -1,619 +0,0 @@
1
- """SQL Quality Environment - Core environment manager."""
2
-
3
- from __future__ import annotations
4
-
5
- import random as _random
6
- import sqlite3
7
- import threading
8
- import uuid
9
- from typing import Any, Dict
10
-
11
- from fastapi import HTTPException
12
-
13
- from .models import (
14
- ActionType,
15
- SQLAction,
16
- SQLObservation,
17
- SQLReward,
18
- SQLState,
19
- StepResponse,
20
- TrajectoryStep,
21
- )
22
- from .tasks import (
23
- create_dedup_bug_task,
24
- create_fk_integrity_task,
25
- create_null_audit_task,
26
- create_schema_drift_task,
27
- create_type_repair_task,
28
- )
29
-
30
- TASK_CREATORS = {
31
- "null_audit": create_null_audit_task,
32
- "type_repair": create_type_repair_task,
33
- "dedup_bug": create_dedup_bug_task,
34
- "fk_integrity": create_fk_integrity_task,
35
- "schema_drift": create_schema_drift_task,
36
- }
37
-
38
-
39
- class SQLQualityEnvironment:
40
- """Main environment class for SQL Quality tasks."""
41
-
42
- def __init__(self) -> None:
43
- """Initialize environment."""
44
- self._current_task_id: str | None = None
45
- self._episode_id: str | None = None
46
- self._attempt_number: int = 1
47
- self._step_count: int = (
48
- 0 # increments on every action (QUERY, PROFILE, FIX, SUBMIT)
49
- )
50
- self._max_steps_per_episode: int = 20 # prevent infinite loops
51
- self._cumulative_reward: float = 0.0
52
- self._done: bool = False
53
- self._ground_truth: Dict[str, Any] = {}
54
- self._cached_observation: SQLObservation | None = None
55
- self._lock = threading.Lock()
56
- # NEW: Multi-step trajectory support
57
- self._trajectory: list = []
58
- self._original_snapshot: dict = {}
59
- self._original_conn: sqlite3.Connection | None = None
60
- self._active: bool = False
61
- self._last_feedback: str | None = None
62
-
63
- def reset(self, task_id: str, seed: int | None = None) -> SQLObservation:
64
- """Reset environment for a new episode.
65
-
66
- Args:
67
- task_id: One of the registered task IDs
68
- seed: Optional deterministic episode seed
69
-
70
- Returns:
71
- SQLObservation for the new episode
72
-
73
- Raises:
74
- ValueError: If task_id is not valid
75
- """
76
- with self._lock:
77
- # ── NEW: close any live connection from the previous episode ──────
78
- if hasattr(self, "_original_conn") and self._original_conn is not None:
79
- try:
80
- self._original_conn.close()
81
- except Exception:
82
- pass # Already closed or never opened — safe to ignore
83
- self._original_conn = None
84
-
85
- # Validate task_id
86
- if task_id not in TASK_CREATORS:
87
- raise ValueError(f"Invalid task_id: {task_id}")
88
-
89
- # Generate new episode
90
- self._episode_id = str(uuid.uuid4())
91
- self._attempt_number = 1
92
- self._step_count = (
93
- 0 # increments on every action (QUERY, PROFILE, FIX, SUBMIT)
94
- )
95
- self._cumulative_reward = 0.0
96
- self._done = False
97
- self._current_task_id = task_id
98
- self._trajectory = []
99
- self._original_snapshot = {}
100
- self._ground_truth = {}
101
- self._cached_observation = None
102
- self._last_feedback = None
103
-
104
- # Create task-specific database to extract observation data
105
- conn = None
106
- try:
107
- # Generate random episode seed for diversity
108
- episode_seed = seed if seed is not None else _random.randint(0, 2**31 - 1)
109
- creator = TASK_CREATORS[task_id]
110
- conn, observation, self._ground_truth = creator(episode_seed)
111
-
112
- # Store original connection for graders (read-only use)
113
- self._original_conn = conn
114
-
115
- # Snapshot the target table for data preservation grading
116
- target_table = self._ground_truth.get("table", "")
117
- if target_table:
118
- from .graders import _snapshot_table
119
-
120
- self._original_snapshot = _snapshot_table(conn, target_table)
121
-
122
- # Mark episode as active
123
- self._active = True
124
-
125
- # Cache observation for reuse in step()
126
- self._cached_observation = observation.model_copy(
127
- update={"episode_id": self._episode_id}
128
- )
129
-
130
- # Store schema info for dynamic updates
131
- self._schema_info = observation.schema_info
132
-
133
- # Keep connection open for multi-step operations
134
- # Connection will be closed when episode ends or environment is reset
135
-
136
- except Exception as e:
137
- if conn is not None:
138
- conn.close()
139
- raise e
140
-
141
- return self._cached_observation
142
-
143
- def step(self, action: SQLAction) -> StepResponse:
144
- """
145
- Execute one agent action and return the resulting observation and reward.
146
-
147
- Routes by action_type:
148
- QUERY — execute SELECT on episode conn, return result in observation
149
- PROFILE — compute column statistics, return in observation
150
- FIX — execute on fresh_test_db(), score partially, continue episode
151
- SUBMIT — call compute_episode_score(), end episode
152
- """
153
- with self._lock:
154
- if not self._active:
155
- raise HTTPException(
156
- status_code=422, detail="No active episode. Call reset() first."
157
- )
158
- if self._done:
159
- raise HTTPException(
160
- status_code=422,
161
- detail="Episode is already done. Call reset() to start a new episode.",
162
- )
163
-
164
- # Check max steps guard
165
- if self._step_count >= self._max_steps_per_episode:
166
- raise HTTPException(
167
- status_code=422,
168
- detail="Max investigation steps reached. Call SUBMIT or reset().",
169
- )
170
-
171
- action = self._normalize_action(action)
172
-
173
- action_type = action.action_type
174
-
175
- # --- QUERY action ---
176
- if action_type == ActionType.QUERY:
177
- return self._handle_query(action)
178
-
179
- # --- PROFILE action ---
180
- elif action_type == ActionType.PROFILE:
181
- return self._handle_profile(action)
182
-
183
- # --- FIX action ---
184
- elif action_type == ActionType.FIX:
185
- return self._handle_fix(action)
186
-
187
- # --- SUBMIT action (or legacy single-step) ---
188
- else:
189
- return self._handle_submit(action)
190
-
191
- def _normalize_action(self, action: SQLAction) -> SQLAction:
192
- """Infer v2 action_type for backward-compatible payloads without action_type."""
193
- if "action_type" in action.model_fields_set:
194
- return action
195
-
196
- inferred = ActionType.SUBMIT
197
- if action.column_name:
198
- inferred = ActionType.PROFILE
199
- elif action.fix_query:
200
- inferred = ActionType.FIX
201
- elif action.sql:
202
- inferred = (
203
- ActionType.QUERY
204
- if action.sql.strip().upper().startswith("SELECT")
205
- else ActionType.FIX
206
- )
207
- elif action.answer or action.root_cause_table or action.offending_key:
208
- inferred = ActionType.SUBMIT
209
-
210
- return action.model_copy(update={"action_type": inferred})
211
-
212
- def _handle_query(self, action: SQLAction) -> StepResponse:
213
- """
214
- Handle a QUERY action.
215
-
216
- Executes the SQL on the episode connection (supports multi-statement).
217
- Returns a result preview in the observation. Does NOT end the episode.
218
- Awards small step_reward (0.05) for queries that target the right area.
219
- """
220
- import re
221
-
222
- sql = (action.sql or "").strip()
223
-
224
- # Strip comments and leading whitespace to get the first keyword
225
- sql_stripped = re.sub(r"--[^\n]*", "", sql) # remove single-line comments
226
- sql_stripped = re.sub(r"/\*.*?\*/", "", sql_stripped, flags=re.DOTALL) # block comments
227
- first_keyword = sql_stripped.strip().split()[0].upper() if sql_stripped.strip() else ""
228
-
229
- # Allow all SQL types in query box for testing
230
- # Only FIX action should be restricted
231
- ALLOWED_READ_KEYWORDS = {"SELECT", "WITH", "EXPLAIN", "PRAGMA", "UPDATE", "INSERT", "DELETE", "CREATE", "DROP", "ALTER"}
232
- if first_keyword not in ALLOWED_READ_KEYWORDS:
233
- rejection_feedback = (
234
- f"Invalid SQL keyword: '{first_keyword}'. "
235
- f"Allowed keywords: {', '.join(sorted(ALLOWED_READ_KEYWORDS))}"
236
- )
237
- # Add to trajectory as a step but with zero reward and clear feedback
238
- self._trajectory.append(
239
- TrajectoryStep(
240
- step_number=len(self._trajectory) + 1,
241
- action_type=ActionType.QUERY,
242
- sql_submitted=sql[:200],
243
- result_preview=rejection_feedback,
244
- step_reward=0.0,
245
- )
246
- )
247
- self._step_count = getattr(self, "_step_count", 0) + 1
248
- obs = self._build_observation(result_preview=rejection_feedback)
249
- return StepResponse(
250
- observation=obs,
251
- reward=SQLReward(
252
- score=self._cumulative_reward,
253
- step_reward=0.0,
254
- feedback=rejection_feedback,
255
- ),
256
- done=False,
257
- info={"action_type": "query", "error": "non_select_rejected", "step": len(self._trajectory)},
258
- )
259
-
260
- # Split multiple statements by semicolon, but preserve semicolons in strings
261
- statements = []
262
- current = ""
263
- in_string = False
264
- string_char = None
265
- i = 0
266
- while i < len(sql):
267
- char = sql[i]
268
- if char in ("'", '"') and (i == 0 or sql[i-1] != "\\"):
269
- if not in_string:
270
- in_string = True
271
- string_char = char
272
- elif char == string_char:
273
- in_string = False
274
- string_char = None
275
- if char == ";" and not in_string:
276
- stmt = current.strip()
277
- if stmt:
278
- statements.append(stmt)
279
- current = ""
280
- else:
281
- current += char
282
- i += 1
283
- # Add final statement
284
- if current.strip():
285
- statements.append(current.strip())
286
-
287
- # If no statements found, treat as single statement
288
- if not statements:
289
- statements = [sql] if sql else []
290
-
291
- result_preview = ""
292
- step_reward = 0.0
293
- last_error = None
294
-
295
- for stmt in statements:
296
- if not stmt:
297
- continue
298
- try:
299
- cursor = self._original_conn.execute(stmt)
300
- # Check if query returns rows (SELECT) or just modifies (INSERT/UPDATE/DELETE)
301
- if cursor.description:
302
- rows = cursor.fetchmany(5)
303
- cols = [d[0] for d in cursor.description]
304
-
305
- # Update schema info if this is a custom query with new columns
306
- if hasattr(self, '_schema_info') and self._schema_info:
307
- # Check if columns differ from current schema
308
- current_cols = []
309
- for table_info in self._schema_info:
310
- for col_str in table_info.columns:
311
- if isinstance(col_str, str):
312
- current_cols.append(col_str.split(' (')[0])
313
-
314
- if set(cols) != set(current_cols):
315
- # Update schema with new columns from custom query
316
- from .models import SchemaInfo
317
- table_name = f"custom_query_{len(self._trajectory)}"
318
- new_columns = [f"{col} (TEXT)" for col in cols]
319
- self._schema_info.append(
320
- SchemaInfo(table_name=table_name, columns=new_columns, row_count=len(rows))
321
- )
322
-
323
- # Return actual data for formatting, not string
324
- result_preview = {
325
- "type": "table",
326
- "columns": cols,
327
- "rows": [dict(zip(cols, row)) for row in rows],
328
- "total_rows": len(rows)
329
- }
330
- else:
331
- # For INSERT/UPDATE/DELETE, show affected rows
332
- result_preview = {
333
- "type": "affected_rows",
334
- "message": f"Query executed successfully. Rows affected: {cursor.rowcount}",
335
- "rows_affected": cursor.rowcount
336
- }
337
- except sqlite3.Error as e:
338
- last_error = str(e)
339
- result_preview = f"SQL Error: {e}"
340
- break # Stop on first error
341
-
342
- # Small reward for targeted queries (only check first statement for relevance)
343
- if statements and not last_error:
344
- from .graders import _query_reveals_issue
345
- step_reward = (
346
- 0.05 if _query_reveals_issue(statements[0], self._ground_truth) else 0.01
347
- )
348
-
349
- # Add to trajectory
350
- self._trajectory.append(
351
- TrajectoryStep(
352
- step_number=len(self._trajectory) + 1,
353
- action_type=ActionType.QUERY,
354
- sql_submitted=sql,
355
- result_preview=str(result_preview)[:200] if isinstance(result_preview, (dict, list)) else str(result_preview)[:200],
356
- step_reward=step_reward,
357
- )
358
- )
359
-
360
- self._cumulative_reward = min(
361
- 1.0, round(self._cumulative_reward + step_reward, 4)
362
- )
363
- self._last_feedback = str(result_preview)[:200] if isinstance(result_preview, (dict, list)) else str(result_preview)[:200]
364
- self._step_count += 1
365
-
366
- # Build updated observation with trajectory history
367
- obs = self._build_observation(result_preview=result_preview)
368
-
369
- return StepResponse(
370
- observation=obs,
371
- reward=SQLReward(
372
- score=self._cumulative_reward,
373
- step_reward=step_reward,
374
- feedback=str(result_preview)[:200] if isinstance(result_preview, (dict, list)) else str(result_preview)[:200],
375
- ),
376
- done=False,
377
- info={"action_type": "query", "step": len(self._trajectory)},
378
- )
379
-
380
- def _handle_profile(self, action: SQLAction) -> StepResponse:
381
- """
382
- Handle a PROFILE action — statistical summary of a named column.
383
-
384
- Computes: null rate, distinct count, data type distribution, min/max.
385
- Does NOT end the episode. Awards step_reward based on column relevance.
386
- """
387
- col = (action.column_name or "").strip()
388
- result_preview = ""
389
-
390
- if not col:
391
- result_preview = "Error: column_name required for PROFILE action."
392
- else:
393
- # Try to find which table has this column
394
- try:
395
- tables = self._original_conn.execute(
396
- "SELECT name FROM sqlite_master WHERE type='table'"
397
- ).fetchall()
398
- profile_results = []
399
- for (tbl,) in tables:
400
- try:
401
- cols = [
402
- row[1]
403
- for row in self._original_conn.execute(
404
- f"PRAGMA table_info({tbl})"
405
- ).fetchall()
406
- ]
407
- if col in cols:
408
- total = self._original_conn.execute(
409
- f"SELECT COUNT(*) FROM {tbl}"
410
- ).fetchone()[0]
411
- nulls = self._original_conn.execute(
412
- f"SELECT COUNT(*) FROM {tbl} WHERE {col} IS NULL"
413
- ).fetchone()[0]
414
- distinct = self._original_conn.execute(
415
- f"SELECT COUNT(DISTINCT {col}) FROM {tbl}"
416
- ).fetchone()[0]
417
- profile_results.append(
418
- f"{tbl}.{col}: total={total}, nulls={nulls} ({100*nulls//total if total else 0}%), distinct={distinct}"
419
- )
420
- except sqlite3.Error:
421
- pass
422
- result_preview = (
423
- " | ".join(profile_results)
424
- if profile_results
425
- else f"Column '{col}' not found."
426
- )
427
- except sqlite3.Error as e:
428
- result_preview = f"Profile error: {e}"
429
-
430
- # Determine step_reward based on relevance to ground truth
431
- ground_table = self._ground_truth.get("table", "").lower()
432
- ground_col = self._ground_truth.get(
433
- "column", self._ground_truth.get("null_column", "")
434
- ).lower()
435
- col_lower = col.lower()
436
-
437
- if col_lower == ground_col:
438
- step_reward = 0.08 # On-target: profiling exact relevant column
439
- elif ground_table and ground_table in result_preview.lower():
440
- step_reward = 0.05 # Near-target: profiled something from right table
441
- elif col_lower in result_preview.lower() and len(col_lower) > 2:
442
- step_reward = 0.01 # Off-target: profiled something real but irrelevant
443
- else:
444
- step_reward = 0.01 # Minimal: invalid column or junk probe
445
-
446
- self._trajectory.append(
447
- TrajectoryStep(
448
- step_number=len(self._trajectory) + 1,
449
- action_type=ActionType.PROFILE,
450
- sql_submitted=f"PROFILE({col})",
451
- result_preview=result_preview[:200],
452
- step_reward=step_reward,
453
- )
454
- )
455
-
456
- self._cumulative_reward = min(
457
- 1.0, round(self._cumulative_reward + step_reward, 4)
458
- )
459
- self._last_feedback = result_preview[:200]
460
- self._step_count += 1
461
- obs = self._build_observation(result_preview=result_preview)
462
-
463
- return StepResponse(
464
- observation=obs,
465
- reward=SQLReward(
466
- score=self._cumulative_reward,
467
- step_reward=step_reward,
468
- feedback=result_preview[:200],
469
- ),
470
- done=False,
471
- info={"action_type": "profile", "column_profiled": col},
472
- )
473
-
474
- def _handle_fix(self, action: SQLAction) -> StepResponse:
475
- """
476
- Handle a FIX action — execute write query on isolated copy for preview scoring.
477
-
478
- Executes on a _fresh_test_db() copy. Does NOT mutate episode state.
479
- Returns intermediate feedback on whether fix appears to work.
480
- Does NOT end episode — agent can follow up with verification query or SUBMIT.
481
- Awards intermediate reward based on quick fix assessment.
482
- """
483
- fix_sql = action.effective_sql or ""
484
- step_reward = 0.0
485
- feedback = ""
486
-
487
- if fix_sql:
488
- from .graders import grade_fix_correctness
489
-
490
- quick_score, severity = grade_fix_correctness(
491
- fix_sql,
492
- self._original_conn,
493
- self._original_snapshot,
494
- self._ground_truth,
495
- )
496
- step_reward = quick_score * 0.3 # Partial credit for fix attempt
497
- feedback = f"Fix preview: {severity} ({quick_score:.2f}). Submit to finalize score."
498
- else:
499
- feedback = "No SQL provided in fix action."
500
-
501
- self._trajectory.append(
502
- TrajectoryStep(
503
- step_number=len(self._trajectory) + 1,
504
- action_type=ActionType.FIX,
505
- sql_submitted=fix_sql,
506
- result_preview=feedback[:200],
507
- step_reward=step_reward,
508
- )
509
- )
510
-
511
- self._cumulative_reward = min(
512
- 1.0, round(self._cumulative_reward + step_reward, 4)
513
- )
514
- self._last_feedback = feedback
515
- self._step_count += 1
516
- obs = self._build_observation(result_preview=feedback)
517
-
518
- return StepResponse(
519
- observation=obs,
520
- reward=SQLReward(
521
- score=self._cumulative_reward,
522
- step_reward=step_reward,
523
- feedback=feedback,
524
- ),
525
- done=False,
526
- info={
527
- "action_type": "fix",
528
- "severity_preview": severity if fix_sql else "N/A",
529
- },
530
- )
531
-
532
- def _handle_submit(self, action: SQLAction) -> StepResponse:
533
- """
534
- Handle a SUBMIT action — compute final composite score and end episode.
535
-
536
- Calls compute_episode_score() with the full trajectory.
537
- Sets done=True. This is the only action that ends the episode.
538
- """
539
- from .graders import compute_episode_score
540
-
541
- reward = compute_episode_score(
542
- trajectory=self._trajectory,
543
- ground_truth=self._ground_truth,
544
- original_conn=self._original_conn,
545
- original_snapshot=self._original_snapshot,
546
- submit_action=action,
547
- )
548
-
549
- self._done = True
550
- self._active = False
551
- self._cumulative_reward = min(
552
- 1.0, round(self._cumulative_reward + reward.score, 4)
553
- )
554
- self._last_feedback = reward.feedback
555
- obs = self._build_observation()
556
-
557
- # Close connection when episode ends
558
- if self._original_conn:
559
- self._original_conn.close()
560
- self._original_conn = None
561
-
562
- return StepResponse(
563
- observation=obs,
564
- reward=reward,
565
- done=True,
566
- info={
567
- "action_type": "submit",
568
- "final_score": reward.score,
569
- "dimensions": reward.dimensions,
570
- "trajectory_length": len(self._trajectory),
571
- },
572
- )
573
-
574
- def _build_observation(self, result_preview: str = "") -> SQLObservation:
575
- """Build current observation including trajectory history."""
576
- history = [
577
- {
578
- "step": s.step_number,
579
- "type": s.action_type.value,
580
- "sql": (s.sql_submitted or "")[:80],
581
- "result": s.result_preview[:80],
582
- "reward": s.step_reward,
583
- }
584
- for s in self._trajectory[-5:] # Last 5 steps only (context window)
585
- ]
586
- return SQLObservation(
587
- task_id=self._current_task_id,
588
- task_description=self._cached_observation.task_description,
589
- schema_info=self._cached_observation.schema_info,
590
- sample_rows=self._cached_observation.sample_rows,
591
- attempt_number=self._attempt_number,
592
- episode_id=self._episode_id,
593
- trajectory_history=history,
594
- investigation_hint=result_preview[:200] if result_preview else None,
595
- previous_feedback=(
596
- self._last_feedback if hasattr(self, "_last_feedback") else None
597
- ),
598
- max_attempts=self._cached_observation.max_attempts,
599
- )
600
-
601
- def state(self) -> SQLState:
602
- """Get current environment state.
603
-
604
- Returns:
605
- SQLState with current episode information
606
-
607
- Raises:
608
- ValueError: If no episode has been started
609
- """
610
- if self._episode_id is None:
611
- raise ValueError("No episode started. Call reset() first.")
612
-
613
- return SQLState(
614
- task_id=self._current_task_id,
615
- episode_id=self._episode_id,
616
- attempt_number=self._attempt_number,
617
- cumulative_reward=self._cumulative_reward,
618
- done=self._done,
619
- )