SOURCE EXPLORER / 資料ブラウザー
translator.py
実装と資料を、行ごとに確かめる。
日常訳はAIを用いて作成された翻訳です。人間の聖書学・原語の専門家による全訳の検証は確認されていません。既存の翻訳や原語資料と照らし合わせてお読みください。
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.utils.furigana import parse_furigana29from src.utils.retry import RetryConfig, RetryState3031if TYPE_CHECKING:32from src.context.translation_memory import TranslationMemoryManager33from src.evaluation.evaluator import TranslationEvaluator34from src.evaluation.storage import EvaluationStorage35from src.models.evaluation import VerseEvaluation36from src.models.verse import TranslationContext37from src.orchestration.provenance import TranslationProvenance3839logger = logging.getLogger(__name__)4041# Find Claude binary42CLAUDE_BINARY = os.environ.get("CLAUDE_BINARY", "claude")434445def _clean_env() -> dict[str, str]:46"""Return a copy of the environment without CLAUDECODE to avoid nested session errors."""47env = os.environ.copy()48env.pop("CLAUDECODE", None)49return env5051DEFAULT_MODEL = os.environ.get("TRANSLATE_BIBLE_MODEL", "claude-opus-4-8")525354def _sanitize_translation_output(raw: str) -> str:55"""Strip meta-commentary from a verse translation response.5657Models occasionally emit self-corrections ("Wait—let me provide the58correct translation.") or markdown fences around the answer. A verse59translation is a single Japanese block, so: drop code fences, drop60predominantly-Latin blocks, and if several Japanese blocks remain61(a self-correction), keep the last one.62"""63text = raw.strip()64if not text:65return text6667lines = [ln for ln in text.split("\n") if not ln.strip().startswith("```")]6869blocks: list[list[str]] = [[]]70for ln in lines:71if ln.strip():72blocks[-1].append(ln)73elif blocks[-1]:74blocks.append([])75block_texts = ["\n".join(b).strip() for b in blocks if b]7677def is_japanese(block: str) -> bool:78cjk = sum(1 for ch in block if "" <= ch <= "鿿" or "!" <= ch <= "⦆")79latin = sum(1 for ch in block if ch.isascii() and ch.isalpha())80return cjk > latin8182japanese_blocks = [b for b in block_texts if is_japanese(b)]83if not japanese_blocks:84return text8586result = japanese_blocks[-1]87result = re.sub(r"^(翻訳|訳文|Translation)\s*[::]\s*", "", result)88if result != text:89logger.warning("Sanitized translation output (removed meta-commentary)")90return result9192# Default retry configuration93DEFAULT_RETRY_CONFIG = RetryConfig(94max_attempts=3,95base_delay=2.0,96max_delay=60.0,97exponential_base=2.0,98retryable_exceptions=(RateLimitError, ConnectionError, TimeoutError),99)100101# Quality thresholds for evaluation gating102DEFAULT_QUALITY_THRESHOLD = 70.0103THEOLOGICAL_ACCURACY_THRESHOLD = 75.0104105106class Translator:107"""Translates Bible verses using Claude CLI."""108109def __init__(110self,111context_builder: ContextBuilder,112prompt_builder: PromptBuilder,113storage: Storage,114model: str = DEFAULT_MODEL,115rate_limit_delay: float = 1.2,116translation_memory: TranslationMemoryManager | None = None,117auto_evaluate: bool = True,118evaluator: TranslationEvaluator | None = None,119evaluation_storage: EvaluationStorage | None = None,120retry_config: RetryConfig | None = None,121quality_threshold: float = DEFAULT_QUALITY_THRESHOLD,122enforce_quality_gate: bool = False,123subprocess_timeout: float | None = None,124enable_proto_archive: bool = False,125) -> None:126"""Initialize the translator.127128Args:129context_builder: Context builder for creating translation context.130prompt_builder: Prompt builder for formatting prompts.131storage: Storage for saving translations.132model: Claude model to use.133rate_limit_delay: Delay between API calls in seconds.134translation_memory: Optional TM manager for reuse.135auto_evaluate: Whether to automatically evaluate translations.136evaluator: Evaluator to use for auto-evaluation.137evaluation_storage: Storage for evaluation results.138retry_config: Configuration for retry with backoff.139quality_threshold: Minimum score for evaluation gating.140enforce_quality_gate: If True, reject low-quality translations.141subprocess_timeout: Timeout for Claude CLI calls in seconds.142Defaults to the TRANSLATE_BIBLE_TIMEOUT env var, or 120.143Long pericope chunks (10+ verses) can need 300s+.144enable_proto_archive: Whether to archive translation inputs/outputs.145"""146self.context_builder = context_builder147self.prompt_builder = prompt_builder148self.storage = storage149self.model = model150self.rate_limit_delay = rate_limit_delay151self._translation_memory = translation_memory152self._auto_evaluate = auto_evaluate153self._evaluator = evaluator154self._evaluation_storage = evaluation_storage155self._retry_config = retry_config or DEFAULT_RETRY_CONFIG156self._quality_threshold = quality_threshold157self._enforce_quality_gate = enforce_quality_gate158if subprocess_timeout is None:159subprocess_timeout = float(os.environ.get("TRANSLATE_BIBLE_TIMEOUT", "120"))160self._subprocess_timeout = subprocess_timeout161self._enable_archive = enable_proto_archive162# Enable archive on storage if requested163if enable_proto_archive:164self.storage.enable_archive = True165166@property167def enable_proto_archive(self) -> bool:168"""Get proto archive enabled status."""169return self._enable_archive170171@enable_proto_archive.setter172def enable_proto_archive(self, value: bool) -> None:173"""Set proto archive enabled status."""174self._enable_archive = value175self.storage.enable_archive = value176177@property178def translation_memory(self) -> TranslationMemoryManager | None:179"""Get the translation memory manager."""180return self._translation_memory181182@translation_memory.setter183def translation_memory(self, value: TranslationMemoryManager | None) -> None:184"""Set the translation memory manager."""185self._translation_memory = value186187@property188def auto_evaluate(self) -> bool:189"""Get auto-evaluation setting."""190return self._auto_evaluate191192@auto_evaluate.setter193def auto_evaluate(self, value: bool) -> None:194"""Set auto-evaluation setting."""195self._auto_evaluate = value196197def _call_claude(self, system_prompt: str, user_prompt: str) -> str:198"""Call Claude CLI with the given prompts.199200Includes retry logic with exponential backoff for transient errors.201202Args:203system_prompt: System prompt for Claude.204user_prompt: User prompt for Claude.205206Returns:207The response text from Claude.208209Raises:210ClaudeAPIError: If the Claude CLI call fails after all retries.211"""212retry_state = RetryState(self._retry_config)213214while True:215try:216result = subprocess.run(217[218CLAUDE_BINARY,219"--print",220"-p",221user_prompt,222"--system-prompt",223system_prompt,224"--model",225self.model,226],227capture_output=True,228text=True,229check=False,230timeout=self._subprocess_timeout,231env=_clean_env(),232)233234if result.returncode != 0:235error = classify_claude_error(result.stderr, result.returncode)236raise error237238return result.stdout.strip()239240except subprocess.TimeoutExpired as e:241error = ClaudeAPIError(242f"Claude CLI timed out after {self._subprocess_timeout}s",243stderr=str(e),244return_code=-1,245)246if retry_state.should_retry(error):247logger.warning(248"Claude CLI timeout, attempt %d/%d. Retrying...",249retry_state.attempt + 1,250self._retry_config.max_attempts,251)252retry_state.wait()253else:254raise error from e255256except FileNotFoundError as e:257# Claude CLI not installed258from src.exceptions import CLINotFoundError259raise CLINotFoundError() from e260261except ClaudeAPIError as e:262if retry_state.should_retry(e):263logger.warning(264"Claude API error, attempt %d/%d: %s. Retrying...",265retry_state.attempt + 1,266self._retry_config.max_attempts,267str(e),268)269retry_state.wait()270else:271raise272273async def _call_claude_async(self, system_prompt: str, user_prompt: str) -> str:274"""Call Claude CLI asynchronously with the given prompts.275276Includes retry logic with exponential backoff for transient errors.277278Args:279system_prompt: System prompt for Claude.280user_prompt: User prompt for Claude.281282Returns:283The response text from Claude.284285Raises:286ClaudeAPIError: If the Claude CLI call fails after all retries.287"""288retry_state = RetryState(self._retry_config)289290while True:291try:292process = await asyncio.wait_for(293asyncio.create_subprocess_exec(294CLAUDE_BINARY,295"--print",296"-p",297user_prompt,298"--system-prompt",299system_prompt,300"--model",301self.model,302stdout=asyncio.subprocess.PIPE,303stderr=asyncio.subprocess.PIPE,304env=_clean_env(),305),306timeout=self._subprocess_timeout,307)308stdout, stderr = await asyncio.wait_for(309process.communicate(),310timeout=self._subprocess_timeout,311)312313if process.returncode != 0:314error = classify_claude_error(stderr.decode(), process.returncode or 1)315raise error316317return stdout.decode().strip()318319except asyncio.TimeoutError as e:320error = ClaudeAPIError(321f"Claude CLI timed out after {self._subprocess_timeout}s",322stderr="Async timeout",323return_code=-1,324)325if retry_state.should_retry(error):326logger.warning(327"Claude CLI async timeout, attempt %d/%d. Retrying...",328retry_state.attempt + 1,329self._retry_config.max_attempts,330)331await retry_state.async_wait()332else:333raise error from e334335except FileNotFoundError as e:336from src.exceptions import CLINotFoundError337raise CLINotFoundError() from e338339except ClaudeAPIError as e:340if retry_state.should_retry(e):341logger.warning(342"Claude API error (async), attempt %d/%d: %s. Retrying...",343retry_state.attempt + 1,344self._retry_config.max_attempts,345str(e),346)347await retry_state.async_wait()348else:349raise350351def _get_tm_matches(self, context: TranslationContext) -> list | None:352"""Get TM matches for the context if TM is enabled.353354Args:355context: Translation context.356357Returns:358List of TM matches or None if TM not available.359"""360if self._translation_memory is None:361return None362363# Get source text for matching364source_text = (365context.target_verse.greek_text366or context.target_verse.hebrew_text367or ""368)369if not source_text:370return None371372matches = self._translation_memory.find_matches(373source_text, threshold=0.7, max_results=3374)375return matches if matches else None376377def _add_to_tm(378self, context: TranslationContext, translation_text: str379) -> None:380"""Add completed translation to translation memory.381382Args:383context: Translation context.384translation_text: The translated text.385"""386if self._translation_memory is None:387return388389source_text = (390context.target_verse.greek_text391or context.target_verse.hebrew_text392)393if not source_text:394return395396self._translation_memory.add_from_translation(397source_text=source_text,398target_text=translation_text,399reference=context.target_verse.reference,400metadata={"model": self.model},401)402403def _evaluate_translation(404self,405output: TranslationOutput,406context: TranslationContext,407) -> VerseEvaluation | None:408"""Evaluate a translation if auto-evaluate is enabled.409410Args:411output: The translation output.412context: The translation context.413414Returns:415VerseEvaluation or None if evaluation failed/disabled.416417Raises:418EvaluationError: If enforce_quality_gate is True and evaluation fails.419"""420if not self._auto_evaluate or not self._evaluator:421return None422423ref = output.reference424ref_str = f"{ref.book} {ref.chapter}:{ref.verse}"425426try:427from src.evaluation.evaluator import (428EVALUATION_SYSTEM_PROMPT,429parse_evaluation_response,430)431432# Build evaluation prompt433eval_prompt = self._evaluator.build_verse_prompt(434output.text, context435)436437# Call Claude for evaluation438response = self._call_claude(EVALUATION_SYSTEM_PROMPT, eval_prompt)439440# Parse response441scores = parse_evaluation_response(response)442443# Create evaluation444evaluation = self._evaluator.create_verse_evaluation(445verse_number=output.reference.verse,446scores=scores,447)448449# Log evaluation result450logger.info(451"Evaluated %s: overall=%.1f%%, flags=%s",452ref_str,453evaluation.overall_score,454evaluation.flags or "none",455)456457return evaluation458459except Exception as e:460# Log the error with details461logger.error(462"Evaluation failed for %s: %s",463ref_str,464str(e),465)466467# If quality gate is enforced, raise the error468if self._enforce_quality_gate:469raise EvaluationError(470f"Evaluation failed for {ref_str}: {e}",471verse_ref=ref_str,472) from e473474# Otherwise, return None (don't block translation)475return None476477def _check_quality_gate(478self,479evaluation: VerseEvaluation,480reference: VerseReference,481) -> None:482"""Check if translation passes quality threshold.483484Args:485evaluation: The verse evaluation.486reference: The verse reference.487488Raises:489QualityThresholdError: If translation fails quality gate.490"""491if not self._enforce_quality_gate:492return493494ref_str = f"{reference.book} {reference.chapter}:{reference.verse}"495496# Check overall score497if evaluation.overall_score < self._quality_threshold:498raise QualityThresholdError(499f"Translation for {ref_str} failed quality gate: "500f"{evaluation.overall_score:.1f}% < {self._quality_threshold}%",501score=evaluation.overall_score,502threshold=self._quality_threshold,503)504505# Check theological accuracy specifically506if "theological_accuracy" in evaluation.scores:507ta_score = evaluation.scores["theological_accuracy"].score508if ta_score < THEOLOGICAL_ACCURACY_THRESHOLD:509raise QualityThresholdError(510f"Translation for {ref_str} failed theological accuracy: "511f"{ta_score:.1f}% < {THEOLOGICAL_ACCURACY_THRESHOLD}%",512score=ta_score,513threshold=THEOLOGICAL_ACCURACY_THRESHOLD,514dimension="theological_accuracy",515)516517def _save_evaluation(518self,519evaluation: VerseEvaluation,520reference: VerseReference,521) -> None:522"""Save evaluation result if storage is configured.523524Args:525evaluation: The evaluation to save.526reference: The verse reference.527"""528if not self._evaluation_storage:529return530531self._evaluation_storage.save_verse_evaluation(532reference.book,533reference.chapter,534evaluation,535)536537def translate_verse(538self,539reference: VerseReference,540skip_existing: bool = True,541evaluate: bool | None = None,542) -> TranslationOutput:543"""Translate a single verse.544545Args:546reference: Verse reference to translate.547skip_existing: If True, return existing translation if available.548evaluate: Override auto_evaluate setting for this call.549550Returns:551TranslationOutput with the translation.552"""553# Check if already translated554if skip_existing and self.storage.is_verse_translated(reference):555existing = self.storage.load_translation(reference)556if existing:557return existing558559# Build context and prompt560context = self.context_builder.build_context(reference)561562# Get TM matches563tm_matches = self._get_tm_matches(context)564565# Build prompt (with TM matches if available)566prompt = self.prompt_builder.build_prompt(context, tm_matches=tm_matches)567system_prompt = self.prompt_builder.get_system_prompt()568569# Call Claude CLI570translation_text = _sanitize_translation_output(571self._call_claude(system_prompt, prompt)572)573574# Parse furigana from Claude's response575plain_text, annotations = parse_furigana(translation_text)576577# Create output578output = TranslationOutput(579reference=reference,580text=plain_text,581text_with_furigana=translation_text,582furigana_annotations=[583{584"start_offset": a.start_offset,585"end_offset": a.end_offset,586"base_text": a.base_text,587"reading": a.reading,588}589for a in annotations590],591model=self.model,592sources_used=self._get_sources_used(context),593glossary_terms_applied=[t.original for t in context.glossary_terms],594)595596# Save translation597self.storage.save_translation(output)598599# Add to TM600self._add_to_tm(context, plain_text)601602# Evaluate if enabled603should_evaluate = evaluate if evaluate is not None else self._auto_evaluate604if should_evaluate:605evaluation = self._evaluate_translation(output, context)606if evaluation:607self._save_evaluation(evaluation, reference)608# Check quality gate609self._check_quality_gate(evaluation, reference)610611return output612613def translate_verse_with_provenance( # pragma: no cover614self,615reference: VerseReference,616skip_existing: bool = True,617) -> tuple[TranslationOutput, "TranslationProvenance"]:618"""Translate a verse and capture full provenance.619620Args:621reference: Verse reference to translate.622skip_existing: If True, return existing translation if available.623624Returns:625Tuple of (TranslationOutput, TranslationProvenance).626"""627from datetime import datetime628629from src.orchestration.provenance import (630TranslationProvenance,631compute_glossary_hash,632compute_style_hash,633)634635# Check if already translated636if skip_existing and self.storage.is_verse_translated(reference):637existing = self.storage.load_translation(reference)638if existing:639# Try to load existing provenance640provenance = self.storage.load_provenance(reference)641if provenance:642return existing, provenance643# Create minimal provenance for existing translation644provenance = TranslationProvenance(model=existing.model)645return existing, provenance646647# Track timing648started_at = datetime.utcnow()649650# Build context651context = self.context_builder.build_context(reference)652653# Get TM matches654tm_matches = self._get_tm_matches(context) or []655656# Build prompts657prompt = self.prompt_builder.build_prompt(context, tm_matches=tm_matches)658system_prompt = self.prompt_builder.get_system_prompt()659660# Build provenance661provenance = TranslationProvenance(662model=self.model,663prompt_version="v1.0",664system_prompt=system_prompt,665user_prompt=prompt,666started_at=started_at,667attempt_number=1,668)669670# Capture source texts671verse = context.target_verse672if verse.greek_text:673provenance.source_texts["greek"] = verse.greek_text674if verse.hebrew_text:675provenance.source_texts["hebrew"] = verse.hebrew_text676if verse.english_kjv:677provenance.source_texts["kjv"] = verse.english_kjv678if verse.english_asv:679provenance.source_texts["asv"] = verse.english_asv680if verse.japanese_meiji:681provenance.source_texts["meiji"] = verse.japanese_meiji682if verse.japanese_taisho:683provenance.source_texts["taisho"] = verse.japanese_taisho684685# Compute source hashes686provenance.compute_source_hashes()687688# Capture context verses689provenance.preceding_verses = [690str(v.reference) for v in context.preceding_verses691]692provenance.following_verses = [693str(v.reference) for v in context.following_verses694]695696# Capture chapter summary697if context.chapter_summary:698provenance.chapter_summary = context.chapter_summary.summary699700# Capture book metadata701if context.book_metadata:702provenance.book_metadata = {703"name": context.book_metadata.name_english,704"testament": context.book_metadata.testament,705"themes": context.book_metadata.themes,706}707708# Capture glossary hash709provenance.glossary_hash = compute_glossary_hash(context.glossary_terms)710711# Capture style hash712if self.prompt_builder._style:713style_dict = self.prompt_builder._style.to_dict()714provenance.style_config_hash = compute_style_hash(style_dict)715716# Capture TM matches717if tm_matches:718provenance.tm_matches_used = [719{720"source": m.segment.source_text,721"target": m.segment.target_text,722"similarity": m.similarity,723}724for m in tm_matches725]726727# Create input proto for archive (before translation)728input_proto = None729if self._enable_archive:730from src.protos.converters import (731translation_context_to_prompt_input,732tm_match_to_proto,733)734from src.protos.serialization import compute_proto_hash735736input_proto = translation_context_to_prompt_input(737context, system_prompt, prompt, self.model,738)739# Add TM matches to input proto740if tm_matches and hasattr(tm_matches[0], "segment"):741input_proto.tm_matches = [tm_match_to_proto(m) for m in tm_matches]742743# Call Claude744translation_text = self._call_claude(system_prompt, prompt)745746# Complete timing747completed_at = datetime.utcnow()748provenance.completed_at = completed_at749750# Parse furigana from Claude's response751plain_text, annotations = parse_furigana(translation_text)752753# Create output754output = TranslationOutput(755reference=reference,756text=plain_text,757text_with_furigana=translation_text,758furigana_annotations=[759{760"start_offset": a.start_offset,761"end_offset": a.end_offset,762"base_text": a.base_text,763"reading": a.reading,764}765for a in annotations766],767model=self.model,768sources_used=list(provenance.source_texts.keys()),769glossary_terms_applied=[t.original for t in context.glossary_terms],770)771772# Save translation with provenance773self.storage.save_translation_with_provenance(output, provenance)774775# Save proto archive if enabled776if self._enable_archive and input_proto is not None:777from src.protos.converters import (778provenance_to_proto,779translation_result_to_proto,780)781from src.protos.serialization import compute_proto_hash782783output_proto = translation_result_to_proto(784output, translation_text, annotations,785input_hash=compute_proto_hash(input_proto)786)787output_proto.provenance = provenance_to_proto(provenance)788self.storage.save_translation_archive(reference, input_proto, output_proto)789790# Add to TM791self._add_to_tm(context, plain_text)792793return output, provenance794795async def translate_verse_async(796self, reference: VerseReference, skip_existing: bool = True797) -> TranslationOutput:798"""Translate a single verse asynchronously.799800Args:801reference: Verse reference to translate.802skip_existing: If True, return existing translation if available.803804Returns:805TranslationOutput with the translation.806"""807# Check if already translated808if skip_existing and self.storage.is_verse_translated(reference):809existing = self.storage.load_translation(reference)810if existing:811return existing812813# Build context and prompt814context = self.context_builder.build_context(reference)815816# Get TM matches817tm_matches = self._get_tm_matches(context)818819# Build prompt820prompt = self.prompt_builder.build_prompt(context, tm_matches=tm_matches)821system_prompt = self.prompt_builder.get_system_prompt()822823# Call Claude CLI824translation_text = await self._call_claude_async(system_prompt, prompt)825826# Parse furigana from Claude's response827plain_text, annotations = parse_furigana(translation_text)828829# Create output830output = TranslationOutput(831reference=reference,832text=plain_text,833text_with_furigana=translation_text,834furigana_annotations=[835{836"start_offset": a.start_offset,837"end_offset": a.end_offset,838"base_text": a.base_text,839"reading": a.reading,840}841for a in annotations842],843model=self.model,844sources_used=self._get_sources_used(context),845glossary_terms_applied=[t.original for t in context.glossary_terms],846)847848# Save translation849self.storage.save_translation(output)850851# Add to TM852self._add_to_tm(context, plain_text)853854return output855856def translate_chapter(857self,858book: str,859chapter: int,860skip_existing: bool = True,861progress_callback: Callable[..., Any] | None = None,862evaluate: bool | None = None,863) -> list[TranslationOutput]:864"""Translate all verses in a chapter.865866Args:867book: Book name.868chapter: Chapter number.869skip_existing: If True, skip already translated verses.870progress_callback: Optional callback for progress updates.871evaluate: Override auto_evaluate setting for this call.872873Returns:874List of TranslationOutput for each verse.875"""876# Get all verses in the chapter877verses = self.context_builder.loader.load_chapter_verses(book, chapter)878outputs = []879880for i, verse in enumerate(verses):881if progress_callback:882progress_callback(i + 1, len(verses), verse.reference)883884output = self.translate_verse(885verse.reference, skip_existing, evaluate=evaluate886)887outputs.append(output)888889# Rate limiting890if i < len(verses) - 1:891time.sleep(self.rate_limit_delay)892893# Save TM after chapter894if self._translation_memory is not None:895self._translation_memory.save()896897return outputs898899async def translate_chapter_async(900self,901book: str,902chapter: int,903skip_existing: bool = True,904max_concurrent: int = 5,905) -> list[TranslationOutput]:906"""Translate all verses in a chapter asynchronously.907908Args:909book: Book name.910chapter: Chapter number.911skip_existing: If True, skip already translated verses.912max_concurrent: Maximum concurrent CLI calls.913914Returns:915List of TranslationOutput for each verse.916"""917# Get all verses in the chapter918verses = self.context_builder.loader.load_chapter_verses(book, chapter)919920# Create semaphore for rate limiting921semaphore = asyncio.Semaphore(max_concurrent)922923async def translate_with_limit(ref: VerseReference) -> TranslationOutput:924async with semaphore:925result = await self.translate_verse_async(ref, skip_existing)926await asyncio.sleep(self.rate_limit_delay)927return result928929tasks = [translate_with_limit(v.reference) for v in verses]930outputs = await asyncio.gather(*tasks)931932# Save TM after chapter933if self._translation_memory is not None:934self._translation_memory.save()935936return list(outputs)937938def _get_sources_used(self, context: TranslationContext) -> list[str]:939"""Get list of source text types used."""940sources = []941verse = context.target_verse942943if verse.greek_text:944sources.append("greek")945if verse.hebrew_text:946sources.append("hebrew")947if verse.japanese_meiji:948sources.append("meiji")949if verse.japanese_taisho:950sources.append("taisho")951if verse.japanese_kougo:952sources.append("kougo")953if verse.english_kjv:954sources.append("kjv")955if verse.english_asv:956sources.append("asv")957958return sources959960def translate_chunk(961self,962book: str,963pericope: Pericope,964skip_existing: bool = True,965) -> ChunkTranslationOutput:966"""Translate an entire pericope/chunk as a cohesive unit.967968Args:969book: Book name.970pericope: The pericope to translate.971skip_existing: If True, skip verses already translated.972973Returns:974ChunkTranslationOutput with verse-aligned translations.975"""976# Build chunk context977context = self.context_builder.build_chunk_context(book, pericope)978979# Check if all verses already translated980if skip_existing:981existing = self._get_existing_chunk_translations(book, pericope)982if len(existing) == len(pericope.verses):983return ChunkTranslationOutput(984pericope_id=pericope.id,985book=book,986translations=existing,987model=self.model,988coherence_note="Loaded from existing translations",989)990991# Get TM matches for the combined source text992tm_matches = self._get_chunk_tm_matches(context)993994# Seam continuity: surface our own translations of the immediately995# preceding verses so the new chunk continues them seamlessly.996preceding_translations = []997for v in context.preceding_verses:998existing_t = self.storage.load_translation(v.reference)999if existing_t and existing_t.text:1000preceding_translations.append((v.reference, existing_t.text))10011002# Build chunk prompt1003prompt = self.prompt_builder.build_chunk_prompt(1004context,1005tm_matches=tm_matches,1006preceding_translations=preceding_translations or None,1007)1008system_prompt = self.prompt_builder.get_chunk_system_prompt()10091010# Call Claude CLI1011response = self._call_claude(system_prompt, prompt)10121013# Parse JSON response1014translations = self._parse_chunk_response(response, pericope.verses)10151016# Create output1017output = ChunkTranslationOutput(1018pericope_id=pericope.id,1019book=book,1020translations=translations,1021model=self.model,1022)10231024# Save individual verse translations1025self._save_chunk_translations(book, translations)10261027# Add to translation memory1028self._add_chunk_to_tm(context, translations)10291030return output10311032def translate_chunk_by_id(1033self,1034pericope_id: str,1035skip_existing: bool = True,1036) -> ChunkTranslationOutput | None:1037"""Translate a pericope by its ID.10381039Args:1040pericope_id: The pericope identifier.1041skip_existing: If True, skip verses already translated.10421043Returns:1044ChunkTranslationOutput or None if pericope not found.1045"""1046result = self.context_builder.pericope_manager.get_pericope_by_id(pericope_id)1047if result is None:1048return None10491050book, pericope = result1051return self.translate_chunk(book, pericope, skip_existing)10521053def translate_chapter_by_pericopes(1054self,1055book: str,1056chapter: int,1057skip_existing: bool = True,1058progress_callback: Callable[..., Any] | None = None,1059) -> list[ChunkTranslationOutput]:1060"""Translate a chapter using pericope-based chunking.10611062Args:1063book: Book name.1064chapter: Chapter number.1065skip_existing: If True, skip already translated pericopes.1066progress_callback: Optional callback for progress updates.10671068Returns:1069List of ChunkTranslationOutput for each pericope.1070"""1071pericopes = self.context_builder.pericope_manager.get_chapter_pericopes(1072book, chapter1073)10741075if not pericopes:1076# Fall back to verse-by-verse translation1077outputs = self.translate_chapter(1078book, chapter, skip_existing, progress_callback1079)1080# Convert to chunk format1081return [1082ChunkTranslationOutput(1083pericope_id=f"{book}-{chapter}-fallback",1084book=book,1085translations={1086f"{o.reference.chapter}:{o.reference.verse}": o.text1087for o in outputs1088},1089model=self.model,1090coherence_note="Verse-by-verse fallback (no pericope data)",1091)1092]10931094outputs = []1095for i, pericope in enumerate(pericopes):1096if progress_callback:1097progress_callback(i + 1, len(pericopes), pericope)10981099output = self.translate_chunk(book, pericope, skip_existing)1100outputs.append(output)11011102# Rate limiting between pericopes1103if i < len(pericopes) - 1:1104time.sleep(self.rate_limit_delay)11051106# Save TM after chapter1107if self._translation_memory is not None:1108self._translation_memory.save()11091110return outputs11111112def _get_existing_chunk_translations(1113self, book: str, pericope: Pericope1114) -> dict[str, str]:1115"""Get existing translations for verses in a pericope.11161117Args:1118book: Book name.1119pericope: The pericope to check.11201121Returns:1122Dict mapping verse ref to translation text.1123"""1124existing = {}1125for verse_ref in pericope.verses:1126chapter, verse = map(int, verse_ref.split(":"))1127ref = VerseReference(book=book, chapter=chapter, verse=verse)1128if self.storage.is_verse_translated(ref):1129translation = self.storage.load_translation(ref)1130if translation:1131existing[verse_ref] = translation.text1132return existing11331134def _get_chunk_tm_matches(1135self, context: ChunkTranslationContext1136) -> list | None:1137"""Get TM matches for chunk context.11381139Args:1140context: Chunk translation context.11411142Returns:1143List of TM matches or None.1144"""1145if self._translation_memory is None:1146return None11471148# Use combined source text for matching1149combined = context.get_combined_source_text()1150if not combined:1151return None11521153# Get matches for the first verse as representative1154first_verse = context.verses[0] if context.verses else None1155if not first_verse: # pragma: no cover - combined text implies a verse1156return None11571158source = first_verse.greek_text or first_verse.hebrew_text or ""1159if not source:1160return None11611162matches = self._translation_memory.find_matches(1163source, threshold=0.7, max_results=31164)1165return matches if matches else None11661167def _parse_chunk_response(1168self, response: str, expected_verses: list[str]1169) -> dict[str, str]:1170"""Parse JSON response from chunk translation.11711172Uses robust JSON extraction that handles:1173- Markdown code blocks (```json ... ```)1174- Nested JSON structures1175- Text before/after the JSON1176- Unicode characters in values11771178Args:1179response: Raw response from Claude.1180expected_verses: List of expected verse refs.11811182Returns:1183Dict mapping verse ref to translation.11841185Raises:1186ChunkParsingError: If response cannot be parsed.1187"""1188original_response = response1189response = response.strip()11901191# Strategy 1: Remove markdown code blocks if present1192if "```" in response:1193response = self._extract_from_code_block(response)11941195# Strategy 2: Try direct JSON parse first (most common case)1196translations = self._try_parse_json(response)1197if translations is not None:1198return self._validate_translations(translations, expected_verses, original_response)11991200# Strategy 3: Find JSON object using bracket matching1201json_str = self._extract_json_by_brackets(response)1202if json_str:1203translations = self._try_parse_json(json_str)1204if translations is not None:1205return self._validate_translations(translations, expected_verses, original_response)12061207# Strategy 4: Try to find JSON anywhere in the response1208for match in re.finditer(r'\{', response):1209start = match.start()1210json_str = self._extract_json_from_position(response, start)1211if json_str:1212translations = self._try_parse_json(json_str)1213if translations is not None:1214return self._validate_translations(translations, expected_verses, original_response)12151216# All strategies failed1217raise ChunkParsingError(1218"Failed to parse chunk translation response as JSON",1219response=original_response,1220expected_verses=expected_verses,1221)12221223def _extract_from_code_block(self, response: str) -> str:1224"""Extract content from markdown code block.12251226Args:1227response: Response potentially containing code blocks.12281229Returns:1230Content inside code block, or original if not found.1231"""1232lines = response.split("\n")1233json_lines = []1234in_block = False12351236for line in lines:1237stripped = line.strip()1238if stripped.startswith("```") and not in_block:1239in_block = True1240continue1241elif stripped.startswith("```") and in_block:1242break1243elif in_block:1244json_lines.append(line)12451246if json_lines:1247return "\n".join(json_lines)1248return response12491250def _try_parse_json(self, text: str) -> dict | None:1251"""Try to parse text as JSON dict.12521253Args:1254text: Text to parse.12551256Returns:1257Parsed dict or None if parsing failed.1258"""1259try:1260result = json.loads(text)1261if isinstance(result, dict):1262return result1263except json.JSONDecodeError:1264pass1265return None12661267def _extract_json_by_brackets(self, text: str) -> str | None:1268"""Extract JSON object by matching brackets.12691270Handles nested objects correctly.12711272Args:1273text: Text containing JSON.12741275Returns:1276Extracted JSON string or None if not found.1277"""1278start = text.find("{")1279if start == -1:1280return None12811282depth = 01283in_string = False1284escape_next = False12851286for i, char in enumerate(text[start:], start):1287if escape_next:1288escape_next = False1289continue12901291if char == "\\":1292escape_next = True1293continue12941295if char == '"' and not escape_next:1296in_string = not in_string1297continue12981299if in_string:1300continue13011302if char == "{":1303depth += 11304elif char == "}":1305depth -= 11306if depth == 0:1307return text[start : i + 1]13081309return None13101311def _extract_json_from_position(self, text: str, start: int) -> str | None:1312"""Extract JSON object starting at given position.13131314Args:1315text: Full text.1316start: Starting position of '{'.13171318Returns:1319Extracted JSON string or None if invalid.1320"""1321if start >= len(text) or text[start] != "{":1322return None13231324depth = 01325in_string = False1326escape_next = False13271328for i, char in enumerate(text[start:], start):1329if escape_next:1330escape_next = False1331continue13321333if char == "\\":1334escape_next = True1335continue13361337if char == '"' and not escape_next:1338in_string = not in_string1339continue13401341if in_string:1342continue13431344if char == "{":1345depth += 11346elif char == "}":1347depth -= 11348if depth == 0:1349return text[start : i + 1]13501351return None13521353def _validate_translations(1354self,1355translations: dict,1356expected_verses: list[str],1357original_response: str,1358) -> dict[str, str]:1359"""Validate parsed translations have all expected verses.13601361Args:1362translations: Parsed translation dict.1363expected_verses: List of expected verse refs.1364original_response: Original response for error context.13651366Returns:1367Validated translations dict.13681369Raises:1370ChunkParsingError: If validation fails.1371"""1372if not isinstance(translations, dict):1373raise ChunkParsingError(1374f"Expected dict, got {type(translations).__name__}",1375response=original_response,1376expected_verses=expected_verses,1377)13781379# Validate all expected verses are present1380missing = set(expected_verses) - set(translations.keys())1381if missing:1382raise ChunkParsingError(1383f"Missing translations for verses: {sorted(missing)}",1384response=original_response,1385expected_verses=expected_verses,1386)13871388# Keep only the verses this pericope asked for. The prompt shows the1389# preceding and following verses with their references, and a model1390# that "helpfully" translates one of them would otherwise overwrite a1391# verse in the neighbouring pericope; a stray non-verse key ("note")1392# would crash the save loop after half the chunk was written.1393extra = set(translations) - set(expected_verses)1394if extra:1395logger.warning(1396"Discarding %d unrequested key(s) from chunk response: %s",1397len(extra),1398sorted(map(str, extra))[:5],1399)14001401# Validate all values are strings1402for ref in expected_verses:1403text = translations[ref]1404if not isinstance(text, str):1405raise ChunkParsingError(1406f"Translation for {ref} is not a string: {type(text).__name__}",1407response=original_response,1408expected_verses=expected_verses,1409)14101411return {ref: translations[ref] for ref in expected_verses}14121413def _save_chunk_translations(1414self, book: str, translations: dict[str, str]1415) -> None:1416"""Save individual verse translations from chunk output.14171418Args:1419book: Book name.1420translations: Dict mapping verse ref to translation.1421"""1422for verse_ref, text in translations.items():1423chapter, verse = map(int, verse_ref.split(":"))1424ref = VerseReference(book=book, chapter=chapter, verse=verse)14251426# Parse furigana from translation text1427plain_text, annotations = parse_furigana(text)14281429output = TranslationOutput(1430reference=ref,1431text=plain_text,1432text_with_furigana=text,1433furigana_annotations=[1434{1435"start_offset": a.start_offset,1436"end_offset": a.end_offset,1437"base_text": a.base_text,1438"reading": a.reading,1439}1440for a in annotations1441],1442model=self.model,1443sources_used=["chunk_translation"],1444)1445self.storage.save_translation(output)14461447def _add_chunk_to_tm(1448self,1449context: ChunkTranslationContext,1450translations: dict[str, str],1451) -> None:1452"""Add chunk translations to translation memory.14531454Args:1455context: Chunk translation context.1456translations: Dict mapping verse ref to translation.1457"""1458if self._translation_memory is None:1459return14601461for verse in context.verses:1462ref = f"{verse.reference.chapter}:{verse.reference.verse}"1463source = verse.greek_text or verse.hebrew_text1464target = translations.get(ref)14651466if source and target:1467self._translation_memory.add_from_translation(1468source_text=source,1469target_text=target,1470reference=verse.reference,1471metadata={"model": self.model, "pericope_id": context.pericope.id},1472)1473