"""Translator module using Claude CLI.""" from __future__ import annotations import asyncio import json import logging import os import re import subprocess import time from collections.abc import Callable from typing import TYPE_CHECKING, Any from src.context.builder import ContextBuilder from src.data.storage import Storage from src.exceptions import ( ChunkParsingError, ClaudeAPIError, EvaluationError, QualityThresholdError, RateLimitError, classify_claude_error, ) from src.models.pericope import ChunkTranslationContext, ChunkTranslationOutput, Pericope from src.models.verse import TranslationOutput, VerseReference from src.pipeline.prompt import PromptBuilder from src.utils.furigana import parse_furigana from src.utils.retry import RetryConfig, RetryState if TYPE_CHECKING: from src.context.translation_memory import TranslationMemoryManager from src.evaluation.evaluator import TranslationEvaluator from src.evaluation.storage import EvaluationStorage from src.models.evaluation import VerseEvaluation from src.models.verse import TranslationContext from src.orchestration.provenance import TranslationProvenance logger = logging.getLogger(__name__) # Find Claude binary CLAUDE_BINARY = os.environ.get("CLAUDE_BINARY", "claude") def _clean_env() -> dict[str, str]: """Return a copy of the environment without CLAUDECODE to avoid nested session errors.""" env = os.environ.copy() env.pop("CLAUDECODE", None) return env DEFAULT_MODEL = os.environ.get("TRANSLATE_BIBLE_MODEL", "claude-opus-4-8") def _sanitize_translation_output(raw: str) -> str: """Strip meta-commentary from a verse translation response. Models occasionally emit self-corrections ("Wait—let me provide the correct translation.") or markdown fences around the answer. A verse translation is a single Japanese block, so: drop code fences, drop predominantly-Latin blocks, and if several Japanese blocks remain (a self-correction), keep the last one. """ text = raw.strip() if not text: return text lines = [ln for ln in text.split("\n") if not ln.strip().startswith("```")] blocks: list[list[str]] = [[]] for ln in lines: if ln.strip(): blocks[-1].append(ln) elif blocks[-1]: blocks.append([]) block_texts = ["\n".join(b).strip() for b in blocks if b] def is_japanese(block: str) -> bool: cjk = sum(1 for ch in block if "぀" <= ch <= "鿿" or "!" <= ch <= "⦆") latin = sum(1 for ch in block if ch.isascii() and ch.isalpha()) return cjk > latin japanese_blocks = [b for b in block_texts if is_japanese(b)] if not japanese_blocks: return text result = japanese_blocks[-1] result = re.sub(r"^(翻訳|訳文|Translation)\s*[::]\s*", "", result) if result != text: logger.warning("Sanitized translation output (removed meta-commentary)") return result # Default retry configuration DEFAULT_RETRY_CONFIG = RetryConfig( max_attempts=3, base_delay=2.0, max_delay=60.0, exponential_base=2.0, retryable_exceptions=(RateLimitError, ConnectionError, TimeoutError), ) # Quality thresholds for evaluation gating DEFAULT_QUALITY_THRESHOLD = 70.0 THEOLOGICAL_ACCURACY_THRESHOLD = 75.0 class Translator: """Translates Bible verses using Claude CLI.""" def __init__( self, context_builder: ContextBuilder, prompt_builder: PromptBuilder, storage: Storage, model: str = DEFAULT_MODEL, rate_limit_delay: float = 1.2, translation_memory: TranslationMemoryManager | None = None, auto_evaluate: bool = True, evaluator: TranslationEvaluator | None = None, evaluation_storage: EvaluationStorage | None = None, retry_config: RetryConfig | None = None, quality_threshold: float = DEFAULT_QUALITY_THRESHOLD, enforce_quality_gate: bool = False, subprocess_timeout: float | None = None, enable_proto_archive: bool = False, ) -> None: """Initialize the translator. Args: context_builder: Context builder for creating translation context. prompt_builder: Prompt builder for formatting prompts. storage: Storage for saving translations. model: Claude model to use. rate_limit_delay: Delay between API calls in seconds. translation_memory: Optional TM manager for reuse. auto_evaluate: Whether to automatically evaluate translations. evaluator: Evaluator to use for auto-evaluation. evaluation_storage: Storage for evaluation results. retry_config: Configuration for retry with backoff. quality_threshold: Minimum score for evaluation gating. enforce_quality_gate: If True, reject low-quality translations. subprocess_timeout: Timeout for Claude CLI calls in seconds. Defaults to the TRANSLATE_BIBLE_TIMEOUT env var, or 120. Long pericope chunks (10+ verses) can need 300s+. enable_proto_archive: Whether to archive translation inputs/outputs. """ self.context_builder = context_builder self.prompt_builder = prompt_builder self.storage = storage self.model = model self.rate_limit_delay = rate_limit_delay self._translation_memory = translation_memory self._auto_evaluate = auto_evaluate self._evaluator = evaluator self._evaluation_storage = evaluation_storage self._retry_config = retry_config or DEFAULT_RETRY_CONFIG self._quality_threshold = quality_threshold self._enforce_quality_gate = enforce_quality_gate if subprocess_timeout is None: subprocess_timeout = float(os.environ.get("TRANSLATE_BIBLE_TIMEOUT", "120")) self._subprocess_timeout = subprocess_timeout self._enable_archive = enable_proto_archive # Enable archive on storage if requested if enable_proto_archive: self.storage.enable_archive = True @property def enable_proto_archive(self) -> bool: """Get proto archive enabled status.""" return self._enable_archive @enable_proto_archive.setter def enable_proto_archive(self, value: bool) -> None: """Set proto archive enabled status.""" self._enable_archive = value self.storage.enable_archive = value @property def translation_memory(self) -> TranslationMemoryManager | None: """Get the translation memory manager.""" return self._translation_memory @translation_memory.setter def translation_memory(self, value: TranslationMemoryManager | None) -> None: """Set the translation memory manager.""" self._translation_memory = value @property def auto_evaluate(self) -> bool: """Get auto-evaluation setting.""" return self._auto_evaluate @auto_evaluate.setter def auto_evaluate(self, value: bool) -> None: """Set auto-evaluation setting.""" self._auto_evaluate = value def _call_claude(self, system_prompt: str, user_prompt: str) -> str: """Call Claude CLI with the given prompts. Includes retry logic with exponential backoff for transient errors. Args: system_prompt: System prompt for Claude. user_prompt: User prompt for Claude. Returns: The response text from Claude. Raises: ClaudeAPIError: If the Claude CLI call fails after all retries. """ retry_state = RetryState(self._retry_config) while True: try: result = subprocess.run( [ CLAUDE_BINARY, "--print", "-p", user_prompt, "--system-prompt", system_prompt, "--model", self.model, ], capture_output=True, text=True, check=False, timeout=self._subprocess_timeout, env=_clean_env(), ) if result.returncode != 0: error = classify_claude_error(result.stderr, result.returncode) raise error return result.stdout.strip() except subprocess.TimeoutExpired as e: error = ClaudeAPIError( f"Claude CLI timed out after {self._subprocess_timeout}s", stderr=str(e), return_code=-1, ) if retry_state.should_retry(error): logger.warning( "Claude CLI timeout, attempt %d/%d. Retrying...", retry_state.attempt + 1, self._retry_config.max_attempts, ) retry_state.wait() else: raise error from e except FileNotFoundError as e: # Claude CLI not installed from src.exceptions import CLINotFoundError raise CLINotFoundError() from e except ClaudeAPIError as e: if retry_state.should_retry(e): logger.warning( "Claude API error, attempt %d/%d: %s. Retrying...", retry_state.attempt + 1, self._retry_config.max_attempts, str(e), ) retry_state.wait() else: raise async def _call_claude_async(self, system_prompt: str, user_prompt: str) -> str: """Call Claude CLI asynchronously with the given prompts. Includes retry logic with exponential backoff for transient errors. Args: system_prompt: System prompt for Claude. user_prompt: User prompt for Claude. Returns: The response text from Claude. Raises: ClaudeAPIError: If the Claude CLI call fails after all retries. """ retry_state = RetryState(self._retry_config) while True: try: process = await asyncio.wait_for( asyncio.create_subprocess_exec( CLAUDE_BINARY, "--print", "-p", user_prompt, "--system-prompt", system_prompt, "--model", self.model, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=_clean_env(), ), timeout=self._subprocess_timeout, ) stdout, stderr = await asyncio.wait_for( process.communicate(), timeout=self._subprocess_timeout, ) if process.returncode != 0: error = classify_claude_error(stderr.decode(), process.returncode or 1) raise error return stdout.decode().strip() except asyncio.TimeoutError as e: error = ClaudeAPIError( f"Claude CLI timed out after {self._subprocess_timeout}s", stderr="Async timeout", return_code=-1, ) if retry_state.should_retry(error): logger.warning( "Claude CLI async timeout, attempt %d/%d. Retrying...", retry_state.attempt + 1, self._retry_config.max_attempts, ) await retry_state.async_wait() else: raise error from e except FileNotFoundError as e: from src.exceptions import CLINotFoundError raise CLINotFoundError() from e except ClaudeAPIError as e: if retry_state.should_retry(e): logger.warning( "Claude API error (async), attempt %d/%d: %s. Retrying...", retry_state.attempt + 1, self._retry_config.max_attempts, str(e), ) await retry_state.async_wait() else: raise def _get_tm_matches(self, context: TranslationContext) -> list | None: """Get TM matches for the context if TM is enabled. Args: context: Translation context. Returns: List of TM matches or None if TM not available. """ if self._translation_memory is None: return None # Get source text for matching source_text = ( context.target_verse.greek_text or context.target_verse.hebrew_text or "" ) if not source_text: return None matches = self._translation_memory.find_matches( source_text, threshold=0.7, max_results=3 ) return matches if matches else None def _add_to_tm( self, context: TranslationContext, translation_text: str ) -> None: """Add completed translation to translation memory. Args: context: Translation context. translation_text: The translated text. """ if self._translation_memory is None: return source_text = ( context.target_verse.greek_text or context.target_verse.hebrew_text ) if not source_text: return self._translation_memory.add_from_translation( source_text=source_text, target_text=translation_text, reference=context.target_verse.reference, metadata={"model": self.model}, ) def _evaluate_translation( self, output: TranslationOutput, context: TranslationContext, ) -> VerseEvaluation | None: """Evaluate a translation if auto-evaluate is enabled. Args: output: The translation output. context: The translation context. Returns: VerseEvaluation or None if evaluation failed/disabled. Raises: EvaluationError: If enforce_quality_gate is True and evaluation fails. """ if not self._auto_evaluate or not self._evaluator: return None ref = output.reference ref_str = f"{ref.book} {ref.chapter}:{ref.verse}" try: from src.evaluation.evaluator import ( EVALUATION_SYSTEM_PROMPT, parse_evaluation_response, ) # Build evaluation prompt eval_prompt = self._evaluator.build_verse_prompt( output.text, context ) # Call Claude for evaluation response = self._call_claude(EVALUATION_SYSTEM_PROMPT, eval_prompt) # Parse response scores = parse_evaluation_response(response) # Create evaluation evaluation = self._evaluator.create_verse_evaluation( verse_number=output.reference.verse, scores=scores, ) # Log evaluation result logger.info( "Evaluated %s: overall=%.1f%%, flags=%s", ref_str, evaluation.overall_score, evaluation.flags or "none", ) return evaluation except Exception as e: # Log the error with details logger.error( "Evaluation failed for %s: %s", ref_str, str(e), ) # If quality gate is enforced, raise the error if self._enforce_quality_gate: raise EvaluationError( f"Evaluation failed for {ref_str}: {e}", verse_ref=ref_str, ) from e # Otherwise, return None (don't block translation) return None def _check_quality_gate( self, evaluation: VerseEvaluation, reference: VerseReference, ) -> None: """Check if translation passes quality threshold. Args: evaluation: The verse evaluation. reference: The verse reference. Raises: QualityThresholdError: If translation fails quality gate. """ if not self._enforce_quality_gate: return ref_str = f"{reference.book} {reference.chapter}:{reference.verse}" # Check overall score if evaluation.overall_score < self._quality_threshold: raise QualityThresholdError( f"Translation for {ref_str} failed quality gate: " f"{evaluation.overall_score:.1f}% < {self._quality_threshold}%", score=evaluation.overall_score, threshold=self._quality_threshold, ) # Check theological accuracy specifically if "theological_accuracy" in evaluation.scores: ta_score = evaluation.scores["theological_accuracy"].score if ta_score < THEOLOGICAL_ACCURACY_THRESHOLD: raise QualityThresholdError( f"Translation for {ref_str} failed theological accuracy: " f"{ta_score:.1f}% < {THEOLOGICAL_ACCURACY_THRESHOLD}%", score=ta_score, threshold=THEOLOGICAL_ACCURACY_THRESHOLD, dimension="theological_accuracy", ) def _save_evaluation( self, evaluation: VerseEvaluation, reference: VerseReference, ) -> None: """Save evaluation result if storage is configured. Args: evaluation: The evaluation to save. reference: The verse reference. """ if not self._evaluation_storage: return self._evaluation_storage.save_verse_evaluation( reference.book, reference.chapter, evaluation, ) def translate_verse( self, reference: VerseReference, skip_existing: bool = True, evaluate: bool | None = None, ) -> TranslationOutput: """Translate a single verse. Args: reference: Verse reference to translate. skip_existing: If True, return existing translation if available. evaluate: Override auto_evaluate setting for this call. Returns: TranslationOutput with the translation. """ # Check if already translated if skip_existing and self.storage.is_verse_translated(reference): existing = self.storage.load_translation(reference) if existing: return existing # Build context and prompt context = self.context_builder.build_context(reference) # Get TM matches tm_matches = self._get_tm_matches(context) # Build prompt (with TM matches if available) prompt = self.prompt_builder.build_prompt(context, tm_matches=tm_matches) system_prompt = self.prompt_builder.get_system_prompt() # Call Claude CLI translation_text = _sanitize_translation_output( self._call_claude(system_prompt, prompt) ) # Parse furigana from Claude's response plain_text, annotations = parse_furigana(translation_text) # Create output output = TranslationOutput( reference=reference, text=plain_text, text_with_furigana=translation_text, furigana_annotations=[ { "start_offset": a.start_offset, "end_offset": a.end_offset, "base_text": a.base_text, "reading": a.reading, } for a in annotations ], model=self.model, sources_used=self._get_sources_used(context), glossary_terms_applied=[t.original for t in context.glossary_terms], ) # Save translation self.storage.save_translation(output) # Add to TM self._add_to_tm(context, plain_text) # Evaluate if enabled should_evaluate = evaluate if evaluate is not None else self._auto_evaluate if should_evaluate: evaluation = self._evaluate_translation(output, context) if evaluation: self._save_evaluation(evaluation, reference) # Check quality gate self._check_quality_gate(evaluation, reference) return output def translate_verse_with_provenance( # pragma: no cover self, reference: VerseReference, skip_existing: bool = True, ) -> tuple[TranslationOutput, "TranslationProvenance"]: """Translate a verse and capture full provenance. Args: reference: Verse reference to translate. skip_existing: If True, return existing translation if available. Returns: Tuple of (TranslationOutput, TranslationProvenance). """ from datetime import datetime from src.orchestration.provenance import ( TranslationProvenance, compute_glossary_hash, compute_style_hash, ) # Check if already translated if skip_existing and self.storage.is_verse_translated(reference): existing = self.storage.load_translation(reference) if existing: # Try to load existing provenance provenance = self.storage.load_provenance(reference) if provenance: return existing, provenance # Create minimal provenance for existing translation provenance = TranslationProvenance(model=existing.model) return existing, provenance # Track timing started_at = datetime.utcnow() # Build context context = self.context_builder.build_context(reference) # Get TM matches tm_matches = self._get_tm_matches(context) or [] # Build prompts prompt = self.prompt_builder.build_prompt(context, tm_matches=tm_matches) system_prompt = self.prompt_builder.get_system_prompt() # Build provenance provenance = TranslationProvenance( model=self.model, prompt_version="v1.0", system_prompt=system_prompt, user_prompt=prompt, started_at=started_at, attempt_number=1, ) # Capture source texts verse = context.target_verse if verse.greek_text: provenance.source_texts["greek"] = verse.greek_text if verse.hebrew_text: provenance.source_texts["hebrew"] = verse.hebrew_text if verse.english_kjv: provenance.source_texts["kjv"] = verse.english_kjv if verse.english_asv: provenance.source_texts["asv"] = verse.english_asv if verse.japanese_meiji: provenance.source_texts["meiji"] = verse.japanese_meiji if verse.japanese_taisho: provenance.source_texts["taisho"] = verse.japanese_taisho # Compute source hashes provenance.compute_source_hashes() # Capture context verses provenance.preceding_verses = [ str(v.reference) for v in context.preceding_verses ] provenance.following_verses = [ str(v.reference) for v in context.following_verses ] # Capture chapter summary if context.chapter_summary: provenance.chapter_summary = context.chapter_summary.summary # Capture book metadata if context.book_metadata: provenance.book_metadata = { "name": context.book_metadata.name_english, "testament": context.book_metadata.testament, "themes": context.book_metadata.themes, } # Capture glossary hash provenance.glossary_hash = compute_glossary_hash(context.glossary_terms) # Capture style hash if self.prompt_builder._style: style_dict = self.prompt_builder._style.to_dict() provenance.style_config_hash = compute_style_hash(style_dict) # Capture TM matches if tm_matches: provenance.tm_matches_used = [ { "source": m.segment.source_text, "target": m.segment.target_text, "similarity": m.similarity, } for m in tm_matches ] # Create input proto for archive (before translation) input_proto = None if self._enable_archive: from src.protos.converters import ( translation_context_to_prompt_input, tm_match_to_proto, ) from src.protos.serialization import compute_proto_hash input_proto = translation_context_to_prompt_input( context, system_prompt, prompt, self.model, ) # Add TM matches to input proto if tm_matches and hasattr(tm_matches[0], "segment"): input_proto.tm_matches = [tm_match_to_proto(m) for m in tm_matches] # Call Claude translation_text = self._call_claude(system_prompt, prompt) # Complete timing completed_at = datetime.utcnow() provenance.completed_at = completed_at # Parse furigana from Claude's response plain_text, annotations = parse_furigana(translation_text) # Create output output = TranslationOutput( reference=reference, text=plain_text, text_with_furigana=translation_text, furigana_annotations=[ { "start_offset": a.start_offset, "end_offset": a.end_offset, "base_text": a.base_text, "reading": a.reading, } for a in annotations ], model=self.model, sources_used=list(provenance.source_texts.keys()), glossary_terms_applied=[t.original for t in context.glossary_terms], ) # Save translation with provenance self.storage.save_translation_with_provenance(output, provenance) # Save proto archive if enabled if self._enable_archive and input_proto is not None: from src.protos.converters import ( provenance_to_proto, translation_result_to_proto, ) from src.protos.serialization import compute_proto_hash output_proto = translation_result_to_proto( output, translation_text, annotations, input_hash=compute_proto_hash(input_proto) ) output_proto.provenance = provenance_to_proto(provenance) self.storage.save_translation_archive(reference, input_proto, output_proto) # Add to TM self._add_to_tm(context, plain_text) return output, provenance async def translate_verse_async( self, reference: VerseReference, skip_existing: bool = True ) -> TranslationOutput: """Translate a single verse asynchronously. Args: reference: Verse reference to translate. skip_existing: If True, return existing translation if available. Returns: TranslationOutput with the translation. """ # Check if already translated if skip_existing and self.storage.is_verse_translated(reference): existing = self.storage.load_translation(reference) if existing: return existing # Build context and prompt context = self.context_builder.build_context(reference) # Get TM matches tm_matches = self._get_tm_matches(context) # Build prompt prompt = self.prompt_builder.build_prompt(context, tm_matches=tm_matches) system_prompt = self.prompt_builder.get_system_prompt() # Call Claude CLI translation_text = await self._call_claude_async(system_prompt, prompt) # Parse furigana from Claude's response plain_text, annotations = parse_furigana(translation_text) # Create output output = TranslationOutput( reference=reference, text=plain_text, text_with_furigana=translation_text, furigana_annotations=[ { "start_offset": a.start_offset, "end_offset": a.end_offset, "base_text": a.base_text, "reading": a.reading, } for a in annotations ], model=self.model, sources_used=self._get_sources_used(context), glossary_terms_applied=[t.original for t in context.glossary_terms], ) # Save translation self.storage.save_translation(output) # Add to TM self._add_to_tm(context, plain_text) return output def translate_chapter( self, book: str, chapter: int, skip_existing: bool = True, progress_callback: Callable[..., Any] | None = None, evaluate: bool | None = None, ) -> list[TranslationOutput]: """Translate all verses in a chapter. Args: book: Book name. chapter: Chapter number. skip_existing: If True, skip already translated verses. progress_callback: Optional callback for progress updates. evaluate: Override auto_evaluate setting for this call. Returns: List of TranslationOutput for each verse. """ # Get all verses in the chapter verses = self.context_builder.loader.load_chapter_verses(book, chapter) outputs = [] for i, verse in enumerate(verses): if progress_callback: progress_callback(i + 1, len(verses), verse.reference) output = self.translate_verse( verse.reference, skip_existing, evaluate=evaluate ) outputs.append(output) # Rate limiting if i < len(verses) - 1: time.sleep(self.rate_limit_delay) # Save TM after chapter if self._translation_memory is not None: self._translation_memory.save() return outputs async def translate_chapter_async( self, book: str, chapter: int, skip_existing: bool = True, max_concurrent: int = 5, ) -> list[TranslationOutput]: """Translate all verses in a chapter asynchronously. Args: book: Book name. chapter: Chapter number. skip_existing: If True, skip already translated verses. max_concurrent: Maximum concurrent CLI calls. Returns: List of TranslationOutput for each verse. """ # Get all verses in the chapter verses = self.context_builder.loader.load_chapter_verses(book, chapter) # Create semaphore for rate limiting semaphore = asyncio.Semaphore(max_concurrent) async def translate_with_limit(ref: VerseReference) -> TranslationOutput: async with semaphore: result = await self.translate_verse_async(ref, skip_existing) await asyncio.sleep(self.rate_limit_delay) return result tasks = [translate_with_limit(v.reference) for v in verses] outputs = await asyncio.gather(*tasks) # Save TM after chapter if self._translation_memory is not None: self._translation_memory.save() return list(outputs) def _get_sources_used(self, context: TranslationContext) -> list[str]: """Get list of source text types used.""" sources = [] verse = context.target_verse if verse.greek_text: sources.append("greek") if verse.hebrew_text: sources.append("hebrew") if verse.japanese_meiji: sources.append("meiji") if verse.japanese_taisho: sources.append("taisho") if verse.japanese_kougo: sources.append("kougo") if verse.english_kjv: sources.append("kjv") if verse.english_asv: sources.append("asv") return sources def translate_chunk( self, book: str, pericope: Pericope, skip_existing: bool = True, ) -> ChunkTranslationOutput: """Translate an entire pericope/chunk as a cohesive unit. Args: book: Book name. pericope: The pericope to translate. skip_existing: If True, skip verses already translated. Returns: ChunkTranslationOutput with verse-aligned translations. """ # Build chunk context context = self.context_builder.build_chunk_context(book, pericope) # Check if all verses already translated if skip_existing: existing = self._get_existing_chunk_translations(book, pericope) if len(existing) == len(pericope.verses): return ChunkTranslationOutput( pericope_id=pericope.id, book=book, translations=existing, model=self.model, coherence_note="Loaded from existing translations", ) # Get TM matches for the combined source text tm_matches = self._get_chunk_tm_matches(context) # Seam continuity: surface our own translations of the immediately # preceding verses so the new chunk continues them seamlessly. preceding_translations = [] for v in context.preceding_verses: existing_t = self.storage.load_translation(v.reference) if existing_t and existing_t.text: preceding_translations.append((v.reference, existing_t.text)) # Build chunk prompt prompt = self.prompt_builder.build_chunk_prompt( context, tm_matches=tm_matches, preceding_translations=preceding_translations or None, ) system_prompt = self.prompt_builder.get_chunk_system_prompt() # Call Claude CLI response = self._call_claude(system_prompt, prompt) # Parse JSON response translations = self._parse_chunk_response(response, pericope.verses) # Create output output = ChunkTranslationOutput( pericope_id=pericope.id, book=book, translations=translations, model=self.model, ) # Save individual verse translations self._save_chunk_translations(book, translations) # Add to translation memory self._add_chunk_to_tm(context, translations) return output def translate_chunk_by_id( self, pericope_id: str, skip_existing: bool = True, ) -> ChunkTranslationOutput | None: """Translate a pericope by its ID. Args: pericope_id: The pericope identifier. skip_existing: If True, skip verses already translated. Returns: ChunkTranslationOutput or None if pericope not found. """ result = self.context_builder.pericope_manager.get_pericope_by_id(pericope_id) if result is None: return None book, pericope = result return self.translate_chunk(book, pericope, skip_existing) def translate_chapter_by_pericopes( self, book: str, chapter: int, skip_existing: bool = True, progress_callback: Callable[..., Any] | None = None, ) -> list[ChunkTranslationOutput]: """Translate a chapter using pericope-based chunking. Args: book: Book name. chapter: Chapter number. skip_existing: If True, skip already translated pericopes. progress_callback: Optional callback for progress updates. Returns: List of ChunkTranslationOutput for each pericope. """ pericopes = self.context_builder.pericope_manager.get_chapter_pericopes( book, chapter ) if not pericopes: # Fall back to verse-by-verse translation outputs = self.translate_chapter( book, chapter, skip_existing, progress_callback ) # Convert to chunk format return [ ChunkTranslationOutput( pericope_id=f"{book}-{chapter}-fallback", book=book, translations={ f"{o.reference.chapter}:{o.reference.verse}": o.text for o in outputs }, model=self.model, coherence_note="Verse-by-verse fallback (no pericope data)", ) ] outputs = [] for i, pericope in enumerate(pericopes): if progress_callback: progress_callback(i + 1, len(pericopes), pericope) output = self.translate_chunk(book, pericope, skip_existing) outputs.append(output) # Rate limiting between pericopes if i < len(pericopes) - 1: time.sleep(self.rate_limit_delay) # Save TM after chapter if self._translation_memory is not None: self._translation_memory.save() return outputs def _get_existing_chunk_translations( self, book: str, pericope: Pericope ) -> dict[str, str]: """Get existing translations for verses in a pericope. Args: book: Book name. pericope: The pericope to check. Returns: Dict mapping verse ref to translation text. """ existing = {} for verse_ref in pericope.verses: chapter, verse = map(int, verse_ref.split(":")) ref = VerseReference(book=book, chapter=chapter, verse=verse) if self.storage.is_verse_translated(ref): translation = self.storage.load_translation(ref) if translation: existing[verse_ref] = translation.text return existing def _get_chunk_tm_matches( self, context: ChunkTranslationContext ) -> list | None: """Get TM matches for chunk context. Args: context: Chunk translation context. Returns: List of TM matches or None. """ if self._translation_memory is None: return None # Use combined source text for matching combined = context.get_combined_source_text() if not combined: return None # Get matches for the first verse as representative first_verse = context.verses[0] if context.verses else None if not first_verse: # pragma: no cover - combined text implies a verse return None source = first_verse.greek_text or first_verse.hebrew_text or "" if not source: return None matches = self._translation_memory.find_matches( source, threshold=0.7, max_results=3 ) return matches if matches else None def _parse_chunk_response( self, response: str, expected_verses: list[str] ) -> dict[str, str]: """Parse JSON response from chunk translation. Uses robust JSON extraction that handles: - Markdown code blocks (```json ... ```) - Nested JSON structures - Text before/after the JSON - Unicode characters in values Args: response: Raw response from Claude. expected_verses: List of expected verse refs. Returns: Dict mapping verse ref to translation. Raises: ChunkParsingError: If response cannot be parsed. """ original_response = response response = response.strip() # Strategy 1: Remove markdown code blocks if present if "```" in response: response = self._extract_from_code_block(response) # Strategy 2: Try direct JSON parse first (most common case) translations = self._try_parse_json(response) if translations is not None: return self._validate_translations(translations, expected_verses, original_response) # Strategy 3: Find JSON object using bracket matching json_str = self._extract_json_by_brackets(response) if json_str: translations = self._try_parse_json(json_str) if translations is not None: return self._validate_translations(translations, expected_verses, original_response) # Strategy 4: Try to find JSON anywhere in the response for match in re.finditer(r'\{', response): start = match.start() json_str = self._extract_json_from_position(response, start) if json_str: translations = self._try_parse_json(json_str) if translations is not None: return self._validate_translations(translations, expected_verses, original_response) # All strategies failed raise ChunkParsingError( "Failed to parse chunk translation response as JSON", response=original_response, expected_verses=expected_verses, ) def _extract_from_code_block(self, response: str) -> str: """Extract content from markdown code block. Args: response: Response potentially containing code blocks. Returns: Content inside code block, or original if not found. """ lines = response.split("\n") json_lines = [] in_block = False for line in lines: stripped = line.strip() if stripped.startswith("```") and not in_block: in_block = True continue elif stripped.startswith("```") and in_block: break elif in_block: json_lines.append(line) if json_lines: return "\n".join(json_lines) return response def _try_parse_json(self, text: str) -> dict | None: """Try to parse text as JSON dict. Args: text: Text to parse. Returns: Parsed dict or None if parsing failed. """ try: result = json.loads(text) if isinstance(result, dict): return result except json.JSONDecodeError: pass return None def _extract_json_by_brackets(self, text: str) -> str | None: """Extract JSON object by matching brackets. Handles nested objects correctly. Args: text: Text containing JSON. Returns: Extracted JSON string or None if not found. """ start = text.find("{") if start == -1: return None depth = 0 in_string = False escape_next = False for i, char in enumerate(text[start:], start): if escape_next: escape_next = False continue if char == "\\": escape_next = True continue if char == '"' and not escape_next: in_string = not in_string continue if in_string: continue if char == "{": depth += 1 elif char == "}": depth -= 1 if depth == 0: return text[start : i + 1] return None def _extract_json_from_position(self, text: str, start: int) -> str | None: """Extract JSON object starting at given position. Args: text: Full text. start: Starting position of '{'. Returns: Extracted JSON string or None if invalid. """ if start >= len(text) or text[start] != "{": return None depth = 0 in_string = False escape_next = False for i, char in enumerate(text[start:], start): if escape_next: escape_next = False continue if char == "\\": escape_next = True continue if char == '"' and not escape_next: in_string = not in_string continue if in_string: continue if char == "{": depth += 1 elif char == "}": depth -= 1 if depth == 0: return text[start : i + 1] return None def _validate_translations( self, translations: dict, expected_verses: list[str], original_response: str, ) -> dict[str, str]: """Validate parsed translations have all expected verses. Args: translations: Parsed translation dict. expected_verses: List of expected verse refs. original_response: Original response for error context. Returns: Validated translations dict. Raises: ChunkParsingError: If validation fails. """ if not isinstance(translations, dict): raise ChunkParsingError( f"Expected dict, got {type(translations).__name__}", response=original_response, expected_verses=expected_verses, ) # Validate all expected verses are present missing = set(expected_verses) - set(translations.keys()) if missing: raise ChunkParsingError( f"Missing translations for verses: {sorted(missing)}", response=original_response, expected_verses=expected_verses, ) # Keep only the verses this pericope asked for. The prompt shows the # preceding and following verses with their references, and a model # that "helpfully" translates one of them would otherwise overwrite a # verse in the neighbouring pericope; a stray non-verse key ("note") # would crash the save loop after half the chunk was written. extra = set(translations) - set(expected_verses) if extra: logger.warning( "Discarding %d unrequested key(s) from chunk response: %s", len(extra), sorted(map(str, extra))[:5], ) # Validate all values are strings for ref in expected_verses: text = translations[ref] if not isinstance(text, str): raise ChunkParsingError( f"Translation for {ref} is not a string: {type(text).__name__}", response=original_response, expected_verses=expected_verses, ) return {ref: translations[ref] for ref in expected_verses} def _save_chunk_translations( self, book: str, translations: dict[str, str] ) -> None: """Save individual verse translations from chunk output. Args: book: Book name. translations: Dict mapping verse ref to translation. """ for verse_ref, text in translations.items(): chapter, verse = map(int, verse_ref.split(":")) ref = VerseReference(book=book, chapter=chapter, verse=verse) # Parse furigana from translation text plain_text, annotations = parse_furigana(text) output = TranslationOutput( reference=ref, text=plain_text, text_with_furigana=text, furigana_annotations=[ { "start_offset": a.start_offset, "end_offset": a.end_offset, "base_text": a.base_text, "reading": a.reading, } for a in annotations ], model=self.model, sources_used=["chunk_translation"], ) self.storage.save_translation(output) def _add_chunk_to_tm( self, context: ChunkTranslationContext, translations: dict[str, str], ) -> None: """Add chunk translations to translation memory. Args: context: Chunk translation context. translations: Dict mapping verse ref to translation. """ if self._translation_memory is None: return for verse in context.verses: ref = f"{verse.reference.chapter}:{verse.reference.verse}" source = verse.greek_text or verse.hebrew_text target = translations.get(ref) if source and target: self._translation_memory.add_from_translation( source_text=source, target_text=target, reference=verse.reference, metadata={"model": self.model, "pericope_id": context.pericope.id}, )