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).
122 lines
4.4 KiB
Python
122 lines
4.4 KiB
Python
"""Build the final filename/PDF title from OCR (and, later, speech) metadata.
|
|
|
|
Pattern: YYYY-MM-DD_vendor[_doc-title].pdf. When the document's own date
|
|
can't be read, the scan date (capture timestamp from the video) is used
|
|
instead so files still sort chronologically and never get an opaque
|
|
"UNSORTED" prefix; export_summary.csv records that the date came from the
|
|
scan, not the document. Module 4 (speech) isn't built yet; once speech
|
|
transcription lands, a spoken description will take priority here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
|
|
|
|
def slugify(text: str, max_len: int = 40) -> str:
|
|
text = text.lower()
|
|
text = re.sub(r"[^a-z0-9]+", "_", text).strip("_")
|
|
text = text[:max_len].strip("_")
|
|
return text or "doc"
|
|
|
|
|
|
@dataclass
|
|
class NamingResult:
|
|
stem: str # filename without extension
|
|
source: str # "ocr" | "scan_date" | "none" ("speech" once module 4 exists)
|
|
|
|
|
|
_TIME_RE = re.compile(r"^([01]\d|2[0-3]):([0-5]\d)$")
|
|
|
|
|
|
def build_name(
|
|
date: str | None,
|
|
vendor: str | None,
|
|
fallback_dt: datetime,
|
|
max_vendor_len: int = 40,
|
|
form_code: str | None = None,
|
|
tax_year: str | None = None,
|
|
org_name: str | None = None,
|
|
time: str | None = None,
|
|
doc_title: str | None = None,
|
|
party: str | None = None,
|
|
) -> NamingResult:
|
|
"""date must already be normalized to YYYY-MM-DD, time to HH:MM, or None.
|
|
|
|
Time is appended to the date (e.g. 2023-03-20_1525) whenever it was
|
|
extracted, so two same-day visits to the same vendor (Walmart in the
|
|
morning and again in the evening) don't collide or silently overwrite.
|
|
|
|
doc_title is a recognized printed title slug ("transaction_history",
|
|
"invoice"), appended after the vendor unless it would be redundant.
|
|
party is an account holder / SHORTNAME when printed on the document.
|
|
"""
|
|
vendor_slug = slugify(vendor, max_vendor_len) if vendor else None
|
|
party_slug = slugify(party, max_vendor_len) if party else None
|
|
|
|
# Tax slips: prefer 2023_T4_York_University over a date-based name.
|
|
if form_code:
|
|
org_slug = slugify(org_name, max_vendor_len) if org_name else None
|
|
if not org_slug and vendor:
|
|
stripped = vendor
|
|
if stripped.upper().startswith(form_code.upper()):
|
|
stripped = stripped[len(form_code) :].strip()
|
|
org_slug = slugify(stripped, max_vendor_len) if stripped else None
|
|
year = tax_year or (date[:4] if date else None)
|
|
code = form_code.upper()
|
|
if year and org_slug:
|
|
return NamingResult(stem=f"{year}_{code}_{org_slug}", source="ocr")
|
|
if year:
|
|
return NamingResult(stem=f"{year}_{code}", source="ocr")
|
|
if org_slug:
|
|
return NamingResult(stem=f"{code}_{org_slug}", source="ocr")
|
|
return NamingResult(stem=code.lower(), source="ocr")
|
|
|
|
if date:
|
|
date_part = date
|
|
if time and _TIME_RE.match(time):
|
|
date_part = f"{date}_{time.replace(':', '')}"
|
|
source = "ocr"
|
|
else:
|
|
# Scan date beats an opaque "UNSORTED" prefix: files still sort,
|
|
# and the CSV records that the date is when it was scanned.
|
|
date_part = fallback_dt.strftime("%Y-%m-%d")
|
|
source = "scan_date"
|
|
|
|
title_slug = slugify(doc_title, max_vendor_len) if doc_title else None
|
|
parts = [date_part]
|
|
if vendor_slug:
|
|
parts.append(vendor_slug)
|
|
# Skip the title when the vendor line already says the same thing
|
|
# (Tesseract often picks the printed title as the "vendor").
|
|
if title_slug and not (vendor_slug and title_slug in vendor_slug):
|
|
parts.append(title_slug)
|
|
# Account-holder shortname after the doc type — disambiguates family
|
|
# members who share a bank (LEVIT I vs LEVIT M).
|
|
if party_slug and party_slug not in parts and (
|
|
not vendor_slug or party_slug not in vendor_slug
|
|
):
|
|
parts.append(party_slug)
|
|
|
|
if len(parts) == 1:
|
|
parts.append("document" if date else "scan")
|
|
if not date:
|
|
source = "none"
|
|
|
|
return NamingResult(stem="_".join(parts), source=source)
|
|
|
|
|
|
def dedupe_stem(stem: str, used: set[str]) -> str:
|
|
"""Append _2, _3, ... if stem was already used in this run."""
|
|
if stem not in used:
|
|
used.add(stem)
|
|
return stem
|
|
i = 2
|
|
while f"{stem}_{i}" in used:
|
|
i += 1
|
|
deduped = f"{stem}_{i}"
|
|
used.add(deduped)
|
|
return deduped
|