docling-studio/document-parser/tests/neo4j/test_chunk_writer.py
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

113 lines
3.5 KiB
Python

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