rehan953 commited on
Commit
3213837
·
verified ·
1 Parent(s): 07c3fe9

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +246 -8
app.py CHANGED
@@ -28,7 +28,7 @@ Included (data-driven, no institution names):
28
  (two money tokens, tight gap) into two cells so classifiers can see a balance column.
29
  - thead uses th only; degenerate empty/sparse non-financial tables are dropped.
30
 
31
- Configure GLMOCR_API_KEY (environment variable). Optional: glmocr + gradio +
32
  pymupdf + pillow installed.
33
  """
34
 
@@ -94,6 +94,12 @@ PAD_RIGHT_FRAC = 0.10
94
  PAD_TOP_FRAC = 0.018
95
  PAD_BOTTOM_FRAC = 0.018
96
 
 
 
 
 
 
 
97
  ENABLE_CONTRAST = True
98
  # Slight contrast lift only; same factor for every file.
99
  CONTRAST_FACTOR = 1.18
@@ -870,9 +876,156 @@ def _is_date_like(s: str) -> bool:
870
  )
871
 
872
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
873
  def infer_credit_debit_from_balance_deltas(md: str) -> str:
874
- # Intentionally disabled in the simplified app.
875
- return md
 
 
 
 
 
 
 
 
 
 
876
 
877
 
878
  def normalize_balance_snapshot(md: str) -> str:
@@ -1141,11 +1294,22 @@ def _extract_daily_balance_by_date(md: str):
1141
  rows = _extract_rows_plain_from_table(t)
1142
  if len(rows) < 2:
1143
  continue
1144
- hdr = [c.strip().lower() for c in rows[0]]
1145
- if not hdr:
 
 
 
 
 
 
 
 
 
 
 
 
 
1146
  continue
1147
- date_cols = [i for i, c in enumerate(hdr) if "date" in c]
1148
- bal_cols = [i for i, c in enumerate(hdr) if "balance" in c]
1149
  if not date_cols or not bal_cols:
1150
  continue
1151
  pairs = []
@@ -1155,7 +1319,7 @@ def _extract_daily_balance_by_date(md: str):
1155
  pairs.append((di, bi))
1156
  if not pairs:
1157
  continue
1158
- for r in rows[1:]:
1159
  for di, bi in pairs:
1160
  if di >= len(r) or bi >= len(r):
1161
  continue
@@ -1175,6 +1339,75 @@ def _extract_daily_balance_by_date(md: str):
1175
  return out
1176
 
1177
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1178
  def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]:
1179
  import pymupdf as fitz
1180
  from PIL import Image
@@ -1315,6 +1548,11 @@ def run_ocr(uploaded_file):
1315
  if merged and merged != "(No content)" and not merged.lstrip().startswith("Error:"):
1316
  merged = stabilize_table_markup(merged, rounds=2)
1317
  merged = repair_thead_cell_semantics(merged)
 
 
 
 
 
1318
  merged = strip_degenerate_html_tables(merged)
1319
  return merged
1320
 
 
28
  (two money tokens, tight gap) into two cells so classifiers can see a balance column.
29
  - thead uses th only; degenerate empty/sparse non-financial tables are dropped.
30
 
31
+ Configure GLMOCR_API_KEY, GLM_OCR_API_KEY, or ZHIPU_API_KEY (environment). Optional: glmocr + gradio +
32
  pymupdf + pillow installed.
33
  """
34
 
 
94
  PAD_TOP_FRAC = 0.018
95
  PAD_BOTTOM_FRAC = 0.018
96
 
97
+ # Synthesized running-balance columns help some heuristics but downstream LLM
98
+ # extractors may mis-read them as credits; default off (set GLMOCR_SYNTH_RUNNING_BALANCE=1 to enable).
99
+ def _synth_running_balance_enabled() -> bool:
100
+ return os.environ.get("GLMOCR_SYNTH_RUNNING_BALANCE", "").lower() in ("1", "true", "yes")
101
+
102
+
103
  ENABLE_CONTRAST = True
104
  # Slight contrast lift only; same factor for every file.
105
  CONTRAST_FACTOR = 1.18
 
876
  )
877
 
878
 
879
+ def _looks_like_check_serial_token(s: str) -> bool:
880
+ t = (s or "").strip()
881
+ if not t:
882
+ return False
883
+ return bool(re.match(r"^\d{2,4}\*?$", t))
884
+
885
+
886
+ def strip_ultra_long_digit_tokens(md: str) -> str:
887
+ """
888
+ OCR often glues card/account/reference ids (13+ digit runs) into table cells.
889
+ Downstream extractors sometimes mis-read those tokens as currency amounts,
890
+ producing absurd debits/credits. Strip standalone 13+ digit runs (keep normal
891
+ money like 1,234.56 which never has 13 consecutive digits without punctuation).
892
+ """
893
+ if not md:
894
+ return md
895
+ return re.sub(r"\b\d{13,}\b", "", md)
896
+
897
+
898
+ def mask_debit_card_auth_codes(md: str) -> str:
899
+ """
900
+ Mask 5–7 digit auth reference numbers after AUT (e.g. 'AUT 123024 VISA').
901
+ Extraction models often mistake those digits for currency.
902
+ """
903
+ if not md:
904
+ return md
905
+ return re.sub(
906
+ r"(?i)\bAUT[\s,]+(\d{5,7})(?=\s+(?:VISA|DDA)\b)",
907
+ "AUT ******",
908
+ md,
909
+ )
910
+
911
+
912
+ def strip_glued_card_number_suffixes(md: str) -> str:
913
+ """
914
+ Remove PAN-like digit runs glued after state/region markers (e.g. '*CT4085404035422892').
915
+ """
916
+ if not md:
917
+ return md
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:
924
+ - A 'Checks Paid' grid where check numbers land in the description column
925
+ - Subtotal rows where the posting date cell is blank but 'Subtotal:' is in description
926
+
927
+ Those rows are not normal ledger lines; if they survive into extraction they
928
+ double-count against Electronic Deposits / Payments totals and break reconciliation.
929
+ """
930
+ if not md or "<table" not in md.lower():
931
+ return md
932
+
933
+ def _strip_trs(full_table: str) -> str:
934
+ plain = _cell_plain_text(full_table).lower()
935
+ is_checks = ("checks paid" in plain) and (
936
+ "serial no" in plain or "serial no." in plain or "checks:" in plain
937
+ )
938
+ is_posting_amt = ("posting date" in plain) and ("amount" in plain)
939
+
940
+ def maybe_drop_tr(tr_html: str) -> Optional[str]:
941
+ inner_m = re.search(r"<tr\b[^>]*>(.*)</tr>", tr_html, flags=re.IGNORECASE | re.DOTALL)
942
+ if not inner_m:
943
+ return tr_html
944
+ inner = inner_m.group(1)
945
+ cells = [_cell_plain_text(m.group(0)) for m in _CELL.finditer(inner)]
946
+ if not cells:
947
+ return tr_html
948
+
949
+ def _any_cell_subtotal(cs: List[str]) -> bool:
950
+ for c in cs:
951
+ cl = (c or "").strip().lower()
952
+ if cl.startswith("subtotal") or cl == "subtotal:":
953
+ return True
954
+ return False
955
+
956
+ if is_posting_amt and len(cells) >= 3:
957
+ dt = (cells[0] or "").strip()
958
+ desc = (cells[1] or "").strip()
959
+ desc_l = desc.lower()
960
+ dt_l = dt.lower()
961
+ amt_txt = (cells[-1] or "").strip()
962
+ amt = _parse_amount_or_none(amt_txt)
963
+ if _any_cell_subtotal(cells):
964
+ return ""
965
+ # Section headers / rolled-up lines (not individual postings).
966
+ if re.match(r"(?i)^(electronic\s+deposits|deposits|other\s+credits|checks\s+paid|electronic\s+payments|other\s+withdrawals|service\s+charges)\s*$", dt):
967
+ return ""
968
+ if re.match(r"(?i)^subtotal", desc_l) or desc_l.startswith("subtotal"):
969
+ return ""
970
+ # Drop OCR subtotal/total lines that are not dated posting rows.
971
+ if (not _is_date_like(dt)) and any(
972
+ k in desc_l for k in ("subtotal", "total for this cycle", "total year to date")
973
+ ):
974
+ return ""
975
+ # Drop rare glue rows where date is present but description is empty and amount is huge.
976
+ if _is_date_like(dt) and (not desc) and amt is not None and amt >= 50_000:
977
+ return ""
978
+ # OCR sometimes shifts a section subtotal into an amount column as if it were a deposit.
979
+ if _is_date_like(dt) and (not desc) and amt is not None and amt >= 20_000:
980
+ return ""
981
+ # Check numbers can land in DESCRIPTION; those are not spend lines.
982
+ if _is_date_like(dt) and _looks_like_check_serial_token(desc) and amt is not None and amt >= 500:
983
+ return ""
984
+
985
+ if is_checks:
986
+ # Drop printed check lines (DATE + SERIAL + AMOUNT), including 2-up rows.
987
+ # Keep header rows like "SERIAL NO." (no date in col0) and keep subtotal rows.
988
+ hit = False
989
+ for i in range(0, max(0, len(cells) - 2)):
990
+ if _is_date_like((cells[i] or "").strip()) and _looks_like_check_serial_token(cells[i + 1] or ""):
991
+ hit = True
992
+ break
993
+ if hit:
994
+ return ""
995
+
996
+ return tr_html
997
+
998
+ out_parts = []
999
+ pos = 0
1000
+ for m in re.finditer(r"<tr\b[^>]*>.*?</tr>", full_table, flags=re.IGNORECASE | re.DOTALL):
1001
+ out_parts.append(full_table[pos : m.start()])
1002
+ repl = maybe_drop_tr(m.group(0))
1003
+ if repl is not None:
1004
+ out_parts.append(repl)
1005
+ pos = m.end()
1006
+ out_parts.append(full_table[pos:])
1007
+ return "".join(out_parts)
1008
+
1009
+ def _repl_table(m: re.Match) -> str:
1010
+ tbl = m.group(0)
1011
+ return _strip_trs(tbl)
1012
+
1013
+ return re.sub(r"<table\b[^>]*>.*?</table>", _repl_table, md, flags=re.IGNORECASE | re.DOTALL)
1014
+
1015
+
1016
  def infer_credit_debit_from_balance_deltas(md: str) -> str:
1017
+ """
1018
+ Lightweight reconciliation assist:
1019
+ - normalize a clean balance snapshot section
1020
+ - synthesize running balances for date/description/amount tables that lack balance
1021
+ """
1022
+ if not md or "<table" not in md.lower():
1023
+ return md
1024
+ out = prune_td_statement_table_artifacts(md)
1025
+ out = normalize_balance_snapshot(out)
1026
+ if _synth_running_balance_enabled():
1027
+ out = synthesize_running_balances(out)
1028
+ return out
1029
 
1030
 
1031
  def normalize_balance_snapshot(md: str) -> str:
 
1294
  rows = _extract_rows_plain_from_table(t)
1295
  if len(rows) < 2:
1296
  continue
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
1309
+ bal_cols = bcols
1310
+ break
1311
+ if header_idx is None:
1312
  continue
 
 
1313
  if not date_cols or not bal_cols:
1314
  continue
1315
  pairs = []
 
1319
  pairs.append((di, bi))
1320
  if not pairs:
1321
  continue
1322
+ for r in rows[header_idx + 1 :]:
1323
  for di, bi in pairs:
1324
  if di >= len(r) or bi >= len(r):
1325
  continue
 
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.
1345
+ This gives downstream extraction a clean credit/debit stream that should reconcile exactly
1346
+ to beginning/ending balances when daily balances are reliable.
1347
+ """
1348
+ if not md or "<table" not in md.lower():
1349
+ return md
1350
+
1351
+ by_date = _extract_daily_balance_by_date(md)
1352
+ if len(by_date) < 3:
1353
+ return md
1354
+
1355
+ points = []
1356
+ seen = set()
1357
+ for k, v in by_date.items():
1358
+ m = re.match(r"^\s*(\d{1,2})/(\d{1,2})(?:/\d{2,4})?\s*$", k or "")
1359
+ if not m:
1360
+ continue
1361
+ mm = int(m.group(1))
1362
+ dd = int(m.group(2))
1363
+ key = (mm, dd)
1364
+ if key in seen:
1365
+ continue
1366
+ seen.add(key)
1367
+ points.append((mm, dd, v))
1368
+ has_jan = any(mm == 1 for mm, _, _ in points)
1369
+ # Statements often include prior-month carry-forward like 12/31 followed by Jan activity.
1370
+ # Keep late-December carry-forward rows before January rows.
1371
+ points.sort(key=lambda x: ((-1 if has_jan and x[0] == 12 else x[0]), x[1]))
1372
+ if len(points) < 3:
1373
+ return md
1374
+
1375
+ first_bal = points[0][2]
1376
+ last_bal = points[-1][2]
1377
+ if first_bal is None or last_bal is None:
1378
+ return md
1379
+
1380
+ # Remove noisy posting tables but keep account summary and daily balance summary tables.
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
+ if ("posting date" in plain and "amount" in plain) or "daily account activity" in plain:
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>",
1396
+ f"<tr><td>{points[0][0]:02d}/{points[0][1]:02d}</td><td>BALANCE FORWARD</td><td></td><td></td><td>{_fmt_money(first_bal)}</td></tr>",
1397
+ ]
1398
+ for i in range(1, len(points)):
1399
+ mm, dd, bal = points[i]
1400
+ prev = points[i - 1][2]
1401
+ delta = float(bal) - float(prev)
1402
+ credit = _fmt_money(delta) if delta > 0 else ""
1403
+ debit = _fmt_money(abs(delta)) if delta < 0 else ""
1404
+ rows.append(
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
+ return cleaned + "\n\n" + synth
1409
+
1410
+
1411
  def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]:
1412
  import pymupdf as fitz
1413
  from PIL import Image
 
1548
  if merged and merged != "(No content)" and not merged.lstrip().startswith("Error:"):
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
+ merged = _force_ledger_from_daily_balances(merged)
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)
1557
  return merged
1558