Commit 0fd2f875d0489b437589ee50a0bd108bdbbda1d9

Authored by tangwang
1 parent 5e4dc8e4

translate

@@ -73,4 +73,6 @@ logs_*/ @@ -73,4 +73,6 @@ logs_*/
73 73
74 .runtime/ 74 .runtime/
75 .venv* 75 .venv*
76 -.pytest_cache  
77 \ No newline at end of file 76 \ No newline at end of file
  77 +.pytest_cache
  78 +
  79 +models/
@@ -60,12 +60,16 @@ source activate.sh @@ -60,12 +60,16 @@ source activate.sh
60 - `search/`:召回、排序、结果组织 60 - `search/`:召回、排序、结果组织
61 - `query/`:查询解析、多语言处理、改写 61 - `query/`:查询解析、多语言处理、改写
62 - `indexer/`:MySQL 行数据 -> ES 文档的转换与索引流程 62 - `indexer/`:MySQL 行数据 -> ES 文档的转换与索引流程
63 -- `providers/`:能力调用抽象(translation/embedding/rerank) 63 +- `providers/`:能力调用抽象(embedding/rerank)
  64 +- `translation/`:翻译服务客户端、服务编排与后端实现
64 - `reranker/`:重排服务及后端实现 65 - `reranker/`:重排服务及后端实现
65 - `embeddings/`:向量服务(文本/图像) 66 - `embeddings/`:向量服务(文本/图像)
66 - `config/`:配置加载与服务配置解析 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,9 +93,10 @@ source activate.sh
89 | 2. 运行与排障 | `docs/Usage-Guide.md` | 93 | 2. 运行与排障 | `docs/Usage-Guide.md` |
90 | 3. API 详细说明 | `docs/搜索API对接指南.md` | 94 | 3. API 详细说明 | `docs/搜索API对接指南.md` |
91 | 4. 快速参数速查 | `docs/搜索API速查表.md` | 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 import argparse 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 import uvicorn 9 import uvicorn
89 -from typing import Dict, List, Optional, Union  
90 from fastapi import FastAPI, HTTPException 10 from fastapi import FastAPI, HTTPException
91 -from fastapi.responses import JSONResponse  
92 from fastapi.middleware.cors import CORSMiddleware 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 from config.services_config import get_translation_config 15 from config.services_config import get_translation_config
96 from translation.service import TranslationService 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 # Configure logging 23 # Configure logging
99 logging.basicConfig( 24 logging.basicConfig(
@@ -102,37 +27,33 @@ logging.basicConfig( @@ -102,37 +27,33 @@ logging.basicConfig(
102 ) 27 )
103 logger = logging.getLogger(__name__) 28 logger = logging.getLogger(__name__)
104 29
105 -_translation_service: Optional[TranslationService] = None  
106 -  
107 30
  31 +@lru_cache(maxsize=1)
108 def get_translation_service() -> TranslationService: 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 # Request/Response models 36 # Request/Response models
116 class TranslationRequest(BaseModel): 37 class TranslationRequest(BaseModel):
117 """Translation request model.""" 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 "example": { 42 "example": {
129 "text": "商品名称", 43 "text": "商品名称",
130 "target_lang": "en", 44 "target_lang": "en",
131 "source_lang": "zh", 45 "source_lang": "zh",
132 "model": "llm", 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 class TranslationResponse(BaseModel): 59 class TranslationResponse(BaseModel):
@@ -149,13 +70,108 @@ class TranslationResponse(BaseModel): @@ -149,13 +70,108 @@ class TranslationResponse(BaseModel):
149 scene: str = Field(..., description="Translation scene used") 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 # Create FastAPI app 167 # Create FastAPI app
153 app = FastAPI( 168 app = FastAPI(
154 title="Translation Service API", 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 version="1.0.0", 171 version="1.0.0",
157 docs_url="/docs", 172 docs_url="/docs",
158 - redoc_url="/redoc" 173 + redoc_url="/redoc",
  174 + lifespan=lifespan,
159 ) 175 )
160 176
161 # Add CORS middleware 177 # Add CORS middleware
@@ -168,22 +184,6 @@ app.add_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 @app.get("/health") 187 @app.get("/health")
188 async def health_check(): 188 async def health_check():
189 """Health check endpoint.""" 189 """Health check endpoint."""
@@ -192,10 +192,11 @@ async def health_check(): @@ -192,10 +192,11 @@ async def health_check():
192 return { 192 return {
193 "status": "healthy", 193 "status": "healthy",
194 "service": "translation", 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 "available_models": service.available_models, 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 except Exception as e: 201 except Exception as e:
201 logger.error(f"Health check failed: {e}") 202 logger.error(f"Health check failed: {e}")
@@ -210,106 +211,27 @@ async def health_check(): @@ -210,106 +211,27 @@ async def health_check():
210 211
211 @app.post("/translate", response_model=TranslationResponse) 212 @app.post("/translate", response_model=TranslationResponse)
212 async def translate(request: TranslationRequest): 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 if not request.target_lang: 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 try: 219 try:
242 service = get_translation_service() 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 translator = service.get_backend(model) 223 translator = service.get_backend(model)
246 raw_text = request.text 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 if isinstance(raw_text, list): 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 return TranslationResponse( 235 return TranslationResponse(
314 text=raw_text, 236 text=raw_text,
315 target_lang=request.target_lang, 237 target_lang=request.target_lang,
@@ -320,21 +242,16 @@ async def translate(request: TranslationRequest): @@ -320,21 +242,16 @@ async def translate(request: TranslationRequest):
320 scene=scene, 242 scene=scene,
321 ) 243 )
322 244
323 - # 单文本模式:保持原有严格失败语义  
324 translated_text = service.translate( 245 translated_text = service.translate(
325 text=raw_text, 246 text=raw_text,
326 target_lang=request.target_lang, 247 target_lang=request.target_lang,
327 source_lang=request.source_lang, 248 source_lang=request.source_lang,
328 model=model, 249 model=model,
329 scene=scene, 250 scene=scene,
330 - prompt=request.prompt,  
331 ) 251 )
332 252
333 if translated_text is None: 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 return TranslationResponse( 256 return TranslationResponse(
340 text=raw_text, 257 text=raw_text,
@@ -348,12 +265,11 @@ async def translate(request: TranslationRequest): @@ -348,12 +265,11 @@ async def translate(request: TranslationRequest):
348 265
349 except HTTPException: 266 except HTTPException:
350 raise 267 raise
  268 + except ValueError as e:
  269 + raise HTTPException(status_code=400, detail=str(e)) from e
351 except Exception as e: 270 except Exception as e:
352 logger.error(f"Translation error: {e}", exc_info=True) 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 @app.get("/") 275 @app.get("/")
config/config.yaml
@@ -77,10 +77,6 @@ query_config: @@ -77,10 +77,6 @@ query_config:
77 text_embedding_field: "title_embedding" 77 text_embedding_field: "title_embedding"
78 image_embedding_field: null 78 image_embedding_field: null
79 79
80 - # 翻译API配置(provider/URL 在 services.translation)  
81 - translation_service: "deepl"  
82 - translation_api_key: null # 通过环境变量设置  
83 -  
84 # 返回字段配置(_source includes) 80 # 返回字段配置(_source includes)
85 # null表示返回所有字段,[]表示不返回任何字段,列表表示只返回指定字段 81 # null表示返回所有字段,[]表示不返回任何字段,列表表示只返回指定字段
86 source_fields: null 82 source_fields: null
@@ -116,33 +112,61 @@ services: @@ -116,33 +112,61 @@ services:
116 key_prefix: "trans:v2" 112 key_prefix: "trans:v2"
117 ttl_seconds: 62208000 113 ttl_seconds: 62208000
118 sliding_expiration: true 114 sliding_expiration: true
119 - key_include_context: true  
120 - key_include_prompt: true 115 + key_include_scene: true
121 key_include_source_lang: true 116 key_include_source_lang: true
122 capabilities: 117 capabilities:
123 qwen-mt: 118 qwen-mt:
124 enabled: true 119 enabled: true
  120 + backend: "qwen_mt"
125 model: "qwen-mt-flash" 121 model: "qwen-mt-flash"
  122 + base_url: "https://dashscope-us.aliyuncs.com/compatible-mode/v1"
126 timeout_sec: 10.0 123 timeout_sec: 10.0
127 use_cache: true 124 use_cache: true
128 llm: 125 llm:
129 enabled: true 126 enabled: true
  127 + backend: "llm"
130 model: "qwen-flash" 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 timeout_sec: 30.0 130 timeout_sec: 30.0
135 deepl: 131 deepl:
136 enabled: false 132 enabled: false
137 - model: "deepl" 133 + backend: "deepl"
  134 + api_url: "https://api.deepl.com/v2/translate"
138 timeout_sec: 10.0 135 timeout_sec: 10.0
139 - # 可选:用于术语表翻译(由 query_config.translation_glossary_id 衔接)  
140 glossary_id: "" 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 embedding: 170 embedding:
147 provider: "http" # http 171 provider: "http" # http
148 base_url: "http://127.0.0.1:6005" 172 base_url: "http://127.0.0.1:6005"
config/config_loader.py
@@ -37,12 +37,6 @@ class QueryConfig: @@ -37,12 +37,6 @@ class QueryConfig:
37 # Query rewrite dictionary (loaded from external file) 37 # Query rewrite dictionary (loaded from external file)
38 rewrite_dictionary: Dict[str, str] = field(default_factory=dict) 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 # Embedding field names 40 # Embedding field names
47 text_embedding_field: Optional[str] = "title_embedding" 41 text_embedding_field: Optional[str] = "title_embedding"
48 image_embedding_field: Optional[str] = None 42 image_embedding_field: Optional[str] = None
@@ -234,7 +228,6 @@ class ConfigLoader: @@ -234,7 +228,6 @@ class ConfigLoader:
234 228
235 # Parse query config 229 # Parse query config
236 query_config_data = config_data.get("query_config", {}) 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 rewrite_dictionary = self._load_rewrite_dictionary() 231 rewrite_dictionary = self._load_rewrite_dictionary()
239 search_fields_cfg = query_config_data.get("search_fields", {}) 232 search_fields_cfg = query_config_data.get("search_fields", {})
240 text_strategy_cfg = query_config_data.get("text_query_strategy", {}) 233 text_strategy_cfg = query_config_data.get("text_query_strategy", {})
@@ -245,10 +238,6 @@ class ConfigLoader: @@ -245,10 +238,6 @@ class ConfigLoader:
245 enable_text_embedding=query_config_data.get("enable_text_embedding", True), 238 enable_text_embedding=query_config_data.get("enable_text_embedding", True),
246 enable_query_rewrite=query_config_data.get("enable_query_rewrite", True), 239 enable_query_rewrite=query_config_data.get("enable_query_rewrite", True),
247 rewrite_dictionary=rewrite_dictionary, 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 text_embedding_field=query_config_data.get("text_embedding_field"), 241 text_embedding_field=query_config_data.get("text_embedding_field"),
253 image_embedding_field=query_config_data.get("image_embedding_field"), 242 image_embedding_field=query_config_data.get("image_embedding_field"),
254 source_fields=query_config_data.get("source_fields"), 243 source_fields=query_config_data.get("source_fields"),
@@ -459,7 +448,6 @@ class ConfigLoader: @@ -459,7 +448,6 @@ class ConfigLoader:
459 "default_language": config.query_config.default_language, 448 "default_language": config.query_config.default_language,
460 "enable_text_embedding": config.query_config.enable_text_embedding, 449 "enable_text_embedding": config.query_config.enable_text_embedding,
461 "enable_query_rewrite": config.query_config.enable_query_rewrite, 450 "enable_query_rewrite": config.query_config.enable_query_rewrite,
462 - "translation_service": config.query_config.translation_service,  
463 "text_embedding_field": config.query_config.text_embedding_field, 451 "text_embedding_field": config.query_config.text_embedding_field,
464 "image_embedding_field": config.query_config.image_embedding_field, 452 "image_embedding_field": config.query_config.image_embedding_field,
465 "source_fields": config.query_config.source_fields, 453 "source_fields": config.query_config.source_fields,
config/env_config.py
@@ -65,9 +65,6 @@ EMBEDDING_HOST = os.getenv(&#39;EMBEDDING_HOST&#39;, &#39;127.0.0.1&#39;) @@ -65,9 +65,6 @@ EMBEDDING_HOST = os.getenv(&#39;EMBEDDING_HOST&#39;, &#39;127.0.0.1&#39;)
65 EMBEDDING_PORT = int(os.getenv('EMBEDDING_PORT', 6005)) 65 EMBEDDING_PORT = int(os.getenv('EMBEDDING_PORT', 6005))
66 TRANSLATION_HOST = os.getenv('TRANSLATION_HOST', '127.0.0.1') 66 TRANSLATION_HOST = os.getenv('TRANSLATION_HOST', '127.0.0.1')
67 TRANSLATION_PORT = int(os.getenv('TRANSLATION_PORT', 6006)) 67 TRANSLATION_PORT = int(os.getenv('TRANSLATION_PORT', 6006))
68 -TRANSLATION_PROVIDER = os.getenv('TRANSLATION_PROVIDER', 'direct') # deprecated  
69 -TRANSLATION_MODEL = os.getenv('TRANSLATION_MODEL', 'llm')  
70 -TRANSLATION_SCENE = os.getenv('TRANSLATION_SCENE', 'general')  
71 RERANKER_HOST = os.getenv('RERANKER_HOST', '127.0.0.1') 68 RERANKER_HOST = os.getenv('RERANKER_HOST', '127.0.0.1')
72 RERANKER_PORT = int(os.getenv('RERANKER_PORT', 6007)) 69 RERANKER_PORT = int(os.getenv('RERANKER_PORT', 6007))
73 RERANK_PROVIDER = os.getenv('RERANK_PROVIDER', 'http') 70 RERANK_PROVIDER = os.getenv('RERANK_PROVIDER', 'http')
@@ -79,7 +76,6 @@ INDEXER_BASE_URL = os.getenv(&#39;INDEXER_BASE_URL&#39;) or ( @@ -79,7 +76,6 @@ INDEXER_BASE_URL = os.getenv(&#39;INDEXER_BASE_URL&#39;) or (
79 f'http://localhost:{INDEXER_PORT}' if INDEXER_HOST == '0.0.0.0' else f'http://{INDEXER_HOST}:{INDEXER_PORT}' 76 f'http://localhost:{INDEXER_PORT}' if INDEXER_HOST == '0.0.0.0' else f'http://{INDEXER_HOST}:{INDEXER_PORT}'
80 ) 77 )
81 EMBEDDING_SERVICE_URL = os.getenv('EMBEDDING_SERVICE_URL') or f'http://{EMBEDDING_HOST}:{EMBEDDING_PORT}' 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 RERANKER_SERVICE_URL = os.getenv('RERANKER_SERVICE_URL') or f'http://{RERANKER_HOST}:{RERANKER_PORT}/rerank' 79 RERANKER_SERVICE_URL = os.getenv('RERANKER_SERVICE_URL') or f'http://{RERANKER_HOST}:{RERANKER_PORT}/rerank'
84 80
85 # Model IDs / paths 81 # Model IDs / paths
config/services_config.py
@@ -15,6 +15,7 @@ from pathlib import Path @@ -15,6 +15,7 @@ from pathlib import Path
15 from typing import Any, Dict, List, Optional 15 from typing import Any, Dict, List, Optional
16 16
17 import yaml 17 import yaml
  18 +from translation.settings import TranslationConfig, build_translation_config, get_translation_cache
18 19
19 20
20 @dataclass 21 @dataclass
@@ -29,42 +30,6 @@ class ServiceConfig: @@ -29,42 +30,6 @@ class ServiceConfig:
29 return self.providers.get(p, {}) if isinstance(self.providers, dict) else {} 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 def _load_services_raw(config_path: Optional[Path] = None) -> Dict[str, Any]: 33 def _load_services_raw(config_path: Optional[Path] = None) -> Dict[str, Any]:
69 if config_path is None: 34 if config_path is None:
70 config_path = Path(__file__).parent / "config.yaml" 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,70 +59,10 @@ def _resolve_provider_name(env_name: str, config_provider: Any, capability: str)
94 return str(provider).strip().lower() 59 return str(provider).strip().lower()
95 60
96 61
97 -def _resolve_translation() -> TranslationServiceConfig: 62 +def _resolve_translation() -> TranslationConfig:
98 raw = _load_services_raw() 63 raw = _load_services_raw()
99 cfg = raw.get("translation", {}) if isinstance(raw.get("translation"), dict) else {} 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 def _resolve_embedding() -> ServiceConfig: 68 def _resolve_embedding() -> ServiceConfig:
@@ -237,7 +142,7 @@ def get_embedding_backend_config() -&gt; tuple[str, dict]: @@ -237,7 +142,7 @@ def get_embedding_backend_config() -&gt; tuple[str, dict]:
237 142
238 143
239 @lru_cache(maxsize=1) 144 @lru_cache(maxsize=1)
240 -def get_translation_config() -> TranslationServiceConfig: 145 +def get_translation_config() -> TranslationConfig:
241 return _resolve_translation() 146 return _resolve_translation()
242 147
243 148
@@ -252,20 +157,11 @@ def get_rerank_config() -&gt; ServiceConfig: @@ -252,20 +157,11 @@ def get_rerank_config() -&gt; ServiceConfig:
252 157
253 158
254 def get_translation_base_url() -> str: 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 def get_translation_cache_config() -> Dict[str, Any]: 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 def get_embedding_base_url() -> str: 167 def get_embedding_base_url() -> str:
docs/DEVELOPER_GUIDE.md
@@ -43,6 +43,7 @@ @@ -43,6 +43,7 @@
43 浠ヤ笅鏂囨。鐢辨湰鎸囧崡寮曠敤锛屾寜闇娣卞叆锛 43 浠ヤ笅鏂囨。鐢辨湰鎸囧崡寮曠敤锛屾寜闇娣卞叆锛
44 44
45 - [QUICKSTART.md](./QUICKSTART.md) 鈥 鐜銆佹湇鍔°佹ā鍧椼佽姹傜ず渚嬶紱搂2鈥撀 鍚熀纭閰嶇疆涓 Provider/妯″潡鎵╁睍 45 - [QUICKSTART.md](./QUICKSTART.md) 鈥 鐜銆佹湇鍔°佹ā鍧椼佽姹傜ず渚嬶紱搂2鈥撀 鍚熀纭閰嶇疆涓 Provider/妯″潡鎵╁睍
  46 +- [缈昏瘧妯″潡璇存槑.md](./缈昏瘧妯″潡璇存槑.md) 鈥 translator service銆乧apability 閰嶇疆銆佹湰鍦版ā鍨嬮儴缃蹭笌鎺ュ彛濂戠害
46 - [绯荤粺璁捐鏂囨。.md](./绯荤粺璁捐鏂囨。.md) 鈥 绱㈠紩缁撴瀯銆佹暟鎹祦銆侀氱敤鍖栬璁 47 - [绯荤粺璁捐鏂囨。.md](./绯荤粺璁捐鏂囨。.md) 鈥 绱㈠紩缁撴瀯銆佹暟鎹祦銆侀氱敤鍖栬璁
47 - [鎼滅储API瀵规帴鎸囧崡.md](./鎼滅储API瀵规帴鎸囧崡.md) 鈥 鎼滅储/绱㈠紩/绠$悊鎺ュ彛瀹屾暣璇存槑 48 - [鎼滅储API瀵规帴鎸囧崡.md](./鎼滅储API瀵规帴鎸囧崡.md) 鈥 鎼滅储/绱㈠紩/绠$悊鎺ュ彛瀹屾暣璇存槑
48 - [QUICKSTART.md](./QUICKSTART.md) 搂1.4鈥.8 鈥 绯荤粺瑕佹眰銆丳ython 鐜銆佸閮ㄦ湇鍔′笌鐢熶骇鍑瘉銆佸簵鍖犳暟鎹簮 49 - [QUICKSTART.md](./QUICKSTART.md) 搂1.4鈥.8 鈥 绯荤粺瑕佹眰銆丳ython 鐜銆佸閮ㄦ湇鍔′笌鐢熶骇鍑瘉銆佸簵鍖犳暟鎹簮
@@ -64,7 +65,7 @@ @@ -64,7 +65,7 @@
64 65
65 - **澶氱鎴**锛氬崟濂椾唬鐮佷笌绱㈠紩缁撴瀯锛岄氳繃 `tenant_id` 闅旂鏁版嵁锛涚鎴风骇閰嶇疆锛堝涓昏瑷銆佺储寮曡瑷锛夌敱閰嶇疆涓 tenant_config 鏀寔銆 66 - **澶氱鎴**锛氬崟濂椾唬鐮佷笌绱㈠紩缁撴瀯锛岄氳繃 `tenant_id` 闅旂鏁版嵁锛涚鎴风骇閰嶇疆锛堝涓昏瑷銆佺储寮曡瑷锛夌敱閰嶇疆涓 tenant_config 鏀寔銆
66 - **鍙厤缃**锛氬瓧娈垫潈閲嶃佹悳绱㈠煙銆佹帓搴忚〃杈惧紡銆佹煡璇㈡敼鍐欍佸姛鑳藉紑鍏崇瓑鐢遍厤缃┍鍔紝閬垮厤纭紪鐮佷笟鍔¢昏緫銆 67 - **鍙厤缃**锛氬瓧娈垫潈閲嶃佹悳绱㈠煙銆佹帓搴忚〃杈惧紡銆佹煡璇㈡敼鍐欍佸姛鑳藉紑鍏崇瓑鐢遍厤缃┍鍔紝閬垮厤纭紪鐮佷笟鍔¢昏緫銆
67 -- **鍙墿灞**锛氱炕璇/鍚戦噺/閲嶆帓閲囩敤 Provider + 鍚庣鍙彃鎷旇璁★紝鏂板瀹炵幇鏃堕伒寰崗璁笌閰嶇疆瑙勮寖锛屼笉鐮村潖鐜版湁璋冪敤鏂广 68 +- **鍙墿灞**锛歟mbedding / rerank 閲囩敤 Provider + 鍚庣鍙彃鎷旇璁★紱translation 閲囩敤 translator service + capability backend 璁捐銆傛柊澧炲疄鐜版椂閬靛惊鍗忚涓庨厤缃鑼冿紝涓嶇牬鍧忕幇鏈夎皟鐢ㄦ柟銆
68 - **涓嶈礋璐**锛氬晢鍝佷富鏁版嵁鍚屾銆佸簵閾洪厤缃啓搴撱佸叏閲/澧為噺璋冨害绛栫暐鐢变笂娓革紙濡 Java 绱㈠紩绋嬪簭锛夎礋璐o紱鏈粨搴撲笓娉ㄢ滃浣曟煡銆佸浣曞缓 doc鈥濄 69 - **涓嶈礋璐**锛氬晢鍝佷富鏁版嵁鍚屾銆佸簵閾洪厤缃啓搴撱佸叏閲/澧為噺璋冨害绛栫暐鐢变笂娓革紙濡 Java 绱㈠紩绋嬪簭锛夎礋璐o紱鏈粨搴撲笓娉ㄢ滃浣曟煡銆佸浣曞缓 doc鈥濄
69 70
70 --- 71 ---
@@ -109,7 +110,8 @@ query/ # 鏌ヨ瑙f瀽锛氳鑼冨寲銆佹敼鍐欍佺炕璇戙乪mbedding 璋 @@ -109,7 +110,8 @@ query/ # 鏌ヨ瑙f瀽锛氳鑼冨寲銆佹敼鍐欍佺炕璇戙乪mbedding 璋
109 search/ # 鎼滅储鎵ц锛氬璇█鏌ヨ鏋勫缓銆丼earcher銆侀噸鎺掑鎴风銆佸垎鏁拌瀺鍚 110 search/ # 鎼滅储鎵ц锛氬璇█鏌ヨ鏋勫缓銆丼earcher銆侀噸鎺掑鎴风銆佸垎鏁拌瀺鍚
110 embeddings/ # 鍚戦噺鍖栵細鏈嶅姟绔紙server锛夈佹枃鏈/鍥惧儚鍚庣銆佸崗璁笌閰嶇疆 111 embeddings/ # 鍚戦噺鍖栵細鏈嶅姟绔紙server锛夈佹枃鏈/鍥惧儚鍚庣銆佸崗璁笌閰嶇疆
111 reranker/ # 閲嶆帓锛氭湇鍔$锛坰erver锛夈佸悗绔紙backends锛夈侀厤缃 112 reranker/ # 閲嶆帓锛氭湇鍔$锛坰erver锛夈佸悗绔紙backends锛夈侀厤缃
112 -providers/ # 鑳藉姏鎻愪緵鑰咃細缈昏瘧/鍚戦噺/閲嶆帓鐨勫鎴风鎶借薄涓庡伐鍘 113 +providers/ # 鑳藉姏鎻愪緵鑰咃細鍚戦噺/閲嶆帓鐨勫鎴风鎶借薄涓庡伐鍘
  114 +translation/ # 缈昏瘧锛氭湇鍔″鎴风銆佹湇鍔$紪鎺掋佸悗绔疄鐜般佹湰鍦版ā鍨嬫帴鍏
113 suggestion/ # 寤鸿锛氱储寮曟瀯寤恒佸缓璁绱 115 suggestion/ # 寤鸿锛氱储寮曟瀯寤恒佸缓璁绱
114 utils/ # 鍏变韩宸ュ叿锛欵S 瀹㈡埛绔丏B 杩炴帴绛 116 utils/ # 鍏变韩宸ュ叿锛欵S 瀹㈡埛绔丏B 杩炴帴绛
115 mappings/ # ES 绱㈠紩 mapping 瀹氫箟锛堝 search_products.json锛 117 mappings/ # ES 绱㈠紩 mapping 瀹氫箟锛堝 search_products.json锛
@@ -119,7 +121,7 @@ tests/ # 鍗曞厓涓庨泦鎴愭祴璇 @@ -119,7 +121,7 @@ tests/ # 鍗曞厓涓庨泦鎴愭祴璇
119 docs/ # 鏂囨。锛堝惈鏈寚鍗楋級 121 docs/ # 鏂囨。锛堝惈鏈寚鍗楋級
120 ``` 122 ```
121 123
122 -- **绾﹀畾**锛氫笟鍔¢昏緫鎸夎兘鍔涙斁鍏ュ搴旈《灞傚寘锛涙柊澧炩滆兘鍔涒濇椂浼樺厛鑰冭檻鏄惁灞炰簬鐜版湁鏌愬寘鎴 providers锛岄伩鍏嶉殢鎰忔柊寤洪《灞傚寘瀵艰嚧鍒嗗弶銆 124 +- **绾﹀畾**锛氫笟鍔¢昏緫鎸夎兘鍔涙斁鍏ュ搴旈《灞傚寘锛涙柊澧炩滆兘鍔涒濇椂浼樺厛鑰冭檻鏄惁灞炰簬鐜版湁鏌愬寘銆乣translation/` 鎴 providers锛岄伩鍏嶉殢鎰忔柊寤洪《灞傚寘瀵艰嚧鍒嗗弶銆
123 125
124 --- 126 ---
125 127
@@ -166,7 +168,7 @@ docs/ # 鏂囨。锛堝惈鏈寚鍗楋級 @@ -166,7 +168,7 @@ docs/ # 鏂囨。锛堝惈鏈寚鍗楋級
166 168
167 ### 4.8 providers 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 - **鍘熷垯**锛氫笟鍔′唬鐮佸彧渚濊禆璋冪敤鎺ュ彛锛屼笉渚濊禆鍏蜂綋 URL 鎴栨湇鍔″唴鍚庣绫诲瀷锛涚炕璇戣兘鍔涙柊澧炴椂浼樺厛鎵╁睍 `translation/backends/` 涓 `services.translation.capabilities`锛岃屼笉鏄湪涓氬姟渚ф柊澧 provider 鍒嗘敮銆 172 - **鍘熷垯**锛氫笟鍔′唬鐮佸彧渚濊禆璋冪敤鎺ュ彛锛屼笉渚濊禆鍏蜂綋 URL 鎴栨湇鍔″唴鍚庣绫诲瀷锛涚炕璇戣兘鍔涙柊澧炴椂浼樺厛鎵╁睍 `translation/backends/` 涓 `services.translation.capabilities`锛岃屼笉鏄湪涓氬姟渚ф柊澧 provider 鍒嗘敮銆
171 - **璇﹁**锛氭湰鎸囧崡 搂7.2锛沎QUICKSTART.md](./QUICKSTART.md) 搂3銆 173 - **璇﹁**锛氭湰鎸囧崡 搂7.2锛沎QUICKSTART.md](./QUICKSTART.md) 搂3銆
172 174
@@ -197,14 +199,14 @@ docs/ # 鏂囨。锛堝惈鏈寚鍗楋級 @@ -197,14 +199,14 @@ docs/ # 鏂囨。锛堝惈鏈寚鍗楋級
197 ### 5.2 閰嶇疆椹卞姩 199 ### 5.2 閰嶇疆椹卞姩
198 200
199 - 鎼滅储琛屼负锛堝瓧娈垫潈閲嶃佹悳绱㈠煙銆佹帓搴忋乫unction_score銆侀噸鎺掕瀺鍚堝弬鏁扮瓑锛夋潵鑷 `config/config.yaml`锛岀敱 `ConfigLoader` 鍔犺浇銆 201 - 鎼滅储琛屼负锛堝瓧娈垫潈閲嶃佹悳绱㈠煙銆佹帓搴忋乫unction_score銆侀噸鎺掕瀺鍚堝弬鏁扮瓑锛夋潵鑷 `config/config.yaml`锛岀敱 `ConfigLoader` 鍔犺浇銆
200 -- 鑳藉姏璁块棶鏉ヨ嚜 `config.yaml` 鐨 `services` 鍧楀強鐜鍙橀噺锛岀敱 `config/services_config` 瑙f瀽銆 202 +- 鑳藉姏璁块棶鏉ヨ嚜 `config.yaml` 鐨 `services` 鍧楋紝鐢 `config/services_config` 瑙f瀽銆
201 - 鍏朵腑缈昏瘧鍗曠嫭閲囩敤鈥渟ervice + capabilities鈥濇ā鍨嬶細璋冪敤鏂瑰彧閰 `service_url` / `default_model` / `default_scene`锛屾湇鍔″唴閫氳繃 `capabilities` 鎺у埗鍚敤鍝簺缈昏瘧鑳藉姏銆 203 - 鍏朵腑缈昏瘧鍗曠嫭閲囩敤鈥渟ervice + capabilities鈥濇ā鍨嬶細璋冪敤鏂瑰彧閰 `service_url` / `default_model` / `default_scene`锛屾湇鍔″唴閫氳繃 `capabilities` 鎺у埗鍚敤鍝簺缈昏瘧鑳藉姏銆
202 - 鏂板寮鍏虫垨鍙傛暟鏃讹紝浼樺厛鍦ㄧ幇鏈 config 缁撴瀯涓嬫墿灞曪紝閬垮厤鏂板鏁h惤閰嶇疆鏂囦欢銆 204 - 鏂板寮鍏虫垨鍙傛暟鏃讹紝浼樺厛鍦ㄧ幇鏈 config 缁撴瀯涓嬫墿灞曪紝閬垮厤鏂板鏁h惤閰嶇疆鏂囦欢銆
203 205
204 ### 5.3 鍗曚竴閰嶇疆婧愪笌浼樺厛绾 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 ### 5.4 璋冪敤鏂逛笌瀹炵幇瑙h︼紙Client + Backend锛 211 ### 5.4 璋冪敤鏂逛笌瀹炵幇瑙h︼紙Client + Backend锛
210 212
@@ -232,7 +234,7 @@ docs/ # 鏂囨。锛堝惈鏈寚鍗楋級 @@ -232,7 +234,7 @@ docs/ # 鏂囨。锛堝惈鏈寚鍗楋級
232 234
233 ### 5.8 鍚姩鍒濆鍖栫害鏉 235 ### 5.8 鍚姩鍒濆鍖栫害鏉
234 236
235 -- 閲嶈祫婧愪笌鍏抽敭渚濊禆锛堝 translator銆乼ext/image encoder锛夊簲鍦ㄦ湇鍔″惎鍔ㄦ湡鍒濆鍖栦竴娆″苟澶嶇敤锛岄伩鍏嶈姹傛湡鎳掑姞杞姐 237 +- translator service 鍦ㄨ繘绋嬪惎鍔ㄦ椂搴斿畬鎴愰厤缃牎楠屽苟棰勭儹榛樿 backend锛涘叾浣欏凡鍚敤 capability 鍙寜棣栨璇锋眰鎳掑姞杞斤紝閬垮厤澶氫釜鏈湴缈昏瘧妯″瀷鍦ㄥ惎鍔ㄩ樁娈典竴娆℃у崰婊℃樉瀛樸
236 - 鑻ラ厤缃0鏄庡惎鐢ㄦ煇鑳藉姏锛堜緥濡 GPU 鍚庣锛夛紝浣嗚繍琛岃祫婧愪笉婊¤冻锛屽簲鐩存帴鍚姩澶辫触锛屼笉鑷姩闄嶇骇涓哄叾瀹冨悗绔 238 - 鑻ラ厤缃0鏄庡惎鐢ㄦ煇鑳藉姏锛堜緥濡 GPU 鍚庣锛夛紝浣嗚繍琛岃祫婧愪笉婊¤冻锛屽簲鐩存帴鍚姩澶辫触锛屼笉鑷姩闄嶇骇涓哄叾瀹冨悗绔
237 239
238 ### 5.9 鐜闅旂 240 ### 5.9 鐜闅旂
@@ -276,21 +278,23 @@ services: @@ -276,21 +278,23 @@ services:
276 default_scene: "general" 278 default_scene: "general"
277 timeout_sec: 10.0 279 timeout_sec: 10.0
278 capabilities: 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 - **provider**锛氳皟鐢ㄦ柟濡備綍璁块棶锛堝 HTTP锛夈 286 - **provider**锛氳皟鐢ㄦ柟濡備綍璁块棶锛堝 HTTP锛夈
285 - **backend / backends**锛氬綋鑳藉姏鐢辨湰浠撳簱鍐呮湇鍔℃彁渚涙椂锛岃鏈嶅姟鍔犺浇鍝釜鍚庣鍙婂弬鏁般 287 - **backend / backends**锛氬綋鑳藉姏鐢辨湰浠撳簱鍐呮湇鍔℃彁渚涙椂锛岃鏈嶅姟鍔犺浇鍝釜鍚庣鍙婂弬鏁般
286 - **translation.service_url**锛氫笟鍔′晶缁熶竴璋冪敤鐨勭炕璇戞湇鍔″湴鍧銆 288 - **translation.service_url**锛氫笟鍔′晶缁熶竴璋冪敤鐨勭炕璇戞湇鍔″湴鍧銆
287 - **translation.capabilities**锛氱炕璇戞湇鍔″唴閮ㄥ彲鍚敤鐨勮兘鍔涙敞鍐岃〃銆 289 - **translation.capabilities**锛氱炕璇戞湇鍔″唴閮ㄥ彲鍚敤鐨勮兘鍔涙敞鍐岃〃銆
  290 +- **translation 鍐呴儴闈欐佽鍒**锛歴cene 闆嗗悎銆佽瑷鐮佹槧灏勩丩LM prompt 妯℃澘銆佹湰鍦版ā鍨嬫柟鍚戠害鏉熺粺涓浣嶄簬 `translation/`锛屼笉鏄閮 YAML 閰嶇疆銆
288 - 瑙f瀽鍏ュ彛锛歚config/services_config.py` 鐨 `get_*_config()` 鍙 `get_*_base_url()` / `get_rerank_service_url()` 绛夈 291 - 瑙f瀽鍏ュ彛锛歚config/services_config.py` 鐨 `get_*_config()` 鍙 `get_*_base_url()` / `get_rerank_service_url()` 绛夈
289 292
290 ### 6.3 鐜鍙橀噺锛堝父鐢級 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 - 鐜涓庣储寮曪細`ES_HOST`銆乣ES_INDEX_NAMESPACE`銆乣RUNTIME_ENV`銆丏B 涓 Redis 绛 298 - 鐜涓庣储寮曪細`ES_HOST`銆乣ES_INDEX_NAMESPACE`銆乣RUNTIME_ENV`銆丏B 涓 Redis 绛
295 299
296 璇﹁ [QUICKSTART.md](./QUICKSTART.md) 搂1.6锛.env 涓庣敓浜у嚟璇侊級銆乕Usage-Guide.md](./Usage-Guide.md)銆 300 璇﹁ [QUICKSTART.md](./QUICKSTART.md) 搂1.6锛.env 涓庣敓浜у嚟璇侊級銆乕Usage-Guide.md](./Usage-Guide.md)銆
@@ -301,7 +305,8 @@ services: @@ -301,7 +305,8 @@ services:
301 305
302 ### 7.1 浣曟椂鐪嬫墿灞曡鑼 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 - 鏂板鎴栨浛鎹**鍚戦噺/閲嶆帓**鐨勬帹鐞嗗疄鐜帮紙濡傛柊妯″瀷銆乿LLM锛夛細瑙佹湰鎸囧崡 搂7.3鈥撀.6銆 310 - 鏂板鎴栨浛鎹**鍚戦噺/閲嶆帓**鐨勬帹鐞嗗疄鐜帮紙濡傛柊妯″瀷銆乿LLM锛夛細瑙佹湰鎸囧崡 搂7.3鈥撀.6銆
306 311
307 ### 7.2 鏂板 Provider锛堣皟鐢ㄦ柟寮忥級 312 ### 7.2 鏂板 Provider锛堣皟鐢ㄦ柟寮忥級
@@ -316,7 +321,7 @@ services: @@ -316,7 +321,7 @@ services:
316 1. 鍦 `translation/backends/` 涓疄鐜版柊 backend銆 321 1. 鍦 `translation/backends/` 涓疄鐜版柊 backend銆
317 2. 鍦 `translation/service.py` 涓敞鍐屽伐鍘傘 322 2. 鍦 `translation/service.py` 涓敞鍐屽伐鍘傘
318 3. 鍦 `services.translation.capabilities.<name>` 涓嬪鍔犻厤缃紝骞剁敤 `enabled` 鎺у埗鏄惁鍚敤銆 323 3. 鍦 `services.translation.capabilities.<name>` 涓嬪鍔犻厤缃紝骞剁敤 `enabled` 鎺у埗鏄惁鍚敤銆
319 -4. 涓氬姟璋冪敤鏂逛繚鎸佷笉鍙橈紝浠嶅彧閫氳繃 `create_translation_provider()` 璋 6006銆 324 +4. 涓氬姟璋冪敤鏂逛繚鎸佷笉鍙橈紝浠嶅彧閫氳繃 `create_translation_client()` 璋 6006銆
320 325
321 ### 7.3 鏂板 Backend锛堟帹鐞嗗疄鐜帮級 326 ### 7.3 鏂板 Backend锛堟帹鐞嗗疄鐜帮級
322 327
@@ -331,7 +336,7 @@ services: @@ -331,7 +336,7 @@ services:
331 ### 7.4 绂佹鍋氭硶 336 ### 7.4 绂佹鍋氭硶
332 337
333 - 鍦ㄤ笟鍔′唬鐮佷腑纭紪鐮佹湇鍔 URL 鎴栧悗绔被鍨嬨 338 - 鍦ㄤ笟鍔′唬鐮佷腑纭紪鐮佹湇鍔 URL 鎴栧悗绔被鍨嬨
334 -- 鏂板鑳藉姏鏃跺鍒朵竴濂楃嫭绔嬮厤缃綋绯绘垨鏂伴《灞傚寘锛岃屼笉绾冲叆 `services` 涓 providers/backends 339 +- 鏂板鑳藉姏鏃跺鍒朵竴濂楃嫭绔嬮厤缃綋绯绘垨鏂伴《灞傚寘锛岃屼笉绾冲叆 `services` 涓 providers/backends锛泃ranslation 涔熷繀椤荤撼鍏 `services.translation.capabilities` 涓 `translation/backends/`
335 - 鏂板鍚庣鏃剁牬鍧忕幇鏈夊崗璁紙濡備慨鏀硅繑鍥為暱搴︺侀『搴忔垨 meta 绾﹀畾锛夈 340 - 鏂板鍚庣鏃剁牬鍧忕幇鏈夊崗璁紙濡備慨鏀硅繑鍥為暱搴︺侀『搴忔垨 meta 绾﹀畾锛夈
336 341
337 ### 7.5 閲嶆帓涓庡悜閲忓寲鍗忚涓庨厤缃熸煡 342 ### 7.5 閲嶆帓涓庡悜閲忓寲鍗忚涓庨厤缃熸煡
@@ -404,7 +409,7 @@ services: @@ -404,7 +409,7 @@ services:
404 409
405 - [ ] 鏂伴昏緫鏀惧湪鍚堥傜殑鐜版湁鍖呬腑锛屾湭闅忔剰鏂板缓涓庣幇鏈夎兘鍔涘钩琛岀殑椤跺眰鍖呫 410 - [ ] 鏂伴昏緫鏀惧湪鍚堥傜殑鐜版湁鍖呬腑锛屾湭闅忔剰鏂板缓涓庣幇鏈夎兘鍔涘钩琛岀殑椤跺眰鍖呫
406 - [ ] 鏈湪涓氬姟浠g爜涓‖缂栫爜鏈嶅姟 URL銆佸悗绔被鍨嬫垨绉熸埛 ID銆 411 - [ ] 鏈湪涓氬姟浠g爜涓‖缂栫爜鏈嶅姟 URL銆佸悗绔被鍨嬫垨绉熸埛 ID銆
407 -- [ ] 璋冪敤澶栭儴鑳藉姏锛堢炕璇/鍚戦噺/閲嶆帓锛夋椂閫氳繃 providers 宸ュ巶鑾峰彇瀹炰緥锛岄厤缃潵鑷 `services_config`銆 412 +- [ ] 璋冪敤澶栭儴鑳藉姏鏃堕伒寰粺涓鍏ュ彛锛歵ranslation 浣跨敤 `translation.create_translation_client()`锛宔mbedding / rerank 浣跨敤 providers 宸ュ巶锛岄厤缃潵鑷 `services_config`銆
408 413
409 ### 9.2 閰嶇疆涓庢墿灞 414 ### 9.2 閰嶇疆涓庢墿灞
410 415
@@ -441,6 +446,7 @@ services: @@ -441,6 +446,7 @@ services:
441 | Provider 涓庡熀纭閰嶇疆銆佹ā鍧楁墿灞曪紙鍗忚涓庡悗绔級 | [QUICKSTART.md](./QUICKSTART.md) 搂2鈥撀佹湰鎸囧崡 搂7 | 446 | Provider 涓庡熀纭閰嶇疆銆佹ā鍧楁墿灞曪紙鍗忚涓庡悗绔級 | [QUICKSTART.md](./QUICKSTART.md) 搂2鈥撀佹湰鎸囧崡 搂7 |
442 | 绱㈠紩缁撴瀯銆佹暟鎹祦銆侀氱敤鍖栬璁 | [绯荤粺璁捐鏂囨。.md](./绯荤粺璁捐鏂囨。.md) | 447 | 绱㈠紩缁撴瀯銆佹暟鎹祦銆侀氱敤鍖栬璁 | [绯荤粺璁捐鏂囨。.md](./绯荤粺璁捐鏂囨。.md) |
443 | 鎼滅储/绱㈠紩 API 瀹屾暣璇存槑 | [鎼滅储API瀵规帴鎸囧崡.md](./鎼滅储API瀵规帴鎸囧崡.md) | 448 | 鎼滅储/绱㈠紩 API 瀹屾暣璇存槑 | [鎼滅储API瀵规帴鎸囧崡.md](./鎼滅储API瀵规帴鎸囧崡.md) |
  449 +| 缈昏瘧妯″潡涓庢湰鍦版ā鍨 | [缈昏瘧妯″潡璇存槑.md](./缈昏瘧妯″潡璇存槑.md) |
444 | 鎼滅储 API 鍙傛暟閫熸煡 | [鎼滅储API閫熸煡琛.md](./鎼滅储API閫熸煡琛.md) | 450 | 鎼滅储 API 鍙傛暟閫熸煡 | [鎼滅储API閫熸煡琛.md](./鎼滅储API閫熸煡琛.md) |
445 | 棣栨閮ㄧ讲銆佹柊鏈哄櫒鐜銆佺敓浜у嚟璇 | [QUICKSTART.md](./QUICKSTART.md) 搂1.4鈥.8 | 451 | 棣栨閮ㄧ讲銆佹柊鏈哄櫒鐜銆佺敓浜у嚟璇 | [QUICKSTART.md](./QUICKSTART.md) 搂1.4鈥.8 |
446 | 杩愮淮銆佹棩蹇椼佸鐜銆佹晠闅 | [Usage-Guide.md](./Usage-Guide.md) | 452 | 杩愮淮銆佹棩蹇椼佸鐜銆佹晠闅 | [Usage-Guide.md](./Usage-Guide.md) |
docs/QUICKSTART.md
@@ -162,13 +162,19 @@ curl -X POST http://localhost:6005/embed/image \ @@ -162,13 +162,19 @@ curl -X POST http://localhost:6005/embed/image \
162 #### Translator 服务(6006) 162 #### Translator 服务(6006)
163 163
164 ```bash 164 ```bash
  165 +./scripts/setup_translator_venv.sh
  166 +./.venv-translator/bin/python scripts/download_translation_models.py --all-local # 如需本地模型
165 ./scripts/start_translator.sh 167 ./scripts/start_translator.sh
166 168
167 curl -X POST http://localhost:6006/translate \ 169 curl -X POST http://localhost:6006/translate \
168 -H "Content-Type: application/json" \ 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 #### Reranker 服务(6007) 178 #### Reranker 服务(6007)
173 179
174 ```bash 180 ```bash
@@ -372,25 +378,25 @@ saas-search 以 MySQL 中的店匠标准表为权威数据源: @@ -372,25 +378,25 @@ saas-search 以 MySQL 中的店匠标准表为权威数据源:
372 |--------|------| 378 |--------|------|
373 | 索引结构(mapping) | 修改 `mappings/search_products.json` → `./scripts/create_tenant_index.sh <tenant_id>` → 重新导入 | 379 | 索引结构(mapping) | 修改 `mappings/search_products.json` → `./scripts/create_tenant_index.sh <tenant_id>` → 重新导入 |
374 | 搜索字段/权重/排序/重排 | 修改 `config/config.yaml` 对应块 | 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 ### 3.1 当前代码结构 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 - 配置解析:`config/services_config.py` 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 ### 3.2 配置与覆盖 401 ### 3.2 配置与覆盖
396 402
@@ -399,10 +405,17 @@ saas-search 以 MySQL 中的店匠标准表为权威数据源: @@ -399,10 +405,17 @@ saas-search 以 MySQL 中的店匠标准表为权威数据源:
399 ```yaml 405 ```yaml
400 services: 406 services:
401 translation: 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 embedding: 419 embedding:
407 provider: "http" 420 provider: "http"
408 backend: "tei" 421 backend: "tei"
@@ -419,8 +432,6 @@ services: @@ -419,8 +432,6 @@ services:
419 432
420 环境变量覆盖(优先级更高): 433 环境变量覆盖(优先级更高):
421 434
422 -- `TRANSLATION_PROVIDER`  
423 -- `TRANSLATION_SERVICE_URL`  
424 - `EMBEDDING_SERVICE_URL` 435 - `EMBEDDING_SERVICE_URL`
425 - `EMBEDDING_BACKEND` 436 - `EMBEDDING_BACKEND`
426 - `TEI_BASE_URL` 437 - `TEI_BASE_URL`
@@ -429,11 +440,19 @@ services: @@ -429,11 +440,19 @@ services:
429 - `RERANK_DASHSCOPE_API_KEY_CN` / `RERANK_DASHSCOPE_API_KEY_US`(`dashscope_rerank` 后端鉴权) 440 - `RERANK_DASHSCOPE_API_KEY_CN` / `RERANK_DASHSCOPE_API_KEY_US`(`dashscope_rerank` 后端鉴权)
430 - `RERANK_DASHSCOPE_ENDPOINT`(`dashscope_rerank` 地域 endpoint 覆盖) 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
@@ -86,52 +86,21 @@ translator的设计 : @@ -86,52 +86,21 @@ translator的设计 :
86 86
87 QueryParser 里面 并不是调用的6006,目前是把6006做了一个provider,然后translate的总体配置又有6006的baseurl,很混乱。 87 QueryParser 里面 并不是调用的6006,目前是把6006做了一个provider,然后translate的总体配置又有6006的baseurl,很混乱。
88 88
89 -config.yaml 里面的 翻译的配置 不是“6006 专用配置”,而是搜索服务的  
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,4 +228,3 @@ https://cloud.tencent.com/document/product/1729/113395#4.-.E7.A4.BA.E4.BE.8B
259 228
260 登录 百炼美国地域控制台:https://modelstudio.console.aliyun.com/us-east-1?spm=5176.2020520104.0.0.6b383a98WjpXff 229 登录 百炼美国地域控制台:https://modelstudio.console.aliyun.com/us-east-1?spm=5176.2020520104.0.0.6b383a98WjpXff
261 在 API Key 管理 中创建或复制一个适用于美国地域的 Key 230 在 API Key 管理 中创建或复制一个适用于美国地域的 Key
262 -  
docs/工作总结-微服务性能优化与架构.md
@@ -84,11 +84,10 @@ instruction: &quot;Given a shopping query, rank product titles by relevance&quot; @@ -84,11 +84,10 @@ instruction: &quot;Given a shopping query, rank product titles by relevance&quot;
84 **背景**:原使用 DeepL,后迁移至 **qwen-mt**(如 `qwen-mt-flash`)。qwen-mt 云端限速约 **RPM=60(每分钟 60 请求)**,此前未做大商品量压测,未暴露问题;高并发索引或查询场景下易触限。 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 - **缓存**:`services.translation.cache` 支持 `key_prefix: "trans:v2"`、`ttl_seconds`、`sliding_expiration` 等,翻译结果写 Redis,减轻重复请求对限速的影响。 91 - **缓存**:`services.translation.cache` 支持 `key_prefix: "trans:v2"`、`ttl_seconds`、`sliding_expiration` 等,翻译结果写 Redis,减轻重复请求对限速的影响。
93 - **场景支撑**:在线索引(indexer)与 query 请求(QueryParser)共用同一套 provider 配置;可按环境或租户通过修改 `config.yaml` 或环境变量切换 provider/model。 92 - **场景支撑**:在线索引(indexer)与 query 请求(QueryParser)共用同一套 provider 配置;可按环境或租户通过修改 `config.yaml` 或环境变量切换 provider/model。
94 - **待配合**:**金伟侧对索引侧翻译调用做流量控制**(限流/排队/批量聚合),避免索引高峰打满 qwen 限速,影响在线 query 翻译。 93 - **待配合**:**金伟侧对索引侧翻译调用做流量控制**(限流/排队/批量聚合),避免索引高峰打满 qwen 限速,影响在线 query 翻译。
@@ -113,14 +112,15 @@ instruction: &quot;Given a shopping query, rank product titles by relevance&quot; @@ -113,14 +112,15 @@ instruction: &quot;Given a shopping query, rank product titles by relevance&quot;
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 ### 2. 服务的监控与拉起机制 125 ### 2. 服务的监控与拉起机制
126 126
docs/搜索API对接指南.md
@@ -159,7 +159,7 @@ curl -X POST &quot;http://43.166.252.75:6002/search/&quot; \ @@ -159,7 +159,7 @@ curl -X POST &quot;http://43.166.252.75:6002/search/&quot; \
159 |------|------|------|------| 159 |------|------|------|------|
160 | 向量服务 | 6005 | `POST /embed/text` | 文本向量化 | 160 | 向量服务 | 6005 | `POST /embed/text` | 文本向量化 |
161 | 向量服务 | 6005 | `POST /embed/image` | 图片向量化 | 161 | 向量服务 | 6005 | `POST /embed/image` | 图片向量化 |
162 -| 翻译服务 | 6006 | `POST /translate` | 文本翻译(Qwen/DeepL) | 162 +| 翻译服务 | 6006 | `POST /translate` | 文本翻译(支持 qwen-mt / llm / deepl / 本地模型) |
163 | 重排服务 | 6007 | `POST /rerank` | 检索结果重排 | 163 | 重排服务 | 6007 | `POST /rerank` | 检索结果重排 |
164 | 内容理解(Indexer 内) | 6004 | `POST /indexer/enrich-content` | 根据商品标题生成 qanchors、tags 等,供 indexer 微服务组合方式使用 | 164 | 内容理解(Indexer 内) | 6004 | `POST /indexer/enrich-content` | 根据商品标题生成 qanchors、tags 等,供 indexer 微服务组合方式使用 |
165 165
@@ -1650,7 +1650,7 @@ curl -X POST &quot;http://localhost:6004/indexer/enrich-content&quot; \ @@ -1650,7 +1650,7 @@ curl -X POST &quot;http://localhost:6004/indexer/enrich-content&quot; \
1650 | 服务 | 默认端口 | Base URL | 说明 | 1650 | 服务 | 默认端口 | Base URL | 说明 |
1651 |------|----------|----------|------| 1651 |------|----------|----------|------|
1652 | 向量服务 | 6005 | `http://localhost:6005` | 文本/图片向量化,用于语义搜索与以图搜图 | 1652 | 向量服务 | 6005 | `http://localhost:6005` | 文本/图片向量化,用于语义搜索与以图搜图 |
1653 -| 翻译服务 | 6006 | `http://localhost:6006` | 多语言翻译(Qwen/DeepL) | 1653 +| 翻译服务 | 6006 | `http://localhost:6006` | 多语言翻译(云端与本地模型统一入口) |
1654 | 重排服务 | 6007 | `http://localhost:6007` | 对检索结果进行二次排序 | 1654 | 重排服务 | 6007 | `http://localhost:6007` | 对检索结果进行二次排序 |
1655 1655
1656 生产环境请将 `localhost` 替换为实际服务地址。 1656 生产环境请将 `localhost` 替换为实际服务地址。
@@ -1801,12 +1801,12 @@ curl &quot;http://localhost:6007/health&quot; @@ -1801,12 +1801,12 @@ curl &quot;http://localhost:6007/health&quot;
1801 1801
1802 ### 7.3 翻译服务(Translation) 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 - **启动**: `./scripts/start_translator.sh` 1805 - **启动**: `./scripts/start_translator.sh`
1806 1806
1807 #### 7.3.1 `POST /translate` — 文本翻译 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 ```json 1812 ```json
@@ -1814,8 +1814,8 @@ curl &quot;http://localhost:6007/health&quot; @@ -1814,8 +1814,8 @@ curl &quot;http://localhost:6007/health&quot;
1814 "text": "商品名称", 1814 "text": "商品名称",
1815 "target_lang": "en", 1815 "target_lang": "en",
1816 "source_lang": "zh", 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 &quot;http://localhost:6007/health&quot; @@ -1825,8 +1825,8 @@ curl &quot;http://localhost:6007/health&quot;
1825 "text": ["商品名称1", "商品名称2"], 1825 "text": ["商品名称1", "商品名称2"],
1826 "target_lang": "en", 1826 "target_lang": "en",
1827 "source_lang": "zh", 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 &quot;http://localhost:6007/health&quot; @@ -1834,9 +1834,13 @@ curl &quot;http://localhost:6007/health&quot;
1834 |------|------|------|------| 1834 |------|------|------|------|
1835 | `text` | string \| string[] | Y | 待翻译文本,既支持单条字符串,也支持字符串列表(批量翻译) | 1835 | `text` | string \| string[] | Y | 待翻译文本,既支持单条字符串,也支持字符串列表(批量翻译) |
1836 | `target_lang` | string | Y | 目标语言:`zh`、`en`、`ru` 等 | 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 ```json 1846 ```json
@@ -1846,7 +1850,8 @@ curl &quot;http://localhost:6007/health&quot; @@ -1846,7 +1850,8 @@ curl &quot;http://localhost:6007/health&quot;
1846 "source_lang": "zh", 1850 "source_lang": "zh",
1847 "translated_text": "Product name", 1851 "translated_text": "Product name",
1848 "status": "success", 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 &quot;http://localhost:6007/health&quot; @@ -1858,13 +1863,14 @@ curl &quot;http://localhost:6007/health&quot;
1858 "source_lang": "zh", 1863 "source_lang": "zh",
1859 "translated_text": ["Product name 1", "Product name 2"], 1864 "translated_text": ["Product name 1", "Product name 2"],
1860 "status": "success", 1865 "status": "success",
1861 - "model": "qwen" 1866 + "model": "qwen-mt",
  1867 + "scene": "sku_name"
1862 } 1868 }
1863 ``` 1869 ```
1864 1870
1865 > **失败语义(批量)**:当 `text` 为列表时,如果其中某条翻译失败,对应位置返回 `null`(即 `translated_text[i] = null`),并保持数组长度与顺序不变;接口整体仍返回 `status="success"`,用于避免“部分失败”导致整批请求失败。 1871 > **失败语义(批量)**:当 `text` 为列表时,如果其中某条翻译失败,对应位置返回 `null`(即 `translated_text[i] = null`),并保持数组长度与顺序不变;接口整体仍返回 `status="success"`,用于避免“部分失败”导致整批请求失败。
1866 1872
1867 -> **实现提示(可忽略)**:服务端会尽可能使用底层翻译 provider 的批量能力(若支持),否则自动拆分逐条翻译;无论采用哪种方式,上述批量契约保持一致。 1873 +> **实现提示(可忽略)**:服务端会尽可能使用底层 backend 的批量能力(若支持),否则自动拆分逐条翻译;无论采用哪种方式,上述批量契约保持一致。
1868 1874
1869 **完整 curl 示例**: 1875 **完整 curl 示例**:
1870 1876
@@ -1902,12 +1908,38 @@ curl -X POST &quot;http://localhost:6006/translate&quot; \ @@ -1902,12 +1908,38 @@ curl -X POST &quot;http://localhost:6006/translate&quot; \
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 #### 7.3.2 `GET /health` — 健康检查 1924 #### 7.3.2 `GET /health` — 健康检查
1906 1925
1907 ```bash 1926 ```bash
1908 curl "http://localhost:6006/health" 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 ### 7.4 内容理解字段生成(Indexer 服务内) 1943 ### 7.4 内容理解字段生成(Indexer 服务内)
1912 1944
1913 内容理解字段生成接口部署在 **Indexer 服务**(默认端口 6004)内,与「翻译、向量化」等独立端口微服务并列,供采用**微服务组合**方式的 indexer 调用。 1945 内容理解字段生成接口部署在 **Indexer 服务**(默认端口 6004)内,与「翻译、向量化」等独立端口微服务并列,供采用**微服务组合**方式的 indexer 调用。
docs/系统设计文档.md
@@ -382,16 +382,7 @@ query_config: @@ -382,16 +382,7 @@ query_config:
382 # 实际翻译 provider 与模型在通用 services 配置中定义 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 1. **语言检测**:自动检测查询语言 388 1. **语言检测**:自动检测查询语言
docs/缓存与Redis使用说明.md
@@ -21,7 +21,7 @@ @@ -21,7 +21,7 @@
21 | 模块 / 场景 | Key 模板 | Value 内容示例 | 过期策略 | 备注 | 21 | 模块 / 场景 | Key 模板 | Value 内容示例 | 过期策略 | 备注 |
22 |------------|----------|----------------|----------|------| 22 |------------|----------|----------------|----------|------|
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"]` 控制 | 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 | 商品内容理解缓存(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` | 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,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 - **用途**:缓存 Qwen-MT 翻译(及 translator service 复用的翻译)结果,减少云端请求,遵守限速。 77 - **用途**:缓存 Qwen-MT 翻译(及 translator service 复用的翻译)结果,减少云端请求,遵守限速。
78 - **配置入口**:`config/config.yaml -> services.translation.cache`,统一由 `config/services_config.get_translation_cache_config()` 解析。 78 - **配置入口**:`config/config.yaml -> services.translation.cache`,统一由 `config/services_config.get_translation_cache_config()` 解析。
79 79
@@ -95,8 +95,7 @@ @@ -95,8 +95,7 @@
95 - `sha256(payload)`:对以下内容整体做 SHA-256: 95 - `sha256(payload)`:对以下内容整体做 SHA-256:
96 - `model` 96 - `model`
97 - `src` / `tgt` 97 - `src` / `tgt`
98 - - `context`(受 `key_include_context` 控制)  
99 - - `prompt`(受 `key_include_prompt` 控制) 98 + - `scene`(受 `key_include_scene` 控制)
100 - 原始 `text` 99 - 原始 `text`
101 100
102 > 注意:所有 key 设计集中在 `_build_cache_key`,**不要在其他位置手动拼翻译缓存 key**。 101 > 注意:所有 key 设计集中在 `_build_cache_key`,**不要在其他位置手动拼翻译缓存 key**。
@@ -120,8 +119,7 @@ services: @@ -120,8 +119,7 @@ services:
120 key_prefix: "trans:v2" 119 key_prefix: "trans:v2"
121 ttl_seconds: 62208000 # 默认约 720 天 120 ttl_seconds: 62208000 # 默认约 720 天
122 sliding_expiration: true 121 sliding_expiration: true
123 - key_include_context: true  
124 - key_include_prompt: true 122 + key_include_scene: true
125 key_include_source_lang: true 123 key_include_source_lang: true
126 ``` 124 ```
127 125
@@ -138,7 +136,7 @@ services: @@ -138,7 +136,7 @@ services:
138 136
139 ### 3.4 关联模块 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 - 文档说明:`docs/翻译模块说明.md` 中提到“推荐通过 Redis 翻译缓存复用结果”。 140 - 文档说明:`docs/翻译模块说明.md` 中提到“推荐通过 Redis 翻译缓存复用结果”。
143 141
144 --- 142 ---
@@ -345,4 +343,3 @@ python scripts/redis/redis_memory_heavy_keys.py --top 100 @@ -345,4 +343,3 @@ python scripts/redis/redis_memory_heavy_keys.py --top 100
345 - **文档同步**: 343 - **文档同步**:
346 - 新增缓存后,应在本文件中补充一行总览表 + 详细小节; 344 - 新增缓存后,应在本文件中补充一行总览表 + 详细小节;
347 - 若缓存与外部系统/历史实现兼容(如 Java 侧翻译缓存),需在说明中显式标注。 345 - 若缓存与外部系统/历史实现兼容(如 Java 侧翻译缓存),需在说明中显式标注。
348 -  
docs/翻译模块说明.md
@@ -10,11 +10,6 @@ DASHSCOPE_API_KEY=sk-xxx @@ -10,11 +10,6 @@ DASHSCOPE_API_KEY=sk-xxx
10 10
11 # DeepL 11 # DeepL
12 DEEPL_AUTH_KEY=xxx 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 > **重要限速说明(Qwen 机翻)** 15 > **重要限速说明(Qwen 机翻)**
@@ -29,7 +24,11 @@ TRANSLATION_SCENE=general @@ -29,7 +24,11 @@ TRANSLATION_SCENE=general
29 24
30 - 业务侧(`QueryParser` / indexer)统一调用 `http://127.0.0.1:6006` 25 - 业务侧(`QueryParser` / indexer)统一调用 `http://127.0.0.1:6006`
31 - 服务内按 `services.translation.capabilities` 加载并管理各翻译能力 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 - 外部接口通过 `model + scene` 指定本次使用哪种能力、哪个场景 32 - 外部接口通过 `model + scene` 指定本次使用哪种能力、哪个场景
34 33
35 配置入口在 `config/config.yaml -> services.translation`,核心字段示例: 34 配置入口在 `config/config.yaml -> services.translation`,核心字段示例:
@@ -44,19 +43,65 @@ services: @@ -44,19 +43,65 @@ services:
44 capabilities: 43 capabilities:
45 qwen-mt: 44 qwen-mt:
46 enabled: true 45 enabled: true
  46 + backend: "qwen_mt"
47 model: "qwen-mt-flash" 47 model: "qwen-mt-flash"
  48 + base_url: "https://dashscope-us.aliyuncs.com/compatible-mode/v1"
48 llm: 49 llm:
49 enabled: true 50 enabled: true
  51 + backend: "llm"
50 model: "qwen-flash" 52 model: "qwen-flash"
  53 + base_url: "https://dashscope-us.aliyuncs.com/compatible-mode/v1"
51 deepl: 54 deepl:
52 enabled: false 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 ## HTTP 接口契约(translator service,端口 6006) 100 ## HTTP 接口契约(translator service,端口 6006)
56 101
57 服务默认监听 `http://localhost:6006`,提供: 102 服务默认监听 `http://localhost:6006`,提供:
58 103
59 -- `POST /translate`: 文本翻译(支持 `qwen/qwen-mt`、`deepl`、`llm` 104 +- `POST /translate`: 文本翻译(支持所有已启用 capability
60 - `GET /health`: 健康检查 105 - `GET /health`: 健康检查
61 106
62 ### `POST /translate` 107 ### `POST /translate`
@@ -69,8 +114,7 @@ services: @@ -69,8 +114,7 @@ services:
69 "target_lang": "en", 114 "target_lang": "en",
70 "source_lang": "zh", 115 "source_lang": "zh",
71 "model": "qwen-mt", 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,15 +154,16 @@ services:
110 154
111 说明: 155 说明:
112 156
113 -- `scene` 是标准字段,`context` 仅保留为兼容别名 157 +- `scene` 是标准字段
  158 +- `prompt` 不属于外部接口;LLM prompt 由 translator service 内部根据 `scene` 生成
114 - `model` 只能选择已在 `services.translation.capabilities` 中启用的能力 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 ### 输入输出形状(Shape) 168 ### 输入输出形状(Shape)
124 169
@@ -131,6 +176,6 @@ services: @@ -131,6 +176,6 @@ services:
131 176
132 服务客户端与服务内后端都可以暴露 `supports_batch`。若后端不支持批量,服务端会逐条拆分并保持 shape。 177 服务客户端与服务内后端都可以暴露 `supports_batch`。若后端不支持批量,服务端会逐条拆分并保持 shape。
133 178
134 -为便于上层(如 `api/translator_app.py`)做最优调用,provider 可暴露: 179 +为便于上层(如 `api/translator_app.py`)做最优调用,client / backend 可暴露:
135 180
136 - `supports_batch: bool`(property) 181 - `supports_batch: bool`(property)
@@ -204,17 +204,21 @@ categoryPath.set(categoryLang, translationCategoryPath) @@ -204,17 +204,21 @@ categoryPath.set(categoryLang, translationCategoryPath)
204 你当前要使用的翻译接口(Python 侧): 204 你当前要使用的翻译接口(Python 侧):
205 205
206 ```bash 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 -H "Content-Type: application/json" \ 208 -H "Content-Type: application/json" \
209 -d '{"text":"儿童小男孩女孩开学 100 天衬衫短袖 搞笑图案字母印花庆祝上衣", 209 -d '{"text":"儿童小男孩女孩开学 100 天衬衫短袖 搞笑图案字母印花庆祝上衣",
210 "target_lang":"en", 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 - `text`:待翻译文本; 217 - `text`:待翻译文本;
216 - `target_lang`:目标语言(如 `"en"`、`"zh"` 等); 218 - `target_lang`:目标语言(如 `"en"`、`"zh"` 等);
217 - - `source_lang`:源语言(支持 `"auto"` 自动检测)。 219 + - `source_lang`:源语言;
  220 + - `model`:启用的翻译能力名称;
  221 + - `scene`:翻译场景(如 `sku_name`、`general`)。
218 - 响应(参考 Java `TranslationServiceImpl.querySaasTranslate`): 222 - 响应(参考 Java `TranslationServiceImpl.querySaasTranslate`):
219 - JSON 里包含 `status` 字段,如果是 `"success"`,且 `translated_text` 非空,则返回翻译结果。 223 - JSON 里包含 `status` 字段,如果是 `"success"`,且 `translated_text` 非空,则返回翻译结果。
220 224
indexer/document_transformer.py
@@ -18,9 +18,6 @@ from indexer.product_enrich import analyze_products @@ -18,9 +18,6 @@ from indexer.product_enrich import analyze_products
18 18
19 logger = logging.getLogger(__name__) 19 logger = logging.getLogger(__name__)
20 20
21 -from query.qwen_mt_translate import Translator  
22 -  
23 -  
24 class SPUDocumentTransformer: 21 class SPUDocumentTransformer:
25 """SPU文档转换器,将SPU、SKU、Option数据转换为ES文档格式。""" 22 """SPU文档转换器,将SPU、SKU、Option数据转换为ES文档格式。"""
26 23
@@ -75,7 +72,7 @@ class SPUDocumentTransformer: @@ -75,7 +72,7 @@ class SPUDocumentTransformer:
75 text=text, 72 text=text,
76 target_lang=lang, 73 target_lang=lang,
77 source_lang=source_lang, 74 source_lang=source_lang,
78 - context=scene, 75 + scene=scene,
79 ) 76 )
80 return translations 77 return translations
81 78
@@ -351,7 +348,7 @@ class SPUDocumentTransformer: @@ -351,7 +348,7 @@ class SPUDocumentTransformer:
351 text=brief_text, 348 text=brief_text,
352 source_lang=primary_lang, 349 source_lang=primary_lang,
353 index_languages=index_langs, 350 index_languages=index_langs,
354 - scene="default", 351 + scene="general",
355 ) 352 )
356 _set_lang_obj("brief", brief_text, translations) 353 _set_lang_obj("brief", brief_text, translations)
357 354
@@ -364,7 +361,7 @@ class SPUDocumentTransformer: @@ -364,7 +361,7 @@ class SPUDocumentTransformer:
364 text=desc_text, 361 text=desc_text,
365 source_lang=primary_lang, 362 source_lang=primary_lang,
366 index_languages=index_langs, 363 index_languages=index_langs,
367 - scene="default", 364 + scene="general",
368 ) 365 )
369 _set_lang_obj("description", desc_text, translations) 366 _set_lang_obj("description", desc_text, translations)
370 367
@@ -377,7 +374,7 @@ class SPUDocumentTransformer: @@ -377,7 +374,7 @@ class SPUDocumentTransformer:
377 text=vendor_text, 374 text=vendor_text,
378 source_lang=primary_lang, 375 source_lang=primary_lang,
379 index_languages=index_langs, 376 index_languages=index_langs,
380 - scene="default", 377 + scene="general",
381 ) 378 )
382 _set_lang_obj("vendor", vendor_text, translations) 379 _set_lang_obj("vendor", vendor_text, translations)
383 380
indexer/incremental_service.py
@@ -14,6 +14,7 @@ from indexer.indexer_logger import ( @@ -14,6 +14,7 @@ from indexer.indexer_logger import (
14 get_indexer_logger, log_index_request, log_index_result, log_spu_processing 14 get_indexer_logger, log_index_request, log_index_result, log_spu_processing
15 ) 15 )
16 from config import ConfigLoader 16 from config import ConfigLoader
  17 +from translation import create_translation_client
17 18
18 # Configure logger 19 # Configure logger
19 logger = logging.getLogger(__name__) 20 logger = logging.getLogger(__name__)
@@ -56,9 +57,7 @@ class IncrementalIndexerService: @@ -56,9 +57,7 @@ class IncrementalIndexerService:
56 or ["option1", "option2", "option3"] 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 # Text embedding encoder (strict when enabled) 62 # Text embedding encoder (strict when enabled)
64 if bool(getattr(self._config.query_config, "enable_text_embedding", False)): 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,6 +10,7 @@ from sqlalchemy import Engine, text
10 from config import ConfigLoader 10 from config import ConfigLoader
11 from config.tenant_config_loader import get_tenant_config_loader 11 from config.tenant_config_loader import get_tenant_config_loader
12 from indexer.document_transformer import SPUDocumentTransformer 12 from indexer.document_transformer import SPUDocumentTransformer
  13 +from translation import create_translation_client
13 14
14 logger = logging.getLogger(__name__) 15 logger = logging.getLogger(__name__)
15 16
@@ -100,9 +101,7 @@ def create_document_transformer( @@ -100,9 +101,7 @@ def create_document_transformer(
100 index_langs = tenant_config.get("index_languages") or [] 101 index_langs = tenant_config.get("index_languages") or []
101 need_translator = len(index_langs) > 1 102 need_translator = len(index_langs) > 1
102 if translator is None and need_translator: 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 # 初始化encoder(如果启用标题向量化且未提供encoder) 106 # 初始化encoder(如果启用标题向量化且未提供encoder)
108 if encoder is None and enable_title_embedding and config.query_config.enable_text_embedding: 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,11 +273,8 @@ def test_document_transformer():
273 tenant_config = tenant_config_loader.get_tenant_config('162') 273 tenant_config = tenant_config_loader.get_tenant_config('162')
274 274
275 # 初始化翻译器(测试环境总是启用,具体翻译方向由tenant_config控制) 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 transformer = SPUDocumentTransformer( 280 transformer = SPUDocumentTransformer(
@@ -366,4 +363,3 @@ def main(): @@ -366,4 +363,3 @@ def main():
366 363
367 if __name__ == '__main__': 364 if __name__ == '__main__':
368 sys.exit(main()) 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 from .rerank import create_rerank_provider 3 from .rerank import create_rerank_provider
9 from .embedding import create_embedding_provider 4 from .embedding import create_embedding_provider
10 5
11 __all__ = [ 6 __all__ = [
12 - "create_translation_provider",  
13 "create_rerank_provider", 7 "create_rerank_provider",
14 "create_embedding_provider", 8 "create_embedding_provider",
15 ] 9 ]
providers/translation.py deleted
@@ -1,28 +0,0 @@ @@ -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 - )  
1 """Query package initialization.""" 1 """Query package initialization."""
2 2
3 from .language_detector import LanguageDetector 3 from .language_detector import LanguageDetector
4 -from .qwen_mt_translate import Translator  
5 from .query_rewriter import QueryRewriter, QueryNormalizer 4 from .query_rewriter import QueryRewriter, QueryNormalizer
6 from .query_parser import QueryParser, ParsedQuery 5 from .query_parser import QueryParser, ParsedQuery
7 6
8 __all__ = [ 7 __all__ = [
9 'LanguageDetector', 8 'LanguageDetector',
10 - 'Translator',  
11 'QueryRewriter', 9 'QueryRewriter',
12 'QueryNormalizer', 10 'QueryNormalizer',
13 'QueryParser', 11 'QueryParser',
query/deepl_provider.py deleted
@@ -1,3 +0,0 @@ @@ -1,3 +0,0 @@
1 -"""Backward-compatible import for DeepL translation backend."""  
2 -  
3 -from translation.backends.deepl import DeepLProvider, DeepLTranslationBackend  
query/llm_translate.py deleted
@@ -1,5 +0,0 @@ @@ -1,5 +0,0 @@
1 -"""Backward-compatible import for LLM translation backend."""  
2 -  
3 -from translation.backends.llm import LLMTranslationBackend, LLMTranslatorProvider, llm_translate  
4 -  
5 -__all__ = ["LLMTranslationBackend", "LLMTranslatorProvider", "llm_translate"]  
query/query_parser.py
@@ -12,8 +12,8 @@ from concurrent.futures import ThreadPoolExecutor, as_completed, wait @@ -12,8 +12,8 @@ from concurrent.futures import ThreadPoolExecutor, as_completed, wait
12 12
13 from embeddings.text_encoder import TextEmbeddingEncoder 13 from embeddings.text_encoder import TextEmbeddingEncoder
14 from config import SearchConfig 14 from config import SearchConfig
  15 +from translation import create_translation_client
15 from .language_detector import LanguageDetector 16 from .language_detector import LanguageDetector
16 -from providers import create_translation_provider  
17 from .query_rewriter import QueryRewriter, QueryNormalizer 17 from .query_rewriter import QueryRewriter, QueryNormalizer
18 18
19 logger = logging.getLogger(__name__) 19 logger = logging.getLogger(__name__)
@@ -138,7 +138,7 @@ class QueryParser: @@ -138,7 +138,7 @@ class QueryParser:
138 cfg.service_url, 138 cfg.service_url,
139 cfg.default_model, 139 cfg.default_model,
140 ) 140 )
141 - self._translator = create_translation_provider(self.config.query_config) 141 + self._translator = create_translation_client()
142 self._translation_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="query-translation") 142 self._translation_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="query-translation")
143 143
144 @property 144 @property
query/qwen_mt_translate.py deleted
@@ -1,5 +0,0 @@ @@ -1,5 +0,0 @@
1 -"""Backward-compatible import for Qwen-MT translation backend."""  
2 -  
3 -from translation.backends.qwen_mt import QwenMTTranslationBackend, Translator  
4 -  
5 -__all__ = ["QwenMTTranslationBackend", "Translator"]  
query/test_translation.py 100755 → 100644
1 #!/usr/bin/env python3 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 main() 42 main()
261 -  
requirements_translator_service.txt 0 → 100644
@@ -0,0 +1,20 @@ @@ -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
scripts/download_translation_models.py 0 → 100755
@@ -0,0 +1,61 @@ @@ -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()
scripts/setup_translator_venv.sh 0 → 100755
@@ -0,0 +1,43 @@ @@ -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 #!/bin/bash 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 set -euo pipefail 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 TRANSLATION_HOST="${TRANSLATION_HOST:-0.0.0.0}" 28 TRANSLATION_HOST="${TRANSLATION_HOST:-0.0.0.0}"
12 TRANSLATION_PORT="${TRANSLATION_PORT:-6006}" 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 echo "========================================" 33 echo "========================================"
15 echo "Starting Translation Service" 34 echo "Starting Translation Service"
16 echo "========================================" 35 echo "========================================"
  36 +echo "Python: ${PYTHON_BIN}"
17 echo "Host: ${TRANSLATION_HOST}" 37 echo "Host: ${TRANSLATION_HOST}"
18 echo "Port: ${TRANSLATION_PORT}" 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 echo 45 echo
21 46
22 -exec python -m uvicorn api.translator_app:app \ 47 +exec "${PYTHON_BIN}" -m uvicorn api.translator_app:app \
23 --host "${TRANSLATION_HOST}" \ 48 --host "${TRANSLATION_HOST}" \
24 --port "${TRANSLATION_PORT}" \ 49 --port "${TRANSLATION_PORT}" \
25 --workers 1 50 --workers 1
tests/ci/test_service_api_contracts.py
@@ -9,6 +9,7 @@ import numpy as np @@ -9,6 +9,7 @@ import numpy as np
9 import pandas as pd 9 import pandas as pd
10 import pytest 10 import pytest
11 from fastapi.testclient import TestClient 11 from fastapi.testclient import TestClient
  12 +from translation.scenes import normalize_scene_name
12 13
13 14
14 class _FakeSearcher: 15 class _FakeSearcher:
@@ -571,18 +572,34 @@ def test_embedding_image_contract(embedding_module): @@ -571,18 +572,34 @@ def test_embedding_image_contract(embedding_module):
571 572
572 573
573 class _FakeTranslator: 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 return f"{text}-{target_lang}" 588 return f"{text}-{target_lang}"
579 589
580 590
581 class _FailingTranslator: 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 return None 603 return None
587 604
588 605
@@ -591,7 +608,44 @@ def translator_client(monkeypatch): @@ -591,7 +608,44 @@ def translator_client(monkeypatch):
591 import api.translator_app as translator_app 608 import api.translator_app as translator_app
592 609
593 translator_app.app.router.on_startup.clear() 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 with TestClient(translator_app.app) as client: 650 with TestClient(translator_app.app) as client:
597 yield client 651 yield client
@@ -610,7 +664,44 @@ def test_translator_api_failure_returns_500(monkeypatch): @@ -610,7 +664,44 @@ def test_translator_api_failure_returns_500(monkeypatch):
610 import api.translator_app as translator_app 664 import api.translator_app as translator_app
611 665
612 translator_app.app.router.on_startup.clear() 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 with TestClient(translator_app.app) as client: 706 with TestClient(translator_app.app) as client:
616 response = client.post( 707 response = client.post(
@@ -626,6 +717,7 @@ def test_translator_health_contract(translator_client: TestClient): @@ -626,6 +717,7 @@ def test_translator_health_contract(translator_client: TestClient):
626 response = translator_client.get("/health") 717 response = translator_client.get("/health")
627 assert response.status_code == 200 718 assert response.status_code == 200
628 assert response.json()["status"] == "healthy" 719 assert response.json()["status"] == "healthy"
  720 + assert response.json()["loaded_models"] == ["qwen-mt"]
629 721
630 722
631 class _FakeReranker: 723 class _FakeReranker:
tests/test_translation_local_backends.py 0 → 100644
@@ -0,0 +1,170 @@ @@ -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 class _RecordingRedis: 4 class _RecordingRedis:
@@ -10,7 +10,13 @@ class _RecordingRedis: @@ -10,7 +10,13 @@ class _RecordingRedis:
10 10
11 11
12 def test_translate_failure_returns_none_and_skips_cache(monkeypatch): 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 fake_redis = _RecordingRedis() 20 fake_redis = _RecordingRedis()
15 translator.use_cache = True 21 translator.use_cache = True
16 translator.redis_client = fake_redis 22 translator.redis_client = fake_redis
@@ -23,7 +29,7 @@ def test_translate_failure_returns_none_and_skips_cache(monkeypatch): @@ -23,7 +29,7 @@ def test_translate_failure_returns_none_and_skips_cache(monkeypatch):
23 text="商品标题", 29 text="商品标题",
24 target_lang="en", 30 target_lang="en",
25 source_lang="zh", 31 source_lang="zh",
26 - prompt="translate for product search", 32 + scene="sku_name",
27 ) 33 )
28 34
29 assert result is None 35 assert result is None
translation/__init__.py
1 """Translation package.""" 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,81 +5,30 @@ from __future__ import annotations
5 import logging 5 import logging
6 import os 6 import os
7 import re 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 import requests 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 logger = logging.getLogger(__name__) 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 class DeepLTranslationBackend: 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 def __init__( 19 def __init__(
70 self, 20 self,
71 api_key: Optional[str], 21 api_key: Optional[str],
72 *, 22 *,
73 - timeout: float = 10.0, 23 + api_url: str,
  24 + timeout: float,
74 glossary_id: Optional[str] = None, 25 glossary_id: Optional[str] = None,
75 ) -> None: 26 ) -> None:
76 - cfg = get_translation_config()  
77 - provider_cfg = cfg.get_capability_cfg("deepl")  
78 self.api_key = api_key or os.getenv("DEEPL_AUTH_KEY") 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 self.model = "deepl" 31 self.model = "deepl"
82 - self.context_presets = _merge_contexts(provider_cfg.get("contexts"))  
83 if not self.api_key: 32 if not self.api_key:
84 logger.warning("DEEPL_AUTH_KEY not set; DeepL translation is unavailable") 33 logger.warning("DEEPL_AUTH_KEY not set; DeepL translation is unavailable")
85 34
@@ -90,19 +39,13 @@ class DeepLTranslationBackend: @@ -90,19 +39,13 @@ class DeepLTranslationBackend:
90 def _resolve_request_context( 39 def _resolve_request_context(
91 self, 40 self,
92 target_lang: str, 41 target_lang: str,
93 - context: Optional[str],  
94 - prompt: Optional[str], 42 + scene: Optional[str],
95 ) -> Optional[str]: 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 return scene_map.get(tgt) or scene_map.get("en") 49 return scene_map.get(tgt) or scene_map.get("en")
107 50
108 def translate( 51 def translate(
@@ -110,8 +53,7 @@ class DeepLTranslationBackend: @@ -110,8 +53,7 @@ class DeepLTranslationBackend:
110 text: Union[str, Sequence[str]], 53 text: Union[str, Sequence[str]],
111 target_lang: str, 54 target_lang: str,
112 source_lang: Optional[str] = None, 55 source_lang: Optional[str] = None,
113 - context: Optional[str] = None,  
114 - prompt: Optional[str] = None, 56 + scene: Optional[str] = None,
115 ) -> Union[Optional[str], List[Optional[str]]]: 57 ) -> Union[Optional[str], List[Optional[str]]]:
116 if isinstance(text, (list, tuple)): 58 if isinstance(text, (list, tuple)):
117 results: List[Optional[str]] = [] 59 results: List[Optional[str]] = []
@@ -123,8 +65,7 @@ class DeepLTranslationBackend: @@ -123,8 +65,7 @@ class DeepLTranslationBackend:
123 text=str(item), 65 text=str(item),
124 target_lang=target_lang, 66 target_lang=target_lang,
125 source_lang=source_lang, 67 source_lang=source_lang,
126 - context=context,  
127 - prompt=prompt, 68 + scene=scene,
128 ) 69 )
129 results.append(out) 70 results.append(out)
130 return results 71 return results
@@ -132,13 +73,13 @@ class DeepLTranslationBackend: @@ -132,13 +73,13 @@ class DeepLTranslationBackend:
132 if not self.api_key: 73 if not self.api_key:
133 return None 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 headers = { 77 headers = {
137 "Authorization": f"DeepL-Auth-Key {self.api_key}", 78 "Authorization": f"DeepL-Auth-Key {self.api_key}",
138 "Content-Type": "application/json", 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 text_to_translate, needs_extraction = self._add_ecommerce_context(text, source_lang, api_context) 83 text_to_translate, needs_extraction = self._add_ecommerce_context(text, source_lang, api_context)
143 84
144 payload = { 85 payload = {
@@ -146,14 +87,14 @@ class DeepLTranslationBackend: @@ -146,14 +87,14 @@ class DeepLTranslationBackend:
146 "target_lang": target_code, 87 "target_lang": target_code,
147 } 88 }
148 if source_lang: 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 if api_context: 91 if api_context:
151 payload["context"] = api_context 92 payload["context"] = api_context
152 if self.glossary_id: 93 if self.glossary_id:
153 payload["glossary_id"] = self.glossary_id 94 payload["glossary_id"] = self.glossary_id
154 95
155 try: 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 if response.status_code != 200: 98 if response.status_code != 200:
158 logger.warning( 99 logger.warning(
159 "[deepl] Failed | status=%s tgt=%s body=%s", 100 "[deepl] Failed | status=%s tgt=%s body=%s",
@@ -184,9 +125,9 @@ class DeepLTranslationBackend: @@ -184,9 +125,9 @@ class DeepLTranslationBackend:
184 self, 125 self,
185 text: str, 126 text: str,
186 source_lang: Optional[str], 127 source_lang: Optional[str],
187 - context: Optional[str], 128 + scene: Optional[str],
188 ) -> Tuple[str, bool]: 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 return text, False 131 return text, False
191 if (source_lang or "").lower() != "zh": 132 if (source_lang or "").lower() != "zh":
192 return text, False 133 return text, False
@@ -215,6 +156,3 @@ class DeepLTranslationBackend: @@ -215,6 +156,3 @@ class DeepLTranslationBackend:
215 if normalized not in context_words: 156 if normalized not in context_words:
216 return normalized 157 return normalized
217 return re.sub(r"[.,!?;:]+$", "", words[-1].lower()) 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,15 +10,12 @@ from typing import List, Optional, Sequence, Union
10 from openai import OpenAI 10 from openai import OpenAI
11 11
12 from config.env_config import DASHSCOPE_API_KEY 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 logger = logging.getLogger(__name__) 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 def _build_prompt( 20 def _build_prompt(
24 text: str, 21 text: str,
@@ -27,25 +24,16 @@ def _build_prompt( @@ -27,25 +24,16 @@ def _build_prompt(
27 target_lang: str, 24 target_lang: str,
28 scene: Optional[str], 25 scene: Optional[str],
29 ) -> str: 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 template = group.get(tgt) or group.get("en") 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 return template.format( 38 return template.format(
51 source_lang=source_lang_label, 39 source_lang=source_lang_label,
@@ -60,20 +48,15 @@ class LLMTranslationBackend: @@ -60,20 +48,15 @@ class LLMTranslationBackend:
60 def __init__( 48 def __init__(
61 self, 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 ) -> None: 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 self.client = self._create_client() 60 self.client = self._create_client()
78 61
79 @property 62 @property
@@ -96,22 +79,23 @@ class LLMTranslationBackend: @@ -96,22 +79,23 @@ class LLMTranslationBackend:
96 text: str, 79 text: str,
97 target_lang: str, 80 target_lang: str,
98 source_lang: Optional[str] = None, 81 source_lang: Optional[str] = None,
99 - context: Optional[str] = None,  
100 - prompt: Optional[str] = None, 82 + scene: Optional[str] = None,
101 ) -> Optional[str]: 83 ) -> Optional[str]:
102 if not text or not str(text).strip(): 84 if not text or not str(text).strip():
103 return text 85 return text
104 if not self.client: 86 if not self.client:
105 return None 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 text=text, 95 text=text,
112 source_lang=src, 96 source_lang=src,
113 target_lang=tgt, 97 target_lang=tgt,
114 - scene=scene, 98 + scene=normalized_scene,
115 ) 99 )
116 start = time.time() 100 start = time.time()
117 try: 101 try:
@@ -158,8 +142,7 @@ class LLMTranslationBackend: @@ -158,8 +142,7 @@ class LLMTranslationBackend:
158 text: Union[str, Sequence[str]], 142 text: Union[str, Sequence[str]],
159 target_lang: str, 143 target_lang: str,
160 source_lang: Optional[str] = None, 144 source_lang: Optional[str] = None,
161 - context: Optional[str] = None,  
162 - prompt: Optional[str] = None, 145 + scene: Optional[str] = None,
163 ) -> Union[Optional[str], List[Optional[str]]]: 146 ) -> Union[Optional[str], List[Optional[str]]]:
164 if isinstance(text, (list, tuple)): 147 if isinstance(text, (list, tuple)):
165 results: List[Optional[str]] = [] 148 results: List[Optional[str]] = []
@@ -172,8 +155,7 @@ class LLMTranslationBackend: @@ -172,8 +155,7 @@ class LLMTranslationBackend:
172 text=str(item), 155 text=str(item),
173 target_lang=target_lang, 156 target_lang=target_lang,
174 source_lang=source_lang, 157 source_lang=source_lang,
175 - context=context,  
176 - prompt=prompt, 158 + scene=scene,
177 ) 159 )
178 ) 160 )
179 return results 161 return results
@@ -182,28 +164,5 @@ class LLMTranslationBackend: @@ -182,28 +164,5 @@ class LLMTranslationBackend:
182 text=str(text), 164 text=str(text),
183 target_lang=target_lang, 165 target_lang=target_lang,
184 source_lang=source_lang, 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 - )  
translation/backends/local_seq2seq.py 0 → 100644
@@ -0,0 +1,277 @@ @@ -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,53 +14,49 @@ from openai import OpenAI
14 14
15 from config.env_config import DASHSCOPE_API_KEY, REDIS_CONFIG 15 from config.env_config import DASHSCOPE_API_KEY, REDIS_CONFIG
16 from config.services_config import get_translation_cache_config 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 logger = logging.getLogger(__name__) 19 logger = logging.getLogger(__name__)
20 20
21 21
22 class QwenMTTranslationBackend: 22 class QwenMTTranslationBackend:
23 - QWEN_DEFAULT_BASE_URL = "https://dashscope-us.aliyuncs.com/compatible-mode/v1"  
24 - QWEN_MODEL = "qwen-mt-flash"  
25 - SOURCE_LANG_CODE_MAP = SOURCE_LANG_CODE_MAP  
26 -  
27 def __init__( 23 def __init__(
28 self, 24 self,
29 - model: str = "qwen", 25 + capability_name: str,
  26 + model: str,
  27 + base_url: str,
30 api_key: Optional[str] = None, 28 api_key: Optional[str] = None,
31 use_cache: bool = True, 29 use_cache: bool = True,
32 timeout: int = 10, 30 timeout: int = 10,
33 glossary_id: Optional[str] = None, 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 self.timeout = int(timeout) 37 self.timeout = int(timeout)
38 self.use_cache = bool(use_cache) 38 self.use_cache = bool(use_cache)
39 self.glossary_id = glossary_id 39 self.glossary_id = glossary_id
40 - self.translation_context = translation_context or "e-commerce product search"  
41 40
42 cache_cfg = get_translation_cache_config() 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 self._api_key = api_key or self._default_api_key(self.model) 48 self._api_key = api_key or self._default_api_key(self.model)
52 self._qwen_client: Optional[OpenAI] = None 49 self._qwen_client: Optional[OpenAI] = None
53 - base_url = os.getenv("DASHSCOPE_BASE_URL") or self.QWEN_DEFAULT_BASE_URL  
54 if self._api_key: 50 if self._api_key:
55 try: 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 except Exception as exc: 53 except Exception as exc:
58 logger.warning("Failed to initialize qwen-mt client: %s", exc, exc_info=True) 54 logger.warning("Failed to initialize qwen-mt client: %s", exc, exc_info=True)
59 else: 55 else:
60 logger.warning("DASHSCOPE_API_KEY not set; qwen-mt translation unavailable") 56 logger.warning("DASHSCOPE_API_KEY not set; qwen-mt translation unavailable")
61 57
62 self.redis_client = None 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 self.redis_client = self._init_redis_client() 60 self.redis_client = self._init_redis_client()
65 61
66 @property 62 @property
@@ -68,18 +64,18 @@ class QwenMTTranslationBackend: @@ -68,18 +64,18 @@ class QwenMTTranslationBackend:
68 return True 64 return True
69 65
70 @staticmethod 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 @staticmethod 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 @staticmethod 80 @staticmethod
85 def _default_api_key(model: str) -> Optional[str]: 81 def _default_api_key(model: str) -> Optional[str]:
@@ -109,14 +105,12 @@ class QwenMTTranslationBackend: @@ -109,14 +105,12 @@ class QwenMTTranslationBackend:
109 text: str, 105 text: str,
110 target_lang: str, 106 target_lang: str,
111 source_lang: Optional[str], 107 source_lang: Optional[str],
112 - context: Optional[str],  
113 - prompt: Optional[str], 108 + scene: Optional[str],
114 ) -> str: 109 ) -> str:
115 src = (source_lang or "auto").strip().lower() if self.cache_include_source_lang else "-" 110 src = (source_lang or "auto").strip().lower() if self.cache_include_source_lang else "-"
116 tgt = (target_lang or "").strip().lower() 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 digest = hashlib.sha256(payload.encode("utf-8")).hexdigest() 114 digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()
121 return f"{self.cache_prefix}:{self.model}:{src}:{tgt}:{digest}" 115 return f"{self.cache_prefix}:{self.model}:{src}:{tgt}:{digest}"
122 116
@@ -125,8 +119,7 @@ class QwenMTTranslationBackend: @@ -125,8 +119,7 @@ class QwenMTTranslationBackend:
125 text: Union[str, Sequence[str]], 119 text: Union[str, Sequence[str]],
126 target_lang: str, 120 target_lang: str,
127 source_lang: Optional[str] = None, 121 source_lang: Optional[str] = None,
128 - context: Optional[str] = None,  
129 - prompt: Optional[str] = None, 122 + scene: Optional[str] = None,
130 ) -> Union[Optional[str], List[Optional[str]]]: 123 ) -> Union[Optional[str], List[Optional[str]]]:
131 if isinstance(text, (list, tuple)): 124 if isinstance(text, (list, tuple)):
132 results: List[Optional[str]] = [] 125 results: List[Optional[str]] = []
@@ -138,8 +131,7 @@ class QwenMTTranslationBackend: @@ -138,8 +131,7 @@ class QwenMTTranslationBackend:
138 text=str(item), 131 text=str(item),
139 target_lang=target_lang, 132 target_lang=target_lang,
140 source_lang=source_lang, 133 source_lang=source_lang,
141 - context=context,  
142 - prompt=prompt, 134 + scene=scene,
143 ) 135 )
144 results.append(out) 136 results.append(out)
145 return results 137 return results
@@ -154,15 +146,14 @@ class QwenMTTranslationBackend: @@ -154,15 +146,14 @@ class QwenMTTranslationBackend:
154 if tgt == "zh" and (self._contains_chinese(text) or self._is_pure_number(text)): 146 if tgt == "zh" and (self._contains_chinese(text) or self._is_pure_number(text)):
155 return text 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 if cached is not None: 150 if cached is not None:
160 return cached 151 return cached
161 152
162 result = self._translate_qwen(text, tgt, src) 153 result = self._translate_qwen(text, tgt, src)
163 154
164 if result is not None: 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 return result 157 return result
167 158
168 def _translate_qwen( 159 def _translate_qwen(
@@ -175,8 +166,8 @@ class QwenMTTranslationBackend: @@ -175,8 +166,8 @@ class QwenMTTranslationBackend:
175 return None 166 return None
176 tgt_norm = (target_lang or "").strip().lower() 167 tgt_norm = (target_lang or "").strip().lower()
177 src_norm = (source_lang or "").strip().lower() 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 start = time.time() 171 start = time.time()
181 try: 172 try:
182 completion = self._qwen_client.chat.completions.create( 173 completion = self._qwen_client.chat.completions.create(
@@ -211,12 +202,11 @@ class QwenMTTranslationBackend: @@ -211,12 +202,11 @@ class QwenMTTranslationBackend:
211 text: str, 202 text: str,
212 target_lang: str, 203 target_lang: str,
213 source_lang: Optional[str] = None, 204 source_lang: Optional[str] = None,
214 - context: Optional[str] = None,  
215 - prompt: Optional[str] = None, 205 + scene: Optional[str] = None,
216 ) -> Optional[str]: 206 ) -> Optional[str]:
217 if not self.redis_client: 207 if not self.redis_client:
218 return None 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 try: 210 try:
221 value = self.redis_client.get(key) 211 value = self.redis_client.get(key)
222 if value and self.cache_sliding_expiration: 212 if value and self.cache_sliding_expiration:
@@ -232,12 +222,11 @@ class QwenMTTranslationBackend: @@ -232,12 +222,11 @@ class QwenMTTranslationBackend:
232 target_lang: str, 222 target_lang: str,
233 translation: str, 223 translation: str,
234 source_lang: Optional[str] = None, 224 source_lang: Optional[str] = None,
235 - context: Optional[str] = None,  
236 - prompt: Optional[str] = None, 225 + scene: Optional[str] = None,
237 ) -> None: 226 ) -> None:
238 if not self.redis_client: 227 if not self.redis_client:
239 return 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 try: 230 try:
242 self.redis_client.setex(key, self.expire_seconds, translation) 231 self.redis_client.setex(key, self.expire_seconds, translation)
243 except Exception as exc: 232 except Exception as exc:
@@ -255,6 +244,3 @@ class QwenMTTranslationBackend: @@ -255,6 +244,3 @@ class QwenMTTranslationBackend:
255 @staticmethod 244 @staticmethod
256 def _is_pure_number(text: str) -> bool: 245 def _is_pure_number(text: str) -> bool:
257 return bool(re.fullmatch(r"[\d.\-+%/,: ]+", (text or "").strip())) 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,6 +8,7 @@ from typing import List, Optional, Sequence, Union
8 import requests 8 import requests
9 9
10 from config.services_config import get_translation_config 10 from config.services_config import get_translation_config
  11 +from translation.settings import normalize_translation_model, normalize_translation_scene
11 12
12 logger = logging.getLogger(__name__) 13 logger = logging.getLogger(__name__)
13 14
@@ -24,10 +25,10 @@ class TranslationServiceClient: @@ -24,10 +25,10 @@ class TranslationServiceClient:
24 timeout_sec: Optional[float] = None, 25 timeout_sec: Optional[float] = None,
25 ) -> None: 26 ) -> None:
26 cfg = get_translation_config() 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 @property 33 @property
33 def model(self) -> str: 34 def model(self) -> str:
@@ -42,22 +43,18 @@ class TranslationServiceClient: @@ -42,22 +43,18 @@ class TranslationServiceClient:
42 text: Union[str, Sequence[str]], 43 text: Union[str, Sequence[str]],
43 target_lang: str, 44 target_lang: str,
44 source_lang: Optional[str] = None, 45 source_lang: Optional[str] = None,
45 - context: Optional[str] = None,  
46 - prompt: Optional[str] = None,  
47 - model: Optional[str] = None,  
48 scene: Optional[str] = None, 46 scene: Optional[str] = None,
  47 + model: Optional[str] = None,
49 ) -> Union[Optional[str], List[Optional[str]]]: 48 ) -> Union[Optional[str], List[Optional[str]]]:
50 if isinstance(text, tuple): 49 if isinstance(text, tuple):
51 text = list(text) 50 text = list(text)
52 payload = { 51 payload = {
53 "text": text, 52 "text": text,
54 "target_lang": target_lang, 53 "target_lang": target_lang,
55 - "source_lang": source_lang or "auto", 54 + "source_lang": source_lang,
56 "model": (model or self.default_model), 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 try: 58 try:
62 response = requests.post( 59 response = requests.post(
63 f"{self.base_url}/translate", 60 f"{self.base_url}/translate",
@@ -84,3 +81,8 @@ class TranslationServiceClient: @@ -84,3 +81,8 @@ class TranslationServiceClient:
84 if isinstance(text, (list, tuple)): 81 if isinstance(text, (list, tuple)):
85 return [None for _ in text] 82 return [None for _ in text]
86 return None 83 return None
  84 +
  85 +
  86 +def create_translation_client() -> TranslationServiceClient:
  87 + """Create the business-side translation client."""
  88 + return TranslationServiceClient()
translation/languages.py 0 → 100644
@@ -0,0 +1,67 @@ @@ -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 "general": { 9 "general": {
5 "zh": "你是一名专业的 {source_lang}({src_lang_code})到 {target_lang}({tgt_lang_code})翻译专家,请准确传达原文含义并符合{target_lang}语言习惯,只输出翻译结果:{text}", 10 "zh": "你是一名专业的 {source_lang}({src_lang_code})到 {target_lang}({tgt_lang_code})翻译专家,请准确传达原文含义并符合{target_lang}语言习惯,只输出翻译结果:{text}",
6 "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 "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,9 +16,8 @@ TRANSLATION_PROMPTS = {
11 "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}", 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 "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}", 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 "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}", 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 "sku_name": { 21 "sku_name": {
18 "zh": "你是一名专业的 {source_lang}({src_lang_code})到 {target_lang}({tgt_lang_code})电商翻译专家,请将原文翻译为{target_lang}商品SKU名称,要求准确完整、简洁专业,只输出结果:{text}", 22 "zh": "你是一名专业的 {source_lang}({src_lang_code})到 {target_lang}({tgt_lang_code})电商翻译专家,请将原文翻译为{target_lang}商品SKU名称,要求准确完整、简洁专业,只输出结果:{text}",
19 "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}", 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,9 +28,8 @@ TRANSLATION_PROMPTS = {
24 "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}", 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 "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}", 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 "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}", 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 "ecommerce_search_query": { 33 "ecommerce_search_query": {
31 "zh": "你是一名专业的 {source_lang}({src_lang_code})到 {target_lang}({tgt_lang_code})翻译助手,请将电商搜索词准确翻译为{target_lang}并符合搜索习惯,只输出结果:{text}", 34 "zh": "你是一名专业的 {source_lang}({src_lang_code})到 {target_lang}({tgt_lang_code})翻译助手,请将电商搜索词准确翻译为{target_lang}并符合搜索习惯,只输出结果:{text}",
32 "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}", 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,6 +40,6 @@ TRANSLATION_PROMPTS = {
37 "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}", 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 "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}", 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 "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}", 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,7 +24,6 @@ class TranslationBackendProtocol(Protocol):
24 text: TranslateInput, 24 text: TranslateInput,
25 target_lang: str, 25 target_lang: str,
26 source_lang: Optional[str] = None, 26 source_lang: Optional[str] = None,
27 - context: Optional[str] = None,  
28 - prompt: Optional[str] = None, 27 + scene: Optional[str] = None,
29 ) -> TranslateOutput: 28 ) -> TranslateOutput:
30 ... 29 ...
translation/scenes.py 0 → 100644
@@ -0,0 +1,36 @@ @@ -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,10 +3,18 @@
3 from __future__ import annotations 3 from __future__ import annotations
4 4
5 import logging 5 import logging
  6 +import threading
6 from typing import Dict, List, Optional 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 from translation.protocols import TranslateInput, TranslateOutput, TranslationBackendProtocol 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 logger = logging.getLogger(__name__) 19 logger = logging.getLogger(__name__)
12 20
@@ -14,72 +22,140 @@ logger = logging.getLogger(__name__) @@ -14,72 +22,140 @@ logger = logging.getLogger(__name__)
14 class TranslationService: 22 class TranslationService:
15 """Owns translation backends and routes calls by model and scene.""" 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 self.config = config or get_translation_config() 26 self.config = config or get_translation_config()
  27 + self._enabled_capabilities = self._collect_enabled_capabilities()
19 self._backends: Dict[str, TranslationBackendProtocol] = {} 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 registry = { 50 registry = {
24 - "qwen-mt": self._create_qwen_mt_backend, 51 + "qwen_mt": self._create_qwen_mt_backend,
25 "deepl": self._create_deepl_backend, 52 "deepl": self._create_deepl_backend,
26 "llm": self._create_llm_backend, 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 from translation.backends.qwen_mt import QwenMTTranslationBackend 63 from translation.backends.qwen_mt import QwenMTTranslationBackend
40 64
41 - cfg = self.config.get_capability_cfg("qwen-mt")  
42 return QwenMTTranslationBackend( 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 api_key=cfg.get("api_key"), 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 glossary_id=cfg.get("glossary_id"), 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 from translation.backends.deepl import DeepLTranslationBackend 76 from translation.backends.deepl import DeepLTranslationBackend
53 77
54 - cfg = self.config.get_capability_cfg("deepl")  
55 return DeepLTranslationBackend( 78 return DeepLTranslationBackend(
56 api_key=cfg.get("api_key"), 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 glossary_id=cfg.get("glossary_id"), 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 from translation.backends.llm import LLMTranslationBackend 86 from translation.backends.llm import LLMTranslationBackend
63 87
64 - cfg = self.config.get_capability_cfg("llm")  
65 return LLMTranslationBackend( 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 @property 129 @property
72 def available_models(self) -> List[str]: 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 return list(self._backends.keys()) 135 return list(self._backends.keys())
74 136
75 def get_backend(self, model: Optional[str] = None) -> TranslationBackendProtocol: 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 raise ValueError( 141 raise ValueError(
80 f"Translation model '{normalized}' is not enabled. " 142 f"Translation model '{normalized}' is not enabled. "
81 f"Available models: {', '.join(self.available_models) or 'none'}" 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 return backend 159 return backend
84 160
85 def translate( 161 def translate(
@@ -90,14 +166,12 @@ class TranslationService: @@ -90,14 +166,12 @@ class TranslationService:
90 *, 166 *,
91 model: Optional[str] = None, 167 model: Optional[str] = None,
92 scene: Optional[str] = None, 168 scene: Optional[str] = None,
93 - prompt: Optional[str] = None,  
94 ) -> TranslateOutput: 169 ) -> TranslateOutput:
95 backend = self.get_backend(model) 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 return backend.translate( 172 return backend.translate(
98 text=text, 173 text=text,
99 target_lang=target_lang, 174 target_lang=target_lang,
100 source_lang=source_lang, 175 source_lang=source_lang,
101 - context=active_scene,  
102 - prompt=prompt, 176 + scene=active_scene,
103 ) 177 )
translation/settings.py 0 → 100644
@@ -0,0 +1,210 @@ @@ -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