Release v0.13: Typography V3 (Safe mode) & Endnote Extraction Fix

This commit is contained in:
谭凯
2026-02-01 11:51:36 +08:00
parent 8e58415173
commit 40491e6386
95 changed files with 8493 additions and 11 deletions
+231
View File
@@ -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