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 1 """Translator service HTTP app."""
2 2  
3 3 import argparse
  4 +import asyncio
4 5 import logging
5 6 import os
6 7 import pathlib
7 8 import time
8 9 import uuid
9 10 from contextlib import asynccontextmanager
10   -from functools import lru_cache
  11 +from functools import lru_cache, partial
11 12 from logging.handlers import TimedRotatingFileHandler
12 13 from typing import List, Optional, Union
13 14  
  15 +import anyio
14 16 import uvicorn
15 17 from fastapi import FastAPI, HTTPException, Request
16 18 from fastapi.middleware.cors import CORSMiddleware
... ... @@ -92,6 +94,17 @@ def get_translation_service() -> TranslationService:
92 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 108 # Request/Response models
96 109 class TranslationRequest(BaseModel):
97 110 """Translation request model."""
... ... @@ -332,6 +345,7 @@ async def health_check():
332 345 return {
333 346 "status": "healthy",
334 347 "service": "translation",
  348 + "max_inflight": _TRANSLATION_MAX_INFLIGHT,
335 349 "default_model": service.config["default_model"],
336 350 "default_scene": service.config["default_scene"],
337 351 "available_models": service.available_models,
... ... @@ -388,7 +402,8 @@ async def translate(request: TranslationRequest, http_request: Request):
388 402 )
389 403  
390 404 if isinstance(raw_text, list):
391   - results = _translate_batch(
  405 + results = await _run_translation_blocking(
  406 + _translate_batch,
392 407 service,
393 408 raw_text,
394 409 target_lang=request.target_lang,
... ... @@ -422,7 +437,8 @@ async def translate(request: TranslationRequest, http_request: Request):
422 437 scene=scene,
423 438 )
424 439  
425   - translated_text = service.translate(
  440 + translated_text = await _run_translation_blocking(
  441 + service.translate,
426 442 text=raw_text,
427 443 target_lang=request.target_lang,
428 444 source_lang=request.source_lang,
... ...
scripts/redis/purge_caches.py 0 → 100644
... ... @@ -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 @@
  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 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 205 def test_translation_request_filter_injects_reqid():
124 206 reqid, token = bind_translation_request_id("req-test-1234567890")
125 207 try:
... ...
translation/backends/llm.py
... ... @@ -3,33 +3,48 @@
3 3 from __future__ import annotations
4 4  
5 5 import logging
  6 +import re
6 7 import time
7 8 from typing import List, Optional, Sequence, Union
8 9  
9 10 from openai import OpenAI
10 11  
11 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 14 from translation.scenes import normalize_scene_name
14 15  
15 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 23 target_lang: str,
23 24 scene: Optional[str],
24   -) -> str:
  25 +) -> tuple[str, str, str]:
25 26 tgt = str(target_lang or "").strip().lower()
26   - src = str(source_lang or "auto").strip().lower() or "auto"
27 27 normalized_scene = normalize_scene_name(scene)
28   - group = TRANSLATION_PROMPTS[normalized_scene]
  28 + group = prompt_groups[normalized_scene]
29 29 template = group.get(tgt) or group.get("en")
30 30 if template is None:
31 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 48 source_lang_label = LANGUAGE_LABELS.get(src, src)
34 49 target_lang_label = LANGUAGE_LABELS.get(tgt, tgt)
35 50  
... ... @@ -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 117 class LLMTranslationBackend:
46 118 def __init__(
47 119 self,
... ... @@ -136,6 +208,150 @@ class LLMTranslationBackend:
136 208 )
137 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 355 def translate(
140 356 self,
141 357 text: Union[str, Sequence[str]],
... ... @@ -144,20 +360,12 @@ class LLMTranslationBackend:
144 360 scene: Optional[str] = None,
145 361 ) -> Union[Optional[str], List[Optional[str]]]:
146 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 370 return self._translate_single(
163 371 text=str(text),
... ...
translation/prompts.py
... ... @@ -43,3 +43,110 @@ TRANSLATION_PROMPTS: Dict[str, Dict[str, str]] = {
43 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 153 \ No newline at end of file
... ...