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)
175 lines
7.1 KiB
Python
175 lines
7.1 KiB
Python
#!/usr/bin/env python3
|
|
# /// script
|
|
# requires-python = ">=3.11"
|
|
# dependencies = ["neo4j>=5.20,<6"]
|
|
# ///
|
|
"""
|
|
Second-pass inspector: how well does our Neo4j graph model the *semantics*
|
|
of a DoclingDocument — the parts Peter Staar would care about?
|
|
|
|
Goes beyond structural 1:1 coverage (inspect_graph.py) to answer:
|
|
- Are SectionHeader levels preserved (outline hierarchy intact)?
|
|
- Can we reconstruct "section scope" (section → its content) from the graph?
|
|
- Are lists vs list-items distinguishable?
|
|
- Are figure captions linked to their figures?
|
|
- What's the depth of the actual hierarchy vs the flat body?
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
REPO = HERE.parents[1]
|
|
|
|
|
|
async def main_async(doc_id: str) -> None:
|
|
sys.path.insert(0, str(REPO / "document-parser"))
|
|
from infra.neo4j import close_driver, get_driver
|
|
|
|
neo = await get_driver(
|
|
os.environ.get("NEO4J_URI", "bolt://localhost:7687"),
|
|
os.environ.get("NEO4J_USER", "neo4j"),
|
|
os.environ.get("NEO4J_PASSWORD", "changeme"),
|
|
)
|
|
try:
|
|
async with neo.driver.session(database=neo.database) as s:
|
|
|
|
async def q(cypher: str, **params):
|
|
res = await s.run(cypher, doc_id=doc_id, **params)
|
|
return [dict(r) async for r in res]
|
|
|
|
print("=== 1. OUTLINE HIERARCHY (section header levels) ===")
|
|
rows = await q(
|
|
"""
|
|
MATCH (e:SectionHeader {doc_id: $doc_id})
|
|
RETURN e.self_ref AS ref, e.level AS level, substring(e.text, 0, 60) AS text
|
|
ORDER BY e.self_ref
|
|
"""
|
|
)
|
|
for r in rows:
|
|
print(f" level={r['level']} {r['ref']:<14} {r['text']}")
|
|
levels = [r["level"] for r in rows]
|
|
print(f" → distinct levels: {sorted(set(levels))}")
|
|
|
|
print("\n=== 2. DIRECT TREE DEPTH via PARENT_OF ===")
|
|
rows = await q(
|
|
"""
|
|
MATCH (root:Element {doc_id: $doc_id})
|
|
WHERE NOT (()-[:PARENT_OF]->(root))
|
|
OPTIONAL MATCH path = (root)-[:PARENT_OF*]->(leaf)
|
|
WITH root, max(length(path)) AS depth
|
|
RETURN
|
|
labels(root) AS labels,
|
|
root.docling_label AS docling_label,
|
|
coalesce(depth, 0) AS depth
|
|
ORDER BY depth DESC
|
|
LIMIT 10
|
|
"""
|
|
)
|
|
for r in rows:
|
|
specific = [l for l in r["labels"] if l != "Element"][0]
|
|
print(f" depth={r['depth']} {specific:<15} ({r['docling_label']})")
|
|
|
|
print("\n=== 3. SECTION SCOPE (can we infer section content from NEXT?) ===")
|
|
# For each section header, walk NEXT until the next section header —
|
|
# that's the section's content span as per docling-agent's logic.
|
|
rows = await q(
|
|
"""
|
|
MATCH (sh:SectionHeader {doc_id: $doc_id})
|
|
OPTIONAL MATCH p = (sh)-[:NEXT*]->(next:SectionHeader {doc_id: $doc_id})
|
|
WITH sh, min(length(p)) AS span
|
|
RETURN
|
|
sh.self_ref AS ref,
|
|
sh.level AS level,
|
|
coalesce(span - 1, -1) AS items_in_scope_if_span_works,
|
|
substring(sh.text, 0, 50) AS title
|
|
ORDER BY sh.self_ref
|
|
"""
|
|
)
|
|
for r in rows:
|
|
span = r["items_in_scope_if_span_works"]
|
|
label = f"~{span} items" if span >= 0 else "last (unknown span)"
|
|
print(f" level={r['level']} {r['ref']:<14} {label:<18} {r['title']}")
|
|
|
|
print("\n=== 4. LIST CONTAINER vs LIST ITEM distinction ===")
|
|
rows = await q(
|
|
"""
|
|
MATCH (e:ListItem {doc_id: $doc_id})
|
|
RETURN e.docling_label AS docling_label, count(*) AS n
|
|
ORDER BY docling_label
|
|
"""
|
|
)
|
|
for r in rows:
|
|
print(f" docling_label={r['docling_label']:<12} neo4j_label=:ListItem count={r['n']}")
|
|
print(" ⚠️ Both 'list' (container) and 'list_item' get :ListItem in Neo4j.")
|
|
|
|
print("\n=== 5. FIGURE ↔ CAPTION linkage ===")
|
|
captions = await q(
|
|
"MATCH (c:Caption {doc_id: $doc_id}) RETURN count(c) AS n"
|
|
)
|
|
linked = await q(
|
|
"""
|
|
MATCH (fig:Figure {doc_id: $doc_id})-[:PARENT_OF]-(c:Caption {doc_id: $doc_id})
|
|
RETURN count(DISTINCT fig) AS figs_with_caption, count(DISTINCT c) AS captions_linked
|
|
"""
|
|
)
|
|
print(
|
|
f" captions={captions[0]['n']} "
|
|
f"figures_with_caption={linked[0]['figs_with_caption']} "
|
|
f"captions_linked={linked[0]['captions_linked']}"
|
|
)
|
|
|
|
print("\n=== 6. TABLE CELL CONTENT — graph-addressable or opaque? ===")
|
|
rows = await q(
|
|
"""
|
|
MATCH (t:Table {doc_id: $doc_id})
|
|
RETURN t.self_ref AS ref,
|
|
CASE WHEN t.cells_json IS NOT NULL THEN 'JSON-blob' ELSE 'missing' END
|
|
AS cells_mode,
|
|
size(coalesce(t.cells_json, '')) AS cells_bytes
|
|
"""
|
|
)
|
|
for r in rows:
|
|
print(f" {r['ref']:<14} cells={r['cells_mode']} ({r['cells_bytes']} bytes)")
|
|
if rows:
|
|
print(" ⚠️ Cells are a JSON string on the Table node — not queryable as graph nodes.")
|
|
|
|
print("\n=== 7. WHAT AN AGENT VISIT LOOKS LIKE (section subgraph) ===")
|
|
# Pick the first section header and show what would be highlighted
|
|
# if we traversed its scope (via NEXT until next same-or-higher-level section).
|
|
rows = await q(
|
|
"""
|
|
MATCH (sh:SectionHeader {doc_id: $doc_id})
|
|
WITH sh ORDER BY sh.self_ref LIMIT 1
|
|
OPTIONAL MATCH chain = (sh)-[:NEXT*0..50]->(e:Element {doc_id: $doc_id})
|
|
WITH sh, e, length(chain) AS pos
|
|
OPTIONAL MATCH (e)<-[:NEXT*0..]-(_stop:SectionHeader)
|
|
WHERE _stop <> sh AND _stop.level <= sh.level
|
|
WITH sh, e, pos
|
|
ORDER BY pos
|
|
LIMIT 8
|
|
RETURN pos, labels(e) AS labels, e.docling_label AS kind,
|
|
substring(e.text, 0, 60) AS text
|
|
"""
|
|
)
|
|
for r in rows:
|
|
specific = [l for l in r["labels"] if l != "Element"][0]
|
|
print(f" pos={r['pos']:<3} {specific:<15} {r['text']}")
|
|
print(
|
|
" → Visiting a section on the graph shows ONE node. Its 'scope' "
|
|
"(the content) must be inferred via NEXT-walk — not materialized as edges."
|
|
)
|
|
|
|
finally:
|
|
await close_driver()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
p = argparse.ArgumentParser(description=__doc__)
|
|
p.add_argument("--doc-id", required=True)
|
|
args = p.parse_args()
|
|
asyncio.run(main_async(args.doc_id))
|