ishaq101's picture
fix/ check and new chart tool (#16)
5a60e93
|
Raw
History Blame Contribute Delete
20.8 kB
# SPINE_V2_PLAN.md — Analysis Spine v2 + Visualization Tool
**Status:****APPROVED 2026-07-13** by Rifqi, with Sofia's sign-off (covers implementing the tool-layer
slice — `ToolOutput.kind` + `render_chart` — in this repo). **Delta at approval:** W3 (activate deferred
`analyze_*` tools) is **deferred to a later cycle — do not start**. W4 stays gated, W5 stays future-only.
FE reminder (plotly rendering) owned by Rifqi, timing TBD.
**Delta 2026-07-13 (pr/16, same-day build):** **W2 and W1 are DONE** — see DEV_PLAN §0.6 (V1–V7)
for the task rows and evidence. Rifqi ran the §4.4 DDL against dedorch and the **live e2e gate
passed in full** (real `POST /api/v2/chat/stream`: viz turn → `message_charts` row keyed by the
`done` message_id → `GET /charts` 200 with a valid v1 envelope; chartless turn → 200 empty;
injected `render_chart` failure → answer still streams with the data as a table, no row). W1 ✅
(13 local tests, one per CK rule). Planner viz behavior verified in-process EN+ID incl. the
viz-infeasible case (Example K was added when the first smoke force-mapped a stand-in column).
Two env finds along the way: `eval.chat_sim` was missing from disk+git (restored by Rifqi — its
hard-coded user/source ids are still stale) and the local `.env` lagged Go's Supabase-S3 data
plane (`storage_provider` — fixed by Rifqi; REPO_STATUS §13 gotcha).
**Owner:** Rifqi. Contributors named per workstream (Sofia = tool layer, Harry = Go/dedorch/contract, mentor = FE).
**Companions:** [REPO_STATUS.md](REPO_STATUS.md) (built state) · [DEV_PLAN.md](DEV_PLAN.md) (sprint tracker) ·
[API_CONTRACT_BE_PYTHON.md](API_CONTRACT_BE_PYTHON.md) (live contract). This file is the design + handoff
source for the Spine-v2 work; when a workstream lands, DEV_PLAN gets the task rows and this doc gets dated deltas.
Status legend (house): ⬜ not started · 🔄 in progress · ✅ done · ⛔ blocked · 🔎 verify · ⏸️ deferred.
---
## 0. Executive summary
We evolve the slow path from a **one-shot pipeline** (plan → run → narrate) into a **staged workflow with one
bounded self-correction point**, and we grow the tool registry into **four families on one spine**:
`check_*`/`retrieve_*` (built) · `analyze_*` (built) · `render_*` (this plan) · `model_*` (future).
Inspiration is CoDA (Google, ICLR 2026) and the Julius.ai comparison, with one governing rule: **CoDA's phases,
not CoDA's agents.** CoDA pays ~15 LLM calls per output because every phase (viz mapping, design, codegen,
debugging, visual evaluation) is its own LLM agent operating on generated code. We keep our declarative engine —
the same phases exist here, but only planning is an LLM decision; mapping lives in the planner's existing single
call, styling is a fixed preset, "generation" is a deterministic spec builder, and evaluation is deterministic
code. Happy path stays **3 LLM calls per turn** (router + planner + assembler); the only growth is an
**evidence-gated repair pass (max +1)** that is NOT built until telemetry justifies it.
What ships, in order:
- **S1a — Quality checkpoint** (0 LLM): after execution, before composition, deterministic code inspects the run
(failed branches, empty/degenerate results, later chart-spec sanity). Verdicts: proceed · honest degrade ·
log-a-repair-candidate. Converts today's silent degrade-and-continue into *explained* degradation and produces
the data for the S1b decision. No invariant touched.
- **S2 — Visualization tool** (`render_chart`): planner-selected, Pattern A, emits **Plotly JSON** (locked
decision, DEV_PLAN §0 deferred row #26 — no matplotlib PNGs, no new dependency). Delivery mirrors traceability:
Python-owned `message_charts` store + `GET /api/v1/charts`, SSE stays text-only. Includes formalizing planner
**path recipes** (the few-shots become named workflows; viz = any recipe + a `render_chart` tail).
- **S1b — Targeted repair (GATED)**: one bounded re-plan of only the failed subgraph, triggered by the
checkpoint. Relaxes INV-6 ("no mid-run LLM") → requires team sign-off *and* S1a telemetry showing it pays.
- **S3 — `model_*` family (FUTURE, sketch only)**: regression/forecast/segmentation as fixed, audited compute
functions on the same spine. Named here so the architecture anticipates it; no build this cycle.
What is deliberately **not changing**: no generated code ever executes; INV-4 (LLM never authors numbers); the
five query-defense layers; never-throw seams; text-only SSE (`sources` stays `[]`); records-based versioned
reports; Go's ownership of dedorch DDL and `analyses_messages`.
---
## 1. Target architecture
```
POST /api/v2/chat/stream (structured_flow turn)
├── Input guard ─► Router (1 LLM) ─► analysis-scoped catalog read (metadata only, PII-safe)
PLAN Planner (1 LLM) — classifies the question into a RECIPE, instantiates a staged Task DAG
│ ▲ └ validator: 8 existing checks + path-shape checks, re-prompt ≤3 (all PRE-run)
│ │
│ └────────────── (S1b, GATED: targeted repair, ≤1 pass, failed subgraph only) ──┐
▼ │
EXECUTE TaskRunner (0 LLM) — waves, ${t<id>} handoffs, degrade-and-continue │
│ tool families (fixed registry, all never-throw): │
│ check_* / retrieve_* · analyze_* · render_* (S2) · model_* (S3) │
▼ │
CHECK Quality checkpoint (S1a, 0 LLM, never-throw) │
│ ok → continue · repairable → repair request ────────────────────────────┘
│ unfixable → honest degrade (specific, not "couldn't compute")
COMPOSE Assembler (1 LLM — narrative only, INV-4; structured fields copied by code)
DELIVER SSE: sources[] → status* → chunk → done{message_id}
writes: report_inputs · message_charts (S2) · message_traceability
FE follow-ups on done: GET /api/v1/traceability · GET /api/v1/charts (S2)
later: POST /api/v1/tools/report (records-based, unchanged)
```
**Recipes (the "predetermined workflow" made explicit).** The planner few-shots already are path templates;
S2 names them and the validator enforces their shape:
| Recipe | Chain | Exists today as |
|---|---|---|
| R1 descriptive | `retrieve_data → analyze_descriptive` | few-shot A/B |
| R2 aggregate / top-N | single grouped-IR `retrieve_data``analyze_aggregate`) | Example G |
| R3 trend | `retrieve_data → analyze_trend` | few-shot |
| R4 correlation | `retrieve_data → analyze_correlation` | few-shot |
| R5 two-metric merge | `retrieve_data ×2 → analyze_merge → …` | Example I |
| R6 infeasible | no tasks + `infeasible_reason` → deterministic refusal | Example H |
| viz tail (S2) | any of R1–R5 **+ `render_chart`** when the user explicitly asks to plot/visualize | NEW — Example J |
CoDA-role mapping (why no new LLM agents): VizMapping → planner args in the existing call · Design Explorer →
fixed style preset inside `render_chart` · Code Generator/Debug → deterministic spec builder (nothing to debug) ·
Visual Evaluator → checkpoint spec checks.
**LLM budget:** happy path 3 calls (unchanged) · S1b failure path max 4 · report unchanged at 1.
---
## 2. Workstreams & sequencing
| # | Workstream | Owner | Status | Gate |
|---|---|---|---|---|
| W1 | S1a quality checkpoint (+ telemetry for the S1b decision) | Rifqi | ✅ 2026-07-13 | none — code-only, 0 LLM |
| 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) |
| 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** |
| W4 | S1b targeted repair pass | Rifqi | ⏸️ | **team sign-off on INV-6 relaxation + S1a telemetry evidence** |
| W5 | S3 `model_*` family | — | ⏸️ | future design review; sketch in §7 |
Sequencing: **W2 leads** (visible product value), **W1 lands alongside or immediately after** (invisible to
users, unblocks nothing, improves everything). W3 deferred at approval. W4 only with evidence. Parallel
dispatch is safe: W1 and W2 are file-disjoint except `coordinator.py` (W1) — coordinate that one file.
---
## 3. W1 — S1a Quality checkpoint
**What it is:** a deterministic, never-throw inspection of `RunState` between `TaskRunner.run` and
`Assembler.assemble`. No LLM. No user-visible new surface — only better inputs to the Assembler and structured
logs.
**Files:**
- NEW `src/agents/slow_path/checkpoint.py``assess(run_state, task_list) -> RunAssessment`.
- `src/agents/slow_path/coordinator.py` — call site between runner and assembler; pass the assessment into
`assemble(...)`.
- `src/agents/slow_path/prompt.py` — render the assessment as a short "execution assessment" block in the
assembler's human content so the narrative names *what specifically* failed/degraded instead of generic
caveats.
- `src/agents/slow_path/schemas.py``RunAssessment` model (pydantic): per-task verdicts + overall verdict
(`ok | degraded | failed`) + `repair_candidates: list[RepairCandidate{task_id, reason}]`.
**v1 checks (all deterministic):**
- CK1 all tasks failed → overall `failed`; coordinator returns a deterministic honest-failure answer (mirrors
the existing infeasible path shape; record stays non-substantive so it can't hit the report floor).
- CK2 `retrieve_data` returned 0 rows and a dependent consumed it → flag task + downstream.
- CK3 table output truncated at the 10k cap → flag (answer must say "based on the first 10,000 rows").
- CK4 `analyze_trend` produced a single bucket → flag (the pr/13 1970-bucket class).
- CK5 all-null column consumed by an `analyze_*` task → flag.
- CK6 (lands with W2) chart-spec sanity — see §4.6.
- Every flag logs `repair_candidate` + reason via structlog → the S1b evidence base.
**Explicit non-goals in S1a:** no re-planning, no LLM, no change to degrade-and-continue semantics, no change to
never-throw seams. INV-6 untouched.
---
## 4. W2 — S2 Visualization tool (`render_chart`)
### 4.1 Tool (Sofia's slice — tool layer is tool-team-owned)
- NEW `src/tools/analytics/visualization.py` — `render_chart(df, chart_type, x, y, series=None, title=None)`.
Pure, deterministic, **no LLM, no new dependency**: builds a Plotly-conformant dict by hand (Plotly JSON is a
documented schema; the FE renders with plotly.js — Python never imports plotly). v1 chart types: `bar`,
`line`, `pie`, `scatter`. `pie` maps `x`→labels, `y`→values. House style preset (colors, axis config) is a
module constant — style is not a HAL decision.
- `src/tools/contracts.py` — `ToolOutput.kind` Literal gains `"chart"` (one line; tool-team-owned file).
- `src/tools/registry.py` — `ToolSpec(name="render_chart", category="analytics.visualization",
input_schema={"required": ["data", "chart_type", "x", "y"], "properties": {…, "series", "title"}},
output_kind="chart", description=visualization.DESCRIPTION)` in `ACTIVE_ANALYTICS_TOOLS`.
- `src/tools/invoker.py` — `_DISPATCH["render_chart"] = (visualization.render_chart, "chart")`. Never-throw
comes free; a failed chart degrades the task, the answer still assembles.
### 4.2 Chart-spec envelope (v1, FE contract)
```json
{
"schema": "dataeyond.chart.v1",
"chart_type": "bar",
"title": "Revenue by region",
"plotly": {
"data": [{ "type": "bar", "x": ["A", "B"], "y": [1, 2], "name": "revenue" }],
"layout": { "title": {"text": "Revenue by region"}, "xaxis": {"title": {"text": "region"}},
"yaxis": {"title": {"text": "revenue"}} }
}
}
```
FE renders with `Plotly.newPlot(el, spec.plotly.data, spec.plotly.layout)`. The envelope (not raw plotly) is
what `ToolOutput.value` carries and what the store persists.
### 4.3 Planner slice (Rifqi)
- `src/agents/planner/examples.py`**Example J**: "plot revenue by region" → R2 retrieve → `render_chart`
with `data="${t1}"`.
- `src/config/prompts/planner.md` — the recipe table (§1) + rule: chart **only when the user explicitly asks**
(plot/visualize/chart/grafik/buatkan diagram); never speculative; `render_chart` is always a tail, fed by a
table-kind upstream.
- `src/agents/planner/validator.py` — path-shape check: `render_chart.data` must reference a task whose tool
yields `table` output.
- `src/config/prompts/intent_router.md` — only if EN/ID viz phrasings misroute in the smoke; keep few-shots ↔
eval dataset mirrored if touched.
- `src/agents/slow_path/prompt.py` — assembler renderer gets a `kind == "chart"` branch: one-line summary
(type, title, point count). Without this the full spec (x/y arrays) floods the assembler prompt
([prompt.py:52](src/agents/slow_path/prompt.py:52) currently dumps `output.value` verbatim for non-table kinds).
- `src/agents/report/generator.py` — no change needed: `_collect_evidence` copies table-kind only; charts cannot
corrupt the markdown report. Report embedding of charts stays **deferred** (standing decision).
### 4.4 Chart store (Rifqi) — mirrors `message_traceability`
- NEW `src/charts/store.py` — `ChartStore` Protocol + `PostgresChartStore` (never-throw `save`, `list` read) +
`NullChartStore`. ORM row in `src/db/postgres/models.py`.
- Write site: `chat_handler._run_slow_path`, after the record persist — scan `record.results_snapshot` outputs
for `kind == "chart"`, save each stamped with `pad.message_id`, `analysis_id`, `user_id`, `record_id`.
Never-throw; a chart-persist failure must not break the answer.
- **Zero DDL from Python** (§2.2). DDL below is run manually against dedorch now and handed to Harry for the
migration:
```sql
CREATE TABLE IF NOT EXISTS message_charts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
message_id text NOT NULL,
analysis_id uuid NOT NULL REFERENCES analyses(id),
user_id text NOT NULL,
record_id text,
chart_type text NOT NULL,
title text,
spec jsonb NOT NULL, -- the dataeyond.chart.v1 envelope (§4.2)
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_message_charts_lookup
ON message_charts (analysis_id, message_id);
```
### 4.5 API surface + contract delta (Rifqi → Harry)
- NEW `GET /api/v1/charts?analysis_id=&message_id=` (`src/api/v1/charts.py`, mounted in `main.py`) →
`{"count": n, "charts": [{chart_id, chart_type, title, spec, created_at}]}`; empty list is a valid 200 (FE:
no charts this turn). Same fetch-on-`done` pattern as traceability; the row is written before `done`, no
polling race.
- `done` event: **proposed additive field** `chart_count` (or `chart_ids`) so the FE can skip the GET on
chartless turns. Harry's call; fallback = FE fetches unconditionally. SSE order and all existing fields
unchanged; `sources` stays `[]`.
- `API_CONTRACT_BE_PYTHON.md` updated in the same change (new §charts + `done` note). FE ask (via Rifqi):
render `spec.plotly` with plotly.js under the assistant message; "chart iteration" v1 = a follow-up chat turn
(the planner re-emits `render_chart` with patched args — no new endpoint).
### 4.6 Checkpoint spec checks (CK6, with W1)
Empty series · `len(x) != len(y)` · bar/pie with > 30 / > 8 categories → flag (S1a: honest note; S1b later:
re-pick args or fall back to table) · non-numeric y for bar/line/scatter.
### 4.7 PII / tracing notes
Chart specs contain the same result rows the user already receives in tables — no new exposure class. Langfuse:
tool spans stay metadata-only; the traceability scratchpad records the `render_chart` call like any tool (verify
the `TraceabilityToolInvoker` summary handles `kind="chart"` sanely — row_count is absent; use point count).
---
## 5. W3 — Activate deferred analytics tools (⏸️ DEFERRED 2026-07-13 — kept for the later cycle)
Move `analyze_comparison`, `analyze_contribution`, `analyze_profile`, `analyze_segment` from
`DEFERRED_ANALYTICS_TOOLS` to `ACTIVE_ANALYTICS_TOOLS` (`src/tools/registry.py` — compute fns and invoker
mappings already exist). Per the registry's own note: each re-activated tool needs planner few-shot coverage
(`examples.py`) — that is the real work. Recipes gain R7 comparison / R8 contribution / R9 segment rows.
Prompt change → full §7B eval gauntlet. Closes the breadth gap vs Julius at near-zero engineering cost.
---
## 6. W4 — S1b targeted repair (GATED — do not build yet)
Trigger: checkpoint returns repair candidates AND the team has approved the INV-6 relaxation AND S1a telemetry
shows a meaningful hit-rate (proposal: revisit after 2 weeks of S1a logs). Design (for the future PR):
`PlannerService.repair(task_list, run_state, reasons)` → patched subgraph for the failed task ids only; runner
re-executes only those tasks + skipped dependents; **max 1 pass per turn**; repair prompt gets the failure
evidence (error strings, row counts). Budget: +1 LLM call, failure paths only. If rejected: S1a's honest-degrade
stands alone and this section moves to an ADR graveyard note — the checkpoint is still worth it.
---
## 7. W5 — S3 `model_*` family (future sketch, no build)
Same spine, fourth family: fixed, audited compute functions (e.g. `model_regression`, `model_forecast`,
`model_cluster` — sklearn/statsmodels behind ToolSpecs; **dependency additions need the §6.4 ask**). Recipe:
`retrieve → prepare → model_* → evaluate` — CRISP-DM's modeling/evaluation stages become real. Outputs are
`stats`/`table`/`series` + optional `render_chart` tails. Reports gain model sections then, not before.
Explicitly NOT codegen — if open-ended modeling demand materializes, a sandboxed codegen tool is a separate
§6.3 guardrail conversation.
---
## 8. Verification gates (per workstream — house §7)
- Every W: `ruff check src/` clean · full local pytest with **exact counts** vs previous ·
`PYTHONPATH=. ./.venv/Scripts/python.exe -c "import main"` exits 0 · no never-throw seam pierced.
- W1: unit tests for every CK rule (fixture RunStates); in-process slow-path run showing the assessment block in
the assembler input; no behavior change on a clean run.
- W2 prompts: `eval.chat_sim` smoke ≥ last committed score (name both files); `eval.intent` only if router
prompt touched; new timestamped results committed, never overwriting.
- W2 e2e: live in-process `structured_flow` turn with an explicit viz ask → chart row in `message_charts`
`GET /charts` 200 with a valid v1 envelope; a chartless turn → 200 empty list; a failing `render_chart`
answer still streams, no chart row.
- W3: eval smokes for each new few-shot; EN+ID phrasing per eval conventions (§7F).
- Contract changes: `API_CONTRACT_BE_PYTHON.md` same-change; REPO_STATUS/DEV_PLAN deltas dated (§7E).
## 9. Decisions log & open questions
Locked (approved 2026-07-13): Plotly JSON not PNG (re-affirms DEV_PLAN #26) · no per-phase LLM agents ·
S1a before/with S2, S1b evidence-gated · ~~charts chat-only for now (report embedding deferred)~~
RESOLVED 2026-07-14: the FE fenced-block hook exists (lead verified), so report embedding LANDED exactly
as sketched — ` ```plotly ` fences in `rendered_markdown`'s EDA section, emitted by the generator from
`results_snapshot` chart outputs, INV-4 copy-verbatim; `has_successful_analysis` now also counts a
successful `render_chart`, so a chart-only session satisfies the report floor ·
`render_chart` only on explicit user ask · ~~ToolOutput.kind + render_chart ownership split~~ RESOLVED:
Sofia signed off, implemented in-repo by the dev session · **W3 deferred to a later cycle**.
Open → owner: `done.chart_count` additive field yes/no → Harry · FE chart placement (under message) +
plotly.js availability + report fenced-block hook → mentor/FE (Rifqi to remind) · INV-6 relaxation for W4 →
team, after S1a telemetry · W3 re-open timing → Rifqi.
## 10. Doc-sync checklist on landing (per workstream)
REPO_STATUS §6/§9 (tool count, new checkpoint stage, charts surface) · DEV_PLAN new task rows under a dated
sprint section · API_CONTRACT §charts + `done` delta · this file: status flips + dated delta banner.