rehan953 commited on
Commit
416ddf3
·
verified ·
1 Parent(s): 71ff542

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +218 -65
app.py CHANGED
@@ -108,11 +108,11 @@ Primary goals for reconcile rate:
108
  earlier larger DA3 table — catches OCR-truncated description duplicates such as
109
  the BoA service-fee page where a VIDDYOZE -1.11 fragment table appears after the
110
  full service-fee table with a longer description string.
111
- - Fix 32: Headless amount column PDFs often leave the rightmost money column without a header;
112
- OCR emits an empty <th>. Fill with \"Amount\" when the column is mostly currency.
113
- Second pass: collapse Date|Transaction Description|Additions|Subtractions + empty 4th
114
- when OCR splits \"Preauth Debit\" / \"Pre-Auth Credit\" into its own column before
115
- the merchant text (East West–style) merge back to Date|Description|Amount.
116
  """
117
 
118
  # Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
@@ -610,8 +610,25 @@ def _fill_headless_trailing_amount_headers(grid):
610
  if (amt_hits / total) < 0.45:
611
  continue
612
 
613
- # Right-align column is the numeric amount; PDF often omits a label for it.
614
- new_header[j] = "Amount"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
615
 
616
  grid[0] = new_header
617
  return grid
@@ -622,38 +639,84 @@ _MMDD_DATE_RE = re.compile(r"^\d{1,2}[-/]\d{2}(?:[-/]\d{2,4})?$")
622
  # Leading transaction-type cell when OCR splits category vs merchant (East West / similar).
623
  _STACKED_TXN_TYPE_RE = re.compile(
624
  r"^(?:Pre-Auth Credit|Pre-Auth Debit|Preauth Debit|"
625
- r"Withdrawal Trns|Deposit(?:\s|$)|Outgoing Wire|Incoming Wire|"
 
626
  r"Service Charge|ACH Debit|ACH Credit|ACH Payment|ACH PMT|"
627
  r"Wire Transfer|POS Purchase|ATM Withdrawal|Transfer Trns)\b",
628
  re.IGNORECASE | re.DOTALL,
629
  )
630
 
631
 
632
- def _collapse_four_col_stacked_transaction_type_to_three(grid):
633
  """
634
- Fix 32b: When the PDF is logically Date | one description | amount, OCR sometimes emits
635
- four columns: Date | short type (e.g. Preauth Debit) | merchant tail | amount, with an
636
- optional empty 4th header cell. Collapse to three columns so markdown matches the PDF row
637
- semantics. Requires every non-empty data row to match the pattern (safe no-op otherwise).
 
 
 
 
 
 
 
638
  """
639
  if not grid or len(grid) < 3:
640
  return grid
641
 
642
  header = [str(c or "").strip() for c in grid[0]]
643
- if len(header) != 4:
644
- return grid
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
645
 
646
- h0 = re.sub(r"\s+", " ", header[0]).strip().lower()
647
- h1 = re.sub(r"\s+", " ", header[1]).strip().lower()
648
- h2 = re.sub(r"\s+", " ", header[2]).strip().lower()
649
- h3 = str(header[3] or "").strip().lower()
650
 
651
- if h0 != "date":
652
  return grid
653
- if "transaction" not in h1 or "description" not in h1:
654
  return grid
655
  if not any(
656
- k in h2
657
  for k in (
658
  "addition",
659
  "subtraction",
@@ -664,58 +727,83 @@ def _collapse_four_col_stacked_transaction_type_to_three(grid):
664
  )
665
  ):
666
  return grid
667
- if h3 not in ("", "amount") and not any(
668
- k in h3 for k in ("addition", "subtraction", "credit", "debit")
669
  ):
670
  return grid
671
 
672
- def _row_matches_split_pattern(cells):
673
- while len(cells) < 4:
674
- cells = cells + [""]
675
- c0, c1, c2, c3 = (
676
- str(cells[0] or "").strip(),
677
- str(cells[1] or "").strip(),
678
- str(cells[2] or "").strip(),
679
- str(cells[3] or "").strip(),
680
- )
681
- if not c0 or not _MMDD_DATE_RE.match(c0):
682
- return False
683
- if not c1 or not _STACKED_TXN_TYPE_RE.match(c1):
684
- return False
685
- if not c2:
686
- return False
687
- if _is_amount_like(c2):
688
- return False
689
- if not _is_amount_like(c3):
690
- return False
691
- return True
692
-
693
  data_rows = [r for r in grid[1:] if any(str(c or "").strip() for c in r)]
694
- if len(data_rows) < 2:
695
  return grid
696
 
 
697
  for row in data_rows:
698
- cells = [str(c or "").strip() for c in row]
699
- if not _row_matches_split_pattern(cells):
700
- return grid
 
 
 
701
 
702
- out = [
703
- [
704
- header[0],
705
- header[1],
706
- header[2],
707
- ]
708
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
709
  for row in grid[1:]:
710
- cells = [str(c or "").strip() for c in row]
711
- while len(cells) < 4:
712
- cells.append("")
713
- if not any(cells):
714
- out.append(["", "", ""])
 
 
 
 
 
 
715
  continue
716
- d0, t1, tail, amt = cells[0], cells[1], cells[2], cells[3]
717
- desc = (t1 + " " + tail).strip()
718
- out.append([d0, desc, amt])
 
 
 
 
 
719
 
720
  return out
721
 
@@ -2808,7 +2896,7 @@ def normalize_html_tables(text: str) -> str:
2808
  grid = _merge_blank_header_text_columns(grid)
2809
 
2810
  grid = _fill_headless_trailing_amount_headers(grid)
2811
- grid = _collapse_four_col_stacked_transaction_type_to_three(grid)
2812
 
2813
  grid = _reconstruct_separator_rows(grid)
2814
 
@@ -4649,6 +4737,69 @@ def _strip_check_image_ocr_junk_html(text: str) -> str:
4649
  return text
4650
 
4651
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4652
  def stabilize_tables_and_text(page_md: str) -> str:
4653
  if not page_md:
4654
  return page_md
@@ -4672,6 +4823,7 @@ def stabilize_tables_and_text(page_md: str) -> str:
4672
  stabilized = _strip_orphan_continued_da3_flatline(stabilized)
4673
  stabilized = _strip_standalone_continued_banner_line(stabilized)
4674
  stabilized = normalize_html_tables(stabilized)
 
4675
  stabilized = _trim_adjacent_da3_suffix_duplicating_next_table_prefix(stabilized)
4676
  stabilized = _split_ucb_deposits_electronic_sections_html(stabilized)
4677
  stabilized = _patch_da3_msft_g_ref_amounts_across_tables(stabilized)
@@ -4804,6 +4956,7 @@ def run_ocr(uploaded_file):
4804
  stabilized = _strip_orphan_continued_da3_flatline(stabilized)
4805
  stabilized = _strip_standalone_continued_banner_line(stabilized)
4806
  stabilized = normalize_html_tables(stabilized)
 
4807
  stabilized = _trim_adjacent_da3_suffix_duplicating_next_table_prefix(stabilized)
4808
  stabilized = _split_ucb_deposits_electronic_sections_html(stabilized)
4809
  stabilized = _patch_da3_msft_g_ref_amounts_across_tables(stabilized)
 
108
  earlier larger DA3 table — catches OCR-truncated description duplicates such as
109
  the BoA service-fee page where a VIDDYOZE -1.11 fragment table appears after the
110
  full service-fee table with a longer description string.
111
+ - Fix 32: Headless trailing <th> over currency cells reuse the prior money header (Additions/
112
+ Subtractions) when present, else \"Amount\". Fix 32b aligns Date|Transaction Description|
113
+ Additions/Subtractions tables to the PDF: credits as Number|Date|Description|Additions
114
+ (blank Number when unused); debits as Date|Description|Subtractions (no Number); merges
115
+ OCR column splits. Adjacent duplicate HTML tables (same header + row multiset) are removed.
116
  """
117
 
118
  # Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
 
610
  if (amt_hits / total) < 0.45:
611
  continue
612
 
613
+ left = j - 1
614
+ while left >= 0 and not new_header[left]:
615
+ left -= 1
616
+ prev = (new_header[left] if left >= 0 else "").strip()
617
+ pl = prev.lower()
618
+ if any(
619
+ k in pl
620
+ for k in (
621
+ "addition",
622
+ "subtraction",
623
+ "credit",
624
+ "debit",
625
+ "deposit",
626
+ "withdrawal",
627
+ )
628
+ ):
629
+ new_header[j] = prev
630
+ else:
631
+ new_header[j] = "Amount"
632
 
633
  grid[0] = new_header
634
  return grid
 
639
  # Leading transaction-type cell when OCR splits category vs merchant (East West / similar).
640
  _STACKED_TXN_TYPE_RE = re.compile(
641
  r"^(?:Pre-Auth Credit|Pre-Auth Debit|Preauth Debit|"
642
+ r"Withdrawal Trns|Withdrawal(?:\s|$)|Deposit(?:\s|$)|Deposit Transfe|"
643
+ r"Outgoing Wire|Incoming Wire|"
644
  r"Service Charge|ACH Debit|ACH Credit|ACH Payment|ACH PMT|"
645
  r"Wire Transfer|POS Purchase|ATM Withdrawal|Transfer Trns)\b",
646
  re.IGNORECASE | re.DOTALL,
647
  )
648
 
649
 
650
+ def _normalize_date_desc_money_table_alignment(grid):
651
  """
652
+ Fix 32b: Align OCR tables with common bank PDF layouts.
653
+
654
+ * Credits (Additions): Number | Date | Transaction Description | Additions Number is often
655
+ blank in the PDF but must exist as a column; OCR may omit it or split description across
656
+ columns and park amounts under a bogus \"Amount\" header.
657
+ * Debits (Subtractions): PDF is usually Date | Description | Subtractions (no Number) —
658
+ when OCR uses the same 4-column split error, merge back to three columns.
659
+
660
+ Handles (a) Date | short type | merchant tail | amount, (b) Date | full desc | empty | amount,
661
+ (c) continuation rows with a blank date. Per-row merge (no single global mode) so mixed OCR
662
+ errors (e.g. wrapped merchant lines) still normalize.
663
  """
664
  if not grid or len(grid) < 3:
665
  return grid
666
 
667
  header = [str(c or "").strip() for c in grid[0]]
668
+ n = len(header)
669
+
670
+ def _pad_row(cells, width):
671
+ cells = [str(c or "").strip() for c in cells]
672
+ while len(cells) < width:
673
+ cells.append("")
674
+ return cells[:width]
675
+
676
+ h0n = re.sub(r"\s+", " ", header[0]).strip().lower()
677
+ h1n = re.sub(r"\s+", " ", header[1]).strip().lower() if n > 1 else ""
678
+ h2n = re.sub(r"\s+", " ", header[2]).strip().lower() if n > 2 else ""
679
+ h3n = re.sub(r"\s+", " ", header[3]).strip().lower() if n > 3 else ""
680
+
681
+ money_label = header[2].strip() if n > 2 else ""
682
+ want_number_col = "addition" in h2n
683
+
684
+ # --- 3-column Date | Transaction Description | Additions/Subtractions: prepend Number for credits only ---
685
+ if n == 3:
686
+ if h0n != "date" or "transaction" not in h1n or "description" not in h1n:
687
+ return grid
688
+ if not any(
689
+ k in h2n
690
+ for k in (
691
+ "addition",
692
+ "subtraction",
693
+ "credit",
694
+ "debit",
695
+ "deposit",
696
+ "withdrawal",
697
+ )
698
+ ):
699
+ return grid
700
+ if not want_number_col:
701
+ return grid
702
+ out = [["Number", header[0], header[1], money_label or "Additions"]]
703
+ for row in grid[1:]:
704
+ c = _pad_row(row, 3)
705
+ if not any(c):
706
+ out.append(["", "", "", ""])
707
+ continue
708
+ out.append(["", c[0], c[1], c[2]])
709
+ return out
710
 
711
+ if n != 4:
712
+ return grid
 
 
713
 
714
+ if h0n != "date":
715
  return grid
716
+ if "transaction" not in h1n or "description" not in h1n:
717
  return grid
718
  if not any(
719
+ k in h2n
720
  for k in (
721
  "addition",
722
  "subtraction",
 
727
  )
728
  ):
729
  return grid
730
+ if h3n not in ("", "amount") and not any(
731
+ k in h3n for k in ("addition", "subtraction", "credit", "debit")
732
  ):
733
  return grid
734
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
735
  data_rows = [r for r in grid[1:] if any(str(c or "").strip() for c in r)]
736
+ if not data_rows:
737
  return grid
738
 
739
+ has_date_led = False
740
  for row in data_rows:
741
+ c = _pad_row(row, 4)
742
+ if c[0] and _MMDD_DATE_RE.match(c[0]):
743
+ has_date_led = True
744
+ break
745
+ if not has_date_led:
746
+ return grid
747
 
748
+ def _merge_date_led_row(c0, c1, c2, c3):
749
+ split_ok = (
750
+ bool(c1)
751
+ and bool(_STACKED_TXN_TYPE_RE.match(c1))
752
+ and bool(c2)
753
+ and not _is_amount_like(c2)
754
+ and _is_amount_like(c3)
755
+ )
756
+ if split_ok:
757
+ return (c1 + " " + c2).strip(), c3
758
+ shift_ok = (
759
+ bool(c1)
760
+ and not _is_amount_like(c1)
761
+ and not (c2 or "").strip()
762
+ and _is_amount_like(c3)
763
+ )
764
+ if shift_ok:
765
+ return c1, c3
766
+ dep_ok = (
767
+ bool(c1)
768
+ and bool(_STACKED_TXN_TYPE_RE.match(c1))
769
+ and not (c2 or "").strip()
770
+ and _is_amount_like(c3)
771
+ )
772
+ if dep_ok:
773
+ return c1, c3
774
+ desc = ((c1 + " " + c2).strip() or c1).strip()
775
+ amt = c3 if _is_amount_like(c3) else ""
776
+ return desc, amt
777
+
778
+ ml = money_label or ("Additions" if want_number_col else "Subtractions")
779
+
780
+ if want_number_col:
781
+ out_h = ["Number", header[0], header[1], ml]
782
+ else:
783
+ out_h = [header[0], header[1], ml]
784
+
785
+ out = [out_h]
786
  for row in grid[1:]:
787
+ c = _pad_row(row, 4)
788
+ c0, c1, c2, c3 = c[0], c[1], c[2], c[3]
789
+ if not any(c):
790
+ out.append([""] * len(out_h))
791
+ continue
792
+ if c0 and _MMDD_DATE_RE.match(c0):
793
+ desc, amt = _merge_date_led_row(c0, c1, c2, c3)
794
+ if want_number_col:
795
+ out.append(["", c0, desc, amt])
796
+ else:
797
+ out.append([c0, desc, amt])
798
  continue
799
+ if want_number_col:
800
+ desc_t = (c1 + " " + c2).strip()
801
+ amt_t = c3 if _is_amount_like(c3) else ""
802
+ out.append(["", c0, desc_t, amt_t])
803
+ else:
804
+ desc_t = (c1 + " " + c2).strip()
805
+ amt_t = c3 if _is_amount_like(c3) else ""
806
+ out.append([c0, desc_t, amt_t])
807
 
808
  return out
809
 
 
2896
  grid = _merge_blank_header_text_columns(grid)
2897
 
2898
  grid = _fill_headless_trailing_amount_headers(grid)
2899
+ grid = _normalize_date_desc_money_table_alignment(grid)
2900
 
2901
  grid = _reconstruct_separator_rows(grid)
2902
 
 
4737
  return text
4738
 
4739
 
4740
+ def _dedupe_adjacent_duplicate_html_tables(text: str) -> str:
4741
+ """
4742
+ Drop consecutive <table> blocks that are identical after grid parse (same header + row multiset),
4743
+ with only whitespace between them. Catches OCR echo where the same credits/debits grid is emitted twice.
4744
+ """
4745
+ if not text or "<table" not in text.lower():
4746
+ return text
4747
+ tbl_pat = re.compile(r"<table[^>]*>.*?</table>", re.DOTALL | re.IGNORECASE)
4748
+ matches = list(tbl_pat.finditer(text))
4749
+ if len(matches) < 2:
4750
+ return text
4751
+
4752
+ def _fingerprint(html: str):
4753
+ try:
4754
+ p = TableGridParser()
4755
+ p.feed(html)
4756
+ g = _build_grid(p.rows)
4757
+ if not g or len(g) < 3:
4758
+ return None
4759
+ hdr = tuple(str(c or "").strip() for c in g[0])
4760
+ body = []
4761
+ for r in g[1:]:
4762
+ if not any(str(c or "").strip() for c in r):
4763
+ continue
4764
+ body.append(tuple(str(c or "").strip() for c in r))
4765
+ if len(body) < 2:
4766
+ return None
4767
+ return (hdr, tuple(sorted(Counter(body).items())))
4768
+ except Exception:
4769
+ return None
4770
+
4771
+ def _gap_ok(ma, mb) -> bool:
4772
+ gap = text[ma.end() : mb.start()]
4773
+ return not re.search(r"<table", gap, re.IGNORECASE)
4774
+
4775
+ drop = set()
4776
+ ref = 0
4777
+ for j in range(1, len(matches)):
4778
+ if j in drop:
4779
+ continue
4780
+ fp_r = _fingerprint(matches[ref].group(0))
4781
+ fp_j = _fingerprint(matches[j].group(0))
4782
+ if fp_r is not None and fp_j is not None and fp_r == fp_j and _gap_ok(
4783
+ matches[ref], matches[j]
4784
+ ):
4785
+ drop.add(j)
4786
+ else:
4787
+ ref = j
4788
+
4789
+ if not drop:
4790
+ return text
4791
+
4792
+ out: List[str] = []
4793
+ pos = 0
4794
+ for i, m in enumerate(matches):
4795
+ out.append(text[pos : m.start()])
4796
+ if i not in drop:
4797
+ out.append(m.group(0))
4798
+ pos = m.end()
4799
+ out.append(text[pos:])
4800
+ return "".join(out)
4801
+
4802
+
4803
  def stabilize_tables_and_text(page_md: str) -> str:
4804
  if not page_md:
4805
  return page_md
 
4823
  stabilized = _strip_orphan_continued_da3_flatline(stabilized)
4824
  stabilized = _strip_standalone_continued_banner_line(stabilized)
4825
  stabilized = normalize_html_tables(stabilized)
4826
+ stabilized = _dedupe_adjacent_duplicate_html_tables(stabilized)
4827
  stabilized = _trim_adjacent_da3_suffix_duplicating_next_table_prefix(stabilized)
4828
  stabilized = _split_ucb_deposits_electronic_sections_html(stabilized)
4829
  stabilized = _patch_da3_msft_g_ref_amounts_across_tables(stabilized)
 
4956
  stabilized = _strip_orphan_continued_da3_flatline(stabilized)
4957
  stabilized = _strip_standalone_continued_banner_line(stabilized)
4958
  stabilized = normalize_html_tables(stabilized)
4959
+ stabilized = _dedupe_adjacent_duplicate_html_tables(stabilized)
4960
  stabilized = _trim_adjacent_da3_suffix_duplicating_next_table_prefix(stabilized)
4961
  stabilized = _split_ucb_deposits_electronic_sections_html(stabilized)
4962
  stabilized = _patch_da3_msft_g_ref_amounts_across_tables(stabilized)