docling-studio/experiments/reasoning-trace/prime_neo4j.py
Pier-Jean Malandrino 8103460e9c 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-29 14:00:00 +02:00

148 lines
5 KiB
Python

#!/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())