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,219 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Dict, Any, TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from langchain_core.prompts import ChatPromptTemplate
|
|
5
|
+
from langchain_core.runnables import Runnable
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from nl2sql.pipeline.state import GraphState
|
|
9
|
+
|
|
10
|
+
from .schemas import DecomposerResponse, SubQuery, UnmappedSubQuery, PostCombineOp
|
|
11
|
+
from .prompts import DECOMPOSER_PROMPT
|
|
12
|
+
from nl2sql.common.errors import PipelineError, ErrorSeverity, ErrorCode
|
|
13
|
+
from nl2sql.common.logger import get_logger
|
|
14
|
+
from nl2sql.context import NL2SQLContext
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
|
|
18
|
+
logger = get_logger("decomposer")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DecomposerNode:
|
|
23
|
+
"""Orchestrates query decomposition and routing.
|
|
24
|
+
|
|
25
|
+
Analyzes the user query to generate semantic sub-queries and combine groups.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
llm (ChatOpenAI): The language model to use.
|
|
29
|
+
prompt (ChatPromptTemplate): The prompt template.
|
|
30
|
+
chain (Runnable): The execution chain.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, ctx: NL2SQLContext):
|
|
34
|
+
"""Initializes the DecomposerNode.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
llm (LLMCallable): The LLM instance or runnable.
|
|
38
|
+
vector_store (Optional[VectorStore]): Vector store for RAG.
|
|
39
|
+
"""
|
|
40
|
+
self.node_name = self.__class__.__name__.lower().replace('node', '')
|
|
41
|
+
self.llm = ctx.llm_registry.get_llm(self.node_name)
|
|
42
|
+
self.prompt = ChatPromptTemplate.from_template(DECOMPOSER_PROMPT)
|
|
43
|
+
self.chain = self.prompt | self.llm.with_structured_output(
|
|
44
|
+
DecomposerResponse, method="function_calling"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def _stable_id(self, prefix: str, payload: Dict[str, Any]) -> str:
|
|
48
|
+
data = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
|
49
|
+
digest = hashlib.sha256(data.encode("utf-8")).hexdigest()[:12]
|
|
50
|
+
return f"{prefix}_{digest}"
|
|
51
|
+
|
|
52
|
+
def __call__(self, state: GraphState) -> Dict[str, Any]:
|
|
53
|
+
"""Executes the decomposer node.
|
|
54
|
+
|
|
55
|
+
Invokes the LLM to produce semantic sub-queries and combine groups.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
state (GraphState): The current execution state.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Dict[str, Any]: Dictionary containing 'sub_queries', confidence, reasoning, etc.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
resolver_response = state.datasource_resolver_response
|
|
65
|
+
if not resolver_response or not resolver_response.resolved_datasources:
|
|
66
|
+
raise ValueError("Unable to resolve any datasource for the current user query.")
|
|
67
|
+
|
|
68
|
+
resolved_datasources = resolver_response.resolved_datasources
|
|
69
|
+
resolved_payload = []
|
|
70
|
+
resolved_ids = set()
|
|
71
|
+
schema_version_map = {}
|
|
72
|
+
for datasource in resolved_datasources:
|
|
73
|
+
resolved_payload.append(datasource.model_dump())
|
|
74
|
+
resolved_ids.add(datasource.datasource_id)
|
|
75
|
+
schema_version_map[datasource.datasource_id] = datasource.schema_version
|
|
76
|
+
|
|
77
|
+
llm_response: DecomposerResponse = self.chain.invoke(
|
|
78
|
+
{
|
|
79
|
+
"user_query": state.user_query,
|
|
80
|
+
"resolved_datasources": resolved_payload,
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
final_sub_queries = []
|
|
85
|
+
unmapped = []
|
|
86
|
+
allowed_ids = set(resolver_response.allowed_datasource_ids)
|
|
87
|
+
unsupported_ids = set(resolver_response.unsupported_datasource_ids)
|
|
88
|
+
id_map: Dict[str, str] = {}
|
|
89
|
+
|
|
90
|
+
for llm_sq in llm_response.sub_queries:
|
|
91
|
+
datasource_id = (llm_sq.datasource_id or "").strip() or None
|
|
92
|
+
if not datasource_id or datasource_id not in resolved_ids:
|
|
93
|
+
unmapped.append(
|
|
94
|
+
UnmappedSubQuery(
|
|
95
|
+
intent=llm_sq.intent,
|
|
96
|
+
reason="no_datasource",
|
|
97
|
+
datasource_id=datasource_id,
|
|
98
|
+
detail="Datasource is missing or not resolved for this sub-query.",
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
continue
|
|
102
|
+
if datasource_id not in allowed_ids:
|
|
103
|
+
unmapped.append(
|
|
104
|
+
UnmappedSubQuery(
|
|
105
|
+
intent=llm_sq.intent,
|
|
106
|
+
reason="restricted_datasource",
|
|
107
|
+
datasource_id=datasource_id,
|
|
108
|
+
detail="Datasource is not allowed for the current user context.",
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
continue
|
|
112
|
+
if datasource_id in unsupported_ids:
|
|
113
|
+
unmapped.append(
|
|
114
|
+
UnmappedSubQuery(
|
|
115
|
+
intent=llm_sq.intent,
|
|
116
|
+
reason="unsupported_datasource",
|
|
117
|
+
datasource_id=datasource_id,
|
|
118
|
+
detail="Datasource does not match any supported adapter capabilities.",
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
continue
|
|
122
|
+
stable_id = self._stable_id(
|
|
123
|
+
"sq",
|
|
124
|
+
{
|
|
125
|
+
"datasource_id": datasource_id,
|
|
126
|
+
"intent": llm_sq.intent,
|
|
127
|
+
"metrics": [m.model_dump() for m in llm_sq.metrics],
|
|
128
|
+
"filters": [f.model_dump() for f in llm_sq.filters],
|
|
129
|
+
"group_by": [g.model_dump() for g in llm_sq.group_by],
|
|
130
|
+
"expected_schema": [c.model_dump() for c in llm_sq.expected_schema],
|
|
131
|
+
},
|
|
132
|
+
)
|
|
133
|
+
id_map[llm_sq.id] = stable_id
|
|
134
|
+
sq = SubQuery(
|
|
135
|
+
id=stable_id,
|
|
136
|
+
intent=llm_sq.intent,
|
|
137
|
+
datasource_id=datasource_id,
|
|
138
|
+
metrics=llm_sq.metrics,
|
|
139
|
+
filters=llm_sq.filters,
|
|
140
|
+
group_by=llm_sq.group_by,
|
|
141
|
+
expected_schema=llm_sq.expected_schema,
|
|
142
|
+
schema_version=schema_version_map.get(datasource_id),
|
|
143
|
+
)
|
|
144
|
+
final_sub_queries.append(sq)
|
|
145
|
+
|
|
146
|
+
valid_ids = {sq.id for sq in final_sub_queries}
|
|
147
|
+
combine_groups = []
|
|
148
|
+
for group in llm_response.combine_groups:
|
|
149
|
+
updated_inputs = []
|
|
150
|
+
for inp in group.inputs:
|
|
151
|
+
mapped_id = id_map.get(inp.subquery_id, inp.subquery_id)
|
|
152
|
+
if mapped_id in valid_ids:
|
|
153
|
+
updated_inputs.append(inp.model_copy(update={"subquery_id": mapped_id}))
|
|
154
|
+
if not updated_inputs:
|
|
155
|
+
continue
|
|
156
|
+
combine_groups.append(group.model_copy(update={"inputs": updated_inputs}))
|
|
157
|
+
|
|
158
|
+
combine_groups = sorted(combine_groups, key=lambda g: g.group_id)
|
|
159
|
+
final_sub_queries = sorted(final_sub_queries, key=lambda s: s.id)
|
|
160
|
+
|
|
161
|
+
post_combine_ops = []
|
|
162
|
+
for op in llm_response.post_combine_ops or []:
|
|
163
|
+
op_id = self._stable_id(
|
|
164
|
+
"op",
|
|
165
|
+
{
|
|
166
|
+
"target_group_id": op.target_group_id,
|
|
167
|
+
"operation": op.operation,
|
|
168
|
+
"filters": [f.model_dump() for f in op.filters],
|
|
169
|
+
"metrics": [m.model_dump() for m in op.metrics],
|
|
170
|
+
"group_by": [g.model_dump() for g in op.group_by],
|
|
171
|
+
"order_by": [o.model_dump() for o in op.order_by],
|
|
172
|
+
"limit": op.limit,
|
|
173
|
+
"expected_schema": [c.model_dump() for c in op.expected_schema],
|
|
174
|
+
"metadata": op.metadata,
|
|
175
|
+
},
|
|
176
|
+
)
|
|
177
|
+
post_combine_ops.append(op.model_copy(update={"op_id": op_id}))
|
|
178
|
+
|
|
179
|
+
post_combine_ops = sorted(post_combine_ops, key=lambda o: o.op_id)
|
|
180
|
+
|
|
181
|
+
response = DecomposerResponse(
|
|
182
|
+
sub_queries=final_sub_queries,
|
|
183
|
+
combine_groups=combine_groups,
|
|
184
|
+
post_combine_ops=post_combine_ops,
|
|
185
|
+
unmapped_subqueries=unmapped,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
"decomposer_response": response,
|
|
190
|
+
"reasoning": [{"node": self.node_name, "content": "Decomposition completed."}],
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
except Exception as e:
|
|
194
|
+
logger.error(f"Node {self.node_name} failed: {e}")
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
"decomposer_response": DecomposerResponse(
|
|
198
|
+
sub_queries=[],
|
|
199
|
+
combine_groups=[],
|
|
200
|
+
post_combine_ops=[],
|
|
201
|
+
unmapped_subqueries=[],
|
|
202
|
+
),
|
|
203
|
+
"reasoning": [
|
|
204
|
+
{
|
|
205
|
+
"node": self.node_name,
|
|
206
|
+
"content": f"Decomposition failed: {str(e)}",
|
|
207
|
+
"type": "error",
|
|
208
|
+
}
|
|
209
|
+
],
|
|
210
|
+
"errors": [
|
|
211
|
+
PipelineError(
|
|
212
|
+
node=self.node_name,
|
|
213
|
+
message=f"Decomposition failed: {str(e)}",
|
|
214
|
+
severity=ErrorSeverity.CRITICAL,
|
|
215
|
+
error_code=ErrorCode.ORCHESTRATOR_CRASH,
|
|
216
|
+
stack_trace=str(e),
|
|
217
|
+
)
|
|
218
|
+
],
|
|
219
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Prompts for the Query Decomposer node."""
|
|
2
|
+
|
|
3
|
+
DECOMPOSER_PROMPT = """
|
|
4
|
+
SYSTEM:
|
|
5
|
+
You are a Semantic Query Decomposer. You output ONLY structured semantic intent.
|
|
6
|
+
|
|
7
|
+
TASK:
|
|
8
|
+
Decompose the user query into semantic sub-queries and combine groups.
|
|
9
|
+
Your output must be deterministic and strictly follow the JSON contract.
|
|
10
|
+
|
|
11
|
+
INPUTS:
|
|
12
|
+
User Query:
|
|
13
|
+
{user_query}
|
|
14
|
+
|
|
15
|
+
Resolved Datasources (id + semantic metadata):
|
|
16
|
+
{resolved_datasources}
|
|
17
|
+
|
|
18
|
+
RULES:
|
|
19
|
+
1) Use resolved_datasources metadata to select the most appropriate datasource for each subquery.
|
|
20
|
+
2) If an intent cannot be mapped to any resolved datasource, emit it under unmapped_subqueries.
|
|
21
|
+
3) SubQueries must contain ONLY semantic intent:
|
|
22
|
+
- metrics
|
|
23
|
+
- filters
|
|
24
|
+
- group_by
|
|
25
|
+
4) Do NOT emit:
|
|
26
|
+
- SQL
|
|
27
|
+
- table names
|
|
28
|
+
- column names
|
|
29
|
+
- joins
|
|
30
|
+
- physical schema
|
|
31
|
+
5) Define combine_groups explicitly using:
|
|
32
|
+
- standalone
|
|
33
|
+
- compare
|
|
34
|
+
- join
|
|
35
|
+
- union
|
|
36
|
+
6) For join or compare:
|
|
37
|
+
- include join_keys as left/right semantic attribute pairs.
|
|
38
|
+
7) Any filters, metrics, group_by, order_by, or limits that apply AFTER a combine must be emitted in post_combine_ops.
|
|
39
|
+
8) expected_schema must be derived strictly from semantic intent (metrics + group_by) and be minimal.
|
|
40
|
+
It defines the semantic output contract for downstream aggregation, not physical columns.
|
|
41
|
+
9) Do not invent attributes not implied by the user query or datasource metadata.
|
|
42
|
+
10) Output JSON only. No commentary.
|
|
43
|
+
|
|
44
|
+
OUTPUT FORMAT:
|
|
45
|
+
Return JSON exactly matching this structure:
|
|
46
|
+
|
|
47
|
+
{{
|
|
48
|
+
"sub_queries": [
|
|
49
|
+
{{
|
|
50
|
+
"id": "sq_1",
|
|
51
|
+
"datasource_id": "ds_sales",
|
|
52
|
+
"intent": "total revenue by region last quarter",
|
|
53
|
+
"metrics": [{{"name": "total_revenue", "aggregation": "sum"}}],
|
|
54
|
+
"filters": [{{"attribute": "time_period", "operator": "=", "value": "last_quarter"}}],
|
|
55
|
+
"group_by": [{{"attribute": "region"}}],
|
|
56
|
+
"expected_schema": [
|
|
57
|
+
{{"name": "region", "dtype": "string"}},
|
|
58
|
+
{{"name": "total_revenue", "dtype": "float"}}
|
|
59
|
+
]
|
|
60
|
+
}}
|
|
61
|
+
],
|
|
62
|
+
"combine_groups": [
|
|
63
|
+
{{
|
|
64
|
+
"group_id": "cg_1",
|
|
65
|
+
"operation": "standalone",
|
|
66
|
+
"inputs": [
|
|
67
|
+
{{"subquery_id": "sq_1", "role": "base"}}
|
|
68
|
+
],
|
|
69
|
+
"join_keys": []
|
|
70
|
+
}}
|
|
71
|
+
],
|
|
72
|
+
"post_combine_ops": [
|
|
73
|
+
{{
|
|
74
|
+
"op_id": "op_1",
|
|
75
|
+
"target_group_id": "cg_1",
|
|
76
|
+
"operation": "filter",
|
|
77
|
+
"filters": [{{"attribute": "total_revenue", "operator": ">", "value": 1000}}],
|
|
78
|
+
"metrics": [],
|
|
79
|
+
"group_by": [],
|
|
80
|
+
"order_by": [{{"attribute": "total_revenue", "direction": "desc"}}],
|
|
81
|
+
"limit": 10,
|
|
82
|
+
"expected_schema": [
|
|
83
|
+
{{"name": "region", "dtype": "string"}},
|
|
84
|
+
{{"name": "total_revenue", "dtype": "float"}}
|
|
85
|
+
]
|
|
86
|
+
}}
|
|
87
|
+
],
|
|
88
|
+
"unmapped_subqueries": []
|
|
89
|
+
}}
|
|
90
|
+
|
|
91
|
+
VALIDATION:
|
|
92
|
+
- sub_queries must be non-empty unless all intents are unmapped.
|
|
93
|
+
- combine_groups must reference valid subquery_id values.
|
|
94
|
+
- post_combine_ops.target_group_id must reference an existing group_id.
|
|
95
|
+
- expected_schema must match semantic outputs only.
|
|
96
|
+
"""
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import List, Optional, Literal, Dict, Any
|
|
3
|
+
from pydantic import BaseModel, Field, model_validator
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _contains_physical_tokens(text: str) -> bool:
|
|
7
|
+
forbidden = ["select", "from", "join", "where", "group by", "order by", ";", "--", "/*", "*/"]
|
|
8
|
+
lowered = text.lower()
|
|
9
|
+
return any(token in lowered for token in forbidden)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MetricSpec(BaseModel):
|
|
13
|
+
name: str
|
|
14
|
+
aggregation: Optional[Literal["count", "sum", "avg", "min", "max"]] = None
|
|
15
|
+
description: Optional[str] = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FilterSpec(BaseModel):
|
|
19
|
+
attribute: str
|
|
20
|
+
operator: Literal["=", "!=", ">", ">=", "<", "<=", "between", "in", "contains"]
|
|
21
|
+
value: str | int | float | bool | List[str] | List[int] | List[float]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GroupBySpec(BaseModel):
|
|
25
|
+
attribute: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OrderBySpec(BaseModel):
|
|
29
|
+
attribute: str
|
|
30
|
+
direction: Literal["asc", "desc"] = "asc"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ExpectedColumn(BaseModel):
|
|
34
|
+
name: str
|
|
35
|
+
dtype: Optional[Literal["string", "int", "float", "bool", "date", "datetime"]] = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SubQuery(BaseModel):
|
|
39
|
+
id: str
|
|
40
|
+
datasource_id: str
|
|
41
|
+
intent: str
|
|
42
|
+
kind: Literal["scan"] = "scan"
|
|
43
|
+
metrics: List[MetricSpec] = Field(default_factory=list)
|
|
44
|
+
filters: List[FilterSpec] = Field(default_factory=list)
|
|
45
|
+
group_by: List[GroupBySpec] = Field(default_factory=list)
|
|
46
|
+
expected_schema: List[ExpectedColumn] = Field(default_factory=list)
|
|
47
|
+
schema_version: Optional[str] = None
|
|
48
|
+
|
|
49
|
+
@model_validator(mode="after")
|
|
50
|
+
def validate_semantic_only(self):
|
|
51
|
+
content = " ".join(
|
|
52
|
+
[
|
|
53
|
+
self.intent,
|
|
54
|
+
" ".join(m.name for m in self.metrics),
|
|
55
|
+
" ".join(f.attribute for f in self.filters),
|
|
56
|
+
" ".join(g.attribute for g in self.group_by),
|
|
57
|
+
" ".join(c.name for c in self.expected_schema),
|
|
58
|
+
]
|
|
59
|
+
).lower()
|
|
60
|
+
if _contains_physical_tokens(content):
|
|
61
|
+
raise ValueError("SubQuery contains SQL or physical schema tokens.")
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CombineInput(BaseModel):
|
|
66
|
+
subquery_id: str
|
|
67
|
+
role: Optional[Literal["left", "right", "base", "compare", "primary", "secondary"]] = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class JoinKeyPair(BaseModel):
|
|
71
|
+
left: str
|
|
72
|
+
right: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class CombineGroup(BaseModel):
|
|
76
|
+
group_id: str
|
|
77
|
+
operation: Literal["standalone", "compare", "join", "union"]
|
|
78
|
+
inputs: List[CombineInput]
|
|
79
|
+
join_keys: List[JoinKeyPair] = Field(default_factory=list)
|
|
80
|
+
|
|
81
|
+
@model_validator(mode="after")
|
|
82
|
+
def validate_roles(self):
|
|
83
|
+
if self.operation in {"compare", "join"}:
|
|
84
|
+
if any(i.role is None for i in self.inputs):
|
|
85
|
+
raise ValueError("Compare/join combine groups require roles.")
|
|
86
|
+
if not self.join_keys:
|
|
87
|
+
raise ValueError("Compare/join combine groups require join_keys.")
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class PostCombineOp(BaseModel):
|
|
92
|
+
op_id: str
|
|
93
|
+
target_group_id: str
|
|
94
|
+
operation: Literal["filter", "aggregate", "project", "sort", "limit"]
|
|
95
|
+
filters: List[FilterSpec] = Field(default_factory=list)
|
|
96
|
+
metrics: List[MetricSpec] = Field(default_factory=list)
|
|
97
|
+
group_by: List[GroupBySpec] = Field(default_factory=list)
|
|
98
|
+
order_by: List[OrderBySpec] = Field(default_factory=list)
|
|
99
|
+
limit: Optional[int] = None
|
|
100
|
+
expected_schema: List[ExpectedColumn] = Field(default_factory=list)
|
|
101
|
+
metadata: Dict[str, Any] = Field(default_factory=dict)
|
|
102
|
+
|
|
103
|
+
@model_validator(mode="after")
|
|
104
|
+
def validate_semantic_only(self):
|
|
105
|
+
content = " ".join(
|
|
106
|
+
[
|
|
107
|
+
" ".join(m.name for m in self.metrics),
|
|
108
|
+
" ".join(f.attribute for f in self.filters),
|
|
109
|
+
" ".join(g.attribute for g in self.group_by),
|
|
110
|
+
" ".join(o.attribute for o in self.order_by),
|
|
111
|
+
" ".join(c.name for c in self.expected_schema),
|
|
112
|
+
]
|
|
113
|
+
)
|
|
114
|
+
if _contains_physical_tokens(content):
|
|
115
|
+
raise ValueError("PostCombineOp contains SQL or physical schema tokens.")
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class UnmappedSubQuery(BaseModel):
|
|
120
|
+
intent: str
|
|
121
|
+
reason: Literal["no_datasource", "restricted_datasource", "unsupported_datasource"]
|
|
122
|
+
datasource_id: Optional[str] = None
|
|
123
|
+
detail: Optional[str] = None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class DecomposerResponse(BaseModel):
|
|
127
|
+
sub_queries: List[SubQuery]
|
|
128
|
+
combine_groups: List[CombineGroup]
|
|
129
|
+
post_combine_ops: List[PostCombineOp] = Field(default_factory=list)
|
|
130
|
+
unmapped_subqueries: List[UnmappedSubQuery] = Field(default_factory=list)
|
|
131
|
+
|
|
132
|
+
@model_validator(mode="after")
|
|
133
|
+
def validate_references(self):
|
|
134
|
+
ids = {sq.id for sq in self.sub_queries}
|
|
135
|
+
for group in self.combine_groups:
|
|
136
|
+
for inp in group.inputs:
|
|
137
|
+
if inp.subquery_id not in ids:
|
|
138
|
+
raise ValueError(f"CombineGroup references unknown subquery: {inp.subquery_id}")
|
|
139
|
+
group_ids = {g.group_id for g in self.combine_groups}
|
|
140
|
+
for op in self.post_combine_ops:
|
|
141
|
+
if op.target_group_id not in group_ids:
|
|
142
|
+
raise ValueError(f"PostCombineOp references unknown combine group: {op.target_group_id}")
|
|
143
|
+
return self
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Dict, Any
|
|
4
|
+
|
|
5
|
+
from nl2sql.common.errors import PipelineError, ErrorSeverity, ErrorCode
|
|
6
|
+
from nl2sql.common.logger import get_logger
|
|
7
|
+
from nl2sql.context import NL2SQLContext
|
|
8
|
+
from nl2sql.execution.contracts import ExecutorRequest
|
|
9
|
+
from nl2sql.execution.executor import SqlExecutorService
|
|
10
|
+
from nl2sql_adapter_sdk.capabilities import DatasourceCapability
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from nl2sql.pipeline.state import SubgraphExecutionState
|
|
14
|
+
|
|
15
|
+
logger = get_logger("executor")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ExecutorNode:
|
|
19
|
+
"""Thin wrapper that delegates to executor services based on capabilities."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, ctx: NL2SQLContext):
|
|
22
|
+
self.node_name = self.__class__.__name__.lower().replace("node", "")
|
|
23
|
+
self.ds_registry = ctx.ds_registry
|
|
24
|
+
self.executor = SqlExecutorService(ctx.ds_registry)
|
|
25
|
+
self.tenant_id = ctx.tenant_id
|
|
26
|
+
|
|
27
|
+
def _supports_sql(self, ds_id: str) -> bool:
|
|
28
|
+
"""Reports whether a datasource may be executed as SQL.
|
|
29
|
+
|
|
30
|
+
The SQL executor is the only executor, so a datasource that does not
|
|
31
|
+
declare SUPPORTS_SQL -- or whose adapter cannot report capabilities --
|
|
32
|
+
gets no executor at all rather than being run as SQL anyway.
|
|
33
|
+
"""
|
|
34
|
+
adapter = self.ds_registry.get_adapter(ds_id)
|
|
35
|
+
try:
|
|
36
|
+
capabilities = adapter.capabilities()
|
|
37
|
+
except Exception as exc:
|
|
38
|
+
logger.error(f"Failed to get capabilities for datasource '{ds_id}'. {exc}")
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
normalized = {
|
|
42
|
+
cap.value if isinstance(cap, DatasourceCapability) else str(cap)
|
|
43
|
+
for cap in capabilities
|
|
44
|
+
}
|
|
45
|
+
return DatasourceCapability.SUPPORTS_SQL.value in normalized
|
|
46
|
+
|
|
47
|
+
def __call__(self, state: SubgraphExecutionState) -> Dict[str, Any]:
|
|
48
|
+
try:
|
|
49
|
+
ds_id = state.sub_query.datasource_id
|
|
50
|
+
sql = state.generator_response.sql_draft
|
|
51
|
+
|
|
52
|
+
if not sql:
|
|
53
|
+
error = PipelineError(
|
|
54
|
+
node=self.node_name,
|
|
55
|
+
message="No SQL to execute.",
|
|
56
|
+
severity=ErrorSeverity.ERROR,
|
|
57
|
+
error_code=ErrorCode.MISSING_SQL,
|
|
58
|
+
)
|
|
59
|
+
return {"executor_response": None, "errors": [error]}
|
|
60
|
+
|
|
61
|
+
if not ds_id:
|
|
62
|
+
error = PipelineError(
|
|
63
|
+
node=self.node_name,
|
|
64
|
+
message="No datasource_id in state.",
|
|
65
|
+
severity=ErrorSeverity.ERROR,
|
|
66
|
+
error_code=ErrorCode.MISSING_DATASOURCE_ID,
|
|
67
|
+
)
|
|
68
|
+
return {"executor_response": None, "errors": [error]}
|
|
69
|
+
|
|
70
|
+
executor = self.executor if self._supports_sql(ds_id) else None
|
|
71
|
+
if executor is None:
|
|
72
|
+
error = PipelineError(
|
|
73
|
+
node=self.node_name,
|
|
74
|
+
message=f"No executor available for datasource '{ds_id}'.",
|
|
75
|
+
severity=ErrorSeverity.ERROR,
|
|
76
|
+
error_code=ErrorCode.INVALID_STATE,
|
|
77
|
+
)
|
|
78
|
+
return {"executor_response": None, "errors": [error]}
|
|
79
|
+
|
|
80
|
+
request = ExecutorRequest(
|
|
81
|
+
node_id=state.sub_query.id,
|
|
82
|
+
trace_id=state.trace_id,
|
|
83
|
+
subgraph_name=state.subgraph_name,
|
|
84
|
+
datasource_id=ds_id,
|
|
85
|
+
schema_version=state.sub_query.schema_version,
|
|
86
|
+
sql=sql,
|
|
87
|
+
user_context=state.user_context,
|
|
88
|
+
tenant_id=self.tenant_id,
|
|
89
|
+
)
|
|
90
|
+
response = executor.execute(request)
|
|
91
|
+
return {
|
|
92
|
+
"executor_response": response,
|
|
93
|
+
"errors": response.errors,
|
|
94
|
+
"reasoning": response.reasoning,
|
|
95
|
+
}
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
logger.error(f"Node {self.node_name} failed: {exc}")
|
|
98
|
+
error = PipelineError(
|
|
99
|
+
node=self.node_name,
|
|
100
|
+
message=f"Executor crash: {exc}",
|
|
101
|
+
severity=ErrorSeverity.CRITICAL,
|
|
102
|
+
error_code=ErrorCode.EXECUTOR_CRASH,
|
|
103
|
+
)
|
|
104
|
+
return {
|
|
105
|
+
"executor_response": None,
|
|
106
|
+
"errors": [error],
|
|
107
|
+
}
|