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 | """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, |
| @@ -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 | + |
| @@ -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 |