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
a95e1b628d
5 changed files with 608 additions and 15 deletions
|
|
@ -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"))
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue