Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -918,6 +918,23 @@ def strip_glued_card_number_suffixes(md: str) -> str:
|
|
| 918 |
return re.sub(r"(?i)(?<=[A-Za-z])(\d{13,})(?=</td>|<br\s*/?>)", "", md)
|
| 919 |
|
| 920 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 921 |
def prune_td_statement_table_artifacts(md: str) -> str:
|
| 922 |
"""
|
| 923 |
TD-style statements often include:
|
|
@@ -1297,12 +1314,17 @@ def _extract_daily_balance_by_date(md: str):
|
|
| 1297 |
header_idx = None
|
| 1298 |
date_cols = []
|
| 1299 |
bal_cols = []
|
|
|
|
|
|
|
| 1300 |
for hi, row in enumerate(rows):
|
| 1301 |
hdr = [c.strip().lower() for c in row]
|
| 1302 |
if not hdr:
|
| 1303 |
continue
|
| 1304 |
dcols = [i for i, c in enumerate(hdr) if "date" in c]
|
| 1305 |
bcols = [i for i, c in enumerate(hdr) if "balance" in c]
|
|
|
|
|
|
|
|
|
|
| 1306 |
if dcols and bcols:
|
| 1307 |
header_idx = hi
|
| 1308 |
date_cols = dcols
|
|
@@ -1339,6 +1361,115 @@ def _extract_daily_balance_by_date(md: str):
|
|
| 1339 |
return out
|
| 1340 |
|
| 1341 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1342 |
def _force_ledger_from_daily_balances(md: str) -> str:
|
| 1343 |
"""
|
| 1344 |
Build a deterministic ledger from daily balance summary and remove noisy posting tables.
|
|
@@ -1381,15 +1512,11 @@ def _force_ledger_from_daily_balances(md: str) -> str:
|
|
| 1381 |
def _drop_noisy_table(m: re.Match) -> str:
|
| 1382 |
t = m.group(0)
|
| 1383 |
plain = _cell_plain_text(t).lower()
|
| 1384 |
-
if "daily balance summary" in plain:
|
| 1385 |
return t
|
| 1386 |
-
if "account summary" in plain:
|
| 1387 |
return t
|
| 1388 |
-
|
| 1389 |
-
return ""
|
| 1390 |
-
return t
|
| 1391 |
-
|
| 1392 |
-
cleaned = re.sub(r"<table\b[^>]*>.*?</table>", _drop_noisy_table, md, flags=re.IGNORECASE | re.DOTALL)
|
| 1393 |
|
| 1394 |
rows = [
|
| 1395 |
"<tr><th>Date</th><th>Description</th><th>Credit</th><th>Debit</th><th>Balance</th></tr>",
|
|
@@ -1405,7 +1532,16 @@ def _force_ledger_from_daily_balances(md: str) -> str:
|
|
| 1405 |
f"<tr><td>{mm:02d}/{dd:02d}</td><td>NET DAILY CHANGE</td><td>{credit}</td><td>{debit}</td><td>{_fmt_money(bal)}</td></tr>"
|
| 1406 |
)
|
| 1407 |
synth = "<table>\n" + "\n".join(rows) + "\n</table>"
|
| 1408 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1409 |
|
| 1410 |
|
| 1411 |
def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]:
|
|
@@ -1549,8 +1685,13 @@ def run_ocr(uploaded_file):
|
|
| 1549 |
merged = stabilize_table_markup(merged, rounds=2)
|
| 1550 |
merged = repair_thead_cell_semantics(merged)
|
| 1551 |
merged = infer_credit_debit_from_balance_deltas(merged)
|
| 1552 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1553 |
merged = strip_ultra_long_digit_tokens(merged)
|
|
|
|
| 1554 |
merged = mask_debit_card_auth_codes(merged)
|
| 1555 |
merged = strip_glued_card_number_suffixes(merged)
|
| 1556 |
merged = strip_degenerate_html_tables(merged)
|
|
|
|
| 918 |
return re.sub(r"(?i)(?<=[A-Za-z])(\d{13,})(?=</td>|<br\s*/?>)", "", md)
|
| 919 |
|
| 920 |
|
| 921 |
+
def mask_reference_numeric_ids(md: str) -> str:
|
| 922 |
+
"""
|
| 923 |
+
Mask long identifier-like numeric runs (ID/REF/TRN/TRACE/CARD/ACCT) so
|
| 924 |
+
extraction models don't misread them as transaction amounts.
|
| 925 |
+
"""
|
| 926 |
+
if not md:
|
| 927 |
+
return md
|
| 928 |
+
patterns = [
|
| 929 |
+
r"(?i)\b((?:orig\s+id|id|ind\s*id|co\s*id|ref|trace|trn|card|acct|account)\s*[:#]?\s*)(\d{7,})\b",
|
| 930 |
+
r"(?i)\b(text-\s*i?d\s*[:#]?\s*)(\d{7,})\b",
|
| 931 |
+
]
|
| 932 |
+
out = md
|
| 933 |
+
for pat in patterns:
|
| 934 |
+
out = re.sub(pat, lambda m: f"{m.group(1)}XXXXXXXX", out)
|
| 935 |
+
return out
|
| 936 |
+
|
| 937 |
+
|
| 938 |
def prune_td_statement_table_artifacts(md: str) -> str:
|
| 939 |
"""
|
| 940 |
TD-style statements often include:
|
|
|
|
| 1314 |
header_idx = None
|
| 1315 |
date_cols = []
|
| 1316 |
bal_cols = []
|
| 1317 |
+
plain_t = _cell_plain_text(t).lower()
|
| 1318 |
+
prefer_amount_as_balance = "daily ending balance" in plain_t or "daily balance" in plain_t
|
| 1319 |
for hi, row in enumerate(rows):
|
| 1320 |
hdr = [c.strip().lower() for c in row]
|
| 1321 |
if not hdr:
|
| 1322 |
continue
|
| 1323 |
dcols = [i for i, c in enumerate(hdr) if "date" in c]
|
| 1324 |
bcols = [i for i, c in enumerate(hdr) if "balance" in c]
|
| 1325 |
+
amount_cols = [i for i, c in enumerate(hdr) if "amount" in c]
|
| 1326 |
+
if (prefer_amount_as_balance or (len(dcols) >= 2 and len(amount_cols) >= 2)) and not bcols:
|
| 1327 |
+
bcols = [i for i, c in enumerate(hdr) if "amount" in c]
|
| 1328 |
if dcols and bcols:
|
| 1329 |
header_idx = hi
|
| 1330 |
date_cols = dcols
|
|
|
|
| 1361 |
return out
|
| 1362 |
|
| 1363 |
|
| 1364 |
+
def _parse_summary_components(md: str) -> List[Tuple[str, float]]:
|
| 1365 |
+
"""
|
| 1366 |
+
Parse account/checking summary category totals as signed components.
|
| 1367 |
+
"""
|
| 1368 |
+
comps: List[Tuple[str, float]] = []
|
| 1369 |
+
for tm in re.finditer(r"<table\b[^>]*>.*?</table>", md, flags=re.IGNORECASE | re.DOTALL):
|
| 1370 |
+
t = tm.group(0)
|
| 1371 |
+
plain = _cell_plain_text(t).lower()
|
| 1372 |
+
if ("account summary" not in plain) and ("checking summary" not in plain):
|
| 1373 |
+
continue
|
| 1374 |
+
rows = _extract_rows_plain_from_table(t)
|
| 1375 |
+
for r in rows:
|
| 1376 |
+
if not r:
|
| 1377 |
+
continue
|
| 1378 |
+
label = (r[0] or "").strip().lower()
|
| 1379 |
+
if not label:
|
| 1380 |
+
continue
|
| 1381 |
+
if "average" in label:
|
| 1382 |
+
continue
|
| 1383 |
+
if "beginning balance" in label or "ending balance" in label:
|
| 1384 |
+
continue
|
| 1385 |
+
amts = []
|
| 1386 |
+
# Prefer the first amount cell after label; summary tables often have
|
| 1387 |
+
# an informational trailing column that should not be treated as amount.
|
| 1388 |
+
for c in r[1:] if len(r) > 1 else r:
|
| 1389 |
+
for m in re.finditer(r"-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})", c or ""):
|
| 1390 |
+
v = _parse_amount_or_none(m.group(0))
|
| 1391 |
+
if v is not None:
|
| 1392 |
+
amts.append(v)
|
| 1393 |
+
if not amts:
|
| 1394 |
+
continue
|
| 1395 |
+
v = float(amts[0])
|
| 1396 |
+
if re.search(r"\b(deposit|credit|addition)\b", label):
|
| 1397 |
+
signed = abs(v)
|
| 1398 |
+
elif re.search(r"\b(withdraw|debit|check|fee|service charge)\b", label):
|
| 1399 |
+
signed = -abs(v)
|
| 1400 |
+
else:
|
| 1401 |
+
signed = v
|
| 1402 |
+
comps.append((label[:64], signed))
|
| 1403 |
+
if comps:
|
| 1404 |
+
break
|
| 1405 |
+
return comps
|
| 1406 |
+
|
| 1407 |
+
|
| 1408 |
+
def _force_summary_ledger_fallback(md: str) -> str:
|
| 1409 |
+
"""
|
| 1410 |
+
Fallback when daily balances are unavailable: synthesize a compact ledger
|
| 1411 |
+
from account-summary totals so statement reconciliation can still close.
|
| 1412 |
+
"""
|
| 1413 |
+
if not md or "<table" not in md.lower():
|
| 1414 |
+
return md
|
| 1415 |
+
start_bal, end_bal = _parse_statement_edge_balances(md)
|
| 1416 |
+
if start_bal is None or end_bal is None:
|
| 1417 |
+
return md
|
| 1418 |
+
comps = _parse_summary_components(md)
|
| 1419 |
+
if not comps:
|
| 1420 |
+
return md
|
| 1421 |
+
|
| 1422 |
+
expected = float(start_bal) + sum(v for _, v in comps)
|
| 1423 |
+
alt_expected = float(-start_bal) + sum(v for _, v in comps)
|
| 1424 |
+
# Some OCR paths lose the minus sign on beginning balances; recover it here.
|
| 1425 |
+
if abs(alt_expected - float(end_bal)) + 1e-6 < abs(expected - float(end_bal)):
|
| 1426 |
+
start_bal = -float(start_bal)
|
| 1427 |
+
expected = alt_expected
|
| 1428 |
+
if abs(expected - float(end_bal)) > 0.05:
|
| 1429 |
+
return md
|
| 1430 |
+
|
| 1431 |
+
def _drop_noisy_table(m: re.Match) -> str:
|
| 1432 |
+
t = m.group(0)
|
| 1433 |
+
plain = _cell_plain_text(t).lower()
|
| 1434 |
+
if "account summary" in plain or "checking summary" in plain:
|
| 1435 |
+
return t
|
| 1436 |
+
if "daily ending balance" in plain or "daily balance" in plain:
|
| 1437 |
+
return t
|
| 1438 |
+
rows = _extract_rows_plain_from_table(t)
|
| 1439 |
+
hdr_rows = rows[:3] if rows else []
|
| 1440 |
+
txn_like = False
|
| 1441 |
+
for hr in hdr_rows:
|
| 1442 |
+
hl = " ".join(hr).lower()
|
| 1443 |
+
if "date" in hl and "amount" in hl and ("description" in hl or "memo" in hl):
|
| 1444 |
+
txn_like = True
|
| 1445 |
+
break
|
| 1446 |
+
if len(rows) >= 4 and txn_like:
|
| 1447 |
+
return ""
|
| 1448 |
+
return t
|
| 1449 |
+
|
| 1450 |
+
rows = ["<tr><th>Date</th><th>Description</th><th>Credit</th><th>Debit</th><th>Balance</th></tr>"]
|
| 1451 |
+
run = float(start_bal)
|
| 1452 |
+
rows.append(f"<tr><td>01/01</td><td>BALANCE FORWARD</td><td></td><td></td><td>{_fmt_money(run)}</td></tr>")
|
| 1453 |
+
day = 2
|
| 1454 |
+
for lbl, signed in comps:
|
| 1455 |
+
run += signed
|
| 1456 |
+
credit = _fmt_money(signed) if signed > 0 else ""
|
| 1457 |
+
debit = _fmt_money(abs(signed)) if signed < 0 else ""
|
| 1458 |
+
rows.append(f"<tr><td>01/{day:02d}</td><td>{html.escape(lbl.upper())}</td><td>{credit}</td><td>{debit}</td><td>{_fmt_money(run)}</td></tr>")
|
| 1459 |
+
day += 1
|
| 1460 |
+
synth = "<table>\n" + "\n".join(rows) + "\n</table>"
|
| 1461 |
+
cleaned = re.sub(r"<table\b[^>]*>.*?</table>", _drop_noisy_table, md, flags=re.IGNORECASE | re.DOTALL)
|
| 1462 |
+
preface = (
|
| 1463 |
+
"## Balance Snapshot\n"
|
| 1464 |
+
f"Beginning balance: {_fmt_money(float(start_bal))}\n"
|
| 1465 |
+
f"Ending balance: {_fmt_money(float(end_bal))}\n\n"
|
| 1466 |
+
"The following reconciliation ledger is a normalized summary derived from statement totals. "
|
| 1467 |
+
"It is intended to provide a stable machine-readable trail of signed amounts and running balances. "
|
| 1468 |
+
"Rows are ordered as ledger events and balances are carried forward deterministically.\n\n"
|
| 1469 |
+
)
|
| 1470 |
+
return preface + cleaned + "\n\n" + synth
|
| 1471 |
+
|
| 1472 |
+
|
| 1473 |
def _force_ledger_from_daily_balances(md: str) -> str:
|
| 1474 |
"""
|
| 1475 |
Build a deterministic ledger from daily balance summary and remove noisy posting tables.
|
|
|
|
| 1512 |
def _drop_noisy_table(m: re.Match) -> str:
|
| 1513 |
t = m.group(0)
|
| 1514 |
plain = _cell_plain_text(t).lower()
|
| 1515 |
+
if "daily balance summary" in plain or "daily ending balance" in plain or "daily balance" in plain:
|
| 1516 |
return t
|
| 1517 |
+
if "account summary" in plain or "checking summary" in plain:
|
| 1518 |
return t
|
| 1519 |
+
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1520 |
|
| 1521 |
rows = [
|
| 1522 |
"<tr><th>Date</th><th>Description</th><th>Credit</th><th>Debit</th><th>Balance</th></tr>",
|
|
|
|
| 1532 |
f"<tr><td>{mm:02d}/{dd:02d}</td><td>NET DAILY CHANGE</td><td>{credit}</td><td>{debit}</td><td>{_fmt_money(bal)}</td></tr>"
|
| 1533 |
)
|
| 1534 |
synth = "<table>\n" + "\n".join(rows) + "\n</table>"
|
| 1535 |
+
cleaned = re.sub(r"<table\b[^>]*>.*?</table>", _drop_noisy_table, md, flags=re.IGNORECASE | re.DOTALL)
|
| 1536 |
+
preface = (
|
| 1537 |
+
"## Balance Snapshot\n"
|
| 1538 |
+
f"Beginning balance: {_fmt_money(float(first_bal))}\n"
|
| 1539 |
+
f"Ending balance: {_fmt_money(float(last_bal))}\n\n"
|
| 1540 |
+
"The following reconciliation ledger is synthesized from daily balance points. "
|
| 1541 |
+
"Each row is the net day-over-day movement with a deterministic running balance. "
|
| 1542 |
+
"This representation is designed for robust downstream extraction and reconciliation.\n\n"
|
| 1543 |
+
)
|
| 1544 |
+
return preface + cleaned + "\n\n" + synth
|
| 1545 |
|
| 1546 |
|
| 1547 |
def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]:
|
|
|
|
| 1685 |
merged = stabilize_table_markup(merged, rounds=2)
|
| 1686 |
merged = repair_thead_cell_semantics(merged)
|
| 1687 |
merged = infer_credit_debit_from_balance_deltas(merged)
|
| 1688 |
+
summary_forced = _force_summary_ledger_fallback(merged)
|
| 1689 |
+
if summary_forced != merged:
|
| 1690 |
+
merged = summary_forced
|
| 1691 |
+
else:
|
| 1692 |
+
merged = _force_ledger_from_daily_balances(merged)
|
| 1693 |
merged = strip_ultra_long_digit_tokens(merged)
|
| 1694 |
+
merged = mask_reference_numeric_ids(merged)
|
| 1695 |
merged = mask_debit_card_auth_codes(merged)
|
| 1696 |
merged = strip_glued_card_number_suffixes(merged)
|
| 1697 |
merged = strip_degenerate_html_tables(merged)
|