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()