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.
Files changed (28) hide show
  1. text2sql_eval_toolkit/__init__.py +107 -0
  2. text2sql_eval_toolkit/analysis/__init__.py +5 -0
  3. text2sql_eval_toolkit/analysis/error_analysis.py +335 -0
  4. text2sql_eval_toolkit/analysis/report_tools.py +719 -0
  5. text2sql_eval_toolkit/config_args.py +65 -0
  6. text2sql_eval_toolkit/data/__init__.py +4 -0
  7. text2sql_eval_toolkit/data/benchmarks.json +69 -0
  8. text2sql_eval_toolkit/data/test-benchmarks.json +69 -0
  9. text2sql_eval_toolkit/env_loader.py +55 -0
  10. text2sql_eval_toolkit/evaluation/__init__.py +26 -0
  11. text2sql_eval_toolkit/evaluation/evaluation_tools.py +759 -0
  12. text2sql_eval_toolkit/evaluation/llm_as_judge.py +90 -0
  13. text2sql_eval_toolkit/execution/__init__.py +5 -0
  14. text2sql_eval_toolkit/execution/execution_tools.py +1448 -0
  15. text2sql_eval_toolkit/execution/replace_select_tool.py +114 -0
  16. text2sql_eval_toolkit/inference/__init__.py +5 -0
  17. text2sql_eval_toolkit/inference/agentic_pipeline.py +2335 -0
  18. text2sql_eval_toolkit/inference/base_pipeline.py +11 -0
  19. text2sql_eval_toolkit/inference/baseline_llm_pipeline.py +372 -0
  20. text2sql_eval_toolkit/inference/inference_tools.py +769 -0
  21. text2sql_eval_toolkit/logging.py +54 -0
  22. text2sql_eval_toolkit/profiling/profiling_tools.py +185 -0
  23. text2sql_eval_toolkit/utils.py +302 -0
  24. text2sql_eval_toolkit-1.0.0.dist-info/METADATA +382 -0
  25. text2sql_eval_toolkit-1.0.0.dist-info/RECORD +28 -0
  26. text2sql_eval_toolkit-1.0.0.dist-info/WHEEL +5 -0
  27. text2sql_eval_toolkit-1.0.0.dist-info/licenses/LICENSE +201 -0
  28. 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']}")