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,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,3 @@
1
+ from .node import ExecutorNode
2
+
3
+ __all__ = ["ExecutorNode"]
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ from .node import GeneratorNode
2
+ from .schemas import GeneratorResponse
3
+
4
+ __all__ = ["GeneratorNode", "GeneratorResponse"]