Commit 1e370dc970e2b0ca0984afd3280832f01c804d9d
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
Showing
6 changed files
with
720 additions
and
24 deletions
Show diff stats
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, | ... | ... |
| ... | ... | @@ -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 | + | ... | ... |
| ... | ... | @@ -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 | ... | ... |