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:
parent
d02731b622
commit
b2e0af3847
5 changed files with 608 additions and 15 deletions
|
|
@ -17,8 +17,10 @@ from itertools import pairwise
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from infra.docling_tree import (
|
from infra.docling_tree import (
|
||||||
|
build_collapse_index,
|
||||||
dfs_order,
|
dfs_order,
|
||||||
element_label,
|
element_label,
|
||||||
|
is_inline_group,
|
||||||
iter_items,
|
iter_items,
|
||||||
iter_pages,
|
iter_pages,
|
||||||
iter_provs,
|
iter_provs,
|
||||||
|
|
@ -27,15 +29,22 @@ from infra.docling_tree import (
|
||||||
from infra.neo4j.queries import GraphPayload
|
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
|
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 {
|
return {
|
||||||
"id": f"elem::{item.get('self_ref')}",
|
"id": f"elem::{item.get('self_ref')}",
|
||||||
"group": "element",
|
"group": "element",
|
||||||
"label": element_label(item.get("label") or ""),
|
"label": element_label(item.get("label") or ""),
|
||||||
"docling_label": (item.get("label") or "").lower(),
|
"docling_label": (item.get("label") or "").lower(),
|
||||||
"self_ref": item.get("self_ref"),
|
"self_ref": item.get("self_ref"),
|
||||||
"text": (item.get("text") or "")[:200],
|
"text": raw_text[:200],
|
||||||
"prov_page": first_page,
|
"prov_page": first_page,
|
||||||
"provs": provs,
|
"provs": provs,
|
||||||
"level": item.get("level"),
|
"level": item.get("level"),
|
||||||
|
|
@ -112,6 +121,10 @@ def build_graph_payload(
|
||||||
for p in pages_raw:
|
for p in pages_raw:
|
||||||
nodes.append(_page_node(doc_id, p))
|
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 nodes + collect parent/body metadata for edges below. The
|
||||||
# `element_idx` mirrors TreeWriter's `enumerate(elements)` so PARENT_OF
|
# `element_idx` mirrors TreeWriter's `enumerate(elements)` so PARENT_OF
|
||||||
# carries the same `order` the Neo4j projection does.
|
# carries the same `order` the Neo4j projection does.
|
||||||
|
|
@ -119,11 +132,17 @@ def build_graph_payload(
|
||||||
element_idx = 0
|
element_idx = 0
|
||||||
for _, item in iter_items(doc_data):
|
for _, item in iter_items(doc_data):
|
||||||
ref = item.get("self_ref")
|
ref = item.get("self_ref")
|
||||||
if not ref:
|
if not ref or ref in skip_refs:
|
||||||
continue
|
continue
|
||||||
by_ref[ref] = item
|
by_ref[ref] = item
|
||||||
provs = iter_provs(item)
|
if is_inline_group(item):
|
||||||
nodes.append(_element_node(doc_id, item, provs))
|
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)
|
pref = parent_ref(item)
|
||||||
if pref == "#/body":
|
if pref == "#/body":
|
||||||
|
|
@ -143,8 +162,8 @@ def build_graph_payload(
|
||||||
|
|
||||||
element_idx += 1
|
element_idx += 1
|
||||||
|
|
||||||
# NEXT chain (DFS pre-order from body).
|
# NEXT chain (DFS pre-order from body), inline-group children skipped.
|
||||||
for a, b in pairwise(dfs_order(doc_data)):
|
for a, b in pairwise(dfs_order(doc_data, skip_refs)):
|
||||||
if a in by_ref and b in by_ref:
|
if a in by_ref and b in by_ref:
|
||||||
edges.append(_edge(f"elem::{a}", f"elem::{b}", "NEXT"))
|
edges.append(_edge(f"elem::{a}", f"elem::{b}", "NEXT"))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ LABEL_MAP: dict[str, str] = {
|
||||||
"text": "Paragraph",
|
"text": "Paragraph",
|
||||||
"list_item": "ListItem",
|
"list_item": "ListItem",
|
||||||
"list": "List", # distinct from :ListItem — a list is a container
|
"list": "List", # distinct from :ListItem — a list is a container
|
||||||
|
"inline": "Paragraph", # see issue #197 — collapsed into one paragraph node
|
||||||
"table": "Table",
|
"table": "Table",
|
||||||
"picture": "Figure",
|
"picture": "Figure",
|
||||||
"formula": "Formula",
|
"formula": "Formula",
|
||||||
|
|
@ -44,6 +45,28 @@ def element_label(docling_label: str) -> str:
|
||||||
return LABEL_MAP.get(docling_label.lower(), DEFAULT_LABEL)
|
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]]]:
|
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."""
|
"""Yield every item from texts/tables/pictures/groups with its source list key."""
|
||||||
for key in ("texts", "tables", "pictures", "groups"):
|
for key in ("texts", "tables", "pictures", "groups"):
|
||||||
|
|
@ -95,8 +118,15 @@ def iter_provs(item: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def dfs_order(doc_data: dict[str, Any]) -> list[str]:
|
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)."""
|
"""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]] = {}
|
by_ref: dict[str, dict[str, Any]] = {}
|
||||||
for _, item in iter_items(doc_data):
|
for _, item in iter_items(doc_data):
|
||||||
ref = item.get("self_ref")
|
ref = item.get("self_ref")
|
||||||
|
|
@ -110,17 +140,127 @@ def dfs_order(doc_data: dict[str, Any]) -> list[str]:
|
||||||
return
|
return
|
||||||
for ch in children:
|
for ch in children:
|
||||||
ref = ch.get("$ref") or ch.get("cref")
|
ref = ch.get("$ref") or ch.get("cref")
|
||||||
if not ref:
|
if not ref or ref in skip:
|
||||||
continue
|
continue
|
||||||
order.append(ref)
|
order.append(ref)
|
||||||
child = by_ref.get(ref)
|
child = by_ref.get(ref)
|
||||||
if child:
|
if child and not is_inline_group(child):
|
||||||
walk(child.get("children"))
|
walk(child.get("children"))
|
||||||
|
|
||||||
walk(body.get("children"))
|
walk(body.get("children"))
|
||||||
return order
|
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]]:
|
def iter_pages(doc_data: dict[str, Any]) -> Iterator[dict[str, Any]]:
|
||||||
"""Yield page dicts with `page_no`, `width`, `height` from the `pages` map."""
|
"""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():
|
for page_no_str, page_obj in (doc_data.get("pages") or {}).items():
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,10 @@ from datetime import UTC, datetime
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from infra.docling_tree import (
|
from infra.docling_tree import (
|
||||||
|
build_collapse_index,
|
||||||
dfs_order,
|
dfs_order,
|
||||||
element_label,
|
element_label,
|
||||||
|
is_inline_group,
|
||||||
iter_items,
|
iter_items,
|
||||||
iter_pages,
|
iter_pages,
|
||||||
iter_provs,
|
iter_provs,
|
||||||
|
|
@ -82,6 +84,12 @@ async def write_document(
|
||||||
doc_data = json.loads(document_json)
|
doc_data = json.loads(document_json)
|
||||||
ingested_at = datetime.now(tz=UTC).isoformat()
|
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]] = []
|
elements: list[dict[str, Any]] = []
|
||||||
# Parallel list: one row per Provenance — each refers back to its owner
|
# 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
|
# 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]] = []
|
provenances: list[dict[str, Any]] = []
|
||||||
for _, item in iter_items(doc_data):
|
for _, item in iter_items(doc_data):
|
||||||
ref = item.get("self_ref")
|
ref = item.get("self_ref")
|
||||||
if not ref:
|
if not ref or ref in skip_refs:
|
||||||
continue
|
continue
|
||||||
specific = element_label(item.get("label") or "")
|
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(
|
elements.append(
|
||||||
{
|
{
|
||||||
"specific_label": specific,
|
"specific_label": specific,
|
||||||
"parent_ref": parent_ref(item),
|
"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})
|
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)]
|
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 (
|
async with (
|
||||||
neo.driver.session(database=neo.database) as session,
|
neo.driver.session(database=neo.database) as session,
|
||||||
|
|
|
||||||
|
|
@ -208,3 +208,262 @@ async def test_reader_returns_verbatim_json(neo4j_driver):
|
||||||
async def test_reader_missing_doc_returns_none(neo4j_driver):
|
async def test_reader_missing_doc_returns_none(neo4j_driver):
|
||||||
await bootstrap_schema(neo4j_driver)
|
await bootstrap_schema(neo4j_driver)
|
||||||
assert await read_document_json(neo4j_driver, "no-such-doc") is None
|
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
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,166 @@ def test_title_is_surfaced_on_document_node():
|
||||||
assert doc_node["title"] == "My Doc.pdf"
|
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():
|
def test_element_text_is_capped_at_200_chars():
|
||||||
long = "x" * 500
|
long = "x" * 500
|
||||||
fixture = {
|
fixture = {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue