Nitesh321 commited on
Commit
798461f
·
0 Parent(s):

feat: SQL Analytics OpenEnv environment — 10 tasks, dense rewards, RL-ready

Browse files
.dockerignore ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Virtual environment
2
+ .venv/
3
+ venv/
4
+ env/
5
+
6
+ # Python cache
7
+ __pycache__/
8
+ *.py[cod]
9
+ *.pyo
10
+ *.pyd
11
+ .Python
12
+
13
+ # Test files
14
+ test_env.py
15
+ tests/
16
+ .pytest_cache/
17
+
18
+ # Build artifacts
19
+ *.egg-info/
20
+ dist/
21
+ build/
22
+
23
+ # Logs
24
+ *.log
25
+ outputs/
26
+
27
+ # IDE
28
+ .vscode/
29
+ .idea/
30
+ *.swp
31
+ *.swo
32
+
33
+ # OS
34
+ .DS_Store
35
+ Thumbs.db
36
+
37
+ # Git
38
+ .git/
39
+ .gitignore
40
+
41
+ # Local env files
42
+ .env
43
+ .env.*
.gitignore ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.pyd
6
+ .pytest_cache/
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+ .env
11
+ *.log
12
+ test_output.txt
13
+ test_err.txt
14
+ workspace/
Dockerfile ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── SQL Analytics OpenEnv — Dockerfile ───────────────────────────────────────
2
+ # Compatible with HuggingFace Spaces (Docker SDK)
3
+ # Runtime: python:3.11-slim | Port: 8000 | User: non-root (appuser)
4
+
5
+ FROM python:3.11-slim
6
+
7
+ LABEL maintainer="sql-analytics-env"
8
+ LABEL version="0.1.0"
9
+ LABEL description="SQL Analytics OpenEnv environment for RL agents"
10
+
11
+ # Keeps Python from generating .pyc files + enables unbuffered stdout logging
12
+ ENV PYTHONDONTWRITEBYTECODE=1 \
13
+ PYTHONUNBUFFERED=1 \
14
+ PIP_NO_CACHE_DIR=1 \
15
+ PYTHONIOENCODING=utf-8
16
+
17
+ WORKDIR /app
18
+
19
+ # Install system dependencies (build-essential needed for some pip wheels)
20
+ RUN apt-get update && apt-get install -y --no-install-recommends \
21
+ build-essential \
22
+ curl \
23
+ && rm -rf /var/lib/apt/lists/*
24
+
25
+ # ── Install Python dependencies BEFORE copying source code ───────────────────
26
+ # (Docker layer cache: re-run only when pyproject.toml changes, not source)
27
+ COPY pyproject.toml ./
28
+ RUN pip install --upgrade pip && \
29
+ pip install \
30
+ "openenv-core>=0.1.0" \
31
+ "fastapi>=0.110.0" \
32
+ "uvicorn[standard]>=0.29.0" \
33
+ "pydantic>=2.0.0"
34
+
35
+ # ── Copy application source ───────────────────────────────────────────────────
36
+ COPY . .
37
+
38
+ # ── Health check (Docker-native, used by orchestrators) ──────────────────────
39
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
40
+ CMD curl -f http://localhost:8000/health || exit 1
41
+
42
+ # ── Non-root user (required by HuggingFace Spaces) ───────────────────────────
43
+ RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
44
+ USER appuser
45
+
46
+ EXPOSE 8000
47
+
48
+ CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
README.md ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SQL Analytics OpenEnv Environment
2
+
3
+ ![Python](https://img.shields.io/badge/Python-3.10%2B-blue)
4
+ ![OpenEnv](https://img.shields.io/badge/OpenEnv-spec__v1-green)
5
+ ![License](https://img.shields.io/badge/License-MIT-yellow)
6
+ ![Tasks](https://img.shields.io/badge/Tasks-6%20(easy%2Fmedium%2Fhard)-orange)
7
+
8
+ A real-world OpenEnv environment where an AI agent answers business-intelligence
9
+ questions by writing SQL queries against a seeded e-commerce database.
10
+
11
+ ---
12
+
13
+ ## Overview
14
+
15
+ The agent interacts with a **SQLite e-commerce database** containing:
16
+
17
+ | Table | Rows | Description |
18
+ |--------------|-------|----------------------------------------------|
19
+ | customers | 100 | Registered users with US state + signup date |
20
+ | categories | 5 | Electronics, Clothing, Books, Food, Sports |
21
+ | products | 50 | Name, category, price, stock |
22
+ | orders | 500 | Date, total amount, status (delivered/…) |
23
+ | order_items | 1000+ | Per-line quantity and unit price |
24
+ | reviews | 300 | 1–5 star ratings per product |
25
+
26
+ ---
27
+
28
+ ## Database Schema
29
+
30
+ ```
31
+ customers(id, name, email, state, signup_date, last_order_date)
32
+ categories(id, name)
33
+ products(id, name, category_id, price, stock)
34
+ orders(id, customer_id, order_date, total_amount, status)
35
+ order_items(id, order_id, product_id, quantity, unit_price)
36
+ reviews(id, product_id, customer_id, rating, review_date)
37
+ ```
38
+
39
+ ---
40
+
41
+ ## Tasks
42
+
43
+ | Task ID | Difficulty | Question |
44
+ |-----------|------------|-------------------------------------------------------|
45
+ | task_001 | Easy | Count customers from California |
46
+ | task_002 | Easy | Name + price of the most expensive Electronics item |
47
+ | task_003 | Medium | Top 5 customers by total spend |
48
+ | task_004 | Medium | Product category with highest average rating |
49
+ | task_005 | Hard | Q1-2024-only buyers (early-churn signal) |
50
+ | task_006 | Hard | Monthly 2024 revenue + month-over-month growth rate |
51
+
52
+ ---
53
+
54
+ ## Action / Observation Spaces
55
+
56
+ ### Action — `SQLAction`
57
+
58
+ | Field | Type | Description |
59
+ |-------|------|---------------------------------------|
60
+ | `sql` | str | SQL SELECT query (SQLite dialect) |
61
+
62
+ ### Observation — `SQLObservation`
63
+
64
+ | Field | Type | Description |
65
+ |--------------------|--------------------|------------------------------------------|
66
+ | `result` | `list[dict] / None`| Query result rows |
67
+ | `error` | `str / None` | SQL error message (if any) |
68
+ | `db_schema` | `str / None` | Full schema description (on reset only) |
69
+ | `task_description` | `str / None` | Natural-language task question |
70
+ | `reward` | `float` | Partial-progress reward [0.0 – 1.0] |
71
+ | `done` | `bool` | True when episode ends |
72
+ | `metadata` | `dict` | task_id, difficulty, attempts, message |
73
+
74
+ ### Reward Signal (partial progress)
75
+
76
+ | Condition | Reward |
77
+ |--------------------------------------|-----------|
78
+ | SQL syntax / write-op error | 0.0 |
79
+ | Valid SQL but wrong answer | 0.1 – 0.5 |
80
+ | Near-correct (close count / overlap) | 0.5 – 0.9 |
81
+ | Correct answer | 1.0 |
82
+
83
+ ### Episode ends (`done=True`) when:
84
+ 1. Agent submits a correct answer (`reward >= 0.9`)
85
+ 2. Agent exhausts 10 attempts without solving
86
+ 3. `step()` called before `reset()`
87
+
88
+ ---
89
+
90
+ ## Installation
91
+
92
+ ### Prerequisites
93
+ - Python 3.10+
94
+ - pip
95
+ - (Optional) Docker for containerized deployment
96
+
97
+ ### Setup
98
+
99
+ ```bash
100
+ # Clone / navigate to the environment folder
101
+ cd sql_analytics_env
102
+
103
+ # Create and activate virtual environment
104
+ python -m venv .venv
105
+ .venv\Scripts\activate # Windows
106
+ # source .venv/bin/activate # Mac/Linux
107
+
108
+ # Install dependencies
109
+ pip install openenv-core fastapi "uvicorn[standard]" pydantic "openai>=1.30.0"
110
+ ```
111
+
112
+ ---
113
+
114
+ ## Running Locally
115
+
116
+ ### Start the server
117
+
118
+ ```bash
119
+ uvicorn server.app:app --host 0.0.0.0 --port 8000 --reload
120
+ ```
121
+
122
+ ### Verify health
123
+
124
+ ```bash
125
+ curl.exe http://localhost:8000/health
126
+ # {"status": "ok", "environment": "sql_analytics_env", "version": "0.1.0"}
127
+ ```
128
+
129
+ ### Browse API docs
130
+
131
+ Open in browser: **http://localhost:8000/docs** (Swagger UI)
132
+
133
+ ### Run smoke tests
134
+
135
+ ```bash
136
+ python -X utf8 test_env.py
137
+ ```
138
+
139
+ Expected: all `[PASS]`, Average correct reward = 1.00
140
+
141
+ ---
142
+
143
+ ## Docker
144
+
145
+ ```bash
146
+ # Build
147
+ docker build -t sql-analytics-env .
148
+
149
+ # Run
150
+ docker run -p 8000:8000 sql-analytics-env
151
+
152
+ # Verify
153
+ curl http://localhost:8000/health
154
+ ```
155
+
156
+ ---
157
+
158
+ ## Running the Baseline Agent
159
+
160
+ ```bash
161
+ # Set LLM credentials
162
+ set OPENAI_API_KEY=sk-your-key-here # Windows CMD
163
+ # export OPENAI_API_KEY=sk-your-key-here # Mac/Linux / PowerShell
164
+
165
+ # Optional overrides
166
+ set MODEL_NAME=gpt-4o-mini
167
+ set ENV_BASE_URL=http://localhost:8000
168
+ set MAX_STEPS=8
169
+
170
+ # Run inference
171
+ python -X utf8 inference.py
172
+ ```
173
+
174
+ ### Expected output
175
+
176
+ ```
177
+ [START] {"task_id": "task_001", "task_description": "...", "difficulty": "easy", ...}
178
+ [STEP] {"task_id": "task_001", "step": 1, "sql": "SELECT COUNT(*) ...", "reward": 1.0, ...}
179
+ [END] {"task_id": "task_001", "final_score": 1.0, "total_steps": 1, "success": true, ...}
180
+ ...
181
+ {"summary": {"scores": {...}, "average_score": 0.95, "tasks_solved": 6, "total_tasks": 6}}
182
+ ```
183
+
184
+ Runs in well under **20 minutes** on a 2 vCPU / 8 GB machine.
185
+
186
+ ---
187
+
188
+ ## HuggingFace Spaces Deployment
189
+
190
+ 1. Create a new Space → **Docker** SDK type
191
+ 2. Push this folder as the Space repository root
192
+ 3. HF Spaces builds from `Dockerfile` and serves on port 8000
193
+ 4. Set `ENV_BASE_URL=https://<your-space>.hf.space` in your inference runner
194
+
195
+ ---
196
+
197
+ ## Troubleshooting
198
+
199
+ | Problem | Fix |
200
+ |---------|-----|
201
+ | `uvicorn: command not found` | Activate venv: `.venv\Scripts\activate` |
202
+ | `Port 8000 already in use` | Use `--port 8001` |
203
+ | `ModuleNotFoundError: openenv` | Run `pip install openenv-core` |
204
+ | Unicode error on Windows | Always use `python -X utf8 inference.py` |
205
+ | `curl` not working in PowerShell | Use `curl.exe` or `Invoke-WebRequest -UseBasicParsing` |
206
+ | LLM rate limit in inference.py | Agent auto-retries with exponential backoff |
207
+ | SQL query times out | Queries limited to 5 seconds; simplify your query |
208
+
209
+ ---
210
+
211
+ ## Pre-submission Checklist
212
+
213
+ - [x] HF Space returns HTTP 200 on `/health`
214
+ - [x] `openenv.yaml` passes `openenv validate`
215
+ - [x] `Dockerfile` builds successfully with HEALTHCHECK
216
+ - [x] `inference.py` in root directory
217
+ - [x] Baseline script runs in < 20 minutes
218
+ - [x] Structured `[START]` / `[STEP]` / `[END]` stdout logging
219
+ - [x] 6 tasks with easy / medium / hard graders
220
+ - [x] Partial-progress reward functions (0.0 to 1.0)
221
+ - [x] Compatible with 2 vCPU / 8 GB deployment
222
+ - [x] CORS enabled for cross-origin access
223
+ - [x] Query timeout (5s) prevents hangs
224
+ - [x] Write-operation blocking (SELECT only)
225
+ - [x] Retry logic in inference.py for LLM failures
226
+ - [x] `.dockerignore` excludes venv and cache files
__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """SQL Analytics OpenEnv environment package."""
2
+
3
+ try:
4
+ from .models import SQLAction, SQLObservation, SQLState
5
+ from .client import SQLAnalyticsEnv
6
+ except ImportError:
7
+ pass
8
+
9
+ __all__ = ["SQLAction", "SQLObservation", "SQLState", "SQLAnalyticsEnv"]
client.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ client.py
3
+ ---------
4
+ Client for the SQL Analytics Environment.
5
+
6
+ SQLAnalyticsEnv wraps EnvClient with the three typed models specific to
7
+ this environment (SQLAction, SQLObservation, SQLState). All connection
8
+ and session management is inherited from EnvClient.
9
+
10
+ Example usage
11
+ -------------
12
+ # Against a local server:
13
+ with SQLAnalyticsEnv(base_url="http://localhost:8000") as env:
14
+ obs = env.reset(task_id="task_003")
15
+ print(obs.task_description)
16
+
17
+ result_obs = env.step(
18
+ SQLAction(sql="SELECT name, SUM(total_amount) FROM ...")
19
+ )
20
+ print(result_obs.result, result_obs.reward)
21
+
22
+ # Against a HuggingFace Space:
23
+ env = SQLAnalyticsEnv.from_env("your-hf-username/sql-analytics-env")
24
+ try:
25
+ obs = env.reset(task_id="task_001")
26
+ ...
27
+ finally:
28
+ env.close()
29
+
30
+ # From a local Docker image:
31
+ env = SQLAnalyticsEnv.from_docker_image("sql-analytics-env:latest")
32
+ try:
33
+ obs = env.reset(task_id="task_002")
34
+ ...
35
+ finally:
36
+ env.close()
37
+ """
38
+
39
+ from openenv.core.env_client import EnvClient
40
+
41
+ try:
42
+ from .models import SQLAction, SQLObservation, SQLState
43
+ except ImportError:
44
+ from models import SQLAction, SQLObservation, SQLState
45
+
46
+
47
+ class SQLAnalyticsEnv(EnvClient[SQLAction, SQLObservation, SQLState]):
48
+ """
49
+ Typed client for the SQL Analytics OpenEnv environment.
50
+
51
+ Inherits the full EnvClient interface:
52
+ reset(**kwargs) -> SQLObservation
53
+ step(action: SQLAction) -> SQLObservation
54
+ state() -> SQLState
55
+ close()
56
+
57
+ Convenience class methods (from EnvClient):
58
+ SQLAnalyticsEnv.from_env(hf_repo_id)
59
+ SQLAnalyticsEnv.from_docker_image(image_tag)
60
+ """
61
+ pass
inference.py ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ inference.py
3
+ ------------
4
+ Baseline LLM agent for the SQL Analytics OpenEnv environment.
5
+
6
+ The agent receives a natural-language business-intelligence question,
7
+ iteratively generates SQL queries using an LLM (via OpenAI-compatible
8
+ API), executes them in the environment, and improves based on feedback.
9
+
10
+ Logging
11
+ -------
12
+ Every event is printed to stdout in the strictly-required format:
13
+
14
+ [START] {"task_id": ..., "task_description": ..., "difficulty": ...}
15
+ [STEP] {"task_id": ..., "step": ..., "sql": ..., "reward": ...,
16
+ "result_rows": ..., "error": ..., "done": ...}
17
+ [END] {"task_id": ..., "final_score": ..., "total_steps": ...,
18
+ "success": ..., "timestamp": ...}
19
+
20
+ Environment variables
21
+ ---------------------
22
+ API_BASE_URL Base URL for the OpenAI-compatible LLM endpoint
23
+ (default: https://api.openai.com/v1)
24
+ MODEL_NAME LLM model to use (default: gpt-4o-mini)
25
+ HF_TOKEN HuggingFace token when connecting to HF Spaces
26
+ ENV_BASE_URL URL of the running sql_analytics_env server
27
+ (default: http://localhost:8000)
28
+ MAX_STEPS Max LLM turns per task (default: 8)
29
+
30
+ Usage
31
+ -----
32
+ # Against local server started with: uvicorn server.app:app --port 8000
33
+ python inference.py
34
+
35
+ # Against a HuggingFace Space:
36
+ ENV_BASE_URL=https://<your-space>.hf.space python inference.py
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import json
42
+ import os
43
+ import sys
44
+ import time
45
+ from datetime import datetime, timezone
46
+ from typing import Any, Dict, List, Optional
47
+
48
+ from openai import OpenAI, RateLimitError, APIConnectionError, APIStatusError
49
+
50
+ # ── Environment client ────────────────────────────────────────────────────────
51
+ # Support running inference.py both as standalone and inside the package.
52
+ try:
53
+ from client import SQLAnalyticsEnv
54
+ from models import SQLAction
55
+ except ImportError:
56
+ from sql_analytics_env.client import SQLAnalyticsEnv
57
+ from sql_analytics_env.models import SQLAction
58
+
59
+ # ── Config ────────────────────────────────────────────────────────────────────
60
+
61
+ API_BASE_URL = os.environ.get("API_BASE_URL", "https://api.openai.com/v1")
62
+ MODEL_NAME = os.environ.get("MODEL_NAME", "gpt-4o-mini")
63
+ ENV_BASE_URL = os.environ.get("ENV_BASE_URL", "http://localhost:8000")
64
+ MAX_STEPS = int(os.environ.get("MAX_STEPS", "8"))
65
+
66
+ ALL_TASK_IDS = [
67
+ "task_001", # easy
68
+ "task_002", # easy
69
+ "task_003", # medium
70
+ "task_004", # medium
71
+ "task_005", # hard
72
+ "task_006", # hard
73
+ "task_007", # easy
74
+ "task_008", # easy
75
+ "task_009", # medium
76
+ "task_010", # medium
77
+ ]
78
+
79
+ # ── Logging helpers ───────────────────────────────────────────────────────────
80
+
81
+ def _emit(tag: str, payload: Dict[str, Any]) -> None:
82
+ """Print a structured log line to stdout (required evaluation format)."""
83
+ print(f"[{tag}] {json.dumps(payload, default=str)}", flush=True)
84
+
85
+
86
+ def log_start(task_id: str, task_description: str, difficulty: str) -> None:
87
+ _emit("START", {
88
+ "task_id": task_id,
89
+ "task_description": task_description,
90
+ "difficulty": difficulty,
91
+ "timestamp": datetime.now(timezone.utc).isoformat(),
92
+ })
93
+
94
+
95
+ def log_step(
96
+ task_id: str,
97
+ step: int,
98
+ sql: str,
99
+ reward: float,
100
+ result_rows: int,
101
+ error: Optional[str],
102
+ done: bool,
103
+ ) -> None:
104
+ _emit("STEP", {
105
+ "task_id": task_id,
106
+ "step": step,
107
+ "sql": sql,
108
+ "reward": reward,
109
+ "result_rows": result_rows,
110
+ "error": error,
111
+ "done": done,
112
+ })
113
+
114
+
115
+ def log_end(
116
+ task_id: str,
117
+ final_score: float,
118
+ total_steps: int,
119
+ success: bool,
120
+ ) -> None:
121
+ _emit("END", {
122
+ "task_id": task_id,
123
+ "final_score": final_score,
124
+ "total_steps": total_steps,
125
+ "success": success,
126
+ "timestamp": datetime.now(timezone.utc).isoformat(),
127
+ })
128
+
129
+
130
+ # ── LLM helpers ───────────────────────────────────────────────────────────────
131
+
132
+ _SYSTEM_PROMPT = """You are an expert SQL analyst. You will be given a business
133
+ intelligence question about an e-commerce database and must answer it by
134
+ writing a single SQL query.
135
+
136
+ Rules:
137
+ - Write ONLY the raw SQL query — no markdown, no explanation, no backticks.
138
+ - The database is SQLite; use SQLite-compatible syntax.
139
+ - Use ROUND(..., 2) for monetary values and ratings.
140
+ - Use strftime('%m', date_column) to extract the month as a string;
141
+ CAST(strftime('%m', ...) AS INTEGER) for an integer.
142
+ - When asked for growth rate you may use a self-join or a subquery
143
+ (SQLite does not always support window functions LAG).
144
+ - Return only the columns asked for in the task description.
145
+ """
146
+
147
+ def _build_user_message(
148
+ schema: str,
149
+ task_description: str,
150
+ history: List[Dict],
151
+ ) -> str:
152
+ """Build the user turn including schema, task, and any prior attempt feedback."""
153
+ lines = [
154
+ "DATABASE SCHEMA:",
155
+ schema,
156
+ "",
157
+ "TASK:",
158
+ task_description,
159
+ ]
160
+
161
+ if history:
162
+ lines += ["", "PREVIOUS ATTEMPTS (most recent first):"]
163
+ for attempt in reversed(history[-3:]): # show last 3
164
+ lines.append(f" SQL: {attempt['sql']}")
165
+ if attempt.get("error"):
166
+ lines.append(f" Error: {attempt['error']}")
167
+ else:
168
+ rows = attempt.get("result_rows", 0)
169
+ reward = attempt.get("reward", 0.0)
170
+ msg = attempt.get("message", "")
171
+ lines.append(f" Rows returned: {rows} | Reward: {reward:.2f} | Feedback: {msg}")
172
+ lines += ["", "Please write an improved SQL query."]
173
+ else:
174
+ lines += ["", "Write the SQL query:"]
175
+
176
+ return "\n".join(lines)
177
+
178
+
179
+ def _extract_sql(llm_response: str) -> str:
180
+ """Strip markdown fences and whitespace from the LLM output."""
181
+ sql = llm_response.strip()
182
+ for fence in ("```sql", "```SQL", "```"):
183
+ if sql.startswith(fence):
184
+ sql = sql[len(fence):]
185
+ if "```" in sql:
186
+ sql = sql[: sql.index("```")]
187
+ break
188
+ return sql.strip()
189
+
190
+
191
+ # ── Agent loop ────────────────────────────────────────────────────────────────
192
+
193
+ def run_task(
194
+ client: OpenAI,
195
+ env: SQLAnalyticsEnv,
196
+ task_id: str,
197
+ ) -> float:
198
+ """Run one task episode. Returns the final score (0.0–1.0)."""
199
+ # Reset
200
+ obs = env.reset(task_id=task_id)
201
+ schema = obs.db_schema or ""
202
+ task_description = obs.task_description or ""
203
+ difficulty = obs.metadata.get("difficulty", "?") if obs.metadata else "?"
204
+
205
+ log_start(task_id, task_description, difficulty)
206
+
207
+ history: List[Dict] = []
208
+ final_score = 0.0
209
+ step_count = 0
210
+
211
+ for step in range(1, MAX_STEPS + 1):
212
+ # Ask LLM (with retry + exponential backoff)
213
+ user_msg = _build_user_message(schema, task_description, history)
214
+ sql = ""
215
+ for attempt in range(1, 4): # up to 3 retries
216
+ try:
217
+ response = client.chat.completions.create(
218
+ model=MODEL_NAME,
219
+ messages=[
220
+ {"role": "system", "content": _SYSTEM_PROMPT},
221
+ {"role": "user", "content": user_msg},
222
+ ],
223
+ temperature=0.0,
224
+ max_tokens=512,
225
+ )
226
+ sql = _extract_sql(response.choices[0].message.content or "")
227
+ break # success
228
+ except RateLimitError:
229
+ wait = 2 ** attempt
230
+ print(f"[WARN] Rate limited — waiting {wait}s (attempt {attempt}/3)", file=sys.stderr)
231
+ time.sleep(wait)
232
+ except APIConnectionError as exc:
233
+ print(f"[WARN] Connection error on step {step}, attempt {attempt}: {exc}", file=sys.stderr)
234
+ time.sleep(2)
235
+ except APIStatusError as exc:
236
+ print(f"[WARN] API error {exc.status_code} on step {step}: {exc.message}", file=sys.stderr)
237
+ break
238
+ except Exception as exc:
239
+ print(f"[WARN] Unexpected LLM error on step {step}: {exc}", file=sys.stderr)
240
+ break
241
+
242
+ if not sql:
243
+ print(f"[WARN] No SQL generated for step {step} — skipping.", file=sys.stderr)
244
+ break
245
+
246
+ if not sql:
247
+ break
248
+
249
+ # Execute in environment
250
+ obs = env.step(SQLAction(sql=sql))
251
+
252
+ result_rows = len(obs.result) if obs.result else 0
253
+ reward = obs.reward or 0.0
254
+ error = obs.error
255
+ done = obs.done
256
+ message = (obs.metadata or {}).get("message", "")
257
+ step_count = step
258
+
259
+ log_step(task_id, step, sql, reward, result_rows, error, done)
260
+
261
+ history.append({
262
+ "sql": sql,
263
+ "error": error,
264
+ "result_rows": result_rows,
265
+ "reward": reward,
266
+ "message": message,
267
+ })
268
+
269
+ final_score = reward
270
+
271
+ if done:
272
+ break
273
+
274
+ success = final_score >= 0.9
275
+ log_end(task_id, final_score, step_count, success)
276
+ return final_score
277
+
278
+
279
+ # ── Entry point ───────────────────────────────────────────────────────────────
280
+
281
+ def main() -> None:
282
+ llm_client = OpenAI(base_url=API_BASE_URL)
283
+ env = SQLAnalyticsEnv(base_url=ENV_BASE_URL)
284
+
285
+ scores: Dict[str, float] = {}
286
+
287
+ try:
288
+ for task_id in ALL_TASK_IDS:
289
+ score = run_task(llm_client, env, task_id)
290
+ scores[task_id] = score
291
+ finally:
292
+ env.close()
293
+
294
+ # Summary
295
+ avg = sum(scores.values()) / len(scores) if scores else 0.0
296
+ print(
297
+ json.dumps({
298
+ "summary": {
299
+ "scores": scores,
300
+ "average_score": round(avg, 4),
301
+ "tasks_solved": sum(1 for s in scores.values() if s >= 0.9),
302
+ "total_tasks": len(scores),
303
+ }
304
+ }),
305
+ flush=True,
306
+ )
307
+
308
+
309
+ if __name__ == "__main__":
310
+ main()
models.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ models.py
3
+ ---------
4
+ Action / Observation / State typed models for the SQL Analytics environment.
5
+
6
+ The agent submits a SQL query (SQLAction) and receives back the query
7
+ result rows, any SQL error, the current task description, and reward
8
+ signal (SQLObservation). Episode metadata is tracked in SQLState.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ from pydantic import Field, field_validator
16
+
17
+ try:
18
+ from openenv.core.env_server.interfaces import Action, Observation, State
19
+ except ImportError:
20
+ from openenv.core.env_server.types import Action, Observation, State
21
+
22
+
23
+ class SQLAction(Action):
24
+ """
25
+ A single SQL query submitted by the agent.
26
+
27
+ Example::
28
+
29
+ SQLAction(sql="SELECT COUNT(*) FROM customers WHERE state = 'CA'")
30
+ """
31
+
32
+ sql: str = Field(
33
+ ...,
34
+ min_length=1,
35
+ description="SQL SELECT query to execute against the e-commerce database.",
36
+ json_schema_extra={"example": "SELECT COUNT(*) FROM customers WHERE state = 'CA'"},
37
+ )
38
+
39
+ @field_validator("sql")
40
+ @classmethod
41
+ def strip_whitespace(cls, v: str) -> str:
42
+ """Strip leading/trailing whitespace from the query."""
43
+ return v.strip()
44
+
45
+
46
+ class SQLObservation(Observation):
47
+ """
48
+ Result of executing a SQL query in the environment.
49
+
50
+ Fields
51
+ ------
52
+ result : Rows returned by the query (list of dicts), None on error.
53
+ error : SQL error message if the query failed, else None.
54
+ db_schema : Human-readable database schema (populated on reset).
55
+ task_description: Natural-language question the agent must answer.
56
+ """
57
+
58
+ result: Optional[List[Dict[str, Any]]] = None
59
+ error: Optional[str] = None
60
+ db_schema: Optional[str] = None
61
+ task_description: Optional[str] = None
62
+
63
+
64
+ class SQLState(State):
65
+ """Episode-level state for the SQL Analytics environment."""
66
+
67
+ task_id: str = ""
68
+ task_description: str = ""
69
+ difficulty: str = "" # "easy" | "medium" | "hard"
70
+ attempts: int = 0
71
+ max_attempts: int = 10
72
+ solved: bool = False
openenv.yaml ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ version: 0.1.0
3
+ name: sql_analytics_env
4
+ type: space
5
+ runtime: fastapi
6
+ app: server.app:app
7
+ port: 8000
8
+
9
+ description: |
10
+ SQL Analytics environment where AI agents answer real-world
11
+ business-intelligence questions by writing SQL queries against
12
+ a seeded e-commerce database (100 customers, 50 products,
13
+ 500 orders, 300 reviews). Ten tasks across easy/medium/hard
14
+ difficulty with partial-progress reward signals (0.0 - 1.0).
15
+
16
+ tags:
17
+ - sql
18
+ - database
19
+ - business-intelligence
20
+ - reinforcement-learning
21
+ - real-world
22
+ - ecommerce
23
+
24
+ tasks:
25
+ - task_001
26
+ - task_002
27
+ - task_003
28
+ - task_004
29
+ - task_005
30
+ - task_006
31
+ - task_007
32
+ - task_008
33
+ - task_009
34
+ - task_010
pyproject.toml ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sql-analytics-env"
7
+ version = "0.1.0"
8
+ description = "SQL Analytics OpenEnv environment — BI question-answering via SQL"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.10"
12
+
13
+ authors = [
14
+ {name = "SQL Analytics Env", email = "sql-analytics@example.com"}
15
+ ]
16
+
17
+ keywords = [
18
+ "sql", "database", "reinforcement-learning",
19
+ "openenv", "business-intelligence", "ecommerce", "agent"
20
+ ]
21
+
22
+ classifiers = [
23
+ "Development Status :: 4 - Beta",
24
+ "Intended Audience :: Science/Research",
25
+ "License :: OSI Approved :: MIT License",
26
+ "Programming Language :: Python :: 3.10",
27
+ "Programming Language :: Python :: 3.11",
28
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
29
+ ]
30
+
31
+ dependencies = [
32
+ "openenv-core>=0.1.0,<1.0.0",
33
+ "fastapi>=0.110.0,<1.0.0",
34
+ "uvicorn[standard]>=0.29.0,<1.0.0",
35
+ "pydantic>=2.0.0,<3.0.0",
36
+ "openai>=1.30.0,<3.0.0",
37
+ ]
38
+
39
+ [project.optional-dependencies]
40
+ dev = [
41
+ "pytest>=8.0.0",
42
+ "pytest-asyncio>=0.23.0",
43
+ "httpx>=0.27.0",
44
+ ]
45
+
46
+ [project.urls]
47
+ Homepage = "https://huggingface.co/spaces/your-username/sql-analytics-env"
48
+ Repository = "https://github.com/your-username/sql-analytics-env"
49
+ Documentation = "https://github.com/your-username/sql-analytics-env#readme"
50
+
51
+ [project.scripts]
52
+ sql-analytics-server = "server.app:main"
53
+
54
+ [tool.hatch.build.targets.wheel]
55
+ packages = ["."]
56
+
57
+ [tool.pytest.ini_options]
58
+ testpaths = ["."]
59
+ python_files = ["test_*.py"]
server/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # server package
server/app.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ server/app.py
3
+ -------------
4
+ FastAPI application for the SQL Analytics Environment.
5
+
6
+ Exposes the environment over HTTP + WebSocket endpoints that are
7
+ consumed by EnvClient / the OpenEnv evaluation harness.
8
+
9
+ Usage (development):
10
+ uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
11
+
12
+ Usage (production via Docker):
13
+ uvicorn server.app:app --host 0.0.0.0 --port 8000 --workers 1
14
+ """
15
+
16
+ import time
17
+ from fastapi import Request
18
+ from fastapi.middleware.cors import CORSMiddleware
19
+ from fastapi.responses import JSONResponse
20
+
21
+ try:
22
+ from openenv.core.env_server.http_server import create_app
23
+ from .sql_analytics_environment import SQLAnalyticsEnvironment
24
+ from ..models import SQLAction, SQLObservation
25
+ except ImportError:
26
+ from openenv.core.env_server.http_server import create_app
27
+ from server.sql_analytics_environment import SQLAnalyticsEnvironment
28
+ from models import SQLAction, SQLObservation
29
+
30
+ # ── Create base app ───────────────────────────────────────────────────────────
31
+ # Pass the class (not an instance) so the HTTP server can instantiate
32
+ # a fresh environment per WebSocket session.
33
+ app = create_app(
34
+ SQLAnalyticsEnvironment,
35
+ SQLAction,
36
+ SQLObservation,
37
+ env_name="sql_analytics_env",
38
+ )
39
+
40
+ # ── CORS — allow all origins (needed for HuggingFace Spaces) ─────────────────
41
+ app.add_middleware(
42
+ CORSMiddleware,
43
+ allow_origins=["*"],
44
+ allow_credentials=True,
45
+ allow_methods=["*"],
46
+ allow_headers=["*"],
47
+ )
48
+
49
+ # ── Explicit health check (required by OpenEnv validator + hackathon) ─────────
50
+ @app.get("/health", tags=["meta"])
51
+ async def health():
52
+ """
53
+ Health check endpoint.
54
+ Returns HTTP 200 when the server is up and ready.
55
+ Required by OpenEnv pre-submission checks.
56
+ """
57
+ return {"status": "ok", "environment": "sql_analytics_env", "version": "0.1.0"}
58
+
59
+
60
+ # ── Environment metadata ──────────────────────────────────────────────────────
61
+ @app.get("/info", tags=["meta"])
62
+ async def info():
63
+ """Return environment metadata for discoverability."""
64
+ return {
65
+ "name": "sql_analytics_env",
66
+ "version": "0.1.0",
67
+ "description": (
68
+ "SQL Analytics environment where AI agents answer "
69
+ "business-intelligence questions by writing SQL queries "
70
+ "against a seeded e-commerce database."
71
+ ),
72
+ "tasks": [
73
+ {"task_id": "task_001", "difficulty": "easy", "topic": "Count customers by state"},
74
+ {"task_id": "task_002", "difficulty": "easy", "topic": "Most expensive product in category"},
75
+ {"task_id": "task_003", "difficulty": "medium", "topic": "Top 5 customers by spend"},
76
+ {"task_id": "task_004", "difficulty": "medium", "topic": "Category with highest avg rating"},
77
+ {"task_id": "task_005", "difficulty": "hard", "topic": "Q1-only buyers (churn signal)"},
78
+ {"task_id": "task_006", "difficulty": "hard", "topic": "Monthly revenue + growth rate"},
79
+ ],
80
+ "action_space": "SQLAction { sql: str }",
81
+ "observation_space": "SQLObservation { result, error, db_schema, task_description, reward, done }",
82
+ "reward_range": [0.0, 1.0],
83
+ "max_steps": 10,
84
+ }
85
+
86
+
87
+ # ── Request timing middleware (helps with debugging slow queries) ─────────────
88
+ @app.middleware("http")
89
+ async def add_process_time_header(request: Request, call_next):
90
+ start = time.perf_counter()
91
+ response = await call_next(request)
92
+ elapsed = round(time.perf_counter() - start, 4)
93
+ response.headers["X-Process-Time"] = str(elapsed)
94
+ return response
95
+
96
+
97
+ # ── Global exception handler ──────────────────────────────────────────────────
98
+ @app.exception_handler(Exception)
99
+ async def global_exception_handler(request: Request, exc: Exception):
100
+ return JSONResponse(
101
+ status_code=500,
102
+ content={
103
+ "error": type(exc).__name__,
104
+ "detail": str(exc),
105
+ "path": str(request.url),
106
+ },
107
+ )
108
+
109
+
110
+ def main() -> None:
111
+ """Entry point for direct execution."""
112
+ import uvicorn
113
+ uvicorn.run(app, host="0.0.0.0", port=8000)
114
+
115
+
116
+ if __name__ == "__main__":
117
+ main()
server/sql_analytics_environment.py ADDED
@@ -0,0 +1,1077 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ sql_analytics_environment.py
3
+ -----------------------------
4
+ Core environment implementation for the SQL Analytics OpenEnv environment.
5
+
6
+ An AI agent answers real-world business-intelligence questions by writing
7
+ SQL queries against a seeded e-commerce SQLite database.
8
+
9
+ Database schema
10
+ ---------------
11
+ customers(id, name, email, state, signup_date, last_order_date)
12
+ categories(id, name)
13
+ products(id, name, category_id, price, stock)
14
+ orders(id, customer_id, order_date, total_amount, status)
15
+ order_items(id, order_id, product_id, quantity, unit_price)
16
+ reviews(id, product_id, customer_id, rating, review_date)
17
+
18
+ Six tasks (easy x2 / medium x2 / hard x2) with deterministic expected
19
+ answers. Rewards carry partial-progress signals so an RL agent can learn
20
+ from near-misses.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ import random
27
+ import re
28
+ import sqlite3
29
+ import threading
30
+ from datetime import datetime, timedelta
31
+ from typing import Any, Dict, List, Optional, Tuple
32
+ from uuid import uuid4
33
+
34
+ try:
35
+ from openenv.core.env_server.interfaces import Environment
36
+ from ..models import SQLAction, SQLObservation, SQLState
37
+ except ImportError:
38
+ from openenv.core.env_server.interfaces import Environment
39
+ from models import SQLAction, SQLObservation, SQLState
40
+
41
+ # ── Logger ────────────────────────────────────────────────────────────────────
42
+ logger = logging.getLogger("sql_analytics_env")
43
+ logging.basicConfig(
44
+ level=logging.INFO,
45
+ format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
46
+ )
47
+
48
+ # ── Query safety ──────────────────────────────────────────────────────────────
49
+ _QUERY_TIMEOUT_SECONDS = 5 # max seconds per SQL query
50
+ _BLOCKED_KEYWORDS = frozenset([ # read-only enforcement
51
+ "insert", "update", "delete", "drop", "create", "alter",
52
+ "attach", "detach", "pragma", "vacuum",
53
+ ])
54
+
55
+
56
+ # ── Schema ─────────────────────────────────────────────────────────────────────
57
+
58
+ _SCHEMA_DDL = """
59
+ CREATE TABLE IF NOT EXISTS customers (
60
+ id INTEGER PRIMARY KEY,
61
+ name TEXT NOT NULL,
62
+ email TEXT NOT NULL,
63
+ state TEXT NOT NULL,
64
+ signup_date TEXT NOT NULL,
65
+ last_order_date TEXT
66
+ );
67
+
68
+ CREATE TABLE IF NOT EXISTS categories (
69
+ id INTEGER PRIMARY KEY,
70
+ name TEXT NOT NULL
71
+ );
72
+
73
+ CREATE TABLE IF NOT EXISTS products (
74
+ id INTEGER PRIMARY KEY,
75
+ name TEXT NOT NULL,
76
+ category_id INTEGER NOT NULL REFERENCES categories(id),
77
+ price REAL NOT NULL,
78
+ stock INTEGER NOT NULL
79
+ );
80
+
81
+ CREATE TABLE IF NOT EXISTS orders (
82
+ id INTEGER PRIMARY KEY,
83
+ customer_id INTEGER NOT NULL REFERENCES customers(id),
84
+ order_date TEXT NOT NULL,
85
+ total_amount REAL NOT NULL,
86
+ status TEXT NOT NULL
87
+ );
88
+
89
+ CREATE TABLE IF NOT EXISTS order_items (
90
+ id INTEGER PRIMARY KEY,
91
+ order_id INTEGER NOT NULL REFERENCES orders(id),
92
+ product_id INTEGER NOT NULL REFERENCES products(id),
93
+ quantity INTEGER NOT NULL,
94
+ unit_price REAL NOT NULL
95
+ );
96
+
97
+ CREATE TABLE IF NOT EXISTS reviews (
98
+ id INTEGER PRIMARY KEY,
99
+ product_id INTEGER NOT NULL REFERENCES products(id),
100
+ customer_id INTEGER NOT NULL REFERENCES customers(id),
101
+ rating INTEGER NOT NULL,
102
+ review_date TEXT NOT NULL
103
+ );
104
+ """
105
+
106
+ # Static schema description — runtime-enriched version built after DB seed
107
+ _SCHEMA_DESCRIPTION = """
108
+ Database Schema (SQLite, read-only):
109
+ =======================================================
110
+
111
+ customers(id, name, email, state, signup_date, last_order_date)
112
+ - state : 2-letter US state code (e.g. 'CA', 'NY', 'TX')
113
+ - signup_date : 'YYYY-MM-DD'
114
+ - last_order_date: 'YYYY-MM-DD' or NULL
115
+
116
+ categories(id, name)
117
+ - name values: 'Electronics', 'Clothing', 'Books', 'Food', 'Sports'
118
+
119
+ products(id, name, category_id, price, stock)
120
+ - category_id references categories.id
121
+
122
+ orders(id, customer_id, order_date, total_amount, status)
123
+ - order_date : 'YYYY-MM-DD'
124
+ - status : 'delivered' | 'shipped' | 'pending' | 'cancelled'
125
+ - customer_id references customers.id
126
+
127
+ order_items(id, order_id, product_id, quantity, unit_price)
128
+ - order_id references orders.id
129
+ - product_id references products.id
130
+
131
+ reviews(id, product_id, customer_id, rating, review_date)
132
+ - rating : integer 1-5
133
+ - review_date: 'YYYY-MM-DD'
134
+ - product_id references products.id
135
+ - customer_id references customers.id
136
+ """.strip()
137
+
138
+
139
+ def _build_rich_schema(conn: sqlite3.Connection) -> str:
140
+ """
141
+ Build a rich schema description with row counts and sample data.
142
+ This is much more useful for an AI agent than just column names —
143
+ it can see what actual values look like (e.g. 'CA' not 'California').
144
+ """
145
+ conn.row_factory = sqlite3.Row
146
+
147
+ tables = [
148
+ ("customers", "id, name, email, state, signup_date, last_order_date"),
149
+ ("categories", "id, name"),
150
+ ("products", "id, name, category_id, price, stock"),
151
+ ("orders", "id, customer_id, order_date, total_amount, status"),
152
+ ("order_items", "id, order_id, product_id, quantity, unit_price"),
153
+ ("reviews", "id, product_id, customer_id, rating, review_date"),
154
+ ]
155
+
156
+ notes = {
157
+ "customers": "state is 2-letter US code. signup_date/last_order_date are 'YYYY-MM-DD'.",
158
+ "categories": "name is one of: 'Electronics', 'Clothing', 'Books', 'Food', 'Sports'.",
159
+ "products": "category_id references categories.id. price is REAL.",
160
+ "orders": "status is one of: 'delivered', 'shipped', 'pending', 'cancelled'.",
161
+ "order_items": "Links orders to products. unit_price is the price at time of order.",
162
+ "reviews": "rating is integer 1-5. review_date is 'YYYY-MM-DD'.",
163
+ }
164
+
165
+ lines = [
166
+ "Database Schema (SQLite, read-only):",
167
+ "=" * 55,
168
+ "",
169
+ ]
170
+
171
+ for table, cols in tables:
172
+ row_count = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
173
+ lines.append(f"TABLE: {table} ({row_count:,} rows)")
174
+ lines.append(f" Columns : {cols}")
175
+ lines.append(f" Notes : {notes[table]}")
176
+
177
+ # Sample rows (first 3)
178
+ sample_rows = conn.execute(
179
+ f"SELECT {cols} FROM {table} LIMIT 3"
180
+ ).fetchall()
181
+ if sample_rows:
182
+ lines.append(" Sample :")
183
+ col_names = cols.split(", ")
184
+ for r in sample_rows:
185
+ row_str = " | ".join(f"{col}={str(r[col])[:18]}" for col in col_names)
186
+ lines.append(f" {row_str}")
187
+ lines.append("")
188
+
189
+ return "\n".join(lines).strip()
190
+
191
+
192
+ def _educational_error(sql: str, raw_error: str) -> str:
193
+ """
194
+ Convert a raw SQLite error into a human-readable educational message
195
+ that helps the agent understand what went wrong and how to fix it.
196
+ """
197
+ e = raw_error.lower()
198
+ available_tables = "customers, categories, products, orders, order_items, reviews"
199
+
200
+ if "no such table" in e:
201
+ bad = raw_error.split(":")[-1].strip()
202
+ return (
203
+ f"Table '{bad}' does not exist. "
204
+ f"Available tables: {available_tables}. "
205
+ "Check your spelling and use lowercase table names."
206
+ )
207
+ if "no such column" in e:
208
+ return (
209
+ f"{raw_error}. "
210
+ "Check the schema for the correct column name. "
211
+ "Tip: use table_name.column_name to avoid ambiguity."
212
+ )
213
+ if "ambiguous column" in e:
214
+ bad = raw_error.split(":")[-1].strip()
215
+ return (
216
+ f"Column '{bad}' is ambiguous — it exists in multiple tables. "
217
+ "Qualify it with the table name, e.g. customers.id or orders.id."
218
+ )
219
+ if "syntax error" in e or "near" in e:
220
+ return (
221
+ f"SQL syntax error: {raw_error}. "
222
+ "Common causes: missing comma, unmatched parenthesis, "
223
+ "misspelled keyword, or missing FROM clause."
224
+ )
225
+ if "not unique" in e or "unique constraint" in e:
226
+ return f"Duplicate value error: {raw_error}. You may need DISTINCT."
227
+ if "no such function" in e:
228
+ fn = raw_error.split(":")[-1].strip()
229
+ return (
230
+ f"Function '{fn}' is not available in SQLite. "
231
+ "SQLite supports: COUNT, SUM, AVG, MIN, MAX, ROUND, "
232
+ "strftime, COALESCE, IFNULL, CAST."
233
+ )
234
+
235
+ return f"SQL error: {raw_error}"
236
+
237
+
238
+ # ── Database seeding ───────────────────────────────────────────────────────────
239
+
240
+ def _seed(conn: sqlite3.Connection) -> None:
241
+ """Populate the database with deterministic synthetic e-commerce data."""
242
+ rng = random.Random(42)
243
+
244
+ # ── categories ──
245
+ categories = ["Electronics", "Clothing", "Books", "Food", "Sports"]
246
+ conn.executemany(
247
+ "INSERT INTO categories (id, name) VALUES (?, ?)",
248
+ [(i + 1, n) for i, n in enumerate(categories)],
249
+ )
250
+
251
+ # ── products (10 per category = 50 total) ──
252
+ _PRODUCTS: Dict[int, List[Tuple[str, float]]] = {
253
+ 1: [ # Electronics
254
+ ("iPhone 15", 999.99), ("Samsung 55\" TV", 799.99),
255
+ ("MacBook Pro 14\"", 2499.99), ("AirPods Pro", 249.99),
256
+ ("iPad Air", 599.99), ("Sony WH-1000XM5", 349.99),
257
+ ("Dell 27\" Monitor", 399.99), ("Mechanical Keyboard", 149.99),
258
+ ("Logitech MX Master", 99.99), ("USB-C Hub 7-port", 49.99),
259
+ ],
260
+ 2: [ # Clothing
261
+ ("Running Shoes", 129.99), ("Slim Fit Jeans", 59.99),
262
+ ("Winter Parka", 199.99), ("Yoga Pants", 49.99),
263
+ ("Classic T-Shirt", 24.99), ("Formal Blazer", 299.99),
264
+ ("Canvas Sneakers", 89.99), ("Floral Dress", 79.99),
265
+ ("Zip Hoodie", 54.99), ("Board Shorts", 34.99),
266
+ ],
267
+ 3: [ # Books
268
+ ("Python Cookbook 3e", 39.99), ("Clean Code", 44.99),
269
+ ("Deep Learning (Goodfellow)", 54.99), ("The Pragmatic Programmer", 49.99),
270
+ ("Design Patterns (GoF)", 44.99), ("Atomic Habits", 19.99),
271
+ ("Zero to One", 24.99), ("Sapiens", 22.99),
272
+ ("The Art of War", 12.99), ("Thinking Fast and Slow", 17.99),
273
+ ],
274
+ 4: [ # Food
275
+ ("Organic Coffee 1 kg", 24.99), ("Matcha Green Tea Set", 19.99),
276
+ ("Whey Protein 2 kg", 49.99), ("Granola Bar 24-pack", 14.99),
277
+ ("Extra Virgin Olive Oil", 18.99), ("70% Dark Chocolate Box", 22.99),
278
+ ("Roasted Almonds 500 g", 16.99), ("Artisan Pasta 500 g", 8.99),
279
+ ("Craft Hot Sauce Set", 29.99), ("Raw Organic Honey", 12.99),
280
+ ],
281
+ 5: [ # Sports
282
+ ("Yoga Mat 6mm", 49.99), ("Resistance Band Set", 29.99),
283
+ ("Adjustable Dumbbell 20 kg", 89.99), ("Tennis Racket Pro", 79.99),
284
+ ("Official Basketball", 34.99), ("Speed Jump Rope", 14.99),
285
+ ("Foam Roller 60cm", 24.99), ("Doorframe Pull-up Bar", 39.99),
286
+ ("Padded Gym Gloves", 19.99), ("40 oz Insulated Bottle", 29.99),
287
+ ],
288
+ }
289
+
290
+ products: List[Tuple] = []
291
+ pid = 1
292
+ for cat_id, items in _PRODUCTS.items():
293
+ for name, price in items:
294
+ products.append((pid, name, cat_id, price, rng.randint(0, 200)))
295
+ pid += 1
296
+ conn.executemany(
297
+ "INSERT INTO products (id, name, category_id, price, stock) VALUES (?,?,?,?,?)",
298
+ products,
299
+ )
300
+
301
+ # ── customers (100) ──
302
+ _FIRST = ["Alice", "Bob", "Carol", "David", "Eve", "Frank", "Grace",
303
+ "Henry", "Iris", "Jack", "Karen", "Leo", "Mary", "Nick",
304
+ "Olivia", "Paul", "Quinn", "Rachel", "Sam", "Tina"]
305
+ _LAST = ["Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia",
306
+ "Miller", "Davis", "Martinez", "Wilson", "Anderson", "Taylor",
307
+ "Thomas", "Moore", "Jackson", "Martin", "Lee", "Perez",
308
+ "Thompson", "White"]
309
+ _STATES = (["CA"] * 20 + ["NY"] * 12 + ["TX"] * 12 + ["FL"] * 8 +
310
+ ["WA"] * 6 + ["IL"] * 5 + ["GA"] * 5 + ["MA"] * 5 +
311
+ ["CO"] * 4 + ["AZ"] * 4 + ["PA"] * 4 + ["OR"] * 3 +
312
+ ["NV"] * 3 + ["OH"] * 3 + ["MI"] * 3 + ["NC"] * 3)
313
+
314
+ BASE = datetime(2023, 1, 1)
315
+ customers: List[Tuple] = []
316
+ for i in range(100):
317
+ fn = rng.choice(_FIRST)
318
+ ln = rng.choice(_LAST)
319
+ st = rng.choice(_STATES)
320
+ signup = (BASE + timedelta(days=rng.randint(0, 365))).strftime("%Y-%m-%d")
321
+ customers.append((i + 1, f"{fn} {ln}",
322
+ f"{fn.lower()}.{ln.lower()}{i}@example.com",
323
+ st, signup, None))
324
+ conn.executemany(
325
+ "INSERT INTO customers (id, name, email, state, signup_date, last_order_date)"
326
+ " VALUES (?,?,?,?,?,?)",
327
+ customers,
328
+ )
329
+
330
+ # ── orders (500) and order_items ──
331
+ orders: List[Tuple] = []
332
+ items_rows: List[Tuple] = []
333
+ oid = 1
334
+ iid = 1
335
+ last_order: Dict[int, str] = {}
336
+ YEAR_START = datetime(2024, 1, 1)
337
+
338
+ for _ in range(500):
339
+ cust_id = rng.randint(1, 100)
340
+ odate = (YEAR_START + timedelta(days=rng.randint(0, 364))).strftime("%Y-%m-%d")
341
+ status = rng.choices(
342
+ ["delivered", "shipped", "pending", "cancelled"],
343
+ weights=[60, 20, 10, 10],
344
+ )[0]
345
+
346
+ n_items = rng.randint(1, 4)
347
+ chosen = rng.sample(range(1, 51), n_items)
348
+ total = 0.0
349
+ for prod_id in chosen:
350
+ qty = rng.randint(1, 3)
351
+ price = products[prod_id - 1][3]
352
+ total += qty * price
353
+ items_rows.append((iid, oid, prod_id, qty, price))
354
+ iid += 1
355
+
356
+ total = round(total, 2)
357
+ orders.append((oid, cust_id, odate, total, status))
358
+
359
+ if cust_id not in last_order or odate > last_order[cust_id]:
360
+ last_order[cust_id] = odate
361
+ oid += 1
362
+
363
+ conn.executemany(
364
+ "INSERT INTO orders (id, customer_id, order_date, total_amount, status)"
365
+ " VALUES (?,?,?,?,?)",
366
+ orders,
367
+ )
368
+ conn.executemany(
369
+ "INSERT INTO order_items (id, order_id, product_id, quantity, unit_price)"
370
+ " VALUES (?,?,?,?,?)",
371
+ items_rows,
372
+ )
373
+
374
+ for cid, ldate in last_order.items():
375
+ conn.execute(
376
+ "UPDATE customers SET last_order_date = ? WHERE id = ?", (ldate, cid)
377
+ )
378
+
379
+ # ── reviews (300) ──
380
+ reviews: List[Tuple] = []
381
+ for i in range(300):
382
+ prod_id = rng.randint(1, 50)
383
+ cust_id = rng.randint(1, 100)
384
+ rating = rng.choices([1, 2, 3, 4, 5], weights=[5, 10, 20, 35, 30])[0]
385
+ rdate = (YEAR_START + timedelta(days=rng.randint(0, 364))).strftime("%Y-%m-%d")
386
+ reviews.append((i + 1, prod_id, cust_id, rating, rdate))
387
+ conn.executemany(
388
+ "INSERT INTO reviews (id, product_id, customer_id, rating, review_date)"
389
+ " VALUES (?,?,?,?,?)",
390
+ reviews,
391
+ )
392
+ conn.commit()
393
+
394
+
395
+ # ── Task definitions ───────────────────────────────────────────────────────────
396
+
397
+ TASKS = [
398
+ # ── EASY ──────────────────────────────────────────────────────────────────
399
+ {
400
+ "task_id": "task_001",
401
+ "difficulty": "easy",
402
+ "description": (
403
+ "Count the total number of customers registered in the state of "
404
+ "California (state = 'CA'). Return a single integer value."
405
+ ),
406
+ "hint": "Use the customers table and filter WHERE state = 'CA'.",
407
+ },
408
+ {
409
+ "task_id": "task_002",
410
+ "difficulty": "easy",
411
+ "description": (
412
+ "Find the name and price of the most expensive product in the "
413
+ "'Electronics' category. Return exactly one row with columns: "
414
+ "name, price."
415
+ ),
416
+ "hint": (
417
+ "JOIN products with categories, filter WHERE categories.name = "
418
+ "'Electronics', ORDER BY price DESC, LIMIT 1."
419
+ ),
420
+ },
421
+ # ── MEDIUM ────────────────────────────────────────────────────────────────
422
+ {
423
+ "task_id": "task_003",
424
+ "difficulty": "medium",
425
+ "description": (
426
+ "List the top 5 customers by total amount spent across all their "
427
+ "orders (any status). Return columns: name, total_spent "
428
+ "(rounded to 2 decimal places). Order by total_spent descending."
429
+ ),
430
+ "hint": (
431
+ "JOIN customers with orders. GROUP BY customer. "
432
+ "SUM(total_amount). ORDER BY total_spent DESC. LIMIT 5."
433
+ ),
434
+ },
435
+ {
436
+ "task_id": "task_004",
437
+ "difficulty": "medium",
438
+ "description": (
439
+ "Which product category has the highest average customer rating? "
440
+ "Return columns: category_name, avg_rating "
441
+ "(rounded to 2 decimal places). Return only the top category."
442
+ ),
443
+ "hint": (
444
+ "JOIN reviews → products → categories. "
445
+ "GROUP BY category. AVG(rating). ORDER BY avg_rating DESC. LIMIT 1."
446
+ ),
447
+ },
448
+ # ── HARD ──────────────────────────────────────────────────────────────────
449
+ {
450
+ "task_id": "task_005",
451
+ "difficulty": "hard",
452
+ "description": (
453
+ "Find all customers who placed at least one order in Q1 2024 "
454
+ "(January-March, i.e. order_date < '2024-04-01') but have NOT "
455
+ "placed any order from April 2024 onwards "
456
+ "(order_date >= '2024-04-01'). "
457
+ "Return columns: name, email. Order alphabetically by name."
458
+ ),
459
+ "hint": (
460
+ "Use subqueries or EXCEPT. One subquery selects customer_ids with "
461
+ "orders before '2024-04-01'; another selects customer_ids with "
462
+ "orders from '2024-04-01' onward. Take the difference."
463
+ ),
464
+ },
465
+ {
466
+ "task_id": "task_006",
467
+ "difficulty": "hard",
468
+ "description": (
469
+ "Calculate monthly revenue (sum of total_amount) for each month "
470
+ "in 2024 using only 'delivered' orders. Then compute the "
471
+ "month-over-month growth rate as a percentage. "
472
+ "Return columns: month (integer 1-12), revenue "
473
+ "(rounded to 2 decimal places), growth_rate (percentage change "
474
+ "vs previous month rounded to 2 decimal places; NULL for the "
475
+ "first month). Order by month ascending. "
476
+ "Only include months that have at least one delivered order."
477
+ ),
478
+ "hint": (
479
+ "Use strftime('%m', order_date) to extract month. "
480
+ "Filter status = 'delivered'. For growth_rate use a self-join or "
481
+ "window function LAG() if SQLite version supports it."
482
+ ),
483
+ },
484
+
485
+ # ── EASY (additional) ─────────────────────────────────────────────────────
486
+ {
487
+ "task_id": "task_007",
488
+ "difficulty": "easy",
489
+ "description": (
490
+ "How many products are currently out of stock (stock = 0)? "
491
+ "Return a single integer value."
492
+ ),
493
+ "hint": "Use the products table. Filter WHERE stock = 0. COUNT(*).",
494
+ },
495
+ {
496
+ "task_id": "task_008",
497
+ "difficulty": "easy",
498
+ "description": (
499
+ "What is the average order value (total_amount) across ALL orders "
500
+ "regardless of status? Return a single value rounded to 2 decimal "
501
+ "places. Column name: avg_order_value."
502
+ ),
503
+ "hint": "Use the orders table. AVG(total_amount). ROUND to 2 decimal places.",
504
+ },
505
+
506
+ # ── MEDIUM (additional) ───────────────────────────────────────────────────
507
+ {
508
+ "task_id": "task_009",
509
+ "difficulty": "medium",
510
+ "description": (
511
+ "List all products that have NEVER received any customer review. "
512
+ "Return columns: name, category_name. "
513
+ "Order by product name alphabetically."
514
+ ),
515
+ "hint": (
516
+ "LEFT JOIN products with reviews ON products.id = reviews.product_id. "
517
+ "Filter WHERE reviews.id IS NULL (no matching review). "
518
+ "JOIN categories to get category_name."
519
+ ),
520
+ },
521
+ {
522
+ "task_id": "task_010",
523
+ "difficulty": "medium",
524
+ "description": (
525
+ "Which US state has the highest number of registered customers? "
526
+ "Return columns: state, customer_count. Return only the top 1 state."
527
+ ),
528
+ "hint": (
529
+ "Use the customers table. GROUP BY state. COUNT(*) AS customer_count. "
530
+ "ORDER BY customer_count DESC. LIMIT 1."
531
+ ),
532
+ },
533
+ ]
534
+
535
+
536
+ # ── Grading helpers ────────────────────────────────────────────────────────────
537
+
538
+ def _rows_to_dicts(rows: List[sqlite3.Row]) -> List[Dict[str, Any]]:
539
+ return [dict(r) for r in rows]
540
+
541
+
542
+ def _lower_keys(row: Dict[str, Any]) -> Dict[str, Any]:
543
+ return {k.lower(): v for k, v in row.items()}
544
+
545
+
546
+ def _find_col(row: Dict[str, Any], *hints: str) -> Optional[Any]:
547
+ """Return the value of the first column whose name contains any hint."""
548
+ for hint in hints:
549
+ for k, v in row.items():
550
+ if hint in k:
551
+ return v
552
+ return None
553
+
554
+
555
+ def _grade_count(result: List, expected: int) -> Tuple[float, bool, str]:
556
+ if not result:
557
+ return 0.1, False, "Empty result; expected a single count row."
558
+ row = _lower_keys(result[0])
559
+ raw = next(iter(row.values()), None)
560
+ try:
561
+ got = int(raw)
562
+ except (TypeError, ValueError):
563
+ return 0.2, False, f"Cannot parse integer from first column: {raw!r}"
564
+
565
+ if got == expected:
566
+ return 1.0, True, f"Correct! Count = {got}."
567
+ diff = abs(got - expected) / max(expected, 1)
568
+ if diff <= 0.02:
569
+ return 0.8, False, f"Very close: got {got}, expected {expected}."
570
+ elif diff <= 0.15:
571
+ return 0.4, False, f"Somewhat close: got {got}, expected {expected}."
572
+ return 0.1, False, f"Incorrect: got {got}, expected {expected}."
573
+
574
+
575
+ def _grade_single_row(
576
+ result: List, expected: Dict[str, Any]
577
+ ) -> Tuple[float, bool, str]:
578
+ if not result:
579
+ return 0.1, False, "Empty result; expected one row."
580
+ row = _lower_keys(result[0])
581
+ hits = 0
582
+ for key, exp_val in expected.items():
583
+ # Flexible column matching
584
+ got_val = row.get(key.lower())
585
+ if got_val is None:
586
+ for k in row:
587
+ if key.lower() in k or k in key.lower():
588
+ got_val = row[k]
589
+ break
590
+ if got_val is None:
591
+ continue
592
+ if isinstance(exp_val, float):
593
+ try:
594
+ if abs(float(got_val) - exp_val) / max(abs(exp_val), 0.01) < 0.01:
595
+ hits += 1
596
+ except (TypeError, ValueError):
597
+ pass
598
+ else:
599
+ if str(got_val).strip().lower() == str(exp_val).strip().lower():
600
+ hits += 1
601
+ score = hits / len(expected)
602
+ return score, score >= 1.0, f"Matched {hits}/{len(expected)} expected fields."
603
+
604
+
605
+ def _grade_top_n(
606
+ result: List,
607
+ expected: List[Dict],
608
+ name_col: str,
609
+ value_col: str,
610
+ ) -> Tuple[float, bool, str]:
611
+ if not result:
612
+ return 0.1, False, "Empty result; expected multiple rows."
613
+ n = len(expected)
614
+ result_low = [_lower_keys(r) for r in result]
615
+
616
+ got_names = []
617
+ for row in result_low:
618
+ v = _find_col(row, name_col, "name", "customer")
619
+ if v is not None:
620
+ got_names.append(str(v))
621
+
622
+ exp_names = [str(e[name_col]) for e in expected]
623
+
624
+ if not got_names:
625
+ return 0.2, False, f"Could not find '{name_col}' column in result."
626
+
627
+ # Exact order
628
+ if got_names[:n] == exp_names:
629
+ return 1.0, True, f"Perfect top-{n} in correct order."
630
+
631
+ overlap = len(set(got_names[:n]) & set(exp_names))
632
+ pos_matches = sum(
633
+ 1 for i, g in enumerate(got_names[:n])
634
+ if i < len(exp_names) and g == exp_names[i]
635
+ )
636
+ score = 0.3 + 0.5 * (overlap / n) + 0.2 * (pos_matches / n)
637
+ return min(score, 0.95), False, f"Partial: {overlap}/{n} names, {pos_matches}/{n} in correct position."
638
+
639
+
640
+ def _grade_set_match(
641
+ result: List,
642
+ expected: List[Dict],
643
+ col: str = "name",
644
+ ) -> Tuple[float, bool, str]:
645
+ result_low = [_lower_keys(r) for r in result]
646
+ got_set = set()
647
+ for row in result_low:
648
+ v = _find_col(row, col, "name", "customer")
649
+ if v is not None:
650
+ got_set.add(str(v).strip())
651
+ exp_set = {str(e[col]).strip() for e in expected}
652
+
653
+ if not exp_set and not got_set:
654
+ return 1.0, True, "Correct: no rows expected and none returned."
655
+ if not exp_set:
656
+ return 0.0, False, f"Expected 0 rows but got {len(got_set)}."
657
+ if not got_set:
658
+ return 0.1, False, "Empty result but expected rows."
659
+
660
+ precision = len(got_set & exp_set) / len(got_set)
661
+ recall = len(got_set & exp_set) / len(exp_set)
662
+ f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0
663
+ return f1, f1 >= 0.95, (
664
+ f"F1={f1:.2f} (precision={precision:.2f}, recall={recall:.2f}). "
665
+ f"Expected {len(exp_set)} rows, got {len(got_set)}."
666
+ )
667
+
668
+
669
+ def _grade_monthly_revenue(
670
+ result: List, expected: List[Dict]
671
+ ) -> Tuple[float, bool, str]:
672
+ if not result or len(result) < 2:
673
+ return 0.1, False, f"Too few rows ({len(result)}); expected ~{len(expected)}."
674
+
675
+ result_low = [_lower_keys(r) for r in result]
676
+ got_months: Dict[int, float] = {}
677
+ for row in result_low:
678
+ m = _find_col(row, "month")
679
+ r = _find_col(row, "revenue", "amount", "total", "sum")
680
+ if m is not None and r is not None:
681
+ try:
682
+ got_months[int(m)] = float(r)
683
+ except (TypeError, ValueError):
684
+ pass
685
+
686
+ if not got_months:
687
+ return 0.2, False, "Could not extract month/revenue columns."
688
+
689
+ exp_months = {e["month"]: e["revenue"] for e in expected}
690
+
691
+ # Revenue accuracy
692
+ rev_scores = []
693
+ for mo, exp_rev in exp_months.items():
694
+ got_rev = got_months.get(mo)
695
+ if got_rev is None:
696
+ rev_scores.append(0.0)
697
+ else:
698
+ diff = abs(got_rev - exp_rev) / max(abs(exp_rev), 1.0)
699
+ rev_scores.append(max(0.0, 1.0 - diff * 5))
700
+
701
+ rev_score = sum(rev_scores) / len(rev_scores)
702
+
703
+ # Bonus: growth_rate column present
704
+ has_growth = any(
705
+ any("growth" in k or "rate" in k for k in _lower_keys(r))
706
+ for r in result
707
+ )
708
+ score = 0.75 * rev_score + (0.25 if has_growth else 0.0)
709
+ done = score >= 0.85
710
+ return round(score, 4), done, (
711
+ f"Revenue accuracy={rev_score:.2f}, growth_rate column present={has_growth}."
712
+ )
713
+
714
+
715
+ # ── Environment ────────────────────────────────────────────────────────────────
716
+
717
+ class SQLAnalyticsEnvironment(Environment):
718
+ """
719
+ OpenEnv environment: SQL Analytics over an e-commerce database.
720
+
721
+ An AI agent receives a natural-language BI question and must write
722
+ correct SQL queries against the database to find the answer.
723
+
724
+ Interaction loop
725
+ ----------------
726
+ 1. ``reset(task_id="task_001")`` → initial SQLObservation with schema + task
727
+ 2. ``step(SQLAction(sql="SELECT ..."))`` → query result + partial reward
728
+ 3. Repeat step 2 until ``observation.done == True``
729
+
730
+ Tasks (difficulty)
731
+ ------------------
732
+ task_001 easy count customers in CA
733
+ task_002 easy most expensive Electronics product
734
+ task_003 medium top 5 customers by spend
735
+ task_004 medium category with highest avg rating
736
+ task_005 hard Q1-only buyers (churn signal)
737
+ task_006 hard monthly revenue + growth rate
738
+ """
739
+
740
+ SUPPORTS_CONCURRENT_SESSIONS = False
741
+
742
+ def __init__(self) -> None:
743
+ self._conn: Optional[sqlite3.Connection] = None
744
+ self._state = SQLState(episode_id=str(uuid4()), step_count=0)
745
+ self._current_task: Optional[Dict] = None
746
+ self._answers: Dict[str, Any] = {}
747
+ self._setup()
748
+
749
+ # ── setup ──────────────────────────────────────────────────────────────
750
+
751
+ def _setup(self) -> None:
752
+ self._conn = sqlite3.connect(":memory:", check_same_thread=False)
753
+ self._conn.row_factory = sqlite3.Row
754
+ self._conn.executescript(_SCHEMA_DDL)
755
+ _seed(self._conn)
756
+ # Build rich schema AFTER seeding so sample rows are available
757
+ self._rich_schema = _build_rich_schema(self._conn)
758
+ self._precompute_answers()
759
+
760
+ def _precompute_answers(self) -> None:
761
+ c = self._conn
762
+
763
+ # task_001
764
+ r = c.execute(
765
+ "SELECT COUNT(*) AS cnt FROM customers WHERE state = 'CA'"
766
+ ).fetchone()
767
+ self._answers["task_001"] = int(r["cnt"])
768
+
769
+ # task_002
770
+ r = c.execute("""
771
+ SELECT p.name, p.price
772
+ FROM products p
773
+ JOIN categories cat ON p.category_id = cat.id
774
+ WHERE cat.name = 'Electronics'
775
+ ORDER BY p.price DESC
776
+ LIMIT 1
777
+ """).fetchone()
778
+ self._answers["task_002"] = {"name": r["name"], "price": r["price"]}
779
+
780
+ # task_003
781
+ rows = c.execute("""
782
+ SELECT cu.name, ROUND(SUM(o.total_amount), 2) AS total_spent
783
+ FROM customers cu
784
+ JOIN orders o ON cu.id = o.customer_id
785
+ GROUP BY cu.id, cu.name
786
+ ORDER BY total_spent DESC
787
+ LIMIT 5
788
+ """).fetchall()
789
+ self._answers["task_003"] = _rows_to_dicts(rows)
790
+
791
+ # task_004
792
+ r = c.execute("""
793
+ SELECT cat.name AS category_name, ROUND(AVG(rv.rating), 2) AS avg_rating
794
+ FROM reviews rv
795
+ JOIN products p ON rv.product_id = p.id
796
+ JOIN categories cat ON p.category_id = cat.id
797
+ GROUP BY cat.id, cat.name
798
+ ORDER BY avg_rating DESC
799
+ LIMIT 1
800
+ """).fetchone()
801
+ self._answers["task_004"] = {
802
+ "category_name": r["category_name"],
803
+ "avg_rating": r["avg_rating"],
804
+ }
805
+
806
+ # task_005
807
+ rows = c.execute("""
808
+ SELECT DISTINCT cu.name, cu.email
809
+ FROM customers cu
810
+ WHERE cu.id IN (
811
+ SELECT customer_id FROM orders
812
+ WHERE order_date >= '2024-01-01' AND order_date < '2024-04-01'
813
+ )
814
+ AND cu.id NOT IN (
815
+ SELECT customer_id FROM orders
816
+ WHERE order_date >= '2024-04-01'
817
+ )
818
+ ORDER BY cu.name
819
+ """).fetchall()
820
+ self._answers["task_005"] = _rows_to_dicts(rows)
821
+
822
+ # task_006
823
+ rows = c.execute("""
824
+ SELECT CAST(strftime('%m', order_date) AS INTEGER) AS month,
825
+ ROUND(SUM(total_amount), 2) AS revenue
826
+ FROM orders
827
+ WHERE status = 'delivered'
828
+ AND order_date BETWEEN '2024-01-01' AND '2024-12-31'
829
+ GROUP BY month
830
+ ORDER BY month
831
+ """).fetchall()
832
+ monthly: List[Dict] = _rows_to_dicts(rows)
833
+ for i, m in enumerate(monthly):
834
+ if i == 0:
835
+ m["growth_rate"] = None
836
+ else:
837
+ prev = monthly[i - 1]["revenue"]
838
+ m["growth_rate"] = (
839
+ round((m["revenue"] - prev) / prev * 100, 2)
840
+ if prev else None
841
+ )
842
+ self._answers["task_006"] = monthly
843
+
844
+ # task_007 — out of stock count
845
+ r = c.execute("SELECT COUNT(*) AS cnt FROM products WHERE stock = 0").fetchone()
846
+ self._answers["task_007"] = int(r["cnt"])
847
+
848
+ # task_008 — average order value
849
+ r = c.execute(
850
+ "SELECT ROUND(AVG(total_amount), 2) AS avg_order_value FROM orders"
851
+ ).fetchone()
852
+ self._answers["task_008"] = {"avg_order_value": r["avg_order_value"]}
853
+
854
+ # task_009 — products never reviewed
855
+ rows = c.execute("""
856
+ SELECT p.name, cat.name AS category_name
857
+ FROM products p
858
+ JOIN categories cat ON p.category_id = cat.id
859
+ LEFT JOIN reviews rv ON p.id = rv.product_id
860
+ WHERE rv.id IS NULL
861
+ ORDER BY p.name
862
+ """).fetchall()
863
+ self._answers["task_009"] = _rows_to_dicts(rows)
864
+
865
+ # task_010 — state with most customers
866
+ r = c.execute("""
867
+ SELECT state, COUNT(*) AS customer_count
868
+ FROM customers
869
+ GROUP BY state
870
+ ORDER BY customer_count DESC
871
+ LIMIT 1
872
+ """).fetchone()
873
+ self._answers["task_010"] = {"state": r["state"], "customer_count": r["customer_count"]}
874
+
875
+ # ── OpenEnv API ────────────────────────────────────────────────────────
876
+
877
+ def reset(
878
+ self,
879
+ seed: Optional[int] = None,
880
+ episode_id: Optional[str] = None,
881
+ task_id: Optional[str] = None,
882
+ **kwargs: Any,
883
+ ) -> SQLObservation:
884
+ """Start a new episode for the given task_id (default: task_001)."""
885
+ task_id = task_id or "task_001"
886
+ task_def = next(
887
+ (t for t in TASKS if t["task_id"] == task_id), TASKS[0]
888
+ )
889
+
890
+ self._current_task = task_def
891
+ self._state = SQLState(
892
+ episode_id=episode_id or str(uuid4()),
893
+ step_count=0,
894
+ task_id=task_def["task_id"],
895
+ task_description=task_def["description"],
896
+ difficulty=task_def["difficulty"],
897
+ attempts=0,
898
+ max_attempts=10,
899
+ solved=False,
900
+ )
901
+
902
+ return SQLObservation(
903
+ done=False,
904
+ reward=0.0,
905
+ result=None,
906
+ error=None,
907
+ db_schema=self._rich_schema, # ← rich schema with samples
908
+ task_description=task_def["description"],
909
+ metadata={
910
+ "task_id": task_def["task_id"],
911
+ "difficulty": task_def["difficulty"],
912
+ "hint": task_def["hint"],
913
+ "max_steps": 10,
914
+ "message": (
915
+ "Environment ready. Submit a SQL query via SQLAction(sql=...) "
916
+ "to answer the task. Tip: solving in fewer steps earns an "
917
+ "efficiency bonus on top of your base reward."
918
+ ),
919
+ },
920
+ )
921
+
922
+ def step(
923
+ self,
924
+ action: SQLAction,
925
+ timeout_s: Optional[float] = None,
926
+ **kwargs: Any,
927
+ ) -> SQLObservation:
928
+ """Execute the agent's SQL query and return a graded observation."""
929
+ if self._current_task is None:
930
+ return SQLObservation(
931
+ done=True,
932
+ reward=0.0,
933
+ error="No task loaded. Call reset(task_id=...) first.",
934
+ metadata={"message": "Call reset() before step()."},
935
+ )
936
+
937
+ self._state.step_count += 1
938
+ self._state.attempts += 1
939
+
940
+ # Execute SQL (read-only, with safety checks + timeout)
941
+ sql = (action.sql or "").strip()
942
+ result: Optional[List] = None
943
+ error: Optional[str] = None
944
+
945
+ # Block write operations
946
+ sql_lower = sql.lower()
947
+ blocked = [kw for kw in _BLOCKED_KEYWORDS if re.search(r'\b' + kw + r'\b', sql_lower)]
948
+ if blocked:
949
+ error = f"Write operations are not allowed: {blocked}. Only SELECT queries are permitted."
950
+ logger.warning("Blocked SQL keyword(s) %s in episode=%s", blocked, self._state.episode_id)
951
+ elif not sql:
952
+ error = "Empty SQL query submitted."
953
+ else:
954
+ # Run query in a thread with a timeout
955
+ _result_box: List = []
956
+ _error_box: List = []
957
+
958
+ def _run_query():
959
+ try:
960
+ cur = self._conn.execute(sql)
961
+ rows = cur.fetchall()
962
+ _result_box.append(_rows_to_dicts(rows))
963
+ except Exception as exc:
964
+ _error_box.append(str(exc))
965
+
966
+ t = threading.Thread(target=_run_query, daemon=True)
967
+ t.start()
968
+ t.join(timeout=_QUERY_TIMEOUT_SECONDS)
969
+
970
+ if t.is_alive():
971
+ error = f"Query timed out after {_QUERY_TIMEOUT_SECONDS}s."
972
+ logger.warning("Query timeout in episode=%s | sql=%s", self._state.episode_id, sql[:80])
973
+ elif _error_box:
974
+ # Convert raw error → educational message
975
+ error = _educational_error(sql, _error_box[0])
976
+ else:
977
+ result = _result_box[0] if _result_box else []
978
+
979
+ logger.info(
980
+ "step | episode=%s | task=%s | attempt=%d | rows=%s | reward_pending | error=%s",
981
+ self._state.episode_id,
982
+ self._state.task_id,
983
+ self._state.attempts,
984
+ len(result) if result is not None else "N/A",
985
+ error,
986
+ )
987
+
988
+ # Grade
989
+ reward, done, message = self._grade(result, error)
990
+
991
+ # ── Efficiency bonus ────────────────────────────────────────────────
992
+ # Reward agents that solve correctly in fewer steps.
993
+ # Solving in 1 step → reward stays 1.0 (no penalty)
994
+ # Solving in 5 steps → base * 0.92
995
+ # Solving in 10 steps → base * 0.82
996
+ # Only applied when the task is actually solved (base reward = 1.0)
997
+ if done and reward >= 0.9:
998
+ efficiency = max(0.82, 1.0 - (self._state.attempts - 1) * 0.02)
999
+ reward = round(reward * efficiency, 4)
1000
+ if self._state.attempts == 1:
1001
+ message += " [+efficiency bonus: solved in 1 step!]"
1002
+ elif efficiency < 1.0:
1003
+ message += f" [efficiency={efficiency:.2f}: solved in {self._state.attempts} steps]"
1004
+
1005
+ if done:
1006
+ self._state.solved = reward >= 0.82 # still counts as solved after bonus
1007
+
1008
+ # Enforce max-attempts cap
1009
+ if self._state.attempts >= self._state.max_attempts and not done:
1010
+ done = True
1011
+ message += f" [Max attempts ({self._state.max_attempts}) reached.]"
1012
+
1013
+ return SQLObservation(
1014
+ done=done,
1015
+ reward=reward,
1016
+ result=result,
1017
+ error=error,
1018
+ db_schema=self._rich_schema,
1019
+ task_description=self._current_task["description"],
1020
+ metadata={
1021
+ "task_id": self._current_task["task_id"],
1022
+ "difficulty": self._current_task["difficulty"],
1023
+ "attempts": self._state.attempts,
1024
+ "max_attempts": self._state.max_attempts,
1025
+ "steps_remaining": max(0, self._state.max_attempts - self._state.attempts),
1026
+ "message": message,
1027
+ },
1028
+ )
1029
+
1030
+ @property
1031
+ def state(self) -> SQLState:
1032
+ return self._state
1033
+
1034
+ # ── Grading dispatch ───────────────────────────────────────────────────
1035
+
1036
+ def _grade(
1037
+ self,
1038
+ result: Optional[List],
1039
+ error: Optional[str],
1040
+ ) -> Tuple[float, bool, str]:
1041
+ task_id = self._current_task["task_id"]
1042
+
1043
+ if error:
1044
+ # Return 0.0 but keep done=False so agent can retry
1045
+ return 0.0, False, error
1046
+ if result is None:
1047
+ return 0.0, False, "No result returned."
1048
+
1049
+ if task_id == "task_001":
1050
+ return _grade_count(result, self._answers["task_001"])
1051
+ elif task_id == "task_002":
1052
+ return _grade_single_row(result, self._answers["task_002"])
1053
+ elif task_id == "task_003":
1054
+ return _grade_top_n(
1055
+ result, self._answers["task_003"],
1056
+ name_col="name", value_col="total_spent",
1057
+ )
1058
+ elif task_id == "task_004":
1059
+ return _grade_single_row(result, self._answers["task_004"])
1060
+ elif task_id == "task_005":
1061
+ return _grade_set_match(
1062
+ result, self._answers["task_005"], col="name"
1063
+ )
1064
+ elif task_id == "task_006":
1065
+ return _grade_monthly_revenue(result, self._answers["task_006"])
1066
+ elif task_id == "task_007":
1067
+ return _grade_count(result, self._answers["task_007"])
1068
+ elif task_id == "task_008":
1069
+ return _grade_single_row(result, self._answers["task_008"])
1070
+ elif task_id == "task_009":
1071
+ return _grade_set_match(
1072
+ result, self._answers["task_009"], col="name"
1073
+ )
1074
+ elif task_id == "task_010":
1075
+ return _grade_single_row(result, self._answers["task_010"])
1076
+
1077
+ return 0.0, False, f"Unknown task_id: {task_id}"
skills/rl-gym-auditor/SKILL.md ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: rl-gym-auditor
3
+ description: >
4
+ Use this skill to audit and score any Reinforcement Learning gym or OpenEnv-style environment.
5
+ Trigger whenever the user asks to audit, review, evaluate, check, score, or improve an RL gym,
6
+ OpenEnv environment, AI training environment, reward function, or RL observation design.
7
+ Also trigger when the user asks questions like "is my RL env good?", "how do I improve my gym?",
8
+ "what's wrong with my reward function?", "is this environment hackathon-ready?", or
9
+ "how does my OpenEnv compare to best practices?". If any Python file contains reset()/step()/state()
10
+ methods or reward-related grading functions, this skill should be used.
11
+ ---
12
+
13
+ # RL Gym Auditor
14
+
15
+ You are an expert RL environment designer. Your job is to audit a Reinforcement Learning gym
16
+ (typically an OpenEnv-style environment) and produce a detailed quality report with scores,
17
+ PASS/WARN/FAIL verdicts, and concrete improvement suggestions.
18
+
19
+ ## Step 1 — Locate the Environment Files
20
+
21
+ Search the project directory for the environment implementation. Look for:
22
+ - Files named `*environment*.py`, `*env*.py`, `gym*.py`
23
+ - Files containing `def reset(`, `def step(`, `def state(`
24
+ - Files containing reward-related words: `reward`, `_grade`, `score`
25
+ - Spec files: `openenv.yaml`, `openenv.yml`
26
+ - Model/type files: `models.py`, `types.py`
27
+
28
+ Read the main environment file fully. Also read any models/types file and the spec file.
29
+
30
+ ## Step 2 — Audit Across 5 Categories
31
+
32
+ Evaluate each category and assign a score from 0-10. Be honest and precise. A WARN means
33
+ "works but could be better", FAIL means "missing or broken". Every verdict MUST use the exact
34
+ label `[PASS]`, `[WARN]`, or `[FAIL]` — this consistency is what makes scores comparable.
35
+
36
+ ---
37
+
38
+ ### Category 1: Reward Signal Quality (weight: 30%)
39
+
40
+ This is the most important category because reward quality directly determines whether RL
41
+ training will converge. A sparse reward (0 until solved, then 1) gives the agent no gradient
42
+ to follow. A dense reward (partial credit at every step) enables learning from near-misses.
43
+
44
+ | Criterion | PASS condition | Score impact |
45
+ |-----------|---------------|-------------|
46
+ | **Reward range** | All rewards are in [0.0, 1.0] | -3 if outside range |
47
+ | **Dense rewards** | Agent gets meaningful signal at every step, not just 0/1 | -4 if binary only |
48
+ | **Partial credit** | Wrong-but-close answers score higher than completely wrong | -3 if no gradient |
49
+ | **Error handling** | Errors give 0.0 but don't end the episode (agent can retry) | -2 if errors terminate |
50
+ | **Efficiency bonus** | Faster solutions score higher than slow ones | -1 if missing |
51
+ | **Grader diversity** | Different task types use different grading logic | -1 if one-size-fits-all |
52
+
53
+ ---
54
+
55
+ ### Category 2: Task Coverage (weight: 25%)
56
+
57
+ | Criterion | PASS condition |
58
+ |-----------|---------------|
59
+ | **Minimum tasks** | At least 3 tasks present |
60
+ | **Difficulty curve** | Tasks span easy, medium, AND hard |
61
+ | **Pattern diversity** | Tasks require different skills (COUNT vs JOIN vs subquery, etc.) |
62
+ | **Deterministic answers** | Every task has a fixed correct answer (no random expected output) |
63
+ | **Clear descriptions** | Each task has a human-readable natural language description |
64
+ | **Spec file complete** | openenv.yaml (or equivalent) lists ALL implemented tasks. FAIL if tasks exist in code but not in spec. |
65
+
66
+ ---
67
+
68
+ ### Category 3: Observation Quality (weight: 20%)
69
+
70
+ The observation is what the agent *sees*. Garbage in = garbage out for RL.
71
+
72
+ | Criterion | PASS condition |
73
+ |-----------|---------------|
74
+ | **Schema in observation** | Agent sees table/field names at reset AND at every step |
75
+ | **Sample data** | Agent sees a few real rows (not just column names) |
76
+ | **Error messages** | Errors are educational ("Table X not found, available: ...") not raw tracebacks |
77
+ | **Progress signal** | `steps_remaining` or `attempts` exposed in metadata |
78
+ | **Task description** | Natural language question is in the observation |
79
+
80
+ ---
81
+
82
+ ### Category 4: Safety & Isolation (weight: 15%)
83
+
84
+ | Criterion | PASS condition |
85
+ |-----------|---------------|
86
+ | **Read-only enforcement** | Write operations are blocked using regex word-boundary matching (`re.search(r'\b' + kw + r'\b', ...)`) not naive `.split()` |
87
+ | **Query timeout** | Long-running queries are killed after N seconds |
88
+ | **Deterministic seeding** | Random seed is fixed (e.g. `random.Random(42)`) |
89
+ | **No global state leaks** | Each `reset()` produces a clean episode |
90
+ | **Input validation** | Empty/null/invalid actions are handled gracefully |
91
+
92
+ ---
93
+
94
+ ### Category 5: API Compliance (weight: 10%)
95
+
96
+ | Criterion | PASS condition |
97
+ |-----------|---------------|
98
+ | **reset() exists** | Returns initial observation with schema + task |
99
+ | **step() exists** | Accepts action, returns observation with reward + done |
100
+ | **state() exists** | Returns current episode state (attempts, task_id, solved) |
101
+ | **Typed models** | Action, Observation, State use typed classes (Pydantic or dataclass) |
102
+ | **/health endpoint** | HTTP server exposes GET /health returning {"status": "ok"} |
103
+ | **Spec file valid** | openenv.yaml present with name, port, and ALL tasks listed |
104
+
105
+ ---
106
+
107
+ ## Step 3 — Calculate Scores
108
+
109
+ For each category, count how many criteria PASS, WARN, or FAIL.
110
+
111
+ Score formula per category:
112
+ - Each criterion is worth `10 / num_criteria` points
113
+ - PASS = full points
114
+ - WARN = half points
115
+ - FAIL = 0 points
116
+
117
+ Overall RL Gym Quality Score = weighted average:
118
+ ```
119
+ overall = (reward * 0.30) + (tasks * 0.25) + (obs * 0.20) + (safety * 0.15) + (api * 0.10)
120
+ ```
121
+
122
+ Grade thresholds:
123
+ - 9.0-10.0 -> **A** — Production-ready, hackathon-winning quality
124
+ - 7.5-8.9 -> **B** — Good, minor improvements recommended
125
+ - 6.0-7.4 -> **C** — Acceptable, several gaps to address
126
+ - 4.0-5.9 -> **D** — Needs significant work
127
+ - Below 4.0 -> **F** — Fundamental issues, major rework needed
128
+
129
+ ---
130
+
131
+ ## Step 4 — Print the Audit Report
132
+
133
+ Use this EXACT format for the output. Always show the weighted score breakdown and always
134
+ include the TOP 3 IMPROVEMENTS section at the end.
135
+
136
+ ```
137
+ ============================================================
138
+ RL GYM AUDIT REPORT
139
+ Environment: <name>
140
+ Audited: <datetime>
141
+ ============================================================
142
+
143
+ CATEGORY 1: REWARD SIGNAL QUALITY [X.X / 10]
144
+ ------------------------------------------------------------
145
+ [PASS] Reward range bounded 0.0-1.0
146
+ [PASS] Dense rewards — partial credit at every step
147
+ [WARN] Efficiency bonus present but floor too high (0.82)
148
+ [FAIL] No grader diversity — all tasks use same grading fn
149
+ ...
150
+ Suggestions:
151
+ - <specific, actionable suggestion with code reference>
152
+
153
+ CATEGORY 2: TASK COVERAGE [X.X / 10]
154
+ ------------------------------------------------------------
155
+ ...
156
+
157
+ CATEGORY 3: OBSERVATION QUALITY [X.X / 10]
158
+ ------------------------------------------------------------
159
+ ...
160
+
161
+ CATEGORY 4: SAFETY & ISOLATION [X.X / 10]
162
+ ------------------------------------------------------------
163
+ ...
164
+
165
+ CATEGORY 5: API COMPLIANCE [X.X / 10]
166
+ ------------------------------------------------------------
167
+ ...
168
+
169
+ ============================================================
170
+ SCORE BREAKDOWN
171
+ ============================================================
172
+ Category 1 — Reward Signal Quality (30%): X.X / 10 -> X.XX
173
+ Category 2 — Task Coverage (25%): X.X / 10 -> X.XX
174
+ Category 3 — Observation Quality (20%): X.X / 10 -> X.XX
175
+ Category 4 — Safety & Isolation (15%): X.X / 10 -> X.XX
176
+ Category 5 — API Compliance (10%): X.X / 10 -> X.XX
177
+
178
+ ============================================================
179
+ OVERALL RL GYM QUALITY SCORE: X.X / 10 Grade: X
180
+ ============================================================
181
+
182
+ TOP 3 IMPROVEMENTS (highest impact first):
183
+ 1. [CATEGORY] Specific actionable improvement
184
+ 2. [CATEGORY] Specific actionable improvement
185
+ 3. [CATEGORY] Specific actionable improvement
186
+ ============================================================
187
+ ```
188
+
189
+ ## Important Notes
190
+
191
+ - Be honest. A score of 6/10 is not bad feedback — it's useful feedback.
192
+ - Read the actual code, don't guess. If something is ambiguous, note it as WARN with "could not verify".
193
+ - Focus on what matters for RL training quality, not code style.
194
+ - Every PASS/WARN/FAIL must have a 1-line explanation after the label.
195
+ - Suggestions must be specific: not "improve reward function" but "In `_grade_count()`, add a partial score of 0.4 when the result is within 15% of expected."
196
+ - The spec file (openenv.yaml) listing ALL tasks is a FAIL, not a WARN, if tasks are missing from the list but implemented in code.
197
+ - Read-only enforcement using `.split()` instead of regex is a WARN because it's bypassable.
test_env.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ test_env.py — Smoke test for SQL Analytics OpenEnv environment
3
+ """
4
+ import sys, os
5
+ sys.stdout.reconfigure(encoding="utf-8") if hasattr(sys.stdout, "reconfigure") else None
6
+ sys.path.insert(0, os.path.dirname(__file__))
7
+
8
+ from models import SQLAction
9
+ from server.sql_analytics_environment import SQLAnalyticsEnvironment, TASKS
10
+
11
+ PASS = "[PASS]"
12
+ FAIL = "[FAIL]"
13
+
14
+ # ── Correct SQL answers for all 10 tasks ──────────────────────────────────────
15
+ CORRECT_SQL = {
16
+ "task_001": "SELECT COUNT(*) FROM customers WHERE state = 'CA'",
17
+ "task_002": """
18
+ SELECT p.name, p.price FROM products p
19
+ JOIN categories c ON p.category_id = c.id
20
+ WHERE c.name = 'Electronics' ORDER BY p.price DESC LIMIT 1
21
+ """,
22
+ "task_003": """
23
+ SELECT c.name, ROUND(SUM(o.total_amount), 2) AS total_spent
24
+ FROM customers c JOIN orders o ON c.id = o.customer_id
25
+ GROUP BY c.id, c.name ORDER BY total_spent DESC LIMIT 5
26
+ """,
27
+ "task_004": """
28
+ SELECT cat.name AS category_name, ROUND(AVG(r.rating), 2) AS avg_rating
29
+ FROM reviews r JOIN products p ON r.product_id = p.id
30
+ JOIN categories cat ON p.category_id = cat.id
31
+ GROUP BY cat.id, cat.name ORDER BY avg_rating DESC LIMIT 1
32
+ """,
33
+ "task_005": """
34
+ SELECT DISTINCT c.name, c.email FROM customers c
35
+ WHERE c.id IN (SELECT customer_id FROM orders WHERE order_date < '2024-04-01')
36
+ AND c.id NOT IN (SELECT customer_id FROM orders WHERE order_date >= '2024-04-01')
37
+ ORDER BY c.name
38
+ """,
39
+ "task_006": """
40
+ SELECT CAST(strftime('%m', order_date) AS INTEGER) AS month,
41
+ ROUND(SUM(total_amount), 2) AS revenue, NULL AS growth_rate
42
+ FROM orders WHERE status = 'delivered'
43
+ AND order_date BETWEEN '2024-01-01' AND '2024-12-31'
44
+ GROUP BY month ORDER BY month
45
+ """,
46
+ "task_007": "SELECT COUNT(*) FROM products WHERE stock = 0",
47
+ "task_008": "SELECT ROUND(AVG(total_amount), 2) AS avg_order_value FROM orders",
48
+ "task_009": """
49
+ SELECT p.name, cat.name AS category_name
50
+ FROM products p JOIN categories cat ON p.category_id = cat.id
51
+ LEFT JOIN reviews rv ON p.id = rv.product_id
52
+ WHERE rv.id IS NULL ORDER BY p.name
53
+ """,
54
+ "task_010": """
55
+ SELECT state, COUNT(*) AS customer_count FROM customers
56
+ GROUP BY state ORDER BY customer_count DESC LIMIT 1
57
+ """,
58
+ }
59
+
60
+ WRONG_SQL = {
61
+ "task_001": "SELECT COUNT(*) FROM customers WHERE state = 'TX'",
62
+ "task_002": "INVALID SQL !!!",
63
+ "task_003": "SELECT name FROM customers LIMIT 5",
64
+ "task_004": "SELECT 'Electronics', 5.0",
65
+ "task_005": "SELECT name, email FROM customers LIMIT 2",
66
+ "task_006": "SELECT 1 AS month, 100.0 AS revenue",
67
+ "task_007": "SELECT COUNT(*) FROM products WHERE stock > 100",
68
+ "task_008": "SELECT ROUND(AVG(total_amount), 2) AS avg FROM orders WHERE status='delivered'",
69
+ "task_009": "SELECT name FROM products LIMIT 3",
70
+ "task_010": "SELECT state FROM customers LIMIT 1",
71
+ }
72
+
73
+
74
+ def separator(title):
75
+ print(f"\n{'='*60}")
76
+ print(f" {title}")
77
+ print('='*60)
78
+
79
+
80
+ def run_test(env, task_id, sql, label):
81
+ env.reset(task_id=task_id)
82
+ obs = env.step(SQLAction(sql=sql))
83
+ icon = PASS if (label == "CORRECT" and obs.reward >= 0.8) or \
84
+ (label == "WRONG" and obs.reward < 0.5) else FAIL
85
+ result_count = len(obs.result) if obs.result else 0
86
+ print(
87
+ f" {icon} [{label:7s}] reward={obs.reward:.4f} "
88
+ f"rows={result_count:3d} done={str(obs.done):5s} "
89
+ f"| {(obs.metadata or {}).get('message','')[:55]}"
90
+ )
91
+ return obs.reward
92
+
93
+
94
+ def main():
95
+ print("\n" + "="*60)
96
+ print(" SQL Analytics OpenEnv — Full Smoke Test (10 tasks)")
97
+ print("="*60)
98
+
99
+ env = SQLAnalyticsEnvironment()
100
+ print(f"\n {PASS} Environment instantiated (SQLite DB seeded)")
101
+
102
+ # ── 1. Rich Schema ────────────────────────────────────────────────────
103
+ separator("1. Rich Schema (sample data included)")
104
+ obs = env.reset(task_id="task_001")
105
+ assert obs.db_schema, "Schema should be populated on reset"
106
+ assert "Sample" in obs.db_schema, "Rich schema should include sample data"
107
+ assert "rows)" in obs.db_schema, "Rich schema should include row counts"
108
+ print(f" {PASS} reset() returns RICH schema with sample data + row counts")
109
+ print(f"\n Schema preview (first 15 lines):\n")
110
+ for line in (obs.db_schema or "").splitlines()[:15]:
111
+ print(f" {line}")
112
+
113
+ # ── 2. All 10 tasks — correct SQL ─────────────────────────────────────
114
+ separator("2. Correct SQL — all 10 tasks (expect reward >= 0.8)")
115
+ correct_scores = []
116
+ for task in TASKS:
117
+ tid = task["task_id"]
118
+ score = run_test(env, tid, CORRECT_SQL[tid], "CORRECT")
119
+ correct_scores.append(score)
120
+
121
+ # ── 3. All 10 tasks — wrong SQL ───────────────────────────────────────
122
+ separator("3. Wrong SQL — all 10 tasks (expect reward < 0.5)")
123
+ wrong_scores = []
124
+ for task in TASKS:
125
+ tid = task["task_id"]
126
+ score = run_test(env, tid, WRONG_SQL[tid], "WRONG")
127
+ wrong_scores.append(score)
128
+
129
+ # ── 4. Efficiency bonus ───────────────────────────────────────────────
130
+ separator("4. Efficiency Bonus (solve in 1 step vs 5 steps)")
131
+ env.reset(task_id="task_001")
132
+ obs1 = env.step(SQLAction(sql=CORRECT_SQL["task_001"])) # solve in 1 step
133
+ print(f" Solved in 1 step → reward={obs1.reward:.4f}")
134
+
135
+ env.reset(task_id="task_001")
136
+ for _ in range(4):
137
+ env.step(SQLAction(sql="SELECT 1")) # 4 wrong attempts
138
+ obs5 = env.step(SQLAction(sql=CORRECT_SQL["task_001"])) # solve on 5th step
139
+ print(f" Solved in 5 steps → reward={obs5.reward:.4f}")
140
+ assert obs1.reward > obs5.reward, "1-step solve should have higher reward than 5-step"
141
+ print(f" {PASS} Efficiency bonus working: {obs1.reward:.4f} > {obs5.reward:.4f}")
142
+
143
+ # ── 5. Educational error messages ─────────────────────────────────────
144
+ separator("5. Educational Error Messages")
145
+ env.reset(task_id="task_001")
146
+
147
+ bad_table = env.step(SQLAction(sql="SELECT * FROM orderz"))
148
+ print(f" Bad table name : {bad_table.error[:65]}")
149
+ assert "Available tables" in (bad_table.error or ""), "Should mention available tables"
150
+ print(f" {PASS} Bad table → educational message with available tables")
151
+
152
+ bad_col = env.step(SQLAction(sql="SELECT nonexistent_col FROM customers"))
153
+ print(f" Bad column name : {bad_col.error[:65]}")
154
+ print(f" {PASS} Bad column → educational error message")
155
+
156
+ syntax_err = env.step(SQLAction(sql="SELECT FROM customers WHERE"))
157
+ print(f" Syntax error : {syntax_err.error[:65]}")
158
+ assert "syntax" in (syntax_err.error or "").lower()
159
+ print(f" {PASS} Syntax error → educational message")
160
+
161
+ # ── 6. New task verification (007–010) ────────────────────────────────
162
+ separator("6. New Tasks (007-010) correct answers")
163
+ for tid in ["task_007", "task_008", "task_009", "task_010"]:
164
+ env.reset(task_id=tid)
165
+ obs = env.step(SQLAction(sql=CORRECT_SQL[tid]))
166
+ icon = PASS if obs.reward >= 0.8 else FAIL
167
+ print(f" {icon} {tid} reward={obs.reward:.4f} done={obs.done}")
168
+
169
+ # ── 7. Steps remaining in metadata ───────────────────────────────────
170
+ separator("7. steps_remaining in metadata")
171
+ env.reset(task_id="task_002")
172
+ for i in range(3):
173
+ obs = env.step(SQLAction(sql="SELECT 1"))
174
+ remaining = (obs.metadata or {}).get("steps_remaining", -1)
175
+ assert remaining == 7, f"Expected 7 steps remaining after 3 steps, got {remaining}"
176
+ print(f" {PASS} steps_remaining={remaining} correctly tracked")
177
+
178
+ # ── 8. State tracking ────────────────────────────────────────────────
179
+ separator("8. State Tracking")
180
+ env.reset(task_id="task_003")
181
+ for i in range(3):
182
+ env.step(SQLAction(sql="SELECT 1"))
183
+ s = env.state
184
+ assert s.attempts == 3
185
+ assert s.step_count == 3
186
+ print(f" {PASS} step_count={s.step_count} attempts={s.attempts} task_id={s.task_id}")
187
+
188
+ # ── 9. Max attempts cap ───────────────────────────────────────────────
189
+ separator("9. Max Attempts Cap (10 steps → done=True)")
190
+ env.reset(task_id="task_002")
191
+ last_obs = None
192
+ for _ in range(11):
193
+ last_obs = env.step(SQLAction(sql="SELECT 1"))
194
+ if last_obs.done:
195
+ break
196
+ assert last_obs.done
197
+ print(f" {PASS} done=True after {env.state.attempts} attempts")
198
+
199
+ # ── Summary ───────────────────────────────────────────────────────────
200
+ separator("Summary")
201
+ avg_correct = sum(correct_scores) / len(correct_scores)
202
+ avg_wrong = sum(wrong_scores) / len(wrong_scores)
203
+ all_pass = avg_correct >= 0.8 and avg_wrong < 0.5
204
+
205
+ print(f" Tasks total : {len(TASKS)}")
206
+ print(f" Avg reward (correct) : {avg_correct:.4f}")
207
+ print(f" Avg reward (wrong) : {avg_wrong:.4f}")
208
+ print()
209
+ icon = PASS if all_pass else FAIL
210
+ print(f" {icon} Overall: {'PASSED' if all_pass else 'FAILED'}")
211
+ print()
212
+
213
+ if not all_pass:
214
+ sys.exit(1)
215
+
216
+
217
+ if __name__ == "__main__":
218
+ main()