Avinash Nalla commited on
Commit
d618a6e
·
1 Parent(s): 189d6c8

Phase 1: heat code extraction and multi-folder MTR routing

Browse files
app/lib/utils/brief_html.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Daily Brief HTML rendering.
2
+
3
+ Pure-stdlib module — has no dependencies on the rest of the app, so it can
4
+ be imported by preview scripts, tests, and the scheduler alike. The shape
5
+ of the items list matches a SELECT * from the `daily_brief` table.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+
11
+
12
+ def build_brief_html(items: list[dict]) -> str:
13
+ """Render the Daily Brief email body from `daily_brief` rows.
14
+
15
+ Sections rendered (in order, only if non-empty):
16
+ 1. Ambiguous Routings — `reason == "ambiguous"`
17
+ 2. MTRs Missing Heat Code — `reason == "mtr_no_heat_code"`
18
+ 3. Processing Errors — `reason == "error"`
19
+
20
+ If all sections are empty, returns a short "nothing to report" body.
21
+ """
22
+ ambiguous = [i for i in items if i["reason"] == "ambiguous"]
23
+ no_heat = [i for i in items if i["reason"] == "mtr_no_heat_code"]
24
+ errors = [i for i in items if i["reason"] == "error"]
25
+
26
+ sections: list[str] = []
27
+
28
+ # One-line summary at top so ops doesn't need to count rows.
29
+ summary_bits = []
30
+ if ambiguous: summary_bits.append(f"{len(ambiguous)} ambiguous routing(s)")
31
+ if no_heat: summary_bits.append(f"{len(no_heat)} MTR(s) without a heat code")
32
+ if errors: summary_bits.append(f"{len(errors)} processing error(s)")
33
+ if summary_bits:
34
+ sections.append(
35
+ f"<p>Today's automation summary: {'; '.join(summary_bits)}.</p>"
36
+ )
37
+
38
+ if ambiguous:
39
+ rows = ""
40
+ for item in ambiguous:
41
+ scores: dict = (
42
+ json.loads(item["confidence_scores"])
43
+ if item.get("confidence_scores") else {}
44
+ )
45
+ score_cols = "".join(
46
+ f"<td>{doc_type}: {score:.2%}</td>"
47
+ for doc_type, score in sorted(scores.items())
48
+ )
49
+ rows += (
50
+ f"<tr>"
51
+ f"<td>{item.get('sender_email', '')}</td>"
52
+ f"<td>{item.get('subject', '')}</td>"
53
+ f"<td>{item.get('attachment_filename', '')}</td>"
54
+ f"{score_cols}"
55
+ f"</tr>"
56
+ )
57
+ first_scores = (
58
+ json.loads(ambiguous[0]["confidence_scores"])
59
+ if ambiguous[0].get("confidence_scores") else {}
60
+ )
61
+ score_headers = "".join(
62
+ f"<th>{doc_type}</th>" for doc_type in sorted(first_scores.keys())
63
+ )
64
+ sections.append(
65
+ "<h2>Ambiguous Routings</h2>"
66
+ "<p>These attachments scored above the confidence threshold for "
67
+ "more than one document type and were not auto-routed. Review and "
68
+ "file manually.</p>"
69
+ "<table border='1' cellpadding='4' cellspacing='0'>"
70
+ f"<tr><th>Sender</th><th>Subject</th><th>Attachment</th>{score_headers}</tr>"
71
+ f"{rows}"
72
+ "</table>"
73
+ )
74
+
75
+ if no_heat:
76
+ rows = ""
77
+ for item in no_heat:
78
+ rows += (
79
+ f"<tr>"
80
+ f"<td>{item.get('sender_email', '')}</td>"
81
+ f"<td>{item.get('subject', '')}</td>"
82
+ f"<td>{item.get('attachment_filename', '')}</td>"
83
+ f"</tr>"
84
+ )
85
+ sections.append(
86
+ "<h2>MTRs Missing Heat Code</h2>"
87
+ "<p>These MTRs were uploaded to <code>MTR/_no_heat_code/</code> "
88
+ "because no heat code could be extracted from the document. "
89
+ "Please review the file in Dropbox, identify the heat code(s), "
90
+ "and move/copy under the correct <code>MTR/&lt;heat_code&gt;/</code> "
91
+ "folder.</p>"
92
+ "<table border='1' cellpadding='4' cellspacing='0'>"
93
+ "<tr><th>Sender</th><th>Subject</th><th>Attachment</th></tr>"
94
+ f"{rows}"
95
+ "</table>"
96
+ )
97
+
98
+ if errors:
99
+ rows = ""
100
+ for item in errors:
101
+ rows += (
102
+ f"<tr>"
103
+ f"<td>{item.get('sender_email', '')}</td>"
104
+ f"<td>{item.get('subject', '')}</td>"
105
+ f"<td>{item.get('attachment_filename', '')}</td>"
106
+ f"<td>{item.get('error_message', '')}</td>"
107
+ f"</tr>"
108
+ )
109
+ sections.append(
110
+ "<h2>Processing Errors</h2>"
111
+ "<p>These attachments raised an exception during processing. "
112
+ "Stack traces are in the processor log files.</p>"
113
+ "<table border='1' cellpadding='4' cellspacing='0'>"
114
+ "<tr><th>Sender</th><th>Subject</th><th>Attachment</th><th>Error</th></tr>"
115
+ f"{rows}"
116
+ "</table>"
117
+ )
118
+
119
+ if not sections:
120
+ return (
121
+ "<html><body>"
122
+ "<p>No items to report today — all attachments routed cleanly.</p>"
123
+ "</body></html>"
124
+ )
125
+
126
+ return f"<html><body>{''.join(sections)}</body></html>"
app/lib/utils/heat_code.py ADDED
@@ -0,0 +1,427 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Heat code extraction for MTR (Material Test Report) PDFs.
2
+
3
+ Public API:
4
+ extract_heat_codes(file_bytes, filename=None) -> list[str]
5
+ Returns a deduplicated, order-preserving list of heat codes found
6
+ in the document. Empty list if none found — caller's responsibility
7
+ to route to the no-heat-code fallback.
8
+
9
+ Strategy (in order; results unioned, then filtered + deduped):
10
+ 1. pdfplumber table extraction — look for columns whose header matches
11
+ a heat-code label and pull the column's non-empty values.
12
+ 2. Label-anchored regex on extracted text — handles inline "Heat No.: ABC123"
13
+ and similar patterns.
14
+ 3. Filename hint — if the filename contains a plausible heat-code-shaped
15
+ token AND that token also appears in document text, include it.
16
+ 4. OCR fallback for image-only PDFs — runs at 300 DPI with psm 6, then
17
+ reruns strategies 2+3 on the OCR text.
18
+
19
+ Filtering rejects:
20
+ - ASTM/SA/EN/ISO/ASME spec numbers ("A105", "SA-312", "EN 10204", "ASME B16.5")
21
+ - Standalone 4-digit years (2020–2030)
22
+ - Common ALL-CAPS technical words (MAX, MIN, PASS, FAIL, etc.)
23
+ - Template placeholders ("__", "___", "—", "-")
24
+ - Single characters or 1–3 char tokens (too short to be heat codes)
25
+
26
+ Designed to drop into `app/lib/utils/heat_code.py` in the email automation
27
+ repo with no changes — public API and imports are repo-compatible.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import io
32
+ import logging
33
+ import re
34
+ from typing import Iterable
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ # ─── Label patterns ────────────────────────────────────────────────────────────
40
+ # Synonyms for "heat code" that may appear as labels in MTRs. Case-insensitive.
41
+ # Order matters only for logging; matching is union.
42
+ _LABEL_PATTERNS: tuple[str, ...] = (
43
+ r"heat\s*code",
44
+ r"heat\s*no\.?",
45
+ r"heat\s*nr\.?",
46
+ r"heat\s*#",
47
+ r"heat\s*number",
48
+ r"lot\s*no\.?", # Bonney Forge and other small-mill MTRs
49
+ r"lot\s*number",
50
+ )
51
+
52
+ # Matches a single label occurrence, capturing label and trailing token.
53
+ # Heat code token: 4-15 chars, alphanumeric with optional dashes.
54
+ _LABEL_ANCHORED_RE = re.compile(
55
+ r"(?i)(?P<label>" + "|".join(_LABEL_PATTERNS) + r")"
56
+ r"\s*[:\-]?\s*"
57
+ r"(?P<code>[A-Z0-9][A-Z0-9\-]{2,14})",
58
+ )
59
+
60
+ # Standalone heat-code shape (used for filename/table validation).
61
+ # Must start with alphanumeric, 4-15 chars total, alphanumeric + dashes.
62
+ _CODE_SHAPE_RE = re.compile(r"^[A-Z0-9][A-Z0-9\-]{3,14}$", re.IGNORECASE)
63
+
64
+ # Pipe-spec patterns to reject (e.g., "40S", "304L", "80SS" — digit-led + short).
65
+ # Heat codes like "1ZZWN" survive because they have multiple letters in a row.
66
+ _PIPE_SPEC_RE = re.compile(r"^\d{1,4}[A-Z]{1,2}$", re.IGNORECASE)
67
+
68
+ # Tokens to reject regardless of context.
69
+ _SPEC_PREFIXES: tuple[str, ...] = (
70
+ "ASTM", "ASME", "SA-", "EN", "ISO", "DIN", "API", "AISI", "JIS", "NACE",
71
+ "MSS", "MR0",
72
+ )
73
+
74
+ # All-caps words that look code-like but are technical jargon.
75
+ _REJECT_WORDS: frozenset[str] = frozenset({
76
+ "MAX", "MIN", "AVG", "STD", "NA", "N/A", "TBD", "TBC", "ACCEPT", "REJECT",
77
+ "PASS", "FAIL", "PASSED", "FAILED", "OK", "ACCEPTED",
78
+ "REMARK", "REMARKS", "NOTE", "NOTES", "TOTAL", "VALUE", "RESULT",
79
+ "TYPE", "GRADE", "SIZE", "DESCRIPTION", "QTY", "QUANTITY",
80
+ "TEMP", "SPEC", "SPECIFICATION", "REV", "DATE", "PAGE", "BLANK", "NONE",
81
+ "LADLE", "PRODUCT", "MILL", "NORMALIZED", "ANNEALED", "TEMPERED",
82
+ "CUSTOMER", "ITEM", "ORDER", "PO", "INVOICE", "QUANTITY",
83
+ "HEAT", "TREATMENT", "COMPOSITION", "ANALYSIS",
84
+ "BUNDLE", "DRAWING", "MFG", "MFGID", "POR", "TAG",
85
+ "DIMENSION", "DIMENSIONAL", "VISUAL", "CHEMICAL", "MECHANICAL", "HARDNESS",
86
+ "TENSILE", "YIELD", "ELONG", "ELONGATION", "REDUCTION",
87
+ "RESULT", "RESULTS", "EXAMINATION", "INSPECTION", "MATERIAL",
88
+ "TENSION", "LOCATION", "ABBREVIATION", "WELDED", "SEAMLESS",
89
+ "MARKING", "CERTIFIED", "CERTIFICATE", "REPORT",
90
+ })
91
+
92
+ # Spec number patterns to reject (e.g., A312, SA-105, A105-21).
93
+ _SPEC_NUM_RE = re.compile(r"^[A-Z]{1,4}-?\d{2,5}(-\d{1,3})?$", re.IGNORECASE)
94
+
95
+ # Year-like standalone tokens (2020-2030).
96
+ _YEAR_RE = re.compile(r"^(20[2-3]\d)$")
97
+
98
+
99
+ # ─── Public API ────────────────────────────────────────────────────────────────
100
+
101
+ def extract_heat_codes(file_bytes: bytes, filename: str | None = None) -> list[str]:
102
+ """Extract heat codes from an MTR PDF.
103
+
104
+ Returns a deduplicated, ordered list. Empty if no codes can be confidently
105
+ extracted — caller files to a `_no_heat_code/` fallback path.
106
+ """
107
+ candidates: list[tuple[str, str]] = [] # (code, source) for logging
108
+
109
+ # Strategy 1: pdfplumber tables (text-layer PDFs)
110
+ try:
111
+ for code in _from_pdfplumber_tables(file_bytes):
112
+ candidates.append((code, "table"))
113
+ except Exception as e:
114
+ logger.debug("pdfplumber table extraction failed: %s", e)
115
+
116
+ # Strategy 2: column-position aware extraction (text-layer)
117
+ # Handles MTRs where the heat label is a column header and the value sits
118
+ # directly below it in the same X-range (DSI, 24L0604, A8633, KB419-1).
119
+ try:
120
+ for code in _from_column_position(file_bytes):
121
+ candidates.append((code, "column"))
122
+ except Exception as e:
123
+ logger.debug("column-position extraction failed: %s", e)
124
+
125
+ # Strategy 3: label-anchored regex on text-layer extract
126
+ text_layer = _text_layer(file_bytes)
127
+ for code in _from_label_anchored(text_layer):
128
+ candidates.append((code, "text_label"))
129
+
130
+ # Strategy 4: filename hint
131
+ if filename:
132
+ for code in _from_filename(filename, text_layer):
133
+ candidates.append((code, "filename"))
134
+
135
+ # Strategy 5: OCR fallback if we found nothing or text layer is thin
136
+ if not candidates or len(text_layer) < 500:
137
+ try:
138
+ ocr_text = _ocr_text(file_bytes)
139
+ for code in _from_label_anchored(ocr_text):
140
+ candidates.append((code, "ocr_label"))
141
+ for code in _from_ocr_column_heuristic(ocr_text):
142
+ candidates.append((code, "ocr_column"))
143
+ if filename:
144
+ for code in _from_filename(filename, ocr_text):
145
+ candidates.append((code, "ocr_filename"))
146
+ except Exception as e:
147
+ logger.debug("OCR fallback failed: %s", e)
148
+
149
+ # Filter + dedupe (case-insensitive on code; preserve first-seen casing)
150
+ filtered = _filter_and_dedupe(candidates)
151
+
152
+ if logger.isEnabledFor(logging.DEBUG):
153
+ logger.debug(
154
+ "Heat code extraction: %d raw candidates → %d after filter. Final: %s",
155
+ len(candidates), len(filtered), filtered,
156
+ )
157
+
158
+ return filtered
159
+
160
+
161
+ # ─── Strategy implementations ──────────────────────────────────────────────────
162
+
163
+ def _text_layer(file_bytes: bytes) -> str:
164
+ """Extract embedded text layer via pdfplumber. '' if image-only."""
165
+ try:
166
+ import pdfplumber
167
+ with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
168
+ return "\n".join((page.extract_text() or "") for page in pdf.pages[:3])
169
+ except Exception:
170
+ return ""
171
+
172
+
173
+ def _ocr_text(file_bytes: bytes) -> str:
174
+ """OCR first 3 pages at 300 DPI with multiple page-segmentation modes
175
+ to maximize chances of catching small/isolated heat-code text.
176
+
177
+ psm 6 — uniform block of text. Good for regular tables.
178
+ psm 11 — sparse text. Good for small isolated values in form boxes
179
+ (e.g., MTR W000009810's "1ZZWN" in the small HEAT CODE cell).
180
+ psm 4 — single column. Good for column-aligned tables (Flotite).
181
+
182
+ Outputs are concatenated; downstream code is order-insensitive and
183
+ dedupes candidates.
184
+ """
185
+ from pdf2image import convert_from_bytes
186
+ import pytesseract
187
+ imgs = convert_from_bytes(file_bytes, dpi=300, first_page=1, last_page=3)
188
+ parts: list[str] = []
189
+ for img in imgs:
190
+ for psm in ("6", "11", "4"):
191
+ try:
192
+ parts.append(pytesseract.image_to_string(img, config=f"--psm {psm}"))
193
+ except Exception as e:
194
+ logger.debug("OCR psm=%s failed: %s", psm, e)
195
+ return "\n".join(parts)
196
+
197
+
198
+ def _from_pdfplumber_tables(file_bytes: bytes) -> Iterable[str]:
199
+ """Find tables whose header column matches a heat label; yield column values."""
200
+ import pdfplumber
201
+
202
+ label_re = re.compile(r"(?i)" + "|".join(_LABEL_PATTERNS))
203
+
204
+ with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
205
+ for page in pdf.pages[:3]:
206
+ for table in page.extract_tables() or []:
207
+ if not table or len(table) < 2:
208
+ continue
209
+
210
+ # Find heat-label column index by scanning header rows.
211
+ heat_col_idx: int | None = None
212
+ for row in table[: min(3, len(table))]:
213
+ for idx, cell in enumerate(row):
214
+ if cell and label_re.search(str(cell)):
215
+ heat_col_idx = idx
216
+ break
217
+ if heat_col_idx is not None:
218
+ break
219
+
220
+ if heat_col_idx is None:
221
+ continue
222
+
223
+ # Pull values from data rows in that column.
224
+ for row in table:
225
+ if heat_col_idx >= len(row):
226
+ continue
227
+ cell = row[heat_col_idx]
228
+ if not cell:
229
+ continue
230
+ for token in _tokenize_cell(str(cell)):
231
+ if _CODE_SHAPE_RE.match(token):
232
+ yield token
233
+
234
+
235
+ def _from_column_position(file_bytes: bytes) -> Iterable[str]:
236
+ """Find the X-position of a heat-label word, then yield tokens whose
237
+ x-center sits within ±half the label's width on the lines directly below.
238
+
239
+ Handles MTRs where the heat label is a column header (in a wide
240
+ whitespace-aligned table) and the data row sits below, e.g. DSI:
241
+ Part Specif. HEAT No C Si Mn ...
242
+ BODY ASTM A216 WCB-2021 VC9387 0.213 ...
243
+ """
244
+ import pdfplumber
245
+
246
+ label_re = re.compile(r"(?i)" + "|".join(_LABEL_PATTERNS))
247
+
248
+ with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
249
+ for page in pdf.pages[:3]:
250
+ words = page.extract_words(use_text_flow=True) or []
251
+ if not words:
252
+ continue
253
+
254
+ # Group words by approximate line (round y0 to nearest 2pt).
255
+ lines: dict[int, list[dict]] = {}
256
+ for w in words:
257
+ key = round(w["top"] / 2)
258
+ lines.setdefault(key, []).append(w)
259
+
260
+ sorted_keys = sorted(lines.keys())
261
+
262
+ # Look for a label word; capture its x-range; scan lines below.
263
+ for i, key in enumerate(sorted_keys):
264
+ line_words = lines[key]
265
+ # Reconstruct the line text to test label patterns against
266
+ # multi-word labels like "HEAT No" or "Heat Code".
267
+ # Match individual words first, then consider their pairings.
268
+ for j, word in enumerate(line_words):
269
+ pair_text = word["text"]
270
+ if j + 1 < len(line_words):
271
+ pair_text = pair_text + " " + line_words[j + 1]["text"]
272
+
273
+ if not label_re.search(pair_text):
274
+ continue
275
+
276
+ # X-range of the label (use bbox of just this word, or
277
+ # span both words if the match was over the pair).
278
+ x0 = word["x0"]
279
+ x1 = line_words[j + 1]["x1"] if (
280
+ j + 1 < len(line_words) and label_re.search(pair_text)
281
+ and not label_re.search(word["text"])
282
+ ) else word["x1"]
283
+ label_center = (x0 + x1) / 2
284
+ half_width = max((x1 - x0) / 2, 20.0) # min 20pt to be forgiving
285
+
286
+ # Scan up to 6 lines below for tokens whose center is within
287
+ # half-width of the label.
288
+ for next_key in sorted_keys[i + 1 : i + 7]:
289
+ for nw in lines[next_key]:
290
+ nw_center = (nw["x0"] + nw["x1"]) / 2
291
+ if abs(nw_center - label_center) <= half_width:
292
+ tok = nw["text"].strip().strip(",;|")
293
+ if _CODE_SHAPE_RE.match(tok):
294
+ yield tok
295
+
296
+
297
+ def _from_ocr_column_heuristic(ocr_text: str) -> Iterable[str]:
298
+ """For OCR text: when a line contains a heat label, yield code-shaped
299
+ tokens from the next 3 lines. Cheap-and-pretty-good for the cases where
300
+ the OCR preserves line breaks between header and data rows.
301
+ """
302
+ if not ocr_text:
303
+ return
304
+ label_re = re.compile(r"(?i)" + "|".join(_LABEL_PATTERNS))
305
+ lines = ocr_text.splitlines()
306
+ for i, line in enumerate(lines):
307
+ if not label_re.search(line):
308
+ continue
309
+ # Look at the next 3 non-empty lines.
310
+ looked = 0
311
+ for j in range(i + 1, min(i + 8, len(lines))):
312
+ nxt = lines[j].strip()
313
+ if not nxt:
314
+ continue
315
+ looked += 1
316
+ if looked > 3:
317
+ break
318
+ for tok in _tokenize_cell(nxt):
319
+ if _CODE_SHAPE_RE.match(tok):
320
+ yield tok
321
+
322
+
323
+ def _from_label_anchored(text: str) -> Iterable[str]:
324
+ """Yield candidates following 'Heat No.', 'Lot No.', etc."""
325
+ if not text:
326
+ return
327
+ for m in _LABEL_ANCHORED_RE.finditer(text):
328
+ yield m.group("code")
329
+
330
+
331
+ def _from_filename(filename: str, doc_text: str) -> Iterable[str]:
332
+ """If filename contains a heat-code-shaped token that appears (exactly or
333
+ fuzzily) in document text, yield it.
334
+
335
+ Fuzzy match handles OCR-mangled docs where e.g. "SD55822" in the filename
336
+ shows up as "S055822" in the OCR text (D→0 confusion). Uses simple
337
+ same-length / one-edit-away check (good enough for OCR drift).
338
+ """
339
+ # Split filename on whitespace and underscore ONLY — preserve dashes so
340
+ # codes like "Y211213D40-1" and "KB419-1" survive intact.
341
+ base = filename.rsplit(".", 1)[0] # drop .pdf
342
+ tokens = re.split(r"[\s_]+", base)
343
+ text_upper = doc_text.upper() if doc_text else ""
344
+
345
+ for tok in tokens:
346
+ if not _CODE_SHAPE_RE.match(tok):
347
+ continue
348
+ tok_upper = tok.upper()
349
+ if tok_upper in text_upper:
350
+ yield tok
351
+ elif _fuzzy_token_in_text(tok_upper, text_upper, max_edits=2):
352
+ yield tok
353
+
354
+
355
+ def _fuzzy_token_in_text(token: str, text: str, max_edits: int = 2) -> bool:
356
+ """True if `text` contains a same-length window within `max_edits` of `token`.
357
+
358
+ Cheap-and-good-enough: scans every same-length substring and counts char
359
+ differences. O(len(text) * len(token)) but n is small (n ≤ 15) and we
360
+ only call it as a fallback.
361
+ """
362
+ n = len(token)
363
+ if n == 0 or len(text) < n:
364
+ return False
365
+ for i in range(len(text) - n + 1):
366
+ window = text[i : i + n]
367
+ diff = sum(1 for a, b in zip(token, window) if a != b)
368
+ if diff <= max_edits:
369
+ return True
370
+ return False
371
+
372
+
373
+ def _tokenize_cell(cell_text: str) -> list[str]:
374
+ """Split a table cell into candidate tokens, preserving dashes."""
375
+ return [t.strip() for t in re.split(r"[\s,;|]+", cell_text) if t.strip()]
376
+
377
+
378
+ def _filter_and_dedupe(candidates: list[tuple[str, str]]) -> list[str]:
379
+ """Apply rejection rules + dedupe case-insensitively, preserving order.
380
+ Also drops substring duplicates: if 'KB419' and 'KB419-1' are both present,
381
+ keep only the longer one (avoid over-counting when codes get split)."""
382
+ seen: dict[str, str] = {} # upper -> original-cased
383
+
384
+ for code, _source in candidates:
385
+ code = code.strip()
386
+ upper = code.upper()
387
+
388
+ if upper in seen:
389
+ continue
390
+ if not _is_plausible_heat_code(code):
391
+ continue
392
+ seen[upper] = code
393
+
394
+ # Substring dedup: drop any code that is a strict prefix of another.
395
+ uppers = list(seen.keys())
396
+ keep = set(uppers)
397
+ for u in uppers:
398
+ for other in uppers:
399
+ if u != other and other.startswith(u) and len(other) > len(u):
400
+ keep.discard(u)
401
+ break
402
+ return [seen[u] for u in uppers if u in keep]
403
+
404
+
405
+ def _is_plausible_heat_code(code: str) -> bool:
406
+ upper = code.upper()
407
+
408
+ if not _CODE_SHAPE_RE.match(code):
409
+ return False
410
+ if upper in _REJECT_WORDS:
411
+ return False
412
+ if _YEAR_RE.match(upper):
413
+ return False
414
+ # Pipe-spec rejection (40S, 304L, 80SS-style schedule/grade designators).
415
+ if _PIPE_SPEC_RE.match(upper):
416
+ return False
417
+ # Spec numbers — only reject when the token explicitly starts with a known
418
+ # spec prefix (ASTM, ASME, SA-, EN, ISO, etc.). Pure shape ("D460", "KB419-1")
419
+ # overlaps with real heat codes; we don't reject those on shape alone.
420
+ if any(upper.startswith(p) for p in _SPEC_PREFIXES):
421
+ return False
422
+ # Reject all-letter tokens regardless of length — real heat codes have at
423
+ # least one digit. (None of our 14 confirmed samples are pure-alpha.)
424
+ if upper.isalpha():
425
+ return False
426
+
427
+ return True
app/processor.py CHANGED
@@ -26,6 +26,7 @@ from app.database import (
26
  from app.lib.utils.classifier import ClassifyResult, ScoringWeights, classify
27
  from app.lib.utils.dropbox_client import DropboxClient
28
  from app.lib.utils.error_handler import processing_error_handler
 
29
  from app.lib.utils.notifier import send_developer_alert
30
 
31
  logging.basicConfig(
@@ -68,6 +69,19 @@ def _date_prefix(received_dt: str) -> str:
68
  return "00000000"
69
 
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  def _scoring_weights() -> ScoringWeights:
72
  return ScoringWeights(
73
  filename=settings.score_weight_filename,
@@ -182,18 +196,98 @@ async def _process_notification(dbx: DropboxClient, notif: dict) -> None:
182
  else:
183
  # result.reason == "routed"
184
  month_folder = received_dt[:7] if received_dt else "unknown"
185
- dest_path = f"{_DEST_MAP[result.doc_type]}/{month_folder}/{date_prefix}_{filename}"
186
- actual_path = await asyncio.to_thread(dbx.upload, file_bytes, dest_path)
187
- logger.info("Uploaded %s → %s", filename, actual_path)
188
- async with get_db(settings.database_path) as conn:
189
- await log_routing_result(
190
- conn, notif_id, filename, result.doc_type, "uploaded",
191
- email_identifier=email_identifier,
192
- destination_path=actual_path,
193
- confidence_scores=result.scores,
 
 
 
194
  )
195
- uploaded_count += 1
196
- final_status = "routed"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
 
198
  except Exception:
199
  # processing_error_handler already logged and wrote to DB; continue to next attachment.
 
26
  from app.lib.utils.classifier import ClassifyResult, ScoringWeights, classify
27
  from app.lib.utils.dropbox_client import DropboxClient
28
  from app.lib.utils.error_handler import processing_error_handler
29
+ from app.lib.utils.heat_code import extract_heat_codes
30
  from app.lib.utils.notifier import send_developer_alert
31
 
32
  logging.basicConfig(
 
69
  return "00000000"
70
 
71
 
72
+ def _sanitize_path_segment(s: str) -> str:
73
+ """Make a string safe for use as a Dropbox path segment.
74
+
75
+ Strips surrounding whitespace and replaces any character that isn't an
76
+ ASCII letter, digit, dash, dot, or underscore with an underscore. Returns
77
+ "unknown" if the result would be empty.
78
+ """
79
+ import re
80
+ s = (s or "").strip()
81
+ cleaned = re.sub(r"[^A-Za-z0-9._-]", "_", s)
82
+ return cleaned or "unknown"
83
+
84
+
85
  def _scoring_weights() -> ScoringWeights:
86
  return ScoringWeights(
87
  filename=settings.score_weight_filename,
 
196
  else:
197
  # result.reason == "routed"
198
  month_folder = received_dt[:7] if received_dt else "unknown"
199
+ type_path = _DEST_MAP[result.doc_type]
200
+
201
+ if result.doc_type == "mtr":
202
+ # MTRs: extract heat code(s) and upload one copy per
203
+ # code under {mtr_path}/{heat_code}/{YYYY-MM}/...
204
+ # If no heat code can be extracted, fall back to
205
+ # {mtr_path}/_no_heat_code/{YYYY-MM}/... and flag
206
+ # the row in the daily brief for ops to manually re-file.
207
+ heat_codes = extract_heat_codes(file_bytes, filename=filename)
208
+ logger.info(
209
+ "MTR %s heat codes: %s",
210
+ filename, heat_codes or "<none — fallback>",
211
  )
212
+
213
+ if heat_codes:
214
+ for heat_code in heat_codes:
215
+ safe_code = _sanitize_path_segment(heat_code)
216
+ dest_path = (
217
+ f"{type_path}/{safe_code}/{month_folder}/"
218
+ f"{date_prefix}_{filename}"
219
+ )
220
+ actual_path = await asyncio.to_thread(
221
+ dbx.upload, file_bytes, dest_path
222
+ )
223
+ logger.info(
224
+ "Uploaded MTR %s (heat=%s) → %s",
225
+ filename, heat_code, actual_path,
226
+ )
227
+ async with get_db(settings.database_path) as conn:
228
+ await log_routing_result(
229
+ conn, notif_id, filename, "mtr", "uploaded",
230
+ email_identifier=email_identifier,
231
+ destination_path=actual_path,
232
+ confidence_scores=result.scores,
233
+ )
234
+ uploaded_count += 1
235
+ final_status = "routed"
236
+ else:
237
+ # No heat code → fallback location + daily brief flag.
238
+ dest_path = (
239
+ f"{type_path}/_no_heat_code/{month_folder}/"
240
+ f"{date_prefix}_{filename}"
241
+ )
242
+ actual_path = await asyncio.to_thread(
243
+ dbx.upload, file_bytes, dest_path
244
+ )
245
+ logger.info(
246
+ "Uploaded MTR %s (no heat code extracted) → %s",
247
+ filename, actual_path,
248
+ )
249
+ async with get_db(settings.database_path) as conn:
250
+ from app.database import insert_daily_brief
251
+ await log_routing_result(
252
+ conn, notif_id, filename, "mtr",
253
+ "uploaded_no_heat_code",
254
+ email_identifier=email_identifier,
255
+ destination_path=actual_path,
256
+ confidence_scores=result.scores,
257
+ )
258
+ await insert_daily_brief(
259
+ conn,
260
+ email_identifier=email_identifier,
261
+ notification_id=notif_id,
262
+ sender_email=sender_email,
263
+ subject=subject,
264
+ filename=filename,
265
+ reason="mtr_no_heat_code",
266
+ confidence_scores=result.scores,
267
+ )
268
+ uploaded_count += 1
269
+ final_status = "brief_pending"
270
+
271
+ else:
272
+ # po / invoice — single upload at the type root.
273
+ # (Phase 2 will insert a {client} subfolder here based
274
+ # on the sender's email domain.)
275
+ dest_path = (
276
+ f"{type_path}/{month_folder}/{date_prefix}_{filename}"
277
+ )
278
+ actual_path = await asyncio.to_thread(
279
+ dbx.upload, file_bytes, dest_path
280
+ )
281
+ logger.info("Uploaded %s → %s", filename, actual_path)
282
+ async with get_db(settings.database_path) as conn:
283
+ await log_routing_result(
284
+ conn, notif_id, filename, result.doc_type, "uploaded",
285
+ email_identifier=email_identifier,
286
+ destination_path=actual_path,
287
+ confidence_scores=result.scores,
288
+ )
289
+ uploaded_count += 1
290
+ final_status = "routed"
291
 
292
  except Exception:
293
  # processing_error_handler already logged and wrote to DB; continue to next attachment.
app/scheduler.py CHANGED
@@ -13,6 +13,7 @@ from app.database import (
13
  mark_brief_items_sent,
14
  run_retention_cleanup,
15
  )
 
16
  from app.lib.utils.notifier import _build_sendmail_payload, send_developer_alert
17
 
18
  logger = logging.getLogger(__name__)
@@ -121,7 +122,7 @@ class RenewalScheduler:
121
  logger.warning("Daily brief: BRIEF_RECIPIENTS not configured, skipping send")
122
  return
123
 
124
- html = _build_brief_html(items)
125
  path, payload = _build_sendmail_payload(
126
  recipients=recipients,
127
  subject=f"RCM Email Automation — Daily Brief {datetime.now(timezone.utc).strftime('%Y-%m-%d')}",
@@ -173,65 +174,6 @@ class RenewalScheduler:
173
  self._scheduler.shutdown(wait=False)
174
 
175
 
176
- def _build_brief_html(items: list[dict]) -> str:
177
- ambiguous = [i for i in items if i["reason"] == "ambiguous"]
178
- errors = [i for i in items if i["reason"] == "error"]
179
-
180
- sections: list[str] = []
181
-
182
- if ambiguous:
183
- rows = ""
184
- for item in ambiguous:
185
- scores: dict = json.loads(item["confidence_scores"]) if item["confidence_scores"] else {}
186
- score_cols = "".join(
187
- f"<td>{doc_type}: {score:.2%}</td>"
188
- for doc_type, score in sorted(scores.items())
189
- )
190
- rows += (
191
- f"<tr>"
192
- f"<td>{item.get('sender_email', '')}</td>"
193
- f"<td>{item.get('subject', '')}</td>"
194
- f"<td>{item.get('attachment_filename', '')}</td>"
195
- f"{score_cols}"
196
- f"</tr>"
197
- )
198
- score_headers = "".join(
199
- f"<th>{doc_type}</th>"
200
- for doc_type in sorted(
201
- json.loads(ambiguous[0]["confidence_scores"]).keys()
202
- if ambiguous[0]["confidence_scores"] else []
203
- )
204
- )
205
- sections.append(
206
- f"<h2>Ambiguous Routings</h2>"
207
- f"<table border='1' cellpadding='4' cellspacing='0'>"
208
- f"<tr><th>Sender</th><th>Subject</th><th>Attachment</th>{score_headers}</tr>"
209
- f"{rows}"
210
- f"</table>"
211
- )
212
-
213
- if errors:
214
- rows = ""
215
- for item in errors:
216
- rows += (
217
- f"<tr>"
218
- f"<td>{item.get('sender_email', '')}</td>"
219
- f"<td>{item.get('subject', '')}</td>"
220
- f"<td>{item.get('attachment_filename', '')}</td>"
221
- f"<td>{item.get('error_message', '')}</td>"
222
- f"</tr>"
223
- )
224
- sections.append(
225
- f"<h2>Processing Errors</h2>"
226
- f"<table border='1' cellpadding='4' cellspacing='0'>"
227
- f"<tr><th>Sender</th><th>Subject</th><th>Attachment</th><th>Error</th></tr>"
228
- f"{rows}"
229
- f"</table>"
230
- )
231
-
232
- return f"<html><body>{''.join(sections)}</body></html>"
233
-
234
-
235
  def _cleanup_log_files(log_path: Path) -> None:
236
  cutoff = datetime.now(timezone.utc).replace(tzinfo=None)
237
  for log_file in log_path.glob("processor.log.*"):
 
13
  mark_brief_items_sent,
14
  run_retention_cleanup,
15
  )
16
+ from app.lib.utils.brief_html import build_brief_html
17
  from app.lib.utils.notifier import _build_sendmail_payload, send_developer_alert
18
 
19
  logger = logging.getLogger(__name__)
 
122
  logger.warning("Daily brief: BRIEF_RECIPIENTS not configured, skipping send")
123
  return
124
 
125
+ html = build_brief_html(items)
126
  path, payload = _build_sendmail_payload(
127
  recipients=recipients,
128
  subject=f"RCM Email Automation — Daily Brief {datetime.now(timezone.utc).strftime('%Y-%m-%d')}",
 
174
  self._scheduler.shutdown(wait=False)
175
 
176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  def _cleanup_log_files(log_path: Path) -> None:
178
  cutoff = datetime.now(timezone.utc).replace(tzinfo=None)
179
  for log_file in log_path.glob("processor.log.*"):
scripts/preview_brief.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Render a sample Daily Brief email for client sign-off.
2
+
3
+ Runs the production `_build_brief_html()` with realistic synthetic data
4
+ covering all three section types: ambiguous routings, MTRs missing heat
5
+ codes, and processing errors.
6
+
7
+ Usage: python -m scripts.preview_brief [output_path]
8
+
9
+ If output_path is omitted, writes to ./daily_brief_preview.html relative
10
+ to the repo root.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ # Make repo root importable when run via `python scripts/preview_brief.py`
19
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
20
+
21
+ from app.lib.utils.brief_html import build_brief_html
22
+
23
+
24
+ # Synthetic items modeled on real `daily_brief` rows. Confidence scores are
25
+ # JSON-encoded strings because that's how they're stored in SQLite.
26
+ SAMPLE_ITEMS: list[dict] = [
27
+ # Two ambiguous routings — multiple doc types crossed the threshold.
28
+ {
29
+ "id": 1,
30
+ "sender_email": "ap@westlake.com",
31
+ "subject": "RCM - Westlake PO 4502953154 and quote",
32
+ "attachment_filename": "Westlake_PO_4502953154.pdf",
33
+ "reason": "ambiguous",
34
+ "confidence_scores": json.dumps({
35
+ "po": 0.81, "invoice": 0.12, "mtr": 0.03, "quote": 0.74,
36
+ }),
37
+ },
38
+ {
39
+ "id": 2,
40
+ "sender_email": "billing@halliburton.com",
41
+ "subject": "RCM - Halliburton invoice 759085",
42
+ "attachment_filename": "Halliburton_invoice_759085.pdf",
43
+ "reason": "ambiguous",
44
+ "confidence_scores": json.dumps({
45
+ "po": 0.71, "invoice": 0.78, "mtr": 0.01, "quote": 0.05,
46
+ }),
47
+ },
48
+
49
+ # Two MTRs where heat code couldn't be extracted.
50
+ {
51
+ "id": 3,
52
+ "sender_email": "qa@woi-houston.com",
53
+ "subject": "RCM - MTR for WOI order W000009810",
54
+ "attachment_filename": "MTR W000009810.pdf",
55
+ "reason": "mtr_no_heat_code",
56
+ "confidence_scores": json.dumps({
57
+ "po": 0.04, "invoice": 0.02, "mtr": 0.82, "quote": 0.00,
58
+ }),
59
+ },
60
+ {
61
+ "id": 4,
62
+ "sender_email": "certs@flotite.com",
63
+ "subject": "RCM - Material Test Report S3000CSPGGL015MP",
64
+ "attachment_filename": "0.5_Full Port_Part No S3000CSPGGL015MP.pdf",
65
+ "reason": "mtr_no_heat_code",
66
+ "confidence_scores": json.dumps({
67
+ "po": 0.03, "invoice": 0.01, "mtr": 0.79, "quote": 0.00,
68
+ }),
69
+ },
70
+
71
+ # One processing error.
72
+ {
73
+ "id": 5,
74
+ "sender_email": "noreply@example-vendor.com",
75
+ "subject": "RCM - corrupted attachment",
76
+ "attachment_filename": "scan_2026-05-10_corrupted.pdf",
77
+ "reason": "error",
78
+ "error_message": (
79
+ "PDFSyntaxError: No /Root object! - Is this really a PDF? "
80
+ "(stack trace in /logs/processor.log.2026-05-10)"
81
+ ),
82
+ "confidence_scores": None,
83
+ },
84
+ ]
85
+
86
+
87
+ def main() -> None:
88
+ out = Path(sys.argv[1]) if len(sys.argv) > 1 else (
89
+ Path(__file__).resolve().parent.parent / "daily_brief_preview.html"
90
+ )
91
+ html = build_brief_html(SAMPLE_ITEMS)
92
+ out.write_text(html, encoding="utf-8")
93
+ print(f"Wrote {len(html)} bytes to {out}")
94
+ print("Open the file in a browser to preview.")
95
+
96
+
97
+ if __name__ == "__main__":
98
+ main()