[KM-560][KM-561] drop catalog LLM enrichment + rename store to data_catalog + add /data-catalog index endpoint
Browse files- Remove CatalogEnricher entirely (cost cut). Planner reads stats +
sample rows + column names directly. Deletes src/catalog/enricher.py,
config/prompts/catalog_enricher.md, and the StructuredPipeline enrich
step. render_source moves to src/catalog/render.py.
- Rename jsonb table catalogs -> data_catalog. Class name unchanged;
table created fresh on next init_db (no rows in prod yet, no
migration needed).
- Add GET /api/v1/data-catalog/{user_id} returning list[CatalogIndexEntry]
(source_id, source_type, name, location_ref, table_count, updated_at).
Wired in main.py. Lightweight summary intended for the catalog
refresher.
- Update ARCHITECTURE.md / REPO_CONTEXT.md / PROGRESS.md: LLM call sites
now three (was four), ingestion flow is introspect -> validate -> upsert.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- ARCHITECTURE.md +4 -7
- PROGRESS.md +48 -7
- REPO_CONTEXT.md +10 -10
- main.py +2 -0
- src/api/v1/data_catalog.py +67 -0
- src/catalog/introspect/base.py +3 -2
- src/catalog/introspect/database.py +2 -2
- src/catalog/introspect/tabular.py +1 -2
- src/catalog/{enricher.py → render.py} +5 -142
- src/config/prompts/catalog_enricher.md +0 -62
- src/db/postgres/init_db.py +1 -1
- src/db/postgres/models.py +4 -1
- src/models/api/catalog.py +18 -5
- src/pipeline/structured_pipeline.py +14 -21
- src/pipeline/triggers.py +9 -6
- src/query/planner/prompt.py +1 -5
- src/security/README.md +1 -1
|
@@ -95,12 +95,11 @@ Each stage is its own module with typed input and typed output. No god classes.
|
|
| 95 |
|
| 96 |
### 4.5 Minimal LLM surface
|
| 97 |
|
| 98 |
-
LLM calls happen in exactly
|
| 99 |
|
| 100 |
1. **`IntentRouter`** — once per user message
|
| 101 |
-
2. **`
|
| 102 |
-
3. **`
|
| 103 |
-
4. **`ChatbotAgent`** — once per answer (formats the response)
|
| 104 |
|
| 105 |
Compiler and executors are pure code. No LLM in the hot path of query construction.
|
| 106 |
|
|
@@ -115,11 +114,9 @@ source upload / DB connect
|
|
| 115 |
↓
|
| 116 |
introspect schema (DB: information_schema; tabular: file headers + sample rows)
|
| 117 |
↓
|
| 118 |
-
CatalogEnricher (1 LLM call per source — generates AI descriptions)
|
| 119 |
-
↓
|
| 120 |
validate (Pydantic)
|
| 121 |
↓
|
| 122 |
-
write to catalog store (Postgres jsonb, keyed by user_id)
|
| 123 |
```
|
| 124 |
|
| 125 |
For unstructured files: chunk + embed → PGVector.
|
|
|
|
| 95 |
|
| 96 |
### 4.5 Minimal LLM surface
|
| 97 |
|
| 98 |
+
LLM calls happen in exactly three places (KM-557 removed `CatalogEnricher`; ingestion is now LLM-free — the planner reads column names, stats, and sample rows directly):
|
| 99 |
|
| 100 |
1. **`IntentRouter`** — once per user message
|
| 101 |
+
2. **`QueryPlanner`** — once per structured query (produces the IR)
|
| 102 |
+
3. **`ChatbotAgent`** — once per answer (formats the response)
|
|
|
|
| 103 |
|
| 104 |
Compiler and executors are pure code. No LLM in the hot path of query construction.
|
| 105 |
|
|
|
|
| 114 |
↓
|
| 115 |
introspect schema (DB: information_schema; tabular: file headers + sample rows)
|
| 116 |
↓
|
|
|
|
|
|
|
| 117 |
validate (Pydantic)
|
| 118 |
↓
|
| 119 |
+
write to catalog store (Postgres jsonb in `data_catalog`, keyed by user_id)
|
| 120 |
```
|
| 121 |
|
| 122 |
For unstructured files: chunk + embed → PGVector.
|
|
@@ -2,7 +2,7 @@
|
|
| 2 |
|
| 3 |
Persistent tracker mirroring the 42-item ownership table in `REPO_CONTEXT.md` "Team — division of work". Update as PRs land. Future Claude Code sessions read this to know what's already done.
|
| 4 |
|
| 5 |
-
**Last updated**: 2026-05-
|
| 6 |
**Current open PR**: none — all Phase 2 contracts shipped on `pr/1`. Cleanup PR pending (API rewiring + Phase 1 removal).
|
| 7 |
|
| 8 |
---
|
|
@@ -22,7 +22,8 @@ Persistent tracker mirroring the 42-item ownership table in `REPO_CONTEXT.md` "T
|
|
| 22 |
|---|---|---|---|
|
| 23 |
| PR1 | `[x]` merged | DB | Contract locks + catalog plumbing + DB introspector + IR validator + tests |
|
| 24 |
| PR1-tab | `[x]` shipped | TAB | Tabular introspector + on_tabular_uploaded trigger + 31 unit tests |
|
| 25 |
-
| PR2a | `[x]` merged | DB | CatalogEnricher + StructuredPipeline + on_db_registered trigger + FK extension on Table |
|
|
|
|
| 26 |
| PR2b | `[x]` shipped | DB-solo (B-review) | IntentRouter + planner prompt + planner LLM service |
|
| 27 |
| PR3-DB | `[x]` shipped | DB | SqlCompiler (Postgres) + DbExecutor (sqlglot guard, RO + statement_timeout, asyncio.to_thread) + 36 golden IR→SQL tests |
|
| 28 |
| PR3-TAB | `[x]` shipped | TAB | PandasCompiler + TabularExecutor + 43+12 golden IR→DataFrame tests |
|
|
@@ -44,7 +45,7 @@ Persistent tracker mirroring the 42-item ownership table in `REPO_CONTEXT.md` "T
|
|
| 44 |
| 2 | IR Pydantic models (`query/ir/models.py`) | `[x]` | Pre-existing scaffold |
|
| 45 |
| 3 | IR operator whitelists (`query/ir/operators.py`) | `[x]` | PR1 filled `TYPE_COMPATIBILITY` matrix |
|
| 46 |
| 4 | PII patterns / regex (`security/pii_patterns.py`) | `[x]` | Pre-existing |
|
| 47 |
-
| — | `
|
| 48 |
| — | `QueryResult` shape (`query/executor/base.py`) | `[x]` | Pre-existing scaffold; `columns: list[str]` added (TAB owner, PR1-tab) — DbExecutor updated to populate it. |
|
| 49 |
| — | `Source.location_ref` URI scheme | `[x]` | PR1 documented in `catalog/models.py` docstring |
|
| 50 |
|
|
@@ -60,7 +61,7 @@ Persistent tracker mirroring the 42-item ownership table in `REPO_CONTEXT.md` "T
|
|
| 60 |
|
| 61 |
| # | Item | Owner | Status | Notes |
|
| 62 |
|---|---|---|---|---|
|
| 63 |
-
| 8 | Catalog enricher + prompt
|
| 64 |
| 9 | Catalog validator (`catalog/validator.py`) | B | `[x]` | PR1 (DB owner picked up) — uniqueness invariants |
|
| 65 |
| 10 | Catalog store — Postgres jsonb (`catalog/store.py`) | B | `[x]` | PR1 (DB owner picked up) — `INSERT ... ON CONFLICT` |
|
| 66 |
| 11 | Catalog reader (`catalog/reader.py`) | B | `[x]` | PR1 (DB owner picked up) — filters by source_hint, empty on miss |
|
|
@@ -70,7 +71,7 @@ Persistent tracker mirroring the 42-item ownership table in `REPO_CONTEXT.md` "T
|
|
| 70 |
|
| 71 |
| # | Item | Owner | Status | Notes |
|
| 72 |
|---|---|---|---|---|
|
| 73 |
-
| 13 | Structured pipeline (`pipeline/structured_pipeline.py`) | B | `[x]` | PR2a (DB owner) —
|
| 74 |
| 14 | Triggers (`pipeline/triggers.py`) | B | `[~]` | PR2a — `on_db_registered` implemented (DB owner). PR1-tab — `on_tabular_uploaded` implemented (TAB owner). `on_document_uploaded`, `on_catalog_rebuild_requested` still stubs. |
|
| 75 |
| 15 | Ingestion orchestrator (`pipeline/orchestrator.py`) | B | `[ ]` | Likely redundant — StructuredPipeline already takes the introspector at run() time. Revisit if a higher-level routing layer is needed. |
|
| 76 |
| 16 | Document pipeline (`pipeline/document_pipeline.py`) | TAB | `[x]` | Flattened `pipeline/document_pipeline/document_pipeline.py` (folder) → `pipeline/document_pipeline.py` (file). Updated import in `api/v1/document.py`. |
|
|
@@ -81,7 +82,7 @@ Persistent tracker mirroring the 42-item ownership table in `REPO_CONTEXT.md` "T
|
|
| 81 |
|---|---|---|---|---|
|
| 82 |
| 17 | IR validator (`query/ir/validator.py`) | B | `[x]` | PR1 (DB owner) — full rule set; descriptive errors for planner retry |
|
| 83 |
| 18 | Planner LLM service (`query/planner/service.py`) | B | `[x]` | PR2b — Azure OpenAI structured output → `QueryIR`. Injectable chain. Supports retry via `previous_error` argument. |
|
| 84 |
-
| 19 | Planner prompt (`query/planner/prompt.py`, `config/prompts/query_planner.md`) | B | `[x]` | PR2b — system prompt with hard constraints + few-shot for DB and tabular sources. `build_planner_prompt(question, catalog, previous_error)`
|
| 85 |
| 20 | Intent router (`agents/intent_router.py`, `config/prompts/intent_router.md`) | B | `[x]` | PR2b — single LLM call → `IntentRouterDecision(needs_search, source_hint, rewritten_query)`. Supports conversation history. |
|
| 86 |
| 21 | Executor base + `QueryResult` (`query/executor/base.py`) | B | `[x]` | Pre-existing scaffold |
|
| 87 |
| 22 | Executor dispatcher (`query/executor/dispatcher.py`) | B | `[x]` | PR4 — picks DbExecutor / TabularExecutor by `source.source_type`. Lazy imports of production executors keep import side-effect-free for tests. Caches per source_type. |
|
|
@@ -121,6 +122,7 @@ Persistent tracker mirroring the 42-item ownership table in `REPO_CONTEXT.md` "T
|
|
| 121 |
| 35 | Document/tabular upload endpoints (`api/v1/document.py`) | TAB | `[x]` | Rewired `/document/process` — after processing CSV/XLSX, calls `on_tabular_uploaded(document_id, user_id)`. Catalog ingestion failure is logged but does not fail the request (document already ingested to vector store). |
|
| 122 |
| 36 | Chat stream endpoint (`api/v1/chat.py`) | B | `[x]` | Rewired `/chat/stream` — replaced `query_executor.execute()` (Phase 1) with `CatalogReader + QueryService` (Phase 2). Kept Phase 1 structure: Redis cache, message persistence, fast intent, orchestrator, retriever, chatbot. Only query execution block swapped. |
|
| 123 |
| 37 | Room / users endpoints (`api/v1/room.py`, `api/v1/users.py`) | B | `[ ]` | No catalog work; only touch if auth flow changes |
|
|
|
|
| 124 |
|
| 125 |
### Tests + eval
|
| 126 |
|
|
@@ -131,6 +133,7 @@ Persistent tracker mirroring the 42-item ownership table in `REPO_CONTEXT.md` "T
|
|
| 131 |
| 40 | IR validator tests (`tests/query/ir/test_validator.py`) | B | `[x]` | PR1 — 19 tests, all rules covered |
|
| 132 |
| — | PII detector tests (`tests/catalog/test_pii_detector.py`) | B | `[x]` | PR1 — 26 tests (parametrized) |
|
| 133 |
| — | Catalog validator tests (`tests/catalog/test_validator.py`) | B | `[x]` | PR1 — 5 tests |
|
|
|
|
| 134 |
| — | Catalog store integration test (`tests/catalog/test_store.py`) | DB | `[x]` | PR1 — module-level skip without `RUN_INTEGRATION_TESTS=1` |
|
| 135 |
| — | DB introspector test | DB | `[ ]` | Deferred to PR2 — needs Postgres testcontainer or fixture infra |
|
| 136 |
| — | Tabular introspector test | TAB | `[x]` | PR1-tab — 31 unit tests (CSV/XLSX/Parquet, stats, PII, error paths). No DB/blob I/O — mocks injected via constructor. |
|
|
@@ -141,7 +144,45 @@ Persistent tracker mirroring the 42-item ownership table in `REPO_CONTEXT.md` "T
|
|
| 141 |
|
| 142 |
---
|
| 143 |
|
| 144 |
-
## What just shipped (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
**Files implemented**:
|
| 147 |
- `src/agents/intent_router.py` — `IntentRouter.classify(message, history) → IntentRouterDecision`. Pydantic model for structured output. History-aware query rewriting.
|
|
|
|
| 2 |
|
| 3 |
Persistent tracker mirroring the 42-item ownership table in `REPO_CONTEXT.md` "Team — division of work". Update as PRs land. Future Claude Code sessions read this to know what's already done.
|
| 4 |
|
| 5 |
+
**Last updated**: 2026-05-11 ([KM-557] catalog LLM enrichment removed; jsonb table renamed `catalogs` → `data_catalog`; new `GET /api/v1/data-catalog/{user_id}` index endpoint)
|
| 6 |
**Current open PR**: none — all Phase 2 contracts shipped on `pr/1`. Cleanup PR pending (API rewiring + Phase 1 removal).
|
| 7 |
|
| 8 |
---
|
|
|
|
| 22 |
|---|---|---|---|
|
| 23 |
| PR1 | `[x]` merged | DB | Contract locks + catalog plumbing + DB introspector + IR validator + tests |
|
| 24 |
| PR1-tab | `[x]` shipped | TAB | Tabular introspector + on_tabular_uploaded trigger + 31 unit tests |
|
| 25 |
+
| PR2a | `[x]` merged | DB | CatalogEnricher + StructuredPipeline + on_db_registered trigger + FK extension on Table (enricher later removed in KM-557) |
|
| 26 |
+
| KM-557 | `[x]` shipped | DB | Drop CatalogEnricher entirely (cost cut — planner uses stats + sample rows directly); rename jsonb table `catalogs` → `data_catalog`; add `GET /api/v1/data-catalog/{user_id}` index endpoint for catalog refresher |
|
| 27 |
| PR2b | `[x]` shipped | DB-solo (B-review) | IntentRouter + planner prompt + planner LLM service |
|
| 28 |
| PR3-DB | `[x]` shipped | DB | SqlCompiler (Postgres) + DbExecutor (sqlglot guard, RO + statement_timeout, asyncio.to_thread) + 36 golden IR→SQL tests |
|
| 29 |
| PR3-TAB | `[x]` shipped | TAB | PandasCompiler + TabularExecutor + 43+12 golden IR→DataFrame tests |
|
|
|
|
| 45 |
| 2 | IR Pydantic models (`query/ir/models.py`) | `[x]` | Pre-existing scaffold |
|
| 46 |
| 3 | IR operator whitelists (`query/ir/operators.py`) | `[x]` | PR1 filled `TYPE_COMPATIBILITY` matrix |
|
| 47 |
| 4 | PII patterns / regex (`security/pii_patterns.py`) | `[x]` | Pre-existing |
|
| 48 |
+
| — | `data_catalog` Postgres jsonb table (`db/postgres/models.py`) | `[x]` | PR1 added `Catalog` SQLAlchemy class + `init_db.py` import. KM-557 renamed `__tablename__` from `catalogs` → `data_catalog`; created fresh (no migration) |
|
| 49 |
| — | `QueryResult` shape (`query/executor/base.py`) | `[x]` | Pre-existing scaffold; `columns: list[str]` added (TAB owner, PR1-tab) — DbExecutor updated to populate it. |
|
| 50 |
| — | `Source.location_ref` URI scheme | `[x]` | PR1 documented in `catalog/models.py` docstring |
|
| 51 |
|
|
|
|
| 61 |
|
| 62 |
| # | Item | Owner | Status | Notes |
|
| 63 |
|---|---|---|---|---|
|
| 64 |
+
| 8 | ~~Catalog enricher + prompt~~ | B | **REMOVED in KM-557** | Cost optimization — planner reads stats + sample rows + column names directly. `catalog/enricher.py` + `config/prompts/catalog_enricher.md` deleted. `render_source` (the only piece still needed) moved to `src/catalog/render.py`. Tests moved to `tests/catalog/test_render.py`. |
|
| 65 |
| 9 | Catalog validator (`catalog/validator.py`) | B | `[x]` | PR1 (DB owner picked up) — uniqueness invariants |
|
| 66 |
| 10 | Catalog store — Postgres jsonb (`catalog/store.py`) | B | `[x]` | PR1 (DB owner picked up) — `INSERT ... ON CONFLICT` |
|
| 67 |
| 11 | Catalog reader (`catalog/reader.py`) | B | `[x]` | PR1 (DB owner picked up) — filters by source_hint, empty on miss |
|
|
|
|
| 71 |
|
| 72 |
| # | Item | Owner | Status | Notes |
|
| 73 |
|---|---|---|---|---|
|
| 74 |
+
| 13 | Structured pipeline (`pipeline/structured_pipeline.py`) | B | `[x]` | PR2a (DB owner) — Source-type-agnostic: caller supplies the introspector. `default_structured_pipeline()` factory wires production deps lazily so tests can inject mocks without `Settings()` construction. **KM-557**: enrich step removed; pipeline is now `introspect → merge with existing → validate → upsert`. Constructor no longer takes `enricher`. |
|
| 75 |
| 14 | Triggers (`pipeline/triggers.py`) | B | `[~]` | PR2a — `on_db_registered` implemented (DB owner). PR1-tab — `on_tabular_uploaded` implemented (TAB owner). `on_document_uploaded`, `on_catalog_rebuild_requested` still stubs. |
|
| 76 |
| 15 | Ingestion orchestrator (`pipeline/orchestrator.py`) | B | `[ ]` | Likely redundant — StructuredPipeline already takes the introspector at run() time. Revisit if a higher-level routing layer is needed. |
|
| 77 |
| 16 | Document pipeline (`pipeline/document_pipeline.py`) | TAB | `[x]` | Flattened `pipeline/document_pipeline/document_pipeline.py` (folder) → `pipeline/document_pipeline.py` (file). Updated import in `api/v1/document.py`. |
|
|
|
|
| 82 |
|---|---|---|---|---|
|
| 83 |
| 17 | IR validator (`query/ir/validator.py`) | B | `[x]` | PR1 (DB owner) — full rule set; descriptive errors for planner retry |
|
| 84 |
| 18 | Planner LLM service (`query/planner/service.py`) | B | `[x]` | PR2b — Azure OpenAI structured output → `QueryIR`. Injectable chain. Supports retry via `previous_error` argument. |
|
| 85 |
+
| 19 | Planner prompt (`query/planner/prompt.py`, `config/prompts/query_planner.md`) | B | `[x]` | PR2b — system prompt with hard constraints + few-shot for DB and tabular sources. `build_planner_prompt(question, catalog, previous_error)` calls `catalog.render.render_source` (renamed from `catalog.enricher.render_source` in KM-557). |
|
| 86 |
| 20 | Intent router (`agents/intent_router.py`, `config/prompts/intent_router.md`) | B | `[x]` | PR2b — single LLM call → `IntentRouterDecision(needs_search, source_hint, rewritten_query)`. Supports conversation history. |
|
| 87 |
| 21 | Executor base + `QueryResult` (`query/executor/base.py`) | B | `[x]` | Pre-existing scaffold |
|
| 88 |
| 22 | Executor dispatcher (`query/executor/dispatcher.py`) | B | `[x]` | PR4 — picks DbExecutor / TabularExecutor by `source.source_type`. Lazy imports of production executors keep import side-effect-free for tests. Caches per source_type. |
|
|
|
|
| 122 |
| 35 | Document/tabular upload endpoints (`api/v1/document.py`) | TAB | `[x]` | Rewired `/document/process` — after processing CSV/XLSX, calls `on_tabular_uploaded(document_id, user_id)`. Catalog ingestion failure is logged but does not fail the request (document already ingested to vector store). |
|
| 123 |
| 36 | Chat stream endpoint (`api/v1/chat.py`) | B | `[x]` | Rewired `/chat/stream` — replaced `query_executor.execute()` (Phase 1) with `CatalogReader + QueryService` (Phase 2). Kept Phase 1 structure: Redis cache, message persistence, fast intent, orchestrator, retriever, chatbot. Only query execution block swapped. |
|
| 124 |
| 37 | Room / users endpoints (`api/v1/room.py`, `api/v1/users.py`) | B | `[ ]` | No catalog work; only touch if auth flow changes |
|
| 125 |
+
| — | Data catalog index endpoint (`api/v1/data_catalog.py`) | DB | `[x]` | **KM-557** — `GET /api/v1/data-catalog/{user_id}` → `list[CatalogIndexEntry]` (source_id, source_type, name, location_ref, table_count, updated_at). Lightweight summary used by the catalog refresher; full catalog rows are not exposed. Router wired in `main.py`. Response model in `src/models/api/catalog.py:CatalogIndexEntry`. |
|
| 126 |
|
| 127 |
### Tests + eval
|
| 128 |
|
|
|
|
| 133 |
| 40 | IR validator tests (`tests/query/ir/test_validator.py`) | B | `[x]` | PR1 — 19 tests, all rules covered |
|
| 134 |
| — | PII detector tests (`tests/catalog/test_pii_detector.py`) | B | `[x]` | PR1 — 26 tests (parametrized) |
|
| 135 |
| — | Catalog validator tests (`tests/catalog/test_validator.py`) | B | `[x]` | PR1 — 5 tests |
|
| 136 |
+
| — | Catalog render tests (`tests/catalog/test_render.py`) | B | `[x]` | **KM-557** — 5 tests (renamed from `test_enricher.py`; LLM enrichment tests dropped, render-only tests kept). |
|
| 137 |
| — | Catalog store integration test (`tests/catalog/test_store.py`) | DB | `[x]` | PR1 — module-level skip without `RUN_INTEGRATION_TESTS=1` |
|
| 138 |
| — | DB introspector test | DB | `[ ]` | Deferred to PR2 — needs Postgres testcontainer or fixture infra |
|
| 139 |
| — | Tabular introspector test | TAB | `[x]` | PR1-tab — 31 unit tests (CSV/XLSX/Parquet, stats, PII, error paths). No DB/blob I/O — mocks injected via constructor. |
|
|
|
|
| 144 |
|
| 145 |
---
|
| 146 |
|
| 147 |
+
## What just shipped (KM-557 — DB owner)
|
| 148 |
+
|
| 149 |
+
After lead review of the catalog ingestion cost: dropped LLM enrichment,
|
| 150 |
+
renamed the storage table, and exposed a lightweight index endpoint for
|
| 151 |
+
the upcoming catalog refresher.
|
| 152 |
+
|
| 153 |
+
**Files deleted**:
|
| 154 |
+
- `src/catalog/enricher.py` — entire CatalogEnricher + EnrichmentResponse + apply_descriptions removed
|
| 155 |
+
- `src/config/prompts/catalog_enricher.md` — dead prompt
|
| 156 |
+
- `tests/catalog/test_enricher.py` — replaced by `test_render.py`
|
| 157 |
+
|
| 158 |
+
**Files added**:
|
| 159 |
+
- `src/catalog/render.py` — new home for `render_source` (the only piece of the old enricher still needed; consumed by `query/planner/prompt.py`)
|
| 160 |
+
- `src/api/v1/data_catalog.py` — `GET /api/v1/data-catalog/{user_id}` returns `list[CatalogIndexEntry]`
|
| 161 |
+
- `tests/catalog/test_render.py` — 5 tests (same coverage as the old render block)
|
| 162 |
+
|
| 163 |
+
**Files modified**:
|
| 164 |
+
- `src/db/postgres/models.py` — `__tablename__ = "data_catalog"` (was `"catalogs"`). Class name unchanged
|
| 165 |
+
- `src/pipeline/structured_pipeline.py` — `StructuredPipeline(validator, store)` (was `(enricher, validator, store)`); pipeline is now `introspect → merge → validate → upsert`; `default_structured_pipeline()` no longer constructs an enricher
|
| 166 |
+
- `src/pipeline/triggers.py` — docstrings updated; `on_catalog_rebuild_requested` docstring rewritten for the refresher use case
|
| 167 |
+
- `src/query/planner/prompt.py` — import now `from ...catalog.render import render_source`
|
| 168 |
+
- `src/catalog/introspect/{base,database,tabular}.py` — docstring scrubs (no behavior changes)
|
| 169 |
+
- `src/models/api/catalog.py` — added `CatalogIndexEntry`; simplified `CatalogRebuildResponse` to `sources_rebuilt`
|
| 170 |
+
- `main.py` — registered `data_catalog_router`
|
| 171 |
+
- `src/security/README.md` — one stale wording fix
|
| 172 |
+
|
| 173 |
+
**No migration**: the `data_catalog` table is created from scratch on first `init_db()`. The old `catalogs` table was never deployed against production data, so no rename SQL is needed.
|
| 174 |
+
|
| 175 |
+
**Tests**: all 4 `test_structured_pipeline.py` tests reworked to construct `StructuredPipeline(validator=, store=)` without `enricher`. 5 `test_render.py` tests cover render_source standalone.
|
| 176 |
+
|
| 177 |
+
**Lint**: `ruff check` clean on modified Phase 2 paths.
|
| 178 |
+
|
| 179 |
+
**Open follow-ups left for the lead**:
|
| 180 |
+
- `on_catalog_rebuild_requested` body — the refresher will iterate the index endpoint and call this trigger per source
|
| 181 |
+
- `api/v1/db_client.py` `/ingest` still doesn't call `on_db_registered` — same blocker as before, untouched by KM-557
|
| 182 |
+
|
| 183 |
+
---
|
| 184 |
+
|
| 185 |
+
## What shipped previously (PR2b/4/5/6/7-bundle — DB owner solo, teammate reviews)
|
| 186 |
|
| 187 |
**Files implemented**:
|
| 188 |
- `src/agents/intent_router.py` — `IntentRouter.classify(message, history) → IntentRouterDecision`. Pydantic model for structured output. History-aware query rewriting.
|
|
@@ -68,9 +68,9 @@ src/ — all application code
|
|
| 68 |
| `catalog/introspect/base.py` | `BaseIntrospector.introspect(location_ref) -> Source` |
|
| 69 |
| `catalog/introspect/database.py` | `information_schema` + ~100 row sample → draft Source |
|
| 70 |
| `catalog/introspect/tabular.py` | Parquet/CSV/XLSX header reader + sample (one Table per sheet for XLSX) |
|
| 71 |
-
| `catalog/
|
| 72 |
| `catalog/validator.py` | invariants beyond Pydantic shape (unique IDs, FK refs) |
|
| 73 |
-
| `catalog/store.py` | persist as Postgres `jsonb` row keyed by user_id (`get/upsert/delete`) |
|
| 74 |
| `catalog/reader.py` | load + filter catalog by source_hint (returns full catalog for ≤50 tables) |
|
| 75 |
| `catalog/pii_detector.py` | flag PII columns at ingestion → suppresses `sample_values` |
|
| 76 |
|
|
@@ -97,14 +97,16 @@ src/ — all application code
|
|
| 97 |
| `retrieval/document.py` | `DocumentRetriever` over PGVector chunks |
|
| 98 |
| `retrieval/router.py` | dispatches the `unstructured` route (the `chat` and `structured` routes do not pass through here) |
|
| 99 |
|
| 100 |
-
### Agents — the
|
| 101 |
|
| 102 |
| Path | Role |
|
| 103 |
|---|---|
|
| 104 |
| `agents/intent_router.py` | classify message → `needs_search`, `source_hint ∈ {chat, unstructured, structured}` |
|
| 105 |
| `agents/chatbot.py` | final answer formation (receives Cu chunks or QueryResult); SSE-streamed |
|
| 106 |
|
| 107 |
-
(`
|
|
|
|
|
|
|
| 108 |
|
| 109 |
### Pipelines — ingestion coordinators
|
| 110 |
|
|
@@ -139,7 +141,7 @@ src/ — all application code
|
|
| 139 |
| `observability/langfuse/langfuse.py` | trace helper |
|
| 140 |
| `config/settings.py` | pydantic-settings; `.env` uses double-underscore aliases |
|
| 141 |
| `config/env_constant.py` | env file path constant |
|
| 142 |
-
| `config/prompts/*.md` | prompt templates: `intent_router`, `
|
| 143 |
|
| 144 |
---
|
| 145 |
|
|
@@ -153,8 +155,7 @@ src/ — all application code
|
|
| 153 |
|
| 154 |
4. **Pipeline stage isolation.** Each stage (`IntentRouter`, `CatalogReader`, `QueryPlanner`, `IRValidator`, `QueryCompiler`, `QueryExecutor`, `ChatbotAgent`) is its own module with typed input and typed output. No god classes.
|
| 155 |
|
| 156 |
-
5. **Minimal LLM surface.** Only
|
| 157 |
-
- `CatalogEnricher` — once per source, **at ingestion** (not query time)
|
| 158 |
- `IntentRouter` — once per user message
|
| 159 |
- `QueryPlanner` — once per structured query
|
| 160 |
- `ChatbotAgent` — once per answer (formatting)
|
|
@@ -179,9 +180,8 @@ source upload / DB connect
|
|
| 179 |
│
|
| 180 |
└── structured (DB schema or tabular file)
|
| 181 |
→ introspect (information_schema or file headers + sample rows)
|
| 182 |
-
→ CatalogEnricher (1 LLM call per source — AI descriptions)
|
| 183 |
→ CatalogValidator (Pydantic + unique-IDs + FK refs)
|
| 184 |
-
→ CatalogStore.upsert(user_id jsonb row)
|
| 185 |
```
|
| 186 |
|
| 187 |
### Query (per user message)
|
|
@@ -313,7 +313,7 @@ The service is built by two engineers; many modules are source-type-agnostic and
|
|
| 313 |
| 6 | Tabular introspector (CSV/XLSX/Parquet headers + sample) | `catalog/introspect/tabular.py` | TAB | Each XLSX sheet → one Table |
|
| 314 |
| 7 | `BaseIntrospector` ABC | `catalog/introspect/base.py` | B | Confirm signature returns the same `Source` shape |
|
| 315 |
| **Ingestion — shared catalog plumbing** | | | | |
|
| 316 |
-
| 8 | Catalog enricher + prompt |
|
| 317 |
| 9 | Catalog validator | `catalog/validator.py` | B | Type-agnostic |
|
| 318 |
| 10 | Catalog store (Postgres jsonb) | `catalog/store.py` | B | Recommend DB (Postgres expertise) |
|
| 319 |
| 11 | Catalog reader | `catalog/reader.py` | B | Type-agnostic |
|
|
|
|
| 68 |
| `catalog/introspect/base.py` | `BaseIntrospector.introspect(location_ref) -> Source` |
|
| 69 |
| `catalog/introspect/database.py` | `information_schema` + ~100 row sample → draft Source |
|
| 70 |
| `catalog/introspect/tabular.py` | Parquet/CSV/XLSX header reader + sample (one Table per sheet for XLSX) |
|
| 71 |
+
| `catalog/render.py` | renders a `Source` as the canonical text block consumed by the planner (KM-557; LLM enrichment removed — planner reads stats + samples directly) |
|
| 72 |
| `catalog/validator.py` | invariants beyond Pydantic shape (unique IDs, FK refs) |
|
| 73 |
+
| `catalog/store.py` | persist as Postgres `jsonb` row keyed by user_id (`get/upsert/delete`) — table `data_catalog` |
|
| 74 |
| `catalog/reader.py` | load + filter catalog by source_hint (returns full catalog for ≤50 tables) |
|
| 75 |
| `catalog/pii_detector.py` | flag PII columns at ingestion → suppresses `sample_values` |
|
| 76 |
|
|
|
|
| 97 |
| `retrieval/document.py` | `DocumentRetriever` over PGVector chunks |
|
| 98 |
| `retrieval/router.py` | dispatches the `unstructured` route (the `chat` and `structured` routes do not pass through here) |
|
| 99 |
|
| 100 |
+
### Agents — the three LLM call sites
|
| 101 |
|
| 102 |
| Path | Role |
|
| 103 |
|---|---|
|
| 104 |
| `agents/intent_router.py` | classify message → `needs_search`, `source_hint ∈ {chat, unstructured, structured}` |
|
| 105 |
| `agents/chatbot.py` | final answer formation (receives Cu chunks or QueryResult); SSE-streamed |
|
| 106 |
|
| 107 |
+
(`QueryPlanner` is the third LLM call site, under `query/planner/`. The
|
| 108 |
+
fourth — `CatalogEnricher` — was removed in KM-557; ingestion no longer
|
| 109 |
+
makes any LLM calls.)
|
| 110 |
|
| 111 |
### Pipelines — ingestion coordinators
|
| 112 |
|
|
|
|
| 141 |
| `observability/langfuse/langfuse.py` | trace helper |
|
| 142 |
| `config/settings.py` | pydantic-settings; `.env` uses double-underscore aliases |
|
| 143 |
| `config/env_constant.py` | env file path constant |
|
| 144 |
+
| `config/prompts/*.md` | prompt templates: `intent_router`, `query_planner`, `chatbot_system`, `guardrails` (KM-557 removed `catalog_enricher`) |
|
| 145 |
|
| 146 |
---
|
| 147 |
|
|
|
|
| 155 |
|
| 156 |
4. **Pipeline stage isolation.** Each stage (`IntentRouter`, `CatalogReader`, `QueryPlanner`, `IRValidator`, `QueryCompiler`, `QueryExecutor`, `ChatbotAgent`) is its own module with typed input and typed output. No god classes.
|
| 157 |
|
| 158 |
+
5. **Minimal LLM surface.** Only three LLM call sites in the system (KM-557 dropped `CatalogEnricher` — ingestion is now LLM-free; the planner reads stats + sample rows + column names directly):
|
|
|
|
| 159 |
- `IntentRouter` — once per user message
|
| 160 |
- `QueryPlanner` — once per structured query
|
| 161 |
- `ChatbotAgent` — once per answer (formatting)
|
|
|
|
| 180 |
│
|
| 181 |
└── structured (DB schema or tabular file)
|
| 182 |
→ introspect (information_schema or file headers + sample rows)
|
|
|
|
| 183 |
→ CatalogValidator (Pydantic + unique-IDs + FK refs)
|
| 184 |
+
→ CatalogStore.upsert(user_id jsonb row in `data_catalog`)
|
| 185 |
```
|
| 186 |
|
| 187 |
### Query (per user message)
|
|
|
|
| 313 |
| 6 | Tabular introspector (CSV/XLSX/Parquet headers + sample) | `catalog/introspect/tabular.py` | TAB | Each XLSX sheet → one Table |
|
| 314 |
| 7 | `BaseIntrospector` ABC | `catalog/introspect/base.py` | B | Confirm signature returns the same `Source` shape |
|
| 315 |
| **Ingestion — shared catalog plumbing** | | | | |
|
| 316 |
+
| 8 | ~~Catalog enricher + prompt~~ | — | **REMOVED in KM-557.** Cost optimization — planner reads stats + sample rows directly. `catalog/render.py` keeps the source-rendering helper. |
|
| 317 |
| 9 | Catalog validator | `catalog/validator.py` | B | Type-agnostic |
|
| 318 |
| 10 | Catalog store (Postgres jsonb) | `catalog/store.py` | B | Recommend DB (Postgres expertise) |
|
| 319 |
| 11 | Catalog reader | `catalog/reader.py` | B | Type-agnostic |
|
|
@@ -11,6 +11,7 @@ from src.api.v1.room import router as room_router
|
|
| 11 |
from src.api.v1.users import router as users_router
|
| 12 |
from src.api.v1.knowledge import router as knowledge_router
|
| 13 |
from src.api.v1.db_client import router as db_client_router
|
|
|
|
| 14 |
from src.db.postgres.init_db import init_db
|
| 15 |
import uvicorn
|
| 16 |
|
|
@@ -37,6 +38,7 @@ app.include_router(knowledge_router)
|
|
| 37 |
app.include_router(room_router)
|
| 38 |
app.include_router(chat_router)
|
| 39 |
app.include_router(db_client_router)
|
|
|
|
| 40 |
|
| 41 |
|
| 42 |
@app.on_event("startup")
|
|
|
|
| 11 |
from src.api.v1.users import router as users_router
|
| 12 |
from src.api.v1.knowledge import router as knowledge_router
|
| 13 |
from src.api.v1.db_client import router as db_client_router
|
| 14 |
+
from src.api.v1.data_catalog import router as data_catalog_router
|
| 15 |
from src.db.postgres.init_db import init_db
|
| 16 |
import uvicorn
|
| 17 |
|
|
|
|
| 38 |
app.include_router(room_router)
|
| 39 |
app.include_router(chat_router)
|
| 40 |
app.include_router(db_client_router)
|
| 41 |
+
app.include_router(data_catalog_router)
|
| 42 |
|
| 43 |
|
| 44 |
@app.on_event("startup")
|
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API endpoints for the per-user data catalog index.
|
| 2 |
+
|
| 3 |
+
The index is a lightweight summary of every structured source registered
|
| 4 |
+
by a user (DB connections and tabular files). It is intended to be
|
| 5 |
+
consumed by the catalog refresher and by frontend listings — full
|
| 6 |
+
catalog payloads (tables + columns + samples + stats) are not exposed
|
| 7 |
+
here on purpose.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from typing import List
|
| 11 |
+
|
| 12 |
+
from fastapi import APIRouter, HTTPException, status
|
| 13 |
+
|
| 14 |
+
from src.catalog.store import CatalogStore
|
| 15 |
+
from src.middlewares.logging import get_logger, log_execution
|
| 16 |
+
from src.models.api.catalog import CatalogIndexEntry
|
| 17 |
+
|
| 18 |
+
logger = get_logger("data_catalog_api")
|
| 19 |
+
|
| 20 |
+
router = APIRouter(prefix="/api/v1", tags=["Data Catalog"])
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@router.get(
|
| 24 |
+
"/data-catalog/{user_id}",
|
| 25 |
+
response_model=List[CatalogIndexEntry],
|
| 26 |
+
summary="List the user's data catalog index",
|
| 27 |
+
response_description="One entry per registered structured source.",
|
| 28 |
+
responses={
|
| 29 |
+
200: {"description": "Returns an empty list if the user has no registered sources."},
|
| 30 |
+
500: {"description": "Internal server error while reading the catalog."},
|
| 31 |
+
},
|
| 32 |
+
)
|
| 33 |
+
@log_execution(logger)
|
| 34 |
+
async def list_data_catalog_index(user_id: str):
|
| 35 |
+
"""
|
| 36 |
+
Return a lightweight index of every structured source registered by the user.
|
| 37 |
+
|
| 38 |
+
One entry per source (DB connection or tabular file), including the
|
| 39 |
+
`source_id`, `source_type`, display `name`, `location_ref`, current
|
| 40 |
+
`table_count`, and `updated_at` timestamp.
|
| 41 |
+
|
| 42 |
+
Used by the catalog refresher to decide which sources need to be
|
| 43 |
+
rebuilt. Returns an empty list if the user has no catalog yet.
|
| 44 |
+
"""
|
| 45 |
+
try:
|
| 46 |
+
catalog = await CatalogStore().get(user_id)
|
| 47 |
+
except Exception as e:
|
| 48 |
+
logger.error("Failed to read catalog index", user_id=user_id, error=str(e))
|
| 49 |
+
raise HTTPException(
|
| 50 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 51 |
+
detail=f"Failed to read catalog index: {e}",
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
if catalog is None:
|
| 55 |
+
return []
|
| 56 |
+
|
| 57 |
+
return [
|
| 58 |
+
CatalogIndexEntry(
|
| 59 |
+
source_id=s.source_id,
|
| 60 |
+
source_type=s.source_type,
|
| 61 |
+
name=s.name,
|
| 62 |
+
location_ref=s.location_ref,
|
| 63 |
+
table_count=len(s.tables),
|
| 64 |
+
updated_at=s.updated_at,
|
| 65 |
+
)
|
| 66 |
+
for s in catalog.sources
|
| 67 |
+
]
|
|
@@ -1,7 +1,8 @@
|
|
| 1 |
"""BaseIntrospector — contract for source-specific schema readers.
|
| 2 |
|
| 3 |
-
Subclasses produce a
|
| 4 |
-
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from abc import ABC, abstractmethod
|
|
|
|
| 1 |
"""BaseIntrospector — contract for source-specific schema readers.
|
| 2 |
|
| 3 |
+
Subclasses produce a Source object with raw schema (names, types, sample
|
| 4 |
+
values, stats). The planner consumes this directly — descriptions are not
|
| 5 |
+
LLM-generated.
|
| 6 |
"""
|
| 7 |
|
| 8 |
from abc import ABC, abstractmethod
|
|
@@ -1,8 +1,8 @@
|
|
| 1 |
"""Database schema introspection (Postgres / MySQL / Supabase).
|
| 2 |
|
| 3 |
Reads information_schema for tables/columns/types, samples ~100 rows per table
|
| 4 |
-
for `sample_values` and basic stats.
|
| 5 |
-
|
| 6 |
|
| 7 |
Reuses Phase 1 utilities (`database_client_service`, `db_credential_encryption`,
|
| 8 |
`db_pipeline_service.engine_scope`, `extractor.get_schema/profile_column/get_row_count`)
|
|
|
|
| 1 |
"""Database schema introspection (Postgres / MySQL / Supabase).
|
| 2 |
|
| 3 |
Reads information_schema for tables/columns/types, samples ~100 rows per table
|
| 4 |
+
for `sample_values` and basic stats. Description fields are left empty —
|
| 5 |
+
the planner relies on names + samples + stats directly.
|
| 6 |
|
| 7 |
Reuses Phase 1 utilities (`database_client_service`, `db_credential_encryption`,
|
| 8 |
`db_pipeline_service.engine_scope`, `extractor.get_schema/profile_column/get_row_count`)
|
|
@@ -75,8 +75,7 @@ class TabularIntrospector(BaseIntrospector):
|
|
| 75 |
"""Read column names, dtypes, and sample values from Parquet/CSV/XLSX.
|
| 76 |
|
| 77 |
Heavy I/O dependencies (`fetch_doc`, `fetch_blob`) are injectable so unit
|
| 78 |
-
tests can pass mocks without triggering Settings or DB construction
|
| 79 |
-
pattern as CatalogEnricher's `structured_chain` parameter.
|
| 80 |
"""
|
| 81 |
|
| 82 |
def __init__(
|
|
|
|
| 75 |
"""Read column names, dtypes, and sample values from Parquet/CSV/XLSX.
|
| 76 |
|
| 77 |
Heavy I/O dependencies (`fetch_doc`, `fetch_blob`) are injectable so unit
|
| 78 |
+
tests can pass mocks without triggering Settings or DB construction.
|
|
|
|
| 79 |
"""
|
| 80 |
|
| 81 |
def __init__(
|
|
@@ -1,92 +1,16 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
Input: a draft Source produced by an introspector (raw schema, no descriptions).
|
| 4 |
-
Output: the same Source enriched with description fields at source / table /
|
| 5 |
-
column level. Other fields (sample_values, stats, foreign_keys, ids, etc.) are
|
| 6 |
-
preserved verbatim — the LLM only emits descriptions keyed by stable IDs and
|
| 7 |
-
they are merged back in.
|
| 8 |
-
|
| 9 |
-
Prompt: `src/config/prompts/catalog_enricher.md`.
|
| 10 |
-
LLM: Azure OpenAI GPT-4o by default. The structured-output runnable is
|
| 11 |
-
injectable via the constructor for testability.
|
| 12 |
-
"""
|
| 13 |
|
| 14 |
from __future__ import annotations
|
| 15 |
|
| 16 |
-
from pathlib import Path
|
| 17 |
-
|
| 18 |
-
from langchain_core.prompts import ChatPromptTemplate
|
| 19 |
-
from langchain_core.runnables import Runnable
|
| 20 |
-
from langchain_openai import AzureChatOpenAI
|
| 21 |
-
from pydantic import BaseModel, Field
|
| 22 |
-
|
| 23 |
-
from src.middlewares.logging import get_logger
|
| 24 |
-
|
| 25 |
from .models import Source
|
| 26 |
|
| 27 |
-
logger = get_logger("catalog_enricher")
|
| 28 |
-
|
| 29 |
-
_PROMPT_PATH = (
|
| 30 |
-
Path(__file__).resolve().parent.parent / "config" / "prompts" / "catalog_enricher.md"
|
| 31 |
-
)
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
class _IdDescription(BaseModel):
|
| 35 |
-
"""One description keyed by a stable identifier from the input Source.
|
| 36 |
-
|
| 37 |
-
`target_id` MUST match a `source_id`, `table_id`, or `column_id` exactly
|
| 38 |
-
as it appeared in the rendered input — do not regenerate or transform.
|
| 39 |
-
"""
|
| 40 |
-
|
| 41 |
-
target_id: str = Field(
|
| 42 |
-
...,
|
| 43 |
-
description="source_id, table_id, or column_id — copied verbatim from the input.",
|
| 44 |
-
)
|
| 45 |
-
description: str = Field(
|
| 46 |
-
...,
|
| 47 |
-
description="One-line factual description grounded in the input.",
|
| 48 |
-
)
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
class EnrichmentResponse(BaseModel):
|
| 52 |
-
"""Structured output: a flat list of (id, description) pairs."""
|
| 53 |
-
|
| 54 |
-
descriptions: list[_IdDescription] = Field(default_factory=list)
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
def _load_prompt_text() -> str:
|
| 58 |
-
return _PROMPT_PATH.read_text(encoding="utf-8")
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
def _build_default_chain() -> Runnable:
|
| 62 |
-
"""Construct the production LangChain runnable.
|
| 63 |
-
|
| 64 |
-
`settings` is imported lazily so importing this module is side-effect-free
|
| 65 |
-
for tests that inject a mock `structured_chain` (otherwise
|
| 66 |
-
`Settings()` would fail without a populated `.env`).
|
| 67 |
-
"""
|
| 68 |
-
from src.config.settings import settings
|
| 69 |
-
|
| 70 |
-
llm = AzureChatOpenAI(
|
| 71 |
-
azure_deployment=settings.azureai_deployment_name_4o,
|
| 72 |
-
openai_api_version=settings.azureai_api_version_4o,
|
| 73 |
-
azure_endpoint=settings.azureai_endpoint_url_4o,
|
| 74 |
-
api_key=settings.azureai_api_key_4o,
|
| 75 |
-
temperature=0.2,
|
| 76 |
-
)
|
| 77 |
-
prompt = ChatPromptTemplate.from_messages(
|
| 78 |
-
[
|
| 79 |
-
("system", _load_prompt_text()),
|
| 80 |
-
("human", "{source_text}"),
|
| 81 |
-
]
|
| 82 |
-
)
|
| 83 |
-
return prompt | llm.with_structured_output(EnrichmentResponse)
|
| 84 |
-
|
| 85 |
|
| 86 |
def render_source(source: Source) -> str:
|
| 87 |
-
"""Render a Source as the text
|
| 88 |
|
| 89 |
-
|
|
|
|
|
|
|
| 90 |
"""
|
| 91 |
lines: list[str] = [
|
| 92 |
f"Source: {source.name} ({source.source_type})",
|
|
@@ -133,64 +57,3 @@ def render_source(source: Source) -> str:
|
|
| 133 |
)
|
| 134 |
lines.append(f" - {src_col_name} -> {tgt_table_name}.{tgt_col_name}")
|
| 135 |
return "\n".join(lines)
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
def apply_descriptions(source: Source, response: EnrichmentResponse) -> Source:
|
| 139 |
-
"""Merge LLM-emitted descriptions back into the Source by stable ID.
|
| 140 |
-
|
| 141 |
-
Items the LLM omitted retain their existing description (usually ""
|
| 142 |
-
from the introspector). All other fields are preserved verbatim.
|
| 143 |
-
"""
|
| 144 |
-
by_id = {d.target_id: d.description for d in response.descriptions}
|
| 145 |
-
|
| 146 |
-
new_tables = []
|
| 147 |
-
for table in source.tables:
|
| 148 |
-
new_columns = [
|
| 149 |
-
col.model_copy(
|
| 150 |
-
update={"description": by_id.get(col.column_id, col.description)}
|
| 151 |
-
)
|
| 152 |
-
for col in table.columns
|
| 153 |
-
]
|
| 154 |
-
new_tables.append(
|
| 155 |
-
table.model_copy(
|
| 156 |
-
update={
|
| 157 |
-
"description": by_id.get(table.table_id, table.description),
|
| 158 |
-
"columns": new_columns,
|
| 159 |
-
}
|
| 160 |
-
)
|
| 161 |
-
)
|
| 162 |
-
|
| 163 |
-
return source.model_copy(
|
| 164 |
-
update={
|
| 165 |
-
"description": by_id.get(source.source_id, source.description),
|
| 166 |
-
"tables": new_tables,
|
| 167 |
-
}
|
| 168 |
-
)
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
class CatalogEnricher:
|
| 172 |
-
"""Adds AI-generated descriptions to a freshly introspected source.
|
| 173 |
-
|
| 174 |
-
Inject `structured_chain` for tests; default builds an Azure OpenAI
|
| 175 |
-
GPT-4o chain wired to `with_structured_output(EnrichmentResponse)`.
|
| 176 |
-
"""
|
| 177 |
-
|
| 178 |
-
def __init__(self, structured_chain: Runnable | None = None) -> None:
|
| 179 |
-
self._chain = structured_chain
|
| 180 |
-
|
| 181 |
-
def _ensure_chain(self) -> Runnable:
|
| 182 |
-
if self._chain is None:
|
| 183 |
-
self._chain = _build_default_chain()
|
| 184 |
-
return self._chain
|
| 185 |
-
|
| 186 |
-
async def enrich(self, source: Source) -> Source:
|
| 187 |
-
rendered = render_source(source)
|
| 188 |
-
chain = self._ensure_chain()
|
| 189 |
-
response: EnrichmentResponse = await chain.ainvoke({"source_text": rendered})
|
| 190 |
-
logger.info(
|
| 191 |
-
"catalog source enriched",
|
| 192 |
-
source_id=source.source_id,
|
| 193 |
-
descriptions=len(response.descriptions),
|
| 194 |
-
tables=len(source.tables),
|
| 195 |
-
)
|
| 196 |
-
return apply_descriptions(source, response)
|
|
|
|
| 1 |
+
"""Render a `Source` into the canonical text block consumed by the planner."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
from .models import Source
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
def render_source(source: Source) -> str:
|
| 9 |
+
"""Render a Source as the canonical text block consumed by the planner.
|
| 10 |
|
| 11 |
+
Includes stable IDs (so the LLM can echo them back), per-column data
|
| 12 |
+
type, sample values (or `PII (suppressed)` for flagged columns), basic
|
| 13 |
+
stats, and resolved-by-name foreign keys.
|
| 14 |
"""
|
| 15 |
lines: list[str] = [
|
| 16 |
f"Source: {source.name} ({source.source_type})",
|
|
|
|
| 57 |
)
|
| 58 |
lines.append(f" - {src_col_name} -> {tgt_table_name}.{tgt_col_name}")
|
| 59 |
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,62 +0,0 @@
|
|
| 1 |
-
You are a senior data analyst writing concise, factual descriptions for a user's data catalog. The catalog is consumed by an AI agent that helps the same user query their data, so descriptions must accurately convey what each source / table / column **is**, not what an analyst might guess it could be.
|
| 2 |
-
|
| 3 |
-
## Your task
|
| 4 |
-
|
| 5 |
-
Given a single data source rendered below, produce a description for the source itself, each table inside it, and each column inside each table. Return your output as structured JSON matching the requested schema; identifiers (`source_id`, `table_id`, `column_id`) must be copied **verbatim** from the input.
|
| 6 |
-
|
| 7 |
-
## Style rules
|
| 8 |
-
|
| 9 |
-
- **One factual sentence per item.** Two only if a second is genuinely necessary (e.g., to call out a unit or important caveat).
|
| 10 |
-
- **Ground every claim in the evidence shown** — column names, sample values, stats, foreign keys, table names. Do not invent semantics that the inputs do not support.
|
| 11 |
-
- **Do not restate the data type.** ("`amount` (decimal)" is redundant; say what `amount` represents.)
|
| 12 |
-
- **Mention obvious units / scales** when sample values make them clear (e.g., "in cents", "in milliseconds", "0–100 score").
|
| 13 |
-
- **PII columns have `samples=PII (suppressed)`.** Describe their role from the column name only — do not speculate about content. Example: "Customer's email address." (correct), not "Email address, e.g. alice@example.com" (wrong, invented).
|
| 14 |
-
- **Foreign keys**: when present, mention the relationship at the table level (e.g., "Each row links to a customer via `customer_id`"). Don't repeat the FK in the column description unless it's the primary point.
|
| 15 |
-
- **Do not include sample values verbatim in any description.** Use them as evidence to infer meaning, not as content to quote.
|
| 16 |
-
- **No markdown formatting in descriptions** (no bold, lists, code fences). Plain text only.
|
| 17 |
-
|
| 18 |
-
## Source-level description
|
| 19 |
-
|
| 20 |
-
One or two sentences describing what kind of data this source holds and what the user might use it for. If the source has only one table, the source and table descriptions can overlap but should not be identical word-for-word.
|
| 21 |
-
|
| 22 |
-
## Table-level description
|
| 23 |
-
|
| 24 |
-
What real-world entity or event each row represents. Mention the grain (one row per …) if non-obvious.
|
| 25 |
-
|
| 26 |
-
## Column-level description
|
| 27 |
-
|
| 28 |
-
What the column **means**, not what it stores. Read sample values, ranges, and the column name together to triangulate. If genuinely ambiguous, write a description that captures the ambiguity rather than picking one interpretation arbitrarily.
|
| 29 |
-
|
| 30 |
-
## Few-shot example
|
| 31 |
-
|
| 32 |
-
Input (rendered source):
|
| 33 |
-
|
| 34 |
-
```
|
| 35 |
-
Source: prod_db (schema)
|
| 36 |
-
|
| 37 |
-
Tables:
|
| 38 |
-
|
| 39 |
-
Table: orders (12,453 rows)
|
| 40 |
-
Columns:
|
| 41 |
-
- id [int]: samples=[1, 2, 3], min=1, max=12453, distinct=12453
|
| 42 |
-
- customer_id [int]: samples=[42, 17, 99], min=1, max=8200, distinct=8200
|
| 43 |
-
- total_cents [int]: samples=[2499, 4999, 1999], min=99, max=999900, distinct=4321
|
| 44 |
-
- status [string]: samples=[completed, pending, refunded], distinct=4
|
| 45 |
-
- created_at [datetime]: samples=[2026-04-01T08:12:00Z, 2026-04-01T08:14:00Z], distinct=12440
|
| 46 |
-
Foreign keys:
|
| 47 |
-
- customer_id -> customers.id
|
| 48 |
-
```
|
| 49 |
-
|
| 50 |
-
Expected descriptions:
|
| 51 |
-
|
| 52 |
-
- Source: "Production order ledger for the storefront — one row per checkout, used for reporting on revenue, fulfillment, and customer purchase history."
|
| 53 |
-
- Table `orders`: "One row per completed or attempted checkout; links each order to the customer who placed it."
|
| 54 |
-
- Column `id`: "Unique order identifier."
|
| 55 |
-
- Column `customer_id`: "Identifier of the customer who placed the order; references customers.id."
|
| 56 |
-
- Column `total_cents`: "Order total in cents (USD), inclusive of taxes and discounts."
|
| 57 |
-
- Column `status`: "Lifecycle state of the order — values include completed, pending, and refunded."
|
| 58 |
-
- Column `created_at`: "Timestamp when the order was placed (UTC)."
|
| 59 |
-
|
| 60 |
-
## Output
|
| 61 |
-
|
| 62 |
-
Return strict JSON matching the schema requested by the structured-output binding. The shape mirrors the input source: a flat map of identifiers to descriptions is acceptable but the full nested form is preferred. Identifiers must match the input exactly — do not regenerate them.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -22,7 +22,7 @@ async def init_db():
|
|
| 22 |
await conn.execute(text("SELECT pg_advisory_xact_lock(1573678846307946496)"))
|
| 23 |
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
|
| 24 |
|
| 25 |
-
# Create application tables
|
| 26 |
await conn.run_sync(Base.metadata.create_all)
|
| 27 |
|
| 28 |
# Schema migrations (idempotent — safe to run on every startup)
|
|
|
|
| 22 |
await conn.execute(text("SELECT pg_advisory_xact_lock(1573678846307946496)"))
|
| 23 |
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
|
| 24 |
|
| 25 |
+
# Create application tables (includes `data_catalog`)
|
| 26 |
await conn.run_sync(Base.metadata.create_all)
|
| 27 |
|
| 28 |
# Schema migrations (idempotent — safe to run on every startup)
|
|
@@ -104,8 +104,11 @@ class Catalog(Base):
|
|
| 104 |
`data` holds the full Pydantic Catalog (src/catalog/models.py:Catalog)
|
| 105 |
serialized via `model_dump(mode="json")`. Read path uses
|
| 106 |
`Catalog.model_validate(...)` to rehydrate.
|
|
|
|
|
|
|
|
|
|
| 107 |
"""
|
| 108 |
-
__tablename__ = "
|
| 109 |
|
| 110 |
user_id = Column(String, primary_key=True)
|
| 111 |
data = Column(JSONB, nullable=False)
|
|
|
|
| 104 |
`data` holds the full Pydantic Catalog (src/catalog/models.py:Catalog)
|
| 105 |
serialized via `model_dump(mode="json")`. Read path uses
|
| 106 |
`Catalog.model_validate(...)` to rehydrate.
|
| 107 |
+
|
| 108 |
+
Dedicated table — kept separate from `langchain_pg_embedding` so unstructured
|
| 109 |
+
embeddings and structured-catalog metadata never share storage.
|
| 110 |
"""
|
| 111 |
+
__tablename__ = "data_catalog"
|
| 112 |
|
| 113 |
user_id = Column(String, primary_key=True)
|
| 114 |
data = Column(JSONB, nullable=False)
|
|
@@ -1,6 +1,8 @@
|
|
| 1 |
-
"""Request / response models for catalog-related routes
|
| 2 |
|
| 3 |
-
from
|
|
|
|
|
|
|
| 4 |
|
| 5 |
|
| 6 |
class CatalogRebuildRequest(BaseModel):
|
|
@@ -9,6 +11,17 @@ class CatalogRebuildRequest(BaseModel):
|
|
| 9 |
|
| 10 |
class CatalogRebuildResponse(BaseModel):
|
| 11 |
user_id: str
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Request / response models for catalog-related routes."""
|
| 2 |
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
|
| 7 |
|
| 8 |
class CatalogRebuildRequest(BaseModel):
|
|
|
|
| 11 |
|
| 12 |
class CatalogRebuildResponse(BaseModel):
|
| 13 |
user_id: str
|
| 14 |
+
sources_rebuilt: int
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class CatalogIndexEntry(BaseModel):
|
| 18 |
+
"""One row in the per-user catalog index — used by the refresher to decide
|
| 19 |
+
which sources to rebuild and by the UI to list registered sources.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
source_id: str = Field(..., description="Stable internal source identifier.")
|
| 23 |
+
source_type: str = Field(..., description="schema | tabular | unstructured.")
|
| 24 |
+
name: str = Field(..., description="Display name (DB name or filename).")
|
| 25 |
+
location_ref: str = Field(..., description="URI: dbclient://… or az_blob://…")
|
| 26 |
+
table_count: int = Field(..., description="Number of tables/sheets in this source.")
|
| 27 |
+
updated_at: datetime = Field(..., description="Last time this source was (re)introspected.")
|
|
@@ -1,18 +1,17 @@
|
|
| 1 |
-
"""StructuredPipeline —
|
| 2 |
|
| 3 |
Steps (per source, end-to-end):
|
| 4 |
1. introspect (caller-supplied — DatabaseIntrospector or TabularIntrospector)
|
| 5 |
-
2.
|
| 6 |
-
3.
|
| 7 |
-
4.
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
Source-type-agnostic: the caller picks the introspector. Triggers in
|
| 11 |
`pipeline/triggers.py` know which one to use based on the upload event.
|
| 12 |
-
|
| 13 |
-
Heavy production deps (`CatalogEnricher`, `CatalogStore`) are imported lazily
|
| 14 |
-
inside `default_structured_pipeline()` so unit tests that inject mocks never
|
| 15 |
-
trigger `Settings()` construction at module import.
|
| 16 |
"""
|
| 17 |
|
| 18 |
from __future__ import annotations
|
|
@@ -25,7 +24,6 @@ from src.catalog.models import Catalog, Source
|
|
| 25 |
from src.middlewares.logging import get_logger
|
| 26 |
|
| 27 |
if TYPE_CHECKING:
|
| 28 |
-
from src.catalog.enricher import CatalogEnricher
|
| 29 |
from src.catalog.store import CatalogStore
|
| 30 |
from src.catalog.validator import CatalogValidator
|
| 31 |
|
|
@@ -33,7 +31,7 @@ logger = get_logger("structured_pipeline")
|
|
| 33 |
|
| 34 |
|
| 35 |
class StructuredPipeline:
|
| 36 |
-
"""Orchestrates introspect →
|
| 37 |
|
| 38 |
Dependencies are injected (no concrete imports at class-definition time)
|
| 39 |
so tests can pass mocks without constructing Settings or opening DB
|
|
@@ -42,11 +40,9 @@ class StructuredPipeline:
|
|
| 42 |
|
| 43 |
def __init__(
|
| 44 |
self,
|
| 45 |
-
enricher: CatalogEnricher,
|
| 46 |
validator: CatalogValidator,
|
| 47 |
store: CatalogStore,
|
| 48 |
) -> None:
|
| 49 |
-
self._enricher = enricher
|
| 50 |
self._validator = validator
|
| 51 |
self._store = store
|
| 52 |
|
|
@@ -57,18 +53,17 @@ class StructuredPipeline:
|
|
| 57 |
user_id: str,
|
| 58 |
) -> Source:
|
| 59 |
source = await introspector.introspect(location_ref)
|
| 60 |
-
|
| 61 |
-
merged = await self._merge_with_existing(user_id, enriched)
|
| 62 |
self._validator.validate(merged)
|
| 63 |
await self._store.upsert(merged)
|
| 64 |
logger.info(
|
| 65 |
"structured pipeline complete",
|
| 66 |
user_id=user_id,
|
| 67 |
-
source_id=
|
| 68 |
-
source_type=
|
| 69 |
-
tables=len(
|
| 70 |
)
|
| 71 |
-
return
|
| 72 |
|
| 73 |
async def _merge_with_existing(self, user_id: str, new_source: Source) -> Catalog:
|
| 74 |
existing = await self._store.get(user_id)
|
|
@@ -87,12 +82,10 @@ def default_structured_pipeline() -> StructuredPipeline:
|
|
| 87 |
Lazy imports keep `from src.pipeline.structured_pipeline import …` cheap
|
| 88 |
and side-effect-free for tests.
|
| 89 |
"""
|
| 90 |
-
from src.catalog.enricher import CatalogEnricher
|
| 91 |
from src.catalog.store import CatalogStore
|
| 92 |
from src.catalog.validator import CatalogValidator
|
| 93 |
|
| 94 |
return StructuredPipeline(
|
| 95 |
-
enricher=CatalogEnricher(),
|
| 96 |
validator=CatalogValidator(),
|
| 97 |
store=CatalogStore(),
|
| 98 |
)
|
|
|
|
| 1 |
+
"""StructuredPipeline — builds a catalog for DB / tabular sources.
|
| 2 |
|
| 3 |
Steps (per source, end-to-end):
|
| 4 |
1. introspect (caller-supplied — DatabaseIntrospector or TabularIntrospector)
|
| 5 |
+
2. merge (replace any existing source with the same source_id)
|
| 6 |
+
3. validate (catalog/validator.py)
|
| 7 |
+
4. upsert (catalog/store.py)
|
| 8 |
+
|
| 9 |
+
LLM-driven enrichment was removed: the planner relies on stats + sample
|
| 10 |
+
rows + column names directly. Source/table/column `description` fields stay
|
| 11 |
+
in the model but are not populated by this pipeline.
|
| 12 |
|
| 13 |
Source-type-agnostic: the caller picks the introspector. Triggers in
|
| 14 |
`pipeline/triggers.py` know which one to use based on the upload event.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
"""
|
| 16 |
|
| 17 |
from __future__ import annotations
|
|
|
|
| 24 |
from src.middlewares.logging import get_logger
|
| 25 |
|
| 26 |
if TYPE_CHECKING:
|
|
|
|
| 27 |
from src.catalog.store import CatalogStore
|
| 28 |
from src.catalog.validator import CatalogValidator
|
| 29 |
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
class StructuredPipeline:
|
| 34 |
+
"""Orchestrates introspect → merge → validate → store.
|
| 35 |
|
| 36 |
Dependencies are injected (no concrete imports at class-definition time)
|
| 37 |
so tests can pass mocks without constructing Settings or opening DB
|
|
|
|
| 40 |
|
| 41 |
def __init__(
|
| 42 |
self,
|
|
|
|
| 43 |
validator: CatalogValidator,
|
| 44 |
store: CatalogStore,
|
| 45 |
) -> None:
|
|
|
|
| 46 |
self._validator = validator
|
| 47 |
self._store = store
|
| 48 |
|
|
|
|
| 53 |
user_id: str,
|
| 54 |
) -> Source:
|
| 55 |
source = await introspector.introspect(location_ref)
|
| 56 |
+
merged = await self._merge_with_existing(user_id, source)
|
|
|
|
| 57 |
self._validator.validate(merged)
|
| 58 |
await self._store.upsert(merged)
|
| 59 |
logger.info(
|
| 60 |
"structured pipeline complete",
|
| 61 |
user_id=user_id,
|
| 62 |
+
source_id=source.source_id,
|
| 63 |
+
source_type=source.source_type,
|
| 64 |
+
tables=len(source.tables),
|
| 65 |
)
|
| 66 |
+
return source
|
| 67 |
|
| 68 |
async def _merge_with_existing(self, user_id: str, new_source: Source) -> Catalog:
|
| 69 |
existing = await self._store.get(user_id)
|
|
|
|
| 82 |
Lazy imports keep `from src.pipeline.structured_pipeline import …` cheap
|
| 83 |
and side-effect-free for tests.
|
| 84 |
"""
|
|
|
|
| 85 |
from src.catalog.store import CatalogStore
|
| 86 |
from src.catalog.validator import CatalogValidator
|
| 87 |
|
| 88 |
return StructuredPipeline(
|
|
|
|
| 89 |
validator=CatalogValidator(),
|
| 90 |
store=CatalogStore(),
|
| 91 |
)
|
|
@@ -19,8 +19,7 @@ async def on_db_registered(database_client_id: str, user_id: str) -> None:
|
|
| 19 |
Called by `/api/v1/database-clients/{id}/ingest` (after rewiring in a
|
| 20 |
later PR). The DatabaseIntrospector resolves the client_id to a
|
| 21 |
DatabaseClient row, decrypts credentials, connects, and produces a Source.
|
| 22 |
-
The
|
| 23 |
-
and upserted.
|
| 24 |
"""
|
| 25 |
from src.catalog.introspect.database import database_introspector
|
| 26 |
from src.pipeline.structured_pipeline import default_structured_pipeline
|
|
@@ -40,8 +39,8 @@ async def on_tabular_uploaded(document_id: str, user_id: str) -> None:
|
|
| 40 |
|
| 41 |
Called after a CSV/XLSX/Parquet file has been processed and its Parquet
|
| 42 |
blob(s) uploaded. The TabularIntrospector downloads the original blob,
|
| 43 |
-
profiles each column, and produces a Source. The
|
| 44 |
-
|
| 45 |
"""
|
| 46 |
from src.catalog.introspect.tabular import tabular_introspector
|
| 47 |
from src.pipeline.structured_pipeline import default_structured_pipeline
|
|
@@ -65,6 +64,10 @@ async def on_document_uploaded(document_id: str, user_id: str) -> None:
|
|
| 65 |
|
| 66 |
|
| 67 |
async def on_catalog_rebuild_requested(user_id: str) -> None:
|
| 68 |
-
"""Stub — re-runs every source for a user
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
raise NotImplementedError
|
|
|
|
| 19 |
Called by `/api/v1/database-clients/{id}/ingest` (after rewiring in a
|
| 20 |
later PR). The DatabaseIntrospector resolves the client_id to a
|
| 21 |
DatabaseClient row, decrypts credentials, connects, and produces a Source.
|
| 22 |
+
The catalog is then validated and upserted (no LLM enrichment step).
|
|
|
|
| 23 |
"""
|
| 24 |
from src.catalog.introspect.database import database_introspector
|
| 25 |
from src.pipeline.structured_pipeline import default_structured_pipeline
|
|
|
|
| 39 |
|
| 40 |
Called after a CSV/XLSX/Parquet file has been processed and its Parquet
|
| 41 |
blob(s) uploaded. The TabularIntrospector downloads the original blob,
|
| 42 |
+
profiles each column, and produces a Source. The catalog is then validated
|
| 43 |
+
and upserted (no LLM enrichment step).
|
| 44 |
"""
|
| 45 |
from src.catalog.introspect.tabular import tabular_introspector
|
| 46 |
from src.pipeline.structured_pipeline import default_structured_pipeline
|
|
|
|
| 64 |
|
| 65 |
|
| 66 |
async def on_catalog_rebuild_requested(user_id: str) -> None:
|
| 67 |
+
"""Stub — re-runs every source for a user (catalog refresher).
|
| 68 |
+
|
| 69 |
+
Implemented when the bulk refresh script lands. Expected to iterate over
|
| 70 |
+
every Source in the user's current catalog, re-introspect it, and upsert
|
| 71 |
+
the refreshed result.
|
| 72 |
+
"""
|
| 73 |
raise NotImplementedError
|
|
@@ -2,16 +2,12 @@
|
|
| 2 |
|
| 3 |
Renders the catalog into a compact textual form that fits the LLM context
|
| 4 |
window. For users with ≤50 tables the full catalog goes in verbatim.
|
| 5 |
-
|
| 6 |
-
Reuses `catalog.enricher.render_source` so the planner sees the same
|
| 7 |
-
source-rendering format as the enricher does at ingestion time — keeping
|
| 8 |
-
catalog descriptions consistent across both LLM call sites.
|
| 9 |
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
-
from ...catalog.enricher import render_source
|
| 14 |
from ...catalog.models import Catalog
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
def render_catalog(catalog: Catalog) -> str:
|
|
|
|
| 2 |
|
| 3 |
Renders the catalog into a compact textual form that fits the LLM context
|
| 4 |
window. For users with ≤50 tables the full catalog goes in verbatim.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
|
|
| 9 |
from ...catalog.models import Catalog
|
| 10 |
+
from ...catalog.render import render_source
|
| 11 |
|
| 12 |
|
| 13 |
def render_catalog(catalog: Catalog) -> str:
|
|
@@ -3,6 +3,6 @@
|
|
| 3 |
Cross-cutting security primitives:
|
| 4 |
- credential encryption (Fernet) for stored DB credentials
|
| 5 |
- authentication / password / JWT helpers
|
| 6 |
-
- PII detection patterns used by the catalog
|
| 7 |
|
| 8 |
Consolidates utilities previously split between `utils/` and `users/`.
|
|
|
|
| 3 |
Cross-cutting security primitives:
|
| 4 |
- credential encryption (Fernet) for stored DB credentials
|
| 5 |
- authentication / password / JWT helpers
|
| 6 |
+
- PII detection patterns used by the catalog introspectors
|
| 7 |
|
| 8 |
Consolidates utilities previously split between `utils/` and `users/`.
|