text2sql-eval-toolkit 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- text2sql_eval_toolkit/__init__.py +107 -0
- text2sql_eval_toolkit/analysis/__init__.py +5 -0
- text2sql_eval_toolkit/analysis/error_analysis.py +335 -0
- text2sql_eval_toolkit/analysis/report_tools.py +719 -0
- text2sql_eval_toolkit/config_args.py +65 -0
- text2sql_eval_toolkit/data/__init__.py +4 -0
- text2sql_eval_toolkit/data/benchmarks.json +69 -0
- text2sql_eval_toolkit/data/test-benchmarks.json +69 -0
- text2sql_eval_toolkit/env_loader.py +55 -0
- text2sql_eval_toolkit/evaluation/__init__.py +26 -0
- text2sql_eval_toolkit/evaluation/evaluation_tools.py +759 -0
- text2sql_eval_toolkit/evaluation/llm_as_judge.py +90 -0
- text2sql_eval_toolkit/execution/__init__.py +5 -0
- text2sql_eval_toolkit/execution/execution_tools.py +1448 -0
- text2sql_eval_toolkit/execution/replace_select_tool.py +114 -0
- text2sql_eval_toolkit/inference/__init__.py +5 -0
- text2sql_eval_toolkit/inference/agentic_pipeline.py +2335 -0
- text2sql_eval_toolkit/inference/base_pipeline.py +11 -0
- text2sql_eval_toolkit/inference/baseline_llm_pipeline.py +372 -0
- text2sql_eval_toolkit/inference/inference_tools.py +769 -0
- text2sql_eval_toolkit/logging.py +54 -0
- text2sql_eval_toolkit/profiling/profiling_tools.py +185 -0
- text2sql_eval_toolkit/utils.py +302 -0
- text2sql_eval_toolkit-1.0.0.dist-info/METADATA +382 -0
- text2sql_eval_toolkit-1.0.0.dist-info/RECORD +28 -0
- text2sql_eval_toolkit-1.0.0.dist-info/WHEEL +5 -0
- text2sql_eval_toolkit-1.0.0.dist-info/licenses/LICENSE +201 -0
- text2sql_eval_toolkit-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2335 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright IBM Corp. 2025 - 2026
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
#
|
|
5
|
+
|
|
6
|
+
"""
|
|
7
|
+
Agentic pipeline for text-to-SQL generation using LangGraph.
|
|
8
|
+
|
|
9
|
+
This module provides an agentic pipeline that:
|
|
10
|
+
- Generates SQL queries from natural language questions
|
|
11
|
+
- Executes queries to check for errors
|
|
12
|
+
- Probes database schema when needed
|
|
13
|
+
- Fixes errors and retries up to a maximum number of attempts
|
|
14
|
+
- Validates results for correctness
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import time
|
|
21
|
+
from typing import Any, Dict, List, Optional, TypedDict, Annotated
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
import pandas as pd
|
|
24
|
+
|
|
25
|
+
# Note: LangGraph imports are available but we're using a simpler state machine approach
|
|
26
|
+
# from langgraph.graph import StateGraph, END
|
|
27
|
+
# from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
|
|
28
|
+
from text2sql_eval_toolkit.logging import get_logger
|
|
29
|
+
from text2sql_eval_toolkit.inference.base_pipeline import BasePipeline
|
|
30
|
+
from text2sql_eval_toolkit.inference.inference_tools import (
|
|
31
|
+
Text2SQLPrompt,
|
|
32
|
+
WXAIClientChatAPI,
|
|
33
|
+
VLLMClientChatAPI,
|
|
34
|
+
ClaudeClientChatAPI,
|
|
35
|
+
OpenAIClientChatAPI,
|
|
36
|
+
postprocess_sql,
|
|
37
|
+
)
|
|
38
|
+
from text2sql_eval_toolkit.utils import (
|
|
39
|
+
get_benchmark_info,
|
|
40
|
+
get_question_id,
|
|
41
|
+
get_utterance,
|
|
42
|
+
)
|
|
43
|
+
from text2sql_eval_toolkit.execution.execution_tools import (
|
|
44
|
+
run_sql_and_get_dataframe_async,
|
|
45
|
+
run_sqlite_query_with_timeout,
|
|
46
|
+
run_sql_and_get_dataframe_mysql_async,
|
|
47
|
+
normalize_mysql_connection_string,
|
|
48
|
+
quote_mixed_case_columns,
|
|
49
|
+
quote_mysql_identifiers,
|
|
50
|
+
)
|
|
51
|
+
import asyncpg
|
|
52
|
+
import sqlite3
|
|
53
|
+
from func_timeout import func_timeout, FunctionTimedOut
|
|
54
|
+
|
|
55
|
+
logger = get_logger(__name__)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AgentState(TypedDict):
|
|
59
|
+
"""State for the agentic SQL generation pipeline."""
|
|
60
|
+
|
|
61
|
+
question: str
|
|
62
|
+
schema: dict
|
|
63
|
+
db_type: str
|
|
64
|
+
db_id: Optional[str]
|
|
65
|
+
db_connection_info: dict
|
|
66
|
+
attempt: int
|
|
67
|
+
max_attempts: int
|
|
68
|
+
sql_history: List[str]
|
|
69
|
+
error_history: List[str]
|
|
70
|
+
current_sql: Optional[str]
|
|
71
|
+
execution_result: Optional[dict]
|
|
72
|
+
execution_error: Optional[str]
|
|
73
|
+
schema_probes: List[str]
|
|
74
|
+
reasoning: List[str]
|
|
75
|
+
final_sql: Optional[str]
|
|
76
|
+
final_df: Optional[str]
|
|
77
|
+
messages: Annotated[List[Any], "messages"]
|
|
78
|
+
llm_judge_verdict: Optional[str] # For v3: ACCEPT or RETRY
|
|
79
|
+
llm_judge_confidence: Optional[str] # For v3: HIGH, MEDIUM, or LOW
|
|
80
|
+
llm_judge_reasoning: Optional[str] # For v3: LLM judge explanation
|
|
81
|
+
agent_trace: List[dict] # Full trace of all LLM interactions
|
|
82
|
+
token_usage_per_attempt: List[dict] # Token usage for each attempt
|
|
83
|
+
total_token_usage: dict # Aggregated token usage
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class DatabaseExecutor:
|
|
87
|
+
"""Handles database query execution for different database types."""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self, db_type: str, db_connection_info: dict, db_id: Optional[str] = None
|
|
91
|
+
):
|
|
92
|
+
self.db_type = db_type
|
|
93
|
+
self.db_connection_info = db_connection_info
|
|
94
|
+
self.db_id = db_id
|
|
95
|
+
self._pool = None
|
|
96
|
+
self._normalized_conn_str = None
|
|
97
|
+
self._connect_args = None
|
|
98
|
+
|
|
99
|
+
async def initialize(self):
|
|
100
|
+
"""Initialize database connections if needed."""
|
|
101
|
+
try:
|
|
102
|
+
if self.db_type == "postgres":
|
|
103
|
+
connection_string = os.getenv(
|
|
104
|
+
self.db_connection_info.get("connection_string_env_var")
|
|
105
|
+
)
|
|
106
|
+
schema_name = self.db_connection_info.get("schema_name")
|
|
107
|
+
if connection_string:
|
|
108
|
+
self._pool = await asyncpg.create_pool(
|
|
109
|
+
dsn=connection_string, min_size=1, max_size=1
|
|
110
|
+
)
|
|
111
|
+
self._schema_name = schema_name
|
|
112
|
+
else:
|
|
113
|
+
logger.warning(
|
|
114
|
+
"PostgreSQL connection string not found in environment variables"
|
|
115
|
+
)
|
|
116
|
+
elif self.db_type == "mysql":
|
|
117
|
+
connection_string = os.getenv(
|
|
118
|
+
self.db_connection_info.get("connection_string_env_var")
|
|
119
|
+
)
|
|
120
|
+
if connection_string:
|
|
121
|
+
self._normalized_conn_str, self._connect_args = (
|
|
122
|
+
normalize_mysql_connection_string(connection_string, self.db_id)
|
|
123
|
+
)
|
|
124
|
+
else:
|
|
125
|
+
logger.warning(
|
|
126
|
+
"MySQL connection string not found in environment variables"
|
|
127
|
+
)
|
|
128
|
+
elif self.db_type == "sqlite":
|
|
129
|
+
db_folder = self.db_connection_info.get("db_folder")
|
|
130
|
+
if db_folder and self.db_id:
|
|
131
|
+
db_filename = self.db_id + ".sqlite"
|
|
132
|
+
from text2sql_eval_toolkit.utils import BENCHMARKS_FILE
|
|
133
|
+
|
|
134
|
+
self._db_path = (
|
|
135
|
+
Path(BENCHMARKS_FILE).parent
|
|
136
|
+
/ Path(db_folder)
|
|
137
|
+
/ self.db_id
|
|
138
|
+
/ db_filename
|
|
139
|
+
)
|
|
140
|
+
if not self._db_path.exists():
|
|
141
|
+
logger.warning(f"SQLite database not found at {self._db_path}")
|
|
142
|
+
else:
|
|
143
|
+
logger.warning("SQLite db_folder or db_id not provided")
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logger.error(f"Error initializing database connection: {e}")
|
|
146
|
+
raise
|
|
147
|
+
|
|
148
|
+
async def execute_query(self, sql: str) -> dict:
|
|
149
|
+
"""
|
|
150
|
+
Execute a SQL query and return results or error.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
dict with keys:
|
|
154
|
+
- success: bool
|
|
155
|
+
- df: pandas DataFrame (if success)
|
|
156
|
+
- error: str (if not success)
|
|
157
|
+
- row_count: int (if success)
|
|
158
|
+
- execution_time_ms: float (time taken to execute query)
|
|
159
|
+
"""
|
|
160
|
+
execution_start = time.perf_counter()
|
|
161
|
+
try:
|
|
162
|
+
if self.db_type == "postgres":
|
|
163
|
+
if not self._pool:
|
|
164
|
+
await self.initialize()
|
|
165
|
+
async with self._pool.acquire() as conn:
|
|
166
|
+
await conn.execute(f"SET search_path TO {self._schema_name}")
|
|
167
|
+
rows = await conn.fetch(sql)
|
|
168
|
+
if rows:
|
|
169
|
+
columns = rows[0].keys()
|
|
170
|
+
else:
|
|
171
|
+
columns = []
|
|
172
|
+
data = [dict(row) for row in rows]
|
|
173
|
+
df = await asyncio.to_thread(pd.DataFrame, data, columns=columns)
|
|
174
|
+
execution_end = time.perf_counter()
|
|
175
|
+
execution_time_ms = (execution_end - execution_start) * 1000
|
|
176
|
+
return {
|
|
177
|
+
"success": True,
|
|
178
|
+
"df": df,
|
|
179
|
+
"row_count": len(df),
|
|
180
|
+
"error": None,
|
|
181
|
+
"execution_time_ms": round(execution_time_ms, 2),
|
|
182
|
+
}
|
|
183
|
+
elif self.db_type == "mysql":
|
|
184
|
+
if not self._normalized_conn_str:
|
|
185
|
+
await self.initialize()
|
|
186
|
+
sql = quote_mysql_identifiers(sql)
|
|
187
|
+
df = await run_sql_and_get_dataframe_mysql_async(
|
|
188
|
+
self._normalized_conn_str, self._connect_args, self.db_id, sql
|
|
189
|
+
)
|
|
190
|
+
execution_end = time.perf_counter()
|
|
191
|
+
execution_time_ms = (execution_end - execution_start) * 1000
|
|
192
|
+
return {
|
|
193
|
+
"success": True,
|
|
194
|
+
"df": df,
|
|
195
|
+
"row_count": len(df),
|
|
196
|
+
"error": None,
|
|
197
|
+
"execution_time_ms": round(execution_time_ms, 2),
|
|
198
|
+
}
|
|
199
|
+
elif self.db_type == "sqlite":
|
|
200
|
+
if not hasattr(self, "_db_path"):
|
|
201
|
+
await self.initialize()
|
|
202
|
+
df = await run_sqlite_query_with_timeout(self._db_path, sql, timeout=30)
|
|
203
|
+
execution_end = time.perf_counter()
|
|
204
|
+
execution_time_ms = (execution_end - execution_start) * 1000
|
|
205
|
+
return {
|
|
206
|
+
"success": True,
|
|
207
|
+
"df": df,
|
|
208
|
+
"row_count": len(df),
|
|
209
|
+
"error": None,
|
|
210
|
+
"execution_time_ms": round(execution_time_ms, 2),
|
|
211
|
+
}
|
|
212
|
+
else:
|
|
213
|
+
return {
|
|
214
|
+
"success": False,
|
|
215
|
+
"df": None,
|
|
216
|
+
"row_count": 0,
|
|
217
|
+
"error": f"Unsupported database type: {self.db_type}",
|
|
218
|
+
"execution_time_ms": None,
|
|
219
|
+
}
|
|
220
|
+
except Exception as e:
|
|
221
|
+
error_msg = str(e)
|
|
222
|
+
logger.debug(f"SQL execution error: {error_msg}")
|
|
223
|
+
execution_end = time.perf_counter()
|
|
224
|
+
execution_time_ms = (execution_end - execution_start) * 1000
|
|
225
|
+
return {
|
|
226
|
+
"success": False,
|
|
227
|
+
"df": None,
|
|
228
|
+
"row_count": 0,
|
|
229
|
+
"error": error_msg,
|
|
230
|
+
"execution_time_ms": round(execution_time_ms, 2),
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async def probe_schema(self, query_type: str = "tables") -> dict:
|
|
234
|
+
"""
|
|
235
|
+
Probe the database schema.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
query_type: Type of probe - "tables", "columns", "sample"
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
dict with schema information
|
|
242
|
+
"""
|
|
243
|
+
try:
|
|
244
|
+
if query_type == "tables":
|
|
245
|
+
if self.db_type == "postgres":
|
|
246
|
+
sql = """
|
|
247
|
+
SELECT table_name
|
|
248
|
+
FROM information_schema.tables
|
|
249
|
+
WHERE table_schema = current_schema()
|
|
250
|
+
ORDER BY table_name;
|
|
251
|
+
"""
|
|
252
|
+
elif self.db_type == "mysql":
|
|
253
|
+
sql = "SHOW TABLES;"
|
|
254
|
+
elif self.db_type == "sqlite":
|
|
255
|
+
sql = "SELECT name FROM sqlite_master WHERE type='table';"
|
|
256
|
+
else:
|
|
257
|
+
return {
|
|
258
|
+
"success": False,
|
|
259
|
+
"error": f"Unsupported db_type: {self.db_type}",
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
result = await self.execute_query(sql)
|
|
263
|
+
if result["success"]:
|
|
264
|
+
return {"success": True, "tables": result["df"].to_dict("records")}
|
|
265
|
+
return result
|
|
266
|
+
elif query_type == "columns":
|
|
267
|
+
# Get columns for all tables
|
|
268
|
+
tables_result = await self.probe_schema("tables")
|
|
269
|
+
if not tables_result["success"]:
|
|
270
|
+
return tables_result
|
|
271
|
+
|
|
272
|
+
all_columns = {}
|
|
273
|
+
for table_info in tables_result["tables"]:
|
|
274
|
+
# Extract table name based on database type
|
|
275
|
+
if isinstance(table_info, dict):
|
|
276
|
+
table_name = table_info.get("table_name") or table_info.get(
|
|
277
|
+
"name"
|
|
278
|
+
)
|
|
279
|
+
# MySQL SHOW TABLES returns dict with key like "Tables_in_database"
|
|
280
|
+
if not table_name:
|
|
281
|
+
for key in table_info.keys():
|
|
282
|
+
if key.startswith("Tables_in"):
|
|
283
|
+
table_name = table_info[key]
|
|
284
|
+
break
|
|
285
|
+
else:
|
|
286
|
+
table_name = str(table_info)
|
|
287
|
+
|
|
288
|
+
if not table_name:
|
|
289
|
+
continue
|
|
290
|
+
|
|
291
|
+
if self.db_type == "postgres":
|
|
292
|
+
sql = f"""
|
|
293
|
+
SELECT column_name, data_type, is_nullable
|
|
294
|
+
FROM information_schema.columns
|
|
295
|
+
WHERE table_schema = current_schema() AND table_name = '{table_name}'
|
|
296
|
+
ORDER BY ordinal_position;
|
|
297
|
+
"""
|
|
298
|
+
elif self.db_type == "mysql":
|
|
299
|
+
sql = f"DESCRIBE `{table_name}`;"
|
|
300
|
+
elif self.db_type == "sqlite":
|
|
301
|
+
sql = f"PRAGMA table_info({table_name});"
|
|
302
|
+
else:
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
result = await self.execute_query(sql)
|
|
306
|
+
if result["success"]:
|
|
307
|
+
all_columns[table_name] = result["df"].to_dict("records")
|
|
308
|
+
|
|
309
|
+
return {"success": True, "columns": all_columns}
|
|
310
|
+
else:
|
|
311
|
+
return {"success": False, "error": f"Unknown query_type: {query_type}"}
|
|
312
|
+
except Exception as e:
|
|
313
|
+
return {"success": False, "error": str(e)}
|
|
314
|
+
|
|
315
|
+
async def close(self):
|
|
316
|
+
"""Close database connections."""
|
|
317
|
+
if self._pool:
|
|
318
|
+
await self._pool.close()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class AgenticSQLGenerationPipeline(BasePipeline):
|
|
322
|
+
"""
|
|
323
|
+
Agentic pipeline for SQL generation with error recovery and schema probing.
|
|
324
|
+
|
|
325
|
+
Supports multiple versions (v0-v5):
|
|
326
|
+
- v0: Agent-aware prompts with basic retry logic (agentic-baseline0)
|
|
327
|
+
- v1: Baseline-compatible prompts with retry logic (agentic-baseline1)
|
|
328
|
+
- v2: Smart retry logic with error classification (agentic-baseline2)
|
|
329
|
+
- v3: LLM judge validation for semantic correctness (agentic-baseline3)
|
|
330
|
+
- v4: Truly agentic with LLM-controlled workflow (agentic-baseline4)
|
|
331
|
+
- v5: Truly agentic with improved prompting and systematic reasoning (agentic-baseline5)
|
|
332
|
+
"""
|
|
333
|
+
|
|
334
|
+
def __init__(
|
|
335
|
+
self,
|
|
336
|
+
max_attempts: int = 3,
|
|
337
|
+
use_baseline_prompt: bool = False,
|
|
338
|
+
version: str = "v1",
|
|
339
|
+
):
|
|
340
|
+
"""
|
|
341
|
+
Initialize the agentic pipeline.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
max_attempts: Maximum number of attempts to generate SQL
|
|
345
|
+
use_baseline_prompt: If True, use baseline-compatible prompt for first attempt.
|
|
346
|
+
If False, use agent-aware prompt from the start.
|
|
347
|
+
version: Pipeline version:
|
|
348
|
+
- "v0": Agent-aware prompts (agentic-baseline0)
|
|
349
|
+
- "v1": Baseline-compatible prompts (agentic-baseline1)
|
|
350
|
+
- "v2": Smart retry logic with error classification (agentic-baseline2)
|
|
351
|
+
- "v3": LLM judge validation (agentic-baseline3)
|
|
352
|
+
- "v4": Truly agentic with LLM-controlled tools (agentic-baseline4)
|
|
353
|
+
- "v5": Truly agentic with improved prompting (agentic-baseline5)
|
|
354
|
+
"""
|
|
355
|
+
super().__init__()
|
|
356
|
+
self.max_attempts = max_attempts
|
|
357
|
+
self.use_baseline_prompt = use_baseline_prompt
|
|
358
|
+
self.version = version
|
|
359
|
+
|
|
360
|
+
def _create_llm_client(self, model_name: str, model_parameters: dict):
|
|
361
|
+
"""Create an LLM client based on model name."""
|
|
362
|
+
if model_name.startswith("wxai:"):
|
|
363
|
+
return WXAIClientChatAPI(model_name[5:], model_parameters)
|
|
364
|
+
elif model_name.startswith("anthropic:"):
|
|
365
|
+
return ClaudeClientChatAPI(model_name[10:], model_parameters)
|
|
366
|
+
elif model_name.startswith("vllm:"):
|
|
367
|
+
return VLLMClientChatAPI(model_name[5:], model_parameters)
|
|
368
|
+
elif model_name.startswith("openai:"):
|
|
369
|
+
return OpenAIClientChatAPI(model_name[7:], model_parameters)
|
|
370
|
+
elif model_name.startswith("rits"):
|
|
371
|
+
logger.info(f"Getting RITS model endpoint for {model_name}")
|
|
372
|
+
model_id = model_name.split("/")[-1].replace(".", "-").lower()
|
|
373
|
+
rits_api_key = os.environ.get("RITS_API_KEY")
|
|
374
|
+
if rits_api_key is None:
|
|
375
|
+
raise ValueError("Missing RITS_API_KEY environment variable")
|
|
376
|
+
os.environ["VLLM_API_BASE"] = (
|
|
377
|
+
f"https://inference-3scale-apicast-production.apps.rits.fmaas.res.ibm.com/{model_id}/v1"
|
|
378
|
+
)
|
|
379
|
+
return VLLMClientChatAPI(model_name[5:], model_parameters)
|
|
380
|
+
else:
|
|
381
|
+
raise NotImplementedError(f"Model {model_name} is not supported.")
|
|
382
|
+
|
|
383
|
+
def _build_v4_system_prompt(self, state: AgentState) -> str:
|
|
384
|
+
"""
|
|
385
|
+
Build system prompt for v4 (truly agentic) pipeline.
|
|
386
|
+
|
|
387
|
+
This prompt instructs the LLM on how to use tools to solve the task.
|
|
388
|
+
"""
|
|
389
|
+
schema_str = self._verbalize_schema(state["schema"])
|
|
390
|
+
|
|
391
|
+
system_prompt = f"""You are an expert SQL agent tasked with converting natural language questions into SQL queries.
|
|
392
|
+
|
|
393
|
+
You have access to the following tools that let you interact with the database:
|
|
394
|
+
|
|
395
|
+
1. **probe_schema**(table_name: str, reason: str)
|
|
396
|
+
- Query the database to get detailed schema information about a specific table
|
|
397
|
+
- Use when you need to know exact column names, data types, or sample data
|
|
398
|
+
|
|
399
|
+
2. **generate_sql**(sql: str, reasoning: str)
|
|
400
|
+
- Generate and execute a SQL query
|
|
401
|
+
- The query will be automatically executed and you'll get results or errors
|
|
402
|
+
|
|
403
|
+
3. **analyze_error**(analysis: str, fixable: bool)
|
|
404
|
+
- Analyze an error from a failed SQL query
|
|
405
|
+
- Determine what went wrong and if it can be fixed
|
|
406
|
+
|
|
407
|
+
4. **submit_final_answer**(sql: str, confidence: "high"|"medium"|"low", explanation: str)
|
|
408
|
+
- Submit your final SQL query
|
|
409
|
+
- Use when you have a working query or have exhausted attempts
|
|
410
|
+
|
|
411
|
+
**Database Schema:**
|
|
412
|
+
{schema_str}
|
|
413
|
+
|
|
414
|
+
**Instructions:**
|
|
415
|
+
1. Think step-by-step using a ReAct (Reasoning + Acting) approach
|
|
416
|
+
2. You must respond with ONLY a JSON object in this exact format:
|
|
417
|
+
{{
|
|
418
|
+
"thought": "Your reasoning about what to do next",
|
|
419
|
+
"action": "tool_name",
|
|
420
|
+
"action_input": {{...tool parameters...}}
|
|
421
|
+
}}
|
|
422
|
+
|
|
423
|
+
3. After each action, you'll receive the result and can choose your next action
|
|
424
|
+
4. You have {state["max_attempts"]} total attempts - use them wisely
|
|
425
|
+
5. Always start by generating SQL unless you're missing critical schema information
|
|
426
|
+
|
|
427
|
+
**Example Response Format:**
|
|
428
|
+
{{
|
|
429
|
+
"thought": "I need to find the average salary by department. The schema shows a salary column in the employees table and a department_id. I'll write a GROUP BY query.",
|
|
430
|
+
"action": "generate_sql",
|
|
431
|
+
"action_input": {{
|
|
432
|
+
"sql": "SELECT department_id, AVG(salary) FROM employees GROUP BY department_id",
|
|
433
|
+
"reasoning": "Grouping by department and calculating average salary"
|
|
434
|
+
}}
|
|
435
|
+
}}
|
|
436
|
+
|
|
437
|
+
Remember: Respond ONLY with valid JSON. No extra text before or after."""
|
|
438
|
+
|
|
439
|
+
return system_prompt
|
|
440
|
+
|
|
441
|
+
def _build_v5_system_prompt(self, state: AgentState) -> str:
|
|
442
|
+
"""
|
|
443
|
+
Build system prompt for v5 (improved agentic) pipeline.
|
|
444
|
+
|
|
445
|
+
V5 improvements:
|
|
446
|
+
- Emphasizes multi-step reasoning
|
|
447
|
+
- Mandates schema probing
|
|
448
|
+
- Adds column selection validation
|
|
449
|
+
- Discourages rushing
|
|
450
|
+
- Includes process checklist
|
|
451
|
+
"""
|
|
452
|
+
schema_str = self._verbalize_schema(state["schema"])
|
|
453
|
+
|
|
454
|
+
system_prompt = f"""You are an expert SQL agent tasked with converting natural language questions into SQL queries.
|
|
455
|
+
|
|
456
|
+
⚠️ IMPORTANT: Take a systematic, multi-step approach. Submitting on your first try is rarely optimal - you have {state["max_attempts"]} attempts, use them to explore, validate, and refine!
|
|
457
|
+
|
|
458
|
+
**SYSTEMATIC PROCESS (Follow These Steps):**
|
|
459
|
+
|
|
460
|
+
1. **ANALYZE** - Understand exactly what the question is asking
|
|
461
|
+
- What specific data is requested?
|
|
462
|
+
- What is the expected format of the answer?
|
|
463
|
+
- Are there any implicit requirements (e.g., "which month" means return ONLY month, not full date)?
|
|
464
|
+
|
|
465
|
+
2. **EXPLORE** - Use probe_schema to understand the database
|
|
466
|
+
- ALWAYS probe relevant tables before generating SQL
|
|
467
|
+
- Understand column formats (e.g., is Date stored as YYYYMM string or timestamp?)
|
|
468
|
+
- Verify table/column names and data types
|
|
469
|
+
- Check for sample data if unsure
|
|
470
|
+
|
|
471
|
+
3. **PLAN** - Think through your SQL logic
|
|
472
|
+
- What tables do I need?
|
|
473
|
+
- What joins are required?
|
|
474
|
+
- What filters/aggregations?
|
|
475
|
+
- **CRITICAL:** What columns should the SELECT return? (No extras!)
|
|
476
|
+
|
|
477
|
+
4. **GENERATE** - Create the SQL query
|
|
478
|
+
- Write clean, correct SQL
|
|
479
|
+
- Match the database dialect
|
|
480
|
+
|
|
481
|
+
5. **VALIDATE** - Before submitting, check:
|
|
482
|
+
☐ Does SELECT return ONLY what the question asks for?
|
|
483
|
+
☐ No extra columns (like intermediate calculations)?
|
|
484
|
+
☐ Correct granularity (e.g., month vs full date)?
|
|
485
|
+
☐ Proper aggregation level?
|
|
486
|
+
☐ Correct ordering if question asks for "first", "highest", "least", etc.?
|
|
487
|
+
|
|
488
|
+
6. **SUBMIT** - Only when confident
|
|
489
|
+
|
|
490
|
+
**Available Tools:**
|
|
491
|
+
|
|
492
|
+
1. **probe_schema**(table_name: str, reason: str)
|
|
493
|
+
- Query database for detailed schema information about a table
|
|
494
|
+
- **USE THIS FIRST** before generating SQL
|
|
495
|
+
- Helps understand column formats, types, and sample data
|
|
496
|
+
|
|
497
|
+
2. **generate_sql**(sql: str, reasoning: str)
|
|
498
|
+
- Generate and execute a SQL query
|
|
499
|
+
- Will be automatically executed and you'll get results or errors
|
|
500
|
+
|
|
501
|
+
3. **analyze_error**(analysis: str, fixable: bool)
|
|
502
|
+
- Analyze errors from failed SQL queries
|
|
503
|
+
- Determine what went wrong and if it's fixable
|
|
504
|
+
|
|
505
|
+
4. **submit_final_answer**(sql: str, confidence: "high"|"medium"|"low", explanation: str)
|
|
506
|
+
- Submit your final SQL query
|
|
507
|
+
- Only use after validation or when attempts exhausted
|
|
508
|
+
|
|
509
|
+
**Database Schema (High-Level Overview):**
|
|
510
|
+
{schema_str}
|
|
511
|
+
|
|
512
|
+
**Column Selection Rules (CRITICAL):**
|
|
513
|
+
- If question asks "which month?", return ONLY the month column (not month + count/consumption)
|
|
514
|
+
- If question asks "who?", return ONLY the identifier (CustomerID, not CustomerID + aggregate)
|
|
515
|
+
- If question asks "how many?", return ONLY the count (not count + other columns)
|
|
516
|
+
- Match the question's specificity exactly
|
|
517
|
+
- Use SUBSTR, DATE functions as needed to extract specific parts (e.g., month from YYYYMM)
|
|
518
|
+
|
|
519
|
+
**Response Format:**
|
|
520
|
+
You MUST respond with ONLY a JSON object:
|
|
521
|
+
{{
|
|
522
|
+
"thought": "Before taking action, I need to [what I'm trying to learn/accomplish]. This will help me [why this is the right step].",
|
|
523
|
+
"action": "tool_name",
|
|
524
|
+
"action_input": {{...tool parameters...}}
|
|
525
|
+
}}
|
|
526
|
+
|
|
527
|
+
**Example Good Flow:**
|
|
528
|
+
Step 1:
|
|
529
|
+
{{
|
|
530
|
+
"thought": "The question asks about 'peak month for SME customers in 2013'. Before generating SQL, I need to understand how the Date column is formatted in the yearmonth table - is it YYYYMM string, full timestamp, or separate year/month columns? This will help me write the correct date extraction logic.",
|
|
531
|
+
"action": "probe_schema",
|
|
532
|
+
"action_input": {{
|
|
533
|
+
"table_name": "yearmonth",
|
|
534
|
+
"reason": "Need to understand Date column format to extract month correctly"
|
|
535
|
+
}}
|
|
536
|
+
}}
|
|
537
|
+
|
|
538
|
+
Step 2 (after seeing Date is YYYYMM string):
|
|
539
|
+
{{
|
|
540
|
+
"thought": "Now I know Date is stored as YYYYMM string like '201301'. The question asks 'what was the peak month' so I need to return ONLY the month (MM), not the full date or consumption amount. I'll use SUBSTR(Date, 5, 2) to extract just the month digits, filter for SME and 2013, group by month, order by total consumption DESC, and LIMIT 1.",
|
|
541
|
+
"action": "generate_sql",
|
|
542
|
+
"action_input": {{
|
|
543
|
+
"sql": "SELECT SUBSTR(Date, 5, 2) AS month FROM yearmonth ym JOIN customers c ON ym.CustomerID = c.CustomerID WHERE c.Segment = 'SME' AND SUBSTR(Date, 1, 4) = '2013' GROUP BY SUBSTR(Date, 5, 2) ORDER BY SUM(ym.Consumption) DESC LIMIT 1",
|
|
544
|
+
"reasoning": "Extract only month (MM) from YYYYMM date string, filter for SME in 2013, find month with highest total consumption. Returns only month as requested."
|
|
545
|
+
}}
|
|
546
|
+
}}
|
|
547
|
+
|
|
548
|
+
Step 3 (after SQL executes successfully):
|
|
549
|
+
{{
|
|
550
|
+
"thought": "The SQL executed successfully and returned the month. Let me verify: (1) Does it return only the month column? Yes. (2) Is the logic correct for finding the peak? Yes, I'm grouping by month and ordering by SUM(Consumption) DESC with LIMIT 1. (3) Are filters correct? Yes, SME segment and year 2013. I'm confident this is correct.",
|
|
551
|
+
"action": "submit_final_answer",
|
|
552
|
+
"action_input": {{
|
|
553
|
+
"sql": "SELECT SUBSTR(Date, 5, 2) AS month FROM yearmonth ym JOIN customers c ON ym.CustomerID = c.CustomerID WHERE c.Segment = 'SME' AND SUBSTR(Date, 1, 4) = '2013' GROUP BY SUBSTR(Date, 5, 2) ORDER BY SUM(ym.Consumption) DESC LIMIT 1",
|
|
554
|
+
"confidence": "high",
|
|
555
|
+
"explanation": "Returns only the month (MM) with highest SME consumption in 2013, correctly handling YYYYMM date format"
|
|
556
|
+
}}
|
|
557
|
+
}}
|
|
558
|
+
|
|
559
|
+
⚠️ Remember:
|
|
560
|
+
- ALWAYS probe schema first
|
|
561
|
+
- Think carefully about what columns to return
|
|
562
|
+
- Don't rush - validate before submitting
|
|
563
|
+
- Respond ONLY with valid JSON, no extra text"""
|
|
564
|
+
|
|
565
|
+
return system_prompt
|
|
566
|
+
|
|
567
|
+
def _get_v4_tools(self) -> List[dict]:
|
|
568
|
+
"""
|
|
569
|
+
Define tools available to the LLM for v4 (truly agentic) pipeline.
|
|
570
|
+
|
|
571
|
+
These tools give the LLM control over the workflow.
|
|
572
|
+
"""
|
|
573
|
+
return [
|
|
574
|
+
{
|
|
575
|
+
"type": "function",
|
|
576
|
+
"function": {
|
|
577
|
+
"name": "generate_sql",
|
|
578
|
+
"description": "Generate a SQL query to answer the user's question. Use this when you have enough information about the database schema and are ready to write SQL.",
|
|
579
|
+
"parameters": {
|
|
580
|
+
"type": "object",
|
|
581
|
+
"properties": {
|
|
582
|
+
"sql": {
|
|
583
|
+
"type": "string",
|
|
584
|
+
"description": "The SQL query to execute. Must be valid SQL for the target database.",
|
|
585
|
+
},
|
|
586
|
+
"reasoning": {
|
|
587
|
+
"type": "string",
|
|
588
|
+
"description": "Explain your reasoning for this SQL query and what it's trying to accomplish.",
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
"required": ["sql", "reasoning"],
|
|
592
|
+
},
|
|
593
|
+
},
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
"type": "function",
|
|
597
|
+
"function": {
|
|
598
|
+
"name": "probe_schema",
|
|
599
|
+
"description": "Query the database to get detailed schema information about a specific table. Use this when you need to know the exact columns, data types, or sample data from a table.",
|
|
600
|
+
"parameters": {
|
|
601
|
+
"type": "object",
|
|
602
|
+
"properties": {
|
|
603
|
+
"table_name": {
|
|
604
|
+
"type": "string",
|
|
605
|
+
"description": "The name of the table to probe for more information.",
|
|
606
|
+
},
|
|
607
|
+
"reason": {
|
|
608
|
+
"type": "string",
|
|
609
|
+
"description": "Why you need to probe this table (e.g., 'Need to know exact column names', 'Not sure about data types').",
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
"required": ["table_name", "reason"],
|
|
613
|
+
},
|
|
614
|
+
},
|
|
615
|
+
},
|
|
616
|
+
{
|
|
617
|
+
"type": "function",
|
|
618
|
+
"function": {
|
|
619
|
+
"name": "analyze_error",
|
|
620
|
+
"description": "Analyze an error from a failed SQL query to understand what went wrong. Use this after a SQL execution fails to get insights on how to fix it.",
|
|
621
|
+
"parameters": {
|
|
622
|
+
"type": "object",
|
|
623
|
+
"properties": {
|
|
624
|
+
"analysis": {
|
|
625
|
+
"type": "string",
|
|
626
|
+
"description": "Your analysis of what went wrong and what needs to be fixed.",
|
|
627
|
+
},
|
|
628
|
+
"fixable": {
|
|
629
|
+
"type": "boolean",
|
|
630
|
+
"description": "Whether you believe this error can be fixed with another attempt.",
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
"required": ["analysis", "fixable"],
|
|
634
|
+
},
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
"type": "function",
|
|
639
|
+
"function": {
|
|
640
|
+
"name": "submit_final_answer",
|
|
641
|
+
"description": "Submit your final SQL query and result. Use this when you're confident in your SQL and have a successful execution, or when you've exhausted attempts and want to submit your best effort.",
|
|
642
|
+
"parameters": {
|
|
643
|
+
"type": "object",
|
|
644
|
+
"properties": {
|
|
645
|
+
"sql": {
|
|
646
|
+
"type": "string",
|
|
647
|
+
"description": "The final SQL query to submit.",
|
|
648
|
+
},
|
|
649
|
+
"confidence": {
|
|
650
|
+
"type": "string",
|
|
651
|
+
"enum": ["high", "medium", "low"],
|
|
652
|
+
"description": "Your confidence level in this answer.",
|
|
653
|
+
},
|
|
654
|
+
"explanation": {
|
|
655
|
+
"type": "string",
|
|
656
|
+
"description": "Brief explanation of your final answer.",
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
"required": ["sql", "confidence", "explanation"],
|
|
660
|
+
},
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
]
|
|
664
|
+
|
|
665
|
+
def _build_v2_prompt(
|
|
666
|
+
self, state: AgentState, error_classification: dict = None
|
|
667
|
+
) -> List[dict]:
|
|
668
|
+
"""
|
|
669
|
+
Build improved v2 prompt with targeted fix instructions.
|
|
670
|
+
|
|
671
|
+
For first attempt: Uses baseline prompt.
|
|
672
|
+
For retries: Provides specific guidance based on error type.
|
|
673
|
+
"""
|
|
674
|
+
messages = []
|
|
675
|
+
|
|
676
|
+
db_type = state["db_type"]
|
|
677
|
+
schema_text = self._verbalize_schema(state["schema"])
|
|
678
|
+
|
|
679
|
+
# System message
|
|
680
|
+
system_prompt = (
|
|
681
|
+
"You are a SQL expert. Your task is to convert natural language questions "
|
|
682
|
+
"into accurate SQL queries using the given database schema and instructions."
|
|
683
|
+
)
|
|
684
|
+
messages.append({"role": "system", "content": system_prompt})
|
|
685
|
+
|
|
686
|
+
if state["attempt"] == 1:
|
|
687
|
+
# FIRST ATTEMPT: Exact baseline prompt
|
|
688
|
+
user_content = (
|
|
689
|
+
f"Your task is to convert a natural language question into an accurate SQL query "
|
|
690
|
+
f"using the given {db_type} database schema.\n\n"
|
|
691
|
+
f"**Question:**:\n{state['question']}\n\n"
|
|
692
|
+
f"**Database Engine / Dialect:**:\n{db_type}\n\n"
|
|
693
|
+
f"**Schema:**\n{schema_text}\n\n"
|
|
694
|
+
"**Instructions:**\n"
|
|
695
|
+
"- Only use columns listed in the schema.\n"
|
|
696
|
+
"- Do not use any other columns or tables not mentioned in the schema.\n"
|
|
697
|
+
"- Ensure the SQL query is valid and executable.\n"
|
|
698
|
+
"- Use proper SQL syntax and conventions.\n"
|
|
699
|
+
"- Generate a complete SQL query that answers the question.\n"
|
|
700
|
+
f"- Use the correct SQL dialect for the database, i.e., {db_type}.\n"
|
|
701
|
+
"- Do not include any explanations or comments in the SQL output.\n"
|
|
702
|
+
"- Your output must start with ```sql and end with ```.\n\n"
|
|
703
|
+
f"Question: {state['question']}"
|
|
704
|
+
)
|
|
705
|
+
else:
|
|
706
|
+
# RETRY: Targeted fix based on error classification
|
|
707
|
+
user_content = (
|
|
708
|
+
f"**Question:** {state['question']}\n\n"
|
|
709
|
+
f"**Database Type:** {db_type}\n\n"
|
|
710
|
+
f"**Schema:**\n{schema_text}\n\n"
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
# Add previous SQL for reference
|
|
714
|
+
if state.get("sql_history") and len(state["sql_history"]) > 0:
|
|
715
|
+
user_content += f"\n**Previous SQL (failed):**\n```sql\n{state['sql_history'][-1]}\n```\n\n"
|
|
716
|
+
|
|
717
|
+
# Add error
|
|
718
|
+
if state.get("error_history"):
|
|
719
|
+
user_content += f"**Error:**\n{state['error_history'][-1]}\n\n"
|
|
720
|
+
|
|
721
|
+
# Add targeted fix instructions based on error category
|
|
722
|
+
if error_classification:
|
|
723
|
+
category = error_classification.get("category", "unknown")
|
|
724
|
+
|
|
725
|
+
if category == "column_error":
|
|
726
|
+
user_content += (
|
|
727
|
+
"**Fix Instructions:**\n"
|
|
728
|
+
"- The column name is incorrect or doesn't exist\n"
|
|
729
|
+
"- Check the schema carefully for the correct column name\n"
|
|
730
|
+
"- Look for similar column names or aliases\n"
|
|
731
|
+
"- Ensure you're using the exact column name from the schema\n\n"
|
|
732
|
+
)
|
|
733
|
+
elif category == "table_error":
|
|
734
|
+
user_content += (
|
|
735
|
+
"**Fix Instructions:**\n"
|
|
736
|
+
"- The table name is incorrect or doesn't exist\n"
|
|
737
|
+
"- Check the schema carefully for the correct table name\n"
|
|
738
|
+
"- Ensure you're using the exact table name from the schema\n\n"
|
|
739
|
+
)
|
|
740
|
+
elif category == "syntax_error":
|
|
741
|
+
user_content += (
|
|
742
|
+
"**Fix Instructions:**\n"
|
|
743
|
+
"- There is a SQL syntax error\n"
|
|
744
|
+
"- Review SQL syntax rules carefully\n"
|
|
745
|
+
"- Check for missing commas, parentheses, or keywords\n"
|
|
746
|
+
f"- Ensure the query follows {db_type} syntax\n\n"
|
|
747
|
+
)
|
|
748
|
+
elif category == "ambiguous_reference":
|
|
749
|
+
user_content += (
|
|
750
|
+
"**Fix Instructions:**\n"
|
|
751
|
+
"- A column reference is ambiguous (appears in multiple tables)\n"
|
|
752
|
+
"- Use table aliases to qualify the column (e.g., t1.column_name)\n"
|
|
753
|
+
"- Ensure all columns in JOINs are properly qualified\n\n"
|
|
754
|
+
)
|
|
755
|
+
elif category == "aggregation_error":
|
|
756
|
+
user_content += (
|
|
757
|
+
"**Fix Instructions:**\n"
|
|
758
|
+
"- There is an issue with GROUP BY or aggregate functions\n"
|
|
759
|
+
"- All non-aggregated columns must appear in GROUP BY\n"
|
|
760
|
+
"- Check if aggregate functions are used correctly\n\n"
|
|
761
|
+
)
|
|
762
|
+
else:
|
|
763
|
+
user_content += (
|
|
764
|
+
"**Fix Instructions:**\n"
|
|
765
|
+
"- Analyze the error message carefully\n"
|
|
766
|
+
"- Make a minimal, targeted fix to the previous SQL\n"
|
|
767
|
+
"- Only change what's necessary to fix the error\n\n"
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
user_content += (
|
|
771
|
+
"**Instructions:**\n"
|
|
772
|
+
"- Make a minimal fix to the previous SQL\n"
|
|
773
|
+
"- Only change what's needed to fix the error\n"
|
|
774
|
+
"- Keep the rest of the query structure the same\n"
|
|
775
|
+
"- Only use columns and tables from the schema\n"
|
|
776
|
+
f"- Use {db_type} syntax\n"
|
|
777
|
+
"- Your output must start with ```sql and end with ```.\n"
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
messages.append({"role": "user", "content": user_content})
|
|
781
|
+
return messages
|
|
782
|
+
|
|
783
|
+
def _build_v3_validation_prompt(self, state: AgentState) -> List[dict]:
|
|
784
|
+
"""
|
|
785
|
+
Build v3 LLM judge validation prompt.
|
|
786
|
+
|
|
787
|
+
This prompt asks the LLM to validate if the generated SQL and resulting
|
|
788
|
+
dataframe correctly and accurately answer the original question.
|
|
789
|
+
"""
|
|
790
|
+
messages = []
|
|
791
|
+
|
|
792
|
+
db_type = state["db_type"]
|
|
793
|
+
|
|
794
|
+
schema_text = self._verbalize_schema(state["schema"])
|
|
795
|
+
|
|
796
|
+
# System message
|
|
797
|
+
system_prompt = (
|
|
798
|
+
"You are an expert SQL validator and data analyst. Your task is to assess "
|
|
799
|
+
"whether a generated SQL query and its results correctly and accurately answer "
|
|
800
|
+
"a given natural language question."
|
|
801
|
+
)
|
|
802
|
+
messages.append({"role": "system", "content": system_prompt})
|
|
803
|
+
|
|
804
|
+
# Get the current execution result
|
|
805
|
+
result_df_json = state.get("execution_result", {}).get("df")
|
|
806
|
+
row_count = state.get("execution_result", {}).get("row_count", 0)
|
|
807
|
+
|
|
808
|
+
# Convert dataframe JSON to readable format
|
|
809
|
+
df_preview = "No results"
|
|
810
|
+
if result_df_json:
|
|
811
|
+
try:
|
|
812
|
+
df = pd.read_json(result_df_json, orient="split")
|
|
813
|
+
# Show first 10 rows
|
|
814
|
+
df_preview = df.head(10).to_string(index=False)
|
|
815
|
+
if len(df) > 10:
|
|
816
|
+
df_preview += f"\n... ({len(df) - 10} more rows)"
|
|
817
|
+
except:
|
|
818
|
+
df_preview = (
|
|
819
|
+
f"({row_count} rows returned, but could not parse dataframe)"
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
# Build comprehensive validation prompt
|
|
823
|
+
user_content = f"""You are validating a text-to-SQL system's output. Your task is to determine if the generated SQL query and its results correctly and accurately answer the user's question.
|
|
824
|
+
|
|
825
|
+
**Original Question:**
|
|
826
|
+
{state["question"]}
|
|
827
|
+
|
|
828
|
+
**Database Type:** {db_type}
|
|
829
|
+
|
|
830
|
+
**Database Schema:**
|
|
831
|
+
{schema_text}
|
|
832
|
+
|
|
833
|
+
**Generated SQL Query:**
|
|
834
|
+
```sql
|
|
835
|
+
{state["current_sql"]}
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
**Query Execution Results:**
|
|
839
|
+
{df_preview}
|
|
840
|
+
|
|
841
|
+
**Total Rows Returned:** {row_count}
|
|
842
|
+
|
|
843
|
+
**Your Task:**
|
|
844
|
+
Carefully analyze whether the SQL query and its results correctly and accurately answer the original question. Consider:
|
|
845
|
+
|
|
846
|
+
1. **Query Correctness:**
|
|
847
|
+
- Does the SQL query target the right tables and columns?
|
|
848
|
+
- Are the JOINs, WHERE clauses, and filters appropriate for the question?
|
|
849
|
+
- Does the query logic match what the question is asking for?
|
|
850
|
+
- Are aggregations (COUNT, SUM, AVG, etc.) used correctly if needed?
|
|
851
|
+
- Is the GROUP BY clause correct if aggregation is used?
|
|
852
|
+
|
|
853
|
+
2. **Result Validation:**
|
|
854
|
+
- Do the returned results make sense for the question?
|
|
855
|
+
- Is the number of rows reasonable? (e.g., if asking for "the top 5", are there 5 or fewer rows?)
|
|
856
|
+
- Are the column names in the result relevant to what was asked?
|
|
857
|
+
- Do the data values look appropriate for the question?
|
|
858
|
+
|
|
859
|
+
3. **Completeness:**
|
|
860
|
+
- Does the query return all the information requested in the question?
|
|
861
|
+
- Are there any missing columns or filters that should be included?
|
|
862
|
+
|
|
863
|
+
4. **Common Issues to Check:**
|
|
864
|
+
- Missing or incorrect filters (WHERE clauses)
|
|
865
|
+
- Wrong aggregation level (GROUP BY issues)
|
|
866
|
+
- Incorrect JOINs or missing tables
|
|
867
|
+
- Wrong sorting (ORDER BY) or limits
|
|
868
|
+
- Overly broad results (too many rows when specific answer expected)
|
|
869
|
+
- Empty results when data should exist
|
|
870
|
+
|
|
871
|
+
**Response Format:**
|
|
872
|
+
Provide your assessment in the following format:
|
|
873
|
+
|
|
874
|
+
VERDICT: [ACCEPT or RETRY]
|
|
875
|
+
|
|
876
|
+
CONFIDENCE: [HIGH, MEDIUM, or LOW]
|
|
877
|
+
|
|
878
|
+
REASONING:
|
|
879
|
+
[Provide detailed reasoning for your decision. If RETRY, explain what seems wrong and what should be fixed.]
|
|
880
|
+
|
|
881
|
+
**Guidelines:**
|
|
882
|
+
- Use ACCEPT if the SQL and results correctly answer the question, even if the format could be improved.
|
|
883
|
+
- Use RETRY if there are clear errors, missing information, or the results don't match what was asked.
|
|
884
|
+
- Be strict but fair - minor formatting differences are acceptable, but logical errors require RETRY.
|
|
885
|
+
- If results are empty but the question suggests data should exist, consider RETRY.
|
|
886
|
+
- If you're uncertain, provide MEDIUM or LOW confidence and explain your concerns.
|
|
887
|
+
"""
|
|
888
|
+
|
|
889
|
+
messages.append({"role": "user", "content": user_content})
|
|
890
|
+
return messages
|
|
891
|
+
|
|
892
|
+
async def _validate_with_llm_judge(self, state: AgentState, client) -> dict:
|
|
893
|
+
"""
|
|
894
|
+
Use LLM as judge to validate if SQL and results are correct.
|
|
895
|
+
|
|
896
|
+
Returns:
|
|
897
|
+
dict with keys: verdict (ACCEPT/RETRY), confidence (HIGH/MEDIUM/LOW), reasoning (str)
|
|
898
|
+
"""
|
|
899
|
+
messages = self._build_v3_validation_prompt(state)
|
|
900
|
+
|
|
901
|
+
try:
|
|
902
|
+
response = await asyncio.to_thread(client.generate_sql, messages)
|
|
903
|
+
|
|
904
|
+
# Parse the response
|
|
905
|
+
verdict = "RETRY" # Default to retry if we can't parse
|
|
906
|
+
confidence = "LOW"
|
|
907
|
+
reasoning = response
|
|
908
|
+
|
|
909
|
+
# Extract VERDICT
|
|
910
|
+
if "VERDICT:" in response:
|
|
911
|
+
verdict_line = [
|
|
912
|
+
line for line in response.split("\n") if "VERDICT:" in line
|
|
913
|
+
]
|
|
914
|
+
if verdict_line:
|
|
915
|
+
verdict_text = verdict_line[0].split("VERDICT:")[1].strip().upper()
|
|
916
|
+
if "ACCEPT" in verdict_text:
|
|
917
|
+
verdict = "ACCEPT"
|
|
918
|
+
elif "RETRY" in verdict_text:
|
|
919
|
+
verdict = "RETRY"
|
|
920
|
+
|
|
921
|
+
# Extract CONFIDENCE
|
|
922
|
+
if "CONFIDENCE:" in response:
|
|
923
|
+
conf_line = [
|
|
924
|
+
line for line in response.split("\n") if "CONFIDENCE:" in line
|
|
925
|
+
]
|
|
926
|
+
if conf_line:
|
|
927
|
+
conf_text = conf_line[0].split("CONFIDENCE:")[1].strip().upper()
|
|
928
|
+
if "HIGH" in conf_text:
|
|
929
|
+
confidence = "HIGH"
|
|
930
|
+
elif "MEDIUM" in conf_text:
|
|
931
|
+
confidence = "MEDIUM"
|
|
932
|
+
elif "LOW" in conf_text:
|
|
933
|
+
confidence = "LOW"
|
|
934
|
+
|
|
935
|
+
# Extract REASONING
|
|
936
|
+
if "REASONING:" in response:
|
|
937
|
+
reasoning_start = response.find("REASONING:")
|
|
938
|
+
reasoning = response[reasoning_start + len("REASONING:") :].strip()
|
|
939
|
+
|
|
940
|
+
logger.debug(f"LLM Judge Verdict: {verdict} (Confidence: {confidence})")
|
|
941
|
+
logger.debug(f"LLM Judge Reasoning: {reasoning[:200]}...")
|
|
942
|
+
|
|
943
|
+
result = {
|
|
944
|
+
"verdict": verdict,
|
|
945
|
+
"confidence": confidence,
|
|
946
|
+
"reasoning": reasoning,
|
|
947
|
+
"messages": messages, # Include messages for trace
|
|
948
|
+
"response": response, # Include full response for trace
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
return result
|
|
952
|
+
|
|
953
|
+
except Exception as e:
|
|
954
|
+
logger.error(f"Error in LLM judge validation: {e}")
|
|
955
|
+
return {
|
|
956
|
+
"verdict": "ACCEPT", # Default to accepting on error
|
|
957
|
+
"confidence": "LOW",
|
|
958
|
+
"reasoning": f"Validation error: {str(e)}",
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
def _build_baseline_compatible_prompt(
|
|
962
|
+
self, state: AgentState, is_retry: bool = False
|
|
963
|
+
) -> List[dict]:
|
|
964
|
+
"""
|
|
965
|
+
Build a baseline-compatible prompt.
|
|
966
|
+
|
|
967
|
+
For first attempt: Uses EXACT same prompt as baseline.
|
|
968
|
+
For retries: Adds minimal error context while staying close to baseline format.
|
|
969
|
+
"""
|
|
970
|
+
messages = []
|
|
971
|
+
|
|
972
|
+
# Convert db_type for display
|
|
973
|
+
db_type = state["db_type"]
|
|
974
|
+
|
|
975
|
+
schema_text = self._verbalize_schema(state["schema"])
|
|
976
|
+
|
|
977
|
+
# Use baseline system message
|
|
978
|
+
system_prompt = (
|
|
979
|
+
"You are a SQL expert. Your task is to convert natural language questions "
|
|
980
|
+
"into accurate SQL queries using the given database schema and instructions."
|
|
981
|
+
)
|
|
982
|
+
messages.append({"role": "system", "content": system_prompt})
|
|
983
|
+
|
|
984
|
+
if not is_retry:
|
|
985
|
+
# FIRST ATTEMPT: Exact baseline prompt
|
|
986
|
+
user_content = (
|
|
987
|
+
f"Your task is to convert a natural language question into an accurate SQL query "
|
|
988
|
+
f"using the given {db_type} database schema.\n\n"
|
|
989
|
+
f"**Question:**:\n{state['question']}\n\n"
|
|
990
|
+
f"**Database Engine / Dialect:**:\n{db_type}\n\n"
|
|
991
|
+
f"**Schema:**\n{schema_text}\n\n"
|
|
992
|
+
"**Instructions:**\n"
|
|
993
|
+
"- Only use columns listed in the schema.\n"
|
|
994
|
+
"- Do not use any other columns or tables not mentioned in the schema.\n"
|
|
995
|
+
"- Ensure the SQL query is valid and executable.\n"
|
|
996
|
+
"- Use proper SQL syntax and conventions.\n"
|
|
997
|
+
"- Generate a complete SQL query that answers the question.\n"
|
|
998
|
+
f"- Use the correct SQL dialect for the database, i.e., {db_type}.\n"
|
|
999
|
+
"- Do not include any explanations or comments in the SQL output.\n"
|
|
1000
|
+
"- Your output must start with ```sql and end with ```.\n\n"
|
|
1001
|
+
f"Question: {state['question']}"
|
|
1002
|
+
)
|
|
1003
|
+
else:
|
|
1004
|
+
# RETRY: Add minimal error context
|
|
1005
|
+
user_content = (
|
|
1006
|
+
f"Your task is to convert a natural language question into an accurate SQL query "
|
|
1007
|
+
f"using the given {db_type} database schema.\n\n"
|
|
1008
|
+
f"**Question:**:\n{state['question']}\n\n"
|
|
1009
|
+
f"**Database Engine / Dialect:**:\n{db_type}\n\n"
|
|
1010
|
+
f"**Schema:**\n{schema_text}\n\n"
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
# Add error from previous attempt
|
|
1014
|
+
if state.get("error_history"):
|
|
1015
|
+
user_content += f"\n**Previous attempt failed with error:**\n{state['error_history'][-1]}\n\n"
|
|
1016
|
+
|
|
1017
|
+
user_content += (
|
|
1018
|
+
"**Instructions:**\n"
|
|
1019
|
+
"- Only use columns listed in the schema.\n"
|
|
1020
|
+
"- Do not use any other columns or tables not mentioned in the schema.\n"
|
|
1021
|
+
"- Fix the error from the previous attempt.\n"
|
|
1022
|
+
"- Ensure the SQL query is valid and executable.\n"
|
|
1023
|
+
"- Use proper SQL syntax and conventions.\n"
|
|
1024
|
+
f"- Use the correct SQL dialect for the database, i.e., {db_type}.\n"
|
|
1025
|
+
"- Do not include any explanations or comments in the SQL output.\n"
|
|
1026
|
+
"- Your output must start with ```sql and end with ```.\n\n"
|
|
1027
|
+
f"Question: {state['question']}"
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
messages.append({"role": "user", "content": user_content})
|
|
1031
|
+
return messages
|
|
1032
|
+
|
|
1033
|
+
def _build_agent_prompt(self, state: AgentState) -> List[dict]:
|
|
1034
|
+
"""Build the prompt for the agent based on current state (original agent-aware version)."""
|
|
1035
|
+
messages = []
|
|
1036
|
+
|
|
1037
|
+
system_prompt = """You are an expert SQL assistant that helps convert natural language questions into accurate SQL queries.
|
|
1038
|
+
|
|
1039
|
+
Your capabilities:
|
|
1040
|
+
1. Generate SQL queries from natural language questions
|
|
1041
|
+
2. Analyze SQL errors and fix them
|
|
1042
|
+
3. Probe database schema when information is missing
|
|
1043
|
+
4. Validate query results for correctness
|
|
1044
|
+
|
|
1045
|
+
You have access to:
|
|
1046
|
+
- The database schema
|
|
1047
|
+
- The ability to execute SQL queries
|
|
1048
|
+
- The ability to probe the database for additional schema information
|
|
1049
|
+
- Previous attempts and errors (if any)
|
|
1050
|
+
|
|
1051
|
+
When you encounter an error:
|
|
1052
|
+
1. Analyze the error message carefully
|
|
1053
|
+
2. Check if the error is due to missing schema information
|
|
1054
|
+
3. If needed, probe the database for more information
|
|
1055
|
+
4. Fix the SQL query based on the error and try again
|
|
1056
|
+
|
|
1057
|
+
When you generate SQL:
|
|
1058
|
+
- Use only columns and tables mentioned in the schema
|
|
1059
|
+
- Follow the correct SQL dialect for the database
|
|
1060
|
+
- Ensure the query is syntactically correct
|
|
1061
|
+
- Make sure the query answers the question accurately"""
|
|
1062
|
+
|
|
1063
|
+
messages.append({"role": "system", "content": system_prompt})
|
|
1064
|
+
|
|
1065
|
+
# Add reasoning history
|
|
1066
|
+
if state.get("reasoning"):
|
|
1067
|
+
messages.append(
|
|
1068
|
+
{
|
|
1069
|
+
"role": "assistant",
|
|
1070
|
+
"content": f"Previous reasoning:\n" + "\n".join(state["reasoning"]),
|
|
1071
|
+
}
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
# Add schema information
|
|
1075
|
+
schema_text = self._verbalize_schema(state["schema"])
|
|
1076
|
+
messages.append(
|
|
1077
|
+
{
|
|
1078
|
+
"role": "user",
|
|
1079
|
+
"content": f"""**Question:** {state["question"]}
|
|
1080
|
+
|
|
1081
|
+
**Database Type:** {state["db_type"]}
|
|
1082
|
+
|
|
1083
|
+
**Schema:**
|
|
1084
|
+
{schema_text}
|
|
1085
|
+
|
|
1086
|
+
**Attempt:** {state["attempt"]} of {state["max_attempts"]}""",
|
|
1087
|
+
}
|
|
1088
|
+
)
|
|
1089
|
+
|
|
1090
|
+
# Add error history if any
|
|
1091
|
+
if state.get("error_history"):
|
|
1092
|
+
messages.append(
|
|
1093
|
+
{
|
|
1094
|
+
"role": "user",
|
|
1095
|
+
"content": f"""**Previous Errors:**
|
|
1096
|
+
{chr(10).join(f"Attempt {i + 1}: {err}" for i, err in enumerate(state["error_history"]))}""",
|
|
1097
|
+
}
|
|
1098
|
+
)
|
|
1099
|
+
|
|
1100
|
+
# Add schema probes if any
|
|
1101
|
+
if state.get("schema_probes"):
|
|
1102
|
+
messages.append(
|
|
1103
|
+
{
|
|
1104
|
+
"role": "user",
|
|
1105
|
+
"content": f"""**Additional Schema Information from Probes:**
|
|
1106
|
+
{chr(10).join(state["schema_probes"])}""",
|
|
1107
|
+
}
|
|
1108
|
+
)
|
|
1109
|
+
|
|
1110
|
+
# Add instruction based on state
|
|
1111
|
+
if state["attempt"] == 1:
|
|
1112
|
+
instruction = "Generate a SQL query to answer the question."
|
|
1113
|
+
elif state.get("execution_error"):
|
|
1114
|
+
instruction = f"""The previous SQL query failed with error: {state["execution_error"]}
|
|
1115
|
+
|
|
1116
|
+
Analyze the error and generate a corrected SQL query. If you need more schema information, indicate what you need to probe."""
|
|
1117
|
+
else:
|
|
1118
|
+
instruction = (
|
|
1119
|
+
"Review the previous attempt and generate an improved SQL query."
|
|
1120
|
+
)
|
|
1121
|
+
|
|
1122
|
+
messages.append({"role": "user", "content": instruction})
|
|
1123
|
+
|
|
1124
|
+
return messages
|
|
1125
|
+
|
|
1126
|
+
def _verbalize_schema(self, schema: dict) -> str:
|
|
1127
|
+
"""Convert schema dict to text format."""
|
|
1128
|
+
lines = []
|
|
1129
|
+
db_desc = schema.get("description", "")
|
|
1130
|
+
if db_desc:
|
|
1131
|
+
lines.append(f"Database description: {db_desc}\n")
|
|
1132
|
+
|
|
1133
|
+
tables = []
|
|
1134
|
+
if not isinstance(schema.get("tables"), list) and isinstance(
|
|
1135
|
+
schema.get("tables"), dict
|
|
1136
|
+
):
|
|
1137
|
+
for table_name, table_obj in schema.get("tables").items():
|
|
1138
|
+
tables.append(table_obj)
|
|
1139
|
+
else:
|
|
1140
|
+
tables = schema.get("tables", [])
|
|
1141
|
+
|
|
1142
|
+
for table in tables:
|
|
1143
|
+
table_name = table.get("name")
|
|
1144
|
+
table_desc = table.get("description", "")
|
|
1145
|
+
lines.append(f"Table: {table_name}")
|
|
1146
|
+
if table_desc:
|
|
1147
|
+
lines.append(f" Description: {table_desc}")
|
|
1148
|
+
lines.append(" Columns:")
|
|
1149
|
+
for col in table.get("columns", []):
|
|
1150
|
+
col_name = col.get("name")
|
|
1151
|
+
col_type = col.get("type")
|
|
1152
|
+
col_desc = col.get("description", "")
|
|
1153
|
+
pk = " (Primary Key)" if col.get("primary_key", False) else ""
|
|
1154
|
+
samples = col.get("samples") or col.get("value_samples")
|
|
1155
|
+
sample_str = ""
|
|
1156
|
+
if samples and isinstance(samples, list):
|
|
1157
|
+
shown = samples[:5]
|
|
1158
|
+
shown_str = ", ".join(str(s) for s in shown)
|
|
1159
|
+
sample_str = f" # Example values: {shown_str}"
|
|
1160
|
+
elif samples and isinstance(samples, (str, int, float)):
|
|
1161
|
+
sample_str = f" # Example value: {samples}"
|
|
1162
|
+
if col_desc:
|
|
1163
|
+
lines.append(
|
|
1164
|
+
f" - {col_name} ({col_type}){pk}: {col_desc}{sample_str}"
|
|
1165
|
+
)
|
|
1166
|
+
else:
|
|
1167
|
+
lines.append(f" - {col_name} ({col_type}){pk}{sample_str}")
|
|
1168
|
+
lines.append("")
|
|
1169
|
+
|
|
1170
|
+
return "\n".join(lines)
|
|
1171
|
+
|
|
1172
|
+
async def _generate_sql_node(self, state: AgentState, client) -> AgentState:
|
|
1173
|
+
"""Node: Generate SQL query."""
|
|
1174
|
+
logger.debug(f"Generating SQL (attempt {state['attempt']})")
|
|
1175
|
+
|
|
1176
|
+
# Choose prompt strategy based on version and configuration
|
|
1177
|
+
if self.version == "v3":
|
|
1178
|
+
# V3: Use baseline prompt + LLM judge feedback on retries
|
|
1179
|
+
if state["attempt"] == 1:
|
|
1180
|
+
# First attempt: use baseline prompt
|
|
1181
|
+
messages = self._build_v2_prompt(state, error_classification=None)
|
|
1182
|
+
else:
|
|
1183
|
+
# Retry: incorporate LLM judge feedback if available
|
|
1184
|
+
llm_feedback = state.get("llm_judge_reasoning", "")
|
|
1185
|
+
error_classification = None
|
|
1186
|
+
if state.get("execution_error"):
|
|
1187
|
+
error_classification = self._classify_error(
|
|
1188
|
+
state["execution_error"]
|
|
1189
|
+
)
|
|
1190
|
+
elif llm_feedback:
|
|
1191
|
+
# Use LLM feedback as error context
|
|
1192
|
+
state["error_history"].append(f"LLM Judge Feedback: {llm_feedback}")
|
|
1193
|
+
messages = self._build_v2_prompt(state, error_classification)
|
|
1194
|
+
elif self.version == "v2":
|
|
1195
|
+
# V2: Use improved prompting with error classification
|
|
1196
|
+
error_classification = None
|
|
1197
|
+
if state.get("execution_error"):
|
|
1198
|
+
error_classification = self._classify_error(state["execution_error"])
|
|
1199
|
+
messages = self._build_v2_prompt(state, error_classification)
|
|
1200
|
+
elif self.version == "v1":
|
|
1201
|
+
# V1: baseline-compatible prompts (baseline1)
|
|
1202
|
+
is_retry = state["attempt"] > 1
|
|
1203
|
+
messages = self._build_baseline_compatible_prompt(state, is_retry=is_retry)
|
|
1204
|
+
elif self.version == "v0" or self.use_baseline_prompt is False:
|
|
1205
|
+
# V0: agent-aware prompts (baseline0)
|
|
1206
|
+
messages = self._build_agent_prompt(state)
|
|
1207
|
+
else:
|
|
1208
|
+
# Fallback: use baseline-compatible if use_baseline_prompt is True
|
|
1209
|
+
is_retry = state["attempt"] > 1
|
|
1210
|
+
messages = self._build_baseline_compatible_prompt(state, is_retry=is_retry)
|
|
1211
|
+
|
|
1212
|
+
# Generate SQL using the client
|
|
1213
|
+
try:
|
|
1214
|
+
sql, token_usage = await asyncio.to_thread(client.generate_sql, messages)
|
|
1215
|
+
sql = postprocess_sql(sql)
|
|
1216
|
+
|
|
1217
|
+
state["current_sql"] = sql
|
|
1218
|
+
state["sql_history"].append(sql)
|
|
1219
|
+
state["reasoning"].append(
|
|
1220
|
+
f"Generated SQL (attempt {state['attempt']}): {sql}"
|
|
1221
|
+
)
|
|
1222
|
+
|
|
1223
|
+
# Track token usage for this attempt
|
|
1224
|
+
if token_usage:
|
|
1225
|
+
state["token_usage_per_attempt"].append(token_usage)
|
|
1226
|
+
# Update total token usage
|
|
1227
|
+
for key in ["prompt_tokens", "completion_tokens", "total_tokens"]:
|
|
1228
|
+
state["total_token_usage"][key] = (
|
|
1229
|
+
state["total_token_usage"].get(key, 0) + token_usage.get(key, 0)
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
# Save full trace of this interaction
|
|
1233
|
+
state["agent_trace"].append(
|
|
1234
|
+
{
|
|
1235
|
+
"step": f"generate_sql_attempt_{state['attempt']}",
|
|
1236
|
+
"messages": messages,
|
|
1237
|
+
"response": sql,
|
|
1238
|
+
"parsed_sql": sql,
|
|
1239
|
+
"token_usage": token_usage,
|
|
1240
|
+
}
|
|
1241
|
+
)
|
|
1242
|
+
|
|
1243
|
+
logger.debug(f"Generated SQL: {sql}")
|
|
1244
|
+
except Exception as e:
|
|
1245
|
+
logger.error(f"Error generating SQL: {e}")
|
|
1246
|
+
state["execution_error"] = f"Error generating SQL: {str(e)}"
|
|
1247
|
+
state["error_history"].append(state["execution_error"])
|
|
1248
|
+
|
|
1249
|
+
# Save error in trace
|
|
1250
|
+
state["agent_trace"].append(
|
|
1251
|
+
{
|
|
1252
|
+
"step": f"generate_sql_attempt_{state['attempt']}",
|
|
1253
|
+
"messages": messages,
|
|
1254
|
+
"error": str(e),
|
|
1255
|
+
}
|
|
1256
|
+
)
|
|
1257
|
+
|
|
1258
|
+
return state
|
|
1259
|
+
|
|
1260
|
+
async def _execute_sql_node(
|
|
1261
|
+
self, state: AgentState, db_executor: DatabaseExecutor
|
|
1262
|
+
) -> AgentState:
|
|
1263
|
+
"""Node: Execute SQL query."""
|
|
1264
|
+
if not state.get("current_sql"):
|
|
1265
|
+
state["execution_error"] = "No SQL to execute"
|
|
1266
|
+
return state
|
|
1267
|
+
|
|
1268
|
+
logger.debug(f"Executing SQL: {state['current_sql']}")
|
|
1269
|
+
|
|
1270
|
+
result = await db_executor.execute_query(state["current_sql"])
|
|
1271
|
+
|
|
1272
|
+
if result["success"]:
|
|
1273
|
+
state["execution_result"] = {
|
|
1274
|
+
"success": True,
|
|
1275
|
+
"row_count": result["row_count"],
|
|
1276
|
+
"df": result["df"].to_json(orient="split")
|
|
1277
|
+
if result["df"] is not None
|
|
1278
|
+
else None,
|
|
1279
|
+
"execution_time_ms": result.get("execution_time_ms"),
|
|
1280
|
+
}
|
|
1281
|
+
state["execution_error"] = None
|
|
1282
|
+
logger.debug(
|
|
1283
|
+
f"SQL executed successfully, returned {result['row_count']} rows"
|
|
1284
|
+
)
|
|
1285
|
+
else:
|
|
1286
|
+
state["execution_result"] = None
|
|
1287
|
+
state["execution_error"] = result["error"]
|
|
1288
|
+
state["error_history"].append(result["error"])
|
|
1289
|
+
logger.debug(f"SQL execution failed: {result['error']}")
|
|
1290
|
+
|
|
1291
|
+
return state
|
|
1292
|
+
|
|
1293
|
+
async def _probe_schema_node(
|
|
1294
|
+
self, state: AgentState, db_executor: DatabaseExecutor
|
|
1295
|
+
) -> AgentState:
|
|
1296
|
+
"""Node: Probe database schema for additional information."""
|
|
1297
|
+
logger.debug("Probing database schema")
|
|
1298
|
+
|
|
1299
|
+
# Probe for tables and columns
|
|
1300
|
+
columns_result = await db_executor.probe_schema("columns")
|
|
1301
|
+
|
|
1302
|
+
if columns_result["success"]:
|
|
1303
|
+
probe_text = "Additional schema information from database:\n"
|
|
1304
|
+
for table_name, columns in columns_result["columns"].items():
|
|
1305
|
+
probe_text += f"\nTable: {table_name}\n"
|
|
1306
|
+
for col in columns:
|
|
1307
|
+
if isinstance(col, dict):
|
|
1308
|
+
col_name = (
|
|
1309
|
+
col.get("column_name")
|
|
1310
|
+
or col.get("Field")
|
|
1311
|
+
or col.get("name")
|
|
1312
|
+
)
|
|
1313
|
+
col_type = (
|
|
1314
|
+
col.get("data_type") or col.get("Type") or col.get("type")
|
|
1315
|
+
)
|
|
1316
|
+
probe_text += f" - {col_name} ({col_type})\n"
|
|
1317
|
+
|
|
1318
|
+
state["schema_probes"].append(probe_text)
|
|
1319
|
+
state["reasoning"].append(
|
|
1320
|
+
"Probed database for additional schema information"
|
|
1321
|
+
)
|
|
1322
|
+
|
|
1323
|
+
return state
|
|
1324
|
+
|
|
1325
|
+
async def _validate_result_node(self, state: AgentState, client=None) -> AgentState:
|
|
1326
|
+
"""Node: Validate the execution result."""
|
|
1327
|
+
if (
|
|
1328
|
+
not state.get("execution_result")
|
|
1329
|
+
or not state["execution_result"]["success"]
|
|
1330
|
+
):
|
|
1331
|
+
return state
|
|
1332
|
+
|
|
1333
|
+
# Basic validation: check if result makes sense
|
|
1334
|
+
row_count = state["execution_result"]["row_count"]
|
|
1335
|
+
|
|
1336
|
+
if row_count == 0:
|
|
1337
|
+
state["reasoning"].append(
|
|
1338
|
+
"Query executed but returned 0 rows. This might be correct or might indicate an issue."
|
|
1339
|
+
)
|
|
1340
|
+
else:
|
|
1341
|
+
state["reasoning"].append(
|
|
1342
|
+
f"Query executed successfully and returned {row_count} rows."
|
|
1343
|
+
)
|
|
1344
|
+
|
|
1345
|
+
# V3: Use LLM judge to validate if SQL and results are correct
|
|
1346
|
+
if self.version == "v3" and client is not None:
|
|
1347
|
+
logger.debug("Running LLM judge validation (v3)")
|
|
1348
|
+
validation_result = await self._validate_with_llm_judge(state, client)
|
|
1349
|
+
|
|
1350
|
+
state["llm_judge_verdict"] = validation_result["verdict"]
|
|
1351
|
+
state["llm_judge_confidence"] = validation_result["confidence"]
|
|
1352
|
+
state["llm_judge_reasoning"] = validation_result["reasoning"]
|
|
1353
|
+
|
|
1354
|
+
state["reasoning"].append(
|
|
1355
|
+
f"LLM Judge: {validation_result['verdict']} (Confidence: {validation_result['confidence']})"
|
|
1356
|
+
)
|
|
1357
|
+
|
|
1358
|
+
# Save LLM judge interaction to trace
|
|
1359
|
+
state["agent_trace"].append(
|
|
1360
|
+
{
|
|
1361
|
+
"step": f"llm_judge_validation_attempt_{state['attempt']}",
|
|
1362
|
+
"messages": validation_result.get("messages", []),
|
|
1363
|
+
"response": validation_result.get("response", ""),
|
|
1364
|
+
"verdict": validation_result["verdict"],
|
|
1365
|
+
"confidence": validation_result["confidence"],
|
|
1366
|
+
"reasoning": validation_result["reasoning"],
|
|
1367
|
+
}
|
|
1368
|
+
)
|
|
1369
|
+
|
|
1370
|
+
# If LLM judge says ACCEPT, mark as final
|
|
1371
|
+
if validation_result["verdict"] == "ACCEPT":
|
|
1372
|
+
state["final_sql"] = state["current_sql"]
|
|
1373
|
+
if state["execution_result"].get("df"):
|
|
1374
|
+
state["final_df"] = state["execution_result"]["df"]
|
|
1375
|
+
# If RETRY, clear final markers so we try again
|
|
1376
|
+
else:
|
|
1377
|
+
state["final_sql"] = None
|
|
1378
|
+
state["final_df"] = None
|
|
1379
|
+
else:
|
|
1380
|
+
# For non-v3 versions: Mark as final if successful execution
|
|
1381
|
+
state["final_sql"] = state["current_sql"]
|
|
1382
|
+
if state["execution_result"].get("df"):
|
|
1383
|
+
state["final_df"] = state["execution_result"]["df"]
|
|
1384
|
+
|
|
1385
|
+
return state
|
|
1386
|
+
|
|
1387
|
+
def _classify_error(self, error_message: str) -> dict:
|
|
1388
|
+
"""
|
|
1389
|
+
Classify SQL error to determine if retry is worthwhile (v2 improvement).
|
|
1390
|
+
|
|
1391
|
+
Returns dict with:
|
|
1392
|
+
- fixable: bool - whether this error is likely fixable
|
|
1393
|
+
- category: str - error category
|
|
1394
|
+
- confidence: float - confidence that retry will help
|
|
1395
|
+
"""
|
|
1396
|
+
error_lower = error_message.lower()
|
|
1397
|
+
|
|
1398
|
+
# High-confidence fixable errors (syntax, typos, schema issues)
|
|
1399
|
+
if any(
|
|
1400
|
+
term in error_lower
|
|
1401
|
+
for term in ["no such column", "unknown column", "column", "does not exist"]
|
|
1402
|
+
):
|
|
1403
|
+
return {"fixable": True, "category": "column_error", "confidence": 0.8}
|
|
1404
|
+
|
|
1405
|
+
if any(
|
|
1406
|
+
term in error_lower
|
|
1407
|
+
for term in ["no such table", "unknown table", "table", "relation"]
|
|
1408
|
+
):
|
|
1409
|
+
return {"fixable": True, "category": "table_error", "confidence": 0.8}
|
|
1410
|
+
|
|
1411
|
+
if any(term in error_lower for term in ["syntax error", "near", "unexpected"]):
|
|
1412
|
+
return {"fixable": True, "category": "syntax_error", "confidence": 0.6}
|
|
1413
|
+
|
|
1414
|
+
if any(term in error_lower for term in ["ambiguous", "ambiguous column"]):
|
|
1415
|
+
return {
|
|
1416
|
+
"fixable": True,
|
|
1417
|
+
"category": "ambiguous_reference",
|
|
1418
|
+
"confidence": 0.7,
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
# Medium-confidence fixable errors
|
|
1422
|
+
if any(term in error_lower for term in ["type", "datatype", "cast", "convert"]):
|
|
1423
|
+
return {"fixable": True, "category": "type_error", "confidence": 0.5}
|
|
1424
|
+
|
|
1425
|
+
if any(
|
|
1426
|
+
term in error_lower for term in ["group by", "aggregate", "must appear"]
|
|
1427
|
+
):
|
|
1428
|
+
return {"fixable": True, "category": "aggregation_error", "confidence": 0.6}
|
|
1429
|
+
|
|
1430
|
+
# Low-confidence or unfixable errors
|
|
1431
|
+
if any(
|
|
1432
|
+
term in error_lower for term in ["timeout", "timed out", "lock", "deadlock"]
|
|
1433
|
+
):
|
|
1434
|
+
return {"fixable": False, "category": "timeout", "confidence": 0.1}
|
|
1435
|
+
|
|
1436
|
+
if any(term in error_lower for term in ["permission", "denied", "access"]):
|
|
1437
|
+
return {"fixable": False, "category": "permission", "confidence": 0.0}
|
|
1438
|
+
|
|
1439
|
+
# Unknown error - low confidence
|
|
1440
|
+
return {"fixable": True, "category": "unknown", "confidence": 0.3}
|
|
1441
|
+
|
|
1442
|
+
def _should_retry(self, state: AgentState) -> str:
|
|
1443
|
+
"""Determine next step based on state (improved for v2)."""
|
|
1444
|
+
# If we have a successful result, we're done
|
|
1445
|
+
if state.get("execution_result") and state["execution_result"].get("success"):
|
|
1446
|
+
return "end"
|
|
1447
|
+
|
|
1448
|
+
# If we've exceeded max attempts, stop
|
|
1449
|
+
if state["attempt"] >= state["max_attempts"]:
|
|
1450
|
+
return "end"
|
|
1451
|
+
|
|
1452
|
+
# V2: Smarter retry decisions based on error classification
|
|
1453
|
+
if self.version == "v2" and state.get("execution_error"):
|
|
1454
|
+
error_classification = self._classify_error(state["execution_error"])
|
|
1455
|
+
|
|
1456
|
+
# Don't retry if error is not fixable
|
|
1457
|
+
if not error_classification["fixable"]:
|
|
1458
|
+
logger.debug(
|
|
1459
|
+
f"Error not fixable ({error_classification['category']}), stopping"
|
|
1460
|
+
)
|
|
1461
|
+
return "end"
|
|
1462
|
+
|
|
1463
|
+
# Don't retry if confidence is too low and we've tried once
|
|
1464
|
+
if error_classification["confidence"] < 0.4 and state["attempt"] >= 2:
|
|
1465
|
+
logger.debug(
|
|
1466
|
+
f"Low confidence ({error_classification['confidence']}) and already tried, stopping"
|
|
1467
|
+
)
|
|
1468
|
+
return "end"
|
|
1469
|
+
|
|
1470
|
+
# Decide whether to probe schema or just retry
|
|
1471
|
+
if (
|
|
1472
|
+
error_classification["category"] in ["column_error", "table_error"]
|
|
1473
|
+
and len(state["schema_probes"]) == 0
|
|
1474
|
+
):
|
|
1475
|
+
return "probe_schema"
|
|
1476
|
+
|
|
1477
|
+
return "generate_sql"
|
|
1478
|
+
|
|
1479
|
+
# V1: Original simple logic
|
|
1480
|
+
if state.get("execution_error"):
|
|
1481
|
+
error_lower = state["execution_error"].lower()
|
|
1482
|
+
schema_related_errors = [
|
|
1483
|
+
"column",
|
|
1484
|
+
"table",
|
|
1485
|
+
"relation",
|
|
1486
|
+
"does not exist",
|
|
1487
|
+
"unknown",
|
|
1488
|
+
"invalid",
|
|
1489
|
+
]
|
|
1490
|
+
if any(term in error_lower for term in schema_related_errors):
|
|
1491
|
+
return "probe_schema"
|
|
1492
|
+
return "generate_sql"
|
|
1493
|
+
|
|
1494
|
+
return "end"
|
|
1495
|
+
|
|
1496
|
+
async def _run_agent(
|
|
1497
|
+
self,
|
|
1498
|
+
question: str,
|
|
1499
|
+
schema: dict,
|
|
1500
|
+
db_type: str,
|
|
1501
|
+
db_connection_info: dict,
|
|
1502
|
+
db_id: Optional[str],
|
|
1503
|
+
client,
|
|
1504
|
+
max_attempts: int,
|
|
1505
|
+
) -> dict:
|
|
1506
|
+
"""Run the agentic pipeline for a single question."""
|
|
1507
|
+
|
|
1508
|
+
# Initialize state
|
|
1509
|
+
state: AgentState = {
|
|
1510
|
+
"question": question,
|
|
1511
|
+
"schema": schema,
|
|
1512
|
+
"db_type": db_type,
|
|
1513
|
+
"db_id": db_id,
|
|
1514
|
+
"db_connection_info": db_connection_info,
|
|
1515
|
+
"attempt": 0,
|
|
1516
|
+
"max_attempts": max_attempts,
|
|
1517
|
+
"sql_history": [],
|
|
1518
|
+
"error_history": [],
|
|
1519
|
+
"current_sql": None,
|
|
1520
|
+
"execution_result": None,
|
|
1521
|
+
"execution_error": None,
|
|
1522
|
+
"schema_probes": [],
|
|
1523
|
+
"reasoning": [],
|
|
1524
|
+
"final_sql": None,
|
|
1525
|
+
"final_df": None,
|
|
1526
|
+
"messages": [],
|
|
1527
|
+
"llm_judge_verdict": None,
|
|
1528
|
+
"llm_judge_confidence": None,
|
|
1529
|
+
"llm_judge_reasoning": None,
|
|
1530
|
+
"agent_trace": [], # Full trace of all LLM interactions
|
|
1531
|
+
"token_usage_per_attempt": [], # Token usage for each attempt
|
|
1532
|
+
"total_token_usage": {}, # Aggregated token usage
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
# Initialize database executor
|
|
1536
|
+
db_executor = DatabaseExecutor(db_type, db_connection_info, db_id)
|
|
1537
|
+
await db_executor.initialize()
|
|
1538
|
+
|
|
1539
|
+
try:
|
|
1540
|
+
# Main loop
|
|
1541
|
+
while state["attempt"] < max_attempts:
|
|
1542
|
+
state["attempt"] += 1
|
|
1543
|
+
logger.debug(f"Agent attempt {state['attempt']}/{max_attempts}")
|
|
1544
|
+
|
|
1545
|
+
# Generate SQL
|
|
1546
|
+
state = await self._generate_sql_node(state, client)
|
|
1547
|
+
|
|
1548
|
+
if not state.get("current_sql"):
|
|
1549
|
+
# Failed to generate, try again if we have attempts left
|
|
1550
|
+
continue
|
|
1551
|
+
|
|
1552
|
+
# Execute SQL
|
|
1553
|
+
state = await self._execute_sql_node(state, db_executor)
|
|
1554
|
+
|
|
1555
|
+
# Check if successful
|
|
1556
|
+
if state.get("execution_result") and state["execution_result"].get(
|
|
1557
|
+
"success"
|
|
1558
|
+
):
|
|
1559
|
+
# Validate result
|
|
1560
|
+
state = await self._validate_result_node(state, client=client)
|
|
1561
|
+
|
|
1562
|
+
# For v3: Check LLM judge verdict
|
|
1563
|
+
if self.version == "v3":
|
|
1564
|
+
if state.get("llm_judge_verdict") == "ACCEPT":
|
|
1565
|
+
logger.debug("LLM judge accepted the result, stopping")
|
|
1566
|
+
break
|
|
1567
|
+
elif state.get("llm_judge_verdict") == "RETRY":
|
|
1568
|
+
logger.debug(
|
|
1569
|
+
f"LLM judge requested retry: {state.get('llm_judge_reasoning', '')[:100]}"
|
|
1570
|
+
)
|
|
1571
|
+
# Continue to next attempt
|
|
1572
|
+
continue
|
|
1573
|
+
else:
|
|
1574
|
+
# For non-v3: successful execution means we're done
|
|
1575
|
+
break
|
|
1576
|
+
|
|
1577
|
+
# If error suggests schema issue, probe schema
|
|
1578
|
+
if state.get("execution_error"):
|
|
1579
|
+
error_lower = state["execution_error"].lower()
|
|
1580
|
+
schema_related = any(
|
|
1581
|
+
term in error_lower
|
|
1582
|
+
for term in [
|
|
1583
|
+
"column",
|
|
1584
|
+
"table",
|
|
1585
|
+
"relation",
|
|
1586
|
+
"does not exist",
|
|
1587
|
+
"unknown",
|
|
1588
|
+
]
|
|
1589
|
+
)
|
|
1590
|
+
if schema_related and len(state["schema_probes"]) == 0:
|
|
1591
|
+
state = await self._probe_schema_node(state, db_executor)
|
|
1592
|
+
|
|
1593
|
+
# Finalize: use last SQL if we have one, even if it failed
|
|
1594
|
+
if not state.get("final_sql") and state.get("current_sql"):
|
|
1595
|
+
state["final_sql"] = state["current_sql"]
|
|
1596
|
+
|
|
1597
|
+
finally:
|
|
1598
|
+
await db_executor.close()
|
|
1599
|
+
|
|
1600
|
+
return {
|
|
1601
|
+
"predicted_sql": state.get("final_sql"),
|
|
1602
|
+
"predicted_df": state.get("final_df"),
|
|
1603
|
+
"sql_execution_error": state.get("execution_error")
|
|
1604
|
+
if not state.get("execution_result")
|
|
1605
|
+
or not state["execution_result"].get("success")
|
|
1606
|
+
else None,
|
|
1607
|
+
"execution_time_ms": state.get("execution_result", {}).get("execution_time_ms") if state.get("execution_result") else None,
|
|
1608
|
+
"attempts": state["attempt"],
|
|
1609
|
+
"reasoning": state.get("reasoning", []),
|
|
1610
|
+
"agent_trace": state.get("agent_trace", []), # Full LLM interaction history
|
|
1611
|
+
"token_usage": state.get("total_token_usage"), # Aggregated token usage
|
|
1612
|
+
"token_usage_per_attempt": state.get("token_usage_per_attempt", []), # Per-attempt breakdown
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
async def _run_agent_v4(
|
|
1616
|
+
self,
|
|
1617
|
+
question: str,
|
|
1618
|
+
schema: dict,
|
|
1619
|
+
db_type: str,
|
|
1620
|
+
db_connection_info: dict,
|
|
1621
|
+
db_id: Optional[str],
|
|
1622
|
+
client,
|
|
1623
|
+
max_attempts: int,
|
|
1624
|
+
) -> dict:
|
|
1625
|
+
"""
|
|
1626
|
+
Run the truly agentic v4 pipeline where LLM controls the workflow.
|
|
1627
|
+
|
|
1628
|
+
Uses ReAct pattern: LLM reasons and chooses actions via JSON responses.
|
|
1629
|
+
"""
|
|
1630
|
+
# Initialize state
|
|
1631
|
+
state: AgentState = {
|
|
1632
|
+
"question": question,
|
|
1633
|
+
"schema": schema,
|
|
1634
|
+
"db_type": db_type,
|
|
1635
|
+
"db_id": db_id,
|
|
1636
|
+
"db_connection_info": db_connection_info,
|
|
1637
|
+
"attempt": 0,
|
|
1638
|
+
"max_attempts": max_attempts,
|
|
1639
|
+
"sql_history": [],
|
|
1640
|
+
"error_history": [],
|
|
1641
|
+
"current_sql": None,
|
|
1642
|
+
"execution_result": None,
|
|
1643
|
+
"execution_error": None,
|
|
1644
|
+
"schema_probes": [],
|
|
1645
|
+
"reasoning": [],
|
|
1646
|
+
"final_sql": None,
|
|
1647
|
+
"final_df": None,
|
|
1648
|
+
"messages": [],
|
|
1649
|
+
"llm_judge_verdict": None,
|
|
1650
|
+
"llm_judge_confidence": None,
|
|
1651
|
+
"llm_judge_reasoning": None,
|
|
1652
|
+
"agent_trace": [],
|
|
1653
|
+
"token_usage_per_attempt": [], # Token usage for each attempt
|
|
1654
|
+
"total_token_usage": {}, # Aggregated token usage
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
# Initialize database executor
|
|
1658
|
+
db_executor = DatabaseExecutor(db_type, db_connection_info, db_id)
|
|
1659
|
+
await db_executor.initialize()
|
|
1660
|
+
|
|
1661
|
+
# Build system prompt - use v5 if version is v5, otherwise v4
|
|
1662
|
+
if self.version == "v5":
|
|
1663
|
+
system_prompt = self._build_v5_system_prompt(state)
|
|
1664
|
+
else:
|
|
1665
|
+
system_prompt = self._build_v4_system_prompt(state)
|
|
1666
|
+
|
|
1667
|
+
# Initialize conversation
|
|
1668
|
+
messages = [
|
|
1669
|
+
{"role": "system", "content": system_prompt},
|
|
1670
|
+
{
|
|
1671
|
+
"role": "user",
|
|
1672
|
+
"content": f"Question: {question}\n\nPlease help me write the SQL query to answer this question.",
|
|
1673
|
+
},
|
|
1674
|
+
]
|
|
1675
|
+
|
|
1676
|
+
try:
|
|
1677
|
+
# Main agent loop - LLM decides what to do
|
|
1678
|
+
action_count = 0
|
|
1679
|
+
# v5 needs more actions for thorough exploration (4-7 actions typical)
|
|
1680
|
+
# v4 is more direct (1-2 actions typical)
|
|
1681
|
+
action_multiplier = 5 if self.version == "v5" else 3
|
|
1682
|
+
max_actions = (
|
|
1683
|
+
max_attempts * action_multiplier
|
|
1684
|
+
) # Allow multiple actions per attempt
|
|
1685
|
+
|
|
1686
|
+
while action_count < max_actions and state["attempt"] < max_attempts:
|
|
1687
|
+
action_count += 1
|
|
1688
|
+
logger.debug(f"V4 Agent action {action_count}/{max_actions}")
|
|
1689
|
+
|
|
1690
|
+
# Get LLM's next action
|
|
1691
|
+
llm_response = None
|
|
1692
|
+
token_usage = None
|
|
1693
|
+
try:
|
|
1694
|
+
if isinstance(client, WXAIClientChatAPI):
|
|
1695
|
+
response = client.model.chat(messages=messages)
|
|
1696
|
+
# Handle both response formats
|
|
1697
|
+
if "choices" in response and len(response["choices"]) > 0:
|
|
1698
|
+
message = response["choices"][0].get("message", {})
|
|
1699
|
+
llm_response = message.get(
|
|
1700
|
+
"content", message.get("text", "")
|
|
1701
|
+
).strip()
|
|
1702
|
+
else:
|
|
1703
|
+
raise ValueError(f"Unexpected response format: {response}")
|
|
1704
|
+
# Extract token usage
|
|
1705
|
+
usage = response.get("usage", {})
|
|
1706
|
+
if usage:
|
|
1707
|
+
token_usage = {
|
|
1708
|
+
"prompt_tokens": usage.get("prompt_tokens", 0),
|
|
1709
|
+
"completion_tokens": usage.get("completion_tokens", 0),
|
|
1710
|
+
"total_tokens": usage.get("total_tokens", 0),
|
|
1711
|
+
}
|
|
1712
|
+
elif isinstance(client, ClaudeClientChatAPI):
|
|
1713
|
+
# Claude has system separate
|
|
1714
|
+
claude_messages = [
|
|
1715
|
+
msg for msg in messages if msg["role"] != "system"
|
|
1716
|
+
]
|
|
1717
|
+
claude_system = next(
|
|
1718
|
+
(
|
|
1719
|
+
msg["content"]
|
|
1720
|
+
for msg in messages
|
|
1721
|
+
if msg["role"] == "system"
|
|
1722
|
+
),
|
|
1723
|
+
"",
|
|
1724
|
+
)
|
|
1725
|
+
response = client.client.messages.create(
|
|
1726
|
+
model=client.model_name,
|
|
1727
|
+
max_tokens=client.model_parameters.get("max_tokens", 1024),
|
|
1728
|
+
system=claude_system,
|
|
1729
|
+
messages=claude_messages,
|
|
1730
|
+
)
|
|
1731
|
+
llm_response = response.content[0].text.strip()
|
|
1732
|
+
# Extract token usage from Claude
|
|
1733
|
+
if hasattr(response, 'usage'):
|
|
1734
|
+
token_usage = {
|
|
1735
|
+
"prompt_tokens": getattr(response.usage, 'input_tokens', 0),
|
|
1736
|
+
"completion_tokens": getattr(response.usage, 'output_tokens', 0),
|
|
1737
|
+
"total_tokens": getattr(response.usage, 'input_tokens', 0) + getattr(response.usage, 'output_tokens', 0),
|
|
1738
|
+
}
|
|
1739
|
+
elif isinstance(client, VLLMClientChatAPI):
|
|
1740
|
+
response = client._make_chat_request(messages)
|
|
1741
|
+
llm_response = response["choices"][0]["message"][
|
|
1742
|
+
"content"
|
|
1743
|
+
].strip()
|
|
1744
|
+
# Extract token usage
|
|
1745
|
+
usage = response.get("usage", {})
|
|
1746
|
+
if usage:
|
|
1747
|
+
token_usage = {
|
|
1748
|
+
"prompt_tokens": usage.get("prompt_tokens", 0),
|
|
1749
|
+
"completion_tokens": usage.get("completion_tokens", 0),
|
|
1750
|
+
"total_tokens": usage.get("total_tokens", 0),
|
|
1751
|
+
}
|
|
1752
|
+
elif isinstance(client, OpenAIClientChatAPI):
|
|
1753
|
+
# OpenAI client uses the standard chat completions API
|
|
1754
|
+
response = client.client.chat.completions.create(
|
|
1755
|
+
model=client.model_name,
|
|
1756
|
+
messages=messages,
|
|
1757
|
+
**client.model_parameters,
|
|
1758
|
+
)
|
|
1759
|
+
llm_response = response.choices[0].message.content.strip()
|
|
1760
|
+
# Extract token usage from OpenAI
|
|
1761
|
+
if hasattr(response, 'usage') and response.usage:
|
|
1762
|
+
token_usage = {
|
|
1763
|
+
"prompt_tokens": response.usage.prompt_tokens,
|
|
1764
|
+
"completion_tokens": response.usage.completion_tokens,
|
|
1765
|
+
"total_tokens": response.usage.total_tokens,
|
|
1766
|
+
}
|
|
1767
|
+
else:
|
|
1768
|
+
raise ValueError(f"Unsupported client type: {type(client)}")
|
|
1769
|
+
|
|
1770
|
+
if not llm_response:
|
|
1771
|
+
raise ValueError("Empty response from LLM")
|
|
1772
|
+
|
|
1773
|
+
# Track token usage for this attempt
|
|
1774
|
+
if token_usage:
|
|
1775
|
+
state["token_usage_per_attempt"].append(token_usage)
|
|
1776
|
+
# Update total token usage
|
|
1777
|
+
for key in ["prompt_tokens", "completion_tokens", "total_tokens"]:
|
|
1778
|
+
state["total_token_usage"][key] = (
|
|
1779
|
+
state["total_token_usage"].get(key, 0) + token_usage.get(key, 0)
|
|
1780
|
+
)
|
|
1781
|
+
|
|
1782
|
+
# Record trace
|
|
1783
|
+
state["agent_trace"].append(
|
|
1784
|
+
{
|
|
1785
|
+
"action_num": action_count,
|
|
1786
|
+
"messages": messages.copy(),
|
|
1787
|
+
"response": llm_response,
|
|
1788
|
+
"token_usage": token_usage,
|
|
1789
|
+
}
|
|
1790
|
+
)
|
|
1791
|
+
|
|
1792
|
+
# Parse JSON response
|
|
1793
|
+
llm_response_clean = llm_response
|
|
1794
|
+
if "```json" in llm_response:
|
|
1795
|
+
llm_response_clean = (
|
|
1796
|
+
llm_response.split("```json")[1].split("```")[0].strip()
|
|
1797
|
+
)
|
|
1798
|
+
elif "```" in llm_response:
|
|
1799
|
+
llm_response_clean = (
|
|
1800
|
+
llm_response.split("```")[1].split("```")[0].strip()
|
|
1801
|
+
)
|
|
1802
|
+
|
|
1803
|
+
action_data = json.loads(llm_response_clean)
|
|
1804
|
+
thought = action_data.get("thought", "")
|
|
1805
|
+
action = action_data.get("action", "")
|
|
1806
|
+
action_input = action_data.get("action_input", {})
|
|
1807
|
+
|
|
1808
|
+
state["reasoning"].append(f"[Action {action_count}] {thought}")
|
|
1809
|
+
logger.debug(
|
|
1810
|
+
f"LLM chose action: {action} with input: {action_input}"
|
|
1811
|
+
)
|
|
1812
|
+
|
|
1813
|
+
except (json.JSONDecodeError, KeyError, IndexError, ValueError) as e:
|
|
1814
|
+
response_preview = (
|
|
1815
|
+
llm_response[:200] if llm_response else "No response received"
|
|
1816
|
+
)
|
|
1817
|
+
error_msg = f"Failed to parse LLM response: {e}\nResponse: {response_preview}"
|
|
1818
|
+
logger.error(error_msg)
|
|
1819
|
+
|
|
1820
|
+
if llm_response:
|
|
1821
|
+
messages.append({"role": "assistant", "content": llm_response})
|
|
1822
|
+
messages.append(
|
|
1823
|
+
{
|
|
1824
|
+
"role": "user",
|
|
1825
|
+
"content": f"Error: Your response must be valid JSON with 'thought', 'action', and 'action_input' fields. Please try again.",
|
|
1826
|
+
}
|
|
1827
|
+
)
|
|
1828
|
+
else:
|
|
1829
|
+
# If we couldn't get a response at all, log and break
|
|
1830
|
+
logger.error(
|
|
1831
|
+
"Failed to get response from LLM, stopping agent loop"
|
|
1832
|
+
)
|
|
1833
|
+
break
|
|
1834
|
+
continue
|
|
1835
|
+
|
|
1836
|
+
# Execute the chosen action
|
|
1837
|
+
if action == "generate_sql":
|
|
1838
|
+
state["attempt"] += 1
|
|
1839
|
+
sql = action_input.get("sql", "").strip()
|
|
1840
|
+
reasoning = action_input.get("reasoning", "")
|
|
1841
|
+
|
|
1842
|
+
if not sql:
|
|
1843
|
+
observation = "Error: No SQL provided"
|
|
1844
|
+
else:
|
|
1845
|
+
state["current_sql"] = sql
|
|
1846
|
+
state["sql_history"].append(sql)
|
|
1847
|
+
|
|
1848
|
+
# Execute SQL
|
|
1849
|
+
state = await self._execute_sql_node(state, db_executor)
|
|
1850
|
+
|
|
1851
|
+
if state.get("execution_result") and state[
|
|
1852
|
+
"execution_result"
|
|
1853
|
+
].get("success"):
|
|
1854
|
+
observation = f"✅ Success! Query executed successfully.\nRows returned: {state['execution_result'].get('row_count', 0)}\nResult preview: {str(state['execution_result'].get('preview', ''))[:200]}"
|
|
1855
|
+
# Note: Don't set final_sql/final_df here - only set them when agent explicitly calls submit_final_answer
|
|
1856
|
+
# This allows v5 to use generate_sql for exploration without triggering early stop
|
|
1857
|
+
else:
|
|
1858
|
+
observation = f"❌ SQL execution failed.\nError: {state.get('execution_error', 'Unknown error')}"
|
|
1859
|
+
state["error_history"].append(
|
|
1860
|
+
state.get("execution_error", "")
|
|
1861
|
+
)
|
|
1862
|
+
|
|
1863
|
+
messages.append({"role": "assistant", "content": llm_response})
|
|
1864
|
+
messages.append(
|
|
1865
|
+
{
|
|
1866
|
+
"role": "user",
|
|
1867
|
+
"content": f"Observation: {observation}\n\nWhat's your next action?",
|
|
1868
|
+
}
|
|
1869
|
+
)
|
|
1870
|
+
|
|
1871
|
+
elif action == "probe_schema":
|
|
1872
|
+
table_name = action_input.get("table_name", "")
|
|
1873
|
+
reason = action_input.get("reason", "")
|
|
1874
|
+
|
|
1875
|
+
if not table_name:
|
|
1876
|
+
observation = "Error: No table name provided"
|
|
1877
|
+
else:
|
|
1878
|
+
# Probe schema
|
|
1879
|
+
state = await self._probe_schema_node(state, db_executor)
|
|
1880
|
+
schema_info = (
|
|
1881
|
+
state.get("schema_probes", [])[-1]
|
|
1882
|
+
if state.get("schema_probes")
|
|
1883
|
+
else "No schema information retrieved"
|
|
1884
|
+
)
|
|
1885
|
+
observation = f"Schema information for '{table_name}':\n{schema_info[:500]}"
|
|
1886
|
+
|
|
1887
|
+
messages.append({"role": "assistant", "content": llm_response})
|
|
1888
|
+
messages.append(
|
|
1889
|
+
{
|
|
1890
|
+
"role": "user",
|
|
1891
|
+
"content": f"Observation: {observation}\n\nWhat's your next action?",
|
|
1892
|
+
}
|
|
1893
|
+
)
|
|
1894
|
+
|
|
1895
|
+
elif action == "analyze_error":
|
|
1896
|
+
analysis = action_input.get("analysis", "")
|
|
1897
|
+
fixable = action_input.get("fixable", True)
|
|
1898
|
+
|
|
1899
|
+
state["reasoning"].append(
|
|
1900
|
+
f"Error analysis: {analysis} (Fixable: {fixable})"
|
|
1901
|
+
)
|
|
1902
|
+
|
|
1903
|
+
if not fixable:
|
|
1904
|
+
observation = "Based on your analysis, this error is not fixable. Consider submitting your best attempt."
|
|
1905
|
+
else:
|
|
1906
|
+
observation = (
|
|
1907
|
+
"Error analyzed. What's your next action to fix it?"
|
|
1908
|
+
)
|
|
1909
|
+
|
|
1910
|
+
messages.append({"role": "assistant", "content": llm_response})
|
|
1911
|
+
messages.append(
|
|
1912
|
+
{
|
|
1913
|
+
"role": "user",
|
|
1914
|
+
"content": f"Observation: {observation}\n\nWhat's your next action?",
|
|
1915
|
+
}
|
|
1916
|
+
)
|
|
1917
|
+
|
|
1918
|
+
elif action == "submit_final_answer":
|
|
1919
|
+
sql = action_input.get("sql", "")
|
|
1920
|
+
confidence = action_input.get("confidence", "medium")
|
|
1921
|
+
explanation = action_input.get("explanation", "")
|
|
1922
|
+
|
|
1923
|
+
# Use the submitted SQL if provided, otherwise use current_sql
|
|
1924
|
+
submitted_sql = sql if sql else state.get("current_sql")
|
|
1925
|
+
|
|
1926
|
+
# If SQL was submitted but not executed yet, execute it now
|
|
1927
|
+
if submitted_sql and not state.get("final_df"):
|
|
1928
|
+
logger.debug(
|
|
1929
|
+
"SQL submitted in final_answer but not executed yet - executing now"
|
|
1930
|
+
)
|
|
1931
|
+
state["current_sql"] = submitted_sql
|
|
1932
|
+
state["sql_history"].append(submitted_sql)
|
|
1933
|
+
state["attempt"] += 1
|
|
1934
|
+
|
|
1935
|
+
# Execute the SQL
|
|
1936
|
+
state = await self._execute_sql_node(state, db_executor)
|
|
1937
|
+
|
|
1938
|
+
# Check if execution was successful
|
|
1939
|
+
if state.get("execution_result") and state[
|
|
1940
|
+
"execution_result"
|
|
1941
|
+
].get("success"):
|
|
1942
|
+
state["final_sql"] = submitted_sql
|
|
1943
|
+
state["final_df"] = state.get("execution_result", {}).get(
|
|
1944
|
+
"df"
|
|
1945
|
+
)
|
|
1946
|
+
logger.debug(
|
|
1947
|
+
"SQL executed successfully in submit_final_answer"
|
|
1948
|
+
)
|
|
1949
|
+
else:
|
|
1950
|
+
# Execution failed - still set final_sql but no dataframe
|
|
1951
|
+
state["final_sql"] = submitted_sql
|
|
1952
|
+
logger.warning(
|
|
1953
|
+
f"SQL execution failed in submit_final_answer: {state.get('execution_error')}"
|
|
1954
|
+
)
|
|
1955
|
+
elif (
|
|
1956
|
+
state.get("final_sql") and sql and sql != state.get("final_sql")
|
|
1957
|
+
):
|
|
1958
|
+
# LLM submitted different SQL than what was executed
|
|
1959
|
+
logger.warning(
|
|
1960
|
+
f"LLM submitted different SQL in final_answer than what was executed. "
|
|
1961
|
+
f"Using the executed SQL and dataframe."
|
|
1962
|
+
)
|
|
1963
|
+
# Keep the existing final_sql and final_df from execution
|
|
1964
|
+
elif not state.get("final_sql"):
|
|
1965
|
+
# No SQL executed at all - just set it (shouldn't happen but handle it)
|
|
1966
|
+
state["final_sql"] = submitted_sql
|
|
1967
|
+
|
|
1968
|
+
state["reasoning"].append(
|
|
1969
|
+
f"Final answer submitted with {confidence} confidence: {explanation}"
|
|
1970
|
+
)
|
|
1971
|
+
logger.debug(
|
|
1972
|
+
f"Agent submitted final answer with {confidence} confidence"
|
|
1973
|
+
)
|
|
1974
|
+
break
|
|
1975
|
+
|
|
1976
|
+
else:
|
|
1977
|
+
observation = f"Unknown action: {action}. Please use one of: generate_sql, probe_schema, analyze_error, submit_final_answer"
|
|
1978
|
+
messages.append({"role": "assistant", "content": llm_response})
|
|
1979
|
+
messages.append(
|
|
1980
|
+
{
|
|
1981
|
+
"role": "user",
|
|
1982
|
+
"content": f"Error: {observation}\n\nWhat's your next action?",
|
|
1983
|
+
}
|
|
1984
|
+
)
|
|
1985
|
+
|
|
1986
|
+
# Check if we should stop
|
|
1987
|
+
if state.get("final_sql") and state.get("final_df"):
|
|
1988
|
+
logger.debug("Agent has successful result, stopping")
|
|
1989
|
+
break
|
|
1990
|
+
|
|
1991
|
+
if state["attempt"] >= max_attempts:
|
|
1992
|
+
logger.debug("Max attempts reached, stopping")
|
|
1993
|
+
break
|
|
1994
|
+
|
|
1995
|
+
# Finalize - if agent didn't call submit_final_answer, try to find best SQL
|
|
1996
|
+
if not state.get("final_sql"):
|
|
1997
|
+
# Look through SQL history for the best candidate (avoid exploratory queries)
|
|
1998
|
+
best_sql = None
|
|
1999
|
+
best_df = None
|
|
2000
|
+
|
|
2001
|
+
def is_exploratory(sql: str) -> bool:
|
|
2002
|
+
"""Check if a SQL query is exploratory (not a real answer)."""
|
|
2003
|
+
sql_lower = sql.lower().strip()
|
|
2004
|
+
|
|
2005
|
+
# Skip schema probing queries
|
|
2006
|
+
if any(
|
|
2007
|
+
pattern in sql_lower
|
|
2008
|
+
for pattern in [
|
|
2009
|
+
"information_schema",
|
|
2010
|
+
"pragma_table_info",
|
|
2011
|
+
"pragma table_info",
|
|
2012
|
+
"show columns",
|
|
2013
|
+
"describe ",
|
|
2014
|
+
"desc ",
|
|
2015
|
+
"pg_tables",
|
|
2016
|
+
"pg_catalog",
|
|
2017
|
+
]
|
|
2018
|
+
):
|
|
2019
|
+
return True
|
|
2020
|
+
|
|
2021
|
+
# Skip queries with DISTINCT + LIMIT (usually exploratory)
|
|
2022
|
+
if "distinct" in sql_lower and "limit" in sql_lower:
|
|
2023
|
+
return True
|
|
2024
|
+
|
|
2025
|
+
# Skip small SELECT * or SELECT [1-2 columns] with LIMIT (exploratory)
|
|
2026
|
+
if "limit" in sql_lower:
|
|
2027
|
+
# Count SELECT items (rough heuristic)
|
|
2028
|
+
if sql_lower.count("select") == 1: # Single SELECT
|
|
2029
|
+
select_to_from = sql_lower.split("from")[0].split("select")[
|
|
2030
|
+
1
|
|
2031
|
+
]
|
|
2032
|
+
# If selecting * or very few columns
|
|
2033
|
+
if "*" in select_to_from or select_to_from.count(",") <= 1:
|
|
2034
|
+
return True
|
|
2035
|
+
|
|
2036
|
+
return False
|
|
2037
|
+
|
|
2038
|
+
# Go through sql_history in reverse (most recent first)
|
|
2039
|
+
for sql in reversed(state.get("sql_history", [])):
|
|
2040
|
+
if not is_exploratory(sql):
|
|
2041
|
+
best_sql = sql
|
|
2042
|
+
break
|
|
2043
|
+
|
|
2044
|
+
# If we found a good SQL, use it
|
|
2045
|
+
if best_sql:
|
|
2046
|
+
state["final_sql"] = best_sql
|
|
2047
|
+
logger.debug(
|
|
2048
|
+
f"Using best SQL from history as fallback: {best_sql[:100]}..."
|
|
2049
|
+
)
|
|
2050
|
+
# Try to find the corresponding dataframe from execution_result if available
|
|
2051
|
+
if state.get("execution_result") and state["execution_result"].get(
|
|
2052
|
+
"success"
|
|
2053
|
+
):
|
|
2054
|
+
if state.get("current_sql") == best_sql:
|
|
2055
|
+
# The best SQL is the last one executed
|
|
2056
|
+
state["final_df"] = state["execution_result"].get("df")
|
|
2057
|
+
elif state.get("current_sql"):
|
|
2058
|
+
# No good SQL found, use current as last resort (or don't return anything)
|
|
2059
|
+
if not is_exploratory(state["current_sql"]):
|
|
2060
|
+
state["final_sql"] = state["current_sql"]
|
|
2061
|
+
logger.warning(
|
|
2062
|
+
"No good SQL found in history, using current_sql as fallback"
|
|
2063
|
+
)
|
|
2064
|
+
else:
|
|
2065
|
+
logger.warning(
|
|
2066
|
+
"Agent exhausted attempts without finding valid SQL"
|
|
2067
|
+
)
|
|
2068
|
+
|
|
2069
|
+
finally:
|
|
2070
|
+
await db_executor.close()
|
|
2071
|
+
|
|
2072
|
+
return {
|
|
2073
|
+
"predicted_sql": state.get("final_sql"),
|
|
2074
|
+
"predicted_df": state.get("final_df"),
|
|
2075
|
+
"sql_execution_error": state.get("execution_error")
|
|
2076
|
+
if not state.get("execution_result")
|
|
2077
|
+
or not state["execution_result"].get("success")
|
|
2078
|
+
else None,
|
|
2079
|
+
"execution_time_ms": state.get("execution_result", {}).get("execution_time_ms") if state.get("execution_result") else None,
|
|
2080
|
+
"attempts": state["attempt"],
|
|
2081
|
+
"reasoning": state.get("reasoning", []),
|
|
2082
|
+
"agent_trace": state.get("agent_trace", []),
|
|
2083
|
+
"token_usage": state.get("total_token_usage"), # Aggregated token usage
|
|
2084
|
+
"token_usage_per_attempt": state.get("token_usage_per_attempt", []), # Per-attempt breakdown
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
async def generate_sql(
|
|
2088
|
+
self,
|
|
2089
|
+
idx,
|
|
2090
|
+
record,
|
|
2091
|
+
schema,
|
|
2092
|
+
db_type,
|
|
2093
|
+
pipeline_id,
|
|
2094
|
+
model_name,
|
|
2095
|
+
model_parameters,
|
|
2096
|
+
client,
|
|
2097
|
+
predictions_data,
|
|
2098
|
+
semaphore,
|
|
2099
|
+
benchmark_id: str,
|
|
2100
|
+
db_connection_info: dict,
|
|
2101
|
+
max_attempts: int = 3,
|
|
2102
|
+
timeout=1200,
|
|
2103
|
+
force_rerun: bool = False,
|
|
2104
|
+
skip_inference_error_retries: bool = False,
|
|
2105
|
+
):
|
|
2106
|
+
"""Generate SQL for a single record using the agentic pipeline."""
|
|
2107
|
+
async with semaphore:
|
|
2108
|
+
question_id = get_question_id(record)
|
|
2109
|
+
try:
|
|
2110
|
+
utterance = get_utterance(record)
|
|
2111
|
+
evidence = record.get("evidence", None)
|
|
2112
|
+
db_schema = (
|
|
2113
|
+
schema.get(record.get("db_id"))
|
|
2114
|
+
if "tables" not in schema
|
|
2115
|
+
else schema
|
|
2116
|
+
)
|
|
2117
|
+
db_id = record.get("db_id", None)
|
|
2118
|
+
|
|
2119
|
+
existing = next(
|
|
2120
|
+
(p for p in predictions_data if p.get("id") == question_id), None
|
|
2121
|
+
)
|
|
2122
|
+
if existing:
|
|
2123
|
+
if "predictions" not in existing:
|
|
2124
|
+
existing["predictions"] = {}
|
|
2125
|
+
pred = existing["predictions"].get(pipeline_id)
|
|
2126
|
+
if pred:
|
|
2127
|
+
# Always retry if there was an inference error (unless skip flag is set)
|
|
2128
|
+
if "inference_error" in pred and not skip_inference_error_retries:
|
|
2129
|
+
logger.info(
|
|
2130
|
+
f"Retrying failed inference for id={question_id}, pipeline={pipeline_id}"
|
|
2131
|
+
)
|
|
2132
|
+
# Continue with inference (don't return)
|
|
2133
|
+
elif not force_rerun:
|
|
2134
|
+
logger.info(
|
|
2135
|
+
f"Prediction for id={question_id}, pipeline={pipeline_id} already exists. Skipping..."
|
|
2136
|
+
)
|
|
2137
|
+
return
|
|
2138
|
+
|
|
2139
|
+
logger.debug(f"Starting agentic inference for record #{idx}")
|
|
2140
|
+
|
|
2141
|
+
# Track inference time
|
|
2142
|
+
inference_start = time.perf_counter()
|
|
2143
|
+
|
|
2144
|
+
# Run agentic pipeline - use v4/v5 for truly agentic, otherwise use standard
|
|
2145
|
+
if self.version in ["v4", "v5"]:
|
|
2146
|
+
logger.debug(
|
|
2147
|
+
f"Using {self.version} (truly agentic) pipeline with LLM-controlled workflow"
|
|
2148
|
+
)
|
|
2149
|
+
result = await asyncio.wait_for(
|
|
2150
|
+
self._run_agent_v4(
|
|
2151
|
+
utterance,
|
|
2152
|
+
db_schema,
|
|
2153
|
+
db_type,
|
|
2154
|
+
db_connection_info,
|
|
2155
|
+
db_id,
|
|
2156
|
+
client,
|
|
2157
|
+
max_attempts,
|
|
2158
|
+
),
|
|
2159
|
+
timeout=timeout,
|
|
2160
|
+
)
|
|
2161
|
+
else:
|
|
2162
|
+
result = await asyncio.wait_for(
|
|
2163
|
+
self._run_agent(
|
|
2164
|
+
utterance,
|
|
2165
|
+
db_schema,
|
|
2166
|
+
db_type,
|
|
2167
|
+
db_connection_info,
|
|
2168
|
+
db_id,
|
|
2169
|
+
client,
|
|
2170
|
+
max_attempts,
|
|
2171
|
+
),
|
|
2172
|
+
timeout=timeout,
|
|
2173
|
+
)
|
|
2174
|
+
|
|
2175
|
+
inference_end = time.perf_counter()
|
|
2176
|
+
inference_time_ms = (inference_end - inference_start) * 1000
|
|
2177
|
+
|
|
2178
|
+
logger.debug(f"Finished agentic inference for record #{idx}")
|
|
2179
|
+
|
|
2180
|
+
if existing:
|
|
2181
|
+
existing["predictions"][pipeline_id] = {
|
|
2182
|
+
"predicted_sql": result["predicted_sql"],
|
|
2183
|
+
"predicted_df": result.get("predicted_df"),
|
|
2184
|
+
"sql_execution_error": result.get("sql_execution_error"),
|
|
2185
|
+
"model_name": model_name,
|
|
2186
|
+
"model_parameters": model_parameters,
|
|
2187
|
+
"agent_attempts": result["attempts"],
|
|
2188
|
+
"agent_reasoning": result.get("reasoning", []),
|
|
2189
|
+
"inference_time_ms": round(inference_time_ms, 2),
|
|
2190
|
+
"execution_time_ms": result.get("execution_time_ms"),
|
|
2191
|
+
"agent_trace": result.get(
|
|
2192
|
+
"agent_trace", []
|
|
2193
|
+
), # Full LLM interaction history
|
|
2194
|
+
"token_usage": result.get("token_usage"), # Aggregated token usage
|
|
2195
|
+
"token_usage_per_attempt": result.get("token_usage_per_attempt", []), # Per-attempt breakdown
|
|
2196
|
+
}
|
|
2197
|
+
else:
|
|
2198
|
+
record["predictions"] = {
|
|
2199
|
+
pipeline_id: {
|
|
2200
|
+
"predicted_sql": result["predicted_sql"],
|
|
2201
|
+
"predicted_df": result.get("predicted_df"),
|
|
2202
|
+
"sql_execution_error": result.get("sql_execution_error"),
|
|
2203
|
+
"model_name": model_name,
|
|
2204
|
+
"model_parameters": model_parameters,
|
|
2205
|
+
"agent_attempts": result["attempts"],
|
|
2206
|
+
"agent_reasoning": result.get("reasoning", []),
|
|
2207
|
+
"inference_time_ms": round(inference_time_ms, 2),
|
|
2208
|
+
"execution_time_ms": result.get("execution_time_ms"),
|
|
2209
|
+
"agent_trace": result.get(
|
|
2210
|
+
"agent_trace", []
|
|
2211
|
+
), # Full LLM interaction history
|
|
2212
|
+
"token_usage": result.get("token_usage"), # Aggregated token usage
|
|
2213
|
+
"token_usage_per_attempt": result.get("token_usage_per_attempt", []), # Per-attempt breakdown
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
predictions_data.append(record)
|
|
2217
|
+
|
|
2218
|
+
except TimeoutError as e:
|
|
2219
|
+
logger.error(
|
|
2220
|
+
f"Timeout in record {idx} (question id={question_id}): {e}"
|
|
2221
|
+
)
|
|
2222
|
+
# Create prediction record with timeout error
|
|
2223
|
+
error_record = {
|
|
2224
|
+
"predicted_sql": None,
|
|
2225
|
+
"model_name": model_name,
|
|
2226
|
+
"model_parameters": model_parameters,
|
|
2227
|
+
"inference_error": f"TimeoutError: {str(e)}",
|
|
2228
|
+
"inference_time_ms": timeout * 1000, # Max time reached
|
|
2229
|
+
}
|
|
2230
|
+
if existing:
|
|
2231
|
+
existing["predictions"][pipeline_id] = error_record
|
|
2232
|
+
else:
|
|
2233
|
+
record["predictions"] = {pipeline_id: error_record}
|
|
2234
|
+
predictions_data.append(record)
|
|
2235
|
+
|
|
2236
|
+
except Exception as e:
|
|
2237
|
+
logger.error(f"Record {idx} (question id={question_id}) failed: {e}")
|
|
2238
|
+
# Create prediction record with inference error
|
|
2239
|
+
error_record = {
|
|
2240
|
+
"predicted_sql": None,
|
|
2241
|
+
"model_name": model_name,
|
|
2242
|
+
"model_parameters": model_parameters,
|
|
2243
|
+
"inference_error": str(e),
|
|
2244
|
+
"raw_response": getattr(e, 'response', None), # Capture raw response if available
|
|
2245
|
+
}
|
|
2246
|
+
if existing:
|
|
2247
|
+
existing["predictions"][pipeline_id] = error_record
|
|
2248
|
+
else:
|
|
2249
|
+
record["predictions"] = {pipeline_id: error_record}
|
|
2250
|
+
predictions_data.append(record)
|
|
2251
|
+
|
|
2252
|
+
def run_pipeline(
|
|
2253
|
+
self,
|
|
2254
|
+
benchmark_id: str,
|
|
2255
|
+
model_name: str,
|
|
2256
|
+
model_parameters: dict,
|
|
2257
|
+
max_num_threads: int = 16,
|
|
2258
|
+
max_attempts: int = 3,
|
|
2259
|
+
force_rerun: bool = False,
|
|
2260
|
+
skip_inference_error_retries: bool = False,
|
|
2261
|
+
):
|
|
2262
|
+
"""Run the agentic pipeline for a benchmark."""
|
|
2263
|
+
# Follow the same naming convention as baseline: model_name + method descriptor
|
|
2264
|
+
# Naming: agentic-baseline0 (original), agentic-baseline1, agentic-baseline2, agentic-baseline3, agentic-baseline4 (truly agentic), agentic-baseline5 (improved agentic)
|
|
2265
|
+
if self.version == "v5":
|
|
2266
|
+
pipeline_id = f"{model_name}-agentic-baseline5-{max_attempts}attempts"
|
|
2267
|
+
elif self.version == "v4":
|
|
2268
|
+
pipeline_id = f"{model_name}-agentic-baseline4-{max_attempts}attempts"
|
|
2269
|
+
elif self.version == "v3":
|
|
2270
|
+
pipeline_id = f"{model_name}-agentic-baseline3-{max_attempts}attempts"
|
|
2271
|
+
elif self.version == "v2":
|
|
2272
|
+
pipeline_id = f"{model_name}-agentic-baseline2-{max_attempts}attempts"
|
|
2273
|
+
elif self.version == "v1":
|
|
2274
|
+
pipeline_id = f"{model_name}-agentic-baseline1-{max_attempts}attempts"
|
|
2275
|
+
elif self.version == "v0":
|
|
2276
|
+
pipeline_id = f"{model_name}-agentic-baseline0-{max_attempts}attempts"
|
|
2277
|
+
else:
|
|
2278
|
+
# Fallback for backward compatibility
|
|
2279
|
+
if self.use_baseline_prompt:
|
|
2280
|
+
pipeline_id = f"{model_name}-agentic-baseline1-{max_attempts}attempts"
|
|
2281
|
+
else:
|
|
2282
|
+
pipeline_id = f"{model_name}-agentic-baseline0-{max_attempts}attempts"
|
|
2283
|
+
logger.debug(
|
|
2284
|
+
f"🚀 Running agentic inference for benchmark {benchmark_id}, pipeline: {pipeline_id}"
|
|
2285
|
+
)
|
|
2286
|
+
|
|
2287
|
+
benchmark_info = get_benchmark_info(benchmark_id)
|
|
2288
|
+
db_type = benchmark_info["db_engine"]["db_type"]
|
|
2289
|
+
|
|
2290
|
+
with open(benchmark_info["schema_json_path"], "r") as f:
|
|
2291
|
+
schema = json.load(f)
|
|
2292
|
+
with open(benchmark_info["benchmark_json_path"], "r") as fin:
|
|
2293
|
+
data = json.load(fin)
|
|
2294
|
+
if os.path.exists(benchmark_info["predictions_path"]):
|
|
2295
|
+
with open(benchmark_info["predictions_path"], "r") as pf:
|
|
2296
|
+
predictions_data = json.load(pf)
|
|
2297
|
+
else:
|
|
2298
|
+
predictions_data = []
|
|
2299
|
+
|
|
2300
|
+
client = self._create_llm_client(model_name, model_parameters)
|
|
2301
|
+
db_connection_info = benchmark_info["db_engine"]
|
|
2302
|
+
|
|
2303
|
+
async def run_all():
|
|
2304
|
+
semaphore = asyncio.Semaphore(max_num_threads)
|
|
2305
|
+
tasks = [
|
|
2306
|
+
self.generate_sql(
|
|
2307
|
+
idx,
|
|
2308
|
+
obj,
|
|
2309
|
+
schema,
|
|
2310
|
+
db_type,
|
|
2311
|
+
pipeline_id,
|
|
2312
|
+
model_name,
|
|
2313
|
+
model_parameters,
|
|
2314
|
+
client,
|
|
2315
|
+
predictions_data,
|
|
2316
|
+
semaphore,
|
|
2317
|
+
benchmark_id,
|
|
2318
|
+
db_connection_info,
|
|
2319
|
+
max_attempts,
|
|
2320
|
+
force_rerun=force_rerun,
|
|
2321
|
+
skip_inference_error_retries=skip_inference_error_retries,
|
|
2322
|
+
)
|
|
2323
|
+
for idx, obj in enumerate(data)
|
|
2324
|
+
]
|
|
2325
|
+
await asyncio.gather(*tasks)
|
|
2326
|
+
|
|
2327
|
+
asyncio.run(run_all())
|
|
2328
|
+
|
|
2329
|
+
with open(benchmark_info["predictions_path"], "w") as fout:
|
|
2330
|
+
json.dump(predictions_data, fout, ensure_ascii=False, indent=2)
|
|
2331
|
+
|
|
2332
|
+
logger.debug(
|
|
2333
|
+
f"✅ Agentic inference completed for benchmark '{benchmark_id}', pipeline: {pipeline_id}."
|
|
2334
|
+
)
|
|
2335
|
+
logger.info(f"Predictions written to {benchmark_info['predictions_path']}")
|