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
« 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.
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.
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"""
16from __future__ import annotations
18from html import escape
19from pathlib import Path
20from typing import List, Optional, Sequence, Tuple
22from PIL.Image import Image
24from quber.core.extractors import ExtractedTable
25from quber.core.extractors.base import CELL_STATUS_REFERENCE, GroundedCell
26from quber.review.html import before_b64
28DEFAULT_DPI = 150
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)
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"""
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
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
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 ' '}</{tag}>")
75 rows.append("<tr>" + "".join(cells) + "</tr>")
76 return '<table class="md">' + "".join(rows) + "</table>"
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>"
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.
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
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 )
141 if not sections:
142 return None
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