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 (built state) Β· DEV_PLAN.md (sprint tracker) Β·
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-ownedmessage_chartsstore +GET /api/v1/charts, SSE stays text-only. Includes formalizing planner path recipes (the few-shots become named workflows; viz = any recipe + arender_charttail). - 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 intoassemble(...).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βRunAssessmentmodel (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_datareturned 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_trendproduced 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.piemapsxβlabels,yβvalues. House style preset (colors, axis config) is a module constant β style is not a HAL decision. src/tools/contracts.pyβToolOutput.kindLiteral 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)inACTIVE_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)
{
"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_chartwithdata="${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_chartis always a tail, fed by a table-kind upstream.src/agents/planner/validator.pyβ path-shape check:render_chart.datamust reference a task whose tool yieldstableoutput.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 akind == "chart"branch: one-line summary (type, title, point count). Without this the full spec (x/y arrays) floods the assembler prompt (prompt.py:52 currently dumpsoutput.valueverbatim for non-table kinds).src/agents/report/generator.pyβ no change needed:_collect_evidencecopies 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βChartStoreProtocol +PostgresChartStore(never-throwsave,listread) +NullChartStore. ORM row insrc/db/postgres/models.py. - Write site:
chat_handler._run_slow_path, after the record persist β scanrecord.results_snapshotoutputs forkind == "chart", save each stamped withpad.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:
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 inmain.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-donepattern as traceability; the row is written beforedone, no polling race. doneevent: proposed additive fieldchart_count(orchart_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;sourcesstays[].API_CONTRACT_BE_PYTHON.mdupdated in the same change (new Β§charts +donenote). FE ask (via Rifqi): renderspec.plotlywith plotly.js under the assistant message; "chart iteration" v1 = a follow-up chat turn (the planner re-emitsrender_chartwith 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_simsmoke β₯ last committed score (name both files);eval.intentonly if router prompt touched; new timestamped results committed, never overwriting. - W2 e2e: live in-process
structured_flowturn with an explicit viz ask β chart row inmessage_chartsβGET /charts200 with a valid v1 envelope; a chartless turn β 200 empty list; a failingrender_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.mdsame-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.