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 (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-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)

{
  "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 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:
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.