Rifqi Hafizuddin Claude Opus 4.8 commited on
Commit
4835fb8
·
1 Parent(s): e0384e1

chore(docs): tidy root markdown — archive stale docs, delete superseded ones

Browse files

Archive (moved to local gitignored docs/_archive/): ARCHITECTURE, AGENT_ARCHITECTURE_CONTEXT_new, PROGRESS, CHECKPOINT_PLAN_2026-06-17, ORCHESTRATOR_REWORK_PLAN. Delete (stale/superseded by REPO_STATUS.md + DEV_PLAN.md): REPO_CONTEXT, PROJECT_SUMMARY, PHASE1_TO_PHASE2_REPORT. Root now keeps README + REPO_STATUS + DEV_PLAN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

.gitignore CHANGED
@@ -53,6 +53,5 @@ migratego/
53
  docs/specs/tabular_parquet_contract.md
54
  docs/specs/tabular_parquet.md
55
 
56
- # Personal / local working docs (not for the shared repo)
57
- AGENT_ARCHITECTURE_CONTEXT_new.md
58
- PROJECT_SUMMARY.md
 
53
  docs/specs/tabular_parquet_contract.md
54
  docs/specs/tabular_parquet.md
55
 
56
+ # Personal / local working docs (not for the shared repo) — archived out of root
57
+ docs/_archive/
 
ARCHITECTURE.md DELETED
@@ -1,353 +0,0 @@
1
- # Architecture — Data Eyond Agentic Service
2
-
3
- **Last updated**: 2026-05-20
4
- **Status**: Phase 2 catalog path shipped; document ingestion has moved to a separate Go service. The long-term split is **Python = agent/ML layer, Go = data plane**; this document covers the Python side only.
5
-
6
- ---
7
-
8
- ## Product vision (north star)
9
-
10
- Data Eyond is an *AI data scientist* for business analytics, structured around **CRISP-DM** (Business Understanding → Data Understanding → Data Preparation → Modeling → Evaluation → Deployment). Targets executives doing self-serve deep-dives and data analysts/scientists offloading routine work.
11
-
12
- Envisioned user flow: **interview agent** captures goal → user connects data sources → asks natural-language question → CRISP-DM-structured analytical response, exportable as a **presentation** or **notebook-style report**.
13
-
14
- The catalog-driven, IR-based architecture documented below is the *foundation*. The next architectural evolution is an agentic layer (analytical planner, per-stage CRISP-DM agents, evaluator, reporter) that consumes the existing IntentRouter → QueryPlanner → Executor → ChatbotAgent spine as its tool layer. See `REPO_CONTEXT.md` → *Roadmap — agentic evolution* for the target agent topology.
15
-
16
- ---
17
-
18
- ## TL;DR
19
-
20
- A catalog-driven AI service for data analysis. Users upload documents and register databases or tabular files; they ask natural-language questions and get answers grounded in their data.
21
-
22
- The architecture has two paths:
23
-
24
- - **Unstructured** (PDF, DOCX, TXT) — dense similarity over prose chunks (the right primitive for free-form text). **Ingestion is handled by a separate Go service**; this Python service reads embeddings from PGVector at query time.
25
- - **Structured** (databases, XLSX, CSV, Parquet) — a per-user **data catalog** describes what tables/columns exist; an LLM produces a structured **JSON intermediate representation (IR)** of the user's intent; a deterministic compiler turns the IR into SQL or pandas operations.
26
-
27
- The LLM produces *intent*, not query syntax. Deterministic code does the rest.
28
-
29
- ---
30
-
31
- ## 1. Why catalog-driven design
32
-
33
- For a database or spreadsheet, a user's question maps to *known tables and columns* — not to *similar text fragments*. Treating structured data with the same retrieval primitive as prose (chunk + embed + rank top-K) makes the right column survive a probabilistic ranking lottery. Catalog-based **lookup** is the right primitive instead.
34
-
35
- A central per-user catalog also means:
36
-
37
- - One place to keep table/column descriptions (AI-generated, refreshed when the source changes).
38
- - The query planner sees the user's full data landscape in a single prompt.
39
- - Schema stays stable across user sessions without hitting the source DB on every query.
40
- - New sources auto-update the catalog without re-embedding chunks.
41
-
42
- ---
43
-
44
- ## 2. Source taxonomy
45
-
46
- ```
47
- Sources
48
- ├── Unstructured (pdf, docx, txt) → Cu (prose chunks via DocumentRetriever)
49
- └── Structured
50
- ├── Schema (DB) → Cs (DB tables + columns)
51
- └── Tabular (xlsx, csv, parquet) → Ct (sheets + columns)
52
- Cs ∪ Ct = Data Catalog Context
53
- ```
54
-
55
- - **Cu** = unstructured prose context. Retrieval primitive: dense similarity over chunks.
56
- - **Cs** = DB schema context (tables, columns, descriptions, sample values).
57
- - **Ct** = tabular file context (sheets, columns, descriptions, sample values).
58
- - **Data Catalog Context** = `Cs ∪ Ct`. Passed to the query planner as a single unified view.
59
-
60
- DB vs tabular is **not** a routing concern — it's a per-source attribute (`source_type`) on each catalog entry. The split only matters at execution time (SQL vs pandas).
61
-
62
- ---
63
-
64
- ## 3. Routing model
65
-
66
- > **Superseded 2026-06-18** — the 3-way `source_hint` below was reworked into a flat **6-intent** handler router (`chat`, `help`, `problem_statement`, `check`, `unstructured_flow`, `structured_flow`). Modality (structured vs unstructured *data*) is now the Planner's job, not the router's. See `ORCHESTRATOR_REWORK_PLAN.md`.
67
-
68
- ```
69
- source_hint ∈ { chat, unstructured, structured }
70
- ```
71
-
72
- - `chat` — no search, conversational reply only
73
- - `unstructured` — DocumentRetriever path (Cu)
74
- - `structured` — catalog-driven path (Cs ∪ Ct → planner → compiler → executor)
75
-
76
- The router commits to one path. Cross-source questions ("compare DB sales vs uploaded customer file") are handled inside the structured path because the planner sees both Cs and Ct in one prompt.
77
-
78
- ---
79
-
80
- ## 4. Core architectural decisions
81
-
82
- ### 4.1 Catalog as primary context, not retrieval
83
-
84
- For most users (≤50 tables), the entire catalog fits in ~3-5k tokens and is passed verbatim to the planner. No vector search, no BM25, no chunk retrieval. The LLM reads the whole catalog and picks the right table.
85
-
86
- When a user has hundreds of tables, **catalog-level retrieval** (BM25 + table-level vectors with RRF) can be added as a slicer between `CatalogReader` and `Planner`. Deferred until measurably needed.
87
-
88
- ### 4.2 JSON IR over raw SQL
89
-
90
- The planner LLM emits a structured JSON IR describing query intent — not a SQL string. A deterministic compiler turns the IR into SQL (per dialect) or pandas/polars operations.
91
-
92
- Benefits:
93
-
94
- - Validatable with Pydantic before execution
95
- - Compiler whitelists allowed operations (no DROP, DELETE, etc.)
96
- - Portable: same IR → SQL (any dialect) / pandas / polars
97
- - Cheaper tokens, easier to debug, trivially testable without an LLM
98
- - LLM cannot emit valid-but-wrong SQL syntax
99
-
100
- ### 4.3 Deterministic compiler, not LLM SQL writer
101
-
102
- The LLM produces *intent* (the IR). All actual query construction is deterministic Python. Compiler bugs are reproducible and fixable. Same IR always produces the same query.
103
-
104
- ### 4.4 Pipeline stage isolation
105
-
106
- Each stage is its own module with typed input and typed output. No god classes. Stages: `IntentRouter`, `CatalogReader`, `QueryPlanner`, `IRValidator`, `QueryCompiler`, `QueryExecutor`, `ChatbotAgent`. Each is testable in isolation.
107
-
108
- ### 4.5 Minimal LLM surface
109
-
110
- 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):
111
-
112
- 1. **`IntentRouter`** — once per user message
113
- 2. **`QueryPlanner`** — once per structured query (produces the IR)
114
- 3. **`ChatbotAgent`** — once per answer (formats the response)
115
-
116
- Compiler and executors are pure code. No LLM in the hot path of query construction.
117
-
118
- ---
119
-
120
- ## 5. End-to-end flow
121
-
122
- ### Ingestion (when user uploads a file or connects a DB)
123
-
124
- ```
125
- Structured sources (DB connect / XLSX / CSV / Parquet upload) — Python:
126
- source upload / DB connect
127
-
128
- introspect schema (DB: information_schema; tabular: file headers + sample rows)
129
-
130
- validate (Pydantic)
131
-
132
- write to catalog store (Postgres jsonb in `data_catalog`, keyed by user_id)
133
- ```
134
-
135
- **Unstructured ingestion (PDF / DOCX / TXT) is handled by a separate Go service**, which writes chunks + embeddings into the `documents` collection in PGVector. The Python service does not own this path — it reads only.
136
-
137
- ### Query (per user message)
138
-
139
- ```
140
- User message
141
-
142
- Chat cache check (Redis, 24h TTL)
143
- ↓ miss
144
- Load chat history
145
-
146
- IntentRouter LLM → needs_search? source_hint?
147
-
148
- ├── chat → ChatbotAgent → SSE stream
149
- ├── unstructured → DocumentRetriever (raw SQL: pgvector `<=>` cosine or `<+>` manhattan) → answerer
150
- └── structured →
151
- CatalogReader (load full Cs ∪ Ct for user)
152
-
153
- QueryPlanner LLM → JSON IR
154
-
155
- IRValidator (Pydantic + columns-exist + ops whitelist)
156
-
157
- QueryCompiler → SQL (schema source) or pandas (tabular source)
158
-
159
- QueryExecutor (DbExecutor or TabularExecutor)
160
-
161
- QueryResult
162
-
163
- ChatbotAgent → SSE stream
164
- ```
165
-
166
- ---
167
-
168
- ## 6. Data catalog
169
-
170
- ### Storage
171
-
172
- Per-user JSON document, stored as a `jsonb` row in Postgres keyed by `user_id`.
173
-
174
- ### Schema (initial scope)
175
-
176
- ```
177
- Catalog
178
- ├── user_id, schema_version, generated_at
179
- └── sources[]
180
- └── Source
181
- ├── source_id, source_type, name, description, location_ref, updated_at
182
- └── tables[]
183
- └── Table
184
- ├── table_id, name, description, row_count
185
- └── columns[]
186
- └── Column
187
- ├── column_id, name, data_type, description
188
- ├── nullable
189
- ├── pii_flag
190
- ├── sample_values[]
191
- └── stats: { min, max, distinct_count } | null
192
- ```
193
-
194
- ### Best-practice fields deferred
195
-
196
- `description_human`, `synonyms[]`, `tags[]`, `primary_key`, `foreign_keys`, `unit`, `semantic_type`, `example_questions[]`, `schema_hash`, `enrichment_status`. Add when justified by user need.
197
-
198
- ### Stable IDs
199
-
200
- `source_id`, `table_id`, `column_id` are stable internal references. `name` fields can change (e.g. column rename in source DB) without invalidating cached IRs.
201
-
202
- ### PII handling
203
-
204
- Columns with `pii_flag: true` have `sample_values: null` — real values never enter LLM prompts. Auto-detected at ingestion via name patterns + value regex.
205
-
206
- ---
207
-
208
- ## 7. JSON IR
209
-
210
- ### Schema (initial scope)
211
-
212
- ```
213
- QueryIR
214
- ├── ir_version : "1.0"
215
- ├── source_id : str (references catalog)
216
- ├── table_id : str (references catalog)
217
- ├── select[] : SelectItem
218
- │ ├── { kind: "column", column_id, alias? }
219
- │ └── { kind: "agg", fn, column_id?, alias? }
220
- ├── filters[] : { column_id, op, value, value_type }
221
- ├── group_by[] : column_id
222
- ├── order_by[] : { column_id | alias, dir }
223
- └── limit : int | null
224
- ```
225
-
226
- ### Whitelisted operators
227
-
228
- ```
229
- Filter ops: = != < <= > >= in not_in is_null is_not_null like between
230
- Agg fns: count count_distinct sum avg min max
231
- ```
232
-
233
- ### Validation rules (enforced before execution)
234
-
235
- - `source_id` exists in catalog for this user
236
- - `table_id` belongs to that source
237
- - Every `column_id` exists in that table
238
- - Every `agg.fn` and `filter.op` is whitelisted
239
- - `value_type` consistent with column's `data_type`
240
- - `limit` positive int, ≤ hard cap (e.g. 10000)
241
-
242
- If any rule fails → reject IR → re-prompt planner with error context (max 3 retries).
243
-
244
- ### Deferred features
245
-
246
- `having`, `offset`, boolean tree filters (OR/NOT), `distinct`, joins, window functions. Add as user demand proves the limitation.
247
-
248
- ---
249
-
250
- ## 8. Executors
251
-
252
- Same input (validated IR), same output (`QueryResult`), different backends.
253
-
254
- ### DbExecutor (schema sources)
255
-
256
- ```
257
- IR → SqlCompiler → SQL string + params
258
-
259
- sqlglot validation (SELECT-only, whitelist tables/columns, LIMIT enforced)
260
-
261
- asyncpg / pymysql in read-only transaction with timeout (30s)
262
-
263
- QueryResult
264
- ```
265
-
266
- Identifiers come from catalog (verified at validation time, safe to inline as quoted identifiers). Values are always parameterized — never inlined as strings.
267
-
268
- ### TabularExecutor (tabular sources)
269
-
270
- ```
271
- IR → PandasCompiler → operation chain
272
-
273
- choose strategy by file size:
274
- ≤ 100 MB → eager pandas
275
- 100 MB-1 GB → pyarrow with predicate pushdown
276
- > 1 GB → polars lazy scan
277
-
278
- execute in asyncio.to_thread (CPU work off the event loop)
279
-
280
- QueryResult
281
- ```
282
-
283
- Initially eager pandas is sufficient. Add the others when a real file is too big.
284
-
285
- ### Shared safety guarantees
286
-
287
- 1. IR validated before reaching compiler
288
- 2. Compiler is deterministic (no LLM)
289
- 3. Identifiers from catalog (trusted)
290
- 4. Values parameterized
291
- 5. sqlglot second-line defence for SQL
292
- 6. Read-only at every layer
293
- 7. Timeouts and row caps
294
-
295
- ---
296
-
297
- ## 9. Implementation scope
298
-
299
- ### Initial PR — what ships first
300
-
301
- | Item | Folder |
302
- |---|---|
303
- | Data catalog Pydantic models | `src/catalog/models.py` |
304
- | Catalog ingestion (introspect → enrich → validate → store) | `src/catalog/`, `src/pipeline/` |
305
- | `IntentRouter` with 3-way source_hint | `src/agents/` |
306
- | `CatalogReader` (loads full catalog) | `src/catalog/reader.py` |
307
- | `QueryPlanner` LLM call | `src/query/planner/` |
308
- | JSON IR Pydantic models | `src/query/ir/models.py` |
309
- | IR validator | `src/query/ir/validator.py` |
310
-
311
- **Output**: a validated JSON IR object. Execution lands in a follow-up PR.
312
-
313
- ### Follow-up PRs
314
-
315
- | PR | Scope |
316
- |---|---|
317
- | 2 | `QueryCompiler` (IR → SQL / pandas) |
318
- | 3 | `QueryExecutor` split: `DbExecutor` + `TabularExecutor` |
319
- | 4 | Retry / self-correction loop on execution failure |
320
- | 5 | Eval harness (golden question→IR→result examples) |
321
- | 6 | Auto PII tagging in catalog |
322
- | Later | Joins in IR, schema drift detection, hybrid catalog search |
323
-
324
- ---
325
-
326
- ## 10. Open questions
327
-
328
- | # | Question | Why it matters |
329
- |---|---|---|
330
- | 1 | Catalog storage: JSON file per user vs Postgres `jsonb` row? | Affects ingestion + read performance |
331
- | 2 | Should the catalog also list unstructured files (with descriptions only)? | Gives router unified view of all user sources |
332
- | 3 | Catalog refresh trigger: explicit "rebuild" button, on every upload, or background TTL? | Staleness vs latency tradeoff |
333
- | 4 | Confirm joins are out of initial IR scope? | Limits what user questions can be answered |
334
- | 5 | PII handling for sample_values: mask, synthesize, or skip? | Affects what gets sent to LLM prompts |
335
-
336
- ---
337
-
338
- ## 11. References
339
-
340
- - `docs/flowchart.html` — interactive end-to-end diagram (open in browser)
341
- - `docs/flowchart.mmd` — mermaid source for the diagram
342
-
343
- ---
344
-
345
- ## Glossary
346
-
347
- - **Cu** — unstructured context (prose chunks)
348
- - **Cs** — schema context (DB tables/columns from catalog)
349
- - **Ct** — tabular context (file sheets/columns from catalog)
350
- - **IR** — intermediate representation (the JSON query shape)
351
- - **PR** — pull request (a unit of code change)
352
- - **PII** — personally identifiable information (names, emails, etc.)
353
- - **ABC** — abstract base class (Python contract for subclasses)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
CHECKPOINT_PLAN_2026-06-17.md DELETED
@@ -1,147 +0,0 @@
1
- # Checkpoint Plan — Wednesday, 17 June 2026
2
-
3
- Working plan for Sofhia & Rifqi based on the checkpoint with mas Harry on **Thursday, 11 June 2026**.
4
- Goal: everything below is **merged and demo-able before the next sync on Wednesday, 17 June (afternoon)**.
5
-
6
- **Updated at: Friday, 12 June 2026** (Sofhia + Rifqi)
7
-
8
- > Source of truth for decisions is the meeting itself. Note: the NotebookLM summary is **stale on two points** — Data Availability Check was *eliminated* as a tool, and Success Metrics was *folded into* the Problem Statement template. Do not build either as a standalone skill.
9
-
10
- ---
11
-
12
- ## 0. Progress (per Fri 12 Jun — Sofhia)
13
-
14
- Dated snapshot of what landed this session. Live task status (incl. what's left) lives in §2 Ownership — this section only records the deltas + traceability.
15
-
16
- - ✅ **Tool matrix** built (xlsx, all ~10 tools + status colours) — presentation material ready.
17
- - ✅ **Registry trimmed to 4 active analytics** (`KM-641`, commit `66e2e4d`): `ACTIVE_ANALYTICS_TOOLS` (descriptive, aggregate, correlation, trend) vs `DEFERRED_ANALYTICS_TOOLS` (comparison, contribution, profile, segment) — specs + compute fns kept, only registry exposure withheld. Tests 206 pass, ruff/mypy clean.
18
- - ✅ **Planner few-shot synced**: Example A `analyze_contribution` → `analyze_aggregate` (so few-shots don't reference a deferred tool).
19
- - ✅ **Data-access tools renamed** (`KM-642`, commit `c38c0c2`): `query_structured` → `data_retrieve`, `retrieve_documents` → `knowledge_retrieve` across the tool layer + planner stub/prompt/validator/few-shots. Mechanical, no behavior change.
20
- - ✅ **`data_check` merge + `knowledge_check`** (`KM-643`, commit `4bd5f1e`): `list_sources` + `describe_source` → one parameterized `data_check` (no arg = list structured sources; `source_id` = schema) + new `knowledge_check` (unstructured). Tests 206 pass.
21
- - ✅ **Redis Cloud live** (free tier, TTL = 1 h), env vars shared in the group (Rifqi).
22
- - ✅ **Planner tool list verified** against the trimmed registry — no references to old tool names or deferred analytics anywhere in `src/` (Rifqi).
23
- - 📌 **Decision:** `tests/` stays gitignored — team decided not to push tests to origin (closes PROGRESS.md R3 as won't-do).
24
- - 📌 **Ownership:** Rifqi owns `generate_report` development + the `analysis_records` table / real `AnalysisStore` (contract still co-designed with Sofhia).
25
- - ✅ **R5 cache fix** (Rifqi, `b701e95`): chat cache scoped by `user_id`, TTL 24h→1h.
26
- - ✅ **AnalysisRecord persistence landed** (Rifqi): `stage` now flows to the record (CRISP-DM grouping for the report) + identity fields (`record_id`/`analysis_id`/`user_id`); `PostgresAnalysisStore` + `analysis_records` table replace `NullAnalysisStore`, wired into `ChatHandler`. Unblocks the `generate_report` renderer and the DoD "record persisted" step. Open: `analysis_id` handoff from Harry's Analysis State.
27
- - ✅ **Verb-first tool naming** (Sofhia, commit `2d6406d`): the 4 data/knowledge tools renamed to lead with a verb — `data_check`→`check_data`, `knowledge_check`→`check_knowledge`, `data_retrieve`→`retrieve_data`, `knowledge_retrieve`→`retrieve_knowledge` (the `analyze_*` tools already lead with a verb). These verb-first names are now canonical; the tool-set table + §3 below use them. Dated log entries above keep the old names as historical record.
28
-
29
- ---
30
-
31
- ## 1. Locked decisions (from the 2026-06-11 checkpoint)
32
-
33
- 1. **Single chat page.** The separate interview/survey page is killed. Sidebar = Knowledge menu (connect/manage data) + Analysis menu (sessions).
34
- 2. **Data-first hard gate.** Creating a new analysis requires **≥ 1 bound data source** (server-side rejection, no empty sessions). User provides title + optional short description.
35
- 3. **Analysis State lives in the DB.** Per-analysis row: `user_id`, `data_source_ids[]`, `interview_status` (default `not_pass`), `report_status` (default `no_report` → `V1`, `V2`, …). Explicitly **NOT cached, NOT in Redis** — the Orchestrator reads it from Postgres every turn.
36
- 4. **Skills, not agents.** No separate interview agent. The Orchestrator routes per user turn using the Analysis State; an analytical request still executes through the existing Planner → TaskRunner → Assembler spine (static plan, no mid-run LLM).
37
- 5. **Interview = one skill: Problem Statement.** Success metrics become fields inside the PS template (what to increase/decrease + target). Data availability check is handled by the data-first creation gate + PS validation cross-checking fields against the bound catalog — not a separate tool.
38
- 6. **Analytics focus = 4 tools:** descriptive, aggregate, correlation, trend. The other four composites (comparison, contribution, profile, segment) are **deprioritized, not deleted** — keep the code, just don't register them. If "comparison" returns later it should be a proper statistical **test**, not a generic compare.
39
- 7. **`describe_source` merges into the listing tool** — one call returns sources *with* their schema/metadata, fewer tools for the planner.
40
- 8. **Report = on-demand, button-triggered (not a chat skill).** A dedicated "Generate Report" button in the Analysis menu calls a **report API** (not the chat route): trigger generation for a session, list its versions, fetch a version. Renders from accumulated **AnalysisRecords + the Problem Statement** — never from chat history. Each report is a **persisted, versioned artifact**: generation snapshots the record IDs it used and bumps `report_status` to `V<n>`. (Owner: Rifqi, KM-644.)
41
- 9. **Help = deterministic guide.** No LLM: read Analysis State → tell the user the next required step. Callable in any state.
42
- 10. **Redis Cloud free tier, TTL = 1 hour**, env shared in the team group — for retrieval/query caching only, never for state.
43
-
44
- ### Final tool set (~10)
45
-
46
- | Tool (canonical, verb-first) | Maps to (lineage) | Status |
47
- |---|---|---|
48
- | `check_knowledge` | new — list user's documents + metadata | done |
49
- | `check_data` | `list_sources` + `describe_source` merged (catalog-backed) | done |
50
- | `retrieve_knowledge` | `retrieve_documents` → `knowledge_retrieve` | done |
51
- | `retrieve_data` | `query_structured` → `data_retrieve` (tabular: file + DB, both working) | done |
52
- | `analyze_descriptive` | `src/tools/analytics/descriptive.py` | done |
53
- | `analyze_aggregate` | `src/tools/analytics/aggregation.py` | done |
54
- | `analyze_correlation` | `src/tools/analytics/relationship.py` | done |
55
- | `analyze_trend` | `src/tools/analytics/temporal.py` | done |
56
- | `problem_statement` | new — interview skill (**Harry**) | Harry |
57
- | `generate_report` | new — on-demand, versioned | to design |
58
- | `help` | new — deterministic state guide | to build |
59
-
60
- (`problem_statement` + `help` live at the orchestrator level; `generate_report` is **button-triggered via a dedicated report API**, not chat-routed (decision #8). The TaskRunner registry holds the 4 analytics + 4 data/knowledge tools. Unregister `analyze_comparison`, `analyze_contribution`, `analyze_profile`, `analyze_segment` from the planner-visible registry — keep the modules.)
61
-
62
- ---
63
-
64
- ## 2. Ownership
65
-
66
- ### Sofhia
67
- - [x] 4 analytics tools: trim registry to 4 active, tests still pass after deprioritizing the other four. (`KM-641`, commit `66e2e4d`)
68
- - [x] Data/knowledge tools: merge `describe_source` into `data_check`, rename `retrieve_documents` → `knowledge_retrieve`, `query_structured` → `data_retrieve`, build `knowledge_check`. (`KM-642` `c38c0c2`, `KM-643` `4bd5f1e`)
69
- - [ ] Co-design `generate_report` contract with Rifqi (Rifqi owns development, see §3).
70
- - [x] Tool matrix (see §4).
71
-
72
- ### Rifqi
73
- - [x] **Redis Cloud free tier** (~30–50 MB): create instance, set TTL = 1 h, share env vars in the group. (done 12 Jun)
74
- - [x] **R5 cache fix**: chat cache key scoped by `user_id`, TTL 24h→1h (urgent on shared Redis). (12 Jun, commit `b701e95`)
75
- - [x] **AnalysisRecord contract gaps closed**: `stage` (CRISP-DM) now flows Task→TaskResult→TaskSummary so the report can group the method appendix; `AnalysisRecord` gained `record_id`/`analysis_id`/`user_id` identity fields. (12 Jun)
76
- - [x] **`analysis_records` table + real `AnalysisStore`**: `PostgresAnalysisStore` (save + `list_for_analysis`, never-throw) replaces `NullAnalysisStore`; wired into `ChatHandler`, `user_id` stamped at save. Satisfies the DoD "record persisted" step. (12 Jun)
77
- - [ ] **Own `generate_report` development — KM-644 "Report Generator"** (contract co-designed with Sofhia, see §3). Button-triggered via a dedicated **report API** (trigger / list versions / fetch); reads `analysis_records` + Problem Statement; persists a versioned report artifact, bumps `report_status`. *(record persistence done above; report API + persistence + renderer + contract doc next)*
78
- - [x] Verify planner tool list matches the trimmed registry (4 analytics + 4 data/knowledge) and few-shots don't reference removed tools. (verified 12 Jun — no stale tool names in `src/`)
79
- - ⚠️ **Blocked-on-Harry**: `analysis_id` is `NULL` on persisted records until the Analysis State reaches the slow path — need the session-ID handoff so `generate_report` can group records per analysis.
80
-
81
- ### Shared (Sofhia + Rifqi)
82
- - [ ] `generate_report` design + skeleton: input = AnalysisRecords for the session + Problem Statement from Analysis State; output = versioned artifact; bumps `report_status`. Agree on the contract even if rendering is stubbed for Wednesday. (Development: Rifqi.)
83
- - [ ] `help` skill: deterministic — read Analysis State, return the next required step. Small, do it together or whoever finishes first.
84
- - [ ] Tool behavior smoke test end-to-end on an easy case (descriptive/aggregate path), per Harry's ask: "robust tools before agents."
85
-
86
- ### Harry (dependencies — not ours, but we block on them)
87
- - `problem_statement` skill + PS template (incl. increase/decrease target fields).
88
- - Analysis State class + DB table, frontend analysis-builder step.
89
- - Merging our PRs (he auto-merges; he clones from latest after).
90
-
91
- ---
92
-
93
- ## 3. Per-tool behavior contract (how to build each one)
94
-
95
- Harry's framing: for every tool, define **goal / trigger / input / process / output**, and behave like a Claude-style skill — if a required argument is missing, respond with a polite feedback message asking for it (e.g. table/column name), never guess silently.
96
-
97
- - **`check_knowledge`** — "what documents do I have?" → list documents with name, type, uploaded-at.
98
- - **`check_data`** — "what data do I have?" → sources (file + DB) with schema/metadata from the data catalog, created/uploaded timestamps.
99
- - **`retrieve_knowledge`** — RAG over uploaded documents; returns passages with source attribution.
100
- - **`retrieve_data`** — query tabular data (file + DB) via QueryIR; output consumable by the `analyze_*` tools.
101
- - **`analyze_*` (4)** — require valid table/column references; if missing or wrong, return actionable feedback instead of guessing.
102
- - **`generate_report`** — button-triggered via a dedicated report API (not chat-routed); on-demand only (never auto); post-pass gated; renders from AnalysisRecords + PS; persists a versioned artifact, snapshots record IDs, bumps version. (KM-644, Rifqi.)
103
- - **`help`** — no LLM; state → next step. Repeating it is fine, that's its job.
104
-
105
- ---
106
-
107
- ## 4. Tool matrix (deliverable for the sync)
108
-
109
- Harry explicitly asked for a matrix covering every tool. Produce one sheet/markdown table with columns:
110
-
111
- `tool | goal | trigger (when the orchestrator calls it) | input | process | output | gated by interview_status? | status (done / in progress / planned)`
112
-
113
- Use the tool set table in §1 as the row list. This doubles as the presentation material on Wednesday.
114
-
115
- ---
116
-
117
- ## 5. Day-by-day
118
-
119
- | Day | Target |
120
- |---|---|
121
- | **Thu 11** | Checkpoint meeting + task split with Harry. |
122
- | **Fri 12 (today)** | ✅ Registry trimmed to 4 analytics + few-shot synced (Sofhia, KM-641). ✅ Tool matrix built. ⏳ Redis Cloud + env share (Rifqi). |
123
- | **Mon 15** | Data/knowledge tools done (`data_check` merge, renames, `knowledge_check`). `generate_report` contract agreed. |
124
- | **Tue 16** | `help` skill done. `generate_report` skeleton wired to AnalysisRecord. Tool matrix drafted. End-to-end smoke test on the easy path. |
125
- | **Wed 17 (AM)** | Buffer: fix fallout, finalize matrix, rehearse the demo flow. |
126
- | **Wed 17 (PM)** | **Sync with Harry.** |
127
-
128
- ---
129
-
130
- ## 6. Open questions to confirm with Harry on Wednesday
131
-
132
- 1. **Gate scope.** Proposal: keep the fast path + exploration tools (`check_knowledge`, `check_data`, retrieves, `help`, arguably `descriptive`) available **pre-pass**; gate only the insight tools (correlation, trend, report). Hard-gating everything risks frustrating users who just want to look at their data.
133
- 2. **Who flips `interview_status` to `pass`?** Proposal: a deterministic validator (PS template slots complete + fields cross-checked against the bound catalog) makes the call — the LLM conducts the conversation but never decides the pass. ("Conversational skin, deterministic skeleton.")
134
- 3. **Skills vs spine — one sentence to lock in writing:** *"Skills are registry tools executed by the existing Planner → TaskRunner → Assembler spine; the Analysis State gate is a pre-check in the Orchestrator."* This keeps the new flow and the locked architecture fully compatible.
135
- 4. `generate_report` invocation goes through the same gate (post-pass only) — confirm.
136
-
137
- ---
138
-
139
- ## 7. Definition of done for Wednesday
140
-
141
- - [ ] All team PRs merged; Harry unblocked on the Analysis State class.
142
- - [ ] Registry exposes exactly 4 analytics + 4 data/knowledge tools, all passing local tests.
143
- - [ ] Redis Cloud shared and working locally for all three of us (TTL 1 h).
144
- - [ ] `help` works against a (possibly stubbed) Analysis State.
145
- - [ ] `generate_report` contract written; skeleton callable.
146
- - [ ] Tool matrix ready to present.
147
- - [ ] One end-to-end happy path runs: create analysis (with data) → blocked pre-pass → interview stub passes → descriptive/aggregate answer → record persisted.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
PHASE1_TO_PHASE2_REPORT.md DELETED
@@ -1,273 +0,0 @@
1
- # Phase 1 → Phase 2 Migration Report
2
-
3
- A walkthrough of what changed between the original retrieval-style backend (Phase 1) and the current catalog-driven backend (Phase 2). Intended as a hand-off for the lead.
4
-
5
- ---
6
-
7
- ## 1. The conceptual change
8
-
9
- **Phase 1** was a single retrieval-style RAG pipeline. Every question — whether it pointed at a database, a spreadsheet, or a PDF — went through the same primitive: **chunk + embed + top-K** over PGVector. Schema and tabular columns were embedded as chunks and ranked alongside prose. When the question needed SQL, the LLM **wrote the SQL string directly** (via `query_executor`).
10
-
11
- **Phase 2** splits the system into two paths governed by an LLM router:
12
-
13
- | Path | Primitive | Why |
14
- |---|---|---|
15
- | Unstructured (PDF / DOCX / TXT) | Dense similarity over prose chunks (PGVector) | Right primitive for free text |
16
- | Structured (DB / CSV / XLSX / Parquet) | **Per-user data catalog** → LLM emits a **JSON IR** of intent → deterministic **compiler** → **executor** (SQL or pandas) | A column lookup shouldn't go through a similarity ranking lottery; the LLM emits intent, never SQL syntax |
17
-
18
- Three explicit LLM call sites only:
19
-
20
- 1. **Intent router** (classifies the user message into `chat` / `unstructured` / `structured`)
21
- 2. **Query planner** (turns the question + catalog into a Pydantic-validated `QueryIR`)
22
- 3. **Chatbot agent** (formats the final answer, streamed over SSE)
23
-
24
- Everything else — IR validation, SQL/pandas compilation, execution — is deterministic Python.
25
-
26
- ---
27
-
28
- ## 2. File-by-file changes
29
-
30
- ### 2.1 Deleted (Phase 1 only)
31
-
32
- | Phase 1 path | Reason it was removed |
33
- |---|---|
34
- | `src/rag/base.py`, `src/rag/retriever.py`, `src/rag/router.py` | Replaced by `src/retrieval/` |
35
- | `src/rag/retrievers/baseline.py`, `schema.py`, `document.py` | Schema retrieval gone (catalog replaces it); document retriever rewritten in `src/retrieval/document.py` |
36
- | `src/tools/search.py` (whole `tools/` folder) | Only consumer was `rag/router.py` |
37
- | `src/query/base.py` | Duplicate of `query/executor/base.py` |
38
- | `src/query/query_executor.py` | Replaced by `src/query/service.py` |
39
- | `src/query/executors/db_executor.py` | Replaced by `src/query/executor/db.py` |
40
- | `src/query/executors/tabular.py` | Replaced by `src/query/executor/tabular.py` |
41
- | `src/agents/chatbot.py` (Phase 1 LangChain chatbot) | Phase 2 `ChatbotAgent` lives at the same path now — see §2.2 |
42
- | `src/api/v1/knowledge.py` | Fake `/knowledge/rebuild` endpoint, never wired |
43
- | `src/config/agents/system_prompt.md`, `guardrails_prompt.md` | Replaced by `src/config/prompts/{chatbot_system,guardrails}.md` |
44
- | `src/models/structured_output.py` (`IntentClassification`) | Replaced by `IntentRouterDecision` Pydantic model inside `agents/orchestration.py` |
45
- | `src/models/sql_query.py` | LLM no longer emits SQL; IR replaces it |
46
- | `src/pipeline/orchestrator.py` (empty stub) | Redundant — `StructuredPipeline` takes the introspector at `run()` time |
47
-
48
- ### 2.2 Renamed / moved (same role, new home)
49
-
50
- | Phase 1 location | Phase 2 location | Notes |
51
- |---|---|---|
52
- | `src/agents/chatbot.py` (Phase 1) → deleted, then `src/agents/answer_agent.py` (`AnswerAgent`) → renamed | `src/agents/chatbot.py::ChatbotAgent` | Final answer formation; streams via `astream` |
53
- | `src/knowledge/parquet_service.py` | `src/storage/parquet.py` | Parquet upload/download helper |
54
- | `src/pipeline/document_pipeline/document_pipeline.py` (folder) | `src/pipeline/document_pipeline.py` (flat) | Single module |
55
- | `src/rag/retrievers/document.py` | `src/retrieval/document.py` | `DocumentRetriever` migrated; tabular file types filtered out of results. **Post-report update (mentor commit 61c746f, 2026-05-20):** rewritten to raw SQL (pgvector `<=>` cosine, `<+>` manhattan only) to dodge asyncpg type-mapping issues with the Go-ingested schema. MMR / euclidean / inner_product dropped. |
56
- | `src/rag/router.py` | `src/retrieval/router.py` | `RetrievalRouter`, Redis-cached, unstructured-only; dead `db: AsyncSession` + `source_hint` params removed |
57
- | `src/rag/base.py` (`RetrievalResult`, `BaseRetriever`) | `src/retrieval/base.py` | Same dataclass + ABC |
58
-
59
- > **Heads-up on the intent router**: the Phase 1 file `src/agents/orchestration.py` and its class `OrchestratorAgent` were **kept in place** for Phase 2 — but the body was fully rewritten. The class now emits `IntentRouterDecision(needs_search, source_hint ∈ {chat, unstructured, structured}, rewritten_query)`. The prompt file and test file use the `intent_router` name (`config/prompts/intent_router.md`, `tests/agents/test_intent_router.py`), but **the source module is still `orchestration.py` and the class is still `OrchestratorAgent`**. Existing imports continue to work; only the behavior changed.
60
-
61
- ### 2.3 Added (Phase 2 new)
62
-
63
- **Catalog subsystem (whole new concept)**
64
-
65
- | Path | Role |
66
- |---|---|
67
- | `src/catalog/models.py` | Pydantic: `Catalog → Source[] → Table[] → Column[]`, `ForeignKey`, `ColumnStats.top_values` |
68
- | `src/catalog/introspect/base.py` | `BaseIntrospector` ABC |
69
- | `src/catalog/introspect/database.py` | DB introspector — wraps Phase 1 `db_pipeline/extractor.py` (`get_schema`, `profile_column`, `get_row_count`) |
70
- | `src/catalog/introspect/tabular.py` | CSV / XLSX / Parquet introspector — one `Table` per XLSX sheet |
71
- | `src/catalog/render.py` | Renders a `Source` for the planner prompt |
72
- | `src/catalog/validator.py` | Unique-ID + foreign-key-ref invariants |
73
- | `src/catalog/store.py` | Postgres `jsonb` upsert keyed by `user_id` (table `data_catalog`) |
74
- | `src/catalog/reader.py` | Loads + filters catalog by `source_hint` |
75
- | `src/catalog/pii_detector.py` | Flags PII columns at ingestion → suppresses `sample_values` |
76
- | `src/security/pii_patterns.py` | Name patterns + value regex used by the detector |
77
-
78
- **JSON IR + query subsystem**
79
-
80
- | Path | Role |
81
- |---|---|
82
- | `src/query/ir/models.py` | `QueryIR` Pydantic schema |
83
- | `src/query/ir/operators.py` | `ALLOWED_FILTER_OPS`, `ALLOWED_AGG_FNS`, `LIMIT_HARD_CAP`, `TYPE_COMPATIBILITY` |
84
- | `src/query/ir/validator.py` | Catalog-aware IR validation (rejects unknown column ids, bad ops, type mismatches, oversize limits) |
85
- | `src/query/planner/service.py` | `QueryPlannerService.plan(question, catalog, previous_error)` — Azure OpenAI structured output → `QueryIR` |
86
- | `src/query/planner/prompt.py` | Builds the planner prompt from catalog text |
87
- | `src/query/compiler/base.py` | Compiler ABC |
88
- | `src/query/compiler/sql.py` | `SqlCompiler` (Postgres) — all 12 filter ops, params as a dict |
89
- | `src/query/compiler/pandas.py` | `PandasCompiler` — returns `CompiledPandas(apply, output_columns)` |
90
- | `src/query/executor/base.py` | `BaseExecutor` + `QueryResult` |
91
- | `src/query/executor/db.py` | `DbExecutor` — sqlglot SELECT-only guard, RO txn, 30 s `statement_timeout`, 10 k row cap |
92
- | `src/query/executor/tabular.py` | `TabularExecutor` — Parquet via blob, `asyncio.to_thread`, 10 k cap |
93
- | `src/query/executor/dispatcher.py` | `ExecutorDispatcher.pick(ir)` — picks by `source.source_type` |
94
- | `src/query/service.py` | `QueryService.run(user_id, question, catalog)` — plan → validate → retry (max 3) → dispatch → execute |
95
-
96
- **Agents**
97
-
98
- | Path | Role |
99
- |---|---|
100
- | `src/agents/orchestration.py` | `OrchestratorAgent` — Phase 1 file/class name preserved; Phase 2 body. Emits `IntentRouterDecision` |
101
- | `src/agents/chatbot.py` | `ChatbotAgent` — formerly `AnswerAgent` in `agents/answer_agent.py`; renamed in Cleanup PR |
102
- | `src/agents/chat_handler.py` | `ChatHandler.handle(...)` — top-level orchestrator; yields `intent` / `chunk` / `done` / `error` SSE events |
103
-
104
- **Pipelines & API**
105
-
106
- | Path | Role |
107
- |---|---|
108
- | `src/pipeline/structured_pipeline.py` | DB / tabular ingestion: introspect → merge → validate → upsert |
109
- | `src/pipeline/triggers.py` | `on_db_registered`, `on_tabular_uploaded`, `on_document_uploaded`, `on_catalog_rebuild_requested` |
110
- | `src/api/v1/data_catalog.py` | `GET /api/v1/data-catalog/{user_id}` + `POST /api/v1/data-catalog/rebuild` |
111
- | `src/models/api/catalog.py` | Catalog request/response models |
112
- | `src/config/prompts/intent_router.md`, `query_planner.md`, `chatbot_system.md`, `guardrails.md` | New prompts. `guardrails.md` is appended to `chatbot_system.md` at load time |
113
- | `src/db/postgres/models.py` (added `Catalog` SQLAlchemy class) | Stores the per-user jsonb document in `data_catalog` |
114
-
115
- ### 2.4 Rewired API endpoints
116
-
117
- | Endpoint | Phase 1 wiring | Phase 2 wiring |
118
- |---|---|---|
119
- | `POST /api/v1/chat/stream` | Inline in `chat.py`: `OrchestratorAgent` → `retriever` → `query_executor` → `chatbot` | Delegates to `ChatHandler.handle()`. Redis cache, fast intent, history load, and message persistence stay in the endpoint |
120
- | `POST /api/v1/database-clients/{id}/ingest` | Called `db_pipeline_service.run()` and dual-wrote vectors | Calls **only** `on_db_registered` (catalog build). Failure → HTTP 500 |
121
- | `POST /api/v1/document/process` | Always pushed to vector store | PDF/DOCX/TXT → `knowledge_processor` (vectors); CSV/XLSX → `on_tabular_uploaded` (catalog only, **no vector embedding**) |
122
- | `POST /api/v1/document/upload` | Storage + DB row | Same, plus `on_document_uploaded` trigger |
123
- | `POST /api/v1/data-catalog/rebuild` | — | New: iterates all sources, re-runs per-source trigger |
124
- | `GET /api/v1/data-catalog/{user_id}` | — | New: returns `list[CatalogIndexEntry]` |
125
-
126
- ### 2.5 Phase 1 files still in production use
127
-
128
- These were **not rewritten** — Phase 2 imports them directly:
129
-
130
- - `src/database_client/database_client_service.py`
131
- - `src/utils/db_credential_encryption.py` (`decrypt_credentials_dict`) — `src/security/credentials.py` is still a stub
132
- - `src/pipeline/db_pipeline/db_pipeline_service.py` (`engine_scope` context manager — used by both the introspector and `DbExecutor`)
133
- - `src/pipeline/db_pipeline/extractor.py` (`get_schema`, `profile_column`, `get_row_count`)
134
- - `src/knowledge/processing_service.py` (PDF / DOCX / TXT extraction + embedding)
135
- - `src/db/postgres/{connection,init_db,vector_store}.py`, `src/storage/az_blob/`, `src/middlewares/`, `src/security/auth.py`
136
-
137
- ---
138
-
139
- ## 3. End-to-end flow (current state)
140
-
141
- ### 3.1 Ingestion
142
-
143
- ```
144
- User action Pipeline Storage
145
- ────────────── ──────────────────────────── ─────────────────
146
- upload PDF/DOCX/TXT → DocumentPipeline → Azure Blob + PGVector
147
- (extract → chunk → embed) (table: langchain_pg_embedding)
148
- + on_document_uploaded + retrieval cache invalidate
149
-
150
- upload CSV/XLSX → TabularIntrospector → Azure Blob (Parquet)
151
- (sheets / columns + sample + stats) + data_catalog jsonb row
152
- → CatalogValidator → CatalogStore (NO vector store — catalog only)
153
- via on_tabular_uploaded
154
-
155
- register DB → DatabaseIntrospector → data_catalog jsonb row
156
- (information_schema + sample + FKs)
157
- → validate → store
158
- via on_db_registered
159
- ```
160
-
161
- ### 3.2 Query (per user message → SSE stream)
162
-
163
- ```
164
- POST /api/v1/chat/stream
165
-
166
- ├── Redis cache check (24h TTL) — hit returns cached stream
167
- ├── _fast_intent (greetings / goodbyes) — bypass LLM
168
- ├── load history from chat_messages
169
-
170
- └── ChatHandler.handle(message, user_id, history) [src/agents/chat_handler.py]
171
-
172
- ├─ OrchestratorAgent.classify() [agents/orchestration.py]
173
- │ → needs_search, source_hint, rewritten_query
174
-
175
- ├── source_hint == "chat"
176
- │ → ChatbotAgent.astream() → yield chunk events
177
-
178
- ├── source_hint == "unstructured"
179
- │ → RetrievalRouter.retrieve() [retrieval/router.py, Redis-cached]
180
- │ → DocumentRetriever (raw SQL: pgvector `<=>` cosine or `<+>` manhattan)
181
- │ → ChatbotAgent.astream(chunks=...)
182
-
183
- └── source_hint == "structured"
184
- → CatalogReader.read(user_id, "structured") [catalog/reader.py]
185
- → QueryService.run(user_id, question, catalog) [query/service.py]
186
-
187
- ├─ QueryPlannerService.plan(...) [query/planner/service.py]
188
- │ LLM(catalog, question, prev_error?) → QueryIR
189
-
190
- ├─ IRValidator.validate(ir, catalog) [query/ir/validator.py]
191
- │ fail → loop back to planner with error context (max 3)
192
-
193
- ├─ ExecutorDispatcher.pick(ir) [query/executor/dispatcher.py]
194
- │ schema source → DbExecutor
195
- │ tabular source → TabularExecutor
196
-
197
- ├─ DbExecutor.run(ir): [query/executor/db.py]
198
- │ SqlCompiler → (sql, params)
199
- │ → sqlglot SELECT-only guard
200
- │ → engine_scope (Phase 1 utility) in asyncio.to_thread
201
- │ → RO txn + statement_timeout=30s + 10k cap
202
-
203
- ├─ TabularExecutor.run(ir): [query/executor/tabular.py]
204
- │ resolve Parquet blob path
205
- │ → download → PandasCompiler.apply(df)
206
- │ → asyncio.to_thread → 10k cap
207
-
208
- └─ QueryResult { rows, columns, row_count,
209
- truncated, source_id, error?, elapsed_ms }
210
-
211
- ChatbotAgent.astream(query_result=...)
212
- → yield chunk events
213
-
214
- └── final events: done / error
215
-
216
- └── persist user + assistant messages to chat_messages
217
- └── populate Redis cache
218
- ```
219
-
220
- **Safety invariants for the structured path** (read-only at every layer):
221
-
222
- 1. IR validated against the catalog before reaching the compiler
223
- 2. Identifiers come from the catalog (trusted; inlined as quoted identifiers)
224
- 3. Values from `IR.filters` are always parameterized
225
- 4. Compiler is deterministic — no LLM in the hot path
226
- 5. sqlglot rejects anything that isn't a pure SELECT
227
- 6. DB connection is read-only with a 30 s `statement_timeout`
228
- 7. Hard 10 000 row cap on both executors; neither raises — errors go in `QueryResult.error`
229
-
230
- ---
231
-
232
- ## 4. Summary table for review
233
-
234
- | Concern | Phase 1 — where it lived | Phase 2 — where it lives | Change type |
235
- |---|---|---|---|
236
- | Intent classification | `agents/orchestration.py::OrchestratorAgent` (free-text intent) | **Same path + same class name** — body rewritten to emit `IntentRouterDecision` | Body rewrite only |
237
- | Top-level chat orchestration | Inline in `api/v1/chat.py` | `agents/chat_handler.py::ChatHandler` | Extracted to a reusable module |
238
- | Final answer formation | `agents/chatbot.py` (Phase 1 LangChain) | `agents/chatbot.py::ChatbotAgent` (was `AnswerAgent` in `answer_agent.py` mid-cycle) | Rewritten + renamed |
239
- | Schema retrieval (DB / tabular) | `rag/retrievers/schema.py` + PGVector chunks | **Removed**. Replaced by catalog (`catalog/store.py` jsonb) loaded verbatim into planner prompt | Whole concept replaced |
240
- | Doc retrieval (PDF / DOCX / TXT) | `rag/retrievers/document.py`, `rag/router.py` | `retrieval/document.py`, `retrieval/router.py` | Moved; Redis cache restored; tabular files filtered. **Post-report update:** rewritten to raw SQL (cosine / manhattan only); collection renamed `document_embeddings` → `documents` to match the Go ingestion service. |
241
- | Query writing | `query/query_executor.py` + `models/sql_query.py` (LLM writes SQL) | `query/planner/service.py` (LLM writes IR) + `query/compiler/sql.py` (deterministic) | LLM emits intent, not SQL |
242
- | DB execution | `query/executors/db_executor.py` | `query/executor/db.py::DbExecutor` | Folder renamed (`executors` → `executor`); sqlglot guard + RO txn + 30 s timeout kept |
243
- | Tabular execution | `query/executors/tabular.py` | `query/executor/tabular.py::TabularExecutor` | Parquet-only; pandas compiler split out |
244
- | Executor selection | Hard-coded in `query_executor.py` | `query/executor/dispatcher.py::ExecutorDispatcher` | New; routes by `source.source_type` |
245
- | Catalog (NEW) | — | `catalog/` (models, introspect/, validator, store, reader, pii_detector, render) | New subsystem |
246
- | Catalog persistence | (data was embedded in PGVector) | Postgres jsonb table `data_catalog`, keyed by `user_id` | New table |
247
- | Ingestion triggers | Inline in API endpoints | `pipeline/triggers.py` (`on_db_registered`, `on_tabular_uploaded`, `on_document_uploaded`, `on_catalog_rebuild_requested`) | Centralized event entry points |
248
- | Structured pipeline | `pipeline/db_pipeline/db_pipeline_service.py` (still present for `engine_scope` + extractor reuse) | `pipeline/structured_pipeline.py` (orchestrator) — reuses Phase 1 extractor | New orchestrator wraps Phase 1 introspection helpers |
249
- | Document pipeline | `pipeline/document_pipeline/document_pipeline.py` (folder) | `pipeline/document_pipeline.py` (file) | Flattened; CSV / XLSX now skip the vector store |
250
- | Parquet helper | `knowledge/parquet_service.py` | `storage/parquet.py` | Moved into `storage/` |
251
- | Prompts | `config/agents/system_prompt.md`, `guardrails_prompt.md` | `config/prompts/{intent_router,query_planner,chatbot_system,guardrails}.md` | Folder renamed; split into four files; guardrails appended to `chatbot_system` at load |
252
- | PII detection | — | `catalog/pii_detector.py` + `security/pii_patterns.py` | New. Columns flagged `pii_flag=true` get `sample_values: null` so PII never enters prompts |
253
- | Chat endpoint | `api/v1/chat.py` (does everything inline) | `api/v1/chat.py` (cache + history + persistence) → delegates to `ChatHandler` | Slimmed; SSE event shape is `intent` / `chunk` / `done` / `error` |
254
- | DB ingest endpoint | `api/v1/db_client.py::ingest` (Phase 1 `db_pipeline_service.run()`) | `api/v1/db_client.py::ingest` (calls `on_db_registered` only) | Phase 1 dual-write removed |
255
- | Document process endpoint | `api/v1/document.py::process` (always vectorize) | `api/v1/document.py::process` (PDF/DOCX/TXT → vectors; CSV/XLSX → catalog via `on_tabular_uploaded`) | Routing by file type |
256
- | Catalog management API | — | `api/v1/data_catalog.py` (GET index + POST rebuild) | New |
257
-
258
- **Bottom line.** Every Phase 1 file under `src/rag/`, `src/tools/`, `src/query/executors/`, `src/query/query_executor.py`, `src/query/base.py`, `src/api/v1/knowledge.py`, and `src/config/agents/` is gone. Phase 1 introspection helpers under `src/pipeline/db_pipeline/` and `src/database_client/` are still imported by Phase 2 — they were not rewritten, just wrapped. The three LLM call sites are now explicit and the SQL-writing one no longer exists; the planner emits a Pydantic-validated `QueryIR` instead.
259
-
260
- The one filename gotcha to remember: the **intent router** still lives at `src/agents/orchestration.py` as class `OrchestratorAgent` (Phase 1 name kept for import-site compatibility, Phase 2 body). The matching prompt and tests use the `intent_router` name, but the source module does not.
261
-
262
- ---
263
-
264
- ## 5. Addendum — post-report changes (2026-05-20, mentor commit `61c746f`)
265
-
266
- This report was originally written as a snapshot at Phase 2 completion. The Phase 2 architecture itself hasn't changed, but a few implementation details have shifted as the Go migration progresses. Captured here so the report stays trustworthy:
267
-
268
- - **Doc ingestion is now a Go service.** PDF/DOCX/TXT chunking + embedding + writes into PGVector are no longer done by Python. The Python service reads only.
269
- - **PGVector collection renamed:** `document_embeddings` → `documents` (to match the Go service's writes). Touched files: `db/postgres/vector_store.py`, `retrieval/document.py`.
270
- - **`DocumentRetriever` rewritten to raw SQL.** Uses pgvector operators directly (`<=>` cosine, `<+>` manhattan). The LangChain ORM path couldn't cope with the schema written by the Go service (asyncpg type-mapping issues — id String vs UUID, jsonb_path_match binding quirks). MMR / euclidean / inner_product were dropped as part of the rewrite.
271
- - **Intent router defaults flipped.** Ambiguous topical/knowledge questions now prefer `unstructured` (was `structured`). Indonesian few-shot examples added to the prompt.
272
- - **Cache management endpoints added:** `DELETE /api/v1/chat/cache`, `DELETE /api/v1/chat/cache/room/{id}`, `DELETE /api/v1/retrieval/cache/{user_id}`. Redis chat cache now stores `{response, sources}` (was just `response`) so cached replies repopulate `message_sources`.
273
- - **Direction.** The long-term split is **Python = agent/ML layer, Go = data plane**. More pieces are expected to follow doc ingestion out of Python.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
PROGRESS.md DELETED
@@ -1,692 +0,0 @@
1
- # Progress — Phase 2 catalog-driven build
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-06-12 (Redis Cloud live; R3 closed as won't-do; R5 cache fix; AnalysisRecord persistence landed — `PostgresAnalysisStore` + `analysis_records` table)
6
- **Current open PR**: `pr/3` — active.
7
-
8
- ---
9
-
10
- ## What just shipped (2026-06-12 — AnalysisRecord persistence, Rifqi)
11
-
12
- Groundwork for `generate_report`. The slow path now persists a real, citable
13
- record; the report (next) renders from it.
14
-
15
- - **Contract gaps closed** (`agents/slow_path/schemas.py`): `stage: CrispStage`
16
- added to `TaskResult` + `TaskSummary` and populated at all 3 `TaskResult` build
17
- sites in `task_runner.py` + copied in `assembler._build_record` — so the report
18
- can group its method appendix by CRISP-DM phase. `AnalysisRecord` gained identity:
19
- `record_id` (auto uuid), `analysis_id`/`user_id` (optional; stamped at persist).
20
- - **Real store** (`agents/slow_path/store.py`): `PostgresAnalysisStore` —
21
- `save()` (never-throw, idempotent upsert) + `list_for_analysis()` (oldest-first,
22
- the report's render order). `NullAnalysisStore` kept (tests / disabled persistence).
23
- `AnalysisStore` Protocol gained `list_for_analysis`.
24
- - **Table** (`db/postgres/models.py`): `analysis_records` jsonb table (one row per
25
- run, indexed by `analysis_id` + `user_id`); registered in `init_db.py`, created by
26
- `create_all` on startup (no migration — `data_catalog` precedent).
27
- - **Wired** (`agents/chat_handler.py`): default store flipped to `PostgresAnalysisStore`;
28
- `user_id` stamped onto the record at the save site (in scope there).
29
- - **Open**: `analysis_id` is `NULL` until Harry's Analysis State reaches the slow
30
- path (session-ID handoff needed to group records per analysis).
31
-
32
- ---
33
-
34
- ## Principal architecture review (2026-06-10) — findings + fix tracker
35
-
36
- A full external review (read the context docs + the slow path, tool layer, query
37
- spine, catalog plumbing, chat endpoint, config/connection layers) landed. It confirmed
38
- the DB-latency diagnosis and surfaced several gaps **not previously tracked here**.
39
- Verified against code before logging. Severity: **critical** / important / nice-to-have.
40
-
41
- **Runtime / latency (the original problem):**
42
- - DB connection handling is the anomaly, NOT cold start. `DbExecutor._run_sync`
43
- (`db.py:192`) → `engine_scope` does `create_engine → connect (TCP+TLS+SCRAM) → 2×SET
44
- → dispose` on EVERY query. Measured ~6–8s for 60 rows; a 2nd warm-session query was
45
- still ~6.6s → per-call handshake, never amortized. `engine_scope`'s connect-once-dispose
46
- semantics were designed for the ingestion pipeline and wrongly inherited by the query path.
47
- - `describe_source` ~3.5s is **planner-induced waste**: every few-shot (`examples.py`)
48
- opens with a `describe_source` task, so the LLM always plans a tool that re-reads from
49
- the catalog DB the same catalog already rendered into its prompt. Its impl does 2
50
- sequential full-catalog reads (`data_access.py:127-128`). Total catalog reads/request ~5×.
51
- - Azure LLM clients rebuilt per request: `ChatHandler(enable_tracing=True)` is constructed
52
- per request (`chat.py:172`) → fresh Orchestrator/Chatbot → fresh AzureChatOpenAI → fresh
53
- TLS to Azure each call. Planner/Assembler correctly use module singletons; the other two don't.
54
- - Tokens (~13k/request) are NORMAL for this design — do not optimize for $.
55
- - **Reject the scheduled DB-warmer idea**: targets cold start (~1.8s slice) not the per-call
56
- handshake, keeps serverless user DBs awake 24/7 (their compute bill), and decrypts every
57
- tenant's creds on a cron (attack surface). Strictly dominated by an engine cache +
58
- request-scoped pre-connect.
59
-
60
- **Fix tracker (new):**
61
-
62
- | # | Fix | Severity | Owner | Status |
63
- |---|---|---|---|---|
64
- | R1 | **AuthN/AuthZ** on data endpoints — reject body-supplied `user_id`/`room_id`, derive identity from a verified token. `/chat/stream` has none (`chat.py:40,128`); tenant isolation is client honesty. **CORRECTION to the review:** `security/auth.py` is a STUB (all `NotImplementedError`); the real JWT impl lives in `src/users/users.py` (`encode_jwt`/`decode_jwt`, HS, env-keyed) **but is unused** — `/login` (`api/v1/users.py`) returns the user profile as plain JSON and mints NO token. So R1 is cross-team: (1) `/login` must issue a JWT, (2) frontend must send it as `Bearer`, (3) data endpoints validate it. **Gates the engine-cache work (DB2).** | **critical** | DB/B + frontend | `[ ]` |
65
- | R2 | **Always compile a LIMIT** — `sql.py` now emits a bound for every query: explicit limit honored (clamped to `MAX_RESULT_ROWS=10000`), unbounded queries get `LIMIT cap+1` so an unbounded SELECT can't stream a whole table into memory. `CompiledSql.row_cap` carries the cap; `DbExecutor` caps + flags truncation from it (dropped its own `_ROW_HARD_CAP`). Tests updated (`test_sql.py`, +3 cases); `S608` restored to `tests/**` ruff ignore (was dropped). | **critical** | DB | `[x]` |
66
- | R3 | **Commit `tests/` + minimal CI** — `tests/` is gitignored; the 200+ tests cited as done exist only on laptops (already caused rename rot). ~~GitHub origin carries tests; HF Space gets the Docker build.~~ **2026-06-12: team decided tests stay gitignored/local — closed as won't-do.** | **critical (process)** | shared | `[won't do]` |
67
- | DB1 | **In-memory `describe_source`** (request-scoped `MemoizingCatalogReader`, `reader.py`) + **LLM-client hoist** (shared module-level `ChatHandler` in `chat.py`). Measured live: `describe_source` 3.5s→~2.0s (structured read now served from the planner's cached snapshot; only the unstructured read remains a round-trip), catalog reads/request ~5→~2. External `query_structured` handshake unchanged (DB2's job) so total slow path is ~flat until DB2. Tests: `tests/catalog/test_reader.py`. | important | agent | `[x]` |
68
- | DB2 | **Keyed engine cache** — `src/database_client/engine.py::UserEngineCache` (process singleton): pooled engines keyed by `client_id + creds-hash` (rotation auto-invalidates), bounded LRU (50) + 600s idle TTL, `pool_pre_ping` + `pool_recycle=300`. `DbExecutor._run_sync` reuses the warm connection instead of `create_engine→connect→dispose` per query (postgres/supabase only; other db_types keep the legacy path — no regression). **Live-measured: warm `query_structured` 6.6–9.4s → ~2.5s** (the residual is the per-call catalog-DB client fetch + pre-ping, not the external handshake). **Finding:** Neon's transaction pooler REJECTS `default_transaction_read_only` as a libpq startup `option` — caught live; moved read-only + statement_timeout to a per-connection `connect` event (best-effort; authoritative read-only is the SELECT-only compiler + sqlglot guard, see R10). Per-request ownership/active check kept. Proceeded ahead of R1 per owner decision (marginal security delta over the existing no-auth state; auth tracked separately). Tests: `tests/database_client/test_engine.py`. First query/process still cold → DB3. | important | DB | `[x]` |
69
- | DB3 | **Speculative pre-connect** — `DbExecutor.prewarm(catalog, user_id)` warms the pooled engine for schema sources (fire-and-forget at slow-path entry) so the cold first-query handshake overlaps the ~4s Planner call. Best-effort, never raises; gated to the default path (skipped when a coordinator factory is injected). Verified live through `ChatHandler.handle`. | nice-to-have | DB | `[x]` |
70
- | R4 | **Per-stage progress events** — `SlowPathCoordinator.run` gained an optional `progress` callback; `ChatHandler` bridges it to SSE `status` events (`chat.py` forwards them). Live: stream now shows `Planning…`→`Running N steps…`→`Composing…` (max wire gap ~4.6s, was ~13s of silence) → fixes proxy idle-timeout + UX. **Deferred:** token-streaming the Assembler answer needs splitting it into a streamed prose call + a structured-record call — that doubles the Assembler LLM calls (cost/latency), so it's a separate decision; the answer is still emitted as one chunk after the (fast ~2.5s) Assembler. Test: `test_chat_handler_wiring.py`. | important | agent | `[~]` |
71
- | R5 | **Response cache**: key on `user_id` + catalog version; invalidate on ingest. Was `chat:{room_id}:{message}`, 24h TTL, no user → cross-user replay + stale answers. **2026-06-12 (Rifqi):** key now `chat:{room_id}:{user_id}:{message}` via `_chat_cache_key()`, TTL 24h→1h (checkpoint decision) — urgent now that Redis is a shared Cloud instance. `DELETE /chat/cache` gained a required `user_id` param (frontend heads-up); room-wide clear pattern unchanged. **Still open:** catalog-version in key / invalidate-on-ingest. | important | B | `[~]` |
72
- | R6 | **Hard time budget** — wrap `coordinator.run()` in `asyncio.wait_for` (60–90s). `Constraints.time_budget_seconds` is rendered but not enforced. | important | agent | `[ ]` |
73
- | R7 | **Root-task-failure short-circuit** before the Assembler (templated/fast-path fallback, NOT replanning) — stops paying ~2k tok to narrate an empty RunState. | important | agent | `[ ]` |
74
- | R8 | **Catalog upsert race** — per-user advisory lock around read-merge-upsert (`store.py`); concurrent uploads can drop a source. | important | DB | `[ ]` |
75
- | R9 | **`extra="ignore"`** in `settings.py:15` (currently `allow` → typo'd env vars silently swallowed); require Azure keys in prod. | nice-to-have | B | `[ ]` |
76
- | R10 | **Read-only enforcement is session-state, not a server role.** `REPO_CONTEXT.md` counts "read-only DB credentials" as a defense layer but nothing requests/verifies a read-only role. Either request read-only creds at registration (verify via `SELECT current_setting(...)`) or drop the claim. | important | DB | `[ ]` |
77
- | R11 | **De-duplicate** `_PLACEHOLDER_RE` (`task_runner.py:31` vs validator) and `_DATA_ACCESS_TOOLS` (invoker vs planner registry) — import one from the other; comments aren't a sync mechanism. **TAB slice done (90e80f9):** canonical `DATA_ACCESS_TOOLS` now lives once in `tools/data_access.py`; `invoker.py` imports it (was a duplicated frozenset synced by comment). **Agent slice done (2026-06-10):** `PLACEHOLDER_RE` single-sourced in `planner/schemas.py` (part of the ToolCall placeholder convention); validator + task_runner import it. `planner/registry.py` keeps local spec *bodies* (stub pending KM-465 #4) but name-checks them against `DATA_ACCESS_TOOLS` in `_data_access_slice()` — upstream rename/add now raises at `default_registry()` instead of drifting silently. Registry output unchanged (same 12 tools, same order). | nice-to-have | agent/tool | `[x]` |
78
- | R12 | **Doc/process hygiene** — some code docstrings cite internal design specs that are not committed to the repo (design docs are kept out of version control), so the references dangle for anyone but the author; `CLAUDE.md` lists deleted modules (enricher, `pipeline/orchestrator.py`); `main` is 38 commits behind on a dead architecture. | nice-to-have | agent | `[ ]` |
79
- | R13 | **Pre-existing test failure** (found during R2, NOT caused by it): `tests/query/planner/test_prompt.py::test_render_catalog_with_sources` fails — `query/planner/prompt.py::render_catalog` now renders stable IDs (`src_test_db`) the test asserts are absent. Old query-planner path; confirmed failing on a clean tree. | nice-to-have | DB | `[ ]` |
80
- | T1 | **`input_schema` is presence-only, not type-checked** — `ToolSpec.input_schema` comment said "validates ToolCall.args", but `TaskRunner._validate_args` only enforces `required` presence; the `properties` types are documentation, never validated at runtime. Clarified the contract in `tools/contracts.py` so nobody assumes type-safety (a wrong-typed arg passes validation, surfaces only inside the compute fn). Doc-only, no behavior change (90e80f9). | nice-to-have | TAB | `[x]` |
81
- | T2 | **Dead Python embed path?** — `document_pipeline.process()` → `knowledge_processor` → `vector_store.aadd_documents()` still writes PDF/DOCX/TXT embeddings to `langchain_pg_embedding`, contradicting CLAUDE.md's "Go is sole writer, Python reads only". Verified the Go service (`Orchestrator-Agent-Service/internal/documents`) IS a complete ingestion writer to the same tables for all 5 file types (OCR + chunk + embed) → the Python embed branch is very likely redundant. **Blocked on one operational fact:** does the frontend still upload to `/document/process` (Python) or to Go? Park until confirmed — deleting a live ingestion path would break unstructured RAG. The csv/xlsx parquet branch stays regardless (feeds the catalog/tabular path). | nice-to-have | TAB | `[blocked]` |
82
-
83
- **Slow-path endpoint wiring (2026-06-10):** the Orchestrator→slow-path is now wired
84
- into the live endpoint behind an **env flag**. `settings.enable_slow_path` (env
85
- `ENABLE_SLOW_PATH`, default **off**) is passed to the shared `ChatHandler` in
86
- `api/v1/chat.py`. Flip `ENABLE_SLOW_PATH=true` to route `structured` intents through
87
- Planner→TaskRunner→Assembler and test end-to-end from `/chat/stream` (status progress
88
- events + answer stream). Stays opt-in because `BusinessContext` is still the stub;
89
- fast/unstructured paths unchanged. Verified live via `ChatHandler.handle`.
90
-
91
- **Architecture verdict:** fundamentally sound (catalog-driven IR + deterministic compiler
92
- + static plan is the right call). Debt is transitional duplication (two planners/registries/
93
- contract modules — documented, owned) and `ChatHandler` drifting toward a god object
94
- (extract the slow-path composition root + the SSE `_build_sources`/`_normalize_chunks`
95
- mappers when convenient).
96
-
97
- ---
98
-
99
- ## What just shipped (2026-06-09/10 — tool layer, tracing, slow-path wiring)
100
-
101
- Big stretch since the slow-path workers landed. The tool layer (teammate-owned) is
102
- now **complete and real**, the slow path is **wired into `ChatHandler` behind a gate**,
103
- and the whole chat pipeline is **traced**. Fast path still untouched; live behavior
104
- unchanged (flags default off).
105
-
106
- **Tool layer — COMPLETE (teammate, KM-624→630).** `src/tools/` was re-created (the
107
- 2026-05-11 note about deleting it is superseded). Now teammate-owned:
108
- - `src/tools/analytics/` — the 8 **composite** `analyze_*` computes (descriptive,
109
- aggregate, comparison, contribution, profile, correlation, segment, trend) +
110
- prompt-style DESCRIPTIONs (KM-624/625).
111
- - `src/tools/contracts.py` — canonical `ToolSpec`/`ToolRegistry`/`ToolOutput` (KM-627).
112
- `agents/planner/contracts.py` now just re-exports them + keeps the `BusinessContext`
113
- stub (lead's).
114
- - `src/tools/registry.py::analytics_registry()` (KM-628); `src/tools/invoker.py` +
115
- `src/tools/data_access.py` — `AnalyticsToolInvoker` (KM-629), `DataAccessToolInvoker`
116
- + `CompositeToolInvoker` (KM-630). All never-throw. **Pattern A confirmed** (`analyze_*`
117
- take a `data` `${t<id>}` placeholder from an upstream `query_structured`).
118
- - **Verified live E2E (2026-06-09):** real `query_structured` against a user's Neon
119
- Postgres → `analyze_trend` → Assembler. `analyze_contribution` surfaced a real tool
120
- bug (Decimal vs float in `decomposition.py`) — degrade-and-continue held; **now fixed
121
- by the tool owner** (`_coerce_decimals` in `invoker._materialize`, KM-630 / commit
122
- 1195870), so the whole `analyze_*` family is covered in one place. **Directive:** agent
123
- side does NOT modify `src/tools/` without confirmation.
124
-
125
- **Planner — realigned to the real tools (KM-626).** `registry.py::default_registry()`
126
- composes the real `analytics_registry()` + a local stub for the 4 data-access tools.
127
- Few-shots grown to **A–D**: A `analyze_contribution`, B `analyze_trend`, C mixed
128
- structured+unstructured (`retrieve_documents`, independent branch), D `analyze_aggregate`.
129
- `parallelizable_with` **removed** from `Task` (schema/validator/examples/prompt) —
130
- TaskRunner derives parallelism from `depends_on` alone.
131
-
132
- **Slow-path wiring — built, GATED OFF (KM-626).** `agents/chat_handler.py` gains a
133
- `structured→slow` branch behind `ChatHandler(enable_slow_path=False)`: when on it builds
134
- a per-request `CompositeToolInvoker` (composition root) + `SlowPathCoordinator`, streams
135
- `chat_answer`, persists the `analysis_record`. Two seams isolate the remaining blockers:
136
- - `agents/planner/business_context.py::get_business_context(user_id)` — async stub
137
- `BusinessContext`; TODO(lead) swap for the real read.
138
- - `agents/slow_path/store.py` — `AnalysisStore` Protocol + `NullAnalysisStore` (logs
139
- only). Real store = `analysis_records` table in the catalog DB (Neon `dataeyond`) —
140
- **table not created yet**. `chat_answer` still emitted as one chunk (not token-streamed).
141
-
142
- **Observability — Langfuse tracing wired (KM-631).** `src/observability/langfuse/
143
- tracing.py` — `RequestTracer`/`NullTracer`/`TracingToolInvoker` + `_redact`. One trace
144
- per request groups Orchestrator.classify, Planner.plan (each retry = its own generation),
145
- Assembler.assemble, Chatbot.astream + tool spans (latency/metadata only). Gated:
146
- `ChatHandler(enable_tracing=False)`; `api/v1/chat.py` opts in (`=True`). PII policy:
147
- Orchestrator+Planner unmasked (question + PII-safe summary); Assembler+Chatbot masked
148
- (see real rows/chunks); tool spans carry name + arg keys + row count only. Zero added
149
- LLM tokens; verified live to US Cloud.
150
-
151
- **Live evals green (2026-06-09, real Azure 4o):** `RUN_PLANNER_EVAL=1` and
152
- `RUN_SLOW_PATH_EVAL=1` both pass — Planner emits valid catalog-consistent `QueryIR` and
153
- wires Pattern A correctly; self-corrects via retry.
154
-
155
- **Open follow-ups:** real `BusinessContext` (lead); create `analysis_records` table +
156
- real `AnalysisStore` (**Rifqi owns, 2026-06-12** — folded into `generate_report` work,
157
- see `CHECKPOINT_PLAN_2026-06-17.md`); register data-access `ToolSpec`s upstream (`data_access_registry()`)
158
- or keep the planner stub; 4o → GPT-mini deployment swap; flip `enable_slow_path` on once
159
- `BusinessContext` is real. NOTE: 3 test files pre-existing broken from rename rot
160
- (`test_chat_handler.py`, `test_intent_router.py`, `test_answer_agent.py` import the old
161
- `answer_agent`/`intent_router` module names).
162
-
163
- ---
164
-
165
- ## What just shipped (2026-06-10 — TAB: tool-layer hardening + DRY)
166
-
167
- Owner-side companion to the agent block above. After the live E2E surfaced real-data
168
- edge cases, the tool layer got a round of correctness hardening. All in TAB-owned paths
169
- (`src/tools/`, `src/catalog/`); no agent-side or API change.
170
-
171
- **JSON-safety across the `analyze_*` family.** Real DB rows carry scalar types that
172
- don't survive the jsonb / SSE round-trip:
173
- - `[KM-630] coerce DB Decimal → float` (commit 1195870) — `_coerce_decimals` in
174
- `invoker._materialize` converts object-columns holding `decimal.Decimal` (asyncpg
175
- returns NUMERIC as `Decimal`) to `float64` before any compute runs. Fixes the
176
- `float + Decimal` TypeError in `decomposition.analyze_contribution` **and** the whole
177
- family in one seam — only touches columns that actually contain a `Decimal`.
178
- - `[KM-624] non-JSON-safe scalars in mode & top_value` (commit 6981ed3) — normalize
179
- numpy / non-native scalars so descriptive + top-value outputs serialize cleanly.
180
-
181
- **Planner↔Tools registry alignment + Timestamp keys** (commit 4bb7623, `fix(tools)`):
182
- - `registry.py` — `analyze_descriptive.required` corrected `["data"]` → `["data",
183
- "column_ids"]` to match the compute signature (`column_ids` has no default). Prevents
184
- the Planner from emitting a call that's missing a required arg. `analyze_profile` stays
185
- `["data"]` (its `column_ids` defaults to `None`).
186
- - `aggregation._clean` — group-by over a datetime column produced `pd.Timestamp` group
187
- keys that aren't JSON-safe; now normalized to `.isoformat()` alongside the existing
188
- numpy `.item()` branch.
189
-
190
- **DRY: single `SAMPLE_LIMIT` constant** (commit 6d46ba5, `[NOTICKET] refactor(catalog)`):
191
- - One source of truth in `catalog/introspect/base.py` (`SAMPLE_LIMIT = 3`, down from 5 —
192
- token cost: sample values feed the planner prompt). Both introspection paths import it:
193
- `catalog/introspect/tabular.py` and `pipeline/db_pipeline/extractor.py` (which dropped
194
- its own local `= 3`). Dependency direction is pipeline→catalog (no circular import).
195
- Stale test `test_sample_values_capped_at_five` updated to assert the real cap (3).
196
-
197
- **Audit result:** Planner↔Tools arg alignment swept end-to-end — 7/8 `analyze_*` tools
198
- already matched; the 1 mismatch (`analyze_descriptive`) is the fix above. Pattern A holds
199
- across all of them.
200
-
201
- ---
202
-
203
- ## What just shipped (2026-06-08 — KM-626: slow-path agent layer)
204
-
205
- The rest of the slow path after the Planner (KM-567) — TaskRunner, Assembler, and
206
- the coordinator. Built and tested against
207
- mocks; **not yet wired into the live `ChatHandler`** (waits on the tool team's real
208
- `ToolInvoker` + a real `BusinessContext`). Fast path untouched.
209
-
210
- **Naming:** "Orchestrator" = the entry dispatcher only (`agents/orchestration.py`).
211
- The slow-path **workers** live in **`agents/slow_path/`** — deliberately NOT named
212
- "orchestrator".
213
-
214
- **Files added** (`src/agents/slow_path/`):
215
- - `schemas.py` — `TaskResult`, `RunState`; `TaskSummary`, `AnalysisRecord`,
216
- `AssembledOutput`, `AssemblerNarrative`. Reuses `ToolOutput`.
217
- - `invoker.py` — `ToolInvoker` Protocol only; the tool team owns the impl (KM-418).
218
- - `errors.py` — `SlowPathError`, `AssemblerError`.
219
- - `task_runner.py` — deterministic, 0 LLM: wave-based execution, `${t<id>}` placeholder
220
- resolution, internal `validate_args`, never-throw invoke, status labeling,
221
- degrade-and-continue → `RunState`.
222
- - `assembler.py` + `prompt.py` + `config/prompts/assembler.md` — single LLM call →
223
- `AssemblerNarrative`; code merges with `RunState` to build the `AnalysisRecord`
224
- (structured fields copied, never re-authored).
225
- - `coordinator.py` — `SlowPathCoordinator`: Planner → TaskRunner → Assembler.
226
-
227
- **Tests added** (`tests/agents/slow_path/`, 12 passing; gitignored): schema round-trips
228
- + chat_answer-first; runner happy/placeholder/parallel/degrade/arg-miss; assembler
229
- narrative-vs-snapshot + question threading; coordinator end-to-end. `ruff` clean;
230
- tool-agnostic (no `src/tools/*` import).
231
-
232
- **Open follow-ups (not blockers):** wire `SlowPathCoordinator` into the expanded
233
- Orchestrator/`ChatHandler` once the real invoker + `BusinessContext` exist; swap the
234
- test `MockToolInvoker` for the tool team's real one (zero agent change, INV-7); 4o →
235
- GPT-mini deployment swap.
236
-
237
- ---
238
-
239
- ## What just shipped (2026-06-08 — tool taxonomy + ownership revision)
240
-
241
- Team decisions after the teammate pushed KM-624 (`src/tools/analytics/`):
242
-
243
- - **Composite tools, not atomic.** v1 uses **composite "family" tools** (`analyze_*`),
244
- not the atomic `compute_*` set the earlier draft assumed. One `analyze_*` call does a
245
- whole analytical job (e.g. `analyze_descriptive` subsumes median/mode/stddev/percentile;
246
- `analyze_trend` subsumes `date_trunc`). Tool-taxonomy decision recorded.
247
- - **Tool team owns ALL tools** — compute, data-access (`query_structured`,
248
- `retrieve_documents`, `list_sources`, `describe_source`), the wrapper/invoker layer
249
- (KM-418), and **all tool tests**. The agent team owns nothing below the registry contract.
250
- - **Planner stub realigned to the real tools.** `registry.py` rewritten from the 9 atomic
251
- entries to **12 composite entries** (4 data-access + 8 `analyze_*`); `examples.py`
252
- rewritten (Example A → `analyze_contribution`, Example B → `analyze_trend`); `planner.md`
253
- bullet updated; planner tests updated. 32 passing + 1 gated, `ruff` clean.
254
- - **Open (tool team's call):** Pattern A (analyze_* take a `${t<id>}` `data` placeholder
255
- from an upstream `query_structured`) vs Pattern B (self-fetch by `source_id`). Stub
256
- assumes A; reshaped to match once decided (agent code unaffected, INV-7).
257
- - **New coupling:** the tool team's `query_structured`/`retrieve_documents` are expected
258
- to call our existing `QueryService`/`RetrievalRouter`; `query_structured` stays
259
- inline-`QueryIR` so `IRValidator` still applies. Interface to coordinate.
260
-
261
- **Next (our scope, all mock-able now):** TaskRunner + Assembler against a `MockToolInvoker`,
262
- then Orchestrator slow-path wiring. Stubs still to retire on integration: `contracts.py`
263
- (BusinessContext from lead; ToolSpec/ToolRegistry/ToolOutput from tool team) and `registry.py`
264
- (real registry from tool team). Infra: swap the 4o stand-in for a GPT-mini deployment.
265
-
266
- ---
267
-
268
- ## What just shipped (2026-06-05 — Phase 3: Planner agent)
269
-
270
- First slow-path agent (the Planner). A single LLM
271
- call turns BusinessContext + Catalog + ToolRegistry + question + Constraints into a
272
- validated, **static** `TaskList` (DAG of fully-specified tool-call chains). No
273
- replanning (INV-6); tool-agnostic against a registry contract (INV-7). Fast path
274
- (`agents/orchestration.py`, `agents/chatbot.py`, `query/`) untouched.
275
-
276
- **Files added** (`src/agents/planner/`):
277
- - `contracts.py` — **STUB** Pydantic contracts pending reconciliation: `BusinessContext`
278
- (+KeyTerm/DataTableNote/DataColumnNote, lead's), `ToolSpec`/`ToolRegistry` (tool
279
- team KM-608), `ToolOutput` envelope.
280
- - `schemas.py` — `CrispStage`, `ToolCall`, `Task`, `TaskList`. No replan schemas.
281
- - `inputs.py` — `CatalogSummary` (condensed, PII `sample_values` nulled, `from_catalog`
282
- builder + `render`) and `Constraints` (max_tasks=5, modeling_allowed=False).
283
- - `registry.py` — **STUB** v1 P0 registry: query_structured, retrieve_documents,
284
- list_sources, describe_source, compute_median/stddev/percentile/mode, date_trunc.
285
- - `errors.py` — `PlannerError`, `PlannerValidationError`.
286
- - `prompt.py` + `config/prompts/planner.md` — system prompt (INV-1/6/7 + principles) +
287
- per-call human content (context + catalog + tools + constraints + few-shots + question).
288
- - `examples.py` — two few-shots (A exploratory revenue-by-category; B descriptive
289
- monthly-trend-by-region with date_trunc), built from the real `TaskList` schema.
290
- - `validator.py` — `PlannerValidator` running the 8 checks; reuses the existing
291
- `IRValidator` for inline `query_structured` IRs.
292
- - `service.py` — `PlannerService` + `plan_analysis(...)`: chain (mirrors
293
- `query/planner/service.py`) + validate-and-retry loop (max 3, mirrors `QueryService`).
294
-
295
- **Tests added** (`tests/agents/planner/`, 30 passing + 1 gated): `test_schemas.py`,
296
- `test_inputs.py`, `test_validator.py` (one failure per check + happy paths),
297
- `test_service.py` (`_FakeChain` + retry), `test_golden_questions.py` (live eval gated on
298
- `RUN_PLANNER_EVAL=1`). `ruff check` clean on planner paths.
299
-
300
- **Open follow-ups (not blockers):** reconcile `BusinessContext` with the lead and
301
- `ToolRegistry`/`ToolSpec` + real tools with teammate (KM-608); "GPT mini" currently uses
302
- the configured 4o deployment (swap `azure_deployment` when a mini deployment exists). Next:
303
- Orchestrator slow-path expansion + TaskRunner + Assembler.
304
-
305
- ---
306
-
307
- ## Legend
308
-
309
- - `[x]` done and merged
310
- - `[~]` in progress (open PR or active branch)
311
- - `[ ]` not started
312
- - **DB** / **TAB** / **B** — ownership (from REPO_CONTEXT.md)
313
-
314
- ---
315
-
316
- ## PR sequence
317
-
318
- | PR | Status | Owner(s) | Scope |
319
- |---|---|---|---|
320
- | PR1 | `[x]` merged | DB | Contract locks + catalog plumbing + DB introspector + IR validator + tests |
321
- | PR1-tab | `[x]` shipped | TAB | Tabular introspector + on_tabular_uploaded trigger + 31 unit tests |
322
- | PR2a | `[x]` merged | DB | CatalogEnricher + StructuredPipeline + on_db_registered trigger + FK extension on Table (enricher later removed in KM-557) |
323
- | 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 |
324
- | PR2b | `[x]` shipped | DB-solo (B-review) | IntentRouter + planner prompt + planner LLM service |
325
- | PR3-DB | `[x]` shipped | DB | SqlCompiler (Postgres) + DbExecutor (sqlglot guard, RO + statement_timeout, asyncio.to_thread) + 36 golden IR→SQL tests |
326
- | PR3-TAB | `[x]` shipped | TAB | PandasCompiler + TabularExecutor + 43+12 golden IR→DataFrame tests |
327
- | PR4 | `[x]` | DB-solo (B-review) | ExecutorDispatcher + QueryService + ChatHandler module. **API rewired in Cleanup PR.** |
328
- | PR5 | `[x]` shipped | DB-solo (B-review) | Retry/self-correction loop on validation failure (lives in QueryService, max 3 attempts, planner re-prompted with prior error) |
329
- | PR6 | `[~]` scaffold | DB-solo (B-review) | Eval harness scaffold + 3 DB-targeting golden cases. Skipped without `RUN_PLANNER_EVAL=1` env. TAB extends with tabular cases. |
330
- | PR7 | `[x]` | DB-solo (B-review) | `ChatbotAgent` (renamed from `AnswerAgent`) + chatbot_system + guardrails prompts. `answer_agent.py` → `chatbot.py`, `AnswerAgent` → `ChatbotAgent`. API rewired in Cleanup PR. |
331
- | Cleanup | `[x]` | B | ChatHandler wired to chat.py; Phase 1 dual-write dropped from /ingest; on_catalog_rebuild_requested + POST /data-catalog/rebuild; dead modules deleted (chatbot Phase 1, orchestrator, query/base, knowledge.py, config/agents/); retrieval cache restored via RetrievalRouter; top_values added to ColumnStats; lifespan migration; knowledge_router removed. |
332
-
333
- ---
334
-
335
- ## All items
336
-
337
- ### Contracts (B — shared)
338
-
339
- | # | Item | Status | Notes |
340
- |---|---|---|---|
341
- | 1 | Catalog Pydantic models (`catalog/models.py`) | `[x]` | PR1 added `location_ref` URI-scheme docstring; PR2a added `ForeignKey` model + `Table.foreign_keys` field |
342
- | 2 | IR Pydantic models (`query/ir/models.py`) | `[x]` | Pre-existing scaffold |
343
- | 3 | IR operator whitelists (`query/ir/operators.py`) | `[x]` | PR1 filled `TYPE_COMPATIBILITY` matrix |
344
- | 4 | PII patterns / regex (`security/pii_patterns.py`) | `[x]` | Pre-existing |
345
- | — | `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) |
346
- | — | `QueryResult` shape (`query/executor/base.py`) | `[x]` | Pre-existing scaffold; `columns: list[str]` added (TAB owner, PR1-tab) — DbExecutor updated to populate it. |
347
- | — | `Source.location_ref` URI scheme | `[x]` | PR1 documented in `catalog/models.py` docstring |
348
-
349
- ### Ingestion — introspection
350
-
351
- | # | Item | Owner | Status | Notes |
352
- |---|---|---|---|---|
353
- | 5 | DB introspector (`catalog/introspect/database.py`) | DB | `[x]` | PR1 — reuses Phase 1 `database_client_service`, `db_credential_encryption`, `db_pipeline_service.engine_scope`, `extractor.get_schema/profile_column/get_row_count`. PR2a wired FK extraction (was discarded before). |
354
- | 6 | Tabular introspector (`catalog/introspect/tabular.py`) | TAB | `[x]` | PR1-tab — downloads original blob (CSV/XLSX/Parquet), one Table per sheet (XLSX) or one Table (CSV/Parquet). `source_id = document_id`. `fetch_doc`/`fetch_blob` injectable for unit tests (no Settings). **2026-06-10**: sample cap now imports the shared `SAMPLE_LIMIT` (=3) from `catalog/introspect/base.py` — single source of truth across the tabular + DB introspection paths (commit 6d46ba5). |
355
- | 7 | `BaseIntrospector` ABC (`catalog/introspect/base.py`) | B | `[x]` | Pre-existing; signature locked |
356
-
357
- ### Ingestion — shared catalog plumbing
358
-
359
- | # | Item | Owner | Status | Notes |
360
- |---|---|---|---|---|
361
- | 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`. |
362
- | 9 | Catalog validator (`catalog/validator.py`) | B | `[x]` | PR1 (DB owner picked up) — uniqueness invariants |
363
- | 10 | Catalog store — Postgres jsonb (`catalog/store.py`) | B | `[x]` | PR1 (DB owner picked up) — `INSERT ... ON CONFLICT` |
364
- | 11 | Catalog reader (`catalog/reader.py`) | B | `[x]` | PR1 (DB owner picked up) — filters by source_hint, empty on miss |
365
- | 12 | PII detector (`catalog/pii_detector.py`) | B | `[x]` | PR1 (DB owner picked up) — name + value matching, bias toward over-flag |
366
-
367
- ### Ingestion — pipelines
368
-
369
- | # | Item | Owner | Status | Notes |
370
- |---|---|---|---|---|
371
- | 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`. |
372
- | 14 | Triggers (`pipeline/triggers.py`) | B | `[x]` | PR2a — `on_db_registered` implemented (DB owner). PR1-tab — `on_tabular_uploaded` implemented (TAB owner). **2026-05-11** — `on_document_uploaded` implemented. **2026-05-12** — `on_catalog_rebuild_requested` implemented: iterates all Sources in current catalog, re-runs `on_db_registered` (schema) or `on_tabular_uploaded` (tabular) per source; per-source errors logged but don't abort. |
373
- | 15 | Ingestion orchestrator (`pipeline/orchestrator.py`) | B | **DELETED** | Redundant stub — `StructuredPipeline` already takes introspector at run() time. Deleted in Cleanup PR. |
374
- | 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`. |
375
-
376
- ### Query — shared spine
377
-
378
- | # | Item | Owner | Status | Notes |
379
- |---|---|---|---|---|
380
- | 17 | IR validator (`query/ir/validator.py`) | B | `[x]` | PR1 (DB owner) — full rule set; descriptive errors for planner retry |
381
- | 18 | Planner LLM service (`query/planner/service.py`) | B | `[x]` | PR2b — Azure OpenAI structured output → `QueryIR`. Injectable chain. Supports retry via `previous_error` argument. |
382
- | 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). |
383
- | 20 | Intent router (`agents/orchestration.py` — class `OrchestratorAgent`; `config/prompts/intent_router.md`) | B | `[x]` | PR2b — single LLM call → `IntentRouterDecision(needs_search, source_hint, rewritten_query)`. Supports conversation history. **NOTE**: source filename + class name were kept from Phase 1 for import-site compatibility; only the body is Phase 2. Prompt file and test file use the `intent_router` name. |
384
- | 21 | Executor base + `QueryResult` (`query/executor/base.py`) | B | `[x]` | Pre-existing scaffold |
385
- | 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. |
386
- | 23 | Compiler base ABC (`query/compiler/base.py`) | B | `[x]` | Pre-existing scaffold |
387
- | 24 | Top-level QueryService (`query/service.py`) | B | `[x]` | PR4+5 — `plan → validate → dispatch → execute → QueryResult`. Retry loop on validation failure (max 3, planner re-prompted with prior error). Catches NotImplementedError from TabularExecutor placeholder gracefully. Never raises. |
388
-
389
- ### Query — DB path
390
-
391
- | # | Item | Status | Notes |
392
- |---|---|---|---|
393
- | 25 | SQL compiler (`query/compiler/sql.py`) | `[x]` | PR3-DB — Postgres dialect (Supabase reuses); deterministic IR → (sql, named-params dict); double-quoted identifiers from catalog; all whitelisted ops (=, !=, <, <=, >, >=, in, not_in, is_null, is_not_null, like, between); alias-aware order_by; `CompiledSql.params: dict[str, Any]` (changed from `list`). MySQL/BigQuery/Snowflake compilers later. |
394
- | 26 | DB executor (`query/executor/db.py`) | `[x]` | PR3-DB — sync engine via `db_pipeline_service.engine_scope` inside `asyncio.to_thread`. sqlglot SELECT-only / no-DML guard. Postgres-only session settings: `default_transaction_read_only=on` + `statement_timeout=30000`. asyncio.wait_for backstop. Never raises — populates `QueryResult.error`. 10k row hard cap. |
395
- | 27 | Credential encryption (`security/credentials.py`) | `[ ]` | Stub exists; PR1 reused Phase 1 `utils/db_credential_encryption.py` instead. Move in cleanup PR |
396
- | 28 | User-DB connection management | `[x]` | PR3-DB reused Phase 1 `db_pipeline_service.engine_scope` (same as PR1 introspector); no new helper needed |
397
-
398
- ### Query — Tabular path
399
-
400
- | # | Item | Status | Notes |
401
- |---|---|---|---|
402
- | 29 | Pandas compiler (`query/compiler/pandas.py`) | `[x]` | PR3-TAB — `CompiledPandas` dataclass; all 12 filter ops; all 6 aggs; group_by via `pd.concat` of Series; alias-aware order_by; `_like_to_regex` (`%`→`.*`, `_`→`.`); pure module-level helpers. (`polars` for large files still deferred — see Planned dependencies.) |
403
- | 30 | Tabular executor (`query/executor/tabular.py`) | `[x]` | PR3-TAB — `fetch_blob` injectable for tests; blob path: single-table → `{uid}/{did}.parquet`, multi-table → `{uid}/{did}__{table.name}.parquet`; `asyncio.to_thread`; 10k row hard cap; errors → `QueryResult.error`. Dispatcher routes to it by `source_type`. |
404
- | 31 | Parquet upload/download wrapper | `[x]` | Moved `knowledge/parquet_service.py` → `storage/parquet.py`. Updated 4 import sites: `pipeline/document_pipeline.py`, `knowledge/processing_service.py`, `query/executor/tabular.py`, `query/executors/tabular.py`. |
405
-
406
- ### Agents + chat
407
-
408
- | # | Item | Status | Notes |
409
- |---|---|---|---|
410
- | 32 | Chatbot agent + prompt (`agents/chatbot.py`, `config/prompts/chatbot_system.md`) | `[x]` | PR7-bundle — `ChatbotAgent` (was `AnswerAgent`) streams tokens, accepts `QueryResult` or list[`DocumentChunk`] or neither. **Cleanup PR**: renamed `answer_agent.py` → `chatbot.py`, `AnswerAgent` → `ChatbotAgent`; Phase 1 `agents/chatbot.py` deleted. |
411
- | 33 | Guardrails prompt (`config/prompts/guardrails.md`) | `[x]` | PR7-bundle — appended to `chatbot_system.md` so guardrails take precedence in conflict. |
412
- | — | Chat handler / orchestrator (`agents/chat_handler.py`) | `[x]` | PR4-bundle — top-level Phase 2 orchestrator. Routes by `source_hint`: chat → AnswerAgent direct; structured → CatalogReader + QueryService; unstructured → DocumentRetriever placeholder + AnswerAgent. Yields `intent` / `chunk` / `done` / `error` SSE-style events. Phase 1 chat.py NOT touched — cleanup PR rewires the API to call this. **2026-06-09**: gained the gated `structured→slow` branch (`enable_slow_path=False`) + `enable_tracing` (KM-626/631). |
413
-
414
- ### Tools — slow-path "Tools" component (TAB)
415
-
416
- New scope after the original 42-item table; added as the tool layer landed (KM-608/624–631). All TAB-owned (`src/tools/`), all never-throw.
417
-
418
- | # | Item | Owner | Status | Notes |
419
- |---|---|---|---|---|
420
- | — | Analytics compute fns (`tools/analytics/`) | TAB | `[x]` | KM-608/624/625 — 8 **composite** `analyze_*` fns (descriptive, aggregate, comparison, contribution, profile, correlation, segment, trend) + prompt-style DESCRIPTIONs. Pure pandas, no I/O. JSON-safe outputs (numpy/Decimal/Timestamp normalized — KM-624 + commit 4bb7623). |
421
- | — | Tool contracts (`tools/contracts.py`) | TAB | `[x]` | KM-627 — canonical `ToolSpec` / `ToolRegistry` / `ToolOutput`. `agents/planner/contracts.py` re-exports them (+ keeps the lead's `BusinessContext` stub). |
422
- | — | Analytics registry (`tools/registry.py`) | TAB | `[x]` | KM-628 — `analytics_registry()`. `analyze_descriptive.required` = `["data","column_ids"]` (aligned to compute signature, commit 4bb7623). |
423
- | — | Invoker layer (`tools/invoker.py`) | TAB | `[x]` | KM-629 — `AnalyticsToolInvoker` (Pattern A: `analyze_*` take a `data` `${t<id>}` placeholder from upstream `query_structured`; `_materialize` → DataFrame, `_coerce_decimals` covers the whole family) + `CompositeToolInvoker` (routes data-access vs analytics by name). |
424
- | — | Data-access tools (`tools/data_access.py`) | TAB | `[x]` | KM-630 — `DataAccessToolInvoker`: `list_sources` / `describe_source` / `query_structured` / `retrieve_documents`. Per-request DI (`user_id` + `CatalogReader`). `query_structured` calls `IRValidator` + `ExecutorDispatcher` (planner skipped — IR pre-built by the agent Planner). **Superseded by KM-642/643** — renamed `data_retrieve`/`knowledge_retrieve` and `list_sources`+`describe_source` merged into `data_check` + new `knowledge_check`; see row below. |
425
- | — | Tool tests (`tests/unit/tools/`) | TAB | `[x]` | analytics + data-access + invoker tests (gitignored). Incl. regression `test_decimal_columns_coerced_for_analyze_contribution`. |
426
- | — | Data/knowledge tool taxonomy (`tools/data_access.py`) | TAB | `[x]` | KM-642/643 (commits c38c0c2, 4bd5f1e) — renamed `query_structured`→`data_retrieve`, `retrieve_documents`→`knowledge_retrieve`; merged `list_sources`+`describe_source` → parameterized `data_check` (no arg = list structured sources; `source_id` = that source's schema) + new `knowledge_check` (unstructured/documents). Split mirrors the catalog's structured/unstructured slices. Planner stub/prompt/validator/few-shots synced; `DATA_ACCESS_TOOLS` guard kept in lockstep. Note: dated log entries above (e.g. the 2026-06-09 E2E) keep the old names as historical record. |
427
-
428
- ### API surface
429
-
430
- | # | Item | Owner | Status | Notes |
431
- |---|---|---|---|---|
432
- | 34 | DB client endpoints (`api/v1/db_client.py`) | DB | `[x]` | **Cleanup PR** — `/ingest` now calls only `on_db_registered`. Phase 1 `db_pipeline_service.run()` + `decrypt_credentials_dict` removed. Error from catalog build now raises HTTP 500 (was silent log). Response simplified to `{"status": "success", "client_id": ...}`. |
433
- | 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. **2026-05-11** — CSV/XLSX no longer ingested to vector store (`knowledge_processor` skipped for tabular types in `document_pipeline.py`); they go to catalog only. |
434
- | 36 | Chat stream endpoint (`api/v1/chat.py`) | B | `[x]` | Rewired `/chat/stream` — replaced `query_executor.execute()` (Phase 1) with `CatalogReader + QueryService` (Phase 2). **Cleanup PR**: fully rewired to `ChatHandler.handle()`. Inline intent routing, retrieval, and answer generation removed. Redis cache, fast intent, history loading, and message persistence remain in chat.py. Sources event emits `[]` (retrieval not yet exposed by ChatHandler). |
435
- | 37 | Room / users endpoints (`api/v1/room.py`, `api/v1/users.py`) | B | `[ ]` | No catalog work; only touch if auth flow changes |
436
- | — | Data catalog index endpoint (`api/v1/data_catalog.py`) | DB | `[x]` | **KM-557** — `GET /api/v1/data-catalog/{user_id}` → `list[CatalogIndexEntry]`. **Cleanup PR** — added `POST /api/v1/data-catalog/rebuild?user_id=` → calls `on_catalog_rebuild_requested`; per-source errors logged but don't fail the request. |
437
-
438
- ### Tests + eval
439
-
440
- | # | Item | Owner | Status | Notes |
441
- |---|---|---|---|---|
442
- | 38 | DB compiler golden tests (`tests/query/compiler/test_sql.py`) | DB | `[x]` | PR3-DB — 36 tests across all whitelisted ops, identifier quoting, agg / count_distinct / count(*), order_by alias resolution, parameter sequencing, error paths. Pure-Python, no LLM, no DB. |
443
- | 39 | Pandas compiler golden tests (`tests/unit/query/compiler/test_pandas_compiler.py`) | TAB | `[x]` | PR3-TAB — 43 tests: all 12 filter ops, all 6 aggs, group_by, order_by, limit, aliases, empty DataFrame, error paths. `test_tabular_executor.py` adds 12 more (blob name resolution + happy path + error paths). |
444
- | 40 | IR validator tests (`tests/query/ir/test_validator.py`) | B | `[x]` | PR1 — 19 tests, all rules covered |
445
- | — | PII detector tests (`tests/catalog/test_pii_detector.py`) | B | `[x]` | PR1 — 26 tests (parametrized) |
446
- | — | Catalog validator tests (`tests/catalog/test_validator.py`) | B | `[x]` | PR1 — 5 tests |
447
- | — | 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). |
448
- | — | Catalog store integration test (`tests/catalog/test_store.py`) | DB | `[x]` | PR1 — module-level skip without `RUN_INTEGRATION_TESTS=1` |
449
- | — | DB introspector test | DB | `[ ]` | Deferred to PR2 — needs Postgres testcontainer or fixture infra |
450
- | — | 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. |
451
- | 41 | Planner eval (`tests/query/planner/`) | B | `[x]` | PR6-scaffold — `test_golden_questions.py` with 3 DB-targeting cases. TAB added `test_golden_tabular.py` with 4 tabular cases (group_by+sum, top-N+limit, date range filter, XLSX sheet selection). All 4 passed against real Azure OpenAI. Fix shipped alongside: `query/planner/service.py` replaced `("system", text)` tuple with `SystemMessage` — without this, `{...}` in `query_planner.md` was parsed as f-string variables and crashed on every real invocation. |
452
- | 42 | E2E smoke tests (`tests/e2e/`) | B | `[ ]` | Defer until Phase 2 endpoints are wired (cleanup PR). Component-level orchestration is already covered by `test_chat_handler.py` + `test_service.py`. |
453
- | — | Golden IR fixtures (`tests/fixtures/golden_irs.json`) | B | `[~]` | PR1 seeded with 5 DB-targeting examples; TAB extends in PR1-tab |
454
- | — | Shared `sample_catalog` fixture (`tests/conftest.py`) | B | `[x]` | PR1 — DB-shaped; TAB may add tabular sibling |
455
-
456
- ---
457
-
458
- ## What just shipped (2026-05-12 — Cleanup PR)
459
-
460
- **Phase 1 removal + Phase 2 API rewiring:**
461
- - `src/api/v1/chat.py` — fully rewired to `ChatHandler.handle()`. Removed inline IntentRouter, retrieval, and ChatbotAgent calls. Redis cache, fast intent, load_history, save_messages stay in chat.py.
462
- - `src/api/v1/db_client.py` — `/ingest` now calls only `on_db_registered`. Phase 1 `db_pipeline_service.run()` block removed. Catalog build failure now raises HTTP 500.
463
- - `src/api/v1/data_catalog.py` — added `POST /api/v1/data-catalog/rebuild` endpoint.
464
- - `src/pipeline/triggers.py` — `on_catalog_rebuild_requested` implemented: iterates catalog sources, re-runs the appropriate trigger per source type, per-source errors logged.
465
-
466
- **Dead modules deleted:**
467
- - `src/agents/chatbot.py` (Phase 1 LangChain chatbot)
468
- - `src/pipeline/orchestrator.py` (empty stub)
469
- - `src/query/base.py` (old duplicate of `executor/base.py`)
470
- - `src/api/v1/knowledge.py` (fake `/knowledge/rebuild` endpoint)
471
- - `src/config/agents/` (folder — prompts only used by deleted Phase 1 chatbot)
472
-
473
- **Renames:**
474
- - `src/agents/answer_agent.py` → `src/agents/chatbot.py`; `AnswerAgent` → `ChatbotAgent`; updated all import sites (`chat_handler.py`, `chat.py`)
475
-
476
- **Fixes + improvements:**
477
- - `src/agents/chat_handler.py` — `_get_document_retriever()` now returns `RetrievalRouter` (Redis-cached) instead of `DocumentRetriever` directly; retrieval-level cache restored.
478
- - `src/retrieval/router.py` — removed dead `db: AsyncSession` and `source_hint` parameters + `_UNSTRUCTURED_HINTS` constant from `retrieve()`. Cache key simplified.
479
- - `src/knowledge/processing_service.py` — removed dead `_build_csv_documents`, `_build_excel_documents`, `_profile_dataframe`, `_to_sheet_document` methods + `pandas` and `upload_parquet` imports.
480
- - `src/catalog/models.py` — added `top_values: list[Any] | None` to `ColumnStats`.
481
- - `src/catalog/introspect/tabular.py` — `_to_column` now populates `top_values` for columns with ≤10 distinct values; useful for query planner WHERE clause generation.
482
- - `main.py` — replaced deprecated `@app.on_event("startup")` with `lifespan` context manager; removed `knowledge_router`.
483
-
484
- ---
485
-
486
- ## What just shipped (KM-557 — DB owner)
487
-
488
- After lead review of the catalog ingestion cost: dropped LLM enrichment,
489
- renamed the storage table, and exposed a lightweight index endpoint for
490
- the upcoming catalog refresher.
491
-
492
- **Files deleted**:
493
- - `src/catalog/enricher.py` — entire CatalogEnricher + EnrichmentResponse + apply_descriptions removed
494
- - `src/config/prompts/catalog_enricher.md` — dead prompt
495
- - `tests/catalog/test_enricher.py` — replaced by `test_render.py`
496
-
497
- **Files added**:
498
- - `src/catalog/render.py` — new home for `render_source` (the only piece of the old enricher still needed; consumed by `query/planner/prompt.py`)
499
- - `src/api/v1/data_catalog.py` — `GET /api/v1/data-catalog/{user_id}` returns `list[CatalogIndexEntry]`
500
- - `tests/catalog/test_render.py` — 5 tests (same coverage as the old render block)
501
-
502
- **Files modified**:
503
- - `src/db/postgres/models.py` — `__tablename__ = "data_catalog"` (was `"catalogs"`). Class name unchanged
504
- - `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
505
- - `src/pipeline/triggers.py` — docstrings updated; `on_catalog_rebuild_requested` docstring rewritten for the refresher use case
506
- - `src/query/planner/prompt.py` — import now `from ...catalog.render import render_source`
507
- - `src/catalog/introspect/{base,database,tabular}.py` — docstring scrubs (no behavior changes)
508
- - `src/models/api/catalog.py` — added `CatalogIndexEntry`; simplified `CatalogRebuildResponse` to `sources_rebuilt`
509
- - `main.py` — registered `data_catalog_router`
510
- - `src/security/README.md` — one stale wording fix
511
-
512
- **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.
513
-
514
- **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.
515
-
516
- **Lint**: `ruff check` clean on modified Phase 2 paths.
517
-
518
- **Open follow-ups left for the lead**:
519
- - `on_catalog_rebuild_requested` body — the refresher will iterate the index endpoint and call this trigger per source
520
- - `api/v1/db_client.py` `/ingest` still doesn't call `on_db_registered` — same blocker as before, untouched by KM-557
521
-
522
- ---
523
-
524
- ## What just shipped (2026-05-11 — retrieval migration + bug fixes)
525
-
526
- **Files implemented / migrated**:
527
- - `src/retrieval/base.py` — `RetrievalResult` dataclass + `BaseRetriever` ABC (was in `src/rag/base.py`)
528
- - `src/retrieval/document.py` — full `DocumentRetriever` migrated from `src/rag/retrievers/document.py`; all retrieval methods (MMR/cosine/euclidean/inner_product/manhattan). Tabular file types filtered out from results.
529
- - `src/retrieval/router.py` — `RetrievalRouter` (Redis-cached, unstructured-only). `invalidate_cache(user_id)` clears all `retrieval:{user_id}:*` keys.
530
-
531
- **Deleted** (no longer used):
532
- - `src/rag/` — entire folder (base.py, retriever.py, router.py, retrievers/)
533
- - `src/tools/` — entire folder (search.py was the only real file; only called by deleted rag/ router)
534
-
535
- **Bug fixes**:
536
- - `src/pipeline/document_pipeline.py` — `retrieval_router.invalidate_cache(user_id)` called after `process()` and `delete()`. Redis failure is caught and logged (does not fail the document op).
537
- - `src/pipeline/document_pipeline.py` — CSV/XLSX now skips `knowledge_processor` (vector store). Tabular files go to catalog only; no duplicate embeddings.
538
- - `src/pipeline/triggers.py` — `on_document_uploaded` implemented (was `raise NotImplementedError`).
539
- - `src/agents/chat_handler.py` — `_normalize_chunks` now handles `RetrievalResult` objects. Previously they were silently dropped, causing empty context for unstructured queries through ChatHandler.
540
-
541
- **Import updates** (all changed from `src.rag.*` → `src.retrieval.*`):
542
- - `src/api/v1/chat.py`, `src/query/base.py`, `src/query/query_executor.py`, `src/query/executors/db_executor.py`, `src/query/executors/tabular.py`
543
-
544
- ---
545
-
546
- ## What shipped previously (PR2b/4/5/6/7-bundle — DB owner solo, teammate reviews)
547
-
548
- **Files implemented**:
549
- - `src/agents/orchestration.py` — `OrchestratorAgent.classify(message, history) → IntentRouterDecision`. Pydantic model for structured output. History-aware query rewriting. Phase 1 filename + class name preserved; body fully rewritten for Phase 2.
550
- - `src/agents/answer_agent.py` — `AnswerAgent.astream(...)` streams answer tokens; accepts `QueryResult` and/or `list[DocumentChunk]`. Renames to `chatbot.py` in cleanup PR.
551
- - `src/agents/chat_handler.py` — `ChatHandler.handle(message, user_id, history)` returns `AsyncIterator[dict]` of `intent` / `chunk` / `done` / `error` SSE events. All deps injectable; lazy default builders.
552
- - `src/query/planner/prompt.py` — `render_catalog(catalog)` + `build_planner_prompt(question, catalog, previous_error)`. Reuses `catalog.enricher.render_source` for consistency across LLM call sites.
553
- - `src/query/planner/service.py` — `QueryPlannerService.plan(question, catalog, previous_error)` Azure OpenAI structured output → `QueryIR`.
554
- - `src/query/executor/dispatcher.py` — `ExecutorDispatcher.pick(ir) → BaseExecutor` by `source.source_type`. Lazy executor imports + per-source-type cache.
555
- - `src/query/service.py` — `QueryService.run(user_id, question, catalog) → QueryResult`. Plan→validate→retry-on-failure (max 3)→dispatch→execute. Catches NotImplementedError from TabularExecutor placeholder gracefully.
556
-
557
- **Prompts written** (filled in placeholders):
558
- - `src/config/prompts/intent_router.md`
559
- - `src/config/prompts/query_planner.md`
560
- - `src/config/prompts/chatbot_system.md`
561
- - `src/config/prompts/guardrails.md`
562
-
563
- **Tests added** (46 new — total now 146 + 2 skipped):
564
- - `tests/agents/test_intent_router.py` (4)
565
- - `tests/agents/test_answer_agent.py` (12)
566
- - `tests/agents/test_chat_handler.py` (6)
567
- - `tests/query/planner/test_prompt.py` (7)
568
- - `tests/query/planner/test_service.py` (3)
569
- - `tests/query/executor/test_dispatcher.py` (5)
570
- - `tests/query/test_service.py` (8)
571
- - `tests/query/planner/test_golden_questions.py` (3 — skipped by default; eval harness scaffold)
572
-
573
- **Lint**: `ruff check` clean on all Phase 2 paths. Phase 1 files have pre-existing E501/S608 issues — out of scope for this PR.
574
-
575
- **Placeholders / blockers for teammate** (status as of DB owner's commit, before merge):
576
- - `src/query/executor/tabular.py` (TAB) — DB owner's note: "still raises NotImplementedError". **Post-merge**: TAB shipped this in PR3-TAB; dispatcher now routes to the real `TabularExecutor`. The `NotImplementedError` catch in `QueryService` stays as a safety net.
577
- - `src/retrieval/document.py` — **implemented** (2026-05-11). Full `DocumentRetriever` migrated from `src/rag/retrievers/document.py`; supports MMR/cosine/euclidean/manhattan/inner_product. `_normalize_chunks` in `chat_handler.py` now handles `RetrievalResult` → `DocumentChunk` conversion correctly.
578
- - `src/api/v1/chat.py` (Phase 1) — NOT touched. Cleanup PR rewires the SSE endpoint to call `ChatHandler.handle(...)`.
579
- - `src/api/v1/db_client.py` (Phase 1) — NOT touched. Cleanup PR rewires `/database-clients/{id}/ingest` to call `pipeline.triggers.on_db_registered`.
580
-
581
- ---
582
-
583
- ## What shipped previously (PR3-TAB — TAB owner)
584
-
585
- **Files implemented**:
586
- - `src/query/compiler/pandas.py` — `PandasCompiler` + `CompiledPandas(apply, output_columns)` dataclass. Pure helper functions (easier to test in isolation): `_apply_filters` (all 12 ops, `_like_to_regex` for LIKE), `_apply_select` (column pick + rename), `_apply_agg` (scalar + group_by via `pd.concat` of Series → `reset_index`), `_apply_orderby` (alias-aware via `_resolve_order_col`). Closure captures all IR fields explicitly so `apply(df)` is self-contained.
587
- - `src/query/executor/tabular.py` — `TabularExecutor` with injectable `fetch_blob` (same testability pattern as `TabularIntrospector`). Resolves Parquet blob path from `az_blob://{uid}/{did}` + table: single-table → `{uid}/{did}.parquet`, multi-table → `{uid}/{did}__{table.name}.parquet`. Runs compile → download → `asyncio.to_thread(_load_and_apply)` → 10k hard cap. Never raises; errors populate `QueryResult.error`. Uses `compiled.output_columns` for column labels (safe on empty DataFrame).
588
-
589
- **Tests added** (55 new — total suite was 86 all passing at PR3-TAB time):
590
- - `tests/unit/query/compiler/test_pandas_compiler.py` — 43 tests across all 12 filter ops (including `is_null`, `not_in`, `like`, `between`), all 6 agg fns, group_by, order_by asc/desc, limit-after-order, alias round-trip, empty DataFrame, error paths.
591
- - `tests/unit/query/executor/test_tabular_executor.py` — 12 tests: `_resolve_blob_name` (single/multi-table, bad prefix), happy-path `QueryResult` shape (columns, rows, backend, truncated, source_id), wrong source_type → error, blob fetch failure → error, unknown source → error.
592
-
593
- **Lint**: `ruff check` clean on both files.
594
-
595
- ---
596
-
597
- ## What shipped previously (PR1-tab — TAB owner)
598
-
599
- **Files implemented**:
600
- - `src/catalog/introspect/tabular.py` — `TabularIntrospector` reads original blob (CSV/XLSX/Parquet), profiles each column (dtype, stats, sample values), runs PIIDetector. For XLSX: one `Table` per sheet (`Table.name = sheet_name`); for CSV/Parquet: one `Table` (`Table.name = filename stem`). `fetch_doc`/`fetch_blob` are constructor-injectable for unit tests — no `Settings` or DB required at import time.
601
- - `src/pipeline/triggers.py` — `on_tabular_uploaded` wired (mirrors `on_db_registered` pattern).
602
-
603
- **Tests added** (31 new):
604
- - `tests/unit/catalog/test_introspect_tabular.py` — CSV / XLSX / Parquet shapes, per-column stats, nullable detection, PII name + value matching, sample capping, all error paths. Pure Python, no network I/O.
605
-
606
- **Executor contract note**: introspector downloads the *original* blob for schema reading. The tabular executor (PR3-TAB) downloads *Parquet* blobs for query execution. For CSV/Parquet sources (single table), the executor must call `parquet_blob_name(uid, did, sheet_name=None)`; for XLSX (multi-table), `parquet_blob_name(uid, did, table.name)`.
607
-
608
- ---
609
-
610
- ## What shipped previously (PR3-DB — DB owner)
611
-
612
- **Files implemented**:
613
- - `src/query/compiler/sql.py` — `SqlCompiler` for Postgres dialect; `CompiledSql(sql, params)` dataclass with `params: dict[str, Any]` (changed from `list`); supports all 12 whitelisted filter ops, all 6 aggs, alias-aware order_by; `_qident` escapes embedded double-quotes
614
- - `src/query/executor/db.py` — `DbExecutor` with sqlglot SELECT-only guard, Postgres session-level read-only + 30s `statement_timeout`, `asyncio.wait_for` backstop, 10k row hard cap; rejects non-`schema` source_type and `dbclient://` URI mismatch; never raises (populates `QueryResult.error`)
615
-
616
- **Files extended**:
617
- - `src/query/compiler/pandas.py` — fixed pre-existing UP035 (Callable import)
618
- - `pyproject.toml` — added `S608` to `tests/**` ruff ignore (false positive: tests assert literal SQL strings)
619
-
620
- **Tests added** (36 new, all passing — total now 100):
621
- - `tests/query/compiler/test_sql.py` — every filter op, every agg, count(*), count_distinct, order_by alias vs column, multi-filter AND, identifier quoting escape, error paths
622
-
623
- **Lint**: `ruff check` clean on Phase 2 paths.
624
-
625
- **Hand-off note for teammate**: `CompiledSql.params` is now `dict[str, Any]` not `list`. The pandas compiler will follow the same convention (or document its own) — coordinate when PR3-TAB lands.
626
-
627
- ---
628
-
629
- ## What shipped previously (PR2a — DB owner)
630
-
631
- **Files implemented**:
632
- - `src/catalog/enricher.py` — Azure OpenAI GPT-4o + structured output (`EnrichmentResponse`), `render_source` (reusable by planner prompt later), `apply_descriptions` merger, injectable `structured_chain` for tests
633
- - `src/pipeline/structured_pipeline.py` — `StructuredPipeline` orchestrator + `default_structured_pipeline()` factory with lazy production-dep imports
634
- - `src/pipeline/triggers.py` — `on_db_registered` wired; tabular/document/rebuild stubs preserved with implementation notes
635
-
636
- **Files extended**:
637
- - `src/catalog/models.py` — added `ForeignKey` model, `Table.foreign_keys: list[ForeignKey] = []`
638
- - `src/catalog/introspect/database.py` — `_extract_foreign_keys` populates `Table.foreign_keys` from extractor data
639
- - `src/config/prompts/catalog_enricher.md` — full system prompt with style rules and one few-shot example
640
-
641
- **Tests added** (14 new, all passing — total now 64):
642
- - `tests/catalog/test_enricher.py` — render / apply / end-to-end with fake chain (10 tests)
643
- - `tests/pipeline/test_structured_pipeline.py` — orchestration with stub deps (4 tests)
644
-
645
- **Lint**: `ruff check` clean on all Phase 2 paths. Phase 1 files (`pipeline/db_pipeline/`, `pipeline/document_pipeline/`) have pre-existing ruff issues — out of scope for this PR.
646
-
647
- ---
648
-
649
- ## What shipped previously (PR1 — DB owner's first chunk)
650
-
651
- **Files implemented** (was `NotImplementedError`):
652
- - `src/catalog/pii_detector.py`, `src/catalog/validator.py`, `src/catalog/store.py`, `src/catalog/reader.py`
653
- - `src/catalog/introspect/database.py` (FK extraction added in PR2a)
654
- - `src/query/ir/validator.py`
655
-
656
- **Files extended**:
657
- - `src/query/ir/operators.py` — `TYPE_COMPATIBILITY` matrix
658
- - `src/catalog/models.py` — `location_ref` URI-scheme docstring
659
- - `src/db/postgres/models.py` — `Catalog` SQLAlchemy table; `init_db.py` imports it
660
-
661
- **Tests**: 50 unit tests + 1 integration (gated on `RUN_INTEGRATION_TESTS=1`).
662
-
663
- **Reused Phase 1 utilities** (cleanup deferred):
664
- - `src/database_client/database_client_service.py:get`
665
- - `src/utils/db_credential_encryption.py:decrypt_credentials_dict`
666
- - `src/pipeline/db_pipeline/db_pipeline_service.py:engine_scope`
667
- - `src/pipeline/db_pipeline/extractor.py:get_schema/profile_column/get_row_count`
668
-
669
- ---
670
-
671
- ## Open contract items (not yet locked)
672
-
673
- - **Joins in IR** — currently single-table only (ARCHITECTURE.md §7); DB owner accepted the constraint for v1, will revisit in PR3 if it's blocking real queries
674
- - **`updated_at` on Source vs `generated_at` on Catalog** — Pydantic models have both; introspector sets per-Source; CatalogStore preserves both
675
- - **Catalog refresh trigger** (open question §3) — default policy is rebuild-on-upload-or-connect; auto-refresh deferred
676
- - **Unstructured catalog entries** (open question §2) — currently empty filter for `source_hint="unstructured"`; revisit when adding doc descriptions
677
- - **PII handling for `sample_values`** (open question §5) — currently nulls them out (skip); mask/synthesize deferred
678
- - **Dialect priority for SQL compiler** — PR3 will land Postgres first, MySQL second; BigQuery/Snowflake/SQL Server later
679
-
680
- ---
681
-
682
- ## How to update this file
683
-
684
- When a PR lands:
685
- 1. Flip status from `[ ]` or `[~]` to `[x]`
686
- 2. Add a short note (file paths, scope cuts, surprises)
687
- 3. Bump "Last updated" at the top
688
- 4. If a new contract decision lands, move it from "Open contract items" to the relevant inline note
689
-
690
- When opening a PR:
691
- 1. Flip status to `[~]` and add yourself as the active owner in the PR row
692
- 2. Don't promise items in the PR description that aren't in the table
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
REPO_CONTEXT.md DELETED
@@ -1,494 +0,0 @@
1
- # Repo Context — Agentic Service Data Eyond Catalog
2
-
3
- Orientation file for future Claude Code sessions. Cross-reference `ARCHITECTURE.md` for the full design rationale and decision log.
4
-
5
- ---
6
-
7
- ## Product vision — Data Eyond, your AI data scientist
8
-
9
- Data Eyond is positioned as an *AI data scientist* that supports business analytics. It is built around the **CRISP-DM** framework (Business Understanding → Data Understanding → Data Preparation → Modeling → Evaluation → Deployment) — the agent works through data problems the way a real analyst would, not as a one-shot Q&A bot.
10
-
11
- **Target users:**
12
- - **Executives** — deep-dive into their own data and extract insight to drive business decisions without needing a data team in the loop.
13
- - **Data analysts / scientists** — offload routine analysis so they can focus on heavier work.
14
-
15
- **Envisioned user flow:**
16
- 1. **Discovery interview** — a short conversation with a Data Eyond *interview agent* that draws out goal, business context, and what the user is actually trying to learn (CRISP-DM Business Understanding).
17
- 2. **Connect data** — DB connection or file upload (DB, CSV, XLSX, Parquet, documents).
18
- 3. **Ask Data Eyond** — natural-language analytical question.
19
- 4. **CRISP-DM-structured analytical response** — exportable as a **presentation deliverable** or a **notebook-style report**.
20
-
21
- North star: less "chatbot over a database", more "junior data scientist that hands back a polished, decision-ready deliverable."
22
-
23
- The current repo (Phase 2, below) is the *foundation* — IntentRouter → QueryPlanner → Executor → ChatbotAgent gives us a reliable structured-query spine. The next evolution is the agentic layer that turns this into an end-to-end CRISP-DM workflow (see *Roadmap — agentic evolution* further down).
24
-
25
- ---
26
-
27
- ## TL;DR
28
-
29
- FastAPI multi-agent backend for data analysis. Users upload documents and register databases / tabular files; they ask natural-language questions and get answers grounded in their data, streamed via SSE.
30
-
31
- The architecture has two paths:
32
-
33
- - **Unstructured** (PDF, DOCX, TXT) — dense similarity over prose chunks (PGVector).
34
- - **Structured** (databases, XLSX, CSV, Parquet) — a per-user **data catalog** describes what tables/columns exist; an LLM produces a **JSON IR** of intent; a deterministic Python compiler turns the IR into SQL or pandas; the executor runs it.
35
-
36
- The LLM produces *intent*, not query syntax. Deterministic code does the rest.
37
-
38
- The Phase 2 end-to-end flow is **wired and runnable** as of 2026-05-12. See *Implementation status* below for the per-file matrix. `PROGRESS.md` is the authoritative line-by-line tracker; this file is the orientation.
39
-
40
- ---
41
-
42
- ## Stack
43
-
44
- - Python 3.12, FastAPI 0.115, uvicorn, sse-starlette
45
- - Async SQLAlchemy 2.0 + asyncpg (Postgres), psycopg3 (PGVector multi-statement workaround)
46
- - LangChain 0.3 + langchain-postgres (PGVector) + langchain-openai (Azure OpenAI GPT-4o + embeddings)
47
- - LangGraph 0.2 + langgraph-checkpoint-postgres
48
- - Redis 5 (response + retrieval cache)
49
- - Azure Blob Storage (uploads + Parquet)
50
- - pandas, pyarrow, polars-ready (deferred), sqlglot, pydantic v2, structlog, slowapi, langfuse
51
- - presidio-analyzer + spaCy `en_core_web_lg` (PII), pytesseract + pdf2image (PDF OCR)
52
- - DB connectors: psycopg2, pymysql, pymssql, sqlalchemy-bigquery, snowflake-sqlalchemy
53
-
54
- Run: `uv run --no-sync uvicorn main:app --host 0.0.0.0 --port 7860`. On Windows use `uv run --no-sync python run.py` (sets `WindowsSelectorEventLoopPolicy` for psycopg3 async).
55
-
56
- ---
57
-
58
- ## Top-level layout
59
-
60
- ```
61
- main.py — FastAPI app + middleware + router wiring + init_db() on startup
62
- run.py — Windows-safe local entry point
63
- ARCHITECTURE.md — design intent (source of truth for shape + invariants)
64
- README.md
65
- Dockerfile — python:3.12-slim, installs spaCy en_core_web_lg, tesseract, poppler
66
- pyproject.toml / uv.lock
67
- scripts/ — backfill scripts (build_initial_catalogs, enrich_all_sources)
68
- src/ — all application code
69
- ```
70
-
71
- ---
72
-
73
- ## src/ map
74
-
75
- ### Core data shapes (only files with real content)
76
-
77
- | Path | Role |
78
- |---|---|
79
- | `catalog/models.py` | Pydantic: `Catalog → Source[] → Table[] → Column[]` |
80
- | `query/ir/models.py` | `QueryIR` (select / filters / group_by / order_by / limit) |
81
- | `query/ir/operators.py` | `ALLOWED_FILTER_OPS`, `ALLOWED_AGG_FNS`, `LIMIT_HARD_CAP=10000` |
82
- | `security/pii_patterns.py` | name patterns + email/phone regex for PII detection |
83
-
84
- ### Catalog — identity layer for structured sources (Cs ∪ Ct)
85
-
86
- | Path | Role |
87
- |---|---|
88
- | `catalog/introspect/base.py` | `BaseIntrospector.introspect(location_ref) -> Source` |
89
- | `catalog/introspect/database.py` | `information_schema` + ~100 row sample → draft Source |
90
- | `catalog/introspect/tabular.py` | Parquet/CSV/XLSX header reader + sample (one Table per sheet for XLSX) |
91
- | `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) |
92
- | `catalog/validator.py` | invariants beyond Pydantic shape (unique IDs, FK refs) |
93
- | `catalog/store.py` | persist as Postgres `jsonb` row keyed by user_id (`get/upsert/delete`) — table `data_catalog` |
94
- | `catalog/reader.py` | load + filter catalog by source_hint (returns full catalog for ≤50 tables) |
95
- | `catalog/pii_detector.py` | flag PII columns at ingestion → suppresses `sample_values` |
96
-
97
- ### Query — catalog-driven structured path
98
-
99
- | Path | Role |
100
- |---|---|
101
- | `query/service.py` | `QueryService.run(user_id, question, catalog) -> QueryResult` (top-level) |
102
- | `query/planner/service.py` | LLM call: question + catalog → QueryIR (structured output) |
103
- | `query/planner/prompt.py` | renders catalog into the planner prompt |
104
- | `query/ir/validator.py` | catalog-aware IR validation: column_ids exist, ops whitelisted, value_type matches data_type, limit ≤ cap |
105
- | `query/compiler/base.py` | `BaseCompiler.compile(ir) -> object` |
106
- | `query/compiler/sql.py` | IR → `(sql, params)`; identifiers from catalog, values parameterized |
107
- | `query/compiler/pandas.py` | IR → callable that runs against a DataFrame |
108
- | `query/executor/base.py` | `BaseExecutor.run(ir) -> QueryResult` (uniform across backends) |
109
- | `query/executor/db.py` | runs compiled SQL via asyncpg/pymysql in read-only txn (sqlglot second-line defence) |
110
- | `query/executor/tabular.py` | runs pandas/polars chain on a Parquet file (eager pandas → pyarrow pushdown → polars lazy by file size) |
111
- | `query/executor/dispatcher.py` | picks DB vs Tabular executor based on `source.source_type` of the IR's source |
112
-
113
- ### Retrieval — unstructured path (Cu)
114
-
115
- | Path | Role |
116
- |---|---|
117
- | `retrieval/document.py` | `DocumentRetriever` over PGVector chunks |
118
- | `retrieval/router.py` | dispatches the `unstructured` route (the `chat` and `structured` routes do not pass through here) |
119
-
120
- ### Agents — the three LLM call sites
121
-
122
- | Path | Role |
123
- |---|---|
124
- | `agents/orchestration.py` | `OrchestratorAgent` — classifies message → `needs_search`, `source_hint ∈ {chat, unstructured, structured}`, `rewritten_query`. Filename + class name kept from Phase 1; body replaced with Phase 2 logic. Output model is `IntentRouterDecision` |
125
- | `agents/chatbot.py` | `ChatbotAgent` — final answer formation (receives Cu chunks or QueryResult); SSE-streamed via `astream` |
126
- | `agents/chat_handler.py` | `ChatHandler` — top-level orchestrator; routes to chat / unstructured / structured and yields SSE-style `intent`/`chunk`/`done`/`error` events |
127
-
128
- (`QueryPlanner` is the third LLM call site, under `query/planner/`. The
129
- fourth — `CatalogEnricher` — was removed in KM-557; ingestion no longer
130
- makes any LLM calls.)
131
-
132
- ### Pipelines — ingestion coordinators
133
-
134
- | Path | Role |
135
- |---|---|
136
- | `pipeline/structured_pipeline.py` | DB / tabular: introspect → merge → validate → store (no enrich step since KM-557) |
137
- | `pipeline/document_pipeline.py` | unstructured: extract → chunk → embed → PGVector. CSV/XLSX skip vector store (catalog only). Invalidates retrieval cache on process/delete. |
138
- | `pipeline/triggers.py` | event entry points called by API routes: `on_db_registered`, `on_tabular_uploaded`, `on_document_uploaded`, `on_catalog_rebuild_requested` |
139
-
140
- (`pipeline/orchestrator.py` was deleted in the Cleanup PR — it was a redundant stub; `StructuredPipeline` already takes the introspector at `run()` time.)
141
-
142
- ### Security — cross-cutting
143
-
144
- | Path | Role |
145
- |---|---|
146
- | `security/auth.py` | bcrypt password hash/verify, JWT encode/decode, get_user |
147
- | `security/credentials.py` | Fernet encrypt/decrypt for stored DB credentials |
148
- | `security/pii_patterns.py` | (already listed) |
149
-
150
- ### API + infra + config
151
-
152
- | Path | Role |
153
- |---|---|
154
- | `api/v1/*.py` | FastAPI routers — thin endpoints delegating to `pipeline/triggers` and `query/service` |
155
- | `models/api/{catalog,chat,document}.py` | request/response Pydantic models |
156
- | `db/postgres/connection.py` | two async engines: `engine` (app) and `_pgvector_engine` (PGVector) |
157
- | `db/postgres/init_db.py` | startup: creates `vector` extension, all tables, HNSW + GIN indexes |
158
- | `db/postgres/models.py` | SQLAlchemy app tables (users, rooms, chat messages, …) |
159
- | `db/postgres/vector_store.py` | shared PGVector instance (collection `documents` — written by Go ingestion service) |
160
- | `db/redis/connection.py` | async Redis client |
161
- | `storage/az_blob/az_blob.py` | Azure Blob async wrapper (uploads + Parquet) |
162
- | `middlewares/{cors,logging,rate_limit}.py` | CORS allow-all (POC), structlog JSON, slowapi |
163
- | `observability/langfuse/langfuse.py` | trace helper |
164
- | `config/settings.py` | pydantic-settings; `.env` uses double-underscore aliases |
165
- | `config/env_constant.py` | env file path constant |
166
- | `config/prompts/*.md` | prompt templates: `intent_router`, `query_planner`, `chatbot_system`, `guardrails` (KM-557 removed `catalog_enricher`) |
167
-
168
- ---
169
-
170
- ## Core architectural decisions
171
-
172
- 1. **Catalog as primary context, not retrieval.** For ≤50 tables (typical), the entire catalog is rendered into the planner prompt verbatim (~3–5k tokens). No vector search, no BM25, no top-k for structured data. Catalog-level retrieval (BM25 + table-level vectors with RRF) is the *deferred* upgrade for users with hundreds of tables.
173
-
174
- 2. **JSON IR over raw SQL.** The planner LLM emits a Pydantic-validated intent, never a SQL string. The compiler is deterministic Python. Benefits: validatable before execution, dialect-portable (one IR → SQL of any dialect / pandas / polars), cheaper tokens, trivially testable without an LLM, and the LLM literally cannot emit invalid SQL syntax.
175
-
176
- 3. **Deterministic compiler, not LLM SQL writer.** All actual query construction happens in pure code. Compiler bugs are reproducible and fixable. Same IR → same query.
177
-
178
- 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.
179
-
180
- 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):
181
- - `IntentRouter` — once per user message
182
- - `QueryPlanner` — once per structured query
183
- - `ChatbotAgent` — once per answer (formatting)
184
-
185
- 6. **Three-way routing**: `chat` / `unstructured` / `structured`. The router commits to one path. Cross-source questions ("compare DB sales vs uploaded customer file") are handled inside the structured path because the planner sees Cs ∪ Ct in one prompt. **DB vs tabular is not a routing concern** — it's a per-source attribute (`source_type`) that only matters at execution time.
186
-
187
- 7. **Stable IDs.** `source_id`, `table_id`, `column_id` are stable internal references. Renaming a column in the source DB does not invalidate cached IRs.
188
-
189
- 8. **PII suppression at the boundary.** Columns flagged with `pii_flag=true` have `sample_values: null` — real PII never enters LLM prompts. Auto-detected at ingestion via name patterns + value regex (`security/pii_patterns.py`). When in doubt, flag — false positives cost nothing; false negatives leak data.
190
-
191
- ---
192
-
193
- ## End-to-end flows
194
-
195
- ### Ingestion (when user uploads a file or connects a DB)
196
-
197
- ```
198
- source upload / DB connect
199
-
200
- ├── unstructured (pdf/docx/txt)
201
- │ → DocumentPipeline: extract → chunk → embed → PGVector
202
-
203
- └── structured (DB schema or tabular file)
204
- → introspect (information_schema or file headers + sample rows)
205
- → CatalogValidator (Pydantic + unique-IDs + FK refs)
206
- → CatalogStore.upsert(user_id jsonb row in `data_catalog`)
207
- ```
208
-
209
- ### Query (per user message)
210
-
211
- ```
212
- user message
213
-
214
- → Redis cache check (24h TTL) ── miss ─→ continue
215
-
216
- → IntentRouter LLM → needs_search? source_hint?
217
-
218
- ├── chat → ChatbotAgent → SSE stream
219
- ├── unstructured → DocumentRetriever (Cu) → ChatbotAgent → SSE stream
220
- └── structured →
221
- CatalogReader.read(user_id, "structured") # full Cs ∪ Ct
222
-
223
- QueryPlanner LLM(question, catalog) → QueryIR
224
-
225
- IRValidator.validate(ir, catalog)
226
- (source_id ∈ catalog, table_id ∈ source, column_ids ∈ table,
227
- ops/aggs whitelisted, value_type matches data_type, limit ≤ 10000)
228
- fail → re-prompt planner with error context (max 3 retries)
229
-
230
- ExecutorDispatcher.pick(ir) # by source.source_type
231
- ├─ DbExecutor → SqlCompiler → sqlglot guard → asyncpg/pymysql
232
- │ (read-only txn, 30s timeout)
233
- └─ TabularExecutor → PandasCompiler → eager pandas (≤100 MB)
234
- or pyarrow pushdown (100 MB–1 GB)
235
- or polars lazy scan (>1 GB)
236
-
237
- QueryResult
238
-
239
- ChatbotAgent → SSE stream
240
- ```
241
-
242
- ---
243
-
244
- ## Catalog schema (per-user `jsonb` row)
245
-
246
- ```
247
- Catalog
248
- ├── user_id, schema_version, generated_at
249
- └── sources[]
250
- └── Source { source_id, source_type, name, description, location_ref, updated_at }
251
- └── tables[]
252
- └── Table { table_id, name, description, row_count, foreign_keys[] }
253
- ├── columns[]
254
- │ └── Column { column_id, name, data_type, description,
255
- │ nullable, pii_flag, sample_values[]|null, stats|null }
256
- └── foreign_keys[]
257
- └── ForeignKey { column_id, target_table_id, target_column_id }
258
- ```
259
-
260
- `source_type ∈ {schema, tabular, unstructured}`.
261
- `data_type ∈ {int, decimal, string, datetime, date, bool, json}`.
262
- `ForeignKey` references are within the SAME `Source` only; cross-source FKs are not modeled.
263
-
264
- Deferred Column fields (add when justified): `description_human`, `synonyms[]`, `tags[]`, `primary_key`, `unit`, `semantic_type`, `example_questions[]`, `schema_hash`, `enrichment_status`.
265
-
266
- ---
267
-
268
- ## JSON IR schema
269
-
270
- ```jsonc
271
- {
272
- "ir_version": "1.0",
273
- "source_id": "...",
274
- "table_id": "...",
275
- "select": [
276
- {"kind": "column", "column_id": "...", "alias": "..."},
277
- {"kind": "agg", "fn": "count|count_distinct|sum|avg|min|max",
278
- "column_id": "...?", "alias": "..."}
279
- ],
280
- "filters": [
281
- {"column_id": "...",
282
- "op": "= | != | < | <= | > | >= | in | not_in | is_null | is_not_null | like | between",
283
- "value": ...,
284
- "value_type": "int|decimal|string|datetime|date|bool"}
285
- ],
286
- "group_by": ["column_id", ...],
287
- "order_by": [{"column_id": "...", "dir": "asc|desc"}],
288
- "limit": 100
289
- }
290
- ```
291
-
292
- Single-table only in v1. `having`, `offset`, boolean filter trees, `distinct`, joins, window functions are deferred until user demand proves the limitation.
293
-
294
- ---
295
-
296
- ## Implementation status
297
-
298
- **As of 2026-05-12 — Phase 2 end-to-end flow is wired.** `PROGRESS.md` has the per-PR line-item table; this section is the high-level snapshot. Stub files (`raise NotImplementedError`) are now the exception, not the rule.
299
-
300
- | Area | Status | Notes |
301
- |---|---|---|
302
- | Catalog Pydantic models | ✅ | `catalog/models.py` — incl. `ForeignKey`, `ColumnStats.top_values` |
303
- | JSON IR Pydantic models | ✅ | `query/ir/models.py` + `operators.py` (TYPE_COMPATIBILITY filled) |
304
- | Catalog ingestion — DB | ✅ | introspect → validate → upsert. `on_db_registered` wired; `/api/v1/db-clients/{id}/ingest` calls it |
305
- | Catalog ingestion — tabular | ✅ | CSV/XLSX/Parquet; `on_tabular_uploaded` wired into `/api/v1/document/process`. XLSX → one Table per sheet. CSV/XLSX skip vector store |
306
- | Catalog ingestion — unstructured | ✅ | `on_document_uploaded` implemented; full DocumentPipeline (extract → chunk → embed → PGVector) |
307
- | Catalog store / reader / validator / PII detector | ✅ | `data_catalog` jsonb table (renamed from `catalogs` in KM-557) |
308
- | LLM enrichment | ❌ removed (KM-557) | Cost cut — planner reads `column.stats` + `sample_values` + `top_values` + `column.name` directly. `catalog/render.py` keeps the source-rendering helper |
309
- | `IntentRouter` (lives as `OrchestratorAgent` in `agents/orchestration.py`) | ✅ | 3-way `source_hint`, history-aware query rewriting. Filename + class name kept from Phase 1; Phase 2 body |
310
- | `CatalogReader` | ✅ | Loads full catalog; filters by `source_hint` |
311
- | `QueryPlanner` LLM call | ✅ | Azure OpenAI structured output → `QueryIR`; supports retry with `previous_error` |
312
- | IR validator | ✅ | Catalog-aware; full rule set; descriptive errors |
313
- | SQL compiler (Postgres) | ✅ | All 12 filter ops, all 6 aggs, alias-aware order_by, parameterized values, quoted identifiers |
314
- | DbExecutor | ✅ | sqlglot SELECT-only guard, RO txn, `statement_timeout=30000`, 10k row cap, never raises |
315
- | Pandas compiler | ✅ | Same op coverage as SQL; pure module-level helpers |
316
- | TabularExecutor | ✅ | Parquet blob path resolution, `asyncio.to_thread`, 10k cap, never raises |
317
- | ExecutorDispatcher | ✅ | Routes by `source.source_type`; lazy imports + cache |
318
- | QueryService | ✅ | plan → validate → retry-on-fail (max 3) → dispatch → execute → `QueryResult` |
319
- | `ChatbotAgent` + prompt + guardrails | ✅ | Renamed from `AnswerAgent` in Cleanup PR. Guardrails appended to `chatbot_system.md` |
320
- | `ChatHandler` (top-level chat orchestrator) | ✅ | SSE events: `intent` / `chunk` / `done` / `error` |
321
- | `DocumentRetriever` + `RetrievalRouter` (Redis-cached) | ✅ | Migrated from `src/rag/` (now deleted). Mentor commit `61c746f` rewrote to raw SQL (pgvector `<=>` cosine, `<+>` manhattan) to dodge asyncpg type-mapping issues with Go-ingested schema. Methods reduced to `cosine | manhattan`. Collection: `documents`. |
322
- | `/api/v1/chat/stream` | ✅ | Rewired to `ChatHandler`; Redis cache + fast intent + history + message persistence remain in chat.py |
323
- | `/api/v1/db-clients/{id}/ingest` | ✅ | Calls only `on_db_registered`; Phase 1 dual-write removed |
324
- | `/api/v1/document/{upload,process,delete}` | ✅ | `/process` triggers `on_tabular_uploaded` for CSV/XLSX |
325
- | `GET /api/v1/data-catalog/{user_id}` | ✅ | Index endpoint (KM-557) |
326
- | `POST /api/v1/data-catalog/rebuild` | ✅ | Iterates sources, re-runs per-source trigger |
327
- | Credential encryption | ⚠️ stub | `security/credentials.py` not migrated; runtime reuses Phase 1 `utils/db_credential_encryption.py` |
328
- | Tests | ✅ 146+ unit | Compilers (DB 36, Pandas 43), validators, introspectors, agents, chat handler, dispatcher, planner |
329
- | Planner eval harness | 🟡 scaffold | 3 DB + 4 tabular golden cases. Gated on `RUN_PLANNER_EVAL=1`. Real Azure OpenAI passing |
330
- | E2E smoke tests | ❌ not started | Component-level orchestration is covered |
331
- | DB introspector unit test | ❌ deferred | Needs Postgres testcontainer |
332
- | Sources event in `/chat/stream` | ⚠️ emits `[]` | `ChatHandler` doesn't surface retrieval sources yet; same gap reflected in `save_messages` |
333
-
334
- **Deferred to later phases**: joins in IR, schema drift detection, hybrid catalog search (BM25 + RRF for 100+ table users), polars lazy scan for >1GB tabular files, MySQL/BigQuery/Snowflake SQL dialects, mask/synthesize PII strategies.
335
-
336
- ---
337
-
338
- ## Team — division of work
339
-
340
- The service is built by two engineers; many modules are source-type-agnostic and shared.
341
-
342
- - **DB** owns SQL paths: introspection, SQL compiler, DB executor, credential storage.
343
- - **TAB** owns tabular paths: CSV/XLSX/Parquet introspection, pandas compiler, tabular executor, blob/Parquet plumbing.
344
- - **B** = both — shared contracts and source-type-agnostic plumbing. Pair-program or split with explicit hand-off.
345
-
346
- ### Step-by-step ownership
347
-
348
- | # | Step | File / area | Owner | Notes |
349
- |---|---|---|---|---|
350
- | 0 | **Lock contracts before coding** | — | B | See "Decisions to lock" below; block until aligned |
351
- | 1 | Catalog Pydantic models | `catalog/models.py` | B | Already done; only touch if both agree |
352
- | 2 | IR Pydantic models | `query/ir/models.py` | B | Already done; joins/window fns require joint sign-off |
353
- | 3 | IR operator whitelists | `query/ir/operators.py` | B | Already done; both compilers rely on these |
354
- | 4 | PII patterns / regex | `security/pii_patterns.py` | B | Already done; extend together as gaps appear |
355
- | **Ingestion — introspection** | | | | |
356
- | 5 | DB introspector (information_schema, sample, FKs) | `catalog/introspect/database.py` | DB | Use SQLAlchemy `inspect()`; dialect-aware quoting |
357
- | 6 | Tabular introspector (CSV/XLSX/Parquet headers + sample) | `catalog/introspect/tabular.py` | TAB | Each XLSX sheet → one Table |
358
- | 7 | `BaseIntrospector` ABC | `catalog/introspect/base.py` | B | Confirm signature returns the same `Source` shape |
359
- | **Ingestion — shared catalog plumbing** | | | | |
360
- | 8 | ~~Catalog enricher + prompt~~ | — | **REMOVED in KM-557.** Cost optimization — planner reads stats + sample rows directly. `catalog/render.py` keeps the source-rendering helper. |
361
- | 9 | Catalog validator | `catalog/validator.py` | B | Type-agnostic |
362
- | 10 | Catalog store (Postgres jsonb) | `catalog/store.py` | B | Recommend DB (Postgres expertise) |
363
- | 11 | Catalog reader | `catalog/reader.py` | B | Type-agnostic |
364
- | 12 | PII detector | `catalog/pii_detector.py` | B | Either; uses `pii_patterns.py` |
365
- | **Ingestion — pipelines** | | | | |
366
- | 13 | Structured pipeline (introspect → enrich → validate → store) | `pipeline/structured_pipeline.py` | B | Pair on this — calls both introspectors via dispatcher |
367
- | 14 | Triggers (`on_db_registered`, `on_tabular_uploaded`) | `pipeline/triggers.py` | B | Each owns their trigger function |
368
- | 15 | Ingestion orchestrator | `pipeline/orchestrator.py` | B | Routes by source_type; pair |
369
- | 16 | Document pipeline (PDF/DOCX/TXT) | `pipeline/document_pipeline.py` | TAB | Tabular-adjacent (file uploads) |
370
- | **Query — shared spine** | | | | |
371
- | 17 | IR validator (catalog-aware) | `query/ir/validator.py` | B | Recommend DB; both must agree on exact error messages so retry-prompt is consistent |
372
- | 18 | Planner LLM service | `query/planner/service.py` | B | Type-agnostic |
373
- | 19 | Planner prompt (catalog → text) | `query/planner/prompt.py`, `config/prompts/query_planner.md` | B | **Pair-program**. Must describe DB tables and tabular files in one consistent format |
374
- | 20 | Intent router (chat/unstructured/structured) | `agents/orchestration.py` (class `OrchestratorAgent` — Phase 1 filename + class name preserved; Phase 2 body), `config/prompts/intent_router.md` | B | Type-agnostic. The prompt file uses `intent_router.md`, but the source module is still `orchestration.py` |
375
- | 21 | Executor base + `QueryResult` | `query/executor/base.py` | B | Lock the shape before either implements an executor |
376
- | 22 | Executor dispatcher | `query/executor/dispatcher.py` | B | Reads `source.source_type` from catalog; pair |
377
- | 23 | Compiler base ABC | `query/compiler/base.py` | B | Already done |
378
- | 24 | Top-level QueryService | `query/service.py` | B | Wires planner → validator → compiler → executor; pair |
379
- | **Query — DB path** | | | | |
380
- | 25 | SQL compiler (IR → SQL + params, per dialect) | `query/compiler/sql.py` | DB | Identifiers from catalog (quoted), values parameterized |
381
- | 26 | DB executor (asyncpg/pymysql, sqlglot guard, RO txn, 30s timeout) | `query/executor/db.py` | DB | |
382
- | 27 | Credential encryption (Fernet) | `security/credentials.py` | DB | Needed for stored user DB creds |
383
- | 28 | User-DB connection management | helper in pipelines | DB | engine_scope context manager pattern |
384
- | **Query — Tabular path** | | | | |
385
- | 29 | Pandas compiler (IR → callable on DataFrame) | `query/compiler/pandas.py` | TAB | Same IR, different backend |
386
- | 30 | Tabular executor (eager pandas first; pyarrow / polars later) | `query/executor/tabular.py` | TAB | Initial scope: eager pandas only |
387
- | 31 | Parquet upload/download + Azure Blob wrapper | `storage/az_blob/az_blob.py` (+ helper) | TAB | XLSX sheet → one Parquet per sheet (deterministic blob name) |
388
- | **Agents + chat** | | | | |
389
- | 32 | Chatbot agent + prompt | `agents/chatbot.py`, `config/prompts/chatbot_system.md` | B | Receives QueryResult or Cu chunks |
390
- | 33 | Guardrails prompt | `config/prompts/guardrails.md` | B | |
391
- | **API surface** | | | | |
392
- | 34 | DB client endpoints (register/ingest/list/delete) | `api/v1/db_client.py` | DB | |
393
- | 35 | Document/tabular upload endpoints | `api/v1/document.py` | TAB | |
394
- | 36 | Chat stream endpoint (SSE) | `api/v1/chat.py` | B | Dispatches both paths; pair |
395
- | 37 | Room / users endpoints | `api/v1/room.py`, `api/v1/users.py` | B | Whoever has bandwidth |
396
- | **Tests + eval** | | | | |
397
- | 38 | DB compiler golden tests (IR → SQL fixtures) | `tests/query/compiler/test_sql.py` | DB | Pure-Python, no LLM |
398
- | 39 | Pandas compiler golden tests (IR → expected DataFrame) | `tests/query/compiler/test_pandas.py` | TAB | Pure-Python, no LLM |
399
- | 40 | IR validator tests (catalog × IR error matrix) | `tests/query/ir/test_validator.py` | B | Each contributes test cases for their source type |
400
- | 41 | Planner eval (golden question → IR examples) | `tests/query/planner/` | B | Each contributes ~10 question→IR examples |
401
- | 42 | E2E smoke tests | `tests/e2e/` | B | Pair |
402
-
403
- ### Decisions to lock before coding
404
-
405
- If made unilaterally these create silent contract drift. Lock them in a 30-min sync first.
406
-
407
- | Decision | Why it matters | Recommended call |
408
- |---|---|---|
409
- | `QueryResult` shape (current scaffold: `source_id, backend, rows, row_count, truncated, elapsed_ms, error`) | Both executors return this; chatbot consumes it | Lock as-is unless either side needs more (e.g. `column_types` for formatting) |
410
- | `Source.location_ref` format (`az_blob://...` vs `dbclient://{id}` etc.) | Dispatcher and executors both parse this | Pick a convention now; document in `catalog/models.py` docstring |
411
- | Where do user DB credentials live? | DB executor needs creds to run queries; Source has `location_ref` but creds are encrypted separately | Recommend: `location_ref="dbclient://{client_id}"`; executor looks up creds by ID |
412
- | How does dispatcher pick the executor? | Routes by `source.source_type` — but where does dispatcher get it (catalog reload, or IR carries it)? | Recommend: dispatcher takes `(Catalog, IR)`, looks up source by `IR.source_id` |
413
- | Joins in v1 IR? | Excluded per ARCHITECTURE.md §7. DB path is most affected — real DB use often needs joins. | Recommend: ship single-table; revisit in PR 2. **DB owner must accept the constraint or push back early** |
414
- | Planner prompt — render tabular vs DB sources uniformly | If described differently, planner gets confused | Pair-program. Render both as `Table: name (n rows) — Columns: ...` regardless of source_type |
415
- | Error contract — raise or return `QueryResult.error`? | Both executors must behave the same so chatbot branches consistently | Recommend: never raise from `executor.run()`; populate `QueryResult.error` |
416
- | PII handling for tabular `sample_values` | DB samples come from `information_schema`; tabular from file reads. Same `pii_flag` rule must apply both sides | Confirm tabular introspector calls `pii_detector` |
417
- | Catalog refresh trigger (open question §3) | Affects both pipelines symmetrically | Default: rebuild on every upload/connect; defer auto-refresh |
418
- | `updated_at` semantics — per-Source vs per-Catalog | Affects how each pipeline writes | Recommend: per-Source `updated_at` + Catalog-level `generated_at` |
419
- | Dialect support scope for v1 | DB compiler must implement at least one dialect well | Recommend: Postgres first (matches app DB); MySQL second |
420
- | Test-fixture format for golden IRs | Both compilers test against golden IR → expected output | Recommend: shared `tests/fixtures/golden_irs.json`; each side adds expected SQL or DataFrame |
421
- | Logging conventions | structlog is already in place; both should log the same fields | Quick agreement: log `source_id`, `table_id`, `ir_version`, `elapsed_ms` |
422
-
423
- ### Working rhythm (suggested)
424
-
425
- 1. **Day 1** — 30-min sync to lock the decisions table. PR any contract/docstring changes that fall out.
426
- 2. **Week 1** — both build introspectors + agree on the planner prompt format. PR in parallel; review each other's.
427
- 3. **Week 2** — DB builds SQL compiler + DB executor; TAB builds pandas compiler + tabular executor. Both write golden tests against shared IR fixtures.
428
- 4. **Week 3** — pair on dispatcher, QueryService, and chat endpoint integration. End-to-end smoke test.
429
- 5. **Ongoing** — short daily standup, mostly to flag IR-shape questions and catalog-field additions *before* either side implements against an unconfirmed contract.
430
-
431
- Biggest risk: **silent contract drift** — one side adds a `QueryResult` field or assumes a new IR op exists, the other ships without it, and integration breaks at the dispatcher. The §0 lock + shared golden-IR fixtures are what prevent that.
432
-
433
- ### Onboarding to Claude Code
434
-
435
- If you're new to Claude Code, before you start:
436
-
437
- 1. Read `ARCHITECTURE.md` end-to-end (~10 min) — this is the source of truth.
438
- 2. Skim this file (`REPO_CONTEXT.md`) — find your section in the ownership table.
439
- 3. Read your owned files' docstrings — every stub explains its contract.
440
- 4. Open Claude Code in this repo. When you ask Claude to implement a stub:
441
- - Reference the file path + the contract it should follow
442
- - Point it at `ARCHITECTURE.md` section if relevant (e.g. §7 for IR validation)
443
- - Ask it to write the test first (golden IR fixtures), then the implementation
444
- - Always review the diff — don't auto-accept
445
-
446
- Useful slash commands while working: `/review` (PR review), `/security-review` (audit pending changes).
447
-
448
- ---
449
-
450
- ## Conventions & gotchas
451
-
452
- - **Async event loop on Windows**: `run.py` sets `WindowsSelectorEventLoopPolicy` because psycopg3 async needs it. Don't call `uvicorn` directly on Windows.
453
- - **Two Postgres engines**: `engine` (app tables) and `_pgvector_engine` (asyncpg with `prepared_statement_cache_size=0`) — the latter is required because PGVector emits `advisory_lock + CREATE EXTENSION` as a multi-statement string and asyncpg rejects multi-statement prepared queries. `init_db.py` creates the extension explicitly so `PGVector(create_extension=False)` skips that path.
454
- - **Read-only at every layer for user DBs**: IR validation + compiler whitelists + sqlglot SELECT-only check + read-only DB credentials + LIMIT enforcement + 30s timeout. Five layers; no single point of failure.
455
- - **Identifiers vs values**: identifiers (table/column names) come from the catalog and are inlined as quoted identifiers — they were verified at validation time so this is safe. Values from `IR.filters` are *always* parameterized, never inlined as strings.
456
- - **Credential encryption**: Fernet via `dataeyond__db__credential__key` env var; lives in `security/credentials.py`. Sensitive fields = `{"password", "service_account_json"}`.
457
- - **Settings env-var aliases**: `.env` uses double-underscore names (`azureai__api_key__4o`); `Settings` exposes them as `azureai_api_key_4o` via `Field(alias=...)`. Mind both forms when adding settings.
458
- - **Prompts**: `src/config/prompts/*.md` — `intent_router`, `query_planner`, `chatbot_system`, `guardrails` are all written. `chatbot_system` has `guardrails` appended so guardrails take precedence in conflict. `catalog_enricher.md` was deleted in KM-557. `config/agents/` folder deleted in Cleanup PR.
459
- - **Planner prompt parsing gotcha**: `query/planner/service.py` uses `SystemMessage(content=...)` not `("system", text)`. The tuple form causes LangChain to interpret `{...}` in `query_planner.md` as f-string variables and crash on every real invocation. Don't refactor back to tuples.
460
- - **Tests**: 146+ unit tests in place. Run with `uv run pytest`. Planner eval gated on `RUN_PLANNER_EVAL=1`; catalog store integration test gated on `RUN_INTEGRATION_TESTS=1`.
461
-
462
- ---
463
-
464
- ## Recommended reading order
465
-
466
- 1. `ARCHITECTURE.md` — design intent (the source of truth)
467
- 2. `src/catalog/models.py` + `src/query/ir/models.py` — the two data shapes everything else moves between
468
- 3. `src/query/ir/operators.py` + `src/security/pii_patterns.py` — the explicit whitelists / patterns
469
- 4. Skim every `__init__.py`-level docstring under `src/catalog/`, `src/query/`, `src/agents/`, `src/pipeline/` — each describes the contract its module enforces
470
- 5. `main.py` + `src/db/postgres/{connection,init_db}.py` — runtime bootstrap
471
- 6. `ARCHITECTURE.md §10` — five open questions that haven't been decided yet
472
-
473
- ---
474
-
475
- ## Open questions
476
-
477
- Resolved as Phase 2 landed:
478
-
479
- 1. ✅ Catalog storage shape — Postgres `jsonb` row in `data_catalog` table, keyed by `user_id`.
480
- 2. ❌ Unstructured files in catalog — still not modeled; router uses `source_hint` from the LLM instead.
481
- 3. 🟡 Catalog refresh trigger — rebuild-on-upload-or-connect is the default. Explicit endpoint `POST /api/v1/data-catalog/rebuild` exists. Background TTL deferred.
482
- 4. ✅ Joins out of v1 IR — confirmed; single-table only. Revisit when real queries need it.
483
- 5. 🟡 PII `sample_values` — currently nulled out (skip). Mask/synthesize deferred.
484
-
485
- ---
486
-
487
- ## Glossary
488
-
489
- - **Cu** — unstructured context (prose chunks)
490
- - **Cs** — schema context (DB tables/columns from catalog)
491
- - **Ct** — tabular context (file sheets/columns from catalog)
492
- - **IR** — intermediate representation (the JSON query shape)
493
- - **PII** — personally identifiable information
494
- - **ABC** — abstract base class