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

1"""Which pages get scanned, read off the parse. 

2 

3ADE is the right reader for three things a financial document prints on a page, 

4and nomination is how we aim it at them. 

5 

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. 

9 

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: 

12 

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. 

25 

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. 

28 

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""" 

36 

37from __future__ import annotations 

38 

39from pathlib import Path 

40from typing import Dict, List, Mapping, Optional 

41 

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 

46 

47from quber.agents.completeness import page_words 

48 

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"}) 

53 

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"}) 

59 

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 

66 

67 

68class NominatedPage(BaseModel): 

69 """One page the parse says holds something nothing has read.""" 

70 

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 ) 

82 

83 

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. 

89 

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] = {} 

95 

96 def page_entry(page: int) -> NominatedPage: 

97 return by_page.setdefault(page, NominatedPage(page=page)) 

98 

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) 

108 

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) 

115 

116 return [by_page[p] for p in sorted(by_page)] 

117 

118 

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. 

121 

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. 

127 

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) 

135 

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 

155 

156 

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)) 

163 

164 

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 

169 

170 

171def picture_classes(picture: PictureItem) -> List[str]: 

172 """The parse's top predicted class for a picture (empty if unclassified). 

173 

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] 

183 

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