diff --git a/brain/app/application/import_campaign.py b/brain/app/application/import_campaign.py index aad5d64..fbffc0c 100644 --- a/brain/app/application/import_campaign.py +++ b/brain/app/application/import_campaign.py @@ -26,7 +26,9 @@ from app.domain.ports import LLMProvider, PdfTextExtractor logger = logging.getLogger(__name__) -_TEMPERATURE = 0.2 +# Très basse : structuration = recopie/réorganisation fidèle, pas de créativité. +# Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent). +_TEMPERATURE = 0.1 # Nom de l'arc unique quand le livre n'est pas découpé en actes/parties. _DEFAULT_ARC_NAME = "Aventure principale" diff --git a/brain/app/application/import_rules.py b/brain/app/application/import_rules.py index 5f62b4c..13caf52 100644 --- a/brain/app/application/import_rules.py +++ b/brain/app/application/import_rules.py @@ -23,7 +23,9 @@ from app.domain.ports import LLMProvider, LLMProviderError, PdfTextExtractor logger = logging.getLogger(__name__) # Température basse : tâche de tri/réécriture fidèle, pas de créativité. -_TEMPERATURE = 0.2 +# Très basse : structuration = recopie/réorganisation fidèle, pas de créativité. +# Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent). +_TEMPERATURE = 0.1 # Taxonomie canonique suggérée au modèle pour homogénéiser les titres entre # morceaux (sinon "Combat" / "Le combat" / "Règles de combat" se dispersent). diff --git a/brain/app/application/llm_retry.py b/brain/app/application/llm_retry.py index bd57433..9db9796 100644 --- a/brain/app/application/llm_retry.py +++ b/brain/app/application/llm_retry.py @@ -12,13 +12,45 @@ from __future__ import annotations import asyncio import logging +import re from app.domain.ports import LLMProvider, LLMProviderError logger = logging.getLogger(__name__) -_ATTEMPTS = 3 -_BASE_DELAY_SECONDS = 2.0 +_ATTEMPTS = 4 +_BASE_DELAY_SECONDS = 3.0 +# Un rate limit (429) "par minute" ne se libère pas en 2-3s : on attend plus +# longtemps pour ces erreurs-là (le free tier OpenRouter plafonne ~20 req/min). +_RATE_LIMIT_DELAYS = [10.0, 25.0, 45.0] + + +def _is_rate_limit(exc: LLMProviderError) -> bool: + msg = str(exc).lower() + return "429" in msg or "rate" in msg or "too many requests" in msg + + +def _is_daily_quota(exc: LLMProviderError) -> bool: + """Limite PAR JOUR (vs par minute) : réessayer est inutile, elle ne se libère + qu'au reset quotidien. OpenRouter le précise dans le corps du 429.""" + msg = str(exc).lower() + return "per-day" in msg or "per day" in msg or "free-models-per-day" in msg + + +# OpenRouter renvoie souvent le délai conseillé (saturation amont) : +# "retry_after_seconds": 8 ou "Retry-After": "8". On le respecte plutôt que +# d'attendre une durée fixe arbitraire. +_RETRY_AFTER_RE = re.compile(r'retry[_-]?after(?:_seconds)?"?\s*:\s*"?([0-9]+(?:\.[0-9]+)?)', re.IGNORECASE) + + +def _suggested_retry_after(exc: LLMProviderError) -> float | None: + match = _RETRY_AFTER_RE.search(str(exc)) + if not match: + return None + try: + return float(match.group(1)) + except ValueError: + return None async def generate_with_retry( @@ -28,7 +60,12 @@ async def generate_with_retry( output_format: str | None = None, temperature: float | None = None, ) -> str: - """Comme `llm.generate`, mais réessaie les erreurs transitoires (backoff x2).""" + """Comme `llm.generate`, mais réessaie les erreurs transitoires (backoff). + + Backoff plus long pour les 429 (rate limit) afin de laisser la fenêtre se + libérer. Nombre de tentatives borné : si le quota est durablement épuisé + (ex. limite/jour), l'erreur finit par remonter au lieu de boucler sans fin. + """ delay = _BASE_DELAY_SECONDS last_error: LLMProviderError | None = None for attempt in range(_ATTEMPTS): @@ -36,12 +73,27 @@ async def generate_with_retry( return await llm.generate(prompt, output_format=output_format, temperature=temperature) except LLMProviderError as exc: last_error = exc + # Quota JOURNALIER épuisé : inutile d'insister, on remonte tout de suite + # (sinon on enchaîne des attentes longues pour rien, et on spamme l'API). + if _is_daily_quota(exc): + logger.warning("Quota journalier du fournisseur épuisé — abandon : %s", exc) + raise if attempt < _ATTEMPTS - 1: + if _is_rate_limit(exc): + suggested = _suggested_retry_after(exc) + if suggested is not None: + # Indication serveur (saturation amont) + petite marge, plafonnée. + wait = min(suggested + 2.0, 60.0) + else: + wait = _RATE_LIMIT_DELAYS[min(attempt, len(_RATE_LIMIT_DELAYS) - 1)] + else: + wait = delay + delay *= 2 logger.warning( - "Appel LLM échoué (tentative %s/%s) : %s — nouvelle tentative dans %ss.", - attempt + 1, _ATTEMPTS, exc, delay, + "Appel LLM échoué (tentative %s/%s)%s : %s — nouvelle tentative dans %ss.", + attempt + 1, _ATTEMPTS, " [rate limit]" if _is_rate_limit(exc) else "", + exc, wait, ) - await asyncio.sleep(delay) - delay *= 2 + await asyncio.sleep(wait) assert last_error is not None raise last_error diff --git a/brain/app/infrastructure/openrouter_adapter.py b/brain/app/infrastructure/openrouter_adapter.py index 631ddaa..b63e8cd 100644 --- a/brain/app/infrastructure/openrouter_adapter.py +++ b/brain/app/infrastructure/openrouter_adapter.py @@ -92,7 +92,16 @@ class OpenRouterLLMProvider: async with client.stream( "POST", _API_URL, headers=self._headers(), json=body ) as response: - response.raise_for_status() + if response.status_code >= 400: + # En streaming, le corps n'est pas lu automatiquement : on le + # lit pour exposer le détail d'OpenRouter (ex. le 429 précise + # "free-models-per-day" vs "per-minute"), sinon on n'a que le + # code HTTP nu et le diagnostic est impossible. + detail = (await response.aread()).decode("utf-8", "replace").strip() + raise LLMProviderError( + f"Erreur OpenRouter (HTTP {response.status_code})" + + (f" : {detail[:500]}" if detail else "") + ) async for token in self._parse_sse(response): yield token except httpx.HTTPError as exc: