Compare commits

...

19 commits

Author SHA1 Message Date
Pier-Jean Malandrino
77fcb32e7f fix(reasoning): re-scroll PDF when re-clicking the active iteration
The watch-based plumbing from iteration click to PDF scroll relied on a
"flip via null" pattern (assign null then the value) to coerce Vue into
re-firing the watcher. Vue 3 collapses synchronous mutations of the same
ref and only delivers the final value, so the trick was a no-op: a second
click on the same iteration left the document view stuck on the previous
page. The bug only showed when the trace had a single iteration — with
several, the user naturally clicks different ones and the value really
changes.

Replace the watch chain with imperative dispatch. ReasoningPanel now just
emits iterationFocus; ReasoningWorkspace handles it by calling the graph
focus and the new StructureViewer.scrollToFocused method directly. Both
side effects fire on every click regardless of state.
2026-04-27 11:48:32 +02:00
Pier-Jean Malandrino
9ec64961fc feat(reasoning): bidirectional PDF ↔ graph focus + DocumentView mode
Propagate Docling `self_ref` through PageElement so bboxes and graph nodes
share a stable identity. Add a Document/Graph mode switch to the reasoning
workspace; selecting a node highlights its bbox (numbered badge, focus ring,
optional dim of non-visited) and clicking a bbox re-centers the graph.
2026-04-23 22:07:58 +02:00
Pier-Jean Malandrino
bef7ec4686 feat(reasoning): live docling-agent runner + UX polish
Backend — live runner
- New `POST /api/documents/:id/rag` endpoint. Loads `document_json` from
  SQLite, reconstructs the DoclingDocument, wraps the model id in
  `ModelIdentifier(ollama_name=...)`, and calls `agent._rag_loop`
  off-thread (blocking sync call). Returns a `RAGResult` in the shape
  the existing v1 import path already consumes, so the frontend overlay
  is fully reused.
- `_rag_loop` is private upstream; we call it because `run()` wraps the
  answer in a synthetic DoclingDocument and drops the iteration trace.
- Settings: `RAG_ENABLED`, `OLLAMA_HOST`, `RAG_MODEL_ID`. Router mounts
  unconditionally; handler 503s when the flag is off or deps aren't
  installed. `rag_available` surfaced in `/api/health`.
- Maps known docling-agent bugs to readable HTTP errors: 502 with
  "the model couldn't produce a parseable answer" when `_rag_loop`
  raises `IndexError` from `find_json_dicts([])[0]` after 3 + 3
  rejection-sampling retries (model-dependent).
- Tests: 11 cases (flag off, query empty, no analysis, happy path,
  model_id wrap, Ollama env, IndexError → 502, other errors → 500,
  deps missing → 503).

Backend — bug fix
- Default `BATCH_PAGE_SIZE` flipped from `10` to `0` to match the
  dataclass default. The old default silently dropped `document_json`
  (see `domain/services.merge_results`) for any doc > 10 pages, which
  broke the reasoning tunnel. Set `BATCH_PAGE_SIZE>0` explicitly on
  memory-constrained deploys if batching is wanted.

Frontend — runner UX
- `features/reasoning/api.ts:runReasoning()` — POST wrapper.
- `RunReasoningDialog.vue` — query textarea + optional model_id
  override. Blocks close while running, 20-40s loading state,
  synthesises a sidecar-shaped envelope so the panel surfaces query +
  model the same way an imported trace would.
- `ReasoningWorkspace.vue` — primary "Run reasoning" button; "Import
  trace" relegated to ghost secondary.
- Store: `runDialogOpen`, `running`, `setRunning`.

Frontend — answer polish
- Answer rendered through `marked` + DOMPurify (models emit markdown
  lists; `pre-wrap` rendered them as plain "1. …" strings).
- Dedicated answer block with orange border, "ANSWER" label, "Copy"
  button (clipboard + "Copied ✓" feedback).
- IterationCard: drop the duplicate `response` block (the main answer
  is authoritative); style reasons equal to `"fallback"` (docling-agent
  `select_from_failure` placeholder) as italic muted "— no structured
  rationale".

Frontend — node details contents
- Clicking a SectionHeader (or any node with compound children) lists
  its contained elements in `NodeDetailsPanel` under a new "Contents"
  block. Children come from the same `parentMap` used for Cytoscape
  compound parenting (explicit PARENT_OF + synthetic section scope),
  inverted once and cached as a computed.
- Click a child row → pan the viewport to it + swap the selection.

Housekeeping
- `cytoscape-navigator` removed from `package-lock.json` (follow-up
  from the minimap removal in the previous commit).
2026-04-21 17:11:54 +02:00
Pier-Jean Malandrino
1f02274ac4 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)
2026-04-21 11:30:47 +02:00
Pier-Jean Malandrino
df786a4ab4
Merge pull request #188 from scub-france/feature/neo4j-integration
feat(neo4j): graph storage, graph API, Maintain UI step
2026-04-20 10:33:36 +02:00
Pier-Jean Malandrino
e4c53f1809 test(chunking): add doc_items field to ChunkResult serialization test
The `doc_items` field was added to `ChunkResult` earlier in the
feature branch (used by ChunkWriter for DERIVED_FROM edges), but the
test fixture was never updated. CI caught it now that the PR is open.

Fixes: tests/test_chunking.py::TestChunkResult::test_serializable
2026-04-20 10:26:05 +02:00
Pier-Jean Malandrino
5bc98ee483 chore(lint): fix ruff violations on Neo4j files
Pre-existing ruff violations surfaced by CI on PR #188:

- TC001: move runtime `Neo4jDriver` imports into `TYPE_CHECKING`
  blocks (queries.py, chunk_writer.py, schema.py, tree_reader.py,
  tree_writer.py)
- SIM117: combine nested `async with` in chunk_writer.write_chunks
  and tree_writer.write_document
- SIM105: replace try/except/pass with `contextlib.suppress` in
  tree_writer._element_props
- F401: remove unused `Neo4jDriver` import in main._init_neo4j
- RUF100: remove unused `# noqa: E402` in tests/neo4j/conftest.py
- I001: sort imports in tests/neo4j/test_chunk_writer.py and
  test_tree_writer.py

Zero behaviour change. `ruff check .` now passes cleanly.
2026-04-20 10:22:33 +02:00
Pier-Jean Malandrino
aa60fbb768 feat(ui): add Maintain step with graph visualization
Move the graph view from a Verify-tab (where it sat post-analysis, off
the main flow) to a dedicated Maintain step after Ingest, so the graph
result is visible at the natural end of the Configure → Verify →
Prepare → Ingest → Maintain pipeline.

- StudioPage: new 'maintain' mode toggle + panel rendering GraphView
- ResultTabs: remove obsolete graph tab
- i18n: add studio.maintain (fr + en)
- GraphView: fix init order — flip loading off and await nextTick before
  renderGraph so the canvas <div> is mounted when cytoscape reads its
  container ref (previous code bailed silently on null ref)
2026-04-20 10:13:18 +02:00
Pier-Jean Malandrino
5a2eaacd4d fix(neo4j): rewrite fetch_graph with CALL subqueries
Previous query chained 6 OPTIONAL MATCH clauses for edges with no
intervening WITH collect(), producing a cartesian product. At 6 pages
(~60 elements, ~300 edges) Neo4j hit 102% CPU and hung > 5min.

Rewritten with one CALL {} subquery per node/edge type: each block
returns a single row with its collected list — no multiplication across
types. 6-page doc now returns in 213ms (was: no return).

Python reshape code (queries.py:137-210) untouched — record keys and
edge map shape preserved.

Refs: https://neo4j.com/developer/kb/using-subqueries-to-control-the-scope-of-aggregations/
2026-04-20 10:13:18 +02:00
Pier-Jean Malandrino
c9359f60e1 feat(neo4j): Day 3 — ChunkWriter, graph API, GraphView, README
ChunkWriter mirrors chunks into Neo4j after OpenSearch indexing, creating
HAS_CHUNK edges and DERIVED_FROM back-references to the source Elements
(via doc_items propagated from the local chunker).

Graph API: GET /api/documents/{id}/graph returns a cytoscape-shaped
payload with nodes + edges for Document / Element / Page / Chunk.
Hard cap at 200 pages returns HTTP 413 per design §8.4.

Frontend: new Graph tab in Studio results, rendered with Cytoscape.js +
dagre layout (lazy-loaded, ~175 KB gz). Legend, node styling per element
label, directional edges styled per edge type.

README gains a Neo4j section with the schema, three demo Cypher
queries, and env vars. Backend tests skip cleanly when the neo4j python
package is not installed locally.

Refs #186
2026-04-20 10:13:18 +02:00
Pier-Jean Malandrino
ee92e3c580 feat(neo4j): Day 2 — TreeWriter, TreeReader, pipeline wiring
Serialize a DoclingDocument to a Neo4j graph: Document + Page + Element
nodes with dynamic specific labels (SectionHeader, Paragraph, Table,
Figure, …), plus HAS_ROOT / PARENT_OF / NEXT / ON_PAGE edges. Replace-on-
write for idempotent re-ingestion.

The reader returns the verbatim document_json stored on the Document
node — reconstruction from graph nodes is deferred to v0.6.

Wired into AnalysisService._finalize_analysis: runs after conversion,
degrades gracefully by default, fails fast when neo4j_required is set.

Refs #186
2026-04-20 10:13:18 +02:00
Pier-Jean Malandrino
3474390688 docs(neo4j): ADR-001 graph viz lib + 200-page endpoint cap
Document the Cytoscape.js vs vis-network / Neovis / D3 / Reagraph analysis
for the graph view, and make the 200-page cap on /api/documents/{id}/graph
explicit (HTTP 413 + truncated flag beyond the cap).

Refs #186
2026-04-20 10:13:18 +02:00
Pier-Jean Malandrino
dfbca40730 feat(neo4j): Day 1 — compose service, driver, schema bootstrap
Add Neo4j as an optional graph-native storage layer (ingestion profile).
Introduces infra/neo4j with a singleton async driver wrapper and an
idempotent bootstrap of constraints + indexes, wired into the FastAPI
lifespan. Integration tests skip when no live Neo4j is reachable.

Refs #186
2026-04-20 10:13:18 +02:00
Pier-Jean Malandrino
358e575f0f
Merge pull request #185 from scub-france/feature/remote-chunking
feat: enable chunking in remote (Docling Serve) mode
2026-04-19 20:20:14 +02:00
Pier-Jean Malandrino
3bdc4cec50 feat: enable chunking in remote (Docling Serve) mode
Hybrid approach: reuse LocalChunker to chunk the DoclingDocument JSON
returned by Serve, so chunking works identically in both local and
remote modes without calling Serve's chunk endpoint.

Backend:
- _build_chunker() always returns LocalChunker (remove engine guard)
- Use docling-core[chunking] extra for required dependencies
- Skip client-side batching in remote mode (Serve manages its own
  resources, and batching discards document_json needed for chunking)
- Fix Serve form fields: remove generate_page_images (not a Serve
  field), use repeated form keys for to_formats and page_range
- Log Serve error response body on 4xx/5xx for diagnosis
- Fix FastAPI 204 DELETE routes missing response_model=None

Frontend:
- Update chunking feature flag to enable Prepare UI in remote mode

Closes #51
2026-04-16 15:11:14 +02:00
Pier-Jean Malandrino
0bffe6e7d4
Merge pull request #183 from scub-france/feat/centralize-magic-numbers-arch-tests
refactor: centralize magic numbers + arch tests (#168, #177)
2026-04-16 10:51:48 +02:00
Pier-Jean Malandrino
987d43735d fix(ci): install pytestarch in backend tests job (#177)
CI was missing pytestarch dependency, causing test_architecture.py to fail
at collection time. Switch to requirements-test.txt which includes all
test dependencies.
2026-04-16 08:32:07 +00:00
Pier-Jean Malandrino
7a76d2efbd feat: add hexagonal architecture tests with pytestarch (#177)
- Create tests/test_architecture.py with 20 automated rules:
  inter-layer dependency checks (domain, services, api, infra, persistence),
  external dependency constraints (fastapi, sqlalchemy, httpx, opensearchpy),
  and port convention enforcement (Protocol only in domain.ports)
- Add requirements-test.txt with pytestarch dependency
- Fix persistence.database importing infra.settings (read DB_PATH from env directly)
2026-04-16 08:32:07 +00:00
Pier-Jean Malandrino
f2436290c5 refactor: centralize magic numbers for page dimensions, limits, and timeout (#168)
- Move DEFAULT_PAGE_WIDTH/HEIGHT to domain/value_objects.py and import in both converters
- Add opensearch_default_limit to Settings (configurable via OPENSEARCH_DEFAULT_LIMIT env var)
- Pass settings.conversion_timeout to ServeConverter, removing independent _DEFAULT_TIMEOUT
- Update OpenSearchStore to accept default_limit from Settings via constructor
2026-04-16 08:32:07 +00:00
90 changed files with 10368 additions and 84 deletions

View file

@ -48,3 +48,8 @@
# Embedding vector dimension (default: 384 for Granite Embedding 30M / all-MiniLM-L6-v2)
# EMBEDDING_DIMENSION=384
# Neo4j — graph-native document structure (used by docker-compose.dev.yml)
# NEO4J_URI=bolt://neo4j:7687
# NEO4J_USER=neo4j
# NEO4J_PASSWORD=changeme

View file

@ -29,7 +29,7 @@ jobs:
with:
python-version: "3.12"
cache: pip
cache-dependency-path: document-parser/requirements.txt
cache-dependency-path: document-parser/requirements-test.txt
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends poppler-utils
@ -37,8 +37,8 @@ jobs:
- name: Install Python dependencies
run: |
pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-asyncio httpx ruff
pip install -r requirements-test.txt
pip install httpx ruff
- name: Lint
run: ruff check .

View file

@ -33,6 +33,7 @@ Upload a PDF, configure the extraction pipeline, and visualize the results — t
- **Per-page results** — right panel syncs with the current PDF page
- **Chunking** — split extracted content into semantic chunks (hierarchical, hybrid, or page-based) with configurable token limits and inline editing
- **Ingestion pipeline** — Docling → chunking → embedding → OpenSearch vector indexing (one-click from Studio)
- **Graph storage (Neo4j)** — full DoclingDocument tree (sections, paragraphs, tables, pages, chunks) mirrored as a graph with `PARENT_OF`, `NEXT`, `ON_PAGE`, `HAS_CHUNK`, `DERIVED_FROM` relations, with an in-app graph view powered by Cytoscape.js
- **Markdown & HTML export** of extracted content
- **Document management** — upload, list, delete, search, filter by indexing status
- **Analysis history** — re-visit and open past analyses
@ -244,6 +245,69 @@ When ingestion is enabled, the UI shows:
| `EMBEDDING_URL` | — | Embedding service endpoint (empty = ingestion disabled) |
| `EMBEDDING_DIMENSION` | `384` | Vector dimension (must match embedding model) |
## Graph storage with Neo4j (opt-in)
Docling Studio can mirror the full **DoclingDocument tree** into a [Neo4j](https://neo4j.com/) graph: sections, paragraphs, tables, figures, pages, and chunks all become first-class nodes connected by `HAS_ROOT`, `PARENT_OF`, `NEXT`, `ON_PAGE`, `HAS_CHUNK`, and `DERIVED_FROM` edges. This enables queries that are impossible with a flat chunk store — navigating a document's outline, finding all tables under a given section, or tracing a chunk back to its source elements.
Enable Neo4j with the ingestion profile (it ships alongside OpenSearch):
```bash
docker compose --profile ingestion \
-f docker-compose.yml -f docker-compose.ingestion.yml \
up --build
```
The Neo4j Browser is available at <http://localhost:7474> (user `neo4j`, password `changeme` by default).
### Schema at a glance
```mermaid
graph TD
D[Document] -->|HAS_ROOT| SH[SectionHeader]
D -->|HAS_CHUNK| C[Chunk]
SH -->|PARENT_OF| P[Paragraph]
SH -->|PARENT_OF| T[Table]
P -->|NEXT| T
P -->|ON_PAGE| PG[Page]
T -->|ON_PAGE| PG
C -->|DERIVED_FROM| P
C -->|DERIVED_FROM| T
```
### Example Cypher queries
Find all "Methods" sections across documents (impossible in vector-only stores):
```cypher
MATCH (d:Document)-[:HAS_ROOT]->(:Element)-[:PARENT_OF*]->(s:SectionHeader)
WHERE toLower(s.text) CONTAINS 'method'
RETURN d.title, s.text, s.level
```
Get the parent section and sibling elements of a chunk (context for RAG):
```cypher
MATCH (c:Chunk {id: $chunk_id})-[:DERIVED_FROM]->(e:Element)
MATCH (e)<-[:PARENT_OF]-(parent:Element)-[:PARENT_OF]->(sibling:Element)
RETURN parent, collect(sibling) AS siblings
```
List all tables from documents ingested from an `invoices/` path:
```cypher
MATCH (d:Document)-[:HAS_ROOT]->(:Element)-[:PARENT_OF*]->(t:Table)
WHERE d.source_uri CONTAINS 'invoices/'
RETURN d.title, t.caption, t.cells_json
```
| Variable | Default | Description |
|----------|---------|-------------|
| `NEO4J_URI` | — | Neo4j Bolt endpoint (empty = graph storage disabled) |
| `NEO4J_USER` | `neo4j` | Neo4j username |
| `NEO4J_PASSWORD` | `changeme` | Neo4j password |
The in-app **Graph** tab (under *Results*) renders the per-document graph with [Cytoscape.js](https://js.cytoscape.org/) (see [ADR-001](docs/architecture/adrs/ADR-001-graph-visualization-library.md) for the library choice). Documents with more than **200 pages** return `HTTP 413` from `GET /api/documents/{id}/graph`; pagination ships in v0.6.
## CI / Release
GitHub Actions pipelines (see [`.github/workflows/`](.github/workflows/)):

View file

@ -9,6 +9,26 @@
# =============================================================================
services:
# --- Neo4j (graph-native document structure) ---
neo4j:
image: neo4j:5.15-community
environment:
NEO4J_AUTH: ${NEO4J_USER:-neo4j}/${NEO4J_PASSWORD:-changeme}
NEO4J_server_memory_heap_initial__size: 512m
NEO4J_server_memory_heap_max__size: 1g
ports:
- "7474:7474"
- "7687:7687"
volumes:
- neo4j_data:/data
- neo4j_logs:/logs
healthcheck:
test: ["CMD-SHELL", "cypher-shell -u $${NEO4J_USER:-neo4j} -p $${NEO4J_PASSWORD:-changeme} 'RETURN 1' || exit 1"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
# --- OpenSearch (single-node, security disabled for local dev) ---
opensearch:
image: opensearchproject/opensearch:2
@ -77,12 +97,17 @@ services:
BATCH_PAGE_SIZE: ${BATCH_PAGE_SIZE:-10}
OPENSEARCH_URL: http://opensearch:9200
EMBEDDING_URL: http://embedding:8001
NEO4J_URI: bolt://neo4j:7687
NEO4J_USER: ${NEO4J_USER:-neo4j}
NEO4J_PASSWORD: ${NEO4J_PASSWORD:-changeme}
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
depends_on:
opensearch:
condition: service_healthy
embedding:
condition: service_healthy
neo4j:
condition: service_healthy
deploy:
resources:
limits:
@ -105,6 +130,8 @@ services:
volumes:
opensearch_data:
neo4j_data:
neo4j_logs:
uploads_data:
db_data:
frontend_node_modules:

View file

@ -1,4 +1,22 @@
services:
# --- Neo4j (graph-native document structure) ---
neo4j:
profiles: ["ingestion"]
image: neo4j:5.15-community
environment:
NEO4J_AUTH: ${NEO4J_USER:-neo4j}/${NEO4J_PASSWORD:-changeme}
NEO4J_server_memory_heap_initial__size: 512m
NEO4J_server_memory_heap_max__size: 1g
volumes:
- neo4j_data:/data
- neo4j_logs:/logs
healthcheck:
test: ["CMD-SHELL", "cypher-shell -u $${NEO4J_USER:-neo4j} -p $${NEO4J_PASSWORD:-changeme} 'RETURN 1' || exit 1"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
# --- OpenSearch (single-node, security disabled) ---
opensearch:
profiles: ["ingestion"]
@ -53,6 +71,9 @@ services:
BATCH_PAGE_SIZE: ${BATCH_PAGE_SIZE:-10}
OPENSEARCH_URL: ${OPENSEARCH_URL:-}
EMBEDDING_URL: ${EMBEDDING_URL:-}
NEO4J_URI: ${NEO4J_URI:-}
NEO4J_USER: ${NEO4J_USER:-neo4j}
NEO4J_PASSWORD: ${NEO4J_PASSWORD:-changeme}
deploy:
resources:
limits:
@ -69,5 +90,7 @@ services:
volumes:
opensearch_data:
neo4j_data:
neo4j_logs:
uploads_data:
db_data:

View file

@ -0,0 +1,142 @@
# ADR-001: Graph visualization library for the Neo4j graph view
**Date**: 2026-04-17
**Status**: Proposed
**Deciders**: Pier-Jean Malandrino
## Context
v0.5.0 introduces Neo4j as a graph-native storage layer for parsed documents
(see [docs/design/neo4j-integration.md](../../design/neo4j-integration.md)
and [#186](https://github.com/scub-france/Docling-Studio/issues/186)). We need
an in-app visualization of that graph: the `DoclingDocument` tree as rendered
in Neo4j, with nodes colored by element type (`SectionHeader`, `Paragraph`,
`Table`, `Figure`, `ListItem`, `Formula`) and edges (`PARENT_OF`, `NEXT`,
`ON_PAGE`, `HAS_CHUNK`, `DERIVED_FROM`).
The view lives in the existing Vue 3 debug panel. It is the **primary demo
artifact** for the Hackernoon hackathon (Neo4j partner), so polish matters as
much as correctness.
### Constraints
- Vue 3 + Vite frontend, no framework change
- Must render the full tree of a 200-page document (worst case ≈ a few
thousand nodes; see graph endpoint cap in the design doc §8.4)
- Needs a **clean hierarchical layout** — documents are trees, not arbitrary
graphs; a good tree layout is the single biggest UX lever
- Needs per-node styling (shape + color by label), click, hover, zoom, pan
- Must be installable without Java/Python-side changes
- License compatible with the repo (MIT-ish preferred)
### Non-goals for v0.5.0
- 3D rendering
- Force-directed simulation as the primary layout (we have a tree)
- Editing nodes in place (read-only view)
- Rendering millions of nodes
## Decision
Use **Cytoscape.js** via a thin Vue wrapper (`vue-cytoscape` or a bespoke
`GraphView.vue` that imports `cytoscape` directly and uses the
`dagre`/`breadthfirst` layouts).
## Consequences
### Positive
- Battle-tested library (13k+ GitHub stars, maintained since 2013, used by
Neo4j's own "Bloom"-style visualizations in the community)
- First-class support for hierarchical layouts via `cytoscape-dagre` (hub-and-
spoke / tree) and built-in `breadthfirst` — both map naturally to our
`PARENT_OF` structure
- CSS-like selector syntax for styling (`node[label = "Table"] { ... }`),
which is pleasant to evolve as we add node types
- Permissive licensing (MIT)
- Headless mode available, so it can be tested outside a DOM (Jest + jsdom
works cleanly)
- Active ecosystem: `cytoscape-cola`, `cytoscape-klay`, `cytoscape-popper` for
tooltips, all maintained
- Bundle size is reasonable for a demo: ~300 KB min+gz for core + dagre, well
below our current frontend budget
### Negative
- Styling DSL is powerful but has its own syntax to learn; not plain CSS
- Large graphs (>10k nodes) benefit from canvas+WebGL libraries
(sigma.js, reagraph) — we are explicitly not in that regime for v0.5, but
we would need to swap if we later visualize the cross-document graph
- No Vue 3 component library that is both maintained and popular — we wrap it
ourselves in `GraphView.vue` (the wrapper is ~50 LOC, so this is minor)
### Neutral
- Not "Neo4j-branded": we do not use Neovis.js, which is a thin Cytoscape
wrapper around the Bolt protocol. Our graph API already returns shaped
JSON, so the Neovis convenience is not worth the lock-in
- We take on one runtime dependency (`cytoscape` + `cytoscape-dagre`)
## Alternatives Considered
### Alternative 1: vis-network (vis.js)
- **Pros**: Very easy to get started, built-in physics, shipped by Neo4j
Browser historically
- **Cons**: Maintenance has been rocky (original vis.js split into several
forks; `vis-network` is the maintained branch but releases are sparse);
hierarchical layout is OK but less configurable than dagre; styling API is
less expressive; TypeScript types lag behind the JS API
- **Why rejected**: Hierarchical layout quality is the single most important
criterion for a document tree, and vis-network is clearly a notch below
Cytoscape + dagre here. Maintenance trajectory is also a concern for a
release we want to keep shipping on
### Alternative 2: Neovis.js
- **Pros**: Built by Neo4j Labs, connects directly to a Bolt endpoint, nice
out-of-the-box "Neo4j look"
- **Cons**: Wraps Cytoscape anyway, so everything it can do we can do with
Cytoscape directly; expects the browser to talk Bolt, which forces us to
expose Neo4j creds in the frontend OR to proxy Bolt through the backend
(both worse than our current "backend returns JSON" design); limited
customization compared to raw Cytoscape
- **Why rejected**: The auth story is a non-starter for a hackathon demo we
want to show publicly, and we lose nothing vs. Cytoscape by going one
layer lower
### Alternative 3: D3 (d3-hierarchy + d3-force)
- **Pros**: Maximum flexibility; beautiful, publication-grade output; full
SVG control
- **Cons**: Much more code for the same result — layout, zoom, pan, hover,
selection all hand-rolled; steeper learning curve for future contributors
to the project; no built-in graph data model
- **Why rejected**: We're building a product feature, not a data-viz
artefact. The time budget (1 day of Day 3) doesn't fit a D3 build-your-own
### Alternative 4: Reagraph / react-force-graph / sigma.js (WebGL)
- **Pros**: Scales to tens of thousands of nodes at 60 FPS; good for future
cross-document visualization
- **Cons**: Optimized for force-directed layouts, weaker hierarchical
support; Reagraph is React-only (requires a React island inside Vue);
sigma.js's tree layout is immature
- **Why rejected**: Wrong regime for a single-document tree. Worth
reconsidering if/when we visualize the full corpus graph in a later release
### Alternative 5: Mermaid
- **Pros**: Trivial to embed, already used in docs
- **Cons**: Static rendering, no interactivity, not designed for thousands of
nodes, no per-node click/hover
- **Why rejected**: A viewer, not a visualizer. We need interactivity
## References
- [Neo4j integration design doc](../../design/neo4j-integration.md) §8.3
- [Issue #186 — Neo4j integration](https://github.com/scub-france/Docling-Studio/issues/186)
- [Cytoscape.js](https://js.cytoscape.org/)
- [cytoscape-dagre](https://github.com/cytoscape/cytoscape.js-dagre)
- [vis-network](https://visjs.github.io/vis-network/docs/network/)
- [Neovis.js](https://github.com/neo4j-contrib/neovis.js)

View file

@ -0,0 +1,435 @@
# Neo4j integration — Docling-Studio v0.5.0
Design doc for Neo4j integration targeting release 0.5.0.
Target: Hackernoon hackathon demo (Neo4j partner).
---
## 1. Context and goals
### Already in Docling-Studio
- Ingestion pipeline: Docling parser → chunking (HybridChunker) → embedding → OpenSearch (vector index)
- Vue 3 + FastAPI UI
- Debug view to inspect/edit chunks before retrieval
- Docker compose with existing services
### What we add in v0.5.0
- Neo4j as **graph-native storage** of the document structure
- A new ingestion layer that stores the DoclingDocument tree faithfully as a graph
- Minimal UI to visualize the graph (demo value to the judges)
- Compose pipeline with Neo4j
### Why graph-native (hackathon positioning)
> Most document AI tools store parsed content as flat chunks in a vector DB.
> Docling-Studio v0.5 introduces a graph-native storage layer on top of Neo4j,
> preserving the full hierarchical structure of documents as first-class citizens.
> This unlocks hybrid retrieval, agentic navigation, and structural debugging —
> impossible with chunk-only stores.
### Out of scope for v0.5.0 (roadmap mention only)
- EnrichmentWriter (entities / summaries / keywords via docling-agent) — v0.6.0
- Agent reasoning trace viewer — v0.6.0
- RAG hybrid (graph traversal + vector) — v0.7.0
- Document versioning — v0.7.0+
---
## 2. Architectural principles
### Port & adapter, with nuance
**Write side**: one `Writer` port, **composable stages** (not alternative adapters).
Pipelines A and B are additive, not exclusive.
```
CORE (always) Pipeline A (RAG) Pipeline B (agent-ready, v0.6+)
┌─────────────┐ ┌────────────────┐ ┌───────────────────┐
│ TreeWriter │ ─────▶ │ ChunkWriter │ │ EnrichmentWriter │
│ │ │ (existing │ │ (via docling- │
│ │ │ OpenSearch + │ │ agent, v0.6+) │
│ │ │ adds chunks │ │ │
│ │ │ to Neo4j) │ │ │
└─────────────┘ └────────────────┘ └───────────────────┘
```
```python
# docling_studio/ingestion/pipeline.py
class Writer(Protocol):
def write(self, doc: DoclingDocument, ctx: IngestionContext) -> None: ...
# Explicit composition per use case
def build_pipeline(config: PipelineConfig) -> list[Writer]:
writers = [TreeWriter(neo4j_driver)]
if config.rag_enabled:
writers.append(ChunkWriter(neo4j_driver, chunker, embedder, opensearch))
if config.enrichment_enabled: # v0.6.0+
writers.append(EnrichmentWriter(neo4j_driver, docling_agent))
return writers
```
**Read side**: two distinct ports (same Neo4j backend, different queries).
```python
class RAGRetrievalPort(Protocol):
def search(self, query: str, k: int) -> list[Chunk]: ...
def similar(self, chunk_id: str, k: int) -> list[Chunk]: ...
class TreeNavigationPort(Protocol): # v0.6.0+
def get_outline(self, doc_id: str) -> Tree: ...
def read_node(self, ref: str) -> Element: ...
def list_children(self, ref: str) -> list[Element]: ...
def walk(self, ref: str, depth: int) -> SubTree: ...
```
---
## 3. Neo4j schema
### Constraints & indexes (created at boot)
```cypher
// Uniqueness
CREATE CONSTRAINT document_id IF NOT EXISTS
FOR (d:Document) REQUIRE d.id IS UNIQUE;
CREATE CONSTRAINT element_composite IF NOT EXISTS
FOR (e:Element) REQUIRE (e.doc_id, e.self_ref) IS UNIQUE;
CREATE CONSTRAINT page_composite IF NOT EXISTS
FOR (p:Page) REQUIRE (p.doc_id, p.page_no) IS UNIQUE;
CREATE CONSTRAINT chunk_id IF NOT EXISTS
FOR (c:Chunk) REQUIRE c.id IS UNIQUE;
// Full-text index (element text search)
CREATE FULLTEXT INDEX element_text IF NOT EXISTS
FOR (e:Element) ON EACH [e.text];
// Simple indexes for per-doc queries
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);
```
### Data model
```cypher
// Root document
(:Document {
id: string, // UUID or PDF hash
title: string,
source_uri: string, // path or S3
ingested_at: datetime,
docling_version: string,
stages_applied: list<string>, // ["tree", "chunks"] etc.
last_tree_write: datetime,
last_chunk_write: datetime,
tenant_id: string // simple multi-tenancy
})
// All tree elements (shared :Element label + specific label)
(:Element:SectionHeader {doc_id, self_ref, text, level, prov_page, prov_bbox})
(:Element:Paragraph {doc_id, self_ref, text, prov_page, prov_bbox})
(:Element:Table {doc_id, self_ref, caption, cells_json, prov_page, prov_bbox})
(:Element:Figure {doc_id, self_ref, caption, image_uri, prov_page, prov_bbox})
(:Element:ListItem {doc_id, self_ref, text, marker, prov_page, prov_bbox})
(:Element:Formula {doc_id, self_ref, latex, text, prov_page, prov_bbox})
// Page for layout provenance
(:Page {doc_id, page_no, width, height})
// Chunks (Pipeline A)
(:Chunk {
id, doc_id,
text,
chunk_index,
embedding_ref, // id in OpenSearch (no inline duplication)
token_count
})
```
### Relations
```cypher
// Hierarchical structure
(:Document)-[:HAS_ROOT]->(:Element)
(:Element)-[:PARENT_OF {order: int}]->(:Element) // order preserves sequence
(:Element)-[:NEXT]->(:Element) // DFS pre-order reading
// Layout
(:Element)-[:ON_PAGE]->(:Page)
// Pipeline A (chunking)
(:Document)-[:HAS_CHUNK]->(:Chunk)
(:Chunk)-[:DERIVED_FROM]->(:Element) // back-reference; a chunk can span multiple elements
```
### Decisions
| Decision | Choice | Rationale |
|----------|-------|---------------|
| Element composite key | `(doc_id, self_ref)` | self_ref not unique across docs |
| Multi-tenancy | `tenant_id` property on Document | Simple, filterable, migrable to multi-db later |
| Table cells | `cells_json` property | v0.5 KISS. May model `(Table)-[:HAS_CELL]->(Cell)` in v0.6+ |
| Reading order | `[:NEXT]` chain + `{order}` on `PARENT_OF` | Both views useful |
| Versioning | None (replace strategy on re-upload) | v0.5 KISS |
| APOC | Not required | Pure Cypher is sufficient for v0.5 |
### Re-ingestion strategy
```cypher
// Before ingesting, wipe existing
MATCH (d:Document {id: $doc_id})
OPTIONAL MATCH (d)-[:HAS_ROOT|HAS_CHUNK]->()
DETACH DELETE d
// Then re-walk cleanly
```
---
## 4. Implementation plan (3 days)
### Day 1 — Infra + schema
- [ ] Add `neo4j` service to `docker-compose.yml` (`neo4j:5.15-community`, persistent volume, healthcheck)
- [ ] Add env vars (`NEO4J_URI`, `NEO4J_USER`, `NEO4J_PASSWORD`) to `.env.example`
- [ ] Create module `docling_studio/storage/neo4j/`:
- `driver.py` — neo4j-python driver wrapper (connection pool, context manager)
- `schema.py` — idempotent `bootstrap_schema()` (CREATE CONSTRAINT / INDEX at startup)
- `__init__.py` with exports
- [ ] Hook `bootstrap_schema()` in FastAPI startup
- [ ] Basic integration tests:
- Driver connection
- Schema bootstrap (idempotence verified)
- Simple round-trip: write Document, read Document, delete Document
**Deliverable:** docker compose boots with healthy Neo4j, schema in place at init.
### Day 2 — TreeWriter (write + read)
- [ ] `storage/neo4j/tree_writer.py``DoclingDocument → Neo4j` walker
- `write_document(doc, tenant_id, driver)` in a transaction
- DFS pre-order, batched `MERGE` for perf
- Pages first, then Elements, then `PARENT_OF` / `NEXT` / `ON_PAGE` relations
- Dynamic labels based on `node.label` (`SectionHeader`, `Paragraph`, …)
- [ ] `storage/neo4j/tree_reader.py` — inverse walker `Neo4j → DoclingDocument`
- `read_document(doc_id, driver) -> DoclingDocument`
- Loads all Elements + Pages, rebuilds the Pydantic structure
- Prerequisite for v0.6 (feeding docling-agent from Neo4j)
- [ ] Integrate into existing ingestion pipeline:
- Add TreeWriter as first stage of `IngestionPipeline`
- `neo4j_enabled: bool` config toggle
- [ ] Round-trip tests:
- 34 varied PDFs (academic, invoice, report)
- Assertion: `doc_original == read_document(write_document(doc_original))`
- Beware dates, bbox floats (tolerance)
**Deliverable:** A PDF uploaded to Docling-Studio is fully present in Neo4j and rebuildable.
### Day 3 — UI + ChunkWriter + packaging
- [ ] `storage/neo4j/chunk_writer.py`:
- After existing chunking, push each Chunk to Neo4j
- Create `(:Chunk)-[:DERIVED_FROM]->(:Element)` via source element `self_ref`
- Do NOT duplicate embeddings (stay in OpenSearch, keep `embedding_ref`)
- [ ] Frontend: new "Graph view" tab in debug panel
- Vue component with `cytoscape` (lighter, better layout API — see [ADR-001](../architecture/adrs/ADR-001-graph-visualization-library.md))
- FastAPI endpoint `/api/documents/{doc_id}/graph` returns full nodes + edges for the document, **capped at 200 pages** (HTTP 413 beyond; pagination deferred to v0.6). The endpoint must include a `truncated: bool` flag and `node_count` / `edge_count` in the response envelope so the UI can warn the user cleanly.
- View: vertical tree, colors per node type, click-to-zoom, hover details
- [ ] Per-document "Graph-ready" / "RAG-ready" badge in list
- [ ] README update:
- "Graph storage with Neo4j" section
- Schema diagram (Mermaid or image)
- 23 Cypher examples like "find all paragraphs under section X that mention Y"
- Neo4j badge in features list
- [ ] (bonus if time) "Query explorer" dev tab for live demo: Cypher editor + results
**Deliverable:** release 0.5.0 with Neo4j visible, functional, documented.
---
## 5. Proposed code structure
```
docling_studio/
├── storage/
│ ├── neo4j/
│ │ ├── __init__.py
│ │ ├── driver.py # connection management
│ │ ├── schema.py # bootstrap_schema()
│ │ ├── tree_writer.py # DoclingDocument -> Neo4j
│ │ ├── tree_reader.py # Neo4j -> DoclingDocument
│ │ ├── chunk_writer.py # Chunks -> Neo4j
│ │ └── queries.py # shared Cypher queries
│ ├── opensearch/ # (existing)
│ └── ports.py # Writer, RAGRetrievalPort protocols
├── ingestion/
│ └── pipeline.py # IngestionPipeline composing Writers
├── api/
│ └── graph.py # /api/documents/{id}/graph
└── frontend/
└── components/
└── GraphView.vue # cytoscape + graph API fetch
```
---
## 6. Docker compose (added excerpt)
```yaml
services:
neo4j:
image: neo4j:5.15-community
environment:
NEO4J_AUTH: ${NEO4J_USER:-neo4j}/${NEO4J_PASSWORD:-changeme}
NEO4J_PLUGINS: '["apoc"]'
NEO4J_server_memory_heap_initial__size: 512m
NEO4J_server_memory_heap_max__size: 1g
ports:
- "7474:7474" # Browser UI (demo)
- "7687:7687" # Bolt protocol
volumes:
- neo4j_data:/data
- neo4j_logs:/logs
healthcheck:
test: ["CMD-SHELL", "cypher-shell -u neo4j -p $${NEO4J_PASSWORD:-changeme} 'RETURN 1' || exit 1"]
interval: 10s
timeout: 5s
retries: 10
docling-studio-backend:
depends_on:
neo4j:
condition: service_healthy
environment:
NEO4J_URI: bolt://neo4j:7687
NEO4J_USER: neo4j
NEO4J_PASSWORD: ${NEO4J_PASSWORD:-changeme}
volumes:
neo4j_data:
neo4j_logs:
```
---
## 7. Tests
### Unit tests
- `tests/storage/neo4j/test_schema.py` — bootstrap is idempotent
- `tests/storage/neo4j/test_tree_writer.py` — round-trip on synthetic DoclingDocument
- `tests/storage/neo4j/test_chunk_writer.py` — chunks written with correct `DERIVED_FROM`
### Integration tests
- `tests/integration/test_ingestion_pipeline.py` — full pipeline on a real PDF
- PDF fixtures: 1 academic (complex heading hierarchy), 1 invoice (tables), 1 report (lists)
### E2E (bonus)
- Upload PDF via UI → check structure in Neo4j Browser
---
## 8. Open decisions to settle before coding
1. **Neo4j edition**: Community (free) or AuraDB (managed) ?
- Rec: Community in Docker for v0.5.0 dev/demo. AuraDB mentioned as prod option.
2. **Chunks: duplicate embeddings in Neo4j or OpenSearch ref ?**
- Rec: OpenSearch ref (avoid duplication; OpenSearch remains source of truth for vectors). In v0.6+, consider native Neo4j vector index.
3. **Graph view UI: cytoscape or vis-network ?**
- Decided: **Cytoscape.js** — see [ADR-001](../architecture/adrs/ADR-001-graph-visualization-library.md) for the full analysis.
4. **Graph endpoint: return full doc or paginate ?**
- Decided: full doc for v0.5, **hard cap at 200 pages**. Beyond the cap, the endpoint returns HTTP 413 with a `truncated: true` flag; the UI shows "Graph too large to render — reduce scope". Pagination ships in v0.6.
5. **Error strategy**: if Neo4j is down at ingestion, fail or degrade gracefully ?
- Rec: **fail fast** for v0.5 (avoid silent inconsistencies). `neo4j_required: bool` config option.
---
## 9. Hooks for later (v0.6.0+ — don't implement but prepare)
**EnrichmentWriter (v0.6)** — will need:
- The reader (Neo4j → DoclingDocument) to re-materialize the doc, feed docling-agent, re-patch enrichments
- A stage addable to `IngestionPipeline` without touching other stages
- An `:Entity` label (not created in v0.5 but schema-compatible)
**Agent reasoning trace viewer (v0.6)** — will need:
- An event stream (WebSocket) that v0.5 already prepares via the reactive UI
- A node_ref ↔ Element correlation in Neo4j (our composite `self_ref` key is enough)
**TreeNavigationPort (v0.7)** — will need:
- Optimized Cypher queries for descendant/ancestor walk (indexes already provisioned)
---
## 10. v0.5.0 success criteria
**Must have:**
- [ ] A PDF uploaded to Docling-Studio is in Neo4j with structure preserved
- [ ] Neo4j Browser shows the graph and is manually explorable
- [ ] A graph visual in the Docling-Studio UI works
- [ ] `docker compose up` works zero-config
- [ ] README mentions Neo4j and describes the schema
**Nice to have (decreasing priority):**
- [ ] Graph-ready / RAG-ready badge per doc
- [ ] Live query explorer in the UI
- [ ] 23 example queries in README that do something impossible with vector-only
**For the hackathon (post-release):**
- [ ] 60s video: upload PDF → structure in Neo4j → cross-doc query impossible in vector-only
- [ ] HackerNoon post explaining "graph-native documents" positioning
- [ ] Explicit Neo4j partnership mention
---
## 11. Fundamental architectural decisions recap
| Question | Answer |
|----------|---------|
| Is Neo4j source of truth or cache ? | **Source of truth** for structure. OpenSearch remains source of truth for embeddings. |
| Does chunking go away ? | No, v0.5.0 keeps existing chunking. "Chunkless" is Pipeline B, v0.6+. |
| Can it be toggled per doc ? | Yes — `stages_applied` on Document + pipeline config |
| What about OpenSearch ? | Stays, stores vectors. Neo4j tracks `(:Chunk)-[:DERIVED_FROM]->(:Element)` links. |
| Multi-tenancy ? | `tenant_id` property on Document, Cypher filter |
| Versioning ? | None for v0.5.0 — replace strategy on re-upload |
---
## Appendix — Demo queries
### Query 1 — All "Methods" sections across documents
```cypher
MATCH (d:Document)-[:HAS_ROOT]->(:Element)-[:PARENT_OF*]->(s:SectionHeader)
WHERE toLower(s.text) CONTAINS 'method'
RETURN d.title, s.text, s.level
```
### Query 2 — Context of a chunk (parent + siblings)
```cypher
MATCH (c:Chunk {id: $chunk_id})-[:DERIVED_FROM]->(e:Element)
MATCH (e)<-[:PARENT_OF]-(parent:Element)
MATCH (parent)-[:PARENT_OF]->(sibling:Element)
RETURN parent, collect(sibling) AS siblings
```
### Query 3 — All tables from a document type
```cypher
MATCH (d:Document)-[:HAS_ROOT]->(:Element)-[:PARENT_OF*]->(t:Table)
WHERE d.source_uri CONTAINS 'invoices/'
RETURN d.title, t.caption, t.cells_json
```
### Query 4 — Direct children of a section (ordered)
```cypher
MATCH (s:Element {doc_id: $doc_id, self_ref: $section_ref})
MATCH (s)-[pof:PARENT_OF]->(child)
RETURN child
ORDER BY pof.order
```
---
*Single reference doc for Neo4j v0.5.0 implementation.
Read this first in the implementation thread.*

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

@ -155,7 +155,7 @@ async def delete_chunk(job_id: str, chunk_index: int, service: ServiceDep) -> li
]
@router.delete("/{job_id}", status_code=204)
@router.delete("/{job_id}", status_code=204, response_model=None)
async def delete_analysis(job_id: str, service: ServiceDep) -> None:
"""Delete an analysis job."""
deleted = await service.delete(job_id)

View file

@ -85,7 +85,7 @@ async def get_document(doc_id: str, service: ServiceDep) -> DocumentResponse:
return _to_response(doc)
@router.delete("/{doc_id}", status_code=204)
@router.delete("/{doc_id}", status_code=204, response_model=None)
async def delete_document(doc_id: str, service: ServiceDep) -> None:
"""Delete a document and its file."""
deleted = await service.delete(doc_id)

View file

@ -0,0 +1,123 @@
"""Graph API — returns a cytoscape-shaped view of the document structure.
Two endpoints:
- `/graph` read from Neo4j. Rich graph (elements + chunks + pages + merges).
Requires the Maintain step (IngestionPipeline) to have run for the document.
- `/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
import logging
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from infra.docling_graph import build_graph_payload
from infra.neo4j import fetch_graph
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/documents", tags=["graph"])
MAX_PAGES = 200
class GraphNode(BaseModel):
id: str
group: str
label: str | None = None
model_config = {"extra": "allow"}
class GraphEdge(BaseModel):
id: str
source: str
target: str
type: str
order: int | None = None
class GraphResponse(BaseModel):
doc_id: str
nodes: list[GraphNode]
edges: list[GraphEdge]
node_count: int
edge_count: int
truncated: bool
page_count: int
@router.get("/{doc_id}/graph", response_model=GraphResponse)
async def get_document_graph(doc_id: str, request: Request) -> GraphResponse:
neo = getattr(request.app.state, "neo4j", None)
if neo is None:
raise HTTPException(status_code=503, detail="Neo4j is not configured")
payload = await fetch_graph(neo, doc_id, max_pages=MAX_PAGES)
if payload is None:
raise HTTPException(status_code=404, detail=f"No graph for document {doc_id}")
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,
)
@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

@ -74,7 +74,7 @@ async def ingest_analysis(
)
@router.delete("/{doc_id}", status_code=204)
@router.delete("/{doc_id}", status_code=204, response_model=None)
async def delete_ingested_document(doc_id: str, ingestion: IngestionDep) -> None:
"""Delete all indexed chunks for a document."""
await ingestion.delete_document(doc_id)

View file

@ -0,0 +1,148 @@
"""Reasoning API — live `docling-agent` runner (R&D).
`POST /api/documents/:id/rag` invokes `docling-agent`'s Chunkless RAG loop
against the stored `DoclingDocument` and returns a `RAGResult` in the same
shape the v1 import dialog already consumes so the frontend overlay code
is fully reused.
Constraints (docling-agent v0.1.0):
- Backend is hard-wired to Ollama (`setup_local_session` in
`docling_agent/agent_models.py`). Set `OLLAMA_HOST` + `RAG_MODEL_ID` in the
environment. No OpenAI/WatsonX path without forking upstream.
- We call the private `_rag_loop` because `DoclingRAGAgent.run()` wraps the
answer in a synthetic `DoclingDocument` and never returns the iteration
trace. This is brittle track upstream for a public hook.
- Sync blocking call offloaded to a thread so we don't stall the event loop.
No streaming at this step (see design doc §7 for v2 SSE plan).
"""
from __future__ import annotations
import asyncio
import logging
import os
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from infra.settings import settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/documents", tags=["reasoning"])
class RagRunRequest(BaseModel):
query: str
# Optional per-run override; falls back to settings.rag_model_id.
model_id: str | None = None
class RagIterationResponse(BaseModel):
iteration: int
section_ref: str
reason: str
section_text_length: int
can_answer: bool
response: str
class RagResultResponse(BaseModel):
answer: str
iterations: list[RagIterationResponse]
converged: bool
@router.post("/{doc_id}/rag", response_model=RagResultResponse)
async def run_rag(doc_id: str, body: RagRunRequest, request: Request) -> RagResultResponse:
if not settings.rag_enabled:
raise HTTPException(status_code=503, detail="Live reasoning disabled (RAG_ENABLED=false)")
if not body.query.strip():
raise HTTPException(status_code=400, detail="Query must not be empty")
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}",
)
# Lazy-import docling-agent so the backend boots even if the dep isn't
# installed (R&D group). If missing, return 503 with a clear install hint.
try:
from docling_agent.agents import DoclingRAGAgent
from docling_core.types.doc.document import DoclingDocument
from mellea.backends.model_ids import ModelIdentifier
except ImportError as e:
raise HTTPException(
status_code=503,
detail=f"docling-agent not installed: {e}. `pip install docling-agent mellea`.",
) from e
# Ollama client reads OLLAMA_HOST at request time; set it per-call so the
# configured host takes effect without needing to restart the server.
os.environ["OLLAMA_HOST"] = settings.ollama_host
raw_model_id = body.model_id or settings.rag_model_id
# `DoclingRAGAgent` (pydantic) validates `model_id` strictly against the
# `ModelIdentifier` dataclass from Mellea. A raw string like "gpt-oss:20b"
# is rejected even though the Ollama backend itself would accept one.
# Wrap on the Ollama axis; add other axes here if we ever fork upstream to
# support non-Ollama backends.
model_id = ModelIdentifier(ollama_name=raw_model_id)
try:
doc = DoclingDocument.model_validate_json(latest.document_json)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to parse document_json: {e}") from e
agent = DoclingRAGAgent(model_id=model_id, tools=[])
logger.info(
"RAG run: doc_id=%s model_id=%s ollama_host=%s query=%r",
doc_id,
model_id,
settings.ollama_host,
body.query[:120],
)
try:
# `_rag_loop` is a synchronous LLM-heavy call (N * model latency). Run
# it in a worker thread so concurrent requests don't block the loop.
result = await asyncio.to_thread(agent._rag_loop, query=body.query, doc=doc)
except IndexError as e:
# Known docling-agent bug: `_attempt_answer` / `_select_section` call
# `find_json_dicts(answer.value)[0]` without checking for an empty
# list. When the model can't produce a parseable JSON after 3
# rejection-sampling retries + 3 `select_from_failure` retries, the
# list is empty and the `[0]` crashes. It's model-dependent (some
# questions + some models trip it, others don't).
#
# Report as 502 Bad Gateway — the upstream LLM couldn't produce a
# usable response, not our fault — with a message the UI can show
# to the user so they pick another model or rephrase.
logger.warning(
"docling-agent produced no parseable JSON for doc=%s model=%s query=%r",
doc_id,
raw_model_id,
body.query[:120],
)
raise HTTPException(
status_code=502,
detail=(
f"The model '{raw_model_id}' couldn't produce a parseable "
"answer after retries. Try a different model (e.g. mistral-small3.2) "
"or rephrase the question."
),
) from e
except Exception as e:
logger.exception("RAG loop failed for doc %s", doc_id)
raise HTTPException(status_code=500, detail=f"RAG loop failed: {e}") from e
return RagResultResponse(
answer=result.answer,
iterations=[RagIterationResponse(**it.model_dump()) for it in result.iterations],
converged=result.converged,
)

View file

@ -35,6 +35,10 @@ class HealthResponse(_CamelModel):
max_page_count: int | None = None
max_file_size_mb: int | None = None
ingestion_available: bool = False
# True when the live-reasoning runner (docling-agent + Ollama) is
# available: RAG_ENABLED=true AND deps importable. Doesn't imply Ollama
# itself is reachable — that's checked per-call.
rag_available: bool = False
class DocumentResponse(_CamelModel):

View file

@ -8,6 +8,10 @@ from __future__ import annotations
from dataclasses import dataclass, field
# US Letter page dimensions (points) — fallback when page size is unknown
DEFAULT_PAGE_WIDTH: float = 612.0
DEFAULT_PAGE_HEIGHT: float = 792.0
@dataclass(frozen=True)
class PageElement:
@ -15,6 +19,11 @@ class PageElement:
bbox: list[float]
content: str
level: int = 0
# Docling `self_ref` ("#/texts/12", "#/tables/3", …). Empty for items
# that don't have one (rare — defensive default). Lets callers correlate
# a rendered bbox with the corresponding node in the graph without
# resorting to fuzzy bbox matching.
self_ref: str = ""
@dataclass(frozen=True)
@ -71,6 +80,14 @@ class ChunkBbox:
bbox: list[float] # [left, top, right, bottom] in TOPLEFT origin
@dataclass(frozen=True)
class ChunkDocItem:
"""Source element referenced by a chunk. Enables Neo4j DERIVED_FROM edges."""
self_ref: str
label: str
@dataclass(frozen=True)
class ChunkResult:
text: str
@ -78,3 +95,4 @@ class ChunkResult:
source_page: int | None = None
token_count: int = 0
bboxes: list[ChunkBbox] = field(default_factory=list)
doc_items: list[ChunkDocItem] = field(default_factory=list)

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

@ -15,7 +15,7 @@ from docling_core.transforms.chunker import HierarchicalChunker
from docling_core.transforms.chunker.hybrid_chunker import HybridChunker
from docling_core.types.doc.document import DoclingDocument
from domain.value_objects import ChunkBbox, ChunkingOptions, ChunkResult
from domain.value_objects import ChunkBbox, ChunkDocItem, ChunkingOptions, ChunkResult
from infra.bbox import EMPTY_BBOX, to_topleft_list
logger = logging.getLogger(__name__)
@ -39,9 +39,18 @@ def _chunk_sync(document_json: str, options: ChunkingOptions) -> list[ChunkResul
source_page = None
token_count = 0
bboxes: list[ChunkBbox] = []
doc_items: list[ChunkDocItem] = []
if hasattr(chunk, "meta") and chunk.meta and chunk.meta.doc_items:
for doc_item in chunk.meta.doc_items:
ref = getattr(doc_item, "self_ref", None)
if ref:
doc_items.append(
ChunkDocItem(
self_ref=ref,
label=str(getattr(doc_item, "label", "") or ""),
)
)
if not hasattr(doc_item, "prov") or not doc_item.prov:
continue
for prov in doc_item.prov:
@ -67,6 +76,7 @@ def _chunk_sync(document_json: str, options: ChunkingOptions) -> list[ChunkResul
source_page=source_page,
token_count=token_count,
bboxes=bboxes,
doc_items=doc_items,
)
)

View file

@ -36,6 +36,8 @@ from docling_core.types.doc import (
)
from domain.value_objects import (
DEFAULT_PAGE_HEIGHT,
DEFAULT_PAGE_WIDTH,
ConversionOptions,
ConversionResult,
PageDetail,
@ -50,10 +52,6 @@ logger = logging.getLogger(__name__)
# Uses a timeout to prevent a frozen conversion from blocking all others.
_converter_lock = threading.Lock()
# US Letter page dimensions (points) — fallback when page size is unknown
_DEFAULT_PAGE_WIDTH = 612.0
_DEFAULT_PAGE_HEIGHT = 792.0
# Default converter (lazy-init on first request)
_default_converter: DoclingConverter | None = None
@ -175,11 +173,11 @@ def _process_content_item(
logger.warning(
"Page %d not found in document metadata — using US Letter fallback (%sx%s pt)",
page_no,
_DEFAULT_PAGE_WIDTH,
_DEFAULT_PAGE_HEIGHT,
DEFAULT_PAGE_WIDTH,
DEFAULT_PAGE_HEIGHT,
)
pages[page_no] = PageDetail(
page_number=page_no, width=_DEFAULT_PAGE_WIDTH, height=_DEFAULT_PAGE_HEIGHT
page_number=page_no, width=DEFAULT_PAGE_WIDTH, height=DEFAULT_PAGE_HEIGHT
)
page_height = pages[page_no].height
@ -196,7 +194,13 @@ def _process_content_item(
content = item.export_to_markdown()
pages[page_no].elements.append(
PageElement(type=element_type, bbox=bbox, content=content, level=level)
PageElement(
type=element_type,
bbox=bbox,
content=content,
level=level,
self_ref=getattr(item, "self_ref", "") or "",
)
)
except (AttributeError, KeyError, TypeError, ValueError):
logger.warning(
@ -248,10 +252,10 @@ def _convert_sync(
pages_detail = [
PageDetail(
page_number=i + 1,
width=doc.pages[i + 1].size.width if (i + 1) in doc.pages else _DEFAULT_PAGE_WIDTH,
width=doc.pages[i + 1].size.width if (i + 1) in doc.pages else DEFAULT_PAGE_WIDTH,
height=doc.pages[i + 1].size.height
if (i + 1) in doc.pages
else _DEFAULT_PAGE_HEIGHT,
else DEFAULT_PAGE_HEIGHT,
)
for i in range(page_count)
]

View file

@ -0,0 +1,31 @@
"""Neo4j storage adapter — graph-native document structure.
Provides a thin driver wrapper, idempotent schema bootstrap, and
walkers between DoclingDocument and the graph model.
"""
from infra.neo4j.chunk_writer import ChunkWriteResult, write_chunks
from infra.neo4j.driver import Neo4jDriver, close_driver, get_driver
from infra.neo4j.queries import fetch_graph
from infra.neo4j.schema import bootstrap_schema
from infra.neo4j.tree_reader import (
delete_document,
document_exists,
read_document_json,
)
from infra.neo4j.tree_writer import TreeWriteResult, write_document
__all__ = [
"ChunkWriteResult",
"Neo4jDriver",
"TreeWriteResult",
"bootstrap_schema",
"close_driver",
"delete_document",
"document_exists",
"fetch_graph",
"get_driver",
"read_document_json",
"write_chunks",
"write_document",
]

View file

@ -0,0 +1,134 @@
"""ChunkWriter — push chunk nodes and DERIVED_FROM edges to Neo4j.
Embeddings stay in OpenSearch. Each :Chunk node carries a chunk_index so the
OpenSearch entry can be retrieved via (doc_id, chunk_index). The
`embedding_ref` property is reserved for a future vector-store id (not used
in v0.5 OpenSearch indexes by doc_id+chunk_index already).
When chunks carry `doc_items` provenance (list of `self_ref` strings), we
create `(:Chunk)-[:DERIVED_FROM]->(:Element)` links so that queries can go
from a chunk back to its source elements. Chunks without doc_items get no
back-links but are still persisted.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from infra.neo4j.driver import Neo4jDriver
logger = logging.getLogger(__name__)
@dataclass
class ChunkWriteResult:
doc_id: str
chunks_written: int
derived_from_edges: int
def _chunk_id(doc_id: str, index: int) -> str:
return f"{doc_id}::chunk::{index}"
async def write_chunks(
neo: Neo4jDriver,
*,
doc_id: str,
chunks_json: str,
) -> ChunkWriteResult:
"""Persist chunks for `doc_id`. Wipes prior chunks first (idempotent)."""
chunks: list[dict[str, Any]] = json.loads(chunks_json)
active = [c for c in chunks if not c.get("deleted")]
chunk_rows: list[dict[str, Any]] = []
derived_rows: list[dict[str, Any]] = []
for idx, c in enumerate(active):
cid = _chunk_id(doc_id, idx)
chunk_rows.append(
{
"id": cid,
"doc_id": doc_id,
"text": c.get("text") or "",
"chunk_index": idx,
"token_count": c.get("tokenCount") or 0,
"embedding_ref": "",
}
)
for item in c.get("docItems") or []:
ref = item.get("selfRef") if isinstance(item, dict) else None
if ref:
derived_rows.append({"chunk_id": cid, "doc_id": doc_id, "self_ref": ref})
async with (
neo.driver.session(database=neo.database) as session,
await session.begin_transaction() as tx,
):
# Replace existing chunks.
await tx.run(
"""
MATCH (d:Document {id: $doc_id})-[:HAS_CHUNK]->(c:Chunk)
DETACH DELETE c
""",
doc_id=doc_id,
)
await tx.run("MATCH (c:Chunk {doc_id: $doc_id}) DETACH DELETE c", doc_id=doc_id)
if chunk_rows:
await tx.run(
"""
MATCH (d:Document {id: $doc_id})
UNWIND $rows AS r
CREATE (c:Chunk {
id: r.id,
doc_id: r.doc_id,
text: r.text,
chunk_index: r.chunk_index,
token_count: r.token_count,
embedding_ref: r.embedding_ref
})
MERGE (d)-[:HAS_CHUNK]->(c)
""",
doc_id=doc_id,
rows=chunk_rows,
)
if derived_rows:
await tx.run(
"""
UNWIND $rows AS r
MATCH (c:Chunk {id: r.chunk_id})
MATCH (e:Element {doc_id: r.doc_id, self_ref: r.self_ref})
MERGE (c)-[:DERIVED_FROM]->(e)
""",
rows=derived_rows,
)
# Flag the Document with the new stage.
await tx.run(
"""
MATCH (d:Document {id: $doc_id})
SET d.stages_applied = [s IN coalesce(d.stages_applied, []) WHERE s <> 'chunks']
+ ['chunks'],
d.last_chunk_write = datetime()
""",
doc_id=doc_id,
)
await tx.commit()
logger.info(
"Neo4j: wrote %d chunks (%d DERIVED_FROM) for doc %s",
len(chunk_rows),
len(derived_rows),
doc_id,
)
return ChunkWriteResult(
doc_id=doc_id,
chunks_written=len(chunk_rows),
derived_from_edges=len(derived_rows),
)

View file

@ -0,0 +1,48 @@
"""Async Neo4j driver wrapper.
Owns a single `AsyncDriver` per process. Callers acquire it via
`get_driver()` and must call `close_driver()` at shutdown.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from neo4j import AsyncDriver, AsyncGraphDatabase
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class Neo4jDriver:
driver: AsyncDriver
database: str = "neo4j"
_instance: Neo4jDriver | None = None
async def get_driver(uri: str, user: str, password: str, database: str = "neo4j") -> Neo4jDriver:
"""Return the process-wide driver, creating it on first call.
Verifies connectivity once at creation raises if the server is unreachable.
"""
global _instance
if _instance is not None:
return _instance
driver = AsyncGraphDatabase.driver(uri, auth=(user, password))
await driver.verify_connectivity()
logger.info("Neo4j driver connected to %s (db=%s)", uri, database)
_instance = Neo4jDriver(driver=driver, database=database)
return _instance
async def close_driver() -> None:
global _instance
if _instance is None:
return
await _instance.driver.close()
_instance = None
logger.info("Neo4j driver closed")

View file

@ -0,0 +1,269 @@
"""Reusable Cypher queries — kept out of the API layer for reuse + testing."""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from infra.neo4j.driver import Neo4jDriver
@dataclass
class GraphPayload:
doc_id: str
nodes: list[dict[str, Any]]
edges: list[dict[str, Any]]
node_count: int
edge_count: int
truncated: bool
page_count: int
# Full graph for one doc: Document + Elements + Pages + Chunks and their edges.
# Each node/edge type is collected inside its own CALL {} subquery so every
# block contributes a single row — avoids the cartesian product that chained
# 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/
#
# 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 = """
MATCH (d:Document {id: $doc_id})
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 (c:Chunk {doc_id: d.id}) RETURN collect(c) AS chunks }
CALL {
WITH d
MATCH (pe:Element {doc_id: d.id})-[r:PARENT_OF]->(ce:Element)
RETURN collect({from: pe.self_ref, to: ce.self_ref, order: r.order, type: 'PARENT_OF'}) AS parent_edges
}
CALL {
WITH d
MATCH (a:Element {doc_id: d.id})-[:NEXT]->(b:Element)
RETURN collect({from: a.self_ref, to: b.self_ref, type: 'NEXT'}) AS next_edges
}
CALL {
WITH d
// 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
}
CALL {
WITH d
MATCH (d)-[:HAS_ROOT]->(rr:Element)
RETURN collect({from: d.id, to: rr.self_ref, type: 'HAS_ROOT'}) AS has_root_edges
}
CALL {
WITH d
MATCH (d)-[:HAS_CHUNK]->(rc:Chunk)
RETURN collect({from: d.id, to: rc.id, type: 'HAS_CHUNK'}) AS has_chunk_edges
}
CALL {
WITH d
MATCH (cc:Chunk {doc_id: d.id})-[:DERIVED_FROM]->(ee:Element)
RETURN collect({from: cc.id, to: ee.self_ref, type: 'DERIVED_FROM'}) AS derived_from_edges
}
RETURN d AS document, elements, pages, chunks,
parent_edges, next_edges, on_page_edges,
has_root_edges, has_chunk_edges, derived_from_edges
"""
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
# driver side; when we project nodes via RETURN, the driver wraps them as Node
# 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 {
"id": f"elem::{e.get('self_ref')}",
"group": "element",
"docling_label": e.get("docling_label"),
"self_ref": e.get("self_ref"),
"text": (e.get("text") or "")[:200],
"prov_page": first_page,
"provs": provs or [],
"level": e.get("level"),
"doc_id": doc_id,
}
def _page_node(doc_id: str, p: dict[str, Any]) -> dict[str, Any]:
return {
"id": f"page::{p.get('page_no')}",
"group": "page",
"page_no": p.get("page_no"),
"width": p.get("width"),
"height": p.get("height"),
"doc_id": doc_id,
}
def _chunk_node(p: dict[str, Any]) -> dict[str, Any]:
return {
"id": f"chunk::{p.get('id')}",
"group": "chunk",
"chunk_index": p.get("chunk_index"),
"text": (p.get("text") or "")[:200],
"token_count": p.get("token_count"),
}
def _edge_id(from_id: str, to_id: str, edge_type: str) -> str:
return f"{edge_type}::{from_id}::{to_id}"
async def fetch_graph(
neo: Neo4jDriver,
doc_id: str,
*,
max_pages: int = 200,
) -> GraphPayload | None:
"""Return the full graph for a document, or None if the document is unknown.
Enforces the page cap from design §8.4: beyond `max_pages`, returns a
`truncated=True` payload with empty node/edge lists so the caller can
surface a clean error (HTTP 413) to the UI.
"""
async with neo.driver.session(database=neo.database) as session:
page_count_result = await session.run(
"MATCH (p:Page {doc_id: $doc_id}) RETURN count(p) AS n",
doc_id=doc_id,
)
pc_record = await page_count_result.single()
if pc_record is None:
return None
page_count = int(pc_record["n"])
exists_result = await session.run(
"MATCH (d:Document {id: $doc_id}) RETURN count(d) AS n",
doc_id=doc_id,
)
exists_record = await exists_result.single()
if not exists_record or exists_record["n"] == 0:
return None
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,
)
result = await session.run(_FETCH_GRAPH, doc_id=doc_id)
record = await result.single()
nodes: list[dict[str, Any]] = []
edges: list[dict[str, Any]] = []
if record is None:
return None
# Document node.
doc_node = record["document"]
if doc_node is not None:
nodes.append(
{
"id": f"doc::{doc_id}",
"group": "document",
"doc_id": doc_id,
"title": doc_node.get("title"),
"stages_applied": doc_node.get("stages_applied"),
}
)
# Element nodes, keeping the specific label (:SectionHeader, etc.).
# 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:
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"]
node = _element_node(doc_id, dict(e), provs=provs)
node["label"] = labels[0] if labels else "TextElement"
nodes.append(node)
# Pages.
for p in record["pages"] or []:
if p is None:
continue
nodes.append(_page_node(doc_id, dict(p)))
# Chunks.
for c in record["chunks"] or []:
if c is None:
continue
nodes.append(_chunk_node(dict(c)))
# Edges — filter out rows whose from/to is null (OPTIONAL MATCH can yield them).
def _push_element_edge(e: dict[str, Any], from_prefix: str, to_prefix: str) -> None:
frm, to = e.get("from"), e.get("to")
if frm is None or to is None:
return
edges.append(
{
"id": _edge_id(f"{from_prefix}{frm}", f"{to_prefix}{to}", e["type"]),
"source": f"{from_prefix}{frm}",
"target": f"{to_prefix}{to}",
"type": e["type"],
"order": e.get("order"),
}
)
for e in record["parent_edges"] or []:
_push_element_edge(e, "elem::", "elem::")
for e in record["next_edges"] or []:
_push_element_edge(e, "elem::", "elem::")
for e in record["on_page_edges"] or []:
_push_element_edge(e, "elem::", "page::")
for e in record["has_root_edges"] or []:
_push_element_edge(e, "doc::", "elem::")
for e in record["has_chunk_edges"] or []:
_push_element_edge(e, "doc::", "chunk::")
for e in record["derived_from_edges"] or []:
_push_element_edge(e, "chunk::", "elem::")
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,56 @@
"""Idempotent Neo4j schema bootstrap.
Runs at backend startup. All statements use `IF NOT EXISTS`, so calling
this multiple times is safe it's the contract integration tests rely on.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from infra.neo4j.driver import Neo4jDriver
logger = logging.getLogger(__name__)
CONSTRAINTS: tuple[str, ...] = (
"CREATE CONSTRAINT document_id IF NOT EXISTS FOR (d:Document) REQUIRE d.id IS UNIQUE",
"CREATE CONSTRAINT element_composite IF NOT EXISTS "
"FOR (e:Element) REQUIRE (e.doc_id, e.self_ref) IS UNIQUE",
"CREATE CONSTRAINT page_composite IF NOT EXISTS "
"FOR (p:Page) REQUIRE (p.doc_id, p.page_no) IS UNIQUE",
"CREATE CONSTRAINT chunk_id IF NOT EXISTS FOR (c:Chunk) REQUIRE c.id IS UNIQUE",
)
INDEXES: tuple[str, ...] = (
"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)",
# 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, ...] = (
"CREATE FULLTEXT INDEX element_text IF NOT EXISTS FOR (e:Element) ON EACH [e.text]",
)
async def bootstrap_schema(neo: Neo4jDriver) -> None:
"""Create constraints and indexes required by the graph model.
Idempotent: safe to call on every startup.
"""
async with neo.driver.session(database=neo.database) as session:
for stmt in (*CONSTRAINTS, *INDEXES, *FULLTEXT_INDEXES):
await session.run(stmt)
logger.info(
"Neo4j schema bootstrapped (%d constraints, %d indexes, %d fulltext)",
len(CONSTRAINTS),
len(INDEXES),
len(FULLTEXT_INDEXES),
)

View file

@ -0,0 +1,64 @@
"""TreeReader — fetch a DoclingDocument back from Neo4j.
v0.5.0 implementation relies on the verbatim `document_json` property stored
on the Document node by TreeWriter. Reconstruction by walking Element nodes
is deferred to v0.6 (EnrichmentWriter prerequisite), where we may need to
rebuild the DoclingDocument after enrichments have been patched on graph
nodes directly.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from infra.neo4j.driver import Neo4jDriver
logger = logging.getLogger(__name__)
async def read_document_json(neo: Neo4jDriver, doc_id: str) -> str | None:
"""Return the stored DoclingDocument JSON for `doc_id`, or None if absent."""
async with neo.driver.session(database=neo.database) as session:
result = await session.run(
"MATCH (d:Document {id: $doc_id}) RETURN d.document_json AS json",
doc_id=doc_id,
)
record = await result.single()
if record is None:
return None
return record["json"]
async def document_exists(neo: Neo4jDriver, doc_id: str) -> bool:
async with neo.driver.session(database=neo.database) as session:
result = await session.run(
"MATCH (d:Document {id: $doc_id}) RETURN count(d) AS n",
doc_id=doc_id,
)
record = await result.single()
return bool(record and record["n"] > 0)
async def delete_document(neo: Neo4jDriver, doc_id: str) -> int:
"""Wipe everything related to a doc_id. Returns nodes removed."""
async with neo.driver.session(database=neo.database) as session:
result = await session.run(
"""
MATCH (d:Document {id: $doc_id})
OPTIONAL MATCH (d)-[:HAS_ROOT|HAS_CHUNK*0..]->(n)
WITH d, collect(DISTINCT n) AS children
DETACH DELETE d
WITH children
UNWIND children AS c
DETACH DELETE c
RETURN size(children) + 1 AS removed
""",
doc_id=doc_id,
)
record = await result.single()
# Also clean up orphan elements and pages tagged with this doc_id.
await session.run("MATCH (e:Element {doc_id: $doc_id}) DETACH DELETE e", doc_id=doc_id)
await session.run("MATCH (p:Page {doc_id: $doc_id}) DETACH DELETE p", doc_id=doc_id)
return int(record["removed"]) if record else 0

View file

@ -0,0 +1,295 @@
"""TreeWriter — persist a DoclingDocument as a graph in Neo4j.
v0.5.0 strategy: replace-on-write. For a given doc_id, all existing
Document/Element/Page/Chunk nodes are wiped before re-ingestion. The full
serialized `DoclingDocument` JSON is stored as a property on the Document
node so that `TreeReader` can round-trip it verbatim reconstruction from
graph nodes is deferred to v0.6 (see docs/design/neo4j-integration.md §2).
"""
from __future__ import annotations
import contextlib
import json
import logging
from dataclasses import dataclass
from datetime import UTC, datetime
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:
from infra.neo4j.driver import Neo4jDriver
logger = logging.getLogger(__name__)
@dataclass
class TreeWriteResult:
doc_id: str
elements_written: int
pages_written: int
provenances_written: int = 0
def _element_props(item: dict[str, Any], doc_id: str) -> dict[str, Any]:
"""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] = {
"doc_id": doc_id,
"self_ref": item.get("self_ref") or "",
"docling_label": (item.get("label") or "").lower(),
"text": item.get("text") or "",
}
# Type-specific extras.
if "level" in item:
props["level"] = item.get("level")
if "caption" in item and isinstance(item.get("caption"), str):
props["caption"] = item.get("caption")
if item.get("data") and isinstance(item["data"], dict):
# Tables carry cell layout under data; stringify to keep the schema flat.
with contextlib.suppress(TypeError, ValueError):
props["cells_json"] = json.dumps(item["data"])
return props
async def write_document(
neo: Neo4jDriver,
*,
doc_id: str,
filename: str,
document_json: str,
tenant_id: str = "default",
source_uri: str | None = None,
docling_version: str | None = None,
) -> TreeWriteResult:
"""Persist the full DoclingDocument tree to Neo4j.
Idempotent: wipes any existing graph for doc_id before writing.
Fails fast (exception propagates) if Neo4j is unavailable per design §8.5.
"""
doc_data = json.loads(document_json)
ingested_at = datetime.now(tz=UTC).isoformat()
elements: list[dict[str, Any]] = []
# 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")
if not ref:
continue
specific = element_label(item.get("label") or "")
elements.append(
{
"specific_label": specific,
"parent_ref": parent_ref(item),
**_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]] = [{"doc_id": doc_id, **p} for p in iter_pages(doc_data)]
reading_order = dfs_order(doc_data)
async with (
neo.driver.session(database=neo.database) as session,
await session.begin_transaction() as tx,
):
# 1. Wipe existing graph for this doc_id (replace strategy).
await tx.run(
"MATCH (d:Document {id: $doc_id}) "
"OPTIONAL MATCH (d)-[:HAS_ROOT|HAS_CHUNK*0..]->(n) "
"DETACH DELETE d, n",
doc_id=doc_id,
)
# 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 (p:Page {doc_id: $doc_id}) DETACH DELETE p", doc_id=doc_id)
# 2. Document node (carries the verbatim JSON for TreeReader).
await tx.run(
"""
CREATE (d:Document {
id: $doc_id,
title: $title,
source_uri: $source_uri,
ingested_at: datetime($ingested_at),
docling_version: $docling_version,
stages_applied: ['tree'],
last_tree_write: datetime($ingested_at),
tenant_id: $tenant_id,
document_json: $document_json
})
""",
doc_id=doc_id,
title=filename,
source_uri=source_uri or "",
ingested_at=ingested_at,
docling_version=docling_version or "",
tenant_id=tenant_id,
document_json=document_json,
)
# 3. Page nodes.
if pages:
await tx.run(
"UNWIND $pages AS p "
"CREATE (:Page {doc_id: p.doc_id, page_no: p.page_no, "
"width: p.width, height: p.height})",
pages=pages,
)
# 4. Element nodes — use dynamic :Element:<specific> labels via APOC-free trick.
# We split by specific label so the CREATE statement is static (no APOC).
by_specific: dict[str, list[dict[str, Any]]] = {}
for e in elements:
by_specific.setdefault(e["specific_label"], []).append(e)
for specific, batch in by_specific.items():
await tx.run(
f"""
UNWIND $batch AS e
CREATE (n:Element:{specific} {{
doc_id: e.doc_id,
self_ref: e.self_ref,
docling_label: e.docling_label,
text: e.text,
level: e.level,
caption: e.caption,
cells_json: e.cells_json
}})
""",
batch=batch,
)
# 5. PARENT_OF relations (tree structure). Order tracked inline.
parent_rows = [
{
"doc_id": doc_id,
"parent_ref": e["parent_ref"],
"child_ref": e["self_ref"],
"order": idx,
}
for idx, e in enumerate(elements)
if e["parent_ref"] and e["parent_ref"] != "#/body"
]
if parent_rows:
await tx.run(
"""
UNWIND $rows AS r
MATCH (p:Element {doc_id: r.doc_id, self_ref: r.parent_ref})
MATCH (c:Element {doc_id: r.doc_id, self_ref: r.child_ref})
MERGE (p)-[rel:PARENT_OF]->(c)
SET rel.order = r.order
""",
rows=parent_rows,
)
# 6. HAS_ROOT for top-level children of the document body.
root_rows = [
{"doc_id": doc_id, "child_ref": e["self_ref"]}
for e in elements
if e["parent_ref"] == "#/body"
]
if root_rows:
await tx.run(
"""
UNWIND $rows AS r
MATCH (d:Document {id: r.doc_id})
MATCH (c:Element {doc_id: r.doc_id, self_ref: r.child_ref})
MERGE (d)-[:HAS_ROOT]->(c)
""",
rows=root_rows,
)
# 7. Provenance nodes — one per (element, prov-entry) pair. Mirrors
# Docling's `item.prov = list[ProvenanceItem]` 1:1 so a single item
# that spans page breaks (or appears twice in the layout) keeps every
# (page, bbox, charspan) without losing data.
if provenances:
await tx.run(
"""
UNWIND $rows AS r
MATCH (e:Element {doc_id: r.doc_id, self_ref: r.self_ref})
CREATE (pv:Provenance {
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=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.
if len(reading_order) > 1:
pairs = [
{"doc_id": doc_id, "a": reading_order[i], "b": reading_order[i + 1]}
for i in range(len(reading_order) - 1)
]
await tx.run(
"""
UNWIND $pairs AS p
MATCH (a:Element {doc_id: p.doc_id, self_ref: p.a})
MATCH (b:Element {doc_id: p.doc_id, self_ref: p.b})
MERGE (a)-[:NEXT]->(b)
""",
pairs=pairs,
)
await tx.commit()
logger.info(
"Neo4j: wrote doc %s (%d elements, %d pages, %d provenances)",
doc_id,
len(elements),
len(pages),
len(provenances),
)
return TreeWriteResult(
doc_id=doc_id,
elements_written=len(elements),
pages_written=len(pages),
provenances_written=len(provenances),
)

View file

@ -69,13 +69,14 @@ class OpenSearchStore:
verify_certs: Whether to verify TLS certificates.
"""
def __init__(self, url: str, *, verify_certs: bool = False) -> None:
def __init__(self, url: str, *, verify_certs: bool = False, default_limit: int = 1000) -> None:
self._client = AsyncOpenSearch(
hosts=[url],
use_ssl=url.startswith("https"),
verify_certs=verify_certs,
ssl_show_warn=False,
)
self._default_limit = default_limit
# -- lifecycle -------------------------------------------------------------
@ -147,9 +148,11 @@ class OpenSearchStore:
index_name: str,
doc_id: str,
*,
limit: int = 1000,
limit: int | None = None,
) -> list[SearchResult]:
"""Retrieve all indexed chunks for a document, ordered by chunk_index."""
if limit is None:
limit = self._default_limit
resp = await self._client.search(
index=index_name,
body={

View file

@ -21,6 +21,8 @@ import httpx
from docling_core.types.doc.base import BoundingBox, CoordOrigin
from domain.value_objects import (
DEFAULT_PAGE_HEIGHT,
DEFAULT_PAGE_WIDTH,
ConversionOptions,
ConversionResult,
PageDetail,
@ -31,7 +33,6 @@ from infra.bbox import to_topleft_list
logger = logging.getLogger(__name__)
_API_PREFIX = "/v1"
_DEFAULT_TIMEOUT = 600.0
# Docling Serve label → our element type
_LABEL_MAP = {
@ -60,7 +61,7 @@ class ServeConverter:
self,
base_url: str,
api_key: str | None = None,
timeout: float = _DEFAULT_TIMEOUT,
timeout: float = 600.0,
):
self._base_url = base_url.rstrip("/")
self._api_key = api_key
@ -95,6 +96,13 @@ class ServeConverter:
headers=self._headers(),
)
if response.status_code >= 400:
logger.error(
"Docling Serve error %d: %s (form_data=%s)",
response.status_code,
response.text[:500],
{k: v for k, v in form_data.items()},
)
response.raise_for_status()
result_data = response.json()
@ -121,8 +129,12 @@ def _build_form_data(
) -> dict[str, str | list[str]]:
"""Build form fields matching Docling Serve's multipart form contract.
Array fields (to_formats) are sent as lists httpx encodes them as
repeated form keys (to_formats=md&to_formats=html&to_formats=json).
Serve uses FastAPI's ``Form()`` parsing — list/tuple fields are sent
as **repeated form keys** (httpx encodes Python lists this way
automatically: ``to_formats=md&to_formats=html&to_formats=json``).
Note: ``generate_page_images`` is a PdfPipelineOptions field, NOT a
ConvertDocumentsOptions field sending it causes a 422.
"""
data: dict[str, str | list[str]] = {
"to_formats": ["md", "html", "json"],
@ -134,11 +146,12 @@ def _build_form_data(
"do_picture_classification": str(options.do_picture_classification).lower(),
"do_picture_description": str(options.do_picture_description).lower(),
"include_images": str(options.generate_picture_images).lower(),
"generate_page_images": str(options.generate_page_images).lower(),
"images_scale": str(options.images_scale),
}
if page_range is not None:
data["page_range"] = f"{page_range[0]}-{page_range[1]}"
# Serve expects page_range as two repeated form fields:
# page_range=1&page_range=10
data["page_range"] = [str(page_range[0]), str(page_range[1])]
return data
@ -192,8 +205,8 @@ def _extract_pages_from_docling_document(doc: dict) -> list[PageDetail]:
size = page_data.get("size", {})
pages_dict[page_no] = PageDetail(
page_number=page_no,
width=size.get("width", 612.0),
height=size.get("height", 792.0),
width=size.get("width", DEFAULT_PAGE_WIDTH),
height=size.get("height", DEFAULT_PAGE_HEIGHT),
)
# Process all element arrays
@ -220,8 +233,8 @@ def _add_element(item: dict, pages: dict[int, PageDetail]) -> None:
if page_no not in pages:
pages[page_no] = PageDetail(
page_number=page_no,
width=612.0,
height=792.0,
width=DEFAULT_PAGE_WIDTH,
height=DEFAULT_PAGE_HEIGHT,
)
bbox_data = prov.get("bbox", {})

View file

@ -25,6 +25,16 @@ class Settings:
batch_page_size: int = 0 # 0 = disabled, > 0 = pages per batch
opensearch_url: str = "" # empty = disabled
embedding_url: str = "" # empty = disabled (e.g. http://localhost:8001)
neo4j_uri: str = "" # empty = disabled (e.g. bolt://neo4j:7687)
neo4j_user: str = "neo4j"
neo4j_password: str = "changeme"
# Live reasoning via docling-agent — off by default (heavy deps, needs an
# Ollama host reachable from the backend). Toggle RAG_ENABLED=true + point
# OLLAMA_HOST at a running instance (default http://localhost:11434).
rag_enabled: bool = False
ollama_host: str = "http://localhost:11434"
rag_model_id: str = "gpt-oss:20b" # matches docling-agent's example_05
opensearch_default_limit: int = 1000 # max chunks returned by get_chunks
embedding_dimension: int = 384 # Granite Embedding 30M / all-MiniLM-L6-v2
upload_dir: str = "./uploads"
db_path: str = "./data/docling_studio.db"
@ -54,6 +64,10 @@ class Settings:
errors.append(f"rate_limit_rpm must be >= 0 (got {self.rate_limit_rpm})")
if self.batch_page_size < 0:
errors.append(f"batch_page_size must be >= 0 (got {self.batch_page_size})")
if self.opensearch_default_limit < 1:
errors.append(
f"opensearch_default_limit must be >= 1 (got {self.opensearch_default_limit})"
)
if self.embedding_dimension < 1:
errors.append(f"embedding_dimension must be >= 1 (got {self.embedding_dimension})")
if self.default_table_mode not in ("accurate", "fast"):
@ -94,9 +108,21 @@ class Settings:
max_file_size=int(os.environ.get("MAX_FILE_SIZE", "0")),
max_file_size_mb=int(os.environ.get("MAX_FILE_SIZE_MB", "50")),
rate_limit_rpm=int(os.environ.get("RATE_LIMIT_RPM", "100")),
batch_page_size=int(os.environ.get("BATCH_PAGE_SIZE", "10")),
# 0 = batching disabled (matches dataclass default). Batching
# preserves memory on very large docs but `merge_results` drops
# `document_json`, which breaks the reasoning tunnel. Enable
# explicitly (e.g. 50+) for memory-bound deploys.
batch_page_size=int(os.environ.get("BATCH_PAGE_SIZE", "0")),
opensearch_url=os.environ.get("OPENSEARCH_URL", ""),
embedding_url=os.environ.get("EMBEDDING_URL", ""),
neo4j_uri=os.environ.get("NEO4J_URI", ""),
neo4j_user=os.environ.get("NEO4J_USER", "neo4j"),
neo4j_password=os.environ.get("NEO4J_PASSWORD", "changeme"),
rag_enabled=os.environ.get("RAG_ENABLED", "false").lower()
in ("1", "true", "yes", "on"),
ollama_host=os.environ.get("OLLAMA_HOST", "http://localhost:11434"),
rag_model_id=os.environ.get("RAG_MODEL_ID", "gpt-oss:20b"),
opensearch_default_limit=int(os.environ.get("OPENSEARCH_DEFAULT_LIMIT", "1000")),
embedding_dimension=int(os.environ.get("EMBEDDING_DIMENSION", "384")),
upload_dir=os.environ.get("UPLOAD_DIR", "./uploads"),
db_path=os.environ.get("DB_PATH", "./data/docling_studio.db"),

View file

@ -47,6 +47,7 @@ def _build_converter():
return ServeConverter(
base_url=settings.docling_serve_url,
api_key=settings.docling_serve_api_key,
timeout=settings.conversion_timeout,
)
else:
from infra.local_converter import LocalConverter
@ -56,12 +57,15 @@ def _build_converter():
def _build_chunker():
"""Build the chunker adapter — only available in local mode."""
if settings.conversion_engine == "local":
from infra.local_chunker import LocalChunker
"""Build the chunker adapter.
return LocalChunker()
return None
Uses LocalChunker in all modes in remote mode it chunks the
DoclingDocument JSON returned by Docling Serve, so docling-core
(lightweight) is the only local dependency needed.
"""
from infra.local_chunker import LocalChunker
return LocalChunker()
def _build_repos() -> tuple[SqliteDocumentRepository, SqliteAnalysisRepository]:
@ -71,6 +75,7 @@ def _build_repos() -> tuple[SqliteDocumentRepository, SqliteAnalysisRepository]:
def _build_analysis_service(
document_repo: SqliteDocumentRepository,
analysis_repo: SqliteAnalysisRepository,
neo4j_driver=None,
) -> AnalysisService:
converter = _build_converter()
chunker = _build_chunker()
@ -86,10 +91,33 @@ def _build_analysis_service(
conversion_timeout=settings.conversion_timeout,
max_concurrent=settings.max_concurrent_analyses,
config=config,
neo4j_driver=neo4j_driver,
)
def _build_ingestion_service() -> IngestionService | None:
async def _init_neo4j():
"""Initialize the Neo4j driver and bootstrap schema — skip if not configured."""
if not settings.neo4j_uri:
logger.info("Neo4j disabled (NEO4J_URI not set)")
return None
from infra.neo4j import bootstrap_schema, get_driver
try:
neo = await get_driver(
settings.neo4j_uri,
settings.neo4j_user,
settings.neo4j_password,
)
await bootstrap_schema(neo)
logger.info("Neo4j ready (uri=%s)", settings.neo4j_uri)
return neo
except Exception:
logger.exception("Neo4j init failed — continuing without graph storage")
return None
def _build_ingestion_service(neo4j_driver=None) -> IngestionService | None:
"""Build the ingestion service — only if embedding + opensearch are configured."""
if not settings.embedding_url or not settings.opensearch_url:
logger.info("Ingestion disabled (EMBEDDING_URL or OPENSEARCH_URL not set)")
@ -99,7 +127,10 @@ def _build_ingestion_service() -> IngestionService | None:
from infra.opensearch_store import OpenSearchStore
embedding = EmbeddingClient(settings.embedding_url)
vector_store = OpenSearchStore(settings.opensearch_url)
vector_store = OpenSearchStore(
settings.opensearch_url,
default_limit=settings.opensearch_default_limit,
)
config = IngestionConfig(
embedding_dimension=settings.embedding_dimension,
)
@ -108,7 +139,7 @@ def _build_ingestion_service() -> IngestionService | None:
settings.embedding_url,
settings.opensearch_url,
)
return IngestionService(embedding, vector_store, config)
return IngestionService(embedding, vector_store, config, neo4j_driver=neo4j_driver)
def _build_document_service(
@ -136,15 +167,30 @@ def _build_document_service(
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await init_db()
document_repo, analysis_repo = _build_repos()
app.state.analysis_service = _build_analysis_service(document_repo, analysis_repo)
# 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.analysis_service = _build_analysis_service(
document_repo, analysis_repo, neo4j_driver=app.state.neo4j
)
app.state.document_service = _build_document_service(document_repo, analysis_repo)
ingestion_service = _build_ingestion_service()
ingestion_service = _build_ingestion_service(neo4j_driver=app.state.neo4j)
app.state.ingestion_service = ingestion_service
if ingestion_service is not None:
app.include_router(ingestion_router)
logger.info("Ingestion router mounted")
logger.info("Docling Studio backend ready (engine=%s)", settings.conversion_engine)
yield
try:
yield
finally:
if app.state.neo4j is not None:
from infra.neo4j import close_driver
await close_driver()
app = FastAPI(
@ -170,6 +216,18 @@ if settings.rate_limit_rpm > 0:
app.include_router(documents_router)
app.include_router(analyses_router)
# Graph view — mounted regardless; individual requests 503 if Neo4j is absent.
from api.graph import router as graph_router # noqa: E402
app.include_router(graph_router)
# Live reasoning (docling-agent runner). Router is mounted unconditionally so
# the route is introspectable in OpenAPI; the handler itself 503s when
# `RAG_ENABLED` is off or the deps aren't installed.
from api.reasoning import router as reasoning_router # noqa: E402
app.include_router(reasoning_router)
@app.get("/api/health", response_model=HealthResponse)
async def health() -> HealthResponse:
@ -192,4 +250,18 @@ async def health() -> HealthResponse:
max_page_count=settings.max_page_count if settings.max_page_count > 0 else None,
max_file_size_mb=settings.max_file_size_mb if settings.max_file_size_mb > 0 else None,
ingestion_available=getattr(app.state, "ingestion_service", None) is not None,
# True when the live-reasoning runner is wired (flag on + deps present).
# The actual Ollama reachability is checked lazily at call-time to avoid
# blocking health checks on the LLM host.
rag_available=settings.rag_enabled and _rag_deps_present(),
)
def _rag_deps_present() -> bool:
"""Import-check only — does not hit Ollama."""
try:
import docling_agent.agents # noqa: F401
import mellea # noqa: F401
except ImportError:
return False
return True

View file

@ -73,6 +73,23 @@ class SqliteAnalysisRepository:
row = await cursor.fetchone()
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:
"""Persist all mutable fields of an analysis job (status, results, timestamps)."""
async with get_connection() as db:

View file

@ -9,11 +9,9 @@ from contextlib import asynccontextmanager
import aiosqlite
from infra.settings import settings
logger = logging.getLogger(__name__)
DB_PATH = settings.db_path
DB_PATH = os.environ.get("DB_PATH", "./data/docling_studio.db")
_SCHEMA = """
CREATE TABLE IF NOT EXISTS documents (

View file

@ -0,0 +1,4 @@
-r requirements.txt
pytest>=8.0.0,<9.0.0
pytest-asyncio>=0.23.0,<1.0.0
pytestarch>=2.0.0,<3.0.0

View file

@ -1,4 +1,4 @@
docling-core>=2.0.0,<3.0.0
docling-core[chunking]>=2.0.0,<3.0.0
fastapi>=0.115.0,<1.0.0
uvicorn[standard]>=0.32.0,<1.0.0
python-multipart>=0.0.12
@ -8,3 +8,8 @@ aiosqlite>=0.20.0,<1.0.0
httpx>=0.27.0,<1.0.0
pypdfium2>=4.0.0,<5.0.0
opensearch-py[async]>=2.6.0,<3.0.0
neo4j>=5.15.0,<6.0.0
# R&D reasoning-trace live runner — calls docling-agent's `_rag_loop` over
# an Ollama backend. Gated server-side by `RAG_ENABLED`; pulls ~60MB of deps.
docling-agent==0.1.0
mellea==0.4.2

View file

@ -44,6 +44,7 @@ def _chunk_to_dict(c: ChunkResult) -> dict:
"sourcePage": c.source_page,
"tokenCount": c.token_count,
"bboxes": [{"page": b.page, "bbox": b.bbox} for b in c.bboxes],
"docItems": [{"selfRef": d.self_ref, "label": d.label} for d in c.doc_items],
}
@ -69,6 +70,7 @@ class AnalysisConfig:
default_table_mode: str = "accurate"
batch_page_size: int = 0
neo4j_required: bool = False # if True, ingestion fails when Neo4j write fails
class AnalysisService:
@ -83,6 +85,7 @@ class AnalysisService:
conversion_timeout: int = 600,
max_concurrent: int = _DEFAULT_MAX_CONCURRENT,
config: AnalysisConfig | None = None,
neo4j_driver=None,
):
self._converter = converter
self._chunker = chunker
@ -93,6 +96,7 @@ class AnalysisService:
self._running_tasks: dict[str, asyncio.Task] = {}
self._background_tasks: set[asyncio.Task] = set()
self._config = config or AnalysisConfig()
self._neo4j = neo4j_driver
async def create(
self,
@ -324,11 +328,18 @@ class AnalysisService:
file_path: str,
options: ConversionOptions,
) -> ConversionResult | None:
"""Run batched or single conversion. Returns None if the job was deleted mid-batch."""
"""Run batched or single conversion. Returns None if the job was deleted mid-batch.
Batching is only used for local mode it limits memory usage when
Docling runs in-process. In remote mode the Serve instance manages
its own resources, and batching would discard document_json (needed
for chunking).
"""
total_pages = _count_pdf_pages(file_path)
batch_size = self._config.batch_page_size
is_remote = self._is_remote_converter()
if batch_size > 0 and total_pages > batch_size:
if batch_size > 0 and total_pages > batch_size and not is_remote:
return await self._run_batched_conversion(
job_id, file_path, options, total_pages, batch_size
)
@ -337,6 +348,15 @@ class AnalysisService:
timeout=self._conversion_timeout,
)
def _is_remote_converter(self) -> bool:
"""Check if the converter is a remote (Serve) adapter."""
try:
from infra.serve_converter import ServeConverter
return isinstance(self._converter, ServeConverter)
except ImportError:
return False
async def _finalize_analysis(
self,
job_id: str,
@ -370,8 +390,32 @@ class AnalysisService:
if result.page_count:
await self._document_repo.update_page_count(job.document_id, result.page_count)
await self._write_tree_to_neo4j(job, result.document_json)
logger.info("Analysis completed: %s (%d pages)", job_id, result.page_count)
async def _write_tree_to_neo4j(self, job, document_json: str | None) -> None:
"""Mirror the DoclingDocument tree into Neo4j if configured.
Silent no-op when Neo4j isn't wired in. Logs but does not fail the
analysis when the write fails, unless `config.neo4j_required` is set.
"""
if self._neo4j is None or not document_json:
return
try:
from infra.neo4j import write_document
await write_document(
self._neo4j,
doc_id=job.document_id,
filename=job.document_filename or job.document_id,
document_json=document_json,
)
except Exception:
logger.exception("Neo4j TreeWriter failed for doc %s", job.document_id)
if self._config.neo4j_required:
raise
async def _run_analysis_inner(
self,
job_id: str,

View file

@ -54,10 +54,12 @@ class IngestionService:
embedding_service: EmbeddingService,
vector_store: VectorStore,
config: IngestionConfig | None = None,
neo4j_driver=None,
) -> None:
self._embedding = embedding_service
self._vector_store = vector_store
self._config = config or IngestionConfig()
self._neo4j = neo4j_driver
async def ensure_index(self) -> None:
"""Ensure the vector index exists with the correct mapping."""
@ -139,6 +141,15 @@ class IngestionService:
indexed = await self._vector_store.index_chunks(self._config.index_name, indexed_chunks)
logger.info("Indexed %d/%d chunks for doc %s", indexed, len(indexed_chunks), doc_id)
# 5. Mirror chunks in Neo4j if configured (with DERIVED_FROM edges).
if self._neo4j is not None:
try:
from infra.neo4j import write_chunks
await write_chunks(self._neo4j, doc_id=doc_id, chunks_json=chunks_json)
except Exception:
logger.exception("Neo4j ChunkWriter failed for doc %s", doc_id)
return IngestionResult(
doc_id=doc_id,
chunks_indexed=indexed,

View file

View file

@ -0,0 +1,40 @@
"""Shared fixtures for Neo4j integration tests.
These tests are skipped unless a live Neo4j is reachable via NEO4J_TEST_URI
(defaulting to bolt://localhost:7687). CI spins up `neo4j:5.15-community`
alongside the job; locally run `docker compose -f docker-compose.dev.yml up neo4j`.
"""
from __future__ import annotations
import os
import pytest
# Skip the entire module cleanly when the neo4j driver package is absent
# (e.g. local dev without the dependency installed).
pytest.importorskip("neo4j")
from infra.neo4j import close_driver, get_driver
def _cfg() -> tuple[str, str, str]:
return (
os.environ.get("NEO4J_TEST_URI", "bolt://localhost:7687"),
os.environ.get("NEO4J_TEST_USER", "neo4j"),
os.environ.get("NEO4J_TEST_PASSWORD", "changeme"),
)
@pytest.fixture
async def neo4j_driver():
uri, user, password = _cfg()
try:
neo = await get_driver(uri, user, password)
except Exception as exc:
pytest.skip(f"Neo4j not reachable at {uri}: {exc}")
# Wipe DB before each test — integration tests assume an empty graph.
async with neo.driver.session(database=neo.database) as session:
await session.run("MATCH (n) DETACH DELETE n")
yield neo
await close_driver()

View file

@ -0,0 +1,113 @@
"""ChunkWriter creates Chunk nodes + DERIVED_FROM links.
Builds on the tree_writer fixture writes the tree first so that DERIVED_FROM
has Elements to link against.
"""
from __future__ import annotations
import json
from infra.neo4j import fetch_graph, write_chunks, write_document
from infra.neo4j.schema import bootstrap_schema
from tests.neo4j.test_tree_writer import FIXTURE
CHUNKS = [
{
"text": "Introduction. First paragraph on page 1.",
"sourcePage": 1,
"tokenCount": 8,
"docItems": [
{"selfRef": "#/texts/0", "label": "section_header"},
{"selfRef": "#/texts/1", "label": "paragraph"},
],
},
{
"text": "Continued on page 2.",
"sourcePage": 2,
"tokenCount": 4,
"docItems": [{"selfRef": "#/texts/2", "label": "paragraph"}],
"deleted": False,
},
# soft-deleted chunk: must be ignored
{"text": "gone", "deleted": True, "docItems": []},
]
async def test_write_chunks_and_derived_from(neo4j_driver):
await bootstrap_schema(neo4j_driver)
await write_document(
neo4j_driver,
doc_id="doc-fixture",
filename="fixture.pdf",
document_json=json.dumps(FIXTURE),
)
result = await write_chunks(
neo4j_driver,
doc_id="doc-fixture",
chunks_json=json.dumps(CHUNKS),
)
assert result.chunks_written == 2
assert result.derived_from_edges == 3
async with neo4j_driver.driver.session(database=neo4j_driver.database) as s:
count = await (
await s.run(
"MATCH (:Document {id: $id})-[:HAS_CHUNK]->(c:Chunk) RETURN count(c) AS n",
id="doc-fixture",
)
).single()
assert count["n"] == 2
# First chunk derives from 2 elements, second from 1.
for idx, expected in [(0, 2), (1, 1)]:
cnt = await (
await s.run(
"MATCH (c:Chunk {id: $cid})-[:DERIVED_FROM]->(e:Element) RETURN count(e) AS n",
cid=f"doc-fixture::chunk::{idx}",
)
).single()
assert cnt["n"] == expected
stages = await (
await s.run(
"MATCH (d:Document {id: $id}) RETURN d.stages_applied AS s", id="doc-fixture"
)
).single()
assert "chunks" in stages["s"]
async def test_fetch_graph_returns_full_payload(neo4j_driver):
await bootstrap_schema(neo4j_driver)
await write_document(
neo4j_driver,
doc_id="doc-fixture",
filename="fixture.pdf",
document_json=json.dumps(FIXTURE),
)
await write_chunks(
neo4j_driver,
doc_id="doc-fixture",
chunks_json=json.dumps(CHUNKS),
)
payload = await fetch_graph(neo4j_driver, "doc-fixture")
assert payload is not None
assert payload.truncated is False
assert payload.page_count == 2
groups = {n["group"] for n in payload.nodes}
assert groups == {"document", "element", "page", "chunk"}
edge_types = {e["type"] for e in payload.edges}
# Every edge kind this fixture can produce should be present.
# 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):
await bootstrap_schema(neo4j_driver)
assert await fetch_graph(neo4j_driver, "no-such-doc") is None

View file

@ -0,0 +1,32 @@
"""Minimal Document node round-trip — validates the driver + schema end-to-end."""
from __future__ import annotations
from infra.neo4j.schema import bootstrap_schema
async def test_document_write_read_delete(neo4j_driver):
await bootstrap_schema(neo4j_driver)
async with neo4j_driver.driver.session(database=neo4j_driver.database) as session:
await session.run(
"CREATE (d:Document {id: $id, title: $title, tenant_id: $tenant})",
id="doc-42",
title="Round-trip fixture",
tenant="default",
)
result = await session.run(
"MATCH (d:Document {id: $id}) RETURN d.title AS title, d.tenant_id AS tenant",
id="doc-42",
)
record = await result.single()
assert record is not None
assert record["title"] == "Round-trip fixture"
assert record["tenant"] == "default"
await session.run("MATCH (d:Document {id: $id}) DETACH DELETE d", id="doc-42")
gone = await (
await session.run("MATCH (d:Document {id: $id}) RETURN d", id="doc-42")
).single()
assert gone is None

View file

@ -0,0 +1,10 @@
"""Neo4j driver connectivity smoke test."""
from __future__ import annotations
async def test_driver_connects_and_runs_cypher(neo4j_driver):
async with neo4j_driver.driver.session(database=neo4j_driver.database) as session:
result = await session.run("RETURN 1 AS x")
record = await result.single()
assert record["x"] == 1

View file

@ -0,0 +1,38 @@
"""Schema bootstrap is idempotent and produces the expected constraints/indexes."""
from __future__ import annotations
from infra.neo4j.schema import CONSTRAINTS, FULLTEXT_INDEXES, INDEXES, bootstrap_schema
async def _count_schema(neo4j_driver) -> tuple[int, int]:
async with neo4j_driver.driver.session(database=neo4j_driver.database) as session:
constraints = await (await session.run("SHOW CONSTRAINTS")).data()
indexes = await (await session.run("SHOW INDEXES")).data()
return len(constraints), len(indexes)
async def test_bootstrap_is_idempotent(neo4j_driver):
await bootstrap_schema(neo4j_driver)
first = await _count_schema(neo4j_driver)
# Running a second time must not duplicate anything.
await bootstrap_schema(neo4j_driver)
second = await _count_schema(neo4j_driver)
assert first == second
# Sanity: we created at least what we declared.
assert first[0] >= len(CONSTRAINTS)
assert first[1] >= len(INDEXES) + len(FULLTEXT_INDEXES)
async def test_document_id_is_unique(neo4j_driver):
await bootstrap_schema(neo4j_driver)
async with neo4j_driver.driver.session(database=neo4j_driver.database) as session:
await session.run("CREATE (d:Document {id: 'doc-1', title: 'first'})")
with_err: Exception | None = None
try:
await session.run("CREATE (d:Document {id: 'doc-1', title: 'dup'})")
except Exception as exc:
with_err = exc
assert with_err is not None, "unique constraint on Document.id must reject duplicates"

View file

@ -0,0 +1,210 @@
"""TreeWriter round-trip + structural sanity checks.
Fixture is a hand-crafted DoclingDocument JSON with: one section containing
two paragraphs and a table, spanning two pages. Tests verify that the graph
mirrors the structure (HAS_ROOT, PARENT_OF, ON_PAGE, NEXT) and that
re-writing the same doc is an idempotent replace.
"""
from __future__ import annotations
import json
from infra.neo4j import read_document_json, write_document
from infra.neo4j.schema import bootstrap_schema
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, "grid": [[1, 2], [3, 4]]},
"prov": [{"page_no": 2, "bbox": {"l": 10, "t": 90, "r": 500, "b": 200}}],
}
],
"pictures": [],
"groups": [],
}
async def _count(session, cypher: str, **params) -> int:
r = await session.run(cypher, **params)
rec = await r.single()
return int(rec["n"]) if rec else 0
async def test_write_creates_expected_structure(neo4j_driver):
await bootstrap_schema(neo4j_driver)
doc_json = json.dumps(FIXTURE)
result = await write_document(
neo4j_driver,
doc_id="doc-fixture",
filename="fixture.pdf",
document_json=doc_json,
)
assert result.elements_written == 4
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:
assert (
await _count(
s,
"MATCH (d:Document {id: $id}) RETURN count(d) AS n",
id="doc-fixture",
)
== 1
)
assert (
await _count(
s,
"MATCH (:Document {id: $id})-[:HAS_ROOT]->(e:Element) RETURN count(e) AS n",
id="doc-fixture",
)
== 4
)
assert (
await _count(
s,
"MATCH (e:Element:SectionHeader {doc_id: $id, self_ref: '#/texts/0'}) "
"RETURN count(e) AS n",
id="doc-fixture",
)
== 1
)
assert (
await _count(
s,
"MATCH (e:Element:Table {doc_id: $id}) RETURN count(e) AS n",
id="doc-fixture",
)
== 1
)
# Reading-order chain: 3 NEXT edges for 4 elements.
assert (
await _count(
s,
"MATCH (a:Element {doc_id: $id})-[:NEXT]->(b:Element {doc_id: $id}) "
"RETURN count(*) AS n",
id="doc-fixture",
)
== 3
)
# Post-v0.6: ON_PAGE attaches Provenance to Page, not Element directly.
# Traverse through the Provenance node.
assert (
await _count(
s,
"MATCH (:Element {doc_id: $id})-[:HAS_PROV]->"
"(:Provenance)-[:ON_PAGE]->(:Page {doc_id: $id}) "
"RETURN count(*) AS n",
id="doc-fixture",
)
== 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):
await bootstrap_schema(neo4j_driver)
doc_json = json.dumps(FIXTURE)
await write_document(
neo4j_driver,
doc_id="doc-fixture",
filename="fixture.pdf",
document_json=doc_json,
)
# Second write with the same id must not duplicate anything.
await write_document(
neo4j_driver,
doc_id="doc-fixture",
filename="fixture.pdf",
document_json=doc_json,
)
async with neo4j_driver.driver.session(database=neo4j_driver.database) as s:
assert (
await _count(s, "MATCH (d:Document {id: $id}) RETURN count(d) AS n", id="doc-fixture")
== 1
)
assert (
await _count(
s,
"MATCH (e:Element {doc_id: $id}) RETURN count(e) AS n",
id="doc-fixture",
)
== 4
)
async def test_reader_returns_verbatim_json(neo4j_driver):
await bootstrap_schema(neo4j_driver)
doc_json = json.dumps(FIXTURE, sort_keys=True)
await write_document(
neo4j_driver,
doc_id="doc-fixture",
filename="fixture.pdf",
document_json=doc_json,
)
read_back = await read_document_json(neo4j_driver, "doc-fixture")
assert read_back is not None
assert json.loads(read_back) == json.loads(doc_json)
async def test_reader_missing_doc_returns_none(neo4j_driver):
await bootstrap_schema(neo4j_driver)
assert await read_document_json(neo4j_driver, "no-such-doc") is None

View file

@ -0,0 +1,208 @@
"""Hexagonal architecture tests — enforce layer dependency rules.
Uses pytestarch for inter-layer dependency rules and ast-based import
scanning for external (third-party) dependency constraints.
Rules enforced:
- domain -> no import from api, services, infra, persistence
- services -> no import from api, infra, persistence
- api -> no import from infra, persistence
- infra -> no import from api, services
- persistence -> no import from api, services, infra
- domain -> no import of fastapi, sqlalchemy, httpx, opensearchpy
- services -> no import of fastapi
"""
from __future__ import annotations
import ast
from pathlib import Path
import pytest
from pytestarch import Rule, get_evaluable_architecture
# ---------------------------------------------------------------------------
# pytestarch evaluable (project root = document-parser/)
# ---------------------------------------------------------------------------
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
# pytestarch uses the directory name as module prefix when given absolute paths.
# We use the directory name to build qualified module references.
_PREFIX = _PROJECT_ROOT.name # "document-parser"
_evaluable = get_evaluable_architecture(str(_PROJECT_ROOT), str(_PROJECT_ROOT))
def _mod(layer: str) -> str:
"""Return the fully-qualified pytestarch module name for a layer."""
return f"{_PREFIX}.{layer}"
# ---------------------------------------------------------------------------
# Helper: collect top-level imports from all .py files in a package
# ---------------------------------------------------------------------------
def _collect_imports(package: str) -> set[str]:
"""Return the set of top-level module names imported by *package*."""
pkg_path = Path(_PROJECT_ROOT) / package
imports: set[str] = set()
for py_file in pkg_path.rglob("*.py"):
tree = ast.parse(py_file.read_text(), filename=str(py_file))
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
imports.add(alias.name.split(".")[0])
elif isinstance(node, ast.ImportFrom) and node.module:
imports.add(node.module.split(".")[0])
return imports
# ---------------------------------------------------------------------------
# Inter-layer dependency rules (pytestarch)
# ---------------------------------------------------------------------------
class TestDomainLayerIsolation:
"""domain must not depend on any other layer."""
@pytest.mark.parametrize("forbidden", ["api", "services", "infra", "persistence"])
def test_domain_does_not_import(self, forbidden: str):
rule = (
Rule()
.modules_that()
.are_sub_modules_of(_mod("domain"))
.should_not()
.import_modules_that()
.are_sub_modules_of(_mod(forbidden))
)
rule.assert_applies(_evaluable)
class TestServicesLayerIsolation:
"""services may import domain only."""
@pytest.mark.parametrize("forbidden", ["api", "infra", "persistence"])
def test_services_does_not_import(self, forbidden: str):
rule = (
Rule()
.modules_that()
.are_sub_modules_of(_mod("services"))
.should_not()
.import_modules_that()
.are_sub_modules_of(_mod(forbidden))
)
rule.assert_applies(_evaluable)
class TestApiLayerIsolation:
"""api may import services and domain, but not infra or persistence."""
@pytest.mark.parametrize("forbidden", ["infra", "persistence"])
def test_api_does_not_import(self, forbidden: str):
rule = (
Rule()
.modules_that()
.are_sub_modules_of(_mod("api"))
.should_not()
.import_modules_that()
.are_sub_modules_of(_mod(forbidden))
)
rule.assert_applies(_evaluable)
class TestInfraLayerIsolation:
"""infra may import domain (ports), but not api or services."""
@pytest.mark.parametrize("forbidden", ["api", "services"])
def test_infra_does_not_import(self, forbidden: str):
rule = (
Rule()
.modules_that()
.are_sub_modules_of(_mod("infra"))
.should_not()
.import_modules_that()
.are_sub_modules_of(_mod(forbidden))
)
rule.assert_applies(_evaluable)
class TestPersistenceLayerIsolation:
"""persistence may import domain, but not api, services, or infra."""
@pytest.mark.parametrize("forbidden", ["api", "services", "infra"])
def test_persistence_does_not_import(self, forbidden: str):
rule = (
Rule()
.modules_that()
.are_sub_modules_of(_mod("persistence"))
.should_not()
.import_modules_that()
.are_sub_modules_of(_mod(forbidden))
)
rule.assert_applies(_evaluable)
# ---------------------------------------------------------------------------
# External dependency rules (ast-based)
# ---------------------------------------------------------------------------
_DOMAIN_FORBIDDEN_EXTERNALS = {"fastapi", "sqlalchemy", "httpx", "opensearchpy"}
_SERVICES_FORBIDDEN_EXTERNALS = {"fastapi"}
class TestDomainExternalDependencies:
"""domain must not import infrastructure-specific third-party libraries."""
@pytest.mark.parametrize("lib", sorted(_DOMAIN_FORBIDDEN_EXTERNALS))
def test_domain_does_not_import_external(self, lib: str):
imports = _collect_imports("domain")
assert lib not in imports, f"domain imports forbidden external library '{lib}'"
class TestServicesExternalDependencies:
"""services must not import web-framework libraries."""
@pytest.mark.parametrize("lib", sorted(_SERVICES_FORBIDDEN_EXTERNALS))
def test_services_does_not_import_external(self, lib: str):
imports = _collect_imports("services")
assert lib not in imports, f"services imports forbidden external library '{lib}'"
# ---------------------------------------------------------------------------
# Convention: ports live exclusively in domain.ports
# ---------------------------------------------------------------------------
class TestPortConvention:
"""Protocol definitions (ports) must live in domain.ports only."""
def test_no_protocol_outside_domain_ports(self):
"""No Protocol subclass should be defined outside domain/ports.py."""
ports_file = Path(_PROJECT_ROOT) / "domain" / "ports.py"
for py_file in Path(_PROJECT_ROOT).rglob("*.py"):
if py_file == ports_file:
continue
# Skip test files and __pycache__
if "tests" in py_file.parts or "__pycache__" in py_file.parts:
continue
tree = ast.parse(py_file.read_text(), filename=str(py_file))
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
for base in node.bases:
base_name = _get_name(base)
if base_name == "Protocol":
pytest.fail(
f"Protocol '{node.name}' defined in {py_file.relative_to(_PROJECT_ROOT)}"
f" — ports must live in domain/ports.py"
)
def _get_name(node: ast.expr) -> str:
"""Extract a simple name from an AST expression node."""
if isinstance(node, ast.Name):
return node.id
if isinstance(node, ast.Attribute):
return node.attr
return ""

View file

@ -66,6 +66,7 @@ class TestChunkResult:
"source_page": 1,
"token_count": 10,
"bboxes": [],
"doc_items": [],
}
@ -465,3 +466,74 @@ class TestRechunkEndpoint:
},
)
assert resp.status_code == 422
# ---------------------------------------------------------------------------
# Remote chunking path — hybrid local chunking from Serve's document_json
# ---------------------------------------------------------------------------
class TestRemoteChunkingPath:
"""Verify that chunking works on document_json produced by Serve (remote mode)."""
@pytest.mark.asyncio
async def test_rechunk_with_serve_document_json(self):
"""AnalysisService.rechunk() works with a LocalChunker even in remote mode."""
from infra.local_chunker import LocalChunker
from services.analysis_service import AnalysisService
chunker = LocalChunker()
analysis_repo = AsyncMock()
document_repo = AsyncMock()
converter = AsyncMock() # ServeConverter mock — not used for rechunking
service = AnalysisService(
converter=converter,
analysis_repo=analysis_repo,
document_repo=document_repo,
chunker=chunker,
)
# Simulate a completed job with document_json from Serve
job = AnalysisJob(id="j-remote", document_id="d1")
job.mark_running()
job.mark_completed(
markdown="# Title\nParagraph text here.",
html="<h1>Title</h1><p>Paragraph text here.</p>",
pages_json="[]",
document_json=json.dumps(
{
"schema_name": "DoclingDocument",
"version": "1.0.0",
"name": "test",
"origin": {
"mimetype": "application/pdf",
"filename": "test.pdf",
"binary_hash": 0,
},
"furniture": {
"self_ref": "#/furniture",
"children": [],
"content_layer": "furniture",
},
"body": {"self_ref": "#/body", "children": [], "content_layer": "body"},
"groups": [],
"texts": [],
"pictures": [],
"tables": [],
"key_value_items": [],
"form_items": [],
"pages": {},
}
),
)
analysis_repo.find_by_id = AsyncMock(return_value=job)
analysis_repo.update_chunks = AsyncMock(return_value=True)
chunks = await service.rechunk(
"j-remote",
{"chunker_type": "hybrid", "max_tokens": 512},
)
assert isinstance(chunks, list)
analysis_repo.update_chunks.assert_called_once()

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

@ -0,0 +1,261 @@
"""Tests for `api.reasoning` — the live `docling-agent` RAG runner endpoint.
docling-agent + mellea are NOT installed in the CI test env (heavy deps).
The endpoint does a lazy import inside the handler; we stub the modules via
`sys.modules` injection so the tests cover the real code path without
bringing in Ollama, mellea, or LLM clients.
"""
from __future__ import annotations
import sys
import types
from dataclasses import replace
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api import reasoning as reasoning_module
from api.reasoning import router
from domain.models import AnalysisJob
def _patched_settings(monkeypatch, **overrides):
"""Replace `api.reasoning.settings` with a frozen dataclass copy carrying
the given overrides. `Settings` is frozen, so attribute-level monkeypatch
doesn't work — we swap the whole instance on the module.
"""
new_settings = replace(reasoning_module.settings, **overrides)
monkeypatch.setattr(reasoning_module, "settings", new_settings)
return new_settings
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="[]",
# Minimal placeholder — the test stubs `DoclingDocument.model_validate_json`
# so the content doesn't need to be a real DoclingDocument.
document_json='{"stub": true}',
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 stub_docling_agent(monkeypatch):
"""Inject fake `docling_agent.agents` + `docling_core.types.doc.document`
modules so the endpoint's lazy imports resolve to our stubs.
Returns the `DoclingRAGAgent` stub class so tests can assert on its calls
/ configure its `_rag_loop` return value.
"""
fake_result = MagicMock()
fake_result.answer = "stub answer"
fake_result.converged = True
fake_result.iterations = [
MagicMock(
model_dump=lambda: {
"iteration": 1,
"section_ref": "#/texts/0",
"reason": "looks relevant",
"section_text_length": 42,
"can_answer": True,
"response": "stub answer",
}
)
]
agent_instance = MagicMock()
agent_instance._rag_loop.return_value = fake_result
agent_class = MagicMock(return_value=agent_instance)
fake_agents_mod = types.ModuleType("docling_agent.agents")
fake_agents_mod.DoclingRAGAgent = agent_class
fake_root_mod = types.ModuleType("docling_agent")
fake_root_mod.agents = fake_agents_mod
fake_doc_class = MagicMock()
fake_doc_class.model_validate_json = MagicMock(return_value="fake-doc-instance")
fake_doc_mod = types.ModuleType("docling_core.types.doc.document")
fake_doc_mod.DoclingDocument = fake_doc_class
# Stub `mellea.backends.model_ids.ModelIdentifier` — the endpoint wraps
# the string model_id in this dataclass before handing to DoclingRAGAgent.
# Identity-like: stores the kwargs so tests can assert on `ollama_name`.
def fake_model_identifier(**kwargs):
m = MagicMock()
m.ollama_name = kwargs.get("ollama_name")
m.openai_name = kwargs.get("openai_name")
return m
fake_model_ids_mod = types.ModuleType("mellea.backends.model_ids")
fake_model_ids_mod.ModelIdentifier = fake_model_identifier
fake_backends_mod = types.ModuleType("mellea.backends")
fake_backends_mod.model_ids = fake_model_ids_mod
fake_mellea_mod = types.ModuleType("mellea")
fake_mellea_mod.backends = fake_backends_mod
monkeypatch.setitem(sys.modules, "docling_agent", fake_root_mod)
monkeypatch.setitem(sys.modules, "docling_agent.agents", fake_agents_mod)
monkeypatch.setitem(sys.modules, "docling_core.types.doc.document", fake_doc_mod)
monkeypatch.setitem(sys.modules, "mellea", fake_mellea_mod)
monkeypatch.setitem(sys.modules, "mellea.backends", fake_backends_mod)
monkeypatch.setitem(sys.modules, "mellea.backends.model_ids", fake_model_ids_mod)
return agent_class, agent_instance, fake_result
@pytest.fixture
def client(mock_analysis_repo: AsyncMock) -> TestClient:
app = FastAPI()
app.include_router(router)
app.state.analysis_repo = mock_analysis_repo
return TestClient(app)
class TestRagDisabled:
def test_503_when_flag_off(self, client: TestClient, monkeypatch) -> None:
_patched_settings(monkeypatch, rag_enabled=False)
resp = client.post("/api/documents/doc-1/rag", json={"query": "Q"})
assert resp.status_code == 503
assert "RAG_ENABLED" in resp.json()["detail"]
class TestRagValidation:
def test_400_when_query_empty(self, client: TestClient, monkeypatch) -> None:
_patched_settings(monkeypatch, rag_enabled=True)
resp = client.post("/api/documents/doc-1/rag", json={"query": " "})
assert resp.status_code == 400
def test_404_when_no_completed_analysis(
self, client: TestClient, mock_analysis_repo: AsyncMock, monkeypatch
) -> None:
_patched_settings(monkeypatch, rag_enabled=True)
mock_analysis_repo.find_latest_completed_by_document.return_value = None
resp = client.post("/api/documents/doc-1/rag", json={"query": "Q"})
assert resp.status_code == 404
class TestRagSuccess:
def test_returns_rag_result_shape(
self, client: TestClient, stub_docling_agent, monkeypatch
) -> None:
_patched_settings(monkeypatch, rag_enabled=True)
_agent_class, _agent_instance, _fake_result = stub_docling_agent
resp = client.post("/api/documents/doc-1/rag", json={"query": "What is this?"})
assert resp.status_code == 200
data = resp.json()
assert data["answer"] == "stub answer"
assert data["converged"] is True
assert len(data["iterations"]) == 1
it = data["iterations"][0]
assert it["iteration"] == 1
assert it["section_ref"] == "#/texts/0"
assert it["can_answer"] is True
def test_calls_rag_loop_with_query_and_doc(
self, client: TestClient, stub_docling_agent, monkeypatch
) -> None:
_patched_settings(monkeypatch, rag_enabled=True)
_agent_class, agent_instance, _ = stub_docling_agent
client.post("/api/documents/doc-1/rag", json={"query": "Hello?"})
agent_instance._rag_loop.assert_called_once()
kwargs = agent_instance._rag_loop.call_args.kwargs
assert kwargs["query"] == "Hello?"
# The stub returns the string "fake-doc-instance" from model_validate_json
# and we pass it straight through to `doc=`.
assert kwargs["doc"] == "fake-doc-instance"
def test_uses_default_model_id_when_not_overridden(
self, client: TestClient, stub_docling_agent, monkeypatch
) -> None:
_patched_settings(monkeypatch, rag_enabled=True, rag_model_id="custom-model:7b")
agent_class, _, _ = stub_docling_agent
client.post("/api/documents/doc-1/rag", json={"query": "Q"})
agent_class.assert_called_once()
# model_id is wrapped in a ModelIdentifier(ollama_name=...) dataclass
# before reaching the agent — the stub exposes the field for assertion.
passed = agent_class.call_args.kwargs["model_id"]
assert passed.ollama_name == "custom-model:7b"
def test_per_request_model_id_override_wins(
self, client: TestClient, stub_docling_agent, monkeypatch
) -> None:
_patched_settings(monkeypatch, rag_enabled=True, rag_model_id="default:7b")
agent_class, _, _ = stub_docling_agent
client.post("/api/documents/doc-1/rag", json={"query": "Q", "model_id": "override:13b"})
passed = agent_class.call_args.kwargs["model_id"]
assert passed.ollama_name == "override:13b"
def test_sets_ollama_host_env_from_settings(
self, client: TestClient, stub_docling_agent, monkeypatch
) -> None:
import os
_patched_settings(monkeypatch, rag_enabled=True, ollama_host="http://ollama:11434")
client.post("/api/documents/doc-1/rag", json={"query": "Q"})
assert os.environ["OLLAMA_HOST"] == "http://ollama:11434"
class TestRagDepsMissing:
def test_503_when_docling_agent_not_installed(self, client: TestClient, monkeypatch) -> None:
_patched_settings(monkeypatch, rag_enabled=True)
# Simulate the import failing: remove any stub and ensure the name
# resolves to a module that raises on attribute access.
monkeypatch.setitem(sys.modules, "docling_agent.agents", None)
resp = client.post("/api/documents/doc-1/rag", json={"query": "Q"})
assert resp.status_code == 503
assert "docling-agent" in resp.json()["detail"]
class TestRagUpstreamFailure:
def test_502_when_docling_agent_raises_indexerror(
self, client: TestClient, stub_docling_agent, monkeypatch
) -> None:
"""Known docling-agent bug: `find_json_dicts(answer.value)[0]` raises
`IndexError` when the model fails to produce parseable JSON after
retries. Our endpoint must surface a 502 with a human-readable
message, not a 500 stack trace."""
_patched_settings(monkeypatch, rag_enabled=True, rag_model_id="granite4:micro-h")
_agent_class, agent_instance, _ = stub_docling_agent
agent_instance._rag_loop.side_effect = IndexError("list index out of range")
resp = client.post("/api/documents/doc-1/rag", json={"query": "Quelle tarification ?"})
assert resp.status_code == 502
detail = resp.json()["detail"]
assert "granite4:micro-h" in detail
assert "parseable" in detail or "rephrase" in detail
def test_500_for_other_unexpected_errors(
self, client: TestClient, stub_docling_agent, monkeypatch
) -> None:
_patched_settings(monkeypatch, rag_enabled=True)
_agent_class, agent_instance, _ = stub_docling_agent
agent_instance._rag_loop.side_effect = RuntimeError("Ollama unreachable")
resp = client.post("/api/documents/doc-1/rag", json={"query": "Q"})
assert resp.status_code == 500
assert "Ollama unreachable" in resp.json()["detail"]

View file

@ -153,6 +153,49 @@ class TestAnalysisRepo:
deleted = await analysis_repo.delete("nonexistent")
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):
await self._insert_doc(document_repo)
for i in range(3):

View file

@ -39,7 +39,6 @@ class TestBuildFormData:
assert data["do_picture_classification"] == "false"
assert data["do_picture_description"] == "false"
assert data["include_images"] == "false"
assert data["generate_page_images"] == "false"
assert data["images_scale"] == "1.0"
assert set(data["to_formats"]) == {"md", "html", "json"}
@ -56,9 +55,14 @@ class TestBuildFormData:
assert data["images_scale"] == "2.0"
assert data["include_images"] == "true"
def test_page_range_included_when_set(self):
def test_no_generate_page_images_field(self):
"""generate_page_images is a PdfPipelineOptions field, not a Serve field."""
data = _build_form_data(ConversionOptions())
assert "generate_page_images" not in data
def test_page_range_as_repeated_fields(self):
data = _build_form_data(ConversionOptions(), page_range=(11, 20))
assert data["page_range"] == "11-20"
assert data["page_range"] == ["11", "20"]
def test_page_range_absent_when_none(self):
data = _build_form_data(ConversionOptions())
@ -402,11 +406,12 @@ class TestServeConverterConvert:
assert len(result.pages[0].elements) == 1
assert result.pages[0].elements[0].type == "title"
# Verify form fields sent as dict with list for repeated keys
# Verify form fields sent correctly
call_kwargs = mock_client.post.call_args
sent_data = call_kwargs.kwargs.get("data", {})
assert sent_data["do_ocr"] == "true"
assert set(sent_data["to_formats"]) == {"md", "html", "json"}
assert "generate_page_images" not in sent_data
@pytest.mark.asyncio
async def test_http_error_raises(self, tmp_path):
@ -414,6 +419,8 @@ class TestServeConverterConvert:
test_file.write_bytes(b"%PDF-1.4 fake content")
mock_response = MagicMock()
mock_response.status_code = 500
mock_response.text = "Internal Server Error"
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"Server Error",
request=MagicMock(),
@ -510,3 +517,17 @@ class TestConverterWiring:
converter = _build_converter()
assert isinstance(converter, ServeConverter)
assert converter._api_key == "my-key"
def test_remote_engine_builds_chunker(self):
"""Chunker must be available in remote mode (hybrid local chunking)."""
from infra.local_chunker import LocalChunker
from infra.settings import Settings
with patch(
"main.settings",
Settings(conversion_engine="remote", docling_serve_url="http://serve:5001"),
):
from main import _build_chunker
chunker = _build_chunker()
assert isinstance(chunker, LocalChunker)

View file

@ -19,6 +19,7 @@ class TestSettingsDefaults:
assert s.max_page_count == 0
assert s.max_file_size_mb == 50
assert s.batch_page_size == 0
assert s.opensearch_default_limit == 1000
assert s.upload_dir == "./uploads"
assert s.db_path == "./data/docling_studio.db"
assert "http://localhost:3000" in s.cors_origins
@ -103,6 +104,12 @@ class TestSettingsValidation:
with pytest.raises(ValueError, match="lock_timeout must be > 0"):
Settings(lock_timeout=0)
def test_zero_opensearch_default_limit_rejected(self):
import pytest
with pytest.raises(ValueError, match="opensearch_default_limit must be >= 1"):
Settings(opensearch_default_limit=0)
def test_invalid_table_mode_rejected(self):
import pytest
@ -146,6 +153,7 @@ class TestSettingsFromEnv:
monkeypatch.setenv("MAX_PAGE_COUNT", "20")
monkeypatch.setenv("MAX_FILE_SIZE_MB", "100")
monkeypatch.setenv("BATCH_PAGE_SIZE", "15")
monkeypatch.setenv("OPENSEARCH_DEFAULT_LIMIT", "500")
monkeypatch.setenv("UPLOAD_DIR", "/data/uploads")
monkeypatch.setenv("DB_PATH", "/data/test.db")
monkeypatch.setenv("CORS_ORIGINS", "http://a.com, http://b.com")
@ -163,6 +171,7 @@ class TestSettingsFromEnv:
assert s.max_page_count == 20
assert s.max_file_size_mb == 100
assert s.batch_page_size == 15
assert s.opensearch_default_limit == 500
assert s.upload_dir == "/data/uploads"
assert s.db_path == "/data/test.db"
assert s.cors_origins == ["http://a.com", "http://b.com"]

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

@ -1,13 +1,16 @@
{
"name": "docling-studio",
"version": "0.3.1",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "docling-studio",
"version": "0.3.1",
"version": "0.4.0",
"dependencies": {
"cytoscape": "^3.30.0",
"cytoscape-dagre": "^2.5.0",
"cytoscape-expand-collapse": "^4.1.1",
"dompurify": "^3.3.3",
"marked": "^17.0.4",
"pinia": "^2.3.0",
@ -16,6 +19,8 @@
},
"devDependencies": {
"@eslint/js": "^9.0.0",
"@types/cytoscape": "^3.21.4",
"@types/cytoscape-dagre": "^2.3.3",
"@types/dompurify": "^3.2.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vitest/mocker": "^4.1.2",
@ -1021,6 +1026,23 @@
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/cytoscape": {
"version": "3.21.9",
"resolved": "https://registry.npmjs.org/@types/cytoscape/-/cytoscape-3.21.9.tgz",
"integrity": "sha512-JyrG4tllI6jvuISPjHK9j2Xv/LTbnLekLke5otGStjFluIyA9JjgnvgZrSBsp8cEDpiTjwgZUZwpPv8TSBcoLw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/cytoscape-dagre": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/@types/cytoscape-dagre/-/cytoscape-dagre-2.3.4.tgz",
"integrity": "sha512-uOGXuPfPLFoKZaegjHl9oj4tqONNJuhUl180FiJgRZ35rVijBs6J4UP1Ah6mA6S46h+7pv4ICqpgfdC3EADZlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"cytoscape": "^3.31"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@ -1824,6 +1846,45 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
},
"node_modules/cytoscape": {
"version": "3.33.2",
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.2.tgz",
"integrity": "sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw==",
"license": "MIT",
"engines": {
"node": ">=0.10"
}
},
"node_modules/cytoscape-dagre": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/cytoscape-dagre/-/cytoscape-dagre-2.5.0.tgz",
"integrity": "sha512-VG2Knemmshop4kh5fpLO27rYcyUaaDkRw+6PiX4bstpB+QFt0p2oauMrsjVbUamGWQ6YNavh7x2em2uZlzV44g==",
"license": "MIT",
"dependencies": {
"dagre": "^0.8.5"
},
"peerDependencies": {
"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/dagre": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
"integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
"license": "MIT",
"dependencies": {
"graphlib": "^2.1.8",
"lodash": "^4.17.15"
}
},
"node_modules/de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
@ -2252,6 +2313,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/graphlib": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
"integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.15"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@ -2401,8 +2471,7 @@
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"dev": true
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
},
"node_modules/lodash.merge": {
"version": "4.6.2",

View file

@ -16,6 +16,9 @@
"format:check": "prettier --check src/"
},
"dependencies": {
"cytoscape": "^3.30.0",
"cytoscape-dagre": "^2.5.0",
"cytoscape-expand-collapse": "^4.1.1",
"dompurify": "^3.3.3",
"marked": "^17.0.4",
"pinia": "^2.3.0",
@ -24,9 +27,11 @@
},
"devDependencies": {
"@eslint/js": "^9.0.0",
"@vitest/mocker": "^4.1.2",
"@types/cytoscape": "^3.21.4",
"@types/cytoscape-dagre": "^2.3.3",
"@types/dompurify": "^3.2.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vitest/mocker": "^4.1.2",
"eslint": "^9.0.0",
"eslint-plugin-vue": "^9.32.0",
"prettier": "^3.4.0",

View file

@ -27,6 +27,22 @@ const routes: RouteRecordRaw[] = [
name: 'search',
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',
name: 'settings',

View file

@ -0,0 +1,59 @@
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 {
id: string
group: 'document' | 'element' | 'page' | 'chunk'
label?: string
docling_label?: string
self_ref?: string
text?: string
prov_page?: number | null
/** Full list of provenances (page + bbox) — used by the bbox-highlight UI. */
provs?: GraphProvenance[]
level?: number | null
page_no?: number
chunk_index?: number
title?: string
doc_id?: string
token_count?: number
[key: string]: unknown
}
export interface GraphEdge {
id: string
source: string
target: string
type: 'HAS_ROOT' | 'PARENT_OF' | 'NEXT' | 'ON_PAGE' | 'HAS_CHUNK' | 'DERIVED_FROM'
order?: number | null
}
export interface GraphPayload {
doc_id: string
nodes: GraphNode[]
edges: GraphEdge[]
node_count: number
edge_count: number
truncated: boolean
page_count: number
}
export function fetchDocumentGraph(docId: string): Promise<GraphPayload> {
return apiFetch<GraphPayload>(`/api/documents/${encodeURIComponent(docId)}/graph`)
}

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

@ -0,0 +1,695 @@
<template>
<div class="graph-view" data-e2e="graph-view">
<div v-if="loading" class="graph-placeholder">
<div class="spinner-large" />
<span>{{ t('results.graphLoading') }}</span>
</div>
<div v-else-if="error" class="graph-placeholder error" data-e2e="graph-error">
<span>{{ error }}</span>
<button class="retry-btn" @click="load">{{ t('results.retry') }}</button>
</div>
<div v-else-if="empty" class="graph-placeholder">
<span>{{ t('results.graphEmpty') }}</span>
</div>
<template v-else>
<div class="graph-toolbar">
<span class="graph-stats">
{{ payload?.node_count }} nodes · {{ payload?.edge_count }} edges ·
{{ payload?.page_count }} pages
</span>
<span class="graph-legend">
<button
v-for="chip in visibleChips"
:key="chip.key"
type="button"
class="legend-chip"
:class="[`legend-${chip.key}`, { 'legend-off': hiddenChips.has(chip.key) }]"
: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>
</div>
<div class="graph-body">
<div class="graph-canvas-wrap">
<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"
:contents="selectedNodeContents"
@close="closeDetails"
@navigate="navigateToNode"
/>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import type { Core } from 'cytoscape'
import { computed, onMounted, onBeforeUnmount, ref, watch, nextTick } from 'vue'
import { useI18n } from '../../../shared/i18n'
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'
// `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 emit = defineEmits<{
/** Emitted on node tap with the element's `self_ref` (null when the tap
* cleared the selection, or when the tapped node has no self_ref
* Document / Page / Chunk). Consumers can mirror the selection elsewhere
* (e.g. the ReasoningWorkspace syncs it to the PDF viewer). */
nodeFocus: [selfRef: string | null]
}>()
const { t } = useI18n()
const containerRef = ref<HTMLDivElement | null>(null)
const payload = ref<GraphPayload | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const empty = ref(false)
// Exposed via defineExpose so parent components (e.g. the reasoning trace
// 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)
// Compound parenting map (childId parentId), kept in sync with the
// Cytoscape render so the details panel can show "this section contains ".
// Updated at the end of `renderGraph` before that it's empty.
const parentMap = ref<Map<string, string>>(new Map())
// Inverse index of parentMap: parentId childId[]. Enables the
// NodeDetailsPanel "contents" section (click a section see what's in it).
const childrenByParent = computed<Map<string, GraphNode[]>>(() => {
const out = new Map<string, GraphNode[]>()
const byId = new Map<string, GraphNode>()
for (const n of payload.value?.nodes ?? []) byId.set(n.id, n)
for (const [childId, parentId] of parentMap.value) {
const child = byId.get(childId)
if (!child) continue
if (!out.has(parentId)) out.set(parentId, [])
out.get(parentId)!.push(child)
}
return out
})
const selectedNodeContents = computed<GraphNode[]>(() => {
const id = selectedNode.value?.id
if (!id) return []
return childrenByParent.value.get(id) ?? []
})
// Only surface chips that actually have matching nodes in the current
// payload. Keeps the legend in sync with the source (e.g. Reasoning view
// never emits Chunk nodes, so the Chunk chip would dangle) without
// hardcoding per-view chip lists.
const visibleChips = computed(() => {
const nodes = payload.value?.nodes ?? []
if (nodes.length === 0) return []
return LEGEND_CHIPS.filter((chip) => nodes.some((n) => chip.match(n)))
})
// 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> = {
document: '#1E293B',
SectionHeader: '#F97316',
Paragraph: '#3B82F6',
TextElement: '#3B82F6',
Table: '#8B5CF6',
Figure: '#22C55E',
ListItem: '#06B6D4',
Formula: '#EC4899',
Code: '#14B8A6',
Caption: '#EAB308',
Page: '#94A3B8',
Chunk: '#DC2626',
}
function nodeColor(n: GraphPayload['nodes'][number]): string {
if (n.group === 'document') return NODE_COLORS.document
if (n.group === 'page') return NODE_COLORS.Page
if (n.group === 'chunk') return NODE_COLORS.Chunk
return NODE_COLORS[n.label || 'TextElement'] || NODE_COLORS.TextElement
}
function nodeLabel(n: GraphPayload['nodes'][number]): string {
if (n.group === 'document') return n.title || n.id
if (n.group === 'page') return `p.${n.page_no}`
if (n.group === 'chunk') return `chunk #${n.chunk_index}`
const txt = (n.text || '').slice(0, 40)
return txt || n.label || n.docling_label || n.self_ref || n.id
}
async function load(): Promise<void> {
if (!props.docId) {
empty.value = true
return
}
loading.value = true
error.value = null
empty.value = false
try {
payload.value = await props.fetcher(props.docId)
if (!payload.value.nodes.length) {
empty.value = true
return
}
// Flip loading off so the canvas <div> mounts, then wait a tick before init.
loading.value = false
await nextTick()
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) {
error.value = (e as Error).message || 'Failed to load graph'
console.error('Failed to load graph', e)
} finally {
loading.value = false
}
}
async function renderGraph(): Promise<void> {
if (!containerRef.value || !payload.value) return
// Dynamic imports keep the heavy Cytoscape bundle out of the main chunk.
const [{ default: cytoscape }, { default: dagre }, ecMod] = await Promise.all([
import('cytoscape'),
import('cytoscape-dagre'),
import('cytoscape-expand-collapse'),
])
const C = cytoscape as any
C.use(dagre)
// 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). Also persisted on `parentMap` so the
// NodeDetailsPanel can list what a given section contains.
const computedParentMap = mergeParentMaps(
explicitParentMap(payload.value.edges),
computeSectionParents(payload.value.nodes, payload.value.edges),
)
parentMap.value = computedParentMap
const elements = [
...payload.value.nodes.map((n) => ({
data: {
id: n.id,
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),
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: computedParentMap.get(n.id),
raw: n,
},
})),
...payload.value.edges.map((e) => ({
data: {
id: e.id,
source: e.source,
target: e.target,
type: e.type,
},
})),
]
cy.value = cytoscape({
container: containerRef.value,
elements,
style: [
{
selector: 'node',
style: {
'background-color': 'data(bg)',
label: 'data(label)',
color: '#0F172A',
'font-size': 10,
'text-wrap': 'ellipsis',
'text-max-width': '140px',
'text-valign': 'center',
'text-halign': 'center',
width: 28,
height: 28,
'border-width': 1,
'border-color': '#0F172A',
},
},
{
selector: 'node[group = "document"]',
style: { shape: 'round-rectangle', width: 60, height: 36, color: '#F8FAFC' },
},
{
selector: 'node[group = "page"]',
style: { shape: 'round-rectangle', width: 40, height: 24 },
},
{
selector: 'node[group = "chunk"]',
style: { shape: 'diamond', color: '#F8FAFC' },
},
{
selector: 'edge',
style: {
width: 1,
'line-color': '#94A3B8',
'target-arrow-color': '#94A3B8',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
'font-size': 8,
color: '#64748B',
},
},
{
selector: 'edge[type = "PARENT_OF"]',
style: { 'line-color': '#1E293B', 'target-arrow-color': '#1E293B', width: 1.5 },
},
{
selector: 'edge[type = "NEXT"]',
style: { 'line-style': 'dashed', 'line-color': '#64748B' },
},
{
selector: 'edge[type = "ON_PAGE"]',
style: { 'line-color': '#CBD5E1', width: 1 },
},
{
selector: 'edge[type = "DERIVED_FROM"]',
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,
},
},
],
layout: {
name: 'dagre',
rankDir: 'TB',
nodeSep: 30,
edgeSep: 10,
rankSep: 40,
} as any,
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')
// Let the outer workspace mirror the selection (e.g. into the PDF view).
// Nodes without a `self_ref` (Document / Page / Chunk) emit `null` so
// the consumer can reset its focus.
emit('nodeFocus', raw.self_ref ?? null)
})
// Click on background close details panel + clear cross-view focus.
cy.value.on('tap', (evt) => {
if (evt.target === cy.value) {
selectedNode.value = null
cy.value?.nodes('.nd-selected').removeClass('nd-selected')
emit('nodeFocus', null)
}
})
// 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')
}
/**
* Triggered when the user clicks a child row inside the NodeDetailsPanel
* (e.g. the "Contents" list of a section). Switch the selection, center the
* viewport on the target, and flash the node briefly so the eye can catch it.
*/
function navigateToNode(target: GraphNode): void {
selectedNode.value = target
if (!cy.value) return
cy.value.nodes('.nd-selected').removeClass('nd-selected')
const el = cy.value.getElementById(target.id)
if (el && el.length > 0) {
el.addClass('nd-selected')
cy.value.animate({ center: { eles: el }, duration: 250 })
}
}
/**
* Mirror an external selection (e.g. user clicked a bbox in the PDF view)
* onto the graph: select the matching node, scroll it into view, update
* the details panel. No-op if the element isn't in the current graph
* (common for a PDF-only element that the reasoning graph didn't emit).
*/
function selectBySelfRef(selfRef: string): void {
const node = payload.value?.nodes.find((n) => n.self_ref === selfRef) ?? null
if (!node) return
navigateToNode(node)
}
function disposeGraph(): void {
if (cy.value) {
cy.value.destroy()
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)
onBeforeUnmount(disposeGraph)
watch(
() => props.docId,
() => {
disposeGraph()
load()
},
)
// Let parent components observe the live Cytoscape instance (e.g. the
// reasoning-trace overlay reads it via `graphViewRef.value?.cy`).
defineExpose({ cy, load, selectBySelfRef })
</script>
<style scoped>
.graph-view {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.graph-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
gap: 12px;
flex-wrap: wrap;
}
.graph-stats {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
color: var(--text-muted);
}
.graph-legend {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.legend-chip {
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
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 {
background: #1e293b;
}
.legend-section {
background: #f97316;
}
.legend-paragraph {
background: #3b82f6;
}
.legend-table {
background: #8b5cf6;
}
.legend-figure {
background: #22c55e;
}
.legend-page {
background: #94a3b8;
color: #0f172a;
}
.legend-chunk {
background: #dc2626;
}
.graph-body {
flex: 1;
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);
}
.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 {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 12px;
color: var(--text-muted);
font-size: 14px;
padding: 32px;
text-align: center;
}
.graph-placeholder.error {
color: var(--error);
}
.retry-btn {
background: var(--accent);
color: white;
border: none;
padding: 6px 16px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 12px;
}
.spinner-large {
width: 32px;
height: 32px;
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

@ -0,0 +1,409 @@
<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>
<section v-if="contents && contents.length > 0" class="nd-contents-block">
<h4 class="nd-section-title">
{{ t('graph.contains').replace('{n}', String(contents.length)) }}
</h4>
<ul class="nd-contents">
<li v-for="child in contents" :key="child.id">
<button
type="button"
class="nd-child"
:data-e2e="`node-details-child-${child.id}`"
@click="$emit('navigate', child)"
>
<span
class="nd-child-chip"
:style="{ background: kindColorFor(child) }"
:title="child.label ?? child.group"
>
{{ kindLabelFor(child) }}
</span>
<span class="nd-child-text">{{ previewText(child) }}</span>
</button>
</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
/**
* Nodes whose compound parent (PARENT_OF or synthetic section scope) is the
* currently-selected node. Computed upstream in GraphView so we don't have
* to re-walk the whole edge list here. Empty or null for leaf nodes.
*/
contents?: readonly GraphNode[] | null
}>()
defineEmits<{
close: []
/** User clicked a child row — GraphView pans + swaps selection. */
navigate: [node: GraphNode]
}>()
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)
}
// Label + color helpers factored so they work for children too, not just the
// currently-selected node. Keep them consistent with the chips above.
function kindLabelFor(n: GraphNode): string {
if (n.group === 'document') return 'Document'
if (n.group === 'page') return 'Page'
if (n.group === 'chunk') return 'Chunk'
return n.label ?? 'Element'
}
function kindColorFor(n: GraphNode): string {
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
}
/**
* Short label for a child row. Prefer the node's own text (truncated), fall
* back to its self_ref so users can still recognise / debug missing text.
*/
function previewText(n: GraphNode): string {
const raw = (n.text as string | undefined) ?? ''
const clean = raw.replace(/\s+/g, ' ').trim()
if (clean) return clean.length > 80 ? clean.slice(0, 80) + '…' : clean
if (n.group === 'page') return `p.${n.page_no ?? '?'}`
if (n.group === 'chunk') return `chunk #${n.chunk_index ?? '?'}`
return n.self_ref ?? n.id
}
</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;
}
.nd-contents-block {
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 10px;
border-top: 1px solid var(--border-light);
}
.nd-contents {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
/* Cap the list height so a section with hundreds of paragraphs doesn't
* blow the panel out. Scroll internally above that. */
max-height: 340px;
overflow-y: auto;
}
.nd-child {
display: flex;
align-items: baseline;
gap: 8px;
width: 100%;
text-align: left;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
padding: 5px 8px;
cursor: pointer;
font: inherit;
color: inherit;
transition: all var(--transition);
}
.nd-child:hover {
background: var(--border-light);
border-color: var(--border);
}
.nd-child-chip {
flex: 0 0 auto;
display: inline-block;
padding: 1px 7px;
border-radius: 8px;
color: #f8fafc;
font-size: 9px;
font-weight: 600;
letter-spacing: 0.3px;
}
.nd-child-text {
flex: 1 1 auto;
font-size: 12px;
line-height: 1.4;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
</style>

View file

@ -40,8 +40,10 @@
<canvas
ref="canvasRef"
class="overlay-canvas"
:class="{ selectable }"
@mousemove="onMouseMove"
@mouseleave="hoveredElement = null"
@click="onCanvasClick"
/>
<!-- Tooltip -->
<div v-if="hoveredElement" class="tooltip" :style="tooltipStyle">
@ -76,8 +78,39 @@ const ELEMENT_COLORS: Record<string, string> = {
const props = defineProps({
pages: { type: Array as () => Page[], default: () => [] },
documentId: String,
/**
* Reasoning-trace integration hooks. Optional when unset, StructureViewer
* renders like before (Studio "Structure" tab). When set, enables overlays
* for the reasoning viewer without forking the component:
*
* - `visitedBySelfRef`: elements whose `self_ref` is in this map render in
* the reasoning accent color with a numbered badge (the visit order).
* - `focusedSelfRef`: when it changes, auto-scroll to the page of that
* element and pulse its bbox briefly.
* - `selectable`: when true, clicking a bbox emits `elementFocus` so a
* parent can sync the selection with the graph view.
*/
visitedBySelfRef: {
type: Object as () => Map<string, number> | null,
default: null,
},
focusedSelfRef: { type: String as () => string | null, default: null },
selectable: { type: Boolean, default: false },
/**
* When true AND `visitedBySelfRef` is set, non-visited elements are drawn
* with reduced alpha so the visited ones pop. Matches the reasoning
* panel's "Focus" toggle behavior on the graph.
*/
dimNonVisited: { type: Boolean, default: false },
})
const emit = defineEmits<{
/** Fired when the user clicks a bbox — only if `selectable` is true. */
elementFocus: [selfRef: string]
}>()
const REASONING_COLOR = '#EA580C'
const selectedPage = ref(1)
const hiddenTypes = reactive(new Set<string>())
const containerRef = ref<HTMLDivElement | null>(null)
@ -136,39 +169,94 @@ function drawOverlay() {
const scale = computeScale(img.clientWidth, img.clientHeight, page.width, page.height)
// Two-pass draw so reasoning overlays (highlight + pulse) sit on top of
// the base element strokes without being painted over by subsequent
// elements. First pass = base, second pass = accents.
for (const el of visibleElements.value) {
const rect = bboxToRect(el.bbox, scale)
const color = ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text
const baseColor = ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text
const isVisited =
props.visitedBySelfRef !== null && !!el.self_ref && props.visitedBySelfRef.has(el.self_ref)
ctx.strokeStyle = color
ctx.lineWidth = 2
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
ctx.fillStyle = color + '20'
ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
if (isVisited) {
// Reasoning-visited element reasoning accent color, bolder stroke,
// more saturated fill than the base element. The visit-order badge
// is drawn in the second pass below.
ctx.strokeStyle = REASONING_COLOR
ctx.lineWidth = 3
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
ctx.fillStyle = REASONING_COLOR + '33'
ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
} else {
// Dim non-visited when focus mode is on and a visited set is present,
// so visited bboxes pop. Otherwise keep the regular styling.
const dim = props.dimNonVisited && props.visitedBySelfRef !== null
ctx.strokeStyle = baseColor + (dim ? '22' : '')
ctx.lineWidth = dim ? 1 : 2
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
ctx.fillStyle = baseColor + (dim ? '08' : '20')
ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
}
}
// Second pass numbered badges on visited elements + focus pulse ring.
for (const el of visibleElements.value) {
const rect = bboxToRect(el.bbox, scale)
const order =
props.visitedBySelfRef !== null && el.self_ref
? props.visitedBySelfRef.get(el.self_ref)
: undefined
if (order !== undefined) {
drawVisitBadge(ctx, rect.x, rect.y, order)
}
if (props.focusedSelfRef && el.self_ref === props.focusedSelfRef) {
ctx.strokeStyle = REASONING_COLOR
ctx.lineWidth = 2
ctx.setLineDash([6, 4])
ctx.strokeRect(rect.x - 4, rect.y - 4, rect.w + 8, rect.h + 8)
ctx.setLineDash([])
}
}
}
function drawVisitBadge(ctx: CanvasRenderingContext2D, x: number, y: number, order: number): void {
const radius = 10
const cx = x
const cy = y
ctx.fillStyle = REASONING_COLOR
ctx.beginPath()
ctx.arc(cx, cy, radius, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = '#ffffff'
ctx.font = 'bold 11px -apple-system, sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(String(order), cx, cy + 0.5)
}
function elementAt(e: MouseEvent): PageElement | null {
const canvas = canvasRef.value
const page = currentPageData.value
const img = imageRef.value
if (!canvas || !page || !img) return null
const canvasRect = canvas.getBoundingClientRect()
const mx = e.clientX - canvasRect.left
const my = e.clientY - canvasRect.top
const scale = computeScale(img.clientWidth, img.clientHeight, page.width, page.height)
for (const el of visibleElements.value) {
if (pointInRect(mx, my, bboxToRect(el.bbox, scale))) return el
}
return null
}
function onMouseMove(e: MouseEvent) {
const canvas = canvasRef.value
const page = currentPageData.value
const img = imageRef.value
if (!canvas || !page || !img) return
if (!canvas) return
const canvasRect = canvas.getBoundingClientRect()
const mx = e.clientX - canvasRect.left
const my = e.clientY - canvasRect.top
const scale = computeScale(img.clientWidth, img.clientHeight, page.width, page.height)
let found: PageElement | null = null
for (const el of visibleElements.value) {
if (pointInRect(mx, my, bboxToRect(el.bbox, scale))) {
found = el
break
}
}
const found = elementAt(e)
hoveredElement.value = found
if (found) {
tooltipStyle.value = {
@ -178,9 +266,51 @@ function onMouseMove(e: MouseEvent) {
}
}
function onCanvasClick(e: MouseEvent): void {
if (!props.selectable) return
const el = elementAt(e)
if (el?.self_ref) emit('elementFocus', el.self_ref)
}
watch([() => props.pages, selectedPage, hiddenTypes], () => {
nextTick(drawOverlay)
})
watch(
() => [props.visitedBySelfRef, props.dimNonVisited],
() => nextTick(drawOverlay),
)
// When the caller sets a focused self_ref (e.g. the user clicked a node in
// the graph), find which page that element lives on and jump to it. The
// overlay redraw will then show the dashed focus ring around its bbox.
function scrollToFocused(ref: string | null): void {
if (!ref) {
nextTick(drawOverlay)
return
}
for (const page of props.pages) {
if (page.elements.some((e) => e.self_ref === ref)) {
if (selectedPage.value !== page.page_number) {
selectedPage.value = page.page_number
// Let <img> reload before drawing drawOverlay runs on @load.
} else {
nextTick(drawOverlay)
}
return
}
}
// Ref not on any page (e.g. a #/body node) just redraw to clear the
// previous focus ring.
nextTick(drawOverlay)
}
watch(() => props.focusedSelfRef, scrollToFocused)
// Imperative entry point so callers can re-trigger a scroll on the same
// self_ref (the watch above only fires on value change). Used by the
// reasoning workspace when the user re-clicks the active iteration card.
defineExpose({ scrollToFocused })
</script>
<style scoped>
@ -282,6 +412,10 @@ watch([() => props.pages, selectedPage, hiddenTypes], () => {
pointer-events: auto;
}
.overlay-canvas.selectable {
cursor: pointer;
}
.tooltip {
position: absolute;
background: var(--bg-surface);

View file

@ -29,12 +29,12 @@ describe('useFeatureFlagStore', () => {
expect(store.isEnabled('chunking')).toBe(true)
})
it('disables chunking when engine is remote', async () => {
it('enables chunking when engine is remote', async () => {
mockApiFetch.mockResolvedValue({ status: 'ok', engine: 'remote' })
const store = useFeatureFlagStore()
await store.load()
expect(store.engine).toBe('remote')
expect(store.isEnabled('chunking')).toBe(false)
expect(store.isEnabled('chunking')).toBe(true)
})
it('enables disclaimer when deploymentMode is huggingface', async () => {

View file

@ -16,7 +16,7 @@ interface HealthResponse {
ingestionAvailable?: boolean
}
export type FeatureFlag = 'chunking' | 'disclaimer' | 'ingestion'
export type FeatureFlag = 'chunking' | 'disclaimer' | 'ingestion' | 'reasoning'
interface FeatureFlagDef {
description: string
@ -32,7 +32,7 @@ interface FeatureFlagContext {
const featureRegistry: Record<FeatureFlag, FeatureFlagDef> = {
chunking: {
description: 'Document chunking for RAG preparation',
isEnabled: (ctx) => ctx.engine === 'local',
isEnabled: (ctx) => ctx.engine !== null,
},
disclaimer: {
description: 'Show shared-instance disclaimer banner',
@ -42,6 +42,13 @@ const featureRegistry: Record<FeatureFlag, FeatureFlagDef> = {
description: 'OpenSearch ingestion pipeline (embedding + vector indexing)',
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', () => {

View file

@ -23,6 +23,6 @@ describe('useFeatureFlag', () => {
expect(flag.value).toBe(true)
store.$patch({ engine: 'remote' })
expect(flag.value).toBe(false)
expect(flag.value).toBe(true)
})
})

View file

@ -0,0 +1,41 @@
import { apiFetch } from '../../shared/api/http'
import type { GraphPayload } from '../analysis/graphApi'
import type { RAGResult } from './types'
/**
* 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`)
}
/**
* Kick off a `docling-agent` RAG run against a document and wait for the
* `RAGResult` (no streaming yet the backend blocks on `_rag_loop` and
* returns once the loop converges or hits `max_iterations`).
*
* Runs typically take 2040s depending on the model + Ollama latency. The
* caller should show a loading state.
*
* Errors:
* - 503 if `RAG_ENABLED=false` server-side or docling-agent isn't installed
* - 404 if no completed analysis exists for the doc
* - 500 if the loop itself raises (Ollama unreachable, model missing, )
*/
export function runReasoning(docId: string, query: string, modelId?: string): Promise<RAGResult> {
return apiFetch<RAGResult>(`/api/documents/${encodeURIComponent(docId)}/rag`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
// Backend accepts snake_case; don't camelCase here.
model_id: modelId || undefined,
}),
})
}

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,156 @@
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)
// Separate modal for the live runner (POST /api/documents/:id/rag), so it
// can coexist with the import dialog conceptually even if only one is ever
// open at a time.
const runDialogOpen = ref(false)
const running = 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
}
function openRunDialog(): void {
runDialogOpen.value = true
}
function closeRunDialog(): void {
runDialogOpen.value = false
}
function setRunning(v: boolean): void {
running.value = v
}
/**
* 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 {
// Pure state — drives the active-card highlight in the iteration list.
// Side effects (graph pan, PDF scroll) are dispatched imperatively from
// ReasoningWorkspace.onIterationFocus so re-clicking the same iteration
// still re-focuses both views (a watch here would no-op on same value).
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
runDialogOpen.value = false
running.value = false
focusMode.value = true
}
return {
// state
importDialogOpen,
runDialogOpen,
running,
rawResult,
envelope,
overlay,
activeIteration,
error,
focusMode,
// computed
hasTrace,
iterations,
presentCount,
missingCount,
// actions
openImportDialog,
closeImportDialog,
openRunDialog,
closeRunDialog,
setRunning,
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,107 @@
<template>
<div class="rdv-root" data-e2e="reasoning-document-view">
<div v-if="!pages || pages.length === 0" class="rdv-empty">
{{ t('reasoning.docNoContent') }}
</div>
<StructureViewer
v-else
ref="structureViewerRef"
:pages="pages"
:document-id="docId"
:visited-by-self-ref="visitedBySelfRef"
:focused-self-ref="focusedSelfRef"
:dim-non-visited="reasoningStore.focusMode"
selectable
class="rdv-viewer"
@element-focus="(ref) => emit('elementFocus', ref)"
/>
</div>
</template>
<script setup lang="ts">
/**
* PDF rendering of the document (per-page PNG via /api/documents/:id/preview),
* augmented with reasoning overlays:
* - elements visited by the RAG loop get a bold orange stroke + numbered
* badge showing the visit order
* - the current `focusedSelfRef` (usually driven by a graph-node click
* upstream) auto-jumps to the right page and pulses its bbox
* - clicking a bbox emits `elementFocus` so the graph can mirror the
* selection (bidirectional sync handled in ReasoningWorkspace).
*/
import { computed, ref } from 'vue'
import StructureViewer from '../../analysis/ui/StructureViewer.vue'
import { useAnalysisStore } from '../../analysis/store'
import { useI18n } from '../../../shared/i18n'
import type { Page } from '../../../shared/types'
import { useReasoningStore } from '../store'
const props = defineProps<{
docId: string
focusedSelfRef: string | null
}>()
const emit = defineEmits<{ elementFocus: [selfRef: string] }>()
const structureViewerRef = ref<InstanceType<typeof StructureViewer> | null>(null)
// Imperative passthrough so the reasoning workspace can re-trigger a scroll
// on the same self_ref (e.g. user re-clicks the active iteration card
// the prop hasn't changed so the watch wouldn't fire).
function scrollToFocused(selfRef: string | null): void {
structureViewerRef.value?.scrollToFocused(selfRef)
}
defineExpose({ scrollToFocused })
const analysisStore = useAnalysisStore()
const reasoningStore = useReasoningStore()
const { t } = useI18n()
const pages = computed<Page[]>(() => {
const hit = analysisStore.analyses.find(
(a) => a.documentId === props.docId && a.status === 'COMPLETED' && a.pagesJson,
)
if (!hit?.pagesJson) return []
try {
return JSON.parse(hit.pagesJson) as Page[]
} catch {
return []
}
})
const visitedBySelfRef = computed<Map<string, number>>(() => {
const out = new Map<string, number>()
for (const it of reasoningStore.iterations) {
if (!it.present || !it.sectionRef) continue
if (!out.has(it.sectionRef)) out.set(it.sectionRef, it.iteration)
}
return out
})
</script>
<style scoped>
.rdv-root {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow-y: auto;
background: var(--bg);
padding: 16px 20px;
}
.rdv-viewer {
max-width: 960px;
margin: 0 auto;
}
.rdv-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 13px;
font-style: italic;
}
</style>

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,173 @@
<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" :class="{ placeholder: isPlaceholderReason }">
{{ isPlaceholderReason ? t('reasoning.reasonPlaceholder') : iteration.reason }}
</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')
})
// docling-agent emits the literal string "fallback" for `reason` when its
// `select_from_failure` branch runs (the model's structured output didn't
// parse N times in a row). Don't show that noise render a dash-style
// placeholder the user can visually skip.
const isPlaceholderReason = computed(() => {
const r = (props.iteration.reason || '').trim().toLowerCase()
return r === '' || r === 'fallback'
})
</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-reason.placeholder {
color: var(--text-muted);
font-style: italic;
}
.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,485 @@
<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-answer-label">{{ t('reasoning.answerLabel') }}</span>
<span class="rp-answer-actions">
<span class="rp-converged" :class="{ yes: result.converged, no: !result.converged }">
{{ result.converged ? t('reasoning.converged') : t('reasoning.notConverged') }}
</span>
<button
class="rp-copy-btn"
:title="t('reasoning.copyAnswer')"
data-e2e="reasoning-copy-answer"
@click="copyAnswer"
>
{{ copied ? t('reasoning.copied') : t('reasoning.copy') }}
</button>
</span>
</div>
<!-- eslint-disable-next-line vue/no-v-html -- sanitized by DOMPurify -->
<div class="rp-answer-body markdown-body" v-html="renderedAnswer" />
<div class="rp-answer-footer">
<span class="rp-stats">
{{ store.presentCount }} / {{ store.iterations.length }} {{ t('reasoning.resolved') }}
</span>
</div>
</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="(n) => emit('iterationFocus', n)"
/>
</div>
</section>
</aside>
<ImportTraceDialog />
</template>
<script setup lang="ts">
import type { Core } from 'cytoscape'
import DOMPurify from 'dompurify'
import { marked } from 'marked'
import { computed, ref, watch } from 'vue'
import { useI18n } from '../../../shared/i18n'
import {
applyReasoningOverlay,
buildDegradedOverlay,
clearReasoningOverlay,
} 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
}>()
// Iteration clicks bubble up to the workspace, which dispatches focus to
// both the graph and the PDF directly keeping the panel ignorant of its
// siblings and avoiding watch-based plumbing that misfires on repeat clicks.
const emit = defineEmits<{ iterationFocus: [iteration: number] }>()
const store = useReasoningStore()
const { t } = useI18n()
const result = computed(() => store.rawResult)
const envelope = computed(() => store.envelope)
// Render the answer as markdown so numbered lists, bold, etc. render properly.
// Models tend to produce markdown-formatted answers (numbered lists especially),
// and plain-text `pre-wrap` made them near-unreadable.
const renderedAnswer = computed(() => {
const raw = result.value?.answer ?? ''
if (!raw.trim()) return ''
return DOMPurify.sanitize(marked.parse(raw, { async: false }) as string)
})
const copied = ref(false)
let copyResetTimer: ReturnType<typeof setTimeout> | null = null
async function copyAnswer(): Promise<void> {
const text = result.value?.answer
if (!text) return
try {
await navigator.clipboard.writeText(text)
copied.value = true
if (copyResetTimer) clearTimeout(copyResetTimer)
copyResetTimer = setTimeout(() => {
copied.value = false
}, 1800)
} catch (e) {
console.warn('Copy failed', e)
}
}
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 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: 10px;
padding: 14px 16px;
background: var(--bg);
border: 1px solid #ea580c;
border-radius: var(--radius);
box-shadow: 0 1px 3px rgba(234, 88, 12, 0.08);
}
.rp-answer-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.rp-answer-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.8px;
text-transform: uppercase;
color: #ea580c;
}
.rp-answer-actions {
display: inline-flex;
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-copy-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
padding: 2px 8px;
font-size: 10px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition);
}
.rp-copy-btn:hover {
background: var(--border-light);
color: var(--text);
}
.rp-stats {
font-size: 10px;
color: var(--text-muted);
font-family: 'IBM Plex Mono', monospace;
}
.rp-answer-footer {
display: flex;
justify-content: flex-end;
border-top: 1px solid var(--border-light);
padding-top: 6px;
}
/* Markdown-rendered answer body. Mirrors a subset of MarkdownViewer styles,
* tuned for a narrow right-rail context (tighter sizes than the full viewer). */
.rp-answer-body {
font-size: 13.5px;
line-height: 1.6;
color: var(--text);
}
.rp-answer-body :deep(p) {
margin: 0 0 8px;
}
.rp-answer-body :deep(p:last-child) {
margin-bottom: 0;
}
.rp-answer-body :deep(ol),
.rp-answer-body :deep(ul) {
margin: 4px 0 8px;
padding-left: 22px;
}
.rp-answer-body :deep(li) {
margin: 2px 0;
}
.rp-answer-body :deep(strong) {
color: var(--text);
font-weight: 600;
}
.rp-answer-body :deep(code) {
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
background: var(--border-light);
padding: 1px 5px;
border-radius: 3px;
}
.rp-answer-body :deep(pre) {
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
background: var(--border-light);
padding: 8px 10px;
border-radius: var(--radius-sm);
overflow-x: auto;
margin: 6px 0;
}
.rp-answer-body :deep(h1),
.rp-answer-body :deep(h2),
.rp-answer-body :deep(h3),
.rp-answer-body :deep(h4) {
margin: 10px 0 4px;
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.rp-answer-body :deep(a) {
color: #ea580c;
text-decoration: underline;
}
.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,283 @@
<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>
<!-- Main-pane toggle: graph vs docling markdown. Overlay panel on the
right stays mounted in both modes so the iteration list keeps
living context only the main pane swaps. -->
<div class="rw-mode-switch" role="tablist" :aria-label="t('reasoning.modeSwitchLabel')">
<button
type="button"
role="tab"
class="rw-mode-btn"
:class="{ active: mode === 'graph' }"
:aria-selected="mode === 'graph'"
data-e2e="reasoning-mode-graph"
@click="mode = 'graph'"
>
{{ t('reasoning.modeGraph') }}
</button>
<button
type="button"
role="tab"
class="rw-mode-btn"
:class="{ active: mode === 'document' }"
:aria-selected="mode === 'document'"
data-e2e="reasoning-mode-document"
@click="mode = 'document'"
>
{{ t('reasoning.modeDocument') }}
</button>
</div>
<button
class="rw-action-btn rw-action-ghost"
data-e2e="reasoning-workspace-import"
@click="reasoningStore.openImportDialog()"
>
{{ t('reasoning.importBtn') }}
</button>
<button
class="rw-action-btn"
data-e2e="reasoning-workspace-run"
@click="reasoningStore.openRunDialog()"
>
{{ t('reasoning.runBtn') }}
</button>
</header>
<div class="rw-body">
<!-- Keep GraphView mounted via v-show rather than v-if so the Cytoscape
instance + its layout state survive a toggle to document mode and
back rebuilding is expensive and would reset pan/zoom. -->
<GraphView
v-show="mode === 'graph'"
ref="graphViewRef"
:doc-id="docId"
:fetcher="fetchReasoningGraph"
@node-focus="onGraphNodeFocus"
/>
<!-- v-show (not v-if) so the StructureViewer's scroll-to-focused watch
sees transitions from null sectionRef that happen while we're in
graph mode. Otherwise the viewer mounts with an already-set prop
and the initial scroll never fires. -->
<DocumentView
v-show="mode === 'document'"
ref="documentViewRef"
:doc-id="docId"
:focused-self-ref="focusedSelfRef"
@element-focus="onPdfElementFocus"
/>
<ReasoningPanel :cy="graphCy" @iteration-focus="onIterationFocus" />
</div>
<RunReasoningDialog :doc-id="docId" :doc-filename="docFilename" />
</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 { focusIteration, nodeIdForSectionRef } from '../graphReasoningOverlay'
import { useReasoningStore } from '../store'
import DocumentView from './DocumentView.vue'
import ReasoningPanel from './ReasoningPanel.vue'
import RunReasoningDialog from './RunReasoningDialog.vue'
type WorkspaceMode = 'graph' | 'document'
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)
const documentViewRef = ref<InstanceType<typeof DocumentView> | null>(null)
const mode = ref<WorkspaceMode>('graph')
// Shared focused element (Docling self_ref like "#/texts/12") the one
// bridge between graph and PDF. Clicking a node in the graph sets this,
// clicking a bbox in the PDF sets this. When set, both views highlight
// the corresponding element. Persists across mode toggles so jumping from
// Graph Document preserves the currently-looked-at element.
const focusedSelfRef = ref<string | null>(null)
function onGraphNodeFocus(selfRef: string | null): void {
focusedSelfRef.value = selfRef
}
function onPdfElementFocus(selfRef: string): void {
focusedSelfRef.value = selfRef
// Mirror the selection on the graph side if the user switches back to
// graph mode, they'll see the same element selected + centered.
graphViewRef.value?.selectBySelfRef(selfRef)
}
// Click on an iteration card in the reasoning panel. We dispatch focus to
// both the graph and the PDF imperatively (rather than via watches on
// `activeIteration` / `focusedSelfRef`). Watches misfire when the user
// re-clicks the same iteration: Vue collapses synchronous "flip via null"
// mutations and only sees the final value, equal to the previous one so
// nothing scrolls. Calling `focusIteration` and `scrollToFocused` directly
// works on every click regardless of state.
function onIterationFocus(iteration: number): void {
reasoningStore.setActiveIteration(iteration)
const hit = reasoningStore.iterations.find((i) => i.iteration === iteration)
if (!hit?.present || !hit.sectionRef) return
if (graphCy.value) focusIteration(graphCy.value, nodeIdForSectionRef(hit.sectionRef))
focusedSelfRef.value = hit.sectionRef
documentViewRef.value?.scrollToFocused(hit.sectionRef)
}
// Reset the reasoning store when switching docs a trace imported for one
// document is meaningless on another. The main-pane mode resets too so a
// new doc opens on the graph (consistent default).
watch(
() => props.docId,
() => {
reasoningStore.reset()
mode.value = 'graph'
focusedSelfRef.value = null
},
)
// 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);
}
/* Secondary action next to the primary Run button — import is a rarer path. */
.rw-action-ghost {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.rw-action-ghost:hover {
background: var(--border-light);
color: var(--text);
filter: none;
}
/* Segmented control for the main-pane mode (graph vs document). Sits
* between the doc title and the action buttons. */
.rw-mode-switch {
display: inline-flex;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.rw-mode-btn {
background: transparent;
border: 0;
padding: 5px 12px;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition);
}
.rw-mode-btn + .rw-mode-btn {
border-left: 1px solid var(--border);
}
.rw-mode-btn:hover:not(.active) {
background: var(--border-light);
color: var(--text);
}
.rw-mode-btn.active {
background: var(--accent);
color: #fff;
font-weight: 500;
}
.rw-body {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: row;
overflow: hidden;
}
.rw-body > :deep(.graph-view),
.rw-body > :deep(.rdv-root) {
flex: 1 1 auto;
min-width: 0;
}
</style>

View file

@ -0,0 +1,296 @@
<template>
<div
v-if="store.runDialogOpen"
class="run-modal-backdrop"
data-e2e="reasoning-run-modal"
@click.self="close"
>
<div class="run-modal" role="dialog" aria-modal="true" :aria-label="t('reasoning.runTitle')">
<div class="run-modal-header">
<h3>{{ t('reasoning.runTitle') }}</h3>
<button
class="run-modal-close"
:aria-label="t('reasoning.close')"
:disabled="store.running"
@click="close"
>
</button>
</div>
<p class="run-modal-hint">{{ t('reasoning.runHint') }}</p>
<label class="run-field">
<span class="run-field-label">{{ t('reasoning.runQueryLabel') }}</span>
<textarea
v-model="query"
class="run-field-input"
rows="3"
:placeholder="t('reasoning.runQueryPlaceholder')"
:disabled="store.running"
data-e2e="reasoning-run-query"
/>
</label>
<label class="run-field">
<span class="run-field-label">{{ t('reasoning.runModelLabel') }}</span>
<input
v-model="modelId"
type="text"
class="run-field-input"
:placeholder="t('reasoning.runModelPlaceholder')"
:disabled="store.running"
data-e2e="reasoning-run-model"
/>
<span class="run-field-sub">{{ t('reasoning.runModelSub') }}</span>
</label>
<div v-if="store.running" class="run-loading" data-e2e="reasoning-run-loading">
<div class="spinner" />
<span>{{ t('reasoning.running') }}</span>
</div>
<div v-if="errorMsg" class="run-modal-error" data-e2e="reasoning-run-error">
{{ errorMsg }}
</div>
<div class="run-modal-actions">
<button class="run-ghost" :disabled="store.running" @click="close">
{{ t('reasoning.cancel') }}
</button>
<button
class="run-primary"
:disabled="!query.trim() || store.running"
data-e2e="reasoning-run-submit"
@click="submit"
>
{{ t('reasoning.runSubmit') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from '../../../shared/i18n'
import { runReasoning } from '../api'
import { useReasoningStore } from '../store'
import type { SidecarEnvelope } from '../types'
const props = defineProps<{ docId: string; docFilename?: string | null }>()
const store = useReasoningStore()
const { t } = useI18n()
const query = ref('')
const modelId = ref('')
const errorMsg = ref<string | null>(null)
function close(): void {
if (store.running) return // don't let the user close mid-run
store.closeRunDialog()
errorMsg.value = null
}
async function submit(): Promise<void> {
const q = query.value.trim()
if (!q) return
errorMsg.value = null
store.setRunning(true)
try {
const result = await runReasoning(props.docId, q, modelId.value.trim() || undefined)
// Synthesize a sidecar-like envelope so the panel can show what was asked
// and which model answered, same as an imported trace.
const envelope: SidecarEnvelope = {
filename: props.docFilename ?? undefined,
query: q,
model: modelId.value.trim()
? { ollama_name: modelId.value.trim(), hf_model_name: null }
: undefined,
result,
}
store.setResult(result, envelope)
// Keep the query for the user's reference but close the dialog.
store.closeRunDialog()
} catch (e) {
errorMsg.value = (e as Error).message || t('reasoning.runErrUnknown')
} finally {
store.setRunning(false)
}
}
</script>
<style scoped>
.run-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;
}
.run-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);
display: flex;
flex-direction: column;
gap: 14px;
}
.run-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.run-modal-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.run-modal-close {
background: transparent;
border: 0;
color: var(--text-muted);
font-size: 16px;
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-sm);
}
.run-modal-close:hover:not(:disabled) {
background: var(--border-light);
color: var(--text);
}
.run-modal-close:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.run-modal-hint {
font-size: 13px;
color: var(--text-muted);
margin: 0;
}
.run-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.run-field-label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.run-field-input {
width: 100%;
padding: 8px;
font-size: 13px;
font-family: inherit;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
resize: vertical;
}
.run-field-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.run-field-sub {
font-size: 11px;
color: var(--text-muted);
}
.run-loading {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--text-secondary);
padding: 8px 12px;
background: var(--accent-muted, rgba(234, 88, 12, 0.08));
border-radius: var(--radius-sm);
}
.run-modal-error {
padding: 8px 12px;
border-radius: var(--radius-sm);
background: rgba(220, 38, 38, 0.08);
color: var(--error, #dc2626);
font-size: 12px;
font-family: 'IBM Plex Mono', monospace;
word-break: break-word;
}
.run-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.run-primary {
background: var(--accent);
color: white;
border: 0;
padding: 7px 16px;
font-size: 13px;
font-weight: 500;
border-radius: var(--radius-sm);
cursor: pointer;
}
.run-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.run-ghost {
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
padding: 7px 14px;
font-size: 13px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.run-ghost:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spinner {
width: 18px;
height: 18px;
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,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

@ -84,6 +84,27 @@
</svg>
{{ t('studio.ingest') }}
</button>
<button
v-if="chunkingEnabled && ingestionEnabled && ingestionStore.available"
class="toggle-btn"
data-e2e="toggle-btn maintain-btn"
:class="{ active: mode === 'maintain' }"
@click="mode = 'maintain'"
:disabled="!analysisStore.currentAnalysis"
>
<svg class="toggle-icon" viewBox="0 0 20 20" fill="currentColor">
<path
d="M10 3.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM6 10a4 4 0 118 0 4 4 0 01-8 0zm4-2a2 2 0 100 4 2 2 0 000-4z"
/>
<path
d="M10 1v2M10 17v2M1 10h2M17 10h2M3.5 3.5l1.4 1.4M15.1 15.1l1.4 1.4M3.5 16.5l1.4-1.4M15.1 4.9l1.4-1.4"
stroke="currentColor"
stroke-width="1.5"
fill="none"
/>
</svg>
{{ t('studio.maintain') }}
</button>
</div>
</div>
<div class="topbar-actions">
@ -475,6 +496,11 @@
:chunk-count="analysisStore.currentChunks?.length ?? 0"
/>
</div>
<!-- MAINTAIN MODE -->
<div v-if="mode === 'maintain'" class="maintain-panel">
<GraphView :doc-id="analysisStore.currentAnalysis?.documentId ?? null" />
</div>
</div>
</div>
</div>
@ -489,6 +515,7 @@ import { useIngestionStore } from '../features/ingestion/store'
import { DocumentUpload, DocumentList } from '../features/document/index'
import { ResultTabs } from '../features/analysis/index'
import BboxOverlay from '../features/analysis/ui/BboxOverlay.vue'
import GraphView from '../features/analysis/ui/GraphView.vue'
import { ChunkPanel } from '../features/chunking'
import { IngestPanel } from '../features/ingestion'
import { useFeatureFlag } from '../features/feature-flags'
@ -1404,10 +1431,11 @@ onBeforeUnmount(() => {
padding-top: 16px;
}
/* Verify / Prepare / Ingest panels */
/* Verify / Prepare / Ingest / Maintain panels */
.verify-panel,
.prepare-panel,
.ingest-panel-wrapper {
.ingest-panel-wrapper,
.maintain-panel {
height: 100%;
overflow: hidden;
display: flex;

View file

@ -11,6 +11,7 @@ const messages: Messages = {
'nav.studio': 'Studio',
'nav.documents': 'Documents',
'nav.history': 'Historique',
'nav.reasoning': 'Raisonnement',
'nav.settings': 'Paramètres',
'nav.collapse': 'Réduire la barre latérale',
'nav.expand': 'Développer la barre latérale',
@ -81,6 +82,17 @@ const messages: Messages = {
'results.elements': 'Éléments',
'results.markdown': 'Markdown',
'results.images': 'Images',
'results.graph': 'Graphe',
'results.graphLoading': 'Chargement du graphe…',
'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})',
'graph.contains': 'Contenu ({n})',
'results.retry': 'Réessayer',
'results.pageOf': 'Page {current} sur {total}',
'results.noElements': 'Aucun élément détecté sur cette page',
'results.noImages': 'Aucune image détectée dans ce document',
@ -110,6 +122,86 @@ const messages: Messages = {
// Chunking
'studio.prepare': 'Préparer',
'studio.ingest': 'Ingérer',
'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.answerLabel': 'Réponse',
'reasoning.copy': 'Copier',
'reasoning.copied': 'Copié ✓',
'reasoning.copyAnswer': 'Copier la réponse dans le presse-papier',
'reasoning.reasonPlaceholder': '— pas de justification structurée',
'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.modeSwitchLabel': 'Mode d\u2019affichage',
'reasoning.modeGraph': 'Graphe',
'reasoning.modeDocument': 'Document',
'reasoning.docNoContent': 'Aucun contenu rendu disponible pour ce 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.runBtn': 'Lancer le reasoning',
'reasoning.runTitle': 'Lancer docling-agent',
'reasoning.runHint':
'Pose une question au document. Le backend appelle docling-agent via Ollama et renvoie la trace dès que la boucle converge (20-40s).',
'reasoning.runQueryLabel': 'Question',
'reasoning.runQueryPlaceholder': 'Ex : Quelles sont les obligations du fournisseur ?',
'reasoning.runModelLabel': 'Modèle (optionnel)',
'reasoning.runModelPlaceholder': 'gpt-oss:20b',
'reasoning.runModelSub':
'Nom du modèle Ollama. Laisser vide pour utiliser le défaut serveur (RAG_MODEL_ID).',
'reasoning.runSubmit': 'Lancer',
'reasoning.running': 'docling-agent tourne... (20-40s)',
'reasoning.runErrUnknown': 'Erreur inconnue lors de l\u2019appel à docling-agent.',
'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.chunkerType': 'Type de chunker',
'chunking.maxTokens': 'Tokens max',
@ -189,6 +281,7 @@ const messages: Messages = {
'nav.studio': 'Studio',
'nav.documents': 'Documents',
'nav.history': 'History',
'nav.reasoning': 'Reasoning',
'nav.settings': 'Settings',
'nav.collapse': 'Collapse sidebar',
'nav.expand': 'Expand sidebar',
@ -253,6 +346,17 @@ const messages: Messages = {
'results.elements': 'Elements',
'results.markdown': 'Markdown',
'results.images': 'Images',
'results.graph': 'Graph',
'results.graphLoading': 'Loading graph…',
'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})',
'graph.contains': 'Contents ({n})',
'results.retry': 'Retry',
'results.pageOf': 'Page {current} of {total}',
'results.noElements': 'No elements detected on this page',
'results.noImages': 'No images detected in this document',
@ -279,6 +383,84 @@ const messages: Messages = {
'studio.prepare': 'Prepare',
'studio.ingest': 'Ingest',
'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.answerLabel': 'Answer',
'reasoning.copy': 'Copy',
'reasoning.copied': 'Copied ✓',
'reasoning.copyAnswer': 'Copy answer to clipboard',
'reasoning.reasonPlaceholder': '— no structured rationale',
'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.modeSwitchLabel': 'View mode',
'reasoning.modeGraph': 'Graph',
'reasoning.modeDocument': 'Document',
'reasoning.docNoContent': 'No rendered content available for this document.',
'reasoning.analyzing': 'Analyzing document...',
'reasoning.analyzingHint':
'Docling is parsing the PDF with default settings. May take 13 minutes depending on size.',
'reasoning.runBtn': 'Run reasoning',
'reasoning.runTitle': 'Run docling-agent',
'reasoning.runHint':
'Ask a question against this document. The backend calls docling-agent over Ollama and returns the trace once the loop converges (2040s).',
'reasoning.runQueryLabel': 'Question',
'reasoning.runQueryPlaceholder': 'e.g. What are the supplier obligations?',
'reasoning.runModelLabel': 'Model (optional)',
'reasoning.runModelPlaceholder': 'gpt-oss:20b',
'reasoning.runModelSub':
'Ollama model name. Leave empty to use the server default (RAG_MODEL_ID).',
'reasoning.runSubmit': 'Run',
'reasoning.running': 'docling-agent is thinking… (2040s)',
'reasoning.runErrUnknown': 'Unknown error while calling docling-agent.',
'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.chunkerType': 'Chunker type',
'chunking.maxTokens': 'Max tokens',

View file

@ -67,6 +67,10 @@ export interface PageElement {
bbox: [number, number, number, number]
content: string
level: number
/** Docling `self_ref` "#/texts/12", "#/tables/3", etc. Empty string for
* items that don't have one (rare). Lets callers correlate a bbox with
* the matching graph node without fuzzy bbox matching. */
self_ref?: string
}
// Backend serializes with snake_case (dataclasses.asdict)

View file

@ -62,6 +62,23 @@
<span class="nav-label">{{ t('nav.search') }}</span>
</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
to="/history"
class="nav-item"
@ -138,6 +155,7 @@ import { useIngestionStore } from '../../features/ingestion/store'
const featureStore = useFeatureFlagStore()
const ingestionStore = useIngestionStore()
const ingestionEnabled = computed(() => featureStore.isEnabled('ingestion'))
const reasoningEnabled = computed(() => featureStore.isEnabled('reasoning'))
const version = computed(() => featureStore.appVersion)
const route = useRoute()
const { t } = useI18n()