Commit 5e4dc8e4d3206a9bd3326c0b202b4f972feb5ee7
1 parent
4a37d233
翻译架构按“一个翻译服务 +
多个独立翻译能力”重构。现在业务侧不再把翻译当 provider 选型,QueryParser 和 indexer 统一通过 6006 的 translator service client 调用;真正的能力选择、启用开关、model + scene 路由,都收口到服务端和新的 translation/ 目录里了。 这次的核心改动在 config/services_config.py、providers/translation.py、api/translator_app.py、config/config.yaml 和新的 translation/service.py。配置从旧的 services.translation.provider/providers 改成了 service_url + default_model + default_scene + capabilities,每个能力可独立 enabled;服务端新增了统一的 backend 管理与懒加载,真实实现集中到 translation/backends/qwen_mt.py、translation/backends/llm.py、translation/backends/deepl.py,旧的 query/qwen_mt_translate.py、query/llm_translate.py、query/deepl_provider.py 只保留兼容导出。接口上,/translate 现在标准支持 scene,context 作为兼容别名继续可用,健康检查会返回默认模型、默认场景和已启用能力。
Showing
20 changed files
with
1208 additions
and
1118 deletions
Show diff stats
api/translator_app.py
| @@ -83,24 +83,17 @@ Start the service: | @@ -83,24 +83,17 @@ Start the service: | ||
| 83 | uvicorn api.translator_app:app --host 0.0.0.0 --port 6006 --reload | 83 | uvicorn api.translator_app:app --host 0.0.0.0 --port 6006 --reload |
| 84 | """ | 84 | """ |
| 85 | 85 | ||
| 86 | -import os | ||
| 87 | -import sys | ||
| 88 | import logging | 86 | import logging |
| 89 | import argparse | 87 | import argparse |
| 90 | import uvicorn | 88 | import uvicorn |
| 91 | -from typing import Dict, List, Optional, Sequence, Union | 89 | +from typing import Dict, List, Optional, Union |
| 92 | from fastapi import FastAPI, HTTPException | 90 | from fastapi import FastAPI, HTTPException |
| 93 | from fastapi.responses import JSONResponse | 91 | from fastapi.responses import JSONResponse |
| 94 | from fastapi.middleware.cors import CORSMiddleware | 92 | from fastapi.middleware.cors import CORSMiddleware |
| 95 | from pydantic import BaseModel, Field | 93 | from pydantic import BaseModel, Field |
| 96 | 94 | ||
| 97 | -# Add parent directory to path | ||
| 98 | -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | ||
| 99 | - | ||
| 100 | -from query.qwen_mt_translate import Translator | ||
| 101 | -from query.llm_translate import LLMTranslatorProvider | ||
| 102 | -from query.deepl_provider import DeepLProvider | ||
| 103 | from config.services_config import get_translation_config | 95 | from config.services_config import get_translation_config |
| 96 | +from translation.service import TranslationService | ||
| 104 | 97 | ||
| 105 | # Configure logging | 98 | # Configure logging |
| 106 | logging.basicConfig( | 99 | logging.basicConfig( |
| @@ -109,54 +102,14 @@ logging.basicConfig( | @@ -109,54 +102,14 @@ logging.basicConfig( | ||
| 109 | ) | 102 | ) |
| 110 | logger = logging.getLogger(__name__) | 103 | logger = logging.getLogger(__name__) |
| 111 | 104 | ||
| 112 | -# Global translator instances cache (keyed by model) | ||
| 113 | -_translators: Dict[str, object] = {} | ||
| 114 | - | 105 | +_translation_service: Optional[TranslationService] = None |
| 115 | 106 | ||
| 116 | -def _resolve_default_model() -> str: | ||
| 117 | - """ | ||
| 118 | - Resolve translator model from services.translation config first. | ||
| 119 | 107 | ||
| 120 | - Priority: | ||
| 121 | - 1) TRANSLATION_MODEL env (explicit runtime override) | ||
| 122 | - 2) services.translation.provider + providers.<provider>.model | ||
| 123 | - 3) qwen-mt | ||
| 124 | - """ | ||
| 125 | - env_model = (os.getenv("TRANSLATION_MODEL") or "").strip() | ||
| 126 | - if env_model: | ||
| 127 | - return env_model | ||
| 128 | - try: | ||
| 129 | - cfg = get_translation_config() | ||
| 130 | - provider = (cfg.provider or "").strip().lower() | ||
| 131 | - provider_cfg = cfg.get_provider_cfg() if hasattr(cfg, "get_provider_cfg") else {} | ||
| 132 | - model = (provider_cfg.get("model") or "").strip().lower() if isinstance(provider_cfg, dict) else "" | ||
| 133 | - if provider == "llm": | ||
| 134 | - return "llm" | ||
| 135 | - if provider in {"qwen-mt", "direct", "http"}: | ||
| 136 | - return model or "qwen-mt" | ||
| 137 | - if provider == "deepl": | ||
| 138 | - return "deepl" | ||
| 139 | - except Exception: | ||
| 140 | - pass | ||
| 141 | - return "qwen-mt" | ||
| 142 | - | ||
| 143 | - | ||
| 144 | -def get_translator(model: str = "qwen") -> object: | ||
| 145 | - """Get or create translator instance for the specified model.""" | ||
| 146 | - global _translators | ||
| 147 | - if model not in _translators: | ||
| 148 | - logger.info(f"Initializing translator with model: {model}...") | ||
| 149 | - normalized = (model or "qwen").strip().lower() | ||
| 150 | - if normalized in {"qwen", "qwen-mt", "qwen-mt-flash", "qwen-mt-flush"}: | ||
| 151 | - _translators[model] = Translator(model=normalized, use_cache=True, timeout=10) | ||
| 152 | - elif normalized == "deepl": | ||
| 153 | - _translators[model] = DeepLProvider(api_key=None, timeout=10.0) | ||
| 154 | - elif normalized == "llm": | ||
| 155 | - _translators[model] = LLMTranslatorProvider() | ||
| 156 | - else: | ||
| 157 | - raise ValueError(f"Unsupported model: {model}") | ||
| 158 | - logger.info(f"Translator initialized with model: {model}") | ||
| 159 | - return _translators[model] | 108 | +def get_translation_service() -> TranslationService: |
| 109 | + global _translation_service | ||
| 110 | + if _translation_service is None: | ||
| 111 | + _translation_service = TranslationService(get_translation_config()) | ||
| 112 | + return _translation_service | ||
| 160 | 113 | ||
| 161 | 114 | ||
| 162 | # Request/Response models | 115 | # Request/Response models |
| @@ -166,7 +119,8 @@ class TranslationRequest(BaseModel): | @@ -166,7 +119,8 @@ class TranslationRequest(BaseModel): | ||
| 166 | target_lang: str = Field(..., description="Target language code (zh, en, ru, etc.)") | 119 | target_lang: str = Field(..., description="Target language code (zh, en, ru, etc.)") |
| 167 | source_lang: Optional[str] = Field(None, description="Source language code (optional, auto-detect if not provided)") | 120 | source_lang: Optional[str] = Field(None, description="Source language code (optional, auto-detect if not provided)") |
| 168 | model: Optional[str] = Field(None, description="Translation model: qwen-mt | deepl | llm") | 121 | model: Optional[str] = Field(None, description="Translation model: qwen-mt | deepl | llm") |
| 169 | - context: Optional[str] = Field(None, description="Optional translation scene or context") | 122 | + scene: Optional[str] = Field(None, description="Translation scene, paired with model routing") |
| 123 | + context: Optional[str] = Field(None, description="Deprecated alias of scene") | ||
| 170 | prompt: Optional[str] = Field(None, description="Optional prompt override") | 124 | prompt: Optional[str] = Field(None, description="Optional prompt override") |
| 171 | 125 | ||
| 172 | class Config: | 126 | class Config: |
| @@ -176,7 +130,7 @@ class TranslationRequest(BaseModel): | @@ -176,7 +130,7 @@ class TranslationRequest(BaseModel): | ||
| 176 | "target_lang": "en", | 130 | "target_lang": "en", |
| 177 | "source_lang": "zh", | 131 | "source_lang": "zh", |
| 178 | "model": "llm", | 132 | "model": "llm", |
| 179 | - "context": "sku_name" | 133 | + "scene": "sku_name" |
| 180 | } | 134 | } |
| 181 | } | 135 | } |
| 182 | 136 | ||
| @@ -192,6 +146,7 @@ class TranslationResponse(BaseModel): | @@ -192,6 +146,7 @@ class TranslationResponse(BaseModel): | ||
| 192 | ) | 146 | ) |
| 193 | status: str = Field(..., description="Translation status") | 147 | status: str = Field(..., description="Translation status") |
| 194 | model: str = Field(..., description="Translation model used") | 148 | model: str = Field(..., description="Translation model used") |
| 149 | + scene: str = Field(..., description="Translation scene used") | ||
| 195 | 150 | ||
| 196 | 151 | ||
| 197 | # Create FastAPI app | 152 | # Create FastAPI app |
| @@ -217,10 +172,13 @@ app.add_middleware( | @@ -217,10 +172,13 @@ app.add_middleware( | ||
| 217 | async def startup_event(): | 172 | async def startup_event(): |
| 218 | """Initialize translator on startup.""" | 173 | """Initialize translator on startup.""" |
| 219 | logger.info("Starting Translation Service API on port 6006") | 174 | logger.info("Starting Translation Service API on port 6006") |
| 220 | - default_model = _resolve_default_model() | ||
| 221 | try: | 175 | try: |
| 222 | - get_translator(model=default_model) | ||
| 223 | - logger.info(f"Translation service ready with default model: {default_model}") | 176 | + service = get_translation_service() |
| 177 | + logger.info( | ||
| 178 | + "Translation service ready | default_model=%s available_models=%s", | ||
| 179 | + service.config.default_model, | ||
| 180 | + service.available_models, | ||
| 181 | + ) | ||
| 224 | except Exception as e: | 182 | except Exception as e: |
| 225 | logger.error(f"Failed to initialize translator: {e}", exc_info=True) | 183 | logger.error(f"Failed to initialize translator: {e}", exc_info=True) |
| 226 | raise | 184 | raise |
| @@ -230,17 +188,14 @@ async def startup_event(): | @@ -230,17 +188,14 @@ async def startup_event(): | ||
| 230 | async def health_check(): | 188 | async def health_check(): |
| 231 | """Health check endpoint.""" | 189 | """Health check endpoint.""" |
| 232 | try: | 190 | try: |
| 233 | - # 仅做轻量级本地检查,避免在健康检查中触发潜在的阻塞初始化或外部依赖 | ||
| 234 | - default_model = _resolve_default_model() | ||
| 235 | - # 如果启动事件成功,默认模型通常会已经初始化到缓存中 | ||
| 236 | - translator = _translators.get(default_model) or next(iter(_translators.values()), None) | 191 | + service = get_translation_service() |
| 237 | return { | 192 | return { |
| 238 | "status": "healthy", | 193 | "status": "healthy", |
| 239 | "service": "translation", | 194 | "service": "translation", |
| 240 | - "default_model": default_model, | ||
| 241 | - "available_models": list(_translators.keys()), | ||
| 242 | - "translator_initialized": translator is not None, | ||
| 243 | - "cache_enabled": bool(getattr(translator, "use_cache", False)) | 195 | + "default_model": service.config.default_model, |
| 196 | + "default_scene": service.config.default_scene, | ||
| 197 | + "available_models": service.available_models, | ||
| 198 | + "enabled_capabilities": service.config.enabled_models, | ||
| 244 | } | 199 | } |
| 245 | except Exception as e: | 200 | except Exception as e: |
| 246 | logger.error(f"Health check failed: {e}") | 201 | logger.error(f"Health check failed: {e}") |
| @@ -283,27 +238,22 @@ async def translate(request: TranslationRequest): | @@ -283,27 +238,22 @@ async def translate(request: TranslationRequest): | ||
| 283 | detail="target_lang is required" | 238 | detail="target_lang is required" |
| 284 | ) | 239 | ) |
| 285 | 240 | ||
| 286 | - # Validate model parameter | ||
| 287 | - model = request.model.lower() if request.model else _resolve_default_model().lower() | ||
| 288 | - if model not in ["qwen", "qwen-mt", "deepl", "llm"]: | ||
| 289 | - raise HTTPException( | ||
| 290 | - status_code=400, | ||
| 291 | - detail="Invalid model. Supported models: 'qwen-mt', 'deepl', 'llm'" | ||
| 292 | - ) | ||
| 293 | - | ||
| 294 | try: | 241 | try: |
| 295 | - # Get translator instance for the specified model | ||
| 296 | - translator = get_translator(model=model) | 242 | + service = get_translation_service() |
| 243 | + scene = (request.scene or request.context or service.config.default_scene).strip() or "general" | ||
| 244 | + model = service.config.normalize_model_name(request.model or service.config.default_model) | ||
| 245 | + translator = service.get_backend(model) | ||
| 297 | raw_text = request.text | 246 | raw_text = request.text |
| 298 | 247 | ||
| 299 | # 如果是列表,并且底层 provider 声明支持 batch,则直接传 list | 248 | # 如果是列表,并且底层 provider 声明支持 batch,则直接传 list |
| 300 | if isinstance(raw_text, list) and getattr(translator, "supports_batch", False): | 249 | if isinstance(raw_text, list) and getattr(translator, "supports_batch", False): |
| 301 | try: | 250 | try: |
| 302 | - translated_list = translator.translate( | 251 | + translated_list = service.translate( |
| 303 | text=raw_text, | 252 | text=raw_text, |
| 304 | target_lang=request.target_lang, | 253 | target_lang=request.target_lang, |
| 305 | source_lang=request.source_lang, | 254 | source_lang=request.source_lang, |
| 306 | - context=request.context, | 255 | + model=model, |
| 256 | + scene=scene, | ||
| 307 | prompt=request.prompt, | 257 | prompt=request.prompt, |
| 308 | ) | 258 | ) |
| 309 | except Exception as exc: | 259 | except Exception as exc: |
| @@ -334,6 +284,7 @@ async def translate(request: TranslationRequest): | @@ -334,6 +284,7 @@ async def translate(request: TranslationRequest): | ||
| 334 | translated_text=normalized, | 284 | translated_text=normalized, |
| 335 | status="success", | 285 | status="success", |
| 336 | model=str(getattr(translator, "model", model)), | 286 | model=str(getattr(translator, "model", model)), |
| 287 | + scene=scene, | ||
| 337 | ) | 288 | ) |
| 338 | 289 | ||
| 339 | # 否则:统一走逐条拆分逻辑(包括不支持 batch 的 provider) | 290 | # 否则:统一走逐条拆分逻辑(包括不支持 batch 的 provider) |
| @@ -345,11 +296,12 @@ async def translate(request: TranslationRequest): | @@ -345,11 +296,12 @@ async def translate(request: TranslationRequest): | ||
| 345 | results.append(item) # type: ignore[arg-type] | 296 | results.append(item) # type: ignore[arg-type] |
| 346 | continue | 297 | continue |
| 347 | try: | 298 | try: |
| 348 | - out = translator.translate( | 299 | + out = service.translate( |
| 349 | text=str(item), | 300 | text=str(item), |
| 350 | target_lang=request.target_lang, | 301 | target_lang=request.target_lang, |
| 351 | source_lang=request.source_lang, | 302 | source_lang=request.source_lang, |
| 352 | - context=request.context, | 303 | + model=model, |
| 304 | + scene=scene, | ||
| 353 | prompt=request.prompt, | 305 | prompt=request.prompt, |
| 354 | ) | 306 | ) |
| 355 | except Exception as exc: | 307 | except Exception as exc: |
| @@ -365,14 +317,16 @@ async def translate(request: TranslationRequest): | @@ -365,14 +317,16 @@ async def translate(request: TranslationRequest): | ||
| 365 | translated_text=results, | 317 | translated_text=results, |
| 366 | status="success", | 318 | status="success", |
| 367 | model=str(getattr(translator, "model", model)), | 319 | model=str(getattr(translator, "model", model)), |
| 320 | + scene=scene, | ||
| 368 | ) | 321 | ) |
| 369 | 322 | ||
| 370 | # 单文本模式:保持原有严格失败语义 | 323 | # 单文本模式:保持原有严格失败语义 |
| 371 | - translated_text = translator.translate( | 324 | + translated_text = service.translate( |
| 372 | text=raw_text, | 325 | text=raw_text, |
| 373 | target_lang=request.target_lang, | 326 | target_lang=request.target_lang, |
| 374 | source_lang=request.source_lang, | 327 | source_lang=request.source_lang, |
| 375 | - context=request.context, | 328 | + model=model, |
| 329 | + scene=scene, | ||
| 376 | prompt=request.prompt, | 330 | prompt=request.prompt, |
| 377 | ) | 331 | ) |
| 378 | 332 | ||
| @@ -388,7 +342,8 @@ async def translate(request: TranslationRequest): | @@ -388,7 +342,8 @@ async def translate(request: TranslationRequest): | ||
| 388 | source_lang=request.source_lang, | 342 | source_lang=request.source_lang, |
| 389 | translated_text=translated_text, | 343 | translated_text=translated_text, |
| 390 | status="success", | 344 | status="success", |
| 391 | - model=str(getattr(translator, "model", model)) | 345 | + model=str(getattr(translator, "model", model)), |
| 346 | + scene=scene, | ||
| 392 | ) | 347 | ) |
| 393 | 348 | ||
| 394 | except HTTPException: | 349 | except HTTPException: |
config/config.yaml
| @@ -107,9 +107,9 @@ rerank: | @@ -107,9 +107,9 @@ rerank: | ||
| 107 | # 可扩展服务/provider 注册表(单一配置源) | 107 | # 可扩展服务/provider 注册表(单一配置源) |
| 108 | services: | 108 | services: |
| 109 | translation: | 109 | translation: |
| 110 | - provider: "llm" # qwen-mt | deepl | http | llm | ||
| 111 | - base_url: "http://127.0.0.1:6006" | ||
| 112 | - model: "qwen-flash" | 110 | + service_url: "http://127.0.0.1:6006" |
| 111 | + default_model: "llm" | ||
| 112 | + default_scene: "general" | ||
| 113 | timeout_sec: 10.0 | 113 | timeout_sec: 10.0 |
| 114 | cache: | 114 | cache: |
| 115 | enabled: true | 115 | enabled: true |
| @@ -119,20 +119,21 @@ services: | @@ -119,20 +119,21 @@ services: | ||
| 119 | key_include_context: true | 119 | key_include_context: true |
| 120 | key_include_prompt: true | 120 | key_include_prompt: true |
| 121 | key_include_source_lang: true | 121 | key_include_source_lang: true |
| 122 | - providers: | 122 | + capabilities: |
| 123 | qwen-mt: | 123 | qwen-mt: |
| 124 | - model: "qwen-mt-flush" | ||
| 125 | - http: | ||
| 126 | - base_url: "http://127.0.0.1:6006" | ||
| 127 | - model: "qwen-mt-flush" | 124 | + enabled: true |
| 125 | + model: "qwen-mt-flash" | ||
| 128 | timeout_sec: 10.0 | 126 | timeout_sec: 10.0 |
| 127 | + use_cache: true | ||
| 129 | llm: | 128 | llm: |
| 129 | + enabled: true | ||
| 130 | model: "qwen-flash" | 130 | model: "qwen-flash" |
| 131 | # 可选:覆盖 DashScope 兼容模式的 Endpoint 与超时 | 131 | # 可选:覆盖 DashScope 兼容模式的 Endpoint 与超时 |
| 132 | # base_url 留空则使用 DASHSCOPE_BASE_URL 或默认地域 | 132 | # base_url 留空则使用 DASHSCOPE_BASE_URL 或默认地域 |
| 133 | base_url: "" | 133 | base_url: "" |
| 134 | timeout_sec: 30.0 | 134 | timeout_sec: 30.0 |
| 135 | deepl: | 135 | deepl: |
| 136 | + enabled: false | ||
| 136 | model: "deepl" | 137 | model: "deepl" |
| 137 | timeout_sec: 10.0 | 138 | timeout_sec: 10.0 |
| 138 | # 可选:用于术语表翻译(由 query_config.translation_glossary_id 衔接) | 139 | # 可选:用于术语表翻译(由 query_config.translation_glossary_id 衔接) |
config/env_config.py
| @@ -65,8 +65,9 @@ EMBEDDING_HOST = os.getenv('EMBEDDING_HOST', '127.0.0.1') | @@ -65,8 +65,9 @@ EMBEDDING_HOST = os.getenv('EMBEDDING_HOST', '127.0.0.1') | ||
| 65 | EMBEDDING_PORT = int(os.getenv('EMBEDDING_PORT', 6005)) | 65 | EMBEDDING_PORT = int(os.getenv('EMBEDDING_PORT', 6005)) |
| 66 | TRANSLATION_HOST = os.getenv('TRANSLATION_HOST', '127.0.0.1') | 66 | TRANSLATION_HOST = os.getenv('TRANSLATION_HOST', '127.0.0.1') |
| 67 | TRANSLATION_PORT = int(os.getenv('TRANSLATION_PORT', 6006)) | 67 | TRANSLATION_PORT = int(os.getenv('TRANSLATION_PORT', 6006)) |
| 68 | -TRANSLATION_PROVIDER = os.getenv('TRANSLATION_PROVIDER', 'direct') | ||
| 69 | -TRANSLATION_MODEL = os.getenv('TRANSLATION_MODEL', 'qwen') | 68 | +TRANSLATION_PROVIDER = os.getenv('TRANSLATION_PROVIDER', 'direct') # deprecated |
| 69 | +TRANSLATION_MODEL = os.getenv('TRANSLATION_MODEL', 'llm') | ||
| 70 | +TRANSLATION_SCENE = os.getenv('TRANSLATION_SCENE', 'general') | ||
| 70 | RERANKER_HOST = os.getenv('RERANKER_HOST', '127.0.0.1') | 71 | RERANKER_HOST = os.getenv('RERANKER_HOST', '127.0.0.1') |
| 71 | RERANKER_PORT = int(os.getenv('RERANKER_PORT', 6007)) | 72 | RERANKER_PORT = int(os.getenv('RERANKER_PORT', 6007)) |
| 72 | RERANK_PROVIDER = os.getenv('RERANK_PROVIDER', 'http') | 73 | RERANK_PROVIDER = os.getenv('RERANK_PROVIDER', 'http') |
config/services_config.py
| 1 | """ | 1 | """ |
| 2 | -Services configuration - single source for translation, embedding, rerank providers. | 2 | +Services configuration - single source for translation, embedding, rerank. |
| 3 | 3 | ||
| 4 | -All provider selection and endpoint resolution is centralized here. | ||
| 5 | -Priority: env vars > config.yaml. | 4 | +Translation is modeled as: |
| 5 | +- one translator service endpoint used by business callers | ||
| 6 | +- multiple translation capabilities loaded inside the translator service | ||
| 6 | """ | 7 | """ |
| 7 | 8 | ||
| 8 | from __future__ import annotations | 9 | from __future__ import annotations |
| @@ -11,25 +12,60 @@ import os | @@ -11,25 +12,60 @@ import os | ||
| 11 | from dataclasses import dataclass, field | 12 | from dataclasses import dataclass, field |
| 12 | from functools import lru_cache | 13 | from functools import lru_cache |
| 13 | from pathlib import Path | 14 | from pathlib import Path |
| 14 | -from typing import Any, Dict, Optional | 15 | +from typing import Any, Dict, List, Optional |
| 15 | 16 | ||
| 16 | import yaml | 17 | import yaml |
| 17 | 18 | ||
| 18 | 19 | ||
| 19 | @dataclass | 20 | @dataclass |
| 20 | class ServiceConfig: | 21 | class ServiceConfig: |
| 21 | - """Config for one capability (translation/embedding/rerank).""" | 22 | + """Config for one capability (embedding/rerank).""" |
| 23 | + | ||
| 22 | provider: str | 24 | provider: str |
| 23 | providers: Dict[str, Any] = field(default_factory=dict) | 25 | providers: Dict[str, Any] = field(default_factory=dict) |
| 24 | 26 | ||
| 25 | def get_provider_cfg(self) -> Dict[str, Any]: | 27 | def get_provider_cfg(self) -> Dict[str, Any]: |
| 26 | - """Get config for current provider.""" | ||
| 27 | p = (self.provider or "").strip().lower() | 28 | p = (self.provider or "").strip().lower() |
| 28 | return self.providers.get(p, {}) if isinstance(self.providers, dict) else {} | 29 | return self.providers.get(p, {}) if isinstance(self.providers, dict) else {} |
| 29 | 30 | ||
| 30 | 31 | ||
| 32 | +@dataclass | ||
| 33 | +class TranslationServiceConfig: | ||
| 34 | + """Dedicated config model for the translation service.""" | ||
| 35 | + | ||
| 36 | + service_url: str | ||
| 37 | + timeout_sec: float | ||
| 38 | + default_model: str | ||
| 39 | + default_scene: str | ||
| 40 | + capabilities: Dict[str, Dict[str, Any]] = field(default_factory=dict) | ||
| 41 | + cache: Dict[str, Any] = field(default_factory=dict) | ||
| 42 | + | ||
| 43 | + def normalize_model_name(self, model: Optional[str]) -> str: | ||
| 44 | + normalized = str(model or self.default_model).strip().lower() | ||
| 45 | + aliases = { | ||
| 46 | + "qwen": "qwen-mt", | ||
| 47 | + "qwen-mt-flash": "qwen-mt", | ||
| 48 | + "qwen-mt-flush": "qwen-mt", | ||
| 49 | + "service": self.default_model, | ||
| 50 | + "default": self.default_model, | ||
| 51 | + } | ||
| 52 | + return aliases.get(normalized, normalized) | ||
| 53 | + | ||
| 54 | + @property | ||
| 55 | + def enabled_models(self) -> List[str]: | ||
| 56 | + items: List[str] = [] | ||
| 57 | + for name, cfg in self.capabilities.items(): | ||
| 58 | + if isinstance(cfg, dict) and bool(cfg.get("enabled", False)): | ||
| 59 | + items.append(str(name).strip().lower()) | ||
| 60 | + return items | ||
| 61 | + | ||
| 62 | + def get_capability_cfg(self, model: Optional[str]) -> Dict[str, Any]: | ||
| 63 | + normalized = self.normalize_model_name(model) | ||
| 64 | + value = self.capabilities.get(normalized) | ||
| 65 | + return dict(value) if isinstance(value, dict) else {} | ||
| 66 | + | ||
| 67 | + | ||
| 31 | def _load_services_raw(config_path: Optional[Path] = None) -> Dict[str, Any]: | 68 | def _load_services_raw(config_path: Optional[Path] = None) -> Dict[str, Any]: |
| 32 | - """Load services block from config.yaml.""" | ||
| 33 | if config_path is None: | 69 | if config_path is None: |
| 34 | config_path = Path(__file__).parent / "config.yaml" | 70 | config_path = Path(__file__).parent / "config.yaml" |
| 35 | path = Path(config_path) | 71 | path = Path(config_path) |
| @@ -48,11 +84,7 @@ def _load_services_raw(config_path: Optional[Path] = None) -> Dict[str, Any]: | @@ -48,11 +84,7 @@ def _load_services_raw(config_path: Optional[Path] = None) -> Dict[str, Any]: | ||
| 48 | return services | 84 | return services |
| 49 | 85 | ||
| 50 | 86 | ||
| 51 | -def _resolve_provider_name( | ||
| 52 | - env_name: str, | ||
| 53 | - config_provider: Any, | ||
| 54 | - capability: str, | ||
| 55 | -) -> str: | 87 | +def _resolve_provider_name(env_name: str, config_provider: Any, capability: str) -> str: |
| 56 | provider = os.getenv(env_name) or config_provider | 88 | provider = os.getenv(env_name) or config_provider |
| 57 | if not provider: | 89 | if not provider: |
| 58 | raise ValueError( | 90 | raise ValueError( |
| @@ -62,27 +94,70 @@ def _resolve_provider_name( | @@ -62,27 +94,70 @@ def _resolve_provider_name( | ||
| 62 | return str(provider).strip().lower() | 94 | return str(provider).strip().lower() |
| 63 | 95 | ||
| 64 | 96 | ||
| 65 | -def _resolve_translation() -> ServiceConfig: | 97 | +def _resolve_translation() -> TranslationServiceConfig: |
| 66 | raw = _load_services_raw() | 98 | raw = _load_services_raw() |
| 67 | cfg = raw.get("translation", {}) if isinstance(raw.get("translation"), dict) else {} | 99 | cfg = raw.get("translation", {}) if isinstance(raw.get("translation"), dict) else {} |
| 68 | - providers = cfg.get("providers", {}) if isinstance(cfg.get("providers"), dict) else {} | ||
| 69 | 100 | ||
| 70 | - provider = _resolve_provider_name( | ||
| 71 | - env_name="TRANSLATION_PROVIDER", | ||
| 72 | - config_provider=cfg.get("provider"), | ||
| 73 | - capability="translation", | 101 | + service_url = ( |
| 102 | + os.getenv("TRANSLATION_SERVICE_URL") | ||
| 103 | + or cfg.get("service_url") | ||
| 104 | + or cfg.get("base_url") | ||
| 105 | + or "http://127.0.0.1:6006" | ||
| 74 | ) | 106 | ) |
| 75 | - if provider not in ("qwen-mt", "deepl", "direct", "local", "inprocess", "http", "service", "llm"): | ||
| 76 | - raise ValueError(f"Unsupported translation provider: {provider}") | 107 | + timeout_sec = float(os.getenv("TRANSLATION_TIMEOUT_SEC") or cfg.get("timeout_sec") or 10.0) |
| 108 | + | ||
| 109 | + raw_capabilities = cfg.get("capabilities") | ||
| 110 | + if not isinstance(raw_capabilities, dict): | ||
| 111 | + raw_capabilities = cfg.get("providers") | ||
| 112 | + capabilities = raw_capabilities if isinstance(raw_capabilities, dict) else {} | ||
| 113 | + | ||
| 114 | + default_model = str( | ||
| 115 | + os.getenv("TRANSLATION_MODEL") | ||
| 116 | + or cfg.get("default_model") | ||
| 117 | + or cfg.get("provider") | ||
| 118 | + or "qwen-mt" | ||
| 119 | + ).strip().lower() | ||
| 120 | + default_scene = str( | ||
| 121 | + os.getenv("TRANSLATION_SCENE") | ||
| 122 | + or cfg.get("default_scene") | ||
| 123 | + or "general" | ||
| 124 | + ).strip() or "general" | ||
| 125 | + | ||
| 126 | + resolved_capabilities: Dict[str, Dict[str, Any]] = {} | ||
| 127 | + for name, value in capabilities.items(): | ||
| 128 | + if not isinstance(value, dict): | ||
| 129 | + continue | ||
| 130 | + normalized = str(name or "").strip().lower() | ||
| 131 | + if not normalized: | ||
| 132 | + continue | ||
| 133 | + copied = dict(value) | ||
| 134 | + copied.setdefault("enabled", normalized == default_model) | ||
| 135 | + resolved_capabilities[normalized] = copied | ||
| 136 | + | ||
| 137 | + aliases = { | ||
| 138 | + "qwen": "qwen-mt", | ||
| 139 | + "qwen-mt-flash": "qwen-mt", | ||
| 140 | + "qwen-mt-flush": "qwen-mt", | ||
| 141 | + } | ||
| 142 | + default_model = aliases.get(default_model, default_model) | ||
| 77 | 143 | ||
| 78 | - # Env override for http base_url | ||
| 79 | - env_url = os.getenv("TRANSLATION_SERVICE_URL") | ||
| 80 | - if env_url and provider in ("http", "service"): | ||
| 81 | - providers = dict(providers) | ||
| 82 | - providers["http"] = dict(providers.get("http", {})) | ||
| 83 | - providers["http"]["base_url"] = env_url.rstrip("/") | 144 | + if default_model not in resolved_capabilities: |
| 145 | + raise ValueError( | ||
| 146 | + f"services.translation.default_model '{default_model}' is not defined in capabilities" | ||
| 147 | + ) | ||
| 148 | + if not bool(resolved_capabilities[default_model].get("enabled", False)): | ||
| 149 | + resolved_capabilities[default_model]["enabled"] = True | ||
| 84 | 150 | ||
| 85 | - return ServiceConfig(provider=provider, providers=providers) | 151 | + cache_cfg = cfg.get("cache", {}) if isinstance(cfg.get("cache"), dict) else {} |
| 152 | + | ||
| 153 | + return TranslationServiceConfig( | ||
| 154 | + service_url=str(service_url).rstrip("/"), | ||
| 155 | + timeout_sec=timeout_sec, | ||
| 156 | + default_model=default_model, | ||
| 157 | + default_scene=default_scene, | ||
| 158 | + capabilities=resolved_capabilities, | ||
| 159 | + cache=cache_cfg, | ||
| 160 | + ) | ||
| 86 | 161 | ||
| 87 | 162 | ||
| 88 | def _resolve_embedding() -> ServiceConfig: | 163 | def _resolve_embedding() -> ServiceConfig: |
| @@ -134,18 +209,10 @@ def _resolve_rerank() -> ServiceConfig: | @@ -134,18 +209,10 @@ def _resolve_rerank() -> ServiceConfig: | ||
| 134 | 209 | ||
| 135 | 210 | ||
| 136 | def get_rerank_backend_config() -> tuple[str, dict]: | 211 | def get_rerank_backend_config() -> tuple[str, dict]: |
| 137 | - """ | ||
| 138 | - Resolve reranker backend name and config for the reranker service process. | ||
| 139 | - Returns (backend_name, backend_cfg). | ||
| 140 | - Env RERANK_BACKEND overrides config. | ||
| 141 | - """ | ||
| 142 | raw = _load_services_raw() | 212 | raw = _load_services_raw() |
| 143 | cfg = raw.get("rerank", {}) if isinstance(raw.get("rerank"), dict) else {} | 213 | cfg = raw.get("rerank", {}) if isinstance(raw.get("rerank"), dict) else {} |
| 144 | backends = cfg.get("backends", {}) if isinstance(cfg.get("backends"), dict) else {} | 214 | backends = cfg.get("backends", {}) if isinstance(cfg.get("backends"), dict) else {} |
| 145 | - name = ( | ||
| 146 | - os.getenv("RERANK_BACKEND") | ||
| 147 | - or cfg.get("backend") | ||
| 148 | - ) | 215 | + name = os.getenv("RERANK_BACKEND") or cfg.get("backend") |
| 149 | if not name: | 216 | if not name: |
| 150 | raise ValueError("services.rerank.backend is required (or env RERANK_BACKEND)") | 217 | raise ValueError("services.rerank.backend is required (or env RERANK_BACKEND)") |
| 151 | name = str(name).strip().lower() | 218 | name = str(name).strip().lower() |
| @@ -156,18 +223,10 @@ def get_rerank_backend_config() -> tuple[str, dict]: | @@ -156,18 +223,10 @@ def get_rerank_backend_config() -> tuple[str, dict]: | ||
| 156 | 223 | ||
| 157 | 224 | ||
| 158 | def get_embedding_backend_config() -> tuple[str, dict]: | 225 | def get_embedding_backend_config() -> tuple[str, dict]: |
| 159 | - """ | ||
| 160 | - Resolve embedding backend name and config for the embedding service process. | ||
| 161 | - Returns (backend_name, backend_cfg). | ||
| 162 | - Env EMBEDDING_BACKEND overrides config. | ||
| 163 | - """ | ||
| 164 | raw = _load_services_raw() | 226 | raw = _load_services_raw() |
| 165 | cfg = raw.get("embedding", {}) if isinstance(raw.get("embedding"), dict) else {} | 227 | cfg = raw.get("embedding", {}) if isinstance(raw.get("embedding"), dict) else {} |
| 166 | backends = cfg.get("backends", {}) if isinstance(cfg.get("backends"), dict) else {} | 228 | backends = cfg.get("backends", {}) if isinstance(cfg.get("backends"), dict) else {} |
| 167 | - name = ( | ||
| 168 | - os.getenv("EMBEDDING_BACKEND") | ||
| 169 | - or cfg.get("backend") | ||
| 170 | - ) | 229 | + name = os.getenv("EMBEDDING_BACKEND") or cfg.get("backend") |
| 171 | if not name: | 230 | if not name: |
| 172 | raise ValueError("services.embedding.backend is required (or env EMBEDDING_BACKEND)") | 231 | raise ValueError("services.embedding.backend is required (or env EMBEDDING_BACKEND)") |
| 173 | name = str(name).strip().lower() | 232 | name = str(name).strip().lower() |
| @@ -178,44 +237,26 @@ def get_embedding_backend_config() -> tuple[str, dict]: | @@ -178,44 +237,26 @@ def get_embedding_backend_config() -> tuple[str, dict]: | ||
| 178 | 237 | ||
| 179 | 238 | ||
| 180 | @lru_cache(maxsize=1) | 239 | @lru_cache(maxsize=1) |
| 181 | -def get_translation_config() -> ServiceConfig: | ||
| 182 | - """Get translation service config.""" | 240 | +def get_translation_config() -> TranslationServiceConfig: |
| 183 | return _resolve_translation() | 241 | return _resolve_translation() |
| 184 | 242 | ||
| 185 | 243 | ||
| 186 | @lru_cache(maxsize=1) | 244 | @lru_cache(maxsize=1) |
| 187 | def get_embedding_config() -> ServiceConfig: | 245 | def get_embedding_config() -> ServiceConfig: |
| 188 | - """Get embedding service config.""" | ||
| 189 | return _resolve_embedding() | 246 | return _resolve_embedding() |
| 190 | 247 | ||
| 191 | 248 | ||
| 192 | @lru_cache(maxsize=1) | 249 | @lru_cache(maxsize=1) |
| 193 | def get_rerank_config() -> ServiceConfig: | 250 | def get_rerank_config() -> ServiceConfig: |
| 194 | - """Get rerank service config.""" | ||
| 195 | return _resolve_rerank() | 251 | return _resolve_rerank() |
| 196 | 252 | ||
| 197 | 253 | ||
| 198 | def get_translation_base_url() -> str: | 254 | def get_translation_base_url() -> str: |
| 199 | - """Resolve translation HTTP base URL (for http provider).""" | ||
| 200 | - base = ( | ||
| 201 | - os.getenv("TRANSLATION_SERVICE_URL") | ||
| 202 | - or get_translation_config().providers.get("http", {}).get("base_url") | ||
| 203 | - ) | ||
| 204 | - if not base: | ||
| 205 | - raise ValueError("Translation HTTP base_url is not configured") | ||
| 206 | - return str(base).rstrip("/") | 255 | + return get_translation_config().service_url |
| 207 | 256 | ||
| 208 | 257 | ||
| 209 | def get_translation_cache_config() -> Dict[str, Any]: | 258 | def get_translation_cache_config() -> Dict[str, Any]: |
| 210 | - """ | ||
| 211 | - Resolve translation cache policy from services.translation.cache. | ||
| 212 | - | ||
| 213 | - All translation cache key/TTL behavior should be configured in config.yaml, | ||
| 214 | - not hardcoded in code. | ||
| 215 | - """ | ||
| 216 | - raw = _load_services_raw() | ||
| 217 | - cfg = raw.get("translation", {}) if isinstance(raw.get("translation"), dict) else {} | ||
| 218 | - cache_cfg = cfg.get("cache", {}) if isinstance(cfg.get("cache"), dict) else {} | 259 | + cache_cfg = get_translation_config().cache |
| 219 | return { | 260 | return { |
| 220 | "enabled": bool(cache_cfg.get("enabled", True)), | 261 | "enabled": bool(cache_cfg.get("enabled", True)), |
| 221 | "key_prefix": str(cache_cfg.get("key_prefix", "trans:v2")), | 262 | "key_prefix": str(cache_cfg.get("key_prefix", "trans:v2")), |
| @@ -228,31 +269,23 @@ def get_translation_cache_config() -> Dict[str, Any]: | @@ -228,31 +269,23 @@ def get_translation_cache_config() -> Dict[str, Any]: | ||
| 228 | 269 | ||
| 229 | 270 | ||
| 230 | def get_embedding_base_url() -> str: | 271 | def get_embedding_base_url() -> str: |
| 231 | - """Resolve embedding HTTP base URL.""" | ||
| 232 | - base = ( | ||
| 233 | - os.getenv("EMBEDDING_SERVICE_URL") | ||
| 234 | - or get_embedding_config().providers.get("http", {}).get("base_url") | ||
| 235 | - ) | 272 | + base = os.getenv("EMBEDDING_SERVICE_URL") or get_embedding_config().providers.get("http", {}).get("base_url") |
| 236 | if not base: | 273 | if not base: |
| 237 | raise ValueError("Embedding HTTP base_url is not configured") | 274 | raise ValueError("Embedding HTTP base_url is not configured") |
| 238 | return str(base).rstrip("/") | 275 | return str(base).rstrip("/") |
| 239 | 276 | ||
| 240 | 277 | ||
| 241 | -def get_rerank_service_url() -> str: | ||
| 242 | - """Resolve rerank service URL (full path including /rerank).""" | 278 | +def get_rerank_base_url() -> str: |
| 243 | base = ( | 279 | base = ( |
| 244 | os.getenv("RERANKER_SERVICE_URL") | 280 | os.getenv("RERANKER_SERVICE_URL") |
| 245 | or get_rerank_config().providers.get("http", {}).get("service_url") | 281 | or get_rerank_config().providers.get("http", {}).get("service_url") |
| 246 | or get_rerank_config().providers.get("http", {}).get("base_url") | 282 | or get_rerank_config().providers.get("http", {}).get("base_url") |
| 247 | ) | 283 | ) |
| 248 | if not base: | 284 | if not base: |
| 249 | - raise ValueError("Rerank HTTP service_url/base_url is not configured") | ||
| 250 | - base = str(base).rstrip("/") | ||
| 251 | - return base if base.endswith("/rerank") else f"{base}/rerank" | 285 | + raise ValueError("Rerank HTTP base_url is not configured") |
| 286 | + return str(base).rstrip("/") | ||
| 252 | 287 | ||
| 253 | 288 | ||
| 254 | -def clear_services_cache() -> None: | ||
| 255 | - """Clear cached config (for tests).""" | ||
| 256 | - get_translation_config.cache_clear() | ||
| 257 | - get_embedding_config.cache_clear() | ||
| 258 | - get_rerank_config.cache_clear() | 289 | +def get_rerank_service_url() -> str: |
| 290 | + """Backward-compatible alias.""" | ||
| 291 | + return get_rerank_base_url() |
docs/DEVELOPER_GUIDE.md
| @@ -166,14 +166,14 @@ docs/ # 文档(含本指南) | @@ -166,14 +166,14 @@ docs/ # 文档(含本指南) | ||
| 166 | 166 | ||
| 167 | ### 4.8 providers | 167 | ### 4.8 providers |
| 168 | 168 | ||
| 169 | -- **职责**:统一“能力”的调用方式:翻译、向量、重排均通过工厂函数(如 `create_translation_provider()`、`create_rerank_provider()`、`create_embedding_provider()`)获取实现,配置来自 `config/services_config`(即 `config.yaml` 的 `services` + 环境变量)。 | ||
| 170 | -- **原则**:业务代码只依赖 Provider 接口,不依赖具体 URL 或后端类型;新增调用方式(如新 Provider 类型)在对应 `providers/<capability>.py` 中实现并在工厂中注册。 | 169 | +- **职责**:统一“能力”的调用方式。向量、重排仍是标准 provider 工厂;翻译侧的 `create_translation_provider()` 现在固定返回 translator service client,由 6006 服务统一承接后端选择与路由。 |
| 170 | +- **原则**:业务代码只依赖调用接口,不依赖具体 URL 或服务内后端类型;翻译能力新增时优先扩展 `translation/backends/` 与 `services.translation.capabilities`,而不是在业务侧新增 provider 分支。 | ||
| 171 | - **详见**:本指南 §7.2;[QUICKSTART.md](./QUICKSTART.md) §3。 | 171 | - **详见**:本指南 §7.2;[QUICKSTART.md](./QUICKSTART.md) §3。 |
| 172 | 172 | ||
| 173 | -补充约定(翻译 provider): | 173 | +补充约定(翻译 client): |
| 174 | 174 | ||
| 175 | - `translate(text=...)` 支持 `str` 与 `List[str]` 两种输入;当输入为列表时,输出必须与输入 **等长且顺序对应**,失败位置为 `None`(HTTP JSON 表现为 `null`)。 | 175 | - `translate(text=...)` 支持 `str` 与 `List[str]` 两种输入;当输入为列表时,输出必须与输入 **等长且顺序对应**,失败位置为 `None`(HTTP JSON 表现为 `null`)。 |
| 176 | -- provider 可暴露 `supports_batch: bool`(property)用于标识其是否支持直接批量调用;上层在处理 `text` 为列表时可优先走 batch,否则逐条拆分调用。 | 176 | +- client / backend 可暴露 `supports_batch: bool`(property)用于标识其是否支持直接批量调用;上层在处理 `text` 为列表时可优先走 batch,否则逐条拆分调用。 |
| 177 | 177 | ||
| 178 | ### 4.9 suggestion | 178 | ### 4.9 suggestion |
| 179 | 179 | ||
| @@ -197,18 +197,19 @@ docs/ # 文档(含本指南) | @@ -197,18 +197,19 @@ docs/ # 文档(含本指南) | ||
| 197 | ### 5.2 配置驱动 | 197 | ### 5.2 配置驱动 |
| 198 | 198 | ||
| 199 | - 搜索行为(字段权重、搜索域、排序、function_score、重排融合参数等)来自 `config/config.yaml`,由 `ConfigLoader` 加载。 | 199 | - 搜索行为(字段权重、搜索域、排序、function_score、重排融合参数等)来自 `config/config.yaml`,由 `ConfigLoader` 加载。 |
| 200 | -- 能力访问(翻译/向量/重排的 provider、URL、后端类型)来自 `config.yaml` 的 `services` 块及环境变量,由 `config/services_config` 解析。 | 200 | +- 能力访问来自 `config.yaml` 的 `services` 块及环境变量,由 `config/services_config` 解析。 |
| 201 | +- 其中翻译单独采用“service + capabilities”模型:调用方只配 `service_url` / `default_model` / `default_scene`,服务内通过 `capabilities` 控制启用哪些翻译能力。 | ||
| 201 | - 新增开关或参数时,优先在现有 config 结构下扩展,避免新增散落配置文件。 | 202 | - 新增开关或参数时,优先在现有 config 结构下扩展,避免新增散落配置文件。 |
| 202 | 203 | ||
| 203 | ### 5.3 单一配置源与优先级 | 204 | ### 5.3 单一配置源与优先级 |
| 204 | 205 | ||
| 205 | - 同一类配置只在一个地方定义默认值;覆盖顺序约定为:**环境变量 > config 文件**。 | 206 | - 同一类配置只在一个地方定义默认值;覆盖顺序约定为:**环境变量 > config 文件**。 |
| 206 | -- 服务 URL、后端类型等均在 `services.<capability>` 下配置;环境变量用于部署态覆盖(如 `RERANKER_SERVICE_URL`、`RERANK_BACKEND`)。 | 207 | +- 服务 URL、后端类型等均在 `services.<capability>` 下配置;环境变量用于部署态覆盖(如 `TRANSLATION_SERVICE_URL`、`TRANSLATION_MODEL`、`RERANKER_SERVICE_URL`、`RERANK_BACKEND`)。 |
| 207 | 208 | ||
| 208 | -### 5.4 调用方与实现解耦(Provider + Backend) | 209 | +### 5.4 调用方与实现解耦(Client + Backend) |
| 209 | 210 | ||
| 210 | -- **调用方**:通过 Provider(如 `HttpRerankProvider`)访问能力,不依赖具体 URL 或服务内实现。 | ||
| 211 | -- **服务内**:通过“后端”实现具体推理(如 BGE 与 Qwen3-vLLM);后端实现协议、在配置与工厂中注册即可插拔。 | 211 | +- **调用方**:通过 client/provider 访问能力,不依赖具体 URL 或服务内实现;翻译调用方统一连 translator service。 |
| 212 | +- **服务内**:通过“后端”实现具体推理(如 qwen-mt、DeepL、LLM、本地模型;或 BGE 与 Qwen3-vLLM);后端实现协议、在配置与工厂中注册即可插拔。 | ||
| 212 | - 新增“一种调用方式”在 providers 中扩展;新增“一种推理实现”在对应服务的 backends 中扩展,并遵循本指南 §7。 | 213 | - 新增“一种调用方式”在 providers 中扩展;新增“一种推理实现”在对应服务的 backends 中扩展,并遵循本指南 §7。 |
| 213 | 214 | ||
| 214 | ### 5.5 协议契约 | 215 | ### 5.5 协议契约 |
| @@ -246,7 +247,7 @@ docs/ # 文档(含本指南) | @@ -246,7 +247,7 @@ docs/ # 文档(含本指南) | ||
| 246 | 247 | ||
| 247 | ### 6.1 主配置文件 | 248 | ### 6.1 主配置文件 |
| 248 | 249 | ||
| 249 | -- **config/config.yaml**:搜索行为(field_boosts、query_config.search_fields、query_config.text_query_strategy,含翻译失败时的原文兜底 boost、ranking、function_score、rerank 融合参数)、SPU 配置、**services**(翻译/向量/重排的 provider 与 backends)、tenant_config 等。 | 250 | +- **config/config.yaml**:搜索行为(field_boosts、query_config.search_fields、query_config.text_query_strategy,含翻译失败时的原文兜底 boost、ranking、function_score、rerank 融合参数)、SPU 配置、**services**(翻译 service 与 capabilities、向量/重排的 provider 与 backends)、tenant_config 等。 |
| 250 | - **.env**:敏感信息与部署态变量(DB、ES、Redis、API Key、端口等);不提交敏感值,可提供 `.env.example` 模板。 | 251 | - **.env**:敏感信息与部署态变量(DB、ES、Redis、API Key、端口等);不提交敏感值,可提供 `.env.example` 模板。 |
| 251 | 252 | ||
| 252 | ### 6.2 services 块结构(能力统一约定) | 253 | ### 6.2 services 块结构(能力统一约定) |
| @@ -265,14 +266,31 @@ services: | @@ -265,14 +266,31 @@ services: | ||
| 265 | qwen3_vllm: { ... } | 266 | qwen3_vllm: { ... } |
| 266 | ``` | 267 | ``` |
| 267 | 268 | ||
| 269 | +翻译是特例,结构为: | ||
| 270 | + | ||
| 271 | +```yaml | ||
| 272 | +services: | ||
| 273 | + translation: | ||
| 274 | + service_url: "http://127.0.0.1:6006" | ||
| 275 | + default_model: "llm" | ||
| 276 | + default_scene: "general" | ||
| 277 | + timeout_sec: 10.0 | ||
| 278 | + capabilities: | ||
| 279 | + llm: { enabled: true, model: "qwen-flash" } | ||
| 280 | + qwen-mt: { enabled: true, model: "qwen-mt-flash" } | ||
| 281 | + deepl: { enabled: false, timeout_sec: 10.0 } | ||
| 282 | +``` | ||
| 283 | + | ||
| 268 | - **provider**:调用方如何访问(如 HTTP)。 | 284 | - **provider**:调用方如何访问(如 HTTP)。 |
| 269 | - **backend / backends**:当能力由本仓库内服务提供时,该服务加载哪个后端及参数。 | 285 | - **backend / backends**:当能力由本仓库内服务提供时,该服务加载哪个后端及参数。 |
| 286 | +- **translation.service_url**:业务侧统一调用的翻译服务地址。 | ||
| 287 | +- **translation.capabilities**:翻译服务内部可启用的能力注册表。 | ||
| 270 | - 解析入口:`config/services_config.py` 的 `get_*_config()` 及 `get_*_base_url()` / `get_rerank_service_url()` 等。 | 288 | - 解析入口:`config/services_config.py` 的 `get_*_config()` 及 `get_*_base_url()` / `get_rerank_service_url()` 等。 |
| 271 | 289 | ||
| 272 | ### 6.3 环境变量(常用) | 290 | ### 6.3 环境变量(常用) |
| 273 | 291 | ||
| 274 | - 能力 URL:`TRANSLATION_SERVICE_URL`、`EMBEDDING_SERVICE_URL`、`RERANKER_SERVICE_URL` | 292 | - 能力 URL:`TRANSLATION_SERVICE_URL`、`EMBEDDING_SERVICE_URL`、`RERANKER_SERVICE_URL` |
| 275 | -- 能力选择:`TRANSLATION_PROVIDER`、`EMBEDDING_PROVIDER`、`EMBEDDING_BACKEND`、`RERANK_PROVIDER`、`RERANK_BACKEND` | 293 | +- 能力选择:`TRANSLATION_MODEL`、`TRANSLATION_SCENE`、`EMBEDDING_PROVIDER`、`EMBEDDING_BACKEND`、`RERANK_PROVIDER`、`RERANK_BACKEND` |
| 276 | - 环境与索引:`ES_HOST`、`ES_INDEX_NAMESPACE`、`RUNTIME_ENV`、DB 与 Redis 等 | 294 | - 环境与索引:`ES_HOST`、`ES_INDEX_NAMESPACE`、`RUNTIME_ENV`、DB 与 Redis 等 |
| 277 | 295 | ||
| 278 | 详见 [QUICKSTART.md](./QUICKSTART.md) §1.6(.env 与生产凭证)、[Usage-Guide.md](./Usage-Guide.md)。 | 296 | 详见 [QUICKSTART.md](./QUICKSTART.md) §1.6(.env 与生产凭证)、[Usage-Guide.md](./Usage-Guide.md)。 |
| @@ -293,6 +311,13 @@ services: | @@ -293,6 +311,13 @@ services: | ||
| 293 | 3. 在 `config/config.yaml` 的 `services.<capability>.providers` 下补充参数。 | 311 | 3. 在 `config/config.yaml` 的 `services.<capability>.providers` 下补充参数。 |
| 294 | 4. 不修改业务调用方(search/query/indexer 仍通过工厂获取实例)。 | 312 | 4. 不修改业务调用方(search/query/indexer 仍通过工厂获取实例)。 |
| 295 | 313 | ||
| 314 | +翻译不再按这套扩展。新增翻译能力时: | ||
| 315 | + | ||
| 316 | +1. 在 `translation/backends/` 中实现新 backend。 | ||
| 317 | +2. 在 `translation/service.py` 中注册工厂。 | ||
| 318 | +3. 在 `services.translation.capabilities.<name>` 下增加配置,并用 `enabled` 控制是否启用。 | ||
| 319 | +4. 业务调用方保持不变,仍只通过 `create_translation_provider()` 调 6006。 | ||
| 320 | + | ||
| 296 | ### 7.3 新增 Backend(推理实现) | 321 | ### 7.3 新增 Backend(推理实现) |
| 297 | 322 | ||
| 298 | 1. **实现协议**:在对应目录(如 `reranker/backends/`、`embeddings/`)实现满足协议接口的类。 | 323 | 1. **实现协议**:在对应目录(如 `reranker/backends/`、`embeddings/`)实现满足协议接口的类。 |
docs/TODO.txt
| @@ -84,7 +84,7 @@ When sorting on a field, scores are not computed. By setting track_scores to tru | @@ -84,7 +84,7 @@ When sorting on a field, scores are not computed. By setting track_scores to tru | ||
| 84 | provider backend 两者的关系,如何配合。 | 84 | provider backend 两者的关系,如何配合。 |
| 85 | translator的设计 : | 85 | translator的设计 : |
| 86 | 86 | ||
| 87 | -QueryParser 里面 并不是调用的6006,目前是把6006做了一个provider,然后translate的总体配置又有6006的baseurl,很混乱!!! | 87 | +QueryParser 里面 并不是调用的6006,目前是把6006做了一个provider,然后translate的总体配置又有6006的baseurl,很混乱。 |
| 88 | 88 | ||
| 89 | config.yaml 里面的 翻译的配置 不是“6006 专用配置”,而是搜索服务的 | 89 | config.yaml 里面的 翻译的配置 不是“6006 专用配置”,而是搜索服务的 |
| 90 | 6006本来之前是做一个provider。 | 90 | 6006本来之前是做一个provider。 |
docs/翻译模块说明.md
| @@ -12,7 +12,9 @@ DASHSCOPE_API_KEY=sk-xxx | @@ -12,7 +12,9 @@ DASHSCOPE_API_KEY=sk-xxx | ||
| 12 | DEEPL_AUTH_KEY=xxx | 12 | DEEPL_AUTH_KEY=xxx |
| 13 | 13 | ||
| 14 | # 可选 | 14 | # 可选 |
| 15 | -TRANSLATION_MODEL=qwen # 或 deepl | 15 | +TRANSLATION_SERVICE_URL=http://127.0.0.1:6006 |
| 16 | +TRANSLATION_MODEL=llm # 默认能力;也可传 qwen-mt / deepl | ||
| 17 | +TRANSLATION_SCENE=general | ||
| 16 | ``` | 18 | ``` |
| 17 | 19 | ||
| 18 | > **重要限速说明(Qwen 机翻)** | 20 | > **重要限速说明(Qwen 机翻)** |
| @@ -21,9 +23,34 @@ TRANSLATION_MODEL=qwen # 或 deepl | @@ -21,9 +23,34 @@ TRANSLATION_MODEL=qwen # 或 deepl | ||
| 21 | > - 高并发场景需要在调用端做限流 / 去抖,或改为离线批量翻译 | 23 | > - 高并发场景需要在调用端做限流 / 去抖,或改为离线批量翻译 |
| 22 | > - 如需更高吞吐,可考虑 DeepL 或自建翻译服务 | 24 | > - 如需更高吞吐,可考虑 DeepL 或自建翻译服务 |
| 23 | 25 | ||
| 24 | -## Provider 配置 | ||
| 25 | - | ||
| 26 | -Provider 与 URL 在 `config/config.yaml` 的 `services.translation`。详见 [QUICKSTART.md](./QUICKSTART.md) §3 与 [DEVELOPER_GUIDE.md](./DEVELOPER_GUIDE.md) §7.2。 | 26 | +## 配置模型 |
| 27 | + | ||
| 28 | +翻译已改为“一个翻译服务 + 多种翻译能力”的结构: | ||
| 29 | + | ||
| 30 | +- 业务侧(`QueryParser` / indexer)统一调用 `http://127.0.0.1:6006` | ||
| 31 | +- 服务内按 `services.translation.capabilities` 加载并管理各翻译能力 | ||
| 32 | +- 每种能力独立配置 `enabled`、`model`、`timeout` 等参数 | ||
| 33 | +- 外部接口通过 `model + scene` 指定本次使用哪种能力、哪个场景 | ||
| 34 | + | ||
| 35 | +配置入口在 `config/config.yaml -> services.translation`,核心字段示例: | ||
| 36 | + | ||
| 37 | +```yaml | ||
| 38 | +services: | ||
| 39 | + translation: | ||
| 40 | + service_url: "http://127.0.0.1:6006" | ||
| 41 | + default_model: "llm" | ||
| 42 | + default_scene: "general" | ||
| 43 | + timeout_sec: 10.0 | ||
| 44 | + capabilities: | ||
| 45 | + qwen-mt: | ||
| 46 | + enabled: true | ||
| 47 | + model: "qwen-mt-flash" | ||
| 48 | + llm: | ||
| 49 | + enabled: true | ||
| 50 | + model: "qwen-flash" | ||
| 51 | + deepl: | ||
| 52 | + enabled: false | ||
| 53 | +``` | ||
| 27 | 54 | ||
| 28 | ## HTTP 接口契约(translator service,端口 6006) | 55 | ## HTTP 接口契约(translator service,端口 6006) |
| 29 | 56 | ||
| @@ -41,8 +68,8 @@ Provider 与 URL 在 `config/config.yaml` 的 `services.translation`。详见 [Q | @@ -41,8 +68,8 @@ Provider 与 URL 在 `config/config.yaml` 的 `services.translation`。详见 [Q | ||
| 41 | "text": "商品名称", | 68 | "text": "商品名称", |
| 42 | "target_lang": "en", | 69 | "target_lang": "en", |
| 43 | "source_lang": "zh", | 70 | "source_lang": "zh", |
| 44 | - "model": "qwen", | ||
| 45 | - "context": "sku_name", | 71 | + "model": "qwen-mt", |
| 72 | + "scene": "sku_name", | ||
| 46 | "prompt": null | 73 | "prompt": null |
| 47 | } | 74 | } |
| 48 | ``` | 75 | ``` |
| @@ -60,7 +87,8 @@ Provider 与 URL 在 `config/config.yaml` 的 `services.translation`。详见 [Q | @@ -60,7 +87,8 @@ Provider 与 URL 在 `config/config.yaml` 的 `services.translation`。详见 [Q | ||
| 60 | "source_lang": "zh", | 87 | "source_lang": "zh", |
| 61 | "translated_text": "Product name", | 88 | "translated_text": "Product name", |
| 62 | "status": "success", | 89 | "status": "success", |
| 63 | - "model": "qwen" | 90 | + "model": "qwen-mt", |
| 91 | + "scene": "sku_name" | ||
| 64 | } | 92 | } |
| 65 | ``` | 93 | ``` |
| 66 | 94 | ||
| @@ -73,17 +101,24 @@ Provider 与 URL 在 `config/config.yaml` 的 `services.translation`。详见 [Q | @@ -73,17 +101,24 @@ Provider 与 URL 在 `config/config.yaml` 的 `services.translation`。详见 [Q | ||
| 73 | "source_lang": "zh", | 101 | "source_lang": "zh", |
| 74 | "translated_text": ["Product name 1", null], | 102 | "translated_text": ["Product name 1", null], |
| 75 | "status": "success", | 103 | "status": "success", |
| 76 | - "model": "qwen" | 104 | + "model": "qwen-mt", |
| 105 | + "scene": "sku_name" | ||
| 77 | } | 106 | } |
| 78 | ``` | 107 | ``` |
| 79 | 108 | ||
| 80 | 批量模式下,**单条失败用 `null` 占位**(即 `translated_text[i] = null`),保证长度与顺序一一对应,避免部分失败导致整批报错。 | 109 | 批量模式下,**单条失败用 `null` 占位**(即 `translated_text[i] = null`),保证长度与顺序一一对应,避免部分失败导致整批报错。 |
| 81 | 110 | ||
| 111 | +说明: | ||
| 112 | + | ||
| 113 | +- `scene` 是标准字段,`context` 仅保留为兼容别名 | ||
| 114 | +- `model` 只能选择已在 `services.translation.capabilities` 中启用的能力 | ||
| 115 | +- `/health` 会返回 `default_model`、`default_scene` 与 `enabled_capabilities` | ||
| 116 | + | ||
| 82 | --- | 117 | --- |
| 83 | 118 | ||
| 84 | -## 开发者接口约定(Provider / 代码调用) | 119 | +## 开发者接口约定(代码调用) |
| 85 | 120 | ||
| 86 | -除 HTTP 微服务外,代码侧(如 query/indexer)通常通过 `providers.translation.create_translation_provider()` 获取翻译 provider 实例并调用 `translate()`。 | 121 | +代码侧(如 query/indexer)仍通过 `providers.translation.create_translation_provider()` 获取实例并调用 `translate()`,但该实例现在固定是 **translator service client**,不再在业务侧做翻译 provider 选择。 |
| 87 | 122 | ||
| 88 | ### 输入输出形状(Shape) | 123 | ### 输入输出形状(Shape) |
| 89 | 124 | ||
| @@ -94,13 +129,8 @@ Provider 与 URL 在 `config/config.yaml` 的 `services.translation`。详见 [Q | @@ -94,13 +129,8 @@ Provider 与 URL 在 `config/config.yaml` 的 `services.translation`。详见 [Q | ||
| 94 | 129 | ||
| 95 | ### 批量能力标识(supports_batch) | 130 | ### 批量能力标识(supports_batch) |
| 96 | 131 | ||
| 97 | -不同 provider 对批量的实现方式可能不同(例如:真正一次请求传多条,或内部循环逐条翻译并保持 shape)。 | 132 | +服务客户端与服务内后端都可以暴露 `supports_batch`。若后端不支持批量,服务端会逐条拆分并保持 shape。 |
| 98 | 133 | ||
| 99 | 为便于上层(如 `api/translator_app.py`)做最优调用,provider 可暴露: | 134 | 为便于上层(如 `api/translator_app.py`)做最优调用,provider 可暴露: |
| 100 | 135 | ||
| 101 | - `supports_batch: bool`(property) | 136 | - `supports_batch: bool`(property) |
| 102 | - | ||
| 103 | -上层在收到 `text` 为列表时: | ||
| 104 | - | ||
| 105 | -- **若 `supports_batch=True`**:可以直接将列表传给 `translate(text=[...])` | ||
| 106 | -- **若 `supports_batch=False`**:上层会逐条拆分调用(仍保证输出列表一一对应、失败为 `null`) |
providers/translation.py
| 1 | -"""Translation provider factory and HTTP provider implementation.""" | ||
| 2 | -from __future__ import annotations | ||
| 3 | - | ||
| 4 | -import logging | ||
| 5 | -from typing import Any, Dict, List, Optional, Sequence, Union | ||
| 6 | -import requests | ||
| 7 | - | ||
| 8 | -from config.services_config import get_translation_config, get_translation_base_url | ||
| 9 | - | ||
| 10 | -logger = logging.getLogger(__name__) | ||
| 11 | - | ||
| 12 | - | ||
| 13 | -class HttpTranslationProvider: | ||
| 14 | - """Translation via HTTP service.""" | ||
| 15 | - | ||
| 16 | - def __init__( | ||
| 17 | - self, | ||
| 18 | - base_url: str, | ||
| 19 | - model: str = "qwen", | ||
| 20 | - timeout_sec: float = 10.0, | ||
| 21 | - ): | ||
| 22 | - self.base_url = (base_url or "").rstrip("/") | ||
| 23 | - self.model = model or "qwen" | ||
| 24 | - self.timeout_sec = float(timeout_sec or 10.0) | ||
| 25 | - | ||
| 26 | - @property | ||
| 27 | - def supports_batch(self) -> bool: | ||
| 28 | - """ | ||
| 29 | - Whether this provider supports list input natively. | ||
| 30 | - | ||
| 31 | - 当前实现中,我们已经在 `_translate_once` 内处理了 list, | ||
| 32 | - 所以可以直接视为支持 batch。 | ||
| 33 | - """ | ||
| 34 | - return True | 1 | +"""Translation client factory for business callers.""" |
| 35 | 2 | ||
| 36 | - def _translate_once( | ||
| 37 | - self, | ||
| 38 | - text: Union[str, Sequence[str]], | ||
| 39 | - target_lang: str, | ||
| 40 | - source_lang: Optional[str] = None, | ||
| 41 | - context: Optional[str] = None, | ||
| 42 | - prompt: Optional[str] = None, | ||
| 43 | - ) -> Union[Optional[str], List[Optional[str]]]: | ||
| 44 | - # 允许 text 为单个字符串或字符串列表 | ||
| 45 | - if isinstance(text, (list, tuple)): | ||
| 46 | - # 上游约定:列表输入时,输出列表一一对应;失败位置为 None | ||
| 47 | - results: List[Optional[str]] = [] | ||
| 48 | - for item in text: | ||
| 49 | - if item is None or not str(item).strip(): | ||
| 50 | - # 空字符串/None 不视为失败,原样返回以保持语义 | ||
| 51 | - results.append(item) # type: ignore[arg-type] | ||
| 52 | - continue | ||
| 53 | - try: | ||
| 54 | - single = self._translate_once( | ||
| 55 | - text=str(item), | ||
| 56 | - target_lang=target_lang, | ||
| 57 | - source_lang=source_lang, | ||
| 58 | - context=context, | ||
| 59 | - prompt=prompt, | ||
| 60 | - ) | ||
| 61 | - results.append(single) # type: ignore[arg-type] | ||
| 62 | - except Exception: | ||
| 63 | - # 理论上不会进入,因为内部已捕获;兜底保持长度一致 | ||
| 64 | - results.append(None) | ||
| 65 | - return results | 3 | +from __future__ import annotations |
| 66 | 4 | ||
| 67 | - if not text or not str(text).strip(): | ||
| 68 | - return text # type: ignore[return-value] | ||
| 69 | - try: | ||
| 70 | - url = f"{self.base_url}/translate" | ||
| 71 | - payload = { | ||
| 72 | - "text": text, | ||
| 73 | - "target_lang": target_lang, | ||
| 74 | - "source_lang": source_lang or "auto", | ||
| 75 | - "model": self.model, | ||
| 76 | - } | ||
| 77 | - if context: | ||
| 78 | - payload["context"] = context | ||
| 79 | - if prompt: | ||
| 80 | - payload["prompt"] = prompt | ||
| 81 | - response = requests.post(url, json=payload, timeout=self.timeout_sec) | ||
| 82 | - if response.status_code != 200: | ||
| 83 | - logger.warning( | ||
| 84 | - "HTTP translator failed: status=%s body=%s", | ||
| 85 | - response.status_code, | ||
| 86 | - (response.text or "")[:200], | ||
| 87 | - ) | ||
| 88 | - return None | ||
| 89 | - data = response.json() | ||
| 90 | - translated = data.get("translated_text") | ||
| 91 | - return translated if translated is not None else None | ||
| 92 | - except Exception as exc: | ||
| 93 | - logger.warning("HTTP translator request failed: %s", exc, exc_info=True) | ||
| 94 | - return None | 5 | +from typing import Any |
| 95 | 6 | ||
| 96 | - def translate( | ||
| 97 | - self, | ||
| 98 | - text: Union[str, Sequence[str]], | ||
| 99 | - target_lang: str, | ||
| 100 | - source_lang: Optional[str] = None, | ||
| 101 | - context: Optional[str] = None, | ||
| 102 | - prompt: Optional[str] = None, | ||
| 103 | - ) -> Union[Optional[str], List[Optional[str]]]: | ||
| 104 | - return self._translate_once( | ||
| 105 | - text=text, | ||
| 106 | - target_lang=target_lang, | ||
| 107 | - source_lang=source_lang, | ||
| 108 | - context=context, | ||
| 109 | - prompt=prompt, | ||
| 110 | - ) | 7 | +from config.services_config import get_translation_config |
| 8 | +from translation.client import TranslationServiceClient | ||
| 111 | 9 | ||
| 112 | 10 | ||
| 113 | -def create_translation_provider(query_config: Any = None) -> Any: | 11 | +def create_translation_provider(query_config: Any = None) -> TranslationServiceClient: |
| 114 | """ | 12 | """ |
| 115 | - Create translation provider from services config. | 13 | + Create a translation client. |
| 116 | 14 | ||
| 117 | - query_config: optional, for api_key/glossary_id/context (used by direct provider). | 15 | + Translation is no longer selected via provider mechanism on the caller side. |
| 16 | + Search / indexer always talk to the translator service, while the service | ||
| 17 | + itself decides which translation capabilities are enabled and how to route. | ||
| 118 | """ | 18 | """ |
| 119 | - cfg = get_translation_config() | ||
| 120 | - provider = cfg.provider | ||
| 121 | - pc = cfg.get_provider_cfg() | ||
| 122 | - | ||
| 123 | - if provider in ("qwen-mt", "direct", "local", "inprocess"): | ||
| 124 | - from query.qwen_mt_translate import Translator | ||
| 125 | - model = pc.get("model") or "qwen-mt-flash" | ||
| 126 | - qc = query_config or _empty_query_config() | ||
| 127 | - return Translator( | ||
| 128 | - model=model, | ||
| 129 | - api_key=getattr(qc, "translation_api_key", None), | ||
| 130 | - use_cache=True, | ||
| 131 | - glossary_id=getattr(qc, "translation_glossary_id", None), | ||
| 132 | - translation_context=getattr(qc, "translation_context", "e-commerce product search"), | ||
| 133 | - ) | ||
| 134 | - | ||
| 135 | - elif provider in ("http", "service"): | ||
| 136 | - base_url = get_translation_base_url() | ||
| 137 | - model = pc.get("model") or "qwen" | ||
| 138 | - timeout = pc.get("timeout_sec", 10.0) | ||
| 139 | - qc = query_config or _empty_query_config() | ||
| 140 | - return HttpTranslationProvider( | ||
| 141 | - base_url=base_url, | ||
| 142 | - model=model, | ||
| 143 | - timeout_sec=float(timeout), | ||
| 144 | - ) | ||
| 145 | - | ||
| 146 | - elif provider == "llm": | ||
| 147 | - from query.llm_translate import LLMTranslatorProvider | ||
| 148 | - model = pc.get("model") | ||
| 149 | - timeout = float(pc.get("timeout_sec", 30.0)) | ||
| 150 | - base_url = (pc.get("base_url") or "").strip() or None | ||
| 151 | - return LLMTranslatorProvider( | ||
| 152 | - model=model, | ||
| 153 | - timeout_sec=timeout, | ||
| 154 | - base_url=base_url, | ||
| 155 | - ) | ||
| 156 | 19 | ||
| 157 | - elif provider == "deepl": | ||
| 158 | - from query.deepl_provider import DeepLProvider | ||
| 159 | - qc = query_config or _empty_query_config() | ||
| 160 | - return DeepLProvider( | ||
| 161 | - api_key=getattr(qc, "translation_api_key", None), | ||
| 162 | - timeout=float(pc.get("timeout_sec", 10.0)), | ||
| 163 | - glossary_id=pc.get("glossary_id") or getattr(qc, "translation_glossary_id", None), | ||
| 164 | - ) | ||
| 165 | - | ||
| 166 | - raise ValueError(f"Unsupported translation provider: {provider}") | ||
| 167 | - | ||
| 168 | - | ||
| 169 | -def _empty_query_config() -> Any: | ||
| 170 | - """Minimal object with default translation attrs.""" | ||
| 171 | - class _QC: | ||
| 172 | - translation_api_key = None | ||
| 173 | - translation_glossary_id = None | ||
| 174 | - translation_context = "e-commerce product search" | ||
| 175 | - return _QC() | 20 | + cfg = get_translation_config() |
| 21 | + qc = query_config | ||
| 22 | + default_scene = getattr(qc, "translation_context", None) if qc is not None else None | ||
| 23 | + return TranslationServiceClient( | ||
| 24 | + base_url=cfg.service_url, | ||
| 25 | + default_model=cfg.default_model, | ||
| 26 | + default_scene=default_scene or cfg.default_scene, | ||
| 27 | + timeout_sec=cfg.timeout_sec, | ||
| 28 | + ) |
query/deepl_provider.py
| 1 | -""" | ||
| 2 | -DeepL backend provider. | ||
| 3 | - | ||
| 4 | -This module only handles network calls to DeepL. | ||
| 5 | -It does not handle cache, async fanout, or fallback semantics. | ||
| 6 | -""" | ||
| 7 | - | ||
| 8 | -from __future__ import annotations | ||
| 9 | - | ||
| 10 | -import logging | ||
| 11 | -import os | ||
| 12 | -import re | ||
| 13 | -from typing import Dict, List, Optional, Sequence, Tuple, Union | ||
| 14 | - | ||
| 15 | -import requests | ||
| 16 | -from config.services_config import get_translation_config | ||
| 17 | - | ||
| 18 | - | ||
| 19 | -logger = logging.getLogger(__name__) | ||
| 20 | - | ||
| 21 | -DEFAULT_CONTEXTS: Dict[str, Dict[str, str]] = { | ||
| 22 | - "sku_name": { | ||
| 23 | - "zh": "商品SKU名称", | ||
| 24 | - "en": "product SKU name", | ||
| 25 | - }, | ||
| 26 | - "ecommerce_search_query": { | ||
| 27 | - "zh": "电商", | ||
| 28 | - "en": "e-commerce", | ||
| 29 | - }, | ||
| 30 | - "general": { | ||
| 31 | - "zh": "", | ||
| 32 | - "en": "", | ||
| 33 | - }, | ||
| 34 | -} | ||
| 35 | -SCENE_NAMES = frozenset(DEFAULT_CONTEXTS.keys()) | ||
| 36 | - | ||
| 37 | - | ||
| 38 | -def _merge_contexts(raw: object) -> Dict[str, Dict[str, str]]: | ||
| 39 | - merged: Dict[str, Dict[str, str]] = { | ||
| 40 | - scene: dict(lang_map) for scene, lang_map in DEFAULT_CONTEXTS.items() | ||
| 41 | - } | ||
| 42 | - if not isinstance(raw, dict): | ||
| 43 | - return merged | ||
| 44 | - for scene, lang_map in raw.items(): | ||
| 45 | - if not isinstance(lang_map, dict): | ||
| 46 | - continue | ||
| 47 | - scene_name = str(scene or "").strip() | ||
| 48 | - if not scene_name: | ||
| 49 | - continue | ||
| 50 | - merged.setdefault(scene_name, {}) | ||
| 51 | - for lang, value in lang_map.items(): | ||
| 52 | - lang_key = str(lang or "").strip().lower() | ||
| 53 | - context_value = str(value or "").strip() | ||
| 54 | - if lang_key and context_value: | ||
| 55 | - merged[scene_name][lang_key] = context_value | ||
| 56 | - return merged | ||
| 57 | - | ||
| 58 | - | ||
| 59 | -class DeepLProvider: | ||
| 60 | - API_URL = "https://api.deepl.com/v2/translate" # Pro tier | ||
| 61 | - LANG_CODE_MAP = { | ||
| 62 | - "zh": "ZH", | ||
| 63 | - "en": "EN", | ||
| 64 | - "ru": "RU", | ||
| 65 | - "ar": "AR", | ||
| 66 | - "ja": "JA", | ||
| 67 | - "es": "ES", | ||
| 68 | - "de": "DE", | ||
| 69 | - "fr": "FR", | ||
| 70 | - "it": "IT", | ||
| 71 | - "pt": "PT", | ||
| 72 | - } | ||
| 73 | - | ||
| 74 | - def __init__( | ||
| 75 | - self, | ||
| 76 | - api_key: Optional[str], | ||
| 77 | - *, | ||
| 78 | - timeout: float = 10.0, | ||
| 79 | - glossary_id: Optional[str] = None, | ||
| 80 | - ) -> None: | ||
| 81 | - cfg = get_translation_config() | ||
| 82 | - provider_cfg = cfg.providers.get("deepl", {}) if isinstance(cfg.providers, dict) else {} | ||
| 83 | - self.api_key = api_key or os.getenv("DEEPL_AUTH_KEY") | ||
| 84 | - self.timeout = float(provider_cfg.get("timeout_sec") or timeout or 10.0) | ||
| 85 | - self.glossary_id = glossary_id or provider_cfg.get("glossary_id") | ||
| 86 | - self.model = "deepl" | ||
| 87 | - self.context_presets = _merge_contexts(provider_cfg.get("contexts")) | ||
| 88 | - if not self.api_key: | ||
| 89 | - logger.warning("DEEPL_AUTH_KEY not set; DeepL translation is unavailable") | ||
| 90 | - | ||
| 91 | - @property | ||
| 92 | - def supports_batch(self) -> bool: | ||
| 93 | - """ | ||
| 94 | - DeepL HTTP API 本身支持一次传多条 text,这里先返回 False, | ||
| 95 | - 由上层逐条拆分,后续如果要真正批量,可调整实现。 | ||
| 96 | - """ | ||
| 97 | - return False | ||
| 98 | - | ||
| 99 | - def _resolve_request_context( | ||
| 100 | - self, | ||
| 101 | - target_lang: str, | ||
| 102 | - context: Optional[str], | ||
| 103 | - prompt: Optional[str], | ||
| 104 | - ) -> Optional[str]: | ||
| 105 | - if prompt: | ||
| 106 | - return prompt | ||
| 107 | - if context in SCENE_NAMES: | ||
| 108 | - scene_map = self.context_presets.get(context) or self.context_presets.get("default") or {} | ||
| 109 | - tgt = (target_lang or "").strip().lower() | ||
| 110 | - return scene_map.get(tgt) or scene_map.get("en") | ||
| 111 | - if context: | ||
| 112 | - return context | ||
| 113 | - scene_map = self.context_presets.get("default") or {} | ||
| 114 | - tgt = (target_lang or "").strip().lower() | ||
| 115 | - return scene_map.get(tgt) or scene_map.get("en") | ||
| 116 | - | ||
| 117 | - def translate( | ||
| 118 | - self, | ||
| 119 | - text: Union[str, Sequence[str]], | ||
| 120 | - target_lang: str, | ||
| 121 | - source_lang: Optional[str] = None, | ||
| 122 | - context: Optional[str] = None, | ||
| 123 | - prompt: Optional[str] = None, | ||
| 124 | - ) -> Union[Optional[str], List[Optional[str]]]: | ||
| 125 | - if isinstance(text, (list, tuple)): | ||
| 126 | - results: List[Optional[str]] = [] | ||
| 127 | - for item in text: | ||
| 128 | - if item is None or not str(item).strip(): | ||
| 129 | - results.append(item) # type: ignore[arg-type] | ||
| 130 | - continue | ||
| 131 | - out = self.translate( | ||
| 132 | - text=str(item), | ||
| 133 | - target_lang=target_lang, | ||
| 134 | - source_lang=source_lang, | ||
| 135 | - context=context, | ||
| 136 | - prompt=prompt, | ||
| 137 | - ) | ||
| 138 | - results.append(out) | ||
| 139 | - return results | ||
| 140 | - | ||
| 141 | - if not self.api_key: | ||
| 142 | - return None | ||
| 143 | - | ||
| 144 | - target_code = self.LANG_CODE_MAP.get((target_lang or "").lower(), (target_lang or "").upper()) | ||
| 145 | - headers = { | ||
| 146 | - "Authorization": f"DeepL-Auth-Key {self.api_key}", | ||
| 147 | - "Content-Type": "application/json", | ||
| 148 | - } | ||
| 149 | - | ||
| 150 | - api_context = self._resolve_request_context(target_lang, context, prompt) | ||
| 151 | - text_to_translate, needs_extraction = self._add_ecommerce_context(text, source_lang, api_context) | ||
| 152 | - | ||
| 153 | - payload = { | ||
| 154 | - "text": [text_to_translate], | ||
| 155 | - "target_lang": target_code, | ||
| 156 | - } | ||
| 157 | - if source_lang: | ||
| 158 | - payload["source_lang"] = self.LANG_CODE_MAP.get(source_lang.lower(), source_lang.upper()) | ||
| 159 | - if api_context: | ||
| 160 | - payload["context"] = api_context | ||
| 161 | - if self.glossary_id: | ||
| 162 | - payload["glossary_id"] = self.glossary_id | ||
| 163 | - | ||
| 164 | - try: | ||
| 165 | - response = requests.post(self.API_URL, headers=headers, json=payload, timeout=self.timeout) | ||
| 166 | - if response.status_code != 200: | ||
| 167 | - logger.warning( | ||
| 168 | - "[deepl] Failed | status=%s tgt=%s body=%s", | ||
| 169 | - response.status_code, | ||
| 170 | - target_code, | ||
| 171 | - (response.text or "")[:200], | ||
| 172 | - ) | ||
| 173 | - return None | ||
| 174 | - | ||
| 175 | - data = response.json() | ||
| 176 | - translations = data.get("translations") or [] | ||
| 177 | - if not translations: | ||
| 178 | - return None | ||
| 179 | - translated = translations[0].get("text") | ||
| 180 | - if not translated: | ||
| 181 | - return None | ||
| 182 | - if needs_extraction: | ||
| 183 | - translated = self._extract_term_from_translation(translated, text, target_code) | ||
| 184 | - return translated | ||
| 185 | - except requests.Timeout: | ||
| 186 | - logger.warning("[deepl] Timeout | tgt=%s timeout=%.1fs", target_code, self.timeout) | ||
| 187 | - return None | ||
| 188 | - except Exception as exc: | ||
| 189 | - logger.warning("[deepl] Exception | tgt=%s error=%s", target_code, exc, exc_info=True) | ||
| 190 | - return None | ||
| 191 | - | ||
| 192 | - def _add_ecommerce_context( | ||
| 193 | - self, | ||
| 194 | - text: str, | ||
| 195 | - source_lang: Optional[str], | ||
| 196 | - context: Optional[str], | ||
| 197 | - ) -> Tuple[str, bool]: | ||
| 198 | - if not context or "e-commerce" not in context.lower(): | ||
| 199 | - return text, False | ||
| 200 | - if (source_lang or "").lower() != "zh": | ||
| 201 | - return text, False | ||
| 202 | - | ||
| 203 | - term = (text or "").strip() | ||
| 204 | - if len(term.split()) == 1 and len(term) <= 2: | ||
| 205 | - return f"购买 {term}", True | ||
| 206 | - return text, False | ||
| 207 | - | ||
| 208 | - def _extract_term_from_translation( | ||
| 209 | - self, | ||
| 210 | - translated_text: str, | ||
| 211 | - original_text: str, | ||
| 212 | - target_lang_code: str, | ||
| 213 | - ) -> str: | ||
| 214 | - del original_text | ||
| 215 | - if target_lang_code != "EN": | ||
| 216 | - return translated_text | ||
| 217 | - | ||
| 218 | - words = translated_text.strip().split() | ||
| 219 | - if len(words) <= 1: | ||
| 220 | - return translated_text | ||
| 221 | - context_words = {"buy", "purchase", "product", "item", "commodity", "goods"} | ||
| 222 | - for word in reversed(words): | ||
| 223 | - normalized = re.sub(r"[.,!?;:]+$", "", word.lower()) | ||
| 224 | - if normalized not in context_words: | ||
| 225 | - return normalized | ||
| 226 | - return re.sub(r"[.,!?;:]+$", "", words[-1].lower()) | 1 | +"""Backward-compatible import for DeepL translation backend.""" |
| 227 | 2 | ||
| 3 | +from translation.backends.deepl import DeepLProvider, DeepLTranslationBackend |
query/llm_translate.py
| 1 | -""" | ||
| 2 | -LLM-based translation backend (DashScope-compatible OpenAI API). | 1 | +"""Backward-compatible import for LLM translation backend.""" |
| 3 | 2 | ||
| 4 | -Failure semantics are strict: | ||
| 5 | -- success: translated string | ||
| 6 | -- failure: None | ||
| 7 | -""" | 3 | +from translation.backends.llm import LLMTranslationBackend, LLMTranslatorProvider, llm_translate |
| 8 | 4 | ||
| 9 | -from __future__ import annotations | ||
| 10 | - | ||
| 11 | -import logging | ||
| 12 | -import os | ||
| 13 | -import time | ||
| 14 | -from typing import List, Optional, Sequence, Union | ||
| 15 | - | ||
| 16 | -from openai import OpenAI | ||
| 17 | - | ||
| 18 | -from config.env_config import DASHSCOPE_API_KEY | ||
| 19 | -from config.services_config import get_translation_config | ||
| 20 | -from config.translate_prompts import TRANSLATION_PROMPTS | ||
| 21 | -from config.tenant_config_loader import SOURCE_LANG_CODE_MAP, TARGET_LANG_CODE_MAP | ||
| 22 | - | ||
| 23 | - | ||
| 24 | -logger = logging.getLogger(__name__) | ||
| 25 | - | ||
| 26 | - | ||
| 27 | -DEFAULT_QWEN_BASE_URL = "https://dashscope-us.aliyuncs.com/compatible-mode/v1" | ||
| 28 | -DEFAULT_LLM_MODEL = "qwen-flash" | ||
| 29 | - | ||
| 30 | - | ||
| 31 | -def _build_prompt( | ||
| 32 | - text: str, | ||
| 33 | - *, | ||
| 34 | - source_lang: Optional[str], | ||
| 35 | - target_lang: str, | ||
| 36 | - scene: Optional[str], | ||
| 37 | -) -> str: | ||
| 38 | - """ | ||
| 39 | - 从 config.translate_prompts.TRANSLATION_PROMPTS 中构建提示词。 | ||
| 40 | - | ||
| 41 | - 要求:模板必须包含 {source_lang}({src_lang_code}){target_lang}({tgt_lang_code})。 | ||
| 42 | - 这里统一使用 code 作为占位的 lang 与 label,外部接口仍然只传语言 code。 | ||
| 43 | - """ | ||
| 44 | - tgt = (target_lang or "").lower() or "en" | ||
| 45 | - src = (source_lang or "auto").lower() | ||
| 46 | - | ||
| 47 | - # 将业务上下文 scene 映射为模板分组名 | ||
| 48 | - normalized_scene = (scene or "").strip() or "general" | ||
| 49 | - # 如果出现历史词,则报错,用于发现错误 | ||
| 50 | - if normalized_scene in {"query", "ecommerce_search", "ecommerce_search_query"}: | ||
| 51 | - group_key = "ecommerce_search_query" | ||
| 52 | - elif normalized_scene in {"product_title", "sku_name"}: | ||
| 53 | - group_key = "sku_name" | ||
| 54 | - else: | ||
| 55 | - group_key = normalized_scene | ||
| 56 | - group = TRANSLATION_PROMPTS.get(group_key) or TRANSLATION_PROMPTS["general"] | ||
| 57 | - | ||
| 58 | - # 先按目标语言 code 取模板,取不到回退到英文 | ||
| 59 | - template = group.get(tgt) or group.get("en") | ||
| 60 | - if not template: | ||
| 61 | - # 理论上不会发生,兜底一个简单模板 | ||
| 62 | - template = ( | ||
| 63 | - "You are a professional {source_lang} ({src_lang_code}) to " | ||
| 64 | - "{target_lang} ({tgt_lang_code}) translator, output only the translation: {text}" | ||
| 65 | - ) | ||
| 66 | - | ||
| 67 | - # 目前不额外维护语言名称映射,直接使用 code 作为 label | ||
| 68 | - source_lang_label = SOURCE_LANG_CODE_MAP.get(src, src) | ||
| 69 | - target_lang_label = SOURCE_LANG_CODE_MAP.get(tgt, tgt) | ||
| 70 | - | ||
| 71 | - return template.format( | ||
| 72 | - source_lang=source_lang_label, | ||
| 73 | - src_lang_code=src, | ||
| 74 | - target_lang=target_lang_label, | ||
| 75 | - tgt_lang_code=tgt, | ||
| 76 | - text=text, | ||
| 77 | - ) | ||
| 78 | - | ||
| 79 | - | ||
| 80 | -class LLMTranslatorProvider: | ||
| 81 | - def __init__( | ||
| 82 | - self, | ||
| 83 | - *, | ||
| 84 | - model: Optional[str] = None, | ||
| 85 | - timeout_sec: float = 30.0, | ||
| 86 | - base_url: Optional[str] = None, | ||
| 87 | - ) -> None: | ||
| 88 | - cfg = get_translation_config() | ||
| 89 | - llm_cfg = cfg.providers.get("llm", {}) if isinstance(cfg.providers, dict) else {} | ||
| 90 | - self.model = model or llm_cfg.get("model") or DEFAULT_LLM_MODEL | ||
| 91 | - self.timeout_sec = float(llm_cfg.get("timeout_sec") or timeout_sec or 30.0) | ||
| 92 | - self.base_url = ( | ||
| 93 | - (base_url or "").strip() | ||
| 94 | - or (llm_cfg.get("base_url") or "").strip() | ||
| 95 | - or os.getenv("DASHSCOPE_BASE_URL") | ||
| 96 | - or DEFAULT_QWEN_BASE_URL | ||
| 97 | - ) | ||
| 98 | - self.client = self._create_client() | ||
| 99 | - | ||
| 100 | - @property | ||
| 101 | - def supports_batch(self) -> bool: | ||
| 102 | - """Whether this provider efficiently supports list input.""" | ||
| 103 | - # 我们在 translate 中已经原生支持 list,所以这里返回 True | ||
| 104 | - return True | ||
| 105 | - | ||
| 106 | - def _create_client(self) -> Optional[OpenAI]: | ||
| 107 | - api_key = DASHSCOPE_API_KEY or os.getenv("DASHSCOPE_API_KEY") | ||
| 108 | - if not api_key: | ||
| 109 | - logger.warning("DASHSCOPE_API_KEY not set; llm translation unavailable") | ||
| 110 | - return None | ||
| 111 | - try: | ||
| 112 | - return OpenAI(api_key=api_key, base_url=self.base_url) | ||
| 113 | - except Exception as exc: | ||
| 114 | - logger.error("Failed to initialize llm translation client: %s", exc, exc_info=True) | ||
| 115 | - return None | ||
| 116 | - | ||
| 117 | - def _translate_single( | ||
| 118 | - self, | ||
| 119 | - text: str, | ||
| 120 | - target_lang: str, | ||
| 121 | - source_lang: Optional[str] = None, | ||
| 122 | - context: Optional[str] = None, | ||
| 123 | - prompt: Optional[str] = None, | ||
| 124 | - ) -> Optional[str]: | ||
| 125 | - if not text or not str(text).strip(): | ||
| 126 | - return text | ||
| 127 | - if not self.client: | ||
| 128 | - return None | ||
| 129 | - | ||
| 130 | - tgt = (target_lang or "").lower() or "en" | ||
| 131 | - src = (source_lang or "auto").lower() | ||
| 132 | - scene = context or "default" | ||
| 133 | - user_prompt = prompt or _build_prompt( | ||
| 134 | - text=text, | ||
| 135 | - source_lang=src, | ||
| 136 | - target_lang=tgt, | ||
| 137 | - scene=scene, | ||
| 138 | - ) | ||
| 139 | - start = time.time() | ||
| 140 | - try: | ||
| 141 | - logger.info( | ||
| 142 | - "[llm] Request | src=%s tgt=%s model=%s prompt=%s", | ||
| 143 | - src, | ||
| 144 | - tgt, | ||
| 145 | - self.model, | ||
| 146 | - user_prompt, | ||
| 147 | - ) | ||
| 148 | - completion = self.client.chat.completions.create( | ||
| 149 | - model=self.model, | ||
| 150 | - messages=[{"role": "user", "content": user_prompt}], | ||
| 151 | - timeout=self.timeout_sec, | ||
| 152 | - ) | ||
| 153 | - content = (completion.choices[0].message.content or "").strip() | ||
| 154 | - latency_ms = (time.time() - start) * 1000 | ||
| 155 | - if not content: | ||
| 156 | - logger.warning("[llm] Empty result | src=%s tgt=%s latency=%.1fms", src, tgt, latency_ms) | ||
| 157 | - return None | ||
| 158 | - logger.info( | ||
| 159 | - "[llm] Success | src=%s tgt=%s src_text=%s response=%s latency=%.1fms", | ||
| 160 | - src, | ||
| 161 | - tgt, | ||
| 162 | - text, | ||
| 163 | - content, | ||
| 164 | - latency_ms, | ||
| 165 | - ) | ||
| 166 | - return content | ||
| 167 | - except Exception as exc: | ||
| 168 | - latency_ms = (time.time() - start) * 1000 | ||
| 169 | - logger.warning( | ||
| 170 | - "[llm] Failed | src=%s tgt=%s latency=%.1fms error=%s", | ||
| 171 | - src, | ||
| 172 | - tgt, | ||
| 173 | - latency_ms, | ||
| 174 | - exc, | ||
| 175 | - exc_info=True, | ||
| 176 | - ) | ||
| 177 | - return None | ||
| 178 | - | ||
| 179 | - def translate( | ||
| 180 | - self, | ||
| 181 | - text: Union[str, Sequence[str]], | ||
| 182 | - target_lang: str, | ||
| 183 | - source_lang: Optional[str] = None, | ||
| 184 | - context: Optional[str] = None, | ||
| 185 | - prompt: Optional[str] = None, | ||
| 186 | - ) -> Union[Optional[str], List[Optional[str]]]: | ||
| 187 | - """ | ||
| 188 | - Translate a single string or a list of strings. | ||
| 189 | - | ||
| 190 | - - If input is a list, returns a list of the same length. | ||
| 191 | - - Per-item failures are returned as None. | ||
| 192 | - """ | ||
| 193 | - if isinstance(text, (list, tuple)): | ||
| 194 | - results: List[Optional[str]] = [] | ||
| 195 | - for item in text: | ||
| 196 | - # 保证一一对应,即使某个元素为空也占位 | ||
| 197 | - if item is None: | ||
| 198 | - results.append(None) | ||
| 199 | - continue | ||
| 200 | - results.append( | ||
| 201 | - self._translate_single( | ||
| 202 | - text=str(item), | ||
| 203 | - target_lang=target_lang, | ||
| 204 | - source_lang=source_lang, | ||
| 205 | - context=context, | ||
| 206 | - prompt=prompt, | ||
| 207 | - ) | ||
| 208 | - ) | ||
| 209 | - return results | ||
| 210 | - | ||
| 211 | - return self._translate_single( | ||
| 212 | - text=str(text), | ||
| 213 | - target_lang=target_lang, | ||
| 214 | - source_lang=source_lang, | ||
| 215 | - context=context, | ||
| 216 | - prompt=prompt, | ||
| 217 | - ) | ||
| 218 | - | ||
| 219 | - | ||
| 220 | -def llm_translate( | ||
| 221 | - text: Union[str, Sequence[str]], | ||
| 222 | - target_lang: str, | ||
| 223 | - *, | ||
| 224 | - source_lang: Optional[str] = None, | ||
| 225 | - source_lang_label: Optional[str] = None, | ||
| 226 | - target_lang_label: Optional[str] = None, | ||
| 227 | - timeout_sec: Optional[float] = None, | ||
| 228 | -) -> Union[Optional[str], List[Optional[str]]]: | ||
| 229 | - provider = LLMTranslatorProvider(timeout_sec=timeout_sec or 30.0) | ||
| 230 | - return provider.translate( | ||
| 231 | - text=text, | ||
| 232 | - target_lang=target_lang, | ||
| 233 | - source_lang=source_lang, | ||
| 234 | - context=None, | ||
| 235 | - ) | ||
| 236 | - | ||
| 237 | - | ||
| 238 | -__all__ = ["LLMTranslatorProvider", "llm_translate"] | 5 | +__all__ = ["LLMTranslationBackend", "LLMTranslatorProvider", "llm_translate"] |
query/query_parser.py
| @@ -133,7 +133,11 @@ class QueryParser: | @@ -133,7 +133,11 @@ class QueryParser: | ||
| 133 | if self._translator is None: | 133 | if self._translator is None: |
| 134 | from config.services_config import get_translation_config | 134 | from config.services_config import get_translation_config |
| 135 | cfg = get_translation_config() | 135 | cfg = get_translation_config() |
| 136 | - logger.info("Initializing translator at QueryParser construction (provider=%s)...", cfg.provider) | 136 | + logger.info( |
| 137 | + "Initializing translator client at QueryParser construction (service_url=%s, default_model=%s)...", | ||
| 138 | + cfg.service_url, | ||
| 139 | + cfg.default_model, | ||
| 140 | + ) | ||
| 137 | self._translator = create_translation_provider(self.config.query_config) | 141 | self._translator = create_translation_provider(self.config.query_config) |
| 138 | self._translation_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="query-translation") | 142 | self._translation_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="query-translation") |
| 139 | 143 |
query/qwen_mt_translate.py
| 1 | -"""Qwen-MT translation orchestrator with cache and async helpers.""" | 1 | +"""Backward-compatible import for Qwen-MT translation backend.""" |
| 2 | 2 | ||
| 3 | -from __future__ import annotations | 3 | +from translation.backends.qwen_mt import QwenMTTranslationBackend, Translator |
| 4 | 4 | ||
| 5 | -import hashlib | ||
| 6 | -import logging | ||
| 7 | -import os | ||
| 8 | -import re | ||
| 9 | -import time | ||
| 10 | -from typing import Dict, List, Optional, Sequence, Union | ||
| 11 | - | ||
| 12 | -import redis | ||
| 13 | -from openai import OpenAI | ||
| 14 | - | ||
| 15 | -from config.env_config import DASHSCOPE_API_KEY, REDIS_CONFIG | ||
| 16 | -from config.services_config import get_translation_cache_config | ||
| 17 | -from config.tenant_config_loader import SOURCE_LANG_CODE_MAP, TARGET_LANG_CODE_MAP | ||
| 18 | - | ||
| 19 | -logger = logging.getLogger(__name__) | ||
| 20 | - | ||
| 21 | - | ||
| 22 | -class Translator: | ||
| 23 | - QWEN_DEFAULT_BASE_URL = "https://dashscope-us.aliyuncs.com/compatible-mode/v1" | ||
| 24 | - QWEN_MODEL = "qwen-mt-flash" | ||
| 25 | - | ||
| 26 | - def __init__( | ||
| 27 | - self, | ||
| 28 | - model: str = "qwen", | ||
| 29 | - api_key: Optional[str] = None, | ||
| 30 | - use_cache: bool = True, | ||
| 31 | - timeout: int = 10, | ||
| 32 | - glossary_id: Optional[str] = None, | ||
| 33 | - translation_context: Optional[str] = None, | ||
| 34 | - ): | ||
| 35 | - self.model = self._normalize_model(model) | ||
| 36 | - self.timeout = int(timeout) | ||
| 37 | - self.use_cache = bool(use_cache) | ||
| 38 | - self.glossary_id = glossary_id | ||
| 39 | - self.translation_context = translation_context or "e-commerce product search" | ||
| 40 | - | ||
| 41 | - cache_cfg = get_translation_cache_config() | ||
| 42 | - self.cache_prefix = str(cache_cfg.get("key_prefix", "trans:v2")) | ||
| 43 | - self.expire_seconds = int(cache_cfg.get("ttl_seconds", 360 * 24 * 3600)) | ||
| 44 | - self.cache_sliding_expiration = bool(cache_cfg.get("sliding_expiration", True)) | ||
| 45 | - self.cache_include_context = bool(cache_cfg.get("key_include_context", True)) | ||
| 46 | - self.cache_include_prompt = bool(cache_cfg.get("key_include_prompt", True)) | ||
| 47 | - self.cache_include_source_lang = bool(cache_cfg.get("key_include_source_lang", True)) | ||
| 48 | - | ||
| 49 | - self.qwen_model_name = self._resolve_qwen_model_name(model) | ||
| 50 | - self._api_key = api_key or self._default_api_key(self.model) | ||
| 51 | - self._qwen_client: Optional[OpenAI] = None | ||
| 52 | - base_url = os.getenv("DASHSCOPE_BASE_URL") or self.QWEN_DEFAULT_BASE_URL | ||
| 53 | - if self._api_key: | ||
| 54 | - try: | ||
| 55 | - self._qwen_client = OpenAI(api_key=self._api_key, base_url=base_url) | ||
| 56 | - except Exception as exc: | ||
| 57 | - logger.warning("Failed to initialize qwen-mt client: %s", exc, exc_info=True) | ||
| 58 | - else: | ||
| 59 | - logger.warning("DASHSCOPE_API_KEY not set; qwen-mt translation unavailable") | ||
| 60 | - | ||
| 61 | - self.redis_client = None | ||
| 62 | - if self.use_cache and bool(cache_cfg.get("enabled", True)): | ||
| 63 | - self.redis_client = self._init_redis_client() | ||
| 64 | - | ||
| 65 | - @property | ||
| 66 | - def supports_batch(self) -> bool: | ||
| 67 | - """ | ||
| 68 | - 标记该 provider 已支持列表输入。 | ||
| 69 | - | ||
| 70 | - 当前实现为循环单条调用(带缓存),不是真正的并行批量请求, | ||
| 71 | - 但对上层来说可以直接传 list,返回 list。 | ||
| 72 | - """ | ||
| 73 | - return True | ||
| 74 | - | ||
| 75 | - @staticmethod | ||
| 76 | - def _normalize_model(model: str) -> str: | ||
| 77 | - m = (model or "qwen").strip().lower() | ||
| 78 | - if m.startswith("qwen"): | ||
| 79 | - return "qwen-mt" | ||
| 80 | - raise ValueError(f"Unsupported model: {model}. Supported models: 'qwen', 'qwen-mt', 'qwen-mt-flash'") | ||
| 81 | - | ||
| 82 | - @staticmethod | ||
| 83 | - def _resolve_qwen_model_name(model: str) -> str: | ||
| 84 | - m = (model or "qwen").strip().lower() | ||
| 85 | - if m in {"qwen", "qwen-mt"}: | ||
| 86 | - return "qwen-mt-flash" | ||
| 87 | - return m | ||
| 88 | - | ||
| 89 | - @staticmethod | ||
| 90 | - def _default_api_key(model: str) -> Optional[str]: | ||
| 91 | - del model | ||
| 92 | - return DASHSCOPE_API_KEY or os.getenv("DASHSCOPE_API_KEY") | ||
| 93 | - | ||
| 94 | - def _init_redis_client(self): | ||
| 95 | - try: | ||
| 96 | - client = redis.Redis( | ||
| 97 | - host=REDIS_CONFIG.get("host", "localhost"), | ||
| 98 | - port=REDIS_CONFIG.get("port", 6479), | ||
| 99 | - password=REDIS_CONFIG.get("password"), | ||
| 100 | - decode_responses=True, | ||
| 101 | - socket_timeout=REDIS_CONFIG.get("socket_timeout", 1), | ||
| 102 | - socket_connect_timeout=REDIS_CONFIG.get("socket_connect_timeout", 1), | ||
| 103 | - retry_on_timeout=REDIS_CONFIG.get("retry_on_timeout", False), | ||
| 104 | - health_check_interval=10, | ||
| 105 | - ) | ||
| 106 | - client.ping() | ||
| 107 | - return client | ||
| 108 | - except Exception as exc: | ||
| 109 | - logger.warning("Failed to initialize translation redis cache: %s", exc) | ||
| 110 | - return None | ||
| 111 | - | ||
| 112 | - def _build_cache_key( | ||
| 113 | - self, | ||
| 114 | - text: str, | ||
| 115 | - target_lang: str, | ||
| 116 | - source_lang: Optional[str], | ||
| 117 | - context: Optional[str], | ||
| 118 | - prompt: Optional[str], | ||
| 119 | - ) -> str: | ||
| 120 | - src = (source_lang or "auto").strip().lower() if self.cache_include_source_lang else "-" | ||
| 121 | - tgt = (target_lang or "").strip().lower() | ||
| 122 | - ctx = (context or "").strip() if self.cache_include_context else "" | ||
| 123 | - prm = (prompt or "").strip() if self.cache_include_prompt else "" | ||
| 124 | - payload = f"model={self.model}\nsrc={src}\ntgt={tgt}\nctx={ctx}\nprm={prm}\ntext={text}" | ||
| 125 | - digest = hashlib.sha256(payload.encode("utf-8")).hexdigest() | ||
| 126 | - return f"{self.cache_prefix}:{self.model}:{src}:{tgt}:{digest}" | ||
| 127 | - | ||
| 128 | - def translate( | ||
| 129 | - self, | ||
| 130 | - text: Union[str, Sequence[str]], | ||
| 131 | - target_lang: str, | ||
| 132 | - source_lang: Optional[str] = None, | ||
| 133 | - context: Optional[str] = None, | ||
| 134 | - prompt: Optional[str] = None, | ||
| 135 | - ) -> Union[Optional[str], List[Optional[str]]]: | ||
| 136 | - if isinstance(text, (list, tuple)): | ||
| 137 | - results: List[Optional[str]] = [] | ||
| 138 | - for item in text: | ||
| 139 | - if item is None or not str(item).strip(): | ||
| 140 | - results.append(item) # type: ignore[arg-type] | ||
| 141 | - continue | ||
| 142 | - # 对于 batch,这里沿用单条的缓存与规则,逐条调用 | ||
| 143 | - out = self.translate( | ||
| 144 | - text=str(item), | ||
| 145 | - target_lang=target_lang, | ||
| 146 | - source_lang=source_lang, | ||
| 147 | - context=context, | ||
| 148 | - prompt=prompt, | ||
| 149 | - ) | ||
| 150 | - results.append(out) | ||
| 151 | - return results | ||
| 152 | - | ||
| 153 | - if not text or not str(text).strip(): | ||
| 154 | - return text # type: ignore[return-value] | ||
| 155 | - | ||
| 156 | - tgt = (target_lang or "").strip().lower() | ||
| 157 | - src = (source_lang or "").strip().lower() or None | ||
| 158 | - if tgt == "en" and self._is_english_text(text): | ||
| 159 | - return text | ||
| 160 | - if tgt == "zh" and (self._contains_chinese(text) or self._is_pure_number(text)): | ||
| 161 | - return text | ||
| 162 | - | ||
| 163 | - translation_context = context or self.translation_context | ||
| 164 | - cached = self._get_cached_translation_redis(text, tgt, src, translation_context, prompt) | ||
| 165 | - if cached is not None: | ||
| 166 | - return cached | ||
| 167 | - | ||
| 168 | - result = self._translate_qwen(text, tgt, src) | ||
| 169 | - | ||
| 170 | - if result is not None: | ||
| 171 | - self._set_cached_translation_redis(text, tgt, result, src, translation_context, prompt) | ||
| 172 | - return result | ||
| 173 | - | ||
| 174 | - def _translate_qwen( | ||
| 175 | - self, | ||
| 176 | - text: str, | ||
| 177 | - target_lang: str, | ||
| 178 | - source_lang: Optional[str], | ||
| 179 | - ) -> Optional[str]: | ||
| 180 | - if not self._qwen_client: | ||
| 181 | - return None | ||
| 182 | - tgt_norm = (target_lang or "").strip().lower() | ||
| 183 | - src_norm = (source_lang or "").strip().lower() | ||
| 184 | - tgt_qwen = self.SOURCE_LANG_CODE_MAP.get(tgt_norm, tgt_norm.capitalize()) | ||
| 185 | - src_qwen = "auto" if not src_norm or src_norm == "auto" else self.SOURCE_LANG_CODE_MAP.get(src_norm, src_norm.capitalize()) | ||
| 186 | - start = time.time() | ||
| 187 | - try: | ||
| 188 | - completion = self._qwen_client.chat.completions.create( | ||
| 189 | - model=self.qwen_model_name, | ||
| 190 | - messages=[{"role": "user", "content": text}], | ||
| 191 | - extra_body={ | ||
| 192 | - "translation_options": { | ||
| 193 | - "source_lang": src_qwen, | ||
| 194 | - "target_lang": tgt_qwen, | ||
| 195 | - } | ||
| 196 | - }, | ||
| 197 | - timeout=self.timeout, | ||
| 198 | - ) | ||
| 199 | - content = (completion.choices[0].message.content or "").strip() | ||
| 200 | - if not content: | ||
| 201 | - return None | ||
| 202 | - logger.info("[qwen-mt] Success | src=%s tgt=%s latency=%.1fms", src_qwen, tgt_qwen, (time.time() - start) * 1000) | ||
| 203 | - return content | ||
| 204 | - except Exception as exc: | ||
| 205 | - logger.warning( | ||
| 206 | - "[qwen-mt] Failed | src=%s tgt=%s latency=%.1fms error=%s", | ||
| 207 | - src_qwen, | ||
| 208 | - tgt_qwen, | ||
| 209 | - (time.time() - start) * 1000, | ||
| 210 | - exc, | ||
| 211 | - exc_info=True, | ||
| 212 | - ) | ||
| 213 | - return None | ||
| 214 | - | ||
| 215 | - | ||
| 216 | - def _get_cached_translation_redis( | ||
| 217 | - self, | ||
| 218 | - text: str, | ||
| 219 | - target_lang: str, | ||
| 220 | - source_lang: Optional[str] = None, | ||
| 221 | - context: Optional[str] = None, | ||
| 222 | - prompt: Optional[str] = None, | ||
| 223 | - ) -> Optional[str]: | ||
| 224 | - if not self.redis_client: | ||
| 225 | - return None | ||
| 226 | - key = self._build_cache_key(text, target_lang, source_lang, context, prompt) | ||
| 227 | - try: | ||
| 228 | - value = self.redis_client.get(key) | ||
| 229 | - if value and self.cache_sliding_expiration: | ||
| 230 | - self.redis_client.expire(key, self.expire_seconds) | ||
| 231 | - return value | ||
| 232 | - except Exception as exc: | ||
| 233 | - logger.warning("Redis get translation cache failed: %s", exc) | ||
| 234 | - return None | ||
| 235 | - | ||
| 236 | - def _set_cached_translation_redis( | ||
| 237 | - self, | ||
| 238 | - text: str, | ||
| 239 | - target_lang: str, | ||
| 240 | - translation: str, | ||
| 241 | - source_lang: Optional[str] = None, | ||
| 242 | - context: Optional[str] = None, | ||
| 243 | - prompt: Optional[str] = None, | ||
| 244 | - ) -> None: | ||
| 245 | - if not self.redis_client: | ||
| 246 | - return | ||
| 247 | - key = self._build_cache_key(text, target_lang, source_lang, context, prompt) | ||
| 248 | - try: | ||
| 249 | - self.redis_client.setex(key, self.expire_seconds, translation) | ||
| 250 | - except Exception as exc: | ||
| 251 | - logger.warning("Redis set translation cache failed: %s", exc) | ||
| 252 | - | ||
| 253 | - def _shop_lang_matches(self, shop_lang_lower: str, lang_code: str) -> bool: | ||
| 254 | - if not shop_lang_lower or not lang_code: | ||
| 255 | - return False | ||
| 256 | - if shop_lang_lower == lang_code: | ||
| 257 | - return True | ||
| 258 | - if lang_code == "zh" and "zh" in shop_lang_lower: | ||
| 259 | - return True | ||
| 260 | - if lang_code == "en" and "en" in shop_lang_lower: | ||
| 261 | - return True | ||
| 262 | - return False | ||
| 263 | - | ||
| 264 | - def get_translation_needs(self, detected_lang: str, supported_langs: List[str]) -> List[str]: | ||
| 265 | - if detected_lang in supported_langs: | ||
| 266 | - return [lang for lang in supported_langs if lang != detected_lang] | ||
| 267 | - return supported_langs | ||
| 268 | - | ||
| 269 | - def _is_english_text(self, text: str) -> bool: | ||
| 270 | - if not text or not text.strip(): | ||
| 271 | - return True | ||
| 272 | - text_clean = re.sub(r"[\s\.,!?;:\-\'\"\(\)\[\]{}]", "", text) | ||
| 273 | - if not text_clean: | ||
| 274 | - return True | ||
| 275 | - ascii_count = sum(1 for c in text_clean if ord(c) < 128) | ||
| 276 | - return (ascii_count / len(text_clean)) > 0.8 | ||
| 277 | - | ||
| 278 | - def _contains_chinese(self, text: str) -> bool: | ||
| 279 | - if not text: | ||
| 280 | - return False | ||
| 281 | - return bool(re.search(r"[\u4e00-\u9fff]", text)) | ||
| 282 | - | ||
| 283 | - def _is_pure_number(self, text: str) -> bool: | ||
| 284 | - if not text or not text.strip(): | ||
| 285 | - return False | ||
| 286 | - text_clean = re.sub(r"[\s\.,]", "", text.strip()) | ||
| 287 | - return bool(text_clean) and text_clean.isdigit() | 5 | +__all__ = ["QwenMTTranslationBackend", "Translator"] |
| @@ -0,0 +1,11 @@ | @@ -0,0 +1,11 @@ | ||
| 1 | +"""Translation backend registry.""" | ||
| 2 | + | ||
| 3 | +from .deepl import DeepLTranslationBackend | ||
| 4 | +from .llm import LLMTranslationBackend | ||
| 5 | +from .qwen_mt import QwenMTTranslationBackend | ||
| 6 | + | ||
| 7 | +__all__ = [ | ||
| 8 | + "DeepLTranslationBackend", | ||
| 9 | + "LLMTranslationBackend", | ||
| 10 | + "QwenMTTranslationBackend", | ||
| 11 | +] |
| @@ -0,0 +1,220 @@ | @@ -0,0 +1,220 @@ | ||
| 1 | +"""DeepL translation backend.""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import logging | ||
| 6 | +import os | ||
| 7 | +import re | ||
| 8 | +from typing import Dict, List, Optional, Sequence, Tuple, Union | ||
| 9 | + | ||
| 10 | +import requests | ||
| 11 | + | ||
| 12 | +from config.services_config import get_translation_config | ||
| 13 | + | ||
| 14 | +logger = logging.getLogger(__name__) | ||
| 15 | + | ||
| 16 | +DEFAULT_CONTEXTS: Dict[str, Dict[str, str]] = { | ||
| 17 | + "sku_name": { | ||
| 18 | + "zh": "商品SKU名称", | ||
| 19 | + "en": "product SKU name", | ||
| 20 | + }, | ||
| 21 | + "ecommerce_search_query": { | ||
| 22 | + "zh": "电商", | ||
| 23 | + "en": "e-commerce", | ||
| 24 | + }, | ||
| 25 | + "general": { | ||
| 26 | + "zh": "", | ||
| 27 | + "en": "", | ||
| 28 | + }, | ||
| 29 | +} | ||
| 30 | +SCENE_NAMES = frozenset(DEFAULT_CONTEXTS.keys()) | ||
| 31 | + | ||
| 32 | + | ||
| 33 | +def _merge_contexts(raw: object) -> Dict[str, Dict[str, str]]: | ||
| 34 | + merged: Dict[str, Dict[str, str]] = { | ||
| 35 | + scene: dict(lang_map) for scene, lang_map in DEFAULT_CONTEXTS.items() | ||
| 36 | + } | ||
| 37 | + if not isinstance(raw, dict): | ||
| 38 | + return merged | ||
| 39 | + for scene, lang_map in raw.items(): | ||
| 40 | + if not isinstance(lang_map, dict): | ||
| 41 | + continue | ||
| 42 | + scene_name = str(scene or "").strip() | ||
| 43 | + if not scene_name: | ||
| 44 | + continue | ||
| 45 | + merged.setdefault(scene_name, {}) | ||
| 46 | + for lang, value in lang_map.items(): | ||
| 47 | + lang_key = str(lang or "").strip().lower() | ||
| 48 | + context_value = str(value or "").strip() | ||
| 49 | + if lang_key and context_value: | ||
| 50 | + merged[scene_name][lang_key] = context_value | ||
| 51 | + return merged | ||
| 52 | + | ||
| 53 | + | ||
| 54 | +class DeepLTranslationBackend: | ||
| 55 | + API_URL = "https://api.deepl.com/v2/translate" | ||
| 56 | + LANG_CODE_MAP = { | ||
| 57 | + "zh": "ZH", | ||
| 58 | + "en": "EN", | ||
| 59 | + "ru": "RU", | ||
| 60 | + "ar": "AR", | ||
| 61 | + "ja": "JA", | ||
| 62 | + "es": "ES", | ||
| 63 | + "de": "DE", | ||
| 64 | + "fr": "FR", | ||
| 65 | + "it": "IT", | ||
| 66 | + "pt": "PT", | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + def __init__( | ||
| 70 | + self, | ||
| 71 | + api_key: Optional[str], | ||
| 72 | + *, | ||
| 73 | + timeout: float = 10.0, | ||
| 74 | + glossary_id: Optional[str] = None, | ||
| 75 | + ) -> None: | ||
| 76 | + cfg = get_translation_config() | ||
| 77 | + provider_cfg = cfg.get_capability_cfg("deepl") | ||
| 78 | + self.api_key = api_key or os.getenv("DEEPL_AUTH_KEY") | ||
| 79 | + self.timeout = float(provider_cfg.get("timeout_sec") or timeout or 10.0) | ||
| 80 | + self.glossary_id = glossary_id or provider_cfg.get("glossary_id") | ||
| 81 | + self.model = "deepl" | ||
| 82 | + self.context_presets = _merge_contexts(provider_cfg.get("contexts")) | ||
| 83 | + if not self.api_key: | ||
| 84 | + logger.warning("DEEPL_AUTH_KEY not set; DeepL translation is unavailable") | ||
| 85 | + | ||
| 86 | + @property | ||
| 87 | + def supports_batch(self) -> bool: | ||
| 88 | + return False | ||
| 89 | + | ||
| 90 | + def _resolve_request_context( | ||
| 91 | + self, | ||
| 92 | + target_lang: str, | ||
| 93 | + context: Optional[str], | ||
| 94 | + prompt: Optional[str], | ||
| 95 | + ) -> Optional[str]: | ||
| 96 | + if prompt: | ||
| 97 | + return prompt | ||
| 98 | + if context in SCENE_NAMES: | ||
| 99 | + scene_map = self.context_presets.get(context) or self.context_presets.get("default") or {} | ||
| 100 | + tgt = (target_lang or "").strip().lower() | ||
| 101 | + return scene_map.get(tgt) or scene_map.get("en") | ||
| 102 | + if context: | ||
| 103 | + return context | ||
| 104 | + scene_map = self.context_presets.get("default") or {} | ||
| 105 | + tgt = (target_lang or "").strip().lower() | ||
| 106 | + return scene_map.get(tgt) or scene_map.get("en") | ||
| 107 | + | ||
| 108 | + def translate( | ||
| 109 | + self, | ||
| 110 | + text: Union[str, Sequence[str]], | ||
| 111 | + target_lang: str, | ||
| 112 | + source_lang: Optional[str] = None, | ||
| 113 | + context: Optional[str] = None, | ||
| 114 | + prompt: Optional[str] = None, | ||
| 115 | + ) -> Union[Optional[str], List[Optional[str]]]: | ||
| 116 | + if isinstance(text, (list, tuple)): | ||
| 117 | + results: List[Optional[str]] = [] | ||
| 118 | + for item in text: | ||
| 119 | + if item is None or not str(item).strip(): | ||
| 120 | + results.append(item) # type: ignore[arg-type] | ||
| 121 | + continue | ||
| 122 | + out = self.translate( | ||
| 123 | + text=str(item), | ||
| 124 | + target_lang=target_lang, | ||
| 125 | + source_lang=source_lang, | ||
| 126 | + context=context, | ||
| 127 | + prompt=prompt, | ||
| 128 | + ) | ||
| 129 | + results.append(out) | ||
| 130 | + return results | ||
| 131 | + | ||
| 132 | + if not self.api_key: | ||
| 133 | + return None | ||
| 134 | + | ||
| 135 | + target_code = self.LANG_CODE_MAP.get((target_lang or "").lower(), (target_lang or "").upper()) | ||
| 136 | + headers = { | ||
| 137 | + "Authorization": f"DeepL-Auth-Key {self.api_key}", | ||
| 138 | + "Content-Type": "application/json", | ||
| 139 | + } | ||
| 140 | + | ||
| 141 | + api_context = self._resolve_request_context(target_lang, context, prompt) | ||
| 142 | + text_to_translate, needs_extraction = self._add_ecommerce_context(text, source_lang, api_context) | ||
| 143 | + | ||
| 144 | + payload = { | ||
| 145 | + "text": [text_to_translate], | ||
| 146 | + "target_lang": target_code, | ||
| 147 | + } | ||
| 148 | + if source_lang: | ||
| 149 | + payload["source_lang"] = self.LANG_CODE_MAP.get(source_lang.lower(), source_lang.upper()) | ||
| 150 | + if api_context: | ||
| 151 | + payload["context"] = api_context | ||
| 152 | + if self.glossary_id: | ||
| 153 | + payload["glossary_id"] = self.glossary_id | ||
| 154 | + | ||
| 155 | + try: | ||
| 156 | + response = requests.post(self.API_URL, headers=headers, json=payload, timeout=self.timeout) | ||
| 157 | + if response.status_code != 200: | ||
| 158 | + logger.warning( | ||
| 159 | + "[deepl] Failed | status=%s tgt=%s body=%s", | ||
| 160 | + response.status_code, | ||
| 161 | + target_code, | ||
| 162 | + (response.text or "")[:200], | ||
| 163 | + ) | ||
| 164 | + return None | ||
| 165 | + | ||
| 166 | + data = response.json() | ||
| 167 | + translations = data.get("translations") or [] | ||
| 168 | + if not translations: | ||
| 169 | + return None | ||
| 170 | + translated = translations[0].get("text") | ||
| 171 | + if not translated: | ||
| 172 | + return None | ||
| 173 | + if needs_extraction: | ||
| 174 | + translated = self._extract_term_from_translation(translated, text, target_code) | ||
| 175 | + return translated | ||
| 176 | + except requests.Timeout: | ||
| 177 | + logger.warning("[deepl] Timeout | tgt=%s timeout=%.1fs", target_code, self.timeout) | ||
| 178 | + return None | ||
| 179 | + except Exception as exc: | ||
| 180 | + logger.warning("[deepl] Exception | tgt=%s error=%s", target_code, exc, exc_info=True) | ||
| 181 | + return None | ||
| 182 | + | ||
| 183 | + def _add_ecommerce_context( | ||
| 184 | + self, | ||
| 185 | + text: str, | ||
| 186 | + source_lang: Optional[str], | ||
| 187 | + context: Optional[str], | ||
| 188 | + ) -> Tuple[str, bool]: | ||
| 189 | + if not context or "e-commerce" not in context.lower(): | ||
| 190 | + return text, False | ||
| 191 | + if (source_lang or "").lower() != "zh": | ||
| 192 | + return text, False | ||
| 193 | + | ||
| 194 | + term = (text or "").strip() | ||
| 195 | + if len(term.split()) == 1 and len(term) <= 2: | ||
| 196 | + return f"购买 {term}", True | ||
| 197 | + return text, False | ||
| 198 | + | ||
| 199 | + def _extract_term_from_translation( | ||
| 200 | + self, | ||
| 201 | + translated_text: str, | ||
| 202 | + original_text: str, | ||
| 203 | + target_lang_code: str, | ||
| 204 | + ) -> str: | ||
| 205 | + del original_text | ||
| 206 | + if target_lang_code != "EN": | ||
| 207 | + return translated_text | ||
| 208 | + | ||
| 209 | + words = translated_text.strip().split() | ||
| 210 | + if len(words) <= 1: | ||
| 211 | + return translated_text | ||
| 212 | + context_words = {"buy", "purchase", "product", "item", "commodity", "goods"} | ||
| 213 | + for word in reversed(words): | ||
| 214 | + normalized = re.sub(r"[.,!?;:]+$", "", word.lower()) | ||
| 215 | + if normalized not in context_words: | ||
| 216 | + return normalized | ||
| 217 | + return re.sub(r"[.,!?;:]+$", "", words[-1].lower()) | ||
| 218 | + | ||
| 219 | + | ||
| 220 | +DeepLProvider = DeepLTranslationBackend |
| @@ -0,0 +1,209 @@ | @@ -0,0 +1,209 @@ | ||
| 1 | +"""LLM-based translation backend.""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import logging | ||
| 6 | +import os | ||
| 7 | +import time | ||
| 8 | +from typing import List, Optional, Sequence, Union | ||
| 9 | + | ||
| 10 | +from openai import OpenAI | ||
| 11 | + | ||
| 12 | +from config.env_config import DASHSCOPE_API_KEY | ||
| 13 | +from config.services_config import get_translation_config | ||
| 14 | +from config.translate_prompts import TRANSLATION_PROMPTS | ||
| 15 | +from config.tenant_config_loader import SOURCE_LANG_CODE_MAP | ||
| 16 | + | ||
| 17 | +logger = logging.getLogger(__name__) | ||
| 18 | + | ||
| 19 | +DEFAULT_QWEN_BASE_URL = "https://dashscope-us.aliyuncs.com/compatible-mode/v1" | ||
| 20 | +DEFAULT_LLM_MODEL = "qwen-flash" | ||
| 21 | + | ||
| 22 | + | ||
| 23 | +def _build_prompt( | ||
| 24 | + text: str, | ||
| 25 | + *, | ||
| 26 | + source_lang: Optional[str], | ||
| 27 | + target_lang: str, | ||
| 28 | + scene: Optional[str], | ||
| 29 | +) -> str: | ||
| 30 | + tgt = (target_lang or "").lower() or "en" | ||
| 31 | + src = (source_lang or "auto").lower() | ||
| 32 | + normalized_scene = (scene or "").strip() or "general" | ||
| 33 | + if normalized_scene in {"query", "ecommerce_search", "ecommerce_search_query"}: | ||
| 34 | + group_key = "ecommerce_search_query" | ||
| 35 | + elif normalized_scene in {"product_title", "sku_name"}: | ||
| 36 | + group_key = "sku_name" | ||
| 37 | + else: | ||
| 38 | + group_key = normalized_scene | ||
| 39 | + group = TRANSLATION_PROMPTS.get(group_key) or TRANSLATION_PROMPTS["general"] | ||
| 40 | + template = group.get(tgt) or group.get("en") | ||
| 41 | + if not template: | ||
| 42 | + template = ( | ||
| 43 | + "You are a professional {source_lang} ({src_lang_code}) to " | ||
| 44 | + "{target_lang} ({tgt_lang_code}) translator, output only the translation: {text}" | ||
| 45 | + ) | ||
| 46 | + | ||
| 47 | + source_lang_label = SOURCE_LANG_CODE_MAP.get(src, src) | ||
| 48 | + target_lang_label = SOURCE_LANG_CODE_MAP.get(tgt, tgt) | ||
| 49 | + | ||
| 50 | + return template.format( | ||
| 51 | + source_lang=source_lang_label, | ||
| 52 | + src_lang_code=src, | ||
| 53 | + target_lang=target_lang_label, | ||
| 54 | + tgt_lang_code=tgt, | ||
| 55 | + text=text, | ||
| 56 | + ) | ||
| 57 | + | ||
| 58 | + | ||
| 59 | +class LLMTranslationBackend: | ||
| 60 | + def __init__( | ||
| 61 | + self, | ||
| 62 | + *, | ||
| 63 | + model: Optional[str] = None, | ||
| 64 | + timeout_sec: float = 30.0, | ||
| 65 | + base_url: Optional[str] = None, | ||
| 66 | + ) -> None: | ||
| 67 | + cfg = get_translation_config() | ||
| 68 | + llm_cfg = cfg.get_capability_cfg("llm") | ||
| 69 | + self.model = model or llm_cfg.get("model") or DEFAULT_LLM_MODEL | ||
| 70 | + self.timeout_sec = float(llm_cfg.get("timeout_sec") or timeout_sec or 30.0) | ||
| 71 | + self.base_url = ( | ||
| 72 | + (base_url or "").strip() | ||
| 73 | + or (llm_cfg.get("base_url") or "").strip() | ||
| 74 | + or os.getenv("DASHSCOPE_BASE_URL") | ||
| 75 | + or DEFAULT_QWEN_BASE_URL | ||
| 76 | + ) | ||
| 77 | + self.client = self._create_client() | ||
| 78 | + | ||
| 79 | + @property | ||
| 80 | + def supports_batch(self) -> bool: | ||
| 81 | + return True | ||
| 82 | + | ||
| 83 | + def _create_client(self) -> Optional[OpenAI]: | ||
| 84 | + api_key = DASHSCOPE_API_KEY or os.getenv("DASHSCOPE_API_KEY") | ||
| 85 | + if not api_key: | ||
| 86 | + logger.warning("DASHSCOPE_API_KEY not set; llm translation unavailable") | ||
| 87 | + return None | ||
| 88 | + try: | ||
| 89 | + return OpenAI(api_key=api_key, base_url=self.base_url) | ||
| 90 | + except Exception as exc: | ||
| 91 | + logger.error("Failed to initialize llm translation client: %s", exc, exc_info=True) | ||
| 92 | + return None | ||
| 93 | + | ||
| 94 | + def _translate_single( | ||
| 95 | + self, | ||
| 96 | + text: str, | ||
| 97 | + target_lang: str, | ||
| 98 | + source_lang: Optional[str] = None, | ||
| 99 | + context: Optional[str] = None, | ||
| 100 | + prompt: Optional[str] = None, | ||
| 101 | + ) -> Optional[str]: | ||
| 102 | + if not text or not str(text).strip(): | ||
| 103 | + return text | ||
| 104 | + if not self.client: | ||
| 105 | + return None | ||
| 106 | + | ||
| 107 | + tgt = (target_lang or "").lower() or "en" | ||
| 108 | + src = (source_lang or "auto").lower() | ||
| 109 | + scene = context or "default" | ||
| 110 | + user_prompt = prompt or _build_prompt( | ||
| 111 | + text=text, | ||
| 112 | + source_lang=src, | ||
| 113 | + target_lang=tgt, | ||
| 114 | + scene=scene, | ||
| 115 | + ) | ||
| 116 | + start = time.time() | ||
| 117 | + try: | ||
| 118 | + logger.info( | ||
| 119 | + "[llm] Request | src=%s tgt=%s model=%s prompt=%s", | ||
| 120 | + src, | ||
| 121 | + tgt, | ||
| 122 | + self.model, | ||
| 123 | + user_prompt, | ||
| 124 | + ) | ||
| 125 | + completion = self.client.chat.completions.create( | ||
| 126 | + model=self.model, | ||
| 127 | + messages=[{"role": "user", "content": user_prompt}], | ||
| 128 | + timeout=self.timeout_sec, | ||
| 129 | + ) | ||
| 130 | + content = (completion.choices[0].message.content or "").strip() | ||
| 131 | + latency_ms = (time.time() - start) * 1000 | ||
| 132 | + if not content: | ||
| 133 | + logger.warning("[llm] Empty result | src=%s tgt=%s latency=%.1fms", src, tgt, latency_ms) | ||
| 134 | + return None | ||
| 135 | + logger.info( | ||
| 136 | + "[llm] Success | src=%s tgt=%s src_text=%s response=%s latency=%.1fms", | ||
| 137 | + src, | ||
| 138 | + tgt, | ||
| 139 | + text, | ||
| 140 | + content, | ||
| 141 | + latency_ms, | ||
| 142 | + ) | ||
| 143 | + return content | ||
| 144 | + except Exception as exc: | ||
| 145 | + latency_ms = (time.time() - start) * 1000 | ||
| 146 | + logger.warning( | ||
| 147 | + "[llm] Failed | src=%s tgt=%s latency=%.1fms error=%s", | ||
| 148 | + src, | ||
| 149 | + tgt, | ||
| 150 | + latency_ms, | ||
| 151 | + exc, | ||
| 152 | + exc_info=True, | ||
| 153 | + ) | ||
| 154 | + return None | ||
| 155 | + | ||
| 156 | + def translate( | ||
| 157 | + self, | ||
| 158 | + text: Union[str, Sequence[str]], | ||
| 159 | + target_lang: str, | ||
| 160 | + source_lang: Optional[str] = None, | ||
| 161 | + context: Optional[str] = None, | ||
| 162 | + prompt: Optional[str] = None, | ||
| 163 | + ) -> Union[Optional[str], List[Optional[str]]]: | ||
| 164 | + if isinstance(text, (list, tuple)): | ||
| 165 | + results: List[Optional[str]] = [] | ||
| 166 | + for item in text: | ||
| 167 | + if item is None: | ||
| 168 | + results.append(None) | ||
| 169 | + continue | ||
| 170 | + results.append( | ||
| 171 | + self._translate_single( | ||
| 172 | + text=str(item), | ||
| 173 | + target_lang=target_lang, | ||
| 174 | + source_lang=source_lang, | ||
| 175 | + context=context, | ||
| 176 | + prompt=prompt, | ||
| 177 | + ) | ||
| 178 | + ) | ||
| 179 | + return results | ||
| 180 | + | ||
| 181 | + return self._translate_single( | ||
| 182 | + text=str(text), | ||
| 183 | + target_lang=target_lang, | ||
| 184 | + source_lang=source_lang, | ||
| 185 | + context=context, | ||
| 186 | + prompt=prompt, | ||
| 187 | + ) | ||
| 188 | + | ||
| 189 | + | ||
| 190 | +LLMTranslatorProvider = LLMTranslationBackend | ||
| 191 | + | ||
| 192 | + | ||
| 193 | +def llm_translate( | ||
| 194 | + text: Union[str, Sequence[str]], | ||
| 195 | + target_lang: str, | ||
| 196 | + *, | ||
| 197 | + source_lang: Optional[str] = None, | ||
| 198 | + source_lang_label: Optional[str] = None, | ||
| 199 | + target_lang_label: Optional[str] = None, | ||
| 200 | + timeout_sec: Optional[float] = None, | ||
| 201 | +) -> Union[Optional[str], List[Optional[str]]]: | ||
| 202 | + del source_lang_label, target_lang_label | ||
| 203 | + provider = LLMTranslationBackend(timeout_sec=timeout_sec or 30.0) | ||
| 204 | + return provider.translate( | ||
| 205 | + text=text, | ||
| 206 | + target_lang=target_lang, | ||
| 207 | + source_lang=source_lang, | ||
| 208 | + context=None, | ||
| 209 | + ) |
| @@ -0,0 +1,260 @@ | @@ -0,0 +1,260 @@ | ||
| 1 | +"""Qwen-MT translation backend with cache support.""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import hashlib | ||
| 6 | +import logging | ||
| 7 | +import os | ||
| 8 | +import re | ||
| 9 | +import time | ||
| 10 | +from typing import List, Optional, Sequence, Union | ||
| 11 | + | ||
| 12 | +import redis | ||
| 13 | +from openai import OpenAI | ||
| 14 | + | ||
| 15 | +from config.env_config import DASHSCOPE_API_KEY, REDIS_CONFIG | ||
| 16 | +from config.services_config import get_translation_cache_config | ||
| 17 | +from config.tenant_config_loader import SOURCE_LANG_CODE_MAP | ||
| 18 | + | ||
| 19 | +logger = logging.getLogger(__name__) | ||
| 20 | + | ||
| 21 | + | ||
| 22 | +class QwenMTTranslationBackend: | ||
| 23 | + QWEN_DEFAULT_BASE_URL = "https://dashscope-us.aliyuncs.com/compatible-mode/v1" | ||
| 24 | + QWEN_MODEL = "qwen-mt-flash" | ||
| 25 | + SOURCE_LANG_CODE_MAP = SOURCE_LANG_CODE_MAP | ||
| 26 | + | ||
| 27 | + def __init__( | ||
| 28 | + self, | ||
| 29 | + model: str = "qwen", | ||
| 30 | + api_key: Optional[str] = None, | ||
| 31 | + use_cache: bool = True, | ||
| 32 | + timeout: int = 10, | ||
| 33 | + glossary_id: Optional[str] = None, | ||
| 34 | + translation_context: Optional[str] = None, | ||
| 35 | + ): | ||
| 36 | + self.model = self._normalize_model(model) | ||
| 37 | + self.timeout = int(timeout) | ||
| 38 | + self.use_cache = bool(use_cache) | ||
| 39 | + self.glossary_id = glossary_id | ||
| 40 | + self.translation_context = translation_context or "e-commerce product search" | ||
| 41 | + | ||
| 42 | + cache_cfg = get_translation_cache_config() | ||
| 43 | + self.cache_prefix = str(cache_cfg.get("key_prefix", "trans:v2")) | ||
| 44 | + self.expire_seconds = int(cache_cfg.get("ttl_seconds", 360 * 24 * 3600)) | ||
| 45 | + self.cache_sliding_expiration = bool(cache_cfg.get("sliding_expiration", True)) | ||
| 46 | + self.cache_include_context = bool(cache_cfg.get("key_include_context", True)) | ||
| 47 | + self.cache_include_prompt = bool(cache_cfg.get("key_include_prompt", True)) | ||
| 48 | + self.cache_include_source_lang = bool(cache_cfg.get("key_include_source_lang", True)) | ||
| 49 | + | ||
| 50 | + self.qwen_model_name = self._resolve_qwen_model_name(model) | ||
| 51 | + self._api_key = api_key or self._default_api_key(self.model) | ||
| 52 | + self._qwen_client: Optional[OpenAI] = None | ||
| 53 | + base_url = os.getenv("DASHSCOPE_BASE_URL") or self.QWEN_DEFAULT_BASE_URL | ||
| 54 | + if self._api_key: | ||
| 55 | + try: | ||
| 56 | + self._qwen_client = OpenAI(api_key=self._api_key, base_url=base_url) | ||
| 57 | + except Exception as exc: | ||
| 58 | + logger.warning("Failed to initialize qwen-mt client: %s", exc, exc_info=True) | ||
| 59 | + else: | ||
| 60 | + logger.warning("DASHSCOPE_API_KEY not set; qwen-mt translation unavailable") | ||
| 61 | + | ||
| 62 | + self.redis_client = None | ||
| 63 | + if self.use_cache and bool(cache_cfg.get("enabled", True)): | ||
| 64 | + self.redis_client = self._init_redis_client() | ||
| 65 | + | ||
| 66 | + @property | ||
| 67 | + def supports_batch(self) -> bool: | ||
| 68 | + return True | ||
| 69 | + | ||
| 70 | + @staticmethod | ||
| 71 | + def _normalize_model(model: str) -> str: | ||
| 72 | + m = (model or "qwen").strip().lower() | ||
| 73 | + if m.startswith("qwen"): | ||
| 74 | + return "qwen-mt" | ||
| 75 | + raise ValueError(f"Unsupported model: {model}. Supported models: 'qwen', 'qwen-mt', 'qwen-mt-flash'") | ||
| 76 | + | ||
| 77 | + @staticmethod | ||
| 78 | + def _resolve_qwen_model_name(model: str) -> str: | ||
| 79 | + m = (model or "qwen").strip().lower() | ||
| 80 | + if m in {"qwen", "qwen-mt"}: | ||
| 81 | + return "qwen-mt-flash" | ||
| 82 | + return m | ||
| 83 | + | ||
| 84 | + @staticmethod | ||
| 85 | + def _default_api_key(model: str) -> Optional[str]: | ||
| 86 | + del model | ||
| 87 | + return DASHSCOPE_API_KEY or os.getenv("DASHSCOPE_API_KEY") | ||
| 88 | + | ||
| 89 | + def _init_redis_client(self): | ||
| 90 | + try: | ||
| 91 | + client = redis.Redis( | ||
| 92 | + host=REDIS_CONFIG.get("host", "localhost"), | ||
| 93 | + port=REDIS_CONFIG.get("port", 6479), | ||
| 94 | + password=REDIS_CONFIG.get("password"), | ||
| 95 | + decode_responses=True, | ||
| 96 | + socket_timeout=REDIS_CONFIG.get("socket_timeout", 1), | ||
| 97 | + socket_connect_timeout=REDIS_CONFIG.get("socket_connect_timeout", 1), | ||
| 98 | + retry_on_timeout=REDIS_CONFIG.get("retry_on_timeout", False), | ||
| 99 | + health_check_interval=10, | ||
| 100 | + ) | ||
| 101 | + client.ping() | ||
| 102 | + return client | ||
| 103 | + except Exception as exc: | ||
| 104 | + logger.warning("Failed to initialize translation redis cache: %s", exc) | ||
| 105 | + return None | ||
| 106 | + | ||
| 107 | + def _build_cache_key( | ||
| 108 | + self, | ||
| 109 | + text: str, | ||
| 110 | + target_lang: str, | ||
| 111 | + source_lang: Optional[str], | ||
| 112 | + context: Optional[str], | ||
| 113 | + prompt: Optional[str], | ||
| 114 | + ) -> str: | ||
| 115 | + src = (source_lang or "auto").strip().lower() if self.cache_include_source_lang else "-" | ||
| 116 | + tgt = (target_lang or "").strip().lower() | ||
| 117 | + ctx = (context or "").strip() if self.cache_include_context else "" | ||
| 118 | + prm = (prompt or "").strip() if self.cache_include_prompt else "" | ||
| 119 | + payload = f"model={self.model}\nsrc={src}\ntgt={tgt}\nctx={ctx}\nprm={prm}\ntext={text}" | ||
| 120 | + digest = hashlib.sha256(payload.encode("utf-8")).hexdigest() | ||
| 121 | + return f"{self.cache_prefix}:{self.model}:{src}:{tgt}:{digest}" | ||
| 122 | + | ||
| 123 | + def translate( | ||
| 124 | + self, | ||
| 125 | + text: Union[str, Sequence[str]], | ||
| 126 | + target_lang: str, | ||
| 127 | + source_lang: Optional[str] = None, | ||
| 128 | + context: Optional[str] = None, | ||
| 129 | + prompt: Optional[str] = None, | ||
| 130 | + ) -> Union[Optional[str], List[Optional[str]]]: | ||
| 131 | + if isinstance(text, (list, tuple)): | ||
| 132 | + results: List[Optional[str]] = [] | ||
| 133 | + for item in text: | ||
| 134 | + if item is None or not str(item).strip(): | ||
| 135 | + results.append(item) # type: ignore[arg-type] | ||
| 136 | + continue | ||
| 137 | + out = self.translate( | ||
| 138 | + text=str(item), | ||
| 139 | + target_lang=target_lang, | ||
| 140 | + source_lang=source_lang, | ||
| 141 | + context=context, | ||
| 142 | + prompt=prompt, | ||
| 143 | + ) | ||
| 144 | + results.append(out) | ||
| 145 | + return results | ||
| 146 | + | ||
| 147 | + if not text or not str(text).strip(): | ||
| 148 | + return text # type: ignore[return-value] | ||
| 149 | + | ||
| 150 | + tgt = (target_lang or "").strip().lower() | ||
| 151 | + src = (source_lang or "").strip().lower() or None | ||
| 152 | + if tgt == "en" and self._is_english_text(text): | ||
| 153 | + return text | ||
| 154 | + if tgt == "zh" and (self._contains_chinese(text) or self._is_pure_number(text)): | ||
| 155 | + return text | ||
| 156 | + | ||
| 157 | + translation_context = context or self.translation_context | ||
| 158 | + cached = self._get_cached_translation_redis(text, tgt, src, translation_context, prompt) | ||
| 159 | + if cached is not None: | ||
| 160 | + return cached | ||
| 161 | + | ||
| 162 | + result = self._translate_qwen(text, tgt, src) | ||
| 163 | + | ||
| 164 | + if result is not None: | ||
| 165 | + self._set_cached_translation_redis(text, tgt, result, src, translation_context, prompt) | ||
| 166 | + return result | ||
| 167 | + | ||
| 168 | + def _translate_qwen( | ||
| 169 | + self, | ||
| 170 | + text: str, | ||
| 171 | + target_lang: str, | ||
| 172 | + source_lang: Optional[str], | ||
| 173 | + ) -> Optional[str]: | ||
| 174 | + if not self._qwen_client: | ||
| 175 | + return None | ||
| 176 | + tgt_norm = (target_lang or "").strip().lower() | ||
| 177 | + src_norm = (source_lang or "").strip().lower() | ||
| 178 | + tgt_qwen = self.SOURCE_LANG_CODE_MAP.get(tgt_norm, tgt_norm.capitalize()) | ||
| 179 | + src_qwen = "auto" if not src_norm or src_norm == "auto" else self.SOURCE_LANG_CODE_MAP.get(src_norm, src_norm.capitalize()) | ||
| 180 | + start = time.time() | ||
| 181 | + try: | ||
| 182 | + completion = self._qwen_client.chat.completions.create( | ||
| 183 | + model=self.qwen_model_name, | ||
| 184 | + messages=[{"role": "user", "content": text}], | ||
| 185 | + extra_body={ | ||
| 186 | + "translation_options": { | ||
| 187 | + "source_lang": src_qwen, | ||
| 188 | + "target_lang": tgt_qwen, | ||
| 189 | + } | ||
| 190 | + }, | ||
| 191 | + timeout=self.timeout, | ||
| 192 | + ) | ||
| 193 | + content = (completion.choices[0].message.content or "").strip() | ||
| 194 | + if not content: | ||
| 195 | + return None | ||
| 196 | + logger.info("[qwen-mt] Success | src=%s tgt=%s latency=%.1fms", src_qwen, tgt_qwen, (time.time() - start) * 1000) | ||
| 197 | + return content | ||
| 198 | + except Exception as exc: | ||
| 199 | + logger.warning( | ||
| 200 | + "[qwen-mt] Failed | src=%s tgt=%s latency=%.1fms error=%s", | ||
| 201 | + src_qwen, | ||
| 202 | + tgt_qwen, | ||
| 203 | + (time.time() - start) * 1000, | ||
| 204 | + exc, | ||
| 205 | + exc_info=True, | ||
| 206 | + ) | ||
| 207 | + return None | ||
| 208 | + | ||
| 209 | + def _get_cached_translation_redis( | ||
| 210 | + self, | ||
| 211 | + text: str, | ||
| 212 | + target_lang: str, | ||
| 213 | + source_lang: Optional[str] = None, | ||
| 214 | + context: Optional[str] = None, | ||
| 215 | + prompt: Optional[str] = None, | ||
| 216 | + ) -> Optional[str]: | ||
| 217 | + if not self.redis_client: | ||
| 218 | + return None | ||
| 219 | + key = self._build_cache_key(text, target_lang, source_lang, context, prompt) | ||
| 220 | + try: | ||
| 221 | + value = self.redis_client.get(key) | ||
| 222 | + if value and self.cache_sliding_expiration: | ||
| 223 | + self.redis_client.expire(key, self.expire_seconds) | ||
| 224 | + return value | ||
| 225 | + except Exception as exc: | ||
| 226 | + logger.warning("Redis get translation cache failed: %s", exc) | ||
| 227 | + return None | ||
| 228 | + | ||
| 229 | + def _set_cached_translation_redis( | ||
| 230 | + self, | ||
| 231 | + text: str, | ||
| 232 | + target_lang: str, | ||
| 233 | + translation: str, | ||
| 234 | + source_lang: Optional[str] = None, | ||
| 235 | + context: Optional[str] = None, | ||
| 236 | + prompt: Optional[str] = None, | ||
| 237 | + ) -> None: | ||
| 238 | + if not self.redis_client: | ||
| 239 | + return | ||
| 240 | + key = self._build_cache_key(text, target_lang, source_lang, context, prompt) | ||
| 241 | + try: | ||
| 242 | + self.redis_client.setex(key, self.expire_seconds, translation) | ||
| 243 | + except Exception as exc: | ||
| 244 | + logger.warning("Redis set translation cache failed: %s", exc) | ||
| 245 | + | ||
| 246 | + @staticmethod | ||
| 247 | + def _contains_chinese(text: str) -> bool: | ||
| 248 | + return bool(re.search(r"[\u4e00-\u9fff]", text or "")) | ||
| 249 | + | ||
| 250 | + @staticmethod | ||
| 251 | + def _is_english_text(text: str) -> bool: | ||
| 252 | + stripped = (text or "").strip() | ||
| 253 | + return bool(stripped) and bool(re.fullmatch(r"[A-Za-z0-9\s\W]+", stripped)) and not QwenMTTranslationBackend._contains_chinese(stripped) | ||
| 254 | + | ||
| 255 | + @staticmethod | ||
| 256 | + def _is_pure_number(text: str) -> bool: | ||
| 257 | + return bool(re.fullmatch(r"[\d.\-+%/,: ]+", (text or "").strip())) | ||
| 258 | + | ||
| 259 | + | ||
| 260 | +Translator = QwenMTTranslationBackend |
| @@ -0,0 +1,86 @@ | @@ -0,0 +1,86 @@ | ||
| 1 | +"""HTTP client for the translation service.""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import logging | ||
| 6 | +from typing import List, Optional, Sequence, Union | ||
| 7 | + | ||
| 8 | +import requests | ||
| 9 | + | ||
| 10 | +from config.services_config import get_translation_config | ||
| 11 | + | ||
| 12 | +logger = logging.getLogger(__name__) | ||
| 13 | + | ||
| 14 | + | ||
| 15 | +class TranslationServiceClient: | ||
| 16 | + """Business-side translation client that talks to the translator service.""" | ||
| 17 | + | ||
| 18 | + def __init__( | ||
| 19 | + self, | ||
| 20 | + *, | ||
| 21 | + base_url: Optional[str] = None, | ||
| 22 | + default_model: Optional[str] = None, | ||
| 23 | + default_scene: Optional[str] = None, | ||
| 24 | + timeout_sec: Optional[float] = None, | ||
| 25 | + ) -> None: | ||
| 26 | + cfg = get_translation_config() | ||
| 27 | + self.base_url = (base_url or cfg.service_url).rstrip("/") | ||
| 28 | + self.default_model = cfg.normalize_model_name(default_model or cfg.default_model) | ||
| 29 | + self.default_scene = (default_scene or cfg.default_scene or "general").strip() or "general" | ||
| 30 | + self.timeout_sec = float(timeout_sec or cfg.timeout_sec or 10.0) | ||
| 31 | + | ||
| 32 | + @property | ||
| 33 | + def model(self) -> str: | ||
| 34 | + return self.default_model | ||
| 35 | + | ||
| 36 | + @property | ||
| 37 | + def supports_batch(self) -> bool: | ||
| 38 | + return True | ||
| 39 | + | ||
| 40 | + def translate( | ||
| 41 | + self, | ||
| 42 | + text: Union[str, Sequence[str]], | ||
| 43 | + target_lang: str, | ||
| 44 | + source_lang: Optional[str] = None, | ||
| 45 | + context: Optional[str] = None, | ||
| 46 | + prompt: Optional[str] = None, | ||
| 47 | + model: Optional[str] = None, | ||
| 48 | + scene: Optional[str] = None, | ||
| 49 | + ) -> Union[Optional[str], List[Optional[str]]]: | ||
| 50 | + if isinstance(text, tuple): | ||
| 51 | + text = list(text) | ||
| 52 | + payload = { | ||
| 53 | + "text": text, | ||
| 54 | + "target_lang": target_lang, | ||
| 55 | + "source_lang": source_lang or "auto", | ||
| 56 | + "model": (model or self.default_model), | ||
| 57 | + "scene": (scene or context or self.default_scene), | ||
| 58 | + } | ||
| 59 | + if prompt: | ||
| 60 | + payload["prompt"] = prompt | ||
| 61 | + try: | ||
| 62 | + response = requests.post( | ||
| 63 | + f"{self.base_url}/translate", | ||
| 64 | + json=payload, | ||
| 65 | + timeout=self.timeout_sec, | ||
| 66 | + ) | ||
| 67 | + if response.status_code != 200: | ||
| 68 | + logger.warning( | ||
| 69 | + "Translation service request failed: status=%s body=%s", | ||
| 70 | + response.status_code, | ||
| 71 | + (response.text or "")[:300], | ||
| 72 | + ) | ||
| 73 | + return self._empty_result_for(text) | ||
| 74 | + data = response.json() | ||
| 75 | + return data.get("translated_text") | ||
| 76 | + except Exception as exc: | ||
| 77 | + logger.warning("Translation service request error: %s", exc, exc_info=True) | ||
| 78 | + return self._empty_result_for(text) | ||
| 79 | + | ||
| 80 | + @staticmethod | ||
| 81 | + def _empty_result_for( | ||
| 82 | + text: Union[str, Sequence[str]], | ||
| 83 | + ) -> Union[Optional[str], List[Optional[str]]]: | ||
| 84 | + if isinstance(text, (list, tuple)): | ||
| 85 | + return [None for _ in text] | ||
| 86 | + return None |
| @@ -0,0 +1,30 @@ | @@ -0,0 +1,30 @@ | ||
| 1 | +"""Protocols for translation service backends.""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from typing import List, Optional, Protocol, Sequence, Union, runtime_checkable | ||
| 6 | + | ||
| 7 | + | ||
| 8 | +TranslateInput = Union[str, Sequence[str]] | ||
| 9 | +TranslateOutput = Union[Optional[str], List[Optional[str]]] | ||
| 10 | + | ||
| 11 | + | ||
| 12 | +@runtime_checkable | ||
| 13 | +class TranslationBackendProtocol(Protocol): | ||
| 14 | + """Shared protocol implemented by translation backends.""" | ||
| 15 | + | ||
| 16 | + model: str | ||
| 17 | + | ||
| 18 | + @property | ||
| 19 | + def supports_batch(self) -> bool: | ||
| 20 | + ... | ||
| 21 | + | ||
| 22 | + def translate( | ||
| 23 | + self, | ||
| 24 | + text: TranslateInput, | ||
| 25 | + target_lang: str, | ||
| 26 | + source_lang: Optional[str] = None, | ||
| 27 | + context: Optional[str] = None, | ||
| 28 | + prompt: Optional[str] = None, | ||
| 29 | + ) -> TranslateOutput: | ||
| 30 | + ... |
| @@ -0,0 +1,103 @@ | @@ -0,0 +1,103 @@ | ||
| 1 | +"""Translation service orchestration.""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import logging | ||
| 6 | +from typing import Dict, List, Optional | ||
| 7 | + | ||
| 8 | +from config.services_config import TranslationServiceConfig, get_translation_config | ||
| 9 | +from translation.protocols import TranslateInput, TranslateOutput, TranslationBackendProtocol | ||
| 10 | + | ||
| 11 | +logger = logging.getLogger(__name__) | ||
| 12 | + | ||
| 13 | + | ||
| 14 | +class TranslationService: | ||
| 15 | + """Owns translation backends and routes calls by model and scene.""" | ||
| 16 | + | ||
| 17 | + def __init__(self, config: Optional[TranslationServiceConfig] = None) -> None: | ||
| 18 | + self.config = config or get_translation_config() | ||
| 19 | + self._backends: Dict[str, TranslationBackendProtocol] = {} | ||
| 20 | + self._init_enabled_backends() | ||
| 21 | + | ||
| 22 | + def _init_enabled_backends(self) -> None: | ||
| 23 | + registry = { | ||
| 24 | + "qwen-mt": self._create_qwen_mt_backend, | ||
| 25 | + "deepl": self._create_deepl_backend, | ||
| 26 | + "llm": self._create_llm_backend, | ||
| 27 | + } | ||
| 28 | + for name in self.config.enabled_models: | ||
| 29 | + factory = registry.get(name) | ||
| 30 | + if factory is None: | ||
| 31 | + logger.warning("Translation backend '%s' is enabled but not registered", name) | ||
| 32 | + continue | ||
| 33 | + self._backends[name] = factory() | ||
| 34 | + | ||
| 35 | + if not self._backends: | ||
| 36 | + raise ValueError("No enabled translation backends found in services.translation.capabilities") | ||
| 37 | + | ||
| 38 | + def _create_qwen_mt_backend(self) -> TranslationBackendProtocol: | ||
| 39 | + from translation.backends.qwen_mt import QwenMTTranslationBackend | ||
| 40 | + | ||
| 41 | + cfg = self.config.get_capability_cfg("qwen-mt") | ||
| 42 | + return QwenMTTranslationBackend( | ||
| 43 | + model=cfg.get("model") or "qwen-mt-flash", | ||
| 44 | + api_key=cfg.get("api_key"), | ||
| 45 | + use_cache=bool(cfg.get("use_cache", True)), | ||
| 46 | + timeout=int(cfg.get("timeout_sec", 10)), | ||
| 47 | + glossary_id=cfg.get("glossary_id"), | ||
| 48 | + translation_context=cfg.get("translation_context"), | ||
| 49 | + ) | ||
| 50 | + | ||
| 51 | + def _create_deepl_backend(self) -> TranslationBackendProtocol: | ||
| 52 | + from translation.backends.deepl import DeepLTranslationBackend | ||
| 53 | + | ||
| 54 | + cfg = self.config.get_capability_cfg("deepl") | ||
| 55 | + return DeepLTranslationBackend( | ||
| 56 | + api_key=cfg.get("api_key"), | ||
| 57 | + timeout=float(cfg.get("timeout_sec", 10.0)), | ||
| 58 | + glossary_id=cfg.get("glossary_id"), | ||
| 59 | + ) | ||
| 60 | + | ||
| 61 | + def _create_llm_backend(self) -> TranslationBackendProtocol: | ||
| 62 | + from translation.backends.llm import LLMTranslationBackend | ||
| 63 | + | ||
| 64 | + cfg = self.config.get_capability_cfg("llm") | ||
| 65 | + return LLMTranslationBackend( | ||
| 66 | + model=cfg.get("model"), | ||
| 67 | + timeout_sec=float(cfg.get("timeout_sec", 30.0)), | ||
| 68 | + base_url=cfg.get("base_url"), | ||
| 69 | + ) | ||
| 70 | + | ||
| 71 | + @property | ||
| 72 | + def available_models(self) -> List[str]: | ||
| 73 | + return list(self._backends.keys()) | ||
| 74 | + | ||
| 75 | + def get_backend(self, model: Optional[str] = None) -> TranslationBackendProtocol: | ||
| 76 | + normalized = self.config.normalize_model_name(model) | ||
| 77 | + backend = self._backends.get(normalized) | ||
| 78 | + if backend is None: | ||
| 79 | + raise ValueError( | ||
| 80 | + f"Translation model '{normalized}' is not enabled. " | ||
| 81 | + f"Available models: {', '.join(self.available_models) or 'none'}" | ||
| 82 | + ) | ||
| 83 | + return backend | ||
| 84 | + | ||
| 85 | + def translate( | ||
| 86 | + self, | ||
| 87 | + text: TranslateInput, | ||
| 88 | + target_lang: str, | ||
| 89 | + source_lang: Optional[str] = None, | ||
| 90 | + *, | ||
| 91 | + model: Optional[str] = None, | ||
| 92 | + scene: Optional[str] = None, | ||
| 93 | + prompt: Optional[str] = None, | ||
| 94 | + ) -> TranslateOutput: | ||
| 95 | + backend = self.get_backend(model) | ||
| 96 | + active_scene = (scene or self.config.default_scene or "general").strip() or "general" | ||
| 97 | + return backend.translate( | ||
| 98 | + text=text, | ||
| 99 | + target_lang=target_lang, | ||
| 100 | + source_lang=source_lang, | ||
| 101 | + context=active_scene, | ||
| 102 | + prompt=prompt, | ||
| 103 | + ) |