Spaces:
Sleeping
Sleeping
Commit ·
a97839e
0
Parent(s):
Initial submission
Browse files- Dockerfile +28 -0
- __pycache__/main.cpython-313.pyc +0 -0
- data/ecommerce.db +0 -0
- inference.py +264 -0
- main.py +367 -0
- openenv.yaml +163 -0
- requirements.txt +6 -0
- results.json +30 -0
- 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()
|