Commit 1e370dc970e2b0ca0984afd3280832f01c804d9d

Authored by tangwang
1 parent dba57642

1. 翻译性能优化

1)接口层 translator_app.py 支持并发调用翻译后端(http接口 /translate 处理函数 调用results = _translate_batch 改为 results = await _run_translation_blocking)
2)translation/backends/llm.py 支持batch翻译

2. 缓存清理脚本:
 scripts/redis/purge_caches.py 删除 embedding_prefix:*、embed:*、anchor_prefix:*,以及 trans:* 但会跳过 trans:deepl*
1)dry run方法:
source activate.sh && python scripts/redis/purge_caches.py --dry-run
2)真正的清空缓存:python scripts/redis/purge_caches.py
api/translator_app.py
1 """Translator service HTTP app.""" 1 """Translator service HTTP app."""
2 2
3 import argparse 3 import argparse
  4 +import asyncio
4 import logging 5 import logging
5 import os 6 import os
6 import pathlib 7 import pathlib
7 import time 8 import time
8 import uuid 9 import uuid
9 from contextlib import asynccontextmanager 10 from contextlib import asynccontextmanager
10 -from functools import lru_cache 11 +from functools import lru_cache, partial
11 from logging.handlers import TimedRotatingFileHandler 12 from logging.handlers import TimedRotatingFileHandler
12 from typing import List, Optional, Union 13 from typing import List, Optional, Union
13 14
  15 +import anyio
14 import uvicorn 16 import uvicorn
15 from fastapi import FastAPI, HTTPException, Request 17 from fastapi import FastAPI, HTTPException, Request
16 from fastapi.middleware.cors import CORSMiddleware 18 from fastapi.middleware.cors import CORSMiddleware
@@ -92,6 +94,17 @@ def get_translation_service() -> TranslationService: @@ -92,6 +94,17 @@ def get_translation_service() -> TranslationService:
92 return TranslationService(get_translation_config()) 94 return TranslationService(get_translation_config())
93 95
94 96
  97 +_TRANSLATION_MAX_INFLIGHT = int(os.getenv("TRANSLATION_MAX_INFLIGHT", "4"))
  98 +_translation_inflight_semaphore = asyncio.Semaphore(max(1, _TRANSLATION_MAX_INFLIGHT))
  99 +
  100 +
  101 +async def _run_translation_blocking(fn, /, *args, **kwargs):
  102 + """Run blocking translation work in a thread with bounded concurrency."""
  103 + bound = partial(fn, *args, **kwargs)
  104 + async with _translation_inflight_semaphore:
  105 + return await anyio.to_thread.run_sync(bound)
  106 +
  107 +
95 # Request/Response models 108 # Request/Response models
96 class TranslationRequest(BaseModel): 109 class TranslationRequest(BaseModel):
97 """Translation request model.""" 110 """Translation request model."""
@@ -332,6 +345,7 @@ async def health_check(): @@ -332,6 +345,7 @@ async def health_check():
332 return { 345 return {
333 "status": "healthy", 346 "status": "healthy",
334 "service": "translation", 347 "service": "translation",
  348 + "max_inflight": _TRANSLATION_MAX_INFLIGHT,
335 "default_model": service.config["default_model"], 349 "default_model": service.config["default_model"],
336 "default_scene": service.config["default_scene"], 350 "default_scene": service.config["default_scene"],
337 "available_models": service.available_models, 351 "available_models": service.available_models,
@@ -388,7 +402,8 @@ async def translate(request: TranslationRequest, http_request: Request): @@ -388,7 +402,8 @@ async def translate(request: TranslationRequest, http_request: Request):
388 ) 402 )
389 403
390 if isinstance(raw_text, list): 404 if isinstance(raw_text, list):
391 - results = _translate_batch( 405 + results = await _run_translation_blocking(
  406 + _translate_batch,
392 service, 407 service,
393 raw_text, 408 raw_text,
394 target_lang=request.target_lang, 409 target_lang=request.target_lang,
@@ -422,7 +437,8 @@ async def translate(request: TranslationRequest, http_request: Request): @@ -422,7 +437,8 @@ async def translate(request: TranslationRequest, http_request: Request):
422 scene=scene, 437 scene=scene,
423 ) 438 )
424 439
425 - translated_text = service.translate( 440 + translated_text = await _run_translation_blocking(
  441 + service.translate,
426 text=raw_text, 442 text=raw_text,
427 target_lang=request.target_lang, 443 target_lang=request.target_lang,
428 source_lang=request.source_lang, 444 source_lang=request.source_lang,
scripts/redis/purge_caches.py 0 → 100644
@@ -0,0 +1,194 @@ @@ -0,0 +1,194 @@
  1 +#!/usr/bin/env python3
  2 +"""
  3 +Purge Redis caches used by this repo (exclude trans:deepl*).
  4 +
  5 +Default behavior (db=0):
  6 +- Delete embedding cache keys: {embedding_cache_prefix}:*
  7 + (includes :image: and :clip_text: namespaces)
  8 +- Delete legacy embedding keys: embed:* (older deployments wrote raw logical keys)
  9 +- Delete anchors cache keys: {anchor_cache_prefix}:*
  10 +- Delete translation cache keys: trans:* EXCEPT those starting with "trans:deepl"
  11 +
  12 +Usage:
  13 + source activate.sh
  14 + python scripts/redis/purge_caches.py --dry-run
  15 + python scripts/redis/purge_caches.py
  16 + python scripts/redis/purge_caches.py --db 1
  17 +"""
  18 +
  19 +from __future__ import annotations
  20 +
  21 +import argparse
  22 +import sys
  23 +from pathlib import Path
  24 +from typing import Iterable, List, Optional
  25 +
  26 +import redis
  27 +
  28 +PROJECT_ROOT = Path(__file__).parent.parent.parent
  29 +sys.path.insert(0, str(PROJECT_ROOT))
  30 +
  31 +from config.env_config import REDIS_CONFIG # type: ignore
  32 +
  33 +
  34 +def get_redis_client(db: int) -> redis.Redis:
  35 + return redis.Redis(
  36 + host=REDIS_CONFIG.get("host", "localhost"),
  37 + port=REDIS_CONFIG.get("port", 6479),
  38 + password=REDIS_CONFIG.get("password"),
  39 + db=db,
  40 + decode_responses=True,
  41 + socket_timeout=10,
  42 + socket_connect_timeout=10,
  43 + )
  44 +
  45 +
  46 +def iter_scan_keys(client: redis.Redis, pattern: str, scan_count: int = 2000) -> Iterable[str]:
  47 + cursor = 0
  48 + while True:
  49 + cursor, batch = client.scan(cursor=cursor, match=pattern, count=scan_count)
  50 + for k in batch:
  51 + yield k
  52 + if cursor == 0:
  53 + break
  54 +
  55 +
  56 +def delete_keys(
  57 + *,
  58 + client: redis.Redis,
  59 + keys: Iterable[str],
  60 + dry_run: bool,
  61 + batch_size: int = 1000,
  62 +) -> int:
  63 + deleted = 0
  64 + buf: List[str] = []
  65 +
  66 + def flush() -> int:
  67 + nonlocal buf
  68 + if not buf:
  69 + return 0
  70 + if dry_run:
  71 + n = len(buf)
  72 + buf = []
  73 + return n
  74 + pipe = client.pipeline(transaction=False)
  75 + for k in buf:
  76 + pipe.delete(k)
  77 + results = pipe.execute()
  78 + n = int(sum(1 for r in results if isinstance(r, int) and r > 0))
  79 + buf = []
  80 + return n
  81 +
  82 + for k in keys:
  83 + buf.append(k)
  84 + if len(buf) >= batch_size:
  85 + deleted += flush()
  86 + deleted += flush()
  87 + return deleted
  88 +
  89 +
  90 +def build_tasks(
  91 + *,
  92 + embedding_prefix: str,
  93 + anchor_prefix: str,
  94 + keep_translation_prefix: str,
  95 + include_translation: bool,
  96 +) -> List[dict]:
  97 + tasks = [
  98 + {"name": "embedding", "pattern": f"{embedding_prefix}:*", "exclude_prefix": None},
  99 + {"name": "embedding_legacy_embed", "pattern": "embed:*", "exclude_prefix": None},
  100 + {"name": "anchors", "pattern": f"{anchor_prefix}:*", "exclude_prefix": None},
  101 + ]
  102 + if include_translation:
  103 + tasks.append(
  104 + {
  105 + "name": "translation",
  106 + "pattern": "trans:*",
  107 + "exclude_prefix": keep_translation_prefix,
  108 + }
  109 + )
  110 + return tasks
  111 +
  112 +
  113 +def main() -> None:
  114 + parser = argparse.ArgumentParser(description="Purge Redis caches (skip trans:deepl*)")
  115 + parser.add_argument("--db", type=int, default=0, help="Redis database number (default: 0)")
  116 + parser.add_argument("--dry-run", action="store_true", help="Only count keys; do not delete")
  117 + parser.add_argument(
  118 + "--include-translation",
  119 + action="store_true",
  120 + default=True,
  121 + help="Also purge translation cache (default: true)",
  122 + )
  123 + parser.add_argument(
  124 + "--no-translation",
  125 + dest="include_translation",
  126 + action="store_false",
  127 + help="Do not purge translation cache",
  128 + )
  129 + parser.add_argument(
  130 + "--keep-translation-prefix",
  131 + type=str,
  132 + default="trans:deepl",
  133 + help='Do not delete translation keys starting with this prefix (default: "trans:deepl")',
  134 + )
  135 + parser.add_argument(
  136 + "--embedding-prefix",
  137 + type=str,
  138 + default=str(REDIS_CONFIG.get("embedding_cache_prefix", "embedding")),
  139 + help='Embedding cache prefix (default from REDIS_CONFIG["embedding_cache_prefix"])',
  140 + )
  141 + parser.add_argument(
  142 + "--anchor-prefix",
  143 + type=str,
  144 + default=str(REDIS_CONFIG.get("anchor_cache_prefix", "product_anchors")),
  145 + help='Anchors cache prefix (default from REDIS_CONFIG["anchor_cache_prefix"])',
  146 + )
  147 + parser.add_argument("--batch-size", type=int, default=1000, help="DEL pipeline batch size")
  148 + args = parser.parse_args()
  149 +
  150 + client = get_redis_client(db=args.db)
  151 + client.ping()
  152 +
  153 + tasks = build_tasks(
  154 + embedding_prefix=args.embedding_prefix,
  155 + anchor_prefix=args.anchor_prefix,
  156 + keep_translation_prefix=args.keep_translation_prefix,
  157 + include_translation=args.include_translation,
  158 + )
  159 +
  160 + total_matched = 0
  161 + total_deleted = 0
  162 +
  163 + for t in tasks:
  164 + pattern: str = t["pattern"]
  165 + exclude_prefix: Optional[str] = t["exclude_prefix"]
  166 +
  167 + def filtered_keys() -> Iterable[str]:
  168 + for k in iter_scan_keys(client, pattern=pattern):
  169 + if exclude_prefix and k.startswith(exclude_prefix):
  170 + continue
  171 + yield k
  172 +
  173 + n = delete_keys(
  174 + client=client,
  175 + keys=filtered_keys(),
  176 + dry_run=args.dry_run,
  177 + batch_size=args.batch_size,
  178 + )
  179 + total_matched += n
  180 + if not args.dry_run:
  181 + total_deleted += n
  182 +
  183 + action = "would delete" if args.dry_run else "deleted"
  184 + print(f"[{t['name']}] pattern={pattern} exclude_prefix={exclude_prefix!r} -> {action} {n:,} keys")
  185 +
  186 + if args.dry_run:
  187 + print(f"\nDry run complete. Total keys that would be deleted: {total_matched:,}")
  188 + else:
  189 + print(f"\nPurge complete. Total keys deleted: {total_deleted:,}")
  190 +
  191 +
  192 +if __name__ == "__main__":
  193 + main()
  194 +
tests/test_translation_llm_backend.py 0 → 100644
@@ -0,0 +1,89 @@ @@ -0,0 +1,89 @@
  1 +from types import SimpleNamespace
  2 +
  3 +from translation.backends.llm import LLMTranslationBackend
  4 +
  5 +
  6 +class _FakeCompletions:
  7 + def __init__(self, responses):
  8 + self.responses = list(responses)
  9 + self.calls = []
  10 +
  11 + def create(self, *, model, messages, timeout):
  12 + self.calls.append(
  13 + {
  14 + "model": model,
  15 + "messages": messages,
  16 + "timeout": timeout,
  17 + }
  18 + )
  19 + content = self.responses.pop(0)
  20 + return SimpleNamespace(
  21 + choices=[
  22 + SimpleNamespace(
  23 + message=SimpleNamespace(content=content),
  24 + )
  25 + ]
  26 + )
  27 +
  28 +
  29 +def _build_backend(monkeypatch, responses):
  30 + fake_completions = _FakeCompletions(responses)
  31 + fake_client = SimpleNamespace(chat=SimpleNamespace(completions=fake_completions))
  32 + monkeypatch.setattr(LLMTranslationBackend, "_create_client", lambda self: fake_client)
  33 + backend = LLMTranslationBackend(
  34 + capability_name="llm",
  35 + model="test-model",
  36 + timeout_sec=5.0,
  37 + base_url="https://example.com",
  38 + api_key="test-key",
  39 + )
  40 + return backend, fake_completions
  41 +
  42 +
  43 +def test_llm_translate_batch_uses_single_request(monkeypatch):
  44 + backend, fake_completions = _build_backend(monkeypatch, ["1. Dress\n2. Shirt"])
  45 +
  46 + results = backend.translate(
  47 + ["连衣裙", "衬衫"],
  48 + target_lang="en",
  49 + source_lang="zh",
  50 + scene="sku_name",
  51 + )
  52 +
  53 + assert results == ["Dress", "Shirt"]
  54 + assert len(fake_completions.calls) == 1
  55 + prompt = fake_completions.calls[0]["messages"][0]["content"]
  56 + assert "Output exactly one line for each input item, in the same order, using this exact format:" in prompt
  57 + assert "1. translation\n2. translation" in prompt
  58 + assert "1. 连衣裙\n2. 衬衫" in prompt
  59 +
  60 +
  61 +def test_llm_translate_batch_falls_back_to_single_on_invalid_output(monkeypatch):
  62 + backend, fake_completions = _build_backend(monkeypatch, ["Dress\n2. Shirt", "Dress", "Shirt"])
  63 +
  64 + results = backend.translate(
  65 + ["连衣裙", "衬衫"],
  66 + target_lang="en",
  67 + source_lang="zh",
  68 + scene="sku_name",
  69 + )
  70 +
  71 + assert results == ["Dress", "Shirt"]
  72 + assert len(fake_completions.calls) == 3
  73 +
  74 +
  75 +def test_llm_translate_batch_preserves_empty_items(monkeypatch):
  76 + backend, fake_completions = _build_backend(monkeypatch, ["1. Product"])
  77 +
  78 + results = backend.translate(
  79 + [None, " ", "商品"],
  80 + target_lang="en",
  81 + source_lang="zh",
  82 + scene="general",
  83 + )
  84 +
  85 + assert results == [None, " ", "Product"]
  86 + assert len(fake_completions.calls) == 1
  87 + prompt = fake_completions.calls[0]["messages"][0]["content"]
  88 + assert "1. 商品" in prompt
  89 + assert "Input:\n1. 商品" in prompt
tests/test_translator_failure_semantics.py
@@ -120,6 +120,88 @@ def test_service_caches_all_capabilities(monkeypatch): @@ -120,6 +120,88 @@ def test_service_caches_all_capabilities(monkeypatch):
120 ] 120 ]
121 121
122 122
  123 +def test_service_batch_only_sends_cache_misses_to_backend(monkeypatch):
  124 + monkeypatch.setattr(TranslationCache, "_init_redis_client", staticmethod(lambda: None))
  125 + backend_calls = []
  126 +
  127 + def _fake_create_backend(self, *, name, backend_type, cfg):
  128 + del self, backend_type, cfg
  129 +
  130 + class _Backend:
  131 + model = name
  132 +
  133 + @property
  134 + def supports_batch(self):
  135 + return True
  136 +
  137 + def translate(self, text, target_lang, source_lang=None, scene=None):
  138 + backend_calls.append(
  139 + {
  140 + "text": text,
  141 + "target_lang": target_lang,
  142 + "source_lang": source_lang,
  143 + "scene": scene,
  144 + }
  145 + )
  146 + if isinstance(text, list):
  147 + return [f"{name}:{item}" for item in text]
  148 + return f"{name}:{text}"
  149 +
  150 + return _Backend()
  151 +
  152 + monkeypatch.setattr(TranslationService, "_create_backend", _fake_create_backend)
  153 + service = TranslationService(
  154 + {
  155 + "service_url": "http://127.0.0.1:6006",
  156 + "timeout_sec": 10.0,
  157 + "default_model": "llm",
  158 + "default_scene": "general",
  159 + "capabilities": {
  160 + "llm": {
  161 + "enabled": True,
  162 + "backend": "llm",
  163 + "model": "dummy-llm",
  164 + "base_url": "https://example.com",
  165 + "timeout_sec": 10.0,
  166 + "use_cache": True,
  167 + }
  168 + },
  169 + "cache": {
  170 + "ttl_seconds": 60,
  171 + "sliding_expiration": True,
  172 + },
  173 + }
  174 + )
  175 +
  176 + fake_cache = _FakeCache()
  177 + fake_cache.storage[("llm", "en", "商品1")] = "cached:商品1"
  178 + fake_cache.storage[("llm", "en", "商品3")] = "cached:商品3"
  179 + service._translation_cache = fake_cache
  180 +
  181 + results = service.translate(
  182 + ["商品1", "商品2", "商品3", "商品4"],
  183 + target_lang="en",
  184 + source_lang="zh",
  185 + model="llm",
  186 + scene="sku_name",
  187 + )
  188 +
  189 + assert results == [
  190 + "cached:商品1",
  191 + "llm:商品2",
  192 + "cached:商品3",
  193 + "llm:商品4",
  194 + ]
  195 + assert backend_calls == [
  196 + {
  197 + "text": ["商品2", "商品4"],
  198 + "target_lang": "en",
  199 + "source_lang": "zh",
  200 + "scene": "sku_name",
  201 + }
  202 + ]
  203 +
  204 +
123 def test_translation_request_filter_injects_reqid(): 205 def test_translation_request_filter_injects_reqid():
124 reqid, token = bind_translation_request_id("req-test-1234567890") 206 reqid, token = bind_translation_request_id("req-test-1234567890")
125 try: 207 try:
translation/backends/llm.py
@@ -3,33 +3,48 @@ @@ -3,33 +3,48 @@
3 from __future__ import annotations 3 from __future__ import annotations
4 4
5 import logging 5 import logging
  6 +import re
6 import time 7 import time
7 from typing import List, Optional, Sequence, Union 8 from typing import List, Optional, Sequence, Union
8 9
9 from openai import OpenAI 10 from openai import OpenAI
10 11
11 from translation.languages import LANGUAGE_LABELS 12 from translation.languages import LANGUAGE_LABELS
12 -from translation.prompts import TRANSLATION_PROMPTS 13 +from translation.prompts import BATCH_TRANSLATION_PROMPTS, TRANSLATION_PROMPTS
13 from translation.scenes import normalize_scene_name 14 from translation.scenes import normalize_scene_name
14 15
15 logger = logging.getLogger(__name__) 16 logger = logging.getLogger(__name__)
  17 +_NUMBERED_LINE_RE = re.compile(r"^\s*(\d+)[\.\uFF0E]\s*(.*)\s*$")
16 18
17 19
18 -def _build_prompt(  
19 - text: str, 20 +def _resolve_prompt_template(
  21 + prompt_groups: dict[str, dict[str, str]],
20 *, 22 *,
21 - source_lang: Optional[str],  
22 target_lang: str, 23 target_lang: str,
23 scene: Optional[str], 24 scene: Optional[str],
24 -) -> str: 25 +) -> tuple[str, str, str]:
25 tgt = str(target_lang or "").strip().lower() 26 tgt = str(target_lang or "").strip().lower()
26 - src = str(source_lang or "auto").strip().lower() or "auto"  
27 normalized_scene = normalize_scene_name(scene) 27 normalized_scene = normalize_scene_name(scene)
28 - group = TRANSLATION_PROMPTS[normalized_scene] 28 + group = prompt_groups[normalized_scene]
29 template = group.get(tgt) or group.get("en") 29 template = group.get(tgt) or group.get("en")
30 if template is None: 30 if template is None:
31 raise ValueError(f"Missing llm translation prompt for scene='{normalized_scene}' target_lang='{tgt}'") 31 raise ValueError(f"Missing llm translation prompt for scene='{normalized_scene}' target_lang='{tgt}'")
  32 + return tgt, normalized_scene, template
  33 +
32 34
  35 +def _build_prompt(
  36 + text: str,
  37 + *,
  38 + source_lang: Optional[str],
  39 + target_lang: str,
  40 + scene: Optional[str],
  41 +) -> str:
  42 + src = str(source_lang or "auto").strip().lower() or "auto"
  43 + tgt, _normalized_scene, template = _resolve_prompt_template(
  44 + TRANSLATION_PROMPTS,
  45 + target_lang=target_lang,
  46 + scene=scene,
  47 + )
33 source_lang_label = LANGUAGE_LABELS.get(src, src) 48 source_lang_label = LANGUAGE_LABELS.get(src, src)
34 target_lang_label = LANGUAGE_LABELS.get(tgt, tgt) 49 target_lang_label = LANGUAGE_LABELS.get(tgt, tgt)
35 50
@@ -42,6 +57,63 @@ def _build_prompt( @@ -42,6 +57,63 @@ def _build_prompt(
42 ) 57 )
43 58
44 59
  60 +def _build_batch_prompt(
  61 + texts: Sequence[str],
  62 + *,
  63 + source_lang: Optional[str],
  64 + target_lang: str,
  65 + scene: Optional[str],
  66 +) -> str:
  67 + src = str(source_lang or "auto").strip().lower() or "auto"
  68 + tgt, _normalized_scene, template = _resolve_prompt_template(
  69 + BATCH_TRANSLATION_PROMPTS,
  70 + target_lang=target_lang,
  71 + scene=scene,
  72 + )
  73 + source_lang_label = LANGUAGE_LABELS.get(src, src)
  74 + target_lang_label = LANGUAGE_LABELS.get(tgt, tgt)
  75 + numbered_input = "\n".join(f"{idx}. {item}" for idx, item in enumerate(texts, start=1))
  76 + format_example = "\n".join(f"{idx}. translation" for idx in range(1, len(texts) + 1))
  77 +
  78 + return template.format(
  79 + source_lang=source_lang_label,
  80 + src_lang_code=src,
  81 + target_lang=target_lang_label,
  82 + tgt_lang_code=tgt,
  83 + item_count=len(texts),
  84 + format_example=format_example,
  85 + text=numbered_input,
  86 + )
  87 +
  88 +
  89 +def _parse_batch_translation_output(content: str, *, expected_count: int) -> Optional[List[str]]:
  90 + numbered_lines: dict[int, str] = {}
  91 + for raw_line in content.splitlines():
  92 + stripped = raw_line.strip()
  93 + if not stripped or stripped.startswith("```"):
  94 + continue
  95 + match = _NUMBERED_LINE_RE.match(stripped)
  96 + if match is None:
  97 + logger.warning("[llm] Invalid batch line format | line=%s", raw_line)
  98 + return None
  99 + index = int(match.group(1))
  100 + if index in numbered_lines:
  101 + logger.warning("[llm] Duplicate batch line index | index=%s", index)
  102 + return None
  103 + numbered_lines[index] = match.group(2).strip()
  104 +
  105 + expected_indices = set(range(1, expected_count + 1))
  106 + actual_indices = set(numbered_lines.keys())
  107 + if actual_indices != expected_indices:
  108 + logger.warning(
  109 + "[llm] Batch line indices mismatch | expected=%s actual=%s",
  110 + sorted(expected_indices),
  111 + sorted(actual_indices),
  112 + )
  113 + return None
  114 + return [numbered_lines[idx] for idx in range(1, expected_count + 1)]
  115 +
  116 +
45 class LLMTranslationBackend: 117 class LLMTranslationBackend:
46 def __init__( 118 def __init__(
47 self, 119 self,
@@ -136,6 +208,150 @@ class LLMTranslationBackend: @@ -136,6 +208,150 @@ class LLMTranslationBackend:
136 ) 208 )
137 return None 209 return None
138 210
  211 + def _translate_batch_serial_fallback(
  212 + self,
  213 + texts: Sequence[Optional[str]],
  214 + target_lang: str,
  215 + source_lang: Optional[str] = None,
  216 + scene: Optional[str] = None,
  217 + ) -> List[Optional[str]]:
  218 + results: List[Optional[str]] = []
  219 + for item in texts:
  220 + if item is None:
  221 + results.append(None)
  222 + continue
  223 + normalized = str(item)
  224 + if not normalized.strip():
  225 + results.append(normalized)
  226 + continue
  227 + results.append(
  228 + self._translate_single(
  229 + text=normalized,
  230 + target_lang=target_lang,
  231 + source_lang=source_lang,
  232 + scene=scene,
  233 + )
  234 + )
  235 + return results
  236 +
  237 + def _translate_batch(
  238 + self,
  239 + texts: Sequence[Optional[str]],
  240 + target_lang: str,
  241 + source_lang: Optional[str] = None,
  242 + scene: Optional[str] = None,
  243 + ) -> List[Optional[str]]:
  244 + results: List[Optional[str]] = [None] * len(texts)
  245 + prompt_texts: List[str] = []
  246 + prompt_positions: List[int] = []
  247 +
  248 + for idx, item in enumerate(texts):
  249 + if item is None:
  250 + continue
  251 + normalized = str(item)
  252 + if not normalized.strip():
  253 + results[idx] = normalized
  254 + continue
  255 + if "\n" in normalized or "\r" in normalized:
  256 + logger.info("[llm] Batch fallback to serial | reason=multiline_input item_index=%s", idx)
  257 + return self._translate_batch_serial_fallback(
  258 + texts=texts,
  259 + target_lang=target_lang,
  260 + source_lang=source_lang,
  261 + scene=scene,
  262 + )
  263 + prompt_texts.append(normalized)
  264 + prompt_positions.append(idx)
  265 +
  266 + if not prompt_texts:
  267 + return results
  268 + if not self.client:
  269 + return results
  270 +
  271 + tgt = str(target_lang or "").strip().lower()
  272 + src = str(source_lang or "auto").strip().lower() or "auto"
  273 + if scene is None:
  274 + raise ValueError("llm translation scene is required")
  275 + normalized_scene = normalize_scene_name(scene)
  276 + user_prompt = _build_batch_prompt(
  277 + texts=prompt_texts,
  278 + source_lang=src,
  279 + target_lang=tgt,
  280 + scene=normalized_scene,
  281 + )
  282 +
  283 + start = time.time()
  284 + try:
  285 + logger.info(
  286 + "[llm] Batch request | src=%s tgt=%s model=%s item_count=%s prompt=%s",
  287 + src,
  288 + tgt,
  289 + self.model,
  290 + len(prompt_texts),
  291 + user_prompt,
  292 + )
  293 + completion = self.client.chat.completions.create(
  294 + model=self.model,
  295 + messages=[{"role": "user", "content": user_prompt}],
  296 + timeout=self.timeout_sec,
  297 + )
  298 + content = (completion.choices[0].message.content or "").strip()
  299 + latency_ms = (time.time() - start) * 1000
  300 + if not content:
  301 + logger.warning(
  302 + "[llm] Empty batch result | src=%s tgt=%s item_count=%s latency=%.1fms",
  303 + src,
  304 + tgt,
  305 + len(prompt_texts),
  306 + latency_ms,
  307 + )
  308 + return self._translate_batch_serial_fallback(
  309 + texts=texts,
  310 + target_lang=target_lang,
  311 + source_lang=source_lang,
  312 + scene=scene,
  313 + )
  314 +
  315 + parsed = _parse_batch_translation_output(content, expected_count=len(prompt_texts))
  316 + if parsed is None:
  317 + logger.warning(
  318 + "[llm] Batch parse failed, fallback to serial | src=%s tgt=%s item_count=%s response=%s",
  319 + src,
  320 + tgt,
  321 + len(prompt_texts),
  322 + content,
  323 + )
  324 + return self._translate_batch_serial_fallback(
  325 + texts=texts,
  326 + target_lang=target_lang,
  327 + source_lang=source_lang,
  328 + scene=scene,
  329 + )
  330 +
  331 + for position, translated in zip(prompt_positions, parsed):
  332 + results[position] = translated
  333 + logger.info(
  334 + "[llm] Batch success | src=%s tgt=%s item_count=%s response=%s latency=%.1fms",
  335 + src,
  336 + tgt,
  337 + len(prompt_texts),
  338 + content,
  339 + latency_ms,
  340 + )
  341 + return results
  342 + except Exception as exc:
  343 + latency_ms = (time.time() - start) * 1000
  344 + logger.warning(
  345 + "[llm] Batch failed | src=%s tgt=%s item_count=%s latency=%.1fms error=%s",
  346 + src,
  347 + tgt,
  348 + len(prompt_texts),
  349 + latency_ms,
  350 + exc,
  351 + exc_info=True,
  352 + )
  353 + return results
  354 +
139 def translate( 355 def translate(
140 self, 356 self,
141 text: Union[str, Sequence[str]], 357 text: Union[str, Sequence[str]],
@@ -144,20 +360,12 @@ class LLMTranslationBackend: @@ -144,20 +360,12 @@ class LLMTranslationBackend:
144 scene: Optional[str] = None, 360 scene: Optional[str] = None,
145 ) -> Union[Optional[str], List[Optional[str]]]: 361 ) -> Union[Optional[str], List[Optional[str]]]:
146 if isinstance(text, (list, tuple)): 362 if isinstance(text, (list, tuple)):
147 - results: List[Optional[str]] = []  
148 - for item in text:  
149 - if item is None:  
150 - results.append(None)  
151 - continue  
152 - results.append(  
153 - self._translate_single(  
154 - text=str(item),  
155 - target_lang=target_lang,  
156 - source_lang=source_lang,  
157 - scene=scene,  
158 - )  
159 - )  
160 - return results 363 + return self._translate_batch(
  364 + text,
  365 + target_lang=target_lang,
  366 + source_lang=source_lang,
  367 + scene=scene,
  368 + )
161 369
162 return self._translate_single( 370 return self._translate_single(
163 text=str(text), 371 text=str(text),
translation/prompts.py
@@ -43,3 +43,110 @@ TRANSLATION_PROMPTS: Dict[str, Dict[str, str]] = { @@ -43,3 +43,110 @@ TRANSLATION_PROMPTS: Dict[str, Dict[str, str]] = {
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}", 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 }, 44 },
45 } 45 }
  46 +
  47 +
  48 +BATCH_TRANSLATION_PROMPTS: Dict[str, Dict[str, str]] = {
  49 + "general": {
  50 + "en": (
  51 + "Translate each item from {source_lang} ({src_lang_code}) to {target_lang} ({tgt_lang_code}). "
  52 + "Accurately and completely preserve the meaning of the original text, including key features and attributes.\n"
  53 + "Output exactly one line for each input item, in the same order, using this exact format:\n"
  54 + "1. translation\n"
  55 + "2. translation\n"
  56 + "...\n"
  57 + "Do not explain or output anything else.\n"
  58 + "Input:\n{text}"
  59 + ),
  60 + "zh": (
  61 + "将每一项从 {source_lang} ({src_lang_code}) 翻译为 {target_lang} ({tgt_lang_code})。"
  62 + "请准确完整地保留原文含义,包括关键特征和属性。\n"
  63 + "请按输入顺序逐行输出,每个输入对应一行,格式必须如下:\n"
  64 + "1. 翻译结果\n"
  65 + "2. 翻译结果\n"
  66 + "...\n"
  67 + "不要解释或输出其他任何内容。\n"
  68 + "输入:\n{text}"
  69 + ),
  70 + "ru": (
  71 + "Переведите каждый элемент с {source_lang} ({src_lang_code}) на {target_lang} ({tgt_lang_code}). "
  72 + "Точно и полностью сохраняйте смысл исходного текста, включая ключевые характеристики и свойства.\n"
  73 + "Выводите ровно по одной строке для каждого входного элемента в том же порядке, в следующем формате:\n"
  74 + "1. перевод\n"
  75 + "2. перевод\n"
  76 + "...\n"
  77 + "Не добавляйте объяснений и ничего лишнего.\n"
  78 + "Входные данные:\n{text}"
  79 + ),
  80 + },
  81 + "sku_name": {
  82 + "en": (
  83 + "Translate each item from {source_lang} ({src_lang_code}) to a {target_lang} ({tgt_lang_code}) product title.\n"
  84 + "Accurately and completely preserve the meaning of the original text, including key features and attributes.\n"
  85 + "As a product title, keep the style concise, clear, professional, and natural in {target_lang}.\n"
  86 + "Output exactly one line for each input item, in the same order, using this exact format:\n"
  87 + "1. translation\n"
  88 + "2. translation\n"
  89 + "...\n"
  90 + "Do not explain or output anything else.\n"
  91 + "Input:\n{text}"
  92 + ),
  93 + "zh": (
  94 + "将每一项从 {source_lang} ({src_lang_code}) 翻译为 {target_lang} ({tgt_lang_code}) 的商品标题。\n"
  95 + "请准确完整地保留原文含义,包括关键特征和属性。\n"
  96 + "作为商品标题,请保持简洁、清晰、专业,并符合 {target_lang} 的自然表达。\n"
  97 + "请按输入顺序逐行输出,每个输入对应一行,格式必须如下:\n"
  98 + "1. 翻译结果\n"
  99 + "2. 翻译结果\n"
  100 + "...\n"
  101 + "不要解释或输出其他任何内容。\n"
  102 + "输入:\n{text}"
  103 + ),
  104 + "ru": (
  105 + "Переведите каждый элемент с {source_lang} ({src_lang_code}) на {target_lang} ({tgt_lang_code}) в виде названия товара.\n"
  106 + "Точно и полностью сохраняйте смысл исходного текста, включая ключевые характеристики и свойства.\n"
  107 + "Как название товара, текст должен быть кратким, ясным, профессиональным и естественным на {target_lang}.\n"
  108 + "Выводите ровно по одной строке для каждого входного элемента в том же порядке, в следующем формате:\n"
  109 + "1. перевод\n"
  110 + "2. перевод\n"
  111 + "...\n"
  112 + "Не добавляйте объяснений и ничего лишнего.\n"
  113 + "Входные данные:\n{text}"
  114 + ),
  115 + },
  116 + "ecommerce_search_query": {
  117 + "en": (
  118 + "Translate each item from {source_lang} ({src_lang_code}) to a natural {target_lang} ({tgt_lang_code}) "
  119 + "ecommerce search query.\n"
  120 + "Accurately and completely preserve the meaning of the original text, including key features and attributes.\n"
  121 + "Use concise search-style wording.\n"
  122 + "Output exactly one line for each input item, in the same order, using this exact format:\n"
  123 + "1. translation\n"
  124 + "2. translation\n"
  125 + "...\n"
  126 + "Do not explain or output anything else.\n"
  127 + "Input:\n{text}"
  128 + ),
  129 + "zh": (
  130 + "将每一项从 {source_lang} ({src_lang_code}) 翻译为自然的 {target_lang} ({tgt_lang_code}) 电商搜索词。\n"
  131 + "请准确完整地保留原文含义,包括关键特征和属性。\n"
  132 + "使用简洁的搜索关键词风格表达。\n"
  133 + "请按输入顺序逐行输出,每个输入对应一行,格式必须如下:\n"
  134 + "1. 翻译结果\n"
  135 + "2. 翻译结果\n"
  136 + "...\n"
  137 + "不要解释或输出其他任何内容。\n"
  138 + "输入:\n{text}"
  139 + ),
  140 + "ru": (
  141 + "Переведите каждый элемент с {source_lang} ({src_lang_code}) на естественный поисковый запрос для электронной коммерции на {target_lang} ({tgt_lang_code}).\n"
  142 + "Точно и полностью сохраняйте смысл исходного текста, включая ключевые характеристики и свойства.\n"
  143 + "Используйте краткую форму, характерную для поисковых запросов.\n"
  144 + "Выводите ровно по одной строке для каждого входного элемента в том же порядке, в следующем формате:\n"
  145 + "1. перевод\n"
  146 + "2. перевод\n"
  147 + "...\n"
  148 + "Не добавляйте объяснений и ничего лишнего.\n"
  149 + "Входные данные:\n{text}"
  150 + ),
  151 + },
  152 +}
46 \ No newline at end of file 153 \ No newline at end of file