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)
113 lines
3.5 KiB
Python
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
|