Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -34,6 +34,10 @@ Primary goals for reconcile rate:
|
|
| 34 |
(same normalized Date|Description|Amount keys), strip that suffix from table N. Fixes
|
| 35 |
formats (e.g. Truist) where deposit rows are glued onto the withdrawals table and repeated
|
| 36 |
under the proper deposits heading (K>=2; first table must keep >=1 data row).
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
"""
|
| 38 |
|
| 39 |
# Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
|
|
@@ -3099,9 +3103,12 @@ def normalize_html_tables(text: str) -> str:
|
|
| 3099 |
# (Hardin County Bank: "CHASE CREDIT CRD EPAY 8319249882 500.00")
|
| 3100 |
grid = _extract_fused_desc_amount(grid)
|
| 3101 |
|
|
|
|
|
|
|
|
|
|
| 3102 |
# Fix 3: fused key-value rows in summary sections (no-space guard — safe on all PDFs)
|
| 3103 |
grid = _fix_fused_keyvalue_rows(grid)
|
| 3104 |
-
# Fix 16: duplicate data rows inside same Date|Description|Amount table (OCR repeats).
|
| 3105 |
grid = _dedupe_duplicate_rows_in_da3_table(grid)
|
| 3106 |
# Fix 12: split fused summary+daily-balance combined table, if detected.
|
| 3107 |
split_tables = _split_combined_summary_daily_balance_grid(grid)
|
|
@@ -3142,24 +3149,113 @@ def _is_da3_header(grid):
|
|
| 3142 |
)
|
| 3143 |
|
| 3144 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3145 |
def _dedupe_duplicate_rows_in_da3_table(grid):
|
| 3146 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3147 |
if not grid or len(grid) < 2:
|
| 3148 |
return grid
|
| 3149 |
if not _is_da3_header(grid):
|
| 3150 |
return grid
|
| 3151 |
-
|
| 3152 |
-
out = [grid[0]]
|
| 3153 |
for row in grid[1:]:
|
| 3154 |
cells = [str(c or "").strip() for c in row]
|
| 3155 |
if not any(cells):
|
| 3156 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3157 |
k = _transaction_row_dedupe_key(cells)
|
| 3158 |
-
|
| 3159 |
-
|
| 3160 |
-
|
| 3161 |
-
|
| 3162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3163 |
|
| 3164 |
|
| 3165 |
def _row_text_full_ucb(grid, r: int) -> str:
|
|
|
|
| 34 |
(same normalized Date|Description|Amount keys), strip that suffix from table N. Fixes
|
| 35 |
formats (e.g. Truist) where deposit rows are glued onto the withdrawals table and repeated
|
| 36 |
under the proper deposits heading (K>=2; first table must keep >=1 data row).
|
| 37 |
+
- Fix 19: DA3 rows whose Amount cell is not strict currency (e.g. fused card digits + merchant tail)
|
| 38 |
+
— take the rightmost plausible money token from Description+Amount text for the amount.
|
| 39 |
+
- Fix 20: DA3 in-table dedupe — cap consecutive identical normalized keys at 2 rows (keeps legitimate
|
| 40 |
+
back-to-back identical charges; still collapses 3+ OCR stutters).
|
| 41 |
"""
|
| 42 |
|
| 43 |
# Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
|
|
|
|
| 3103 |
# (Hardin County Bank: "CHASE CREDIT CRD EPAY 8319249882 500.00")
|
| 3104 |
grid = _extract_fused_desc_amount(grid)
|
| 3105 |
|
| 3106 |
+
# Fix 19: junk Amount cell but money token still present in row text (Truist MSBILL/PIN).
|
| 3107 |
+
grid = _repair_da3_garbled_amount_cells(grid)
|
| 3108 |
+
|
| 3109 |
# Fix 3: fused key-value rows in summary sections (no-space guard — safe on all PDFs)
|
| 3110 |
grid = _fix_fused_keyvalue_rows(grid)
|
| 3111 |
+
# Fix 16/20: duplicate data rows inside same Date|Description|Amount table (OCR repeats).
|
| 3112 |
grid = _dedupe_duplicate_rows_in_da3_table(grid)
|
| 3113 |
# Fix 12: split fused summary+daily-balance combined table, if detected.
|
| 3114 |
split_tables = _split_combined_summary_daily_balance_grid(grid)
|
|
|
|
| 3149 |
)
|
| 3150 |
|
| 3151 |
|
| 3152 |
+
_DA3_STRICT_AMOUNT = re.compile(r"^-?\$?\d{1,3}(?:,\d{3})*\.\d{2}-?$")
|
| 3153 |
+
_DA3_MONEY_TOKEN = re.compile(
|
| 3154 |
+
r"(?<![\d,])(-?\$?\d{1,3}(?:,\d{3})*\.\d{2}|-?\$?\d{2,6}\.\d{2})(?!\d)"
|
| 3155 |
+
)
|
| 3156 |
+
|
| 3157 |
+
|
| 3158 |
+
def _repair_da3_garbled_amount_cells(grid):
|
| 3159 |
+
"""
|
| 3160 |
+
Fix 19: Amount column shows fused junk (letters, card digits) while the real debit/credit
|
| 3161 |
+
still appears as a money token in the row text — use the rightmost plausible amount.
|
| 3162 |
+
Only for Date|Description|Amount tables and strict MM/DD dates in column 0.
|
| 3163 |
+
"""
|
| 3164 |
+
if not grid or len(grid) < 2 or not _is_da3_header(grid):
|
| 3165 |
+
return grid
|
| 3166 |
+
|
| 3167 |
+
date_re = re.compile(r"^\d{1,2}/\d{1,2}(?:/\d{2,4})?$")
|
| 3168 |
+
out = [list(grid[0])]
|
| 3169 |
+
changed = False
|
| 3170 |
+
|
| 3171 |
+
for row in grid[1:]:
|
| 3172 |
+
cells = [str(c or "").strip() for c in row]
|
| 3173 |
+
while len(cells) < 3:
|
| 3174 |
+
cells.append("")
|
| 3175 |
+
c0, c1, c2 = cells[0], cells[1], cells[2]
|
| 3176 |
+
if not date_re.match(c0):
|
| 3177 |
+
out.append(cells)
|
| 3178 |
+
continue
|
| 3179 |
+
c2s = c2.strip()
|
| 3180 |
+
if _DA3_STRICT_AMOUNT.match(c2s):
|
| 3181 |
+
out.append(cells)
|
| 3182 |
+
continue
|
| 3183 |
+
blob = f"{c1} {c2}".strip()
|
| 3184 |
+
if not blob:
|
| 3185 |
+
out.append(cells)
|
| 3186 |
+
continue
|
| 3187 |
+
found = _DA3_MONEY_TOKEN.findall(blob)
|
| 3188 |
+
if not found:
|
| 3189 |
+
out.append(cells)
|
| 3190 |
+
continue
|
| 3191 |
+
pick_raw = None
|
| 3192 |
+
for m in reversed(found):
|
| 3193 |
+
t = m.replace("$", "").replace(",", "")
|
| 3194 |
+
try:
|
| 3195 |
+
v = float(t)
|
| 3196 |
+
except ValueError:
|
| 3197 |
+
continue
|
| 3198 |
+
if 0.01 <= v <= 9_999_999.99:
|
| 3199 |
+
pick_raw = m.replace("$", "").strip()
|
| 3200 |
+
break
|
| 3201 |
+
if not pick_raw:
|
| 3202 |
+
out.append(cells)
|
| 3203 |
+
continue
|
| 3204 |
+
new_desc = c1
|
| 3205 |
+
for suf in (
|
| 3206 |
+
pick_raw,
|
| 3207 |
+
pick_raw.replace(",", ""),
|
| 3208 |
+
f"${pick_raw}",
|
| 3209 |
+
f"${pick_raw.replace(',', '')}",
|
| 3210 |
+
):
|
| 3211 |
+
if suf and new_desc.rstrip().endswith(suf):
|
| 3212 |
+
new_desc = new_desc[: -len(suf)].rstrip()
|
| 3213 |
+
break
|
| 3214 |
+
out.append([c0, new_desc, pick_raw])
|
| 3215 |
+
changed = True
|
| 3216 |
+
|
| 3217 |
+
return out if changed else grid
|
| 3218 |
+
|
| 3219 |
+
|
| 3220 |
def _dedupe_duplicate_rows_in_da3_table(grid):
|
| 3221 |
+
"""
|
| 3222 |
+
Fix 16 + 20: Drop long runs of OCR-identical rows; allow up to 2 consecutive rows with the
|
| 3223 |
+
same normalized Date|Description|Amount key (legitimate duplicate charges). Non-consecutive
|
| 3224 |
+
repeats are kept (each run capped separately).
|
| 3225 |
+
"""
|
| 3226 |
if not grid or len(grid) < 2:
|
| 3227 |
return grid
|
| 3228 |
if not _is_da3_header(grid):
|
| 3229 |
return grid
|
| 3230 |
+
data_rows = []
|
|
|
|
| 3231 |
for row in grid[1:]:
|
| 3232 |
cells = [str(c or "").strip() for c in row]
|
| 3233 |
if not any(cells):
|
| 3234 |
continue
|
| 3235 |
+
data_rows.append(row)
|
| 3236 |
+
|
| 3237 |
+
if not data_rows:
|
| 3238 |
+
return grid
|
| 3239 |
+
|
| 3240 |
+
out = [grid[0]]
|
| 3241 |
+
i = 0
|
| 3242 |
+
while i < len(data_rows):
|
| 3243 |
+
row = data_rows[i]
|
| 3244 |
+
cells = [str(c or "").strip() for c in row]
|
| 3245 |
k = _transaction_row_dedupe_key(cells)
|
| 3246 |
+
j = i + 1
|
| 3247 |
+
while j < len(data_rows):
|
| 3248 |
+
ncells = [str(c or "").strip() for c in data_rows[j]]
|
| 3249 |
+
if _transaction_row_dedupe_key(ncells) != k:
|
| 3250 |
+
break
|
| 3251 |
+
j += 1
|
| 3252 |
+
run_len = j - i
|
| 3253 |
+
take = min(run_len, 2)
|
| 3254 |
+
for t in range(take):
|
| 3255 |
+
out.append(data_rows[i + t])
|
| 3256 |
+
i = j
|
| 3257 |
+
|
| 3258 |
+
return out
|
| 3259 |
|
| 3260 |
|
| 3261 |
def _row_text_full_ucb(grid, r: int) -> str:
|