nl2sql-engine 0.1.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 (192) hide show
  1. nl2sql/__init__.py +38 -0
  2. nl2sql/adapters/__init__.py +0 -0
  3. nl2sql/adapters/duckdb/__init__.py +0 -0
  4. nl2sql/adapters/duckdb/adapter.py +71 -0
  5. nl2sql/adapters/mssql/__init__.py +0 -0
  6. nl2sql/adapters/mssql/adapter.py +122 -0
  7. nl2sql/adapters/mysql/__init__.py +0 -0
  8. nl2sql/adapters/mysql/adapter.py +123 -0
  9. nl2sql/adapters/postgres/__init__.py +0 -0
  10. nl2sql/adapters/postgres/adapter.py +115 -0
  11. nl2sql/adapters/sqlalchemy_base/__init__.py +17 -0
  12. nl2sql/adapters/sqlalchemy_base/adapter.py +476 -0
  13. nl2sql/adapters/sqlalchemy_base/models.py +36 -0
  14. nl2sql/adapters/sqlite/__init__.py +0 -0
  15. nl2sql/adapters/sqlite/adapter.py +88 -0
  16. nl2sql/aggregation/__init__.py +3 -0
  17. nl2sql/aggregation/aggregator.py +98 -0
  18. nl2sql/aggregation/engines/__init__.py +3 -0
  19. nl2sql/aggregation/engines/polars_duckdb.py +125 -0
  20. nl2sql/api/__init__.py +0 -0
  21. nl2sql/api/auth_api.py +60 -0
  22. nl2sql/api/benchmark_api.py +114 -0
  23. nl2sql/api/datasource_api.py +132 -0
  24. nl2sql/api/indexing_api.py +59 -0
  25. nl2sql/api/llm_api.py +82 -0
  26. nl2sql/api/policy_api.py +135 -0
  27. nl2sql/api/query_api.py +138 -0
  28. nl2sql/api/result_api.py +24 -0
  29. nl2sql/api/settings_api.py +65 -0
  30. nl2sql/auth/__init__.py +8 -0
  31. nl2sql/auth/models.py +36 -0
  32. nl2sql/auth/rbac.py +25 -0
  33. nl2sql/cli/__init__.py +0 -0
  34. nl2sql/cli/checks.py +53 -0
  35. nl2sql/cli/commands/__init__.py +0 -0
  36. nl2sql/cli/commands/benchmark.py +34 -0
  37. nl2sql/cli/commands/doctor.py +49 -0
  38. nl2sql/cli/commands/indexing.py +126 -0
  39. nl2sql/cli/commands/info.py +25 -0
  40. nl2sql/cli/commands/install.py +27 -0
  41. nl2sql/cli/commands/policy.py +57 -0
  42. nl2sql/cli/commands/run.py +166 -0
  43. nl2sql/cli/commands/setup.py +415 -0
  44. nl2sql/cli/commands/visualize.py +34 -0
  45. nl2sql/cli/common/decorators.py +34 -0
  46. nl2sql/cli/config.py +24 -0
  47. nl2sql/cli/console.py +52 -0
  48. nl2sql/cli/demo/__init__.py +1 -0
  49. nl2sql/cli/demo/data.py +87 -0
  50. nl2sql/cli/demo/defaults.py +122 -0
  51. nl2sql/cli/demo/factory.py +289 -0
  52. nl2sql/cli/demo/manager.py +230 -0
  53. nl2sql/cli/demo/schemas.py +336 -0
  54. nl2sql/cli/demo/writers/__init__.py +0 -0
  55. nl2sql/cli/demo/writers/docker.py +182 -0
  56. nl2sql/cli/demo/writers/sqlite.py +88 -0
  57. nl2sql/cli/generators/datasources/__init__.py +3 -0
  58. nl2sql/cli/generators/datasources/generator.py +24 -0
  59. nl2sql/cli/generators/datasources/templates.py +7 -0
  60. nl2sql/cli/generators/env/__init__.py +3 -0
  61. nl2sql/cli/generators/env/generator.py +46 -0
  62. nl2sql/cli/generators/env/templates.py +25 -0
  63. nl2sql/cli/generators/llm/__init__.py +3 -0
  64. nl2sql/cli/generators/llm/generator.py +24 -0
  65. nl2sql/cli/generators/llm/templates.py +4 -0
  66. nl2sql/cli/generators/policies/__init__.py +3 -0
  67. nl2sql/cli/generators/policies/generator.py +20 -0
  68. nl2sql/cli/generators/policies/templates.py +2 -0
  69. nl2sql/cli/main.py +195 -0
  70. nl2sql/cli/reporting.py +878 -0
  71. nl2sql/cli/types.py +13 -0
  72. nl2sql/common/__init__.py +1 -0
  73. nl2sql/common/cancellation.py +25 -0
  74. nl2sql/common/context.py +5 -0
  75. nl2sql/common/errors.py +109 -0
  76. nl2sql/common/event_logger.py +88 -0
  77. nl2sql/common/exceptions.py +3 -0
  78. nl2sql/common/logger.py +119 -0
  79. nl2sql/common/metrics.py +50 -0
  80. nl2sql/common/resilience.py +59 -0
  81. nl2sql/common/settings.py +195 -0
  82. nl2sql/configs/__init__.py +6 -0
  83. nl2sql/configs/datasources.py +10 -0
  84. nl2sql/configs/llm.py +36 -0
  85. nl2sql/configs/manager.py +176 -0
  86. nl2sql/configs/policies.py +14 -0
  87. nl2sql/configs/sample_questions.py +11 -0
  88. nl2sql/configs/secrets.py +11 -0
  89. nl2sql/context.py +106 -0
  90. nl2sql/datasources/__init__.py +21 -0
  91. nl2sql/datasources/discovery.py +28 -0
  92. nl2sql/datasources/models.py +21 -0
  93. nl2sql/datasources/protocols.py +3 -0
  94. nl2sql/datasources/registry.py +172 -0
  95. nl2sql/evaluation/__init__.py +6 -0
  96. nl2sql/evaluation/benchmark_runner.py +320 -0
  97. nl2sql/evaluation/evaluator.py +134 -0
  98. nl2sql/evaluation/types.py +22 -0
  99. nl2sql/execution/__init__.py +4 -0
  100. nl2sql/execution/artifacts/__init__.py +3 -0
  101. nl2sql/execution/artifacts/parquet.py +41 -0
  102. nl2sql/execution/artifacts/store.py +165 -0
  103. nl2sql/execution/contracts.py +57 -0
  104. nl2sql/execution/execution_store.py +25 -0
  105. nl2sql/execution/executor/__init__.py +3 -0
  106. nl2sql/execution/executor/sql_executor.py +116 -0
  107. nl2sql/indexing/__init__.py +7 -0
  108. nl2sql/indexing/chunk_builder.py +227 -0
  109. nl2sql/indexing/embeddings.py +180 -0
  110. nl2sql/indexing/enrichment_service.py +316 -0
  111. nl2sql/indexing/models.py +209 -0
  112. nl2sql/indexing/orchestrator.py +90 -0
  113. nl2sql/indexing/vector_store.py +422 -0
  114. nl2sql/llm/__init__.py +8 -0
  115. nl2sql/llm/models.py +10 -0
  116. nl2sql/llm/registry.py +214 -0
  117. nl2sql/pipeline/__init__.py +1 -0
  118. nl2sql/pipeline/graph.py +73 -0
  119. nl2sql/pipeline/graph_utils.py +141 -0
  120. nl2sql/pipeline/nodes/__init__.py +25 -0
  121. nl2sql/pipeline/nodes/aggregator/__init__.py +4 -0
  122. nl2sql/pipeline/nodes/aggregator/node.py +55 -0
  123. nl2sql/pipeline/nodes/aggregator/prompts.py +20 -0
  124. nl2sql/pipeline/nodes/aggregator/schemas.py +28 -0
  125. nl2sql/pipeline/nodes/answer_synthesizer/__init__.py +4 -0
  126. nl2sql/pipeline/nodes/answer_synthesizer/node.py +98 -0
  127. nl2sql/pipeline/nodes/answer_synthesizer/prompts.py +19 -0
  128. nl2sql/pipeline/nodes/answer_synthesizer/schemas.py +24 -0
  129. nl2sql/pipeline/nodes/ast_planner/__init__.py +4 -0
  130. nl2sql/pipeline/nodes/ast_planner/node.py +104 -0
  131. nl2sql/pipeline/nodes/ast_planner/prompts.py +138 -0
  132. nl2sql/pipeline/nodes/ast_planner/schemas.py +236 -0
  133. nl2sql/pipeline/nodes/datasource_resolver/__init__.py +4 -0
  134. nl2sql/pipeline/nodes/datasource_resolver/node.py +253 -0
  135. nl2sql/pipeline/nodes/datasource_resolver/schemas.py +21 -0
  136. nl2sql/pipeline/nodes/decomposer/__init__.py +3 -0
  137. nl2sql/pipeline/nodes/decomposer/node.py +219 -0
  138. nl2sql/pipeline/nodes/decomposer/prompts.py +96 -0
  139. nl2sql/pipeline/nodes/decomposer/schemas.py +143 -0
  140. nl2sql/pipeline/nodes/executor/__init__.py +3 -0
  141. nl2sql/pipeline/nodes/executor/node.py +107 -0
  142. nl2sql/pipeline/nodes/generator/__init__.py +4 -0
  143. nl2sql/pipeline/nodes/generator/node.py +267 -0
  144. nl2sql/pipeline/nodes/generator/schemas.py +13 -0
  145. nl2sql/pipeline/nodes/global_planner/__init__.py +4 -0
  146. nl2sql/pipeline/nodes/global_planner/node.py +186 -0
  147. nl2sql/pipeline/nodes/global_planner/schemas.py +101 -0
  148. nl2sql/pipeline/nodes/refiner/__init__.py +4 -0
  149. nl2sql/pipeline/nodes/refiner/node.py +132 -0
  150. nl2sql/pipeline/nodes/refiner/prompts.py +28 -0
  151. nl2sql/pipeline/nodes/refiner/schemas.py +13 -0
  152. nl2sql/pipeline/nodes/schema_retriever/__init__.py +3 -0
  153. nl2sql/pipeline/nodes/schema_retriever/node.py +252 -0
  154. nl2sql/pipeline/nodes/schema_retriever/schema.py +27 -0
  155. nl2sql/pipeline/nodes/validator/__init__.py +7 -0
  156. nl2sql/pipeline/nodes/validator/node.py +839 -0
  157. nl2sql/pipeline/nodes/validator/schemas.py +12 -0
  158. nl2sql/pipeline/pipeline_runner.py +72 -0
  159. nl2sql/pipeline/routes.py +72 -0
  160. nl2sql/pipeline/runtime.py +153 -0
  161. nl2sql/pipeline/state.py +92 -0
  162. nl2sql/pipeline/subgraphs/__init__.py +5 -0
  163. nl2sql/pipeline/subgraphs/schemas.py +23 -0
  164. nl2sql/pipeline/subgraphs/sql_agent.py +167 -0
  165. nl2sql/public_api.py +199 -0
  166. nl2sql/schema/__init__.py +37 -0
  167. nl2sql/schema/in_memory_store.py +173 -0
  168. nl2sql/schema/protocol.py +88 -0
  169. nl2sql/schema/sqlite_store.py +233 -0
  170. nl2sql/schema/store.py +29 -0
  171. nl2sql/secrets/__init__.py +14 -0
  172. nl2sql/secrets/factory.py +85 -0
  173. nl2sql/secrets/interfaces.py +16 -0
  174. nl2sql/secrets/manager.py +139 -0
  175. nl2sql/secrets/models.py +56 -0
  176. nl2sql/secrets/providers/aws.py +30 -0
  177. nl2sql/secrets/providers/azure.py +49 -0
  178. nl2sql/secrets/providers/env.py +8 -0
  179. nl2sql/secrets/providers/hashi.py +46 -0
  180. nl2sql/services/__init__.py +0 -0
  181. nl2sql/services/callbacks/__init__.py +0 -0
  182. nl2sql/services/callbacks/monitor.py +84 -0
  183. nl2sql/services/callbacks/node_context.py +7 -0
  184. nl2sql/services/callbacks/node_handlers.py +187 -0
  185. nl2sql/services/callbacks/node_metrics.py +14 -0
  186. nl2sql/services/callbacks/presenter.py +12 -0
  187. nl2sql/services/callbacks/token_handler.py +56 -0
  188. nl2sql_engine-0.1.0.dist-info/METADATA +295 -0
  189. nl2sql_engine-0.1.0.dist-info/RECORD +192 -0
  190. nl2sql_engine-0.1.0.dist-info/WHEEL +5 -0
  191. nl2sql_engine-0.1.0.dist-info/entry_points.txt +9 -0
  192. nl2sql_engine-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,320 @@
1
+ import concurrent.futures
2
+ from dataclasses import dataclass
3
+ from typing import List, Dict, Any
4
+
5
+ import yaml
6
+ from nl2sql_adapter_sdk.contracts import ResultFrame
7
+
8
+ from nl2sql.datasources import DatasourceRegistry
9
+ from nl2sql.llm import LLMRegistry
10
+ from nl2sql.indexing.vector_store import VectorStore
11
+ from nl2sql.pipeline.runtime import run_with_graph
12
+ from nl2sql.evaluation.evaluator import ModelEvaluator
13
+ from nl2sql.evaluation.types import BenchmarkConfig
14
+
15
+
16
+ @dataclass
17
+ class BenchmarkResult:
18
+ """Standardized result object from a benchmark run."""
19
+
20
+ results: List[Dict[str, Any]]
21
+ metrics: Dict[str, Any]
22
+ iterations: int = 1
23
+
24
+
25
+ class BenchmarkRunner:
26
+ """
27
+ Orchestrates the execution of the Benchmark suite.
28
+ Decoupled from CLI presentation logic.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ config: BenchmarkConfig,
34
+ datasource_registry: DatasourceRegistry,
35
+ vector_store: VectorStore,
36
+ llm_registry: LLMRegistry,
37
+ ):
38
+ self.config = config
39
+ self.ds_registry = datasource_registry
40
+ self.vector_store = vector_store
41
+ self.llm_registry = llm_registry
42
+
43
+ def run_dataset(self, config_name: str = "default", progress_callback=None) -> BenchmarkResult:
44
+ """
45
+ Runs the dataset evaluation.
46
+ """
47
+ dataset = self._load_dataset()
48
+
49
+ results = []
50
+ workers = 5
51
+ iterations = self.config.iterations if self.config.iterations else 1
52
+
53
+ with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
54
+ futures = []
55
+ for _ in range(iterations):
56
+ for item in dataset:
57
+ futures.append(executor.submit(self._evaluate_case, item))
58
+
59
+ total_tasks = len(dataset) * iterations
60
+
61
+ # Use callback for progress bar if provided
62
+ iterator = concurrent.futures.as_completed(futures)
63
+ if progress_callback:
64
+ iterator = progress_callback(
65
+ iterator,
66
+ total=total_tasks,
67
+ description=f"Evaluating ({workers} parallel, {iterations} runs)...",
68
+ )
69
+
70
+ for future in iterator:
71
+ results.append(future.result())
72
+
73
+ # Sort results by ID
74
+ results.sort(key=lambda x: x["id"])
75
+
76
+ # Calculate Metrics
77
+ metrics = ModelEvaluator.calculate_aggregate_metrics(results, len(results))
78
+
79
+ return BenchmarkResult(results=results, metrics=metrics, iterations=iterations)
80
+
81
+ def _load_dataset(self) -> List[Dict]:
82
+ """Loads and filters the dataset."""
83
+ dataset_path = self.config.dataset_path
84
+ if not dataset_path.exists():
85
+ raise FileNotFoundError(f"Dataset file not found: {dataset_path}")
86
+
87
+ dataset = yaml.safe_load(dataset_path.read_text())
88
+ if not isinstance(dataset, list):
89
+ raise ValueError("Dataset must be a list of test cases.")
90
+
91
+ if self.config.include_ids:
92
+ dataset = [item for item in dataset if item.get("id") in self.config.include_ids]
93
+ if not dataset:
94
+ raise ValueError(f"No test cases found matching IDs: {self.config.include_ids}")
95
+
96
+ return dataset
97
+
98
+ def _evaluate_case(self, item: dict) -> dict:
99
+ """Evaluates a single test case."""
100
+ q_id = item.get("id", "unknown")
101
+ question = item.get("question")
102
+ expected_sql = item.get("expected_sql")
103
+ expected_ds = item.get("datasource")
104
+ expected_layer = item.get("expected_routing_layer")
105
+
106
+ try:
107
+ state = run_with_graph(
108
+ registry=self.ds_registry,
109
+ llm_registry=self.llm_registry,
110
+ user_query=question,
111
+ datasource_id=None,
112
+ execute=not self.config.routing_only,
113
+ vector_store=self.vector_store,
114
+ vector_store_path=self.config.vector_store_path,
115
+ )
116
+ except Exception as e:
117
+ return {
118
+ "id": q_id,
119
+ "question": question,
120
+ "status": "ERROR",
121
+ "error": str(e),
122
+ "routing_match": False,
123
+ "sql_match": False,
124
+ }
125
+
126
+ actual_ds = state.get("datasource_id") or set()
127
+ expected_set = set(expected_ds) if expected_ds else set()
128
+ routing_match = actual_ds == expected_set
129
+
130
+ # --- Metrics Extraction ---
131
+ all_routing_info = state.get("routing_info", {})
132
+ primary_id = sorted(list(actual_ds))[0] if actual_ds else None
133
+ routing_info = all_routing_info.get(primary_id) if primary_id else None
134
+
135
+ def get_val(obj, key, default=None):
136
+ if isinstance(obj, dict):
137
+ return obj.get(key, default)
138
+ return getattr(obj, key, default)
139
+
140
+ if routing_info:
141
+ routing_layer = get_val(routing_info, "layer", "unknown")
142
+ routing_reasoning = get_val(routing_info, "reasoning", "")
143
+ routing_tokens = get_val(routing_info, "tokens", 0)
144
+ routing_latency = get_val(routing_info, "latency", 0)
145
+ l1_score = get_val(routing_info, "l1_score", 0.0)
146
+ candidates = get_val(routing_info, "candidates", [])
147
+ if candidates and not isinstance(candidates[0], dict):
148
+ candidates = [{"id": c.id, "score": c.score} for c in candidates]
149
+ else:
150
+ routing_layer = "unknown"
151
+ routing_reasoning = "No routing info"
152
+ routing_tokens = 0
153
+ routing_latency = 0
154
+ l1_score = 0.0
155
+ candidates = []
156
+
157
+ layer_match = routing_layer == expected_layer
158
+
159
+ if self.config.routing_only:
160
+ return {
161
+ "id": q_id,
162
+ "question": question,
163
+ "status": "PASS" if routing_match else "ROUTE_FAIL",
164
+ "routing_match": routing_match,
165
+ "sql_match": None,
166
+ "actual_ds": actual_ds,
167
+ "expected_ds": expected_ds,
168
+ "routing_layer": routing_layer,
169
+ "routing_reasoning": routing_reasoning,
170
+ "routing_tokens": routing_tokens,
171
+ "routing_latency": routing_latency,
172
+ "l1_score": l1_score,
173
+ "candidates": candidates,
174
+ "expected_layer": expected_layer,
175
+ "layer_match": layer_match,
176
+ }
177
+
178
+ generated_sql = None
179
+ execution_res = None
180
+ subgraph_outputs = state.get("subgraph_outputs") or {}
181
+ if subgraph_outputs:
182
+ first_output = next(iter(subgraph_outputs.values()))
183
+ generated_sql = (
184
+ first_output.get("sql_draft")
185
+ if isinstance(first_output, dict)
186
+ else getattr(first_output, "sql_draft", None)
187
+ )
188
+ execution_res = (
189
+ first_output.get("artifact")
190
+ if isinstance(first_output, dict)
191
+ else getattr(first_output, "artifact", None)
192
+ )
193
+
194
+ if not generated_sql:
195
+ generated_sql_data = state.get("sql_draft")
196
+ if isinstance(generated_sql_data, str):
197
+ generated_sql = generated_sql_data
198
+ else:
199
+ generated_sql = (
200
+ generated_sql_data.get("sql")
201
+ if isinstance(generated_sql_data, dict)
202
+ else getattr(generated_sql_data, "sql", None)
203
+ )
204
+
205
+ if execution_res is None:
206
+ execution_res = state.get("execution")
207
+ generated_rows = execution_res.get("rows") if isinstance(execution_res, dict) else getattr(execution_res, "rows", [])
208
+ exec_error = execution_res.get("error") if isinstance(execution_res, dict) else getattr(execution_res, "error", None)
209
+
210
+ if exec_error:
211
+ return {
212
+ "id": q_id,
213
+ "question": question,
214
+ "status": "EXEC_FAIL",
215
+ "error": exec_error,
216
+ "routing_match": routing_match,
217
+ "sql_match": False,
218
+ "gen_sql": generated_sql,
219
+ }
220
+
221
+ if not generated_sql:
222
+ return {
223
+ "id": q_id,
224
+ "question": question,
225
+ "status": "NO_SQL",
226
+ "routing_match": routing_match,
227
+ "sql_match": False,
228
+ }
229
+
230
+ if not expected_sql:
231
+ return {
232
+ "id": q_id,
233
+ "question": question,
234
+ "status": "NO_GT",
235
+ "routing_match": routing_match,
236
+ "sql_match": None,
237
+ "semantic_sql_match": None,
238
+ "gen_sql": generated_sql,
239
+ }
240
+
241
+ if not expected_ds:
242
+ return {
243
+ "id": q_id,
244
+ "status": "BAD_CONFIG",
245
+ "error": "Dataset missing expected datasource",
246
+ "routing_match": routing_match,
247
+ "sql_match": False,
248
+ }
249
+
250
+ try:
251
+ expected_ds_id = expected_ds[0] if isinstance(expected_ds, list) else expected_ds
252
+ adapter = self.ds_registry.get_adapter(expected_ds_id)
253
+ if not hasattr(adapter, "execute_sql"):
254
+ raise ValueError(f"Datasource '{expected_ds_id}' does not support SQL execution")
255
+ result: ResultFrame = adapter.execute_sql(expected_sql)
256
+ expected_rows = result.to_row_dicts()
257
+ except Exception as e:
258
+ return {
259
+ "id": q_id,
260
+ "question": question,
261
+ "status": "GT_FAIL",
262
+ "error": str(e),
263
+ "routing_match": routing_match,
264
+ "sql_match": False,
265
+ "gen_sql": generated_sql,
266
+ }
267
+
268
+ try:
269
+ data_match = ModelEvaluator.compare_results(generated_rows, expected_rows, order_matters=False)
270
+
271
+ try:
272
+ semantic_sql_match = ModelEvaluator.compare_sql_semantic(generated_sql, expected_sql)
273
+ except ValueError as ve:
274
+ err_msg = str(ve)
275
+ if "Ground Truth" in err_msg:
276
+ return {
277
+ "id": q_id,
278
+ "question": question,
279
+ "status": "INVALID_GT",
280
+ "error": err_msg,
281
+ "routing_match": routing_match,
282
+ "sql_match": None if data_match else False,
283
+ "semantic_sql_match": None,
284
+ "gen_sql": generated_sql,
285
+ }
286
+ return {
287
+ "id": q_id,
288
+ "question": question,
289
+ "status": "INVALID_SQL",
290
+ "error": err_msg,
291
+ "routing_match": routing_match,
292
+ "sql_match": data_match,
293
+ "semantic_sql_match": False,
294
+ "gen_sql": generated_sql,
295
+ }
296
+
297
+ return {
298
+ "id": q_id,
299
+ "question": question,
300
+ "status": "PASS" if data_match else "DATA_MISMATCH",
301
+ "routing_match": routing_match,
302
+ "sql_match": data_match,
303
+ "semantic_sql_match": semantic_sql_match,
304
+ "gen_sql": generated_sql,
305
+ "exp_sql": expected_sql,
306
+ "gen_rows": len(generated_rows),
307
+ "exp_rows": len(expected_rows),
308
+ "expected_layer": expected_layer,
309
+ "layer_match": layer_match,
310
+ }
311
+
312
+ except Exception as e:
313
+ return {
314
+ "id": q_id,
315
+ "status": "COMPARE_FAIL",
316
+ "error": str(e),
317
+ "routing_match": routing_match,
318
+ "sql_match": False,
319
+ "gen_sql": generated_sql,
320
+ }
@@ -0,0 +1,134 @@
1
+ import pandas as pd
2
+ import sqlglot
3
+ from sqlglot import exp
4
+ from typing import List, Dict, Any, Optional
5
+
6
+ class ModelEvaluator:
7
+ """Evaluates the correctness of AI-generated SQL and its execution results."""
8
+
9
+ @staticmethod
10
+ def compare_sql_semantic(generated_sql: str, expected_sql: str) -> bool:
11
+ """Compares two SQL queries semantically by normalizing them to ASTs.
12
+
13
+ Strict Mode: Raises generic exceptions (ValueError) if parsing fails.
14
+
15
+ Args:
16
+ generated_sql (str): The SQL generated by the model.
17
+ expected_sql (str): The ground truth SQL.
18
+
19
+ Returns:
20
+ bool: True if semantically equivalent, False otherwise.
21
+
22
+ Raises:
23
+ ValueError: If either SQL query is invalid or unparseable.
24
+ """
25
+ if not generated_sql or not expected_sql:
26
+ return False
27
+
28
+ if generated_sql.strip() == expected_sql.strip():
29
+ return True
30
+
31
+ try:
32
+ gen_ast = sqlglot.parse_one(generated_sql)
33
+ except Exception as e:
34
+ raise ValueError(f"Generated SQL is invalid/unparseable: {e}")
35
+
36
+ try:
37
+ exp_ast = sqlglot.parse_one(expected_sql)
38
+ except Exception as e:
39
+ raise ValueError(f"Ground Truth SQL is invalid/unparseable: {e}")
40
+
41
+ return gen_ast.sql() == exp_ast.sql()
42
+
43
+ @staticmethod
44
+ def compare_results(
45
+ generated_rows: List[Dict[str, Any]],
46
+ expected_rows: List[Dict[str, Any]],
47
+ order_matters: bool = False
48
+ ) -> bool:
49
+ """Compares two result sets (lists of dicts).
50
+
51
+ Args:
52
+ generated_rows (List[Dict[str, Any]]): Rows returned by the AI query.
53
+ expected_rows (List[Dict[str, Any]]): Rows returned by the ground truth query.
54
+ order_matters (bool): If True, strictly enforces row order.
55
+
56
+ Returns:
57
+ bool: True if the result sets match, False otherwise.
58
+ """
59
+ if len(generated_rows) != len(expected_rows):
60
+ return False
61
+
62
+ if not generated_rows and not expected_rows:
63
+ return True
64
+
65
+ try:
66
+ df_gen = pd.DataFrame(generated_rows)
67
+ df_exp = pd.DataFrame(expected_rows)
68
+
69
+ df_gen.columns = df_gen.columns.str.lower()
70
+ df_exp.columns = df_exp.columns.str.lower()
71
+
72
+ df_gen = df_gen.reindex(sorted(df_gen.columns), axis=1)
73
+ df_exp = df_exp.reindex(sorted(df_exp.columns), axis=1)
74
+
75
+ if list(df_gen.columns) != list(df_exp.columns):
76
+ return False
77
+
78
+ if not order_matters:
79
+ df_gen = df_gen.sort_values(by=list(df_gen.columns)).reset_index(drop=True)
80
+ df_exp = df_exp.sort_values(by=list(df_exp.columns)).reset_index(drop=True)
81
+
82
+
83
+ pd.testing.assert_frame_equal(df_gen, df_exp, check_dtype=False, check_like=True, atol=1e-5)
84
+ return True
85
+
86
+ except AssertionError:
87
+ return False
88
+ except Exception:
89
+ # Fallback for complex types or other issues
90
+ return False
91
+
92
+ @staticmethod
93
+ def calculate_aggregate_metrics(results: List[Dict[str, Any]], total_samples: int) -> Dict[str, Any]:
94
+ """Calculates high-level metrics from a list of result dictionaries.
95
+
96
+ Args:
97
+ results (List[Dict[str, Any]]): List of evaluation result dictionaries.
98
+ total_samples (int): Total number of samples evaluated.
99
+
100
+ Returns:
101
+ Dict[str, Any]: Dictionary containing aggregate metrics:
102
+ - routing_accuracy
103
+ - execution_accuracy
104
+ - semantic_sql_accuracy
105
+ - valid_sql_rate
106
+ - layer_distribution (count and percentage)
107
+ """
108
+ if not total_samples:
109
+ return {}
110
+
111
+ correct_routing = sum(1 for r in results if r.get("routing_match"))
112
+ correct_sql = sum(1 for r in results if r.get("sql_match"))
113
+ correct_semantic = sum(1 for r in results if r.get("semantic_sql_match"))
114
+ valid_sql = sum(1 for r in results if r.get("status") not in ["EXEC_FAIL", "NO_SQL", "ERROR", "ROUTE_FAIL", "BAD_CONFIG", "GT_FAIL", "NO_GT", "INVALID_GT", "INVALID_SQL"])
115
+
116
+ layer_counts = {"layer_1": 0, "layer_2": 0, "layer_3": 0, "fallback": 0}
117
+ for r in results:
118
+ layer = r.get("routing_layer", "unknown")
119
+ if layer in layer_counts:
120
+ layer_counts[layer] += 1
121
+
122
+
123
+ total_with_gt = sum(1 for r in results if r.get("status") not in ["NO_GT", "INVALID_GT"])
124
+
125
+ metrics = {
126
+ "routing_accuracy": (correct_routing / total_samples) * 100,
127
+ "execution_accuracy": (correct_sql / total_with_gt) * 100 if total_with_gt > 0 else 0.0,
128
+ "semantic_sql_accuracy": (correct_semantic / total_with_gt) * 100 if total_with_gt > 0 else 0.0,
129
+ "valid_sql_rate": (valid_sql / total_samples) * 100,
130
+ "layer_distribution": layer_counts,
131
+ "layer_percentages": {k: (v / total_samples) * 100 for k, v in layer_counts.items()}
132
+ }
133
+
134
+ return metrics
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ import pathlib
4
+ from typing import Optional, List
5
+
6
+ from pydantic import BaseModel
7
+
8
+
9
+ class BenchmarkConfig(BaseModel):
10
+ """Configuration for running benchmarks."""
11
+
12
+ dataset_path: pathlib.Path
13
+ config_path: Optional[pathlib.Path] = None
14
+ bench_config_path: Optional[pathlib.Path] = None
15
+ llm_config_path: Optional[pathlib.Path] = None
16
+ vector_store_path: Optional[str] = None
17
+ secrets_path: Optional[pathlib.Path] = None
18
+ iterations: int = 3
19
+ routing_only: bool = False
20
+ include_ids: Optional[List[str]] = None
21
+ export_path: Optional[pathlib.Path] = None
22
+ stub_llm: bool = False
@@ -0,0 +1,4 @@
1
+ from .contracts import ArtifactRef, ExecutorResponse, ExecutorRequest
2
+ from .execution_store import ExecutionStore
3
+
4
+ __all__ = ["ArtifactRef", "ExecutorResponse", "ExecutorRequest", "ExecutionStore"]
@@ -0,0 +1,3 @@
1
+ from .store import ArtifactStore, ArtifactStoreConfig, build_artifact_store
2
+
3
+ __all__ = ["ArtifactStore", "ArtifactStoreConfig", "build_artifact_store"]
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+ import polars as pl
6
+
7
+ from nl2sql_adapter_sdk.contracts import ResultFrame
8
+
9
+
10
+ def result_frame_to_polars(frame: ResultFrame) -> pl.DataFrame:
11
+ rows = frame.to_row_dicts()
12
+ columns = frame.columns
13
+ if columns:
14
+ return pl.DataFrame(rows, schema=columns)
15
+ return pl.DataFrame(rows)
16
+
17
+
18
+ def polars_to_result_frame(df: pl.DataFrame) -> ResultFrame:
19
+ rows = df.to_dicts()
20
+ columns = df.columns
21
+ return ResultFrame.from_row_dicts(rows, columns=columns, row_count=len(rows), success=True)
22
+
23
+
24
+ def write_parquet(
25
+ df: pl.DataFrame,
26
+ target: Any,
27
+ storage_options: Optional[Dict[str, Any]] = None,
28
+ ) -> None:
29
+ if storage_options is None:
30
+ df.write_parquet(target)
31
+ else:
32
+ df.write_parquet(target, storage_options=storage_options)
33
+
34
+
35
+ def read_parquet(
36
+ source: Any,
37
+ storage_options: Optional[Dict[str, Any]] = None,
38
+ ) -> pl.DataFrame:
39
+ if storage_options is None:
40
+ return pl.read_parquet(source)
41
+ return pl.read_parquet(source, storage_options=storage_options)
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import re
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Any, Dict, Optional
10
+
11
+ import polars as pl
12
+
13
+ from nl2sql.common.settings import settings
14
+ from nl2sql.execution.contracts import ArtifactRef
15
+ from nl2sql_adapter_sdk.contracts import ResultFrame
16
+
17
+ from .parquet import polars_to_result_frame, read_parquet, result_frame_to_polars, write_parquet
18
+
19
+ _PLACEHOLDER = re.compile(r"<([a-zA-Z0-9_]+)>")
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class ArtifactStoreConfig:
24
+ backend: str
25
+ base_uri: str
26
+ path_template: str
27
+ s3_bucket: Optional[str] = None
28
+ s3_prefix: Optional[str] = None
29
+ adls_account: Optional[str] = None
30
+ adls_container: Optional[str] = None
31
+ adls_connection_string: Optional[str] = None
32
+
33
+
34
+ class ArtifactStore:
35
+ """Writes and reads Parquet result artifacts for every supported backend.
36
+
37
+ Backends differ only in how the target URI is built; polars handles ``s3://``
38
+ and ``abfs://`` natively, so the read/write path itself is shared.
39
+ """
40
+
41
+ def __init__(self, config: ArtifactStoreConfig) -> None:
42
+ self.config = config
43
+
44
+ def create_artifact_ref(self, frame: ResultFrame, metadata: Dict[str, str]) -> ArtifactRef:
45
+ uri = self._build_uri(metadata)
46
+ df = result_frame_to_polars(frame)
47
+ write_parquet(df, uri, storage_options=self._storage_options())
48
+
49
+ payload = {
50
+ "columns": frame.columns,
51
+ "row_count": frame.row_count,
52
+ "path": uri,
53
+ }
54
+ content_hash = hashlib.sha256(
55
+ json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
56
+ ).hexdigest()
57
+
58
+ return self._build_artifact_ref(
59
+ uri=uri,
60
+ frame=frame,
61
+ content_hash=content_hash,
62
+ bytes_written=self._bytes_written(uri, df),
63
+ schema_version=metadata.get("schema_version"),
64
+ )
65
+
66
+ def read_result_frame(self, artifact: ArtifactRef) -> ResultFrame:
67
+ return polars_to_result_frame(self.read_parquet(artifact))
68
+
69
+ def read_parquet(self, artifact: ArtifactRef) -> pl.DataFrame:
70
+ return read_parquet(artifact.uri, storage_options=self._storage_options())
71
+
72
+ def _render_path(self, metadata: Dict[str, str]) -> str:
73
+ def substitute(match: re.Match) -> str:
74
+ key = match.group(1)
75
+ value = metadata.get(key)
76
+ if value in (None, ""):
77
+ raise ValueError(
78
+ f"Cannot render artifact path template '{self.config.path_template}': "
79
+ f"no value for placeholder '<{key}>'. Provide '{key}' in the artifact metadata "
80
+ f"or remove it from RESULT_ARTIFACT_PATH_TEMPLATE."
81
+ )
82
+ return str(value)
83
+
84
+ return _PLACEHOLDER.sub(substitute, self.config.path_template)
85
+
86
+ def _build_uri(self, metadata: Dict[str, str]) -> str:
87
+ backend = self.config.backend
88
+ relative_path = self._render_path(metadata)
89
+
90
+ if backend == "local":
91
+ target = Path(self.config.base_uri) / relative_path
92
+ target.parent.mkdir(parents=True, exist_ok=True)
93
+ return str(target.resolve())
94
+
95
+ if backend == "s3":
96
+ if not self.config.s3_bucket:
97
+ raise ValueError(
98
+ "S3 artifact backend requires a bucket; set RESULT_ARTIFACT_S3_BUCKET."
99
+ )
100
+ prefix = (self.config.s3_prefix or "").strip("/")
101
+ key = f"{prefix}/{relative_path}" if prefix else relative_path
102
+ return f"s3://{self.config.s3_bucket}/{key}"
103
+
104
+ if backend == "adls":
105
+ if not self.config.adls_container:
106
+ raise ValueError(
107
+ "ADLS artifact backend requires a container; set RESULT_ARTIFACT_ADLS_CONTAINER."
108
+ )
109
+ if not self.config.adls_account:
110
+ raise ValueError(
111
+ "ADLS artifact backend requires a storage account; set RESULT_ARTIFACT_ADLS_ACCOUNT."
112
+ )
113
+ host = f"{self.config.adls_account}.dfs.core.windows.net"
114
+ return f"abfs://{self.config.adls_container}@{host}/{relative_path}"
115
+
116
+ raise ValueError(
117
+ f"Unsupported artifact backend '{backend}'. Expected one of: local, s3, adls."
118
+ )
119
+
120
+ def _storage_options(self) -> Optional[Dict[str, Any]]:
121
+ if self.config.backend == "adls" and self.config.adls_connection_string:
122
+ return {"connection_string": self.config.adls_connection_string}
123
+ return None
124
+
125
+ def _bytes_written(self, uri: str, df: pl.DataFrame) -> int:
126
+ if self.config.backend == "local":
127
+ path = Path(uri)
128
+ return path.stat().st_size if path.exists() else 0
129
+ return df.estimated_size()
130
+
131
+ def _build_artifact_ref(
132
+ self,
133
+ uri: str,
134
+ frame: ResultFrame,
135
+ content_hash: str,
136
+ bytes_written: int,
137
+ schema_version: Optional[str],
138
+ ) -> ArtifactRef:
139
+ return ArtifactRef(
140
+ uri=uri,
141
+ backend=self.config.backend,
142
+ format="parquet",
143
+ row_count=frame.row_count or len(frame.rows),
144
+ columns=frame.columns,
145
+ bytes=bytes_written,
146
+ content_hash=content_hash,
147
+ created_at=datetime.utcnow(),
148
+ schema_version=schema_version,
149
+ path_template=self.config.path_template,
150
+ )
151
+
152
+
153
+ def build_artifact_store() -> ArtifactStore:
154
+ return ArtifactStore(
155
+ ArtifactStoreConfig(
156
+ backend=settings.result_artifact_backend,
157
+ base_uri=settings.result_artifact_base_uri,
158
+ path_template=settings.result_artifact_path_template,
159
+ s3_bucket=settings.result_artifact_s3_bucket,
160
+ s3_prefix=settings.result_artifact_s3_prefix,
161
+ adls_account=settings.result_artifact_adls_account,
162
+ adls_container=settings.result_artifact_adls_container,
163
+ adls_connection_string=settings.result_artifact_adls_connection_string,
164
+ )
165
+ )