fix/ check and new chart tool

#16
API_CONTRACT_BE_PYTHON.md CHANGED
@@ -34,6 +34,7 @@ The frontend uses this service during the analysis conversation flow:
34
  | `GET` | `/api/v1/tools/report/{analysis_id}/readiness` | Report-readiness signal for the Generate-Report button (added 2026-07-09). |
35
  | `GET` | `/api/v1/tools/report/{analysis_id}/{version}` | Retrieve one report version. |
36
  | `GET` | `/api/v1/traceability` | Retrieve provenance for one assistant answer. |
 
37
 
38
  ## Common Concepts
39
 
@@ -61,6 +62,8 @@ Common event types:
61
 
62
  The stream carries answer text only. Planning, tool call details, and full provenance are fetched from `GET /api/v1/traceability` after the stream is done.
63
 
 
 
64
  ## Chat
65
 
66
  ### `POST /api/v2/chat/stream`
@@ -353,10 +356,24 @@ Report v2 fields (added 2026-07-09; all default-empty, so older stored reports r
353
  - `unresolved` — runs that were attempted but produced no usable evidence (every `analyze_*` step failed). Not part of the findings body.
354
  - `excluded` — runs the caller excluded via `exclude_record_ids`.
355
  - `evidence_tables` — `record_id` → small result tables copied from the run's stored outputs (max 3 tables per record, max 10 rows each; `truncated: true` when rows were capped). Rendered as markdown tables under the matching Key Findings group in `rendered_markdown`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
 
357
  Precondition:
358
 
359
- - Reports require at least one completed analysis record for the session.
360
  - If slow-path analysis recording is disabled, report generation can return `409` by design.
361
 
362
  ### `GET /api/v1/tools/report/{analysis_id}`
@@ -643,3 +660,114 @@ Frontend rendering guidance:
643
  - Default state can be collapsed.
644
  - Show planning, tool calls, and sources as separate sections.
645
  - Treat `planning: null`, `tool_calls: []`, and `sources: []` as valid states.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  | `GET` | `/api/v1/tools/report/{analysis_id}/readiness` | Report-readiness signal for the Generate-Report button (added 2026-07-09). |
35
  | `GET` | `/api/v1/tools/report/{analysis_id}/{version}` | Retrieve one report version. |
36
  | `GET` | `/api/v1/traceability` | Retrieve provenance for one assistant answer. |
37
+ | `GET` | `/api/v1/charts` | Retrieve chart(s) produced by `render_chart` for one assistant answer (added 2026-07-13). |
38
 
39
  ## Common Concepts
40
 
 
62
 
63
  The stream carries answer text only. Planning, tool call details, and full provenance are fetched from `GET /api/v1/traceability` after the stream is done.
64
 
65
+ **Charts (added 2026-07-13, SPINE_V2_PLAN §4.5):** the `done` event is unchanged by charts — no additive `chart_count`/`chart_ids` field yet (open question owned by Harry). Until that's resolved, the frontend fetches `GET /api/v1/charts` unconditionally on every `done`, the same fetch-on-`done` pattern already used for traceability. SSE order and every existing field stay exactly as documented above; `sources` stays `[]`.
66
+
67
  ## Chat
68
 
69
  ### `POST /api/v2/chat/stream`
 
356
  - `unresolved` — runs that were attempted but produced no usable evidence (every `analyze_*` step failed). Not part of the findings body.
357
  - `excluded` — runs the caller excluded via `exclude_record_ids`.
358
  - `evidence_tables` — `record_id` → small result tables copied from the run's stored outputs (max 3 tables per record, max 10 rows each; `truncated: true` when rows were capped). Rendered as markdown tables under the matching Key Findings group in `rendered_markdown`.
359
+ - `charts` *(added 2026-07-14)* — `record_id` → `dataeyond.chart.v1` envelopes (see §Charts) copied verbatim from the run's stored outputs (max 3 per record). `rendered_markdown` gains an `## EDA` section where each chart appears as a fenced block the frontend renders with plotly.js:
360
+
361
+ ````text
362
+ ```plotly
363
+ {
364
+ "schema": "dataeyond.chart.v1",
365
+ "chart_type": "bar",
366
+ "title": "…",
367
+ "plotly": { "data": [ … ], "layout": { … } }
368
+ }
369
+ ```
370
+ ````
371
+
372
+ The fence content is the **full envelope** (same shape as `charts[].spec` on `GET /api/v1/charts`) — the FE hook parses it and renders `Plotly.newPlot(el, parsed.plotly.data, parsed.plotly.layout)`. A bold caption line (chart title) precedes each fence.
373
 
374
  Precondition:
375
 
376
+ - Reports require at least one completed analysis record for the session (*updated 2026-07-14:* a run whose `render_chart` succeeded counts — a chart-only session can generate a report).
377
  - If slow-path analysis recording is disabled, report generation can return `409` by design.
378
 
379
  ### `GET /api/v1/tools/report/{analysis_id}`
 
660
  - Default state can be collapsed.
661
  - Show planning, tool calls, and sources as separate sections.
662
  - Treat `planning: null`, `tool_calls: []`, and `sources: []` as valid states.
663
+
664
+ ## Charts
665
+
666
+ > Added 2026-07-13 (SPINE_V2_PLAN §4.4/§4.5, S2 visualization). A chart is a `render_chart` tool output, planner-selected only when the user explicitly asks to plot/visualize. Delivery mirrors traceability: a Python-owned store plus a dedicated `GET` endpoint; the streamed answer (SSE) stays text-only — charts are never embedded in `chunk`.
667
+
668
+ ### `GET /api/v1/charts`
669
+
670
+ Returns every chart produced by `render_chart` during one assistant answer.
671
+
672
+ The frontend should call this after the chat stream emits `done`, using the `message_id` from the `done` event — the same fetch-on-`done` pattern as traceability; the row(s) are written **before** `done`, so there is no polling race.
673
+
674
+ > **Updated 2026-07-13 (lead review):** lookup is now by **`message_id` alone** (it is a server-minted UUID — globally unique; `analysis_id` was removed from the query), and every response is **HTTP 200 with an explicit `status` marker** instead of a bare list: `success` (≥1 chart), `empty` (the turn completed but produced no charts — the common case), `not_found` (no completed turn is known for this `message_id`: mistyped/stale id, or an error turn). *(Note the asymmetry: `GET /api/v1/traceability` still takes `analysis_id` + `message_id` — aligning it is a separate change if wanted.)*
675
+
676
+ Query params:
677
+
678
+ | Query | Required | Description |
679
+ | --- | --- | --- |
680
+ | `message_id` | Yes | Assistant answer identifier returned by the stream's `done` event. |
681
+
682
+ Example:
683
+
684
+ ```text
685
+ GET /api/v1/charts?message_id=88f10c3a-6f03-4204-bf98-41ffc20388b2
686
+ ```
687
+
688
+ The `dataeyond.chart.v1` envelope (the shape of `charts[].spec`, verbatim from `render_chart`):
689
+
690
+ ```json
691
+ {
692
+ "schema": "dataeyond.chart.v1",
693
+ "chart_type": "bar",
694
+ "title": "Revenue by region",
695
+ "plotly": {
696
+ "data": [{ "type": "bar", "x": ["A", "B"], "y": [1, 2], "name": "revenue" }],
697
+ "layout": { "title": {"text": "Revenue by region"}, "xaxis": {"title": {"text": "region"}},
698
+ "yaxis": {"title": {"text": "revenue"}} }
699
+ }
700
+ }
701
+ ```
702
+
703
+ Frontend renders it with `Plotly.newPlot(el, spec.plotly.data, spec.plotly.layout)`. v1 chart types: `bar`, `line`, `pie`, `scatter`.
704
+
705
+ Response `200` (`status: success` — ≥1 chart):
706
+
707
+ ```json
708
+ {
709
+ "status": "success",
710
+ "message": "1 chart(s) for this message.",
711
+ "count": 1,
712
+ "charts": [
713
+ {
714
+ "chart_id": "3fbd8e2e-8e21-4d4b-9b21-9e6b6a0a6a6e",
715
+ "chart_type": "bar",
716
+ "title": "Revenue by region",
717
+ "spec": {
718
+ "schema": "dataeyond.chart.v1",
719
+ "chart_type": "bar",
720
+ "title": "Revenue by region",
721
+ "plotly": {
722
+ "data": [{ "type": "bar", "x": ["A", "B"], "y": [1, 2], "name": "revenue" }],
723
+ "layout": { "title": {"text": "Revenue by region"}, "xaxis": {"title": {"text": "region"}},
724
+ "yaxis": {"title": {"text": "revenue"}} }
725
+ }
726
+ },
727
+ "created_at": "2026-07-13T03:21:09.114Z"
728
+ }
729
+ ]
730
+ }
731
+ ```
732
+
733
+ Response `200` (`status: empty` — chartless turn, the common case; not an error):
734
+
735
+ ```json
736
+ {
737
+ "status": "empty",
738
+ "message": "This message completed without producing charts.",
739
+ "count": 0,
740
+ "charts": []
741
+ }
742
+ ```
743
+
744
+ Response `200` (`status: not_found` — no completed turn known for this id):
745
+
746
+ ```json
747
+ {
748
+ "status": "not_found",
749
+ "message": "No completed turn is known for this message_id.",
750
+ "count": 0,
751
+ "charts": []
752
+ }
753
+ ```
754
+
755
+ Field rules:
756
+
757
+ - `status` is the outcome marker: `success` | `empty` | `not_found` (always HTTP 200 — the FE fetches unconditionally, so a missing turn is a data state, not a transport failure). `empty` vs `not_found` is decided against the turn's traceability row (written before `done`), so `not_found` reliably means "this id never completed a turn".
758
+ - `message` is a human-readable line for logs/debugging — do not parse it; branch on `status`.
759
+ - `spec` is the full `dataeyond.chart.v1` envelope, unmodified — it is the source of truth, not a projection; render straight from it.
760
+ - `chart_type` / `title` are copied out of `spec` for convenience (list rendering without parsing `spec`); `title` may be `null`.
761
+ - A turn can produce more than one chart (multiple `render_chart` calls in the same plan); `charts` is ordered by creation time.
762
+ - The payload carries no `user_id` / `analysis_id` — charts are keyed by `message_id` alone.
763
+
764
+ > **DDL note (Harry / dedorch migration):** the original manual index is `(analysis_id, message_id)`, which does not serve a `message_id`-only lookup. Additive index for the migration (also safe to run manually now):
765
+ > ```sql
766
+ > CREATE INDEX IF NOT EXISTS idx_message_charts_message ON message_charts (message_id);
767
+ > ```
768
+
769
+ Frontend rendering guidance:
770
+
771
+ - Fetch unconditionally on every `done` — see the SSE note above (no `chart_count` hint yet); an empty `charts[]` means render nothing extra.
772
+ - Render each chart under the assistant message it belongs to, via `Plotly.newPlot`.
773
+ - "Chart iteration" v1 = a follow-up chat turn (e.g. "make it a line chart") — the planner re-emits `render_chart` with patched args. There is no separate edit endpoint.
DEV_PLAN.md CHANGED
@@ -68,6 +68,30 @@ base64-mangled from Go. Fix tasks (same status legend as §0):
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
 
73
  1. **"Problem statement" is replaced by two user-entered fields: `objective` + `business_questions`.**
@@ -195,7 +219,7 @@ Status legend: ⬜ not started · 🔄 in progress · ✅ done · ⛔ blocked ·
195
  | 23 | Report markdown formatting: tables, **bold**, *italic*, horizontal separators | Sofhia | ✅ | Done 2026-06-25. Added `---` separators between header + each section in `_render_markdown`. Tables (EDA) / bold (method labels) / italic (meta + citations) already emitted. Relaxed `report_summary.md` to allow inline `**bold**`/`*italic*` for emphasis (kept no-headings/no-bullets so it doesn't duplicate the section structure / Key Findings). Compile + ruff clean |
196
  | 24 | Clarify report input contract: records table (+ `last_report` for edit mode?) | Rifqi/Sofhia ↔ Harry | ⬜ new | Edit-mode input left open at the checkpoint |
197
  | 25 | Migrate Python chat path to Go `analyses_messages` (+ `analyses`) | Rifqi ↔ Harry | ✅ | Done 2026-07-02. Read path already on `analyses_messages` (commit `0066161`). This change makes Python **read-only**: removed the `save_messages` calls from `/api/v2/chat/stream` so **Go is the sole writer** — fixes the double-write both Go+Python were producing. `load_history` still reads `analyses_messages`. v1 `/chat/stream` is unwired so left untouched |
198
- | 26 | **Charts (DEFERRED):** store Plotly JSON in a future `chart` table (not matplotlib PNG) | — | ⏸️ | After the markdown path is done end-to-end |
199
  | 27 | **Images (DEFERRED):** image table (id, analysis_id, msg/report ref, order) + originals in a bucket | — | ⏸️ | Maintenance-heavy; parked |
200
  | 28 | **UI research** (FE): new-analysis form, knowledge menu (user vs analysis level), report artifacts + version selector | Team | ⬜ new | No dedicated UI person; interview + old analysis UI removed |
201
 
@@ -218,3 +242,8 @@ Status legend: ⬜ not started · 🔄 in progress · ✅ done · ⛔ blocked ·
218
  - **Report input for edit mode** — does Python need the last report content? (#24)
219
  - ~~`report_inputs` write scope — every agent call vs slow-path-only? (#21)~~ RESOLVED: one row per slow-path run (telemetry stays Langfuse).
220
  - **Python history source** — confirm Go's `analysis_message` (#25).
 
 
 
 
 
 
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
+ ## 0.6. pr/16 sprint — Spine v2: W2 charts + W1 checkpoint (SPINE_V2_PLAN, approved 2026-07-13)
72
+
73
+ Scope approved 2026-07-13 (Rifqi + Sofia): build W2 (`render_chart` + chart store + `GET /charts`
74
+ + planner viz slice) and W1 (S1a quality checkpoint). **W3 (activate deferred `analyze_*`) deferred
75
+ at approval — do not start until Rifqi re-opens.** W4 (S1b repair) stays gated on INV-6 sign-off +
76
+ S1a telemetry. Design + handoff source: `SPINE_V2_PLAN.md`.
77
+
78
+ | # | Task | Owner | Status | Note |
79
+ |---|---|---|---|---|
80
+ | V1 | `render_chart` tool slice: `visualization.py` (Plotly-JSON `dataeyond.chart.v1` envelope, no plotly dep) + `ToolOutput.kind` `"chart"` + registry + invoker | Rifqi (Sofia signed off on the tool-layer edit) | ✅ | done 2026-07-13; deterministic spec builder (bar/line/pie/scatter, fixed style preset), traceability scratchpad summarizes chart outputs compactly (point_count, not the raw arrays) |
81
+ | V2 | Chart store + API: `MessageChartRow` (`message_charts`) + `src/charts/store.py` (never-throw save) + write site in `_run_slow_path` + `GET /api/v1/charts` + contract §charts | Rifqi | ✅ | done 2026-07-13; empty list = valid 200; `done` event unchanged (no `chart_count` — open, Harry); FE fetches unconditionally on `done` |
82
+ | V3 | Planner viz slice: recipe table + "Charts only on explicit ask" rule (planner.md), Example J (viz tail) + Example K (viz-infeasible), validator Check 10 (`render_chart.data` must be table-kind), assembler chart one-liner guard | Rifqi | ✅ | done 2026-07-13; behavioral matrix verified in-process (real LLM): explicit ask EN/ID → chart tail; plain question → no chart; absent dimension → infeasible (Example K + prompt guard added after the first smoke force-mapped `status` AS "region") |
83
+ | V4 | W1 S1a quality checkpoint: `slow_path/checkpoint.py` (CK1–CK6) + `RunAssessment` schemas + coordinator call site + "Execution assessment" block in the assembler input + `refusals.run_failure_message` | Rifqi | ✅ | done 2026-07-13; 13 local tests (one per CK rule + never-throw + prompt + coordinator); CK1 all-failed → deterministic honest failure, **no assembler call**; clean run renders nothing (zero behavior change); every flag logs `repair_candidate` (S1b evidence) |
84
+ | V5 | `message_charts` DDL: run manually against dedorch (block for the live e2e chart test), then hand the schema to Harry for the dedorch migration | Rifqi → Harry | ✅ | Rifqi ran the DDL 2026-07-13; **live e2e ALL PASS** same day (real v2 endpoint: viz turn → chart row keyed by `done` message_id → `GET /charts` valid v1 envelope; chartless → 200 empty; injected `render_chart` failure → answer streams as a table, no row). **Remaining: send Harry the migration handoff** (schema + contract §charts) |
85
+ | V6 | Restore the `eval.chat_sim` harness — `eval/chat_sim/*.py` is missing from disk AND git (only `__pycache__` remains); §7B prompt gate can't run | Rifqi | ✅ | restored by Rifqi 2026-07-13 (accidental delete). ⚠️ its hard-coded `DEFAULT_USER_ID`/`TITANIC_SOURCE_ID` are stale (that user has no catalog; the Titanic blobs are gone) — update the constants before the next full run |
86
+ | V7 | Local `.env` lagged Go's Supabase-S3 data plane: `storage_provider=azure_blob` + empty `supabase_s3_*` made EVERY local tabular retrieve fail `BlobNotFound` | Rifqi | ✅ | found during the e2e (masked as "data not available" by the honest-degrade path); Rifqi set the six values 2026-07-13. Gotcha documented in REPO_STATUS §13 |
87
+ | V8 | Lead review of `GET /charts`: lookup by `message_id` alone + tri-state response marker (`status: success \| empty \| not_found` + `message`) instead of a bare list | Rifqi (lead ask) | ✅ | done 2026-07-14; `not_found` vs `empty` decided against the turn's traceability row (PK lookup); always HTTP 200; contract §charts updated. ⚠️ additive DDL for the new lookup: `CREATE INDEX IF NOT EXISTS idx_message_charts_message ON message_charts (message_id);` (run manually + include in Harry's migration). Traceability GET still takes both params — aligning it is open |
88
+ | V9 | Report chart embedding: `AnalysisReport.charts` (verbatim envelopes per record) + `## EDA` section with ` ```plotly ` fences (content = the **full v1 envelope**, pretty-printed — the FE hook's verified shape); `has_successful_analysis` extended so a successful `render_chart` counts (chart-only sessions satisfy the report floor) | Rifqi | ✅ | done 2026-07-14; first cut emitted bare `{data, layout}` — FE test showed the hook parses the full envelope, fixed same day; live reports v3 (wrong fence) → **v4 (correct)** for analysis `7be50846…` (3 charts embedded); suite **381 passed, 7 skipped** (+5 chart-embed tests; same 2 pre-existing failures) |
89
+
90
+ Full-suite evidence for this sprint: **376 passed, 7 skipped** (+13 new checkpoint tests; the 2
91
+ failures — `test_chat_handler::test_structured_flow_runs_slow_path`,
92
+ `test_reader::test_structured_read_falls_back_to_user_scope_when_no_analysis_row` — reproduce at
93
+ HEAD before this diff, i.e. pre-existing). Ruff clean on all touched paths; `import main` OK.
94
+
95
  ## 1. The direction change (locked decisions from 2026-06-24)
96
 
97
  1. **"Problem statement" is replaced by two user-entered fields: `objective` + `business_questions`.**
 
219
  | 23 | Report markdown formatting: tables, **bold**, *italic*, horizontal separators | Sofhia | ✅ | Done 2026-06-25. Added `---` separators between header + each section in `_render_markdown`. Tables (EDA) / bold (method labels) / italic (meta + citations) already emitted. Relaxed `report_summary.md` to allow inline `**bold**`/`*italic*` for emphasis (kept no-headings/no-bullets so it doesn't duplicate the section structure / Key Findings). Compile + ruff clean |
220
  | 24 | Clarify report input contract: records table (+ `last_report` for edit mode?) | Rifqi/Sofhia ↔ Harry | ⬜ new | Edit-mode input left open at the checkpoint |
221
  | 25 | Migrate Python chat path to Go `analyses_messages` (+ `analyses`) | Rifqi ↔ Harry | ✅ | Done 2026-07-02. Read path already on `analyses_messages` (commit `0066161`). This change makes Python **read-only**: removed the `save_messages` calls from `/api/v2/chat/stream` so **Go is the sole writer** — fixes the double-write both Go+Python were producing. `load_history` still reads `analyses_messages`. v1 `/chat/stream` is unwired so left untouched |
222
+ | 26 | **Charts (DEFERRED):** store Plotly JSON in a future `chart` table (not matplotlib PNG) | — | 🔎 | **Landed 2026-07-13 (pr/16, §0.6 V1–V3):** `render_chart` + `message_charts` + `GET /api/v1/charts`, Plotly JSON as decided. 🔎 pending the dedorch DDL run (V5) + live e2e |
223
  | 27 | **Images (DEFERRED):** image table (id, analysis_id, msg/report ref, order) + originals in a bucket | — | ⏸️ | Maintenance-heavy; parked |
224
  | 28 | **UI research** (FE): new-analysis form, knowledge menu (user vs analysis level), report artifacts + version selector | Team | ⬜ new | No dedicated UI person; interview + old analysis UI removed |
225
 
 
242
  - **Report input for edit mode** — does Python need the last report content? (#24)
243
  - ~~`report_inputs` write scope — every agent call vs slow-path-only? (#21)~~ RESOLVED: one row per slow-path run (telemetry stays Langfuse).
244
  - **Python history source** — confirm Go's `analysis_message` (#25).
245
+ - **`done.chart_count`** — additive field on the SSE `done` event so the FE can skip `GET /charts`
246
+ on chartless turns? (Harry; SPINE_V2_PLAN §4.5.) Until decided the FE fetches unconditionally.
247
+ - **W3 re-open timing** (deferred `analyze_*` activation) — Rifqi (deferred at the 2026-07-13 approval).
248
+ - **INV-6 relaxation for S1b targeted repair** — team, only after S1a `repair_candidate` telemetry
249
+ shows a meaningful hit-rate (SPINE_V2_PLAN §6).
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. **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
@@ -141,33 +141,36 @@ Two facts to internalise:
141
 
142
  ## 6. Feature list (what's built)
143
 
144
- - **5-intent handler router** (`chat`/`help`/`check`/`unstructured_flow`/`structured_flow`) with history-aware query rewriting (EN/ID).
145
  - **Skills:** `help` (LLM, state-aware next-step guidance), `check` (no-LLM data/document inventory). *(The `problem_statement` skill and the `problem_validated` gate were removed 2026-06-25 — KM-652; `gate.py` kept as a no-op seam, `problem_statement.py` kept but unwired.)*
146
- - **Slow analytical path:** Planner → TaskRunner → Assembler (static plan, degrade-and-continue, 3 LLM calls fixed).
147
  - **Structured query engine:** catalog-driven JSON IR → deterministic SQL/pandas compiler → read-only executor, with **single-level FK joins** (DB sources only).
148
  - **Unstructured RAG** over PGVector.
149
- - **Analytics tools:** 4 registered composite `analyze_*` (descriptive, aggregate, correlation, trend) + 4 data-access tools (check_data, check_knowledge, retrieve_data, retrieve_knowledge). Four further composites (comparison, contribution, profile, segment) exist in code but are **not registered** with the Planner.
 
150
  - **Versioned report generation** from persisted records.
151
  - **Analysis sessions:** data-first creation gate (≥1 bound source); each turn reads the analysis-scope catalog so it sees only that analysis's bound sources.
152
  - **Langfuse tracing** (PII-masked), **Redis caching**, **pooled DB engines** + speculative prewarm.
153
 
154
  ---
155
 
156
- ## 7. API surface (this repo, all under `/api/v1`)
157
 
158
- > **» pr/5 (decided, not yet in code):** chat `/api/v2/chat/stream` (`analysis_id`); `/tools` splits
159
- > into `/tools/list` + `/tools/help` + `/tools/report`; new `/api/v1/traceability`; and the
160
- > analysis-CRUD / `room` / `users` / `document` / `db_client` / `data_catalog` rows are unwired from
161
- > `main` + Swagger. See the Direction-update banner.
 
162
 
163
  | Endpoint | Purpose | Caller |
164
  |---|---|---|
165
- | `POST /chat/stream` | Main chat SSE (router → dispatch) | FE → Go → Python (the only FE→Python call today) |
166
- | `DELETE /chat/cache` · `/chat/cache/room/{id}` · `/retrieval/cache/{user_id}` | Cache management | internal / ops |
167
- | `POST /analysis/create` · `GET /analysis` · `GET /analysis/{id}` | Analysis-session CRUD (state + room + bindings created atomically) | intended FE → Go |
168
- | `POST /report` · `GET /report/{id}` · `GET /report/{id}/{ver}` | Report generate / list / fetch | FE → Go (report button) |
169
- | `GET /tools` | Slash-command catalog (static, cacheable) | Go caches it for the FE "/" menu |
170
- | `users` · `room` · `document` · `db_client` · `data_catalog` routers | Phase-1 legacy; functionally migrated to Go | mostly dormant |
 
171
 
172
  ---
173
 
@@ -187,6 +190,8 @@ unless `SKIP_INIT_DB=true`.
187
  | `reports` *(dedorch)* | uuid, `analysis_id`, `user_id`, `title` + markdown `content` + `version` (UNIQUE per analysis) | Go + Python ReportStore | report API |
188
  | `data_sources` *(dedorch, Go-owned)* | per-analysis binding table. **Python no longer reads or writes it** — bindings live in Go's `analyses.data_bind`, which Go materializes into the analysis-scope `data_catalog` row; Python scopes off that row. The table exists (Go migration) but Python is fully decoupled — do **not** drop it manually | Go migration | — (unused by Python) |
189
  | `analyses_messages` *(dedorch)* | the analysis chat room (`role ∈ user\|ai`); replaces deprecated `rooms`/`chat_messages` | Go `/analyses/{id}/messages` | Python chat path **not yet migrated here** (§12) |
 
 
190
 
191
  > ✅ **Python ORM ↔ dedorch drift — reconciled 2026-07-01.** `AnalysisStateRow` (`analyses`) dropped
192
  > `problem_statement`/`problem_validated` and added `objective`/`business_questions` (Harry's #3);
@@ -243,13 +248,22 @@ dedorch state migration (#3/#4) renames it.
243
  - **TaskRunner** (`slow_path/task_runner.py`) — deterministic, 0 LLM. Wave-based execution,
244
  `${t<id>}` placeholder resolution (Pattern A), never-throw invocation, **degrade-and-continue**
245
  (failed task → dependents skipped, independent branches run). No replanning.
 
 
 
 
 
 
 
 
246
  - **Assembler** (`slow_path/assembler.py`) — 1 LLM call authoring only the narrative; code copies
247
  the structured `results_snapshot` / `tasks_run` from the run state into the `AnalysisRecord`
248
  (the report's source of truth).
249
 
250
  Streaming + persistence: `chat_handler._run_slow_path` bridges per-stage progress to SSE `status`
251
  events, prewarms the DB engine in parallel with planning, emits the answer, then persists the
252
- record stamped with `user_id` + `analysis_id`.
 
253
 
254
  ### Structured query engine — `src/query/`
255
  `QueryService.run` (`query/service.py`): plan → validate → retry(3) → dispatch → execute; **never
@@ -275,14 +289,23 @@ to the whole (mis-named) user catalog.
275
  `retrieve_data` runs a pre-built IR (validate → dispatch → execute, skipping the planner) and
276
  coerces `Decimal`→`float` — the Pattern A handoff the `analyze_*` tools consume. The planner
277
  registry composes a local data-access spec stub (name-checked against `DATA_ACCESS_TOOLS`) with the
278
- real `analytics_registry()`.
 
 
 
 
279
 
280
  ### Report — `src/agents/report/`
281
  `generator.py` reads records, deterministically assembles structured fields, 1 LLM call for the
282
  executive summary; `store.py` versions under an advisory lock and persists markdown to dedorch
283
- `reports`; `readiness.py` defines the **report floor** (≥1 successful `analyze_*`; the
284
- `problem_validated` precondition was dropped 2026-06-25) shared by the report API and the Help
285
- readiness signal so the two can't disagree.
 
 
 
 
 
286
 
287
  ### Observability — Langfuse
288
  The endpoint's `ChatHandler` runs with `enable_tracing=True`. One trace per request groups
@@ -337,6 +360,9 @@ Python is consumer-only). State **re-verified against the Go source 2026-06-29**
337
  `rooms`/`chat_messages`/`interview_*` tables to `zdeprecated_*`.
338
  - **`report_inputs`** (the slow-path structured output, formerly `analysis_records`) stays
339
  **Python-owned**; its finalized schema goes to Harry so the dedorch migration creates it post-cutover.
 
 
 
340
  - **Connection-string cutover DONE (2026-07-01).** Python's `postgres_connstring` now points at
341
  **dedorch** and reads the Go-migrated tables directly. Every ORM model Python reads (`analyses`,
342
  `analyses_messages`, `data_catalog`) has been reconciled to its dedorch shape.
@@ -374,6 +400,14 @@ records-based report; floor: ≥1 `analyze_*` success). Wiring Go → Python is
374
  `cryptography.fernet.InvalidToken` — whose `str()` is **empty**, so it logged as `error=""` and
375
  masqueraded as a DB-connection failure (the executor now logs `repr(e)` to expose it). Tell-apart:
376
  a valid-but-wrong key → `InvalidToken`; a malformed key → a non-empty `ValueError` at cipher build.
 
 
 
 
 
 
 
 
377
  - **Never-throw seams** are pervasive (tool invoker, query service, executors, state/catalog reads,
378
  record persistence, report summary). Failures degrade into soft output rather than raising — good
379
  for UX, but they can mask real breakage (e.g. a missing analysis-scope catalog silently falling
 
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. **Spine v2 W2+W1 2026-07-13 (pr/16):** §6/§7/§8/§9/§12 — `render_chart` tool lands (first of the `render_*` family: deterministic Plotly-JSON `dataeyond.chart.v1` envelope, hand-built, **no plotly dependency**; planner-selected only on an explicit chart ask, EN/ID) + Python-owned `message_charts` store + `GET /api/v1/charts` (FE fetches on `done`, same pattern as traceability; empty list = valid 200); planner gains a named **recipe table** + viz few-shots (Example J tail, Example K viz-infeasible) + validator Check 10 (`render_chart.data` must reference a table-producing task); and the slow path gains the **S1a quality checkpoint** (`slow_path/checkpoint.py`, 0 LLM, never-throw, between runner and assembler: CK1 all-failed → deterministic honest-failure answer with **no** assembler call, CK2 empty retrieve + downstream, CK3 10k-cap truncation, CK4 single trend bucket, CK5 all-null column, CK6 chart-spec sanity; flags render as an "Execution assessment" block in the assembler input and every flag logs `repair_candidate` — the S1b evidence base). Design + handoff doc: `SPINE_V2_PLAN.md`. **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
 
141
 
142
  ## 6. Feature list (what's built)
143
 
144
+ - **6-intent handler router** (`chat`/`help`/`check`/`unstructured_flow`/`structured_flow`/`out_of_scope`, the last added 2026-07-03) with history-aware query rewriting (EN/ID).
145
  - **Skills:** `help` (LLM, state-aware next-step guidance), `check` (no-LLM data/document inventory). *(The `problem_statement` skill and the `problem_validated` gate were removed 2026-06-25 — KM-652; `gate.py` kept as a no-op seam, `problem_statement.py` kept but unwired.)*
146
+ - **Slow analytical path:** Planner → TaskRunner → **S1a quality checkpoint** (0 LLM, added 2026-07-13) → Assembler (static plan, degrade-and-continue, 3 LLM calls fixed; an all-failed run now short-circuits to a deterministic honest-failure answer — 2 calls).
147
  - **Structured query engine:** catalog-driven JSON IR → deterministic SQL/pandas compiler → read-only executor, with **single-level FK joins** (DB sources only).
148
  - **Unstructured RAG** over PGVector.
149
+ - **Analytics tools:** 6 registered (5 composite `analyze_*` descriptive, aggregate, correlation, trend, merge — plus `render_chart`, added 2026-07-13) + 4 data-access tools (check_data, check_knowledge, retrieve_data, retrieve_knowledge). Four further composites (comparison, contribution, profile, segment) exist in code but are **not registered** with the Planner (W3, deferred).
150
+ - **Charts (S2, 2026-07-13; updated 2026-07-14):** planner-selected `render_chart` (only on an explicit chart ask, EN/ID) builds a `dataeyond.chart.v1` Plotly-JSON envelope; persisted to Python-owned `message_charts` before `done`, fetched via `GET /api/v1/charts?message_id=` (tri-state `status` marker: success/empty/not_found). SSE stays text-only. **Reports embed charts too** (2026-07-14): the generator copies chart envelopes from `results_snapshot` into an `## EDA` section as ` ```plotly ` fenced blocks (FE hook renders them); a successful `render_chart` now counts toward the report floor (`has_successful_analysis`).
151
  - **Versioned report generation** from persisted records.
152
  - **Analysis sessions:** data-first creation gate (≥1 bound source); each turn reads the analysis-scope catalog so it sees only that analysis's bound sources.
153
  - **Langfuse tracing** (PII-masked), **Redis caching**, **pooled DB engines** + speculative prewarm.
154
 
155
  ---
156
 
157
+ ## 7. API surface (this repo)
158
 
159
+ > **pr/5 restructure IN CODE (table refreshed 2026-07-13).** The banner that stood here
160
+ > ("decided, not yet in code") is done: chat lives at `/api/v2/chat/stream`, the skills regrouped
161
+ > under `/api/v1/tools/*`, `traceability` and (2026-07-13) `charts` are mounted, and the analysis-CRUD
162
+ > / `room` / `users` / `document` / `db_client` / `data_catalog` routers are unwired from `main` +
163
+ > Swagger (files kept, commented mounts). Table below is the **live** surface (`main.py` mounts).
164
 
165
  | Endpoint | Purpose | Caller |
166
  |---|---|---|
167
+ | `POST /api/v2/chat/stream` | Main chat SSE (`analysis_id`; router → dispatch) | FE → Go → Python |
168
+ | `GET /api/v1/tools/list` | Slash-command catalog (static, cacheable) | Go caches it for the FE "/" menu |
169
+ | `POST /api/v1/tools/help` | State-aware help skill | FE → Go → Python |
170
+ | `POST /api/v1/tools/report` (+ `GET /records` · `…/readiness` · `…/{analysis_id}/{version}` GETs) | Report generate / curate / readiness / fetch | FE → Go (report button) |
171
+ | `GET /api/v1/traceability` | Per-turn provenance (fetched on `done`) | FE Go Python |
172
+ | `GET /api/v1/charts?message_id=` | Per-turn `render_chart` envelopes (fetched on `done`); always 200 with `status: success\|empty\|not_found` added 2026-07-13, reshaped per lead review 2026-07-14 | FE Go → Python |
173
+ | `users` · `room` · `document` · `db_client` · `data_catalog` · v1 `chat` · analysis-CRUD routers | Unwired (files kept in tree, not mounted) | — |
174
 
175
  ---
176
 
 
190
  | `reports` *(dedorch)* | uuid, `analysis_id`, `user_id`, `title` + markdown `content` + `version` (UNIQUE per analysis) | Go + Python ReportStore | report API |
191
  | `data_sources` *(dedorch, Go-owned)* | per-analysis binding table. **Python no longer reads or writes it** — bindings live in Go's `analyses.data_bind`, which Go materializes into the analysis-scope `data_catalog` row; Python scopes off that row. The table exists (Go migration) but Python is fully decoupled — do **not** drop it manually | Go migration | — (unused by Python) |
192
  | `analyses_messages` *(dedorch)* | the analysis chat room (`role ∈ user\|ai`); replaces deprecated `rooms`/`chat_messages` | Go `/analyses/{id}/messages` | Python chat path **not yet migrated here** (§12) |
193
+ | `message_traceability` *(Python-owned)* | one jsonb `TraceabilityPayload` per assistant turn (PK `message_id`); flushed before `done` | chat pipeline (KM-691) | `GET /api/v1/traceability` |
194
+ | `message_charts` *(Python-owned, added 2026-07-13)* | one row per `render_chart` chart — `spec` jsonb holds the full `dataeyond.chart.v1` envelope; keyed (`analysis_id`, `message_id`), multiple rows per turn allowed; written before `done`, never-throw | slow-path chart persist (`chat_handler._run_slow_path`) | `GET /api/v1/charts` |
195
 
196
  > ✅ **Python ORM ↔ dedorch drift — reconciled 2026-07-01.** `AnalysisStateRow` (`analyses`) dropped
197
  > `problem_statement`/`problem_validated` and added `objective`/`business_questions` (Harry's #3);
 
248
  - **TaskRunner** (`slow_path/task_runner.py`) — deterministic, 0 LLM. Wave-based execution,
249
  `${t<id>}` placeholder resolution (Pattern A), never-throw invocation, **degrade-and-continue**
250
  (failed task → dependents skipped, independent branches run). No replanning.
251
+ - **Quality checkpoint (S1a)** (`slow_path/checkpoint.py`, added 2026-07-13) — deterministic,
252
+ 0 LLM, never-throw inspection between runner and assembler. CK1 all-failed → the coordinator
253
+ returns a deterministic honest-failure answer (`refusals.run_failure_message`, EN/ID) with **no**
254
+ assembler call and a non-substantive record; CK2 empty retrieve (+ transitive dependents),
255
+ CK3 10k-cap truncation, CK4 single trend bucket, CK5 all-null column consumed, CK6 chart-spec
256
+ sanity (§4.6 of SPINE_V2_PLAN). Degraded flags render as an "# Execution assessment" block in the
257
+ assembler's human content; every flag logs `repair_candidate` via structlog (the gated-S1b
258
+ evidence base). A clean run renders nothing — zero behavior change.
259
  - **Assembler** (`slow_path/assembler.py`) — 1 LLM call authoring only the narrative; code copies
260
  the structured `results_snapshot` / `tasks_run` from the run state into the `AnalysisRecord`
261
  (the report's source of truth).
262
 
263
  Streaming + persistence: `chat_handler._run_slow_path` bridges per-stage progress to SSE `status`
264
  events, prewarms the DB engine in parallel with planning, emits the answer, then persists the
265
+ record stamped with `user_id` + `analysis_id`, and (2026-07-13) any `kind="chart"` outputs to
266
+ `message_charts` — both never-throw, both before `done`.
267
 
268
  ### Structured query engine — `src/query/`
269
  `QueryService.run` (`query/service.py`): plan → validate → retry(3) → dispatch → execute; **never
 
289
  `retrieve_data` runs a pre-built IR (validate → dispatch → execute, skipping the planner) and
290
  coerces `Decimal`→`float` — the Pattern A handoff the `analyze_*` tools consume. The planner
291
  registry composes a local data-access spec stub (name-checked against `DATA_ACCESS_TOOLS`) with the
292
+ real `analytics_registry()`. **2026-07-13:** `analytics_registry()` also exposes `render_chart`
293
+ (`src/tools/analytics/visualization.py`, category `analytics.visualization`, `output_kind="chart"`
294
+ — `ToolOutput.kind` gained `"chart"`): a pure spec builder mapping a table to a Plotly-JSON
295
+ envelope (bar/line/pie/scatter, fixed house style preset, **no plotly import**); the planner
296
+ validator's Check 10 forces its `data` to reference a table-producing task.
297
 
298
  ### Report — `src/agents/report/`
299
  `generator.py` reads records, deterministically assembles structured fields, 1 LLM call for the
300
  executive summary; `store.py` versions under an advisory lock and persists markdown to dedorch
301
+ `reports`; `readiness.py` defines the **report floor** (≥1 successful `analyze_*` **or**, since
302
+ 2026-07-14, `render_chart` a chart-only session is substantive; the `problem_validated`
303
+ precondition was dropped 2026-06-25) shared by the report API and the Help readiness signal so the
304
+ two can't disagree. **2026-07-14:** the report embeds charts — `_collect_charts` copies
305
+ `dataeyond.chart.v1` envelopes verbatim (INV-4) from `results_snapshot` into
306
+ `AnalysisReport.charts`, rendered as ` ```plotly ` fenced blocks in the `## EDA` section
307
+ (fence content = the **full v1 envelope**, pretty-printed — the shape the FE's fence hook parses,
308
+ verified 2026-07-14).
309
 
310
  ### Observability — Langfuse
311
  The endpoint's `ChatHandler` runs with `enable_tracing=True`. One trace per request groups
 
360
  `rooms`/`chat_messages`/`interview_*` tables to `zdeprecated_*`.
361
  - **`report_inputs`** (the slow-path structured output, formerly `analysis_records`) stays
362
  **Python-owned**; its finalized schema goes to Harry so the dedorch migration creates it post-cutover.
363
+ Same pattern for **`message_traceability`** (created manually 2026-07-06) and **`message_charts`**
364
+ (created manually 2026-07-13, DDL in `SPINE_V2_PLAN.md` §4.4; live e2e verified same day —
365
+ Harry's migration handoff for both is still the open item).
366
  - **Connection-string cutover DONE (2026-07-01).** Python's `postgres_connstring` now points at
367
  **dedorch** and reads the Go-migrated tables directly. Every ORM model Python reads (`analyses`,
368
  `analyses_messages`, `data_catalog`) has been reconciled to its dedorch shape.
 
400
  `cryptography.fernet.InvalidToken` — whose `str()` is **empty**, so it logged as `error=""` and
401
  masqueraded as a DB-connection failure (the executor now logs `repr(e)` to expose it). Tell-apart:
402
  a valid-but-wrong key → `InvalidToken`; a malformed key → a non-empty `ValueError` at cipher build.
403
+ - **Storage-provider parity with Go (gotcha, found 2026-07-13).** Go's data plane uploads tabular
404
+ parquet to **Supabase S3** and writes `location_ref: object_storage://…`; Python's
405
+ `TabularExecutor` picks its download backend from `settings.storage_provider`
406
+ (`azure_blob` | `supabase_s3`, blank → Azure legacy). If the `.env` still says `azure_blob`,
407
+ **every tabular `retrieve_data` fails with an Azure `BlobNotFound`** — and the never-throw path
408
+ degrades it into an honest "data not available" answer, so it masquerades as a data problem.
409
+ Tell-apart: `BlobNotFound` + `location_ref` starting `object_storage://` ⇒ env gap; set
410
+ `storage_provider=supabase_s3` + the five `supabase_s3_*` values (match Go's data plane).
411
  - **Never-throw seams** are pervasive (tool invoker, query service, executors, state/catalog reads,
412
  record persistence, report summary). Failures degrade into soft output rather than raising — good
413
  for UX, but they can mask real breakage (e.g. a missing analysis-scope catalog silently falling
SPINE_V2_PLAN.md ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPINE_V2_PLAN.md — Analysis Spine v2 + Visualization Tool
2
+
3
+ **Status:** ✅ **APPROVED 2026-07-13** by Rifqi, with Sofia's sign-off (covers implementing the tool-layer
4
+ slice — `ToolOutput.kind` + `render_chart` — in this repo). **Delta at approval:** W3 (activate deferred
5
+ `analyze_*` tools) is **deferred to a later cycle — do not start**. W4 stays gated, W5 stays future-only.
6
+ FE reminder (plotly rendering) owned by Rifqi, timing TBD.
7
+ **Delta 2026-07-13 (pr/16, same-day build):** **W2 and W1 are DONE** — see DEV_PLAN §0.6 (V1–V7)
8
+ for the task rows and evidence. Rifqi ran the §4.4 DDL against dedorch and the **live e2e gate
9
+ passed in full** (real `POST /api/v2/chat/stream`: viz turn → `message_charts` row keyed by the
10
+ `done` message_id → `GET /charts` 200 with a valid v1 envelope; chartless turn → 200 empty;
11
+ injected `render_chart` failure → answer still streams with the data as a table, no row). W1 ✅
12
+ (13 local tests, one per CK rule). Planner viz behavior verified in-process EN+ID incl. the
13
+ viz-infeasible case (Example K was added when the first smoke force-mapped a stand-in column).
14
+ Two env finds along the way: `eval.chat_sim` was missing from disk+git (restored by Rifqi — its
15
+ hard-coded user/source ids are still stale) and the local `.env` lagged Go's Supabase-S3 data
16
+ plane (`storage_provider` — fixed by Rifqi; REPO_STATUS §13 gotcha).
17
+ **Owner:** Rifqi. Contributors named per workstream (Sofia = tool layer, Harry = Go/dedorch/contract, mentor = FE).
18
+ **Companions:** [REPO_STATUS.md](REPO_STATUS.md) (built state) · [DEV_PLAN.md](DEV_PLAN.md) (sprint tracker) ·
19
+ [API_CONTRACT_BE_PYTHON.md](API_CONTRACT_BE_PYTHON.md) (live contract). This file is the design + handoff
20
+ source for the Spine-v2 work; when a workstream lands, DEV_PLAN gets the task rows and this doc gets dated deltas.
21
+
22
+ Status legend (house): ⬜ not started · 🔄 in progress · ✅ done · ⛔ blocked · 🔎 verify · ⏸️ deferred.
23
+
24
+ ---
25
+
26
+ ## 0. Executive summary
27
+
28
+ We evolve the slow path from a **one-shot pipeline** (plan → run → narrate) into a **staged workflow with one
29
+ bounded self-correction point**, and we grow the tool registry into **four families on one spine**:
30
+ `check_*`/`retrieve_*` (built) · `analyze_*` (built) · `render_*` (this plan) · `model_*` (future).
31
+
32
+ Inspiration is CoDA (Google, ICLR 2026) and the Julius.ai comparison, with one governing rule: **CoDA's phases,
33
+ not CoDA's agents.** CoDA pays ~15 LLM calls per output because every phase (viz mapping, design, codegen,
34
+ debugging, visual evaluation) is its own LLM agent operating on generated code. We keep our declarative engine —
35
+ the same phases exist here, but only planning is an LLM decision; mapping lives in the planner's existing single
36
+ call, styling is a fixed preset, "generation" is a deterministic spec builder, and evaluation is deterministic
37
+ code. Happy path stays **3 LLM calls per turn** (router + planner + assembler); the only growth is an
38
+ **evidence-gated repair pass (max +1)** that is NOT built until telemetry justifies it.
39
+
40
+ What ships, in order:
41
+
42
+ - **S1a — Quality checkpoint** (0 LLM): after execution, before composition, deterministic code inspects the run
43
+ (failed branches, empty/degenerate results, later chart-spec sanity). Verdicts: proceed · honest degrade ·
44
+ log-a-repair-candidate. Converts today's silent degrade-and-continue into *explained* degradation and produces
45
+ the data for the S1b decision. No invariant touched.
46
+ - **S2 — Visualization tool** (`render_chart`): planner-selected, Pattern A, emits **Plotly JSON** (locked
47
+ decision, DEV_PLAN §0 deferred row #26 — no matplotlib PNGs, no new dependency). Delivery mirrors traceability:
48
+ Python-owned `message_charts` store + `GET /api/v1/charts`, SSE stays text-only. Includes formalizing planner
49
+ **path recipes** (the few-shots become named workflows; viz = any recipe + a `render_chart` tail).
50
+ - **S1b — Targeted repair (GATED)**: one bounded re-plan of only the failed subgraph, triggered by the
51
+ checkpoint. Relaxes INV-6 ("no mid-run LLM") → requires team sign-off *and* S1a telemetry showing it pays.
52
+ - **S3 — `model_*` family (FUTURE, sketch only)**: regression/forecast/segmentation as fixed, audited compute
53
+ functions on the same spine. Named here so the architecture anticipates it; no build this cycle.
54
+
55
+ What is deliberately **not changing**: no generated code ever executes; INV-4 (LLM never authors numbers); the
56
+ five query-defense layers; never-throw seams; text-only SSE (`sources` stays `[]`); records-based versioned
57
+ reports; Go's ownership of dedorch DDL and `analyses_messages`.
58
+
59
+ ---
60
+
61
+ ## 1. Target architecture
62
+
63
+ ```
64
+ POST /api/v2/chat/stream (structured_flow turn)
65
+
66
+ ├── Input guard ─► Router (1 LLM) ─► analysis-scoped catalog read (metadata only, PII-safe)
67
+
68
+ PLAN Planner (1 LLM) — classifies the question into a RECIPE, instantiates a staged Task DAG
69
+ │ ▲ └ validator: 8 existing checks + path-shape checks, re-prompt ≤3 (all PRE-run)
70
+ │ │
71
+ │ └────────────── (S1b, GATED: targeted repair, ≤1 pass, failed subgraph only) ──┐
72
+ ▼ │
73
+ EXECUTE TaskRunner (0 LLM) — waves, ${t<id>} handoffs, degrade-and-continue │
74
+ │ tool families (fixed registry, all never-throw): │
75
+ │ check_* / retrieve_* · analyze_* · render_* (S2) · model_* (S3) │
76
+ ▼ │
77
+ CHECK Quality checkpoint (S1a, 0 LLM, never-throw) │
78
+ │ ok → continue · repairable → repair request ────────────────────────────┘
79
+ │ unfixable → honest degrade (specific, not "couldn't compute")
80
+
81
+ COMPOSE Assembler (1 LLM — narrative only, INV-4; structured fields copied by code)
82
+
83
+ DELIVER SSE: sources[] → status* → chunk → done{message_id}
84
+ writes: report_inputs · message_charts (S2) · message_traceability
85
+ FE follow-ups on done: GET /api/v1/traceability · GET /api/v1/charts (S2)
86
+ later: POST /api/v1/tools/report (records-based, unchanged)
87
+ ```
88
+
89
+ **Recipes (the "predetermined workflow" made explicit).** The planner few-shots already are path templates;
90
+ S2 names them and the validator enforces their shape:
91
+
92
+ | Recipe | Chain | Exists today as |
93
+ |---|---|---|
94
+ | R1 descriptive | `retrieve_data → analyze_descriptive` | few-shot A/B |
95
+ | R2 aggregate / top-N | single grouped-IR `retrieve_data` (± `analyze_aggregate`) | Example G |
96
+ | R3 trend | `retrieve_data → analyze_trend` | few-shot |
97
+ | R4 correlation | `retrieve_data → analyze_correlation` | few-shot |
98
+ | R5 two-metric merge | `retrieve_data ×2 → analyze_merge → …` | Example I |
99
+ | R6 infeasible | no tasks + `infeasible_reason` → deterministic refusal | Example H |
100
+ | viz tail (S2) | any of R1–R5 **+ `render_chart`** when the user explicitly asks to plot/visualize | NEW — Example J |
101
+
102
+ CoDA-role mapping (why no new LLM agents): VizMapping → planner args in the existing call · Design Explorer →
103
+ fixed style preset inside `render_chart` · Code Generator/Debug → deterministic spec builder (nothing to debug) ·
104
+ Visual Evaluator → checkpoint spec checks.
105
+
106
+ **LLM budget:** happy path 3 calls (unchanged) · S1b failure path max 4 · report unchanged at 1.
107
+
108
+ ---
109
+
110
+ ## 2. Workstreams & sequencing
111
+
112
+ | # | Workstream | Owner | Status | Gate |
113
+ |---|---|---|---|---|
114
+ | W1 | S1a quality checkpoint (+ telemetry for the S1b decision) | Rifqi | ✅ 2026-07-13 | none — code-only, 0 LLM |
115
+ | W2 | S2 `render_chart` tool + chart store + `GET /charts` + planner slice (recipes + Example J) | Rifqi + Sofia | ✅ 2026-07-13 (DDL run + live e2e ALL PASS) | Harry: migration handoff + `done.chart_count` still open · FE: Plotly render (Rifqi to remind) |
116
+ | W3 | Activate the 4 deferred `analyze_*` tools (comparison, contribution, profile, segment) | Sofia + Rifqi | ⏸️ | **Deferred at approval (2026-07-13) — do not start until Rifqi re-opens** |
117
+ | W4 | S1b targeted repair pass | Rifqi | ⏸️ | **team sign-off on INV-6 relaxation + S1a telemetry evidence** |
118
+ | W5 | S3 `model_*` family | — | ⏸️ | future design review; sketch in §7 |
119
+
120
+ Sequencing: **W2 leads** (visible product value), **W1 lands alongside or immediately after** (invisible to
121
+ users, unblocks nothing, improves everything). W3 deferred at approval. W4 only with evidence. Parallel
122
+ dispatch is safe: W1 and W2 are file-disjoint except `coordinator.py` (W1) — coordinate that one file.
123
+
124
+ ---
125
+
126
+ ## 3. W1 — S1a Quality checkpoint
127
+
128
+ **What it is:** a deterministic, never-throw inspection of `RunState` between `TaskRunner.run` and
129
+ `Assembler.assemble`. No LLM. No user-visible new surface — only better inputs to the Assembler and structured
130
+ logs.
131
+
132
+ **Files:**
133
+ - NEW `src/agents/slow_path/checkpoint.py` — `assess(run_state, task_list) -> RunAssessment`.
134
+ - `src/agents/slow_path/coordinator.py` — call site between runner and assembler; pass the assessment into
135
+ `assemble(...)`.
136
+ - `src/agents/slow_path/prompt.py` — render the assessment as a short "execution assessment" block in the
137
+ assembler's human content so the narrative names *what specifically* failed/degraded instead of generic
138
+ caveats.
139
+ - `src/agents/slow_path/schemas.py` — `RunAssessment` model (pydantic): per-task verdicts + overall verdict
140
+ (`ok | degraded | failed`) + `repair_candidates: list[RepairCandidate{task_id, reason}]`.
141
+
142
+ **v1 checks (all deterministic):**
143
+ - CK1 all tasks failed → overall `failed`; coordinator returns a deterministic honest-failure answer (mirrors
144
+ the existing infeasible path shape; record stays non-substantive so it can't hit the report floor).
145
+ - CK2 `retrieve_data` returned 0 rows and a dependent consumed it → flag task + downstream.
146
+ - CK3 table output truncated at the 10k cap → flag (answer must say "based on the first 10,000 rows").
147
+ - CK4 `analyze_trend` produced a single bucket → flag (the pr/13 1970-bucket class).
148
+ - CK5 all-null column consumed by an `analyze_*` task → flag.
149
+ - CK6 (lands with W2) chart-spec sanity — see §4.6.
150
+ - Every flag logs `repair_candidate` + reason via structlog → the S1b evidence base.
151
+
152
+ **Explicit non-goals in S1a:** no re-planning, no LLM, no change to degrade-and-continue semantics, no change to
153
+ never-throw seams. INV-6 untouched.
154
+
155
+ ---
156
+
157
+ ## 4. W2 — S2 Visualization tool (`render_chart`)
158
+
159
+ ### 4.1 Tool (Sofia's slice — tool layer is tool-team-owned)
160
+ - NEW `src/tools/analytics/visualization.py` — `render_chart(df, chart_type, x, y, series=None, title=None)`.
161
+ Pure, deterministic, **no LLM, no new dependency**: builds a Plotly-conformant dict by hand (Plotly JSON is a
162
+ documented schema; the FE renders with plotly.js — Python never imports plotly). v1 chart types: `bar`,
163
+ `line`, `pie`, `scatter`. `pie` maps `x`→labels, `y`→values. House style preset (colors, axis config) is a
164
+ module constant — style is not a HAL decision.
165
+ - `src/tools/contracts.py` — `ToolOutput.kind` Literal gains `"chart"` (one line; tool-team-owned file).
166
+ - `src/tools/registry.py` — `ToolSpec(name="render_chart", category="analytics.visualization",
167
+ input_schema={"required": ["data", "chart_type", "x", "y"], "properties": {…, "series", "title"}},
168
+ output_kind="chart", description=visualization.DESCRIPTION)` in `ACTIVE_ANALYTICS_TOOLS`.
169
+ - `src/tools/invoker.py` — `_DISPATCH["render_chart"] = (visualization.render_chart, "chart")`. Never-throw
170
+ comes free; a failed chart degrades the task, the answer still assembles.
171
+
172
+ ### 4.2 Chart-spec envelope (v1, FE contract)
173
+ ```json
174
+ {
175
+ "schema": "dataeyond.chart.v1",
176
+ "chart_type": "bar",
177
+ "title": "Revenue by region",
178
+ "plotly": {
179
+ "data": [{ "type": "bar", "x": ["A", "B"], "y": [1, 2], "name": "revenue" }],
180
+ "layout": { "title": {"text": "Revenue by region"}, "xaxis": {"title": {"text": "region"}},
181
+ "yaxis": {"title": {"text": "revenue"}} }
182
+ }
183
+ }
184
+ ```
185
+ FE renders with `Plotly.newPlot(el, spec.plotly.data, spec.plotly.layout)`. The envelope (not raw plotly) is
186
+ what `ToolOutput.value` carries and what the store persists.
187
+
188
+ ### 4.3 Planner slice (Rifqi)
189
+ - `src/agents/planner/examples.py` — **Example J**: "plot revenue by region" → R2 retrieve → `render_chart`
190
+ with `data="${t1}"`.
191
+ - `src/config/prompts/planner.md` — the recipe table (§1) + rule: chart **only when the user explicitly asks**
192
+ (plot/visualize/chart/grafik/buatkan diagram); never speculative; `render_chart` is always a tail, fed by a
193
+ table-kind upstream.
194
+ - `src/agents/planner/validator.py` — path-shape check: `render_chart.data` must reference a task whose tool
195
+ yields `table` output.
196
+ - `src/config/prompts/intent_router.md` — only if EN/ID viz phrasings misroute in the smoke; keep few-shots ↔
197
+ eval dataset mirrored if touched.
198
+ - `src/agents/slow_path/prompt.py` — assembler renderer gets a `kind == "chart"` branch: one-line summary
199
+ (type, title, point count). Without this the full spec (x/y arrays) floods the assembler prompt
200
+ ([prompt.py:52](src/agents/slow_path/prompt.py:52) currently dumps `output.value` verbatim for non-table kinds).
201
+ - `src/agents/report/generator.py` — no change needed: `_collect_evidence` copies table-kind only; charts cannot
202
+ corrupt the markdown report. Report embedding of charts stays **deferred** (standing decision).
203
+
204
+ ### 4.4 Chart store (Rifqi) — mirrors `message_traceability`
205
+ - NEW `src/charts/store.py` — `ChartStore` Protocol + `PostgresChartStore` (never-throw `save`, `list` read) +
206
+ `NullChartStore`. ORM row in `src/db/postgres/models.py`.
207
+ - Write site: `chat_handler._run_slow_path`, after the record persist — scan `record.results_snapshot` outputs
208
+ for `kind == "chart"`, save each stamped with `pad.message_id`, `analysis_id`, `user_id`, `record_id`.
209
+ Never-throw; a chart-persist failure must not break the answer.
210
+ - **Zero DDL from Python** (§2.2). DDL below is run manually against dedorch now and handed to Harry for the
211
+ migration:
212
+ ```sql
213
+ CREATE TABLE IF NOT EXISTS message_charts (
214
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
215
+ message_id text NOT NULL,
216
+ analysis_id uuid NOT NULL REFERENCES analyses(id),
217
+ user_id text NOT NULL,
218
+ record_id text,
219
+ chart_type text NOT NULL,
220
+ title text,
221
+ spec jsonb NOT NULL, -- the dataeyond.chart.v1 envelope (§4.2)
222
+ created_at timestamptz NOT NULL DEFAULT now()
223
+ );
224
+ CREATE INDEX IF NOT EXISTS idx_message_charts_lookup
225
+ ON message_charts (analysis_id, message_id);
226
+ ```
227
+
228
+ ### 4.5 API surface + contract delta (Rifqi → Harry)
229
+ - NEW `GET /api/v1/charts?analysis_id=&message_id=` (`src/api/v1/charts.py`, mounted in `main.py`) →
230
+ `{"count": n, "charts": [{chart_id, chart_type, title, spec, created_at}]}`; empty list is a valid 200 (FE:
231
+ no charts this turn). Same fetch-on-`done` pattern as traceability; the row is written before `done`, no
232
+ polling race.
233
+ - `done` event: **proposed additive field** `chart_count` (or `chart_ids`) so the FE can skip the GET on
234
+ chartless turns. Harry's call; fallback = FE fetches unconditionally. SSE order and all existing fields
235
+ unchanged; `sources` stays `[]`.
236
+ - `API_CONTRACT_BE_PYTHON.md` updated in the same change (new §charts + `done` note). FE ask (via Rifqi):
237
+ render `spec.plotly` with plotly.js under the assistant message; "chart iteration" v1 = a follow-up chat turn
238
+ (the planner re-emits `render_chart` with patched args — no new endpoint).
239
+
240
+ ### 4.6 Checkpoint spec checks (CK6, with W1)
241
+ Empty series · `len(x) != len(y)` · bar/pie with > 30 / > 8 categories → flag (S1a: honest note; S1b later:
242
+ re-pick args or fall back to table) · non-numeric y for bar/line/scatter.
243
+
244
+ ### 4.7 PII / tracing notes
245
+ Chart specs contain the same result rows the user already receives in tables — no new exposure class. Langfuse:
246
+ tool spans stay metadata-only; the traceability scratchpad records the `render_chart` call like any tool (verify
247
+ the `TraceabilityToolInvoker` summary handles `kind="chart"` sanely — row_count is absent; use point count).
248
+
249
+ ---
250
+
251
+ ## 5. W3 — Activate deferred analytics tools (⏸️ DEFERRED 2026-07-13 — kept for the later cycle)
252
+
253
+ Move `analyze_comparison`, `analyze_contribution`, `analyze_profile`, `analyze_segment` from
254
+ `DEFERRED_ANALYTICS_TOOLS` to `ACTIVE_ANALYTICS_TOOLS` (`src/tools/registry.py` — compute fns and invoker
255
+ mappings already exist). Per the registry's own note: each re-activated tool needs planner few-shot coverage
256
+ (`examples.py`) — that is the real work. Recipes gain R7 comparison / R8 contribution / R9 segment rows.
257
+ Prompt change → full §7B eval gauntlet. Closes the breadth gap vs Julius at near-zero engineering cost.
258
+
259
+ ---
260
+
261
+ ## 6. W4 — S1b targeted repair (GATED — do not build yet)
262
+
263
+ Trigger: checkpoint returns repair candidates AND the team has approved the INV-6 relaxation AND S1a telemetry
264
+ shows a meaningful hit-rate (proposal: revisit after 2 weeks of S1a logs). Design (for the future PR):
265
+ `PlannerService.repair(task_list, run_state, reasons)` → patched subgraph for the failed task ids only; runner
266
+ re-executes only those tasks + skipped dependents; **max 1 pass per turn**; repair prompt gets the failure
267
+ evidence (error strings, row counts). Budget: +1 LLM call, failure paths only. If rejected: S1a's honest-degrade
268
+ stands alone and this section moves to an ADR graveyard note — the checkpoint is still worth it.
269
+
270
+ ---
271
+
272
+ ## 7. W5 — S3 `model_*` family (future sketch, no build)
273
+
274
+ Same spine, fourth family: fixed, audited compute functions (e.g. `model_regression`, `model_forecast`,
275
+ `model_cluster` — sklearn/statsmodels behind ToolSpecs; **dependency additions need the §6.4 ask**). Recipe:
276
+ `retrieve → prepare → model_* → evaluate` — CRISP-DM's modeling/evaluation stages become real. Outputs are
277
+ `stats`/`table`/`series` + optional `render_chart` tails. Reports gain model sections then, not before.
278
+ Explicitly NOT codegen — if open-ended modeling demand materializes, a sandboxed codegen tool is a separate
279
+ §6.3 guardrail conversation.
280
+
281
+ ---
282
+
283
+ ## 8. Verification gates (per workstream — house §7)
284
+
285
+ - Every W: `ruff check src/` clean · full local pytest with **exact counts** vs previous ·
286
+ `PYTHONPATH=. ./.venv/Scripts/python.exe -c "import main"` exits 0 · no never-throw seam pierced.
287
+ - W1: unit tests for every CK rule (fixture RunStates); in-process slow-path run showing the assessment block in
288
+ the assembler input; no behavior change on a clean run.
289
+ - W2 prompts: `eval.chat_sim` smoke ≥ last committed score (name both files); `eval.intent` only if router
290
+ prompt touched; new timestamped results committed, never overwriting.
291
+ - W2 e2e: live in-process `structured_flow` turn with an explicit viz ask → chart row in `message_charts` →
292
+ `GET /charts` 200 with a valid v1 envelope; a chartless turn → 200 empty list; a failing `render_chart` →
293
+ answer still streams, no chart row.
294
+ - W3: eval smokes for each new few-shot; EN+ID phrasing per eval conventions (§7F).
295
+ - Contract changes: `API_CONTRACT_BE_PYTHON.md` same-change; REPO_STATUS/DEV_PLAN deltas dated (§7E).
296
+
297
+ ## 9. Decisions log & open questions
298
+
299
+ Locked (approved 2026-07-13): Plotly JSON not PNG (re-affirms DEV_PLAN #26) · no per-phase LLM agents ·
300
+ S1a before/with S2, S1b evidence-gated · ~~charts chat-only for now (report embedding deferred)~~
301
+ RESOLVED 2026-07-14: the FE fenced-block hook exists (lead verified), so report embedding LANDED exactly
302
+ as sketched — ` ```plotly ` fences in `rendered_markdown`'s EDA section, emitted by the generator from
303
+ `results_snapshot` chart outputs, INV-4 copy-verbatim; `has_successful_analysis` now also counts a
304
+ successful `render_chart`, so a chart-only session satisfies the report floor ·
305
+ `render_chart` only on explicit user ask · ~~ToolOutput.kind + render_chart ownership split~~ RESOLVED:
306
+ Sofia signed off, implemented in-repo by the dev session · **W3 deferred to a later cycle**.
307
+
308
+ Open → owner: `done.chart_count` additive field yes/no → Harry · FE chart placement (under message) +
309
+ plotly.js availability + report fenced-block hook → mentor/FE (Rifqi to remind) · INV-6 relaxation for W4 →
310
+ team, after S1a telemetry · W3 re-open timing → Rifqi.
311
+
312
+ ## 10. Doc-sync checklist on landing (per workstream)
313
+
314
+ REPO_STATUS §6/§9 (tool count, new checkpoint stage, charts surface) · DEV_PLAN new task rows under a dated
315
+ sprint section · API_CONTRACT §charts + `done` delta · this file: status flips + dated delta banner.
eval/chat_sim/README.md ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Chat simulator (end-to-end, in-process)
2
+
3
+ Drives the **real** `ChatHandler.handle()` in-process to simulate a user chatting in
4
+ one analysis session — from creation through a report — and prints **what each step
5
+ does**: router decision, slow-path status, every tool call, every LLM call (output +
6
+ tokens + latency), the streamed answer, and a final report built from the run's
7
+ `report_inputs`.
8
+
9
+ In-process (not via the HTTP server) on purpose: the SSE endpoint hides the internal
10
+ LLM outputs (router/planner/assembler), so a script that consumes `/chat/stream` can't
11
+ show them. This hooks the same tracer seam the handler threads its Langfuse callbacks
12
+ + tool spans through (`ScriptTracer.active=True`) — **no source changes**.
13
+
14
+ ## Run
15
+
16
+ Module mode (`-m`) so `src` imports resolve; needs a populated `.env` (Azure OpenAI +
17
+ Postgres + Azure Blob for the Titanic Parquet). `ENABLE_SLOW_PATH` is forced on here.
18
+
19
+ ```bash
20
+ uv run python -m eval.chat_sim.run_chat # scripted Titanic convo + report
21
+ uv run python -m eval.chat_sim.run_chat --interactive # type your own messages; 'report' / 'exit'
22
+ uv run python -m eval.chat_sim.run_chat --max-turns 1 --no-report # cheap smoke test
23
+ uv run python -m eval.chat_sim.run_chat --no-bind # planner sees the whole catalog (not just Titanic)
24
+ uv run python -m eval.chat_sim.run_chat --plain # no ANSI colors
25
+ ```
26
+
27
+ (Or `./.venv/Scripts/python.exe -m eval.chat_sim.run_chat ...` on Windows.)
28
+
29
+ ## What it does each run
30
+
31
+ 1. **Creates a fresh analysis** (`AnalysisStateStore.create`) with an objective, and
32
+ **scopes it to the Titanic source** by seeding an analysis-scope `data_catalog` row
33
+ (the user catalog restricted to Titanic) so `structured_flow` is scoped to one
34
+ source — same as `/analysis/create` (in production Go materializes this row from
35
+ `analyses.data_bind`).
36
+ 2. **Runs each turn** through `handle()` and prints the router decision, slow-path
37
+ status pings, the tool table (kind / rows / latency / error), the LLM table
38
+ (in/out/total tokens + ms + a prompt/output snippet), and the answer + sources.
39
+ 3. **Generates a report** (mirrors `POST /report`: floor check → `ReportGenerator` →
40
+ `ReportStore`) from the `report_inputs` the structured turns persisted.
41
+
42
+ ## Notes
43
+
44
+ - **Default user** is `4b5d1bac-…` whose playground catalog has the Titanic CSV
45
+ (tabular) + a "dummy" Postgres (schema). Override with `--user-id`.
46
+ - **Writes to the DB the `.env` points at** — a fresh `analyses` row + `report_inputs`
47
+ + a `reports` row per run. Point `.env` at the playground DB. Each run is a new
48
+ `analysis_id` (printed at the end) so runs don't collide.
49
+ - **"output: <no text content — structured / tool-call output>"** is expected for the
50
+ router, planner, and assembler — they use structured/function-call output, so the
51
+ LLM message has no plain text. Their result shows up in the ROUTER line, the plan,
52
+ and the streamed ANSWER respectively. The chatbot/help calls stream text, so their
53
+ output is shown (annotated `masked→cloud` where the cloud trace would redact it).
54
+ - The first scripted question may route to `chat` rather than `help` — that's the live
55
+ router's real call, shown transparently.
eval/chat_sim/__init__.py ADDED
File without changes
eval/chat_sim/run_chat.py ADDED
@@ -0,0 +1,528 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """End-to-end chat simulator with full step transparency (in-process).
2
+
3
+ Simulates a user chatting inside ONE analysis session, from creation onward, and
4
+ prints what each step of the pipeline does:
5
+
6
+ - the ROUTER decision (intent / rewritten query / confidence)
7
+ - slow-path STATUS pings (Planning… / Running N steps…)
8
+ - every TOOL call the slow path makes (check_data / retrieve_data / analyze_* —
9
+ with result kind, row count, latency, error)
10
+ - every LLM call (router / planner / assembler / chatbot / help) with
11
+ input-token / output-token / latency, and a snippet of the raw model output
12
+ - the streamed ANSWER + its SOURCES
13
+ - a per-turn timing + token summary
14
+ - finally, a REPORT generated from the slow-path report_inputs the run produced
15
+
16
+ It calls `ChatHandler.handle()` IN-PROCESS (no server) so it can see the internal
17
+ LLM outputs the SSE endpoint hides. Transparency is captured by injecting a custom
18
+ tracer (`ScriptTracer`) into the exact seam the handler already threads its Langfuse
19
+ callbacks + tool spans through — no source changes.
20
+
21
+ Run as a module from the repo root (so `src` imports resolve):
22
+
23
+ uv run python -m eval.chat_sim.run_chat # predefined Titanic convo + report
24
+ uv run python -m eval.chat_sim.run_chat --interactive # you type the messages
25
+ uv run python -m eval.chat_sim.run_chat --no-report # skip the report capstone
26
+ uv run python -m eval.chat_sim.run_chat --no-bind # don't scope to Titanic (whole catalog)
27
+
28
+ Needs a populated `.env` (Azure OpenAI + Postgres + Azure Blob for the Titanic
29
+ Parquet). Writes to the DB the `.env` points at (analysis state + report_inputs +
30
+ report) — point it at the playground DB. ENABLE_SLOW_PATH is forced on here.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import asyncio
37
+ import json
38
+ import sys
39
+ import time
40
+ import uuid
41
+ from dataclasses import dataclass, field
42
+ from typing import Any
43
+
44
+ # --- Windows: psycopg3 async needs the selector loop (mirrors run.py). Set BEFORE
45
+ # anything touches asyncio.
46
+ if sys.platform == "win32":
47
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
48
+
49
+ # Windows consoles default to cp1252 and choke on the box-drawing glyphs below.
50
+ for _stream in (sys.stdout, sys.stderr):
51
+ try:
52
+ _stream.reconfigure(encoding="utf-8") # type: ignore[union-attr]
53
+ except Exception:
54
+ pass
55
+
56
+ from langchain_core.callbacks import BaseCallbackHandler # noqa: E402
57
+ from langchain_core.messages import AIMessage, BaseMessage, HumanMessage # noqa: E402
58
+
59
+ from src.agents.chat_handler import ChatHandler # noqa: E402
60
+
61
+ # This user's catalog (verified in the playground DB):
62
+ # tabular source 9b565bc8-… = Titanic-Dataset.csv (891 rows)
63
+ # schema source aaa0a4c6-… = "dummy" postgres (orders/customers/products/…)
64
+ DEFAULT_USER_ID = "4b5d1bac-7211-490f-9a3d-66fed0168d5a"
65
+ TITANIC_SOURCE_ID = "9b565bc8-ccc4-4d10-9382-0bad416a091b"
66
+ TITANIC_NAME = "Titanic-Dataset.csv"
67
+
68
+ OBJECTIVE = "Understand what drove passenger survival on the Titanic — by sex, class, and fare."
69
+
70
+ # Default scripted conversation. Chosen to exercise every router intent against the
71
+ # real Titanic columns (Survived, Sex, Pclass, Age, Fare, Embarked).
72
+ DEFAULT_TURNS = [
73
+ "What can you help me do in this analysis?", # -> help
74
+ "What data do I have available here?", # -> check
75
+ "What was the overall passenger survival rate, and how did it differ "
76
+ "between male and female passengers?", # -> structured_flow
77
+ "Did higher passenger class (Pclass) come with a higher average fare and "
78
+ "a higher survival rate?", # -> structured_flow
79
+ ]
80
+
81
+ # ANSI (Windows Terminal / VS Code support it). Disable with --plain.
82
+ _C = {
83
+ "h": "\033[1;36m", "u": "\033[1;33m", "ai": "\033[1;32m",
84
+ "dim": "\033[2m", "warn": "\033[1;31m", "r": "\033[0m",
85
+ }
86
+
87
+
88
+ def c(key: str, text: str) -> str:
89
+ return f"{_C.get(key, '')}{text}{_C['r']}" if _C.get("_on", True) else text
90
+
91
+
92
+ # ───────────────────────── transparency capture ──────────────────────────────
93
+
94
+
95
+ @dataclass
96
+ class LlmCall:
97
+ idx: int
98
+ ms: int | None
99
+ tin: int
100
+ tout: int
101
+ ttot: int
102
+ prompt_preview: str
103
+ output_preview: str
104
+ masked: bool
105
+
106
+
107
+ @dataclass
108
+ class ToolCall:
109
+ tool: str
110
+ arg_keys: list[str]
111
+ kind: str | None
112
+ rows: int | None
113
+ error: str | None
114
+ ms: int
115
+
116
+
117
+ @dataclass
118
+ class Sink:
119
+ """Per-turn collector shared by all StepLoggers + spans of that turn."""
120
+ llm: list[LlmCall] = field(default_factory=list)
121
+ tools: list[ToolCall] = field(default_factory=list)
122
+
123
+
124
+ def _usage(response: Any) -> tuple[int, int, int]:
125
+ """Sum token usage off an LLMResult (usage_metadata, legacy fallback)."""
126
+ tin = tout = ttot = 0
127
+ for gens in getattr(response, "generations", []) or []:
128
+ for g in gens:
129
+ msg = getattr(g, "message", None)
130
+ um = getattr(msg, "usage_metadata", None) if msg else None
131
+ if um:
132
+ tin += um.get("input_tokens", 0)
133
+ tout += um.get("output_tokens", 0)
134
+ ttot += um.get("total_tokens", 0)
135
+ if ttot == 0 and getattr(response, "llm_output", None):
136
+ u = response.llm_output.get("token_usage") or {}
137
+ tin += u.get("prompt_tokens", 0)
138
+ tout += u.get("completion_tokens", 0)
139
+ ttot += u.get("total_tokens", 0)
140
+ return tin, tout, ttot
141
+
142
+
143
+ def _out_text(response: Any) -> str:
144
+ try:
145
+ gens = response.generations
146
+ g = gens[0][0]
147
+ msg = getattr(g, "message", None)
148
+ return (getattr(msg, "content", None) or getattr(g, "text", "") or "").strip()
149
+ except Exception:
150
+ return ""
151
+
152
+
153
+ def _preview(text: str, n: int = 240) -> str:
154
+ text = " ".join(str(text).split())
155
+ return text if len(text) <= n else text[: n - 1] + "…"
156
+
157
+
158
+ class StepLogger(BaseCallbackHandler):
159
+ """One per `tracer.callbacks()` call; all share the turn's Sink.
160
+
161
+ Captures each LLM call's latency + tokens + a snippet of prompt/output. Matches
162
+ start->end by run_id so concurrent/streamed calls don't cross wires.
163
+ """
164
+
165
+ def __init__(self, sink: Sink, masked: bool = False) -> None:
166
+ self.sink = sink
167
+ self.masked = masked
168
+ self._t0: dict[Any, float] = {}
169
+ self._prompt: dict[Any, str] = {}
170
+
171
+ def on_chat_model_start(self, serialized, messages, *, run_id=None, **kw): # type: ignore[override]
172
+ self._t0[run_id] = time.perf_counter()
173
+ try:
174
+ flat = [m for grp in messages for m in grp]
175
+ self._prompt[run_id] = _preview(
176
+ next((getattr(m, "content", "") for m in flat
177
+ if m.__class__.__name__.startswith("System")), ""
178
+ ) or (flat[-1].content if flat else ""), 120
179
+ )
180
+ except Exception:
181
+ self._prompt[run_id] = ""
182
+
183
+ def on_llm_start(self, serialized, prompts, *, run_id=None, **kw): # type: ignore[override]
184
+ self._t0[run_id] = time.perf_counter()
185
+ self._prompt[run_id] = _preview(prompts[0] if prompts else "", 120)
186
+
187
+ def on_llm_end(self, response, *, run_id=None, **kw): # type: ignore[override]
188
+ t0 = self._t0.pop(run_id, None)
189
+ ms = round((time.perf_counter() - t0) * 1000) if t0 else None
190
+ tin, tout, ttot = _usage(response)
191
+ self.sink.llm.append(LlmCall(
192
+ idx=len(self.sink.llm) + 1, ms=ms, tin=tin, tout=tout, ttot=ttot,
193
+ prompt_preview=self._prompt.pop(run_id, ""),
194
+ output_preview=_preview(_out_text(response)),
195
+ masked=self.masked,
196
+ ))
197
+
198
+
199
+ class ScriptSpan:
200
+ """Mirrors tracing._ToolSpan: a metadata-only span around one slow-path tool call."""
201
+
202
+ def __init__(self, sink: Sink, tool: str, args: dict) -> None:
203
+ self.sink = sink
204
+ self.tool = tool
205
+ self.args = args
206
+ self.t0 = time.perf_counter()
207
+
208
+ def end(self, out: Any) -> None:
209
+ kind = getattr(out, "kind", None)
210
+ rows = len(getattr(out, "rows", None) or []) if kind == "table" else None
211
+ err = getattr(out, "error", None)
212
+ self.sink.tools.append(ToolCall(
213
+ tool=self.tool,
214
+ arg_keys=sorted(self.args) if isinstance(self.args, dict) else [],
215
+ kind=kind, rows=rows,
216
+ error=_preview(err, 160) if err else None,
217
+ ms=round((time.perf_counter() - self.t0) * 1000),
218
+ ))
219
+
220
+
221
+ class ScriptTracer:
222
+ """Drop-in for RequestTracer/NullTracer. active=True so the slow path wraps its
223
+ ToolInvoker in TracingToolInvoker and routes tool spans here."""
224
+
225
+ active = True
226
+
227
+ def __init__(self, sink: Sink) -> None:
228
+ self.sink = sink
229
+
230
+ def callbacks(self, *, masked: bool = False) -> list:
231
+ return [StepLogger(self.sink, masked)]
232
+
233
+ def tool_span(self, tool: str, args: dict) -> Any:
234
+ return ScriptSpan(self.sink, tool, args)
235
+
236
+ def end(self, *, output: Any = None) -> None:
237
+ return None
238
+
239
+
240
+ class InstrumentedChatHandler(ChatHandler):
241
+ """ChatHandler that emits our ScriptTracer instead of Langfuse/Null, so every
242
+ LLM + tool step of a turn lands in `self.sink`."""
243
+
244
+ def __init__(self, *a, **k) -> None:
245
+ super().__init__(*a, **k)
246
+ self.sink = Sink()
247
+
248
+ def _make_tracer(self, user_id: str, question: str) -> Any: # type: ignore[override]
249
+ return ScriptTracer(self.sink)
250
+
251
+
252
+ # ───────────────────────────── pretty printing ───────────────────────────────
253
+
254
+
255
+ def banner(text: str, ch: str = "═") -> None:
256
+ print(f"\n{c('h', ch * 78)}\n{c('h', text)}\n{c('h', ch * 78)}")
257
+
258
+
259
+ def _llm_labels(intent: str | None, n: int) -> list[str]:
260
+ """Best-effort name per LLM call, by the path's known call order."""
261
+ seq = {
262
+ "structured_flow": ["router", "planner", "assembler"],
263
+ "help": ["router", "help"],
264
+ "unstructured_flow": ["router", "chatbot"],
265
+ "chat": ["router", "chatbot"],
266
+ "check": ["router"],
267
+ }.get(intent or "", ["router"])
268
+ out = []
269
+ for i in range(n):
270
+ if i < len(seq) - 1:
271
+ out.append(seq[i])
272
+ elif i == n - 1:
273
+ out.append(seq[-1]) # last call = final author
274
+ else:
275
+ out.append(f"{seq[1] if len(seq) > 1 else 'llm'}·retry")
276
+ return out
277
+
278
+
279
+ def print_turn_steps(sink: Sink, intent: str | None, total_ms: int) -> None:
280
+ if sink.tools:
281
+ print(c("dim", "\n tool calls (slow path):"))
282
+ for t in sink.tools:
283
+ tag = c("warn", "ERROR") if t.error else (t.kind or "ok")
284
+ extra = f" rows={t.rows}" if t.rows is not None else ""
285
+ print(f" • {t.tool:<18} {tag:<7}{extra:<10} {t.ms:>5}ms"
286
+ f" args={t.arg_keys}")
287
+ if t.error:
288
+ print(c("warn", f" ↳ {t.error}"))
289
+
290
+ if sink.llm:
291
+ labels = _llm_labels(intent, len(sink.llm))
292
+ print(c("dim", "\n llm calls (output / tokens / latency):"))
293
+ print(c("dim", f" {'#':<2} {'step':<14} {'in':>6} {'out':>6} {'tot':>6} {'ms':>6}"))
294
+ for call, label in zip(sink.llm, labels):
295
+ ms = f"{call.ms}" if call.ms is not None else "?"
296
+ print(f" {call.idx:<2} {label:<14} {call.tin:>6} {call.tout:>6} "
297
+ f"{call.ttot:>6} {ms:>6}")
298
+ print(c("dim", f" prompt: {call.prompt_preview}"))
299
+ # Local tool over your own data → show output regardless of the masked
300
+ # flag (masking only matters for Langfuse Cloud). Note when it's a
301
+ # cloud-masked call or has no text (structured / tool-call output).
302
+ out = call.output_preview or "<no text content — structured / tool-call output>"
303
+ tag = " (masked→cloud)" if call.masked else ""
304
+ print(c("dim", f" output{tag}: {out}"))
305
+
306
+ tin = sum(c_.tin for c_ in sink.llm)
307
+ tout = sum(c_.tout for c_ in sink.llm)
308
+ print(c("dim", f"\n ── turn: {total_ms}ms · {len(sink.llm)} llm call(s) · "
309
+ f"{len(sink.tools)} tool call(s) · {tin}+{tout} tokens"))
310
+
311
+
312
+ # ───────────────────────────── setup / turns ─────────────────────────────────
313
+
314
+
315
+ async def setup_analysis(user_id: str, bind_titanic: bool) -> str:
316
+ """Create a fresh analysis session (state row) + optionally bind it to Titanic.
317
+
318
+ Mirrors what `/analysis/create` does: a state row carrying the goal, plus an
319
+ analysis-scope `data_catalog` row (B) restricting the analysis to one source, so
320
+ structured_flow is scoped deterministically. Returns the analysis_id (== room_id).
321
+ """
322
+ from src.agents.state_store import AnalysisStateStore
323
+
324
+ analysis_id = str(uuid.uuid4())
325
+ await AnalysisStateStore().create(
326
+ analysis_id=analysis_id,
327
+ user_id=user_id,
328
+ analysis_title="Titanic survival analysis (sim)",
329
+ objective=OBJECTIVE,
330
+ )
331
+ print(f" created analysis {c('h', analysis_id)}")
332
+ print(f" objective: {OBJECTIVE}")
333
+
334
+ if bind_titanic:
335
+ try:
336
+ from datetime import UTC, datetime
337
+
338
+ from src.catalog.models import Catalog as CatalogModel
339
+ from src.catalog.store import CatalogStore
340
+ from src.db.postgres.connection import AsyncSessionLocal
341
+ from src.db.postgres.models import Catalog as CatalogRow
342
+
343
+ # Scope structured_flow by seeding the analysis-scope catalog (B): the
344
+ # user's catalog restricted to Titanic. structured_flow reads this row via
345
+ # CatalogStore.get_by_analysis (the data_sources binding table was removed;
346
+ # in production Go materializes B from analyses.data_bind).
347
+ user_cat = await CatalogStore().get(user_id)
348
+ titanic = [
349
+ s for s in (user_cat.sources if user_cat else [])
350
+ if s.source_id == TITANIC_SOURCE_ID
351
+ ]
352
+ if not titanic:
353
+ print(c("warn", f" Titanic source {TITANIC_SOURCE_ID} not in user "
354
+ "catalog — running unscoped (whole catalog)"))
355
+ else:
356
+ scoped = CatalogModel(
357
+ user_id=user_id, generated_at=datetime.now(UTC), sources=titanic,
358
+ )
359
+ async with AsyncSessionLocal() as s:
360
+ s.add(CatalogRow(
361
+ scope_type="analysis", user_id=user_id, analysis_id=analysis_id,
362
+ catalog_payload=scoped.model_dump(mode="json"),
363
+ ))
364
+ await s.commit()
365
+ print(f" bound source: {TITANIC_NAME} ({TITANIC_SOURCE_ID}) "
366
+ f"{c('dim', '→ structured_flow scoped to Titanic (analysis catalog)')}")
367
+ except Exception as e: # noqa: BLE001 — fail-open to whole catalog
368
+ print(c("warn", f" binding skipped ({type(e).__name__}: {e}) — "
369
+ f"fail-open to whole catalog"))
370
+ else:
371
+ print(c("dim", " no binding → structured_flow sees the whole catalog"))
372
+ return analysis_id
373
+
374
+
375
+ async def run_turn(
376
+ handler: InstrumentedChatHandler,
377
+ user_id: str,
378
+ analysis_id: str,
379
+ message: str,
380
+ history: list[BaseMessage],
381
+ ) -> None:
382
+ handler.sink = Sink()
383
+ banner(f"USER ▸ {message}", "─")
384
+
385
+ answer = ""
386
+ sources: list[dict] = []
387
+ intent: str | None = None
388
+ t0 = time.perf_counter()
389
+
390
+ async for ev in handler.handle(message, user_id, history, analysis_id=analysis_id):
391
+ kind, data = ev["event"], ev["data"]
392
+ if kind == "intent":
393
+ try:
394
+ d = json.loads(data)
395
+ intent = d.get("intent")
396
+ print(f" {c('h', 'ROUTER')} → intent={c('h', intent)} "
397
+ f"confidence={d.get('confidence')}")
398
+ rq = d.get("rewritten_query")
399
+ if rq and rq != message:
400
+ print(c("dim", f" rewritten: {rq}"))
401
+ except Exception:
402
+ pass
403
+ elif kind == "status":
404
+ print(c("dim", f" · {data}"))
405
+ elif kind == "sources":
406
+ try:
407
+ sources = json.loads(data) or []
408
+ except Exception:
409
+ sources = []
410
+ elif kind == "chunk":
411
+ answer += data
412
+ elif kind == "error":
413
+ print(c("warn", f" ERROR: {data}"))
414
+
415
+ total_ms = round((time.perf_counter() - t0) * 1000)
416
+ print(f"\n {c('ai', 'ANSWER')} ▾")
417
+ for line in (answer or "(empty)").splitlines() or ["(empty)"]:
418
+ print(f" {line}")
419
+ if sources:
420
+ print(c("dim", f"\n sources ({len(sources)}): "
421
+ + ", ".join(s.get("filename") or s.get("document_id", "?")
422
+ for s in sources)))
423
+ print_turn_steps(handler.sink, intent, total_ms)
424
+
425
+ history.append(HumanMessage(content=message))
426
+ history.append(AIMessage(content=answer))
427
+
428
+
429
+ async def generate_report(user_id: str, analysis_id: str) -> None:
430
+ """Mirror POST /report: floor check → ReportGenerator → ReportStore → print."""
431
+ banner("REPORT ▸ generating from accumulated report_inputs")
432
+ from src.agents.gate import stub_analysis_state
433
+ from src.agents.report.generator import ReportGenerator
434
+ from src.agents.report.readiness import report_floor
435
+ from src.agents.report.schemas import ProblemStatement
436
+ from src.agents.report.store import ReportStore
437
+ from src.agents.state_store import AnalysisStateStore
438
+
439
+ state = await AnalysisStateStore().get(analysis_id)
440
+ missing, _ = await report_floor(
441
+ analysis_id, state or stub_analysis_state(problem_validated=False)
442
+ )
443
+ if missing:
444
+ print(c("warn", f" floor not met (409 in the API): {', '.join(missing)}"))
445
+ print(c("dim", " → need ≥1 successful slow-path analysis first "
446
+ "(did the structured turns run analyze_* tools?)"))
447
+ return
448
+
449
+ objective = (getattr(state, "objective", "") or
450
+ getattr(state, "problem_statement", "") or "")
451
+ ps = ProblemStatement(
452
+ objective=objective,
453
+ business_questions=list(getattr(state, "business_questions", []) or []),
454
+ )
455
+ t0 = time.perf_counter()
456
+ report = await ReportGenerator().generate(
457
+ analysis_id, user_id, problem_statement=ps, user_name=None
458
+ )
459
+ saved = await ReportStore().save(report)
460
+ print(f" generated v{saved.version} in {round((time.perf_counter()-t0)*1000)}ms "
461
+ f"· report_id={saved.report_id} · built from {len(saved.record_ids)} record(s)\n")
462
+ print(c("dim", " ── rendered markdown ──"))
463
+ for line in saved.rendered_markdown.splitlines():
464
+ print(f" {line}")
465
+
466
+
467
+ # ──────────────────────────────── main ───────────────────────────────────────
468
+
469
+
470
+ async def amain(args: argparse.Namespace) -> None:
471
+ if args.plain:
472
+ _C["_on"] = False
473
+
474
+ banner("DATA EYOND — end-to-end chat simulator (in-process)")
475
+ print(f" user_id: {args.user_id}")
476
+ print(f" slow_path: ON tracing→terminal: ON db: (from .env)")
477
+
478
+ handler = InstrumentedChatHandler(
479
+ enable_tracing=False, enable_gate=False
480
+ )
481
+ analysis_id = await setup_analysis(args.user_id, bind_titanic=not args.no_bind)
482
+ history: list[BaseMessage] = []
483
+
484
+ if args.interactive:
485
+ print(c("dim", "\n interactive mode — type a message, 'report' to generate, "
486
+ "'exit' to quit.\n"))
487
+ loop = asyncio.get_event_loop()
488
+ while True:
489
+ try:
490
+ msg = (await loop.run_in_executor(None, input, "you ▸ ")).strip()
491
+ except (EOFError, KeyboardInterrupt):
492
+ break
493
+ if not msg:
494
+ continue
495
+ if msg.lower() in {"exit", "quit"}:
496
+ break
497
+ if msg.lower() == "report":
498
+ await generate_report(args.user_id, analysis_id)
499
+ continue
500
+ await run_turn(handler, args.user_id, analysis_id, msg, history)
501
+ else:
502
+ turns = DEFAULT_TURNS[: args.max_turns] if args.max_turns else DEFAULT_TURNS
503
+ for msg in turns:
504
+ await run_turn(handler, args.user_id, analysis_id, msg, history)
505
+
506
+ if not args.no_report:
507
+ await generate_report(args.user_id, analysis_id)
508
+
509
+ banner("DONE")
510
+ print(f" analysis_id (== room_id): {analysis_id}")
511
+ print(c("dim", " state, report_inputs, and report were written to the .env DB."))
512
+
513
+
514
+ def main() -> None:
515
+ p = argparse.ArgumentParser(description="End-to-end chat simulator with step transparency")
516
+ p.add_argument("--user-id", default=DEFAULT_USER_ID)
517
+ p.add_argument("--interactive", action="store_true", help="type messages yourself")
518
+ p.add_argument("--no-report", action="store_true", help="skip the report capstone")
519
+ p.add_argument("--no-bind", action="store_true",
520
+ help="don't scope to Titanic (planner sees the whole catalog)")
521
+ p.add_argument("--max-turns", type=int, default=0,
522
+ help="run only the first N scripted turns (cheap smoke test)")
523
+ p.add_argument("--plain", action="store_true", help="disable ANSI colors")
524
+ asyncio.run(amain(p.parse_args()))
525
+
526
+
527
+ if __name__ == "__main__":
528
+ main()
main.py CHANGED
@@ -22,6 +22,7 @@ from src.api.v1.report import router as report_router
22
  from src.api.v1.tools import router as tools_router
23
  from src.api.v1.help import router as help_router # pr/5 Phase 2: dedicated /tools/help
24
  from src.api.v1.traceability import router as traceability_router # KM-691
 
25
  from src.api.v2.chat import router as chat_v2_router # pr/5 Phase 2: v2 chat pilot (analysis_id)
26
  from src.db.postgres.init_db import init_db
27
  from src.config.settings import settings
@@ -68,6 +69,7 @@ app.include_router(report_router)
68
  app.include_router(tools_router)
69
  app.include_router(help_router)
70
  app.include_router(traceability_router) # KM-691: GET /api/v1/traceability
 
71
  app.include_router(chat_v2_router) # pr/5 Phase 2: POST /api/v2/chat/stream (analysis_id)
72
 
73
 
 
22
  from src.api.v1.tools import router as tools_router
23
  from src.api.v1.help import router as help_router # pr/5 Phase 2: dedicated /tools/help
24
  from src.api.v1.traceability import router as traceability_router # KM-691
25
+ from src.api.v1.charts import router as charts_router # W2: GET /api/v1/charts (SPINE_V2_PLAN §4.5)
26
  from src.api.v2.chat import router as chat_v2_router # pr/5 Phase 2: v2 chat pilot (analysis_id)
27
  from src.db.postgres.init_db import init_db
28
  from src.config.settings import settings
 
69
  app.include_router(tools_router)
70
  app.include_router(help_router)
71
  app.include_router(traceability_router) # KM-691: GET /api/v1/traceability
72
+ app.include_router(charts_router) # W2: GET /api/v1/charts (SPINE_V2_PLAN §4.5)
73
  app.include_router(chat_v2_router) # pr/5 Phase 2: POST /api/v2/chat/stream (analysis_id)
74
 
75
 
src/agents/chat_handler.py CHANGED
@@ -54,6 +54,7 @@ from .refusals import blocked_message, out_of_scope_message
54
 
55
  if TYPE_CHECKING:
56
  from ..catalog.reader import CatalogReader
 
57
  from ..retrieval.router import RetrievalRouter
58
  from ..traceability.store import TraceabilityStore
59
  from .gate import AnalysisState
@@ -105,6 +106,7 @@ class ChatHandler:
105
  ) = None,
106
  analysis_store: ReportInputStore | None = None,
107
  traceability_store: TraceabilityStore | None = None,
 
108
  check_invoker_factory: Callable[[str], Any] | None = None,
109
  ps_agent: ProblemStatementAgent | None = None,
110
  help_agent: HelpAgent | None = None,
@@ -128,6 +130,9 @@ class ChatHandler:
128
  # Traceability (KM-691): user-facing per-turn provenance store. Injectable for
129
  # tests; lazily built (Postgres) in production. Distinct from Langfuse tracing.
130
  self._traceability_store = traceability_store
 
 
 
131
  # `check` skill: builds the data-access invoker (check_data/check_knowledge)
132
  # per request with the authenticated user_id. Injectable for tests.
133
  self._check_invoker_factory = check_invoker_factory
@@ -644,6 +649,13 @@ class ChatHandler:
644
  self._traceability_store = PostgresTraceabilityStore()
645
  return self._traceability_store
646
 
 
 
 
 
 
 
 
647
  async def _flush_trace(
648
  self, pad: TraceabilityScratchpad, analysis_id: str | None, user_id: str
649
  ) -> None:
@@ -763,6 +775,22 @@ class ChatHandler:
763
  pad.set_planning_from_record(record)
764
  except Exception as e: # persistence must never break the user's answer
765
  logger.error("analysis_record persist failed", user_id=user_id, error=str(e))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
766
  tracer.end() # output omitted (chat_answer may contain PII on Cloud)
767
  if pad is not None:
768
  await self._flush_trace(pad, analysis_id, user_id)
 
54
 
55
  if TYPE_CHECKING:
56
  from ..catalog.reader import CatalogReader
57
+ from ..charts.store import ChartStore
58
  from ..retrieval.router import RetrievalRouter
59
  from ..traceability.store import TraceabilityStore
60
  from .gate import AnalysisState
 
106
  ) = None,
107
  analysis_store: ReportInputStore | None = None,
108
  traceability_store: TraceabilityStore | None = None,
109
+ chart_store: ChartStore | None = None,
110
  check_invoker_factory: Callable[[str], Any] | None = None,
111
  ps_agent: ProblemStatementAgent | None = None,
112
  help_agent: HelpAgent | None = None,
 
130
  # Traceability (KM-691): user-facing per-turn provenance store. Injectable for
131
  # tests; lazily built (Postgres) in production. Distinct from Langfuse tracing.
132
  self._traceability_store = traceability_store
133
+ # Charts (S2, SPINE_V2_PLAN §4.4): `render_chart` output store. Injectable for
134
+ # tests; lazily built (Postgres) in production.
135
+ self._chart_store = chart_store
136
  # `check` skill: builds the data-access invoker (check_data/check_knowledge)
137
  # per request with the authenticated user_id. Injectable for tests.
138
  self._check_invoker_factory = check_invoker_factory
 
649
  self._traceability_store = PostgresTraceabilityStore()
650
  return self._traceability_store
651
 
652
+ def _get_chart_store(self) -> ChartStore:
653
+ if self._chart_store is None:
654
+ from ..charts import PostgresChartStore
655
+
656
+ self._chart_store = PostgresChartStore()
657
+ return self._chart_store
658
+
659
  async def _flush_trace(
660
  self, pad: TraceabilityScratchpad, analysis_id: str | None, user_id: str
661
  ) -> None:
 
775
  pad.set_planning_from_record(record)
776
  except Exception as e: # persistence must never break the user's answer
777
  logger.error("analysis_record persist failed", user_id=user_id, error=str(e))
778
+ # SPINE_V2_PLAN §4.4: chart rows are written before `done`; the FE fetches
779
+ # GET /api/v1/charts unconditionally on every `done` (no polling race).
780
+ try:
781
+ if pad is not None and pad.message_id:
782
+ for task_result in result.analysis_record.results_snapshot.values():
783
+ for output in task_result.outputs:
784
+ if output.kind == "chart" and isinstance(output.value, dict):
785
+ await self._get_chart_store().save(
786
+ message_id=pad.message_id,
787
+ analysis_id=analysis_id or "",
788
+ user_id=user_id,
789
+ record_id=result.analysis_record.record_id,
790
+ envelope=output.value,
791
+ )
792
+ except Exception as e: # chart persist must never break the user's answer
793
+ logger.error("chart persist failed", user_id=user_id, error=str(e))
794
  tracer.end() # output omitted (chat_answer may contain PII on Cloud)
795
  if pad is not None:
796
  await self._flush_trace(pad, analysis_id, user_id)
src/agents/handlers/check.py CHANGED
@@ -278,6 +278,18 @@ async def _fetch_db_tables(
278
  }
279
 
280
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  def _matched_source_ids(message: str, inventory: ToolOutput) -> list[str]:
282
  """All source_ids whose name appears as a whole word in the message.
283
 
@@ -285,9 +297,9 @@ def _matched_source_ids(message: str, inventory: ToolOutput) -> list[str]:
285
  the tool needs exact `source_id`s. We resolve them against the inventory
286
  rows (kind="table", columns include "source_id" + "name") instead of an LLM
287
  — a cheap match against catalog metadata already in hand. Whole-word match
288
- (`\\b`) avoids nuisance hits ("orders" inside "reorders") and treats `_` as
289
- part of the word, so "sales" won't pick up "sales_archive". Multiple named
290
- sources all match, so the caller can show each schema.
291
  """
292
  if inventory.kind != "table" or not inventory.rows:
293
  return []
@@ -301,7 +313,7 @@ def _matched_source_ids(message: str, inventory: ToolOutput) -> list[str]:
301
  matched: list[str] = []
302
  for row in inventory.rows:
303
  name = str(row[name_idx])
304
- if name and re.search(rf"\b{re.escape(name)}\b", message, re.IGNORECASE):
305
  matched.append(str(row[id_idx]))
306
  return matched
307
 
@@ -370,7 +382,6 @@ _SCHEMA_STR = {
370
  "yes": "Yes", "no": "—",
371
  "db_tables": "{name} has {n} tables:",
372
  "db_item": "- {table} ({cols} columns{rows})",
373
- "db_hint": "Name a table to see its columns.",
374
  },
375
  "Indonesian": {
376
  "lead": "Berikut kolom dan tipe data di {n} sumber yang kamu punya:",
@@ -383,7 +394,6 @@ _SCHEMA_STR = {
383
  "yes": "Ya", "no": "—",
384
  "db_tables": "{name} punya {n} tabel:",
385
  "db_item": "- {table} ({cols} kolom{rows})",
386
- "db_hint": "Sebut nama tabelnya untuk lihat kolomnya.",
387
  },
388
  }
389
 
@@ -542,7 +552,6 @@ def _render_schema_source(out: ToolOutput, reply_language: str) -> str:
542
  lines.append(
543
  sc["db_item"].format(table=tname, cols=len(tcols), rows=_rows_suffix(rc))
544
  )
545
- lines.append(sc["db_hint"])
546
  return "\n".join(lines)
547
 
548
  parts: list[str] = []
@@ -580,6 +589,41 @@ def _render_schemas(schemas: list[ToolOutput], reply_language: str) -> str:
580
  return lead + "\n\n" + "\n\n".join(blocks)
581
 
582
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
583
  async def run_check(
584
  message: str, invoker: ToolInvoker, reply_language: str = "English"
585
  ) -> str:
@@ -618,7 +662,12 @@ async def run_check(
618
  schemas = await asyncio.gather(
619
  *(invoker.invoke("check_data", {"source_id": sid}) for sid in ids)
620
  )
621
- return _render_schemas(schemas, reply_language) or _no_match
 
 
 
 
 
622
 
623
  if intent == "data":
624
  inventory = await invoker.invoke("check_data", {})
 
278
  }
279
 
280
 
281
+ def _name_in_message(name: str, message: str) -> bool:
282
+ """Whole-word, case-insensitive match of `name` in `message`.
283
+
284
+ `\\b` treats `_` as a word char, so "sales" won't hit "sales_archive" and the
285
+ short table name "pa" won't hit "pareto" — the same rule used for source names,
286
+ reused here for table names.
287
+ """
288
+ return bool(name) and (
289
+ re.search(rf"\b{re.escape(name)}\b", message, re.IGNORECASE) is not None
290
+ )
291
+
292
+
293
  def _matched_source_ids(message: str, inventory: ToolOutput) -> list[str]:
294
  """All source_ids whose name appears as a whole word in the message.
295
 
 
297
  the tool needs exact `source_id`s. We resolve them against the inventory
298
  rows (kind="table", columns include "source_id" + "name") instead of an LLM
299
  — a cheap match against catalog metadata already in hand. Whole-word match
300
+ avoids nuisance hits ("orders" inside "reorders") and treats `_` as part of
301
+ the word, so "sales" won't pick up "sales_archive". Multiple named sources all
302
+ match, so the caller can show each schema.
303
  """
304
  if inventory.kind != "table" or not inventory.rows:
305
  return []
 
313
  matched: list[str] = []
314
  for row in inventory.rows:
315
  name = str(row[name_idx])
316
+ if _name_in_message(name, message):
317
  matched.append(str(row[id_idx]))
318
  return matched
319
 
 
382
  "yes": "Yes", "no": "—",
383
  "db_tables": "{name} has {n} tables:",
384
  "db_item": "- {table} ({cols} columns{rows})",
 
385
  },
386
  "Indonesian": {
387
  "lead": "Berikut kolom dan tipe data di {n} sumber yang kamu punya:",
 
394
  "yes": "Ya", "no": "—",
395
  "db_tables": "{name} punya {n} tabel:",
396
  "db_item": "- {table} ({cols} kolom{rows})",
 
397
  },
398
  }
399
 
 
552
  lines.append(
553
  sc["db_item"].format(table=tname, cols=len(tcols), rows=_rows_suffix(rc))
554
  )
 
555
  return "\n".join(lines)
556
 
557
  parts: list[str] = []
 
589
  return lead + "\n\n" + "\n\n".join(blocks)
590
 
591
 
592
+ def _filter_to_named_tables(
593
+ schemas: list[ToolOutput], message: str
594
+ ) -> list[ToolOutput] | None:
595
+ """Narrow drilled schemas to the specific table(s) named in the message.
596
+
597
+ Lets "what columns are in badactor" answer with badactor's columns instead of
598
+ the whole-DB table list. When the message names table(s) present in a source,
599
+ that source is narrowed to just those tables; sources with none of the named
600
+ tables are dropped, so a table-specific question doesn't dump every other
601
+ source's schema. Returns None when the message names no table at all, so the
602
+ caller keeps the default whole-source view. Table names are matched
603
+ whole-word (same rule as source names) against each drilled schema's rows.
604
+ """
605
+ narrowed: list[ToolOutput] = []
606
+ any_named = False
607
+ for out in schemas:
608
+ if out.kind != "table":
609
+ continue
610
+ cols = out.columns or []
611
+ if "table_name" not in cols:
612
+ continue
613
+ tn_idx = cols.index("table_name")
614
+ keep = {
615
+ str(r[tn_idx])
616
+ for r in (out.rows or [])
617
+ if _name_in_message(str(r[tn_idx]), message)
618
+ }
619
+ if not keep:
620
+ continue
621
+ any_named = True
622
+ rows = [r for r in (out.rows or []) if str(r[tn_idx]) in keep]
623
+ narrowed.append(out.model_copy(update={"rows": rows}))
624
+ return narrowed if any_named else None
625
+
626
+
627
  async def run_check(
628
  message: str, invoker: ToolInvoker, reply_language: str = "English"
629
  ) -> str:
 
662
  schemas = await asyncio.gather(
663
  *(invoker.invoke("check_data", {"source_id": sid}) for sid in ids)
664
  )
665
+ # Table-level drill: if the message names specific table(s) e.g. "what
666
+ # columns are in badactor" — narrow to those so we answer with that table's
667
+ # columns instead of a whole-DB table list. Falls back to the full view when
668
+ # no table is named.
669
+ narrowed = _filter_to_named_tables(schemas, message)
670
+ return _render_schemas(narrowed or schemas, reply_language) or _no_match
671
 
672
  if intent == "data":
673
  inventory = await invoker.invoke("check_data", {})
src/agents/planner/examples.py CHANGED
@@ -730,6 +730,119 @@ _EXAMPLE_I = TaskList(
730
  )
731
 
732
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
733
  EXAMPLES: list[tuple[str, TaskList]] = [
734
  ("Which product categories drove last quarter's revenue?", _EXAMPLE_A),
735
  ("How has monthly revenue trended by region this year, and what's unusual?", _EXAMPLE_B),
@@ -744,6 +857,8 @@ EXAMPLES: list[tuple[str, TaskList]] = [
744
  "order quantity?",
745
  _EXAMPLE_I,
746
  ),
 
 
747
  ]
748
 
749
 
 
730
  )
731
 
732
 
733
+ # --------------------------------------------------------------------------- #
734
+ # Example J — visualization tail (SPINE_V2_PLAN §4.3, recipe "viz tail").
735
+ # "Show me a bar chart of total revenue per product category."
736
+ # Shows: render_chart is planned ONLY on an explicit ask ("bar chart"), and is
737
+ # ALWAYS a tail step — its `data` consumes a TABLE-producing upstream (here the
738
+ # grouped top-N-style retrieve from Example G, without the limit), never stats/
739
+ # series/metadata. `x`/`y` reference that table's column aliases; the chart
740
+ # carries the already-aggregated rows (one bar per category), never raw order
741
+ # rows. The `assumptions` line carries the feasibility check on purpose: a chart
742
+ # ask never licenses aliasing a stand-in column (see Example K).
743
+ # --------------------------------------------------------------------------- #
744
+
745
+ _EXAMPLE_J = TaskList(
746
+ plan_id="example_j",
747
+ goal_restated="Chart total revenue per product category as a bar chart.",
748
+ assumptions=[
749
+ "The chart dimension exists in the catalog (c_category) — a chart ask is "
750
+ "planned only when the asked-for dimension and measure have real catalog "
751
+ "columns; otherwise it is infeasible, chart or no chart."
752
+ ],
753
+ open_questions=[],
754
+ tasks=[
755
+ Task(
756
+ id="t1",
757
+ stage="data_understanding",
758
+ objective="Confirm the sales source exposes category and revenue.",
759
+ tool_calls=[ToolCall(tool="check_data", args={"source_id": "src_sales"})],
760
+ expected_output="source_shape",
761
+ success_criteria="Produced the orders table schema; category and revenue present.",
762
+ depends_on=[],
763
+ estimated_cost="low",
764
+ ),
765
+ Task(
766
+ id="t2",
767
+ stage="data_preparation",
768
+ objective="Aggregate total revenue per product category.",
769
+ tool_calls=[
770
+ ToolCall(
771
+ tool="retrieve_data",
772
+ args={
773
+ "ir": {
774
+ "source_id": "src_sales",
775
+ "table_id": "t_orders",
776
+ "select": [
777
+ {"kind": "column", "column_id": "c_category", "alias": "category"},
778
+ {
779
+ "kind": "agg",
780
+ "fn": "sum",
781
+ "column_id": "c_revenue",
782
+ "alias": "total_revenue",
783
+ },
784
+ ],
785
+ "group_by": ["c_category"],
786
+ }
787
+ },
788
+ )
789
+ ],
790
+ expected_output="revenue_per_category",
791
+ success_criteria="Produced one total-revenue row per category.",
792
+ depends_on=["t1"],
793
+ estimated_cost="low",
794
+ ),
795
+ Task(
796
+ id="t3",
797
+ stage="evaluation",
798
+ objective="Render the per-category revenue table as a bar chart.",
799
+ tool_calls=[
800
+ ToolCall(
801
+ tool="render_chart",
802
+ args={
803
+ # `data` references the TABLE task (t2) — a chart is always a
804
+ # tail on an aggregated table, never on raw rows or stats.
805
+ "data": "${t2}",
806
+ "chart_type": "bar",
807
+ "x": "category",
808
+ "y": "total_revenue",
809
+ "title": "Total revenue by product category",
810
+ },
811
+ )
812
+ ],
813
+ expected_output="revenue_bar_chart",
814
+ success_criteria="Produced a bar-chart spec with one bar per category.",
815
+ depends_on=["t2"],
816
+ estimated_cost="low",
817
+ ),
818
+ ],
819
+ )
820
+
821
+
822
+ # --------------------------------------------------------------------------- #
823
+ # Example K — a chart ask that is INFEASIBLE (SPINE_V2_PLAN §4.3 + planner.md
824
+ # "Charts ... never relaxes feasibility"). "Plot the customer churn rate by
825
+ # month as a line chart" against the sales catalog: churn does not exist, and
826
+ # the explicit chart request does NOT license mapping some other column into
827
+ # the asked-for measure — the verdict is the same infeasible_reason Example H
828
+ # would give, chart or no chart.
829
+ # --------------------------------------------------------------------------- #
830
+
831
+ _EXAMPLE_K = TaskList(
832
+ plan_id="example_k",
833
+ goal_restated="Chart the monthly customer churn rate as a line chart.",
834
+ assumptions=[],
835
+ open_questions=[],
836
+ tasks=[],
837
+ infeasible_reason=(
838
+ "The connected source has no churn or subscription-status data — the "
839
+ "orders table only carries order-level category, revenue, quantity, and "
840
+ "dates — so there is nothing to chart. Nearest chartable analyses: "
841
+ "monthly revenue trend, or order counts per category."
842
+ ),
843
+ )
844
+
845
+
846
  EXAMPLES: list[tuple[str, TaskList]] = [
847
  ("Which product categories drove last quarter's revenue?", _EXAMPLE_A),
848
  ("How has monthly revenue trended by region this year, and what's unusual?", _EXAMPLE_B),
 
857
  "order quantity?",
858
  _EXAMPLE_I,
859
  ),
860
+ ("Show me a bar chart of total revenue per product category.", _EXAMPLE_J),
861
+ ("Plot the customer churn rate by month as a line chart.", _EXAMPLE_K),
862
  ]
863
 
864
 
src/agents/planner/validator.py CHANGED
@@ -11,6 +11,8 @@ rejects structurally-invalid plans (duplicate ids, dangling edges, cycles).
11
 
12
  from __future__ import annotations
13
 
 
 
14
  from pydantic import ValidationError
15
 
16
  from src.middlewares.logging import get_logger
@@ -281,6 +283,24 @@ class PlannerValidator:
281
  "analyzable data rows. Feed analyze_* from a data-producing tool "
282
  "(e.g. retrieve_data)."
283
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
 
285
  @staticmethod
286
  def _validate_dag(tasks_by_id: dict, id_set: set[str]) -> None:
@@ -322,14 +342,33 @@ def _is_placeholder(value: str) -> bool:
322
 
323
 
324
  def _placeholder_refs(task) -> set[str]:
 
 
 
 
 
325
  refs: set[str] = set()
326
  for call in task.tool_calls:
327
- for value in call.args.values():
328
- if isinstance(value, str):
329
- refs.update(PLACEHOLDER_RE.findall(value))
330
  return refs
331
 
332
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
  def _is_checkable(text: str) -> bool:
334
  low = text.lower()
335
  return any(tok in low for tok in _CHECKABLE_TOKENS)
 
11
 
12
  from __future__ import annotations
13
 
14
+ from collections.abc import Iterator
15
+
16
  from pydantic import ValidationError
17
 
18
  from src.middlewares.logging import get_logger
 
283
  "analyzable data rows. Feed analyze_* from a data-producing tool "
284
  "(e.g. retrieve_data)."
285
  )
286
+ # Check 10 — render_chart path shape (SPINE_V2_PLAN §4.3): the chart's
287
+ # `data` must come from a task whose tool yields a TABLE. A stats- or
288
+ # series-kind upstream passes the category denylist above but cannot
289
+ # be materialized into a DataFrame at execution, so the chart could
290
+ # only fail late; reject pre-run so the retry re-prompts toward a
291
+ # grouped retrieve_data or a table-producing analyze_*.
292
+ if (
293
+ call.tool == "render_chart"
294
+ and ref_spec is not None
295
+ and ref_spec.output_kind != "table"
296
+ ):
297
+ raise PlannerValidationError(
298
+ f"task {task_id}: render_chart takes its {arg_name!r} from task "
299
+ f"{match.group(1)} ({ref_tool!r}), which yields "
300
+ f"{ref_spec.output_kind!r} output — a chart is drawn from a TABLE. "
301
+ "Feed it a table-producing task (a grouped retrieve_data, "
302
+ "analyze_aggregate, analyze_merge, ...)."
303
+ )
304
 
305
  @staticmethod
306
  def _validate_dag(tasks_by_id: dict, id_set: set[str]) -> None:
 
342
 
343
 
344
  def _placeholder_refs(task) -> set[str]:
345
+ """Task ids referenced by any placeholder in a task's args — including ones
346
+ NESTED inside a retrieve_data IR (e.g. a '${t2.customer_id}' value-handoff
347
+ filter). A '${t<id>.<col>}' ref contributes its TASK id ('t2'); the column
348
+ suffix is stripped so the DAG check still enforces the dependency (task ids
349
+ contain no '.')."""
350
  refs: set[str] = set()
351
  for call in task.tool_calls:
352
+ for value in _iter_arg_strings(call.args):
353
+ for ref in PLACEHOLDER_RE.findall(value):
354
+ refs.add(ref.split(".", 1)[0])
355
  return refs
356
 
357
 
358
+ def _iter_arg_strings(value: object) -> Iterator[str]:
359
+ """Yield every string reachable in a tool-arg value, recursing into dicts and
360
+ lists so placeholders nested inside an IR are seen — not just top-level string
361
+ args (which is all `data`/`data_right` Pattern-A handoffs used to be)."""
362
+ if isinstance(value, str):
363
+ yield value
364
+ elif isinstance(value, dict):
365
+ for v in value.values():
366
+ yield from _iter_arg_strings(v)
367
+ elif isinstance(value, list):
368
+ for v in value:
369
+ yield from _iter_arg_strings(v)
370
+
371
+
372
  def _is_checkable(text: str) -> bool:
373
  low = text.lower()
374
  return any(tok in low for tok in _CHECKABLE_TOKENS)
src/agents/refusals.py CHANGED
@@ -76,6 +76,33 @@ _DATA_GAP = {
76
  }
77
 
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  def data_gap_message(reason: str | None, reply_language: str | None = None) -> str:
80
  """Answer for an infeasible analysis: the bound sources lack the asked-for data.
81
 
 
76
  }
77
 
78
 
79
+ # Run failure: the plan was feasible but EVERY execution step failed (S1a CK1,
80
+ # SPINE_V2_PLAN §3). Deterministic like the data-gap answer — a run that just
81
+ # failed is not re-asked to prose up its own failure. Same reply_language keying.
82
+ _RUN_FAILURE = {
83
+ "en": (
84
+ "I ran the analysis, but every step failed, so I don't have a result I can "
85
+ "stand behind. {reason}You can try rephrasing the question, or check that "
86
+ "the connected data source is reachable."
87
+ ),
88
+ "id": (
89
+ "Saya sudah menjalankan analisisnya, tetapi semua langkah gagal, jadi belum "
90
+ "ada hasil yang bisa saya laporkan. {reason}Coba ubah pertanyaannya, atau "
91
+ "periksa apakah sumber data yang terhubung dapat diakses."
92
+ ),
93
+ }
94
+
95
+
96
+ def run_failure_message(reason: str | None, reply_language: str | None = None) -> str:
97
+ """Answer for an all-steps-failed run (S1a CK1). `reason` is the most specific
98
+ failure detail available (may be None); `reply_language` as in data_gap_message."""
99
+ detail = (reason or "").strip()
100
+ if detail and not detail.endswith((".", "!", "?")):
101
+ detail += "."
102
+ lang = "id" if reply_language == "Indonesian" else "en"
103
+ return _RUN_FAILURE[lang].format(reason=f"{detail} " if detail else "")
104
+
105
+
106
  def data_gap_message(reason: str | None, reply_language: str | None = None) -> str:
107
  """Answer for an infeasible analysis: the bound sources lack the asked-for data.
108
 
src/agents/report/generator.py CHANGED
@@ -13,6 +13,7 @@ Chain construction mirrors `agents/slow_path/assembler.py`.
13
 
14
  from __future__ import annotations
15
 
 
16
  import re
17
  from datetime import UTC, datetime
18
  from pathlib import Path
@@ -201,6 +202,33 @@ def _collect_evidence(records: list[AnalysisRecord]) -> dict[str, list[EvidenceT
201
  return out
202
 
203
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  def _unresolved_note(rec: AnalysisRecord) -> AttributedNote:
205
  # Goal + the record's own first caveat as the "why" — both Assembler-authored,
206
  # nothing new is synthesized here.
@@ -446,8 +474,26 @@ def _render_markdown(report: AnalysisReport) -> str:
446
  blocks.append("\n".join(block))
447
  parts.append("\n\n".join(blocks))
448
 
449
- # ## EDA — reserved for future exploratory visuals; charts wrapped as MDX
450
- # components will render here. Emitted only when it has content; omitted today.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
 
452
  if report.data_sources:
453
  lines = ["## Data Sources", "| source | type | detail |", "|---|---|---|"]
@@ -607,6 +653,7 @@ class ReportGenerator:
607
  unresolved=[_unresolved_note(r) for r in unresolved_records],
608
  excluded=[_excluded_note(r) for r in excluded],
609
  evidence_tables=_collect_evidence(records),
 
610
  data_sources=data_sources,
611
  method_steps=method_steps,
612
  )
 
13
 
14
  from __future__ import annotations
15
 
16
+ import json
17
  import re
18
  from datetime import UTC, datetime
19
  from pathlib import Path
 
202
  return out
203
 
204
 
205
+ _CHARTS_MAX_PER_RECORD = 3 # mirrors _EVIDENCE_MAX_TABLES
206
+
207
+
208
+ def _collect_charts(records: list[AnalysisRecord]) -> dict[str, list[dict]]:
209
+ """Copy chart envelopes out of each record's `results_snapshot` (INV-4).
210
+
211
+ `render_chart` outputs only — the full `dataeyond.chart.v1` envelope, verbatim
212
+ (the snapshot trimmer never touches chart-kind outputs, so the spec is whole).
213
+ Rendered as ```plotly fenced blocks in the markdown EDA section.
214
+ """
215
+ out: dict[str, list[dict]] = {}
216
+ for rec in records:
217
+ charts: list[dict] = []
218
+ for result in rec.results_snapshot.values():
219
+ for output in result.outputs:
220
+ if len(charts) >= _CHARTS_MAX_PER_RECORD:
221
+ break
222
+ if output.kind != "chart" or not isinstance(output.value, dict):
223
+ continue
224
+ if not isinstance(output.value.get("plotly"), dict):
225
+ continue
226
+ charts.append(output.value)
227
+ if charts:
228
+ out[rec.record_id] = charts
229
+ return out
230
+
231
+
232
  def _unresolved_note(rec: AnalysisRecord) -> AttributedNote:
233
  # Goal + the record's own first caveat as the "why" — both Assembler-authored,
234
  # nothing new is synthesized here.
 
474
  blocks.append("\n".join(block))
475
  parts.append("\n\n".join(blocks))
476
 
477
+ # ## EDA — charts (W2, 2026-07-14). Each chart a `render_chart` task produced
478
+ # is emitted as a ```plotly fenced block the FE's fence hook renders with
479
+ # plotly.js. Fence content is the FULL dataeyond.chart.v1 envelope, verbatim
480
+ # (INV-4) and pretty-printed — the shape the FE hook parses (verified against
481
+ # the FE 2026-07-14; it reads spec.plotly internally). Section omitted when no
482
+ # record produced a chart.
483
+ if report.charts:
484
+ lines = ["## EDA"]
485
+ ordered_rids = [rid for rid in report.record_ids if rid in report.charts]
486
+ ordered_rids += [rid for rid in report.charts if rid not in ordered_rids]
487
+ for rid in ordered_rids:
488
+ for envelope in report.charts[rid]:
489
+ caption = envelope.get("title") or report.record_goals.get(rid) or "Chart"
490
+ lines.append(f"**{_mdx_escape(str(caption))}**")
491
+ lines.append("")
492
+ lines.append("```plotly")
493
+ lines.append(json.dumps(envelope, ensure_ascii=False, indent=2))
494
+ lines.append("```")
495
+ lines.append("")
496
+ parts.append("\n".join(lines).rstrip())
497
 
498
  if report.data_sources:
499
  lines = ["## Data Sources", "| source | type | detail |", "|---|---|---|"]
 
653
  unresolved=[_unresolved_note(r) for r in unresolved_records],
654
  excluded=[_excluded_note(r) for r in excluded],
655
  evidence_tables=_collect_evidence(records),
656
+ charts=_collect_charts(records),
657
  data_sources=data_sources,
658
  method_steps=method_steps,
659
  )
src/agents/report/readiness.py CHANGED
@@ -72,15 +72,18 @@ def _is_newer(a: datetime, b: datetime) -> bool:
72
 
73
 
74
  def has_successful_analysis(record) -> bool:
75
- """True if the record has at least one *analysis* task that succeeded.
76
 
77
  A failed run still writes findings (narrating the failure) and its data-access
78
  tasks (check_/retrieve_) succeed, so we can't key on findings or on "any task
79
- succeeded". An analysis tool (analyze_*) completing is the real "we produced a
80
- result" signal.
 
 
81
  """
82
  return any(
83
- t.status == "success" and any(tool.startswith("analyze") for tool in t.tools_used)
 
84
  for t in record.tasks_run
85
  )
86
 
 
72
 
73
 
74
  def has_successful_analysis(record) -> bool:
75
+ """True if the record has at least one *result-producing* task that succeeded.
76
 
77
  A failed run still writes findings (narrating the failure) and its data-access
78
  tasks (check_/retrieve_) succeed, so we can't key on findings or on "any task
79
+ succeeded". A completed analysis tool (analyze_*) or, since W2 charts
80
+ (2026-07-14), a completed `render_chart`, whose viz-tail upstream necessarily
81
+ computed the numbers being charted — is the real "we produced a result"
82
+ signal. A chart-only session therefore satisfies the report floor.
83
  """
84
  return any(
85
+ t.status == "success"
86
+ and any(tool.startswith("analyze") or tool == "render_chart" for tool in t.tools_used)
87
  for t in record.tasks_run
88
  )
89
 
src/agents/report/schemas.py CHANGED
@@ -146,6 +146,10 @@ class AnalysisReport(BaseModel):
146
  excluded: list[AttributedNote] = Field(default_factory=list)
147
  # record_id -> small result tables copied from that record's results_snapshot.
148
  evidence_tables: dict[str, list[EvidenceTable]] = Field(default_factory=dict)
 
 
 
 
149
  data_sources: list[DataSourceRef] = Field(default_factory=list)
150
  method_steps: list[TaskSummary] = Field(default_factory=list) # carries `stage`
151
  rendered_markdown: str = ""
 
146
  excluded: list[AttributedNote] = Field(default_factory=list)
147
  # record_id -> small result tables copied from that record's results_snapshot.
148
  evidence_tables: dict[str, list[EvidenceTable]] = Field(default_factory=dict)
149
+ # record_id -> dataeyond.chart.v1 envelopes, copied verbatim from that record's
150
+ # results_snapshot (W2 charts, 2026-07-14). The markdown renders each as a
151
+ # ```plotly fenced block in the EDA section; the FE's fence hook draws it.
152
+ charts: dict[str, list[dict]] = Field(default_factory=dict)
153
  data_sources: list[DataSourceRef] = Field(default_factory=list)
154
  method_steps: list[TaskSummary] = Field(default_factory=list) # carries `stage`
155
  rendered_markdown: str = ""
src/agents/slow_path/assembler.py CHANGED
@@ -33,6 +33,7 @@ from .schemas import (
33
  AnalysisRecord,
34
  AssembledOutput,
35
  AssemblerNarrative,
 
36
  RunState,
37
  TaskResult,
38
  TaskSummary,
@@ -96,6 +97,7 @@ class Assembler:
96
  question: str | None = None,
97
  reply_language: str | None = None,
98
  callbacks: list | None = None,
 
99
  ) -> AssembledOutput:
100
  chain = self._ensure_chain()
101
  # `reply_language` is detected upstream from the ORIGINAL user message. Fall back
@@ -103,7 +105,9 @@ class Assembler:
103
  # rewritten_query, which may be normalized to English, so the caller should pass it.
104
  if reply_language is None:
105
  reply_language = detect_reply_language([], message=question)
106
- human_content = build_assembler_prompt(run_state, context, question, reply_language)
 
 
107
  try:
108
  if callbacks:
109
  narrative: AssemblerNarrative = await chain.ainvoke(
 
33
  AnalysisRecord,
34
  AssembledOutput,
35
  AssemblerNarrative,
36
+ RunAssessment,
37
  RunState,
38
  TaskResult,
39
  TaskSummary,
 
97
  question: str | None = None,
98
  reply_language: str | None = None,
99
  callbacks: list | None = None,
100
+ assessment: RunAssessment | None = None,
101
  ) -> AssembledOutput:
102
  chain = self._ensure_chain()
103
  # `reply_language` is detected upstream from the ORIGINAL user message. Fall back
 
105
  # rewritten_query, which may be normalized to English, so the caller should pass it.
106
  if reply_language is None:
107
  reply_language = detect_reply_language([], message=question)
108
+ human_content = build_assembler_prompt(
109
+ run_state, context, question, reply_language, assessment=assessment
110
+ )
111
  try:
112
  if callbacks:
113
  narrative: AssemblerNarrative = await chain.ainvoke(
src/agents/slow_path/checkpoint.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Quality checkpoint (S1a) — deterministic run inspection (SPINE_V2_PLAN §3, W1).
2
+
3
+ Sits between `TaskRunner.run` and `Assembler.assemble` in the coordinator. 0 LLM,
4
+ never-throw: `assess` inspects the completed `RunState` against the plan and turns
5
+ today's *silent* degrade-and-continue into an *explained* one — the Assembler gets
6
+ told what specifically failed/degraded, and every flag is structlog'd as a
7
+ `repair_candidate` so the S1b (targeted repair, gated) decision has an evidence
8
+ base. INV-6 untouched: nothing here re-plans or calls a model.
9
+
10
+ v1 checks:
11
+ CK1 all tasks failed -> overall "failed" (coordinator answers honestly, no LLM)
12
+ CK2 empty retrieve consumed -> flag the retrieve + its transitive dependents
13
+ CK3 table truncated at the cap -> flag (answer must scope itself to the first 10k rows)
14
+ CK4 trend collapsed to 1 bucket -> flag (the pr/13 1970-bucket class)
15
+ CK5 all-null column consumed -> flag the consuming analyze_* task
16
+ CK6 chart-spec sanity (§4.6) -> flag empty/mismatched/overcrowded/non-numeric charts
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections import defaultdict, deque
22
+ from typing import Any
23
+
24
+ from src.middlewares.logging import get_logger
25
+
26
+ from ..planner.schemas import PLACEHOLDER_RE, TaskList
27
+ from .schemas import RepairCandidate, RunAssessment, RunState, TaskAssessment
28
+
29
+ logger = get_logger("slow_path_checkpoint")
30
+
31
+ _TABLE_ROW_CAP = 10_000 # the query pipeline's LIMIT defense cap (CK3)
32
+ _BAR_CATEGORY_CAP = 30 # §4.6 — a bar chart beyond this is unreadable
33
+ _PIE_CATEGORY_CAP = 8 # §4.6 — a pie chart beyond this is unreadable
34
+
35
+
36
+ def assess(run_state: RunState, task_list: TaskList) -> RunAssessment:
37
+ """Inspect a completed run. NEVER raises — on an internal slip the checkpoint
38
+ degrades to today's behavior (an all-ok pass-through), never blocks the answer."""
39
+ try:
40
+ return _assess(run_state, task_list)
41
+ except Exception as exc: # noqa: BLE001 — never-throw seam (house pattern)
42
+ logger.error("checkpoint failed", error=repr(exc))
43
+ return RunAssessment(overall="ok")
44
+
45
+
46
+ def _assess(run_state: RunState, task_list: TaskList) -> RunAssessment:
47
+ results = run_state.results
48
+ notes: dict[str, list[str]] = {tid: [] for tid in results}
49
+ candidates: list[RepairCandidate] = []
50
+
51
+ def flag(task_id: str, check: str, reason: str, *, repairable: bool) -> None:
52
+ if task_id not in notes:
53
+ return
54
+ notes[task_id].append(reason)
55
+ # Telemetry for the S1b decision (SPINE_V2_PLAN §3): every flag is logged,
56
+ # repairable or not — the hit-rate decides whether a repair pass pays.
57
+ logger.info("repair_candidate", task_id=task_id, check=check, reason=reason)
58
+ if repairable:
59
+ candidates.append(RepairCandidate(task_id=task_id, reason=reason))
60
+
61
+ # CK1 — everything failed: no partial result worth prose. The coordinator
62
+ # returns a deterministic honest-failure answer (0 LLM), mirroring the
63
+ # infeasible path; the record stays non-substantive.
64
+ if results and all(r.status == "failure" for r in results.values()):
65
+ logger.info(
66
+ "repair_candidate",
67
+ task_id="*",
68
+ check="CK1",
69
+ reason="all tasks failed",
70
+ )
71
+ return RunAssessment(
72
+ overall="failed",
73
+ tasks=[
74
+ TaskAssessment(
75
+ task_id=tid, verdict="failed", notes=[r.error] if r.error else []
76
+ )
77
+ for tid, r in results.items()
78
+ ],
79
+ )
80
+
81
+ dependents = _transitive_dependents(task_list)
82
+
83
+ for tid, result in results.items():
84
+ for out in result.outputs:
85
+ # CK2 — an empty retrieval, with downstream consumers flagged too.
86
+ if out.tool == "retrieve_data" and out.kind == "table" and not (out.rows or []):
87
+ flag(tid, "CK2", "retrieve_data returned 0 rows (the filters "
88
+ "matched nothing)", repairable=True)
89
+ for dep in sorted(dependents.get(tid, ())):
90
+ flag(dep, "CK2", f"consumed the empty result of {tid}",
91
+ repairable=False)
92
+ # CK3 — the retrieval cap was hit; findings only cover the cap window.
93
+ elif out.kind == "table" and out.rows is not None and len(out.rows) >= _TABLE_ROW_CAP:
94
+ flag(tid, "CK3", f"table hit the {_TABLE_ROW_CAP:,}-row retrieval "
95
+ "cap — findings describe only the first "
96
+ f"{_TABLE_ROW_CAP:,} rows", repairable=False)
97
+
98
+ # CK4 — a single trend bucket (e.g. every date parsed into one period).
99
+ if out.tool == "analyze_trend" and out.kind == "series" and isinstance(out.value, dict):
100
+ points = out.value.get("points")
101
+ if isinstance(points, list) and len(points) == 1:
102
+ freq = out.value.get("freq", "period")
103
+ flag(tid, "CK4", f"trend collapsed into a single {freq} bucket "
104
+ "(1 point) — no movement can be described",
105
+ repairable=True)
106
+
107
+ # CK6 — chart-spec sanity (§4.6; lands with W2's render_chart).
108
+ if out.kind == "chart" and isinstance(out.value, dict):
109
+ for issue in _chart_issues(out.value):
110
+ flag(tid, "CK6", issue, repairable=True)
111
+
112
+ # CK5 — an analyze_* consumed a table whose column(s) are entirely null.
113
+ # Consumption is read from the PLAN (the `data`/`data_right` placeholders);
114
+ # the runner resolves the same references at execution time.
115
+ for task in task_list.tasks:
116
+ for call in task.tool_calls:
117
+ if not call.tool.startswith("analyze_"):
118
+ continue
119
+ for arg_name in ("data", "data_right"):
120
+ ref = _placeholder_ref(call.args.get(arg_name))
121
+ table = _last_table_output(results.get(ref)) if ref else None
122
+ if table is None:
123
+ continue
124
+ null_cols = _all_null_columns(table)
125
+ if null_cols:
126
+ flag(task.id, "CK5",
127
+ f"input column(s) {null_cols} from {ref} are entirely "
128
+ "null — results based on them are meaningless",
129
+ repairable=True)
130
+
131
+ assessments: list[TaskAssessment] = []
132
+ for tid, result in results.items():
133
+ if result.status == "failure":
134
+ verdict = "failed"
135
+ elif notes[tid] or result.status == "partial":
136
+ verdict = "degraded"
137
+ else:
138
+ verdict = "ok"
139
+ assessments.append(TaskAssessment(task_id=tid, verdict=verdict, notes=notes[tid]))
140
+
141
+ overall = "degraded" if any(a.verdict != "ok" for a in assessments) else "ok"
142
+ return RunAssessment(overall=overall, tasks=assessments, repair_candidates=candidates)
143
+
144
+
145
+ def _transitive_dependents(task_list: TaskList) -> dict[str, set[str]]:
146
+ """dependents[id] = every task downstream of id via depends_on edges."""
147
+ direct: dict[str, set[str]] = defaultdict(set)
148
+ for task in task_list.tasks:
149
+ for dep in task.depends_on:
150
+ direct[dep].add(task.id)
151
+ out: dict[str, set[str]] = {}
152
+ for tid in list(direct):
153
+ seen: set[str] = set()
154
+ queue = deque(direct[tid])
155
+ while queue:
156
+ node = queue.popleft()
157
+ if node in seen:
158
+ continue
159
+ seen.add(node)
160
+ queue.extend(direct.get(node, ()))
161
+ out[tid] = seen
162
+ return out
163
+
164
+
165
+ def _placeholder_ref(value: Any) -> str | None:
166
+ if not isinstance(value, str):
167
+ return None
168
+ match = PLACEHOLDER_RE.fullmatch(value.strip())
169
+ return match.group(1) if match else None
170
+
171
+
172
+ def _last_table_output(result: Any) -> Any | None:
173
+ """The referenced task's representative output (matches TaskRunner's
174
+ `outputs[-1]` resolution), if it is a non-empty table."""
175
+ if result is None or not result.outputs:
176
+ return None
177
+ out = result.outputs[-1]
178
+ if out.kind != "table" or not out.columns or not out.rows:
179
+ return None
180
+ return out
181
+
182
+
183
+ def _all_null_columns(table: Any) -> list[str]:
184
+ cols: list[str] = []
185
+ for i, name in enumerate(table.columns):
186
+ if all(row[i] is None for row in table.rows if i < len(row)):
187
+ cols.append(name)
188
+ return cols
189
+
190
+
191
+ def _chart_issues(envelope: dict[str, Any]) -> list[str]:
192
+ """§4.6 spec checks on a dataeyond.chart.v1 envelope. Purely structural —
193
+ the spec builder is deterministic, so an issue here means the *plan args*
194
+ picked a bad shape (wrong column, oversized category set), not a tool bug."""
195
+ issues: list[str] = []
196
+ chart_type = envelope.get("chart_type", "chart")
197
+ plotly = envelope.get("plotly")
198
+ traces = plotly.get("data") if isinstance(plotly, dict) else None
199
+ if not isinstance(traces, list) or not traces:
200
+ return [f"{chart_type} chart has no data traces"]
201
+
202
+ for trace in traces:
203
+ if not isinstance(trace, dict):
204
+ continue
205
+ name = trace.get("name")
206
+ label = f"series {name!r}" if name else "a series"
207
+ xs = trace.get("x") if trace.get("x") is not None else trace.get("labels")
208
+ ys = trace.get("y") if trace.get("y") is not None else trace.get("values")
209
+ xs = xs if isinstance(xs, list) else []
210
+ ys = ys if isinstance(ys, list) else []
211
+ if not xs or not ys or all(v is None for v in ys):
212
+ issues.append(f"{chart_type} chart: {label} is empty")
213
+ continue
214
+ if len(xs) != len(ys):
215
+ issues.append(
216
+ f"{chart_type} chart: {label} has mismatched lengths "
217
+ f"(x={len(xs)}, y={len(ys)})"
218
+ )
219
+ if chart_type == "bar" and len(xs) > _BAR_CATEGORY_CAP:
220
+ issues.append(
221
+ f"bar chart: {label} has {len(xs)} categories "
222
+ f"(> {_BAR_CATEGORY_CAP}) — unreadable; aggregate or top-N first"
223
+ )
224
+ if chart_type == "pie" and len(xs) > _PIE_CATEGORY_CAP:
225
+ issues.append(
226
+ f"pie chart has {len(xs)} slices (> {_PIE_CATEGORY_CAP}) — "
227
+ "unreadable; group the tail into 'other' or use a bar chart"
228
+ )
229
+ if chart_type in ("bar", "line", "scatter") and any(
230
+ v is not None and not isinstance(v, int | float) for v in ys
231
+ ):
232
+ issues.append(f"{chart_type} chart: {label} has non-numeric y values")
233
+ return issues
src/agents/slow_path/coordinator.py CHANGED
@@ -18,9 +18,10 @@ from ..planner.contracts import BusinessContext, ToolRegistry
18
  from ..planner.inputs import Constraints
19
  from ..planner.schemas import TaskList
20
  from ..planner.service import PlannerService
21
- from ..refusals import data_gap_message
22
  from .assembler import Assembler
23
- from .schemas import AnalysisRecord, AssembledOutput
 
24
  from .task_runner import TaskRunner
25
 
26
 
@@ -70,14 +71,52 @@ class SlowPathCoordinator:
70
  run_state = await self._task_runner.run(
71
  task_list, business_context_id=context.project_id
72
  )
 
 
 
 
 
 
 
73
  if progress:
74
  await progress("Composing the answer…")
75
  asm_kw = {"callbacks": assembler_callbacks} if assembler_callbacks else {}
76
  return await self._assembler.assemble(
77
- run_state, context, question=query, reply_language=reply_language, **asm_kw
 
78
  )
79
 
80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  def _infeasible_output(
82
  task_list: TaskList, context: BusinessContext, reply_language: str | None
83
  ) -> AssembledOutput:
 
18
  from ..planner.inputs import Constraints
19
  from ..planner.schemas import TaskList
20
  from ..planner.service import PlannerService
21
+ from ..refusals import data_gap_message, run_failure_message
22
  from .assembler import Assembler
23
+ from .checkpoint import assess
24
+ from .schemas import AnalysisRecord, AssembledOutput, RunState
25
  from .task_runner import TaskRunner
26
 
27
 
 
71
  run_state = await self._task_runner.run(
72
  task_list, business_context_id=context.project_id
73
  )
74
+ # S1a quality checkpoint (SPINE_V2_PLAN §3): deterministic, 0 LLM,
75
+ # never-throw. `failed` short-circuits to an honest deterministic answer
76
+ # (no Assembler call); `degraded` flags flow into the Assembler's input so
77
+ # the narrative names what specifically was affected; `ok` changes nothing.
78
+ assessment = assess(run_state, task_list)
79
+ if assessment.overall == "failed":
80
+ return _run_failure_output(task_list, run_state, context, reply_language)
81
  if progress:
82
  await progress("Composing the answer…")
83
  asm_kw = {"callbacks": assembler_callbacks} if assembler_callbacks else {}
84
  return await self._assembler.assemble(
85
+ run_state, context, question=query, reply_language=reply_language,
86
+ assessment=assessment, **asm_kw
87
  )
88
 
89
 
90
+ def _run_failure_output(
91
+ task_list: TaskList,
92
+ run_state: RunState,
93
+ context: BusinessContext,
94
+ reply_language: str | None,
95
+ ) -> AssembledOutput:
96
+ """Every task failed (S1a CK1): answer honestly and deterministically — same
97
+ shape as the infeasible path. The record carries no tasks_run/results, so it
98
+ is non-substantive: it can never satisfy the report floor."""
99
+ errors = [r.error for r in run_state.results.values() if r.error]
100
+ # The first error is usually the root cause (later tasks fail on the missing
101
+ # upstream); one specific reason beats a list of cascaded ones.
102
+ reason = errors[0] if errors else None
103
+ return AssembledOutput(
104
+ chat_answer=run_failure_message(reason, reply_language),
105
+ analysis_record=AnalysisRecord(
106
+ goal_restated=task_list.goal_restated,
107
+ findings=[],
108
+ caveats=errors or ["all analysis steps failed"],
109
+ data_used=[],
110
+ open_questions=list(task_list.open_questions),
111
+ tasks_run=[],
112
+ results_snapshot={},
113
+ plan_id=task_list.plan_id,
114
+ business_context_id=context.project_id,
115
+ created_at=datetime.now(UTC),
116
+ ),
117
+ )
118
+
119
+
120
  def _infeasible_output(
121
  task_list: TaskList, context: BusinessContext, reply_language: str | None
122
  ) -> AssembledOutput:
src/agents/slow_path/prompt.py CHANGED
@@ -11,7 +11,7 @@ from __future__ import annotations
11
 
12
  from ..planner.contracts import BusinessContext, ToolOutput
13
  from ..planner.prompt import render_business_context
14
- from .schemas import RunState, TaskResult
15
 
16
  _MAX_ROWS = 20
17
 
@@ -48,20 +48,56 @@ def _render_output(output: ToolOutput) -> str:
48
  )
49
  more = "" if len(rows) <= _MAX_ROWS else f" … (+{len(rows) - _MAX_ROWS} more rows)"
50
  return f"({output.tool}) table [{header}]: {preview}{more}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  meta = f" meta={output.meta}" if output.meta else ""
52
  return f"({output.tool}) {output.kind}: {output.value}{meta}"
53
 
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  def build_assembler_prompt(
56
  run_state: RunState,
57
  context: BusinessContext,
58
  question: str | None = None,
59
  reply_language: str | None = None,
 
60
  ) -> str:
61
  sections = [
62
  f"# Business context\n\n{render_business_context(context)}",
63
  f"# Analysis results\n\n{render_run_state(run_state)}",
64
  ]
 
 
 
 
65
  if question:
66
  sections.append(f"# Original question\n\n{question}")
67
  if reply_language:
 
11
 
12
  from ..planner.contracts import BusinessContext, ToolOutput
13
  from ..planner.prompt import render_business_context
14
+ from .schemas import RunAssessment, RunState, TaskResult
15
 
16
  _MAX_ROWS = 20
17
 
 
48
  )
49
  more = "" if len(rows) <= _MAX_ROWS else f" … (+{len(rows) - _MAX_ROWS} more rows)"
50
  return f"({output.tool}) table [{header}]: {preview}{more}"
51
+ if output.kind == "chart" and isinstance(output.value, dict):
52
+ # W2 (SPINE_V2_PLAN §4.3): one-line summary only — the raw spec's x/y
53
+ # arrays would flood the prompt. The chart itself reaches the user via
54
+ # GET /api/v1/charts; the narrative only refers to it.
55
+ chart_type = output.value.get("chart_type", "chart")
56
+ title = output.value.get("title") or ""
57
+ plotly = output.value.get("plotly")
58
+ traces = plotly.get("data") if isinstance(plotly, dict) else None
59
+ n_traces = len(traces) if isinstance(traces, list) else 0
60
+ return (
61
+ f"({output.tool}) chart: {chart_type} \"{title}\" ({n_traces} series) — "
62
+ "shown to the user alongside this answer; refer to it, do not restate "
63
+ "its data points"
64
+ )
65
  meta = f" meta={output.meta}" if output.meta else ""
66
  return f"({output.tool}) {output.kind}: {output.value}{meta}"
67
 
68
 
69
+ def render_assessment(assessment: RunAssessment) -> str | None:
70
+ """The S1a checkpoint's flags as a short block (SPINE_V2_PLAN §3) — only tasks
71
+ that carry specific notes; a clean run renders nothing (no behavior change)."""
72
+ flagged = [t for t in assessment.tasks if t.notes]
73
+ if not flagged:
74
+ return None
75
+ lines = [f"Overall execution: {assessment.overall}."]
76
+ for t in flagged:
77
+ lines.extend(f"- [{t.verdict}] {t.task_id}: {note}" for note in t.notes)
78
+ lines.append(
79
+ "Name these limitations specifically in the answer — say what was affected "
80
+ "and how it scopes the findings. Do not present partial results as complete, "
81
+ "and never fall back to a generic \"couldn't compute\"."
82
+ )
83
+ return "\n".join(lines)
84
+
85
+
86
  def build_assembler_prompt(
87
  run_state: RunState,
88
  context: BusinessContext,
89
  question: str | None = None,
90
  reply_language: str | None = None,
91
+ assessment: RunAssessment | None = None,
92
  ) -> str:
93
  sections = [
94
  f"# Business context\n\n{render_business_context(context)}",
95
  f"# Analysis results\n\n{render_run_state(run_state)}",
96
  ]
97
+ if assessment is not None:
98
+ block = render_assessment(assessment)
99
+ if block:
100
+ sections.append(f"# Execution assessment\n\n{block}")
101
  if question:
102
  sections.append(f"# Original question\n\n{question}")
103
  if reply_language:
src/agents/slow_path/schemas.py CHANGED
@@ -53,6 +53,41 @@ class RunState(BaseModel):
53
  open_questions: list[str] = Field(default_factory=list)
54
 
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  # --------------------------------------------------------------------------- #
57
  # Assembled output (Assembler -> Orchestrator / memory) — §8.3
58
  # --------------------------------------------------------------------------- #
 
53
  open_questions: list[str] = Field(default_factory=list)
54
 
55
 
56
+ # --------------------------------------------------------------------------- #
57
+ # Quality checkpoint (S1a) — checkpoint.assess() -> Assembler (SPINE_V2_PLAN §3)
58
+ # --------------------------------------------------------------------------- #
59
+
60
+ AssessmentVerdict = Literal["ok", "degraded", "failed"]
61
+
62
+
63
+ class RepairCandidate(BaseModel):
64
+ """One checkpoint flag a future targeted repair (S1b, gated) could act on.
65
+
66
+ Until S1b is approved this is telemetry: every candidate is also structlog'd
67
+ so the team can measure whether a repair pass would pay for itself.
68
+ """
69
+
70
+ task_id: str
71
+ reason: str
72
+
73
+
74
+ class TaskAssessment(BaseModel):
75
+ task_id: str
76
+ verdict: AssessmentVerdict
77
+ notes: list[str] = Field(default_factory=list) # specific, human-readable flags
78
+
79
+
80
+ class RunAssessment(BaseModel):
81
+ """Deterministic post-execution verdict (0 LLM). `overall` drives the
82
+ coordinator: `failed` -> honest deterministic failure answer (no Assembler
83
+ call); `degraded` -> the flags are rendered into the Assembler's input so the
84
+ narrative names *what specifically* was affected; `ok` -> no behavior change."""
85
+
86
+ overall: AssessmentVerdict
87
+ tasks: list[TaskAssessment] = Field(default_factory=list)
88
+ repair_candidates: list[RepairCandidate] = Field(default_factory=list)
89
+
90
+
91
  # --------------------------------------------------------------------------- #
92
  # Assembled output (Assembler -> Orchestrator / memory) — §8.3
93
  # --------------------------------------------------------------------------- #
src/agents/slow_path/task_runner.py CHANGED
@@ -126,16 +126,28 @@ class TaskRunner:
126
 
127
  @staticmethod
128
  def _resolve_value(value: Any, results: dict[str, TaskResult]) -> Any:
129
- # A data arg is exactly a "${t<id>}" placeholder (Pattern A); resolve it to
130
- # the referenced task's representative output (its last ToolOutput).
131
- # Materializing that envelope into a DataFrame is the invoker's job.
 
 
132
  if isinstance(value, str):
133
  match = PLACEHOLDER_RE.fullmatch(value.strip())
134
  if match:
135
- upstream = results.get(match.group(1))
 
 
 
 
136
  if upstream is None or not upstream.outputs:
137
- return None
138
- return upstream.outputs[-1]
 
 
 
 
 
 
139
  return value
140
 
141
  def _validate_args(self, tool: str, resolved: dict[str, Any]) -> str | None:
@@ -156,6 +168,27 @@ class TaskRunner:
156
  return ToolOutput(tool=tool, kind="error", error=f"invoker raised: {exc}")
157
 
158
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  def _label(outputs: list[ToolOutput]) -> TaskStatus:
160
  if not outputs:
161
  return "failure"
 
126
 
127
  @staticmethod
128
  def _resolve_value(value: Any, results: dict[str, TaskResult]) -> Any:
129
+ # Resolve "${t<id>}" placeholders from upstream results, recursing into
130
+ # containers so a placeholder NESTED inside a tool arg is resolved too —
131
+ # not just a whole-value "${t2}" data arg. This is what lets a
132
+ # "${t2.customer_id}" filter value inside a retrieve_data IR become a real
133
+ # list (value-handoff): "cari-lalu-lookup" / anti-join across two steps.
134
  if isinstance(value, str):
135
  match = PLACEHOLDER_RE.fullmatch(value.strip())
136
  if match:
137
+ # group(1) is "t<id>" (whole output → Pattern A, materialized to a
138
+ # DataFrame by the invoker) or "t<id>.<column>" (value-handoff →
139
+ # the list of that column's upstream values).
140
+ task_id, _, column = match.group(1).partition(".")
141
+ upstream = results.get(task_id)
142
  if upstream is None or not upstream.outputs:
143
+ return [] if column else None
144
+ out = upstream.outputs[-1]
145
+ return _column_values(out, column) if column else out
146
+ return value
147
+ if isinstance(value, dict):
148
+ return {k: TaskRunner._resolve_value(v, results) for k, v in value.items()}
149
+ if isinstance(value, list):
150
+ return [TaskRunner._resolve_value(v, results) for v in value]
151
  return value
152
 
153
  def _validate_args(self, tool: str, resolved: dict[str, Any]) -> str | None:
 
168
  return ToolOutput(tool=tool, kind="error", error=f"invoker raised: {exc}")
169
 
170
 
171
+ def _column_values(out: ToolOutput, column: str) -> list[Any]:
172
+ """Ordered, de-duplicated values of `column` from a table ToolOutput.
173
+
174
+ Backs value-handoff: a "${t<id>.<column>}" reference resolves to the SET of
175
+ that column's upstream values so an `in`/`not_in` filter can look up / anti-
176
+ join by a prior step's result (e.g. "customers who never ordered" =
177
+ `CustomerID not_in ${orders.customer_id}`). Empty list when the column is
178
+ absent or the output isn't a table — an `in` then matches nothing and a
179
+ `not_in` matches everything, the correct set semantics for an empty reference.
180
+ """
181
+ columns = out.columns or []
182
+ if column not in columns:
183
+ return []
184
+ idx = columns.index(column)
185
+ seen: dict[Any, None] = {}
186
+ for row in out.rows or []:
187
+ if idx < len(row):
188
+ seen.setdefault(row[idx], None)
189
+ return list(seen)
190
+
191
+
192
  def _label(outputs: list[ToolOutput]) -> TaskStatus:
193
  if not outputs:
194
  return "failure"
src/api/v1/charts.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Charts endpoint — user-facing chart retrieval for one turn (S2 visualization,
2
+ SPINE_V2_PLAN §4.4/§4.5).
3
+
4
+ `GET /api/v1/charts?message_id=` returns every chart a `render_chart` tool call
5
+ produced during one assistant turn, as `dataeyond.chart.v1` envelopes
6
+ (SPINE_V2_PLAN §4.2). Rows are written by the chat pipeline right before the `done`
7
+ SSE event; the FE fires this GET on `done` with the `message_id` from that event.
8
+ Lookup is by `message_id` alone (Python-minted UUID4 — lead decision 2026-07-13).
9
+
10
+ Every response is HTTP 200 with an explicit `status` marker (lead ask 2026-07-13):
11
+ - `success` — ≥1 chart; render them.
12
+ - `empty` — the turn completed but produced no charts (the common case).
13
+ - `not_found` — no completed turn is known for this message_id (mistyped/stale
14
+ id, or an error turn — those never write a row). Distinguished
15
+ from `empty` via the turn's traceability row.
16
+
17
+ No auth — Go fronts Python.
18
+ """
19
+
20
+ from typing import Literal
21
+
22
+ from fastapi import APIRouter, Query
23
+ from pydantic import BaseModel
24
+
25
+ from src.charts import ChartRecord, PostgresChartStore
26
+ from src.middlewares.logging import get_logger, log_execution
27
+
28
+ logger = get_logger("charts_api")
29
+
30
+ router = APIRouter(prefix="/api/v1", tags=["Charts"])
31
+
32
+ # Warm, process-shared store (mirrors the chat endpoints' module-level instances).
33
+ _store = PostgresChartStore()
34
+
35
+
36
+ class ChartsResponse(BaseModel):
37
+ status: Literal["success", "empty", "not_found"]
38
+ message: str
39
+ count: int
40
+ charts: list[ChartRecord]
41
+
42
+
43
+ @router.get("/charts", response_model=ChartsResponse)
44
+ @log_execution(logger)
45
+ async def get_charts(
46
+ message_id: str = Query(
47
+ ..., description="Assistant turn id, taken from the `done` SSE event"
48
+ ),
49
+ ) -> ChartsResponse:
50
+ """Fetch every chart for one turn. Always 200 — the `status` field carries the
51
+ outcome, so the FE can call this unconditionally on every `done`."""
52
+ charts = await _store.list_for_message(message_id)
53
+ if charts:
54
+ return ChartsResponse(
55
+ status="success",
56
+ message=f"{len(charts)} chart(s) for this message.",
57
+ count=len(charts),
58
+ charts=charts,
59
+ )
60
+ if await _store.turn_exists(message_id):
61
+ return ChartsResponse(
62
+ status="empty",
63
+ message="This message completed without producing charts.",
64
+ count=0,
65
+ charts=[],
66
+ )
67
+ return ChartsResponse(
68
+ status="not_found",
69
+ message="No completed turn is known for this message_id.",
70
+ count=0,
71
+ charts=[],
72
+ )
src/charts/__init__.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Charts (S2 visualization, SPINE_V2_PLAN §4.4): `render_chart` output persistence.
2
+
3
+ Chart envelopes produced by the `render_chart` tool (`dataeyond.chart.v1`, SPINE_V2_PLAN
4
+ §4.2) are persisted through a `ChartStore` right before the `done` SSE event, and served
5
+ by `GET /api/v1/charts`. SSE stays text-only — charts are never embedded in the streamed
6
+ answer.
7
+ """
8
+
9
+ from .store import (
10
+ ChartRecord,
11
+ ChartStore,
12
+ NullChartStore,
13
+ PostgresChartStore,
14
+ )
15
+
16
+ __all__ = [
17
+ "ChartRecord",
18
+ "ChartStore",
19
+ "NullChartStore",
20
+ "PostgresChartStore",
21
+ ]
src/charts/store.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ChartStore — the seam the chat pipeline persists `render_chart` outputs through
2
+ (S2 visualization, SPINE_V2_PLAN §4.4).
3
+
4
+ `ChatHandler._run_slow_path` scans a completed `AnalysisRecord.results_snapshot` for
5
+ `ToolOutput(kind="chart")` entries and flushes one `MessageChartRow` per chart through
6
+ this seam, right before the `done` SSE event (mirrors the traceability flush). Unlike
7
+ traceability's one row per turn, a turn with multiple `render_chart` calls writes
8
+ multiple rows sharing one `message_id`. `GET /api/v1/charts` reads them back by
9
+ (analysis_id, message_id).
10
+
11
+ - `NullChartStore` logs the envelope and stores nothing (tests / disabled persistence).
12
+ - `PostgresChartStore` writes one `message_charts` row per chart (dedorch,
13
+ `AsyncSessionLocal`), mirroring `PostgresTraceabilityStore`.
14
+
15
+ `save` must NEVER raise on the caller's path — a chart-persist failure must not break
16
+ the user's answer. `list_for_message` is the endpoint read; an empty list is a valid
17
+ result (chartless turn), not an error.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import uuid
23
+ from datetime import datetime
24
+ from typing import Protocol, runtime_checkable
25
+
26
+ from pydantic import BaseModel
27
+ from sqlalchemy import select
28
+
29
+ from src.db.postgres.connection import AsyncSessionLocal
30
+ from src.db.postgres.models import MessageChartRow, MessageTraceabilityRow
31
+ from src.middlewares.logging import get_logger
32
+
33
+ logger = get_logger("charts_store")
34
+
35
+
36
+ class ChartRecord(BaseModel):
37
+ """One persisted chart, as served by `GET /api/v1/charts`."""
38
+
39
+ chart_id: str
40
+ chart_type: str
41
+ title: str | None = None
42
+ spec: dict
43
+ created_at: datetime
44
+
45
+
46
+ @runtime_checkable
47
+ class ChartStore(Protocol):
48
+ """Persist + read `render_chart` outputs for one assistant `message_id`.
49
+
50
+ `save` must never raise on the caller's path. `list_for_message` returns every
51
+ chart for one turn (possibly empty — a turn need not have asked for a chart);
52
+ the lookup is by `message_id` alone (Python-minted UUID4, globally unique —
53
+ lead decision 2026-07-13). `turn_exists` says whether the turn flushed a
54
+ traceability row, so the endpoint can tell "no charts" from "unknown id".
55
+ """
56
+
57
+ async def save(
58
+ self,
59
+ *,
60
+ message_id: str,
61
+ analysis_id: str,
62
+ user_id: str,
63
+ record_id: str | None,
64
+ envelope: dict,
65
+ ) -> None: ...
66
+
67
+ async def list_for_message(self, message_id: str) -> list[ChartRecord]: ...
68
+
69
+ async def turn_exists(self, message_id: str) -> bool: ...
70
+
71
+
72
+ class NullChartStore:
73
+ """No-op store: logs the envelope, persists nothing. Reads return `[]`."""
74
+
75
+ async def save(
76
+ self,
77
+ *,
78
+ message_id: str,
79
+ analysis_id: str,
80
+ user_id: str,
81
+ record_id: str | None,
82
+ envelope: dict,
83
+ ) -> None:
84
+ logger.info(
85
+ "chart produced (not persisted — NullChartStore)",
86
+ message_id=message_id,
87
+ chart_type=envelope.get("chart_type", "unknown"),
88
+ )
89
+
90
+ async def list_for_message(self, message_id: str) -> list[ChartRecord]:
91
+ return []
92
+
93
+ async def turn_exists(self, message_id: str) -> bool:
94
+ return False
95
+
96
+
97
+ class PostgresChartStore:
98
+ """Writes/reads `message_charts` rows. One insert per chart (not an upsert)."""
99
+
100
+ async def save(
101
+ self,
102
+ *,
103
+ message_id: str,
104
+ analysis_id: str,
105
+ user_id: str,
106
+ record_id: str | None,
107
+ envelope: dict,
108
+ ) -> None:
109
+ try:
110
+ async with AsyncSessionLocal() as session:
111
+ row = MessageChartRow(
112
+ id=str(uuid.uuid4()),
113
+ message_id=message_id,
114
+ analysis_id=analysis_id,
115
+ user_id=user_id,
116
+ record_id=record_id,
117
+ chart_type=envelope.get("chart_type", "unknown"),
118
+ title=envelope.get("title"),
119
+ spec=envelope,
120
+ )
121
+ session.add(row)
122
+ await session.commit()
123
+ logger.info(
124
+ "chart persisted",
125
+ message_id=message_id,
126
+ analysis_id=analysis_id,
127
+ chart_type=envelope.get("chart_type", "unknown"),
128
+ )
129
+ except Exception as exc: # never break the user's answer
130
+ logger.error(
131
+ "chart persist failed",
132
+ message_id=message_id,
133
+ error=str(exc),
134
+ )
135
+
136
+ async def list_for_message(self, message_id: str) -> list[ChartRecord]:
137
+ # message_id-only lookup (lead decision 2026-07-13). NOTE for the Harry
138
+ # migration: the manual DDL's composite index (analysis_id, message_id)
139
+ # does not serve this predicate — an additive index on (message_id) is
140
+ # part of the handoff.
141
+ async with AsyncSessionLocal() as session:
142
+ result = await session.execute(
143
+ select(MessageChartRow)
144
+ .where(MessageChartRow.message_id == message_id)
145
+ .order_by(MessageChartRow.created_at)
146
+ )
147
+ rows = result.scalars().all()
148
+ return [
149
+ ChartRecord(
150
+ chart_id=row.id,
151
+ chart_type=row.chart_type,
152
+ title=row.title,
153
+ spec=row.spec,
154
+ created_at=row.created_at,
155
+ )
156
+ for row in rows
157
+ ]
158
+
159
+ async def turn_exists(self, message_id: str) -> bool:
160
+ """True iff the turn flushed its traceability row (written before `done`).
161
+
162
+ Lets the endpoint tri-state a zero-chart GET: `empty` (completed turn, no
163
+ charts — the common case) vs `not_found` (unknown/mistyped id, or an error
164
+ turn, which never writes traceability). PK lookup — cheap.
165
+ """
166
+ async with AsyncSessionLocal() as session:
167
+ result = await session.execute(
168
+ select(MessageTraceabilityRow.message_id).where(
169
+ MessageTraceabilityRow.message_id == message_id
170
+ )
171
+ )
172
+ return result.scalar_one_or_none() is not None
src/config/prompts/planner.md CHANGED
@@ -20,7 +20,9 @@ only a `TaskList` object that conforms to the provided schema.
20
  section. Copy the stable ids verbatim — downstream validation does a literal
21
  id lookup, so a paraphrased name fails.
22
  5. **No modeling in v1.** There are no modeling tools. Do not emit `modeling`
23
- tasks. The product is descriptive/diagnostic only — no predictions, no charts.
 
 
24
  6. **Never re-purpose a column as a different business measure.** A column means
25
  what the catalog says it means — do not alias one concept as another to force
26
  an answer (e.g. selecting an availability percentage AS "revenue", or a
@@ -35,6 +37,21 @@ only a `TaskList` object that conforms to the provided schema.
35
  is noise, not helpfulness. A multi-part task list is correct ONLY when the
36
  question itself has multiple parts (e.g. "trend by region AND what's unusual").
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  # How to plan
39
 
40
  - **Smallest plan that answers the question.** Do not exceed `Constraints.max_tasks`.
@@ -74,6 +91,24 @@ only a `TaskList` object that conforms to the provided schema.
74
  `orders.total_amount`); if they genuinely aren't linked, say the data isn't
75
  connected rather than guessing. Prefer an existing measure column over
76
  recomputing. Joins are database-only — not available for tabular/file sources.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  - **Two measures per entity ("which X has both the worst A and the biggest B").**
78
  Compute each measure in its OWN grouped `retrieve_data` task (one aggregate per
79
  entity each), then align them with `analyze_merge`:
@@ -82,6 +117,20 @@ only a `TaskList` object that conforms to the provided schema.
82
  to a task's LAST output, so two retrievals inside one task lose the first
83
  table. The merged table (one row per entity, both measures) answers the
84
  question, or feeds a further `analyze_*` step.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  - **Mixing structured + unstructured.** If qualitative context helps, add a
86
  `retrieve_knowledge` task against an unstructured source listed in the catalog.
87
  - **CRISP-DM stages.** Tag each task with the stage it serves:
 
20
  section. Copy the stable ids verbatim — downstream validation does a literal
21
  id lookup, so a paraphrased name fails.
22
  5. **No modeling in v1.** There are no modeling tools. Do not emit `modeling`
23
+ tasks. The product is descriptive/diagnostic only — no predictions. A chart
24
+ exists ONLY as a `render_chart` tail on an explicit user ask (see "Charts"
25
+ under "How to plan") — never speculatively.
26
  6. **Never re-purpose a column as a different business measure.** A column means
27
  what the catalog says it means — do not alias one concept as another to force
28
  an answer (e.g. selecting an availability percentage AS "revenue", or a
 
37
  is noise, not helpfulness. A multi-part task list is correct ONLY when the
38
  question itself has multiple parts (e.g. "trend by region AND what's unusual").
39
 
40
+ # Recipes — the named workflows
41
+
42
+ Classify the question into ONE recipe, then instantiate it. Most questions are a
43
+ recipe verbatim; a genuinely multi-part question composes recipes.
44
+
45
+ | Recipe | The question asks for… | Chain |
46
+ |---|---|---|
47
+ | R1 descriptive | a summary/distribution of columns | `retrieve_data` → `analyze_descriptive` |
48
+ | R2 aggregate / top-N | totals or averages per group, "top N by …" | ONE grouped `retrieve_data` IR (± `analyze_aggregate`) |
49
+ | R3 trend | movement over time | `retrieve_data` → `analyze_trend` |
50
+ | R4 correlation | the relationship between numeric columns | `retrieve_data` → `analyze_correlation` |
51
+ | R5 two-metric merge | "which X has both A and B" | `retrieve_data` ×2 → `analyze_merge` → … |
52
+ | R6 infeasible | a measure/entity no catalog column holds | `tasks: []` + `infeasible_reason` |
53
+ | viz tail | …any of the above, WITH an explicit ask to chart it | R1–R5 + a `render_chart` tail |
54
+
55
  # How to plan
56
 
57
  - **Smallest plan that answers the question.** Do not exceed `Constraints.max_tasks`.
 
91
  `orders.total_amount`); if they genuinely aren't linked, say the data isn't
92
  connected rather than guessing. Prefer an existing measure column over
93
  recomputing. Joins are database-only — not available for tabular/file sources.
94
+ - **Look up or exclude by a prior step's values (value-handoff).** To filter one
95
+ task by the values another task produced — WITHOUT a join — put
96
+ `"${t<id>.<alias>}"` as a filter `value` and use op `in` (keep matches) or
97
+ `not_in` (exclude them). `<alias>` is the SELECT alias of the referenced task's
98
+ column; it resolves to the LIST of that column's values. `value_type` stays the
99
+ element type (e.g. `"string"`), never `"list"`. Set `depends_on`. This works on
100
+ tabular AND database sources, and is the ONLY way to relate two tables on a
101
+ tabular/file source (no joins there). Two patterns:
102
+ - **Attribute of the top/bottom entity** ("name of the customer with the most
103
+ orders"): t1 = group by the id + aggregate + `order_by` + `limit: 1` to get the
104
+ winning id; t2 = `retrieve_data` selecting the attribute (e.g. the name) where
105
+ `{"op": "in", "value": "${t1.<id_alias>}", "value_type": "string", ...}`.
106
+ - **"Never / not among" (anti-join)** ("customers who never ordered"): t1 =
107
+ select the id column from the transaction table (e.g. all `Orders.CustomerID`);
108
+ t2 = select from the entity table where
109
+ `{"op": "not_in", "value": "${t1.<id_alias>}", "value_type": "string", ...}`.
110
+ Do NOT use `analyze_merge` (a left merge) for this — it fans out to one row per
111
+ match and does not isolate the non-matching rows, so the result is wrong.
112
  - **Two measures per entity ("which X has both the worst A and the biggest B").**
113
  Compute each measure in its OWN grouped `retrieve_data` task (one aggregate per
114
  entity each), then align them with `analyze_merge`:
 
117
  to a task's LAST output, so two retrievals inside one task lose the first
118
  table. The merged table (one row per entity, both measures) answers the
119
  question, or feeds a further `analyze_*` step.
120
+ - **Charts (`render_chart`) only on an explicit ask.** Add a `render_chart` tail
121
+ ONLY when the user explicitly asks to see a chart — "plot", "chart", "graph",
122
+ "visualize", "diagram" (ID: "grafik", "diagram", "visualisasikan", "buatkan
123
+ grafik/diagram") — NEVER speculatively on a plain data question. It is always
124
+ the LAST step: its `data` takes a `"${t<id>}"` from a task that yields a
125
+ **table** (a grouped `retrieve_data` or a table-producing `analyze_*` — never
126
+ stats/series/metadata), and `x`/`y` are that table's column aliases. Chart the
127
+ already-aggregated table (one row per category/period), not raw rows. Pick
128
+ `chart_type` by the question: `bar` (magnitude per category), `line` (over
129
+ time), `pie` (share of a small whole), `scatter` (two numeric columns).
130
+ A chart ask NEVER relaxes feasibility (rule 6): if the asked-for dimension or
131
+ measure has no catalog column, the question is **infeasible** — never chart a
132
+ stand-in column aliased under the asked-for name (e.g. never select a status
133
+ column AS "region" because the user asked for a chart by region).
134
  - **Mixing structured + unstructured.** If qualitative context helps, add a
135
  `retrieve_knowledge` task against an unstructured source listed in the catalog.
136
  - **CRISP-DM stages.** Tag each task with the stage it serves:
src/db/postgres/models.py CHANGED
@@ -284,3 +284,36 @@ class MessageTraceabilityRow(Base):
284
  intent = Column(String, nullable=False)
285
  data = Column(JSONB, nullable=False) # full TraceabilityPayload (source of truth)
286
  created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  intent = Column(String, nullable=False)
285
  data = Column(JSONB, nullable=False) # full TraceabilityPayload (source of truth)
286
  created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
287
+
288
+
289
+ class MessageChartRow(Base):
290
+ """One row per chart produced by `render_chart` — S2 visualization (SPINE_V2_PLAN §4.4).
291
+
292
+ `spec` holds the full `dataeyond.chart.v1` envelope (SPINE_V2_PLAN §4.2) exactly as
293
+ returned by the tool — `{schema, chart_type, title, plotly: {data, layout}}` — it is
294
+ the source of truth the FE renders with
295
+ `Plotly.newPlot(el, spec.plotly.data, spec.plotly.layout)`. Unlike
296
+ `MessageTraceabilityRow` (one row per turn), **multiple rows can share one
297
+ `message_id`** — one row per chart a turn produced. Written before the `done` SSE
298
+ event and served by `GET /api/v1/charts` (SPINE_V2_PLAN §4.4/§4.5).
299
+
300
+ OWNERSHIP / HANDOFF (SPINE_V2_PLAN §4.4, 2026-07-13): **Python-owned for now**, the
301
+ same pattern as `report_inputs` / `message_traceability`. Post-cutover `init_db` no
302
+ longer runs `create_all`, so the table is created by a one-time manual DDL run
303
+ against dedorch (Rifqi, 2026-07-13); the finalized schema goes to Harry so the
304
+ dedorch migration re-creates it later (`analysis_id` gains the FK to `analyses(id)`
305
+ there).
306
+ """
307
+ __tablename__ = "message_charts"
308
+
309
+ # Client-minted default — the DDL's `gen_random_uuid()` default never fires from
310
+ # Python (we always pass `id` explicitly, but the default is here for parity).
311
+ id = Column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
312
+ message_id = Column(String, nullable=False, index=True) # turn id; multiple rows per turn
313
+ analysis_id = Column(UUID(as_uuid=False), nullable=False, index=True) # analysis session id
314
+ user_id = Column(String, nullable=False)
315
+ record_id = Column(String, nullable=True)
316
+ chart_type = Column(String, nullable=False)
317
+ title = Column(String, nullable=True)
318
+ spec = Column(JSONB, nullable=False) # full dataeyond.chart.v1 envelope (source of truth)
319
+ created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
src/query/compiler/pandas.py CHANGED
@@ -136,6 +136,19 @@ def _like_to_regex(pattern: str) -> str:
136
  return "".join(parts)
137
 
138
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  def _apply_filters(
140
  df: pd.DataFrame,
141
  filters: list[FilterClause],
@@ -161,9 +174,9 @@ def _apply_filters(
161
  elif op == ">=":
162
  mask &= series >= val
163
  elif op == "in":
164
- mask &= series.isin(val)
165
  elif op == "not_in":
166
- mask &= ~series.isin(val)
167
  elif op == "is_null":
168
  mask &= series.isna()
169
  elif op == "is_not_null":
 
136
  return "".join(parts)
137
 
138
 
139
+ def _as_list(val: Any) -> list[Any]:
140
+ """Coerce a scalar to a single-element list for `isin`.
141
+
142
+ `in`/`not_in` values are normally lists, but a value-handoff (or a planner
143
+ single-value filter) can arrive as a scalar; pandas `.isin()` rejects a bare
144
+ scalar ("only list-like objects are allowed to be passed to isin()"). Wrapping
145
+ keeps both shapes working.
146
+ """
147
+ if isinstance(val, list | tuple | set):
148
+ return list(val)
149
+ return [val]
150
+
151
+
152
  def _apply_filters(
153
  df: pd.DataFrame,
154
  filters: list[FilterClause],
 
174
  elif op == ">=":
175
  mask &= series >= val
176
  elif op == "in":
177
+ mask &= series.isin(_as_list(val))
178
  elif op == "not_in":
179
+ mask &= ~series.isin(_as_list(val))
180
  elif op == "is_null":
181
  mask &= series.isna()
182
  elif op == "is_not_null":
src/query/executor/tabular.py CHANGED
@@ -193,12 +193,22 @@ def _resolve_blob_name(source: Source, table: Table) -> str:
193
  return parquet_blob_name(user_id, document_id, sheet_name)
194
 
195
 
 
 
 
 
 
 
 
 
 
 
196
  def _render_query(ir: QueryIR, cols_by_id: dict) -> str:
197
  from ..ir.models import AggSelect, ColumnSelect
198
  parts = ["df"]
199
  if ir.filters:
200
  conds = " & ".join(
201
- f'(df["{cols_by_id[f.column_id].name}"] {f.op} {f.value!r})'
202
  for f in ir.filters
203
  )
204
  parts.append(f"[{conds}]")
 
193
  return parquet_blob_name(user_id, document_id, sheet_name)
194
 
195
 
196
+ def _render_value(v: object, cap: int = 5) -> str:
197
+ """Render a filter value for the human-readable query string, truncating a
198
+ long list so a value-handoff `in`/`not_in` set doesn't dump thousands of ids
199
+ into the logs AND the user-facing traceability record (KM-691)."""
200
+ if isinstance(v, list | tuple) and len(v) > cap:
201
+ head = ", ".join(repr(x) for x in v[:cap])
202
+ return f"[{head}, … +{len(v) - cap} more]"
203
+ return repr(v)
204
+
205
+
206
  def _render_query(ir: QueryIR, cols_by_id: dict) -> str:
207
  from ..ir.models import AggSelect, ColumnSelect
208
  parts = ["df"]
209
  if ir.filters:
210
  conds = " & ".join(
211
+ f'(df["{cols_by_id[f.column_id].name}"] {f.op} {_render_value(f.value)})'
212
  for f in ir.filters
213
  )
214
  parts.append(f"[{conds}]")
src/tools/analytics/visualization.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """render_chart — declarative chart-spec builder (SPINE_V2_PLAN §4.1, S2).
2
+
3
+ First tool of the `render_*` family: turns an already-materialized DataFrame into
4
+ a Plotly-conformant JSON spec wrapped in the `dataeyond.chart.v1` envelope
5
+ (SPINE_V2_PLAN §4.2). The FE renders it with plotly.js
6
+ (`Plotly.newPlot(el, spec.plotly.data, spec.plotly.layout)`).
7
+
8
+ Deliberately NOT a code generator and NOT a plotly import (locked decision,
9
+ DEV_PLAN deferred row #26): the spec is a hand-built dict, so no generated code
10
+ ever executes and no new dependency lands. Style (colors, axis chrome) is the
11
+ fixed module preset below — never a planner/LLM decision (CoDA's "Design
12
+ Explorer" phase collapsed to a constant).
13
+
14
+ Pattern A, same as the `analyze_*` family: `data` is resolved to a DataFrame by
15
+ the invoker from an upstream table-kind output; this function never fetches.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import datetime as _dt
21
+ from decimal import Decimal
22
+
23
+ import numpy as np
24
+ import pandas as pd
25
+
26
+ from src.tools.analytics.descriptive import ColumnNotFoundError
27
+
28
+ # v1 chart types (SPINE_V2_PLAN §4.1). `pie` maps x -> labels, y -> values.
29
+ CHART_TYPES = ("bar", "line", "pie", "scatter")
30
+
31
+
32
+ class UnsupportedChartTypeError(ValueError):
33
+ """The requested chart_type is not in CHART_TYPES (error_code UNSUPPORTED_CHART_TYPE)."""
34
+
35
+
36
+ # --------------------------------------------------------------------------- #
37
+ # House style preset — fixed by design, not a planner argument.
38
+ #
39
+ # Colorway = the dataviz reference categorical palette (8 slots, light mode).
40
+ # The slot ORDER is the colorblind-safety mechanism (validated: worst adjacent
41
+ # CVD deltaE 24.2 vs the >=12 target) — series take slots in this fixed order,
42
+ # never cycled or re-picked. Backgrounds are transparent so the chart inherits
43
+ # the FE surface; inks/gridlines are the palette's chrome roles.
44
+ # --------------------------------------------------------------------------- #
45
+ COLORWAY = [
46
+ "#2a78d6", # blue
47
+ "#1baf7a", # aqua
48
+ "#eda100", # yellow
49
+ "#008300", # green
50
+ "#4a3aa7", # violet
51
+ "#e34948", # red
52
+ "#e87ba4", # magenta
53
+ "#eb6834", # orange
54
+ ]
55
+
56
+ _INK_SECONDARY = "#52514e" # body text
57
+ _INK_MUTED = "#898781" # axis titles / tick labels (mode-neutral)
58
+ _GRIDLINE = "#e1e0d9" # hairline grid
59
+ _BASELINE = "#c3c2b7" # axis line
60
+
61
+ LAYOUT_PRESET: dict[str, object] = {
62
+ "colorway": COLORWAY,
63
+ "font": {
64
+ "family": 'system-ui, -apple-system, "Segoe UI", sans-serif',
65
+ "size": 13,
66
+ "color": _INK_SECONDARY,
67
+ },
68
+ "paper_bgcolor": "rgba(0,0,0,0)",
69
+ "plot_bgcolor": "rgba(0,0,0,0)",
70
+ "margin": {"t": 48, "r": 16, "b": 48, "l": 56},
71
+ "bargap": 0.25, # keeps adjacent bars visually separated (mark-spec gap)
72
+ }
73
+
74
+ # Mark specs: thin marks — 2px lines, 8px markers.
75
+ _LINE_STYLE = {"width": 2}
76
+ _MARKER_STYLE = {"size": 8}
77
+
78
+
79
+ def _axis(label: str) -> dict[str, object]:
80
+ """Recessive axis chrome + the column name as the axis title."""
81
+ return {
82
+ "title": {"text": label, "font": {"color": _INK_MUTED}},
83
+ "tickfont": {"color": _INK_MUTED},
84
+ "gridcolor": _GRIDLINE,
85
+ "linecolor": _BASELINE,
86
+ "automargin": True,
87
+ }
88
+
89
+
90
+ def _clean(value: object) -> object:
91
+ """One JSON-safe scalar: numpy -> Python, NaN/NaT -> None, dates -> ISO strings.
92
+
93
+ Chart specs are persisted as JSONB and shipped to the FE, so every value in
94
+ the trace arrays must survive json.dumps. Upstream `_materialize` already
95
+ normalizes fully-numeric columns, but mixed columns can still carry Decimal,
96
+ and date columns carry Timestamps.
97
+ """
98
+ if value is None or value is pd.NaT:
99
+ return None
100
+ if isinstance(value, float) and np.isnan(value):
101
+ return None
102
+ if hasattr(value, "item"): # numpy scalar (incl. np.datetime64 via Timestamp below)
103
+ value = value.item()
104
+ if isinstance(value, float) and np.isnan(value):
105
+ return None
106
+ if isinstance(value, Decimal):
107
+ return float(value)
108
+ if isinstance(value, pd.Timestamp):
109
+ return value.isoformat()
110
+ if isinstance(value, _dt.datetime | _dt.date):
111
+ return value.isoformat()
112
+ if isinstance(value, bool | int | float | str):
113
+ return value
114
+ return str(value)
115
+
116
+
117
+ def _values(col: pd.Series) -> list[object]:
118
+ return [_clean(v) for v in col.tolist()]
119
+
120
+
121
+ def _series_label(value: object) -> str:
122
+ """Trace name for one series group; null groups get an explicit label."""
123
+ cleaned = _clean(value)
124
+ return "(missing)" if cleaned is None else str(cleaned)
125
+
126
+
127
+ # Prompt-style description read by the Planner to decide WHEN to pick this tool.
128
+ DESCRIPTION = """\
129
+ Summary: Build a chart specification (bar, line, pie, scatter) from an \
130
+ already-retrieved table, for the app to render. Maps one x column and one y \
131
+ column (pie: x -> slice labels, y -> slice values); an optional `series` column \
132
+ splits bar/line/scatter into one trace per category. Produces a spec only — it \
133
+ computes no numbers.
134
+
135
+ USE WHEN the user EXPLICITLY asks to see a chart — "plot", "chart", "graph", \
136
+ "visualize", "diagram" (ID: "grafik", "diagram", "plot", "visualisasikan", \
137
+ "buatkan grafik/diagram"). ALWAYS a tail step: `data` consumes the output of an \
138
+ upstream task that yields a table (retrieve_data or a table-kind analyze_*).
139
+
140
+ DON'T USE WHEN:
141
+ - the user did not explicitly ask for a chart -> answer with tables/stats only \
142
+ (never add a speculative chart)
143
+ - the numbers still need computing -> run the analyze_*/retrieve step first, \
144
+ then chart its table output
145
+ - the upstream output is stats- or series-kind -> feed it a table-kind task \
146
+ (e.g. a grouped retrieve or analyze_aggregate)
147
+
148
+ Example questions:
149
+ - "plot revenue by region as a bar chart"
150
+ - "buatkan grafik penjualan per kategori"
151
+ - "show a pie chart of orders by sales channel"
152
+ - "visualize monthly revenue" (chain a month-grouped table first, then line)
153
+ """
154
+
155
+
156
+ def render_chart(
157
+ df: pd.DataFrame,
158
+ chart_type: str,
159
+ x: str,
160
+ y: str,
161
+ series: str | None = None,
162
+ title: str | None = None,
163
+ ) -> dict[str, object]:
164
+ """Build a `dataeyond.chart.v1` envelope from a table (SPINE_V2_PLAN §4.2).
165
+
166
+ Args:
167
+ df: already-materialized data (the invoker resolves `data` upstream).
168
+ x: column for the x axis (pie: slice labels).
169
+ y: column for the y axis (pie: slice values).
170
+ series: optional column splitting bar/line/scatter into one trace per
171
+ distinct value (fixed-order colorway slots). Ignored for pie.
172
+ title: chart title; defaults to "<y> by <x>".
173
+
174
+ Returns:
175
+ The envelope dict: {schema, chart_type, title, plotly: {data, layout}}.
176
+
177
+ Raises:
178
+ UnsupportedChartTypeError: chart_type not in CHART_TYPES.
179
+ ColumnNotFoundError: x, y, or series absent from the data.
180
+ """
181
+ if chart_type not in CHART_TYPES:
182
+ raise UnsupportedChartTypeError(
183
+ f"unsupported chart_type '{chart_type}'; supported: {list(CHART_TYPES)}"
184
+ )
185
+ needed = [x, y] if chart_type == "pie" or series is None else [x, y, series]
186
+ missing = [c for c in needed if c not in df.columns]
187
+ if missing:
188
+ raise ColumnNotFoundError(f"columns not found: {missing}")
189
+
190
+ chart_title = title or f"{y} by {x}"
191
+
192
+ if chart_type == "pie":
193
+ traces: list[dict[str, object]] = [
194
+ {"type": "pie", "labels": _values(df[x]), "values": _values(df[y])}
195
+ ]
196
+ else:
197
+ plotly_type = "bar" if chart_type == "bar" else "scatter"
198
+ mode = {"line": "lines+markers", "scatter": "markers"}.get(chart_type)
199
+
200
+ def _trace(frame: pd.DataFrame, name: str) -> dict[str, object]:
201
+ t: dict[str, object] = {
202
+ "type": plotly_type,
203
+ "x": _values(frame[x]),
204
+ "y": _values(frame[y]),
205
+ "name": name,
206
+ }
207
+ if mode is not None:
208
+ t["mode"] = mode
209
+ t["line"] = dict(_LINE_STYLE)
210
+ t["marker"] = dict(_MARKER_STYLE)
211
+ return t
212
+
213
+ if series is None:
214
+ traces = [_trace(df, y)]
215
+ else:
216
+ # sort=True keeps trace order (and therefore colorway slot
217
+ # assignment) deterministic across runs of the same data.
218
+ traces = [
219
+ _trace(group, _series_label(value))
220
+ for value, group in df.groupby(series, dropna=False, sort=True)
221
+ ]
222
+
223
+ layout: dict[str, object] = {
224
+ **LAYOUT_PRESET,
225
+ "title": {"text": chart_title},
226
+ # Legend only when there is more than one thing to identify: multi-series
227
+ # traces, or pie slices (a single named series is titled, not legended).
228
+ "showlegend": len(traces) > 1 or chart_type == "pie",
229
+ }
230
+ if chart_type != "pie":
231
+ layout["xaxis"] = _axis(x)
232
+ layout["yaxis"] = _axis(y)
233
+
234
+ return {
235
+ "schema": "dataeyond.chart.v1",
236
+ "chart_type": chart_type,
237
+ "title": chart_title,
238
+ "plotly": {"data": traces, "layout": layout},
239
+ }
src/tools/contracts.py CHANGED
@@ -68,7 +68,7 @@ class ToolRegistry(BaseModel):
68
 
69
  class ToolOutput(BaseModel):
70
  tool: str
71
- kind: Literal["scalar", "table", "stats", "series", "documents", "error"]
72
  value: Any | None = None
73
  columns: list[str] | None = None
74
  rows: list[list[Any]] | None = None
 
68
 
69
  class ToolOutput(BaseModel):
70
  tool: str
71
+ kind: Literal["scalar", "table", "stats", "series", "documents", "chart", "error"]
72
  value: Any | None = None
73
  columns: list[str] | None = None
74
  rows: list[list[Any]] | None = None
src/tools/invoker.py CHANGED
@@ -36,6 +36,7 @@ from src.tools.analytics import (
36
  relationship,
37
  segmentation,
38
  temporal,
 
39
  )
40
  from src.tools.contracts import ToolOutput
41
  from src.tools.data_access import DATA_ACCESS_TOOLS, DataAccessToolInvoker
@@ -54,6 +55,7 @@ _DISPATCH: dict[str, tuple[Callable[..., Any], str]] = {
54
  "analyze_segment": (segmentation.analyze_segment, "table"),
55
  "analyze_trend": (temporal.analyze_trend, "series"),
56
  "analyze_merge": (merge.analyze_merge, "table"),
 
57
  }
58
 
59
 
 
36
  relationship,
37
  segmentation,
38
  temporal,
39
+ visualization,
40
  )
41
  from src.tools.contracts import ToolOutput
42
  from src.tools.data_access import DATA_ACCESS_TOOLS, DataAccessToolInvoker
 
55
  "analyze_segment": (segmentation.analyze_segment, "table"),
56
  "analyze_trend": (temporal.analyze_trend, "series"),
57
  "analyze_merge": (merge.analyze_merge, "table"),
58
+ "render_chart": (visualization.render_chart, "chart"),
59
  }
60
 
61
 
src/tools/registry.py CHANGED
@@ -34,10 +34,12 @@ from src.tools.analytics import (
34
  relationship,
35
  segmentation,
36
  temporal,
 
37
  )
38
  from src.tools.contracts import ToolRegistry, ToolSpec
39
 
40
- # Active this round — the four analytics tools the Planner may select.
 
41
  ACTIVE_ANALYTICS_TOOLS: list[ToolSpec] = [
42
  ToolSpec(
43
  name="analyze_descriptive",
@@ -113,6 +115,23 @@ ACTIVE_ANALYTICS_TOOLS: list[ToolSpec] = [
113
  output_kind="table",
114
  description=merge.DESCRIPTION,
115
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  ]
117
 
118
  # Deferred this round — specs kept intact for easy re-activation, NOT exposed to
 
34
  relationship,
35
  segmentation,
36
  temporal,
37
+ visualization,
38
  )
39
  from src.tools.contracts import ToolRegistry, ToolSpec
40
 
41
+ # Active this round — the analytics (+ render_chart, SPINE_V2_PLAN §4.1) tools
42
+ # the Planner may select.
43
  ACTIVE_ANALYTICS_TOOLS: list[ToolSpec] = [
44
  ToolSpec(
45
  name="analyze_descriptive",
 
115
  output_kind="table",
116
  description=merge.DESCRIPTION,
117
  ),
118
+ ToolSpec(
119
+ name="render_chart",
120
+ category="analytics.visualization",
121
+ input_schema={
122
+ "required": ["data", "chart_type", "x", "y"],
123
+ "properties": {
124
+ "data": {"type": "string"},
125
+ "chart_type": {"type": "string"},
126
+ "x": {"type": "string"},
127
+ "y": {"type": "string"},
128
+ "series": {"type": "string"},
129
+ "title": {"type": "string"},
130
+ },
131
+ },
132
+ output_kind="chart",
133
+ description=visualization.DESCRIPTION,
134
+ ),
135
  ]
136
 
137
  # Deferred this round — specs kept intact for easy re-activation, NOT exposed to
src/traceability/scratchpad.py CHANGED
@@ -71,13 +71,40 @@ def _output_to_dict(output: Any) -> dict[str, Any]:
71
  ]
72
  value = getattr(output, "value", None)
73
  if value is not None:
74
- result["value"] = _truncate(value)
 
 
 
 
 
 
 
75
  error = getattr(output, "error", None)
76
  if error is not None:
77
  result["error"] = _truncate(error)
78
  return result
79
 
80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  def _meta_of(output: Any) -> dict[str, Any]:
82
  """Best-effort read of a tool result's `meta` dict (ToolOutput or plain dict)."""
83
  if isinstance(output, dict):
@@ -109,6 +136,11 @@ def _summarize(name: str, out_dict: dict[str, Any], meta: dict[str, Any]) -> str
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
 
 
71
  ]
72
  value = getattr(output, "value", None)
73
  if value is not None:
74
+ if kind == "chart" and isinstance(value, dict):
75
+ # The full dataeyond.chart.v1 envelope is persisted in message_charts
76
+ # (SPINE_V2_PLAN §4.4); traceability keeps a compact summary — the
77
+ # spec's x/y arrays are numeric, so _truncate's string caps would let
78
+ # them through whole.
79
+ result.update(_chart_summary(value))
80
+ else:
81
+ result["value"] = _truncate(value)
82
  error = getattr(output, "error", None)
83
  if error is not None:
84
  result["error"] = _truncate(error)
85
  return result
86
 
87
 
88
+ def _chart_summary(envelope: dict[str, Any]) -> dict[str, Any]:
89
+ """Compact wire shape for a chart-kind output: chart_type/title/trace_count/
90
+ point_count (charts have no rows, so `row_count` is absent — §4.7)."""
91
+ plotly = envelope.get("plotly")
92
+ traces = plotly.get("data") if isinstance(plotly, dict) else None
93
+ traces = traces if isinstance(traces, list) else []
94
+ points = 0
95
+ for trace in traces:
96
+ if isinstance(trace, dict):
97
+ axis = trace.get("x") if trace.get("x") is not None else trace.get("labels")
98
+ if isinstance(axis, list):
99
+ points += len(axis)
100
+ return {
101
+ "chart_type": envelope.get("chart_type"),
102
+ "title": envelope.get("title"),
103
+ "trace_count": len(traces),
104
+ "point_count": points,
105
+ }
106
+
107
+
108
  def _meta_of(output: Any) -> dict[str, Any]:
109
  """Best-effort read of a tool result's `meta` dict (ToolOutput or plain dict)."""
110
  if isinstance(output, dict):
 
136
  if name.startswith("analyze_"):
137
  pretty = name.removeprefix("analyze_").replace("_", " ")
138
  return f"Ran {pretty} analysis on {ncol} columns" if ncol else f"Ran {pretty} analysis"
139
+ if name == "render_chart":
140
+ ctype = out_dict.get("chart_type") or "chart"
141
+ points = out_dict.get("point_count")
142
+ summary = f"Built a {ctype} chart"
143
+ return f"{summary} ({points} data points)" if points else summary
144
  return name.replace("_", " ")
145
 
146