feat(reasoning): reasoning-trace viewer v1 with SQLite-backed graph

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)
This commit is contained in:
Pier-Jean Malandrino 2026-04-21 11:30:47 +02:00
parent f8675aea83
commit 8103460e9c
47 changed files with 5834 additions and 177 deletions

View file

@ -0,0 +1,253 @@
# 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-agent` run** — 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 `Element` is keyed by
`(doc_id, self_ref)`, Cytoscape node id is `elem::${self_ref}`. **This is the
killer enabler.** `RAGIteration.section_ref → node` is a string concat, no resolver.
- `GraphView.vue` (Cytoscape + dagre) already handles styles via selectors
(`selector: 'edge[type = "NEXT"]'`, `selector: 'node[kind = "section"]'`) — adding
a `visited` class + `REASONING_NEXT` synthetic edge type is ~20 LOC of style.
- `analysis_jobs.document_json` in 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-agent` debugging 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-agent` in `document-parser/requirements.txt`.
- `RAGResult` JSON (as produced by `experiments/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:
1. For each `iteration[i].section_ref`, find node `` `elem::${section_ref}` ``. If
missing, tag as `resolution_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).
2. Add class `visited` + data attribute `visitOrder: i+1` on matched nodes.
3. Insert **synthetic edges** between successive visited nodes with `type: "REASONING_NEXT"`
and `data: { order: i }`. These edges are UI-only, never written to Neo4j.
4. On import, fit viewport to the visited subgraph (`cy.fit(cy.$('.visited'), 80)`).
5. On iteration card click → `cy.$(`#elem::${ref}`).flashClass('pulse', 800)` +
centered pan.
Cytoscape styles (append to the existing stylesheet array in `GraphView.vue`):
```js
{ 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` → add `defineExpose({ cy })` + a `<slot name="overlay" :cy="cy"/>`
that the parent can populate with `<ReasoningPanel>`.
- `ReasoningPanel` appears 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_ref`s 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 mock `cy` (`cytoscape({ headless: true })`)
with a known node set, verify `visited` class 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 `RAGResult` was produced against `len(sources) > 1`.
- Phrase-level attribution — `docling-agent` doesn'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-contained `uv run` script.
- `experiments/reasoning-trace/README.md`
- `experiments/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 same
`GraphReasoningOverlay` used 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 group `rag`.
- Persist traces in Neo4j as `(:ReasoningRun {id, query, converged})-[:VISITED {order,
reason, can_answer}]->(:Element)` for replay + cross-run analytics. Leverages
`TreeWriter` pattern 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-trace`** off `origin/release/0.5.0`.
- Merge target: **next release branch (`release/0.6.0`)** once cut — *not* `0.5.0`.
- Until then: live on the feature branch; rebase onto `release/0.5.0` periodically to
absorb Neo4j fixes.
- Issues: one umbrella + one per §4 subsystem (resolver, endpoint, UI panel, overlay,
import dialog, tests). Commit with `Closes #NNN` per project convention.
- PR: opened against `release/0.6.0` when available; draft in the meantime.
---
## 9. Open questions (answered by the sidecar first run)
1. Are emitted `section_ref`s reachable as `elem::${ref}` in the Neo4j graph built
by `TreeWriter`? I.e. is the `self_ref` the agent sees the same `self_ref` we
wrote to the graph? (Expected yes — both come from the same `DoclingDocument`
but the sidecar on a real doc from SQLite will confirm in one run.)
2. Hit rate of the agent: with `max_iterations=5` and `granite4:micro-h`, does it
converge, and how many sections does it actually visit? Determines if the overlay
ever has more than 12 marked nodes (and whether `REASONING_NEXT` edges are worth
the effort vs just node markers).
3. Quality of `iteration.reason` — is it substantive enough to show in the panel, or
LLM filler we should hide? Sidecar output will tell.
4. 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.

View file

@ -1,9 +1,11 @@
"""Graph API — returns a cytoscape-shaped view of the Neo4j graph for a doc. """Graph API — returns a cytoscape-shaped view of the document structure.
v0.5 contract: Two endpoints:
- Returns the **full** graph for the document (see design §8.4) - `/graph` read from Neo4j. Rich graph (elements + chunks + pages + merges).
- Hard cap at 200 pages; beyond that, HTTP 413 with `truncated: true` Requires the Maintain step (IngestionPipeline) to have run for the document.
- No pagination (ships in v0.6) - `/reasoning-graph` built on-the-fly from the SQLite `document_json` blob.
No Neo4j dependency. Lighter graph (no chunks) but enough to render the
reasoning-trace overlay on top of `GraphView`.
""" """
from __future__ import annotations from __future__ import annotations
@ -13,6 +15,7 @@ import logging
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel from pydantic import BaseModel
from infra.docling_graph import build_graph_payload
from infra.neo4j import fetch_graph from infra.neo4j import fetch_graph
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -74,3 +77,47 @@ async def get_document_graph(doc_id: str, request: Request) -> GraphResponse:
truncated=payload.truncated, truncated=payload.truncated,
page_count=payload.page_count, page_count=payload.page_count,
) )
@router.get("/{doc_id}/reasoning-graph", response_model=GraphResponse)
async def get_reasoning_graph(doc_id: str, request: Request) -> GraphResponse:
"""Graph projection built from SQLite `document_json` — no Neo4j needed.
Serves the reasoning-trace viewer, which only needs the element/page/edge
structure to overlay iterations onto.
"""
analysis_repo = getattr(request.app.state, "analysis_repo", None)
if analysis_repo is None:
raise HTTPException(status_code=500, detail="AnalysisRepository not wired")
latest = await analysis_repo.find_latest_completed_by_document(doc_id)
if latest is None or not latest.document_json:
raise HTTPException(
status_code=404,
detail=f"No completed analysis with document_json for {doc_id}",
)
payload = build_graph_payload(
latest.document_json,
doc_id=doc_id,
title=latest.document_filename or doc_id,
max_pages=MAX_PAGES,
)
if payload.truncated:
raise HTTPException(
status_code=413,
detail=(
f"Graph too large: document has {payload.page_count} pages "
f"(cap {MAX_PAGES}). Pagination ships in v0.6."
),
)
return GraphResponse(
doc_id=payload.doc_id,
nodes=[GraphNode(**n) for n in payload.nodes],
edges=[GraphEdge(**e) for e in payload.edges],
node_count=payload.node_count,
edge_count=payload.edge_count,
truncated=payload.truncated,
page_count=payload.page_count,
)

View file

@ -0,0 +1,159 @@
"""Build a Cytoscape-shaped graph payload straight from a serialized
`DoclingDocument` (i.e. the `document_json` blob stored in SQLite).
Mirrors `infra.neo4j.queries.fetch_graph` so the frontend can reuse the same
`GraphView` component the only intentional difference is the absence of
Chunk nodes / HAS_CHUNK / DERIVED_FROM edges, since chunks are a product of
the Maintain step and don't exist in `document_json` alone.
Used by the reasoning-trace viewer, which needs the structural graph to
overlay iterations onto but does NOT need (and should not require) Neo4j.
"""
from __future__ import annotations
import json
from itertools import pairwise
from typing import Any
from infra.docling_tree import (
dfs_order,
element_label,
iter_items,
iter_pages,
iter_provs,
parent_ref,
)
from infra.neo4j.queries import GraphPayload
def _element_node(doc_id: str, item: dict[str, Any], provs: list[dict[str, Any]]) -> dict[str, Any]:
first_page = provs[0].get("page_no") if provs else None
return {
"id": f"elem::{item.get('self_ref')}",
"group": "element",
"label": element_label(item.get("label") or ""),
"docling_label": (item.get("label") or "").lower(),
"self_ref": item.get("self_ref"),
"text": (item.get("text") or "")[:200],
"prov_page": first_page,
"provs": provs,
"level": item.get("level"),
"doc_id": doc_id,
}
def _page_node(doc_id: str, page: dict[str, Any]) -> dict[str, Any]:
return {
"id": f"page::{page.get('page_no')}",
"group": "page",
"page_no": page.get("page_no"),
"width": page.get("width"),
"height": page.get("height"),
"doc_id": doc_id,
}
def _edge(source: str, target: str, edge_type: str, *, order: int | None = None) -> dict[str, Any]:
return {
"id": f"{edge_type}::{source}::{target}",
"source": source,
"target": target,
"type": edge_type,
"order": order,
}
def build_graph_payload(
document_json: str,
*,
doc_id: str,
title: str | None = None,
max_pages: int = 200,
) -> GraphPayload:
"""Build a `GraphPayload` equivalent to `fetch_graph(neo4j, doc_id)` from
the raw `DoclingDocument` JSON.
Returns `truncated=True` with empty node/edge lists beyond `max_pages`, so
the caller can mirror the Neo4j endpoint's 413 behavior.
"""
doc_data = json.loads(document_json)
pages_raw = list(iter_pages(doc_data))
page_count = len(pages_raw)
if page_count > max_pages:
return GraphPayload(
doc_id=doc_id,
nodes=[],
edges=[],
node_count=0,
edge_count=0,
truncated=True,
page_count=page_count,
)
nodes: list[dict[str, Any]] = []
edges: list[dict[str, Any]] = []
doc_node_id = f"doc::{doc_id}"
nodes.append(
{
"id": doc_node_id,
"group": "document",
"doc_id": doc_id,
"title": title,
# `stages_applied` is a Neo4j-only artifact; keep the key present
# for shape parity but leave it empty since SQLite doesn't track it.
"stages_applied": [],
}
)
# Page nodes.
for p in pages_raw:
nodes.append(_page_node(doc_id, p))
# Element nodes + collect parent/body metadata for edges below. The
# `element_idx` mirrors TreeWriter's `enumerate(elements)` so PARENT_OF
# carries the same `order` the Neo4j projection does.
by_ref: dict[str, dict[str, Any]] = {}
element_idx = 0
for _, item in iter_items(doc_data):
ref = item.get("self_ref")
if not ref:
continue
by_ref[ref] = item
provs = iter_provs(item)
nodes.append(_element_node(doc_id, item, provs))
pref = parent_ref(item)
if pref == "#/body":
edges.append(_edge(doc_node_id, f"elem::{ref}", "HAS_ROOT"))
elif pref:
edges.append(_edge(f"elem::{pref}", f"elem::{ref}", "PARENT_OF", order=element_idx))
# ON_PAGE, dedup'd per (element, page) — matches the Neo4j query's
# DISTINCT projection through Provenance.
seen_pages: set[int] = set()
for prov in provs:
page_no = prov.get("page_no")
if page_no is None or page_no in seen_pages:
continue
seen_pages.add(page_no)
edges.append(_edge(f"elem::{ref}", f"page::{page_no}", "ON_PAGE"))
element_idx += 1
# NEXT chain (DFS pre-order from body).
for a, b in pairwise(dfs_order(doc_data)):
if a in by_ref and b in by_ref:
edges.append(_edge(f"elem::{a}", f"elem::{b}", "NEXT"))
return GraphPayload(
doc_id=doc_id,
nodes=nodes,
edges=edges,
node_count=len(nodes),
edge_count=len(edges),
truncated=False,
page_count=page_count,
)

View file

@ -0,0 +1,136 @@
"""Pure helpers over a serialized `DoclingDocument` dict.
No I/O, no Neo4j. Shared between:
- `infra.neo4j.tree_writer` persists the tree into Neo4j during the Maintain
step (IngestionPipeline).
- `infra.docling_graph` builds an in-memory `GraphPayload` from the SQLite
`document_json` blob for the reasoning-trace viewer.
Keep this module the single source of truth for how we read Docling's own
structure, so the two consumers can't drift.
"""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
# Docling label -> specific Neo4j/Cytoscape label. Every element carries the
# generic :Element tag too. Kept 1:1 with docling-core's label taxonomy so the
# projection is a faithful mirror of the DoclingDocument.
LABEL_MAP: dict[str, str] = {
"section_header": "SectionHeader",
"title": "SectionHeader",
"paragraph": "Paragraph",
"text": "Paragraph",
"list_item": "ListItem",
"list": "List", # distinct from :ListItem — a list is a container
"table": "Table",
"picture": "Figure",
"formula": "Formula",
"code": "Code",
"caption": "Caption",
"footnote": "Footnote",
"page_header": "PageHeader",
"page_footer": "PageFooter",
"key_value_area": "KeyValueArea",
"form_area": "FormArea",
"document_index": "DocumentIndex",
}
DEFAULT_LABEL = "TextElement"
def element_label(docling_label: str) -> str:
return LABEL_MAP.get(docling_label.lower(), DEFAULT_LABEL)
def iter_items(doc_data: dict[str, Any]) -> Iterator[tuple[str, dict[str, Any]]]:
"""Yield every item from texts/tables/pictures/groups with its source list key."""
for key in ("texts", "tables", "pictures", "groups"):
for item in doc_data.get(key, []) or []:
yield key, item
def parent_ref(item: dict[str, Any]) -> str | None:
parent = item.get("parent")
if isinstance(parent, dict):
return parent.get("$ref") or parent.get("cref")
return None
def iter_provs(item: dict[str, Any]) -> list[dict[str, Any]]:
"""Flatten a Docling item's `prov[]` into a list of dict rows.
A single item may have multiple provs when it spans page breaks or appears
more than once in the layout. The returned dicts carry the original index
under `order` so sequence is preserved.
"""
provs = item.get("prov") or []
rows: list[dict[str, Any]] = []
for idx, p in enumerate(provs):
bbox = p.get("bbox")
l_, t_, r_, b_ = 0.0, 0.0, 0.0, 0.0
if isinstance(bbox, dict):
l_ = float(bbox.get("l", 0.0) or 0.0)
t_ = float(bbox.get("t", 0.0) or 0.0)
r_ = float(bbox.get("r", 0.0) or 0.0)
b_ = float(bbox.get("b", 0.0) or 0.0)
elif isinstance(bbox, (list, tuple)) and len(bbox) >= 4:
l_, t_, r_, b_ = (float(x) for x in bbox[:4])
coord_origin = (bbox.get("coord_origin") if isinstance(bbox, dict) else None) or "TOPLEFT"
charspan = p.get("charspan") or []
rows.append(
{
"order": idx,
"page_no": p.get("page_no"),
"bbox_l": l_,
"bbox_t": t_,
"bbox_r": r_,
"bbox_b": b_,
"coord_origin": coord_origin,
"charspan_start": int(charspan[0]) if len(charspan) >= 1 else None,
"charspan_end": int(charspan[1]) if len(charspan) >= 2 else None,
}
)
return rows
def dfs_order(doc_data: dict[str, Any]) -> list[str]:
"""Return `self_ref`s in reading order (DFS pre-order from body)."""
by_ref: dict[str, dict[str, Any]] = {}
for _, item in iter_items(doc_data):
ref = item.get("self_ref")
if ref:
by_ref[ref] = item
body = doc_data.get("body") or {}
order: list[str] = []
def walk(children: list[dict[str, Any]] | None) -> None:
if not children:
return
for ch in children:
ref = ch.get("$ref") or ch.get("cref")
if not ref:
continue
order.append(ref)
child = by_ref.get(ref)
if child:
walk(child.get("children"))
walk(body.get("children"))
return order
def iter_pages(doc_data: dict[str, Any]) -> Iterator[dict[str, Any]]:
"""Yield page dicts with `page_no`, `width`, `height` from the `pages` map."""
for page_no_str, page_obj in (doc_data.get("pages") or {}).items():
try:
page_no = int(page_no_str)
except (TypeError, ValueError):
continue
size = (page_obj or {}).get("size") or {}
yield {
"page_no": page_no,
"width": size.get("width"),
"height": size.get("height"),
}

View file

@ -25,9 +25,31 @@ class GraphPayload:
# block contributes a single row — avoids the cartesian product that chained # block contributes a single row — avoids the cartesian product that chained
# OPTIONAL MATCH on 6+ edge types would produce (hangs on multi-page docs). # OPTIONAL MATCH on 6+ edge types would produce (hangs on multi-page docs).
# See: https://neo4j.com/developer/kb/using-subqueries-to-control-the-scope-of-aggregations/ # See: https://neo4j.com/developer/kb/using-subqueries-to-control-the-scope-of-aggregations/
#
# Provenance nodes (post-v0.6 refactor) are NOT returned as top-level graph
# nodes — they're metadata of their owning Element. We aggregate them inline
# per element, and derive a dedup'd ON_PAGE edge set from them.
_FETCH_GRAPH = """ _FETCH_GRAPH = """
MATCH (d:Document {id: $doc_id}) MATCH (d:Document {id: $doc_id})
CALL { WITH d MATCH (e:Element {doc_id: d.id}) RETURN collect(e) AS elements } CALL {
WITH d
MATCH (e:Element {doc_id: d.id})
OPTIONAL MATCH (e)-[hp:HAS_PROV]->(pv:Provenance)
WITH e, pv ORDER BY hp.order
WITH e,
collect(
CASE WHEN pv IS NULL THEN NULL ELSE {
order: pv.prov_order,
page_no: pv.page_no,
bbox_l: pv.bbox_l, bbox_t: pv.bbox_t,
bbox_r: pv.bbox_r, bbox_b: pv.bbox_b,
coord_origin: pv.coord_origin,
charspan_start: pv.charspan_start,
charspan_end: pv.charspan_end
} END
) AS all_provs
RETURN collect({element: e, provs: [p IN all_provs WHERE p IS NOT NULL]}) AS elements
}
CALL { WITH d MATCH (p:Page {doc_id: d.id}) RETURN collect(p) AS pages } CALL { WITH d MATCH (p:Page {doc_id: d.id}) RETURN collect(p) AS pages }
CALL { WITH d MATCH (c:Chunk {doc_id: d.id}) RETURN collect(c) AS chunks } CALL { WITH d MATCH (c:Chunk {doc_id: d.id}) RETURN collect(c) AS chunks }
CALL { CALL {
@ -42,7 +64,10 @@ CALL {
} }
CALL { CALL {
WITH d WITH d
MATCH (er:Element {doc_id: d.id})-[:ON_PAGE]->(pr:Page) // ON_PAGE is stored on Provenance since v0.6; surface it at the Element
// level (dedup'd per Element/Page pair) for the Cytoscape viz.
MATCH (er:Element {doc_id: d.id})-[:HAS_PROV]->(:Provenance)-[:ON_PAGE]->(pr:Page)
WITH DISTINCT er, pr
RETURN collect({from: er.self_ref, to: pr.page_no, type: 'ON_PAGE'}) AS on_page_edges RETURN collect({from: er.self_ref, to: pr.page_no, type: 'ON_PAGE'}) AS on_page_edges
} }
CALL { CALL {
@ -66,17 +91,25 @@ RETURN d AS document, elements, pages, chunks,
""" """
def _element_node(doc_id: str, e: dict[str, Any]) -> dict[str, Any]: def _element_node(
doc_id: str, e: dict[str, Any], provs: list[dict[str, Any]] | None = None
) -> dict[str, Any]:
# Determine the specific element label: Neo4j returns it via labels(e) on the # Determine the specific element label: Neo4j returns it via labels(e) on the
# driver side; when we project nodes via RETURN, the driver wraps them as Node # driver side; when we project nodes via RETURN, the driver wraps them as Node
# objects, so we convert below. # objects, so we convert below.
first_page: int | None = None
if provs:
# Convenience: the first provenance's page — the old `prov_page` property,
# useful for label rendering in Cytoscape. Full list is in `provs`.
first_page = provs[0].get("page_no")
return { return {
"id": f"elem::{e.get('self_ref')}", "id": f"elem::{e.get('self_ref')}",
"group": "element", "group": "element",
"docling_label": e.get("docling_label"), "docling_label": e.get("docling_label"),
"self_ref": e.get("self_ref"), "self_ref": e.get("self_ref"),
"text": (e.get("text") or "")[:200], "text": (e.get("text") or "")[:200],
"prov_page": e.get("prov_page"), "prov_page": first_page,
"provs": provs or [],
"level": e.get("level"), "level": e.get("level"),
"doc_id": doc_id, "doc_id": doc_id,
} }
@ -171,11 +204,17 @@ async def fetch_graph(
) )
# Element nodes, keeping the specific label (:SectionHeader, etc.). # Element nodes, keeping the specific label (:SectionHeader, etc.).
for e in record["elements"] or []: # Each row is a {element, provs} dict from the CALL above; provs is a list
# of per-provenance dicts in original order.
for row in record["elements"] or []:
if row is None:
continue
e = row.get("element") if isinstance(row, dict) else None
if e is None: if e is None:
continue continue
provs = [p for p in (row.get("provs") or []) if p is not None]
labels = [label for label in e.labels if label != "Element"] labels = [label for label in e.labels if label != "Element"]
node = _element_node(doc_id, dict(e)) node = _element_node(doc_id, dict(e), provs=provs)
node["label"] = labels[0] if labels else "TextElement" node["label"] = labels[0] if labels else "TextElement"
nodes.append(node) nodes.append(node)

View file

@ -27,6 +27,12 @@ CONSTRAINTS: tuple[str, ...] = (
INDEXES: tuple[str, ...] = ( INDEXES: tuple[str, ...] = (
"CREATE INDEX element_doc IF NOT EXISTS FOR (e:Element) ON (e.doc_id)", "CREATE INDEX element_doc IF NOT EXISTS FOR (e:Element) ON (e.doc_id)",
"CREATE INDEX chunk_doc IF NOT EXISTS FOR (c:Chunk) ON (c.doc_id)", "CREATE INDEX chunk_doc IF NOT EXISTS FOR (c:Chunk) ON (c.doc_id)",
# Reasoning tunnel / bbox-highlight: looking up a Provenance by its owner
# element is a hot path (one lookup per visited section). Composite index
# avoids a full scan of every Provenance in the DB.
"CREATE INDEX provenance_element IF NOT EXISTS "
"FOR (pv:Provenance) ON (pv.doc_id, pv.element_ref)",
"CREATE INDEX provenance_page IF NOT EXISTS FOR (pv:Provenance) ON (pv.doc_id, pv.page_no)",
) )
FULLTEXT_INDEXES: tuple[str, ...] = ( FULLTEXT_INDEXES: tuple[str, ...] = (

View file

@ -16,80 +16,41 @@ from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from infra.docling_tree import (
dfs_order,
element_label,
iter_items,
iter_pages,
iter_provs,
parent_ref,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from infra.neo4j.driver import Neo4jDriver from infra.neo4j.driver import Neo4jDriver
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Docling label → specific Neo4j label. Every node also carries :Element.
_LABEL_MAP: dict[str, str] = {
"section_header": "SectionHeader",
"title": "SectionHeader",
"paragraph": "Paragraph",
"text": "Paragraph",
"list_item": "ListItem",
"list": "ListItem",
"table": "Table",
"picture": "Figure",
"formula": "Formula",
"code": "Code",
"caption": "Caption",
"footnote": "Footnote",
"page_header": "PageHeader",
"page_footer": "PageFooter",
}
_DEFAULT_LABEL = "TextElement"
def _element_label(docling_label: str) -> str:
return _LABEL_MAP.get(docling_label.lower(), _DEFAULT_LABEL)
@dataclass @dataclass
class TreeWriteResult: class TreeWriteResult:
doc_id: str doc_id: str
elements_written: int elements_written: int
pages_written: int pages_written: int
provenances_written: int = 0
def _iter_items(doc_data: dict[str, Any]):
"""Yield every item from texts/tables/pictures/groups with its source list."""
for key in ("texts", "tables", "pictures", "groups"):
for item in doc_data.get(key, []) or []:
yield key, item
def _first_prov(item: dict[str, Any]) -> tuple[int | None, list[float] | None]:
prov = item.get("prov") or []
if not prov:
return None, None
p0 = prov[0]
bbox = p0.get("bbox")
bbox_list: list[float] | None = None
if isinstance(bbox, dict):
bbox_list = [bbox.get("l", 0.0), bbox.get("t", 0.0), bbox.get("r", 0.0), bbox.get("b", 0.0)]
elif isinstance(bbox, list):
bbox_list = list(bbox)
return p0.get("page_no"), bbox_list
def _parent_ref(item: dict[str, Any]) -> str | None:
parent = item.get("parent")
if isinstance(parent, dict):
return parent.get("$ref") or parent.get("cref")
return None
def _element_props(item: dict[str, Any], doc_id: str) -> dict[str, Any]: def _element_props(item: dict[str, Any], doc_id: str) -> dict[str, Any]:
page, bbox = _first_prov(item) """Properties stored on the `:Element` node itself.
Provenance (page + bbox) is NOT here anymore see `_iter_provs` and the
`:Provenance` nodes. Keeping it out of the element matches DoclingDocument's
own model (`prov` is a list of objects, not a scalar).
"""
props: dict[str, Any] = { props: dict[str, Any] = {
"doc_id": doc_id, "doc_id": doc_id,
"self_ref": item.get("self_ref") or "", "self_ref": item.get("self_ref") or "",
"docling_label": (item.get("label") or "").lower(), "docling_label": (item.get("label") or "").lower(),
"text": item.get("text") or "", "text": item.get("text") or "",
"prov_page": page,
"prov_bbox": bbox,
} }
# Type-specific extras. # Type-specific extras.
if "level" in item: if "level" in item:
@ -103,32 +64,6 @@ def _element_props(item: dict[str, Any], doc_id: str) -> dict[str, Any]:
return props return props
def _dfs_order(doc_data: dict[str, Any]) -> list[str]:
"""Return self_refs in reading order (DFS pre-order from body)."""
by_ref: dict[str, dict[str, Any]] = {}
for _, item in _iter_items(doc_data):
ref = item.get("self_ref")
if ref:
by_ref[ref] = item
body = doc_data.get("body") or {}
order: list[str] = []
def walk(children: list[dict[str, Any]] | None) -> None:
if not children:
return
for ch in children:
ref = ch.get("$ref") or ch.get("cref")
if not ref:
continue
order.append(ref)
child = by_ref.get(ref)
if child:
walk(child.get("children"))
walk(body.get("children"))
return order
async def write_document( async def write_document(
neo: Neo4jDriver, neo: Neo4jDriver,
*, *,
@ -148,36 +83,28 @@ async def write_document(
ingested_at = datetime.now(tz=UTC).isoformat() ingested_at = datetime.now(tz=UTC).isoformat()
elements: list[dict[str, Any]] = [] elements: list[dict[str, Any]] = []
for _, item in _iter_items(doc_data): # Parallel list: one row per Provenance — each refers back to its owner
# element via `self_ref`, so we can batch MATCH-and-link after both node
# sets are created.
provenances: list[dict[str, Any]] = []
for _, item in iter_items(doc_data):
ref = item.get("self_ref") ref = item.get("self_ref")
if not ref: if not ref:
continue continue
specific = _element_label(item.get("label") or "") specific = element_label(item.get("label") or "")
elements.append( elements.append(
{ {
"specific_label": specific, "specific_label": specific,
"parent_ref": _parent_ref(item), "parent_ref": parent_ref(item),
**_element_props(item, doc_id), **_element_props(item, doc_id),
} }
) )
for prov in iter_provs(item):
provenances.append({"doc_id": doc_id, "self_ref": ref, **prov})
pages: list[dict[str, Any]] = [] pages: list[dict[str, Any]] = [{"doc_id": doc_id, **p} for p in iter_pages(doc_data)]
for page_no_str, page_obj in (doc_data.get("pages") or {}).items():
try:
page_no = int(page_no_str)
except (TypeError, ValueError):
continue
size = page_obj.get("size") or {}
pages.append(
{
"doc_id": doc_id,
"page_no": page_no,
"width": size.get("width"),
"height": size.get("height"),
}
)
reading_order = _dfs_order(doc_data) reading_order = dfs_order(doc_data)
async with ( async with (
neo.driver.session(database=neo.database) as session, neo.driver.session(database=neo.database) as session,
@ -190,7 +117,9 @@ async def write_document(
"DETACH DELETE d, n", "DETACH DELETE d, n",
doc_id=doc_id, doc_id=doc_id,
) )
# Also wipe orphan elements/chunks that may still reference this doc. # Orphan sweep — covers Provenance/Element/Page/Chunk that may linger
# from an interrupted write or a pre-refactor schema.
await tx.run("MATCH (pv:Provenance {doc_id: $doc_id}) DETACH DELETE pv", doc_id=doc_id)
await tx.run("MATCH (e:Element {doc_id: $doc_id}) DETACH DELETE e", doc_id=doc_id) await tx.run("MATCH (e:Element {doc_id: $doc_id}) DETACH DELETE e", doc_id=doc_id)
await tx.run("MATCH (p:Page {doc_id: $doc_id}) DETACH DELETE p", doc_id=doc_id) await tx.run("MATCH (p:Page {doc_id: $doc_id}) DETACH DELETE p", doc_id=doc_id)
@ -241,8 +170,6 @@ async def write_document(
self_ref: e.self_ref, self_ref: e.self_ref,
docling_label: e.docling_label, docling_label: e.docling_label,
text: e.text, text: e.text,
prov_page: e.prov_page,
prov_bbox: e.prov_bbox,
level: e.level, level: e.level,
caption: e.caption, caption: e.caption,
cells_json: e.cells_json cells_json: e.cells_json
@ -291,21 +218,48 @@ async def write_document(
rows=root_rows, rows=root_rows,
) )
# 7. ON_PAGE from first provenance. # 7. Provenance nodes — one per (element, prov-entry) pair. Mirrors
on_page_rows = [ # Docling's `item.prov = list[ProvenanceItem]` 1:1 so a single item
{"doc_id": doc_id, "self_ref": e["self_ref"], "page_no": e["prov_page"]} # that spans page breaks (or appears twice in the layout) keeps every
for e in elements # (page, bbox, charspan) without losing data.
if e["prov_page"] is not None if provenances:
]
if on_page_rows:
await tx.run( await tx.run(
""" """
UNWIND $rows AS r UNWIND $rows AS r
MATCH (e:Element {doc_id: r.doc_id, self_ref: r.self_ref}) MATCH (e:Element {doc_id: r.doc_id, self_ref: r.self_ref})
MATCH (p:Page {doc_id: r.doc_id, page_no: r.page_no}) CREATE (pv:Provenance {
MERGE (e)-[:ON_PAGE]->(p) doc_id: r.doc_id,
element_ref: r.self_ref,
prov_order: r.order,
page_no: r.page_no,
bbox_l: r.bbox_l,
bbox_t: r.bbox_t,
bbox_r: r.bbox_r,
bbox_b: r.bbox_b,
coord_origin: r.coord_origin,
charspan_start: r.charspan_start,
charspan_end: r.charspan_end
})
CREATE (e)-[:HAS_PROV {order: r.order}]->(pv)
""", """,
rows=on_page_rows, rows=provenances,
)
# ON_PAGE now attaches the Provenance to its Page — lets downstream
# queries ("what's on page 3?") stay simple without walking through
# the Element. A Provenance with no page_no (rare) yields no edge.
await tx.run(
"""
UNWIND $rows AS r
WITH r WHERE r.page_no IS NOT NULL
MATCH (pv:Provenance {
doc_id: r.doc_id,
element_ref: r.self_ref,
prov_order: r.order
})
MATCH (p:Page {doc_id: r.doc_id, page_no: r.page_no})
MERGE (pv)-[:ON_PAGE]->(p)
""",
rows=provenances,
) )
# 8. NEXT chain in DFS pre-order. # 8. NEXT chain in DFS pre-order.
@ -327,9 +281,15 @@ async def write_document(
await tx.commit() await tx.commit()
logger.info( logger.info(
"Neo4j: wrote doc %s (%d elements, %d pages)", "Neo4j: wrote doc %s (%d elements, %d pages, %d provenances)",
doc_id, doc_id,
len(elements), len(elements),
len(pages), len(pages),
len(provenances),
)
return TreeWriteResult(
doc_id=doc_id,
elements_written=len(elements),
pages_written=len(pages),
provenances_written=len(provenances),
) )
return TreeWriteResult(doc_id=doc_id, elements_written=len(elements), pages_written=len(pages))

View file

@ -167,6 +167,12 @@ def _build_document_service(
async def lifespan(app: FastAPI) -> AsyncIterator[None]: async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await init_db() await init_db()
document_repo, analysis_repo = _build_repos() document_repo, analysis_repo = _build_repos()
# Exposed on app.state so routers that need direct repo access (e.g. the
# reasoning-graph endpoint, which reads `document_json` from SQLite to
# build the graph without touching Neo4j) can reach them without going
# through a service.
app.state.analysis_repo = analysis_repo
app.state.document_repo = document_repo
app.state.neo4j = await _init_neo4j() app.state.neo4j = await _init_neo4j()
app.state.analysis_service = _build_analysis_service( app.state.analysis_service = _build_analysis_service(
document_repo, analysis_repo, neo4j_driver=app.state.neo4j document_repo, analysis_repo, neo4j_driver=app.state.neo4j

View file

@ -73,6 +73,23 @@ class SqliteAnalysisRepository:
row = await cursor.fetchone() row = await cursor.fetchone()
return _row_to_job(row) if row else None return _row_to_job(row) if row else None
async def find_latest_completed_by_document(self, document_id: str) -> AnalysisJob | None:
"""Latest COMPLETED analysis with a non-null `document_json` for a doc.
Used by the reasoning-trace tunnel to prime Neo4j from an existing
analysis when the graph doesn't yet exist (e.g. analysis ran before
Neo4j was wired in).
"""
async with get_connection() as db:
cursor = await db.execute(
f"{_SELECT_WITH_DOC} WHERE aj.document_id = ? "
"AND aj.status = 'COMPLETED' AND aj.document_json IS NOT NULL "
"ORDER BY aj.completed_at DESC LIMIT 1",
(document_id,),
)
row = await cursor.fetchone()
return _row_to_job(row) if row else None
async def update_status(self, job: AnalysisJob) -> None: async def update_status(self, job: AnalysisJob) -> None:
"""Persist all mutable fields of an analysis job (status, results, timestamps).""" """Persist all mutable fields of an analysis job (status, results, timestamps)."""
async with get_connection() as db: async with get_connection() as db:

View file

@ -102,8 +102,10 @@ async def test_fetch_graph_returns_full_payload(neo4j_driver):
assert groups == {"document", "element", "page", "chunk"} assert groups == {"document", "element", "page", "chunk"}
edge_types = {e["type"] for e in payload.edges} edge_types = {e["type"] for e in payload.edges}
# Every edge kind written by TreeWriter and ChunkWriter should be present. # Every edge kind this fixture can produce should be present.
assert {"HAS_ROOT", "PARENT_OF", "NEXT", "ON_PAGE", "HAS_CHUNK", "DERIVED_FROM"} <= edge_types # PARENT_OF is intentionally excluded: all FIXTURE items have
# `parent = #/body`, so they're roots (→ HAS_ROOT) with no nested hierarchy.
assert {"HAS_ROOT", "NEXT", "ON_PAGE", "HAS_CHUNK", "DERIVED_FROM"} <= edge_types
async def test_fetch_graph_missing_doc_returns_none(neo4j_driver): async def test_fetch_graph_missing_doc_returns_none(neo4j_driver):

View file

@ -86,6 +86,8 @@ async def test_write_creates_expected_structure(neo4j_driver):
assert result.elements_written == 4 assert result.elements_written == 4
assert result.pages_written == 2 assert result.pages_written == 2
# Every item in FIXTURE has exactly one prov entry → 4 Provenance nodes.
assert result.provenances_written == 4
async with neo4j_driver.driver.session(database=neo4j_driver.database) as s: async with neo4j_driver.driver.session(database=neo4j_driver.database) as s:
assert ( assert (
@ -131,16 +133,28 @@ async def test_write_creates_expected_structure(neo4j_driver):
) )
== 3 == 3
) )
# ON_PAGE: one per element with prov. # Post-v0.6: ON_PAGE attaches Provenance to Page, not Element directly.
# Traverse through the Provenance node.
assert ( assert (
await _count( await _count(
s, s,
"MATCH (:Element {doc_id: $id})-[:ON_PAGE]->(:Page {doc_id: $id}) " "MATCH (:Element {doc_id: $id})-[:HAS_PROV]->"
"(:Provenance)-[:ON_PAGE]->(:Page {doc_id: $id}) "
"RETURN count(*) AS n", "RETURN count(*) AS n",
id="doc-fixture", id="doc-fixture",
) )
== 4 == 4
) )
# Each element has exactly one Provenance here (single-page fixture).
assert (
await _count(
s,
"MATCH (e:Element {doc_id: $id})-[:HAS_PROV]->(pv:Provenance) "
"RETURN count(pv) AS n",
id="doc-fixture",
)
== 4
)
async def test_rewrite_is_idempotent_replace(neo4j_driver): async def test_rewrite_is_idempotent_replace(neo4j_driver):

View file

@ -501,7 +501,8 @@ class TestRemoteChunkingPath:
markdown="# Title\nParagraph text here.", markdown="# Title\nParagraph text here.",
html="<h1>Title</h1><p>Paragraph text here.</p>", html="<h1>Title</h1><p>Paragraph text here.</p>",
pages_json="[]", pages_json="[]",
document_json=json.dumps({ document_json=json.dumps(
{
"schema_name": "DoclingDocument", "schema_name": "DoclingDocument",
"version": "1.0.0", "version": "1.0.0",
"name": "test", "name": "test",
@ -510,7 +511,11 @@ class TestRemoteChunkingPath:
"filename": "test.pdf", "filename": "test.pdf",
"binary_hash": 0, "binary_hash": 0,
}, },
"furniture": {"self_ref": "#/furniture", "children": [], "content_layer": "furniture"}, "furniture": {
"self_ref": "#/furniture",
"children": [],
"content_layer": "furniture",
},
"body": {"self_ref": "#/body", "children": [], "content_layer": "body"}, "body": {"self_ref": "#/body", "children": [], "content_layer": "body"},
"groups": [], "groups": [],
"texts": [], "texts": [],
@ -519,7 +524,8 @@ class TestRemoteChunkingPath:
"key_value_items": [], "key_value_items": [],
"form_items": [], "form_items": [],
"pages": {}, "pages": {},
}), }
),
) )
analysis_repo.find_by_id = AsyncMock(return_value=job) analysis_repo.find_by_id = AsyncMock(return_value=job)
analysis_repo.update_chunks = AsyncMock(return_value=True) analysis_repo.update_chunks = AsyncMock(return_value=True)

View file

@ -0,0 +1,238 @@
"""Tests for `infra.docling_graph.build_graph_payload`.
The fixture mirrors the DoclingDocument shape used in
`tests/neo4j/test_tree_writer.py` so any structural drift between the two
consumers (TreeWriter -> Neo4j, builder -> SQLite reasoning-graph) surfaces
immediately.
"""
from __future__ import annotations
import json
from infra.docling_graph import build_graph_payload
FIXTURE = {
"name": "fixture.pdf",
"pages": {
"1": {"page_no": 1, "size": {"width": 595, "height": 842}},
"2": {"page_no": 2, "size": {"width": 595, "height": 842}},
},
"body": {
"self_ref": "#/body",
"children": [
{"$ref": "#/texts/0"},
{"$ref": "#/texts/1"},
{"$ref": "#/texts/2"},
{"$ref": "#/tables/0"},
],
},
"texts": [
{
"self_ref": "#/texts/0",
"parent": {"$ref": "#/body"},
"label": "section_header",
"text": "Introduction",
"level": 1,
"prov": [{"page_no": 1, "bbox": {"l": 10, "t": 10, "r": 100, "b": 30}}],
},
{
"self_ref": "#/texts/1",
"parent": {"$ref": "#/body"},
"label": "paragraph",
"text": "First paragraph on page 1.",
"prov": [{"page_no": 1, "bbox": {"l": 10, "t": 40, "r": 500, "b": 80}}],
},
{
"self_ref": "#/texts/2",
"parent": {"$ref": "#/body"},
"label": "paragraph",
"text": "Continued on page 2.",
"prov": [{"page_no": 2, "bbox": {"l": 10, "t": 40, "r": 500, "b": 80}}],
},
],
"tables": [
{
"self_ref": "#/tables/0",
"parent": {"$ref": "#/body"},
"label": "table",
"text": "",
"data": {"num_rows": 2, "num_cols": 2},
"prov": [{"page_no": 2, "bbox": {"l": 10, "t": 90, "r": 500, "b": 200}}],
}
],
"pictures": [],
"groups": [],
}
def _ids(items, group=None):
return [i["id"] for i in items if group is None or i.get("group") == group]
def _types(edges):
return [e["type"] for e in edges]
def test_builds_document_page_and_element_nodes():
payload = build_graph_payload(json.dumps(FIXTURE), doc_id="doc-fixture", title="fixture.pdf")
assert payload.doc_id == "doc-fixture"
assert payload.page_count == 2
assert payload.truncated is False
assert _ids(payload.nodes, group="document") == ["doc::doc-fixture"]
assert set(_ids(payload.nodes, group="page")) == {"page::1", "page::2"}
assert set(_ids(payload.nodes, group="element")) == {
"elem::#/texts/0",
"elem::#/texts/1",
"elem::#/texts/2",
"elem::#/tables/0",
}
def test_element_keeps_docling_label_and_specific_label():
payload = build_graph_payload(json.dumps(FIXTURE), doc_id="doc-fixture")
by_id = {n["id"]: n for n in payload.nodes}
section = by_id["elem::#/texts/0"]
para = by_id["elem::#/texts/1"]
table = by_id["elem::#/tables/0"]
# Specific label mirrors TreeWriter's `_LABEL_MAP`.
assert section["label"] == "SectionHeader"
assert para["label"] == "Paragraph"
assert table["label"] == "Table"
# Docling's original label is preserved (lowercased) for filtering.
assert section["docling_label"] == "section_header"
assert para["docling_label"] == "paragraph"
def test_provs_are_carried_on_element_nodes():
payload = build_graph_payload(json.dumps(FIXTURE), doc_id="doc-fixture")
by_id = {n["id"]: n for n in payload.nodes}
para = by_id["elem::#/texts/1"]
assert para["prov_page"] == 1
assert len(para["provs"]) == 1
assert para["provs"][0]["bbox_l"] == 10.0
assert para["provs"][0]["page_no"] == 1
def test_has_root_parent_next_and_on_page_edges():
payload = build_graph_payload(json.dumps(FIXTURE), doc_id="doc-fixture")
types = _types(payload.edges)
# 4 top-level children => 4 HAS_ROOT.
assert types.count("HAS_ROOT") == 4
# 4 elements in reading order => 3 NEXT edges.
assert types.count("NEXT") == 3
# 4 elements each on 1 page => 4 ON_PAGE edges.
assert types.count("ON_PAGE") == 4
# No PARENT_OF in this flat fixture (all parents are #/body).
assert types.count("PARENT_OF") == 0
def test_on_page_edges_point_to_correct_pages():
payload = build_graph_payload(json.dumps(FIXTURE), doc_id="doc-fixture")
on_page = [(e["source"], e["target"]) for e in payload.edges if e["type"] == "ON_PAGE"]
assert ("elem::#/texts/0", "page::1") in on_page
assert ("elem::#/texts/2", "page::2") in on_page
assert ("elem::#/tables/0", "page::2") in on_page
def test_on_page_dedups_when_element_has_multiple_provs_same_page():
# Paragraph with two provs on the same page — we expect ONE ON_PAGE edge.
fixture = {
**FIXTURE,
"texts": [
{
"self_ref": "#/texts/0",
"parent": {"$ref": "#/body"},
"label": "paragraph",
"text": "Split across two provs same page",
"prov": [
{"page_no": 1, "bbox": {"l": 0, "t": 0, "r": 10, "b": 10}},
{"page_no": 1, "bbox": {"l": 0, "t": 20, "r": 10, "b": 30}},
],
},
],
"tables": [],
"body": {"self_ref": "#/body", "children": [{"$ref": "#/texts/0"}]},
}
payload = build_graph_payload(json.dumps(fixture), doc_id="doc-split")
on_page = [e for e in payload.edges if e["type"] == "ON_PAGE"]
assert len(on_page) == 1
def test_parent_of_edges_when_items_are_nested():
fixture = {
"pages": {"1": {"page_no": 1, "size": {"width": 595, "height": 842}}},
"body": {"self_ref": "#/body", "children": [{"$ref": "#/texts/0"}]},
"texts": [
{
"self_ref": "#/texts/0",
"parent": {"$ref": "#/body"},
"label": "section_header",
"text": "Chapter",
"level": 1,
"children": [{"$ref": "#/texts/1"}],
"prov": [{"page_no": 1, "bbox": {"l": 0, "t": 0, "r": 10, "b": 10}}],
},
{
"self_ref": "#/texts/1",
"parent": {"$ref": "#/texts/0"},
"label": "paragraph",
"text": "Body",
"prov": [{"page_no": 1, "bbox": {"l": 0, "t": 20, "r": 10, "b": 30}}],
},
],
"tables": [],
"pictures": [],
"groups": [],
}
payload = build_graph_payload(json.dumps(fixture), doc_id="doc-nested")
parents = [(e["source"], e["target"]) for e in payload.edges if e["type"] == "PARENT_OF"]
assert parents == [("elem::#/texts/0", "elem::#/texts/1")]
roots = [e for e in payload.edges if e["type"] == "HAS_ROOT"]
assert len(roots) == 1 # only the section is a direct child of body
# NEXT follows DFS order: #/texts/0 -> #/texts/1
nexts = [(e["source"], e["target"]) for e in payload.edges if e["type"] == "NEXT"]
assert nexts == [("elem::#/texts/0", "elem::#/texts/1")]
def test_truncated_when_page_count_exceeds_cap():
fixture = {
**FIXTURE,
"pages": {str(i): {"page_no": i, "size": {"width": 1, "height": 1}} for i in range(1, 12)},
}
payload = build_graph_payload(json.dumps(fixture), doc_id="doc-big", max_pages=10)
assert payload.truncated is True
assert payload.page_count == 11
assert payload.nodes == []
assert payload.edges == []
def test_title_is_surfaced_on_document_node():
payload = build_graph_payload(json.dumps(FIXTURE), doc_id="doc-fixture", title="My Doc.pdf")
doc_node = next(n for n in payload.nodes if n["group"] == "document")
assert doc_node["title"] == "My Doc.pdf"
def test_element_text_is_capped_at_200_chars():
long = "x" * 500
fixture = {
"pages": {"1": {"page_no": 1, "size": {"width": 1, "height": 1}}},
"body": {"self_ref": "#/body", "children": [{"$ref": "#/texts/0"}]},
"texts": [
{
"self_ref": "#/texts/0",
"parent": {"$ref": "#/body"},
"label": "paragraph",
"text": long,
"prov": [{"page_no": 1}],
}
],
"tables": [],
"pictures": [],
"groups": [],
}
payload = build_graph_payload(json.dumps(fixture), doc_id="doc-long")
para = next(n for n in payload.nodes if n.get("self_ref") == "#/texts/0")
assert len(para["text"]) == 200

View file

@ -0,0 +1,114 @@
"""Tests for `api.graph` — the `/graph` (Neo4j) and `/reasoning-graph`
(SQLite) endpoints. Neo4j itself is not exercised here; `/graph` is covered
by the integration tests under `tests/neo4j/`. This file focuses on the
SQLite-backed reasoning endpoint and the error paths.
"""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.graph import router
from domain.models import AnalysisJob
FIXTURE = {
"pages": {"1": {"page_no": 1, "size": {"width": 595, "height": 842}}},
"body": {"self_ref": "#/body", "children": [{"$ref": "#/texts/0"}]},
"texts": [
{
"self_ref": "#/texts/0",
"parent": {"$ref": "#/body"},
"label": "section_header",
"text": "Hello",
"level": 1,
"prov": [{"page_no": 1, "bbox": {"l": 0, "t": 0, "r": 10, "b": 10}}],
}
],
"tables": [],
"pictures": [],
"groups": [],
}
def _job_with_doc_json() -> AnalysisJob:
job = AnalysisJob(document_id="doc-1")
job.document_filename = "hello.pdf"
job.mark_running()
job.mark_completed(
markdown="# Hello",
html="<h1>Hello</h1>",
pages_json="[]",
document_json=json.dumps(FIXTURE),
chunks_json="[]",
)
return job
@pytest.fixture
def mock_analysis_repo() -> AsyncMock:
repo = AsyncMock()
repo.find_latest_completed_by_document.return_value = _job_with_doc_json()
return repo
@pytest.fixture
def client(mock_analysis_repo: AsyncMock) -> TestClient:
app = FastAPI()
app.include_router(router)
app.state.analysis_repo = mock_analysis_repo
app.state.neo4j = None # /reasoning-graph must not need Neo4j
return TestClient(app)
class TestReasoningGraph:
def test_returns_payload_built_from_sqlite_json(self, client: TestClient) -> None:
resp = client.get("/api/documents/doc-1/reasoning-graph")
assert resp.status_code == 200
data = resp.json()
assert data["doc_id"] == "doc-1"
assert data["page_count"] == 1
assert data["truncated"] is False
groups = {n["group"] for n in data["nodes"]}
assert groups == {"document", "page", "element"}
edge_types = {e["type"] for e in data["edges"]}
# HAS_ROOT + ON_PAGE expected; NEXT absent (single element so no chain).
assert edge_types == {"HAS_ROOT", "ON_PAGE"}
def test_404_when_no_completed_analysis(
self, client: TestClient, mock_analysis_repo: AsyncMock
) -> None:
mock_analysis_repo.find_latest_completed_by_document.return_value = None
resp = client.get("/api/documents/doc-1/reasoning-graph")
assert resp.status_code == 404
def test_404_when_analysis_has_no_document_json(
self, client: TestClient, mock_analysis_repo: AsyncMock
) -> None:
job = AnalysisJob(document_id="doc-1")
job.mark_running()
job.mark_completed(
markdown="", html="", pages_json="[]", document_json=None, chunks_json="[]"
)
mock_analysis_repo.find_latest_completed_by_document.return_value = job
resp = client.get("/api/documents/doc-1/reasoning-graph")
assert resp.status_code == 404
def test_does_not_need_neo4j(self, client: TestClient) -> None:
# `app.state.neo4j = None` and the endpoint still serves — proves the
# reasoning graph is fully decoupled from the Neo4j provider.
resp = client.get("/api/documents/doc-1/reasoning-graph")
assert resp.status_code == 200
class TestPrimeEndpointRemoved:
def test_graph_prime_endpoint_is_gone(self, client: TestClient) -> None:
# Guardrail — if someone reintroduces /graph/prime we want a failing test.
resp = client.post("/api/documents/doc-1/graph/prime")
assert resp.status_code in (404, 405)

View file

@ -153,6 +153,49 @@ class TestAnalysisRepo:
deleted = await analysis_repo.delete("nonexistent") deleted = await analysis_repo.delete("nonexistent")
assert deleted is False assert deleted is False
async def test_find_latest_completed_by_document(self, document_repo, analysis_repo):
"""Reasoning tunnel helper: latest COMPLETED analysis with document_json."""
await self._insert_doc(document_repo)
# Each job must be insert()'d before update_status can touch it.
# Scenarios: pending (excluded — not COMPLETED), old completed without
# document_json (excluded — NULL json), recent completed with
# document_json (the one we want), running (excluded).
pending = AnalysisJob(id="job-pending", document_id="doc-1")
await analysis_repo.insert(pending)
old_completed = AnalysisJob(id="job-old", document_id="doc-1")
await analysis_repo.insert(old_completed)
old_completed.mark_running()
old_completed.mark_completed(markdown="", html="", pages_json="[]")
await analysis_repo.update_status(old_completed)
latest = AnalysisJob(id="job-latest", document_id="doc-1")
await analysis_repo.insert(latest)
latest.mark_running()
latest.mark_completed(
markdown="md",
html="<p/>",
pages_json="[]",
document_json='{"body":{"children":[]},"texts":[]}',
)
await analysis_repo.update_status(latest)
running = AnalysisJob(id="job-running", document_id="doc-1")
await analysis_repo.insert(running)
running.mark_running()
await analysis_repo.update_status(running)
found = await analysis_repo.find_latest_completed_by_document("doc-1")
assert found is not None
assert found.id == "job-latest"
assert found.document_json is not None
async def test_find_latest_completed_by_document_none(self, document_repo, analysis_repo):
await self._insert_doc(document_repo)
found = await analysis_repo.find_latest_completed_by_document("doc-1")
assert found is None
async def test_delete_by_document(self, document_repo, analysis_repo): async def test_delete_by_document(self, document_repo, analysis_repo):
await self._insert_doc(document_repo) await self._insert_doc(document_repo)
for i in range(3): for i in range(3):

View file

@ -0,0 +1 @@
output/

View file

@ -0,0 +1,139 @@
# Reasoning Trace — R&D sandbox
Goal: run `docling-agent`'s RAG loop against a document already ingested in
Docling-Studio, capture the `RAGResult` (per-iteration reasoning trace), and
inspect what the agent does.
Fully **isolated** from the Studio backend: no deps added to
`document-parser/`, no services modified. Just a script + uv inline deps.
---
## What it does
1. Reads the pre-parsed `DoclingDocument` directly from Studio's SQLite
(`analysis_jobs.document_json`) — no PDF re-conversion.
2. Instantiates `DoclingRAGAgent` against a local Ollama model.
3. Calls `agent._rag_loop()` directly (the public `.run()` method discards the
`RAGResult`; we need the iterations to see the reasoning trace).
4. Dumps the full `RAGResult` as JSON to `output/`.
---
## Prerequisites
### 1. Ollama running
```sh
# If not already running as a service:
ollama serve # in another terminal
```
### 2. A model pulled
Recommended (Peter Staar's default, ~3B params, good JSON adherence):
```sh
ollama pull granite4:micro-h
```
Alternative already on your machine (2 GB, may struggle with strict JSON
rejection sampling):
```
llama3.2:3b
```
Bigger/more reliable but slower (20B):
```sh
ollama pull gpt-oss:20b
```
### 3. Pick an analysis job id
Any `COMPLETED` row from `analysis_jobs` with a non-null `document_json`:
```sh
sqlite3 document-parser/data/docling_studio.db \
"SELECT aj.id, d.filename, length(aj.document_json)
FROM analysis_jobs aj JOIN documents d ON d.id=aj.document_id
WHERE aj.document_json IS NOT NULL AND aj.status='COMPLETED'
ORDER BY length(aj.document_json) DESC LIMIT 5;"
```
On this machine, the biggest one right now is:
```
722d5631-0089-44a3-a64a-7ce5b99579d3 — CCI - Conférence IA - Offre Commerciale v1.0
```
---
## Run
```sh
uv run experiments/reasoning-trace/inspect_doc.py \
--job-id 722d5631-0089-44a3-a64a-7ce5b99579d3 \
--query "Quels sont les livrables principaux proposés ?" \
--model granite4:micro-h
```
Flags:
- `--job-id` — required, analysis_jobs.id
- `--query` — required, the question
- `--model` — either a mellea catalog constant (`IBM_GRANITE_4_HYBRID_MICRO`)
or a raw Ollama tag (`granite4:micro-h`, `llama3.2:3b`). Default:
`granite4:micro-h`.
- `--max-iters` — default 5 (agent's own default)
- `--quiet` — disable the rich panels during the loop
First run will take ~12 min: `uv` solves the `docling-agent` env (pulls
docling-core, mellea, pydantic, rich, …) into a cached virtualenv. Subsequent
runs are instant.
---
## Output
`experiments/reasoning-trace/output/<job-id-prefix>_<utc>.json`
Schema:
```json
{
"job_id": "…",
"filename": "…",
"query": "…",
"model": { "ollama_name": "…", "hf_model_name": "…" },
"max_iterations": 5,
"result": {
"answer": "…",
"converged": true,
"iterations": [
{ "iteration": 1, "section_ref": "#/texts/3",
"reason": "…", "section_text_length": 412,
"can_answer": false, "response": "…" },
]
}
}
```
This is the artifact the v1 Studio endpoint (`POST /api/rag/inspect`) will
import — so anything that works here should work there.
---
## Things to check on first run
- **Do we actually get a trace?** `iterations` list should have ≥ 1 entries
(empty means "no section headers found" fallback — bad sign for the viz idea).
- **Are `section_ref` values `#/texts/N` paths or `#/groups/N`?** Determines
how the resolver walks the tree.
- **Reasoning quality**: does `reason` actually explain the pick, or is it
LLM filler? That affects whether the trace is worth surfacing visually.
- **Convergence rate**: with `max_iters=5`, does a small model converge at all,
or hit the cap and return a partial answer?
- **Latency**: per-iteration wall-clock on your M-series machine with granite4.
---
## Next step (if the above looks promising)
Resolve each `iteration.section_ref``(page_no, bbox)` using the same
`DoclingDocument` that was loaded here. That's the `reasoning_service.py`
resolver described in `docs/design/reasoning-trace.md` §3.2 — implement it in
a second script here (`resolve_trace.py`) before touching Studio.

View file

@ -0,0 +1,154 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "docling-agent",
# "rich",
# ]
# ///
"""
Run a docling-agent RAG inspection on a Docling-Studio analysis job and dump the RAGResult.
Bypasses `DoclingRAGAgent.run()` (which discards the RAGResult) and calls the private
`_rag_loop()` directly so we can capture the per-iteration trace.
Loads the DoclingDocument from Studio's SQLite (`analysis_jobs.document_json`), so no
re-parsing of the PDF is needed same doc the UI is showing.
Usage:
uv run experiments/reasoning-trace/inspect_doc.py \\
--job-id 722d5631-0089-44a3-a64a-7ce5b99579d3 \\
--query "Quels sont les points clés de l'offre ?" \\
--model granite4:micro-h
Output:
experiments/reasoning-trace/output/<job-id-prefix>_<utc-timestamp>.json
"""
from __future__ import annotations
import argparse
import json
import sqlite3
import sys
from datetime import datetime, timezone
from pathlib import Path
from docling_agent.agent.rag import DoclingRAGAgent
from docling_core.types.doc.document import DoclingDocument
from mellea.backends import model_ids as M
from mellea.backends.model_ids import ModelIdentifier
HERE = Path(__file__).resolve().parent
REPO = HERE.parents[1]
DB_PATH = REPO / "document-parser" / "data" / "docling_studio.db"
OUT_DIR = HERE / "output"
def load_doc(job_id: str) -> tuple[DoclingDocument, str]:
if not DB_PATH.exists():
sys.exit(f"SQLite DB not found at {DB_PATH}")
con = sqlite3.connect(DB_PATH)
con.row_factory = sqlite3.Row
row = con.execute(
"""
SELECT aj.document_json, d.filename
FROM analysis_jobs aj
JOIN documents d ON d.id = aj.document_id
WHERE aj.id = ?
""",
(job_id,),
).fetchone()
con.close()
if row is None:
sys.exit(f"No analysis job with id {job_id}")
if not row["document_json"]:
sys.exit(f"Analysis job {job_id} has no document_json (not completed?)")
return DoclingDocument.model_validate_json(row["document_json"]), row["filename"]
def resolve_model(name: str) -> ModelIdentifier:
"""Accept either a mellea catalog constant name (e.g. 'IBM_GRANITE_4_HYBRID_MICRO')
or a raw Ollama tag (e.g. 'granite4:micro-h', 'llama3.2:3b')."""
const = getattr(M, name.upper(), None)
if isinstance(const, ModelIdentifier):
return const
return ModelIdentifier(ollama_name=name)
def summarize_structure(doc: DoclingDocument) -> str:
from docling_core.types.doc.document import SectionHeaderItem, TitleItem
headers = [
item for item, _ in doc.iterate_items()
if isinstance(item, (TitleItem, SectionHeaderItem))
]
return (
f"texts={len(doc.texts)} "
f"tables={len(doc.tables)} "
f"pictures={len(doc.pictures)} "
f"groups={len(doc.groups)} "
f"section_headers={len(headers)}"
)
def main() -> None:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--job-id", required=True, help="analysis_jobs.id from Studio SQLite")
p.add_argument("--query", required=True, help="Question to ask the document")
p.add_argument(
"--model",
default="granite4:micro-h",
help="Ollama tag or mellea catalog constant (default: granite4:micro-h)",
)
p.add_argument("--max-iters", type=int, default=5)
p.add_argument("--quiet", action="store_true", help="disable rich progress panels")
args = p.parse_args()
print(f"→ Loading DoclingDocument from analysis {args.job_id[:8]}")
doc, filename = load_doc(args.job_id)
print(f" {filename}")
print(f" {summarize_structure(doc)}")
model_id = resolve_model(args.model)
print(f"→ Model: ollama={model_id.ollama_name!r} hf={model_id.hf_model_name!r}")
agent = DoclingRAGAgent(
model_id=model_id,
tools=[],
max_iterations=args.max_iters,
verbose=not args.quiet,
)
print(f"→ Running RAG loop (query: {args.query!r})\n")
# Intentional: agent.run() discards the RAGResult. _rag_loop gives us the trace.
result = agent._rag_loop(query=args.query, doc=doc)
OUT_DIR.mkdir(exist_ok=True)
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
out_path = OUT_DIR / f"{args.job_id[:8]}_{ts}.json"
payload = {
"job_id": args.job_id,
"filename": filename,
"query": args.query,
"model": {
"ollama_name": model_id.ollama_name,
"hf_model_name": model_id.hf_model_name,
},
"max_iterations": args.max_iters,
"result": json.loads(result.model_dump_json()),
}
out_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False))
print()
print(f"✓ Wrote {out_path.relative_to(REPO)}")
print(
f" converged={result.converged} "
f"iterations={len(result.iterations)} "
f"answer_chars={len(result.answer)}"
)
if result.iterations:
print(" section_refs visited:", [it.section_ref for it in result.iterations])
if __name__ == "__main__":
main()

View file

@ -0,0 +1,367 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "neo4j>=5.20,<6",
# ]
# ///
"""
Compare the Neo4j graph for a given doc_id against the source DoclingDocument
stored in SQLite. Reports whether the graph is "well-formed" all elements
present, hierarchy intact, no orphans, reading order faithful.
Use when you want to sanity-check the Neo4j writer before building anything
on top of the graph (e.g. the live RAG overlay).
Usage:
uv run experiments/reasoning-trace/inspect_graph.py \\
--doc-id 307ad2ba-93d8-4dfd-8e38-c1ea06d23f0d
Env:
NEO4J_URI default bolt://localhost:7687
NEO4J_USER default neo4j
NEO4J_PASSWORD default changeme
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sqlite3
import sys
from collections import Counter
from pathlib import Path
HERE = Path(__file__).resolve().parent
REPO = HERE.parents[1]
DB_PATH = REPO / "document-parser" / "data" / "docling_studio.db"
def _load_doc_json(doc_id: str) -> tuple[str, dict]:
"""Return (filename, parsed DoclingDocument dict) for the latest completed
analysis of this doc."""
if not DB_PATH.exists():
sys.exit(f"SQLite DB not found at {DB_PATH}")
con = sqlite3.connect(DB_PATH)
con.row_factory = sqlite3.Row
row = con.execute(
"""
SELECT d.filename, aj.document_json
FROM analysis_jobs aj
JOIN documents d ON d.id = aj.document_id
WHERE aj.document_id = ?
AND aj.status = 'COMPLETED'
AND aj.document_json IS NOT NULL
ORDER BY aj.completed_at DESC LIMIT 1
""",
(doc_id,),
).fetchone()
con.close()
if not row:
sys.exit(f"No completed analysis with document_json for doc_id={doc_id}")
return row["filename"], json.loads(row["document_json"])
# ---------------------------------------------------------------------------
# Source-side summaries (DoclingDocument JSON)
# ---------------------------------------------------------------------------
_ITEM_LISTS = ("texts", "tables", "pictures", "groups")
def _iter_items(doc: dict):
for key in _ITEM_LISTS:
for it in doc.get(key) or []:
yield key, it
def _parent_ref(item: dict) -> str | None:
parent = item.get("parent")
if isinstance(parent, dict):
return parent.get("$ref") or parent.get("cref")
return None
def _dfs_reading_order(doc: dict) -> list[str]:
by_ref: dict[str, dict] = {}
for _, it in _iter_items(doc):
ref = it.get("self_ref")
if ref:
by_ref[ref] = it
body = doc.get("body") or {}
order: list[str] = []
def walk(children):
for ch in children or []:
ref = ch.get("$ref") or ch.get("cref")
if not ref:
continue
order.append(ref)
walk((by_ref.get(ref) or {}).get("children"))
walk(body.get("children"))
return order
def _summarize_source(doc: dict) -> dict:
items_by_kind = Counter()
labels = Counter()
roots_from_body = 0
with_prov = 0
no_text_no_caption = 0
section_headers: list[str] = []
for kind, it in _iter_items(doc):
items_by_kind[kind] += 1
label = (it.get("label") or "").lower()
labels[label] += 1
if _parent_ref(it) == "#/body":
roots_from_body += 1
if it.get("prov"):
with_prov += 1
if not (it.get("text") or it.get("caption")):
no_text_no_caption += 1
if label in ("section_header", "title"):
section_headers.append(it.get("self_ref") or "<?>")
pages = doc.get("pages") or {}
reading_order = _dfs_reading_order(doc)
return {
"filename_in_doc": doc.get("name"),
"items_by_kind": dict(items_by_kind),
"labels": dict(labels),
"total_items": sum(items_by_kind.values()),
"section_headers_count": len(section_headers),
"section_headers_sample": section_headers[:5],
"roots_from_body": roots_from_body,
"items_with_prov": with_prov,
"items_without_prov": sum(items_by_kind.values()) - with_prov,
"items_without_text_nor_caption": no_text_no_caption,
"pages_count": len(pages),
"reading_order_length": len(reading_order),
"reading_order_sample": reading_order[:5],
}
# ---------------------------------------------------------------------------
# Graph-side summaries (Neo4j)
# ---------------------------------------------------------------------------
async def _summarize_graph(neo, doc_id: str) -> dict:
async with neo.driver.session(database=neo.database) as s:
async def q(cypher: str, **params) -> list[dict]:
res = await s.run(cypher, doc_id=doc_id, **params)
return [dict(r) async for r in res]
doc = await q("MATCH (d:Document {id: $doc_id}) RETURN d.title AS title")
if not doc:
return {"exists": False}
elements = await q(
"MATCH (e:Element {doc_id: $doc_id}) "
"RETURN labels(e) AS labels, e.docling_label AS docling_label, "
" e.self_ref AS self_ref, e.prov_page AS page"
)
pages = await q(
"MATCH (p:Page {doc_id: $doc_id}) RETURN p.page_no AS page_no"
)
edges_by_type = await q(
"""
MATCH (n {doc_id: $doc_id})-[r]->(m)
WHERE (m:Document OR m:Element OR m:Page OR m:Chunk)
RETURN type(r) AS type, count(r) AS n
"""
)
# HAS_ROOT starts at :Document, so n.doc_id filter above miss the edge
# (doc has id= not doc_id=); fetch separately.
has_root = await q(
"MATCH (d:Document {id: $doc_id})-[:HAS_ROOT]->(c:Element) "
"RETURN count(c) AS n"
)
# Orphan detection: elements with no incoming PARENT_OF and not a root.
orphans = await q(
"""
MATCH (e:Element {doc_id: $doc_id})
WHERE NOT (()-[:PARENT_OF]->(e))
AND NOT ((:Document {id: $doc_id})-[:HAS_ROOT]->(e))
RETURN e.self_ref AS self_ref, e.docling_label AS label
"""
)
# Elements with prov_page but no ON_PAGE edge.
on_page_missing = await q(
"""
MATCH (e:Element {doc_id: $doc_id})
WHERE e.prov_page IS NOT NULL AND NOT (e)-[:ON_PAGE]->(:Page)
RETURN count(e) AS n
"""
)
# Pages with no element attached.
empty_pages = await q(
"""
MATCH (p:Page {doc_id: $doc_id})
WHERE NOT (:Element {doc_id: $doc_id})-[:ON_PAGE]->(p)
RETURN p.page_no AS page_no
"""
)
specific_label_counter: Counter = Counter()
docling_label_counter: Counter = Counter()
for e in elements:
specifics = [lbl for lbl in e["labels"] if lbl != "Element"]
specific_label_counter[specifics[0] if specifics else "<none>"] += 1
docling_label_counter[e["docling_label"] or "<none>"] += 1
edges = {row["type"]: row["n"] for row in edges_by_type}
edges["HAS_ROOT"] = has_root[0]["n"] if has_root else 0
return {
"exists": True,
"title": doc[0]["title"],
"element_count": len(elements),
"page_count": len(pages),
"specific_labels": dict(specific_label_counter),
"docling_labels": dict(docling_label_counter),
"edges": edges,
"orphan_elements": orphans,
"elements_with_prov_but_no_on_page": on_page_missing[0]["n"] if on_page_missing else 0,
"empty_pages": [r["page_no"] for r in empty_pages],
}
# ---------------------------------------------------------------------------
# Coherence checks
# ---------------------------------------------------------------------------
async def _coherence(neo, doc_id: str, doc: dict) -> dict:
source_refs = {it.get("self_ref") for _, it in _iter_items(doc) if it.get("self_ref")}
async with neo.driver.session(database=neo.database) as s:
res = await s.run(
"MATCH (e:Element {doc_id: $doc_id}) RETURN e.self_ref AS r",
doc_id=doc_id,
)
graph_refs = {row["r"] async for row in res}
return {
"source_refs_count": len(source_refs),
"graph_refs_count": len(graph_refs),
"missing_in_graph": sorted(source_refs - graph_refs)[:20],
"missing_in_graph_count": len(source_refs - graph_refs),
"extra_in_graph": sorted(graph_refs - source_refs)[:20],
"extra_in_graph_count": len(graph_refs - source_refs),
"reading_order_source_len": len(_dfs_reading_order(doc)),
}
# ---------------------------------------------------------------------------
# Pretty report
# ---------------------------------------------------------------------------
def _section(title: str, payload: dict) -> str:
lines = [f"\n── {title} " + "" * max(2, 70 - len(title))]
for k, v in payload.items():
if isinstance(v, (dict, list)):
lines.append(f" {k}: {json.dumps(v, ensure_ascii=False, default=str)[:220]}")
else:
lines.append(f" {k}: {v}")
return "\n".join(lines)
def _verdict(source: dict, graph: dict, coherence: dict) -> str:
issues: list[str] = []
if not graph.get("exists"):
return "❌ No graph in Neo4j for this doc."
if coherence["missing_in_graph_count"]:
issues.append(
f"MISSING — {coherence['missing_in_graph_count']} self_ref(s) in "
"the source DoclingDocument are not in Neo4j"
)
if coherence["extra_in_graph_count"]:
issues.append(
f"EXTRA — {coherence['extra_in_graph_count']} self_ref(s) in the "
"graph are not in the source doc"
)
# Edge integrity:
edges = graph.get("edges", {})
expected_has_root = source["roots_from_body"]
actual_has_root = edges.get("HAS_ROOT", 0)
if actual_has_root != expected_has_root:
issues.append(
f"HAS_ROOT mismatch — source has {expected_has_root} top-level "
f"items, graph has {actual_has_root}"
)
# Reading order: NEXT chain should be reading_order_length - 1.
expected_next = max(source["reading_order_length"] - 1, 0)
actual_next = edges.get("NEXT", 0)
if actual_next != expected_next:
issues.append(
f"NEXT chain mismatch — expected {expected_next}, got {actual_next}"
)
# ON_PAGE: every item with prov should have one ON_PAGE edge.
expected_on_page = source["items_with_prov"]
actual_on_page = edges.get("ON_PAGE", 0)
if actual_on_page != expected_on_page:
issues.append(
f"ON_PAGE mismatch — {expected_on_page} items have prov, "
f"{actual_on_page} ON_PAGE edges exist"
)
if graph.get("elements_with_prov_but_no_on_page"):
issues.append(
f"ON_PAGE broken — {graph['elements_with_prov_but_no_on_page']} "
"elements have prov_page but no ON_PAGE edge"
)
if graph.get("orphan_elements"):
orphans = graph["orphan_elements"]
issues.append(f"ORPHANS — {len(orphans)} disconnected Element node(s)")
if graph.get("empty_pages"):
issues.append(
f"EMPTY PAGES — pages with no element attached: {graph['empty_pages']}"
)
if not issues:
return "✅ Graph is well-formed — matches source doc 1:1."
return "⚠️ Findings:\n " + "\n ".join(f"{x}" for x in issues)
async def main_async() -> None:
p = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
p.add_argument("--doc-id", required=True, help="documents.id (not analysis_jobs.id)")
args = p.parse_args()
filename, doc = _load_doc_json(args.doc_id)
# Imports deferred so we can patch sys.path from within the script runtime.
sys.path.insert(0, str(REPO / "document-parser"))
from infra.neo4j import close_driver, get_driver
neo = await get_driver(
os.environ.get("NEO4J_URI", "bolt://localhost:7687"),
os.environ.get("NEO4J_USER", "neo4j"),
os.environ.get("NEO4J_PASSWORD", "changeme"),
)
try:
source = _summarize_source(doc)
graph = await _summarize_graph(neo, args.doc_id)
coh = await _coherence(neo, args.doc_id, doc)
print(f"Document: {filename}")
print(f"doc_id: {args.doc_id}")
print(_section("SOURCE (DoclingDocument from SQLite)", source))
print(_section("GRAPH (Neo4j)", graph))
print(_section("COHERENCE", coh))
print()
print(_verdict(source, graph, coh))
finally:
await close_driver()
if __name__ == "__main__":
asyncio.run(main_async())

View file

@ -0,0 +1,175 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = ["neo4j>=5.20,<6"]
# ///
"""
Second-pass inspector: how well does our Neo4j graph model the *semantics*
of a DoclingDocument the parts Peter Staar would care about?
Goes beyond structural 1:1 coverage (inspect_graph.py) to answer:
- Are SectionHeader levels preserved (outline hierarchy intact)?
- Can we reconstruct "section scope" (section its content) from the graph?
- Are lists vs list-items distinguishable?
- Are figure captions linked to their figures?
- What's the depth of the actual hierarchy vs the flat body?
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
REPO = HERE.parents[1]
async def main_async(doc_id: str) -> None:
sys.path.insert(0, str(REPO / "document-parser"))
from infra.neo4j import close_driver, get_driver
neo = await get_driver(
os.environ.get("NEO4J_URI", "bolt://localhost:7687"),
os.environ.get("NEO4J_USER", "neo4j"),
os.environ.get("NEO4J_PASSWORD", "changeme"),
)
try:
async with neo.driver.session(database=neo.database) as s:
async def q(cypher: str, **params):
res = await s.run(cypher, doc_id=doc_id, **params)
return [dict(r) async for r in res]
print("=== 1. OUTLINE HIERARCHY (section header levels) ===")
rows = await q(
"""
MATCH (e:SectionHeader {doc_id: $doc_id})
RETURN e.self_ref AS ref, e.level AS level, substring(e.text, 0, 60) AS text
ORDER BY e.self_ref
"""
)
for r in rows:
print(f" level={r['level']} {r['ref']:<14} {r['text']}")
levels = [r["level"] for r in rows]
print(f" → distinct levels: {sorted(set(levels))}")
print("\n=== 2. DIRECT TREE DEPTH via PARENT_OF ===")
rows = await q(
"""
MATCH (root:Element {doc_id: $doc_id})
WHERE NOT (()-[:PARENT_OF]->(root))
OPTIONAL MATCH path = (root)-[:PARENT_OF*]->(leaf)
WITH root, max(length(path)) AS depth
RETURN
labels(root) AS labels,
root.docling_label AS docling_label,
coalesce(depth, 0) AS depth
ORDER BY depth DESC
LIMIT 10
"""
)
for r in rows:
specific = [l for l in r["labels"] if l != "Element"][0]
print(f" depth={r['depth']} {specific:<15} ({r['docling_label']})")
print("\n=== 3. SECTION SCOPE (can we infer section content from NEXT?) ===")
# For each section header, walk NEXT until the next section header —
# that's the section's content span as per docling-agent's logic.
rows = await q(
"""
MATCH (sh:SectionHeader {doc_id: $doc_id})
OPTIONAL MATCH p = (sh)-[:NEXT*]->(next:SectionHeader {doc_id: $doc_id})
WITH sh, min(length(p)) AS span
RETURN
sh.self_ref AS ref,
sh.level AS level,
coalesce(span - 1, -1) AS items_in_scope_if_span_works,
substring(sh.text, 0, 50) AS title
ORDER BY sh.self_ref
"""
)
for r in rows:
span = r["items_in_scope_if_span_works"]
label = f"~{span} items" if span >= 0 else "last (unknown span)"
print(f" level={r['level']} {r['ref']:<14} {label:<18} {r['title']}")
print("\n=== 4. LIST CONTAINER vs LIST ITEM distinction ===")
rows = await q(
"""
MATCH (e:ListItem {doc_id: $doc_id})
RETURN e.docling_label AS docling_label, count(*) AS n
ORDER BY docling_label
"""
)
for r in rows:
print(f" docling_label={r['docling_label']:<12} neo4j_label=:ListItem count={r['n']}")
print(" ⚠️ Both 'list' (container) and 'list_item' get :ListItem in Neo4j.")
print("\n=== 5. FIGURE ↔ CAPTION linkage ===")
captions = await q(
"MATCH (c:Caption {doc_id: $doc_id}) RETURN count(c) AS n"
)
linked = await q(
"""
MATCH (fig:Figure {doc_id: $doc_id})-[:PARENT_OF]-(c:Caption {doc_id: $doc_id})
RETURN count(DISTINCT fig) AS figs_with_caption, count(DISTINCT c) AS captions_linked
"""
)
print(
f" captions={captions[0]['n']} "
f"figures_with_caption={linked[0]['figs_with_caption']} "
f"captions_linked={linked[0]['captions_linked']}"
)
print("\n=== 6. TABLE CELL CONTENT — graph-addressable or opaque? ===")
rows = await q(
"""
MATCH (t:Table {doc_id: $doc_id})
RETURN t.self_ref AS ref,
CASE WHEN t.cells_json IS NOT NULL THEN 'JSON-blob' ELSE 'missing' END
AS cells_mode,
size(coalesce(t.cells_json, '')) AS cells_bytes
"""
)
for r in rows:
print(f" {r['ref']:<14} cells={r['cells_mode']} ({r['cells_bytes']} bytes)")
if rows:
print(" ⚠️ Cells are a JSON string on the Table node — not queryable as graph nodes.")
print("\n=== 7. WHAT AN AGENT VISIT LOOKS LIKE (section subgraph) ===")
# Pick the first section header and show what would be highlighted
# if we traversed its scope (via NEXT until next same-or-higher-level section).
rows = await q(
"""
MATCH (sh:SectionHeader {doc_id: $doc_id})
WITH sh ORDER BY sh.self_ref LIMIT 1
OPTIONAL MATCH chain = (sh)-[:NEXT*0..50]->(e:Element {doc_id: $doc_id})
WITH sh, e, length(chain) AS pos
OPTIONAL MATCH (e)<-[:NEXT*0..]-(_stop:SectionHeader)
WHERE _stop <> sh AND _stop.level <= sh.level
WITH sh, e, pos
ORDER BY pos
LIMIT 8
RETURN pos, labels(e) AS labels, e.docling_label AS kind,
substring(e.text, 0, 60) AS text
"""
)
for r in rows:
specific = [l for l in r["labels"] if l != "Element"][0]
print(f" pos={r['pos']:<3} {specific:<15} {r['text']}")
print(
" → Visiting a section on the graph shows ONE node. Its 'scope' "
"(the content) must be inferred via NEXT-walk — not materialized as edges."
)
finally:
await close_driver()
if __name__ == "__main__":
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--doc-id", required=True)
args = p.parse_args()
asyncio.run(main_async(args.doc_id))

View file

@ -0,0 +1,148 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "neo4j>=5.20,<6",
# "python-dotenv>=1.0",
# ]
# ///
"""
Populate Neo4j with the DoclingDocument tree + pages for one or more analysis
jobs, **without re-parsing the PDFs**. Reuses Studio's own TreeWriter so the
graph is byte-identical to what an in-UI analysis would produce.
Use when:
- Neo4j was brought up after some docs were already analyzed (orphan graphs).
- You want to prime a demo environment from existing SQLite state.
Usage:
# Single job
uv run experiments/reasoning-trace/prime_neo4j.py \\
--job-id 722d5631-0089-44a3-a64a-7ce5b99579d3
# All completed analyses that have a document_json but no graph yet
uv run experiments/reasoning-trace/prime_neo4j.py --all-missing
Env (defaults match docker-compose.dev.yml):
NEO4J_URI default bolt://localhost:7687
NEO4J_USER default neo4j
NEO4J_PASSWORD default changeme
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sqlite3
import sys
from datetime import UTC, datetime
from pathlib import Path
HERE = Path(__file__).resolve().parent
REPO = HERE.parents[1]
DB_PATH = REPO / "document-parser" / "data" / "docling_studio.db"
# Studio's own TreeWriter lives in document-parser/infra/neo4j. Import it by
# adding document-parser to sys.path — this keeps us byte-identical with what
# the live backend writes, instead of re-implementing the walk.
sys.path.insert(0, str(REPO / "document-parser"))
def _fetch_row(job_id: str) -> tuple[str, str, str] | None:
con = sqlite3.connect(DB_PATH)
con.row_factory = sqlite3.Row
row = con.execute(
"""
SELECT aj.document_id, d.filename, aj.document_json
FROM analysis_jobs aj
JOIN documents d ON d.id = aj.document_id
WHERE aj.id = ? AND aj.document_json IS NOT NULL
""",
(job_id,),
).fetchone()
con.close()
return (row["document_id"], row["filename"], row["document_json"]) if row else None
def _fetch_all_completed() -> list[tuple[str, str, str, str]]:
"""Latest completed analysis per document that has a document_json."""
con = sqlite3.connect(DB_PATH)
con.row_factory = sqlite3.Row
rows = con.execute(
"""
SELECT aj.id, aj.document_id, d.filename, aj.document_json
FROM analysis_jobs aj
JOIN documents d ON d.id = aj.document_id
WHERE aj.document_json IS NOT NULL
AND aj.status = 'COMPLETED'
GROUP BY aj.document_id
HAVING MAX(aj.completed_at)
""",
).fetchall()
con.close()
return [(r["id"], r["document_id"], r["filename"], r["document_json"]) for r in rows]
async def prime(job_id: str, doc_id: str, filename: str, document_json: str) -> None:
# Imports deferred until after sys.path is patched.
from infra.neo4j import bootstrap_schema, close_driver, get_driver, write_document
uri = os.environ.get("NEO4J_URI", "bolt://localhost:7687")
user = os.environ.get("NEO4J_USER", "neo4j")
pwd = os.environ.get("NEO4J_PASSWORD", "changeme")
neo = await get_driver(uri, user, pwd)
try:
# Schema is idempotent; safe to run every time.
await bootstrap_schema(neo)
result = await write_document(
neo,
doc_id=doc_id,
filename=filename,
document_json=document_json,
)
print(
f"{doc_id[:8]} {filename[:40]:<40} "
f"elements={result.element_count} pages={result.page_count}"
)
finally:
await close_driver()
async def main_async() -> None:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
g = p.add_mutually_exclusive_group(required=True)
g.add_argument("--job-id", help="analysis_jobs.id to prime")
g.add_argument(
"--all-missing",
action="store_true",
help="prime every completed analysis with a document_json (latest per doc)",
)
args = p.parse_args()
if not DB_PATH.exists():
sys.exit(f"SQLite DB not found at {DB_PATH}")
started = datetime.now(tz=UTC)
if args.job_id:
row = _fetch_row(args.job_id)
if row is None:
sys.exit(f"No analysis with id {args.job_id} or no document_json")
doc_id, filename, document_json = row
print(f"→ Priming Neo4j for job {args.job_id[:8]} (doc {doc_id[:8]})")
await prime(args.job_id, doc_id, filename, document_json)
else:
rows = _fetch_all_completed()
print(f"→ Priming Neo4j for {len(rows)} document(s)")
for job_id, doc_id, filename, document_json in rows:
try:
await prime(job_id, doc_id, filename, document_json)
except Exception as e:
print(f"{doc_id[:8]} {filename[:40]:<40} FAILED: {e}")
elapsed = (datetime.now(tz=UTC) - started).total_seconds()
print(f"Done in {elapsed:.1f}s")
if __name__ == "__main__":
asyncio.run(main_async())

7
frontend/env.d.ts vendored
View file

@ -7,3 +7,10 @@ declare module '*.vue' {
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown> const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
export default component export default component
} }
// Cytoscape plugins we use in GraphView — they ship no types. Treated as
// opaque plugin objects; the runtime APIs they add to `cy` are called via
// `(cy as any)` at the call site and typed loosely.
declare module 'cytoscape-expand-collapse'
declare module 'cytoscape-navigator'
declare module 'cytoscape-navigator/cytoscape.js-navigator.css'

View file

@ -10,6 +10,8 @@
"dependencies": { "dependencies": {
"cytoscape": "^3.30.0", "cytoscape": "^3.30.0",
"cytoscape-dagre": "^2.5.0", "cytoscape-dagre": "^2.5.0",
"cytoscape-expand-collapse": "^4.1.1",
"cytoscape-navigator": "^2.0.2",
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
"marked": "^17.0.4", "marked": "^17.0.4",
"pinia": "^2.3.0", "pinia": "^2.3.0",
@ -1866,6 +1868,22 @@
"cytoscape": "^3.2.22" "cytoscape": "^3.2.22"
} }
}, },
"node_modules/cytoscape-expand-collapse": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/cytoscape-expand-collapse/-/cytoscape-expand-collapse-4.1.1.tgz",
"integrity": "sha512-MI4/GsA6Rf6RRzNR1aCitBLSnxiIKLxvZyCzF+oti/zn/ui1jmf769VcEFAEbjjsAtwteGsTmczI+niCMWJNvA==",
"peerDependencies": {
"cytoscape": "^3.3.0"
}
},
"node_modules/cytoscape-navigator": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/cytoscape-navigator/-/cytoscape-navigator-2.0.2.tgz",
"integrity": "sha512-TZFBLFWEMW858UOt4rzusOjtDj7YT5vNx2uCwpUuicUYbaWCHHcUROBZWO+hiuSPWpVhvGLFlOq3NBcAVYOAgw==",
"peerDependencies": {
"cytoscape": "^2.6.0 || ^3.0.0"
}
},
"node_modules/dagre": { "node_modules/dagre": {
"version": "0.8.5", "version": "0.8.5",
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz", "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",

View file

@ -18,6 +18,7 @@
"dependencies": { "dependencies": {
"cytoscape": "^3.30.0", "cytoscape": "^3.30.0",
"cytoscape-dagre": "^2.5.0", "cytoscape-dagre": "^2.5.0",
"cytoscape-expand-collapse": "^4.1.1",
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
"marked": "^17.0.4", "marked": "^17.0.4",
"pinia": "^2.3.0", "pinia": "^2.3.0",
@ -28,9 +29,9 @@
"@eslint/js": "^9.0.0", "@eslint/js": "^9.0.0",
"@types/cytoscape": "^3.21.4", "@types/cytoscape": "^3.21.4",
"@types/cytoscape-dagre": "^2.3.3", "@types/cytoscape-dagre": "^2.3.3",
"@vitest/mocker": "^4.1.2",
"@types/dompurify": "^3.2.0", "@types/dompurify": "^3.2.0",
"@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue": "^6.0.5",
"@vitest/mocker": "^4.1.2",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"eslint-plugin-vue": "^9.32.0", "eslint-plugin-vue": "^9.32.0",
"prettier": "^3.4.0", "prettier": "^3.4.0",

View file

@ -27,6 +27,22 @@ const routes: RouteRecordRaw[] = [
name: 'search', name: 'search',
component: () => import('../../pages/SearchPage.vue'), component: () => import('../../pages/SearchPage.vue'),
}, },
{
// Reasoning-trace tunnel. Route is always registered; the page shows
// an empty state when the `reasoning` feature flag is off (same pattern
// as /search does for ingestion).
path: '/reasoning',
name: 'reasoning',
component: () => import('../../pages/ReasoningPage.vue'),
},
{
// Deep-link into a specific document's reasoning workspace, e.g. shared
// by Peter to a teammate.
path: '/reasoning/:docId',
name: 'reasoning-doc',
component: () => import('../../pages/ReasoningPage.vue'),
props: true,
},
{ {
path: '/settings', path: '/settings',
name: 'settings', name: 'settings',

View file

@ -1,5 +1,22 @@
import { apiFetch } from '../../shared/api/http' import { apiFetch } from '../../shared/api/http'
/**
* A single provenance entry for an element matches Docling's
* `ProvenanceItem`. One element may have multiple (e.g. a paragraph that
* spans a page break has two entries, one per page).
*/
export interface GraphProvenance {
order: number
page_no: number | null
bbox_l: number
bbox_t: number
bbox_r: number
bbox_b: number
coord_origin: string
charspan_start: number | null
charspan_end: number | null
}
export interface GraphNode { export interface GraphNode {
id: string id: string
group: 'document' | 'element' | 'page' | 'chunk' group: 'document' | 'element' | 'page' | 'chunk'
@ -8,6 +25,8 @@ export interface GraphNode {
self_ref?: string self_ref?: string
text?: string text?: string
prov_page?: number | null prov_page?: number | null
/** Full list of provenances (page + bbox) — used by the bbox-highlight UI. */
provs?: GraphProvenance[]
level?: number | null level?: number | null
page_no?: number page_no?: number
chunk_index?: number chunk_index?: number

View file

@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import type { GraphNode } from './graphApi'
import { LEGEND_CHIPS, findChip, partitionByHiddenChips } from './legendFilters'
function node(overrides: Partial<GraphNode>): GraphNode {
return {
id: overrides.id ?? 'n1',
group: overrides.group ?? 'element',
...overrides,
}
}
describe('LEGEND_CHIPS matching', () => {
it('section matches only SectionHeader elements', () => {
const chip = findChip('section')!
expect(chip.match(node({ group: 'element', label: 'SectionHeader' }))).toBe(true)
expect(chip.match(node({ group: 'element', label: 'Paragraph' }))).toBe(false)
// An element with no label should NOT match section.
expect(chip.match(node({ group: 'element' }))).toBe(false)
})
it('paragraph matches Paragraph and TextElement (fallback)', () => {
const chip = findChip('paragraph')!
expect(chip.match(node({ group: 'element', label: 'Paragraph' }))).toBe(true)
// TextElement is the generic fallback Docling-labels that don't have a
// specific mapping end up with. It's visually rendered in the paragraph
// color, so the chip covers both.
expect(chip.match(node({ group: 'element', label: 'TextElement' }))).toBe(true)
expect(chip.match(node({ group: 'element', label: 'Table' }))).toBe(false)
})
it('group-based chips use `group` not `label`', () => {
expect(findChip('document')!.match(node({ group: 'document' }))).toBe(true)
expect(findChip('page')!.match(node({ group: 'page', page_no: 1 }))).toBe(true)
expect(findChip('chunk')!.match(node({ group: 'chunk' }))).toBe(true)
// document chip must NOT match elements that happen to have label===undefined
expect(findChip('document')!.match(node({ group: 'element' }))).toBe(false)
})
it('exposes a stable list of chips', () => {
// The sidebar CSS targets `.legend-${key}` — renaming a key here would
// silently break the styles. Pin the expected order + keys.
expect(LEGEND_CHIPS.map((c) => c.key)).toEqual([
'document',
'section',
'paragraph',
'table',
'figure',
'page',
'chunk',
])
})
})
describe('partitionByHiddenChips', () => {
const nodes: GraphNode[] = [
node({ id: 'd', group: 'document' }),
node({ id: 's1', group: 'element', label: 'SectionHeader' }),
node({ id: 'p1', group: 'element', label: 'Paragraph' }),
node({ id: 'f1', group: 'element', label: 'Figure' }),
node({ id: 'pg1', group: 'page' }),
]
it('returns everything in `show` when the hidden set is empty', () => {
const { hide, show } = partitionByHiddenChips(nodes, new Set())
expect(hide).toHaveLength(0)
expect(show).toHaveLength(nodes.length)
})
it('hides the nodes matching the selected chips', () => {
const { hide, show } = partitionByHiddenChips(nodes, new Set(['figure', 'document']))
expect(hide.map((n) => n.id).sort()).toEqual(['d', 'f1'])
expect(show.map((n) => n.id).sort()).toEqual(['p1', 'pg1', 's1'])
})
it('ignores unknown chip keys (no silent breakage)', () => {
const { hide, show } = partitionByHiddenChips(nodes, new Set(['some-new-kind']))
expect(hide).toHaveLength(0)
expect(show).toHaveLength(nodes.length)
})
it('keeps un-classifiable nodes visible even if all chips are off', () => {
// A hypothetical element with an unmapped label (e.g. :Formula is in the
// writer but has no chip yet) must not be hidden when toggling unrelated
// chips — only explicit chip matches cause hiding.
const weird = node({ id: 'w', group: 'element', label: 'Formula' })
const { hide, show } = partitionByHiddenChips(
[weird, ...nodes],
new Set(['section', 'paragraph']),
)
expect(hide.map((n) => n.id)).toEqual(['s1', 'p1'])
expect(show.some((n) => n.id === 'w')).toBe(true)
})
})

View file

@ -0,0 +1,108 @@
/**
* Declarative definition of GraphView's legend chips.
*
* Each chip maps to (a) a swatch color shown in the toolbar, and (b) a
* predicate that decides whether a given node is "of that kind". Clicking a
* chip toggles the matching nodes' visibility via the `.hidden` Cytoscape
* class pure function kept here so it's unit-testable without mounting
* the component or spinning up Cytoscape.
*/
import type { GraphNode } from './graphApi'
export interface LegendChip {
/** Stable ID used as the key in the `hiddenChips` Set + as a CSS slug. */
key: string
/** Display label shown in the chip. Kept English for now matches
* existing `legend-*` class names that the design stylesheet targets. */
label: string
/** Swatch color (also used for the CSS class `legend-${key}`). */
color: string
/** Returns true when a node belongs to this chip's category. */
match: (node: GraphNode) => boolean
}
/**
* Legend order matches the chips currently rendered in GraphView's toolbar,
* so swapping to this source of truth is a drop-in replacement.
*
* `kindLabel` is the specific Neo4j label returned by `fetch_graph`
* (e.g. `SectionHeader`, `Paragraph`, `Table`, ). Group-based chips target
* the `group` attribute (document / page / chunk) only `element` nodes
* have a useful `kindLabel`.
*/
export const LEGEND_CHIPS: LegendChip[] = [
{
key: 'document',
label: 'Document',
color: '#1E293B',
match: (n) => n.group === 'document',
},
{
key: 'section',
label: 'Section',
color: '#F97316',
match: (n) => n.group === 'element' && n.label === 'SectionHeader',
},
{
key: 'paragraph',
label: 'Paragraph',
color: '#3B82F6',
match: (n) => n.group === 'element' && (n.label === 'Paragraph' || n.label === 'TextElement'),
},
{
key: 'table',
label: 'Table',
color: '#8B5CF6',
match: (n) => n.group === 'element' && n.label === 'Table',
},
{
key: 'figure',
label: 'Figure',
color: '#22C55E',
match: (n) => n.group === 'element' && n.label === 'Figure',
},
{
key: 'page',
label: 'Page',
color: '#94A3B8',
match: (n) => n.group === 'page',
},
{
key: 'chunk',
label: 'Chunk',
color: '#DC2626',
match: (n) => n.group === 'chunk',
},
]
/** Lookup by key — tiny helper to keep GraphView concise. */
export function findChip(key: string): LegendChip | undefined {
return LEGEND_CHIPS.find((c) => c.key === key)
}
/**
* Partition an iterable of nodes into `{ hide, show }` based on a set of
* chip keys to hide. A node is hidden if it matches any chip in the set.
* Nodes matching no chip at all (e.g. a future new kind we haven't added
* to the legend) are always shown no silent disappearance.
*/
export function partitionByHiddenChips(
nodes: Iterable<GraphNode>,
hiddenChipKeys: ReadonlySet<string>,
): { hide: GraphNode[]; show: GraphNode[] } {
const hide: GraphNode[] = []
const show: GraphNode[] = []
for (const n of nodes) {
let matchedHidden = false
for (const key of hiddenChipKeys) {
const chip = findChip(key)
if (chip && chip.match(n)) {
matchedHidden = true
break
}
}
;(matchedHidden ? hide : show).push(n)
}
return { hide, show }
}

View file

@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest'
import type { GraphEdge, GraphNode } from './graphApi'
import { computeSectionParents, explicitParentMap, mergeParentMaps } from './sectionParenting'
function el(selfRef: string, label: string): GraphNode {
return {
id: `elem::${selfRef}`,
group: 'element',
label,
self_ref: selfRef,
}
}
function nextEdge(from: string, to: string): GraphEdge {
return {
id: `NEXT::elem::${from}::elem::${to}`,
source: `elem::${from}`,
target: `elem::${to}`,
type: 'NEXT',
}
}
function parentEdge(parent: string, child: string): GraphEdge {
return {
id: `PARENT_OF::elem::${parent}::elem::${child}`,
source: `elem::${parent}`,
target: `elem::${child}`,
type: 'PARENT_OF',
}
}
describe('computeSectionParents', () => {
it('returns empty when no SectionHeader exists', () => {
const nodes = [el('#/texts/0', 'Paragraph'), el('#/texts/1', 'Paragraph')]
const edges = [nextEdge('#/texts/0', '#/texts/1')]
expect(computeSectionParents(nodes, edges)).toEqual(new Map())
})
it('parents every element to the preceding SectionHeader', () => {
// SH ─▶ P ─▶ P ─▶ SH ─▶ P
const nodes = [
el('#/texts/0', 'SectionHeader'),
el('#/texts/1', 'Paragraph'),
el('#/texts/2', 'Paragraph'),
el('#/texts/3', 'SectionHeader'),
el('#/texts/4', 'Paragraph'),
]
const edges = [
nextEdge('#/texts/0', '#/texts/1'),
nextEdge('#/texts/1', '#/texts/2'),
nextEdge('#/texts/2', '#/texts/3'),
nextEdge('#/texts/3', '#/texts/4'),
]
const parents = computeSectionParents(nodes, edges)
expect(parents.get('elem::#/texts/1')).toBe('elem::#/texts/0')
expect(parents.get('elem::#/texts/2')).toBe('elem::#/texts/0')
expect(parents.get('elem::#/texts/4')).toBe('elem::#/texts/3')
// Headers are never themselves synthetically parented.
expect(parents.has('elem::#/texts/0')).toBe(false)
expect(parents.has('elem::#/texts/3')).toBe(false)
})
it('does not override an explicit PARENT_OF', () => {
// SH ─▶ List ─▶ ListItem, with PARENT_OF(List, ListItem)
const nodes = [
el('#/texts/0', 'SectionHeader'),
el('#/groups/0', 'List'),
el('#/texts/1', 'ListItem'),
]
const edges: GraphEdge[] = [
nextEdge('#/texts/0', '#/groups/0'),
nextEdge('#/groups/0', '#/texts/1'),
parentEdge('#/groups/0', '#/texts/1'),
]
const parents = computeSectionParents(nodes, edges)
// The List falls under the section…
expect(parents.get('elem::#/groups/0')).toBe('elem::#/texts/0')
// …but the ListItem keeps its explicit List parent, not the section.
expect(parents.has('elem::#/texts/1')).toBe(false)
})
it('leaves elements preceding the first SectionHeader unparented', () => {
// Page-header stuff can come before any SectionHeader — don't force-parent it.
const nodes = [
el('#/texts/0', 'PageHeader'),
el('#/texts/1', 'SectionHeader'),
el('#/texts/2', 'Paragraph'),
]
const edges = [nextEdge('#/texts/0', '#/texts/1'), nextEdge('#/texts/1', '#/texts/2')]
const parents = computeSectionParents(nodes, edges)
expect(parents.has('elem::#/texts/0')).toBe(false)
expect(parents.get('elem::#/texts/2')).toBe('elem::#/texts/1')
})
it('handles multiple disconnected NEXT chains deterministically', () => {
// Two chains, each with its own section. Heads are sorted by id so the
// walk order is stable run-to-run.
const nodes = [
el('#/texts/10', 'SectionHeader'),
el('#/texts/11', 'Paragraph'),
el('#/texts/20', 'SectionHeader'),
el('#/texts/21', 'Paragraph'),
]
const edges = [nextEdge('#/texts/10', '#/texts/11'), nextEdge('#/texts/20', '#/texts/21')]
const parents = computeSectionParents(nodes, edges)
expect(parents.get('elem::#/texts/11')).toBe('elem::#/texts/10')
expect(parents.get('elem::#/texts/21')).toBe('elem::#/texts/20')
})
})
describe('explicitParentMap + mergeParentMaps', () => {
it('extracts explicit PARENT_OF into a child→parent map', () => {
const edges = [parentEdge('#/groups/0', '#/texts/1')]
const m = explicitParentMap(edges)
expect(m.get('elem::#/texts/1')).toBe('elem::#/groups/0')
})
it('explicit wins over synthetic when both map the same child', () => {
const synthetic = new Map([['c', 'synthetic-parent']])
const explicit = new Map([['c', 'real-parent']])
const merged = mergeParentMaps(explicit, synthetic)
expect(merged.get('c')).toBe('real-parent')
})
it('preserves synthetic entries that have no explicit counterpart', () => {
const synthetic = new Map([
['c1', 'section-1'],
['c2', 'section-1'],
])
const explicit = new Map([['c2', 'list-a']])
const merged = mergeParentMaps(explicit, synthetic)
expect(merged.get('c1')).toBe('section-1')
expect(merged.get('c2')).toBe('list-a')
})
})

View file

@ -0,0 +1,111 @@
/**
* Synthesize "section scope" compound-node parents for GraphView.
*
* The Docling graph is structurally flat most elements are direct children
* of `#/body` (HAS_ROOT) with no explicit parent relationship to the section
* header that "owns" them in the reader's mental model. To enable the
* expand-collapse UX per section, we derive that ownership here, at render
* time, from the NEXT reading-order chain:
*
* - Walk NEXT from the head of the chain.
* - Every time we hit a SectionHeader, it becomes the "current section".
* - Every non-SectionHeader node thereafter unless it has an explicit
* PARENT_OF edge (e.g. list_item list) gets that section as its
* compound parent.
*
* The function returns a `Map<childId, parentId>` where both ids are full
* Cytoscape node ids (`elem::<self_ref>`). The caller merges this with any
* existing PARENT_OF to produce the final `data.parent` on each node.
*
* Edge cases handled:
* - No SectionHeader in the doc returns an empty map (nothing to collapse).
* - Multiple disconnected NEXT chains each is walked independently.
* - Explicit PARENT_OF on a child never overridden.
* - SectionHeader that is itself a list_item or nested its descendants
* still use it as their section anchor; the SectionHeader keeps its own
* explicit parent.
*/
import type { GraphEdge, GraphNode } from './graphApi'
export function computeSectionParents(
nodes: readonly GraphNode[],
edges: readonly GraphEdge[],
): Map<string, string> {
const elementIds = new Set<string>()
const kindById = new Map<string, string | undefined>()
for (const n of nodes) {
if (n.group !== 'element') continue
elementIds.add(n.id)
kindById.set(n.id, n.label)
}
if (elementIds.size === 0) return new Map()
const explicitParentOf = new Set<string>()
const nextMap = new Map<string, string>()
const hasIncomingNext = new Set<string>()
for (const e of edges) {
if (!elementIds.has(e.source) || !elementIds.has(e.target)) continue
if (e.type === 'PARENT_OF') {
explicitParentOf.add(e.target)
} else if (e.type === 'NEXT') {
// Preserve first-seen NEXT edge per source (should be unique anyway).
if (!nextMap.has(e.source)) nextMap.set(e.source, e.target)
hasIncomingNext.add(e.target)
}
}
// Walk heads deterministically (sorted) so the same graph always produces
// the same parenting map — useful for tests and reproducible debugging.
const heads = [...elementIds].filter((id) => !hasIncomingNext.has(id)).sort()
const parents = new Map<string, string>()
const visited = new Set<string>()
for (const head of heads) {
let currentSection: string | null = null
let node: string | undefined = head
while (node && !visited.has(node)) {
visited.add(node)
const kind = kindById.get(node)
if (kind === 'SectionHeader') {
currentSection = node
// The SectionHeader itself does NOT get a synthetic parent — it is
// the anchor. If it has an explicit PARENT_OF, that one remains
// authoritative downstream (the merger respects it).
} else if (currentSection && !explicitParentOf.has(node)) {
parents.set(node, currentSection)
}
node = nextMap.get(node)
}
}
return parents
}
/**
* Merge an explicit PARENT_OF map with synthetic section parents. Explicit
* wins. Produces the final `childId → parentId` map that callers set as
* `data.parent` on Cytoscape nodes.
*/
export function mergeParentMaps(
explicit: ReadonlyMap<string, string>,
synthetic: ReadonlyMap<string, string>,
): Map<string, string> {
const out = new Map(synthetic)
for (const [child, parent] of explicit) out.set(child, parent)
return out
}
/** Extract the explicit PARENT_OF map from the edge list — convenience. */
export function explicitParentMap(edges: readonly GraphEdge[]): Map<string, string> {
const out = new Map<string, string>()
for (const e of edges) {
if (e.type === 'PARENT_OF') out.set(e.target, e.source)
}
return out
}

View file

@ -18,26 +18,65 @@
{{ payload?.page_count }} pages {{ payload?.page_count }} pages
</span> </span>
<span class="graph-legend"> <span class="graph-legend">
<span class="legend-chip legend-document">Document</span> <button
<span class="legend-chip legend-section">Section</span> v-for="chip in LEGEND_CHIPS"
<span class="legend-chip legend-paragraph">Paragraph</span> :key="chip.key"
<span class="legend-chip legend-table">Table</span> type="button"
<span class="legend-chip legend-figure">Figure</span> class="legend-chip"
<span class="legend-chip legend-page">Page</span> :class="[`legend-${chip.key}`, { 'legend-off': hiddenChips.has(chip.key) }]"
<span class="legend-chip legend-chunk">Chunk</span> :data-e2e="`legend-${chip.key}`"
:title="
hiddenChips.has(chip.key) ? `Show ${chip.label} nodes` : `Hide ${chip.label} nodes`
"
:aria-pressed="!hiddenChips.has(chip.key)"
@click="toggleChip(chip.key)"
>
{{ chip.label }}
</button>
</span> </span>
</div> </div>
<div class="graph-body">
<div class="graph-canvas-wrap">
<div ref="containerRef" class="graph-canvas" data-e2e="graph-canvas" /> <div ref="containerRef" class="graph-canvas" data-e2e="graph-canvas" />
<!-- Hover tooltip, positioned inside the canvas wrap so its
coords match cy.renderedPosition() exactly. -->
<div
v-if="tooltip"
class="graph-tooltip"
data-e2e="graph-tooltip"
:style="{ left: tooltip.x + 'px', top: tooltip.y + 'px' }"
>
{{ tooltip.text }}
</div>
</div>
<NodeDetailsPanel :node="selectedNode" @close="closeDetails" />
</div>
</template> </template>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Core } from 'cytoscape'
import { onMounted, onBeforeUnmount, ref, watch, nextTick } from 'vue' import { onMounted, onBeforeUnmount, ref, watch, nextTick } from 'vue'
import { useI18n } from '../../../shared/i18n' import { useI18n } from '../../../shared/i18n'
import { fetchDocumentGraph, type GraphPayload } from '../graphApi' import { reasoningOverlayStyles } from '../../reasoning/graphReasoningOverlay'
import { fetchDocumentGraph, type GraphNode, type GraphPayload } from '../graphApi'
import { LEGEND_CHIPS, findChip } from '../legendFilters'
import { computeSectionParents, explicitParentMap, mergeParentMaps } from '../sectionParenting'
import NodeDetailsPanel from './NodeDetailsPanel.vue'
const props = defineProps<{ docId: string | null }>() // `fetcher` is optional so Maintain can keep using the Neo4j-backed endpoint
// (`fetchDocumentGraph`, the default) while the reasoning-trace page can
// inject a SQLite-backed fetcher that returns the same `GraphPayload` shape
// without requiring Neo4j. Keeping this at the component boundary means the
// rendering pipeline below doesn't care where the graph came from.
const props = withDefaults(
defineProps<{
docId: string | null
fetcher?: (docId: string) => Promise<GraphPayload>
}>(),
{ fetcher: fetchDocumentGraph },
)
const { t } = useI18n() const { t } = useI18n()
const containerRef = ref<HTMLDivElement | null>(null) const containerRef = ref<HTMLDivElement | null>(null)
@ -45,8 +84,22 @@ const payload = ref<GraphPayload | null>(null)
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const empty = ref(false) const empty = ref(false)
// eslint-disable-next-line @typescript-eslint/no-explicit-any // Exposed via defineExpose so parent components (e.g. the reasoning trace
let cy: any | null = null // overlay) can read the live Cytoscape instance reactively. Null while the
// graph is loading / empty / unmounted.
const cy = ref<Core | null>(null)
// Legend-driven visibility: user clicks a chip its key lands here and the
// matching nodes get the `.hidden` Cytoscape class. Reset to empty every time
// the doc changes (see the `watch(() => props.docId)` below) kept as a Set
// because order doesn't matter and membership checks are the hot path.
const hiddenChips = ref<Set<string>>(new Set())
// Click details panel. Null = panel hidden.
const selectedNode = ref<GraphNode | null>(null)
// Hover tooltip: position (px within .graph-canvas) + text. Null hides it.
const tooltip = ref<{ x: number; y: number; text: string } | null>(null)
const NODE_COLORS: Record<string, string> = { const NODE_COLORS: Record<string, string> = {
document: '#1E293B', document: '#1E293B',
@ -87,7 +140,7 @@ async function load(): Promise<void> {
error.value = null error.value = null
empty.value = false empty.value = false
try { try {
payload.value = await fetchDocumentGraph(props.docId) payload.value = await props.fetcher(props.docId)
if (!payload.value.nodes.length) { if (!payload.value.nodes.length) {
empty.value = true empty.value = true
return return
@ -96,6 +149,9 @@ async function load(): Promise<void> {
loading.value = false loading.value = false
await nextTick() await nextTick()
await renderGraph() await renderGraph()
// Re-apply any chips the user had toggled before this load (e.g. they
// hid Pages on doc A, then navigated to doc B keep Pages hidden).
applyHiddenChips()
} catch (e) { } catch (e) {
error.value = (e as Error).message || 'Failed to load graph' error.value = (e as Error).message || 'Failed to load graph'
console.error('Failed to load graph', e) console.error('Failed to load graph', e)
@ -106,26 +162,47 @@ async function load(): Promise<void> {
async function renderGraph(): Promise<void> { async function renderGraph(): Promise<void> {
if (!containerRef.value || !payload.value) return if (!containerRef.value || !payload.value) return
// Dynamic import keeps cytoscape out of the main chunk. // Dynamic imports keep the heavy Cytoscape bundle out of the main chunk.
const [{ default: cytoscape }, { default: dagre }] = await Promise.all([ const [{ default: cytoscape }, { default: dagre }, ecMod] = await Promise.all([
import('cytoscape'), import('cytoscape'),
import('cytoscape-dagre'), import('cytoscape-dagre'),
import('cytoscape-expand-collapse'),
]) ])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(cytoscape as any).use(dagre)
if (cy) { const C = cytoscape as any
cy.destroy() C.use(dagre)
cy = null // Plugin registration is idempotent calling use() twice is a no-op.
C.use((ecMod as any).default ?? ecMod)
if (cy.value) {
cy.value.destroy()
cy.value = null
} }
// Compute compound parenting: merge docling-native PARENT_OF with the
// synthetic section-scope parents so every non-root element sits inside
// its section visually (enables per-section collapse via the legend chips
// and the expand-collapse plugin).
const parentMap = mergeParentMaps(
explicitParentMap(payload.value.edges),
computeSectionParents(payload.value.nodes, payload.value.edges),
)
const elements = [ const elements = [
...payload.value.nodes.map((n) => ({ ...payload.value.nodes.map((n) => ({
data: { data: {
id: n.id, id: n.id,
label: nodeLabel(n), label: nodeLabel(n),
// `kindLabel` is the specific Neo4j label (SectionHeader, Paragraph,
// Figure, ) kept as a data attribute so legend filters can match
// on it. `label` above is the human display string for Cytoscape.
kindLabel: n.label ?? null,
bg: nodeColor(n), bg: nodeColor(n),
group: n.group, group: n.group,
// Compound-node parent: used by the expand-collapse plugin to
// fold/unfold a section's scope. `undefined` = this node is a root
// of the compound hierarchy (Documents, unparented sections, etc.).
parent: parentMap.get(n.id),
raw: n, raw: n,
}, },
})), })),
@ -139,7 +216,7 @@ async function renderGraph(): Promise<void> {
})), })),
] ]
cy = cytoscape({ cy.value = cytoscape({
container: containerRef.value, container: containerRef.value,
elements, elements,
style: [ style: [
@ -200,8 +277,47 @@ async function renderGraph(): Promise<void> {
selector: 'edge[type = "DERIVED_FROM"]', selector: 'edge[type = "DERIVED_FROM"]',
style: { 'line-color': '#DC2626', 'target-arrow-color': '#DC2626' }, style: { 'line-color': '#DC2626', 'target-arrow-color': '#DC2626' },
}, },
// Reasoning-trace overlay: visited-node class + synthetic REASONING_NEXT edges.
...reasoningOverlayStyles,
// Legend-driven visibility: chips toggle the `.hidden` class on
// matching nodes. `display: none` cascades to connected edges for free.
{ selector: 'node.hidden', style: { display: 'none' } },
// Compound nodes (section scope + explicit PARENT_OF containers). A
// node with :parent children is rendered as a bounding box here
// visually groups the section's content together. Minimal padding so
// the layout stays compact.
{
selector: ':parent',
style: {
'background-opacity': 0.08,
'background-color': '#F97316',
'border-color': '#FDBA74',
'border-width': 1,
'padding-top': '12px',
'padding-bottom': '12px',
'padding-left': '12px',
'padding-right': '12px',
'text-valign': 'top',
'text-halign': 'center',
'text-margin-y': -4,
shape: 'round-rectangle',
},
},
// Click-selected node: stronger border so the user sees which one
// populated the details panel.
{
selector: 'node.nd-selected',
style: {
'border-width': 4,
'border-color': '#0EA5E9',
'overlay-color': '#0EA5E9',
'overlay-opacity': 0.12,
'overlay-padding': 4,
'z-index': 60,
},
},
], ],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
layout: { layout: {
name: 'dagre', name: 'dagre',
rankDir: 'TB', rankDir: 'TB',
@ -211,13 +327,117 @@ async function renderGraph(): Promise<void> {
} as any, } as any,
wheelSensitivity: 0.15, wheelSensitivity: 0.15,
}) })
// --- Plugin activation ---------------------------------------------------
// Expand/collapse on compound nodes. `layoutBy` re-runs dagre after each
// toggle so the graph stays tidy. Don't animate on big docs the per-node
// tween is choppy and the user just wants the end state.
;(cy.value as any).expandCollapse({
layoutBy: { name: 'dagre', rankDir: 'TB', nodeSep: 30, rankSep: 40 },
fisheye: false,
animate: false,
undoable: false,
cueEnabled: true, // shows the +/- cue on compound nodes
expandCollapseCuePosition: 'top-left',
expandCollapseCueSize: 12,
})
// --- Interactions --------------------------------------------------------
cy.value.on('tap', 'node', (evt) => {
const raw = evt.target.data('raw') as GraphNode | undefined
if (!raw) return
selectedNode.value = raw
// Visual feedback clear previous selection class first.
cy.value?.nodes('.nd-selected').removeClass('nd-selected')
evt.target.addClass('nd-selected')
})
// Click on background close details panel.
cy.value.on('tap', (evt) => {
if (evt.target === cy.value) {
selectedNode.value = null
cy.value?.nodes('.nd-selected').removeClass('nd-selected')
}
})
// Hover tooltip shows the full node text (backend truncates to 200 chars).
cy.value.on('mouseover', 'node', (evt) => {
const raw = evt.target.data('raw') as GraphNode | undefined
if (!raw) return
const text = tooltipTextFor(raw)
if (!text) return
const pos = evt.target.renderedPosition()
tooltip.value = { x: pos.x, y: pos.y, text }
})
cy.value.on('mouseout', 'node', () => {
tooltip.value = null
})
cy.value.on('pan zoom', () => {
// Tooltip coordinates are in rendered space; on pan/zoom they're stale.
tooltip.value = null
})
}
/**
* What to show on hover: prefer the node's full text, then title, then ref.
* Keeps the tooltip useful across node kinds (Document/Page/Chunk too).
*/
function tooltipTextFor(n: GraphNode): string {
if (n.group === 'document') return (n.title as string | undefined) ?? n.id
if (n.group === 'page') return `Page ${n.page_no ?? '?'}`
if (n.group === 'chunk') {
const head = ((n.text as string | undefined) ?? '').slice(0, 160)
return head ? `chunk #${n.chunk_index ?? '?'}\n${head}` : `chunk #${n.chunk_index ?? '?'}`
}
const text = (n.text as string | undefined) ?? ''
const ref = n.self_ref ?? ''
if (text) return text
return ref || n.label || ''
}
function closeDetails(): void {
selectedNode.value = null
cy.value?.nodes('.nd-selected').removeClass('nd-selected')
} }
function disposeGraph(): void { function disposeGraph(): void {
if (cy) { if (cy.value) {
cy.destroy() cy.value.destroy()
cy = null cy.value = null
} }
selectedNode.value = null
tooltip.value = null
}
/**
* Apply the current `hiddenChips` set to the Cytoscape instance marks
* every node whose chip is in the set with the `.hidden` class, and clears
* the class from nodes whose chip is no longer hidden.
*
* Called after every re-render (so chip state survives a doc reload) and
* whenever the user toggles a chip.
*/
function applyHiddenChips(): void {
const c = cy.value
if (!c) return
c.nodes().forEach((n: any) => {
const raw = n.data('raw')
if (!raw) return
const hiddenByChip = [...hiddenChips.value].some((key) => {
const chip = findChip(key)
return chip?.match(raw) ?? false
})
if (hiddenByChip) n.addClass('hidden')
else n.removeClass('hidden')
})
}
function toggleChip(key: string): void {
const next = new Set(hiddenChips.value)
if (next.has(key)) next.delete(key)
else next.add(key)
hiddenChips.value = next
applyHiddenChips()
} }
onMounted(load) onMounted(load)
@ -229,6 +449,10 @@ watch(
load() load()
}, },
) )
// Let parent components observe the live Cytoscape instance (e.g. the
// reasoning-trace overlay reads it via `graphViewRef.value?.cy`).
defineExpose({ cy, load })
</script> </script>
<style scoped> <style scoped>
@ -267,6 +491,21 @@ watch(
padding: 2px 8px; padding: 2px 8px;
border-radius: 10px; border-radius: 10px;
color: #f8fafc; color: #f8fafc;
border: 0;
cursor: pointer;
font-family: inherit;
transition: opacity var(--transition);
}
.legend-chip:hover {
filter: brightness(1.12);
}
/* Inactive (user clicked the chip to hide that node kind). */
.legend-chip.legend-off {
opacity: 0.32;
text-decoration: line-through;
filter: saturate(0.4);
} }
.legend-document { .legend-document {
@ -292,12 +531,43 @@ watch(
background: #dc2626; background: #dc2626;
} }
.graph-canvas { .graph-body {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
display: flex;
flex-direction: row;
overflow: hidden;
}
.graph-canvas-wrap {
flex: 1 1 auto;
min-width: 0;
position: relative;
overflow: hidden;
}
.graph-canvas {
position: absolute;
inset: 0;
background: var(--bg); background: var(--bg);
} }
.graph-tooltip {
position: absolute;
transform: translate(-50%, calc(-100% - 14px));
max-width: 280px;
padding: 6px 10px;
background: rgba(15, 23, 42, 0.94);
color: #f8fafc;
font-size: 11px;
line-height: 1.4;
border-radius: var(--radius-sm);
pointer-events: none;
white-space: pre-wrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
z-index: 20;
}
.graph-placeholder { .graph-placeholder {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -0,0 +1,278 @@
<template>
<aside
v-if="node"
class="nd-panel"
data-e2e="node-details-panel"
role="complementary"
:aria-label="t('graph.nodeDetails')"
>
<header class="nd-header">
<span class="nd-kind-chip" :style="{ background: kindColor }">{{ kindLabel }}</span>
<button class="nd-close" :aria-label="t('graph.close')" @click="$emit('close')"></button>
</header>
<dl class="nd-fields">
<template v-if="selfRef">
<dt>self_ref</dt>
<dd class="nd-mono">{{ selfRef }}</dd>
</template>
<template v-if="doclingLabel">
<dt>docling_label</dt>
<dd class="nd-mono">{{ doclingLabel }}</dd>
</template>
<template v-if="level != null">
<dt>level</dt>
<dd class="nd-mono">{{ level }}</dd>
</template>
<template v-if="pageNo != null">
<dt>{{ t('graph.page') }}</dt>
<dd class="nd-mono">p.{{ pageNo }}</dd>
</template>
<template v-if="chunkIndex != null">
<dt>chunk index</dt>
<dd class="nd-mono">#{{ chunkIndex }}</dd>
</template>
</dl>
<section v-if="text" class="nd-text-block">
<h4 class="nd-section-title">{{ t('graph.text') }}</h4>
<p class="nd-text">{{ text }}</p>
</section>
<section v-if="provs.length > 0" class="nd-provs-block">
<h4 class="nd-section-title">
{{ t('graph.provenances').replace('{n}', String(provs.length)) }}
</h4>
<ul class="nd-provs">
<li v-for="(p, i) in provs" :key="i" class="nd-prov">
<span class="nd-prov-page">p.{{ p.page_no ?? '?' }}</span>
<span class="nd-prov-bbox">
[{{ fmt(p.bbox_l) }}, {{ fmt(p.bbox_t) }}, {{ fmt(p.bbox_r) }}, {{ fmt(p.bbox_b) }}]
</span>
<span v-if="p.coord_origin" class="nd-prov-origin">{{ p.coord_origin }}</span>
</li>
</ul>
</section>
</aside>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '../../../shared/i18n'
import type { GraphNode, GraphProvenance } from '../graphApi'
const props = defineProps<{ node: GraphNode | null }>()
defineEmits<{ close: [] }>()
const { t } = useI18n()
// Centralised colour map, mirrors NODE_COLORS in GraphView keeping a copy
// here avoids coupling the detail panel to GraphView's internal state.
const KIND_COLORS: Record<string, string> = {
document: '#1E293B',
SectionHeader: '#F97316',
Paragraph: '#3B82F6',
TextElement: '#3B82F6',
Table: '#8B5CF6',
Figure: '#22C55E',
List: '#06B6D4',
ListItem: '#06B6D4',
Formula: '#EC4899',
Code: '#14B8A6',
Caption: '#EAB308',
PageHeader: '#64748B',
PageFooter: '#64748B',
KeyValueArea: '#D946EF',
FormArea: '#D946EF',
DocumentIndex: '#0EA5E9',
Page: '#94A3B8',
Chunk: '#DC2626',
}
const kindLabel = computed<string>(() => {
const n = props.node
if (!n) return ''
if (n.group === 'document') return 'Document'
if (n.group === 'page') return 'Page'
if (n.group === 'chunk') return 'Chunk'
return n.label ?? 'Element'
})
const kindColor = computed<string>(() => {
const n = props.node
if (!n) return '#64748B'
if (n.group === 'document') return KIND_COLORS.document
if (n.group === 'page') return KIND_COLORS.Page
if (n.group === 'chunk') return KIND_COLORS.Chunk
return KIND_COLORS[n.label ?? ''] || KIND_COLORS.TextElement
})
const selfRef = computed(() => props.node?.self_ref ?? null)
const doclingLabel = computed(() => (props.node?.docling_label as string | undefined) ?? null)
const level = computed<number | null>(() => {
const v = props.node?.level
return typeof v === 'number' ? v : null
})
const pageNo = computed<number | null>(() => {
const v = props.node?.page_no ?? props.node?.prov_page
return typeof v === 'number' ? v : null
})
const chunkIndex = computed<number | null>(() => {
const v = props.node?.chunk_index
return typeof v === 'number' ? v : null
})
const text = computed<string>(() => (props.node?.text as string | undefined) ?? '')
const provs = computed<GraphProvenance[]>(() => (props.node?.provs as GraphProvenance[]) ?? [])
function fmt(n: number | null | undefined): string {
if (n == null) return '—'
return n.toFixed(1)
}
</script>
<style scoped>
.nd-panel {
display: flex;
flex-direction: column;
gap: 14px;
width: 320px;
flex: 0 0 320px;
padding: 14px 16px;
background: var(--bg);
border-left: 1px solid var(--border);
overflow-y: auto;
height: 100%;
}
.nd-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.nd-kind-chip {
display: inline-block;
padding: 3px 10px;
border-radius: 10px;
color: #f8fafc;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.3px;
}
.nd-close {
background: transparent;
border: 0;
color: var(--text-muted);
font-size: 16px;
cursor: pointer;
padding: 2px 8px;
border-radius: var(--radius-sm);
}
.nd-close:hover {
background: var(--border-light);
color: var(--text);
}
.nd-fields {
display: grid;
grid-template-columns: 100px 1fr;
gap: 6px 10px;
margin: 0;
font-size: 12px;
}
.nd-fields dt {
color: var(--text-muted);
font-weight: 500;
text-transform: uppercase;
font-size: 10px;
letter-spacing: 0.4px;
align-self: center;
}
.nd-fields dd {
margin: 0;
color: var(--text);
}
.nd-mono {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
}
.nd-section-title {
margin: 0 0 6px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.nd-text-block {
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 10px;
border-top: 1px solid var(--border-light);
}
.nd-text {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--text);
white-space: pre-wrap;
}
.nd-provs-block {
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 10px;
border-top: 1px solid var(--border-light);
}
.nd-provs {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.nd-prov {
display: grid;
grid-template-columns: 40px 1fr auto;
gap: 6px;
align-items: baseline;
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
padding: 4px 6px;
background: var(--border-light);
border-radius: var(--radius-sm);
}
.nd-prov-page {
font-weight: 700;
color: var(--text);
}
.nd-prov-bbox {
color: var(--text-secondary);
}
.nd-prov-origin {
color: var(--text-muted);
font-size: 9px;
text-transform: lowercase;
}
</style>

View file

@ -16,7 +16,7 @@ interface HealthResponse {
ingestionAvailable?: boolean ingestionAvailable?: boolean
} }
export type FeatureFlag = 'chunking' | 'disclaimer' | 'ingestion' export type FeatureFlag = 'chunking' | 'disclaimer' | 'ingestion' | 'reasoning'
interface FeatureFlagDef { interface FeatureFlagDef {
description: string description: string
@ -42,6 +42,13 @@ const featureRegistry: Record<FeatureFlag, FeatureFlagDef> = {
description: 'OpenSearch ingestion pipeline (embedding + vector indexing)', description: 'OpenSearch ingestion pipeline (embedding + vector indexing)',
isEnabled: (ctx) => ctx.ingestionAvailable, isEnabled: (ctx) => ctx.ingestionAvailable,
}, },
reasoning: {
// SQLite-backed (builds the graph from `document_json` on the fly), so no
// server-side gating needed. Kept as a flag so a future deployment can
// still kill-switch the UI if it wants to.
description: 'Reasoning trace tunnel (docling-agent RAGResult viewer)',
isEnabled: () => true,
},
} }
export const useFeatureFlagStore = defineStore('feature-flags', () => { export const useFeatureFlagStore = defineStore('feature-flags', () => {

View file

@ -0,0 +1,15 @@
import { apiFetch } from '../../shared/api/http'
import type { GraphPayload } from '../analysis/graphApi'
/**
* Fetch the reasoning-trace graph for a document built on the backend from
* the SQLite `document_json` blob, not Neo4j. This is intentionally decoupled
* from Maintain's richer Neo4j graph: reasoning only needs the structural
* view (sections, parent/child, reading order, pages) to overlay iterations
* onto, and should work even if Neo4j isn't configured.
*
* 404 if no completed analysis with `document_json` exists for the doc.
*/
export function fetchReasoningGraph(docId: string): Promise<GraphPayload> {
return apiFetch<GraphPayload>(`/api/documents/${encodeURIComponent(docId)}/reasoning-graph`)
}

View file

@ -0,0 +1,245 @@
import cytoscape from 'cytoscape'
import type { Core } from 'cytoscape'
import { beforeEach, describe, expect, it } from 'vitest'
import {
REASONING_EDGE_TYPE,
applyReasoningOverlay,
buildDegradedOverlay,
clearReasoningOverlay,
focusIteration,
nodeIdForSectionRef,
} from './graphReasoningOverlay'
import type { RAGResult } from './types'
function seed(): Core {
// Headless mode — no DOM container needed.
return cytoscape({
headless: true,
elements: [
{ data: { id: 'elem::#/texts/0', group: 'element' } },
{ data: { id: 'elem::#/texts/3', group: 'element' } },
{ data: { id: 'elem::#/texts/7', group: 'element' } },
{ data: { id: 'elem::#/groups/1', group: 'element' } },
],
})
}
function result(iterations: Array<Partial<RAGResult['iterations'][number]>>): RAGResult {
return {
answer: 'x',
converged: true,
iterations: iterations.map((it, i) => ({
iteration: i + 1,
section_ref: '',
reason: '',
section_text_length: 0,
can_answer: false,
response: '',
...it,
})),
}
}
describe('nodeIdForSectionRef', () => {
it('matches the backend _element_node id format', () => {
// Kept in sync with document-parser/infra/neo4j/queries.py::_element_node.
expect(nodeIdForSectionRef('#/texts/3')).toBe('elem::#/texts/3')
})
})
describe('applyReasoningOverlay', () => {
let cy: Core
beforeEach(() => {
cy = seed()
})
it('marks every resolvable section as visited with its iteration order', () => {
const res = applyReasoningOverlay(
cy,
result([{ section_ref: '#/texts/0' }, { section_ref: '#/texts/3' }]),
)
expect(res.presentCount).toBe(2)
expect(res.missingCount).toBe(0)
expect(cy.getElementById('elem::#/texts/0').hasClass('visited')).toBe(true)
expect(cy.getElementById('elem::#/texts/0').data('visitOrder')).toBe(1)
expect(cy.getElementById('elem::#/texts/3').data('visitOrder')).toBe(2)
})
it('adds a REASONING_NEXT edge between each consecutive visited pair', () => {
applyReasoningOverlay(
cy,
result([
{ section_ref: '#/texts/0' },
{ section_ref: '#/texts/3' },
{ section_ref: '#/texts/7' },
]),
)
const edges = cy.$(`edge[type = "${REASONING_EDGE_TYPE}"]`)
expect(edges.length).toBe(2)
const ids = edges.map((e) => e.id()).sort()
expect(ids).toEqual([
'REASONING_NEXT::elem::#/texts/0::elem::#/texts/3',
'REASONING_NEXT::elem::#/texts/3::elem::#/texts/7',
])
})
it('reports missing section refs and does not crash on them', () => {
const res = applyReasoningOverlay(
cy,
result([
{ section_ref: '#/texts/0' },
{ section_ref: '#/texts/999' }, // not in graph
{ section_ref: '#/texts/3' },
]),
)
expect(res.presentCount).toBe(2)
expect(res.missingCount).toBe(1)
expect(res.resolved[1].present).toBe(false)
expect(cy.getElementById('elem::#/texts/999').nonempty()).toBe(false)
})
it('breaks the arrow chain across a missing iteration (no ghost edges)', () => {
applyReasoningOverlay(
cy,
result([
{ section_ref: '#/texts/0' },
{ section_ref: '#/texts/999' }, // missing
{ section_ref: '#/texts/3' },
]),
)
// Only the chain between present-to-present pairs gets edges. With one
// missing in the middle, we still draw 0→3 because the filter keeps the
// present ones adjacent in the sequence used for edge drawing. Assert it:
// one edge between 0 and 3.
const edges = cy.$(`edge[type = "${REASONING_EDGE_TYPE}"]`)
expect(edges.length).toBe(1)
expect(edges[0].data('source')).toBe('elem::#/texts/0')
expect(edges[0].data('target')).toBe('elem::#/texts/3')
})
it('is idempotent — re-applying replaces the previous overlay', () => {
applyReasoningOverlay(cy, result([{ section_ref: '#/texts/0' }]))
applyReasoningOverlay(cy, result([{ section_ref: '#/texts/3' }, { section_ref: '#/texts/7' }]))
expect(cy.getElementById('elem::#/texts/0').hasClass('visited')).toBe(false)
expect(cy.getElementById('elem::#/texts/3').hasClass('visited')).toBe(true)
expect(cy.$(`edge[type = "${REASONING_EDGE_TYPE}"]`).length).toBe(1)
})
it('dims every non-visited node so the trace pops visually', () => {
applyReasoningOverlay(cy, result([{ section_ref: '#/texts/0' }, { section_ref: '#/texts/3' }]))
// Visited ones keep their full opacity (no dimmed class).
expect(cy.getElementById('elem::#/texts/0').hasClass('dimmed')).toBe(false)
expect(cy.getElementById('elem::#/texts/3').hasClass('dimmed')).toBe(false)
// Everything else gets dimmed.
expect(cy.getElementById('elem::#/texts/7').hasClass('dimmed')).toBe(true)
expect(cy.getElementById('elem::#/groups/1').hasClass('dimmed')).toBe(true)
})
it('does not dim anything when the trace has no resolvable iterations', () => {
// All missing → we don't want to wash out the whole graph for nothing.
applyReasoningOverlay(cy, result([{ section_ref: '#/texts/999' }]))
expect(cy.$('.dimmed').length).toBe(0)
})
it('skips dimming entirely when focusMode is off', () => {
applyReasoningOverlay(
cy,
result([{ section_ref: '#/texts/0' }, { section_ref: '#/texts/3' }]),
{ focusMode: false },
)
// Visited still highlighted...
expect(cy.getElementById('elem::#/texts/0').hasClass('visited')).toBe(true)
// ...but nothing else gets dimmed.
expect(cy.$('.dimmed').length).toBe(0)
})
it('does not dim the synthetic REASONING_NEXT edges', () => {
applyReasoningOverlay(
cy,
result([
{ section_ref: '#/texts/0' },
{ section_ref: '#/texts/3' },
{ section_ref: '#/texts/7' },
]),
)
const reasoningEdges = cy.$(`edge[type = "${REASONING_EDGE_TYPE}"]`)
expect(reasoningEdges.length).toBe(2)
reasoningEdges.forEach((e) => {
expect(e.hasClass('dimmed')).toBe(false)
})
})
it('preserves the original graph elements (no destructive mutations)', () => {
const beforeIds = cy
.elements()
.map((e) => e.id())
.sort()
applyReasoningOverlay(cy, result([{ section_ref: '#/texts/0' }]))
clearReasoningOverlay(cy)
const afterIds = cy
.elements()
.map((e) => e.id())
.sort()
expect(afterIds).toEqual(beforeIds)
})
})
describe('clearReasoningOverlay', () => {
it('removes class, data, dimming, and synthetic edges', () => {
const cy = seed()
applyReasoningOverlay(cy, result([{ section_ref: '#/texts/0' }, { section_ref: '#/texts/3' }]))
clearReasoningOverlay(cy)
expect(cy.$('.visited').length).toBe(0)
expect(cy.$('.dimmed').length).toBe(0)
expect(cy.getElementById('elem::#/texts/0').data('visitOrder')).toBeUndefined()
expect(cy.$(`edge[type = "${REASONING_EDGE_TYPE}"]`).length).toBe(0)
})
it('is a no-op when nothing is overlaid', () => {
const cy = seed()
expect(() => clearReasoningOverlay(cy)).not.toThrow()
})
})
describe('buildDegradedOverlay', () => {
// Used when the Neo4j graph isn't loaded — we still want to show the
// reasoning trace cards, just without graph positioning.
it('returns every iteration with present=false', () => {
const out = buildDegradedOverlay(
result([
{ section_ref: '#/texts/0', reason: 'r0', can_answer: false },
{ section_ref: '#/texts/3', reason: 'r1', can_answer: true, response: 'done' },
]),
)
expect(out.resolved).toHaveLength(2)
expect(out.presentCount).toBe(0)
expect(out.missingCount).toBe(2)
expect(out.resolved.every((r) => !r.present)).toBe(true)
expect(out.resolved[0].reason).toBe('r0')
expect(out.resolved[1].canAnswer).toBe(true)
expect(out.resolved[1].response).toBe('done')
expect(out.resolved[0].nodeId).toBe('elem::#/texts/0')
})
it('handles an empty iterations list', () => {
const out = buildDegradedOverlay(result([]))
expect(out.resolved).toEqual([])
expect(out.presentCount).toBe(0)
expect(out.missingCount).toBe(0)
})
})
describe('focusIteration', () => {
it('is a no-op for a missing node', () => {
const cy = seed()
expect(() => focusIteration(cy, 'elem::#/texts/does-not-exist')).not.toThrow()
})
})

View file

@ -0,0 +1,278 @@
/**
* Pure Cytoscape-manipulation helpers for the reasoning-trace overlay.
*
* Keeping this as a plain TS module (not a component) makes it trivially
* testable against `cytoscape({ headless: true })` and lets the same code
* drive both the v1 static-import UX and the v2 streaming-runner UX.
*
* The overlay is purely visual: it adds a class + a transient `visitOrder`
* data attribute to existing element nodes and injects synthetic
* `REASONING_NEXT` edges between successive visited nodes. None of this is
* persisted to Neo4j.
*/
import type { Core } from 'cytoscape'
import type { OverlayResult, RAGResult, ResolvedIteration } from './types'
export const REASONING_EDGE_TYPE = 'REASONING_NEXT'
const VISITED_CLASS = 'visited'
const DIMMED_CLASS = 'dimmed'
/**
* Build the Cytoscape node id for a given `section_ref`.
*
* Must stay in sync with `document-parser/infra/neo4j/queries.py::_element_node`
* which emits `f"elem::{self_ref}"`. The `#` that lives inside a Docling
* self_ref (e.g. `#/texts/3`) is fine inside an id string we just always
* look up via `cy.getElementById()` rather than the `#id` selector syntax,
* which would conflict with the leading `#`.
*/
export function nodeIdForSectionRef(sectionRef: string): string {
return `elem::${sectionRef}`
}
export interface OverlayOptions {
/**
* When true (default), dim every non-visited element and hide the Document
* root the user sees the trace pop against a muted background.
* When false, the graph keeps its full colors; only visited nodes get the
* orange ring + numbered badge and the REASONING_NEXT arrows are drawn.
* Toggled from the ReasoningPanel header.
*/
focusMode?: boolean
}
/**
* Apply the overlay for a freshly imported `RAGResult`.
*
* Idempotent: any previous overlay is cleared first. Returns a summary that
* the caller (store / panel) uses to drive the UI (list of iterations,
* missing count, etc).
*/
export function applyReasoningOverlay(
cy: Core,
result: RAGResult,
options: OverlayOptions = {},
): OverlayResult {
const focusMode = options.focusMode ?? true
clearReasoningOverlay(cy)
const resolved: ResolvedIteration[] = result.iterations.map((it) => {
const nodeId = nodeIdForSectionRef(it.section_ref)
const node = cy.getElementById(nodeId)
const present = node.nonempty()
if (present) {
node.addClass(VISITED_CLASS)
node.data('visitOrder', it.iteration)
}
return {
iteration: it.iteration,
sectionRef: it.section_ref,
nodeId,
present,
reason: it.reason,
canAnswer: it.can_answer,
response: it.response,
sectionTextLength: it.section_text_length,
}
})
// Dim everything that isn't part of the trace. We do this BEFORE injecting
// the synthetic REASONING_NEXT edges so those edges never receive `.dimmed`
// — they're the foreground of the viz. Takes the inspiration from the
// BboxOverlay approach (dim non-highlighted bboxes, keep the active ones
// fully opaque) and applies it to the graph.
//
// The Document root node is hidden outright (via a `display: none` rule on
// `node.dimmed[group = "document"]`) — it sits at the center of the layout
// and adds zero signal to a reasoning trace, only visual noise. Its
// connected `HAS_ROOT` edge is hidden automatically by Cytoscape.
const present = resolved.filter((r) => r.present)
if (focusMode && present.length > 0) {
cy.elements().not(`.${VISITED_CLASS}`).addClass(DIMMED_CLASS)
}
// Draw trace arrows only between successively-present iterations. Gaps
// (missing nodes) break the chain — we don't draw edges to ghosts.
for (let i = 0; i < present.length - 1; i++) {
const src = present[i]
const tgt = present[i + 1]
cy.add({
group: 'edges',
data: {
id: `${REASONING_EDGE_TYPE}::${src.nodeId}::${tgt.nodeId}`,
source: src.nodeId,
target: tgt.nodeId,
type: REASONING_EDGE_TYPE,
order: i,
},
})
}
const visitedEles = cy.$(`.${VISITED_CLASS}`)
if (visitedEles.nonempty()) {
// Padding keeps the arrows readable when the trace is one or two nodes.
cy.fit(visitedEles, 80)
}
return {
resolved,
presentCount: present.length,
missingCount: resolved.length - present.length,
}
}
/**
* Build a "degraded" overlay result when no Cytoscape instance is available
* (graph failed to load, or graph is empty for this document). Every iteration
* is marked `present: false`; the panel can still render the iteration cards
* so the user sees the reasoning they just don't get highlights on the graph.
*/
export function buildDegradedOverlay(result: RAGResult): OverlayResult {
const resolved: ResolvedIteration[] = result.iterations.map((it) => ({
iteration: it.iteration,
sectionRef: it.section_ref,
nodeId: nodeIdForSectionRef(it.section_ref),
present: false,
reason: it.reason,
canAnswer: it.can_answer,
response: it.response,
sectionTextLength: it.section_text_length,
}))
return {
resolved,
presentCount: 0,
missingCount: resolved.length,
}
}
/**
* Remove every overlay artifact from the graph. Safe to call when nothing
* is overlaid it becomes a no-op.
*/
export function clearReasoningOverlay(cy: Core): void {
cy.$(`.${VISITED_CLASS}`).forEach((n) => {
n.removeClass(VISITED_CLASS)
n.removeData('visitOrder')
})
cy.$(`.${DIMMED_CLASS}`).removeClass(DIMMED_CLASS)
cy.$(`edge[type = "${REASONING_EDGE_TYPE}"]`).remove()
}
/**
* Center the viewport on a single iteration's node and give it a quick
* visual pulse. No-op if the node isn't present (missing iteration).
*/
export function focusIteration(cy: Core, nodeId: string): void {
const node = cy.getElementById(nodeId)
if (!node.nonempty()) return
cy.animate(
{
center: { eles: node },
zoom: Math.max(cy.zoom(), 1.0),
},
{ duration: 300 },
)
// flashClass is a cytoscape-builtin: add the class then remove it after N ms.
node.flashClass('pulse', 800)
}
/**
* Shape of a single Cytoscape stylesheet block used for the overlay. We keep
* it intentionally loose (`Record<string, unknown>` for `style`) because the
* upstream cytoscape types fork selectors by node-vs-edge prefix and flag
* cross-type properties, while the runtime itself accepts everything we emit
* here.
*/
interface StyleBlock {
selector: string
style: Record<string, unknown>
}
/**
* Style rules appended to the existing GraphView stylesheet. Exported so the
* component can spread them into its Cytoscape config.
*/
export const reasoningOverlayStyles: StyleBlock[] = [
// Non-trace elements get dimmed — BboxOverlay-inspired: keep the colors,
// drop the opacity hard so the trace pops. Node opacity cascades to label
// + border; edges get a stronger fade because they add visual noise.
{
selector: `node.${DIMMED_CLASS}`,
style: {
opacity: 0.18,
'text-opacity': 0.25,
},
},
{
selector: `edge.${DIMMED_CLASS}`,
style: {
opacity: 0.08,
},
},
// Hide the Document root node entirely when a trace is active — it sits
// at the center of the dagre layout and is pure noise for reasoning-path
// inspection. Its `HAS_ROOT` edge is hidden automatically by Cytoscape
// because `display: none` cascades to connected edges.
{
selector: `node.${DIMMED_CLASS}[group = "document"]`,
style: {
display: 'none',
},
},
// Visited nodes: orange ring + label-less numbered badge above them.
// Explicit opacity: 1 prevents inheritance quirks when the user re-applies
// an overlay on top of an existing one.
{
selector: `node.${VISITED_CLASS}`,
style: {
'border-color': '#EA580C',
'border-width': 4,
'overlay-color': '#EA580C',
'overlay-opacity': 0.08,
'overlay-padding': 4,
opacity: 1,
'z-index': 50,
},
},
{
selector: `node.${VISITED_CLASS}[visitOrder]`,
style: {
label: 'data(visitOrder)',
'text-valign': 'top',
'text-margin-y': -6,
'text-background-color': '#EA580C',
'text-background-opacity': 1,
'text-background-padding': '3px',
'text-background-shape': 'roundrectangle',
'text-border-color': '#FFFFFF',
'text-border-width': 1.5,
'text-border-opacity': 1,
color: '#FFFFFF',
'font-weight': 700,
'font-size': 12,
'text-opacity': 1,
},
},
{
selector: `edge[type = "${REASONING_EDGE_TYPE}"]`,
style: {
'line-color': '#EA580C',
'target-arrow-color': '#EA580C',
'target-arrow-shape': 'triangle',
'arrow-scale': 1.4,
'curve-style': 'bezier',
'control-point-step-size': 40,
width: 3,
opacity: 0.95,
'z-index': 99,
},
},
{
selector: 'node.pulse',
style: {
'border-width': 7,
'border-color': '#F59E0B',
},
},
]

View file

@ -0,0 +1,84 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { parseImportedTrace, useReasoningStore } from './store'
describe('parseImportedTrace', () => {
const bare = {
answer: 'ok',
converged: true,
iterations: [],
}
it('accepts a bare RAGResult', () => {
const parsed = parseImportedTrace(bare)
expect(parsed?.result.answer).toBe('ok')
expect(parsed?.envelope).toBeNull()
})
it('accepts a sidecar envelope and extracts the result', () => {
const parsed = parseImportedTrace({
job_id: 'abc',
filename: 'x.pdf',
result: bare,
})
expect(parsed?.result.answer).toBe('ok')
expect(parsed?.envelope?.job_id).toBe('abc')
})
it('rejects shapes that are neither envelope nor bare RAGResult', () => {
expect(parseImportedTrace(null)).toBeNull()
expect(parseImportedTrace('string')).toBeNull()
expect(parseImportedTrace({ foo: 'bar' })).toBeNull()
expect(parseImportedTrace({ answer: 'x' })).toBeNull() // missing converged + iterations
})
})
describe('useReasoningStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('starts empty', () => {
const s = useReasoningStore()
expect(s.hasTrace).toBe(false)
expect(s.iterations).toEqual([])
expect(s.presentCount).toBe(0)
})
it('setResult populates and resets active iteration', () => {
const s = useReasoningStore()
s.setActiveIteration(3)
s.setResult({ answer: 'a', converged: true, iterations: [] }, null)
expect(s.hasTrace).toBe(true)
expect(s.activeIteration).toBeNull()
})
it('toggleFocusMode flips the flag', () => {
const s = useReasoningStore()
expect(s.focusMode).toBe(true)
s.toggleFocusMode()
expect(s.focusMode).toBe(false)
s.toggleFocusMode()
expect(s.focusMode).toBe(true)
})
it('reset restores focusMode to the default on', () => {
const s = useReasoningStore()
s.toggleFocusMode()
expect(s.focusMode).toBe(false)
s.reset()
expect(s.focusMode).toBe(true)
})
it('reset clears everything including the dialog flag', () => {
const s = useReasoningStore()
s.openImportDialog()
s.setResult({ answer: 'a', converged: true, iterations: [] }, null)
s.setError('boom')
s.reset()
expect(s.hasTrace).toBe(false)
expect(s.error).toBeNull()
expect(s.importDialogOpen).toBe(false)
})
})

View file

@ -0,0 +1,128 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type { OverlayResult, RAGResult, ResolvedIteration, SidecarEnvelope } from './types'
/**
* Parse an arbitrary JSON payload as either:
* - a bare `RAGResult` (what `docling-agent` emits directly), or
* - the sidecar envelope (`{ job_id, filename, query, model, result }`)
*
* Returns `null` if the shape doesn't match either.
*/
export function parseImportedTrace(
raw: unknown,
): { result: RAGResult; envelope: SidecarEnvelope | null } | null {
if (!raw || typeof raw !== 'object') return null
const obj = raw as Record<string, unknown>
// Envelope shape
if (obj.result && typeof obj.result === 'object') {
const result = obj.result as RAGResult
if (isRAGResult(result)) {
return { result, envelope: obj as unknown as SidecarEnvelope }
}
}
// Bare RAGResult
if (isRAGResult(obj as unknown as RAGResult)) {
return { result: obj as unknown as RAGResult, envelope: null }
}
return null
}
function isRAGResult(x: RAGResult | undefined): boolean {
if (!x || typeof x !== 'object') return false
return (
typeof x.answer === 'string' && typeof x.converged === 'boolean' && Array.isArray(x.iterations)
)
}
export const useReasoningStore = defineStore('reasoning', () => {
const importDialogOpen = ref(false)
const rawResult = ref<RAGResult | null>(null)
const envelope = ref<SidecarEnvelope | null>(null)
const overlay = ref<OverlayResult | null>(null)
const activeIteration = ref<number | null>(null)
const error = ref<string | null>(null)
// Focus mode: when true, non-visited elements are dimmed so the trace pops.
// Default ON because that's the primary value of the feature; user can
// switch it off from the panel to see the trace in the full graph context.
const focusMode = ref(true)
const hasTrace = computed(() => rawResult.value !== null)
const iterations = computed<ResolvedIteration[]>(() => overlay.value?.resolved ?? [])
const presentCount = computed(() => overlay.value?.presentCount ?? 0)
const missingCount = computed(() => overlay.value?.missingCount ?? 0)
function openImportDialog(): void {
importDialogOpen.value = true
}
function closeImportDialog(): void {
importDialogOpen.value = false
}
/**
* Called by `ImportTraceDialog` once the user has supplied a JSON file.
* Does NOT touch Cytoscape the `ReasoningPanel` watches `rawResult` and
* reapplies the overlay via `graphReasoningOverlay.applyReasoningOverlay`.
*/
function setResult(result: RAGResult, env: SidecarEnvelope | null): void {
rawResult.value = result
envelope.value = env
error.value = null
activeIteration.value = null
}
function setOverlay(o: OverlayResult | null): void {
overlay.value = o
}
function setActiveIteration(n: number | null): void {
activeIteration.value = n
}
function setError(msg: string | null): void {
error.value = msg
}
function toggleFocusMode(): void {
focusMode.value = !focusMode.value
}
/** Full reset — e.g. when the user switches document. */
function reset(): void {
rawResult.value = null
envelope.value = null
overlay.value = null
activeIteration.value = null
error.value = null
importDialogOpen.value = false
focusMode.value = true
}
return {
// state
importDialogOpen,
rawResult,
envelope,
overlay,
activeIteration,
error,
focusMode,
// computed
hasTrace,
iterations,
presentCount,
missingCount,
// actions
openImportDialog,
closeImportDialog,
setResult,
setOverlay,
setActiveIteration,
setError,
toggleFocusMode,
reset,
}
})

View file

@ -0,0 +1,65 @@
/**
* Types mirroring the `docling-agent` RAG output.
*
* The JSON imported by the user is produced either by:
* - the R&D sidecar (`experiments/reasoning-trace/inspect_doc.py`), or
* - any external `docling-agent` run that was serialized to JSON.
*
* Since `docling-agent` uses plain pydantic (no alias generator), field names
* are **snake_case** here. This is one of the rare spots in the frontend where
* we don't normalize to camelCase keeping the shape 1:1 with upstream means
* a schema drift upstream gives us a clean type error rather than silent
* re-mapping.
*
* Source of truth: docling-project/docling-agent @ docling_agent/agent/rag_models.py
*/
export interface RAGIteration {
iteration: number
section_ref: string
reason: string
section_text_length: number
can_answer: boolean
response: string
}
export interface RAGResult {
answer: string
iterations: RAGIteration[]
converged: boolean
}
/**
* Envelope written by the R&D sidecar. The viewer also accepts a bare
* `RAGResult` (see `parseImportedTrace` in the store).
*/
export interface SidecarEnvelope {
job_id?: string
filename?: string
query?: string
model?: { ollama_name?: string | null; hf_model_name?: string | null }
max_iterations?: number
result: RAGResult
}
/**
* One iteration after matching its `section_ref` against the currently-loaded
* Cytoscape graph. `present=false` means the section ref has no corresponding
* node (doc not through Maintain, or a different version of the doc).
*/
export interface ResolvedIteration {
iteration: number
sectionRef: string
nodeId: string
present: boolean
reason: string
canAnswer: boolean
response: string
sectionTextLength: number
}
export interface OverlayResult {
resolved: ResolvedIteration[]
presentCount: number
missingCount: number
}

View file

@ -0,0 +1,340 @@
<template>
<div
v-if="store.importDialogOpen"
class="trace-modal-backdrop"
data-e2e="reasoning-import-modal"
@click.self="close"
>
<div
class="trace-modal"
role="dialog"
aria-modal="true"
:aria-label="t('reasoning.importTitle')"
>
<div class="trace-modal-header">
<h3>{{ t('reasoning.importTitle') }}</h3>
<button class="trace-modal-close" :aria-label="t('reasoning.close')" @click="close">
</button>
</div>
<p class="trace-modal-hint">{{ t('reasoning.importHint') }}</p>
<div
class="trace-drop"
:class="{ dragging, parsing }"
data-e2e="reasoning-drop-zone"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
@click="openFilePicker"
>
<input
ref="fileInput"
type="file"
accept=".json,application/json"
hidden
@change="onFileSelect"
/>
<div v-if="parsing" class="trace-drop-state">
<div class="spinner" />
<span>{{ t('reasoning.parsing') }}</span>
</div>
<div v-else class="trace-drop-state">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="trace-icon"
>
<path d="M12 16V4m0 0L8 8m4-4l4 4M4 17v2a1 1 0 001 1h14a1 1 0 001-1v-2" />
</svg>
<span class="trace-drop-title">{{ t('reasoning.drop') }}</span>
<span class="trace-drop-sub">{{ t('reasoning.dropSub') }}</span>
</div>
</div>
<details class="trace-paste">
<summary>{{ t('reasoning.pasteToggle') }}</summary>
<textarea
v-model="pastedJson"
class="trace-paste-area"
:placeholder="t('reasoning.pastePlaceholder')"
rows="6"
data-e2e="reasoning-paste-area"
/>
<button
class="trace-paste-btn"
:disabled="!pastedJson.trim() || parsing"
@click="submitPasted"
>
{{ t('reasoning.pasteSubmit') }}
</button>
</details>
<div v-if="errorMsg" class="trace-modal-error" data-e2e="reasoning-import-error">
{{ errorMsg }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from '../../../shared/i18n'
import { useReasoningStore } from '../store'
import { parseImportedTrace } from '../store'
const store = useReasoningStore()
const { t } = useI18n()
const fileInput = ref<HTMLInputElement | null>(null)
const dragging = ref(false)
const parsing = ref(false)
const pastedJson = ref('')
const errorMsg = ref<string | null>(null)
function close(): void {
store.closeImportDialog()
errorMsg.value = null
pastedJson.value = ''
}
function openFilePicker(): void {
fileInput.value?.click()
}
async function readFileAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result ?? ''))
reader.onerror = () => reject(reader.error ?? new Error('read failed'))
reader.readAsText(file)
})
}
function ingest(rawText: string): boolean {
let raw: unknown
try {
raw = JSON.parse(rawText)
} catch (e) {
errorMsg.value = t('reasoning.errJson').replace('{msg}', (e as Error).message)
return false
}
const parsed = parseImportedTrace(raw)
if (!parsed) {
errorMsg.value = t('reasoning.errShape')
return false
}
errorMsg.value = null
store.setResult(parsed.result, parsed.envelope)
close()
return true
}
async function handleFile(file: File): Promise<void> {
errorMsg.value = null
parsing.value = true
try {
const text = await readFileAsText(file)
ingest(text)
} catch (e) {
errorMsg.value = (e as Error).message
} finally {
parsing.value = false
}
}
async function onFileSelect(e: Event): Promise<void> {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (file) await handleFile(file)
target.value = ''
}
async function onDrop(e: DragEvent): Promise<void> {
dragging.value = false
const file = e.dataTransfer?.files?.[0]
if (file) await handleFile(file)
}
function submitPasted(): void {
if (!pastedJson.value.trim()) return
parsing.value = true
try {
ingest(pastedJson.value)
} finally {
parsing.value = false
}
}
</script>
<style scoped>
.trace-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 16px;
}
.trace-modal {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
width: min(560px, 100%);
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 12px 48px rgba(15, 23, 42, 0.25);
}
.trace-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.trace-modal-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.trace-modal-close {
background: transparent;
border: 0;
color: var(--text-muted);
font-size: 16px;
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-sm);
}
.trace-modal-close:hover {
background: var(--border-light);
color: var(--text);
}
.trace-modal-hint {
font-size: 13px;
color: var(--text-muted);
margin: 4px 0 16px;
}
.trace-drop {
border: 2px dashed var(--border-light);
border-radius: var(--radius);
padding: 28px 16px;
text-align: center;
cursor: pointer;
transition: all var(--transition);
}
.trace-drop:hover,
.trace-drop.dragging {
border-color: var(--accent);
background: var(--accent-muted);
}
.trace-drop.parsing {
pointer-events: none;
opacity: 0.7;
}
.trace-drop-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.trace-icon {
width: 32px;
height: 32px;
color: var(--text-muted);
}
.trace-drop-title {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
}
.trace-drop-sub {
font-size: 12px;
color: var(--text-muted);
}
.trace-paste {
margin-top: 12px;
}
.trace-paste summary {
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
padding: 4px 0;
}
.trace-paste-area {
display: block;
width: 100%;
margin-top: 8px;
padding: 8px;
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
resize: vertical;
}
.trace-paste-btn {
margin-top: 8px;
background: var(--accent);
color: white;
border: none;
padding: 6px 16px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 12px;
}
.trace-paste-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.trace-modal-error {
margin-top: 12px;
padding: 8px 12px;
border-radius: var(--radius-sm);
background: rgba(220, 38, 38, 0.08);
color: var(--error, #dc2626);
font-size: 12px;
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border-light);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -0,0 +1,172 @@
<template>
<button
type="button"
class="it-card"
:class="{ active, missing: !iteration.present, converged: iteration.canAnswer }"
:data-e2e="`reasoning-iteration-${iteration.iteration}`"
@click="$emit('focus', iteration.iteration)"
>
<div class="it-row">
<span class="it-badge">{{ iteration.iteration }}</span>
<span class="it-ref" :title="iteration.sectionRef">{{ iteration.sectionRef }}</span>
<span
class="it-status"
:class="{
ok: iteration.canAnswer,
more: !iteration.canAnswer && iteration.present,
missing: !iteration.present,
}"
>
{{ statusLabel }}
</span>
</div>
<p v-if="iteration.reason" class="it-reason">{{ iteration.reason }}</p>
<p v-if="iteration.response && iteration.canAnswer" class="it-response">
{{ iteration.response }}
</p>
<div class="it-meta">
<span v-if="iteration.sectionTextLength">
{{ t('reasoning.charsLabel').replace('{n}', String(iteration.sectionTextLength)) }}
</span>
</div>
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '../../../shared/i18n'
import type { ResolvedIteration } from '../types'
const props = defineProps<{
iteration: ResolvedIteration
active: boolean
}>()
defineEmits<{ focus: [iteration: number] }>()
const { t } = useI18n()
const statusLabel = computed(() => {
if (!props.iteration.present) return t('reasoning.statusMissing')
if (props.iteration.canAnswer) return t('reasoning.statusAnswered')
return t('reasoning.statusMore')
})
</script>
<style scoped>
.it-card {
display: block;
width: 100%;
text-align: left;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
cursor: pointer;
transition: all var(--transition);
font-family: inherit;
color: inherit;
}
.it-card:hover {
border-color: var(--accent);
background: var(--accent-muted);
}
.it-card.active {
border-color: #ea580c;
box-shadow: 0 0 0 2px rgba(234, 88, 12, 0.2);
background: rgba(234, 88, 12, 0.06);
}
.it-card.missing {
opacity: 0.6;
}
.it-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.it-badge {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: #ea580c;
color: #ffffff;
font-weight: 700;
font-size: 11px;
}
.it-card.missing .it-badge {
background: var(--text-muted);
}
.it-ref {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 auto;
}
.it-status {
flex: 0 0 auto;
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.it-status.ok {
background: rgba(22, 163, 74, 0.15);
color: #15803d;
}
.it-status.more {
background: rgba(234, 179, 8, 0.15);
color: #a16207;
}
.it-status.missing {
background: var(--border-light);
color: var(--text-muted);
}
.it-reason {
margin: 8px 0 0;
font-size: 12px;
line-height: 1.45;
color: var(--text);
}
.it-response {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.45;
color: var(--text-secondary);
font-style: italic;
padding: 6px 8px;
border-left: 2px solid #ea580c;
background: rgba(234, 88, 12, 0.04);
border-radius: 2px;
}
.it-meta {
margin-top: 6px;
font-size: 10px;
color: var(--text-muted);
font-family: 'IBM Plex Mono', monospace;
}
</style>

View file

@ -0,0 +1,355 @@
<template>
<div class="rp-picker">
<header class="rp-picker-header">
<h1 class="rp-picker-title">{{ t('reasoning.pageTitle') }}</h1>
<p class="rp-picker-subtitle">{{ t('reasoning.pageSubtitle') }}</p>
</header>
<section class="rp-picker-upload" data-e2e="reasoning-upload">
<div
class="rp-drop"
:class="{ dragging, uploading }"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
@click="openFilePicker"
>
<input ref="fileInput" type="file" accept=".pdf" hidden @change="onFileSelect" />
<div v-if="uploading" class="rp-drop-state">
<div class="spinner" />
<span>{{ t('reasoning.uploading') }}</span>
</div>
<div v-else class="rp-drop-state">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="rp-upload-icon"
>
<path d="M12 16V4m0 0L8 8m4-4l4 4M4 17v2a1 1 0 001 1h14a1 1 0 001-1v-2" />
</svg>
<span class="rp-drop-title">{{ t('reasoning.dropPdf') }}</span>
<span class="rp-drop-sub">{{ t('reasoning.dropPdfHint') }}</span>
</div>
</div>
<p v-if="uploadError" class="rp-upload-error" data-e2e="reasoning-upload-error">
{{ uploadError }}
</p>
</section>
<section v-if="docsWithAnalysis.length > 0" class="rp-picker-list">
<h2 class="rp-list-title">{{ t('reasoning.existingDocs') }}</h2>
<div class="rp-doc-grid">
<button
v-for="doc in docsWithAnalysis"
:key="doc.id"
class="rp-doc-card"
:data-e2e="`reasoning-doc-${doc.id}`"
@click="emit('select', doc.id)"
>
<div class="rp-doc-card-icon">
<svg viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="rp-doc-card-body">
<div class="rp-doc-name" :title="doc.filename">{{ doc.filename }}</div>
<div class="rp-doc-meta">
<span v-if="doc.pageCount">
{{ t('reasoning.pagesCount').replace('{n}', String(doc.pageCount)) }}
</span>
<span class="rp-doc-meta-dot">·</span>
<span>{{ formatDate(doc.createdAt) }}</span>
</div>
</div>
</button>
</div>
</section>
<p v-else-if="documentStore.documents.length > 0" class="rp-empty-hint">
{{ t('reasoning.noAnalyzedDocs') }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useAnalysisStore } from '../../analysis/store'
import { useDocumentStore } from '../../document/store'
import { useI18n } from '../../../shared/i18n'
const emit = defineEmits<{
select: [docId: string]
uploaded: [docId: string]
}>()
const documentStore = useDocumentStore()
const analysisStore = useAnalysisStore()
const { t } = useI18n()
const fileInput = ref<HTMLInputElement | null>(null)
const dragging = ref(false)
const uploading = ref(false)
const uploadError = ref<string | null>(null)
// Docs that have at least one analysis with document_json the graph can
// be primed for them without a fresh Docling run. Others need analysis
// first (handled by the upload path or the workspace's silent analyze).
const docsWithAnalysis = computed(() => {
const analyzedDocIds = new Set(
analysisStore.analyses
.filter((a) => a.hasDocumentJson && a.status === 'COMPLETED')
.map((a) => a.documentId),
)
return documentStore.documents
.filter((d) => analyzedDocIds.has(d.id))
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))
})
onMounted(async () => {
// Fetch both lists in parallel pickers without prior state need them.
await Promise.all([
documentStore.documents.length ? Promise.resolve() : documentStore.load(),
analysisStore.analyses.length ? Promise.resolve() : analysisStore.load(),
])
})
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString()
} catch {
return iso
}
}
function openFilePicker(): void {
fileInput.value?.click()
}
function isPdf(file: File): boolean {
return file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')
}
async function handleFile(file: File): Promise<void> {
uploadError.value = null
if (!isPdf(file)) {
uploadError.value = t('upload.invalidFormat')
return
}
uploading.value = true
try {
const doc = await documentStore.upload(file)
if (doc) emit('uploaded', doc.id)
} catch (e) {
uploadError.value = (e as Error).message || t('upload.uploading')
} finally {
uploading.value = false
}
}
async function onFileSelect(e: Event): Promise<void> {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (file) await handleFile(file)
target.value = ''
}
async function onDrop(e: DragEvent): Promise<void> {
dragging.value = false
const file = e.dataTransfer?.files?.[0]
if (file) await handleFile(file)
}
</script>
<style scoped>
.rp-picker {
display: flex;
flex-direction: column;
gap: 32px;
padding: 48px max(24px, 6vw);
max-width: 960px;
margin: 0 auto;
width: 100%;
}
.rp-picker-header {
text-align: center;
}
.rp-picker-title {
margin: 0 0 8px;
font-size: 28px;
font-weight: 700;
color: var(--text);
}
.rp-picker-subtitle {
margin: 0;
font-size: 14px;
color: var(--text-muted);
}
.rp-picker-upload {
display: flex;
flex-direction: column;
gap: 8px;
}
.rp-drop {
border: 2px dashed var(--border-light);
border-radius: var(--radius);
padding: 40px 16px;
text-align: center;
cursor: pointer;
transition: all var(--transition);
}
.rp-drop:hover,
.rp-drop.dragging {
border-color: var(--accent);
background: var(--accent-muted);
}
.rp-drop.uploading {
pointer-events: none;
opacity: 0.7;
}
.rp-drop-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.rp-upload-icon {
width: 40px;
height: 40px;
color: var(--text-muted);
}
.rp-drop-title {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
}
.rp-drop-sub {
font-size: 12px;
color: var(--text-muted);
}
.rp-upload-error {
margin: 0;
padding: 8px 12px;
font-size: 12px;
color: var(--error, #dc2626);
background: rgba(220, 38, 38, 0.08);
border-radius: var(--radius-sm);
}
.rp-list-title {
margin: 0 0 16px;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
font-weight: 600;
}
.rp-doc-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.rp-doc-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
text-align: left;
font-family: inherit;
color: inherit;
transition: all var(--transition);
}
.rp-doc-card:hover {
border-color: var(--accent);
background: var(--accent-muted);
transform: translateY(-1px);
}
.rp-doc-card-icon {
flex: 0 0 auto;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--border-light);
border-radius: var(--radius-sm);
color: var(--text-muted);
}
.rp-doc-card-icon svg {
width: 20px;
height: 20px;
}
.rp-doc-card-body {
flex: 1 1 auto;
min-width: 0;
}
.rp-doc-name {
font-size: 13px;
font-weight: 500;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rp-doc-meta {
margin-top: 2px;
font-size: 11px;
color: var(--text-muted);
font-family: 'IBM Plex Mono', monospace;
}
.rp-doc-meta-dot {
margin: 0 4px;
}
.rp-empty-hint {
font-size: 12px;
color: var(--text-muted);
font-style: italic;
text-align: center;
}
.spinner {
width: 28px;
height: 28px;
border: 2px solid var(--border-light);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -0,0 +1,352 @@
<template>
<aside
v-if="store.hasTrace || store.importDialogOpen"
class="reasoning-panel"
data-e2e="reasoning-panel"
>
<header class="rp-header">
<h3>{{ t('reasoning.panelTitle') }}</h3>
<div class="rp-header-actions">
<button
class="rp-btn-ghost rp-btn-toggle"
:class="{ active: store.focusMode }"
:aria-pressed="store.focusMode"
data-e2e="reasoning-focus-toggle"
:title="t('reasoning.focusHint')"
@click="store.toggleFocusMode()"
>
<span class="rp-dot" />
{{ t('reasoning.focus') }}
</button>
<button class="rp-btn-ghost" @click="store.openImportDialog()">
{{ t('reasoning.reimport') }}
</button>
<button class="rp-btn-ghost" @click="onClear">{{ t('reasoning.clear') }}</button>
</div>
</header>
<section v-if="envelope" class="rp-meta">
<div v-if="envelope.query" class="rp-query">
<span class="rp-meta-label">{{ t('reasoning.query') }}</span>
<span class="rp-meta-value">{{ envelope.query }}</span>
</div>
<div class="rp-meta-row">
<span v-if="envelope.filename" class="rp-meta-chip">{{ envelope.filename }}</span>
<span v-if="envelope.model?.ollama_name" class="rp-meta-chip">
{{ envelope.model.ollama_name }}
</span>
</div>
</section>
<section v-if="result" class="rp-answer">
<div class="rp-answer-header">
<span class="rp-converged" :class="{ yes: result.converged, no: !result.converged }">
{{ result.converged ? t('reasoning.converged') : t('reasoning.notConverged') }}
</span>
<span class="rp-stats">
{{ store.presentCount }} / {{ store.iterations.length }} {{ t('reasoning.resolved') }}
</span>
</div>
<p class="rp-answer-text">{{ result.answer }}</p>
</section>
<section v-if="store.missingCount > 0" class="rp-warn" data-e2e="reasoning-missing-warn">
{{ missingWarning }}
</section>
<section class="rp-iterations">
<h4 class="rp-section-title">{{ t('reasoning.iterationsTitle') }}</h4>
<div v-if="store.iterations.length === 0" class="rp-empty">
{{ t('reasoning.noIterations') }}
</div>
<div v-else class="rp-iteration-list">
<IterationCard
v-for="it in store.iterations"
:key="it.iteration"
:iteration="it"
:active="store.activeIteration === it.iteration"
@focus="onFocus"
/>
</div>
</section>
</aside>
<ImportTraceDialog />
</template>
<script setup lang="ts">
import type { Core } from 'cytoscape'
import { computed, watch } from 'vue'
import { useI18n } from '../../../shared/i18n'
import {
applyReasoningOverlay,
buildDegradedOverlay,
clearReasoningOverlay,
focusIteration,
nodeIdForSectionRef,
} from '../graphReasoningOverlay'
import { useReasoningStore } from '../store'
import IterationCard from './IterationCard.vue'
import ImportTraceDialog from './ImportTraceDialog.vue'
const props = defineProps<{
/**
* The live Cytoscape instance from the GraphView. May be `null` while the
* graph is loading or if Maintain hasn't been run for this document.
* Passed down from StudioPage via `graphViewRef.cy`.
*/
cy: Core | null
}>()
const store = useReasoningStore()
const { t } = useI18n()
const result = computed(() => store.rawResult)
const envelope = computed(() => store.envelope)
const missingWarning = computed(() => {
// Full miss + no cy the graph simply isn't loaded. Different message
// than "N sections are actually missing from the graph".
if (!props.cy && store.missingCount > 0 && store.presentCount === 0) {
return t('reasoning.graphNotLoadedWarn')
}
return t('reasoning.missingWarn').replace('{n}', String(store.missingCount))
})
function reapplyOverlay(): void {
if (!store.rawResult) {
if (props.cy) clearReasoningOverlay(props.cy)
store.setOverlay(null)
return
}
// When the Cytoscape instance is available (graph loaded for this doc) we
// run the full overlay: mark visited nodes, draw REASONING_NEXT arrows.
// Otherwise (404 on the graph endpoint, or Maintain not run yet) we still
// build the iteration list in "degraded" mode so the user can read the
// reasoning they just won't see nodes highlighted.
const out = props.cy
? applyReasoningOverlay(props.cy, store.rawResult, { focusMode: store.focusMode })
: buildDegradedOverlay(store.rawResult)
store.setOverlay(out)
}
// Reapply whenever cy, rawResult, or focusMode changes. This handles:
// - User imports trace after graph loaded (rawResult changes).
// - User navigates to a different doc which swaps cy (cy changes).
// - Graph loads AFTER the trace was already imported (cy null non-null).
// - User toggles focus mode (focusMode changes) dim in, dim out.
// - User clears the trace (rawResult null clearReasoningOverlay).
watch(
() => [props.cy, store.rawResult, store.focusMode] as const,
() => reapplyOverlay(),
{ immediate: true },
)
function onFocus(iteration: number): void {
store.setActiveIteration(iteration)
if (!props.cy) return
const hit = store.iterations.find((i) => i.iteration === iteration)
if (!hit || !hit.present) return
focusIteration(props.cy, nodeIdForSectionRef(hit.sectionRef))
}
function onClear(): void {
if (props.cy) clearReasoningOverlay(props.cy)
store.reset()
}
</script>
<style scoped>
.reasoning-panel {
display: flex;
flex-direction: column;
gap: 12px;
width: 340px;
flex: 0 0 340px;
padding: 16px;
border-left: 1px solid var(--border);
background: var(--bg);
overflow-y: auto;
height: 100%;
}
.rp-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.rp-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.rp-header-actions {
display: flex;
gap: 4px;
}
.rp-btn-ghost {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
padding: 4px 8px;
font-size: 11px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition);
}
.rp-btn-ghost:hover {
background: var(--border-light);
color: var(--text);
}
.rp-btn-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
}
.rp-btn-toggle .rp-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border);
transition: background var(--transition);
}
.rp-btn-toggle.active {
border-color: #ea580c;
color: #ea580c;
background: rgba(234, 88, 12, 0.08);
}
.rp-btn-toggle.active .rp-dot {
background: #ea580c;
}
.rp-meta {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-light);
}
.rp-meta-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
display: block;
margin-bottom: 2px;
}
.rp-meta-value {
font-size: 12px;
color: var(--text);
line-height: 1.4;
}
.rp-meta-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.rp-meta-chip {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
background: var(--border-light);
color: var(--text-secondary);
font-size: 10px;
font-family: 'IBM Plex Mono', monospace;
}
.rp-answer {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
background: var(--accent-muted, rgba(234, 88, 12, 0.04));
border: 1px solid var(--border-light);
border-radius: var(--radius-sm);
}
.rp-answer-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.rp-converged {
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.rp-converged.yes {
background: rgba(22, 163, 74, 0.15);
color: #15803d;
}
.rp-converged.no {
background: rgba(234, 179, 8, 0.15);
color: #a16207;
}
.rp-stats {
font-size: 10px;
color: var(--text-muted);
font-family: 'IBM Plex Mono', monospace;
}
.rp-answer-text {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--text);
white-space: pre-wrap;
}
.rp-warn {
padding: 8px 10px;
background: rgba(234, 179, 8, 0.1);
border: 1px solid rgba(234, 179, 8, 0.3);
border-radius: var(--radius-sm);
color: #a16207;
font-size: 12px;
}
.rp-section-title {
margin: 0 0 8px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
font-weight: 600;
}
.rp-iteration-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.rp-empty {
font-size: 12px;
color: var(--text-muted);
font-style: italic;
}
</style>

View file

@ -0,0 +1,130 @@
<template>
<div class="rw-root" data-e2e="reasoning-workspace">
<header class="rw-topbar">
<button class="rw-back-btn" data-e2e="reasoning-back" @click="emit('back')">
{{ t('reasoning.changeDoc') }}
</button>
<div class="rw-doc-title" :title="docFilename ?? docId">
{{ docFilename ?? docId }}
</div>
<button
class="rw-action-btn"
data-e2e="reasoning-workspace-import"
@click="reasoningStore.openImportDialog()"
>
{{ t('reasoning.importBtn') }}
</button>
</header>
<div class="rw-body">
<GraphView ref="graphViewRef" :doc-id="docId" :fetcher="fetchReasoningGraph" />
<ReasoningPanel :cy="graphCy" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import GraphView from '../../analysis/ui/GraphView.vue'
import { useI18n } from '../../../shared/i18n'
import { fetchReasoningGraph } from '../api'
import { useReasoningStore } from '../store'
import ReasoningPanel from './ReasoningPanel.vue'
const props = defineProps<{
docId: string
docFilename?: string | null
}>()
const emit = defineEmits<{ back: [] }>()
const { t } = useI18n()
const reasoningStore = useReasoningStore()
const graphViewRef = ref<InstanceType<typeof GraphView> | null>(null)
const graphCy = computed(() => graphViewRef.value?.cy ?? null)
// Reset the reasoning store when switching docs a trace imported for one
// document is meaningless on another.
watch(
() => props.docId,
() => reasoningStore.reset(),
)
// Clean up so a later navigation back to the workspace starts fresh.
onBeforeUnmount(() => reasoningStore.reset())
</script>
<style scoped>
.rw-root {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.rw-topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.rw-back-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
padding: 5px 10px;
font-size: 12px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition);
}
.rw-back-btn:hover {
background: var(--border-light);
color: var(--text);
}
.rw-doc-title {
flex: 1 1 auto;
font-size: 13px;
font-weight: 500;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rw-action-btn {
background: var(--accent);
color: white;
border: 0;
padding: 6px 14px;
font-size: 12px;
font-weight: 500;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition);
}
.rw-action-btn:hover {
filter: brightness(0.95);
}
.rw-body {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: row;
overflow: hidden;
}
.rw-body > :deep(.graph-view) {
flex: 1 1 auto;
min-width: 0;
}
</style>

View file

@ -0,0 +1,259 @@
<template>
<div v-if="phase === 'picker'" class="rp-host">
<ReasoningDocPicker @select="onSelect" @uploaded="onUploaded" />
</div>
<div v-else-if="phase === 'preparing'" class="rp-host rp-centered" data-e2e="reasoning-preparing">
<div class="rp-prep-card">
<div class="spinner-large" />
<div class="rp-prep-title">{{ prepTitle }}</div>
<div v-if="prepHint" class="rp-prep-hint">{{ prepHint }}</div>
<button class="rp-ghost" @click="goToPicker">{{ t('reasoning.cancel') }}</button>
</div>
</div>
<div v-else-if="phase === 'error'" class="rp-host rp-centered" data-e2e="reasoning-error">
<div class="rp-error-card">
<div class="rp-error-title">{{ t('reasoning.prepError') }}</div>
<p class="rp-error-msg">{{ errorMsg }}</p>
<div class="rp-error-actions">
<button class="rp-primary" @click="retry">{{ t('reasoning.retry') }}</button>
<button class="rp-ghost" @click="goToPicker">{{ t('reasoning.pickAnother') }}</button>
</div>
</div>
</div>
<div v-else-if="phase === 'ready' && currentDocId" class="rp-host">
<ReasoningWorkspace
:doc-id="currentDocId"
:doc-filename="currentDocFilename"
@back="goToPicker"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAnalysisStore } from '../features/analysis/store'
import { useDocumentStore } from '../features/document/store'
import ReasoningDocPicker from '../features/reasoning/ui/ReasoningDocPicker.vue'
import ReasoningWorkspace from '../features/reasoning/ui/ReasoningWorkspace.vue'
import { useI18n } from '../shared/i18n'
const props = defineProps<{ docId?: string }>()
type Phase = 'picker' | 'preparing' | 'ready' | 'error'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const documentStore = useDocumentStore()
const analysisStore = useAnalysisStore()
const phase = ref<Phase>('picker')
const errorMsg = ref<string>('')
const prepTitle = ref<string>('')
const prepHint = ref<string | null>(null)
// When navigating via `/reasoning/:docId`, the router prop populates here.
// `route.params.docId` is the reactive source of truth.
const currentDocId = computed<string | null>(() => {
const raw = props.docId ?? (route.params.docId as string | undefined)
return raw || null
})
const currentDocFilename = computed<string | null>(() => {
if (!currentDocId.value) return null
const doc = documentStore.documents.find((d) => d.id === currentDocId.value)
return doc?.filename ?? null
})
/**
* Drive the doc through the prepare pipeline: ensure a completed analysis
* exists (run one silently if not). The graph itself is built on demand from
* SQLite by `/api/documents/:id/reasoning-graph`, so no priming step is needed.
*/
async function prepareDoc(docId: string): Promise<void> {
phase.value = 'preparing'
errorMsg.value = ''
try {
if (documentStore.documents.length === 0) await documentStore.load()
if (analysisStore.analyses.length === 0) await analysisStore.load()
const completed = analysisStore.analyses.find(
(a) => a.documentId === docId && a.status === 'COMPLETED' && a.hasDocumentJson,
)
if (!completed) {
// Silent analysis with defaults the tunnel doesn't expose pipeline
// options (see design §(b) silencieux).
prepTitle.value = t('reasoning.analyzing')
prepHint.value = t('reasoning.analyzingHint')
await analysisStore.run(docId)
await waitForAnalysisIdle()
const again = analysisStore.analyses.find(
(a) => a.documentId === docId && a.status === 'COMPLETED' && a.hasDocumentJson,
)
if (!again) {
throw new Error(analysisStore.error || t('reasoning.prepErrAnalysis'))
}
}
phase.value = 'ready'
} catch (e) {
errorMsg.value = (e as Error).message || t('reasoning.prepErrUnknown')
phase.value = 'error'
}
}
function waitForAnalysisIdle(timeoutMs = 10 * 60 * 1000): Promise<void> {
return new Promise((resolve, reject) => {
if (!analysisStore.running) return resolve()
const started = Date.now()
const id = window.setInterval(() => {
if (!analysisStore.running) {
window.clearInterval(id)
resolve()
} else if (Date.now() - started > timeoutMs) {
window.clearInterval(id)
reject(new Error(t('reasoning.prepErrTimeout')))
}
}, 500)
})
}
function onSelect(docId: string): void {
router.push({ name: 'reasoning-doc', params: { docId } })
}
function onUploaded(docId: string): void {
router.push({ name: 'reasoning-doc', params: { docId } })
}
function goToPicker(): void {
router.push({ name: 'reasoning' })
}
async function retry(): Promise<void> {
if (currentDocId.value) await prepareDoc(currentDocId.value)
}
// React to route param changes (pushes, back/forward).
watch(
currentDocId,
(id) => {
if (!id) {
phase.value = 'picker'
return
}
void prepareDoc(id)
},
{ immediate: false },
)
onMounted(() => {
if (currentDocId.value) void prepareDoc(currentDocId.value)
else phase.value = 'picker'
})
</script>
<style scoped>
.rp-host {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.rp-centered {
align-items: center;
justify-content: center;
padding: 48px 24px;
}
.rp-prep-card,
.rp-error-card {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
padding: 32px 40px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
text-align: center;
max-width: 480px;
}
.rp-prep-title,
.rp-error-title {
font-size: 15px;
font-weight: 600;
color: var(--text);
}
.rp-prep-hint,
.rp-error-msg {
margin: 0;
font-size: 12px;
color: var(--text-muted);
line-height: 1.5;
}
.rp-error-msg {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
padding: 8px 12px;
background: rgba(220, 38, 38, 0.08);
color: var(--error, #dc2626);
border-radius: var(--radius-sm);
width: 100%;
text-align: left;
}
.rp-error-actions {
display: flex;
gap: 8px;
margin-top: 4px;
}
.rp-primary {
background: var(--accent);
color: white;
border: 0;
padding: 6px 14px;
font-size: 12px;
font-weight: 500;
border-radius: var(--radius-sm);
cursor: pointer;
}
.rp-ghost {
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
padding: 6px 14px;
font-size: 12px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.spinner-large {
width: 36px;
height: 36px;
border: 3px solid var(--border-light);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -11,6 +11,7 @@ const messages: Messages = {
'nav.studio': 'Studio', 'nav.studio': 'Studio',
'nav.documents': 'Documents', 'nav.documents': 'Documents',
'nav.history': 'Historique', 'nav.history': 'Historique',
'nav.reasoning': 'Raisonnement',
'nav.settings': 'Paramètres', 'nav.settings': 'Paramètres',
'nav.collapse': 'Réduire la barre latérale', 'nav.collapse': 'Réduire la barre latérale',
'nav.expand': 'Développer la barre latérale', 'nav.expand': 'Développer la barre latérale',
@ -84,6 +85,12 @@ const messages: Messages = {
'results.graph': 'Graphe', 'results.graph': 'Graphe',
'results.graphLoading': 'Chargement du graphe…', 'results.graphLoading': 'Chargement du graphe…',
'results.graphEmpty': 'Pas encore de graphe pour ce document (activez Neo4j).', 'results.graphEmpty': 'Pas encore de graphe pour ce document (activez Neo4j).',
// GraphView — node details panel & interactions
'graph.nodeDetails': 'Détails du nœud',
'graph.close': 'Fermer',
'graph.page': 'Page',
'graph.text': 'Texte',
'graph.provenances': 'Provenances ({n})',
'results.retry': 'Réessayer', 'results.retry': 'Réessayer',
'results.pageOf': 'Page {current} sur {total}', 'results.pageOf': 'Page {current} sur {total}',
'results.noElements': 'Aucun élément détecté sur cette page', 'results.noElements': 'Aucun élément détecté sur cette page',
@ -115,6 +122,63 @@ const messages: Messages = {
'studio.prepare': 'Préparer', 'studio.prepare': 'Préparer',
'studio.ingest': 'Ingérer', 'studio.ingest': 'Ingérer',
'studio.maintain': 'Maintenir', 'studio.maintain': 'Maintenir',
// Reasoning trace (R&D v1 — overlays a docling-agent RAGResult on the graph)
'reasoning.importBtn': 'Importer une trace de raisonnement',
'reasoning.importTitle': 'Importer une trace de raisonnement',
'reasoning.importHint':
'Dépose un JSON RAGResult produit par docling-agent (ou par le script R&D experiments/reasoning-trace).',
'reasoning.drop': 'Glisse un fichier .json ici',
'reasoning.dropSub': 'ou clique pour le choisir',
'reasoning.parsing': 'Analyse du fichier...',
'reasoning.pasteToggle': 'Coller le JSON à la place',
'reasoning.pastePlaceholder': "Colle ici le contenu JSON d'un RAGResult...",
'reasoning.pasteSubmit': 'Charger',
'reasoning.close': 'Fermer',
'reasoning.errJson': 'JSON invalide : {msg}',
'reasoning.errShape':
"Le fichier n'a pas la forme d'un RAGResult (answer, converged, iterations).",
'reasoning.panelTitle': 'Trace de raisonnement',
'reasoning.focus': 'Focus',
'reasoning.focusHint':
'Atténuer les éléments non visités pour faire ressortir le chemin de raisonnement.',
'reasoning.reimport': 'Réimporter',
'reasoning.clear': 'Effacer',
'reasoning.query': 'Question',
'reasoning.converged': 'Convergé',
'reasoning.notConverged': 'Itérations max atteintes',
'reasoning.resolved': 'sections résolues',
'reasoning.missingWarn':
'{n} section(s) introuvable(s) dans le graphe. Le document a peut-être été re-analysé — relance « Maintenir » ou régénère la trace.',
'reasoning.graphNotLoadedWarn':
'Le graphe Neo4j de ce document n\u2019est pas chargé — les itérations sont affichées mais ne peuvent pas être positionnées sur la structure. Lance « prime_neo4j » ou re-déclenche une analyse.',
'reasoning.iterationsTitle': 'Itérations',
'reasoning.noIterations': "L'agent n'a visité aucune section (document sans en-têtes ?).",
'reasoning.statusAnswered': 'Répondu',
'reasoning.statusMore': 'Continue',
'reasoning.statusMissing': 'Absent',
'reasoning.charsLabel': '{n} caractères',
// Reasoning page (standalone tunnel)
'reasoning.pageTitle': 'Reasoning Trace',
'reasoning.pageSubtitle':
'Importe un PDF, puis dépose une trace RAGResult produite par docling-agent pour visualiser le chemin de raisonnement sur le graphe du document.',
'reasoning.dropPdf': 'Dépose un PDF',
'reasoning.dropPdfHint': 'ou clique pour en choisir un',
'reasoning.uploading': 'Import du document...',
'reasoning.existingDocs': 'Documents déjà analysés',
'reasoning.noAnalyzedDocs':
'Aucun des documents existants n\u2019a encore été analysé — lance-en un depuis Studio, ou dépose un nouveau PDF ci-dessus.',
'reasoning.pagesCount': '{n} pages',
'reasoning.changeDoc': 'Changer de document',
'reasoning.analyzing': 'Analyse du document...',
'reasoning.analyzingHint':
'Docling analyse le PDF avec la configuration par défaut. Cela peut prendre 1 à 3 minutes selon la taille.',
'reasoning.cancel': 'Annuler',
'reasoning.retry': 'Réessayer',
'reasoning.pickAnother': 'Choisir un autre document',
'reasoning.prepError': 'Préparation impossible',
'reasoning.prepErrAnalysis': "L'analyse Docling a échoué ou n'a pas produit de document_json.",
'reasoning.prepErrTimeout': "L'analyse prend trop de temps — réessaye plus tard.",
'reasoning.prepErrUnknown': 'Erreur inconnue.',
'chunking.settings': 'Chunking', 'chunking.settings': 'Chunking',
'chunking.chunkerType': 'Type de chunker', 'chunking.chunkerType': 'Type de chunker',
'chunking.maxTokens': 'Tokens max', 'chunking.maxTokens': 'Tokens max',
@ -194,6 +258,7 @@ const messages: Messages = {
'nav.studio': 'Studio', 'nav.studio': 'Studio',
'nav.documents': 'Documents', 'nav.documents': 'Documents',
'nav.history': 'History', 'nav.history': 'History',
'nav.reasoning': 'Reasoning',
'nav.settings': 'Settings', 'nav.settings': 'Settings',
'nav.collapse': 'Collapse sidebar', 'nav.collapse': 'Collapse sidebar',
'nav.expand': 'Expand sidebar', 'nav.expand': 'Expand sidebar',
@ -261,6 +326,12 @@ const messages: Messages = {
'results.graph': 'Graph', 'results.graph': 'Graph',
'results.graphLoading': 'Loading graph…', 'results.graphLoading': 'Loading graph…',
'results.graphEmpty': 'No graph yet for this document (enable Neo4j).', 'results.graphEmpty': 'No graph yet for this document (enable Neo4j).',
// GraphView — node details panel & interactions
'graph.nodeDetails': 'Node details',
'graph.close': 'Close',
'graph.page': 'Page',
'graph.text': 'Text',
'graph.provenances': 'Provenances ({n})',
'results.retry': 'Retry', 'results.retry': 'Retry',
'results.pageOf': 'Page {current} of {total}', 'results.pageOf': 'Page {current} of {total}',
'results.noElements': 'No elements detected on this page', 'results.noElements': 'No elements detected on this page',
@ -289,6 +360,61 @@ const messages: Messages = {
'studio.prepare': 'Prepare', 'studio.prepare': 'Prepare',
'studio.ingest': 'Ingest', 'studio.ingest': 'Ingest',
'studio.maintain': 'Maintain', 'studio.maintain': 'Maintain',
// Reasoning trace (R&D v1 — overlays a docling-agent RAGResult on the graph)
'reasoning.importBtn': 'Import reasoning trace',
'reasoning.importTitle': 'Import reasoning trace',
'reasoning.importHint':
'Drop a RAGResult JSON produced by docling-agent (or by the experiments/reasoning-trace R&D script).',
'reasoning.drop': 'Drop a .json file here',
'reasoning.dropSub': 'or click to pick one',
'reasoning.parsing': 'Parsing file...',
'reasoning.pasteToggle': 'Paste JSON instead',
'reasoning.pastePlaceholder': 'Paste a RAGResult JSON payload here...',
'reasoning.pasteSubmit': 'Load',
'reasoning.close': 'Close',
'reasoning.errJson': 'Invalid JSON: {msg}',
'reasoning.errShape': "File doesn't look like a RAGResult (answer, converged, iterations).",
'reasoning.panelTitle': 'Reasoning trace',
'reasoning.focus': 'Focus',
'reasoning.focusHint': 'Dim non-visited elements to make the reasoning path stand out.',
'reasoning.reimport': 'Re-import',
'reasoning.clear': 'Clear',
'reasoning.query': 'Question',
'reasoning.converged': 'Converged',
'reasoning.notConverged': 'Max iterations',
'reasoning.resolved': 'sections resolved',
'reasoning.missingWarn':
'{n} section(s) missing from the graph. The document may have been re-analyzed — re-run Maintain or regenerate the trace.',
'reasoning.graphNotLoadedWarn':
"This document's Neo4j graph isn't loaded — iterations are shown but can't be positioned on the structure. Run prime_neo4j or trigger a fresh analysis.",
'reasoning.iterationsTitle': 'Iterations',
'reasoning.noIterations': 'Agent visited no section (document without headers?).',
'reasoning.statusAnswered': 'Answered',
'reasoning.statusMore': 'More needed',
'reasoning.statusMissing': 'Missing',
'reasoning.charsLabel': '{n} chars',
// Reasoning page (standalone tunnel)
'reasoning.pageTitle': 'Reasoning Trace',
'reasoning.pageSubtitle':
"Drop a PDF, then import a RAGResult trace from docling-agent to visualize the reasoning path on the document's graph.",
'reasoning.dropPdf': 'Drop a PDF',
'reasoning.dropPdfHint': 'or click to pick one',
'reasoning.uploading': 'Uploading document...',
'reasoning.existingDocs': 'Previously analyzed documents',
'reasoning.noAnalyzedDocs':
'None of your existing documents have been analyzed yet — run one from Studio, or drop a new PDF above.',
'reasoning.pagesCount': '{n} pages',
'reasoning.changeDoc': 'Change document',
'reasoning.analyzing': 'Analyzing document...',
'reasoning.analyzingHint':
'Docling is parsing the PDF with default settings. May take 13 minutes depending on size.',
'reasoning.cancel': 'Cancel',
'reasoning.retry': 'Retry',
'reasoning.pickAnother': 'Pick another document',
'reasoning.prepError': 'Preparation failed',
'reasoning.prepErrAnalysis': 'Docling analysis failed or produced no document_json.',
'reasoning.prepErrTimeout': 'Analysis is taking too long — try again later.',
'reasoning.prepErrUnknown': 'Unknown error.',
'chunking.settings': 'Chunking', 'chunking.settings': 'Chunking',
'chunking.chunkerType': 'Chunker type', 'chunking.chunkerType': 'Chunker type',
'chunking.maxTokens': 'Max tokens', 'chunking.maxTokens': 'Max tokens',

View file

@ -62,6 +62,23 @@
<span class="nav-label">{{ t('nav.search') }}</span> <span class="nav-label">{{ t('nav.search') }}</span>
</RouterLink> </RouterLink>
<RouterLink
v-if="reasoningEnabled"
to="/reasoning"
class="nav-item"
data-e2e="nav-reasoning"
:class="{
active: route.name === 'reasoning' || route.name === 'reasoning-doc',
}"
>
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
<path
d="M10 2a5 5 0 00-5 5c0 1.72.87 3.24 2.2 4.14.47.32.8.84.8 1.4V14a1 1 0 001 1h2a1 1 0 001-1v-1.46c0-.56.33-1.08.8-1.4A5 5 0 0010 2zM8 17a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z"
/>
</svg>
<span class="nav-label">{{ t('nav.reasoning') }}</span>
</RouterLink>
<RouterLink <RouterLink
to="/history" to="/history"
class="nav-item" class="nav-item"
@ -138,6 +155,7 @@ import { useIngestionStore } from '../../features/ingestion/store'
const featureStore = useFeatureFlagStore() const featureStore = useFeatureFlagStore()
const ingestionStore = useIngestionStore() const ingestionStore = useIngestionStore()
const ingestionEnabled = computed(() => featureStore.isEnabled('ingestion')) const ingestionEnabled = computed(() => featureStore.isEnabled('ingestion'))
const reasoningEnabled = computed(() => featureStore.isEnabled('reasoning'))
const version = computed(() => featureStore.appVersion) const version = computed(() => featureStore.appVersion)
const route = useRoute() const route = useRoute()
const { t } = useI18n() const { t } = useI18n()