rehan953 commited on
Commit
2939418
·
verified ·
1 Parent(s): 3ba0707

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +222 -2
app.py CHANGED
@@ -43,6 +43,14 @@ Primary goals for reconcile rate:
43
  (then subset dedupe can drop the stray duplicate block, e.g. under Deposits).
44
  - Fix 22: Drop a single OCR “flat line” between pages: (continued) + DESCRIPTION + AMOUNT + many MM/DD
45
  tokens (noise before a proper <table>).
 
 
 
 
 
 
 
 
46
  """
47
 
48
  # Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
@@ -64,7 +72,7 @@ import re
64
  import html
65
  import tempfile
66
  from typing import List, Tuple
67
- from collections import defaultdict
68
  from html.parser import HTMLParser
69
 
70
  import yaml
@@ -3143,6 +3151,59 @@ def _transaction_row_dedupe_key(cells):
3143
  return (date_s, desc_s, amt_s)
3144
 
3145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3146
  def _is_da3_header(grid):
3147
  if not grid or len(grid[0]) < 3:
3148
  return False
@@ -3400,12 +3461,15 @@ def _strip_orphan_continued_da3_flatline(text: str) -> str:
3400
  out = []
3401
  for line in lines:
3402
  core = line.rstrip("\r\n")
 
 
 
3403
  if (
3404
  len(core) > 80
3405
  and "(continued)" in core.lower()
3406
  and "description" in core.lower()
3407
  and "amount" in core.lower()
3408
- and len(re.findall(r"\d{2}/\d{2}", core)) >= 3
3409
  ):
3410
  continue
3411
  out.append(line)
@@ -3570,6 +3634,8 @@ def _dedupe_subset_transaction_tables_html(text: str) -> str:
3570
  "Deposits (continued)" preview before the same rows appear in the full list).
3571
  - Identical row sets → drop the later table.
3572
  - Fuzzy overlap (≥92% of later rows match earlier) → drop later (Electronic Debits vs main).
 
 
3573
  - Runs multiple passes until stable.
3574
  Row matching uses normalized keys so OCR variants (e.g. "0 27" vs "027", backticks)
3575
  do not prevent duplicate tables from being detected.
@@ -3653,6 +3719,23 @@ def _dedupe_subset_transaction_tables_html(text: str) -> str:
3653
  drop_idx.add(j)
3654
  continue
3655
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3656
  if not drop_idx:
3657
  return t
3658
 
@@ -3675,6 +3758,136 @@ def _dedupe_subset_transaction_tables_html(text: str) -> str:
3675
  return out
3676
 
3677
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3678
  def stabilize_tables_and_text(page_md: str) -> str:
3679
  if not page_md:
3680
  return page_md
@@ -3697,6 +3910,8 @@ def stabilize_tables_and_text(page_md: str) -> str:
3697
  stabilized = _split_ucb_deposits_electronic_sections_html(stabilized)
3698
  stabilized = _patch_da3_msft_g_ref_amounts_across_tables(stabilized)
3699
  stabilized = _dedupe_subset_transaction_tables_html(stabilized)
 
 
3700
  return close_unclosed_html(stabilized)
3701
 
3702
  # --------------------------
@@ -3820,6 +4035,7 @@ def run_ocr(uploaded_file):
3820
  # Safe no-op for scanned PDFs (no text layer) and non-transaction pages.
3821
  if is_pdf and not is_nfcu_doc:
3822
  stabilized = _patch_ocr_with_textlayer(stabilized, path, page_num)
 
3823
  # _patch_ocr_with_textlayer may append Case B rows from the PDF text layer
3824
  # with fused UCB-style cells; run the same HTML table pass again (Fix 13/14).
3825
  stabilized = normalize_html_tables(stabilized)
@@ -3827,6 +4043,7 @@ def run_ocr(uploaded_file):
3827
  stabilized = _split_ucb_deposits_electronic_sections_html(stabilized)
3828
  stabilized = _patch_da3_msft_g_ref_amounts_across_tables(stabilized)
3829
  stabilized = _dedupe_subset_transaction_tables_html(stabilized)
 
3830
  elif is_pdf and is_nfcu_doc:
3831
  # Navy-only additive fix: rebuild malformed summary table from
3832
  # text layer, but keep GLM body/header/footer unchanged.
@@ -3907,10 +4124,13 @@ def run_ocr(uploaded_file):
3907
  merged = "\n\n---page-separator---\n\n".join(all_pages) if all_pages else "(No content)"
3908
  # Cross-page: UCB section split + same transaction tables dedupe once on full doc.
3909
  if all_pages and merged and not merged.startswith("Error") and "<table" in merged.lower():
 
3910
  merged = _trim_adjacent_da3_suffix_duplicating_next_table_prefix(merged)
3911
  merged = _split_ucb_deposits_electronic_sections_html(merged)
3912
  merged = _patch_da3_msft_g_ref_amounts_across_tables(merged)
3913
  merged = _dedupe_subset_transaction_tables_html(merged)
 
 
3914
  return merged
3915
 
3916
  except Exception as e:
 
43
  (then subset dedupe can drop the stray duplicate block, e.g. under Deposits).
44
  - Fix 22: Drop a single OCR “flat line” between pages: (continued) + DESCRIPTION + AMOUNT + many MM/DD
45
  tokens (noise before a proper <table>).
46
+ - Fix 23: Doc-wide DA3 table dedupe using multiset of (date, amount) keys — catches duplicate sections when
47
+ OCR varies description text between copies (e.g. withdrawals repeated under Deposits).
48
+ - Fix 24: Run orphan flatline strip on merged PDF output and after text-layer patch so page-boundary
49
+ noise is removed consistently.
50
+ - Fix 25: After a centered “deposits + credits/interest” heading, if two DA3 tables appear back-to-back
51
+ and the first is mostly credit/deposit rows while the second is mostly debit/withdrawal-style
52
+ rows (generic keywords), drop the second — fixes OCR pasting the withdrawals block again.
53
+ - Fix 23b: Loose multiset keys use normalized float amounts so comma/spacing variants still match.
54
  """
55
 
56
  # Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
 
72
  import html
73
  import tempfile
74
  from typing import List, Tuple
75
+ from collections import Counter, defaultdict
76
  from html.parser import HTMLParser
77
 
78
  import yaml
 
3151
  return (date_s, desc_s, amt_s)
3152
 
3153
 
3154
+ def _da3_loose_key(cells):
3155
+ """
3156
+ (date, amount) only — for detecting duplicate DA3 tables when description drifts between OCR passes.
3157
+ Uses strict currency + float amount so junk cells do not create keys; 1,234.56 vs 1234.56 match.
3158
+ """
3159
+ parts = [str(c or "").strip() for c in cells[:3]]
3160
+ while len(parts) < 3:
3161
+ parts.append("")
3162
+ date_s, desc_s, amt_s = parts[0], parts[1], parts[2]
3163
+ if not _DA3_LEADING_DATE.match(date_s):
3164
+ return None
3165
+ comb = f"{date_s} {desc_s}".lower()
3166
+ if re.search(r"\btotal\b", comb) and len(desc_s) > 25:
3167
+ return None
3168
+ if not _DA3_STRICT_AMOUNT.match(amt_s.strip()):
3169
+ return None
3170
+ t_amt = re.sub(r"[\s$,]", "", amt_s).replace("−", "-").replace("$", "")
3171
+ try:
3172
+ af = round(float(t_amt), 2)
3173
+ except ValueError:
3174
+ return None
3175
+ d_norm = re.sub(r"\s+", "", date_s)
3176
+ return (d_norm, af)
3177
+
3178
+
3179
+ def _row_counter_loose_da3(g):
3180
+ """Multiset of (date, amount) for DA3 data rows with strict amounts."""
3181
+ c = Counter()
3182
+ if not g or len(g) < 2:
3183
+ return c
3184
+ for r in g[1:]:
3185
+ cells = [str(x or "").strip() for x in r]
3186
+ if not any(cells):
3187
+ continue
3188
+ k = _da3_loose_key(cells)
3189
+ if k:
3190
+ c[k] += 1
3191
+ return c
3192
+
3193
+
3194
+ def _overlap_ratio_counter(c_num, c_den):
3195
+ """Fraction of c_num's multiset mass matched in c_den (min counts per key)."""
3196
+ tot = sum(c_num.values())
3197
+ if tot == 0:
3198
+ return 0.0
3199
+ hit = sum(min(c_num[k], c_den.get(k, 0)) for k in c_num)
3200
+ return hit / tot
3201
+
3202
+
3203
+ def _multiset_leq(c_small, c_big):
3204
+ return all(c_small[k] <= c_big.get(k, 0) for k in c_small)
3205
+
3206
+
3207
  def _is_da3_header(grid):
3208
  if not grid or len(grid[0]) < 3:
3209
  return False
 
3461
  out = []
3462
  for line in lines:
3463
  core = line.rstrip("\r\n")
3464
+ date_hits = len(re.findall(r"\d{2}/\d{2}", core)) + len(
3465
+ re.findall(r"\b\d{2}-\d{2}-\d{2}\b", core)
3466
+ )
3467
  if (
3468
  len(core) > 80
3469
  and "(continued)" in core.lower()
3470
  and "description" in core.lower()
3471
  and "amount" in core.lower()
3472
+ and date_hits >= 3
3473
  ):
3474
  continue
3475
  out.append(line)
 
3634
  "Deposits (continued)" preview before the same rows appear in the full list).
3635
  - Identical row sets → drop the later table.
3636
  - Fuzzy overlap (≥92% of later rows match earlier) → drop later (Electronic Debits vs main).
3637
+ - Fix 23: multiset overlap on (date, amount) only — drop when OCR varies description
3638
+ between two copies of the same section (e.g. withdrawals pasted twice).
3639
  - Runs multiple passes until stable.
3640
  Row matching uses normalized keys so OCR variants (e.g. "0 27" vs "027", backticks)
3641
  do not prevent duplicate tables from being detected.
 
3719
  drop_idx.add(j)
3720
  continue
3721
 
3722
+ # Fix 23: multiset (date, amount) overlap — duplicate tables when description OCR differs.
3723
+ ci = _row_counter_loose_da3(gi)
3724
+ cj = _row_counter_loose_da3(gj)
3725
+ ni, nj = sum(ci.values()), sum(cj.values())
3726
+ if ni >= 6 and nj >= 6:
3727
+ oi_j = _overlap_ratio_counter(ci, cj)
3728
+ oj_i = _overlap_ratio_counter(cj, ci)
3729
+ if oi_j >= 0.88 and oj_i >= 0.88:
3730
+ drop_idx.add(i)
3731
+ break
3732
+ if _multiset_leq(ci, cj) and ni < nj and oi_j >= 0.9:
3733
+ drop_idx.add(i)
3734
+ break
3735
+ if _multiset_leq(cj, ci) and nj < ni and oj_i >= 0.9:
3736
+ drop_idx.add(j)
3737
+ continue
3738
+
3739
  if not drop_idx:
3740
  return t
3741
 
 
3758
  return out
3759
 
3760
 
3761
+ _RE_CENTER_DIV_TWO_DA3_TABLES = re.compile(
3762
+ r'(<div\s+align=["\']center["\']\s*>\s*[^<]*?</div>\s*)'
3763
+ r'(<table[^>]*>.*?</table>)'
3764
+ r'(\s*<table[^>]*>.*?</table>)',
3765
+ re.IGNORECASE | re.DOTALL,
3766
+ )
3767
+
3768
+
3769
+ def _da3_row_credit_vs_debit_category(desc: str) -> str:
3770
+ """
3771
+ Generic classification for Date|Description|Amount rows (middle column only).
3772
+ Used to detect OCR pasting a withdrawals table after a deposits heading.
3773
+ """
3774
+ d = (desc or "").lower()
3775
+ if "debit card return" in d:
3776
+ return "credit"
3777
+ if "debit card" in d and "return" in d:
3778
+ return "credit"
3779
+ if "deposit" in d and "debit card" not in d[:60]:
3780
+ return "credit"
3781
+ if "incoming wire" in d or "wire ref" in d:
3782
+ return "credit"
3783
+ if "zelle business reversal" in d or ("reversal" in d and "zelle" in d):
3784
+ return "credit"
3785
+ if "lrm claims" in d or ("claims" in d and "adj" in d):
3786
+ return "credit"
3787
+ # Credits / tax refunds that omit the word "deposit" (common on statement continuations)
3788
+ if "adp tax" in d and "customer" in d:
3789
+ return "credit"
3790
+ if "debit card" in d:
3791
+ return "debit"
3792
+ if "ach corp debit" in d:
3793
+ return "debit"
3794
+ if "bus online ach settlement" in d:
3795
+ return "debit"
3796
+ if "zelle business payment to" in d:
3797
+ return "debit"
3798
+ if "visa money transfer debit" in d:
3799
+ return "debit"
3800
+ if "telephone payment" in d or "service charges" in d:
3801
+ return "debit"
3802
+ if "merch fee" in d:
3803
+ return "debit"
3804
+ if "deposit" in d:
3805
+ return "credit"
3806
+ return "unknown"
3807
+
3808
+
3809
+ def _da3_table_credit_debit_fractions(grid):
3810
+ """Returns (debit_frac, credit_frac, unknown_frac, scored_row_count)."""
3811
+ deb = cre = unk = 0
3812
+ if not grid or len(grid) < 2:
3813
+ return 0.0, 0.0, 0.0, 0
3814
+ for row in grid[1:]:
3815
+ cells = [str(c or "").strip() for c in row]
3816
+ while len(cells) < 3:
3817
+ cells.append("")
3818
+ if not _DA3_LEADING_DATE.match(cells[0]):
3819
+ continue
3820
+ if not _DA3_STRICT_AMOUNT.match((cells[2] or "").strip()):
3821
+ continue
3822
+ cat = _da3_row_credit_vs_debit_category(cells[1])
3823
+ if cat == "debit":
3824
+ deb += 1
3825
+ elif cat == "credit":
3826
+ cre += 1
3827
+ else:
3828
+ unk += 1
3829
+ tot = deb + cre + unk
3830
+ if tot == 0:
3831
+ return 0.0, 0.0, 0.0, 0
3832
+ return deb / tot, cre / tot, unk / tot, tot
3833
+
3834
+
3835
+ def _drop_da3_misplaced_debit_after_deposit_heading_tables(text: str) -> str:
3836
+ """
3837
+ Fix 25: PDF order is withdrawals → then deposits. OCR sometimes emits two DA3 tables
3838
+ after the deposits heading: real deposits, then a duplicate withdrawals block.
3839
+ Drop the second table when it is overwhelmingly debit-like and the first is credit-like.
3840
+ Repeats until no more matches (multiple duplicate blocks).
3841
+ """
3842
+ if not text or "<table" not in text.lower():
3843
+ return text
3844
+ if "deposit" not in text.lower():
3845
+ return text
3846
+
3847
+ for _ in range(12):
3848
+ replaced = False
3849
+ pos = 0
3850
+ while True:
3851
+ m = _RE_CENTER_DIV_TWO_DA3_TABLES.search(text, pos)
3852
+ if not m:
3853
+ break
3854
+ div = m.group(1)
3855
+ t1 = m.group(2)
3856
+ t2 = m.group(3)
3857
+ sl = div.lower()
3858
+ if "deposit" not in sl or not any(k in sl for k in ("credit", "interest", "credits")):
3859
+ pos = m.start() + 1
3860
+ continue
3861
+
3862
+ g1 = _parse_da3_grid_from_table_html(t1)
3863
+ g2 = _parse_da3_grid_from_table_html(t2)
3864
+ if not g1 or not g2 or not _is_da3_header(g1) or not _is_da3_header(g2):
3865
+ pos = m.start() + 1
3866
+ continue
3867
+
3868
+ db1, cr1, unk1, n1 = _da3_table_credit_debit_fractions(g1)
3869
+ db2, cr2, _unk2, n2 = _da3_table_credit_debit_fractions(g2)
3870
+ if n1 < 5 or n2 < 10:
3871
+ pos = m.start() + 1
3872
+ continue
3873
+ # First table: deposits often have ADP/merchant lines scored as unknown — blend unk toward credit.
3874
+ credit_like_first = cr1 + 0.42 * unk1
3875
+ if credit_like_first < 0.30 and credit_like_first <= db1 + 0.05:
3876
+ pos = m.start() + 1
3877
+ continue
3878
+ if db2 < 0.74 or db2 <= cr2 + 0.07:
3879
+ pos = m.start() + 1
3880
+ continue
3881
+
3882
+ text = text[: m.start()] + div + t1 + text[m.end() :]
3883
+ replaced = True
3884
+ break
3885
+ if not replaced:
3886
+ break
3887
+
3888
+ return text
3889
+
3890
+
3891
  def stabilize_tables_and_text(page_md: str) -> str:
3892
  if not page_md:
3893
  return page_md
 
3910
  stabilized = _split_ucb_deposits_electronic_sections_html(stabilized)
3911
  stabilized = _patch_da3_msft_g_ref_amounts_across_tables(stabilized)
3912
  stabilized = _dedupe_subset_transaction_tables_html(stabilized)
3913
+ stabilized = _drop_da3_misplaced_debit_after_deposit_heading_tables(stabilized)
3914
+ stabilized = _strip_orphan_continued_da3_flatline(stabilized)
3915
  return close_unclosed_html(stabilized)
3916
 
3917
  # --------------------------
 
4035
  # Safe no-op for scanned PDFs (no text layer) and non-transaction pages.
4036
  if is_pdf and not is_nfcu_doc:
4037
  stabilized = _patch_ocr_with_textlayer(stabilized, path, page_num)
4038
+ stabilized = _strip_orphan_continued_da3_flatline(stabilized)
4039
  # _patch_ocr_with_textlayer may append Case B rows from the PDF text layer
4040
  # with fused UCB-style cells; run the same HTML table pass again (Fix 13/14).
4041
  stabilized = normalize_html_tables(stabilized)
 
4043
  stabilized = _split_ucb_deposits_electronic_sections_html(stabilized)
4044
  stabilized = _patch_da3_msft_g_ref_amounts_across_tables(stabilized)
4045
  stabilized = _dedupe_subset_transaction_tables_html(stabilized)
4046
+ stabilized = _drop_da3_misplaced_debit_after_deposit_heading_tables(stabilized)
4047
  elif is_pdf and is_nfcu_doc:
4048
  # Navy-only additive fix: rebuild malformed summary table from
4049
  # text layer, but keep GLM body/header/footer unchanged.
 
4124
  merged = "\n\n---page-separator---\n\n".join(all_pages) if all_pages else "(No content)"
4125
  # Cross-page: UCB section split + same transaction tables dedupe once on full doc.
4126
  if all_pages and merged and not merged.startswith("Error") and "<table" in merged.lower():
4127
+ merged = _strip_orphan_continued_da3_flatline(merged)
4128
  merged = _trim_adjacent_da3_suffix_duplicating_next_table_prefix(merged)
4129
  merged = _split_ucb_deposits_electronic_sections_html(merged)
4130
  merged = _patch_da3_msft_g_ref_amounts_across_tables(merged)
4131
  merged = _dedupe_subset_transaction_tables_html(merged)
4132
+ merged = _drop_da3_misplaced_debit_after_deposit_heading_tables(merged)
4133
+ merged = _strip_orphan_continued_da3_flatline(merged)
4134
  return merged
4135
 
4136
  except Exception as e: