rehan953 commited on
Commit
9fb875b
·
verified ·
1 Parent(s): f6baa95

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +169 -0
app.py CHANGED
@@ -18,6 +18,8 @@ Primary goals for reconcile rate:
18
  - footer extraction enabled on all pages with deduplication guard (no double-print if body OCR already captured it)
19
  - Fix 4: cross-validate OCR table row counts against PDF text layer; inject missing duplicate rows
20
  (safe no-op for scanned PDFs and non-transaction pages)
 
 
21
  """
22
 
23
  # Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
@@ -2188,6 +2190,161 @@ def convert_plaintext_bank_sections(text: str) -> str:
2188
 
2189
  return "\n".join(out)
2190
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2191
  def normalize_html_tables(text: str) -> str:
2192
  """
2193
  For every <table>...</table>:
@@ -2432,6 +2589,18 @@ def run_ocr(uploaded_file):
2432
 
2433
  parts.append(stabilized)
2434
 
 
 
 
 
 
 
 
 
 
 
 
 
2435
  # Footer extraction: PDF text layer -> band words -> OCR crop
2436
  # Deduplication guard prevents double-printing when body OCR already captured it.
2437
  if ENABLE_FOOTER_OCR and page_num < len(page_images):
 
18
  - footer extraction enabled on all pages with deduplication guard (no double-print if body OCR already captured it)
19
  - Fix 4: cross-validate OCR table row counts against PDF text layer; inject missing duplicate rows
20
  (safe no-op for scanned PDFs and non-transaction pages)
21
+ - Fix NF-1: extract Navy Federal "Summary of your deposit accounts" table from PDF text layer
22
+ using spatial word positions (GLM-OCR fails on this wide-column layout)
23
  """
24
 
25
  # Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
 
2190
 
2191
  return "\n".join(out)
2192
 
2193
+ # --------------------------
2194
+ # Navy Federal Summary Table extraction (Fix NF-1)
2195
+ # --------------------------
2196
+
2197
+ def _extract_nfcu_summary_table(pdf_path: str, page_num: int) -> str:
2198
+ """
2199
+ Extract Navy Federal 'Summary of your deposit accounts' table from the PDF
2200
+ text layer using spatial word positions.
2201
+
2202
+ GLM-OCR fails on this table because the five data columns are spread very
2203
+ wide across the page and the account-name column has no visible border.
2204
+ The PDF text layer contains the data perfectly — we read it with pdfplumber.
2205
+
2206
+ Detection guards (all must pass — safe no-op for all other banks):
2207
+ 1. A word 'Summary' must appear in the top 55% of the page height.
2208
+ 2. Words 'deposit' or 'accounts' must appear within 15pt of that word.
2209
+
2210
+ Returns HTML table string, or "" if not found or on any error.
2211
+ """
2212
+ try:
2213
+ import pdfplumber
2214
+ from collections import defaultdict as _defaultdict
2215
+
2216
+ with pdfplumber.open(pdf_path) as pdf:
2217
+ if page_num >= len(pdf.pages):
2218
+ return ""
2219
+ page = pdf.pages[page_num]
2220
+ words = page.extract_words()
2221
+ page_h = float(page.height)
2222
+
2223
+ # Guard 1: find 'Summary' word in top 55% of page
2224
+ summary_word = next(
2225
+ (w for w in words
2226
+ if w['text'].lower() == 'summary' and w['top'] < page_h * 0.55),
2227
+ None
2228
+ )
2229
+ if not summary_word:
2230
+ return ""
2231
+
2232
+ # Guard 2: nearby words must confirm this is a deposit summary
2233
+ nearby = [w['text'].lower() for w in words
2234
+ if abs(w['top'] - summary_word['top']) < 15]
2235
+ if 'deposit' not in nearby and 'accounts' not in nearby:
2236
+ return ""
2237
+
2238
+ # Band: start ~15pt below "Summary of..." heading, span ~115pt
2239
+ y_start = summary_word['top'] + 15
2240
+ y_end = summary_word['top'] + 115
2241
+
2242
+ band_words = [w for w in words if y_start <= w['top'] <= y_end]
2243
+ if not band_words:
2244
+ return ""
2245
+
2246
+ # Group words into lines by y-bucket (5pt tolerance)
2247
+ lines_by_y = _defaultdict(list)
2248
+ for w in band_words:
2249
+ bucket = round(w['top'] / 5) * 5
2250
+ lines_by_y[bucket].append(w)
2251
+
2252
+ rows_raw = [sorted(lines_by_y[y], key=lambda w: w['x0'])
2253
+ for y in sorted(lines_by_y.keys())]
2254
+
2255
+ if len(rows_raw) < 3:
2256
+ return ""
2257
+
2258
+ # Column centers from first two header rows
2259
+ # (two-line header: "Previous Deposits/ Withdrawals/ Ending YTD" /
2260
+ # "Balance Credits Debits Balance Dividends")
2261
+ header_words_flat = [w for r in rows_raw[:2] for w in r]
2262
+ header_xs = sorted((w['x0'] + w['x1']) / 2 for w in header_words_flat)
2263
+
2264
+ col_centers = []
2265
+ cluster = [header_xs[0]]
2266
+ for x in header_xs[1:]:
2267
+ if x - cluster[-1] > 30:
2268
+ col_centers.append(sum(cluster) / len(cluster))
2269
+ cluster = [x]
2270
+ else:
2271
+ cluster.append(x)
2272
+ col_centers.append(sum(cluster) / len(cluster))
2273
+
2274
+ def _assign_col(x_center):
2275
+ return min(range(len(col_centers)),
2276
+ key=lambda i: abs(col_centers[i] - x_center))
2277
+
2278
+ # Build column labels by merging two header rows
2279
+ col_labels = [''] * len(col_centers)
2280
+ for r in rows_raw[:2]:
2281
+ for w in r:
2282
+ xc = (w['x0'] + w['x1']) / 2
2283
+ ci = _assign_col(xc)
2284
+ sep = ' ' if col_labels[ci] else ''
2285
+ col_labels[ci] += sep + w['text']
2286
+
2287
+ # Data rows: each account has a name line + a values line
2288
+ _MONEY_PAT = re.compile(r'^\$[\d,]+\.?\d*-?$|^[\d,]+\.?\d*-?$')
2289
+
2290
+ merged = []
2291
+ current_name = []
2292
+ current_vals = {}
2293
+
2294
+ for line in rows_raw[2:]:
2295
+ texts = [w['text'] for w in line]
2296
+ has_money = any(_MONEY_PAT.match(t) for t in texts)
2297
+ is_totals = any(w['text'].lower() == 'totals' for w in line)
2298
+
2299
+ if has_money:
2300
+ # Values line — may also contain account number and/or 'Totals'
2301
+ val_words = [w for w in line
2302
+ if not re.match(r'^\d{7,}$', w['text'])
2303
+ and w['text'].lower() != 'totals']
2304
+ acct_words = [w for w in line if re.match(r'^\d{7,}$', w['text'])]
2305
+
2306
+ for w in val_words:
2307
+ xc = (w['x0'] + w['x1']) / 2
2308
+ ci = _assign_col(xc)
2309
+ current_vals[ci] = w['text']
2310
+
2311
+ name_str = ' '.join(current_name)
2312
+ if acct_words:
2313
+ name_str = (name_str + ' ' + acct_words[0]['text']).strip()
2314
+ if is_totals:
2315
+ name_str = 'Totals'
2316
+
2317
+ merged.append((name_str, dict(current_vals)))
2318
+ current_name = []
2319
+ current_vals = {}
2320
+ else:
2321
+ current_name.extend(texts)
2322
+
2323
+ if not merged:
2324
+ return ""
2325
+
2326
+ n_cols = len(col_labels)
2327
+ header_html = '<tr><th>Account</th>' + ''.join(
2328
+ f'<th>{lbl}</th>' for lbl in col_labels) + '</tr>'
2329
+
2330
+ rows_html = []
2331
+ for name, vals in merged:
2332
+ row = f'<tr><td>{name}</td>'
2333
+ for ci in range(n_cols):
2334
+ row += f'<td>{vals.get(ci, "")}</td>'
2335
+ row += '</tr>'
2336
+ rows_html.append(row)
2337
+
2338
+ return (
2339
+ 'Summary of your deposit accounts\n'
2340
+ '<table>\n' + header_html + '\n' +
2341
+ '\n'.join(rows_html) + '\n</table>'
2342
+ )
2343
+
2344
+ except Exception as e:
2345
+ log.warning("_extract_nfcu_summary_table failed (page %d): %s", page_num, e)
2346
+ return ""
2347
+
2348
  def normalize_html_tables(text: str) -> str:
2349
  """
2350
  For every <table>...</table>:
 
2589
 
2590
  parts.append(stabilized)
2591
 
2592
+ # Fix NF-1: prepend Navy Federal "Summary of your deposit accounts" table.
2593
+ # Uses pdfplumber spatial word positions to extract the wide-column summary
2594
+ # table that GLM-OCR fails to parse correctly.
2595
+ # Safe no-op for all non-NFCU PDFs (returns "" when guards don't match).
2596
+ if is_pdf:
2597
+ try:
2598
+ nfcu_summary = _extract_nfcu_summary_table(path, page_num)
2599
+ if nfcu_summary:
2600
+ parts.insert(0, nfcu_summary)
2601
+ except Exception:
2602
+ pass
2603
+
2604
  # Footer extraction: PDF text layer -> band words -> OCR crop
2605
  # Deduplication guard prevents double-printing when body OCR already captured it.
2606
  if ENABLE_FOOTER_OCR and page_num < len(page_images):