Skip to content

Commit 32fda57

Browse files
authored
Merge pull request #1968 from Open-Source-Legal/claude/issue-1961-4cndml
PDF chunking Phase 3: overlap windows + dedup + cross-boundary relationship re-linking
2 parents b0491c4 + dc74716 commit 32fda57

8 files changed

Lines changed: 810 additions & 58 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- PDF chunking Phase 3 (issue #1961): chunked parsing now uses **overlap windows** so structure spanning a chunk boundary survives. `BaseChunkedParser` (`opencontractserver/pipeline/base/chunked_parser.py`) splits each chunk over an overlap-extended parse range via `calculate_page_chunks_with_overlap(..., overlap=chunk_overlap)` (new `chunk_overlap` knob, default `DEFAULT_CHUNK_OVERLAP=2` in `opencontractserver/constants/document_processing.py`; `DoclingParser` exposes it as the `DOCLING_CHUNK_OVERLAP` pipeline setting). Each chunk's reassembly offset is its parse-range `start` (so local page `i` maps to global page `start+i`), superseding the Phase-1 placeholder note that suggested `core_start`. `ChunkReassembler` (`opencontractserver/pipeline/base/chunk_reassembler.py`) now dedupes overlap pages by global index (keep-first), dedupes annotations by a chunk-independent content signature (label + global page geometry), and **re-links** cross-boundary relationship endpoints and `parent_id` references onto the surviving canonical IDs — replacing the previous `c{idx}_`-prefix severing that orphaned cross-chunk references. Residual orphans (a reference spanning more pages than the configured overlap) are logged + metered (`metric=chunk_reassembly_orphan_refs`) but never fatal. Covered by a service-level golden-equivalence gate (`opencontractserver/tests/test_chunked_parser.py::TestChunkedOverlapGoldenEquivalence`) proving a chunked-with-overlap parse reproduces the single-shot PAWLs, annotations, and relationships, plus reassembler-level dedup/re-link tests in `test_chunk_reassembler.py`. Hardening: `ChunkReassembler.finalize()` is now guarded against a second call (the in-place id-remap is single-use); the annotation dedup signature normalizes `rawText` whitespace so the same span surviving in two chunks still collapses; `_dedupe_pages` logs (`metric=chunk_reassembly_page_missing_index`) when a PAWLs page lacks an `index`; and the `overlap >= max_pages_per_chunk` error in `calculate_page_chunks_with_overlap` (plus a `BaseChunkedParser` docstring note) now tells subclasses with a small `max_pages_per_chunk` to pin `chunk_overlap = 0`.

opencontractserver/constants/document_processing.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,14 @@
147147
# Documents with fewer pages than this threshold are parsed as a single request.
148148
DEFAULT_MIN_PAGES_FOR_CHUNKING = 75
149149

150+
# Number of pages each chunk's parse range is extended beyond its core boundary
151+
# on every interior side. Overlap lets structure (paragraphs, sections,
152+
# relationships) that spans a chunk boundary be captured *whole* in at least one
153+
# chunk, so reassembly can dedupe the duplicates and re-link cross-boundary
154+
# references instead of orphaning them. Must be < DEFAULT_MAX_PAGES_PER_CHUNK and
155+
# should exceed the largest expected boundary-spanning structure (in pages).
156+
DEFAULT_CHUNK_OVERLAP = 2
157+
150158
# Maximum number of chunks to process concurrently via thread pool.
151159
# Controls parallelism of HTTP requests to the parsing microservice.
152160
DEFAULT_MAX_CONCURRENT_CHUNKS = 3

opencontractserver/pipeline/base/chunk_reassembler.py

Lines changed: 221 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@
77
"""
88

99
import logging
10-
from typing import Optional
10+
from typing import Any, Optional
1111

12-
from opencontractserver.annotations.compact_json import offset_annotation_json
12+
from opencontractserver.annotations.compact_json import (
13+
is_span_format,
14+
iter_page_annotations,
15+
offset_annotation_json,
16+
)
1317
from opencontractserver.types.dicts import (
1418
OpenContractDocExport,
1519
OpenContractsAnnotationPythonType,
@@ -19,6 +23,71 @@
1923

2024
logger = logging.getLogger(__name__)
2125

26+
# Decimal places used when folding annotation bounding boxes into a dedup
27+
# signature. The same physical page parsed in two overlapping chunks yields
28+
# bit-identical bounds, so this only guards against trivial float noise.
29+
_BOUNDS_SIGNATURE_PRECISION = 2
30+
31+
32+
def _annotation_signature(annotation: OpenContractsAnnotationPythonType) -> tuple:
33+
"""Build a content signature identifying an annotation independent of chunk.
34+
35+
Two copies of the same structure parsed in different (overlapping) chunks
36+
share a signature once their page indices have been offset into global
37+
space, which is what lets reassembly dedupe them and re-link references.
38+
39+
The signature is derived from the annotation's label plus its global page
40+
geometry (page index, bounding box, token indices) — NOT its chunk-prefixed
41+
``id`` — so it is stable across the ``c{idx}_`` prefixing applied per chunk.
42+
"""
43+
label = annotation.get("annotationLabel")
44+
# Collapse internal/trailing whitespace so two copies of the same span that
45+
# differ only in incidental whitespace (e.g. a trailing newline emitted by
46+
# one chunk parse but not the other) still hash to the same signature and
47+
# dedupe. Geometry + label already discriminate distinct structures; this
48+
# only hardens the supplementary raw_text component against parse noise.
49+
raw_text = " ".join((annotation.get("rawText", "") or "").split())
50+
annotation_json: Any = annotation.get("annotation_json")
51+
52+
# Span annotations (text documents) key on their character offsets.
53+
if is_span_format(annotation_json):
54+
return (
55+
"span",
56+
label,
57+
annotation_json.get("start"),
58+
annotation_json.get("end"),
59+
raw_text,
60+
)
61+
62+
pages: list[tuple] = []
63+
for page in iter_page_annotations(annotation_json, raw_text=raw_text):
64+
b = page.bounds
65+
bounds_sig = (
66+
round(float(b.get("top", 0)), _BOUNDS_SIGNATURE_PRECISION),
67+
round(float(b.get("left", 0)), _BOUNDS_SIGNATURE_PRECISION),
68+
round(float(b.get("right", 0)), _BOUNDS_SIGNATURE_PRECISION),
69+
round(float(b.get("bottom", 0)), _BOUNDS_SIGNATURE_PRECISION),
70+
)
71+
pages.append((page.page_index, bounds_sig, tuple(sorted(page.token_indices))))
72+
pages.sort()
73+
return ("multipage", label, tuple(pages), raw_text)
74+
75+
76+
def _relationship_signature(
77+
relationship: OpenContractsRelationshipPythonType,
78+
) -> tuple:
79+
"""Content signature for a relationship after its endpoint IDs are remapped.
80+
81+
Keyed on the label plus the (order-independent) sets of source/target
82+
annotation IDs, so a relationship duplicated across an overlap zone collapses
83+
to one once both copies point at the same deduped global annotation IDs.
84+
"""
85+
return (
86+
relationship.get("relationshipLabel"),
87+
frozenset(relationship.get("source_annotation_ids", [])),
88+
frozenset(relationship.get("target_annotation_ids", [])),
89+
)
90+
2291

2392
def offset_annotation(
2493
annotation: OpenContractsAnnotationPythonType,
@@ -76,6 +145,7 @@ def __init__(self) -> None:
76145
self._doc_labels: list[str] = []
77146
self._seen_doc_labels: set[str] = set()
78147
self._total_pages = 0
148+
self._finalized = False
79149

80150
def add_chunk(
81151
self,
@@ -120,31 +190,163 @@ def add_chunk(
120190
def finalize(self) -> OpenContractDocExport:
121191
if self._first is None:
122192
raise ValueError("Cannot finalize an empty ChunkReassembler")
193+
# finalize() mutates the accumulated annotation/relationship dicts in
194+
# place (id_remap rewrites). A second call would re-apply the remap to
195+
# already-remapped IDs and corrupt the output, so the class is strictly
196+
# single-use — make that invariant explicit rather than implicit.
197+
if self._finalized:
198+
raise RuntimeError("ChunkReassembler.finalize() called more than once")
199+
self._finalized = True
200+
201+
pawls = self._dedupe_pages()
202+
annotations, id_remap = self._dedupe_annotations()
203+
relationships = self._relink_and_dedupe_relationships(id_remap)
204+
205+
# page_count is the number of unique global pages, not the sum of the
206+
# chunks' page counts (overlap makes the latter over-count). Fall back to
207+
# the summed count only if no PAWLs pages were emitted at all.
208+
page_count = len(pawls) if pawls else self._total_pages
123209

124210
result: OpenContractDocExport = {
125211
"title": self._first.get("title", ""),
126212
"content": "\n".join(self._content_parts),
127213
"description": self._first.get("description"),
128-
"pawls_file_content": self._pawls,
129-
"page_count": self._total_pages,
214+
"pawls_file_content": pawls,
215+
"page_count": page_count,
130216
"doc_labels": self._doc_labels,
131-
"labelled_text": self._annotations,
132-
"relationships": self._relationships,
217+
"labelled_text": annotations,
218+
"relationships": relationships,
133219
}
134220

135-
# Detect and warn about orphaned cross-chunk parent references
136-
all_annotation_ids = {a["id"] for a in self._annotations if a.get("id")}
137-
orphaned_count = 0
221+
self._report_residual_orphans(annotations, relationships)
222+
return result
223+
224+
# ------------------------------------------------------------------
225+
# finalize() helpers — dedupe overlap + re-link cross-boundary refs
226+
# ------------------------------------------------------------------
227+
228+
def _dedupe_pages(self) -> list[PawlsPagePythonType]:
229+
"""Drop duplicate overlap pages (keep first per global index), ordered."""
230+
seen: set[int] = set()
231+
deduped: list[PawlsPagePythonType] = []
232+
for page_data in self._pawls:
233+
page_info = page_data.get("page", {})
234+
if "index" not in page_info:
235+
# A page without a global index collapses to 0 below, so all but
236+
# the first such page would be silently dropped as "duplicates".
237+
# Surface it: this signals a malformed PAWLs page upstream.
238+
logger.warning(
239+
"PAWLs page missing 'index' during reassembly dedup; "
240+
"defaulting to 0 (malformed page?)",
241+
extra={"metric": "chunk_reassembly_page_missing_index"},
242+
)
243+
idx = page_info.get("index", 0)
244+
if idx in seen:
245+
continue
246+
seen.add(idx)
247+
deduped.append(page_data)
248+
deduped.sort(key=lambda p: p.get("page", {}).get("index", 0))
249+
return deduped
250+
251+
def _dedupe_annotations(
252+
self,
253+
) -> tuple[list[OpenContractsAnnotationPythonType], dict[Any, Any]]:
254+
"""Collapse annotations duplicated across an overlap zone.
255+
256+
Returns the surviving annotations (first copy per signature wins, so the
257+
owning/earlier chunk's copy is canonical) and an ``id_remap`` mapping each
258+
dropped duplicate's global ID to the surviving canonical ID — used to
259+
re-link relationships and ``parent_id`` references that pointed at a
260+
dropped copy.
261+
"""
262+
canonical_by_sig: dict[tuple, Any] = {}
263+
id_remap: dict[Any, Any] = {}
264+
deduped: list[OpenContractsAnnotationPythonType] = []
265+
138266
for ann in self._annotations:
267+
sig = _annotation_signature(ann)
268+
ann_id = ann.get("id")
269+
if sig in canonical_by_sig:
270+
canonical_id = canonical_by_sig[sig]
271+
if ann_id is not None and canonical_id is not None:
272+
id_remap[ann_id] = canonical_id
273+
continue
274+
canonical_by_sig[sig] = ann_id
275+
deduped.append(ann)
276+
277+
# Re-anchor surviving parent_id references onto the canonical IDs.
278+
for ann in deduped:
139279
pid = ann.get("parent_id")
140-
if pid is not None and pid not in all_annotation_ids:
141-
orphaned_count += 1
142-
143-
if orphaned_count > 0:
144-
logger.debug(
145-
f"Reassembly produced {orphaned_count} orphaned parent_id "
146-
f"reference(s). Cross-chunk parent-child relationships cannot "
147-
f"be preserved when chunks are parsed independently."
148-
)
280+
if pid is not None and pid in id_remap:
281+
ann["parent_id"] = id_remap[pid]
149282

150-
return result
283+
return deduped, id_remap
284+
285+
def _relink_and_dedupe_relationships(
286+
self, id_remap: dict[Any, Any]
287+
) -> list[OpenContractsRelationshipPythonType]:
288+
"""Rewrite endpoint IDs through ``id_remap`` then drop duplicates.
289+
290+
Re-linking lets a relationship authored in one chunk reference annotations
291+
whose canonical copy lives in another chunk; once both copies of a
292+
boundary-spanning relationship point at the same global IDs they dedupe to
293+
a single edge.
294+
"""
295+
seen: set[tuple] = set()
296+
deduped: list[OpenContractsRelationshipPythonType] = []
297+
for rel in self._relationships:
298+
rel["source_annotation_ids"] = [
299+
id_remap.get(sid, sid) for sid in rel.get("source_annotation_ids", [])
300+
]
301+
rel["target_annotation_ids"] = [
302+
id_remap.get(tid, tid) for tid in rel.get("target_annotation_ids", [])
303+
]
304+
sig = _relationship_signature(rel)
305+
if sig in seen:
306+
continue
307+
seen.add(sig)
308+
deduped.append(rel)
309+
return deduped
310+
311+
def _report_residual_orphans(
312+
self,
313+
annotations: list[OpenContractsAnnotationPythonType],
314+
relationships: list[OpenContractsRelationshipPythonType],
315+
) -> None:
316+
"""Log + meter references that survived dedup but resolve to nothing.
317+
318+
With sufficient overlap every boundary-spanning reference is re-linked, so
319+
a non-zero count means a structure spanned *more* pages than ``overlap``.
320+
This is surfaced for observability but is never fatal.
321+
"""
322+
all_ids = {a["id"] for a in annotations if a.get("id")}
323+
orphan_parents = sum(
324+
1
325+
for a in annotations
326+
if a.get("parent_id") is not None and a.get("parent_id") not in all_ids
327+
)
328+
orphan_rel_refs = 0
329+
for rel in relationships:
330+
for ref in (
331+
*rel.get("source_annotation_ids", []),
332+
*rel.get("target_annotation_ids", []),
333+
):
334+
if ref not in all_ids:
335+
orphan_rel_refs += 1
336+
337+
total = orphan_parents + orphan_rel_refs
338+
if total:
339+
logger.warning(
340+
"Chunk reassembly left %d residual cross-boundary orphan "
341+
"reference(s) (%d parent_id, %d relationship endpoint) after "
342+
"overlap dedup + re-linking — a structure likely spans more "
343+
"pages than the configured chunk overlap.",
344+
total,
345+
orphan_parents,
346+
orphan_rel_refs,
347+
extra={
348+
"metric": "chunk_reassembly_orphan_refs",
349+
"orphan_parent_refs": orphan_parents,
350+
"orphan_relationship_refs": orphan_rel_refs,
351+
},
352+
)

0 commit comments

Comments
 (0)