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)
148 lines
5 KiB
Python
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())
|