Spaces:
Sleeping
Sleeping
Nitesh321 commited on
Commit ·
798461f
0
Parent(s):
feat: SQL Analytics OpenEnv environment — 10 tasks, dense rewards, RL-ready
Browse files- .dockerignore +43 -0
- .gitignore +14 -0
- Dockerfile +48 -0
- README.md +226 -0
- __init__.py +9 -0
- client.py +61 -0
- inference.py +310 -0
- models.py +72 -0
- openenv.yaml +34 -0
- pyproject.toml +59 -0
- server/__init__.py +1 -0
- server/app.py +117 -0
- server/sql_analytics_environment.py +1077 -0
- skills/rl-gym-auditor/SKILL.md +197 -0
- test_env.py +218 -0
.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 |
+

|
| 4 |
+

|
| 5 |
+

|
| 6 |
+
-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()
|