Initial commit

This commit is contained in:
谭凯
2026-01-19 09:51:07 +08:00
commit 9ef82393be
174 changed files with 22285 additions and 0 deletions
+241
View File
@@ -0,0 +1,241 @@
"""
LLM Client Module - Generic OpenAI Compatible
Features:
1. Fully configurable via config.json (base_url, headers).
2. Mode-aware prompt building (bilingual vs chinese).
3. Format repair capability for chinese mode.
"""
import asyncio
import json
import re
from openai import AsyncOpenAI
from typing import List, Dict, Optional, Any
from loguru import logger
import time
from tenacity import retry, stop_after_attempt, wait_exponential
from .manifest_manager import ManifestItem
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."""
def __init__(self, config: Dict):
self.config = config
llm_config = config["llm"]
api_key = llm_config.get("api_key")
base_url = llm_config.get("base_url")
extra_headers = llm_config.get("extra_headers", {})
if not api_key:
raise ValueError("API Key is missing in config")
self.client = AsyncOpenAI(
base_url=base_url,
api_key=api_key,
default_headers=extra_headers
)
self.models = llm_config.get("models", {"fast": "gpt-3.5-turbo", "smart": "gpt-4"})
self.rate_limiter = RateLimiter(
llm_config["rate_limits"]["requests_per_minute"],
llm_config["rate_limits"]["concurrent_requests"]
)
self.prompts = self._load_prompts()
def _load_prompts(self) -> Dict:
try:
with open("config/prompts.json", "r", encoding="utf-8") as f:
return json.load(f)
except:
return {}
async def translate_chunk(self, items: List[ManifestItem], glossary: Dict = None,
instruction: str = None, model_type: str = "fast",
mode: str = "bilingual") -> Dict[str, str]:
"""
Translate a chunk of items.
Args:
items: List of ManifestItem to translate
glossary: Term dictionary
instruction: Style guide
model_type: "fast" or "smart"
mode: "bilingual" or "chinese"
"""
if not items: return {}
model = self.models.get(model_type, self.models.get("fast"))
prompt = self._build_prompt(items, mode)
try:
# Build System Prompt
base_sys_prompt = self.prompts.get("translation", {}).get("system", "You are a professional translator.")
# 中文模式:添加占位符保护指令
if mode == "chinese":
base_sys_prompt += """
Placeholder Instructions (CRITICAL):
1. Text contains PAIRED placeholders: φNφ (start) and φ/Nφ (end), like HTML tags.
2. Example: "φ1φTable Talkφ/1φ" means italic text, translate as "φ1φ桌谈φ/1φ"
3. Single placeholders φNφ without φ/Nφ are inline elements (footnotes, formulas) - keep them in place.
4. RULES:
- DO NOT create new placeholder numbers that don't exist in the original
- DO NOT remove or modify existing placeholders
- Keep placeholders in the SAME relative position in your translation
- If word order changes, keep placeholders with their associated text
5. Each line starts with paragraph ID (p_xxxxx). Preserve them.
"""
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}"
# Strict formatting instructions
base_sys_prompt += "\n\nRequirements:\n1. Each line MUST start with ID (p_xxxxx).\n2. DO NOT modify IDs.\n3. Return only translations."
raw_response = await self._make_request(model, base_sys_prompt, prompt)
if not raw_response:
return {item.global_id: f"[Error - Empty Response]" for item in items}
return self._simple_parse(raw_response, items, mode)
except Exception as e:
logger.error(f"Translation failed ({model}): {e}")
return {item.global_id: f"[Error - {str(e)}]" for item in items}
async def repair_format(self, original_text: str, broken_translation: str) -> str:
"""
修复翻译格式:将占位符正确插入到译文中。
"""
model = self.models.get("fast")
system_prompt = "You are a format repair assistant. Your ONLY job is to insert placeholders into the translation."
user_prompt = f"""
Original Text (with placeholders):
{original_text}
Translation (placeholders missing/incorrect):
{broken_translation}
Task:
Please rewrite the Translation to include ALL placeholders (φcXXXXXφ) from the Original Text.
1. DO NOT translate again. Keep the meaning of the Translation.
2. Place φcXXXXXφ tags exactly where they correspond to the original format (bold, italic, links).
3. Output ONLY the fixed translation.
"""
try:
return await self._make_request(model, system_prompt, user_prompt)
except Exception as e:
logger.error(f"Format repair failed: {e}")
return broken_translation
async def raw_chat_completion(self, system_prompt: str, user_prompt: str, model_type: str = "smart") -> str:
"""Generic chat completion (for Profiler)."""
model = self.models.get(model_type, self.models.get("smart"))
return await self._make_request(model, system_prompt, user_prompt)
def _build_prompt(self, items: List[ManifestItem], mode: str = "bilingual") -> str:
"""构建翻译提示词"""
lines = []
for item in items:
if mode == "chinese":
# 中文模式:使用带占位符的文本和段落类型
text = item.text_with_placeholders if item.text_with_placeholders else item.clean_text
p_type = getattr(item, 'paragraph_type', 'body').upper()
lines.append(f"{item.global_id} [{p_type}] {text}")
else:
# 双语模式:使用纯文本
lines.append(f"{item.global_id} {item.clean_text}")
return "\n".join(lines)
def _simple_parse(self, response: str, items: List[ManifestItem], mode: str = "bilingual") -> Dict[str, str]:
"""解析 LLM 响应"""
results = {}
for i, item in enumerate(items):
current_id = item.global_id
start_idx = response.find(current_id)
if start_idx == -1: continue
end_idx = len(response)
if i + 1 < len(items):
next_id = items[i+1].global_id
next_found = response.find(next_id, start_idx + len(current_id))
if next_found != -1:
end_idx = next_found
content = response[start_idx:end_idx].strip()
clean_content = content[len(current_id):].strip()
clean_content = clean_content.lstrip(": \t")
# 移除类型标记 (如 [BODY])
if mode == "chinese":
clean_content = re.sub(r'^\[[A-Z]+\]\s*', '', clean_content)
if clean_content:
results[current_id] = clean_content
# Fallback: 逐行解析
if len(results) < len(items):
for line in response.split("\n"):
line = line.strip()
for item in items:
if item.global_id not in results and line.startswith(item.global_id):
res = line[len(item.global_id):].strip().lstrip(": ")
if mode == "chinese":
res = re.sub(r'^\[[A-Z]+\]\s*', '', res)
if res: results[item.global_id] = res
return results
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
async def _make_request(self, model: str, system_prompt: str, user_prompt: str) -> str:
await self.rate_limiter.acquire()
try:
resp = await self.client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
temperature=self.config['translation'].get('temperature', 0.2),
max_tokens=8000
)
return resp.choices[0].message.content.strip()
finally:
self.rate_limiter.release()
async def close(self):
await self.client.close()
# Alias for backward compatibility
OpenRouterClient = LLMClient