OPEN SOURCE
software/src/orchestration/orchestrator.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"""Main orchestrator for fault-tolerant Bible translation.23Coordinates parallel book translation across multiple processes,4handling job state, graceful shutdown, and error recovery.5"""67from __future__ import annotations89import json10import logging11import os12import time13from concurrent.futures import ThreadPoolExecutor, as_completed14from datetime import datetime15from pathlib import Path16from typing import TYPE_CHECKING, Any1718from src.orchestration.models import (19ALL_BOOKS,20NEW_TESTAMENT_BOOKS,21OLD_TESTAMENT_BOOKS,22BookResult,23JobResult,24JobStatus,25)26from src.orchestration.provenance import (27TranslationProvenance,28compute_glossary_hash,29compute_hash,30compute_style_hash,31)32from src.orchestration.rate_limiter import DummyRateLimiter, SharedRateLimiter33from src.orchestration.signals import GracefulShutdown34from src.orchestration.state import JobStateStore3536if TYPE_CHECKING:37from src.models.verse import VerseReference3839logger = logging.getLogger(__name__)404142class JobOrchestrator:43"""Orchestrates fault-tolerant Bible translation across multiple processes.4445Features:46- Parallel book translation using multiprocessing47- Verse-level progress tracking with SQLite persistence48- Graceful shutdown handling (Ctrl+C)49- Resume capability from any point50- Full provenance tracking5152Example:53orchestrator = JobOrchestrator(data_dir=Path("./data"))5455# Start new job56job_id = orchestrator.create_job(books=["john", "matthew"])57result = orchestrator.run(job_id)5859# Resume after crash60result = orchestrator.resume()61"""6263def __init__(64self,65data_dir: Path,66max_parallel_books: int = 4,67max_concurrent_api_calls: int = 5,68rate_limit_delay: float = 1.2,69) -> None:70"""Initialize the job orchestrator.7172Args:73data_dir: Path to the data directory.74max_parallel_books: Maximum books to translate in parallel.75max_concurrent_api_calls: Maximum concurrent API calls across all workers.76rate_limit_delay: Delay between API calls in seconds.77"""78self.data_dir = Path(data_dir)79self.max_parallel_books = max_parallel_books80self.max_concurrent_api_calls = max_concurrent_api_calls81self.rate_limit_delay = rate_limit_delay8283# Initialize state store84self.state_store = JobStateStore(self.data_dir / "jobs" / "state.db")8586# Shutdown handler (set during run)87self._shutdown: GracefulShutdown | None = None8889def create_job(90self,91books: list[str] | None = None,92nt_only: bool = False,93ot_only: bool = False,94skip_completed: bool = True,95job_id: str | None = None,96) -> str:97"""Create a new Bible translation job.9899Args:100books: Specific books to translate (default: all 66).101nt_only: Only translate New Testament (27 books).102ot_only: Only translate Old Testament (39 books).103skip_completed: Skip books that are already fully translated.104job_id: Optional specific job ID.105106Returns:107Job ID for the created job.108"""109# Determine which books to translate110if books:111selected_books = [b.lower() for b in books]112elif nt_only:113selected_books = list(NEW_TESTAMENT_BOOKS)114elif ot_only:115selected_books = list(OLD_TESTAMENT_BOOKS)116else:117selected_books = list(ALL_BOOKS)118119# Validate book names120invalid = set(selected_books) - set(ALL_BOOKS)121if invalid:122raise ValueError(f"Invalid book names: {invalid}")123124# Load current configuration125config = self._snapshot_config()126127# Create job in state store128job = self.state_store.create_bible_job(129books=selected_books,130config=config,131job_id=job_id,132)133134logger.info("Created job %s with %d books", job.job_id, len(selected_books))135136return job.job_id137138def run(self, job_id: str) -> JobResult:139"""Run a Bible translation job.140141Args:142job_id: Job identifier to run.143144Returns:145JobResult with completion status and statistics.146"""147job = self.state_store.get_bible_job(job_id)148if not job:149raise ValueError(f"Job not found: {job_id}")150151if job.status == JobStatus.COMPLETED:152raise ValueError(f"Job already completed: {job_id}")153154# Mark job as started155start_time = datetime.utcnow()156self.state_store.update_job_status(157job_id, JobStatus.IN_PROGRESS, started_at=start_time158)159160book_results: list[BookResult] = []161162with GracefulShutdown() as shutdown:163self._shutdown = shutdown164165# Create thread-safe rate limiter166rate_limiter = SharedRateLimiter(167max_concurrent=self.max_concurrent_api_calls,168delay=self.rate_limit_delay,169)170171try:172# Initialize verse jobs for all books173self._initialize_all_books(job_id, job.books)174175# Process books in parallel using thread pool176if self.max_parallel_books > 1:177book_results = self._run_parallel(178job_id, job.books, rate_limiter, shutdown179)180else:181book_results = self._run_sequential(182job_id, job.books, rate_limiter, shutdown183)184185finally:186rate_limiter.shutdown()187188# Compute final statistics189end_time = datetime.utcnow()190duration = (end_time - start_time).total_seconds()191192books_completed = sum(1 for r in book_results if r.success)193books_failed = len(book_results) - books_completed194total_translated = sum(r.verses_translated for r in book_results)195total_failed = sum(r.verses_failed for r in book_results)196197# Determine final status198all_success = books_failed == 0 and not shutdown.should_stop199final_status = JobStatus.COMPLETED if all_success else JobStatus.FAILED200201self.state_store.update_job_status(202job_id, final_status, completed_at=end_time203)204205return JobResult(206job_id=job_id,207success=all_success,208books_completed=books_completed,209books_failed=books_failed,210total_verses_translated=total_translated,211total_verses_failed=total_failed,212duration_seconds=duration,213book_results=book_results,214)215216def resume(self, job_id: str | None = None) -> JobResult:217"""Resume an incomplete job.218219Args:220job_id: Optional specific job ID. If None, resumes latest incomplete.221222Returns:223JobResult with completion status.224"""225if job_id:226job = self.state_store.get_bible_job(job_id)227if not job:228raise ValueError(f"Job not found: {job_id}")229else:230job = self.state_store.get_latest_incomplete_job()231if not job:232raise ValueError("No incomplete jobs found")233234logger.info("Resuming job %s", job.job_id)235236# Reset any orphaned verses (in_progress but not completed)237reset_count = self.state_store.reset_orphaned_verses(job.job_id)238if reset_count > 0:239logger.info("Reset %d orphaned verses", reset_count)240241# Re-queue failed verses too — resuming is the retry mechanism242# (e.g. after fixing a timeout or a transient API problem)243failed_reset = 0244for book in job.books:245failed_reset += self.state_store.reset_failed_verses(job.job_id, book)246if failed_reset > 0:247logger.info("Re-queued %d previously failed verses", failed_reset)248249return self.run(job.job_id)250251def get_status(self, job_id: str) -> dict[str, Any]:252"""Get detailed status for a job.253254Args:255job_id: Job identifier.256257Returns:258Dictionary with job status and progress.259"""260job = self.state_store.get_bible_job(job_id)261if not job:262raise ValueError(f"Job not found: {job_id}")263264book_jobs = self.state_store.get_book_jobs(job_id)265266return {267"job_id": job.job_id,268"status": job.status.value,269"created_at": job.created_at.isoformat(),270"started_at": job.started_at.isoformat() if job.started_at else None,271"completed_at": job.completed_at.isoformat() if job.completed_at else None,272"total_books": len(job.books),273"books": [274{275"book": bj.book,276"status": bj.status.value,277"total_verses": bj.total_verses,278"completed_verses": bj.completed_verses,279"failed_verses": bj.failed_verses,280"progress": bj.progress_percentage,281}282for bj in book_jobs283],284"overall_progress": job.progress_percentage,285}286287def _snapshot_config(self) -> dict[str, Any]:288"""Capture current configuration for job snapshot.289290Returns:291Dictionary with configuration snapshot.292"""293config: dict[str, Any] = {}294295# Load style config if exists296style_path = self.data_dir / "config" / "style.json"297if style_path.exists():298with style_path.open() as f:299config["style"] = json.load(f)300301# Add model info302from src.pipeline.translator import DEFAULT_MODEL303304config["model"] = DEFAULT_MODEL305306return config307308def _initialize_all_books(self, job_id: str, books: list[str]) -> None:309"""Initialize verse jobs for all books.310311Verses already translated on disk are marked COMPLETED immediately312so progress bars reflect the true starting position.313314Args:315job_id: Job identifier.316books: List of book names.317"""318from src.data.loader import DataLoader319from src.data.storage import Storage320321loader = DataLoader(self.data_dir)322storage = Storage(self.data_dir)323total_verses = 0324325for book in books:326# Check if already initialized327book_job = self.state_store.get_book_job(job_id, book)328if book_job and book_job.total_verses > 0:329total_verses += book_job.total_verses330continue331332# Load progress once per book to check existing translations333progress = storage.get_progress(book)334completed_map = progress.get("completed", {})335336# Get all verses for this book337pending_verses: list[tuple[int, int]] = []338completed_verses: list[tuple[int, int]] = []339chapters = loader.get_book_chapters(book)340341for chapter in chapters:342chapter_verses = loader.load_chapter_verses(book, chapter)343chapter_completed = completed_map.get(str(chapter), [])344for verse in chapter_verses:345ch = verse.reference.chapter346vs = verse.reference.verse347if vs in chapter_completed:348completed_verses.append((ch, vs))349else:350pending_verses.append((ch, vs))351352all_verses = pending_verses + completed_verses353if all_verses:354self.state_store.initialize_verse_jobs(355job_id, book, pending_verses, completed_verses,356)357total_verses += len(all_verses)358logger.info(359"Initialized %s: %d verses (%d already translated)",360book, len(all_verses), len(completed_verses),361)362else:363logger.warning("No verses found for %s", book)364365# Update job total366self.state_store.update_job_verse_counts(job_id, total=total_verses)367368def _run_parallel(369self,370job_id: str,371books: list[str],372rate_limiter: SharedRateLimiter,373shutdown: GracefulShutdown,374) -> list[BookResult]:375"""Run book translations in parallel using threads.376377Uses ThreadPoolExecutor since workers are I/O-bound (API calls).378Threads share memory so the rate limiter works without pickling.379380Args:381job_id: Job identifier.382books: List of book names.383rate_limiter: Shared rate limiter.384shutdown: Shutdown handler.385386Returns:387List of BookResult for each book.388"""389results: list[BookResult] = []390391with ThreadPoolExecutor(max_workers=self.max_parallel_books) as executor:392# Submit all books393futures = {}394for book in books:395if shutdown.should_stop:396break397398future = executor.submit(399_translate_book_worker,400job_id,401book,402str(self.data_dir),403str(self.state_store.db_path),404rate_limiter,405)406futures[future] = book407408# Collect results409for future in as_completed(futures):410book = futures[future]411if shutdown.should_stop:412results.append(BookResult(413book=book,414success=False,415verses_translated=0,416verses_failed=0,417duration_seconds=0,418error_message="Cancelled due to shutdown",419))420continue421422try:423result = future.result()424results.append(result)425except Exception as e:426logger.error("Book %s failed: %s", book, e)427results.append(BookResult(428book=book,429success=False,430verses_translated=0,431verses_failed=0,432duration_seconds=0,433error_message=str(e),434))435436return results437438def _run_sequential(439self,440job_id: str,441books: list[str],442rate_limiter: SharedRateLimiter,443shutdown: GracefulShutdown,444) -> list[BookResult]:445"""Run book translations sequentially.446447Args:448job_id: Job identifier.449books: List of book names.450rate_limiter: Shared rate limiter.451shutdown: Shutdown handler.452453Returns:454List of BookResult for each book.455"""456results: list[BookResult] = []457458for book in books:459if shutdown.should_stop:460results.append(BookResult(461book=book,462success=False,463verses_translated=0,464verses_failed=0,465duration_seconds=0,466error_message="Cancelled due to shutdown",467))468continue469470result = _translate_book_worker(471job_id,472book,473str(self.data_dir),474str(self.state_store.db_path),475rate_limiter,476)477results.append(result)478479return results480481482def _translate_book_worker( # pragma: no cover483job_id: str,484book: str,485data_dir_str: str,486db_path_str: str,487rate_limiter: SharedRateLimiter | DummyRateLimiter,488) -> BookResult:489"""Worker function to translate a single book.490491This runs in a separate process when using parallel translation.492493Args:494job_id: Job identifier.495book: Book name to translate.496data_dir_str: Path to data directory (string for pickling).497db_path_str: Path to state database (string for pickling).498rate_limiter: Shared rate limiter.499500Returns:501BookResult with translation statistics.502"""503data_dir = Path(data_dir_str)504state_store = JobStateStore(db_path_str)505worker_pid = os.getpid()506507start_time = time.time()508verses_translated = 0509verses_failed = 0510511# Mark book as in progress512state_store.update_book_status(513job_id, book, JobStatus.IN_PROGRESS,514worker_pid=worker_pid,515started_at=datetime.utcnow(),516)517518try:519# Create translator components520from src.config.style_config import StyleConfigManager521from src.context.builder import ContextBuilder522from src.context.glossary import GlossaryManager523from src.context.pericope import PericopeManager524from src.context.translation_memory import TranslationMemoryManager525from src.data.loader import DataLoader526from src.data.storage import Storage527from src.evaluation.evaluator import TranslationEvaluator528from src.evaluation.storage import EvaluationStorage529from src.models.verse import VerseReference530from src.pipeline.prompt import PromptBuilder531from src.pipeline.translator import Translator532533loader = DataLoader(data_dir)534storage = Storage(data_dir)535536glossary_terms = loader.load_glossary()537glossary = GlossaryManager(glossary_terms)538539style_manager = StyleConfigManager(data_dir / "config" / "style.json")540style = style_manager.get_style()541542tm = TranslationMemoryManager(data_dir / "memory" / "segments.json")543pericope_manager = PericopeManager(data_dir / "metadata" / "pericopes.json")544545context_builder = ContextBuilder(loader, glossary, pericope_manager=pericope_manager)546prompt_builder = PromptBuilder(glossary, style=style, translation_memory=tm)547548evaluator = TranslationEvaluator(data_dir=data_dir)549eval_storage = EvaluationStorage(data_dir)550551# Per-verse LLM evaluation doubles API cost/time on whole-Bible runs;552# opt in with TRANSLATE_BIBLE_EVAL=1 (sampled evaluation after the run553# is usually the better trade).554auto_evaluate = os.environ.get("TRANSLATE_BIBLE_EVAL", "0") == "1"555556translator = Translator(557context_builder,558prompt_builder,559storage,560translation_memory=tm,561auto_evaluate=auto_evaluate,562evaluator=evaluator,563evaluation_storage=eval_storage,564)565566# Get pending verses567pending = state_store.get_pending_verses(job_id, book)568569# Pericope/chunk mode is the quality default (see IMPROVEMENT_LOG.md):570# verse-isolated translation fragments sentences, breaks formulaic571# series, and drifts referents. Translate every pericope that still572# has pending verses as one unit; verses not covered by pericope data573# fall through to the per-verse loop below.574book_pericopes = pericope_manager.get_book_pericopes(book)575if book_pericopes:576pending_set = {(vj.chapter, vj.verse) for vj in pending}577covered: set[tuple[int, int]] = set()578for pericope in book_pericopes:579refs = [tuple(map(int, v.split(":"))) for v in pericope.verses]580covered.update(refs)581todo = [rv for rv in refs if rv in pending_set]582if not todo:583continue584for ch, vs in todo:585state_store.atomic_claim_verse(job_id, book, ch, vs, worker_pid)586try:587with rate_limiter:588translator.translate_chunk(book, pericope, skip_existing=True)589for ch, vs in todo:590ref = VerseReference(book=book, chapter=ch, verse=vs)591if storage.is_verse_translated(ref):592state_store.update_verse_status(593job_id, book, ch, vs, JobStatus.COMPLETED594)595verses_translated += 1596else:597state_store.update_verse_status(598job_id, book, ch, vs, JobStatus.FAILED,599error="verse missing after chunk translation",600)601verses_failed += 1602except Exception as e:603logger.error("Failed pericope %s: %s", pericope.id, e)604for ch, vs in todo:605state_store.update_verse_status(606job_id, book, ch, vs, JobStatus.FAILED, error=str(e)607)608verses_failed += 1609pending = [610vj for vj in pending if (vj.chapter, vj.verse) not in covered611]612613for verse_job in pending:614# Try to claim the verse atomically615claimed = state_store.atomic_claim_verse(616job_id, book, verse_job.chapter, verse_job.verse, worker_pid617)618if not claimed:619continue # Another worker got it620621ref = VerseReference(622book=book, chapter=verse_job.chapter, verse=verse_job.verse623)624625# Skip verses already translated on disk626if storage.is_verse_translated(ref):627state_store.update_verse_status(628job_id, book, verse_job.chapter, verse_job.verse,629JobStatus.COMPLETED,630)631verses_translated += 1632continue633634try:635# Rate limit the API call636with rate_limiter:637# Translate with provenance tracking638output, provenance = _translate_verse_with_provenance(639translator, ref, storage, data_dir640)641642# Save provenance643storage.save_translation_with_provenance(output, provenance)644645# Mark verse as completed646state_store.update_verse_status(647job_id, book, verse_job.chapter, verse_job.verse,648JobStatus.COMPLETED,649)650verses_translated += 1651652except Exception as e:653logger.error("Failed to translate %s: %s", ref, e)654state_store.update_verse_status(655job_id, book, verse_job.chapter, verse_job.verse,656JobStatus.FAILED, error=str(e),657)658verses_failed += 1659660# Save TM661tm.save()662663# Mark book as completed664duration = time.time() - start_time665state_store.update_book_status(666job_id, book, JobStatus.COMPLETED,667completed_at=datetime.utcnow(),668)669670return BookResult(671book=book,672success=True,673verses_translated=verses_translated,674verses_failed=verses_failed,675duration_seconds=duration,676)677678except Exception as e:679logger.error("Book %s failed: %s", book, e)680duration = time.time() - start_time681682state_store.update_book_status(683job_id, book, JobStatus.FAILED,684completed_at=datetime.utcnow(),685error_message=str(e),686)687688return BookResult(689book=book,690success=False,691verses_translated=verses_translated,692verses_failed=verses_failed,693duration_seconds=duration,694error_message=str(e),695)696697698def _translate_verse_with_provenance( # pragma: no cover699translator: Any,700reference: VerseReference,701storage: Any,702data_dir: Path,703) -> tuple[Any, TranslationProvenance]:704"""Translate a verse and capture full provenance.705706Args:707translator: Translator instance.708reference: Verse reference to translate.709storage: Storage instance.710data_dir: Data directory path.711712Returns:713Tuple of (TranslationOutput, TranslationProvenance).714"""715from src.models.verse import TranslationOutput716717# Track timing718started_at = datetime.utcnow()719720# Build context721context = translator.context_builder.build_context(reference)722723# Get TM matches724tm_matches = translator._get_tm_matches(context) or []725726# Build prompts727prompt = translator.prompt_builder.build_prompt(context, tm_matches=tm_matches)728system_prompt = translator.prompt_builder.get_system_prompt()729730# Build provenance731provenance = TranslationProvenance(732model=translator.model,733prompt_version="v1.0",734system_prompt=system_prompt,735user_prompt=prompt,736started_at=started_at,737attempt_number=1,738)739740# Capture source texts741verse = context.target_verse742if verse.greek_text:743provenance.source_texts["greek"] = verse.greek_text744if verse.hebrew_text:745provenance.source_texts["hebrew"] = verse.hebrew_text746if verse.english_kjv:747provenance.source_texts["kjv"] = verse.english_kjv748if verse.english_asv:749provenance.source_texts["asv"] = verse.english_asv750if verse.japanese_meiji:751provenance.source_texts["meiji"] = verse.japanese_meiji752if verse.japanese_taisho:753provenance.source_texts["taisho"] = verse.japanese_taisho754755# Compute source hashes756provenance.compute_source_hashes()757758# Capture context verses759provenance.preceding_verses = [760str(v.reference) for v in context.preceding_verses761]762provenance.following_verses = [763str(v.reference) for v in context.following_verses764]765766# Capture chapter summary767if context.chapter_summary:768provenance.chapter_summary = context.chapter_summary.summary769770# Capture book metadata771if context.book_metadata:772provenance.book_metadata = {773"name": context.book_metadata.name_english,774"testament": context.book_metadata.testament,775"themes": context.book_metadata.themes,776}777778# Capture glossary hash779provenance.glossary_hash = compute_glossary_hash(context.glossary_terms)780781# Capture style hash782if translator.prompt_builder._style:783style_dict = translator.prompt_builder._style.to_dict()784provenance.style_config_hash = compute_style_hash(style_dict)785786# Capture TM matches787if tm_matches:788provenance.tm_matches_used = [789{790"source": m.segment.source_text,791"target": m.segment.target_text,792"similarity": m.similarity,793}794for m in tm_matches795]796797# Call Claude798translation_text = translator._call_claude(system_prompt, prompt)799800# Complete timing801completed_at = datetime.utcnow()802provenance.completed_at = completed_at803804# Create output805output = TranslationOutput(806reference=reference,807text=translation_text,808model=translator.model,809sources_used=list(provenance.source_texts.keys()),810glossary_terms_applied=[t.original for t in context.glossary_terms],811)812813# Add to TM814translator._add_to_tm(context, translation_text)815816return output, provenance817