rehan953 commited on
Commit
ad458b5
·
verified ·
1 Parent(s): 11bf437

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +267 -0
app.py CHANGED
@@ -119,6 +119,12 @@ Primary goals for reconcile rate:
119
  five columns using trailing amount in the description cell and TD-specific debit/credit
120
  routing. Fires on \"continued\" in-table text and/or TD body cues (TD ZELLE, LENDINGCLUB,
121
  etc.). Safe no-op elsewhere.
 
 
 
 
 
 
122
  - Fix MD-1: Markdown fidelity — (a) collapse adjacent HTML tables that are the same register
123
  (identical multiset of date + debit + credit + balance per row, descriptions may differ);
124
  keep the table with richer description text so output matches the PDF once. (b) Common OCR
@@ -2972,6 +2978,187 @@ def _fix_three_col_date_desc_amount_left_shift(grid):
2972
  return out if changed else grid
2973
 
2974
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2975
  def _normalize_fused_date_posted_amount_table(grid):
2976
  if not grid or len(grid) < 2:
2977
  return grid
@@ -3441,6 +3628,8 @@ def normalize_html_tables(text: str) -> str:
3441
  grid = _normalize_double_desc_amount_header_table(grid)
3442
  grid = _normalize_ucb_three_col_beginning_balance_fusion(grid)
3443
  grid = _fix_three_col_date_desc_amount_left_shift(grid)
 
 
3444
 
3445
  grid = _drop_truly_empty_columns(grid)
3446
  grid = _merge_blank_header_text_columns(grid)
@@ -3482,6 +3671,7 @@ def normalize_html_tables(text: str) -> str:
3482
 
3483
  grid = _fix_fused_keyvalue_rows(grid)
3484
  grid = _dedupe_duplicate_rows_in_da3_table(grid)
 
3485
  split_tables = _split_combined_summary_daily_balance_grid(grid)
3486
  if split_tables:
3487
  out.append("\n\n".join(_grid_to_html(g) for g in split_tables if g))
@@ -3657,6 +3847,22 @@ def _is_da3_header(grid):
3657
  )
3658
 
3659
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3660
  _DA3_STRICT_AMOUNT = re.compile(r"^-?\$?\d{1,3}(?:,\d{3})*\.\d{2}-?$")
3661
  _DA3_MONEY_TOKEN = re.compile(
3662
  r"(?<![\d,])(-?\$?\d{1,3}(?:,\d{3})*\.\d{2}|-?\$?\d{2,6}\.\d{2})(?!\d)"
@@ -3774,6 +3980,67 @@ def _dedupe_duplicate_rows_in_da3_table(grid):
3774
  return out
3775
 
3776
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3777
  def _row_text_full_ucb(grid, r: int) -> str:
3778
  if not grid or r < 0 or r >= len(grid):
3779
  return ""
 
119
  five columns using trailing amount in the description cell and TD-specific debit/credit
120
  routing. Fires on \"continued\" in-table text and/or TD body cues (TD ZELLE, LENDINGCLUB,
121
  etc.). Safe no-op elsewhere.
122
+ - Fix SE-CONT: CO-OP / credit-union account banner (BUSINESS - N [CONT]) often emits three columns
123
+ | Amount | Amount | (date+desc | amount | balance) or a four-column variant (date+desc
124
+ fragment | desc tail | amount | balance). CONT may be omitted by OCR. Downstream mis-maps
125
+ amount vs balance. Normalize to Date | Transaction Description | Transaction Amount | Balance.
126
+ - Fix DA4-dedupe: In 4-column Date|…|Amount|Balance registers, consecutive rows identical on all four
127
+ cells (same running balance) are OCR stutters — collapse to one; honour text-layer counts when present.
128
  - Fix MD-1: Markdown fidelity — (a) collapse adjacent HTML tables that are the same register
129
  (identical multiset of date + debit + credit + balance per row, descriptions may differ);
130
  keep the table with richer description text so output matches the PDF once. (b) Common OCR
 
2978
  return out if changed else grid
2979
 
2980
 
2981
+ # CO-OP account banner: digits + optional CONT (OCR often truncates "CONT")
2982
+ _BUSINESS_COOP_BANNER_FIRST_CELL_RE = re.compile(
2983
+ r"^BUSINESS\s*-\s*\d+(?:\s*CONT)?\s*$",
2984
+ re.IGNORECASE,
2985
+ )
2986
+
2987
+
2988
+ def _first_cell_is_business_coop_banner(s: str) -> bool:
2989
+ t = str(s or "").strip()
2990
+ return bool(t and _BUSINESS_COOP_BANNER_FIRST_CELL_RE.match(t))
2991
+
2992
+
2993
+ def _fix_business_coop_cont_three_col_register_grid(grid):
2994
+ """
2995
+ Fix SE-CONT: CO-OP network continuation (BUSINESS-…CONT | Amount | Amount) where col1 is
2996
+ MM/DD + description, col2 is transaction amount, col3 is running balance. Downstream
2997
+ extraction treats the two money columns like Amount/Balance in a different column order,
2998
+ inflating debits/credits. Expand to the same 4-column register as the first page.
2999
+ """
3000
+ if not grid or len(grid) < 3:
3001
+ return grid
3002
+
3003
+ def _tight_cell(s: str) -> str:
3004
+ return re.sub(r"\s+", "", str(s or "").strip().lower())
3005
+
3006
+ hdr_idx = None
3007
+ scan = min(5, len(grid))
3008
+ for ri in range(scan):
3009
+ cells = [str(c or "").strip() for c in grid[ri]]
3010
+ while len(cells) < 3:
3011
+ cells.append("")
3012
+ if len(cells) < 3:
3013
+ continue
3014
+ if _first_cell_is_business_coop_banner(cells[0]) and _tight_cell(
3015
+ cells[1]
3016
+ ) == "amount" and _tight_cell(cells[2]) == "amount":
3017
+ hdr_idx = ri
3018
+ break
3019
+ if hdr_idx is None:
3020
+ return grid
3021
+
3022
+ date_desc_re = re.compile(r"^(\d{1,2}/\d{1,2})\s+(.+)$", re.DOTALL)
3023
+ money_re = re.compile(r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})-?$")
3024
+
3025
+ def _is_date_txn_desc_subheader(cells: List[str]) -> bool:
3026
+ c0 = str(cells[0] or "").strip().lower()
3027
+ return (
3028
+ "date" in c0
3029
+ and "transaction" in c0
3030
+ and "description" in c0
3031
+ and len(c0) < 80
3032
+ )
3033
+
3034
+ data_start = hdr_idx + 1
3035
+ if data_start < len(grid):
3036
+ r1 = [str(c or "").strip() for c in grid[data_start]]
3037
+ while len(r1) < 3:
3038
+ r1.append("")
3039
+ if _is_date_txn_desc_subheader(r1):
3040
+ data_start += 1
3041
+
3042
+ new_grid = [
3043
+ ["Date", "Transaction Description", "Transaction Amount", "Balance"],
3044
+ ]
3045
+ changed = False
3046
+ for row in grid[data_start:]:
3047
+ cells = [str(c or "").strip() for c in row]
3048
+ while len(cells) < 3:
3049
+ cells.append("")
3050
+ c0, c1, c2 = cells[0], cells[1], cells[2]
3051
+ if not c0:
3052
+ continue
3053
+ m = date_desc_re.match(c0.strip())
3054
+ if not m:
3055
+ continue
3056
+ amt = (c1 or "").strip()
3057
+ bal = (c2 or "").strip()
3058
+ if not amt or not bal:
3059
+ continue
3060
+ if not money_re.match(amt) or not money_re.match(bal):
3061
+ continue
3062
+ new_grid.append(
3063
+ [
3064
+ m.group(1).strip(),
3065
+ (m.group(2) or "").strip(),
3066
+ amt,
3067
+ bal,
3068
+ ]
3069
+ )
3070
+ changed = True
3071
+
3072
+ if not changed or len(new_grid) < 2:
3073
+ return grid
3074
+ return new_grid
3075
+
3076
+
3077
+ def _fix_business_coop_cont_four_col_register_grid(grid):
3078
+ """
3079
+ Fix SE-CONT (4-col): Same CO-OP continuation banner as 3-col, but OCR uses four columns:
3080
+ BUSINESS-…CONT | (blank) | Amount | Amount, then subheader, then rows where col0 is
3081
+ MM/DD + description start, col1 is description tail (e.g. SEATTLE), col2 amount, col3 balance.
3082
+ Without merging, downstream maps columns incorrectly. Output canonical 4-column register.
3083
+ """
3084
+ if not grid or len(grid) < 3:
3085
+ return grid
3086
+
3087
+ ncols = max(len(r) for r in grid if isinstance(r, list))
3088
+ if ncols < 4:
3089
+ return grid
3090
+
3091
+ def _tight_cell(s: str) -> str:
3092
+ return re.sub(r"\s+", "", str(s or "").strip().lower())
3093
+
3094
+ hdr_idx = None
3095
+ for ri in range(min(6, len(grid))):
3096
+ cells = [str(c or "").strip() for c in grid[ri]]
3097
+ while len(cells) < ncols:
3098
+ cells.append("")
3099
+ if len(cells) < 4:
3100
+ continue
3101
+ if not _first_cell_is_business_coop_banner(cells[0]):
3102
+ continue
3103
+ if _tight_cell(cells[-2]) != "amount" or _tight_cell(cells[-1]) != "amount":
3104
+ continue
3105
+ hdr_idx = ri
3106
+ break
3107
+ if hdr_idx is None:
3108
+ return grid
3109
+
3110
+ date_desc_re = re.compile(r"^(\d{1,2}/\d{1,2})\s+(.+)$", re.DOTALL)
3111
+ money_re = re.compile(r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})-?$")
3112
+
3113
+ def _skip_subheader(cells: List[str]) -> bool:
3114
+ c0 = str(cells[0] or "").strip()
3115
+ cl = c0.lower()
3116
+ if "date" not in cl or "transaction" not in cl or "description" not in cl:
3117
+ return False
3118
+ return not bool(date_desc_re.match(c0.strip()))
3119
+
3120
+ data_start = hdr_idx + 1
3121
+ while data_start < len(grid):
3122
+ cells = [str(c or "").strip() for c in grid[data_start]]
3123
+ while len(cells) < ncols:
3124
+ cells.append("")
3125
+ if _skip_subheader(cells):
3126
+ data_start += 1
3127
+ continue
3128
+ break
3129
+
3130
+ new_grid = [
3131
+ ["Date", "Transaction Description", "Transaction Amount", "Balance"],
3132
+ ]
3133
+ changed = False
3134
+ for row in grid[data_start:]:
3135
+ cells = [str(c or "").strip() for c in row]
3136
+ while len(cells) < ncols:
3137
+ cells.append("")
3138
+ c0 = cells[0] if cells else ""
3139
+ c1 = cells[1] if len(cells) > 1 else ""
3140
+ c2 = cells[2] if len(cells) > 2 else ""
3141
+ c3 = cells[3] if len(cells) > 3 else ""
3142
+ if not c0:
3143
+ continue
3144
+ m = date_desc_re.match(c0.strip())
3145
+ if not m:
3146
+ continue
3147
+ amt = (c2 or "").strip()
3148
+ bal = (c3 or "").strip()
3149
+ if not amt or not bal:
3150
+ continue
3151
+ if not money_re.match(amt) or not money_re.match(bal):
3152
+ continue
3153
+ desc = ((m.group(2) or "").strip() + " " + (c1 or "").strip()).strip()
3154
+ new_grid.append([m.group(1).strip(), desc, amt, bal])
3155
+ changed = True
3156
+
3157
+ if not changed or len(new_grid) < 2:
3158
+ return grid
3159
+ return new_grid
3160
+
3161
+
3162
  def _normalize_fused_date_posted_amount_table(grid):
3163
  if not grid or len(grid) < 2:
3164
  return grid
 
3628
  grid = _normalize_double_desc_amount_header_table(grid)
3629
  grid = _normalize_ucb_three_col_beginning_balance_fusion(grid)
3630
  grid = _fix_three_col_date_desc_amount_left_shift(grid)
3631
+ grid = _fix_business_coop_cont_three_col_register_grid(grid)
3632
+ grid = _fix_business_coop_cont_four_col_register_grid(grid)
3633
 
3634
  grid = _drop_truly_empty_columns(grid)
3635
  grid = _merge_blank_header_text_columns(grid)
 
3671
 
3672
  grid = _fix_fused_keyvalue_rows(grid)
3673
  grid = _dedupe_duplicate_rows_in_da3_table(grid)
3674
+ grid = _dedupe_consecutive_identical_da4_register_rows(grid)
3675
  split_tables = _split_combined_summary_daily_balance_grid(grid)
3676
  if split_tables:
3677
  out.append("\n\n".join(_grid_to_html(g) for g in split_tables if g))
 
3847
  )
3848
 
3849
 
3850
+ def _is_da4_date_desc_amount_balance_header(grid):
3851
+ """Date | …Description… | …Amount… | …Balance… (4-column running-balance register)."""
3852
+ if not grid or len(grid[0]) < 4:
3853
+ return False
3854
+ h = [re.sub(r"\s+", " ", str(c or "").strip().lower()) for c in grid[0][:4]]
3855
+ if h[0] != "date":
3856
+ return False
3857
+ if "description" not in h[1]:
3858
+ return False
3859
+ if "amount" not in h[2]:
3860
+ return False
3861
+ if "balance" not in h[3]:
3862
+ return False
3863
+ return True
3864
+
3865
+
3866
  _DA3_STRICT_AMOUNT = re.compile(r"^-?\$?\d{1,3}(?:,\d{3})*\.\d{2}-?$")
3867
  _DA3_MONEY_TOKEN = re.compile(
3868
  r"(?<![\d,])(-?\$?\d{1,3}(?:,\d{3})*\.\d{2}|-?\$?\d{2,6}\.\d{2})(?!\d)"
 
3980
  return out
3981
 
3982
 
3983
+ def _da4_register_row_key(cells: List[str]) -> Tuple[str, str, str, str]:
3984
+ parts = [str(c or "").strip() for c in cells[:4]]
3985
+ while len(parts) < 4:
3986
+ parts.append("")
3987
+ d0, d1, d2, d3 = parts[0], parts[1], parts[2], parts[3]
3988
+ date_s = re.sub(r"\s+", "", d0)
3989
+ amt_s = re.sub(r"[\s$,]", "", d2).lower().replace("−", "-")
3990
+ bal_s = re.sub(r"[\s$,]", "", d3).lower().replace("−", "-")
3991
+ desc_s = html.unescape(d1)
3992
+ desc_s = desc_s.replace("`", "'").replace("'", "'")
3993
+ desc_s = re.sub(r"\s+", "", desc_s.lower())
3994
+ return (date_s, desc_s, amt_s, bal_s)
3995
+
3996
+
3997
+ def _dedupe_consecutive_identical_da4_register_rows(grid):
3998
+ """
3999
+ Fix SE-CONT / generic: OCR often repeats the same register line twice at page breaks
4000
+ (identical date, description, amount, and running balance). Real back-to-back
4001
+ duplicates would show different balances. Collapse consecutive full-row matches;
4002
+ honour text-layer counts when >1 (same 3-col key as Fix 20).
4003
+ """
4004
+ if not grid or len(grid) < 2:
4005
+ return grid
4006
+ if not _is_da4_date_desc_amount_balance_header(grid):
4007
+ return grid
4008
+
4009
+ data_rows = []
4010
+ for row in grid[1:]:
4011
+ cells = [str(c or "").strip() for c in row]
4012
+ if not any(cells):
4013
+ continue
4014
+ data_rows.append(row)
4015
+
4016
+ if not data_rows:
4017
+ return grid
4018
+
4019
+ out = [grid[0]]
4020
+ i = 0
4021
+ while i < len(data_rows):
4022
+ row = data_rows[i]
4023
+ cells = [str(c or "").strip() for c in row]
4024
+ k4 = _da4_register_row_key(cells)
4025
+ j = i + 1
4026
+ while j < len(data_rows):
4027
+ ncells = [str(c or "").strip() for c in data_rows[j]]
4028
+ if _da4_register_row_key(ncells) != k4:
4029
+ break
4030
+ j += 1
4031
+ run_len = j - i
4032
+ k3 = _transaction_row_dedupe_key(cells)
4033
+ tl_confirmed = _tl_freq_context.get(k3, 0)
4034
+ # Default cap 1 for identical running-balance rows; allow more if PDF text layer says so.
4035
+ cap = max(tl_confirmed, 1) if tl_confirmed > 0 else 1
4036
+ take = min(run_len, cap)
4037
+ for t in range(take):
4038
+ out.append(data_rows[i + t])
4039
+ i = j
4040
+
4041
+ return out
4042
+
4043
+
4044
  def _row_text_full_ucb(grid, r: int) -> str:
4045
  if not grid or r < 0 or r >= len(grid):
4046
  return ""