P-Karthik-Mohan commited on
Commit
a97839e
·
0 Parent(s):

Initial submission

Browse files
Files changed (9) hide show
  1. Dockerfile +28 -0
  2. __pycache__/main.cpython-313.pyc +0 -0
  3. data/ecommerce.db +0 -0
  4. inference.py +264 -0
  5. main.py +367 -0
  6. openenv.yaml +163 -0
  7. requirements.txt +6 -0
  8. results.json +30 -0
  9. seed.py +181 -0
Dockerfile ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # Set working directory
4
+ WORKDIR /app
5
+
6
+ # Install system dependencies
7
+ RUN apt-get update && apt-get install -y \
8
+ gcc \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ # Copy requirements first (better Docker layer caching)
12
+ COPY requirements.txt .
13
+ RUN pip install --no-cache-dir -r requirements.txt
14
+
15
+ # Copy all project files
16
+ COPY . .
17
+
18
+ # Create data directory
19
+ RUN mkdir -p data
20
+
21
+ # Seed the database at build time so it's ready instantly
22
+ RUN python seed.py
23
+
24
+ # Hugging Face Spaces uses port 7860
25
+ EXPOSE 7860
26
+
27
+ # Start the FastAPI server
28
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
__pycache__/main.cpython-313.pyc ADDED
Binary file (15.3 kB). View file
 
data/ecommerce.db ADDED
Binary file (57.3 kB). View file
 
inference.py ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ inference.py — Baseline AI agent for SQL Analyst OpenEnv
3
+ ---------------------------------------------------------
4
+ Uses the OpenAI client (pointed at any compatible LLM via API_BASE_URL)
5
+ to solve all 3 tasks by interacting with the running FastAPI environment.
6
+
7
+ Environment variables required:
8
+ API_BASE_URL — LLM API base URL (e.g. https://api.openai.com/v1)
9
+ MODEL_NAME — model to use (e.g. gpt-4o-mini)
10
+ HF_TOKEN — Hugging Face token (used as the API key)
11
+
12
+ Usage:
13
+ python inference.py
14
+ """
15
+
16
+ import os
17
+ import sys
18
+ import json
19
+ import time
20
+ import requests
21
+ from openai import OpenAI
22
+
23
+ # ── Configuration ─────────────────────────────────────────────────────────────
24
+
25
+ ENV_BASE_URL = "http://127.0.0.1:7860" # where FastAPI server is running
26
+ MAX_ATTEMPTS = 5 # max SQL attempts per task
27
+ TASK_IDS = [1, 2, 3] # tasks to solve
28
+
29
+ API_BASE_URL = "https://api.groq.com/openai/v1"
30
+ MODEL_NAME = "llama-3.1-8b-instant"
31
+ HF_TOKEN = "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"
32
+
33
+ # ── OpenAI Client ─────────────────────────────────────────────────────────────
34
+
35
+ client = OpenAI(
36
+ base_url=API_BASE_URL,
37
+ api_key=HF_TOKEN if HF_TOKEN else "no-key-needed",
38
+ )
39
+
40
+ # ── Environment API helpers ───────────────────────────────────────────────────
41
+
42
+ def env_reset(task_id: int) -> dict:
43
+ r = requests.post(f"{ENV_BASE_URL}/reset", json={"task_id": task_id})
44
+ r.raise_for_status()
45
+ return r.json()
46
+
47
+
48
+ def env_step(sql: str) -> dict:
49
+ r = requests.post(f"{ENV_BASE_URL}/step", json={"action": sql})
50
+ r.raise_for_status()
51
+ return r.json()
52
+
53
+
54
+ def env_state() -> dict:
55
+ r = requests.get(f"{ENV_BASE_URL}/state")
56
+ r.raise_for_status()
57
+ return r.json()
58
+
59
+
60
+ def wait_for_server(retries: int = 10, delay: float = 2.0):
61
+ """Wait until the FastAPI server is ready."""
62
+ print("Waiting for environment server...")
63
+ for i in range(retries):
64
+ try:
65
+ r = requests.get(f"{ENV_BASE_URL}/health", timeout=3)
66
+ if r.status_code == 200:
67
+ print("Server is ready.\n")
68
+ return
69
+ except requests.exceptions.ConnectionError:
70
+ pass
71
+ print(f" Not ready yet, retrying in {delay}s... ({i+1}/{retries})")
72
+ time.sleep(delay)
73
+ print("ERROR: Server did not start in time. Is uvicorn running?")
74
+ sys.exit(1)
75
+
76
+ # ── LLM SQL Generator ─────────────────────────────────────────────────────────
77
+
78
+ def build_system_prompt() -> str:
79
+ return """You are an expert SQL analyst. Your job is to write correct SQLite queries.
80
+
81
+ Rules:
82
+ - Only write SELECT or WITH (CTE) statements. Never INSERT, UPDATE, DELETE, or DROP.
83
+ - Always match the exact column names specified in the task.
84
+ - Use proper SQLite syntax. RANK() OVER (...) is supported in SQLite 3.25+.
85
+ - Return ONLY the raw SQL query — no explanation, no markdown, no backticks.
86
+ - If a previous attempt scored less than 1.0, study the feedback and fix the query.
87
+ """
88
+
89
+
90
+ def build_user_prompt(
91
+ task_description: str,
92
+ schema: str,
93
+ hint: str,
94
+ attempt: int,
95
+ previous_attempts: list,
96
+ ) -> str:
97
+ prompt = f"""Task:
98
+ {task_description}
99
+
100
+ Database schema:
101
+ {schema}
102
+
103
+ Hint: {hint}
104
+
105
+ Attempt number: {attempt}
106
+ """
107
+ if previous_attempts:
108
+ prompt += "\nYour previous attempts and their scores:\n"
109
+ for prev in previous_attempts[-3:]: # show last 3 only
110
+ prompt += f"""
111
+ Attempt {prev['attempt']}:
112
+ SQL: {prev['sql']}
113
+ Reward: {prev['reward']} / 1.0
114
+ Columns expected : {prev['details'].get('expected_columns', [])}
115
+ Columns you gave : {prev['details'].get('agent_columns', [])}
116
+ Rows expected : {prev['details'].get('expected_row_count', '?')}
117
+ Rows you gave : {prev['details'].get('agent_row_count', '?')}
118
+ Sub-scores : columns={prev['details'].get('column_score', 0):.2f} rows={prev['details'].get('row_score', 0):.2f} values={prev['details'].get('value_score', 0):.2f}
119
+ """
120
+ prompt += "\nWrite the corrected SQL query now:"
121
+ return prompt
122
+
123
+
124
+ def ask_llm(task_description: str, schema: str, hint: str,
125
+ attempt: int, previous_attempts: list) -> str:
126
+ """Call the LLM and return a SQL string."""
127
+ messages = [
128
+ {"role": "system", "content": build_system_prompt()},
129
+ {"role": "user", "content": build_user_prompt(
130
+ task_description, schema, hint, attempt, previous_attempts
131
+ )},
132
+ ]
133
+ response = client.chat.completions.create(
134
+ model=MODEL_NAME,
135
+ messages=messages,
136
+ temperature=0.0, # deterministic — we want correct SQL, not creative SQL
137
+ max_tokens=512,
138
+ )
139
+ sql = response.choices[0].message.content.strip()
140
+
141
+ # Strip markdown fences if model wraps in ```sql ... ```
142
+ if sql.startswith("```"):
143
+ lines = sql.split("\n")
144
+ sql = "\n".join(
145
+ line for line in lines
146
+ if not line.strip().startswith("```")
147
+ ).strip()
148
+
149
+ return sql
150
+
151
+ # ── Main Agent Loop ───────────────────────────────────────────────────────────
152
+
153
+ def solve_task(task_id: int) -> dict:
154
+ """Run the agent on a single task. Returns final result dict."""
155
+ print(f"\n{'='*60}")
156
+ print(f"TASK {task_id}")
157
+ print('='*60)
158
+
159
+ # Reset environment
160
+ reset_resp = env_reset(task_id)
161
+ obs = reset_resp["observation"]
162
+ task_desc = obs["task_description"]
163
+ schema = obs["schema"]
164
+ hint = obs["hint"]
165
+ difficulty = obs["difficulty"]
166
+
167
+ print(f"Difficulty : {difficulty.upper()}")
168
+ print(f"Task : {task_desc}\n")
169
+
170
+ previous_attempts = []
171
+ best_reward = 0.0
172
+ final_sql = ""
173
+
174
+ for attempt in range(1, MAX_ATTEMPTS + 1):
175
+ print(f" Attempt {attempt}/{MAX_ATTEMPTS} — asking LLM...")
176
+
177
+ # Get SQL from LLM
178
+ sql = ask_llm(task_desc, schema, hint, attempt, previous_attempts)
179
+ print(f" SQL: {sql[:120]}{'...' if len(sql) > 120 else ''}")
180
+
181
+ # Submit to environment
182
+ step_resp = env_step(sql)
183
+ reward = step_resp["reward"]
184
+ done = step_resp["done"]
185
+ details = step_resp["observation"].get("reward_breakdown", {})
186
+
187
+ print(f" Reward: {reward:.3f} "
188
+ f"(cols={details.get('column_score',0):.2f} "
189
+ f"rows={details.get('row_score',0):.2f} "
190
+ f"vals={details.get('value_score',0):.2f})")
191
+
192
+ best_reward = max(best_reward, reward)
193
+ final_sql = sql
194
+
195
+ # Store attempt for LLM feedback
196
+ previous_attempts.append({
197
+ "attempt": attempt,
198
+ "sql": sql,
199
+ "reward": reward,
200
+ "details": details,
201
+ })
202
+
203
+ if done:
204
+ print(f" PERFECT SCORE on attempt {attempt}!")
205
+ break
206
+ elif reward >= 0.8:
207
+ print(f" Score is close ({reward:.3f}). Trying to improve...")
208
+ else:
209
+ print(f" Score is low ({reward:.3f}). Refining query...")
210
+
211
+ return {
212
+ "task_id": task_id,
213
+ "difficulty": difficulty,
214
+ "best_reward": best_reward,
215
+ "attempts": len(previous_attempts),
216
+ "final_sql": final_sql,
217
+ "solved": best_reward >= 1.0,
218
+ }
219
+
220
+
221
+ def main():
222
+ print("SQL Analyst OpenEnv — Baseline Inference Agent")
223
+ print(f"Model : {MODEL_NAME}")
224
+ print(f"API Base : {API_BASE_URL}")
225
+ print(f"Env Server : {ENV_BASE_URL}")
226
+
227
+ wait_for_server()
228
+
229
+ results = []
230
+ for task_id in TASK_IDS:
231
+ result = solve_task(task_id)
232
+ results.append(result)
233
+
234
+ # ── Final Summary ─────────────────────────────────────────────────────────
235
+ print(f"\n{'='*60}")
236
+ print("FINAL RESULTS")
237
+ print('='*60)
238
+
239
+ total_score = 0.0
240
+ for r in results:
241
+ status = "SOLVED" if r["solved"] else f"best={r['best_reward']:.3f}"
242
+ print(f" Task {r['task_id']} ({r['difficulty']:6s}) {status} "
243
+ f"in {r['attempts']} attempt(s)")
244
+ total_score += r["best_reward"]
245
+
246
+ avg_score = total_score / len(results)
247
+ print(f"\n Average reward : {avg_score:.3f} / 1.000")
248
+ print(f" Tasks solved : {sum(1 for r in results if r['solved'])} / {len(results)}")
249
+
250
+ # Save results to file (useful for judges / CI)
251
+ output_path = "results.json"
252
+ with open(output_path, "w") as f:
253
+ json.dump({
254
+ "results": results,
255
+ "avg_score": round(avg_score, 3),
256
+ "tasks_solved": sum(1 for r in results if r["solved"]),
257
+ }, f, indent=2)
258
+ print(f"\n Results saved to {output_path}")
259
+
260
+ return avg_score
261
+
262
+
263
+ if __name__ == "__main__":
264
+ main()
main.py ADDED
@@ -0,0 +1,367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ import os
3
+ import json
4
+ import re
5
+ from datetime import datetime
6
+ from typing import Any, Optional
7
+
8
+ from fastapi import FastAPI, HTTPException
9
+ from pydantic import BaseModel
10
+
11
+ # ── Config ────────────────────────────────────────────────────────────────────
12
+ DB_PATH = os.path.join("data", "ecommerce.db")
13
+ app = FastAPI(title="SQL Analyst OpenEnv", version="1.0.0")
14
+
15
+ # ── Pydantic Models ───────────────────────────────────────────────────────────
16
+
17
+ class StepRequest(BaseModel):
18
+ action: str # The SQL query the agent submits
19
+
20
+ class StepResponse(BaseModel):
21
+ observation: dict
22
+ reward: float # 0.0 – 1.0
23
+ done: bool
24
+ info: dict
25
+
26
+ class ResetRequest(BaseModel):
27
+ task_id: int # 1 = Easy, 2 = Medium, 3 = Hard
28
+
29
+ class ResetResponse(BaseModel):
30
+ observation: dict
31
+ info: dict
32
+
33
+ class StateResponse(BaseModel):
34
+ task_id: int
35
+ task_description: str
36
+ schema_info: str
37
+ attempts: int
38
+ best_reward: float
39
+ history: list
40
+
41
+ # ── Task Definitions ──────────────────────────────────────────────────────────
42
+
43
+ TASKS = {
44
+ 1: {
45
+ "description": (
46
+ "Find the total number of completed orders placed in the year 2024. "
47
+ "Return a single number with column name: total_orders"
48
+ ),
49
+ "difficulty": "easy",
50
+ "hint": "Use COUNT with WHERE filters on status and order_date",
51
+ "answer_query": """
52
+ SELECT COUNT(*) AS total_orders
53
+ FROM orders
54
+ WHERE status = 'completed'
55
+ AND order_date LIKE '2024%'
56
+ """,
57
+ },
58
+ 2: {
59
+ "description": (
60
+ "Find the top 5 customers by total revenue (sum of total_amount for completed orders only). "
61
+ "Return columns: first_name, last_name, total_revenue. "
62
+ "Order by total_revenue descending."
63
+ ),
64
+ "difficulty": "medium",
65
+ "hint": "JOIN orders with customers, GROUP BY customer, filter completed, ORDER and LIMIT",
66
+ "answer_query": """
67
+ SELECT c.first_name, c.last_name,
68
+ ROUND(SUM(o.total_amount), 2) AS total_revenue
69
+ FROM orders o
70
+ JOIN customers c ON o.customer_id = c.customer_id
71
+ WHERE o.status = 'completed'
72
+ GROUP BY o.customer_id
73
+ ORDER BY total_revenue DESC
74
+ LIMIT 5
75
+ """,
76
+ },
77
+ 3: {
78
+ "description": (
79
+ "For each product category, calculate the total revenue (completed orders only) "
80
+ "and rank categories by revenue using a window function. "
81
+ "Return columns: category, total_revenue, revenue_rank. "
82
+ "Order by revenue_rank ascending."
83
+ ),
84
+ "difficulty": "hard",
85
+ "hint": "Use SUM with GROUP BY inside a CTE, then apply RANK() OVER (ORDER BY ...) on the result",
86
+ "answer_query": """
87
+ WITH category_revenue AS (
88
+ SELECT p.category,
89
+ SUM(o.total_amount) AS total_revenue
90
+ FROM orders o
91
+ JOIN products p ON o.product_id = p.product_id
92
+ WHERE o.status = 'completed'
93
+ GROUP BY p.category
94
+ )
95
+ SELECT category,
96
+ total_revenue,
97
+ RANK() OVER (ORDER BY total_revenue DESC) AS revenue_rank
98
+ FROM category_revenue
99
+ ORDER BY revenue_rank ASC
100
+ """,
101
+ },
102
+ }
103
+
104
+ # ── In-Memory Session State ───────────────────────────────────────────────────
105
+
106
+ session = {
107
+ "task_id": None,
108
+ "task": None,
109
+ "expected_rows": None,
110
+ "expected_columns": None,
111
+ "attempts": 0,
112
+ "best_reward": 0.0,
113
+ "history": [],
114
+ }
115
+
116
+ # ── Database Helpers ──────────────────────────────────────────────────────────
117
+
118
+ def get_connection():
119
+ if not os.path.exists(DB_PATH):
120
+ raise HTTPException(
121
+ status_code=500,
122
+ detail=f"Database not found at {DB_PATH}. Run seed.py first."
123
+ )
124
+ conn = sqlite3.connect(DB_PATH)
125
+ conn.row_factory = sqlite3.Row
126
+ return conn
127
+
128
+
129
+ def get_schema_info() -> str:
130
+ conn = get_connection()
131
+ cur = conn.cursor()
132
+ schema_parts = []
133
+ cur.execute("SELECT name FROM sqlite_master WHERE type='table'")
134
+ tables = [r["name"] for r in cur.fetchall()]
135
+ for table in tables:
136
+ cur.execute(f"PRAGMA table_info({table})")
137
+ cols = cur.fetchall()
138
+ col_defs = ", ".join(f"{c['name']} {c['type']}" for c in cols)
139
+ cur.execute(f"SELECT COUNT(*) AS n FROM {table}")
140
+ count = cur.fetchone()["n"]
141
+ schema_parts.append(f" {table} ({col_defs}) -- {count} rows")
142
+ conn.close()
143
+ return "Tables:\n" + "\n".join(schema_parts)
144
+
145
+
146
+ def run_query(sql: str) -> tuple[list[dict], list[str]]:
147
+ """Run a SQL query and return (rows_as_dicts, column_names)."""
148
+ conn = get_connection()
149
+ cur = conn.cursor()
150
+ cur.execute(sql)
151
+ columns = [d[0] for d in cur.description] if cur.description else []
152
+ rows = [dict(zip(columns, row)) for row in cur.fetchall()]
153
+ conn.close()
154
+ return rows, columns
155
+
156
+
157
+ def compute_expected():
158
+ """Run the answer query and cache the expected result."""
159
+ task = session["task"]
160
+ rows, columns = run_query(task["answer_query"])
161
+ session["expected_rows"] = rows
162
+ session["expected_columns"] = columns
163
+
164
+ # ── Reward Function ───────────────────────────────────────────────────────────
165
+
166
+ def compute_reward(agent_rows: list[dict], agent_cols: list[str]) -> tuple[float, dict]:
167
+ """
168
+ Partial scoring reward function — 0.0 to 1.0.
169
+
170
+ Breakdown:
171
+ - 0.30 correct column names
172
+ - 0.30 correct number of rows
173
+ - 0.40 correct values (cell-level match)
174
+ """
175
+ expected_rows = session["expected_rows"]
176
+ expected_cols = session["expected_columns"]
177
+ details = {}
178
+
179
+ # ── Column score (0.30) ──────────────────────────────────────────────────
180
+ agent_cols_lower = [c.lower() for c in agent_cols]
181
+ expected_cols_lower = [c.lower() for c in expected_cols]
182
+ col_matches = sum(1 for c in expected_cols_lower if c in agent_cols_lower)
183
+ col_score = (col_matches / len(expected_cols_lower)) * 0.30 if expected_cols_lower else 0.0
184
+ details["column_score"] = round(col_score, 3)
185
+ details["expected_columns"] = expected_cols
186
+ details["agent_columns"] = agent_cols
187
+
188
+ # ── Row count score (0.30) ───────────────────────────────────────────────
189
+ expected_count = len(expected_rows)
190
+ agent_count = len(agent_rows)
191
+ if expected_count == 0:
192
+ row_score = 0.30 if agent_count == 0 else 0.0
193
+ else:
194
+ row_ratio = min(agent_count, expected_count) / max(agent_count, expected_count)
195
+ row_score = row_ratio * 0.30
196
+ details["row_score"] = round(row_score, 3)
197
+ details["expected_row_count"] = expected_count
198
+ details["agent_row_count"] = agent_count
199
+
200
+ # ── Value match score (0.40) ─────────────────────────────────────────────
201
+ if not expected_rows or not agent_rows:
202
+ value_score = 0.0
203
+ else:
204
+ def normalize(v):
205
+ if v is None:
206
+ return ""
207
+ try:
208
+ return str(round(float(v), 1))
209
+ except (ValueError, TypeError):
210
+ return str(v).strip().lower()
211
+
212
+
213
+ matched_cells = 0
214
+ total_cells = len(expected_rows) * len(expected_cols_lower)
215
+
216
+ for exp_row, agt_row in zip(expected_rows, agent_rows):
217
+ for col in expected_cols_lower:
218
+ exp_val = normalize(exp_row.get(col) or exp_row.get(col.upper()))
219
+ # Try matching by column name first, then by position
220
+ agt_val = normalize(agt_row.get(col) or agt_row.get(col.upper()))
221
+ if not agt_val:
222
+ # Fall back to positional match
223
+ exp_idx = expected_cols_lower.index(col)
224
+ if exp_idx < len(agent_cols):
225
+ pos_col = agent_cols[exp_idx]
226
+ agt_val = normalize(agt_row.get(pos_col))
227
+ if exp_val == agt_val:
228
+ matched_cells += 1
229
+
230
+ value_score = (matched_cells / total_cells) * 0.40 if total_cells > 0 else 0.0
231
+ details["value_score"] = round(value_score, 3)
232
+
233
+ total = round(col_score + row_score + value_score, 3)
234
+ details["total_reward"] = total
235
+ return total, details
236
+
237
+ # ── Endpoints ─────────────────────────────────────────────────────────────────
238
+
239
+ @app.post("/reset", response_model=ResetResponse)
240
+ def reset(req: ResetRequest):
241
+ """Start a new task. Call this before /step."""
242
+ if req.task_id not in TASKS:
243
+ raise HTTPException(status_code=400, detail="task_id must be 1, 2, or 3")
244
+
245
+ session["task_id"] = req.task_id
246
+ session["task"] = TASKS[req.task_id]
247
+ session["attempts"] = 0
248
+ session["best_reward"] = 0.0
249
+ session["history"] = []
250
+
251
+ compute_expected()
252
+
253
+ schema = get_schema_info()
254
+ observation = {
255
+ "task_id": req.task_id,
256
+ "difficulty": session["task"]["difficulty"],
257
+ "task_description": session["task"]["description"],
258
+ "schema": schema,
259
+ "hint": session["task"]["hint"],
260
+ }
261
+ return ResetResponse(
262
+ observation=observation,
263
+ info={"message": f"Task {req.task_id} loaded. Use POST /step with your SQL query."}
264
+ )
265
+
266
+
267
+ @app.post("/step", response_model=StepResponse)
268
+ def step(req: StepRequest):
269
+ """Submit a SQL query. Returns reward 0.0–1.0 and feedback."""
270
+ if session["task_id"] is None:
271
+ raise HTTPException(status_code=400, detail="Call /reset first to load a task.")
272
+
273
+ session["attempts"] += 1
274
+ sql = req.action.strip()
275
+
276
+ # ── Safety: only allow SELECT statements ─────────────────────────────────
277
+ if not re.match(r"^\s*(SELECT|WITH)\b", sql, re.IGNORECASE):
278
+ return StepResponse(
279
+ observation={"error": "Only SELECT or WITH (CTE) statements are allowed."},
280
+ reward=0.0,
281
+ done=False,
282
+ info={"attempt": session["attempts"], "message": "Rejected: not a SELECT/WITH query."}
283
+ )
284
+
285
+ # ── Run the agent's query ─────────────────────────────────────────────────
286
+ try:
287
+ agent_rows, agent_cols = run_query(sql)
288
+ except Exception as e:
289
+ entry = {
290
+ "attempt": session["attempts"],
291
+ "sql": sql,
292
+ "reward": 0.0,
293
+ "error": str(e),
294
+ }
295
+ session["history"].append(entry)
296
+ return StepResponse(
297
+ observation={"error": str(e), "sql_submitted": sql},
298
+ reward=0.0,
299
+ done=False,
300
+ info={"attempt": session["attempts"], "message": "SQL execution error."}
301
+ )
302
+
303
+ # ── Score it ──────────────────────────────────────────────────────────────
304
+ reward, details = compute_reward(agent_rows, agent_cols)
305
+ session["best_reward"] = max(session["best_reward"], reward)
306
+
307
+ done = reward >= 1.0
308
+
309
+ entry = {
310
+ "attempt": session["attempts"],
311
+ "sql": sql,
312
+ "reward": reward,
313
+ "details": details,
314
+ "timestamp": datetime.now().isoformat(),
315
+ }
316
+ session["history"].append(entry)
317
+
318
+ observation = {
319
+ "task_id": session["task_id"],
320
+ "task_description": session["task"]["description"],
321
+ "sql_submitted": sql,
322
+ "result_preview": agent_rows[:5], # show first 5 rows
323
+ "result_row_count": len(agent_rows),
324
+ "reward_breakdown": details,
325
+ }
326
+
327
+ return StepResponse(
328
+ observation=observation,
329
+ reward=reward,
330
+ done=done,
331
+ info={
332
+ "attempt": session["attempts"],
333
+ "best_reward": session["best_reward"],
334
+ "message": "Perfect score! Task complete." if done else "Keep refining your query.",
335
+ }
336
+ )
337
+
338
+
339
+ @app.get("/state", response_model=StateResponse)
340
+ def state():
341
+ """Get current session state — task info, attempts, history."""
342
+ if session["task_id"] is None:
343
+ raise HTTPException(status_code=400, detail="No active task. Call /reset first.")
344
+
345
+ return StateResponse(
346
+ task_id=session["task_id"],
347
+ task_description=session["task"]["description"],
348
+ schema_info=get_schema_info(),
349
+ attempts=session["attempts"],
350
+ best_reward=session["best_reward"],
351
+ history=session["history"],
352
+ )
353
+
354
+
355
+ @app.get("/")
356
+ def root():
357
+ return {
358
+ "name": "SQL Analyst OpenEnv",
359
+ "version": "1.0.0",
360
+ "tasks": {k: {"difficulty": v["difficulty"], "description": v["description"]} for k, v in TASKS.items()},
361
+ "endpoints": ["/reset", "/step", "/state"],
362
+ }
363
+
364
+
365
+ @app.get("/health")
366
+ def health():
367
+ return {"status": "ok", "db_exists": os.path.exists(DB_PATH)}
openenv.yaml ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: sql-analyst-env
2
+ version: "1.0.0"
3
+ description: >
4
+ A real-world SQL data analyst environment where an AI agent must write
5
+ correct SQL queries against an e-commerce database to answer business questions.
6
+ Tasks range from simple aggregations (Easy) to window functions and CTEs (Hard).
7
+
8
+ author: "Hackathon Submission"
9
+ license: MIT
10
+
11
+ environment:
12
+ type: http
13
+ base_url: "http://0.0.0.0:7860"
14
+
15
+ endpoints:
16
+ reset:
17
+ method: POST
18
+ path: /reset
19
+ description: Load a task. Returns task description and database schema.
20
+ request_body:
21
+ task_id:
22
+ type: integer
23
+ description: "1 = Easy, 2 = Medium, 3 = Hard"
24
+ required: true
25
+ response:
26
+ observation:
27
+ type: object
28
+ properties:
29
+ task_id: { type: integer }
30
+ difficulty: { type: string }
31
+ task_description: { type: string }
32
+ schema: { type: string }
33
+ hint: { type: string }
34
+ info:
35
+ type: object
36
+
37
+ step:
38
+ method: POST
39
+ path: /step
40
+ description: Submit a SQL query. Returns reward 0.0-1.0 and result preview.
41
+ request_body:
42
+ action:
43
+ type: string
44
+ description: A valid SQLite SELECT or WITH (CTE) statement.
45
+ required: true
46
+ response:
47
+ observation:
48
+ type: object
49
+ properties:
50
+ task_id: { type: integer }
51
+ task_description: { type: string }
52
+ sql_submitted: { type: string }
53
+ result_preview: { type: array }
54
+ result_row_count: { type: integer }
55
+ reward_breakdown:
56
+ type: object
57
+ properties:
58
+ column_score: { type: number }
59
+ row_score: { type: number }
60
+ value_score: { type: number }
61
+ total_reward: { type: number }
62
+ expected_columns: { type: array }
63
+ agent_columns: { type: array }
64
+ expected_row_count: { type: integer }
65
+ agent_row_count: { type: integer }
66
+ reward:
67
+ type: number
68
+ minimum: 0.0
69
+ maximum: 1.0
70
+ description: Partial score. 1.0 = perfect answer.
71
+ done:
72
+ type: boolean
73
+ description: True when reward reaches 1.0
74
+ info:
75
+ type: object
76
+
77
+ state:
78
+ method: GET
79
+ path: /state
80
+ description: Get current task, attempt count, best reward, and full history.
81
+ response:
82
+ task_id: { type: integer }
83
+ task_description: { type: string }
84
+ schema_info: { type: string }
85
+ attempts: { type: integer }
86
+ best_reward: { type: number }
87
+ history: { type: array }
88
+
89
+ observation_space:
90
+ description: >
91
+ On reset: task description, database schema (3 tables), difficulty, hint.
92
+ On step: submitted SQL, result preview (first 5 rows), row count,
93
+ reward breakdown (column / row / value sub-scores).
94
+
95
+ action_space:
96
+ type: string
97
+ description: >
98
+ A single SQLite-compatible SELECT or WITH statement.
99
+ Only read operations are permitted. INSERT/UPDATE/DELETE are rejected.
100
+ examples:
101
+ - "SELECT COUNT(*) AS total_orders FROM orders WHERE status = 'completed'"
102
+ - "SELECT c.first_name, SUM(o.total_amount) AS revenue FROM orders o JOIN customers c ON o.customer_id = c.customer_id GROUP BY o.customer_id ORDER BY revenue DESC LIMIT 5"
103
+ - "WITH rev AS (SELECT p.category, SUM(o.total_amount) AS total FROM orders o JOIN products p ON o.product_id = p.product_id GROUP BY p.category) SELECT *, RANK() OVER (ORDER BY total DESC) AS rnk FROM rev"
104
+
105
+ reward:
106
+ type: float
107
+ range: [0.0, 1.0]
108
+ description: >
109
+ Partial credit reward with three components:
110
+ - Column names correct : 0.30
111
+ - Row count correct : 0.30
112
+ - Cell values correct : 0.40
113
+ partial_credit: true
114
+
115
+ tasks:
116
+ - id: 1
117
+ name: "Count completed orders"
118
+ difficulty: easy
119
+ description: >
120
+ Find the total number of completed orders placed in 2024.
121
+ Return a single number with column name: total_orders.
122
+
123
+ - id: 2
124
+ name: "Top 5 customers by revenue"
125
+ difficulty: medium
126
+ description: >
127
+ Find the top 5 customers by total revenue from completed orders.
128
+ Return: first_name, last_name, total_revenue. Order by revenue DESC.
129
+
130
+ - id: 3
131
+ name: "Category revenue ranking"
132
+ difficulty: hard
133
+ description: >
134
+ For each product category, calculate total revenue and rank using
135
+ a window function. Return: category, total_revenue, revenue_rank.
136
+ Order by revenue_rank ASC.
137
+
138
+ database:
139
+ engine: SQLite
140
+ path: data/ecommerce.db
141
+ tables:
142
+ customers:
143
+ rows: 100
144
+ columns: [customer_id, first_name, last_name, email, city, signup_date]
145
+ products:
146
+ rows: 30
147
+ columns: [product_id, product_name, category, price, stock]
148
+ orders:
149
+ rows: 600
150
+ columns: [order_id, customer_id, product_id, quantity, total_amount, order_date, status]
151
+
152
+ hardware:
153
+ min_cpu: 1
154
+ min_ram_gb: 1
155
+ notes: "Runs on 2 vCPU / 8GB RAM. No GPU required."
156
+
157
+ inference:
158
+ script: inference.py
159
+ max_runtime_minutes: 20
160
+ llm_env_vars:
161
+ - API_BASE_URL
162
+ - MODEL_NAME
163
+ - HF_TOKEN
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn==0.30.6
3
+ pydantic==2.9.2
4
+ requests==2.32.3
5
+ openai==1.51.0
6
+ pyyaml==6.0.2
results.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "results": [
3
+ {
4
+ "task_id": 1,
5
+ "difficulty": "easy",
6
+ "best_reward": 1.0,
7
+ "attempts": 1,
8
+ "final_sql": "SELECT COUNT(*) AS total_orders \nFROM orders \nWHERE status = 'completed' AND STRFTIME('%Y', order_date) = '2024'",
9
+ "solved": true
10
+ },
11
+ {
12
+ "task_id": 2,
13
+ "difficulty": "medium",
14
+ "best_reward": 1.0,
15
+ "attempts": 1,
16
+ "final_sql": "SELECT c.first_name, c.last_name, SUM(o.total_amount) AS total_revenue\nFROM customers c\nJOIN orders o ON c.customer_id = o.customer_id\nWHERE o.status = 'completed'\nGROUP BY c.customer_id, c.first_name, c.last_name\nORDER BY total_revenue DESC\nLIMIT 5;",
17
+ "solved": true
18
+ },
19
+ {
20
+ "task_id": 3,
21
+ "difficulty": "hard",
22
+ "best_reward": 1.0,
23
+ "attempts": 1,
24
+ "final_sql": "WITH category_revenue AS (\n SELECT p.category, SUM(o.total_amount) AS total_revenue\n FROM orders o\n JOIN products p ON o.product_id = p.product_id\n WHERE o.status = 'completed'\n GROUP BY p.category\n)\nSELECT category, total_revenue, RANK() OVER (ORDER BY total_revenue DESC) AS revenue_rank\nFROM category_revenue\nORDER BY revenue_rank ASC;",
25
+ "solved": true
26
+ }
27
+ ],
28
+ "avg_score": 1.0,
29
+ "tasks_solved": 3
30
+ }
seed.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ import random
3
+ from datetime import datetime, timedelta
4
+ import os
5
+
6
+ DB_PATH = os.path.join("data", "ecommerce.db")
7
+
8
+ FIRST_NAMES = ["Alice", "Bob", "Carol", "David", "Eva", "Frank", "Grace", "Henry",
9
+ "Irene", "Jack", "Karen", "Liam", "Mona", "Nate", "Olivia", "Paul",
10
+ "Quinn", "Rachel", "Sam", "Tina", "Uma", "Victor", "Wendy", "Xander",
11
+ "Yara", "Zoe", "Aaron", "Bella", "Chris", "Diana"]
12
+
13
+ LAST_NAMES = ["Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller",
14
+ "Davis", "Wilson", "Taylor", "Anderson", "Thomas", "Jackson", "White",
15
+ "Harris", "Martin", "Thompson", "Robinson", "Clark", "Lewis"]
16
+
17
+ CITIES = ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix", "Philadelphia",
18
+ "San Antonio", "San Diego", "Dallas", "San Jose", "Austin", "Jacksonville",
19
+ "Mumbai", "Delhi", "Chennai", "Bangalore", "Hyderabad", "Kolkata"]
20
+
21
+ CATEGORIES = ["Electronics", "Clothing", "Books", "Home & Garden", "Sports",
22
+ "Beauty", "Toys", "Food & Grocery", "Automotive", "Music"]
23
+
24
+ PRODUCTS = [
25
+ ("Wireless Headphones", "Electronics", 79.99),
26
+ ("Bluetooth Speaker", "Electronics", 49.99),
27
+ ("USB-C Hub", "Electronics", 34.99),
28
+ ("Mechanical Keyboard", "Electronics", 129.99),
29
+ ("Gaming Mouse", "Electronics", 59.99),
30
+ ("Webcam HD", "Electronics", 89.99),
31
+ ("Monitor 24inch", "Electronics", 249.99),
32
+ ("Phone Stand", "Electronics", 14.99),
33
+ ("Running Shoes", "Clothing", 89.99),
34
+ ("Denim Jacket", "Clothing", 64.99),
35
+ ("Cotton T-Shirt", "Clothing", 19.99),
36
+ ("Yoga Pants", "Clothing", 44.99),
37
+ ("Winter Coat", "Clothing", 149.99),
38
+ ("Baseball Cap", "Clothing", 24.99),
39
+ ("Python Programming", "Books", 39.99),
40
+ ("Data Science Handbook", "Books", 49.99),
41
+ ("Machine Learning Guide", "Books", 54.99),
42
+ ("Cook Book Deluxe", "Books", 29.99),
43
+ ("History of AI", "Books", 34.99),
44
+ ("Garden Hose 50ft", "Home & Garden", 44.99),
45
+ ("Plant Pots Set", "Home & Garden", 29.99),
46
+ ("LED Desk Lamp", "Home & Garden", 39.99),
47
+ ("Yoga Mat", "Sports", 34.99),
48
+ ("Resistance Bands", "Sports", 19.99),
49
+ ("Dumbbell Set 20kg", "Sports", 79.99),
50
+ ("Jump Rope", "Sports", 12.99),
51
+ ("Face Moisturizer", "Beauty", 24.99),
52
+ ("Shampoo Pro", "Beauty", 14.99),
53
+ ("Perfume Set", "Beauty", 59.99),
54
+ ("Building Blocks", "Toys", 34.99),
55
+ ]
56
+
57
+ STATUSES = ["completed", "completed", "completed", "pending", "cancelled"]
58
+
59
+
60
+ def create_tables(conn):
61
+ conn.executescript("""
62
+ DROP TABLE IF EXISTS orders;
63
+ DROP TABLE IF EXISTS products;
64
+ DROP TABLE IF EXISTS customers;
65
+
66
+ CREATE TABLE customers (
67
+ customer_id INTEGER PRIMARY KEY,
68
+ first_name TEXT NOT NULL,
69
+ last_name TEXT NOT NULL,
70
+ email TEXT UNIQUE NOT NULL,
71
+ city TEXT NOT NULL,
72
+ signup_date TEXT NOT NULL
73
+ );
74
+
75
+ CREATE TABLE products (
76
+ product_id INTEGER PRIMARY KEY,
77
+ product_name TEXT NOT NULL,
78
+ category TEXT NOT NULL,
79
+ price REAL NOT NULL,
80
+ stock INTEGER NOT NULL
81
+ );
82
+
83
+ CREATE TABLE orders (
84
+ order_id INTEGER PRIMARY KEY,
85
+ customer_id INTEGER NOT NULL,
86
+ product_id INTEGER NOT NULL,
87
+ quantity INTEGER NOT NULL,
88
+ total_amount REAL NOT NULL,
89
+ order_date TEXT NOT NULL,
90
+ status TEXT NOT NULL,
91
+ FOREIGN KEY (customer_id) REFERENCES customers(customer_id),
92
+ FOREIGN KEY (product_id) REFERENCES products(product_id)
93
+ );
94
+ """)
95
+ print("Tables created.")
96
+
97
+
98
+ def seed_customers(conn, n=100):
99
+ used_emails = set()
100
+ rows = []
101
+ for i in range(1, n + 1):
102
+ fn = random.choice(FIRST_NAMES)
103
+ ln = random.choice(LAST_NAMES)
104
+ base_email = f"{fn.lower()}.{ln.lower()}{i}@example.com"
105
+ while base_email in used_emails:
106
+ base_email = f"{fn.lower()}.{ln.lower()}{i}_{random.randint(10,99)}@example.com"
107
+ used_emails.add(base_email)
108
+ city = random.choice(CITIES)
109
+ days_ago = random.randint(30, 730)
110
+ signup = (datetime.now() - timedelta(days=days_ago)).strftime("%Y-%m-%d")
111
+ rows.append((i, fn, ln, base_email, city, signup))
112
+ conn.executemany(
113
+ "INSERT INTO customers VALUES (?,?,?,?,?,?)", rows
114
+ )
115
+ print(f"Inserted {n} customers.")
116
+
117
+
118
+ def seed_products(conn):
119
+ rows = []
120
+ for i, (name, cat, price) in enumerate(PRODUCTS, start=1):
121
+ stock = random.randint(0, 200)
122
+ rows.append((i, name, cat, price, stock))
123
+ conn.executemany(
124
+ "INSERT INTO products VALUES (?,?,?,?,?)", rows
125
+ )
126
+ print(f"Inserted {len(PRODUCTS)} products.")
127
+
128
+
129
+ def seed_orders(conn, n=600):
130
+ rows = []
131
+ base_date = datetime(2024, 1, 1)
132
+ for i in range(1, n + 1):
133
+ cust_id = random.randint(1, 100)
134
+ prod_id = random.randint(1, len(PRODUCTS))
135
+ qty = random.randint(1, 5)
136
+ price = PRODUCTS[prod_id - 1][2]
137
+ total = round(price * qty, 2)
138
+ days_offset = random.randint(0, 364)
139
+ order_date = (base_date + timedelta(days=days_offset)).strftime("%Y-%m-%d")
140
+ status = random.choice(STATUSES)
141
+ rows.append((i, cust_id, prod_id, qty, total, order_date, status))
142
+ conn.executemany(
143
+ "INSERT INTO orders VALUES (?,?,?,?,?,?,?)", rows
144
+ )
145
+ print(f"Inserted {n} orders.")
146
+
147
+
148
+ def main():
149
+ os.makedirs("data", exist_ok=True)
150
+ conn = sqlite3.connect(DB_PATH)
151
+ create_tables(conn)
152
+ seed_customers(conn)
153
+ seed_products(conn)
154
+ seed_orders(conn)
155
+ conn.commit()
156
+
157
+ # Quick sanity check
158
+ cur = conn.cursor()
159
+ print("\n--- Sanity Check ---")
160
+ for table in ["customers", "products", "orders"]:
161
+ cur.execute(f"SELECT COUNT(*) FROM {table}")
162
+ print(f" {table}: {cur.fetchone()[0]} rows")
163
+
164
+ # Preview a joined query
165
+ cur.execute("""
166
+ SELECT c.first_name, c.last_name, p.product_name, o.total_amount, o.order_date
167
+ FROM orders o
168
+ JOIN customers c ON o.customer_id = c.customer_id
169
+ JOIN products p ON o.product_id = p.product_id
170
+ LIMIT 5
171
+ """)
172
+ print("\n--- Sample joined rows ---")
173
+ for row in cur.fetchall():
174
+ print(" ", row)
175
+
176
+ conn.close()
177
+ print(f"\nDatabase saved to: {DB_PATH}")
178
+
179
+
180
+ if __name__ == "__main__":
181
+ main()