Commit 14e67b717157040d906b1e33098f59e1f9d66ba3

Authored by tangwang
1 parent 294c3d0a

分句后的 batching 现在是“先全量分句,再按 segment 总数按模型 batch_size

推理”,不再是先按原始输入条数切块。也就是说,如果 100 条请求分句后变成
150 个 segments,batch_size=64 时会按 64 + 64 + 22
三批推理,推理完再按原始分句计划合并并还原成 100 条返回。这个改动在
local_seq2seq.py (line 241) 和 local_ctranslate2.py (line 391)。

日志这边也补上了两层你要的关键信息:

分句摘要日志:Translation segmentation
summary,会打印输入条数、非空条数、发生分句的输入数、总 segments
数、当前 batch_size、每条输入分成多少段的统计,见 local_seq2seq.py (line
216) 和 local_ctranslate2.py (line 366)。
每个预测批次日志:Translation inference
batch,会打印第几批、总批数、该批 segment
数、长度统计、首条预览。CTranslate2 另外还会打印 Translation model batch
detail,补充 token 长度和 max_decoding_length,见 local_ctranslate2.py
(line 294)。
我也补了测试,覆盖了“分句后再
batching”和“日志中有分句摘要与每批推理日志”,在
test_translation_local_backends.py (line 358)。
@@ -23,12 +23,24 @@ This is a **production-ready Multi-Tenant E-Commerce Search SaaS** platform spec @@ -23,12 +23,24 @@ This is a **production-ready Multi-Tenant E-Commerce Search SaaS** platform spec
23 23
24 ## Development Environment 24 ## Development Environment
25 25
26 -**Required Environment Setup:** Use project root `activate.sh` (activates conda env `searchengine` and loads `.env`). On a new machine, set `CONDA_ROOT` if conda is not at default path. 26 +**Required Environment Setup:** Default to the project venv via root `activate.sh` (activates `./.venv` and loads `.env`). Do not default to system `python3`/`pip3` for repo work.
27 ```bash 27 ```bash
28 -# Optional on new machine: if conda is ~/anaconda3/bin/conda → export CONDA_ROOT=$HOME/anaconda3  
29 source activate.sh 28 source activate.sh
30 ``` 29 ```
31 -See `docs/QUICKSTART.md` §1.4–1.8 for first-time env creation and production credentials (venv: `./scripts/create_venv.sh`; conda: `conda env create -f environment.yml` or `pip install -r requirements.txt`). 30 +See `docs/QUICKSTART.md` §1.4–1.8 for first-time env creation and production credentials (`./scripts/create_venv.sh` for the main venv).
  31 +
  32 +**Environment Resolution Rules:**
  33 +- Main app, backend, indexer, frontend, generic scripts, and most tests: `./.venv`
  34 +- Translator service runtime and local translation model tooling: `./.venv-translator`
  35 +- Embedding service runtime: `./.venv-embedding`
  36 +- Reranker service runtime: `./.venv-reranker`
  37 +- CN-CLIP service runtime: `./.venv-cnclip`
  38 +- Never assume the system interpreter reflects project dependencies; prefer `source activate.sh` or invoke the exact venv binary directly.
  39 +
  40 +**Operational Rule For Commands:**
  41 +- For repo-wide `pytest`, ad hoc Python scripts, and lightweight development commands, use the matching venv interpreter first.
  42 +- For isolated services, prefer the service scripts (`./scripts/start_translator.sh`, `./scripts/start_embedding_service.sh`, `./scripts/start_reranker.sh`, `./scripts/start_cnclip_service.sh`) because they already select the correct environment.
  43 +- If a dependency appears “missing”, check whether the command was run under the wrong venv before proposing installs.
32 44
33 **Database Configuration:** 45 **Database Configuration:**
34 ```yaml 46 ```yaml
@@ -49,12 +61,14 @@ password: P89cZHS5d7dFyc9R @@ -49,12 +61,14 @@ password: P89cZHS5d7dFyc9R
49 61
50 ### Environment Setup 62 ### Environment Setup
51 ```bash 63 ```bash
52 -# Activate environment (canonical: use activate.sh) 64 +# Activate main project environment (canonical)
53 source activate.sh 65 source activate.sh
54 66
55 # First-time / new machine: create env and install deps 67 # First-time / new machine: create env and install deps
56 -./setup.sh # or: conda env create -f environment.yml  
57 -# If pip-only: pip install -r requirements.txt 68 +./setup.sh
  69 +# or:
  70 +./scripts/create_venv.sh
  71 +source activate.sh
58 ``` 72 ```
59 73
60 ### Data Management 74 ### Data Management
@@ -83,12 +97,12 @@ python main.py serve --host 0.0.0.0 --port 6002 --reload @@ -83,12 +97,12 @@ python main.py serve --host 0.0.0.0 --port 6002 --reload
83 ### Testing 97 ### Testing
84 ```bash 98 ```bash
85 # Run all tests 99 # Run all tests
86 -python -m pytest tests/ 100 +pytest tests/
87 101
88 # Run specific test types 102 # Run specific test types
89 -python -m pytest tests/unit/ # Unit tests  
90 -python -m pytest tests/integration/ # Integration tests  
91 -python -m pytest -m "api" # API tests only 103 +pytest tests/unit/ # Unit tests
  104 +pytest tests/integration/ # Integration tests
  105 +pytest -m "api" # API tests only
92 106
93 # Test search from command line 107 # Test search from command line
94 python main.py search "query" --tenant-id 1 --size 10 108 python main.py search "query" --tenant-id 1 --size 10
@@ -602,4 +616,3 @@ python main.py search "query" --tenant-id 1 # Quick search test @@ -602,4 +616,3 @@ python main.py search "query" --tenant-id 1 # Quick search test
602 8. **Multi-tenant Architecture**: Single index with `tenant_id` isolation 616 8. **Multi-tenant Architecture**: Single index with `tenant_id` isolation
603 9. **Hybrid Search**: BM25 + vector similarity with configurable weighting 617 9. **Hybrid Search**: BM25 + vector similarity with configurable weighting
604 10. **Production Ready**: Health checks, monitoring, graceful degradation 618 10. **Production Ready**: Health checks, monitoring, graceful degradation
605 -  
api/translator_app.py
@@ -5,18 +5,24 @@ import logging @@ -5,18 +5,24 @@ import logging
5 import os 5 import os
6 import pathlib 6 import pathlib
7 import time 7 import time
  8 +import uuid
8 from contextlib import asynccontextmanager 9 from contextlib import asynccontextmanager
9 from functools import lru_cache 10 from functools import lru_cache
10 from logging.handlers import TimedRotatingFileHandler 11 from logging.handlers import TimedRotatingFileHandler
11 from typing import List, Optional, Union 12 from typing import List, Optional, Union
12 13
13 import uvicorn 14 import uvicorn
14 -from fastapi import FastAPI, HTTPException 15 +from fastapi import FastAPI, HTTPException, Request
15 from fastapi.middleware.cors import CORSMiddleware 16 from fastapi.middleware.cors import CORSMiddleware
16 from fastapi.responses import JSONResponse 17 from fastapi.responses import JSONResponse
17 from pydantic import BaseModel, ConfigDict, Field 18 from pydantic import BaseModel, ConfigDict, Field
18 19
19 from config.services_config import get_translation_config 20 from config.services_config import get_translation_config
  21 +from translation.logging_utils import (
  22 + TranslationRequestFilter,
  23 + bind_translation_request_id,
  24 + reset_translation_request_id,
  25 +)
20 from translation.service import TranslationService 26 from translation.service import TranslationService
21 from translation.settings import ( 27 from translation.settings import (
22 get_enabled_translation_models, 28 get_enabled_translation_models,
@@ -33,7 +39,8 @@ def configure_translator_logging() -> None: @@ -33,7 +39,8 @@ def configure_translator_logging() -> None:
33 39
34 log_level = os.getenv("LOG_LEVEL", "INFO").upper() 40 log_level = os.getenv("LOG_LEVEL", "INFO").upper()
35 numeric_level = getattr(logging, log_level, logging.INFO) 41 numeric_level = getattr(logging, log_level, logging.INFO)
36 - formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") 42 + formatter = logging.Formatter("%(asctime)s | reqid:%(reqid)s | %(name)s | %(levelname)s | %(message)s")
  43 + request_filter = TranslationRequestFilter()
37 44
38 root_logger = logging.getLogger() 45 root_logger = logging.getLogger()
39 root_logger.setLevel(numeric_level) 46 root_logger.setLevel(numeric_level)
@@ -42,6 +49,7 @@ def configure_translator_logging() -> None: @@ -42,6 +49,7 @@ def configure_translator_logging() -> None:
42 console_handler = logging.StreamHandler() 49 console_handler = logging.StreamHandler()
43 console_handler.setLevel(numeric_level) 50 console_handler.setLevel(numeric_level)
44 console_handler.setFormatter(formatter) 51 console_handler.setFormatter(formatter)
  52 + console_handler.addFilter(request_filter)
45 root_logger.addHandler(console_handler) 53 root_logger.addHandler(console_handler)
46 54
47 file_handler = TimedRotatingFileHandler( 55 file_handler = TimedRotatingFileHandler(
@@ -53,6 +61,7 @@ def configure_translator_logging() -> None: @@ -53,6 +61,7 @@ def configure_translator_logging() -> None:
53 ) 61 )
54 file_handler.setLevel(numeric_level) 62 file_handler.setLevel(numeric_level)
55 file_handler.setFormatter(formatter) 63 file_handler.setFormatter(formatter)
  64 + file_handler.addFilter(request_filter)
56 root_logger.addHandler(file_handler) 65 root_logger.addHandler(file_handler)
57 66
58 verbose_logger = logging.getLogger("translator.verbose") 67 verbose_logger = logging.getLogger("translator.verbose")
@@ -69,6 +78,7 @@ def configure_translator_logging() -> None: @@ -69,6 +78,7 @@ def configure_translator_logging() -> None:
69 ) 78 )
70 verbose_handler.setLevel(numeric_level) 79 verbose_handler.setLevel(numeric_level)
71 verbose_handler.setFormatter(formatter) 80 verbose_handler.setFormatter(formatter)
  81 + verbose_handler.addFilter(request_filter)
72 verbose_logger.addHandler(verbose_handler) 82 verbose_logger.addHandler(verbose_handler)
73 83
74 84
@@ -178,6 +188,13 @@ def _result_preview(translated: Union[str, List[Optional[str]], None]) -> str: @@ -178,6 +188,13 @@ def _result_preview(translated: Union[str, List[Optional[str]], None]) -> str:
178 return _text_preview(str(translated)) 188 return _text_preview(str(translated))
179 189
180 190
  191 +def _resolve_request_id(http_request: Request) -> str:
  192 + header_value = http_request.headers.get("X-Request-ID")
  193 + if header_value and header_value.strip():
  194 + return header_value.strip()[:32]
  195 + return str(uuid.uuid4())[:8]
  196 +
  197 +
181 def _translate_batch( 198 def _translate_batch(
182 service: TranslationService, 199 service: TranslationService,
183 raw_text: List[str], 200 raw_text: List[str],
@@ -189,15 +206,11 @@ def _translate_batch( @@ -189,15 +206,11 @@ def _translate_batch(
189 ) -> List[Optional[str]]: 206 ) -> List[Optional[str]]:
190 backend = service.get_backend(model) 207 backend = service.get_backend(model)
191 logger.info( 208 logger.info(
192 - "Translation batch dispatch | model=%s scene=%s target_lang=%s source_lang=%s count=%s lengths=%s first_preview=%s supports_batch=%s",  
193 - model,  
194 - scene,  
195 - target_lang,  
196 - source_lang or "auto", 209 + "Translation batch dispatch | execution=%s count=%s lengths=%s first_preview=%s",
  210 + "backend-batch" if getattr(backend, "supports_batch", False) else "per-item",
197 len(raw_text), 211 len(raw_text),
198 [len(str(item or "")) for item in raw_text], 212 [len(str(item or "")) for item in raw_text],
199 _text_preview(raw_text[0] if raw_text else ""), 213 _text_preview(raw_text[0] if raw_text else ""),
200 - bool(getattr(backend, "supports_batch", False)),  
201 ) 214 )
202 if getattr(backend, "supports_batch", False): 215 if getattr(backend, "supports_batch", False):
203 try: 216 try:
@@ -330,12 +343,13 @@ async def health_check(): @@ -330,12 +343,13 @@ async def health_check():
330 343
331 344
332 @app.post("/translate", response_model=TranslationResponse) 345 @app.post("/translate", response_model=TranslationResponse)
333 -async def translate(request: TranslationRequest): 346 +async def translate(request: TranslationRequest, http_request: Request):
334 _ensure_valid_text(request.text) 347 _ensure_valid_text(request.text)
335 348
336 if not request.target_lang: 349 if not request.target_lang:
337 raise HTTPException(status_code=400, detail="target_lang is required") 350 raise HTTPException(status_code=400, detail="target_lang is required")
338 351
  352 + _, request_token = bind_translation_request_id(_resolve_request_id(http_request))
339 request_started = time.perf_counter() 353 request_started = time.perf_counter()
340 try: 354 try:
341 service = get_translation_service() 355 service = get_translation_service()
@@ -447,12 +461,14 @@ async def translate(request: TranslationRequest): @@ -447,12 +461,14 @@ async def translate(request: TranslationRequest):
447 raise 461 raise
448 except ValueError as e: 462 except ValueError as e:
449 latency_ms = (time.perf_counter() - request_started) * 1000 463 latency_ms = (time.perf_counter() - request_started) * 1000
450 - logger.warning("Translation validation error | error=%s latency_ms=%.2f", e, latency_ms, exc_info=True) 464 + logger.warning("Translation validation error | error=%s latency_ms=%.2f", e, latency_ms)
451 raise HTTPException(status_code=400, detail=str(e)) from e 465 raise HTTPException(status_code=400, detail=str(e)) from e
452 except Exception as e: 466 except Exception as e:
453 latency_ms = (time.perf_counter() - request_started) * 1000 467 latency_ms = (time.perf_counter() - request_started) * 1000
454 logger.error("Translation error | error=%s latency_ms=%.2f", e, latency_ms, exc_info=True) 468 logger.error("Translation error | error=%s latency_ms=%.2f", e, latency_ms, exc_info=True)
455 raise HTTPException(status_code=500, detail=f"Translation error: {str(e)}") 469 raise HTTPException(status_code=500, detail=f"Translation error: {str(e)}")
  470 + finally:
  471 + reset_translation_request_id(request_token)
456 472
457 473
458 @app.get("/") 474 @app.get("/")
tests/test_translation_local_backends.py
  1 +import logging
  2 +
  3 +import pytest
1 import torch 4 import torch
2 5
3 from translation.backends.local_seq2seq import MarianMTTranslationBackend, NLLBTranslationBackend 6 from translation.backends.local_seq2seq import MarianMTTranslationBackend, NLLBTranslationBackend
  7 +from translation.backends.local_ctranslate2 import NLLBCTranslate2TranslationBackend
4 from translation.service import TranslationService 8 from translation.service import TranslationService
5 from translation.text_splitter import compute_safe_input_token_limit, split_text_for_translation 9 from translation.text_splitter import compute_safe_input_token_limit, split_text_for_translation
6 10
@@ -44,11 +48,59 @@ class _FakeModel: @@ -44,11 +48,59 @@ class _FakeModel:
44 return [[42]] 48 return [[42]]
45 49
46 50
  51 +class _FakeCT2Tokenizer:
  52 + def __init__(self, src_lang=None):
  53 + self.src_lang = src_lang
  54 + self.pad_token = "</s>"
  55 + self.eos_token = "</s>"
  56 + self.last_call = None
  57 +
  58 + def __call__(self, texts, **kwargs):
  59 + self.last_call = {"texts": list(texts), **kwargs}
  60 + return {"input_ids": [[1, 2, 3] for _ in texts]}
  61 +
  62 + def convert_ids_to_tokens(self, ids):
  63 + del ids
  64 + return ["tok_a", "tok_b", "tok_c"]
  65 +
  66 + def convert_tokens_to_ids(self, tokens):
  67 + if isinstance(tokens, list):
  68 + return [1 for _ in tokens]
  69 + return 1
  70 +
  71 + def decode(self, token_ids, skip_special_tokens=True):
  72 + del token_ids, skip_special_tokens
  73 + return "translated"
  74 +
  75 +
  76 +class _FakeCT2Result:
  77 + def __init__(self, tokens):
  78 + self.hypotheses = [tokens]
  79 +
  80 +
  81 +class _FakeCT2Translator:
  82 + def __init__(self):
  83 + self.last_translate_batch_kwargs = None
  84 +
  85 + def translate_batch(self, source_tokens, **kwargs):
  86 + self.last_translate_batch_kwargs = {"source_tokens": source_tokens, **kwargs}
  87 + target_prefix = kwargs.get("target_prefix") or []
  88 + return [
  89 + _FakeCT2Result((target_prefix[idx] or []) + ["translated_token"])
  90 + for idx, _ in enumerate(source_tokens)
  91 + ]
  92 +
  93 +
47 def _stub_load_model(self): 94 def _stub_load_model(self):
48 self.tokenizer = _FakeTokenizer() 95 self.tokenizer = _FakeTokenizer()
49 self.seq2seq_model = _FakeModel() 96 self.seq2seq_model = _FakeModel()
50 97
51 98
  99 +def _stub_load_ct2_runtime(self):
  100 + self.tokenizer = _FakeCT2Tokenizer()
  101 + self.translator = _FakeCT2Translator()
  102 +
  103 +
52 def test_marian_language_validation(monkeypatch): 104 def test_marian_language_validation(monkeypatch):
53 monkeypatch.setattr(MarianMTTranslationBackend, "_load_model", _stub_load_model) 105 monkeypatch.setattr(MarianMTTranslationBackend, "_load_model", _stub_load_model)
54 backend = MarianMTTranslationBackend( 106 backend = MarianMTTranslationBackend(
@@ -68,12 +120,8 @@ def test_marian_language_validation(monkeypatch): @@ -68,12 +120,8 @@ def test_marian_language_validation(monkeypatch):
68 result = backend.translate("测试", source_lang="zh", target_lang="en") 120 result = backend.translate("测试", source_lang="zh", target_lang="en")
69 assert result == "translated" 121 assert result == "translated"
70 122
71 - try: 123 + with pytest.raises(ValueError, match="source languages"):
72 backend.translate("test", source_lang="en", target_lang="zh") 124 backend.translate("test", source_lang="en", target_lang="zh")
73 - except ValueError as exc:  
74 - assert "source languages" in str(exc)  
75 - else:  
76 - raise AssertionError("Expected unsupported source language to raise")  
77 125
78 126
79 def test_nllb_uses_src_lang_and_forced_bos(monkeypatch): 127 def test_nllb_uses_src_lang_and_forced_bos(monkeypatch):
@@ -97,6 +145,61 @@ def test_nllb_uses_src_lang_and_forced_bos(monkeypatch): @@ -97,6 +145,61 @@ def test_nllb_uses_src_lang_and_forced_bos(monkeypatch):
97 assert backend.seq2seq_model.last_generate_kwargs["forced_bos_token_id"] == 202 145 assert backend.seq2seq_model.last_generate_kwargs["forced_bos_token_id"] == 202
98 146
99 147
  148 +def test_nllb_accepts_finnish_short_code(monkeypatch):
  149 + monkeypatch.setattr(NLLBTranslationBackend, "_load_model", _stub_load_model)
  150 + backend = NLLBTranslationBackend(
  151 + name="nllb-200-distilled-600m",
  152 + model_id="facebook/nllb-200-distilled-600M",
  153 + model_dir="./models/translation/facebook/nllb-200-distilled-600M",
  154 + device="cpu",
  155 + torch_dtype="float32",
  156 + batch_size=1,
  157 + max_input_length=16,
  158 + max_new_tokens=16,
  159 + num_beams=1,
  160 + )
  161 +
  162 + result = backend.translate("test", source_lang="fi", target_lang="zh")
  163 +
  164 + assert result == "translated"
  165 + assert backend.tokenizer.src_lang == "fin_Latn"
  166 + assert backend.seq2seq_model.last_generate_kwargs["forced_bos_token_id"] == 202
  167 +
  168 +
  169 +def test_nllb_ctranslate2_accepts_finnish_short_code(monkeypatch):
  170 + created_tokenizers = []
  171 +
  172 + def _fake_from_pretrained(source, src_lang=None, **kwargs):
  173 + del source, kwargs
  174 + tokenizer = _FakeCT2Tokenizer(src_lang=src_lang)
  175 + created_tokenizers.append(tokenizer)
  176 + return tokenizer
  177 +
  178 + monkeypatch.setattr(NLLBCTranslate2TranslationBackend, "_load_runtime", _stub_load_ct2_runtime)
  179 + monkeypatch.setattr(
  180 + "translation.backends.local_ctranslate2.AutoTokenizer.from_pretrained",
  181 + _fake_from_pretrained,
  182 + )
  183 + backend = NLLBCTranslate2TranslationBackend(
  184 + name="nllb-200-distilled-600m",
  185 + model_id="facebook/nllb-200-distilled-600M",
  186 + model_dir="./models/translation/facebook/nllb-200-distilled-600M",
  187 + device="cpu",
  188 + torch_dtype="float32",
  189 + batch_size=1,
  190 + max_input_length=16,
  191 + max_new_tokens=16,
  192 + num_beams=1,
  193 + )
  194 +
  195 + result = backend.translate("test", source_lang="fi", target_lang="zh")
  196 +
  197 + assert result == "translated"
  198 + assert len(created_tokenizers) == 1
  199 + assert created_tokenizers[0].src_lang == "fin_Latn"
  200 + assert backend.translator.last_translate_batch_kwargs["target_prefix"] == [["zho_Hans"]]
  201 +
  202 +
100 def test_translation_service_preloads_enabled_backends(monkeypatch): 203 def test_translation_service_preloads_enabled_backends(monkeypatch):
101 created = [] 204 created = []
102 205
@@ -245,7 +348,71 @@ def test_local_backend_splits_oversized_text_before_translation(): @@ -245,7 +348,71 @@ def test_local_backend_splits_oversized_text_before_translation():
245 result = backend.translate(text, source_lang="en", target_lang="zh") 348 result = backend.translate(text, source_lang="en", target_lang="zh")
246 349
247 assert result is not None 350 assert result is not None
248 - assert len(backend.translated_batches) == 1  
249 - assert len(backend.translated_batches[0]) >= 2  
250 - assert all(len(piece) <= 16 for piece in backend.translated_batches[0])  
251 - assert result == "".join(f"<{piece.strip()}>" for piece in backend.translated_batches[0]) 351 + all_segments = [piece for batch in backend.translated_batches for piece in batch]
  352 + assert len(all_segments) >= 2
  353 + assert all(len(batch) <= backend.batch_size for batch in backend.translated_batches)
  354 + assert all(len(piece) <= 16 for piece in all_segments)
  355 + assert result == "".join(f"<{piece.strip()}>" for piece in all_segments)
  356 +
  357 +
  358 +def test_local_backend_batches_after_segmentation():
  359 + backend = _SegmentingMarianBackend(
  360 + name="opus-mt-en-zh",
  361 + model_id="Helsinki-NLP/opus-mt-en-zh",
  362 + model_dir="./models/translation/Helsinki-NLP/opus-mt-en-zh",
  363 + device="cpu",
  364 + torch_dtype="float32",
  365 + batch_size=4,
  366 + max_input_length=24,
  367 + max_new_tokens=24,
  368 + num_beams=1,
  369 + source_langs=["en"],
  370 + target_langs=["zh"],
  371 + )
  372 +
  373 + texts = [
  374 + "alpha beta gamma delta, epsilon zeta eta theta, iota kappa lambda mu.",
  375 + "nu xi omicron pi, rho sigma tau upsilon, phi chi psi omega.",
  376 + "dress shirt coat pants, socks shoes belt scarf, hat gloves bag watch.",
  377 + ]
  378 +
  379 + result = backend.translate(texts, source_lang="en", target_lang="zh")
  380 +
  381 + assert isinstance(result, list)
  382 + assert len(result) == 3
  383 + assert len(backend.translated_batches) >= 2
  384 + assert all(len(batch) <= backend.batch_size for batch in backend.translated_batches)
  385 + assert sum(len(batch) for batch in backend.translated_batches) > backend.batch_size
  386 + assert all(item is not None for item in result)
  387 +
  388 +
  389 +def test_local_backend_logs_segmentation_and_inference_batches(caplog):
  390 + backend = _SegmentingMarianBackend(
  391 + name="opus-mt-en-zh",
  392 + model_id="Helsinki-NLP/opus-mt-en-zh",
  393 + model_dir="./models/translation/Helsinki-NLP/opus-mt-en-zh",
  394 + device="cpu",
  395 + torch_dtype="float32",
  396 + batch_size=2,
  397 + max_input_length=24,
  398 + max_new_tokens=24,
  399 + num_beams=1,
  400 + source_langs=["en"],
  401 + target_langs=["zh"],
  402 + )
  403 +
  404 + texts = [
  405 + "one two three four, five six seven eight, nine ten eleven twelve.",
  406 + "thirteen fourteen fifteen sixteen, seventeen eighteen nineteen twenty.",
  407 + ]
  408 +
  409 + with caplog.at_level(logging.INFO):
  410 + backend.translate(texts, source_lang="en", target_lang="zh")
  411 +
  412 + messages = [record.getMessage() for record in caplog.records]
  413 +
  414 + assert any(message.startswith("Translation segmentation summary |") for message in messages)
  415 + inference_logs = [
  416 + message for message in messages if message.startswith("Translation inference batch |")
  417 + ]
  418 + assert len(inference_logs) >= 2
tests/test_translator_failure_semantics.py
  1 +import logging
  2 +
1 from translation.cache import TranslationCache 3 from translation.cache import TranslationCache
  4 +from translation.logging_utils import (
  5 + TranslationRequestFilter,
  6 + bind_translation_request_id,
  7 + reset_translation_request_id,
  8 +)
2 from translation.service import TranslationService 9 from translation.service import TranslationService
3 10
4 11
@@ -107,3 +114,80 @@ def test_service_caches_all_capabilities(monkeypatch): @@ -107,3 +114,80 @@ def test_service_caches_all_capabilities(monkeypatch):
107 ("opus-mt-zh-en", "en", "连衣裙", "opus-mt-zh-en:连衣裙"), 114 ("opus-mt-zh-en", "en", "连衣裙", "opus-mt-zh-en:连衣裙"),
108 ("opus-mt-zh-en", "en", "衬衫", "opus-mt-zh-en:衬衫"), 115 ("opus-mt-zh-en", "en", "衬衫", "opus-mt-zh-en:衬衫"),
109 ] 116 ]
  117 +
  118 +
  119 +def test_translation_request_filter_injects_reqid():
  120 + reqid, token = bind_translation_request_id("req-test-1234567890")
  121 + try:
  122 + record = logging.LogRecord(
  123 + name="translation.service",
  124 + level=logging.INFO,
  125 + pathname=__file__,
  126 + lineno=1,
  127 + msg="hello",
  128 + args=(),
  129 + exc_info=None,
  130 + )
  131 + TranslationRequestFilter().filter(record)
  132 +
  133 + assert reqid == "req-test-1234567890"
  134 + assert record.reqid == "req-test-1234567890"
  135 + finally:
  136 + reset_translation_request_id(token)
  137 +
  138 +
  139 +def test_translation_route_log_focuses_on_routing_decision(monkeypatch, caplog):
  140 + monkeypatch.setattr(TranslationCache, "_init_redis_client", staticmethod(lambda: None))
  141 +
  142 + def _fake_create_backend(self, *, name, backend_type, cfg):
  143 + del self, backend_type, cfg
  144 +
  145 + class _Backend:
  146 + model = name
  147 +
  148 + @property
  149 + def supports_batch(self):
  150 + return True
  151 +
  152 + def translate(self, text, target_lang, source_lang=None, scene=None):
  153 + del target_lang, source_lang, scene
  154 + return text
  155 +
  156 + return _Backend()
  157 +
  158 + monkeypatch.setattr(TranslationService, "_create_backend", _fake_create_backend)
  159 + service = TranslationService(
  160 + {
  161 + "service_url": "http://127.0.0.1:6006",
  162 + "timeout_sec": 10.0,
  163 + "default_model": "llm",
  164 + "default_scene": "general",
  165 + "capabilities": {
  166 + "llm": {
  167 + "enabled": True,
  168 + "backend": "llm",
  169 + "model": "dummy-llm",
  170 + "base_url": "https://example.com",
  171 + "timeout_sec": 10.0,
  172 + "use_cache": True,
  173 + }
  174 + },
  175 + "cache": {
  176 + "ttl_seconds": 60,
  177 + "sliding_expiration": True,
  178 + },
  179 + }
  180 + )
  181 +
  182 + with caplog.at_level(logging.INFO):
  183 + service.translate("商品标题", target_lang="en", source_lang="zh", model="llm")
  184 +
  185 + route_messages = [
  186 + record.getMessage()
  187 + for record in caplog.records
  188 + if record.name == "translation.service" and record.getMessage().startswith("Translation route |")
  189 + ]
  190 +
  191 + assert route_messages == [
  192 + "Translation route | backend=llm request_type=single use_cache=True cache_available=False"
  193 + ]
translation/backends/local_ctranslate2.py
@@ -13,7 +13,12 @@ from typing import Dict, List, Optional, Sequence, Union @@ -13,7 +13,12 @@ from typing import Dict, List, Optional, Sequence, Union
13 13
14 from transformers import AutoTokenizer 14 from transformers import AutoTokenizer
15 15
16 -from translation.languages import MARIAN_LANGUAGE_DIRECTIONS, NLLB_LANGUAGE_CODES 16 +from translation.languages import (
  17 + MARIAN_LANGUAGE_DIRECTIONS,
  18 + build_nllb_language_catalog,
  19 + normalize_language_key,
  20 + resolve_nllb_language_code,
  21 +)
17 from translation.text_splitter import ( 22 from translation.text_splitter import (
18 compute_safe_input_token_limit, 23 compute_safe_input_token_limit,
19 join_translated_segments, 24 join_translated_segments,
@@ -23,6 +28,17 @@ from translation.text_splitter import ( @@ -23,6 +28,17 @@ from translation.text_splitter import (
23 logger = logging.getLogger(__name__) 28 logger = logging.getLogger(__name__)
24 29
25 30
  31 +def _text_preview(text: Optional[str], limit: int = 32) -> str:
  32 + return str(text or "").replace("\n", "\\n")[:limit]
  33 +
  34 +
  35 +def _summarize_lengths(values: Sequence[int]) -> str:
  36 + if not values:
  37 + return "[]"
  38 + total = sum(values)
  39 + return f"min={min(values)} max={max(values)} avg={total / len(values):.1f}"
  40 +
  41 +
26 def _resolve_device(device: Optional[str]) -> str: 42 def _resolve_device(device: Optional[str]) -> str:
27 value = str(device or "auto").strip().lower() 43 value = str(device or "auto").strip().lower()
28 if value not in {"auto", "cpu", "cuda"}: 44 if value not in {"auto", "cpu", "cuda"}:
@@ -285,6 +301,17 @@ class LocalCTranslate2TranslationBackend: @@ -285,6 +301,17 @@ class LocalCTranslate2TranslationBackend:
285 source_tokens = self._encode_source_tokens(texts, source_lang, target_lang) 301 source_tokens = self._encode_source_tokens(texts, source_lang, target_lang)
286 target_prefix = self._target_prefixes(len(source_tokens), source_lang, target_lang) 302 target_prefix = self._target_prefixes(len(source_tokens), source_lang, target_lang)
287 max_decoding_length = self._resolve_max_decoding_length(source_tokens) 303 max_decoding_length = self._resolve_max_decoding_length(source_tokens)
  304 + logger.info(
  305 + "Translation model batch detail | model=%s segment_count=%s token_lengths=%s max_decoding_length=%s batch_type=%s beam_size=%s target_lang=%s source_lang=%s",
  306 + self.model,
  307 + len(source_tokens),
  308 + _summarize_lengths([len(tokens) for tokens in source_tokens]),
  309 + max_decoding_length,
  310 + self.ct2_batch_type,
  311 + self.num_beams,
  312 + target_lang,
  313 + source_lang or "auto",
  314 + )
288 results = self.translator.translate_batch( 315 results = self.translator.translate_batch(
289 source_tokens, 316 source_tokens,
290 target_prefix=target_prefix, 317 target_prefix=target_prefix,
@@ -336,6 +363,59 @@ class LocalCTranslate2TranslationBackend: @@ -336,6 +363,59 @@ class LocalCTranslate2TranslationBackend:
336 ), 363 ),
337 ) 364 )
338 365
  366 + def _log_segmentation_summary(
  367 + self,
  368 + *,
  369 + texts: Sequence[str],
  370 + segment_plans: Sequence[Sequence[str]],
  371 + target_lang: str,
  372 + source_lang: Optional[str],
  373 + ) -> None:
  374 + non_empty_count = sum(1 for text in texts if text.strip())
  375 + segment_counts = [len(segments) for segments in segment_plans if segments]
  376 + total_segments = sum(segment_counts)
  377 + segmented_inputs = sum(1 for count in segment_counts if count > 1)
  378 + logger.info(
  379 + "Translation segmentation summary | model=%s inputs=%s non_empty_inputs=%s segmented_inputs=%s total_segments=%s batch_size=%s target_lang=%s source_lang=%s segments_per_input=%s",
  380 + self.model,
  381 + len(texts),
  382 + non_empty_count,
  383 + segmented_inputs,
  384 + total_segments,
  385 + self.batch_size,
  386 + target_lang,
  387 + source_lang or "auto",
  388 + _summarize_lengths(segment_counts),
  389 + )
  390 +
  391 + def _translate_segment_batches(
  392 + self,
  393 + segments: List[str],
  394 + target_lang: str,
  395 + source_lang: Optional[str] = None,
  396 + ) -> List[Optional[str]]:
  397 + if not segments:
  398 + return []
  399 + outputs: List[Optional[str]] = []
  400 + total_batches = (len(segments) + self.batch_size - 1) // self.batch_size
  401 + for batch_index, start in enumerate(range(0, len(segments), self.batch_size), start=1):
  402 + batch = segments[start:start + self.batch_size]
  403 + logger.info(
  404 + "Translation inference batch | model=%s batch_index=%s total_batches=%s segment_count=%s char_lengths=%s first_preview=%s target_lang=%s source_lang=%s",
  405 + self.model,
  406 + batch_index,
  407 + total_batches,
  408 + len(batch),
  409 + _summarize_lengths([len(segment) for segment in batch]),
  410 + _text_preview(batch[0] if batch else ""),
  411 + target_lang,
  412 + source_lang or "auto",
  413 + )
  414 + outputs.extend(
  415 + self._translate_batch(batch, target_lang=target_lang, source_lang=source_lang)
  416 + )
  417 + return outputs
  418 +
339 def _translate_with_segmentation( 419 def _translate_with_segmentation(
340 self, 420 self,
341 texts: List[str], 421 texts: List[str],
@@ -352,8 +432,15 @@ class LocalCTranslate2TranslationBackend: @@ -352,8 +432,15 @@ class LocalCTranslate2TranslationBackend:
352 segment_plans.append(segments) 432 segment_plans.append(segments)
353 flat_segments.extend(segments) 433 flat_segments.extend(segments)
354 434
  435 + self._log_segmentation_summary(
  436 + texts=texts,
  437 + segment_plans=segment_plans,
  438 + target_lang=target_lang,
  439 + source_lang=source_lang,
  440 + )
  441 +
355 translated_segments = ( 442 translated_segments = (
356 - self._translate_batch(flat_segments, target_lang=target_lang, source_lang=source_lang) 443 + self._translate_segment_batches(flat_segments, target_lang=target_lang, source_lang=source_lang)
357 if flat_segments 444 if flat_segments
358 else [] 445 else []
359 ) 446 )
@@ -387,13 +474,10 @@ class LocalCTranslate2TranslationBackend: @@ -387,13 +474,10 @@ class LocalCTranslate2TranslationBackend:
387 del scene 474 del scene
388 is_single = isinstance(text, str) 475 is_single = isinstance(text, str)
389 texts = self._normalize_texts(text) 476 texts = self._normalize_texts(text)
390 - outputs: List[Optional[str]] = []  
391 - for start in range(0, len(texts), self.batch_size):  
392 - chunk = texts[start:start + self.batch_size]  
393 - if not any(item.strip() for item in chunk):  
394 - outputs.extend([None if not item.strip() else item for item in chunk]) # type: ignore[list-item]  
395 - continue  
396 - outputs.extend(self._translate_with_segmentation(chunk, target_lang=target_lang, source_lang=source_lang)) 477 + if not any(item.strip() for item in texts):
  478 + outputs = [None if not item.strip() else item for item in texts] # type: ignore[list-item]
  479 + return outputs[0] if is_single else outputs
  480 + outputs = self._translate_with_segmentation(texts, target_lang=target_lang, source_lang=source_lang)
397 return outputs[0] if is_single else outputs 481 return outputs[0] if is_single else outputs
398 482
399 483
@@ -492,11 +576,7 @@ class NLLBCTranslate2TranslationBackend(LocalCTranslate2TranslationBackend): @@ -492,11 +576,7 @@ class NLLBCTranslate2TranslationBackend(LocalCTranslate2TranslationBackend):
492 ct2_decoding_length_extra: int = 0, 576 ct2_decoding_length_extra: int = 0,
493 ct2_decoding_length_min: int = 1, 577 ct2_decoding_length_min: int = 1,
494 ) -> None: 578 ) -> None:
495 - overrides = language_codes or {}  
496 - self.language_codes = {  
497 - **NLLB_LANGUAGE_CODES,  
498 - **{str(k).strip().lower(): str(v).strip() for k, v in overrides.items() if str(k).strip()},  
499 - } 579 + self.language_codes = build_nllb_language_catalog(language_codes)
500 self._tokenizers_by_source: Dict[str, object] = {} 580 self._tokenizers_by_source: Dict[str, object] = {}
501 super().__init__( 581 super().__init__(
502 name=name, 582 name=name,
@@ -522,17 +602,17 @@ class NLLBCTranslate2TranslationBackend(LocalCTranslate2TranslationBackend): @@ -522,17 +602,17 @@ class NLLBCTranslate2TranslationBackend(LocalCTranslate2TranslationBackend):
522 ) 602 )
523 603
524 def _validate_languages(self, source_lang: Optional[str], target_lang: str) -> None: 604 def _validate_languages(self, source_lang: Optional[str], target_lang: str) -> None:
525 - src = str(source_lang or "").strip().lower()  
526 - tgt = str(target_lang or "").strip().lower()  
527 - if not src: 605 + if not str(source_lang or "").strip():
528 raise ValueError(f"Model '{self.model}' requires source_lang") 606 raise ValueError(f"Model '{self.model}' requires source_lang")
529 - if src not in self.language_codes: 607 + if resolve_nllb_language_code(source_lang, self.language_codes) is None:
530 raise ValueError(f"Unsupported NLLB source language: {source_lang}") 608 raise ValueError(f"Unsupported NLLB source language: {source_lang}")
531 - if tgt not in self.language_codes: 609 + if resolve_nllb_language_code(target_lang, self.language_codes) is None:
532 raise ValueError(f"Unsupported NLLB target language: {target_lang}") 610 raise ValueError(f"Unsupported NLLB target language: {target_lang}")
533 611
534 def _get_tokenizer_for_source(self, source_lang: str): 612 def _get_tokenizer_for_source(self, source_lang: str):
535 - src_code = self.language_codes[source_lang] 613 + src_code = resolve_nllb_language_code(source_lang, self.language_codes)
  614 + if src_code is None:
  615 + raise ValueError(f"Unsupported NLLB source language: {source_lang}")
536 with self._tokenizer_lock: 616 with self._tokenizer_lock:
537 tokenizer = self._tokenizers_by_source.get(src_code) 617 tokenizer = self._tokenizers_by_source.get(src_code)
538 if tokenizer is None: 618 if tokenizer is None:
@@ -549,7 +629,7 @@ class NLLBCTranslate2TranslationBackend(LocalCTranslate2TranslationBackend): @@ -549,7 +629,7 @@ class NLLBCTranslate2TranslationBackend(LocalCTranslate2TranslationBackend):
549 target_lang: str, 629 target_lang: str,
550 ) -> List[List[str]]: 630 ) -> List[List[str]]:
551 del target_lang 631 del target_lang
552 - source_key = str(source_lang or "").strip().lower() 632 + source_key = normalize_language_key(source_lang)
553 tokenizer = self._get_tokenizer_for_source(source_key) 633 tokenizer = self._get_tokenizer_for_source(source_key)
554 encoded = tokenizer( 634 encoded = tokenizer(
555 texts, 635 texts,
@@ -567,7 +647,9 @@ class NLLBCTranslate2TranslationBackend(LocalCTranslate2TranslationBackend): @@ -567,7 +647,9 @@ class NLLBCTranslate2TranslationBackend(LocalCTranslate2TranslationBackend):
567 target_lang: str, 647 target_lang: str,
568 ) -> Optional[List[Optional[List[str]]]]: 648 ) -> Optional[List[Optional[List[str]]]]:
569 del source_lang 649 del source_lang
570 - tgt_code = self.language_codes[str(target_lang).strip().lower()] 650 + tgt_code = resolve_nllb_language_code(target_lang, self.language_codes)
  651 + if tgt_code is None:
  652 + raise ValueError(f"Unsupported NLLB target language: {target_lang}")
571 return [[tgt_code] for _ in range(count)] 653 return [[tgt_code] for _ in range(count)]
572 654
573 def _postprocess_hypothesis( 655 def _postprocess_hypothesis(
@@ -577,7 +659,9 @@ class NLLBCTranslate2TranslationBackend(LocalCTranslate2TranslationBackend): @@ -577,7 +659,9 @@ class NLLBCTranslate2TranslationBackend(LocalCTranslate2TranslationBackend):
577 target_lang: str, 659 target_lang: str,
578 ) -> List[str]: 660 ) -> List[str]:
579 del source_lang 661 del source_lang
580 - tgt_code = self.language_codes[str(target_lang).strip().lower()] 662 + tgt_code = resolve_nllb_language_code(target_lang, self.language_codes)
  663 + if tgt_code is None:
  664 + raise ValueError(f"Unsupported NLLB target language: {target_lang}")
581 if tokens and tokens[0] == tgt_code: 665 if tokens and tokens[0] == tgt_code:
582 return tokens[1:] 666 return tokens[1:]
583 return tokens 667 return tokens
translation/backends/local_seq2seq.py
@@ -10,7 +10,11 @@ from typing import Dict, List, Optional, Sequence, Union @@ -10,7 +10,11 @@ from typing import Dict, List, Optional, Sequence, Union
10 import torch 10 import torch
11 from transformers import AutoModelForSeq2SeqLM, AutoTokenizer 11 from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
12 12
13 -from translation.languages import MARIAN_LANGUAGE_DIRECTIONS, NLLB_LANGUAGE_CODES 13 +from translation.languages import (
  14 + MARIAN_LANGUAGE_DIRECTIONS,
  15 + build_nllb_language_catalog,
  16 + resolve_nllb_language_code,
  17 +)
14 from translation.text_splitter import ( 18 from translation.text_splitter import (
15 compute_safe_input_token_limit, 19 compute_safe_input_token_limit,
16 join_translated_segments, 20 join_translated_segments,
@@ -20,6 +24,17 @@ from translation.text_splitter import ( @@ -20,6 +24,17 @@ from translation.text_splitter import (
20 logger = logging.getLogger(__name__) 24 logger = logging.getLogger(__name__)
21 25
22 26
  27 +def _text_preview(text: Optional[str], limit: int = 32) -> str:
  28 + return str(text or "").replace("\n", "\\n")[:limit]
  29 +
  30 +
  31 +def _summarize_lengths(values: Sequence[int]) -> str:
  32 + if not values:
  33 + return "[]"
  34 + total = sum(values)
  35 + return f"min={min(values)} max={max(values)} avg={total / len(values):.1f}"
  36 +
  37 +
23 def _resolve_device(device: Optional[str]) -> str: 38 def _resolve_device(device: Optional[str]) -> str:
24 value = str(device or "auto").strip().lower() 39 value = str(device or "auto").strip().lower()
25 if value == "auto": 40 if value == "auto":
@@ -198,6 +213,59 @@ class LocalSeq2SeqTranslationBackend: @@ -198,6 +213,59 @@ class LocalSeq2SeqTranslationBackend:
198 ), 213 ),
199 ) 214 )
200 215
  216 + def _log_segmentation_summary(
  217 + self,
  218 + *,
  219 + texts: Sequence[str],
  220 + segment_plans: Sequence[Sequence[str]],
  221 + target_lang: str,
  222 + source_lang: Optional[str],
  223 + ) -> None:
  224 + non_empty_count = sum(1 for text in texts if text.strip())
  225 + segment_counts = [len(segments) for segments in segment_plans if segments]
  226 + total_segments = sum(segment_counts)
  227 + segmented_inputs = sum(1 for count in segment_counts if count > 1)
  228 + logger.info(
  229 + "Translation segmentation summary | model=%s inputs=%s non_empty_inputs=%s segmented_inputs=%s total_segments=%s batch_size=%s target_lang=%s source_lang=%s segments_per_input=%s",
  230 + self.model,
  231 + len(texts),
  232 + non_empty_count,
  233 + segmented_inputs,
  234 + total_segments,
  235 + self.batch_size,
  236 + target_lang,
  237 + source_lang or "auto",
  238 + _summarize_lengths(segment_counts),
  239 + )
  240 +
  241 + def _translate_segment_batches(
  242 + self,
  243 + segments: List[str],
  244 + target_lang: str,
  245 + source_lang: Optional[str] = None,
  246 + ) -> List[Optional[str]]:
  247 + if not segments:
  248 + return []
  249 + outputs: List[Optional[str]] = []
  250 + total_batches = (len(segments) + self.batch_size - 1) // self.batch_size
  251 + for batch_index, start in enumerate(range(0, len(segments), self.batch_size), start=1):
  252 + batch = segments[start:start + self.batch_size]
  253 + logger.info(
  254 + "Translation inference batch | model=%s batch_index=%s total_batches=%s segment_count=%s char_lengths=%s first_preview=%s target_lang=%s source_lang=%s",
  255 + self.model,
  256 + batch_index,
  257 + total_batches,
  258 + len(batch),
  259 + _summarize_lengths([len(segment) for segment in batch]),
  260 + _text_preview(batch[0] if batch else ""),
  261 + target_lang,
  262 + source_lang or "auto",
  263 + )
  264 + outputs.extend(
  265 + self._translate_batch(batch, target_lang=target_lang, source_lang=source_lang)
  266 + )
  267 + return outputs
  268 +
201 def _translate_with_segmentation( 269 def _translate_with_segmentation(
202 self, 270 self,
203 texts: List[str], 271 texts: List[str],
@@ -214,8 +282,15 @@ class LocalSeq2SeqTranslationBackend: @@ -214,8 +282,15 @@ class LocalSeq2SeqTranslationBackend:
214 segment_plans.append(segments) 282 segment_plans.append(segments)
215 flat_segments.extend(segments) 283 flat_segments.extend(segments)
216 284
  285 + self._log_segmentation_summary(
  286 + texts=texts,
  287 + segment_plans=segment_plans,
  288 + target_lang=target_lang,
  289 + source_lang=source_lang,
  290 + )
  291 +
217 translated_segments = ( 292 translated_segments = (
218 - self._translate_batch(flat_segments, target_lang=target_lang, source_lang=source_lang) 293 + self._translate_segment_batches(flat_segments, target_lang=target_lang, source_lang=source_lang)
219 if flat_segments 294 if flat_segments
220 else [] 295 else []
221 ) 296 )
@@ -249,13 +324,10 @@ class LocalSeq2SeqTranslationBackend: @@ -249,13 +324,10 @@ class LocalSeq2SeqTranslationBackend:
249 del scene 324 del scene
250 is_single = isinstance(text, str) 325 is_single = isinstance(text, str)
251 texts = self._normalize_texts(text) 326 texts = self._normalize_texts(text)
252 - outputs: List[Optional[str]] = []  
253 - for start in range(0, len(texts), self.batch_size):  
254 - chunk = texts[start:start + self.batch_size]  
255 - if not any(item.strip() for item in chunk):  
256 - outputs.extend([None if not item.strip() else item for item in chunk]) # type: ignore[list-item]  
257 - continue  
258 - outputs.extend(self._translate_with_segmentation(chunk, target_lang=target_lang, source_lang=source_lang)) 327 + if not any(item.strip() for item in texts):
  328 + outputs = [None if not item.strip() else item for item in texts] # type: ignore[list-item]
  329 + return outputs[0] if is_single else outputs
  330 + outputs = self._translate_with_segmentation(texts, target_lang=target_lang, source_lang=source_lang)
259 return outputs[0] if is_single else outputs 331 return outputs[0] if is_single else outputs
260 332
261 333
@@ -324,11 +396,7 @@ class NLLBTranslationBackend(LocalSeq2SeqTranslationBackend): @@ -324,11 +396,7 @@ class NLLBTranslationBackend(LocalSeq2SeqTranslationBackend):
324 language_codes: Optional[Dict[str, str]] = None, 396 language_codes: Optional[Dict[str, str]] = None,
325 attn_implementation: Optional[str] = None, 397 attn_implementation: Optional[str] = None,
326 ) -> None: 398 ) -> None:
327 - overrides = language_codes or {}  
328 - self.language_codes = {  
329 - **NLLB_LANGUAGE_CODES,  
330 - **{str(k).strip().lower(): str(v).strip() for k, v in overrides.items() if str(k).strip()},  
331 - } 399 + self.language_codes = build_nllb_language_catalog(language_codes)
332 super().__init__( 400 super().__init__(
333 name=name, 401 name=name,
334 model_id=model_id, 402 model_id=model_id,
@@ -343,24 +411,26 @@ class NLLBTranslationBackend(LocalSeq2SeqTranslationBackend): @@ -343,24 +411,26 @@ class NLLBTranslationBackend(LocalSeq2SeqTranslationBackend):
343 ) 411 )
344 412
345 def _validate_languages(self, source_lang: Optional[str], target_lang: str) -> None: 413 def _validate_languages(self, source_lang: Optional[str], target_lang: str) -> None:
346 - src = str(source_lang or "").strip().lower()  
347 - tgt = str(target_lang or "").strip().lower()  
348 - if not src: 414 + if not str(source_lang or "").strip():
349 raise ValueError(f"Model '{self.model}' requires source_lang") 415 raise ValueError(f"Model '{self.model}' requires source_lang")
350 - if src not in self.language_codes: 416 + if resolve_nllb_language_code(source_lang, self.language_codes) is None:
351 raise ValueError(f"Unsupported NLLB source language: {source_lang}") 417 raise ValueError(f"Unsupported NLLB source language: {source_lang}")
352 - if tgt not in self.language_codes: 418 + if resolve_nllb_language_code(target_lang, self.language_codes) is None:
353 raise ValueError(f"Unsupported NLLB target language: {target_lang}") 419 raise ValueError(f"Unsupported NLLB target language: {target_lang}")
354 420
355 def _prepare_tokenizer(self, source_lang: Optional[str], target_lang: str) -> Dict[str, object]: 421 def _prepare_tokenizer(self, source_lang: Optional[str], target_lang: str) -> Dict[str, object]:
356 del target_lang 422 del target_lang
357 - src_code = self.language_codes[str(source_lang).strip().lower()] 423 + src_code = resolve_nllb_language_code(source_lang, self.language_codes)
  424 + if src_code is None:
  425 + raise ValueError(f"Unsupported NLLB source language: {source_lang}")
358 self.tokenizer.src_lang = src_code 426 self.tokenizer.src_lang = src_code
359 return {} 427 return {}
360 428
361 def _build_generate_kwargs(self, source_lang: Optional[str], target_lang: str) -> Dict[str, object]: 429 def _build_generate_kwargs(self, source_lang: Optional[str], target_lang: str) -> Dict[str, object]:
362 del source_lang 430 del source_lang
363 - tgt_code = self.language_codes[str(target_lang).strip().lower()] 431 + tgt_code = resolve_nllb_language_code(target_lang, self.language_codes)
  432 + if tgt_code is None:
  433 + raise ValueError(f"Unsupported NLLB target language: {target_lang}")
364 forced_bos_token_id = None 434 forced_bos_token_id = None
365 if hasattr(self.tokenizer, "lang_code_to_id"): 435 if hasattr(self.tokenizer, "lang_code_to_id"):
366 forced_bos_token_id = self.tokenizer.lang_code_to_id.get(tgt_code) 436 forced_bos_token_id = self.tokenizer.lang_code_to_id.get(tgt_code)
translation/cache.py
@@ -40,10 +40,8 @@ class TranslationCache: @@ -40,10 +40,8 @@ class TranslationCache:
40 try: 40 try:
41 value = self.redis_client.get(key) 41 value = self.redis_client.get(key)
42 logger.info( 42 logger.info(
43 - "Translation cache %s | model=%s target_lang=%s text_len=%s key=%s", 43 + "Translation cache %s | text_len=%s key=%s",
44 "hit" if value is not None else "miss", 44 "hit" if value is not None else "miss",
45 - model,  
46 - target_lang,  
47 len(str(source_text or "")), 45 len(str(source_text or "")),
48 key, 46 key,
49 ) 47 )
@@ -61,9 +59,7 @@ class TranslationCache: @@ -61,9 +59,7 @@ class TranslationCache:
61 try: 59 try:
62 self.redis_client.setex(key, self.ttl_seconds, translated_text) 60 self.redis_client.setex(key, self.ttl_seconds, translated_text)
63 logger.info( 61 logger.info(
64 - "Translation cache write | model=%s target_lang=%s text_len=%s result_len=%s ttl_seconds=%s key=%s",  
65 - model,  
66 - target_lang, 62 + "Translation cache write | text_len=%s result_len=%s ttl_seconds=%s key=%s",
67 len(str(source_text or "")), 63 len(str(source_text or "")),
68 len(str(translated_text or "")), 64 len(str(translated_text or "")),
69 self.ttl_seconds, 65 self.ttl_seconds,
translation/languages.py
@@ -2,12 +2,13 @@ @@ -2,12 +2,13 @@
2 2
3 from __future__ import annotations 3 from __future__ import annotations
4 4
5 -from typing import Dict, Tuple 5 +from typing import Dict, Mapping, Optional, Tuple
6 6
7 7
8 LANGUAGE_LABELS: Dict[str, str] = { 8 LANGUAGE_LABELS: Dict[str, str] = {
9 "zh": "Chinese", 9 "zh": "Chinese",
10 "en": "English", 10 "en": "English",
  11 + "fi": "Finnish",
11 "ru": "Russian", 12 "ru": "Russian",
12 "ar": "Arabic", 13 "ar": "Arabic",
13 "ja": "Japanese", 14 "ja": "Japanese",
@@ -49,6 +50,7 @@ DEEPL_LANGUAGE_CODES: Dict[str, str] = { @@ -49,6 +50,7 @@ DEEPL_LANGUAGE_CODES: Dict[str, str] = {
49 50
50 NLLB_LANGUAGE_CODES: Dict[str, str] = { 51 NLLB_LANGUAGE_CODES: Dict[str, str] = {
51 "en": "eng_Latn", 52 "en": "eng_Latn",
  53 + "fi": "fin_Latn",
52 "zh": "zho_Hans", 54 "zh": "zho_Hans",
53 "ru": "rus_Cyrl", 55 "ru": "rus_Cyrl",
54 "ar": "arb_Arab", 56 "ar": "arb_Arab",
@@ -65,3 +67,56 @@ MARIAN_LANGUAGE_DIRECTIONS: Dict[str, Tuple[str, str]] = { @@ -65,3 +67,56 @@ MARIAN_LANGUAGE_DIRECTIONS: Dict[str, Tuple[str, str]] = {
65 "opus-mt-zh-en": ("zh", "en"), 67 "opus-mt-zh-en": ("zh", "en"),
66 "opus-mt-en-zh": ("en", "zh"), 68 "opus-mt-en-zh": ("en", "zh"),
67 } 69 }
  70 +
  71 +
  72 +NLLB_LANGUAGE_ALIASES: Dict[str, str] = {
  73 + "fi_fi": "fi",
  74 + "fin": "fi",
  75 + "fin_fin": "fi",
  76 + "zh_cn": "zh",
  77 + "zh_hans": "zh",
  78 +}
  79 +
  80 +
  81 +def normalize_language_key(language: Optional[str]) -> str:
  82 + return str(language or "").strip().lower().replace("-", "_")
  83 +
  84 +
  85 +def build_nllb_language_catalog(
  86 + overrides: Optional[Mapping[str, str]] = None,
  87 +) -> Dict[str, str]:
  88 + catalog = {
  89 + normalize_language_key(key): str(value).strip()
  90 + for key, value in NLLB_LANGUAGE_CODES.items()
  91 + if str(key).strip()
  92 + }
  93 + for key, value in (overrides or {}).items():
  94 + normalized_key = normalize_language_key(key)
  95 + if normalized_key:
  96 + catalog[normalized_key] = str(value).strip()
  97 + return catalog
  98 +
  99 +
  100 +def resolve_nllb_language_code(
  101 + language: Optional[str],
  102 + language_codes: Optional[Mapping[str, str]] = None,
  103 +) -> Optional[str]:
  104 + normalized = normalize_language_key(language)
  105 + if not normalized:
  106 + return None
  107 +
  108 + catalog = build_nllb_language_catalog(language_codes)
  109 + direct = catalog.get(normalized)
  110 + if direct is not None:
  111 + return direct
  112 +
  113 + alias = NLLB_LANGUAGE_ALIASES.get(normalized)
  114 + if alias is not None:
  115 + aliased = catalog.get(normalize_language_key(alias))
  116 + if aliased is not None:
  117 + return aliased
  118 +
  119 + for code in catalog.values():
  120 + if normalize_language_key(code) == normalized:
  121 + return code
  122 + return None
translation/logging_utils.py 0 → 100644
@@ -0,0 +1,37 @@ @@ -0,0 +1,37 @@
  1 +"""Shared translation logging context helpers."""
  2 +
  3 +from __future__ import annotations
  4 +
  5 +import contextvars
  6 +import logging
  7 +import uuid
  8 +from typing import Optional
  9 +
  10 +
  11 +_translation_request_id_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
  12 + "translation_request_id",
  13 + default=None,
  14 +)
  15 +
  16 +
  17 +def current_translation_request_id() -> str:
  18 + return _translation_request_id_var.get() or "-1"
  19 +
  20 +
  21 +def bind_translation_request_id(request_id: Optional[str] = None) -> tuple[str, contextvars.Token]:
  22 + raw_value = str(request_id or "").strip()
  23 + normalized = raw_value[:32] if raw_value else str(uuid.uuid4())[:8]
  24 + return normalized, _translation_request_id_var.set(normalized)
  25 +
  26 +
  27 +def reset_translation_request_id(token: contextvars.Token) -> None:
  28 + _translation_request_id_var.reset(token)
  29 +
  30 +
  31 +class TranslationRequestFilter(logging.Filter):
  32 + """Inject a request id into translator logs when one is available."""
  33 +
  34 + def filter(self, record: logging.LogRecord) -> bool:
  35 + if not hasattr(record, "reqid"):
  36 + record.reqid = current_translation_request_id()
  37 + return True
translation/service.py
@@ -198,15 +198,10 @@ class TranslationService: @@ -198,15 +198,10 @@ class TranslationService:
198 active_scene = normalize_translation_scene(self.config, scene) 198 active_scene = normalize_translation_scene(self.config, scene)
199 capability_cfg = self._enabled_capabilities[normalized_model] 199 capability_cfg = self._enabled_capabilities[normalized_model]
200 use_cache = bool(capability_cfg.get("use_cache")) 200 use_cache = bool(capability_cfg.get("use_cache"))
201 - text_count = 1 if isinstance(text, str) else len(list(text))  
202 logger.info( 201 logger.info(
203 - "Translation route | model=%s backend=%s scene=%s target_lang=%s source_lang=%s count=%s use_cache=%s cache_available=%s",  
204 - normalized_model, 202 + "Translation route | backend=%s request_type=%s use_cache=%s cache_available=%s",
205 getattr(backend, "model", normalized_model), 203 getattr(backend, "model", normalized_model),
206 - active_scene,  
207 - target_lang,  
208 - source_lang or "auto",  
209 - text_count, 204 + "single" if isinstance(text, str) else "batch",
210 use_cache, 205 use_cache,
211 self._translation_cache.available, 206 self._translation_cache.available,
212 ) 207 )
@@ -252,11 +247,7 @@ class TranslationService: @@ -252,11 +247,7 @@ class TranslationService:
252 cached = self._translation_cache.get(model=model, target_lang=target_lang, source_text=text) 247 cached = self._translation_cache.get(model=model, target_lang=target_lang, source_text=text)
253 if cached is not None: 248 if cached is not None:
254 logger.info( 249 logger.info(
255 - "Translation cache served | model=%s scene=%s target_lang=%s source_lang=%s text_len=%s",  
256 - model,  
257 - scene,  
258 - target_lang,  
259 - source_lang or "auto", 250 + "Translation cache served | request_type=single text_len=%s",
260 len(text), 251 len(text),
261 ) 252 )
262 return cached 253 return cached
@@ -274,21 +265,13 @@ class TranslationService: @@ -274,21 +265,13 @@ class TranslationService:
274 translated_text=translated, 265 translated_text=translated,
275 ) 266 )
276 logger.info( 267 logger.info(
277 - "Translation backend result cached | model=%s scene=%s target_lang=%s source_lang=%s text_len=%s result_len=%s",  
278 - model,  
279 - scene,  
280 - target_lang,  
281 - source_lang or "auto", 268 + "Translation backend result cached | request_type=single text_len=%s result_len=%s",
282 len(text), 269 len(text),
283 len(str(translated)), 270 len(str(translated)),
284 ) 271 )
285 else: 272 else:
286 logger.warning( 273 logger.warning(
287 - "Translation backend returned empty result | model=%s scene=%s target_lang=%s source_lang=%s text_len=%s",  
288 - model,  
289 - scene,  
290 - target_lang,  
291 - source_lang or "auto", 274 + "Translation backend returned empty result | request_type=single text_len=%s",
292 len(text), 275 len(text),
293 ) 276 )
294 return translated 277 return translated
@@ -327,11 +310,7 @@ class TranslationService: @@ -327,11 +310,7 @@ class TranslationService:
327 miss_indices.append(idx) 310 miss_indices.append(idx)
328 311
329 logger.info( 312 logger.info(
330 - "Translation batch cache summary | model=%s scene=%s target_lang=%s source_lang=%s total=%s cache_hits=%s cache_misses=%s",  
331 - model,  
332 - scene,  
333 - target_lang,  
334 - source_lang or "auto", 313 + "Translation batch cache summary | total=%s cache_hits=%s cache_misses=%s",
335 len(texts), 314 len(texts),
336 cache_hits, 315 cache_hits,
337 len(misses), 316 len(misses),
@@ -356,11 +335,7 @@ class TranslationService: @@ -356,11 +335,7 @@ class TranslationService:
356 ) 335 )
357 else: 336 else:
358 logger.warning( 337 logger.warning(
359 - "Translation batch item returned empty result | model=%s scene=%s target_lang=%s source_lang=%s item_index=%s text_len=%s",  
360 - model,  
361 - scene,  
362 - target_lang,  
363 - source_lang or "auto", 338 + "Translation batch item returned empty result | item_index=%s text_len=%s",
364 idx, 339 idx,
365 len(original_text), 340 len(original_text),
366 ) 341 )