Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -58,13 +58,22 @@ Primary goals for reconcile rate:
|
|
| 58 |
- Fix 28: Strip leading credit/deposit rows that OCR prepends to a withdrawals/debits DA3 table
|
| 59 |
when the preceding DA3 table was under a deposits/credits heading. Only strips the
|
| 60 |
contiguous prefix of the debit table whose rows appear in the credit table; requires
|
| 61 |
-
|
| 62 |
- Fix 29: Move a misplaced debit/withdrawal heading to before its orphan DA3 table when OCR
|
| 63 |
places the heading AFTER the table instead of before it. Pattern detected:
|
| 64 |
[credit heading+table] ... [orphan DA3 table] ... [debit heading] — the heading is
|
| 65 |
relocated to just before the orphan table. Guards: no debit heading already before
|
| 66 |
table; credit context must precede table; no other table between orphan and heading;
|
| 67 |
try/except wrapper (safe no-op on any error).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
"""
|
| 69 |
|
| 70 |
# Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
|
|
@@ -432,10 +441,6 @@ def _is_amount_like(s: str) -> bool:
|
|
| 432 |
return re.fullmatch(r"\$?-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?", s.strip()) is not None
|
| 433 |
|
| 434 |
def _drop_truly_empty_columns(grid):
|
| 435 |
-
"""
|
| 436 |
-
Drop columns that are empty in header AND almost always empty in body.
|
| 437 |
-
This fixes summary tables that have an extra blank trailing column.
|
| 438 |
-
"""
|
| 439 |
if not grid or len(grid) < 1:
|
| 440 |
return grid
|
| 441 |
|
|
@@ -470,12 +475,6 @@ def _drop_truly_empty_columns(grid):
|
|
| 470 |
return new_grid
|
| 471 |
|
| 472 |
def _merge_blank_header_text_columns(grid):
|
| 473 |
-
"""
|
| 474 |
-
If the header row has blank columns, and most body rows have non-empty *text* in that
|
| 475 |
-
blank column, merge that column into the nearest non-empty header to the left (usually
|
| 476 |
-
DESCRIPTION), then remove the blank column.
|
| 477 |
-
This fixes transaction tables where DESCRIPTION is split across two columns.
|
| 478 |
-
"""
|
| 479 |
if not grid or len(grid) < 2:
|
| 480 |
return grid
|
| 481 |
|
|
@@ -536,16 +535,12 @@ def _merge_blank_header_text_columns(grid):
|
|
| 536 |
# Shared keyword definitions
|
| 537 |
# --------------------------
|
| 538 |
|
| 539 |
-
# Matches a standalone money value across currencies.
|
| 540 |
-
# Covers: $3,447.10 -1,234.56 £500.00 Rs1000 etc.
|
| 541 |
_MONEY_RE = re.compile(
|
| 542 |
r"^(?:[\$£€¥]|Rs\.?|INR|PKR)?\s*-?\s?\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$",
|
| 543 |
re.IGNORECASE,
|
| 544 |
)
|
| 545 |
|
| 546 |
-
# All column-header keywords recognised across any bank statement format.
|
| 547 |
_HEADER_KW_PATTERNS = [
|
| 548 |
-
# Date variants
|
| 549 |
r"DATE",
|
| 550 |
r"POSTING\s+DATE",
|
| 551 |
r"VALUE\s+DATE",
|
|
@@ -553,7 +548,6 @@ _HEADER_KW_PATTERNS = [
|
|
| 553 |
r"TRANSACTION\s+DATE",
|
| 554 |
r"ENTRY\s+DATE",
|
| 555 |
r"EFFECTIVE\s+DATE",
|
| 556 |
-
# Transaction ID / reference
|
| 557 |
r"TRANSACTION(?:\s+(?:ID|TYPE|NO|NUMBER))?",
|
| 558 |
r"TXN(?:\s+(?:ID|NO|TYPE))?",
|
| 559 |
r"REF(?:ERENCE)?(?:\s*(?:NO|NUM|NUMBER))?",
|
|
@@ -562,30 +556,25 @@ _HEADER_KW_PATTERNS = [
|
|
| 562 |
r"VOUCHER(?:\s*(?:NO|NUMBER))?",
|
| 563 |
r"SR\.?\s*NO\.?",
|
| 564 |
r"SERIAL(?:\s*(?:NO|NUMBER))?",
|
| 565 |
-
# Description variants
|
| 566 |
r"DESCRIPTION",
|
| 567 |
r"DETAILS?",
|
| 568 |
r"PARTICULARS?(?:\s+OF\s+TRANSACTION)?",
|
| 569 |
r"NARRATION",
|
| 570 |
r"REMARKS?",
|
| 571 |
r"NOTES?",
|
| 572 |
-
# Debit variants
|
| 573 |
r"DEBIT",
|
| 574 |
r"DEBITS?",
|
| 575 |
r"DR\.?",
|
| 576 |
r"WITHDRAWALS?",
|
| 577 |
r"PAID\s+OUT",
|
| 578 |
r"MONEY\s+OUT",
|
| 579 |
-
# Credit variants
|
| 580 |
r"CREDIT",
|
| 581 |
r"CREDITS?",
|
| 582 |
r"CR\.?",
|
| 583 |
r"DEPOSITS?",
|
| 584 |
r"PAID\s+IN",
|
| 585 |
r"MONEY\s+IN",
|
| 586 |
-
# Amount
|
| 587 |
r"AMOUNT",
|
| 588 |
-
# Balance variants
|
| 589 |
r"BALANCE",
|
| 590 |
r"BAL\.?",
|
| 591 |
r"RUNNING\s+BALANCE",
|
|
@@ -601,20 +590,16 @@ _HEADER_KW_PATTERNS = [
|
|
| 601 |
r"OPENING\s+BAL\.?",
|
| 602 |
]
|
| 603 |
|
| 604 |
-
# Pre-compiled: each pattern anchored at start, case-insensitive
|
| 605 |
_HEADER_KW_RES = [
|
| 606 |
re.compile(r"^(" + p + r")(.*)", re.IGNORECASE | re.DOTALL)
|
| 607 |
for p in _HEADER_KW_PATTERNS
|
| 608 |
]
|
| 609 |
|
| 610 |
-
# Quick exact-match check: "is this entire string a known keyword?"
|
| 611 |
_HEADER_KW_EXACT_RE = re.compile(
|
| 612 |
r"^(?:" + r"|".join(_HEADER_KW_PATTERNS) + r")$",
|
| 613 |
re.IGNORECASE,
|
| 614 |
)
|
| 615 |
|
| 616 |
-
# Minimum fraction of cells in a row that must be pure keywords for that row
|
| 617 |
-
# to be considered a misplaced header row.
|
| 618 |
_HEADER_ROW_KEYWORD_THRESHOLD = 0.5
|
| 619 |
|
| 620 |
# --------------------------
|
|
@@ -1117,6 +1102,11 @@ def _patch_ocr_with_textlayer(page_md: str, pdf_path: str, page_num: int) -> str
|
|
| 1117 |
key = (_norm(r["date"]), _norm(r["desc"]), _norm(r["amount"]))
|
| 1118 |
tl_freq[key] = tl_freq.get(key, 0) + 1
|
| 1119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1120 |
table_pattern = re.compile(r"<table[^>]*>.*?</table>", re.DOTALL | re.IGNORECASE)
|
| 1121 |
result = page_md
|
| 1122 |
|
|
@@ -1291,6 +1281,9 @@ def _patch_ocr_with_textlayer(page_md: str, pdf_path: str, page_num: int) -> str
|
|
| 1291 |
except Exception as e:
|
| 1292 |
log.warning("_patch_ocr_with_textlayer failed (page %d): %s", page_num, e)
|
| 1293 |
return page_md
|
|
|
|
|
|
|
|
|
|
| 1294 |
|
| 1295 |
# --------------------------
|
| 1296 |
# Fix 5: Mid-table separator row reconstruction
|
|
@@ -2736,6 +2729,15 @@ _DA3_MONEY_TOKEN = re.compile(
|
|
| 2736 |
r"(?<![\d,])(-?\$?\d{1,3}(?:,\d{3})*\.\d{2}|-?\$?\d{2,6}\.\d{2})(?!\d)"
|
| 2737 |
)
|
| 2738 |
_MSFT_PIN_G_REF = re.compile(r"MICROSOFT#(G\d{6,16})", re.IGNORECASE)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2739 |
_DA3_LEADING_DATE = re.compile(r"^\d{1,2}/\d{1,2}(?:/\d{2,4})?$")
|
| 2740 |
|
| 2741 |
|
|
@@ -2824,7 +2826,15 @@ def _dedupe_duplicate_rows_in_da3_table(grid):
|
|
| 2824 |
break
|
| 2825 |
j += 1
|
| 2826 |
run_len = j - i
|
| 2827 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2828 |
for t in range(take):
|
| 2829 |
out.append(data_rows[i + t])
|
| 2830 |
i = j
|
|
@@ -3128,6 +3138,110 @@ def _trim_adjacent_da3_suffix_duplicating_next_table_prefix(text: str) -> str:
|
|
| 3128 |
return text
|
| 3129 |
|
| 3130 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3131 |
def _dedupe_subset_transaction_tables_html(text: str) -> str:
|
| 3132 |
if not text or "<table" not in text.lower():
|
| 3133 |
return text
|
|
@@ -3374,28 +3488,20 @@ def _drop_da3_misplaced_debit_after_deposit_heading_tables(text: str) -> str:
|
|
| 3374 |
# Fix 28: Strip credit-row prefix from debit DA3 table
|
| 3375 |
# --------------------------
|
| 3376 |
|
| 3377 |
-
# Keywords that identify a section heading as debit/withdrawal-oriented.
|
| 3378 |
_DEBIT_HEADING_KEYWORDS = re.compile(
|
| 3379 |
r"\b(withdrawal|withdrawals|debit|debits|charges?|subtraction|subtractions|"
|
| 3380 |
r"payments?\s+made|money\s+out|paid\s+out|electronic\s+debit)\b",
|
| 3381 |
re.IGNORECASE,
|
| 3382 |
)
|
| 3383 |
-
# Keywords that identify a section heading as credit/deposit-oriented.
|
| 3384 |
_CREDIT_HEADING_KEYWORDS = re.compile(
|
| 3385 |
r"\b(deposit|deposits|credit|credits|addition|additions|interest\s+paid|"
|
| 3386 |
r"money\s+in|paid\s+in|incoming|received)\b",
|
| 3387 |
re.IGNORECASE,
|
| 3388 |
)
|
| 3389 |
-
# How many characters before a table start to scan for a section heading.
|
| 3390 |
_HEADING_SCAN_WINDOW = 600
|
| 3391 |
|
| 3392 |
|
| 3393 |
def _get_heading_before_table(text: str, table_start: int) -> str:
|
| 3394 |
-
"""
|
| 3395 |
-
Return the last non-trivial text line in the _HEADING_SCAN_WINDOW chars
|
| 3396 |
-
that precede `table_start`. HTML tags are stripped before scanning so that
|
| 3397 |
-
centered <div> headings are picked up correctly.
|
| 3398 |
-
"""
|
| 3399 |
window = text[max(0, table_start - _HEADING_SCAN_WINDOW): table_start]
|
| 3400 |
clean = re.sub(r"<[^>]+>", " ", window)
|
| 3401 |
lines = [ln.strip() for ln in clean.splitlines() if ln.strip()]
|
|
@@ -3426,7 +3532,6 @@ def _strip_credits_prefix_from_debit_da3_table(text: str) -> str:
|
|
| 3426 |
if len(matches) < 2:
|
| 3427 |
return text
|
| 3428 |
|
| 3429 |
-
# Collect all DA3 tables with their heading context.
|
| 3430 |
da3_tables = []
|
| 3431 |
for m in matches:
|
| 3432 |
g = _parse_da3_grid_from_table_html(m.group(0))
|
|
@@ -3446,10 +3551,7 @@ def _strip_credits_prefix_from_debit_da3_table(text: str) -> str:
|
|
| 3446 |
keys.add(_transaction_row_dedupe_key(cells))
|
| 3447 |
return keys
|
| 3448 |
|
| 3449 |
-
|
| 3450 |
-
# nearest DA3 table preceded by a credit heading, and measure how many
|
| 3451 |
-
# of the debit table's leading rows appear in that credit table.
|
| 3452 |
-
drop_map = {} # table match start -> number of rows to strip
|
| 3453 |
for i in range(1, len(da3_tables)):
|
| 3454 |
ti = da3_tables[i]
|
| 3455 |
hi = ti["heading"].lower()
|
|
@@ -3468,7 +3570,6 @@ def _strip_credits_prefix_from_debit_da3_table(text: str) -> str:
|
|
| 3468 |
keys_j = _row_keys(gj)
|
| 3469 |
if not keys_j:
|
| 3470 |
continue
|
| 3471 |
-
# Count the contiguous leading rows of data_i that are in keys_j.
|
| 3472 |
strip_count = 0
|
| 3473 |
for row in data_i:
|
| 3474 |
cells = [str(c or "").strip() for c in row]
|
|
@@ -3479,7 +3580,6 @@ def _strip_credits_prefix_from_debit_da3_table(text: str) -> str:
|
|
| 3479 |
break
|
| 3480 |
if strip_count < 1:
|
| 3481 |
continue
|
| 3482 |
-
# Guard: at least 1 data row must survive.
|
| 3483 |
if len(data_i) - strip_count < 1:
|
| 3484 |
continue
|
| 3485 |
drop_map[ti["match"].start()] = strip_count
|
|
@@ -3487,12 +3587,11 @@ def _strip_credits_prefix_from_debit_da3_table(text: str) -> str:
|
|
| 3487 |
"Fix 28: stripped %d leading credit row(s) from debit table at offset %d",
|
| 3488 |
strip_count, ti["match"].start(),
|
| 3489 |
)
|
| 3490 |
-
break
|
| 3491 |
|
| 3492 |
if not drop_map:
|
| 3493 |
return text
|
| 3494 |
|
| 3495 |
-
# Apply the strip.
|
| 3496 |
out_chunks = []
|
| 3497 |
last = 0
|
| 3498 |
for m in matches:
|
|
@@ -3520,32 +3619,17 @@ def _strip_credits_prefix_from_debit_da3_table(text: str) -> str:
|
|
| 3520 |
# Fix 29: Move misplaced debit heading to before its orphan DA3 table
|
| 3521 |
# --------------------------
|
| 3522 |
|
| 3523 |
-
# Centered div heading pattern (BoA / generic style)
|
| 3524 |
_CENTER_DIV_HDR_RE = re.compile(
|
| 3525 |
r'<div\s+align=["\']center["\']\s*>\s*(.*?)\s*</div>',
|
| 3526 |
re.IGNORECASE | re.DOTALL,
|
| 3527 |
)
|
| 3528 |
-
_HEADING_SCAN_FORWARD = 800
|
| 3529 |
|
| 3530 |
|
| 3531 |
def _fix29_move_debit_heading_before_orphan_table(text: str) -> str:
|
| 3532 |
"""
|
| 3533 |
Fix 29: When OCR places a debit/withdrawal section heading AFTER the withdrawals table
|
| 3534 |
instead of before it, move the heading to its correct position (just before the table).
|
| 3535 |
-
|
| 3536 |
-
Pattern detected:
|
| 3537 |
-
[credit/deposit DA3 table] ...no debit heading... [orphan DA3 table] [debit heading]
|
| 3538 |
-
where no other DA3 table separates the orphan table from the debit heading.
|
| 3539 |
-
|
| 3540 |
-
Action: move the debit heading to just before the orphan DA3 table.
|
| 3541 |
-
|
| 3542 |
-
Safety guards (ALL must pass):
|
| 3543 |
-
1. Orphan table must be a valid DA3 (Date|Description|Amount).
|
| 3544 |
-
2. A debit heading must appear AFTER the orphan table within _HEADING_SCAN_FORWARD chars.
|
| 3545 |
-
3. No debit heading appears immediately before the orphan table (within _HEADING_SCAN_WINDOW).
|
| 3546 |
-
4. A credit/deposit heading must exist before the orphan table (within 1200 chars).
|
| 3547 |
-
5. No other table appears between the orphan table end and the debit heading.
|
| 3548 |
-
6. Entire function wrapped in try/except -- returns input unchanged on any error.
|
| 3549 |
"""
|
| 3550 |
if not text or "<table" not in text.lower():
|
| 3551 |
return text
|
|
@@ -3566,7 +3650,6 @@ def _fix29_move_debit_heading_before_orphan_table(text: str) -> str:
|
|
| 3566 |
orphan_start = orphan_m.start()
|
| 3567 |
orphan_end = orphan_m.end()
|
| 3568 |
|
| 3569 |
-
# Guard 3: no debit heading already before the orphan table
|
| 3570 |
before_window = text[max(0, orphan_start - _HEADING_SCAN_WINDOW): orphan_start]
|
| 3571 |
before_clean = re.sub(r"<[^>]+>", " ", before_window)
|
| 3572 |
lines_before = [l.strip() for l in before_clean.splitlines() if l.strip()]
|
|
@@ -3576,24 +3659,20 @@ def _fix29_move_debit_heading_before_orphan_table(text: str) -> str:
|
|
| 3576 |
last_meaningful = l
|
| 3577 |
break
|
| 3578 |
if _DEBIT_HEADING_KEYWORDS.search(last_meaningful):
|
| 3579 |
-
continue
|
| 3580 |
|
| 3581 |
-
# Guard 4: credit context must precede the orphan table
|
| 3582 |
big_before = text[max(0, orphan_start - 1200): orphan_start]
|
| 3583 |
if not _CREDIT_HEADING_KEYWORDS.search(re.sub(r"<[^>]+>", " ", big_before)):
|
| 3584 |
continue
|
| 3585 |
|
| 3586 |
-
# Guard 5: no other table between orphan end and the potential debit heading
|
| 3587 |
next_tbl_m = tbl_pat.search(text, orphan_end)
|
| 3588 |
|
| 3589 |
-
# Guard 2: find debit heading after the orphan table (within forward window)
|
| 3590 |
dh_start = dh_end = None
|
| 3591 |
for div_m in _CENTER_DIV_HDR_RE.finditer(text, orphan_end, orphan_end + _HEADING_SCAN_FORWARD):
|
| 3592 |
div_clean = re.sub(r"<[^>]+>", "", div_m.group(1)).strip()
|
| 3593 |
if _DEBIT_HEADING_KEYWORDS.search(div_clean) and not _CREDIT_HEADING_KEYWORDS.search(div_clean):
|
| 3594 |
-
# Guard 5: no table should be between orphan_end and this heading
|
| 3595 |
if next_tbl_m and next_tbl_m.start() < div_m.start():
|
| 3596 |
-
break
|
| 3597 |
dh_start = div_m.start()
|
| 3598 |
dh_end = div_m.end()
|
| 3599 |
break
|
|
@@ -3606,7 +3685,6 @@ def _fix29_move_debit_heading_before_orphan_table(text: str) -> str:
|
|
| 3606 |
if not moves:
|
| 3607 |
return text
|
| 3608 |
|
| 3609 |
-
# Apply moves in reverse order to preserve offsets
|
| 3610 |
for orphan_start, dh_start, dh_end in reversed(moves):
|
| 3611 |
orphan_m2 = next((m for m in tbl_pat.finditer(text) if m.start() == orphan_start), None)
|
| 3612 |
if not orphan_m2:
|
|
@@ -3655,6 +3733,7 @@ def stabilize_tables_and_text(page_md: str) -> str:
|
|
| 3655 |
stabilized = _split_ucb_deposits_electronic_sections_html(stabilized)
|
| 3656 |
stabilized = _patch_da3_msft_g_ref_amounts_across_tables(stabilized)
|
| 3657 |
stabilized = _dedupe_subset_transaction_tables_html(stabilized)
|
|
|
|
| 3658 |
stabilized = _drop_da3_misplaced_debit_after_deposit_heading_tables(stabilized)
|
| 3659 |
stabilized = _strip_credits_prefix_from_debit_da3_table(stabilized) # Fix 28
|
| 3660 |
stabilized = _fix29_move_debit_heading_before_orphan_table(stabilized) # Fix 29
|
|
@@ -3783,6 +3862,7 @@ def run_ocr(uploaded_file):
|
|
| 3783 |
stabilized = _split_ucb_deposits_electronic_sections_html(stabilized)
|
| 3784 |
stabilized = _patch_da3_msft_g_ref_amounts_across_tables(stabilized)
|
| 3785 |
stabilized = _dedupe_subset_transaction_tables_html(stabilized)
|
|
|
|
| 3786 |
stabilized = _drop_da3_misplaced_debit_after_deposit_heading_tables(stabilized)
|
| 3787 |
stabilized = _strip_credits_prefix_from_debit_da3_table(stabilized) # Fix 28
|
| 3788 |
stabilized = _fix29_move_debit_heading_before_orphan_table(stabilized) # Fix 29
|
|
@@ -3859,6 +3939,7 @@ def run_ocr(uploaded_file):
|
|
| 3859 |
merged = _split_ucb_deposits_electronic_sections_html(merged)
|
| 3860 |
merged = _patch_da3_msft_g_ref_amounts_across_tables(merged)
|
| 3861 |
merged = _dedupe_subset_transaction_tables_html(merged)
|
|
|
|
| 3862 |
merged = _drop_da3_misplaced_debit_after_deposit_heading_tables(merged)
|
| 3863 |
merged = _strip_credits_prefix_from_debit_da3_table(merged) # Fix 28
|
| 3864 |
merged = _fix29_move_debit_heading_before_orphan_table(merged) # Fix 29
|
|
|
|
| 58 |
- Fix 28: Strip leading credit/deposit rows that OCR prepends to a withdrawals/debits DA3 table
|
| 59 |
when the preceding DA3 table was under a deposits/credits heading. Only strips the
|
| 60 |
contiguous prefix of the debit table whose rows appear in the credit table; requires
|
| 61 |
+
>=1 data row to remain; entire function wrapped in try/except (safe no-op on any error).
|
| 62 |
- Fix 29: Move a misplaced debit/withdrawal heading to before its orphan DA3 table when OCR
|
| 63 |
places the heading AFTER the table instead of before it. Pattern detected:
|
| 64 |
[credit heading+table] ... [orphan DA3 table] ... [debit heading] — the heading is
|
| 65 |
relocated to just before the orphan table. Guards: no debit heading already before
|
| 66 |
table; credit context must precede table; no other table between orphan and heading;
|
| 67 |
try/except wrapper (safe no-op on any error).
|
| 68 |
+
- Fix 20-override: _dedupe_duplicate_rows_in_da3_table now uses the PDF text-layer frequency
|
| 69 |
+
map (_tl_freq_context) as its cap so legitimate repeated transactions (e.g. 6x
|
| 70 |
+
Temporary Credit Reversal on the same date/amount) are preserved rather than
|
| 71 |
+
being collapsed to 2. Keys absent from the text layer still cap at 2 (OCR stutter
|
| 72 |
+
behaviour unchanged). Safe no-op for scanned PDFs with no text layer.
|
| 73 |
+
- Fix 30: Drop small DA3 fragments (1-5 rows) that are a loose (date+amount) subset of an
|
| 74 |
+
earlier larger DA3 table — catches OCR-truncated description duplicates such as
|
| 75 |
+
the BoA service-fee page where a VIDDYOZE -1.11 fragment table appears after the
|
| 76 |
+
full service-fee table with a longer description string.
|
| 77 |
"""
|
| 78 |
|
| 79 |
# Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
|
|
|
|
| 441 |
return re.fullmatch(r"\$?-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?", s.strip()) is not None
|
| 442 |
|
| 443 |
def _drop_truly_empty_columns(grid):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
if not grid or len(grid) < 1:
|
| 445 |
return grid
|
| 446 |
|
|
|
|
| 475 |
return new_grid
|
| 476 |
|
| 477 |
def _merge_blank_header_text_columns(grid):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
if not grid or len(grid) < 2:
|
| 479 |
return grid
|
| 480 |
|
|
|
|
| 535 |
# Shared keyword definitions
|
| 536 |
# --------------------------
|
| 537 |
|
|
|
|
|
|
|
| 538 |
_MONEY_RE = re.compile(
|
| 539 |
r"^(?:[\$£€¥]|Rs\.?|INR|PKR)?\s*-?\s?\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$",
|
| 540 |
re.IGNORECASE,
|
| 541 |
)
|
| 542 |
|
|
|
|
| 543 |
_HEADER_KW_PATTERNS = [
|
|
|
|
| 544 |
r"DATE",
|
| 545 |
r"POSTING\s+DATE",
|
| 546 |
r"VALUE\s+DATE",
|
|
|
|
| 548 |
r"TRANSACTION\s+DATE",
|
| 549 |
r"ENTRY\s+DATE",
|
| 550 |
r"EFFECTIVE\s+DATE",
|
|
|
|
| 551 |
r"TRANSACTION(?:\s+(?:ID|TYPE|NO|NUMBER))?",
|
| 552 |
r"TXN(?:\s+(?:ID|NO|TYPE))?",
|
| 553 |
r"REF(?:ERENCE)?(?:\s*(?:NO|NUM|NUMBER))?",
|
|
|
|
| 556 |
r"VOUCHER(?:\s*(?:NO|NUMBER))?",
|
| 557 |
r"SR\.?\s*NO\.?",
|
| 558 |
r"SERIAL(?:\s*(?:NO|NUMBER))?",
|
|
|
|
| 559 |
r"DESCRIPTION",
|
| 560 |
r"DETAILS?",
|
| 561 |
r"PARTICULARS?(?:\s+OF\s+TRANSACTION)?",
|
| 562 |
r"NARRATION",
|
| 563 |
r"REMARKS?",
|
| 564 |
r"NOTES?",
|
|
|
|
| 565 |
r"DEBIT",
|
| 566 |
r"DEBITS?",
|
| 567 |
r"DR\.?",
|
| 568 |
r"WITHDRAWALS?",
|
| 569 |
r"PAID\s+OUT",
|
| 570 |
r"MONEY\s+OUT",
|
|
|
|
| 571 |
r"CREDIT",
|
| 572 |
r"CREDITS?",
|
| 573 |
r"CR\.?",
|
| 574 |
r"DEPOSITS?",
|
| 575 |
r"PAID\s+IN",
|
| 576 |
r"MONEY\s+IN",
|
|
|
|
| 577 |
r"AMOUNT",
|
|
|
|
| 578 |
r"BALANCE",
|
| 579 |
r"BAL\.?",
|
| 580 |
r"RUNNING\s+BALANCE",
|
|
|
|
| 590 |
r"OPENING\s+BAL\.?",
|
| 591 |
]
|
| 592 |
|
|
|
|
| 593 |
_HEADER_KW_RES = [
|
| 594 |
re.compile(r"^(" + p + r")(.*)", re.IGNORECASE | re.DOTALL)
|
| 595 |
for p in _HEADER_KW_PATTERNS
|
| 596 |
]
|
| 597 |
|
|
|
|
| 598 |
_HEADER_KW_EXACT_RE = re.compile(
|
| 599 |
r"^(?:" + r"|".join(_HEADER_KW_PATTERNS) + r")$",
|
| 600 |
re.IGNORECASE,
|
| 601 |
)
|
| 602 |
|
|
|
|
|
|
|
| 603 |
_HEADER_ROW_KEYWORD_THRESHOLD = 0.5
|
| 604 |
|
| 605 |
# --------------------------
|
|
|
|
| 1102 |
key = (_norm(r["date"]), _norm(r["desc"]), _norm(r["amount"]))
|
| 1103 |
tl_freq[key] = tl_freq.get(key, 0) + 1
|
| 1104 |
|
| 1105 |
+
# Share with _dedupe_duplicate_rows_in_da3_table so it can use
|
| 1106 |
+
# the confirmed count as the cap (Fix-20 override).
|
| 1107 |
+
global _tl_freq_context
|
| 1108 |
+
_tl_freq_context = dict(tl_freq)
|
| 1109 |
+
|
| 1110 |
table_pattern = re.compile(r"<table[^>]*>.*?</table>", re.DOTALL | re.IGNORECASE)
|
| 1111 |
result = page_md
|
| 1112 |
|
|
|
|
| 1281 |
except Exception as e:
|
| 1282 |
log.warning("_patch_ocr_with_textlayer failed (page %d): %s", page_num, e)
|
| 1283 |
return page_md
|
| 1284 |
+
finally:
|
| 1285 |
+
# Always clear so stale frequencies do not affect subsequent pages.
|
| 1286 |
+
_tl_freq_context.clear()
|
| 1287 |
|
| 1288 |
# --------------------------
|
| 1289 |
# Fix 5: Mid-table separator row reconstruction
|
|
|
|
| 2729 |
r"(?<![\d,])(-?\$?\d{1,3}(?:,\d{3})*\.\d{2}|-?\$?\d{2,6}\.\d{2})(?!\d)"
|
| 2730 |
)
|
| 2731 |
_MSFT_PIN_G_REF = re.compile(r"MICROSOFT#(G\d{6,16})", re.IGNORECASE)
|
| 2732 |
+
|
| 2733 |
+
# Module-level text-layer frequency context (Fix-20 override).
|
| 2734 |
+
# Set by _patch_ocr_with_textlayer before post-patch normalize passes;
|
| 2735 |
+
# cleared in a finally clause so it never bleeds across pages.
|
| 2736 |
+
# Keys are (date_norm, desc_norm, amount_norm) tuples matching tl_freq.
|
| 2737 |
+
# When a key is present, _dedupe_duplicate_rows_in_da3_table uses its value
|
| 2738 |
+
# as the cap instead of the default 2, preserving all text-layer-confirmed rows.
|
| 2739 |
+
_tl_freq_context: dict = {}
|
| 2740 |
+
|
| 2741 |
_DA3_LEADING_DATE = re.compile(r"^\d{1,2}/\d{1,2}(?:/\d{2,4})?$")
|
| 2742 |
|
| 2743 |
|
|
|
|
| 2826 |
break
|
| 2827 |
j += 1
|
| 2828 |
run_len = j - i
|
| 2829 |
+
# Cap at 2 by default (Fix 20: collapse OCR stutters) but honour
|
| 2830 |
+
# the text-layer-confirmed count when available, so legitimate
|
| 2831 |
+
# repeated bank transactions (e.g. 6x Temporary Credit Reversal
|
| 2832 |
+
# on the same date/amount) are not incorrectly collapsed to 2.
|
| 2833 |
+
# Keys absent from _tl_freq_context still cap at 2 — safe no-op
|
| 2834 |
+
# for scanned PDFs or pages where no text layer was extracted.
|
| 2835 |
+
tl_confirmed = _tl_freq_context.get(k, 0)
|
| 2836 |
+
cap = max(tl_confirmed, 2) if tl_confirmed > 0 else 2
|
| 2837 |
+
take = min(run_len, cap)
|
| 2838 |
for t in range(take):
|
| 2839 |
out.append(data_rows[i + t])
|
| 2840 |
i = j
|
|
|
|
| 3138 |
return text
|
| 3139 |
|
| 3140 |
|
| 3141 |
+
def _dedupe_da3_by_date_amount_loose(text: str) -> str:
|
| 3142 |
+
"""
|
| 3143 |
+
Fix 30: Drop small DA3 table fragments (1-5 data rows) that are a loose
|
| 3144 |
+
subset of an earlier larger DA3 table, matched by (date, amount) key alone.
|
| 3145 |
+
|
| 3146 |
+
This catches OCR-truncated duplicate rows where description text differs
|
| 3147 |
+
between the fragment and the anchor table, preventing the exact-key dedup
|
| 3148 |
+
in _dedupe_subset_transaction_tables_html from firing.
|
| 3149 |
+
|
| 3150 |
+
Example: BoA service-fee page — VIDDYOZE -1.11 appears in a 1-row
|
| 3151 |
+
fragment table after a full 2-row service-fee table that has the same
|
| 3152 |
+
row with a much longer description string.
|
| 3153 |
+
|
| 3154 |
+
Guards:
|
| 3155 |
+
- Fragment must have 1-5 data rows.
|
| 3156 |
+
- Anchor table must have more data rows than the fragment.
|
| 3157 |
+
- Every (date, amount) pair in the fragment must be covered by the anchor.
|
| 3158 |
+
- Date must be non-empty; amount must look like currency.
|
| 3159 |
+
- Wrapped in try/except (safe no-op on any error).
|
| 3160 |
+
"""
|
| 3161 |
+
if not text or "<table" not in text.lower():
|
| 3162 |
+
return text
|
| 3163 |
+
try:
|
| 3164 |
+
pattern = re.compile(r"<table[^>]*>.*?</table>", re.DOTALL | re.IGNORECASE)
|
| 3165 |
+
matches = list(pattern.finditer(text))
|
| 3166 |
+
if len(matches) < 2:
|
| 3167 |
+
return text
|
| 3168 |
+
|
| 3169 |
+
_DATE_NONEMPTY = re.compile(r"^\d{1,2}[/\-]\d{2}")
|
| 3170 |
+
_AMT_NONEMPTY = re.compile(r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})-?$")
|
| 3171 |
+
|
| 3172 |
+
def _da_key(cells):
|
| 3173 |
+
"""Loose (date_norm, amount_norm) key — description ignored."""
|
| 3174 |
+
parts = [str(c or "").strip() for c in cells[:3]]
|
| 3175 |
+
while len(parts) < 3:
|
| 3176 |
+
parts.append("")
|
| 3177 |
+
date_s = re.sub(r"\s+", "", parts[0])
|
| 3178 |
+
amt_s = re.sub(r"[\s$,]", "", parts[2]).lower()
|
| 3179 |
+
amt_s = amt_s.replace("\u2212", "-").replace("\u2013", "-")
|
| 3180 |
+
return (date_s, amt_s)
|
| 3181 |
+
|
| 3182 |
+
# Parse all DA3 tables and collect their (date, amount) key lists.
|
| 3183 |
+
da3_info = []
|
| 3184 |
+
for m in matches:
|
| 3185 |
+
g = _parse_da3_grid_from_table_html(m.group(0))
|
| 3186 |
+
if not g or not _is_da3_header(g) or len(g) < 2:
|
| 3187 |
+
continue
|
| 3188 |
+
keys = []
|
| 3189 |
+
for row in g[1:]:
|
| 3190 |
+
cells = [str(c or "").strip() for c in row]
|
| 3191 |
+
if not any(cells):
|
| 3192 |
+
continue
|
| 3193 |
+
k = _da_key(cells)
|
| 3194 |
+
if _DATE_NONEMPTY.match(k[0]) and _AMT_NONEMPTY.match(k[1]):
|
| 3195 |
+
keys.append(k)
|
| 3196 |
+
da3_info.append({"match": m, "keys": keys, "n": len(keys)})
|
| 3197 |
+
|
| 3198 |
+
if len(da3_info) < 2:
|
| 3199 |
+
return text
|
| 3200 |
+
|
| 3201 |
+
drop_set = set()
|
| 3202 |
+
for i in range(1, len(da3_info)):
|
| 3203 |
+
ti = da3_info[i]
|
| 3204 |
+
if ti["n"] == 0 or ti["n"] > 5:
|
| 3205 |
+
continue
|
| 3206 |
+
if ti["match"].start() in drop_set:
|
| 3207 |
+
continue
|
| 3208 |
+
frag_keys = Counter(ti["keys"])
|
| 3209 |
+
for j in range(i - 1, -1, -1):
|
| 3210 |
+
tj = da3_info[j]
|
| 3211 |
+
if tj["match"].start() in drop_set:
|
| 3212 |
+
continue
|
| 3213 |
+
if tj["n"] <= ti["n"]:
|
| 3214 |
+
continue
|
| 3215 |
+
big_keys = Counter(tj["keys"])
|
| 3216 |
+
# Every (date, amount) pair in the fragment must be covered
|
| 3217 |
+
# by the anchor table (multiset subset check).
|
| 3218 |
+
if all(frag_keys[k] <= big_keys.get(k, 0) for k in frag_keys):
|
| 3219 |
+
drop_set.add(ti["match"].start())
|
| 3220 |
+
log.info(
|
| 3221 |
+
"Fix 30: dropped DA3 fragment (%d rows) as loose "
|
| 3222 |
+
"(date+amount) subset of larger table (%d rows)",
|
| 3223 |
+
ti["n"], tj["n"],
|
| 3224 |
+
)
|
| 3225 |
+
break
|
| 3226 |
+
|
| 3227 |
+
if not drop_set:
|
| 3228 |
+
return text
|
| 3229 |
+
|
| 3230 |
+
out = []
|
| 3231 |
+
last = 0
|
| 3232 |
+
for m in matches:
|
| 3233 |
+
out.append(text[last: m.start()])
|
| 3234 |
+
if m.start() not in drop_set:
|
| 3235 |
+
out.append(m.group(0))
|
| 3236 |
+
last = m.end()
|
| 3237 |
+
out.append(text[last:])
|
| 3238 |
+
return "".join(out)
|
| 3239 |
+
|
| 3240 |
+
except Exception as e:
|
| 3241 |
+
log.warning("_dedupe_da3_by_date_amount_loose failed: %s", e)
|
| 3242 |
+
return text
|
| 3243 |
+
|
| 3244 |
+
|
| 3245 |
def _dedupe_subset_transaction_tables_html(text: str) -> str:
|
| 3246 |
if not text or "<table" not in text.lower():
|
| 3247 |
return text
|
|
|
|
| 3488 |
# Fix 28: Strip credit-row prefix from debit DA3 table
|
| 3489 |
# --------------------------
|
| 3490 |
|
|
|
|
| 3491 |
_DEBIT_HEADING_KEYWORDS = re.compile(
|
| 3492 |
r"\b(withdrawal|withdrawals|debit|debits|charges?|subtraction|subtractions|"
|
| 3493 |
r"payments?\s+made|money\s+out|paid\s+out|electronic\s+debit)\b",
|
| 3494 |
re.IGNORECASE,
|
| 3495 |
)
|
|
|
|
| 3496 |
_CREDIT_HEADING_KEYWORDS = re.compile(
|
| 3497 |
r"\b(deposit|deposits|credit|credits|addition|additions|interest\s+paid|"
|
| 3498 |
r"money\s+in|paid\s+in|incoming|received)\b",
|
| 3499 |
re.IGNORECASE,
|
| 3500 |
)
|
|
|
|
| 3501 |
_HEADING_SCAN_WINDOW = 600
|
| 3502 |
|
| 3503 |
|
| 3504 |
def _get_heading_before_table(text: str, table_start: int) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3505 |
window = text[max(0, table_start - _HEADING_SCAN_WINDOW): table_start]
|
| 3506 |
clean = re.sub(r"<[^>]+>", " ", window)
|
| 3507 |
lines = [ln.strip() for ln in clean.splitlines() if ln.strip()]
|
|
|
|
| 3532 |
if len(matches) < 2:
|
| 3533 |
return text
|
| 3534 |
|
|
|
|
| 3535 |
da3_tables = []
|
| 3536 |
for m in matches:
|
| 3537 |
g = _parse_da3_grid_from_table_html(m.group(0))
|
|
|
|
| 3551 |
keys.add(_transaction_row_dedupe_key(cells))
|
| 3552 |
return keys
|
| 3553 |
|
| 3554 |
+
drop_map = {}
|
|
|
|
|
|
|
|
|
|
| 3555 |
for i in range(1, len(da3_tables)):
|
| 3556 |
ti = da3_tables[i]
|
| 3557 |
hi = ti["heading"].lower()
|
|
|
|
| 3570 |
keys_j = _row_keys(gj)
|
| 3571 |
if not keys_j:
|
| 3572 |
continue
|
|
|
|
| 3573 |
strip_count = 0
|
| 3574 |
for row in data_i:
|
| 3575 |
cells = [str(c or "").strip() for c in row]
|
|
|
|
| 3580 |
break
|
| 3581 |
if strip_count < 1:
|
| 3582 |
continue
|
|
|
|
| 3583 |
if len(data_i) - strip_count < 1:
|
| 3584 |
continue
|
| 3585 |
drop_map[ti["match"].start()] = strip_count
|
|
|
|
| 3587 |
"Fix 28: stripped %d leading credit row(s) from debit table at offset %d",
|
| 3588 |
strip_count, ti["match"].start(),
|
| 3589 |
)
|
| 3590 |
+
break
|
| 3591 |
|
| 3592 |
if not drop_map:
|
| 3593 |
return text
|
| 3594 |
|
|
|
|
| 3595 |
out_chunks = []
|
| 3596 |
last = 0
|
| 3597 |
for m in matches:
|
|
|
|
| 3619 |
# Fix 29: Move misplaced debit heading to before its orphan DA3 table
|
| 3620 |
# --------------------------
|
| 3621 |
|
|
|
|
| 3622 |
_CENTER_DIV_HDR_RE = re.compile(
|
| 3623 |
r'<div\s+align=["\']center["\']\s*>\s*(.*?)\s*</div>',
|
| 3624 |
re.IGNORECASE | re.DOTALL,
|
| 3625 |
)
|
| 3626 |
+
_HEADING_SCAN_FORWARD = 800
|
| 3627 |
|
| 3628 |
|
| 3629 |
def _fix29_move_debit_heading_before_orphan_table(text: str) -> str:
|
| 3630 |
"""
|
| 3631 |
Fix 29: When OCR places a debit/withdrawal section heading AFTER the withdrawals table
|
| 3632 |
instead of before it, move the heading to its correct position (just before the table).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3633 |
"""
|
| 3634 |
if not text or "<table" not in text.lower():
|
| 3635 |
return text
|
|
|
|
| 3650 |
orphan_start = orphan_m.start()
|
| 3651 |
orphan_end = orphan_m.end()
|
| 3652 |
|
|
|
|
| 3653 |
before_window = text[max(0, orphan_start - _HEADING_SCAN_WINDOW): orphan_start]
|
| 3654 |
before_clean = re.sub(r"<[^>]+>", " ", before_window)
|
| 3655 |
lines_before = [l.strip() for l in before_clean.splitlines() if l.strip()]
|
|
|
|
| 3659 |
last_meaningful = l
|
| 3660 |
break
|
| 3661 |
if _DEBIT_HEADING_KEYWORDS.search(last_meaningful):
|
| 3662 |
+
continue
|
| 3663 |
|
|
|
|
| 3664 |
big_before = text[max(0, orphan_start - 1200): orphan_start]
|
| 3665 |
if not _CREDIT_HEADING_KEYWORDS.search(re.sub(r"<[^>]+>", " ", big_before)):
|
| 3666 |
continue
|
| 3667 |
|
|
|
|
| 3668 |
next_tbl_m = tbl_pat.search(text, orphan_end)
|
| 3669 |
|
|
|
|
| 3670 |
dh_start = dh_end = None
|
| 3671 |
for div_m in _CENTER_DIV_HDR_RE.finditer(text, orphan_end, orphan_end + _HEADING_SCAN_FORWARD):
|
| 3672 |
div_clean = re.sub(r"<[^>]+>", "", div_m.group(1)).strip()
|
| 3673 |
if _DEBIT_HEADING_KEYWORDS.search(div_clean) and not _CREDIT_HEADING_KEYWORDS.search(div_clean):
|
|
|
|
| 3674 |
if next_tbl_m and next_tbl_m.start() < div_m.start():
|
| 3675 |
+
break
|
| 3676 |
dh_start = div_m.start()
|
| 3677 |
dh_end = div_m.end()
|
| 3678 |
break
|
|
|
|
| 3685 |
if not moves:
|
| 3686 |
return text
|
| 3687 |
|
|
|
|
| 3688 |
for orphan_start, dh_start, dh_end in reversed(moves):
|
| 3689 |
orphan_m2 = next((m for m in tbl_pat.finditer(text) if m.start() == orphan_start), None)
|
| 3690 |
if not orphan_m2:
|
|
|
|
| 3733 |
stabilized = _split_ucb_deposits_electronic_sections_html(stabilized)
|
| 3734 |
stabilized = _patch_da3_msft_g_ref_amounts_across_tables(stabilized)
|
| 3735 |
stabilized = _dedupe_subset_transaction_tables_html(stabilized)
|
| 3736 |
+
stabilized = _dedupe_da3_by_date_amount_loose(stabilized) # Fix 30
|
| 3737 |
stabilized = _drop_da3_misplaced_debit_after_deposit_heading_tables(stabilized)
|
| 3738 |
stabilized = _strip_credits_prefix_from_debit_da3_table(stabilized) # Fix 28
|
| 3739 |
stabilized = _fix29_move_debit_heading_before_orphan_table(stabilized) # Fix 29
|
|
|
|
| 3862 |
stabilized = _split_ucb_deposits_electronic_sections_html(stabilized)
|
| 3863 |
stabilized = _patch_da3_msft_g_ref_amounts_across_tables(stabilized)
|
| 3864 |
stabilized = _dedupe_subset_transaction_tables_html(stabilized)
|
| 3865 |
+
stabilized = _dedupe_da3_by_date_amount_loose(stabilized) # Fix 30
|
| 3866 |
stabilized = _drop_da3_misplaced_debit_after_deposit_heading_tables(stabilized)
|
| 3867 |
stabilized = _strip_credits_prefix_from_debit_da3_table(stabilized) # Fix 28
|
| 3868 |
stabilized = _fix29_move_debit_heading_before_orphan_table(stabilized) # Fix 29
|
|
|
|
| 3939 |
merged = _split_ucb_deposits_electronic_sections_html(merged)
|
| 3940 |
merged = _patch_da3_msft_g_ref_amounts_across_tables(merged)
|
| 3941 |
merged = _dedupe_subset_transaction_tables_html(merged)
|
| 3942 |
+
merged = _dedupe_da3_by_date_amount_loose(merged) # Fix 30
|
| 3943 |
merged = _drop_da3_misplaced_debit_after_deposit_heading_tables(merged)
|
| 3944 |
merged = _strip_credits_prefix_from_debit_da3_table(merged) # Fix 28
|
| 3945 |
merged = _fix29_move_debit_heading_before_orphan_table(merged) # Fix 29
|