Files
diary-news/backend/app/services/translation/spark.py
Mavis b27643123e feat(translate): 加星火 Spark(Lite)作为优先翻译引擎
- 新增 app/services/translation/spark.py: OpenAI 兼容协议客户端,
  URL = https://spark-api-open.xf-yun.com/v1/chat/completions,
  鉴权 = Bearer <APIPassword>, model = lite(默认)/generalv3.5/4.0Ultra 可切换
- service.py 引擎链路调整为: spark → tencent(配额)→ maas → agnes → local。
  优先级降序: spark 配了 key 就用它,失败再走 tencent(继续吃配额,不绕过)。
  要完全绕开 tencent,把 TENCENTCLOUD_SECRET_ID 留空即可。
- 配置: 新增 SPARK_API_PASSWORD / SPARK_BASE_URL / SPARK_MODEL / SPARK_INTERVAL_SEC
  (留空 SPARK_API_PASSWORD = 走原 tencent 主链路,向后兼容)
- 缓存白名单 / 配额计数逻辑保持原行为,只把 spark 加入允许缓存的引擎集合
2026-06-10 23:14:20 +08:00

115 lines
4.4 KiB
Python

"""星火 Spark 翻译(OpenAI 兼容协议)。
- 端点:https://spark-api-open.xf-yun.com/v1/chat/completions
- 模型:lite(也支持 generalv3.5 等,但该项目用 Lite,免费额度)
- 鉴权:Bearer token(APIPassword 直接当 Bearer 用)
- 请求:POST /chat/completions,system prompt 告诉模型做翻译
设计上独立于 LlmClient,专门走 spark_* 配置,避免和 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.spark")
# 经过实测,这套 prompt 在 Spark Lite 上输出稳定(不夹带 reasoning / 注释)。
# Lite 不支持 system 角色(早期 Lite 模型),但 v1 OpenAI 兼容的 Lite 接受 system;
# 保留 system prompt 走 4.0Ultra/Max/Pro 时也通用。
_SYSTEM_PROMPT = """你是一个即时翻译助手。对于用户输入的英文或日文文章,请直接输出对应的简体中文译文。严格遵守:
不要进行任何分步思考、不要输出分析、不要添加注释或说明。
只输出中文译文本身,不包含任何额外文字(如"翻译结果:""以下是中文:"等)。
如果输入内容既非英文也非日文,仅回复:"仅支持英文或日文翻译为中文。" """
class SparkTranslator(BaseTranslator):
"""星火 Spark 翻译(OpenAI 兼容协议,模型 lite / generalv3.5 等)。"""
name = "spark"
def __init__(self):
if not settings.spark_api_password:
raise RuntimeError("Spark APIPassword missing")
self.api_password = settings.spark_api_password
self.base_url = settings.spark_base_url.rstrip("/")
self.model = settings.spark_model
self.interval_sec = settings.spark_interval_sec
def is_configured(self) -> bool:
return bool(self.api_password)
async def translate(
self, text: str, source: str = "auto", target: str = "zh"
) -> TranslationResult:
"""翻译接口。source/target 当前固定 EN/JA → ZH(由 prompt 控制)。"""
if not text.strip():
return TranslationResult(text=text, engine=self.name, chars=0)
if not self.is_configured():
raise RuntimeError("Spark APIPassword missing")
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_password}",
"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"Spark 5xx: {r.status_code} {r.text[:200]}")
if r.status_code != 200:
raise RuntimeError(f"Spark {r.status_code}: {r.text[:300]}")
data = r.json()
# 错误响应:{"error": {...}}
if "error" in data:
raise RuntimeError(f"Spark error: {data['error']}")
content = (
data.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
.strip()
)
if not content:
raise RuntimeError(f"Spark empty content: {r.text[:300]}")
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("spark 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