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

Massive Rewrite: Rebuilt Phase 2 standalone validation constraints perfectly mapped to 0.05-0.95

Browse files
Files changed (4) hide show
  1. inference.py +145 -241
  2. openenv.yaml +37 -29
  3. server/app.py +81 -20
  4. server/environment.py +246 -178
inference.py CHANGED
@@ -1,260 +1,164 @@
1
- """
2
- SQL Data Analyst OpenEnv — baseline inference (hackathon format).
3
-
4
- Environment variables (set before running):
5
- HF_TOKEN Primary API key (Hugging Face / OpenAI-compatible).
6
- API_BASE_URL LLM base URL (e.g. https://api.openai.com/v1 or HF router).
7
- MODEL_NAME Model id for chat completions.
8
- OPENAI_API_KEY Optional fallback if HF_TOKEN is unset.
9
- API_KEY Optional second fallback.
10
-
11
- Optional:
12
- SQL_AGENT_TASK Logged as task= in [START] (default: sql_analyst_episode).
13
- SQL_AGENT_BENCHMARK Logged as env= in [START] (default: sql_agent_openenv).
14
- MAX_STEPS Max env.step calls (default: 24).
15
- OPENAI_SEED Passed to OpenAI only when base URL looks like OpenAI.
16
- ENV_CONTAINER_START true to use SqlEnvClient.from_docker_image (default: false).
17
- IMAGE_NAME / LOCAL_IMAGE_NAME / ENV_IMAGE_NAME Docker image tag when using container.
18
- """
19
-
20
- from __future__ import annotations
21
-
22
  import os
23
  import re
24
- import sys
25
- import textwrap
26
- from typing import Any, Dict, List, Optional
27
-
28
- from dotenv import load_dotenv
29
  from openai import OpenAI
30
 
31
- load_dotenv()
32
-
33
- from client import SqlEnvClient
34
- from models import SqlAction
35
-
36
- # --- Mandatory hackathon configuration ---
37
- API_BASE_URL = os.getenv("API_BASE_URL", "https://api.openai.com/v1")
38
- MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4o-mini")
39
- API_KEY = (
40
- os.getenv("HF_TOKEN")
41
- or os.getenv("OPENAI_API_KEY")
42
- or os.getenv("API_KEY")
43
- )
44
-
45
- TASK_NAME = os.getenv("SQL_AGENT_TASK", "sql_analyst_episode")
46
- BENCHMARK = os.getenv("SQL_AGENT_BENCHMARK", "sql_agent_openenv")
47
-
48
- MAX_STEPS = int(os.getenv("MAX_STEPS", "24"))
49
- OPENAI_SEED = int(os.getenv("OPENAI_SEED", "42"))
50
-
51
- ENV_CONTAINER_START = os.getenv("ENV_CONTAINER_START", "false").lower() == "true"
52
- ENV_IMAGE_NAME = (
53
- os.getenv("LOCAL_IMAGE_NAME")
54
- or os.getenv("IMAGE_NAME")
55
- or os.getenv("ENV_IMAGE_NAME", "sql-agent-env:latest")
56
- )
57
-
58
- # Grader task ids (must match core/tasks.py) for normalized [END] score in [0, 1]
59
- TASK_IDS = [
60
- "employee_payroll_overview",
61
- "department_budget_summary",
62
- "senior_engineering_comp_review",
63
- ]
64
-
65
- SYSTEM_PROMPT = textwrap.dedent(
66
- """
67
- You are an expert Data Analyst and SQL Agent.
68
- You will be provided with a SQL Schema and an instruction.
69
- Reply with exactly one SQL query string to execute.
70
- Do NOT use markdown fences or explanations — only the raw SQL text.
71
- """
72
- ).strip()
73
-
74
-
75
- def log_start(task: str, env: str, model: str) -> None:
76
- print(f"[START] task={task} env={env} model={model}")
77
-
78
- def log_step(
79
- step: int,
80
- action: str,
81
- reward: float,
82
- done: bool,
83
- error: Optional[str],
84
- ) -> None:
85
- err_one = sanitize_one_line(error) if error else "null"
86
- act_one = sanitize_one_line(action)
87
- done_val = "true" if done else "false"
88
- print(f"[STEP] step={step} action={act_one} reward={reward:.2f} done={done_val} error={err_one}")
89
-
90
- def log_end(success: bool, steps: int, score: float) -> None:
91
- succ_val = "true" if success else "false"
92
- print(f"[END] success={succ_val} steps={steps} rewards={score:.2f}")
93
-
94
-
95
- def sanitize_one_line(s: str) -> str:
96
- if not s:
97
- return ""
98
- return " ".join(s.split())
99
-
100
-
101
- def build_user_prompt(step: int, observation: Any, history: List[str]) -> str:
102
- schema = observation.schema_info
103
- instruction = observation.current_task_instruction
104
- exec_result = observation.execution_result or "None"
105
- error = observation.execution_error or "None"
106
- history_block = "\n".join(history[-6:]) if history else "None"
107
- return textwrap.dedent(
108
- f"""
109
- Step: {step}
110
- Database Schema:
111
- {schema}
112
-
113
- Task Instruction:
114
- {instruction}
115
-
116
- Previous Execution Result: {exec_result}
117
- Previous Error: {error}
118
-
119
- Recent history:
120
- {history_block}
121
-
122
- Write the precise SQL query string to accomplish the task instruction. Return nothing else.
123
- """
124
- ).strip()
125
-
126
-
127
- def parse_model_action(response_text: str) -> str:
128
- query = (response_text or "").strip()
129
- if query.startswith("```sql"):
130
- query = query[6:]
131
- if query.startswith("```"):
132
- query = query[3:]
133
- if query.endswith("```"):
134
- query = query[:-3]
135
- return query.strip()
136
-
137
-
138
- def _make_direct_client():
139
- from server.environment import SqlEnvironment
140
-
141
- base_env = SqlEnvironment()
142
-
143
- class DirectClient:
144
- def __init__(self, target):
145
- self.target = target
146
-
147
- def reset(self):
148
- obs = self.target.reset()
149
- return type(
150
- "StepResult",
151
- (),
152
- {"observation": obs, "reward": 0.0, "done": False},
153
- )()
154
-
155
- def step(self, action):
156
- obs = self.target.step(action)
157
- return type(
158
- "StepResult",
159
- (),
160
- {
161
- "observation": obs,
162
- "reward": obs.reward if obs.reward is not None else 0.0,
163
- "done": obs.done,
164
- },
165
- )()
166
-
167
- def close(self):
168
- pass
169
-
170
- return DirectClient(base_env)
171
-
172
-
173
- def main() -> None:
174
- if not API_KEY:
175
- sys.exit(1)
176
-
177
- client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
178
-
179
- if ENV_CONTAINER_START:
180
- env = SqlEnvClient.from_docker_image(ENV_IMAGE_NAME).sync()
181
- else:
182
- env = _make_direct_client()
183
-
184
- history: List[str] = []
185
 
186
- try:
187
- result = env.reset()
188
- observation = result.observation
 
 
 
 
 
 
 
 
189
 
190
- current_task_id = observation.task_id
191
- task_step = 1
192
- log_start(task=current_task_id, env=BENCHMARK, model=MODEL_NAME)
193
-
194
- for step in range(1, MAX_STEPS + 1):
195
- if result.done:
196
- break
197
-
198
- user_prompt = build_user_prompt(step, observation, history)
199
- messages = [
200
- {"role": "system", "content": SYSTEM_PROMPT},
201
- {"role": "user", "content": user_prompt},
202
- ]
203
-
204
- create_kwargs: Dict[str, Any] = {
205
- "model": MODEL_NAME,
206
- "messages": messages,
207
- "temperature": 0.0,
208
- "stream": False,
209
- }
210
- if re.search(r"openai\.com", API_BASE_URL, re.I):
211
- create_kwargs["seed"] = OPENAI_SEED
212
 
 
 
 
 
 
 
213
  try:
214
- completion = client.chat.completions.create(**create_kwargs)
215
- response_text = completion.choices[0].message.content or "SELECT 1"
216
- except Exception:
217
- response_text = "SELECT 1"
 
 
 
 
 
218
 
219
- action_str = parse_model_action(response_text)
220
- result = env.step(SqlAction(query=action_str))
221
- observation = result.observation
 
 
 
 
 
 
 
 
222
 
223
- # reward is obtained from observation task_score or directly from step
224
- task_score = float(observation.task_score if hasattr(observation, 'task_score') and observation.task_score is not None else 0.0)
225
- if hasattr(observation, 'reward') and observation.reward is not None:
226
- task_score = float(observation.reward)
227
-
228
- err_raw = observation.execution_error
229
- next_task_id = getattr(observation, "task_id", None)
 
 
 
 
 
 
 
 
230
 
231
- # task transitions or episode done signals end of current task
232
- task_done = (next_task_id != current_task_id) or result.done
 
 
 
 
 
 
 
 
 
 
 
233
 
234
- log_step(step=task_step, action=action_str, reward=task_score, done=task_done, error=err_raw)
235
-
236
- history.append(f"Q: {action_str[:200]} -> R: {task_score:+.2f}")
237
-
238
- if task_done:
239
- success = (task_score >= 0.95)
240
- log_end(success=success, steps=task_step, score=task_score)
241
- if result.done or not next_task_id:
242
- break
243
- current_task_id = next_task_id
244
- task_step = 1
245
- log_start(task=current_task_id, env=BENCHMARK, model=MODEL_NAME)
246
- history.clear()
247
- else:
248
- task_step += 1
249
 
 
 
 
 
 
 
 
 
250
  except Exception:
251
  pass
252
- finally:
 
 
 
 
 
 
253
  try:
254
- env.close()
255
- except Exception:
256
- pass
257
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
 
259
  if __name__ == "__main__":
260
  main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
  import re
3
+ import json
4
+ import requests
 
 
 
5
  from openai import OpenAI
6
 
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(
87
+ model=MODEL_NAME,
88
+ messages=[
89
+ {"role": "system", "content": SYSTEM_PROMPT},
90
+ {"role": "user", "content": prompt}
91
+ ],
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()
openenv.yaml CHANGED
@@ -1,8 +1,9 @@
1
  name: sql-debugger-env
2
  version: "1.0.0"
3
  description: >
4
- RL environment for training AI agents to write, debug, and optimize SQL queries
5
- against a real SQLite database with employees, departments, and projects.
 
6
 
7
  tasks:
8
  - id: easy_01
@@ -10,64 +11,71 @@ tasks:
10
  difficulty: easy
11
  type: write_query
12
  grader: true
13
- description: "Find Engineering employees with salary above 90000"
14
-
15
  - id: easy_02
16
- name: Count per department
17
  difficulty: easy
18
  type: write_query
19
  grader: true
20
- description: "Count employees per department, return department and count ordered by count DESC."
21
 
22
  - id: medium_01
23
  name: Fix broken JOIN
24
  difficulty: medium
25
  type: fix_query
26
  grader: true
27
- description: "Fix a broken JOIN query (missing ON keyword, wrong table alias in WHERE). Return name and department name."
28
 
29
  - id: medium_02
30
- name: Fix GROUP BY
31
  difficulty: medium
32
  type: fix_query
33
  grader: true
34
- description: "Fix wrong GROUP BY (should GROUP BY department not id) finding max salary per department."
35
 
36
  - id: hard_01
37
  name: Optimize correlated subquery
38
  difficulty: hard
39
  type: optimize_query
40
  grader: true
41
- description: "Replace correlated subquery with window function/CTE to find top earner per department with total hours."
42
 
43
  - id: hard_02
44
- name: Eliminate N+1 subqueries
45
  difficulty: hard
46
  type: optimize_query
47
  grader: true
48
- description: "Eliminate N+1 subqueries using LEFT JOIN + GROUP BY to compute total hours per project."
49
 
50
  action_space:
51
  type: object
52
- fields:
53
- action_type: string
54
- sql: string
55
- explanation: string
 
 
 
 
 
 
56
 
57
  observation_space:
58
  type: object
59
- fields:
60
- task_id: string
61
- task_type: string
62
- task_description: string
63
- schema_info: string
64
- last_sql: string
65
- last_result: string
66
- last_error: string
67
- step_count: integer
68
- done: boolean
69
- reward: float
70
- feedback: string
 
71
 
72
- reward_range: [0.0, 1.0]
73
  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, 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
  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
53
+ properties:
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
server/app.py CHANGED
@@ -1,28 +1,34 @@
1
- from fastapi import FastAPI, Request
2
  from pydantic import BaseModel
3
  from typing import Optional, Dict, Any
4
 
5
- from server.environment import SqlEnvironment, TASKS
6
 
7
- app = FastAPI(title="SQL Agent Environment API")
8
-
9
- env = SqlEnvironment()
10
 
11
  class ResetRequest(BaseModel):
12
  task_id: Optional[str] = None
13
  difficulty: Optional[str] = None
14
 
15
- class ActionRequest(BaseModel):
16
- sql: str
17
- action_type: Optional[str] = None
 
 
18
 
19
  class GradeRequest(BaseModel):
20
  task_id: str
21
- action: ActionRequest
22
 
23
  @app.get("/")
24
  def read_root():
25
- return {"info": "SQL Agent Environment API"}
 
 
 
 
 
26
 
27
  @app.get("/health")
28
  def read_health():
@@ -32,10 +38,6 @@ def read_health():
32
  def read_state():
33
  return env.state
34
 
35
- @app.get("/tasks")
36
- def list_tasks():
37
- return {"tasks": TASKS}
38
-
39
  @app.post("/reset")
40
  def do_reset(req: Optional[ResetRequest] = None):
41
  if req:
@@ -45,19 +47,78 @@ def do_reset(req: Optional[ResetRequest] = None):
45
  return obs
46
 
47
  @app.post("/step")
48
- def do_step(action: ActionRequest):
49
- obs = env.step({"sql": action.sql, "action_type": action.action_type})
50
  return obs
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  @app.post("/grade")
53
  def do_grade(req: GradeRequest):
54
  env.reset(task_id=req.task_id)
55
- obs = env.step({"sql": req.action.sql, "action_type": req.action.action_type})
56
- score = max(0.05, min(0.95, float(obs["reward"])))
 
 
 
57
 
58
  return {
59
  "task_id": req.task_id,
60
  "score": score,
61
- "feedback": obs["feedback"],
62
- "done": obs["done"]
 
63
  }
 
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
13
 
14
+ class StepRequest(BaseModel):
15
+ action_type: str = "write_query"
16
+ sql: str = ""
17
+ explanation: Optional[str] = None
18
+ metadata: dict = {}
19
 
20
  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",
29
+ "tasks": 6,
30
+ "status": "running"
31
+ }
32
 
33
  @app.get("/health")
34
  def read_health():
 
38
  def read_state():
39
  return env.state
40
 
 
 
 
 
41
  @app.post("/reset")
42
  def do_reset(req: Optional[ResetRequest] = None):
43
  if req:
 
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
+ {
59
+ "id": "easy_01",
60
+ "name": "Filter high earners",
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",
68
+ "name": "Count by department",
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",
76
+ "name": "Fix broken JOIN",
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",
84
+ "name": "Fix wrong GROUP BY",
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",
92
+ "name": "Optimize correlated subquery",
93
+ "difficulty": "hard",
94
+ "type": "optimize_query",
95
+ "grader": True,
96
+ "description": "Replace correlated subqueries with window functions"
97
+ },
98
+ {
99
+ "id": "hard_02",
100
+ "name": "Eliminate N+1 problem",
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
  }
server/environment.py CHANGED
@@ -4,257 +4,325 @@ 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 INTEGER, hire_date TEXT, manager_id INTEGER);
8
- CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT, budget INTEGER, 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 INTEGER);
11
  """
12
 
13
  SEED_DATA_SQL = """
14
- INSERT INTO employees VALUES (1, 'Alice', 'Engineering', 95000, '2020-01-15', 3);
15
- INSERT INTO employees VALUES (2, 'Bob', 'Engineering', 85000, '2021-02-20', 3);
16
- INSERT INTO employees VALUES (3, 'Charlie', 'Engineering', 120000, '2019-01-10', NULL);
17
- INSERT INTO employees VALUES (4, 'David', 'Sales', 70000, '2022-03-05', 5);
18
- INSERT INTO employees VALUES (5, 'Eve', 'Sales', 92000, '2018-11-20', NULL);
19
- INSERT INTO employees VALUES (6, 'Frank', 'HR', 65000, '2023-05-12', NULL);
20
-
21
- INSERT INTO departments VALUES (1, 'Engineering', 500000, 'Building A');
22
- INSERT INTO departments VALUES (2, 'Sales', 300000, 'Building B');
23
- INSERT INTO departments VALUES (3, 'HR', 150000, 'Building C');
24
-
25
- INSERT INTO projects VALUES (1, 'Project Alpha', 1, '2023-01-01', 'Active');
26
- INSERT INTO projects VALUES (2, 'Project Beta', 2, '2023-06-15', 'Completed');
27
-
28
- INSERT INTO project_assignments VALUES (1, 1, 100);
29
- INSERT INTO project_assignments VALUES (2, 1, 150);
30
- INSERT INTO project_assignments VALUES (4, 2, 80);
31
  """
32
 
33
- TASKS = [
34
- {
35
- "id": "easy_01",
36
- "name": "Filter high earners",
37
- "difficulty": "easy",
38
- "type": "write_query",
39
- "grader": True,
40
- "description": "Find Engineering employees with salary > 90000, return name and salary ordered by salary DESC.",
41
- "expected_rows": 2
42
- },
43
- {
44
- "id": "easy_02",
45
- "name": "Count per department",
46
- "difficulty": "easy",
47
- "type": "write_query",
48
- "grader": True,
49
- "description": "Count employees per department, return department and count ordered by count DESC.",
50
- "expected_rows": 3
51
- },
52
- {
53
- "id": "medium_01",
54
- "name": "Fix broken JOIN",
55
- "difficulty": "medium",
56
- "type": "fix_query",
57
- "grader": True,
58
- "description": "Fix a broken JOIN query (missing ON keyword, wrong table alias in WHERE). Return name and department name.",
59
- "expected_rows": 6
60
- },
61
- {
62
- "id": "medium_02",
63
- "name": "Fix GROUP BY",
64
- "difficulty": "medium",
65
- "type": "fix_query",
66
- "grader": True,
67
- "description": "Fix wrong GROUP BY (should GROUP BY department not id) finding max salary per department.",
68
- "expected_rows": 3
69
- },
70
- {
71
- "id": "hard_01",
72
- "name": "Optimize correlated subquery",
73
- "difficulty": "hard",
74
- "type": "optimize_query",
75
- "grader": True,
76
- "description": "Replace correlated subquery with window function/CTE to find top earner per department with total hours.",
77
- "expected_rows": 3
78
- },
79
- {
80
- "id": "hard_02",
81
- "name": "Eliminate N+1 subqueries",
82
- "difficulty": "hard",
83
- "type": "optimize_query",
84
- "grader": True,
85
- "description": "Eliminate N+1 subqueries using LEFT JOIN + GROUP BY to compute total hours per project.",
86
- "expected_rows": 2
87
- }
88
- ]
89
 
90
- def build_db() -> sqlite3.Connection:
91
- conn = sqlite3.connect(':memory:')
92
- conn.row_factory = sqlite3.Row
93
- conn.executescript(SCHEMA_SQL)
94
- conn.executescript(SEED_DATA_SQL)
95
- conn.commit()
96
- return conn
97
-
98
- class SqlEnvironment:
99
  def __init__(self):
100
- self.db = build_db()
101
- self.max_steps = 8
102
  self.step_count = 0
103
- self.current_task = TASKS[0]
104
  self.done = False
 
 
105
  self.last_sql = ""
106
  self.last_result = ""
107
  self.last_error = ""
108
- self.reward = 0.05
109
- self.feedback = ""
 
 
 
 
 
 
 
 
 
 
 
 
110
 
111
  def _clamp(self, score: float) -> float:
112
  return round(max(0.05, min(0.95, score)), 4)
113
 
114
- def _execute_sql(self, sql: str) -> tuple[Optional[List[Dict[str, Any]]], Optional[str]]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  upper_sql = sql.upper()
116
  blocked = ["DROP", "DELETE", "INSERT", "UPDATE", "CREATE", "ALTER", "TRUNCATE"]
117
  for b in blocked:
118
  if re.search(rf"\b{b}\b", upper_sql):
119
- return None, f"Blocked keyword {b} found. Only SELECT allowed."
 
120
  try:
121
- cursor = self.db.cursor()
122
  cursor.execute(sql)
123
  rows = cursor.fetchall()
124
- return [dict(row) for row in rows], None
 
 
 
 
125
  except Exception as e:
126
  return None, str(e)
127
 
128
- def _grade_write(self, sql: str, rows: Optional[List[Dict]], expected_rows: int) -> float:
129
  score = 0.0
130
- upper_sql = sql.upper()
131
- if "EMPLOYEES" in upper_sql or "DEPARTMENTS" in upper_sql:
 
 
132
  score += 0.15
133
- if "WHERE" in upper_sql:
 
 
134
  score += 0.10
135
- if "ORDER BY" in upper_sql:
136
- score += 0.15
 
 
 
 
 
 
 
 
137
  if rows is not None:
138
- if len(rows) > 0:
139
- score += 0.25
140
  if len(rows) == expected_rows:
141
  score += 0.25
 
142
  elif abs(len(rows) - expected_rows) == 1:
143
  score += 0.12
144
- return self._clamp(score)
 
 
 
 
 
 
 
 
145
 
146
- def _grade_fix(self, sql: str, rows: Optional[List[Dict]], expected_rows: int) -> float:
147
  score = 0.0
148
- upper_sql = sql.upper()
149
- if rows is not None:
 
 
150
  score += 0.35
151
- if len(rows) == expected_rows:
152
- score += 0.35
153
- if re.search(r"\bJOIN\b.*\bON\b", upper_sql):
 
 
 
 
 
 
 
154
  score += 0.15
155
- if "WHERE" in upper_sql or "GROUP BY" in upper_sql:
 
 
156
  score += 0.10
157
- return self._clamp(score)
 
 
158
 
159
- def _grade_optimize(self, sql: str, rows: Optional[List[Dict]], expected_rows: int) -> float:
160
  score = 0.0
161
- upper_sql = sql.upper()
162
- if re.search(r"\b(WITH|ROW_NUMBER|RANK)\b", upper_sql):
 
 
163
  score += 0.30
164
- if "JOIN" in upper_sql:
 
 
165
  score += 0.25
166
- if upper_sql.count("SELECT") == 1:
 
 
 
167
  score += 0.20
 
 
 
 
 
168
  if rows is not None and len(rows) == expected_rows:
169
  score += 0.20
170
- return self._clamp(score)
 
 
171
 
172
- def reset(self, task_id: Optional[str] = None, difficulty: Optional[str] = None) -> Dict[str, Any]:
173
- self.step_count = 0
174
- self.done = False
175
- self.last_sql = ""
176
- self.last_result = ""
177
- self.last_error = ""
178
- self.reward = self._clamp(0.0)
179
- self.feedback = "Environment reset."
180
 
181
- if task_id:
182
- for t in TASKS:
183
- if t["id"] == task_id:
184
- self.current_task = t
185
- break
186
- elif difficulty:
187
- for t in TASKS:
188
- if t["difficulty"] == difficulty:
189
- self.current_task = t
190
- break
191
- else:
192
- self.current_task = TASKS[0]
193
-
194
- return self.get_observation()
195
 
196
- def get_observation(self) -> Dict[str, Any]:
197
- return {
198
- "task_id": self.current_task["id"],
199
- "task_type": self.current_task["type"],
200
- "task_description": self.current_task["description"],
201
- "schema_info": SCHEMA_SQL.strip(),
202
- "last_sql": self.last_sql,
203
- "last_result": self.last_result,
204
- "last_error": self.last_error,
 
 
205
  "step_count": self.step_count,
206
  "done": self.done,
207
- "reward": self.reward,
208
- "feedback": self.feedback
 
209
  }
 
210
 
211
  @property
212
- def state(self) -> Dict[str, Any]:
213
  return {
214
- "task": self.current_task["id"],
215
- "step": self.step_count,
216
- "done": self.done,
217
- "reward": self.reward
 
 
 
218
  }
219
 
220
- def step(self, action: Dict[str, Any]) -> Dict[str, Any]:
221
- if self.done:
222
- return self.get_observation()
223
-
224
  self.step_count += 1
225
  sql = action.get("sql", "").strip()
226
  self.last_sql = sql
227
 
228
  if not sql:
229
- self.last_error = "No SQL provided."
230
- self.last_result = ""
231
  self.reward = self._clamp(0.0)
232
- self.feedback = "Empty query."
 
 
233
  else:
234
- rows, err = self._execute_sql(sql)
235
  if err:
236
  self.last_error = err
237
  self.last_result = ""
238
- self.reward = self._clamp(0.0)
239
- self.feedback = "Execution failed."
240
  else:
241
  self.last_error = ""
242
- self.last_result = json.dumps(rows)
243
- expected = self.current_task["expected_rows"]
244
- ttype = self.current_task["type"]
245
 
246
- raw_score = 0.0
 
 
 
 
 
 
 
 
 
 
247
  if ttype == "write_query":
248
- raw_score = self._grade_write(sql, rows, expected)
249
  elif ttype == "fix_query":
250
- raw_score = self._grade_fix(sql, rows, expected)
251
  elif ttype == "optimize_query":
252
- raw_score = self._grade_optimize(sql, rows, expected)
253
-
254
- self.reward = raw_score # already clamped internally by the functions
255
- self.feedback = "Query executed successfully."
256
 
257
- if self.step_count >= self.max_steps:
258
  self.done = True
259
-
260
- return self.get_observation()
 
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)