|
7 | 7 | """ |
8 | 8 |
|
9 | 9 | import logging |
10 | | -from typing import Optional |
| 10 | +from typing import Any, Optional |
11 | 11 |
|
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 | +) |
13 | 17 | from opencontractserver.types.dicts import ( |
14 | 18 | OpenContractDocExport, |
15 | 19 | OpenContractsAnnotationPythonType, |
|
19 | 23 |
|
20 | 24 | logger = logging.getLogger(__name__) |
21 | 25 |
|
| 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 | + |
22 | 91 |
|
23 | 92 | def offset_annotation( |
24 | 93 | annotation: OpenContractsAnnotationPythonType, |
@@ -76,6 +145,7 @@ def __init__(self) -> None: |
76 | 145 | self._doc_labels: list[str] = [] |
77 | 146 | self._seen_doc_labels: set[str] = set() |
78 | 147 | self._total_pages = 0 |
| 148 | + self._finalized = False |
79 | 149 |
|
80 | 150 | def add_chunk( |
81 | 151 | self, |
@@ -120,31 +190,163 @@ def add_chunk( |
120 | 190 | def finalize(self) -> OpenContractDocExport: |
121 | 191 | if self._first is None: |
122 | 192 | 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 |
123 | 209 |
|
124 | 210 | result: OpenContractDocExport = { |
125 | 211 | "title": self._first.get("title", ""), |
126 | 212 | "content": "\n".join(self._content_parts), |
127 | 213 | "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, |
130 | 216 | "doc_labels": self._doc_labels, |
131 | | - "labelled_text": self._annotations, |
132 | | - "relationships": self._relationships, |
| 217 | + "labelled_text": annotations, |
| 218 | + "relationships": relationships, |
133 | 219 | } |
134 | 220 |
|
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 | + |
138 | 266 | 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: |
139 | 279 | 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] |
149 | 282 |
|
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