Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -17,11 +17,13 @@ Primary goals for reconcile rate:
|
|
| 17 |
8) reconstruct mid-table separator rows split across columns by OCR (e.g. 'Card account # XXXX 2 | | 889')
|
| 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 |
- Fix 13: collapse Date|Description|Amount|Description|Amount tables to 3 columns; fix fused
|
| 24 |
Beginning/Ending balance rows (UCB-style wide summary tables)
|
|
|
|
| 25 |
- Post-pass: drop later duplicate subset Date|Description|Amount tables (repeated deposit blocks)
|
| 26 |
"""
|
| 27 |
|
|
@@ -2702,32 +2704,46 @@ def _normalize_double_desc_amount_header_table(grid):
|
|
| 2702 |
"""
|
| 2703 |
Fix 13: Collapse OCR tables with a duplicated column group:
|
| 2704 |
Date | Description | Amount | Description | Amount
|
| 2705 |
-
|
| 2706 |
-
|
| 2707 |
Output: Date | Description | Amount
|
| 2708 |
-
|
| 2709 |
-
Strict guard: header row must match exactly those five labels (case/spacing
|
| 2710 |
-
normalized). No bank name checks.
|
| 2711 |
"""
|
| 2712 |
if not grid or len(grid) < 2:
|
| 2713 |
return grid
|
| 2714 |
|
| 2715 |
-
|
| 2716 |
-
|
| 2717 |
-
|
| 2718 |
-
|
| 2719 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2720 |
return grid
|
| 2721 |
|
| 2722 |
-
date_only_re = re.compile(r"^\d{1,2}/\d{1,2}/\d{4}$")
|
| 2723 |
-
date_prefix_re = re.compile(r"^(\d{1,2}/\d{1,2}/\d{4})\s*(.*)$")
|
| 2724 |
money_cell_re = re.compile(r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})-?$")
|
| 2725 |
|
| 2726 |
def _is_money(s):
|
| 2727 |
if not s or not str(s).strip():
|
| 2728 |
return False
|
| 2729 |
-
|
| 2730 |
-
return bool(money_cell_re.match(t))
|
| 2731 |
|
| 2732 |
def _extract_money(s):
|
| 2733 |
if not s:
|
|
@@ -2737,28 +2753,28 @@ def _normalize_double_desc_amount_header_table(grid):
|
|
| 2737 |
|
| 2738 |
out = [["Date", "Description", "Amount"]]
|
| 2739 |
|
| 2740 |
-
for row in grid[1:]:
|
| 2741 |
cells = [str(c or "").strip() for c in row]
|
| 2742 |
-
while len(cells) <
|
| 2743 |
cells.append("")
|
| 2744 |
-
c0, c1, c2
|
|
|
|
|
|
|
| 2745 |
|
| 2746 |
-
|
| 2747 |
-
if not any(cells[:5]):
|
| 2748 |
continue
|
| 2749 |
|
| 2750 |
# A) Fused date + label in col0, amount in col1 (Beginning/Ending Balance, etc.)
|
| 2751 |
mpre = date_prefix_re.match(c0)
|
| 2752 |
if mpre and _is_money(c1):
|
| 2753 |
date_s = mpre.group(1).strip()
|
| 2754 |
-
|
| 2755 |
-
low = (c0 + " " + tail).lower()
|
| 2756 |
if "beginning balance" in low:
|
| 2757 |
desc = "Beginning Balance"
|
| 2758 |
elif "ending balance" in low:
|
| 2759 |
desc = "Ending Balance"
|
| 2760 |
else:
|
| 2761 |
-
desc =
|
| 2762 |
out.append([date_s, desc, c1])
|
| 2763 |
continue
|
| 2764 |
|
|
@@ -2782,12 +2798,71 @@ def _normalize_double_desc_amount_header_table(grid):
|
|
| 2782 |
out.append([date_s, desc, amt])
|
| 2783 |
continue
|
| 2784 |
|
| 2785 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2786 |
out.append([c0, c1, c2])
|
| 2787 |
|
| 2788 |
return out if len(out) >= 2 else grid
|
| 2789 |
|
| 2790 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2791 |
def _normalize_fused_date_posted_amount_table(grid):
|
| 2792 |
"""
|
| 2793 |
Normalize 2-column OCR tables with fused header like:
|
|
@@ -2915,12 +2990,15 @@ def normalize_html_tables(text: str) -> str:
|
|
| 2915 |
last = m.end() # must set here before continue since line below is outside try
|
| 2916 |
continue
|
| 2917 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2918 |
grid = _drop_truly_empty_columns(grid)
|
| 2919 |
grid = _merge_blank_header_text_columns(grid)
|
| 2920 |
|
| 2921 |
-
# Fix 13: Date|Description|Amount|Description|Amount → 3 columns (UCB-style)
|
| 2922 |
-
grid = _normalize_double_desc_amount_header_table(grid)
|
| 2923 |
-
|
| 2924 |
# Fix 5: reconstruct mid-table separator rows split across columns by OCR
|
| 2925 |
grid = _reconstruct_separator_rows(grid)
|
| 2926 |
|
|
@@ -3192,6 +3270,10 @@ def run_ocr(uploaded_file):
|
|
| 3192 |
# Safe no-op for scanned PDFs (no text layer) and non-transaction pages.
|
| 3193 |
if is_pdf and not is_nfcu_doc:
|
| 3194 |
stabilized = _patch_ocr_with_textlayer(stabilized, path, page_num)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3195 |
elif is_pdf and is_nfcu_doc:
|
| 3196 |
# Navy-only additive fix: rebuild malformed summary table from
|
| 3197 |
# text layer, but keep GLM body/header/footer unchanged.
|
|
|
|
| 17 |
8) reconstruct mid-table separator rows split across columns by OCR (e.g. 'Card account # XXXX 2 | | 889')
|
| 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); after injection, re-run normalize_html_tables
|
| 21 |
+
+ subset dedupe so UCB-style fused cells from the text layer get Fix 13/14
|
| 22 |
- Fix NF-1: extract Navy Federal "Summary of your deposit accounts" table from PDF text layer
|
| 23 |
using spatial word positions (GLM-OCR fails on this wide-column layout)
|
| 24 |
- Fix 13: collapse Date|Description|Amount|Description|Amount tables to 3 columns; fix fused
|
| 25 |
Beginning/Ending balance rows (UCB-style wide summary tables)
|
| 26 |
+
- Fix 14: UCB 3-col tables where Beginning Balance amount is fused into description and Amount is $0.00
|
| 27 |
- Post-pass: drop later duplicate subset Date|Description|Amount tables (repeated deposit blocks)
|
| 28 |
"""
|
| 29 |
|
|
|
|
| 2704 |
"""
|
| 2705 |
Fix 13: Collapse OCR tables with a duplicated column group:
|
| 2706 |
Date | Description | Amount | Description | Amount
|
| 2707 |
+
(or 4 cols if trailing Amount column was dropped). UCB-style summary tables
|
| 2708 |
+
put labels like "9 Credit(s) This Period" in the Date column.
|
| 2709 |
Output: Date | Description | Amount
|
|
|
|
|
|
|
|
|
|
| 2710 |
"""
|
| 2711 |
if not grid or len(grid) < 2:
|
| 2712 |
return grid
|
| 2713 |
|
| 2714 |
+
def _hdr_tight(s):
|
| 2715 |
+
return re.sub(r"\s+", "", str(s or "").strip().lower())
|
| 2716 |
+
|
| 2717 |
+
# OCR often inserts spaces inside words; compare whitespace-stripped tokens.
|
| 2718 |
+
expected5_t = ["date", "description", "amount", "description", "amount"]
|
| 2719 |
+
expected4_t = ["date", "description", "amount", "description"]
|
| 2720 |
+
|
| 2721 |
+
def _row_is_ucb_header(cells):
|
| 2722 |
+
if len(cells) == 5:
|
| 2723 |
+
return [_hdr_tight(c) for c in cells] == expected5_t
|
| 2724 |
+
if len(cells) == 4:
|
| 2725 |
+
return [_hdr_tight(c) for c in cells] == expected4_t
|
| 2726 |
+
return False
|
| 2727 |
+
|
| 2728 |
+
hdr_idx = None
|
| 2729 |
+
ncols = 0
|
| 2730 |
+
for i in range(min(5, len(grid))):
|
| 2731 |
+
raw_try = [str(c or "").strip() for c in grid[i]]
|
| 2732 |
+
if _row_is_ucb_header(raw_try):
|
| 2733 |
+
hdr_idx = i
|
| 2734 |
+
ncols = len(raw_try)
|
| 2735 |
+
break
|
| 2736 |
+
if hdr_idx is None:
|
| 2737 |
return grid
|
| 2738 |
|
| 2739 |
+
date_only_re = re.compile(r"^\d{1,2}/\d{1,2}/\d{2,4}$")
|
| 2740 |
+
date_prefix_re = re.compile(r"^(\d{1,2}/\d{1,2}/\d{2,4})\s*(.*)$")
|
| 2741 |
money_cell_re = re.compile(r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})-?$")
|
| 2742 |
|
| 2743 |
def _is_money(s):
|
| 2744 |
if not s or not str(s).strip():
|
| 2745 |
return False
|
| 2746 |
+
return bool(money_cell_re.match(str(s).strip()))
|
|
|
|
| 2747 |
|
| 2748 |
def _extract_money(s):
|
| 2749 |
if not s:
|
|
|
|
| 2753 |
|
| 2754 |
out = [["Date", "Description", "Amount"]]
|
| 2755 |
|
| 2756 |
+
for row in grid[hdr_idx + 1 :]:
|
| 2757 |
cells = [str(c or "").strip() for c in row]
|
| 2758 |
+
while len(cells) < ncols:
|
| 2759 |
cells.append("")
|
| 2760 |
+
c0, c1, c2 = cells[0], cells[1] if len(cells) > 1 else "", cells[2] if len(cells) > 2 else ""
|
| 2761 |
+
c3 = cells[3] if len(cells) > 3 else ""
|
| 2762 |
+
c4 = cells[4] if len(cells) > 4 else ""
|
| 2763 |
|
| 2764 |
+
if not any(cells[:ncols]):
|
|
|
|
| 2765 |
continue
|
| 2766 |
|
| 2767 |
# A) Fused date + label in col0, amount in col1 (Beginning/Ending Balance, etc.)
|
| 2768 |
mpre = date_prefix_re.match(c0)
|
| 2769 |
if mpre and _is_money(c1):
|
| 2770 |
date_s = mpre.group(1).strip()
|
| 2771 |
+
low = (c0 + " " + (mpre.group(2) or "")).lower()
|
|
|
|
| 2772 |
if "beginning balance" in low:
|
| 2773 |
desc = "Beginning Balance"
|
| 2774 |
elif "ending balance" in low:
|
| 2775 |
desc = "Ending Balance"
|
| 2776 |
else:
|
| 2777 |
+
desc = (mpre.group(2) or "").strip() or "Transaction"
|
| 2778 |
out.append([date_s, desc, c1])
|
| 2779 |
continue
|
| 2780 |
|
|
|
|
| 2798 |
out.append([date_s, desc, amt])
|
| 2799 |
continue
|
| 2800 |
|
| 2801 |
+
# D) Summary / label rows: first column is NOT a date, second is money (Credits/Debits/Service)
|
| 2802 |
+
if not date_prefix_re.match(c0) and not date_only_re.match(c0.strip()) and _is_money(c1):
|
| 2803 |
+
out.append(["", c0, c1])
|
| 2804 |
+
continue
|
| 2805 |
+
|
| 2806 |
+
# C) Default: first Date|Description|Amount triple (ignore duplicate pair)
|
| 2807 |
out.append([c0, c1, c2])
|
| 2808 |
|
| 2809 |
return out if len(out) >= 2 else grid
|
| 2810 |
|
| 2811 |
|
| 2812 |
+
def _normalize_ucb_three_col_beginning_balance_fusion(grid):
|
| 2813 |
+
"""
|
| 2814 |
+
Fix 14: UCB sometimes OCRs the summary as 3 columns (not 5), with the real
|
| 2815 |
+
beginning balance inside the description and $0.00 in the amount column:
|
| 2816 |
+
02/01/2025 | Beginning Balance $3,562.49 Average Ledger Balance | $0.00
|
| 2817 |
+
Normalize to: Date | Beginning Balance | $3,562.49
|
| 2818 |
+
Guard: standard Date|Description|Amount header only.
|
| 2819 |
+
"""
|
| 2820 |
+
if not grid or len(grid) < 2:
|
| 2821 |
+
return grid
|
| 2822 |
+
|
| 2823 |
+
def _tight(s):
|
| 2824 |
+
return re.sub(r"\s+", "", str(s or "").strip().lower())
|
| 2825 |
+
|
| 2826 |
+
h0 = [str(c or "").strip() for c in grid[0]]
|
| 2827 |
+
# Do not use max row width — OCR sometimes adds an extra empty column on some rows only.
|
| 2828 |
+
if len(h0) < 3:
|
| 2829 |
+
return grid
|
| 2830 |
+
if _tight(h0[0]) != "date" or _tight(h0[1]) != "description" or _tight(h0[2]) != "amount":
|
| 2831 |
+
return grid
|
| 2832 |
+
|
| 2833 |
+
date_only_re = re.compile(r"^\d{1,2}/\d{1,2}/\d{2,4}$")
|
| 2834 |
+
|
| 2835 |
+
def _extract_money(s):
|
| 2836 |
+
if not s:
|
| 2837 |
+
return ""
|
| 2838 |
+
m = re.search(r"(-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})-?)", str(s))
|
| 2839 |
+
return m.group(1).strip() if m else ""
|
| 2840 |
+
|
| 2841 |
+
zeroish = {"", "$0.00", "0.00", "$0", ".00", "$.00", "0"}
|
| 2842 |
+
|
| 2843 |
+
out = [list(grid[0])]
|
| 2844 |
+
changed = False
|
| 2845 |
+
for row in grid[1:]:
|
| 2846 |
+
cells = [str(c or "").strip() for c in row]
|
| 2847 |
+
while len(cells) < 3:
|
| 2848 |
+
cells.append("")
|
| 2849 |
+
c0, c1, c2 = cells[0], cells[1], cells[2]
|
| 2850 |
+
if (
|
| 2851 |
+
date_only_re.match(c0.strip())
|
| 2852 |
+
and c1
|
| 2853 |
+
and "beginning balance" in c1.lower()
|
| 2854 |
+
):
|
| 2855 |
+
mx = _extract_money(c1)
|
| 2856 |
+
c2n = c2.strip() if c2 else ""
|
| 2857 |
+
if mx and (not c2n or c2n in zeroish):
|
| 2858 |
+
out.append([c0.strip(), "Beginning Balance", mx])
|
| 2859 |
+
changed = True
|
| 2860 |
+
continue
|
| 2861 |
+
out.append([c0, c1, c2])
|
| 2862 |
+
|
| 2863 |
+
return out if changed else grid
|
| 2864 |
+
|
| 2865 |
+
|
| 2866 |
def _normalize_fused_date_posted_amount_table(grid):
|
| 2867 |
"""
|
| 2868 |
Normalize 2-column OCR tables with fused header like:
|
|
|
|
| 2990 |
last = m.end() # must set here before continue since line below is outside try
|
| 2991 |
continue
|
| 2992 |
|
| 2993 |
+
# Fix 13: MUST run before _drop_truly_empty_columns — otherwise the 5th column
|
| 2994 |
+
# can be removed and the UCB double-pair header no longer matches.
|
| 2995 |
+
grid = _normalize_double_desc_amount_header_table(grid)
|
| 2996 |
+
# Fix 14: same bank, 3-col OCR fused Beginning Balance + $0.00 amount column.
|
| 2997 |
+
grid = _normalize_ucb_three_col_beginning_balance_fusion(grid)
|
| 2998 |
+
|
| 2999 |
grid = _drop_truly_empty_columns(grid)
|
| 3000 |
grid = _merge_blank_header_text_columns(grid)
|
| 3001 |
|
|
|
|
|
|
|
|
|
|
| 3002 |
# Fix 5: reconstruct mid-table separator rows split across columns by OCR
|
| 3003 |
grid = _reconstruct_separator_rows(grid)
|
| 3004 |
|
|
|
|
| 3270 |
# Safe no-op for scanned PDFs (no text layer) and non-transaction pages.
|
| 3271 |
if is_pdf and not is_nfcu_doc:
|
| 3272 |
stabilized = _patch_ocr_with_textlayer(stabilized, path, page_num)
|
| 3273 |
+
# _patch_ocr_with_textlayer may append Case B rows from the PDF text layer
|
| 3274 |
+
# with fused UCB-style cells; run the same HTML table pass again (Fix 13/14).
|
| 3275 |
+
stabilized = normalize_html_tables(stabilized)
|
| 3276 |
+
stabilized = _dedupe_subset_transaction_tables_html(stabilized)
|
| 3277 |
elif is_pdf and is_nfcu_doc:
|
| 3278 |
# Navy-only additive fix: rebuild malformed summary table from
|
| 3279 |
# text layer, but keep GLM body/header/footer unchanged.
|