OPEN SOURCE
software/src/context/translation_memory.py
このサイトで読み、必要なファイルを保存できます。
日常訳はAIを用いて作成された翻訳です。人間の聖書学・原語の専門家による全訳の検証は確認されていません。既存の翻訳や原語資料と照らし合わせてお読みください。
1"""Translation Memory management for segment storage and retrieval."""23from __future__ import annotations45import fcntl6import json7import logging8from contextlib import contextmanager9from datetime import datetime, timezone10from pathlib import Path11from typing import Iterator1213from src.models.translation_memory import (14MatchType,15SegmentLevel,16TMMatch,17TMSegment,18TMStatistics,19calculate_similarity,20normalize_text,21)22from src.models.verse import VerseReference2324logger = logging.getLogger(__name__)2526#: Default store location. Kept as a module constant so the test suite can27#: redirect it — a test that constructs a manager with no path would otherwise28#: read and write the real, irreplaceable production memory.29DEFAULT_STORAGE_PATH = Path("data/memory/segments.json")303132class TranslationMemoryManager:33"""Manages translation memory segments for reuse.3435Provides storage, retrieval, and fuzzy matching of36previously translated segments.37"""3839def __init__(self, storage_path: Path | str | None = None) -> None:40"""Initialize the translation memory manager.4142Args:43storage_path: Path to the storage file. If None, uses default.44"""45if storage_path is None:46self._storage_path = DEFAULT_STORAGE_PATH47else:48self._storage_path = Path(storage_path)4950self._segments: dict[str, TMSegment] = {}51self._by_reference: dict[str, list[str]] = {} # ref_str -> [segment_ids]52self._loaded = False5354@property55def storage_path(self) -> Path:56"""Get the storage file path."""57return self._storage_path5859def _ensure_loaded(self) -> None:60"""Ensure segments are loaded from storage."""61if not self._loaded:62self.load()6364def add_segment(self, segment: TMSegment) -> None:65"""Add a segment to the translation memory.6667Args:68segment: The segment to add.69"""70self._ensure_loaded()7172self._segments[segment.id] = segment7374# Index by reference75ref_str = str(segment.reference)76if ref_str not in self._by_reference:77self._by_reference[ref_str] = []78if segment.id not in self._by_reference[ref_str]:79self._by_reference[ref_str].append(segment.id)8081def add_from_translation(82self,83source_text: str,84target_text: str,85reference: VerseReference,86level: SegmentLevel = SegmentLevel.VERSE,87metadata: dict | None = None,88) -> TMSegment:89"""Create and add a segment from a translation result.9091Args:92source_text: Original source text.93target_text: Translated text.94reference: Verse reference.95level: Segment level.96metadata: Additional metadata.9798Returns:99The created TMSegment.100"""101segment = TMSegment.create(102source_text=source_text,103target_text=target_text,104reference=reference,105level=level,106metadata=metadata,107)108self.add_segment(segment)109return segment110111def get_segment(self, segment_id: str) -> TMSegment | None:112"""Get a segment by ID.113114Args:115segment_id: The segment ID.116117Returns:118The segment if found, None otherwise.119"""120self._ensure_loaded()121return self._segments.get(segment_id)122123def get_by_reference(self, reference: VerseReference) -> list[TMSegment]:124"""Get all segments for a verse reference.125126Args:127reference: The verse reference.128129Returns:130List of segments for that reference.131"""132self._ensure_loaded()133134ref_str = str(reference)135segment_ids = self._by_reference.get(ref_str, [])136return [self._segments[sid] for sid in segment_ids if sid in self._segments]137138def find_matches(139self,140source_text: str,141threshold: float = 0.7,142max_results: int = 5,143) -> list[TMMatch]:144"""Find matching segments for a source text.145146Args:147source_text: The source text to match.148threshold: Minimum similarity threshold (0.0-1.0).149max_results: Maximum number of results to return.150151Returns:152List of TMMatch objects sorted by similarity (highest first).153"""154self._ensure_loaded()155156if not self._segments:157return []158159normalized = normalize_text(source_text)160matches = []161162for segment in self._segments.values():163similarity = calculate_similarity(normalized, segment.source_normalized)164165if similarity >= threshold:166if similarity == 1.0:167match_type = MatchType.EXACT168elif segment.level == SegmentLevel.PHRASE:169match_type = MatchType.PHRASE170else:171match_type = MatchType.FUZZY172173matches.append(174TMMatch(175segment=segment,176similarity=similarity,177match_type=match_type,178)179)180181# Sort by similarity (highest first)182matches.sort(key=lambda m: m.similarity, reverse=True)183184return matches[:max_results]185186def find_exact_match(self, source_text: str) -> TMSegment | None:187"""Find an exact match for a source text.188189Args:190source_text: The source text to match.191192Returns:193The matching segment if found, None otherwise.194"""195matches = self.find_matches(source_text, threshold=1.0, max_results=1)196if matches and matches[0].is_exact:197return matches[0].segment198return None199200def increment_reuse(self, segment_id: str) -> bool:201"""Increment the reuse count for a segment.202203Args:204segment_id: The segment ID.205206Returns:207True if successful, False if segment not found.208"""209self._ensure_loaded()210211segment = self._segments.get(segment_id)212if segment:213segment.increment_reuse()214return True215return False216217def get_statistics(self) -> TMStatistics:218"""Get statistics about the translation memory.219220Returns:221TMStatistics object.222"""223self._ensure_loaded()224return TMStatistics.from_segments(list(self._segments.values()))225226def get_all_segments(self) -> list[TMSegment]:227"""Get all segments in the translation memory.228229Returns:230List of all TMSegment objects.231"""232self._ensure_loaded()233return list(self._segments.values())234235def remove_segment(self, segment_id: str) -> bool:236"""Remove a segment from the translation memory.237238Args:239segment_id: The segment ID to remove.240241Returns:242True if removed, False if not found.243"""244self._ensure_loaded()245246segment = self._segments.pop(segment_id, None)247if segment:248ref_str = str(segment.reference)249if ref_str in self._by_reference:250self._by_reference[ref_str] = [251sid for sid in self._by_reference[ref_str] if sid != segment_id252]253return True254return False255256def clear(self) -> None:257"""Clear all segments from memory (not from storage)."""258self._segments.clear()259self._by_reference.clear()260261def save(self) -> None:262"""Merge in-memory segments into the stored file, atomically.263264Parallel book workers each hold their own manager over the same file,265each loaded when its book started and each saving hours later. A plain266overwrite therefore discards every other book's segments — the last267writer wins and days of paid translation vanish silently.268269So the write is both:270271- **atomic** (temp file then rename), so a crash cannot leave a torn272file that poisons every later run; and273- **merging** (re-read under an exclusive lock, combine, then write),274so a slow worker cannot roll back a fast one's work.275276On conflict the in-memory segment wins, being the more recent277translation of that identical source text.278"""279self._ensure_loaded()280self._storage_path.parent.mkdir(parents=True, exist_ok=True)281282with self._exclusive_lock():283merged = self._read_disk_segments()284merged.update(self._segments)285286data = {287"version": "1.0",288"updated_at": datetime.now(timezone.utc).isoformat(),289"segments": [290{291"id": s.id,292"source_text": s.source_text,293"source_normalized": s.source_normalized,294"target_text": s.target_text,295"reference": str(s.reference),296"level": s.level.value,297"created_at": s.created_at.isoformat(),298"reuse_count": s.reuse_count,299"metadata": s.metadata,300}301for s in merged.values()302],303}304305import tempfile306307tmp_fd, tmp_path = tempfile.mkstemp(308dir=self._storage_path.parent,309suffix=".tmp",310)311try:312with open(tmp_fd, "w", encoding="utf-8") as f:313json.dump(data, f, ensure_ascii=False, indent=2)314Path(tmp_path).replace(self._storage_path)315except BaseException:316Path(tmp_path).unlink(missing_ok=True)317raise318319self._segments = merged320self._rebuild_reference_index()321322@contextmanager323def _exclusive_lock(self) -> Iterator[None]:324"""Hold an exclusive lock over the store for the duration of the block.325326Uses a sidecar lock file rather than the store itself, so the atomic327replace cannot swap the locked file out from under a held lock.328329Yields:330None, once the lock is held.331"""332lock_path = self._storage_path.with_suffix(333self._storage_path.suffix + ".lock"334)335lock_path.parent.mkdir(parents=True, exist_ok=True)336handle = lock_path.open("w")337try:338fcntl.flock(handle.fileno(), fcntl.LOCK_EX)339yield340finally:341fcntl.flock(handle.fileno(), fcntl.LOCK_UN)342handle.close()343344def _rebuild_reference_index(self) -> None:345"""Rebuild the reference index from the current segments."""346self._by_reference.clear()347for segment in self._segments.values():348ref_str = str(segment.reference)349self._by_reference.setdefault(ref_str, []).append(segment.id)350351def _quarantine_corrupt_file(self) -> Path:352"""Move an unreadable store aside so a later save cannot overwrite it.353354Returns:355The path the corrupt file was moved to.356"""357stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")358target = self._storage_path.with_name(359f"{self._storage_path.stem}.corrupt.{stamp}.json"360)361self._storage_path.replace(target)362return target363364def _read_disk_segments(self) -> dict[str, TMSegment]:365"""Read stored segments without disturbing in-memory state.366367A corrupt file is quarantined rather than ignored. Ignoring it meant368the next save silently replaced days of accumulated translation with369whatever the current process happened to hold.370371Returns:372The segments currently on disk, keyed by id.373"""374if not self._storage_path.exists():375return {}376377try:378with self._storage_path.open("r", encoding="utf-8") as f:379data = json.load(f)380if not isinstance(data, dict):381# Valid JSON of the wrong shape is just as unreadable; without382# this it crashed load/save with an AttributeError instead.383raise json.JSONDecodeError("not a JSON object", "", 0)384except json.JSONDecodeError:385quarantined = self._quarantine_corrupt_file()386logger.error(387"Translation memory file was unreadable; preserved at %s. "388"Starting from empty rather than overwriting it.",389quarantined,390)391return {}392393segments: dict[str, TMSegment] = {}394for seg_data in data.get("segments", []):395reference = VerseReference.from_string(seg_data["reference"])396segment = TMSegment(397id=seg_data["id"],398source_text=seg_data["source_text"],399source_normalized=seg_data["source_normalized"],400target_text=seg_data["target_text"],401reference=reference,402level=SegmentLevel(seg_data["level"]),403created_at=datetime.fromisoformat(seg_data["created_at"]),404reuse_count=seg_data.get("reuse_count", 0),405metadata=seg_data.get("metadata", {}),406)407segments[segment.id] = segment408return segments409410def load(self) -> None:411"""Load segments from the storage file."""412self._loaded = True413self._segments = self._read_disk_segments()414self._rebuild_reference_index()415416def reload(self) -> None:417"""Force reload from storage, discarding in-memory changes."""418self._loaded = False419self.load()420421def __len__(self) -> int:422"""Get the number of segments in memory."""423self._ensure_loaded()424return len(self._segments)425426def format_matches_for_prompt(427self, matches: list[TMMatch], max_display: int = 3428) -> str:429"""Format TM matches for inclusion in a translation prompt.430431Args:432matches: List of TM matches.433max_display: Maximum number of matches to display.434435Returns:436Formatted string for prompt inclusion.437"""438if not matches:439return "No similar translations found in memory."440441lines = ["Similar translations from memory:"]442for i, match in enumerate(matches[:max_display]):443similarity_pct = int(match.similarity * 100)444seg = match.segment445lines.append(446f" [{similarity_pct}% match] {seg.reference}: {seg.target_text}"447)448449if len(matches) > max_display:450lines.append(f" ... and {len(matches) - max_display} more")451452return "\n".join(lines)453