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,54 @@
1
+ #
2
+ # Copyright IBM Corp. 2025 - 2026
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ #
5
+
6
+ import logging
7
+ from tqdm import tqdm
8
+ from pathlib import Path
9
+
10
+
11
+ class TqdmLoggingHandler(logging.Handler):
12
+ def emit(self, record):
13
+ try:
14
+ msg = self.format(record)
15
+ tqdm.write(msg)
16
+ self.flush()
17
+ except Exception:
18
+ self.handleError(record)
19
+
20
+
21
+ def get_logger(
22
+ name: str = "text2sql_eval_toolkit", level=logging.DEBUG, log_file: str = None
23
+ ):
24
+ logger = logging.getLogger(name)
25
+
26
+ if not logger.handlers:
27
+ logger.setLevel(level)
28
+
29
+ console_handler = TqdmLoggingHandler()
30
+ console_formatter = logging.Formatter(
31
+ "%(asctime)s - %(levelname)s - %(message)s", datefmt="%H:%M:%S"
32
+ )
33
+ console_handler.setFormatter(console_formatter)
34
+ logger.addHandler(console_handler)
35
+
36
+ # Default log file path relative to the project root
37
+ if log_file is None:
38
+ project_root = (
39
+ Path(__file__).resolve().parents[2]
40
+ ) # Go up from src/text2sql_eval_toolkit
41
+ log_file = project_root / "data" / "results" / "bak" / "log.txt"
42
+
43
+ log_file = Path(log_file)
44
+ log_file.parent.mkdir(parents=True, exist_ok=True) # Ensure directory exists
45
+
46
+ # File handler
47
+ file_handler = logging.FileHandler(log_file, mode="w")
48
+ file_formatter = logging.Formatter(
49
+ "%(asctime)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
50
+ )
51
+ file_handler.setFormatter(file_formatter)
52
+ logger.addHandler(file_handler)
53
+
54
+ return logger
@@ -0,0 +1,185 @@
1
+ #
2
+ # Copyright IBM Corp. 2025 - 2026
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ #
5
+
6
+ import json
7
+ import os
8
+ from sqlglot import parse_one, exp
9
+ import shutil
10
+ from tqdm import tqdm
11
+ from typing import Dict
12
+ from text2sql_eval_toolkit.utils import get_gt_sqls
13
+ from text2sql_eval_toolkit.logging import get_logger
14
+
15
+
16
+ logger = get_logger(__name__)
17
+
18
+
19
+ def analyze_sql_query(sql: str, dialect: str = "postgres") -> Dict:
20
+ """
21
+ Analyze a SQL query and classify it into categories with structural features and descriptive tags.
22
+
23
+ Args:
24
+ sql (str): The SQL query string.
25
+ dialect (str): SQL dialect for parsing (default is 'postgres').
26
+
27
+ Returns:
28
+ Dict: A dictionary with structural features and a set of descriptive tags.
29
+ """
30
+ parsed = parse_one(sql, dialect=dialect)
31
+
32
+ def count(exp_type):
33
+ return len(list(parsed.find_all(exp_type)))
34
+
35
+ def count_names(exp_type):
36
+ return len([e.name for e in parsed.find_all(exp_type)])
37
+
38
+ features = {
39
+ "query_table_count": count_names(exp.Table),
40
+ "query_column_count": count_names(exp.Column),
41
+ "query_nested_count": count(exp.Select)
42
+ + count(exp.Delete)
43
+ + count(exp.Insert)
44
+ - 1,
45
+ "query_aggregate_count": count(exp.AggFunc),
46
+ "query_sort_count": count(exp.Ordered),
47
+ "query_window_func_count": count(exp.Window),
48
+ "query_join_count": count(exp.Join),
49
+ }
50
+
51
+ # Classification logic
52
+ is_basic = (
53
+ features["query_table_count"] == 1
54
+ and features["query_nested_count"] == 0
55
+ and features["query_window_func_count"] == 0
56
+ and features["query_join_count"] == 0
57
+ )
58
+
59
+ is_multi_table = (
60
+ features["query_table_count"] > 1
61
+ and features["query_nested_count"] == 0
62
+ and features["query_window_func_count"] == 0
63
+ and features["query_join_count"] >= 1
64
+ )
65
+
66
+ is_advanced = features["query_table_count"] == 1 and (
67
+ features["query_nested_count"] > 0 or features["query_window_func_count"] > 0
68
+ )
69
+
70
+ # Tag generation
71
+ tags = set()
72
+ if is_basic:
73
+ tags.add("single_source_basic")
74
+ if is_multi_table:
75
+ tags.add("multi_table_simple")
76
+ if is_advanced:
77
+ tags.add("single_source_advanced")
78
+
79
+ if features["query_join_count"] > 0:
80
+ tags.add("has_join")
81
+ if features["query_nested_count"] > 0:
82
+ tags.add("has_nested_query")
83
+ if features["query_aggregate_count"] > 0:
84
+ tags.add("has_aggregation")
85
+ if features["query_sort_count"] > 0:
86
+ tags.add("has_sorting")
87
+ if features["query_window_func_count"] > 0:
88
+ tags.add("has_window_function")
89
+
90
+ return {"features": features, "categories": sorted(tags)}
91
+
92
+
93
+ def merge_dictionaries(original_dict, new_dict):
94
+ """
95
+ Merges new_dict into original_dict in-place with specific logic for 'features' and 'categories' keys.
96
+
97
+ Args:
98
+ original_dict (dict): The original dictionary (modified in-place)
99
+ new_dict (dict): The new dictionary to merge
100
+
101
+ Returns:
102
+ bool: True if any conflicts/overwrites occurred, False otherwise
103
+ """
104
+ overwrite_occurred = False
105
+
106
+ for key, value in new_dict.items():
107
+ if key == "features":
108
+ # Initialize features if it doesn't exist
109
+ if key not in original_dict:
110
+ original_dict[key] = {}
111
+
112
+ # Merge features dictionaries
113
+ for feature_key, feature_value in value.items():
114
+ if (
115
+ feature_key in original_dict[key]
116
+ and original_dict[key][feature_key] != feature_value
117
+ ):
118
+ overwrite_occurred = True
119
+ original_dict[key][feature_key] = feature_value
120
+
121
+ elif key == "categories":
122
+ # Initialize categories if it doesn't exist
123
+ if key not in original_dict:
124
+ original_dict[key] = []
125
+
126
+ # Add new categories that aren't already present
127
+ for category in value:
128
+ if category not in original_dict[key]:
129
+ original_dict[key].append(category)
130
+
131
+ else:
132
+ # For other keys, new dictionary takes precedence
133
+ if key in original_dict and original_dict[key] != value:
134
+ overwrite_occurred = True
135
+ original_dict[key] = value
136
+
137
+ return overwrite_occurred
138
+
139
+
140
+ def profile_pred_or_eval_json_file(
141
+ json_file_path: str, dialect: str = "postgres"
142
+ ) -> None:
143
+ # Load the JSON data
144
+ with open(json_file_path, "r", encoding="utf-8") as f:
145
+ data = json.load(f)
146
+
147
+ backup_file_path = json_file_path + ".bak"
148
+ shutil.copy2(json_file_path, backup_file_path)
149
+
150
+ # Ensure the data is a list
151
+ if not isinstance(data, list):
152
+ raise ValueError("JSON file must contain an array of objects.")
153
+
154
+ # Track if any overwrites occur
155
+ overwrite_occurred = False
156
+ # Process each record in the input
157
+ for record in tqdm(data):
158
+ gt_sqls = get_gt_sqls(record)
159
+ sql_query = gt_sqls[0]
160
+ if len(gt_sqls) > 1:
161
+ logger.warning(
162
+ f"More than on gt query in record in {json_file_path}. Profiling only the first one."
163
+ )
164
+
165
+ try:
166
+ analysis_result = analyze_sql_query(sql_query, dialect)
167
+ except Exception as e:
168
+ logger.error(f"Failed to profile SQL query: {sql_query}. Error: {repr(e)}")
169
+ continue
170
+ # Initialize or update the 'meta' field
171
+ if "meta" not in record:
172
+ record["meta"] = analysis_result
173
+ else:
174
+ overwrite_occurred = merge_dictionaries(record["meta"], analysis_result)
175
+
176
+ # Write the updated data back to the original file
177
+ with open(json_file_path, "w", encoding="utf-8") as f:
178
+ json.dump(data, f, indent=2)
179
+
180
+ if not overwrite_occurred:
181
+ os.remove(backup_file_path)
182
+ else:
183
+ print(f"Backup created at {backup_file_path} due to overwrites.")
184
+
185
+ logger.info(f"Profiling complete. Results written in {json_file_path}")
@@ -0,0 +1,302 @@
1
+ #
2
+ # Copyright IBM Corp. 2025 - 2026
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ #
5
+
6
+ import asyncio
7
+ import importlib.resources as resources
8
+ import json
9
+ import os
10
+ import time
11
+ import pandas as pd
12
+ from concurrent.futures import ThreadPoolExecutor, TimeoutError
13
+ from typing import Any, Dict
14
+ from pathlib import Path
15
+ from text2sql_eval_toolkit.logging import get_logger
16
+
17
+
18
+ BENCHMARKS_FILE = resources.files("text2sql_eval_toolkit.data").joinpath(
19
+ "benchmarks.json"
20
+ )
21
+ TEST_BENCHMARKS_FILE = resources.files("text2sql_eval_toolkit.data").joinpath(
22
+ "test-benchmarks.json"
23
+ )
24
+ logger = get_logger(__name__)
25
+
26
+
27
+ def get_available_benchmarks(include_test: bool = True):
28
+ """
29
+ Get list of available benchmark IDs.
30
+
31
+ Args:
32
+ include_test: If True, include test benchmarks from test-benchmarks.json
33
+
34
+ Returns:
35
+ List of benchmark IDs
36
+ """
37
+ benchmarks = []
38
+
39
+ # Load production benchmarks
40
+ if BENCHMARKS_FILE.exists():
41
+ with open(BENCHMARKS_FILE, "r") as f:
42
+ data = json.load(f)
43
+ benchmarks.extend(list(data.keys()))
44
+
45
+ # Load test benchmarks if requested
46
+ if include_test and TEST_BENCHMARKS_FILE.exists():
47
+ with open(TEST_BENCHMARKS_FILE, "r") as f:
48
+ data = json.load(f)
49
+ benchmarks.extend(list(data.keys()))
50
+
51
+ return benchmarks
52
+
53
+
54
+ def get_benchmarks_info(is_test: bool = False) -> Dict[str, Any]:
55
+ """
56
+ Retrieves all the benchmarks' information.
57
+
58
+ Args:
59
+ is_test: If True, load test benchmarks from test-benchmarks.json
60
+
61
+ Returns:
62
+ Dict[str, Any]: Dictionary containing info and paths to benchmark files.
63
+ """
64
+ benchmarks_file = TEST_BENCHMARKS_FILE if is_test else BENCHMARKS_FILE
65
+ benchmarks_info = {}
66
+ try:
67
+ with open(benchmarks_file, "r") as meta_file:
68
+ benchmarks_meta = json.load(meta_file)
69
+ except Exception as e:
70
+ logger.error(f"Error loading the benchmarks JSON file: {benchmarks_file}.")
71
+ raise e
72
+ for benchmark_id, benchmark_info in benchmarks_meta.items():
73
+ root = BENCHMARKS_FILE.parent
74
+ if benchmark_id not in benchmarks_meta:
75
+ raise ValueError(
76
+ f"Benchmark ID '{benchmark_id}' not found in benchmarks.json."
77
+ )
78
+ benchmark_info = benchmarks_meta[benchmark_id]
79
+ benchmark_info["benchmark_json_path"] = resolve_path(
80
+ root, benchmark_info["data"]
81
+ )
82
+ benchmark_info["schema_json_path"] = resolve_path(
83
+ root, benchmark_info["schema"]
84
+ )
85
+ benchmark_info["predictions_path"] = resolve_path(
86
+ root, benchmark_info["predictions"]
87
+ )
88
+ benchmark_info["eval_results_path"] = Path(
89
+ benchmark_info["predictions_path"].with_name(
90
+ benchmark_info["predictions_path"].stem + "_eval.json"
91
+ )
92
+ ).resolve()
93
+ benchmark_info["eval_summary_path"] = Path(
94
+ benchmark_info["predictions_path"].with_name(
95
+ benchmark_info["predictions_path"].stem + "_eval_summary.json"
96
+ )
97
+ ).resolve()
98
+ benchmarks_info[benchmark_id] = benchmark_info
99
+ return benchmarks_info
100
+
101
+
102
+ def resolve_path(root, path_str):
103
+ """
104
+ Resolves a given path string relative to a root directory.
105
+ If the provided path string is absolute, returns it as a Path object.
106
+ Otherwise, returns the path relative to the specified root directory.
107
+ Args:
108
+ root (Path): The root directory to resolve relative paths against.
109
+ path_str (str): The path string to resolve.
110
+ Returns:
111
+ Path: The resolved absolute or relative path as a Path object.
112
+ """
113
+ p = Path(path_str)
114
+ if p.is_absolute():
115
+ return p
116
+ return root / p
117
+
118
+
119
+ def get_benchmark_info(benchmark_id: str, is_test: bool = False) -> Dict[str, Any]:
120
+ """
121
+ Retrieves the benchmark files for a given benchmark ID.
122
+ Automatically detects if benchmark is in test-benchmarks.json if not found in benchmarks.json.
123
+
124
+ Args:
125
+ benchmark_id (str): Identifier for the benchmark dataset.
126
+ is_test (bool): If True, load from test-benchmarks.json. If False, tries production first, then test.
127
+
128
+ Returns:
129
+ Dict[str, Any]: Dictionary containing info and paths to benchmark files.
130
+ """
131
+ # If is_test is True, only look in test benchmarks
132
+ if is_test:
133
+ benchmarks_file = TEST_BENCHMARKS_FILE
134
+ with open(benchmarks_file, "r") as meta_file:
135
+ benchmarks_meta = json.load(meta_file)
136
+ if benchmark_id not in benchmarks_meta:
137
+ raise ValueError(f"Benchmark ID '{benchmark_id}' not found in test-benchmarks.json.")
138
+ else:
139
+ # Try production benchmarks first
140
+ benchmarks_file = BENCHMARKS_FILE
141
+ with open(benchmarks_file, "r") as meta_file:
142
+ benchmarks_meta = json.load(meta_file)
143
+
144
+ # If not found in production, try test benchmarks
145
+ if benchmark_id not in benchmarks_meta and TEST_BENCHMARKS_FILE.exists():
146
+ benchmarks_file = TEST_BENCHMARKS_FILE
147
+ with open(benchmarks_file, "r") as meta_file:
148
+ benchmarks_meta = json.load(meta_file)
149
+ if benchmark_id not in benchmarks_meta:
150
+ raise ValueError(f"Benchmark ID '{benchmark_id}' not found in benchmarks.json or test-benchmarks.json.")
151
+ elif benchmark_id not in benchmarks_meta:
152
+ raise ValueError(f"Benchmark ID '{benchmark_id}' not found in benchmarks.json.")
153
+
154
+ root = benchmarks_file.parent
155
+ benchmark_info = benchmarks_meta[benchmark_id]
156
+ benchmark_info["benchmark_json_path"] = resolve_path(root, benchmark_info["data"])
157
+ benchmark_info["schema_json_path"] = resolve_path(root, benchmark_info["schema"])
158
+ benchmark_info["predictions_path"] = resolve_path(
159
+ root, benchmark_info["predictions"]
160
+ )
161
+ return benchmark_info
162
+
163
+
164
+ def run_with_timeout(func, timeout=90, retries=2, wait=3, *args, **kwargs):
165
+ """
166
+ Runs a function with a timeout and retries.
167
+
168
+ Parameters:
169
+ func (callable): The function to run.
170
+ timeout (int): Timeout in seconds for each attempt.
171
+ retries (int): Number of retries after the first attempt.
172
+ wait (int): Seconds to wait between retries.
173
+ *args, **kwargs: Arguments to pass to the function.
174
+
175
+ Returns:
176
+ The result of the function if successful.
177
+
178
+ Raises:
179
+ TimeoutError: If all attempts time out.
180
+ """
181
+ for attempt in range(retries + 1):
182
+ with ThreadPoolExecutor(max_workers=1) as executor:
183
+ future = executor.submit(func, *args, **kwargs)
184
+ try:
185
+ return future.result(timeout=timeout)
186
+ except TimeoutError:
187
+ logger.info(f"⚠️ Attempt {attempt + 1} timed out.")
188
+ if attempt < retries:
189
+ time.sleep(wait)
190
+ else:
191
+ raise TimeoutError(
192
+ f"❗️ Function timed out after {retries + 1} attempts."
193
+ )
194
+
195
+
196
+ async def run_with_timeout_async(task, base_timeout=90, retries=2, wait=3):
197
+ for attempt in range(retries + 1):
198
+ timeout = base_timeout * (attempt + 1)
199
+ try:
200
+ return await asyncio.wait_for(task(), timeout=timeout)
201
+ except asyncio.TimeoutError:
202
+ logger.info(f"⚠️ Attempt {attempt + 1} timed out after {timeout} seconds.")
203
+ if attempt < retries:
204
+ logger.info(f"⏳ Retrying in {wait} seconds...")
205
+ await asyncio.sleep(wait)
206
+ else:
207
+ raise asyncio.TimeoutError(
208
+ f"❗️ Function timed out after {retries + 1} attempts."
209
+ )
210
+
211
+
212
+ def parse_dataframe(json_str):
213
+ """Reconstruct a DataFrame from a JSON-encoded dictionary."""
214
+ try:
215
+ df_dict = json.loads(json_str)
216
+ return pd.DataFrame(
217
+ data=df_dict["data"], columns=df_dict["columns"], index=df_dict["index"]
218
+ )
219
+ except Exception as e:
220
+ raise ValueError(
221
+ f"Failed to parse DataFrame JSON. Error: {e}. JSON string: {json_str}"
222
+ )
223
+
224
+
225
+ def truncate_dataframe(
226
+ df: pd.DataFrame, head: int = 10, tail: int = 10
227
+ ) -> pd.DataFrame:
228
+ """
229
+ Truncate a DataFrame to show only the first `head` rows and last `tail` rows.
230
+ If the DataFrame has more rows than head + tail, insert a '...' row in between
231
+ with empty strings so print(df) looks clean.
232
+ """
233
+ if len(df) <= head + tail:
234
+ return df
235
+
236
+ top = df.head(head)
237
+ bottom = df.tail(tail)
238
+
239
+ # Row of empty strings with index labeled "..."
240
+ ellipsis_row = pd.DataFrame([[""] * df.shape[1]], columns=df.columns, index=["..."])
241
+
242
+ return pd.concat([top, ellipsis_row, bottom])
243
+
244
+
245
+ def get_question_id(record):
246
+ """Gets Question ID from benchmark or prediction data record"""
247
+ id_keys = ["id", "question_id", "qid", "_id"]
248
+ for key in id_keys:
249
+ question_id = record.get(key)
250
+ if question_id is not None:
251
+ record["id"] = question_id # Ensure 'id' is always set
252
+ return question_id
253
+ raise ValueError(f"Record has no ID field among {id_keys}: {record}")
254
+
255
+
256
+ def get_utterance(record):
257
+ """Gets the question (utterance) from the benchmark or prediction data record"""
258
+ utterance_keys = ["utterance", "page_content", "question"]
259
+ for key in utterance_keys:
260
+ utterance = record.get(key)
261
+ if utterance:
262
+ record["utterance"] = utterance # Ensure 'utterance' is always set
263
+ return utterance
264
+ raise ValueError(f"Record has no utterance field among {utterance_keys}: {record}")
265
+
266
+
267
+ def get_gt_sqls(record):
268
+ gt_sql_keys = ["sql", "SQL", "target", "query"]
269
+ for key in gt_sql_keys:
270
+ gt_sqls = record.get(key)
271
+ if gt_sqls:
272
+ # Skip if it's a dict (structured SQL representation, not a string)
273
+ if isinstance(gt_sqls, dict):
274
+ continue
275
+ if not isinstance(gt_sqls, list):
276
+ gt_sqls = [gt_sqls]
277
+ record["sql"] = gt_sqls
278
+ return gt_sqls
279
+ if "metadata" in record and "sql" in record["metadata"]:
280
+ return [record["metadata"]["sql"]]
281
+ raise ValueError(f"Record has no ground truth SQL: {record}")
282
+
283
+
284
+ def get_question(record):
285
+ return (
286
+ record["page_content"]
287
+ if "page_content" in record
288
+ else (record["question"] if "question" in record else record["utterance"])
289
+ )
290
+
291
+
292
+ def get_default_eval_filename(predictions_file):
293
+ base_name, ext = os.path.splitext(predictions_file)
294
+ return f"{base_name}_eval{ext}"
295
+
296
+
297
+ def add_summary_json_suffix(path: str) -> str:
298
+ return os.path.splitext(path)[0] + "_summary.json"
299
+
300
+
301
+ def add_summary_csv_suffix(path: str) -> str:
302
+ return os.path.splitext(path)[0] + "_summary.csv"