OPEN SOURCE
software/src/pipeline/translator.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"""Translator module using Claude CLI."""23from __future__ import annotations45import asyncio6import json7import logging8import os9import re10import subprocess11import time12from collections.abc import Callable13from typing import TYPE_CHECKING, Any1415from src.context.builder import ContextBuilder16from src.data.storage import Storage17from src.exceptions import (18ChunkParsingError,19ClaudeAPIError,20EvaluationError,21QualityThresholdError,22RateLimitError,23classify_claude_error,24)25from src.models.pericope import ChunkTranslationContext, ChunkTranslationOutput, Pericope26from src.models.verse import TranslationOutput, VerseReference27from src.pipeline.prompt import PromptBuilder28from src.pipeline.request_archive import RequestArchive29from src.utils.furigana import parse_furigana30from src.utils.retry import RetryConfig, RetryState3132if TYPE_CHECKING:33from src.context.translation_memory import TranslationMemoryManager34from src.evaluation.evaluator import TranslationEvaluator35from src.evaluation.storage import EvaluationStorage36from src.models.evaluation import VerseEvaluation37from src.models.verse import TranslationContext38from src.orchestration.provenance import TranslationProvenance3940logger = logging.getLogger(__name__)4142# Find Claude binary43CLAUDE_BINARY = os.environ.get("CLAUDE_BINARY", "claude")444546def _clean_env() -> dict[str, str]:47"""Return a copy of the environment without CLAUDECODE to avoid nested session errors."""48env = os.environ.copy()49env.pop("CLAUDECODE", None)50return env5152DEFAULT_MODEL = os.environ.get("TRANSLATE_BIBLE_MODEL", "claude-opus-4-8")535455def _sanitize_translation_output(raw: str) -> str:56"""Strip meta-commentary from a verse translation response.5758Models occasionally emit self-corrections ("Wait—let me provide the59correct translation.") or markdown fences around the answer. A verse60translation is a single Japanese block, so: drop code fences, drop61predominantly-Latin blocks, and if several Japanese blocks remain62(a self-correction), keep the last one.63"""64text = raw.strip()65if not text:66return text6768lines = [ln for ln in text.split("\n") if not ln.strip().startswith("```")]6970blocks: list[list[str]] = [[]]71for ln in lines:72if ln.strip():73blocks[-1].append(ln)74elif blocks[-1]:75blocks.append([])76block_texts = ["\n".join(b).strip() for b in blocks if b]7778def is_japanese(block: str) -> bool:79cjk = sum(1 for ch in block if "" <= ch <= "鿿" or "!" <= ch <= "⦆")80latin = sum(1 for ch in block if ch.isascii() and ch.isalpha())81return cjk > latin8283japanese_blocks = [b for b in block_texts if is_japanese(b)]84if not japanese_blocks:85return text8687result = japanese_blocks[-1]88result = re.sub(r"^(翻訳|訳文|Translation)\s*[::]\s*", "", result)89if result != text:90logger.warning("Sanitized translation output (removed meta-commentary)")91return result9293# Default retry configuration94DEFAULT_RETRY_CONFIG = RetryConfig(95max_attempts=3,96base_delay=2.0,97max_delay=60.0,98exponential_base=2.0,99retryable_exceptions=(RateLimitError, ConnectionError, TimeoutError),100)101102# Quality thresholds for evaluation gating103DEFAULT_QUALITY_THRESHOLD = 70.0104THEOLOGICAL_ACCURACY_THRESHOLD = 75.0105106107class Translator:108"""Translates Bible verses using Claude CLI."""109110def __init__(111self,112context_builder: ContextBuilder,113prompt_builder: PromptBuilder,114storage: Storage,115model: str = DEFAULT_MODEL,116rate_limit_delay: float = 1.2,117translation_memory: TranslationMemoryManager | None = None,118auto_evaluate: bool = True,119evaluator: TranslationEvaluator | None = None,120evaluation_storage: EvaluationStorage | None = None,121retry_config: RetryConfig | None = None,122quality_threshold: float = DEFAULT_QUALITY_THRESHOLD,123enforce_quality_gate: bool = False,124subprocess_timeout: float | None = None,125enable_proto_archive: bool = False,126) -> None:127"""Initialize the translator.128129Args:130context_builder: Context builder for creating translation context.131prompt_builder: Prompt builder for formatting prompts.132storage: Storage for saving translations.133model: Claude model to use.134rate_limit_delay: Delay between API calls in seconds.135translation_memory: Optional TM manager for reuse.136auto_evaluate: Whether to automatically evaluate translations.137evaluator: Evaluator to use for auto-evaluation.138evaluation_storage: Storage for evaluation results.139retry_config: Configuration for retry with backoff.140quality_threshold: Minimum score for evaluation gating.141enforce_quality_gate: If True, reject low-quality translations.142subprocess_timeout: Timeout for Claude CLI calls in seconds.143Defaults to the TRANSLATE_BIBLE_TIMEOUT env var, or 120.144Long pericope chunks (10+ verses) can need 300s+.145enable_proto_archive: Whether to archive translation inputs/outputs.146"""147self.context_builder = context_builder148self.prompt_builder = prompt_builder149self.storage = storage150self.model = model151self.request_archive = RequestArchive(storage.data_dir / "request-archive")152self.rate_limit_delay = rate_limit_delay153self._translation_memory = translation_memory154self._auto_evaluate = auto_evaluate155self._evaluator = evaluator156self._evaluation_storage = evaluation_storage157self._retry_config = retry_config or DEFAULT_RETRY_CONFIG158self._quality_threshold = quality_threshold159self._enforce_quality_gate = enforce_quality_gate160if subprocess_timeout is None:161subprocess_timeout = float(os.environ.get("TRANSLATE_BIBLE_TIMEOUT", "120"))162self._subprocess_timeout = subprocess_timeout163self._enable_archive = enable_proto_archive164# Enable archive on storage if requested165if enable_proto_archive:166self.storage.enable_archive = True167168@property169def enable_proto_archive(self) -> bool:170"""Get proto archive enabled status."""171return self._enable_archive172173@enable_proto_archive.setter174def enable_proto_archive(self, value: bool) -> None:175"""Set proto archive enabled status."""176self._enable_archive = value177self.storage.enable_archive = value178179@property180def translation_memory(self) -> TranslationMemoryManager | None:181"""Get the translation memory manager."""182return self._translation_memory183184@translation_memory.setter185def translation_memory(self, value: TranslationMemoryManager | None) -> None:186"""Set the translation memory manager."""187self._translation_memory = value188189@property190def auto_evaluate(self) -> bool:191"""Get auto-evaluation setting."""192return self._auto_evaluate193194@auto_evaluate.setter195def auto_evaluate(self, value: bool) -> None:196"""Set auto-evaluation setting."""197self._auto_evaluate = value198199def _call_claude(self, system_prompt: str, user_prompt: str) -> str:200"""Call Claude CLI with the given prompts.201202Includes retry logic with exponential backoff for transient errors.203204Args:205system_prompt: System prompt for Claude.206user_prompt: User prompt for Claude.207208Returns:209The response text from Claude.210211Raises:212ClaudeAPIError: If the Claude CLI call fails after all retries.213"""214retry_state = RetryState(self._retry_config)215216while True:217request_id = self.request_archive.start(self.model, system_prompt, user_prompt)218try:219result = subprocess.run(220[221CLAUDE_BINARY,222"--print",223"-p",224user_prompt,225"--system-prompt",226system_prompt,227"--model",228self.model,229],230capture_output=True,231text=True,232check=False,233timeout=self._subprocess_timeout,234env=_clean_env(),235)236237self.request_archive.finish(request_id, result.stdout, result.stderr, result.returncode)238if result.returncode != 0:239error = classify_claude_error(result.stderr, result.returncode)240raise error241242return result.stdout.strip()243244except subprocess.TimeoutExpired as e:245self.request_archive.finish(request_id, error="timeout")246error = ClaudeAPIError(247f"Claude CLI timed out after {self._subprocess_timeout}s",248stderr=str(e),249return_code=-1,250)251if retry_state.should_retry(error):252logger.warning(253"Claude CLI timeout, attempt %d/%d. Retrying...",254retry_state.attempt + 1,255self._retry_config.max_attempts,256)257retry_state.wait()258else:259raise error from e260261except FileNotFoundError as e:262self.request_archive.finish(request_id, error="cli_not_found")263# Claude CLI not installed264from src.exceptions import CLINotFoundError265raise CLINotFoundError() from e266267except ClaudeAPIError as e:268if retry_state.should_retry(e):269logger.warning(270"Claude API error, attempt %d/%d: %s. Retrying...",271retry_state.attempt + 1,272self._retry_config.max_attempts,273str(e),274)275retry_state.wait()276else:277raise278279async def _call_claude_async(self, system_prompt: str, user_prompt: str) -> str:280"""Call Claude CLI asynchronously with the given prompts.281282Includes retry logic with exponential backoff for transient errors.283284Args:285system_prompt: System prompt for Claude.286user_prompt: User prompt for Claude.287288Returns:289The response text from Claude.290291Raises:292ClaudeAPIError: If the Claude CLI call fails after all retries.293"""294retry_state = RetryState(self._retry_config)295296while True:297request_id = self.request_archive.start(self.model, system_prompt, user_prompt)298process = None299try:300process = await asyncio.wait_for(301asyncio.create_subprocess_exec(302CLAUDE_BINARY,303"--print",304"-p",305user_prompt,306"--system-prompt",307system_prompt,308"--model",309self.model,310stdout=asyncio.subprocess.PIPE,311stderr=asyncio.subprocess.PIPE,312env=_clean_env(),313),314timeout=self._subprocess_timeout,315)316stdout, stderr = await asyncio.wait_for(317process.communicate(),318timeout=self._subprocess_timeout,319)320321self.request_archive.finish(request_id, stdout.decode(), stderr.decode(), process.returncode)322if process.returncode != 0:323error = classify_claude_error(stderr.decode(), process.returncode or 1)324raise error325326return stdout.decode().strip()327328except asyncio.TimeoutError as e:329if process is not None and process.returncode is None:330process.kill()331await process.wait()332self.request_archive.finish(request_id, error="timeout")333error = ClaudeAPIError(334f"Claude CLI timed out after {self._subprocess_timeout}s",335stderr="Async timeout",336return_code=-1,337)338if retry_state.should_retry(error):339logger.warning(340"Claude CLI async timeout, attempt %d/%d. Retrying...",341retry_state.attempt + 1,342self._retry_config.max_attempts,343)344await retry_state.async_wait()345else:346raise error from e347348except FileNotFoundError as e:349self.request_archive.finish(request_id, error="cli_not_found")350from src.exceptions import CLINotFoundError351raise CLINotFoundError() from e352353except ClaudeAPIError as e:354if retry_state.should_retry(e):355logger.warning(356"Claude API error (async), attempt %d/%d: %s. Retrying...",357retry_state.attempt + 1,358self._retry_config.max_attempts,359str(e),360)361await retry_state.async_wait()362else:363raise364365def _get_tm_matches(self, context: TranslationContext) -> list | None:366"""Get TM matches for the context if TM is enabled.367368Args:369context: Translation context.370371Returns:372List of TM matches or None if TM not available.373"""374if self._translation_memory is None:375return None376377# Get source text for matching378source_text = (379context.target_verse.greek_text380or context.target_verse.hebrew_text381or ""382)383if not source_text:384return None385386matches = self._translation_memory.find_matches(387source_text, threshold=0.7, max_results=3388)389return matches if matches else None390391def _add_to_tm(392self, context: TranslationContext, translation_text: str393) -> None:394"""Add completed translation to translation memory.395396Args:397context: Translation context.398translation_text: The translated text.399"""400if self._translation_memory is None:401return402403source_text = (404context.target_verse.greek_text405or context.target_verse.hebrew_text406)407if not source_text:408return409410self._translation_memory.add_from_translation(411source_text=source_text,412target_text=translation_text,413reference=context.target_verse.reference,414metadata={"model": self.model},415)416417def _evaluate_translation(418self,419output: TranslationOutput,420context: TranslationContext,421) -> VerseEvaluation | None:422"""Evaluate a translation if auto-evaluate is enabled.423424Args:425output: The translation output.426context: The translation context.427428Returns:429VerseEvaluation or None if evaluation failed/disabled.430431Raises:432EvaluationError: If enforce_quality_gate is True and evaluation fails.433"""434if not self._auto_evaluate or not self._evaluator:435return None436437ref = output.reference438ref_str = f"{ref.book} {ref.chapter}:{ref.verse}"439440try:441from src.evaluation.evaluator import (442EVALUATION_SYSTEM_PROMPT,443parse_evaluation_response,444)445446# Build evaluation prompt447eval_prompt = self._evaluator.build_verse_prompt(448output.text, context449)450451# Call Claude for evaluation452response = self._call_claude(EVALUATION_SYSTEM_PROMPT, eval_prompt)453454# Parse response455scores = parse_evaluation_response(response)456457# Create evaluation458evaluation = self._evaluator.create_verse_evaluation(459verse_number=output.reference.verse,460scores=scores,461)462463# Log evaluation result464logger.info(465"Evaluated %s: overall=%.1f%%, flags=%s",466ref_str,467evaluation.overall_score,468evaluation.flags or "none",469)470471return evaluation472473except Exception as e:474# Log the error with details475logger.error(476"Evaluation failed for %s: %s",477ref_str,478str(e),479)480481# If quality gate is enforced, raise the error482if self._enforce_quality_gate:483raise EvaluationError(484f"Evaluation failed for {ref_str}: {e}",485verse_ref=ref_str,486) from e487488# Otherwise, return None (don't block translation)489return None490491def _check_quality_gate(492self,493evaluation: VerseEvaluation,494reference: VerseReference,495) -> None:496"""Check if translation passes quality threshold.497498Args:499evaluation: The verse evaluation.500reference: The verse reference.501502Raises:503QualityThresholdError: If translation fails quality gate.504"""505if not self._enforce_quality_gate:506return507508ref_str = f"{reference.book} {reference.chapter}:{reference.verse}"509510# Check overall score511if evaluation.overall_score < self._quality_threshold:512raise QualityThresholdError(513f"Translation for {ref_str} failed quality gate: "514f"{evaluation.overall_score:.1f}% < {self._quality_threshold}%",515score=evaluation.overall_score,516threshold=self._quality_threshold,517)518519# Check theological accuracy specifically520if "theological_accuracy" in evaluation.scores:521ta_score = evaluation.scores["theological_accuracy"].score522if ta_score < THEOLOGICAL_ACCURACY_THRESHOLD:523raise QualityThresholdError(524f"Translation for {ref_str} failed theological accuracy: "525f"{ta_score:.1f}% < {THEOLOGICAL_ACCURACY_THRESHOLD}%",526score=ta_score,527threshold=THEOLOGICAL_ACCURACY_THRESHOLD,528dimension="theological_accuracy",529)530531def _save_evaluation(532self,533evaluation: VerseEvaluation,534reference: VerseReference,535) -> None:536"""Save evaluation result if storage is configured.537538Args:539evaluation: The evaluation to save.540reference: The verse reference.541"""542if not self._evaluation_storage:543return544545self._evaluation_storage.save_verse_evaluation(546reference.book,547reference.chapter,548evaluation,549)550551def translate_verse(552self,553reference: VerseReference,554skip_existing: bool = True,555evaluate: bool | None = None,556) -> TranslationOutput:557"""Translate a single verse.558559Args:560reference: Verse reference to translate.561skip_existing: If True, return existing translation if available.562evaluate: Override auto_evaluate setting for this call.563564Returns:565TranslationOutput with the translation.566"""567# Check if already translated568if skip_existing and self.storage.is_verse_translated(reference):569existing = self.storage.load_translation(reference)570if existing:571return existing572573# Build context and prompt574context = self.context_builder.build_context(reference)575576# Get TM matches577tm_matches = self._get_tm_matches(context)578579# Build prompt (with TM matches if available)580prompt = self.prompt_builder.build_prompt(context, tm_matches=tm_matches)581system_prompt = self.prompt_builder.get_system_prompt()582583# Call Claude CLI584translation_text = _sanitize_translation_output(585self._call_claude(system_prompt, prompt)586)587588# Parse furigana from Claude's response589plain_text, annotations = parse_furigana(translation_text)590591# Create output592output = TranslationOutput(593reference=reference,594text=plain_text,595text_with_furigana=translation_text,596furigana_annotations=[597{598"start_offset": a.start_offset,599"end_offset": a.end_offset,600"base_text": a.base_text,601"reading": a.reading,602}603for a in annotations604],605model=self.model,606sources_used=self._get_sources_used(context),607glossary_terms_applied=[t.original for t in context.glossary_terms],608)609610# Save translation611self.storage.save_translation(output)612613# Add to TM614self._add_to_tm(context, plain_text)615616# Evaluate if enabled617should_evaluate = evaluate if evaluate is not None else self._auto_evaluate618if should_evaluate:619evaluation = self._evaluate_translation(output, context)620if evaluation:621self._save_evaluation(evaluation, reference)622# Check quality gate623self._check_quality_gate(evaluation, reference)624625return output626627def translate_verse_with_provenance( # pragma: no cover628self,629reference: VerseReference,630skip_existing: bool = True,631) -> tuple[TranslationOutput, "TranslationProvenance"]:632"""Translate a verse and capture full provenance.633634Args:635reference: Verse reference to translate.636skip_existing: If True, return existing translation if available.637638Returns:639Tuple of (TranslationOutput, TranslationProvenance).640"""641from datetime import datetime642643from src.orchestration.provenance import (644TranslationProvenance,645compute_glossary_hash,646compute_style_hash,647)648649# Check if already translated650if skip_existing and self.storage.is_verse_translated(reference):651existing = self.storage.load_translation(reference)652if existing:653# Try to load existing provenance654provenance = self.storage.load_provenance(reference)655if provenance:656return existing, provenance657# Create minimal provenance for existing translation658provenance = TranslationProvenance(model=existing.model)659return existing, provenance660661# Track timing662started_at = datetime.utcnow()663664# Build context665context = self.context_builder.build_context(reference)666667# Get TM matches668tm_matches = self._get_tm_matches(context) or []669670# Build prompts671prompt = self.prompt_builder.build_prompt(context, tm_matches=tm_matches)672system_prompt = self.prompt_builder.get_system_prompt()673674# Build provenance675provenance = TranslationProvenance(676model=self.model,677prompt_version="v1.0",678system_prompt=system_prompt,679user_prompt=prompt,680started_at=started_at,681attempt_number=1,682)683684# Capture source texts685verse = context.target_verse686if verse.greek_text:687provenance.source_texts["greek"] = verse.greek_text688if verse.hebrew_text:689provenance.source_texts["hebrew"] = verse.hebrew_text690if verse.english_kjv:691provenance.source_texts["kjv"] = verse.english_kjv692if verse.english_asv:693provenance.source_texts["asv"] = verse.english_asv694if verse.japanese_meiji:695provenance.source_texts["meiji"] = verse.japanese_meiji696if verse.japanese_taisho:697provenance.source_texts["taisho"] = verse.japanese_taisho698699# Compute source hashes700provenance.compute_source_hashes()701702# Capture context verses703provenance.preceding_verses = [704str(v.reference) for v in context.preceding_verses705]706provenance.following_verses = [707str(v.reference) for v in context.following_verses708]709710# Capture chapter summary711if context.chapter_summary:712provenance.chapter_summary = context.chapter_summary.summary713714# Capture book metadata715if context.book_metadata:716provenance.book_metadata = {717"name": context.book_metadata.name_english,718"testament": context.book_metadata.testament,719"themes": context.book_metadata.themes,720}721722# Capture glossary hash723provenance.glossary_hash = compute_glossary_hash(context.glossary_terms)724725# Capture style hash726if self.prompt_builder._style:727style_dict = self.prompt_builder._style.to_dict()728provenance.style_config_hash = compute_style_hash(style_dict)729730# Capture TM matches731if tm_matches:732provenance.tm_matches_used = [733{734"source": m.segment.source_text,735"target": m.segment.target_text,736"similarity": m.similarity,737}738for m in tm_matches739]740741# Create input proto for archive (before translation)742input_proto = None743if self._enable_archive:744from src.protos.converters import (745translation_context_to_prompt_input,746tm_match_to_proto,747)748from src.protos.serialization import compute_proto_hash749750input_proto = translation_context_to_prompt_input(751context, system_prompt, prompt, self.model,752)753# Add TM matches to input proto754if tm_matches and hasattr(tm_matches[0], "segment"):755input_proto.tm_matches = [tm_match_to_proto(m) for m in tm_matches]756757# Call Claude758translation_text = self._call_claude(system_prompt, prompt)759760# Complete timing761completed_at = datetime.utcnow()762provenance.completed_at = completed_at763764# Parse furigana from Claude's response765plain_text, annotations = parse_furigana(translation_text)766767# Create output768output = TranslationOutput(769reference=reference,770text=plain_text,771text_with_furigana=translation_text,772furigana_annotations=[773{774"start_offset": a.start_offset,775"end_offset": a.end_offset,776"base_text": a.base_text,777"reading": a.reading,778}779for a in annotations780],781model=self.model,782sources_used=list(provenance.source_texts.keys()),783glossary_terms_applied=[t.original for t in context.glossary_terms],784)785786# Save translation with provenance787self.storage.save_translation_with_provenance(output, provenance)788789# Save proto archive if enabled790if self._enable_archive and input_proto is not None:791from src.protos.converters import (792provenance_to_proto,793translation_result_to_proto,794)795from src.protos.serialization import compute_proto_hash796797output_proto = translation_result_to_proto(798output, translation_text, annotations,799input_hash=compute_proto_hash(input_proto)800)801output_proto.provenance = provenance_to_proto(provenance)802self.storage.save_translation_archive(reference, input_proto, output_proto)803804# Add to TM805self._add_to_tm(context, plain_text)806807return output, provenance808809async def translate_verse_async(810self, reference: VerseReference, skip_existing: bool = True811) -> TranslationOutput:812"""Translate a single verse asynchronously.813814Args:815reference: Verse reference to translate.816skip_existing: If True, return existing translation if available.817818Returns:819TranslationOutput with the translation.820"""821# Check if already translated822if skip_existing and self.storage.is_verse_translated(reference):823existing = self.storage.load_translation(reference)824if existing:825return existing826827# Build context and prompt828context = self.context_builder.build_context(reference)829830# Get TM matches831tm_matches = self._get_tm_matches(context)832833# Build prompt834prompt = self.prompt_builder.build_prompt(context, tm_matches=tm_matches)835system_prompt = self.prompt_builder.get_system_prompt()836837# Call Claude CLI838translation_text = await self._call_claude_async(system_prompt, prompt)839840# Parse furigana from Claude's response841plain_text, annotations = parse_furigana(translation_text)842843# Create output844output = TranslationOutput(845reference=reference,846text=plain_text,847text_with_furigana=translation_text,848furigana_annotations=[849{850"start_offset": a.start_offset,851"end_offset": a.end_offset,852"base_text": a.base_text,853"reading": a.reading,854}855for a in annotations856],857model=self.model,858sources_used=self._get_sources_used(context),859glossary_terms_applied=[t.original for t in context.glossary_terms],860)861862# Save translation863self.storage.save_translation(output)864865# Add to TM866self._add_to_tm(context, plain_text)867868return output869870def translate_chapter(871self,872book: str,873chapter: int,874skip_existing: bool = True,875progress_callback: Callable[..., Any] | None = None,876evaluate: bool | None = None,877) -> list[TranslationOutput]:878"""Translate all verses in a chapter.879880Args:881book: Book name.882chapter: Chapter number.883skip_existing: If True, skip already translated verses.884progress_callback: Optional callback for progress updates.885evaluate: Override auto_evaluate setting for this call.886887Returns:888List of TranslationOutput for each verse.889"""890# Get all verses in the chapter891verses = self.context_builder.loader.load_chapter_verses(book, chapter)892outputs = []893894for i, verse in enumerate(verses):895if progress_callback:896progress_callback(i + 1, len(verses), verse.reference)897898output = self.translate_verse(899verse.reference, skip_existing, evaluate=evaluate900)901outputs.append(output)902903# Rate limiting904if i < len(verses) - 1:905time.sleep(self.rate_limit_delay)906907# Save TM after chapter908if self._translation_memory is not None:909self._translation_memory.save()910911return outputs912913async def translate_chapter_async(914self,915book: str,916chapter: int,917skip_existing: bool = True,918max_concurrent: int = 5,919) -> list[TranslationOutput]:920"""Translate all verses in a chapter asynchronously.921922Args:923book: Book name.924chapter: Chapter number.925skip_existing: If True, skip already translated verses.926max_concurrent: Maximum concurrent CLI calls.927928Returns:929List of TranslationOutput for each verse.930"""931# Get all verses in the chapter932verses = self.context_builder.loader.load_chapter_verses(book, chapter)933934# Create semaphore for rate limiting935semaphore = asyncio.Semaphore(max_concurrent)936937async def translate_with_limit(ref: VerseReference) -> TranslationOutput:938async with semaphore:939result = await self.translate_verse_async(ref, skip_existing)940await asyncio.sleep(self.rate_limit_delay)941return result942943tasks = [translate_with_limit(v.reference) for v in verses]944outputs = await asyncio.gather(*tasks)945946# Save TM after chapter947if self._translation_memory is not None:948self._translation_memory.save()949950return list(outputs)951952def _get_sources_used(self, context: TranslationContext) -> list[str]:953"""Get list of source text types used."""954sources = []955verse = context.target_verse956957if verse.greek_text:958sources.append("greek")959if verse.hebrew_text:960sources.append("hebrew")961if verse.japanese_meiji:962sources.append("meiji")963if verse.japanese_taisho:964sources.append("taisho")965if verse.japanese_kougo:966sources.append("kougo")967if verse.english_kjv:968sources.append("kjv")969if verse.english_asv:970sources.append("asv")971972return sources973974def translate_chunk(975self,976book: str,977pericope: Pericope,978skip_existing: bool = True,979) -> ChunkTranslationOutput:980"""Translate an entire pericope/chunk as a cohesive unit.981982Args:983book: Book name.984pericope: The pericope to translate.985skip_existing: If True, skip verses already translated.986987Returns:988ChunkTranslationOutput with verse-aligned translations.989"""990# Build chunk context991context = self.context_builder.build_chunk_context(book, pericope)992993# Check if all verses already translated994if skip_existing:995existing = self._get_existing_chunk_translations(book, pericope)996if len(existing) == len(pericope.verses):997return ChunkTranslationOutput(998pericope_id=pericope.id,999book=book,1000translations=existing,1001model=self.model,1002coherence_note="Loaded from existing translations",1003)10041005# Get TM matches for the combined source text1006tm_matches = self._get_chunk_tm_matches(context)10071008# Seam continuity: surface our own translations of the immediately1009# preceding verses so the new chunk continues them seamlessly.1010preceding_translations = []1011for v in context.preceding_verses:1012existing_t = self.storage.load_translation(v.reference)1013if existing_t and existing_t.text:1014preceding_translations.append((v.reference, existing_t.text))10151016# Build chunk prompt1017prompt = self.prompt_builder.build_chunk_prompt(1018context,1019tm_matches=tm_matches,1020preceding_translations=preceding_translations or None,1021)1022system_prompt = self.prompt_builder.get_chunk_system_prompt()10231024# Call Claude CLI1025response = self._call_claude(system_prompt, prompt)10261027# Parse JSON response1028translations = self._parse_chunk_response(response, pericope.verses)10291030# Create output1031output = ChunkTranslationOutput(1032pericope_id=pericope.id,1033book=book,1034translations=translations,1035model=self.model,1036)10371038# Save individual verse translations1039self._save_chunk_translations(book, translations)10401041# Add to translation memory1042self._add_chunk_to_tm(context, translations)10431044return output10451046def translate_chunk_by_id(1047self,1048pericope_id: str,1049skip_existing: bool = True,1050) -> ChunkTranslationOutput | None:1051"""Translate a pericope by its ID.10521053Args:1054pericope_id: The pericope identifier.1055skip_existing: If True, skip verses already translated.10561057Returns:1058ChunkTranslationOutput or None if pericope not found.1059"""1060result = self.context_builder.pericope_manager.get_pericope_by_id(pericope_id)1061if result is None:1062return None10631064book, pericope = result1065return self.translate_chunk(book, pericope, skip_existing)10661067def translate_chapter_by_pericopes(1068self,1069book: str,1070chapter: int,1071skip_existing: bool = True,1072progress_callback: Callable[..., Any] | None = None,1073) -> list[ChunkTranslationOutput]:1074"""Translate a chapter using pericope-based chunking.10751076Args:1077book: Book name.1078chapter: Chapter number.1079skip_existing: If True, skip already translated pericopes.1080progress_callback: Optional callback for progress updates.10811082Returns:1083List of ChunkTranslationOutput for each pericope.1084"""1085pericopes = self.context_builder.pericope_manager.get_chapter_pericopes(1086book, chapter1087)10881089if not pericopes:1090# Fall back to verse-by-verse translation1091outputs = self.translate_chapter(1092book, chapter, skip_existing, progress_callback1093)1094# Convert to chunk format1095return [1096ChunkTranslationOutput(1097pericope_id=f"{book}-{chapter}-fallback",1098book=book,1099translations={1100f"{o.reference.chapter}:{o.reference.verse}": o.text1101for o in outputs1102},1103model=self.model,1104coherence_note="Verse-by-verse fallback (no pericope data)",1105)1106]11071108outputs = []1109for i, pericope in enumerate(pericopes):1110if progress_callback:1111progress_callback(i + 1, len(pericopes), pericope)11121113output = self.translate_chunk(book, pericope, skip_existing)1114outputs.append(output)11151116# Rate limiting between pericopes1117if i < len(pericopes) - 1:1118time.sleep(self.rate_limit_delay)11191120# Save TM after chapter1121if self._translation_memory is not None:1122self._translation_memory.save()11231124return outputs11251126def _get_existing_chunk_translations(1127self, book: str, pericope: Pericope1128) -> dict[str, str]:1129"""Get existing translations for verses in a pericope.11301131Args:1132book: Book name.1133pericope: The pericope to check.11341135Returns:1136Dict mapping verse ref to translation text.1137"""1138existing = {}1139for verse_ref in pericope.verses:1140chapter, verse = map(int, verse_ref.split(":"))1141ref = VerseReference(book=book, chapter=chapter, verse=verse)1142if self.storage.is_verse_translated(ref):1143translation = self.storage.load_translation(ref)1144if translation:1145existing[verse_ref] = translation.text1146return existing11471148def _get_chunk_tm_matches(1149self, context: ChunkTranslationContext1150) -> list | None:1151"""Get TM matches for chunk context.11521153Args:1154context: Chunk translation context.11551156Returns:1157List of TM matches or None.1158"""1159if self._translation_memory is None:1160return None11611162# Use combined source text for matching1163combined = context.get_combined_source_text()1164if not combined:1165return None11661167# Get matches for the first verse as representative1168first_verse = context.verses[0] if context.verses else None1169if not first_verse: # pragma: no cover - combined text implies a verse1170return None11711172source = first_verse.greek_text or first_verse.hebrew_text or ""1173if not source:1174return None11751176matches = self._translation_memory.find_matches(1177source, threshold=0.7, max_results=31178)1179return matches if matches else None11801181def _parse_chunk_response(1182self, response: str, expected_verses: list[str]1183) -> dict[str, str]:1184"""Parse JSON response from chunk translation.11851186Uses robust JSON extraction that handles:1187- Markdown code blocks (```json ... ```)1188- Nested JSON structures1189- Text before/after the JSON1190- Unicode characters in values11911192Args:1193response: Raw response from Claude.1194expected_verses: List of expected verse refs.11951196Returns:1197Dict mapping verse ref to translation.11981199Raises:1200ChunkParsingError: If response cannot be parsed.1201"""1202original_response = response1203response = response.strip()12041205# Strategy 1: Remove markdown code blocks if present1206if "```" in response:1207response = self._extract_from_code_block(response)12081209# Strategy 2: Try direct JSON parse first (most common case)1210translations = self._try_parse_json(response)1211if translations is not None:1212return self._validate_translations(translations, expected_verses, original_response)12131214# Strategy 3: Find JSON object using bracket matching1215json_str = self._extract_json_by_brackets(response)1216if json_str:1217translations = self._try_parse_json(json_str)1218if translations is not None:1219return self._validate_translations(translations, expected_verses, original_response)12201221# Strategy 4: Try to find JSON anywhere in the response1222for match in re.finditer(r'\{', response):1223start = match.start()1224json_str = self._extract_json_from_position(response, start)1225if json_str:1226translations = self._try_parse_json(json_str)1227if translations is not None:1228return self._validate_translations(translations, expected_verses, original_response)12291230# All strategies failed1231raise ChunkParsingError(1232"Failed to parse chunk translation response as JSON",1233response=original_response,1234expected_verses=expected_verses,1235)12361237def _extract_from_code_block(self, response: str) -> str:1238"""Extract content from markdown code block.12391240Args:1241response: Response potentially containing code blocks.12421243Returns:1244Content inside code block, or original if not found.1245"""1246lines = response.split("\n")1247json_lines = []1248in_block = False12491250for line in lines:1251stripped = line.strip()1252if stripped.startswith("```") and not in_block:1253in_block = True1254continue1255elif stripped.startswith("```") and in_block:1256break1257elif in_block:1258json_lines.append(line)12591260if json_lines:1261return "\n".join(json_lines)1262return response12631264def _try_parse_json(self, text: str) -> dict | None:1265"""Try to parse text as JSON dict.12661267Args:1268text: Text to parse.12691270Returns:1271Parsed dict or None if parsing failed.1272"""1273try:1274result = json.loads(text)1275if isinstance(result, dict):1276return result1277except json.JSONDecodeError:1278pass1279return None12801281def _extract_json_by_brackets(self, text: str) -> str | None:1282"""Extract JSON object by matching brackets.12831284Handles nested objects correctly.12851286Args:1287text: Text containing JSON.12881289Returns:1290Extracted JSON string or None if not found.1291"""1292start = text.find("{")1293if start == -1:1294return None12951296depth = 01297in_string = False1298escape_next = False12991300for i, char in enumerate(text[start:], start):1301if escape_next:1302escape_next = False1303continue13041305if char == "\\":1306escape_next = True1307continue13081309if char == '"' and not escape_next:1310in_string = not in_string1311continue13121313if in_string:1314continue13151316if char == "{":1317depth += 11318elif char == "}":1319depth -= 11320if depth == 0:1321return text[start : i + 1]13221323return None13241325def _extract_json_from_position(self, text: str, start: int) -> str | None:1326"""Extract JSON object starting at given position.13271328Args:1329text: Full text.1330start: Starting position of '{'.13311332Returns:1333Extracted JSON string or None if invalid.1334"""1335if start >= len(text) or text[start] != "{":1336return None13371338depth = 01339in_string = False1340escape_next = False13411342for i, char in enumerate(text[start:], start):1343if escape_next:1344escape_next = False1345continue13461347if char == "\\":1348escape_next = True1349continue13501351if char == '"' and not escape_next:1352in_string = not in_string1353continue13541355if in_string:1356continue13571358if char == "{":1359depth += 11360elif char == "}":1361depth -= 11362if depth == 0:1363return text[start : i + 1]13641365return None13661367def _validate_translations(1368self,1369translations: dict,1370expected_verses: list[str],1371original_response: str,1372) -> dict[str, str]:1373"""Validate parsed translations have all expected verses.13741375Args:1376translations: Parsed translation dict.1377expected_verses: List of expected verse refs.1378original_response: Original response for error context.13791380Returns:1381Validated translations dict.13821383Raises:1384ChunkParsingError: If validation fails.1385"""1386if not isinstance(translations, dict):1387raise ChunkParsingError(1388f"Expected dict, got {type(translations).__name__}",1389response=original_response,1390expected_verses=expected_verses,1391)13921393# Validate all expected verses are present1394missing = set(expected_verses) - set(translations.keys())1395if missing:1396raise ChunkParsingError(1397f"Missing translations for verses: {sorted(missing)}",1398response=original_response,1399expected_verses=expected_verses,1400)14011402# Keep only the verses this pericope asked for. The prompt shows the1403# preceding and following verses with their references, and a model1404# that "helpfully" translates one of them would otherwise overwrite a1405# verse in the neighbouring pericope; a stray non-verse key ("note")1406# would crash the save loop after half the chunk was written.1407extra = set(translations) - set(expected_verses)1408if extra:1409logger.warning(1410"Discarding %d unrequested key(s) from chunk response: %s",1411len(extra),1412sorted(map(str, extra))[:5],1413)14141415# Validate all values are strings1416for ref in expected_verses:1417text = translations[ref]1418if not isinstance(text, str):1419raise ChunkParsingError(1420f"Translation for {ref} is not a string: {type(text).__name__}",1421response=original_response,1422expected_verses=expected_verses,1423)14241425return {ref: translations[ref] for ref in expected_verses}14261427def _save_chunk_translations(1428self, book: str, translations: dict[str, str]1429) -> None:1430"""Save individual verse translations from chunk output.14311432Args:1433book: Book name.1434translations: Dict mapping verse ref to translation.1435"""1436for verse_ref, text in translations.items():1437chapter, verse = map(int, verse_ref.split(":"))1438ref = VerseReference(book=book, chapter=chapter, verse=verse)14391440# Parse furigana from translation text1441plain_text, annotations = parse_furigana(text)14421443output = TranslationOutput(1444reference=ref,1445text=plain_text,1446text_with_furigana=text,1447furigana_annotations=[1448{1449"start_offset": a.start_offset,1450"end_offset": a.end_offset,1451"base_text": a.base_text,1452"reading": a.reading,1453}1454for a in annotations1455],1456model=self.model,1457sources_used=["chunk_translation"],1458)1459self.storage.save_translation(output)14601461def _add_chunk_to_tm(1462self,1463context: ChunkTranslationContext,1464translations: dict[str, str],1465) -> None:1466"""Add chunk translations to translation memory.14671468Args:1469context: Chunk translation context.1470translations: Dict mapping verse ref to translation.1471"""1472if self._translation_memory is None:1473return14741475for verse in context.verses:1476ref = f"{verse.reference.chapter}:{verse.reference.verse}"1477source = verse.greek_text or verse.hebrew_text1478target = translations.get(ref)14791480if source and target:1481self._translation_memory.add_from_translation(1482source_text=source,1483target_text=target,1484reference=verse.reference,1485metadata={"model": self.model, "pericope_id": context.pericope.id},1486)1487