OPEN SOURCE
software/src/context/pericope_generator.py
Read on this site and download the files you need.
Nichijoyaku was created using AI. A complete review by human biblical-language and biblical-studies specialists has not been established. Read alongside established translations and original-language resources.
← Back to the open-source guide
1"""Automatic pericope generation from SBLGNT paragraph markers.23Generates pericope boundaries from SBLGNT paragraph markers (¶) when4manually curated pericope data is not available.5"""67from __future__ import annotations89import json10import re11from pathlib import Path12from typing import TYPE_CHECKING1314from src.models.pericope import Pericope, PericopeIndex1516if TYPE_CHECKING:17from src.data.loader import DataLoader181920# Paragraph marker in SBLGNT text21PARAGRAPH_MARKER = "¶"2223# Minimum verses in a pericope (avoid single-verse pericopes)24MIN_PERICOPE_SIZE = 22526# Maximum verses in a pericope (split if too large)27MAX_PERICOPE_SIZE = 25282930class PericopeGenerator:31"""Generates pericope boundaries from SBLGNT paragraph markers.3233The SBLGNT (SBL Greek New Testament) includes paragraph markers (¶)34that indicate natural text divisions. This generator uses those35markers to create pericope boundaries for books that don't have36manually curated pericope data.37"""3839def __init__(40self,41data_dir: Path | str,42loader: DataLoader | None = None,43) -> None:44"""Initialize the pericope generator.4546Args:47data_dir: Path to the data directory.48loader: Optional DataLoader for accessing verse data.49"""50self._data_dir = Path(data_dir)51self._loader = loader5253def _load_greek_data(self, book: str) -> dict:54"""Load Greek source data for a book.5556Args:57book: Book name.5859Returns:60Dictionary of Greek verse data.61"""62path = self._data_dir / "sources" / "greek" / f"{book}.json"63if not path.exists():64return {}6566with path.open("r", encoding="utf-8") as f:67return json.load(f)6869def _find_paragraph_markers(self, greek_data: dict) -> list[str]:70"""Find verse references that start with paragraph markers.7172Args:73greek_data: Dictionary of Greek verse data.7475Returns:76List of verse references (e.g., '1:1', '1:5') that begin paragraphs.77"""78paragraph_starts = []7980for ref, data in sorted(81greek_data.items(),82key=lambda x: self._ref_sort_key(x[0]),83):84text = data.get("text", "") if isinstance(data, dict) else data85if text and text.strip().startswith(PARAGRAPH_MARKER):86paragraph_starts.append(ref)8788return paragraph_starts8990def _ref_sort_key(self, ref: str) -> tuple[int, int]:91"""Get sort key for a verse reference.9293Args:94ref: Verse reference like '1:1'.9596Returns:97Tuple of (chapter, verse) for sorting.98"""99try:100chapter, verse = ref.split(":")101return (int(chapter), int(verse))102except (ValueError, AttributeError):103return (0, 0)104105def _get_all_verses(self, greek_data: dict) -> list[str]:106"""Get all verse references in order.107108Args:109greek_data: Dictionary of Greek verse data.110111Returns:112Sorted list of verse references.113"""114refs = list(greek_data.keys())115return sorted(refs, key=self._ref_sort_key)116117def _split_large_pericope(118self,119verses: list[str],120max_size: int = MAX_PERICOPE_SIZE,121) -> list[list[str]]:122"""Split a large pericope into smaller chunks.123124Args:125verses: List of verse references.126max_size: Maximum pericope size.127128Returns:129List of verse lists, each representing a pericope.130"""131if len(verses) <= max_size:132return [verses]133134# Split into roughly equal parts135num_parts = (len(verses) + max_size - 1) // max_size136part_size = len(verses) // num_parts137138parts = []139for i in range(num_parts):140start = i * part_size141end = start + part_size if i < num_parts - 1 else len(verses)142parts.append(verses[start:end])143144return parts145146def generate_pericopes(147self,148book: str,149min_size: int = MIN_PERICOPE_SIZE,150max_size: int = MAX_PERICOPE_SIZE,151) -> PericopeIndex:152"""Generate pericopes for a book from paragraph markers.153154Args:155book: Book name.156min_size: Minimum pericope size.157max_size: Maximum pericope size.158159Returns:160PericopeIndex containing generated pericopes.161"""162greek_data = self._load_greek_data(book)163164if not greek_data:165return PericopeIndex(166book=book,167name=book.title(),168pericopes=[],169)170171# Get all verses and paragraph markers172all_verses = self._get_all_verses(greek_data)173paragraph_starts = self._find_paragraph_markers(greek_data)174175# Always treat verse 1:1 as a paragraph start if not already176if all_verses and all_verses[0] not in paragraph_starts:177paragraph_starts.insert(0, all_verses[0])178179# Build pericopes between paragraph markers180pericopes = []181pericope_count = 0182183for i, start_ref in enumerate(paragraph_starts):184# Find end of this pericope (start of next, or end of book)185if i + 1 < len(paragraph_starts):186end_ref = paragraph_starts[i + 1]187# Get verses up to but not including the next paragraph188start_idx = all_verses.index(start_ref)189end_idx = all_verses.index(end_ref)190verses = all_verses[start_idx:end_idx]191else:192# Last pericope goes to end of book193start_idx = all_verses.index(start_ref)194verses = all_verses[start_idx:]195196# Skip empty or too-small pericopes (merge with previous)197if len(verses) < min_size:198if pericopes:199# Merge with previous pericope200pericopes[-1].verses.extend(verses)201self._update_pericope_range(pericopes[-1])202continue203204# Split large pericopes205verse_groups = self._split_large_pericope(verses, max_size)206207for verse_group in verse_groups:208pericope_count += 1209pericope = self._create_pericope(210book=book,211verses=verse_group,212index=pericope_count,213)214pericopes.append(pericope)215216return PericopeIndex(217book=book,218name=book.title(),219pericopes=pericopes,220)221222def _create_pericope(223self,224book: str,225verses: list[str],226index: int,227) -> Pericope:228"""Create a Pericope object from a list of verses.229230Args:231book: Book name.232verses: List of verse references.233index: Pericope index number.234235Returns:236Pericope object.237"""238start_chapter, start_verse = map(int, verses[0].split(":"))239end_chapter, end_verse = map(int, verses[-1].split(":"))240241return Pericope(242id=f"{book}-auto-{index:03d}",243title=None,244title_ja=None,245start_chapter=start_chapter,246start_verse=start_verse,247end_chapter=end_chapter,248end_verse=end_verse,249verses=verses,250theme=None,251auto_generated=True,252)253254def _update_pericope_range(self, pericope: Pericope) -> None:255"""Update pericope start/end based on verses list.256257Args:258pericope: Pericope to update.259"""260if not pericope.verses:261return262263start_chapter, start_verse = map(int, pericope.verses[0].split(":"))264end_chapter, end_verse = map(int, pericope.verses[-1].split(":"))265266pericope.start_chapter = start_chapter267pericope.start_verse = start_verse268pericope.end_chapter = end_chapter269pericope.end_verse = end_verse270271def generate_and_save(272self,273book: str,274output_path: Path | None = None,275) -> PericopeIndex:276"""Generate pericopes and save to file.277278Args:279book: Book name.280output_path: Optional output path. Defaults to metadata directory.281282Returns:283Generated PericopeIndex.284"""285index = self.generate_pericopes(book)286287if output_path is None:288output_path = self._data_dir / "metadata" / "pericopes_generated.json"289290# Load existing generated pericopes291if output_path.exists():292with output_path.open("r", encoding="utf-8") as f:293data = json.load(f)294else:295data = {}296297# Update with new pericopes298data[book] = {299"name": index.name,300"pericopes": [p.model_dump() for p in index.pericopes],301"auto_generated": True,302}303304# Save305output_path.parent.mkdir(parents=True, exist_ok=True)306with output_path.open("w", encoding="utf-8") as f:307json.dump(data, f, indent=2, ensure_ascii=False)308309return index310311def generate_all_books(self, books: list[str] | None = None) -> dict[str, PericopeIndex]:312"""Generate pericopes for all books.313314Args:315books: List of book names. If None, scans Greek data directory.316317Returns:318Dictionary mapping book names to PericopeIndex.319"""320if books is None:321greek_dir = self._data_dir / "sources" / "greek"322if greek_dir.exists():323books = [324f.stem for f in greek_dir.glob("*.json")325]326else:327books = []328329result = {}330for book in books:331index = self.generate_pericopes(book)332if index.pericopes:333result[book] = index334335return result336337338def merge_pericope_indexes(339manual: PericopeIndex | None,340generated: PericopeIndex | None,341) -> PericopeIndex | None:342"""Merge manual and generated pericope indexes.343344Manual pericopes take precedence; generated fill gaps.345346Args:347manual: Manually curated pericope index.348generated: Auto-generated pericope index.349350Returns:351Merged PericopeIndex, or None if both inputs are None.352"""353if manual is None and generated is None:354return None355356if manual is None:357return generated358359if generated is None:360return manual361362# Manual takes full precedence if it has pericopes363if manual.pericopes:364return manual365366# Use generated if manual is empty367return generated368