Spaces:
Sleeping
Sleeping
Upload 6 files
Browse files- server/app.py +17 -0
- server/environment.py +509 -0
- server/graders.py +843 -0
- server/main.py +140 -0
- server/models.py +191 -0
- server/tasks.py +690 -0
server/app.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Alias for server/main.py — required by openenv CLI structure.
|
| 3 |
+
The actual application is defined in server/main.py.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from server.main import app, run_server
|
| 7 |
+
|
| 8 |
+
__all__ = ["app"]
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def main():
|
| 12 |
+
"""Main function for openenv CLI compatibility."""
|
| 13 |
+
run_server()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
if __name__ == "__main__":
|
| 17 |
+
main()
|
server/environment.py
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 server.models import (
|
| 14 |
+
ActionType,
|
| 15 |
+
SQLAction,
|
| 16 |
+
SQLObservation,
|
| 17 |
+
SQLReward,
|
| 18 |
+
SQLState,
|
| 19 |
+
StepResponse,
|
| 20 |
+
TrajectoryStep,
|
| 21 |
+
)
|
| 22 |
+
from server.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 server.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 |
+
# Keep connection open for multi-step operations
|
| 131 |
+
# Connection will be closed when episode ends or environment is reset
|
| 132 |
+
|
| 133 |
+
except Exception as e:
|
| 134 |
+
if conn is not None:
|
| 135 |
+
conn.close()
|
| 136 |
+
raise e
|
| 137 |
+
|
| 138 |
+
return self._cached_observation
|
| 139 |
+
|
| 140 |
+
def step(self, action: SQLAction) -> StepResponse:
|
| 141 |
+
"""
|
| 142 |
+
Execute one agent action and return the resulting observation and reward.
|
| 143 |
+
|
| 144 |
+
Routes by action_type:
|
| 145 |
+
QUERY — execute SELECT on episode conn, return result in observation
|
| 146 |
+
PROFILE — compute column statistics, return in observation
|
| 147 |
+
FIX — execute on fresh_test_db(), score partially, continue episode
|
| 148 |
+
SUBMIT — call compute_episode_score(), end episode
|
| 149 |
+
"""
|
| 150 |
+
with self._lock:
|
| 151 |
+
if not self._active:
|
| 152 |
+
raise HTTPException(
|
| 153 |
+
status_code=422, detail="No active episode. Call reset() first."
|
| 154 |
+
)
|
| 155 |
+
if self._done:
|
| 156 |
+
raise HTTPException(
|
| 157 |
+
status_code=422,
|
| 158 |
+
detail="Episode is already done. Call reset() to start a new episode.",
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
# Check max steps guard
|
| 162 |
+
if self._step_count >= self._max_steps_per_episode:
|
| 163 |
+
raise HTTPException(
|
| 164 |
+
status_code=422,
|
| 165 |
+
detail="Max investigation steps reached. Call SUBMIT or reset().",
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
action = self._normalize_action(action)
|
| 169 |
+
|
| 170 |
+
action_type = action.action_type
|
| 171 |
+
|
| 172 |
+
# --- QUERY action ---
|
| 173 |
+
if action_type == ActionType.QUERY:
|
| 174 |
+
return self._handle_query(action)
|
| 175 |
+
|
| 176 |
+
# --- PROFILE action ---
|
| 177 |
+
elif action_type == ActionType.PROFILE:
|
| 178 |
+
return self._handle_profile(action)
|
| 179 |
+
|
| 180 |
+
# --- FIX action ---
|
| 181 |
+
elif action_type == ActionType.FIX:
|
| 182 |
+
return self._handle_fix(action)
|
| 183 |
+
|
| 184 |
+
# --- SUBMIT action (or legacy single-step) ---
|
| 185 |
+
else:
|
| 186 |
+
return self._handle_submit(action)
|
| 187 |
+
|
| 188 |
+
def _normalize_action(self, action: SQLAction) -> SQLAction:
|
| 189 |
+
"""Infer v2 action_type for backward-compatible payloads without action_type."""
|
| 190 |
+
if "action_type" in action.model_fields_set:
|
| 191 |
+
return action
|
| 192 |
+
|
| 193 |
+
inferred = ActionType.SUBMIT
|
| 194 |
+
if action.column_name:
|
| 195 |
+
inferred = ActionType.PROFILE
|
| 196 |
+
elif action.fix_query:
|
| 197 |
+
inferred = ActionType.FIX
|
| 198 |
+
elif action.sql:
|
| 199 |
+
inferred = (
|
| 200 |
+
ActionType.QUERY
|
| 201 |
+
if action.sql.strip().upper().startswith("SELECT")
|
| 202 |
+
else ActionType.FIX
|
| 203 |
+
)
|
| 204 |
+
elif action.answer or action.root_cause_table or action.offending_key:
|
| 205 |
+
inferred = ActionType.SUBMIT
|
| 206 |
+
|
| 207 |
+
return action.model_copy(update={"action_type": inferred})
|
| 208 |
+
|
| 209 |
+
def _handle_query(self, action: SQLAction) -> StepResponse:
|
| 210 |
+
"""
|
| 211 |
+
Handle a read-only QUERY action.
|
| 212 |
+
|
| 213 |
+
Executes the SQL on the episode connection (read-only SELECT enforced).
|
| 214 |
+
Returns a result preview in the observation. Does NOT end the episode.
|
| 215 |
+
Awards small step_reward (0.05) for queries that target the right area.
|
| 216 |
+
"""
|
| 217 |
+
sql = (action.sql or "").strip()
|
| 218 |
+
|
| 219 |
+
# Enforce read-only: only SELECT allowed
|
| 220 |
+
if not sql.upper().startswith("SELECT"):
|
| 221 |
+
step_reward = 0.0
|
| 222 |
+
result_preview = "Error: QUERY action only accepts SELECT statements."
|
| 223 |
+
else:
|
| 224 |
+
try:
|
| 225 |
+
cursor = self._original_conn.execute(sql)
|
| 226 |
+
rows = cursor.fetchmany(5)
|
| 227 |
+
cols = [d[0] for d in cursor.description]
|
| 228 |
+
result_preview = str([dict(zip(cols, row)) for row in rows])[:300]
|
| 229 |
+
# Small reward for targeted queries
|
| 230 |
+
from server.graders import _query_reveals_issue
|
| 231 |
+
|
| 232 |
+
step_reward = (
|
| 233 |
+
0.05 if _query_reveals_issue(sql, self._ground_truth) else 0.01
|
| 234 |
+
)
|
| 235 |
+
except sqlite3.Error as e:
|
| 236 |
+
result_preview = f"SQL Error: {e}"
|
| 237 |
+
step_reward = 0.0
|
| 238 |
+
|
| 239 |
+
# Add to trajectory
|
| 240 |
+
self._trajectory.append(
|
| 241 |
+
TrajectoryStep(
|
| 242 |
+
step_number=len(self._trajectory) + 1,
|
| 243 |
+
action_type=ActionType.QUERY,
|
| 244 |
+
sql_submitted=sql,
|
| 245 |
+
result_preview=result_preview[:200],
|
| 246 |
+
step_reward=step_reward,
|
| 247 |
+
)
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
self._cumulative_reward = min(
|
| 251 |
+
1.0, round(self._cumulative_reward + step_reward, 4)
|
| 252 |
+
)
|
| 253 |
+
self._last_feedback = result_preview[:200]
|
| 254 |
+
self._step_count += 1
|
| 255 |
+
|
| 256 |
+
# Build updated observation with trajectory history
|
| 257 |
+
obs = self._build_observation(result_preview=result_preview)
|
| 258 |
+
|
| 259 |
+
return StepResponse(
|
| 260 |
+
observation=obs,
|
| 261 |
+
reward=SQLReward(
|
| 262 |
+
score=self._cumulative_reward,
|
| 263 |
+
step_reward=step_reward,
|
| 264 |
+
feedback=result_preview[:200],
|
| 265 |
+
),
|
| 266 |
+
done=False,
|
| 267 |
+
info={"action_type": "query", "step": len(self._trajectory)},
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
def _handle_profile(self, action: SQLAction) -> StepResponse:
|
| 271 |
+
"""
|
| 272 |
+
Handle a PROFILE action — statistical summary of a named column.
|
| 273 |
+
|
| 274 |
+
Computes: null rate, distinct count, data type distribution, min/max.
|
| 275 |
+
Does NOT end the episode. Awards step_reward based on column relevance.
|
| 276 |
+
"""
|
| 277 |
+
col = (action.column_name or "").strip()
|
| 278 |
+
result_preview = ""
|
| 279 |
+
|
| 280 |
+
if not col:
|
| 281 |
+
result_preview = "Error: column_name required for PROFILE action."
|
| 282 |
+
else:
|
| 283 |
+
# Try to find which table has this column
|
| 284 |
+
try:
|
| 285 |
+
tables = self._original_conn.execute(
|
| 286 |
+
"SELECT name FROM sqlite_master WHERE type='table'"
|
| 287 |
+
).fetchall()
|
| 288 |
+
profile_results = []
|
| 289 |
+
for (tbl,) in tables:
|
| 290 |
+
try:
|
| 291 |
+
cols = [
|
| 292 |
+
row[1]
|
| 293 |
+
for row in self._original_conn.execute(
|
| 294 |
+
f"PRAGMA table_info({tbl})"
|
| 295 |
+
).fetchall()
|
| 296 |
+
]
|
| 297 |
+
if col in cols:
|
| 298 |
+
total = self._original_conn.execute(
|
| 299 |
+
f"SELECT COUNT(*) FROM {tbl}"
|
| 300 |
+
).fetchone()[0]
|
| 301 |
+
nulls = self._original_conn.execute(
|
| 302 |
+
f"SELECT COUNT(*) FROM {tbl} WHERE {col} IS NULL"
|
| 303 |
+
).fetchone()[0]
|
| 304 |
+
distinct = self._original_conn.execute(
|
| 305 |
+
f"SELECT COUNT(DISTINCT {col}) FROM {tbl}"
|
| 306 |
+
).fetchone()[0]
|
| 307 |
+
profile_results.append(
|
| 308 |
+
f"{tbl}.{col}: total={total}, nulls={nulls} ({100*nulls//total if total else 0}%), distinct={distinct}"
|
| 309 |
+
)
|
| 310 |
+
except sqlite3.Error:
|
| 311 |
+
pass
|
| 312 |
+
result_preview = (
|
| 313 |
+
" | ".join(profile_results)
|
| 314 |
+
if profile_results
|
| 315 |
+
else f"Column '{col}' not found."
|
| 316 |
+
)
|
| 317 |
+
except sqlite3.Error as e:
|
| 318 |
+
result_preview = f"Profile error: {e}"
|
| 319 |
+
|
| 320 |
+
# Determine step_reward based on relevance to ground truth
|
| 321 |
+
ground_table = self._ground_truth.get("table", "").lower()
|
| 322 |
+
ground_col = self._ground_truth.get(
|
| 323 |
+
"column", self._ground_truth.get("null_column", "")
|
| 324 |
+
).lower()
|
| 325 |
+
col_lower = col.lower()
|
| 326 |
+
|
| 327 |
+
if col_lower == ground_col:
|
| 328 |
+
step_reward = 0.08 # On-target: profiling exact relevant column
|
| 329 |
+
elif ground_table and ground_table in result_preview.lower():
|
| 330 |
+
step_reward = 0.05 # Near-target: profiled something from right table
|
| 331 |
+
elif col_lower in result_preview.lower() and len(col_lower) > 2:
|
| 332 |
+
step_reward = 0.01 # Off-target: profiled something real but irrelevant
|
| 333 |
+
else:
|
| 334 |
+
step_reward = 0.01 # Minimal: invalid column or junk probe
|
| 335 |
+
|
| 336 |
+
self._trajectory.append(
|
| 337 |
+
TrajectoryStep(
|
| 338 |
+
step_number=len(self._trajectory) + 1,
|
| 339 |
+
action_type=ActionType.PROFILE,
|
| 340 |
+
sql_submitted=f"PROFILE({col})",
|
| 341 |
+
result_preview=result_preview[:200],
|
| 342 |
+
step_reward=step_reward,
|
| 343 |
+
)
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
self._cumulative_reward = min(
|
| 347 |
+
1.0, round(self._cumulative_reward + step_reward, 4)
|
| 348 |
+
)
|
| 349 |
+
self._last_feedback = result_preview[:200]
|
| 350 |
+
self._step_count += 1
|
| 351 |
+
obs = self._build_observation(result_preview=result_preview)
|
| 352 |
+
|
| 353 |
+
return StepResponse(
|
| 354 |
+
observation=obs,
|
| 355 |
+
reward=SQLReward(
|
| 356 |
+
score=self._cumulative_reward,
|
| 357 |
+
step_reward=step_reward,
|
| 358 |
+
feedback=result_preview[:200],
|
| 359 |
+
),
|
| 360 |
+
done=False,
|
| 361 |
+
info={"action_type": "profile", "column_profiled": col},
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
def _handle_fix(self, action: SQLAction) -> StepResponse:
|
| 365 |
+
"""
|
| 366 |
+
Handle a FIX action — execute write query on isolated copy for preview scoring.
|
| 367 |
+
|
| 368 |
+
Executes on a _fresh_test_db() copy. Does NOT mutate episode state.
|
| 369 |
+
Returns intermediate feedback on whether fix appears to work.
|
| 370 |
+
Does NOT end episode — agent can follow up with verification query or SUBMIT.
|
| 371 |
+
Awards intermediate reward based on quick fix assessment.
|
| 372 |
+
"""
|
| 373 |
+
fix_sql = action.effective_sql or ""
|
| 374 |
+
step_reward = 0.0
|
| 375 |
+
feedback = ""
|
| 376 |
+
|
| 377 |
+
if fix_sql:
|
| 378 |
+
from server.graders import grade_fix_correctness
|
| 379 |
+
|
| 380 |
+
quick_score, severity = grade_fix_correctness(
|
| 381 |
+
fix_sql,
|
| 382 |
+
self._original_conn,
|
| 383 |
+
self._original_snapshot,
|
| 384 |
+
self._ground_truth,
|
| 385 |
+
)
|
| 386 |
+
step_reward = quick_score * 0.3 # Partial credit for fix attempt
|
| 387 |
+
feedback = f"Fix preview: {severity} ({quick_score:.2f}). Submit to finalize score."
|
| 388 |
+
else:
|
| 389 |
+
feedback = "No SQL provided in fix action."
|
| 390 |
+
|
| 391 |
+
self._trajectory.append(
|
| 392 |
+
TrajectoryStep(
|
| 393 |
+
step_number=len(self._trajectory) + 1,
|
| 394 |
+
action_type=ActionType.FIX,
|
| 395 |
+
sql_submitted=fix_sql,
|
| 396 |
+
result_preview=feedback[:200],
|
| 397 |
+
step_reward=step_reward,
|
| 398 |
+
)
|
| 399 |
+
)
|
| 400 |
+
|
| 401 |
+
self._cumulative_reward = min(
|
| 402 |
+
1.0, round(self._cumulative_reward + step_reward, 4)
|
| 403 |
+
)
|
| 404 |
+
self._last_feedback = feedback
|
| 405 |
+
self._step_count += 1
|
| 406 |
+
obs = self._build_observation(result_preview=feedback)
|
| 407 |
+
|
| 408 |
+
return StepResponse(
|
| 409 |
+
observation=obs,
|
| 410 |
+
reward=SQLReward(
|
| 411 |
+
score=self._cumulative_reward,
|
| 412 |
+
step_reward=step_reward,
|
| 413 |
+
feedback=feedback,
|
| 414 |
+
),
|
| 415 |
+
done=False,
|
| 416 |
+
info={
|
| 417 |
+
"action_type": "fix",
|
| 418 |
+
"severity_preview": severity if fix_sql else "N/A",
|
| 419 |
+
},
|
| 420 |
+
)
|
| 421 |
+
|
| 422 |
+
def _handle_submit(self, action: SQLAction) -> StepResponse:
|
| 423 |
+
"""
|
| 424 |
+
Handle a SUBMIT action — compute final composite score and end episode.
|
| 425 |
+
|
| 426 |
+
Calls compute_episode_score() with the full trajectory.
|
| 427 |
+
Sets done=True. This is the only action that ends the episode.
|
| 428 |
+
"""
|
| 429 |
+
from server.graders import compute_episode_score
|
| 430 |
+
|
| 431 |
+
reward = compute_episode_score(
|
| 432 |
+
trajectory=self._trajectory,
|
| 433 |
+
ground_truth=self._ground_truth,
|
| 434 |
+
original_conn=self._original_conn,
|
| 435 |
+
original_snapshot=self._original_snapshot,
|
| 436 |
+
submit_action=action,
|
| 437 |
+
)
|
| 438 |
+
|
| 439 |
+
self._done = True
|
| 440 |
+
self._active = False
|
| 441 |
+
self._cumulative_reward = min(
|
| 442 |
+
1.0, round(self._cumulative_reward + reward.score, 4)
|
| 443 |
+
)
|
| 444 |
+
self._last_feedback = reward.feedback
|
| 445 |
+
obs = self._build_observation()
|
| 446 |
+
|
| 447 |
+
# Close connection when episode ends
|
| 448 |
+
if self._original_conn:
|
| 449 |
+
self._original_conn.close()
|
| 450 |
+
self._original_conn = None
|
| 451 |
+
|
| 452 |
+
return StepResponse(
|
| 453 |
+
observation=obs,
|
| 454 |
+
reward=reward,
|
| 455 |
+
done=True,
|
| 456 |
+
info={
|
| 457 |
+
"action_type": "submit",
|
| 458 |
+
"final_score": reward.score,
|
| 459 |
+
"dimensions": reward.dimensions,
|
| 460 |
+
"trajectory_length": len(self._trajectory),
|
| 461 |
+
},
|
| 462 |
+
)
|
| 463 |
+
|
| 464 |
+
def _build_observation(self, result_preview: str = "") -> SQLObservation:
|
| 465 |
+
"""Build current observation including trajectory history."""
|
| 466 |
+
history = [
|
| 467 |
+
{
|
| 468 |
+
"step": s.step_number,
|
| 469 |
+
"type": s.action_type.value,
|
| 470 |
+
"sql": (s.sql_submitted or "")[:80],
|
| 471 |
+
"result": s.result_preview[:80],
|
| 472 |
+
"reward": s.step_reward,
|
| 473 |
+
}
|
| 474 |
+
for s in self._trajectory[-5:] # Last 5 steps only (context window)
|
| 475 |
+
]
|
| 476 |
+
return SQLObservation(
|
| 477 |
+
task_id=self._current_task_id,
|
| 478 |
+
task_description=self._cached_observation.task_description,
|
| 479 |
+
schema_info=self._cached_observation.schema_info,
|
| 480 |
+
sample_rows=self._cached_observation.sample_rows,
|
| 481 |
+
attempt_number=self._attempt_number,
|
| 482 |
+
episode_id=self._episode_id,
|
| 483 |
+
trajectory_history=history,
|
| 484 |
+
investigation_hint=result_preview[:200] if result_preview else None,
|
| 485 |
+
previous_feedback=(
|
| 486 |
+
self._last_feedback if hasattr(self, "_last_feedback") else None
|
| 487 |
+
),
|
| 488 |
+
max_attempts=self._cached_observation.max_attempts,
|
| 489 |
+
)
|
| 490 |
+
|
| 491 |
+
def state(self) -> SQLState:
|
| 492 |
+
"""Get current environment state.
|
| 493 |
+
|
| 494 |
+
Returns:
|
| 495 |
+
SQLState with current episode information
|
| 496 |
+
|
| 497 |
+
Raises:
|
| 498 |
+
ValueError: If no episode has been started
|
| 499 |
+
"""
|
| 500 |
+
if self._episode_id is None:
|
| 501 |
+
raise ValueError("No episode started. Call reset() first.")
|
| 502 |
+
|
| 503 |
+
return SQLState(
|
| 504 |
+
task_id=self._current_task_id,
|
| 505 |
+
episode_id=self._episode_id,
|
| 506 |
+
attempt_number=self._attempt_number,
|
| 507 |
+
cumulative_reward=self._cumulative_reward,
|
| 508 |
+
done=self._done,
|
| 509 |
+
)
|
server/graders.py
ADDED
|
@@ -0,0 +1,843 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Grading functions for SQL Quality Environment."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from difflib import SequenceMatcher
|
| 6 |
+
import sqlite3
|
| 7 |
+
from typing import Any, Dict
|
| 8 |
+
|
| 9 |
+
from server.models import ActionType, SQLAction, SQLReward, TrajectoryStep
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _fresh_test_db(original_conn: sqlite3.Connection) -> sqlite3.Connection:
|
| 13 |
+
"""Create an isolated in-memory copy of a sqlite database."""
|
| 14 |
+
copy = sqlite3.connect(":memory:")
|
| 15 |
+
for line in original_conn.iterdump():
|
| 16 |
+
copy.execute(line)
|
| 17 |
+
copy.commit()
|
| 18 |
+
return copy
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# Backward-compatible alias
|
| 22 |
+
_make_fresh_copy = _fresh_test_db
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _query_reveals_issue(sql: str, ground_truth: dict) -> bool:
|
| 26 |
+
"""Return True when query references both ground-truth table and column."""
|
| 27 |
+
sql_lower = (sql or "").lower()
|
| 28 |
+
table = ground_truth.get("table", ground_truth.get("target_table", "")).lower()
|
| 29 |
+
col = ground_truth.get(
|
| 30 |
+
"column", ground_truth.get("null_column", ground_truth.get("target_column", ""))
|
| 31 |
+
).lower()
|
| 32 |
+
return bool(table and col and table in sql_lower and col in sql_lower)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _measure_data_preservation(
|
| 36 |
+
test_conn: sqlite3.Connection,
|
| 37 |
+
original_snapshot: dict,
|
| 38 |
+
table_name: str,
|
| 39 |
+
key_col: str = "id",
|
| 40 |
+
) -> float:
|
| 41 |
+
"""Measure row/value preservation ratio after a fix."""
|
| 42 |
+
if not original_snapshot:
|
| 43 |
+
return 1.0
|
| 44 |
+
|
| 45 |
+
try:
|
| 46 |
+
cursor = test_conn.execute(f"SELECT * FROM {table_name}")
|
| 47 |
+
cols = [d[0] for d in cursor.description]
|
| 48 |
+
if key_col not in cols:
|
| 49 |
+
return 0.0
|
| 50 |
+
key_idx = cols.index(key_col)
|
| 51 |
+
post_fix = {row[key_idx]: dict(zip(cols, row)) for row in cursor.fetchall()}
|
| 52 |
+
except sqlite3.Error:
|
| 53 |
+
return 0.0
|
| 54 |
+
|
| 55 |
+
if not post_fix:
|
| 56 |
+
return 0.0
|
| 57 |
+
|
| 58 |
+
original_ids = set(original_snapshot.keys())
|
| 59 |
+
post_ids = set(post_fix.keys())
|
| 60 |
+
row_score = len(original_ids & post_ids) / max(1, len(original_ids))
|
| 61 |
+
|
| 62 |
+
matched_rows = original_ids & post_ids
|
| 63 |
+
if not matched_rows:
|
| 64 |
+
return round(row_score * 0.5, 4)
|
| 65 |
+
|
| 66 |
+
value_matches = 0
|
| 67 |
+
value_total = 0
|
| 68 |
+
for rid in matched_rows:
|
| 69 |
+
orig_row = original_snapshot[rid]
|
| 70 |
+
post_row = post_fix[rid]
|
| 71 |
+
for col in orig_row:
|
| 72 |
+
value_total += 1
|
| 73 |
+
try:
|
| 74 |
+
if abs(float(str(orig_row[col])) - float(str(post_row.get(col, 0)))) < 0.001:
|
| 75 |
+
value_matches += 1
|
| 76 |
+
except (ValueError, TypeError):
|
| 77 |
+
if str(orig_row[col]) == str(post_row.get(col, "")):
|
| 78 |
+
value_matches += 1
|
| 79 |
+
value_score = value_matches / max(1, value_total)
|
| 80 |
+
return round(row_score * 0.5 + value_score * 0.5, 4)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _snapshot_table(
|
| 84 |
+
conn: sqlite3.Connection,
|
| 85 |
+
table_name: str,
|
| 86 |
+
key_col: str = "id",
|
| 87 |
+
limit: int = 200,
|
| 88 |
+
) -> dict:
|
| 89 |
+
"""Capture table contents before a fix."""
|
| 90 |
+
try:
|
| 91 |
+
cursor = conn.execute(f"SELECT * FROM {table_name} LIMIT {limit}")
|
| 92 |
+
cols = [d[0] for d in cursor.description]
|
| 93 |
+
if key_col not in cols:
|
| 94 |
+
return {}
|
| 95 |
+
key_idx = cols.index(key_col)
|
| 96 |
+
return {row[key_idx]: dict(zip(cols, row)) for row in cursor.fetchall()}
|
| 97 |
+
except sqlite3.Error:
|
| 98 |
+
return {}
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _snapshot_for_preservation(original_snapshot: dict, ground_truth: dict) -> dict:
|
| 102 |
+
"""Filter snapshots to preservation-critical columns when configured."""
|
| 103 |
+
preserve_columns = ground_truth.get("preserve_columns")
|
| 104 |
+
if not preserve_columns:
|
| 105 |
+
return original_snapshot
|
| 106 |
+
filtered: dict = {}
|
| 107 |
+
for row_id, row in original_snapshot.items():
|
| 108 |
+
filtered[row_id] = {col: row[col] for col in preserve_columns if col in row}
|
| 109 |
+
return filtered
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _null_audit_score(answer: str, ground_truth: dict) -> tuple[float, str, dict]:
|
| 113 |
+
"""Schema-aware null_audit scoring with lexical near-miss partial credit."""
|
| 114 |
+
clean = (answer or "").strip()
|
| 115 |
+
if not clean:
|
| 116 |
+
return 0.0, "CATASTROPHIC", {
|
| 117 |
+
"answer_correct": 0.0,
|
| 118 |
+
"answer_in_schema": 0.0,
|
| 119 |
+
"lexical_near_miss": 0.0,
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
clean_lower = clean.lower()
|
| 123 |
+
correct = ground_truth.get("null_column", "").strip().lower()
|
| 124 |
+
all_cols = [c.lower() for c in ground_truth.get("all_columns", [])]
|
| 125 |
+
|
| 126 |
+
if clean_lower == correct:
|
| 127 |
+
return 1.0, "PERFECT", {
|
| 128 |
+
"answer_correct": 1.0,
|
| 129 |
+
"answer_in_schema": 0.0,
|
| 130 |
+
"lexical_near_miss": 0.0,
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
if clean_lower in all_cols:
|
| 134 |
+
return 0.2, "ACCEPTABLE", {
|
| 135 |
+
"answer_correct": 0.0,
|
| 136 |
+
"answer_in_schema": 0.2,
|
| 137 |
+
"lexical_near_miss": 0.0,
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
# Bounded lexical partial for out-of-schema near misses only.
|
| 141 |
+
best_similarity = 0.0
|
| 142 |
+
for col in all_cols:
|
| 143 |
+
best_similarity = max(best_similarity, SequenceMatcher(None, clean_lower, col).ratio())
|
| 144 |
+
if best_similarity >= 0.85:
|
| 145 |
+
lexical = 0.15
|
| 146 |
+
elif best_similarity >= 0.65:
|
| 147 |
+
lexical = 0.1
|
| 148 |
+
elif best_similarity >= 0.45:
|
| 149 |
+
lexical = 0.05
|
| 150 |
+
else:
|
| 151 |
+
lexical = 0.0
|
| 152 |
+
|
| 153 |
+
return lexical, ("DANGEROUS" if lexical > 0 else "CATASTROPHIC"), {
|
| 154 |
+
"answer_correct": 0.0,
|
| 155 |
+
"answer_in_schema": 0.0,
|
| 156 |
+
"lexical_near_miss": lexical,
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _evaluate_fix_outcome(
|
| 161 |
+
test_conn: sqlite3.Connection,
|
| 162 |
+
ground_truth: dict,
|
| 163 |
+
original_snapshot: dict | None = None,
|
| 164 |
+
) -> tuple[bool, float, bool]:
|
| 165 |
+
"""Evaluate task-specific resolution + preservation + schema integrity."""
|
| 166 |
+
task_type = ground_truth.get("task_type", "")
|
| 167 |
+
target_table = ground_truth.get(
|
| 168 |
+
"table",
|
| 169 |
+
ground_truth.get("target_table", ground_truth.get("root_cause_table", "transactions")),
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
try:
|
| 173 |
+
tables = {
|
| 174 |
+
row[0]
|
| 175 |
+
for row in test_conn.execute(
|
| 176 |
+
"SELECT name FROM sqlite_master WHERE type='table'"
|
| 177 |
+
).fetchall()
|
| 178 |
+
}
|
| 179 |
+
except sqlite3.Error:
|
| 180 |
+
return False, 0.0, False
|
| 181 |
+
|
| 182 |
+
if target_table not in tables:
|
| 183 |
+
return False, 0.0, False
|
| 184 |
+
|
| 185 |
+
if task_type == "type_repair":
|
| 186 |
+
target_col = ground_truth.get("column", ground_truth.get("target_column", "amount"))
|
| 187 |
+
try:
|
| 188 |
+
cols = {
|
| 189 |
+
row[1]: row[2].upper()
|
| 190 |
+
for row in test_conn.execute(f"PRAGMA table_info({target_table})")
|
| 191 |
+
}
|
| 192 |
+
except sqlite3.Error:
|
| 193 |
+
return False, 0.0, True
|
| 194 |
+
|
| 195 |
+
issue_resolved = cols.get(target_col, "") in (
|
| 196 |
+
"REAL",
|
| 197 |
+
"NUMERIC",
|
| 198 |
+
"FLOAT",
|
| 199 |
+
"DOUBLE",
|
| 200 |
+
"INTEGER",
|
| 201 |
+
)
|
| 202 |
+
preserve_source = _snapshot_for_preservation(original_snapshot or {}, ground_truth)
|
| 203 |
+
preservation = _measure_data_preservation(test_conn, preserve_source, target_table)
|
| 204 |
+
return issue_resolved, preservation, True
|
| 205 |
+
|
| 206 |
+
if task_type == "dedup_bug":
|
| 207 |
+
key_col = ground_truth.get("column", ground_truth.get("offending_key", "order_id"))
|
| 208 |
+
expected_clean = ground_truth.get("expected_clean_count", 0)
|
| 209 |
+
try:
|
| 210 |
+
remaining_dupes = test_conn.execute(
|
| 211 |
+
f"SELECT COUNT(*) FROM (SELECT {key_col}, COUNT(*) c "
|
| 212 |
+
f"FROM {target_table} GROUP BY {key_col} HAVING c > 1)"
|
| 213 |
+
).fetchone()[0]
|
| 214 |
+
final_count = test_conn.execute(
|
| 215 |
+
f"SELECT COUNT(*) FROM {target_table}"
|
| 216 |
+
).fetchone()[0]
|
| 217 |
+
orphan_count = test_conn.execute(
|
| 218 |
+
"SELECT COUNT(*) FROM order_items oi "
|
| 219 |
+
"LEFT JOIN orders o ON oi.order_id = o.id "
|
| 220 |
+
"WHERE o.id IS NULL"
|
| 221 |
+
).fetchone()[0]
|
| 222 |
+
except sqlite3.Error:
|
| 223 |
+
return False, 0.0, True
|
| 224 |
+
|
| 225 |
+
issue_resolved = remaining_dupes == 0
|
| 226 |
+
count_ok = abs(final_count - expected_clean) <= max(1, int(expected_clean * 0.02))
|
| 227 |
+
no_orphans = orphan_count == 0
|
| 228 |
+
if issue_resolved and count_ok and no_orphans:
|
| 229 |
+
preservation = 1.0
|
| 230 |
+
elif issue_resolved and count_ok:
|
| 231 |
+
preservation = 0.95
|
| 232 |
+
elif issue_resolved:
|
| 233 |
+
preservation = 0.85
|
| 234 |
+
elif final_count > 0:
|
| 235 |
+
preservation = 0.5
|
| 236 |
+
else:
|
| 237 |
+
preservation = 0.0
|
| 238 |
+
return issue_resolved, preservation, True
|
| 239 |
+
|
| 240 |
+
if task_type == "fk_integrity":
|
| 241 |
+
parent_table = ground_truth.get("parent_table", "customers")
|
| 242 |
+
parent_key = ground_truth.get("parent_key", "id")
|
| 243 |
+
fk_col = ground_truth.get("column", "customer_id")
|
| 244 |
+
valid_ids = set(ground_truth.get("valid_invoice_ids", []))
|
| 245 |
+
expected_clean = ground_truth.get("expected_clean_count", len(valid_ids))
|
| 246 |
+
|
| 247 |
+
try:
|
| 248 |
+
orphan_count = test_conn.execute(
|
| 249 |
+
f"SELECT COUNT(*) FROM {target_table} c "
|
| 250 |
+
f"LEFT JOIN {parent_table} p ON c.{fk_col} = p.{parent_key} "
|
| 251 |
+
f"WHERE p.{parent_key} IS NULL"
|
| 252 |
+
).fetchone()[0]
|
| 253 |
+
survivors = {
|
| 254 |
+
row[0]
|
| 255 |
+
for row in test_conn.execute(f"SELECT id FROM {target_table}").fetchall()
|
| 256 |
+
}
|
| 257 |
+
except sqlite3.Error:
|
| 258 |
+
return False, 0.0, True
|
| 259 |
+
|
| 260 |
+
issue_resolved = orphan_count == 0
|
| 261 |
+
if not survivors:
|
| 262 |
+
return issue_resolved, 0.0, True
|
| 263 |
+
keep_ratio = len(survivors & valid_ids) / max(1, len(valid_ids))
|
| 264 |
+
unexpected_ratio = len(survivors - valid_ids) / max(1, len(survivors))
|
| 265 |
+
count_ok = abs(len(survivors) - expected_clean) <= max(1, int(expected_clean * 0.02))
|
| 266 |
+
if issue_resolved and count_ok and keep_ratio >= 0.995 and unexpected_ratio == 0:
|
| 267 |
+
preservation = 1.0
|
| 268 |
+
elif issue_resolved and keep_ratio >= 0.98:
|
| 269 |
+
preservation = 0.9
|
| 270 |
+
elif keep_ratio >= 0.7:
|
| 271 |
+
preservation = 0.6
|
| 272 |
+
else:
|
| 273 |
+
preservation = 0.2
|
| 274 |
+
return issue_resolved, preservation, True
|
| 275 |
+
|
| 276 |
+
if task_type == "schema_drift":
|
| 277 |
+
expected_columns = ground_truth.get(
|
| 278 |
+
"expected_columns",
|
| 279 |
+
["event_id", "user_id", "event_type", "amount", "event_time"],
|
| 280 |
+
)
|
| 281 |
+
expected_rows = ground_truth.get("expected_row_count", 0)
|
| 282 |
+
canonical_table = ground_truth.get("table", "canonical_events")
|
| 283 |
+
if canonical_table not in tables:
|
| 284 |
+
return False, 0.0, False
|
| 285 |
+
|
| 286 |
+
try:
|
| 287 |
+
cols = {
|
| 288 |
+
row[1]: row[2].upper()
|
| 289 |
+
for row in test_conn.execute(f"PRAGMA table_info({canonical_table})")
|
| 290 |
+
}
|
| 291 |
+
row_count = test_conn.execute(
|
| 292 |
+
f"SELECT COUNT(*) FROM {canonical_table}"
|
| 293 |
+
).fetchone()[0]
|
| 294 |
+
distinct_ids = test_conn.execute(
|
| 295 |
+
f"SELECT COUNT(DISTINCT event_id) FROM {canonical_table}"
|
| 296 |
+
).fetchone()[0]
|
| 297 |
+
null_critical = test_conn.execute(
|
| 298 |
+
f"SELECT COUNT(*) FROM {canonical_table} "
|
| 299 |
+
"WHERE event_id IS NULL OR user_id IS NULL OR event_type IS NULL OR event_time IS NULL"
|
| 300 |
+
).fetchone()[0]
|
| 301 |
+
numeric_amount = test_conn.execute(
|
| 302 |
+
f"SELECT COUNT(*) FROM {canonical_table} WHERE typeof(amount) IN ('real','integer')"
|
| 303 |
+
).fetchone()[0]
|
| 304 |
+
except sqlite3.Error:
|
| 305 |
+
return False, 0.0, True
|
| 306 |
+
|
| 307 |
+
columns_ok = all(col in cols for col in expected_columns)
|
| 308 |
+
row_ok = row_count == expected_rows
|
| 309 |
+
ids_ok = distinct_ids == row_count
|
| 310 |
+
critical_ok = null_critical == 0
|
| 311 |
+
numeric_ratio = numeric_amount / max(1, row_count)
|
| 312 |
+
issue_resolved = columns_ok and row_ok and ids_ok and critical_ok and numeric_ratio >= 0.99
|
| 313 |
+
|
| 314 |
+
preservation = 0.0
|
| 315 |
+
if columns_ok:
|
| 316 |
+
preservation += 0.3
|
| 317 |
+
if row_ok:
|
| 318 |
+
preservation += 0.3
|
| 319 |
+
if ids_ok and critical_ok:
|
| 320 |
+
preservation += 0.25
|
| 321 |
+
preservation += min(0.15, numeric_ratio * 0.15)
|
| 322 |
+
return issue_resolved, round(min(1.0, preservation), 4), True
|
| 323 |
+
|
| 324 |
+
# Fallback
|
| 325 |
+
preserve_source = _snapshot_for_preservation(original_snapshot or {}, ground_truth)
|
| 326 |
+
preservation = _measure_data_preservation(test_conn, preserve_source, target_table)
|
| 327 |
+
return preservation >= 0.99, preservation, True
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def grade_diagnostic_efficiency(
|
| 331 |
+
trajectory: list[TrajectoryStep],
|
| 332 |
+
ground_truth: dict,
|
| 333 |
+
) -> float:
|
| 334 |
+
"""D1: reward faster diagnosis of the correct table/column."""
|
| 335 |
+
query_steps = [
|
| 336 |
+
s for s in trajectory if s.action_type in (ActionType.QUERY, ActionType.PROFILE)
|
| 337 |
+
]
|
| 338 |
+
for i, step in enumerate(query_steps):
|
| 339 |
+
if _query_reveals_issue(step.sql_submitted or "", ground_truth):
|
| 340 |
+
step_number = i + 1
|
| 341 |
+
if step_number <= 2:
|
| 342 |
+
return 1.0
|
| 343 |
+
if step_number <= 4:
|
| 344 |
+
return 0.7
|
| 345 |
+
if step_number <= 6:
|
| 346 |
+
return 0.4
|
| 347 |
+
return 0.2
|
| 348 |
+
return 0.0
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def grade_hypothesis_quality(
|
| 352 |
+
trajectory: list[TrajectoryStep],
|
| 353 |
+
ground_truth: dict,
|
| 354 |
+
) -> float:
|
| 355 |
+
"""D2: reward systematic investigation sequence."""
|
| 356 |
+
query_steps = [
|
| 357 |
+
s for s in trajectory if s.action_type in (ActionType.QUERY, ActionType.PROFILE)
|
| 358 |
+
]
|
| 359 |
+
if not query_steps:
|
| 360 |
+
return 0.0
|
| 361 |
+
|
| 362 |
+
table = ground_truth.get("table", ground_truth.get("target_table", "")).lower()
|
| 363 |
+
col = ground_truth.get(
|
| 364 |
+
"column", ground_truth.get("null_column", ground_truth.get("target_column", ""))
|
| 365 |
+
).lower()
|
| 366 |
+
sqls = [s.sql_submitted or "" for s in query_steps]
|
| 367 |
+
|
| 368 |
+
first_table = next((i for i, q in enumerate(sqls) if table and table in q.lower()), None)
|
| 369 |
+
first_col = next((i for i, q in enumerate(sqls) if col and col in q.lower()), None)
|
| 370 |
+
if first_table is not None and first_col is not None and first_table < first_col:
|
| 371 |
+
return 1.0
|
| 372 |
+
if first_col is not None:
|
| 373 |
+
return 0.6
|
| 374 |
+
if first_table is not None:
|
| 375 |
+
return 0.3
|
| 376 |
+
return 0.0
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
def grade_fix_correctness(
|
| 380 |
+
fix_sql: str,
|
| 381 |
+
original_conn: sqlite3.Connection,
|
| 382 |
+
original_snapshot: dict,
|
| 383 |
+
ground_truth: dict,
|
| 384 |
+
) -> tuple[float, str]:
|
| 385 |
+
"""D3: task-aware fix correctness with catastrophic override tiers."""
|
| 386 |
+
task_type = ground_truth.get("task_type", "")
|
| 387 |
+
if task_type == "null_audit":
|
| 388 |
+
return 1.0, "PERFECT"
|
| 389 |
+
if not fix_sql or fix_sql.strip().upper() in ("SELECT 1", ""):
|
| 390 |
+
return 0.0, "CATASTROPHIC"
|
| 391 |
+
|
| 392 |
+
test_conn = None
|
| 393 |
+
try:
|
| 394 |
+
test_conn = _fresh_test_db(original_conn)
|
| 395 |
+
try:
|
| 396 |
+
test_conn.executescript(fix_sql)
|
| 397 |
+
test_conn.commit()
|
| 398 |
+
except sqlite3.Error:
|
| 399 |
+
return 0.0, "CATASTROPHIC"
|
| 400 |
+
|
| 401 |
+
issue_resolved, preservation, schema_intact = _evaluate_fix_outcome(
|
| 402 |
+
test_conn, ground_truth, original_snapshot
|
| 403 |
+
)
|
| 404 |
+
if not schema_intact or preservation < 0.5:
|
| 405 |
+
return 0.0, "CATASTROPHIC"
|
| 406 |
+
if issue_resolved and preservation >= 0.995:
|
| 407 |
+
return 1.0, "PERFECT"
|
| 408 |
+
if issue_resolved and preservation >= 0.95:
|
| 409 |
+
return 0.8, "ACCEPTABLE"
|
| 410 |
+
if issue_resolved and preservation >= 0.8:
|
| 411 |
+
return 0.5, "DEGRADED"
|
| 412 |
+
return 0.2, "DANGEROUS"
|
| 413 |
+
finally:
|
| 414 |
+
if test_conn:
|
| 415 |
+
test_conn.close()
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
def grade_self_verification(
|
| 419 |
+
trajectory: list[TrajectoryStep],
|
| 420 |
+
fix_index: int,
|
| 421 |
+
ground_truth: dict,
|
| 422 |
+
) -> float:
|
| 423 |
+
"""D4: bonus for post-fix verification query."""
|
| 424 |
+
post_fix = trajectory[fix_index + 1 :] if fix_index < len(trajectory) - 1 else []
|
| 425 |
+
post_fix_queries = [s for s in post_fix if s.action_type == ActionType.QUERY]
|
| 426 |
+
if not post_fix_queries:
|
| 427 |
+
return 0.0
|
| 428 |
+
|
| 429 |
+
table = ground_truth.get("table", "").lower()
|
| 430 |
+
col = ground_truth.get("column", ground_truth.get("null_column", "")).lower()
|
| 431 |
+
for step in post_fix_queries:
|
| 432 |
+
sql = (step.sql_submitted or "").lower()
|
| 433 |
+
if table in sql and col in sql:
|
| 434 |
+
return 0.15
|
| 435 |
+
return 0.0
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
def grade_generalization(
|
| 439 |
+
fix_sql: str,
|
| 440 |
+
original_conn: sqlite3.Connection,
|
| 441 |
+
ground_truth: dict,
|
| 442 |
+
) -> float:
|
| 443 |
+
"""D5: test whether the fix transfers to schema/data variants."""
|
| 444 |
+
task_type = ground_truth.get("task_type", "")
|
| 445 |
+
if task_type == "null_audit" or not fix_sql:
|
| 446 |
+
return 1.0
|
| 447 |
+
|
| 448 |
+
def _run_variant(
|
| 449 |
+
variant_conn: sqlite3.Connection,
|
| 450 |
+
variant_gt: dict | None = None,
|
| 451 |
+
) -> bool:
|
| 452 |
+
gt = variant_gt or ground_truth
|
| 453 |
+
try:
|
| 454 |
+
variant_conn.executescript(fix_sql)
|
| 455 |
+
variant_conn.commit()
|
| 456 |
+
resolved, preservation, schema_intact = _evaluate_fix_outcome(
|
| 457 |
+
variant_conn,
|
| 458 |
+
gt,
|
| 459 |
+
{},
|
| 460 |
+
)
|
| 461 |
+
return bool(resolved and schema_intact and preservation >= 0.8)
|
| 462 |
+
except sqlite3.Error:
|
| 463 |
+
return False
|
| 464 |
+
finally:
|
| 465 |
+
variant_conn.close()
|
| 466 |
+
|
| 467 |
+
passes = 0
|
| 468 |
+
|
| 469 |
+
# Variant 1: base copy
|
| 470 |
+
try:
|
| 471 |
+
if _run_variant(_fresh_test_db(original_conn)):
|
| 472 |
+
passes += 1
|
| 473 |
+
except Exception:
|
| 474 |
+
pass
|
| 475 |
+
|
| 476 |
+
# Variant 2: schema transfer
|
| 477 |
+
try:
|
| 478 |
+
v2 = _fresh_test_db(original_conn)
|
| 479 |
+
if task_type == "type_repair":
|
| 480 |
+
v2.execute("ALTER TABLE transactions ADD COLUMN source_system TEXT DEFAULT 'legacy'")
|
| 481 |
+
v2.commit()
|
| 482 |
+
if _run_variant(v2):
|
| 483 |
+
passes += 1
|
| 484 |
+
elif task_type == "dedup_bug":
|
| 485 |
+
v2.execute("ALTER TABLE order_items ADD COLUMN ref_id INTEGER")
|
| 486 |
+
v2.execute("UPDATE order_items SET ref_id = order_id")
|
| 487 |
+
v2.commit()
|
| 488 |
+
if _run_variant(v2):
|
| 489 |
+
passes += 1
|
| 490 |
+
elif task_type == "fk_integrity":
|
| 491 |
+
v2.execute("ALTER TABLE invoices ADD COLUMN ingest_batch TEXT DEFAULT 'A'")
|
| 492 |
+
v2.commit()
|
| 493 |
+
if _run_variant(v2):
|
| 494 |
+
passes += 1
|
| 495 |
+
elif task_type == "schema_drift":
|
| 496 |
+
v2.execute("ALTER TABLE events_v2 ADD COLUMN metadata_json TEXT DEFAULT '{}'")
|
| 497 |
+
v2.commit()
|
| 498 |
+
if _run_variant(v2):
|
| 499 |
+
passes += 1
|
| 500 |
+
else:
|
| 501 |
+
v2.close()
|
| 502 |
+
except Exception:
|
| 503 |
+
pass
|
| 504 |
+
|
| 505 |
+
# Variant 3: harder data distribution
|
| 506 |
+
try:
|
| 507 |
+
v3 = _fresh_test_db(original_conn)
|
| 508 |
+
variant_gt = dict(ground_truth)
|
| 509 |
+
if task_type == "type_repair":
|
| 510 |
+
v3.execute(
|
| 511 |
+
"INSERT INTO transactions (id, user_id, amount, description, category, date, transaction_date) "
|
| 512 |
+
"SELECT id+10000, user_id, amount, description, category, date, transaction_date FROM transactions"
|
| 513 |
+
)
|
| 514 |
+
v3.commit()
|
| 515 |
+
if _run_variant(v3):
|
| 516 |
+
passes += 1
|
| 517 |
+
elif task_type == "dedup_bug":
|
| 518 |
+
v3.execute(
|
| 519 |
+
"INSERT INTO order_items (id, order_id, product_name, quantity, price) "
|
| 520 |
+
"SELECT NULL, order_id, product_name, quantity, price FROM order_items "
|
| 521 |
+
"WHERE rowid IN (SELECT MIN(rowid) FROM order_items GROUP BY order_id)"
|
| 522 |
+
)
|
| 523 |
+
v3.commit()
|
| 524 |
+
if _run_variant(v3):
|
| 525 |
+
passes += 1
|
| 526 |
+
elif task_type == "fk_integrity":
|
| 527 |
+
v3.execute(
|
| 528 |
+
"UPDATE invoices SET customer_id = 900000 + id WHERE id % 17 = 0"
|
| 529 |
+
)
|
| 530 |
+
v3.commit()
|
| 531 |
+
variant_gt["expected_clean_count"] = (
|
| 532 |
+
v3.execute(
|
| 533 |
+
"SELECT COUNT(*) FROM invoices i "
|
| 534 |
+
"JOIN customers c ON i.customer_id = c.id"
|
| 535 |
+
).fetchone()[0]
|
| 536 |
+
)
|
| 537 |
+
variant_gt["valid_invoice_ids"] = [
|
| 538 |
+
row[0]
|
| 539 |
+
for row in v3.execute(
|
| 540 |
+
"SELECT i.id FROM invoices i JOIN customers c ON i.customer_id = c.id"
|
| 541 |
+
).fetchall()
|
| 542 |
+
]
|
| 543 |
+
if _run_variant(v3, variant_gt):
|
| 544 |
+
passes += 1
|
| 545 |
+
elif task_type == "schema_drift":
|
| 546 |
+
v3.execute(
|
| 547 |
+
"INSERT INTO events_v1 (id, user_id, event_name, amount_text, event_time) "
|
| 548 |
+
"SELECT id+10000, user_id, event_name, amount_text, event_time FROM events_v1 WHERE id <= 100"
|
| 549 |
+
)
|
| 550 |
+
v3.execute(
|
| 551 |
+
"INSERT INTO events_v2 (event_id, account_id, event_type, amount, event_ts, source) "
|
| 552 |
+
"SELECT event_id+20000, account_id, event_type, amount, event_ts, source FROM events_v2 WHERE event_id <= 700"
|
| 553 |
+
)
|
| 554 |
+
v3.commit()
|
| 555 |
+
variant_gt["expected_row_count"] = (
|
| 556 |
+
variant_gt.get("expected_row_count", 0) + 200
|
| 557 |
+
)
|
| 558 |
+
if _run_variant(v3, variant_gt):
|
| 559 |
+
passes += 1
|
| 560 |
+
else:
|
| 561 |
+
v3.close()
|
| 562 |
+
except Exception:
|
| 563 |
+
pass
|
| 564 |
+
|
| 565 |
+
if passes == 3:
|
| 566 |
+
return 1.0
|
| 567 |
+
if passes == 2:
|
| 568 |
+
return 0.6
|
| 569 |
+
if passes == 1:
|
| 570 |
+
return 0.3
|
| 571 |
+
return 0.0
|
| 572 |
+
|
| 573 |
+
|
| 574 |
+
def compute_episode_score(
|
| 575 |
+
trajectory: list[TrajectoryStep],
|
| 576 |
+
ground_truth: dict,
|
| 577 |
+
original_conn: sqlite3.Connection,
|
| 578 |
+
original_snapshot: dict,
|
| 579 |
+
submit_action: SQLAction,
|
| 580 |
+
) -> SQLReward:
|
| 581 |
+
"""Compute five-dimension composite score at SUBMIT."""
|
| 582 |
+
task_type = ground_truth.get("task_type", "")
|
| 583 |
+
|
| 584 |
+
# null_audit is intentionally direct: answer quality maps directly to final score.
|
| 585 |
+
if task_type == "null_audit":
|
| 586 |
+
score, severity, null_meta = _null_audit_score(
|
| 587 |
+
submit_action.answer or "",
|
| 588 |
+
ground_truth,
|
| 589 |
+
)
|
| 590 |
+
score = round(max(0.0, min(1.0, score)), 4)
|
| 591 |
+
return SQLReward(
|
| 592 |
+
score=score,
|
| 593 |
+
step_reward=score,
|
| 594 |
+
feedback=f"Null audit answer score: {score:.4f} ({severity})",
|
| 595 |
+
dimensions={
|
| 596 |
+
"d1_diagnostic_efficiency": 0.0,
|
| 597 |
+
"d2_hypothesis_quality": 0.0,
|
| 598 |
+
"d3_fix_correctness": round(score, 4),
|
| 599 |
+
"d3_fix_severity": severity,
|
| 600 |
+
"d4_self_verification": 0.0,
|
| 601 |
+
"d5_generalization": 1.0,
|
| 602 |
+
"d3_effective": round(score, 4),
|
| 603 |
+
"composite": score,
|
| 604 |
+
**null_meta,
|
| 605 |
+
},
|
| 606 |
+
partial_scores={
|
| 607 |
+
"d1": 0.0,
|
| 608 |
+
"d2": 0.0,
|
| 609 |
+
"d3": round(score, 4),
|
| 610 |
+
"d4": 0.0,
|
| 611 |
+
"d5": 1.0,
|
| 612 |
+
**null_meta,
|
| 613 |
+
},
|
| 614 |
+
)
|
| 615 |
+
|
| 616 |
+
d1 = grade_diagnostic_efficiency(trajectory, ground_truth)
|
| 617 |
+
d2 = grade_hypothesis_quality(trajectory, ground_truth)
|
| 618 |
+
|
| 619 |
+
fix_index = next(
|
| 620 |
+
(i for i, s in enumerate(trajectory) if s.action_type == ActionType.FIX),
|
| 621 |
+
-1,
|
| 622 |
+
)
|
| 623 |
+
fix_sql = trajectory[fix_index].sql_submitted if fix_index >= 0 else None
|
| 624 |
+
|
| 625 |
+
null_meta = {}
|
| 626 |
+
if fix_sql:
|
| 627 |
+
d3_raw, severity = grade_fix_correctness(
|
| 628 |
+
fix_sql,
|
| 629 |
+
original_conn,
|
| 630 |
+
original_snapshot,
|
| 631 |
+
ground_truth,
|
| 632 |
+
)
|
| 633 |
+
d5 = grade_generalization(fix_sql, original_conn, ground_truth)
|
| 634 |
+
else:
|
| 635 |
+
d5 = 0.0
|
| 636 |
+
if task_type == "dedup_bug":
|
| 637 |
+
correct_table = ground_truth.get("table", "order_items").lower()
|
| 638 |
+
correct_key = ground_truth.get("column", "order_id").lower()
|
| 639 |
+
table_ok = (submit_action.root_cause_table or "").lower() == correct_table
|
| 640 |
+
key_ok = (submit_action.offending_key or "").lower() == correct_key
|
| 641 |
+
if table_ok and key_ok:
|
| 642 |
+
d3_raw, severity = 0.7, "ACCEPTABLE"
|
| 643 |
+
elif table_ok:
|
| 644 |
+
d3_raw, severity = 0.4, "DANGEROUS"
|
| 645 |
+
else:
|
| 646 |
+
d3_raw, severity = 0.0, "CATASTROPHIC"
|
| 647 |
+
else:
|
| 648 |
+
d3_raw, severity = 0.0, "CATASTROPHIC"
|
| 649 |
+
|
| 650 |
+
d3_effective = d3_raw * (0.5 + 0.5 * d5)
|
| 651 |
+
d4 = grade_self_verification(trajectory, fix_index, ground_truth) if fix_index >= 0 else 0.0
|
| 652 |
+
|
| 653 |
+
raw_score = d1 * 0.20 + d2 * 0.20 + d3_effective * 0.35 + max(0.0, d4) * 0.10
|
| 654 |
+
final_score = min(1.0, raw_score + max(0.0, d4))
|
| 655 |
+
|
| 656 |
+
if severity == "CATASTROPHIC":
|
| 657 |
+
final_score = min(final_score, 0.05)
|
| 658 |
+
|
| 659 |
+
final_score = round(max(0.0, min(1.0, final_score)), 4)
|
| 660 |
+
feedback_parts = [
|
| 661 |
+
f"Diagnostic efficiency: {d1:.2f}",
|
| 662 |
+
f"Hypothesis quality: {d2:.2f}",
|
| 663 |
+
f"Fix correctness: {d3_raw:.2f} ({severity})",
|
| 664 |
+
f"Fix generalization: {d5:.2f}",
|
| 665 |
+
f"Self-verification: {d4:.2f}",
|
| 666 |
+
f"Composite score: {final_score:.4f}",
|
| 667 |
+
]
|
| 668 |
+
if task_type == "null_audit" and null_meta.get("lexical_near_miss", 0.0) > 0:
|
| 669 |
+
feedback_parts.append(
|
| 670 |
+
f"Lexical near miss partial: {null_meta['lexical_near_miss']:.2f}"
|
| 671 |
+
)
|
| 672 |
+
|
| 673 |
+
dimensions = {
|
| 674 |
+
"d1_diagnostic_efficiency": round(d1, 4),
|
| 675 |
+
"d2_hypothesis_quality": round(d2, 4),
|
| 676 |
+
"d3_fix_correctness": round(d3_raw, 4),
|
| 677 |
+
"d3_fix_severity": severity,
|
| 678 |
+
"d4_self_verification": round(d4, 4),
|
| 679 |
+
"d5_generalization": round(d5, 4),
|
| 680 |
+
"d3_effective": round(d3_effective, 4),
|
| 681 |
+
"composite": final_score,
|
| 682 |
+
}
|
| 683 |
+
if null_meta:
|
| 684 |
+
dimensions.update(null_meta)
|
| 685 |
+
|
| 686 |
+
return SQLReward(
|
| 687 |
+
score=final_score,
|
| 688 |
+
step_reward=final_score,
|
| 689 |
+
feedback=" | ".join(feedback_parts),
|
| 690 |
+
dimensions=dimensions,
|
| 691 |
+
partial_scores={
|
| 692 |
+
"d1": round(d1, 4),
|
| 693 |
+
"d2": round(d2, 4),
|
| 694 |
+
"d3": round(d3_raw, 4),
|
| 695 |
+
"d4": round(d4, 4),
|
| 696 |
+
"d5": round(d5, 4),
|
| 697 |
+
**null_meta,
|
| 698 |
+
},
|
| 699 |
+
)
|
| 700 |
+
|
| 701 |
+
|
| 702 |
+
def grade_null_audit(action: SQLAction, ground_truth: Dict[str, Any]) -> SQLReward:
|
| 703 |
+
"""Single-step null_audit scorer used by validation scripts."""
|
| 704 |
+
score, severity, meta = _null_audit_score(action.answer or "", ground_truth)
|
| 705 |
+
answer = (action.answer or "").strip()
|
| 706 |
+
if not answer:
|
| 707 |
+
feedback = "No answer submitted"
|
| 708 |
+
elif meta["answer_correct"] == 1.0:
|
| 709 |
+
feedback = f"Correct: identified '{ground_truth.get('null_column', '')}'."
|
| 710 |
+
elif meta["answer_in_schema"] > 0:
|
| 711 |
+
feedback = (
|
| 712 |
+
f"Submitted column '{answer}' exists in the schema but is not the high-null column."
|
| 713 |
+
)
|
| 714 |
+
elif meta["lexical_near_miss"] > 0:
|
| 715 |
+
feedback = (
|
| 716 |
+
f"Submitted column '{answer}' not in schema, but close to a valid column name."
|
| 717 |
+
)
|
| 718 |
+
else:
|
| 719 |
+
feedback = f"Submitted column '{answer}' is not present in the schema."
|
| 720 |
+
|
| 721 |
+
return SQLReward(
|
| 722 |
+
score=score,
|
| 723 |
+
step_reward=score,
|
| 724 |
+
feedback=feedback,
|
| 725 |
+
dimensions={
|
| 726 |
+
"legacy_mode": True,
|
| 727 |
+
"d3_fix_severity": severity,
|
| 728 |
+
"composite": score,
|
| 729 |
+
**meta,
|
| 730 |
+
},
|
| 731 |
+
partial_scores=meta,
|
| 732 |
+
)
|
| 733 |
+
|
| 734 |
+
|
| 735 |
+
def grade_type_repair(
|
| 736 |
+
action: SQLAction, ground_truth: dict, conn: sqlite3.Connection
|
| 737 |
+
) -> SQLReward:
|
| 738 |
+
"""Single-step type_repair scorer for exploit checks and compatibility."""
|
| 739 |
+
fix_sql = (action.fix_query or action.sql or "").strip()
|
| 740 |
+
target_table = ground_truth.get("target_table", "transactions")
|
| 741 |
+
partial_scores = {"syntax_valid": 0.0, "type_correct": 0.0, "data_valid": 0.0}
|
| 742 |
+
|
| 743 |
+
def _make(feedback: str) -> SQLReward:
|
| 744 |
+
score = round(sum(partial_scores.values()), 4)
|
| 745 |
+
return SQLReward(
|
| 746 |
+
score=score,
|
| 747 |
+
step_reward=score,
|
| 748 |
+
feedback=feedback,
|
| 749 |
+
dimensions={"legacy_mode": True, "composite": score, **partial_scores},
|
| 750 |
+
partial_scores=partial_scores.copy(),
|
| 751 |
+
)
|
| 752 |
+
|
| 753 |
+
if not fix_sql:
|
| 754 |
+
return _make("No fix_query provided")
|
| 755 |
+
if fix_sql.upper().startswith("SELECT"):
|
| 756 |
+
return _make("No-op fix_query did not modify the target schema")
|
| 757 |
+
|
| 758 |
+
snapshot = _snapshot_for_preservation(_snapshot_table(conn, target_table), ground_truth)
|
| 759 |
+
test_conn = _fresh_test_db(conn)
|
| 760 |
+
try:
|
| 761 |
+
try:
|
| 762 |
+
test_conn.executescript(fix_sql)
|
| 763 |
+
test_conn.commit()
|
| 764 |
+
except sqlite3.Error as exc:
|
| 765 |
+
return _make(f"SQL error: {exc}")
|
| 766 |
+
|
| 767 |
+
issue_resolved, preservation, schema_intact = _evaluate_fix_outcome(
|
| 768 |
+
test_conn,
|
| 769 |
+
{**ground_truth, "task_type": "type_repair"},
|
| 770 |
+
snapshot,
|
| 771 |
+
)
|
| 772 |
+
if not schema_intact:
|
| 773 |
+
return _make("Destructive query detected: target table missing")
|
| 774 |
+
|
| 775 |
+
partial_scores["syntax_valid"] = 0.3
|
| 776 |
+
if issue_resolved:
|
| 777 |
+
partial_scores["type_correct"] = 0.4
|
| 778 |
+
if preservation >= 0.995:
|
| 779 |
+
partial_scores["data_valid"] = 0.3
|
| 780 |
+
|
| 781 |
+
if partial_scores["type_correct"] == 0.0:
|
| 782 |
+
return _make("Fix executed but target column type is still incorrect")
|
| 783 |
+
if partial_scores["data_valid"] == 0.0:
|
| 784 |
+
return _make("Fix executed but data preservation check failed")
|
| 785 |
+
if sum(partial_scores.values()) == 1.0:
|
| 786 |
+
return _make("Correct fix: target column type repaired with data preserved")
|
| 787 |
+
return _make("Fix executed with partial credit")
|
| 788 |
+
finally:
|
| 789 |
+
test_conn.close()
|
| 790 |
+
|
| 791 |
+
|
| 792 |
+
def grade_dedup_bug(
|
| 793 |
+
action: SQLAction, ground_truth: Dict[str, Any], conn: sqlite3.Connection
|
| 794 |
+
) -> SQLReward:
|
| 795 |
+
"""Single-step dedup_bug scorer for exploit checks and compatibility."""
|
| 796 |
+
fix_sql = (action.fix_query or action.sql or "").strip()
|
| 797 |
+
target_table = ground_truth.get("table", ground_truth.get("root_cause_table", "order_items"))
|
| 798 |
+
target_key = ground_truth.get("column", ground_truth.get("offending_key", "order_id"))
|
| 799 |
+
table_ok = (action.root_cause_table or "").lower() == target_table.lower()
|
| 800 |
+
key_ok = (action.offending_key or "").lower() == target_key.lower()
|
| 801 |
+
partial_scores = {
|
| 802 |
+
"root_cause_identified": 0.4 if table_ok else 0.0,
|
| 803 |
+
"key_identified": 0.3 if key_ok else 0.0,
|
| 804 |
+
"fix_valid": 0.0,
|
| 805 |
+
}
|
| 806 |
+
|
| 807 |
+
sql_error = None
|
| 808 |
+
if fix_sql and fix_sql.upper() != "SELECT 1":
|
| 809 |
+
test_conn = _fresh_test_db(conn)
|
| 810 |
+
try:
|
| 811 |
+
test_conn.executescript(fix_sql)
|
| 812 |
+
test_conn.commit()
|
| 813 |
+
issue_resolved, preservation, schema_intact = _evaluate_fix_outcome(
|
| 814 |
+
test_conn,
|
| 815 |
+
{**ground_truth, "task_type": "dedup_bug"},
|
| 816 |
+
{},
|
| 817 |
+
)
|
| 818 |
+
if schema_intact and issue_resolved and preservation >= 0.95:
|
| 819 |
+
partial_scores["fix_valid"] = 0.3
|
| 820 |
+
except sqlite3.Error as exc:
|
| 821 |
+
sql_error = str(exc)
|
| 822 |
+
finally:
|
| 823 |
+
test_conn.close()
|
| 824 |
+
|
| 825 |
+
score = round(sum(partial_scores.values()), 4)
|
| 826 |
+
if sql_error:
|
| 827 |
+
feedback = f"SQL error: {sql_error}"
|
| 828 |
+
elif partial_scores["fix_valid"] == 0.3:
|
| 829 |
+
feedback = "Correct table, key, and dedup fix identified."
|
| 830 |
+
elif table_ok and key_ok:
|
| 831 |
+
feedback = "Correct table and key identified, but fix did not resolve duplication."
|
| 832 |
+
elif table_ok:
|
| 833 |
+
feedback = "Correct root cause table identified."
|
| 834 |
+
else:
|
| 835 |
+
feedback = "Root cause not identified."
|
| 836 |
+
|
| 837 |
+
return SQLReward(
|
| 838 |
+
score=score,
|
| 839 |
+
step_reward=score,
|
| 840 |
+
feedback=feedback,
|
| 841 |
+
dimensions={"legacy_mode": True, "composite": score, **partial_scores},
|
| 842 |
+
partial_scores=partial_scores,
|
| 843 |
+
)
|
server/main.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
|
| 3 |
+
from fastapi import FastAPI, HTTPException
|
| 4 |
+
|
| 5 |
+
from server.environment import SQLQualityEnvironment
|
| 6 |
+
from server.models import (
|
| 7 |
+
HealthResponse,
|
| 8 |
+
ResetRequest,
|
| 9 |
+
SQLAction,
|
| 10 |
+
SQLObservation,
|
| 11 |
+
SQLState,
|
| 12 |
+
StepResponse,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
app = FastAPI(
|
| 16 |
+
title="SQL Quality Audit Agent Environment",
|
| 17 |
+
description="OpenEnv-compatible environment for training AI agents on SQL data quality tasks",
|
| 18 |
+
version="0.1.0",
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
# Module-level singleton — ONE instance shared across all requests
|
| 22 |
+
_env = SQLQualityEnvironment()
|
| 23 |
+
|
| 24 |
+
# HTTP-level request lock to serialize concurrent requests
|
| 25 |
+
_request_lock = asyncio.Lock()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@app.get("/health", response_model=HealthResponse)
|
| 29 |
+
async def health() -> HealthResponse:
|
| 30 |
+
"""Health check endpoint for HF Space ping validation.
|
| 31 |
+
|
| 32 |
+
Returns:
|
| 33 |
+
dict: Status response indicating the server is running
|
| 34 |
+
"""
|
| 35 |
+
return HealthResponse(status="ok")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@app.post("/ping", response_model=HealthResponse)
|
| 39 |
+
async def ping() -> HealthResponse:
|
| 40 |
+
"""Ping endpoint for submission validator — accepts empty body.
|
| 41 |
+
|
| 42 |
+
Returns:
|
| 43 |
+
dict: Status response indicating the server is responsive
|
| 44 |
+
"""
|
| 45 |
+
return HealthResponse(status="ok")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@app.post("/reset", response_model=SQLObservation)
|
| 49 |
+
async def reset(request: ResetRequest) -> SQLObservation:
|
| 50 |
+
"""Reset environment and return initial observation for specified task.
|
| 51 |
+
|
| 52 |
+
Args:
|
| 53 |
+
request: ResetRequest containing task_id and optional deterministic seed
|
| 54 |
+
|
| 55 |
+
Returns:
|
| 56 |
+
SQLObservation: Initial state of database for requested task
|
| 57 |
+
|
| 58 |
+
Raises:
|
| 59 |
+
HTTPException 422: If task_id is not a valid task identifier
|
| 60 |
+
HTTPException 500: If environment initialization fails
|
| 61 |
+
"""
|
| 62 |
+
async with _request_lock:
|
| 63 |
+
VALID_TASKS = {
|
| 64 |
+
"null_audit",
|
| 65 |
+
"type_repair",
|
| 66 |
+
"dedup_bug",
|
| 67 |
+
"fk_integrity",
|
| 68 |
+
"schema_drift",
|
| 69 |
+
}
|
| 70 |
+
if request.task_id not in VALID_TASKS:
|
| 71 |
+
raise HTTPException(
|
| 72 |
+
status_code=422,
|
| 73 |
+
detail=f"Invalid task_id '{request.task_id}'. Must be one of: {VALID_TASKS}",
|
| 74 |
+
)
|
| 75 |
+
try:
|
| 76 |
+
return _env.reset(request.task_id, seed=request.seed)
|
| 77 |
+
except Exception as e:
|
| 78 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@app.post("/step", response_model=StepResponse)
|
| 82 |
+
async def step(action: SQLAction) -> StepResponse:
|
| 83 |
+
"""Submit an action and receive observation, reward, done flag, and info.
|
| 84 |
+
|
| 85 |
+
Args:
|
| 86 |
+
action: SQLAction containing task_id and fix_query or answer
|
| 87 |
+
|
| 88 |
+
Returns:
|
| 89 |
+
StepResponse: Updated observation, reward, done status, and info
|
| 90 |
+
|
| 91 |
+
Raises:
|
| 92 |
+
HTTPException 422: If action is invalid or episode is already done
|
| 93 |
+
HTTPException 500: If environment step execution fails
|
| 94 |
+
"""
|
| 95 |
+
async with _request_lock:
|
| 96 |
+
try:
|
| 97 |
+
return _env.step(action)
|
| 98 |
+
except HTTPException:
|
| 99 |
+
raise
|
| 100 |
+
except ValueError as e:
|
| 101 |
+
raise HTTPException(status_code=422, detail=str(e))
|
| 102 |
+
except Exception as e:
|
| 103 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@app.get("/state", response_model=SQLState)
|
| 107 |
+
async def state() -> SQLState:
|
| 108 |
+
"""Get current episode state.
|
| 109 |
+
|
| 110 |
+
Returns:
|
| 111 |
+
SQLState: Current episode information including task_id, episode_id, attempt_number, cumulative_reward, and done status
|
| 112 |
+
|
| 113 |
+
Raises:
|
| 114 |
+
HTTPException 422: If no active episode exists
|
| 115 |
+
"""
|
| 116 |
+
try:
|
| 117 |
+
return _env.state()
|
| 118 |
+
except ValueError as e:
|
| 119 |
+
raise HTTPException(status_code=422, detail=str(e))
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def run_server() -> None:
|
| 123 |
+
"""
|
| 124 |
+
Entry point for 'uv run server' command.
|
| 125 |
+
|
| 126 |
+
Uses PORT environment variable if set, otherwise defaults to 7860
|
| 127 |
+
(matching the Dockerfile EXPOSE and HF Spaces convention).
|
| 128 |
+
Override locally: PORT=8000 uv run server
|
| 129 |
+
"""
|
| 130 |
+
import os
|
| 131 |
+
|
| 132 |
+
import uvicorn
|
| 133 |
+
|
| 134 |
+
port = int(os.getenv("PORT", "7860"))
|
| 135 |
+
uvicorn.run("server.main:app", host="0.0.0.0", port=port, reload=False)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def main() -> None:
|
| 139 |
+
"""Primary CLI entrypoint expected by OpenEnv validators."""
|
| 140 |
+
run_server()
|
server/models.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from enum import Enum
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
from pydantic import BaseModel, Field, field_validator
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class ActionType(str, Enum):
|
| 10 |
+
"""
|
| 11 |
+
The four action types available to an agent in sql_quality_env.
|
| 12 |
+
|
| 13 |
+
QUERY — Read-only SELECT statement. Inspect schema or data.
|
| 14 |
+
Does not modify the database. Can be called multiple times.
|
| 15 |
+
PROFILE — Run null-rate and type distribution stats on a named column.
|
| 16 |
+
Shortcut for common inspection patterns. Read-only.
|
| 17 |
+
FIX — Submit a write query intended to resolve the data issue.
|
| 18 |
+
Executes on an isolated _fresh_test_db() copy for scoring.
|
| 19 |
+
SUBMIT — Declare final diagnosis and close the episode.
|
| 20 |
+
Triggers composite score computation. Episode ends after this.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
QUERY = "query"
|
| 24 |
+
PROFILE = "profile"
|
| 25 |
+
FIX = "fix"
|
| 26 |
+
SUBMIT = "submit"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class SchemaInfo(BaseModel):
|
| 30 |
+
"""Schema information for a database table."""
|
| 31 |
+
|
| 32 |
+
table_name: str
|
| 33 |
+
columns: list[str] # Format: "column_name (TYPE)"
|
| 34 |
+
row_count: int
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class SQLObservation(BaseModel):
|
| 38 |
+
"""What the agent sees at each step."""
|
| 39 |
+
|
| 40 |
+
task_id: str = Field(
|
| 41 |
+
description=(
|
| 42 |
+
"One of: null_audit, type_repair, dedup_bug, fk_integrity, schema_drift"
|
| 43 |
+
)
|
| 44 |
+
)
|
| 45 |
+
task_description: str = Field(
|
| 46 |
+
description="Natural language description of the task"
|
| 47 |
+
)
|
| 48 |
+
schema_info: list[SchemaInfo] = Field(description="Schema of all relevant tables")
|
| 49 |
+
sample_rows: dict[str, list[dict]] = Field(
|
| 50 |
+
description="First 5 rows of each table as list of dicts"
|
| 51 |
+
)
|
| 52 |
+
episode_id: Optional[str] = Field(
|
| 53 |
+
default=None, description="UUID4 identifying this episode"
|
| 54 |
+
)
|
| 55 |
+
attempt_number: int = Field(ge=1, description="Current attempt (1-indexed)")
|
| 56 |
+
max_attempts: int = Field(
|
| 57 |
+
ge=1, description="Maximum attempts allowed for this task"
|
| 58 |
+
)
|
| 59 |
+
previous_feedback: Optional[str] = Field(
|
| 60 |
+
default=None, description="Feedback from previous attempt, if any"
|
| 61 |
+
)
|
| 62 |
+
# NEW FIELDS:
|
| 63 |
+
trajectory_history: list[dict] = Field(
|
| 64 |
+
default_factory=list,
|
| 65 |
+
description=(
|
| 66 |
+
"List of previous actions and their results in this episode. "
|
| 67 |
+
"Each entry: {action_type, sql_or_answer, result_preview, step_reward}. "
|
| 68 |
+
"Empty on first step after reset()."
|
| 69 |
+
),
|
| 70 |
+
)
|
| 71 |
+
available_actions: list[str] = Field(
|
| 72 |
+
default_factory=lambda: ["query", "profile", "fix", "submit"],
|
| 73 |
+
description="Action types available at this point in the episode.",
|
| 74 |
+
)
|
| 75 |
+
investigation_hint: str | None = Field(
|
| 76 |
+
default=None,
|
| 77 |
+
description=(
|
| 78 |
+
"Hint provided after a failed attempt. None on first step. "
|
| 79 |
+
"Populated with actionable feedback after each scored action."
|
| 80 |
+
),
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class SQLAction(BaseModel):
|
| 85 |
+
"""
|
| 86 |
+
Agent action for sql_quality_env.
|
| 87 |
+
|
| 88 |
+
Fields:
|
| 89 |
+
task_id: Which task this action targets.
|
| 90 |
+
action_type: One of QUERY / PROFILE / FIX / SUBMIT.
|
| 91 |
+
sql: For QUERY and FIX actions — the SQL statement to execute.
|
| 92 |
+
column_name: For PROFILE actions — the column to profile.
|
| 93 |
+
answer: For null_audit SUBMIT — the column name identified.
|
| 94 |
+
fix_query: Alias for sql in FIX context (kept for backward compat).
|
| 95 |
+
root_cause_table: For dedup_bug SUBMIT — the table with duplicates.
|
| 96 |
+
offending_key: For dedup_bug SUBMIT — the key column with duplicates.
|
| 97 |
+
"""
|
| 98 |
+
|
| 99 |
+
task_id: str
|
| 100 |
+
action_type: ActionType = ActionType.SUBMIT
|
| 101 |
+
sql: str | None = None
|
| 102 |
+
column_name: str | None = None
|
| 103 |
+
answer: str | None = None
|
| 104 |
+
fix_query: str | None = None
|
| 105 |
+
root_cause_table: str | None = None
|
| 106 |
+
offending_key: str | None = None
|
| 107 |
+
|
| 108 |
+
@property
|
| 109 |
+
def effective_sql(self) -> str | None:
|
| 110 |
+
"""Return sql or fix_query, whichever is set. fix_query takes precedence."""
|
| 111 |
+
return self.fix_query or self.sql
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
class SQLReward(BaseModel):
|
| 115 |
+
"""
|
| 116 |
+
Reward returned after each step.
|
| 117 |
+
|
| 118 |
+
Fields:
|
| 119 |
+
score: Episode-level composite score 0.0–1.0.
|
| 120 |
+
step_reward: Immediate reward for this specific action (not cumulative).
|
| 121 |
+
Used for dense reward shaping in RL training.
|
| 122 |
+
feedback: Human-readable explanation of what was scored and why.
|
| 123 |
+
dimensions: Per-dimension breakdown. Present only after SUBMIT.
|
| 124 |
+
None for intermediate QUERY/PROFILE/FIX actions.
|
| 125 |
+
partial_scores: Legacy field kept for backward compatibility.
|
| 126 |
+
"""
|
| 127 |
+
|
| 128 |
+
score: float
|
| 129 |
+
step_reward: float = 0.0
|
| 130 |
+
feedback: str = ""
|
| 131 |
+
dimensions: dict | None = None
|
| 132 |
+
partial_scores: dict | None = None
|
| 133 |
+
|
| 134 |
+
@field_validator("score", "step_reward")
|
| 135 |
+
@classmethod
|
| 136 |
+
def clamp_to_unit(cls, v: float) -> float:
|
| 137 |
+
"""Ensure scores stay in [0.0, 1.0]."""
|
| 138 |
+
return round(max(0.0, min(1.0, v)), 4)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class TrajectoryStep(BaseModel):
|
| 142 |
+
"""A single step in the agent's investigation trajectory."""
|
| 143 |
+
|
| 144 |
+
step_number: int
|
| 145 |
+
action_type: ActionType
|
| 146 |
+
sql_submitted: str | None = None
|
| 147 |
+
answer_submitted: str | None = None
|
| 148 |
+
result_preview: str = ""
|
| 149 |
+
step_reward: float = 0.0
|
| 150 |
+
timestamp_ms: int = 0
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
class SQLState(BaseModel):
|
| 154 |
+
"""Current episode state."""
|
| 155 |
+
|
| 156 |
+
task_id: str
|
| 157 |
+
episode_id: str = Field(description="UUID4 identifying this episode")
|
| 158 |
+
attempt_number: int
|
| 159 |
+
cumulative_reward: float = Field(ge=0.0)
|
| 160 |
+
done: bool
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
class ResetRequest(BaseModel):
|
| 164 |
+
"""Request body for /reset endpoint."""
|
| 165 |
+
|
| 166 |
+
task_id: str = Field(
|
| 167 |
+
description=(
|
| 168 |
+
"One of: null_audit, type_repair, dedup_bug, fk_integrity, schema_drift"
|
| 169 |
+
)
|
| 170 |
+
)
|
| 171 |
+
seed: int | None = Field(
|
| 172 |
+
default=None,
|
| 173 |
+
description=(
|
| 174 |
+
"Optional deterministic episode seed. If omitted, environment chooses a random seed."
|
| 175 |
+
),
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
class StepResponse(BaseModel):
|
| 180 |
+
"""Response body for /step endpoint."""
|
| 181 |
+
|
| 182 |
+
observation: SQLObservation
|
| 183 |
+
reward: SQLReward
|
| 184 |
+
done: bool
|
| 185 |
+
info: dict
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
class HealthResponse(BaseModel):
|
| 189 |
+
"""Response body for health/ping endpoints."""
|
| 190 |
+
|
| 191 |
+
status: str = Field(description="Service health status")
|
server/tasks.py
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Task definitions for SQL Quality Environment."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import sqlite3
|
| 6 |
+
from typing import Any, Dict, Tuple
|
| 7 |
+
|
| 8 |
+
from server.models import SchemaInfo, SQLObservation
|
| 9 |
+
|
| 10 |
+
# Allowed table names for validation
|
| 11 |
+
ALLOWED_TABLES = {
|
| 12 |
+
"users",
|
| 13 |
+
"transactions",
|
| 14 |
+
"categories",
|
| 15 |
+
"orders",
|
| 16 |
+
"order_items",
|
| 17 |
+
"shipments",
|
| 18 |
+
"customers",
|
| 19 |
+
"invoices",
|
| 20 |
+
"events_v1",
|
| 21 |
+
"events_v2",
|
| 22 |
+
"canonical_events",
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _validate_table_name(table_name: str) -> str:
|
| 27 |
+
"""Validate table name against allowed set to prevent SQL injection."""
|
| 28 |
+
if table_name not in ALLOWED_TABLES:
|
| 29 |
+
raise ValueError(f"Invalid table name: {table_name}")
|
| 30 |
+
return table_name
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _extract_sample_rows(
|
| 34 |
+
conn: sqlite3.Connection, table_name: str, limit: int = 5
|
| 35 |
+
) -> list:
|
| 36 |
+
"""Extract sample rows with correct column names as dict keys."""
|
| 37 |
+
validated_name = _validate_table_name(table_name)
|
| 38 |
+
cursor = conn.execute(f"SELECT * FROM {validated_name} LIMIT ?", (limit,))
|
| 39 |
+
columns = [description[0] for description in cursor.description]
|
| 40 |
+
rows = cursor.fetchall()
|
| 41 |
+
return [dict(zip(columns, row)) for row in rows]
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def create_null_audit_task(
|
| 45 |
+
seed: int = 42,
|
| 46 |
+
) -> Tuple[sqlite3.Connection, SQLObservation, Dict[str, Any]]:
|
| 47 |
+
"""Create null audit task with users table."""
|
| 48 |
+
import random
|
| 49 |
+
|
| 50 |
+
rng = random.Random(seed)
|
| 51 |
+
conn = sqlite3.connect(":memory:", check_same_thread=False)
|
| 52 |
+
cursor = conn.cursor()
|
| 53 |
+
|
| 54 |
+
nullable_columns = ["email", "age", "city", "signup_date"]
|
| 55 |
+
null_column = rng.choice(nullable_columns)
|
| 56 |
+
|
| 57 |
+
cursor.execute(
|
| 58 |
+
"""
|
| 59 |
+
CREATE TABLE users (
|
| 60 |
+
id INTEGER PRIMARY KEY,
|
| 61 |
+
name TEXT NOT NULL,
|
| 62 |
+
email TEXT,
|
| 63 |
+
age INTEGER,
|
| 64 |
+
city TEXT,
|
| 65 |
+
signup_date TEXT,
|
| 66 |
+
last_login_at TEXT,
|
| 67 |
+
referral_code TEXT
|
| 68 |
+
)
|
| 69 |
+
"""
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
total_rows = 1000
|
| 73 |
+
target_null_count = 520
|
| 74 |
+
null_indices = set(rng.sample(range(total_rows), target_null_count))
|
| 75 |
+
users_data = []
|
| 76 |
+
|
| 77 |
+
for i in range(total_rows):
|
| 78 |
+
name = f"user_{i}"
|
| 79 |
+
email = f"user_{i}@example.com"
|
| 80 |
+
age = rng.randint(18, 80)
|
| 81 |
+
city = rng.choice(["New York", "Los Angeles", "Chicago", "Houston", "Phoenix"])
|
| 82 |
+
signup_date = f"2023-{rng.randint(1, 12):02d}-{rng.randint(1, 28):02d}"
|
| 83 |
+
last_login_at = (
|
| 84 |
+
f"2024-{rng.randint(1, 12):02d}-{rng.randint(1, 28):02d}"
|
| 85 |
+
f"T{rng.randint(0, 23):02d}:{rng.randint(0, 59):02d}:00"
|
| 86 |
+
)
|
| 87 |
+
referral_code = (
|
| 88 |
+
f"REF_{rng.choice('ABCDEFGHJKLMNPQRSTUVWXYZ')}"
|
| 89 |
+
f"{rng.randint(0, 9)}"
|
| 90 |
+
f"{rng.choice('ABCDEFGHJKLMNPQRSTUVWXYZ')}"
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
if i in null_indices:
|
| 94 |
+
if null_column == "email":
|
| 95 |
+
email = None
|
| 96 |
+
elif null_column == "age":
|
| 97 |
+
age = None
|
| 98 |
+
elif null_column == "city":
|
| 99 |
+
city = None
|
| 100 |
+
elif null_column == "signup_date":
|
| 101 |
+
signup_date = None
|
| 102 |
+
|
| 103 |
+
if rng.random() < 0.1 and null_column != "email":
|
| 104 |
+
email = None
|
| 105 |
+
if rng.random() < 0.1 and null_column != "age":
|
| 106 |
+
age = None
|
| 107 |
+
if rng.random() < 0.1 and null_column != "city":
|
| 108 |
+
city = None
|
| 109 |
+
if rng.random() < 0.1 and null_column != "signup_date":
|
| 110 |
+
signup_date = None
|
| 111 |
+
|
| 112 |
+
users_data.append(
|
| 113 |
+
(i, name, email, age, city, signup_date, last_login_at, referral_code)
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
cursor.executemany(
|
| 117 |
+
"""
|
| 118 |
+
INSERT INTO users (
|
| 119 |
+
id, name, email, age, city, signup_date, last_login_at, referral_code
|
| 120 |
+
)
|
| 121 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
| 122 |
+
""",
|
| 123 |
+
users_data,
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
cursor.execute("PRAGMA table_info(users)")
|
| 127 |
+
pragma_rows = cursor.fetchall()
|
| 128 |
+
columns = [f"{row[1]} ({row[2]})" for row in pragma_rows]
|
| 129 |
+
all_columns = [row[1] for row in pragma_rows]
|
| 130 |
+
|
| 131 |
+
sample_rows = {"users": _extract_sample_rows(conn, "users", 5)}
|
| 132 |
+
schema_info = [
|
| 133 |
+
SchemaInfo(table_name="users", columns=columns, row_count=total_rows)
|
| 134 |
+
]
|
| 135 |
+
|
| 136 |
+
observation = SQLObservation(
|
| 137 |
+
task_id="null_audit",
|
| 138 |
+
task_description="Identify column in 'users' table with a null rate exceeding 50%",
|
| 139 |
+
schema_info=schema_info,
|
| 140 |
+
sample_rows=sample_rows,
|
| 141 |
+
attempt_number=1,
|
| 142 |
+
max_attempts=1,
|
| 143 |
+
previous_feedback=None,
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
ground_truth = {
|
| 147 |
+
"null_column": null_column, # existing — the column with NULLs
|
| 148 |
+
"all_columns": all_columns, # NEW — for partial credit
|
| 149 |
+
"table": "users", # NEW — for D1/D2 query matching
|
| 150 |
+
"column": null_column, # NEW — alias for unified access
|
| 151 |
+
"task_type": "null_audit", # NEW — for compute_episode_score routing
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
return conn, observation, ground_truth
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def create_type_repair_task(
|
| 158 |
+
seed: int = 42,
|
| 159 |
+
) -> Tuple[sqlite3.Connection, SQLObservation, Dict[str, Any]]:
|
| 160 |
+
"""Create type repair task with transactions table."""
|
| 161 |
+
import random
|
| 162 |
+
|
| 163 |
+
rng = random.Random(seed)
|
| 164 |
+
conn = sqlite3.connect(":memory:", check_same_thread=False)
|
| 165 |
+
cursor = conn.cursor()
|
| 166 |
+
|
| 167 |
+
cursor.execute(
|
| 168 |
+
"""
|
| 169 |
+
CREATE TABLE transactions (
|
| 170 |
+
id INTEGER PRIMARY KEY,
|
| 171 |
+
user_id INTEGER NOT NULL,
|
| 172 |
+
amount TEXT,
|
| 173 |
+
description TEXT,
|
| 174 |
+
category TEXT,
|
| 175 |
+
date TEXT,
|
| 176 |
+
transaction_date TEXT
|
| 177 |
+
)
|
| 178 |
+
"""
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
cursor.execute(
|
| 182 |
+
"""
|
| 183 |
+
CREATE TABLE categories (
|
| 184 |
+
id INTEGER PRIMARY KEY,
|
| 185 |
+
name TEXT NOT NULL,
|
| 186 |
+
budget_limit REAL NOT NULL,
|
| 187 |
+
created_at TEXT NOT NULL
|
| 188 |
+
)
|
| 189 |
+
"""
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
for i, (name, budget) in enumerate(
|
| 193 |
+
[
|
| 194 |
+
("Food & Beverage", 5000.0),
|
| 195 |
+
("Office Supplies", 2000.0),
|
| 196 |
+
("Travel", 8000.0),
|
| 197 |
+
("Software", 15000.0),
|
| 198 |
+
("Hardware", 25000.0),
|
| 199 |
+
("Marketing", 10000.0),
|
| 200 |
+
("HR", 6000.0),
|
| 201 |
+
("Legal", 12000.0),
|
| 202 |
+
],
|
| 203 |
+
start=1,
|
| 204 |
+
):
|
| 205 |
+
cursor.execute(
|
| 206 |
+
"INSERT INTO categories (id, name, budget_limit, created_at) VALUES (?, ?, ?, ?)",
|
| 207 |
+
(i, name, budget, "2024-01-01"),
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
transactions_data = []
|
| 211 |
+
for i in range(1000):
|
| 212 |
+
user_id = rng.randint(1, 100)
|
| 213 |
+
amount_value = round(rng.uniform(10.0, 1000.0), 2)
|
| 214 |
+
amount_text = f"{amount_value:.2f}"
|
| 215 |
+
description = f"Transaction {i}"
|
| 216 |
+
category = rng.choice(
|
| 217 |
+
[
|
| 218 |
+
"food",
|
| 219 |
+
"software",
|
| 220 |
+
"travel",
|
| 221 |
+
"hardware",
|
| 222 |
+
"marketing",
|
| 223 |
+
]
|
| 224 |
+
)
|
| 225 |
+
date = f"2023-{rng.randint(1, 12):02d}-{rng.randint(1, 28):02d}"
|
| 226 |
+
transaction_date = f"2023-{rng.randint(1, 12):02d}-{rng.randint(1, 28):02d}"
|
| 227 |
+
transactions_data.append(
|
| 228 |
+
(i, user_id, amount_text, description, category, date, transaction_date)
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
cursor.executemany(
|
| 232 |
+
"""
|
| 233 |
+
INSERT INTO transactions (
|
| 234 |
+
id, user_id, amount, description, category, date, transaction_date
|
| 235 |
+
)
|
| 236 |
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
| 237 |
+
""",
|
| 238 |
+
transactions_data,
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
schema_info = []
|
| 242 |
+
for table_name, row_count in [("transactions", 1000), ("categories", 8)]:
|
| 243 |
+
cursor.execute(f"PRAGMA table_info({table_name})")
|
| 244 |
+
columns = [f"{row[1]} ({row[2]})" for row in cursor.fetchall()]
|
| 245 |
+
schema_info.append(
|
| 246 |
+
SchemaInfo(table_name=table_name, columns=columns, row_count=row_count)
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
sample_rows = {
|
| 250 |
+
"transactions": _extract_sample_rows(conn, "transactions", 5),
|
| 251 |
+
"categories": _extract_sample_rows(conn, "categories", 5),
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
observation = SQLObservation(
|
| 255 |
+
task_id="type_repair",
|
| 256 |
+
task_description="A production data pipeline is reporting incorrect aggregate totals on financial records. Inspect all provided tables and sample rows to identify the data quality issue, then submit a fix_query that corrects it. Your fix must preserve all existing rows and their original values.",
|
| 257 |
+
schema_info=schema_info,
|
| 258 |
+
sample_rows=sample_rows,
|
| 259 |
+
attempt_number=1,
|
| 260 |
+
max_attempts=3,
|
| 261 |
+
previous_feedback=None,
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
ground_truth = {
|
| 265 |
+
"target_table": "transactions", # existing
|
| 266 |
+
"target_column": "amount", # existing
|
| 267 |
+
"current_type": "TEXT", # existing
|
| 268 |
+
"correct_type": "REAL", # existing
|
| 269 |
+
"sample_values": ["12.50", "345.75", "89.99"], # existing
|
| 270 |
+
"preserve_columns": ["id", "description", "amount"], # compatible core fields
|
| 271 |
+
"table": "transactions", # NEW — unified alias
|
| 272 |
+
"column": "amount", # NEW — unified alias
|
| 273 |
+
"task_type": "type_repair", # NEW
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
return conn, observation, ground_truth
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def create_dedup_bug_task(
|
| 280 |
+
seed: int = 42,
|
| 281 |
+
) -> Tuple[sqlite3.Connection, SQLObservation, Dict[str, Any]]:
|
| 282 |
+
"""Create deduplication bug task with orders, order_items, and shipments."""
|
| 283 |
+
import random
|
| 284 |
+
|
| 285 |
+
rng = random.Random(seed)
|
| 286 |
+
conn = sqlite3.connect(":memory:", check_same_thread=False)
|
| 287 |
+
cursor = conn.cursor()
|
| 288 |
+
|
| 289 |
+
cursor.execute(
|
| 290 |
+
"""
|
| 291 |
+
CREATE TABLE orders (
|
| 292 |
+
id INTEGER PRIMARY KEY,
|
| 293 |
+
customer_id INTEGER NOT NULL,
|
| 294 |
+
total_amount REAL NOT NULL
|
| 295 |
+
)
|
| 296 |
+
"""
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
cursor.execute(
|
| 300 |
+
"""
|
| 301 |
+
CREATE TABLE order_items (
|
| 302 |
+
id INTEGER PRIMARY KEY,
|
| 303 |
+
order_id INTEGER NOT NULL,
|
| 304 |
+
product_name TEXT NOT NULL,
|
| 305 |
+
quantity INTEGER NOT NULL,
|
| 306 |
+
price REAL NOT NULL
|
| 307 |
+
)
|
| 308 |
+
"""
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
cursor.execute(
|
| 312 |
+
"""
|
| 313 |
+
CREATE TABLE shipments (
|
| 314 |
+
id INTEGER PRIMARY KEY,
|
| 315 |
+
order_item_id INTEGER NOT NULL,
|
| 316 |
+
tracking_number TEXT NOT NULL,
|
| 317 |
+
shipped_at TEXT NOT NULL
|
| 318 |
+
)
|
| 319 |
+
"""
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
+
orders_data = []
|
| 323 |
+
for i in range(200):
|
| 324 |
+
customer_id = rng.randint(1, 50)
|
| 325 |
+
total_amount = round(rng.uniform(50.0, 500.0), 2)
|
| 326 |
+
orders_data.append((i, customer_id, total_amount))
|
| 327 |
+
|
| 328 |
+
cursor.executemany(
|
| 329 |
+
"""
|
| 330 |
+
INSERT INTO orders (id, customer_id, total_amount)
|
| 331 |
+
VALUES (?, ?, ?)
|
| 332 |
+
""",
|
| 333 |
+
orders_data,
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
order_items_data = []
|
| 337 |
+
for order_id in range(160):
|
| 338 |
+
item_id = order_id
|
| 339 |
+
product_name = f"Product {item_id}"
|
| 340 |
+
quantity = rng.randint(1, 5)
|
| 341 |
+
price = round(rng.uniform(10.0, 100.0), 2)
|
| 342 |
+
order_items_data.append((item_id, order_id, product_name, quantity, price))
|
| 343 |
+
|
| 344 |
+
cursor.executemany(
|
| 345 |
+
"""
|
| 346 |
+
INSERT INTO order_items (id, order_id, product_name, quantity, price)
|
| 347 |
+
VALUES (?, ?, ?, ?, ?)
|
| 348 |
+
""",
|
| 349 |
+
order_items_data,
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
duplicate_data = []
|
| 353 |
+
for order_id in range(160):
|
| 354 |
+
item_id = 160 + order_id
|
| 355 |
+
product_name = f"Duplicate Product {item_id}"
|
| 356 |
+
quantity = rng.randint(1, 5)
|
| 357 |
+
price = round(rng.uniform(10.0, 100.0), 2)
|
| 358 |
+
duplicate_data.append((item_id, order_id, product_name, quantity, price))
|
| 359 |
+
|
| 360 |
+
cursor.executemany(
|
| 361 |
+
"""
|
| 362 |
+
INSERT INTO order_items (id, order_id, product_name, quantity, price)
|
| 363 |
+
VALUES (?, ?, ?, ?, ?)
|
| 364 |
+
""",
|
| 365 |
+
duplicate_data,
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
shipments_data = []
|
| 369 |
+
for shipment_id in range(160):
|
| 370 |
+
tracking_number = f"TRK{shipment_id:06d}"
|
| 371 |
+
shipped_at = (
|
| 372 |
+
f"2024-{rng.randint(1, 12):02d}-{rng.randint(1, 28):02d}"
|
| 373 |
+
f"T{rng.randint(8, 20):02d}:{rng.randint(0, 59):02d}:00"
|
| 374 |
+
)
|
| 375 |
+
shipments_data.append((shipment_id, shipment_id, tracking_number, shipped_at))
|
| 376 |
+
|
| 377 |
+
cursor.executemany(
|
| 378 |
+
"""
|
| 379 |
+
INSERT INTO shipments (id, order_item_id, tracking_number, shipped_at)
|
| 380 |
+
VALUES (?, ?, ?, ?)
|
| 381 |
+
""",
|
| 382 |
+
shipments_data,
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
# Count legitimate rows (unique order_ids) for preservation grading
|
| 386 |
+
clean_count_cursor = conn.execute(
|
| 387 |
+
"SELECT COUNT(DISTINCT order_id) FROM order_items"
|
| 388 |
+
)
|
| 389 |
+
expected_clean_count = clean_count_cursor.fetchone()[0]
|
| 390 |
+
original_row_count = conn.execute("SELECT COUNT(*) FROM order_items").fetchone()[0]
|
| 391 |
+
|
| 392 |
+
schema_info = []
|
| 393 |
+
for table_name, row_count in [
|
| 394 |
+
("orders", 200),
|
| 395 |
+
("order_items", 320),
|
| 396 |
+
("shipments", 160),
|
| 397 |
+
]:
|
| 398 |
+
validated_name = _validate_table_name(table_name)
|
| 399 |
+
cursor.execute(f"PRAGMA table_info({validated_name})")
|
| 400 |
+
columns = [f"{row[1]} ({row[2]})" for row in cursor.fetchall()]
|
| 401 |
+
schema_info.append(
|
| 402 |
+
SchemaInfo(table_name=table_name, columns=columns, row_count=row_count)
|
| 403 |
+
)
|
| 404 |
+
|
| 405 |
+
sample_rows = {}
|
| 406 |
+
sample_rows["orders"] = _extract_sample_rows(conn, "orders", 5)
|
| 407 |
+
|
| 408 |
+
# Deterministic sampling: ensure at least one duplicate pair is visible
|
| 409 |
+
# First, get one order_id that has duplicates
|
| 410 |
+
cursor.execute("""
|
| 411 |
+
SELECT order_id FROM order_items
|
| 412 |
+
GROUP BY order_id HAVING COUNT(*) > 1
|
| 413 |
+
ORDER BY order_id LIMIT 1
|
| 414 |
+
""")
|
| 415 |
+
dup_order_id = cursor.fetchone()
|
| 416 |
+
|
| 417 |
+
if dup_order_id:
|
| 418 |
+
dup_order_id = dup_order_id[0]
|
| 419 |
+
# Get 2 rows with the duplicate order_id (to show the pattern)
|
| 420 |
+
cursor.execute("SELECT * FROM order_items WHERE order_id = ? LIMIT 2", (dup_order_id,))
|
| 421 |
+
dup_rows = cursor.fetchall()
|
| 422 |
+
dup_columns = [description[0] for description in cursor.description]
|
| 423 |
+
|
| 424 |
+
# Fill remaining samples with other rows (ensuring variety)
|
| 425 |
+
remaining = 5 - len(dup_rows)
|
| 426 |
+
cursor.execute("""
|
| 427 |
+
SELECT * FROM order_items
|
| 428 |
+
WHERE order_id != ?
|
| 429 |
+
ORDER BY rowid LIMIT ?
|
| 430 |
+
""", (dup_order_id, remaining))
|
| 431 |
+
other_rows = cursor.fetchall()
|
| 432 |
+
|
| 433 |
+
all_rows = dup_rows + other_rows
|
| 434 |
+
sample_rows["order_items"] = [dict(zip(dup_columns, row)) for row in all_rows]
|
| 435 |
+
else:
|
| 436 |
+
# Fallback to deterministic order if no duplicates found (shouldn't happen)
|
| 437 |
+
cursor.execute("SELECT * FROM order_items ORDER BY rowid LIMIT 5")
|
| 438 |
+
columns = [description[0] for description in cursor.description]
|
| 439 |
+
rows = cursor.fetchall()
|
| 440 |
+
sample_rows["order_items"] = [dict(zip(columns, row)) for row in rows]
|
| 441 |
+
|
| 442 |
+
sample_rows["shipments"] = _extract_sample_rows(conn, "shipments", 5)
|
| 443 |
+
|
| 444 |
+
observation = SQLObservation(
|
| 445 |
+
task_id="dedup_bug",
|
| 446 |
+
task_description="A reporting query that joins two tables is returning more rows than expected based on known business volumes. Inspect the schema and sample data, identify which table contains the data integrity issue and which column is responsible, then submit a fix_query that resolves the duplication.",
|
| 447 |
+
schema_info=schema_info,
|
| 448 |
+
sample_rows=sample_rows,
|
| 449 |
+
attempt_number=1,
|
| 450 |
+
max_attempts=5,
|
| 451 |
+
previous_feedback=None,
|
| 452 |
+
)
|
| 453 |
+
|
| 454 |
+
ground_truth = {
|
| 455 |
+
"root_cause_table": "order_items", # existing
|
| 456 |
+
"offending_key": "order_id", # existing
|
| 457 |
+
"expected_clean_count": expected_clean_count, # rows that SHOULD remain
|
| 458 |
+
"original_row_count": original_row_count, # rows before fix
|
| 459 |
+
"duplicate_count": original_row_count - expected_clean_count, # rows to delete
|
| 460 |
+
"table": "order_items", # NEW — unified alias
|
| 461 |
+
"column": "order_id", # NEW — unified alias
|
| 462 |
+
"task_type": "dedup_bug", # NEW
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
return conn, observation, ground_truth
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
def create_fk_integrity_task(
|
| 469 |
+
seed: int = 42,
|
| 470 |
+
) -> Tuple[sqlite3.Connection, SQLObservation, Dict[str, Any]]:
|
| 471 |
+
"""Create FK integrity task with orphan child rows in invoices.customer_id."""
|
| 472 |
+
import random
|
| 473 |
+
|
| 474 |
+
rng = random.Random(seed)
|
| 475 |
+
conn = sqlite3.connect(":memory:", check_same_thread=False)
|
| 476 |
+
cursor = conn.cursor()
|
| 477 |
+
|
| 478 |
+
cursor.execute(
|
| 479 |
+
"""
|
| 480 |
+
CREATE TABLE customers (
|
| 481 |
+
id INTEGER PRIMARY KEY,
|
| 482 |
+
name TEXT NOT NULL,
|
| 483 |
+
segment TEXT NOT NULL,
|
| 484 |
+
created_at TEXT NOT NULL
|
| 485 |
+
)
|
| 486 |
+
"""
|
| 487 |
+
)
|
| 488 |
+
cursor.execute(
|
| 489 |
+
"""
|
| 490 |
+
CREATE TABLE invoices (
|
| 491 |
+
id INTEGER PRIMARY KEY,
|
| 492 |
+
customer_id INTEGER NOT NULL,
|
| 493 |
+
amount REAL NOT NULL,
|
| 494 |
+
status TEXT NOT NULL,
|
| 495 |
+
issued_at TEXT NOT NULL
|
| 496 |
+
)
|
| 497 |
+
"""
|
| 498 |
+
)
|
| 499 |
+
|
| 500 |
+
customer_rows = []
|
| 501 |
+
for i in range(1, 401):
|
| 502 |
+
customer_rows.append(
|
| 503 |
+
(
|
| 504 |
+
i,
|
| 505 |
+
f"Customer_{i}",
|
| 506 |
+
rng.choice(["enterprise", "mid_market", "smb"]),
|
| 507 |
+
f"2024-{rng.randint(1,12):02d}-{rng.randint(1,28):02d}",
|
| 508 |
+
)
|
| 509 |
+
)
|
| 510 |
+
cursor.executemany(
|
| 511 |
+
"INSERT INTO customers (id, name, segment, created_at) VALUES (?, ?, ?, ?)",
|
| 512 |
+
customer_rows,
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
invoice_rows = []
|
| 516 |
+
for i in range(1, 1201):
|
| 517 |
+
invoice_rows.append(
|
| 518 |
+
(
|
| 519 |
+
i,
|
| 520 |
+
rng.randint(1, 400),
|
| 521 |
+
round(rng.uniform(25.0, 2500.0), 2),
|
| 522 |
+
rng.choice(["issued", "paid", "overdue"]),
|
| 523 |
+
f"2025-{rng.randint(1,12):02d}-{rng.randint(1,28):02d}",
|
| 524 |
+
)
|
| 525 |
+
)
|
| 526 |
+
|
| 527 |
+
orphan_invoice_ids = set(rng.sample(range(1, 1201), 180))
|
| 528 |
+
rewritten_rows = []
|
| 529 |
+
valid_invoice_ids = []
|
| 530 |
+
for row in invoice_rows:
|
| 531 |
+
invoice_id, customer_id, amount, status, issued_at = row
|
| 532 |
+
if invoice_id in orphan_invoice_ids:
|
| 533 |
+
rewritten_rows.append(
|
| 534 |
+
(invoice_id, 10000 + rng.randint(1, 1000), amount, status, issued_at)
|
| 535 |
+
)
|
| 536 |
+
else:
|
| 537 |
+
rewritten_rows.append(row)
|
| 538 |
+
valid_invoice_ids.append(invoice_id)
|
| 539 |
+
|
| 540 |
+
cursor.executemany(
|
| 541 |
+
"INSERT INTO invoices (id, customer_id, amount, status, issued_at) VALUES (?, ?, ?, ?, ?)",
|
| 542 |
+
rewritten_rows,
|
| 543 |
+
)
|
| 544 |
+
|
| 545 |
+
schema_info = []
|
| 546 |
+
for table_name, row_count in [("customers", 400), ("invoices", 1200)]:
|
| 547 |
+
cursor.execute(f"PRAGMA table_info({table_name})")
|
| 548 |
+
columns = [f"{row[1]} ({row[2]})" for row in cursor.fetchall()]
|
| 549 |
+
schema_info.append(
|
| 550 |
+
SchemaInfo(table_name=table_name, columns=columns, row_count=row_count)
|
| 551 |
+
)
|
| 552 |
+
|
| 553 |
+
sample_rows = {
|
| 554 |
+
"customers": _extract_sample_rows(conn, "customers", 5),
|
| 555 |
+
"invoices": _extract_sample_rows(conn, "invoices", 5),
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
observation = SQLObservation(
|
| 559 |
+
task_id="fk_integrity",
|
| 560 |
+
task_description=(
|
| 561 |
+
"A billing pipeline shows referential integrity drift between parent and child records. "
|
| 562 |
+
"Inspect the schema and samples, then submit a fix_query that removes orphan relationships "
|
| 563 |
+
"while preserving valid invoice records."
|
| 564 |
+
),
|
| 565 |
+
schema_info=schema_info,
|
| 566 |
+
sample_rows=sample_rows,
|
| 567 |
+
attempt_number=1,
|
| 568 |
+
max_attempts=4,
|
| 569 |
+
previous_feedback=None,
|
| 570 |
+
)
|
| 571 |
+
|
| 572 |
+
ground_truth = {
|
| 573 |
+
"task_type": "fk_integrity",
|
| 574 |
+
"table": "invoices",
|
| 575 |
+
"column": "customer_id",
|
| 576 |
+
"parent_table": "customers",
|
| 577 |
+
"parent_key": "id",
|
| 578 |
+
"expected_clean_count": len(valid_invoice_ids),
|
| 579 |
+
"valid_invoice_ids": valid_invoice_ids,
|
| 580 |
+
"orphan_count": len(orphan_invoice_ids),
|
| 581 |
+
"preserve_columns": ["id", "customer_id", "amount", "status", "issued_at"],
|
| 582 |
+
}
|
| 583 |
+
return conn, observation, ground_truth
|
| 584 |
+
|
| 585 |
+
|
| 586 |
+
def create_schema_drift_task(
|
| 587 |
+
seed: int = 42,
|
| 588 |
+
) -> Tuple[sqlite3.Connection, SQLObservation, Dict[str, Any]]:
|
| 589 |
+
"""Create schema drift task with two upstream versions that need canonicalization."""
|
| 590 |
+
import random
|
| 591 |
+
|
| 592 |
+
rng = random.Random(seed)
|
| 593 |
+
conn = sqlite3.connect(":memory:", check_same_thread=False)
|
| 594 |
+
cursor = conn.cursor()
|
| 595 |
+
|
| 596 |
+
cursor.execute(
|
| 597 |
+
"""
|
| 598 |
+
CREATE TABLE events_v1 (
|
| 599 |
+
id INTEGER PRIMARY KEY,
|
| 600 |
+
user_id INTEGER NOT NULL,
|
| 601 |
+
event_name TEXT NOT NULL,
|
| 602 |
+
amount_text TEXT NOT NULL,
|
| 603 |
+
event_time TEXT NOT NULL
|
| 604 |
+
)
|
| 605 |
+
"""
|
| 606 |
+
)
|
| 607 |
+
cursor.execute(
|
| 608 |
+
"""
|
| 609 |
+
CREATE TABLE events_v2 (
|
| 610 |
+
event_id INTEGER PRIMARY KEY,
|
| 611 |
+
account_id INTEGER NOT NULL,
|
| 612 |
+
event_type TEXT NOT NULL,
|
| 613 |
+
amount REAL NOT NULL,
|
| 614 |
+
event_ts TEXT NOT NULL,
|
| 615 |
+
source TEXT NOT NULL
|
| 616 |
+
)
|
| 617 |
+
"""
|
| 618 |
+
)
|
| 619 |
+
|
| 620 |
+
v1_rows = []
|
| 621 |
+
for i in range(1, 601):
|
| 622 |
+
amount = round(rng.uniform(5.0, 500.0), 2)
|
| 623 |
+
v1_rows.append(
|
| 624 |
+
(
|
| 625 |
+
i,
|
| 626 |
+
rng.randint(1, 300),
|
| 627 |
+
rng.choice(["purchase", "refund", "chargeback"]),
|
| 628 |
+
f"{amount:.2f}",
|
| 629 |
+
f"2025-{rng.randint(1,12):02d}-{rng.randint(1,28):02d}T{rng.randint(0,23):02d}:{rng.randint(0,59):02d}:00",
|
| 630 |
+
)
|
| 631 |
+
)
|
| 632 |
+
cursor.executemany(
|
| 633 |
+
"INSERT INTO events_v1 (id, user_id, event_name, amount_text, event_time) VALUES (?, ?, ?, ?, ?)",
|
| 634 |
+
v1_rows,
|
| 635 |
+
)
|
| 636 |
+
|
| 637 |
+
v2_rows = []
|
| 638 |
+
for i in range(601, 1201):
|
| 639 |
+
v2_rows.append(
|
| 640 |
+
(
|
| 641 |
+
i,
|
| 642 |
+
rng.randint(1, 300),
|
| 643 |
+
rng.choice(["purchase", "refund", "chargeback"]),
|
| 644 |
+
round(rng.uniform(5.0, 500.0), 2),
|
| 645 |
+
f"2025-{rng.randint(1,12):02d}-{rng.randint(1,28):02d}T{rng.randint(0,23):02d}:{rng.randint(0,59):02d}:00",
|
| 646 |
+
rng.choice(["batch_a", "batch_b"]),
|
| 647 |
+
)
|
| 648 |
+
)
|
| 649 |
+
cursor.executemany(
|
| 650 |
+
"INSERT INTO events_v2 (event_id, account_id, event_type, amount, event_ts, source) VALUES (?, ?, ?, ?, ?, ?)",
|
| 651 |
+
v2_rows,
|
| 652 |
+
)
|
| 653 |
+
|
| 654 |
+
schema_info = []
|
| 655 |
+
for table_name, row_count in [("events_v1", 600), ("events_v2", 600)]:
|
| 656 |
+
cursor.execute(f"PRAGMA table_info({table_name})")
|
| 657 |
+
columns = [f"{row[1]} ({row[2]})" for row in cursor.fetchall()]
|
| 658 |
+
schema_info.append(
|
| 659 |
+
SchemaInfo(table_name=table_name, columns=columns, row_count=row_count)
|
| 660 |
+
)
|
| 661 |
+
|
| 662 |
+
sample_rows = {
|
| 663 |
+
"events_v1": _extract_sample_rows(conn, "events_v1", 5),
|
| 664 |
+
"events_v2": _extract_sample_rows(conn, "events_v2", 5),
|
| 665 |
+
}
|
| 666 |
+
|
| 667 |
+
observation = SQLObservation(
|
| 668 |
+
task_id="schema_drift",
|
| 669 |
+
task_description=(
|
| 670 |
+
"Two upstream event producers emit the same business concept with drifted schemas. "
|
| 671 |
+
"Create a canonical_events artifact that normalizes both streams into one aligned schema "
|
| 672 |
+
"with numeric amounts and consistent timestamps."
|
| 673 |
+
),
|
| 674 |
+
schema_info=schema_info,
|
| 675 |
+
sample_rows=sample_rows,
|
| 676 |
+
attempt_number=1,
|
| 677 |
+
max_attempts=4,
|
| 678 |
+
previous_feedback=None,
|
| 679 |
+
)
|
| 680 |
+
|
| 681 |
+
ground_truth = {
|
| 682 |
+
"task_type": "schema_drift",
|
| 683 |
+
"table": "canonical_events",
|
| 684 |
+
"column": "amount",
|
| 685 |
+
"source_tables": ["events_v1", "events_v2"],
|
| 686 |
+
"expected_columns": ["event_id", "user_id", "event_type", "amount", "event_time"],
|
| 687 |
+
"expected_row_count": 1200,
|
| 688 |
+
"preserve_columns": ["event_id", "user_id", "event_type", "amount", "event_time"],
|
| 689 |
+
}
|
| 690 |
+
return conn, observation, ground_truth
|