"""Content-based classification for Subsplash roster exports. Weekdays and filenames are intentionally absent from this module. A roster is identified only by the ministry-role structure found in its extracted text. """ from __future__ import annotations from dataclasses import dataclass from enum import Enum from os import PathLike from pathlib import Path import re import unicodedata import fitz # type: ignore class RosterType(Enum): MAIN = "main" NURSERY_ONLY = "nursery_only" UNKNOWN = "unknown" class RosterClassificationError(ValueError): """Raised when a pair cannot be assigned without guessing.""" # Each key is one maintainable role concept. Aliases tolerate small heading # changes without counting variants as separate evidence. NURSERY_ROLE_ALIASES = ("NURSERY WORKERS", "NURSERY WORKER", "NURSERY") BROAD_ROLE_ALIASES: dict[str, tuple[str, ...]] = { "MEDIA": ("MEDIA",), "LITTLE WADERS": ("LITTLE WADERS", "LITTLE WADER"), "RIVER KID": ("RIVER KIDS", "RIVER KID"), "GREETERS": ("GREETERS", "GREETER"), "USHERS": ("USHERS", "USHER"), "OFFERING MESSAGE": ("OFFERING MESSAGE",), "PRAYER": ("PRAYER",), "CLEANER": ("CLEANERS", "CLEANER"), } MINIMUM_MAIN_ROLE_SIGNALS = 2 @dataclass(frozen=True) class RosterClassification: roster_type: RosterType nursery_found: bool broader_roles: tuple[str, ...] normalized_text: str @property def diagnostic(self) -> str: nursery = "nursery heading found" if self.nursery_found else "no nursery heading" roles = ", ".join(self.broader_roles) if self.broader_roles else "none" return f"{nursery}; broader role headings: {roles}" @dataclass(frozen=True) class AssignedRosters: main: Path nursery: Path main_classification: RosterClassification nursery_classification: RosterClassification def normalize_roster_text(text: str) -> str: """Normalize case and spacing while preserving extracted line boundaries.""" normalized_lines = [] for raw_line in unicodedata.normalize("NFKC", text).upper().splitlines(): line = re.sub(r"[^A-Z0-9]+", " ", raw_line).strip() if line: normalized_lines.append(line) return "\n".join(normalized_lines) def classify_roster_text(text: str) -> RosterClassification: """Classify extracted text without using dates, weekdays, or length.""" normalized = normalize_roster_text(text) lines = frozenset(normalized.splitlines()) nursery_found = any(alias in lines for alias in NURSERY_ROLE_ALIASES) broader_roles = tuple( role for role, aliases in BROAD_ROLE_ALIASES.items() if any(alias in lines for alias in aliases) ) if len(broader_roles) >= MINIMUM_MAIN_ROLE_SIGNALS: roster_type = RosterType.MAIN elif nursery_found and not broader_roles: roster_type = RosterType.NURSERY_ONLY else: roster_type = RosterType.UNKNOWN return RosterClassification( roster_type=roster_type, nursery_found=nursery_found, broader_roles=broader_roles, normalized_text=normalized, ) def classify_roster_pdf(path: str | PathLike[str]) -> RosterClassification: """Extract page text and classify a previously validated one-page PDF.""" document = fitz.open(path) try: text = "\n".join(page.get_text("text") for page in document) finally: document.close() return classify_roster_text(text) def assign_roster_pdfs( first: str | PathLike[str], second: str | PathLike[str], ) -> AssignedRosters: """Assign an unordered PDF pair, refusing every ambiguous combination.""" paths = (Path(first), Path(second)) classifications = tuple(classify_roster_pdf(path) for path in paths) main_indexes = [ index for index, result in enumerate(classifications) if result.roster_type is RosterType.MAIN ] nursery_indexes = [ index for index, result in enumerate(classifications) if result.roster_type is RosterType.NURSERY_ONLY ] if len(main_indexes) == 1 and len(nursery_indexes) == 1: main_index = main_indexes[0] nursery_index = nursery_indexes[0] return AssignedRosters( main=paths[main_index], nursery=paths[nursery_index], main_classification=classifications[main_index], nursery_classification=classifications[nursery_index], ) diagnostics = "; ".join( f"PDF {index + 1}: {result.roster_type.value} ({result.diagnostic})" for index, result in enumerate(classifications) ) raise RosterClassificationError( "The formatter could not determine one full roster and one nursery-only " "roster from the two PDFs. The full roster needs several broader ministry-role " "headings, while the nursery-only roster should contain Nursery or Nursery " f"Workers without those broader roles. {diagnostics}." )