Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -25,6 +25,7 @@ Primary goals for reconcile rate:
|
|
| 25 |
Beginning/Ending balance rows (UCB-style wide summary tables)
|
| 26 |
- Fix 14: UCB 3-col tables where Beginning Balance amount is fused into description and Amount is $0.00
|
| 27 |
- Fix 15: UCB (and similar) 3-col rows where Date+Description are fused in col1 and Amount sits in col2 with col3 empty
|
|
|
|
| 28 |
- Post-pass: dedupe 3-col Date|Description|Amount tables when one is a duplicate fragment
|
| 29 |
(later subset of earlier, earlier subset of later, or identical row sets)
|
| 30 |
"""
|
|
@@ -3094,6 +3095,8 @@ def normalize_html_tables(text: str) -> str:
|
|
| 3094 |
|
| 3095 |
# Fix 3: fused key-value rows in summary sections (no-space guard β safe on all PDFs)
|
| 3096 |
grid = _fix_fused_keyvalue_rows(grid)
|
|
|
|
|
|
|
| 3097 |
# Fix 12: split fused summary+daily-balance combined table, if detected.
|
| 3098 |
split_tables = _split_combined_summary_daily_balance_grid(grid)
|
| 3099 |
if split_tables:
|
|
@@ -3107,6 +3110,50 @@ def normalize_html_tables(text: str) -> str:
|
|
| 3107 |
out.append(text[last:])
|
| 3108 |
return "".join(out)
|
| 3109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3110 |
def _dedupe_subset_transaction_tables_html(text: str) -> str:
|
| 3111 |
"""
|
| 3112 |
Remove duplicate 3-column Date|Description|Amount tables:
|
|
@@ -3114,6 +3161,8 @@ def _dedupe_subset_transaction_tables_html(text: str) -> str:
|
|
| 3114 |
- Earlier table is a strict subset of a later one β drop earlier (UCB-style
|
| 3115 |
"Deposits (continued)" preview before the same rows appear in the full list).
|
| 3116 |
- Identical row sets β drop the later table.
|
|
|
|
|
|
|
| 3117 |
|
| 3118 |
Row matching uses normalized keys so OCR variants (e.g. "0 27" vs "027", backticks)
|
| 3119 |
do not prevent duplicate tables from being detected.
|
|
@@ -3122,9 +3171,6 @@ def _dedupe_subset_transaction_tables_html(text: str) -> str:
|
|
| 3122 |
return text
|
| 3123 |
|
| 3124 |
pattern = re.compile(r"<table[^>]*>.*?</table>", re.DOTALL | re.IGNORECASE)
|
| 3125 |
-
matches = list(pattern.finditer(text))
|
| 3126 |
-
if len(matches) < 2:
|
| 3127 |
-
return text
|
| 3128 |
|
| 3129 |
def _parse_grid(html: str):
|
| 3130 |
try:
|
|
@@ -3134,34 +3180,6 @@ def _dedupe_subset_transaction_tables_html(text: str) -> str:
|
|
| 3134 |
except Exception:
|
| 3135 |
return None
|
| 3136 |
|
| 3137 |
-
grids = []
|
| 3138 |
-
for m in matches:
|
| 3139 |
-
grids.append((m, _parse_grid(m.group(0))))
|
| 3140 |
-
|
| 3141 |
-
def _header_matches(grid):
|
| 3142 |
-
if not grid or len(grid) < 2:
|
| 3143 |
-
return False
|
| 3144 |
-
h = [re.sub(r"\s+", " ", str(c or "").strip().lower()) for c in grid[0]]
|
| 3145 |
-
if len(h) != 3:
|
| 3146 |
-
return False
|
| 3147 |
-
d_ok = h[0] == "date"
|
| 3148 |
-
desc_ok = "description" in h[1]
|
| 3149 |
-
amt_ok = h[2] == "amount"
|
| 3150 |
-
return d_ok and desc_ok and amt_ok
|
| 3151 |
-
|
| 3152 |
-
def _norm_row_key(cells):
|
| 3153 |
-
"""Stable key for same logical row despite minor OCR/HTML differences."""
|
| 3154 |
-
parts = [str(c or "").strip() for c in cells[:3]]
|
| 3155 |
-
while len(parts) < 3:
|
| 3156 |
-
parts.append("")
|
| 3157 |
-
date_s, desc_s, amt_s = parts[0], parts[1], parts[2]
|
| 3158 |
-
date_s = re.sub(r"\s+", "", date_s)
|
| 3159 |
-
amt_s = re.sub(r"[\s$,]", "", amt_s).lower().replace("β", "-")
|
| 3160 |
-
desc_s = html.unescape(desc_s)
|
| 3161 |
-
desc_s = desc_s.replace("`", "'").replace("β", "'")
|
| 3162 |
-
desc_s = re.sub(r"\s+", "", desc_s.lower())
|
| 3163 |
-
return (date_s, desc_s, amt_s)
|
| 3164 |
-
|
| 3165 |
def _row_set(g):
|
| 3166 |
s = set()
|
| 3167 |
for r in g[1:]:
|
|
@@ -3170,56 +3188,84 @@ def _dedupe_subset_transaction_tables_html(text: str) -> str:
|
|
| 3170 |
cells = [str(c or "").strip() for c in r]
|
| 3171 |
if not any(cells):
|
| 3172 |
continue
|
| 3173 |
-
s.add(
|
| 3174 |
return s
|
| 3175 |
|
| 3176 |
-
|
| 3177 |
-
|
| 3178 |
-
if
|
| 3179 |
-
|
| 3180 |
-
mi, gi = grids[i]
|
| 3181 |
-
if not _header_matches(gi):
|
| 3182 |
-
continue
|
| 3183 |
-
si = _row_set(gi)
|
| 3184 |
-
if not si:
|
| 3185 |
-
continue
|
| 3186 |
|
| 3187 |
-
|
| 3188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3189 |
continue
|
| 3190 |
-
|
| 3191 |
-
if not
|
| 3192 |
continue
|
| 3193 |
-
|
| 3194 |
-
if not
|
| 3195 |
continue
|
| 3196 |
|
| 3197 |
-
|
| 3198 |
-
|
| 3199 |
-
|
| 3200 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3201 |
|
| 3202 |
-
|
| 3203 |
-
|
| 3204 |
-
|
| 3205 |
-
|
| 3206 |
|
| 3207 |
-
|
| 3208 |
-
|
| 3209 |
-
|
|
|
|
| 3210 |
|
| 3211 |
-
|
| 3212 |
-
|
|
|
|
| 3213 |
|
| 3214 |
-
|
| 3215 |
-
|
| 3216 |
-
|
| 3217 |
-
|
| 3218 |
-
|
| 3219 |
-
|
| 3220 |
-
|
| 3221 |
-
|
| 3222 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3223 |
|
| 3224 |
|
| 3225 |
def stabilize_tables_and_text(page_md: str) -> str:
|
|
@@ -3444,7 +3490,11 @@ def run_ocr(uploaded_file):
|
|
| 3444 |
if parts:
|
| 3445 |
all_pages.append("\n\n".join(parts))
|
| 3446 |
|
| 3447 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3448 |
|
| 3449 |
except Exception as e:
|
| 3450 |
import traceback
|
|
|
|
| 25 |
Beginning/Ending balance rows (UCB-style wide summary tables)
|
| 26 |
- Fix 14: UCB 3-col tables where Beginning Balance amount is fused into description and Amount is $0.00
|
| 27 |
- Fix 15: UCB (and similar) 3-col rows where Date+Description are fused in col1 and Amount sits in col2 with col3 empty
|
| 28 |
+
- Fix 16: remove duplicate rows within a single Date|Description|Amount table; multi-pass table dedupe + final doc-wide dedupe
|
| 29 |
- Post-pass: dedupe 3-col Date|Description|Amount tables when one is a duplicate fragment
|
| 30 |
(later subset of earlier, earlier subset of later, or identical row sets)
|
| 31 |
"""
|
|
|
|
| 3095 |
|
| 3096 |
# Fix 3: fused key-value rows in summary sections (no-space guard β safe on all PDFs)
|
| 3097 |
grid = _fix_fused_keyvalue_rows(grid)
|
| 3098 |
+
# Fix 16: duplicate data rows inside same Date|Description|Amount table (OCR repeats).
|
| 3099 |
+
grid = _dedupe_duplicate_rows_in_da3_table(grid)
|
| 3100 |
# Fix 12: split fused summary+daily-balance combined table, if detected.
|
| 3101 |
split_tables = _split_combined_summary_daily_balance_grid(grid)
|
| 3102 |
if split_tables:
|
|
|
|
| 3110 |
out.append(text[last:])
|
| 3111 |
return "".join(out)
|
| 3112 |
|
| 3113 |
+
|
| 3114 |
+
def _transaction_row_dedupe_key(cells):
|
| 3115 |
+
"""Stable key for Date|Description|Amount rows (OCR variants, HTML entities)."""
|
| 3116 |
+
parts = [str(c or "").strip() for c in cells[:3]]
|
| 3117 |
+
while len(parts) < 3:
|
| 3118 |
+
parts.append("")
|
| 3119 |
+
date_s, desc_s, amt_s = parts[0], parts[1], parts[2]
|
| 3120 |
+
date_s = re.sub(r"\s+", "", date_s)
|
| 3121 |
+
amt_s = re.sub(r"[\s$,]", "", amt_s).lower().replace("β", "-")
|
| 3122 |
+
desc_s = html.unescape(desc_s)
|
| 3123 |
+
desc_s = desc_s.replace("`", "'").replace("β", "'")
|
| 3124 |
+
desc_s = re.sub(r"\s+", "", desc_s.lower())
|
| 3125 |
+
return (date_s, desc_s, amt_s)
|
| 3126 |
+
|
| 3127 |
+
|
| 3128 |
+
def _is_da3_header(grid):
|
| 3129 |
+
if not grid or len(grid[0]) < 3:
|
| 3130 |
+
return False
|
| 3131 |
+
h = [re.sub(r"\s+", " ", str(c or "").strip().lower()) for c in grid[0]]
|
| 3132 |
+
if len(h) != 3:
|
| 3133 |
+
return False
|
| 3134 |
+
return h[0] == "date" and "description" in h[1] and h[2] == "amount"
|
| 3135 |
+
|
| 3136 |
+
|
| 3137 |
+
def _dedupe_duplicate_rows_in_da3_table(grid):
|
| 3138 |
+
"""Drop repeated data rows within one Date|Description|Amount table (same normalized key)."""
|
| 3139 |
+
if not grid or len(grid) < 2:
|
| 3140 |
+
return grid
|
| 3141 |
+
if not _is_da3_header(grid):
|
| 3142 |
+
return grid
|
| 3143 |
+
seen = set()
|
| 3144 |
+
out = [grid[0]]
|
| 3145 |
+
for row in grid[1:]:
|
| 3146 |
+
cells = [str(c or "").strip() for c in row]
|
| 3147 |
+
if not any(cells):
|
| 3148 |
+
continue
|
| 3149 |
+
k = _transaction_row_dedupe_key(cells)
|
| 3150 |
+
if k in seen:
|
| 3151 |
+
continue
|
| 3152 |
+
seen.add(k)
|
| 3153 |
+
out.append(row)
|
| 3154 |
+
return out if len(out) < len(grid) else grid
|
| 3155 |
+
|
| 3156 |
+
|
| 3157 |
def _dedupe_subset_transaction_tables_html(text: str) -> str:
|
| 3158 |
"""
|
| 3159 |
Remove duplicate 3-column Date|Description|Amount tables:
|
|
|
|
| 3161 |
- Earlier table is a strict subset of a later one β drop earlier (UCB-style
|
| 3162 |
"Deposits (continued)" preview before the same rows appear in the full list).
|
| 3163 |
- Identical row sets β drop the later table.
|
| 3164 |
+
- Fuzzy overlap (β₯92% of later rows match earlier) β drop later (Electronic Debits vs main).
|
| 3165 |
+
- Runs multiple passes until stable.
|
| 3166 |
|
| 3167 |
Row matching uses normalized keys so OCR variants (e.g. "0 27" vs "027", backticks)
|
| 3168 |
do not prevent duplicate tables from being detected.
|
|
|
|
| 3171 |
return text
|
| 3172 |
|
| 3173 |
pattern = re.compile(r"<table[^>]*>.*?</table>", re.DOTALL | re.IGNORECASE)
|
|
|
|
|
|
|
|
|
|
| 3174 |
|
| 3175 |
def _parse_grid(html: str):
|
| 3176 |
try:
|
|
|
|
| 3180 |
except Exception:
|
| 3181 |
return None
|
| 3182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3183 |
def _row_set(g):
|
| 3184 |
s = set()
|
| 3185 |
for r in g[1:]:
|
|
|
|
| 3188 |
cells = [str(c or "").strip() for c in r]
|
| 3189 |
if not any(cells):
|
| 3190 |
continue
|
| 3191 |
+
s.add(_transaction_row_dedupe_key(cells))
|
| 3192 |
return s
|
| 3193 |
|
| 3194 |
+
def _one_pass(t: str) -> str:
|
| 3195 |
+
matches = list(pattern.finditer(t))
|
| 3196 |
+
if len(matches) < 2:
|
| 3197 |
+
return t
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3198 |
|
| 3199 |
+
grids = []
|
| 3200 |
+
for m in matches:
|
| 3201 |
+
grids.append((m, _parse_grid(m.group(0))))
|
| 3202 |
+
|
| 3203 |
+
drop_idx = set()
|
| 3204 |
+
for i in range(1, len(grids)):
|
| 3205 |
+
if i in drop_idx:
|
| 3206 |
continue
|
| 3207 |
+
mi, gi = grids[i]
|
| 3208 |
+
if not _is_da3_header(gi):
|
| 3209 |
continue
|
| 3210 |
+
si = _row_set(gi)
|
| 3211 |
+
if not si:
|
| 3212 |
continue
|
| 3213 |
|
| 3214 |
+
for j in range(i):
|
| 3215 |
+
if j in drop_idx:
|
| 3216 |
+
continue
|
| 3217 |
+
mj, gj = grids[j]
|
| 3218 |
+
if not _is_da3_header(gj):
|
| 3219 |
+
continue
|
| 3220 |
+
sj = _row_set(gj)
|
| 3221 |
+
if not sj:
|
| 3222 |
+
continue
|
| 3223 |
|
| 3224 |
+
# Earlier j fully contained in later i (preview + full list) β drop earlier.
|
| 3225 |
+
if sj <= si and len(sj) < len(si):
|
| 3226 |
+
drop_idx.add(j)
|
| 3227 |
+
continue
|
| 3228 |
|
| 3229 |
+
# Later i strict subset of earlier j β drop later (e.g. Electronic Debits).
|
| 3230 |
+
if si <= sj and len(si) < len(sj):
|
| 3231 |
+
drop_idx.add(i)
|
| 3232 |
+
break
|
| 3233 |
|
| 3234 |
+
if si == sj:
|
| 3235 |
+
drop_idx.add(i)
|
| 3236 |
+
break
|
| 3237 |
|
| 3238 |
+
inter = si & sj
|
| 3239 |
+
# Later table's rows almost all appear in earlier table (OCR drift).
|
| 3240 |
+
ri = len(inter) / len(si) if si else 0.0
|
| 3241 |
+
rj = len(inter) / len(sj) if sj else 0.0
|
| 3242 |
+
if ri >= 0.88 and len(si) <= len(sj):
|
| 3243 |
+
drop_idx.add(i)
|
| 3244 |
+
break
|
| 3245 |
+
if rj >= 0.88 and len(sj) < len(si):
|
| 3246 |
+
drop_idx.add(j)
|
| 3247 |
+
continue
|
| 3248 |
+
|
| 3249 |
+
if not drop_idx:
|
| 3250 |
+
return t
|
| 3251 |
+
|
| 3252 |
+
out = []
|
| 3253 |
+
last = 0
|
| 3254 |
+
for idx, m in enumerate(matches):
|
| 3255 |
+
out.append(t[last : m.start()])
|
| 3256 |
+
if idx not in drop_idx:
|
| 3257 |
+
out.append(m.group(0))
|
| 3258 |
+
last = m.end()
|
| 3259 |
+
out.append(t[last:])
|
| 3260 |
+
return "".join(out)
|
| 3261 |
+
|
| 3262 |
+
out = text
|
| 3263 |
+
for _ in range(12):
|
| 3264 |
+
nxt = _one_pass(out)
|
| 3265 |
+
if nxt == out:
|
| 3266 |
+
break
|
| 3267 |
+
out = nxt
|
| 3268 |
+
return out
|
| 3269 |
|
| 3270 |
|
| 3271 |
def stabilize_tables_and_text(page_md: str) -> str:
|
|
|
|
| 3490 |
if parts:
|
| 3491 |
all_pages.append("\n\n".join(parts))
|
| 3492 |
|
| 3493 |
+
merged = "\n\n---page-separator---\n\n".join(all_pages) if all_pages else "(No content)"
|
| 3494 |
+
# Cross-page: same transaction tables split across page boundaries must dedupe once.
|
| 3495 |
+
if all_pages and merged and not merged.startswith("Error") and "<table" in merged.lower():
|
| 3496 |
+
merged = _dedupe_subset_transaction_tables_html(merged)
|
| 3497 |
+
return merged
|
| 3498 |
|
| 3499 |
except Exception as e:
|
| 3500 |
import traceback
|