Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -20,6 +20,9 @@ Primary goals for reconcile rate:
|
|
| 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
|
|
@@ -2695,6 +2698,96 @@ def _split_combined_summary_daily_balance_grid(grid):
|
|
| 2695 |
daily = [["Date", "Ledger Balance"]] + daily_rows
|
| 2696 |
return [summary, daily]
|
| 2697 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2698 |
def _normalize_fused_date_posted_amount_table(grid):
|
| 2699 |
"""
|
| 2700 |
Normalize 2-column OCR tables with fused header like:
|
|
@@ -2825,6 +2918,9 @@ def normalize_html_tables(text: str) -> str:
|
|
| 2825 |
grid = _drop_truly_empty_columns(grid)
|
| 2826 |
grid = _merge_blank_header_text_columns(grid)
|
| 2827 |
|
|
|
|
|
|
|
|
|
|
| 2828 |
# Fix 5: reconstruct mid-table separator rows split across columns by OCR
|
| 2829 |
grid = _reconstruct_separator_rows(grid)
|
| 2830 |
|
|
@@ -2875,6 +2971,86 @@ def normalize_html_tables(text: str) -> str:
|
|
| 2875 |
out.append(text[last:])
|
| 2876 |
return "".join(out)
|
| 2877 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2878 |
def stabilize_tables_and_text(page_md: str) -> str:
|
| 2879 |
if not page_md:
|
| 2880 |
return page_md
|
|
@@ -2892,6 +3068,7 @@ def stabilize_tables_and_text(page_md: str) -> str:
|
|
| 2892 |
stabilized = "\n\n".join(out_blocks)
|
| 2893 |
stabilized = convert_plaintext_bank_sections(stabilized)
|
| 2894 |
stabilized = normalize_html_tables(stabilized)
|
|
|
|
| 2895 |
return close_unclosed_html(stabilized)
|
| 2896 |
|
| 2897 |
# --------------------------
|
|
|
|
| 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 |
|
| 28 |
# Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
|
|
|
|
| 2698 |
daily = [["Date", "Ledger Balance"]] + daily_rows
|
| 2699 |
return [summary, daily]
|
| 2700 |
|
| 2701 |
+
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 |
+
Common on United Community Bank-style statements where a second
|
| 2706 |
+
Description/Amount pair holds sub-totals (e.g. Average Ledger Balance).
|
| 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 |
+
raw_h = [str(c or "").strip() for c in grid[0]]
|
| 2716 |
+
header_norm = [re.sub(r"\s+", " ", h).lower() for h in raw_h]
|
| 2717 |
+
if len(header_norm) != 5:
|
| 2718 |
+
return grid
|
| 2719 |
+
if header_norm != ["date", "description", "amount", "description", "amount"]:
|
| 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 |
+
t = str(s).strip()
|
| 2730 |
+
return bool(money_cell_re.match(t))
|
| 2731 |
+
|
| 2732 |
+
def _extract_money(s):
|
| 2733 |
+
if not s:
|
| 2734 |
+
return ""
|
| 2735 |
+
m = re.search(r"(-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})-?)", str(s))
|
| 2736 |
+
return m.group(1).strip() if m else ""
|
| 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) < 5:
|
| 2743 |
+
cells.append("")
|
| 2744 |
+
c0, c1, c2, c3, c4 = cells[:5]
|
| 2745 |
+
|
| 2746 |
+
# Skip completely empty rows
|
| 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 |
+
tail = (mpre.group(2) or "").strip()
|
| 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 = tail if tail else "Transaction"
|
| 2762 |
+
out.append([date_s, desc, c1])
|
| 2763 |
+
continue
|
| 2764 |
+
|
| 2765 |
+
# B) Date-only col0; description may contain Beginning Balance + amount; amount col wrong ($0)
|
| 2766 |
+
if date_only_re.match(c0.strip()):
|
| 2767 |
+
date_s = c0.strip()
|
| 2768 |
+
desc = c1
|
| 2769 |
+
amt = c2
|
| 2770 |
+
if desc and "beginning balance" in desc.lower():
|
| 2771 |
+
mx = _extract_money(desc)
|
| 2772 |
+
if mx and (not amt or amt in ("$0.00", "0.00", "$0", ".00", "$.00")):
|
| 2773 |
+
amt = mx
|
| 2774 |
+
desc = re.sub(
|
| 2775 |
+
r"(?i)beginning\s+balance\s*\$?[\d,]+\.?\d*\s*",
|
| 2776 |
+
"Beginning Balance ",
|
| 2777 |
+
desc,
|
| 2778 |
+
)
|
| 2779 |
+
desc = re.sub(r"(?i)\s*average\s+ledger\s+balance\s*", " ", desc).strip()
|
| 2780 |
+
if not desc or desc == "Beginning Balance":
|
| 2781 |
+
desc = "Beginning Balance"
|
| 2782 |
+
out.append([date_s, desc, amt])
|
| 2783 |
+
continue
|
| 2784 |
+
|
| 2785 |
+
# C) Default: use first Date/Description/Amount triple; ignore empty second pair
|
| 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:
|
|
|
|
| 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 |
|
|
|
|
| 2971 |
out.append(text[last:])
|
| 2972 |
return "".join(out)
|
| 2973 |
|
| 2974 |
+
def _dedupe_subset_transaction_tables_html(text: str) -> str:
|
| 2975 |
+
"""
|
| 2976 |
+
Remove duplicate 3-column transaction tables where a later table's rows are a
|
| 2977 |
+
strict subset of an earlier table's rows (same Date|Description|Amount header).
|
| 2978 |
+
Used when OCR repeats the same deposit block under a section heading.
|
| 2979 |
+
"""
|
| 2980 |
+
if not text or "<table" not in text.lower():
|
| 2981 |
+
return text
|
| 2982 |
+
|
| 2983 |
+
pattern = re.compile(r"<table[^>]*>.*?</table>", re.DOTALL | re.IGNORECASE)
|
| 2984 |
+
matches = list(pattern.finditer(text))
|
| 2985 |
+
if len(matches) < 2:
|
| 2986 |
+
return text
|
| 2987 |
+
|
| 2988 |
+
def _parse_grid(html: str):
|
| 2989 |
+
try:
|
| 2990 |
+
p = TableGridParser()
|
| 2991 |
+
p.feed(html)
|
| 2992 |
+
return _build_grid(p.rows)
|
| 2993 |
+
except Exception:
|
| 2994 |
+
return None
|
| 2995 |
+
|
| 2996 |
+
grids = []
|
| 2997 |
+
for m in matches:
|
| 2998 |
+
grids.append((m, _parse_grid(m.group(0))))
|
| 2999 |
+
|
| 3000 |
+
drop_idx = set()
|
| 3001 |
+
for i in range(1, len(grids)):
|
| 3002 |
+
mi, gi = grids[i]
|
| 3003 |
+
if not gi or len(gi) < 2:
|
| 3004 |
+
continue
|
| 3005 |
+
h = [re.sub(r"\s+", " ", str(c or "").strip().lower()) for c in gi[0]]
|
| 3006 |
+
if len(h) != 3 or h != ["date", "description", "amount"]:
|
| 3007 |
+
continue
|
| 3008 |
+
|
| 3009 |
+
for j in range(i):
|
| 3010 |
+
if j in drop_idx:
|
| 3011 |
+
continue
|
| 3012 |
+
mj, gj = grids[j]
|
| 3013 |
+
if not gj or len(gj) < 2:
|
| 3014 |
+
continue
|
| 3015 |
+
hj = [re.sub(r"\s+", " ", str(c or "").strip().lower()) for c in gj[0]]
|
| 3016 |
+
if len(hj) != 3 or hj != ["date", "description", "amount"]:
|
| 3017 |
+
continue
|
| 3018 |
+
|
| 3019 |
+
def _row_set(g):
|
| 3020 |
+
s = set()
|
| 3021 |
+
for r in g[1:]:
|
| 3022 |
+
if not any(str(c or "").strip() for c in r):
|
| 3023 |
+
continue
|
| 3024 |
+
t = tuple(str(c or "").strip() for c in r)
|
| 3025 |
+
if any(t):
|
| 3026 |
+
s.add(t)
|
| 3027 |
+
return s
|
| 3028 |
+
|
| 3029 |
+
si, sj = _row_set(gi), _row_set(gj)
|
| 3030 |
+
if not si or not sj:
|
| 3031 |
+
continue
|
| 3032 |
+
# Drop later table if it is a strict subset of an earlier one (repeated block).
|
| 3033 |
+
if si <= sj and len(si) < len(sj):
|
| 3034 |
+
drop_idx.add(i)
|
| 3035 |
+
break
|
| 3036 |
+
if si == sj:
|
| 3037 |
+
drop_idx.add(i)
|
| 3038 |
+
break
|
| 3039 |
+
|
| 3040 |
+
if not drop_idx:
|
| 3041 |
+
return text
|
| 3042 |
+
|
| 3043 |
+
out = []
|
| 3044 |
+
last = 0
|
| 3045 |
+
for idx, m in enumerate(matches):
|
| 3046 |
+
out.append(text[last : m.start()])
|
| 3047 |
+
if idx not in drop_idx:
|
| 3048 |
+
out.append(m.group(0))
|
| 3049 |
+
last = m.end()
|
| 3050 |
+
out.append(text[last:])
|
| 3051 |
+
return "".join(out)
|
| 3052 |
+
|
| 3053 |
+
|
| 3054 |
def stabilize_tables_and_text(page_md: str) -> str:
|
| 3055 |
if not page_md:
|
| 3056 |
return page_md
|
|
|
|
| 3068 |
stabilized = "\n\n".join(out_blocks)
|
| 3069 |
stabilized = convert_plaintext_bank_sections(stabilized)
|
| 3070 |
stabilized = normalize_html_tables(stabilized)
|
| 3071 |
+
stabilized = _dedupe_subset_transaction_tables_html(stabilized)
|
| 3072 |
return close_unclosed_html(stabilized)
|
| 3073 |
|
| 3074 |
# --------------------------
|