rehan953 commited on
Commit
37508d7
Β·
verified Β·
1 Parent(s): 3d43af7

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +235 -55
app.py CHANGED
@@ -12,6 +12,7 @@ Primary goals for reconcile rate:
12
  3) merge "blank header" columns that contain text into the left column (common when DESCRIPTION is split)
13
  4) clean common header artifacts (e.g. "DESCRIPTIONBeginning Balance", "BALANCE$3,447.10")
14
  5) recover fused first data row generically (two-signal guard: text fusion + money fusion must both fire)
 
15
  """
16
 
17
  # Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
@@ -498,54 +499,99 @@ def _merge_blank_header_text_columns(grid):
498
 
499
 
500
  # --------------------------
501
- # Generic fused-header recovery
502
  # --------------------------
503
 
504
- # Matches a standalone money value: optional currency symbol, optional sign,
505
- # digits with optional comma grouping, optional decimal cents.
506
- # Covers: $3,447.10 -1,234.56 $-500.00 1000 etc.
507
  _MONEY_RE = re.compile(
508
  r"^[\$£€Β₯]?\s*-?\s?\d{1,3}(?:,\d{3})*(?:\.\d{2})?$"
509
  )
510
 
511
- # All column-header keywords we recognise across any bank statement format.
512
- # Each entry is a raw regex fragment (no anchors) used to detect the keyword
513
- # at the START of a header cell.
514
  _HEADER_KW_PATTERNS = [
 
515
  r"DATE",
516
  r"POSTING\s+DATE",
517
  r"VALUE\s+DATE",
518
- r"TRANSACTION(?:\s+(?:ID|DATE|TYPE|NO|NUMBER))?",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
519
  r"DESCRIPTION",
520
  r"DETAILS?",
521
- r"PARTICULARS?",
522
  r"NARRATION",
523
  r"REMARKS?",
524
- r"REF(?:ERENCE)?(?:\s*(?:NO|NUM|NUMBER))?",
525
- r"CHEQUE(?:\s*(?:NO|NUMBER))?",
526
- r"AMOUNT",
527
  r"DEBIT",
 
 
 
 
 
 
 
528
  r"CREDIT",
529
- r"DEPOSIT",
530
- r"WITHDRAWAL",
 
 
 
 
 
 
 
 
531
  r"BALANCE",
 
532
  r"RUNNING\s+BALANCE",
 
533
  r"AVAILABLE\s+BALANCE",
 
 
534
  r"LEDGER\s+BALANCE",
 
 
 
 
 
535
  ]
536
 
537
- # Pre-compile: each pattern anchored at start, case-insensitive
538
  _HEADER_KW_RES = [
539
  re.compile(r"^(" + p + r")(.*)", re.IGNORECASE | re.DOTALL)
540
  for p in _HEADER_KW_PATTERNS
541
  ]
542
 
543
- # A set version for quick "is this entire string a known keyword?" check
544
  _HEADER_KW_EXACT_RE = re.compile(
545
  r"^(?:" + r"|".join(_HEADER_KW_PATTERNS) + r")$",
546
  re.IGNORECASE,
547
  )
548
 
 
 
 
 
 
 
 
 
549
 
550
  def _split_keyword_remainder(cell_text: str):
551
  """
@@ -567,20 +613,13 @@ def _extract_fused_header_artifacts(header_row):
567
  Generic detector for the OCR artifact where the first data row of a table
568
  gets fused into the header cells during OCR.
569
 
570
- The artifact pattern:
571
- - At least one header cell contains a known column keyword PLUS free text
572
- that is NOT itself a keyword β†’ "text-fused" column
573
- e.g. "DESCRIPTIONBeginning Balance"
574
- "NARRATIONOpening Balance"
575
- "DETAILSForward Balance"
576
- - At least one OTHER header cell contains a known column keyword PLUS a
577
- money amount β†’ "money-fused" column
578
- e.g. "BALANCE$3,447.10"
579
- "AMOUNT 5,000.00"
580
-
581
- Both signals must fire simultaneously (two-signal guard). If only one
582
- fires the header is left completely unchanged and None is returned β€”
583
- this prevents false positives on clean PDFs from any bank.
584
 
585
  Returns:
586
  (cleaned_header : list[str], recovered_row : list[str] | None)
@@ -592,8 +631,8 @@ def _extract_fused_header_artifacts(header_row):
592
  cleaned = list(header_row)
593
  recovered = [""] * ncols
594
 
595
- text_fused = [] # column indices where a descriptive label was fused
596
- money_fused = [] # column indices where a money amount was fused
597
 
598
  for idx, cell in enumerate(header_row):
599
  cell_s = str(cell or "").strip()
@@ -603,42 +642,30 @@ def _extract_fused_header_artifacts(header_row):
603
  keyword, remainder = _split_keyword_remainder(cell_s)
604
 
605
  if not remainder:
606
- # Nothing fused into this cell β€” leave it alone.
607
  continue
608
 
609
- # Determine what kind of remainder this is.
610
  remainder_no_space = remainder.replace(" ", "")
611
 
612
  if _MONEY_RE.match(remainder_no_space):
613
- # Money amount fused into this header cell.
614
  cleaned[idx] = keyword
615
  recovered[idx] = remainder
616
  money_fused.append(idx)
617
 
618
  elif not _HEADER_KW_EXACT_RE.match(remainder.split()[0] if remainder.split() else ""):
619
- # Free descriptive text fused into this header cell.
620
- # (We exclude the case where the remainder is itself a keyword,
621
- # which would just be a two-word column name like "POSTING DATE"
622
- # partially matched β€” those are handled by the longer patterns.)
623
  cleaned[idx] = keyword
624
  recovered[idx] = remainder
625
  text_fused.append(idx)
626
 
627
- # Two-signal guard: only inject when BOTH kinds of fusion are present.
628
  if text_fused and money_fused:
629
  return cleaned, recovered
630
 
631
- # One or zero signals β€” do not touch anything.
632
  return list(header_row), None
633
 
634
 
635
  def _clean_header_artifacts(grid):
636
  """
637
- Entry point called from normalize_html_tables.
638
- Applies _extract_fused_header_artifacts to the header row of the grid.
639
-
640
  Returns (grid, recovered_row | None).
641
- The caller inserts recovered_row at position [1] when it is not None.
642
  """
643
  if not grid or not grid[0]:
644
  return grid, None
@@ -648,14 +675,168 @@ def _clean_header_artifacts(grid):
648
  return grid, recovered_row
649
 
650
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
651
  def normalize_html_tables(text: str) -> str:
652
  """
653
  For every <table>...</table>:
654
- - parse to grid (expand colspan/rowspan)
655
- - drop truly-empty columns
656
- - merge blank-header text columns into the left column
657
- - clean header artifacts and recover fused first data row (generic)
658
- - emit normalized <table> HTML
 
 
659
  """
660
  if not text or "<table" not in text.lower():
661
  return text
@@ -674,15 +855,14 @@ def normalize_html_tables(text: str) -> str:
674
  grid = _drop_truly_empty_columns(grid)
675
  grid = _merge_blank_header_text_columns(grid)
676
 
677
- # Generic fused-header artifact recovery.
678
- # recovered_row is only non-None when the two-signal guard passes
679
- # (both a text-fused cell and a money-fused cell were detected).
680
- # On all clean PDFs this returns None β†’ no side effects.
681
  grid, recovered_row = _clean_header_artifacts(grid)
682
-
683
  if recovered_row is not None:
684
  grid.insert(1, recovered_row)
685
 
 
 
 
686
  out.append(_grid_to_html(grid) if grid else table_html)
687
  except Exception:
688
  out.append(table_html)
 
12
  3) merge "blank header" columns that contain text into the left column (common when DESCRIPTION is split)
13
  4) clean common header artifacts (e.g. "DESCRIPTIONBeginning Balance", "BALANCE$3,447.10")
14
  5) recover fused first data row generically (two-signal guard: text fusion + money fusion must both fire)
15
+ 6) promote misplaced header row: when real column headers land in a data row, restructure the grid
16
  """
17
 
18
  # Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
 
499
 
500
 
501
  # --------------------------
502
+ # Shared keyword definitions
503
  # --------------------------
504
 
505
+ # Matches a standalone money value across currencies.
506
+ # Covers: $3,447.10 -1,234.56 Β£500.00 1000 etc.
 
507
  _MONEY_RE = re.compile(
508
  r"^[\$£€Β₯]?\s*-?\s?\d{1,3}(?:,\d{3})*(?:\.\d{2})?$"
509
  )
510
 
511
+ # All column-header keywords recognised across any bank statement format.
 
 
512
  _HEADER_KW_PATTERNS = [
513
+ # Date variants
514
  r"DATE",
515
  r"POSTING\s+DATE",
516
  r"VALUE\s+DATE",
517
+ r"TXN\s+DATE",
518
+ r"TRANSACTION\s+DATE",
519
+ r"ENTRY\s+DATE",
520
+ r"EFFECTIVE\s+DATE",
521
+
522
+ # Transaction ID / reference
523
+ r"TRANSACTION(?:\s+(?:ID|TYPE|NO|NUMBER))?",
524
+ r"TXN(?:\s+(?:ID|NO|TYPE))?",
525
+ r"REF(?:ERENCE)?(?:\s*(?:NO|NUM|NUMBER))?",
526
+ r"CHEQUE(?:\s*(?:NO|NUMBER))?",
527
+ r"CHQ(?:\s*(?:NO|NUMBER))?",
528
+ r"VOUCHER(?:\s*(?:NO|NUMBER))?",
529
+ r"SR\.?\s*NO\.?",
530
+ r"SERIAL(?:\s*(?:NO|NUMBER))?",
531
+
532
+ # Description variants
533
  r"DESCRIPTION",
534
  r"DETAILS?",
535
+ r"PARTICULARS?(?:\s+OF\s+TRANSACTION)?",
536
  r"NARRATION",
537
  r"REMARKS?",
538
+ r"NOTES?",
539
+
540
+ # Debit variants
541
  r"DEBIT",
542
+ r"DEBITS?",
543
+ r"DR\.?",
544
+ r"WITHDRAWALS?",
545
+ r"PAID\s+OUT",
546
+ r"MONEY\s+OUT",
547
+
548
+ # Credit variants
549
  r"CREDIT",
550
+ r"CREDITS?",
551
+ r"CR\.?",
552
+ r"DEPOSITS?",
553
+ r"PAID\s+IN",
554
+ r"MONEY\s+IN",
555
+
556
+ # Amount
557
+ r"AMOUNT",
558
+
559
+ # Balance variants
560
  r"BALANCE",
561
+ r"BAL\.?",
562
  r"RUNNING\s+BALANCE",
563
+ r"RUNNING\s+BAL\.?",
564
  r"AVAILABLE\s+BALANCE",
565
+ r"AVAILABLE\s+BAL\.?",
566
+ r"AVAIL\.?\s+BAL\.?",
567
  r"LEDGER\s+BALANCE",
568
+ r"LEDGER\s+BAL\.?",
569
+ r"CLOSING\s+BALANCE",
570
+ r"CLOSING\s+BAL\.?",
571
+ r"OPENING\s+BALANCE",
572
+ r"OPENING\s+BAL\.?",
573
  ]
574
 
575
+ # Pre-compiled: each pattern anchored at start, case-insensitive
576
  _HEADER_KW_RES = [
577
  re.compile(r"^(" + p + r")(.*)", re.IGNORECASE | re.DOTALL)
578
  for p in _HEADER_KW_PATTERNS
579
  ]
580
 
581
+ # Quick exact-match check: "is this entire string a known keyword?"
582
  _HEADER_KW_EXACT_RE = re.compile(
583
  r"^(?:" + r"|".join(_HEADER_KW_PATTERNS) + r")$",
584
  re.IGNORECASE,
585
  )
586
 
587
+ # Minimum fraction of cells in a row that must be pure keywords for that row
588
+ # to be considered a misplaced header row.
589
+ _HEADER_ROW_KEYWORD_THRESHOLD = 0.5
590
+
591
+
592
+ # --------------------------
593
+ # Fix 1: Fused-header recovery
594
+ # --------------------------
595
 
596
  def _split_keyword_remainder(cell_text: str):
597
  """
 
613
  Generic detector for the OCR artifact where the first data row of a table
614
  gets fused into the header cells during OCR.
615
 
616
+ The artifact pattern (BOTH signals must fire simultaneously):
617
+ - Signal 1 β€” text-fused: a header cell contains KEYWORD + free descriptive text
618
+ e.g. "DESCRIPTIONBeginning Balance" "NARRATIONOpening Balance"
619
+ - Signal 2 β€” money-fused: a DIFFERENT header cell contains KEYWORD + money amount
620
+ e.g. "BALANCE$3,447.10" "AMOUNT 5,000.00"
621
+
622
+ Two-signal guard prevents false positives on clean PDFs from any bank.
 
 
 
 
 
 
 
623
 
624
  Returns:
625
  (cleaned_header : list[str], recovered_row : list[str] | None)
 
631
  cleaned = list(header_row)
632
  recovered = [""] * ncols
633
 
634
+ text_fused = []
635
+ money_fused = []
636
 
637
  for idx, cell in enumerate(header_row):
638
  cell_s = str(cell or "").strip()
 
642
  keyword, remainder = _split_keyword_remainder(cell_s)
643
 
644
  if not remainder:
 
645
  continue
646
 
 
647
  remainder_no_space = remainder.replace(" ", "")
648
 
649
  if _MONEY_RE.match(remainder_no_space):
 
650
  cleaned[idx] = keyword
651
  recovered[idx] = remainder
652
  money_fused.append(idx)
653
 
654
  elif not _HEADER_KW_EXACT_RE.match(remainder.split()[0] if remainder.split() else ""):
 
 
 
 
655
  cleaned[idx] = keyword
656
  recovered[idx] = remainder
657
  text_fused.append(idx)
658
 
 
659
  if text_fused and money_fused:
660
  return cleaned, recovered
661
 
 
662
  return list(header_row), None
663
 
664
 
665
  def _clean_header_artifacts(grid):
666
  """
667
+ Entry point for Fix 1 called from normalize_html_tables.
 
 
668
  Returns (grid, recovered_row | None).
 
669
  """
670
  if not grid or not grid[0]:
671
  return grid, None
 
675
  return grid, recovered_row
676
 
677
 
678
+ # --------------------------
679
+ # Fix 2: Misplaced header row promotion
680
+ # --------------------------
681
+
682
+ def _row_keyword_score(row):
683
+ """
684
+ Return the fraction of non-empty cells in this row that are pure header
685
+ keywords (e.g. DATE, DESCRIPTION, DEBIT, CREDIT, BALANCE).
686
+ Also handles cells like "DATE DESCRIPTION" where two keywords are space-joined
687
+ into one cell β€” these count as a keyword cell too.
688
+ """
689
+ non_empty = [str(c or "").strip() for c in row if str(c or "").strip()]
690
+ if not non_empty:
691
+ return 0.0
692
+
693
+ keyword_hits = 0
694
+ for cell in non_empty:
695
+ # Check if the whole cell is a known keyword
696
+ if _HEADER_KW_EXACT_RE.match(cell):
697
+ keyword_hits += 1
698
+ continue
699
+ # Check if the cell is two or more space-separated keywords
700
+ # e.g. "DATE DESCRIPTION"
701
+ parts = cell.split()
702
+ if all(_HEADER_KW_EXACT_RE.match(p) for p in parts) and len(parts) > 1:
703
+ keyword_hits += 1
704
+ continue
705
+
706
+ return keyword_hits / len(non_empty)
707
+
708
+
709
+ def _split_merged_keyword_cell(cell_text: str, target_ncols: int, col_idx: int, total_cols: int):
710
+ """
711
+ When a data cell contains multiple space-separated keywords (e.g. "DATE DESCRIPTION"),
712
+ split it into individual keyword tokens. Returns a list of strings to replace
713
+ the single cell β€” padded/trimmed to fit the available column space.
714
+ """
715
+ parts = cell_text.strip().split()
716
+ # Only split if every part is a keyword
717
+ if len(parts) > 1 and all(_HEADER_KW_EXACT_RE.match(p) for p in parts):
718
+ return parts
719
+ return [cell_text]
720
+
721
+
722
+ def _promote_misplaced_header_row(grid):
723
+ """
724
+ Detects and fixes the OCR artifact where the real column headers (DATE,
725
+ DESCRIPTION, DEBIT, CREDIT, BALANCE …) land in a <td> data row instead
726
+ of the <th> header row.
727
+
728
+ This happens when a bank PDF wraps the transaction section in a larger
729
+ table that also contains section titles (e.g. "ACCOUNT ACTIVITY" and
730
+ "Transactions by Date (continued)"), so the OCR emits:
731
+
732
+ <th>ACCOUNT ACTIVITY</th> ← wrong header (section title)
733
+ <td>Transactions by Date …</td> ← subtitle row
734
+ <td>DATE DESCRIPTION</td> <td>DEBIT</td> <td>CREDIT</td> <td>BALANCE</td>
735
+ <td>01/05 …</td> … ← real data
736
+
737
+ The fix:
738
+ 1. Scan every data row (rows 1+) for a row whose cells are predominantly
739
+ pure header keywords.
740
+ 2. When found, promote that row to be the new header (row 0) and discard
741
+ all rows above it (they are section titles / subtitles).
742
+ 3. If any cell in the promoted row contains multiple space-joined keywords
743
+ (e.g. "DATE DESCRIPTION"), expand it into separate columns and
744
+ re-align the data rows below accordingly.
745
+
746
+ Guard conditions (no-op if not met β€” safe for all other PDFs):
747
+ - The current header row must NOT already look like real column headers
748
+ (keyword score < threshold).
749
+ - At least one data row must have keyword score >= threshold.
750
+ - The candidate row must appear within the first 5 data rows (it is
751
+ always near the top of the table, never buried mid-table).
752
+ """
753
+ if not grid or len(grid) < 2:
754
+ return grid
755
+
756
+ current_header = grid[0]
757
+ current_header_score = _row_keyword_score(current_header)
758
+
759
+ # If the current header already looks like real column headers, do nothing.
760
+ if current_header_score >= _HEADER_ROW_KEYWORD_THRESHOLD:
761
+ return grid
762
+
763
+ # Search the first few data rows for a candidate header row.
764
+ candidate_idx = None
765
+ candidate_score = 0.0
766
+ search_limit = min(len(grid), 6) # rows 1..5
767
+
768
+ for i in range(1, search_limit):
769
+ score = _row_keyword_score(grid[i])
770
+ if score >= _HEADER_ROW_KEYWORD_THRESHOLD and score > candidate_score:
771
+ candidate_score = score
772
+ candidate_idx = i
773
+
774
+ if candidate_idx is None:
775
+ return grid
776
+
777
+ # --- Promote the candidate row ---
778
+ candidate_row = grid[candidate_idx]
779
+
780
+ # Expand any merged keyword cells (e.g. "DATE DESCRIPTION" β†’ ["DATE", "DESCRIPTION"])
781
+ expanded_header = []
782
+ for cell in candidate_row:
783
+ cell_s = str(cell or "").strip()
784
+ parts = cell_s.split()
785
+ if len(parts) > 1 and all(_HEADER_KW_EXACT_RE.match(p) for p in parts):
786
+ expanded_header.extend(parts)
787
+ else:
788
+ expanded_header.append(cell_s)
789
+
790
+ new_ncols = len(expanded_header)
791
+ old_ncols = len(candidate_row)
792
+ col_expansion = new_ncols - old_ncols # how many extra columns were added
793
+
794
+ # Re-align data rows below the candidate (expand first cell if needed)
795
+ new_data_rows = []
796
+ for row in grid[candidate_idx + 1:]:
797
+ if col_expansion > 0 and row:
798
+ # The first cell of a data row often contains DATE + start of DESCRIPTION
799
+ # fused together (e.g. "01/05 TD ZELLE RECEIVED,...").
800
+ # Split it into [date_part, description_part] to fill the expanded columns.
801
+ first_cell = str(row[0] or "").strip()
802
+ date_match = re.match(r"^(\d{1,2}/\d{1,2})\s+(.*)", first_cell, re.DOTALL)
803
+ if date_match and col_expansion == 1:
804
+ new_row = [date_match.group(1), date_match.group(2).strip()] + list(row[1:])
805
+ else:
806
+ # Can't split cleanly β€” pad with empty cells to maintain column count
807
+ new_row = list(row) + [""] * col_expansion
808
+ new_data_rows.append(new_row)
809
+ else:
810
+ new_data_rows.append(list(row))
811
+
812
+ # Normalise all rows to new_ncols width
813
+ def pad(row, n):
814
+ r = list(row)
815
+ while len(r) < n:
816
+ r.append("")
817
+ return r[:n]
818
+
819
+ new_grid = [pad(expanded_header, new_ncols)]
820
+ for row in new_data_rows:
821
+ new_grid.append(pad(row, new_ncols))
822
+
823
+ return new_grid
824
+
825
+
826
+ # --------------------------
827
+ # Normalizer entry point
828
+ # --------------------------
829
+
830
  def normalize_html_tables(text: str) -> str:
831
  """
832
  For every <table>...</table>:
833
+ 1. Parse to grid (expand colspan/rowspan).
834
+ 2. Drop truly-empty columns.
835
+ 3. Merge blank-header text columns into the left column.
836
+ 4. Fix 1 β€” clean fused header artifacts and recover the lost first data row.
837
+ 5. Fix 2 β€” promote a misplaced header row when real column headers landed
838
+ in a <td> data row instead of the <th> header row.
839
+ 6. Emit normalised <table> HTML.
840
  """
841
  if not text or "<table" not in text.lower():
842
  return text
 
855
  grid = _drop_truly_empty_columns(grid)
856
  grid = _merge_blank_header_text_columns(grid)
857
 
858
+ # Fix 1: fused-header artifact (two-signal guard β€” safe on clean PDFs)
 
 
 
859
  grid, recovered_row = _clean_header_artifacts(grid)
 
860
  if recovered_row is not None:
861
  grid.insert(1, recovered_row)
862
 
863
+ # Fix 2: misplaced header row promotion (keyword-score guard β€” safe on clean PDFs)
864
+ grid = _promote_misplaced_header_row(grid)
865
+
866
  out.append(_grid_to_html(grid) if grid else table_html)
867
  except Exception:
868
  out.append(table_html)