feat(translate): 接入腾讯 MaaS u2 作为 TMT 备用翻译通道

新通道:腾讯 MaaS u2 模型(云知声),OpenAI 兼容协议
- 端点:https://maas-api.hivoice.cn/v1
- 模型:u2(翻译专用,实测 + 锁定 prompt 后译文质量稳定)
- 备用链路:TMT 配额耗尽 / TMT 失败时自动降级到 MaaS

关键 prompt 工程(锁定):
- 必须用 user 提供的固定中文 prompt,否则 u2 会把译文放进 reasoning_content 而 content 返乱码
- 限定只接 EN/JA → ZH
- 中文输入固定返回拒绝文案

新增/改动:
- backend/app/services/translation/tencent_maas.py: 新建
- backend/app/services/translation/service.py: 备用链 maas → local,初始化失败友好降级
- backend/app/config.py: 加 tencent_maas_* 4 个配置
- .env.example: 文档化
This commit is contained in:
Mavis
2026-06-09 17:33:45 +08:00
parent a5bfb7d49a
commit 3e56fed541
4 changed files with 227 additions and 24 deletions

View File

@@ -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)