"""Glossary management for consistent theological term translation.""" from __future__ import annotations import re from typing import TYPE_CHECKING, cast from src.models.glossary import EnhancedGlossaryTerm, GlossaryTerm, TheologicalDomain if TYPE_CHECKING: pass _HEBREW_MARKS = re.compile(r"[֑-ׇ/]") def _normalize_hebrew(text: str) -> str: """Strip Hebrew pointing/cantillation and morpheme separators.""" return _HEBREW_MARKS.sub("", text) class GlossaryManager: """Manages theological term glossary for consistent translations. Supports morphological matching through lemmas and inflected forms. """ def __init__(self, terms: list[GlossaryTerm] | None = None) -> None: """Initialize the glossary manager. Args: terms: Initial list of glossary terms. """ self._terms: list[GlossaryTerm] = terms or [] self._by_original: dict[str, GlossaryTerm] = {} self._by_transliteration: dict[str, GlossaryTerm] = {} self._by_domain: dict[TheologicalDomain, list[GlossaryTerm]] = { domain: [] for domain in TheologicalDomain } # Morphological indexes self._by_lemma: dict[str, GlossaryTerm] = {} self._by_strongs: dict[str, GlossaryTerm] = {} self._all_forms: dict[str, GlossaryTerm] = {} # All matchable forms self._match_pattern: re.Pattern | None = None # Compiled regex for fast matching self._rebuild_indexes() def _rebuild_indexes(self) -> None: """Rebuild lookup indexes including morphological indexes.""" self._by_original = {term.original: term for term in self._terms} self._by_transliteration = { term.transliteration.lower(): term for term in self._terms } # Rebuild domain index self._by_domain = {domain: [] for domain in TheologicalDomain} for term in self._terms: self._by_domain[term.domain].append(term) # Rebuild morphological indexes self._by_lemma = {} self._by_strongs = {} self._all_forms = {} for term in self._terms: # Index by lemma if term.lemma: self._by_lemma[term.lemma] = term # Index by Strong's number if term.strongs_number: self._by_strongs[term.strongs_number.upper()] = term # Index all matchable forms for form in term.get_all_matchable_forms(): self._all_forms[form.lower()] = term # Build compiled regex pattern for fast matching self._build_match_pattern() def _build_match_pattern(self) -> None: """Build compiled regex pattern for matching all glossary forms.""" if not self._all_forms: self._match_pattern = None return # Sort forms by length (longest first) to match longest forms first sorted_forms = cast(list[str], sorted(self._all_forms.keys(), key=len, reverse=True)) # Escape special regex characters and join with | escaped_forms = [re.escape(form) for form in sorted_forms] pattern_str = "|".join(escaped_forms) # Compile with Unicode support; IGNORECASE so verse-initial # capitalized Greek forms (Μακάριοι) still match. self._match_pattern = re.compile(pattern_str, re.UNICODE | re.IGNORECASE) def add_term(self, term: GlossaryTerm) -> None: """Add a term to the glossary.""" self._terms.append(term) self._by_original[term.original] = term self._by_transliteration[term.transliteration.lower()] = term self._by_domain[term.domain].append(term) # Update morphological indexes if term.lemma: self._by_lemma[term.lemma] = term if term.strongs_number: self._by_strongs[term.strongs_number.upper()] = term for form in term.get_all_matchable_forms(): self._all_forms[form.lower()] = term # Rebuild match pattern (could be optimized for single additions) self._build_match_pattern() def get_by_original(self, original: str) -> GlossaryTerm | None: """Look up a term by its original Greek/Hebrew form.""" return self._by_original.get(original) def get_by_transliteration(self, translit: str) -> GlossaryTerm | None: """Look up a term by its transliteration.""" return self._by_transliteration.get(translit.lower()) def get_by_lemma(self, lemma: str) -> GlossaryTerm | None: """Look up a term by its lemma (dictionary form). Args: lemma: The lemma to look up. Returns: The GlossaryTerm if found, None otherwise. """ return self._by_lemma.get(lemma) def get_by_strongs(self, strongs: str) -> GlossaryTerm | None: """Look up a term by Strong's Concordance number. Args: strongs: Strong's number (e.g., 'G3056'). Returns: The GlossaryTerm if found, None otherwise. """ return self._by_strongs.get(strongs.upper()) def get_by_any_form(self, form: str) -> GlossaryTerm | None: """Look up a term by any of its matchable forms. Searches original, lemma, and all inflected forms. Args: form: The form to look up. Returns: The GlossaryTerm if found, None otherwise. """ return self._all_forms.get(form.lower()) def get_by_domain(self, domain: TheologicalDomain) -> list[GlossaryTerm]: """Get all terms in a specific theological domain. Args: domain: The theological domain to filter by. Returns: List of terms in that domain. """ return list(self._by_domain.get(domain, [])) def get_related_terms(self, term: GlossaryTerm) -> list[GlossaryTerm]: """Get all related terms for a given term. Args: term: The term to find related terms for. Returns: List of related GlossaryTerm objects. """ related = [] for original in term.related_terms: related_term = self.get_by_original(original) if related_term: related.append(related_term) return related def find_terms_in_text(self, text: str) -> list[GlossaryTerm]: """Find all glossary terms present in a text. Uses efficient compiled regex matching with morphological awareness. Matches original forms, lemmas, and all known inflected forms. Args: text: Source text (Greek, Hebrew, or transliteration). Returns: List of unique matching glossary terms. """ if not self._match_pattern or not text: return [] found_terms: dict[str, GlossaryTerm] = {} # Use dict to deduplicate # Match against the raw text and, for Hebrew, against a normalized # copy with vowel points/cantillation (U+0591-U+05C7) and MorphHB # "/" morpheme separators removed — glossary entries store unpointed # consonantal forms, which never match pointed text directly. candidates = [text] normalized = _normalize_hebrew(text) if normalized != text: candidates.append(normalized) for candidate in candidates: for match in self._match_pattern.finditer(candidate): matched_form = match.group(0) term = self._all_forms.get(matched_form.lower()) if term and term.original not in found_terms: found_terms[term.original] = term # Also check transliterations (case-insensitive) text_lower = text.lower() for term in self._terms: if term.original not in found_terms: if term.transliteration.lower() in text_lower: found_terms[term.original] = term return list(found_terms.values()) def find_terms_in_text_with_positions( self, text: str ) -> list[tuple[GlossaryTerm, int, int]]: """Find all glossary terms in text with their positions. Useful for highlighting or annotating text with glossary matches. Args: text: Source text (Greek, Hebrew, or transliteration). Returns: List of tuples (term, start_pos, end_pos) for each match. """ if not self._match_pattern or not text: return [] matches = [] for match in self._match_pattern.finditer(text): matched_form = match.group(0) term = self._all_forms.get(matched_form.lower()) if term: matches.append((term, match.start(), match.end())) return matches def get_all_terms(self) -> list[GlossaryTerm]: """Get all glossary terms.""" return list(self._terms) def format_for_prompt( self, terms: list[GlossaryTerm], include_enhanced: bool = True ) -> str: """Format glossary terms for inclusion in a translation prompt. Args: terms: List of terms to format. include_enhanced: Whether to include enhanced metadata. Returns: Formatted string for prompt inclusion. """ if not terms: return "No specific glossary terms apply to this verse." lines = [] for term in terms: # Core information line = f"- {term.original} ({term.transliteration}) → {term.japanese}" if term.notes: line += f" [{term.notes}]" lines.append(line) # Enhanced metadata (if available and requested) if include_enhanced and isinstance(term, EnhancedGlossaryTerm): if term.semantic_range: semantic_str = ", ".join(term.semantic_range) lines.append(f" Semantic range: {semantic_str}") if term.avoid_translations: avoid_str = ", ".join(term.avoid_translations) lines.append(f" Avoid: {avoid_str}") if term.definition: lines.append(f" Definition: {term.definition}") return "\n".join(lines) def get_domain_summary(self) -> dict[str, int]: """Get a summary of term counts by domain. Returns: Dictionary mapping domain names to term counts. """ return { domain.value: len(terms) for domain, terms in self._by_domain.items() } def __len__(self) -> int: return len(self._terms)