Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -1035,6 +1035,132 @@ def _extract_rows_plain_from_table(full_table: str) -> List[List[str]]:
|
|
| 1035 |
return rows
|
| 1036 |
|
| 1037 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1038 |
def _extract_daily_balance_by_date(md: str):
|
| 1039 |
"""
|
| 1040 |
Build a date->balance map from daily-balance style tables.
|
|
@@ -1545,6 +1671,7 @@ def run_ocr(uploaded_file):
|
|
| 1545 |
if merged and merged != "(No content)" and not merged.lstrip().startswith("Error:"):
|
| 1546 |
merged = mask_non_monetary_long_numbers(merged)
|
| 1547 |
merged = directionalize_amount_headers(merged)
|
|
|
|
| 1548 |
merged = stabilize_table_markup(merged, rounds=4)
|
| 1549 |
merged = enrich_transaction_tables_with_daily_balances(merged)
|
| 1550 |
# Keep transaction amounts untouched; add explicit snapshot for metadata extraction.
|
|
|
|
| 1035 |
return rows
|
| 1036 |
|
| 1037 |
|
| 1038 |
+
def _fmt_money(v: float) -> str:
|
| 1039 |
+
return f"{v:,.2f}"
|
| 1040 |
+
|
| 1041 |
+
|
| 1042 |
+
def _is_date_like(s: str) -> bool:
|
| 1043 |
+
t = (s or "").strip()
|
| 1044 |
+
return bool(re.match(r"^(?:\d{1,2}[/-]\d{1,2}(?:[/-]\d{2,4})?)$", t))
|
| 1045 |
+
|
| 1046 |
+
|
| 1047 |
+
def normalize_ambiguous_amount_balance_tables(md: str) -> str:
|
| 1048 |
+
"""
|
| 1049 |
+
For transaction-like tables that expose only a single Amount column plus
|
| 1050 |
+
Balance, convert to explicit Credit/Debit using generic running-balance
|
| 1051 |
+
inference. This avoids sign ambiguity and improves reconcile stability.
|
| 1052 |
+
"""
|
| 1053 |
+
if not md or "<table" not in md.lower():
|
| 1054 |
+
return md
|
| 1055 |
+
|
| 1056 |
+
credit_hint_re = re.compile(
|
| 1057 |
+
r"\b(deposit|deposits|credit|credits|incoming|received|interest earned|refund|return)\b",
|
| 1058 |
+
re.IGNORECASE,
|
| 1059 |
+
)
|
| 1060 |
+
debit_hint_re = re.compile(
|
| 1061 |
+
r"\b(debit|debits|withdrawal|withdrawals|check|checks|fee|fees|charge|charges|payment|transfer out|purchase)\b",
|
| 1062 |
+
re.IGNORECASE,
|
| 1063 |
+
)
|
| 1064 |
+
|
| 1065 |
+
def repl_table(m: re.Match) -> str:
|
| 1066 |
+
full = m.group(0)
|
| 1067 |
+
rows = _extract_rows_plain_from_table(full)
|
| 1068 |
+
if len(rows) < 3:
|
| 1069 |
+
return full
|
| 1070 |
+
|
| 1071 |
+
hdr = [c.strip().lower() for c in rows[0]]
|
| 1072 |
+
amount_idxs = [i for i, c in enumerate(hdr) if "amount" in c]
|
| 1073 |
+
balance_idxs = [i for i, c in enumerate(hdr) if "balance" in c]
|
| 1074 |
+
has_credit = any("credit" in c for c in hdr)
|
| 1075 |
+
has_debit = any("debit" in c for c in hdr)
|
| 1076 |
+
date_idxs = [i for i, c in enumerate(hdr) if "date" in c]
|
| 1077 |
+
desc_idxs = [i for i, c in enumerate(hdr) if "description" in c or "memo" in c or "details" in c]
|
| 1078 |
+
|
| 1079 |
+
if has_credit or has_debit or not amount_idxs or not balance_idxs:
|
| 1080 |
+
return full
|
| 1081 |
+
|
| 1082 |
+
amount_idx = amount_idxs[0]
|
| 1083 |
+
balance_idx = balance_idxs[0]
|
| 1084 |
+
date_idx = date_idxs[0] if date_idxs else 0
|
| 1085 |
+
desc_idx = desc_idxs[0] if desc_idxs else min(1, max(0, len(rows[0]) - 1))
|
| 1086 |
+
|
| 1087 |
+
new_rows: List[List[str]] = [["Date", "Description", "Credit", "Debit", "Balance"]]
|
| 1088 |
+
prev_bal = None
|
| 1089 |
+
section_hint = ""
|
| 1090 |
+
converted = 0
|
| 1091 |
+
|
| 1092 |
+
for r in rows[1:]:
|
| 1093 |
+
if not r:
|
| 1094 |
+
continue
|
| 1095 |
+
padded = r + [""] * (max(amount_idx, balance_idx, date_idx, desc_idx) + 1 - len(r))
|
| 1096 |
+
date_txt = (padded[date_idx] or "").strip()
|
| 1097 |
+
desc_txt = (padded[desc_idx] or "").strip()
|
| 1098 |
+
amt_txt = (padded[amount_idx] or "").strip()
|
| 1099 |
+
bal_txt = (padded[balance_idx] or "").strip()
|
| 1100 |
+
amt = _parse_amount_or_none(amt_txt)
|
| 1101 |
+
bal = _parse_amount_or_none(bal_txt)
|
| 1102 |
+
|
| 1103 |
+
row_blob = " ".join((c or "").strip() for c in padded).strip()
|
| 1104 |
+
if row_blob and not _is_date_like(date_txt) and amt is None:
|
| 1105 |
+
section_hint = row_blob
|
| 1106 |
+
continue
|
| 1107 |
+
if amt is None and bal is None:
|
| 1108 |
+
continue
|
| 1109 |
+
|
| 1110 |
+
direction = ""
|
| 1111 |
+
# Primary: infer from balance delta between consecutive rows.
|
| 1112 |
+
if amt is not None and bal is not None and prev_bal is not None:
|
| 1113 |
+
delta = bal - prev_bal
|
| 1114 |
+
tol = max(0.55, 0.025 * abs(amt))
|
| 1115 |
+
if abs(abs(delta) - abs(amt)) <= tol:
|
| 1116 |
+
direction = "credit" if delta > 0 else "debit"
|
| 1117 |
+
|
| 1118 |
+
# Secondary: section + description lexical hints.
|
| 1119 |
+
ctx = f"{section_hint} {desc_txt}"
|
| 1120 |
+
if not direction and credit_hint_re.search(ctx):
|
| 1121 |
+
direction = "credit"
|
| 1122 |
+
if not direction and debit_hint_re.search(ctx):
|
| 1123 |
+
direction = "debit"
|
| 1124 |
+
|
| 1125 |
+
credit = ""
|
| 1126 |
+
debit = ""
|
| 1127 |
+
if amt is not None:
|
| 1128 |
+
if direction == "credit":
|
| 1129 |
+
credit = _fmt_money(abs(amt))
|
| 1130 |
+
elif direction == "debit":
|
| 1131 |
+
debit = _fmt_money(abs(amt))
|
| 1132 |
+
else:
|
| 1133 |
+
# Conservative fallback for ambiguous rows: preserve as debit
|
| 1134 |
+
# to avoid overstating inflows.
|
| 1135 |
+
debit = _fmt_money(abs(amt))
|
| 1136 |
+
|
| 1137 |
+
balance_out = _fmt_money(bal) if bal is not None else ""
|
| 1138 |
+
new_rows.append([date_txt, desc_txt, credit, debit, balance_out])
|
| 1139 |
+
if bal is not None:
|
| 1140 |
+
prev_bal = bal
|
| 1141 |
+
converted += 1
|
| 1142 |
+
|
| 1143 |
+
if converted < 5:
|
| 1144 |
+
return full
|
| 1145 |
+
|
| 1146 |
+
html_rows = []
|
| 1147 |
+
html_rows.append(
|
| 1148 |
+
"<tr>" + "".join(f"<th>{html.escape(c)}</th>" for c in new_rows[0]) + "</tr>"
|
| 1149 |
+
)
|
| 1150 |
+
for rr in new_rows[1:]:
|
| 1151 |
+
html_rows.append(
|
| 1152 |
+
"<tr>" + "".join(f"<td>{html.escape(c)}</td>" for c in rr) + "</tr>"
|
| 1153 |
+
)
|
| 1154 |
+
return "<table>\n" + "\n".join(html_rows) + "\n</table>"
|
| 1155 |
+
|
| 1156 |
+
return re.sub(
|
| 1157 |
+
r"<table\b[^>]*>.*?</table>",
|
| 1158 |
+
repl_table,
|
| 1159 |
+
md,
|
| 1160 |
+
flags=re.IGNORECASE | re.DOTALL,
|
| 1161 |
+
)
|
| 1162 |
+
|
| 1163 |
+
|
| 1164 |
def _extract_daily_balance_by_date(md: str):
|
| 1165 |
"""
|
| 1166 |
Build a date->balance map from daily-balance style tables.
|
|
|
|
| 1671 |
if merged and merged != "(No content)" and not merged.lstrip().startswith("Error:"):
|
| 1672 |
merged = mask_non_monetary_long_numbers(merged)
|
| 1673 |
merged = directionalize_amount_headers(merged)
|
| 1674 |
+
merged = normalize_ambiguous_amount_balance_tables(merged)
|
| 1675 |
merged = stabilize_table_markup(merged, rounds=4)
|
| 1676 |
merged = enrich_transaction_tables_with_daily_balances(merged)
|
| 1677 |
# Keep transaction amounts untouched; add explicit snapshot for metadata extraction.
|