/fix traceability
#15
by rhbt6767 - opened
- API_CONTRACT_BE_PYTHON.md +32 -4
- DEV_PLAN.md +2 -0
- DS_TOOLS_PLAN.md +328 -0
- REPO_STATUS.md +1 -1
- src/agents/chat_handler.py +3 -0
- src/agents/handlers/check.py +7 -2
- src/catalog/reader.py +51 -19
- src/traceability/resolve.py +172 -0
- src/traceability/schemas.py +106 -0
- src/traceability/scratchpad.py +79 -0
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 |
-
- `
|
|
|
|
|
|
|
|
|
|
| 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: `
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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).
|
| 85 |
-
|
| 86 |
-
user-scope reader
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 98 |
-
#
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
#
|
| 103 |
-
#
|
| 104 |
-
#
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
catalog
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|