chore(translate): 降频 2秒/次 + 改 spark 为 wss WebSocket 鉴权(智谱/zhipu=第一)
This commit is contained in:
@@ -70,19 +70,28 @@ class Settings(BaseSettings):
|
|||||||
tencent_tmt_quota_buffer: float = 0.05
|
tencent_tmt_quota_buffer: float = 0.05
|
||||||
tencent_tmt_max_chars_per_req: int = 4500
|
tencent_tmt_max_chars_per_req: int = 4500
|
||||||
|
|
||||||
# ===== 星火 Spark(优先翻译;Lite/免费)=====
|
# ===== 智谱 GLM(OpenAI 兼容,翻译主通道)=====
|
||||||
# 留空 = 不启用星火(直接走腾讯 TMT)
|
# 用法:智谱开放平台 GLM-4 系列,通过 OpenAI 协议调用
|
||||||
spark_api_password: str = ""
|
# 留空 api_key = 不启用该 provider
|
||||||
spark_base_url: str = "https://spark-api-open.xf-yun.com/v1"
|
|
||||||
spark_model: str = "lite"
|
|
||||||
spark_interval_sec: float = 1.0
|
|
||||||
|
|
||||||
# ===== 智谱 GLM(第二序位翻译;GLM-4-Flash 免费)=====
|
|
||||||
# 留空 = 不启用智谱(spark 不可用时直接降级到 tencent)
|
|
||||||
zhipu_api_key: str = ""
|
zhipu_api_key: str = ""
|
||||||
zhipu_base_url: str = "https://open.bigmodel.cn/api/paas/v4"
|
zhipu_base_url: str = "https://open.bigmodel.cn/api/paas/v4"
|
||||||
zhipu_model: str = "glm-4-flash"
|
zhipu_chat_model: str = "glm-4-flash"
|
||||||
zhipu_interval_sec: float = 1.0
|
zhipu_model: str = "glm-4-flash" # 兼容旧字段名
|
||||||
|
# 2 秒/次(用户要求 6/11,降低频率避免触发限流)
|
||||||
|
zhipu_interval_sec: float = 2.0
|
||||||
|
|
||||||
|
# ===== 讯飞星火(WebSocket,翻译二级通道)=====
|
||||||
|
# 用法:讯飞星火 v1.1 Spark Lite,WebSocket 鉴权需要 APPID + APIKey + APISecret
|
||||||
|
# 留空任意一个 = 不启用该 provider
|
||||||
|
# (历史实现用的是 OpenAI 兼容 + APIPassword,已切换为 WebSocket 鉴权,字段名换)
|
||||||
|
spark_appid: str = ""
|
||||||
|
spark_api_key: str = "" # WebSocket 鉴权用的 APIKey
|
||||||
|
spark_api_secret: str = "" # WebSocket 鉴权用的 APISecret
|
||||||
|
spark_domain: str = "lite" # v1.1 Spark Lite
|
||||||
|
# 兼容旧字段名(留空,只在没填 WebSocket 字段时起提示作用)
|
||||||
|
spark_api_password: str = ""
|
||||||
|
# 2 秒/次(用户要求 6/11,降低频率避免触发限流)
|
||||||
|
spark_interval_sec: float = 2.0
|
||||||
|
|
||||||
@field_validator("tencent_tmt_quota_buffer")
|
@field_validator("tencent_tmt_quota_buffer")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -102,8 +111,8 @@ class Settings(BaseSettings):
|
|||||||
tencent_maas_api_key: str = ""
|
tencent_maas_api_key: str = ""
|
||||||
tencent_maas_base_url: str = "https://maas-api.hivoice.cn/v1"
|
tencent_maas_base_url: str = "https://maas-api.hivoice.cn/v1"
|
||||||
tencent_maas_model: str = "u2"
|
tencent_maas_model: str = "u2"
|
||||||
# 每篇调用间隔(秒),与 LLM 客户端解耦
|
# 2 秒/次(与智谱/星火统一节流)
|
||||||
tencent_maas_interval_sec: float = 1.0
|
tencent_maas_interval_sec: float = 2.0
|
||||||
|
|
||||||
# ===== 抓取 =====
|
# ===== 抓取 =====
|
||||||
fetch_global_qps: int = 4
|
fetch_global_qps: int = 4
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
"""星火 Spark 翻译(OpenAI 兼容协议)。
|
"""讯飞星火 Spark Lite 翻译后端(WebSocket 协议,用户 6/11 要求改回 wss)。
|
||||||
|
|
||||||
- 端点:https://spark-api-open.xf-yun.com/v1/chat/completions
|
- 端点:wss://spark-api.xf-yun.com/v1.1/chat
|
||||||
- 模型:lite(也支持 generalv3.5 等,但该项目用 Lite,免费额度)
|
- 模型:Spark Lite(v1.1 通用轻量版,限时免费)
|
||||||
- 鉴权:Bearer token(APIPassword 直接当 Bearer 用)
|
- 鉴权:URL QueryString 带 authorization(HMAC-SHA256 签名)
|
||||||
- 请求:POST /chat/completions,system prompt 告诉模型做翻译
|
- 需要 APPID + APIKey + APISecret
|
||||||
|
- 签名算法见 _build_auth_url()
|
||||||
|
|
||||||
设计上独立于 LlmClient,专门走 spark_* 配置,避免和 LLM 智能增强共用 client 的节流。
|
设计上独立于 LlmClient(不在通用 OpenAI 协议内),
|
||||||
|
鉴权 URL 每次调用前重算(因为 date 是当前时间)。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
|
from datetime import datetime
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import httpx
|
import websockets
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.services.translation.base import BaseTranslator, TranslationResult
|
from app.services.translation.base import BaseTranslator, TranslationResult
|
||||||
@@ -21,84 +29,108 @@ from app.services.translation.base import BaseTranslator, TranslationResult
|
|||||||
logger = logging.getLogger("news.translate.spark")
|
logger = logging.getLogger("news.translate.spark")
|
||||||
|
|
||||||
|
|
||||||
# 经过实测,这套 prompt 在 Spark Lite 上输出稳定(不夹带 reasoning / 注释)。
|
# 讯飞 v1.1 域
|
||||||
# Lite 不支持 system 角色(早期 Lite 模型),但 v1 OpenAI 兼容的 Lite 接受 system;
|
_SPARK_HOST = "spark-api.xf-yun.com"
|
||||||
# 保留 system prompt 走 4.0Ultra/Max/Pro 时也通用。
|
_SPARK_PATH = "/v1.1/chat"
|
||||||
_SYSTEM_PROMPT = """你是一个即时翻译助手。对于用户输入的英文或日文文章,请直接输出对应的简体中文译文。严格遵守:
|
|
||||||
|
|
||||||
不要进行任何分步思考、不要输出分析、不要添加注释或说明。
|
|
||||||
|
|
||||||
只输出中文译文本身,不包含任何额外文字(如"翻译结果:"、"以下是中文:"等)。
|
# 讯飞做翻译的 system / user prompt 包装
|
||||||
|
_SYSTEM_PROMPT = (
|
||||||
|
"你是一个翻译助手。请将用户输入的英文或日文文本翻译成简体中文。"
|
||||||
|
"严格遵守:不要输出分析、不要输出注释、不要添加任何包裹文字,只输出译文本身。"
|
||||||
|
)
|
||||||
|
|
||||||
如果输入内容既非英文也非日文,仅回复:"仅支持英文或日文翻译为中文。" """
|
|
||||||
|
def _build_auth_url(api_key: str, api_secret: str) -> str:
|
||||||
|
"""构造带鉴权 query 的 WebSocket URL。
|
||||||
|
|
||||||
|
算法来自讯飞开放平台官方文档:
|
||||||
|
1) date = 当前 GMT 时间 (RFC 1123 格式)
|
||||||
|
2) signature_origin = "host: {host}\\ndate: {date}\\n GET {path} HTTP/1.1"
|
||||||
|
3) signature_sha = HMAC-SHA256(api_secret, signature_origin)
|
||||||
|
4) signature = base64(signature_sha)
|
||||||
|
5) authorization_origin = "api_key=\\"{api_key}\\", algorithm=\\"hmac-sha256\\", "
|
||||||
|
"headers=\\"host date request-line\\", signature=\\"{signature}\\""
|
||||||
|
6) authorization = base64(authorization_origin)
|
||||||
|
7) 最终 URL: wss://{host}{path}?host={host}&date={urlencoded_date}&authorization={urlencoded_authorization}
|
||||||
|
"""
|
||||||
|
now = datetime.utcnow()
|
||||||
|
date = now.strftime("%a, %d %b %Y %H:%M:%S GMT")
|
||||||
|
signature_origin = f"host: {_SPARK_HOST}\ndate: {date}\n GET {_SPARK_PATH} HTTP/1.1"
|
||||||
|
signature_sha = hmac.new(
|
||||||
|
api_secret.encode("utf-8"),
|
||||||
|
signature_origin.encode("utf-8"),
|
||||||
|
digestmod=hashlib.sha256,
|
||||||
|
).digest()
|
||||||
|
signature = base64.b64encode(signature_sha).decode("utf-8")
|
||||||
|
authorization_origin = (
|
||||||
|
f'api_key="{api_key}", algorithm="hmac-sha256", '
|
||||||
|
f'headers="host date request-line", signature="{signature}"'
|
||||||
|
)
|
||||||
|
authorization = base64.b64encode(authorization_origin.encode("utf-8")).decode("utf-8")
|
||||||
|
# 注意 date 里带冒号 / 逗号,必须 url-encode
|
||||||
|
return f"wss://{_SPARK_HOST}{_SPARK_PATH}?{urlencode({'host': _SPARK_HOST, 'date': date, 'authorization': authorization})}"
|
||||||
|
|
||||||
|
|
||||||
class SparkTranslator(BaseTranslator):
|
class SparkTranslator(BaseTranslator):
|
||||||
"""星火 Spark 翻译(OpenAI 兼容协议,模型 lite / generalv3.5 等)。"""
|
"""讯飞星火 Spark Lite 翻译后端(WebSocket)。"""
|
||||||
|
|
||||||
name = "spark"
|
name = "spark"
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
if not settings.spark_api_password:
|
if not settings.spark_appid or not settings.spark_api_key or not settings.spark_api_secret:
|
||||||
raise RuntimeError("Spark APIPassword missing")
|
raise RuntimeError("讯飞星火 APPID / APIKey / APISecret 未配置(需要 WSS 鉴权三件套)")
|
||||||
self.api_password = settings.spark_api_password
|
self.appid = settings.spark_appid
|
||||||
self.base_url = settings.spark_base_url.rstrip("/")
|
self.api_key = settings.spark_api_key
|
||||||
self.model = settings.spark_model
|
self.api_secret = settings.spark_api_secret
|
||||||
|
self.domain = settings.spark_domain
|
||||||
self.interval_sec = settings.spark_interval_sec
|
self.interval_sec = settings.spark_interval_sec
|
||||||
|
|
||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
return bool(self.api_password)
|
return bool(self.appid and self.api_key and self.api_secret)
|
||||||
|
|
||||||
async def translate(
|
async def translate(
|
||||||
self, text: str, source: str = "auto", target: str = "zh"
|
self, text: str, source: str = "auto", target: str = "zh"
|
||||||
) -> TranslationResult:
|
) -> TranslationResult:
|
||||||
"""翻译接口。source/target 当前固定 EN/JA → ZH(由 prompt 控制)。"""
|
|
||||||
if not text.strip():
|
if not text.strip():
|
||||||
return TranslationResult(text=text, engine=self.name, chars=0)
|
return TranslationResult(text=text, engine=self.name, chars=0)
|
||||||
|
|
||||||
if not self.is_configured():
|
if not self.is_configured():
|
||||||
raise RuntimeError("Spark APIPassword missing")
|
raise RuntimeError("讯飞星火未配置(APPID/APIKey/APISecret)")
|
||||||
|
|
||||||
system = _SYSTEM_PROMPT
|
# 长度截断:Spark Lite 单轮 8K context,留余量
|
||||||
user = text
|
max_input = 4000
|
||||||
|
truncated = text[:max_input] if len(text) > max_input else text
|
||||||
|
|
||||||
url = f"{self.base_url}/chat/completions"
|
# 构造请求体
|
||||||
payload = {
|
payload = {
|
||||||
"model": self.model,
|
"header": {"app_id": self.appid, "uid": "translator"},
|
||||||
"messages": [
|
"parameter": {
|
||||||
{"role": "system", "content": system},
|
"chat": {
|
||||||
{"role": "user", "content": user},
|
"domain": self.domain,
|
||||||
],
|
"temperature": 0.1, # 翻译低温度
|
||||||
"temperature": 0.0,
|
"max_tokens": 4096,
|
||||||
"max_tokens": max(256, len(text) * 3),
|
}
|
||||||
}
|
},
|
||||||
headers = {
|
"payload": {
|
||||||
"Authorization": f"Bearer {self.api_password}",
|
"message": {
|
||||||
"Content-Type": "application/json",
|
"text": [
|
||||||
|
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": truncated},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# 简单串行 + 重试 1 次
|
# 鉴权 URL(每次重算)
|
||||||
|
auth_url = _build_auth_url(self.api_key, self.api_secret)
|
||||||
|
|
||||||
|
# 简单重试 1 次
|
||||||
last_exc: Exception | None = None
|
last_exc: Exception | None = None
|
||||||
for attempt in range(2):
|
for attempt in range(2):
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
content = await self._send_once(auth_url, payload)
|
||||||
r = await client.post(url, json=payload, headers=headers)
|
# 节流(避免被限流 — 2 秒/次)
|
||||||
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)
|
await asyncio.sleep(self.interval_sec)
|
||||||
return TranslationResult(
|
return TranslationResult(
|
||||||
text=content, engine=self.name, chars=len(text), cached=False
|
text=content, engine=self.name, chars=len(text), cached=False
|
||||||
@@ -112,3 +144,39 @@ class SparkTranslator(BaseTranslator):
|
|||||||
raise
|
raise
|
||||||
assert last_exc is not None
|
assert last_exc is not None
|
||||||
raise last_exc
|
raise last_exc
|
||||||
|
|
||||||
|
async def _send_once(self, auth_url: str, payload: dict) -> str:
|
||||||
|
"""单次 WebSocket 调用,聚合流式响应,返回完整文本。"""
|
||||||
|
# websockets 13+ 异步上下文(关闭旧版 serve / connect 双 API 模糊)
|
||||||
|
async with websockets.connect(auth_url, ping_interval=None) as ws:
|
||||||
|
await ws.send(json.dumps(payload, ensure_ascii=False))
|
||||||
|
|
||||||
|
collected: list[str] = []
|
||||||
|
while True:
|
||||||
|
raw = await ws.recv()
|
||||||
|
data = json.loads(raw)
|
||||||
|
# 错误响应(header.code != 0)
|
||||||
|
header = data.get("header", {})
|
||||||
|
code = header.get("code", 0)
|
||||||
|
if code != 0:
|
||||||
|
msg = header.get("message", "")
|
||||||
|
raise RuntimeError(f"Spark 错误 {code}: {msg}")
|
||||||
|
|
||||||
|
# 流式增量
|
||||||
|
choices = data.get("payload", {}).get("choices", {})
|
||||||
|
status = choices.get("status", 0)
|
||||||
|
text_parts = choices.get("text", [])
|
||||||
|
if text_parts:
|
||||||
|
for t in text_parts:
|
||||||
|
content = t.get("content", "")
|
||||||
|
if content:
|
||||||
|
collected.append(content)
|
||||||
|
|
||||||
|
# status=2 表示结束
|
||||||
|
if status == 2:
|
||||||
|
break
|
||||||
|
|
||||||
|
full = "".join(collected).strip()
|
||||||
|
if not full:
|
||||||
|
raise RuntimeError("Spark 返回空 content")
|
||||||
|
return full
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ dependencies = [
|
|||||||
"python-dateutil>=2.9.0",
|
"python-dateutil>=2.9.0",
|
||||||
# translation
|
# translation
|
||||||
"tencentcloud-sdk-python>=3.0.1200",
|
"tencentcloud-sdk-python>=3.0.1200",
|
||||||
|
"websockets>=13.0", # 讯飞星火 WebSocket 鉴权 / 调用
|
||||||
# scheduling
|
# scheduling
|
||||||
"apscheduler>=3.10.4",
|
"apscheduler>=3.10.4",
|
||||||
# observability
|
# observability
|
||||||
|
|||||||
Reference in New Issue
Block a user