Add optional nursery roster composition
This commit is contained in:
@@ -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}."
|
||||
)
|
||||
Reference in New Issue
Block a user