Coverage for src / quber / core / extractors / set_of_mark / assemble.py: 81%
47 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-09-23 22:14 -0400
« prev ^ index » next coverage.py v7.14.0, created at 2026-09-23 22:14 -0400
1"""Turn a cell grid and its geometry into a finished `ExtractedTable`.
3Everything after the cells are in hand is the same work whichever tool produced
4them: correct the structure against the table's image, ground every corrected
5cell in a measured box, verify the statuses that carry no box, and record what
6the header area printed that no cell carries.
8Two callers hand cells in. The Set-of-Mark pipeline hands in a region-constrained
9Camelot grid. The page-scan workflow hands in the grid a scan read off a table
10printed as an image, where there was no text layer for Camelot to read. Both get
11back the same object, filled the same way, because the correction and grounding
12steps read the table's image and the page's own text and never ask which tool
13found the cells.
14"""
16from __future__ import annotations
18import asyncio
19from dataclasses import dataclass
20from pathlib import Path
21from typing import List, Literal, Optional, Tuple
23from quber.agents.llm_client import LLMClient
24from quber.agents.status_inspector import StatusInspector
25from quber.core.extractors.base import (
26 ExtractedTable,
27 MergedCellBox,
28 grid_fingerprint,
29 grounded_grid,
30 table_address,
31)
32from quber.core.extractors.camelot.acquire import grid_to_markdown
33from quber.core.extractors.camelot.correspondence.correction import correct_structure, printed_title
34from quber.core.extractors.set_of_mark.inspection import inspect_gap_cells
35from quber.core.extractors.set_of_mark.merge_grounding import (
36 find_dropped_header_text,
37 locate_markers,
38 log_ungrounded,
39 log_ungrounded_cells,
40 resolve_corrected_grid,
41 resolve_merges,
42)
44Box = Tuple[float, float, float, float]
45NormBox = Tuple[float, float, float, float]
46TableKind = Literal["text_table", "chart", "image_table"]
49@dataclass
50class TableAssembly:
51 """Run-scoped inputs the assembly needs, the same for every table in a run."""
53 source: Path
54 llm: LLMClient
55 correct_sem: asyncio.Semaphore
56 dpi: int
57 # Verifies each unboxed cell's proposed status against the table image; None
58 # disables inspection and the proposed statuses stand.
59 inspector: Optional[StatusInspector] = None
62@dataclass
63class CamelotOrigin:
64 """The Camelot extraction a table's cells came from.
66 Absent for cells no Camelot pass produced, whose `bbox`, `flavor` and
67 `camelot_accuracy` then keep their defaults, because there is no extraction
68 for those fields to describe.
69 """
71 bbox: Optional[Box]
72 flavor: Literal["lattice", "stream", "unknown"]
73 accuracy: float
76async def assemble_table(
77 assembly: TableAssembly,
78 page: int,
79 ordinal: int,
80 page_image: Path,
81 page_dims: Tuple[float, float],
82 cells: List[List[str]],
83 cell_boxes: List[List[Optional[Box]]],
84 bbox: Optional[Box],
85 title: str = "",
86 som_region: Optional[NormBox] = None,
87 kind: TableKind = "text_table",
88 camelot: Optional[CamelotOrigin] = None,
89 ground_values: bool = True,
90) -> ExtractedTable:
91 """One table, corrected and grounded, from its cells and their boxes.
93 `bbox` is the table's box in PDF points with a bottom-left page origin. It
94 scopes the image crop the correction reads and the page text the grounding
95 guard allows, so an adjacent table can never bleed in. Without it there is
96 nothing to crop, so the cells are rendered as they arrived and the fields the
97 correction fills stay empty.
99 `cell_boxes` is shaped exactly like `cells`, in the same frame as `bbox`, and
100 may hold None wherever no box was measured. `title` is the identity read off
101 the page image before the cells were captured; it stands only when no
102 correction ran, and only if the page prints it. A correction's title is the
103 printed name copied off the page, or empty when none is printed, and that is
104 what the table carries.
105 """
106 page_w, page_h = page_dims
107 correction = await correct_structure(
108 cells,
109 page_image,
110 bbox,
111 str(assembly.source),
112 page,
113 assembly.llm,
114 assembly.correct_sem,
115 assembly.dpi,
116 ground_values,
117 )
118 # The pre-correction grid paired with per-cell geometry; values are immutable
119 # through correction, so it stays joinable to the final markdown. Boxes
120 # normalize to the page frame here so the whole table reads in one frame.
121 cell_grid = grounded_grid(cells, cell_boxes, page_w, page_h)
123 table_id = table_address(assembly.source, page, ordinal)
124 fingerprint = grid_fingerprint(cells)
125 origin_bbox = camelot.bbox if camelot else None
126 flavor: Literal["lattice", "stream", "unknown"] = camelot.flavor if camelot else "unknown"
127 accuracy = camelot.accuracy if camelot else 0.0
129 if correction is None:
130 return ExtractedTable(
131 table_id=table_id,
132 content_fingerprint=fingerprint,
133 title=await printed_title(title, str(assembly.source), page),
134 markdown=grid_to_markdown(cells),
135 page=page,
136 source=str(assembly.source),
137 som_region=som_region,
138 kind=kind,
139 cell_grid=cell_grid,
140 corrected_grid=cell_grid,
141 bbox=origin_bbox,
142 flavor=flavor,
143 camelot_accuracy=accuracy,
144 )
146 # Cells the agent combined resolve by the source ADDRESSES it read off the
147 # printed coordinate frame: address -> cell box, text-validated. The
148 # grounding stage then closes the whole table: corrected_grid gives every
149 # corrected cell its measured box, so nothing downstream re-derives geometry.
150 merged: List[MergedCellBox] = []
151 if correction.cell_merges:
152 merged = resolve_merges(correction.cell_merges, cell_grid, correction.markdown)
153 log_ungrounded(merged, page)
154 corrected_grid = resolve_corrected_grid(
155 correction.markdown, merged, cell_grid, correction.footnote_refs, correction.region_text
156 )
157 # Classified statuses are text-evidence hypotheses; the inspector verifies
158 # each against the table image, and whatever it cannot positively confirm
159 # downgrades to `unverified` for user inspection.
160 await inspect_gap_cells(
161 corrected_grid,
162 correction.markdown,
163 str(assembly.source),
164 page,
165 page_image,
166 bbox,
167 assembly.dpi,
168 assembly.inspector,
169 )
170 log_ungrounded_cells(corrected_grid, page)
171 dropped_text = find_dropped_header_text(
172 corrected_grid,
173 cell_grid,
174 " ".join(
175 [correction.title, correction.caption, correction.units]
176 + [f"{f.marker} {f.text}".strip() for f in correction.footnotes]
177 ),
178 page,
179 )
180 return ExtractedTable(
181 table_id=table_id,
182 content_fingerprint=fingerprint,
183 title=correction.title,
184 caption=correction.caption,
185 markdown=correction.markdown,
186 footnotes=correction.footnotes,
187 footnote_refs=correction.footnote_refs,
188 footnote_marks=locate_markers(
189 correction.footnote_marks,
190 correction.footnote_refs,
191 correction.markdown,
192 correction.footnotes,
193 # Both titles: the correction sometimes strips a marker suffix the
194 # image-read title retains.
195 f"{correction.title} {title} {correction.caption}",
196 ),
197 units=correction.units,
198 header_rows=correction.header_rows,
199 llm_corrected=correction.llm_corrected,
200 corrected_grid=corrected_grid,
201 merged_cells=merged,
202 dropped_text=dropped_text,
203 page=page,
204 source=str(assembly.source),
205 som_region=som_region,
206 kind=kind,
207 cell_grid=cell_grid,
208 bbox=origin_bbox,
209 flavor=flavor,
210 camelot_accuracy=accuracy,
211 )