Commit 0fd2f875d0489b437589ee50a0bd108bdbbda1d9
1 parent
5e4dc8e4
translate
Showing
48 changed files
with
1731 additions
and
1146 deletions
Show diff stats
.gitignore
README.md
| ... | ... | @@ -60,12 +60,16 @@ source activate.sh |
| 60 | 60 | - `search/`:召回、排序、结果组织 |
| 61 | 61 | - `query/`:查询解析、多语言处理、改写 |
| 62 | 62 | - `indexer/`:MySQL 行数据 -> ES 文档的转换与索引流程 |
| 63 | -- `providers/`:能力调用抽象(translation/embedding/rerank) | |
| 63 | +- `providers/`:能力调用抽象(embedding/rerank) | |
| 64 | +- `translation/`:翻译服务客户端、服务编排与后端实现 | |
| 64 | 65 | - `reranker/`:重排服务及后端实现 |
| 65 | 66 | - `embeddings/`:向量服务(文本/图像) |
| 66 | 67 | - `config/`:配置加载与服务配置解析 |
| 67 | 68 | |
| 68 | -关键设计:**Provider(调用方式)与 Backend(推理实现)分离**,新增能力优先在协议与工厂注册,不改调用方主流程。 | |
| 69 | +关键设计: | |
| 70 | + | |
| 71 | +- embedding / rerank 继续采用 **Provider(调用方式)与 Backend(推理实现)分离** | |
| 72 | +- translation 采用 **一个 translator service + 多个 capability backend**,业务侧统一调用 6006,不再做翻译 provider 选择 | |
| 69 | 73 | |
| 70 | 74 | --- |
| 71 | 75 | |
| ... | ... | @@ -89,9 +93,10 @@ source activate.sh |
| 89 | 93 | | 2. 运行与排障 | `docs/Usage-Guide.md` | |
| 90 | 94 | | 3. API 详细说明 | `docs/搜索API对接指南.md` | |
| 91 | 95 | | 4. 快速参数速查 | `docs/搜索API速查表.md` | |
| 92 | -| 5. 首次环境搭建、生产凭证 | `docs/QUICKSTART.md` §1.4–1.8 | | |
| 93 | -| 6. TEI 文本向量专项 | `docs/TEI_SERVICE说明文档.md` | | |
| 94 | -| 7. CN-CLIP 图片向量专项 | `docs/CNCLIP_SERVICE说明文档.md` | | |
| 96 | +| 5. 翻译专项 | `docs/翻译模块说明.md` | | |
| 97 | +| 6. 首次环境搭建、生产凭证 | `docs/QUICKSTART.md` §1.4–1.8 | | |
| 98 | +| 7. TEI 文本向量专项 | `docs/TEI_SERVICE说明文档.md` | | |
| 99 | +| 8. CN-CLIP 图片向量专项 | `docs/CNCLIP_SERVICE说明文档.md` | | |
| 95 | 100 | |
| 96 | 101 | --- |
| 97 | 102 | ... | ... |
api/translator_app.py
| 1 | +"""Translator service HTTP app.""" | |
| 1 | 2 | |
| 2 | -""" | |
| 3 | - | |
| 4 | -# 方式1:直接运行 | |
| 5 | -python api/translator_app.py | |
| 6 | - | |
| 7 | -# 方式2:使用 uvicorn | |
| 8 | -uvicorn api.translator_app:app --host 0.0.0.0 --port 6006 --reload | |
| 9 | - | |
| 10 | - | |
| 11 | -使用说明: | |
| 12 | -Translation HTTP Service | |
| 13 | - | |
| 14 | -This service provides a RESTful API for text translation using Qwen (default) or DeepL API. | |
| 15 | -The service runs on port 6006 and provides a simple translation endpoint. | |
| 16 | - | |
| 17 | -API Endpoint: | |
| 18 | - POST /translate | |
| 19 | - | |
| 20 | -Request Body (JSON): | |
| 21 | - { | |
| 22 | - "text": "要翻译的文本", | |
| 23 | - "target_lang": "en", # Required: target language code (zh, en, ru, etc.) | |
| 24 | - "source_lang": "zh", # Optional: source language code (auto-detect if not provided) | |
| 25 | - "model": "qwen" # Optional: translation model ("qwen" or "deepl", default: "qwen") | |
| 26 | - } | |
| 27 | - | |
| 28 | -Response (JSON): | |
| 29 | - { | |
| 30 | - "text": "要翻译的文本", | |
| 31 | - "target_lang": "en", | |
| 32 | - "source_lang": "zh", | |
| 33 | - "translated_text": "Text to translate", | |
| 34 | - "status": "success" | |
| 35 | - } | |
| 36 | - | |
| 37 | -Usage Examples: | |
| 38 | - | |
| 39 | -1. Translate Chinese to English: | |
| 40 | - curl -X POST http://localhost:6006/translate \ | |
| 41 | - -H "Content-Type: application/json" \ | |
| 42 | - -d '{ | |
| 43 | - "text": "商品名称", | |
| 44 | - "target_lang": "en", | |
| 45 | - "source_lang": "zh" | |
| 46 | - }' | |
| 47 | - | |
| 48 | -2. Translate with auto-detection: | |
| 49 | - curl -X POST http://localhost:6006/translate \ | |
| 50 | - -H "Content-Type: application/json" \ | |
| 51 | - -d '{ | |
| 52 | - "text": "Product name", | |
| 53 | - "target_lang": "zh" | |
| 54 | - }' | |
| 55 | - | |
| 56 | -3. Translate using DeepL model: | |
| 57 | - curl -X POST http://localhost:6006/translate \ | |
| 58 | - -H "Content-Type: application/json" \ | |
| 59 | - -d '{ | |
| 60 | - "text": "商品名称", | |
| 61 | - "target_lang": "en", | |
| 62 | - "source_lang": "zh", | |
| 63 | - "model": "deepl" | |
| 64 | - }' | |
| 65 | - | |
| 66 | -4. Translate Russian to English: | |
| 67 | - curl -X POST http://localhost:6006/translate \ | |
| 68 | - -H "Content-Type: application/json" \ | |
| 69 | - -d '{ | |
| 70 | - "text": "Название товара", | |
| 71 | - "target_lang": "en", | |
| 72 | - "source_lang": "ru" | |
| 73 | - }' | |
| 74 | - | |
| 75 | -Health Check: | |
| 76 | - GET /health | |
| 77 | - | |
| 78 | - curl http://localhost:6006/health | |
| 79 | - | |
| 80 | -Start the service: | |
| 81 | - python api/translator_app.py | |
| 82 | - # or | |
| 83 | - uvicorn api.translator_app:app --host 0.0.0.0 --port 6006 --reload | |
| 84 | -""" | |
| 85 | - | |
| 86 | -import logging | |
| 87 | 3 | import argparse |
| 4 | +import logging | |
| 5 | +from contextlib import asynccontextmanager | |
| 6 | +from functools import lru_cache | |
| 7 | +from typing import List, Optional, Union | |
| 8 | + | |
| 88 | 9 | import uvicorn |
| 89 | -from typing import Dict, List, Optional, Union | |
| 90 | 10 | from fastapi import FastAPI, HTTPException |
| 91 | -from fastapi.responses import JSONResponse | |
| 92 | 11 | from fastapi.middleware.cors import CORSMiddleware |
| 93 | -from pydantic import BaseModel, Field | |
| 12 | +from fastapi.responses import JSONResponse | |
| 13 | +from pydantic import BaseModel, ConfigDict, Field | |
| 94 | 14 | |
| 95 | 15 | from config.services_config import get_translation_config |
| 96 | 16 | from translation.service import TranslationService |
| 17 | +from translation.settings import ( | |
| 18 | + get_enabled_translation_models, | |
| 19 | + normalize_translation_model, | |
| 20 | + normalize_translation_scene, | |
| 21 | +) | |
| 97 | 22 | |
| 98 | 23 | # Configure logging |
| 99 | 24 | logging.basicConfig( |
| ... | ... | @@ -102,37 +27,33 @@ logging.basicConfig( |
| 102 | 27 | ) |
| 103 | 28 | logger = logging.getLogger(__name__) |
| 104 | 29 | |
| 105 | -_translation_service: Optional[TranslationService] = None | |
| 106 | - | |
| 107 | 30 | |
| 31 | +@lru_cache(maxsize=1) | |
| 108 | 32 | 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 | |
| 33 | + return TranslationService(get_translation_config()) | |
| 113 | 34 | |
| 114 | 35 | |
| 115 | 36 | # Request/Response models |
| 116 | 37 | class TranslationRequest(BaseModel): |
| 117 | 38 | """Translation request model.""" |
| 118 | - text: Union[str, List[str]] = Field(..., description="Text to translate (string or list of strings)") | |
| 119 | - target_lang: str = Field(..., description="Target language code (zh, en, ru, etc.)") | |
| 120 | - source_lang: Optional[str] = Field(None, description="Source language code (optional, auto-detect if not provided)") | |
| 121 | - model: Optional[str] = Field(None, description="Translation model: qwen-mt | deepl | llm") | |
| 122 | - scene: Optional[str] = Field(None, description="Translation scene, paired with model routing") | |
| 123 | - context: Optional[str] = Field(None, description="Deprecated alias of scene") | |
| 124 | - prompt: Optional[str] = Field(None, description="Optional prompt override") | |
| 125 | 39 | |
| 126 | - class Config: | |
| 127 | - json_schema_extra = { | |
| 40 | + model_config = ConfigDict( | |
| 41 | + json_schema_extra={ | |
| 128 | 42 | "example": { |
| 129 | 43 | "text": "商品名称", |
| 130 | 44 | "target_lang": "en", |
| 131 | 45 | "source_lang": "zh", |
| 132 | 46 | "model": "llm", |
| 133 | - "scene": "sku_name" | |
| 47 | + "scene": "sku_name", | |
| 134 | 48 | } |
| 135 | 49 | } |
| 50 | + ) | |
| 51 | + | |
| 52 | + text: Union[str, List[str]] = Field(..., description="Text to translate (string or list of strings)") | |
| 53 | + target_lang: str = Field(..., description="Target language code (zh, en, ru, etc.)") | |
| 54 | + source_lang: Optional[str] = Field(None, description="Source language code (optional, auto-detect if not provided)") | |
| 55 | + model: Optional[str] = Field(None, description="Enabled translation capability name") | |
| 56 | + scene: Optional[str] = Field(None, description="Translation scene, paired with model routing") | |
| 136 | 57 | |
| 137 | 58 | |
| 138 | 59 | class TranslationResponse(BaseModel): |
| ... | ... | @@ -149,13 +70,108 @@ class TranslationResponse(BaseModel): |
| 149 | 70 | scene: str = Field(..., description="Translation scene used") |
| 150 | 71 | |
| 151 | 72 | |
| 73 | +def _normalize_scene(service: TranslationService, scene: Optional[str]) -> str: | |
| 74 | + return normalize_translation_scene(service.config, scene) | |
| 75 | + | |
| 76 | + | |
| 77 | +def _normalize_model(service: TranslationService, model: Optional[str]) -> str: | |
| 78 | + return normalize_translation_model(service.config, model or service.config["default_model"]) | |
| 79 | + | |
| 80 | + | |
| 81 | +def _ensure_valid_text(text: Union[str, List[str]]) -> None: | |
| 82 | + if isinstance(text, list): | |
| 83 | + if not text: | |
| 84 | + raise HTTPException(status_code=400, detail="Text list cannot be empty") | |
| 85 | + return | |
| 86 | + if not text or not text.strip(): | |
| 87 | + raise HTTPException(status_code=400, detail="Text cannot be empty") | |
| 88 | + | |
| 89 | + | |
| 90 | +def _normalize_batch_result( | |
| 91 | + original: List[str], | |
| 92 | + translated: Union[str, List[Optional[str]], None], | |
| 93 | +) -> List[Optional[str]]: | |
| 94 | + if translated is None: | |
| 95 | + return [None for _ in original] | |
| 96 | + if not isinstance(translated, list): | |
| 97 | + raise HTTPException(status_code=500, detail="Batch translation provider returned non-list result") | |
| 98 | + return [translated[idx] if idx < len(translated) else None for idx, _ in enumerate(original)] | |
| 99 | + | |
| 100 | + | |
| 101 | +def _translate_batch( | |
| 102 | + service: TranslationService, | |
| 103 | + raw_text: List[str], | |
| 104 | + *, | |
| 105 | + target_lang: str, | |
| 106 | + source_lang: Optional[str], | |
| 107 | + model: str, | |
| 108 | + scene: str, | |
| 109 | +) -> List[Optional[str]]: | |
| 110 | + backend = service.get_backend(model) | |
| 111 | + if getattr(backend, "supports_batch", False): | |
| 112 | + try: | |
| 113 | + translated = service.translate( | |
| 114 | + text=raw_text, | |
| 115 | + target_lang=target_lang, | |
| 116 | + source_lang=source_lang, | |
| 117 | + model=model, | |
| 118 | + scene=scene, | |
| 119 | + ) | |
| 120 | + return _normalize_batch_result(raw_text, translated) | |
| 121 | + except ValueError: | |
| 122 | + raise | |
| 123 | + except Exception as exc: | |
| 124 | + logger.error("Batch translation failed: %s", exc, exc_info=True) | |
| 125 | + | |
| 126 | + results: List[Optional[str]] = [] | |
| 127 | + for item in raw_text: | |
| 128 | + if item is None or not str(item).strip(): | |
| 129 | + results.append(item) # type: ignore[arg-type] | |
| 130 | + continue | |
| 131 | + try: | |
| 132 | + out = service.translate( | |
| 133 | + text=str(item), | |
| 134 | + target_lang=target_lang, | |
| 135 | + source_lang=source_lang, | |
| 136 | + model=model, | |
| 137 | + scene=scene, | |
| 138 | + ) | |
| 139 | + except ValueError: | |
| 140 | + raise | |
| 141 | + except Exception as exc: | |
| 142 | + logger.warning("Per-item translation failed: %s", exc, exc_info=True) | |
| 143 | + out = None | |
| 144 | + results.append(out) | |
| 145 | + return results | |
| 146 | + | |
| 147 | + | |
| 148 | +@asynccontextmanager | |
| 149 | +async def lifespan(_: FastAPI): | |
| 150 | + """Warm the default backend on process startup.""" | |
| 151 | + logger.info("Starting Translation Service API") | |
| 152 | + service = get_translation_service() | |
| 153 | + default_backend = service.get_backend(service.config["default_model"]) | |
| 154 | + logger.info( | |
| 155 | + "Translation service ready | default_model=%s available_models=%s loaded_models=%s", | |
| 156 | + service.config["default_model"], | |
| 157 | + service.available_models, | |
| 158 | + service.loaded_models, | |
| 159 | + ) | |
| 160 | + logger.info( | |
| 161 | + "Default translation backend warmed up | model=%s", | |
| 162 | + getattr(default_backend, "model", service.config["default_model"]), | |
| 163 | + ) | |
| 164 | + yield | |
| 165 | + | |
| 166 | + | |
| 152 | 167 | # Create FastAPI app |
| 153 | 168 | app = FastAPI( |
| 154 | 169 | title="Translation Service API", |
| 155 | - description="RESTful API for text translation using Qwen (default) or DeepL", | |
| 170 | + description="Translation service with pluggable capabilities and scene routing", | |
| 156 | 171 | version="1.0.0", |
| 157 | 172 | docs_url="/docs", |
| 158 | - redoc_url="/redoc" | |
| 173 | + redoc_url="/redoc", | |
| 174 | + lifespan=lifespan, | |
| 159 | 175 | ) |
| 160 | 176 | |
| 161 | 177 | # Add CORS middleware |
| ... | ... | @@ -168,22 +184,6 @@ app.add_middleware( |
| 168 | 184 | ) |
| 169 | 185 | |
| 170 | 186 | |
| 171 | -@app.on_event("startup") | |
| 172 | -async def startup_event(): | |
| 173 | - """Initialize translator on startup.""" | |
| 174 | - logger.info("Starting Translation Service API on port 6006") | |
| 175 | - try: | |
| 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 | - ) | |
| 182 | - except Exception as e: | |
| 183 | - logger.error(f"Failed to initialize translator: {e}", exc_info=True) | |
| 184 | - raise | |
| 185 | - | |
| 186 | - | |
| 187 | 187 | @app.get("/health") |
| 188 | 188 | async def health_check(): |
| 189 | 189 | """Health check endpoint.""" |
| ... | ... | @@ -192,10 +192,11 @@ async def health_check(): |
| 192 | 192 | return { |
| 193 | 193 | "status": "healthy", |
| 194 | 194 | "service": "translation", |
| 195 | - "default_model": service.config.default_model, | |
| 196 | - "default_scene": service.config.default_scene, | |
| 195 | + "default_model": service.config["default_model"], | |
| 196 | + "default_scene": service.config["default_scene"], | |
| 197 | 197 | "available_models": service.available_models, |
| 198 | - "enabled_capabilities": service.config.enabled_models, | |
| 198 | + "enabled_capabilities": get_enabled_translation_models(service.config), | |
| 199 | + "loaded_models": service.loaded_models, | |
| 199 | 200 | } |
| 200 | 201 | except Exception as e: |
| 201 | 202 | logger.error(f"Health check failed: {e}") |
| ... | ... | @@ -210,106 +211,27 @@ async def health_check(): |
| 210 | 211 | |
| 211 | 212 | @app.post("/translate", response_model=TranslationResponse) |
| 212 | 213 | async def translate(request: TranslationRequest): |
| 213 | - """ | |
| 214 | - Translate text to target language. | |
| 215 | - | |
| 216 | - Uses a fixed prompt optimized for product SKU name translation. | |
| 217 | - The translation is cached in Redis for performance. | |
| 218 | - | |
| 219 | - Supports both Qwen (default) and DeepL models via the 'model' parameter. | |
| 220 | - """ | |
| 221 | - # 允许 text 为字符串或字符串列表 | |
| 222 | - if isinstance(request.text, list): | |
| 223 | - if not request.text: | |
| 224 | - raise HTTPException( | |
| 225 | - status_code=400, | |
| 226 | - detail="Text list cannot be empty" | |
| 227 | - ) | |
| 228 | - else: | |
| 229 | - if not request.text or not request.text.strip(): | |
| 230 | - raise HTTPException( | |
| 231 | - status_code=400, | |
| 232 | - detail="Text cannot be empty" | |
| 233 | - ) | |
| 234 | - | |
| 214 | + _ensure_valid_text(request.text) | |
| 215 | + | |
| 235 | 216 | if not request.target_lang: |
| 236 | - raise HTTPException( | |
| 237 | - status_code=400, | |
| 238 | - detail="target_lang is required" | |
| 239 | - ) | |
| 240 | - | |
| 217 | + raise HTTPException(status_code=400, detail="target_lang is required") | |
| 218 | + | |
| 241 | 219 | try: |
| 242 | 220 | 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) | |
| 221 | + scene = _normalize_scene(service, request.scene) | |
| 222 | + model = _normalize_model(service, request.model) | |
| 245 | 223 | translator = service.get_backend(model) |
| 246 | 224 | raw_text = request.text |
| 247 | 225 | |
| 248 | - # 如果是列表,并且底层 provider 声明支持 batch,则直接传 list | |
| 249 | - if isinstance(raw_text, list) and getattr(translator, "supports_batch", False): | |
| 250 | - try: | |
| 251 | - translated_list = service.translate( | |
| 252 | - text=raw_text, | |
| 253 | - target_lang=request.target_lang, | |
| 254 | - source_lang=request.source_lang, | |
| 255 | - model=model, | |
| 256 | - scene=scene, | |
| 257 | - prompt=request.prompt, | |
| 258 | - ) | |
| 259 | - except Exception as exc: | |
| 260 | - logger.error("Batch translation failed: %s", exc, exc_info=True) | |
| 261 | - # 回退到逐条拆分逻辑 | |
| 262 | - translated_list = None | |
| 263 | - | |
| 264 | - if translated_list is not None: | |
| 265 | - # 规范化为 List[Optional[str]],并保证长度对应 | |
| 266 | - if not isinstance(translated_list, list): | |
| 267 | - raise HTTPException( | |
| 268 | - status_code=500, | |
| 269 | - detail="Batch translation provider returned non-list result", | |
| 270 | - ) | |
| 271 | - normalized: List[Optional[str]] = [] | |
| 272 | - for idx, item in enumerate(raw_text): | |
| 273 | - if idx < len(translated_list): | |
| 274 | - val = translated_list[idx] | |
| 275 | - else: | |
| 276 | - val = None | |
| 277 | - # 失败语义:失败位置为 None | |
| 278 | - normalized.append(val) | |
| 279 | - | |
| 280 | - return TranslationResponse( | |
| 281 | - text=raw_text, | |
| 282 | - target_lang=request.target_lang, | |
| 283 | - source_lang=request.source_lang, | |
| 284 | - translated_text=normalized, | |
| 285 | - status="success", | |
| 286 | - model=str(getattr(translator, "model", model)), | |
| 287 | - scene=scene, | |
| 288 | - ) | |
| 289 | - | |
| 290 | - # 否则:统一走逐条拆分逻辑(包括不支持 batch 的 provider) | |
| 291 | 226 | if isinstance(raw_text, list): |
| 292 | - results: List[Optional[str]] = [] | |
| 293 | - for item in raw_text: | |
| 294 | - if item is None or not str(item).strip(): | |
| 295 | - # 空元素不视为失败,直接返回原值 | |
| 296 | - results.append(item) # type: ignore[arg-type] | |
| 297 | - continue | |
| 298 | - try: | |
| 299 | - out = service.translate( | |
| 300 | - text=str(item), | |
| 301 | - target_lang=request.target_lang, | |
| 302 | - source_lang=request.source_lang, | |
| 303 | - model=model, | |
| 304 | - scene=scene, | |
| 305 | - prompt=request.prompt, | |
| 306 | - ) | |
| 307 | - except Exception as exc: | |
| 308 | - logger.warning("Per-item translation failed: %s", exc, exc_info=True) | |
| 309 | - out = None | |
| 310 | - # 失败语义:该元素为 None | |
| 311 | - results.append(out) | |
| 312 | - | |
| 227 | + results = _translate_batch( | |
| 228 | + service, | |
| 229 | + raw_text, | |
| 230 | + target_lang=request.target_lang, | |
| 231 | + source_lang=request.source_lang, | |
| 232 | + model=model, | |
| 233 | + scene=scene, | |
| 234 | + ) | |
| 313 | 235 | return TranslationResponse( |
| 314 | 236 | text=raw_text, |
| 315 | 237 | target_lang=request.target_lang, |
| ... | ... | @@ -320,21 +242,16 @@ async def translate(request: TranslationRequest): |
| 320 | 242 | scene=scene, |
| 321 | 243 | ) |
| 322 | 244 | |
| 323 | - # 单文本模式:保持原有严格失败语义 | |
| 324 | 245 | translated_text = service.translate( |
| 325 | 246 | text=raw_text, |
| 326 | 247 | target_lang=request.target_lang, |
| 327 | 248 | source_lang=request.source_lang, |
| 328 | 249 | model=model, |
| 329 | 250 | scene=scene, |
| 330 | - prompt=request.prompt, | |
| 331 | 251 | ) |
| 332 | 252 | |
| 333 | 253 | if translated_text is None: |
| 334 | - raise HTTPException( | |
| 335 | - status_code=500, | |
| 336 | - detail="Translation failed" | |
| 337 | - ) | |
| 254 | + raise HTTPException(status_code=500, detail="Translation failed") | |
| 338 | 255 | |
| 339 | 256 | return TranslationResponse( |
| 340 | 257 | text=raw_text, |
| ... | ... | @@ -348,12 +265,11 @@ async def translate(request: TranslationRequest): |
| 348 | 265 | |
| 349 | 266 | except HTTPException: |
| 350 | 267 | raise |
| 268 | + except ValueError as e: | |
| 269 | + raise HTTPException(status_code=400, detail=str(e)) from e | |
| 351 | 270 | except Exception as e: |
| 352 | 271 | logger.error(f"Translation error: {e}", exc_info=True) |
| 353 | - raise HTTPException( | |
| 354 | - status_code=500, | |
| 355 | - detail=f"Translation error: {str(e)}" | |
| 356 | - ) | |
| 272 | + raise HTTPException(status_code=500, detail=f"Translation error: {str(e)}") | |
| 357 | 273 | |
| 358 | 274 | |
| 359 | 275 | @app.get("/") | ... | ... |
config/config.yaml
| ... | ... | @@ -77,10 +77,6 @@ query_config: |
| 77 | 77 | text_embedding_field: "title_embedding" |
| 78 | 78 | image_embedding_field: null |
| 79 | 79 | |
| 80 | - # 翻译API配置(provider/URL 在 services.translation) | |
| 81 | - translation_service: "deepl" | |
| 82 | - translation_api_key: null # 通过环境变量设置 | |
| 83 | - | |
| 84 | 80 | # 返回字段配置(_source includes) |
| 85 | 81 | # null表示返回所有字段,[]表示不返回任何字段,列表表示只返回指定字段 |
| 86 | 82 | source_fields: null |
| ... | ... | @@ -116,33 +112,61 @@ services: |
| 116 | 112 | key_prefix: "trans:v2" |
| 117 | 113 | ttl_seconds: 62208000 |
| 118 | 114 | sliding_expiration: true |
| 119 | - key_include_context: true | |
| 120 | - key_include_prompt: true | |
| 115 | + key_include_scene: true | |
| 121 | 116 | key_include_source_lang: true |
| 122 | 117 | capabilities: |
| 123 | 118 | qwen-mt: |
| 124 | 119 | enabled: true |
| 120 | + backend: "qwen_mt" | |
| 125 | 121 | model: "qwen-mt-flash" |
| 122 | + base_url: "https://dashscope-us.aliyuncs.com/compatible-mode/v1" | |
| 126 | 123 | timeout_sec: 10.0 |
| 127 | 124 | use_cache: true |
| 128 | 125 | llm: |
| 129 | 126 | enabled: true |
| 127 | + backend: "llm" | |
| 130 | 128 | model: "qwen-flash" |
| 131 | - # 可选:覆盖 DashScope 兼容模式的 Endpoint 与超时 | |
| 132 | - # base_url 留空则使用 DASHSCOPE_BASE_URL 或默认地域 | |
| 133 | - base_url: "" | |
| 129 | + base_url: "https://dashscope-us.aliyuncs.com/compatible-mode/v1" | |
| 134 | 130 | timeout_sec: 30.0 |
| 135 | 131 | deepl: |
| 136 | 132 | enabled: false |
| 137 | - model: "deepl" | |
| 133 | + backend: "deepl" | |
| 134 | + api_url: "https://api.deepl.com/v2/translate" | |
| 138 | 135 | timeout_sec: 10.0 |
| 139 | - # 可选:用于术语表翻译(由 query_config.translation_glossary_id 衔接) | |
| 140 | 136 | glossary_id: "" |
| 141 | - google: | |
| 142 | - enabled: false | |
| 143 | - project_id: "" | |
| 144 | - location: "global" | |
| 145 | - model: "" | |
| 137 | + nllb-200-distilled-600m: | |
| 138 | + enabled: true | |
| 139 | + backend: "local_nllb" | |
| 140 | + model_id: "facebook/nllb-200-distilled-600M" | |
| 141 | + model_dir: "./models/translation/facebook/nllb-200-distilled-600M" | |
| 142 | + device: "cuda" | |
| 143 | + torch_dtype: "float16" | |
| 144 | + batch_size: 8 | |
| 145 | + max_input_length: 256 | |
| 146 | + max_new_tokens: 256 | |
| 147 | + num_beams: 1 | |
| 148 | + opus-mt-zh-en: | |
| 149 | + enabled: true | |
| 150 | + backend: "local_marian" | |
| 151 | + model_id: "Helsinki-NLP/opus-mt-zh-en" | |
| 152 | + model_dir: "./models/translation/Helsinki-NLP/opus-mt-zh-en" | |
| 153 | + device: "cuda" | |
| 154 | + torch_dtype: "float16" | |
| 155 | + batch_size: 16 | |
| 156 | + max_input_length: 256 | |
| 157 | + max_new_tokens: 256 | |
| 158 | + num_beams: 1 | |
| 159 | + opus-mt-en-zh: | |
| 160 | + enabled: true | |
| 161 | + backend: "local_marian" | |
| 162 | + model_id: "Helsinki-NLP/opus-mt-en-zh" | |
| 163 | + model_dir: "./models/translation/Helsinki-NLP/opus-mt-en-zh" | |
| 164 | + device: "cuda" | |
| 165 | + torch_dtype: "float16" | |
| 166 | + batch_size: 16 | |
| 167 | + max_input_length: 256 | |
| 168 | + max_new_tokens: 256 | |
| 169 | + num_beams: 1 | |
| 146 | 170 | embedding: |
| 147 | 171 | provider: "http" # http |
| 148 | 172 | base_url: "http://127.0.0.1:6005" | ... | ... |
config/config_loader.py
| ... | ... | @@ -37,12 +37,6 @@ class QueryConfig: |
| 37 | 37 | # Query rewrite dictionary (loaded from external file) |
| 38 | 38 | rewrite_dictionary: Dict[str, str] = field(default_factory=dict) |
| 39 | 39 | |
| 40 | - # Translation settings (provider/URL in services.translation) | |
| 41 | - translation_service: str = "deepl" | |
| 42 | - translation_api_key: Optional[str] = None | |
| 43 | - translation_glossary_id: Optional[str] = None | |
| 44 | - translation_context: str = "e-commerce product search" | |
| 45 | - | |
| 46 | 40 | # Embedding field names |
| 47 | 41 | text_embedding_field: Optional[str] = "title_embedding" |
| 48 | 42 | image_embedding_field: Optional[str] = None |
| ... | ... | @@ -234,7 +228,6 @@ class ConfigLoader: |
| 234 | 228 | |
| 235 | 229 | # Parse query config |
| 236 | 230 | query_config_data = config_data.get("query_config", {}) |
| 237 | - services_data = config_data.get("services", {}) if isinstance(config_data.get("services", {}), dict) else {} | |
| 238 | 231 | rewrite_dictionary = self._load_rewrite_dictionary() |
| 239 | 232 | search_fields_cfg = query_config_data.get("search_fields", {}) |
| 240 | 233 | text_strategy_cfg = query_config_data.get("text_query_strategy", {}) |
| ... | ... | @@ -245,10 +238,6 @@ class ConfigLoader: |
| 245 | 238 | enable_text_embedding=query_config_data.get("enable_text_embedding", True), |
| 246 | 239 | enable_query_rewrite=query_config_data.get("enable_query_rewrite", True), |
| 247 | 240 | rewrite_dictionary=rewrite_dictionary, |
| 248 | - translation_api_key=query_config_data.get("translation_api_key"), | |
| 249 | - translation_service=query_config_data.get("translation_service") or "deepl", | |
| 250 | - translation_glossary_id=query_config_data.get("translation_glossary_id"), | |
| 251 | - translation_context=query_config_data.get("translation_context") or "e-commerce product search", | |
| 252 | 241 | text_embedding_field=query_config_data.get("text_embedding_field"), |
| 253 | 242 | image_embedding_field=query_config_data.get("image_embedding_field"), |
| 254 | 243 | source_fields=query_config_data.get("source_fields"), |
| ... | ... | @@ -459,7 +448,6 @@ class ConfigLoader: |
| 459 | 448 | "default_language": config.query_config.default_language, |
| 460 | 449 | "enable_text_embedding": config.query_config.enable_text_embedding, |
| 461 | 450 | "enable_query_rewrite": config.query_config.enable_query_rewrite, |
| 462 | - "translation_service": config.query_config.translation_service, | |
| 463 | 451 | "text_embedding_field": config.query_config.text_embedding_field, |
| 464 | 452 | "image_embedding_field": config.query_config.image_embedding_field, |
| 465 | 453 | "source_fields": config.query_config.source_fields, | ... | ... |
config/env_config.py
| ... | ... | @@ -65,9 +65,6 @@ EMBEDDING_HOST = os.getenv('EMBEDDING_HOST', '127.0.0.1') |
| 65 | 65 | EMBEDDING_PORT = int(os.getenv('EMBEDDING_PORT', 6005)) |
| 66 | 66 | TRANSLATION_HOST = os.getenv('TRANSLATION_HOST', '127.0.0.1') |
| 67 | 67 | TRANSLATION_PORT = int(os.getenv('TRANSLATION_PORT', 6006)) |
| 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') | |
| 71 | 68 | RERANKER_HOST = os.getenv('RERANKER_HOST', '127.0.0.1') |
| 72 | 69 | RERANKER_PORT = int(os.getenv('RERANKER_PORT', 6007)) |
| 73 | 70 | RERANK_PROVIDER = os.getenv('RERANK_PROVIDER', 'http') |
| ... | ... | @@ -79,7 +76,6 @@ INDEXER_BASE_URL = os.getenv('INDEXER_BASE_URL') or ( |
| 79 | 76 | f'http://localhost:{INDEXER_PORT}' if INDEXER_HOST == '0.0.0.0' else f'http://{INDEXER_HOST}:{INDEXER_PORT}' |
| 80 | 77 | ) |
| 81 | 78 | EMBEDDING_SERVICE_URL = os.getenv('EMBEDDING_SERVICE_URL') or f'http://{EMBEDDING_HOST}:{EMBEDDING_PORT}' |
| 82 | -TRANSLATION_SERVICE_URL = os.getenv('TRANSLATION_SERVICE_URL') or f'http://{TRANSLATION_HOST}:{TRANSLATION_PORT}' | |
| 83 | 79 | RERANKER_SERVICE_URL = os.getenv('RERANKER_SERVICE_URL') or f'http://{RERANKER_HOST}:{RERANKER_PORT}/rerank' |
| 84 | 80 | |
| 85 | 81 | # Model IDs / paths | ... | ... |
config/services_config.py
| ... | ... | @@ -15,6 +15,7 @@ from pathlib import Path |
| 15 | 15 | from typing import Any, Dict, List, Optional |
| 16 | 16 | |
| 17 | 17 | import yaml |
| 18 | +from translation.settings import TranslationConfig, build_translation_config, get_translation_cache | |
| 18 | 19 | |
| 19 | 20 | |
| 20 | 21 | @dataclass |
| ... | ... | @@ -29,42 +30,6 @@ class ServiceConfig: |
| 29 | 30 | return self.providers.get(p, {}) if isinstance(self.providers, dict) else {} |
| 30 | 31 | |
| 31 | 32 | |
| 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 | - | |
| 68 | 33 | def _load_services_raw(config_path: Optional[Path] = None) -> Dict[str, Any]: |
| 69 | 34 | if config_path is None: |
| 70 | 35 | config_path = Path(__file__).parent / "config.yaml" |
| ... | ... | @@ -94,70 +59,10 @@ def _resolve_provider_name(env_name: str, config_provider: Any, capability: str) |
| 94 | 59 | return str(provider).strip().lower() |
| 95 | 60 | |
| 96 | 61 | |
| 97 | -def _resolve_translation() -> TranslationServiceConfig: | |
| 62 | +def _resolve_translation() -> TranslationConfig: | |
| 98 | 63 | raw = _load_services_raw() |
| 99 | 64 | cfg = raw.get("translation", {}) if isinstance(raw.get("translation"), dict) else {} |
| 100 | - | |
| 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" | |
| 106 | - ) | |
| 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) | |
| 143 | - | |
| 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 | |
| 150 | - | |
| 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 | - ) | |
| 65 | + return build_translation_config(cfg) | |
| 161 | 66 | |
| 162 | 67 | |
| 163 | 68 | def _resolve_embedding() -> ServiceConfig: |
| ... | ... | @@ -237,7 +142,7 @@ def get_embedding_backend_config() -> tuple[str, dict]: |
| 237 | 142 | |
| 238 | 143 | |
| 239 | 144 | @lru_cache(maxsize=1) |
| 240 | -def get_translation_config() -> TranslationServiceConfig: | |
| 145 | +def get_translation_config() -> TranslationConfig: | |
| 241 | 146 | return _resolve_translation() |
| 242 | 147 | |
| 243 | 148 | |
| ... | ... | @@ -252,20 +157,11 @@ def get_rerank_config() -> ServiceConfig: |
| 252 | 157 | |
| 253 | 158 | |
| 254 | 159 | def get_translation_base_url() -> str: |
| 255 | - return get_translation_config().service_url | |
| 160 | + return str(get_translation_config()["service_url"]) | |
| 256 | 161 | |
| 257 | 162 | |
| 258 | 163 | def get_translation_cache_config() -> Dict[str, Any]: |
| 259 | - cache_cfg = get_translation_config().cache | |
| 260 | - return { | |
| 261 | - "enabled": bool(cache_cfg.get("enabled", True)), | |
| 262 | - "key_prefix": str(cache_cfg.get("key_prefix", "trans:v2")), | |
| 263 | - "ttl_seconds": int(cache_cfg.get("ttl_seconds", 360 * 24 * 3600)), | |
| 264 | - "sliding_expiration": bool(cache_cfg.get("sliding_expiration", True)), | |
| 265 | - "key_include_context": bool(cache_cfg.get("key_include_context", True)), | |
| 266 | - "key_include_prompt": bool(cache_cfg.get("key_include_prompt", True)), | |
| 267 | - "key_include_source_lang": bool(cache_cfg.get("key_include_source_lang", True)), | |
| 268 | - } | |
| 164 | + return get_translation_cache(get_translation_config()) | |
| 269 | 165 | |
| 270 | 166 | |
| 271 | 167 | def get_embedding_base_url() -> str: | ... | ... |
docs/DEVELOPER_GUIDE.md
| ... | ... | @@ -43,6 +43,7 @@ |
| 43 | 43 | 浠ヤ笅鏂囨。鐢辨湰鎸囧崡寮曠敤锛屾寜闇娣卞叆锛 |
| 44 | 44 | |
| 45 | 45 | - [QUICKSTART.md](./QUICKSTART.md) 鈥 鐜銆佹湇鍔°佹ā鍧椼佽姹傜ず渚嬶紱搂2鈥撀 鍚熀纭閰嶇疆涓 Provider/妯″潡鎵╁睍 |
| 46 | +- [缈昏瘧妯″潡璇存槑.md](./缈昏瘧妯″潡璇存槑.md) 鈥 translator service銆乧apability 閰嶇疆銆佹湰鍦版ā鍨嬮儴缃蹭笌鎺ュ彛濂戠害 | |
| 46 | 47 | - [绯荤粺璁捐鏂囨。.md](./绯荤粺璁捐鏂囨。.md) 鈥 绱㈠紩缁撴瀯銆佹暟鎹祦銆侀氱敤鍖栬璁 |
| 47 | 48 | - [鎼滅储API瀵规帴鎸囧崡.md](./鎼滅储API瀵规帴鎸囧崡.md) 鈥 鎼滅储/绱㈠紩/绠$悊鎺ュ彛瀹屾暣璇存槑 |
| 48 | 49 | - [QUICKSTART.md](./QUICKSTART.md) 搂1.4鈥.8 鈥 绯荤粺瑕佹眰銆丳ython 鐜銆佸閮ㄦ湇鍔′笌鐢熶骇鍑瘉銆佸簵鍖犳暟鎹簮 |
| ... | ... | @@ -64,7 +65,7 @@ |
| 64 | 65 | |
| 65 | 66 | - **澶氱鎴**锛氬崟濂椾唬鐮佷笌绱㈠紩缁撴瀯锛岄氳繃 `tenant_id` 闅旂鏁版嵁锛涚鎴风骇閰嶇疆锛堝涓昏瑷銆佺储寮曡瑷锛夌敱閰嶇疆涓 tenant_config 鏀寔銆 |
| 66 | 67 | - **鍙厤缃**锛氬瓧娈垫潈閲嶃佹悳绱㈠煙銆佹帓搴忚〃杈惧紡銆佹煡璇㈡敼鍐欍佸姛鑳藉紑鍏崇瓑鐢遍厤缃┍鍔紝閬垮厤纭紪鐮佷笟鍔¢昏緫銆 |
| 67 | -- **鍙墿灞**锛氱炕璇/鍚戦噺/閲嶆帓閲囩敤 Provider + 鍚庣鍙彃鎷旇璁★紝鏂板瀹炵幇鏃堕伒寰崗璁笌閰嶇疆瑙勮寖锛屼笉鐮村潖鐜版湁璋冪敤鏂广 | |
| 68 | +- **鍙墿灞**锛歟mbedding / rerank 閲囩敤 Provider + 鍚庣鍙彃鎷旇璁★紱translation 閲囩敤 translator service + capability backend 璁捐銆傛柊澧炲疄鐜版椂閬靛惊鍗忚涓庨厤缃鑼冿紝涓嶇牬鍧忕幇鏈夎皟鐢ㄦ柟銆 | |
| 68 | 69 | - **涓嶈礋璐**锛氬晢鍝佷富鏁版嵁鍚屾銆佸簵閾洪厤缃啓搴撱佸叏閲/澧為噺璋冨害绛栫暐鐢变笂娓革紙濡 Java 绱㈠紩绋嬪簭锛夎礋璐o紱鏈粨搴撲笓娉ㄢ滃浣曟煡銆佸浣曞缓 doc鈥濄 |
| 69 | 70 | |
| 70 | 71 | --- |
| ... | ... | @@ -109,7 +110,8 @@ query/ # 鏌ヨ瑙f瀽锛氳鑼冨寲銆佹敼鍐欍佺炕璇戙乪mbedding 璋 |
| 109 | 110 | search/ # 鎼滅储鎵ц锛氬璇█鏌ヨ鏋勫缓銆丼earcher銆侀噸鎺掑鎴风銆佸垎鏁拌瀺鍚 |
| 110 | 111 | embeddings/ # 鍚戦噺鍖栵細鏈嶅姟绔紙server锛夈佹枃鏈/鍥惧儚鍚庣銆佸崗璁笌閰嶇疆 |
| 111 | 112 | reranker/ # 閲嶆帓锛氭湇鍔$锛坰erver锛夈佸悗绔紙backends锛夈侀厤缃 |
| 112 | -providers/ # 鑳藉姏鎻愪緵鑰咃細缈昏瘧/鍚戦噺/閲嶆帓鐨勫鎴风鎶借薄涓庡伐鍘 | |
| 113 | +providers/ # 鑳藉姏鎻愪緵鑰咃細鍚戦噺/閲嶆帓鐨勫鎴风鎶借薄涓庡伐鍘 | |
| 114 | +translation/ # 缈昏瘧锛氭湇鍔″鎴风銆佹湇鍔$紪鎺掋佸悗绔疄鐜般佹湰鍦版ā鍨嬫帴鍏 | |
| 113 | 115 | suggestion/ # 寤鸿锛氱储寮曟瀯寤恒佸缓璁绱 |
| 114 | 116 | utils/ # 鍏变韩宸ュ叿锛欵S 瀹㈡埛绔丏B 杩炴帴绛 |
| 115 | 117 | mappings/ # ES 绱㈠紩 mapping 瀹氫箟锛堝 search_products.json锛 |
| ... | ... | @@ -119,7 +121,7 @@ tests/ # 鍗曞厓涓庨泦鎴愭祴璇 |
| 119 | 121 | docs/ # 鏂囨。锛堝惈鏈寚鍗楋級 |
| 120 | 122 | ``` |
| 121 | 123 | |
| 122 | -- **绾﹀畾**锛氫笟鍔¢昏緫鎸夎兘鍔涙斁鍏ュ搴旈《灞傚寘锛涙柊澧炩滆兘鍔涒濇椂浼樺厛鑰冭檻鏄惁灞炰簬鐜版湁鏌愬寘鎴 providers锛岄伩鍏嶉殢鎰忔柊寤洪《灞傚寘瀵艰嚧鍒嗗弶銆 | |
| 124 | +- **绾﹀畾**锛氫笟鍔¢昏緫鎸夎兘鍔涙斁鍏ュ搴旈《灞傚寘锛涙柊澧炩滆兘鍔涒濇椂浼樺厛鑰冭檻鏄惁灞炰簬鐜版湁鏌愬寘銆乣translation/` 鎴 providers锛岄伩鍏嶉殢鎰忔柊寤洪《灞傚寘瀵艰嚧鍒嗗弶銆 | |
| 123 | 125 | |
| 124 | 126 | --- |
| 125 | 127 | |
| ... | ... | @@ -166,7 +168,7 @@ docs/ # 鏂囨。锛堝惈鏈寚鍗楋級 |
| 166 | 168 | |
| 167 | 169 | ### 4.8 providers |
| 168 | 170 | |
| 169 | -- **鑱岃矗**锛氱粺涓鈥滆兘鍔涒濈殑璋冪敤鏂瑰紡銆傚悜閲忋侀噸鎺掍粛鏄爣鍑 provider 宸ュ巶锛涚炕璇戜晶鐨 `create_translation_provider()` 鐜板湪鍥哄畾杩斿洖 translator service client锛岀敱 6006 鏈嶅姟缁熶竴鎵挎帴鍚庣閫夋嫨涓庤矾鐢便 | |
| 171 | +- **鑱岃矗**锛氱粺涓鈥滆兘鍔涒濈殑璋冪敤鏂瑰紡銆傚悜閲忋侀噸鎺掍粛鏄爣鍑 provider 宸ュ巶锛涚炕璇戜晶閫氳繃 `translation.create_translation_client()` 鑾峰彇 translator service client锛岀敱 6006 鏈嶅姟缁熶竴鎵挎帴鍚庣閫夋嫨涓庤矾鐢便 | |
| 170 | 172 | - **鍘熷垯**锛氫笟鍔′唬鐮佸彧渚濊禆璋冪敤鎺ュ彛锛屼笉渚濊禆鍏蜂綋 URL 鎴栨湇鍔″唴鍚庣绫诲瀷锛涚炕璇戣兘鍔涙柊澧炴椂浼樺厛鎵╁睍 `translation/backends/` 涓 `services.translation.capabilities`锛岃屼笉鏄湪涓氬姟渚ф柊澧 provider 鍒嗘敮銆 |
| 171 | 173 | - **璇﹁**锛氭湰鎸囧崡 搂7.2锛沎QUICKSTART.md](./QUICKSTART.md) 搂3銆 |
| 172 | 174 | |
| ... | ... | @@ -197,14 +199,14 @@ docs/ # 鏂囨。锛堝惈鏈寚鍗楋級 |
| 197 | 199 | ### 5.2 閰嶇疆椹卞姩 |
| 198 | 200 | |
| 199 | 201 | - 鎼滅储琛屼负锛堝瓧娈垫潈閲嶃佹悳绱㈠煙銆佹帓搴忋乫unction_score銆侀噸鎺掕瀺鍚堝弬鏁扮瓑锛夋潵鑷 `config/config.yaml`锛岀敱 `ConfigLoader` 鍔犺浇銆 |
| 200 | -- 鑳藉姏璁块棶鏉ヨ嚜 `config.yaml` 鐨 `services` 鍧楀強鐜鍙橀噺锛岀敱 `config/services_config` 瑙f瀽銆 | |
| 202 | +- 鑳藉姏璁块棶鏉ヨ嚜 `config.yaml` 鐨 `services` 鍧楋紝鐢 `config/services_config` 瑙f瀽銆 | |
| 201 | 203 | - 鍏朵腑缈昏瘧鍗曠嫭閲囩敤鈥渟ervice + capabilities鈥濇ā鍨嬶細璋冪敤鏂瑰彧閰 `service_url` / `default_model` / `default_scene`锛屾湇鍔″唴閫氳繃 `capabilities` 鎺у埗鍚敤鍝簺缈昏瘧鑳藉姏銆 |
| 202 | 204 | - 鏂板寮鍏虫垨鍙傛暟鏃讹紝浼樺厛鍦ㄧ幇鏈 config 缁撴瀯涓嬫墿灞曪紝閬垮厤鏂板鏁h惤閰嶇疆鏂囦欢銆 |
| 203 | 205 | |
| 204 | 206 | ### 5.3 鍗曚竴閰嶇疆婧愪笌浼樺厛绾 |
| 205 | 207 | |
| 206 | -- 鍚屼竴绫婚厤缃彧鍦ㄤ竴涓湴鏂瑰畾涔夐粯璁ゅ硷紱瑕嗙洊椤哄簭绾﹀畾涓猴細**鐜鍙橀噺 > config 鏂囦欢**銆 | |
| 207 | -- 鏈嶅姟 URL銆佸悗绔被鍨嬬瓑鍧囧湪 `services.<capability>` 涓嬮厤缃紱鐜鍙橀噺鐢ㄤ簬閮ㄧ讲鎬佽鐩栵紙濡 `TRANSLATION_SERVICE_URL`銆乣TRANSLATION_MODEL`銆乣RERANKER_SERVICE_URL`銆乣RERANK_BACKEND`锛夈 | |
| 208 | +- 鍚屼竴绫婚厤缃彧鍦ㄤ竴涓湴鏂瑰畾涔夐粯璁ゅ硷紱涓氬姟琛屼负浠 `config/config.yaml` 涓哄敮涓鏉ユ簮锛屾晱鎰熶俊鎭笌绔彛绛夐儴缃插彉閲忔斁鍦ㄧ幆澧冨彉閲忋 | |
| 209 | +- 鏈嶅姟 URL銆佸悗绔被鍨嬬瓑鍧囧湪 `services.<capability>` 涓嬮厤缃紱缈昏瘧鐨 `service_url` / `default_model` / `default_scene` 涓嶅啀鎺ュ彈鐜鍙橀噺瑕嗙洊锛岄伩鍏嶅嚭鐜扳滅湅閰嶇疆鍜屽疄闄呰涓轰笉涓鑷粹濄 | |
| 208 | 210 | |
| 209 | 211 | ### 5.4 璋冪敤鏂逛笌瀹炵幇瑙h︼紙Client + Backend锛 |
| 210 | 212 | |
| ... | ... | @@ -232,7 +234,7 @@ docs/ # 鏂囨。锛堝惈鏈寚鍗楋級 |
| 232 | 234 | |
| 233 | 235 | ### 5.8 鍚姩鍒濆鍖栫害鏉 |
| 234 | 236 | |
| 235 | -- 閲嶈祫婧愪笌鍏抽敭渚濊禆锛堝 translator銆乼ext/image encoder锛夊簲鍦ㄦ湇鍔″惎鍔ㄦ湡鍒濆鍖栦竴娆″苟澶嶇敤锛岄伩鍏嶈姹傛湡鎳掑姞杞姐 | |
| 237 | +- translator service 鍦ㄨ繘绋嬪惎鍔ㄦ椂搴斿畬鎴愰厤缃牎楠屽苟棰勭儹榛樿 backend锛涘叾浣欏凡鍚敤 capability 鍙寜棣栨璇锋眰鎳掑姞杞斤紝閬垮厤澶氫釜鏈湴缈昏瘧妯″瀷鍦ㄥ惎鍔ㄩ樁娈典竴娆℃у崰婊℃樉瀛樸 | |
| 236 | 238 | - 鑻ラ厤缃0鏄庡惎鐢ㄦ煇鑳藉姏锛堜緥濡 GPU 鍚庣锛夛紝浣嗚繍琛岃祫婧愪笉婊¤冻锛屽簲鐩存帴鍚姩澶辫触锛屼笉鑷姩闄嶇骇涓哄叾瀹冨悗绔 |
| 237 | 239 | |
| 238 | 240 | ### 5.9 鐜闅旂 |
| ... | ... | @@ -276,21 +278,23 @@ services: |
| 276 | 278 | default_scene: "general" |
| 277 | 279 | timeout_sec: 10.0 |
| 278 | 280 | 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 } | |
| 281 | + llm: { enabled: true, backend: "llm", model: "qwen-flash", base_url: "https://dashscope-us.aliyuncs.com/compatible-mode/v1", timeout_sec: 30.0 } | |
| 282 | + qwen-mt: { enabled: true, backend: "qwen_mt", model: "qwen-mt-flash", base_url: "https://dashscope-us.aliyuncs.com/compatible-mode/v1", timeout_sec: 10.0, use_cache: true } | |
| 283 | + deepl: { enabled: false, backend: "deepl", api_url: "https://api.deepl.com/v2/translate", timeout_sec: 10.0 } | |
| 282 | 284 | ``` |
| 283 | 285 | |
| 284 | 286 | - **provider**锛氳皟鐢ㄦ柟濡備綍璁块棶锛堝 HTTP锛夈 |
| 285 | 287 | - **backend / backends**锛氬綋鑳藉姏鐢辨湰浠撳簱鍐呮湇鍔℃彁渚涙椂锛岃鏈嶅姟鍔犺浇鍝釜鍚庣鍙婂弬鏁般 |
| 286 | 288 | - **translation.service_url**锛氫笟鍔′晶缁熶竴璋冪敤鐨勭炕璇戞湇鍔″湴鍧銆 |
| 287 | 289 | - **translation.capabilities**锛氱炕璇戞湇鍔″唴閮ㄥ彲鍚敤鐨勮兘鍔涙敞鍐岃〃銆 |
| 290 | +- **translation 鍐呴儴闈欐佽鍒**锛歴cene 闆嗗悎銆佽瑷鐮佹槧灏勩丩LM prompt 妯℃澘銆佹湰鍦版ā鍨嬫柟鍚戠害鏉熺粺涓浣嶄簬 `translation/`锛屼笉鏄閮 YAML 閰嶇疆銆 | |
| 288 | 291 | - 瑙f瀽鍏ュ彛锛歚config/services_config.py` 鐨 `get_*_config()` 鍙 `get_*_base_url()` / `get_rerank_service_url()` 绛夈 |
| 289 | 292 | |
| 290 | 293 | ### 6.3 鐜鍙橀噺锛堝父鐢級 |
| 291 | 294 | |
| 292 | -- 鑳藉姏 URL锛歚TRANSLATION_SERVICE_URL`銆乣EMBEDDING_SERVICE_URL`銆乣RERANKER_SERVICE_URL` | |
| 293 | -- 鑳藉姏閫夋嫨锛歚TRANSLATION_MODEL`銆乣TRANSLATION_SCENE`銆乣EMBEDDING_PROVIDER`銆乣EMBEDDING_BACKEND`銆乣RERANK_PROVIDER`銆乣RERANK_BACKEND` | |
| 295 | +- 鑳藉姏 URL锛歚EMBEDDING_SERVICE_URL`銆乣RERANKER_SERVICE_URL` | |
| 296 | +- 鑳藉姏閫夋嫨锛歚EMBEDDING_PROVIDER`銆乣EMBEDDING_BACKEND`銆乣RERANK_PROVIDER`銆乣RERANK_BACKEND` | |
| 297 | +- 缈昏瘧鏈嶅姟琛屼负锛氱粺涓鏌ョ湅 `config/config.yaml -> services.translation` | |
| 294 | 298 | - 鐜涓庣储寮曪細`ES_HOST`銆乣ES_INDEX_NAMESPACE`銆乣RUNTIME_ENV`銆丏B 涓 Redis 绛 |
| 295 | 299 | |
| 296 | 300 | 璇﹁ [QUICKSTART.md](./QUICKSTART.md) 搂1.6锛.env 涓庣敓浜у嚟璇侊級銆乕Usage-Guide.md](./Usage-Guide.md)銆 |
| ... | ... | @@ -301,7 +305,8 @@ services: |
| 301 | 305 | |
| 302 | 306 | ### 7.1 浣曟椂鐪嬫墿灞曡鑼 |
| 303 | 307 | |
| 304 | -- 鏂板鎴栨浛鎹**缈昏瘧/鍚戦噺/閲嶆帓**鐨勮皟鐢ㄦ柟寮忥紙濡傛柊鐨 HTTP 瀹㈡埛绔乬RPC锛夛細瑙佹湰鎸囧崡 搂7.2銆乕QUICKSTART.md](./QUICKSTART.md) 搂3銆 | |
| 308 | +- 鏂板鎴栨浛鎹**鍚戦噺/閲嶆帓**鐨勮皟鐢ㄦ柟寮忥紙濡傛柊鐨 HTTP 瀹㈡埛绔乬RPC锛夛細瑙佹湰鎸囧崡 搂7.2銆乕QUICKSTART.md](./QUICKSTART.md) 搂3銆 | |
| 309 | +- 鏂板缈昏瘧鑳藉姏锛堝鏂颁簯绔ā鍨嬫垨鏈湴妯″瀷锛夛細瑙佹湰鎸囧崡 搂7.2 涓殑 translation 鐗逛緥璇存槑銆 | |
| 305 | 310 | - 鏂板鎴栨浛鎹**鍚戦噺/閲嶆帓**鐨勬帹鐞嗗疄鐜帮紙濡傛柊妯″瀷銆乿LLM锛夛細瑙佹湰鎸囧崡 搂7.3鈥撀.6銆 |
| 306 | 311 | |
| 307 | 312 | ### 7.2 鏂板 Provider锛堣皟鐢ㄦ柟寮忥級 |
| ... | ... | @@ -316,7 +321,7 @@ services: |
| 316 | 321 | 1. 鍦 `translation/backends/` 涓疄鐜版柊 backend銆 |
| 317 | 322 | 2. 鍦 `translation/service.py` 涓敞鍐屽伐鍘傘 |
| 318 | 323 | 3. 鍦 `services.translation.capabilities.<name>` 涓嬪鍔犻厤缃紝骞剁敤 `enabled` 鎺у埗鏄惁鍚敤銆 |
| 319 | -4. 涓氬姟璋冪敤鏂逛繚鎸佷笉鍙橈紝浠嶅彧閫氳繃 `create_translation_provider()` 璋 6006銆 | |
| 324 | +4. 涓氬姟璋冪敤鏂逛繚鎸佷笉鍙橈紝浠嶅彧閫氳繃 `create_translation_client()` 璋 6006銆 | |
| 320 | 325 | |
| 321 | 326 | ### 7.3 鏂板 Backend锛堟帹鐞嗗疄鐜帮級 |
| 322 | 327 | |
| ... | ... | @@ -331,7 +336,7 @@ services: |
| 331 | 336 | ### 7.4 绂佹鍋氭硶 |
| 332 | 337 | |
| 333 | 338 | - 鍦ㄤ笟鍔′唬鐮佷腑纭紪鐮佹湇鍔 URL 鎴栧悗绔被鍨嬨 |
| 334 | -- 鏂板鑳藉姏鏃跺鍒朵竴濂楃嫭绔嬮厤缃綋绯绘垨鏂伴《灞傚寘锛岃屼笉绾冲叆 `services` 涓 providers/backends銆 | |
| 339 | +- 鏂板鑳藉姏鏃跺鍒朵竴濂楃嫭绔嬮厤缃綋绯绘垨鏂伴《灞傚寘锛岃屼笉绾冲叆 `services` 涓 providers/backends锛泃ranslation 涔熷繀椤荤撼鍏 `services.translation.capabilities` 涓 `translation/backends/`銆 | |
| 335 | 340 | - 鏂板鍚庣鏃剁牬鍧忕幇鏈夊崗璁紙濡備慨鏀硅繑鍥為暱搴︺侀『搴忔垨 meta 绾﹀畾锛夈 |
| 336 | 341 | |
| 337 | 342 | ### 7.5 閲嶆帓涓庡悜閲忓寲鍗忚涓庨厤缃熸煡 |
| ... | ... | @@ -404,7 +409,7 @@ services: |
| 404 | 409 | |
| 405 | 410 | - [ ] 鏂伴昏緫鏀惧湪鍚堥傜殑鐜版湁鍖呬腑锛屾湭闅忔剰鏂板缓涓庣幇鏈夎兘鍔涘钩琛岀殑椤跺眰鍖呫 |
| 406 | 411 | - [ ] 鏈湪涓氬姟浠g爜涓‖缂栫爜鏈嶅姟 URL銆佸悗绔被鍨嬫垨绉熸埛 ID銆 |
| 407 | -- [ ] 璋冪敤澶栭儴鑳藉姏锛堢炕璇/鍚戦噺/閲嶆帓锛夋椂閫氳繃 providers 宸ュ巶鑾峰彇瀹炰緥锛岄厤缃潵鑷 `services_config`銆 | |
| 412 | +- [ ] 璋冪敤澶栭儴鑳藉姏鏃堕伒寰粺涓鍏ュ彛锛歵ranslation 浣跨敤 `translation.create_translation_client()`锛宔mbedding / rerank 浣跨敤 providers 宸ュ巶锛岄厤缃潵鑷 `services_config`銆 | |
| 408 | 413 | |
| 409 | 414 | ### 9.2 閰嶇疆涓庢墿灞 |
| 410 | 415 | |
| ... | ... | @@ -441,6 +446,7 @@ services: |
| 441 | 446 | | Provider 涓庡熀纭閰嶇疆銆佹ā鍧楁墿灞曪紙鍗忚涓庡悗绔級 | [QUICKSTART.md](./QUICKSTART.md) 搂2鈥撀佹湰鎸囧崡 搂7 | |
| 442 | 447 | | 绱㈠紩缁撴瀯銆佹暟鎹祦銆侀氱敤鍖栬璁 | [绯荤粺璁捐鏂囨。.md](./绯荤粺璁捐鏂囨。.md) | |
| 443 | 448 | | 鎼滅储/绱㈠紩 API 瀹屾暣璇存槑 | [鎼滅储API瀵规帴鎸囧崡.md](./鎼滅储API瀵规帴鎸囧崡.md) | |
| 449 | +| 缈昏瘧妯″潡涓庢湰鍦版ā鍨 | [缈昏瘧妯″潡璇存槑.md](./缈昏瘧妯″潡璇存槑.md) | | |
| 444 | 450 | | 鎼滅储 API 鍙傛暟閫熸煡 | [鎼滅储API閫熸煡琛.md](./鎼滅储API閫熸煡琛.md) | |
| 445 | 451 | | 棣栨閮ㄧ讲銆佹柊鏈哄櫒鐜銆佺敓浜у嚟璇 | [QUICKSTART.md](./QUICKSTART.md) 搂1.4鈥.8 | |
| 446 | 452 | | 杩愮淮銆佹棩蹇椼佸鐜銆佹晠闅 | [Usage-Guide.md](./Usage-Guide.md) | | ... | ... |
docs/QUICKSTART.md
| ... | ... | @@ -162,13 +162,19 @@ curl -X POST http://localhost:6005/embed/image \ |
| 162 | 162 | #### Translator 服务(6006) |
| 163 | 163 | |
| 164 | 164 | ```bash |
| 165 | +./scripts/setup_translator_venv.sh | |
| 166 | +./.venv-translator/bin/python scripts/download_translation_models.py --all-local # 如需本地模型 | |
| 165 | 167 | ./scripts/start_translator.sh |
| 166 | 168 | |
| 167 | 169 | curl -X POST http://localhost:6006/translate \ |
| 168 | 170 | -H "Content-Type: application/json" \ |
| 169 | - -d '{"text":"商品名称","target_lang":"en","source_lang":"zh"}' | |
| 171 | + -d '{"text":"商品名称","target_lang":"en","source_lang":"zh","model":"qwen-mt","scene":"sku_name"}' | |
| 170 | 172 | ``` |
| 171 | 173 | |
| 174 | +说明: | |
| 175 | +- translator service 是翻译统一入口,业务侧不再直接选择翻译 provider。 | |
| 176 | +- 本地模型默认关闭;需先在 `config/config.yaml -> services.translation.capabilities` 中启用,再通过 `model` 指定。 | |
| 177 | + | |
| 172 | 178 | #### Reranker 服务(6007) |
| 173 | 179 | |
| 174 | 180 | ```bash |
| ... | ... | @@ -372,25 +378,25 @@ saas-search 以 MySQL 中的店匠标准表为权威数据源: |
| 372 | 378 | |--------|------| |
| 373 | 379 | | 索引结构(mapping) | 修改 `mappings/search_products.json` → `./scripts/create_tenant_index.sh <tenant_id>` → 重新导入 | |
| 374 | 380 | | 搜索字段/权重/排序/重排 | 修改 `config/config.yaml` 对应块 | |
| 375 | -| provider 与服务 URL | 修改 `config/config.yaml` 的 `services` 块,或用环境变量覆盖 | | |
| 381 | +| provider 与服务 URL | 修改 `config/config.yaml` 的 `services` 块;translation 的 `service_url/default_model/default_scene` 只认 YAML,embedding/rerank 仍可按需用环境变量覆盖 | | |
| 376 | 382 | |
| 377 | 383 | --- |
| 378 | 384 | |
| 379 | -## 3. Provider 架构 | |
| 385 | +## 3. 能力接入架构 | |
| 380 | 386 | |
| 381 | 387 | 目标:调用方稳定、配置可切换、单一配置源。 |
| 382 | 388 | |
| 383 | 389 | ### 3.1 当前代码结构 |
| 384 | 390 | |
| 385 | -- 模块:`providers/` | |
| 386 | -- 工厂:`create_translation_provider()`、`create_embedding_provider()`、`create_rerank_provider()` | |
| 391 | +- 模块:`providers/` + `translation/` | |
| 392 | +- 工厂:`translation.create_translation_client()`、`create_embedding_provider()`、`create_rerank_provider()` | |
| 387 | 393 | - 配置解析:`config/services_config.py` |
| 388 | 394 | |
| 389 | -| 能力 | Provider 实现 | 调用方 | | |
| 390 | -|------|---------------|--------| | |
| 391 | -| translation | `providers/translation.py`(direct/http) | `query/query_parser.py`、索引链路 | | |
| 392 | -| embedding | `providers/embedding.py`(http) | 文本/图像编码调用 | | |
| 393 | -| rerank | `providers/rerank.py`(http) | `search/rerank_client.py` | | |
| 395 | +| 能力 | 调用入口 | 服务内实现 | | |
| 396 | +|------|----------|------------| | |
| 397 | +| translation | `translation/client.py` | `translation/service.py` + `translation/backends/` | | |
| 398 | +| embedding | `providers/embedding.py`(http) | embedding 服务内 backend | | |
| 399 | +| rerank | `providers/rerank.py`(http) | reranker 服务内 backend | | |
| 394 | 400 | |
| 395 | 401 | ### 3.2 配置与覆盖 |
| 396 | 402 | |
| ... | ... | @@ -399,10 +405,17 @@ saas-search 以 MySQL 中的店匠标准表为权威数据源: |
| 399 | 405 | ```yaml |
| 400 | 406 | services: |
| 401 | 407 | translation: |
| 402 | - provider: "direct" | |
| 403 | - providers: | |
| 404 | - direct: { model: "qwen" } | |
| 405 | - http: { base_url: "http://127.0.0.1:6006", model: "qwen", timeout_sec: 10.0 } | |
| 408 | + service_url: "http://127.0.0.1:6006" | |
| 409 | + default_model: "llm" | |
| 410 | + default_scene: "general" | |
| 411 | + timeout_sec: 10.0 | |
| 412 | + capabilities: | |
| 413 | + qwen-mt: { enabled: true, backend: "qwen_mt", model: "qwen-mt-flash", base_url: "https://dashscope-us.aliyuncs.com/compatible-mode/v1", timeout_sec: 10.0, use_cache: true } | |
| 414 | + llm: { enabled: true, backend: "llm", model: "qwen-flash", base_url: "https://dashscope-us.aliyuncs.com/compatible-mode/v1", timeout_sec: 30.0 } | |
| 415 | + deepl: { enabled: false, backend: "deepl", api_url: "https://api.deepl.com/v2/translate", timeout_sec: 10.0 } | |
| 416 | + nllb-200-distilled-600m: { enabled: false, backend: "local_nllb", model_id: "facebook/nllb-200-distilled-600M" } | |
| 417 | + opus-mt-zh-en: { enabled: false, backend: "local_marian", model_id: "Helsinki-NLP/opus-mt-zh-en" } | |
| 418 | + opus-mt-en-zh: { enabled: false, backend: "local_marian", model_id: "Helsinki-NLP/opus-mt-en-zh" } | |
| 406 | 419 | embedding: |
| 407 | 420 | provider: "http" |
| 408 | 421 | backend: "tei" |
| ... | ... | @@ -419,8 +432,6 @@ services: |
| 419 | 432 | |
| 420 | 433 | 环境变量覆盖(优先级更高): |
| 421 | 434 | |
| 422 | -- `TRANSLATION_PROVIDER` | |
| 423 | -- `TRANSLATION_SERVICE_URL` | |
| 424 | 435 | - `EMBEDDING_SERVICE_URL` |
| 425 | 436 | - `EMBEDDING_BACKEND` |
| 426 | 437 | - `TEI_BASE_URL` |
| ... | ... | @@ -429,11 +440,19 @@ services: |
| 429 | 440 | - `RERANK_DASHSCOPE_API_KEY_CN` / `RERANK_DASHSCOPE_API_KEY_US`(`dashscope_rerank` 后端鉴权) |
| 430 | 441 | - `RERANK_DASHSCOPE_ENDPOINT`(`dashscope_rerank` 地域 endpoint 覆盖) |
| 431 | 442 | |
| 432 | -### 3.3 新增 provider 的最小步骤 | |
| 443 | +### 3.3 新增接入能力的最小步骤 | |
| 433 | 444 | |
| 434 | -1. 在 `providers/<capability>.py` 实现 provider 类 | |
| 435 | -2. 在 `create_*_provider()` 注册 | |
| 436 | -3. 在 `config/config.yaml` 的 `services.<capability>.providers` 新增配置 | |
| 445 | +1. translation 新增能力: | |
| 446 | + 在 `translation/backends/` 实现 backend,在 `translation/service.py` 注册,并在 `services.translation.capabilities` 增加配置。 | |
| 447 | +2. embedding / rerank 新增调用方式: | |
| 448 | + 在 `providers/<capability>.py` 实现 provider 类,并在 `create_*_provider()` 注册。 | |
| 449 | +3. embedding / rerank 新增服务内模型: | |
| 450 | + 在对应服务的 `backends/` 下实现并注册,在 `services.<capability>.backends` 新增配置。 | |
| 451 | + | |
| 452 | +说明: | |
| 453 | +- translation 的 scene 规则、语言码映射、prompt 模板、模型方向约束位于 `translation/` 内部,不再放到 `config/`。 | |
| 454 | +- 翻译公共接口只暴露 `model + scene`,不暴露 `prompt`。 | |
| 455 | +- translation 的 `service_url`、`default_model`、`default_scene` 来自 `config/config.yaml -> services.translation`,不再由环境变量静默覆盖。 | |
| 437 | 456 | |
| 438 | 457 | --- |
| 439 | 458 | ... | ... |
docs/TODO.txt
| ... | ... | @@ -86,52 +86,21 @@ translator的设计 : |
| 86 | 86 | |
| 87 | 87 | QueryParser 里面 并不是调用的6006,目前是把6006做了一个provider,然后translate的总体配置又有6006的baseurl,很混乱。 |
| 88 | 88 | |
| 89 | -config.yaml 里面的 翻译的配置 不是“6006 专用配置”,而是搜索服务的 | |
| 90 | -6006本来之前是做一个provider。 | |
| 91 | -结果后面改造成了综合体,但是还没改完,改到一半发现之前的实现跟我的设计或者想法有偏差。 | |
| 89 | +翻译模块重构已完成。以下旧结论已失效,不再适用: | |
| 92 | 90 | |
| 91 | +- 业务侧不再把 translation 当 provider 选择。 | |
| 92 | +- `QueryParser` / indexer 统一通过 `translation.create_translation_client()` 调用 6006 translator service。 | |
| 93 | +- 翻译配置统一为 `services.translation`: | |
| 94 | + - 外部配置只保留部署相关项,如 `service_url`、`default_model`、`default_scene`、各 capability 的 `backend/base_url/api_url/model_dir` 等。 | |
| 95 | + - scene 规则、语言码映射、LLM prompt 模板、本地模型方向约束统一收口在 `translation/` 内部。 | |
| 96 | +- 外部接口统一使用 `model + scene`,不再对外暴露 `prompt`。 | |
| 93 | 97 | |
| 94 | -需要继续改完!!!!!!!! | |
| 98 | +以以下文档为准: | |
| 95 | 99 | |
| 96 | - | |
| 97 | -- `config.yaml` **不是“6006 专用配置”**,而是整个系统的 **统一 services 配置**,由 `config/services_config.py` 读取,**搜索 API 进程和翻译服务进程都会用到它**。 | |
| 98 | -- 关键决定行为的是这一行: | |
| 99 | - | |
| 100 | -```yaml | |
| 101 | -translation: | |
| 102 | - provider: "llm" | |
| 103 | -``` | |
| 104 | - | |
| 105 | -在当前配置下: | |
| 106 | - | |
| 107 | -- 搜索 API 进程里,`QueryParser` 初始化翻译器时走的是: | |
| 108 | - | |
| 109 | -```python | |
| 110 | -create_translation_provider(...) # provider == "llm" | |
| 111 | -``` | |
| 112 | - | |
| 113 | -进而返回的是 `LLMTranslatorProvider`(本进程内调用),**不会走 `base_url`,也不会走 6006 端口**。 | |
| 114 | -- `base_url: "http://127.0.0.1:6006"` 只在 `provider: "http"` / `"service"` 时被 `HttpTranslationProvider` 使用;在 `provider: "llm"` 时,这个字段对 `QueryParser` 是完全被忽略的。 | |
| 115 | - | |
| 116 | -所以现在的实际情况是: | |
| 117 | - | |
| 118 | -- **QueryParser 中的翻译是“本进程直连 LLM API”**,所以日志在搜索后端自己的日志文件里。 | |
| 119 | -- 如果你希望「QueryParser 永远通过 6006 端口的翻译服务」,需要把 provider 改成 HTTP: | |
| 120 | - | |
| 121 | -```yaml | |
| 122 | -translation: | |
| 123 | - provider: "http" # ← 改成 http 或 service | |
| 124 | - cache: ... | |
| 125 | - providers: | |
| 126 | - http: | |
| 127 | - base_url: "http://127.0.0.1:6006" | |
| 128 | - model: "llm" # 或 "qwen-mt-flush",看你想用哪个 | |
| 129 | - timeout_sec: 10.0 | |
| 130 | - llm:. | |
| 131 | - model: "qwen-flash" # 留给翻译服务自身内部使用 | |
| 132 | - qwen-mt: ... | |
| 133 | - deepl: ... | |
| 134 | -``` | |
| 100 | +- `docs/翻译模块说明.md` | |
| 101 | +- `docs/DEVELOPER_GUIDE.md` | |
| 102 | +- `docs/QUICKSTART.md` | |
| 103 | +- `docs/搜索API对接指南.md` | |
| 135 | 104 | |
| 136 | 105 | |
| 137 | 106 | |
| ... | ... | @@ -259,4 +228,3 @@ https://cloud.tencent.com/document/product/1729/113395#4.-.E7.A4.BA.E4.BE.8B |
| 259 | 228 | |
| 260 | 229 | 登录 百炼美国地域控制台:https://modelstudio.console.aliyun.com/us-east-1?spm=5176.2020520104.0.0.6b383a98WjpXff |
| 261 | 230 | 在 API Key 管理 中创建或复制一个适用于美国地域的 Key |
| 262 | - | ... | ... |
docs/工作总结-微服务性能优化与架构.md
| ... | ... | @@ -84,11 +84,10 @@ instruction: "Given a shopping query, rank product titles by relevance" |
| 84 | 84 | **背景**:原使用 DeepL,后迁移至 **qwen-mt**(如 `qwen-mt-flash`)。qwen-mt 云端限速约 **RPM=60(每分钟 60 请求)**,此前未做大商品量压测,未暴露问题;高并发索引或查询场景下易触限。 |
| 85 | 85 | |
| 86 | 86 | **当前方案**: |
| 87 | -- **迁移至 qwen-flash**:在配置中将翻译改为 **LLM provider + qwen-flash 模型**,由 DashScope 兼容 API 调用,可配置化切换。 | |
| 88 | -- **可配置化(具体配置)**: | |
| 89 | - - **入口**:`config/config.yaml` → `services.translation`;`provider: "llm"` 时使用 `providers.llm`,`model: "qwen-flash"`,`timeout_sec: 30`,`base_url` 可选(为空则用 `DASHSCOPE_BASE_URL`);环境变量 `DASHSCOPE_API_KEY` 注入 Key。 | |
| 90 | - - **Provider 取值**:`provider` 可为 `http`(走翻译服务 6006)、`qwen-mt`(直连 qwen-mt-flush 等)、`deepl`(DeepL API)、`llm`(对话模型 qwen-flash 等);工厂函数 `providers/translation.py` 的 `create_translation_provider(query_config)` 根据 `get_translation_config()` 解析结果返回对应实现。 | |
| 91 | - - **调用位置**:QueryParser(`query/query_parser.py`)与 Indexer(`indexer/incremental_service.py`、`indexer/indexing_utils.py`)均通过 `create_translation_provider(...)` 获取实例,不写死 URL 或模型名。 | |
| 87 | +- **统一 translator service**:业务侧统一走 6006,按 `model + scene` 选择能力,不再存在翻译 provider 分支。 | |
| 88 | +- **配置入口**:`config/config.yaml` → `services.translation`,显式声明 `service_url`、`default_model`、`default_scene`、各 capability 的 `backend`、`base_url/api_url`、timeout 与本地模型运行参数。 | |
| 89 | +- **内部规则收口**:scene 集合、语言码映射、LLM prompt 模板、本地模型方向约束统一放在 `translation/` 内部,不再散落在 `config/`、`query/` 等位置。 | |
| 90 | +- **调用位置**:QueryParser 与 Indexer 均通过 `translation.create_translation_client()` 获取客户端,不写死 URL 或模型名。 | |
| 92 | 91 | - **缓存**:`services.translation.cache` 支持 `key_prefix: "trans:v2"`、`ttl_seconds`、`sliding_expiration` 等,翻译结果写 Redis,减轻重复请求对限速的影响。 |
| 93 | 92 | - **场景支撑**:在线索引(indexer)与 query 请求(QueryParser)共用同一套 provider 配置;可按环境或租户通过修改 `config.yaml` 或环境变量切换 provider/model。 |
| 94 | 93 | - **待配合**:**金伟侧对索引侧翻译调用做流量控制**(限流/排队/批量聚合),避免索引高峰打满 qwen 限速,影响在线 query 翻译。 |
| ... | ... | @@ -113,14 +112,15 @@ instruction: "Given a shopping query, rank product titles by relevance" |
| 113 | 112 | |
| 114 | 113 | ## 二、架构 |
| 115 | 114 | |
| 116 | -### 1. Provider 与动态选择翻译 | |
| 115 | +### 1. Translator Service 与动态选择翻译 | |
| 117 | 116 | |
| 118 | -- **设计**:参考 `docs/系统设计文档.md`、`docs/DEVELOPER_GUIDE.md`,翻译/向量/重排均采用 **Provider + Backend** 解耦;配置单一来源为 `config/config.yaml` 的 `services` 块,环境变量可覆盖。 | |
| 117 | +- **设计**:翻译已从 provider 架构中独立出来,采用 **一个 translator service + 多个 capability backend**;配置单一来源为 `config/config.yaml` 的 `services.translation` 块,`service_url` / `default_model` / `default_scene` 不再接受环境变量静默覆盖。 | |
| 119 | 118 | - **翻译(具体实现)**: |
| 120 | - - **工厂**:`providers/translation.py` 的 `create_translation_provider(query_config)`;内部调用 `config/services_config.get_translation_config()` 得到 `provider` 与 `providers.<name>` 参数。 | |
| 121 | - - **分支**:`provider in ("qwen-mt", "direct", "local", "inprocess")` → 使用 `query/qwen_mt_translate.py` 的 `Translator`(model 如 qwen-mt-flush);`provider == "http"` 或 `"service"` → `HttpTranslationProvider`(base_url 为翻译服务 6006,model 如 qwen);`provider == "llm"` → `query/llm_translate.py` 的 `LLMTranslatorProvider`(model 如 qwen-flash,base_url 可选);`provider == "deepl"` → `query/deepl_provider.py` 的 `DeepLProvider`。 | |
| 122 | - - **调用方**:`query/query_parser.py`(搜索前翻译)、`indexer/incremental_service.py`、`indexer/indexing_utils.py`(索引时翻译)均通过上述工厂获取实例,不写死 URL 或模型名。 | |
| 123 | -- **效果**:仅改 `config.yaml` 的 `services.translation.provider` 及对应 `providers.<name>` 即可切换 DeepL、qwen-mt、qwen-flash(llm)、HTTP 翻译服务等。 | |
| 119 | + - **业务入口**:`translation.create_translation_client()` | |
| 120 | + - **服务编排**:`translation/service.py` | |
| 121 | + - **后端实现**:`translation/backends/qwen_mt.py`、`translation/backends/llm.py`、`translation/backends/deepl.py`、`translation/backends/local_seq2seq.py` | |
| 122 | + - **调用方**:`query/query_parser.py`、`indexer/incremental_service.py`、`indexer/indexing_utils.py` | |
| 123 | +- **效果**:仅改 `services.translation.default_model` 或启用的 capability,即可切换云端/本地翻译能力;调用方始终只连 6006。 | |
| 124 | 124 | |
| 125 | 125 | ### 2. 服务的监控与拉起机制 |
| 126 | 126 | ... | ... |
docs/搜索API对接指南.md
| ... | ... | @@ -159,7 +159,7 @@ curl -X POST "http://43.166.252.75:6002/search/" \ |
| 159 | 159 | |------|------|------|------| |
| 160 | 160 | | 向量服务 | 6005 | `POST /embed/text` | 文本向量化 | |
| 161 | 161 | | 向量服务 | 6005 | `POST /embed/image` | 图片向量化 | |
| 162 | -| 翻译服务 | 6006 | `POST /translate` | 文本翻译(Qwen/DeepL) | | |
| 162 | +| 翻译服务 | 6006 | `POST /translate` | 文本翻译(支持 qwen-mt / llm / deepl / 本地模型) | | |
| 163 | 163 | | 重排服务 | 6007 | `POST /rerank` | 检索结果重排 | |
| 164 | 164 | | 内容理解(Indexer 内) | 6004 | `POST /indexer/enrich-content` | 根据商品标题生成 qanchors、tags 等,供 indexer 微服务组合方式使用 | |
| 165 | 165 | |
| ... | ... | @@ -1650,7 +1650,7 @@ curl -X POST "http://localhost:6004/indexer/enrich-content" \ |
| 1650 | 1650 | | 服务 | 默认端口 | Base URL | 说明 | |
| 1651 | 1651 | |------|----------|----------|------| |
| 1652 | 1652 | | 向量服务 | 6005 | `http://localhost:6005` | 文本/图片向量化,用于语义搜索与以图搜图 | |
| 1653 | -| 翻译服务 | 6006 | `http://localhost:6006` | 多语言翻译(Qwen/DeepL) | | |
| 1653 | +| 翻译服务 | 6006 | `http://localhost:6006` | 多语言翻译(云端与本地模型统一入口) | | |
| 1654 | 1654 | | 重排服务 | 6007 | `http://localhost:6007` | 对检索结果进行二次排序 | |
| 1655 | 1655 | |
| 1656 | 1656 | 生产环境请将 `localhost` 替换为实际服务地址。 |
| ... | ... | @@ -1801,12 +1801,12 @@ curl "http://localhost:6007/health" |
| 1801 | 1801 | |
| 1802 | 1802 | ### 7.3 翻译服务(Translation) |
| 1803 | 1803 | |
| 1804 | -- **Base URL**: `http://localhost:6006`(可通过 `TRANSLATION_SERVICE_URL` 覆盖) | |
| 1804 | +- **Base URL**: `http://localhost:6006`(以 `config/config.yaml -> services.translation.service_url` 为准) | |
| 1805 | 1805 | - **启动**: `./scripts/start_translator.sh` |
| 1806 | 1806 | |
| 1807 | 1807 | #### 7.3.1 `POST /translate` — 文本翻译 |
| 1808 | 1808 | |
| 1809 | -支持 Qwen(默认)与 DeepL 模型,适用于商品名称、描述等电商场景。 | |
| 1809 | +支持 translator service 内所有已启用 capability,适用于商品名称、描述、query 等电商场景。当前可配置能力包括 `qwen-mt`、`llm`、`deepl` 以及本地模型 `nllb-200-distilled-600m`、`opus-mt-zh-en`、`opus-mt-en-zh`。 | |
| 1810 | 1810 | |
| 1811 | 1811 | **请求体**(支持单条字符串或字符串列表): |
| 1812 | 1812 | ```json |
| ... | ... | @@ -1814,8 +1814,8 @@ curl "http://localhost:6007/health" |
| 1814 | 1814 | "text": "商品名称", |
| 1815 | 1815 | "target_lang": "en", |
| 1816 | 1816 | "source_lang": "zh", |
| 1817 | - "model": "qwen", | |
| 1818 | - "context": "sku_name" | |
| 1817 | + "model": "qwen-mt", | |
| 1818 | + "scene": "sku_name" | |
| 1819 | 1819 | } |
| 1820 | 1820 | ``` |
| 1821 | 1821 | |
| ... | ... | @@ -1825,8 +1825,8 @@ curl "http://localhost:6007/health" |
| 1825 | 1825 | "text": ["商品名称1", "商品名称2"], |
| 1826 | 1826 | "target_lang": "en", |
| 1827 | 1827 | "source_lang": "zh", |
| 1828 | - "model": "qwen", | |
| 1829 | - "context": "sku_name" | |
| 1828 | + "model": "qwen-mt", | |
| 1829 | + "scene": "sku_name" | |
| 1830 | 1830 | } |
| 1831 | 1831 | ``` |
| 1832 | 1832 | |
| ... | ... | @@ -1834,9 +1834,13 @@ curl "http://localhost:6007/health" |
| 1834 | 1834 | |------|------|------|------| |
| 1835 | 1835 | | `text` | string \| string[] | Y | 待翻译文本,既支持单条字符串,也支持字符串列表(批量翻译) | |
| 1836 | 1836 | | `target_lang` | string | Y | 目标语言:`zh`、`en`、`ru` 等 | |
| 1837 | -| `source_lang` | string | N | 源语言,不传则自动检测 | | |
| 1838 | -| `model` | string | N | `qwen`(默认)、`deepl` 或 `llm` | | |
| 1839 | -| `context` | string | N | 翻译场景参数:商品标题翻译使用 `sku_name`,搜索请求中的 query 翻译使用 `ecommerce_search_query`,其它通用场景可不传或使用 `general` | | |
| 1837 | +| `source_lang` | string | N | 源语言。云端模型可不传;`nllb-200-distilled-600m` 建议显式传入 | | |
| 1838 | +| `model` | string | N | 已启用 capability 名称,如 `qwen-mt`、`llm`、`deepl`、`nllb-200-distilled-600m`、`opus-mt-zh-en`、`opus-mt-en-zh` | | |
| 1839 | +| `scene` | string | N | 翻译场景参数,与 `model` 配套使用;当前标准值为 `sku_name`、`ecommerce_search_query`、`general` | | |
| 1840 | + | |
| 1841 | +说明: | |
| 1842 | +- 外部接口不接受 `prompt`;LLM prompt 由服务端按 `scene` 自动生成。 | |
| 1843 | +- 传入未定义的 `scene` 或未启用的 `model` 会返回 `400`。 | |
| 1840 | 1844 | |
| 1841 | 1845 | **响应**: |
| 1842 | 1846 | ```json |
| ... | ... | @@ -1846,7 +1850,8 @@ curl "http://localhost:6007/health" |
| 1846 | 1850 | "source_lang": "zh", |
| 1847 | 1851 | "translated_text": "Product name", |
| 1848 | 1852 | "status": "success", |
| 1849 | - "model": "qwen" | |
| 1853 | + "model": "qwen-mt", | |
| 1854 | + "scene": "sku_name" | |
| 1850 | 1855 | } |
| 1851 | 1856 | ``` |
| 1852 | 1857 | |
| ... | ... | @@ -1858,13 +1863,14 @@ curl "http://localhost:6007/health" |
| 1858 | 1863 | "source_lang": "zh", |
| 1859 | 1864 | "translated_text": ["Product name 1", "Product name 2"], |
| 1860 | 1865 | "status": "success", |
| 1861 | - "model": "qwen" | |
| 1866 | + "model": "qwen-mt", | |
| 1867 | + "scene": "sku_name" | |
| 1862 | 1868 | } |
| 1863 | 1869 | ``` |
| 1864 | 1870 | |
| 1865 | 1871 | > **失败语义(批量)**:当 `text` 为列表时,如果其中某条翻译失败,对应位置返回 `null`(即 `translated_text[i] = null`),并保持数组长度与顺序不变;接口整体仍返回 `status="success"`,用于避免“部分失败”导致整批请求失败。 |
| 1866 | 1872 | |
| 1867 | -> **实现提示(可忽略)**:服务端会尽可能使用底层翻译 provider 的批量能力(若支持),否则自动拆分逐条翻译;无论采用哪种方式,上述批量契约保持一致。 | |
| 1873 | +> **实现提示(可忽略)**:服务端会尽可能使用底层 backend 的批量能力(若支持),否则自动拆分逐条翻译;无论采用哪种方式,上述批量契约保持一致。 | |
| 1868 | 1874 | |
| 1869 | 1875 | **完整 curl 示例**: |
| 1870 | 1876 | |
| ... | ... | @@ -1902,12 +1908,38 @@ curl -X POST "http://localhost:6006/translate" \ |
| 1902 | 1908 | }' |
| 1903 | 1909 | ``` |
| 1904 | 1910 | |
| 1911 | +使用本地 OPUS 模型(中文 → 英文): | |
| 1912 | +```bash | |
| 1913 | +curl -X POST "http://localhost:6006/translate" \ | |
| 1914 | + -H "Content-Type: application/json" \ | |
| 1915 | + -d '{ | |
| 1916 | + "text": "蓝牙耳机", | |
| 1917 | + "target_lang": "en", | |
| 1918 | + "source_lang": "zh", | |
| 1919 | + "model": "opus-mt-zh-en", | |
| 1920 | + "scene": "sku_name" | |
| 1921 | + }' | |
| 1922 | +``` | |
| 1923 | + | |
| 1905 | 1924 | #### 7.3.2 `GET /health` — 健康检查 |
| 1906 | 1925 | |
| 1907 | 1926 | ```bash |
| 1908 | 1927 | curl "http://localhost:6006/health" |
| 1909 | 1928 | ``` |
| 1910 | 1929 | |
| 1930 | +典型响应: | |
| 1931 | +```json | |
| 1932 | +{ | |
| 1933 | + "status": "healthy", | |
| 1934 | + "service": "translation", | |
| 1935 | + "default_model": "llm", | |
| 1936 | + "default_scene": "general", | |
| 1937 | + "available_models": ["qwen-mt", "llm", "opus-mt-zh-en"], | |
| 1938 | + "enabled_capabilities": ["qwen-mt", "llm", "opus-mt-zh-en"], | |
| 1939 | + "loaded_models": ["llm"] | |
| 1940 | +} | |
| 1941 | +``` | |
| 1942 | + | |
| 1911 | 1943 | ### 7.4 内容理解字段生成(Indexer 服务内) |
| 1912 | 1944 | |
| 1913 | 1945 | 内容理解字段生成接口部署在 **Indexer 服务**(默认端口 6004)内,与「翻译、向量化」等独立端口微服务并列,供采用**微服务组合**方式的 indexer 调用。 | ... | ... |
docs/系统设计文档.md
| ... | ... | @@ -382,16 +382,7 @@ query_config: |
| 382 | 382 | # 实际翻译 provider 与模型在通用 services 配置中定义 |
| 383 | 383 | ``` |
| 384 | 384 | |
| 385 | -实际代码中,通过通用的 translation provider 抽象来选择具体后端和模型,文档不固定绑定某一个具体翻译服务或模型名称,以保持可配置性。 | |
| 386 | - | |
| 387 | -此外,为了支持**高质量、提示词可控的 LLM 翻译**(例如商品富化脚本、离线分析工具),在 `query/llm_translate.py` 中提供了一个独立的 LLM 翻译辅助模块: | |
| 388 | - | |
| 389 | -- **配置入口**:`config/config.yaml -> services.translation.providers.llm`,用于指定: | |
| 390 | - - `model`: 例如 `qwen-flash`(DashScope 兼容模式的对话模型) | |
| 391 | - - `base_url`: 可选;为空时使用环境变量 `DASHSCOPE_BASE_URL` 或默认 Endpoint | |
| 392 | - - `timeout_sec`: LLM 调用超时 | |
| 393 | -- **环境变量**:仍通过 `DASHSCOPE_API_KEY` 注入 DashScope API Key。 | |
| 394 | -- **使用方式**:主查询路径继续使用 machine translation(`query.translator.Translator`),只在需要更强表达控制的场景(如批量标注、产品分类脚本)中显式调用 `llm_translate()`。 | |
| 385 | +实际代码中,翻译已改为统一的 translator service 架构:业务侧通过 `translation.create_translation_client()` 访问 6006,由 `translation/service.py` 在服务内按 `model + scene` 路由到具体 backend。scene 集合、语言码映射、LLM prompt 模板、本地模型方向约束等翻译域知识位于 `translation/` 内部,不再通过外部 provider 抽象分散管理。 | |
| 395 | 386 | |
| 396 | 387 | #### 功能特性 |
| 397 | 388 | 1. **语言检测**:自动检测查询语言 | ... | ... |
docs/缓存与Redis使用说明.md
| ... | ... | @@ -21,7 +21,7 @@ |
| 21 | 21 | | 模块 / 场景 | Key 模板 | Value 内容示例 | 过期策略 | 备注 | |
| 22 | 22 | |------------|----------|----------------|----------|------| |
| 23 | 23 | | 向量缓存(text/image embedding) | `{EMBEDDING_CACHE_PREFIX}:{query_or_url}` / `{EMBEDDING_CACHE_PREFIX}:image:{url_or_path}` | **BF16 bytes**(每维 2 字节大端存储),读取后恢复为 `np.float32` | TTL=`REDIS_CONFIG["cache_expire_days"]` 天;访问时滑动过期 | 见 `embeddings/text_encoder.py`(文本)与 `embeddings/image_encoder.py`(图片);前缀由 `REDIS_CONFIG["embedding_cache_prefix"]` 控制 | |
| 24 | -| 翻译结果缓存(Qwen-MT 翻译) | `{cache_prefix}:{model}:{src}:{tgt}:{sha256(payload)}` | 机翻后的单条字符串 | TTL=`services.translation.cache.ttl_seconds` 秒;可配置滑动过期 | 见 `query/qwen_mt_translate.py` + `config/config.yaml` | | |
| 24 | +| 翻译结果缓存(Qwen-MT 翻译) | `{cache_prefix}:{model}:{src}:{tgt}:{sha256(payload)}` | 机翻后的单条字符串 | TTL=`services.translation.cache.ttl_seconds` 秒;可配置滑动过期 | 见 `translation/backends/qwen_mt.py` + `config/config.yaml` | | |
| 25 | 25 | | 商品内容理解缓存(anchors / 语义属性 / tags) | `{ANCHOR_CACHE_PREFIX}:{tenant_or_global}:{target_lang}:{md5(title)}` | `json.dumps(dict)`,包含 id/title/category/tags/anchor_text 等 | TTL=`ANCHOR_CACHE_EXPIRE_DAYS` 天 | 见 `indexer/product_enrich.py` | |
| 26 | 26 | |
| 27 | 27 | 下面按模块详细说明。 |
| ... | ... | @@ -71,9 +71,9 @@ |
| 71 | 71 | |
| 72 | 72 | --- |
| 73 | 73 | |
| 74 | -## 3. 翻译结果缓存(query/qwen_mt_translate.py) | |
| 74 | +## 3. 翻译结果缓存(translation/backends/qwen_mt.py) | |
| 75 | 75 | |
| 76 | -- **代码位置**:`query/qwen_mt_translate.py` 中 `Translator` 类 | |
| 76 | +- **代码位置**:`translation/backends/qwen_mt.py` 中 `QwenMTTranslationBackend` | |
| 77 | 77 | - **用途**:缓存 Qwen-MT 翻译(及 translator service 复用的翻译)结果,减少云端请求,遵守限速。 |
| 78 | 78 | - **配置入口**:`config/config.yaml -> services.translation.cache`,统一由 `config/services_config.get_translation_cache_config()` 解析。 |
| 79 | 79 | |
| ... | ... | @@ -95,8 +95,7 @@ |
| 95 | 95 | - `sha256(payload)`:对以下内容整体做 SHA-256: |
| 96 | 96 | - `model` |
| 97 | 97 | - `src` / `tgt` |
| 98 | - - `context`(受 `key_include_context` 控制) | |
| 99 | - - `prompt`(受 `key_include_prompt` 控制) | |
| 98 | + - `scene`(受 `key_include_scene` 控制) | |
| 100 | 99 | - 原始 `text` |
| 101 | 100 | |
| 102 | 101 | > 注意:所有 key 设计集中在 `_build_cache_key`,**不要在其他位置手动拼翻译缓存 key**。 |
| ... | ... | @@ -120,8 +119,7 @@ services: |
| 120 | 119 | key_prefix: "trans:v2" |
| 121 | 120 | ttl_seconds: 62208000 # 默认约 720 天 |
| 122 | 121 | sliding_expiration: true |
| 123 | - key_include_context: true | |
| 124 | - key_include_prompt: true | |
| 122 | + key_include_scene: true | |
| 125 | 123 | key_include_source_lang: true |
| 126 | 124 | ``` |
| 127 | 125 | |
| ... | ... | @@ -138,7 +136,7 @@ services: |
| 138 | 136 | |
| 139 | 137 | ### 3.4 关联模块 |
| 140 | 138 | |
| 141 | -- `api/translator_app.py` 会通过 `query.qwen_mt_translate.Translator` 复用同一套缓存逻辑; | |
| 139 | +- `api/translator_app.py` 会通过 `translation.backends.qwen_mt.QwenMTTranslationBackend` 复用同一套缓存逻辑; | |
| 142 | 140 | - 文档说明:`docs/翻译模块说明.md` 中提到“推荐通过 Redis 翻译缓存复用结果”。 |
| 143 | 141 | |
| 144 | 142 | --- |
| ... | ... | @@ -345,4 +343,3 @@ python scripts/redis/redis_memory_heavy_keys.py --top 100 |
| 345 | 343 | - **文档同步**: |
| 346 | 344 | - 新增缓存后,应在本文件中补充一行总览表 + 详细小节; |
| 347 | 345 | - 若缓存与外部系统/历史实现兼容(如 Java 侧翻译缓存),需在说明中显式标注。 |
| 348 | - | ... | ... |
docs/翻译模块说明.md
| ... | ... | @@ -10,11 +10,6 @@ DASHSCOPE_API_KEY=sk-xxx |
| 10 | 10 | |
| 11 | 11 | # DeepL |
| 12 | 12 | DEEPL_AUTH_KEY=xxx |
| 13 | - | |
| 14 | -# 可选 | |
| 15 | -TRANSLATION_SERVICE_URL=http://127.0.0.1:6006 | |
| 16 | -TRANSLATION_MODEL=llm # 默认能力;也可传 qwen-mt / deepl | |
| 17 | -TRANSLATION_SCENE=general | |
| 18 | 13 | ``` |
| 19 | 14 | |
| 20 | 15 | > **重要限速说明(Qwen 机翻)** |
| ... | ... | @@ -29,7 +24,11 @@ TRANSLATION_SCENE=general |
| 29 | 24 | |
| 30 | 25 | - 业务侧(`QueryParser` / indexer)统一调用 `http://127.0.0.1:6006` |
| 31 | 26 | - 服务内按 `services.translation.capabilities` 加载并管理各翻译能力 |
| 32 | -- 每种能力独立配置 `enabled`、`model`、`timeout` 等参数 | |
| 27 | +- 已启用 capability 统一注册,后端实例按首次调用懒加载,避免多个本地模型在启动阶段一次性占满显存 | |
| 28 | +- `config.yaml` 只保留部署相关配置;scene 规则、语言码映射、prompt 模板、模型方向约束等翻译域知识统一收口在 `translation/` 内部 | |
| 29 | +- 每种能力独立配置 `enabled`、`model`、`base_url/api_url`、`timeout`、本地模型运行参数等部署项 | |
| 30 | +- 每种能力显式声明 `backend` 类型,例如 `qwen_mt`、`llm`、`deepl`、`local_nllb`、`local_marian` | |
| 31 | +- `service_url`、`default_model`、`default_scene` 只从 `config/config.yaml` 读取,不再接受环境变量静默覆盖 | |
| 33 | 32 | - 外部接口通过 `model + scene` 指定本次使用哪种能力、哪个场景 |
| 34 | 33 | |
| 35 | 34 | 配置入口在 `config/config.yaml -> services.translation`,核心字段示例: |
| ... | ... | @@ -44,19 +43,65 @@ services: |
| 44 | 43 | capabilities: |
| 45 | 44 | qwen-mt: |
| 46 | 45 | enabled: true |
| 46 | + backend: "qwen_mt" | |
| 47 | 47 | model: "qwen-mt-flash" |
| 48 | + base_url: "https://dashscope-us.aliyuncs.com/compatible-mode/v1" | |
| 48 | 49 | llm: |
| 49 | 50 | enabled: true |
| 51 | + backend: "llm" | |
| 50 | 52 | model: "qwen-flash" |
| 53 | + base_url: "https://dashscope-us.aliyuncs.com/compatible-mode/v1" | |
| 51 | 54 | deepl: |
| 52 | 55 | enabled: false |
| 56 | + backend: "deepl" | |
| 57 | + api_url: "https://api.deepl.com/v2/translate" | |
| 58 | + nllb-200-distilled-600m: | |
| 59 | + enabled: false | |
| 60 | + backend: "local_nllb" | |
| 61 | + model_id: "facebook/nllb-200-distilled-600M" | |
| 62 | + opus-mt-zh-en: | |
| 63 | + enabled: false | |
| 64 | + backend: "local_marian" | |
| 65 | + model_id: "Helsinki-NLP/opus-mt-zh-en" | |
| 66 | + opus-mt-en-zh: | |
| 67 | + enabled: false | |
| 68 | + backend: "local_marian" | |
| 69 | + model_id: "Helsinki-NLP/opus-mt-en-zh" | |
| 53 | 70 | ``` |
| 54 | 71 | |
| 72 | +## 本地模型部署 | |
| 73 | + | |
| 74 | +本仓库已内置 3 个本地机翻 capability: | |
| 75 | + | |
| 76 | +- `nllb-200-distilled-600m` | |
| 77 | +- `opus-mt-zh-en` | |
| 78 | +- `opus-mt-en-zh` | |
| 79 | + | |
| 80 | +推荐流程: | |
| 81 | + | |
| 82 | +1. 创建独立运行环境:`./scripts/setup_translator_venv.sh` | |
| 83 | +2. 下载本地模型:`./.venv-translator/bin/python scripts/download_translation_models.py --all-local` | |
| 84 | +3. 在 `config/config.yaml` 中把对应 capability 的 `enabled` 改为 `true` | |
| 85 | +4. 启动服务:`./scripts/start_translator.sh` | |
| 86 | + | |
| 87 | +默认模型目录: | |
| 88 | + | |
| 89 | +- `models/translation/facebook/nllb-200-distilled-600M` | |
| 90 | +- `models/translation/Helsinki-NLP/opus-mt-zh-en` | |
| 91 | +- `models/translation/Helsinki-NLP/opus-mt-en-zh` | |
| 92 | + | |
| 93 | +说明: | |
| 94 | + | |
| 95 | +- 目前只支持 3 个标准 scene:`general`、`sku_name`、`ecommerce_search_query` | |
| 96 | +- `nllb-200-distilled-600m` 支持多语,但依赖明确的 `source_lang` | |
| 97 | +- 两个 OPUS 模型分别只支持 `zh -> en` 与 `en -> zh` | |
| 98 | +- 本地模型建议单 worker 运行,避免重复加载占用显存 | |
| 99 | + | |
| 55 | 100 | ## HTTP 接口契约(translator service,端口 6006) |
| 56 | 101 | |
| 57 | 102 | 服务默认监听 `http://localhost:6006`,提供: |
| 58 | 103 | |
| 59 | -- `POST /translate`: 文本翻译(支持 `qwen/qwen-mt`、`deepl`、`llm`) | |
| 104 | +- `POST /translate`: 文本翻译(支持所有已启用 capability) | |
| 60 | 105 | - `GET /health`: 健康检查 |
| 61 | 106 | |
| 62 | 107 | ### `POST /translate` |
| ... | ... | @@ -69,8 +114,7 @@ services: |
| 69 | 114 | "target_lang": "en", |
| 70 | 115 | "source_lang": "zh", |
| 71 | 116 | "model": "qwen-mt", |
| 72 | - "scene": "sku_name", | |
| 73 | - "prompt": null | |
| 117 | + "scene": "sku_name" | |
| 74 | 118 | } |
| 75 | 119 | ``` |
| 76 | 120 | |
| ... | ... | @@ -110,15 +154,16 @@ services: |
| 110 | 154 | |
| 111 | 155 | 说明: |
| 112 | 156 | |
| 113 | -- `scene` 是标准字段,`context` 仅保留为兼容别名 | |
| 157 | +- `scene` 是标准字段 | |
| 158 | +- `prompt` 不属于外部接口;LLM prompt 由 translator service 内部根据 `scene` 生成 | |
| 114 | 159 | - `model` 只能选择已在 `services.translation.capabilities` 中启用的能力 |
| 115 | -- `/health` 会返回 `default_model`、`default_scene` 与 `enabled_capabilities` | |
| 160 | +- `/health` 会返回 `default_model`、`default_scene`、`enabled_capabilities` 与 `loaded_models` | |
| 116 | 161 | |
| 117 | 162 | --- |
| 118 | 163 | |
| 119 | 164 | ## 开发者接口约定(代码调用) |
| 120 | 165 | |
| 121 | -代码侧(如 query/indexer)仍通过 `providers.translation.create_translation_provider()` 获取实例并调用 `translate()`,但该实例现在固定是 **translator service client**,不再在业务侧做翻译 provider 选择。 | |
| 166 | +代码侧(如 query/indexer)通过 `translation.create_translation_client()` 获取实例并调用 `translate()`;业务侧不再存在翻译 provider 选择逻辑。 | |
| 122 | 167 | |
| 123 | 168 | ### 输入输出形状(Shape) |
| 124 | 169 | |
| ... | ... | @@ -131,6 +176,6 @@ services: |
| 131 | 176 | |
| 132 | 177 | 服务客户端与服务内后端都可以暴露 `supports_batch`。若后端不支持批量,服务端会逐条拆分并保持 shape。 |
| 133 | 178 | |
| 134 | -为便于上层(如 `api/translator_app.py`)做最优调用,provider 可暴露: | |
| 179 | +为便于上层(如 `api/translator_app.py`)做最优调用,client / backend 可暴露: | |
| 135 | 180 | |
| 136 | 181 | - `supports_batch: bool`(property) | ... | ... |
indexer/README.md
| ... | ... | @@ -204,17 +204,21 @@ categoryPath.set(categoryLang, translationCategoryPath) |
| 204 | 204 | 你当前要使用的翻译接口(Python 侧): |
| 205 | 205 | |
| 206 | 206 | ```bash |
| 207 | -curl -X POST http://43.166.252.75:6006/translate \ | |
| 207 | +curl -X POST http://127.0.0.1:6006/translate \ | |
| 208 | 208 | -H "Content-Type: application/json" \ |
| 209 | 209 | -d '{"text":"儿童小男孩女孩开学 100 天衬衫短袖 搞笑图案字母印花庆祝上衣", |
| 210 | 210 | "target_lang":"en", |
| 211 | - "source_lang":"auto"}' | |
| 211 | + "source_lang":"zh", | |
| 212 | + "model":"qwen-mt", | |
| 213 | + "scene":"sku_name"}' | |
| 212 | 214 | ``` |
| 213 | 215 | |
| 214 | 216 | - 请求参数: |
| 215 | 217 | - `text`:待翻译文本; |
| 216 | 218 | - `target_lang`:目标语言(如 `"en"`、`"zh"` 等); |
| 217 | - - `source_lang`:源语言(支持 `"auto"` 自动检测)。 | |
| 219 | + - `source_lang`:源语言; | |
| 220 | + - `model`:启用的翻译能力名称; | |
| 221 | + - `scene`:翻译场景(如 `sku_name`、`general`)。 | |
| 218 | 222 | - 响应(参考 Java `TranslationServiceImpl.querySaasTranslate`): |
| 219 | 223 | - JSON 里包含 `status` 字段,如果是 `"success"`,且 `translated_text` 非空,则返回翻译结果。 |
| 220 | 224 | ... | ... |
indexer/document_transformer.py
| ... | ... | @@ -18,9 +18,6 @@ from indexer.product_enrich import analyze_products |
| 18 | 18 | |
| 19 | 19 | logger = logging.getLogger(__name__) |
| 20 | 20 | |
| 21 | -from query.qwen_mt_translate import Translator | |
| 22 | - | |
| 23 | - | |
| 24 | 21 | class SPUDocumentTransformer: |
| 25 | 22 | """SPU文档转换器,将SPU、SKU、Option数据转换为ES文档格式。""" |
| 26 | 23 | |
| ... | ... | @@ -75,7 +72,7 @@ class SPUDocumentTransformer: |
| 75 | 72 | text=text, |
| 76 | 73 | target_lang=lang, |
| 77 | 74 | source_lang=source_lang, |
| 78 | - context=scene, | |
| 75 | + scene=scene, | |
| 79 | 76 | ) |
| 80 | 77 | return translations |
| 81 | 78 | |
| ... | ... | @@ -351,7 +348,7 @@ class SPUDocumentTransformer: |
| 351 | 348 | text=brief_text, |
| 352 | 349 | source_lang=primary_lang, |
| 353 | 350 | index_languages=index_langs, |
| 354 | - scene="default", | |
| 351 | + scene="general", | |
| 355 | 352 | ) |
| 356 | 353 | _set_lang_obj("brief", brief_text, translations) |
| 357 | 354 | |
| ... | ... | @@ -364,7 +361,7 @@ class SPUDocumentTransformer: |
| 364 | 361 | text=desc_text, |
| 365 | 362 | source_lang=primary_lang, |
| 366 | 363 | index_languages=index_langs, |
| 367 | - scene="default", | |
| 364 | + scene="general", | |
| 368 | 365 | ) |
| 369 | 366 | _set_lang_obj("description", desc_text, translations) |
| 370 | 367 | |
| ... | ... | @@ -377,7 +374,7 @@ class SPUDocumentTransformer: |
| 377 | 374 | text=vendor_text, |
| 378 | 375 | source_lang=primary_lang, |
| 379 | 376 | index_languages=index_langs, |
| 380 | - scene="default", | |
| 377 | + scene="general", | |
| 381 | 378 | ) |
| 382 | 379 | _set_lang_obj("vendor", vendor_text, translations) |
| 383 | 380 | ... | ... |
indexer/incremental_service.py
| ... | ... | @@ -14,6 +14,7 @@ from indexer.indexer_logger import ( |
| 14 | 14 | get_indexer_logger, log_index_request, log_index_result, log_spu_processing |
| 15 | 15 | ) |
| 16 | 16 | from config import ConfigLoader |
| 17 | +from translation import create_translation_client | |
| 17 | 18 | |
| 18 | 19 | # Configure logger |
| 19 | 20 | logger = logging.getLogger(__name__) |
| ... | ... | @@ -56,9 +57,7 @@ class IncrementalIndexerService: |
| 56 | 57 | or ["option1", "option2", "option3"] |
| 57 | 58 | ) |
| 58 | 59 | |
| 59 | - from providers import create_translation_provider | |
| 60 | - | |
| 61 | - self._translator = create_translation_provider(self._config.query_config) | |
| 60 | + self._translator = create_translation_client() | |
| 62 | 61 | |
| 63 | 62 | # Text embedding encoder (strict when enabled) |
| 64 | 63 | if bool(getattr(self._config.query_config, "enable_text_embedding", False)): | ... | ... |
indexer/indexing_utils.py
| ... | ... | @@ -10,6 +10,7 @@ from sqlalchemy import Engine, text |
| 10 | 10 | from config import ConfigLoader |
| 11 | 11 | from config.tenant_config_loader import get_tenant_config_loader |
| 12 | 12 | from indexer.document_transformer import SPUDocumentTransformer |
| 13 | +from translation import create_translation_client | |
| 13 | 14 | |
| 14 | 15 | logger = logging.getLogger(__name__) |
| 15 | 16 | |
| ... | ... | @@ -100,9 +101,7 @@ def create_document_transformer( |
| 100 | 101 | index_langs = tenant_config.get("index_languages") or [] |
| 101 | 102 | need_translator = len(index_langs) > 1 |
| 102 | 103 | if translator is None and need_translator: |
| 103 | - from providers import create_translation_provider | |
| 104 | - | |
| 105 | - translator = create_translation_provider(config.query_config) | |
| 104 | + translator = create_translation_client() | |
| 106 | 105 | |
| 107 | 106 | # 初始化encoder(如果启用标题向量化且未提供encoder) |
| 108 | 107 | if encoder is None and enable_title_embedding and config.query_config.enable_text_embedding: | ... | ... |
indexer/test_indexing.py
| ... | ... | @@ -273,11 +273,8 @@ def test_document_transformer(): |
| 273 | 273 | tenant_config = tenant_config_loader.get_tenant_config('162') |
| 274 | 274 | |
| 275 | 275 | # 初始化翻译器(测试环境总是启用,具体翻译方向由tenant_config控制) |
| 276 | - from query.qwen_mt_translate import Translator | |
| 277 | - translator = Translator( | |
| 278 | - api_key=config.query_config.translation_api_key, | |
| 279 | - use_cache=True | |
| 280 | - ) | |
| 276 | + from translation.backends.qwen_mt import QwenMTTranslationBackend | |
| 277 | + translator = QwenMTTranslationBackend(use_cache=True) | |
| 281 | 278 | |
| 282 | 279 | # 创建转换器 |
| 283 | 280 | transformer = SPUDocumentTransformer( |
| ... | ... | @@ -366,4 +363,3 @@ def main(): |
| 366 | 363 | |
| 367 | 364 | if __name__ == '__main__': |
| 368 | 365 | sys.exit(main()) |
| 369 | - | ... | ... |
providers/__init__.py
| 1 | -""" | |
| 2 | -Pluggable providers for translation, embedding, rerank. | |
| 1 | +"""Pluggable providers for embedding and rerank.""" | |
| 3 | 2 | |
| 4 | -All provider selection is driven by config/services_config (services block). | |
| 5 | -""" | |
| 6 | - | |
| 7 | -from .translation import create_translation_provider | |
| 8 | 3 | from .rerank import create_rerank_provider |
| 9 | 4 | from .embedding import create_embedding_provider |
| 10 | 5 | |
| 11 | 6 | __all__ = [ |
| 12 | - "create_translation_provider", | |
| 13 | 7 | "create_rerank_provider", |
| 14 | 8 | "create_embedding_provider", |
| 15 | 9 | ] | ... | ... |
providers/translation.py deleted
| ... | ... | @@ -1,28 +0,0 @@ |
| 1 | -"""Translation client factory for business callers.""" | |
| 2 | - | |
| 3 | -from __future__ import annotations | |
| 4 | - | |
| 5 | -from typing import Any | |
| 6 | - | |
| 7 | -from config.services_config import get_translation_config | |
| 8 | -from translation.client import TranslationServiceClient | |
| 9 | - | |
| 10 | - | |
| 11 | -def create_translation_provider(query_config: Any = None) -> TranslationServiceClient: | |
| 12 | - """ | |
| 13 | - Create a translation client. | |
| 14 | - | |
| 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. | |
| 18 | - """ | |
| 19 | - | |
| 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/__init__.py
| 1 | 1 | """Query package initialization.""" |
| 2 | 2 | |
| 3 | 3 | from .language_detector import LanguageDetector |
| 4 | -from .qwen_mt_translate import Translator | |
| 5 | 4 | from .query_rewriter import QueryRewriter, QueryNormalizer |
| 6 | 5 | from .query_parser import QueryParser, ParsedQuery |
| 7 | 6 | |
| 8 | 7 | __all__ = [ |
| 9 | 8 | 'LanguageDetector', |
| 10 | - 'Translator', | |
| 11 | 9 | 'QueryRewriter', |
| 12 | 10 | 'QueryNormalizer', |
| 13 | 11 | 'QueryParser', | ... | ... |
query/deepl_provider.py deleted
query/llm_translate.py deleted
query/query_parser.py
| ... | ... | @@ -12,8 +12,8 @@ from concurrent.futures import ThreadPoolExecutor, as_completed, wait |
| 12 | 12 | |
| 13 | 13 | from embeddings.text_encoder import TextEmbeddingEncoder |
| 14 | 14 | from config import SearchConfig |
| 15 | +from translation import create_translation_client | |
| 15 | 16 | from .language_detector import LanguageDetector |
| 16 | -from providers import create_translation_provider | |
| 17 | 17 | from .query_rewriter import QueryRewriter, QueryNormalizer |
| 18 | 18 | |
| 19 | 19 | logger = logging.getLogger(__name__) |
| ... | ... | @@ -138,7 +138,7 @@ class QueryParser: |
| 138 | 138 | cfg.service_url, |
| 139 | 139 | cfg.default_model, |
| 140 | 140 | ) |
| 141 | - self._translator = create_translation_provider(self.config.query_config) | |
| 141 | + self._translator = create_translation_client() | |
| 142 | 142 | self._translation_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="query-translation") |
| 143 | 143 | |
| 144 | 144 | @property | ... | ... |
query/qwen_mt_translate.py deleted
| 1 | 1 | #!/usr/bin/env python3 |
| 2 | -""" | |
| 3 | -Translation function test script. | |
| 4 | - | |
| 5 | -Test content: | |
| 6 | -1. Translation prompt configuration loading | |
| 7 | -2. Synchronous translation (indexing scenario) | |
| 8 | -3. Asynchronous translation (query scenario) | |
| 9 | -4. Usage of different prompts | |
| 10 | -5. Cache functionality | |
| 11 | -6. DeepL Context parameter usage | |
| 12 | -""" | |
| 13 | - | |
| 14 | -import sys | |
| 15 | -import os | |
| 16 | -from pathlib import Path | |
| 17 | -from concurrent.futures import ThreadPoolExecutor | |
| 18 | - | |
| 19 | -# Add parent directory to path | |
| 20 | -sys.path.insert(0, str(Path(__file__).parent.parent)) | |
| 21 | - | |
| 22 | -from config import ConfigLoader | |
| 23 | -from query.qwen_mt_translate import Translator | |
| 24 | -import logging | |
| 25 | - | |
| 26 | -# Configure logging | |
| 27 | -logging.basicConfig( | |
| 28 | - level=logging.INFO, | |
| 29 | - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' | |
| 30 | -) | |
| 31 | -logger = logging.getLogger(__name__) | |
| 32 | - | |
| 33 | - | |
| 34 | -def test_config_loading(): | |
| 35 | - """Test configuration loading""" | |
| 36 | - print("\n" + "="*60) | |
| 37 | - print("Test 1: Configuration loading") | |
| 38 | - print("="*60) | |
| 39 | - | |
| 40 | - try: | |
| 41 | - config_loader = ConfigLoader() | |
| 42 | - config = config_loader.load_config() | |
| 43 | - | |
| 44 | - print(f"✓ Configuration loaded successfully") | |
| 45 | - print(f" Translation service: {config.query_config.translation_service}") | |
| 46 | - | |
| 47 | - return config | |
| 48 | - except Exception as e: | |
| 49 | - print(f"✗ Configuration loading failed: {e}") | |
| 50 | - import traceback | |
| 51 | - traceback.print_exc() | |
| 52 | - return None | |
| 53 | - | |
| 54 | - | |
| 55 | -def test_translator_sync(config): | |
| 56 | - """Test synchronous translation (indexing scenario)""" | |
| 57 | - print("\n" + "="*60) | |
| 58 | - print("Test 2: Synchronous translation (indexing scenario)") | |
| 59 | - print("="*60) | |
| 60 | - | |
| 61 | - if not config: | |
| 62 | - print("✗ Skipped: Configuration not loaded") | |
| 63 | - return None | |
| 64 | - | |
| 65 | - try: | |
| 66 | - translator = Translator( | |
| 67 | - api_key=config.query_config.translation_api_key, | |
| 68 | - use_cache=True, | |
| 69 | - glossary_id=config.query_config.translation_glossary_id, | |
| 70 | - translation_context=config.query_config.translation_context | |
| 71 | - ) | |
| 72 | - | |
| 73 | - # 测试商品标题翻译(使用sku_name提示词) | |
| 74 | - test_texts = [ | |
| 75 | - ("蓝牙耳机", "zh", "en", "sku_name"), | |
| 76 | - ("Wireless Headphones", "en", "zh", "sku_name"), | |
| 77 | - ] | |
| 78 | - | |
| 79 | - for text, source_lang, target_lang, scene in test_texts: | |
| 80 | - print(f"\nTranslation test:") | |
| 81 | - print(f" Original text ({source_lang}): {text}") | |
| 82 | - print(f" Target language: {target_lang}") | |
| 83 | - print(f" Scene: {scene}") | |
| 84 | - | |
| 85 | - result = translator.translate( | |
| 86 | - text, | |
| 87 | - target_lang=target_lang, | |
| 88 | - source_lang=source_lang, | |
| 89 | - context=scene, | |
| 90 | - ) | |
| 91 | - | |
| 92 | - if result: | |
| 93 | - print(f" Result: {result}") | |
| 94 | - print(f" ✓ Translation successful") | |
| 95 | - else: | |
| 96 | - print(f" ⚠ Translation returned None (possibly mock mode or no API key)") | |
| 97 | - | |
| 98 | - return translator | |
| 99 | - | |
| 100 | - except Exception as e: | |
| 101 | - print(f"✗ Synchronous translation test failed: {e}") | |
| 102 | - import traceback | |
| 103 | - traceback.print_exc() | |
| 104 | - return None | |
| 105 | - | |
| 106 | - | |
| 107 | -def test_translator_async(config, translator): | |
| 108 | - """Test asynchronous translation (query scenario)""" | |
| 109 | - print("\n" + "="*60) | |
| 110 | - print("Test 3: Asynchronous translation (query scenario)") | |
| 111 | - print("="*60) | |
| 112 | - | |
| 113 | - if not config or not translator: | |
| 114 | - print("✗ Skipped: Configuration or translator not initialized") | |
| 115 | - return | |
| 116 | - | |
| 117 | - try: | |
| 118 | - query_text = "手机" | |
| 119 | - target_langs = ['en'] | |
| 120 | - source_lang = 'zh' | |
| 121 | - | |
| 122 | - print(f"Query text: {query_text}") | |
| 123 | - print(f"Target languages: {target_langs}") | |
| 124 | - print("Scene: ecommerce_search_query") | |
| 125 | - | |
| 126 | - print(f"\nConcurrent translation via generic translate():") | |
| 127 | - with ThreadPoolExecutor(max_workers=len(target_langs)) as executor: | |
| 128 | - futures = { | |
| 129 | - lang: executor.submit( | |
| 130 | - translator.translate, | |
| 131 | - query_text, | |
| 132 | - lang, | |
| 133 | - source_lang, | |
| 134 | - "ecommerce_search_query", | |
| 135 | - ) | |
| 136 | - for lang in target_langs | |
| 137 | - } | |
| 138 | - for lang, future in futures.items(): | |
| 139 | - print(f" {lang}: {future.result()}") | |
| 140 | - | |
| 141 | - except Exception as e: | |
| 142 | - print(f"✗ Asynchronous translation test failed: {e}") | |
| 143 | - import traceback | |
| 144 | - traceback.print_exc() | |
| 145 | - | |
| 146 | - | |
| 147 | -def test_cache(): | |
| 148 | - """测试缓存功能""" | |
| 149 | - print("\n" + "="*60) | |
| 150 | - print("Test 4: Cache functionality") | |
| 151 | - print("="*60) | |
| 152 | - | |
| 153 | - try: | |
| 154 | - config_loader = ConfigLoader() | |
| 155 | - config = config_loader.load_config() | |
| 156 | - | |
| 157 | - translator = Translator( | |
| 158 | - api_key=config.query_config.translation_api_key, | |
| 159 | - use_cache=True | |
| 160 | - ) | |
| 161 | - | |
| 162 | - test_text = "测试文本" | |
| 163 | - target_lang = "en" | |
| 164 | - source_lang = "zh" | |
| 165 | - | |
| 166 | - print(f"First translation (should call API or return mock):") | |
| 167 | - result1 = translator.translate(test_text, target_lang, source_lang, context="default") | |
| 168 | - print(f" Result: {result1}") | |
| 169 | - | |
| 170 | - print(f"\nSecond translation (should use cache):") | |
| 171 | - result2 = translator.translate(test_text, target_lang, source_lang, context="default") | |
| 172 | - print(f" Result: {result2}") | |
| 173 | - | |
| 174 | - if result1 == result2: | |
| 175 | - print(f" ✓ Cache functionality working properly") | |
| 176 | - else: | |
| 177 | - print(f" ⚠ Cache might have issues") | |
| 178 | - | |
| 179 | - except Exception as e: | |
| 180 | - print(f"✗ Cache test failed: {e}") | |
| 181 | - import traceback | |
| 182 | - traceback.print_exc() | |
| 183 | - | |
| 184 | - | |
| 185 | -def test_context_parameter(): | |
| 186 | - """Test DeepL Context parameter usage""" | |
| 187 | - print("\n" + "="*60) | |
| 188 | - print("Test 5: DeepL Context parameter") | |
| 189 | - print("="*60) | |
| 190 | - | |
| 191 | - try: | |
| 192 | - config_loader = ConfigLoader() | |
| 193 | - config = config_loader.load_config() | |
| 194 | - | |
| 195 | - translator = Translator( | |
| 196 | - api_key=config.query_config.translation_api_key, | |
| 197 | - use_cache=False # 禁用缓存以便测试 | |
| 198 | - ) | |
| 199 | - | |
| 200 | - # 测试带context和不带context的翻译 | |
| 201 | - text = "手机" | |
| 202 | - | |
| 203 | - print(f"Test text: {text}") | |
| 204 | - print("Scene: ecommerce_search_query") | |
| 205 | - | |
| 206 | - # 带context的翻译 | |
| 207 | - result_with_context = translator.translate( | |
| 208 | - text, | |
| 209 | - target_lang='en', | |
| 210 | - source_lang='zh', | |
| 211 | - context="ecommerce_search_query", | |
| 212 | - ) | |
| 213 | - print(f"\nTranslation result with context: {result_with_context}") | |
| 214 | - | |
| 215 | - # 不带context的翻译 | |
| 216 | - result_without_context = translator.translate( | |
| 217 | - text, | |
| 218 | - target_lang='en', | |
| 219 | - source_lang='zh', | |
| 220 | - prompt=None | |
| 221 | - ) | |
| 222 | - print(f"Translation result without context: {result_without_context}") | |
| 223 | - | |
| 224 | - print(f"\n✓ Context parameter test completed") | |
| 225 | - print(f" Note: According to DeepL API, context parameter affects translation but does not participate in translation itself") | |
| 226 | - | |
| 227 | - except Exception as e: | |
| 228 | - print(f"✗ Context parameter test failed: {e}") | |
| 229 | - import traceback | |
| 230 | - traceback.print_exc() | |
| 231 | - | |
| 232 | - | |
| 233 | -def main(): | |
| 234 | - """Main test function""" | |
| 235 | - print("="*60) | |
| 236 | - print("Translation function test") | |
| 237 | - print("="*60) | |
| 238 | - | |
| 239 | - # 测试1: 配置加载 | |
| 240 | - config = test_config_loading() | |
| 241 | - | |
| 242 | - # 测试2: 同步翻译 | |
| 243 | - translator = test_translator_sync(config) | |
| 244 | - | |
| 245 | - # 测试3: 异步翻译 | |
| 246 | - test_translator_async(config, translator) | |
| 247 | - | |
| 248 | - # 测试4: 缓存功能 | |
| 249 | - test_cache() | |
| 250 | - | |
| 251 | - # 测试5: Context参数 | |
| 252 | - test_context_parameter() | |
| 253 | - | |
| 254 | - print("\n" + "="*60) | |
| 255 | - print("Test completed") | |
| 256 | - print("="*60) | |
| 257 | - | |
| 258 | - | |
| 259 | -if __name__ == '__main__': | |
| 2 | +"""Manual smoke test for the translator service.""" | |
| 3 | + | |
| 4 | +from __future__ import annotations | |
| 5 | + | |
| 6 | +import argparse | |
| 7 | +import json | |
| 8 | +from typing import Optional | |
| 9 | + | |
| 10 | +from translation import create_translation_client | |
| 11 | + | |
| 12 | + | |
| 13 | +def main() -> None: | |
| 14 | + parser = argparse.ArgumentParser(description="Smoke test the translator service") | |
| 15 | + parser.add_argument("--text", default="蓝牙耳机", help="Text to translate") | |
| 16 | + parser.add_argument("--source-lang", default="zh", help="Source language") | |
| 17 | + parser.add_argument("--target-lang", default="en", help="Target language") | |
| 18 | + parser.add_argument("--model", default=None, help="Enabled translation capability name") | |
| 19 | + parser.add_argument("--scene", default="sku_name", help="Translation scene") | |
| 20 | + args = parser.parse_args() | |
| 21 | + | |
| 22 | + client = create_translation_client() | |
| 23 | + result: Optional[str] = client.translate( | |
| 24 | + text=args.text, | |
| 25 | + target_lang=args.target_lang, | |
| 26 | + source_lang=args.source_lang, | |
| 27 | + model=args.model, | |
| 28 | + scene=args.scene, | |
| 29 | + ) | |
| 30 | + payload = { | |
| 31 | + "text": args.text, | |
| 32 | + "source_lang": args.source_lang, | |
| 33 | + "target_lang": args.target_lang, | |
| 34 | + "model": args.model or client.default_model, | |
| 35 | + "scene": args.scene, | |
| 36 | + "translated_text": result, | |
| 37 | + } | |
| 38 | + print(json.dumps(payload, ensure_ascii=False, indent=2)) | |
| 39 | + | |
| 40 | + | |
| 41 | +if __name__ == "__main__": | |
| 260 | 42 | main() |
| 261 | - | ... | ... |
| ... | ... | @@ -0,0 +1,20 @@ |
| 1 | +# Dependencies for isolated translator service venv. | |
| 2 | + | |
| 3 | +pyyaml>=6.0 | |
| 4 | +python-dotenv>=1.0.0 | |
| 5 | +redis>=5.0.0 | |
| 6 | +numpy>=1.24.0 | |
| 7 | +openai>=1.0.0 | |
| 8 | +fastapi>=0.100.0 | |
| 9 | +uvicorn[standard]>=0.23.0 | |
| 10 | +pydantic>=2.0.0 | |
| 11 | +requests>=2.31.0 | |
| 12 | +httpx>=0.24.0 | |
| 13 | +tqdm>=4.65.0 | |
| 14 | + | |
| 15 | +torch>=2.0.0 | |
| 16 | +transformers>=4.30.0 | |
| 17 | +sentencepiece>=0.2.0 | |
| 18 | +sacremoses>=0.1.1 | |
| 19 | +safetensors>=0.4.0 | |
| 20 | +huggingface_hub>=0.24.0 | ... | ... |
| ... | ... | @@ -0,0 +1,61 @@ |
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Download local translation models declared in services.translation.capabilities.""" | |
| 3 | + | |
| 4 | +from __future__ import annotations | |
| 5 | + | |
| 6 | +import argparse | |
| 7 | +from pathlib import Path | |
| 8 | +import os | |
| 9 | +import sys | |
| 10 | +from typing import Iterable | |
| 11 | + | |
| 12 | +from huggingface_hub import snapshot_download | |
| 13 | + | |
| 14 | +PROJECT_ROOT = Path(__file__).resolve().parent.parent | |
| 15 | +if str(PROJECT_ROOT) not in sys.path: | |
| 16 | + sys.path.insert(0, str(PROJECT_ROOT)) | |
| 17 | +os.environ.setdefault("HF_HUB_DISABLE_XET", "1") | |
| 18 | + | |
| 19 | +from config.services_config import get_translation_config | |
| 20 | + | |
| 21 | + | |
| 22 | +LOCAL_BACKENDS = {"local_nllb", "local_marian"} | |
| 23 | + | |
| 24 | + | |
| 25 | +def iter_local_capabilities(selected: set[str] | None = None) -> Iterable[tuple[str, dict]]: | |
| 26 | + cfg = get_translation_config() | |
| 27 | + for name, capability in cfg.capabilities.items(): | |
| 28 | + backend = str(capability.get("backend") or "").strip().lower() | |
| 29 | + if backend not in LOCAL_BACKENDS: | |
| 30 | + continue | |
| 31 | + if selected and name not in selected: | |
| 32 | + continue | |
| 33 | + yield name, capability | |
| 34 | + | |
| 35 | + | |
| 36 | +def main() -> None: | |
| 37 | + parser = argparse.ArgumentParser(description="Download local translation models") | |
| 38 | + parser.add_argument("--all-local", action="store_true", help="Download all configured local translation models") | |
| 39 | + parser.add_argument("--models", nargs="*", default=[], help="Specific capability names to download") | |
| 40 | + args = parser.parse_args() | |
| 41 | + | |
| 42 | + selected = {item.strip().lower() for item in args.models if item.strip()} or None | |
| 43 | + if not args.all_local and not selected: | |
| 44 | + parser.error("pass --all-local or --models <name> ...") | |
| 45 | + | |
| 46 | + for name, capability in iter_local_capabilities(selected): | |
| 47 | + model_id = str(capability.get("model_id") or "").strip() | |
| 48 | + model_dir = Path(str(capability.get("model_dir") or "")).expanduser() | |
| 49 | + if not model_id or not model_dir: | |
| 50 | + raise ValueError(f"Capability '{name}' must define model_id and model_dir") | |
| 51 | + model_dir.parent.mkdir(parents=True, exist_ok=True) | |
| 52 | + print(f"[download] {name} -> {model_dir} ({model_id})") | |
| 53 | + snapshot_download( | |
| 54 | + repo_id=model_id, | |
| 55 | + local_dir=str(model_dir), | |
| 56 | + ) | |
| 57 | + print(f"[done] {name}") | |
| 58 | + | |
| 59 | + | |
| 60 | +if __name__ == "__main__": | |
| 61 | + main() | ... | ... |
| ... | ... | @@ -0,0 +1,43 @@ |
| 1 | +#!/bin/bash | |
| 2 | +# | |
| 3 | +# Create isolated venv for translator service (.venv-translator). | |
| 4 | +# | |
| 5 | +set -euo pipefail | |
| 6 | + | |
| 7 | +PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" | |
| 8 | +cd "${PROJECT_ROOT}" | |
| 9 | + | |
| 10 | +VENV_DIR="${PROJECT_ROOT}/.venv-translator" | |
| 11 | +PYTHON_BIN="${PYTHON_BIN:-python3}" | |
| 12 | +TMP_DIR="${TRANSLATOR_PIP_TMPDIR:-${PROJECT_ROOT}/.tmp/translator-pip}" | |
| 13 | + | |
| 14 | +if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then | |
| 15 | + echo "ERROR: python not found: ${PYTHON_BIN}" >&2 | |
| 16 | + exit 1 | |
| 17 | +fi | |
| 18 | + | |
| 19 | +if [[ -d "${VENV_DIR}" && ! -f "${VENV_DIR}/bin/activate" ]]; then | |
| 20 | + echo "Found broken venv at ${VENV_DIR}, recreating..." | |
| 21 | + rm -rf "${VENV_DIR}" | |
| 22 | +fi | |
| 23 | + | |
| 24 | +if [[ ! -d "${VENV_DIR}" ]]; then | |
| 25 | + echo "Creating ${VENV_DIR}" | |
| 26 | + "${PYTHON_BIN}" -m venv "${VENV_DIR}" | |
| 27 | +else | |
| 28 | + echo "Reusing ${VENV_DIR}" | |
| 29 | +fi | |
| 30 | + | |
| 31 | +mkdir -p "${TMP_DIR}" | |
| 32 | +export TMPDIR="${TMP_DIR}" | |
| 33 | +PIP_ARGS=(--no-cache-dir) | |
| 34 | + | |
| 35 | +echo "Using TMPDIR=${TMPDIR}" | |
| 36 | +"${VENV_DIR}/bin/python" -m pip install "${PIP_ARGS[@]}" --upgrade pip wheel | |
| 37 | +"${VENV_DIR}/bin/python" -m pip install "${PIP_ARGS[@]}" -r requirements_translator_service.txt | |
| 38 | + | |
| 39 | +echo | |
| 40 | +echo "Done." | |
| 41 | +echo "Translator venv: ${VENV_DIR}" | |
| 42 | +echo "Download local models: ./.venv-translator/bin/python scripts/download_translation_models.py --all-local" | |
| 43 | +echo "Start service: ./scripts/start_translator.sh" | ... | ... |
scripts/start_translator.sh
| 1 | 1 | #!/bin/bash |
| 2 | 2 | # |
| 3 | -# Start Translation Service | |
| 3 | +# Start Translation Service (port 6006). | |
| 4 | +# | |
| 5 | +# Design: | |
| 6 | +# - Run in isolated venv `.venv-translator` | |
| 7 | +# - Load enabled translation capabilities at startup | |
| 8 | +# - Local models should be downloaded ahead of time into configured model_dir | |
| 4 | 9 | # |
| 5 | - | |
| 6 | 10 | set -euo pipefail |
| 7 | 11 | |
| 8 | -cd "$(dirname "$0")/.." | |
| 9 | -source ./activate.sh | |
| 12 | +PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" | |
| 13 | +cd "${PROJECT_ROOT}" | |
| 14 | + | |
| 15 | +TRANSLATOR_VENV="${TRANSLATOR_VENV:-${PROJECT_ROOT}/.venv-translator}" | |
| 16 | +PYTHON_BIN="${TRANSLATOR_VENV}/bin/python" | |
| 17 | + | |
| 18 | +if [[ ! -x "${PYTHON_BIN}" ]]; then | |
| 19 | + echo "ERROR: translator venv not found: ${TRANSLATOR_VENV}" >&2 | |
| 20 | + echo "Please run: ./scripts/setup_translator_venv.sh" >&2 | |
| 21 | + exit 1 | |
| 22 | +fi | |
| 23 | + | |
| 24 | +# shellcheck source=scripts/lib/load_env.sh | |
| 25 | +source "${PROJECT_ROOT}/scripts/lib/load_env.sh" | |
| 26 | +load_env_file "${PROJECT_ROOT}/.env" | |
| 10 | 27 | |
| 11 | 28 | TRANSLATION_HOST="${TRANSLATION_HOST:-0.0.0.0}" |
| 12 | 29 | TRANSLATION_PORT="${TRANSLATION_PORT:-6006}" |
| 30 | +DEFAULT_MODEL=$("${PYTHON_BIN}" -c "from config.services_config import get_translation_config; print(get_translation_config()['default_model'])") | |
| 31 | +ENABLED_MODELS=$("${PYTHON_BIN}" -c "from config.services_config import get_translation_config; from translation.settings import get_enabled_translation_models; print(','.join(get_enabled_translation_models(get_translation_config())))") | |
| 13 | 32 | |
| 14 | 33 | echo "========================================" |
| 15 | 34 | echo "Starting Translation Service" |
| 16 | 35 | echo "========================================" |
| 36 | +echo "Python: ${PYTHON_BIN}" | |
| 17 | 37 | echo "Host: ${TRANSLATION_HOST}" |
| 18 | 38 | echo "Port: ${TRANSLATION_PORT}" |
| 19 | -echo "Default model: ${TRANSLATION_MODEL:-qwen}" | |
| 39 | +echo "Default model: ${DEFAULT_MODEL}" | |
| 40 | +echo "Enabled models: ${ENABLED_MODELS}" | |
| 41 | +echo | |
| 42 | +echo "Tips:" | |
| 43 | +echo " - Use a single worker so local models are loaded once." | |
| 44 | +echo " - Download local models first if you enable them in config." | |
| 20 | 45 | echo |
| 21 | 46 | |
| 22 | -exec python -m uvicorn api.translator_app:app \ | |
| 47 | +exec "${PYTHON_BIN}" -m uvicorn api.translator_app:app \ | |
| 23 | 48 | --host "${TRANSLATION_HOST}" \ |
| 24 | 49 | --port "${TRANSLATION_PORT}" \ |
| 25 | 50 | --workers 1 | ... | ... |
tests/ci/test_service_api_contracts.py
| ... | ... | @@ -9,6 +9,7 @@ import numpy as np |
| 9 | 9 | import pandas as pd |
| 10 | 10 | import pytest |
| 11 | 11 | from fastapi.testclient import TestClient |
| 12 | +from translation.scenes import normalize_scene_name | |
| 12 | 13 | |
| 13 | 14 | |
| 14 | 15 | class _FakeSearcher: |
| ... | ... | @@ -571,18 +572,34 @@ def test_embedding_image_contract(embedding_module): |
| 571 | 572 | |
| 572 | 573 | |
| 573 | 574 | class _FakeTranslator: |
| 574 | - model = "qwen" | |
| 575 | - use_cache = True | |
| 576 | - | |
| 577 | - def translate(self, text: str, target_lang: str, source_lang: str | None = None, prompt: str | None = None): | |
| 575 | + model = "qwen-mt" | |
| 576 | + supports_batch = True | |
| 577 | + | |
| 578 | + def translate( | |
| 579 | + self, | |
| 580 | + text: str | List[str], | |
| 581 | + target_lang: str, | |
| 582 | + source_lang: str | None = None, | |
| 583 | + scene: str | None = None, | |
| 584 | + ): | |
| 585 | + del source_lang, scene | |
| 586 | + if isinstance(text, list): | |
| 587 | + return [f"{item}-{target_lang}" for item in text] | |
| 578 | 588 | return f"{text}-{target_lang}" |
| 579 | 589 | |
| 580 | 590 | |
| 581 | 591 | class _FailingTranslator: |
| 582 | - model = "qwen" | |
| 583 | - use_cache = True | |
| 584 | - | |
| 585 | - def translate(self, text: str, target_lang: str, source_lang: str | None = None, prompt: str | None = None): | |
| 592 | + model = "qwen-mt" | |
| 593 | + supports_batch = True | |
| 594 | + | |
| 595 | + def translate( | |
| 596 | + self, | |
| 597 | + text: str | List[str], | |
| 598 | + target_lang: str, | |
| 599 | + source_lang: str | None = None, | |
| 600 | + scene: str | None = None, | |
| 601 | + ): | |
| 602 | + del text, target_lang, source_lang, scene | |
| 586 | 603 | return None |
| 587 | 604 | |
| 588 | 605 | |
| ... | ... | @@ -591,7 +608,44 @@ def translator_client(monkeypatch): |
| 591 | 608 | import api.translator_app as translator_app |
| 592 | 609 | |
| 593 | 610 | translator_app.app.router.on_startup.clear() |
| 594 | - monkeypatch.setattr(translator_app, "get_translator", lambda model="qwen": _FakeTranslator()) | |
| 611 | + | |
| 612 | + class _FakeService: | |
| 613 | + def __init__(self, translator): | |
| 614 | + self._translator = translator | |
| 615 | + self.config = { | |
| 616 | + "default_model": "qwen-mt", | |
| 617 | + "default_scene": "general", | |
| 618 | + "capabilities": { | |
| 619 | + "qwen-mt": { | |
| 620 | + "enabled": True, | |
| 621 | + "backend": "qwen_mt", | |
| 622 | + "model": "qwen-mt-flash", | |
| 623 | + "base_url": "https://example.com", | |
| 624 | + "timeout_sec": 10.0, | |
| 625 | + "use_cache": True, | |
| 626 | + } | |
| 627 | + }, | |
| 628 | + "cache": { | |
| 629 | + "enabled": True, | |
| 630 | + "key_prefix": "trans:v2", | |
| 631 | + "ttl_seconds": 60, | |
| 632 | + "sliding_expiration": True, | |
| 633 | + "key_include_scene": True, | |
| 634 | + "key_include_source_lang": True, | |
| 635 | + }, | |
| 636 | + } | |
| 637 | + self.available_models = ["qwen-mt"] | |
| 638 | + self.loaded_models = ["qwen-mt"] | |
| 639 | + | |
| 640 | + def get_backend(self, model=None): | |
| 641 | + del model | |
| 642 | + return self._translator | |
| 643 | + | |
| 644 | + def translate(self, **kwargs): | |
| 645 | + kwargs.pop("model", None) | |
| 646 | + return self._translator.translate(**kwargs) | |
| 647 | + | |
| 648 | + monkeypatch.setattr(translator_app, "get_translation_service", lambda: _FakeService(_FakeTranslator())) | |
| 595 | 649 | |
| 596 | 650 | with TestClient(translator_app.app) as client: |
| 597 | 651 | yield client |
| ... | ... | @@ -610,7 +664,44 @@ def test_translator_api_failure_returns_500(monkeypatch): |
| 610 | 664 | import api.translator_app as translator_app |
| 611 | 665 | |
| 612 | 666 | translator_app.app.router.on_startup.clear() |
| 613 | - monkeypatch.setattr(translator_app, "get_translator", lambda model="qwen": _FailingTranslator()) | |
| 667 | + | |
| 668 | + class _FakeService: | |
| 669 | + def __init__(self, translator): | |
| 670 | + self._translator = translator | |
| 671 | + self.config = { | |
| 672 | + "default_model": "qwen-mt", | |
| 673 | + "default_scene": "general", | |
| 674 | + "capabilities": { | |
| 675 | + "qwen-mt": { | |
| 676 | + "enabled": True, | |
| 677 | + "backend": "qwen_mt", | |
| 678 | + "model": "qwen-mt-flash", | |
| 679 | + "base_url": "https://example.com", | |
| 680 | + "timeout_sec": 10.0, | |
| 681 | + "use_cache": True, | |
| 682 | + } | |
| 683 | + }, | |
| 684 | + "cache": { | |
| 685 | + "enabled": True, | |
| 686 | + "key_prefix": "trans:v2", | |
| 687 | + "ttl_seconds": 60, | |
| 688 | + "sliding_expiration": True, | |
| 689 | + "key_include_scene": True, | |
| 690 | + "key_include_source_lang": True, | |
| 691 | + }, | |
| 692 | + } | |
| 693 | + self.available_models = ["qwen-mt"] | |
| 694 | + self.loaded_models = ["qwen-mt"] | |
| 695 | + | |
| 696 | + def get_backend(self, model=None): | |
| 697 | + del model | |
| 698 | + return self._translator | |
| 699 | + | |
| 700 | + def translate(self, **kwargs): | |
| 701 | + kwargs.pop("model", None) | |
| 702 | + return self._translator.translate(**kwargs) | |
| 703 | + | |
| 704 | + monkeypatch.setattr(translator_app, "get_translation_service", lambda: _FakeService(_FailingTranslator())) | |
| 614 | 705 | |
| 615 | 706 | with TestClient(translator_app.app) as client: |
| 616 | 707 | response = client.post( |
| ... | ... | @@ -626,6 +717,7 @@ def test_translator_health_contract(translator_client: TestClient): |
| 626 | 717 | response = translator_client.get("/health") |
| 627 | 718 | assert response.status_code == 200 |
| 628 | 719 | assert response.json()["status"] == "healthy" |
| 720 | + assert response.json()["loaded_models"] == ["qwen-mt"] | |
| 629 | 721 | |
| 630 | 722 | |
| 631 | 723 | class _FakeReranker: | ... | ... |
| ... | ... | @@ -0,0 +1,170 @@ |
| 1 | +import torch | |
| 2 | + | |
| 3 | +from translation.backends.local_seq2seq import MarianMTTranslationBackend, NLLBTranslationBackend | |
| 4 | +from translation.service import TranslationService | |
| 5 | + | |
| 6 | + | |
| 7 | +class _FakeBatch(dict): | |
| 8 | + def to(self, device): | |
| 9 | + self["device"] = device | |
| 10 | + return self | |
| 11 | + | |
| 12 | + | |
| 13 | +class _FakeTokenizer: | |
| 14 | + def __init__(self): | |
| 15 | + self.src_lang = None | |
| 16 | + self.pad_token = "</s>" | |
| 17 | + self.eos_token = "</s>" | |
| 18 | + self.lang_code_to_id = {"eng_Latn": 101, "zho_Hans": 202} | |
| 19 | + self.last_call = None | |
| 20 | + | |
| 21 | + def __call__(self, texts, **kwargs): | |
| 22 | + self.last_call = {"texts": list(texts), **kwargs} | |
| 23 | + return _FakeBatch({"input_ids": torch.tensor([[1, 2, 3]])}) | |
| 24 | + | |
| 25 | + def batch_decode(self, generated, skip_special_tokens=True): | |
| 26 | + del generated, skip_special_tokens | |
| 27 | + return ["translated" for _ in range(len(self.last_call["texts"]))] | |
| 28 | + | |
| 29 | + def convert_tokens_to_ids(self, token): | |
| 30 | + return self.lang_code_to_id[token] | |
| 31 | + | |
| 32 | + | |
| 33 | +class _FakeModel: | |
| 34 | + def to(self, device): | |
| 35 | + self.device = device | |
| 36 | + return self | |
| 37 | + | |
| 38 | + def eval(self): | |
| 39 | + return self | |
| 40 | + | |
| 41 | + def generate(self, **kwargs): | |
| 42 | + self.last_generate_kwargs = kwargs | |
| 43 | + return [[42]] | |
| 44 | + | |
| 45 | + | |
| 46 | +def _stub_load_model(self): | |
| 47 | + self.tokenizer = _FakeTokenizer() | |
| 48 | + self.seq2seq_model = _FakeModel() | |
| 49 | + | |
| 50 | + | |
| 51 | +def test_marian_language_validation(monkeypatch): | |
| 52 | + monkeypatch.setattr(MarianMTTranslationBackend, "_load_model", _stub_load_model) | |
| 53 | + backend = MarianMTTranslationBackend( | |
| 54 | + name="opus-mt-zh-en", | |
| 55 | + model_id="Helsinki-NLP/opus-mt-zh-en", | |
| 56 | + model_dir="./models/translation/Helsinki-NLP/opus-mt-zh-en", | |
| 57 | + device="cpu", | |
| 58 | + torch_dtype="float32", | |
| 59 | + batch_size=1, | |
| 60 | + max_input_length=16, | |
| 61 | + max_new_tokens=16, | |
| 62 | + num_beams=1, | |
| 63 | + source_langs=["zh"], | |
| 64 | + target_langs=["en"], | |
| 65 | + ) | |
| 66 | + | |
| 67 | + result = backend.translate("测试", source_lang="zh", target_lang="en") | |
| 68 | + assert result == "translated" | |
| 69 | + | |
| 70 | + try: | |
| 71 | + backend.translate("test", source_lang="en", target_lang="zh") | |
| 72 | + except ValueError as exc: | |
| 73 | + assert "source languages" in str(exc) | |
| 74 | + else: | |
| 75 | + raise AssertionError("Expected unsupported source language to raise") | |
| 76 | + | |
| 77 | + | |
| 78 | +def test_nllb_uses_src_lang_and_forced_bos(monkeypatch): | |
| 79 | + monkeypatch.setattr(NLLBTranslationBackend, "_load_model", _stub_load_model) | |
| 80 | + backend = NLLBTranslationBackend( | |
| 81 | + name="nllb-200-distilled-600m", | |
| 82 | + model_id="facebook/nllb-200-distilled-600M", | |
| 83 | + model_dir="./models/translation/facebook/nllb-200-distilled-600M", | |
| 84 | + device="cpu", | |
| 85 | + torch_dtype="float32", | |
| 86 | + batch_size=1, | |
| 87 | + max_input_length=16, | |
| 88 | + max_new_tokens=16, | |
| 89 | + num_beams=1, | |
| 90 | + ) | |
| 91 | + | |
| 92 | + result = backend.translate("test", source_lang="en", target_lang="zh") | |
| 93 | + | |
| 94 | + assert result == "translated" | |
| 95 | + assert backend.tokenizer.src_lang == "eng_Latn" | |
| 96 | + assert backend.seq2seq_model.last_generate_kwargs["forced_bos_token_id"] == 202 | |
| 97 | + | |
| 98 | + | |
| 99 | +def test_translation_service_lazy_loads_enabled_backends(monkeypatch): | |
| 100 | + created = [] | |
| 101 | + | |
| 102 | + def _fake_create_backend(self, *, name, backend_type, cfg): | |
| 103 | + del self, cfg | |
| 104 | + created.append((name, backend_type)) | |
| 105 | + | |
| 106 | + class _Backend: | |
| 107 | + model = name | |
| 108 | + | |
| 109 | + @property | |
| 110 | + def supports_batch(self): | |
| 111 | + return True | |
| 112 | + | |
| 113 | + def translate(self, text, target_lang, source_lang=None, scene=None): | |
| 114 | + del target_lang, source_lang, scene | |
| 115 | + return text | |
| 116 | + | |
| 117 | + return _Backend() | |
| 118 | + | |
| 119 | + monkeypatch.setattr(TranslationService, "_create_backend", _fake_create_backend) | |
| 120 | + config = { | |
| 121 | + "service_url": "http://127.0.0.1:6006", | |
| 122 | + "timeout_sec": 10.0, | |
| 123 | + "default_model": "opus-mt-en-zh", | |
| 124 | + "default_scene": "general", | |
| 125 | + "capabilities": { | |
| 126 | + "opus-mt-en-zh": { | |
| 127 | + "enabled": True, | |
| 128 | + "backend": "local_marian", | |
| 129 | + "model_id": "dummy", | |
| 130 | + "model_dir": "dummy", | |
| 131 | + "device": "cpu", | |
| 132 | + "torch_dtype": "float32", | |
| 133 | + "batch_size": 1, | |
| 134 | + "max_input_length": 8, | |
| 135 | + "max_new_tokens": 8, | |
| 136 | + "num_beams": 1, | |
| 137 | + }, | |
| 138 | + "nllb-200-distilled-600m": { | |
| 139 | + "enabled": True, | |
| 140 | + "backend": "local_nllb", | |
| 141 | + "model_id": "dummy", | |
| 142 | + "model_dir": "dummy", | |
| 143 | + "device": "cpu", | |
| 144 | + "torch_dtype": "float32", | |
| 145 | + "batch_size": 1, | |
| 146 | + "max_input_length": 8, | |
| 147 | + "max_new_tokens": 8, | |
| 148 | + "num_beams": 1, | |
| 149 | + }, | |
| 150 | + }, | |
| 151 | + "cache": { | |
| 152 | + "enabled": True, | |
| 153 | + "key_prefix": "trans:v2", | |
| 154 | + "ttl_seconds": 60, | |
| 155 | + "sliding_expiration": True, | |
| 156 | + "key_include_scene": True, | |
| 157 | + "key_include_source_lang": True, | |
| 158 | + }, | |
| 159 | + } | |
| 160 | + | |
| 161 | + service = TranslationService(config) | |
| 162 | + | |
| 163 | + assert service.available_models == ["opus-mt-en-zh", "nllb-200-distilled-600m"] | |
| 164 | + assert service.loaded_models == [] | |
| 165 | + | |
| 166 | + backend = service.get_backend("opus-mt-en-zh") | |
| 167 | + | |
| 168 | + assert backend.model == "opus-mt-en-zh" | |
| 169 | + assert created == [("opus-mt-en-zh", "local_marian")] | |
| 170 | + assert service.loaded_models == ["opus-mt-en-zh"] | ... | ... |
tests/test_translator_failure_semantics.py
| 1 | -from query.qwen_mt_translate import Translator | |
| 1 | +from translation.backends.qwen_mt import QwenMTTranslationBackend | |
| 2 | 2 | |
| 3 | 3 | |
| 4 | 4 | class _RecordingRedis: |
| ... | ... | @@ -10,7 +10,13 @@ class _RecordingRedis: |
| 10 | 10 | |
| 11 | 11 | |
| 12 | 12 | def test_translate_failure_returns_none_and_skips_cache(monkeypatch): |
| 13 | - translator = Translator(model="qwen", api_key="dummy-key", use_cache=False) | |
| 13 | + translator = QwenMTTranslationBackend( | |
| 14 | + capability_name="qwen-mt", | |
| 15 | + model="qwen-mt-flash", | |
| 16 | + base_url="https://dashscope-us.aliyuncs.com/compatible-mode/v1", | |
| 17 | + api_key="dummy-key", | |
| 18 | + use_cache=False, | |
| 19 | + ) | |
| 14 | 20 | fake_redis = _RecordingRedis() |
| 15 | 21 | translator.use_cache = True |
| 16 | 22 | translator.redis_client = fake_redis |
| ... | ... | @@ -23,7 +29,7 @@ def test_translate_failure_returns_none_and_skips_cache(monkeypatch): |
| 23 | 29 | text="商品标题", |
| 24 | 30 | target_lang="en", |
| 25 | 31 | source_lang="zh", |
| 26 | - prompt="translate for product search", | |
| 32 | + scene="sku_name", | |
| 27 | 33 | ) |
| 28 | 34 | |
| 29 | 35 | assert result is None | ... | ... |
translation/__init__.py
| 1 | 1 | """Translation package.""" |
| 2 | 2 | |
| 3 | -__all__ = [ | |
| 4 | - "client", | |
| 5 | - "service", | |
| 6 | - "protocols", | |
| 7 | - "backends", | |
| 8 | -] | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from typing import Any | |
| 6 | + | |
| 7 | +__all__ = ["TranslationServiceClient", "create_translation_client", "TranslationService"] | |
| 8 | + | |
| 9 | + | |
| 10 | +def __getattr__(name: str) -> Any: | |
| 11 | + if name in {"TranslationServiceClient", "create_translation_client"}: | |
| 12 | + from .client import TranslationServiceClient, create_translation_client | |
| 13 | + | |
| 14 | + exports = { | |
| 15 | + "TranslationServiceClient": TranslationServiceClient, | |
| 16 | + "create_translation_client": create_translation_client, | |
| 17 | + } | |
| 18 | + return exports[name] | |
| 19 | + if name == "TranslationService": | |
| 20 | + from .service import TranslationService | |
| 21 | + | |
| 22 | + return TranslationService | |
| 23 | + raise AttributeError(name) | ... | ... |
translation/backends/__init__.py
| 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 | -] | |
| 1 | +"""Translation backend implementations.""" | ... | ... |
translation/backends/deepl.py
| ... | ... | @@ -5,81 +5,30 @@ from __future__ import annotations |
| 5 | 5 | import logging |
| 6 | 6 | import os |
| 7 | 7 | import re |
| 8 | -from typing import Dict, List, Optional, Sequence, Tuple, Union | |
| 8 | +from typing import List, Optional, Sequence, Tuple, Union | |
| 9 | 9 | |
| 10 | 10 | import requests |
| 11 | 11 | |
| 12 | -from config.services_config import get_translation_config | |
| 12 | +from translation.languages import DEEPL_LANGUAGE_CODES | |
| 13 | +from translation.scenes import SCENE_DEEPL_CONTEXTS, normalize_scene_name | |
| 13 | 14 | |
| 14 | 15 | logger = logging.getLogger(__name__) |
| 15 | 16 | |
| 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 | 17 | |
| 54 | 18 | 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 | 19 | def __init__( |
| 70 | 20 | self, |
| 71 | 21 | api_key: Optional[str], |
| 72 | 22 | *, |
| 73 | - timeout: float = 10.0, | |
| 23 | + api_url: str, | |
| 24 | + timeout: float, | |
| 74 | 25 | glossary_id: Optional[str] = None, |
| 75 | 26 | ) -> None: |
| 76 | - cfg = get_translation_config() | |
| 77 | - provider_cfg = cfg.get_capability_cfg("deepl") | |
| 78 | 27 | 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") | |
| 28 | + self.api_url = api_url | |
| 29 | + self.timeout = float(timeout) | |
| 30 | + self.glossary_id = glossary_id | |
| 81 | 31 | self.model = "deepl" |
| 82 | - self.context_presets = _merge_contexts(provider_cfg.get("contexts")) | |
| 83 | 32 | if not self.api_key: |
| 84 | 33 | logger.warning("DEEPL_AUTH_KEY not set; DeepL translation is unavailable") |
| 85 | 34 | |
| ... | ... | @@ -90,19 +39,13 @@ class DeepLTranslationBackend: |
| 90 | 39 | def _resolve_request_context( |
| 91 | 40 | self, |
| 92 | 41 | target_lang: str, |
| 93 | - context: Optional[str], | |
| 94 | - prompt: Optional[str], | |
| 42 | + scene: Optional[str], | |
| 95 | 43 | ) -> 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() | |
| 44 | + if scene is None: | |
| 45 | + raise ValueError("deepl translation scene is required") | |
| 46 | + normalized_scene = normalize_scene_name(scene) | |
| 47 | + scene_map = SCENE_DEEPL_CONTEXTS[normalized_scene] | |
| 48 | + tgt = str(target_lang or "").strip().lower() | |
| 106 | 49 | return scene_map.get(tgt) or scene_map.get("en") |
| 107 | 50 | |
| 108 | 51 | def translate( |
| ... | ... | @@ -110,8 +53,7 @@ class DeepLTranslationBackend: |
| 110 | 53 | text: Union[str, Sequence[str]], |
| 111 | 54 | target_lang: str, |
| 112 | 55 | source_lang: Optional[str] = None, |
| 113 | - context: Optional[str] = None, | |
| 114 | - prompt: Optional[str] = None, | |
| 56 | + scene: Optional[str] = None, | |
| 115 | 57 | ) -> Union[Optional[str], List[Optional[str]]]: |
| 116 | 58 | if isinstance(text, (list, tuple)): |
| 117 | 59 | results: List[Optional[str]] = [] |
| ... | ... | @@ -123,8 +65,7 @@ class DeepLTranslationBackend: |
| 123 | 65 | text=str(item), |
| 124 | 66 | target_lang=target_lang, |
| 125 | 67 | source_lang=source_lang, |
| 126 | - context=context, | |
| 127 | - prompt=prompt, | |
| 68 | + scene=scene, | |
| 128 | 69 | ) |
| 129 | 70 | results.append(out) |
| 130 | 71 | return results |
| ... | ... | @@ -132,13 +73,13 @@ class DeepLTranslationBackend: |
| 132 | 73 | if not self.api_key: |
| 133 | 74 | return None |
| 134 | 75 | |
| 135 | - target_code = self.LANG_CODE_MAP.get((target_lang or "").lower(), (target_lang or "").upper()) | |
| 76 | + target_code = DEEPL_LANGUAGE_CODES.get((target_lang or "").lower(), (target_lang or "").upper()) | |
| 136 | 77 | headers = { |
| 137 | 78 | "Authorization": f"DeepL-Auth-Key {self.api_key}", |
| 138 | 79 | "Content-Type": "application/json", |
| 139 | 80 | } |
| 140 | 81 | |
| 141 | - api_context = self._resolve_request_context(target_lang, context, prompt) | |
| 82 | + api_context = self._resolve_request_context(target_lang, scene) | |
| 142 | 83 | text_to_translate, needs_extraction = self._add_ecommerce_context(text, source_lang, api_context) |
| 143 | 84 | |
| 144 | 85 | payload = { |
| ... | ... | @@ -146,14 +87,14 @@ class DeepLTranslationBackend: |
| 146 | 87 | "target_lang": target_code, |
| 147 | 88 | } |
| 148 | 89 | if source_lang: |
| 149 | - payload["source_lang"] = self.LANG_CODE_MAP.get(source_lang.lower(), source_lang.upper()) | |
| 90 | + payload["source_lang"] = DEEPL_LANGUAGE_CODES.get(source_lang.lower(), source_lang.upper()) | |
| 150 | 91 | if api_context: |
| 151 | 92 | payload["context"] = api_context |
| 152 | 93 | if self.glossary_id: |
| 153 | 94 | payload["glossary_id"] = self.glossary_id |
| 154 | 95 | |
| 155 | 96 | try: |
| 156 | - response = requests.post(self.API_URL, headers=headers, json=payload, timeout=self.timeout) | |
| 97 | + response = requests.post(self.api_url, headers=headers, json=payload, timeout=self.timeout) | |
| 157 | 98 | if response.status_code != 200: |
| 158 | 99 | logger.warning( |
| 159 | 100 | "[deepl] Failed | status=%s tgt=%s body=%s", |
| ... | ... | @@ -184,9 +125,9 @@ class DeepLTranslationBackend: |
| 184 | 125 | self, |
| 185 | 126 | text: str, |
| 186 | 127 | source_lang: Optional[str], |
| 187 | - context: Optional[str], | |
| 128 | + scene: Optional[str], | |
| 188 | 129 | ) -> Tuple[str, bool]: |
| 189 | - if not context or "e-commerce" not in context.lower(): | |
| 130 | + if not scene or "e-commerce" not in scene.lower(): | |
| 190 | 131 | return text, False |
| 191 | 132 | if (source_lang or "").lower() != "zh": |
| 192 | 133 | return text, False |
| ... | ... | @@ -215,6 +156,3 @@ class DeepLTranslationBackend: |
| 215 | 156 | if normalized not in context_words: |
| 216 | 157 | return normalized |
| 217 | 158 | return re.sub(r"[.,!?;:]+$", "", words[-1].lower()) |
| 218 | - | |
| 219 | - | |
| 220 | -DeepLProvider = DeepLTranslationBackend | ... | ... |
translation/backends/llm.py
| ... | ... | @@ -10,15 +10,12 @@ from typing import List, Optional, Sequence, Union |
| 10 | 10 | from openai import OpenAI |
| 11 | 11 | |
| 12 | 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 | |
| 13 | +from translation.languages import LANGUAGE_LABELS | |
| 14 | +from translation.prompts import TRANSLATION_PROMPTS | |
| 15 | +from translation.scenes import normalize_scene_name | |
| 16 | 16 | |
| 17 | 17 | logger = logging.getLogger(__name__) |
| 18 | 18 | |
| 19 | -DEFAULT_QWEN_BASE_URL = "https://dashscope-us.aliyuncs.com/compatible-mode/v1" | |
| 20 | -DEFAULT_LLM_MODEL = "qwen-flash" | |
| 21 | - | |
| 22 | 19 | |
| 23 | 20 | def _build_prompt( |
| 24 | 21 | text: str, |
| ... | ... | @@ -27,25 +24,16 @@ def _build_prompt( |
| 27 | 24 | target_lang: str, |
| 28 | 25 | scene: Optional[str], |
| 29 | 26 | ) -> 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"] | |
| 27 | + tgt = str(target_lang or "").strip().lower() | |
| 28 | + src = str(source_lang or "auto").strip().lower() or "auto" | |
| 29 | + normalized_scene = normalize_scene_name(scene) | |
| 30 | + group = TRANSLATION_PROMPTS[normalized_scene] | |
| 40 | 31 | 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 | - ) | |
| 32 | + if template is None: | |
| 33 | + raise ValueError(f"Missing llm translation prompt for scene='{normalized_scene}' target_lang='{tgt}'") | |
| 46 | 34 | |
| 47 | - source_lang_label = SOURCE_LANG_CODE_MAP.get(src, src) | |
| 48 | - target_lang_label = SOURCE_LANG_CODE_MAP.get(tgt, tgt) | |
| 35 | + source_lang_label = LANGUAGE_LABELS.get(src, src) | |
| 36 | + target_lang_label = LANGUAGE_LABELS.get(tgt, tgt) | |
| 49 | 37 | |
| 50 | 38 | return template.format( |
| 51 | 39 | source_lang=source_lang_label, |
| ... | ... | @@ -60,20 +48,15 @@ class LLMTranslationBackend: |
| 60 | 48 | def __init__( |
| 61 | 49 | self, |
| 62 | 50 | *, |
| 63 | - model: Optional[str] = None, | |
| 64 | - timeout_sec: float = 30.0, | |
| 65 | - base_url: Optional[str] = None, | |
| 51 | + capability_name: str, | |
| 52 | + model: str, | |
| 53 | + timeout_sec: float, | |
| 54 | + base_url: str, | |
| 66 | 55 | ) -> 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 | - ) | |
| 56 | + self.capability_name = capability_name | |
| 57 | + self.model = model | |
| 58 | + self.timeout_sec = float(timeout_sec) | |
| 59 | + self.base_url = base_url | |
| 77 | 60 | self.client = self._create_client() |
| 78 | 61 | |
| 79 | 62 | @property |
| ... | ... | @@ -96,22 +79,23 @@ class LLMTranslationBackend: |
| 96 | 79 | text: str, |
| 97 | 80 | target_lang: str, |
| 98 | 81 | source_lang: Optional[str] = None, |
| 99 | - context: Optional[str] = None, | |
| 100 | - prompt: Optional[str] = None, | |
| 82 | + scene: Optional[str] = None, | |
| 101 | 83 | ) -> Optional[str]: |
| 102 | 84 | if not text or not str(text).strip(): |
| 103 | 85 | return text |
| 104 | 86 | if not self.client: |
| 105 | 87 | return None |
| 106 | 88 | |
| 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( | |
| 89 | + tgt = str(target_lang or "").strip().lower() | |
| 90 | + src = str(source_lang or "auto").strip().lower() or "auto" | |
| 91 | + if scene is None: | |
| 92 | + raise ValueError("llm translation scene is required") | |
| 93 | + normalized_scene = normalize_scene_name(scene) | |
| 94 | + user_prompt = _build_prompt( | |
| 111 | 95 | text=text, |
| 112 | 96 | source_lang=src, |
| 113 | 97 | target_lang=tgt, |
| 114 | - scene=scene, | |
| 98 | + scene=normalized_scene, | |
| 115 | 99 | ) |
| 116 | 100 | start = time.time() |
| 117 | 101 | try: |
| ... | ... | @@ -158,8 +142,7 @@ class LLMTranslationBackend: |
| 158 | 142 | text: Union[str, Sequence[str]], |
| 159 | 143 | target_lang: str, |
| 160 | 144 | source_lang: Optional[str] = None, |
| 161 | - context: Optional[str] = None, | |
| 162 | - prompt: Optional[str] = None, | |
| 145 | + scene: Optional[str] = None, | |
| 163 | 146 | ) -> Union[Optional[str], List[Optional[str]]]: |
| 164 | 147 | if isinstance(text, (list, tuple)): |
| 165 | 148 | results: List[Optional[str]] = [] |
| ... | ... | @@ -172,8 +155,7 @@ class LLMTranslationBackend: |
| 172 | 155 | text=str(item), |
| 173 | 156 | target_lang=target_lang, |
| 174 | 157 | source_lang=source_lang, |
| 175 | - context=context, | |
| 176 | - prompt=prompt, | |
| 158 | + scene=scene, | |
| 177 | 159 | ) |
| 178 | 160 | ) |
| 179 | 161 | return results |
| ... | ... | @@ -182,28 +164,5 @@ class LLMTranslationBackend: |
| 182 | 164 | text=str(text), |
| 183 | 165 | target_lang=target_lang, |
| 184 | 166 | source_lang=source_lang, |
| 185 | - context=context, | |
| 186 | - prompt=prompt, | |
| 167 | + scene=scene, | |
| 187 | 168 | ) |
| 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,277 @@ |
| 1 | +"""Local seq2seq translation backends powered by Transformers.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import logging | |
| 6 | +import os | |
| 7 | +import threading | |
| 8 | +from typing import Dict, List, Optional, Sequence, Union | |
| 9 | + | |
| 10 | +import torch | |
| 11 | +from transformers import AutoModelForSeq2SeqLM, AutoTokenizer | |
| 12 | + | |
| 13 | +from translation.languages import MARIAN_LANGUAGE_DIRECTIONS, NLLB_LANGUAGE_CODES | |
| 14 | + | |
| 15 | +logger = logging.getLogger(__name__) | |
| 16 | + | |
| 17 | + | |
| 18 | +def _resolve_device(device: Optional[str]) -> str: | |
| 19 | + value = str(device or "auto").strip().lower() | |
| 20 | + if value == "auto": | |
| 21 | + return "cuda" if torch.cuda.is_available() else "cpu" | |
| 22 | + return value | |
| 23 | + | |
| 24 | + | |
| 25 | +def _resolve_dtype(dtype: Optional[str], device: str) -> Optional[torch.dtype]: | |
| 26 | + value = str(dtype or "auto").strip().lower() | |
| 27 | + if value == "auto": | |
| 28 | + return torch.float16 if device.startswith("cuda") else None | |
| 29 | + if value in {"float16", "fp16", "half"}: | |
| 30 | + return torch.float16 if device.startswith("cuda") else None | |
| 31 | + if value in {"bfloat16", "bf16"}: | |
| 32 | + return torch.bfloat16 | |
| 33 | + if value in {"float32", "fp32"}: | |
| 34 | + return torch.float32 | |
| 35 | + raise ValueError(f"Unsupported torch dtype: {dtype}") | |
| 36 | + | |
| 37 | + | |
| 38 | +class LocalSeq2SeqTranslationBackend: | |
| 39 | + """Base backend for local Hugging Face seq2seq translation models.""" | |
| 40 | + | |
| 41 | + def __init__( | |
| 42 | + self, | |
| 43 | + *, | |
| 44 | + name: str, | |
| 45 | + model_id: str, | |
| 46 | + model_dir: str, | |
| 47 | + device: str, | |
| 48 | + torch_dtype: str, | |
| 49 | + batch_size: int, | |
| 50 | + max_input_length: int, | |
| 51 | + max_new_tokens: int, | |
| 52 | + num_beams: int, | |
| 53 | + ) -> None: | |
| 54 | + self.model = name | |
| 55 | + self.model_id = model_id | |
| 56 | + self.model_dir = model_dir | |
| 57 | + self.device = _resolve_device(device) | |
| 58 | + self.torch_dtype = _resolve_dtype(torch_dtype, self.device) | |
| 59 | + self.batch_size = int(batch_size) | |
| 60 | + self.max_input_length = int(max_input_length) | |
| 61 | + self.max_new_tokens = int(max_new_tokens) | |
| 62 | + self.num_beams = int(num_beams) | |
| 63 | + self._lock = threading.Lock() | |
| 64 | + self._load_model() | |
| 65 | + | |
| 66 | + @property | |
| 67 | + def supports_batch(self) -> bool: | |
| 68 | + return True | |
| 69 | + | |
| 70 | + def _load_model(self) -> None: | |
| 71 | + model_path = self.model_dir if os.path.exists(self.model_dir) else self.model_id | |
| 72 | + logger.info( | |
| 73 | + "Loading local translation model | name=%s source=%s device=%s dtype=%s", | |
| 74 | + self.model, | |
| 75 | + model_path, | |
| 76 | + self.device, | |
| 77 | + self.torch_dtype, | |
| 78 | + ) | |
| 79 | + tokenizer_kwargs = self._tokenizer_kwargs() | |
| 80 | + model_kwargs = self._model_kwargs() | |
| 81 | + self.tokenizer = AutoTokenizer.from_pretrained(model_path, **tokenizer_kwargs) | |
| 82 | + self.seq2seq_model = AutoModelForSeq2SeqLM.from_pretrained(model_path, **model_kwargs) | |
| 83 | + self.seq2seq_model.to(self.device) | |
| 84 | + self.seq2seq_model.eval() | |
| 85 | + if self.tokenizer.pad_token is None and self.tokenizer.eos_token is not None: | |
| 86 | + self.tokenizer.pad_token = self.tokenizer.eos_token | |
| 87 | + | |
| 88 | + def _tokenizer_kwargs(self) -> Dict[str, object]: | |
| 89 | + return {} | |
| 90 | + | |
| 91 | + def _model_kwargs(self) -> Dict[str, object]: | |
| 92 | + kwargs: Dict[str, object] = {} | |
| 93 | + if self.torch_dtype is not None: | |
| 94 | + kwargs["dtype"] = self.torch_dtype | |
| 95 | + return kwargs | |
| 96 | + | |
| 97 | + def _normalize_texts(self, text: Union[str, Sequence[str]]) -> List[str]: | |
| 98 | + if isinstance(text, str): | |
| 99 | + return [text] | |
| 100 | + return ["" if item is None else str(item) for item in text] | |
| 101 | + | |
| 102 | + def _validate_languages(self, source_lang: Optional[str], target_lang: str) -> None: | |
| 103 | + del source_lang, target_lang | |
| 104 | + | |
| 105 | + def _prepare_tokenizer(self, source_lang: Optional[str], target_lang: str) -> Dict[str, object]: | |
| 106 | + del source_lang, target_lang | |
| 107 | + return {} | |
| 108 | + | |
| 109 | + def _build_generate_kwargs(self, source_lang: Optional[str], target_lang: str) -> Dict[str, object]: | |
| 110 | + del source_lang, target_lang | |
| 111 | + return { | |
| 112 | + "num_beams": self.num_beams, | |
| 113 | + } | |
| 114 | + | |
| 115 | + def _translate_batch( | |
| 116 | + self, | |
| 117 | + texts: List[str], | |
| 118 | + target_lang: str, | |
| 119 | + source_lang: Optional[str] = None, | |
| 120 | + ) -> List[Optional[str]]: | |
| 121 | + self._validate_languages(source_lang, target_lang) | |
| 122 | + tokenizer_kwargs = self._prepare_tokenizer(source_lang, target_lang) | |
| 123 | + with self._lock, torch.inference_mode(): | |
| 124 | + encoded = self.tokenizer( | |
| 125 | + texts, | |
| 126 | + return_tensors="pt", | |
| 127 | + padding=True, | |
| 128 | + truncation=True, | |
| 129 | + max_length=self.max_input_length, | |
| 130 | + **tokenizer_kwargs, | |
| 131 | + ) | |
| 132 | + encoded = {key: value.to(self.device) for key, value in encoded.items()} | |
| 133 | + generate_kwargs = self._build_generate_kwargs(source_lang, target_lang) | |
| 134 | + input_ids = encoded.get("input_ids") | |
| 135 | + if input_ids is not None and "max_length" not in generate_kwargs: | |
| 136 | + generate_kwargs["max_length"] = int(input_ids.shape[-1]) + self.max_new_tokens | |
| 137 | + generated = self.seq2seq_model.generate( | |
| 138 | + **encoded, | |
| 139 | + **generate_kwargs, | |
| 140 | + ) | |
| 141 | + outputs = self.tokenizer.batch_decode(generated, skip_special_tokens=True) | |
| 142 | + return [item.strip() if item and item.strip() else None for item in outputs] | |
| 143 | + | |
| 144 | + def translate( | |
| 145 | + self, | |
| 146 | + text: Union[str, Sequence[str]], | |
| 147 | + target_lang: str, | |
| 148 | + source_lang: Optional[str] = None, | |
| 149 | + scene: Optional[str] = None, | |
| 150 | + ) -> Union[Optional[str], List[Optional[str]]]: | |
| 151 | + del scene | |
| 152 | + is_single = isinstance(text, str) | |
| 153 | + texts = self._normalize_texts(text) | |
| 154 | + outputs: List[Optional[str]] = [] | |
| 155 | + for start in range(0, len(texts), self.batch_size): | |
| 156 | + chunk = texts[start:start + self.batch_size] | |
| 157 | + if not any(item.strip() for item in chunk): | |
| 158 | + outputs.extend([None if not item.strip() else item for item in chunk]) # type: ignore[list-item] | |
| 159 | + continue | |
| 160 | + outputs.extend(self._translate_batch(chunk, target_lang=target_lang, source_lang=source_lang)) | |
| 161 | + return outputs[0] if is_single else outputs | |
| 162 | + | |
| 163 | + | |
| 164 | +class MarianMTTranslationBackend(LocalSeq2SeqTranslationBackend): | |
| 165 | + """Local backend for Marian/OPUS MT models.""" | |
| 166 | + | |
| 167 | + def __init__( | |
| 168 | + self, | |
| 169 | + *, | |
| 170 | + name: str, | |
| 171 | + model_id: str, | |
| 172 | + model_dir: str, | |
| 173 | + device: str, | |
| 174 | + torch_dtype: str, | |
| 175 | + batch_size: int, | |
| 176 | + max_input_length: int, | |
| 177 | + max_new_tokens: int, | |
| 178 | + num_beams: int, | |
| 179 | + source_langs: Sequence[str], | |
| 180 | + target_langs: Sequence[str], | |
| 181 | + ) -> None: | |
| 182 | + self.source_langs = {str(lang).strip().lower() for lang in source_langs if str(lang).strip()} | |
| 183 | + self.target_langs = {str(lang).strip().lower() for lang in target_langs if str(lang).strip()} | |
| 184 | + super().__init__( | |
| 185 | + name=name, | |
| 186 | + model_id=model_id, | |
| 187 | + model_dir=model_dir, | |
| 188 | + device=device, | |
| 189 | + torch_dtype=torch_dtype, | |
| 190 | + batch_size=batch_size, | |
| 191 | + max_input_length=max_input_length, | |
| 192 | + max_new_tokens=max_new_tokens, | |
| 193 | + num_beams=num_beams, | |
| 194 | + ) | |
| 195 | + | |
| 196 | + def _validate_languages(self, source_lang: Optional[str], target_lang: str) -> None: | |
| 197 | + src = str(source_lang or "").strip().lower() | |
| 198 | + tgt = str(target_lang or "").strip().lower() | |
| 199 | + if self.source_langs and src not in self.source_langs: | |
| 200 | + raise ValueError( | |
| 201 | + f"Model '{self.model}' only supports source languages: {sorted(self.source_langs)}" | |
| 202 | + ) | |
| 203 | + if self.target_langs and tgt not in self.target_langs: | |
| 204 | + raise ValueError( | |
| 205 | + f"Model '{self.model}' only supports target languages: {sorted(self.target_langs)}" | |
| 206 | + ) | |
| 207 | + | |
| 208 | + | |
| 209 | +class NLLBTranslationBackend(LocalSeq2SeqTranslationBackend): | |
| 210 | + """Local backend for NLLB translation models.""" | |
| 211 | + | |
| 212 | + def __init__( | |
| 213 | + self, | |
| 214 | + *, | |
| 215 | + name: str, | |
| 216 | + model_id: str, | |
| 217 | + model_dir: str, | |
| 218 | + device: str, | |
| 219 | + torch_dtype: str, | |
| 220 | + batch_size: int, | |
| 221 | + max_input_length: int, | |
| 222 | + max_new_tokens: int, | |
| 223 | + num_beams: int, | |
| 224 | + language_codes: Optional[Dict[str, str]] = None, | |
| 225 | + ) -> None: | |
| 226 | + overrides = language_codes or {} | |
| 227 | + self.language_codes = { | |
| 228 | + **NLLB_LANGUAGE_CODES, | |
| 229 | + **{str(k).strip().lower(): str(v).strip() for k, v in overrides.items() if str(k).strip()}, | |
| 230 | + } | |
| 231 | + super().__init__( | |
| 232 | + name=name, | |
| 233 | + model_id=model_id, | |
| 234 | + model_dir=model_dir, | |
| 235 | + device=device, | |
| 236 | + torch_dtype=torch_dtype, | |
| 237 | + batch_size=batch_size, | |
| 238 | + max_input_length=max_input_length, | |
| 239 | + max_new_tokens=max_new_tokens, | |
| 240 | + num_beams=num_beams, | |
| 241 | + ) | |
| 242 | + | |
| 243 | + def _validate_languages(self, source_lang: Optional[str], target_lang: str) -> None: | |
| 244 | + src = str(source_lang or "").strip().lower() | |
| 245 | + tgt = str(target_lang or "").strip().lower() | |
| 246 | + if not src: | |
| 247 | + raise ValueError(f"Model '{self.model}' requires source_lang") | |
| 248 | + if src not in self.language_codes: | |
| 249 | + raise ValueError(f"Unsupported NLLB source language: {source_lang}") | |
| 250 | + if tgt not in self.language_codes: | |
| 251 | + raise ValueError(f"Unsupported NLLB target language: {target_lang}") | |
| 252 | + | |
| 253 | + def _prepare_tokenizer(self, source_lang: Optional[str], target_lang: str) -> Dict[str, object]: | |
| 254 | + del target_lang | |
| 255 | + src_code = self.language_codes[str(source_lang).strip().lower()] | |
| 256 | + self.tokenizer.src_lang = src_code | |
| 257 | + return {} | |
| 258 | + | |
| 259 | + def _build_generate_kwargs(self, source_lang: Optional[str], target_lang: str) -> Dict[str, object]: | |
| 260 | + del source_lang | |
| 261 | + tgt_code = self.language_codes[str(target_lang).strip().lower()] | |
| 262 | + forced_bos_token_id = None | |
| 263 | + if hasattr(self.tokenizer, "lang_code_to_id"): | |
| 264 | + forced_bos_token_id = self.tokenizer.lang_code_to_id.get(tgt_code) | |
| 265 | + if forced_bos_token_id is None: | |
| 266 | + forced_bos_token_id = self.tokenizer.convert_tokens_to_ids(tgt_code) | |
| 267 | + return { | |
| 268 | + "num_beams": self.num_beams, | |
| 269 | + "forced_bos_token_id": forced_bos_token_id, | |
| 270 | + } | |
| 271 | + | |
| 272 | + | |
| 273 | +def get_marian_language_direction(model_name: str) -> tuple[str, str]: | |
| 274 | + direction = MARIAN_LANGUAGE_DIRECTIONS.get(model_name) | |
| 275 | + if direction is None: | |
| 276 | + raise ValueError(f"Translation capability '{model_name}' is not registered with Marian language directions") | |
| 277 | + return direction | ... | ... |
translation/backends/qwen_mt.py
| ... | ... | @@ -14,53 +14,49 @@ from openai import OpenAI |
| 14 | 14 | |
| 15 | 15 | from config.env_config import DASHSCOPE_API_KEY, REDIS_CONFIG |
| 16 | 16 | from config.services_config import get_translation_cache_config |
| 17 | -from config.tenant_config_loader import SOURCE_LANG_CODE_MAP | |
| 17 | +from translation.languages import QWEN_LANGUAGE_CODES | |
| 18 | 18 | |
| 19 | 19 | logger = logging.getLogger(__name__) |
| 20 | 20 | |
| 21 | 21 | |
| 22 | 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 | 23 | def __init__( |
| 28 | 24 | self, |
| 29 | - model: str = "qwen", | |
| 25 | + capability_name: str, | |
| 26 | + model: str, | |
| 27 | + base_url: str, | |
| 30 | 28 | api_key: Optional[str] = None, |
| 31 | 29 | use_cache: bool = True, |
| 32 | 30 | timeout: int = 10, |
| 33 | 31 | glossary_id: Optional[str] = None, |
| 34 | - translation_context: Optional[str] = None, | |
| 35 | 32 | ): |
| 36 | - self.model = self._normalize_model(model) | |
| 33 | + self.capability_name = capability_name | |
| 34 | + self.model = self._normalize_capability_name(capability_name) | |
| 35 | + self.qwen_model_name = self._normalize_model_name(model) | |
| 36 | + self.base_url = base_url | |
| 37 | 37 | self.timeout = int(timeout) |
| 38 | 38 | self.use_cache = bool(use_cache) |
| 39 | 39 | self.glossary_id = glossary_id |
| 40 | - self.translation_context = translation_context or "e-commerce product search" | |
| 41 | 40 | |
| 42 | 41 | 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)) | |
| 42 | + self.cache_prefix = str(cache_cfg["key_prefix"]) | |
| 43 | + self.expire_seconds = int(cache_cfg["ttl_seconds"]) | |
| 44 | + self.cache_sliding_expiration = bool(cache_cfg["sliding_expiration"]) | |
| 45 | + self.cache_include_scene = bool(cache_cfg["key_include_scene"]) | |
| 46 | + self.cache_include_source_lang = bool(cache_cfg["key_include_source_lang"]) | |
| 49 | 47 | |
| 50 | - self.qwen_model_name = self._resolve_qwen_model_name(model) | |
| 51 | 48 | self._api_key = api_key or self._default_api_key(self.model) |
| 52 | 49 | self._qwen_client: Optional[OpenAI] = None |
| 53 | - base_url = os.getenv("DASHSCOPE_BASE_URL") or self.QWEN_DEFAULT_BASE_URL | |
| 54 | 50 | if self._api_key: |
| 55 | 51 | try: |
| 56 | - self._qwen_client = OpenAI(api_key=self._api_key, base_url=base_url) | |
| 52 | + self._qwen_client = OpenAI(api_key=self._api_key, base_url=self.base_url) | |
| 57 | 53 | except Exception as exc: |
| 58 | 54 | logger.warning("Failed to initialize qwen-mt client: %s", exc, exc_info=True) |
| 59 | 55 | else: |
| 60 | 56 | logger.warning("DASHSCOPE_API_KEY not set; qwen-mt translation unavailable") |
| 61 | 57 | |
| 62 | 58 | self.redis_client = None |
| 63 | - if self.use_cache and bool(cache_cfg.get("enabled", True)): | |
| 59 | + if self.use_cache and bool(cache_cfg["enabled"]): | |
| 64 | 60 | self.redis_client = self._init_redis_client() |
| 65 | 61 | |
| 66 | 62 | @property |
| ... | ... | @@ -68,18 +64,18 @@ class QwenMTTranslationBackend: |
| 68 | 64 | return True |
| 69 | 65 | |
| 70 | 66 | @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'") | |
| 67 | + def _normalize_capability_name(name: str) -> str: | |
| 68 | + normalized = str(name or "").strip().lower() | |
| 69 | + if normalized != "qwen-mt": | |
| 70 | + raise ValueError(f"Qwen-MT backend capability must be 'qwen-mt', got '{name}'") | |
| 71 | + return normalized | |
| 76 | 72 | |
| 77 | 73 | @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 | |
| 74 | + def _normalize_model_name(model: str) -> str: | |
| 75 | + normalized = str(model or "").strip() | |
| 76 | + if not normalized: | |
| 77 | + raise ValueError("qwen-mt backend model is required") | |
| 78 | + return normalized | |
| 83 | 79 | |
| 84 | 80 | @staticmethod |
| 85 | 81 | def _default_api_key(model: str) -> Optional[str]: |
| ... | ... | @@ -109,14 +105,12 @@ class QwenMTTranslationBackend: |
| 109 | 105 | text: str, |
| 110 | 106 | target_lang: str, |
| 111 | 107 | source_lang: Optional[str], |
| 112 | - context: Optional[str], | |
| 113 | - prompt: Optional[str], | |
| 108 | + scene: Optional[str], | |
| 114 | 109 | ) -> str: |
| 115 | 110 | src = (source_lang or "auto").strip().lower() if self.cache_include_source_lang else "-" |
| 116 | 111 | 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}" | |
| 112 | + scn = (scene or "").strip() if self.cache_include_scene else "" | |
| 113 | + payload = f"model={self.model}\nsrc={src}\ntgt={tgt}\nscene={scn}\ntext={text}" | |
| 120 | 114 | digest = hashlib.sha256(payload.encode("utf-8")).hexdigest() |
| 121 | 115 | return f"{self.cache_prefix}:{self.model}:{src}:{tgt}:{digest}" |
| 122 | 116 | |
| ... | ... | @@ -125,8 +119,7 @@ class QwenMTTranslationBackend: |
| 125 | 119 | text: Union[str, Sequence[str]], |
| 126 | 120 | target_lang: str, |
| 127 | 121 | source_lang: Optional[str] = None, |
| 128 | - context: Optional[str] = None, | |
| 129 | - prompt: Optional[str] = None, | |
| 122 | + scene: Optional[str] = None, | |
| 130 | 123 | ) -> Union[Optional[str], List[Optional[str]]]: |
| 131 | 124 | if isinstance(text, (list, tuple)): |
| 132 | 125 | results: List[Optional[str]] = [] |
| ... | ... | @@ -138,8 +131,7 @@ class QwenMTTranslationBackend: |
| 138 | 131 | text=str(item), |
| 139 | 132 | target_lang=target_lang, |
| 140 | 133 | source_lang=source_lang, |
| 141 | - context=context, | |
| 142 | - prompt=prompt, | |
| 134 | + scene=scene, | |
| 143 | 135 | ) |
| 144 | 136 | results.append(out) |
| 145 | 137 | return results |
| ... | ... | @@ -154,15 +146,14 @@ class QwenMTTranslationBackend: |
| 154 | 146 | if tgt == "zh" and (self._contains_chinese(text) or self._is_pure_number(text)): |
| 155 | 147 | return text |
| 156 | 148 | |
| 157 | - translation_context = context or self.translation_context | |
| 158 | - cached = self._get_cached_translation_redis(text, tgt, src, translation_context, prompt) | |
| 149 | + cached = self._get_cached_translation_redis(text, tgt, src, scene) | |
| 159 | 150 | if cached is not None: |
| 160 | 151 | return cached |
| 161 | 152 | |
| 162 | 153 | result = self._translate_qwen(text, tgt, src) |
| 163 | 154 | |
| 164 | 155 | if result is not None: |
| 165 | - self._set_cached_translation_redis(text, tgt, result, src, translation_context, prompt) | |
| 156 | + self._set_cached_translation_redis(text, tgt, result, src, scene) | |
| 166 | 157 | return result |
| 167 | 158 | |
| 168 | 159 | def _translate_qwen( |
| ... | ... | @@ -175,8 +166,8 @@ class QwenMTTranslationBackend: |
| 175 | 166 | return None |
| 176 | 167 | tgt_norm = (target_lang or "").strip().lower() |
| 177 | 168 | 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()) | |
| 169 | + tgt_qwen = QWEN_LANGUAGE_CODES.get(tgt_norm, tgt_norm.capitalize()) | |
| 170 | + src_qwen = "auto" if not src_norm or src_norm == "auto" else QWEN_LANGUAGE_CODES.get(src_norm, src_norm.capitalize()) | |
| 180 | 171 | start = time.time() |
| 181 | 172 | try: |
| 182 | 173 | completion = self._qwen_client.chat.completions.create( |
| ... | ... | @@ -211,12 +202,11 @@ class QwenMTTranslationBackend: |
| 211 | 202 | text: str, |
| 212 | 203 | target_lang: str, |
| 213 | 204 | source_lang: Optional[str] = None, |
| 214 | - context: Optional[str] = None, | |
| 215 | - prompt: Optional[str] = None, | |
| 205 | + scene: Optional[str] = None, | |
| 216 | 206 | ) -> Optional[str]: |
| 217 | 207 | if not self.redis_client: |
| 218 | 208 | return None |
| 219 | - key = self._build_cache_key(text, target_lang, source_lang, context, prompt) | |
| 209 | + key = self._build_cache_key(text, target_lang, source_lang, scene) | |
| 220 | 210 | try: |
| 221 | 211 | value = self.redis_client.get(key) |
| 222 | 212 | if value and self.cache_sliding_expiration: |
| ... | ... | @@ -232,12 +222,11 @@ class QwenMTTranslationBackend: |
| 232 | 222 | target_lang: str, |
| 233 | 223 | translation: str, |
| 234 | 224 | source_lang: Optional[str] = None, |
| 235 | - context: Optional[str] = None, | |
| 236 | - prompt: Optional[str] = None, | |
| 225 | + scene: Optional[str] = None, | |
| 237 | 226 | ) -> None: |
| 238 | 227 | if not self.redis_client: |
| 239 | 228 | return |
| 240 | - key = self._build_cache_key(text, target_lang, source_lang, context, prompt) | |
| 229 | + key = self._build_cache_key(text, target_lang, source_lang, scene) | |
| 241 | 230 | try: |
| 242 | 231 | self.redis_client.setex(key, self.expire_seconds, translation) |
| 243 | 232 | except Exception as exc: |
| ... | ... | @@ -255,6 +244,3 @@ class QwenMTTranslationBackend: |
| 255 | 244 | @staticmethod |
| 256 | 245 | def _is_pure_number(text: str) -> bool: |
| 257 | 246 | return bool(re.fullmatch(r"[\d.\-+%/,: ]+", (text or "").strip())) |
| 258 | - | |
| 259 | - | |
| 260 | -Translator = QwenMTTranslationBackend | ... | ... |
translation/client.py
| ... | ... | @@ -8,6 +8,7 @@ from typing import List, Optional, Sequence, Union |
| 8 | 8 | import requests |
| 9 | 9 | |
| 10 | 10 | from config.services_config import get_translation_config |
| 11 | +from translation.settings import normalize_translation_model, normalize_translation_scene | |
| 11 | 12 | |
| 12 | 13 | logger = logging.getLogger(__name__) |
| 13 | 14 | |
| ... | ... | @@ -24,10 +25,10 @@ class TranslationServiceClient: |
| 24 | 25 | timeout_sec: Optional[float] = None, |
| 25 | 26 | ) -> None: |
| 26 | 27 | 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) | |
| 28 | + self.base_url = str(base_url or cfg["service_url"]).rstrip("/") | |
| 29 | + self.default_model = normalize_translation_model(cfg, default_model or cfg["default_model"]) | |
| 30 | + self.default_scene = normalize_translation_scene(cfg, default_scene or cfg["default_scene"]) | |
| 31 | + self.timeout_sec = float(cfg["timeout_sec"] if timeout_sec is None else timeout_sec) | |
| 31 | 32 | |
| 32 | 33 | @property |
| 33 | 34 | def model(self) -> str: |
| ... | ... | @@ -42,22 +43,18 @@ class TranslationServiceClient: |
| 42 | 43 | text: Union[str, Sequence[str]], |
| 43 | 44 | target_lang: str, |
| 44 | 45 | source_lang: Optional[str] = None, |
| 45 | - context: Optional[str] = None, | |
| 46 | - prompt: Optional[str] = None, | |
| 47 | - model: Optional[str] = None, | |
| 48 | 46 | scene: Optional[str] = None, |
| 47 | + model: Optional[str] = None, | |
| 49 | 48 | ) -> Union[Optional[str], List[Optional[str]]]: |
| 50 | 49 | if isinstance(text, tuple): |
| 51 | 50 | text = list(text) |
| 52 | 51 | payload = { |
| 53 | 52 | "text": text, |
| 54 | 53 | "target_lang": target_lang, |
| 55 | - "source_lang": source_lang or "auto", | |
| 54 | + "source_lang": source_lang, | |
| 56 | 55 | "model": (model or self.default_model), |
| 57 | - "scene": (scene or context or self.default_scene), | |
| 56 | + "scene": self.default_scene if scene is None else scene, | |
| 58 | 57 | } |
| 59 | - if prompt: | |
| 60 | - payload["prompt"] = prompt | |
| 61 | 58 | try: |
| 62 | 59 | response = requests.post( |
| 63 | 60 | f"{self.base_url}/translate", |
| ... | ... | @@ -84,3 +81,8 @@ class TranslationServiceClient: |
| 84 | 81 | if isinstance(text, (list, tuple)): |
| 85 | 82 | return [None for _ in text] |
| 86 | 83 | return None |
| 84 | + | |
| 85 | + | |
| 86 | +def create_translation_client() -> TranslationServiceClient: | |
| 87 | + """Create the business-side translation client.""" | |
| 88 | + return TranslationServiceClient() | ... | ... |
| ... | ... | @@ -0,0 +1,67 @@ |
| 1 | +"""Translation-internal language metadata.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from typing import Dict, Tuple | |
| 6 | + | |
| 7 | + | |
| 8 | +LANGUAGE_LABELS: Dict[str, str] = { | |
| 9 | + "zh": "Chinese", | |
| 10 | + "en": "English", | |
| 11 | + "ru": "Russian", | |
| 12 | + "ar": "Arabic", | |
| 13 | + "ja": "Japanese", | |
| 14 | + "es": "Spanish", | |
| 15 | + "de": "German", | |
| 16 | + "fr": "French", | |
| 17 | + "it": "Italian", | |
| 18 | + "pt": "Portuguese", | |
| 19 | +} | |
| 20 | + | |
| 21 | + | |
| 22 | +QWEN_LANGUAGE_CODES: Dict[str, str] = { | |
| 23 | + "zh": "Chinese", | |
| 24 | + "en": "English", | |
| 25 | + "ru": "Russian", | |
| 26 | + "ar": "Arabic", | |
| 27 | + "ja": "Japanese", | |
| 28 | + "es": "Spanish", | |
| 29 | + "de": "German", | |
| 30 | + "fr": "French", | |
| 31 | + "it": "Italian", | |
| 32 | + "pt": "Portuguese", | |
| 33 | +} | |
| 34 | + | |
| 35 | + | |
| 36 | +DEEPL_LANGUAGE_CODES: Dict[str, str] = { | |
| 37 | + "zh": "ZH", | |
| 38 | + "en": "EN", | |
| 39 | + "ru": "RU", | |
| 40 | + "ar": "AR", | |
| 41 | + "ja": "JA", | |
| 42 | + "es": "ES", | |
| 43 | + "de": "DE", | |
| 44 | + "fr": "FR", | |
| 45 | + "it": "IT", | |
| 46 | + "pt": "PT", | |
| 47 | +} | |
| 48 | + | |
| 49 | + | |
| 50 | +NLLB_LANGUAGE_CODES: Dict[str, str] = { | |
| 51 | + "en": "eng_Latn", | |
| 52 | + "zh": "zho_Hans", | |
| 53 | + "ru": "rus_Cyrl", | |
| 54 | + "ar": "arb_Arab", | |
| 55 | + "ja": "jpn_Jpan", | |
| 56 | + "es": "spa_Latn", | |
| 57 | + "de": "deu_Latn", | |
| 58 | + "fr": "fra_Latn", | |
| 59 | + "it": "ita_Latn", | |
| 60 | + "pt": "por_Latn", | |
| 61 | +} | |
| 62 | + | |
| 63 | + | |
| 64 | +MARIAN_LANGUAGE_DIRECTIONS: Dict[str, Tuple[str, str]] = { | |
| 65 | + "opus-mt-zh-en": ("zh", "en"), | |
| 66 | + "opus-mt-en-zh": ("en", "zh"), | |
| 67 | +} | ... | ... |
config/translate_prompts.py renamed to translation/prompts.py
| 1 | -from config.tenant_config_loader import SOURCE_LANG_CODE_MAP, TARGET_LANG_CODE_MAP | |
| 1 | +"""Prompt templates for llm-based translation.""" | |
| 2 | 2 | |
| 3 | -TRANSLATION_PROMPTS = { | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from typing import Dict | |
| 6 | + | |
| 7 | + | |
| 8 | +TRANSLATION_PROMPTS: Dict[str, Dict[str, str]] = { | |
| 4 | 9 | "general": { |
| 5 | 10 | "zh": "你是一名专业的 {source_lang}({src_lang_code})到 {target_lang}({tgt_lang_code})翻译专家,请准确传达原文含义并符合{target_lang}语言习惯,只输出翻译结果:{text}", |
| 6 | 11 | "en": "You are a professional {source_lang} ({src_lang_code}) to {target_lang} ({tgt_lang_code}) translator. Accurately convey the meaning following {target_lang} grammar and usage, output only the translation: {text}", |
| ... | ... | @@ -11,9 +16,8 @@ TRANSLATION_PROMPTS = { |
| 11 | 16 | "de": "Du bist ein professioneller Übersetzer von {source_lang} ({src_lang_code}) nach {target_lang} ({tgt_lang_code}). Gib die Bedeutung korrekt wieder und gib nur die Übersetzung aus: {text}", |
| 12 | 17 | "fr": "Vous êtes un traducteur professionnel de {source_lang} ({src_lang_code}) vers {target_lang} ({tgt_lang_code}). Transmettez fidèlement le sens et produisez uniquement la traduction : {text}", |
| 13 | 18 | "it": "Sei un traduttore professionista da {source_lang} ({src_lang_code}) a {target_lang} ({tgt_lang_code}). Trasmetti accuratamente il significato e restituisci solo la traduzione: {text}", |
| 14 | - "pt": "Você é um tradutor profissional de {source_lang} ({src_lang_code}) para {target_lang} ({tgt_lang_code}). Transmita o significado com precisão e produza apenas a tradução: {text}" | |
| 19 | + "pt": "Você é um tradutor profissional de {source_lang} ({src_lang_code}) para {target_lang} ({tgt_lang_code}). Transmita o significado com precisão e produza apenas a tradução: {text}", | |
| 15 | 20 | }, |
| 16 | - | |
| 17 | 21 | "sku_name": { |
| 18 | 22 | "zh": "你是一名专业的 {source_lang}({src_lang_code})到 {target_lang}({tgt_lang_code})电商翻译专家,请将原文翻译为{target_lang}商品SKU名称,要求准确完整、简洁专业,只输出结果:{text}", |
| 19 | 23 | "en": "You are a professional {source_lang} ({src_lang_code}) to {target_lang} ({tgt_lang_code}) ecommerce translator. Translate into a concise and accurate {target_lang} product SKU name, output only the result: {text}", |
| ... | ... | @@ -24,9 +28,8 @@ TRANSLATION_PROMPTS = { |
| 24 | 28 | "de": "Du bist ein E-Commerce-Übersetzer von {source_lang} ({src_lang_code}) nach {target_lang} ({tgt_lang_code}). Übersetze in einen präzisen und kurzen {target_lang} Produkt-SKU-Namen, nur Ergebnis ausgeben: {text}", |
| 25 | 29 | "fr": "Vous êtes un traducteur e-commerce de {source_lang} ({src_lang_code}) vers {target_lang} ({tgt_lang_code}). Traduisez en un nom SKU produit {target_lang} précis et concis, sortie uniquement : {text}", |
| 26 | 30 | "it": "Sei un traduttore ecommerce da {source_lang} ({src_lang_code}) a {target_lang} ({tgt_lang_code}). Traduce in un nome SKU prodotto {target_lang} conciso e accurato, restituisci solo il risultato: {text}", |
| 27 | - "pt": "Você é um tradutor de e-commerce de {source_lang} ({src_lang_code}) para {target_lang} ({tgt_lang_code}). Traduza para um nome SKU de produto {target_lang} conciso e preciso, produza apenas o resultado: {text}" | |
| 31 | + "pt": "Você é um tradutor de e-commerce de {source_lang} ({src_lang_code}) para {target_lang} ({tgt_lang_code}). Traduza para um nome SKU de produto {target_lang} conciso e preciso, produza apenas o resultado: {text}", | |
| 28 | 32 | }, |
| 29 | - | |
| 30 | 33 | "ecommerce_search_query": { |
| 31 | 34 | "zh": "你是一名专业的 {source_lang}({src_lang_code})到 {target_lang}({tgt_lang_code})翻译助手,请将电商搜索词准确翻译为{target_lang}并符合搜索习惯,只输出结果:{text}", |
| 32 | 35 | "en": "You are a professional {source_lang} ({src_lang_code}) to {target_lang} ({tgt_lang_code}) translator. Translate the ecommerce search query accurately following {target_lang} search habits, output only the result: {text}", |
| ... | ... | @@ -37,6 +40,6 @@ TRANSLATION_PROMPTS = { |
| 37 | 40 | "de": "Du bist ein Übersetzer von {source_lang} ({src_lang_code}) nach {target_lang} ({tgt_lang_code}). Übersetze die E-Commerce-Suchanfrage entsprechend den Suchgewohnheiten, nur Ergebnis ausgeben: {text}", |
| 38 | 41 | "fr": "Vous êtes un traducteur de {source_lang} ({src_lang_code}) vers {target_lang} ({tgt_lang_code}). Traduisez la requête de recherche e-commerce selon les habitudes de recherche, sortie uniquement : {text}", |
| 39 | 42 | "it": "Sei un traduttore da {source_lang} ({src_lang_code}) a {target_lang} ({tgt_lang_code}). Traduce la query di ricerca ecommerce secondo le abitudini di ricerca e restituisci solo il risultato: {text}", |
| 40 | - "pt": "Você é um tradutor de {source_lang} ({src_lang_code}) para {target_lang} ({tgt_lang_code}). Traduza a consulta de busca de ecommerce conforme os hábitos de busca e produza apenas o resultado: {text}" | |
| 41 | - } | |
| 43 | + "pt": "Você é um tradutor de {source_lang} ({src_lang_code}) para {target_lang} ({tgt_lang_code}). Traduza a consulta de busca de ecommerce conforme os hábitos de busca e produza apenas o resultado: {text}", | |
| 44 | + }, | |
| 42 | 45 | } | ... | ... |
translation/protocols.py
| ... | ... | @@ -24,7 +24,6 @@ class TranslationBackendProtocol(Protocol): |
| 24 | 24 | text: TranslateInput, |
| 25 | 25 | target_lang: str, |
| 26 | 26 | source_lang: Optional[str] = None, |
| 27 | - context: Optional[str] = None, | |
| 28 | - prompt: Optional[str] = None, | |
| 27 | + scene: Optional[str] = None, | |
| 29 | 28 | ) -> TranslateOutput: |
| 30 | 29 | ... | ... | ... |
| ... | ... | @@ -0,0 +1,36 @@ |
| 1 | +"""Canonical translation scenes and scene-specific metadata.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from typing import Dict | |
| 6 | + | |
| 7 | + | |
| 8 | +SCENE_DEEPL_CONTEXTS: Dict[str, Dict[str, str]] = { | |
| 9 | + "general": { | |
| 10 | + "zh": "", | |
| 11 | + "en": "", | |
| 12 | + }, | |
| 13 | + "sku_name": { | |
| 14 | + "zh": "商品SKU名称", | |
| 15 | + "en": "product SKU name", | |
| 16 | + }, | |
| 17 | + "ecommerce_search_query": { | |
| 18 | + "zh": "电商搜索词", | |
| 19 | + "en": "e-commerce search query", | |
| 20 | + }, | |
| 21 | +} | |
| 22 | + | |
| 23 | + | |
| 24 | +SUPPORTED_SCENES = frozenset(SCENE_DEEPL_CONTEXTS.keys()) | |
| 25 | + | |
| 26 | + | |
| 27 | +def normalize_scene_name(scene: str) -> str: | |
| 28 | + normalized = str(scene or "").strip() | |
| 29 | + if not normalized: | |
| 30 | + raise ValueError("translation scene cannot be empty") | |
| 31 | + if normalized not in SUPPORTED_SCENES: | |
| 32 | + raise ValueError( | |
| 33 | + f"Unsupported translation scene '{normalized}'. " | |
| 34 | + f"Supported scenes: {', '.join(sorted(SUPPORTED_SCENES))}" | |
| 35 | + ) | |
| 36 | + return normalized | ... | ... |
translation/service.py
| ... | ... | @@ -3,10 +3,18 @@ |
| 3 | 3 | from __future__ import annotations |
| 4 | 4 | |
| 5 | 5 | import logging |
| 6 | +import threading | |
| 6 | 7 | from typing import Dict, List, Optional |
| 7 | 8 | |
| 8 | -from config.services_config import TranslationServiceConfig, get_translation_config | |
| 9 | +from config.services_config import get_translation_config | |
| 9 | 10 | from translation.protocols import TranslateInput, TranslateOutput, TranslationBackendProtocol |
| 11 | +from translation.settings import ( | |
| 12 | + TranslationConfig, | |
| 13 | + get_enabled_translation_models, | |
| 14 | + get_translation_capability, | |
| 15 | + normalize_translation_model, | |
| 16 | + normalize_translation_scene, | |
| 17 | +) | |
| 10 | 18 | |
| 11 | 19 | logger = logging.getLogger(__name__) |
| 12 | 20 | |
| ... | ... | @@ -14,72 +22,140 @@ logger = logging.getLogger(__name__) |
| 14 | 22 | class TranslationService: |
| 15 | 23 | """Owns translation backends and routes calls by model and scene.""" |
| 16 | 24 | |
| 17 | - def __init__(self, config: Optional[TranslationServiceConfig] = None) -> None: | |
| 25 | + def __init__(self, config: Optional[TranslationConfig] = None) -> None: | |
| 18 | 26 | self.config = config or get_translation_config() |
| 27 | + self._enabled_capabilities = self._collect_enabled_capabilities() | |
| 19 | 28 | self._backends: Dict[str, TranslationBackendProtocol] = {} |
| 20 | - self._init_enabled_backends() | |
| 29 | + self._backend_lock = threading.Lock() | |
| 30 | + if not self._enabled_capabilities: | |
| 31 | + raise ValueError("No enabled translation backends found in services.translation.capabilities") | |
| 21 | 32 | |
| 22 | - def _init_enabled_backends(self) -> None: | |
| 33 | + def _collect_enabled_capabilities(self) -> Dict[str, Dict[str, object]]: | |
| 34 | + enabled: Dict[str, Dict[str, object]] = {} | |
| 35 | + for name in get_enabled_translation_models(self.config): | |
| 36 | + capability = get_translation_capability(self.config, name, require_enabled=True) | |
| 37 | + backend_type = capability.get("backend") | |
| 38 | + if not backend_type: | |
| 39 | + raise ValueError(f"Translation capability '{name}' must define a backend") | |
| 40 | + enabled[name] = capability | |
| 41 | + return enabled | |
| 42 | + | |
| 43 | + def _create_backend( | |
| 44 | + self, | |
| 45 | + *, | |
| 46 | + name: str, | |
| 47 | + backend_type: str, | |
| 48 | + cfg: Dict[str, object], | |
| 49 | + ) -> TranslationBackendProtocol: | |
| 23 | 50 | registry = { |
| 24 | - "qwen-mt": self._create_qwen_mt_backend, | |
| 51 | + "qwen_mt": self._create_qwen_mt_backend, | |
| 25 | 52 | "deepl": self._create_deepl_backend, |
| 26 | 53 | "llm": self._create_llm_backend, |
| 54 | + "local_nllb": self._create_local_nllb_backend, | |
| 55 | + "local_marian": self._create_local_marian_backend, | |
| 27 | 56 | } |
| 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") | |
| 57 | + factory = registry.get(backend_type) | |
| 58 | + if factory is None: | |
| 59 | + raise ValueError(f"Unsupported translation backend '{backend_type}' for capability '{name}'") | |
| 60 | + return factory(name=name, cfg=cfg) | |
| 37 | 61 | |
| 38 | - def _create_qwen_mt_backend(self) -> TranslationBackendProtocol: | |
| 62 | + def _create_qwen_mt_backend(self, *, name: str, cfg: Dict[str, object]) -> TranslationBackendProtocol: | |
| 39 | 63 | from translation.backends.qwen_mt import QwenMTTranslationBackend |
| 40 | 64 | |
| 41 | - cfg = self.config.get_capability_cfg("qwen-mt") | |
| 42 | 65 | return QwenMTTranslationBackend( |
| 43 | - model=cfg.get("model") or "qwen-mt-flash", | |
| 66 | + capability_name=name, | |
| 67 | + model=str(cfg["model"]).strip(), | |
| 68 | + base_url=str(cfg["base_url"]).strip(), | |
| 44 | 69 | api_key=cfg.get("api_key"), |
| 45 | - use_cache=bool(cfg.get("use_cache", True)), | |
| 46 | - timeout=int(cfg.get("timeout_sec", 10)), | |
| 70 | + use_cache=bool(cfg["use_cache"]), | |
| 71 | + timeout=int(cfg["timeout_sec"]), | |
| 47 | 72 | glossary_id=cfg.get("glossary_id"), |
| 48 | - translation_context=cfg.get("translation_context"), | |
| 49 | 73 | ) |
| 50 | 74 | |
| 51 | - def _create_deepl_backend(self) -> TranslationBackendProtocol: | |
| 75 | + def _create_deepl_backend(self, *, name: str, cfg: Dict[str, object]) -> TranslationBackendProtocol: | |
| 52 | 76 | from translation.backends.deepl import DeepLTranslationBackend |
| 53 | 77 | |
| 54 | - cfg = self.config.get_capability_cfg("deepl") | |
| 55 | 78 | return DeepLTranslationBackend( |
| 56 | 79 | api_key=cfg.get("api_key"), |
| 57 | - timeout=float(cfg.get("timeout_sec", 10.0)), | |
| 80 | + api_url=str(cfg["api_url"]).strip(), | |
| 81 | + timeout=float(cfg["timeout_sec"]), | |
| 58 | 82 | glossary_id=cfg.get("glossary_id"), |
| 59 | 83 | ) |
| 60 | 84 | |
| 61 | - def _create_llm_backend(self) -> TranslationBackendProtocol: | |
| 85 | + def _create_llm_backend(self, *, name: str, cfg: Dict[str, object]) -> TranslationBackendProtocol: | |
| 62 | 86 | from translation.backends.llm import LLMTranslationBackend |
| 63 | 87 | |
| 64 | - cfg = self.config.get_capability_cfg("llm") | |
| 65 | 88 | return LLMTranslationBackend( |
| 66 | - model=cfg.get("model"), | |
| 67 | - timeout_sec=float(cfg.get("timeout_sec", 30.0)), | |
| 68 | - base_url=cfg.get("base_url"), | |
| 89 | + capability_name=name, | |
| 90 | + model=str(cfg["model"]).strip(), | |
| 91 | + timeout_sec=float(cfg["timeout_sec"]), | |
| 92 | + base_url=str(cfg["base_url"]).strip(), | |
| 93 | + ) | |
| 94 | + | |
| 95 | + def _create_local_nllb_backend(self, *, name: str, cfg: Dict[str, object]) -> TranslationBackendProtocol: | |
| 96 | + from translation.backends.local_seq2seq import NLLBTranslationBackend | |
| 97 | + | |
| 98 | + return NLLBTranslationBackend( | |
| 99 | + name=name, | |
| 100 | + model_id=str(cfg["model_id"]).strip(), | |
| 101 | + model_dir=str(cfg["model_dir"]).strip(), | |
| 102 | + device=str(cfg["device"]).strip(), | |
| 103 | + torch_dtype=str(cfg["torch_dtype"]).strip(), | |
| 104 | + batch_size=int(cfg["batch_size"]), | |
| 105 | + max_input_length=int(cfg["max_input_length"]), | |
| 106 | + max_new_tokens=int(cfg["max_new_tokens"]), | |
| 107 | + num_beams=int(cfg["num_beams"]), | |
| 108 | + ) | |
| 109 | + | |
| 110 | + def _create_local_marian_backend(self, *, name: str, cfg: Dict[str, object]) -> TranslationBackendProtocol: | |
| 111 | + from translation.backends.local_seq2seq import MarianMTTranslationBackend, get_marian_language_direction | |
| 112 | + | |
| 113 | + source_lang, target_lang = get_marian_language_direction(name) | |
| 114 | + | |
| 115 | + return MarianMTTranslationBackend( | |
| 116 | + name=name, | |
| 117 | + model_id=str(cfg["model_id"]).strip(), | |
| 118 | + model_dir=str(cfg["model_dir"]).strip(), | |
| 119 | + device=str(cfg["device"]).strip(), | |
| 120 | + torch_dtype=str(cfg["torch_dtype"]).strip(), | |
| 121 | + batch_size=int(cfg["batch_size"]), | |
| 122 | + max_input_length=int(cfg["max_input_length"]), | |
| 123 | + max_new_tokens=int(cfg["max_new_tokens"]), | |
| 124 | + num_beams=int(cfg["num_beams"]), | |
| 125 | + source_langs=[source_lang], | |
| 126 | + target_langs=[target_lang], | |
| 69 | 127 | ) |
| 70 | 128 | |
| 71 | 129 | @property |
| 72 | 130 | def available_models(self) -> List[str]: |
| 131 | + return list(self._enabled_capabilities.keys()) | |
| 132 | + | |
| 133 | + @property | |
| 134 | + def loaded_models(self) -> List[str]: | |
| 73 | 135 | return list(self._backends.keys()) |
| 74 | 136 | |
| 75 | 137 | 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: | |
| 138 | + normalized = normalize_translation_model(self.config, model) | |
| 139 | + capability_cfg = self._enabled_capabilities.get(normalized) | |
| 140 | + if capability_cfg is None: | |
| 79 | 141 | raise ValueError( |
| 80 | 142 | f"Translation model '{normalized}' is not enabled. " |
| 81 | 143 | f"Available models: {', '.join(self.available_models) or 'none'}" |
| 82 | 144 | ) |
| 145 | + backend = self._backends.get(normalized) | |
| 146 | + if backend is not None: | |
| 147 | + return backend | |
| 148 | + with self._backend_lock: | |
| 149 | + backend = self._backends.get(normalized) | |
| 150 | + if backend is None: | |
| 151 | + backend_type = str(capability_cfg["backend"]) | |
| 152 | + logger.info("Initializing translation backend | model=%s backend=%s", normalized, backend_type) | |
| 153 | + backend = self._create_backend( | |
| 154 | + name=normalized, | |
| 155 | + backend_type=backend_type, | |
| 156 | + cfg=capability_cfg, | |
| 157 | + ) | |
| 158 | + self._backends[normalized] = backend | |
| 83 | 159 | return backend |
| 84 | 160 | |
| 85 | 161 | def translate( |
| ... | ... | @@ -90,14 +166,12 @@ class TranslationService: |
| 90 | 166 | *, |
| 91 | 167 | model: Optional[str] = None, |
| 92 | 168 | scene: Optional[str] = None, |
| 93 | - prompt: Optional[str] = None, | |
| 94 | 169 | ) -> TranslateOutput: |
| 95 | 170 | backend = self.get_backend(model) |
| 96 | - active_scene = (scene or self.config.default_scene or "general").strip() or "general" | |
| 171 | + active_scene = normalize_translation_scene(self.config, scene) | |
| 97 | 172 | return backend.translate( |
| 98 | 173 | text=text, |
| 99 | 174 | target_lang=target_lang, |
| 100 | 175 | source_lang=source_lang, |
| 101 | - context=active_scene, | |
| 102 | - prompt=prompt, | |
| 176 | + scene=active_scene, | |
| 103 | 177 | ) | ... | ... |
| ... | ... | @@ -0,0 +1,210 @@ |
| 1 | +"""Translation config normalization and validation helpers.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from typing import Any, Dict, List, Mapping, Optional | |
| 6 | + | |
| 7 | +from translation.scenes import normalize_scene_name | |
| 8 | + | |
| 9 | + | |
| 10 | +TranslationConfig = Dict[str, Any] | |
| 11 | + | |
| 12 | + | |
| 13 | +def build_translation_config(raw_cfg: Mapping[str, Any]) -> TranslationConfig: | |
| 14 | + if not isinstance(raw_cfg, Mapping): | |
| 15 | + raise ValueError("services.translation must be a mapping") | |
| 16 | + | |
| 17 | + config: TranslationConfig = { | |
| 18 | + "service_url": _require_http_url(raw_cfg.get("service_url"), "services.translation.service_url").rstrip("/"), | |
| 19 | + "timeout_sec": _require_positive_float(raw_cfg.get("timeout_sec"), "services.translation.timeout_sec"), | |
| 20 | + "default_model": _require_string(raw_cfg.get("default_model"), "services.translation.default_model").lower(), | |
| 21 | + "default_scene": normalize_scene_name( | |
| 22 | + _require_string(raw_cfg.get("default_scene"), "services.translation.default_scene") | |
| 23 | + ), | |
| 24 | + "cache": _build_cache_config(raw_cfg.get("cache")), | |
| 25 | + "capabilities": _build_capabilities(raw_cfg.get("capabilities")), | |
| 26 | + } | |
| 27 | + | |
| 28 | + default_model = config["default_model"] | |
| 29 | + capabilities = config["capabilities"] | |
| 30 | + if default_model not in capabilities: | |
| 31 | + raise ValueError( | |
| 32 | + f"services.translation.default_model '{default_model}' is not defined in services.translation.capabilities" | |
| 33 | + ) | |
| 34 | + if not capabilities[default_model]["enabled"]: | |
| 35 | + raise ValueError( | |
| 36 | + f"services.translation.default_model '{default_model}' must reference an enabled capability" | |
| 37 | + ) | |
| 38 | + if not get_enabled_translation_models(config): | |
| 39 | + raise ValueError("At least one translation capability must be enabled") | |
| 40 | + | |
| 41 | + return config | |
| 42 | + | |
| 43 | + | |
| 44 | +def normalize_translation_model(config: Mapping[str, Any], model: Optional[str]) -> str: | |
| 45 | + normalized = str(model or config.get("default_model") or "").strip().lower() | |
| 46 | + if not normalized: | |
| 47 | + raise ValueError("translation model cannot be empty") | |
| 48 | + return normalized | |
| 49 | + | |
| 50 | + | |
| 51 | +def normalize_translation_scene(config: Mapping[str, Any], scene: Optional[str]) -> str: | |
| 52 | + return normalize_scene_name(scene or config.get("default_scene")) | |
| 53 | + | |
| 54 | + | |
| 55 | +def get_enabled_translation_models(config: Mapping[str, Any]) -> List[str]: | |
| 56 | + capabilities = config.get("capabilities") | |
| 57 | + if not isinstance(capabilities, Mapping): | |
| 58 | + raise ValueError("translation config missing capabilities") | |
| 59 | + return [name for name, capability in capabilities.items() if isinstance(capability, Mapping) and capability.get("enabled") is True] | |
| 60 | + | |
| 61 | + | |
| 62 | +def get_translation_capability( | |
| 63 | + config: Mapping[str, Any], | |
| 64 | + model: Optional[str], | |
| 65 | + *, | |
| 66 | + require_enabled: bool = False, | |
| 67 | +) -> Dict[str, Any]: | |
| 68 | + normalized = normalize_translation_model(config, model) | |
| 69 | + capabilities = config.get("capabilities") | |
| 70 | + if not isinstance(capabilities, Mapping): | |
| 71 | + raise ValueError("translation config missing capabilities") | |
| 72 | + | |
| 73 | + capability = capabilities.get(normalized) | |
| 74 | + if not isinstance(capability, Mapping): | |
| 75 | + raise ValueError(f"Translation capability '{normalized}' is not defined") | |
| 76 | + if require_enabled and capability.get("enabled") is not True: | |
| 77 | + enabled = ", ".join(get_enabled_translation_models(config)) or "none" | |
| 78 | + raise ValueError(f"Translation model '{normalized}' is not enabled. Available models: {enabled}") | |
| 79 | + return dict(capability) | |
| 80 | + | |
| 81 | + | |
| 82 | +def get_translation_cache(config: Mapping[str, Any]) -> Dict[str, Any]: | |
| 83 | + cache = config.get("cache") | |
| 84 | + if not isinstance(cache, Mapping): | |
| 85 | + raise ValueError("translation config missing cache") | |
| 86 | + return dict(cache) | |
| 87 | + | |
| 88 | + | |
| 89 | +def _build_cache_config(raw_cache: Any) -> Dict[str, Any]: | |
| 90 | + if not isinstance(raw_cache, Mapping): | |
| 91 | + raise ValueError("services.translation.cache must be a mapping") | |
| 92 | + return { | |
| 93 | + "enabled": _require_bool(raw_cache.get("enabled"), "services.translation.cache.enabled"), | |
| 94 | + "key_prefix": _require_string(raw_cache.get("key_prefix"), "services.translation.cache.key_prefix"), | |
| 95 | + "ttl_seconds": _require_positive_int(raw_cache.get("ttl_seconds"), "services.translation.cache.ttl_seconds"), | |
| 96 | + "sliding_expiration": _require_bool( | |
| 97 | + raw_cache.get("sliding_expiration"), | |
| 98 | + "services.translation.cache.sliding_expiration", | |
| 99 | + ), | |
| 100 | + "key_include_scene": _require_bool( | |
| 101 | + raw_cache.get("key_include_scene"), | |
| 102 | + "services.translation.cache.key_include_scene", | |
| 103 | + ), | |
| 104 | + "key_include_source_lang": _require_bool( | |
| 105 | + raw_cache.get("key_include_source_lang"), | |
| 106 | + "services.translation.cache.key_include_source_lang", | |
| 107 | + ), | |
| 108 | + } | |
| 109 | + | |
| 110 | + | |
| 111 | +def _build_capabilities(raw_capabilities: Any) -> Dict[str, Dict[str, Any]]: | |
| 112 | + if not isinstance(raw_capabilities, Mapping): | |
| 113 | + raise ValueError("services.translation.capabilities must be a mapping") | |
| 114 | + | |
| 115 | + resolved: Dict[str, Dict[str, Any]] = {} | |
| 116 | + for name, raw_capability in raw_capabilities.items(): | |
| 117 | + if not isinstance(raw_capability, Mapping): | |
| 118 | + raise ValueError(f"services.translation.capabilities.{name} must be a mapping") | |
| 119 | + | |
| 120 | + capability_name = _require_string(name, "translation capability name").lower() | |
| 121 | + prefix = f"services.translation.capabilities.{capability_name}" | |
| 122 | + capability = dict(raw_capability) | |
| 123 | + capability["enabled"] = _require_bool(capability.get("enabled"), f"{prefix}.enabled") | |
| 124 | + capability["backend"] = _require_string(capability.get("backend"), f"{prefix}.backend").lower() | |
| 125 | + _validate_capability(capability_name, capability) | |
| 126 | + resolved[capability_name] = capability | |
| 127 | + | |
| 128 | + return resolved | |
| 129 | + | |
| 130 | + | |
| 131 | +def _validate_capability(name: str, capability: Mapping[str, Any]) -> None: | |
| 132 | + prefix = f"services.translation.capabilities.{name}" | |
| 133 | + backend = capability.get("backend") | |
| 134 | + | |
| 135 | + if backend == "qwen_mt": | |
| 136 | + _require_string(capability.get("model"), f"{prefix}.model") | |
| 137 | + _require_http_url(capability.get("base_url"), f"{prefix}.base_url") | |
| 138 | + _require_positive_float(capability.get("timeout_sec"), f"{prefix}.timeout_sec") | |
| 139 | + _require_bool(capability.get("use_cache"), f"{prefix}.use_cache") | |
| 140 | + return | |
| 141 | + | |
| 142 | + if backend == "llm": | |
| 143 | + _require_string(capability.get("model"), f"{prefix}.model") | |
| 144 | + _require_http_url(capability.get("base_url"), f"{prefix}.base_url") | |
| 145 | + _require_positive_float(capability.get("timeout_sec"), f"{prefix}.timeout_sec") | |
| 146 | + return | |
| 147 | + | |
| 148 | + if backend == "deepl": | |
| 149 | + _require_http_url(capability.get("api_url"), f"{prefix}.api_url") | |
| 150 | + _require_positive_float(capability.get("timeout_sec"), f"{prefix}.timeout_sec") | |
| 151 | + return | |
| 152 | + | |
| 153 | + if backend in {"local_nllb", "local_marian"}: | |
| 154 | + _require_string(capability.get("model_id"), f"{prefix}.model_id") | |
| 155 | + _require_string(capability.get("model_dir"), f"{prefix}.model_dir") | |
| 156 | + _require_string(capability.get("device"), f"{prefix}.device") | |
| 157 | + _require_string(capability.get("torch_dtype"), f"{prefix}.torch_dtype") | |
| 158 | + _require_positive_int(capability.get("batch_size"), f"{prefix}.batch_size") | |
| 159 | + _require_positive_int(capability.get("max_input_length"), f"{prefix}.max_input_length") | |
| 160 | + _require_positive_int(capability.get("max_new_tokens"), f"{prefix}.max_new_tokens") | |
| 161 | + _require_positive_int(capability.get("num_beams"), f"{prefix}.num_beams") | |
| 162 | + return | |
| 163 | + | |
| 164 | + raise ValueError(f"Unsupported translation backend '{backend}' for capability '{name}'") | |
| 165 | + | |
| 166 | + | |
| 167 | +def _require_string(value: Any, field_name: str) -> str: | |
| 168 | + text = str(value or "").strip() | |
| 169 | + if not text: | |
| 170 | + raise ValueError(f"{field_name} is required") | |
| 171 | + return text | |
| 172 | + | |
| 173 | + | |
| 174 | +def _require_float(value: Any, field_name: str) -> float: | |
| 175 | + if value in (None, ""): | |
| 176 | + raise ValueError(f"{field_name} is required") | |
| 177 | + return float(value) | |
| 178 | + | |
| 179 | + | |
| 180 | +def _require_positive_float(value: Any, field_name: str) -> float: | |
| 181 | + parsed = _require_float(value, field_name) | |
| 182 | + if parsed <= 0: | |
| 183 | + raise ValueError(f"{field_name} must be greater than 0") | |
| 184 | + return parsed | |
| 185 | + | |
| 186 | + | |
| 187 | +def _require_int(value: Any, field_name: str) -> int: | |
| 188 | + if value in (None, ""): | |
| 189 | + raise ValueError(f"{field_name} is required") | |
| 190 | + return int(value) | |
| 191 | + | |
| 192 | + | |
| 193 | +def _require_positive_int(value: Any, field_name: str) -> int: | |
| 194 | + parsed = _require_int(value, field_name) | |
| 195 | + if parsed <= 0: | |
| 196 | + raise ValueError(f"{field_name} must be greater than 0") | |
| 197 | + return parsed | |
| 198 | + | |
| 199 | + | |
| 200 | +def _require_bool(value: Any, field_name: str) -> bool: | |
| 201 | + if not isinstance(value, bool): | |
| 202 | + raise ValueError(f"{field_name} must be a boolean") | |
| 203 | + return value | |
| 204 | + | |
| 205 | + | |
| 206 | +def _require_http_url(value: Any, field_name: str) -> str: | |
| 207 | + text = _require_string(value, field_name) | |
| 208 | + if not (text.startswith("http://") or text.startswith("https://")): | |
| 209 | + raise ValueError(f"{field_name} must start with http:// or https://") | |
| 210 | + return text | ... | ... |