Deadman922 commited on
Commit
30628a7
·
verified ·
1 Parent(s): 114fdd4

Delete server/environment.py

Browse files
Files changed (1) hide show
  1. server/environment.py +0 -609
server/environment.py DELETED
@@ -1,609 +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
- result_preview = str([dict(zip(cols, row)) for row in rows])[:300]
324
- else:
325
- # For INSERT/UPDATE/DELETE, show affected rows
326
- result_preview = f"Query executed successfully. Rows affected: {cursor.rowcount}"
327
- except sqlite3.Error as e:
328
- last_error = str(e)
329
- result_preview = f"SQL Error: {e}"
330
- break # Stop on first error
331
-
332
- # Small reward for targeted queries (only check first statement for relevance)
333
- if statements and not last_error:
334
- from .graders import _query_reveals_issue
335
- step_reward = (
336
- 0.05 if _query_reveals_issue(statements[0], self._ground_truth) else 0.01
337
- )
338
-
339
- # Add to trajectory
340
- self._trajectory.append(
341
- TrajectoryStep(
342
- step_number=len(self._trajectory) + 1,
343
- action_type=ActionType.QUERY,
344
- sql_submitted=sql,
345
- result_preview=result_preview[:200],
346
- step_reward=step_reward,
347
- )
348
- )
349
-
350
- self._cumulative_reward = min(
351
- 1.0, round(self._cumulative_reward + step_reward, 4)
352
- )
353
- self._last_feedback = result_preview[:200]
354
- self._step_count += 1
355
-
356
- # Build updated observation with trajectory history
357
- obs = self._build_observation(result_preview=result_preview)
358
-
359
- return StepResponse(
360
- observation=obs,
361
- reward=SQLReward(
362
- score=self._cumulative_reward,
363
- step_reward=step_reward,
364
- feedback=result_preview[:200],
365
- ),
366
- done=False,
367
- info={"action_type": "query", "step": len(self._trajectory)},
368
- )
369
-
370
- def _handle_profile(self, action: SQLAction) -> StepResponse:
371
- """
372
- Handle a PROFILE action — statistical summary of a named column.
373
-
374
- Computes: null rate, distinct count, data type distribution, min/max.
375
- Does NOT end the episode. Awards step_reward based on column relevance.
376
- """
377
- col = (action.column_name or "").strip()
378
- result_preview = ""
379
-
380
- if not col:
381
- result_preview = "Error: column_name required for PROFILE action."
382
- else:
383
- # Try to find which table has this column
384
- try:
385
- tables = self._original_conn.execute(
386
- "SELECT name FROM sqlite_master WHERE type='table'"
387
- ).fetchall()
388
- profile_results = []
389
- for (tbl,) in tables:
390
- try:
391
- cols = [
392
- row[1]
393
- for row in self._original_conn.execute(
394
- f"PRAGMA table_info({tbl})"
395
- ).fetchall()
396
- ]
397
- if col in cols:
398
- total = self._original_conn.execute(
399
- f"SELECT COUNT(*) FROM {tbl}"
400
- ).fetchone()[0]
401
- nulls = self._original_conn.execute(
402
- f"SELECT COUNT(*) FROM {tbl} WHERE {col} IS NULL"
403
- ).fetchone()[0]
404
- distinct = self._original_conn.execute(
405
- f"SELECT COUNT(DISTINCT {col}) FROM {tbl}"
406
- ).fetchone()[0]
407
- profile_results.append(
408
- f"{tbl}.{col}: total={total}, nulls={nulls} ({100*nulls//total if total else 0}%), distinct={distinct}"
409
- )
410
- except sqlite3.Error:
411
- pass
412
- result_preview = (
413
- " | ".join(profile_results)
414
- if profile_results
415
- else f"Column '{col}' not found."
416
- )
417
- except sqlite3.Error as e:
418
- result_preview = f"Profile error: {e}"
419
-
420
- # Determine step_reward based on relevance to ground truth
421
- ground_table = self._ground_truth.get("table", "").lower()
422
- ground_col = self._ground_truth.get(
423
- "column", self._ground_truth.get("null_column", "")
424
- ).lower()
425
- col_lower = col.lower()
426
-
427
- if col_lower == ground_col:
428
- step_reward = 0.08 # On-target: profiling exact relevant column
429
- elif ground_table and ground_table in result_preview.lower():
430
- step_reward = 0.05 # Near-target: profiled something from right table
431
- elif col_lower in result_preview.lower() and len(col_lower) > 2:
432
- step_reward = 0.01 # Off-target: profiled something real but irrelevant
433
- else:
434
- step_reward = 0.01 # Minimal: invalid column or junk probe
435
-
436
- self._trajectory.append(
437
- TrajectoryStep(
438
- step_number=len(self._trajectory) + 1,
439
- action_type=ActionType.PROFILE,
440
- sql_submitted=f"PROFILE({col})",
441
- result_preview=result_preview[:200],
442
- step_reward=step_reward,
443
- )
444
- )
445
-
446
- self._cumulative_reward = min(
447
- 1.0, round(self._cumulative_reward + step_reward, 4)
448
- )
449
- self._last_feedback = result_preview[:200]
450
- self._step_count += 1
451
- obs = self._build_observation(result_preview=result_preview)
452
-
453
- return StepResponse(
454
- observation=obs,
455
- reward=SQLReward(
456
- score=self._cumulative_reward,
457
- step_reward=step_reward,
458
- feedback=result_preview[:200],
459
- ),
460
- done=False,
461
- info={"action_type": "profile", "column_profiled": col},
462
- )
463
-
464
- def _handle_fix(self, action: SQLAction) -> StepResponse:
465
- """
466
- Handle a FIX action — execute write query on isolated copy for preview scoring.
467
-
468
- Executes on a _fresh_test_db() copy. Does NOT mutate episode state.
469
- Returns intermediate feedback on whether fix appears to work.
470
- Does NOT end episode — agent can follow up with verification query or SUBMIT.
471
- Awards intermediate reward based on quick fix assessment.
472
- """
473
- fix_sql = action.effective_sql or ""
474
- step_reward = 0.0
475
- feedback = ""
476
-
477
- if fix_sql:
478
- from .graders import grade_fix_correctness
479
-
480
- quick_score, severity = grade_fix_correctness(
481
- fix_sql,
482
- self._original_conn,
483
- self._original_snapshot,
484
- self._ground_truth,
485
- )
486
- step_reward = quick_score * 0.3 # Partial credit for fix attempt
487
- feedback = f"Fix preview: {severity} ({quick_score:.2f}). Submit to finalize score."
488
- else:
489
- feedback = "No SQL provided in fix action."
490
-
491
- self._trajectory.append(
492
- TrajectoryStep(
493
- step_number=len(self._trajectory) + 1,
494
- action_type=ActionType.FIX,
495
- sql_submitted=fix_sql,
496
- result_preview=feedback[:200],
497
- step_reward=step_reward,
498
- )
499
- )
500
-
501
- self._cumulative_reward = min(
502
- 1.0, round(self._cumulative_reward + step_reward, 4)
503
- )
504
- self._last_feedback = feedback
505
- self._step_count += 1
506
- obs = self._build_observation(result_preview=feedback)
507
-
508
- return StepResponse(
509
- observation=obs,
510
- reward=SQLReward(
511
- score=self._cumulative_reward,
512
- step_reward=step_reward,
513
- feedback=feedback,
514
- ),
515
- done=False,
516
- info={
517
- "action_type": "fix",
518
- "severity_preview": severity if fix_sql else "N/A",
519
- },
520
- )
521
-
522
- def _handle_submit(self, action: SQLAction) -> StepResponse:
523
- """
524
- Handle a SUBMIT action — compute final composite score and end episode.
525
-
526
- Calls compute_episode_score() with the full trajectory.
527
- Sets done=True. This is the only action that ends the episode.
528
- """
529
- from .graders import compute_episode_score
530
-
531
- reward = compute_episode_score(
532
- trajectory=self._trajectory,
533
- ground_truth=self._ground_truth,
534
- original_conn=self._original_conn,
535
- original_snapshot=self._original_snapshot,
536
- submit_action=action,
537
- )
538
-
539
- self._done = True
540
- self._active = False
541
- self._cumulative_reward = min(
542
- 1.0, round(self._cumulative_reward + reward.score, 4)
543
- )
544
- self._last_feedback = reward.feedback
545
- obs = self._build_observation()
546
-
547
- # Close connection when episode ends
548
- if self._original_conn:
549
- self._original_conn.close()
550
- self._original_conn = None
551
-
552
- return StepResponse(
553
- observation=obs,
554
- reward=reward,
555
- done=True,
556
- info={
557
- "action_type": "submit",
558
- "final_score": reward.score,
559
- "dimensions": reward.dimensions,
560
- "trajectory_length": len(self._trajectory),
561
- },
562
- )
563
-
564
- def _build_observation(self, result_preview: str = "") -> SQLObservation:
565
- """Build current observation including trajectory history."""
566
- history = [
567
- {
568
- "step": s.step_number,
569
- "type": s.action_type.value,
570
- "sql": (s.sql_submitted or "")[:80],
571
- "result": s.result_preview[:80],
572
- "reward": s.step_reward,
573
- }
574
- for s in self._trajectory[-5:] # Last 5 steps only (context window)
575
- ]
576
- return SQLObservation(
577
- task_id=self._current_task_id,
578
- task_description=self._cached_observation.task_description,
579
- schema_info=self._cached_observation.schema_info,
580
- sample_rows=self._cached_observation.sample_rows,
581
- attempt_number=self._attempt_number,
582
- episode_id=self._episode_id,
583
- trajectory_history=history,
584
- investigation_hint=result_preview[:200] if result_preview else None,
585
- previous_feedback=(
586
- self._last_feedback if hasattr(self, "_last_feedback") else None
587
- ),
588
- max_attempts=self._cached_observation.max_attempts,
589
- )
590
-
591
- def state(self) -> SQLState:
592
- """Get current environment state.
593
-
594
- Returns:
595
- SQLState with current episode information
596
-
597
- Raises:
598
- ValueError: If no episode has been started
599
- """
600
- if self._episode_id is None:
601
- raise ValueError("No episode started. Call reset() first.")
602
-
603
- return SQLState(
604
- task_id=self._current_task_id,
605
- episode_id=self._episode_id,
606
- attempt_number=self._attempt_number,
607
- cumulative_reward=self._cumulative_reward,
608
- done=self._done,
609
- )