Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -36,11 +36,16 @@ Primary goals for reconcile rate:
|
|
| 36 |
debit pattern) and at least one ITM DEPOSIT row exists, OCR stacked headings wrong — emit
|
| 37 |
Electronic Credits, then the table, then Electronic Debits (section continues below). Safe no-op
|
| 38 |
if the Debits heading was not before the table or any row looks like a card/ATM debit.
|
|
|
|
|
|
|
| 39 |
- Fix 17c-html: Same as 17c using raw HTML cues when TableGridParser/header normalization would skip the table.
|
|
|
|
| 40 |
- Fix 17d: Drop a second Electronic Debits block + DA3 table when its rows are a loose (date+amount)
|
| 41 |
multiset subset of the immediately preceding DA3 table (OCR duplicate tail / wrong split).
|
|
|
|
|
|
|
| 42 |
- Fix 17e: Repeat EC/ED fused + 17d passes until stable (handles chained replacements). Centered section titles
|
| 43 |
-
match both align=
|
| 44 |
- Post-pass: dedupe 3-col Date|Description|Amount tables when one is a duplicate fragment
|
| 45 |
(later subset of earlier, earlier subset of later, or identical row sets)
|
| 46 |
- Fix 18: adjacent DA3 tables — if the last K data rows of table N match the first K rows of table N+1
|
|
@@ -87,6 +92,9 @@ Primary goals for reconcile rate:
|
|
| 87 |
earlier larger DA3 table — catches OCR-truncated description duplicates such as
|
| 88 |
the BoA service-fee page where a VIDDYOZE -1.11 fragment table appears after the
|
| 89 |
full service-fee table with a longer description string.
|
|
|
|
|
|
|
|
|
|
| 90 |
"""
|
| 91 |
|
| 92 |
# Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
|
|
@@ -1115,10 +1123,6 @@ def _patch_ocr_with_textlayer(page_md: str, pdf_path: str, page_num: int) -> str
|
|
| 1115 |
key = (_norm(r["date"]), _norm(r["desc"]), _norm(r["amount"]))
|
| 1116 |
tl_freq[key] = tl_freq.get(key, 0) + 1
|
| 1117 |
|
| 1118 |
-
# Share with _dedupe_duplicate_rows_in_da3_table (Fix-20 override).
|
| 1119 |
-
# Keys MUST match _transaction_row_dedupe_key (date/desc/amount, no spaces in
|
| 1120 |
-
# date/desc; amount stripped of $/,/space). The injection map tl_freq uses
|
| 1121 |
-
# _norm() with spaces — different tuple shape — so we keep a parallel count.
|
| 1122 |
global _tl_freq_context
|
| 1123 |
tl_dedupe = {}
|
| 1124 |
for r in tl_rows:
|
|
@@ -2678,7 +2682,7 @@ def _transaction_row_dedupe_key(cells):
|
|
| 2678 |
date_s = re.sub(r"\s+", "", date_s)
|
| 2679 |
amt_s = re.sub(r"[\s$,]", "", amt_s).lower().replace("−", "-")
|
| 2680 |
desc_s = html.unescape(desc_s)
|
| 2681 |
-
desc_s = desc_s.replace("`", "'").replace("
|
| 2682 |
desc_s = re.sub(r"\s+", "", desc_s.lower())
|
| 2683 |
return (date_s, desc_s, amt_s)
|
| 2684 |
|
|
@@ -2747,11 +2751,6 @@ _DA3_MONEY_TOKEN = re.compile(
|
|
| 2747 |
)
|
| 2748 |
_MSFT_PIN_G_REF = re.compile(r"MICROSOFT#(G\d{6,16})", re.IGNORECASE)
|
| 2749 |
|
| 2750 |
-
# Module-level text-layer frequency context (Fix-20 override).
|
| 2751 |
-
# Set by _patch_ocr_with_textlayer; consumed by normalize_html_tables in run_ocr's
|
| 2752 |
-
# post-patch pass. Cleared at the start of stabilize_tables_and_text (new page)
|
| 2753 |
-
# and on patch failure so keys never bleed across pages.
|
| 2754 |
-
# Keys match _transaction_row_dedupe_key (not the _norm()-based tl_freq map).
|
| 2755 |
_tl_freq_context: dict = {}
|
| 2756 |
|
| 2757 |
_DA3_LEADING_DATE = re.compile(r"^\d{1,2}/\d{1,2}(?:/\d{2,4})?$")
|
|
@@ -2842,12 +2841,6 @@ def _dedupe_duplicate_rows_in_da3_table(grid):
|
|
| 2842 |
break
|
| 2843 |
j += 1
|
| 2844 |
run_len = j - i
|
| 2845 |
-
# Cap at 2 by default (Fix 20: collapse OCR stutters) but honour
|
| 2846 |
-
# the text-layer-confirmed count when available, so legitimate
|
| 2847 |
-
# repeated bank transactions (e.g. 6x Temporary Credit Reversal
|
| 2848 |
-
# on the same date/amount) are not incorrectly collapsed to 2.
|
| 2849 |
-
# Keys absent from _tl_freq_context still cap at 2 — safe no-op
|
| 2850 |
-
# for scanned PDFs or pages where no text layer was extracted.
|
| 2851 |
tl_confirmed = _tl_freq_context.get(k, 0)
|
| 2852 |
cap = max(tl_confirmed, 2) if tl_confirmed > 0 else 2
|
| 2853 |
take = min(run_len, cap)
|
|
@@ -2942,10 +2935,38 @@ def _ucb_has_itm_deposit_data_row(grid) -> bool:
|
|
| 2942 |
return False
|
| 2943 |
|
| 2944 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2945 |
def _ucb_table_credits_only_no_strong_debits(grid) -> bool:
|
| 2946 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2947 |
if not grid or len(grid) < 2 or not _is_da3_header(grid):
|
| 2948 |
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2949 |
has_data = False
|
| 2950 |
for r in range(1, len(grid)):
|
| 2951 |
row_txt = _row_text_full_ucb(grid, r).strip()
|
|
@@ -2961,6 +2982,9 @@ def _ucb_html_table_body_looks_itm_credits_only(table_html: str) -> bool:
|
|
| 2961 |
"""
|
| 2962 |
Detect ITM-only credit block without relying on parsed grid headers (Fix 17c-html).
|
| 2963 |
Conservative: require ITM deposit text and absence of common electronic-debit verbs in the HTML.
|
|
|
|
|
|
|
|
|
|
| 2964 |
"""
|
| 2965 |
if not table_html or "<table" not in table_html.lower():
|
| 2966 |
return False
|
|
@@ -2971,6 +2995,30 @@ def _ucb_html_table_body_looks_itm_credits_only(table_html: str) -> bool:
|
|
| 2971 |
return False
|
| 2972 |
if "recur payment" in low or "debit card" in low:
|
| 2973 |
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2974 |
return True
|
| 2975 |
|
| 2976 |
|
|
@@ -3028,6 +3076,7 @@ def _try_split_ucb_ec_ed_from_fused_table_html(
|
|
| 3028 |
|
| 3029 |
# Fix 17c: headings stacked as EC / ED / table but rows are all credits (e.g. ITM-only) —
|
| 3030 |
# put the table under Electronic Credits, then leave Electronic Debits for continuation.
|
|
|
|
| 3031 |
if had_ed_heading_immediately_before_table and _ucb_table_credits_only_no_strong_debits(grid):
|
| 3032 |
sect = '<div align="center">\n\n{title}\n\n</div>\n\n'
|
| 3033 |
parts = []
|
|
@@ -3043,6 +3092,7 @@ def _try_split_ucb_ec_ed_from_fused_table_html(
|
|
| 3043 |
return "\n\n".join(parts)
|
| 3044 |
|
| 3045 |
# Fix 17c-html: same reorder when grid header checks fail but raw HTML is clearly ITM-only credits.
|
|
|
|
| 3046 |
if had_ed_heading_immediately_before_table and _ucb_html_table_body_looks_itm_credits_only(table_html):
|
| 3047 |
sect = '<div align="center">\n\n{title}\n\n</div>\n\n'
|
| 3048 |
parts = []
|
|
@@ -3069,8 +3119,6 @@ _UCB_CENTER_DIV = (
|
|
| 3069 |
r')'
|
| 3070 |
)
|
| 3071 |
|
| 3072 |
-
# Optional empty centered banner only; EC/ED title divs are re-emitted after a successful split
|
| 3073 |
-
# so we do not duplicate headings.
|
| 3074 |
_UCB_EC_ED_SINGLE_FUSED_TABLE = re.compile(
|
| 3075 |
r'(?P<lead>(?:' + _UCB_CENTER_DIV + r'\s*</div>\s*)?)'
|
| 3076 |
r'(?P<ecdiv>' + _UCB_CENTER_DIV + r'\s*Electronic\s+Credits\s*</div>\s*)'
|
|
@@ -3080,10 +3128,32 @@ _UCB_EC_ED_SINGLE_FUSED_TABLE = re.compile(
|
|
| 3080 |
)
|
| 3081 |
|
| 3082 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3083 |
def _ucb_strip_redundant_ed_subset_fragment(text: str) -> str:
|
| 3084 |
"""
|
| 3085 |
Fix 17d: Remove a duplicate <div>Electronic Debits</div> + <table> when that table's rows are a
|
| 3086 |
loose (date, amount) multiset subset of the DA3 table that appears immediately before this block.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3087 |
"""
|
| 3088 |
if not text or "electronic debits" not in text.lower():
|
| 3089 |
return text
|
|
@@ -3095,7 +3165,6 @@ def _ucb_strip_redundant_ed_subset_fragment(text: str) -> str:
|
|
| 3095 |
)
|
| 3096 |
tbl_pat = re.compile(r'<table[^>]*>.*?</table>', re.DOTALL | re.IGNORECASE)
|
| 3097 |
_DATE_NONEMPTY = re.compile(r"^\d{1,2}[/\-]\d{2}")
|
| 3098 |
-
# Stripped amounts may be "2194.19" (no comma) — must not use comma-group regex.
|
| 3099 |
_AMT_STRIPPED_OK = re.compile(r"^-?\d+(?:\.\d{1,4})?-?$")
|
| 3100 |
|
| 3101 |
def _loose_da_key_cells(cells):
|
|
@@ -3144,6 +3213,14 @@ def _ucb_strip_redundant_ed_subset_fragment(text: str) -> str:
|
|
| 3144 |
cb, cf = Counter(kb), Counter(kf)
|
| 3145 |
if not all(cf[k] <= cb.get(k, 0) for k in cf):
|
| 3146 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3147 |
text = text[: m.start()] + text[m.end() :]
|
| 3148 |
changed = True
|
| 3149 |
break
|
|
@@ -3428,21 +3505,6 @@ def _dedupe_da3_by_date_amount_loose(text: str) -> str:
|
|
| 3428 |
"""
|
| 3429 |
Fix 30: Drop small DA3 table fragments (1-5 data rows) that are a loose
|
| 3430 |
subset of an earlier larger DA3 table, matched by (date, amount) key alone.
|
| 3431 |
-
|
| 3432 |
-
This catches OCR-truncated duplicate rows where description text differs
|
| 3433 |
-
between the fragment and the anchor table, preventing the exact-key dedup
|
| 3434 |
-
in _dedupe_subset_transaction_tables_html from firing.
|
| 3435 |
-
|
| 3436 |
-
Example: BoA service-fee page — VIDDYOZE -1.11 appears in a 1-row
|
| 3437 |
-
fragment table after a full 2-row service-fee table that has the same
|
| 3438 |
-
row with a much longer description string.
|
| 3439 |
-
|
| 3440 |
-
Guards:
|
| 3441 |
-
- Fragment must have 1-5 data rows.
|
| 3442 |
-
- Anchor table must have more data rows than the fragment.
|
| 3443 |
-
- Every (date, amount) pair in the fragment must be covered by the anchor.
|
| 3444 |
-
- Date must be non-empty; amount must look like currency.
|
| 3445 |
-
- Wrapped in try/except (safe no-op on any error).
|
| 3446 |
"""
|
| 3447 |
if not text or "<table" not in text.lower():
|
| 3448 |
return text
|
|
@@ -3456,7 +3518,6 @@ def _dedupe_da3_by_date_amount_loose(text: str) -> str:
|
|
| 3456 |
_AMT_NONEMPTY = re.compile(r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})-?$")
|
| 3457 |
|
| 3458 |
def _da_key(cells):
|
| 3459 |
-
"""Loose (date_norm, amount_norm) key — description ignored."""
|
| 3460 |
parts = [str(c or "").strip() for c in cells[:3]]
|
| 3461 |
while len(parts) < 3:
|
| 3462 |
parts.append("")
|
|
@@ -3465,7 +3526,6 @@ def _dedupe_da3_by_date_amount_loose(text: str) -> str:
|
|
| 3465 |
amt_s = amt_s.replace("\u2212", "-").replace("\u2013", "-")
|
| 3466 |
return (date_s, amt_s)
|
| 3467 |
|
| 3468 |
-
# Parse all DA3 tables and collect their (date, amount) key lists.
|
| 3469 |
da3_info = []
|
| 3470 |
for m in matches:
|
| 3471 |
g = _parse_da3_grid_from_table_html(m.group(0))
|
|
@@ -3499,8 +3559,6 @@ def _dedupe_da3_by_date_amount_loose(text: str) -> str:
|
|
| 3499 |
if tj["n"] <= ti["n"]:
|
| 3500 |
continue
|
| 3501 |
big_keys = Counter(tj["keys"])
|
| 3502 |
-
# Every (date, amount) pair in the fragment must be covered
|
| 3503 |
-
# by the anchor table (multiset subset check).
|
| 3504 |
if all(frag_keys[k] <= big_keys.get(k, 0) for k in frag_keys):
|
| 3505 |
drop_set.add(ti["match"].start())
|
| 3506 |
log.info(
|
|
@@ -3801,14 +3859,6 @@ def _strip_credits_prefix_from_debit_da3_table(text: str) -> str:
|
|
| 3801 |
"""
|
| 3802 |
Fix 28: When OCR prepends the rows of a preceding credits/deposits DA3 table
|
| 3803 |
onto the top of a debits/withdrawals DA3 table, strip that prefix.
|
| 3804 |
-
Safety guards (ALL must pass):
|
| 3805 |
-
1. Both tables must be valid DA3 (Date|Description|Amount header).
|
| 3806 |
-
2. Target (later) table must follow a debit/withdrawal heading.
|
| 3807 |
-
3. Source (earlier) table must follow a credit/deposit heading.
|
| 3808 |
-
4. Only the contiguous leading prefix of matching rows is removed.
|
| 3809 |
-
5. At least 1 data row must remain in the target after stripping.
|
| 3810 |
-
6. Strip count must be >= 1.
|
| 3811 |
-
7. Entire function wrapped in try/except — returns input unchanged on error.
|
| 3812 |
"""
|
| 3813 |
if not text or "<table" not in text.lower():
|
| 3814 |
return text
|
|
@@ -4000,8 +4050,6 @@ def stabilize_tables_and_text(page_md: str) -> str:
|
|
| 4000 |
if not page_md:
|
| 4001 |
return page_md
|
| 4002 |
|
| 4003 |
-
# Fresh page: drop any text-layer dedupe caps so the first normalize pass
|
| 4004 |
-
# (before _patch_ocr_with_textlayer) does not see stale keys from a prior page.
|
| 4005 |
_tl_freq_context.clear()
|
| 4006 |
|
| 4007 |
page_md = normalize_money_glyphs(page_md)
|
|
@@ -4119,7 +4167,6 @@ def run_ocr(uploaded_file):
|
|
| 4119 |
|
| 4120 |
all_pages = []
|
| 4121 |
for page_num, page_result in enumerate(results):
|
| 4122 |
-
# Header normalize runs before body patch; do not reuse prior page caps.
|
| 4123 |
_tl_freq_context.clear()
|
| 4124 |
page_md, regions = get_page_md_and_regions(page_result)
|
| 4125 |
img_h = page_heights[page_num] if page_num < len(page_heights) else 1000
|
|
|
|
| 36 |
debit pattern) and at least one ITM DEPOSIT row exists, OCR stacked headings wrong — emit
|
| 37 |
Electronic Credits, then the table, then Electronic Debits (section continues below). Safe no-op
|
| 38 |
if the Debits heading was not before the table or any row looks like a card/ATM debit.
|
| 39 |
+
GUARD: Do NOT fire Fix 17c if ALL rows are ITM DEPOSIT — that indicates a Deposits (continued)
|
| 40 |
+
block that was incorrectly preceded by EC/ED headings, not a genuine Electronic Credits table.
|
| 41 |
- Fix 17c-html: Same as 17c using raw HTML cues when TableGridParser/header normalization would skip the table.
|
| 42 |
+
Same ITM-only guard applied.
|
| 43 |
- Fix 17d: Drop a second Electronic Debits block + DA3 table when its rows are a loose (date+amount)
|
| 44 |
multiset subset of the immediately preceding DA3 table (OCR duplicate tail / wrong split).
|
| 45 |
+
GUARD: Do NOT drop a small table if it contains genuine credit-side rows (POS Return,
|
| 46 |
+
Acct Fund INST, DDA Transfer IN) — these are misplaced EC rows, not ED duplicates.
|
| 47 |
- Fix 17e: Repeat EC/ED fused + 17d passes until stable (handles chained replacements). Centered section titles
|
| 48 |
+
match both align="center" and style="text-align:center" on divs.
|
| 49 |
- Post-pass: dedupe 3-col Date|Description|Amount tables when one is a duplicate fragment
|
| 50 |
(later subset of earlier, earlier subset of later, or identical row sets)
|
| 51 |
- Fix 18: adjacent DA3 tables — if the last K data rows of table N match the first K rows of table N+1
|
|
|
|
| 92 |
earlier larger DA3 table — catches OCR-truncated description duplicates such as
|
| 93 |
the BoA service-fee page where a VIDDYOZE -1.11 fragment table appears after the
|
| 94 |
full service-fee table with a longer description string.
|
| 95 |
+
- Fix 17c/17c-html/17d GUARD (UCB page 3): Prevent misclassification of "Deposits (continued)"
|
| 96 |
+
ITM DEPOSIT blocks as Electronic Credits, and prevent Fix 17d from dropping the
|
| 97 |
+
real Electronic Credits table (POS Return / Acct Fund INST / DDA Transfer IN).
|
| 98 |
"""
|
| 99 |
|
| 100 |
# Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
|
|
|
|
| 1123 |
key = (_norm(r["date"]), _norm(r["desc"]), _norm(r["amount"]))
|
| 1124 |
tl_freq[key] = tl_freq.get(key, 0) + 1
|
| 1125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1126 |
global _tl_freq_context
|
| 1127 |
tl_dedupe = {}
|
| 1128 |
for r in tl_rows:
|
|
|
|
| 2682 |
date_s = re.sub(r"\s+", "", date_s)
|
| 2683 |
amt_s = re.sub(r"[\s$,]", "", amt_s).lower().replace("−", "-")
|
| 2684 |
desc_s = html.unescape(desc_s)
|
| 2685 |
+
desc_s = desc_s.replace("`", "'").replace("\u2019", "'")
|
| 2686 |
desc_s = re.sub(r"\s+", "", desc_s.lower())
|
| 2687 |
return (date_s, desc_s, amt_s)
|
| 2688 |
|
|
|
|
| 2751 |
)
|
| 2752 |
_MSFT_PIN_G_REF = re.compile(r"MICROSOFT#(G\d{6,16})", re.IGNORECASE)
|
| 2753 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2754 |
_tl_freq_context: dict = {}
|
| 2755 |
|
| 2756 |
_DA3_LEADING_DATE = re.compile(r"^\d{1,2}/\d{1,2}(?:/\d{2,4})?$")
|
|
|
|
| 2841 |
break
|
| 2842 |
j += 1
|
| 2843 |
run_len = j - i
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2844 |
tl_confirmed = _tl_freq_context.get(k, 0)
|
| 2845 |
cap = max(tl_confirmed, 2) if tl_confirmed > 0 else 2
|
| 2846 |
take = min(run_len, cap)
|
|
|
|
| 2935 |
return False
|
| 2936 |
|
| 2937 |
|
| 2938 |
+
def _ucb_all_rows_are_itm_deposit(grid) -> bool:
|
| 2939 |
+
"""
|
| 2940 |
+
Returns True if ALL data rows (row index >= 1) in a DA3 grid are
|
| 2941 |
+
ITM DEPOSIT rows. Used to guard Fix 17c / 17c-html: a table that is
|
| 2942 |
+
exclusively ITM DEPOSIT entries is a "Deposits (continued)" block,
|
| 2943 |
+
NOT an Electronic Credits table, even when it has no strong-debit rows.
|
| 2944 |
+
"""
|
| 2945 |
+
if not grid or len(grid) < 2:
|
| 2946 |
+
return False
|
| 2947 |
+
data_rows = [grid[r] for r in range(1, len(grid))
|
| 2948 |
+
if any(str(c or "").strip() for c in grid[r])]
|
| 2949 |
+
if not data_rows:
|
| 2950 |
+
return False
|
| 2951 |
+
return all(_is_itm_deposit_row_ucb(grid, r) for r in range(1, len(grid))
|
| 2952 |
+
if any(str(c or "").strip() for c in grid[r]))
|
| 2953 |
+
|
| 2954 |
+
|
| 2955 |
def _ucb_table_credits_only_no_strong_debits(grid) -> bool:
|
| 2956 |
+
"""
|
| 2957 |
+
True if DA3 table has real data rows but none match strong
|
| 2958 |
+
electronic-debit patterns AND at least one ITM DEPOSIT row exists.
|
| 2959 |
+
|
| 2960 |
+
FIX 17c GUARD: Returns False when ALL data rows are ITM DEPOSIT.
|
| 2961 |
+
That case is a "Deposits (continued)" block — not Electronic Credits —
|
| 2962 |
+
so Fix 17c must NOT reorder it under an EC heading.
|
| 2963 |
+
"""
|
| 2964 |
if not grid or len(grid) < 2 or not _is_da3_header(grid):
|
| 2965 |
return False
|
| 2966 |
+
# Guard: if every row is ITM DEPOSIT, this is Deposits (continued),
|
| 2967 |
+
# not Electronic Credits. Do not trigger Fix 17c.
|
| 2968 |
+
if _ucb_all_rows_are_itm_deposit(grid):
|
| 2969 |
+
return False
|
| 2970 |
has_data = False
|
| 2971 |
for r in range(1, len(grid)):
|
| 2972 |
row_txt = _row_text_full_ucb(grid, r).strip()
|
|
|
|
| 2982 |
"""
|
| 2983 |
Detect ITM-only credit block without relying on parsed grid headers (Fix 17c-html).
|
| 2984 |
Conservative: require ITM deposit text and absence of common electronic-debit verbs in the HTML.
|
| 2985 |
+
|
| 2986 |
+
FIX 17c-html GUARD: Returns False when the table contains ONLY ITM DEPOSIT rows
|
| 2987 |
+
(no other description text), as that indicates a Deposits (continued) block.
|
| 2988 |
"""
|
| 2989 |
if not table_html or "<table" not in table_html.lower():
|
| 2990 |
return False
|
|
|
|
| 2995 |
return False
|
| 2996 |
if "recur payment" in low or "debit card" in low:
|
| 2997 |
return False
|
| 2998 |
+
# Additional guard: if every <td> description cell only contains "ITM DEPOSIT"
|
| 2999 |
+
# (no other transaction types), treat this as Deposits (continued), not EC.
|
| 3000 |
+
# Extract description cell texts from <td> elements (skip header row <th>).
|
| 3001 |
+
td_texts = re.findall(r"<td>([^<]*)</td>", table_html, re.IGNORECASE)
|
| 3002 |
+
if td_texts:
|
| 3003 |
+
# td_texts from a DA3 table come in triplets: date, desc, amount
|
| 3004 |
+
# Check if any non-date, non-amount cell has text other than "ITM DEPOSIT"
|
| 3005 |
+
non_itm_desc = False
|
| 3006 |
+
for i, txt in enumerate(td_texts):
|
| 3007 |
+
txt_stripped = txt.strip().lower()
|
| 3008 |
+
if not txt_stripped:
|
| 3009 |
+
continue
|
| 3010 |
+
# Skip date-like tokens and amount-like tokens
|
| 3011 |
+
if re.match(r"^\d{1,2}/\d{1,2}(?:/\d{2,4})?$", txt_stripped):
|
| 3012 |
+
continue
|
| 3013 |
+
if re.match(r"^\$?[\d,]+\.\d{2}$", txt_stripped):
|
| 3014 |
+
continue
|
| 3015 |
+
# It's a description cell
|
| 3016 |
+
if "itm deposit" not in txt_stripped and "itm dep" not in txt_stripped:
|
| 3017 |
+
non_itm_desc = True
|
| 3018 |
+
break
|
| 3019 |
+
if not non_itm_desc:
|
| 3020 |
+
# All description cells are ITM DEPOSIT — this is Deposits (continued)
|
| 3021 |
+
return False
|
| 3022 |
return True
|
| 3023 |
|
| 3024 |
|
|
|
|
| 3076 |
|
| 3077 |
# Fix 17c: headings stacked as EC / ED / table but rows are all credits (e.g. ITM-only) —
|
| 3078 |
# put the table under Electronic Credits, then leave Electronic Debits for continuation.
|
| 3079 |
+
# GUARD: Do NOT fire if all rows are ITM DEPOSIT (that is Deposits continued, not EC).
|
| 3080 |
if had_ed_heading_immediately_before_table and _ucb_table_credits_only_no_strong_debits(grid):
|
| 3081 |
sect = '<div align="center">\n\n{title}\n\n</div>\n\n'
|
| 3082 |
parts = []
|
|
|
|
| 3092 |
return "\n\n".join(parts)
|
| 3093 |
|
| 3094 |
# Fix 17c-html: same reorder when grid header checks fail but raw HTML is clearly ITM-only credits.
|
| 3095 |
+
# GUARD: _ucb_html_table_body_looks_itm_credits_only now returns False for pure ITM DEPOSIT tables.
|
| 3096 |
if had_ed_heading_immediately_before_table and _ucb_html_table_body_looks_itm_credits_only(table_html):
|
| 3097 |
sect = '<div align="center">\n\n{title}\n\n</div>\n\n'
|
| 3098 |
parts = []
|
|
|
|
| 3119 |
r')'
|
| 3120 |
)
|
| 3121 |
|
|
|
|
|
|
|
| 3122 |
_UCB_EC_ED_SINGLE_FUSED_TABLE = re.compile(
|
| 3123 |
r'(?P<lead>(?:' + _UCB_CENTER_DIV + r'\s*</div>\s*)?)'
|
| 3124 |
r'(?P<ecdiv>' + _UCB_CENTER_DIV + r'\s*Electronic\s+Credits\s*</div>\s*)'
|
|
|
|
| 3128 |
)
|
| 3129 |
|
| 3130 |
|
| 3131 |
+
def _ucb_table_html_has_genuine_credit_rows(table_html: str) -> bool:
|
| 3132 |
+
"""
|
| 3133 |
+
Returns True if the table contains rows that are clearly credit-side
|
| 3134 |
+
(POS Return, Acct Fund INST/CUPERTINO, DDA Transfer IN).
|
| 3135 |
+
Used by Fix 17d guard to prevent dropping the real EC table.
|
| 3136 |
+
"""
|
| 3137 |
+
if not table_html:
|
| 3138 |
+
return False
|
| 3139 |
+
low = table_html.lower()
|
| 3140 |
+
if "pos return" in low:
|
| 3141 |
+
return True
|
| 3142 |
+
if "acct fund" in low and ("inst" in low or "cupertino" in low):
|
| 3143 |
+
return True
|
| 3144 |
+
if "dda transfer" in low and "in" in low:
|
| 3145 |
+
return True
|
| 3146 |
+
return False
|
| 3147 |
+
|
| 3148 |
+
|
| 3149 |
def _ucb_strip_redundant_ed_subset_fragment(text: str) -> str:
|
| 3150 |
"""
|
| 3151 |
Fix 17d: Remove a duplicate <div>Electronic Debits</div> + <table> when that table's rows are a
|
| 3152 |
loose (date, amount) multiset subset of the DA3 table that appears immediately before this block.
|
| 3153 |
+
|
| 3154 |
+
GUARD: Do NOT drop the table if it contains genuine credit-side rows (POS Return,
|
| 3155 |
+
Acct Fund INST/CUPERTINO, DDA Transfer IN). Those rows belong under Electronic Credits
|
| 3156 |
+
and must not be silently discarded as ED duplicates.
|
| 3157 |
"""
|
| 3158 |
if not text or "electronic debits" not in text.lower():
|
| 3159 |
return text
|
|
|
|
| 3165 |
)
|
| 3166 |
tbl_pat = re.compile(r'<table[^>]*>.*?</table>', re.DOTALL | re.IGNORECASE)
|
| 3167 |
_DATE_NONEMPTY = re.compile(r"^\d{1,2}[/\-]\d{2}")
|
|
|
|
| 3168 |
_AMT_STRIPPED_OK = re.compile(r"^-?\d+(?:\.\d{1,4})?-?$")
|
| 3169 |
|
| 3170 |
def _loose_da_key_cells(cells):
|
|
|
|
| 3213 |
cb, cf = Counter(kb), Counter(kf)
|
| 3214 |
if not all(cf[k] <= cb.get(k, 0) for k in cf):
|
| 3215 |
continue
|
| 3216 |
+
# GUARD: Do not drop a table that contains genuine credit-side rows.
|
| 3217 |
+
# These rows belong under Electronic Credits, not as ED duplicates.
|
| 3218 |
+
if _ucb_table_html_has_genuine_credit_rows(m.group("tbl")):
|
| 3219 |
+
log.info(
|
| 3220 |
+
"Fix 17d: skipping drop of table with genuine credit rows "
|
| 3221 |
+
"(POS Return / Acct Fund INST / DDA Transfer IN)"
|
| 3222 |
+
)
|
| 3223 |
+
continue
|
| 3224 |
text = text[: m.start()] + text[m.end() :]
|
| 3225 |
changed = True
|
| 3226 |
break
|
|
|
|
| 3505 |
"""
|
| 3506 |
Fix 30: Drop small DA3 table fragments (1-5 data rows) that are a loose
|
| 3507 |
subset of an earlier larger DA3 table, matched by (date, amount) key alone.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3508 |
"""
|
| 3509 |
if not text or "<table" not in text.lower():
|
| 3510 |
return text
|
|
|
|
| 3518 |
_AMT_NONEMPTY = re.compile(r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})-?$")
|
| 3519 |
|
| 3520 |
def _da_key(cells):
|
|
|
|
| 3521 |
parts = [str(c or "").strip() for c in cells[:3]]
|
| 3522 |
while len(parts) < 3:
|
| 3523 |
parts.append("")
|
|
|
|
| 3526 |
amt_s = amt_s.replace("\u2212", "-").replace("\u2013", "-")
|
| 3527 |
return (date_s, amt_s)
|
| 3528 |
|
|
|
|
| 3529 |
da3_info = []
|
| 3530 |
for m in matches:
|
| 3531 |
g = _parse_da3_grid_from_table_html(m.group(0))
|
|
|
|
| 3559 |
if tj["n"] <= ti["n"]:
|
| 3560 |
continue
|
| 3561 |
big_keys = Counter(tj["keys"])
|
|
|
|
|
|
|
| 3562 |
if all(frag_keys[k] <= big_keys.get(k, 0) for k in frag_keys):
|
| 3563 |
drop_set.add(ti["match"].start())
|
| 3564 |
log.info(
|
|
|
|
| 3859 |
"""
|
| 3860 |
Fix 28: When OCR prepends the rows of a preceding credits/deposits DA3 table
|
| 3861 |
onto the top of a debits/withdrawals DA3 table, strip that prefix.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3862 |
"""
|
| 3863 |
if not text or "<table" not in text.lower():
|
| 3864 |
return text
|
|
|
|
| 4050 |
if not page_md:
|
| 4051 |
return page_md
|
| 4052 |
|
|
|
|
|
|
|
| 4053 |
_tl_freq_context.clear()
|
| 4054 |
|
| 4055 |
page_md = normalize_money_glyphs(page_md)
|
|
|
|
| 4167 |
|
| 4168 |
all_pages = []
|
| 4169 |
for page_num, page_result in enumerate(results):
|
|
|
|
| 4170 |
_tl_freq_context.clear()
|
| 4171 |
page_md, regions = get_page_md_and_regions(page_result)
|
| 4172 |
img_h = page_heights[page_num] if page_num < len(page_heights) else 1000
|