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,316 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict, List, Optional, Tuple
4
+
5
+ from pydantic import BaseModel, Field
6
+ from langchain_core.prompts import ChatPromptTemplate
7
+
8
+ from nl2sql.schema import SchemaSnapshot
9
+ from nl2sql_adapter_sdk.schema import TableMetadata, ColumnMetadata
10
+ from nl2sql.common.logger import get_logger
11
+
12
+ logger = get_logger("indexing_enrichment")
13
+
14
+ # Name of the agent in configs/llm.yaml that enrichment calls.
15
+ ENRICHMENT_LLM_NAME = "indexing_enrichment"
16
+
17
+
18
+ class DatasourceEnrichment(BaseModel):
19
+ description: Optional[str] = None
20
+ domains: List[str] = Field(default_factory=list)
21
+ sample_questions: List[str] = Field(default_factory=list)
22
+ citations: List[str] = Field(default_factory=list)
23
+
24
+
25
+ class TableEnrichment(BaseModel):
26
+ description: Optional[str] = None
27
+ citations: List[str] = Field(default_factory=list)
28
+
29
+
30
+ class ColumnEnrichment(BaseModel):
31
+ description: Optional[str] = None
32
+ synonyms: List[str] = Field(default_factory=list)
33
+ citations: List[str] = Field(default_factory=list)
34
+
35
+
36
+ class SchemaEnrichment(BaseModel):
37
+ datasource: DatasourceEnrichment
38
+ tables: Dict[str, TableEnrichment] = Field(default_factory=dict)
39
+ columns: Dict[str, ColumnEnrichment] = Field(default_factory=dict)
40
+
41
+
42
+ ENRICHMENT_PROMPT = """You are enriching database schema metadata for retrieval.
43
+ Use ONLY the evidence provided. Do not introduce any tables, columns, or facts
44
+ that are not present in evidence. If evidence is insufficient, return empty values.
45
+
46
+ Return a JSON object that matches this schema:
47
+ SchemaEnrichment = {{
48
+ "datasource": {{
49
+ "description": string|null,
50
+ "domains": [string],
51
+ "sample_questions": [string],
52
+ "citations": [string]
53
+ }},
54
+ "tables": {{
55
+ "<table_key>": {{
56
+ "description": string|null,
57
+ "citations": [string]
58
+ }}
59
+ }},
60
+ "columns": {{
61
+ "<table_key>.<column_name>": {{
62
+ "description": string|null,
63
+ "synonyms": [string],
64
+ "citations": [string]
65
+ }}
66
+ }}
67
+ }}
68
+
69
+ Evidence (JSON):
70
+ {evidence_json}
71
+ """
72
+
73
+
74
+ def build_evidence(
75
+ snapshot: SchemaSnapshot,
76
+ datasource_description: Optional[str],
77
+ existing_questions: List[str],
78
+ ) -> Dict[str, object]:
79
+ contract = snapshot.contract
80
+ metadata = snapshot.metadata
81
+
82
+ tables_payload = []
83
+ relationships_payload = []
84
+
85
+ for table_key, table_contract in contract.tables.items():
86
+ table_md = metadata.tables.get(table_key)
87
+ columns_payload = []
88
+ for col_name, col_contract in table_contract.columns.items():
89
+ col_md = table_md.columns.get(col_name) if table_md else None
90
+ stats = col_md.statistics.model_dump() if col_md and col_md.statistics else {}
91
+ columns_payload.append(
92
+ {
93
+ "name": col_name,
94
+ "type": col_contract.data_type,
95
+ "description": col_md.description if col_md else None,
96
+ "statistics": stats,
97
+ "sample_values": stats.get("sample_values", []),
98
+ "pii": bool(col_md.pii) if col_md else False,
99
+ }
100
+ )
101
+
102
+ tables_payload.append(
103
+ {
104
+ "table_key": table_key,
105
+ "description": table_md.description if table_md else None,
106
+ "row_count": table_md.row_count if table_md else None,
107
+ "columns": columns_payload,
108
+ }
109
+ )
110
+
111
+ for fk in table_contract.foreign_keys:
112
+ relationships_payload.append(
113
+ {
114
+ "from_table": table_key,
115
+ "to_table": fk.referred_table.full_name,
116
+ "from_columns": fk.constrained_columns,
117
+ "to_columns": fk.referred_columns,
118
+ }
119
+ )
120
+
121
+ return {
122
+ "datasource_id": contract.datasource_id,
123
+ "engine_type": contract.engine_type,
124
+ "datasource_description": datasource_description or metadata.description or "",
125
+ "domains": metadata.domains or [],
126
+ "existing_questions": existing_questions,
127
+ "tables": tables_payload,
128
+ "relationships": relationships_payload,
129
+ }
130
+
131
+
132
+ def _normalize_text(value: Optional[str]) -> Optional[str]:
133
+ if value is None:
134
+ return None
135
+ cleaned = value.strip()
136
+ return cleaned or None
137
+
138
+
139
+ def _dedupe_list(items: List[str]) -> List[str]:
140
+ seen = set()
141
+ result = []
142
+ for item in items:
143
+ cleaned = item.strip()
144
+ if not cleaned:
145
+ continue
146
+ key = cleaned.lower()
147
+ if key in seen:
148
+ continue
149
+ seen.add(key)
150
+ result.append(cleaned)
151
+ return result
152
+
153
+
154
+ def _question_mentions_schema(question: str, table_names: List[str], column_names: List[str]) -> bool:
155
+ text = question.lower()
156
+ return any(name in text for name in table_names + column_names)
157
+
158
+
159
+ def sanitize_enrichment(
160
+ snapshot: SchemaSnapshot,
161
+ enrichment: SchemaEnrichment,
162
+ max_questions: int = 100,
163
+ ) -> SchemaEnrichment:
164
+ contract = snapshot.contract
165
+ valid_tables = set(contract.tables.keys())
166
+ valid_columns = set()
167
+ table_names = []
168
+ column_names = []
169
+
170
+ for table_key, table_contract in contract.tables.items():
171
+ table_names.append(table_contract.table.table_name.lower())
172
+ table_names.append(table_key.lower())
173
+ for col_name in table_contract.columns.keys():
174
+ valid_columns.add(f"{table_key}.{col_name}")
175
+ column_names.append(col_name.lower())
176
+
177
+ sanitized_tables: Dict[str, TableEnrichment] = {}
178
+ for table_key, table_enrichment in enrichment.tables.items():
179
+ if table_key not in valid_tables:
180
+ continue
181
+ sanitized_tables[table_key] = TableEnrichment(
182
+ description=_normalize_text(table_enrichment.description),
183
+ citations=_dedupe_list(table_enrichment.citations),
184
+ )
185
+
186
+ sanitized_columns: Dict[str, ColumnEnrichment] = {}
187
+ for column_key, column_enrichment in enrichment.columns.items():
188
+ if column_key not in valid_columns:
189
+ continue
190
+ sanitized_columns[column_key] = ColumnEnrichment(
191
+ description=_normalize_text(column_enrichment.description),
192
+ synonyms=_dedupe_list(column_enrichment.synonyms),
193
+ citations=_dedupe_list(column_enrichment.citations),
194
+ )
195
+
196
+ questions = [
197
+ q.strip()
198
+ for q in enrichment.datasource.sample_questions
199
+ if q and q.strip()
200
+ ]
201
+ questions = [
202
+ q for q in questions if _question_mentions_schema(q, table_names, column_names)
203
+ ]
204
+ questions = questions[:max_questions]
205
+
206
+ return SchemaEnrichment(
207
+ datasource=DatasourceEnrichment(
208
+ description=_normalize_text(enrichment.datasource.description),
209
+ domains=_dedupe_list(enrichment.datasource.domains),
210
+ sample_questions=questions,
211
+ citations=_dedupe_list(enrichment.datasource.citations),
212
+ ),
213
+ tables=sanitized_tables,
214
+ columns=sanitized_columns,
215
+ )
216
+
217
+
218
+ def apply_enrichment(
219
+ snapshot: SchemaSnapshot,
220
+ enrichment: SchemaEnrichment,
221
+ ) -> SchemaSnapshot:
222
+ metadata = snapshot.metadata.model_copy(deep=True)
223
+
224
+ if enrichment.datasource.description:
225
+ metadata.description = enrichment.datasource.description
226
+ if enrichment.datasource.domains:
227
+ metadata.domains = enrichment.datasource.domains
228
+
229
+ for table_key, table_contract in snapshot.contract.tables.items():
230
+ table_md = metadata.tables.get(table_key)
231
+ if not table_md:
232
+ table_md = TableMetadata(table=table_contract.table, columns={})
233
+ metadata.tables[table_key] = table_md
234
+
235
+ table_enrichment = enrichment.tables.get(table_key)
236
+ if table_enrichment and table_enrichment.description:
237
+ table_md.description = table_enrichment.description
238
+
239
+ for col_name in table_contract.columns.keys():
240
+ column_md = table_md.columns.get(col_name)
241
+ if not column_md:
242
+ column_md = ColumnMetadata()
243
+ table_md.columns[col_name] = column_md
244
+
245
+ column_key = f"{table_key}.{col_name}"
246
+ column_enrichment = enrichment.columns.get(column_key)
247
+ if not column_enrichment:
248
+ continue
249
+ if column_enrichment.description:
250
+ column_md.description = column_enrichment.description
251
+ if column_enrichment.synonyms:
252
+ column_md.synonyms = column_enrichment.synonyms
253
+
254
+ return snapshot.model_copy(update={"metadata": metadata})
255
+
256
+
257
+ def enrich_schema_snapshot(
258
+ snapshot: SchemaSnapshot,
259
+ llm_registry,
260
+ datasource_description: Optional[str],
261
+ existing_questions: List[str],
262
+ max_questions: int = 100,
263
+ ) -> Tuple[SchemaSnapshot, List[str]]:
264
+ """Adds LLM-written descriptions to a schema snapshot, if an LLM is usable.
265
+
266
+ Enrichment is an embellishment, never a prerequisite: indexing has to work
267
+ without any LLM at all. Every step that can involve the model - resolving
268
+ the client, binding structured output, the call itself - therefore sits
269
+ inside one guard, and any failure returns the inputs untouched so the
270
+ caller indexes an unenriched schema instead of failing.
271
+
272
+ Args:
273
+ snapshot: Schema snapshot to enrich.
274
+ llm_registry: Registry the ``indexing_enrichment`` client is built from.
275
+ datasource_description: Operator-supplied description, if any.
276
+ existing_questions: Example questions already configured.
277
+ max_questions: Upper bound on the merged question list.
278
+
279
+ Returns:
280
+ The enriched snapshot and merged questions, or the originals unchanged
281
+ when enrichment could not run.
282
+ """
283
+ datasource_id = snapshot.contract.datasource_id
284
+
285
+ try:
286
+ llm = llm_registry.get_llm(ENRICHMENT_LLM_NAME)
287
+ evidence = build_evidence(snapshot, datasource_description, existing_questions)
288
+ prompt = ChatPromptTemplate.from_template(ENRICHMENT_PROMPT)
289
+ chain = prompt | llm.with_structured_output(
290
+ SchemaEnrichment, method="function_calling"
291
+ )
292
+ enrichment = chain.invoke({"evidence_json": evidence})
293
+ except ValueError as exc:
294
+ # No usable LLM is configured - typically no API key. Expected on the
295
+ # key-free path, so it is reported without a stack trace.
296
+ logger.info(
297
+ f"Schema enrichment skipped for '{datasource_id}': {exc} "
298
+ "Indexing continues without LLM-written descriptions."
299
+ )
300
+ return snapshot, existing_questions
301
+ except Exception as exc:
302
+ # Anything else is a genuine surprise and stays fully visible, but it
303
+ # still must not take indexing down with it.
304
+ logger.warning(
305
+ f"Schema enrichment failed for '{datasource_id}': {exc}. "
306
+ "Indexing continues without LLM-written descriptions.",
307
+ exc_info=True,
308
+ )
309
+ return snapshot, existing_questions
310
+
311
+ sanitized = sanitize_enrichment(snapshot, enrichment, max_questions=max_questions)
312
+ updated_snapshot = apply_enrichment(snapshot, sanitized)
313
+
314
+ merged_questions = _dedupe_list(existing_questions + sanitized.datasource.sample_questions)
315
+ merged_questions = merged_questions[:max_questions]
316
+ return updated_snapshot, merged_questions
@@ -0,0 +1,209 @@
1
+ from __future__ import annotations
2
+ from typing import List, Dict, Optional, Literal, Any
3
+ from pydantic import BaseModel, Field
4
+ from nl2sql_adapter_sdk.schema import TableRef, ColumnRef
5
+
6
+
7
+ class BaseChunk(BaseModel):
8
+ """
9
+ Base class for all schema chunks.
10
+ Chunk IDs MUST be deterministic and stable across runs.
11
+ """
12
+ id: str
13
+ type: str
14
+
15
+ def get_page_content(self) -> str:
16
+ raise NotImplementedError("Each chunk must implement get_page_content()")
17
+
18
+ def get_metadata(self) -> Dict[str, Any]:
19
+ return {
20
+ "id": self.id,
21
+ "type": self.type,
22
+ }
23
+
24
+
25
+ class DatasourceChunk(BaseChunk):
26
+ type: Literal["schema.datasource"] = Field(
27
+ default="schema.datasource", frozen=True
28
+ )
29
+
30
+ datasource_id: str
31
+ description: str
32
+ domains: Optional[List[str]] = None
33
+ schema_version: str
34
+ examples: Optional[List[str]] = None
35
+
36
+ def get_page_content(self) -> str:
37
+ domains = ", ".join(self.domains) if self.domains else "N/A"
38
+ examples = ", ".join(self.examples) if self.examples else "N/A"
39
+ return (
40
+ f"Datasource: {self.datasource_id}\n"
41
+ f"{self.description}\n"
42
+ f"Domains: {domains}\n"
43
+ f"Examples: {examples}"
44
+ )
45
+
46
+ def get_metadata(self) -> Dict[str, Any]:
47
+ return {
48
+ **super().get_metadata(),
49
+ "datasource_id": self.datasource_id,
50
+ "schema_version": self.schema_version,
51
+ }
52
+
53
+
54
+ class TableChunk(BaseChunk):
55
+ type: Literal["schema.table"] = Field(
56
+ default="schema.table", frozen=True
57
+ )
58
+
59
+ datasource_id: str
60
+ table: TableRef
61
+ description: Optional[str] = None
62
+ primary_key: List[str] = Field(default_factory=list)
63
+ columns: List[str] = Field(default_factory=list)
64
+ foreign_keys: List[str] = Field(
65
+ default_factory=list,
66
+ description="Human-readable FK summaries (retrieval only)"
67
+ )
68
+ row_count: Optional[int] = None
69
+ schema_version: str
70
+
71
+ def get_page_content(self) -> str:
72
+ pk = ", ".join(self.primary_key) if self.primary_key else "None"
73
+ column_list = ", ".join(self.columns) if self.columns else "None"
74
+ return (
75
+ f"Table: {self.table.full_name}\n"
76
+ f"{self.description or ''}\n"
77
+ f"Primary Key: {pk}\n"
78
+ f"Columns: {column_list}"
79
+ )
80
+
81
+ def get_metadata(self) -> Dict[str, Any]:
82
+ return {
83
+ **super().get_metadata(),
84
+ "datasource_id": self.datasource_id,
85
+ "table": self.table.full_name,
86
+ "row_count": self.row_count,
87
+ "schema_version": self.schema_version,
88
+ "description": self.description,
89
+ "primary_key": ','.join(self.primary_key),
90
+ "foreign_keys": ','.join(self.foreign_keys),
91
+ }
92
+
93
+
94
+ class ColumnChunk(BaseChunk):
95
+ type: Literal["schema.column"] = Field(
96
+ default="schema.column", frozen=True
97
+ )
98
+
99
+ datasource_id: str
100
+ column: ColumnRef
101
+ dtype: str
102
+ description: Optional[str] = None
103
+ column_stats: Dict[str, Any] = Field(default_factory=dict)
104
+ synonyms: Optional[List[str]] = None
105
+ pii: bool = False
106
+ schema_version: str
107
+
108
+ def get_page_content(self) -> str:
109
+ stats = f"Stats: {self.column_stats}" if self.column_stats else ""
110
+ synonyms = (
111
+ f"Synonyms: {', '.join(self.synonyms)}"
112
+ if self.synonyms
113
+ else ""
114
+ )
115
+ return (
116
+ f"Table: {self.column.table.full_name}\n"
117
+ f"Column: {self.column.column_name}\n"
118
+ f"Type: {self.dtype}\n"
119
+ f"{self.description or ''}\n"
120
+ f"{stats}\n"
121
+ f"{synonyms}"
122
+ )
123
+
124
+ def get_metadata(self) -> Dict[str, Any]:
125
+ return {
126
+ **super().get_metadata(),
127
+ "datasource_id": self.datasource_id,
128
+ "column": self.column.column_name,
129
+ "table": self.column.table.full_name,
130
+ "dtype": self.dtype,
131
+ "pii": self.pii,
132
+ "schema_version": self.schema_version,
133
+ "description": self.description,
134
+ }
135
+
136
+ class RelationshipChunk(BaseChunk):
137
+ type: Literal["schema.relationship"] = Field(
138
+ default="schema.relationship", frozen=True
139
+ )
140
+
141
+ datasource_id: str
142
+ from_table: TableRef
143
+ to_table: TableRef
144
+
145
+ from_columns: Optional[List[str]] = None
146
+ to_columns: Optional[List[str]] = None
147
+
148
+ cardinality: Literal[
149
+ "one-to-one",
150
+ "one-to-many",
151
+ "many-to-one",
152
+ "many-to-many",
153
+ "unknown"
154
+ ] = "unknown"
155
+
156
+ business_meaning: Optional[str] = None
157
+ schema_version: str
158
+
159
+ def get_page_content(self) -> str:
160
+ return (
161
+ f"Relationship between {self.from_table.full_name} "
162
+ f"and {self.to_table.full_name}.\n"
163
+ f"{self.business_meaning or ''}"
164
+ )
165
+
166
+ def get_metadata(self) -> Dict[str, Any]:
167
+ return {
168
+ **super().get_metadata(),
169
+ "datasource_id": self.datasource_id,
170
+ "from_table": self.from_table.full_name,
171
+ "to_table": self.to_table.full_name,
172
+ "table": self.from_table.full_name,
173
+ "from_columns": self.from_columns or [],
174
+ "to_columns": self.to_columns or [],
175
+ "cardinality": self.cardinality,
176
+ "schema_version": self.schema_version,
177
+ }
178
+
179
+
180
+ class MetricChunk(BaseChunk):
181
+ type: Literal["schema.metric"] = Field(
182
+ default="schema.metric", frozen=True
183
+ )
184
+
185
+ datasource_id: str
186
+ name: str
187
+ definition: Optional[str] = None
188
+ grain: Optional[str] = None
189
+ business_meaning: Optional[str] = None
190
+ owner: Optional[str] = None
191
+ version: str = "v1"
192
+ schema_version: str
193
+
194
+ def get_page_content(self) -> str:
195
+ return (
196
+ f"Metric: {self.name}\n"
197
+ f"{self.business_meaning or ''}\n"
198
+ f"Definition: {self.definition or ''}\n"
199
+ f"Grain: {self.grain or 'N/A'}"
200
+ )
201
+
202
+ def get_metadata(self) -> Dict[str, Any]:
203
+ return {
204
+ **super().get_metadata(),
205
+ "datasource_id": self.datasource_id,
206
+ "name": self.name,
207
+ "version": self.version,
208
+ "schema_version": self.schema_version,
209
+ }
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict, TYPE_CHECKING
4
+
5
+ from nl2sql.datasources.protocols import DatasourceAdapterProtocol
6
+ from nl2sql.common.logger import get_logger
7
+ from nl2sql.indexing.chunk_builder import SchemaChunkBuilder
8
+ from nl2sql.indexing.enrichment_service import enrich_schema_snapshot
9
+
10
+ if TYPE_CHECKING:
11
+ from nl2sql.context import NL2SQLContext
12
+
13
+ logger = get_logger("indexing_orchestrator")
14
+
15
+
16
+ class IndexingOrchestrator:
17
+ """
18
+ Orchestrates schema indexing for datasources.
19
+
20
+ This class coordinates schema snapshot retrieval, schema version
21
+ registration, chunk construction, and vector store refresh.
22
+ """
23
+
24
+ def __init__(self, ctx: NL2SQLContext):
25
+ """
26
+ Initializes the indexing orchestrator.
27
+
28
+ Args:
29
+ ctx: Initialized NL2SQLContext.
30
+ """
31
+ self.vector_store = ctx.vector_store
32
+ self.schema_store = ctx.schema_store
33
+ self.config_manager = ctx.config_manager
34
+ self.llm_registry = ctx.llm_registry
35
+
36
+ def clear_store(self) -> None:
37
+ """
38
+ Clears the vector store.
39
+ """
40
+ self.vector_store.clear()
41
+
42
+ def index_datasource(
43
+ self,
44
+ adapter: DatasourceAdapterProtocol,
45
+ ) -> Dict[str, int]:
46
+ """
47
+ Indexes schema chunks for a datasource.
48
+
49
+ Args:
50
+ adapter: SQLAlchemy adapter for the datasource.
51
+
52
+ Returns:
53
+ Indexing statistics by chunk type.
54
+ """
55
+ schema_snapshot = adapter.fetch_schema_snapshot()
56
+
57
+ questions = self.config_manager.get_example_questions(adapter.datasource_id)
58
+ datasource_description = self.config_manager.get_datasource_description(
59
+ adapter.datasource_id
60
+ )
61
+
62
+ # Best-effort: enrich_schema_snapshot owns resolving its own LLM and
63
+ # degrades to the unenriched snapshot when none is usable, so indexing
64
+ # never depends on an API key being present.
65
+ schema_snapshot, questions = enrich_schema_snapshot(
66
+ snapshot=schema_snapshot,
67
+ llm_registry=self.llm_registry,
68
+ datasource_description=datasource_description,
69
+ existing_questions=questions,
70
+ )
71
+
72
+ schema_version, evicted_versions = self.schema_store.register_snapshot(
73
+ schema_snapshot
74
+ )
75
+
76
+ chunk_builder = SchemaChunkBuilder(
77
+ ds_id=adapter.datasource_id,
78
+ schema_snapshot=schema_snapshot,
79
+ schema_version=schema_version,
80
+ questions=questions,
81
+ )
82
+
83
+ chunks = chunk_builder.build()
84
+
85
+ return self.vector_store.refresh_schema_chunks(
86
+ datasource_id=adapter.datasource_id,
87
+ schema_version=schema_version,
88
+ chunks=chunks,
89
+ evicted_versions=evicted_versions,
90
+ )