rehan953 commited on
Commit
0c1c330
·
verified ·
1 Parent(s): 6d7f99b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +190 -4
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 = "cee1d52dd91a4ab591b3f6e105f8ad89.LgbQTECuzX0zrito"
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(share=True)
 
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()