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
This commit is contained in:
Pier-Jean Malandrino 2026-04-17 15:40:08 +02:00
parent c1d3a687ac
commit c2550867b7
18 changed files with 1114 additions and 9 deletions

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

@ -0,0 +1,76 @@
"""Graph API — returns a cytoscape-shaped view of the Neo4j graph for a doc.
v0.5 contract:
- Returns the **full** graph for the document (see design §8.4)
- Hard cap at 200 pages; beyond that, HTTP 413 with `truncated: true`
- No pagination (ships in v0.6)
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
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,
)

View file

@ -75,6 +75,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
@ -82,3 +90,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

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

@ -4,7 +4,9 @@ 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,
@ -14,13 +16,16 @@ from infra.neo4j.tree_reader import (
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,133 @@
"""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 Any
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:
async with 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,213 @@
"""Reusable Cypher queries — kept out of the API layer for reuse + testing."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
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.
# The graph is returned as flat node + edge lists ready for cytoscape.
_FETCH_GRAPH = """
MATCH (d:Document {id: $doc_id})
OPTIONAL MATCH (d)-[:HAS_ROOT]->(root:Element)
OPTIONAL MATCH (e:Element {doc_id: $doc_id})
OPTIONAL MATCH (p:Page {doc_id: $doc_id})
OPTIONAL MATCH (c:Chunk {doc_id: $doc_id})
WITH d,
collect(DISTINCT e) AS elements,
collect(DISTINCT p) AS pages,
collect(DISTINCT c) AS chunks
OPTIONAL MATCH (pe:Element {doc_id: $doc_id})-[r_po:PARENT_OF]->(ce:Element {doc_id: $doc_id})
OPTIONAL MATCH (a:Element {doc_id: $doc_id})-[r_nx:NEXT]->(b:Element {doc_id: $doc_id})
OPTIONAL MATCH (er:Element {doc_id: $doc_id})-[r_op:ON_PAGE]->(pr:Page {doc_id: $doc_id})
OPTIONAL MATCH (d)-[r_hr:HAS_ROOT]->(rr:Element {doc_id: $doc_id})
OPTIONAL MATCH (d)-[r_hc:HAS_CHUNK]->(rc:Chunk {doc_id: $doc_id})
OPTIONAL MATCH (cc:Chunk {doc_id: $doc_id})-[r_df:DERIVED_FROM]->(ee:Element {doc_id: $doc_id})
RETURN
d AS document,
elements, pages, chunks,
collect(DISTINCT {from: pe.self_ref, to: ce.self_ref, order: r_po.order, type: 'PARENT_OF'}) AS parent_edges,
collect(DISTINCT {from: a.self_ref, to: b.self_ref, type: 'NEXT'}) AS next_edges,
collect(DISTINCT {from: er.self_ref, to: pr.page_no, type: 'ON_PAGE'}) AS on_page_edges,
collect(DISTINCT {from: d.id, to: rr.self_ref, type: 'HAS_ROOT'}) AS has_root_edges,
collect(DISTINCT {from: d.id, to: rc.id, type: 'HAS_CHUNK'}) AS has_chunk_edges,
collect(DISTINCT {from: cc.id, to: ee.self_ref, type: 'DERIVED_FROM'}) AS derived_from_edges
"""
def _element_node(doc_id: str, e: dict[str, Any]) -> 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.
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": e.get("prov_page"),
"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.).
for e in record["elements"] or []:
if e is None:
continue
labels = [label for label in e.labels if label != "Element"]
node = _element_node(doc_id, dict(e))
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

@ -117,7 +117,7 @@ async def _init_neo4j():
return None
def _build_ingestion_service() -> IngestionService | 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)")
@ -139,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(
@ -172,7 +172,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
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)
@ -210,6 +210,11 @@ 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)
@app.get("/api/health", response_model=HealthResponse)
async def health() -> HealthResponse:

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],
}

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

@ -11,7 +11,11 @@ import os
import pytest
from infra.neo4j import close_driver, get_driver
# 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 # noqa: E402
def _cfg() -> tuple[str, str, str]:

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 written by TreeWriter and ChunkWriter should be present.
assert {"HAS_ROOT", "PARENT_OF", "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

@ -1,13 +1,15 @@
{
"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",
"dompurify": "^3.3.3",
"marked": "^17.0.4",
"pinia": "^2.3.0",
@ -16,6 +18,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 +1025,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 +1845,37 @@
"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/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 +2304,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 +2462,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,8 @@
"format:check": "prettier --check src/"
},
"dependencies": {
"cytoscape": "^3.30.0",
"cytoscape-dagre": "^2.5.0",
"dompurify": "^3.3.3",
"marked": "^17.0.4",
"pinia": "^2.3.0",
@ -24,6 +26,8 @@
},
"devDependencies": {
"@eslint/js": "^9.0.0",
"@types/cytoscape": "^3.21.4",
"@types/cytoscape-dagre": "^2.3.3",
"@vitest/mocker": "^4.1.2",
"@types/dompurify": "^3.2.0",
"@vitejs/plugin-vue": "^6.0.5",

View file

@ -0,0 +1,40 @@
import { apiFetch } from '../../shared/api/http'
export interface GraphNode {
id: string
group: 'document' | 'element' | 'page' | 'chunk'
label?: string
docling_label?: string
self_ref?: string
text?: string
prov_page?: number | null
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,341 @@
<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">
<span class="legend-chip legend-document">Document</span>
<span class="legend-chip legend-section">Section</span>
<span class="legend-chip legend-paragraph">Paragraph</span>
<span class="legend-chip legend-table">Table</span>
<span class="legend-chip legend-figure">Figure</span>
<span class="legend-chip legend-page">Page</span>
<span class="legend-chip legend-chunk">Chunk</span>
</span>
</div>
<div ref="containerRef" class="graph-canvas" data-e2e="graph-canvas" />
</template>
</div>
</template>
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from '../../../shared/i18n'
import { fetchDocumentGraph, type GraphPayload } from '../graphApi'
const props = defineProps<{ docId: 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)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let cy: any | 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 fetchDocumentGraph(props.docId)
if (!payload.value.nodes.length) {
empty.value = true
} else {
// Wait for template to render the canvas before initializing Cytoscape.
await new Promise((r) => requestAnimationFrame(r))
await renderGraph()
}
} 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 import keeps cytoscape out of the main chunk.
const [{ default: cytoscape }, { default: dagre }] = await Promise.all([
import('cytoscape'),
import('cytoscape-dagre'),
])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(cytoscape as any).use(dagre)
if (cy) {
cy.destroy()
cy = null
}
const elements = [
...payload.value.nodes.map((n) => ({
data: {
id: n.id,
label: nodeLabel(n),
bg: nodeColor(n),
group: n.group,
raw: n,
},
})),
...payload.value.edges.map((e) => ({
data: {
id: e.id,
source: e.source,
target: e.target,
type: e.type,
},
})),
]
cy = 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' },
},
],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
layout: {
name: 'dagre',
rankDir: 'TB',
nodeSep: 30,
edgeSep: 10,
rankSep: 40,
} as any,
wheelSensitivity: 0.15,
})
}
function disposeGraph(): void {
if (cy) {
cy.destroy()
cy = null
}
}
onMounted(load)
onBeforeUnmount(disposeGraph)
watch(
() => props.docId,
() => {
disposeGraph()
load()
},
)
</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;
}
.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-canvas {
flex: 1;
min-height: 0;
background: var(--bg);
}
.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

@ -106,6 +106,12 @@
<!-- IMAGES -->
<ImageGallery v-else-if="activeTab === 'images'" :pages="currentPageAsArray" />
<!-- GRAPH -->
<GraphView
v-else-if="activeTab === 'graph'"
:doc-id="store.currentAnalysis?.documentId ?? null"
/>
</div>
</div>
<div v-else-if="store.currentAnalysis?.status === 'RUNNING'" class="result-placeholder">
@ -193,6 +199,7 @@ import { ref, computed, reactive } from 'vue'
import { useAnalysisStore } from '../store'
import MarkdownViewer from './MarkdownViewer.vue'
import ImageGallery from './ImageGallery.vue'
import GraphView from './GraphView.vue'
import { useI18n } from '../../../shared/i18n'
import type { PageElement } from '../../../shared/types'
@ -223,6 +230,7 @@ const tabs = computed(() => [
{ id: 'elements', label: t('results.elements') },
{ id: 'markdown', label: t('results.markdown') },
{ id: 'images', label: t('results.images') },
{ id: 'graph', label: t('results.graph') },
])
const totalPages = computed(() => store.currentPages.length)

View file

@ -81,6 +81,10 @@ 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).',
'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',
@ -253,6 +257,10 @@ 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).',
'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',