rehan953 commited on
Commit
8a421f8
Β·
verified Β·
1 Parent(s): 25e632e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +121 -71
app.py CHANGED
@@ -25,6 +25,7 @@ Primary goals for reconcile rate:
25
  Beginning/Ending balance rows (UCB-style wide summary tables)
26
  - Fix 14: UCB 3-col tables where Beginning Balance amount is fused into description and Amount is $0.00
27
  - Fix 15: UCB (and similar) 3-col rows where Date+Description are fused in col1 and Amount sits in col2 with col3 empty
 
28
  - Post-pass: dedupe 3-col Date|Description|Amount tables when one is a duplicate fragment
29
  (later subset of earlier, earlier subset of later, or identical row sets)
30
  """
@@ -3094,6 +3095,8 @@ def normalize_html_tables(text: str) -> str:
3094
 
3095
  # Fix 3: fused key-value rows in summary sections (no-space guard β€” safe on all PDFs)
3096
  grid = _fix_fused_keyvalue_rows(grid)
 
 
3097
  # Fix 12: split fused summary+daily-balance combined table, if detected.
3098
  split_tables = _split_combined_summary_daily_balance_grid(grid)
3099
  if split_tables:
@@ -3107,6 +3110,50 @@ def normalize_html_tables(text: str) -> str:
3107
  out.append(text[last:])
3108
  return "".join(out)
3109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3110
  def _dedupe_subset_transaction_tables_html(text: str) -> str:
3111
  """
3112
  Remove duplicate 3-column Date|Description|Amount tables:
@@ -3114,6 +3161,8 @@ def _dedupe_subset_transaction_tables_html(text: str) -> str:
3114
  - Earlier table is a strict subset of a later one β†’ drop earlier (UCB-style
3115
  "Deposits (continued)" preview before the same rows appear in the full list).
3116
  - Identical row sets β†’ drop the later table.
 
 
3117
 
3118
  Row matching uses normalized keys so OCR variants (e.g. "0 27" vs "027", backticks)
3119
  do not prevent duplicate tables from being detected.
@@ -3122,9 +3171,6 @@ def _dedupe_subset_transaction_tables_html(text: str) -> str:
3122
  return text
3123
 
3124
  pattern = re.compile(r"<table[^>]*>.*?</table>", re.DOTALL | re.IGNORECASE)
3125
- matches = list(pattern.finditer(text))
3126
- if len(matches) < 2:
3127
- return text
3128
 
3129
  def _parse_grid(html: str):
3130
  try:
@@ -3134,34 +3180,6 @@ def _dedupe_subset_transaction_tables_html(text: str) -> str:
3134
  except Exception:
3135
  return None
3136
 
3137
- grids = []
3138
- for m in matches:
3139
- grids.append((m, _parse_grid(m.group(0))))
3140
-
3141
- def _header_matches(grid):
3142
- if not grid or len(grid) < 2:
3143
- return False
3144
- h = [re.sub(r"\s+", " ", str(c or "").strip().lower()) for c in grid[0]]
3145
- if len(h) != 3:
3146
- return False
3147
- d_ok = h[0] == "date"
3148
- desc_ok = "description" in h[1]
3149
- amt_ok = h[2] == "amount"
3150
- return d_ok and desc_ok and amt_ok
3151
-
3152
- def _norm_row_key(cells):
3153
- """Stable key for same logical row despite minor OCR/HTML differences."""
3154
- parts = [str(c or "").strip() for c in cells[:3]]
3155
- while len(parts) < 3:
3156
- parts.append("")
3157
- date_s, desc_s, amt_s = parts[0], parts[1], parts[2]
3158
- date_s = re.sub(r"\s+", "", date_s)
3159
- amt_s = re.sub(r"[\s$,]", "", amt_s).lower().replace("βˆ’", "-")
3160
- desc_s = html.unescape(desc_s)
3161
- desc_s = desc_s.replace("`", "'").replace("’", "'")
3162
- desc_s = re.sub(r"\s+", "", desc_s.lower())
3163
- return (date_s, desc_s, amt_s)
3164
-
3165
  def _row_set(g):
3166
  s = set()
3167
  for r in g[1:]:
@@ -3170,56 +3188,84 @@ def _dedupe_subset_transaction_tables_html(text: str) -> str:
3170
  cells = [str(c or "").strip() for c in r]
3171
  if not any(cells):
3172
  continue
3173
- s.add(_norm_row_key(cells))
3174
  return s
3175
 
3176
- drop_idx = set()
3177
- for i in range(1, len(grids)):
3178
- if i in drop_idx:
3179
- continue
3180
- mi, gi = grids[i]
3181
- if not _header_matches(gi):
3182
- continue
3183
- si = _row_set(gi)
3184
- if not si:
3185
- continue
3186
 
3187
- for j in range(i):
3188
- if j in drop_idx:
 
 
 
 
 
3189
  continue
3190
- mj, gj = grids[j]
3191
- if not _header_matches(gj):
3192
  continue
3193
- sj = _row_set(gj)
3194
- if not sj:
3195
  continue
3196
 
3197
- # Earlier j fully contained in later i (preview + full list) β†’ drop earlier.
3198
- if sj <= si and len(sj) < len(si):
3199
- drop_idx.add(j)
3200
- continue
 
 
 
 
 
3201
 
3202
- # Later i strict subset of earlier j β†’ drop later.
3203
- if si <= sj and len(si) < len(sj):
3204
- drop_idx.add(i)
3205
- break
3206
 
3207
- if si == sj:
3208
- drop_idx.add(i)
3209
- break
 
3210
 
3211
- if not drop_idx:
3212
- return text
 
3213
 
3214
- out = []
3215
- last = 0
3216
- for idx, m in enumerate(matches):
3217
- out.append(text[last : m.start()])
3218
- if idx not in drop_idx:
3219
- out.append(m.group(0))
3220
- last = m.end()
3221
- out.append(text[last:])
3222
- return "".join(out)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3223
 
3224
 
3225
  def stabilize_tables_and_text(page_md: str) -> str:
@@ -3444,7 +3490,11 @@ def run_ocr(uploaded_file):
3444
  if parts:
3445
  all_pages.append("\n\n".join(parts))
3446
 
3447
- return "\n\n---page-separator---\n\n".join(all_pages) if all_pages else "(No content)"
 
 
 
 
3448
 
3449
  except Exception as e:
3450
  import traceback
 
25
  Beginning/Ending balance rows (UCB-style wide summary tables)
26
  - Fix 14: UCB 3-col tables where Beginning Balance amount is fused into description and Amount is $0.00
27
  - Fix 15: UCB (and similar) 3-col rows where Date+Description are fused in col1 and Amount sits in col2 with col3 empty
28
+ - Fix 16: remove duplicate rows within a single Date|Description|Amount table; multi-pass table dedupe + final doc-wide dedupe
29
  - Post-pass: dedupe 3-col Date|Description|Amount tables when one is a duplicate fragment
30
  (later subset of earlier, earlier subset of later, or identical row sets)
31
  """
 
3095
 
3096
  # Fix 3: fused key-value rows in summary sections (no-space guard β€” safe on all PDFs)
3097
  grid = _fix_fused_keyvalue_rows(grid)
3098
+ # Fix 16: duplicate data rows inside same Date|Description|Amount table (OCR repeats).
3099
+ grid = _dedupe_duplicate_rows_in_da3_table(grid)
3100
  # Fix 12: split fused summary+daily-balance combined table, if detected.
3101
  split_tables = _split_combined_summary_daily_balance_grid(grid)
3102
  if split_tables:
 
3110
  out.append(text[last:])
3111
  return "".join(out)
3112
 
3113
+
3114
+ def _transaction_row_dedupe_key(cells):
3115
+ """Stable key for Date|Description|Amount rows (OCR variants, HTML entities)."""
3116
+ parts = [str(c or "").strip() for c in cells[:3]]
3117
+ while len(parts) < 3:
3118
+ parts.append("")
3119
+ date_s, desc_s, amt_s = parts[0], parts[1], parts[2]
3120
+ date_s = re.sub(r"\s+", "", date_s)
3121
+ amt_s = re.sub(r"[\s$,]", "", amt_s).lower().replace("βˆ’", "-")
3122
+ desc_s = html.unescape(desc_s)
3123
+ desc_s = desc_s.replace("`", "'").replace("’", "'")
3124
+ desc_s = re.sub(r"\s+", "", desc_s.lower())
3125
+ return (date_s, desc_s, amt_s)
3126
+
3127
+
3128
+ def _is_da3_header(grid):
3129
+ if not grid or len(grid[0]) < 3:
3130
+ return False
3131
+ h = [re.sub(r"\s+", " ", str(c or "").strip().lower()) for c in grid[0]]
3132
+ if len(h) != 3:
3133
+ return False
3134
+ return h[0] == "date" and "description" in h[1] and h[2] == "amount"
3135
+
3136
+
3137
+ def _dedupe_duplicate_rows_in_da3_table(grid):
3138
+ """Drop repeated data rows within one Date|Description|Amount table (same normalized key)."""
3139
+ if not grid or len(grid) < 2:
3140
+ return grid
3141
+ if not _is_da3_header(grid):
3142
+ return grid
3143
+ seen = set()
3144
+ out = [grid[0]]
3145
+ for row in grid[1:]:
3146
+ cells = [str(c or "").strip() for c in row]
3147
+ if not any(cells):
3148
+ continue
3149
+ k = _transaction_row_dedupe_key(cells)
3150
+ if k in seen:
3151
+ continue
3152
+ seen.add(k)
3153
+ out.append(row)
3154
+ return out if len(out) < len(grid) else grid
3155
+
3156
+
3157
  def _dedupe_subset_transaction_tables_html(text: str) -> str:
3158
  """
3159
  Remove duplicate 3-column Date|Description|Amount tables:
 
3161
  - Earlier table is a strict subset of a later one β†’ drop earlier (UCB-style
3162
  "Deposits (continued)" preview before the same rows appear in the full list).
3163
  - Identical row sets β†’ drop the later table.
3164
+ - Fuzzy overlap (β‰₯92% of later rows match earlier) β†’ drop later (Electronic Debits vs main).
3165
+ - Runs multiple passes until stable.
3166
 
3167
  Row matching uses normalized keys so OCR variants (e.g. "0 27" vs "027", backticks)
3168
  do not prevent duplicate tables from being detected.
 
3171
  return text
3172
 
3173
  pattern = re.compile(r"<table[^>]*>.*?</table>", re.DOTALL | re.IGNORECASE)
 
 
 
3174
 
3175
  def _parse_grid(html: str):
3176
  try:
 
3180
  except Exception:
3181
  return None
3182
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3183
  def _row_set(g):
3184
  s = set()
3185
  for r in g[1:]:
 
3188
  cells = [str(c or "").strip() for c in r]
3189
  if not any(cells):
3190
  continue
3191
+ s.add(_transaction_row_dedupe_key(cells))
3192
  return s
3193
 
3194
+ def _one_pass(t: str) -> str:
3195
+ matches = list(pattern.finditer(t))
3196
+ if len(matches) < 2:
3197
+ return t
 
 
 
 
 
 
3198
 
3199
+ grids = []
3200
+ for m in matches:
3201
+ grids.append((m, _parse_grid(m.group(0))))
3202
+
3203
+ drop_idx = set()
3204
+ for i in range(1, len(grids)):
3205
+ if i in drop_idx:
3206
  continue
3207
+ mi, gi = grids[i]
3208
+ if not _is_da3_header(gi):
3209
  continue
3210
+ si = _row_set(gi)
3211
+ if not si:
3212
  continue
3213
 
3214
+ for j in range(i):
3215
+ if j in drop_idx:
3216
+ continue
3217
+ mj, gj = grids[j]
3218
+ if not _is_da3_header(gj):
3219
+ continue
3220
+ sj = _row_set(gj)
3221
+ if not sj:
3222
+ continue
3223
 
3224
+ # Earlier j fully contained in later i (preview + full list) β†’ drop earlier.
3225
+ if sj <= si and len(sj) < len(si):
3226
+ drop_idx.add(j)
3227
+ continue
3228
 
3229
+ # Later i strict subset of earlier j β†’ drop later (e.g. Electronic Debits).
3230
+ if si <= sj and len(si) < len(sj):
3231
+ drop_idx.add(i)
3232
+ break
3233
 
3234
+ if si == sj:
3235
+ drop_idx.add(i)
3236
+ break
3237
 
3238
+ inter = si & sj
3239
+ # Later table's rows almost all appear in earlier table (OCR drift).
3240
+ ri = len(inter) / len(si) if si else 0.0
3241
+ rj = len(inter) / len(sj) if sj else 0.0
3242
+ if ri >= 0.88 and len(si) <= len(sj):
3243
+ drop_idx.add(i)
3244
+ break
3245
+ if rj >= 0.88 and len(sj) < len(si):
3246
+ drop_idx.add(j)
3247
+ continue
3248
+
3249
+ if not drop_idx:
3250
+ return t
3251
+
3252
+ out = []
3253
+ last = 0
3254
+ for idx, m in enumerate(matches):
3255
+ out.append(t[last : m.start()])
3256
+ if idx not in drop_idx:
3257
+ out.append(m.group(0))
3258
+ last = m.end()
3259
+ out.append(t[last:])
3260
+ return "".join(out)
3261
+
3262
+ out = text
3263
+ for _ in range(12):
3264
+ nxt = _one_pass(out)
3265
+ if nxt == out:
3266
+ break
3267
+ out = nxt
3268
+ return out
3269
 
3270
 
3271
  def stabilize_tables_and_text(page_md: str) -> str:
 
3490
  if parts:
3491
  all_pages.append("\n\n".join(parts))
3492
 
3493
+ merged = "\n\n---page-separator---\n\n".join(all_pages) if all_pages else "(No content)"
3494
+ # Cross-page: same transaction tables split across page boundaries must dedupe once.
3495
+ if all_pages and merged and not merged.startswith("Error") and "<table" in merged.lower():
3496
+ merged = _dedupe_subset_transaction_tables_html(merged)
3497
+ return merged
3498
 
3499
  except Exception as e:
3500
  import traceback