Coverage for src / quber / review / flags.py: 94%

51 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-09-23 22:14 -0400

1"""Flagged-decision review HTML — the human view of the run's flags record. 

2 

3One section per table carrying any flagged decision: the table as printed (a 

4page crop with the table's boundary drawn), the extraction output with the 

5flagged cells highlighted — red for a defect the page contradicts, gold for a 

6cell that could not be verified, green for a conventional label the extraction 

7added — and the reasoning recorded for each flag. Printed text that reached no 

8output cell (a table-level 'header_text_dropped' flag) is listed with the 

9reasoning, since it has no output cell to highlight. 

10 

11Rendered from tables already extracted, no extraction of its own. The 

12stylesheet carries print rules (A4 landscape, one table per page), so printing 

13the HTML to PDF yields the same paginated document. 

14""" 

15 

16from __future__ import annotations 

17 

18from html import escape 

19from pathlib import Path 

20from typing import List, Optional, Sequence, Tuple 

21 

22from PIL.Image import Image 

23 

24from quber.core.extractors import ExtractedTable 

25from quber.core.extractors.base import CELL_STATUS_REFERENCE, GroundedCell 

26from quber.review.html import before_b64 

27 

28DEFAULT_DPI = 150 

29 

30# Every status registered for inspection renders here — the document shows 

31# ALL flagged decisions, not only the failures. ('header_text_dropped' is table-level and 

32# never appears on a cell; it renders in the reasoning list instead.) 

33RENDERED_STATUSES = frozenset(s.code for s in CELL_STATUS_REFERENCE if s.inspect) 

34 

35CSS = """ 

36@page { size: A4 landscape; margin: 13mm; } 

37body { font-family: 'Liberation Serif', serif; color: #1a1a1a; font-size: 10.5pt; } 

38h1 { font-size: 13.5pt; color: #1f3756; border-bottom: 2px solid #46647f; padding-bottom: 4px; } 

39h2 { font-size: 11pt; color: #46647f; margin: 10px 0 3px 0; } 

40.pagebreak { page-break-before: always; } 

41p, li { line-height: 1.45; } 

42img.capture { max-width: 100%; max-height: 300px; border: 1px solid #ccc; } 

43table.md { border-collapse: collapse; width: 100%; margin-top: 6px; font-size: 7.2pt; } 

44table.md th { background: #1f3756; color: #fff; padding: 3px 5px; text-align: left; font-size: 7pt; } 

45table.md td { border: 1px solid #b9c6d4; padding: 2.5px 5px; } 

46table.md tr:nth-child(even) td { background: #eef3f9; } 

47table.md th.defect, table.md td.defect { background: #f7d9d9; color: #7a1212; border: 2px solid #c81e1e; } 

48table.md th.unverified, table.md td.unverified { background: #f5e3bb; color: #6b4e0e; border: 2px solid #bd8b1c; } 

49table.md th.total_label_added, table.md td.total_label_added, 

50table.md th.header_label_added, table.md td.header_label_added 

51 { background: #e2efe5; color: #205231; border: 2px solid #2f6b3f; } 

52span.defect { color: #c81e1e; font-weight: bold; } 

53span.unverified { color: #bd8b1c; font-weight: bold; } 

54span.total_label_added, span.header_label_added { color: #2f6b3f; font-weight: bold; } 

55span.header_text_dropped { color: #46647f; font-weight: bold; } 

56""" 

57 

58 

59def render_flag_page(pdf: Path, page: int, dpi: int) -> Optional[Image]: 

60 """Rasterize one page of the PDF; None when the page cannot be rendered.""" 

61 from pdf2image import convert_from_path 

62 

63 imgs = convert_from_path(str(pdf), dpi=dpi, fmt="png", first_page=page, last_page=page) 

64 return imgs[0] if imgs else None 

65 

66 

67def _grid_html(grid: List[List[GroundedCell]]) -> str: 

68 rows = [] 

69 for r, row in enumerate(grid): 

70 tag = "th" if r == 0 else "td" 

71 cells = [] 

72 for cell in row: 

73 cls = f' class="{cell.status}"' if cell.status in RENDERED_STATUSES else "" 

74 cells.append(f"<{tag}{cls}>{escape(cell.text) or '&nbsp;'}</{tag}>") 

75 rows.append("<tr>" + "".join(cells) + "</tr>") 

76 return '<table class="md">' + "".join(rows) + "</table>" 

77 

78 

79def _reasoning_html(flagged: List[Tuple[int, int, GroundedCell]], dropped: Sequence[str]) -> str: 

80 items = [] 

81 for r, c, cell in flagged: 

82 note = escape(cell.note or "(no inspector evidence recorded)") 

83 items.append( 

84 f"<li><b>({r},{c})</b> <span class='{cell.status}'>{cell.status}</span> " 

85 f"'{escape(cell.text)}': {note}</li>" 

86 ) 

87 for fragment in dropped: 

88 items.append( 

89 f"<li><b>(table)</b> <span class='header_text_dropped'>header_text_dropped</span> " 

90 f"'{escape(fragment)}': printed above the table's first value row but carried " 

91 "into no output cell.</li>" 

92 ) 

93 return "<ul>" + "".join(items) + "</ul>" 

94 

95 

96def render_flags_html( 

97 items: Sequence[Tuple[Path, Sequence[ExtractedTable]]], 

98 out_path: Path, 

99 *, 

100 dpi: int = DEFAULT_DPI, 

101) -> Optional[Path]: 

102 """Render the flagged-decision review document for already-extracted PDFs. 

103 

104 ``items`` pairs each source PDF with its extracted tables, the same shape 

105 ``render_review_html`` takes. Only tables carrying at least one flagged 

106 decision (a cell whose status is registered for inspection, or dropped 

107 printed text) get a section; when no table qualifies, nothing is written 

108 and None is returned. 

109 """ 

110 sections: List[str] = [] 

111 for pdf, tables in items: 

112 for ft in tables: 

113 flagged = [ 

114 (r, c, cell) 

115 for r, row in enumerate(ft.corrected_grid or []) 

116 for c, cell in enumerate(row) 

117 if cell.status in RENDERED_STATUSES 

118 ] 

119 if not flagged and not ft.dropped_text: 

120 continue 

121 

122 box = ft.content_region or ft.som_region 

123 crop_region = ft.som_region or ft.content_region 

124 img = render_flag_page(pdf, ft.page, dpi) if box and crop_region else None 

125 img_html = ( 

126 f"<img class='capture' src='data:image/png;base64,{before_b64(img, box, crop_region)}'>" 

127 if img is not None and box and crop_region 

128 else "<p><em>(no page image available)</em></p>" 

129 ) 

130 heading = escape(f"{ft.table_id or f'p{ft.page}'}: {ft.title or '(untitled)'}") 

131 sections.append( 

132 "<div class='pagebreak'>" 

133 f"<h1>{heading}</h1>" 

134 f"<h2>As printed (page {ft.page})</h2>{img_html}" 

135 "<h2>Extraction output (red = defect, gold = unverified, green = added label)</h2>" 

136 f"{_grid_html(ft.corrected_grid)}" 

137 f"<h2>Flagged decisions</h2>{_reasoning_html(flagged, ft.dropped_text)}" 

138 "</div>" 

139 ) 

140 

141 if not sections: 

142 return None 

143 

144 intro = ( 

145 "<p>Each section shows one table with flagged decisions: the table as printed, the " 

146 "extraction output with the flagged cells highlighted — red where the page contradicts " 

147 "the output (defect), gold where the output could not be verified against the page " 

148 "(unverified), green where the extraction added a conventional label the page does not " 

149 "print (total_label_added / header_label_added) — and the reasoning recorded for each. " 

150 "Printed text that reached no output cell is listed as 'header_text_dropped'.</p>" 

151 ) 

152 out_path.write_text( 

153 f"<!doctype html><html><head><meta charset='utf-8'><style>{CSS}</style></head><body>" 

154 f"<h1>Review queue — flagged extraction decisions</h1>{intro}" + "".join(sections) + "</body></html>", 

155 encoding="utf-8", 

156 ) 

157 return out_path