Initial commit
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user