Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,9 +1,7 @@
|
|
| 1 |
"""
|
| 2 |
GLM-OCR Hugging Face Space app for PDF/image OCR with header inclusion
|
| 3 |
and table-structure stabilization for downstream bank-statement pipelines.
|
| 4 |
-
|
| 5 |
Hard-coded knobs (no environment variables required).
|
| 6 |
-
|
| 7 |
Primary goals for reconcile rate:
|
| 8 |
- preserve right-most columns (often "Balance") by higher DPI render + right padding
|
| 9 |
- keep tables as tables (convert markdown pipe tables -> HTML table)
|
|
@@ -11,8 +9,9 @@ Primary goals for reconcile rate:
|
|
| 11 |
- normalize HTML tables generically so downstream parsing/classification is stable:
|
| 12 |
1) expand colspan/rowspan into a rectangular grid
|
| 13 |
2) drop truly-empty columns (common in summary tables)
|
| 14 |
-
3) merge
|
| 15 |
4) clean common header artifacts (e.g. "DESCRIPTIONBeginning Balance", "BALANCE$3,447.10")
|
|
|
|
| 16 |
"""
|
| 17 |
|
| 18 |
# Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
|
|
@@ -52,7 +51,6 @@ FORMATTER_PATH = os.path.join(GLMOCR_BASE, "postprocess", "result_formatter.py")
|
|
| 52 |
# HARD-CODED SETTINGS (edit these numbers to tune quality/speed)
|
| 53 |
# ============================================================
|
| 54 |
|
| 55 |
-
# IMPORTANT: Do NOT hard-code secrets in a public Space.
|
| 56 |
GLMOCR_API_KEY = "e2b138b2005a41cb9d87dd18805838aa.lyd51L23rcDbsw0w"
|
| 57 |
|
| 58 |
# Higher = better OCR for small/right-aligned digits; slower
|
|
@@ -423,7 +421,6 @@ def _drop_truly_empty_columns(grid):
|
|
| 423 |
total += 1
|
| 424 |
if str(row[i] or "").strip():
|
| 425 |
non_empty += 1
|
| 426 |
-
# keep column if it has meaningful content; otherwise drop
|
| 427 |
if total > 0 and (non_empty / total) <= 0.05:
|
| 428 |
keep[i] = False
|
| 429 |
|
|
@@ -480,7 +477,6 @@ def _merge_blank_header_text_columns(grid):
|
|
| 480 |
non_empty_ratio = non_empty / total
|
| 481 |
texty_ratio = (texty / non_empty) if non_empty else 0.0
|
| 482 |
|
| 483 |
-
# Merge only when it behaves like description continuation text
|
| 484 |
if non_empty_ratio >= 0.55 and texty_ratio >= 0.70:
|
| 485 |
for r in range(1, len(grid)):
|
| 486 |
row = grid[r]
|
|
@@ -501,41 +497,164 @@ def _merge_blank_header_text_columns(grid):
|
|
| 501 |
return new_grid
|
| 502 |
|
| 503 |
|
| 504 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 505 |
"""
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
- BALANCE$3,447.10 -> BALANCE
|
| 509 |
"""
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 513 |
|
| 514 |
-
|
| 515 |
-
|
| 516 |
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
hdr[idx] = "DESCRIPTION"
|
| 521 |
continue
|
| 522 |
|
| 523 |
-
|
| 524 |
-
r"\$?\s*-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?", c
|
| 525 |
-
):
|
| 526 |
-
hdr[idx] = "BALANCE"
|
| 527 |
|
| 528 |
-
|
| 529 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 530 |
|
| 531 |
|
| 532 |
def normalize_html_tables(text: str) -> str:
|
| 533 |
"""
|
| 534 |
For every <table>...</table>:
|
| 535 |
- parse to grid (expand colspan/rowspan)
|
| 536 |
-
- drop truly-empty columns
|
| 537 |
-
- merge blank-header text columns into the left column
|
| 538 |
-
- clean
|
| 539 |
- emit normalized <table> HTML
|
| 540 |
"""
|
| 541 |
if not text or "<table" not in text.lower():
|
|
@@ -554,7 +673,16 @@ def normalize_html_tables(text: str) -> str:
|
|
| 554 |
grid = _build_grid(p.rows)
|
| 555 |
grid = _drop_truly_empty_columns(grid)
|
| 556 |
grid = _merge_blank_header_text_columns(grid)
|
| 557 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 558 |
out.append(_grid_to_html(grid) if grid else table_html)
|
| 559 |
except Exception:
|
| 560 |
out.append(table_html)
|
|
|
|
| 1 |
"""
|
| 2 |
GLM-OCR Hugging Face Space app for PDF/image OCR with header inclusion
|
| 3 |
and table-structure stabilization for downstream bank-statement pipelines.
|
|
|
|
| 4 |
Hard-coded knobs (no environment variables required).
|
|
|
|
| 5 |
Primary goals for reconcile rate:
|
| 6 |
- preserve right-most columns (often "Balance") by higher DPI render + right padding
|
| 7 |
- keep tables as tables (convert markdown pipe tables -> HTML table)
|
|
|
|
| 9 |
- normalize HTML tables generically so downstream parsing/classification is stable:
|
| 10 |
1) expand colspan/rowspan into a rectangular grid
|
| 11 |
2) drop truly-empty columns (common in summary tables)
|
| 12 |
+
3) merge "blank header" columns that contain text into the left column (common when DESCRIPTION is split)
|
| 13 |
4) clean common header artifacts (e.g. "DESCRIPTIONBeginning Balance", "BALANCE$3,447.10")
|
| 14 |
+
5) recover fused first data row generically (two-signal guard: text fusion + money fusion must both fire)
|
| 15 |
"""
|
| 16 |
|
| 17 |
# Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
|
|
|
|
| 51 |
# HARD-CODED SETTINGS (edit these numbers to tune quality/speed)
|
| 52 |
# ============================================================
|
| 53 |
|
|
|
|
| 54 |
GLMOCR_API_KEY = "e2b138b2005a41cb9d87dd18805838aa.lyd51L23rcDbsw0w"
|
| 55 |
|
| 56 |
# Higher = better OCR for small/right-aligned digits; slower
|
|
|
|
| 421 |
total += 1
|
| 422 |
if str(row[i] or "").strip():
|
| 423 |
non_empty += 1
|
|
|
|
| 424 |
if total > 0 and (non_empty / total) <= 0.05:
|
| 425 |
keep[i] = False
|
| 426 |
|
|
|
|
| 477 |
non_empty_ratio = non_empty / total
|
| 478 |
texty_ratio = (texty / non_empty) if non_empty else 0.0
|
| 479 |
|
|
|
|
| 480 |
if non_empty_ratio >= 0.55 and texty_ratio >= 0.70:
|
| 481 |
for r in range(1, len(grid)):
|
| 482 |
row = grid[r]
|
|
|
|
| 497 |
return new_grid
|
| 498 |
|
| 499 |
|
| 500 |
+
# --------------------------
|
| 501 |
+
# Generic fused-header recovery
|
| 502 |
+
# --------------------------
|
| 503 |
+
|
| 504 |
+
# Matches a standalone money value: optional currency symbol, optional sign,
|
| 505 |
+
# digits with optional comma grouping, optional decimal cents.
|
| 506 |
+
# Covers: $3,447.10 -1,234.56 $-500.00 1000 etc.
|
| 507 |
+
_MONEY_RE = re.compile(
|
| 508 |
+
r"^[\$Β£β¬Β₯]?\s*-?\s?\d{1,3}(?:,\d{3})*(?:\.\d{2})?$"
|
| 509 |
+
)
|
| 510 |
+
|
| 511 |
+
# All column-header keywords we recognise across any bank statement format.
|
| 512 |
+
# Each entry is a raw regex fragment (no anchors) used to detect the keyword
|
| 513 |
+
# at the START of a header cell.
|
| 514 |
+
_HEADER_KW_PATTERNS = [
|
| 515 |
+
r"DATE",
|
| 516 |
+
r"POSTING\s+DATE",
|
| 517 |
+
r"VALUE\s+DATE",
|
| 518 |
+
r"TRANSACTION(?:\s+(?:ID|DATE|TYPE|NO|NUMBER))?",
|
| 519 |
+
r"DESCRIPTION",
|
| 520 |
+
r"DETAILS?",
|
| 521 |
+
r"PARTICULARS?",
|
| 522 |
+
r"NARRATION",
|
| 523 |
+
r"REMARKS?",
|
| 524 |
+
r"REF(?:ERENCE)?(?:\s*(?:NO|NUM|NUMBER))?",
|
| 525 |
+
r"CHEQUE(?:\s*(?:NO|NUMBER))?",
|
| 526 |
+
r"AMOUNT",
|
| 527 |
+
r"DEBIT",
|
| 528 |
+
r"CREDIT",
|
| 529 |
+
r"DEPOSIT",
|
| 530 |
+
r"WITHDRAWAL",
|
| 531 |
+
r"BALANCE",
|
| 532 |
+
r"RUNNING\s+BALANCE",
|
| 533 |
+
r"AVAILABLE\s+BALANCE",
|
| 534 |
+
r"LEDGER\s+BALANCE",
|
| 535 |
+
]
|
| 536 |
+
|
| 537 |
+
# Pre-compile: each pattern anchored at start, case-insensitive
|
| 538 |
+
_HEADER_KW_RES = [
|
| 539 |
+
re.compile(r"^(" + p + r")(.*)", re.IGNORECASE | re.DOTALL)
|
| 540 |
+
for p in _HEADER_KW_PATTERNS
|
| 541 |
+
]
|
| 542 |
+
|
| 543 |
+
# A set version for quick "is this entire string a known keyword?" check
|
| 544 |
+
_HEADER_KW_EXACT_RE = re.compile(
|
| 545 |
+
r"^(?:" + r"|".join(_HEADER_KW_PATTERNS) + r")$",
|
| 546 |
+
re.IGNORECASE,
|
| 547 |
+
)
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
def _split_keyword_remainder(cell_text: str):
|
| 551 |
"""
|
| 552 |
+
If cell_text starts with a known header keyword followed by extra content,
|
| 553 |
+
return (keyword, remainder). Otherwise return (cell_text, "").
|
|
|
|
| 554 |
"""
|
| 555 |
+
s = cell_text.strip()
|
| 556 |
+
for pattern in _HEADER_KW_RES:
|
| 557 |
+
m = pattern.match(s)
|
| 558 |
+
if m:
|
| 559 |
+
keyword = m.group(1).strip()
|
| 560 |
+
remainder = m.group(2).strip()
|
| 561 |
+
return keyword, remainder
|
| 562 |
+
return s, ""
|
| 563 |
+
|
| 564 |
+
|
| 565 |
+
def _extract_fused_header_artifacts(header_row):
|
| 566 |
+
"""
|
| 567 |
+
Generic detector for the OCR artifact where the first data row of a table
|
| 568 |
+
gets fused into the header cells during OCR.
|
| 569 |
+
|
| 570 |
+
The artifact pattern:
|
| 571 |
+
- At least one header cell contains a known column keyword PLUS free text
|
| 572 |
+
that is NOT itself a keyword β "text-fused" column
|
| 573 |
+
e.g. "DESCRIPTIONBeginning Balance"
|
| 574 |
+
"NARRATIONOpening Balance"
|
| 575 |
+
"DETAILSForward Balance"
|
| 576 |
+
- At least one OTHER header cell contains a known column keyword PLUS a
|
| 577 |
+
money amount β "money-fused" column
|
| 578 |
+
e.g. "BALANCE$3,447.10"
|
| 579 |
+
"AMOUNT 5,000.00"
|
| 580 |
+
|
| 581 |
+
Both signals must fire simultaneously (two-signal guard). If only one
|
| 582 |
+
fires the header is left completely unchanged and None is returned β
|
| 583 |
+
this prevents false positives on clean PDFs from any bank.
|
| 584 |
+
|
| 585 |
+
Returns:
|
| 586 |
+
(cleaned_header : list[str], recovered_row : list[str] | None)
|
| 587 |
+
"""
|
| 588 |
+
if not header_row:
|
| 589 |
+
return list(header_row), None
|
| 590 |
+
|
| 591 |
+
ncols = len(header_row)
|
| 592 |
+
cleaned = list(header_row)
|
| 593 |
+
recovered = [""] * ncols
|
| 594 |
|
| 595 |
+
text_fused = [] # column indices where a descriptive label was fused
|
| 596 |
+
money_fused = [] # column indices where a money amount was fused
|
| 597 |
|
| 598 |
+
for idx, cell in enumerate(header_row):
|
| 599 |
+
cell_s = str(cell or "").strip()
|
| 600 |
+
if not cell_s:
|
|
|
|
| 601 |
continue
|
| 602 |
|
| 603 |
+
keyword, remainder = _split_keyword_remainder(cell_s)
|
|
|
|
|
|
|
|
|
|
| 604 |
|
| 605 |
+
if not remainder:
|
| 606 |
+
# Nothing fused into this cell β leave it alone.
|
| 607 |
+
continue
|
| 608 |
+
|
| 609 |
+
# Determine what kind of remainder this is.
|
| 610 |
+
remainder_no_space = remainder.replace(" ", "")
|
| 611 |
+
|
| 612 |
+
if _MONEY_RE.match(remainder_no_space):
|
| 613 |
+
# Money amount fused into this header cell.
|
| 614 |
+
cleaned[idx] = keyword
|
| 615 |
+
recovered[idx] = remainder
|
| 616 |
+
money_fused.append(idx)
|
| 617 |
+
|
| 618 |
+
elif not _HEADER_KW_EXACT_RE.match(remainder.split()[0] if remainder.split() else ""):
|
| 619 |
+
# Free descriptive text fused into this header cell.
|
| 620 |
+
# (We exclude the case where the remainder is itself a keyword,
|
| 621 |
+
# which would just be a two-word column name like "POSTING DATE"
|
| 622 |
+
# partially matched β those are handled by the longer patterns.)
|
| 623 |
+
cleaned[idx] = keyword
|
| 624 |
+
recovered[idx] = remainder
|
| 625 |
+
text_fused.append(idx)
|
| 626 |
+
|
| 627 |
+
# Two-signal guard: only inject when BOTH kinds of fusion are present.
|
| 628 |
+
if text_fused and money_fused:
|
| 629 |
+
return cleaned, recovered
|
| 630 |
+
|
| 631 |
+
# One or zero signals β do not touch anything.
|
| 632 |
+
return list(header_row), None
|
| 633 |
+
|
| 634 |
+
|
| 635 |
+
def _clean_header_artifacts(grid):
|
| 636 |
+
"""
|
| 637 |
+
Entry point called from normalize_html_tables.
|
| 638 |
+
Applies _extract_fused_header_artifacts to the header row of the grid.
|
| 639 |
+
|
| 640 |
+
Returns (grid, recovered_row | None).
|
| 641 |
+
The caller inserts recovered_row at position [1] when it is not None.
|
| 642 |
+
"""
|
| 643 |
+
if not grid or not grid[0]:
|
| 644 |
+
return grid, None
|
| 645 |
+
|
| 646 |
+
cleaned_header, recovered_row = _extract_fused_header_artifacts(grid[0])
|
| 647 |
+
grid[0] = cleaned_header
|
| 648 |
+
return grid, recovered_row
|
| 649 |
|
| 650 |
|
| 651 |
def normalize_html_tables(text: str) -> str:
|
| 652 |
"""
|
| 653 |
For every <table>...</table>:
|
| 654 |
- parse to grid (expand colspan/rowspan)
|
| 655 |
+
- drop truly-empty columns
|
| 656 |
+
- merge blank-header text columns into the left column
|
| 657 |
+
- clean header artifacts and recover fused first data row (generic)
|
| 658 |
- emit normalized <table> HTML
|
| 659 |
"""
|
| 660 |
if not text or "<table" not in text.lower():
|
|
|
|
| 673 |
grid = _build_grid(p.rows)
|
| 674 |
grid = _drop_truly_empty_columns(grid)
|
| 675 |
grid = _merge_blank_header_text_columns(grid)
|
| 676 |
+
|
| 677 |
+
# Generic fused-header artifact recovery.
|
| 678 |
+
# recovered_row is only non-None when the two-signal guard passes
|
| 679 |
+
# (both a text-fused cell and a money-fused cell were detected).
|
| 680 |
+
# On all clean PDFs this returns None β no side effects.
|
| 681 |
+
grid, recovered_row = _clean_header_artifacts(grid)
|
| 682 |
+
|
| 683 |
+
if recovered_row is not None:
|
| 684 |
+
grid.insert(1, recovered_row)
|
| 685 |
+
|
| 686 |
out.append(_grid_to_html(grid) if grid else table_html)
|
| 687 |
except Exception:
|
| 688 |
out.append(table_html)
|