"""星火 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