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)
56 lines
2 KiB
Python
56 lines
2 KiB
Python
"""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),
|
|
)
|