Spaces:
Sleeping
Sleeping
Commit ·
3c1b0c7
1
Parent(s): f812d5b
Complete SQL Query Debugger OpenEnv - 24/24 tests passing, Docker verified
Browse files- .gitignore +37 -0
- Dockerfile +13 -0
- README.md +235 -0
- api/server.py +293 -0
- baseline.py +249 -0
- env/environment.py +291 -0
- env/graders.py +440 -0
- env/reward.py +204 -0
- openenv.yaml +108 -0
- requirements.txt +7 -0
- tests/__init__.py +0 -0
- tests/test_environment.py +139 -0
- tests/test_graders.py +140 -0
.gitignore
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Environment variables — NEVER commit real keys
|
| 2 |
+
.env
|
| 3 |
+
|
| 4 |
+
# Python cache
|
| 5 |
+
__pycache__/
|
| 6 |
+
*.py[cod]
|
| 7 |
+
*.pyo
|
| 8 |
+
*.pyd
|
| 9 |
+
.Python
|
| 10 |
+
|
| 11 |
+
# Virtual environments
|
| 12 |
+
venv/
|
| 13 |
+
env/
|
| 14 |
+
ENV/
|
| 15 |
+
.venv/
|
| 16 |
+
|
| 17 |
+
# Pytest cache
|
| 18 |
+
.pytest_cache/
|
| 19 |
+
*.pytest_cache
|
| 20 |
+
|
| 21 |
+
# VS Code
|
| 22 |
+
.vscode/
|
| 23 |
+
|
| 24 |
+
# Docker
|
| 25 |
+
*.tar
|
| 26 |
+
|
| 27 |
+
# OS files
|
| 28 |
+
.DS_Store
|
| 29 |
+
Thumbs.db
|
| 30 |
+
|
| 31 |
+
# Test files we don't need in prod
|
| 32 |
+
test_*.py
|
| 33 |
+
|
| 34 |
+
# Distribution
|
| 35 |
+
dist/
|
| 36 |
+
build/
|
| 37 |
+
*.egg-info/
|
Dockerfile
CHANGED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
|
| 7 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 8 |
+
|
| 9 |
+
COPY . .
|
| 10 |
+
|
| 11 |
+
EXPOSE 7860
|
| 12 |
+
|
| 13 |
+
CMD ["uvicorn", "api.server:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SQL Query Debugger — OpenEnv Environment
|
| 2 |
+
|
| 3 |
+
> **META × PyTorch × SST OpenEnv Hackathon** | Round 1 | March 28 – April 5, 2025
|
| 4 |
+
|
| 5 |
+
An OpenEnv-compliant reinforcement learning environment where AI agents learn to debug SQL queries across three difficulty levels: syntax errors, logic bugs, and performance issues.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Motivation
|
| 10 |
+
|
| 11 |
+
SQL is the most widely used data language in the world. Every software engineer, data scientist, and analyst writes SQL daily. Yet debugging SQL queries remains a frustrating, time-consuming task — a developer staring at a wrong JOIN or a missing index can lose hours of productive work.
|
| 12 |
+
|
| 13 |
+
Despite this, no OpenEnv environment exists for SQL debugging. Existing RL benchmarks focus on code generation, not debugging. This environment fills that gap by training agents to diagnose and fix real SQL problems that real engineers face every day — from simple syntax errors to complex N+1 performance anti-patterns that silently destroy application performance at scale.
|
| 14 |
+
|
| 15 |
+
## Why This Domain?
|
| 16 |
+
|
| 17 |
+
SQL debugging is uniquely well-suited for RL evaluation:
|
| 18 |
+
|
| 19 |
+
1. **Deterministic grading** — a fixed query either matches expected output or it doesn't. No ambiguity, no LLM-based scoring.
|
| 20 |
+
2. **Natural difficulty curve** — syntax errors (easy) → logic bugs (medium) → performance anti-patterns (hard) map perfectly to agent skill levels.
|
| 21 |
+
3. **Real business value** — companies lose millions in engineering hours and infrastructure costs to slow or incorrect SQL. An agent that debugs SQL has immediate commercial value.
|
| 22 |
+
4. **Gap in ecosystem** — no OpenEnv environment for SQL debugging exists. This is genuinely novel.
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## Environment Overview
|
| 27 |
+
|
| 28 |
+
| Property | Value |
|
| 29 |
+
|---|---|
|
| 30 |
+
| Domain | SQL Query Debugging |
|
| 31 |
+
| Tasks | 15 (5 easy, 5 medium, 5 hard) |
|
| 32 |
+
| Max Steps | 20 per episode |
|
| 33 |
+
| Reward Type | Dense (-1.0 to 1.0) |
|
| 34 |
+
| Grader Type | Deterministic (programmatic) |
|
| 35 |
+
| API Port | 7860 |
|
| 36 |
+
|
| 37 |
+
---
|
| 38 |
+
|
| 39 |
+
## Action Space
|
| 40 |
+
|
| 41 |
+
Agents can take 6 action types:
|
| 42 |
+
|
| 43 |
+
| Action | Description | Reward Signal |
|
| 44 |
+
|---|---|---|
|
| 45 |
+
| `identify_error` | Identify error location and type | +0.15 step reward + partial grader |
|
| 46 |
+
| `propose_fix` | Propose a fix without committing | +0.25 step reward + 40% grader score |
|
| 47 |
+
| `submit_answer` | Submit final fixed query | Full grader score |
|
| 48 |
+
| `request_hint` | Request a progressive hint | -0.05 penalty |
|
| 49 |
+
| `explain_issue` | Explain the issue in detail | +0.10 step reward |
|
| 50 |
+
| `optimize_query` | Submit optimized query (hard tasks) | +0.20 step reward + full grader score |
|
| 51 |
+
|
| 52 |
+
---
|
| 53 |
+
|
| 54 |
+
## Observation Space
|
| 55 |
+
|
| 56 |
+
Every observation contains:
|
| 57 |
+
```json
|
| 58 |
+
{
|
| 59 |
+
"task_id": "easy_001",
|
| 60 |
+
"task_description": "Fix the SQL syntax error: missing comma in SELECT clause",
|
| 61 |
+
"current_context": {
|
| 62 |
+
"buggy_query": "SELECT id name email FROM users WHERE active = 1",
|
| 63 |
+
"error_message": "ERROR: syntax error at or near 'name'",
|
| 64 |
+
"database_schema": {"users": ["id INT", "name VARCHAR", "email VARCHAR"]},
|
| 65 |
+
"error_type_hint": "syntax",
|
| 66 |
+
"steps_remaining": 20
|
| 67 |
+
},
|
| 68 |
+
"step_count": 0,
|
| 69 |
+
"difficulty": "easy",
|
| 70 |
+
"max_steps": 20,
|
| 71 |
+
"hints_used": 0,
|
| 72 |
+
"previous_actions": []
|
| 73 |
+
}
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
**Critical:** Ground truth (fixed query) is never included in the observation.
|
| 77 |
+
|
| 78 |
+
---
|
| 79 |
+
|
| 80 |
+
## Task Descriptions
|
| 81 |
+
|
| 82 |
+
### Easy — Syntax Errors
|
| 83 |
+
| ID | Description |
|
| 84 |
+
|---|---|
|
| 85 |
+
| easy_001 | Missing commas in SELECT clause |
|
| 86 |
+
| easy_002 | Missing WHERE keyword |
|
| 87 |
+
| easy_003 | Unclosed string literal |
|
| 88 |
+
| easy_004 | ORDER instead of ORDER BY |
|
| 89 |
+
| easy_005 | GROUP instead of GROUP BY |
|
| 90 |
+
|
| 91 |
+
### Medium — Logic Bugs
|
| 92 |
+
| ID | Description |
|
| 93 |
+
|---|---|
|
| 94 |
+
| medium_001 | INNER JOIN excludes users with zero orders — should be LEFT JOIN |
|
| 95 |
+
| medium_002 | Wrong JOIN condition causing incorrect product associations |
|
| 96 |
+
| medium_003 | Aggregate function in WHERE instead of HAVING |
|
| 97 |
+
| medium_004 | Correlated subquery correlating on wrong column |
|
| 98 |
+
| medium_005 | COUNT(DISTINCT *) — invalid DISTINCT usage |
|
| 99 |
+
|
| 100 |
+
### Hard — Performance Issues
|
| 101 |
+
| ID | Description |
|
| 102 |
+
|---|---|
|
| 103 |
+
| hard_001 | N+1 correlated subqueries in SELECT — O(n) DB hits |
|
| 104 |
+
| hard_002 | Function on indexed column prevents index usage |
|
| 105 |
+
| hard_003 | Implicit cartesian product — missing JOIN condition |
|
| 106 |
+
| hard_004 | SELECT * across 3-table JOIN causing over-fetching |
|
| 107 |
+
| hard_005 | Window function in WHERE clause + missing PARTITION BY |
|
| 108 |
+
|
| 109 |
+
---
|
| 110 |
+
|
| 111 |
+
## Reward Design
|
| 112 |
+
|
| 113 |
+
Reward is **dense** — the agent receives signal at every step, not just at the end.
|
| 114 |
+
```
|
| 115 |
+
Step 1: identify_error correctly → +0.15 (step) + 0.03 (partial grader)
|
| 116 |
+
Step 2: propose_fix with good query → +0.25 (step) + 0.36 (40% grader)
|
| 117 |
+
Step 3: submit_answer perfectly → +0.90 (full grader) + 0.10 (efficiency bonus)
|
| 118 |
+
|
| 119 |
+
Hint requested → -0.05 (penalty)
|
| 120 |
+
Same action 3x in a row → -0.05 per repeat (loop penalty)
|
| 121 |
+
Null / invalid action → -0.10 (penalty)
|
| 122 |
+
Max steps reached → -0.10 (penalty)
|
| 123 |
+
```
|
| 124 |
+
|
| 125 |
+
---
|
| 126 |
+
|
| 127 |
+
## API Endpoints
|
| 128 |
+
|
| 129 |
+
| Endpoint | Method | Description |
|
| 130 |
+
|---|---|---|
|
| 131 |
+
| `/health` | GET | Liveness check — always 200 |
|
| 132 |
+
| `/reset` | POST | Start new episode → Observation |
|
| 133 |
+
| `/step` | POST | Submit action → (obs, reward, done, info) |
|
| 134 |
+
| `/state` | GET | Current episode state |
|
| 135 |
+
| `/tasks` | GET | All 15 tasks + action schema |
|
| 136 |
+
| `/grader` | POST | Grade an episode → float score |
|
| 137 |
+
| `/baseline` | POST | Run baseline agent → scores JSON |
|
| 138 |
+
|
| 139 |
+
---
|
| 140 |
+
|
| 141 |
+
## Setup & Installation
|
| 142 |
+
|
| 143 |
+
### Requirements
|
| 144 |
+
- Python 3.11+
|
| 145 |
+
- Docker Desktop
|
| 146 |
+
|
| 147 |
+
### Local Setup
|
| 148 |
+
```bash
|
| 149 |
+
# Clone the repository
|
| 150 |
+
git clone https://github.com/YOUR_USERNAME/sql-query-debugger
|
| 151 |
+
cd sql-query-debugger
|
| 152 |
+
|
| 153 |
+
# Install dependencies
|
| 154 |
+
pip install -r requirements.txt
|
| 155 |
+
|
| 156 |
+
# Set environment variable
|
| 157 |
+
cp .env.example .env
|
| 158 |
+
# Edit .env and add your OPENAI_API_KEY
|
| 159 |
+
|
| 160 |
+
# Run the server
|
| 161 |
+
uvicorn api.server:app --host 0.0.0.0 --port 7860 --reload
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
### Docker Setup
|
| 165 |
+
```bash
|
| 166 |
+
# Build
|
| 167 |
+
docker build -t sql-query-debugger .
|
| 168 |
+
|
| 169 |
+
# Run
|
| 170 |
+
docker run -p 7860:7860 -e OPENAI_API_KEY=your-key sql-query-debugger
|
| 171 |
+
```
|
| 172 |
+
|
| 173 |
+
### Verify
|
| 174 |
+
```bash
|
| 175 |
+
curl http://localhost:7860/health
|
| 176 |
+
# {"status":"ok","version":"1.0.0"}
|
| 177 |
+
|
| 178 |
+
curl -X POST http://localhost:7860/reset -H "Content-Type: application/json" -d '{}'
|
| 179 |
+
# Returns initial Observation
|
| 180 |
+
|
| 181 |
+
curl http://localhost:7860/tasks
|
| 182 |
+
# Returns all 15 tasks with action schema
|
| 183 |
+
```
|
| 184 |
+
|
| 185 |
+
---
|
| 186 |
+
|
| 187 |
+
## Baseline Scores
|
| 188 |
+
|
| 189 |
+
The rule-based baseline agent scores:
|
| 190 |
+
|
| 191 |
+
| Difficulty | Task | Score | Steps |
|
| 192 |
+
|---|---|---|---|
|
| 193 |
+
| Easy | easy_001 | 0.80 | 2 |
|
| 194 |
+
| Medium | medium_001 | 0.98 | 2 |
|
| 195 |
+
| Hard | hard_001 | 0.80 | 2 |
|
| 196 |
+
| **Average** | | **0.86** | **2** |
|
| 197 |
+
|
| 198 |
+
Baseline uses heuristic rules — no LLM calls. A trained RL agent is expected to significantly outperform this baseline on hard tasks.
|
| 199 |
+
|
| 200 |
+
---
|
| 201 |
+
|
| 202 |
+
## Project Structure
|
| 203 |
+
```
|
| 204 |
+
sql-query-debugger/
|
| 205 |
+
├── openenv.yaml # OpenEnv metadata
|
| 206 |
+
├── Dockerfile # Container definition
|
| 207 |
+
├── requirements.txt # Pinned dependencies
|
| 208 |
+
├── README.md # This file
|
| 209 |
+
├── baseline.py # Baseline inference script
|
| 210 |
+
├── .env.example # Environment variable template
|
| 211 |
+
├── env/
|
| 212 |
+
│ ├── environment.py # Core: step() reset() state()
|
| 213 |
+
│ ├── models.py # Pydantic models
|
| 214 |
+
│ ├── tasks.py # Task definitions + manager
|
| 215 |
+
│ ├── graders.py # Deterministic graders
|
| 216 |
+
│ └── reward.py # Dense reward shaping
|
| 217 |
+
├── api/
|
| 218 |
+
│ └── server.py # FastAPI — all 7 endpoints
|
| 219 |
+
├── dataset/
|
| 220 |
+
│ ├── easy_cases.json # 5 syntax error tasks
|
| 221 |
+
│ ├── medium_cases.json # 5 logic bug tasks
|
| 222 |
+
│ └── hard_cases.json # 5 performance tasks
|
| 223 |
+
└── tests/
|
| 224 |
+
├── test_environment.py
|
| 225 |
+
└── test_graders.py
|
| 226 |
+
```
|
| 227 |
+
|
| 228 |
+
---
|
| 229 |
+
|
| 230 |
+
## Built For
|
| 231 |
+
|
| 232 |
+
**META × PyTorch × SST OpenEnv Hackathon**
|
| 233 |
+
Round 1: March 28 – April 5, 2025 | $30,000 Prize Pool
|
| 234 |
+
|
| 235 |
+
*Build something you would be proud to show to a senior engineer at Meta.*
|
api/server.py
CHANGED
|
@@ -0,0 +1,293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import time
|
| 3 |
+
import asyncio
|
| 4 |
+
from typing import Optional
|
| 5 |
+
from contextlib import asynccontextmanager
|
| 6 |
+
|
| 7 |
+
from fastapi import FastAPI, HTTPException, Request
|
| 8 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 9 |
+
from fastapi.responses import JSONResponse
|
| 10 |
+
from pydantic import ValidationError
|
| 11 |
+
|
| 12 |
+
from env.environment import environment
|
| 13 |
+
from env.models import (
|
| 14 |
+
Action, Observation, EpisodeState,
|
| 15 |
+
DifficultyLevel, ActionType,
|
| 16 |
+
StepResponse, ResetResponse, TaskListResponse,
|
| 17 |
+
BaselineResponse, BaselineResult,
|
| 18 |
+
GraderRequest, GraderResponse,
|
| 19 |
+
HealthResponse, TaskInfo
|
| 20 |
+
)
|
| 21 |
+
from env.tasks import task_manager, ACTION_SCHEMA
|
| 22 |
+
from env.graders import grade
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ─────────────────────────────────────────────
|
| 26 |
+
# STARTUP / SHUTDOWN
|
| 27 |
+
# ─────────────────────────────────────────────
|
| 28 |
+
|
| 29 |
+
_startup_time = time.time()
|
| 30 |
+
|
| 31 |
+
@asynccontextmanager
|
| 32 |
+
async def lifespan(app: FastAPI):
|
| 33 |
+
# Warm up — pre-load datasets and reset environment
|
| 34 |
+
environment.reset(difficulty="easy")
|
| 35 |
+
yield
|
| 36 |
+
# Shutdown — nothing to clean up
|
| 37 |
+
|
| 38 |
+
# ─────────────────────────────────────────────
|
| 39 |
+
# APP DEFINITION
|
| 40 |
+
# ─────────────────────────────────────────────
|
| 41 |
+
|
| 42 |
+
app = FastAPI(
|
| 43 |
+
title = "SQL Query Debugger — OpenEnv Environment",
|
| 44 |
+
description = (
|
| 45 |
+
"An OpenEnv-compliant reinforcement learning environment where AI agents "
|
| 46 |
+
"learn to debug SQL queries across syntax errors, logic bugs, and performance issues. "
|
| 47 |
+
"Built for the META × PyTorch × SST OpenEnv Hackathon."
|
| 48 |
+
),
|
| 49 |
+
version = "1.0.0",
|
| 50 |
+
lifespan = lifespan,
|
| 51 |
+
docs_url = "/docs",
|
| 52 |
+
redoc_url = "/redoc",
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
app.add_middleware(
|
| 56 |
+
CORSMiddleware,
|
| 57 |
+
allow_origins = ["*"],
|
| 58 |
+
allow_credentials = True,
|
| 59 |
+
allow_methods = ["*"],
|
| 60 |
+
allow_headers = ["*"],
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ─────────────────────────────────────────────
|
| 65 |
+
# GLOBAL EXCEPTION HANDLER
|
| 66 |
+
# ─────────────────────────────────────────────
|
| 67 |
+
|
| 68 |
+
@app.exception_handler(Exception)
|
| 69 |
+
async def global_exception_handler(request: Request, exc: Exception):
|
| 70 |
+
return JSONResponse(
|
| 71 |
+
status_code = 500,
|
| 72 |
+
content = {"error": str(exc), "type": type(exc).__name__}
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# ─────────────────────────────────────────────
|
| 77 |
+
# 1. /health — GET
|
| 78 |
+
# Must always return 200 even if env not initialized
|
| 79 |
+
# ─────────────────────────────────────────────
|
| 80 |
+
|
| 81 |
+
@app.get("/health", response_model=HealthResponse, tags=["System"])
|
| 82 |
+
async def health():
|
| 83 |
+
"""
|
| 84 |
+
Liveness check. Always returns 200.
|
| 85 |
+
Used by HF Space health monitoring.
|
| 86 |
+
"""
|
| 87 |
+
return HealthResponse(
|
| 88 |
+
status = "ok",
|
| 89 |
+
version = "1.0.0",
|
| 90 |
+
uptime = round(time.time() - _startup_time, 2)
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# ─────────────────────────────────────────────
|
| 95 |
+
# 2. /reset — POST
|
| 96 |
+
# Starts new episode, returns Observation
|
| 97 |
+
# ─────────────────────────────────────────────
|
| 98 |
+
|
| 99 |
+
class ResetRequest(Action.__class__):
|
| 100 |
+
pass
|
| 101 |
+
|
| 102 |
+
from pydantic import BaseModel
|
| 103 |
+
|
| 104 |
+
class ResetBody(BaseModel):
|
| 105 |
+
difficulty: Optional[str] = None
|
| 106 |
+
task_id: Optional[str] = None
|
| 107 |
+
|
| 108 |
+
@app.post("/reset", response_model=Observation, tags=["Environment"])
|
| 109 |
+
async def reset(body: ResetBody = ResetBody()):
|
| 110 |
+
"""
|
| 111 |
+
Starts a fresh episode.
|
| 112 |
+
Returns the initial Observation the agent sees.
|
| 113 |
+
|
| 114 |
+
Edge case: always returns valid Observation even if dataset issues occur.
|
| 115 |
+
"""
|
| 116 |
+
try:
|
| 117 |
+
obs = environment.reset(
|
| 118 |
+
difficulty = body.difficulty,
|
| 119 |
+
task_id = body.task_id
|
| 120 |
+
)
|
| 121 |
+
return obs
|
| 122 |
+
except ValueError as e:
|
| 123 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 124 |
+
except Exception as e:
|
| 125 |
+
raise HTTPException(status_code=500, detail=f"Reset failed: {str(e)}")
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# ─────────────────────────────────────────────
|
| 129 |
+
# 3. /step — POST
|
| 130 |
+
# Accepts Action, returns StepResponse
|
| 131 |
+
# ─────────────────────────────────────────────
|
| 132 |
+
|
| 133 |
+
@app.post("/step", response_model=StepResponse, tags=["Environment"])
|
| 134 |
+
async def step(action: Action):
|
| 135 |
+
"""
|
| 136 |
+
Submits an action to the environment.
|
| 137 |
+
Returns (observation, reward, done, info).
|
| 138 |
+
|
| 139 |
+
Edge cases:
|
| 140 |
+
- Invalid/malformed action → reward=-0.1, done=False
|
| 141 |
+
- Episode already done → returns terminal state
|
| 142 |
+
- Null payload → graceful penalty
|
| 143 |
+
"""
|
| 144 |
+
try:
|
| 145 |
+
response = environment.step(action)
|
| 146 |
+
return response
|
| 147 |
+
except ValidationError as e:
|
| 148 |
+
# Malformed action — return penalty reward, never crash
|
| 149 |
+
obs = environment.state()
|
| 150 |
+
return StepResponse(
|
| 151 |
+
observation = environment._build_observation(),
|
| 152 |
+
reward = __import__("env.models", fromlist=["Reward"]).Reward(
|
| 153 |
+
score = -0.1,
|
| 154 |
+
breakdown = {"validation_error": -0.1},
|
| 155 |
+
feedback = f"Malformed action: {str(e)}"
|
| 156 |
+
),
|
| 157 |
+
done = False,
|
| 158 |
+
info = {"error": "validation_error", "detail": str(e)}
|
| 159 |
+
)
|
| 160 |
+
except Exception as e:
|
| 161 |
+
raise HTTPException(status_code=500, detail=f"Step failed: {str(e)}")
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# ─────────────────────────────────────────────
|
| 165 |
+
# 4. /state — GET
|
| 166 |
+
# Returns current environment state
|
| 167 |
+
# ─────────────────────────────────────────────
|
| 168 |
+
|
| 169 |
+
@app.get("/state", response_model=EpisodeState, tags=["Environment"])
|
| 170 |
+
async def state():
|
| 171 |
+
"""
|
| 172 |
+
Returns full current environment state.
|
| 173 |
+
Works before reset() is called — returns default empty state.
|
| 174 |
+
Must always be JSON-serializable.
|
| 175 |
+
"""
|
| 176 |
+
return environment.state()
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
# ─────────────────────────────────────────────
|
| 180 |
+
# 5. /tasks — GET
|
| 181 |
+
# Lists all tasks + action schema
|
| 182 |
+
# ─────────────────────────────────────────────
|
| 183 |
+
|
| 184 |
+
@app.get("/tasks", response_model=TaskListResponse, tags=["Tasks"])
|
| 185 |
+
async def tasks():
|
| 186 |
+
"""
|
| 187 |
+
Lists all 15 tasks with full action schema definitions.
|
| 188 |
+
Validator checks for action field definitions, not just task names.
|
| 189 |
+
"""
|
| 190 |
+
all_tasks = task_manager.list_all_tasks()
|
| 191 |
+
return TaskListResponse(
|
| 192 |
+
tasks = all_tasks,
|
| 193 |
+
total = len(all_tasks),
|
| 194 |
+
action_types = [a.value for a in ActionType]
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
# ─────────────────────────────────────────────
|
| 199 |
+
# 6. /grader — POST
|
| 200 |
+
# Grades a completed episode
|
| 201 |
+
# ─────────────────────────────────────────────
|
| 202 |
+
|
| 203 |
+
@app.post("/grader", response_model=GraderResponse, tags=["Grading"])
|
| 204 |
+
async def grader(request: GraderRequest):
|
| 205 |
+
"""
|
| 206 |
+
Grades a completed episode.
|
| 207 |
+
Returns float score between 0.0 and 1.0.
|
| 208 |
+
|
| 209 |
+
Edge cases:
|
| 210 |
+
- Null/empty episode → returns 0.0, never crashes
|
| 211 |
+
- Unknown task_id → returns 0.0 with explanation
|
| 212 |
+
"""
|
| 213 |
+
try:
|
| 214 |
+
# Edge case: null action in request
|
| 215 |
+
if request.action is None:
|
| 216 |
+
return GraderResponse(
|
| 217 |
+
score = 0.0,
|
| 218 |
+
feedback = "No action provided for grading.",
|
| 219 |
+
breakdown = {"error": "null_action"}
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
score, breakdown, feedback = grade(request.action, request.task_id)
|
| 223 |
+
return GraderResponse(
|
| 224 |
+
score = score,
|
| 225 |
+
feedback = feedback,
|
| 226 |
+
breakdown = breakdown
|
| 227 |
+
)
|
| 228 |
+
except Exception as e:
|
| 229 |
+
# Never crash — return 0.0
|
| 230 |
+
return GraderResponse(
|
| 231 |
+
score = 0.0,
|
| 232 |
+
feedback = f"Grader error: {str(e)}",
|
| 233 |
+
breakdown = {"error": str(e)}
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
# ─────────────────────────────────────────────
|
| 238 |
+
# 7. /baseline — POST
|
| 239 |
+
# Runs baseline inference, returns scores
|
| 240 |
+
# Must complete within 60 seconds
|
| 241 |
+
# ─────────────────────────────────────────────
|
| 242 |
+
|
| 243 |
+
@app.post("/baseline", response_model=BaselineResponse, tags=["Baseline"])
|
| 244 |
+
async def baseline():
|
| 245 |
+
"""
|
| 246 |
+
Runs the baseline agent against all 3 difficulty levels.
|
| 247 |
+
Returns scores JSON. Must complete within 60 seconds.
|
| 248 |
+
|
| 249 |
+
Edge case: OPENAI_API_KEY not set → returns error scores without crashing.
|
| 250 |
+
"""
|
| 251 |
+
try:
|
| 252 |
+
# Import here to avoid circular imports
|
| 253 |
+
import baseline as baseline_module
|
| 254 |
+
results = await asyncio.wait_for(
|
| 255 |
+
asyncio.to_thread(baseline_module.run_baseline),
|
| 256 |
+
timeout=55.0 # 5s buffer before 60s limit
|
| 257 |
+
)
|
| 258 |
+
return results
|
| 259 |
+
except asyncio.TimeoutError:
|
| 260 |
+
# Return partial results on timeout
|
| 261 |
+
return BaselineResponse(
|
| 262 |
+
results=[
|
| 263 |
+
BaselineResult(task_id="timeout", difficulty=DifficultyLevel.EASY,
|
| 264 |
+
score=0.0, steps=0, feedback="Baseline timed out after 55 seconds.")
|
| 265 |
+
],
|
| 266 |
+
average_score=0.0
|
| 267 |
+
)
|
| 268 |
+
except Exception as e:
|
| 269 |
+
return BaselineResponse(
|
| 270 |
+
results=[
|
| 271 |
+
BaselineResult(task_id="error", difficulty=DifficultyLevel.EASY,
|
| 272 |
+
score=0.0, steps=0, feedback=f"Baseline error: {str(e)}")
|
| 273 |
+
],
|
| 274 |
+
average_score=0.0
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
# ─────────────────────────────────────────────
|
| 279 |
+
# ROOT — redirect to docs
|
| 280 |
+
# ─────────────────────────────────────────────
|
| 281 |
+
|
| 282 |
+
@app.get("/", tags=["System"])
|
| 283 |
+
async def root():
|
| 284 |
+
return {
|
| 285 |
+
"name": "SQL Query Debugger — OpenEnv Environment",
|
| 286 |
+
"version": "1.0.0",
|
| 287 |
+
"docs": "/docs",
|
| 288 |
+
"health": "/health",
|
| 289 |
+
"endpoints": ["/reset", "/step", "/state", "/tasks", "/grader", "/baseline", "/health"],
|
| 290 |
+
"hackathon": "META × PyTorch × SST OpenEnv Hackathon",
|
| 291 |
+
"domain": "SQL Query Debugging",
|
| 292 |
+
"tasks_count": 15,
|
| 293 |
+
}
|
baseline.py
CHANGED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import time
|
| 3 |
+
from env.environment import SQLDebuggerEnvironment
|
| 4 |
+
from env.models import (
|
| 5 |
+
Action, ActionType, DifficultyLevel,
|
| 6 |
+
BaselineResponse, BaselineResult
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
# BASELINE AGENT
|
| 11 |
+
# Uses rule-based heuristics — no GPT-4
|
| 12 |
+
# Must complete within 60 seconds
|
| 13 |
+
# OPENAI_API_KEY must come from environment
|
| 14 |
+
|
| 15 |
+
def _check_api_key():
|
| 16 |
+
"""Edge case: OPENAI_API_KEY not set → raise clear error."""
|
| 17 |
+
key = os.environ.get("OPENAI_API_KEY")
|
| 18 |
+
if not key:
|
| 19 |
+
raise ValueError(
|
| 20 |
+
"OPENAI_API_KEY environment variable is not set. "
|
| 21 |
+
"Please set it before running baseline: "
|
| 22 |
+
"set OPENAI_API_KEY=your-key-here"
|
| 23 |
+
)
|
| 24 |
+
return key
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _rule_based_agent(env: SQLDebuggerEnvironment, task: dict) -> tuple[float, int, str]:
|
| 28 |
+
"""
|
| 29 |
+
Rule-based baseline agent that analyzes the buggy query
|
| 30 |
+
and attempts a fix using heuristics.
|
| 31 |
+
Fast — no API calls needed for baseline scoring.
|
| 32 |
+
"""
|
| 33 |
+
context = task.get("current_context", {})
|
| 34 |
+
buggy_query = context.get("buggy_query", "")
|
| 35 |
+
error_msg = context.get("error_message", "")
|
| 36 |
+
error_type = context.get("error_type_hint", "syntax")
|
| 37 |
+
category = context.get("category", "syntax")
|
| 38 |
+
|
| 39 |
+
steps_taken = 0
|
| 40 |
+
total_reward = 0.0
|
| 41 |
+
|
| 42 |
+
# ── Step 1: Identify the error ────────────────────────────────
|
| 43 |
+
identify_payload = {
|
| 44 |
+
"error_location": _guess_error_location(buggy_query, error_msg, category),
|
| 45 |
+
"error_type": error_type,
|
| 46 |
+
"explanation": f"Detected {category} issue in query: {error_msg[:100]}"
|
| 47 |
+
}
|
| 48 |
+
action1 = Action(
|
| 49 |
+
action_type=ActionType.IDENTIFY_ERROR,
|
| 50 |
+
payload=identify_payload
|
| 51 |
+
)
|
| 52 |
+
resp1 = env.step(action1)
|
| 53 |
+
total_reward += resp1.reward.score
|
| 54 |
+
steps_taken += 1
|
| 55 |
+
|
| 56 |
+
if resp1.done:
|
| 57 |
+
return total_reward, steps_taken, resp1.reward.feedback
|
| 58 |
+
|
| 59 |
+
# ── Step 2: Submit answer based on heuristic fix ──────────────
|
| 60 |
+
fixed_query = _apply_heuristic_fix(buggy_query, category, error_msg)
|
| 61 |
+
explanation = _generate_explanation(buggy_query, fixed_query, category)
|
| 62 |
+
|
| 63 |
+
if category == "performance":
|
| 64 |
+
action2 = Action(
|
| 65 |
+
action_type=ActionType.OPTIMIZE_QUERY,
|
| 66 |
+
payload={
|
| 67 |
+
"optimized_query": fixed_query,
|
| 68 |
+
"optimization_type": f"Fix {category} issue: {error_type}",
|
| 69 |
+
"explanation": explanation,
|
| 70 |
+
"root_cause": f"Performance issue detected: {error_msg[:100]}",
|
| 71 |
+
"expected_improvement":"Significant reduction in query execution time",
|
| 72 |
+
"confidence": 0.6
|
| 73 |
+
}
|
| 74 |
+
)
|
| 75 |
+
else:
|
| 76 |
+
action2 = Action(
|
| 77 |
+
action_type=ActionType.SUBMIT_ANSWER,
|
| 78 |
+
payload={
|
| 79 |
+
"fixed_query": fixed_query,
|
| 80 |
+
"explanation": explanation,
|
| 81 |
+
"error_type": error_type,
|
| 82 |
+
"error_location": identify_payload["error_location"],
|
| 83 |
+
"confidence": 0.6
|
| 84 |
+
}
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
resp2 = env.step(action2)
|
| 88 |
+
total_reward += resp2.reward.score
|
| 89 |
+
steps_taken += 1
|
| 90 |
+
|
| 91 |
+
return total_reward, steps_taken, resp2.reward.feedback
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _guess_error_location(query: str, error_msg: str, category: str) -> str:
|
| 95 |
+
"""Heuristic: guess where the error is based on keywords."""
|
| 96 |
+
q = query.upper()
|
| 97 |
+
e = error_msg.upper()
|
| 98 |
+
|
| 99 |
+
if "SELECT" in e or "COLUMN" in e:
|
| 100 |
+
return "SELECT clause"
|
| 101 |
+
if "WHERE" in e or "FILTER" in e:
|
| 102 |
+
return "WHERE clause"
|
| 103 |
+
if "JOIN" in e or "ON" in e:
|
| 104 |
+
return "JOIN condition"
|
| 105 |
+
if "GROUP" in e or "HAVING" in e:
|
| 106 |
+
return "GROUP BY / HAVING clause"
|
| 107 |
+
if "ORDER" in e:
|
| 108 |
+
return "ORDER BY clause"
|
| 109 |
+
if category == "performance":
|
| 110 |
+
return "Query structure — performance bottleneck"
|
| 111 |
+
return "Unknown location"
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _apply_heuristic_fix(query: str, category: str, error_msg: str) -> str:
|
| 115 |
+
"""
|
| 116 |
+
Apply simple heuristic fixes based on category.
|
| 117 |
+
Not perfect — baseline is meant to score low-medium,
|
| 118 |
+
showing the environment has room for agent improvement.
|
| 119 |
+
"""
|
| 120 |
+
q = query.strip()
|
| 121 |
+
|
| 122 |
+
if category == "syntax":
|
| 123 |
+
# Fix missing commas in SELECT
|
| 124 |
+
if "syntax error" in error_msg.lower() and "name" in error_msg.lower():
|
| 125 |
+
import re
|
| 126 |
+
q = re.sub(r"SELECT\s+(\w+)\s+(\w+)", r"SELECT \1, \2", q, flags=re.IGNORECASE)
|
| 127 |
+
|
| 128 |
+
# Fix missing WHERE
|
| 129 |
+
if "WHERE" not in q.upper() and "=" in q:
|
| 130 |
+
q = q.replace(" id =", " WHERE id =")
|
| 131 |
+
q = q.replace(" name =", " WHERE name =")
|
| 132 |
+
|
| 133 |
+
# Fix unclosed string
|
| 134 |
+
if q.count("'") % 2 != 0:
|
| 135 |
+
q = q + "'"
|
| 136 |
+
|
| 137 |
+
# Fix ORDER → ORDER BY
|
| 138 |
+
import re
|
| 139 |
+
q = re.sub(r"\bORDER\s+(?!BY)(\w)", r"ORDER BY \1", q, flags=re.IGNORECASE)
|
| 140 |
+
|
| 141 |
+
# Fix GROUP → GROUP BY
|
| 142 |
+
q = re.sub(r"\bGROUP\s+(?!BY)(\w)", r"GROUP BY \1", q, flags=re.IGNORECASE)
|
| 143 |
+
|
| 144 |
+
elif category == "logic":
|
| 145 |
+
# Fix INNER JOIN → LEFT JOIN for inclusion
|
| 146 |
+
if "INNER JOIN" in q.upper():
|
| 147 |
+
q = q.replace("INNER JOIN", "LEFT JOIN").replace("inner join", "LEFT JOIN")
|
| 148 |
+
|
| 149 |
+
# Fix WHERE aggregate → HAVING
|
| 150 |
+
import re
|
| 151 |
+
having_pattern = re.compile(
|
| 152 |
+
r"WHERE\s+(AVG|SUM|COUNT|MAX|MIN)\s*\(", re.IGNORECASE
|
| 153 |
+
)
|
| 154 |
+
if having_pattern.search(q):
|
| 155 |
+
# Move aggregate condition to HAVING
|
| 156 |
+
q = having_pattern.sub("HAVING \\1(", q)
|
| 157 |
+
|
| 158 |
+
elif category == "performance":
|
| 159 |
+
# For performance issues, suggest JOIN-based rewrite
|
| 160 |
+
if "SELECT *" in q.upper():
|
| 161 |
+
q = q.replace("SELECT *", "SELECT id, name, status, created_at")
|
| 162 |
+
|
| 163 |
+
return q
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _generate_explanation(buggy: str, fixed: str, category: str) -> str:
|
| 167 |
+
"""Generate a human-readable explanation of the fix."""
|
| 168 |
+
if buggy.strip() == fixed.strip():
|
| 169 |
+
return f"Analyzed the {category} issue. The query may require deeper inspection."
|
| 170 |
+
|
| 171 |
+
explanations = {
|
| 172 |
+
"syntax": "Fixed syntax error in the SQL query by correcting the query structure.",
|
| 173 |
+
"logic": "Fixed logic error by correcting the JOIN type and query conditions.",
|
| 174 |
+
"performance": "Optimized query performance by restructuring to avoid expensive operations.",
|
| 175 |
+
}
|
| 176 |
+
base = explanations.get(category, "Applied heuristic fix to the SQL query.")
|
| 177 |
+
return f"{base} Original: '{buggy[:60]}...' Fixed: '{fixed[:60]}...'"
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
# ─────────────────────────────────────────────
|
| 181 |
+
# MAIN BASELINE RUNNER
|
| 182 |
+
# ─────────────────────────────────────────────
|
| 183 |
+
|
| 184 |
+
def run_baseline() -> BaselineResponse:
|
| 185 |
+
"""
|
| 186 |
+
Runs baseline agent against one task of each difficulty.
|
| 187 |
+
Returns BaselineResponse with scores for all 3 tasks.
|
| 188 |
+
Must complete within 60 seconds.
|
| 189 |
+
"""
|
| 190 |
+
# Check API key exists (even if rule-based agent doesn't use it,
|
| 191 |
+
# the spec requires it to be validated)
|
| 192 |
+
try:
|
| 193 |
+
_check_api_key()
|
| 194 |
+
except ValueError as e:
|
| 195 |
+
print(f"Warning: {e}")
|
| 196 |
+
# Continue with rule-based agent anyway for demo
|
| 197 |
+
|
| 198 |
+
results = []
|
| 199 |
+
difficulties = [
|
| 200 |
+
(DifficultyLevel.EASY, "easy_001"),
|
| 201 |
+
(DifficultyLevel.MEDIUM, "medium_001"),
|
| 202 |
+
(DifficultyLevel.HARD, "hard_001"),
|
| 203 |
+
]
|
| 204 |
+
|
| 205 |
+
for difficulty, task_id in difficulties:
|
| 206 |
+
env = SQLDebuggerEnvironment()
|
| 207 |
+
try:
|
| 208 |
+
obs = env.reset(difficulty=difficulty.value, task_id=task_id)
|
| 209 |
+
task_context = {"current_context": obs.current_context}
|
| 210 |
+
|
| 211 |
+
start = time.time()
|
| 212 |
+
score, steps, feedback = _rule_based_agent(env, task_context)
|
| 213 |
+
elapsed = time.time() - start
|
| 214 |
+
|
| 215 |
+
results.append(BaselineResult(
|
| 216 |
+
task_id = task_id,
|
| 217 |
+
difficulty = difficulty,
|
| 218 |
+
score = round(score, 4),
|
| 219 |
+
steps = steps,
|
| 220 |
+
feedback = f"{feedback} (elapsed: {elapsed:.2f}s)"
|
| 221 |
+
))
|
| 222 |
+
print(f"Baseline {difficulty.value}: score={round(score,4)}, steps={steps}")
|
| 223 |
+
|
| 224 |
+
except Exception as e:
|
| 225 |
+
results.append(BaselineResult(
|
| 226 |
+
task_id = task_id,
|
| 227 |
+
difficulty = difficulty,
|
| 228 |
+
score = 0.0,
|
| 229 |
+
steps = 0,
|
| 230 |
+
feedback = f"Error: {str(e)}"
|
| 231 |
+
))
|
| 232 |
+
|
| 233 |
+
avg = round(sum(r.score for r in results) / len(results), 4) if results else 0.0
|
| 234 |
+
print(f"Baseline average score: {avg}")
|
| 235 |
+
|
| 236 |
+
return BaselineResponse(results=results, average_score=avg)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
# ─────────────────────────────────────────────
|
| 240 |
+
# DIRECT RUN
|
| 241 |
+
# ─────────────────────────────────────────────
|
| 242 |
+
|
| 243 |
+
if __name__ == "__main__":
|
| 244 |
+
print("Running baseline agent...")
|
| 245 |
+
response = run_baseline()
|
| 246 |
+
print(f"\nFinal Results:")
|
| 247 |
+
for r in response.results:
|
| 248 |
+
print(f" {r.difficulty.value:8} | {r.task_id:12} | score={r.score} | steps={r.steps}")
|
| 249 |
+
print(f"\nAverage Score: {response.average_score}")
|
env/environment.py
CHANGED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import random
|
| 3 |
+
from typing import Optional
|
| 4 |
+
from pydantic import ValidationError
|
| 5 |
+
|
| 6 |
+
from env.models import (
|
| 7 |
+
Action, Observation, Reward, EpisodeState,
|
| 8 |
+
DifficultyLevel, ActionType, StepResponse
|
| 9 |
+
)
|
| 10 |
+
from env.tasks import task_manager
|
| 11 |
+
from env.reward import compute_reward, is_done, MAX_STEPS
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class SQLDebuggerEnvironment:
|
| 15 |
+
"""
|
| 16 |
+
OpenEnv-compliant SQL Query Debugger Environment.
|
| 17 |
+
|
| 18 |
+
Implements the 3 required methods:
|
| 19 |
+
reset() → Observation
|
| 20 |
+
step() → (Observation, Reward, done, info)
|
| 21 |
+
state() → EpisodeState
|
| 22 |
+
|
| 23 |
+
Design principles:
|
| 24 |
+
- Dense reward signal at every step
|
| 25 |
+
- No state leakage between episodes
|
| 26 |
+
- Graceful handling of all edge cases
|
| 27 |
+
- Deterministic grading
|
| 28 |
+
- Thread-safe episode state
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
def __init__(self):
|
| 32 |
+
self._state = EpisodeState()
|
| 33 |
+
self._current_task = None
|
| 34 |
+
self._started_at = None
|
| 35 |
+
|
| 36 |
+
# ─────────────────────────────────────────────
|
| 37 |
+
# reset() → Observation
|
| 38 |
+
# ─────────────────────────────────────────────
|
| 39 |
+
|
| 40 |
+
def reset(self, difficulty: Optional[str] = None, task_id: Optional[str] = None) -> Observation:
|
| 41 |
+
"""
|
| 42 |
+
Starts a fresh episode. Clears ALL state from previous episode.
|
| 43 |
+
Loads a new task from the dataset.
|
| 44 |
+
Returns the initial Observation the agent sees.
|
| 45 |
+
|
| 46 |
+
Edge cases handled:
|
| 47 |
+
- reset() called mid-episode → cleanly resets, no state leakage
|
| 48 |
+
- invalid difficulty → defaults to random
|
| 49 |
+
- dataset empty → raises ValueError with clear message
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
# ── Resolve difficulty ────────────────────────────────────
|
| 53 |
+
if difficulty is not None:
|
| 54 |
+
try:
|
| 55 |
+
diff_enum = DifficultyLevel(difficulty.lower())
|
| 56 |
+
except ValueError:
|
| 57 |
+
# Invalid difficulty — pick random
|
| 58 |
+
diff_enum = random.choice(list(DifficultyLevel))
|
| 59 |
+
else:
|
| 60 |
+
diff_enum = random.choice(list(DifficultyLevel))
|
| 61 |
+
|
| 62 |
+
# ── Load task ─────────────────────────────────────────────
|
| 63 |
+
try:
|
| 64 |
+
task = task_manager.get_task(diff_enum, task_id=task_id)
|
| 65 |
+
except Exception as e:
|
| 66 |
+
raise ValueError(f"Failed to load task: {str(e)}")
|
| 67 |
+
|
| 68 |
+
# ── Reset ALL state — no leakage ──────────────────────────
|
| 69 |
+
self._current_task = task
|
| 70 |
+
self._started_at = time.time()
|
| 71 |
+
self._state = EpisodeState(
|
| 72 |
+
task_id = task["id"],
|
| 73 |
+
difficulty = diff_enum,
|
| 74 |
+
step_count = 0,
|
| 75 |
+
total_reward = 0.0,
|
| 76 |
+
done = False,
|
| 77 |
+
hints_used = 0,
|
| 78 |
+
previous_actions = [],
|
| 79 |
+
action_counts = {},
|
| 80 |
+
started_at = self._started_at,
|
| 81 |
+
last_reward = 0.0,
|
| 82 |
+
initialized = True,
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
# ── Build initial observation ─────────────────────────────
|
| 86 |
+
context = task_manager.build_observation_context(task)
|
| 87 |
+
return Observation(
|
| 88 |
+
task_id = task["id"],
|
| 89 |
+
task_description = task["description"],
|
| 90 |
+
current_context = context,
|
| 91 |
+
step_count = 0,
|
| 92 |
+
difficulty = diff_enum,
|
| 93 |
+
max_steps = MAX_STEPS,
|
| 94 |
+
hints_used = 0,
|
| 95 |
+
previous_actions = [],
|
| 96 |
+
metadata = {
|
| 97 |
+
"category": task.get("category", ""),
|
| 98 |
+
"estimated_steps": task.get("estimated_fix_steps", 5),
|
| 99 |
+
"started_at": self._started_at,
|
| 100 |
+
}
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
# ─────────────────────────────────────────────
|
| 104 |
+
# step() → (Observation, Reward, done, info)
|
| 105 |
+
# ─────────────────────────────────────────────
|
| 106 |
+
|
| 107 |
+
def step(self, action: Optional[Action]) -> StepResponse:
|
| 108 |
+
"""
|
| 109 |
+
Accepts an Action, processes it, updates state,
|
| 110 |
+
computes dense reward, returns next Observation.
|
| 111 |
+
|
| 112 |
+
Edge cases handled:
|
| 113 |
+
- step() called before reset() → auto-resets
|
| 114 |
+
- null action → reward=-0.1, done=False, never crash
|
| 115 |
+
- malformed action payload → catches ValidationError
|
| 116 |
+
- agent loops (same action 3+ times) → loop penalty
|
| 117 |
+
- episode already done → returns terminal observation
|
| 118 |
+
- max steps reached → forces done=True
|
| 119 |
+
- extremely long payload → truncated in models.py
|
| 120 |
+
"""
|
| 121 |
+
|
| 122 |
+
# ── Auto-reset if not initialized ────────────────────────
|
| 123 |
+
if not self._state.initialized or self._current_task is None:
|
| 124 |
+
obs = self.reset()
|
| 125 |
+
return StepResponse(
|
| 126 |
+
observation=obs,
|
| 127 |
+
reward=Reward(score=0.0, breakdown={"auto_reset": True}, feedback="Environment auto-reset."),
|
| 128 |
+
done=False,
|
| 129 |
+
info={"auto_reset": True}
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
# ── Episode already done ──────────────────────────────────
|
| 133 |
+
if self._state.done:
|
| 134 |
+
obs = self._build_observation()
|
| 135 |
+
return StepResponse(
|
| 136 |
+
observation=obs,
|
| 137 |
+
reward=Reward(score=0.0, breakdown={"episode_done": True}, feedback="Episode already finished. Call reset()."),
|
| 138 |
+
done=True,
|
| 139 |
+
info={"episode_done": True, "total_reward": self._state.total_reward}
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
# ── Handle null / invalid action ─────────────────────────
|
| 143 |
+
if action is None or action.payload is None:
|
| 144 |
+
self._state.step_count += 1
|
| 145 |
+
obs = self._build_observation()
|
| 146 |
+
reward = Reward(
|
| 147 |
+
score=-0.1,
|
| 148 |
+
breakdown={"invalid_action": -0.1},
|
| 149 |
+
feedback="Null or invalid action received. Penalty -0.1."
|
| 150 |
+
)
|
| 151 |
+
self._state.last_reward = -0.1
|
| 152 |
+
self._state.total_reward = round(self._state.total_reward - 0.1, 4)
|
| 153 |
+
done = self._state.step_count >= MAX_STEPS
|
| 154 |
+
self._state.done = done
|
| 155 |
+
return StepResponse(observation=obs, reward=reward, done=done, info={"error": "null_action"})
|
| 156 |
+
|
| 157 |
+
# ── Validate action type ──────────────────────────────────
|
| 158 |
+
try:
|
| 159 |
+
action_type_val = action.action_type.value if hasattr(action.action_type, "value") else str(action.action_type)
|
| 160 |
+
except Exception:
|
| 161 |
+
action_type_val = "unknown"
|
| 162 |
+
|
| 163 |
+
# ── Update step count ─────────────────────────────────────
|
| 164 |
+
self._state.step_count += 1
|
| 165 |
+
self._state.previous_actions.append(action_type_val)
|
| 166 |
+
self._state.action_counts[action_type_val] = self._state.action_counts.get(action_type_val, 0) + 1
|
| 167 |
+
|
| 168 |
+
# ── Track hints ───────────────────────────────────────────
|
| 169 |
+
if action.action_type == ActionType.REQUEST_HINT:
|
| 170 |
+
self._state.hints_used += 1
|
| 171 |
+
# Inject hint into next observation context
|
| 172 |
+
hint_text = task_manager.get_hint(self._current_task, self._state.hints_used)
|
| 173 |
+
self._current_task["_last_hint"] = hint_text
|
| 174 |
+
|
| 175 |
+
# ── Compute dense reward ──────────────────────────────────
|
| 176 |
+
reward = compute_reward(
|
| 177 |
+
action = action,
|
| 178 |
+
task_id = self._state.task_id,
|
| 179 |
+
difficulty = self._state.difficulty,
|
| 180 |
+
step_count = self._state.step_count,
|
| 181 |
+
previous_actions = self._state.previous_actions[:-1], # exclude current
|
| 182 |
+
hints_used = self._state.hints_used,
|
| 183 |
+
estimated_steps = self._current_task.get("estimated_fix_steps", 5),
|
| 184 |
+
action_counts = self._state.action_counts,
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
# ── Update cumulative reward ──────────────────────────────
|
| 188 |
+
self._state.last_reward = reward.score
|
| 189 |
+
self._state.total_reward = round(self._state.total_reward + reward.score, 4)
|
| 190 |
+
|
| 191 |
+
# ── Check done condition ──────────────────────────────────
|
| 192 |
+
done = is_done(
|
| 193 |
+
action_type = action.action_type,
|
| 194 |
+
step_count = self._state.step_count,
|
| 195 |
+
grader_score = reward.breakdown.get("grader_score", 0.0),
|
| 196 |
+
)
|
| 197 |
+
self._state.done = done
|
| 198 |
+
|
| 199 |
+
# ── Build next observation ────────────────────────────────
|
| 200 |
+
obs = self._build_observation()
|
| 201 |
+
|
| 202 |
+
# ── Build info dict ───────────────────────────────────────
|
| 203 |
+
info = {
|
| 204 |
+
"step_count": self._state.step_count,
|
| 205 |
+
"total_reward": self._state.total_reward,
|
| 206 |
+
"hints_used": self._state.hints_used,
|
| 207 |
+
"action_counts": self._state.action_counts,
|
| 208 |
+
"task_id": self._state.task_id,
|
| 209 |
+
"difficulty": self._state.difficulty.value if self._state.difficulty else None,
|
| 210 |
+
}
|
| 211 |
+
if done:
|
| 212 |
+
info["episode_summary"] = {
|
| 213 |
+
"total_steps": self._state.step_count,
|
| 214 |
+
"total_reward": self._state.total_reward,
|
| 215 |
+
"hints_used": self._state.hints_used,
|
| 216 |
+
"duration_sec": round(time.time() - (self._started_at or time.time()), 2),
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
return StepResponse(observation=obs, reward=reward, done=done, info=info)
|
| 220 |
+
|
| 221 |
+
# ─────────────────────────────────────────────
|
| 222 |
+
# state() → EpisodeState
|
| 223 |
+
# ─────────────────────────────────────────────
|
| 224 |
+
|
| 225 |
+
def state(self) -> EpisodeState:
|
| 226 |
+
"""
|
| 227 |
+
Returns the full current state at any point.
|
| 228 |
+
Must be JSON-serializable. Must always reflect latest step.
|
| 229 |
+
|
| 230 |
+
Edge case: state() called before reset() → returns default empty state.
|
| 231 |
+
Never crashes.
|
| 232 |
+
"""
|
| 233 |
+
return self._state
|
| 234 |
+
|
| 235 |
+
# ─────────────────────────────────────────────
|
| 236 |
+
# INTERNAL HELPERS
|
| 237 |
+
# ─────────────────────────────────────────────
|
| 238 |
+
|
| 239 |
+
def _build_observation(self) -> Observation:
|
| 240 |
+
"""
|
| 241 |
+
Builds the current Observation from internal state.
|
| 242 |
+
Injects hint into context if one was just requested.
|
| 243 |
+
CRITICAL: Never leaks fixed_query (ground truth) to agent.
|
| 244 |
+
"""
|
| 245 |
+
if self._current_task is None:
|
| 246 |
+
# Fallback safe observation
|
| 247 |
+
return Observation(
|
| 248 |
+
task_id = "none",
|
| 249 |
+
task_description = "No task loaded. Call reset() first.",
|
| 250 |
+
current_context = {},
|
| 251 |
+
step_count = self._state.step_count,
|
| 252 |
+
difficulty = DifficultyLevel.EASY,
|
| 253 |
+
max_steps = MAX_STEPS,
|
| 254 |
+
hints_used = self._state.hints_used,
|
| 255 |
+
previous_actions = self._state.previous_actions,
|
| 256 |
+
metadata = {}
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
context = task_manager.build_observation_context(self._current_task)
|
| 260 |
+
|
| 261 |
+
# Inject hint if available
|
| 262 |
+
if "_last_hint" in self._current_task:
|
| 263 |
+
context["last_hint"] = self._current_task["_last_hint"]
|
| 264 |
+
|
| 265 |
+
# Add step progress info
|
| 266 |
+
context["steps_remaining"] = MAX_STEPS - self._state.step_count
|
| 267 |
+
context["total_reward_so_far"] = self._state.total_reward
|
| 268 |
+
|
| 269 |
+
return Observation(
|
| 270 |
+
task_id = self._state.task_id or "none",
|
| 271 |
+
task_description = self._current_task.get("description", ""),
|
| 272 |
+
current_context = context,
|
| 273 |
+
step_count = self._state.step_count,
|
| 274 |
+
difficulty = self._state.difficulty or DifficultyLevel.EASY,
|
| 275 |
+
max_steps = MAX_STEPS,
|
| 276 |
+
hints_used = self._state.hints_used,
|
| 277 |
+
previous_actions = self._state.previous_actions.copy(),
|
| 278 |
+
metadata = {
|
| 279 |
+
"category": self._current_task.get("category", ""),
|
| 280 |
+
"estimated_steps": self._current_task.get("estimated_fix_steps", 5),
|
| 281 |
+
"total_reward": self._state.total_reward,
|
| 282 |
+
"action_counts": self._state.action_counts,
|
| 283 |
+
}
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
# ─────────────────────────────────────────────
|
| 288 |
+
# SINGLETON INSTANCE (used by FastAPI)
|
| 289 |
+
# ─────────────────────────────────────────────
|
| 290 |
+
|
| 291 |
+
environment = SQLDebuggerEnvironment()
|
env/graders.py
CHANGED
|
@@ -0,0 +1,440 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from env.models import Action, DifficultyLevel
|
| 3 |
+
from env.tasks import task_manager
|
| 4 |
+
|
| 5 |
+
# HELPERS
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _normalize(text: str) -> str:
|
| 9 |
+
"""Normalize SQL for comparison — lowercase, strip whitespace, collapse spaces."""
|
| 10 |
+
if not isinstance(text, str):
|
| 11 |
+
return ""
|
| 12 |
+
return re.sub(r"\s+", " ", text.strip().lower())
|
| 13 |
+
|
| 14 |
+
def _safe_get(payload: dict, key: str, default=None):
|
| 15 |
+
"""Safe dict access — never KeyError."""
|
| 16 |
+
if not isinstance(payload, dict):
|
| 17 |
+
return default
|
| 18 |
+
return payload.get(key, default)
|
| 19 |
+
|
| 20 |
+
def _score_explanation(explanation: str) -> float:
|
| 21 |
+
"""Score explanation quality by length and keyword richness."""
|
| 22 |
+
if not explanation or not isinstance(explanation, str):
|
| 23 |
+
return 0.0
|
| 24 |
+
explanation = explanation.strip()
|
| 25 |
+
if len(explanation) < 10:
|
| 26 |
+
return 0.0
|
| 27 |
+
if len(explanation) < 30:
|
| 28 |
+
return 0.05
|
| 29 |
+
if len(explanation) < 80:
|
| 30 |
+
return 0.10
|
| 31 |
+
return 0.15
|
| 32 |
+
|
| 33 |
+
def _score_confidence(confidence) -> float:
|
| 34 |
+
"""Give partial credit for providing a valid confidence score."""
|
| 35 |
+
try:
|
| 36 |
+
c = float(confidence)
|
| 37 |
+
if 0.0 <= c <= 1.0:
|
| 38 |
+
return 0.05
|
| 39 |
+
except (TypeError, ValueError):
|
| 40 |
+
pass
|
| 41 |
+
return 0.0
|
| 42 |
+
|
| 43 |
+
def _query_similarity(submitted: str, expected: str) -> float:
|
| 44 |
+
"""
|
| 45 |
+
Multi-level SQL similarity check.
|
| 46 |
+
Returns 0.0 - 1.0 based on how close the submitted query is to expected.
|
| 47 |
+
Handles case, whitespace, and keyword-level matching.
|
| 48 |
+
"""
|
| 49 |
+
s = _normalize(submitted)
|
| 50 |
+
e = _normalize(expected)
|
| 51 |
+
|
| 52 |
+
# Exact match after normalization
|
| 53 |
+
if s == e:
|
| 54 |
+
return 1.0
|
| 55 |
+
|
| 56 |
+
# Tokenize and check keyword overlap
|
| 57 |
+
s_tokens = set(s.split())
|
| 58 |
+
e_tokens = set(e.split())
|
| 59 |
+
|
| 60 |
+
if not e_tokens:
|
| 61 |
+
return 0.0
|
| 62 |
+
|
| 63 |
+
overlap = len(s_tokens & e_tokens) / len(e_tokens)
|
| 64 |
+
|
| 65 |
+
# Check critical keywords present
|
| 66 |
+
critical_keywords = _extract_critical_keywords(e)
|
| 67 |
+
critical_found = sum(1 for kw in critical_keywords if kw in s)
|
| 68 |
+
critical_score = critical_found / len(critical_keywords) if critical_keywords else 0.0
|
| 69 |
+
|
| 70 |
+
# Weighted combination
|
| 71 |
+
return round((overlap * 0.4) + (critical_score * 0.6), 4)
|
| 72 |
+
|
| 73 |
+
def _extract_critical_keywords(query: str) -> list[str]:
|
| 74 |
+
"""Extract SQL keywords that are critical to correctness."""
|
| 75 |
+
keywords = [
|
| 76 |
+
"left join", "inner join", "right join",
|
| 77 |
+
"group by", "order by", "having",
|
| 78 |
+
"partition by", "coalesce", "distinct",
|
| 79 |
+
"where", "on", "and", "or", "not",
|
| 80 |
+
"count", "sum", "avg", "max", "min",
|
| 81 |
+
"select", "from", "join"
|
| 82 |
+
]
|
| 83 |
+
found = []
|
| 84 |
+
q = query.lower()
|
| 85 |
+
for kw in keywords:
|
| 86 |
+
if kw in q:
|
| 87 |
+
found.append(kw)
|
| 88 |
+
return found
|
| 89 |
+
|
| 90 |
+
def _score_error_type(submitted_type: str, expected_type: str) -> float:
|
| 91 |
+
"""Score for correctly identifying the error type."""
|
| 92 |
+
if not submitted_type:
|
| 93 |
+
return 0.0
|
| 94 |
+
s = submitted_type.strip().lower()
|
| 95 |
+
e = expected_type.strip().lower()
|
| 96 |
+
if s == e:
|
| 97 |
+
return 0.10
|
| 98 |
+
# Partial: performance ↔ optimization are related
|
| 99 |
+
related = {
|
| 100 |
+
"performance": ["optimization", "slow", "index", "scan"],
|
| 101 |
+
"logic": ["semantic", "incorrect", "wrong"],
|
| 102 |
+
"syntax": ["parse", "grammar", "token"]
|
| 103 |
+
}
|
| 104 |
+
for canonical, aliases in related.items():
|
| 105 |
+
if e == canonical and any(alias in s for alias in aliases):
|
| 106 |
+
return 0.05
|
| 107 |
+
return 0.0
|
| 108 |
+
|
| 109 |
+
def _score_error_location(submitted_location: str, expected_location: str) -> float:
|
| 110 |
+
"""Score for correctly identifying WHERE in the query the error is."""
|
| 111 |
+
if not submitted_location or not expected_location:
|
| 112 |
+
return 0.0
|
| 113 |
+
s = submitted_location.strip().lower()
|
| 114 |
+
e = expected_location.strip().lower()
|
| 115 |
+
if s == e:
|
| 116 |
+
return 0.15
|
| 117 |
+
# Partial: check if key location words overlap
|
| 118 |
+
e_words = set(e.split())
|
| 119 |
+
s_words = set(s.split())
|
| 120 |
+
overlap = len(e_words & s_words) / len(e_words) if e_words else 0.0
|
| 121 |
+
return round(overlap * 0.10, 4)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# GRADERS PER DIFFICULTY
|
| 125 |
+
|
| 126 |
+
def grade_easy(action: Action, ground_truth: dict) -> tuple[float, dict, str]:
|
| 127 |
+
"""
|
| 128 |
+
Easy task grader — syntax errors.
|
| 129 |
+
Max score: 1.0
|
| 130 |
+
Partial credit across: fix correctness, error location, error type, explanation, confidence.
|
| 131 |
+
DETERMINISTIC: same input always returns same score.
|
| 132 |
+
"""
|
| 133 |
+
# Edge case: null or malformed action
|
| 134 |
+
if action is None or action.payload is None:
|
| 135 |
+
return 0.0, {"error": "null_action"}, "No action provided."
|
| 136 |
+
|
| 137 |
+
payload = action.payload
|
| 138 |
+
score = 0.0
|
| 139 |
+
breakdown = {}
|
| 140 |
+
feedback_parts = []
|
| 141 |
+
|
| 142 |
+
action_type = action.action_type.value if hasattr(action.action_type, "value") else str(action.action_type)
|
| 143 |
+
|
| 144 |
+
# ── 1. Query fix correctness (0.50) ──────────────────────────
|
| 145 |
+
submitted_query = _safe_get(payload, "fixed_query", "") or _safe_get(payload, "optimized_query", "")
|
| 146 |
+
expected_query = ground_truth.get("fixed_query", "")
|
| 147 |
+
similarity = _query_similarity(submitted_query, expected_query)
|
| 148 |
+
|
| 149 |
+
if similarity >= 1.0:
|
| 150 |
+
fix_score = 0.50
|
| 151 |
+
feedback_parts.append("Correct fix applied.")
|
| 152 |
+
elif similarity >= 0.75:
|
| 153 |
+
fix_score = 0.30
|
| 154 |
+
feedback_parts.append("Fix is mostly correct but has minor differences.")
|
| 155 |
+
elif similarity >= 0.50:
|
| 156 |
+
fix_score = 0.15
|
| 157 |
+
feedback_parts.append("Fix is partially correct.")
|
| 158 |
+
else:
|
| 159 |
+
fix_score = 0.0
|
| 160 |
+
feedback_parts.append("Fix is incorrect or not provided.")
|
| 161 |
+
|
| 162 |
+
score += fix_score
|
| 163 |
+
breakdown["fix_correctness"] = round(fix_score, 4)
|
| 164 |
+
|
| 165 |
+
# ── 2. Error location (0.15) ─────────────────────────────────
|
| 166 |
+
submitted_location = _safe_get(payload, "error_location", "")
|
| 167 |
+
expected_location = ground_truth.get("error_location", "")
|
| 168 |
+
loc_score = _score_error_location(str(submitted_location), expected_location)
|
| 169 |
+
score += loc_score
|
| 170 |
+
breakdown["error_location"] = round(loc_score, 4)
|
| 171 |
+
if loc_score > 0:
|
| 172 |
+
feedback_parts.append("Correctly identified error location.")
|
| 173 |
+
|
| 174 |
+
# ── 3. Error type (0.10) ─────────────────────────────────────
|
| 175 |
+
submitted_type = _safe_get(payload, "error_type", "")
|
| 176 |
+
expected_type = ground_truth.get("error_type", "syntax")
|
| 177 |
+
type_score = _score_error_type(str(submitted_type), expected_type)
|
| 178 |
+
score += type_score
|
| 179 |
+
breakdown["error_type"] = round(type_score, 4)
|
| 180 |
+
if type_score > 0:
|
| 181 |
+
feedback_parts.append("Correctly identified error type.")
|
| 182 |
+
|
| 183 |
+
# ── 4. Explanation quality (0.15) ────────────────────────────
|
| 184 |
+
explanation = _safe_get(payload, "explanation", "") or _safe_get(payload, "change_made", "")
|
| 185 |
+
expl_score = _score_explanation(str(explanation) if explanation else "")
|
| 186 |
+
score += expl_score
|
| 187 |
+
breakdown["explanation"] = round(expl_score, 4)
|
| 188 |
+
if expl_score > 0:
|
| 189 |
+
feedback_parts.append("Explanation provided.")
|
| 190 |
+
|
| 191 |
+
# ── 5. Confidence (0.05) ─────────────────────────────────────
|
| 192 |
+
confidence = _safe_get(payload, "confidence", None)
|
| 193 |
+
conf_score = _score_confidence(confidence)
|
| 194 |
+
score += conf_score
|
| 195 |
+
breakdown["confidence"] = round(conf_score, 4)
|
| 196 |
+
|
| 197 |
+
# ── 6. Hint penalty ──────────────────────────────────────────
|
| 198 |
+
# Hint penalty is applied in reward.py, not here
|
| 199 |
+
|
| 200 |
+
final_score = round(min(score, 1.0), 4)
|
| 201 |
+
feedback = " ".join(feedback_parts) if feedback_parts else "No valid response provided."
|
| 202 |
+
return final_score, breakdown, feedback
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def grade_medium(action: Action, ground_truth: dict) -> tuple[float, dict, str]:
|
| 206 |
+
"""
|
| 207 |
+
Medium task grader — logic errors (wrong JOINs, wrong aggregations, etc).
|
| 208 |
+
Max score: 1.0
|
| 209 |
+
Higher bar: must correctly identify the logic flaw, not just syntax.
|
| 210 |
+
DETERMINISTIC: same input always returns same score.
|
| 211 |
+
"""
|
| 212 |
+
if action is None or action.payload is None:
|
| 213 |
+
return 0.0, {"error": "null_action"}, "No action provided."
|
| 214 |
+
|
| 215 |
+
payload = action.payload
|
| 216 |
+
score = 0.0
|
| 217 |
+
breakdown = {}
|
| 218 |
+
feedback_parts = []
|
| 219 |
+
|
| 220 |
+
# ── 1. Query fix correctness (0.40) ──────────────────────────
|
| 221 |
+
submitted_query = _safe_get(payload, "fixed_query", "") or _safe_get(payload, "optimized_query", "")
|
| 222 |
+
expected_query = ground_truth.get("fixed_query", "")
|
| 223 |
+
similarity = _query_similarity(submitted_query, expected_query)
|
| 224 |
+
|
| 225 |
+
if similarity >= 1.0:
|
| 226 |
+
fix_score = 0.40
|
| 227 |
+
feedback_parts.append("Correct fix applied.")
|
| 228 |
+
elif similarity >= 0.80:
|
| 229 |
+
fix_score = 0.28
|
| 230 |
+
feedback_parts.append("Fix is mostly correct.")
|
| 231 |
+
elif similarity >= 0.60:
|
| 232 |
+
fix_score = 0.16
|
| 233 |
+
feedback_parts.append("Fix is partially correct.")
|
| 234 |
+
elif similarity >= 0.40:
|
| 235 |
+
fix_score = 0.08
|
| 236 |
+
feedback_parts.append("Fix shows some understanding.")
|
| 237 |
+
else:
|
| 238 |
+
fix_score = 0.0
|
| 239 |
+
feedback_parts.append("Fix is incorrect or missing.")
|
| 240 |
+
|
| 241 |
+
score += fix_score
|
| 242 |
+
breakdown["fix_correctness"] = round(fix_score, 4)
|
| 243 |
+
|
| 244 |
+
# ── 2. Identifies the logic flaw (0.20) ──────────────────────
|
| 245 |
+
explanation = str(_safe_get(payload, "explanation", "") or _safe_get(payload, "change_made", "") or "")
|
| 246 |
+
error_type = ground_truth.get("error_type", "logic")
|
| 247 |
+
category = ground_truth.get("category", "")
|
| 248 |
+
|
| 249 |
+
logic_keywords = {
|
| 250 |
+
"logic": ["join", "left join", "inner join", "having", "where", "group by",
|
| 251 |
+
"aggregate", "subquery", "correlation", "distinct", "count"],
|
| 252 |
+
"performance": ["index", "scan", "n+1", "correlated", "cartesian", "window"]
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
keywords_to_check = logic_keywords.get(error_type, logic_keywords["logic"])
|
| 256 |
+
expl_lower = explanation.lower()
|
| 257 |
+
keyword_hits = sum(1 for kw in keywords_to_check if kw in expl_lower)
|
| 258 |
+
logic_score = min(keyword_hits * 0.05, 0.20)
|
| 259 |
+
score += logic_score
|
| 260 |
+
breakdown["logic_flaw_identification"] = round(logic_score, 4)
|
| 261 |
+
if logic_score > 0:
|
| 262 |
+
feedback_parts.append("Shows understanding of the logic flaw.")
|
| 263 |
+
|
| 264 |
+
# ── 3. Error location (0.15) ─────────────────────────────────
|
| 265 |
+
submitted_location = _safe_get(payload, "error_location", "")
|
| 266 |
+
expected_location = ground_truth.get("error_location", "")
|
| 267 |
+
loc_score = _score_error_location(str(submitted_location), expected_location)
|
| 268 |
+
score += loc_score
|
| 269 |
+
breakdown["error_location"] = round(loc_score, 4)
|
| 270 |
+
|
| 271 |
+
# ── 4. Explanation quality (0.15) ────────────────────────────
|
| 272 |
+
expl_score = _score_explanation(explanation)
|
| 273 |
+
score += expl_score
|
| 274 |
+
breakdown["explanation"] = round(expl_score, 4)
|
| 275 |
+
|
| 276 |
+
# ── 5. Confidence (0.05) ─────────────────────────────────────
|
| 277 |
+
confidence = _safe_get(payload, "confidence", None)
|
| 278 |
+
conf_score = _score_confidence(confidence)
|
| 279 |
+
score += conf_score
|
| 280 |
+
breakdown["confidence"] = round(conf_score, 4)
|
| 281 |
+
|
| 282 |
+
# ── 6. Impact analysis bonus (0.05) ──────────────────────────
|
| 283 |
+
impact = str(_safe_get(payload, "impact", "") or "")
|
| 284 |
+
if len(impact.strip()) > 20:
|
| 285 |
+
score += 0.05
|
| 286 |
+
breakdown["impact_analysis"] = 0.05
|
| 287 |
+
feedback_parts.append("Impact analysis provided.")
|
| 288 |
+
else:
|
| 289 |
+
breakdown["impact_analysis"] = 0.0
|
| 290 |
+
|
| 291 |
+
final_score = round(min(score, 1.0), 4)
|
| 292 |
+
feedback = " ".join(feedback_parts) if feedback_parts else "No valid response provided."
|
| 293 |
+
return final_score, breakdown, feedback
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def grade_hard(action: Action, ground_truth: dict) -> tuple[float, dict, str]:
|
| 297 |
+
"""
|
| 298 |
+
Hard task grader — performance issues (N+1, missing index, cartesian, etc).
|
| 299 |
+
Max score: 1.0 but frontier models expected ~0.10-0.20.
|
| 300 |
+
Extremely strict — requires deep understanding of performance concepts.
|
| 301 |
+
DETERMINISTIC: same input always returns same score.
|
| 302 |
+
"""
|
| 303 |
+
if action is None or action.payload is None:
|
| 304 |
+
return 0.0, {"error": "null_action"}, "No action provided."
|
| 305 |
+
|
| 306 |
+
payload = action.payload
|
| 307 |
+
score = 0.0
|
| 308 |
+
breakdown = {}
|
| 309 |
+
feedback_parts = []
|
| 310 |
+
|
| 311 |
+
rubric = ground_truth.get("scoring_rubric", {})
|
| 312 |
+
|
| 313 |
+
# ── 1. Query correctness (0.30) ──────────────────────────────
|
| 314 |
+
submitted_query = (
|
| 315 |
+
_safe_get(payload, "optimized_query", "")
|
| 316 |
+
or _safe_get(payload, "fixed_query", "")
|
| 317 |
+
or ""
|
| 318 |
+
)
|
| 319 |
+
expected_query = ground_truth.get("fixed_query", "")
|
| 320 |
+
similarity = _query_similarity(submitted_query, expected_query)
|
| 321 |
+
|
| 322 |
+
if similarity >= 1.0:
|
| 323 |
+
fix_score = 0.30
|
| 324 |
+
feedback_parts.append("Perfectly optimized query.")
|
| 325 |
+
elif similarity >= 0.85:
|
| 326 |
+
fix_score = 0.22
|
| 327 |
+
feedback_parts.append("Query is mostly correct.")
|
| 328 |
+
elif similarity >= 0.65:
|
| 329 |
+
fix_score = 0.14
|
| 330 |
+
feedback_parts.append("Query shows correct approach but incomplete.")
|
| 331 |
+
elif similarity >= 0.40:
|
| 332 |
+
fix_score = 0.07
|
| 333 |
+
feedback_parts.append("Query partially addresses the issue.")
|
| 334 |
+
else:
|
| 335 |
+
fix_score = 0.0
|
| 336 |
+
feedback_parts.append("Query does not address the performance issue.")
|
| 337 |
+
|
| 338 |
+
score += fix_score
|
| 339 |
+
breakdown["query_correctness"] = round(fix_score, 4)
|
| 340 |
+
|
| 341 |
+
# ── 2. Performance concept identification (0.30) ──────────────
|
| 342 |
+
explanation = str(_safe_get(payload, "explanation", "") or _safe_get(payload, "change_made", "") or "")
|
| 343 |
+
optimization = str(_safe_get(payload, "optimization_type", "") or "")
|
| 344 |
+
combined_text = (explanation + " " + optimization).lower()
|
| 345 |
+
perf_issue = ground_truth.get("performance_issue", {})
|
| 346 |
+
issue_type = perf_issue.get("type", "").lower()
|
| 347 |
+
|
| 348 |
+
performance_concept_map = {
|
| 349 |
+
"n+1": ["n+1", "correlated subquery", "subquery per row", "multiple queries", "join instead"],
|
| 350 |
+
"full table scan": ["full table scan", "index not used", "function on column", "sargable", "range scan", "seek"],
|
| 351 |
+
"cartesian product": ["cartesian", "cross join", "missing join condition", "implicit join", "comma join"],
|
| 352 |
+
"select *": ["select *", "over-fetch", "covering index", "column projection", "unnecessary columns"],
|
| 353 |
+
"window function": ["window function", "partition by", "row_number", "subquery filter", "where clause window"]
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
concept_score = 0.0
|
| 357 |
+
for concept, keywords in performance_concept_map.items():
|
| 358 |
+
if any(concept_part in issue_type for concept_part in concept.split()):
|
| 359 |
+
hits = sum(1 for kw in keywords if kw in combined_text)
|
| 360 |
+
concept_score = min(hits * 0.06, 0.30)
|
| 361 |
+
break
|
| 362 |
+
|
| 363 |
+
score += concept_score
|
| 364 |
+
breakdown["performance_concept"] = round(concept_score, 4)
|
| 365 |
+
if concept_score > 0:
|
| 366 |
+
feedback_parts.append("Demonstrates understanding of the performance issue.")
|
| 367 |
+
|
| 368 |
+
# ── 3. Explanation depth (0.15) ───────────────────────────────
|
| 369 |
+
expl_score = _score_explanation(explanation)
|
| 370 |
+
# Hard tasks require deeper explanations — bonus for long explanations
|
| 371 |
+
if len(explanation.strip()) > 150:
|
| 372 |
+
expl_score = min(expl_score + 0.05, 0.15)
|
| 373 |
+
score += expl_score
|
| 374 |
+
breakdown["explanation_depth"] = round(expl_score, 4)
|
| 375 |
+
|
| 376 |
+
# ── 4. Root cause analysis (0.10) ─────────────────────────────
|
| 377 |
+
root_cause = str(_safe_get(payload, "root_cause", "") or "")
|
| 378 |
+
if len(root_cause.strip()) > 30:
|
| 379 |
+
score += 0.10
|
| 380 |
+
breakdown["root_cause_analysis"] = 0.10
|
| 381 |
+
feedback_parts.append("Root cause analysis provided.")
|
| 382 |
+
else:
|
| 383 |
+
breakdown["root_cause_analysis"] = 0.0
|
| 384 |
+
|
| 385 |
+
# ── 5. Expected improvement (0.10) ────────────────────────────
|
| 386 |
+
improvement = str(_safe_get(payload, "expected_improvement", "") or "")
|
| 387 |
+
if len(improvement.strip()) > 20:
|
| 388 |
+
score += 0.10
|
| 389 |
+
breakdown["expected_improvement"] = 0.10
|
| 390 |
+
feedback_parts.append("Performance improvement estimate provided.")
|
| 391 |
+
else:
|
| 392 |
+
breakdown["expected_improvement"] = 0.0
|
| 393 |
+
|
| 394 |
+
# ── 6. Confidence (0.05) ──────────────────────────────────────
|
| 395 |
+
confidence = _safe_get(payload, "confidence", None)
|
| 396 |
+
conf_score = _score_confidence(confidence)
|
| 397 |
+
score += conf_score
|
| 398 |
+
breakdown["confidence"] = round(conf_score, 4)
|
| 399 |
+
|
| 400 |
+
# Hard cap: frontier model should score ~0.10-0.20
|
| 401 |
+
# We do NOT artificially cap — the rubric naturally produces low scores
|
| 402 |
+
final_score = round(min(score, 1.0), 4)
|
| 403 |
+
feedback = " ".join(feedback_parts) if feedback_parts else "Performance issue not identified."
|
| 404 |
+
return final_score, breakdown, feedback
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
# ─────────────────────────────────────────────
|
| 408 |
+
# MAIN GRADER DISPATCHER
|
| 409 |
+
# ─────────────────────────────────────────────
|
| 410 |
+
|
| 411 |
+
def grade(action: Action, task_id: str) -> tuple[float, dict, str]:
|
| 412 |
+
"""
|
| 413 |
+
Main grader entry point.
|
| 414 |
+
Looks up ground truth, dispatches to correct grader by difficulty.
|
| 415 |
+
ALWAYS returns (float, dict, str) — never crashes.
|
| 416 |
+
"""
|
| 417 |
+
# Edge case: null action
|
| 418 |
+
if action is None:
|
| 419 |
+
return 0.0, {"error": "null_action"}, "No action provided."
|
| 420 |
+
|
| 421 |
+
# Edge case: unknown task
|
| 422 |
+
ground_truth = task_manager.get_ground_truth(task_id)
|
| 423 |
+
if ground_truth is None:
|
| 424 |
+
return 0.0, {"error": "unknown_task"}, f"Task '{task_id}' not found."
|
| 425 |
+
|
| 426 |
+
# Dispatch by difficulty
|
| 427 |
+
difficulty = ground_truth.get("id", "").split("_")[0]
|
| 428 |
+
|
| 429 |
+
try:
|
| 430 |
+
if difficulty == "easy":
|
| 431 |
+
return grade_easy(action, ground_truth)
|
| 432 |
+
elif difficulty == "medium":
|
| 433 |
+
return grade_medium(action, ground_truth)
|
| 434 |
+
elif difficulty == "hard":
|
| 435 |
+
return grade_hard(action, ground_truth)
|
| 436 |
+
else:
|
| 437 |
+
return 0.0, {"error": "unknown_difficulty"}, f"Unknown difficulty: {difficulty}"
|
| 438 |
+
except Exception as e:
|
| 439 |
+
# Never crash — return 0.0 with error info
|
| 440 |
+
return 0.0, {"error": str(e)}, f"Grader error: {str(e)}"
|
env/reward.py
CHANGED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from env.models import Action, Reward, DifficultyLevel, ActionType
|
| 2 |
+
from env.graders import grade
|
| 3 |
+
|
| 4 |
+
# CONSTANTS
|
| 5 |
+
|
| 6 |
+
MAX_STEPS = 20
|
| 7 |
+
HINT_PENALTY = -0.05 # Per hint requested
|
| 8 |
+
LOOP_PENALTY = -0.05 # Same action 3+ times in a row
|
| 9 |
+
INVALID_PENALTY = -0.10 # Null / malformed action
|
| 10 |
+
STEP_EFFICIENCY_BONUS = 0.10 # Bonus for solving in fewer steps than estimated
|
| 11 |
+
|
| 12 |
+
# Dense reward per action type (before grader score)
|
| 13 |
+
STEP_REWARDS = {
|
| 14 |
+
ActionType.IDENTIFY_ERROR: 0.15, # Rewarded for diagnosing
|
| 15 |
+
ActionType.PROPOSE_FIX: 0.25, # Rewarded for attempting fix
|
| 16 |
+
ActionType.SUBMIT_ANSWER: 0.00, # Final score comes from grader
|
| 17 |
+
ActionType.REQUEST_HINT: 0.00, # No reward, only penalty
|
| 18 |
+
ActionType.EXPLAIN_ISSUE: 0.10, # Rewarded for explaining
|
| 19 |
+
ActionType.OPTIMIZE_QUERY: 0.20, # Rewarded for optimization attempt
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# LOOP DETECTOR
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _detect_loop(previous_actions: list[str], current_action: str) -> bool:
|
| 27 |
+
"""
|
| 28 |
+
Returns True if the agent has submitted the same action type
|
| 29 |
+
3 or more times in a row — indicating a stuck loop.
|
| 30 |
+
"""
|
| 31 |
+
if len(previous_actions) < 2:
|
| 32 |
+
return False
|
| 33 |
+
last_two = previous_actions[-2:]
|
| 34 |
+
return all(a == current_action for a in last_two)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _count_consecutive(previous_actions: list[str], current_action: str) -> int:
|
| 38 |
+
"""Count how many times the current action has been repeated consecutively."""
|
| 39 |
+
count = 1
|
| 40 |
+
for a in reversed(previous_actions):
|
| 41 |
+
if a == current_action:
|
| 42 |
+
count += 1
|
| 43 |
+
else:
|
| 44 |
+
break
|
| 45 |
+
return count
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# EFFICIENCY BONUS
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _efficiency_bonus(step_count: int, estimated_steps: int) -> float:
|
| 52 |
+
"""
|
| 53 |
+
Bonus reward if agent solves faster than estimated.
|
| 54 |
+
Encourages efficient reasoning, not just correct answers.
|
| 55 |
+
"""
|
| 56 |
+
if step_count <= 0 or estimated_steps <= 0:
|
| 57 |
+
return 0.0
|
| 58 |
+
if step_count <= estimated_steps:
|
| 59 |
+
ratio = step_count / estimated_steps
|
| 60 |
+
# More bonus the faster — scales from 0.10 down to 0.0
|
| 61 |
+
return round(STEP_EFFICIENCY_BONUS * (1.0 - ratio + 0.1), 4)
|
| 62 |
+
return 0.0
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# MAIN REWARD FUNCTION
|
| 66 |
+
|
| 67 |
+
def compute_reward(
|
| 68 |
+
action: Action,
|
| 69 |
+
task_id: str,
|
| 70 |
+
difficulty: DifficultyLevel,
|
| 71 |
+
step_count: int,
|
| 72 |
+
previous_actions: list[str],
|
| 73 |
+
hints_used: int,
|
| 74 |
+
estimated_steps: int,
|
| 75 |
+
action_counts: dict[str, int],
|
| 76 |
+
) -> Reward:
|
| 77 |
+
"""
|
| 78 |
+
Computes a DENSE reward signal for every step.
|
| 79 |
+
Never returns 0.0 for all steps — reward varies at each step.
|
| 80 |
+
|
| 81 |
+
Dense reward components:
|
| 82 |
+
1. Step reward — small reward just for taking valid action
|
| 83 |
+
2. Grader score — full grader score on submit_answer / optimize_query
|
| 84 |
+
3. Loop penalty — repeated same action 3+ times
|
| 85 |
+
4. Hint penalty — accumulated hint cost
|
| 86 |
+
5. Efficiency bonus — solved faster than estimated steps
|
| 87 |
+
6. Invalid penalty — null / malformed action
|
| 88 |
+
|
| 89 |
+
Score is always clamped to [-1.0, 1.0].
|
| 90 |
+
"""
|
| 91 |
+
|
| 92 |
+
breakdown = {}
|
| 93 |
+
feedback_parts = []
|
| 94 |
+
final_score = 0.0
|
| 95 |
+
|
| 96 |
+
# ── Edge case: null action ────────────────────────────────────
|
| 97 |
+
if action is None or action.payload is None:
|
| 98 |
+
return Reward(
|
| 99 |
+
score=-0.1,
|
| 100 |
+
breakdown={"invalid_action": -0.1},
|
| 101 |
+
feedback="Invalid or null action received. Penalty applied."
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
action_type_val = action.action_type.value if hasattr(action.action_type, "value") else str(action.action_type)
|
| 105 |
+
action_type_enum = action.action_type
|
| 106 |
+
|
| 107 |
+
# ── 1. Step reward (dense signal) ────────────────────────────
|
| 108 |
+
step_reward = STEP_REWARDS.get(action_type_enum, 0.05)
|
| 109 |
+
breakdown["step_reward"] = round(step_reward, 4)
|
| 110 |
+
final_score += step_reward
|
| 111 |
+
if step_reward > 0:
|
| 112 |
+
feedback_parts.append(f"Action '{action_type_val}' rewarded +{step_reward}.")
|
| 113 |
+
|
| 114 |
+
# ── 2. Grader score for terminal actions ──────────────────────
|
| 115 |
+
grader_score = 0.0
|
| 116 |
+
is_terminal = action_type_enum in (ActionType.SUBMIT_ANSWER, ActionType.OPTIMIZE_QUERY)
|
| 117 |
+
|
| 118 |
+
if is_terminal:
|
| 119 |
+
raw_score, grader_breakdown, grader_feedback = grade(action, task_id)
|
| 120 |
+
grader_score = raw_score
|
| 121 |
+
breakdown["grader_score"] = round(grader_score, 4)
|
| 122 |
+
breakdown["grader_breakdown"] = grader_breakdown
|
| 123 |
+
final_score += grader_score
|
| 124 |
+
feedback_parts.append(grader_feedback)
|
| 125 |
+
|
| 126 |
+
# Efficiency bonus — only on correct terminal action
|
| 127 |
+
if grader_score >= 0.5:
|
| 128 |
+
eff_bonus = _efficiency_bonus(step_count, estimated_steps)
|
| 129 |
+
if eff_bonus > 0:
|
| 130 |
+
final_score += eff_bonus
|
| 131 |
+
breakdown["efficiency_bonus"] = round(eff_bonus, 4)
|
| 132 |
+
feedback_parts.append(f"Efficiency bonus +{eff_bonus} for solving in {step_count} steps.")
|
| 133 |
+
|
| 134 |
+
elif action_type_enum == ActionType.PROPOSE_FIX:
|
| 135 |
+
# Partial grader score for propose_fix — encourages iterative improvement
|
| 136 |
+
raw_score, grader_breakdown, _ = grade(action, task_id)
|
| 137 |
+
partial = round(raw_score * 0.4, 4) # 40% of full grader score
|
| 138 |
+
grader_score = partial
|
| 139 |
+
breakdown["partial_grader_score"] = partial
|
| 140 |
+
final_score += partial
|
| 141 |
+
if partial > 0:
|
| 142 |
+
feedback_parts.append(f"Partial fix credit +{partial}.")
|
| 143 |
+
|
| 144 |
+
elif action_type_enum == ActionType.IDENTIFY_ERROR:
|
| 145 |
+
# Small grader check on error identification
|
| 146 |
+
raw_score, _, _ = grade(action, task_id)
|
| 147 |
+
partial = round(raw_score * 0.2, 4) # 20% for identification step
|
| 148 |
+
breakdown["identification_score"] = partial
|
| 149 |
+
final_score += partial
|
| 150 |
+
|
| 151 |
+
# ── 3. Loop penalty ───────────────────────────────────────────
|
| 152 |
+
if _detect_loop(previous_actions, action_type_val):
|
| 153 |
+
consecutive = _count_consecutive(previous_actions, action_type_val)
|
| 154 |
+
loop_pen = LOOP_PENALTY * min(consecutive - 2, 3) # Cap at 3x penalty
|
| 155 |
+
final_score += loop_pen
|
| 156 |
+
breakdown["loop_penalty"] = round(loop_pen, 4)
|
| 157 |
+
feedback_parts.append(f"Loop detected ({consecutive}x same action). Penalty {loop_pen}.")
|
| 158 |
+
|
| 159 |
+
# ── 4. Hint penalty ───────────────────────────────────────────
|
| 160 |
+
if action_type_enum == ActionType.REQUEST_HINT:
|
| 161 |
+
hint_pen = HINT_PENALTY
|
| 162 |
+
final_score += hint_pen
|
| 163 |
+
breakdown["hint_penalty"] = round(hint_pen, 4)
|
| 164 |
+
feedback_parts.append(f"Hint requested. Penalty {hint_pen}.")
|
| 165 |
+
|
| 166 |
+
# ── 5. Max steps penalty ──────────────────────────────────────
|
| 167 |
+
if step_count >= MAX_STEPS - 1:
|
| 168 |
+
final_score += -0.10
|
| 169 |
+
breakdown["max_steps_penalty"] = -0.10
|
| 170 |
+
feedback_parts.append("Approaching max steps limit. Penalty applied.")
|
| 171 |
+
|
| 172 |
+
# ── Clamp to [-1.0, 1.0] ─────────────────────────────────────
|
| 173 |
+
final_score = round(max(-1.0, min(1.0, final_score)), 4)
|
| 174 |
+
breakdown["total"] = final_score
|
| 175 |
+
|
| 176 |
+
feedback = " ".join(feedback_parts) if feedback_parts else "Step processed."
|
| 177 |
+
|
| 178 |
+
return Reward(
|
| 179 |
+
score=final_score,
|
| 180 |
+
breakdown=breakdown,
|
| 181 |
+
feedback=feedback
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# EPISODE DONE CONDITION
|
| 186 |
+
|
| 187 |
+
def is_done(
|
| 188 |
+
action_type: ActionType,
|
| 189 |
+
step_count: int,
|
| 190 |
+
grader_score: float = 0.0,
|
| 191 |
+
) -> bool:
|
| 192 |
+
"""
|
| 193 |
+
Episode ends when:
|
| 194 |
+
1. Agent submits final answer (submit_answer / optimize_query)
|
| 195 |
+
2. Max steps reached
|
| 196 |
+
3. Perfect score achieved
|
| 197 |
+
"""
|
| 198 |
+
if action_type in (ActionType.SUBMIT_ANSWER, ActionType.OPTIMIZE_QUERY):
|
| 199 |
+
return True
|
| 200 |
+
if step_count >= MAX_STEPS:
|
| 201 |
+
return True
|
| 202 |
+
if grader_score >= 1.0:
|
| 203 |
+
return True
|
| 204 |
+
return False
|
openenv.yaml
CHANGED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: sql-query-debugger
|
| 2 |
+
version: '1.0.0'
|
| 3 |
+
description: 'An OpenEnv environment where AI agents learn to debug SQL queries across syntax errors, logic bugs, and performance issues'
|
| 4 |
+
tags: [openenv, real-world, sql, debugging, performance, reinforcement-learning]
|
| 5 |
+
|
| 6 |
+
tasks:
|
| 7 |
+
- id: easy_001
|
| 8 |
+
difficulty: easy
|
| 9 |
+
description: 'Fix SQL syntax error: missing commas in SELECT clause'
|
| 10 |
+
|
| 11 |
+
- id: easy_002
|
| 12 |
+
difficulty: easy
|
| 13 |
+
description: 'Fix SQL syntax error: missing WHERE keyword'
|
| 14 |
+
|
| 15 |
+
- id: easy_003
|
| 16 |
+
difficulty: easy
|
| 17 |
+
description: 'Fix SQL syntax error: unclosed string literal'
|
| 18 |
+
|
| 19 |
+
- id: easy_004
|
| 20 |
+
difficulty: easy
|
| 21 |
+
description: 'Fix SQL syntax error: ORDER used instead of ORDER BY'
|
| 22 |
+
|
| 23 |
+
- id: easy_005
|
| 24 |
+
difficulty: easy
|
| 25 |
+
description: 'Fix SQL syntax error: GROUP used instead of GROUP BY'
|
| 26 |
+
|
| 27 |
+
- id: medium_001
|
| 28 |
+
difficulty: medium
|
| 29 |
+
description: 'Fix wrong JOIN type: INNER JOIN excludes users with no orders'
|
| 30 |
+
|
| 31 |
+
- id: medium_002
|
| 32 |
+
difficulty: medium
|
| 33 |
+
description: 'Fix wrong JOIN condition causing incorrect product associations'
|
| 34 |
+
|
| 35 |
+
- id: medium_003
|
| 36 |
+
difficulty: medium
|
| 37 |
+
description: 'Fix aggregation logic: HAVING clause used incorrectly as WHERE'
|
| 38 |
+
|
| 39 |
+
- id: medium_004
|
| 40 |
+
difficulty: medium
|
| 41 |
+
description: 'Fix correlated subquery correlating on wrong column'
|
| 42 |
+
|
| 43 |
+
- id: medium_005
|
| 44 |
+
difficulty: medium
|
| 45 |
+
description: 'Fix DISTINCT misuse with COUNT causing invalid query'
|
| 46 |
+
|
| 47 |
+
- id: hard_001
|
| 48 |
+
difficulty: hard
|
| 49 |
+
description: 'Detect and fix N+1 query pattern with correlated subqueries'
|
| 50 |
+
|
| 51 |
+
- id: hard_002
|
| 52 |
+
difficulty: hard
|
| 53 |
+
description: 'Fix function on indexed column preventing index usage'
|
| 54 |
+
|
| 55 |
+
- id: hard_003
|
| 56 |
+
difficulty: hard
|
| 57 |
+
description: 'Fix implicit cartesian product from missing JOIN condition'
|
| 58 |
+
|
| 59 |
+
- id: hard_004
|
| 60 |
+
difficulty: hard
|
| 61 |
+
description: 'Fix SELECT * in multi-table JOIN causing over-fetching'
|
| 62 |
+
|
| 63 |
+
- id: hard_005
|
| 64 |
+
difficulty: hard
|
| 65 |
+
description: 'Fix window function misuse with missing PARTITION BY and ORDER BY'
|
| 66 |
+
|
| 67 |
+
action_space:
|
| 68 |
+
type: discrete
|
| 69 |
+
actions:
|
| 70 |
+
- identify_error
|
| 71 |
+
- propose_fix
|
| 72 |
+
- submit_answer
|
| 73 |
+
- request_hint
|
| 74 |
+
- explain_issue
|
| 75 |
+
- optimize_query
|
| 76 |
+
|
| 77 |
+
observation_space:
|
| 78 |
+
type: dict
|
| 79 |
+
fields:
|
| 80 |
+
- task_id
|
| 81 |
+
- task_description
|
| 82 |
+
- current_context
|
| 83 |
+
- step_count
|
| 84 |
+
- difficulty
|
| 85 |
+
- max_steps
|
| 86 |
+
- hints_used
|
| 87 |
+
- previous_actions
|
| 88 |
+
|
| 89 |
+
reward:
|
| 90 |
+
min: -1.0
|
| 91 |
+
max: 1.0
|
| 92 |
+
type: dense
|
| 93 |
+
description: 'Dense reward signal at every step. Partial credit for identification, fixing, and explanation quality.'
|
| 94 |
+
|
| 95 |
+
episode:
|
| 96 |
+
max_steps: 20
|
| 97 |
+
termination: 'submit_answer or optimize_query action, or max_steps reached'
|
| 98 |
+
|
| 99 |
+
api:
|
| 100 |
+
port: 7860
|
| 101 |
+
endpoints:
|
| 102 |
+
- GET /health
|
| 103 |
+
- POST /reset
|
| 104 |
+
- POST /step
|
| 105 |
+
- GET /state
|
| 106 |
+
- GET /tasks
|
| 107 |
+
- POST /grader
|
| 108 |
+
- POST /baseline
|
requirements.txt
CHANGED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.135.2
|
| 2 |
+
uvicorn==0.42.0
|
| 3 |
+
pydantic==2.12.5
|
| 4 |
+
openai==2.30.0
|
| 5 |
+
python-dotenv==1.2.2
|
| 6 |
+
pytest==9.0.2
|
| 7 |
+
huggingface_hub==1.8.0
|
tests/__init__.py
ADDED
|
File without changes
|
tests/test_environment.py
CHANGED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from env.environment import SQLDebuggerEnvironment
|
| 3 |
+
from env.models import Action, ActionType, DifficultyLevel
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@pytest.fixture
|
| 7 |
+
def env():
|
| 8 |
+
e = SQLDebuggerEnvironment()
|
| 9 |
+
return e
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def test_state_before_reset(env):
|
| 13 |
+
"""state() before reset must not crash — returns default state."""
|
| 14 |
+
s = env.state()
|
| 15 |
+
assert s.initialized == False
|
| 16 |
+
assert s.step_count == 0
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_reset_easy(env):
|
| 20 |
+
obs = env.reset(difficulty="easy")
|
| 21 |
+
assert obs.task_id.startswith("easy")
|
| 22 |
+
assert obs.step_count == 0
|
| 23 |
+
assert obs.difficulty == DifficultyLevel.EASY
|
| 24 |
+
assert "fixed_query" not in obs.current_context
|
| 25 |
+
assert "buggy_query" in obs.current_context
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_reset_medium(env):
|
| 29 |
+
obs = env.reset(difficulty="medium")
|
| 30 |
+
assert obs.task_id.startswith("medium")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_reset_hard(env):
|
| 34 |
+
obs = env.reset(difficulty="hard")
|
| 35 |
+
assert obs.task_id.startswith("hard")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_reset_clears_state(env):
|
| 39 |
+
"""Reset mid-episode must clear all state — no leakage."""
|
| 40 |
+
env.reset(difficulty="easy")
|
| 41 |
+
action = Action(action_type=ActionType.IDENTIFY_ERROR,
|
| 42 |
+
payload={"error_location": "SELECT", "error_type": "syntax"})
|
| 43 |
+
env.step(action)
|
| 44 |
+
assert env.state().step_count == 1
|
| 45 |
+
|
| 46 |
+
# Reset mid-episode
|
| 47 |
+
env.reset(difficulty="medium")
|
| 48 |
+
assert env.state().step_count == 0
|
| 49 |
+
assert env.state().total_reward == 0.0
|
| 50 |
+
assert env.state().previous_actions == []
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_step_identify_error(env):
|
| 54 |
+
env.reset(difficulty="easy")
|
| 55 |
+
action = Action(action_type=ActionType.IDENTIFY_ERROR,
|
| 56 |
+
payload={"error_location": "SELECT clause", "error_type": "syntax",
|
| 57 |
+
"explanation": "Missing commas"})
|
| 58 |
+
resp = env.step(action)
|
| 59 |
+
assert resp.reward.score > 0
|
| 60 |
+
assert resp.done == False
|
| 61 |
+
assert resp.observation.step_count == 1
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_step_null_action(env):
|
| 65 |
+
"""Null action must return -0.1, never crash."""
|
| 66 |
+
env.reset(difficulty="easy")
|
| 67 |
+
resp = env.step(None)
|
| 68 |
+
assert resp.reward.score == -0.1
|
| 69 |
+
assert resp.done == False
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_step_after_done(env):
|
| 73 |
+
"""Step after done must not crash."""
|
| 74 |
+
env.reset(difficulty="easy", task_id="easy_001")
|
| 75 |
+
action = Action(action_type=ActionType.SUBMIT_ANSWER,
|
| 76 |
+
payload={"fixed_query": "SELECT id, name, email FROM users WHERE active = 1",
|
| 77 |
+
"explanation": "Fixed", "confidence": 0.9})
|
| 78 |
+
env.step(action)
|
| 79 |
+
assert env.state().done == True
|
| 80 |
+
|
| 81 |
+
# Step again after done
|
| 82 |
+
resp = env.step(action)
|
| 83 |
+
assert resp.done == True
|
| 84 |
+
assert "Call reset()" in resp.reward.feedback
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def test_dense_reward(env):
|
| 88 |
+
"""Reward must vary at each step — not only at end."""
|
| 89 |
+
env.reset(difficulty="easy")
|
| 90 |
+
rewards = []
|
| 91 |
+
actions = [
|
| 92 |
+
Action(action_type=ActionType.IDENTIFY_ERROR,
|
| 93 |
+
payload={"error_location": "SELECT", "error_type": "syntax"}),
|
| 94 |
+
Action(action_type=ActionType.EXPLAIN_ISSUE,
|
| 95 |
+
payload={"explanation": "Missing commas between column names in SELECT"}),
|
| 96 |
+
]
|
| 97 |
+
for a in actions:
|
| 98 |
+
r = env.step(a)
|
| 99 |
+
rewards.append(r.reward.score)
|
| 100 |
+
if r.done:
|
| 101 |
+
break
|
| 102 |
+
|
| 103 |
+
# Rewards must not all be zero
|
| 104 |
+
assert any(r != 0.0 for r in rewards)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_max_steps(env):
|
| 108 |
+
"""Episode must terminate at max_steps."""
|
| 109 |
+
env.reset(difficulty="easy")
|
| 110 |
+
action = Action(action_type=ActionType.IDENTIFY_ERROR,
|
| 111 |
+
payload={"error_location": "x", "error_type": "syntax"})
|
| 112 |
+
done = False
|
| 113 |
+
for _ in range(25):
|
| 114 |
+
resp = env.step(action)
|
| 115 |
+
if resp.done:
|
| 116 |
+
done = True
|
| 117 |
+
break
|
| 118 |
+
assert done == True
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def test_hint_injected_in_context(env):
|
| 122 |
+
"""Hint must appear in next observation after request_hint."""
|
| 123 |
+
env.reset(difficulty="easy")
|
| 124 |
+
action = Action(action_type=ActionType.REQUEST_HINT,
|
| 125 |
+
payload={"hint_type": "location"})
|
| 126 |
+
resp = env.step(action)
|
| 127 |
+
assert "last_hint" in resp.observation.current_context
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def test_state_reflects_latest_step(env):
|
| 131 |
+
"""state() must always reflect the latest step accurately."""
|
| 132 |
+
env.reset(difficulty="easy")
|
| 133 |
+
action = Action(action_type=ActionType.IDENTIFY_ERROR,
|
| 134 |
+
payload={"error_location": "SELECT", "error_type": "syntax"})
|
| 135 |
+
env.step(action)
|
| 136 |
+
s = env.state()
|
| 137 |
+
assert s.step_count == 1
|
| 138 |
+
assert s.initialized == True
|
| 139 |
+
assert "identify_error" in s.previous_actions
|
tests/test_graders.py
CHANGED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from env.models import Action, ActionType
|
| 3 |
+
from env.graders import grade, _normalize, _query_similarity
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_easy_perfect_score():
|
| 7 |
+
action = Action(
|
| 8 |
+
action_type=ActionType.SUBMIT_ANSWER,
|
| 9 |
+
payload={
|
| 10 |
+
"fixed_query": "SELECT id, name, email FROM users WHERE active = 1",
|
| 11 |
+
"explanation": "Added missing commas between column names in SELECT clause",
|
| 12 |
+
"error_type": "syntax",
|
| 13 |
+
"error_location":"SELECT clause",
|
| 14 |
+
"confidence": 0.95
|
| 15 |
+
}
|
| 16 |
+
)
|
| 17 |
+
score, breakdown, feedback = grade(action, "easy_001")
|
| 18 |
+
assert score > 0.5
|
| 19 |
+
assert 0.0 <= score <= 1.0
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_null_action_returns_zero():
|
| 23 |
+
score, breakdown, feedback = grade(None, "easy_001")
|
| 24 |
+
assert score == 0.0
|
| 25 |
+
assert "null" in feedback.lower() or "no action" in feedback.lower()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_unknown_task_returns_zero():
|
| 29 |
+
action = Action(action_type=ActionType.SUBMIT_ANSWER,
|
| 30 |
+
payload={"fixed_query": "SELECT 1", "explanation": "test"})
|
| 31 |
+
score, _, _ = grade(action, "nonexistent_task_999")
|
| 32 |
+
assert score == 0.0
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_determinism():
|
| 36 |
+
"""Same input must always return same score."""
|
| 37 |
+
action = Action(
|
| 38 |
+
action_type=ActionType.SUBMIT_ANSWER,
|
| 39 |
+
payload={
|
| 40 |
+
"fixed_query": "SELECT id, name, email FROM users WHERE active = 1",
|
| 41 |
+
"explanation": "Fixed commas",
|
| 42 |
+
"error_type": "syntax",
|
| 43 |
+
"error_location":"SELECT clause",
|
| 44 |
+
"confidence": 0.9
|
| 45 |
+
}
|
| 46 |
+
)
|
| 47 |
+
scores = [grade(action, "easy_001")[0] for _ in range(5)]
|
| 48 |
+
assert len(set(scores)) == 1
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_score_range():
|
| 52 |
+
"""All graders must return score in 0.0 - 1.0."""
|
| 53 |
+
action = Action(action_type=ActionType.SUBMIT_ANSWER,
|
| 54 |
+
payload={"fixed_query": "SELECT 1", "explanation": "test"})
|
| 55 |
+
for task_id in ["easy_001", "medium_001", "hard_001"]:
|
| 56 |
+
score, _, _ = grade(action, task_id)
|
| 57 |
+
assert 0.0 <= score <= 1.0, f"Score out of range for {task_id}: {score}"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_no_binary_graders():
|
| 61 |
+
"""Graders must not always return only 0 or only 1."""
|
| 62 |
+
payloads = [
|
| 63 |
+
{"fixed_query": "SELECT id, name, email FROM users WHERE active = 1",
|
| 64 |
+
"explanation": "Fixed", "confidence": 0.9},
|
| 65 |
+
{"fixed_query": "SELECT *", "explanation": "wrong"},
|
| 66 |
+
{"fixed_query": "", "explanation": ""},
|
| 67 |
+
]
|
| 68 |
+
for task_id in ["easy_001", "medium_001", "hard_001"]:
|
| 69 |
+
scores = set()
|
| 70 |
+
for p in payloads:
|
| 71 |
+
action = Action(action_type=ActionType.SUBMIT_ANSWER, payload=p)
|
| 72 |
+
score, _, _ = grade(action, task_id)
|
| 73 |
+
scores.add(score)
|
| 74 |
+
assert len(scores) > 1, f"Grader for {task_id} returns same score always"
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_empty_string_answer():
|
| 78 |
+
"""Empty string must return 0.0, not crash."""
|
| 79 |
+
action = Action(action_type=ActionType.SUBMIT_ANSWER,
|
| 80 |
+
payload={"fixed_query": "", "explanation": ""})
|
| 81 |
+
score, _, _ = grade(action, "easy_001")
|
| 82 |
+
assert score == 0.0 or score < 0.3
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_case_insensitive_normalization():
|
| 86 |
+
"""Grader normalizes case — UPPER and lower should score similarly."""
|
| 87 |
+
action_upper = Action(action_type=ActionType.SUBMIT_ANSWER,
|
| 88 |
+
payload={"fixed_query": "SELECT ID, NAME, EMAIL FROM USERS WHERE ACTIVE = 1",
|
| 89 |
+
"explanation": "Fixed", "confidence": 0.9})
|
| 90 |
+
action_lower = Action(action_type=ActionType.SUBMIT_ANSWER,
|
| 91 |
+
payload={"fixed_query": "select id, name, email from users where active = 1",
|
| 92 |
+
"explanation": "Fixed", "confidence": 0.9})
|
| 93 |
+
score_upper, _, _ = grade(action_upper, "easy_001")
|
| 94 |
+
score_lower, _, _ = grade(action_lower, "easy_001")
|
| 95 |
+
assert abs(score_upper - score_lower) < 0.1
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def test_whitespace_normalization():
|
| 99 |
+
"""Extra whitespace must not affect score."""
|
| 100 |
+
action = Action(action_type=ActionType.SUBMIT_ANSWER,
|
| 101 |
+
payload={"fixed_query": " SELECT id, name, email FROM users WHERE active = 1 ",
|
| 102 |
+
"explanation": "Fixed", "confidence": 0.9})
|
| 103 |
+
score, _, _ = grade(action, "easy_001")
|
| 104 |
+
assert score > 0.5
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_medium_logic_grader():
|
| 108 |
+
action = Action(
|
| 109 |
+
action_type=ActionType.SUBMIT_ANSWER,
|
| 110 |
+
payload={
|
| 111 |
+
"fixed_query": "SELECT u.id, u.name, COUNT(o.id) as order_count FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id, u.name",
|
| 112 |
+
"explanation": "Changed INNER JOIN to LEFT JOIN to include users with zero orders",
|
| 113 |
+
"error_type": "logic",
|
| 114 |
+
"error_location": "JOIN type",
|
| 115 |
+
"confidence": 0.9
|
| 116 |
+
}
|
| 117 |
+
)
|
| 118 |
+
score, _, _ = grade(action, "medium_001")
|
| 119 |
+
assert score > 0.4
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def test_hard_grader_frontier_model_range():
|
| 123 |
+
"""Hard grader must allow scores in 0.10-0.20 range for partial answers."""
|
| 124 |
+
action = Action(
|
| 125 |
+
action_type=ActionType.OPTIMIZE_QUERY,
|
| 126 |
+
payload={
|
| 127 |
+
"optimized_query": "SELECT u.id FROM users u LEFT JOIN orders o ON u.id = o.user_id",
|
| 128 |
+
"optimization_type": "Replace N+1 with JOIN",
|
| 129 |
+
"explanation": "N+1 pattern detected",
|
| 130 |
+
"confidence": 0.5
|
| 131 |
+
}
|
| 132 |
+
)
|
| 133 |
+
score, _, _ = grade(action, "hard_001")
|
| 134 |
+
assert 0.0 <= score <= 1.0
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def test_query_similarity_helper():
|
| 138 |
+
assert _query_similarity("SELECT id FROM users", "SELECT id FROM users") == 1.0
|
| 139 |
+
assert _query_similarity("", "SELECT id FROM users") < 0.5
|
| 140 |
+
assert _query_similarity("SELECT id FROM users", "") == 0.0
|