Coverage for src / quber / core / figures / nominate.py: 68%
74 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"""Which pages get scanned, read off the parse.
3ADE is the right reader for three things a financial document prints on a page,
4and nomination is how we aim it at them.
6It chooses pages, not regions. ADE locates and bounds whatever it finds on the
7page it is handed, so there is nothing here to bound and no second judgement to
8make. The only decision is where to send it.
10The parse already reports all three signals, so nomination reads work that has
11been done rather than doing any of its own, and costs nothing:
13- A picture the parse classified as a chart. Plotted values are the whole point
14 of a chart, and no component in the pipeline reads them.
15- A picture the parse classified as something else with content in it — a
16 photograph, a map, a diagram, a calendar. Also read by nothing today.
17- A table with no text layer under it. Its cells hold text, so something read
18 them, and the only thing that could have is the parse reading the page image.
19 Such a table never reached the Set-of-Mark and Camelot engine either, because
20 that engine extracts from a text layer and there was none, so what stands in
21 the document is the parse's own reading with nothing having checked it since.
22 `table_verdicts` measures this against the source document rather than against
23 a record written earlier, so it holds however the tables have been renumbered
24 since and needs no artifact beyond the two the workflow already takes.
26Logos and icons are left where they are. A wordmark is printed on every page of a
27deck as identity rather than content, and there is nothing inside it to read.
29Every nominated page is scanned; nothing filters the list after it is built.
30Nomination is a cost optimization, not a guarantee: a page whose content the
31parse never classified — an infographic drawn entirely in the text layer — has
32no signal here and is invisible to it. The orchestrator's `pages="all"` mode
33exists for exactly those documents, scanning every page so nothing depends on
34the parse having seen what matters.
35"""
37from __future__ import annotations
39from pathlib import Path
40from typing import Dict, List, Mapping, Optional
42from docling_core.types.doc.base import CoordOrigin
43from docling_core.types.doc.document import DoclingDocument, PictureItem, TableItem
44from loguru import logger
45from pydantic import BaseModel, Field
47from quber.agents.completeness import page_words
49#: Picture classes the parse assigns to page furniture — the same mark repeated
50#: on every page rather than document content. A scan reads them as readily as it
51#: reads a chart, and there is nothing inside them to read.
52FURNITURE_CLASSES = frozenset({"logo", "icon"})
54#: Table verdicts that mean no text layer stood under the table's box, so what
55#: the document holds is the parse's own reading of the page image. `empty` is
56#: included because a table region with no cells under it at all had no text to
57#: read either.
58UNREAD_VERDICTS = frozenset({"ocr", "empty"})
60#: Text-layer words under a table's box, as a fraction of the cells the table
61#: holds, below which the table was not read from a text layer. A table typeset
62#: as text carries at least one word per filled cell and usually several; a table
63#: printed as an image carries none at all, so the margin between the two is
64#: wide and the exact fraction does not decide any real case.
65TEXT_LAYER_RATIO = 0.5
68class NominatedPage(BaseModel):
69 """One page the parse says holds something nothing has read."""
71 page: int = Field(description="1-based source page")
72 picture_refs: List[str] = Field(
73 default_factory=list, description="The pictures on the page that nominated it, in document order"
74 )
75 picture_classes: List[str] = Field(
76 default_factory=list, description="The class the parse assigned each of them"
77 )
78 table_refs: List[str] = Field(
79 default_factory=list,
80 description="Tables on the page the parse read off the page image, in document order",
81 )
84def nominate_pages(
85 document: DoclingDocument,
86 table_verdicts: Optional[Mapping[str, str]] = None,
87) -> List[NominatedPage]:
88 """Pages of `document` holding a picture or a table nothing has read.
90 `table_verdicts` maps a table's `self_ref` to the parse's OCR-versus-native
91 verdict for it. Omitted, no table nominates a page and only the pictures do.
92 """
93 verdicts = table_verdicts or {}
94 by_page: dict[int, NominatedPage] = {}
96 def page_entry(page: int) -> NominatedPage:
97 return by_page.setdefault(page, NominatedPage(page=page))
99 for picture in document.pictures:
100 if not picture.prov:
101 continue
102 classes = picture_classes(picture)
103 if classes and all(c in FURNITURE_CLASSES for c in classes):
104 continue
105 nominated = page_entry(picture.prov[0].page_no)
106 nominated.picture_refs.append(picture.self_ref)
107 nominated.picture_classes.extend(classes)
109 for table in document.tables:
110 if not table.prov:
111 continue
112 if verdicts.get(table.self_ref) not in UNREAD_VERDICTS:
113 continue
114 page_entry(table.prov[0].page_no).table_refs.append(table.self_ref)
116 return [by_page[p] for p in sorted(by_page)]
119def table_verdicts(document: DoclingDocument, source: Path) -> Dict[str, str]:
120 """Whether each table's text stands in the page's text layer, keyed by reference.
122 A table is `native` when the page prints text under its box, and `ocr` when
123 it holds cells the page has no text for — the parse read those off the page
124 image. The comparison is a count of words under the box against the cells the
125 table holds, and the two cases are far apart: a table typeset as text carries
126 at least a word per filled cell, one printed as an image carries none.
128 A page whose text cannot be read at all leaves its tables `native`, so a
129 failure here never sends a page to be scanned on a signal nobody measured.
130 """
131 by_page: Dict[int, List[TableItem]] = {}
132 for table in document.tables:
133 if table.prov:
134 by_page.setdefault(table.prov[0].page_no, []).append(table)
136 verdicts: Dict[str, str] = {}
137 for page, tables in sorted(by_page.items()):
138 try:
139 _width, height, words = page_words(source, page)
140 except Exception as exc:
141 logger.warning(
142 "Nomination: page {} text layer unreadable ({}: {}); its {} table(s) are taken as "
143 "typeset text and none is nominated",
144 page,
145 type(exc).__name__,
146 exc,
147 len(tables),
148 )
149 continue
150 for table in tables:
151 filled = sum(1 for c in table.data.table_cells if (c.text or "").strip())
152 under = sum(1 for w in words if _center_in(w, _top_left_box(table, height)))
153 verdicts[table.self_ref] = "native" if under >= TEXT_LAYER_RATIO * filled else "ocr"
154 return verdicts
157def _top_left_box(table: TableItem, page_height: float) -> tuple[float, float, float, float]:
158 """A table's provenance box as (left, top, right, bottom) in top-left points."""
159 bbox = table.prov[0].bbox
160 if bbox.coord_origin == CoordOrigin.TOPLEFT:
161 return (bbox.l, min(bbox.t, bbox.b), bbox.r, max(bbox.t, bbox.b))
162 return (bbox.l, page_height - max(bbox.t, bbox.b), bbox.r, page_height - min(bbox.t, bbox.b))
165def _center_in(word: tuple[float, float, float, float, str], box: tuple[float, float, float, float]) -> bool:
166 """True when a word's centre falls inside a top-left box."""
167 left, top, right, bottom = box
168 return left <= (word[0] + word[2]) / 2 <= right and top <= (word[1] + word[3]) / 2 <= bottom
171def picture_classes(picture: PictureItem) -> List[str]:
172 """The parse's top predicted class for a picture (empty if unclassified).
174 Predictions are ordered by descending confidence, so the first entry is the
175 call. Reads the current `meta.classification` field, falling back to the
176 deprecated `annotations` list for output written before that move.
177 """
178 meta = getattr(picture, "meta", None)
179 classification = getattr(meta, "classification", None) if meta is not None else None
180 predictions = getattr(classification, "predictions", None) if classification is not None else None
181 if predictions:
182 return [predictions[0].class_name]
184 classes: List[str] = []
185 for ann in getattr(picture, "annotations", []):
186 predicted = getattr(ann, "predicted_classes", None)
187 if predicted:
188 classes.append(predicted[0].class_name)
189 return classes