Add optional nursery roster composition

This commit is contained in:
Media
2026-08-30 20:04:00 -05:00
parent 3eab672c6c
commit 7651a3ed9d
12 changed files with 1578 additions and 116 deletions
+153
View File
@@ -0,0 +1,153 @@
"""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}."
)