rehan953 commited on
Commit
53b4e69
·
verified ·
1 Parent(s): 5a7f070

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +343 -2
app.py CHANGED
@@ -118,6 +118,11 @@ MIN_CROP_PIXELS = 112 * 112
118
  PAGE_PNG_COMPRESS_LEVEL = 3
119
  # JPEG quality for small header/footer crops sent to the API.
120
  ZONE_JPEG_QUALITY = 95
 
 
 
 
 
121
 
122
  MIN_PDF_TEXT_CHARS_NATIVE_LAYER = 1500
123
 
@@ -1363,6 +1368,298 @@ def normalize_amount_only_transaction_tables(md: str) -> str:
1363
  )
1364
 
1365
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1366
  def _extract_daily_balance_by_date(md: str):
1367
  """
1368
  Build a date->balance map from daily-balance style tables.
@@ -1761,8 +2058,44 @@ def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]:
1761
  canvas.paste(img, (pad_l, pad_t))
1762
  img = canvas
1763
 
1764
- img_path = os.path.join(tempfile.gettempdir(), f"glmocr_page_{os.getpid()}_{i}.png")
 
 
 
 
 
 
 
 
 
 
 
 
 
1765
  img.save(img_path, "PNG", compress_level=PAGE_PNG_COMPRESS_LEVEL)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1766
  page_images.append(img_path)
1767
  page_heights.append(img.height)
1768
 
@@ -1874,6 +2207,10 @@ def run_ocr(uploaded_file):
1874
  merged = strip_malformed_leading_table_rows(merged)
1875
  merged = strip_subtotal_rows_from_transaction_tables(merged)
1876
  merged = mask_non_monetary_long_numbers(merged)
 
 
 
 
1877
  merged = stabilize_table_markup(merged, rounds=4)
1878
  merged = enrich_transaction_tables_with_daily_balances(merged)
1879
  # Keep transaction amounts untouched; add explicit snapshot for metadata extraction.
@@ -1892,7 +2229,11 @@ def run_ocr(uploaded_file):
1892
  finally:
1893
  for p in page_images:
1894
  try:
1895
- if isinstance(p, str) and p.endswith(".png") and "glmocr_page_" in os.path.basename(p):
 
 
 
 
1896
  os.unlink(p)
1897
  except Exception:
1898
  pass
 
118
  PAGE_PNG_COMPRESS_LEVEL = 3
119
  # JPEG quality for small header/footer crops sent to the API.
120
  ZONE_JPEG_QUALITY = 95
121
+ PAGE_JPEG_QUALITY = 88
122
+ MAX_PAGE_UPLOAD_BYTES = 9_200_000
123
+ # Keep page PNGs under MaaS input constraints to avoid 400 errors.
124
+ MAX_PAGE_LONG_SIDE = 3600
125
+ MAX_PAGE_TOTAL_PIXELS = 7_800_000
126
 
127
  MIN_PDF_TEXT_CHARS_NATIVE_LAYER = 1500
128
 
 
1368
  )
1369
 
1370
 
1371
+ def infer_credit_debit_from_balance_deltas(md: str) -> str:
1372
+ """
1373
+ When OCR leaves Credit/Debit empty but a running Balance column is present,
1374
+ infer signed movement from consecutive balance deltas (same for all banks).
1375
+ """
1376
+ if not md or "<table" not in md.lower():
1377
+ return md
1378
+
1379
+ def repl_table(m: re.Match) -> str:
1380
+ full = m.group(0)
1381
+ rows = _extract_rows_plain_from_table(full)
1382
+ if len(rows) < 3:
1383
+ return full
1384
+
1385
+ hdr = [c.strip() for c in rows[0]]
1386
+ hdr_l = [h.lower() for h in hdr]
1387
+ date_i = next((i for i, h in enumerate(hdr_l) if "date" in h), None)
1388
+ desc_i = next(
1389
+ (i for i, h in enumerate(hdr_l) if "description" in h or "memo" in h or "details" in h),
1390
+ None,
1391
+ )
1392
+ credit_i = next((i for i, h in enumerate(hdr_l) if "credit" in h), None)
1393
+ debit_i = next((i for i, h in enumerate(hdr_l) if "debit" in h), None)
1394
+ balance_i = next((i for i, h in enumerate(hdr_l) if "balance" in h), None)
1395
+
1396
+ if date_i is None or desc_i is None or credit_i is None or debit_i is None or balance_i is None:
1397
+ return full
1398
+
1399
+ max_len = max(len(hdr), max((len(r) for r in rows), default=0))
1400
+ body_bals: List[Optional[float]] = []
1401
+ body_rows: List[List[str]] = []
1402
+ for r in rows[1:]:
1403
+ padded = (r + [""] * (max_len - len(r)))[:max_len]
1404
+ dt = (padded[date_i] or "").strip()
1405
+ if not _is_date_like(dt):
1406
+ continue
1407
+ cr = _parse_amount_or_none((padded[credit_i] or "").strip()) if credit_i < len(padded) else None
1408
+ db = _parse_amount_or_none((padded[debit_i] or "").strip()) if debit_i < len(padded) else None
1409
+ bal = _parse_amount_or_none((padded[balance_i] or "").strip()) if balance_i < len(padded) else None
1410
+ body_rows.append(padded)
1411
+ body_bals.append(bal)
1412
+
1413
+ if len(body_rows) < 3:
1414
+ return full
1415
+
1416
+ empty_cd = 0
1417
+ for i, pr in enumerate(body_rows):
1418
+ cr = _parse_amount_or_none((pr[credit_i] or "").strip()) if credit_i < len(pr) else None
1419
+ db = _parse_amount_or_none((pr[debit_i] or "").strip()) if debit_i < len(pr) else None
1420
+ if cr is None and db is None:
1421
+ empty_cd += 1
1422
+ if empty_cd < max(3, int(0.6 * len(body_rows))):
1423
+ return full
1424
+
1425
+ new_body: List[List[str]] = []
1426
+ for i, pr in enumerate(body_rows):
1427
+ row = pr[:]
1428
+ cr0 = _parse_amount_or_none((row[credit_i] or "").strip()) if credit_i < len(row) else None
1429
+ db0 = _parse_amount_or_none((row[debit_i] or "").strip()) if debit_i < len(row) else None
1430
+ if cr0 is not None or db0 is not None:
1431
+ new_body.append(row)
1432
+ continue
1433
+ if i == 0 or body_bals[i] is None:
1434
+ new_body.append(row)
1435
+ continue
1436
+ prev_b = body_bals[i - 1]
1437
+ cur_b = body_bals[i]
1438
+ if prev_b is None or cur_b is None:
1439
+ new_body.append(row)
1440
+ continue
1441
+ delta = cur_b - prev_b
1442
+ if abs(delta) < 1e-6:
1443
+ new_body.append(row)
1444
+ continue
1445
+ tol = max(0.55, 0.025 * abs(delta))
1446
+ if abs(delta) <= tol:
1447
+ new_body.append(row)
1448
+ continue
1449
+ if delta > 0:
1450
+ row[credit_i] = _fmt_money(delta)
1451
+ else:
1452
+ row[debit_i] = _fmt_money(-delta)
1453
+ new_body.append(row)
1454
+
1455
+ filled = 0
1456
+ for i, pr in enumerate(new_body):
1457
+ cr = _parse_amount_or_none((pr[credit_i] or "").strip()) if credit_i < len(pr) else None
1458
+ db = _parse_amount_or_none((pr[debit_i] or "").strip()) if debit_i < len(pr) else None
1459
+ if cr is not None or db is not None:
1460
+ filled += 1
1461
+ if filled < max(2, int(0.35 * len(new_body))):
1462
+ return full
1463
+
1464
+ html_rows = ["<tr>" + "".join(f"<th>{html.escape(c)}</th>" for c in hdr) + "</tr>"]
1465
+ for pr in new_body:
1466
+ cells = (pr + [""] * (len(hdr) - len(pr)))[: len(hdr)]
1467
+ html_rows.append(
1468
+ "<tr>" + "".join(f"<td>{html.escape((c or '').strip())}</td>" for c in cells) + "</tr>"
1469
+ )
1470
+ return "<table>\n" + "\n".join(html_rows) + "\n</table>"
1471
+
1472
+ return re.sub(
1473
+ r"<table\b[^>]*>.*?</table>",
1474
+ repl_table,
1475
+ md,
1476
+ flags=re.IGNORECASE | re.DOTALL,
1477
+ )
1478
+
1479
+
1480
+ def sanitize_transaction_tables(md: str) -> str:
1481
+ """
1482
+ Final defensive cleanup for transaction-like tables:
1483
+ - remove in-body header repeats and subtotal rows
1484
+ - deduplicate repeated rows
1485
+ - correct likely credit/debit direction using row text and balance deltas
1486
+ """
1487
+ if not md or "<table" not in md.lower():
1488
+ return md
1489
+
1490
+ credit_kw = re.compile(
1491
+ r"\b(deposit|deposits|credit|credits|recd|received|return|refund|interest|rtp\s*rcvd|pmt\s*cr)\b",
1492
+ re.IGNORECASE,
1493
+ )
1494
+ debit_kw = re.compile(
1495
+ r"\b(debit|debits|withdrawal|withdrawals|check|checks|payment|purchase|charge|fee|ach)\b",
1496
+ re.IGNORECASE,
1497
+ )
1498
+
1499
+ def parse_money(txt: str):
1500
+ t = (txt or "").strip()
1501
+ if not t:
1502
+ return None
1503
+ return _parse_amount_or_none(t)
1504
+
1505
+ def fmt_money(v: float) -> str:
1506
+ return f"{abs(v):,.2f}"
1507
+
1508
+ def repl_table(m: re.Match) -> str:
1509
+ full = m.group(0)
1510
+ rows = _extract_rows_plain_from_table(full)
1511
+ if len(rows) < 2:
1512
+ return full
1513
+
1514
+ hdr = [c.strip() for c in rows[0]]
1515
+ hdr_l = [h.lower() for h in hdr]
1516
+ date_i = next((i for i, h in enumerate(hdr_l) if "date" in h), None)
1517
+ desc_i = next((i for i, h in enumerate(hdr_l) if "description" in h or "memo" in h or "details" in h), None)
1518
+ credit_i = next((i for i, h in enumerate(hdr_l) if "credit" in h), None)
1519
+ debit_i = next((i for i, h in enumerate(hdr_l) if "debit" in h), None)
1520
+ amount_i = next((i for i, h in enumerate(hdr_l) if "amount" in h), None)
1521
+ balance_i = next((i for i, h in enumerate(hdr_l) if "balance" in h), None)
1522
+ is_txn = (date_i is not None) and (desc_i is not None) and (
1523
+ credit_i is not None or debit_i is not None or amount_i is not None
1524
+ )
1525
+ if not is_txn:
1526
+ return full
1527
+ # Without a real balance column, do not rewrite the table (avoids blank balances).
1528
+ if balance_i is None:
1529
+ return full
1530
+
1531
+ max_cols = max(len(hdr), max((len(r) for r in rows), default=len(hdr)))
1532
+ if max_cols < 4:
1533
+ max_cols = 4
1534
+
1535
+ new_hdr = hdr[:]
1536
+ if amount_i is not None and credit_i is None and debit_i is None:
1537
+ new_hdr[amount_i] = "Debit"
1538
+ debit_i = amount_i
1539
+ amount_i = None
1540
+ if credit_i is None:
1541
+ new_hdr.append("Credit")
1542
+ credit_i = len(new_hdr) - 1
1543
+ if debit_i is None:
1544
+ new_hdr.append("Debit")
1545
+ debit_i = len(new_hdr) - 1
1546
+
1547
+ records = []
1548
+ seen = set()
1549
+ for r in rows[1:]:
1550
+ padded = r + [""] * (len(new_hdr) - len(r))
1551
+ low = [c.strip().lower() for c in padded]
1552
+ joined = " ".join(low)
1553
+
1554
+ # Remove duplicate header rows inside body and subtotal/total lines.
1555
+ if any(
1556
+ x in low
1557
+ for x in ("date", "posting date", "description", "amount", "credit", "debit", "balance")
1558
+ ):
1559
+ continue
1560
+ if "subtotal" in joined or joined.startswith("total "):
1561
+ continue
1562
+
1563
+ dt = padded[date_i].strip() if date_i is not None and date_i < len(padded) else ""
1564
+ if not _is_date_like(dt):
1565
+ continue
1566
+ desc = padded[desc_i].strip() if desc_i is not None and desc_i < len(padded) else ""
1567
+ bal = parse_money(padded[balance_i]) if balance_i < len(padded) else None
1568
+ cr = parse_money(padded[credit_i]) if credit_i < len(padded) else None
1569
+ db = parse_money(padded[debit_i]) if debit_i < len(padded) else None
1570
+
1571
+ if cr is None and db is None:
1572
+ continue
1573
+
1574
+ amt = cr if cr is not None else db
1575
+ direction = "credit" if cr is not None else "debit"
1576
+
1577
+ # Row-level lexical correction
1578
+ if amt is not None:
1579
+ if credit_kw.search(desc) and not debit_kw.search(desc):
1580
+ direction = "credit"
1581
+ elif debit_kw.search(desc) and not credit_kw.search(desc):
1582
+ direction = "debit"
1583
+
1584
+ key = (dt, desc, f"{amt:.2f}" if isinstance(amt, (int, float)) else "", f"{bal:.2f}" if isinstance(bal, (int, float)) else "")
1585
+ if key in seen:
1586
+ continue
1587
+ seen.add(key)
1588
+ records.append(
1589
+ {
1590
+ "row": padded[: len(new_hdr)],
1591
+ "amount": abs(amt) if amt is not None else None,
1592
+ "balance": bal,
1593
+ "lex_dir": direction,
1594
+ }
1595
+ )
1596
+
1597
+ if not records:
1598
+ return full
1599
+
1600
+ # Choose balance interpretation direction (forward vs reverse row order)
1601
+ # based on which one produces more valid delta-to-amount matches.
1602
+ def delta_match(delta: float, amt_val: float) -> str:
1603
+ tol = max(0.75, 0.03 * abs(amt_val))
1604
+ if abs(delta - abs(amt_val)) <= tol:
1605
+ return "credit"
1606
+ if abs(delta + abs(amt_val)) <= tol:
1607
+ return "debit"
1608
+ return ""
1609
+
1610
+ fwd_matches = 0
1611
+ rev_matches = 0
1612
+ prev_bal = None
1613
+ for rec in records:
1614
+ bal = rec["balance"]
1615
+ amt_val = rec["amount"]
1616
+ if bal is None or amt_val is None:
1617
+ continue
1618
+ if prev_bal is not None:
1619
+ if delta_match(bal - prev_bal, amt_val):
1620
+ fwd_matches += 1
1621
+ if delta_match(prev_bal - bal, amt_val):
1622
+ rev_matches += 1
1623
+ prev_bal = bal
1624
+
1625
+ use_reverse = rev_matches > fwd_matches
1626
+
1627
+ body = []
1628
+ prev_bal = None
1629
+ for rec in records:
1630
+ out = rec["row"][:]
1631
+ amt_val = rec["amount"]
1632
+ bal = rec["balance"]
1633
+ direction = rec["lex_dir"]
1634
+
1635
+ if amt_val is not None and bal is not None and prev_bal is not None:
1636
+ delta = (prev_bal - bal) if use_reverse else (bal - prev_bal)
1637
+ d = delta_match(delta, amt_val)
1638
+ if d:
1639
+ direction = d
1640
+ if bal is not None:
1641
+ prev_bal = bal
1642
+
1643
+ out[credit_i] = fmt_money(amt_val) if (amt_val is not None and direction == "credit") else ""
1644
+ out[debit_i] = fmt_money(amt_val) if (amt_val is not None and direction == "debit") else ""
1645
+ out[balance_i] = f"{bal:,.2f}" if bal is not None else out[balance_i]
1646
+ body.append(out)
1647
+
1648
+ if not body:
1649
+ return full
1650
+ html_rows = ["<tr>" + "".join(f"<th>{html.escape(c)}</th>" for c in new_hdr) + "</tr>"]
1651
+ for rr in body:
1652
+ html_rows.append("<tr>" + "".join(f"<td>{html.escape(c)}</td>" for c in rr) + "</tr>")
1653
+ return "<table>\n" + "\n".join(html_rows) + "\n</table>"
1654
+
1655
+ return re.sub(
1656
+ r"<table\b[^>]*>.*?</table>",
1657
+ repl_table,
1658
+ md,
1659
+ flags=re.IGNORECASE | re.DOTALL,
1660
+ )
1661
+
1662
+
1663
  def _extract_daily_balance_by_date(md: str):
1664
  """
1665
  Build a date->balance map from daily-balance style tables.
 
2058
  canvas.paste(img, (pad_l, pad_t))
2059
  img = canvas
2060
 
2061
+ # Deterministic guardrail: downscale oversized pages before upload.
2062
+ iw, ih = img.size
2063
+ long_side = max(iw, ih)
2064
+ total_px = iw * ih
2065
+ if long_side > MAX_PAGE_LONG_SIDE or total_px > MAX_PAGE_TOTAL_PIXELS:
2066
+ scale_long = MAX_PAGE_LONG_SIDE / float(long_side)
2067
+ scale_area = (MAX_PAGE_TOTAL_PIXELS / float(total_px)) ** 0.5
2068
+ scale = min(scale_long, scale_area, 1.0)
2069
+ nw = max(900, int(iw * scale))
2070
+ nh = max(900, int(ih * scale))
2071
+ img = img.resize((nw, nh), Image.Resampling.LANCZOS)
2072
+
2073
+ base_path = os.path.join(tempfile.gettempdir(), f"glmocr_page_{os.getpid()}_{i}")
2074
+ img_path = f"{base_path}.png"
2075
  img.save(img_path, "PNG", compress_level=PAGE_PNG_COMPRESS_LEVEL)
2076
+ try:
2077
+ size_bytes = os.path.getsize(img_path)
2078
+ except OSError:
2079
+ size_bytes = 0
2080
+ if size_bytes > MAX_PAGE_UPLOAD_BYTES:
2081
+ jpg_path = f"{base_path}.jpg"
2082
+ quality = PAGE_JPEG_QUALITY
2083
+ work = img
2084
+ for _ in range(5):
2085
+ work.save(jpg_path, "JPEG", quality=quality, optimize=True)
2086
+ try:
2087
+ jsize = os.path.getsize(jpg_path)
2088
+ except OSError:
2089
+ jsize = 0
2090
+ if 0 < jsize <= MAX_PAGE_UPLOAD_BYTES:
2091
+ img_path = jpg_path
2092
+ break
2093
+ quality = max(68, quality - 6)
2094
+ nw = max(900, int(work.width * 0.93))
2095
+ nh = max(900, int(work.height * 0.93))
2096
+ work = work.resize((nw, nh), Image.Resampling.LANCZOS)
2097
+ else:
2098
+ img_path = jpg_path
2099
  page_images.append(img_path)
2100
  page_heights.append(img.height)
2101
 
 
2207
  merged = strip_malformed_leading_table_rows(merged)
2208
  merged = strip_subtotal_rows_from_transaction_tables(merged)
2209
  merged = mask_non_monetary_long_numbers(merged)
2210
+ merged = normalize_ambiguous_amount_balance_tables(merged)
2211
+ merged = normalize_amount_only_transaction_tables(merged)
2212
+ merged = infer_credit_debit_from_balance_deltas(merged)
2213
+ merged = sanitize_transaction_tables(merged)
2214
  merged = stabilize_table_markup(merged, rounds=4)
2215
  merged = enrich_transaction_tables_with_daily_balances(merged)
2216
  # Keep transaction amounts untouched; add explicit snapshot for metadata extraction.
 
2229
  finally:
2230
  for p in page_images:
2231
  try:
2232
+ if (
2233
+ isinstance(p, str)
2234
+ and "glmocr_page_" in os.path.basename(p)
2235
+ and (p.endswith(".png") or p.endswith(".jpg") or p.endswith(".jpeg"))
2236
+ ):
2237
  os.unlink(p)
2238
  except Exception:
2239
  pass