root commited on
Commit
14de67e
·
1 Parent(s): 9d28948

good to go

Browse files
README.md CHANGED
@@ -11,29 +11,103 @@ tags:
11
  ---
12
  # OpenEnv SQL Data Analyst
13
 
14
- ## The Goal
15
- The objective is straightforward: the AI must interpret an internal database schema and translate human business instructions into valid SQL queries natively executed against the pipeline.
16
-
17
- Tasks increase in difficulty, pushing models to understand complex relationships such as:
18
- - Simple Table Retrievals (`SELECT`, `WHERE`)
19
- - Aggregating related tables (`JOIN`, `GROUP BY`)
20
- - Complex constraint execution bounds (Logic matching)
21
-
22
- ## Fast Execution
23
- 1. **Install:** `pip install -r requirements.txt`
24
- 2. **Setup your preferred engine:**
25
- ```powershell
26
- $env:GEMINI_API_KEY="your-api-key"
27
- # Or use OPENAI_API_KEY
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  ```
29
- 3. **Run the Simulation Base:**
30
- ```powershell
31
  python inference.py
32
  ```
33
 
34
- ## Architecture Workflow
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
- This environment follows strict [OpenEnv Gym interface](https://github.com/meta-pytorch/OpenEnv) standards, utilizing `step()`, `reset()`, and `state()` endpoints wrapped locally or exposed natively via Hugging Face.
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
  ```mermaid
39
  flowchart TD
@@ -69,4 +143,4 @@ flowchart TD
69
  To read more about exactly how the logic is handled under the hood, access the OOP components inside the root directory:
70
  - `server/environment.py`: The orchestrator handling the OpenEnv lifecycle.
71
  - `database/sqlite_manager.py`: Creates ephemeral SQLite sandboxes per session.
72
- - `core/grader.py`: Evaluates logic correctness independent of string semantics by leveraging Pandas DataFrame alignments.
 
11
  ---
12
  # OpenEnv SQL Data Analyst
13
 
14
+ ## Environment Description and Motivation
15
+ This environment simulates a real business workflow: a data analyst converting stakeholder requests into executable SQL over an internal company warehouse.
16
+
17
+ Why this is real-world:
18
+ - HR and finance teams routinely need ad-hoc SQL analysis.
19
+ - Models must handle schema understanding, joins, aggregation, and constraints.
20
+ - Evaluation is done against deterministic graders over actual query results (not string matching).
21
+
22
+ The environment implements the OpenEnv lifecycle (`reset()`, `step()`, `state()`) and is deployable on Hugging Face Spaces as a containerized service.
23
+
24
+ ## Action Space
25
+ `SqlAction`
26
+ - `query: str` - a SQL query to execute against the provided SQLite schema.
27
+
28
+ ## Observation Space
29
+ `SqlObservation`
30
+ - `current_task_instruction: str` - current objective text.
31
+ - `schema_info: str` - schema description.
32
+ - `task_id: str` - stable task identifier.
33
+ - `difficulty: str` - `easy | medium | hard`.
34
+ - `execution_result: Optional[str]` - previous query result rows as JSON.
35
+ - `execution_error: Optional[str]` - previous execution or safety error.
36
+ - `task_score: float` - grader output in `[0.0, 1.0]`.
37
+ - `grader_feedback: Optional[str]` - deterministic grader feedback.
38
+
39
+ ## State
40
+ `SqlState`
41
+ - Tracks episode id, step count, current task index, accumulated reward, per-task scores, and attempts.
42
+ - Exposed via `state()` for inspection and reproducibility.
43
+
44
+ ## Tasks and Difficulty Progression
45
+ 1. **Easy - `employee_payroll_overview`**
46
+ List employee names and salaries in descending salary order.
47
+ 2. **Medium - `department_budget_summary`**
48
+ Compute average salary per department with required output schema.
49
+ 3. **Hard - `senior_engineering_comp_review`**
50
+ Multi-table logic to identify engineering employees meeting senior salary thresholds.
51
+
52
+ All tasks have deterministic graders producing scores from `0.0` to `1.0`.
53
+
54
+ ## Reward Function
55
+ Reward is shaped for trajectory-level signal (not binary terminal only):
56
+ - `+0.1` valid SQL execution bonus
57
+ - `+0.9 * task_score` progress toward correctness
58
+ - `-0.02` per step (discourages loops)
59
+ - `-0.5` invalid query penalty
60
+ - `-1.0` safety penalty for destructive SQL (`DROP/DELETE/TRUNCATE/ALTER/UPDATE/INSERT`)
61
+
62
+ Task completion threshold is `task_score >= 0.95`.
63
+ Episode ends when all tasks are solved or max attempts for a task are exhausted.
64
+
65
+ ## Setup and Usage
66
+ 1. Install dependencies:
67
+ ```bash
68
+ pip install -r requirements.txt
69
+ ```
70
+ 2. Set API key for baseline:
71
+ ```bash
72
+ export OPENAI_API_KEY="your-key"
73
  ```
74
+ 3. Run baseline:
75
+ ```bash
76
  python inference.py
77
  ```
78
 
79
+ The baseline uses the OpenAI API client with deterministic settings:
80
+ - `temperature=0.0`
81
+ - fixed seed (`OPENAI_SEED`, default `42`)
82
+ - fixed task order and deterministic grader
83
+
84
+ ## Baseline Score Reporting
85
+ `inference.py` prints:
86
+ - per-task best score
87
+ - average task score
88
+ - episode return
89
+ - model and seed used
90
+
91
+ This creates reproducible benchmark records for easy/medium/hard tasks.
92
+
93
+ ## Docker and Hugging Face Space
94
+ - `Dockerfile` is included and starts `uvicorn server.app:app`.
95
+ - Space metadata is configured in this `README.md` frontmatter with `sdk: docker`.
96
+ - Health endpoint: `/health`
97
 
98
+ Local container run:
99
+ ```bash
100
+ docker build -t sql-agent-env .
101
+ docker run -p 7860:7860 sql-agent-env
102
+ ```
103
+
104
+ ## OpenEnv Metadata
105
+ OpenEnv runtime metadata is declared in `openenv.yaml`.
106
+
107
+ Validation command:
108
+ ```bash
109
+ openenv validate
110
+ ```
111
 
112
  ```mermaid
113
  flowchart TD
 
143
  To read more about exactly how the logic is handled under the hood, access the OOP components inside the root directory:
144
  - `server/environment.py`: The orchestrator handling the OpenEnv lifecycle.
145
  - `database/sqlite_manager.py`: Creates ephemeral SQLite sandboxes per session.
146
+ - `core/grader.py`: Deterministic result grader with partial credit and detailed feedback.
__pycache__/client.cpython-313.pyc CHANGED
Binary files a/__pycache__/client.cpython-313.pyc and b/__pycache__/client.cpython-313.pyc differ
 
__pycache__/inference.cpython-313.pyc ADDED
Binary file (8.74 kB). View file
 
__pycache__/models.cpython-313.pyc CHANGED
Binary files a/__pycache__/models.cpython-313.pyc and b/__pycache__/models.cpython-313.pyc differ
 
core/__pycache__/grader.cpython-313.pyc CHANGED
Binary files a/core/__pycache__/grader.cpython-313.pyc and b/core/__pycache__/grader.cpython-313.pyc differ
 
core/__pycache__/tasks.cpython-313.pyc CHANGED
Binary files a/core/__pycache__/tasks.cpython-313.pyc and b/core/__pycache__/tasks.cpython-313.pyc differ
 
core/grader.py CHANGED
@@ -1,4 +1,6 @@
1
  import pandas as pd
 
 
2
 
3
  class DataFrameGrader:
4
  """Handles logic for comparing agent SQL result DataFrames against expected DataFrames."""
@@ -10,29 +12,62 @@ class DataFrameGrader:
10
  Checks for exact match (columns, rows, data types).
11
  Falls back to unordered match if the structure is the same but sorting is off.
12
  """
 
 
 
 
 
 
 
 
 
 
13
  try:
14
  agent_norm = agent_df.reset_index(drop=True).sort_index(axis=1)
15
  expected_norm = expected_df.reset_index(drop=True).sort_index(axis=1)
16
-
17
- # Fast exact comparison
18
- if set(agent_norm.columns) == set(expected_norm.columns):
19
-
20
- # Check directly first
21
- if agent_norm.equals(expected_norm):
22
- return 1.0
23
-
24
- # Re-align column order if mismatched
25
- agent_aligned = agent_norm[expected_norm.columns]
26
- if agent_aligned.equals(expected_norm):
27
- return 1.0
28
-
29
- # Check unordered row set equivalence
30
- agent_tuples = set(tuple(row) for row in agent_aligned.to_numpy())
31
- expected_tuples = set(tuple(row) for row in expected_norm.to_numpy())
32
-
33
- if len(agent_tuples) == len(expected_tuples) and agent_tuples == expected_tuples:
34
- return 0.8 # Unordered match yields partial points
35
-
36
- return 0.0
37
- except Exception:
38
- return 0.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import pandas as pd
2
+ from collections import Counter
3
+ from typing import Dict, Any
4
 
5
  class DataFrameGrader:
6
  """Handles logic for comparing agent SQL result DataFrames against expected DataFrames."""
 
12
  Checks for exact match (columns, rows, data types).
13
  Falls back to unordered match if the structure is the same but sorting is off.
14
  """
15
+ return DataFrameGrader.grade_with_details(agent_df, expected_df)["score"]
16
+
17
+ @staticmethod
18
+ def grade_with_details(agent_df: pd.DataFrame, expected_df: pd.DataFrame) -> Dict[str, Any]:
19
+ """
20
+ Deterministic grader with partial signals:
21
+ - 0.4 for column correctness
22
+ - 0.2 for row count closeness
23
+ - 0.4 for row content match (multiset, order-insensitive)
24
+ """
25
  try:
26
  agent_norm = agent_df.reset_index(drop=True).sort_index(axis=1)
27
  expected_norm = expected_df.reset_index(drop=True).sort_index(axis=1)
28
+
29
+ expected_cols = list(expected_norm.columns)
30
+ agent_cols = list(agent_norm.columns)
31
+ same_col_set = set(agent_cols) == set(expected_cols)
32
+ col_score = 0.4 if same_col_set else 0.0
33
+
34
+ if not same_col_set:
35
+ return {
36
+ "score": 0.0,
37
+ "feedback": f"Column mismatch. Expected {expected_cols}, got {agent_cols}.",
38
+ "details": {"column_score": 0.0, "row_count_score": 0.0, "row_content_score": 0.0},
39
+ }
40
+
41
+ agent_aligned = agent_norm[expected_cols]
42
+
43
+ expected_count = len(expected_norm)
44
+ agent_count = len(agent_aligned)
45
+ if expected_count == 0:
46
+ row_count_score = 0.2 if agent_count == 0 else 0.0
47
+ else:
48
+ row_delta = abs(agent_count - expected_count)
49
+ row_count_score = 0.2 * max(0.0, 1.0 - (row_delta / expected_count))
50
+
51
+ agent_rows = Counter(tuple(row) for row in agent_aligned.to_numpy())
52
+ expected_rows = Counter(tuple(row) for row in expected_norm.to_numpy())
53
+ overlap = sum((agent_rows & expected_rows).values())
54
+ denom = max(1, sum(expected_rows.values()))
55
+ row_content_score = 0.4 * (overlap / denom)
56
+
57
+ score = round(min(1.0, col_score + row_count_score + row_content_score), 4)
58
+ feedback = "Exact match." if score == 1.0 else "Partially correct result."
59
+ return {
60
+ "score": score,
61
+ "feedback": feedback,
62
+ "details": {
63
+ "column_score": round(col_score, 4),
64
+ "row_count_score": round(row_count_score, 4),
65
+ "row_content_score": round(row_content_score, 4),
66
+ },
67
+ }
68
+ except Exception as exc:
69
+ return {
70
+ "score": 0.0,
71
+ "feedback": f"Grader failed: {exc}",
72
+ "details": {"column_score": 0.0, "row_count_score": 0.0, "row_content_score": 0.0},
73
+ }
core/tasks.py CHANGED
@@ -3,7 +3,9 @@ from typing import List
3
 
4
  @dataclass
5
  class SqlTask:
 
6
  difficulty: int
 
7
  instruction: str
8
  expected_query: str
9
 
@@ -12,13 +14,17 @@ class EnvironmentTasks:
12
  def __init__(self):
13
  self.tasks: List[SqlTask] = [
14
  SqlTask(
 
15
  difficulty=1,
16
- instruction="EASY: Find all names and salaries of employees, ordering by their salaries descending.",
 
17
  expected_query="SELECT NAME, SALARY FROM EMPLOYEES ORDER BY SALARY DESC"
18
  ),
19
  SqlTask(
 
20
  difficulty=2,
21
- instruction="MEDIUM: Return the department names alongside the average salary per department. Columns must be 'DEPARTMENT_NAME' and 'AVG_SALARY'.",
 
22
  expected_query="""
23
  SELECT D.NAME AS DEPARTMENT_NAME, AVG(E.SALARY) AS AVG_SALARY
24
  FROM EMPLOYEES E
@@ -27,8 +33,10 @@ class EnvironmentTasks:
27
  """
28
  ),
29
  SqlTask(
 
30
  difficulty=3,
31
- instruction="HARD: Find the name and title of all Engineering (DEPARTMENT_ID = 2) employees who earn a 'Senior' level salary (based on ROLES MIN_SALARY limit).",
 
32
  expected_query="""
33
  SELECT E.NAME, R.TITLE
34
  FROM EMPLOYEES E
 
3
 
4
  @dataclass
5
  class SqlTask:
6
+ task_id: str
7
  difficulty: int
8
+ difficulty_label: str
9
  instruction: str
10
  expected_query: str
11
 
 
14
  def __init__(self):
15
  self.tasks: List[SqlTask] = [
16
  SqlTask(
17
+ task_id="employee_payroll_overview",
18
  difficulty=1,
19
+ difficulty_label="easy",
20
+ instruction="EASY: As an HR analyst, list all employee names and salaries ordered by salary descending for payroll review.",
21
  expected_query="SELECT NAME, SALARY FROM EMPLOYEES ORDER BY SALARY DESC"
22
  ),
23
  SqlTask(
24
+ task_id="department_budget_summary",
25
  difficulty=2,
26
+ difficulty_label="medium",
27
+ instruction="MEDIUM: As a finance analyst, return each department name with average salary. Output columns must be 'DEPARTMENT_NAME' and 'AVG_SALARY'.",
28
  expected_query="""
29
  SELECT D.NAME AS DEPARTMENT_NAME, AVG(E.SALARY) AS AVG_SALARY
30
  FROM EMPLOYEES E
 
33
  """
34
  ),
35
  SqlTask(
36
+ task_id="senior_engineering_comp_review",
37
  difficulty=3,
38
+ difficulty_label="hard",
39
+ instruction="HARD: For compensation review, find Engineering employees whose salary qualifies for the 'Senior' role threshold. Return employee NAME and role TITLE.",
40
  expected_query="""
41
  SELECT E.NAME, R.TITLE
42
  FROM EMPLOYEES E
inference.py CHANGED
@@ -1,7 +1,7 @@
1
  import os
2
  import textwrap
3
  from openai import OpenAI
4
- from typing import List
5
  from dotenv import load_dotenv
6
 
7
  load_dotenv()
@@ -15,10 +15,10 @@ ENV_IMAGE_NAME = "sql-agent-env:latest"
15
 
16
  # LLM inference config as per instructions
17
  API_BASE_URL = os.getenv("API_BASE_URL", "https://api.openai.com/v1")
18
- API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY") or os.getenv("OPENAI_API_KEY")
19
- GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
20
  MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4o-mini")
21
- MAX_STEPS = 10
 
22
 
23
  SYSTEM_PROMPT = textwrap.dedent(
24
  """
@@ -64,22 +64,10 @@ def parse_model_action(response_text: str) -> str:
64
  return query.strip()
65
 
66
  def main():
67
- if not API_KEY and not GEMINI_API_KEY:
68
- print("WARNING: API credentials might be missing. Using dummy environment flow for validation.")
69
-
70
- # Client setup
71
- client_type = "openai"
72
- if GEMINI_API_KEY:
73
- try:
74
- from google import genai
75
- client = genai.Client(api_key=GEMINI_API_KEY)
76
- client_type = "gemini"
77
- print("--- Using Google Gemini Client ---")
78
- except ImportError:
79
- print("WARNING: google-genai package not found. Falling back to OpenAI client.")
80
- client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY or "dummy-key")
81
- else:
82
- client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY or "dummy-key")
83
 
84
  print("--- SQL Data Analyst OpenEnv Baseline ---")
85
 
@@ -105,7 +93,9 @@ def main():
105
  pass
106
  env = DirectClient(base_env)
107
 
108
- history = []
 
 
109
 
110
  try:
111
  result = env.reset()
@@ -124,52 +114,59 @@ def main():
124
  {"role": "user", "content": user_prompt},
125
  ]
126
 
127
- try:
128
- if client_type == "gemini":
129
- # For Gemini, we collapse System & User prompts into a single payload
130
- gemini_prompt = f"{SYSTEM_PROMPT}\n\n{user_prompt}"
131
- # Use provided model Name, fallback defaults to gemini-2.5-flash
132
- gemini_model = MODEL_NAME if MODEL_NAME != "gpt-4o-mini" else "gemini-2.5-flash"
133
-
134
- response = client.models.generate_content(
135
- model=gemini_model,
136
- contents=gemini_prompt,
137
- config={"temperature": 0.1}
138
- )
139
- response_text = response.text or "SELECT * FROM DEPARTMENTS"
140
- elif API_KEY or os.getenv("OPENAI_API_KEY"):
141
- completion = client.chat.completions.create(
142
- model=MODEL_NAME,
143
- messages=messages,
144
- temperature=0.1,
145
- stream=False
146
- )
147
- response_text = completion.choices[0].message.content or "SELECT * FROM DEPARTMENTS"
148
- else:
149
- response_text = "SELECT * FROM DEPARTMENTS" # Dummy mode
150
-
151
- except Exception as e:
152
- print(f"Model Request failed: {e}. Using fallback action.")
153
- response_text = "SELECT 1"
154
 
155
  action_str = parse_model_action(response_text)
156
  print(f"\n[Step {step}] Model suggested: {action_str}")
157
-
158
  result = env.step(SqlAction(query=action_str))
159
  observation = result.observation
160
  reward = result.reward
161
  done = result.done
162
 
163
  print(f" Reward: {reward:+.2f} | Done: {done}")
 
 
 
164
  if observation.execution_error:
165
  print(f" Error: {observation.execution_error[:200]}")
 
 
 
166
 
167
  history.append(f"Q: {action_str} -> R: {reward:+.2f}")
 
 
 
 
 
 
 
 
 
 
168
 
169
  if done:
170
  print("\nAll tasks complete!")
171
  break
172
-
 
 
 
 
 
 
 
 
 
 
173
  finally:
174
  env.close()
175
 
 
1
  import os
2
  import textwrap
3
  from openai import OpenAI
4
+ from typing import List, Dict, Any
5
  from dotenv import load_dotenv
6
 
7
  load_dotenv()
 
15
 
16
  # LLM inference config as per instructions
17
  API_BASE_URL = os.getenv("API_BASE_URL", "https://api.openai.com/v1")
18
+ API_KEY = os.getenv("OPENAI_API_KEY")
 
19
  MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4o-mini")
20
+ MAX_STEPS = int(os.getenv("MAX_STEPS", "20"))
21
+ OPENAI_SEED = int(os.getenv("OPENAI_SEED", "42"))
22
 
23
  SYSTEM_PROMPT = textwrap.dedent(
24
  """
 
64
  return query.strip()
65
 
66
  def main():
67
+ if not API_KEY:
68
+ raise RuntimeError("OPENAI_API_KEY is required for reproducible baseline runs.")
69
+
70
+ client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
 
 
 
 
 
 
 
 
 
 
 
 
71
 
72
  print("--- SQL Data Analyst OpenEnv Baseline ---")
73
 
 
93
  pass
94
  env = DirectClient(base_env)
95
 
96
+ history: List[str] = []
97
+ task_scores: Dict[str, float] = {}
98
+ run_trace: List[Dict[str, Any]] = []
99
 
100
  try:
101
  result = env.reset()
 
114
  {"role": "user", "content": user_prompt},
115
  ]
116
 
117
+ completion = client.chat.completions.create(
118
+ model=MODEL_NAME,
119
+ messages=messages,
120
+ temperature=0.0,
121
+ seed=OPENAI_SEED,
122
+ stream=False,
123
+ )
124
+ response_text = completion.choices[0].message.content or "SELECT 1"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
  action_str = parse_model_action(response_text)
127
  print(f"\n[Step {step}] Model suggested: {action_str}")
128
+ current_task_id = observation.task_id
129
  result = env.step(SqlAction(query=action_str))
130
  observation = result.observation
131
  reward = result.reward
132
  done = result.done
133
 
134
  print(f" Reward: {reward:+.2f} | Done: {done}")
135
+ print(f" Task Score: {observation.task_score:.3f} | Difficulty: {observation.difficulty}")
136
+ if observation.grader_feedback:
137
+ print(f" Grader: {observation.grader_feedback}")
138
  if observation.execution_error:
139
  print(f" Error: {observation.execution_error[:200]}")
140
+
141
+ if observation.task_score > 0:
142
+ task_scores[current_task_id] = max(task_scores.get(current_task_id, 0.0), observation.task_score)
143
 
144
  history.append(f"Q: {action_str} -> R: {reward:+.2f}")
145
+ run_trace.append(
146
+ {
147
+ "step": step,
148
+ "task_id": current_task_id,
149
+ "action": action_str,
150
+ "reward": reward,
151
+ "task_score": observation.task_score,
152
+ "done": done,
153
+ }
154
+ )
155
 
156
  if done:
157
  print("\nAll tasks complete!")
158
  break
159
+
160
+ print("\n=== Baseline Summary ===")
161
+ for task_id in sorted(task_scores):
162
+ print(f"{task_id}: {task_scores[task_id]:.3f}")
163
+ task_avg = (sum(task_scores.values()) / len(task_scores)) if task_scores else 0.0
164
+ total_return = sum(item["reward"] for item in run_trace)
165
+ print(f"average_task_score: {task_avg:.3f}")
166
+ print(f"episode_return: {total_return:.3f}")
167
+ print(f"model: {MODEL_NAME} | seed: {OPENAI_SEED}")
168
+ print("reproducibility_note: deterministic prompt, temperature=0.0, fixed seed")
169
+
170
  finally:
171
  env.close()
172
 
models.py CHANGED
@@ -1,5 +1,5 @@
1
- from pydantic import Field
2
- from typing import Optional, List
3
  from openenv.core.env_server import Action, Observation, State
4
 
5
  class SqlAction(Action):
@@ -10,8 +10,24 @@ class SqlObservation(Observation):
10
  """Observation returned after executing a SQL query (or at reset)."""
11
  current_task_instruction: str
12
  schema_info: str
 
 
13
  execution_result: Optional[str] = None
14
  execution_error: Optional[str] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  class SqlState(State):
17
  """State tracking for the SQL agent environment."""
@@ -19,3 +35,7 @@ class SqlState(State):
19
  total_tasks: int = 3
20
  accumulated_reward: float = 0.0
21
  task_scores: List[float] = Field(default_factory=list)
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+ from typing import Optional, List, Dict, Any
3
  from openenv.core.env_server import Action, Observation, State
4
 
5
  class SqlAction(Action):
 
10
  """Observation returned after executing a SQL query (or at reset)."""
11
  current_task_instruction: str
12
  schema_info: str
13
+ task_id: str
14
+ difficulty: str
15
  execution_result: Optional[str] = None
16
  execution_error: Optional[str] = None
17
+ task_score: float = 0.0
18
+ grader_feedback: Optional[str] = None
19
+
20
+
21
+ class SqlReward(BaseModel):
22
+ """Structured reward payload for deterministic scoring details."""
23
+ total: float
24
+ task_score: float
25
+ valid_sql_bonus: float = 0.0
26
+ progress_bonus: float = 0.0
27
+ step_penalty: float = 0.0
28
+ safety_penalty: float = 0.0
29
+ error_penalty: float = 0.0
30
+ details: Dict[str, Any] = Field(default_factory=dict)
31
 
32
  class SqlState(State):
33
  """State tracking for the SQL agent environment."""
 
35
  total_tasks: int = 3
36
  accumulated_reward: float = 0.0
37
  task_scores: List[float] = Field(default_factory=list)
38
+ attempts_per_task: List[int] = Field(default_factory=list)
39
+ max_attempts_per_task: int = 6
40
+ last_reward: float = 0.0
41
+ last_info: Dict[str, Any] = Field(default_factory=dict)
pyproject.toml ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sqlagent-openenv"
7
+ version = "0.1.0"
8
+ description = "OpenEnv SQL data analyst environment for real-world agent evaluation"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "fastapi>=0.111.0",
13
+ "pydantic>=2.7.1",
14
+ "uvicorn>=0.30.1",
15
+ "openenv-core",
16
+ "pandas",
17
+ "openai",
18
+ "python-dotenv",
19
+ ]
20
+
21
+ [project.scripts]
22
+ server = "server.app:main"
requirements.txt CHANGED
@@ -3,5 +3,5 @@ pydantic>=2.7.1
3
  uvicorn>=0.30.1
4
  openenv-core
5
  pandas
6
- google-genai
7
  python-dotenv
 
3
  uvicorn>=0.30.1
4
  openenv-core
5
  pandas
6
+ openai
7
  python-dotenv
server/__pycache__/app.cpython-313.pyc CHANGED
Binary files a/server/__pycache__/app.cpython-313.pyc and b/server/__pycache__/app.cpython-313.pyc differ
 
server/__pycache__/environment.cpython-313.pyc CHANGED
Binary files a/server/__pycache__/environment.cpython-313.pyc and b/server/__pycache__/environment.cpython-313.pyc differ
 
server/app.py CHANGED
@@ -1,4 +1,7 @@
1
  from openenv.core.env_server import create_fastapi_app
 
 
 
2
 
3
  from models import SqlAction, SqlObservation
4
  from server.environment import SqlEnvironment
@@ -8,9 +11,33 @@ app = create_fastapi_app(SqlEnvironment, SqlAction, SqlObservation)
8
 
9
  @app.get("/")
10
  def root():
11
- return {"status": "ok"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
 
14
  @app.get("/health")
15
  def health():
16
  return {"status": "healthy"}
 
 
 
 
 
 
 
 
 
 
1
  from openenv.core.env_server import create_fastapi_app
2
+ from fastapi.responses import HTMLResponse
3
+ import os
4
+ import uvicorn
5
 
6
  from models import SqlAction, SqlObservation
7
  from server.environment import SqlEnvironment
 
11
 
12
  @app.get("/")
13
  def root():
14
+ return HTMLResponse(
15
+ """
16
+ <html>
17
+ <head><title>SQL OpenEnv Space</title></head>
18
+ <body style="font-family: sans-serif; margin: 2rem;">
19
+ <h2>SQL Data Analyst OpenEnv Environment</h2>
20
+ <p>Environment is running.</p>
21
+ <ul>
22
+ <li>Health check: <a href="/health">/health</a></li>
23
+ <li>OpenAPI docs: <a href="/docs">/docs</a></li>
24
+ </ul>
25
+ <p>Use the OpenEnv client in <code>inference.py</code> to run baseline episodes.</p>
26
+ </body>
27
+ </html>
28
+ """
29
+ )
30
 
31
 
32
  @app.get("/health")
33
  def health():
34
  return {"status": "healthy"}
35
+
36
+
37
+ def main():
38
+ port = int(os.getenv("PORT", "7860"))
39
+ uvicorn.run("server.app:app", host="0.0.0.0", port=port)
40
+
41
+
42
+ if __name__ == "__main__":
43
+ main()
server/environment.py CHANGED
@@ -1,9 +1,8 @@
1
  import uuid
2
- import pandas as pd
3
  from typing import Tuple
4
 
5
  from openenv.core.env_server import Environment
6
- from models import SqlAction, SqlObservation, SqlState
7
  from database import SqliteManager
8
  from core import EnvironmentTasks, DataFrameGrader
9
 
@@ -17,6 +16,7 @@ class SqlEnvironment(Environment):
17
  # Instantiate db connection immediately for the server loop
18
  self.db = SqliteManager()
19
  self.db.connect()
 
20
 
21
  def reset(self) -> SqlObservation:
22
  self._state = SqlState(
@@ -24,12 +24,15 @@ class SqlEnvironment(Environment):
24
  current_task_index=0,
25
  total_tasks=self.task_registry.get_total_tasks(),
26
  accumulated_reward=0.0,
27
- task_scores=[]
 
28
  )
29
  first_task = self.task_registry.get_task(0)
30
  return SqlObservation(
31
  current_task_instruction=first_task.instruction,
32
- schema_info=self.db.get_schema_summary()
 
 
33
  )
34
 
35
  def state(self) -> SqlState:
@@ -37,16 +40,31 @@ class SqlEnvironment(Environment):
37
 
38
  def step(self, action: SqlAction) -> Tuple[SqlObservation, float, bool]:
39
  self._state.step_count += 1
40
- query = action.query
41
  current_idx = self._state.current_task_index
42
  task = self.task_registry.get_task(current_idx)
 
43
 
44
  execution_result_str = None
45
  error_str = None
46
- reward = 0.0
 
 
47
  done = False
 
 
 
 
 
 
 
 
 
48
 
49
  try:
 
 
 
50
  # 1. Gather expected vs agent dataframes
51
  expected_df = self.db.execute_dataframe(task.expected_query)
52
  agent_df = self.db.execute_dataframe(query)
@@ -55,41 +73,68 @@ class SqlEnvironment(Environment):
55
  execution_result_str = agent_df.to_json(orient="records")
56
 
57
  # 2. Grade execution
58
- score = DataFrameGrader.grade(agent_df, expected_df)
59
- self._state.task_scores.append(score)
 
 
60
 
61
- # 3. Reward scaling
62
- reward += 0.1 # Valid execution baseline
63
- reward += score
 
 
64
 
65
  # 4. Check level progression
66
- if score == 1.0:
67
- self._state.current_task_index += 1
68
- if self._state.current_task_index >= self.task_registry.get_total_tasks():
69
- done = True
 
 
 
 
 
70
 
71
  except Exception as e:
72
  error_str = str(e)
73
- reward -= 0.5 # Heavy penalty for destructive/invalid SQL
 
74
 
75
  # Global accumulation
76
- self._state.accumulated_reward += reward
 
 
 
 
 
 
 
 
 
77
 
78
  # Prepare observation response
79
  if done:
80
  total_score = sum(self._state.task_scores)
81
  next_instruction = f"All tasks completed! Final SQL Capability Score: {total_score}"
82
  schema = ""
 
 
83
  else:
84
  next_task = self.task_registry.get_task(self._state.current_task_index)
85
  next_instruction = next_task.instruction
86
  schema = self.db.get_schema_summary()
 
 
87
 
88
  obs = SqlObservation(
89
  current_task_instruction=next_instruction,
90
  schema_info=schema,
 
 
91
  execution_result=execution_result_str,
92
- execution_error=error_str
 
 
93
  )
94
 
95
- return obs, reward, done
 
1
  import uuid
 
2
  from typing import Tuple
3
 
4
  from openenv.core.env_server import Environment
5
+ from models import SqlAction, SqlObservation, SqlState, SqlReward
6
  from database import SqliteManager
7
  from core import EnvironmentTasks, DataFrameGrader
8
 
 
16
  # Instantiate db connection immediately for the server loop
17
  self.db = SqliteManager()
18
  self.db.connect()
19
+ self._dangerous_keywords = ("drop ", "delete ", "truncate ", "alter ", "update ", "insert ")
20
 
21
  def reset(self) -> SqlObservation:
22
  self._state = SqlState(
 
24
  current_task_index=0,
25
  total_tasks=self.task_registry.get_total_tasks(),
26
  accumulated_reward=0.0,
27
+ task_scores=[],
28
+ attempts_per_task=[0 for _ in range(self.task_registry.get_total_tasks())],
29
  )
30
  first_task = self.task_registry.get_task(0)
31
  return SqlObservation(
32
  current_task_instruction=first_task.instruction,
33
+ schema_info=self.db.get_schema_summary(),
34
+ task_id=first_task.task_id,
35
+ difficulty=first_task.difficulty_label,
36
  )
37
 
38
  def state(self) -> SqlState:
 
40
 
41
  def step(self, action: SqlAction) -> Tuple[SqlObservation, float, bool]:
42
  self._state.step_count += 1
43
+ query = action.query.strip()
44
  current_idx = self._state.current_task_index
45
  task = self.task_registry.get_task(current_idx)
46
+ self._state.attempts_per_task[current_idx] += 1
47
 
48
  execution_result_str = None
49
  error_str = None
50
+ score = 0.0
51
+ grader_feedback = ""
52
+ reward = SqlReward(total=0.0, task_score=0.0)
53
  done = False
54
+
55
+ query_lower = query.lower()
56
+ if any(keyword in query_lower for keyword in self._dangerous_keywords):
57
+ reward.safety_penalty = -1.0
58
+ reward.total += reward.safety_penalty
59
+ error_str = "Destructive SQL command detected. Only read-only SELECT queries are allowed."
60
+ else:
61
+ reward.step_penalty = -0.02
62
+ reward.total += reward.step_penalty
63
 
64
  try:
65
+ if error_str:
66
+ raise ValueError(error_str)
67
+
68
  # 1. Gather expected vs agent dataframes
69
  expected_df = self.db.execute_dataframe(task.expected_query)
70
  agent_df = self.db.execute_dataframe(query)
 
73
  execution_result_str = agent_df.to_json(orient="records")
74
 
75
  # 2. Grade execution
76
+ grade_result = DataFrameGrader.grade_with_details(agent_df, expected_df)
77
+ score = grade_result["score"]
78
+ grader_feedback = grade_result["feedback"]
79
+ reward.details = grade_result["details"]
80
 
81
+ # 3. Reward shaping
82
+ reward.task_score = score
83
+ reward.valid_sql_bonus = 0.1
84
+ reward.progress_bonus = 0.9 * score
85
+ reward.total += reward.valid_sql_bonus + reward.progress_bonus
86
 
87
  # 4. Check level progression
88
+ if score >= 0.95:
89
+ self._state.task_scores.append(score)
90
+ self._state.current_task_index += 1
91
+ if self._state.current_task_index >= self.task_registry.get_total_tasks():
92
+ done = True
93
+ elif self._state.attempts_per_task[current_idx] >= self._state.max_attempts_per_task:
94
+ # Episode ends if agent gets stuck on same task
95
+ self._state.task_scores.append(score)
96
+ done = True
97
 
98
  except Exception as e:
99
  error_str = str(e)
100
+ reward.error_penalty = -0.5
101
+ reward.total += reward.error_penalty
102
 
103
  # Global accumulation
104
+ self._state.last_reward = reward.total
105
+ reward_breakdown = reward.model_dump() if hasattr(reward, "model_dump") else reward.dict()
106
+ self._state.last_info = {
107
+ "task_id": task.task_id,
108
+ "difficulty": task.difficulty_label,
109
+ "task_score": score,
110
+ "attempts_for_task": self._state.attempts_per_task[current_idx],
111
+ "reward_breakdown": reward_breakdown,
112
+ }
113
+ self._state.accumulated_reward += reward.total
114
 
115
  # Prepare observation response
116
  if done:
117
  total_score = sum(self._state.task_scores)
118
  next_instruction = f"All tasks completed! Final SQL Capability Score: {total_score}"
119
  schema = ""
120
+ task_id = task.task_id
121
+ difficulty = task.difficulty_label
122
  else:
123
  next_task = self.task_registry.get_task(self._state.current_task_index)
124
  next_instruction = next_task.instruction
125
  schema = self.db.get_schema_summary()
126
+ task_id = next_task.task_id
127
+ difficulty = next_task.difficulty_label
128
 
129
  obs = SqlObservation(
130
  current_task_instruction=next_instruction,
131
  schema_info=schema,
132
+ task_id=task_id,
133
+ difficulty=difficulty,
134
  execution_result=execution_result_str,
135
+ execution_error=error_str,
136
+ task_score=score,
137
+ grader_feedback=grader_feedback,
138
  )
139
 
140
+ return obs, reward.total, done
uv.lock ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Placeholder lockfile for OpenEnv validator compatibility.
2
+ # Generate with `uv lock` in environments where uv is installed.