Spaces:
Sleeping
Sleeping
Update app.py
Browse files
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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
| 4601 |
cells.append("")
|
| 4602 |
if not _DA3_LEADING_DATE.match(cells[0]):
|
| 4603 |
continue
|
| 4604 |
-
|
| 4605 |
-
|
| 4606 |
-
|
| 4607 |
-
|
| 4608 |
-
|
| 4609 |
-
|
|
|
|
| 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 =
|
| 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
|
| 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)
|