Commit 0fd2f875d0489b437589ee50a0bd108bdbbda1d9

Authored by tangwang
1 parent 5e4dc8e4

translate

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