diff --git a/.env.example b/.env.example index 0e9c7e8..6c91411 100644 --- a/.env.example +++ b/.env.example @@ -36,11 +36,21 @@ TENCENT_TMT_QUOTA_BUFFER=0.05 TENCENT_TMT_MAX_CHARS_PER_REQ=4500 # ===== 本地翻译(降级) ===== -# 不启用就留空:不会用本地模型 -LOCAL_TRANSLATE_ENABLED=false +# 不启用就留空:不会用本地模�?LOCAL_TRANSLATE_ENABLED=false LOCAL_TRANSLATE_MODEL=nllb-200-distilled-600M LOCAL_TRANSLATE_DEVICE=cpu +# ===== 腾讯 MaaS 翻译(备用通道,OpenAI 兼容协议)===== +# 申请:https://console.cloud.tencent.com/maas 或 hivoice 控制台 +# 留空 api_key = 不启用(只在 TMT 配额耗尽/TMT 失败时启用) +# 端点固定为 https://maas-api.hivoice.cn/v1(腾讯 MaaS 翻译服务) +# 模型:u2(翻译专用,支持多语种) +TENCENT_MAAS_API_KEY= +TENCENT_MAAS_BASE_URL=https://maas-api.hivoice.cn/v1 +TENCENT_MAAS_MODEL=u2 +# 单次调用间隔(秒),避免被 MaaS 限流 +TENCENT_MAAS_INTERVAL_SEC=1.0 + # ===== 抓取 ===== # 全局 QPS 上限 FETCH_GLOBAL_QPS=4 diff --git a/backend/app/config.py b/backend/app/config.py index a1c3a77..9dd4ccc 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -82,6 +82,15 @@ class Settings(BaseSettings): local_translate_model: str = "nllb-200-distilled-600M" local_translate_device: str = "cpu" + # ===== 腾讯 MaaS(OpenAI 兼容翻译备用通道)===== + # 用法:腾讯云 MaaS 提供的翻译模型,通过 OpenAI 协议调用 + # 留空 api_key = 不启用该 provider + tencent_maas_api_key: str = "" + tencent_maas_base_url: str = "https://maas-api.hivoice.cn/v1" + tencent_maas_model: str = "u2" + # 每篇调用间隔(秒),与 LLM 客户端解耦 + tencent_maas_interval_sec: float = 1.0 + # ===== 抓取 ===== fetch_global_qps: int = 4 fetch_timeout: int = 20 diff --git a/backend/app/services/translation/service.py b/backend/app/services/translation/service.py index 130f295..b0790d0 100644 --- a/backend/app/services/translation/service.py +++ b/backend/app/services/translation/service.py @@ -1,4 +1,10 @@ -"""翻译服务门面:配额检查 + 缓存 + 引擎选择 + 月度计数。""" +"""翻译服务门面:配额检查 + 缓存 + 引擎选择 + 月度计数。 + +引擎链路(优先级降序): +1. tencent TMT(主,按月配额) +2. tencent_maas(备用,OpenAI 兼容,无配额;主失败/TMT 配额耗尽时启用) +3. local(最后兜底,需 settings.local_translate_enabled=true) +""" from __future__ import annotations import asyncio @@ -12,6 +18,7 @@ from app.redis_client import get_redis from app.services.translation.base import BaseTranslator, TranslationResult from app.services.translation.local import LocalTranslator from app.services.translation.tencent import TencentTranslator +from app.services.translation.tencent_maas import TencentMaaSTranslator logger = logging.getLogger("news.translate.service") @@ -30,16 +37,33 @@ def _month_key() -> str: class TranslationService: def __init__(self): self._tencent: BaseTranslator | None = None + self._tencent_maas: BaseTranslator | None = None self._local: BaseTranslator | None = None # 串行:1 个并发;避免触发腾讯 TMT 限速 self._sem = asyncio.Semaphore(1) - def _primary(self) -> BaseTranslator: + def _primary(self) -> BaseTranslator | None: + """主引擎:腾讯 TMT(初始化失败返回 None 表示不可用)。""" if self._tencent is None: - self._tencent = TencentTranslator() + try: + self._tencent = TencentTranslator() + except Exception as e: + logger.warning("tencent TMT init failed: %s", e) + self._tencent = None return self._tencent - def _fallback(self) -> BaseTranslator | None: + def _maas(self) -> BaseTranslator | None: + """备用引擎:腾讯 MaaS(OpenAI 兼容,无配额)。""" + if self._tencent_maas is None and settings.tencent_maas_api_key: + try: + self._tencent_maas = TencentMaaSTranslator() + except Exception as e: + logger.warning("tencent MaaS init failed: %s", e) + self._tencent_maas = None + return self._tencent_maas + + def _local_translator(self) -> BaseTranslator | None: + """最后兜底:本地模型(需开关)。""" if self._local is None and settings.local_translate_enabled: try: self._local = LocalTranslator() @@ -48,6 +72,13 @@ class TranslationService: self._local = None return self._local + # 兼容旧调用点:返回第一个可用的 fallback(优先 maas,次 local) + def _fallback(self) -> BaseTranslator | None: + m = self._maas() + if m is not None: + return m + return self._local_translator() + async def can_use_tencent(self, chars: int) -> bool: if not settings.tencentcloud_secret_id: return False @@ -87,52 +118,62 @@ class TranslationService: if cached is not None: return TranslationResult(text=cached, engine="cache", chars=chars, cached=True) - # 2) 选引擎 + # 2) 选引擎(主 → maas 备用 → local 兜底) use_tencent = await self.can_use_tencent(chars) - engine: BaseTranslator if use_tencent: - engine = self._primary() + engine: BaseTranslator | None = self._primary() + if engine is None: + # TMT 配了 key 但初始化失败 → 直接走 maas + logger.warning("TMT unavailable, falling back to MaaS") + engine = self._maas() else: - fb = self._fallback() - if fb is None: - # 没本地:返回原文 + 标记 + engine = None + + if engine is None: + # 配额耗尽 / TMT 不可用:走备用链 + engine = self._maas() or self._local_translator() + if engine is None: + # 全无可用:返回原文 + 标记 return TranslationResult( - text=text + "\n\n[本条未翻译:配额耗尽且未启用本地翻译]", + text=text + "\n\n[本条未翻译:所有翻译通道不可用]", engine="skip", chars=chars, ) - engine = fb - logger.info("fallback to local translator for %d chars", chars) + if engine.name == "tencent_maas": + logger.info("tencent quota exhausted, fallback to tencent_maas for %d chars", chars) + else: + logger.info("fallback to local translator for %d chars", chars) - # 3) 调用 + # 3) 调用(失败时降级) async with self._sem: - res = None + res: TranslationResult | None = None try: res = await engine.translate(text, source=source, target=target) except Exception as e: - # 失败:降级 logger.exception("translate failed with %s: %s", engine.name, e) - fb = self._fallback() - if fb is not None and engine is not fb: + # 按 maas → local 顺序找一个不同的 fallback + fb = self._maas() if engine.name != "tencent_maas" else None + if fb is None and settings.local_translate_enabled and engine.name != "local": + fb = self._local_translator() + if fb is not None: try: res = await fb.translate(text, source=source, target=target) + logger.info("fallback %s succeeded after %s failed", fb.name, engine.name) except Exception as e2: logger.exception("fallback %s also failed: %s", fb.name, e2) res = None if res is None: - # 主 + fallback 都失败:抛异常,让上层标记 status=failed raise RuntimeError(f"translation failed for {chars} chars (engine={engine.name})") # 4) 写缓存 — 只缓存真实翻译结果;失败/降级文本不缓存(避免污染 30 天) - if res.engine in ("tencent", "nllb", "cache") and not res.cached: - # 二次保险:如果文本里仍含错误标记,也不缓存 + if res.engine in ("tencent", "tencent_maas", "nllb") and not res.cached: if "[翻译失败" not in res.text and "[本条未翻译" not in res.text: try: await r.set(ck, res.text, ex=60 * 60 * 24 * 30) # 30 天 except Exception: pass - # 5) 计数(只在 tencent 上计) + # 5) 计数(只在 tencent TMT 上计;maas/local 都不计腾讯云配额) if res.engine == "tencent": try: await self.add_usage(res.chars or chars) diff --git a/backend/app/services/translation/tencent_maas.py b/backend/app/services/translation/tencent_maas.py new file mode 100644 index 0000000..587f1d3 --- /dev/null +++ b/backend/app/services/translation/tencent_maas.py @@ -0,0 +1,143 @@ +"""腾讯 MaaS 翻译(OpenAI 兼容协议)。 + +- 端点:https://maas-api.hivoice.cn/v1 +- 模型:u2(翻译专用) +- 鉴权:Bearer token(api_key 直接当 Bearer) +- 请求:POST /chat/completions,system prompt 告诉模型做翻译 + +设计上独立于 LlmClient(不走 agnes_* 配置),专门走 tencent_maas_* 配置, +避免和 LLM 智能增强共用 client 的节流。 +""" +from __future__ import annotations + +import asyncio +import logging +import random + +import httpx + +from app.config import settings +from app.services.translation.base import BaseTranslator, TranslationResult + +logger = logging.getLogger("news.translate.tencent_maas") + + +# 简单的源/目标语言映射(MaaS 模型期望 ISO 639-1 代码) +_LANG_MAP = { + "en": "English", + "zh": "Chinese", + "ja": "Japanese", + "ko": "Korean", + "fr": "French", + "de": "German", + "es": "Spanish", + "ru": "Russian", + "ar": "Arabic", +} + + +def _lang_label(code: str) -> str: + """把 ISO 639-1 转成自然语言名(给模型做 prompt 用)。""" + if not code or code == "auto": + return "the source language" + c = code.split("-")[0].lower() + return _LANG_MAP.get(c, c) + + +# 经过反复测试,这个 prompt 是云知声 u2 模型的关键: +# - 明确禁止 reasoning / 分析 / 注释(否则模型会把译文放进 reasoning_content) +# - 限定只接 EN/JA → ZH(对应用户场景) +# - 非英日输入时返回固定拒绝文案(便于上层识别) +_SYSTEM_PROMPT = """你是一个即时翻译助手。对于用户输入的英文或日文文章,请直接输出对应的简体中文译文。严格遵守: + +不要进行任何分步思考、不要输出分析、不要添加注释或说明。 + +只输出中文译文本身,不包含任何额外文字(如"翻译结果:"、"以下是中文:"等)。 + +如果输入内容既非英文也非日文,仅回复:"仅支持英文或日文翻译为中文。" """ + + +class TencentMaaSTranslator(BaseTranslator): + """腾讯 MaaS 翻译(OpenAI 兼容协议,模型 u2)。""" + + name = "tencent_maas" + + def __init__(self): + if not settings.tencent_maas_api_key: + raise RuntimeError("Tencent MaaS api_key missing") + self.api_key = settings.tencent_maas_api_key + self.base_url = settings.tencent_maas_base_url.rstrip("/") + self.model = settings.tencent_maas_model + self.interval_sec = settings.tencent_maas_interval_sec + + def is_configured(self) -> bool: + return bool(self.api_key) + + async def translate( + self, text: str, source: str = "auto", target: str = "zh" + ) -> TranslationResult: + """翻译接口。 + + 注意:source/target 参数当前被忽略,因为 u2 模型在固定 system prompt 下 + 会自行判断 EN/JA → ZH;保留参数是为了兼容 BaseTranslator 接口。 + """ + if not text.strip(): + return TranslationResult(text=text, engine=self.name, chars=0) + + if not self.is_configured(): + raise RuntimeError("Tencent MaaS api_key missing") + + # 固定 system prompt(经过反复测试,这套 prompt 是云知声 u2 模型唯一能稳定输出 + # 译文到 content 字段的写法;改 prompt 格式会导致模型把译文放进 reasoning_content) + system = _SYSTEM_PROMPT + user = text + + url = f"{self.base_url}/chat/completions" + payload = { + "model": self.model, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + "temperature": 0.0, + "max_tokens": max(256, len(text) * 3), + } + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + + # 简单串行 + 重试 1 次 + last_exc: Exception | None = None + for attempt in range(2): + try: + async with httpx.AsyncClient(timeout=60.0) as client: + r = await client.post(url, json=payload, headers=headers) + if r.status_code >= 500: + raise RuntimeError(f"TencentMaas 5xx: {r.status_code} {r.text[:200]}") + if r.status_code != 200: + raise RuntimeError(f"TencentMaas {r.status_code}: {r.text[:300]}") + data = r.json() + content = ( + data.get("choices", [{}])[0] + .get("message", {}) + .get("content", "") + .strip() + ) + if not content: + raise RuntimeError(f"TencentMaas empty content: {r.text[:300]}") + # 节流(避免被 MaaS 限流) + await asyncio.sleep(self.interval_sec) + return TranslationResult( + text=content, engine=self.name, chars=len(text), cached=False + ) + except Exception as e: + last_exc = e + logger.warning("tencent_maas attempt %s failed: %s", attempt, e) + if attempt == 0: + await asyncio.sleep(0.5 + random.random()) + else: + raise + # 不可达 + assert last_exc is not None + raise last_exc \ No newline at end of file