rehan953 commited on
Commit
de55a85
·
verified ·
1 Parent(s): 9c2be93

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +98 -51
app.py CHANGED
@@ -36,11 +36,16 @@ Primary goals for reconcile rate:
36
  debit pattern) and at least one ITM DEPOSIT row exists, OCR stacked headings wrong — emit
37
  Electronic Credits, then the table, then Electronic Debits (section continues below). Safe no-op
38
  if the Debits heading was not before the table or any row looks like a card/ATM debit.
 
 
39
  - Fix 17c-html: Same as 17c using raw HTML cues when TableGridParser/header normalization would skip the table.
 
40
  - Fix 17d: Drop a second Electronic Debits block + DA3 table when its rows are a loose (date+amount)
41
  multiset subset of the immediately preceding DA3 table (OCR duplicate tail / wrong split).
 
 
42
  - Fix 17e: Repeat EC/ED fused + 17d passes until stable (handles chained replacements). Centered section titles
43
- match both align=\"center\" and style=\"text-align:center\" on divs.
44
  - Post-pass: dedupe 3-col Date|Description|Amount tables when one is a duplicate fragment
45
  (later subset of earlier, earlier subset of later, or identical row sets)
46
  - Fix 18: adjacent DA3 tables — if the last K data rows of table N match the first K rows of table N+1
@@ -87,6 +92,9 @@ Primary goals for reconcile rate:
87
  earlier larger DA3 table — catches OCR-truncated description duplicates such as
88
  the BoA service-fee page where a VIDDYOZE -1.11 fragment table appears after the
89
  full service-fee table with a longer description string.
 
 
 
90
  """
91
 
92
  # Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
@@ -1115,10 +1123,6 @@ def _patch_ocr_with_textlayer(page_md: str, pdf_path: str, page_num: int) -> str
1115
  key = (_norm(r["date"]), _norm(r["desc"]), _norm(r["amount"]))
1116
  tl_freq[key] = tl_freq.get(key, 0) + 1
1117
 
1118
- # Share with _dedupe_duplicate_rows_in_da3_table (Fix-20 override).
1119
- # Keys MUST match _transaction_row_dedupe_key (date/desc/amount, no spaces in
1120
- # date/desc; amount stripped of $/,/space). The injection map tl_freq uses
1121
- # _norm() with spaces — different tuple shape — so we keep a parallel count.
1122
  global _tl_freq_context
1123
  tl_dedupe = {}
1124
  for r in tl_rows:
@@ -2678,7 +2682,7 @@ def _transaction_row_dedupe_key(cells):
2678
  date_s = re.sub(r"\s+", "", date_s)
2679
  amt_s = re.sub(r"[\s$,]", "", amt_s).lower().replace("−", "-")
2680
  desc_s = html.unescape(desc_s)
2681
- desc_s = desc_s.replace("`", "'").replace("'", "'")
2682
  desc_s = re.sub(r"\s+", "", desc_s.lower())
2683
  return (date_s, desc_s, amt_s)
2684
 
@@ -2747,11 +2751,6 @@ _DA3_MONEY_TOKEN = re.compile(
2747
  )
2748
  _MSFT_PIN_G_REF = re.compile(r"MICROSOFT#(G\d{6,16})", re.IGNORECASE)
2749
 
2750
- # Module-level text-layer frequency context (Fix-20 override).
2751
- # Set by _patch_ocr_with_textlayer; consumed by normalize_html_tables in run_ocr's
2752
- # post-patch pass. Cleared at the start of stabilize_tables_and_text (new page)
2753
- # and on patch failure so keys never bleed across pages.
2754
- # Keys match _transaction_row_dedupe_key (not the _norm()-based tl_freq map).
2755
  _tl_freq_context: dict = {}
2756
 
2757
  _DA3_LEADING_DATE = re.compile(r"^\d{1,2}/\d{1,2}(?:/\d{2,4})?$")
@@ -2842,12 +2841,6 @@ def _dedupe_duplicate_rows_in_da3_table(grid):
2842
  break
2843
  j += 1
2844
  run_len = j - i
2845
- # Cap at 2 by default (Fix 20: collapse OCR stutters) but honour
2846
- # the text-layer-confirmed count when available, so legitimate
2847
- # repeated bank transactions (e.g. 6x Temporary Credit Reversal
2848
- # on the same date/amount) are not incorrectly collapsed to 2.
2849
- # Keys absent from _tl_freq_context still cap at 2 — safe no-op
2850
- # for scanned PDFs or pages where no text layer was extracted.
2851
  tl_confirmed = _tl_freq_context.get(k, 0)
2852
  cap = max(tl_confirmed, 2) if tl_confirmed > 0 else 2
2853
  take = min(run_len, cap)
@@ -2942,10 +2935,38 @@ def _ucb_has_itm_deposit_data_row(grid) -> bool:
2942
  return False
2943
 
2944
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2945
  def _ucb_table_credits_only_no_strong_debits(grid) -> bool:
2946
- """True if DA3 table has real data rows but none match strong electronic-debit patterns."""
 
 
 
 
 
 
 
2947
  if not grid or len(grid) < 2 or not _is_da3_header(grid):
2948
  return False
 
 
 
 
2949
  has_data = False
2950
  for r in range(1, len(grid)):
2951
  row_txt = _row_text_full_ucb(grid, r).strip()
@@ -2961,6 +2982,9 @@ def _ucb_html_table_body_looks_itm_credits_only(table_html: str) -> bool:
2961
  """
2962
  Detect ITM-only credit block without relying on parsed grid headers (Fix 17c-html).
2963
  Conservative: require ITM deposit text and absence of common electronic-debit verbs in the HTML.
 
 
 
2964
  """
2965
  if not table_html or "<table" not in table_html.lower():
2966
  return False
@@ -2971,6 +2995,30 @@ def _ucb_html_table_body_looks_itm_credits_only(table_html: str) -> bool:
2971
  return False
2972
  if "recur payment" in low or "debit card" in low:
2973
  return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2974
  return True
2975
 
2976
 
@@ -3028,6 +3076,7 @@ def _try_split_ucb_ec_ed_from_fused_table_html(
3028
 
3029
  # Fix 17c: headings stacked as EC / ED / table but rows are all credits (e.g. ITM-only) —
3030
  # put the table under Electronic Credits, then leave Electronic Debits for continuation.
 
3031
  if had_ed_heading_immediately_before_table and _ucb_table_credits_only_no_strong_debits(grid):
3032
  sect = '<div align="center">\n\n{title}\n\n</div>\n\n'
3033
  parts = []
@@ -3043,6 +3092,7 @@ def _try_split_ucb_ec_ed_from_fused_table_html(
3043
  return "\n\n".join(parts)
3044
 
3045
  # Fix 17c-html: same reorder when grid header checks fail but raw HTML is clearly ITM-only credits.
 
3046
  if had_ed_heading_immediately_before_table and _ucb_html_table_body_looks_itm_credits_only(table_html):
3047
  sect = '<div align="center">\n\n{title}\n\n</div>\n\n'
3048
  parts = []
@@ -3069,8 +3119,6 @@ _UCB_CENTER_DIV = (
3069
  r')'
3070
  )
3071
 
3072
- # Optional empty centered banner only; EC/ED title divs are re-emitted after a successful split
3073
- # so we do not duplicate headings.
3074
  _UCB_EC_ED_SINGLE_FUSED_TABLE = re.compile(
3075
  r'(?P<lead>(?:' + _UCB_CENTER_DIV + r'\s*</div>\s*)?)'
3076
  r'(?P<ecdiv>' + _UCB_CENTER_DIV + r'\s*Electronic\s+Credits\s*</div>\s*)'
@@ -3080,10 +3128,32 @@ _UCB_EC_ED_SINGLE_FUSED_TABLE = re.compile(
3080
  )
3081
 
3082
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3083
  def _ucb_strip_redundant_ed_subset_fragment(text: str) -> str:
3084
  """
3085
  Fix 17d: Remove a duplicate <div>Electronic Debits</div> + <table> when that table's rows are a
3086
  loose (date, amount) multiset subset of the DA3 table that appears immediately before this block.
 
 
 
 
3087
  """
3088
  if not text or "electronic debits" not in text.lower():
3089
  return text
@@ -3095,7 +3165,6 @@ def _ucb_strip_redundant_ed_subset_fragment(text: str) -> str:
3095
  )
3096
  tbl_pat = re.compile(r'<table[^>]*>.*?</table>', re.DOTALL | re.IGNORECASE)
3097
  _DATE_NONEMPTY = re.compile(r"^\d{1,2}[/\-]\d{2}")
3098
- # Stripped amounts may be "2194.19" (no comma) — must not use comma-group regex.
3099
  _AMT_STRIPPED_OK = re.compile(r"^-?\d+(?:\.\d{1,4})?-?$")
3100
 
3101
  def _loose_da_key_cells(cells):
@@ -3144,6 +3213,14 @@ def _ucb_strip_redundant_ed_subset_fragment(text: str) -> str:
3144
  cb, cf = Counter(kb), Counter(kf)
3145
  if not all(cf[k] <= cb.get(k, 0) for k in cf):
3146
  continue
 
 
 
 
 
 
 
 
3147
  text = text[: m.start()] + text[m.end() :]
3148
  changed = True
3149
  break
@@ -3428,21 +3505,6 @@ def _dedupe_da3_by_date_amount_loose(text: str) -> str:
3428
  """
3429
  Fix 30: Drop small DA3 table fragments (1-5 data rows) that are a loose
3430
  subset of an earlier larger DA3 table, matched by (date, amount) key alone.
3431
-
3432
- This catches OCR-truncated duplicate rows where description text differs
3433
- between the fragment and the anchor table, preventing the exact-key dedup
3434
- in _dedupe_subset_transaction_tables_html from firing.
3435
-
3436
- Example: BoA service-fee page — VIDDYOZE -1.11 appears in a 1-row
3437
- fragment table after a full 2-row service-fee table that has the same
3438
- row with a much longer description string.
3439
-
3440
- Guards:
3441
- - Fragment must have 1-5 data rows.
3442
- - Anchor table must have more data rows than the fragment.
3443
- - Every (date, amount) pair in the fragment must be covered by the anchor.
3444
- - Date must be non-empty; amount must look like currency.
3445
- - Wrapped in try/except (safe no-op on any error).
3446
  """
3447
  if not text or "<table" not in text.lower():
3448
  return text
@@ -3456,7 +3518,6 @@ def _dedupe_da3_by_date_amount_loose(text: str) -> str:
3456
  _AMT_NONEMPTY = re.compile(r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})-?$")
3457
 
3458
  def _da_key(cells):
3459
- """Loose (date_norm, amount_norm) key — description ignored."""
3460
  parts = [str(c or "").strip() for c in cells[:3]]
3461
  while len(parts) < 3:
3462
  parts.append("")
@@ -3465,7 +3526,6 @@ def _dedupe_da3_by_date_amount_loose(text: str) -> str:
3465
  amt_s = amt_s.replace("\u2212", "-").replace("\u2013", "-")
3466
  return (date_s, amt_s)
3467
 
3468
- # Parse all DA3 tables and collect their (date, amount) key lists.
3469
  da3_info = []
3470
  for m in matches:
3471
  g = _parse_da3_grid_from_table_html(m.group(0))
@@ -3499,8 +3559,6 @@ def _dedupe_da3_by_date_amount_loose(text: str) -> str:
3499
  if tj["n"] <= ti["n"]:
3500
  continue
3501
  big_keys = Counter(tj["keys"])
3502
- # Every (date, amount) pair in the fragment must be covered
3503
- # by the anchor table (multiset subset check).
3504
  if all(frag_keys[k] <= big_keys.get(k, 0) for k in frag_keys):
3505
  drop_set.add(ti["match"].start())
3506
  log.info(
@@ -3801,14 +3859,6 @@ def _strip_credits_prefix_from_debit_da3_table(text: str) -> str:
3801
  """
3802
  Fix 28: When OCR prepends the rows of a preceding credits/deposits DA3 table
3803
  onto the top of a debits/withdrawals DA3 table, strip that prefix.
3804
- Safety guards (ALL must pass):
3805
- 1. Both tables must be valid DA3 (Date|Description|Amount header).
3806
- 2. Target (later) table must follow a debit/withdrawal heading.
3807
- 3. Source (earlier) table must follow a credit/deposit heading.
3808
- 4. Only the contiguous leading prefix of matching rows is removed.
3809
- 5. At least 1 data row must remain in the target after stripping.
3810
- 6. Strip count must be >= 1.
3811
- 7. Entire function wrapped in try/except — returns input unchanged on error.
3812
  """
3813
  if not text or "<table" not in text.lower():
3814
  return text
@@ -4000,8 +4050,6 @@ def stabilize_tables_and_text(page_md: str) -> str:
4000
  if not page_md:
4001
  return page_md
4002
 
4003
- # Fresh page: drop any text-layer dedupe caps so the first normalize pass
4004
- # (before _patch_ocr_with_textlayer) does not see stale keys from a prior page.
4005
  _tl_freq_context.clear()
4006
 
4007
  page_md = normalize_money_glyphs(page_md)
@@ -4119,7 +4167,6 @@ def run_ocr(uploaded_file):
4119
 
4120
  all_pages = []
4121
  for page_num, page_result in enumerate(results):
4122
- # Header normalize runs before body patch; do not reuse prior page caps.
4123
  _tl_freq_context.clear()
4124
  page_md, regions = get_page_md_and_regions(page_result)
4125
  img_h = page_heights[page_num] if page_num < len(page_heights) else 1000
 
36
  debit pattern) and at least one ITM DEPOSIT row exists, OCR stacked headings wrong — emit
37
  Electronic Credits, then the table, then Electronic Debits (section continues below). Safe no-op
38
  if the Debits heading was not before the table or any row looks like a card/ATM debit.
39
+ GUARD: Do NOT fire Fix 17c if ALL rows are ITM DEPOSIT — that indicates a Deposits (continued)
40
+ block that was incorrectly preceded by EC/ED headings, not a genuine Electronic Credits table.
41
  - Fix 17c-html: Same as 17c using raw HTML cues when TableGridParser/header normalization would skip the table.
42
+ Same ITM-only guard applied.
43
  - Fix 17d: Drop a second Electronic Debits block + DA3 table when its rows are a loose (date+amount)
44
  multiset subset of the immediately preceding DA3 table (OCR duplicate tail / wrong split).
45
+ GUARD: Do NOT drop a small table if it contains genuine credit-side rows (POS Return,
46
+ Acct Fund INST, DDA Transfer IN) — these are misplaced EC rows, not ED duplicates.
47
  - Fix 17e: Repeat EC/ED fused + 17d passes until stable (handles chained replacements). Centered section titles
48
+ match both align="center" and style="text-align:center" on divs.
49
  - Post-pass: dedupe 3-col Date|Description|Amount tables when one is a duplicate fragment
50
  (later subset of earlier, earlier subset of later, or identical row sets)
51
  - Fix 18: adjacent DA3 tables — if the last K data rows of table N match the first K rows of table N+1
 
92
  earlier larger DA3 table — catches OCR-truncated description duplicates such as
93
  the BoA service-fee page where a VIDDYOZE -1.11 fragment table appears after the
94
  full service-fee table with a longer description string.
95
+ - Fix 17c/17c-html/17d GUARD (UCB page 3): Prevent misclassification of "Deposits (continued)"
96
+ ITM DEPOSIT blocks as Electronic Credits, and prevent Fix 17d from dropping the
97
+ real Electronic Credits table (POS Return / Acct Fund INST / DDA Transfer IN).
98
  """
99
 
100
  # Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
 
1123
  key = (_norm(r["date"]), _norm(r["desc"]), _norm(r["amount"]))
1124
  tl_freq[key] = tl_freq.get(key, 0) + 1
1125
 
 
 
 
 
1126
  global _tl_freq_context
1127
  tl_dedupe = {}
1128
  for r in tl_rows:
 
2682
  date_s = re.sub(r"\s+", "", date_s)
2683
  amt_s = re.sub(r"[\s$,]", "", amt_s).lower().replace("−", "-")
2684
  desc_s = html.unescape(desc_s)
2685
+ desc_s = desc_s.replace("`", "'").replace("\u2019", "'")
2686
  desc_s = re.sub(r"\s+", "", desc_s.lower())
2687
  return (date_s, desc_s, amt_s)
2688
 
 
2751
  )
2752
  _MSFT_PIN_G_REF = re.compile(r"MICROSOFT#(G\d{6,16})", re.IGNORECASE)
2753
 
 
 
 
 
 
2754
  _tl_freq_context: dict = {}
2755
 
2756
  _DA3_LEADING_DATE = re.compile(r"^\d{1,2}/\d{1,2}(?:/\d{2,4})?$")
 
2841
  break
2842
  j += 1
2843
  run_len = j - i
 
 
 
 
 
 
2844
  tl_confirmed = _tl_freq_context.get(k, 0)
2845
  cap = max(tl_confirmed, 2) if tl_confirmed > 0 else 2
2846
  take = min(run_len, cap)
 
2935
  return False
2936
 
2937
 
2938
+ def _ucb_all_rows_are_itm_deposit(grid) -> bool:
2939
+ """
2940
+ Returns True if ALL data rows (row index >= 1) in a DA3 grid are
2941
+ ITM DEPOSIT rows. Used to guard Fix 17c / 17c-html: a table that is
2942
+ exclusively ITM DEPOSIT entries is a "Deposits (continued)" block,
2943
+ NOT an Electronic Credits table, even when it has no strong-debit rows.
2944
+ """
2945
+ if not grid or len(grid) < 2:
2946
+ return False
2947
+ data_rows = [grid[r] for r in range(1, len(grid))
2948
+ if any(str(c or "").strip() for c in grid[r])]
2949
+ if not data_rows:
2950
+ return False
2951
+ return all(_is_itm_deposit_row_ucb(grid, r) for r in range(1, len(grid))
2952
+ if any(str(c or "").strip() for c in grid[r]))
2953
+
2954
+
2955
  def _ucb_table_credits_only_no_strong_debits(grid) -> bool:
2956
+ """
2957
+ True if DA3 table has real data rows but none match strong
2958
+ electronic-debit patterns AND at least one ITM DEPOSIT row exists.
2959
+
2960
+ FIX 17c GUARD: Returns False when ALL data rows are ITM DEPOSIT.
2961
+ That case is a "Deposits (continued)" block — not Electronic Credits —
2962
+ so Fix 17c must NOT reorder it under an EC heading.
2963
+ """
2964
  if not grid or len(grid) < 2 or not _is_da3_header(grid):
2965
  return False
2966
+ # Guard: if every row is ITM DEPOSIT, this is Deposits (continued),
2967
+ # not Electronic Credits. Do not trigger Fix 17c.
2968
+ if _ucb_all_rows_are_itm_deposit(grid):
2969
+ return False
2970
  has_data = False
2971
  for r in range(1, len(grid)):
2972
  row_txt = _row_text_full_ucb(grid, r).strip()
 
2982
  """
2983
  Detect ITM-only credit block without relying on parsed grid headers (Fix 17c-html).
2984
  Conservative: require ITM deposit text and absence of common electronic-debit verbs in the HTML.
2985
+
2986
+ FIX 17c-html GUARD: Returns False when the table contains ONLY ITM DEPOSIT rows
2987
+ (no other description text), as that indicates a Deposits (continued) block.
2988
  """
2989
  if not table_html or "<table" not in table_html.lower():
2990
  return False
 
2995
  return False
2996
  if "recur payment" in low or "debit card" in low:
2997
  return False
2998
+ # Additional guard: if every <td> description cell only contains "ITM DEPOSIT"
2999
+ # (no other transaction types), treat this as Deposits (continued), not EC.
3000
+ # Extract description cell texts from <td> elements (skip header row <th>).
3001
+ td_texts = re.findall(r"<td>([^<]*)</td>", table_html, re.IGNORECASE)
3002
+ if td_texts:
3003
+ # td_texts from a DA3 table come in triplets: date, desc, amount
3004
+ # Check if any non-date, non-amount cell has text other than "ITM DEPOSIT"
3005
+ non_itm_desc = False
3006
+ for i, txt in enumerate(td_texts):
3007
+ txt_stripped = txt.strip().lower()
3008
+ if not txt_stripped:
3009
+ continue
3010
+ # Skip date-like tokens and amount-like tokens
3011
+ if re.match(r"^\d{1,2}/\d{1,2}(?:/\d{2,4})?$", txt_stripped):
3012
+ continue
3013
+ if re.match(r"^\$?[\d,]+\.\d{2}$", txt_stripped):
3014
+ continue
3015
+ # It's a description cell
3016
+ if "itm deposit" not in txt_stripped and "itm dep" not in txt_stripped:
3017
+ non_itm_desc = True
3018
+ break
3019
+ if not non_itm_desc:
3020
+ # All description cells are ITM DEPOSIT — this is Deposits (continued)
3021
+ return False
3022
  return True
3023
 
3024
 
 
3076
 
3077
  # Fix 17c: headings stacked as EC / ED / table but rows are all credits (e.g. ITM-only) —
3078
  # put the table under Electronic Credits, then leave Electronic Debits for continuation.
3079
+ # GUARD: Do NOT fire if all rows are ITM DEPOSIT (that is Deposits continued, not EC).
3080
  if had_ed_heading_immediately_before_table and _ucb_table_credits_only_no_strong_debits(grid):
3081
  sect = '<div align="center">\n\n{title}\n\n</div>\n\n'
3082
  parts = []
 
3092
  return "\n\n".join(parts)
3093
 
3094
  # Fix 17c-html: same reorder when grid header checks fail but raw HTML is clearly ITM-only credits.
3095
+ # GUARD: _ucb_html_table_body_looks_itm_credits_only now returns False for pure ITM DEPOSIT tables.
3096
  if had_ed_heading_immediately_before_table and _ucb_html_table_body_looks_itm_credits_only(table_html):
3097
  sect = '<div align="center">\n\n{title}\n\n</div>\n\n'
3098
  parts = []
 
3119
  r')'
3120
  )
3121
 
 
 
3122
  _UCB_EC_ED_SINGLE_FUSED_TABLE = re.compile(
3123
  r'(?P<lead>(?:' + _UCB_CENTER_DIV + r'\s*</div>\s*)?)'
3124
  r'(?P<ecdiv>' + _UCB_CENTER_DIV + r'\s*Electronic\s+Credits\s*</div>\s*)'
 
3128
  )
3129
 
3130
 
3131
+ def _ucb_table_html_has_genuine_credit_rows(table_html: str) -> bool:
3132
+ """
3133
+ Returns True if the table contains rows that are clearly credit-side
3134
+ (POS Return, Acct Fund INST/CUPERTINO, DDA Transfer IN).
3135
+ Used by Fix 17d guard to prevent dropping the real EC table.
3136
+ """
3137
+ if not table_html:
3138
+ return False
3139
+ low = table_html.lower()
3140
+ if "pos return" in low:
3141
+ return True
3142
+ if "acct fund" in low and ("inst" in low or "cupertino" in low):
3143
+ return True
3144
+ if "dda transfer" in low and "in" in low:
3145
+ return True
3146
+ return False
3147
+
3148
+
3149
  def _ucb_strip_redundant_ed_subset_fragment(text: str) -> str:
3150
  """
3151
  Fix 17d: Remove a duplicate <div>Electronic Debits</div> + <table> when that table's rows are a
3152
  loose (date, amount) multiset subset of the DA3 table that appears immediately before this block.
3153
+
3154
+ GUARD: Do NOT drop the table if it contains genuine credit-side rows (POS Return,
3155
+ Acct Fund INST/CUPERTINO, DDA Transfer IN). Those rows belong under Electronic Credits
3156
+ and must not be silently discarded as ED duplicates.
3157
  """
3158
  if not text or "electronic debits" not in text.lower():
3159
  return text
 
3165
  )
3166
  tbl_pat = re.compile(r'<table[^>]*>.*?</table>', re.DOTALL | re.IGNORECASE)
3167
  _DATE_NONEMPTY = re.compile(r"^\d{1,2}[/\-]\d{2}")
 
3168
  _AMT_STRIPPED_OK = re.compile(r"^-?\d+(?:\.\d{1,4})?-?$")
3169
 
3170
  def _loose_da_key_cells(cells):
 
3213
  cb, cf = Counter(kb), Counter(kf)
3214
  if not all(cf[k] <= cb.get(k, 0) for k in cf):
3215
  continue
3216
+ # GUARD: Do not drop a table that contains genuine credit-side rows.
3217
+ # These rows belong under Electronic Credits, not as ED duplicates.
3218
+ if _ucb_table_html_has_genuine_credit_rows(m.group("tbl")):
3219
+ log.info(
3220
+ "Fix 17d: skipping drop of table with genuine credit rows "
3221
+ "(POS Return / Acct Fund INST / DDA Transfer IN)"
3222
+ )
3223
+ continue
3224
  text = text[: m.start()] + text[m.end() :]
3225
  changed = True
3226
  break
 
3505
  """
3506
  Fix 30: Drop small DA3 table fragments (1-5 data rows) that are a loose
3507
  subset of an earlier larger DA3 table, matched by (date, amount) key alone.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3508
  """
3509
  if not text or "<table" not in text.lower():
3510
  return text
 
3518
  _AMT_NONEMPTY = re.compile(r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})-?$")
3519
 
3520
  def _da_key(cells):
 
3521
  parts = [str(c or "").strip() for c in cells[:3]]
3522
  while len(parts) < 3:
3523
  parts.append("")
 
3526
  amt_s = amt_s.replace("\u2212", "-").replace("\u2013", "-")
3527
  return (date_s, amt_s)
3528
 
 
3529
  da3_info = []
3530
  for m in matches:
3531
  g = _parse_da3_grid_from_table_html(m.group(0))
 
3559
  if tj["n"] <= ti["n"]:
3560
  continue
3561
  big_keys = Counter(tj["keys"])
 
 
3562
  if all(frag_keys[k] <= big_keys.get(k, 0) for k in frag_keys):
3563
  drop_set.add(ti["match"].start())
3564
  log.info(
 
3859
  """
3860
  Fix 28: When OCR prepends the rows of a preceding credits/deposits DA3 table
3861
  onto the top of a debits/withdrawals DA3 table, strip that prefix.
 
 
 
 
 
 
 
 
3862
  """
3863
  if not text or "<table" not in text.lower():
3864
  return text
 
4050
  if not page_md:
4051
  return page_md
4052
 
 
 
4053
  _tl_freq_context.clear()
4054
 
4055
  page_md = normalize_money_glyphs(page_md)
 
4167
 
4168
  all_pages = []
4169
  for page_num, page_result in enumerate(results):
 
4170
  _tl_freq_context.clear()
4171
  page_md, regions = get_page_md_and_regions(page_result)
4172
  img_h = page_heights[page_num] if page_num < len(page_heights) else 1000