Files
epub_bilingual_translator/src/translation/llm_client.py
T

278 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, APIStatusError
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_not_exception_type
from loguru import logger
from src.common.data_model import ManifestEntry
from src.common.utils import setup_logger, ensure_directory
from src.common.exceptions import ProviderAccessError
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,
timeout: float = 180, max_output_tokens: int = 16000, temperature=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=timeout,
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,
timeout=timeout,
max_retries=0
)
self.model = model
self.max_output_tokens = max_output_tokens
self.temperature = temperature
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(Path(__file__).resolve().parents[2] / "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}")
raise
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 = []
title_notes = []
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()
if item.context and item.context.startswith('navigation-'):
title_notes.append(f"{short_id}: {item.context}")
lines.append(f"{short_id}: {text}")
if title_notes:
instructions = ("Title context (instructions only; do not copy into translations):\n"
"Translate Chapter N as 第N章, Part N as 第N部, and translate the title itself. "
"Keep number/subtitle styling tokens in their original regions.\n"
+ "\n".join(title_notes) + "\n\nSource units:\n")
else:
instructions = ""
return id_map, instructions + "\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 = {}
seen = set()
duplicates = set()
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.
if sub_match.group(1) != match.group(1):
content = ''
break
content = sub_match.group(2).strip()
else:
break
if not content:
continue
if short_id in id_map:
full_id = id_map[short_id]
if short_id in seen:
duplicates.add(full_id)
seen.add(short_id)
results[full_id] = content
else:
logger.warning(f"Unknown short ID in response: {short_id}")
for full_id in duplicates:
results.pop(full_id, None)
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),
retry=retry_if_not_exception_type(ProviderAccessError))
async def _make_request(self, system_prompt: str, user_prompt: str) -> str:
if getattr(self, '_access_error', None):
raise self._access_error
await self.rate_limiter.acquire()
try:
if getattr(self, '_access_error', None):
raise self._access_error
options = {"max_completion_tokens": self.max_output_tokens}
if self.temperature is not None:
options["temperature"] = self.temperature
resp = await self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
**options,
)
choice = resp.choices[0]
if choice.finish_reason != "stop" or not choice.message.content:
raise ValueError(f"Incomplete model response: {choice.finish_reason}")
return choice.message.content.strip()
except APIStatusError as exc:
if exc.status_code in {401, 402, 403}:
reason = 'quota exhausted' if exc.status_code == 402 else 'access denied'
self._access_error = ProviderAccessError(
f'API HTTP {exc.status_code}: {reason}; restore access and resume with the saved manifest.')
raise self._access_error from None
raise
finally:
self.rate_limiter.release()
async def close(self):
await self.client.close()