""" translate.py ──────────── Étape langue cible (NOUVEAU — chantier E) : FR / EN / les deux. Stratégie (sécurité d'abord) : • détection déterministe de la/des langue(s) source (rôles {fr}`…`{en}`…`, sinon heuristique de mots-outils) ; • cible == source → aucun appel LLM ; • bilingue → mono : réduction DÉTERMINISTE (on déballe la langue gardée, on supprime l'autre — zéro LLM, zéro risque) ; • mono → autre langue ou bilingue : appel LLM sur le corps avec les blocs Python MASQUÉS par sentinelles (le Python ne passe jamais par le LLM), puis garde-fous : sentinelles intactes, MÊME ensemble de {{placeholders}}, même nombre de blocs :::::{question}. En cas d'écart → on garde la version non traduite + warning (jamais de sortie corrompue). """ from __future__ import annotations import logging import re from app.llm.client import process_with_openrouter from app.pipeline.postprocess import ( INJECTION_RE, detect_languages, mask_python_blocks, strip_language, unmask_python_blocks, ) from app.pipeline.prompts import ( TRANSLATE_FORMAT_BOTH, TRANSLATE_FORMAT_MONO, TRANSLATE_PROMPT, ) logger = logging.getLogger(__name__) _LABELS = {"fr": "le FRANÇAIS", "en": "l'ANGLAIS", "both": "le BILINGUE FR+EN"} def _placeholders(text: str) -> set[str]: return {tok.strip() for tok in INJECTION_RE.findall(text)} def ensure_language( exercise: str, target: str, model_idx: int, model: str | None = None, ) -> tuple[str, list[dict], dict]: """ Amène l'exercice dans la langue cible. Retourne (exercice, warnings, info={"source": …, "target": …, "action": …}). """ warnings: list[dict] = [] source = detect_languages(exercise) info = {"source": source, "target": target, "action": "aucune"} if target not in ("fr", "en", "both") or target == source: return exercise, warnings, info # Bilingue → mono : déterministe. if source == "both" and target in ("fr", "en"): info["action"] = f"réduction déterministe bilingue → {target}" return strip_language(exercise, keep=target), warnings, info # Mono → autre mono, ou mono → bilingue : LLM sur la prose seule. # Les sentinelles internes \x00PYBLOCKn\x00 (caractère NUL) sont # intransmissibles à un LLM — on les convertit en sentinelles texte # copiables, puis on reconvertit au retour. masked, blocks = mask_python_blocks(exercise) for i in range(len(blocks)): masked = masked.replace(f"\x00PYBLOCK{i}\x00", f"<<>>") fmt = (TRANSLATE_FORMAT_BOTH if target == "both" else TRANSLATE_FORMAT_MONO.format(target_label=_LABELS[target])) try: raw = process_with_openrouter( prompt=TRANSLATE_PROMPT.format( source_label=_LABELS.get(source, source), target_label=_LABELS[target], body=masked, format_directive=fmt, ), model_idx=model_idx, model=model, temperature=0.0, max_tokens=24000, ) except (RuntimeError, ValueError, OSError) as e: warnings.append({ "rule": "langue", "message": f"Étape de traduction en échec ({e}) — sortie laissée en {source}.", }) return exercise, warnings, info translated = raw.strip() translated = re.sub(r"^```\w*\s*\n", "", translated) translated = re.sub(r"\n```\s*$", "", translated) for i in range(len(blocks)): translated = translated.replace(f"<<>>", f"\x00PYBLOCK{i}\x00") # Garde-fous structurels — si l'un casse, on n'applique PAS la traduction. checks = [] for i in range(len(blocks)): if f"\x00PYBLOCK{i}\x00" not in translated: checks.append(f"sentinelle PYBLOCK{i} perdue") if _placeholders(translated) != _placeholders(masked): missing = _placeholders(masked) - _placeholders(translated) added = _placeholders(translated) - _placeholders(masked) checks.append(f"placeholders modifiés (perdus: {sorted(missing)[:4]}, ajoutés: {sorted(added)[:4]})") if translated.count(":::::{question}") != masked.count(":::::{question}"): checks.append("nombre de blocs question modifié") if checks: warnings.append({ "rule": "langue", "message": ("Traduction rejetée par les garde-fous (" + " ; ".join(checks) + ") — sortie laissée en " + source + "."), }) return exercise, warnings, info info["action"] = f"traduction LLM {source} → {target}" return unmask_python_blocks(translated, blocks), warnings, info