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,267 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlglot
4
+ from sqlglot import expressions as exp
5
+ from typing import Dict, Any, List, Union, TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from nl2sql.pipeline.state import SubgraphExecutionState
9
+ from nl2sql.common.errors import PipelineError, ErrorSeverity, ErrorCode
10
+ from nl2sql.datasources import DatasourceRegistry
11
+ from nl2sql.common.logger import get_logger
12
+ from nl2sql.pipeline.nodes.ast_planner.schemas import PlanModel, Expr
13
+ from nl2sql.pipeline.nodes.generator.schemas import GeneratorResponse
14
+ from nl2sql.context import NL2SQLContext
15
+
16
+ logger = get_logger("generator")
17
+
18
+
19
+ class SqlVisitor:
20
+ """Visits the PlanModel AST and converts it to sqlglot expressions.
21
+
22
+ This visitor traverses the deterministic AST (Expr) produced by the Planner
23
+ and builds a corresponding sqlglot expression tree, which can then be
24
+ transpiled to the target dialect.
25
+ """
26
+
27
+ def visit(self, expr: Expr) -> exp.Expression:
28
+ """Dispatches the visit to the appropriate method based on expression kind.
29
+
30
+ Args:
31
+ expr (Expr): The expression node to visit.
32
+
33
+ Returns:
34
+ exp.Expression: The corresponding sqlglot expression.
35
+
36
+ Raises:
37
+ ValueError: If the expression kind is unknown.
38
+ """
39
+ if expr.kind == "literal":
40
+ return self._visit_literal(expr)
41
+ elif expr.kind == "column":
42
+ return self._visit_column(expr)
43
+ elif expr.kind == "func":
44
+ return self._visit_func(expr)
45
+ elif expr.kind == "binary":
46
+ return self._visit_binary(expr)
47
+ elif expr.kind == "unary":
48
+ return self._visit_unary(expr)
49
+ elif expr.kind == "case":
50
+ return self._visit_case(expr)
51
+ raise ValueError(f"Unknown expression kind: {expr.kind}")
52
+
53
+ def _visit_literal(self, expr: Expr) -> exp.Expression:
54
+ """Converts a literal expression to sqlglot."""
55
+ val = expr.value
56
+ if val is None:
57
+ return exp.Null()
58
+ if isinstance(val, bool):
59
+ return exp.Boolean(this="TRUE" if val else "FALSE")
60
+ if isinstance(val, (int, float)):
61
+ return exp.Literal.number(str(val))
62
+ return exp.Literal.string(str(val))
63
+
64
+ def _visit_column(self, expr: Expr) -> exp.Column:
65
+ """Converts a column expression to sqlglot."""
66
+ ident = exp.Identifier(this=expr.column_name, quoted=False)
67
+ if expr.alias:
68
+ return exp.Column(this=ident, table=exp.Identifier(this=expr.alias, quoted=False))
69
+ return exp.Column(this=ident)
70
+
71
+ def _visit_func(self, expr: Expr) -> exp.Expression:
72
+ """Converts a function call expression to sqlglot."""
73
+ if str(expr.func_name).upper() in ("TUPLE", "LIST"):
74
+ return exp.Tuple(expressions=[self.visit(arg) for arg in expr.args])
75
+
76
+ return exp.Anonymous(
77
+ this=expr.func_name,
78
+ expressions=[self.visit(arg) for arg in expr.args]
79
+ )
80
+
81
+ def _visit_binary(self, expr: Expr) -> exp.Expression:
82
+ """Converts a binary operation expression to sqlglot."""
83
+ if not expr.left or not expr.right:
84
+ raise ValueError("Binary expression missing operands")
85
+
86
+ left = self.visit(expr.left)
87
+ right = self.visit(expr.right)
88
+ op = str(expr.op).upper()
89
+
90
+ if op == "=": return exp.EQ(this=left, expression=right)
91
+ if op == "!=": return exp.NEQ(this=left, expression=right)
92
+ if op == ">": return exp.GT(this=left, expression=right)
93
+ if op == "<": return exp.LT(this=left, expression=right)
94
+ if op == ">=": return exp.GTE(this=left, expression=right)
95
+ if op == "<=": return exp.LTE(this=left, expression=right)
96
+ if op == "AND":
97
+ if isinstance(left, exp.Or): left = exp.Paren(this=left)
98
+ if isinstance(right, exp.Or): right = exp.Paren(this=right)
99
+ return exp.And(this=left, expression=right)
100
+ if op == "OR": return exp.Or(this=left, expression=right)
101
+ if op == "LIKE": return exp.Like(this=left, expression=right)
102
+ if op == "IN":
103
+ values = right.expressions if isinstance(right, exp.Tuple) else [right]
104
+ return exp.In(this=left, expressions=values)
105
+
106
+ return exp.Anonymous(this=op, expressions=[left, right])
107
+
108
+ def _visit_unary(self, expr: Expr) -> exp.Expression:
109
+ """Converts a unary operation expression to sqlglot."""
110
+ target = expr.expr
111
+ if not target:
112
+ raise ValueError("Unary expression missing target")
113
+
114
+ node = self.visit(target)
115
+ op = str(expr.op).upper()
116
+
117
+ if op == "NOT":
118
+ return exp.Not(this=node)
119
+ if op == "-":
120
+ return exp.Neg(this=node)
121
+
122
+ return exp.Paren(this=node)
123
+
124
+ def _visit_case(self, expr: Expr) -> exp.Case:
125
+ """Converts a CASE expression to sqlglot."""
126
+ when_list = []
127
+
128
+ if expr.whens:
129
+ for w in expr.whens:
130
+ when_list.append(
131
+ exp.When(
132
+ this=self.visit(w.condition),
133
+ then=self.visit(w.result)
134
+ )
135
+ )
136
+
137
+ default = self.visit(expr.else_expr) if expr.else_expr else None
138
+ return exp.Case(ifs=when_list, default=default)
139
+
140
+
141
+ class GeneratorNode:
142
+ """Generates the final SQL string from the PlanModel using sqlglot.
143
+
144
+ Attributes:
145
+ registry (DatasourceRegistry): The registry to fetch datasource dialects.
146
+ """
147
+
148
+ def __init__(self, ctx: NL2SQLContext):
149
+ """Initializes the GeneratorNode.
150
+
151
+ Args:
152
+ registry (DatasourceRegistry): The registry of datasources.
153
+ """
154
+ self.node_name = self.__class__.__name__.lower().replace('node', '')
155
+ self.ds_registry = ctx.ds_registry
156
+
157
+ def __call__(self, state: SubgraphExecutionState) -> Dict[str, Any]:
158
+ """Executes the generator node.
159
+
160
+ Converts the PlanModel into a SQL string tailored for the target
161
+ datasource's dialect.
162
+
163
+ Args:
164
+ state (SubgraphExecutionState): The current state containing the execution plan.
165
+
166
+ Returns:
167
+ Dict[str, Any]: A dictionary with the generated 'sql_draft' and reasoning.
168
+ """
169
+ try:
170
+ datasource_id = state.sub_query.datasource_id if state.sub_query else None
171
+ if not datasource_id:
172
+ raise ValueError("No datasource selected")
173
+ plan = state.ast_planner_response.plan if state.ast_planner_response else None
174
+ if not plan:
175
+ raise ValueError("No plan provided")
176
+
177
+ adapter = self.ds_registry.get_adapter(datasource_id)
178
+ dialect = adapter.get_dialect()
179
+
180
+ row_limit = adapter.row_limit or 1000
181
+ limit = min(int(plan.limit or row_limit), row_limit)
182
+
183
+ sql = self._generate_sql(plan, limit, dialect)
184
+
185
+ response = GeneratorResponse(
186
+ sql_draft=sql,
187
+ reasoning=[{"node": self.node_name, "content": ["Generated SQL", sql]}],
188
+ )
189
+ return {
190
+ "generator_response": response,
191
+ "reasoning": response.reasoning,
192
+ }
193
+
194
+ except Exception as exc:
195
+ logger.exception(exc)
196
+ error = PipelineError(
197
+ node=self.node_name,
198
+ message=str(exc),
199
+ error_code=ErrorCode.SQL_GEN_FAILED,
200
+ severity=ErrorSeverity.ERROR,
201
+ stack_trace=str(exc),
202
+ )
203
+ return {
204
+ "generator_response": GeneratorResponse(errors=[error]),
205
+ "errors": [error],
206
+ }
207
+
208
+ def _generate_sql(self, plan: PlanModel, limit: int, dialect: str) -> str:
209
+ """Internal helper to build and optimize the SQL query."""
210
+ visitor = SqlVisitor()
211
+ query = exp.select()
212
+
213
+ for s in sorted(plan.select_items, key=lambda x: x.ordinal):
214
+ e = visitor.visit(s.expr)
215
+ if s.alias:
216
+ e = exp.Alias(this=e, alias=exp.Identifier(this=s.alias, quoted=False))
217
+ query = query.select(e)
218
+
219
+ tables = sorted(plan.tables, key=lambda x: x.ordinal)
220
+ if not tables:
221
+ raise ValueError("Plan has no tables")
222
+
223
+ primary = tables[0]
224
+ tbl = exp.Table(this=exp.Identifier(this=primary.name, quoted=False))
225
+
226
+ if primary.schema_name:
227
+ tbl.set("db", exp.Identifier(this=primary.schema_name, quoted=False))
228
+ if primary.database:
229
+ tbl.set("catalog", exp.Identifier(this=primary.database, quoted=False))
230
+ if primary.alias:
231
+ tbl.set("alias", exp.TableAlias(this=exp.Identifier(this=primary.alias, quoted=False)))
232
+
233
+ query = query.from_(tbl)
234
+
235
+ alias_map = {t.alias: t.name for t in tables}
236
+
237
+ for j in sorted(plan.joins, key=lambda x: x.ordinal):
238
+ name = alias_map.get(j.right_alias)
239
+ if not name:
240
+ raise ValueError(f"Join references unknown alias {j.right_alias}")
241
+
242
+ right = exp.Table(this=exp.Identifier(this=name, quoted=False))
243
+ right.set("alias", exp.TableAlias(this=exp.Identifier(this=j.right_alias, quoted=False)))
244
+
245
+ condition = visitor.visit(j.condition)
246
+
247
+ query = query.join(
248
+ right,
249
+ on=condition,
250
+ join_type=j.join_type
251
+ )
252
+
253
+ if plan.where:
254
+ query = query.where(visitor.visit(plan.where))
255
+
256
+ for g in sorted(plan.group_by, key=lambda x: x.ordinal):
257
+ query = query.group_by(visitor.visit(g.expr))
258
+
259
+ if plan.having:
260
+ query = query.having(visitor.visit(plan.having))
261
+
262
+ for o in sorted(plan.order_by, key=lambda x: x.ordinal):
263
+ query = query.order_by(visitor.visit(o.expr), desc=(o.direction == "desc"))
264
+
265
+ query = query.limit(limit)
266
+
267
+ return query.sql(dialect=dialect)
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from nl2sql.common.errors import PipelineError
8
+
9
+
10
+ class GeneratorResponse(BaseModel):
11
+ sql_draft: Optional[str] = None
12
+ errors: List[PipelineError] = Field(default_factory=list)
13
+ reasoning: List[Dict[str, Any]] = Field(default_factory=list)
@@ -0,0 +1,4 @@
1
+ from .node import GlobalPlannerNode
2
+ from .schemas import GlobalPlannerResponse
3
+
4
+ __all__ = ["GlobalPlannerNode", "GlobalPlannerResponse"]
@@ -0,0 +1,186 @@
1
+ from __future__ import annotations
2
+ from typing import Dict, Any, TYPE_CHECKING, List
3
+
4
+ if TYPE_CHECKING:
5
+ from nl2sql.pipeline.state import GraphState
6
+
7
+ from .schemas import (
8
+ RelationSchema,
9
+ ColumnSpec,
10
+ ExecutionDAG,
11
+ LogicalNode,
12
+ LogicalEdge,
13
+ )
14
+ from nl2sql.common.errors import PipelineError, ErrorSeverity, ErrorCode
15
+ from nl2sql.common.logger import get_logger
16
+ from nl2sql.context import NL2SQLContext
17
+ from .schemas import GlobalPlannerResponse
18
+
19
+ logger = get_logger("global_planner")
20
+
21
+
22
+ class GlobalPlannerNode:
23
+ """Generates a deterministic execution plan for aggregating sub-query results.
24
+
25
+ This node runs AFTER the Decomposer and BEFORE the SQL Agents.
26
+ It does not execute SQL, but produces a blueprint (ResultPlan) for the Aggregator.
27
+ """
28
+
29
+ def __init__(self, ctx: NL2SQLContext):
30
+ self.node_name = self.__class__.__name__.lower().replace("node", "")
31
+
32
+ def __call__(self, state: GraphState) -> Dict[str, Any]:
33
+ """Executes the planning logic."""
34
+ decomposer_response = state.decomposer_response
35
+ sub_queries = decomposer_response.sub_queries if decomposer_response else []
36
+ combine_groups = decomposer_response.combine_groups if decomposer_response else []
37
+ post_combine_ops = decomposer_response.post_combine_ops if decomposer_response else []
38
+
39
+ try:
40
+ nodes: List[LogicalNode] = []
41
+ edges: List[LogicalEdge] = []
42
+ node_index: Dict[str, LogicalNode] = {}
43
+
44
+ for sq in sub_queries:
45
+ schema = RelationSchema(
46
+ columns=[ColumnSpec(name=c.name, dtype=c.dtype) for c in sq.expected_schema]
47
+ )
48
+ node = LogicalNode(
49
+ node_id=sq.id,
50
+ kind="scan",
51
+ inputs=[],
52
+ output_schema=schema,
53
+ attributes={
54
+ "datasource_id": sq.datasource_id,
55
+ "intent": sq.intent,
56
+ "metrics": [m.model_dump() for m in sq.metrics],
57
+ "filters": [f.model_dump() for f in sq.filters],
58
+ "group_by": [g.model_dump() for g in sq.group_by],
59
+ "expected_schema": [c.model_dump() for c in sq.expected_schema],
60
+ },
61
+ )
62
+ nodes.append(node)
63
+ node_index[node.node_id] = node
64
+
65
+ combine_node_ids: Dict[str, str] = {}
66
+ for cg in combine_groups:
67
+ combine_id = f"combine_{cg.group_id}"
68
+ combine_node_ids[cg.group_id] = combine_id
69
+ input_ids = [i.subquery_id for i in cg.inputs]
70
+ output_schema = (
71
+ node_index[input_ids[0]].output_schema
72
+ if input_ids and input_ids[0] in node_index
73
+ else RelationSchema(columns=[])
74
+ )
75
+ node = LogicalNode(
76
+ node_id=combine_id,
77
+ kind="combine",
78
+ inputs=input_ids,
79
+ output_schema=output_schema,
80
+ attributes={
81
+ "operation": cg.operation,
82
+ "group_id": cg.group_id,
83
+ "inputs": [i.model_dump() for i in cg.inputs],
84
+ "join_keys": [jk.model_dump() for jk in cg.join_keys],
85
+ },
86
+ )
87
+ nodes.append(node)
88
+ node_index[node.node_id] = node
89
+
90
+ for inp in cg.inputs:
91
+ edges.append(
92
+ LogicalEdge(
93
+ edge_id=f"edge_{inp.subquery_id}_{combine_id}",
94
+ from_id=inp.subquery_id,
95
+ to_id=combine_id,
96
+ role=inp.role,
97
+ )
98
+ )
99
+
100
+ for op in post_combine_ops:
101
+ target_combine_id = combine_node_ids.get(op.target_group_id)
102
+ if not target_combine_id:
103
+ raise ValueError(f"PostCombineOp references unknown combine group: {op.target_group_id}")
104
+ output_schema = RelationSchema(
105
+ columns=[ColumnSpec(name=c.name, dtype=c.dtype) for c in op.expected_schema]
106
+ )
107
+ kind_map = {
108
+ "filter": "post_filter",
109
+ "aggregate": "post_aggregate",
110
+ "project": "post_project",
111
+ "sort": "post_sort",
112
+ "limit": "post_limit",
113
+ }
114
+ node = LogicalNode(
115
+ node_id=op.op_id,
116
+ kind=kind_map[op.operation],
117
+ inputs=[target_combine_id],
118
+ output_schema=output_schema,
119
+ attributes={
120
+ "target_group_id": op.target_group_id,
121
+ "operation": op.operation,
122
+ "filters": [f.model_dump() for f in op.filters],
123
+ "metrics": [m.model_dump() for m in op.metrics],
124
+ "group_by": [g.model_dump() for g in op.group_by],
125
+ "order_by": [o.model_dump() for o in op.order_by],
126
+ "limit": op.limit,
127
+ "expected_schema": [c.model_dump() for c in op.expected_schema],
128
+ "metadata": op.metadata,
129
+ },
130
+ )
131
+ nodes.append(node)
132
+ node_index[node.node_id] = node
133
+ edges.append(
134
+ LogicalEdge(
135
+ edge_id=f"edge_{target_combine_id}_{op.op_id}",
136
+ from_id=target_combine_id,
137
+ to_id=op.op_id,
138
+ )
139
+ )
140
+
141
+ node_ids = {n.node_id for n in nodes}
142
+ for edge in edges:
143
+ if edge.from_id not in node_ids or edge.to_id not in node_ids:
144
+ raise ValueError(f"Edge references unknown node: {edge}")
145
+
146
+ execution_dag = ExecutionDAG(
147
+ nodes=sorted(nodes, key=lambda n: n.node_id),
148
+ edges=sorted(edges, key=lambda e: (e.from_id, e.to_id, e.role or "")),
149
+ )
150
+
151
+ execution_dag.content_hash = self._hash_execution_dag(execution_dag)
152
+ execution_dag.dag_id = f"dag_{execution_dag.content_hash[:12]}"
153
+
154
+ result = GlobalPlannerResponse(execution_dag=execution_dag)
155
+
156
+ return {
157
+ "global_planner_response": result,
158
+ "reasoning": [{"node": self.node_name, "content": "Built explicit execution DAG."}],
159
+ }
160
+
161
+ except Exception as e:
162
+ logger.error(f"GlobalPlanner failed: {e}")
163
+ return {
164
+ "global_planner_response": GlobalPlannerResponse(execution_dag=None),
165
+ "reasoning": [{"node": self.node_name, "content": f"Planning failed: {e}", "type": "error"}],
166
+ "errors": [
167
+ PipelineError(
168
+ node=self.node_name,
169
+ message=f"Global Plan generation failed: {str(e)}",
170
+ severity=ErrorSeverity.ERROR,
171
+ error_code=ErrorCode.PLANNER_FAILED
172
+ )
173
+ ]
174
+ }
175
+
176
+ def _hash_execution_dag(self, dag: ExecutionDAG) -> str:
177
+ import hashlib
178
+ import json
179
+
180
+ payload = {
181
+ "nodes": [n.model_dump() for n in dag.nodes],
182
+ "edges": [e.model_dump() for e in dag.edges],
183
+ "version": dag.version,
184
+ }
185
+ data = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
186
+ return hashlib.sha256(data.encode("utf-8")).hexdigest()
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+ from typing import List, Literal, Optional, Dict, Any
3
+ from pydantic import BaseModel, Field, model_validator
4
+
5
+
6
+ JsonLiteral = str | int | float | bool | None
7
+
8
+
9
+ class ColumnSpec(BaseModel):
10
+ name: str
11
+ dtype: Optional[str] = None
12
+
13
+ class RelationSchema(BaseModel):
14
+ columns: List[ColumnSpec]
15
+
16
+ @model_validator(mode="after")
17
+ def validate_unique_columns(self):
18
+ names = [c.name for c in self.columns]
19
+ if len(names) != len(set(names)):
20
+ raise ValueError(f"Duplicate columns in schema: {names}")
21
+ return self
22
+
23
+ class LogicalNode(BaseModel):
24
+ node_id: str
25
+ kind: Literal[
26
+ "scan",
27
+ "combine",
28
+ "post_filter",
29
+ "post_aggregate",
30
+ "post_project",
31
+ "post_sort",
32
+ "post_limit",
33
+ ]
34
+ inputs: List[str] = Field(default_factory=list)
35
+ output_schema: RelationSchema
36
+ attributes: Dict[str, Any] = Field(default_factory=dict)
37
+
38
+
39
+ class LogicalEdge(BaseModel):
40
+ edge_id: str
41
+ from_id: str
42
+ to_id: str
43
+ role: Optional[str] = None
44
+
45
+
46
+ class ExecutionDAG(BaseModel):
47
+ dag_id: Optional[str] = None
48
+ content_hash: Optional[str] = None
49
+ nodes: List[LogicalNode]
50
+ edges: List[LogicalEdge]
51
+ layers: List[List[str]] = Field(default_factory=list)
52
+ version: str = "v1"
53
+
54
+ @model_validator(mode="after")
55
+ def populate_layers(self):
56
+ if not self.layers and self.nodes:
57
+ self.layers = self._layered_toposort(self.nodes, self.edges)
58
+ return self
59
+
60
+ @staticmethod
61
+ def _layered_toposort(
62
+ nodes: List[LogicalNode],
63
+ edges: List[LogicalEdge],
64
+ ) -> List[List[str]]:
65
+ node_ids = {n.node_id for n in nodes}
66
+ indegree: Dict[str, int] = {n.node_id: 0 for n in nodes}
67
+ dependents: Dict[str, List[str]] = {n.node_id: [] for n in nodes}
68
+
69
+ for edge in edges:
70
+ if edge.from_id not in node_ids or edge.to_id not in node_ids:
71
+ continue
72
+ indegree[edge.to_id] += 1
73
+ dependents[edge.from_id].append(edge.to_id)
74
+
75
+ layers: List[List[str]] = []
76
+ ready = sorted([n_id for n_id, deg in indegree.items() if deg == 0])
77
+ processed = 0
78
+
79
+ while ready:
80
+ current_layer = ready
81
+ layers.append(current_layer)
82
+ processed += len(current_layer)
83
+
84
+ next_ready: List[str] = []
85
+ for node_id in current_layer:
86
+ for child in sorted(dependents.get(node_id, [])):
87
+ indegree[child] -= 1
88
+ if indegree[child] == 0:
89
+ next_ready.append(child)
90
+ ready = sorted(set(next_ready))
91
+
92
+ if processed != len(nodes):
93
+ raise ValueError("ExecutionDAG contains a cycle; layered topo sort failed.")
94
+
95
+ return layers
96
+
97
+
98
+ class GlobalPlannerResponse(BaseModel):
99
+ execution_dag: ExecutionDAG
100
+
101
+
@@ -0,0 +1,4 @@
1
+ from .node import RefinerNode
2
+ from .schemas import RefinerResponse
3
+
4
+ __all__ = ["RefinerNode", "RefinerResponse"]