fix/ check and new chart tool
#16
by sofhiaazzhr - opened
- API_CONTRACT_BE_PYTHON.md +129 -1
- DEV_PLAN.md +30 -1
- REPO_STATUS.md +54 -20
- SPINE_V2_PLAN.md +315 -0
- eval/chat_sim/README.md +55 -0
- eval/chat_sim/__init__.py +0 -0
- eval/chat_sim/run_chat.py +528 -0
- main.py +2 -0
- src/agents/chat_handler.py +28 -0
- src/agents/handlers/check.py +57 -8
- src/agents/planner/examples.py +115 -0
- src/agents/planner/validator.py +42 -3
- src/agents/refusals.py +27 -0
- src/agents/report/generator.py +49 -2
- src/agents/report/readiness.py +7 -4
- src/agents/report/schemas.py +4 -0
- src/agents/slow_path/assembler.py +5 -1
- src/agents/slow_path/checkpoint.py +233 -0
- src/agents/slow_path/coordinator.py +42 -3
- src/agents/slow_path/prompt.py +37 -1
- src/agents/slow_path/schemas.py +35 -0
- src/agents/slow_path/task_runner.py +39 -6
- src/api/v1/charts.py +72 -0
- src/charts/__init__.py +21 -0
- src/charts/store.py +172 -0
- src/config/prompts/planner.md +50 -1
- src/db/postgres/models.py +33 -0
- src/query/compiler/pandas.py +15 -2
- src/query/executor/tabular.py +11 -1
- src/tools/analytics/visualization.py +239 -0
- src/tools/contracts.py +1 -1
- src/tools/invoker.py +2 -0
- src/tools/registry.py +20 -1
- src/traceability/scratchpad.py +33 -1
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) | — |
|
| 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 |
-
- **
|
| 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:**
|
|
|
|
| 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
|
| 157 |
|
| 158 |
-
> **
|
| 159 |
-
>
|
| 160 |
-
>
|
| 161 |
-
> `
|
|
|
|
| 162 |
|
| 163 |
| Endpoint | Purpose | Caller |
|
| 164 |
|---|---|---|
|
| 165 |
-
| `POST /chat/stream` | Main chat SSE (router → dispatch) | FE → Go → Python
|
| 166 |
-
| `
|
| 167 |
-
| `POST /
|
| 168 |
-
| `POST /report`
|
| 169 |
-
| `GET /
|
| 170 |
-
| `
|
|
|
|
| 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_*`
|
| 284 |
-
|
| 285 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 289 |
-
|
| 290 |
-
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 328 |
-
|
| 329 |
-
refs.
|
| 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 —
|
| 450 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 *
|
| 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".
|
| 80 |
-
|
|
|
|
|
|
|
| 81 |
"""
|
| 82 |
return any(
|
| 83 |
-
t.status == "success"
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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 .
|
|
|
|
| 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,
|
|
|
|
| 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 |
-
#
|
| 130 |
-
#
|
| 131 |
-
#
|
|
|
|
|
|
|
| 132 |
if isinstance(value, str):
|
| 133 |
match = PLACEHOLDER_RE.fullmatch(value.strip())
|
| 134 |
if match:
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
if upstream is None or not upstream.outputs:
|
| 137 |
-
return None
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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
|
| 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
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|