rehan953 commited on
Commit
51d561c
·
verified ·
1 Parent(s): 208b105

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +177 -0
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
  # --------------------------