feat(providers): mode groupe pour DeepSeek et MiniMax (15 textes par requete)

Les deux services traduisent desormais les documents par lots
numeros dans une seule requete, comme le provider OpenAI : vitesse
multipliee et moins de limites de debit. Repli automatique texte par
texte si la reponse du service est douteuse ou en erreur : aucune
traduction perdue, le document n'echoue jamais a cause d'un lot.
Tests: nouveau test_deepseek_provider.py + extension minimax.
This commit is contained in:
2026-09-01 20:54:45 +02:00
parent 6595b77761
commit 5d6b19d593
4 changed files with 784 additions and 24 deletions

View File

@@ -44,6 +44,24 @@ def _get_language_name(code: str) -> str:
return language_name(code)
def _build_system_prompt(
source_lang: str, target_lang: str, custom_prompt: Optional[str] = None
) -> str:
"""Build system prompt for translation.
The base translation instructions are ALWAYS present — a custom prompt
(glossary, tone, context) is appended as additional directives, never a
replacement. Same contract as the OpenAI provider.
"""
base = DEFAULT_TRANSLATION_PROMPT.format(
source_lang=source_lang, target_lang=target_lang
)
if custom_prompt and custom_prompt.strip():
return f"{base}\n\nADDITIONAL CONTEXT AND INSTRUCTIONS:\n{custom_prompt.strip()}"
return base
class DeepSeekProviderError(Exception):
def __init__(self, code: str, message: str, details: Optional[Dict[str, Any]] = None):
self.code = code
@@ -155,19 +173,11 @@ class DeepSeekTranslationProvider(TranslationProvider):
source_lang_name = _get_language_name(source_language)
target_lang_name = _get_language_name(target_language)
custom_prompt = request.metadata.get("custom_prompt") if request.metadata else None
_base_prompt = DEFAULT_TRANSLATION_PROMPT.format(
source_lang=source_lang_name, target_lang=target_lang_name
)
# Base translation instructions always present; the custom prompt
# (glossary/tone/context) is appended, never a replacement.
if custom_prompt and custom_prompt.strip():
system_prompt = (
_base_prompt
+ "\n\nADDITIONAL CONTEXT AND INSTRUCTIONS:\n"
+ custom_prompt.strip()
)
else:
system_prompt = _base_prompt
system_prompt = _build_system_prompt(
source_lang_name, target_lang_name, custom_prompt
)
last_error = None
for attempt in range(self._max_retries + 1):
@@ -196,7 +206,120 @@ class DeepSeekTranslationProvider(TranslationProvider):
error=last_error.message if last_error else "Unknown error",
error_code=last_error.code if last_error else DEEPSEEK_SERVICE_ERROR)
def translate_batch(self, requests: List[TranslationRequest]) -> List[TranslationResponse]:
def _make_batch_api_request(
self, texts: List[str], system_prompt: str
) -> Optional[List[str]]:
"""Translate a whole chunk in ONE request via a numbered JSON list.
The user message is a JSON array; the model must answer with a JSON
array of the same length. Returns None when the answer cannot be
parsed confidently — callers then fall back to per-item calls
(correctness over latency). Mirrors the OpenAI provider batch mode.
"""
import json as _json
numbered = _json.dumps(
[{"id": i, "text": t} for i, t in enumerate(texts)],
ensure_ascii=False,
)
batch_system = (
system_prompt
+ "\n\nBATCH MODE: the user message is a JSON array of items with "
"unique ids. Answer with ONLY a JSON array of objects "
'[{"id": <same id>, "translation": "<translated text>"}], same '
"length and same ids, in the same order. Translate every item; "
"keep ids unchanged; no comments, no markdown fence."
)
try:
content, _usage = self._make_api_request(numbered, batch_system)
raw = content.strip()
# Strip an optional markdown fence
if raw.startswith("```"):
raw = raw.strip("`")
if raw.lower().startswith("json"):
raw = raw[4:]
raw = raw.strip()
parsed = _json.loads(raw)
if not isinstance(parsed, list) or len(parsed) != len(texts):
return None
out: List[str] = [""] * len(texts)
for item in parsed:
if not isinstance(item, dict):
return None
idx = item.get("id")
translation = item.get("translation")
if not isinstance(idx, int) or not 0 <= idx < len(texts):
return None
if not isinstance(translation, str) or not translation.strip():
return None
out[idx] = translation.strip()
return out
except DeepSeekProviderError:
raise
except Exception:
return None
def translate_batch(
self, requests: List[TranslationRequest]
) -> List[TranslationResponse]:
"""
Translate multiple texts.
Chunks arrive from the translators as ~15 texts. When every request
shares the same language pair and metadata, they are sent in ONE
call (numbered JSON list — ~15x fewer requests, better contextual
consistency across neighbouring segments). Any parse/API doubt falls
back to the per-item path so a batch failure never corrupts output.
"""
if not requests:
return []
same_pair = len({(r.source_language, r.target_language) for r in requests}) == 1
same_meta = len(
{tuple(sorted((r.metadata or {}).items())) for r in requests}
) == 1
if same_pair and same_meta and len(requests) > 1:
try:
source_lang_name = _get_language_name(
requests[0].source_language or "auto"
) or "the source language (auto-detect)"
target_lang_name = _get_language_name(requests[0].target_language)
custom_prompt = None
if requests[0].metadata:
custom_prompt = requests[0].metadata.get("custom_prompt")
system_prompt = _build_system_prompt(
source_lang_name, target_lang_name, custom_prompt
)
texts = [r.text for r in requests]
translations = self._make_batch_api_request(texts, system_prompt)
if translations is not None:
logger.info(
"deepseek_batch_translation_success",
items=len(requests),
model=self._model,
)
return [
TranslationResponse(
translated_text=t,
provider_name=self._provider_name,
from_cache=False,
)
for t in translations
]
logger.warning(
"deepseek_batch_translation_fallback",
reason="unparseable_response",
items=len(requests),
)
except Exception as e:
logger.warning(
"deepseek_batch_translation_fallback",
reason=type(e).__name__,
items=len(requests),
)
return [self.translate_text(req) for req in requests]
def health_check(self) -> ProviderHealthStatus: