Add page-marker grouping and party-aware naming
Consecutive captures whose printed PAGE X OF Y markers advance under the same total are grouped into one multi-page PDF, with duplicate pages keeping the better read. Naming gains doc-title/party components and a scan-date fallback so nothing lands in UNSORTED. Vision LLM default switches to minicpm-v (faster, fewer false blanks on dense mono pages).
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
"""Multi-page grouping and duplicate-capture handling in run_export's phase 2."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from paperpod.pipeline import _group_documents, _PageCandidate
|
||||
|
||||
|
||||
class _FakeOcr:
|
||||
def __init__(self, mean_confidence: float = 80.0):
|
||||
self.mean_confidence = mean_confidence
|
||||
|
||||
|
||||
def _cand(
|
||||
window_id: int,
|
||||
vendor: str | None = None,
|
||||
date: str | None = None,
|
||||
page_number: int | None = None,
|
||||
page_total: int | None = None,
|
||||
confidence: float = 80.0,
|
||||
total: str | None = None,
|
||||
doc_title: str | None = None,
|
||||
t_start: float | None = None,
|
||||
text: str = "",
|
||||
) -> _PageCandidate:
|
||||
t0 = t_start if t_start is not None else float(window_id)
|
||||
ocr = _FakeOcr(confidence)
|
||||
ocr.text = text
|
||||
return _PageCandidate(
|
||||
window={"window_id": window_id, "t_start": t0, "t_end": t0 + 0.5},
|
||||
crop_path=Path(f"crops/window_{window_id:03d}.png"),
|
||||
ocr=ocr,
|
||||
llm=None,
|
||||
vendor=vendor,
|
||||
date=date,
|
||||
time=None,
|
||||
total=total,
|
||||
form_code=None,
|
||||
tax_year=None,
|
||||
org_name=None,
|
||||
doc_title=doc_title,
|
||||
party=None,
|
||||
page_number=page_number,
|
||||
page_total=page_total,
|
||||
)
|
||||
|
||||
|
||||
def test_incrementing_page_markers_group_into_one_document():
|
||||
docs = _group_documents(
|
||||
[
|
||||
_cand(0, vendor="TD Canada Trust", date="2025-01-31", page_number=1, page_total=2),
|
||||
_cand(1, vendor="TD Canada Trust", page_number=2, page_total=2),
|
||||
]
|
||||
)
|
||||
assert len(docs) == 1
|
||||
assert len(docs[0].pages) == 2
|
||||
|
||||
|
||||
def test_new_page_1_starts_a_new_document():
|
||||
docs = _group_documents(
|
||||
[
|
||||
_cand(0, vendor="TD Canada Trust", page_number=1, page_total=2),
|
||||
_cand(1, vendor="TD Canada Trust", page_number=2, page_total=2),
|
||||
_cand(2, vendor="TD Canada Trust", page_number=1, page_total=2),
|
||||
]
|
||||
)
|
||||
assert len(docs) == 2
|
||||
assert [len(d.pages) for d in docs] == [2, 1]
|
||||
|
||||
|
||||
def test_different_page_totals_do_not_group():
|
||||
docs = _group_documents(
|
||||
[
|
||||
_cand(0, page_number=1, page_total=2, vendor="TD"),
|
||||
_cand(1, page_number=2, page_total=17, vendor="TD"),
|
||||
]
|
||||
)
|
||||
assert len(docs) == 2
|
||||
|
||||
|
||||
def test_documents_without_markers_stay_single_page():
|
||||
docs = _group_documents(
|
||||
[
|
||||
_cand(0, vendor="Costco", date="2024-01-01", total="10.00"),
|
||||
_cand(1, vendor="Walmart", date="2024-01-02", total="20.00"),
|
||||
]
|
||||
)
|
||||
assert len(docs) == 2
|
||||
|
||||
|
||||
def test_duplicate_capture_keeps_higher_confidence_read():
|
||||
docs = _group_documents(
|
||||
[
|
||||
_cand(0, vendor="TD Canada Trust", page_number=1, page_total=2, confidence=60.0),
|
||||
_cand(1, vendor="TD Canada Trust", page_number=1, page_total=2, confidence=90.0),
|
||||
_cand(2, vendor="TD Canada Trust", page_number=2, page_total=2),
|
||||
]
|
||||
)
|
||||
assert len(docs) == 1
|
||||
assert len(docs[0].pages) == 2
|
||||
assert docs[0].pages[0].window["window_id"] == 1 # better read replaced the first
|
||||
|
||||
|
||||
def test_duplicate_signature_without_markers_is_dropped():
|
||||
docs = _group_documents(
|
||||
[
|
||||
_cand(0, vendor="Costco", date="2024-01-01", total="10.00"),
|
||||
_cand(1, vendor="Costco", date="2024-01-01", total="10.00"),
|
||||
]
|
||||
)
|
||||
assert len(docs) == 1
|
||||
assert len(docs[0].pages) == 1
|
||||
|
||||
|
||||
def test_duplicate_signature_far_apart_is_not_dropped():
|
||||
# Marker-less pages of one statement share vendor/date; a deliberate
|
||||
# page flip (many seconds) means it's a different page, not a dup.
|
||||
docs = _group_documents(
|
||||
[
|
||||
_cand(0, vendor="TD Canada Trust", date="2024-12-31", t_start=10.0),
|
||||
_cand(1, vendor="TD Canada Trust", date="2024-12-31", t_start=25.0),
|
||||
]
|
||||
)
|
||||
assert len(docs) == 2
|
||||
|
||||
|
||||
def test_unreadable_marker_gap_does_not_split_document():
|
||||
# Page 12's marker OCR'd fine, page 13's didn't get captured, page 14
|
||||
# still belongs to the same statement.
|
||||
docs = _group_documents(
|
||||
[
|
||||
_cand(0, vendor="TD", page_number=12, page_total=17),
|
||||
_cand(1, vendor="TD", page_number=14, page_total=17),
|
||||
]
|
||||
)
|
||||
assert len(docs) == 1
|
||||
assert len(docs[0].pages) == 2
|
||||
|
||||
|
||||
def test_markerless_page_inside_marker_chain_does_not_split():
|
||||
# Page 10's marker was unreadable but pages 9 and 11 anchor it: one
|
||||
# statement, three pages, in order.
|
||||
docs = _group_documents(
|
||||
[
|
||||
_cand(0, vendor="TD Canada Trust", page_number=9, page_total=17, t_start=0.0),
|
||||
_cand(1, vendor="TD Canada Trust", t_start=10.0),
|
||||
_cand(2, vendor="TD Canada Trust", page_number=11, page_total=17, t_start=20.0),
|
||||
]
|
||||
)
|
||||
assert len(docs) == 1
|
||||
assert [p.window["window_id"] for p in docs[0].pages] == [0, 1, 2]
|
||||
|
||||
|
||||
def test_markerless_page_attaches_to_incomplete_document():
|
||||
# "PAGE 1 OF 2" followed (after a deliberate page flip) by a
|
||||
# same-letterhead capture whose marker was unreadable: it's page 2,
|
||||
# not a new document — as long as the amounts don't match page 1.
|
||||
docs = _group_documents(
|
||||
[
|
||||
_cand(0, vendor="TD Canada Trust", page_number=1, page_total=2,
|
||||
doc_title="transaction_history", t_start=0.0,
|
||||
text="PAGE 1 OF 2 70.22 750.00 3000.00"),
|
||||
_cand(1, vendor="TD Canada Trust", doc_title="transaction_history",
|
||||
t_start=10.0,
|
||||
text="PAGE 2 OF 2 15.00 22.50 100.00 200.00"),
|
||||
]
|
||||
)
|
||||
assert len(docs) == 1
|
||||
assert len(docs[0].pages) == 2
|
||||
|
||||
|
||||
def test_markerless_recapture_of_same_page_is_dropped():
|
||||
# Garbled second capture of PAGE 1 shares the amount fingerprint with
|
||||
# the real page 1 — must not become a fake "page 2".
|
||||
docs = _group_documents(
|
||||
[
|
||||
_cand(0, vendor="TD Canada Trust", page_number=1, page_total=2,
|
||||
doc_title="transaction_history", t_start=0.0, confidence=90.0,
|
||||
text="PAGE 1 OF 2 70.22 DR 750.00 DR 3000.00 DR 490.96 1076 6254276"),
|
||||
_cand(1, vendor="TD Canada Trust", doc_title="transaction_history",
|
||||
t_start=10.0, confidence=55.0,
|
||||
text="PAGK 1 of 10,22 150,00 3000 490 1076 6254276"),
|
||||
]
|
||||
)
|
||||
assert len(docs) == 1
|
||||
assert len(docs[0].pages) == 1
|
||||
assert docs[0].pages[0].window["window_id"] == 0
|
||||
|
||||
|
||||
def test_successive_statement_pages_not_collapsed_by_shared_account_numbers():
|
||||
# Pages of one statement share branch/account numbers (~0.3 overlap) —
|
||||
# that must not replace an earlier marked page with a later one.
|
||||
docs = _group_documents(
|
||||
[
|
||||
_cand(0, vendor="TD Canada Trust", page_number=2, page_total=17,
|
||||
doc_title="transaction_history", t_start=0.0, confidence=92.0,
|
||||
text="PAGE 2 OF 17 1076 6254276 500.00 600.00 700.00"),
|
||||
_cand(1, vendor="TD Canada Trust", doc_title="transaction_history",
|
||||
t_start=10.0, confidence=91.0,
|
||||
text="PAGE 4 OF 17 1076 6254276 800.00 900.00 1000.00"),
|
||||
]
|
||||
)
|
||||
# Second has no readable marker in this unit test (page_number=None):
|
||||
# attach as incomplete-fill; must NOT replace page 2.
|
||||
assert len(docs) == 1
|
||||
assert len(docs[0].pages) == 2
|
||||
assert docs[0].pages[0].window["window_id"] == 0
|
||||
|
||||
|
||||
def test_markerless_low_confidence_does_not_fill_incomplete_doc():
|
||||
# Conf 63 mid-flip noise must not slot in as "page 2" just because the
|
||||
# statement is still incomplete.
|
||||
docs = _group_documents(
|
||||
[
|
||||
_cand(0, vendor="TD Canada Trust", page_number=1, page_total=2,
|
||||
doc_title="transaction_history", t_start=0.0, confidence=91.0,
|
||||
text="PAGE 1 OF 2 70.22 750.00"),
|
||||
_cand(1, vendor="TD Canada Trust", doc_title="transaction_history",
|
||||
t_start=10.0, confidence=63.0,
|
||||
text="garble noise 11 22 33 44 55 66 77"),
|
||||
]
|
||||
)
|
||||
assert len(docs) == 2
|
||||
|
||||
|
||||
def test_markerless_page_does_not_attach_to_complete_document():
|
||||
# After "PAGE 2 OF 2" the statement is done; the next capture is new.
|
||||
docs = _group_documents(
|
||||
[
|
||||
_cand(0, vendor="TD Canada Trust", page_number=2, page_total=2,
|
||||
doc_title="transaction_history", t_start=0.0),
|
||||
_cand(1, vendor="TD Canada Trust", doc_title="transaction_history",
|
||||
t_start=10.0),
|
||||
]
|
||||
)
|
||||
assert len(docs) == 2
|
||||
|
||||
|
||||
def test_markerless_recapture_moments_later_is_a_duplicate():
|
||||
# Identical signature 0.5s later is a contour blip, not a new page.
|
||||
docs = _group_documents(
|
||||
[
|
||||
_cand(0, vendor="Costco", date="2024-01-01", total="10.00", t_start=0.0),
|
||||
_cand(1, vendor="Costco", date="2024-01-01", total="10.00", t_start=1.0),
|
||||
]
|
||||
)
|
||||
assert len(docs) == 1
|
||||
assert len(docs[0].pages) == 1
|
||||
|
||||
|
||||
def test_marked_page_not_replaced_by_nearby_same_vendor_date():
|
||||
# Statement pages share vendor+date and flip in ~3s — must not treat the
|
||||
# next page as a signature duplicate of the previous.
|
||||
docs = _group_documents(
|
||||
[
|
||||
_cand(0, vendor="TD Canada Trust", date="2024-12-31",
|
||||
page_number=3, page_total=17, doc_title="transaction_history",
|
||||
t_start=40.0, confidence=88.0,
|
||||
text="PAGE 3 OF 17 1076 6254276 100.00 200.00"),
|
||||
_cand(1, vendor="TD Canada Trust", date="2024-12-31",
|
||||
doc_title="transaction_history", t_start=49.0, confidence=91.0,
|
||||
text="PAGE 4 OF 17 1076 6254276 300.00 400.00 500.00"),
|
||||
]
|
||||
)
|
||||
assert len(docs) == 1
|
||||
assert len(docs[0].pages) == 2
|
||||
assert docs[0].pages[0].page_number == 3
|
||||
Reference in New Issue
Block a user