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,422 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import List, Optional, Dict, Any
4
+
5
+ from langchain_chroma import Chroma
6
+ from langchain_core.documents import Document
7
+ from langchain_core.embeddings import Embeddings
8
+
9
+ from nl2sql.indexing.embeddings import (
10
+ PROVIDER_BY_DIMENSION,
11
+ EmbeddingService,
12
+ describe_embeddings,
13
+ )
14
+ from nl2sql.common.exceptions import NL2SQLError
15
+ from nl2sql.common.logger import get_logger
16
+ from .models import BaseChunk
17
+
18
+ logger = get_logger(__name__)
19
+
20
+
21
+ class EmbeddingDimensionMismatchError(NL2SQLError):
22
+ """Raised when a persisted collection was built with a different embedder."""
23
+
24
+
25
+ def check_embedding_dimension_compatibility(
26
+ persisted_dimension: Optional[int],
27
+ embeddings: Embeddings,
28
+ collection_name: str,
29
+ ) -> None:
30
+ """
31
+ Verifies that a persisted collection can be queried with the current embedder.
32
+
33
+ Vectors from different embedding providers have different dimensionality
34
+ (OpenAI ``text-embedding-3-small`` is 1536, the local model is 384), so an
35
+ index built with one provider cannot be read with the other.
36
+
37
+ Args:
38
+ persisted_dimension: Dimensionality of the persisted vectors, or None
39
+ when the collection is empty or the dimension cannot be determined.
40
+ embeddings: Embedding implementation configured for this process.
41
+ collection_name: Name of the Chroma collection.
42
+
43
+ Raises:
44
+ EmbeddingDimensionMismatchError: If the dimensions disagree.
45
+ """
46
+ provider, model, expected_dimension = describe_embeddings(embeddings)
47
+
48
+ if persisted_dimension is None or expected_dimension is None:
49
+ return
50
+ if persisted_dimension == expected_dimension:
51
+ return
52
+
53
+ built_with = PROVIDER_BY_DIMENSION.get(persisted_dimension)
54
+ origin = (
55
+ f"the '{built_with}' embedding provider"
56
+ if built_with
57
+ else "a different embedding provider"
58
+ )
59
+
60
+ raise EmbeddingDimensionMismatchError(
61
+ f"Vector store collection '{collection_name}' was indexed with {origin} "
62
+ f"({persisted_dimension}-dimensional vectors), but the configured provider "
63
+ f"is '{provider}' ({model}, {expected_dimension}-dimensional vectors). "
64
+ "A vector index cannot be shared across embedding providers: re-index with "
65
+ "'nl2sql index' after changing EMBEDDING_PROVIDER, or point VECTOR_STORE at "
66
+ "a separate directory per provider."
67
+ )
68
+
69
+
70
+ class VectorStore:
71
+ """
72
+ Vector store for NL2SQL orchestration.
73
+
74
+ This store indexes schema chunks and provides staged retrieval
75
+ for datasource routing, schema grounding, and planning context.
76
+ """
77
+
78
+ def __init__(
79
+ self,
80
+ collection_name: str,
81
+ persist_directory: str,
82
+ embeddings: Optional[Embeddings] = None,
83
+ ):
84
+ """
85
+ Initializes the vector store.
86
+
87
+ Args:
88
+ collection_name: Name of the Chroma collection.
89
+ persist_directory: Directory used for persistence.
90
+ embeddings: Embedding implementation to use.
91
+ """
92
+ self.collection_name = collection_name
93
+ self.embeddings = embeddings or EmbeddingService.get_embeddings()
94
+ self.persist_directory = persist_directory
95
+ self._initialize_vector_store()
96
+
97
+ def _initialize_vector_store(self) -> None:
98
+ """
99
+ Initializes the underlying Chroma vector store.
100
+ """
101
+ self.vectorstore = Chroma(
102
+ collection_name=self.collection_name,
103
+ embedding_function=self.embeddings,
104
+ persist_directory=self.persist_directory,
105
+ )
106
+ self._dimension_checked = False
107
+
108
+ def _verify_embedding_dimensions(self) -> None:
109
+ """
110
+ Checks the persisted vectors against the configured embedder once per
111
+ store instance.
112
+
113
+ The check runs on the read path only: indexing clears the collection
114
+ before writing, so a provider switch is fixed by re-running
115
+ ``nl2sql index`` rather than blocked by it.
116
+ """
117
+ if self._dimension_checked:
118
+ return
119
+ check_embedding_dimension_compatibility(
120
+ self._persisted_dimension(),
121
+ self.embeddings,
122
+ self.collection_name,
123
+ )
124
+ self._dimension_checked = True
125
+
126
+ def _persisted_dimension(self) -> Optional[int]:
127
+ """
128
+ Reads the dimensionality of the vectors already persisted in the collection.
129
+
130
+ Returns:
131
+ Vector dimensionality, or None when the collection is empty or the
132
+ dimension cannot be determined.
133
+ """
134
+ try:
135
+ stored = self.vectorstore._collection.peek(limit=1).get("embeddings")
136
+ if stored is None or len(stored) == 0:
137
+ return None
138
+ return len(stored[0])
139
+ except Exception as exc:
140
+ logger.debug(f"Could not determine persisted embedding dimension: {exc}")
141
+ return None
142
+
143
+ def initialize_if_not_exists(self) -> None:
144
+ """
145
+ Initializes the vector store if it does not exist.
146
+ """
147
+ try:
148
+ _ = self.vectorstore._collection.count()
149
+ except Exception:
150
+ logger.info("Vector store not found, initializing new store.")
151
+ self._initialize_vector_store()
152
+
153
+ self._verify_embedding_dimensions()
154
+
155
+ def is_empty(self) -> bool:
156
+ """
157
+ Checks whether the vector store is empty.
158
+
159
+ Returns:
160
+ True if the store contains no documents.
161
+ """
162
+ try:
163
+ return self.vectorstore._collection.count() == 0
164
+ except Exception as exc:
165
+ logger.error(f"Failed to check vector store state: {exc}")
166
+ return True
167
+
168
+ def clear(self) -> None:
169
+ """
170
+ Deletes the entire vector collection.
171
+ """
172
+ try:
173
+ self.vectorstore.delete_collection()
174
+ self._initialize_vector_store()
175
+ except Exception as exc:
176
+ logger.error(f"Failed to clear vector store: {exc}")
177
+
178
+ def delete_documents(self, filter: Dict[str, Any]) -> None:
179
+ """
180
+ Deletes documents matching a metadata filter.
181
+
182
+ Args:
183
+ filter: Metadata filter used for deletion.
184
+ """
185
+ try:
186
+ where = (
187
+ {"$and": [{k: v} for k, v in filter.items()]}
188
+ if len(filter) > 1
189
+ else filter
190
+ )
191
+ self.vectorstore._collection.delete(where=where)
192
+ except Exception as exc:
193
+ logger.error(f"Failed to delete documents: {exc}")
194
+
195
+ def refresh_schema_chunks(
196
+ self,
197
+ datasource_id: str,
198
+ schema_version: str,
199
+ chunks: List[BaseChunk],
200
+ evicted_versions: List[str],
201
+ ) -> Dict[str, int]:
202
+ """
203
+ Indexes schema chunks for a datasource and evicts old versions.
204
+
205
+ Args:
206
+ datasource_id: Datasource identifier.
207
+ schema_version: Active schema version.
208
+ chunks: Schema chunks to index.
209
+ evicted_versions: Schema versions to remove.
210
+
211
+ Returns:
212
+ Indexing statistics by chunk type.
213
+ """
214
+ self._delete_evicted_versions(datasource_id, evicted_versions)
215
+
216
+ self.delete_documents(
217
+ {
218
+ "datasource_id": datasource_id,
219
+ "schema_version": schema_version,
220
+ }
221
+ )
222
+
223
+ documents = self._prepare_chunk_documents(chunks)
224
+
225
+ if documents:
226
+ self.vectorstore.add_documents(documents)
227
+
228
+ stats: Dict[str, Any] = {}
229
+ stats["datasource_id"] = datasource_id
230
+ stats["schema_version"] = schema_version
231
+ for chunk in chunks:
232
+ stats[chunk.type] = stats.get(chunk.type, 0) + 1
233
+
234
+ return stats
235
+
236
+ def _delete_evicted_versions(
237
+ self,
238
+ datasource_id: str,
239
+ evicted_versions: List[str],
240
+ ) -> None:
241
+ """
242
+ Deletes all documents belonging to evicted schema versions.
243
+
244
+ Args:
245
+ datasource_id: Datasource identifier.
246
+ evicted_versions: Schema versions to remove.
247
+ """
248
+ for version in evicted_versions:
249
+ self.delete_documents(
250
+ {
251
+ "datasource_id": datasource_id,
252
+ "schema_version": version,
253
+ }
254
+ )
255
+
256
+ def _prepare_chunk_documents(
257
+ self,
258
+ chunks: List[BaseChunk],
259
+ ) -> List[Document]:
260
+ """
261
+ Converts schema chunks into vector documents.
262
+
263
+ Args:
264
+ chunks: Schema chunks to convert.
265
+
266
+ Returns:
267
+ List of vector documents.
268
+ """
269
+ return [
270
+ Document(
271
+ page_content=chunk.get_page_content(),
272
+ metadata=chunk.get_metadata(),
273
+ )
274
+ for chunk in chunks
275
+ ]
276
+
277
+ def retrieve_datasource_candidates(
278
+ self,
279
+ query: str,
280
+ k: int = 3,
281
+ ) -> List[Document]:
282
+ """
283
+ Retrieves candidate datasources for a user query.
284
+
285
+ Args:
286
+ query: User query.
287
+ k: Number of datasource candidates to retrieve.
288
+
289
+ Returns:
290
+ Retrieved datasource documents.
291
+ """
292
+ self.initialize_if_not_exists()
293
+ from nl2sql.common.resilience import VECTOR_BREAKER
294
+
295
+ @VECTOR_BREAKER
296
+ def _execute():
297
+ return self.vectorstore.max_marginal_relevance_search(
298
+ query,
299
+ k=k,
300
+ fetch_k=k * 4,
301
+ lambda_mult=0.7,
302
+ filter={"type": "schema.datasource"},
303
+ )
304
+
305
+ return _execute()
306
+
307
+ def retrieve_schema_context(
308
+ self,
309
+ query: str,
310
+ datasource_id: str,
311
+ k: int = 8,
312
+ ) -> List[Document]:
313
+ """
314
+ Retrieves schema-level context for a datasource.
315
+
316
+ Args:
317
+ query: User query.
318
+ datasource_id: Selected datasource identifier.
319
+ k: Number of schema documents to retrieve.
320
+
321
+ Returns:
322
+ Retrieved schema documents.
323
+ """
324
+ self.initialize_if_not_exists()
325
+ from nl2sql.common.resilience import VECTOR_BREAKER
326
+
327
+
328
+ @VECTOR_BREAKER
329
+ def _execute():
330
+ return self.vectorstore.max_marginal_relevance_search(
331
+ query,
332
+ k=k,
333
+ fetch_k=k * 4,
334
+ lambda_mult=0.7,
335
+ filter={
336
+ "$and": [
337
+ {"datasource_id": datasource_id},
338
+ {"type": {"$in": ["schema.table", "schema.metric"]}},
339
+ ]
340
+ },
341
+ )
342
+
343
+ return _execute()
344
+
345
+ def retrieve_column_candidates(
346
+ self,
347
+ query: str,
348
+ datasource_id: str,
349
+ k: int = 8,
350
+ ) -> List[Document]:
351
+ """
352
+ Retrieves candidate column documents for a datasource.
353
+
354
+ Args:
355
+ query: User query.
356
+ datasource_id: Selected datasource identifier.
357
+ k: Number of column documents to retrieve.
358
+
359
+ Returns:
360
+ Retrieved column documents.
361
+ """
362
+ from nl2sql.common.resilience import VECTOR_BREAKER
363
+
364
+ self.initialize_if_not_exists()
365
+
366
+ @VECTOR_BREAKER
367
+ def _execute():
368
+ return self.vectorstore.max_marginal_relevance_search(
369
+ query,
370
+ k=k,
371
+ fetch_k=k * 4,
372
+ lambda_mult=0.7,
373
+ filter={
374
+ "$and": [
375
+ {"datasource_id": datasource_id},
376
+ {"type": "schema.column"},
377
+ ]
378
+ },
379
+ )
380
+
381
+ return _execute()
382
+
383
+ def retrieve_planning_context(
384
+ self,
385
+ query: str,
386
+ datasource_id: str,
387
+ tables: List[str],
388
+ k: int = 12,
389
+ ) -> List[Document]:
390
+ """
391
+ Retrieves planning-level context for selected tables.
392
+
393
+ Args:
394
+ query: User query.
395
+ datasource_id: Selected datasource identifier.
396
+ tables: Fully qualified table names.
397
+ k: Number of planning documents to retrieve.
398
+
399
+ Returns:
400
+ Retrieved planning documents.
401
+ """
402
+ from nl2sql.common.resilience import VECTOR_BREAKER
403
+
404
+ self.initialize_if_not_exists()
405
+
406
+ @VECTOR_BREAKER
407
+ def _execute():
408
+ return self.vectorstore.max_marginal_relevance_search(
409
+ query,
410
+ k=k,
411
+ fetch_k=k * 4,
412
+ lambda_mult=0.7,
413
+ filter={
414
+ "$and": [
415
+ {"datasource_id": datasource_id},
416
+ {"type": {"$in": ["schema.column", "schema.relationship"]}},
417
+ {"table": {"$in": tables}},
418
+ ]
419
+ },
420
+ )
421
+
422
+ return _execute()
nl2sql/llm/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+
2
+ from .registry import LLMRegistry
3
+ from .models import AgentConfig
4
+
5
+ __all__ = [
6
+ "LLMRegistry",
7
+ "AgentConfig",
8
+ ]
nl2sql/llm/models.py ADDED
@@ -0,0 +1,10 @@
1
+ """Re-export of the canonical ``AgentConfig``.
2
+
3
+ The model lives in :mod:`nl2sql.configs.llm`, which owns the on-disk file
4
+ schemas. This module keeps the ``nl2sql.llm.models`` import path working for
5
+ ``LLMRegistry`` and existing callers.
6
+ """
7
+
8
+ from nl2sql.configs.llm import AgentConfig
9
+
10
+ __all__ = ["AgentConfig"]
nl2sql/llm/registry.py ADDED
@@ -0,0 +1,214 @@
1
+ import os
2
+ from threading import RLock
3
+ from typing import Any, Dict, NamedTuple, Optional
4
+
5
+ from langchain_openai import ChatOpenAI
6
+
7
+ from nl2sql.secrets import SecretManager
8
+ from .models import AgentConfig
9
+
10
+
11
+ class ProviderPreset(NamedTuple):
12
+ """Endpoint and credential defaults for one OpenAI-compatible provider.
13
+
14
+ Attributes:
15
+ base_url: Endpoint the provider is reached on, or None to let the
16
+ OpenAI client resolve its own default.
17
+ api_key_env: Environment variable named in errors and used as the
18
+ last-resort source of the key.
19
+ api_key_placeholder: Stand-in key for providers that authenticate
20
+ nothing. Non-None means the provider needs no real credential.
21
+ """
22
+
23
+ base_url: Optional[str]
24
+ api_key_env: Optional[str]
25
+ api_key_placeholder: Optional[str] = None
26
+
27
+
28
+ # OpenAI, OpenRouter and Ollama all speak the OpenAI wire protocol, so a single
29
+ # ChatOpenAI client serves all three; only the endpoint differs. A
30
+ # config-supplied ``base_url`` overrides the preset, which is what lets the same
31
+ # path serve vLLM, LiteLLM or any other OpenAI-compatible endpoint.
32
+ #
33
+ # Ollama needs no credential, but ChatOpenAI refuses to construct without an
34
+ # ``api_key`` (``openai.OpenAIError: Missing credentials``), so its preset
35
+ # supplies a placeholder the local daemon ignores.
36
+ PROVIDER_PRESETS: Dict[str, ProviderPreset] = {
37
+ "openai": ProviderPreset(
38
+ base_url=None,
39
+ api_key_env="OPENAI_API_KEY",
40
+ ),
41
+ "openrouter": ProviderPreset(
42
+ base_url="https://openrouter.ai/api/v1",
43
+ api_key_env="OPENROUTER_API_KEY",
44
+ ),
45
+ "ollama": ProviderPreset(
46
+ base_url="http://localhost:11434/v1",
47
+ api_key_env=None,
48
+ api_key_placeholder="ollama",
49
+ ),
50
+ }
51
+
52
+
53
+ class LLMRegistry:
54
+
55
+ def __init__(self, secret_manager: SecretManager):
56
+ self.secret_manager = secret_manager
57
+ self.llms = {}
58
+ self._configs: Dict[str, AgentConfig] = {}
59
+ self._lock = RLock()
60
+
61
+ def register_llms(self, config: Dict[str, AgentConfig]):
62
+ for agent in config.values():
63
+ self.register_llm(agent)
64
+
65
+ def register_llm(self, agent: AgentConfig):
66
+ """Validates an agent's configuration and records it for later use.
67
+
68
+ The client itself is built on first ``get_llm`` so that constructing a
69
+ context does not require credentials for every configured agent. What is
70
+ genuinely misconfiguration - an unknown provider, a missing model - still
71
+ fails here rather than mid-query.
72
+
73
+ Args:
74
+ agent: Configuration for one agent.
75
+
76
+ Raises:
77
+ ValueError: If the provider is unknown or the model is empty.
78
+ """
79
+ if agent.provider not in PROVIDER_PRESETS:
80
+ raise ValueError(
81
+ f"Unsupported LLM provider: {agent.provider}. "
82
+ f"Valid providers are: {', '.join(sorted(PROVIDER_PRESETS))}."
83
+ )
84
+ if not agent.model or not agent.model.strip():
85
+ raise ValueError(
86
+ f"LLM agent '{agent.name}' has no model configured. "
87
+ "Set 'model' for it in configs/llm.yaml."
88
+ )
89
+
90
+ with self._lock:
91
+ self._configs[agent.name] = agent
92
+ # A re-registration replaces any client built from the old config.
93
+ self.llms.pop(agent.name, None)
94
+
95
+ def get_llm(self, name: str) -> ChatOpenAI:
96
+ """Returns the client for an agent, building it on first use.
97
+
98
+ Args:
99
+ name: Agent name; falls back to the 'default' agent.
100
+
101
+ Returns:
102
+ ChatOpenAI: The cached client for that agent.
103
+
104
+ Raises:
105
+ ValueError: If neither the named agent nor a 'default' agent is
106
+ registered, or if the provider needs an API key that cannot be
107
+ resolved.
108
+ """
109
+ with self._lock:
110
+ if name in self.llms:
111
+ return self.llms[name]
112
+
113
+ config = self._configs.get(name) or self._configs.get("default")
114
+ if config is None:
115
+ raise ValueError(
116
+ f"No LLM named '{name}' is configured and no 'default' LLM has "
117
+ "been registered. Add it to configs/llm.yaml (under 'agents', or "
118
+ "as the 'default' agent)."
119
+ )
120
+
121
+ if config.name in self.llms:
122
+ return self.llms[config.name]
123
+
124
+ client = self._build_client(config)
125
+ self.llms[config.name] = client
126
+ return client
127
+
128
+ def _build_client(self, agent: AgentConfig) -> ChatOpenAI:
129
+ """Builds the ChatOpenAI client for one agent from its provider preset.
130
+
131
+ Args:
132
+ agent: Validated configuration for the agent.
133
+
134
+ Returns:
135
+ ChatOpenAI: A client pointed at the configured endpoint.
136
+ """
137
+ preset = PROVIDER_PRESETS[agent.provider]
138
+ api_key = self._resolve_api_key(agent, preset)
139
+
140
+ base_url = agent.base_url or preset.base_url
141
+ kwargs = {"base_url": base_url} if base_url else {}
142
+
143
+ return ChatOpenAI(
144
+ model=agent.model,
145
+ api_key=api_key,
146
+ temperature=agent.temperature,
147
+ tags=[agent.name],
148
+ seed=42,
149
+ **kwargs,
150
+ )
151
+
152
+ def _resolve_api_key(self, agent: AgentConfig, preset: ProviderPreset):
153
+ """Resolves the API key for an agent, or explains what is missing.
154
+
155
+ Args:
156
+ agent: Configuration for the agent.
157
+ preset: Preset for the agent's provider.
158
+
159
+ Returns:
160
+ The resolved key, the provider's placeholder for key-free providers,
161
+ or the value of the provider's environment variable.
162
+
163
+ Raises:
164
+ ValueError: If the provider requires a key and none can be found.
165
+ """
166
+ try:
167
+ resolved = self.secret_manager.resolve_object(agent.api_key)
168
+ except ValueError:
169
+ # An unresolvable "${env:...}" reference is the same situation as no
170
+ # key at all, and is reported as such below.
171
+ resolved = None
172
+
173
+ if resolved is not None and resolved.get_secret_value():
174
+ return resolved
175
+
176
+ if preset.api_key_placeholder:
177
+ return preset.api_key_placeholder
178
+
179
+ env_var = self._api_key_env_var(agent, preset)
180
+ from_env = os.environ.get(env_var) if env_var else None
181
+ if from_env:
182
+ return from_env
183
+
184
+ raise ValueError(
185
+ f"LLM agent '{agent.name}' uses provider '{agent.provider}', which "
186
+ f"requires an API key, but none could be resolved. Set {env_var} in "
187
+ "the environment, or give the agent an 'api_key' in configs/llm.yaml."
188
+ )
189
+
190
+ @staticmethod
191
+ def _api_key_env_var(agent: AgentConfig, preset: ProviderPreset) -> Optional[str]:
192
+ """Returns the environment variable the agent's key should come from.
193
+
194
+ A config that already says ``${env:SOME_VAR}`` names its own variable;
195
+ anything else falls back to the provider's conventional one.
196
+ """
197
+ raw = agent.api_key.get_secret_value() if agent.api_key else ""
198
+ if raw and raw.startswith("${env:") and raw.endswith("}"):
199
+ return raw[len("${env:") : -1]
200
+ return preset.api_key_env
201
+
202
+ def get_llm_config(self, name: str) -> Dict[str, Any]:
203
+ with self._lock:
204
+ if name not in self._configs:
205
+ name = "default"
206
+ config = self._configs[name]
207
+ return config.model_dump(exclude={"api_key"})
208
+
209
+ def list_llms(self) -> Dict[str, Dict[str, Any]]:
210
+ with self._lock:
211
+ return {
212
+ name: config.model_dump(exclude={"api_key"})
213
+ for name, config in self._configs.items()
214
+ }
@@ -0,0 +1 @@
1
+ """Core pipeline module containing the graph definition and state management."""