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.
- nl2sql/__init__.py +38 -0
- nl2sql/adapters/__init__.py +0 -0
- nl2sql/adapters/duckdb/__init__.py +0 -0
- nl2sql/adapters/duckdb/adapter.py +71 -0
- nl2sql/adapters/mssql/__init__.py +0 -0
- nl2sql/adapters/mssql/adapter.py +122 -0
- nl2sql/adapters/mysql/__init__.py +0 -0
- nl2sql/adapters/mysql/adapter.py +123 -0
- nl2sql/adapters/postgres/__init__.py +0 -0
- nl2sql/adapters/postgres/adapter.py +115 -0
- nl2sql/adapters/sqlalchemy_base/__init__.py +17 -0
- nl2sql/adapters/sqlalchemy_base/adapter.py +476 -0
- nl2sql/adapters/sqlalchemy_base/models.py +36 -0
- nl2sql/adapters/sqlite/__init__.py +0 -0
- nl2sql/adapters/sqlite/adapter.py +88 -0
- nl2sql/aggregation/__init__.py +3 -0
- nl2sql/aggregation/aggregator.py +98 -0
- nl2sql/aggregation/engines/__init__.py +3 -0
- nl2sql/aggregation/engines/polars_duckdb.py +125 -0
- nl2sql/api/__init__.py +0 -0
- nl2sql/api/auth_api.py +60 -0
- nl2sql/api/benchmark_api.py +114 -0
- nl2sql/api/datasource_api.py +132 -0
- nl2sql/api/indexing_api.py +59 -0
- nl2sql/api/llm_api.py +82 -0
- nl2sql/api/policy_api.py +135 -0
- nl2sql/api/query_api.py +138 -0
- nl2sql/api/result_api.py +24 -0
- nl2sql/api/settings_api.py +65 -0
- nl2sql/auth/__init__.py +8 -0
- nl2sql/auth/models.py +36 -0
- nl2sql/auth/rbac.py +25 -0
- nl2sql/cli/__init__.py +0 -0
- nl2sql/cli/checks.py +53 -0
- nl2sql/cli/commands/__init__.py +0 -0
- nl2sql/cli/commands/benchmark.py +34 -0
- nl2sql/cli/commands/doctor.py +49 -0
- nl2sql/cli/commands/indexing.py +126 -0
- nl2sql/cli/commands/info.py +25 -0
- nl2sql/cli/commands/install.py +27 -0
- nl2sql/cli/commands/policy.py +57 -0
- nl2sql/cli/commands/run.py +166 -0
- nl2sql/cli/commands/setup.py +415 -0
- nl2sql/cli/commands/visualize.py +34 -0
- nl2sql/cli/common/decorators.py +34 -0
- nl2sql/cli/config.py +24 -0
- nl2sql/cli/console.py +52 -0
- nl2sql/cli/demo/__init__.py +1 -0
- nl2sql/cli/demo/data.py +87 -0
- nl2sql/cli/demo/defaults.py +122 -0
- nl2sql/cli/demo/factory.py +289 -0
- nl2sql/cli/demo/manager.py +230 -0
- nl2sql/cli/demo/schemas.py +336 -0
- nl2sql/cli/demo/writers/__init__.py +0 -0
- nl2sql/cli/demo/writers/docker.py +182 -0
- nl2sql/cli/demo/writers/sqlite.py +88 -0
- nl2sql/cli/generators/datasources/__init__.py +3 -0
- nl2sql/cli/generators/datasources/generator.py +24 -0
- nl2sql/cli/generators/datasources/templates.py +7 -0
- nl2sql/cli/generators/env/__init__.py +3 -0
- nl2sql/cli/generators/env/generator.py +46 -0
- nl2sql/cli/generators/env/templates.py +25 -0
- nl2sql/cli/generators/llm/__init__.py +3 -0
- nl2sql/cli/generators/llm/generator.py +24 -0
- nl2sql/cli/generators/llm/templates.py +4 -0
- nl2sql/cli/generators/policies/__init__.py +3 -0
- nl2sql/cli/generators/policies/generator.py +20 -0
- nl2sql/cli/generators/policies/templates.py +2 -0
- nl2sql/cli/main.py +195 -0
- nl2sql/cli/reporting.py +878 -0
- nl2sql/cli/types.py +13 -0
- nl2sql/common/__init__.py +1 -0
- nl2sql/common/cancellation.py +25 -0
- nl2sql/common/context.py +5 -0
- nl2sql/common/errors.py +109 -0
- nl2sql/common/event_logger.py +88 -0
- nl2sql/common/exceptions.py +3 -0
- nl2sql/common/logger.py +119 -0
- nl2sql/common/metrics.py +50 -0
- nl2sql/common/resilience.py +59 -0
- nl2sql/common/settings.py +195 -0
- nl2sql/configs/__init__.py +6 -0
- nl2sql/configs/datasources.py +10 -0
- nl2sql/configs/llm.py +36 -0
- nl2sql/configs/manager.py +176 -0
- nl2sql/configs/policies.py +14 -0
- nl2sql/configs/sample_questions.py +11 -0
- nl2sql/configs/secrets.py +11 -0
- nl2sql/context.py +106 -0
- nl2sql/datasources/__init__.py +21 -0
- nl2sql/datasources/discovery.py +28 -0
- nl2sql/datasources/models.py +21 -0
- nl2sql/datasources/protocols.py +3 -0
- nl2sql/datasources/registry.py +172 -0
- nl2sql/evaluation/__init__.py +6 -0
- nl2sql/evaluation/benchmark_runner.py +320 -0
- nl2sql/evaluation/evaluator.py +134 -0
- nl2sql/evaluation/types.py +22 -0
- nl2sql/execution/__init__.py +4 -0
- nl2sql/execution/artifacts/__init__.py +3 -0
- nl2sql/execution/artifacts/parquet.py +41 -0
- nl2sql/execution/artifacts/store.py +165 -0
- nl2sql/execution/contracts.py +57 -0
- nl2sql/execution/execution_store.py +25 -0
- nl2sql/execution/executor/__init__.py +3 -0
- nl2sql/execution/executor/sql_executor.py +116 -0
- nl2sql/indexing/__init__.py +7 -0
- nl2sql/indexing/chunk_builder.py +227 -0
- nl2sql/indexing/embeddings.py +180 -0
- nl2sql/indexing/enrichment_service.py +316 -0
- nl2sql/indexing/models.py +209 -0
- nl2sql/indexing/orchestrator.py +90 -0
- nl2sql/indexing/vector_store.py +422 -0
- nl2sql/llm/__init__.py +8 -0
- nl2sql/llm/models.py +10 -0
- nl2sql/llm/registry.py +214 -0
- nl2sql/pipeline/__init__.py +1 -0
- nl2sql/pipeline/graph.py +73 -0
- nl2sql/pipeline/graph_utils.py +141 -0
- nl2sql/pipeline/nodes/__init__.py +25 -0
- nl2sql/pipeline/nodes/aggregator/__init__.py +4 -0
- nl2sql/pipeline/nodes/aggregator/node.py +55 -0
- nl2sql/pipeline/nodes/aggregator/prompts.py +20 -0
- nl2sql/pipeline/nodes/aggregator/schemas.py +28 -0
- nl2sql/pipeline/nodes/answer_synthesizer/__init__.py +4 -0
- nl2sql/pipeline/nodes/answer_synthesizer/node.py +98 -0
- nl2sql/pipeline/nodes/answer_synthesizer/prompts.py +19 -0
- nl2sql/pipeline/nodes/answer_synthesizer/schemas.py +24 -0
- nl2sql/pipeline/nodes/ast_planner/__init__.py +4 -0
- nl2sql/pipeline/nodes/ast_planner/node.py +104 -0
- nl2sql/pipeline/nodes/ast_planner/prompts.py +138 -0
- nl2sql/pipeline/nodes/ast_planner/schemas.py +236 -0
- nl2sql/pipeline/nodes/datasource_resolver/__init__.py +4 -0
- nl2sql/pipeline/nodes/datasource_resolver/node.py +253 -0
- nl2sql/pipeline/nodes/datasource_resolver/schemas.py +21 -0
- nl2sql/pipeline/nodes/decomposer/__init__.py +3 -0
- nl2sql/pipeline/nodes/decomposer/node.py +219 -0
- nl2sql/pipeline/nodes/decomposer/prompts.py +96 -0
- nl2sql/pipeline/nodes/decomposer/schemas.py +143 -0
- nl2sql/pipeline/nodes/executor/__init__.py +3 -0
- nl2sql/pipeline/nodes/executor/node.py +107 -0
- nl2sql/pipeline/nodes/generator/__init__.py +4 -0
- nl2sql/pipeline/nodes/generator/node.py +267 -0
- nl2sql/pipeline/nodes/generator/schemas.py +13 -0
- nl2sql/pipeline/nodes/global_planner/__init__.py +4 -0
- nl2sql/pipeline/nodes/global_planner/node.py +186 -0
- nl2sql/pipeline/nodes/global_planner/schemas.py +101 -0
- nl2sql/pipeline/nodes/refiner/__init__.py +4 -0
- nl2sql/pipeline/nodes/refiner/node.py +132 -0
- nl2sql/pipeline/nodes/refiner/prompts.py +28 -0
- nl2sql/pipeline/nodes/refiner/schemas.py +13 -0
- nl2sql/pipeline/nodes/schema_retriever/__init__.py +3 -0
- nl2sql/pipeline/nodes/schema_retriever/node.py +252 -0
- nl2sql/pipeline/nodes/schema_retriever/schema.py +27 -0
- nl2sql/pipeline/nodes/validator/__init__.py +7 -0
- nl2sql/pipeline/nodes/validator/node.py +839 -0
- nl2sql/pipeline/nodes/validator/schemas.py +12 -0
- nl2sql/pipeline/pipeline_runner.py +72 -0
- nl2sql/pipeline/routes.py +72 -0
- nl2sql/pipeline/runtime.py +153 -0
- nl2sql/pipeline/state.py +92 -0
- nl2sql/pipeline/subgraphs/__init__.py +5 -0
- nl2sql/pipeline/subgraphs/schemas.py +23 -0
- nl2sql/pipeline/subgraphs/sql_agent.py +167 -0
- nl2sql/public_api.py +199 -0
- nl2sql/schema/__init__.py +37 -0
- nl2sql/schema/in_memory_store.py +173 -0
- nl2sql/schema/protocol.py +88 -0
- nl2sql/schema/sqlite_store.py +233 -0
- nl2sql/schema/store.py +29 -0
- nl2sql/secrets/__init__.py +14 -0
- nl2sql/secrets/factory.py +85 -0
- nl2sql/secrets/interfaces.py +16 -0
- nl2sql/secrets/manager.py +139 -0
- nl2sql/secrets/models.py +56 -0
- nl2sql/secrets/providers/aws.py +30 -0
- nl2sql/secrets/providers/azure.py +49 -0
- nl2sql/secrets/providers/env.py +8 -0
- nl2sql/secrets/providers/hashi.py +46 -0
- nl2sql/services/__init__.py +0 -0
- nl2sql/services/callbacks/__init__.py +0 -0
- nl2sql/services/callbacks/monitor.py +84 -0
- nl2sql/services/callbacks/node_context.py +7 -0
- nl2sql/services/callbacks/node_handlers.py +187 -0
- nl2sql/services/callbacks/node_metrics.py +14 -0
- nl2sql/services/callbacks/presenter.py +12 -0
- nl2sql/services/callbacks/token_handler.py +56 -0
- nl2sql_engine-0.1.0.dist-info/METADATA +295 -0
- nl2sql_engine-0.1.0.dist-info/RECORD +192 -0
- nl2sql_engine-0.1.0.dist-info/WHEEL +5 -0
- nl2sql_engine-0.1.0.dist-info/entry_points.txt +9 -0
- 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,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
|
+
)
|