rehan953 commited on
Commit
1b1cb6a
·
verified ·
1 Parent(s): d16a54d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +148 -67
app.py CHANGED
@@ -58,13 +58,22 @@ Primary goals for reconcile rate:
58
  - Fix 28: Strip leading credit/deposit rows that OCR prepends to a withdrawals/debits DA3 table
59
  when the preceding DA3 table was under a deposits/credits heading. Only strips the
60
  contiguous prefix of the debit table whose rows appear in the credit table; requires
61
- 1 data row to remain; entire function wrapped in try/except (safe no-op on any error).
62
  - Fix 29: Move a misplaced debit/withdrawal heading to before its orphan DA3 table when OCR
63
  places the heading AFTER the table instead of before it. Pattern detected:
64
  [credit heading+table] ... [orphan DA3 table] ... [debit heading] — the heading is
65
  relocated to just before the orphan table. Guards: no debit heading already before
66
  table; credit context must precede table; no other table between orphan and heading;
67
  try/except wrapper (safe no-op on any error).
 
 
 
 
 
 
 
 
 
68
  """
69
 
70
  # Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
@@ -432,10 +441,6 @@ def _is_amount_like(s: str) -> bool:
432
  return re.fullmatch(r"\$?-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?", s.strip()) is not None
433
 
434
  def _drop_truly_empty_columns(grid):
435
- """
436
- Drop columns that are empty in header AND almost always empty in body.
437
- This fixes summary tables that have an extra blank trailing column.
438
- """
439
  if not grid or len(grid) < 1:
440
  return grid
441
 
@@ -470,12 +475,6 @@ def _drop_truly_empty_columns(grid):
470
  return new_grid
471
 
472
  def _merge_blank_header_text_columns(grid):
473
- """
474
- If the header row has blank columns, and most body rows have non-empty *text* in that
475
- blank column, merge that column into the nearest non-empty header to the left (usually
476
- DESCRIPTION), then remove the blank column.
477
- This fixes transaction tables where DESCRIPTION is split across two columns.
478
- """
479
  if not grid or len(grid) < 2:
480
  return grid
481
 
@@ -536,16 +535,12 @@ def _merge_blank_header_text_columns(grid):
536
  # Shared keyword definitions
537
  # --------------------------
538
 
539
- # Matches a standalone money value across currencies.
540
- # Covers: $3,447.10 -1,234.56 £500.00 Rs1000 etc.
541
  _MONEY_RE = re.compile(
542
  r"^(?:[\$£€¥]|Rs\.?|INR|PKR)?\s*-?\s?\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$",
543
  re.IGNORECASE,
544
  )
545
 
546
- # All column-header keywords recognised across any bank statement format.
547
  _HEADER_KW_PATTERNS = [
548
- # Date variants
549
  r"DATE",
550
  r"POSTING\s+DATE",
551
  r"VALUE\s+DATE",
@@ -553,7 +548,6 @@ _HEADER_KW_PATTERNS = [
553
  r"TRANSACTION\s+DATE",
554
  r"ENTRY\s+DATE",
555
  r"EFFECTIVE\s+DATE",
556
- # Transaction ID / reference
557
  r"TRANSACTION(?:\s+(?:ID|TYPE|NO|NUMBER))?",
558
  r"TXN(?:\s+(?:ID|NO|TYPE))?",
559
  r"REF(?:ERENCE)?(?:\s*(?:NO|NUM|NUMBER))?",
@@ -562,30 +556,25 @@ _HEADER_KW_PATTERNS = [
562
  r"VOUCHER(?:\s*(?:NO|NUMBER))?",
563
  r"SR\.?\s*NO\.?",
564
  r"SERIAL(?:\s*(?:NO|NUMBER))?",
565
- # Description variants
566
  r"DESCRIPTION",
567
  r"DETAILS?",
568
  r"PARTICULARS?(?:\s+OF\s+TRANSACTION)?",
569
  r"NARRATION",
570
  r"REMARKS?",
571
  r"NOTES?",
572
- # Debit variants
573
  r"DEBIT",
574
  r"DEBITS?",
575
  r"DR\.?",
576
  r"WITHDRAWALS?",
577
  r"PAID\s+OUT",
578
  r"MONEY\s+OUT",
579
- # Credit variants
580
  r"CREDIT",
581
  r"CREDITS?",
582
  r"CR\.?",
583
  r"DEPOSITS?",
584
  r"PAID\s+IN",
585
  r"MONEY\s+IN",
586
- # Amount
587
  r"AMOUNT",
588
- # Balance variants
589
  r"BALANCE",
590
  r"BAL\.?",
591
  r"RUNNING\s+BALANCE",
@@ -601,20 +590,16 @@ _HEADER_KW_PATTERNS = [
601
  r"OPENING\s+BAL\.?",
602
  ]
603
 
604
- # Pre-compiled: each pattern anchored at start, case-insensitive
605
  _HEADER_KW_RES = [
606
  re.compile(r"^(" + p + r")(.*)", re.IGNORECASE | re.DOTALL)
607
  for p in _HEADER_KW_PATTERNS
608
  ]
609
 
610
- # Quick exact-match check: "is this entire string a known keyword?"
611
  _HEADER_KW_EXACT_RE = re.compile(
612
  r"^(?:" + r"|".join(_HEADER_KW_PATTERNS) + r")$",
613
  re.IGNORECASE,
614
  )
615
 
616
- # Minimum fraction of cells in a row that must be pure keywords for that row
617
- # to be considered a misplaced header row.
618
  _HEADER_ROW_KEYWORD_THRESHOLD = 0.5
619
 
620
  # --------------------------
@@ -1117,6 +1102,11 @@ def _patch_ocr_with_textlayer(page_md: str, pdf_path: str, page_num: int) -> str
1117
  key = (_norm(r["date"]), _norm(r["desc"]), _norm(r["amount"]))
1118
  tl_freq[key] = tl_freq.get(key, 0) + 1
1119
 
 
 
 
 
 
1120
  table_pattern = re.compile(r"<table[^>]*>.*?</table>", re.DOTALL | re.IGNORECASE)
1121
  result = page_md
1122
 
@@ -1291,6 +1281,9 @@ def _patch_ocr_with_textlayer(page_md: str, pdf_path: str, page_num: int) -> str
1291
  except Exception as e:
1292
  log.warning("_patch_ocr_with_textlayer failed (page %d): %s", page_num, e)
1293
  return page_md
 
 
 
1294
 
1295
  # --------------------------
1296
  # Fix 5: Mid-table separator row reconstruction
@@ -2736,6 +2729,15 @@ _DA3_MONEY_TOKEN = re.compile(
2736
  r"(?<![\d,])(-?\$?\d{1,3}(?:,\d{3})*\.\d{2}|-?\$?\d{2,6}\.\d{2})(?!\d)"
2737
  )
2738
  _MSFT_PIN_G_REF = re.compile(r"MICROSOFT#(G\d{6,16})", re.IGNORECASE)
 
 
 
 
 
 
 
 
 
2739
  _DA3_LEADING_DATE = re.compile(r"^\d{1,2}/\d{1,2}(?:/\d{2,4})?$")
2740
 
2741
 
@@ -2824,7 +2826,15 @@ def _dedupe_duplicate_rows_in_da3_table(grid):
2824
  break
2825
  j += 1
2826
  run_len = j - i
2827
- take = min(run_len, 2)
 
 
 
 
 
 
 
 
2828
  for t in range(take):
2829
  out.append(data_rows[i + t])
2830
  i = j
@@ -3128,6 +3138,110 @@ def _trim_adjacent_da3_suffix_duplicating_next_table_prefix(text: str) -> str:
3128
  return text
3129
 
3130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3131
  def _dedupe_subset_transaction_tables_html(text: str) -> str:
3132
  if not text or "<table" not in text.lower():
3133
  return text
@@ -3374,28 +3488,20 @@ def _drop_da3_misplaced_debit_after_deposit_heading_tables(text: str) -> str:
3374
  # Fix 28: Strip credit-row prefix from debit DA3 table
3375
  # --------------------------
3376
 
3377
- # Keywords that identify a section heading as debit/withdrawal-oriented.
3378
  _DEBIT_HEADING_KEYWORDS = re.compile(
3379
  r"\b(withdrawal|withdrawals|debit|debits|charges?|subtraction|subtractions|"
3380
  r"payments?\s+made|money\s+out|paid\s+out|electronic\s+debit)\b",
3381
  re.IGNORECASE,
3382
  )
3383
- # Keywords that identify a section heading as credit/deposit-oriented.
3384
  _CREDIT_HEADING_KEYWORDS = re.compile(
3385
  r"\b(deposit|deposits|credit|credits|addition|additions|interest\s+paid|"
3386
  r"money\s+in|paid\s+in|incoming|received)\b",
3387
  re.IGNORECASE,
3388
  )
3389
- # How many characters before a table start to scan for a section heading.
3390
  _HEADING_SCAN_WINDOW = 600
3391
 
3392
 
3393
  def _get_heading_before_table(text: str, table_start: int) -> str:
3394
- """
3395
- Return the last non-trivial text line in the _HEADING_SCAN_WINDOW chars
3396
- that precede `table_start`. HTML tags are stripped before scanning so that
3397
- centered <div> headings are picked up correctly.
3398
- """
3399
  window = text[max(0, table_start - _HEADING_SCAN_WINDOW): table_start]
3400
  clean = re.sub(r"<[^>]+>", " ", window)
3401
  lines = [ln.strip() for ln in clean.splitlines() if ln.strip()]
@@ -3426,7 +3532,6 @@ def _strip_credits_prefix_from_debit_da3_table(text: str) -> str:
3426
  if len(matches) < 2:
3427
  return text
3428
 
3429
- # Collect all DA3 tables with their heading context.
3430
  da3_tables = []
3431
  for m in matches:
3432
  g = _parse_da3_grid_from_table_html(m.group(0))
@@ -3446,10 +3551,7 @@ def _strip_credits_prefix_from_debit_da3_table(text: str) -> str:
3446
  keys.add(_transaction_row_dedupe_key(cells))
3447
  return keys
3448
 
3449
- # For each DA3 table preceded by a debit heading, look backward for the
3450
- # nearest DA3 table preceded by a credit heading, and measure how many
3451
- # of the debit table's leading rows appear in that credit table.
3452
- drop_map = {} # table match start -> number of rows to strip
3453
  for i in range(1, len(da3_tables)):
3454
  ti = da3_tables[i]
3455
  hi = ti["heading"].lower()
@@ -3468,7 +3570,6 @@ def _strip_credits_prefix_from_debit_da3_table(text: str) -> str:
3468
  keys_j = _row_keys(gj)
3469
  if not keys_j:
3470
  continue
3471
- # Count the contiguous leading rows of data_i that are in keys_j.
3472
  strip_count = 0
3473
  for row in data_i:
3474
  cells = [str(c or "").strip() for c in row]
@@ -3479,7 +3580,6 @@ def _strip_credits_prefix_from_debit_da3_table(text: str) -> str:
3479
  break
3480
  if strip_count < 1:
3481
  continue
3482
- # Guard: at least 1 data row must survive.
3483
  if len(data_i) - strip_count < 1:
3484
  continue
3485
  drop_map[ti["match"].start()] = strip_count
@@ -3487,12 +3587,11 @@ def _strip_credits_prefix_from_debit_da3_table(text: str) -> str:
3487
  "Fix 28: stripped %d leading credit row(s) from debit table at offset %d",
3488
  strip_count, ti["match"].start(),
3489
  )
3490
- break # found nearest credit table — stop looking further back
3491
 
3492
  if not drop_map:
3493
  return text
3494
 
3495
- # Apply the strip.
3496
  out_chunks = []
3497
  last = 0
3498
  for m in matches:
@@ -3520,32 +3619,17 @@ def _strip_credits_prefix_from_debit_da3_table(text: str) -> str:
3520
  # Fix 29: Move misplaced debit heading to before its orphan DA3 table
3521
  # --------------------------
3522
 
3523
- # Centered div heading pattern (BoA / generic style)
3524
  _CENTER_DIV_HDR_RE = re.compile(
3525
  r'<div\s+align=["\']center["\']\s*>\s*(.*?)\s*</div>',
3526
  re.IGNORECASE | re.DOTALL,
3527
  )
3528
- _HEADING_SCAN_FORWARD = 800 # chars after table end to search for misplaced heading
3529
 
3530
 
3531
  def _fix29_move_debit_heading_before_orphan_table(text: str) -> str:
3532
  """
3533
  Fix 29: When OCR places a debit/withdrawal section heading AFTER the withdrawals table
3534
  instead of before it, move the heading to its correct position (just before the table).
3535
-
3536
- Pattern detected:
3537
- [credit/deposit DA3 table] ...no debit heading... [orphan DA3 table] [debit heading]
3538
- where no other DA3 table separates the orphan table from the debit heading.
3539
-
3540
- Action: move the debit heading to just before the orphan DA3 table.
3541
-
3542
- Safety guards (ALL must pass):
3543
- 1. Orphan table must be a valid DA3 (Date|Description|Amount).
3544
- 2. A debit heading must appear AFTER the orphan table within _HEADING_SCAN_FORWARD chars.
3545
- 3. No debit heading appears immediately before the orphan table (within _HEADING_SCAN_WINDOW).
3546
- 4. A credit/deposit heading must exist before the orphan table (within 1200 chars).
3547
- 5. No other table appears between the orphan table end and the debit heading.
3548
- 6. Entire function wrapped in try/except -- returns input unchanged on any error.
3549
  """
3550
  if not text or "<table" not in text.lower():
3551
  return text
@@ -3566,7 +3650,6 @@ def _fix29_move_debit_heading_before_orphan_table(text: str) -> str:
3566
  orphan_start = orphan_m.start()
3567
  orphan_end = orphan_m.end()
3568
 
3569
- # Guard 3: no debit heading already before the orphan table
3570
  before_window = text[max(0, orphan_start - _HEADING_SCAN_WINDOW): orphan_start]
3571
  before_clean = re.sub(r"<[^>]+>", " ", before_window)
3572
  lines_before = [l.strip() for l in before_clean.splitlines() if l.strip()]
@@ -3576,24 +3659,20 @@ def _fix29_move_debit_heading_before_orphan_table(text: str) -> str:
3576
  last_meaningful = l
3577
  break
3578
  if _DEBIT_HEADING_KEYWORDS.search(last_meaningful):
3579
- continue # already has debit heading before it
3580
 
3581
- # Guard 4: credit context must precede the orphan table
3582
  big_before = text[max(0, orphan_start - 1200): orphan_start]
3583
  if not _CREDIT_HEADING_KEYWORDS.search(re.sub(r"<[^>]+>", " ", big_before)):
3584
  continue
3585
 
3586
- # Guard 5: no other table between orphan end and the potential debit heading
3587
  next_tbl_m = tbl_pat.search(text, orphan_end)
3588
 
3589
- # Guard 2: find debit heading after the orphan table (within forward window)
3590
  dh_start = dh_end = None
3591
  for div_m in _CENTER_DIV_HDR_RE.finditer(text, orphan_end, orphan_end + _HEADING_SCAN_FORWARD):
3592
  div_clean = re.sub(r"<[^>]+>", "", div_m.group(1)).strip()
3593
  if _DEBIT_HEADING_KEYWORDS.search(div_clean) and not _CREDIT_HEADING_KEYWORDS.search(div_clean):
3594
- # Guard 5: no table should be between orphan_end and this heading
3595
  if next_tbl_m and next_tbl_m.start() < div_m.start():
3596
- break # another table intervenes
3597
  dh_start = div_m.start()
3598
  dh_end = div_m.end()
3599
  break
@@ -3606,7 +3685,6 @@ def _fix29_move_debit_heading_before_orphan_table(text: str) -> str:
3606
  if not moves:
3607
  return text
3608
 
3609
- # Apply moves in reverse order to preserve offsets
3610
  for orphan_start, dh_start, dh_end in reversed(moves):
3611
  orphan_m2 = next((m for m in tbl_pat.finditer(text) if m.start() == orphan_start), None)
3612
  if not orphan_m2:
@@ -3655,6 +3733,7 @@ def stabilize_tables_and_text(page_md: str) -> str:
3655
  stabilized = _split_ucb_deposits_electronic_sections_html(stabilized)
3656
  stabilized = _patch_da3_msft_g_ref_amounts_across_tables(stabilized)
3657
  stabilized = _dedupe_subset_transaction_tables_html(stabilized)
 
3658
  stabilized = _drop_da3_misplaced_debit_after_deposit_heading_tables(stabilized)
3659
  stabilized = _strip_credits_prefix_from_debit_da3_table(stabilized) # Fix 28
3660
  stabilized = _fix29_move_debit_heading_before_orphan_table(stabilized) # Fix 29
@@ -3783,6 +3862,7 @@ def run_ocr(uploaded_file):
3783
  stabilized = _split_ucb_deposits_electronic_sections_html(stabilized)
3784
  stabilized = _patch_da3_msft_g_ref_amounts_across_tables(stabilized)
3785
  stabilized = _dedupe_subset_transaction_tables_html(stabilized)
 
3786
  stabilized = _drop_da3_misplaced_debit_after_deposit_heading_tables(stabilized)
3787
  stabilized = _strip_credits_prefix_from_debit_da3_table(stabilized) # Fix 28
3788
  stabilized = _fix29_move_debit_heading_before_orphan_table(stabilized) # Fix 29
@@ -3859,6 +3939,7 @@ def run_ocr(uploaded_file):
3859
  merged = _split_ucb_deposits_electronic_sections_html(merged)
3860
  merged = _patch_da3_msft_g_ref_amounts_across_tables(merged)
3861
  merged = _dedupe_subset_transaction_tables_html(merged)
 
3862
  merged = _drop_da3_misplaced_debit_after_deposit_heading_tables(merged)
3863
  merged = _strip_credits_prefix_from_debit_da3_table(merged) # Fix 28
3864
  merged = _fix29_move_debit_heading_before_orphan_table(merged) # Fix 29
 
58
  - Fix 28: Strip leading credit/deposit rows that OCR prepends to a withdrawals/debits DA3 table
59
  when the preceding DA3 table was under a deposits/credits heading. Only strips the
60
  contiguous prefix of the debit table whose rows appear in the credit table; requires
61
+ >=1 data row to remain; entire function wrapped in try/except (safe no-op on any error).
62
  - Fix 29: Move a misplaced debit/withdrawal heading to before its orphan DA3 table when OCR
63
  places the heading AFTER the table instead of before it. Pattern detected:
64
  [credit heading+table] ... [orphan DA3 table] ... [debit heading] — the heading is
65
  relocated to just before the orphan table. Guards: no debit heading already before
66
  table; credit context must precede table; no other table between orphan and heading;
67
  try/except wrapper (safe no-op on any error).
68
+ - Fix 20-override: _dedupe_duplicate_rows_in_da3_table now uses the PDF text-layer frequency
69
+ map (_tl_freq_context) as its cap so legitimate repeated transactions (e.g. 6x
70
+ Temporary Credit Reversal on the same date/amount) are preserved rather than
71
+ being collapsed to 2. Keys absent from the text layer still cap at 2 (OCR stutter
72
+ behaviour unchanged). Safe no-op for scanned PDFs with no text layer.
73
+ - Fix 30: Drop small DA3 fragments (1-5 rows) that are a loose (date+amount) subset of an
74
+ earlier larger DA3 table — catches OCR-truncated description duplicates such as
75
+ the BoA service-fee page where a VIDDYOZE -1.11 fragment table appears after the
76
+ full service-fee table with a longer description string.
77
  """
78
 
79
  # Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
 
441
  return re.fullmatch(r"\$?-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?", s.strip()) is not None
442
 
443
  def _drop_truly_empty_columns(grid):
 
 
 
 
444
  if not grid or len(grid) < 1:
445
  return grid
446
 
 
475
  return new_grid
476
 
477
  def _merge_blank_header_text_columns(grid):
 
 
 
 
 
 
478
  if not grid or len(grid) < 2:
479
  return grid
480
 
 
535
  # Shared keyword definitions
536
  # --------------------------
537
 
 
 
538
  _MONEY_RE = re.compile(
539
  r"^(?:[\$£€¥]|Rs\.?|INR|PKR)?\s*-?\s?\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$",
540
  re.IGNORECASE,
541
  )
542
 
 
543
  _HEADER_KW_PATTERNS = [
 
544
  r"DATE",
545
  r"POSTING\s+DATE",
546
  r"VALUE\s+DATE",
 
548
  r"TRANSACTION\s+DATE",
549
  r"ENTRY\s+DATE",
550
  r"EFFECTIVE\s+DATE",
 
551
  r"TRANSACTION(?:\s+(?:ID|TYPE|NO|NUMBER))?",
552
  r"TXN(?:\s+(?:ID|NO|TYPE))?",
553
  r"REF(?:ERENCE)?(?:\s*(?:NO|NUM|NUMBER))?",
 
556
  r"VOUCHER(?:\s*(?:NO|NUMBER))?",
557
  r"SR\.?\s*NO\.?",
558
  r"SERIAL(?:\s*(?:NO|NUMBER))?",
 
559
  r"DESCRIPTION",
560
  r"DETAILS?",
561
  r"PARTICULARS?(?:\s+OF\s+TRANSACTION)?",
562
  r"NARRATION",
563
  r"REMARKS?",
564
  r"NOTES?",
 
565
  r"DEBIT",
566
  r"DEBITS?",
567
  r"DR\.?",
568
  r"WITHDRAWALS?",
569
  r"PAID\s+OUT",
570
  r"MONEY\s+OUT",
 
571
  r"CREDIT",
572
  r"CREDITS?",
573
  r"CR\.?",
574
  r"DEPOSITS?",
575
  r"PAID\s+IN",
576
  r"MONEY\s+IN",
 
577
  r"AMOUNT",
 
578
  r"BALANCE",
579
  r"BAL\.?",
580
  r"RUNNING\s+BALANCE",
 
590
  r"OPENING\s+BAL\.?",
591
  ]
592
 
 
593
  _HEADER_KW_RES = [
594
  re.compile(r"^(" + p + r")(.*)", re.IGNORECASE | re.DOTALL)
595
  for p in _HEADER_KW_PATTERNS
596
  ]
597
 
 
598
  _HEADER_KW_EXACT_RE = re.compile(
599
  r"^(?:" + r"|".join(_HEADER_KW_PATTERNS) + r")$",
600
  re.IGNORECASE,
601
  )
602
 
 
 
603
  _HEADER_ROW_KEYWORD_THRESHOLD = 0.5
604
 
605
  # --------------------------
 
1102
  key = (_norm(r["date"]), _norm(r["desc"]), _norm(r["amount"]))
1103
  tl_freq[key] = tl_freq.get(key, 0) + 1
1104
 
1105
+ # Share with _dedupe_duplicate_rows_in_da3_table so it can use
1106
+ # the confirmed count as the cap (Fix-20 override).
1107
+ global _tl_freq_context
1108
+ _tl_freq_context = dict(tl_freq)
1109
+
1110
  table_pattern = re.compile(r"<table[^>]*>.*?</table>", re.DOTALL | re.IGNORECASE)
1111
  result = page_md
1112
 
 
1281
  except Exception as e:
1282
  log.warning("_patch_ocr_with_textlayer failed (page %d): %s", page_num, e)
1283
  return page_md
1284
+ finally:
1285
+ # Always clear so stale frequencies do not affect subsequent pages.
1286
+ _tl_freq_context.clear()
1287
 
1288
  # --------------------------
1289
  # Fix 5: Mid-table separator row reconstruction
 
2729
  r"(?<![\d,])(-?\$?\d{1,3}(?:,\d{3})*\.\d{2}|-?\$?\d{2,6}\.\d{2})(?!\d)"
2730
  )
2731
  _MSFT_PIN_G_REF = re.compile(r"MICROSOFT#(G\d{6,16})", re.IGNORECASE)
2732
+
2733
+ # Module-level text-layer frequency context (Fix-20 override).
2734
+ # Set by _patch_ocr_with_textlayer before post-patch normalize passes;
2735
+ # cleared in a finally clause so it never bleeds across pages.
2736
+ # Keys are (date_norm, desc_norm, amount_norm) tuples matching tl_freq.
2737
+ # When a key is present, _dedupe_duplicate_rows_in_da3_table uses its value
2738
+ # as the cap instead of the default 2, preserving all text-layer-confirmed rows.
2739
+ _tl_freq_context: dict = {}
2740
+
2741
  _DA3_LEADING_DATE = re.compile(r"^\d{1,2}/\d{1,2}(?:/\d{2,4})?$")
2742
 
2743
 
 
2826
  break
2827
  j += 1
2828
  run_len = j - i
2829
+ # Cap at 2 by default (Fix 20: collapse OCR stutters) but honour
2830
+ # the text-layer-confirmed count when available, so legitimate
2831
+ # repeated bank transactions (e.g. 6x Temporary Credit Reversal
2832
+ # on the same date/amount) are not incorrectly collapsed to 2.
2833
+ # Keys absent from _tl_freq_context still cap at 2 — safe no-op
2834
+ # for scanned PDFs or pages where no text layer was extracted.
2835
+ tl_confirmed = _tl_freq_context.get(k, 0)
2836
+ cap = max(tl_confirmed, 2) if tl_confirmed > 0 else 2
2837
+ take = min(run_len, cap)
2838
  for t in range(take):
2839
  out.append(data_rows[i + t])
2840
  i = j
 
3138
  return text
3139
 
3140
 
3141
+ def _dedupe_da3_by_date_amount_loose(text: str) -> str:
3142
+ """
3143
+ Fix 30: Drop small DA3 table fragments (1-5 data rows) that are a loose
3144
+ subset of an earlier larger DA3 table, matched by (date, amount) key alone.
3145
+
3146
+ This catches OCR-truncated duplicate rows where description text differs
3147
+ between the fragment and the anchor table, preventing the exact-key dedup
3148
+ in _dedupe_subset_transaction_tables_html from firing.
3149
+
3150
+ Example: BoA service-fee page — VIDDYOZE -1.11 appears in a 1-row
3151
+ fragment table after a full 2-row service-fee table that has the same
3152
+ row with a much longer description string.
3153
+
3154
+ Guards:
3155
+ - Fragment must have 1-5 data rows.
3156
+ - Anchor table must have more data rows than the fragment.
3157
+ - Every (date, amount) pair in the fragment must be covered by the anchor.
3158
+ - Date must be non-empty; amount must look like currency.
3159
+ - Wrapped in try/except (safe no-op on any error).
3160
+ """
3161
+ if not text or "<table" not in text.lower():
3162
+ return text
3163
+ try:
3164
+ pattern = re.compile(r"<table[^>]*>.*?</table>", re.DOTALL | re.IGNORECASE)
3165
+ matches = list(pattern.finditer(text))
3166
+ if len(matches) < 2:
3167
+ return text
3168
+
3169
+ _DATE_NONEMPTY = re.compile(r"^\d{1,2}[/\-]\d{2}")
3170
+ _AMT_NONEMPTY = re.compile(r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})-?$")
3171
+
3172
+ def _da_key(cells):
3173
+ """Loose (date_norm, amount_norm) key — description ignored."""
3174
+ parts = [str(c or "").strip() for c in cells[:3]]
3175
+ while len(parts) < 3:
3176
+ parts.append("")
3177
+ date_s = re.sub(r"\s+", "", parts[0])
3178
+ amt_s = re.sub(r"[\s$,]", "", parts[2]).lower()
3179
+ amt_s = amt_s.replace("\u2212", "-").replace("\u2013", "-")
3180
+ return (date_s, amt_s)
3181
+
3182
+ # Parse all DA3 tables and collect their (date, amount) key lists.
3183
+ da3_info = []
3184
+ for m in matches:
3185
+ g = _parse_da3_grid_from_table_html(m.group(0))
3186
+ if not g or not _is_da3_header(g) or len(g) < 2:
3187
+ continue
3188
+ keys = []
3189
+ for row in g[1:]:
3190
+ cells = [str(c or "").strip() for c in row]
3191
+ if not any(cells):
3192
+ continue
3193
+ k = _da_key(cells)
3194
+ if _DATE_NONEMPTY.match(k[0]) and _AMT_NONEMPTY.match(k[1]):
3195
+ keys.append(k)
3196
+ da3_info.append({"match": m, "keys": keys, "n": len(keys)})
3197
+
3198
+ if len(da3_info) < 2:
3199
+ return text
3200
+
3201
+ drop_set = set()
3202
+ for i in range(1, len(da3_info)):
3203
+ ti = da3_info[i]
3204
+ if ti["n"] == 0 or ti["n"] > 5:
3205
+ continue
3206
+ if ti["match"].start() in drop_set:
3207
+ continue
3208
+ frag_keys = Counter(ti["keys"])
3209
+ for j in range(i - 1, -1, -1):
3210
+ tj = da3_info[j]
3211
+ if tj["match"].start() in drop_set:
3212
+ continue
3213
+ if tj["n"] <= ti["n"]:
3214
+ continue
3215
+ big_keys = Counter(tj["keys"])
3216
+ # Every (date, amount) pair in the fragment must be covered
3217
+ # by the anchor table (multiset subset check).
3218
+ if all(frag_keys[k] <= big_keys.get(k, 0) for k in frag_keys):
3219
+ drop_set.add(ti["match"].start())
3220
+ log.info(
3221
+ "Fix 30: dropped DA3 fragment (%d rows) as loose "
3222
+ "(date+amount) subset of larger table (%d rows)",
3223
+ ti["n"], tj["n"],
3224
+ )
3225
+ break
3226
+
3227
+ if not drop_set:
3228
+ return text
3229
+
3230
+ out = []
3231
+ last = 0
3232
+ for m in matches:
3233
+ out.append(text[last: m.start()])
3234
+ if m.start() not in drop_set:
3235
+ out.append(m.group(0))
3236
+ last = m.end()
3237
+ out.append(text[last:])
3238
+ return "".join(out)
3239
+
3240
+ except Exception as e:
3241
+ log.warning("_dedupe_da3_by_date_amount_loose failed: %s", e)
3242
+ return text
3243
+
3244
+
3245
  def _dedupe_subset_transaction_tables_html(text: str) -> str:
3246
  if not text or "<table" not in text.lower():
3247
  return text
 
3488
  # Fix 28: Strip credit-row prefix from debit DA3 table
3489
  # --------------------------
3490
 
 
3491
  _DEBIT_HEADING_KEYWORDS = re.compile(
3492
  r"\b(withdrawal|withdrawals|debit|debits|charges?|subtraction|subtractions|"
3493
  r"payments?\s+made|money\s+out|paid\s+out|electronic\s+debit)\b",
3494
  re.IGNORECASE,
3495
  )
 
3496
  _CREDIT_HEADING_KEYWORDS = re.compile(
3497
  r"\b(deposit|deposits|credit|credits|addition|additions|interest\s+paid|"
3498
  r"money\s+in|paid\s+in|incoming|received)\b",
3499
  re.IGNORECASE,
3500
  )
 
3501
  _HEADING_SCAN_WINDOW = 600
3502
 
3503
 
3504
  def _get_heading_before_table(text: str, table_start: int) -> str:
 
 
 
 
 
3505
  window = text[max(0, table_start - _HEADING_SCAN_WINDOW): table_start]
3506
  clean = re.sub(r"<[^>]+>", " ", window)
3507
  lines = [ln.strip() for ln in clean.splitlines() if ln.strip()]
 
3532
  if len(matches) < 2:
3533
  return text
3534
 
 
3535
  da3_tables = []
3536
  for m in matches:
3537
  g = _parse_da3_grid_from_table_html(m.group(0))
 
3551
  keys.add(_transaction_row_dedupe_key(cells))
3552
  return keys
3553
 
3554
+ drop_map = {}
 
 
 
3555
  for i in range(1, len(da3_tables)):
3556
  ti = da3_tables[i]
3557
  hi = ti["heading"].lower()
 
3570
  keys_j = _row_keys(gj)
3571
  if not keys_j:
3572
  continue
 
3573
  strip_count = 0
3574
  for row in data_i:
3575
  cells = [str(c or "").strip() for c in row]
 
3580
  break
3581
  if strip_count < 1:
3582
  continue
 
3583
  if len(data_i) - strip_count < 1:
3584
  continue
3585
  drop_map[ti["match"].start()] = strip_count
 
3587
  "Fix 28: stripped %d leading credit row(s) from debit table at offset %d",
3588
  strip_count, ti["match"].start(),
3589
  )
3590
+ break
3591
 
3592
  if not drop_map:
3593
  return text
3594
 
 
3595
  out_chunks = []
3596
  last = 0
3597
  for m in matches:
 
3619
  # Fix 29: Move misplaced debit heading to before its orphan DA3 table
3620
  # --------------------------
3621
 
 
3622
  _CENTER_DIV_HDR_RE = re.compile(
3623
  r'<div\s+align=["\']center["\']\s*>\s*(.*?)\s*</div>',
3624
  re.IGNORECASE | re.DOTALL,
3625
  )
3626
+ _HEADING_SCAN_FORWARD = 800
3627
 
3628
 
3629
  def _fix29_move_debit_heading_before_orphan_table(text: str) -> str:
3630
  """
3631
  Fix 29: When OCR places a debit/withdrawal section heading AFTER the withdrawals table
3632
  instead of before it, move the heading to its correct position (just before the table).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3633
  """
3634
  if not text or "<table" not in text.lower():
3635
  return text
 
3650
  orphan_start = orphan_m.start()
3651
  orphan_end = orphan_m.end()
3652
 
 
3653
  before_window = text[max(0, orphan_start - _HEADING_SCAN_WINDOW): orphan_start]
3654
  before_clean = re.sub(r"<[^>]+>", " ", before_window)
3655
  lines_before = [l.strip() for l in before_clean.splitlines() if l.strip()]
 
3659
  last_meaningful = l
3660
  break
3661
  if _DEBIT_HEADING_KEYWORDS.search(last_meaningful):
3662
+ continue
3663
 
 
3664
  big_before = text[max(0, orphan_start - 1200): orphan_start]
3665
  if not _CREDIT_HEADING_KEYWORDS.search(re.sub(r"<[^>]+>", " ", big_before)):
3666
  continue
3667
 
 
3668
  next_tbl_m = tbl_pat.search(text, orphan_end)
3669
 
 
3670
  dh_start = dh_end = None
3671
  for div_m in _CENTER_DIV_HDR_RE.finditer(text, orphan_end, orphan_end + _HEADING_SCAN_FORWARD):
3672
  div_clean = re.sub(r"<[^>]+>", "", div_m.group(1)).strip()
3673
  if _DEBIT_HEADING_KEYWORDS.search(div_clean) and not _CREDIT_HEADING_KEYWORDS.search(div_clean):
 
3674
  if next_tbl_m and next_tbl_m.start() < div_m.start():
3675
+ break
3676
  dh_start = div_m.start()
3677
  dh_end = div_m.end()
3678
  break
 
3685
  if not moves:
3686
  return text
3687
 
 
3688
  for orphan_start, dh_start, dh_end in reversed(moves):
3689
  orphan_m2 = next((m for m in tbl_pat.finditer(text) if m.start() == orphan_start), None)
3690
  if not orphan_m2:
 
3733
  stabilized = _split_ucb_deposits_electronic_sections_html(stabilized)
3734
  stabilized = _patch_da3_msft_g_ref_amounts_across_tables(stabilized)
3735
  stabilized = _dedupe_subset_transaction_tables_html(stabilized)
3736
+ stabilized = _dedupe_da3_by_date_amount_loose(stabilized) # Fix 30
3737
  stabilized = _drop_da3_misplaced_debit_after_deposit_heading_tables(stabilized)
3738
  stabilized = _strip_credits_prefix_from_debit_da3_table(stabilized) # Fix 28
3739
  stabilized = _fix29_move_debit_heading_before_orphan_table(stabilized) # Fix 29
 
3862
  stabilized = _split_ucb_deposits_electronic_sections_html(stabilized)
3863
  stabilized = _patch_da3_msft_g_ref_amounts_across_tables(stabilized)
3864
  stabilized = _dedupe_subset_transaction_tables_html(stabilized)
3865
+ stabilized = _dedupe_da3_by_date_amount_loose(stabilized) # Fix 30
3866
  stabilized = _drop_da3_misplaced_debit_after_deposit_heading_tables(stabilized)
3867
  stabilized = _strip_credits_prefix_from_debit_da3_table(stabilized) # Fix 28
3868
  stabilized = _fix29_move_debit_heading_before_orphan_table(stabilized) # Fix 29
 
3939
  merged = _split_ucb_deposits_electronic_sections_html(merged)
3940
  merged = _patch_da3_msft_g_ref_amounts_across_tables(merged)
3941
  merged = _dedupe_subset_transaction_tables_html(merged)
3942
+ merged = _dedupe_da3_by_date_amount_loose(merged) # Fix 30
3943
  merged = _drop_da3_misplaced_debit_after_deposit_heading_tables(merged)
3944
  merged = _strip_credits_prefix_from_debit_da3_table(merged) # Fix 28
3945
  merged = _fix29_move_debit_heading_before_orphan_table(merged) # Fix 29