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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +256 -1162
app.py CHANGED
@@ -26,8 +26,6 @@ Included (data-driven, no institution names):
26
  but do not disable an entire table. Stabilization runs in multiple passes.
27
  - Split single cells that clearly contain transaction amount + trailing balance
28
  (two money tokens, tight gap) into two cells so classifiers can see a balance column.
29
- - Long runs of plain-text ledger lines (date + description + amount, optional balance)
30
- are coerced into one HTML table when 5+ consecutive lines match generic patterns.
31
  - thead uses th only; degenerate empty/sparse non-financial tables are dropped.
32
 
33
  Configure GLMOCR_API_KEY (environment variable). Optional: glmocr + gradio +
@@ -55,6 +53,7 @@ import logging
55
  import os
56
  import re
57
  import tempfile
 
58
  from collections import Counter
59
  from typing import List, Optional, Tuple
60
 
@@ -86,12 +85,12 @@ if not GLMOCR_API_KEY:
86
 
87
  # Rasterization: higher scale = more pixels per PDF point (helps small type,
88
  # boxed headers, and narrow columns). Same constant for all uploads.
89
- RENDER_SCALE = 3.35
90
 
91
  # White margin as a fraction of page width/height after render. Extra right
92
  # margin helps right-aligned currency columns that hug the page edge.
93
  PAD_LEFT_FRAC = 0.035
94
- PAD_RIGHT_FRAC = 0.125
95
  PAD_TOP_FRAC = 0.018
96
  PAD_BOTTOM_FRAC = 0.018
97
 
@@ -118,11 +117,6 @@ MIN_CROP_PIXELS = 112 * 112
118
  PAGE_PNG_COMPRESS_LEVEL = 3
119
  # JPEG quality for small header/footer crops sent to the API.
120
  ZONE_JPEG_QUALITY = 95
121
- PAGE_JPEG_QUALITY = 88
122
- MAX_PAGE_UPLOAD_BYTES = 9_200_000
123
- # Keep page PNGs under MaaS input constraints to avoid 400 errors.
124
- MAX_PAGE_LONG_SIDE = 3600
125
- MAX_PAGE_TOTAL_PIXELS = 7_800_000
126
 
127
  MIN_PDF_TEXT_CHARS_NATIVE_LAYER = 1500
128
 
@@ -169,7 +163,7 @@ if CONFIG_PATH:
169
  with open(CONFIG_PATH, "r", encoding="utf-8") as f:
170
  config = yaml.safe_load(f)
171
  config.setdefault("pipeline", {}).setdefault("maas", {})
172
- config["pipeline"]["maas"]["enabled"] = TrueCONFIG_PATH
173
  config["pipeline"]["maas"]["api_key"] = GLMOCR_API_KEY
174
  with open(CONFIG_PATH, "w", encoding="utf-8") as f:
175
  yaml.dump(config, f, default_flow_style=False, sort_keys=False)
@@ -743,323 +737,6 @@ def strip_degenerate_html_tables(md: str) -> str:
743
  _GLUED_HEADER_ROW_RE = re.compile(r"date\d{1,2}/\d{1,2}|typedeposit|amount\d", re.IGNORECASE)
744
 
745
 
746
- def strip_malformed_leading_table_rows(md: str) -> str:
747
- """
748
- Remove malformed leading rows in HTML tables when they are clearly bogus
749
- header artifacts and the table already contains a proper header row below.
750
- """
751
- if not md or "<table" not in md.lower():
752
- return md
753
-
754
- def repl_table(m: re.Match) -> str:
755
- full = m.group(0)
756
- trs = list(re.finditer(r"<tr\b[^>]*>.*?</tr>", full, flags=re.IGNORECASE | re.DOTALL))
757
- if len(trs) < 2:
758
- return full
759
-
760
- first = trs[0].group(0)
761
- second = trs[1].group(0)
762
- first_plain = _cell_plain_text(first).lower()
763
- second_plain = _cell_plain_text(second).lower()
764
-
765
- first_is_malformed = bool(_GLUED_HEADER_ROW_RE.search(first_plain))
766
- second_looks_header = (
767
- ("date" in second_plain and "description" in second_plain)
768
- or ("posting date" in second_plain and "description" in second_plain)
769
- or ("date" in second_plain and "balance" in second_plain)
770
- )
771
- if not (first_is_malformed and second_looks_header):
772
- return full
773
-
774
- return full.replace(first, "", 1)
775
-
776
- return re.sub(
777
- r"<table\b[^>]*>.*?</table>",
778
- repl_table,
779
- md,
780
- flags=re.IGNORECASE | re.DOTALL,
781
- )
782
-
783
-
784
- def strip_subtotal_rows_from_transaction_tables(md: str) -> str:
785
- """
786
- Remove subtotal/total line-item rows from transaction tables so aggregate
787
- amounts are not mistaken as individual transactions.
788
- """
789
- if not md or "<table" not in md.lower():
790
- return md
791
-
792
- def repl_table(m: re.Match) -> str:
793
- full = m.group(0)
794
- rows = list(re.finditer(r"<tr\b[^>]*>.*?</tr>", full, flags=re.IGNORECASE | re.DOTALL))
795
- if len(rows) < 2:
796
- return full
797
- # Some tables start with a title row, then the actual header appears in row 2/3.
798
- sample_headers = [_cell_plain_text(r.group(0)).lower() for r in rows[:4]]
799
- is_txn_like = any(
800
- ("date" in hp or "posting date" in hp)
801
- and ("description" in hp)
802
- and ("amount" in hp or "debit" in hp or "credit" in hp)
803
- for hp in sample_headers
804
- )
805
- if not is_txn_like:
806
- return full
807
-
808
- out = full
809
- for r in rows[1:]:
810
- rp = _cell_plain_text(r.group(0)).lower()
811
- if "subtotal" in rp or rp.startswith("total "):
812
- out = out.replace(r.group(0), "", 1)
813
- return out
814
-
815
- return re.sub(
816
- r"<table\b[^>]*>.*?</table>",
817
- repl_table,
818
- md,
819
- flags=re.IGNORECASE | re.DOTALL,
820
- )
821
-
822
-
823
- # Match long digit runs even when OCR glues trailing letters (e.g. 9257403599CCD).
824
- _LONG_NUM_TOKEN = re.compile(r"(?<!\d)\d{7,}(?!\d)")
825
-
826
-
827
- def mask_non_monetary_long_numbers(md: str) -> str:
828
- """
829
- Prevent long reference/trace/account IDs from being interpreted as monetary
830
- values by downstream transaction extraction.
831
- """
832
- if not md:
833
- return md
834
-
835
- def repl(m: re.Match) -> str:
836
- tok = m.group(0)
837
- # Keep common money-like tokens (handled elsewhere with decimal cents).
838
- if re.match(r"^\d+\.\d{2}$", tok):
839
- return tok
840
- # Keep YYYYMMDD-like date compact tokens.
841
- if len(tok) == 8 and tok.startswith(("19", "20")):
842
- return tok
843
- # Replace digits entirely so downstream cannot reinterpret ID tokens as money.
844
- return "IDNUM"
845
-
846
- return _LONG_NUM_TOKEN.sub(repl, md)
847
-
848
-
849
- def directionalize_amount_headers(md: str) -> str:
850
- """
851
- Convert ambiguous 'Amount' headers into 'Credit' or 'Debit' when table
852
- context clearly indicates transaction direction (e.g. Deposits, Checks).
853
- """
854
- if not md or "<table" not in md.lower():
855
- return md
856
-
857
- def map_header_token(label: str, ctx: str) -> str:
858
- low = label.strip().lower()
859
- if "amount" not in low:
860
- return label
861
- if re.search(r"\bcredit|deposit|deposits|incoming|received|interest earned|payment received\b", ctx):
862
- return re.sub(r"amount", "Credit", label, flags=re.IGNORECASE)
863
- if re.search(r"\bdebit|debits|withdrawal|withdrawals|check|checks|fee|fees|charge|charges|payment|payments\b", ctx):
864
- return re.sub(r"amount", "Debit", label, flags=re.IGNORECASE)
865
- return label
866
-
867
- def repl_table(m: re.Match) -> str:
868
- full = m.group(0)
869
- # Table-local context from headers and first rows.
870
- ctx = _cell_plain_text(full).lower()
871
- changed = False
872
-
873
- def repl_th(thm: re.Match) -> str:
874
- nonlocal changed
875
- tag = thm.group(0)
876
- inner_m = re.search(r"<th\b[^>]*>(.*?)</th>", tag, flags=re.IGNORECASE | re.DOTALL)
877
- if not inner_m:
878
- return tag
879
- inner = inner_m.group(1)
880
- plain = _cell_plain_text(inner)
881
- mapped = map_header_token(plain, ctx)
882
- if mapped == plain:
883
- return tag
884
- changed = True
885
- return re.sub(
886
- r"(<th\b[^>]*>)(.*?)(</th>)",
887
- rf"\1{html.escape(mapped)}\3",
888
- tag,
889
- count=1,
890
- flags=re.IGNORECASE | re.DOTALL,
891
- )
892
-
893
- out = re.sub(r"<th\b[^>]*>.*?</th>", repl_th, full, flags=re.IGNORECASE | re.DOTALL)
894
- if changed:
895
- return out
896
- # Fallback for OCR tables that put header row in <td>.
897
- if "amount" in ctx and ("deposit" in ctx or "debit" in ctx or "withdraw" in ctx or "check" in ctx or "fee" in ctx):
898
- out2 = re.sub(
899
- r"(<td\b[^>]*>\s*)amount(\s*</td>)",
900
- lambda mm: mm.group(1)
901
- + (
902
- "Credit"
903
- if ("deposit" in ctx or "credit" in ctx or "incoming" in ctx or "received" in ctx)
904
- else "Debit"
905
- )
906
- + mm.group(2),
907
- full,
908
- flags=re.IGNORECASE,
909
- )
910
- return out2
911
- return full
912
-
913
- return re.sub(
914
- r"<table\b[^>]*>.*?</table>",
915
- repl_table,
916
- md,
917
- flags=re.IGNORECASE | re.DOTALL,
918
- )
919
-
920
-
921
- def directionalize_amount_headers_by_nearby_context(md: str) -> str:
922
- """
923
- Use nearby narrative labels around each table (outside HTML cells) to set
924
- Amount->Credit/Debit when the table itself is ambiguous.
925
- """
926
- if not md or "<table" not in md.lower():
927
- return md
928
-
929
- credit_hint_re = re.compile(
930
- r"\b(all\s+credit\s+activity|credit\s+activity|deposits?|credits?|money\s+in)\b",
931
- re.IGNORECASE,
932
- )
933
- debit_hint_re = re.compile(
934
- r"\b(all\s+debit\s+activity|debit\s+activity|withdrawals?|debits?|checks?|money\s+out)\b",
935
- re.IGNORECASE,
936
- )
937
-
938
- table_re = re.compile(r"<table\b[^>]*>.*?</table>", re.IGNORECASE | re.DOTALL)
939
- out_parts: List[str] = []
940
- last_end = 0
941
- for tm in table_re.finditer(md):
942
- out_parts.append(md[last_end:tm.start()])
943
- tbl = tm.group(0)
944
-
945
- left = re.sub(r"<[^>]+>", " ", md[max(0, tm.start() - 700): tm.start()])
946
- right = re.sub(r"<[^>]+>", " ", md[tm.end(): min(len(md), tm.end() + 250)])
947
- ctx = f"{left} {right}"
948
- is_credit = bool(credit_hint_re.search(ctx))
949
- is_debit = bool(debit_hint_re.search(ctx))
950
- if is_credit ^ is_debit:
951
- w = "Credit" if is_credit else "Debit"
952
- tbl = re.sub(r"(<th\b[^>]*>\s*)amount(\s*</th>)", rf"\1{w}\2", tbl, flags=re.IGNORECASE)
953
- tbl = re.sub(r"(<td\b[^>]*>\s*)amount(\s*</td>)", rf"\1{w}\2", tbl, flags=re.IGNORECASE)
954
-
955
- out_parts.append(tbl)
956
- last_end = tm.end()
957
- out_parts.append(md[last_end:])
958
- return "".join(out_parts)
959
-
960
-
961
- def _strip_trailing_money_tokens(rest: str, max_take: int = 2) -> Tuple[str, List[str]]:
962
- """Strip 1–2 currency tokens from the right of *rest*; return (description, tokens oldest-first)."""
963
- cur = rest.rstrip()
964
- toks: List[str] = []
965
- for _ in range(max_take):
966
- cur = cur.rstrip()
967
- m = _EOL_MONEY.search(cur)
968
- if not m or m.end() != len(cur):
969
- break
970
- toks.append(m.group().strip())
971
- cur = cur[: m.start()].rstrip()
972
- toks.reverse()
973
- return cur, toks
974
-
975
-
976
- def _parse_ledger_line(line: str) -> Optional[Tuple[str, str, str, str]]:
977
- """
978
- If *line* looks like a bank-style ledger row (date, description, amount, optional balance),
979
- return (date, description, amount, balance_or_empty); else None.
980
- """
981
- ln = line.strip()
982
- if not ln or ln[0] in "#|!<":
983
- return None
984
- mm = re.match(
985
- r"^(?P<d>(?:\d{1,2}[/-]\d{1,2}(?:[/-]\d{2,4})?|"
986
- r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4}))"
987
- r"\s+",
988
- ln,
989
- re.I,
990
- )
991
- if not mm:
992
- return None
993
- d = mm.group("d")
994
- tail = ln[mm.end() :]
995
- desc, toks = _strip_trailing_money_tokens(tail, 2)
996
- desc = desc.strip()
997
- if not toks or len(desc) < 2 or len(desc) > 420:
998
- return None
999
- amt = toks[0]
1000
- bal = toks[1] if len(toks) > 1 else ""
1001
- return (d, desc, amt, bal)
1002
-
1003
-
1004
- def _ledger_rows_to_html(rows: List[Tuple[str, str, str, str]]) -> str:
1005
- has_bal = any(r[3] for r in rows)
1006
- head = (
1007
- "<tr><th>Date</th><th>Description</th><th>Amount</th>"
1008
- + ("<th>Balance</th>" if has_bal else "")
1009
- + "</tr>"
1010
- )
1011
- body_lines = []
1012
- for d, desc, amt, bal in rows:
1013
- cells = [
1014
- f"<td>{html.escape(d)}</td>",
1015
- f"<td>{html.escape(desc)}</td>",
1016
- f"<td>{html.escape(amt)}</td>",
1017
- ]
1018
- if has_bal:
1019
- cells.append(f"<td>{html.escape(bal)}</td>" if bal else "<td></td>")
1020
- body_lines.append("<tr>" + "".join(cells) + "</tr>")
1021
- return "<table>\n" + head + "\n" + "\n".join(body_lines) + "\n</table>"
1022
-
1023
-
1024
- def coalesce_loose_ledger_lines(text: str) -> str:
1025
- """
1026
- When the model emits many consecutive plain-text ledger lines (no HTML table),
1027
- downstream classification sees no tables and skips. Convert long runs of
1028
- generic date + amount lines into a single HTML table. Requires 5+ matching
1029
- consecutive non-empty lines; does not match institution names.
1030
- """
1031
- if not text or "<table" in text.lower():
1032
- return text
1033
- lines = text.split("\n")
1034
- out: List[str] = []
1035
- i = 0
1036
- while i < len(lines):
1037
- if not lines[i].strip():
1038
- out.append(lines[i])
1039
- i += 1
1040
- continue
1041
- run_rows: List[Tuple[str, str, str, str]] = []
1042
- j = i
1043
- while j < len(lines):
1044
- raw = lines[j]
1045
- if not raw.strip():
1046
- break
1047
- parsed = _parse_ledger_line(raw)
1048
- if parsed is None:
1049
- break
1050
- run_rows.append(parsed)
1051
- j += 1
1052
- if len(run_rows) >= 5:
1053
- if i > 0 and out and out[-1].strip():
1054
- out.append("")
1055
- out.append(_ledger_rows_to_html(run_rows))
1056
- i = j
1057
- continue
1058
- out.append(lines[i])
1059
- i += 1
1060
- return "\n".join(out)
1061
-
1062
-
1063
  def looks_like_markdown_table(block: str) -> bool:
1064
  lines = [ln.rstrip() for ln in block.strip().splitlines() if ln.strip()]
1065
  if len(lines) < 2:
@@ -1117,7 +794,7 @@ def normalize_money_glyphs(text: str) -> str:
1117
 
1118
 
1119
  def light_stabilize_markdown(page_md: str) -> str:
1120
- """Convert obvious GitHub-style pipe tables to HTML; normalize money glyphs; repair tags."""
1121
  if not page_md:
1122
  return page_md
1123
  page_md = normalize_money_glyphs(page_md)
@@ -1127,27 +804,34 @@ def light_stabilize_markdown(page_md: str) -> str:
1127
  if looks_like_markdown_table(b):
1128
  out_blocks.append(md_table_to_html(b))
1129
  else:
1130
- out_blocks.append(coalesce_loose_ledger_lines(b))
1131
- merged = close_unclosed_html("\n\n".join(out_blocks))
1132
- merged = strip_malformed_leading_table_rows(merged)
1133
- merged = strip_subtotal_rows_from_transaction_tables(merged)
1134
- merged = mask_non_monetary_long_numbers(merged)
1135
  merged = repair_thead_cell_semantics(merged)
1136
- merged = stabilize_table_markup(merged, rounds=4)
1137
  merged = strip_degenerate_html_tables(merged)
1138
  return merged
1139
 
1140
 
1141
  def _parse_amount_or_none(s: str):
1142
- t = (s or "").strip()
1143
- if not t:
 
 
1144
  return None
 
1145
  t = t.replace("$", "").replace(",", "").replace("(", "-").replace(")", "")
1146
  t = t.replace("−", "-").replace("–", "-").replace("—", "-")
1147
  if not re.search(r"\d", t):
1148
  return None
 
 
1149
  try:
1150
- return float(t)
 
 
 
 
 
1151
  except Exception:
1152
  return None
1153
 
@@ -1171,493 +855,279 @@ def _fmt_money(v: float) -> str:
1171
 
1172
  def _is_date_like(s: str) -> bool:
1173
  t = (s or "").strip()
1174
- return bool(re.match(r"^(?:\d{1,2}[/-]\d{1,2}(?:[/-]\d{2,4})?)$", t))
1175
-
1176
-
1177
- def normalize_ambiguous_amount_balance_tables(md: str) -> str:
1178
- """
1179
- For transaction-like tables that expose only a single Amount column plus
1180
- Balance, convert to explicit Credit/Debit using generic running-balance
1181
- inference. This avoids sign ambiguity and improves reconcile stability.
1182
- """
1183
- if not md or "<table" not in md.lower():
1184
- return md
1185
-
1186
- credit_hint_re = re.compile(
1187
- r"\b(deposit|deposits|credit|credits|incoming|received|interest earned|refund|return)\b",
1188
- re.IGNORECASE,
1189
- )
1190
- debit_hint_re = re.compile(
1191
- r"\b(debit|debits|withdrawal|withdrawals|check|checks|fee|fees|charge|charges|payment|transfer out|purchase)\b",
1192
- re.IGNORECASE,
1193
  )
1194
 
1195
- def repl_table(m: re.Match) -> str:
1196
- full = m.group(0)
1197
- rows = _extract_rows_plain_from_table(full)
1198
- if len(rows) < 3:
1199
- return full
1200
-
1201
- hdr = [c.strip().lower() for c in rows[0]]
1202
- amount_idxs = [i for i, c in enumerate(hdr) if "amount" in c]
1203
- balance_idxs = [i for i, c in enumerate(hdr) if "balance" in c]
1204
- has_credit = any("credit" in c for c in hdr)
1205
- has_debit = any("debit" in c for c in hdr)
1206
- date_idxs = [i for i, c in enumerate(hdr) if "date" in c]
1207
- desc_idxs = [i for i, c in enumerate(hdr) if "description" in c or "memo" in c or "details" in c]
1208
-
1209
- if has_credit or has_debit or not amount_idxs or not balance_idxs:
1210
- return full
1211
-
1212
- amount_idx = amount_idxs[0]
1213
- balance_idx = balance_idxs[0]
1214
- date_idx = date_idxs[0] if date_idxs else 0
1215
- desc_idx = desc_idxs[0] if desc_idxs else min(1, max(0, len(rows[0]) - 1))
1216
-
1217
- new_rows: List[List[str]] = [["Date", "Description", "Credit", "Debit", "Balance"]]
1218
- prev_bal = None
1219
- section_hint = ""
1220
- converted = 0
1221
 
1222
- for r in rows[1:]:
1223
- if not r:
1224
- continue
1225
- padded = r + [""] * (max(amount_idx, balance_idx, date_idx, desc_idx) + 1 - len(r))
1226
- date_txt = (padded[date_idx] or "").strip()
1227
- desc_txt = (padded[desc_idx] or "").strip()
1228
- amt_txt = (padded[amount_idx] or "").strip()
1229
- bal_txt = (padded[balance_idx] or "").strip()
1230
- amt = _parse_amount_or_none(amt_txt)
1231
- bal = _parse_amount_or_none(bal_txt)
1232
-
1233
- row_blob = " ".join((c or "").strip() for c in padded).strip()
1234
- if row_blob and not _is_date_like(date_txt) and amt is None:
1235
- section_hint = row_blob
1236
- continue
1237
- if amt is None and bal is None:
1238
- continue
1239
-
1240
- direction = ""
1241
- # Primary: infer from balance delta between consecutive rows.
1242
- if amt is not None and bal is not None and prev_bal is not None:
1243
- delta = bal - prev_bal
1244
- tol = max(0.55, 0.025 * abs(amt))
1245
- if abs(abs(delta) - abs(amt)) <= tol:
1246
- direction = "credit" if delta > 0 else "debit"
1247
-
1248
- # Secondary: section + description lexical hints.
1249
- ctx = f"{section_hint} {desc_txt}"
1250
- if not direction and credit_hint_re.search(ctx):
1251
- direction = "credit"
1252
- if not direction and debit_hint_re.search(ctx):
1253
- direction = "debit"
1254
-
1255
- credit = ""
1256
- debit = ""
1257
- if amt is not None:
1258
- if direction == "credit":
1259
- credit = _fmt_money(abs(amt))
1260
- elif direction == "debit":
1261
- debit = _fmt_money(abs(amt))
1262
- else:
1263
- # Conservative fallback for ambiguous rows: preserve as debit
1264
- # to avoid overstating inflows.
1265
- debit = _fmt_money(abs(amt))
1266
-
1267
- balance_out = _fmt_money(bal) if bal is not None else ""
1268
- new_rows.append([date_txt, desc_txt, credit, debit, balance_out])
1269
- if bal is not None:
1270
- prev_bal = bal
1271
- converted += 1
1272
-
1273
- if converted < 5:
1274
- return full
1275
-
1276
- html_rows = []
1277
- html_rows.append(
1278
- "<tr>" + "".join(f"<th>{html.escape(c)}</th>" for c in new_rows[0]) + "</tr>"
1279
- )
1280
- for rr in new_rows[1:]:
1281
- html_rows.append(
1282
- "<tr>" + "".join(f"<td>{html.escape(c)}</td>" for c in rr) + "</tr>"
1283
- )
1284
- return "<table>\n" + "\n".join(html_rows) + "\n</table>"
1285
-
1286
- return re.sub(
1287
- r"<table\b[^>]*>.*?</table>",
1288
- repl_table,
1289
- md,
1290
- flags=re.IGNORECASE | re.DOTALL,
1291
- )
1292
 
1293
 
1294
- def normalize_amount_only_transaction_tables(md: str) -> str:
1295
- """
1296
- Convert transaction-like tables with Date/Description/Amount (no Credit/Debit)
1297
- into explicit Credit/Debit columns using generic lexical heuristics.
1298
- """
1299
- if not md or "<table" not in md.lower():
1300
  return md
1301
-
1302
- credit_hint_re = re.compile(
1303
- r"\b(deposit|deposits|credit|credits|incoming|received|return|refund|interest|pmt cr)\b",
1304
- re.IGNORECASE,
1305
- )
1306
- debit_hint_re = re.compile(
1307
- r"\b(debit|debits|withdrawal|withdrawals|check|checks|fee|fees|charge|charges|payment|purchase|ach|bill)\b",
1308
- re.IGNORECASE,
 
 
1309
  )
 
 
 
 
 
 
1310
 
1311
- def repl_table(m: re.Match) -> str:
1312
- full = m.group(0)
1313
- rows = _extract_rows_plain_from_table(full)
1314
- if len(rows) < 3:
1315
- return full
1316
- hdr = [c.strip().lower() for c in rows[0]]
1317
- if any("credit" in c for c in hdr) or any("debit" in c for c in hdr):
1318
- return full
1319
- amount_idxs = [i for i, c in enumerate(hdr) if "amount" in c]
1320
- date_idxs = [i for i, c in enumerate(hdr) if "date" in c]
1321
- desc_idxs = [i for i, c in enumerate(hdr) if "description" in c or "memo" in c or "details" in c]
1322
- if not amount_idxs or not date_idxs:
1323
- return full
1324
- amount_idx = amount_idxs[0]
1325
- date_idx = date_idxs[0]
1326
- desc_idx = desc_idxs[0] if desc_idxs else min(1, len(rows[0]) - 1)
1327
- table_ctx = _cell_plain_text(full)
1328
 
1329
- new_rows = [["Date", "Description", "Credit", "Debit"]]
1330
- converted = 0
1331
- for r in rows[1:]:
 
 
 
 
 
 
 
 
 
1332
  if not r:
1333
  continue
1334
- padded = r + [""] * (max(amount_idx, date_idx, desc_idx) + 1 - len(r))
1335
- date_txt = (padded[date_idx] or "").strip()
1336
- if not _is_date_like(date_txt):
1337
- continue
1338
- desc_txt = (padded[desc_idx] or "").strip()
1339
- amt = _parse_amount_or_none((padded[amount_idx] or "").strip())
1340
- if amt is None:
 
1341
  continue
1342
- ctx = f"{table_ctx} {desc_txt}"
1343
- credit = ""
1344
- debit = ""
1345
- if credit_hint_re.search(ctx) and not debit_hint_re.search(ctx):
1346
- credit = _fmt_money(abs(amt))
1347
- elif debit_hint_re.search(ctx) and not credit_hint_re.search(ctx):
1348
- debit = _fmt_money(abs(amt))
1349
- else:
1350
- # Conservative default for unknown direction.
1351
- debit = _fmt_money(abs(amt))
1352
- new_rows.append([date_txt, desc_txt, credit, debit])
1353
- converted += 1
1354
-
1355
- if converted < 5:
1356
- return full
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1357
 
1358
- html_rows = ["<tr>" + "".join(f"<th>{html.escape(c)}</th>" for c in new_rows[0]) + "</tr>"]
1359
- for rr in new_rows[1:]:
1360
- html_rows.append("<tr>" + "".join(f"<td>{html.escape(c)}</td>" for c in rr) + "</tr>")
1361
- return "<table>\n" + "\n".join(html_rows) + "\n</table>"
1362
 
1363
- return re.sub(
1364
- r"<table\b[^>]*>.*?</table>",
1365
- repl_table,
1366
- md,
1367
- flags=re.IGNORECASE | re.DOTALL,
1368
- )
 
 
 
 
 
1369
 
1370
 
1371
- def infer_credit_debit_from_balance_deltas(md: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1372
  """
1373
- When OCR leaves Credit/Debit empty but a running Balance column is present,
1374
- infer signed movement from consecutive balance deltas (same for all banks).
1375
  """
1376
  if not md or "<table" not in md.lower():
1377
  return md
 
 
 
1378
 
1379
- def repl_table(m: re.Match) -> str:
1380
- full = m.group(0)
 
 
 
 
 
 
 
 
1381
  rows = _extract_rows_plain_from_table(full)
1382
  if len(rows) < 3:
1383
- return full
1384
-
1385
  hdr = [c.strip() for c in rows[0]]
1386
  hdr_l = [h.lower() for h in hdr]
1387
  date_i = next((i for i, h in enumerate(hdr_l) if "date" in h), None)
1388
- desc_i = next(
1389
- (i for i, h in enumerate(hdr_l) if "description" in h or "memo" in h or "details" in h),
1390
- None,
1391
- )
 
1392
  credit_i = next((i for i, h in enumerate(hdr_l) if "credit" in h), None)
1393
  debit_i = next((i for i, h in enumerate(hdr_l) if "debit" in h), None)
1394
- balance_i = next((i for i, h in enumerate(hdr_l) if "balance" in h), None)
 
1395
 
1396
- if date_i is None or desc_i is None or credit_i is None or debit_i is None or balance_i is None:
1397
- return full
 
1398
 
1399
  max_len = max(len(hdr), max((len(r) for r in rows), default=0))
1400
- body_bals: List[Optional[float]] = []
1401
- body_rows: List[List[str]] = []
1402
- for r in rows[1:]:
1403
- padded = (r + [""] * (max_len - len(r)))[:max_len]
1404
- dt = (padded[date_i] or "").strip()
1405
  if not _is_date_like(dt):
1406
  continue
1407
- cr = _parse_amount_or_none((padded[credit_i] or "").strip()) if credit_i < len(padded) else None
1408
- db = _parse_amount_or_none((padded[debit_i] or "").strip()) if debit_i < len(padded) else None
1409
- bal = _parse_amount_or_none((padded[balance_i] or "").strip()) if balance_i < len(padded) else None
1410
- body_rows.append(padded)
1411
- body_bals.append(bal)
1412
-
1413
- if len(body_rows) < 3:
1414
- return full
1415
-
1416
- empty_cd = 0
1417
- for i, pr in enumerate(body_rows):
1418
- cr = _parse_amount_or_none((pr[credit_i] or "").strip()) if credit_i < len(pr) else None
1419
- db = _parse_amount_or_none((pr[debit_i] or "").strip()) if debit_i < len(pr) else None
1420
- if cr is None and db is None:
1421
- empty_cd += 1
1422
- if empty_cd < max(3, int(0.6 * len(body_rows))):
1423
- return full
1424
-
1425
- new_body: List[List[str]] = []
1426
- for i, pr in enumerate(body_rows):
1427
- row = pr[:]
1428
- cr0 = _parse_amount_or_none((row[credit_i] or "").strip()) if credit_i < len(row) else None
1429
- db0 = _parse_amount_or_none((row[debit_i] or "").strip()) if debit_i < len(row) else None
1430
- if cr0 is not None or db0 is not None:
1431
- new_body.append(row)
1432
- continue
1433
- if i == 0 or body_bals[i] is None:
1434
- new_body.append(row)
1435
- continue
1436
- prev_b = body_bals[i - 1]
1437
- cur_b = body_bals[i]
1438
- if prev_b is None or cur_b is None:
1439
- new_body.append(row)
1440
- continue
1441
- delta = cur_b - prev_b
1442
- if abs(delta) < 1e-6:
1443
- new_body.append(row)
1444
- continue
1445
- tol = max(0.55, 0.025 * abs(delta))
1446
- if abs(delta) <= tol:
1447
- new_body.append(row)
1448
- continue
1449
- if delta > 0:
1450
- row[credit_i] = _fmt_money(delta)
1451
- else:
1452
- row[debit_i] = _fmt_money(-delta)
1453
- new_body.append(row)
1454
-
1455
- filled = 0
1456
- for i, pr in enumerate(new_body):
1457
- cr = _parse_amount_or_none((pr[credit_i] or "").strip()) if credit_i < len(pr) else None
1458
- db = _parse_amount_or_none((pr[debit_i] or "").strip()) if debit_i < len(pr) else None
1459
- if cr is not None or db is not None:
1460
- filled += 1
1461
- if filled < max(2, int(0.35 * len(new_body))):
1462
- return full
1463
-
1464
- html_rows = ["<tr>" + "".join(f"<th>{html.escape(c)}</th>" for c in hdr) + "</tr>"]
1465
- for pr in new_body:
1466
- cells = (pr + [""] * (len(hdr) - len(pr)))[: len(hdr)]
1467
- html_rows.append(
1468
- "<tr>" + "".join(f"<td>{html.escape((c or '').strip())}</td>" for c in cells) + "</tr>"
1469
  )
1470
- return "<table>\n" + "\n".join(html_rows) + "\n</table>"
1471
-
1472
- return re.sub(
1473
- r"<table\b[^>]*>.*?</table>",
1474
- repl_table,
1475
- md,
1476
- flags=re.IGNORECASE | re.DOTALL,
1477
- )
1478
-
 
 
 
 
 
 
 
 
 
1479
 
1480
- def sanitize_transaction_tables(md: str) -> str:
1481
- """
1482
- Final defensive cleanup for transaction-like tables:
1483
- - remove in-body header repeats and subtotal rows
1484
- - deduplicate repeated rows
1485
- - correct likely credit/debit direction using row text and balance deltas
1486
- """
1487
- if not md or "<table" not in md.lower():
1488
  return md
1489
 
1490
- credit_kw = re.compile(
1491
- r"\b(deposit|deposits|credit|credits|recd|received|return|refund|interest|rtp\s*rcvd|pmt\s*cr)\b",
1492
- re.IGNORECASE,
1493
- )
1494
- debit_kw = re.compile(
1495
- r"\b(debit|debits|withdrawal|withdrawals|check|checks|payment|purchase|charge|fee|ach)\b",
1496
- re.IGNORECASE,
1497
- )
1498
 
1499
- def parse_money(txt: str):
1500
- t = (txt or "").strip()
1501
- if not t:
1502
- return None
1503
- return _parse_amount_or_none(t)
1504
-
1505
- def fmt_money(v: float) -> str:
1506
- return f"{abs(v):,.2f}"
1507
-
1508
- def repl_table(m: re.Match) -> str:
1509
- full = m.group(0)
1510
- rows = _extract_rows_plain_from_table(full)
1511
- if len(rows) < 2:
1512
- return full
1513
 
1514
- hdr = [c.strip() for c in rows[0]]
1515
- hdr_l = [h.lower() for h in hdr]
1516
- date_i = next((i for i, h in enumerate(hdr_l) if "date" in h), None)
1517
- desc_i = next((i for i, h in enumerate(hdr_l) if "description" in h or "memo" in h or "details" in h), None)
1518
- credit_i = next((i for i, h in enumerate(hdr_l) if "credit" in h), None)
1519
- debit_i = next((i for i, h in enumerate(hdr_l) if "debit" in h), None)
1520
- amount_i = next((i for i, h in enumerate(hdr_l) if "amount" in h), None)
1521
- balance_i = next((i for i, h in enumerate(hdr_l) if "balance" in h), None)
1522
- is_txn = (date_i is not None) and (desc_i is not None) and (
1523
- credit_i is not None or debit_i is not None or amount_i is not None
1524
- )
1525
- if not is_txn:
1526
- return full
1527
- # Without a real balance column, do not rewrite the table (avoids blank balances).
1528
  if balance_i is None:
1529
- return full
1530
-
1531
- max_cols = max(len(hdr), max((len(r) for r in rows), default=len(hdr)))
1532
- if max_cols < 4:
1533
- max_cols = 4
1534
-
1535
- new_hdr = hdr[:]
1536
- if amount_i is not None and credit_i is None and debit_i is None:
1537
- new_hdr[amount_i] = "Debit"
1538
- debit_i = amount_i
1539
- amount_i = None
1540
- if credit_i is None:
1541
- new_hdr.append("Credit")
1542
- credit_i = len(new_hdr) - 1
1543
- if debit_i is None:
1544
- new_hdr.append("Debit")
1545
- debit_i = len(new_hdr) - 1
1546
-
1547
- records = []
1548
- seen = set()
1549
- for r in rows[1:]:
1550
- padded = r + [""] * (len(new_hdr) - len(r))
1551
- low = [c.strip().lower() for c in padded]
1552
- joined = " ".join(low)
1553
-
1554
- # Remove duplicate header rows inside body and subtotal/total lines.
1555
- if any(
1556
- x in low
1557
- for x in ("date", "posting date", "description", "amount", "credit", "debit", "balance")
1558
- ):
1559
- continue
1560
- if "subtotal" in joined or joined.startswith("total "):
1561
- continue
1562
-
1563
- dt = padded[date_i].strip() if date_i is not None and date_i < len(padded) else ""
1564
- if not _is_date_like(dt):
1565
- continue
1566
- desc = padded[desc_i].strip() if desc_i is not None and desc_i < len(padded) else ""
1567
- bal = parse_money(padded[balance_i]) if balance_i < len(padded) else None
1568
- cr = parse_money(padded[credit_i]) if credit_i < len(padded) else None
1569
- db = parse_money(padded[debit_i]) if debit_i < len(padded) else None
1570
-
1571
- if cr is None and db is None:
1572
- continue
1573
-
1574
- amt = cr if cr is not None else db
1575
- direction = "credit" if cr is not None else "debit"
1576
-
1577
- # Row-level lexical correction
1578
- if amt is not None:
1579
- if credit_kw.search(desc) and not debit_kw.search(desc):
1580
- direction = "credit"
1581
- elif debit_kw.search(desc) and not credit_kw.search(desc):
1582
- direction = "debit"
1583
-
1584
- key = (dt, desc, f"{amt:.2f}" if isinstance(amt, (int, float)) else "", f"{bal:.2f}" if isinstance(bal, (int, float)) else "")
1585
- if key in seen:
1586
- continue
1587
- seen.add(key)
1588
- records.append(
1589
- {
1590
- "row": padded[: len(new_hdr)],
1591
- "amount": abs(amt) if amt is not None else None,
1592
- "balance": bal,
1593
- "lex_dir": direction,
1594
- }
1595
- )
1596
-
1597
- if not records:
1598
- return full
1599
-
1600
- # Choose balance interpretation direction (forward vs reverse row order)
1601
- # based on which one produces more valid delta-to-amount matches.
1602
- def delta_match(delta: float, amt_val: float) -> str:
1603
- tol = max(0.75, 0.03 * abs(amt_val))
1604
- if abs(delta - abs(amt_val)) <= tol:
1605
- return "credit"
1606
- if abs(delta + abs(amt_val)) <= tol:
1607
- return "debit"
1608
- return ""
1609
-
1610
- fwd_matches = 0
1611
- rev_matches = 0
1612
- prev_bal = None
1613
- for rec in records:
1614
- bal = rec["balance"]
1615
- amt_val = rec["amount"]
1616
- if bal is None or amt_val is None:
1617
  continue
1618
- if prev_bal is not None:
1619
- if delta_match(bal - prev_bal, amt_val):
1620
- fwd_matches += 1
1621
- if delta_match(prev_bal - bal, amt_val):
1622
- rev_matches += 1
1623
- prev_bal = bal
1624
-
1625
- use_reverse = rev_matches > fwd_matches
1626
-
1627
- body = []
1628
- prev_bal = None
1629
- for rec in records:
1630
- out = rec["row"][:]
1631
- amt_val = rec["amount"]
1632
- bal = rec["balance"]
1633
- direction = rec["lex_dir"]
1634
-
1635
- if amt_val is not None and bal is not None and prev_bal is not None:
1636
- delta = (prev_bal - bal) if use_reverse else (bal - prev_bal)
1637
- d = delta_match(delta, amt_val)
1638
- if d:
1639
- direction = d
1640
- if bal is not None:
1641
- prev_bal = bal
1642
-
1643
- out[credit_i] = fmt_money(amt_val) if (amt_val is not None and direction == "credit") else ""
1644
- out[debit_i] = fmt_money(amt_val) if (amt_val is not None and direction == "debit") else ""
1645
- out[balance_i] = f"{bal:,.2f}" if bal is not None else out[balance_i]
1646
- body.append(out)
1647
-
1648
- if not body:
1649
- return full
1650
- html_rows = ["<tr>" + "".join(f"<th>{html.escape(c)}</th>" for c in new_hdr) + "</tr>"]
1651
- for rr in body:
1652
- html_rows.append("<tr>" + "".join(f"<td>{html.escape(c)}</td>" for c in rr) + "</tr>")
1653
- return "<table>\n" + "\n".join(html_rows) + "\n</table>"
1654
 
1655
- return re.sub(
1656
- r"<table\b[^>]*>.*?</table>",
1657
- repl_table,
1658
- md,
1659
- flags=re.IGNORECASE | re.DOTALL,
1660
- )
1661
 
1662
 
1663
  def _extract_daily_balance_by_date(md: str):
@@ -1705,333 +1175,6 @@ def _extract_daily_balance_by_date(md: str):
1705
  return out
1706
 
1707
 
1708
- def enrich_transaction_tables_with_daily_balances(md: str) -> str:
1709
- """
1710
- If transaction tables have Date+Amount but no Balance column, append a Balance
1711
- column using date-matched daily ending balances from the same document.
1712
- """
1713
- if not md or "<table" not in md.lower():
1714
- return md
1715
- dmap = _extract_daily_balance_by_date(md)
1716
- if not dmap:
1717
- return md
1718
-
1719
- def repl(m: re.Match) -> str:
1720
- table = m.group(0)
1721
- if re.search(r"(?:colspan|rowspan)\s*=", table, flags=re.IGNORECASE):
1722
- return table
1723
- tr_blocks = list(re.finditer(r"<tr\b[^>]*>.*?</tr>", table, flags=re.IGNORECASE | re.DOTALL))
1724
- if len(tr_blocks) < 2:
1725
- return table
1726
- first = tr_blocks[0].group(0)
1727
- h_inner_m = re.search(r"<tr\b[^>]*>(.*)</tr>", first, flags=re.IGNORECASE | re.DOTALL)
1728
- if not h_inner_m:
1729
- return table
1730
- h_inner = h_inner_m.group(1)
1731
- h_cells = list(_CELL.finditer(h_inner))
1732
- if not h_cells:
1733
- return table
1734
- hdr_txt = [_cell_plain_text(c.group(0)).strip().lower() for c in h_cells]
1735
- if any("balance" in c for c in hdr_txt):
1736
- return table
1737
- if not any("date" in c for c in hdr_txt):
1738
- return table
1739
- if not any(("amount" in c) or ("debit" in c) or ("credit" in c) for c in hdr_txt):
1740
- return table
1741
- date_idx = next((i for i, c in enumerate(hdr_txt) if "date" in c), -1)
1742
- if date_idx < 0:
1743
- return table
1744
-
1745
- new_parts: List[str] = []
1746
- cursor = 0
1747
- added = 0
1748
- for i, trm in enumerate(tr_blocks):
1749
- new_parts.append(table[cursor : trm.start()])
1750
- tr_seg = trm.group(0)
1751
- inner_m = re.search(r"<tr\b[^>]*>(.*)</tr>", tr_seg, flags=re.IGNORECASE | re.DOTALL)
1752
- if not inner_m:
1753
- new_parts.append(tr_seg)
1754
- cursor = trm.end()
1755
- continue
1756
- tr_open_m = re.search(r"<tr\b[^>]*>", tr_seg, flags=re.IGNORECASE)
1757
- tr_open = tr_open_m.group(0) if tr_open_m else "<tr>"
1758
- tr_close = "</tr>"
1759
- inner = inner_m.group(1)
1760
- cells = [c.group(0) for c in _CELL.finditer(inner)]
1761
- if i == 0:
1762
- new_inner = inner + "<th>Balance</th>"
1763
- new_parts.append(tr_open + new_inner + tr_close)
1764
- else:
1765
- if date_idx >= len(cells):
1766
- new_parts.append(tr_seg)
1767
- cursor = trm.end()
1768
- continue
1769
- d_txt = _cell_plain_text(cells[date_idx])
1770
- dm = re.search(r"\d{1,2}/\d{1,2}(?:/\d{2,4})?", d_txt or "")
1771
- bal = dmap.get(dm.group(0)) if dm else None
1772
- if bal is None and dm:
1773
- bal = dmap.get("/".join(dm.group(0).split("/")[:2]))
1774
- if bal is None:
1775
- new_parts.append(tr_seg)
1776
- cursor = trm.end()
1777
- continue
1778
- new_inner = inner + f"<td>{bal:,.2f}</td>"
1779
- new_parts.append(tr_open + new_inner + tr_close)
1780
- added += 1
1781
- cursor = trm.end()
1782
- new_parts.append(table[cursor:])
1783
- if added < 3:
1784
- return table
1785
- return "".join(new_parts)
1786
-
1787
- return re.sub(r"<table\b[^>]*>.*?</table>", repl, md, flags=re.IGNORECASE | re.DOTALL)
1788
-
1789
-
1790
- def _parse_statement_edge_balances(md: str):
1791
- start = None
1792
- end = None
1793
- m1 = re.search(
1794
- r"(?:Beginning|Starting)\s+balance[^$\n]{0,80}\$?\s*(-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?)",
1795
- md,
1796
- flags=re.IGNORECASE,
1797
- )
1798
- if m1:
1799
- start = _parse_amount_or_none(m1.group(1))
1800
- m2 = re.search(
1801
- r"Ending\s+balance[^$\n]{0,80}\$?\s*(-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?)",
1802
- md,
1803
- flags=re.IGNORECASE,
1804
- )
1805
- if m2:
1806
- end = _parse_amount_or_none(m2.group(1))
1807
- return start, end
1808
-
1809
-
1810
- def synthesize_running_balance_columns(md: str) -> str:
1811
- """
1812
- Fallback when no explicit balance column exists: infer running balances from
1813
- statement beginning/ending balance and signed transaction amounts.
1814
- """
1815
- if not md or "<table" not in md.lower():
1816
- return md
1817
- start_bal, end_bal = _parse_statement_edge_balances(md)
1818
- if start_bal is None and end_bal is None:
1819
- return md
1820
-
1821
- table_re = re.compile(r"<table\b[^>]*>.*?</table>", re.IGNORECASE | re.DOTALL)
1822
- tr_re = re.compile(r"<tr\b[^>]*>.*?</tr>", re.IGNORECASE | re.DOTALL)
1823
-
1824
- tables = list(table_re.finditer(md))
1825
- row_refs = []
1826
- amounts = []
1827
- headers = {}
1828
-
1829
- for ti, tm in enumerate(tables):
1830
- t = tm.group(0)
1831
- if re.search(r"(?:colspan|rowspan)\s*=", t, flags=re.IGNORECASE):
1832
- continue
1833
- trs = list(tr_re.finditer(t))
1834
- if len(trs) < 2:
1835
- continue
1836
- h_inner_m = re.search(r"<tr\b[^>]*>(.*)</tr>", trs[0].group(0), flags=re.IGNORECASE | re.DOTALL)
1837
- if not h_inner_m:
1838
- continue
1839
- h_cells = [c.group(0) for c in _CELL.finditer(h_inner_m.group(1))]
1840
- if not h_cells:
1841
- continue
1842
- htxt = [_cell_plain_text(c).strip().lower() for c in h_cells]
1843
- if any("balance" in h for h in htxt):
1844
- continue
1845
- date_idx = next((i for i, h in enumerate(htxt) if "date" in h), -1)
1846
- amt_idx = next((i for i, h in enumerate(htxt) if "amount" in h or "debit" in h or "credit" in h), -1)
1847
- if date_idx < 0 or amt_idx < 0:
1848
- continue
1849
- headers[ti] = (date_idx, amt_idx)
1850
- for ri, trm in enumerate(trs[1:], 1):
1851
- inner_m = re.search(r"<tr\b[^>]*>(.*)</tr>", trm.group(0), flags=re.IGNORECASE | re.DOTALL)
1852
- if not inner_m:
1853
- continue
1854
- cells = [c.group(0) for c in _CELL.finditer(inner_m.group(1))]
1855
- if len(cells) <= max(date_idx, amt_idx):
1856
- continue
1857
- d_txt = _cell_plain_text(cells[date_idx])
1858
- if not re.search(r"\d{1,2}/\d{1,2}(?:/\d{2,4})?", d_txt or ""):
1859
- continue
1860
- amt = _parse_amount_or_none(_cell_plain_text(cells[amt_idx]))
1861
- if amt is None:
1862
- continue
1863
- row_refs.append((ti, ri))
1864
- amounts.append(amt)
1865
-
1866
- if len(amounts) < 20:
1867
- return md
1868
- if start_bal is None:
1869
- start_bal = (end_bal or 0.0) - sum(amounts)
1870
-
1871
- running = []
1872
- cur = float(start_bal)
1873
- for a in amounts:
1874
- cur += float(a)
1875
- running.append(cur)
1876
- bal_by_ref = {row_refs[i]: running[i] for i in range(len(row_refs))}
1877
-
1878
- pieces = []
1879
- last = 0
1880
- for ti, tm in enumerate(tables):
1881
- pieces.append(md[last : tm.start()])
1882
- t = tm.group(0)
1883
- if ti not in headers:
1884
- pieces.append(t)
1885
- last = tm.end()
1886
- continue
1887
- date_idx, amt_idx = headers[ti]
1888
- trs = list(tr_re.finditer(t))
1889
- out_t = []
1890
- cur2 = 0
1891
- for idx, trm in enumerate(trs):
1892
- out_t.append(t[cur2 : trm.start()])
1893
- tr_seg = trm.group(0)
1894
- inner_m = re.search(r"<tr\b[^>]*>(.*)</tr>", tr_seg, flags=re.IGNORECASE | re.DOTALL)
1895
- tr_open_m = re.search(r"<tr\b[^>]*>", tr_seg, flags=re.IGNORECASE)
1896
- tr_open = tr_open_m.group(0) if tr_open_m else "<tr>"
1897
- if not inner_m:
1898
- out_t.append(tr_seg)
1899
- cur2 = trm.end()
1900
- continue
1901
- inner = inner_m.group(1)
1902
- if idx == 0:
1903
- out_t.append(tr_open + inner + "<th>Balance</th></tr>")
1904
- else:
1905
- b = bal_by_ref.get((ti, idx))
1906
- if b is None:
1907
- out_t.append(tr_seg)
1908
- else:
1909
- out_t.append(tr_open + inner + f"<td>{b:,.2f}</td></tr>")
1910
- cur2 = trm.end()
1911
- out_t.append(t[cur2:])
1912
- pieces.append("".join(out_t))
1913
- last = tm.end()
1914
- pieces.append(md[last:])
1915
- return "".join(pieces)
1916
-
1917
-
1918
- def _extract_txn_date_balance_pairs(md: str):
1919
- pairs = []
1920
- for tm in re.finditer(r"<table\b[^>]*>.*?</table>", md, flags=re.IGNORECASE | re.DOTALL):
1921
- t = tm.group(0)
1922
- rows = _extract_rows_plain_from_table(t)
1923
- if len(rows) < 2:
1924
- continue
1925
- hdr = [c.strip().lower() for c in rows[0]]
1926
- if not hdr:
1927
- continue
1928
- d_i = next((i for i, h in enumerate(hdr) if "date" in h), -1)
1929
- b_i = next((i for i, h in enumerate(hdr) if "balance" in h), -1)
1930
- a_i = next((i for i, h in enumerate(hdr) if "amount" in h or "debit" in h or "credit" in h), -1)
1931
- if d_i < 0 or b_i < 0 or a_i < 0:
1932
- continue
1933
- for r in rows[1:]:
1934
- if len(r) <= max(d_i, b_i):
1935
- continue
1936
- dm = re.search(r"\d{1,2}/\d{1,2}(?:/\d{2,4})?", (r[d_i] or ""))
1937
- if not dm:
1938
- continue
1939
- bal = _parse_amount_or_none(r[b_i])
1940
- if bal is None:
1941
- continue
1942
- pairs.append((dm.group(0), bal))
1943
- return pairs
1944
-
1945
-
1946
- def augment_with_derived_balance_views(md: str) -> str:
1947
- """
1948
- Add a compact derived daily balance table and inferred beginning/ending summary
1949
- when the document lacks a robust explicit daily balance section.
1950
- """
1951
- if not md:
1952
- return md
1953
- # If there is already a clear daily-balance section, keep output unchanged.
1954
- if re.search(r"daily\s+(?:ledger\s+)?balances", md, flags=re.IGNORECASE):
1955
- return md
1956
-
1957
- pairs = _extract_txn_date_balance_pairs(md)
1958
- if len(pairs) < 12:
1959
- return md
1960
-
1961
- by_day = {}
1962
- for d, b in pairs:
1963
- k = "/".join(d.split("/")[:2])
1964
- by_day[k] = b
1965
- if len(by_day) < 8:
1966
- return md
1967
-
1968
- days = sorted(
1969
- by_day.keys(),
1970
- key=lambda x: (int(x.split("/")[0]), int(x.split("/")[1])),
1971
- )
1972
- begin = by_day[days[0]]
1973
- end = by_day[days[-1]]
1974
-
1975
- has_begin = bool(re.search(r"(?:beginning|starting)\s+balance", md, flags=re.IGNORECASE))
1976
- has_end = bool(re.search(r"ending\s+balance", md, flags=re.IGNORECASE))
1977
-
1978
- rows = ["<tr><th>Date</th><th>Balance</th></tr>"]
1979
- for d in days:
1980
- rows.append(f"<tr><td>{html.escape(d)}</td><td>{by_day[d]:,.2f}</td></tr>")
1981
- daily_tbl = "<table>\n" + "\n".join(rows) + "\n</table>"
1982
-
1983
- extra = ["", "<div align=\"center\">Derived daily balances</div>", daily_tbl]
1984
- if not has_begin or not has_end:
1985
- extra.insert(
1986
- 0,
1987
- (
1988
- f"Inferred beginning balance: {begin:,.2f}\n"
1989
- f"Inferred ending balance: {end:,.2f}"
1990
- ),
1991
- )
1992
- return md.rstrip() + "\n\n" + "\n\n".join(extra) + "\n"
1993
-
1994
-
1995
- def prepend_balance_snapshot(md: str) -> str:
1996
- """
1997
- Add a compact, explicit balance snapshot near the top so downstream metadata
1998
- extraction consistently captures beginning/ending balances.
1999
- """
2000
- if not md:
2001
- return md
2002
- if "balance snapshot" in md.lower():
2003
- return md
2004
-
2005
- start_bal, end_bal = _parse_statement_edge_balances(md)
2006
- if start_bal is None and end_bal is None:
2007
- # Try account-summary table fallback
2008
- for tm in re.finditer(r"<table\b[^>]*>.*?</table>", md, flags=re.IGNORECASE | re.DOTALL):
2009
- rows = _extract_rows_plain_from_table(tm.group(0))
2010
- for r in rows:
2011
- if len(r) < 2:
2012
- continue
2013
- key = (r[0] or "").strip().lower()
2014
- val = _parse_amount_or_none(r[1] if len(r) > 1 else "")
2015
- if val is None:
2016
- continue
2017
- if start_bal is None and ("beginning balance" in key or "starting balance" in key):
2018
- start_bal = val
2019
- if end_bal is None and "ending balance" in key:
2020
- end_bal = val
2021
- if start_bal is not None or end_bal is not None:
2022
- break
2023
- if start_bal is None and end_bal is None:
2024
- return md
2025
-
2026
- lines = ["## Balance Snapshot"]
2027
- if start_bal is not None:
2028
- lines.append(f"Beginning balance: {start_bal:,.2f}")
2029
- if end_bal is not None:
2030
- lines.append(f"Ending balance: {end_bal:,.2f}")
2031
- header = "\n".join(lines)
2032
- return header + "\n\n" + md
2033
-
2034
-
2035
  def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]:
2036
  import pymupdf as fitz
2037
  from PIL import Image
@@ -2058,44 +1201,10 @@ def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]:
2058
  canvas.paste(img, (pad_l, pad_t))
2059
  img = canvas
2060
 
2061
- # Deterministic guardrail: downscale oversized pages before upload.
2062
- iw, ih = img.size
2063
- long_side = max(iw, ih)
2064
- total_px = iw * ih
2065
- if long_side > MAX_PAGE_LONG_SIDE or total_px > MAX_PAGE_TOTAL_PIXELS:
2066
- scale_long = MAX_PAGE_LONG_SIDE / float(long_side)
2067
- scale_area = (MAX_PAGE_TOTAL_PIXELS / float(total_px)) ** 0.5
2068
- scale = min(scale_long, scale_area, 1.0)
2069
- nw = max(900, int(iw * scale))
2070
- nh = max(900, int(ih * scale))
2071
- img = img.resize((nw, nh), Image.Resampling.LANCZOS)
2072
-
2073
- base_path = os.path.join(tempfile.gettempdir(), f"glmocr_page_{os.getpid()}_{i}")
2074
- img_path = f"{base_path}.png"
2075
  img.save(img_path, "PNG", compress_level=PAGE_PNG_COMPRESS_LEVEL)
2076
- try:
2077
- size_bytes = os.path.getsize(img_path)
2078
- except OSError:
2079
- size_bytes = 0
2080
- if size_bytes > MAX_PAGE_UPLOAD_BYTES:
2081
- jpg_path = f"{base_path}.jpg"
2082
- quality = PAGE_JPEG_QUALITY
2083
- work = img
2084
- for _ in range(5):
2085
- work.save(jpg_path, "JPEG", quality=quality, optimize=True)
2086
- try:
2087
- jsize = os.path.getsize(jpg_path)
2088
- except OSError:
2089
- jsize = 0
2090
- if 0 < jsize <= MAX_PAGE_UPLOAD_BYTES:
2091
- img_path = jpg_path
2092
- break
2093
- quality = max(68, quality - 6)
2094
- nw = max(900, int(work.width * 0.93))
2095
- nh = max(900, int(work.height * 0.93))
2096
- work = work.resize((nw, nh), Image.Resampling.LANCZOS)
2097
- else:
2098
- img_path = jpg_path
2099
  page_images.append(img_path)
2100
  page_heights.append(img.height)
2101
 
@@ -2204,17 +1313,6 @@ def run_ocr(uploaded_file):
2204
 
2205
  merged = "\n\n---page-separator---\n\n".join(all_pages) if all_pages else "(No content)"
2206
  if merged and merged != "(No content)" and not merged.lstrip().startswith("Error:"):
2207
- merged = strip_malformed_leading_table_rows(merged)
2208
- merged = strip_subtotal_rows_from_transaction_tables(merged)
2209
- merged = mask_non_monetary_long_numbers(merged)
2210
- merged = normalize_ambiguous_amount_balance_tables(merged)
2211
- merged = normalize_amount_only_transaction_tables(merged)
2212
- merged = infer_credit_debit_from_balance_deltas(merged)
2213
- merged = sanitize_transaction_tables(merged)
2214
- merged = stabilize_table_markup(merged, rounds=4)
2215
- merged = enrich_transaction_tables_with_daily_balances(merged)
2216
- # Keep transaction amounts untouched; add explicit snapshot for metadata extraction.
2217
- merged = prepend_balance_snapshot(merged)
2218
  merged = stabilize_table_markup(merged, rounds=2)
2219
  merged = repair_thead_cell_semantics(merged)
2220
  merged = strip_degenerate_html_tables(merged)
@@ -2229,11 +1327,7 @@ def run_ocr(uploaded_file):
2229
  finally:
2230
  for p in page_images:
2231
  try:
2232
- if (
2233
- isinstance(p, str)
2234
- and "glmocr_page_" in os.path.basename(p)
2235
- and (p.endswith(".png") or p.endswith(".jpg") or p.endswith(".jpeg"))
2236
- ):
2237
  os.unlink(p)
2238
  except Exception:
2239
  pass
 
26
  but do not disable an entire table. Stabilization runs in multiple passes.
27
  - Split single cells that clearly contain transaction amount + trailing balance
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 +
 
53
  import os
54
  import re
55
  import tempfile
56
+ import uuid
57
  from collections import Counter
58
  from typing import List, Optional, Tuple
59
 
 
85
 
86
  # Rasterization: higher scale = more pixels per PDF point (helps small type,
87
  # boxed headers, and narrow columns). Same constant for all uploads.
88
+ RENDER_SCALE = 3.0
89
 
90
  # White margin as a fraction of page width/height after render. Extra right
91
  # margin helps right-aligned currency columns that hug the page edge.
92
  PAD_LEFT_FRAC = 0.035
93
+ PAD_RIGHT_FRAC = 0.10
94
  PAD_TOP_FRAC = 0.018
95
  PAD_BOTTOM_FRAC = 0.018
96
 
 
117
  PAGE_PNG_COMPRESS_LEVEL = 3
118
  # JPEG quality for small header/footer crops sent to the API.
119
  ZONE_JPEG_QUALITY = 95
 
 
 
 
 
120
 
121
  MIN_PDF_TEXT_CHARS_NATIVE_LAYER = 1500
122
 
 
163
  with open(CONFIG_PATH, "r", encoding="utf-8") as f:
164
  config = yaml.safe_load(f)
165
  config.setdefault("pipeline", {}).setdefault("maas", {})
166
+ config["pipeline"]["maas"]["enabled"] = True
167
  config["pipeline"]["maas"]["api_key"] = GLMOCR_API_KEY
168
  with open(CONFIG_PATH, "w", encoding="utf-8") as f:
169
  yaml.dump(config, f, default_flow_style=False, sort_keys=False)
 
737
  _GLUED_HEADER_ROW_RE = re.compile(r"date\d{1,2}/\d{1,2}|typedeposit|amount\d", re.IGNORECASE)
738
 
739
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
740
  def looks_like_markdown_table(block: str) -> bool:
741
  lines = [ln.rstrip() for ln in block.strip().splitlines() if ln.strip()]
742
  if len(lines) < 2:
 
794
 
795
 
796
  def light_stabilize_markdown(page_md: str) -> str:
797
+ """Convert obvious GitHub-style pipe tables to HTML; normalize money glyphs; light table pass."""
798
  if not page_md:
799
  return page_md
800
  page_md = normalize_money_glyphs(page_md)
 
804
  if looks_like_markdown_table(b):
805
  out_blocks.append(md_table_to_html(b))
806
  else:
807
+ out_blocks.append(b)
808
+ merged = close_unclosed_html('\n\n'.join(out_blocks))
 
 
 
809
  merged = repair_thead_cell_semantics(merged)
810
+ merged = stabilize_table_markup(merged, rounds=2)
811
  merged = strip_degenerate_html_tables(merged)
812
  return merged
813
 
814
 
815
  def _parse_amount_or_none(s: str):
816
+ raw = (s or "").strip()
817
+ if not raw:
818
+ return None
819
+ if re.search(r"\d{10,}", raw) and "." not in raw and "," not in raw:
820
  return None
821
+ t = raw
822
  t = t.replace("$", "").replace(",", "").replace("(", "-").replace(")", "")
823
  t = t.replace("−", "-").replace("–", "-").replace("—", "-")
824
  if not re.search(r"\d", t):
825
  return None
826
+ if "." not in t and len(re.sub(r"[^\d]", "", t)) >= 8:
827
+ return None
828
  try:
829
+ v = float(t)
830
+ # Guardrail: reject clearly implausible values (OCR-glued IDs/garbage),
831
+ # which can explode statement-level reconciliation arithmetic.
832
+ if abs(v) > 10_000_000:
833
+ return None
834
+ return v
835
  except Exception:
836
  return None
837
 
 
855
 
856
  def _is_date_like(s: str) -> bool:
857
  t = (s or "").strip()
858
+ if not t:
859
+ return False
860
+ if re.match(r"^(?:\d{1,2}[/-]\d{1,2}(?:[/-]\d{2,4})?)$", t):
861
+ return True
862
+ if re.match(r"^\d{4}[/-]\d{1,2}[/-]\d{1,2}$", t):
863
+ return True
864
+ return bool(
865
+ re.match(
866
+ r"^(?:(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4})$",
867
+ t,
868
+ re.I,
869
+ )
 
 
 
 
 
 
 
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:
879
+ """Replace noisy/incorrect snapshot text with values parsed from statement summary."""
880
+ if not md:
 
 
 
881
  return md
882
+ start_bal, end_bal = _parse_statement_edge_balances(md)
883
+ if start_bal is None and end_bal is None:
884
+ return md
885
+ # Remove any existing markdown snapshot block at document start.
886
+ md2 = re.sub(
887
+ r"^\s*##\s*Balance\s+Snapshot\s*\n(?:[^\n]*\n){0,8}\s*",
888
+ "",
889
+ md,
890
+ count=1,
891
+ flags=re.IGNORECASE,
892
  )
893
+ lines = ["## Balance Snapshot"]
894
+ if start_bal is not None:
895
+ lines.append(f"Beginning balance: {start_bal:,.2f}")
896
+ if end_bal is not None:
897
+ lines.append(f"Ending balance: {end_bal:,.2f}")
898
+ return "\n".join(lines) + "\n\n" + md2.lstrip()
899
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
900
 
901
+ def _parse_statement_edge_balances(md: str) -> Tuple[Optional[float], Optional[float]]:
902
+ """Extract statement beginning/ending balances using generic wording patterns."""
903
+ # Prefer account-summary table rows (most reliable for statements).
904
+ for tm in re.finditer(r"<table\b[^>]*>.*?</table>", md, flags=re.IGNORECASE | re.DOTALL):
905
+ tbl = tm.group(0)
906
+ plain_tbl = _cell_plain_text(tbl).lower()
907
+ if "account summary" not in plain_tbl:
908
+ continue
909
+ start = None
910
+ end = None
911
+ rows = _extract_rows_plain_from_table(tbl)
912
+ for r in rows:
913
  if not r:
914
  continue
915
+ key = " ".join((c or "").strip().lower() for c in r[:2])
916
+ amts = []
917
+ for c in r:
918
+ for m in re.finditer(r"-?\d{1,3}(?:,\d{3})*(?:\.\d{2})", c or ""):
919
+ v = _parse_amount_or_none(m.group(0))
920
+ if v is not None:
921
+ amts.append(v)
922
+ if not amts:
923
  continue
924
+ if start is None and ("beginning balance" in key or "starting balance" in key):
925
+ start = amts[0]
926
+ if end is None and "ending balance" in key:
927
+ end = amts[-1]
928
+ if start is not None or end is not None:
929
+ return start, end
930
+
931
+ # Fallback: free-text scan with all candidates, choose the largest magnitude match.
932
+ # Additional fallback forms common in statements.
933
+ start_cands = []
934
+ for m in re.finditer(
935
+ r"(?:Balance\s+Forward(?:\s+From)?|Beginning\s+balance\s+on)\s*[^\n$]{0,60}\$?\s*(-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?)\+?",
936
+ md,
937
+ flags=re.IGNORECASE,
938
+ ):
939
+ v = _parse_amount_or_none(m.group(1))
940
+ if v is not None:
941
+ start_cands.append(v)
942
+ for m in re.finditer(
943
+ r"(?:Beginning|Starting)\s+balance[^$\n]{0,120}\$?\s*(-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?)",
944
+ md,
945
+ flags=re.IGNORECASE,
946
+ ):
947
+ v = _parse_amount_or_none(m.group(1))
948
+ if v is not None:
949
+ start_cands.append(v)
950
+ end_cands = []
951
+ for m in re.finditer(
952
+ r"Ending\s+balance(?:\s+on)?[^\n$]{0,60}\$?\s*(-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?)\+?",
953
+ md,
954
+ flags=re.IGNORECASE,
955
+ ):
956
+ v = _parse_amount_or_none(m.group(1))
957
+ if v is not None:
958
+ end_cands.append(v)
959
+ for m in re.finditer(
960
+ r"Ending\s+balance[^$\n]{0,120}\$?\s*(-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?)",
961
+ md,
962
+ flags=re.IGNORECASE,
963
+ ):
964
+ v = _parse_amount_or_none(m.group(1))
965
+ if v is not None:
966
+ end_cands.append(v)
967
+ start = max(start_cands, key=lambda x: abs(x)) if start_cands else None
968
+ end = max(end_cands, key=lambda x: abs(x)) if end_cands else None
969
+ return start, end
970
 
 
 
 
 
971
 
972
+ def _table_sign_bias(ctx: str) -> int:
973
+ """Estimate sign direction for amount-only tables from local context."""
974
+ c = (ctx or "").lower()
975
+ if re.search(r"\b(deposit|deposits|credit|credits|other credits|rtp\s*rcvd|money\s+in)\b", c):
976
+ return 1
977
+ if re.search(
978
+ r"\b(payment|payments|withdrawal|withdrawals|debit|debits|checks?\s+paid|service\s+charge|fee|fees|money\s+out)\b",
979
+ c,
980
+ ):
981
+ return -1
982
+ return 0
983
 
984
 
985
+ def _signed_amount_from_row(desc: str, amount: Optional[float], table_bias: int) -> Optional[float]:
986
+ if amount is None:
987
+ return None
988
+ d = (desc or "").lower()
989
+ if re.search(r"\b(deposit|credit|recd|received|refund|interest|rtp\s*rcvd)\b", d):
990
+ return abs(amount)
991
+ if re.search(r"\b(payment|withdraw|debit|purchase|fee|charge|check|ach)\b", d):
992
+ return -abs(amount)
993
+ if table_bias > 0:
994
+ return abs(amount)
995
+ if table_bias < 0:
996
+ return -abs(amount)
997
+ return None
998
+
999
+
1000
+ def synthesize_running_balances(md: str) -> str:
1001
  """
1002
+ For transaction-like tables lacking a Balance column, synthesize running balances
1003
+ from statement beginning balance and signed amounts. Generic, pattern-based only.
1004
  """
1005
  if not md or "<table" not in md.lower():
1006
  return md
1007
+ start_bal, end_bal = _parse_statement_edge_balances(md)
1008
+ if start_bal is None:
1009
+ return md
1010
 
1011
+ table_re = re.compile(r"<table\b[^>]*>.*?</table>", re.IGNORECASE | re.DOTALL)
1012
+ table_matches = list(table_re.finditer(md))
1013
+ if not table_matches:
1014
+ return md
1015
+
1016
+ plans = []
1017
+ signed_known = []
1018
+ signed_unknown = []
1019
+ for ti, tm in enumerate(table_matches):
1020
+ full = tm.group(0)
1021
  rows = _extract_rows_plain_from_table(full)
1022
  if len(rows) < 3:
1023
+ continue
 
1024
  hdr = [c.strip() for c in rows[0]]
1025
  hdr_l = [h.lower() for h in hdr]
1026
  date_i = next((i for i, h in enumerate(hdr_l) if "date" in h), None)
1027
+ desc_i = next((i for i, h in enumerate(hdr_l) if "description" in h or "memo" in h or "details" in h), None)
1028
+ if date_i is None or desc_i is None:
1029
+ continue
1030
+ balance_i = next((i for i, h in enumerate(hdr_l) if "balance" in h), None)
1031
+ amount_i = next((i for i, h in enumerate(hdr_l) if "amount" in h and "balance" not in h), None)
1032
  credit_i = next((i for i, h in enumerate(hdr_l) if "credit" in h), None)
1033
  debit_i = next((i for i, h in enumerate(hdr_l) if "debit" in h), None)
1034
+ if amount_i is None and (credit_i is None or debit_i is None):
1035
+ continue
1036
 
1037
+ ctx_left = re.sub(r"<[^>]+>", " ", md[max(0, tm.start() - 320): tm.start()])
1038
+ ctx_tbl = re.sub(r"<[^>]+>", " ", full[:800])
1039
+ bias = _table_sign_bias(ctx_left + " " + ctx_tbl)
1040
 
1041
  max_len = max(len(hdr), max((len(r) for r in rows), default=0))
1042
+ recs = []
1043
+ for ri, r in enumerate(rows[1:], start=1):
1044
+ row = (r + [""] * (max_len - len(r)))[:max_len]
1045
+ dt = (row[date_i] or "").strip()
 
1046
  if not _is_date_like(dt):
1047
  continue
1048
+ desc = (row[desc_i] or "").strip()
1049
+ bal = (
1050
+ _parse_amount_or_none((row[balance_i] or "").strip())
1051
+ if balance_i is not None and balance_i < len(row)
1052
+ else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1053
  )
1054
+ if amount_i is not None and amount_i < len(row):
1055
+ amt = _parse_amount_or_none((row[amount_i] or "").strip())
1056
+ signed = _signed_amount_from_row(desc, amt, bias)
1057
+ else:
1058
+ cr = _parse_amount_or_none((row[credit_i] or "").strip()) if credit_i < len(row) else None
1059
+ db = _parse_amount_or_none((row[debit_i] or "").strip()) if debit_i < len(row) else None
1060
+ signed = None
1061
+ if cr is not None and db is None:
1062
+ signed = abs(cr)
1063
+ elif db is not None and cr is None:
1064
+ signed = -abs(db)
1065
+ recs.append({"ri": ri, "row": row, "signed": signed, "balance": bal})
1066
+ if signed is None:
1067
+ signed_unknown.append((ti, ri))
1068
+ else:
1069
+ signed_known.append(float(signed))
1070
+ if len(recs) >= 3:
1071
+ plans.append({"ti": ti, "hdr": hdr, "balance_i": balance_i, "records": recs})
1072
 
1073
+ if not plans:
 
 
 
 
 
 
 
1074
  return md
1075
 
1076
+ # If signs are mostly inverted for this statement, flip unknown-bias outcomes globally.
1077
+ flip_unknown = False
1078
+ if end_bal is not None and signed_known:
1079
+ fwd = start_bal + sum(signed_known)
1080
+ rev = start_bal - sum(signed_known)
1081
+ flip_unknown = abs(rev - end_bal) + 1e-6 < abs(fwd - end_bal)
 
 
1082
 
1083
+ pieces = []
1084
+ last = 0
1085
+ for ti, tm in enumerate(table_matches):
1086
+ pieces.append(md[last:tm.start()])
1087
+ full = tm.group(0)
1088
+ plan = next((p for p in plans if p["ti"] == ti), None)
1089
+ if not plan:
1090
+ pieces.append(full)
1091
+ last = tm.end()
1092
+ continue
 
 
 
 
1093
 
1094
+ hdr = plan["hdr"][:]
1095
+ balance_i = plan["balance_i"]
 
 
 
 
 
 
 
 
 
 
 
 
1096
  if balance_i is None:
1097
+ hdr.append("Balance")
1098
+ balance_i = len(hdr) - 1
1099
+
1100
+ run = start_bal
1101
+ row_by_ri = {}
1102
+ for rec in plan["records"]:
1103
+ signed = rec["signed"]
1104
+ if signed is None:
1105
+ row_by_ri[rec["ri"]] = rec["row"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1106
  continue
1107
+ if flip_unknown:
1108
+ signed = -signed
1109
+ run += signed
1110
+ row = rec["row"][:]
1111
+ if len(row) < len(hdr):
1112
+ row += [""] * (len(hdr) - len(row))
1113
+ row[balance_i] = _fmt_money(run)
1114
+ row_by_ri[rec["ri"]] = row
1115
+
1116
+ base_rows = _extract_rows_plain_from_table(full)
1117
+ if len(base_rows) < 2:
1118
+ pieces.append(full)
1119
+ last = tm.end()
1120
+ continue
1121
+ out_rows = ["<tr>" + "".join(f"<th>{html.escape(c)}</th>" for c in hdr) + "</tr>"]
1122
+ for ri, old in enumerate(base_rows[1:], start=1):
1123
+ row = row_by_ri.get(ri, old)
1124
+ row = (row + [""] * (len(hdr) - len(row)))[: len(hdr)]
1125
+ out_rows.append("<tr>" + "".join(f"<td>{html.escape((c or '').strip())}</td>" for c in row) + "</tr>")
1126
+ pieces.append("<table>\n" + "\n".join(out_rows) + "\n</table>")
1127
+ last = tm.end()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1128
 
1129
+ pieces.append(md[last:])
1130
+ return "".join(pieces)
 
 
 
 
1131
 
1132
 
1133
  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
 
1201
  canvas.paste(img, (pad_l, pad_t))
1202
  img = canvas
1203
 
1204
+ # Include a per-run unique tag to avoid filename collisions across parallel local runs.
1205
+ uniq = uuid.uuid4().hex[:10]
1206
+ img_path = os.path.join(tempfile.gettempdir(), f"glmocr_page_{os.getpid()}_{uniq}_{i}.png")
 
 
 
 
 
 
 
 
 
 
 
1207
  img.save(img_path, "PNG", compress_level=PAGE_PNG_COMPRESS_LEVEL)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1208
  page_images.append(img_path)
1209
  page_heights.append(img.height)
1210
 
 
1313
 
1314
  merged = "\n\n---page-separator---\n\n".join(all_pages) if all_pages else "(No content)"
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)
 
1327
  finally:
1328
  for p in page_images:
1329
  try:
1330
+ if isinstance(p, str) and p.endswith(".png") and "glmocr_page_" in os.path.basename(p):
 
 
 
 
1331
  os.unlink(p)
1332
  except Exception:
1333
  pass