fix(graph): collapse Docling InlineGroup and Picture children (#197)

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.
This commit is contained in:
Pier-Jean Malandrino 2026-04-28 09:23:31 +02:00
parent fe83dcdf79
commit 157a779e20
5 changed files with 608 additions and 15 deletions

View file

@ -17,8 +17,10 @@ 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,
@ -27,15 +29,22 @@ from infra.docling_tree import (
from infra.neo4j.queries import GraphPayload
def _element_node(doc_id: str, item: dict[str, Any], provs: list[dict[str, Any]]) -> dict[str, Any]:
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": (item.get("text") or "")[:200],
"text": raw_text[:200],
"prov_page": first_page,
"provs": provs,
"level": item.get("level"),
@ -112,6 +121,10 @@ def build_graph_payload(
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.
@ -119,11 +132,17 @@ def build_graph_payload(
element_idx = 0
for _, item in iter_items(doc_data):
ref = item.get("self_ref")
if not ref:
if not ref or ref in skip_refs:
continue
by_ref[ref] = item
provs = iter_provs(item)
nodes.append(_element_node(doc_id, item, provs))
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":
@ -143,8 +162,8 @@ def build_graph_payload(
element_idx += 1
# NEXT chain (DFS pre-order from body).
for a, b in pairwise(dfs_order(doc_data)):
# 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"))

View file

@ -25,6 +25,7 @@ LABEL_MAP: dict[str, str] = {
"text": "Paragraph",
"list_item": "ListItem",
"list": "List", # distinct from :ListItem — a list is a container
"inline": "Paragraph", # see issue #197 — collapsed into one paragraph node
"table": "Table",
"picture": "Figure",
"formula": "Formula",
@ -44,6 +45,28 @@ def element_label(docling_label: str) -> str:
return LABEL_MAP.get(docling_label.lower(), DEFAULT_LABEL)
def is_inline_group(item: dict[str, Any]) -> bool:
"""True iff `item` is a Docling InlineGroup (paragraph of mixed style runs).
Docling represents an inline-styled paragraph as one entry in `groups[]`
(label `inline`) plus N entries in `texts[]` (label `text`), one per style
run. We collapse them into a single Paragraph projection see #197.
"""
return (item.get("label") or "").lower() == "inline"
def is_picture(item: dict[str, Any]) -> bool:
"""True iff `item` is a Docling PictureItem (figure or chart).
A `picture` keeps its node in the graph (it IS the figure), but its
`children` internal text labels extracted from a flowchart, diagram,
chart axis labels are noise for graph readability and are skipped.
Captions live in a separate `captions` field on the picture, not in
`children`, so they are unaffected by this skip.
"""
return (item.get("label") or "").lower() in {"picture", "chart"}
def iter_items(doc_data: dict[str, Any]) -> Iterator[tuple[str, dict[str, Any]]]:
"""Yield every item from texts/tables/pictures/groups with its source list key."""
for key in ("texts", "tables", "pictures", "groups"):
@ -95,8 +118,15 @@ def iter_provs(item: dict[str, Any]) -> list[dict[str, Any]]:
return rows
def dfs_order(doc_data: dict[str, Any]) -> list[str]:
"""Return `self_ref`s in reading order (DFS pre-order from body)."""
def dfs_order(doc_data: dict[str, Any], skip_refs: set[str] | None = None) -> list[str]:
"""Return `self_ref`s in reading order (DFS pre-order from body).
`skip_refs` (typically the set returned by `build_inline_index`) is omitted
from the chain. Inline groups themselves are emitted but the walk does not
recurse into their style-run children, so the resulting order references
only nodes that survive the InlineGroup collapse.
"""
skip = skip_refs or set()
by_ref: dict[str, dict[str, Any]] = {}
for _, item in iter_items(doc_data):
ref = item.get("self_ref")
@ -110,17 +140,127 @@ def dfs_order(doc_data: dict[str, Any]) -> list[str]:
return
for ch in children:
ref = ch.get("$ref") or ch.get("cref")
if not ref:
if not ref or ref in skip:
continue
order.append(ref)
child = by_ref.get(ref)
if child:
if child and not is_inline_group(child):
walk(child.get("children"))
walk(body.get("children"))
return order
def build_collapse_index(
doc_data: dict[str, Any],
) -> tuple[set[str], dict[str, dict[str, Any]]]:
"""Pre-compute graph-projection collapses for a serialized DoclingDocument.
Two cases produce noise nodes if mirrored 1:1 see issue #197:
1. **InlineGroup** Docling emits one `groups[]` entry (label `inline`)
plus N `texts[]` style runs. We collapse the children into the group,
which is then projected as a single `:Paragraph` with concatenated
text and the union of children's provs.
2. **Picture / Chart** internal text labels extracted from flowcharts,
diagrams or chart axes hang off the picture's `children`. The picture
node itself stays, but its descendants are skipped so the graph isn't
drowned in dozens of tiny labels.
Returns `(skip_refs, inline_meta)`:
- `skip_refs`: every `self_ref` to drop from element / edge projections.
- `inline_meta[group_ref]`: `{"text": str, "provs": list[dict]}`
override values for the inline group projection. Pictures don't have
an entry here; they keep their own text/prov.
"""
by_ref: dict[str, dict[str, Any]] = {}
for _, item in iter_items(doc_data):
ref = item.get("self_ref")
if ref:
by_ref[ref] = item
skip_refs: set[str] = set()
inline_meta: dict[str, dict[str, Any]] = {}
for item in by_ref.values():
ref = item.get("self_ref") or ""
if not ref:
continue
if is_inline_group(item):
text_parts, provs = _collect_inline_descendants(ref, by_ref, skip_refs)
# Re-index prov order so the resulting :Provenance nodes are 0..N-1
# contiguous instead of carrying each child's individual indices.
for idx, prov in enumerate(provs):
prov["order"] = idx
inline_meta[ref] = {
"text": " ".join(text_parts),
"provs": provs,
}
elif is_picture(item):
_collect_descendants(ref, by_ref, skip_refs)
return skip_refs, inline_meta
def _collect_descendants(
root_ref: str,
by_ref: dict[str, dict[str, Any]],
skip_refs: set[str],
) -> None:
"""DFS `root_ref`'s subtree and add every descendant to `skip_refs`.
Used for picture children we just want them dropped, not aggregated.
"""
def walk(ref: str) -> None:
item = by_ref.get(ref)
if item is None:
return
for ch in item.get("children") or []:
child_ref = ch.get("$ref") or ch.get("cref")
if not child_ref or child_ref in skip_refs:
continue
skip_refs.add(child_ref)
walk(child_ref)
walk(root_ref)
def _collect_inline_descendants(
group_ref: str,
by_ref: dict[str, dict[str, Any]],
skip_refs: set[str],
) -> tuple[list[str], list[dict[str, Any]]]:
"""DFS an inline group's subtree, returning its text parts and provs in
document order. `skip_refs` is mutated with every visited descendant."""
text_parts: list[str] = []
provs: list[dict[str, Any]] = []
def walk(ref: str) -> None:
item = by_ref.get(ref)
if item is None:
return
for ch in item.get("children") or []:
child_ref = ch.get("$ref") or ch.get("cref")
if not child_ref or child_ref in skip_refs:
continue
skip_refs.add(child_ref)
child = by_ref.get(child_ref)
if child is None:
continue
if is_inline_group(child):
walk(child_ref)
continue
text = child.get("text") or ""
if text:
text_parts.append(text)
provs.extend(iter_provs(child))
walk(group_ref)
return text_parts, provs
def iter_pages(doc_data: dict[str, Any]) -> Iterator[dict[str, Any]]:
"""Yield page dicts with `page_no`, `width`, `height` from the `pages` map."""
for page_no_str, page_obj in (doc_data.get("pages") or {}).items():

View file

@ -17,8 +17,10 @@ from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from infra.docling_tree import (
build_collapse_index,
dfs_order,
element_label,
is_inline_group,
iter_items,
iter_pages,
iter_provs,
@ -82,6 +84,12 @@ async def write_document(
doc_data = json.loads(document_json)
ingested_at = datetime.now(tz=UTC).isoformat()
# Issue #197: collapse two noise patterns from Docling into the projection.
# InlineGroups (paragraph style runs) are merged into a single :Paragraph,
# and Pictures' internal text labels (flowchart/diagram/chart annotations)
# are dropped. Both produce refs that land in `skip_refs`.
skip_refs, inline_meta = build_collapse_index(doc_data)
elements: list[dict[str, Any]] = []
# Parallel list: one row per Provenance — each refers back to its owner
# element via `self_ref`, so we can batch MATCH-and-link after both node
@ -89,22 +97,29 @@ async def write_document(
provenances: list[dict[str, Any]] = []
for _, item in iter_items(doc_data):
ref = item.get("self_ref")
if not ref:
if not ref or ref in skip_refs:
continue
specific = element_label(item.get("label") or "")
props = _element_props(item, doc_id)
if is_inline_group(item):
meta = inline_meta.get(ref, {"text": "", "provs": []})
props["text"] = meta["text"]
item_provs = meta["provs"]
else:
item_provs = iter_provs(item)
elements.append(
{
"specific_label": specific,
"parent_ref": parent_ref(item),
**_element_props(item, doc_id),
**props,
}
)
for prov in iter_provs(item):
for prov in item_provs:
provenances.append({"doc_id": doc_id, "self_ref": ref, **prov})
pages: list[dict[str, Any]] = [{"doc_id": doc_id, **p} for p in iter_pages(doc_data)]
reading_order = dfs_order(doc_data)
reading_order = dfs_order(doc_data, skip_refs)
async with (
neo.driver.session(database=neo.database) as session,

View file

@ -208,3 +208,262 @@ async def test_reader_returns_verbatim_json(neo4j_driver):
async def test_reader_missing_doc_returns_none(neo4j_driver):
await bootstrap_schema(neo4j_driver)
assert await read_document_json(neo4j_driver, "no-such-doc") is None
# Issue #197: Docling emits one InlineGroup per inline-styled paragraph plus N
# child `text` items (one per style run). Naive 1:1 mirroring blew up section
# graphs into per-style-run nodes. The writer now collapses an InlineGroup into
# a single :Paragraph node carrying the concatenated text of its children, and
# skips those children entirely.
INLINE_FIXTURE = {
"name": "inline.html",
"pages": {
"1": {"page_no": 1, "size": {"width": 595, "height": 842}},
},
"body": {
"self_ref": "#/body",
"children": [
{"$ref": "#/texts/0"},
{"$ref": "#/groups/0"},
],
},
"texts": [
{
"self_ref": "#/texts/0",
"parent": {"$ref": "#/body"},
"label": "section_header",
"text": "Heading",
"level": 1,
"prov": [{"page_no": 1, "bbox": {"l": 10, "t": 10, "r": 100, "b": 30}}],
},
{
"self_ref": "#/texts/1",
"parent": {"$ref": "#/groups/0"},
"label": "text",
"text": "Hello",
"prov": [{"page_no": 1, "bbox": {"l": 10, "t": 40, "r": 50, "b": 60}}],
},
{
"self_ref": "#/texts/2",
"parent": {"$ref": "#/groups/0"},
"label": "text",
"text": "world",
"prov": [{"page_no": 1, "bbox": {"l": 55, "t": 40, "r": 100, "b": 60}}],
},
{
"self_ref": "#/texts/3",
"parent": {"$ref": "#/groups/0"},
"label": "text",
"text": "!",
"prov": [{"page_no": 1, "bbox": {"l": 105, "t": 40, "r": 110, "b": 60}}],
},
],
"tables": [],
"pictures": [],
"groups": [
{
"self_ref": "#/groups/0",
"parent": {"$ref": "#/body"},
"label": "inline",
"children": [
{"$ref": "#/texts/1"},
{"$ref": "#/texts/2"},
{"$ref": "#/texts/3"},
],
},
],
}
async def test_inline_group_collapses_into_single_paragraph(neo4j_driver):
await bootstrap_schema(neo4j_driver)
doc_json = json.dumps(INLINE_FIXTURE)
result = await write_document(
neo4j_driver,
doc_id="doc-inline",
filename="inline.html",
document_json=doc_json,
)
# Section header + collapsed inline group only — NOT 5 (+3 style runs).
assert result.elements_written == 2
# The inline group inherits its 3 children's provs (1 each); the section
# header has its own prov → 4 Provenance nodes total.
assert result.provenances_written == 4
async with neo4j_driver.driver.session(database=neo4j_driver.database) as s:
r = await s.run(
"MATCH (e:Element:Paragraph {doc_id: $id, self_ref: '#/groups/0'}) "
"RETURN e.text AS text",
id="doc-inline",
)
rec = await r.single()
assert rec is not None, "InlineGroup should write a :Paragraph node"
assert rec["text"] == "Hello world !"
for child_ref in ("#/texts/1", "#/texts/2", "#/texts/3"):
assert (
await _count(
s,
"MATCH (e:Element {doc_id: $id, self_ref: $ref}) RETURN count(e) AS n",
id="doc-inline",
ref=child_ref,
)
== 0
), f"Style-run {child_ref} should be skipped, not written as a node"
# Inline group inherits provs from all children (order preserved).
assert (
await _count(
s,
"MATCH (e:Element:Paragraph {doc_id: $id, self_ref: '#/groups/0'})"
"-[:HAS_PROV]->(pv:Provenance) RETURN count(pv) AS n",
id="doc-inline",
)
== 3
)
# Reading order: section_header → inline-as-paragraph (1 NEXT edge).
assert (
await _count(
s,
"MATCH (a:Element {doc_id: $id})-[:NEXT]->(b:Element) RETURN count(*) AS n",
id="doc-inline",
)
== 1
)
assert (
await _count(
s,
"MATCH (:Document {id: $id})-[:HAS_ROOT]->(:Element) RETURN count(*) AS n",
id="doc-inline",
)
== 2
)
# ON_PAGE through Provenance → Page (matches the new schema).
assert (
await _count(
s,
"MATCH (:Element {doc_id: $id})-[:HAS_PROV]->"
"(:Provenance)-[:ON_PAGE]->(:Page) RETURN count(*) AS n",
id="doc-inline",
)
== 4
)
# Same issue (#197): a Picture's `children` are internal text labels (flowchart
# boxes, chart axis labels, diagram callouts) extracted by Docling's layout
# model. Mirroring them 1:1 drowns the figure in dozens of tiny nodes.
PICTURE_FIXTURE = {
"name": "figure.pdf",
"pages": {
"1": {"page_no": 1, "size": {"width": 595, "height": 842}},
},
"body": {
"self_ref": "#/body",
"children": [
{"$ref": "#/texts/0"},
{"$ref": "#/pictures/0"},
],
},
"texts": [
{
"self_ref": "#/texts/0",
"parent": {"$ref": "#/body"},
"label": "caption",
"text": "Figure 1: Pipeline overview.",
"prov": [{"page_no": 1, "bbox": {"l": 0, "t": 700, "r": 500, "b": 720}}],
},
# Internal labels — children of #/pictures/0.
{
"self_ref": "#/texts/1",
"parent": {"$ref": "#/pictures/0"},
"label": "text",
"text": "Parse",
"prov": [{"page_no": 1, "bbox": {"l": 100, "t": 200, "r": 130, "b": 220}}],
},
{
"self_ref": "#/texts/2",
"parent": {"$ref": "#/pictures/0"},
"label": "text",
"text": "Build",
"prov": [{"page_no": 1, "bbox": {"l": 140, "t": 200, "r": 170, "b": 220}}],
},
{
"self_ref": "#/texts/3",
"parent": {"$ref": "#/pictures/0"},
"label": "text",
"text": "Enrich",
"prov": [{"page_no": 1, "bbox": {"l": 180, "t": 200, "r": 220, "b": 220}}],
},
],
"tables": [],
"pictures": [
{
"self_ref": "#/pictures/0",
"parent": {"$ref": "#/body"},
"label": "picture",
"children": [
{"$ref": "#/texts/1"},
{"$ref": "#/texts/2"},
{"$ref": "#/texts/3"},
],
"captions": [{"$ref": "#/texts/0"}],
"prov": [{"page_no": 1, "bbox": {"l": 90, "t": 100, "r": 510, "b": 600}}],
}
],
"groups": [],
}
async def test_picture_internal_labels_are_skipped(neo4j_driver):
await bootstrap_schema(neo4j_driver)
doc_json = json.dumps(PICTURE_FIXTURE)
result = await write_document(
neo4j_driver,
doc_id="doc-pic",
filename="figure.pdf",
document_json=doc_json,
)
# Caption + picture only — the 3 internal labels are skipped.
assert result.elements_written == 2
async with neo4j_driver.driver.session(database=neo4j_driver.database) as s:
for child_ref in ("#/texts/1", "#/texts/2", "#/texts/3"):
assert (
await _count(
s,
"MATCH (e:Element {doc_id: $id, self_ref: $ref}) RETURN count(e) AS n",
id="doc-pic",
ref=child_ref,
)
== 0
), f"Picture child {child_ref} should be skipped"
# Picture stays a :Figure node with its own prov.
assert (
await _count(
s,
"MATCH (e:Element:Figure {doc_id: $id, self_ref: '#/pictures/0'}) "
"RETURN count(e) AS n",
id="doc-pic",
)
== 1
)
# No PARENT_OF from the picture to its dropped children.
assert (
await _count(
s,
"MATCH (:Element {doc_id: $id, self_ref: '#/pictures/0'})-[:PARENT_OF]->"
"(:Element) RETURN count(*) AS n",
id="doc-pic",
)
== 0
)

View file

@ -215,6 +215,166 @@ def test_title_is_surfaced_on_document_node():
assert doc_node["title"] == "My Doc.pdf"
def test_inline_group_collapses_into_single_paragraph():
"""Issue #197: an InlineGroup + N child `text` items must yield ONE
Paragraph node carrying the joined text and the union of children's provs."""
fixture = {
"pages": {"1": {"page_no": 1, "size": {"width": 595, "height": 842}}},
"body": {
"self_ref": "#/body",
"children": [{"$ref": "#/texts/0"}, {"$ref": "#/groups/0"}],
},
"texts": [
{
"self_ref": "#/texts/0",
"parent": {"$ref": "#/body"},
"label": "section_header",
"text": "Heading",
"level": 1,
"prov": [{"page_no": 1, "bbox": {"l": 10, "t": 10, "r": 100, "b": 30}}],
},
{
"self_ref": "#/texts/1",
"parent": {"$ref": "#/groups/0"},
"label": "text",
"text": "Hello",
"prov": [{"page_no": 1, "bbox": {"l": 10, "t": 40, "r": 50, "b": 60}}],
},
{
"self_ref": "#/texts/2",
"parent": {"$ref": "#/groups/0"},
"label": "text",
"text": "world",
"prov": [{"page_no": 1, "bbox": {"l": 55, "t": 40, "r": 100, "b": 60}}],
},
{
"self_ref": "#/texts/3",
"parent": {"$ref": "#/groups/0"},
"label": "text",
"text": "!",
"prov": [{"page_no": 1, "bbox": {"l": 105, "t": 40, "r": 110, "b": 60}}],
},
],
"tables": [],
"pictures": [],
"groups": [
{
"self_ref": "#/groups/0",
"parent": {"$ref": "#/body"},
"label": "inline",
"children": [
{"$ref": "#/texts/1"},
{"$ref": "#/texts/2"},
{"$ref": "#/texts/3"},
],
},
],
}
payload = build_graph_payload(json.dumps(fixture), doc_id="doc-inline")
# Section header + collapsed inline group only — NOT 5 (+3 style runs).
elements = [n for n in payload.nodes if n.get("group") == "element"]
assert {e["self_ref"] for e in elements} == {"#/texts/0", "#/groups/0"}
inline = next(n for n in elements if n["self_ref"] == "#/groups/0")
assert inline["label"] == "Paragraph"
assert inline["text"] == "Hello world !"
# Provs union: 3 children x 1 prov each = 3 provs on the collapsed node.
assert len(inline["provs"]) == 3
assert all(p.get("page_no") == 1 for p in inline["provs"])
# NEXT follows the post-collapse reading order — section_header → inline only.
nexts = [(e["source"], e["target"]) for e in payload.edges if e["type"] == "NEXT"]
assert nexts == [("elem::#/texts/0", "elem::#/groups/0")]
# ON_PAGE edges still wire the surviving elements to the page (one per
# element, deduped by `seen_pages`).
on_page = [e for e in payload.edges if e["type"] == "ON_PAGE"]
assert sorted(e["source"] for e in on_page) == ["elem::#/groups/0", "elem::#/texts/0"]
def test_picture_internal_labels_are_skipped():
"""Issue #197: a Picture's `children` (flowchart / diagram text labels
extracted by Docling's layout model) must not become standalone graph
nodes they drown the figure in dozens of tiny labels. The picture
itself stays, and a caption (separate `parent` chain on body) is
unaffected."""
fixture = {
"pages": {"1": {"page_no": 1, "size": {"width": 595, "height": 842}}},
"body": {
"self_ref": "#/body",
"children": [{"$ref": "#/texts/0"}, {"$ref": "#/pictures/0"}],
},
"texts": [
# Caption — separate item under body, referenced from the picture's
# `captions` field (not its `children`). Survives the collapse.
{
"self_ref": "#/texts/0",
"parent": {"$ref": "#/body"},
"label": "caption",
"text": "Figure 1: Sketch of Docling's pipelines.",
"prov": [{"page_no": 1, "bbox": {"l": 0, "t": 700, "r": 500, "b": 720}}],
},
# Internal labels of the figure (flowchart boxes). Live in
# `texts[]` with `parent` pointing at the picture.
*[
{
"self_ref": f"#/texts/{i}",
"parent": {"$ref": "#/pictures/0"},
"label": "text",
"text": label,
"prov": [
{
"page_no": 1,
"bbox": {"l": 100 + i * 30, "t": 200, "r": 130 + i * 30, "b": 220},
}
],
}
for i, label in enumerate(
["Parse", "Build", "Enrich", "Assemble", "Document"], start=1
)
],
],
"tables": [],
"pictures": [
{
"self_ref": "#/pictures/0",
"parent": {"$ref": "#/body"},
"label": "picture",
"children": [
{"$ref": "#/texts/1"},
{"$ref": "#/texts/2"},
{"$ref": "#/texts/3"},
{"$ref": "#/texts/4"},
{"$ref": "#/texts/5"},
],
"captions": [{"$ref": "#/texts/0"}],
"prov": [{"page_no": 1, "bbox": {"l": 90, "t": 100, "r": 510, "b": 600}}],
}
],
"groups": [],
}
payload = build_graph_payload(json.dumps(fixture), doc_id="doc-pic")
elements = [n for n in payload.nodes if n.get("group") == "element"]
refs = {e["self_ref"] for e in elements}
# Picture + caption only — the 5 internal labels must be dropped.
assert refs == {"#/pictures/0", "#/texts/0"}
# Picture keeps its own label / text / prov (no inline-style override).
pic = next(e for e in elements if e["self_ref"] == "#/pictures/0")
assert pic["label"] == "Figure"
assert pic["docling_label"] == "picture"
# No PARENT_OF edges from the picture to its (skipped) children.
parent_edges = [e for e in payload.edges if e["type"] == "PARENT_OF"]
assert all(e["source"] != "elem::#/pictures/0" for e in parent_edges)
# NEXT chain only includes surviving elements.
next_targets = [e["target"] for e in payload.edges if e["type"] == "NEXT"]
assert all(t in {"elem::#/texts/0", "elem::#/pictures/0"} for t in next_targets)
def test_element_text_is_capped_at_200_chars():
long = "x" * 500
fixture = {