Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -24,6 +24,11 @@ Included (data-driven, no institution names):
|
|
| 24 |
grid, then slide a solitary amount token past trailing blank logical slots
|
| 25 |
into the rightmost slot (colspan-aware). Rowspan rows are skipped for edits
|
| 26 |
but do not disable an entire table. Stabilization runs in multiple passes.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
Configure GLMOCR_API_KEY (environment variable). Optional: glmocr + gradio +
|
| 29 |
pymupdf + pillow installed.
|
|
@@ -51,7 +56,7 @@ import os
|
|
| 51 |
import re
|
| 52 |
import tempfile
|
| 53 |
from collections import Counter
|
| 54 |
-
from typing import List, Tuple
|
| 55 |
|
| 56 |
import yaml
|
| 57 |
|
|
@@ -73,7 +78,7 @@ logging.basicConfig(level=logging.INFO)
|
|
| 73 |
# ---------------------------------------------------------------------------
|
| 74 |
|
| 75 |
# Never commit secrets: Space / local runs use ZHIPU_API_KEY or GLMOCR_API_KEY.
|
| 76 |
-
GLMOCR_API_KEY = "
|
| 77 |
if not GLMOCR_API_KEY:
|
| 78 |
log.warning(
|
| 79 |
"No ZHIPU_API_KEY or GLMOCR_API_KEY in environment; GlmOcr() will fail until you set one."
|
|
@@ -304,6 +309,62 @@ _CELL = re.compile(
|
|
| 304 |
re.IGNORECASE | re.DOTALL,
|
| 305 |
)
|
| 306 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
|
| 308 |
def _cell_entries(tr_inner: str) -> List[Tuple[str, int]]:
|
| 309 |
"""(full_cell_html, logical_width) for each td/th; 0 cells if unparseable."""
|
|
@@ -528,6 +589,29 @@ def normalize_html_table_row_widths(md: str) -> str:
|
|
| 528 |
if not tr_blocks:
|
| 529 |
return full
|
| 530 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 531 |
tr_inners: List[str] = []
|
| 532 |
for tm in tr_blocks:
|
| 533 |
seg = tm.group(0)
|
|
@@ -649,6 +733,108 @@ def strip_degenerate_html_tables(md: str) -> str:
|
|
| 649 |
return re.sub(r"\n{3,}", "\n\n", out)
|
| 650 |
|
| 651 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 652 |
def looks_like_markdown_table(block: str) -> bool:
|
| 653 |
lines = [ln.rstrip() for ln in block.strip().splitlines() if ln.strip()]
|
| 654 |
if len(lines) < 2:
|
|
@@ -716,7 +902,7 @@ def light_stabilize_markdown(page_md: str) -> str:
|
|
| 716 |
if looks_like_markdown_table(b):
|
| 717 |
out_blocks.append(md_table_to_html(b))
|
| 718 |
else:
|
| 719 |
-
out_blocks.append(b)
|
| 720 |
merged = close_unclosed_html("\n\n".join(out_blocks))
|
| 721 |
merged = repair_thead_cell_semantics(merged)
|
| 722 |
merged = stabilize_table_markup(merged, rounds=4)
|
|
@@ -900,4 +1086,4 @@ def _create_gradio_demo():
|
|
| 900 |
|
| 901 |
|
| 902 |
if __name__ == "__main__":
|
| 903 |
-
_create_gradio_demo().launch(
|
|
|
|
| 24 |
grid, then slide a solitary amount token past trailing blank logical slots
|
| 25 |
into the rightmost slot (colspan-aware). Rowspan rows are skipped for edits
|
| 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 +
|
| 34 |
pymupdf + pillow installed.
|
|
|
|
| 56 |
import re
|
| 57 |
import tempfile
|
| 58 |
from collections import Counter
|
| 59 |
+
from typing import List, Optional, Tuple
|
| 60 |
|
| 61 |
import yaml
|
| 62 |
|
|
|
|
| 78 |
# ---------------------------------------------------------------------------
|
| 79 |
|
| 80 |
# Never commit secrets: Space / local runs use ZHIPU_API_KEY or GLMOCR_API_KEY.
|
| 81 |
+
GLMOCR_API_KEY = os.environ.get("ZHIPU_API_KEY") or os.environ.get("GLMOCR_API_KEY") or ""
|
| 82 |
if not GLMOCR_API_KEY:
|
| 83 |
log.warning(
|
| 84 |
"No ZHIPU_API_KEY or GLMOCR_API_KEY in environment; GlmOcr() will fail until you set one."
|
|
|
|
| 309 |
re.IGNORECASE | re.DOTALL,
|
| 310 |
)
|
| 311 |
|
| 312 |
+
# Currency tokens inside a cell (not anchored); used to split merged amount+balance.
|
| 313 |
+
_MONEY_IN_TEXT = re.compile(
|
| 314 |
+
r"(?:\$|€|£)?\s*-?\d{1,3}(?:,\d{3})*\.\d{2}\b|(?:\$|€|£)?\s*-?\d+\.\d{2}\b"
|
| 315 |
+
)
|
| 316 |
+
_EOL_MONEY = re.compile(
|
| 317 |
+
r"(?:\$|€|£)?\s*-?\d{1,3}(?:,\d{3})*\.\d{2}$|(?:\$|€|£)?\s*-?\d+\.\d{2}$"
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def _split_cell_trailing_balance(full_cell: str) -> List[str]:
|
| 322 |
+
"""
|
| 323 |
+
When OCR puts transaction amount and running balance in one <td>, split into
|
| 324 |
+
two cells so classifiers can assign separate columns. Uses only currency
|
| 325 |
+
patterns and whitespace gaps (no header names).
|
| 326 |
+
"""
|
| 327 |
+
plain = _cell_plain_text(full_cell)
|
| 328 |
+
if len(plain) < 10:
|
| 329 |
+
return [full_cell]
|
| 330 |
+
spans = [(m.start(), m.end()) for m in _MONEY_IN_TEXT.finditer(plain)]
|
| 331 |
+
if len(spans) < 2:
|
| 332 |
+
return [full_cell]
|
| 333 |
+
(a0, a1), (b0, b1) = spans[-2], spans[-1]
|
| 334 |
+
if b1 < len(plain) - 16:
|
| 335 |
+
return [full_cell]
|
| 336 |
+
gap = plain[a1:b0]
|
| 337 |
+
if re.search(r"[A-Za-z]{2,}", gap):
|
| 338 |
+
return [full_cell]
|
| 339 |
+
if len(gap) > 14:
|
| 340 |
+
return [full_cell]
|
| 341 |
+
left = plain[:b0].strip()
|
| 342 |
+
right = plain[b0:].strip()
|
| 343 |
+
if not left or not right:
|
| 344 |
+
return [full_cell]
|
| 345 |
+
m = _CELL.fullmatch(full_cell.strip())
|
| 346 |
+
if not m:
|
| 347 |
+
return [full_cell]
|
| 348 |
+
tag, attrs = m.group(1), m.group(2)
|
| 349 |
+
return [
|
| 350 |
+
f"<{tag}{attrs}>{html.escape(left)}</{tag}>",
|
| 351 |
+
f"<{tag}{attrs}>{html.escape(right)}</{tag}>",
|
| 352 |
+
]
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
def _expand_tr_inner_split_merged(tr_inner: str) -> str:
|
| 356 |
+
"""Insert extra td/th where a single cell clearly holds amount + trailing balance."""
|
| 357 |
+
entries = _cell_entries(tr_inner)
|
| 358 |
+
if not entries:
|
| 359 |
+
return tr_inner
|
| 360 |
+
parts: List[str] = []
|
| 361 |
+
for full, span in entries:
|
| 362 |
+
if span != 1:
|
| 363 |
+
parts.append(full)
|
| 364 |
+
else:
|
| 365 |
+
parts.extend(_split_cell_trailing_balance(full))
|
| 366 |
+
return "".join(parts)
|
| 367 |
+
|
| 368 |
|
| 369 |
def _cell_entries(tr_inner: str) -> List[Tuple[str, int]]:
|
| 370 |
"""(full_cell_html, logical_width) for each td/th; 0 cells if unparseable."""
|
|
|
|
| 589 |
if not tr_blocks:
|
| 590 |
return full
|
| 591 |
|
| 592 |
+
# Phase 1: split merged amount+balance cells so column counts match real grids.
|
| 593 |
+
phase1_parts: List[str] = []
|
| 594 |
+
last_end = 0
|
| 595 |
+
for tm in tr_blocks:
|
| 596 |
+
phase1_parts.append(body[last_end : tm.start()])
|
| 597 |
+
seg = tm.group(0)
|
| 598 |
+
op = re.search(r"<tr\b[^>]*>", seg, flags=re.IGNORECASE)
|
| 599 |
+
cl = seg.lower().rfind("</tr>")
|
| 600 |
+
if not op or cl < 0:
|
| 601 |
+
phase1_parts.append(seg)
|
| 602 |
+
else:
|
| 603 |
+
open_tr = seg[: op.end()]
|
| 604 |
+
inner = seg[op.end() : cl]
|
| 605 |
+
close_tr = seg[cl:]
|
| 606 |
+
if re.search(r"rowspan\s*=", inner, flags=re.IGNORECASE):
|
| 607 |
+
phase1_parts.append(seg)
|
| 608 |
+
else:
|
| 609 |
+
phase1_parts.append(open_tr + _expand_tr_inner_split_merged(inner) + close_tr)
|
| 610 |
+
last_end = tm.end()
|
| 611 |
+
phase1_parts.append(body[last_end:])
|
| 612 |
+
body = "".join(phase1_parts)
|
| 613 |
+
|
| 614 |
+
tr_blocks = list(re.finditer(r"<tr\b[^>]*>.*?</tr>", body, flags=re.IGNORECASE | re.DOTALL))
|
| 615 |
tr_inners: List[str] = []
|
| 616 |
for tm in tr_blocks:
|
| 617 |
seg = tm.group(0)
|
|
|
|
| 733 |
return re.sub(r"\n{3,}", "\n\n", out)
|
| 734 |
|
| 735 |
|
| 736 |
+
def _strip_trailing_money_tokens(rest: str, max_take: int = 2) -> Tuple[str, List[str]]:
|
| 737 |
+
"""Strip 1–2 currency tokens from the right of *rest*; return (description, tokens oldest-first)."""
|
| 738 |
+
cur = rest.rstrip()
|
| 739 |
+
toks: List[str] = []
|
| 740 |
+
for _ in range(max_take):
|
| 741 |
+
cur = cur.rstrip()
|
| 742 |
+
m = _EOL_MONEY.search(cur)
|
| 743 |
+
if not m or m.end() != len(cur):
|
| 744 |
+
break
|
| 745 |
+
toks.append(m.group().strip())
|
| 746 |
+
cur = cur[: m.start()].rstrip()
|
| 747 |
+
toks.reverse()
|
| 748 |
+
return cur, toks
|
| 749 |
+
|
| 750 |
+
|
| 751 |
+
def _parse_ledger_line(line: str) -> Optional[Tuple[str, str, str, str]]:
|
| 752 |
+
"""
|
| 753 |
+
If *line* looks like a bank-style ledger row (date, description, amount, optional balance),
|
| 754 |
+
return (date, description, amount, balance_or_empty); else None.
|
| 755 |
+
"""
|
| 756 |
+
ln = line.strip()
|
| 757 |
+
if not ln or ln[0] in "#|!<":
|
| 758 |
+
return None
|
| 759 |
+
mm = re.match(
|
| 760 |
+
r"^(?P<d>(?:\d{1,2}[/-]\d{1,2}(?:[/-]\d{2,4})?|"
|
| 761 |
+
r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4}))"
|
| 762 |
+
r"\s+",
|
| 763 |
+
ln,
|
| 764 |
+
re.I,
|
| 765 |
+
)
|
| 766 |
+
if not mm:
|
| 767 |
+
return None
|
| 768 |
+
d = mm.group("d")
|
| 769 |
+
tail = ln[mm.end() :]
|
| 770 |
+
desc, toks = _strip_trailing_money_tokens(tail, 2)
|
| 771 |
+
desc = desc.strip()
|
| 772 |
+
if not toks or len(desc) < 2 or len(desc) > 420:
|
| 773 |
+
return None
|
| 774 |
+
amt = toks[0]
|
| 775 |
+
bal = toks[1] if len(toks) > 1 else ""
|
| 776 |
+
return (d, desc, amt, bal)
|
| 777 |
+
|
| 778 |
+
|
| 779 |
+
def _ledger_rows_to_html(rows: List[Tuple[str, str, str, str]]) -> str:
|
| 780 |
+
has_bal = any(r[3] for r in rows)
|
| 781 |
+
head = (
|
| 782 |
+
"<tr><th>Date</th><th>Description</th><th>Amount</th>"
|
| 783 |
+
+ ("<th>Balance</th>" if has_bal else "")
|
| 784 |
+
+ "</tr>"
|
| 785 |
+
)
|
| 786 |
+
body_lines = []
|
| 787 |
+
for d, desc, amt, bal in rows:
|
| 788 |
+
cells = [
|
| 789 |
+
f"<td>{html.escape(d)}</td>",
|
| 790 |
+
f"<td>{html.escape(desc)}</td>",
|
| 791 |
+
f"<td>{html.escape(amt)}</td>",
|
| 792 |
+
]
|
| 793 |
+
if has_bal:
|
| 794 |
+
cells.append(f"<td>{html.escape(bal)}</td>" if bal else "<td></td>")
|
| 795 |
+
body_lines.append("<tr>" + "".join(cells) + "</tr>")
|
| 796 |
+
return "<table>\n" + head + "\n" + "\n".join(body_lines) + "\n</table>"
|
| 797 |
+
|
| 798 |
+
|
| 799 |
+
def coalesce_loose_ledger_lines(text: str) -> str:
|
| 800 |
+
"""
|
| 801 |
+
When the model emits many consecutive plain-text ledger lines (no HTML table),
|
| 802 |
+
downstream classification sees no tables and skips. Convert long runs of
|
| 803 |
+
generic date + amount lines into a single HTML table. Requires 5+ matching
|
| 804 |
+
consecutive non-empty lines; does not match institution names.
|
| 805 |
+
"""
|
| 806 |
+
if not text or "<table" in text.lower():
|
| 807 |
+
return text
|
| 808 |
+
lines = text.split("\n")
|
| 809 |
+
out: List[str] = []
|
| 810 |
+
i = 0
|
| 811 |
+
while i < len(lines):
|
| 812 |
+
if not lines[i].strip():
|
| 813 |
+
out.append(lines[i])
|
| 814 |
+
i += 1
|
| 815 |
+
continue
|
| 816 |
+
run_rows: List[Tuple[str, str, str, str]] = []
|
| 817 |
+
j = i
|
| 818 |
+
while j < len(lines):
|
| 819 |
+
raw = lines[j]
|
| 820 |
+
if not raw.strip():
|
| 821 |
+
break
|
| 822 |
+
parsed = _parse_ledger_line(raw)
|
| 823 |
+
if parsed is None:
|
| 824 |
+
break
|
| 825 |
+
run_rows.append(parsed)
|
| 826 |
+
j += 1
|
| 827 |
+
if len(run_rows) >= 5:
|
| 828 |
+
if i > 0 and out and out[-1].strip():
|
| 829 |
+
out.append("")
|
| 830 |
+
out.append(_ledger_rows_to_html(run_rows))
|
| 831 |
+
i = j
|
| 832 |
+
continue
|
| 833 |
+
out.append(lines[i])
|
| 834 |
+
i += 1
|
| 835 |
+
return "\n".join(out)
|
| 836 |
+
|
| 837 |
+
|
| 838 |
def looks_like_markdown_table(block: str) -> bool:
|
| 839 |
lines = [ln.rstrip() for ln in block.strip().splitlines() if ln.strip()]
|
| 840 |
if len(lines) < 2:
|
|
|
|
| 902 |
if looks_like_markdown_table(b):
|
| 903 |
out_blocks.append(md_table_to_html(b))
|
| 904 |
else:
|
| 905 |
+
out_blocks.append(coalesce_loose_ledger_lines(b))
|
| 906 |
merged = close_unclosed_html("\n\n".join(out_blocks))
|
| 907 |
merged = repair_thead_cell_semantics(merged)
|
| 908 |
merged = stabilize_table_markup(merged, rounds=4)
|
|
|
|
| 1086 |
|
| 1087 |
|
| 1088 |
if __name__ == "__main__":
|
| 1089 |
+
_create_gradio_demo().launch()
|