From 6293f82a3a97b1af0b5c63bf32923bfe0b80e308 Mon Sep 17 00:00:00 2001 From: Mavis Date: Thu, 11 Jun 2026 09:34:01 +0800 Subject: [PATCH] =?UTF-8?q?chore(translate):=20=E9=99=8D=E9=A2=91=202?= =?UTF-8?q?=E7=A7=92/=E6=AC=A1=20+=20=E6=94=B9=20spark=20=E4=B8=BA=20wss?= =?UTF-8?q?=20WebSocket=20=E9=89=B4=E6=9D=83(=E6=99=BA=E8=B0=B1/zhipu=3D?= =?UTF-8?q?=E7=AC=AC=E4=B8=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/config.py | 35 +++-- backend/app/services/translation/spark.py | 180 +++++++++++++++------- backend/pyproject.toml | 1 + 3 files changed, 147 insertions(+), 69 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index c6a4a97..601fd4a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -70,19 +70,28 @@ class Settings(BaseSettings): tencent_tmt_quota_buffer: float = 0.05 tencent_tmt_max_chars_per_req: int = 4500 - # ===== 星火 Spark(优先翻译;Lite/免费)===== - # 留空 = 不启用星火(直接走腾讯 TMT) - spark_api_password: str = "" - 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) + # ===== 智谱 GLM(OpenAI 兼容,翻译主通道)===== + # 用法:智谱开放平台 GLM-4 系列,通过 OpenAI 协议调用 + # 留空 api_key = 不启用该 provider zhipu_api_key: str = "" zhipu_base_url: str = "https://open.bigmodel.cn/api/paas/v4" - zhipu_model: str = "glm-4-flash" - zhipu_interval_sec: float = 1.0 + zhipu_chat_model: str = "glm-4-flash" + 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") @classmethod @@ -102,8 +111,8 @@ class Settings(BaseSettings): 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 + # 2 秒/次(与智谱/星火统一节流) + tencent_maas_interval_sec: float = 2.0 # ===== 抓取 ===== fetch_global_qps: int = 4 diff --git a/backend/app/services/translation/spark.py b/backend/app/services/translation/spark.py index 121f1bd..463a04a 100644 --- a/backend/app/services/translation/spark.py +++ b/backend/app/services/translation/spark.py @@ -1,19 +1,27 @@ -"""星火 Spark 翻译(OpenAI 兼容协议)。 +"""讯飞星火 Spark Lite 翻译后端(WebSocket 协议,用户 6/11 要求改回 wss)。 -- 端点:https://spark-api-open.xf-yun.com/v1/chat/completions -- 模型:lite(也支持 generalv3.5 等,但该项目用 Lite,免费额度) -- 鉴权:Bearer token(APIPassword 直接当 Bearer 用) -- 请求:POST /chat/completions,system prompt 告诉模型做翻译 +- 端点:wss://spark-api.xf-yun.com/v1.1/chat +- 模型:Spark Lite(v1.1 通用轻量版,限时免费) +- 鉴权:URL QueryString 带 authorization(HMAC-SHA256 签名) + - 需要 APPID + APIKey + APISecret + - 签名算法见 _build_auth_url() -设计上独立于 LlmClient,专门走 spark_* 配置,避免和 LLM 智能增强共用 client 的节流。 +设计上独立于 LlmClient(不在通用 OpenAI 协议内), +鉴权 URL 每次调用前重算(因为 date 是当前时间)。 """ from __future__ import annotations import asyncio +import base64 +import hashlib +import hmac +import json import logging import random +from datetime import datetime +from urllib.parse import urlencode -import httpx +import websockets from app.config import settings 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") -# 经过实测,这套 prompt 在 Spark Lite 上输出稳定(不夹带 reasoning / 注释)。 -# Lite 不支持 system 角色(早期 Lite 模型),但 v1 OpenAI 兼容的 Lite 接受 system; -# 保留 system prompt 走 4.0Ultra/Max/Pro 时也通用。 -_SYSTEM_PROMPT = """你是一个即时翻译助手。对于用户输入的英文或日文文章,请直接输出对应的简体中文译文。严格遵守: +# 讯飞 v1.1 域 +_SPARK_HOST = "spark-api.xf-yun.com" +_SPARK_PATH = "/v1.1/chat" -不要进行任何分步思考、不要输出分析、不要添加注释或说明。 -只输出中文译文本身,不包含任何额外文字(如"翻译结果:"、"以下是中文:"等)。 +# 讯飞做翻译的 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): - """星火 Spark 翻译(OpenAI 兼容协议,模型 lite / generalv3.5 等)。""" + """讯飞星火 Spark Lite 翻译后端(WebSocket)。""" 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 + if not settings.spark_appid or not settings.spark_api_key or not settings.spark_api_secret: + raise RuntimeError("讯飞星火 APPID / APIKey / APISecret 未配置(需要 WSS 鉴权三件套)") + self.appid = settings.spark_appid + self.api_key = settings.spark_api_key + self.api_secret = settings.spark_api_secret + self.domain = settings.spark_domain self.interval_sec = settings.spark_interval_sec 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( 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") + raise RuntimeError("讯飞星火未配置(APPID/APIKey/APISecret)") - system = _SYSTEM_PROMPT - user = text + # 长度截断:Spark Lite 单轮 8K context,留余量 + max_input = 4000 + truncated = text[:max_input] if len(text) > max_input else 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", + "header": {"app_id": self.appid, "uid": "translator"}, + "parameter": { + "chat": { + "domain": self.domain, + "temperature": 0.1, # 翻译低温度 + "max_tokens": 4096, + } + }, + "payload": { + "message": { + "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 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]}") + content = await self._send_once(auth_url, payload) + # 节流(避免被限流 — 2 秒/次) await asyncio.sleep(self.interval_sec) return TranslationResult( text=content, engine=self.name, chars=len(text), cached=False @@ -112,3 +144,39 @@ class SparkTranslator(BaseTranslator): raise assert last_exc is not None 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 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 04473b2..b1f6b6c 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "python-dateutil>=2.9.0", # translation "tencentcloud-sdk-python>=3.0.1200", + "websockets>=13.0", # 讯飞星火 WebSocket 鉴权 / 调用 # scheduling "apscheduler>=3.10.4", # observability