Abhishek Tiwari commited on
Commit
6d2ec98
·
1 Parent(s): e0a6d43

fix: restore UI, add /tasks /grade, clamp scores to (0.05, 0.95)

Browse files
Files changed (4) hide show
  1. inference.py +128 -86
  2. openenv.yaml +54 -23
  3. server/app.py +361 -33
  4. server/environment.py +377 -222
inference.py CHANGED
@@ -7,80 +7,114 @@ from openai import OpenAI
7
  API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
8
  API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY", "")
9
  MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
10
- ENV_URL = os.getenv("ENV_URL", "http://localhost:8000") # defaults to common uvicorn port but user specified 7860
11
  MAX_STEPS = 6
12
  TEMPERATURE = 0.1
13
  MAX_TOKENS = 500
14
 
15
  client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
16
 
17
- SYSTEM_PROMPT = """You are an expert SQL developer.
18
- For write_query tasks: write a correct SQL SELECT query.
19
- For fix_query tasks: fix the broken SQL provided.
20
- For optimize_query tasks: rewrite slow SQL using CTEs or JOINs instead of subqueries.
21
 
22
- ALWAYS respond with ONLY a JSON object like this:
23
- {"action_type": "write_query", "sql": "SELECT ...", "explanation": "reason"}
 
 
 
24
 
25
- Rules: Only SELECT allowed. No DROP DELETE INSERT UPDATE CREATE ALTER."""
 
26
 
27
- def build_prompt(obs: dict) -> str:
28
- obs_data = obs.get("observation", obs)
29
- task_desc = obs_data.get("task_description", "")
30
- schema_info = obs_data.get("schema_info", "")
31
- sample_data = obs_data.get("sample_data", "")
32
- hints = obs_data.get("metadata", [])
33
- last_sql = obs_data.get("last_sql", "")
34
- last_result = obs_data.get("last_result", "")
35
- last_error = obs_data.get("last_error", "")
36
- step_count = obs_data.get("step_count", 0)
37
- feedback = obs_data.get("feedback", "")
38
 
39
- prompt = f"Task Description:\n{task_desc}\n\nSchema Info:\n{schema_info}\n\nSample Data:\n{sample_data}\n"
 
 
 
 
 
 
 
 
40
  if hints:
41
- prompt += f"\nHints: {', '.join(hints)}\n"
 
 
42
  if last_sql:
43
- prompt += f"\nLast SQL Submitted: {last_sql}\n"
 
 
44
  if last_result:
45
- prompt += f"Result of Last SQL: {last_result}\n"
 
 
46
  if last_error:
47
- prompt += f"Error Message: {last_error}\n"
48
- if step_count > 0 and feedback:
49
- prompt += f"Feedback from Grader: {feedback}\n"
 
 
 
 
 
 
50
 
51
- prompt += "\nRespond with JSON action only."
52
- return prompt
53
 
54
  def parse_action(response_text: str, task_type: str) -> dict:
 
55
  try:
56
- return json.loads(response_text)
57
  except json.JSONDecodeError:
58
- json_match = re.search(r'\{.*\}', response_text, re.DOTALL)
59
- if json_match:
60
- try:
61
- return json.loads(json_match.group(0))
62
- except json.JSONDecodeError:
63
- pass
64
-
65
- sql_match = re.search(r'(?i)SELECT\s+.*', response_text, re.DOTALL)
66
- sql = sql_match.group(0).strip() if sql_match else "SELECT 1"
67
- if sql.endswith('```'): sql = sql[:-3].strip()
68
-
69
- return {"action_type": task_type, "sql": sql, "explanation": "Fallback parsed"}
 
 
 
 
70
 
71
  def run_episode(task_id: str) -> dict:
72
- res = requests.post(f"{ENV_URL}/reset", json={"task_id": task_id})
73
- if res.status_code != 200:
 
 
 
 
 
74
  return {"task_id": task_id, "best_reward": 0.05}
75
- obs_obj = res.json()
 
 
 
 
 
76
 
77
- best_reward = 0.05
78
- for step in range(MAX_STEPS):
79
- obs = obs_obj.get("observation", obs_obj)
80
  if obs.get("done", False):
 
81
  break
82
 
83
- prompt = build_prompt(obs_obj)
84
 
85
  try:
86
  completion = client.chat.completions.create(
@@ -92,73 +126,81 @@ def run_episode(task_id: str) -> dict:
92
  temperature=TEMPERATURE,
93
  max_tokens=MAX_TOKENS
94
  )
95
- response_text = completion.choices[0].message.content or "{}"
96
- except Exception:
97
- response_text = "{}"
 
98
 
99
  action = parse_action(response_text, obs.get("task_type", "write_query"))
100
 
101
- res = requests.post(f"{ENV_URL}/step", json=action)
102
- if res.status_code != 200:
103
- break
104
- obs_obj = res.json()
105
- reward = obs_obj.get("reward", 0.05)
106
 
 
 
 
 
 
 
 
 
 
107
  best_reward = max(best_reward, reward)
108
- print(f"Step {step+1}: SQL={action.get('sql', '')[:60]}... Reward={reward:.3f} Feedback={obs_obj.get('observation', {}).get('feedback', '')}")
 
 
 
109
 
110
- if obs_obj.get("done", obs_obj.get("observation", {}).get("done", False)):
111
  break
112
 
113
  return {"task_id": task_id, "best_reward": round(best_reward, 3)}
114
 
115
  def main():
116
- print(f"--- SQL Agent Inference ---")
117
- print(f"Model: {MODEL_NAME}")
118
- print(f"Env URL: {ENV_URL}")
 
 
119
 
120
- health = {"status": "unreachable"}
121
  try:
122
- health = requests.get(f"{ENV_URL}/health").json()
123
- except Exception:
124
- pass
125
- print(f"Health: {health}")
126
-
127
  task_ids = ["easy_01", "easy_02", "medium_01", "medium_02", "hard_01", "hard_02"]
128
  results = []
129
 
130
- for tid in task_ids:
131
- print(f"\n=== Task: {tid} ===")
132
  try:
133
  res = run_episode(tid)
134
  except Exception as e:
 
135
  res = {"task_id": tid, "best_reward": 0.05}
136
- print(f"Error running task {tid}: {e}")
137
  results.append(res)
138
 
139
- print("\n=== FINAL SCORES ===")
140
- easy_scores = []
141
- medium_scores = []
142
- hard_scores = []
143
 
144
  for r in results:
145
- t = r["task_id"]
146
- v = r["best_reward"]
147
- print(f"{t}: {v}")
148
- if t.startswith("easy"): easy_scores.append(v)
149
- elif t.startswith("medium"): medium_scores.append(v)
150
- elif t.startswith("hard"): hard_scores.append(v)
151
 
 
 
 
 
152
  easy_avg = sum(easy_scores)/len(easy_scores) if easy_scores else 0.0
153
  medium_avg = sum(medium_scores)/len(medium_scores) if medium_scores else 0.0
154
  hard_avg = sum(hard_scores)/len(hard_scores) if hard_scores else 0.0
155
- overall = (easy_avg + medium_avg + hard_avg) / 3.0
156
 
157
- print("\n--- AVERAGES ---")
158
- print(f"Easy Average: {easy_avg:.3f}")
159
- print(f"Medium Average: {medium_avg:.3f}")
160
- print(f"Hard Average: {hard_avg:.3f}")
161
- print(f"Overall Score: {overall:.3f}")
162
 
163
  if __name__ == "__main__":
164
  main()
 
7
  API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
8
  API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY", "")
9
  MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
10
+ ENV_URL = os.getenv("ENV_URL", "http://localhost:7860")
11
  MAX_STEPS = 6
12
  TEMPERATURE = 0.1
13
  MAX_TOKENS = 500
14
 
15
  client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
16
 
17
+ SYSTEM_PROMPT = """You are an expert SQL developer working with a SQLite database.
 
 
 
18
 
19
+ Your job depends on the task type:
20
+ - write_query: Write a correct SQL SELECT query from scratch
21
+ - fix_query: You are shown broken SQL. Fix it so it runs correctly
22
+ - optimize_query: You are shown slow SQL. Rewrite it using CTEs (WITH clause)
23
+ or window functions (ROW_NUMBER, RANK) instead of correlated subqueries
24
 
25
+ RESPONSE FORMAT always respond with ONLY this JSON:
26
+ {"action_type": "write_query", "sql": "SELECT ...", "explanation": "brief reason"}
27
 
28
+ RULES:
29
+ - Only SELECT statements are allowed
30
+ - No DROP, DELETE, INSERT, UPDATE, CREATE, ALTER, TRUNCATE
31
+ - For fix_query: keep the same intent, just fix the bugs
32
+ - For optimize_query: use WITH clause or window functions
33
+ - Respond ONLY with the JSON object, no other text"""
34
+
35
+ def build_prompt(obs: dict, step: int, history: list) -> str:
36
+ parts = []
37
+ parts.append(f"=== TASK ===\n{obs.get('task_description', '')}")
 
38
 
39
+ schema = obs.get('schema_info', '')
40
+ if schema:
41
+ parts.append(f"=== DATABASE SCHEMA ===\n{chr(10).join(schema.split(chr(10))[:30])}")
42
+
43
+ data = obs.get('sample_data', '')
44
+ if data:
45
+ parts.append(f"=== SAMPLE DATA ===\n{chr(10).join(data.split(chr(10))[:20])}")
46
+
47
+ hints = obs.get('expected_description', '')
48
  if hints:
49
+ parts.append(f"=== HINTS ===\n{hints}")
50
+
51
+ last_sql = obs.get('last_sql')
52
  if last_sql:
53
+ parts.append(f"=== YOUR PREVIOUS SQL ===\n{last_sql}")
54
+
55
+ last_result = obs.get('last_result')
56
  if last_result:
57
+ parts.append(f"=== RESULT OF PREVIOUS SQL ===\n{last_result}")
58
+
59
+ last_error = obs.get('last_error')
60
  if last_error:
61
+ parts.append(f"=== ERROR ===\n{last_error}")
62
+
63
+ feedback = obs.get('feedback', '')
64
+ if feedback and step > 1:
65
+ parts.append(f"=== GRADER FEEDBACK ===\n{feedback}")
66
+
67
+ if history:
68
+ hist_str = "\n".join(history[-3:])
69
+ parts.append(f"=== RECENT HISTORY ===\n{hist_str}")
70
 
71
+ parts.append("Respond with ONLY a JSON action object.")
72
+ return "\n\n".join(parts)
73
 
74
  def parse_action(response_text: str, task_type: str) -> dict:
75
+ text = response_text.strip()
76
  try:
77
+ return json.loads(text)
78
  except json.JSONDecodeError:
79
+ pass
80
+
81
+ match = re.search(r'\{[^{}]+\}', text)
82
+ if match:
83
+ try:
84
+ return json.loads(match.group(0))
85
+ except json.JSONDecodeError:
86
+ pass
87
+
88
+ sql_match = re.search(r'(?:SELECT|WITH).+', text, re.DOTALL | re.IGNORECASE)
89
+ if sql_match:
90
+ sql = sql_match.group(0).strip()
91
+ if sql.endswith("```"): sql = sql[:-3].strip()
92
+ return {"action_type": task_type, "sql": sql, "explanation": "Regex parsed"}
93
+
94
+ return {"action_type": task_type, "sql": "SELECT 1", "explanation": "parse failed"}
95
 
96
  def run_episode(task_id: str) -> dict:
97
+ try:
98
+ res = requests.post(f"{ENV_URL}/reset", json={"task_id": task_id, "difficulty": None})
99
+ res.raise_for_status()
100
+ obs_obj = res.json()
101
+ obs = obs_obj["observation"]
102
+ except Exception as e:
103
+ print(f" Error resetting environment: {e}")
104
  return {"task_id": task_id, "best_reward": 0.05}
105
+
106
+ print(f" Task: {task_id} | {obs.get('task_type', '')}")
107
+ print(f" Desc: {obs.get('task_description', '')[:100]}...")
108
+
109
+ best_reward = 0.0
110
+ history = []
111
 
112
+ for step in range(1, MAX_STEPS + 1):
 
 
113
  if obs.get("done", False):
114
+ print(f" Done at step {step}")
115
  break
116
 
117
+ prompt = build_prompt(obs, step, history)
118
 
119
  try:
120
  completion = client.chat.completions.create(
 
126
  temperature=TEMPERATURE,
127
  max_tokens=MAX_TOKENS
128
  )
129
+ response_text = completion.choices[0].message.content or ""
130
+ except Exception as e:
131
+ print(f" API Error: {e}")
132
+ response_text = ""
133
 
134
  action = parse_action(response_text, obs.get("task_type", "write_query"))
135
 
136
+ print(f" Step {step}: {action.get('sql', '')[:70]}...")
 
 
 
 
137
 
138
+ try:
139
+ step_res = requests.post(f"{ENV_URL}/step", json=action)
140
+ step_res.raise_for_status()
141
+ step_data = step_res.json()
142
+ except Exception as e:
143
+ print(f" Env Step Error: {e}")
144
+ break
145
+
146
+ reward = step_data.get("reward", 0.05)
147
  best_reward = max(best_reward, reward)
148
+ obs = step_data.get("observation", {})
149
+
150
+ history.append(f"Step {step}: reward={reward:.3f} | {obs.get('feedback', '')[:60]}")
151
+ print(f" Reward: {reward:.3f} | Done: {obs.get('done', False)} | {obs.get('feedback', '')[:60]}")
152
 
153
+ if obs.get("done", False):
154
  break
155
 
156
  return {"task_id": task_id, "best_reward": round(best_reward, 3)}
157
 
158
  def main():
159
+ print("=" * 65)
160
+ print("SQL Debugger OpenEnv — Baseline Inference")
161
+ print(f"Model : {MODEL_NAME}")
162
+ print(f"Env : {ENV_URL}")
163
+ print("=" * 65)
164
 
 
165
  try:
166
+ health_res = requests.get(f"{ENV_URL}/health")
167
+ print(f"Health: {health_res.json()}")
168
+ except Exception as e:
169
+ print(f"Health Check Failed: {e}")
170
+
171
  task_ids = ["easy_01", "easy_02", "medium_01", "medium_02", "hard_01", "hard_02"]
172
  results = []
173
 
174
+ for i, tid in enumerate(task_ids):
175
+ print(f"\\n[{i+1}/6] Running {tid}...")
176
  try:
177
  res = run_episode(tid)
178
  except Exception as e:
179
+ print(f" Error running {tid}: {e}")
180
  res = {"task_id": tid, "best_reward": 0.05}
 
181
  results.append(res)
182
 
183
+ print("\n" + "=" * 65)
184
+ print("BASELINE SCORES")
185
+ print("=" * 65)
 
186
 
187
  for r in results:
188
+ print(f" {r['task_id']:15s}: {r['best_reward']:.3f}")
 
 
 
 
 
189
 
190
+ easy_scores = [r['best_reward'] for r in results if r['task_id'].startswith('easy')]
191
+ medium_scores = [r['best_reward'] for r in results if r['task_id'].startswith('medium')]
192
+ hard_scores = [r['best_reward'] for r in results if r['task_id'].startswith('hard')]
193
+
194
  easy_avg = sum(easy_scores)/len(easy_scores) if easy_scores else 0.0
195
  medium_avg = sum(medium_scores)/len(medium_scores) if medium_scores else 0.0
196
  hard_avg = sum(hard_scores)/len(hard_scores) if hard_scores else 0.0
197
+ overall = sum(r['best_reward'] for r in results) / len(results) if results else 0.0
198
 
199
+ print(f"\\nEasy average : {easy_avg:.3f}")
200
+ print(f"Medium average : {medium_avg:.3f}")
201
+ print(f"Hard average : {hard_avg:.3f}")
202
+ print(f"Overall average : {overall:.3f}")
203
+ print("=" * 65)
204
 
205
  if __name__ == "__main__":
206
  main()
openenv.yaml CHANGED
@@ -1,9 +1,10 @@
1
  name: sql-debugger-env
2
  version: "1.0.0"
3
  description: >
4
- An RL environment for training AI agents to write, fix, and optimize
5
- SQL queries against a real SQLite database with employees, departments,
6
- projects and project assignments tables.
 
7
 
8
  tasks:
9
  - id: easy_01
@@ -11,42 +12,54 @@ tasks:
11
  difficulty: easy
12
  type: write_query
13
  grader: true
14
- description: "Find Engineering employees with salary above 90000, return name and salary ordered by salary DESC"
 
 
15
 
16
  - id: easy_02
17
  name: Count by department
18
  difficulty: easy
19
  type: write_query
20
  grader: true
21
- description: "Count employees per department, return department and count ordered by count DESC"
 
 
22
 
23
  - id: medium_01
24
  name: Fix broken JOIN
25
  difficulty: medium
26
  type: fix_query
27
  grader: true
28
- description: "Fix the broken JOIN query missing ON keyword and using wrong table alias in WHERE"
 
 
29
 
30
  - id: medium_02
31
  name: Fix wrong GROUP BY
32
  difficulty: medium
33
  type: fix_query
34
  grader: true
35
- description: "Fix the GROUP BY clause that incorrectly groups by id instead of department"
 
 
36
 
37
  - id: hard_01
38
  name: Optimize correlated subquery
39
  difficulty: hard
40
  type: optimize_query
41
  grader: true
42
- description: "Replace correlated subqueries with window functions or CTEs to find top earner per department"
 
 
43
 
44
  - id: hard_02
45
  name: Eliminate N+1 problem
46
  difficulty: hard
47
  type: optimize_query
48
  grader: true
49
- description: "Rewrite N+1 correlated subquery using LEFT JOIN and GROUP BY"
 
 
50
 
51
  action_space:
52
  type: object
@@ -54,28 +67,46 @@ action_space:
54
  action_type:
55
  type: string
56
  enum: [write_query, fix_query, optimize_query]
 
57
  sql:
58
  type: string
59
- description: SQL SELECT statement
60
  explanation:
61
  type: string
62
- description: Optional reasoning
63
 
64
  observation_space:
65
  type: object
66
  properties:
67
- task_id: {type: string}
68
- task_type: {type: string}
69
- task_description: {type: string}
70
- schema_info: {type: string}
71
- sample_data: {type: string}
72
- last_sql: {type: string}
73
- last_result: {type: string}
74
- last_error: {type: string}
75
- step_count: {type: integer}
76
- done: {type: boolean}
77
- reward: {type: number, minimum: 0.05, maximum: 0.95}
78
- feedback: {type: string}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
  reward_range: [0.05, 0.95]
81
  max_steps_per_episode: 8
 
1
  name: sql-debugger-env
2
  version: "1.0.0"
3
  description: >
4
+ An RL environment for training AI agents to write, debug, and optimize
5
+ SQL queries against a real SQLite database. Three task types cover
6
+ real data analyst work: writing queries from specs, fixing broken SQL,
7
+ and optimizing slow correlated subqueries.
8
 
9
  tasks:
10
  - id: easy_01
 
12
  difficulty: easy
13
  type: write_query
14
  grader: true
15
+ description: >
16
+ Find all employees in the Engineering department with salary above
17
+ 90000. Return name and salary ordered by salary descending.
18
 
19
  - id: easy_02
20
  name: Count by department
21
  difficulty: easy
22
  type: write_query
23
  grader: true
24
+ description: >
25
+ Count how many employees are in each department. Return department
26
+ name and count ordered by count descending.
27
 
28
  - id: medium_01
29
  name: Fix broken JOIN
30
  difficulty: medium
31
  type: fix_query
32
  grader: true
33
+ description: >
34
+ Fix the broken query that returns employees on active projects.
35
+ Bugs: missing ON keyword in JOIN, wrong table alias in WHERE clause.
36
 
37
  - id: medium_02
38
  name: Fix wrong GROUP BY
39
  difficulty: medium
40
  type: fix_query
41
  grader: true
42
+ description: >
43
+ Fix the query that computes average salary per department but
44
+ incorrectly groups by id instead of department.
45
 
46
  - id: hard_01
47
  name: Optimize correlated subquery
48
  difficulty: hard
49
  type: optimize_query
50
  grader: true
51
+ description: >
52
+ Replace correlated subqueries with window functions or CTEs to
53
+ find the top earner per department with total hours worked.
54
 
55
  - id: hard_02
56
  name: Eliminate N+1 problem
57
  difficulty: hard
58
  type: optimize_query
59
  grader: true
60
+ description: >
61
+ Rewrite the N+1 correlated subquery using LEFT JOIN and GROUP BY
62
+ to get department stats in a single efficient query.
63
 
64
  action_space:
65
  type: object
 
67
  action_type:
68
  type: string
69
  enum: [write_query, fix_query, optimize_query]
70
+ description: The type of SQL action being taken
71
  sql:
72
  type: string
73
+ description: A valid SQLite SELECT statement
74
  explanation:
75
  type: string
76
+ description: Optional explanation of reasoning
77
 
78
  observation_space:
79
  type: object
80
  properties:
81
+ task_id:
82
+ type: string
83
+ task_type:
84
+ type: string
85
+ task_description:
86
+ type: string
87
+ schema_info:
88
+ type: string
89
+ description: DDL schema of all tables
90
+ sample_data:
91
+ type: string
92
+ description: First 3 rows of each table
93
+ last_sql:
94
+ type: string
95
+ last_result:
96
+ type: string
97
+ last_error:
98
+ type: string
99
+ step_count:
100
+ type: integer
101
+ done:
102
+ type: boolean
103
+ reward:
104
+ type: number
105
+ minimum: 0.05
106
+ maximum: 0.95
107
+ description: Strictly between 0 and 1, never exactly 0.0 or 1.0
108
+ feedback:
109
+ type: string
110
 
111
  reward_range: [0.05, 0.95]
112
  max_steps_per_episode: 8
server/app.py CHANGED
@@ -1,12 +1,20 @@
1
- from fastapi import FastAPI
 
2
  from pydantic import BaseModel
3
- from typing import Optional, Dict, Any
 
 
4
 
5
  from server.environment import SQLEnvironment
6
 
7
- app = FastAPI(title="SQL Debugger OpenEnv")
 
 
 
8
  env = SQLEnvironment()
9
 
 
 
10
  class ResetRequest(BaseModel):
11
  task_id: Optional[str] = None
12
  difficulty: Optional[str] = None
@@ -21,8 +29,10 @@ class GradeRequest(BaseModel):
21
  task_id: str
22
  action: dict
23
 
 
 
24
  @app.get("/")
25
- def read_root():
26
  return {
27
  "info": "SQL Agent Environment API",
28
  "version": "1.0.0",
@@ -31,28 +41,29 @@ def read_root():
31
  }
32
 
33
  @app.get("/health")
34
- def read_health():
35
  return {"status": "healthy"}
36
 
37
- @app.get("/state")
38
- def read_state():
39
- return env.state
40
-
41
  @app.post("/reset")
42
- def do_reset(req: Optional[ResetRequest] = None):
43
- if req:
44
- obs = env.reset(task_id=req.task_id, difficulty=req.difficulty)
45
- else:
46
- obs = env.reset()
47
- return obs
48
 
49
  @app.post("/step")
50
- def do_step(req: StepRequest):
51
- obs = env.step(req.model_dump() if hasattr(req, "model_dump") else req.dict())
52
- return obs
 
 
 
 
 
53
 
54
  @app.get("/tasks")
55
  def list_tasks():
 
 
 
 
56
  return {
57
  "tasks": [
58
  {
@@ -61,7 +72,7 @@ def list_tasks():
61
  "difficulty": "easy",
62
  "type": "write_query",
63
  "grader": True,
64
- "description": "Find all employees in Engineering with salary above 90000"
65
  },
66
  {
67
  "id": "easy_02",
@@ -69,7 +80,7 @@ def list_tasks():
69
  "difficulty": "easy",
70
  "type": "write_query",
71
  "grader": True,
72
- "description": "Count employees per department ordered by count"
73
  },
74
  {
75
  "id": "medium_01",
@@ -77,7 +88,7 @@ def list_tasks():
77
  "difficulty": "medium",
78
  "type": "fix_query",
79
  "grader": True,
80
- "description": "Fix the broken JOIN query for active project assignments"
81
  },
82
  {
83
  "id": "medium_02",
@@ -85,7 +96,7 @@ def list_tasks():
85
  "difficulty": "medium",
86
  "type": "fix_query",
87
  "grader": True,
88
- "description": "Fix the GROUP BY clause to aggregate by department"
89
  },
90
  {
91
  "id": "hard_01",
@@ -93,7 +104,7 @@ def list_tasks():
93
  "difficulty": "hard",
94
  "type": "optimize_query",
95
  "grader": True,
96
- "description": "Replace correlated subqueries with window functions"
97
  },
98
  {
99
  "id": "hard_02",
@@ -101,24 +112,341 @@ def list_tasks():
101
  "difficulty": "hard",
102
  "type": "optimize_query",
103
  "grader": True,
104
- "description": "Rewrite N+1 query using LEFT JOIN and GROUP BY"
105
  }
106
  ]
107
  }
108
 
109
  @app.post("/grade")
110
- def do_grade(req: GradeRequest):
111
- env.reset(task_id=req.task_id)
112
- step_result = env.step(req.action)
 
 
 
 
 
 
 
 
 
 
113
 
114
- raw_score = step_result.get("reward", 0.05)
115
- score = max(0.05, min(0.95, float(raw_score)))
116
- score = round(score, 4)
117
 
118
  return {
119
  "task_id": req.task_id,
120
  "score": score,
121
- "feedback": step_result["observation"]["feedback"],
122
- "done": step_result["done"],
123
- "info": step_result.get("info", {})
124
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
2
+ from fastapi.responses import HTMLResponse
3
  from pydantic import BaseModel
4
+ from typing import Optional
5
+ import json
6
+ import asyncio
7
 
8
  from server.environment import SQLEnvironment
9
 
10
+ app = FastAPI(title="SQL Debugger OpenEnv", version="1.0.0")
11
+
12
+ # One environment instance per HTTP session (stateless endpoints)
13
+ # WebSocket sessions get their own instance
14
  env = SQLEnvironment()
15
 
16
+ # ── Pydantic models ───────────────────────────────────────────────────────────
17
+
18
  class ResetRequest(BaseModel):
19
  task_id: Optional[str] = None
20
  difficulty: Optional[str] = None
 
29
  task_id: str
30
  action: dict
31
 
32
+ # ── HTTP endpoints ────────────────────────────────────────────────────────────
33
+
34
  @app.get("/")
35
+ def root():
36
  return {
37
  "info": "SQL Agent Environment API",
38
  "version": "1.0.0",
 
41
  }
42
 
43
  @app.get("/health")
44
+ def health():
45
  return {"status": "healthy"}
46
 
 
 
 
 
47
  @app.post("/reset")
48
+ def reset(req: ResetRequest = ResetRequest()):
49
+ return env.reset(task_id=req.task_id, difficulty=req.difficulty)
 
 
 
 
50
 
51
  @app.post("/step")
52
+ def step(req: StepRequest):
53
+ return env.step(req.dict())
54
+
55
+ @app.get("/state")
56
+ def state():
57
+ return env.state
58
+
59
+ # ── CRITICAL MISSING ENDPOINTS — ADD THESE ───────────────────────────────────
60
 
61
  @app.get("/tasks")
62
  def list_tasks():
63
+ """
64
+ Validator uses this endpoint to find and enumerate all graders.
65
+ Every task MUST have grader: true.
66
+ """
67
  return {
68
  "tasks": [
69
  {
 
72
  "difficulty": "easy",
73
  "type": "write_query",
74
  "grader": True,
75
+ "description": "Find all employees in Engineering with salary above 90000, return name and salary ordered by salary DESC"
76
  },
77
  {
78
  "id": "easy_02",
 
80
  "difficulty": "easy",
81
  "type": "write_query",
82
  "grader": True,
83
+ "description": "Count employees per department, return department and count ordered by count DESC"
84
  },
85
  {
86
  "id": "medium_01",
 
88
  "difficulty": "medium",
89
  "type": "fix_query",
90
  "grader": True,
91
+ "description": "Fix the broken JOIN query with missing ON keyword and wrong table alias in WHERE"
92
  },
93
  {
94
  "id": "medium_02",
 
96
  "difficulty": "medium",
97
  "type": "fix_query",
98
  "grader": True,
99
+ "description": "Fix the GROUP BY that incorrectly groups by id instead of department"
100
  },
101
  {
102
  "id": "hard_01",
 
104
  "difficulty": "hard",
105
  "type": "optimize_query",
106
  "grader": True,
107
+ "description": "Replace correlated subqueries with window functions or CTEs to find top earner per department"
108
  },
109
  {
110
  "id": "hard_02",
 
112
  "difficulty": "hard",
113
  "type": "optimize_query",
114
  "grader": True,
115
+ "description": "Rewrite N+1 correlated subquery using LEFT JOIN and GROUP BY"
116
  }
117
  ]
118
  }
119
 
120
  @app.post("/grade")
121
+ def grade(req: GradeRequest):
122
+ """
123
+ Validator calls this to get a score for a specific task.
124
+ Score MUST be strictly between 0 and 1 (never 0.0, never 1.0).
125
+ This is enforced by _clamp() in the environment AND by the
126
+ absolute safety clamp below.
127
+ """
128
+ # Use a fresh environment instance for grading to avoid state pollution
129
+ grade_env = SQLEnvironment()
130
+ grade_env.reset(task_id=req.task_id)
131
+ result = grade_env.step(req.action)
132
+
133
+ raw_score = result.get("reward", 0.05)
134
 
135
+ # ABSOLUTE SAFETY CLAMP — even if environment has a bug,
136
+ # this line guarantees the validator never sees 0.0 or 1.0
137
+ score = round(max(0.05, min(0.95, float(raw_score))), 4)
138
 
139
  return {
140
  "task_id": req.task_id,
141
  "score": score,
142
+ "feedback": result["observation"]["feedback"],
143
+ "done": result["done"],
144
+ "info": result.get("info", {})
145
  }
146
+
147
+ # ── WebSocket endpoint — required by OpenEnv spec ─────────────────────────────
148
+
149
+ @app.websocket("/ws")
150
+ async def websocket_endpoint(websocket: WebSocket):
151
+ """
152
+ Persistent WebSocket session. Each connection gets its own
153
+ SQLEnvironment instance so sessions are isolated.
154
+ """
155
+ await websocket.accept()
156
+ ws_env = SQLEnvironment()
157
+
158
+ try:
159
+ while True:
160
+ data = await websocket.receive_text()
161
+ try:
162
+ message = json.loads(data)
163
+ except json.JSONDecodeError:
164
+ await websocket.send_text(json.dumps({
165
+ "error": "Invalid JSON"
166
+ }))
167
+ continue
168
+
169
+ msg_type = message.get("type", "")
170
+
171
+ if msg_type == "reset":
172
+ result = ws_env.reset(
173
+ task_id=message.get("task_id"),
174
+ difficulty=message.get("difficulty")
175
+ )
176
+ await websocket.send_text(json.dumps(result))
177
+
178
+ elif msg_type == "step":
179
+ action = message.get("action", {})
180
+ result = ws_env.step(action)
181
+ await websocket.send_text(json.dumps(result))
182
+
183
+ elif msg_type == "state":
184
+ await websocket.send_text(json.dumps(ws_env.state))
185
+
186
+ else:
187
+ await websocket.send_text(json.dumps({
188
+ "error": f"Unknown message type: {msg_type}"
189
+ }))
190
+
191
+ except WebSocketDisconnect:
192
+ pass
193
+
194
+ # ── Web UI ─────────────────────────────────────────────────────────────────────
195
+
196
+ @app.get("/web", response_class=HTMLResponse)
197
+ def web_ui():
198
+ """
199
+ Interactive web playground for testing the environment manually.
200
+ """
201
+ return """
202
+ <!DOCTYPE html>
203
+ <html lang="en">
204
+ <head>
205
+ <meta charset="UTF-8">
206
+ <title>SQL data analyst playground</title>
207
+ <style>
208
+ * { box-sizing: border-box; margin: 0; padding: 0; }
209
+ body {
210
+ background: #0d1117; color: #e6edf3;
211
+ font-family: 'Courier New', monospace;
212
+ padding: 20px;
213
+ }
214
+ .header {
215
+ display: flex; justify-content: space-between;
216
+ align-items: center; margin-bottom: 24px;
217
+ border-bottom: 1px solid #30363d; padding-bottom: 16px;
218
+ }
219
+ .badge {
220
+ background: #1f6feb; color: white;
221
+ font-size: 11px; padding: 2px 8px;
222
+ border-radius: 4px; margin-right: 8px;
223
+ }
224
+ .title { font-size: 28px; font-weight: bold; margin-top: 4px; }
225
+ .links a { color: #58a6ff; text-decoration: none; margin-left: 16px; }
226
+ .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; }
227
+ .panel {
228
+ background: #161b22; border: 1px solid #30363d;
229
+ border-radius: 8px; padding: 16px;
230
+ }
231
+ .panel h3 {
232
+ font-size: 11px; letter-spacing: 1px;
233
+ color: #8b949e; margin-bottom: 12px;
234
+ }
235
+ .task-meta {
236
+ display: flex; gap: 8px; margin-bottom: 8px;
237
+ }
238
+ .tag {
239
+ background: #21262d; border: 1px solid #30363d;
240
+ padding: 2px 8px; border-radius: 4px; font-size: 12px;
241
+ }
242
+ .task-desc { font-size: 14px; line-height: 1.6; color: #e6edf3; }
243
+ .schema-text {
244
+ font-size: 12px; color: #8b949e;
245
+ white-space: pre; overflow-x: auto;
246
+ }
247
+ .sql-panel {
248
+ background: #161b22; border: 1px solid #30363d;
249
+ border-radius: 8px; padding: 16px; margin-bottom: 16px;
250
+ }
251
+ .sql-panel h3 {
252
+ font-size: 11px; letter-spacing: 1px;
253
+ color: #8b949e; margin-bottom: 4px;
254
+ }
255
+ .sql-hint { font-size: 12px; color: #8b949e; margin-bottom: 12px; }
256
+ textarea {
257
+ width: 100%; height: 120px;
258
+ background: #0d1117; color: #79c0ff;
259
+ border: 2px solid #238636; border-radius: 6px;
260
+ padding: 12px; font-family: 'Courier New', monospace;
261
+ font-size: 14px; resize: vertical;
262
+ }
263
+ .actions { display: flex; gap: 12px; margin-top: 12px; }
264
+ button {
265
+ padding: 8px 20px; border-radius: 6px;
266
+ border: none; cursor: pointer;
267
+ font-family: 'Courier New', monospace; font-size: 14px;
268
+ }
269
+ .btn-primary { background: #238636; color: white; }
270
+ .btn-secondary { background: #21262d; color: #e6edf3; border: 1px solid #30363d; }
271
+ .btn-primary:hover { background: #2ea043; }
272
+ .result-panel {
273
+ background: #161b22; border: 1px solid #30363d;
274
+ border-radius: 8px; padding: 16px; margin-bottom: 16px;
275
+ min-height: 80px;
276
+ }
277
+ .result-panel h3 {
278
+ font-size: 11px; letter-spacing: 1px;
279
+ color: #8b949e; margin-bottom: 12px;
280
+ }
281
+ .reward-bar {
282
+ height: 6px; background: #21262d;
283
+ border-radius: 3px; margin: 8px 0;
284
+ }
285
+ .reward-fill { height: 100%; border-radius: 3px; background: #238636; transition: width 0.3s; }
286
+ .feedback { font-size: 13px; color: #8b949e; margin-top: 8px; }
287
+ .result-text {
288
+ font-size: 12px; color: #e6edf3;
289
+ white-space: pre; overflow-x: auto; margin-top: 8px;
290
+ }
291
+ .status {
292
+ font-size: 13px; color: #8b949e;
293
+ padding: 8px 0;
294
+ }
295
+ .status.connected { color: #3fb950; }
296
+ .status.error { color: #f85149; }
297
+ </style>
298
+ </head>
299
+ <body>
300
+ <div class="header">
301
+ <div>
302
+ <div><span class="badge">OPENENV</span></div>
303
+ <div class="title">SQL data analyst playground</div>
304
+ </div>
305
+ <div class="links">
306
+ <a href="/docs">API docs</a>
307
+ <a href="/health">Health</a>
308
+ <a href="/tasks">Tasks</a>
309
+ </div>
310
+ </div>
311
+
312
+ <p style="font-size:13px;color:#8b949e;margin-bottom:20px;">
313
+ Interactive mode uses a persistent <code>WebSocket</code> session at <code>/ws</code>
314
+ (OpenEnv HTTP <code>/step</code> is stateless). Connect, then run reset → SQL steps.
315
+ </p>
316
+
317
+ <div class="grid">
318
+ <div class="panel">
319
+ <h3>CURRENT TASK</h3>
320
+ <div class="task-meta">
321
+ <span class="tag" id="task-id">—</span>
322
+ <span class="tag" id="task-diff">—</span>
323
+ </div>
324
+ <div class="task-desc" id="task-desc">Click "Start episode" to load the first task.</div>
325
+ <div class="feedback" id="hints"></div>
326
+ </div>
327
+ <div class="panel">
328
+ <h3>SCHEMA</h3>
329
+ <div class="schema-text" id="schema-text">—</div>
330
+ </div>
331
+ </div>
332
+
333
+ <div class="sql-panel">
334
+ <h3>YOUR SQL</h3>
335
+ <p class="sql-hint">Type <strong>only valid SQLite</strong> here.</p>
336
+ <textarea id="sql-input" placeholder="Executable SQL only, e.g. SELECT department_id, AVG(salary) ..."></textarea>
337
+ <div class="actions">
338
+ <button class="btn-primary" onclick="startEpisode()">Start episode</button>
339
+ <button class="btn-primary" onclick="submitSQL()">Submit SQL</button>
340
+ <button class="btn-secondary" onclick="nextEpisode()">Next task</button>
341
+ </div>
342
+ </div>
343
+
344
+ <div class="result-panel">
345
+ <h3>LAST RESULT</h3>
346
+ <div id="reward-display" style="font-size:13px;color:#8b949e;">No result yet.</div>
347
+ <div class="reward-bar"><div class="reward-fill" id="reward-bar" style="width:0%"></div></div>
348
+ <div class="feedback" id="feedback-text"></div>
349
+ <div class="result-text" id="result-text"></div>
350
+ </div>
351
+
352
+ <div class="status" id="status">Not connected. Click "Start episode" to begin.</div>
353
+
354
+ <script>
355
+ let ws = null;
356
+ let connected = false;
357
+
358
+ function connect(callback) {
359
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
360
+ ws = new WebSocket(proto + '//' + location.host + '/ws');
361
+
362
+ ws.onopen = () => {
363
+ connected = true;
364
+ document.getElementById('status').textContent = 'Connected via WebSocket.';
365
+ document.getElementById('status').className = 'status connected';
366
+ if (callback) callback();
367
+ };
368
+
369
+ ws.onmessage = (event) => {
370
+ const data = JSON.parse(event.data);
371
+ handleResult(data);
372
+ };
373
+
374
+ ws.onerror = () => {
375
+ document.getElementById('status').textContent = 'WebSocket error.';
376
+ document.getElementById('status').className = 'status error';
377
+ };
378
+
379
+ ws.onclose = () => {
380
+ connected = false;
381
+ document.getElementById('status').textContent = 'Disconnected.';
382
+ document.getElementById('status').className = 'status';
383
+ };
384
+ }
385
+
386
+ function handleResult(data) {
387
+ const obs = data.observation || data;
388
+ if (!obs) return;
389
+
390
+ // Update task panel
391
+ if (obs.task_id) document.getElementById('task-id').textContent = obs.task_id;
392
+ if (obs.task_type) document.getElementById('task-diff').textContent = obs.task_type;
393
+ if (obs.task_description) document.getElementById('task-desc').textContent = obs.task_description;
394
+ if (obs.expected_description) document.getElementById('hints').textContent = obs.expected_description;
395
+ if (obs.schema_info) {
396
+ const lines = obs.schema_info.split('\\n').slice(0, 20).join('\\n');
397
+ document.getElementById('schema-text').textContent = lines;
398
+ }
399
+
400
+ // Update reward
401
+ const reward = data.reward || obs.reward || 0;
402
+ const pct = Math.round(reward * 100);
403
+ document.getElementById('reward-display').textContent = 'Reward: ' + reward.toFixed(4) + ' (' + pct + '%)';
404
+ document.getElementById('reward-bar').style.width = pct + '%';
405
+
406
+ // Update feedback
407
+ if (obs.feedback) document.getElementById('feedback-text').textContent = obs.feedback;
408
+ if (obs.last_result) document.getElementById('result-text').textContent = obs.last_result;
409
+ if (obs.last_error) document.getElementById('result-text').textContent = 'ERROR: ' + obs.last_error;
410
+
411
+ // Show starter SQL if this is a fix/optimize task
412
+ if (obs.last_sql && document.getElementById('sql-input').value === '') {
413
+ document.getElementById('sql-input').value = obs.last_sql;
414
+ }
415
+ }
416
+
417
+ function startEpisode() {
418
+ if (!connected) {
419
+ connect(() => {
420
+ ws.send(JSON.stringify({ type: 'reset' }));
421
+ });
422
+ } else {
423
+ ws.send(JSON.stringify({ type: 'reset' }));
424
+ }
425
+ }
426
+
427
+ function submitSQL() {
428
+ const sql = document.getElementById('sql-input').value.trim();
429
+ if (!sql) { alert('Please enter a SQL query.'); return; }
430
+ if (!connected) { alert('Click Start episode first.'); return; }
431
+ ws.send(JSON.stringify({
432
+ type: 'step',
433
+ action: { action_type: 'write_query', sql: sql }
434
+ }));
435
+ }
436
+
437
+ function nextEpisode() {
438
+ if (!connected) {
439
+ connect(() => {
440
+ ws.send(JSON.stringify({ type: 'reset' }));
441
+ });
442
+ } else {
443
+ ws.send(JSON.stringify({ type: 'reset' }));
444
+ }
445
+ document.getElementById('sql-input').value = '';
446
+ document.getElementById('result-text').textContent = '';
447
+ document.getElementById('feedback-text').textContent = '';
448
+ }
449
+ </script>
450
+ </body>
451
+ </html>
452
+ """
server/environment.py CHANGED
@@ -1,328 +1,483 @@
1
- import sqlite3
2
- import re
3
- from typing import Dict, Any, List, Optional
4
- import json
5
 
6
  SCHEMA_SQL = """
7
- CREATE TABLE employees (id INTEGER PRIMARY KEY, name TEXT, department TEXT, salary REAL, hire_date TEXT, manager_id INTEGER);
8
- CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT, budget REAL, location TEXT);
9
- CREATE TABLE projects (id INTEGER PRIMARY KEY, name TEXT, department_id INTEGER, start_date TEXT, status TEXT);
10
- CREATE TABLE project_assignments (employee_id INTEGER, project_id INTEGER, hours_worked REAL, PRIMARY KEY(employee_id, project_id));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  """
12
 
13
- SEED_DATA_SQL = """
14
- INSERT INTO departments VALUES (1,'Engineering',500000,'New York'), (2,'Marketing',200000,'Chicago'), (3,'Sales',300000,'Los Angeles');
15
- INSERT INTO employees VALUES (1,'Alice Chen','Engineering',95000,'2020-01-15',NULL), (2,'Bob Smith','Engineering',85000,'2021-03-20',1), (3,'Carol Davis','Marketing',72000,'2019-07-01',NULL), (4,'David Lee','Sales',68000,'2022-11-01',NULL), (5,'Eve Turner','Engineering',110000,'2018-05-10',1), (6,'Frank White','Marketing',65000,'2023-01-15',3);
16
- INSERT INTO projects VALUES (1,'Data Platform',1,'2023-01-01','active'), (2,'Website Redesign',2,'2023-03-15','completed'), (3,'CRM Migration',3,'2023-06-01','active');
17
- INSERT INTO project_assignments VALUES (1,1,120),(2,1,80),(5,1,200), (3,2,160),(6,2,40),(4,3,100);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  """
19
 
20
  TASKS = {
21
  "easy_01": {
22
  "type": "write_query",
23
  "difficulty": "easy",
24
- "description": "Find all employees in the Engineering department with salary above 90000. Return name and salary ordered by salary descending.",
25
  "expected_columns": ["name", "salary"],
26
  "expected_row_count": 2,
27
- "hints": ["Filter department = Engineering", "salary > 90000", "ORDER BY salary DESC"]
 
 
 
 
28
  },
29
  "easy_02": {
30
- "type": "write_query",
31
  "difficulty": "easy",
32
- "description": "Count how many employees are in each department. Return department and employee count ordered by count descending.",
33
  "expected_columns": ["department", "count"],
34
  "expected_row_count": 3,
35
- "hints": ["Use GROUP BY department", "Use COUNT(*)"]
36
  },
37
  "medium_01": {
38
  "type": "fix_query",
39
  "difficulty": "medium",
40
- "description": "Fix this broken query that returns employees on active projects with project name and hours worked.",
41
- "broken_sql": "SELECT e.name, p.name, pa.hours_worked FROM employees e JOIN project_assignments pa e.id = pa.employee_id JOIN projects p ON pa.project_id = p.id WHERE projects.status = 'active'",
42
  "expected_row_count": 3,
43
- "bugs": ["Missing ON keyword in first JOIN", "Wrong alias 'projects' should be 'p' in WHERE"]
44
  },
45
  "medium_02": {
46
  "type": "fix_query",
47
- "difficulty": "medium",
48
- "description": "Fix this query that should return average salary per department but groups incorrectly.",
49
- "broken_sql": "SELECT department, AVG(salary) FROM employees GROUP BY id ORDER BY AVG(salary) DESC",
50
  "expected_row_count": 3,
51
- "bugs": ["GROUP BY should use department not id"]
52
  },
53
  "hard_01": {
54
  "type": "optimize_query",
55
  "difficulty": "hard",
56
- "description": "Optimize this slow query: find top earner in each department with their total hours worked. Replace correlated subqueries with window functions or CTEs.",
57
- "slow_sql": "SELECT e.name, e.department, e.salary, (SELECT SUM(hours_worked) FROM project_assignments pa WHERE pa.employee_id = e.id) as total_hours FROM employees e WHERE e.salary = (SELECT MAX(salary) FROM employees e2 WHERE e2.department = e.department) ORDER BY e.salary DESC",
58
  "expected_row_count": 3,
59
- "good_patterns": ["ROW_NUMBER", "RANK", "WITH ", "LEFT JOIN"],
60
- "optimization_hints": ["Use window functions", "Use CTEs"]
 
61
  },
62
  "hard_02": {
63
  "type": "optimize_query",
64
  "difficulty": "hard",
65
- "description": "Rewrite this N+1 query: return each department name, budget, employee count, and active project count using JOINs instead of subqueries.",
66
- "slow_sql": "SELECT d.name, d.budget, (SELECT COUNT(*) FROM employees e WHERE e.department = d.name) as emp_count, (SELECT COUNT(*) FROM projects p WHERE p.department_id = d.id AND p.status='active') as active_projects FROM departments d",
67
  "expected_row_count": 3,
68
  "good_patterns": ["LEFT JOIN", "GROUP BY", "COUNT("],
69
- "optimization_hints": ["Use LEFT JOIN with GROUP BY"]
70
  }
71
  }
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  class SQLEnvironment:
74
  def __init__(self):
 
75
  self.step_count = 0
 
 
76
  self.done = False
77
- self.reward = 0.05
78
- self.feedback = ""
79
- self.last_sql = ""
80
- self.last_result = ""
81
- self.last_error = ""
82
- self.task_id = "easy_01"
83
- self.task = TASKS[self.task_id]
84
  self.conn = None
85
- self._build_db()
86
-
87
- def _build_db(self):
88
- if self.conn:
89
- self.conn.close()
90
- self.conn = sqlite3.connect(":memory:")
91
- self.conn.row_factory = sqlite3.Row
92
- self.conn.executescript(SCHEMA_SQL)
93
- self.conn.executescript(SEED_DATA_SQL)
94
- self.conn.commit()
95
- return self.conn
96
 
97
  def _clamp(self, score: float) -> float:
98
- return round(max(0.05, min(0.95, score)), 4)
99
 
100
- def reset(self, task_id=None, difficulty=None):
 
101
  self.step_count = 0
 
 
102
  self.done = False
103
- self.last_sql = ""
104
- self.last_result = ""
105
- self.last_error = ""
106
- self.reward = self._clamp(0.15)
107
- self.feedback = "Environment reset."
 
 
108
 
 
 
 
 
109
  if task_id and task_id in TASKS:
110
  self.task_id = task_id
111
- self.task = TASKS[task_id]
112
  elif difficulty:
113
- candidates = [k for k, v in TASKS.items() if v["difficulty"] == difficulty]
114
- if candidates:
115
- self.task_id = candidates[0]
116
- self.task = TASKS[self.task_id]
117
  else:
118
- self.task_id = list(TASKS.keys())[0]
119
- self.task = TASKS[self.task_id]
120
-
121
- self.conn = self._build_db()
122
-
123
- if self.task["type"] == "fix_query":
124
- self.last_sql = self.task["broken_sql"]
125
- elif self.task["type"] == "optimize_query":
126
- self.last_sql = self.task["slow_sql"]
 
 
 
 
127
 
128
- return self._build_observation(self.reward, self.feedback)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
- def _execute_sql(self, sql: str):
131
- upper_sql = sql.upper()
132
- blocked = ["DROP", "DELETE", "INSERT", "UPDATE", "CREATE", "ALTER", "TRUNCATE"]
133
- for b in blocked:
134
- if re.search(rf"\b{b}\b", upper_sql):
135
- return None, "Forbidden operation"
136
 
137
  try:
138
- cursor = self.conn.cursor()
139
- cursor.execute(sql)
 
 
140
  rows = cursor.fetchall()
141
  if not rows:
142
- return json.dumps([{}]) if cursor.description else "[]", None
143
-
144
- res_dict = [dict(r) for r in rows[:10]]
145
- return json.dumps(res_dict), None
146
- except Exception as e:
 
 
 
147
  return None, str(e)
148
 
149
- def _grade_write(self, sql, rows, cols) -> tuple[float, str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  score = 0.0
151
- feedback_parts = []
152
- lower_sql = sql.lower()
153
 
154
- if "employees" in lower_sql or "departments" in lower_sql:
 
155
  score += 0.15
156
- feedback_parts.append("Correct table used")
 
 
157
 
158
- if "where" in lower_sql:
159
  score += 0.10
160
- feedback_parts.append("WHERE clause present")
 
 
161
 
162
- expected_cols = [c.lower() for c in self.task["expected_columns"]]
163
  actual_cols = [c.lower() for c in cols]
164
- matched = sum(1 for ec in expected_cols if ec in actual_cols)
165
- if expected_cols:
166
- score += (matched / len(expected_cols)) * 0.25
167
- feedback_parts.append(f"Columns: {matched}/{len(expected_cols)} matched")
 
 
 
 
 
 
168
 
169
- expected_rows = self.task["expected_row_count"]
170
- if rows is not None:
171
- if len(rows) == expected_rows:
172
- score += 0.25
173
- feedback_parts.append(f"Row count correct: {len(rows)}")
174
- elif abs(len(rows) - expected_rows) == 1:
175
- score += 0.12
176
- feedback_parts.append(f"Row count close: {len(rows)} vs {expected_rows}")
177
- else:
178
- feedback_parts.append(f"Row count wrong: {len(rows)} vs {expected_rows}")
179
-
180
- if "order by" in lower_sql:
181
  score += 0.15
182
- feedback_parts.append("ORDER BY present")
 
 
183
 
184
- return self._clamp(score), " | ".join(feedback_parts)
 
 
 
 
185
 
186
- def _grade_fix(self, sql, rows, error) -> tuple[float, str]:
187
  score = 0.0
188
- feedback_parts = []
189
- lower_sql = sql.lower()
190
 
191
  if error is None and rows is not None:
192
  score += 0.35
193
- feedback_parts.append("Query executes successfully")
194
  else:
195
- feedback_parts.append(f"Still broken: {error}")
196
 
197
- expected_rows = self.task["expected_row_count"]
198
- if rows is not None and len(rows) == expected_rows:
199
- score += 0.35
200
- feedback_parts.append(f"Correct rows: {len(rows)}")
 
 
 
 
 
 
201
 
202
- if "join" in lower_sql and " on " in lower_sql:
203
- score += 0.15
204
- feedback_parts.append("JOIN...ON syntax correct")
 
 
205
 
206
- if "group by" in lower_sql or "where" in lower_sql:
 
207
  score += 0.10
208
- feedback_parts.append("Filter/grouping present")
209
-
210
- return self._clamp(score), " | ".join(feedback_parts)
 
 
 
 
211
 
212
- def _grade_optimize(self, sql, rows) -> tuple[float, str]:
213
  score = 0.0
214
- feedback_parts = []
215
  sql_upper = sql.upper()
 
216
 
217
- if "WITH " in sql_upper or "ROW_NUMBER" in sql_upper or "RANK" in sql_upper:
 
 
218
  score += 0.30
219
- feedback_parts.append("Uses CTE or window function")
 
 
 
220
 
221
- if "JOIN" in sql_upper:
222
- score += 0.25
223
- feedback_parts.append("Uses JOIN")
 
 
224
 
225
  select_count = sql_upper.count("SELECT")
226
  if select_count == 1:
227
  score += 0.20
228
- feedback_parts.append("No nested SELECT (no correlated subqueries)")
 
 
 
229
  else:
230
- feedback_parts.append(f"Still has {select_count - 1} nested SELECT(s)")
231
 
232
- expected_rows = self.task["expected_row_count"]
233
- if rows is not None and len(rows) == expected_rows:
234
- score += 0.20
235
- feedback_parts.append(f"Correct rows: {len(rows)}")
 
 
 
 
 
 
236
 
237
- return self._clamp(score), " | ".join(feedback_parts)
238
-
239
- def get_schema_info(self) -> str:
240
- return SCHEMA_SQL.strip()
241
-
242
- def get_sample_data(self) -> str:
243
- samples = []
244
- tables = ["departments", "employees", "projects", "project_assignments"]
245
- cursor = self.conn.cursor()
246
- for t in tables:
247
- cursor.execute(f"SELECT * FROM {t} LIMIT 3")
248
- rows = cursor.fetchall()
249
- cols = [desc[0] for desc in cursor.description] if cursor.description else []
250
- samples.append(f"Table {t}:\nCols: {', '.join(cols)}")
251
- for r in rows:
252
- samples.append(str(dict(r)))
253
- return "\n".join(samples)
254
 
255
- def _build_observation(self, reward, feedback, last_sql=None, last_result=None, last_error=None):
256
- reward = self._clamp(reward)
257
- obs = {
258
- "task_id": self.task_id,
259
- "task_type": self.task["type"],
260
- "task_description": self.task["description"],
261
- "schema_info": self.get_schema_info(),
262
- "sample_data": self.get_sample_data(),
263
- "last_sql": last_sql or self.last_sql,
264
- "last_result": last_result or self.last_result,
265
- "last_error": last_error or self.last_error,
 
 
266
  "step_count": self.step_count,
267
  "done": self.done,
268
- "reward": reward,
269
  "feedback": feedback,
270
- "metadata": self.task.get("hints", []) + self.task.get("good_patterns", [])
271
  }
272
- return {"observation": obs, "reward": reward, "done": self.done, "info": {}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
 
274
  @property
275
- def state(self):
276
  return {
277
- "episode_id": "ep_" + str(self.step_count),
278
- "task_type": self.task["type"],
279
- "task_id": self.task_id,
280
  "step_count": self.step_count,
281
- "total_reward": self.reward,
282
- "best_score_so_far": self.reward,
283
- "attempts": self.step_count
 
284
  }
285
-
286
- def step(self, action: dict):
287
- self.step_count += 1
288
- sql = action.get("sql", "").strip()
289
- self.last_sql = sql
290
-
291
- if not sql:
292
- self.reward = self._clamp(0.0)
293
- self.last_error = "No SQL provided"
294
- self.last_result = ""
295
- self.feedback = "Missing SQL"
296
- else:
297
- res_text, err = self._execute_sql(sql)
298
- if err:
299
- self.last_error = err
300
- self.last_result = ""
301
- self.reward = self._clamp(0.08)
302
- self.feedback = err
303
- else:
304
- self.last_error = ""
305
- self.last_result = res_text
306
-
307
- rows = []
308
- cols = []
309
- if res_text and res_text != "[]" and res_text != "[{}]":
310
- try:
311
- rows = json.loads(res_text)
312
- if rows and isinstance(rows[0], dict):
313
- cols = list(rows[0].keys())
314
- except:
315
- pass
316
-
317
- ttype = self.task["type"]
318
- if ttype == "write_query":
319
- self.reward, self.feedback = self._grade_write(sql, rows, cols)
320
- elif ttype == "fix_query":
321
- self.reward, self.feedback = self._grade_fix(sql, rows, err)
322
- elif ttype == "optimize_query":
323
- self.reward, self.feedback = self._grade_optimize(sql, rows)
324
-
325
- if self.reward > 0.90 or self.step_count >= 8:
326
- self.done = True
327
-
328
- return self._build_observation(self.reward, self.feedback)
 
1
+ import sqlite3, uuid, random, re
2
+ from typing import Optional, Tuple, List, Dict, Any
 
 
3
 
4
  SCHEMA_SQL = """
5
+ CREATE TABLE employees (
6
+ id INTEGER PRIMARY KEY,
7
+ name TEXT NOT NULL,
8
+ department TEXT NOT NULL,
9
+ salary REAL NOT NULL,
10
+ hire_date TEXT NOT NULL,
11
+ manager_id INTEGER REFERENCES employees(id)
12
+ );
13
+
14
+ CREATE TABLE departments (
15
+ id INTEGER PRIMARY KEY,
16
+ name TEXT NOT NULL,
17
+ budget REAL NOT NULL,
18
+ location TEXT NOT NULL
19
+ );
20
+
21
+ CREATE TABLE projects (
22
+ id INTEGER PRIMARY KEY,
23
+ name TEXT NOT NULL,
24
+ department_id INTEGER REFERENCES departments(id),
25
+ start_date TEXT NOT NULL,
26
+ status TEXT NOT NULL
27
+ );
28
+
29
+ CREATE TABLE project_assignments (
30
+ employee_id INTEGER REFERENCES employees(id),
31
+ project_id INTEGER REFERENCES projects(id),
32
+ hours_worked REAL NOT NULL,
33
+ PRIMARY KEY (employee_id, project_id)
34
+ );
35
  """
36
 
37
+ SEED_SQL = """
38
+ INSERT INTO departments VALUES
39
+ (1,'Engineering',500000,'New York'),
40
+ (2,'Marketing',200000,'Chicago'),
41
+ (3,'Sales',300000,'Los Angeles');
42
+
43
+ INSERT INTO employees VALUES
44
+ (1,'Alice Chen','Engineering',95000,'2020-01-15',NULL),
45
+ (2,'Bob Smith','Engineering',85000,'2021-03-20',1),
46
+ (3,'Carol Davis','Marketing',72000,'2019-07-01',NULL),
47
+ (4,'David Lee','Sales',68000,'2022-11-01',NULL),
48
+ (5,'Eve Turner','Engineering',110000,'2018-05-10',1),
49
+ (6,'Frank White','Marketing',65000,'2023-01-15',3);
50
+
51
+ INSERT INTO projects VALUES
52
+ (1,'Data Platform',1,'2023-01-01','active'),
53
+ (2,'Website Redesign',2,'2023-03-15','completed'),
54
+ (3,'CRM Migration',3,'2023-06-01','active');
55
+
56
+ INSERT INTO project_assignments VALUES
57
+ (1,1,120),(2,1,80),(5,1,200),
58
+ (3,2,160),(6,2,40),(4,3,100);
59
  """
60
 
61
  TASKS = {
62
  "easy_01": {
63
  "type": "write_query",
64
  "difficulty": "easy",
65
+ "description": "Find all employees in the Engineering department with salary above 90000. Return their name and salary, ordered by salary descending.",
66
  "expected_columns": ["name", "salary"],
67
  "expected_row_count": 2,
68
+ "expected_rows": [
69
+ {"name": "Eve Turner", "salary": 110000.0},
70
+ {"name": "Alice Chen", "salary": 95000.0}
71
+ ],
72
+ "hints": ["Filter WHERE department = 'Engineering'", "AND salary > 90000", "ORDER BY salary DESC"]
73
  },
74
  "easy_02": {
75
+ "type": "write_query",
76
  "difficulty": "easy",
77
+ "description": "Count how many employees are in each department. Return department name and employee count, ordered by count descending.",
78
  "expected_columns": ["department", "count"],
79
  "expected_row_count": 3,
80
+ "hints": ["Use GROUP BY department", "Use COUNT(*)", "ORDER BY count DESC"]
81
  },
82
  "medium_01": {
83
  "type": "fix_query",
84
  "difficulty": "medium",
85
+ "description": "Fix this broken query that should return all employees working on active projects along with the project name and hours worked.",
86
+ "broken_sql": "SELECT e.name, p.name, pa.hours_worked\nFROM employees e\nJOIN project_assignments pa e.id = pa.employee_id\nJOIN projects p ON pa.project_id = p.id\nWHERE projects.status = 'active'\n",
87
  "expected_row_count": 3,
88
+ "bugs": ["Missing ON keyword between 'pa' and 'e.id' in first JOIN", "Wrong table reference: 'projects.status' should be 'p.status' in WHERE"]
89
  },
90
  "medium_02": {
91
  "type": "fix_query",
92
+ "difficulty": "medium",
93
+ "description": "Fix this query that should return average salary per department but is producing wrong results because it groups by the wrong column.",
94
+ "broken_sql": "SELECT department, AVG(salary)\nFROM employees\nGROUP BY id\nORDER BY AVG(salary) DESC\n",
95
  "expected_row_count": 3,
96
+ "bugs": ["GROUP BY should use 'department' not 'id'"]
97
  },
98
  "hard_01": {
99
  "type": "optimize_query",
100
  "difficulty": "hard",
101
+ "description": "This query works but is slow. Optimize it to find the top earner in each department along with their total hours worked across all projects. Replace correlated subqueries with window functions or CTEs.",
102
+ "slow_sql": "SELECT e.name, e.department, e.salary,\n (SELECT SUM(hours_worked) \n FROM project_assignments pa \n WHERE pa.employee_id = e.id) as total_hours\nFROM employees e\nWHERE e.salary = (SELECT MAX(salary) \n FROM employees e2 \n WHERE e2.department = e.department)\nORDER BY e.salary DESC\n",
103
  "expected_row_count": 3,
104
+ "good_patterns": ["WITH ", "ROW_NUMBER", "RANK", "LEFT JOIN"],
105
+ "bad_patterns": ["SELECT.*SELECT.*SELECT"],
106
+ "optimization_hints": ["Use a CTE (WITH clause) to pre-aggregate hours", "Use ROW_NUMBER() or RANK() window function", "Replace nested SELECT with LEFT JOIN"]
107
  },
108
  "hard_02": {
109
  "type": "optimize_query",
110
  "difficulty": "hard",
111
+ "description": "Rewrite this slow N+1 query to return each department name, total budget, number of employees, and number of active projects. Use JOINs instead of correlated subqueries.",
112
+ "slow_sql": "SELECT d.name, d.budget,\n (SELECT COUNT(*) FROM employees e \n WHERE e.department = d.name) as emp_count,\n (SELECT COUNT(*) FROM projects p \n WHERE p.department_id = d.id \n AND p.status='active') as active_projects\nFROM departments d\n",
113
  "expected_row_count": 3,
114
  "good_patterns": ["LEFT JOIN", "GROUP BY", "COUNT("],
115
+ "optimization_hints": ["Use LEFT JOIN employees ON department name", "Use LEFT JOIN projects ON department_id", "Use GROUP BY d.id or d.name", "Use COUNT() in SELECT"]
116
  }
117
  }
118
 
119
+ def build_db() -> sqlite3.Connection:
120
+ conn = sqlite3.connect(":memory:")
121
+ conn.row_factory = sqlite3.Row
122
+ conn.executescript(SCHEMA_SQL)
123
+ conn.executescript(SEED_SQL)
124
+ conn.commit()
125
+ return conn
126
+
127
+ def get_schema_info() -> str:
128
+ return SCHEMA_SQL.strip()
129
+
130
+ def get_sample_data() -> str:
131
+ conn = build_db()
132
+ lines = []
133
+ for table in ["employees", "departments", "projects", "project_assignments"]:
134
+ cursor = conn.execute(f"SELECT * FROM {table} LIMIT 3")
135
+ cols = [d[0] for d in cursor.description]
136
+ rows = cursor.fetchall()
137
+ lines.append(f"\\n-- {table} (first 3 rows)")
138
+ lines.append(", ".join(cols))
139
+ for row in rows:
140
+ lines.append(", ".join(str(v) for v in row))
141
+ conn.close()
142
+ return "\\n".join(lines)
143
+
144
  class SQLEnvironment:
145
  def __init__(self):
146
+ self.episode_id = ""
147
  self.step_count = 0
148
+ self.total_reward = 0.0
149
+ self.best_score = 0.0
150
  self.done = False
151
+ self.task = None
152
+ self.task_id = None
 
 
 
 
 
153
  self.conn = None
154
+ self.attempts = 0
 
 
 
 
 
 
 
 
 
 
155
 
156
  def _clamp(self, score: float) -> float:
157
+ return round(max(0.05, min(0.95, float(score))), 4)
158
 
159
+ def _reset_state(self):
160
+ self.episode_id = str(uuid.uuid4())
161
  self.step_count = 0
162
+ self.total_reward = 0.0
163
+ self.best_score = 0.0
164
  self.done = False
165
+ self.attempts = 0
166
+ if self.conn:
167
+ try:
168
+ self.conn.close()
169
+ except:
170
+ pass
171
+ self.conn = build_db()
172
 
173
+ def reset(self, task_id=None, difficulty=None) -> dict:
174
+ self._reset_state()
175
+
176
+ available = list(TASKS.keys())
177
  if task_id and task_id in TASKS:
178
  self.task_id = task_id
 
179
  elif difficulty:
180
+ pool = [k for k,v in TASKS.items() if v["difficulty"] == difficulty]
181
+ self.task_id = random.choice(pool) if pool else random.choice(available)
 
 
182
  else:
183
+ self.task_id = random.choice(available)
184
+
185
+ self.task = TASKS[self.task_id]
186
+
187
+ starter_sql = (self.task.get("broken_sql") or self.task.get("slow_sql") or "")
188
+
189
+ return self._build_result(
190
+ reward=0.15,
191
+ feedback="New episode started. Study the schema and task carefully.",
192
+ last_sql=starter_sql.strip() if starter_sql else None,
193
+ last_result=None,
194
+ last_error=None
195
+ )
196
 
197
+ def step(self, action: dict) -> dict:
198
+ if self.done:
199
+ return self._build_result(
200
+ reward=self._clamp(0.0),
201
+ feedback="Episode complete. Call reset() to start a new episode."
202
+ )
203
+
204
+ self.step_count += 1
205
+ self.attempts += 1
206
+ sql = (action.get("sql") or "").strip()
207
+
208
+ if not sql:
209
+ return self._build_result(
210
+ reward=self._clamp(0.0),
211
+ feedback="Empty SQL submitted. Please write a SQL SELECT statement."
212
+ )
213
+
214
+ result_text, error_text = self._execute_sql(sql)
215
+
216
+ if error_text:
217
+ reward = self._clamp(0.04)
218
+ self.total_reward += reward
219
+ return self._build_result(
220
+ reward=reward,
221
+ feedback=f"SQL Error: {error_text}. Fix the syntax and try again.",
222
+ last_sql=sql,
223
+ last_result=None,
224
+ last_error=error_text
225
+ )
226
+
227
+ task_type = self.task["type"]
228
+ if task_type == "write_query":
229
+ rows, cols = self._get_rows_cols(sql)
230
+ reward, feedback = self._grade_write(sql, rows, cols)
231
+ elif task_type == "fix_query":
232
+ rows, cols = self._get_rows_cols(sql)
233
+ reward, feedback = self._grade_fix(sql, rows, error_text)
234
+ elif task_type == "optimize_query":
235
+ rows, cols = self._get_rows_cols(sql)
236
+ reward, feedback = self._grade_optimize(sql, rows)
237
+ else:
238
+ reward, feedback = self._clamp(0.0), "Unknown task type."
239
+
240
+ self.total_reward += reward
241
+ self.best_score = max(self.best_score, reward)
242
+
243
+ if reward >= 0.90 or self.step_count >= 8:
244
+ self.done = True
245
+
246
+ return self._build_result(
247
+ reward=reward,
248
+ feedback=feedback,
249
+ last_sql=sql,
250
+ last_result=result_text,
251
+ last_error=None
252
+ )
253
 
254
+ def _execute_sql(self, sql: str) -> Tuple[Optional[str], Optional[str]]:
255
+ forbidden = ["DROP", "DELETE", "INSERT", "UPDATE", "CREATE", "ALTER", "TRUNCATE"]
256
+ sql_upper = sql.upper()
257
+ for kw in forbidden:
258
+ if kw in sql_upper:
259
+ return None, f"Forbidden: {kw} not allowed. Only SELECT."
260
 
261
  try:
262
+ cursor = self.conn.execute(sql)
263
+ if cursor.description is None:
264
+ return "Query executed (no rows returned).", None
265
+ cols = [d[0] for d in cursor.description]
266
  rows = cursor.fetchall()
267
  if not rows:
268
+ return "Query ran but returned 0 rows.", None
269
+ lines = [", ".join(cols)]
270
+ for row in rows[:10]:
271
+ lines.append(", ".join(str(v) for v in row))
272
+ if len(rows) > 10:
273
+ lines.append(f"... ({len(rows)} total rows, showing first 10)")
274
+ return "\\n".join(lines), None
275
+ except sqlite3.Error as e:
276
  return None, str(e)
277
 
278
+ def _get_rows_cols(self, sql: str):
279
+ forbidden = ["DROP","DELETE","INSERT","UPDATE","CREATE","ALTER","TRUNCATE"]
280
+ if any(kw in sql.upper() for kw in forbidden):
281
+ return [], []
282
+ try:
283
+ cursor = self.conn.execute(sql)
284
+ if cursor.description is None:
285
+ return [], []
286
+ cols = [d[0] for d in cursor.description]
287
+ rows = cursor.fetchall()
288
+ return rows, cols
289
+ except:
290
+ return [], []
291
+
292
+ def _grade_write(self, sql: str, rows: list, cols: list) -> Tuple[float, str]:
293
  score = 0.0
294
+ parts = []
295
+ sql_lower = sql.lower()
296
 
297
+ task_tables = ["employees", "departments", "projects"]
298
+ if any(t in sql_lower for t in task_tables):
299
  score += 0.15
300
+ parts.append("Correct table used (+0.15)")
301
+ else:
302
+ parts.append("Wrong or missing table")
303
 
304
+ if "where" in sql_lower:
305
  score += 0.10
306
+ parts.append("WHERE clause present (+0.10)")
307
+ else:
308
+ parts.append("No WHERE clause")
309
 
310
+ expected_cols = [c.lower() for c in self.task.get("expected_columns", [])]
311
  actual_cols = [c.lower() for c in cols]
312
+ if expected_cols and actual_cols:
313
+ matched = sum(1 for ec in expected_cols if any(ec in ac or ac in ec for ac in actual_cols))
314
+ col_score = (matched / len(expected_cols)) * 0.25
315
+ score += col_score
316
+ parts.append(f"Columns {matched}/{len(expected_cols)} matched (+{col_score:.2f})")
317
+ elif not expected_cols:
318
+ score += 0.15
319
+ parts.append("Column check skipped (+0.15)")
320
+ else:
321
+ parts.append("Wrong columns returned")
322
 
323
+ expected_count = self.task.get("expected_row_count", 0)
324
+ actual_count = len(rows)
325
+ if actual_count == expected_count:
326
+ score += 0.25
327
+ parts.append(f"Row count correct: {actual_count} (+0.25)")
328
+ elif abs(actual_count - expected_count) == 1:
329
+ score += 0.12
330
+ parts.append(f"Row count close: {actual_count} vs {expected_count} (+0.12)")
331
+ else:
332
+ parts.append(f"Row count wrong: {actual_count} vs {expected_count}")
333
+
334
+ if "order by" in sql_lower:
335
  score += 0.15
336
+ parts.append("ORDER BY present (+0.15)")
337
+ else:
338
+ parts.append("No ORDER BY")
339
 
340
+ if "desc" in sql_lower and "order by" in sql_lower:
341
+ score += 0.10
342
+ parts.append("DESC ordering (+0.10)")
343
+
344
+ return self._clamp(score), " | ".join(parts)
345
 
346
+ def _grade_fix(self, sql: str, rows: list, error: Optional[str]) -> Tuple[float, str]:
347
  score = 0.0
348
+ parts = []
349
+ sql_lower = sql.lower()
350
 
351
  if error is None and rows is not None:
352
  score += 0.35
353
+ parts.append("Query executes successfully (+0.35)")
354
  else:
355
+ parts.append(f"Still broken: {error}")
356
 
357
+ expected_count = self.task.get("expected_row_count", 0)
358
+ actual_count = len(rows) if rows else 0
359
+ if actual_count == expected_count:
360
+ score += 0.30
361
+ parts.append(f"Correct rows: {actual_count} (+0.30)")
362
+ elif actual_count > 0:
363
+ score += 0.10
364
+ parts.append(f"Returns data but wrong count: {actual_count} vs {expected_count} (+0.10)")
365
+ else:
366
+ parts.append("Returns no rows")
367
 
368
+ if "join" in sql_lower and " on " in sql_lower:
369
+ score += 0.20
370
+ parts.append("JOIN...ON syntax present (+0.20)")
371
+ elif "join" in sql_lower:
372
+ parts.append("JOIN present but missing ON keyword")
373
 
374
+ broken_sql = self.task.get("broken_sql", "")
375
+ if "projects.status" in broken_sql and "projects.status" not in sql:
376
  score += 0.10
377
+ parts.append("Fixed table alias in WHERE (+0.10)")
378
+ elif "group by" in sql_lower:
379
+ if "group by department" in sql_lower or "group by e.department" in sql_lower:
380
+ score += 0.10
381
+ parts.append("GROUP BY correct column (+0.10)")
382
+
383
+ return self._clamp(score), " | ".join(parts)
384
 
385
+ def _grade_optimize(self, sql: str, rows: list) -> Tuple[float, str]:
386
  score = 0.0
387
+ parts = []
388
  sql_upper = sql.upper()
389
+ sql_lower = sql.lower()
390
 
391
+ uses_cte = "WITH " in sql_upper
392
+ uses_window = any(fn in sql_upper for fn in ["ROW_NUMBER", "RANK(", "DENSE_RANK"])
393
+ if uses_cte or uses_window:
394
  score += 0.30
395
+ label = "CTE" if uses_cte else "window function"
396
+ parts.append(f"Uses {label} (+0.30)")
397
+ else:
398
+ parts.append("No CTE or window function found")
399
 
400
+ if "join" in sql_lower:
401
+ score += 0.20
402
+ parts.append("Uses JOIN (+0.20)")
403
+ else:
404
+ parts.append("No JOIN found")
405
 
406
  select_count = sql_upper.count("SELECT")
407
  if select_count == 1:
408
  score += 0.20
409
+ parts.append("No nested SELECT no correlated subqueries (+0.20)")
410
+ elif select_count == 2:
411
+ score += 0.10
412
+ parts.append(f"Reduced to {select_count} SELECTs (+0.10)")
413
  else:
414
+ parts.append(f"Still has {select_count} SELECTs (correlated subqueries remain)")
415
 
416
+ expected_count = self.task.get("expected_row_count", 0)
417
+ actual_count = len(rows) if rows else 0
418
+ if actual_count == expected_count:
419
+ score += 0.25
420
+ parts.append(f"Correct row count: {actual_count} (+0.25)")
421
+ elif actual_count > 0:
422
+ score += 0.10
423
+ parts.append(f"Returns data, wrong count: {actual_count} vs {expected_count} (+0.10)")
424
+ else:
425
+ parts.append("Returns no rows")
426
 
427
+ good_patterns = self.task.get("good_patterns", [])
428
+ found_good = [p for p in good_patterns if p.upper() in sql_upper]
429
+ if len(found_good) >= 2:
430
+ score += 0.05
431
+ parts.append(f"Good patterns found: {found_good} (+0.05)")
432
+
433
+ return self._clamp(score), " | ".join(parts)
 
 
 
 
 
 
 
 
 
 
434
 
435
+ def _build_result(self, reward, feedback, last_sql=None,
436
+ last_result=None, last_error=None) -> dict:
437
+ safe_reward = self._clamp(reward)
438
+ observation = {
439
+ "task_id": self.task_id or "",
440
+ "task_type": self.task["type"] if self.task else "",
441
+ "task_description": self.task["description"] if self.task else "",
442
+ "schema_info": get_schema_info(),
443
+ "sample_data": get_sample_data(),
444
+ "expected_description": self._get_hints(),
445
+ "last_sql": last_sql,
446
+ "last_result": last_result,
447
+ "last_error": last_error,
448
  "step_count": self.step_count,
449
  "done": self.done,
450
+ "reward": safe_reward,
451
  "feedback": feedback,
452
+ "metadata": {}
453
  }
454
+ return {
455
+ "observation": observation,
456
+ "reward": safe_reward,
457
+ "done": self.done,
458
+ "info": {
459
+ "episode_id": self.episode_id,
460
+ "total_reward": round(self.total_reward, 3),
461
+ "best_score": self.best_score,
462
+ "attempts": self.attempts
463
+ }
464
+ }
465
+
466
+ def _get_hints(self) -> str:
467
+ if not self.task:
468
+ return ""
469
+ hints = (self.task.get("hints") or self.task.get("optimization_hints") or self.task.get("bugs") or [])
470
+ return "Hints: " + "; ".join(hints) if hints else ""
471
 
472
  @property
473
+ def state(self) -> dict:
474
  return {
475
+ "episode_id": self.episode_id,
476
+ "task_type": self.task["type"] if self.task else "",
477
+ "task_id": self.task_id or "",
478
  "step_count": self.step_count,
479
+ "total_reward": round(self.total_reward, 3),
480
+ "best_score_so_far": self.best_score,
481
+ "attempts": self.attempts,
482
+ "done": self.done
483
  }