Adds the `docling-agent` reasoning-trace viewer as a Studio tunnel, per `docs/design/reasoning-trace.md`. Users pick an analyzed document, import a RAGResult JSON, and the iterations are overlaid on the document graph. Graph source is decoupled from Neo4j: a new pure builder (`infra/docling_graph.build_graph_payload`) reads `document_json` from SQLite and emits the same Cytoscape-shaped payload that `fetch_graph` returns from Neo4j. Neo4j stays exclusive to the Maintain ingestion pipeline. Shared DoclingDocument helpers live in `infra/docling_tree.py` so TreeWriter and the builder can't drift on label taxonomy or tree walks. Also removes the Cytoscape minimap (cytoscape-navigator) from GraphView: second render instance hurt perf on large documents for no UX win. Backend - new `GET /api/documents/:id/reasoning-graph` (SQLite-only) - new `infra/docling_tree.py`, `infra/docling_graph.py` - `analysis_repo.find_latest_completed_by_document` - tests: `test_docling_graph.py` (builder), `test_graph_api.py` (endpoint) Frontend - `features/reasoning/` — store, overlay, types, panel, import dialog, workspace, doc picker - new `ReasoningPage` + `/reasoning` and `/reasoning/:docId` routes - `GraphView` gains a `fetcher` prop so reasoning can inject the SQLite-backed fetcher while Maintain keeps using the Neo4j one - drops minimap (nav container, dep, CSS) - legend filters + section parenting extracted for reuse - i18n base strings (FR + EN)
12 KiB
Reasoning Trace Viewer — Docling-Studio v0.6.0 (R&D preview)
Design doc for the docling-agent reasoning trace viewer.
Targeted release: v0.6.0 (R&D branched from release/0.5.0 in parallel to the
0.5 build, so the Neo4j foundation can be leveraged without blocking the hackathon deliverable).
Positioning one-liner:
Studio becomes the reference viewer for any
docling-agentrun — not another chatbot. The PDF is the debug surface.
1. Context
Upstream trigger
Peter Staar (IBM) suggested surfacing the LLM reasoning trace as docling-agent
walks a DoclingDocument outline (chunkless RAG, new IBM repo).
docling-agent in one paragraph
DoclingRAGAgent(model_id, tools, max_iterations=5).run(task, sources=[doc]) returns
a RAGResult with answer, converged, and iterations: list[RAGIteration].
Each RAGIteration carries: iteration, section_ref (JSON-pointer, e.g. #/texts/3),
reason, can_answer, response, section_text_length. No bbox — must be resolved
through DoclingDocument.<items>[i].prov[0].bbox + page_no. Runs on Mellea
(Ollama / OpenAI / HF / WatsonX / LiteLLM / Bedrock). Observability is stdout logs only.
What Studio already brings
- Neo4j graph of the document (0.5.0, just landed) — every
Elementis keyed by(doc_id, self_ref), Cytoscape node id iselem::${self_ref}. This is the killer enabler.RAGIteration.section_ref → nodeis a string concat, no resolver. GraphView.vue(Cytoscape + dagre) already handles styles via selectors (selector: 'edge[type = "NEXT"]',selector: 'node[kind = "section"]') — adding avisitedclass +REASONING_NEXTsynthetic edge type is ~20 LOC of style.analysis_jobs.document_jsonin SQLite → DoclingDocument available for the sidecar runner (no PDF re-conversion). Not used by the viewer itself.
Personas
- v1 (this plan): dev / integrator of
docling-agentdebugging a run that went wrong. - v2 (roadmap): live runner with synchronized demo UX.
- v3+ (non-goals here): business analyst for semantic navigation, batch QA.
2. Scope split — debug first, demo second
Rendering surface pivoted: the trace is drawn on the Neo4j graph, not on the PDF. See §1. This kills the whole bbox-resolution stack from v1.
| Phase | Value | Runtime deps | Surface |
|---|---|---|---|
| v1 — Debug (this plan) | Import externally-produced RAGResult JSON, overlay trace on the existing GraphView |
None server-side. Pure frontend. | GraphView: visited nodes highlighted in order, synthetic REASONING_NEXT edges |
| v2 — Demo (follow-up) | Run the agent live against a loaded document | Ollama + Mellea + docling-agent (new opt-dep group rag) |
Same GraphView + SSE streaming of iterations, staggered reveal |
Building v1 first de-risks the graph-trace UX on real runs (produced by the
R&D sidecar — see experiments/reasoning-trace/) before wiring the live runner.
Code shared between v1 and v2 is the GraphView overlay itself — 100 % reused.
Prerequisite for v1: the target document must have been processed through the "Maintain" step (Neo4j pipeline). Otherwise the graph is empty and the trace has nowhere to render — surface an explicit "Run the Maintain step first" empty state.
3. v1 — Debug mode (frontend-only)
3.1 No backend changes in v1
The GraphView already loads nodes keyed by self_ref via GET /api/documents/{doc_id}/graph.
Iteration section_ref → Cytoscape node id is `elem::${section_ref}` — a client-side
string concat. Nothing to compute server-side.
Consequences:
- No new router, no new service, no new pydantic model, no new migration.
- No dependency on
docling-agentindocument-parser/requirements.txt. RAGResultJSON (as produced byexperiments/reasoning-trace/) is consumed entirely by the frontend.
3.2 Frontend — feature folder
New frontend/src/features/reasoning/:
reasoning/
├── store/reasoningStore.ts # Pinia: trace, activeIteration, importDialogOpen
├── ui/
│ ├── ReasoningPanel.vue # Side panel: query, answer, iteration list
│ ├── IterationCard.vue # Single iteration row (reason + can_answer badge)
│ ├── ImportTraceDialog.vue # Drag-drop / paste RAGResult JSON
│ └── GraphReasoningOverlay.ts # NOT a component — a plugin that decorates cy
└── types.ts # RAGIteration, RAGResult mirror types
3.3 Graph overlay — how it's drawn
GraphReasoningOverlay takes the existing cy Cytoscape instance (exposed from
GraphView.vue via defineExpose) and:
- For each
iteration[i].section_ref, find node`elem::${section_ref}`. If missing, tag asresolution_status: "not_in_graph"and show a warning in the panel (common cause: doc not processed through Maintain, or agent returned a ref that points at a non-Element node). - Add class
visited+ data attributevisitOrder: i+1on matched nodes. - Insert synthetic edges between successive visited nodes with
type: "REASONING_NEXT"anddata: { order: i }. These edges are UI-only, never written to Neo4j. - On import, fit viewport to the visited subgraph (
cy.fit(cy.$('.visited'), 80)). - On iteration card click →
cy.$(#elem::${ref}).flashClass('pulse', 800)+ centered pan.
Cytoscape styles (append to the existing stylesheet array in GraphView.vue):
{ selector: 'node.visited',
style: { 'border-color': '#EA580C', 'border-width': 3, 'overlay-opacity': 0 } },
{ selector: 'node.visited[visitOrder]',
style: { label: 'data(visitOrder)', 'text-valign': 'top',
'text-background-color': '#EA580C', 'text-background-opacity': 1,
'color': '#FFFFFF', 'font-weight': 700 } },
{ selector: 'edge[type = "REASONING_NEXT"]',
style: { 'line-color': '#EA580C', 'target-arrow-color': '#EA580C',
'target-arrow-shape': 'triangle', 'curve-style': 'bezier',
width: 2, 'z-index': 99 } },
Color ramp: single warm color (#EA580C) for v1. Gradient cold→warm is v2 polish.
3.4 Integration points
StudioPage.vue→ "Maintain" tab gains an "Import reasoning trace" action (don't add a 3rd mode — the viz lives inside the graph view, not a new workspace).GraphView.vue→ adddefineExpose({ cy })+ a<slot name="overlay" :cy="cy"/>that the parent can populate with<ReasoningPanel>.ReasoningPanelappears as a right rail when a trace is loaded; collapsible.
3.5 Empty / error states
- Graph empty for this doc → "Run the Maintain step first. Neo4j has no graph for this document yet." (the Maintain button is literally next to it.)
- All
section_refs unresolved in graph → "None of the visited sections exist in the graph. The agent may have been run against a different document, or the doc was re-analyzed since. Re-run Maintain or re-run the agent." - Some resolved, some not → show trace with the missing ones greyed out in the panel.
3.6 Tests
No backend tests in v1 (no backend code).
Frontend (Vitest):
reasoningStore.test.ts— import trace, active iteration transitions, reset on doc change.graphReasoningOverlay.test.ts— given a mockcy(cytoscape({ headless: true })) with a known node set, verifyvisitedclass applied to the right ids and the correct synthetic edges added.ReasoningPanel.test.ts— empty / loaded / partial-resolution states.
3.7 Out of scope for v1
- Live agent runner (v2).
- Multi-doc queries — reject import if
RAGResultwas produced againstlen(sources) > 1. - Phrase-level attribution —
docling-agentdoesn't emit it. - Persisting traces in Neo4j — see §7.
- PDF highlighting — dropped from v1. Could come back as v2.5 if demand exists.
4. File inventory (v1)
New — R&D sidecar (already scaffolded on this branch)
experiments/reasoning-trace/inspect_doc.py— self-containeduv runscript.experiments/reasoning-trace/README.mdexperiments/reasoning-trace/.gitignore
New — frontend
frontend/src/features/reasoning/**(see §3.2)- Vitest siblings under
**/*.test.ts
Touched
frontend/src/features/analysis/ui/GraphView.vue—defineExpose({ cy })+<slot name="overlay">+ 3 new style selectors.frontend/src/pages/StudioPage.vue— "Import reasoning trace" action in the Maintain tab rail.
Untouched
- Entire
document-parser/backend — no new router, service, schema, or dep. pyproject.toml/requirements.txt— no new runtime dep in v1.- Neo4j schema — synthetic edges are client-side Cytoscape only.
- OpenSearch / ingestion — untouched.
- SQLite schema — no migration.
5. Risks & mitigations
| Risk | Mitigation |
|---|---|
RAGResult schema drifts in docling-agent |
schema_version discriminator; strict pydantic; one canonical fixture from Peter pinned in CI. |
section_ref variants (#/texts/3 vs #/body/texts/3) |
Normalize in parser; regex test matrix. |
Synthetic groups without prov |
Documented child-walk fallback + resolved_via_child status surfaced in UI. |
Large RAGResult (hundreds of iterations) |
Hard-cap iterations at 50 in v1 (Peter's agent uses max_iterations=5 by default) — return 413 above. |
document_json blob large (some docs > 5 MB) |
analysis_repo already handles it; but do not log the blob. Add redaction test. |
| Section ref not in graph (doc not through Maintain, or re-analyzed) | Explicit empty-state in ReasoningPanel with a link to the Maintain tab. Partial resolution shown as grey in the trace list. |
| Feature creeping into 0.5.0 | This branch targets v0.6.0. Do not merge into release/0.5.0. Rebase onto the next release branch when cut. |
6. Spec anchoring
Pin the RAGResult shape to docling-agent commit SHA at the time of v1 merge in
a short ADR docs/architecture/adrs/ADR-002-rag-result-schema.md. The schema is
upstream, unversioned, and will move — this doc freezes the contract Studio imports.
7. v2 preview — demo mode (not in this plan)
Kept here to constrain v1 interfaces so nothing needs rewriting:
POST /api/rag/answer— server-side runner. Accepts{doc_id, question, model_id}. Streams iterations via SSE. Frontend consumes the stream with the sameGraphReasoningOverlayused by v1 import — iterations appear one by one with staggered reveal (~400 ms) as the SSE stream drips them in.- Ollama wired through
Mellea— new optional dep grouprag. - Persist traces in Neo4j as
(:ReasoningRun {id, query, converged})-[:VISITED {order, reason, can_answer}]->(:Element)for replay + cross-run analytics. LeveragesTreeWriterpattern already present. This is where the synthetic UI edges become real graph edges. - Cross-run comparison view: overlay multiple runs on the same graph, diff the paths.
8. Branch & workflow
- Branch:
feature/reasoning-traceofforigin/release/0.5.0. - Merge target: next release branch (
release/0.6.0) once cut — not0.5.0. - Until then: live on the feature branch; rebase onto
release/0.5.0periodically to absorb Neo4j fixes. - Issues: one umbrella + one per §4 subsystem (resolver, endpoint, UI panel, overlay,
import dialog, tests). Commit with
Closes #NNNper project convention. - PR: opened against
release/0.6.0when available; draft in the meantime.
9. Open questions (answered by the sidecar first run)
- Are emitted
section_refs reachable aselem::${ref}in the Neo4j graph built byTreeWriter? I.e. is theself_refthe agent sees the sameself_refwe wrote to the graph? (Expected yes — both come from the sameDoclingDocument— but the sidecar on a real doc from SQLite will confirm in one run.) - Hit rate of the agent: with
max_iterations=5andgranite4:micro-h, does it converge, and how many sections does it actually visit? Determines if the overlay ever has more than 1–2 marked nodes (and whetherREASONING_NEXTedges are worth the effort vs just node markers). - Quality of
iteration.reason— is it substantive enough to show in the panel, or LLM filler we should hide? Sidecar output will tell. - Fallback when no section headers exist (
RAGResult(iterations=[], converged=True, answer=<full md>)— see rag.py): what does the panel show? Probably a degraded "no trace available, full-doc answer" state.