OPEN SOURCE
software/tests/test_pipeline.py
このサイトで読み、必要なファイルを保存できます。
日常訳はAIを用いて作成された翻訳です。人間の聖書学・原語の専門家による全訳の検証は確認されていません。既存の翻訳や原語資料と照らし合わせてお読みください。
1"""Tests for translation pipeline."""23import json4import pytest5from pathlib import Path6from unittest.mock import MagicMock, patch78from src.context.builder import ContextBuilder9from src.context.glossary import GlossaryManager10from src.data.loader import DataLoader11from src.data.storage import Storage12from src.exceptions import ClaudeAPIError13from src.models.verse import (14BookMetadata,15ChapterSummary,16GlossaryTerm,17TranslationContext,18Verse,19VerseReference,20)21from src.pipeline.prompt import SYSTEM_PROMPT, PromptBuilder22from src.pipeline.translator import Translator232425@pytest.fixture26def glossary_terms() -> list[GlossaryTerm]:27"""Create test glossary terms."""28return [29GlossaryTerm(30original="λόγος",31transliteration="logos",32japanese="言葉",33english="word",34notes="The Word",35),36]373839@pytest.fixture40def sample_verse() -> Verse:41"""Create a sample verse."""42return Verse(43reference=VerseReference(book="john", chapter=1, verse=1),44greek_text="Ἐν ἀρχῇ ἦν ὁ λόγος",45greek_translit="En archē ēn ho logos",46japanese_meiji="太初に言あり",47japanese_taisho="元始に言あり",48english_kjv="In the beginning was the Word",49english_asv="In the beginning was the Word",50)515253@pytest.fixture54def sample_context(sample_verse: Verse, glossary_terms: list[GlossaryTerm]) -> TranslationContext:55"""Create a sample translation context."""56return TranslationContext(57target_verse=sample_verse,58preceding_verses=[],59following_verses=[60Verse(61reference=VerseReference(book="john", chapter=1, verse=2),62english_kjv="The same was in the beginning with God",63)64],65chapter_summary=ChapterSummary(66book="john",67chapter=1,68summary="The prologue",69key_events=[],70key_characters=[],71key_themes=[],72),73book_metadata=BookMetadata(74book="john",75name_japanese="ヨハネによる福音書",76name_english="Gospel of John",77testament="NT",78author="John",79themes=["love"],80description="The fourth Gospel",81chapter_count=21,82verse_count=879,83),84glossary_terms=glossary_terms,85)868788@pytest.fixture89def temp_data_dir(tmp_path: Path) -> Path:90"""Create temporary data directory."""91sources_dir = tmp_path / "sources"92(sources_dir / "greek").mkdir(parents=True)93(sources_dir / "english" / "kjv").mkdir(parents=True)94(sources_dir / "japanese" / "meiji").mkdir(parents=True)95(sources_dir / "japanese" / "taisho").mkdir(parents=True)96(sources_dir / "english" / "asv").mkdir(parents=True)97(tmp_path / "metadata").mkdir(parents=True)98(tmp_path / "output").mkdir(parents=True)99(tmp_path / "progress").mkdir(parents=True)100101# Add test data102greek_data = {103"1:1": {"text": "Ἐν ἀρχῇ", "translit": "En archē"},104"1:2": {"text": "οὗτος ἦν", "translit": "houtos ēn"},105}106with (sources_dir / "greek" / "john.json").open("w") as f:107json.dump(greek_data, f)108109kjv_data = {"1:1": "In the beginning", "1:2": "The same was"}110with (sources_dir / "english" / "kjv" / "john.json").open("w") as f:111json.dump(kjv_data, f)112113return tmp_path114115116class TestPromptBuilder:117"""Tests for PromptBuilder."""118119def test_get_system_prompt(self, glossary_terms: list[GlossaryTerm]) -> None:120"""Test getting system prompt."""121glossary = GlossaryManager(glossary_terms)122builder = PromptBuilder(glossary)123prompt = builder.get_system_prompt()124assert "expert Bible translator" in prompt125assert "新共同訳" in prompt126127def test_build_prompt(128self, sample_context: TranslationContext, glossary_terms: list[GlossaryTerm]129) -> None:130"""Test building a translation prompt."""131glossary = GlossaryManager(glossary_terms)132builder = PromptBuilder(glossary)133prompt = builder.build_prompt(sample_context)134135# Check all sections are present136assert "SOURCE TEXT:" in prompt137assert "REFERENCE TRANSLATIONS:" in prompt138assert "CONTEXT:" in prompt139assert "GLOSSARY" in prompt140assert "TRANSLATE:" in prompt141142def test_build_prompt_with_greek(143self, sample_context: TranslationContext, glossary_terms: list[GlossaryTerm]144) -> None:145"""Test prompt includes Greek text."""146glossary = GlossaryManager(glossary_terms)147builder = PromptBuilder(glossary)148prompt = builder.build_prompt(sample_context)149150assert "Greek:" in prompt151assert "Ἐν ἀρχῇ" in prompt152assert "Romanization:" in prompt153154def test_build_prompt_with_references(155self, sample_context: TranslationContext, glossary_terms: list[GlossaryTerm]156) -> None:157"""Test prompt includes reference translations."""158glossary = GlossaryManager(glossary_terms)159builder = PromptBuilder(glossary)160prompt = builder.build_prompt(sample_context)161162assert "明治元訳" in prompt163assert "KJV:" in prompt164165def test_build_prompt_with_book_metadata(166self, sample_context: TranslationContext, glossary_terms: list[GlossaryTerm]167) -> None:168"""Test prompt includes book metadata."""169glossary = GlossaryManager(glossary_terms)170builder = PromptBuilder(glossary)171prompt = builder.build_prompt(sample_context)172173assert "Gospel of John" in prompt174assert "ヨハネによる福音書" in prompt175176def test_build_prompt_with_following_verses(177self, sample_context: TranslationContext, glossary_terms: list[GlossaryTerm]178) -> None:179"""Test prompt includes following verses."""180glossary = GlossaryManager(glossary_terms)181builder = PromptBuilder(glossary)182prompt = builder.build_prompt(sample_context)183184assert "Following verses:" in prompt185186def test_build_prompt_hebrew_verse(187self, glossary_terms: list[GlossaryTerm]188) -> None:189"""Test prompt with Hebrew text."""190verse = Verse(191reference=VerseReference(book="genesis", chapter=1, verse=1),192hebrew_text="בְּרֵאשִׁית",193hebrew_translit="bereshit",194english_kjv="In the beginning",195)196context = TranslationContext(target_verse=verse)197198glossary = GlossaryManager(glossary_terms)199builder = PromptBuilder(glossary)200prompt = builder.build_prompt(context)201202assert "Hebrew:" in prompt203assert "בְּרֵאשִׁית" in prompt204205def test_build_prompt_no_metadata(206self, sample_verse: Verse, glossary_terms: list[GlossaryTerm]207) -> None:208"""Test prompt without metadata."""209context = TranslationContext(210target_verse=sample_verse,211book_metadata=None,212chapter_summary=None,213)214215glossary = GlossaryManager(glossary_terms)216builder = PromptBuilder(glossary)217prompt = builder.build_prompt(context)218219# Should still work without metadata220assert "TRANSLATE:" in prompt221222def test_get_best_text_prefers_english(223self, glossary_terms: list[GlossaryTerm]224) -> None:225"""Test _get_best_text prefers English."""226verse = Verse(227reference=VerseReference(book="john", chapter=1, verse=1),228english_kjv="KJV text",229japanese_meiji="Meiji text",230greek_text="Greek text",231)232233glossary = GlossaryManager(glossary_terms)234builder = PromptBuilder(glossary)235text = builder._get_best_text(verse)236237assert text == "KJV text"238239def test_get_best_text_fallback_order(240self, glossary_terms: list[GlossaryTerm]241) -> None:242"""Test _get_best_text fallback order."""243glossary = GlossaryManager(glossary_terms)244builder = PromptBuilder(glossary)245246# Test ASV fallback247verse = Verse(248reference=VerseReference(book="john", chapter=1, verse=1),249english_asv="ASV text",250)251assert builder._get_best_text(verse) == "ASV text"252253# Test Japanese fallback254verse = Verse(255reference=VerseReference(book="john", chapter=1, verse=1),256japanese_taisho="Taisho text",257)258assert builder._get_best_text(verse) == "Taisho text"259260# Test Meiji fallback261verse = Verse(262reference=VerseReference(book="john", chapter=1, verse=1),263japanese_meiji="Meiji text",264)265assert builder._get_best_text(verse) == "Meiji text"266267# Test Greek fallback268verse = Verse(269reference=VerseReference(book="john", chapter=1, verse=1),270greek_text="Greek text",271)272assert builder._get_best_text(verse) == "Greek text"273274# Test Hebrew fallback275verse = Verse(276reference=VerseReference(book="john", chapter=1, verse=1),277hebrew_text="Hebrew text",278)279assert builder._get_best_text(verse) == "Hebrew text"280281# Test empty fallback282verse = Verse(283reference=VerseReference(book="john", chapter=1, verse=1),284)285assert builder._get_best_text(verse) == "[text not available]"286287288class TestTranslator:289"""Tests for Translator."""290291def test_init(self, temp_data_dir: Path) -> None:292"""Test initializing translator."""293loader = DataLoader(temp_data_dir)294storage = Storage(temp_data_dir)295glossary = GlossaryManager()296context_builder = ContextBuilder(loader, glossary)297prompt_builder = PromptBuilder(glossary)298299translator = Translator(context_builder, prompt_builder, storage)300from src.pipeline.translator import DEFAULT_MODEL301302assert translator.model == DEFAULT_MODEL303304def test_translate_verse_skip_existing(self, temp_data_dir: Path) -> None:305"""Test translate_verse skips existing translations."""306loader = DataLoader(temp_data_dir)307storage = Storage(temp_data_dir)308glossary = GlossaryManager()309context_builder = ContextBuilder(loader, glossary)310prompt_builder = PromptBuilder(glossary)311312translator = Translator(context_builder, prompt_builder, storage)313314# Save an existing translation315from src.models.verse import TranslationOutput316317ref = VerseReference(book="john", chapter=1, verse=1)318existing = TranslationOutput(319reference=ref,320text="Existing translation",321model="test",322)323storage.save_translation(existing)324325# Should return existing without calling CLI326result = translator.translate_verse(ref, skip_existing=True)327assert result.text == "Existing translation"328329@patch("src.pipeline.translator.subprocess.run")330def test_translate_verse_calls_claude(331self, mock_run: MagicMock, temp_data_dir: Path332) -> None:333"""Test translate_verse calls Claude CLI for new verses."""334loader = DataLoader(temp_data_dir)335storage = Storage(temp_data_dir)336glossary = GlossaryManager()337context_builder = ContextBuilder(loader, glossary)338prompt_builder = PromptBuilder(glossary)339340translator = Translator(context_builder, prompt_builder, storage)341342# Mock subprocess.run343mock_run.return_value = MagicMock(344returncode=0, stdout="初めに言葉があった。", stderr=""345)346347ref = VerseReference(book="john", chapter=1, verse=1)348result = translator.translate_verse(ref, skip_existing=False)349350assert result.text == "初めに言葉があった。"351mock_run.assert_called_once()352353@patch("src.pipeline.translator.subprocess.run")354def test_call_claude_raises_on_error(355self, mock_run: MagicMock, temp_data_dir: Path356) -> None:357"""Test _call_claude raises ClaudeAPIError on CLI failure."""358loader = DataLoader(temp_data_dir)359storage = Storage(temp_data_dir)360glossary = GlossaryManager()361context_builder = ContextBuilder(loader, glossary)362prompt_builder = PromptBuilder(glossary)363364translator = Translator(context_builder, prompt_builder, storage)365366# Mock subprocess.run to return error367mock_run.return_value = MagicMock(368returncode=1, stdout="", stderr="API error"369)370371with pytest.raises(ClaudeAPIError):372translator._call_claude("system", "user")373374def test_get_sources_used(self, temp_data_dir: Path) -> None:375"""Test _get_sources_used returns correct sources."""376loader = DataLoader(temp_data_dir)377storage = Storage(temp_data_dir)378glossary = GlossaryManager()379context_builder = ContextBuilder(loader, glossary)380prompt_builder = PromptBuilder(glossary)381382translator = Translator(context_builder, prompt_builder, storage)383384verse = Verse(385reference=VerseReference(book="john", chapter=1, verse=1),386greek_text="Greek",387english_kjv="KJV",388japanese_meiji="Meiji",389)390context = TranslationContext(target_verse=verse)391392sources = translator._get_sources_used(context)393assert "greek" in sources394assert "kjv" in sources395assert "meiji" in sources396397def test_get_sources_used_hebrew(self, temp_data_dir: Path) -> None:398"""Test _get_sources_used includes Hebrew."""399loader = DataLoader(temp_data_dir)400storage = Storage(temp_data_dir)401glossary = GlossaryManager()402context_builder = ContextBuilder(loader, glossary)403prompt_builder = PromptBuilder(glossary)404405translator = Translator(context_builder, prompt_builder, storage)406407verse = Verse(408reference=VerseReference(book="genesis", chapter=1, verse=1),409hebrew_text="Hebrew",410japanese_taisho="Taisho",411english_asv="ASV",412)413context = TranslationContext(target_verse=verse)414415sources = translator._get_sources_used(context)416assert "hebrew" in sources417assert "taisho" in sources418assert "asv" in sources419420@patch("src.pipeline.translator.subprocess.run")421def test_translate_verse_parses_furigana(422self, mock_run: MagicMock, temp_data_dir: Path423) -> None:424"""Test translate_verse parses furigana from Claude response."""425loader = DataLoader(temp_data_dir)426storage = Storage(temp_data_dir)427glossary = GlossaryManager()428context_builder = ContextBuilder(loader, glossary)429prompt_builder = PromptBuilder(glossary)430431translator = Translator(context_builder, prompt_builder, storage)432433# Mock subprocess.run returning text with furigana notation434mock_run.return_value = MagicMock(435returncode=0, stdout="神[かみ]は愛[あい]です。", stderr=""436)437438ref = VerseReference(book="john", chapter=1, verse=1)439result = translator.translate_verse(ref, skip_existing=False)440441# Check plain text has furigana stripped442assert result.text == "神は愛です。"443444# Check original text with furigana is preserved445assert result.text_with_furigana == "神[かみ]は愛[あい]です。"446447# Check annotations are correctly extracted448assert len(result.furigana_annotations) == 2449assert result.furigana_annotations[0]["base_text"] == "神"450assert result.furigana_annotations[0]["reading"] == "かみ"451assert result.furigana_annotations[1]["base_text"] == "愛"452assert result.furigana_annotations[1]["reading"] == "あい"453454@patch("src.pipeline.translator.subprocess.run")455def test_translate_verse_no_furigana(456self, mock_run: MagicMock, temp_data_dir: Path457) -> None:458"""Test translate_verse handles text without furigana."""459loader = DataLoader(temp_data_dir)460storage = Storage(temp_data_dir)461glossary = GlossaryManager()462context_builder = ContextBuilder(loader, glossary)463prompt_builder = PromptBuilder(glossary)464465translator = Translator(context_builder, prompt_builder, storage)466467# Mock subprocess.run returning text without furigana468mock_run.return_value = MagicMock(469returncode=0, stdout="初めに言葉があった。", stderr=""470)471472ref = VerseReference(book="john", chapter=1, verse=1)473result = translator.translate_verse(ref, skip_existing=False)474475assert result.text == "初めに言葉があった。"476assert result.text_with_furigana == "初めに言葉があった。"477assert result.furigana_annotations == []478479def test_save_chunk_translations_parses_furigana(480self, temp_data_dir: Path481) -> None:482"""Test _save_chunk_translations parses furigana from chunk output."""483loader = DataLoader(temp_data_dir)484storage = Storage(temp_data_dir)485glossary = GlossaryManager()486context_builder = ContextBuilder(loader, glossary)487prompt_builder = PromptBuilder(glossary)488489translator = Translator(context_builder, prompt_builder, storage)490491# Call _save_chunk_translations with furigana text492translations = {493"1:1": "神[かみ]は愛[あい]です。",494"1:2": "言葉[ことば]は神[かみ]と共[とも]にあった。",495}496translator._save_chunk_translations("john", translations)497498# Load and verify499loaded = storage.load_translation(500VerseReference(book="john", chapter=1, verse=1)501)502assert loaded is not None503assert loaded.text == "神は愛です。"504assert loaded.text_with_furigana == "神[かみ]は愛[あい]です。"505assert len(loaded.furigana_annotations) == 2506507loaded2 = storage.load_translation(508VerseReference(book="john", chapter=1, verse=2)509)510assert loaded2 is not None511assert loaded2.text == "言葉は神と共にあった。"512assert len(loaded2.furigana_annotations) == 3513514515class TestTranslatorProtoArchive:516"""Tests for translator proto archive functionality."""517518def test_enable_proto_archive_default_false(self, temp_data_dir: Path) -> None:519"""Test enable_proto_archive is False by default."""520loader = DataLoader(temp_data_dir)521storage = Storage(temp_data_dir)522glossary = GlossaryManager()523context_builder = ContextBuilder(loader, glossary)524prompt_builder = PromptBuilder(glossary)525526translator = Translator(context_builder, prompt_builder, storage)527assert translator.enable_proto_archive is False528assert storage.enable_archive is False529530def test_enable_proto_archive_init(self, temp_data_dir: Path) -> None:531"""Test enable_proto_archive can be set in init."""532loader = DataLoader(temp_data_dir)533storage = Storage(temp_data_dir)534glossary = GlossaryManager()535context_builder = ContextBuilder(loader, glossary)536prompt_builder = PromptBuilder(glossary)537538translator = Translator(539context_builder, prompt_builder, storage, enable_proto_archive=True540)541assert translator.enable_proto_archive is True542assert storage.enable_archive is True543544def test_enable_proto_archive_property_setter(self, temp_data_dir: Path) -> None:545"""Test enable_proto_archive property setter."""546loader = DataLoader(temp_data_dir)547storage = Storage(temp_data_dir)548glossary = GlossaryManager()549context_builder = ContextBuilder(loader, glossary)550prompt_builder = PromptBuilder(glossary)551552translator = Translator(context_builder, prompt_builder, storage)553assert translator.enable_proto_archive is False554555translator.enable_proto_archive = True556assert translator.enable_proto_archive is True557assert storage.enable_archive is True558559translator.enable_proto_archive = False560assert translator.enable_proto_archive is False561assert storage.enable_archive is False562