Deepikachintamreddy commited on
Commit
ede2fa4
Β·
1 Parent(s): 8381bcd

SQL Query OpenEnv Environment

Browse files
.dockerignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ __pycache__
2
+ *.pyc
3
+ .git
4
+ .gitignore
5
+ outputs/
6
+ *.egg-info
7
+ .venv
Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # Create non-root user (HF Spaces runs with user ID 1000)
4
+ RUN useradd -m -u 1000 user
5
+ USER user
6
+ ENV HOME=/home/user \
7
+ PATH=/home/user/.local/bin:$PATH
8
+
9
+ WORKDIR /app
10
+
11
+ # Install dependencies
12
+ COPY --chown=user server/requirements.txt .
13
+ RUN pip install --no-cache-dir --user -r requirements.txt
14
+
15
+ # Copy all source code
16
+ COPY --chown=user . .
17
+
18
+ # HF Spaces uses port 7860 by default
19
+ ENV PORT=7860
20
+ EXPOSE 7860
21
+
22
+ # Run the FastAPI server
23
+ CMD ["python", "-m", "uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,11 +1,35 @@
1
  ---
2
- title: Sql Query Env
3
- emoji: 🏒
4
- colorFrom: yellow
5
- colorTo: red
6
  sdk: docker
 
 
 
 
 
 
7
  pinned: false
8
- license: mit
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: SQL Query Environment
3
+ emoji: πŸ—ƒοΈ
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: docker
7
+ app_port: 7860
8
+ tags:
9
+ - openenv
10
+ - sql
11
+ - text-to-sql
12
+ - reinforcement-learning
13
  pinned: false
 
14
  ---
15
 
16
+ # SQL Query Environment
17
+
18
+ An OpenEnv-compatible environment where AI agents learn to translate natural language questions into correct SQL queries.
19
+
20
+ ## API Endpoints
21
+
22
+ - `GET /health` β€” Health check
23
+ - `POST /reset` β€” Start new episode: `{"task_id": "task_1"}`
24
+ - `POST /step` β€” Submit SQL: `{"task_id": "task_1", "sql_query": "SELECT ..."}`
25
+ - `GET /state` β€” Current episode state
26
+
27
+ ## Tasks
28
+
29
+ | ID | Difficulty | Description |
30
+ |----|-----------|-------------|
31
+ | task_1 | Easy | Single-table filtering + ordering |
32
+ | task_2 | Medium | JOIN + GROUP BY + HAVING |
33
+ | task_3 | Hard | Subquery + multi-JOIN + aggregation |
34
+
35
+ Grading is fully deterministic with partial credit (0.0 to 1.0).
__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .models import SQLAction, SQLObservation
2
+
3
+ __all__ = ["SQLAction", "SQLObservation"]
inference.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Baseline inference script for the SQL Query Environment.
3
+
4
+ Uses the OpenAI-compatible API to run an LLM agent against all 3 tasks.
5
+ Reads credentials from environment variables:
6
+ - API_BASE_URL: The API endpoint for the LLM
7
+ - MODEL_NAME: The model identifier to use
8
+ - HF_TOKEN: Your Hugging Face / API key
9
+
10
+ Usage:
11
+ API_BASE_URL=https://router.huggingface.co/v1 \
12
+ MODEL_NAME=Qwen/Qwen2.5-72B-Instruct \
13
+ HF_TOKEN=hf_xxx \
14
+ python inference.py
15
+ """
16
+
17
+ import os
18
+ import sys
19
+ import json
20
+ import time
21
+ import requests
22
+
23
+ from openai import OpenAI
24
+
25
+ # ── Configuration ──
26
+ API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
27
+ API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY") or os.getenv("OPENAI_API_KEY", "")
28
+ MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
29
+
30
+ # Environment URL (local or HF Space)
31
+ ENV_URL = os.getenv("ENV_URL", "http://localhost:7860")
32
+
33
+ MAX_ATTEMPTS = 3 # Max steps per task
34
+ TASKS = ["task_1", "task_2", "task_3"]
35
+
36
+
37
+ def call_environment(endpoint: str, payload: dict = None) -> dict:
38
+ """Call the environment's HTTP API."""
39
+ url = f"{ENV_URL.rstrip('/')}/{endpoint.lstrip('/')}"
40
+ if payload is not None:
41
+ resp = requests.post(url, json=payload, timeout=30)
42
+ else:
43
+ resp = requests.get(url, timeout=30)
44
+ resp.raise_for_status()
45
+ return resp.json()
46
+
47
+
48
+ def generate_sql(client: OpenAI, schema: str, question: str, history: str = "") -> str:
49
+ """Ask the LLM to generate a SQL query."""
50
+ system_prompt = (
51
+ "You are an expert SQL query writer. Given a database schema and a "
52
+ "natural language question, write a single SQLite-compatible SQL query "
53
+ "that answers the question.\n\n"
54
+ "RULES:\n"
55
+ "- Return ONLY the SQL query, nothing else.\n"
56
+ "- Do NOT include markdown code fences, explanations, or comments.\n"
57
+ "- Use proper SQLite syntax.\n"
58
+ "- Pay attention to column names and table relationships.\n"
59
+ "- When asked to round values, use ROUND(value, decimal_places).\n"
60
+ "- Use single quotes for string literals.\n"
61
+ )
62
+
63
+ user_prompt = f"DATABASE SCHEMA:\n{schema}\n\nQUESTION: {question}"
64
+ if history:
65
+ user_prompt += f"\n\nPREVIOUS ATTEMPTS AND FEEDBACK:\n{history}"
66
+ user_prompt += "\n\nPlease fix your query based on the feedback above."
67
+
68
+ try:
69
+ completion = client.chat.completions.create(
70
+ model=MODEL_NAME,
71
+ messages=[
72
+ {"role": "system", "content": system_prompt},
73
+ {"role": "user", "content": user_prompt},
74
+ ],
75
+ temperature=0.1,
76
+ max_tokens=500,
77
+ )
78
+ response = completion.choices[0].message.content or ""
79
+ # Clean up: remove markdown fences if present
80
+ response = response.strip()
81
+ if response.startswith("```sql"):
82
+ response = response[6:]
83
+ if response.startswith("```"):
84
+ response = response[3:]
85
+ if response.endswith("```"):
86
+ response = response[:-3]
87
+ return response.strip()
88
+ except Exception as e:
89
+ print(f" LLM API error: {e}")
90
+ return "SELECT 1"
91
+
92
+
93
+ def run_task(client: OpenAI, task_id: str) -> float:
94
+ """Run the agent on a single task. Returns the best reward achieved."""
95
+ print(f"\n{'='*60}")
96
+ print(f"TASK: {task_id.upper()}")
97
+ print(f"{'='*60}")
98
+
99
+ # Reset environment for this task
100
+ reset_resp = call_environment("/reset", {"task_id": task_id})
101
+ obs = reset_resp["observation"]
102
+
103
+ print(f"Question: {obs['task_description']}")
104
+ print(f"Difficulty: {obs['difficulty']}")
105
+
106
+ best_reward = 0.0
107
+ history = ""
108
+
109
+ for attempt in range(1, MAX_ATTEMPTS + 1):
110
+ print(f"\n--- Attempt {attempt}/{MAX_ATTEMPTS} ---")
111
+
112
+ # Generate SQL query
113
+ sql = generate_sql(
114
+ client,
115
+ schema=obs["schema_description"],
116
+ question=obs["task_description"],
117
+ history=history,
118
+ )
119
+ print(f"SQL: {sql[:200]}{'...' if len(sql) > 200 else ''}")
120
+
121
+ # Submit to environment
122
+ step_resp = call_environment("/step", {
123
+ "task_id": task_id,
124
+ "sql_query": sql,
125
+ })
126
+ obs = step_resp["observation"]
127
+ reward = step_resp["reward"]
128
+ done = step_resp["done"]
129
+
130
+ print(f"Reward: {reward:.2f}")
131
+ print(f"Feedback: {obs['feedback']}")
132
+
133
+ best_reward = max(best_reward, reward)
134
+
135
+ # Track history for retry
136
+ history += f"\nAttempt {attempt}: SQL: {sql}\n"
137
+ history += f" Reward: {reward}, Feedback: {obs['feedback']}\n"
138
+ if obs.get("query_error"):
139
+ history += f" Error: {obs['query_error']}\n"
140
+
141
+ if done:
142
+ break
143
+
144
+ print(f"\nBest reward for {task_id}: {best_reward:.2f}")
145
+ return best_reward
146
+
147
+
148
+ def main():
149
+ """Run the baseline agent on all tasks and report scores."""
150
+ print("=" * 60)
151
+ print("SQL Query Environment - Baseline Inference")
152
+ print("=" * 60)
153
+ print(f"API URL: {API_BASE_URL}")
154
+ print(f"Model: {MODEL_NAME}")
155
+ print(f"Env URL: {ENV_URL}")
156
+ print()
157
+
158
+ # Verify environment is up
159
+ try:
160
+ health = call_environment("/health")
161
+ print(f"Environment health: {health}")
162
+ except Exception as e:
163
+ print(f"ERROR: Cannot reach environment at {ENV_URL}: {e}")
164
+ print("Make sure the environment server is running.")
165
+ sys.exit(1)
166
+
167
+ # Create OpenAI client
168
+ client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
169
+
170
+ # Run all tasks
171
+ results = {}
172
+ total_score = 0.0
173
+
174
+ for task_id in TASKS:
175
+ score = run_task(client, task_id)
176
+ results[task_id] = score
177
+ total_score += score
178
+
179
+ # Summary
180
+ print("\n" + "=" * 60)
181
+ print("FINAL RESULTS")
182
+ print("=" * 60)
183
+ for task_id, score in results.items():
184
+ print(f" {task_id:8s}: {score:.2f}")
185
+ avg_score = total_score / len(TASKS)
186
+ print(f" {'AVERAGE':8s}: {avg_score:.2f}")
187
+ print("=" * 60)
188
+
189
+ return results
190
+
191
+
192
+ if __name__ == "__main__":
193
+ main()
models.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Typed Action and Observation models for the SQL Query Environment.
3
+
4
+ Action: The agent submits a SQL query string and a task_id.
5
+ Observation: The environment returns schema info, query results, feedback, and reward.
6
+ """
7
+
8
+ from pydantic import Field
9
+ from openenv.core.env_server.types import Action, Observation
10
+
11
+
12
+ class SQLAction(Action):
13
+ """Action submitted by the agent: a SQL query to execute."""
14
+
15
+ task_id: str = Field(
16
+ ...,
17
+ description="ID of the task being attempted (task_1, task_2, task_3)",
18
+ )
19
+ sql_query: str = Field(
20
+ ...,
21
+ description="The SQL query string to execute against the database",
22
+ )
23
+
24
+
25
+ class SQLObservation(Observation):
26
+ """Observation returned to the agent after each step."""
27
+
28
+ # Task information
29
+ task_id: str = Field(default="", description="Current task ID")
30
+ task_description: str = Field(
31
+ default="", description="Natural language question the agent must answer"
32
+ )
33
+ difficulty: str = Field(default="", description="easy, medium, or hard")
34
+
35
+ # Database schema
36
+ schema_description: str = Field(
37
+ default="", description="SQL CREATE TABLE statements describing the database"
38
+ )
39
+
40
+ # Query result / feedback
41
+ query_result: str = Field(
42
+ default="",
43
+ description="Result of the executed SQL query (rows as text), or error message",
44
+ )
45
+ query_error: bool = Field(
46
+ default=False, description="True if the SQL query caused an error"
47
+ )
48
+ feedback: str = Field(
49
+ default="",
50
+ description="Human-readable feedback on the query result",
51
+ )
52
+
53
+ # Scoring
54
+ reward: float = Field(default=0.0, description="Score from 0.0 to 1.0")
55
+ done: bool = Field(default=False, description="True if the episode is complete")
56
+
57
+ # Metadata
58
+ step_count: int = Field(default=0, description="Number of steps taken so far")
59
+ max_steps: int = Field(
60
+ default=3, description="Maximum steps allowed per task"
61
+ )
openenv.yaml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: sql_query_env
2
+ version: "1.0.0"
3
+ description: >
4
+ A real-world SQL query environment where AI agents learn to translate
5
+ natural language questions into correct SQL queries. Features 3 tasks
6
+ with increasing difficulty (easy/medium/hard), deterministic grading
7
+ via SQLite execution, and partial-credit reward shaping.
8
+ tags:
9
+ - openenv
10
+ - sql
11
+ - text-to-sql
12
+ - data-analysis
13
+ - natural-language-processing
pyproject.toml ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.backends._legacy:_Backend"
4
+
5
+ [project]
6
+ name = "sql_query_env"
7
+ version = "1.0.0"
8
+ description = "SQL Query OpenEnv Environment - Train agents to write SQL from natural language"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "openenv-core>=0.2.1",
12
+ "fastapi>=0.104.0",
13
+ "uvicorn>=0.24.0",
14
+ "pydantic>=2.0.0",
15
+ "websockets>=12.0",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ dev = ["pytest", "httpx"]
20
+
21
+ [tool.setuptools.packages.find]
22
+ include = ["sql_query_env*"]
server/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """SQL Query Environment Server."""
server/app.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI server for the SQL Query Environment.
2
+
3
+ Exposes HTTP endpoints:
4
+ POST /reset - Start a new episode
5
+ POST /step - Submit a SQL query
6
+ GET /state - Get current state
7
+ GET /health - Health check
8
+ """
9
+
10
+ import os
11
+ import sys
12
+ from typing import Optional
13
+
14
+ from fastapi import FastAPI, HTTPException
15
+ from pydantic import BaseModel
16
+
17
+ # Add parent + server to path for imports
18
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
19
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
20
+
21
+ from models import SQLAction, SQLObservation
22
+ from sql_environment import SQLQueryEnvironment
23
+
24
+
25
+ # ── Request/Response models ──
26
+
27
+ class ResetRequest(BaseModel):
28
+ task_id: Optional[str] = "task_1"
29
+
30
+
31
+ class StepRequest(BaseModel):
32
+ task_id: str = "task_1"
33
+ sql_query: str = ""
34
+
35
+
36
+ # ── Environment instance ──
37
+ env = SQLQueryEnvironment()
38
+
39
+
40
+ app = FastAPI(
41
+ title="SQL Query Environment",
42
+ description=(
43
+ "An OpenEnv environment where AI agents learn to write SQL queries. "
44
+ "Features 3 tasks with increasing difficulty and deterministic grading."
45
+ ),
46
+ version="1.0.0",
47
+ )
48
+
49
+
50
+ @app.get("/health")
51
+ async def health():
52
+ """Health check endpoint."""
53
+ return {"status": "healthy"}
54
+
55
+
56
+ @app.post("/reset")
57
+ async def reset(request: ResetRequest = ResetRequest()):
58
+ """Reset the environment and start a new episode."""
59
+ task_id = request.task_id or "task_1"
60
+ obs = env.reset(task_id=task_id)
61
+ return {
62
+ "observation": obs.model_dump(),
63
+ "reward": 0.0,
64
+ "done": False,
65
+ "info": {"episode_started": True, "task_id": task_id},
66
+ }
67
+
68
+
69
+ @app.post("/step")
70
+ async def step(request: StepRequest):
71
+ """Submit a SQL query and get the result with grading."""
72
+ if not request.sql_query:
73
+ raise HTTPException(status_code=400, detail="sql_query is required")
74
+
75
+ action = SQLAction(task_id=request.task_id, sql_query=request.sql_query)
76
+ obs = env.step(action)
77
+
78
+ return {
79
+ "observation": obs.model_dump(),
80
+ "reward": obs.reward,
81
+ "done": obs.done,
82
+ "info": {
83
+ "step_count": obs.step_count,
84
+ "feedback": obs.feedback,
85
+ },
86
+ }
87
+
88
+
89
+ @app.get("/state")
90
+ async def get_state():
91
+ """Get current episode state."""
92
+ s = env.state
93
+ return {
94
+ "episode_id": s.episode_id,
95
+ "step_count": s.step_count,
96
+ }
97
+
98
+
99
+ # ── Run with uvicorn ──
100
+ if __name__ == "__main__":
101
+ import uvicorn
102
+
103
+ port = int(os.environ.get("PORT", 7860))
104
+ uvicorn.run(app, host="0.0.0.0", port=port)
server/database.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Database setup for the SQL Query Environment.
3
+
4
+ Creates an in-memory SQLite database with three tables:
5
+ - departments: id, name, budget, location
6
+ - employees: id, name, department_id, salary, hire_date, is_active
7
+ - projects: id, name, department_id, lead_employee_id, budget, status, start_date
8
+
9
+ All data is deterministic so grading is reproducible.
10
+ """
11
+
12
+ import sqlite3
13
+ from typing import Optional
14
+
15
+ SCHEMA_SQL = """
16
+ CREATE TABLE departments (
17
+ id INTEGER PRIMARY KEY,
18
+ name TEXT NOT NULL,
19
+ budget REAL NOT NULL,
20
+ location TEXT NOT NULL
21
+ );
22
+
23
+ CREATE TABLE employees (
24
+ id INTEGER PRIMARY KEY,
25
+ name TEXT NOT NULL,
26
+ department_id INTEGER NOT NULL,
27
+ salary REAL NOT NULL,
28
+ hire_date TEXT NOT NULL,
29
+ is_active INTEGER NOT NULL DEFAULT 1,
30
+ FOREIGN KEY (department_id) REFERENCES departments(id)
31
+ );
32
+
33
+ CREATE TABLE projects (
34
+ id INTEGER PRIMARY KEY,
35
+ name TEXT NOT NULL,
36
+ department_id INTEGER NOT NULL,
37
+ lead_employee_id INTEGER NOT NULL,
38
+ budget REAL NOT NULL,
39
+ status TEXT NOT NULL CHECK(status IN ('active', 'completed', 'cancelled')),
40
+ start_date TEXT NOT NULL,
41
+ FOREIGN KEY (department_id) REFERENCES departments(id),
42
+ FOREIGN KEY (lead_employee_id) REFERENCES employees(id)
43
+ );
44
+ """
45
+
46
+ SEED_SQL = """
47
+ INSERT INTO departments VALUES (1, 'Engineering', 500000.00, 'Bangalore');
48
+ INSERT INTO departments VALUES (2, 'Marketing', 200000.00, 'Mumbai');
49
+ INSERT INTO departments VALUES (3, 'Sales', 300000.00, 'Delhi');
50
+ INSERT INTO departments VALUES (4, 'HR', 150000.00, 'Bangalore');
51
+ INSERT INTO departments VALUES (5, 'Finance', 250000.00, 'Mumbai');
52
+
53
+ INSERT INTO employees VALUES (1, 'Arjun Sharma', 1, 85000.00, '2020-03-15', 1);
54
+ INSERT INTO employees VALUES (2, 'Priya Patel', 1, 92000.00, '2019-07-01', 1);
55
+ INSERT INTO employees VALUES (3, 'Rahul Verma', 1, 78000.00, '2021-01-10', 1);
56
+ INSERT INTO employees VALUES (4, 'Sneha Gupta', 2, 65000.00, '2020-06-20', 1);
57
+ INSERT INTO employees VALUES (5, 'Vikram Singh', 2, 70000.00, '2018-11-05', 1);
58
+ INSERT INTO employees VALUES (6, 'Anita Desai', 3, 72000.00, '2019-09-12', 1);
59
+ INSERT INTO employees VALUES (7, 'Karan Mehta', 3, 68000.00, '2021-04-01', 1);
60
+ INSERT INTO employees VALUES (8, 'Deepa Nair', 3, 75000.00, '2020-02-28', 0);
61
+ INSERT INTO employees VALUES (9, 'Suresh Kumar', 4, 60000.00, '2022-01-15', 1);
62
+ INSERT INTO employees VALUES (10, 'Meera Joshi', 4, 58000.00, '2021-08-20', 1);
63
+ INSERT INTO employees VALUES (11, 'Amit Rao', 5, 88000.00, '2019-05-10', 1);
64
+ INSERT INTO employees VALUES (12, 'Lakshmi Iyer', 5, 82000.00, '2020-10-01', 1);
65
+ INSERT INTO employees VALUES (13, 'Ravi Krishnan', 1, 95000.00, '2018-03-20', 1);
66
+ INSERT INTO employees VALUES (14, 'Pooja Reddy', 2, 62000.00, '2022-06-15', 1);
67
+ INSERT INTO employees VALUES (15, 'Nikhil Agarwal', 3, 71000.00, '2020-12-01', 1);
68
+
69
+ INSERT INTO projects VALUES (1, 'Cloud Migration', 1, 2, 120000.00, 'active', '2024-01-15');
70
+ INSERT INTO projects VALUES (2, 'Mobile App v2', 1, 1, 80000.00, 'active', '2024-03-01');
71
+ INSERT INTO projects VALUES (3, 'Brand Refresh', 2, 5, 45000.00, 'completed', '2023-06-01');
72
+ INSERT INTO projects VALUES (4, 'Q4 Campaign', 2, 4, 60000.00, 'active', '2024-09-01');
73
+ INSERT INTO projects VALUES (5, 'CRM Integration', 3, 6, 90000.00, 'active', '2024-02-15');
74
+ INSERT INTO projects VALUES (6, 'Sales Dashboard', 3, 15, 35000.00, 'completed', '2023-11-01');
75
+ INSERT INTO projects VALUES (7, 'Payroll Automation', 4, 9, 50000.00, 'active', '2024-04-01');
76
+ INSERT INTO projects VALUES (8, 'Annual Audit Tool', 5, 11, 70000.00, 'cancelled', '2024-01-10');
77
+ INSERT INTO projects VALUES (9, 'Data Pipeline', 1, 13, 150000.00, 'active', '2024-06-01');
78
+ INSERT INTO projects VALUES (10, 'Employee Portal', 4, 10, 40000.00, 'completed', '2023-09-15');
79
+ """
80
+
81
+ SCHEMA_DESCRIPTION = """Tables in the database:
82
+
83
+ CREATE TABLE departments (
84
+ id INTEGER PRIMARY KEY,
85
+ name TEXT NOT NULL,
86
+ budget REAL NOT NULL,
87
+ location TEXT NOT NULL
88
+ );
89
+
90
+ CREATE TABLE employees (
91
+ id INTEGER PRIMARY KEY,
92
+ name TEXT NOT NULL,
93
+ department_id INTEGER NOT NULL,
94
+ salary REAL NOT NULL,
95
+ hire_date TEXT NOT NULL, -- format: YYYY-MM-DD
96
+ is_active INTEGER NOT NULL, -- 1 = active, 0 = inactive
97
+ FOREIGN KEY (department_id) REFERENCES departments(id)
98
+ );
99
+
100
+ CREATE TABLE projects (
101
+ id INTEGER PRIMARY KEY,
102
+ name TEXT NOT NULL,
103
+ department_id INTEGER NOT NULL,
104
+ lead_employee_id INTEGER NOT NULL,
105
+ budget REAL NOT NULL,
106
+ status TEXT NOT NULL, -- 'active', 'completed', or 'cancelled'
107
+ start_date TEXT NOT NULL, -- format: YYYY-MM-DD
108
+ FOREIGN KEY (department_id) REFERENCES departments(id),
109
+ FOREIGN KEY (lead_employee_id) REFERENCES employees(id)
110
+ );""".strip()
111
+
112
+
113
+ def create_database() -> sqlite3.Connection:
114
+ """Create a fresh in-memory SQLite database with schema and seed data."""
115
+ conn = sqlite3.connect(":memory:")
116
+ conn.execute("PRAGMA foreign_keys = ON;")
117
+ conn.executescript(SCHEMA_SQL)
118
+ conn.executescript(SEED_SQL)
119
+ conn.commit()
120
+ return conn
121
+
122
+
123
+ def execute_query(
124
+ conn: sqlite3.Connection, sql: str
125
+ ) -> tuple[list[tuple] | None, list[str] | None, str | None]:
126
+ """
127
+ Execute a SQL query safely.
128
+
129
+ Returns:
130
+ (rows, column_names, error_message)
131
+ - On success: (rows_list, columns_list, None)
132
+ - On error: (None, None, error_string)
133
+ """
134
+ try:
135
+ cursor = conn.execute(sql)
136
+ if cursor.description is not None:
137
+ columns = [desc[0] for desc in cursor.description]
138
+ rows = cursor.fetchall()
139
+ return rows, columns, None
140
+ else:
141
+ conn.commit()
142
+ return None, None, None
143
+ except Exception as e:
144
+ return None, None, str(e)
server/grader.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Deterministic grader for the SQL Query Environment.
3
+
4
+ Compares the agent's query result against the expected result.
5
+ Produces a score between 0.0 and 1.0 with partial credit:
6
+
7
+ 0.0 β€” SQL error or no result returned
8
+ 0.1 β€” Query runs but returns wrong column count
9
+ 0.2 β€” Correct column count but wrong column names
10
+ 0.3 β€” Correct columns but wrong number of rows
11
+ 0.5 β€” Correct structure, some rows match
12
+ 0.7 β€” Most rows match (>= 70% of expected rows found)
13
+ 0.9 β€” All rows match but in wrong order
14
+ 1.0 β€” Exact match (columns, rows, order)
15
+ """
16
+
17
+ from tasks import TaskDefinition
18
+
19
+
20
+ def _normalize_value(val) -> str:
21
+ """Normalize a value for comparison (handle float rounding, casing)."""
22
+ if val is None:
23
+ return "NULL"
24
+ if isinstance(val, float):
25
+ return f"{val:.2f}"
26
+ return str(val).strip().lower()
27
+
28
+
29
+ def _normalize_row(row: tuple) -> tuple:
30
+ return tuple(_normalize_value(v) for v in row)
31
+
32
+
33
+ def grade_query(
34
+ task: TaskDefinition,
35
+ agent_rows: list[tuple] | None,
36
+ agent_columns: list[str] | None,
37
+ query_error: str | None,
38
+ ) -> tuple[float, str]:
39
+ """
40
+ Grade the agent's SQL query result against the expected result.
41
+
42
+ Returns:
43
+ (score, feedback_string)
44
+ """
45
+ # SQL error β†’ 0.0
46
+ if query_error is not None:
47
+ return 0.0, f"SQL error: {query_error}"
48
+
49
+ # No result (e.g. INSERT/UPDATE instead of SELECT) β†’ 0.0
50
+ if agent_rows is None or agent_columns is None:
51
+ return 0.0, "Query did not return any result rows. Expected a SELECT query."
52
+
53
+ expected_cols = [c.lower() for c in task.expected_columns]
54
+ actual_cols = [c.lower() for c in agent_columns]
55
+
56
+ # Wrong column count β†’ 0.1
57
+ if len(actual_cols) != len(expected_cols):
58
+ return 0.1, (
59
+ f"Wrong number of columns. Expected {len(expected_cols)} "
60
+ f"({', '.join(expected_cols)}), got {len(actual_cols)} "
61
+ f"({', '.join(actual_cols)})."
62
+ )
63
+
64
+ # Wrong column names β†’ 0.2
65
+ if actual_cols != expected_cols:
66
+ return 0.2, (
67
+ f"Column names don't match. Expected {expected_cols}, got {actual_cols}. "
68
+ f"Note: column names must match exactly."
69
+ )
70
+
71
+ # Columns match β€” now compare rows
72
+ expected_normalized = [_normalize_row(r) for r in task.expected_rows]
73
+ actual_normalized = [_normalize_row(r) for r in agent_rows]
74
+
75
+ # Wrong row count β†’ partial credit based on matching rows
76
+ if len(actual_normalized) != len(expected_normalized):
77
+ # Count how many expected rows appear in actual
78
+ expected_set = set(expected_normalized)
79
+ actual_set = set(actual_normalized)
80
+ matching = len(expected_set & actual_set)
81
+
82
+ if matching == 0:
83
+ return 0.3, (
84
+ f"Correct columns but wrong number of rows. "
85
+ f"Expected {len(expected_normalized)} rows, got {len(actual_normalized)}. "
86
+ f"No matching rows found."
87
+ )
88
+ ratio = matching / len(expected_normalized)
89
+ score = 0.3 + ratio * 0.4 # Scale from 0.3 to 0.7
90
+ return round(score, 2), (
91
+ f"Correct columns but wrong row count. "
92
+ f"Expected {len(expected_normalized)} rows, got {len(actual_normalized)}. "
93
+ f"{matching}/{len(expected_normalized)} expected rows found."
94
+ )
95
+
96
+ # Same row count β€” check content
97
+ expected_set = set(expected_normalized)
98
+ actual_set = set(actual_normalized)
99
+
100
+ if expected_set != actual_set:
101
+ # Some rows match
102
+ matching = len(expected_set & actual_set)
103
+ if matching == 0:
104
+ return 0.5, (
105
+ "Correct structure (columns and row count) but row values "
106
+ "don't match any expected rows."
107
+ )
108
+ ratio = matching / len(expected_normalized)
109
+ score = 0.5 + ratio * 0.2
110
+ return round(score, 2), (
111
+ f"Partially correct. {matching}/{len(expected_normalized)} rows match."
112
+ )
113
+
114
+ # All rows present β€” check order
115
+ if actual_normalized != expected_normalized:
116
+ return 0.9, (
117
+ "All correct rows found but in wrong order. "
118
+ "Check your ORDER BY clause."
119
+ )
120
+
121
+ # Perfect match!
122
+ return 1.0, "Correct! Query returned the exact expected result."
server/requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ fastapi>=0.104.0
2
+ uvicorn>=0.24.0
3
+ pydantic>=2.0.0
4
+ openenv-core>=0.2.1
server/sql_environment.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SQL Query Environment β€” Core logic.
3
+
4
+ Implements the OpenEnv Environment interface:
5
+ - reset() β†’ creates fresh DB, picks a task, returns initial observation
6
+ - step() β†’ receives SQL, executes it, grades it, returns observation
7
+ - state() β†’ returns current episode state
8
+ """
9
+
10
+ import sqlite3
11
+ import sys
12
+ import os
13
+ from uuid import uuid4
14
+
15
+ from openenv.core.env_server.interfaces import Environment
16
+ from openenv.core.env_server.types import State
17
+
18
+ sys.path.insert(0, os.path.dirname(__file__))
19
+ from database import create_database, execute_query, SCHEMA_DESCRIPTION
20
+ from tasks import ALL_TASKS, TASK_LIST, TaskDefinition
21
+ from grader import grade_query
22
+
23
+ # Import models
24
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
25
+ from models import SQLAction, SQLObservation
26
+
27
+
28
+ MAX_STEPS_PER_TASK = 3
29
+
30
+
31
+ class SQLQueryEnvironment(Environment):
32
+ """
33
+ An environment where an AI agent writes SQL queries to answer
34
+ natural language questions about a company database.
35
+ """
36
+
37
+ def __init__(self):
38
+ super().__init__()
39
+ self._db: sqlite3.Connection | None = None
40
+ self._state = State(episode_id=str(uuid4()), step_count=0)
41
+ self._current_task: TaskDefinition | None = None
42
+ self._step_count: int = 0
43
+ self._done: bool = False
44
+ self._best_reward: float = 0.0
45
+
46
+ def reset(self, **kwargs) -> SQLObservation:
47
+ """Reset environment: fresh DB, pick task, return initial observation."""
48
+ if self._db is not None:
49
+ self._db.close()
50
+ self._db = create_database()
51
+
52
+ task_id = kwargs.get("task_id", None)
53
+ if task_id and task_id in ALL_TASKS:
54
+ self._current_task = ALL_TASKS[task_id]
55
+ else:
56
+ self._current_task = TASK_LIST[0]
57
+
58
+ self._state = State(episode_id=str(uuid4()), step_count=0)
59
+ self._step_count = 0
60
+ self._done = False
61
+ self._best_reward = 0.0
62
+
63
+ return SQLObservation(
64
+ task_id=self._current_task.task_id,
65
+ task_description=self._current_task.description,
66
+ difficulty=self._current_task.difficulty,
67
+ schema_description=SCHEMA_DESCRIPTION,
68
+ query_result="",
69
+ query_error=False,
70
+ feedback="Environment reset. Submit a SQL query to answer the question.",
71
+ reward=0.0,
72
+ done=False,
73
+ step_count=0,
74
+ max_steps=MAX_STEPS_PER_TASK,
75
+ )
76
+
77
+ def step(self, action: SQLAction) -> SQLObservation:
78
+ """Execute agent's SQL, grade it, return observation."""
79
+ if self._done:
80
+ return SQLObservation(
81
+ task_id=self._current_task.task_id if self._current_task else "",
82
+ task_description="",
83
+ difficulty="",
84
+ schema_description=SCHEMA_DESCRIPTION,
85
+ query_result="",
86
+ query_error=False,
87
+ feedback="Episode is already complete. Call reset() to start a new one.",
88
+ reward=0.0,
89
+ done=True,
90
+ step_count=self._step_count,
91
+ max_steps=MAX_STEPS_PER_TASK,
92
+ )
93
+
94
+ self._step_count += 1
95
+ self._state.step_count = self._step_count
96
+
97
+ # Validate task_id
98
+ if action.task_id not in ALL_TASKS:
99
+ return SQLObservation(
100
+ task_id=action.task_id,
101
+ task_description="",
102
+ difficulty="",
103
+ schema_description=SCHEMA_DESCRIPTION,
104
+ query_result="",
105
+ query_error=True,
106
+ feedback=f"Unknown task_id: {action.task_id}. Valid: task_1, task_2, task_3",
107
+ reward=0.0,
108
+ done=False,
109
+ step_count=self._step_count,
110
+ max_steps=MAX_STEPS_PER_TASK,
111
+ )
112
+
113
+ task = ALL_TASKS[action.task_id]
114
+ self._current_task = task
115
+
116
+ # Execute agent's SQL
117
+ agent_rows, agent_cols, error = execute_query(self._db, action.sql_query)
118
+
119
+ # Grade it
120
+ score, feedback = grade_query(task, agent_rows, agent_cols, error)
121
+
122
+ if score > self._best_reward:
123
+ self._best_reward = score
124
+
125
+ # Format result for observation
126
+ if error:
127
+ result_str = f"ERROR: {error}"
128
+ has_error = True
129
+ elif agent_rows is not None and agent_cols is not None:
130
+ header = " | ".join(agent_cols)
131
+ separator = "-" * len(header)
132
+ row_strs = [" | ".join(str(v) for v in row) for row in agent_rows[:20]]
133
+ result_str = f"{header}\n{separator}\n" + "\n".join(row_strs)
134
+ if len(agent_rows) > 20:
135
+ result_str += f"\n... ({len(agent_rows) - 20} more rows)"
136
+ has_error = False
137
+ else:
138
+ result_str = "(no result rows returned)"
139
+ has_error = False
140
+
141
+ # End episode if perfect score or max steps
142
+ is_done = False
143
+ if score >= 1.0:
144
+ is_done = True
145
+ feedback += " Task completed perfectly!"
146
+ elif self._step_count >= MAX_STEPS_PER_TASK:
147
+ is_done = True
148
+ feedback += f" Maximum steps ({MAX_STEPS_PER_TASK}) reached."
149
+
150
+ self._done = is_done
151
+
152
+ return SQLObservation(
153
+ task_id=task.task_id,
154
+ task_description=task.description,
155
+ difficulty=task.difficulty,
156
+ schema_description=SCHEMA_DESCRIPTION,
157
+ query_result=result_str,
158
+ query_error=has_error,
159
+ feedback=feedback,
160
+ reward=score,
161
+ done=is_done,
162
+ step_count=self._step_count,
163
+ max_steps=MAX_STEPS_PER_TASK,
164
+ )
165
+
166
+ @property
167
+ def state(self) -> State:
168
+ return self._state
server/tasks.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Task definitions for the SQL Query Environment.
3
+
4
+ Each task has:
5
+ - task_id: unique identifier
6
+ - description: natural language question
7
+ - difficulty: easy / medium / hard
8
+ - expected_sql: a reference SQL that produces the correct answer
9
+ - expected_columns: the column names in the expected result
10
+ - expected_rows: the exact rows expected (deterministic)
11
+ """
12
+
13
+ from dataclasses import dataclass, field
14
+
15
+
16
+ @dataclass
17
+ class TaskDefinition:
18
+ task_id: str
19
+ description: str
20
+ difficulty: str
21
+ expected_sql: str
22
+ expected_columns: list[str]
23
+ expected_rows: list[tuple]
24
+
25
+
26
+ # ──────────────────────────────────────────────
27
+ # TASK 1 β€” EASY: Single table, simple WHERE + ORDER BY
28
+ # ──────────────────────────────────────────────
29
+ TASK_1 = TaskDefinition(
30
+ task_id="task_1",
31
+ description=(
32
+ "List the names and salaries of all active employees who earn more "
33
+ "than 70000, ordered by salary descending."
34
+ ),
35
+ difficulty="easy",
36
+ expected_sql="""
37
+ SELECT name, salary
38
+ FROM employees
39
+ WHERE is_active = 1 AND salary > 70000
40
+ ORDER BY salary DESC;
41
+ """,
42
+ expected_columns=["name", "salary"],
43
+ expected_rows=[
44
+ ("Ravi Krishnan", 95000.0),
45
+ ("Priya Patel", 92000.0),
46
+ ("Amit Rao", 88000.0),
47
+ ("Arjun Sharma", 85000.0),
48
+ ("Lakshmi Iyer", 82000.0),
49
+ ("Rahul Verma", 78000.0),
50
+ ("Anita Desai", 72000.0),
51
+ ("Nikhil Agarwal", 71000.0),
52
+ ],
53
+ )
54
+
55
+ # ──────────────────────────────────────────────
56
+ # TASK 2 β€” MEDIUM: JOIN + GROUP BY + HAVING
57
+ # ──────────────────────────────────────────────
58
+ TASK_2 = TaskDefinition(
59
+ task_id="task_2",
60
+ description=(
61
+ "For each department, show the department name, the number of active "
62
+ "employees, and the average salary of active employees. Only include "
63
+ "departments that have more than 2 active employees. "
64
+ "Order by average salary descending."
65
+ ),
66
+ difficulty="medium",
67
+ expected_sql="""
68
+ SELECT d.name, COUNT(e.id) AS num_employees, ROUND(AVG(e.salary), 2) AS avg_salary
69
+ FROM departments d
70
+ JOIN employees e ON d.id = e.department_id
71
+ WHERE e.is_active = 1
72
+ GROUP BY d.id, d.name
73
+ HAVING COUNT(e.id) > 2
74
+ ORDER BY avg_salary DESC;
75
+ """,
76
+ expected_columns=["name", "num_employees", "avg_salary"],
77
+ expected_rows=[
78
+ ("Engineering", 4, 87500.0),
79
+ ("Sales", 3, 70333.33),
80
+ ("Marketing", 3, 65666.67),
81
+ ],
82
+ )
83
+
84
+ # ──────────────────────────────────────────────
85
+ # TASK 3 β€” HARD: Subquery + multiple JOINs + complex logic
86
+ # ──────────────────────────────────────────────
87
+ TASK_3 = TaskDefinition(
88
+ task_id="task_3",
89
+ description=(
90
+ "Find all active employees who lead at least one active project and "
91
+ "whose salary is above the overall average salary of all active employees. "
92
+ "For each such employee, show their name, salary, department name, and "
93
+ "the total budget of active projects they lead. "
94
+ "Order by total project budget descending."
95
+ ),
96
+ difficulty="hard",
97
+ expected_sql="""
98
+ SELECT
99
+ e.name,
100
+ e.salary,
101
+ d.name AS department_name,
102
+ SUM(p.budget) AS total_project_budget
103
+ FROM employees e
104
+ JOIN departments d ON e.department_id = d.id
105
+ JOIN projects p ON p.lead_employee_id = e.id
106
+ WHERE e.is_active = 1
107
+ AND p.status = 'active'
108
+ AND e.salary > (
109
+ SELECT AVG(salary) FROM employees WHERE is_active = 1
110
+ )
111
+ GROUP BY e.id, e.name, e.salary, d.name
112
+ ORDER BY total_project_budget DESC;
113
+ """,
114
+ expected_columns=["name", "salary", "department_name", "total_project_budget"],
115
+ expected_rows=[
116
+ ("Ravi Krishnan", 95000.0, "Engineering", 150000.0),
117
+ ("Priya Patel", 92000.0, "Engineering", 120000.0),
118
+ ("Arjun Sharma", 85000.0, "Engineering", 80000.0),
119
+ ],
120
+ )
121
+
122
+ # All tasks in order
123
+ ALL_TASKS = {
124
+ "task_1": TASK_1,
125
+ "task_2": TASK_2,
126
+ "task_3": TASK_3,
127
+ }
128
+
129
+ TASK_LIST = [TASK_1, TASK_2, TASK_3]