API_CONTRACT_BE_PYTHON.md CHANGED
@@ -467,10 +467,13 @@ Field rules:
467
 
468
  - `planning`: present only when the planner ran (`structured_flow`); otherwise `null`.
469
  - `thinking`: **always `null` in v1** — our agents are plain chat completions with no native reasoning output, and synthesizing it post-hoc would be unfaithful. The field stays in the payload so it can be populated later without a contract change.
470
- - `tool_calls`: every invoked tool with `input`, `output`, `status`, `task_id` (nullable), and `error` (nullable); empty for chat / help / greeting / refusal paths.
471
- - `sources`: required for retrieval flows; empty for chat / help / refusal paths and for `check`.
 
 
 
472
  - The payload also carries an internal `user_id` (ownership); the frontend may ignore it.
473
- - Truncation: `preview` ≤ 5 rows; any string inside `input`/`output`/`preview`/`snippet` ≤ 300 chars; rows beyond the preview are dropped (`row_count` is preserved).
474
 
475
  Response `200` for `structured_flow`:
476
 
@@ -507,6 +510,7 @@ Response `200` for `structured_flow`:
507
  "order": 1,
508
  "task_id": null,
509
  "name": "check_data",
 
510
  "input": { "source_hint": "structured" },
511
  "output": {
512
  "kind": "table",
@@ -521,6 +525,7 @@ Response `200` for `structured_flow`:
521
  "order": 2,
522
  "task_id": null,
523
  "name": "retrieve_data",
 
524
  "input": { "ir": { "source_id": "src_sales_db", "table_id": "orders", "select": ["region", "amount"], "group_by": ["region"] } },
525
  "output": {
526
  "kind": "table",
@@ -532,11 +537,34 @@ Response `200` for `structured_flow`:
532
  "error": null
533
  }
534
  ],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
535
  "sources": [
536
  {
537
  "type": "database",
538
  "source_id": "src_sales_db",
 
539
  "name": "orders",
 
540
  "query": "SELECT region, SUM(amount) AS total FROM orders GROUP BY region",
541
  "detail": {
542
  "table": "orders",
@@ -547,7 +575,7 @@ Response `200` for `structured_flow`:
547
  }
548
  ```
549
 
550
- > Note: `retrieve_data`'s real `input` is the compiled query IR under an `ir` key (the planner builds an IR, never raw SQL). The executed SQL/rendered query appears on the corresponding `sources[].query`.
551
 
552
  Response `200` for `unstructured_flow`:
553
 
 
467
 
468
  - `planning`: present only when the planner ran (`structured_flow`); otherwise `null`.
469
  - `thinking`: **always `null` in v1** — our agents are plain chat completions with no native reasoning output, and synthesizing it post-hoc would be unfaithful. The field stays in the payload so it can be populated later without a contract change.
470
+ - `tool_calls`: every invoked tool with `summary` (plain-English one-liner), `input`, `output`, `status`, `task_id` (nullable), and `error` (nullable); empty for chat / help / greeting / refusal paths. `input`/`output` are the raw tool I/O (opaque ids) — render them in a collapsible "technical details" section, not the headline; use `summary` for the headline.
471
+ - `data_used`: one entry per `retrieve_data` call, resolved to **real names** for display (present only when a structured pull ran; empty otherwise). Split into `columns_read` (columns read straight from the user's data, each tagged with its `roles`) and `output_columns` (`kind: "column"` = read from data, `kind: "computed"` = calculated, carrying a `formula` and **no id**). Also carries `tables` (all touched, incl. join targets), `joins`, `filters` (with a plain-language `description`), `group_by`, `order_by`, `limit`, `rows_returned`, and the executed `query`. Built by deterministic catalog lookup — no LLM.
472
+ - **`id` fields are machine-only.** Every `id` in `data_used` (`source.id`, `tables[].id`, `columns_read[].id`) is for linking/reconciliation/audit — **the frontend must never render it.** Show `name` (qualified as `table.name`). A `computed` output column has no id by design.
473
+ - `sources`: required for retrieval flows; empty for chat / help / refusal paths and for `check`. Database sources also carry `source_name` (the DB's real name) and `tables` (every table touched).
474
+ - `thinking`, `filters[].description`, `tool_calls[].summary` are built from fixed templates, never an LLM — traceability adds no latency or token cost and cannot hallucinate.
475
  - The payload also carries an internal `user_id` (ownership); the frontend may ignore it.
476
+ - Truncation: `preview` ≤ 5 rows; any string inside `input`/`output`/`preview`/`snippet` ≤ 300 chars (executed `query` ≤ 2000); rows beyond the preview are dropped (`row_count` is preserved).
477
 
478
  Response `200` for `structured_flow`:
479
 
 
510
  "order": 1,
511
  "task_id": null,
512
  "name": "check_data",
513
+ "summary": "Inspected your data source structure",
514
  "input": { "source_hint": "structured" },
515
  "output": {
516
  "kind": "table",
 
525
  "order": 2,
526
  "task_id": null,
527
  "name": "retrieve_data",
528
+ "summary": "Retrieved 4 rows across 2 columns from orders",
529
  "input": { "ir": { "source_id": "src_sales_db", "table_id": "orders", "select": ["region", "amount"], "group_by": ["region"] } },
530
  "output": {
531
  "kind": "table",
 
537
  "error": null
538
  }
539
  ],
540
+ "data_used": [
541
+ {
542
+ "source": { "id": "src_sales_db", "name": "sales db", "type": "schema" },
543
+ "tables": [ { "id": "orders", "name": "orders", "role": "base" } ],
544
+ "joins": [],
545
+ "columns_read": [
546
+ { "id": "c_region", "name": "region", "table": "orders", "data_type": "string", "pii": false, "roles": ["selected", "grouped"] },
547
+ { "id": "c_amount", "name": "amount", "table": "orders", "data_type": "decimal", "pii": false, "roles": ["aggregated"] }
548
+ ],
549
+ "output_columns": [
550
+ { "name": "region", "kind": "column", "from": "orders.region" },
551
+ { "name": "total", "kind": "computed", "from": "orders.amount", "formula": "SUM(orders.amount)" }
552
+ ],
553
+ "filters": [],
554
+ "group_by": ["orders.region"],
555
+ "order_by": [],
556
+ "limit": null,
557
+ "rows_returned": 4,
558
+ "query": "SELECT region, SUM(amount) AS total FROM orders GROUP BY region"
559
+ }
560
+ ],
561
  "sources": [
562
  {
563
  "type": "database",
564
  "source_id": "src_sales_db",
565
+ "source_name": "sales db",
566
  "name": "orders",
567
+ "tables": ["orders"],
568
  "query": "SELECT region, SUM(amount) AS total FROM orders GROUP BY region",
569
  "detail": {
570
  "table": "orders",
 
575
  }
576
  ```
577
 
578
+ > Note: `tool_calls[].input` is the raw compiled query IR (opaque `column_id`/`table_id`) — the technical layer. **`data_used` is the user-facing layer**: the same pull resolved to real names, with computed columns (e.g. `total`) flagged `kind: "computed"` so they are never shown as if they were columns in the user's database. Every `id` there is machine-only.
579
 
580
  Response `200` for `unstructured_flow`:
581
 
DEV_PLAN.md CHANGED
@@ -65,6 +65,8 @@ base64-mangled from Go. Fix tasks (same status legend as §0):
65
  | Q7 | `analyze_merge` two-table combine tool (unblocks "worst A + biggest B" questions) | tool owner | ✅ | tool shipped by Sofia (8abf635, KM-703); planner slice done 2026-07-09: `_validate_data_source` guards `data_right`, two-retrieve→merge few-shot (Example I), planner.md "Two measures per entity" bullet |
66
  | Q8 | Report v2: business-question answer section, unresolved/excluded sections, evidence tables from `results_snapshot`, caveat dedupe, single language | Rifqi/Sofhia | ✅ | done 2026-07-09: still exactly ONE LLM call (extended to also draft `bq_answers`, index-based record refs, deterministic fallback = v1 behavior); evidence tables from table-kind outputs (≤3/record, ≤10 rows, ≤8 cols, `check_*` skipped); reply language via `detect_reply_language` on objective+BQs; verified in-process against live analysis 935a091e |
67
  | Q9 | Record-curation endpoint (`GET …/records` + `exclude_record_ids`) + readiness GET for the FE delta guard | Rifqi ↔ FE | ✅ | done 2026-07-09: `GET /tools/report/{analysis_id}/records` + `/readiness` (registered before `/{version}` — int-coercion route-order trap), `exclude_record_ids` on POST; contract updated same change; FE wiring pending (Rifqi → FE) |
 
 
68
 
69
  ## 1. The direction change (locked decisions from 2026-06-24)
70
 
 
65
  | Q7 | `analyze_merge` two-table combine tool (unblocks "worst A + biggest B" questions) | tool owner | ✅ | tool shipped by Sofia (8abf635, KM-703); planner slice done 2026-07-09: `_validate_data_source` guards `data_right`, two-retrieve→merge few-shot (Example I), planner.md "Two measures per entity" bullet |
66
  | Q8 | Report v2: business-question answer section, unresolved/excluded sections, evidence tables from `results_snapshot`, caveat dedupe, single language | Rifqi/Sofhia | ✅ | done 2026-07-09: still exactly ONE LLM call (extended to also draft `bq_answers`, index-based record refs, deterministic fallback = v1 behavior); evidence tables from table-kind outputs (≤3/record, ≤10 rows, ≤8 cols, `check_*` skipped); reply language via `detect_reply_language` on objective+BQs; verified in-process against live analysis 935a091e |
67
  | Q9 | Record-curation endpoint (`GET …/records` + `exclude_record_ids`) + readiness GET for the FE delta guard | Rifqi ↔ FE | ✅ | done 2026-07-09: `GET /tools/report/{analysis_id}/records` + `/readiness` (registered before `/{version}` — int-coercion route-order trap), `exclude_record_ids` on POST; contract updated same change; FE wiring pending (Rifqi → FE) |
68
+ | Q10 | Traceability `data_used` layer — resolve IR ids → real names for the FE (users couldn't map `c_…`/`t_…` ids back to their data; aggregate aliases like `total_revenue` looked like real columns) | Rifqi ↔ FE | ✅ | done 2026-07-13 (pr/15): new `src/traceability/resolve.py` builds `data_used[]` (real source/table/column names; joins; plain-language filters; `columns_read` vs `output_columns` with `computed`+`formula`), `tool_calls[].summary`, `sources[]` gains `source_name`+all tables; **ids kept but machine-only (FE must not render)**; deterministic no-LLM, never-throw; catalog threaded to the scratchpad at the composition root. Contract + `TRACEABILITY_FE_HANDOFF.md` updated; FE wiring pending (Rifqi → FE). Additive/non-breaking |
69
+ | Q11 | IR wart: `OrderByClause.column_id` may hold a SELECT **alias** (a computed output), not a catalog column_id | Rifqi | 🔎 | surfaced by Q10 — the resolver tolerates it (`kind: "computed"` fallback), but the IR field name is misleading. Consider an explicit `by_alias` field or renaming. Low priority; no functional bug |
70
 
71
  ## 1. The direction change (locked decisions from 2026-06-24)
72
 
DS_TOOLS_PLAN.md ADDED
@@ -0,0 +1,328 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DS Tools Expansion — Visualization & Modeling (Proposal)
2
+
3
+ **Status:** PROPOSAL — for team/mentor review; §6 items need Harry (Go/dedorch) + FE coordination.
4
+ **Date:** 2026-07-07 · **Branch context:** `pr/12`.
5
+ **Companions:** [REPO_STATUS.md](REPO_STATUS.md) (current built state) · [DEV_PLAN.md](DEV_PLAN.md)
6
+ (§4 #26/#27 deferred charts/images — this doc un-defers #26 with a concrete design) ·
7
+ [API_CONTRACT_BE_PYTHON.md](API_CONTRACT_BE_PYTHON.md) (contract that §6 extends).
8
+ **Research basis:** industry/lit review 2026-07-07 — sources in §9.
9
+
10
+ ---
11
+
12
+ ## 0. Executive summary
13
+
14
+ **The ask.** Data Eyond answers analytical questions with text + markdown tables. The product story
15
+ ("junior data scientist that hands back a decision-ready deliverable", CRISP-DM) requires two
16
+ capabilities the chat surface can't express today: **charts** and **models** (forecast / clusters /
17
+ anomalies / drivers). This doc proposes what to add, on what stack, and in what order.
18
+
19
+ **The one architectural decision that matters.** DS agents in the market split into two families:
20
+
21
+ 1. **Sandboxed code generation** — the LLM writes Python, an isolated sandbox executes it, charts
22
+ come back as PNG files (ChatGPT Advanced Data Analysis, Julius, LangChain deep-agents; infra =
23
+ E2B/Modal/Firecracker microVMs). Maximum flexibility; weakest governance — per Gartner's 2026
24
+ agentic-analytics taxonomy this is "Level 1: output varies per run, no governance."
25
+ 2. **Declarative specs over governed tools** — the LLM (or deterministic code) emits a validated
26
+ JSON *specification*; deterministic code executes it against governed data (Databricks Genie,
27
+ Snowflake Cortex Analyst, ThoughtSpot Spotter; research systems LIDA and chat2plot). Bounded
28
+ flexibility; repeatable, auditable, no arbitrary code execution.
29
+
30
+ **Recommendation: family 2.** It is literally our existing architecture — the query engine already
31
+ does *constrained spec (QueryIR) → validator → deterministic compiler → guarded executor*.
32
+ Visualization and modeling should extend that spine, not bolt a code sandbox onto it. Sandboxed
33
+ codegen is re-evaluated only when curated tools hit an expressiveness ceiling (§7, M2).
34
+
35
+ **Feature set (phased):**
36
+
37
+ | Phase | Feature | User sees | New LLM calls |
38
+ |---|---|---|---|
39
+ | **V1** | Deterministic charts from existing `analyze_*` results (trend→line, aggregate→bar, correlation→heatmap, …) | Interactive chart under the answer, chart in traceability, chart in report | 0 |
40
+ | **V2** | Chart-aware planning: `render_chart` tool + LLM-picked `ChartSpec` for "plot X vs Y" asks and chart-edit turns | Charts on demand, editable ("make it a pie") | 0–1 (structured output) |
41
+ | **M1** | Modeling tools: `analyze_forecast`, `analyze_cluster`, `analyze_anomaly`, `analyze_driver` | Predictions with confidence bands, segments, outliers, ranked drivers — each with metrics, caveats, and a chart | 0 (planner already budgeted) |
42
+ | **M2** | *(deferred)* Hosted code sandbox for the long tail | Arbitrary analyses | n/a — decision gate in §7 |
43
+
44
+ **Stack (and the one-line why — full rationale §4):**
45
+
46
+ | Concern | Pick | Why |
47
+ |---|---|---|
48
+ | Chart artifact format | **Plotly Figure JSON**, compiled from a small pydantic `ChartSpec` | Team already decided Plotly-JSON (DEV_PLAN #26); `plotly==5.24.1` + `kaleido==0.2.1` already pinned in `pyproject.toml`; `react-plotly.js` fits the React/Vite FE; kaleido gives server-side PNG for the deferred PPT/PDF report export |
49
+ | Chart storage/delivery | Python-owned **`message_charts`** JSONB table + **`GET /api/v1/charts`** — the `message_traceability` pattern reused verbatim | SSE stays text-only (house rule); FE fetches artifacts on `done`, exactly like traceability today |
50
+ | Forecasting | **statsmodels** (ETS / SARIMAX, `seasonal_decompose`) | The standard agent-tool library for TS; interpretable, CPU-cheap, no new heavy deps beyond itself |
51
+ | Clustering / anomaly / drivers | **scikit-learn** (KMeans+silhouette, IsolationForest, regularized linear/logistic + permutation importance) | Already in the venv transitively (sentence-transformers) — pin it explicitly; interpretable models only |
52
+ | Explicitly NOT now | code sandbox (E2B/Modal), AutoGluon/FLAML AutoML, prophet, deep-learning TS, Vega-Lite | §4.3 rejected-alternatives table |
53
+
54
+ **Infrastructure delta (the "other than the chatbot interface" part):** one new Python-owned dedorch
55
+ table (DDL handoff to Harry), one new GET endpoint (contract addition), an FE chart renderer
56
+ (react-plotly.js) + artifact fetch on `done`, 2 new Python deps (`statsmodels`, explicit
57
+ `scikit-learn`), and a `chart` output kind in the tool contract (coordinate: `src/tools/contracts.py`
58
+ is tool-team-owned). **No sandbox service, no GPU, no new datastore, no change to the SSE stream.**
59
+
60
+ **Effort (rough):** V1 ≈ 4–5 dev-days Python + 2–3 FE · V2 ≈ 3 · M1 ≈ 6–8. V1 is demo-visible fastest.
61
+
62
+ ---
63
+
64
+ ## 1. How the field does it (what the research says)
65
+
66
+ ### 1.1 Three architecture families
67
+
68
+ **A. Sandboxed code interpreters** (ChatGPT ADA, Claude analysis tool, Julius, LangChain
69
+ deep-agents reference). LLM writes pandas/matplotlib code; a sandbox (E2B, Modal, Daytona,
70
+ LangSmith Sandbox — Firecracker microVM isolation) executes it; PNGs/files come back.
71
+ *Strengths:* unbounded expressiveness — any analysis pandas can do.
72
+ *Weaknesses:* non-repeatable ("output varies per run"), un-auditable code paths, prompt-injection →
73
+ code-execution risk, real infra (microVMs, warm pools, credential isolation — LangChain's own docs:
74
+ "avoid adding credentials to the sandbox"), and results that bypass any governance layer. The 2026
75
+ Gartner-derived maturity taxonomy places these at **Level 1** precisely because of governance.
76
+
77
+ **B. Declarative specs over governed data** (Databricks Genie, Snowflake Cortex Analyst,
78
+ ThoughtSpot Spotter, Amazon Q in QuickSight — **Level 2/3**). The LLM's only job is to emit a
79
+ constrained artifact (SQL against a semantic layer, or a chart/analysis spec); execution is
80
+ deterministic platform code. Research systems converge here for viz: **LIDA** (Microsoft) runs a
81
+ staged pipeline — data *Summarizer* → *Goal Explorer* → *VisGenerator* with generate-validate-repair;
82
+ **chat2plot** generates *"declarative visualization specs in JSON rather than Python code"* for
83
+ *"more secure execution, as the LLM does not directly generate code"*, validated by
84
+ structured-output/function-calling, rendered by plotly or altair.
85
+ *Strengths:* repeatable, auditable, cheap, safe; specs are storable/editable/versionable artifacts.
86
+ *Weaknesses:* bounded expressiveness — you can only draw/fit what the spec grammar covers.
87
+
88
+ **C. Investigative/agentic analytics** (Tellius, Qlik Predict — **Level 3/4**): multi-step
89
+ decomposition of metric changes with quantified attribution ("why did revenue dip?"), ML under the
90
+ hood (segment comparison, variance decomposition), narrative output. Architecturally these are
91
+ family B + a planner + statistical tooling — *not* codegen.
92
+
93
+ ### 1.2 Where Data Eyond already sits
94
+
95
+ The repo is a family-B system with a family-C planner:
96
+ `data_catalog` = the semantic/governance layer · QueryIR + `IRValidator` + SqlCompiler + read-only
97
+ executor = the constrained-spec pipeline · Planner→TaskRunner→Assembler = the investigative loop ·
98
+ `report_inputs` → versioned reports = the audit trail. The 2026 platform comparison's core critique —
99
+ Level-1 tools "sacrifice audit trail, role-controlled, repeatable execution" — is exactly the
100
+ trade-off we already refused when we built IR validation instead of LLM-SQL. Viz and ML should
101
+ follow the same refusal.
102
+
103
+ ### 1.3 ML in analyst agents specifically
104
+
105
+ Published agent systems for business TS/ML (sktime LLM workflows, TimeCopilot, DCATS) wrap
106
+ **statsmodels / scikit-learn estimators as tools** with the LLM planning which tool to call —
107
+ not writing model code. Interpretability drives library choice: ETS/ARIMA with confidence
108
+ intervals, KMeans with silhouette, permutation importance — things an executive-facing narrative
109
+ can explain and a report can defend. Heavy AutoML (AutoGluon, FLAML) appears in Kaggle-style
110
+ agents (MLE-bench, AIDE), not analyst products.
111
+
112
+ ---
113
+
114
+ ## 2. What we add and why (feature detail)
115
+
116
+ Product gaps these close, mapped to CRISP-DM (the report's own structure):
117
+
118
+ 1. **Charts (V1/V2)** — *Data Understanding + Evaluation.* Trend, composition, correlation and
119
+ distribution questions are answered today with tables the user must mentally plot. Every
120
+ comparable product renders charts; our reports' "EDA" section is tables-only. V1 needs **zero
121
+ new LLM calls**: each registered `analyze_*` already returns typed, structured output
122
+ (`ToolOutput.kind ∈ table|stats|series`) that maps rule-deterministically to a chart type.
123
+ 2. **Forecast (M1, `analyze_forecast`)** — *Modeling.* "What will sales look like next quarter?"
124
+ is currently answered by `analyze_trend` (descriptive slope only). ETS/SARIMAX with a holdout
125
+ backtest (MAPE/sMAPE reported) + CI bands is the minimum credible answer.
126
+ 3. **Clustering (M1, `analyze_segment` upgrade → `analyze_cluster`)** — *Modeling.* "What kinds of
127
+ customers do we have?" KMeans on scaled numerics, k by silhouette, cluster profile table +
128
+ PCA-2D scatter. Note the taxonomy already reserved `analyze_segment` (built, unregistered) —
129
+ this either upgrades it or registers a sibling; decide with the tool owner.
130
+ 4. **Anomaly detection (M1, `analyze_anomaly`)** — *Evaluation.* "Anything unusual last month?"
131
+ IsolationForest (tabular) / STL-residual z-score (time series); flagged-rows table + marked
132
+ chart. Also the seed of the Level-4 "proactive monitoring" story later.
133
+ 5. **Driver analysis (M1, `analyze_driver`)** — the Level-3 differentiator: "what's driving
134
+ churn/the dip?" Regularized linear/logistic fit + permutation importance → ranked-driver table.
135
+ Complements `analyze_contribution` (arithmetic decomposition) with statistical attribution.
136
+ 6. **Model artifacts in reports** — every M1 tool writes metrics + caveats into its
137
+ `AnalysisRecord`, so reports gain honest Modeling/Evaluation sections for free (charts embed as
138
+ kaleido PNG when the deferred PPT/PDF export lands — same artifact, two renderings).
139
+
140
+ Non-goals now: dashboards, scheduled/proactive monitoring, model persistence/registry (each run
141
+ fits in-request on ≤10k retrieved rows), deep-learning anything, cross-source joins.
142
+
143
+ ---
144
+
145
+ ## 3. Architecture (how)
146
+
147
+ ### 3.1 Charts — V1 data flow (no LLM)
148
+
149
+ ```
150
+ structured_flow turn (unchanged):
151
+ Planner → TaskRunner → results_snapshot {task_id → ToolOutput}
152
+
153
+ ▼ NEW, deterministic, never-throw
154
+ ChartBuilder.build(results_snapshot, plan)
155
+ rule table: analyze_trend→line · analyze_aggregate→bar/grouped
156
+ analyze_correlation→heatmap · analyze_descriptive→histogram
157
+ analyze_comparison→grouped bar · analyze_contribution→pareto
158
+ → ChartSpec (pydantic, ≤1 per substantive task)
159
+
160
+
161
+ SpecCompiler → plotly.graph_objects.Figure (schema-validated
162
+ by construction) → fig.to_json(), downsample >2k pts/trace,
163
+ payload cap ~1 MB
164
+
165
+
166
+ ChartStore.save → dedorch `message_charts` (Python-owned JSONB, one row per chart,
167
+ keyed analysis_id+message_id+order) — flushed alongside the traceability flush,
168
+ before `done` (same 8-site discipline; error turns write nothing)
169
+
170
+ SSE stream: UNCHANGED (text-only) ──▶ done{message_id}
171
+
172
+ FE on done ──▶ GET /api/v1/charts?analysis_id&message_id → [{chart_id, spec, figure_json,
173
+ title, source_task_id}] ──▶ react-plotly.js render under the answer
174
+ ```
175
+
176
+ Design rules carried over from the house style: **never-throw** (a chart failure degrades to
177
+ no-chart, never kills the turn) · charts derive **only from executed tool results** (never from
178
+ LLM text — grounded by construction, LIDA's "data-faithful" property) · traceability's `tool_calls`
179
+ entries gain a `chart_id` ref so provenance and artifact stay correlated.
180
+
181
+ ### 3.2 Charts — V2 (`render_chart` tool + ChartSpec-by-LLM)
182
+
183
+ - Register `render_chart` in the planner registry (Pattern A: takes `data` = `${t<id>}` + spec
184
+ params) so "plot revenue by region as a pie" becomes a plannable step. `ToolOutput` gains
185
+ `kind="chart"` — **one-line Literal change in tool-team-owned `contracts.py` + an Assembler
186
+ branch; coordinate with the tool owner before building.**
187
+ - Where the rule table is ambiguous or the user asked for a specific viz, ONE structured-output
188
+ LLM call emits `ChartSpec` (chat2plot's exact trick: constrained pydantic schema via function
189
+ calling — the LLM never writes Plotly JSON, so invalid output is a validation error with one
190
+ repair retry, mirroring the Planner's re-prompt loop).
191
+ - Chart-edit turns ("make it horizontal") load the stored spec, apply the delta, re-compile,
192
+ save a new chart row — spec-as-artifact is what makes edits cheap (family-B dividend).
193
+
194
+ ### 3.3 Modeling tools — M1
195
+
196
+ All four are composite tools in the existing taxonomy — Pattern A inputs, `ToolOutput` outputs,
197
+ registered in `analytics_registry()`, planner-visible with prompt-grade descriptions:
198
+
199
+ | Tool | Method (all CPU, interpretable) | Output (`kind`) | Auto-caveats |
200
+ |---|---|---|---|
201
+ | `analyze_forecast` | statsmodels ETS; SARIMAX when seasonality detected; naive-seasonal fallback | `series` (history + forecast + CI) + chart | holdout MAPE/sMAPE; "≥2 seasons or fallback"; missing-period warning |
202
+ | `analyze_cluster` | sklearn scale→KMeans, k∈2..8 by silhouette | `table` (profiles) + PCA scatter chart | silhouette score; "clusters are descriptive, not causal" |
203
+ | `analyze_anomaly` | IsolationForest (tabular) / STL residual z (TS) | `table` (flagged rows) + marked chart | contamination assumption; top-N only |
204
+ | `analyze_driver` | standardized ridge/logistic + permutation importance | `stats` (ranked drivers) + bar chart | R²/AUC on holdout; "association ≠ causation" |
205
+
206
+ Safety/robustness rails (same philosophy as `DbExecutor`): row cap (inherits the 10k retrieve
207
+ cap) + feature cap (≤20 numeric) · fixed `random_state` (repeatable runs — the family-B promise) ·
208
+ `asyncio.to_thread` + 30s wall-clock timeout · never-throw (failure → `kind="error"`, TaskRunner
209
+ degrade-and-continue does the rest) · metrics/caveats copied verbatim into the `AnalysisRecord`
210
+ (Assembler narrates them; it never invents numbers — existing rule).
211
+
212
+ ### 3.4 What explicitly does NOT change
213
+
214
+ Router intents (structured_flow already covers "plot/forecast/segment" asks — verify with new
215
+ `eval/intent` cases, not new intents) · SSE event shape (charts are fetched, not streamed) ·
216
+ QueryIR/compiler/executor (ML runs on already-retrieved DataFrames) · report floor · guardrail
217
+ layers.
218
+
219
+ ---
220
+
221
+ ## 4. Stack rationale (why these picks)
222
+
223
+ ### 4.1 Plotly JSON over the alternatives
224
+
225
+ | Option | Verdict | Reasoning |
226
+ |---|---|---|
227
+ | **Plotly Figure JSON** (pick) | ✅ | Already a pinned dep (`plotly==5.24.1`, `kaleido==0.2.1`) and already the team lean (DEV_PLAN #26 "Plotly→JSON, not matplotlib PNG"). `graph_objects` construction = schema validation for free. `react-plotly.js` is mature for the React/Vite FE. **kaleido closes the report loop**: same figure → interactive JSON in chat, PNG in PPT/PDF export (DEV_PLAN deferred "PPT preferred"). |
228
+ | Vega-Lite | ❌ for now | The research favorite (LIDA/chat2plot support it; tighter grammar, smaller specs) — *when the LLM writes the spec*. In our design the LLM writes a tiny `ChartSpec` and a deterministic compiler emits the figure, so Vega-Lite's LLM-ergonomics advantage mostly evaporates, and it would add an FE renderer + Python compiler we don't have. `ChartSpec` is renderer-agnostic (chat2plot precedent) — a Vega compiler can be added later without touching tools. |
229
+ | matplotlib/seaborn PNG | ❌ | Static, non-interactive, heavier payloads, no client theming, and the team already rejected it (#26). Keep matplotlib only as kaleido's export path. |
230
+ | Mermaid/ASCII in markdown | ❌ | Not data-viz grade. |
231
+
232
+ ### 4.2 statsmodels + scikit-learn over the alternatives
233
+
234
+ - **statsmodels**: the default in every surveyed agent-tools stack for forecasting; ETS/SARIMAX are
235
+ explainable to executives and run in milliseconds on our row caps. *(New dep — needs sign-off.)*
236
+ - **scikit-learn**: KMeans/IsolationForest/linear models cover cluster/anomaly/driver with
237
+ interpretable outputs. Already in the venv **transitively** via sentence-transformers — pin it
238
+ explicitly the moment we import it (transitive deps are not a contract).
239
+ - **Rejected:** prophet (heavy dep, maintenance-mode, marginal gain over ETS at our scale) ·
240
+ AutoGluon/FLAML (GPU-hungry, opaque ensembles — wrong for decision-ready narratives; that's
241
+ Kaggle-agent gear) · sktime/TimeCopilot (nice unified API, but another abstraction layer over the
242
+ two libs we'd still be running; revisit if tool count grows) · LLM-as-forecaster (research shows
243
+ it underperforms classical baselines on numeric TS; we use the LLM to *plan and narrate*, never
244
+ to produce numbers — existing Assembler rule).
245
+
246
+ ### 4.3 No code sandbox (the biggest "why not")
247
+
248
+ Codegen would give the long tail (custom feature engineering, exotic plots) but costs exactly what
249
+ our architecture is sold on: arbitrary LLM code vs our five-layer read-only defense; per-run
250
+ variance vs `report_inputs` repeatability; PNG blobs vs auditable specs; and real infra we don't
251
+ have — HF Spaces is one Docker container (no Firecracker microVMs; in-process `exec()` of LLM code
252
+ is a non-starter against our own guardrail posture). Every hosted option (E2B, Modal, Daytona)
253
+ means a new external service + credential-isolation design. **Decision gate for M2 (§7):** revisit
254
+ only when a logged backlog of user asks provably doesn't fit the curated tools.
255
+
256
+ ---
257
+
258
+ ## 5. Implementation plan (task-table-ready)
259
+
260
+ Statuses: ⬜ not started (all — proposal). Owners are suggestions.
261
+
262
+ | # | Phase | Task | Owner | Note |
263
+ |---|---|---|---|---|
264
+ | 1 | V1 | `ChartSpec` pydantic + rule table + `SpecCompiler` (plotly), downsampling + size caps | Rifqi | pure Python, no seams touched |
265
+ | 2 | V1 | `ChartBuilder` hook in `chat_handler._run_slow_path` after TaskRunner; never-throw | Rifqi | mirrors traceability accumulation |
266
+ | 3 | V1 | `message_charts` ORM + `ChartStore` (save/list); flush wired at the traceability flush sites | Rifqi | Python-owned, like `message_traceability` |
267
+ | 4 | V1 | **DDL handoff to Harry**: `message_charts` (uuid id, analysis_id FK, message_id, user_id, `chart` jsonb {spec, figure, title, source_task_id, order}, created_at) | Rifqi → Harry | plural name, uuid ids — house rules |
268
+ | 5 | V1 | `GET /api/v1/charts?analysis_id&message_id` + contract § in API_CONTRACT_BE_PYTHON.md | Rifqi | mirror traceability endpoint; 404 semantics same |
269
+ | 6 | V1 | FE: react-plotly.js renderer + fetch-on-done; Go passthrough if FE→Go→Python | FE + Harry | coordination, not Python work |
270
+ | 7 | V1 | traceability `tool_calls[].chart_id` ref; local tests (spec compile goldens, store, endpoint) | Rifqi | tests stay local |
271
+ | 8 | V2 | `render_chart` ToolSpec + `kind="chart"` in contracts.py | **tool owner** + Rifqi | contracts are tool-team-owned — coordinate first |
272
+ | 9 | V2 | ChartSpec-by-LLM (structured output + 1 repair retry) for explicit viz asks; chart-edit turns | Rifqi | chat2plot pattern |
273
+ | 10 | V2 | `eval/intent` cases for plot/forecast phrasing (EN+ID); chart-rule goldens in eval or tests | Rifqi + Sofhia | protocol: eval before router-adjacent claims |
274
+ | 11 | M1 | `uv add statsmodels` + pin scikit-learn (sign-off needed) | Rifqi | manual §6.4 |
275
+ | 12 | M1 | `analyze_forecast` (+ backtest + caveats) → registry + planner prompt row + few-shot | Rifqi + tool owner | taxonomy fit review |
276
+ | 13 | M1 | `analyze_cluster` / `analyze_anomaly` / `analyze_driver` (same template) | split | one PR each, stacked |
277
+ | 14 | M1 | Report generator: Modeling/Evaluation sections consume new record fields; kaleido PNG path stub for PPT export | Sofhia (report) / Rifqi | ties into deferred report-formats work |
278
+ | 15 | M2 | ⏸️ sandbox decision gate: collect can't-answer asks in traceability meta; revisit with evidence | — | deferred by design |
279
+
280
+ Sequencing: 1–7 ship V1 end-to-end (demo-visible; FE renderer is the only external dependency —
281
+ until it lands, Swagger shows the JSON). 8–10 next. 11–14 after taxonomy review with the tool
282
+ owner. Estimates: V1 ≈ 4–5 Python dev-days + 2–3 FE; V2 ≈ 3; M1 ≈ 6–8.
283
+
284
+ ## 6. Coordination & infra deltas (the handoff list)
285
+
286
+ 1. **Harry / dedorch:** `message_charts` migration (task #4 DDL). Optional later: `charts_count`
287
+ hint in the `done` payload — contract change, batch it with the next contract rev.
288
+ 2. **FE:** react-plotly.js (+ theme config), fetch-on-done, render under answer bubble; chart
289
+ panel in the report preview later.
290
+ 3. **Tool team:** `kind="chart"` Literal + `render_chart` spec review; `analyze_segment`
291
+ upgrade-vs-sibling decision (§2.3).
292
+ 4. **Deps (Rifqi sign-off):** `statsmodels` new; `scikit-learn` explicit pin. Both CPU wheels,
293
+ no image-size drama on HF.
294
+ 5. **No change requested from:** router intents, SSE shape, Redis, Langfuse, guardrails.
295
+
296
+ ## 7. Risks & mitigations
297
+
298
+ | Risk | Mitigation |
299
+ |---|---|
300
+ | Chart payloads bloat the DB / FE | downsample >2k pts/trace; ~1 MB cap; store spec always, figure optionally recompilable |
301
+ | Wrong chart type annoys users | V1 rules only for unambiguous mappings; else no chart (no-chart beats bad-chart); V2 adds the LLM picker |
302
+ | Forecast on garbage data → confident nonsense | hard preconditions (min periods, regular frequency) → refuse-with-reason into the record; backtest metric always shown; naive fallback labeled |
303
+ | Never-throw hides chart/model failures | same answer as traceability: failure reason lands in the tool span + record caveats, visible in `/traceability` |
304
+ | PII in chart labels/tooltips | charts render only executed result data (same exposure class as today's tables); `pii_flag` columns excluded from V2 LLM spec-picking context (prompt-plane rule holds) |
305
+ | Scope creep toward AutoML/sandbox | M2 gate requires logged evidence of unmet asks, not vibes |
306
+
307
+ ## 8. Open questions
308
+
309
+ 1. `message_charts` vs folding chart JSON into `message_traceability` — separate table recommended
310
+ (different consumer, different lifecycle, report reuse by `chart_id`), but Harry may prefer one
311
+ migration. **Decide at DDL handoff.**
312
+ 2. Does the FE want figure JSON (render-ready, bigger) or spec+data (smaller, FE compiles)?
313
+ Recommend figure JSON first — dumbest possible FE integration.
314
+ 3. `analyze_segment` (existing, unregistered) vs new `analyze_cluster` — tool-owner call.
315
+ 4. Report PNG embedding: inline base64 in markdown vs chart_id placeholder resolved at export —
316
+ recommend placeholder; decide with report-formats work.
317
+ 5. Do forecast/cluster asks change `structured_flow` routing confidence? Add eval cases first
318
+ (task #10) — data before prompt edits.
319
+
320
+ ## 9. Sources (reviewed 2026-07-07)
321
+
322
+ - Tellius — [Best AI Data Analysis Agents in 2026: 12 platforms compared](https://www.tellius.com/resources/blog/best-ai-data-analysis-agents-in-2026-12-platforms-compared-for-nl-to-sql-autonomous-investigation-and-governance) (maturity levels, governance trade-offs, Gartner 2026 agentic-analytics framing)
323
+ - Microsoft Research — [LIDA: grammar-agnostic visualization generation with LLMs](https://microsoft.github.io/lida/) · [paper](https://aclanthology.org/2023.acl-demo.11/) (staged pipeline, generate-validate-repair, data-faithfulness)
324
+ - [chat2plot](https://github.com/nyanp/chat2plot) (declarative JSON specs over codegen; structured-output validation; "more secure execution, as LLM does not directly generate code")
325
+ - LangChain — [deep-agents data analysis reference](https://docs.langchain.com/oss/python/deepagents/data-analysis) (the codegen-family architecture: sandbox backends E2B/Modal/Daytona, PNG artifacts, credential isolation)
326
+ - Modal — [Best code execution sandboxes for AI agents 2026](https://modal.com/resources/best-code-execution-sandboxes-ai-agents) (sandbox infra requirements: microVM isolation, warm pools)
327
+ - [Vega-Lite](https://vega.github.io/vega-lite/) · [Plotly vs Vega comparison thread](https://github.com/plotly/documentation/issues/1330) (template-based vs grammar-based spec trade-off)
328
+ - [sktime LLM workflows](https://medium.com/@benedikt_heidrich/can-we-do-time-series-analysis-with-llm-powered-workflows-using-sktime-12b19cf39376) · [TimeCopilot / agentic forecasting survey](https://arxiv.org/html/2508.04231v1) (statsmodels/sklearn-as-tools pattern; LLM plans, classical models compute)
REPO_STATUS.md CHANGED
@@ -2,7 +2,7 @@
2
 
3
  **Audience:** teammates onboarding onto the Python repo (`Agentic-Service-Data-Eyond-Catalog`).
4
  **Scope:** what the code does **right now** (branch `pr/4`, ticket KM-652). Describes current state only — no roadmap or to-dos.
5
- **Snapshot date:** 2026-06-25. **Data-layer reconcile 2026-07-01:** §8/§12 updated — dedorch cutover done, `data_catalog` model reconciled. **Query-path fix 2026-07-02:** §8/§13 — dedorch catalogs ship no FKs → Python infers them (`fk_inference.py`); shared-Fernet-key gotcha documented. **Agent-quality fixes 2026-07-08 (pr/13):** from the scoped live-test review — the planner gains an explicit **infeasible** outcome (`TaskList.infeasible_reason` → deterministic EN/ID data-gap reply via `refusals.data_gap_message`; no more force-mapping absent measures like `pa` AS "revenue"), the IR validator rejects bare selects under `group_by` (self-corrects via the planner retry), `analyze_trend` handles integer year/month columns (was collapsing every row into one 1970-01 bucket), planner few-shots add top-N (Example G) + infeasible (Example H), numeric catalog `sample_values` are base64-decoded at read (`catalog/sample_decode.py` — stopgap for Go's byte-marshaling; primary fix is Go-side), traceability no longer emits null source rows for failed retrievals, and `check_data` hides `-1` row counts. **Report v2 + analyze_merge planner support 2026-07-09 (pr/13):** Sofia's `analyze_merge` tool (8abf635, KM-703) is now planner-supported (`_validate_data_source` guards `data_right`, two-retrieve→merge few-shot Example I, planner.md "Two measures per entity" rule); the report gains per-business-question answers (`bq_answers` — drafted by the SAME single LLM call, index-based record refs, deterministic fallback unchanged), "Attempted, Unresolved" + "Excluded Analyses" sections (failed runs are no longer silently dropped), evidence tables copied from `results_snapshot` (table-kind outputs, ≤3/record ≤10 rows ≤8 cols, `check_*` skipped), normalized caveat dedupe with caps (12/10), and single-language output via `detect_reply_language`; the report surface adds `GET /tools/report/{analysis_id}/records` (curation list), `GET …/readiness` (FE delta guard), and `exclude_record_ids` on POST — see API_CONTRACT_BE_PYTHON.md. **Report compaction 2026-07-09 (pr/13):** the rendered markdown drops the "Notes & Limitations", "Attempted, Unresolved", and "How This Was Analyzed" sections (team decision — compact report; render blocks commented out in `report/generator.py`, not deleted). The JSON body keeps `caveats`/`open_questions`/`unresolved`/`method_steps` and the curation/records endpoints are unchanged. **Cross-repo update 2026-06-29:** §2/§8/§11/§12 re-verified against
6
  the **Go source** (`Orchestrator-Agent-Service`), not its docs. The Go service has moved well past its
7
  own (uncommitted, stale) design docs: it now hosts the **dedorch SQL migrations** in-repo and a full
8
  **`/api/v1/analyses` + `/api/v1/skills`** REST surface. Go does **not** call Python yet — those skills
 
2
 
3
  **Audience:** teammates onboarding onto the Python repo (`Agentic-Service-Data-Eyond-Catalog`).
4
  **Scope:** what the code does **right now** (branch `pr/4`, ticket KM-652). Describes current state only — no roadmap or to-dos.
5
+ **Snapshot date:** 2026-06-25. **Data-layer reconcile 2026-07-01:** §8/§12 updated — dedorch cutover done, `data_catalog` model reconciled. **Query-path fix 2026-07-02:** §8/§13 — dedorch catalogs ship no FKs → Python infers them (`fk_inference.py`); shared-Fernet-key gotcha documented. **Agent-quality fixes 2026-07-08 (pr/13):** from the scoped live-test review — the planner gains an explicit **infeasible** outcome (`TaskList.infeasible_reason` → deterministic EN/ID data-gap reply via `refusals.data_gap_message`; no more force-mapping absent measures like `pa` AS "revenue"), the IR validator rejects bare selects under `group_by` (self-corrects via the planner retry), `analyze_trend` handles integer year/month columns (was collapsing every row into one 1970-01 bucket), planner few-shots add top-N (Example G) + infeasible (Example H), numeric catalog `sample_values` are base64-decoded at read (`catalog/sample_decode.py` — stopgap for Go's byte-marshaling; primary fix is Go-side), traceability no longer emits null source rows for failed retrievals, and `check_data` hides `-1` row counts. **Report v2 + analyze_merge planner support 2026-07-09 (pr/13):** Sofia's `analyze_merge` tool (8abf635, KM-703) is now planner-supported (`_validate_data_source` guards `data_right`, two-retrieve→merge few-shot Example I, planner.md "Two measures per entity" rule); the report gains per-business-question answers (`bq_answers` — drafted by the SAME single LLM call, index-based record refs, deterministic fallback unchanged), "Attempted, Unresolved" + "Excluded Analyses" sections (failed runs are no longer silently dropped), evidence tables copied from `results_snapshot` (table-kind outputs, ≤3/record ≤10 rows ≤8 cols, `check_*` skipped), normalized caveat dedupe with caps (12/10), and single-language output via `detect_reply_language`; the report surface adds `GET /tools/report/{analysis_id}/records` (curation list), `GET …/readiness` (FE delta guard), and `exclude_record_ids` on POST — see API_CONTRACT_BE_PYTHON.md. **Report compaction 2026-07-09 (pr/13):** the rendered markdown drops the "Notes & Limitations", "Attempted, Unresolved", and "How This Was Analyzed" sections (team decision — compact report; render blocks commented out in `report/generator.py`, not deleted). The JSON body keeps `caveats`/`open_questions`/`unresolved`/`method_steps` and the curation/records endpoints are unchanged. **Traceability `data_used` layer 2026-07-13 (pr/15):** `GET /api/v1/traceability` gains a resolved, user-facing `data_used[]` block (one per `retrieve_data` call) — real source/table/column names, joins, plain-language filters, and result columns split into read-from-data vs `computed` (with `formula`, e.g. `total_revenue` = `SUM(line_total)`, so an alias is never shown as a real column). Ids are kept but **machine-only (FE must not render)**. Also adds `tool_calls[].summary`, and `sources[]` now carry `source_name` + every table touched. Deterministic catalog resolution (new `src/traceability/resolve.py`), no LLM, never-throw; catalog threaded to the scratchpad at the slow-path composition root. Additive/non-breaking; contract + `TRACEABILITY_FE_HANDOFF.md` updated. **Cross-repo update 2026-06-29:** §2/§8/§11/§12 re-verified against
6
  the **Go source** (`Orchestrator-Agent-Service`), not its docs. The Go service has moved well past its
7
  own (uncommitted, stale) design docs: it now hosts the **dedorch SQL migrations** in-repo and a full
8
  **`/api/v1/analyses` + `/api/v1/skills`** REST surface. Go does **not** call Python yet — those skills
src/agents/chat_handler.py CHANGED
@@ -418,6 +418,9 @@ class ChatHandler:
418
  )
419
  reader = MemoizingCatalogReader(scoped)
420
  catalog = await reader.read(user_id, "structured")
 
 
 
421
  # structured_flow always runs the slow analytical path (the
422
  # ENABLE_SLOW_PATH flag was removed 2026-07-02).
423
  # Detect reply language from the ORIGINAL message (not `rewritten` — the
 
418
  )
419
  reader = MemoizingCatalogReader(scoped)
420
  catalog = await reader.read(user_id, "structured")
421
+ # Give traceability the same catalog snapshot so it can resolve the
422
+ # retrieve_data IR ids to real names (the data_used layer). KM-691.
423
+ pad.set_catalog(catalog)
424
  # structured_flow always runs the slow analytical path (the
425
  # ENABLE_SLOW_PATH flag was removed 2026-07-02).
426
  # Detect reply language from the ORIGINAL message (not `rewritten` — the
src/agents/handlers/check.py CHANGED
@@ -550,13 +550,18 @@ def _render_schema_source(out: ToolOutput, reply_language: str) -> str:
550
  tname, tcols = next(iter(tables.items()))
551
  rc = tcols[0]["table_rows"]
552
  head = f"**{name}**" + (f" ({rc} {sc['rows_word']})" if rc else "")
553
- parts.append(f"{head}\n{_columns_table(tcols, reply_language)}")
 
 
 
 
554
  else:
555
  parts.append(f"**{name}**")
556
  for tname, tcols in tables.items():
557
  rc = tcols[0]["table_rows"]
558
  sub = f"{sc['table_word']} {tname}" + (f" ({rc} {sc['rows_word']})" if rc else "") + ":"
559
- parts.append(f"{sub}\n{_columns_table(tcols, reply_language)}")
 
560
 
561
  summary = _render_summary(rows, reply_language)
562
  if summary:
 
550
  tname, tcols = next(iter(tables.items()))
551
  rc = tcols[0]["table_rows"]
552
  head = f"**{name}**" + (f" ({rc} {sc['rows_word']})" if rc else "")
553
+ # Blank line between the bold header and the table: GFM only parses a
554
+ # pipe table as a table when a blank line precedes it. Without it the FE
555
+ # renderer folds header + table into one paragraph and the pipes render
556
+ # literally (the "flattened one-line table" bug).
557
+ parts.append(f"{head}\n\n{_columns_table(tcols, reply_language)}")
558
  else:
559
  parts.append(f"**{name}**")
560
  for tname, tcols in tables.items():
561
  rc = tcols[0]["table_rows"]
562
  sub = f"{sc['table_word']} {tname}" + (f" ({rc} {sc['rows_word']})" if rc else "") + ":"
563
+ # Blank line before the table so GFM renders it as a table (see above).
564
+ parts.append(f"{sub}\n\n{_columns_table(tcols, reply_language)}")
565
 
566
  summary = _render_summary(rows, reply_language)
567
  if summary:
src/catalog/reader.py CHANGED
@@ -7,9 +7,13 @@ Catalog-level search is added later if catalog grows past the limit.
7
  from datetime import UTC, datetime
8
  from typing import Literal
9
 
 
 
10
  from .models import Catalog, Source
11
  from .store import CatalogStore
12
 
 
 
13
  SourceHint = Literal["chat", "unstructured", "structured"]
14
 
15
 
@@ -81,9 +85,19 @@ class AnalysisScopedCatalogReader(CatalogReader):
81
  names. A database shows as "xl test" (analysis-scope) instead of the
82
  auto-generated `postgres_<hash>` placeholder, and documents show at all
83
  (the user-scope catalog holds no `unstructured` sources, so reading them from
84
- user-scope always came back empty). When the analysis has no catalog row
85
- (legacy / not yet bound) or the read fails, it degrades to the wrapped
86
- user-scope reader, so unbound rooms behave exactly as before.
 
 
 
 
 
 
 
 
 
 
87
  """
88
 
89
  def __init__(self, inner: CatalogReader, analysis_id: str | None) -> None:
@@ -94,19 +108,37 @@ class AnalysisScopedCatalogReader(CatalogReader):
94
  self._analysis_id = analysis_id
95
 
96
  async def read(self, user_id: str, source_hint: SourceHint) -> Catalog:
97
- # Read analysis-scope for BOTH structured and unstructured. Verified via
98
- # the dedorch `data_catalog` table: the analysis-scope rows carry the real
99
- # DB names AND the room's documents (`source_type='unstructured'`), whereas
100
- # the user-scope rows hold only structured sources with `postgres_<hash>`
101
- # placeholder names and NO documents at all — so reading documents from
102
- # user-scope always returned empty ("not listed"). Fall back to the
103
- # user-scope reader only when the analysis has no catalog row (legacy /
104
- # unbound room).
105
- if self._analysis_id:
106
- try:
107
- catalog = await self._store.get_by_analysis(self._analysis_id)
108
- except Exception: # noqa: BLE001 — never block check on the analysis read
109
- catalog = None
110
- if catalog is not None:
111
- return _filter_sources(catalog, source_hint)
112
- return await self._inner.read(user_id, source_hint)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  from datetime import UTC, datetime
8
  from typing import Literal
9
 
10
+ from src.middlewares.logging import get_logger
11
+
12
  from .models import Catalog, Source
13
  from .store import CatalogStore
14
 
15
+ logger = get_logger("catalog_reader")
16
+
17
  SourceHint = Literal["chat", "unstructured", "structured"]
18
 
19
 
 
85
  names. A database shows as "xl test" (analysis-scope) instead of the
86
  auto-generated `postgres_<hash>` placeholder, and documents show at all
87
  (the user-scope catalog holds no `unstructured` sources, so reading them from
88
+ user-scope always came back empty).
89
+
90
+ Fallback rule (tightened 2026-07-13): the user-scope reader is used ONLY when
91
+ there is no `analysis_id` at all (a legacy room with no analysis concept).
92
+ When an `analysis_id` IS present but its catalog row is missing (legacy
93
+ analysis created before catalog materialization, or Go hasn't rebuilt the
94
+ binding yet) or the read fails, this returns an EMPTY catalog — NOT the
95
+ user-scope catalog. Previously it degraded to user-scope, which silently
96
+ surfaced sources that are NOT bound to this analysis as if they were (e.g. a
97
+ room bound to a CSV answered "check" with the account's unrelated XLSX). An
98
+ empty result reads correctly as "nothing bound yet — re-save / rebuild the
99
+ binding to materialize the analysis catalog". Every outcome is logged so a
100
+ miss is diagnosable instead of silent.
101
  """
102
 
103
  def __init__(self, inner: CatalogReader, analysis_id: str | None) -> None:
 
108
  self._analysis_id = analysis_id
109
 
110
  async def read(self, user_id: str, source_hint: SourceHint) -> Catalog:
111
+ # No analysis_id at all legacy room with no analysis-scope concept; fall
112
+ # back to the user-scope reader (unchanged behavior).
113
+ if not self._analysis_id:
114
+ return await self._inner.read(user_id, source_hint)
115
+
116
+ # Analysis-scoped room: read its OWN catalog. Analysis-scope rows carry the
117
+ # real DB names AND the room's documents (`source_type='unstructured'`),
118
+ # unlike the user-scope rows (`postgres_<hash>` names, no documents).
119
+ try:
120
+ catalog = await self._store.get_by_analysis(self._analysis_id)
121
+ except Exception as e: # noqa: BLE001 — never block check on the analysis read
122
+ logger.warning(
123
+ "analysis catalog read failed — returning empty",
124
+ analysis_id=self._analysis_id,
125
+ error=str(e),
126
+ )
127
+ catalog = None
128
+
129
+ if catalog is not None:
130
+ logger.info(
131
+ "analysis catalog hit",
132
+ analysis_id=self._analysis_id,
133
+ sources=len(catalog.sources),
134
+ )
135
+ return _filter_sources(catalog, source_hint)
136
+
137
+ # analysis_id present but no catalog row (or read failed): return EMPTY,
138
+ # NOT the user-scope catalog. Surfacing user-scope sources here reads as
139
+ # "these are your bound sources" when they are not (the misleading-XLSX bug).
140
+ logger.info(
141
+ "analysis catalog miss — returning empty (no user-scope fallback)",
142
+ analysis_id=self._analysis_id,
143
+ )
144
+ return Catalog(user_id=user_id, generated_at=datetime.now(UTC))
src/traceability/resolve.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Resolve a query IR into the user-facing `DataUsed` record (KM-691).
2
+
3
+ Pure, deterministic id->name resolution against the catalog — NO LLM. Turns the
4
+ opaque IR the tools ran (column_id / table_id / source_id) into real names the
5
+ user can check against their own database, and splits the result set into columns
6
+ read straight from the data vs values the analysis computed.
7
+
8
+ Never raises on the caller's path: an unresolved id degrades to showing the raw id
9
+ (marked table='?'), never a crash — a trace slip must not break the user's answer.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ from ..catalog.models import Catalog
17
+ from ..query.ir.models import AggSelect, ColumnSelect, QueryIR
18
+ from .schemas import (
19
+ ColumnRef,
20
+ DataUsed,
21
+ FilterRef,
22
+ JoinRef,
23
+ OrderByRef,
24
+ OutputColumn,
25
+ SourceRef,
26
+ TableRef,
27
+ )
28
+
29
+
30
+ class _CatalogIndex:
31
+ """Flat id->name lookups built once from a Catalog (ids are globally unique)."""
32
+
33
+ def __init__(self, catalog: Catalog) -> None:
34
+ self.source: dict[str, tuple[str, str | None]] = {}
35
+ self.table: dict[str, str] = {}
36
+ # column_id -> (table_name, column_name, data_type, pii_flag)
37
+ self.col: dict[str, tuple[str, str, str | None, bool]] = {}
38
+ for s in catalog.sources:
39
+ self.source[s.source_id] = (s.name, s.source_type)
40
+ for t in s.tables:
41
+ self.table[t.table_id] = t.name
42
+ for c in t.columns:
43
+ self.col[c.column_id] = (t.name, c.name, c.data_type, c.pii_flag)
44
+
45
+ def col_name(self, column_id: str) -> str:
46
+ hit = self.col.get(column_id)
47
+ return hit[1] if hit else column_id
48
+
49
+ def col_qual(self, column_id: str) -> str:
50
+ """`table.column` when resolvable, else the raw id (honest fallback)."""
51
+ hit = self.col.get(column_id)
52
+ return f"{hit[0]}.{hit[1]}" if hit else column_id
53
+
54
+
55
+ def _describe_filter(col: str, op: str, value: Any) -> str:
56
+ """Plain-language rendering of one filter (fixed templates — no LLM)."""
57
+ if op == "between" and isinstance(value, list | tuple) and len(value) == 2:
58
+ return f"{col} is between {value[0]} and {value[1]}"
59
+ if op == "in":
60
+ return f"{col} is one of {value}"
61
+ if op == "not_in":
62
+ return f"{col} is not one of {value}"
63
+ if op == "is_null":
64
+ return f"{col} is empty"
65
+ if op == "is_not_null":
66
+ return f"{col} is not empty"
67
+ if op == "like":
68
+ return f"{col} matches {value}"
69
+ return f"{col} {op} {value}"
70
+
71
+
72
+ def resolve_data_used(
73
+ ir: QueryIR,
74
+ catalog: Catalog,
75
+ query: str | None = None,
76
+ rows_returned: int | None = None,
77
+ ) -> DataUsed:
78
+ """Resolve one `retrieve_data` IR into a `DataUsed`. Deterministic; no LLM."""
79
+ idx = _CatalogIndex(catalog)
80
+ src_name, src_type = idx.source.get(ir.source_id, (ir.source_id, None))
81
+
82
+ tables = [TableRef(id=ir.table_id, name=idx.table.get(ir.table_id, ir.table_id), role="base")]
83
+ joins: list[JoinRef] = []
84
+ for j in ir.joins:
85
+ ttid = j.target_table_id
86
+ tables.append(TableRef(id=ttid, name=idx.table.get(ttid, ttid), role="joined"))
87
+ left, right = idx.col_qual(j.left_column_id), idx.col_qual(j.right_column_id)
88
+ joins.append(JoinRef(type=j.type, condition=f"{left} = {right}"))
89
+
90
+ # roles[column_id] -> set of why-used tags, accumulated across every clause.
91
+ roles: dict[str, set[str]] = {}
92
+
93
+ def _tag(cid: str, role: str) -> None:
94
+ roles.setdefault(cid, set()).add(role)
95
+
96
+ output_columns: list[OutputColumn] = []
97
+ for s in ir.select:
98
+ if isinstance(s, ColumnSelect):
99
+ _tag(s.column_id, "selected")
100
+ output_columns.append(
101
+ OutputColumn(
102
+ name=s.alias or idx.col_name(s.column_id),
103
+ kind="column",
104
+ from_=idx.col_qual(s.column_id),
105
+ )
106
+ )
107
+ elif isinstance(s, AggSelect):
108
+ if s.column_id:
109
+ _tag(s.column_id, "aggregated")
110
+ formula = f"{s.fn.upper()}({idx.col_qual(s.column_id)})"
111
+ frm = idx.col_qual(s.column_id)
112
+ else:
113
+ formula = f"{s.fn.upper()}(*)" # count(*) carries no column
114
+ frm = None
115
+ output_columns.append(
116
+ OutputColumn(name=s.alias or formula, kind="computed", formula=formula, from_=frm)
117
+ )
118
+
119
+ filters: list[FilterRef] = []
120
+ for f in ir.filters:
121
+ _tag(f.column_id, "filtered")
122
+ col = idx.col_qual(f.column_id)
123
+ desc = _describe_filter(col, f.op, f.value)
124
+ filters.append(FilterRef(column=col, op=f.op, value=f.value, description=desc))
125
+
126
+ group_by: list[str] = []
127
+ for gid in ir.group_by:
128
+ _tag(gid, "grouped")
129
+ group_by.append(idx.col_qual(gid))
130
+
131
+ for j in ir.joins:
132
+ _tag(j.left_column_id, "joined")
133
+ _tag(j.right_column_id, "joined")
134
+
135
+ order_by: list[OrderByRef] = []
136
+ for o in ir.order_by:
137
+ # IR wart: order_by.column_id may hold a SELECT alias (a computed output),
138
+ # not a catalog column_id. Resolve as a real column when known, else treat
139
+ # it as a computed-output reference rather than inventing a name.
140
+ if o.column_id in idx.col:
141
+ _tag(o.column_id, "ordered")
142
+ order_by.append(OrderByRef(target=idx.col_qual(o.column_id), kind="column", dir=o.dir))
143
+ else:
144
+ order_by.append(OrderByRef(target=o.column_id, kind="computed", dir=o.dir))
145
+
146
+ columns_read: list[ColumnRef] = []
147
+ for cid, rs in roles.items():
148
+ hit = idx.col.get(cid)
149
+ if hit:
150
+ tname, cname, dtype, pii = hit
151
+ columns_read.append(
152
+ ColumnRef(
153
+ id=cid, name=cname, table=tname, data_type=dtype, pii=pii, roles=sorted(rs)
154
+ )
155
+ )
156
+ else:
157
+ # Unresolved id — keep it, mark it, never guess a name.
158
+ columns_read.append(ColumnRef(id=cid, name=cid, table="?", roles=sorted(rs)))
159
+
160
+ return DataUsed(
161
+ source=SourceRef(id=ir.source_id, name=src_name, type=src_type),
162
+ tables=tables,
163
+ joins=joins,
164
+ columns_read=columns_read,
165
+ output_columns=output_columns,
166
+ filters=filters,
167
+ group_by=group_by,
168
+ order_by=order_by,
169
+ limit=ir.limit,
170
+ rows_returned=rows_returned,
171
+ query=query,
172
+ )
src/traceability/schemas.py CHANGED
@@ -47,6 +47,10 @@ class ToolCallInfo(BaseModel):
47
  The field is spelled `input` in the wire contract (the FE reads it), which
48
  shadows the `input` builtin — hence the alias + `populate_by_name` so callers
49
  can pass `input=` on construction and `model_dump(by_alias=True)` emits `input`.
 
 
 
 
50
  """
51
 
52
  model_config = ConfigDict(populate_by_name=True)
@@ -54,12 +58,113 @@ class ToolCallInfo(BaseModel):
54
  order: int
55
  task_id: str | None = None
56
  name: str
 
57
  input_: dict[str, Any] = Field(default_factory=dict, alias="input")
58
  output: dict[str, Any] = Field(default_factory=dict)
59
  status: Literal["success", "error"] = "success"
60
  error: str | None = None
61
 
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  class TraceabilityPayload(BaseModel):
64
  """The full provenance record for one assistant `message_id`."""
65
 
@@ -73,4 +178,5 @@ class TraceabilityPayload(BaseModel):
73
  planning: PlanningInfo | None = None
74
  thinking: str | None = None
75
  tool_calls: list[ToolCallInfo] = Field(default_factory=list)
 
76
  sources: list[dict[str, Any]] = Field(default_factory=list)
 
47
  The field is spelled `input` in the wire contract (the FE reads it), which
48
  shadows the `input` builtin — hence the alias + `populate_by_name` so callers
49
  can pass `input=` on construction and `model_dump(by_alias=True)` emits `input`.
50
+
51
+ `summary` is a plain-English one-liner built from a fixed per-tool template
52
+ (never an LLM call) — the FE headline for this step; `input`/`output` stay raw
53
+ for the collapsible "technical details" layer.
54
  """
55
 
56
  model_config = ConfigDict(populate_by_name=True)
 
58
  order: int
59
  task_id: str | None = None
60
  name: str
61
+ summary: str | None = None
62
  input_: dict[str, Any] = Field(default_factory=dict, alias="input")
63
  output: dict[str, Any] = Field(default_factory=dict)
64
  status: Literal["success", "error"] = "success"
65
  error: str | None = None
66
 
67
 
68
+ # ---------------------------------------------------------------------------
69
+ # `data_used` — the user-facing "what data did this analysis touch" layer.
70
+ # All names are resolved from the catalog at build time (deterministic lookup,
71
+ # no LLM). Every `id` field is MACHINE-ONLY — the FE must never render it; it
72
+ # exists for click-through/linking, reconciliation, and audit. One DataUsed
73
+ # entry per `retrieve_data` call.
74
+ # ---------------------------------------------------------------------------
75
+
76
+
77
+ class SourceRef(BaseModel):
78
+ """The data source a pull read from. `id` is machine-only (FE must not render)."""
79
+
80
+ id: str
81
+ name: str
82
+ type: str | None = None
83
+
84
+
85
+ class TableRef(BaseModel):
86
+ """A table the query touched. `id` is machine-only. `role`: base | joined."""
87
+
88
+ id: str
89
+ name: str
90
+ role: str
91
+
92
+
93
+ class JoinRef(BaseModel):
94
+ """A join, rendered in real names, e.g. condition='order_items.order_id = orders.id'."""
95
+
96
+ type: str
97
+ condition: str
98
+
99
+
100
+ class ColumnRef(BaseModel):
101
+ """A real catalog column the query read. `id` is machine-only (FE must not render).
102
+
103
+ `roles` records why it was used: selected | aggregated | filtered | grouped |
104
+ joined | ordered. `table` is the real table name for qualification.
105
+ """
106
+
107
+ id: str
108
+ name: str
109
+ table: str
110
+ data_type: str | None = None
111
+ pii: bool = False
112
+ roles: list[str] = Field(default_factory=list)
113
+
114
+
115
+ class OutputColumn(BaseModel):
116
+ """A column in the result set.
117
+
118
+ `kind='column'` — read straight from the data (has a real `from`).
119
+ `kind='computed'` — calculated; carries a `formula`, and has NO catalog id
120
+ because it is not a stored column (e.g. total_revenue = SUM(line_total)).
121
+ `from` is spelled with an alias (Python keyword) — populate_by_name lets
122
+ callers pass `from_=`.
123
+ """
124
+
125
+ model_config = ConfigDict(populate_by_name=True)
126
+
127
+ name: str
128
+ kind: Literal["column", "computed"]
129
+ from_: str | None = Field(default=None, alias="from")
130
+ formula: str | None = None
131
+
132
+
133
+ class FilterRef(BaseModel):
134
+ """A filter, resolved to a real column plus a plain-language `description`."""
135
+
136
+ column: str
137
+ op: str
138
+ value: Any = None
139
+ description: str
140
+
141
+
142
+ class OrderByRef(BaseModel):
143
+ """A sort. `target` is a real column name (kind='column') OR a computed
144
+ output alias (kind='computed' — the IR stores the alias in `column_id`)."""
145
+
146
+ target: str
147
+ kind: Literal["column", "computed"]
148
+ dir: str = "asc"
149
+
150
+
151
+ class DataUsed(BaseModel):
152
+ """One `retrieve_data` pull, fully resolved for the user. Names for display,
153
+ ids for machine linkage only (FE must not render any `id`)."""
154
+
155
+ source: SourceRef
156
+ tables: list[TableRef] = Field(default_factory=list)
157
+ joins: list[JoinRef] = Field(default_factory=list)
158
+ columns_read: list[ColumnRef] = Field(default_factory=list)
159
+ output_columns: list[OutputColumn] = Field(default_factory=list)
160
+ filters: list[FilterRef] = Field(default_factory=list)
161
+ group_by: list[str] = Field(default_factory=list)
162
+ order_by: list[OrderByRef] = Field(default_factory=list)
163
+ limit: int | None = None
164
+ rows_returned: int | None = None
165
+ query: str | None = None
166
+
167
+
168
  class TraceabilityPayload(BaseModel):
169
  """The full provenance record for one assistant `message_id`."""
170
 
 
178
  planning: PlanningInfo | None = None
179
  thinking: str | None = None
180
  tool_calls: list[ToolCallInfo] = Field(default_factory=list)
181
+ data_used: list[DataUsed] = Field(default_factory=list)
182
  sources: list[dict[str, Any]] = Field(default_factory=list)
src/traceability/scratchpad.py CHANGED
@@ -87,6 +87,31 @@ def _meta_of(output: Any) -> dict[str, Any]:
87
  return meta if isinstance(meta, dict) else {}
88
 
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  class TraceabilityScratchpad:
91
  """Mutable per-request accumulator; `build()` freezes it into a payload."""
92
 
@@ -98,10 +123,18 @@ class TraceabilityScratchpad:
98
  self._db_sources: list[dict[str, Any]] = []
99
  self._doc_sources: list[dict[str, Any]] = []
100
  self._doc_seen: set[tuple[Any, Any]] = set()
 
 
101
 
102
  def set_intent(self, intent: str) -> None:
103
  self.intent = intent
104
 
 
 
 
 
 
 
105
  def record_tool_call(
106
  self,
107
  name: str,
@@ -118,6 +151,7 @@ class TraceabilityScratchpad:
118
  order=len(self._tool_calls) + 1,
119
  task_id=task_id,
120
  name=name,
 
121
  input=_truncate(dict(args)),
122
  output=out_dict,
123
  status=status,
@@ -126,6 +160,26 @@ class TraceabilityScratchpad:
126
  )
127
  if name == "retrieve_data" and status == "success":
128
  self._record_db_source(output)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
  def _record_db_source(self, output: Any) -> None:
131
  # retrieve_data's args are {"ir": ...}; the reliable source_id/table/query
@@ -196,10 +250,34 @@ class TraceabilityScratchpad:
196
  source["score"] = score
197
  self._doc_sources.append(source)
198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  def build(self, analysis_id: str, user_id: str, message_id: str) -> TraceabilityPayload:
200
  """Freeze the accumulated state into a `TraceabilityPayload`."""
201
  from datetime import UTC, datetime
202
 
 
203
  return TraceabilityPayload(
204
  analysis_id=analysis_id,
205
  message_id=message_id,
@@ -209,6 +287,7 @@ class TraceabilityScratchpad:
209
  planning=self._planning,
210
  thinking=None,
211
  tool_calls=list(self._tool_calls),
 
212
  sources=self._doc_sources + self._db_sources,
213
  )
214
 
 
87
  return meta if isinstance(meta, dict) else {}
88
 
89
 
90
+ def _summarize(name: str, out_dict: dict[str, Any], meta: dict[str, Any]) -> str:
91
+ """One plain-English line per tool step (fixed templates — never an LLM call)."""
92
+ rc = out_dict.get("row_count")
93
+ cols = out_dict.get("columns")
94
+ ncol = len(cols) if isinstance(cols, list) else None
95
+ if name == "check_data":
96
+ return "Inspected your data source structure"
97
+ if name == "retrieve_data":
98
+ parts = "Retrieved"
99
+ if rc is not None:
100
+ parts += f" {rc} rows"
101
+ if ncol:
102
+ parts += f" across {ncol} columns"
103
+ table = meta.get("table_name")
104
+ if table:
105
+ parts += f" from {table}"
106
+ return parts
107
+ if name == "retrieve_knowledge":
108
+ return f"Searched your documents ({rc if rc is not None else 0} passages found)"
109
+ if name.startswith("analyze_"):
110
+ pretty = name.removeprefix("analyze_").replace("_", " ")
111
+ return f"Ran {pretty} analysis on {ncol} columns" if ncol else f"Ran {pretty} analysis"
112
+ return name.replace("_", " ")
113
+
114
+
115
  class TraceabilityScratchpad:
116
  """Mutable per-request accumulator; `build()` freezes it into a payload."""
117
 
 
123
  self._db_sources: list[dict[str, Any]] = []
124
  self._doc_sources: list[dict[str, Any]] = []
125
  self._doc_seen: set[tuple[Any, Any]] = set()
126
+ self._catalog: Any = None # set by set_catalog: enables id->name resolution
127
+ self._retrieve_calls: list[dict[str, Any]] = [] # raw retrieve_data IRs + meta
128
 
129
  def set_intent(self, intent: str) -> None:
130
  self.intent = intent
131
 
132
+ def set_catalog(self, catalog: Any) -> None:
133
+ """Provide the catalog used this turn so `build()` can resolve the IR ids in
134
+ each retrieve_data call to real names (the `data_used` layer). No-op-safe:
135
+ without it, `data_used` stays empty and the raw tool_calls still carry the IR."""
136
+ self._catalog = catalog
137
+
138
  def record_tool_call(
139
  self,
140
  name: str,
 
151
  order=len(self._tool_calls) + 1,
152
  task_id=task_id,
153
  name=name,
154
+ summary=_summarize(name, out_dict, _meta_of(output)),
155
  input=_truncate(dict(args)),
156
  output=out_dict,
157
  status=status,
 
160
  )
161
  if name == "retrieve_data" and status == "success":
162
  self._record_db_source(output)
163
+ self._capture_retrieve(args, output)
164
+
165
+ def _capture_retrieve(self, args: Any, output: Any) -> None:
166
+ """Stash the raw retrieve_data IR + provenance meta so `build()` can resolve a
167
+ `DataUsed`. Gated on the SAME `meta.source_id` check as `_record_db_source`, so
168
+ the two stay index-aligned (the db source was just appended)."""
169
+ meta = _meta_of(output)
170
+ if not meta.get("source_id"):
171
+ return
172
+ ir = args.get("ir") if isinstance(args, dict) else None
173
+ if not isinstance(ir, dict):
174
+ return
175
+ query = meta.get("query")
176
+ self._retrieve_calls.append({
177
+ "ir": ir,
178
+ "query": query[:CAP_QUERY] if isinstance(query, str) else None,
179
+ "source_name": meta.get("source_name"),
180
+ "row_count": meta.get("row_count"),
181
+ "db_source_index": len(self._db_sources) - 1,
182
+ })
183
 
184
  def _record_db_source(self, output: Any) -> None:
185
  # retrieve_data's args are {"ir": ...}; the reliable source_id/table/query
 
250
  source["score"] = score
251
  self._doc_sources.append(source)
252
 
253
+ def _build_data_used(self) -> list[Any]:
254
+ """Resolve each captured retrieve_data IR into a `DataUsed` (real names) and
255
+ enrich the matching db source with source_name + all tables touched. Never
256
+ raises — a resolution slip drops that entry, never breaks the answer."""
257
+ if self._catalog is None or not self._retrieve_calls:
258
+ return []
259
+ from ..query.ir.models import QueryIR
260
+ from .resolve import resolve_data_used
261
+
262
+ out: list[Any] = []
263
+ for rc in self._retrieve_calls:
264
+ try:
265
+ ir = QueryIR.model_validate(rc["ir"])
266
+ du = resolve_data_used(ir, self._catalog, rc.get("query"), rc.get("row_count"))
267
+ out.append(du)
268
+ idx = rc.get("db_source_index")
269
+ if isinstance(idx, int) and 0 <= idx < len(self._db_sources):
270
+ self._db_sources[idx]["source_name"] = rc.get("source_name")
271
+ self._db_sources[idx]["tables"] = [t.name for t in du.tables]
272
+ except Exception as exc: # never break the answer on a resolve slip
273
+ logger.warning("data_used resolve failed", error=str(exc))
274
+ return out
275
+
276
  def build(self, analysis_id: str, user_id: str, message_id: str) -> TraceabilityPayload:
277
  """Freeze the accumulated state into a `TraceabilityPayload`."""
278
  from datetime import UTC, datetime
279
 
280
+ data_used = self._build_data_used() # also enriches self._db_sources in place
281
  return TraceabilityPayload(
282
  analysis_id=analysis_id,
283
  message_id=message_id,
 
287
  planning=self._planning,
288
  thinking=None,
289
  tool_calls=list(self._tool_calls),
290
+ data_used=data_used,
291
  sources=self._doc_sources + self._db_sources,
292
  )
293