154 lines
4.9 KiB
Python
154 lines
4.9 KiB
Python
"""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}."
|
|
)
|