Files
epub_bilingual_translator/archive/v0.02/src/llm_client.py
T
2026-01-19 09:51:07 +08:00

156 lines
6.0 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.
"""
LLM 客户端模块 (LLM Client Module) - 简化 ID 锚点匹配版
该模块负责发送翻译请求,并使用简化后的 ID 作为锚点解析 LLM 的响应。
"""
import asyncio
from openai import AsyncOpenAI
from typing import List, Dict, Optional, Any
from loguru import logger
import time
import re
from .manifest_manager import ManifestItem
class RateLimiter:
"""并发与 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
async def acquire(self):
await self.semaphore.acquire()
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 OpenRouterClient:
"""基于简化 ID 锚点匹配逻辑的 LLM 客户端。"""
def __init__(self, config: Dict):
self.config = config
or_config = config["openrouter"]
api_key = or_config.get("api_key")
if not api_key or api_key == "YOUR_OPENROUTER_API_KEY":
raise ValueError("请设置有效的 OpenRouter API Key")
self.client = AsyncOpenAI(
base_url=or_config["base_url"],
api_key=api_key,
default_headers={"HTTP-Referer": "https://github.com/epub-translator", "X-Title": "EPUB Translator"}
)
self.models = or_config["models"]
self.rate_limiter = RateLimiter(
or_config["rate_limits"]["requests_per_minute"],
or_config["rate_limits"]["concurrent_requests"]
)
async def translate_chunk(self, items: List[ManifestItem], model_type: str = "production") -> Dict[str, str]:
"""
翻译一个段落块。
"""
if not items: return {}
prompt = self._build_prompt(items)
model = self.models.get(model_type, self.models["production"])
try:
raw_response = await self._make_request(prompt, model)
if not raw_response:
return {item.global_id: f"[翻译失败 - API空响应]" for item in items}
return self._parse_with_anchors(raw_response, items)
except Exception as e:
logger.error(f"翻译请求异常: {e}")
return {item.global_id: f"[翻译失败 - {str(e)}]" for item in items}
def _build_prompt(self, items: List[ManifestItem]) -> str:
lines = ["请将以下编号的英文段落翻译成中文。每段翻译前必须带上原编号,格式为 p_xxxxx。", "原文:", ""]
for item in items:
lines.append(f"{item.global_id} {item.clean_text}")
lines.extend(["", "要求:只返回翻译,不要解释。必须保留原编号。"])
return "\n".join(lines)
def _parse_with_anchors(self, response: str, items: List[ManifestItem]) -> Dict[str, str]:
results = {}
for i, item in enumerate(items):
current_id = item.global_id
# 使用简单的字符串拼接构造正则
start_pat = r"\b" + current_id + r"\b"
start_match = re.search(start_pat, response)
if not start_match:
continue
start_pos = start_match.start()
end_pos = len(response)
if i + 1 < len(items):
next_id = items[i+1].global_id
next_pat = r"\b" + next_id + r"\b"
next_match = re.search(next_pat, response[start_pos + 1:])
if next_match:
end_pos = start_pos + 1 + next_match.start()
segment = response[start_pos:end_pos].strip()
# 清洗 ID
# 注意:这里的正则使用了更稳健的拼接方式
id_clean_pat = r"^(\[?" + current_id + r"\]?[::\s]*)+"
cleaned = re.sub(id_clean_pat, "", segment).strip()
if cleaned:
results[current_id] = cleaned
if len(results) < len(items):
expected_ids = {item.global_id for item in items}
missing_ids = expected_ids - set(results.keys())
logger.warning(f"锚点匹配缺失 {len(missing_ids)} 个。尝试降级解析...")
fallback_results = self._fallback_parse(response, items)
for pid, trans in fallback_results.items():
if pid not in results:
results[pid] = trans
return results
def _fallback_parse(self, response: str, items: List[ManifestItem]) -> Dict[str, str]:
results = {}
lines = [l.strip() for l in response.split("\n") if l.strip()]
for line in lines:
for item in items:
if item.global_id in line:
# 同样的稳健拼接
fallback_clean_pat = r"^\(?" + item.global_id + r"\)?[::\s]*"
clean_line = re.sub(fallback_clean_pat, "", line).strip()
if clean_line:
results[item.global_id] = clean_line
return results
async def _make_request(self, prompt: str, model: str) -> str:
await self.rate_limiter.acquire()
try:
resp = await self.client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": "你是一位专业的翻译。请严格按编号输出翻译。"},
{"role": "user", "content": prompt}
],
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()