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
+156
View File
@@ -0,0 +1,156 @@
"""
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()