Two patterns in Docling's serialization were mirrored 1:1 by the graph projection and produced node explosions on real documents: - An InlineGroup (paragraph of mixed style runs) emits one `groups[]` entry plus N `texts[]` runs. Naive iteration created one Paragraph node per run. - A Picture's `children` carry internal text labels extracted by the layout model (flowchart boxes, chart axis labels, diagram callouts). Each child became its own Paragraph node, drowning the figure. `build_collapse_index` (in the shared `infra.docling_tree` helper) now returns the `skip_refs` set + `inline_meta` overrides for both cases. The Neo4j `tree_writer` and the in-memory `docling_graph` consume the same index, so both projections stay in sync. InlineGroups are projected as a single :Paragraph carrying the concatenated text and the union of children's provs (re-indexed). Pictures keep their :Figure node and prov; their descendants are dropped. Captions live in the picture's separate `captions` field, not in `children`, so they are unaffected.
178 lines
5.6 KiB
Python
178 lines
5.6 KiB
Python
"""Build a Cytoscape-shaped graph payload straight from a serialized
|
|
`DoclingDocument` (i.e. the `document_json` blob stored in SQLite).
|
|
|
|
Mirrors `infra.neo4j.queries.fetch_graph` so the frontend can reuse the same
|
|
`GraphView` component — the only intentional difference is the absence of
|
|
Chunk nodes / HAS_CHUNK / DERIVED_FROM edges, since chunks are a product of
|
|
the Maintain step and don't exist in `document_json` alone.
|
|
|
|
Used by the reasoning-trace viewer, which needs the structural graph to
|
|
overlay iterations onto but does NOT need (and should not require) Neo4j.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from itertools import pairwise
|
|
from typing import Any
|
|
|
|
from infra.docling_tree import (
|
|
build_collapse_index,
|
|
dfs_order,
|
|
element_label,
|
|
is_inline_group,
|
|
iter_items,
|
|
iter_pages,
|
|
iter_provs,
|
|
parent_ref,
|
|
)
|
|
from infra.neo4j.queries import GraphPayload
|
|
|
|
|
|
def _element_node(
|
|
doc_id: str,
|
|
item: dict[str, Any],
|
|
provs: list[dict[str, Any]],
|
|
*,
|
|
text_override: str | None = None,
|
|
) -> dict[str, Any]:
|
|
first_page = provs[0].get("page_no") if provs else None
|
|
raw_text = text_override if text_override is not None else (item.get("text") or "")
|
|
return {
|
|
"id": f"elem::{item.get('self_ref')}",
|
|
"group": "element",
|
|
"label": element_label(item.get("label") or ""),
|
|
"docling_label": (item.get("label") or "").lower(),
|
|
"self_ref": item.get("self_ref"),
|
|
"text": raw_text[:200],
|
|
"prov_page": first_page,
|
|
"provs": provs,
|
|
"level": item.get("level"),
|
|
"doc_id": doc_id,
|
|
}
|
|
|
|
|
|
def _page_node(doc_id: str, page: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"id": f"page::{page.get('page_no')}",
|
|
"group": "page",
|
|
"page_no": page.get("page_no"),
|
|
"width": page.get("width"),
|
|
"height": page.get("height"),
|
|
"doc_id": doc_id,
|
|
}
|
|
|
|
|
|
def _edge(source: str, target: str, edge_type: str, *, order: int | None = None) -> dict[str, Any]:
|
|
return {
|
|
"id": f"{edge_type}::{source}::{target}",
|
|
"source": source,
|
|
"target": target,
|
|
"type": edge_type,
|
|
"order": order,
|
|
}
|
|
|
|
|
|
def build_graph_payload(
|
|
document_json: str,
|
|
*,
|
|
doc_id: str,
|
|
title: str | None = None,
|
|
max_pages: int = 200,
|
|
) -> GraphPayload:
|
|
"""Build a `GraphPayload` equivalent to `fetch_graph(neo4j, doc_id)` from
|
|
the raw `DoclingDocument` JSON.
|
|
|
|
Returns `truncated=True` with empty node/edge lists beyond `max_pages`, so
|
|
the caller can mirror the Neo4j endpoint's 413 behavior.
|
|
"""
|
|
doc_data = json.loads(document_json)
|
|
|
|
pages_raw = list(iter_pages(doc_data))
|
|
page_count = len(pages_raw)
|
|
if page_count > max_pages:
|
|
return GraphPayload(
|
|
doc_id=doc_id,
|
|
nodes=[],
|
|
edges=[],
|
|
node_count=0,
|
|
edge_count=0,
|
|
truncated=True,
|
|
page_count=page_count,
|
|
)
|
|
|
|
nodes: list[dict[str, Any]] = []
|
|
edges: list[dict[str, Any]] = []
|
|
|
|
doc_node_id = f"doc::{doc_id}"
|
|
nodes.append(
|
|
{
|
|
"id": doc_node_id,
|
|
"group": "document",
|
|
"doc_id": doc_id,
|
|
"title": title,
|
|
# `stages_applied` is a Neo4j-only artifact; keep the key present
|
|
# for shape parity but leave it empty since SQLite doesn't track it.
|
|
"stages_applied": [],
|
|
}
|
|
)
|
|
|
|
# Page nodes.
|
|
for p in pages_raw:
|
|
nodes.append(_page_node(doc_id, p))
|
|
|
|
# Issue #197: collapse Docling noise — InlineGroup style runs and the
|
|
# internal text labels Docling extracts from pictures/charts.
|
|
skip_refs, inline_meta = build_collapse_index(doc_data)
|
|
|
|
# Element nodes + collect parent/body metadata for edges below. The
|
|
# `element_idx` mirrors TreeWriter's `enumerate(elements)` so PARENT_OF
|
|
# carries the same `order` the Neo4j projection does.
|
|
by_ref: dict[str, dict[str, Any]] = {}
|
|
element_idx = 0
|
|
for _, item in iter_items(doc_data):
|
|
ref = item.get("self_ref")
|
|
if not ref or ref in skip_refs:
|
|
continue
|
|
by_ref[ref] = item
|
|
if is_inline_group(item):
|
|
meta = inline_meta.get(ref, {"text": "", "provs": []})
|
|
provs = meta["provs"]
|
|
text_override: str | None = meta["text"]
|
|
else:
|
|
provs = iter_provs(item)
|
|
text_override = None
|
|
nodes.append(_element_node(doc_id, item, provs, text_override=text_override))
|
|
|
|
pref = parent_ref(item)
|
|
if pref == "#/body":
|
|
edges.append(_edge(doc_node_id, f"elem::{ref}", "HAS_ROOT"))
|
|
elif pref:
|
|
edges.append(_edge(f"elem::{pref}", f"elem::{ref}", "PARENT_OF", order=element_idx))
|
|
|
|
# ON_PAGE, dedup'd per (element, page) — matches the Neo4j query's
|
|
# DISTINCT projection through Provenance.
|
|
seen_pages: set[int] = set()
|
|
for prov in provs:
|
|
page_no = prov.get("page_no")
|
|
if page_no is None or page_no in seen_pages:
|
|
continue
|
|
seen_pages.add(page_no)
|
|
edges.append(_edge(f"elem::{ref}", f"page::{page_no}", "ON_PAGE"))
|
|
|
|
element_idx += 1
|
|
|
|
# NEXT chain (DFS pre-order from body), inline-group children skipped.
|
|
for a, b in pairwise(dfs_order(doc_data, skip_refs)):
|
|
if a in by_ref and b in by_ref:
|
|
edges.append(_edge(f"elem::{a}", f"elem::{b}", "NEXT"))
|
|
|
|
return GraphPayload(
|
|
doc_id=doc_id,
|
|
nodes=nodes,
|
|
edges=edges,
|
|
node_count=len(nodes),
|
|
edge_count=len(edges),
|
|
truncated=False,
|
|
page_count=page_count,
|
|
)
|