rehan953 commited on
Commit
f1ba689
·
verified ·
1 Parent(s): 147d7d7

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +169 -12
app.py CHANGED
@@ -136,6 +136,9 @@ Primary goals for reconcile rate:
136
  - Fix FH-1: Drop a leading Date|Description|Amount (DA3) table when its (date, amount) multiset is a
137
  strict subset of a later DEPOSIT/WITHDRAWAL register (e.g. First Horizon OCR duplicate before
138
  ACCOUNT SUMMARY). Safe no-op when no such pair exists.
 
 
 
139
  """
140
 
141
  # Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
@@ -2037,6 +2040,51 @@ def _normalize_daily_balance_table(grid):
2037
 
2038
  return [new_header] + new_rows
2039
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2040
  def _normalize_checks_paid_table(grid):
2041
  if not grid or len(grid) < 2:
2042
  return grid
@@ -3249,6 +3297,7 @@ def normalize_html_tables(text: str) -> str:
3249
  grid = _normalize_fused_date_posted_amount_table(grid)
3250
 
3251
  grid = _split_fused_multicolumn_header(grid)
 
3252
 
3253
  grid = _normalize_daily_balance_table(grid)
3254
 
@@ -3275,6 +3324,97 @@ def normalize_html_tables(text: str) -> str:
3275
  return "".join(out)
3276
 
3277
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3278
  def _transaction_row_dedupe_key(cells):
3279
  parts = [str(c or "").strip() for c in cells[:3]]
3280
  while len(parts) < 3:
@@ -4533,6 +4673,13 @@ def _fh_withdrawal_column_index(grid) -> Optional[int]:
4533
  return ci
4534
  return None
4535
 
 
 
 
 
 
 
 
4536
 
4537
  def _fh_is_deposit_withdrawal_register(grid) -> bool:
4538
  if not grid or len(grid) < 2:
@@ -4540,7 +4687,10 @@ def _fh_is_deposit_withdrawal_register(grid) -> bool:
4540
  blob = " ".join(
4541
  " ".join(str(c or "") for c in row) for row in grid[: min(5, len(grid))]
4542
  ).upper()
4543
- return "DEPOSIT" in blob and "WITHDRAWAL" in blob and "DATE" in blob
 
 
 
4544
 
4545
 
4546
  def _drop_da3_duplicate_of_later_deposit_withdrawal_register(text: str) -> str:
@@ -4588,25 +4738,29 @@ def _drop_da3_duplicate_of_later_deposit_withdrawal_register(text: str) -> str:
4588
  return None
4589
  return c
4590
 
4591
- def _register_wd_counter(g) -> Optional[Counter]:
4592
  if not _fh_is_deposit_withdrawal_register(g):
4593
  return None
 
4594
  wci = _fh_withdrawal_column_index(g)
4595
- if wci is None:
4596
  return None
4597
  c = Counter()
4598
  for row in g[1:]:
4599
  cells = [str(x or "").strip() for x in row]
4600
- while len(cells) <= wci:
 
 
4601
  cells.append("")
4602
  if not _DA3_LEADING_DATE.match(cells[0]):
4603
  continue
4604
- amt = cells[wci]
4605
- if not amt.strip():
4606
- continue
4607
- if not _DA3_STRICT_AMOUNT.match(amt):
4608
- continue
4609
- c[_norm_da_key_date_amt(cells[0], amt)] += 1
 
4610
  if sum(c.values()) < 3:
4611
  return None
4612
  return c
@@ -4626,7 +4780,7 @@ def _drop_da3_duplicate_of_later_deposit_withdrawal_register(text: str) -> str:
4626
  continue
4627
  for j in range(i + 1, len(grids)):
4628
  mj, gj = grids[j]
4629
- rk = _register_wd_counter(gj)
4630
  if rk is None:
4631
  continue
4632
  n_reg = sum(rk.values())
@@ -4637,7 +4791,7 @@ def _drop_da3_duplicate_of_later_deposit_withdrawal_register(text: str) -> str:
4637
  drop_starts.add(mi.start())
4638
  log.info(
4639
  "Fix FH-1: dropped DA3 (%d rows) duplicate of later "
4640
- "DEPOSIT/WITHDRAWAL register (%d wd rows)",
4641
  n_frag,
4642
  n_reg,
4643
  )
@@ -5507,6 +5661,7 @@ def stabilize_tables_and_text(page_md: str) -> str:
5507
  stabilized = _strip_orphan_continued_da3_flatline(stabilized)
5508
  stabilized = _strip_standalone_continued_banner_line(stabilized)
5509
  stabilized = normalize_html_tables(stabilized)
 
5510
  stabilized = _dedupe_adjacent_duplicate_html_tables(stabilized)
5511
  stabilized = _dedupe_adjacent_loose_register_duplicate_tables(stabilized)
5512
  stabilized = _fix_markdown_fidelity_typos_and_escapes(stabilized)
@@ -5645,6 +5800,7 @@ def run_ocr(uploaded_file):
5645
  stabilized = _strip_orphan_continued_da3_flatline(stabilized)
5646
  stabilized = _strip_standalone_continued_banner_line(stabilized)
5647
  stabilized = normalize_html_tables(stabilized)
 
5648
  stabilized = _dedupe_adjacent_duplicate_html_tables(stabilized)
5649
  stabilized = _dedupe_adjacent_loose_register_duplicate_tables(stabilized)
5650
  stabilized = _fix_markdown_fidelity_typos_and_escapes(stabilized)
@@ -5728,6 +5884,7 @@ def run_ocr(uploaded_file):
5728
  if all_pages and merged and not merged.startswith("Error") and "<table" in merged.lower():
5729
  merged = _strip_orphan_continued_da3_flatline(merged)
5730
  merged = _strip_standalone_continued_banner_line(merged)
 
5731
  merged = _dedupe_adjacent_duplicate_html_tables(merged)
5732
  merged = _dedupe_adjacent_loose_register_duplicate_tables(merged)
5733
  merged = _fix_markdown_fidelity_typos_and_escapes(merged)
 
136
  - Fix FH-1: Drop a leading Date|Description|Amount (DA3) table when its (date, amount) multiset is a
137
  strict subset of a later DEPOSIT/WITHDRAWAL register (e.g. First Horizon OCR duplicate before
138
  ACCOUNT SUMMARY). Safe no-op when no such pair exists.
139
+ - Fix FH-2: Normalize packed DAILY BALANCE SUMMARY and CHECKS PAID SUMMARY tables into one-row-per-item
140
+ layouts; also promote multiline register headers where row0 is placeholder and row1 has true
141
+ DATE/DESCRIPTION/DEPOSIT/WITHDRAWAL labels.
142
  """
143
 
144
  # Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
 
2040
 
2041
  return [new_header] + new_rows
2042
 
2043
+ def _normalize_multiline_register_header(grid):
2044
+ """
2045
+ Normalize two-row register headers where row0 is mostly blank/Amount placeholders and
2046
+ row1 carries the real labels (DATE, DESCRIPTION, DEPOSIT, WITHDRAWAL, CARD #).
2047
+ """
2048
+ if not grid or len(grid) < 3:
2049
+ return grid
2050
+ r0 = [str(c or "").strip() for c in grid[0]]
2051
+ r1 = [str(c or "").strip() for c in grid[1]]
2052
+ if not r1:
2053
+ return grid
2054
+
2055
+ r1_join = " ".join(r1).upper()
2056
+ if "DATE" not in r1_join:
2057
+ return grid
2058
+ if "WITHDRAWAL" not in r1_join and "DEPOSIT" not in r1_join:
2059
+ return grid
2060
+ if "DESCRIPTION" not in r1_join:
2061
+ return grid
2062
+
2063
+ # Guard: only treat as multiline header if row0 is mostly empty / generic title-ish cells.
2064
+ non_empty_r0 = [x for x in r0 if x]
2065
+ if len(non_empty_r0) > max(3, len(r0) // 2):
2066
+ if not ("ACCOUNT HISTORY" in " ".join(r0).upper()):
2067
+ return grid
2068
+ generic_r0 = all(
2069
+ (not x) or (x.upper() in ("AMOUNT", "ACCOUNT HISTORY", "DATE", "DESCRIPTION", "DEPOSIT", "WITHDRAWAL", "CARD #"))
2070
+ for x in r0
2071
+ )
2072
+ if not generic_r0:
2073
+ return grid
2074
+
2075
+ # Use row1 as canonical header, keeping column count stable.
2076
+ ncols = max(len(r0), len(r1))
2077
+ new_header = []
2078
+ for i in range(ncols):
2079
+ v = (r1[i] if i < len(r1) else "").strip()
2080
+ if not v and i < len(r0):
2081
+ v = str(r0[i] or "").strip()
2082
+ new_header.append(v)
2083
+
2084
+ out = [new_header]
2085
+ out.extend(grid[2:])
2086
+ return out
2087
+
2088
  def _normalize_checks_paid_table(grid):
2089
  if not grid or len(grid) < 2:
2090
  return grid
 
3297
  grid = _normalize_fused_date_posted_amount_table(grid)
3298
 
3299
  grid = _split_fused_multicolumn_header(grid)
3300
+ grid = _normalize_multiline_register_header(grid)
3301
 
3302
  grid = _normalize_daily_balance_table(grid)
3303
 
 
3324
  return "".join(out)
3325
 
3326
 
3327
+ def _normalize_packed_daily_balance_and_checks_summary_tables(text: str) -> str:
3328
+ """
3329
+ Normalize packed statement summary tables often seen in First Horizon OCR output:
3330
+ - DAILY BALANCE SUMMARY with grouped date cells (e.g. "01/02 01/03 01/04 01/05")
3331
+ - CHECKS PAID SUMMARY with grouped "DATE CHECK #" triplets + parallel amount columns
3332
+ """
3333
+ if not text or "<table" not in text.lower():
3334
+ return text
3335
+
3336
+ pattern = re.compile(r"<table[^>]*>.*?</table>", re.DOTALL | re.IGNORECASE)
3337
+ out = []
3338
+ last = 0
3339
+
3340
+ _DATE_RE = re.compile(r"^\d{1,2}/\d{2}(?:/\d{2,4})?$")
3341
+
3342
+ for m in pattern.finditer(text):
3343
+ out.append(text[last:m.start()])
3344
+ tbl = m.group(0)
3345
+ try:
3346
+ p = TableGridParser()
3347
+ p.feed(tbl)
3348
+ g = _build_grid(p.rows)
3349
+ if not g or len(g) < 3:
3350
+ out.append(tbl)
3351
+ last = m.end()
3352
+ continue
3353
+
3354
+ h0 = " ".join(str(c or "").strip().upper() for c in g[0])
3355
+
3356
+ # DAILY BALANCE SUMMARY: unpack grouped dates into one row per date.
3357
+ if "DAILY BALANCE SUMMARY" in h0:
3358
+ rows = [["Date", "Balance"]]
3359
+ for row in g[1:]:
3360
+ vals = [str(c or "").strip() for c in row]
3361
+ if not any(vals):
3362
+ continue
3363
+ if any("BALANCE" == v.upper() for v in vals):
3364
+ continue
3365
+ date_tokens = []
3366
+ for cell in vals:
3367
+ for tok in re.split(r"[\s\r\n]+", cell):
3368
+ t = tok.strip()
3369
+ if _DATE_RE.match(t):
3370
+ date_tokens.append(t)
3371
+ amt_cells = [v for v in vals if _DA3_STRICT_AMOUNT.match(v)]
3372
+ for d, a in zip(date_tokens, amt_cells):
3373
+ rows.append([d, a])
3374
+ if len(rows) >= 3:
3375
+ out.append(_grid_to_html(rows))
3376
+ last = m.end()
3377
+ continue
3378
+
3379
+ # CHECKS PAID SUMMARY: unpack grouped DATE/CHECK# with parallel amount columns.
3380
+ if "CHECKS PAID SUMMARY" in h0:
3381
+ rows = [["Date", "Check #", "Amount"]]
3382
+ for row in g[1:]:
3383
+ vals = [str(c or "").strip() for c in row]
3384
+ if not any(vals):
3385
+ continue
3386
+ if "DATE CHECK #" in " ".join(vals).upper():
3387
+ continue
3388
+ left = vals[0] if vals else ""
3389
+ amounts = [v for v in vals[1:] if _DA3_STRICT_AMOUNT.match(v)]
3390
+ # tokens like: 01/24 4701 01/18 4704 * 01/18 916831 *
3391
+ tokens = [t for t in re.split(r"\s+", left) if t]
3392
+ pairs = []
3393
+ i = 0
3394
+ while i < len(tokens):
3395
+ if _DATE_RE.match(tokens[i]) and i + 1 < len(tokens):
3396
+ chk = tokens[i + 1]
3397
+ if chk != "*":
3398
+ pairs.append((tokens[i], chk))
3399
+ i += 2
3400
+ continue
3401
+ i += 1
3402
+ for (d, chk), amt in zip(pairs, amounts):
3403
+ rows.append([d, chk, amt])
3404
+ if len(rows) >= 2:
3405
+ out.append(_grid_to_html(rows))
3406
+ last = m.end()
3407
+ continue
3408
+
3409
+ out.append(tbl)
3410
+ except Exception:
3411
+ out.append(tbl)
3412
+ last = m.end()
3413
+
3414
+ out.append(text[last:])
3415
+ return "".join(out)
3416
+
3417
+
3418
  def _transaction_row_dedupe_key(cells):
3419
  parts = [str(c or "").strip() for c in cells[:3]]
3420
  while len(parts) < 3:
 
4673
  return ci
4674
  return None
4675
 
4676
+ def _fh_deposit_column_index(grid) -> Optional[int]:
4677
+ for row in grid[: min(6, len(grid))]:
4678
+ for ci, c in enumerate(row):
4679
+ if str(c or "").strip().upper() == "DEPOSIT":
4680
+ return ci
4681
+ return None
4682
+
4683
 
4684
  def _fh_is_deposit_withdrawal_register(grid) -> bool:
4685
  if not grid or len(grid) < 2:
 
4687
  blob = " ".join(
4688
  " ".join(str(c or "") for c in row) for row in grid[: min(5, len(grid))]
4689
  ).upper()
4690
+ has_date = "DATE" in blob
4691
+ has_desc = "DESCRIPTION" in blob
4692
+ has_amt_side = ("WITHDRAWAL" in blob) or ("DEPOSIT" in blob)
4693
+ return has_date and has_desc and has_amt_side
4694
 
4695
 
4696
  def _drop_da3_duplicate_of_later_deposit_withdrawal_register(text: str) -> str:
 
4738
  return None
4739
  return c
4740
 
4741
+ def _register_amount_counter(g) -> Optional[Counter]:
4742
  if not _fh_is_deposit_withdrawal_register(g):
4743
  return None
4744
+ dci = _fh_deposit_column_index(g)
4745
  wci = _fh_withdrawal_column_index(g)
4746
+ if dci is None and wci is None:
4747
  return None
4748
  c = Counter()
4749
  for row in g[1:]:
4750
  cells = [str(x or "").strip() for x in row]
4751
+ need_cols = [x for x in (dci, wci) if x is not None]
4752
+ max_idx = max(need_cols) if need_cols else 0
4753
+ while len(cells) <= max_idx:
4754
  cells.append("")
4755
  if not _DA3_LEADING_DATE.match(cells[0]):
4756
  continue
4757
+ for ci in need_cols:
4758
+ amt = cells[ci]
4759
+ if not amt.strip():
4760
+ continue
4761
+ if not _DA3_STRICT_AMOUNT.match(amt):
4762
+ continue
4763
+ c[_norm_da_key_date_amt(cells[0], amt)] += 1
4764
  if sum(c.values()) < 3:
4765
  return None
4766
  return c
 
4780
  continue
4781
  for j in range(i + 1, len(grids)):
4782
  mj, gj = grids[j]
4783
+ rk = _register_amount_counter(gj)
4784
  if rk is None:
4785
  continue
4786
  n_reg = sum(rk.values())
 
4791
  drop_starts.add(mi.start())
4792
  log.info(
4793
  "Fix FH-1: dropped DA3 (%d rows) duplicate of later "
4794
+ "DEPOSIT/WITHDRAWAL register (%d amount rows)",
4795
  n_frag,
4796
  n_reg,
4797
  )
 
5661
  stabilized = _strip_orphan_continued_da3_flatline(stabilized)
5662
  stabilized = _strip_standalone_continued_banner_line(stabilized)
5663
  stabilized = normalize_html_tables(stabilized)
5664
+ stabilized = _normalize_packed_daily_balance_and_checks_summary_tables(stabilized)
5665
  stabilized = _dedupe_adjacent_duplicate_html_tables(stabilized)
5666
  stabilized = _dedupe_adjacent_loose_register_duplicate_tables(stabilized)
5667
  stabilized = _fix_markdown_fidelity_typos_and_escapes(stabilized)
 
5800
  stabilized = _strip_orphan_continued_da3_flatline(stabilized)
5801
  stabilized = _strip_standalone_continued_banner_line(stabilized)
5802
  stabilized = normalize_html_tables(stabilized)
5803
+ stabilized = _normalize_packed_daily_balance_and_checks_summary_tables(stabilized)
5804
  stabilized = _dedupe_adjacent_duplicate_html_tables(stabilized)
5805
  stabilized = _dedupe_adjacent_loose_register_duplicate_tables(stabilized)
5806
  stabilized = _fix_markdown_fidelity_typos_and_escapes(stabilized)
 
5884
  if all_pages and merged and not merged.startswith("Error") and "<table" in merged.lower():
5885
  merged = _strip_orphan_continued_da3_flatline(merged)
5886
  merged = _strip_standalone_continued_banner_line(merged)
5887
+ merged = _normalize_packed_daily_balance_and_checks_summary_tables(merged)
5888
  merged = _dedupe_adjacent_duplicate_html_tables(merged)
5889
  merged = _dedupe_adjacent_loose_register_duplicate_tables(merged)
5890
  merged = _fix_markdown_fidelity_typos_and_escapes(merged)