Files
PaperPod/paperpod/naming/filename.py
T
ilia 12e5d1aeb1 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).
2026-07-26 15:37:28 -04:00

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