Rifqi Hafizuddin Claude Opus 4.7 commited on
Commit
2d6eca0
·
1 Parent(s): ce20d89

[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 CHANGED
@@ -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 four places:
99
 
100
  1. **`IntentRouter`** — once per user message
101
- 2. **`CatalogEnricher`** — once per source, at ingestion (not query time)
102
- 3. **`QueryPlanner`** — once per structured query (produces the IR)
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.
PROGRESS.md CHANGED
@@ -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-08 (items 16,31,35,36,41 done; Phase 1 remnants deleted: query/executors/, query_executor.py, agents/orchestration.py)
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
- | — | `catalogs` Postgres jsonb table (`db/postgres/models.py`) | `[x]` | PR1 added `Catalog` SQLAlchemy class + `init_db.py` import |
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 (`catalog/enricher.py`, `config/prompts/catalog_enricher.md`) | B | `[x]` | PR2a (DB owner picked up) Azure OpenAI GPT-4o, structured output (flat `EnrichmentResponse` keyed by stable IDs), source-type-agnostic prompt with PII suppression and FK rendering. LLM is constructor-injectable for tests. |
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) — `introspect → enrich → merge with existing → validate → upsert`. Source-type-agnostic: caller supplies the introspector. `default_structured_pipeline()` factory wires production deps lazily so tests can inject mocks without `Settings()` construction. |
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)` reuses `catalog.enricher.render_source` so both LLM call sites see the same source format. |
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 (PR2b/4/5/6/7-bundle — DB owner solo, teammate reviews)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.
REPO_CONTEXT.md CHANGED
@@ -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/enricher.py` | one LLM call per sourceadds AI descriptions at source/table/column |
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 four 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
- (`CatalogEnricher` + `QueryPlanner` are the other two LLM call sites — both live under `catalog/` and `query/planner/`.)
 
 
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`, `catalog_enricher`, `query_planner`, `chatbot_system`, `guardrails` |
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 four LLM call sites in the system:
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 | `catalog/enricher.py`, `config/prompts/catalog_enricher.md` | B | Whoever picks it up first; the other reviews. Prompt must work uniformly across source types |
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 |
main.py CHANGED
@@ -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")
src/api/v1/data_catalog.py ADDED
@@ -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
+ ]
src/catalog/introspect/base.py CHANGED
@@ -1,7 +1,8 @@
1
  """BaseIntrospector — contract for source-specific schema readers.
2
 
3
- Subclasses produce a draft Source object with raw schema (names + types +
4
- sample values). The CatalogEnricher then adds descriptions in a separate step.
 
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
src/catalog/introspect/database.py CHANGED
@@ -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. Does NOT generate descriptions
5
- (that happens in CatalogEnricher).
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`)
src/catalog/introspect/tabular.py CHANGED
@@ -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 — same
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__(
src/catalog/{enricher.py → render.py} RENAMED
@@ -1,92 +1,16 @@
1
- """CatalogEnricher runs 1 LLM call per source to generate AI descriptions.
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 that the enricher prompt expects.
88
 
89
- Public so tests and the planner-prompt builder can reuse the same format.
 
 
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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/config/prompts/catalog_enricher.md DELETED
@@ -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.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/db/postgres/init_db.py CHANGED
@@ -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)
src/db/postgres/models.py CHANGED
@@ -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__ = "catalogs"
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)
src/models/api/catalog.py CHANGED
@@ -1,6 +1,8 @@
1
- """Request / response models for catalog-related routes (e.g. /knowledge/rebuild)."""
2
 
3
- from pydantic import BaseModel
 
 
4
 
5
 
6
  class CatalogRebuildRequest(BaseModel):
@@ -9,6 +11,17 @@ class CatalogRebuildRequest(BaseModel):
9
 
10
  class CatalogRebuildResponse(BaseModel):
11
  user_id: str
12
- sources_enriched: int
13
- tables_enriched: int
14
- columns_enriched: int
 
 
 
 
 
 
 
 
 
 
 
 
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.")
src/pipeline/structured_pipeline.py CHANGED
@@ -1,18 +1,17 @@
1
- """StructuredPipeline — runs catalog enrichment for DB / tabular sources.
2
 
3
  Steps (per source, end-to-end):
4
  1. introspect (caller-supplied — DatabaseIntrospector or TabularIntrospector)
5
- 2. enrich (catalog/enricher.py 1 LLM call per source)
6
- 3. merge (replace any existing source with the same source_id)
7
- 4. validate (catalog/validator.py)
8
- 5. upsert (catalog/store.py)
 
 
 
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 → enrich → merge → validate → store.
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
- enriched = await self._enricher.enrich(source)
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=enriched.source_id,
68
- source_type=enriched.source_type,
69
- tables=len(enriched.tables),
70
  )
71
- return enriched
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
  )
src/pipeline/triggers.py CHANGED
@@ -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 CatalogEnricher then fills in descriptions, the catalog is validated
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 CatalogEnricher then fills
44
- in descriptions, the catalog is validated and upserted.
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, useful after enricher prompt
69
- changes. Implemented when the bulk re-enrichment script lands."""
 
 
 
 
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
src/query/planner/prompt.py CHANGED
@@ -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:
src/security/README.md CHANGED
@@ -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 enricher
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/`.