Spaces:
Sleeping
Sleeping
Update app.py
Browse files
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):
|