Release v0.13: Typography V3 (Safe mode) & Endnote Extraction Fix
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
from openai import AsyncOpenAI
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||
from loguru import logger
|
||||
|
||||
from src.common.data_model import ManifestEntry
|
||||
from src.common.utils import setup_logger, ensure_directory
|
||||
|
||||
logger = setup_logger("llm_client")
|
||||
|
||||
# Default chunk save directory (can be overridden)
|
||||
DEFAULT_CHUNK_DIR = Path("tmp/chunks")
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Rate limiter for concurrency and RPM."""
|
||||
def __init__(self, requests_per_minute: int, concurrent_requests: int):
|
||||
self.semaphore = asyncio.Semaphore(concurrent_requests)
|
||||
self.min_interval = 60.0 / requests_per_minute if requests_per_minute > 0 else 0
|
||||
self.last_request_time = 0
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def acquire(self):
|
||||
await self.semaphore.acquire()
|
||||
async with self._lock:
|
||||
current_time = time.time()
|
||||
wait_time = self.min_interval - (current_time - self.last_request_time)
|
||||
if wait_time > 0:
|
||||
await asyncio.sleep(wait_time)
|
||||
self.last_request_time = time.time()
|
||||
|
||||
def release(self):
|
||||
self.semaphore.release()
|
||||
|
||||
|
||||
class LLMClient:
|
||||
"""Generic OpenAI-compatible API Client with short ID strategy."""
|
||||
|
||||
def __init__(self, api_key: str, base_url: str, model: str = "gpt-3.5-turbo",
|
||||
requests_per_minute: int = 60, concurrent_requests: int = 5,
|
||||
extra_headers: Dict = None, chunk_dir: Path = None):
|
||||
|
||||
# Configure proxy client to avoid SOCKS issues and ensure connectivity
|
||||
import httpx
|
||||
import os
|
||||
|
||||
# Prefer HTTP proxy if available to avoid missing socksio support
|
||||
proxy_url = os.environ.get("http_proxy") or os.environ.get("https_proxy")
|
||||
http_client = httpx.AsyncClient(
|
||||
proxy=proxy_url,
|
||||
timeout=60.0,
|
||||
follow_redirects=True
|
||||
) if proxy_url else None
|
||||
|
||||
self.client = AsyncOpenAI(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
default_headers=extra_headers,
|
||||
http_client=http_client
|
||||
)
|
||||
self.model = model
|
||||
|
||||
self.rate_limiter = RateLimiter(requests_per_minute, concurrent_requests)
|
||||
self.prompts = self._load_prompts()
|
||||
self._chunk_counter = 0
|
||||
|
||||
# Chunk directory for debug output
|
||||
self.chunk_dir = chunk_dir or DEFAULT_CHUNK_DIR
|
||||
ensure_directory(self.chunk_dir)
|
||||
|
||||
def _load_prompts(self) -> Dict:
|
||||
try:
|
||||
with open("config/prompts.json", "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load config/prompts.json: {e}")
|
||||
return {}
|
||||
|
||||
async def translate_chunk(self, items: List[ManifestEntry], glossary: Dict = None,
|
||||
instruction: str = None, mode: str = "bilingual") -> Dict[str, str]:
|
||||
"""
|
||||
Translate a chunk of items using short ID strategy.
|
||||
Returns: Dict[entry_id, translated_text]
|
||||
"""
|
||||
if not items: return {}
|
||||
|
||||
# Build prompt with short IDs
|
||||
id_map, prompt = self._build_prompt_with_short_ids(items)
|
||||
|
||||
try:
|
||||
# Build System Prompt
|
||||
base_sys_prompt = self.prompts.get("translation", {}).get("system",
|
||||
"You are a professional English to Chinese translator.")
|
||||
|
||||
if instruction:
|
||||
base_sys_prompt += f"\n\nBook Style Guide:\n{instruction}"
|
||||
|
||||
if glossary:
|
||||
glossary_text = "\n".join([f"{k} -> {v}" for k, v in glossary.items()])
|
||||
base_sys_prompt += f"\n\nTerminology:\n{glossary_text}"
|
||||
|
||||
# Short ID format instructions
|
||||
base_sys_prompt += """
|
||||
|
||||
Output Format:
|
||||
- Each line MUST start with #N: (keep this ID exactly as given)
|
||||
- Preserve any φXφ or φ/Xφ placeholders EXACTLY as-is
|
||||
- Only output translations, no explanations
|
||||
- Match the number of output lines to input lines"""
|
||||
|
||||
# Save chunk before translation
|
||||
chunk_id = self._save_chunk("before", prompt, base_sys_prompt)
|
||||
|
||||
logger.debug(f"Sending request to LLM (Chunk: {chunk_id}, Items: {len(items)})")
|
||||
|
||||
raw_response = await self._make_request(base_sys_prompt, prompt)
|
||||
|
||||
# Save chunk after translation
|
||||
self._save_chunk("after", raw_response, base_sys_prompt, chunk_id)
|
||||
|
||||
# Parse with short ID mapping
|
||||
results = self._parse_short_id_response(raw_response, id_map)
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Translation failed: {e}")
|
||||
return {item.entry_id: f"[Error - {str(e)}]" for item in items}
|
||||
|
||||
def _build_prompt_with_short_ids(self, items: List[ManifestEntry]) -> tuple:
|
||||
"""
|
||||
Build prompt with short IDs (#1, #2, ...).
|
||||
Returns: (id_map, prompt_text)
|
||||
"""
|
||||
id_map = {} # short_id -> entry_id
|
||||
lines = []
|
||||
|
||||
for i, item in enumerate(items, 1):
|
||||
short_id = f"#{i}"
|
||||
id_map[short_id] = item.entry_id
|
||||
|
||||
# Clean text (remove extra whitespace)
|
||||
text = re.sub(r'\s+', ' ', item.original_text).strip()
|
||||
lines.append(f"{short_id}: {text}")
|
||||
|
||||
return id_map, "\n".join(lines)
|
||||
|
||||
def _parse_short_id_response(self, response: str, id_map: Dict[str, str]) -> Dict[str, str]:
|
||||
"""
|
||||
Parse response with short ID format.
|
||||
Returns: Dict[entry_id, translated_text]
|
||||
"""
|
||||
results = {}
|
||||
|
||||
for line in response.split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Match #N: pattern
|
||||
# Handles both ASCII colon (:) and full-width colon (:)
|
||||
match = re.match(r'^#(\d+)[::]\s*(.+)$', line)
|
||||
if match:
|
||||
short_id = f"#{match.group(1)}"
|
||||
content = match.group(2).strip()
|
||||
|
||||
# Recursively strip repeated IDs (e.g. "#12: #12: Text")
|
||||
while True:
|
||||
sub_match = re.match(r'^#(\d+)[::]\s*(.+)$', content)
|
||||
if sub_match:
|
||||
# Check if the inner ID matches the outer ID, or just strip it anyway
|
||||
# Usually LLM repeats the same ID.
|
||||
content = sub_match.group(2).strip()
|
||||
else:
|
||||
break
|
||||
|
||||
if short_id in id_map:
|
||||
full_id = id_map[short_id]
|
||||
results[full_id] = content
|
||||
else:
|
||||
logger.warning(f"Unknown short ID in response: {short_id}")
|
||||
|
||||
return results
|
||||
|
||||
def _save_chunk(self, stage: str, content: str, system_prompt: str = None,
|
||||
chunk_id: str = None) -> str:
|
||||
"""Save chunk to tmp directory for debugging."""
|
||||
if chunk_id is None:
|
||||
self._chunk_counter += 1
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
chunk_id = f"{timestamp}_{self._chunk_counter:04d}"
|
||||
|
||||
filename = self.chunk_dir / f"chunk_{chunk_id}_{stage}.txt"
|
||||
|
||||
with open(filename, "w", encoding="utf-8") as f:
|
||||
if system_prompt and stage == "before":
|
||||
f.write("=== SYSTEM PROMPT ===\n")
|
||||
f.write(system_prompt)
|
||||
f.write("\n\n=== USER PROMPT ===\n")
|
||||
f.write(content)
|
||||
|
||||
logger.debug(f"Saved chunk: {filename}")
|
||||
return chunk_id
|
||||
|
||||
async def raw_chat_completion(self, system_prompt: str, user_prompt: str) -> str:
|
||||
"""Generic chat completion."""
|
||||
return await self._make_request(system_prompt, user_prompt)
|
||||
|
||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
|
||||
async def _make_request(self, system_prompt: str, user_prompt: str) -> str:
|
||||
await self.rate_limiter.acquire()
|
||||
try:
|
||||
resp = await self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt}
|
||||
],
|
||||
temperature=0.3,
|
||||
)
|
||||
return resp.choices[0].message.content.strip()
|
||||
finally:
|
||||
self.rate_limiter.release()
|
||||
|
||||
async def close(self):
|
||||
await self.client.close()
|
||||
@@ -0,0 +1,74 @@
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
import json
|
||||
from src.common.data_model import ManifestEntry
|
||||
from src.common.utils import setup_logger
|
||||
|
||||
logger = setup_logger("manifest_manager")
|
||||
|
||||
class ManifestManager:
|
||||
"""
|
||||
Manages the translation manifest (Source of Truth).
|
||||
Handles persistence and state updates.
|
||||
"""
|
||||
|
||||
def __init__(self, manifest_path: Path):
|
||||
self.manifest_path = manifest_path
|
||||
self.entries: List[ManifestEntry] = []
|
||||
self._entries_map: Dict[str, ManifestEntry] = {}
|
||||
|
||||
def load(self):
|
||||
"""Loads manifest from disk if it exists."""
|
||||
if not self.manifest_path.exists():
|
||||
logger.info(f"Manifest not found at {self.manifest_path}, starting empty.")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.manifest_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
self.entries = [ManifestEntry.model_validate(item) for item in data]
|
||||
self._rebuild_map()
|
||||
logger.info(f"Loaded {len(self.entries)} entries from manifest.")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load manifest: {e}")
|
||||
raise
|
||||
|
||||
def save(self):
|
||||
"""Saves current state to disk."""
|
||||
try:
|
||||
# Pydantic v2: model_dump(mode='json') or just list dump
|
||||
data = [entry.model_dump(mode='json') for entry in self.entries]
|
||||
with open(self.manifest_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
logger.info(f"Saved {len(self.entries)} entries to manifest.")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save manifest: {e}")
|
||||
raise
|
||||
|
||||
def add_entries(self, new_entries: List[ManifestEntry]):
|
||||
"""
|
||||
Adds new entries to the manifest.
|
||||
If an entry with the same ID exists, it keeps the EXISTING one (to preserve translations).
|
||||
"""
|
||||
count = 0
|
||||
for entry in new_entries:
|
||||
if entry.entry_id not in self._entries_map:
|
||||
self.entries.append(entry)
|
||||
self._entries_map[entry.entry_id] = entry
|
||||
count += 1
|
||||
|
||||
if count > 0:
|
||||
logger.info(f"Added {count} new entries to manifest.")
|
||||
|
||||
def update_translation(self, entry_id: str, translation: str):
|
||||
"""Updates translation for a specific entry."""
|
||||
if entry_id in self._entries_map:
|
||||
self._entries_map[entry_id].translated_text = translation
|
||||
else:
|
||||
logger.warning(f"Attempted to update translation for unknown ID: {entry_id}")
|
||||
|
||||
def get_entry(self, entry_id: str) -> Optional[ManifestEntry]:
|
||||
return self._entries_map.get(entry_id)
|
||||
|
||||
def _rebuild_map(self):
|
||||
self._entries_map = {e.entry_id: e for e in self.entries}
|
||||
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
Translator Module - Handles translation of ManifestEntry items.
|
||||
|
||||
Key features:
|
||||
- Character-based chunking (~5000 chars per chunk)
|
||||
- Chapter-aware grouping (chunks don't cross file boundaries)
|
||||
- Concurrent translation with asyncio.gather
|
||||
- Progress tracking and error handling
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import List, Dict
|
||||
from collections import defaultdict
|
||||
from loguru import logger
|
||||
|
||||
from src.common.data_model import ManifestEntry, BookProfile
|
||||
from src.translation.llm_client import LLMClient
|
||||
from src.assembly.format_restorer import FormatRestorer
|
||||
from src.common.utils import setup_logger
|
||||
|
||||
logger = setup_logger("translator")
|
||||
|
||||
# Default chunk size in characters
|
||||
DEFAULT_CHUNK_SIZE = 5000
|
||||
# Maximum concurrent translations
|
||||
MAX_CONCURRENT = 5
|
||||
|
||||
|
||||
class Translator:
|
||||
"""
|
||||
Translates ManifestEntry items using LLM with chapter-aware chunking.
|
||||
Supports both sequential and concurrent translation modes.
|
||||
"""
|
||||
|
||||
def __init__(self, llm_client: LLMClient, chunk_size: int = DEFAULT_CHUNK_SIZE,
|
||||
max_concurrent: int = MAX_CONCURRENT):
|
||||
self.llm_client = llm_client
|
||||
self.chunk_size = chunk_size
|
||||
self.max_concurrent = max_concurrent
|
||||
self.restorer = FormatRestorer()
|
||||
|
||||
async def translate(self, entries: List[ManifestEntry], profile: BookProfile,
|
||||
concurrent: bool = True) -> List[ManifestEntry]:
|
||||
"""
|
||||
Translates all untranslated entries.
|
||||
|
||||
Args:
|
||||
entries: All manifest entries
|
||||
profile: Book profile with style guide
|
||||
concurrent: Use concurrent translation (default True)
|
||||
|
||||
Returns:
|
||||
The same entries list with translated_text populated
|
||||
"""
|
||||
untranslated = [e for e in entries if not e.translated_text]
|
||||
if not untranslated:
|
||||
logger.info("No new entries to translate.")
|
||||
return entries
|
||||
|
||||
logger.info(f"Found {len(untranslated)} entries to translate")
|
||||
|
||||
# Group by chapter (file_path)
|
||||
chapters = self._group_by_chapter(untranslated)
|
||||
logger.info(f"Grouped into {len(chapters)} chapters")
|
||||
|
||||
# Create all chunks
|
||||
all_chunks = []
|
||||
for file_path, chapter_entries in chapters.items():
|
||||
chapter_chunks = self._create_char_based_chunks(chapter_entries)
|
||||
for chunk in chapter_chunks:
|
||||
all_chunks.append((file_path, chunk))
|
||||
|
||||
total_chunks = len(all_chunks)
|
||||
logger.info(f"Created {total_chunks} chunks (avg ~{self.chunk_size} chars each)")
|
||||
|
||||
if concurrent:
|
||||
await self._translate_concurrent(all_chunks, profile)
|
||||
else:
|
||||
await self._translate_sequential(all_chunks, profile)
|
||||
|
||||
translated_count = sum(1 for e in entries if e.translated_text)
|
||||
logger.info(f"Translation complete: {translated_count}/{len(entries)} entries translated")
|
||||
return entries
|
||||
|
||||
async def _translate_concurrent(self, all_chunks: List, profile: BookProfile):
|
||||
"""Translate chunks concurrently with semaphore control."""
|
||||
semaphore = asyncio.Semaphore(self.max_concurrent)
|
||||
completed = [0] # Use list for mutable counter in closure
|
||||
total = len(all_chunks)
|
||||
success = [0]
|
||||
failed = [0]
|
||||
|
||||
async def translate_chunk_task(file_path: str, chunk: List[ManifestEntry], idx: int):
|
||||
async with semaphore:
|
||||
try:
|
||||
results = await self.llm_client.translate_chunk(
|
||||
chunk,
|
||||
instruction=profile.style_guide if profile else None,
|
||||
mode="bilingual"
|
||||
)
|
||||
|
||||
for entry in chunk:
|
||||
if entry.entry_id in results:
|
||||
entry.translated_text = results[entry.entry_id]
|
||||
success[0] += 1
|
||||
else:
|
||||
failed[0] += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Chunk {idx} failed: {e}")
|
||||
failed[0] += len(chunk)
|
||||
finally:
|
||||
completed[0] += 1
|
||||
if completed[0] % 5 == 0 or completed[0] == total:
|
||||
logger.info(f"Progress: {completed[0]}/{total} chunks ({success[0]} translated)")
|
||||
|
||||
tasks = [
|
||||
translate_chunk_task(file_path, chunk, i)
|
||||
for i, (file_path, chunk) in enumerate(all_chunks)
|
||||
]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
logger.info(f"Concurrent translation: {success[0]} success, {failed[0]} failed")
|
||||
|
||||
async def _translate_sequential(self, all_chunks: List, profile: BookProfile):
|
||||
"""Translate chunks sequentially."""
|
||||
total_chunks = len(all_chunks)
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
|
||||
for idx, (file_path, chunk) in enumerate(all_chunks):
|
||||
try:
|
||||
results = await self.llm_client.translate_chunk(
|
||||
chunk,
|
||||
instruction=profile.style_guide if profile else None,
|
||||
mode="bilingual"
|
||||
)
|
||||
|
||||
for entry in chunk:
|
||||
if entry.entry_id in results:
|
||||
entry.translated_text = results[entry.entry_id]
|
||||
success_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
logger.warning(f"Missing translation for: {entry.entry_id[-40:]}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Chunk {idx} translation failed: {e}")
|
||||
fail_count += len(chunk)
|
||||
|
||||
if (idx + 1) % 10 == 0 or idx + 1 == total_chunks:
|
||||
logger.info(f"Progress: {idx + 1}/{total_chunks} chunks ({success_count} entries translated)")
|
||||
|
||||
logger.info(f"Sequential translation: {success_count} success, {fail_count} failed")
|
||||
|
||||
|
||||
async def translate_chapter(self, entries: List[ManifestEntry], file_path: str,
|
||||
profile: BookProfile) -> Dict[str, int]:
|
||||
"""
|
||||
Translate a single chapter.
|
||||
|
||||
Args:
|
||||
entries: All entries (will filter by file_path)
|
||||
file_path: Chapter file path to translate
|
||||
profile: Book profile
|
||||
|
||||
Returns:
|
||||
Dict with 'success' and 'failed' counts
|
||||
"""
|
||||
chapter_entries = [e for e in entries if e.file_path == file_path and not e.translated_text]
|
||||
|
||||
if not chapter_entries:
|
||||
logger.info(f"Chapter {file_path} has no untranslated entries")
|
||||
return {"success": 0, "failed": 0}
|
||||
|
||||
logger.info(f"Translating chapter: {file_path} ({len(chapter_entries)} entries)")
|
||||
|
||||
chunks = self._create_char_based_chunks(chapter_entries)
|
||||
logger.info(f"Created {len(chunks)} chunks")
|
||||
|
||||
success = 0
|
||||
failed = 0
|
||||
|
||||
for i, chunk in enumerate(chunks, 1):
|
||||
chunk_chars = sum(len(e.original_text) for e in chunk)
|
||||
logger.debug(f"Chunk {i}/{len(chunks)}: {len(chunk)} entries, {chunk_chars} chars")
|
||||
|
||||
try:
|
||||
results = await self.llm_client.translate_chunk(
|
||||
chunk,
|
||||
instruction=profile.style_guide if profile else None,
|
||||
mode="bilingual"
|
||||
)
|
||||
|
||||
for entry in chunk:
|
||||
if entry.entry_id in results:
|
||||
entry.translated_text = results[entry.entry_id]
|
||||
|
||||
# Verify placeholder preservation
|
||||
if entry.placeholders:
|
||||
_, restored_ok = self.restorer.restore(
|
||||
entry.translated_text,
|
||||
entry.placeholders,
|
||||
context_id=entry.entry_id
|
||||
)
|
||||
if not restored_ok:
|
||||
logger.warning(f"Placeholder issue: {entry.entry_id[-40:]}")
|
||||
|
||||
success += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Chunk {i} failed: {e}")
|
||||
failed += len(chunk)
|
||||
|
||||
logger.info(f"Chapter done: {success} success, {failed} failed")
|
||||
return {"success": success, "failed": failed}
|
||||
|
||||
def _group_by_chapter(self, entries: List[ManifestEntry]) -> Dict[str, List[ManifestEntry]]:
|
||||
"""Group entries by file_path (chapter)."""
|
||||
chapters = defaultdict(list)
|
||||
for entry in entries:
|
||||
chapters[entry.file_path].append(entry)
|
||||
return dict(chapters)
|
||||
|
||||
def _create_char_based_chunks(self, entries: List[ManifestEntry]) -> List[List[ManifestEntry]]:
|
||||
"""
|
||||
Create chunks based on character count.
|
||||
|
||||
Each chunk contains approximately self.chunk_size characters.
|
||||
Chunks never cross chapter boundaries (entries from same file only).
|
||||
"""
|
||||
chunks = []
|
||||
current_chunk = []
|
||||
current_size = 0
|
||||
|
||||
for entry in entries:
|
||||
text_len = len(entry.original_text)
|
||||
|
||||
# If adding this entry exceeds limit and we have content, start new chunk
|
||||
if current_size + text_len > self.chunk_size and current_chunk:
|
||||
chunks.append(current_chunk)
|
||||
current_chunk = []
|
||||
current_size = 0
|
||||
|
||||
current_chunk.append(entry)
|
||||
current_size += text_len
|
||||
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk)
|
||||
|
||||
return chunks
|
||||
|
||||
def get_chapter_stats(self, entries: List[ManifestEntry]) -> List[Dict]:
|
||||
"""
|
||||
Get statistics for each chapter.
|
||||
|
||||
Returns list of dicts with: file_path, total, translated, pending, chars
|
||||
"""
|
||||
chapters = self._group_by_chapter(entries)
|
||||
stats = []
|
||||
|
||||
for file_path, chapter_entries in sorted(chapters.items()):
|
||||
total = len(chapter_entries)
|
||||
translated = sum(1 for e in chapter_entries if e.translated_text)
|
||||
total_chars = sum(len(e.original_text) for e in chapter_entries)
|
||||
|
||||
# Get first text as title preview
|
||||
first_text = ""
|
||||
for e in chapter_entries:
|
||||
if e.original_text:
|
||||
first_text = e.original_text[:40].replace('\n', ' ')
|
||||
break
|
||||
|
||||
stats.append({
|
||||
"file_path": file_path,
|
||||
"title": first_text,
|
||||
"total": total,
|
||||
"translated": translated,
|
||||
"pending": total - translated,
|
||||
"chars": total_chars
|
||||
})
|
||||
|
||||
return stats
|
||||
|
||||
Reference in New Issue
Block a user