"""Automatic pericope generation from SBLGNT paragraph markers. Generates pericope boundaries from SBLGNT paragraph markers (¶) when manually curated pericope data is not available. """ from __future__ import annotations import json import re from pathlib import Path from typing import TYPE_CHECKING from src.models.pericope import Pericope, PericopeIndex if TYPE_CHECKING: from src.data.loader import DataLoader # Paragraph marker in SBLGNT text PARAGRAPH_MARKER = "¶" # Minimum verses in a pericope (avoid single-verse pericopes) MIN_PERICOPE_SIZE = 2 # Maximum verses in a pericope (split if too large) MAX_PERICOPE_SIZE = 25 class PericopeGenerator: """Generates pericope boundaries from SBLGNT paragraph markers. The SBLGNT (SBL Greek New Testament) includes paragraph markers (¶) that indicate natural text divisions. This generator uses those markers to create pericope boundaries for books that don't have manually curated pericope data. """ def __init__( self, data_dir: Path | str, loader: DataLoader | None = None, ) -> None: """Initialize the pericope generator. Args: data_dir: Path to the data directory. loader: Optional DataLoader for accessing verse data. """ self._data_dir = Path(data_dir) self._loader = loader def _load_greek_data(self, book: str) -> dict: """Load Greek source data for a book. Args: book: Book name. Returns: Dictionary of Greek verse data. """ path = self._data_dir / "sources" / "greek" / f"{book}.json" if not path.exists(): return {} with path.open("r", encoding="utf-8") as f: return json.load(f) def _find_paragraph_markers(self, greek_data: dict) -> list[str]: """Find verse references that start with paragraph markers. Args: greek_data: Dictionary of Greek verse data. Returns: List of verse references (e.g., '1:1', '1:5') that begin paragraphs. """ paragraph_starts = [] for ref, data in sorted( greek_data.items(), key=lambda x: self._ref_sort_key(x[0]), ): text = data.get("text", "") if isinstance(data, dict) else data if text and text.strip().startswith(PARAGRAPH_MARKER): paragraph_starts.append(ref) return paragraph_starts def _ref_sort_key(self, ref: str) -> tuple[int, int]: """Get sort key for a verse reference. Args: ref: Verse reference like '1:1'. Returns: Tuple of (chapter, verse) for sorting. """ try: chapter, verse = ref.split(":") return (int(chapter), int(verse)) except (ValueError, AttributeError): return (0, 0) def _get_all_verses(self, greek_data: dict) -> list[str]: """Get all verse references in order. Args: greek_data: Dictionary of Greek verse data. Returns: Sorted list of verse references. """ refs = list(greek_data.keys()) return sorted(refs, key=self._ref_sort_key) def _split_large_pericope( self, verses: list[str], max_size: int = MAX_PERICOPE_SIZE, ) -> list[list[str]]: """Split a large pericope into smaller chunks. Args: verses: List of verse references. max_size: Maximum pericope size. Returns: List of verse lists, each representing a pericope. """ if len(verses) <= max_size: return [verses] # Split into roughly equal parts num_parts = (len(verses) + max_size - 1) // max_size part_size = len(verses) // num_parts parts = [] for i in range(num_parts): start = i * part_size end = start + part_size if i < num_parts - 1 else len(verses) parts.append(verses[start:end]) return parts def generate_pericopes( self, book: str, min_size: int = MIN_PERICOPE_SIZE, max_size: int = MAX_PERICOPE_SIZE, ) -> PericopeIndex: """Generate pericopes for a book from paragraph markers. Args: book: Book name. min_size: Minimum pericope size. max_size: Maximum pericope size. Returns: PericopeIndex containing generated pericopes. """ greek_data = self._load_greek_data(book) if not greek_data: return PericopeIndex( book=book, name=book.title(), pericopes=[], ) # Get all verses and paragraph markers all_verses = self._get_all_verses(greek_data) paragraph_starts = self._find_paragraph_markers(greek_data) # Always treat verse 1:1 as a paragraph start if not already if all_verses and all_verses[0] not in paragraph_starts: paragraph_starts.insert(0, all_verses[0]) # Build pericopes between paragraph markers pericopes = [] pericope_count = 0 for i, start_ref in enumerate(paragraph_starts): # Find end of this pericope (start of next, or end of book) if i + 1 < len(paragraph_starts): end_ref = paragraph_starts[i + 1] # Get verses up to but not including the next paragraph start_idx = all_verses.index(start_ref) end_idx = all_verses.index(end_ref) verses = all_verses[start_idx:end_idx] else: # Last pericope goes to end of book start_idx = all_verses.index(start_ref) verses = all_verses[start_idx:] # Skip empty or too-small pericopes (merge with previous) if len(verses) < min_size: if pericopes: # Merge with previous pericope pericopes[-1].verses.extend(verses) self._update_pericope_range(pericopes[-1]) continue # Split large pericopes verse_groups = self._split_large_pericope(verses, max_size) for verse_group in verse_groups: pericope_count += 1 pericope = self._create_pericope( book=book, verses=verse_group, index=pericope_count, ) pericopes.append(pericope) return PericopeIndex( book=book, name=book.title(), pericopes=pericopes, ) def _create_pericope( self, book: str, verses: list[str], index: int, ) -> Pericope: """Create a Pericope object from a list of verses. Args: book: Book name. verses: List of verse references. index: Pericope index number. Returns: Pericope object. """ start_chapter, start_verse = map(int, verses[0].split(":")) end_chapter, end_verse = map(int, verses[-1].split(":")) return Pericope( id=f"{book}-auto-{index:03d}", title=None, title_ja=None, start_chapter=start_chapter, start_verse=start_verse, end_chapter=end_chapter, end_verse=end_verse, verses=verses, theme=None, auto_generated=True, ) def _update_pericope_range(self, pericope: Pericope) -> None: """Update pericope start/end based on verses list. Args: pericope: Pericope to update. """ if not pericope.verses: return start_chapter, start_verse = map(int, pericope.verses[0].split(":")) end_chapter, end_verse = map(int, pericope.verses[-1].split(":")) pericope.start_chapter = start_chapter pericope.start_verse = start_verse pericope.end_chapter = end_chapter pericope.end_verse = end_verse def generate_and_save( self, book: str, output_path: Path | None = None, ) -> PericopeIndex: """Generate pericopes and save to file. Args: book: Book name. output_path: Optional output path. Defaults to metadata directory. Returns: Generated PericopeIndex. """ index = self.generate_pericopes(book) if output_path is None: output_path = self._data_dir / "metadata" / "pericopes_generated.json" # Load existing generated pericopes if output_path.exists(): with output_path.open("r", encoding="utf-8") as f: data = json.load(f) else: data = {} # Update with new pericopes data[book] = { "name": index.name, "pericopes": [p.model_dump() for p in index.pericopes], "auto_generated": True, } # Save output_path.parent.mkdir(parents=True, exist_ok=True) with output_path.open("w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) return index def generate_all_books(self, books: list[str] | None = None) -> dict[str, PericopeIndex]: """Generate pericopes for all books. Args: books: List of book names. If None, scans Greek data directory. Returns: Dictionary mapping book names to PericopeIndex. """ if books is None: greek_dir = self._data_dir / "sources" / "greek" if greek_dir.exists(): books = [ f.stem for f in greek_dir.glob("*.json") ] else: books = [] result = {} for book in books: index = self.generate_pericopes(book) if index.pericopes: result[book] = index return result def merge_pericope_indexes( manual: PericopeIndex | None, generated: PericopeIndex | None, ) -> PericopeIndex | None: """Merge manual and generated pericope indexes. Manual pericopes take precedence; generated fill gaps. Args: manual: Manually curated pericope index. generated: Auto-generated pericope index. Returns: Merged PericopeIndex, or None if both inputs are None. """ if manual is None and generated is None: return None if manual is None: return generated if generated is None: return manual # Manual takes full precedence if it has pericopes if manual.pericopes: return manual # Use generated if manual is empty return generated