Spaces:
Runtime error
fix(limiteur): attente bornee, 429 retenus, mentions dans la langue de la reponse
Browse filesIssus de la revue de la branche :
- L'attente d'une place de generation (4 par modele) n'avait pas de
limite : quand toutes etaient prises par des comparaisons longues, un
message du chat attendait des minutes, sans secours ni 503. Elle est
bornee par l'attente acceptee ; au-dela, le modele de secours.
- Mistral : un Retry-After plus long que le budget, ou recu au dernier
essai, n'etait pas inscrit au limiteur ; l'appel suivant retournait
frapper le modele sanctionne. Il est inscrit avant de decider
d'abandonner.
- Un `repousser` (429) ne retenait pas les appels deja endormis sur un
depart reserve : ils partaient en pleine penalite. Ils attendent
l'echeance.
- Le recalage sur les en-tetes de Groq s'inscrivait en queue de fenetre,
apres des departs reserves pour plus tard, et le calcul du prochain
depart faisait attendre trop (88 s au lieu de 59). Il s'inscrit a sa
place. Groq corrige l'usage reel AVANT de recaler, sinon l'estimation
masquait la consommation des autres processus.
- Les mentions de fin suivent la langue de la reponse ; une fin « error »
est une interruption (« reposez la question »), pas une longueur ;
l'explication d'article n'invite plus a « poser une question plus
precise ».
Tests : garde reseau ignoree sous JURIX_E2E (rien a garder), semaphore
par boucle verifie sous contention (sans elle, il ne se lie a aucune
boucle et le test ne prouvait rien), Retry-After distinct de l'espacement
du limiteur, GROQ_ATTENTE_MAX_429_S epingle (un .env le changeait).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- app/core/limiteur.py +58 -3
- app/services/explanation_service.py +5 -4
- app/services/groq_service.py +7 -2
- app/services/llm.py +39 -20
- app/services/mistral_service.py +5 -2
- app/services/rag_service.py +3 -3
- tests/test_core/test_limiteur.py +90 -6
- tests/test_qualite/test_garde_reseau.py +6 -1
- tests/test_services/test_explanation_service.py +12 -6
- tests/test_services/test_groq_service.py +20 -0
- tests/test_services/test_intent_groq.py +1 -0
- tests/test_services/test_llm.py +26 -0
- tests/test_services/test_mistral_service.py +44 -2
- tests/test_services/test_rag_service.py +15 -0
|
@@ -124,10 +124,20 @@ class _Fenetre:
|
|
| 124 |
return debut # inatteignable : la fenetre vide accepte toujours
|
| 125 |
|
| 126 |
def ajouter(self, instant: float, quantite: int) -> Optional[list]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
if self.plafond is None:
|
| 128 |
return None
|
| 129 |
entree = [instant, quantite]
|
| 130 |
-
self.entrees
|
|
|
|
|
|
|
|
|
|
| 131 |
return entree
|
| 132 |
|
| 133 |
def total(self, instant: float) -> int:
|
|
@@ -352,8 +362,13 @@ class Limiteur:
|
|
| 352 |
) -> Reservation:
|
| 353 |
"""Reserve, puis dort jusqu'au depart (sans bloquer la boucle)."""
|
| 354 |
reservation = self.reserver(jetons, attente_max, jetons_entree=jetons_entree)
|
|
|
|
| 355 |
if reservation.attente > 0:
|
| 356 |
await self._dormir_async(reservation.attente)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
return reservation
|
| 358 |
|
| 359 |
def attendre_sync(
|
|
@@ -365,10 +380,35 @@ class Limiteur:
|
|
| 365 |
) -> Reservation:
|
| 366 |
"""Reserve, puis dort jusqu'au depart : pour le pipeline et les scripts."""
|
| 367 |
reservation = self.reserver(jetons, attente_max, jetons_entree=jetons_entree)
|
|
|
|
| 368 |
if reservation.attente > 0:
|
| 369 |
self._dormir(reservation.attente)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
return reservation
|
| 371 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
@asynccontextmanager
|
| 373 |
async def creneau(
|
| 374 |
self,
|
|
@@ -383,12 +423,27 @@ class Limiteur:
|
|
| 383 |
La place est prise AVANT la reservation : reserver d'abord ferait
|
| 384 |
partir la requete plus tard que prevu, et les fenetres compteraient
|
| 385 |
un depart qui n'a pas eu lieu a l'instant inscrit.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
"""
|
| 387 |
semaphore = self._semaphore()
|
|
|
|
| 388 |
if semaphore is not None:
|
| 389 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
try:
|
| 391 |
-
yield await self.attendre(jetons,
|
| 392 |
finally:
|
| 393 |
if semaphore is not None:
|
| 394 |
semaphore.release()
|
|
|
|
| 124 |
return debut # inatteignable : la fenetre vide accepte toujours
|
| 125 |
|
| 126 |
def ajouter(self, instant: float, quantite: int) -> Optional[list]:
|
| 127 |
+
"""
|
| 128 |
+
Inscrit une entree A SA PLACE dans le temps. Les departs reserves
|
| 129 |
+
arrivent dans l'ordre, mais un recalage (consommation des autres
|
| 130 |
+
processus, inscrite a « maintenant ») peut tomber avant des departs
|
| 131 |
+
deja reserves pour plus tard : ajoutee en queue, elle faussait
|
| 132 |
+
`premier_instant`, qui suppose l'ordre.
|
| 133 |
+
"""
|
| 134 |
if self.plafond is None:
|
| 135 |
return None
|
| 136 |
entree = [instant, quantite]
|
| 137 |
+
position = len(self.entrees)
|
| 138 |
+
while position > 0 and self.entrees[position - 1][0] > instant:
|
| 139 |
+
position -= 1
|
| 140 |
+
self.entrees.insert(position, entree)
|
| 141 |
return entree
|
| 142 |
|
| 143 |
def total(self, instant: float) -> int:
|
|
|
|
| 362 |
) -> Reservation:
|
| 363 |
"""Reserve, puis dort jusqu'au depart (sans bloquer la boucle)."""
|
| 364 |
reservation = self.reserver(jetons, attente_max, jetons_entree=jetons_entree)
|
| 365 |
+
debut = self._horloge()
|
| 366 |
if reservation.attente > 0:
|
| 367 |
await self._dormir_async(reservation.attente)
|
| 368 |
+
supplement = self._supplement(debut, attente_max)
|
| 369 |
+
while supplement > 0:
|
| 370 |
+
await self._dormir_async(supplement)
|
| 371 |
+
supplement = self._supplement(debut, attente_max)
|
| 372 |
return reservation
|
| 373 |
|
| 374 |
def attendre_sync(
|
|
|
|
| 380 |
) -> Reservation:
|
| 381 |
"""Reserve, puis dort jusqu'au depart : pour le pipeline et les scripts."""
|
| 382 |
reservation = self.reserver(jetons, attente_max, jetons_entree=jetons_entree)
|
| 383 |
+
debut = self._horloge()
|
| 384 |
if reservation.attente > 0:
|
| 385 |
self._dormir(reservation.attente)
|
| 386 |
+
supplement = self._supplement(debut, attente_max)
|
| 387 |
+
while supplement > 0:
|
| 388 |
+
self._dormir(supplement)
|
| 389 |
+
supplement = self._supplement(debut, attente_max)
|
| 390 |
return reservation
|
| 391 |
|
| 392 |
+
def _supplement(self, debut: float, attente_max: Optional[float]) -> float:
|
| 393 |
+
"""
|
| 394 |
+
L'attente qu'impose un `repousser` survenu PENDANT le sommeil.
|
| 395 |
+
|
| 396 |
+
Un appelant deja endormi partait a l'heure reservee, en plein dans la
|
| 397 |
+
penalite qu'un 429 venait d'imposer : chaque depart reserve recoltait
|
| 398 |
+
son propre 429. Au-dela de `attente_max` (compte depuis la
|
| 399 |
+
reservation), AttenteTropLongue ; la reservation reste comptee, ce qui
|
| 400 |
+
est prudent.
|
| 401 |
+
"""
|
| 402 |
+
with self._verrou:
|
| 403 |
+
maintenant = self._horloge()
|
| 404 |
+
supplement = self._pas_avant - maintenant
|
| 405 |
+
raison = self._raison_pas_avant
|
| 406 |
+
if supplement <= 0:
|
| 407 |
+
return 0.0
|
| 408 |
+
if attente_max is not None and maintenant + supplement - debut > attente_max:
|
| 409 |
+
raise AttenteTropLongue(self.nom, maintenant + supplement - debut, raison)
|
| 410 |
+
return supplement
|
| 411 |
+
|
| 412 |
@asynccontextmanager
|
| 413 |
async def creneau(
|
| 414 |
self,
|
|
|
|
| 423 |
La place est prise AVANT la reservation : reserver d'abord ferait
|
| 424 |
partir la requete plus tard que prevu, et les fenetres compteraient
|
| 425 |
un depart qui n'a pas eu lieu a l'instant inscrit.
|
| 426 |
+
|
| 427 |
+
`attente_max` borne AUSSI l'attente d'une place : sans cela, quand
|
| 428 |
+
toutes les places sont prises par des generations longues, l'appel
|
| 429 |
+
attendait sans limite, et ni le modele de secours ni le 503 promis
|
| 430 |
+
n'arrivaient. Le temps passe a attendre la place est deduit du budget
|
| 431 |
+
de la reservation.
|
| 432 |
"""
|
| 433 |
semaphore = self._semaphore()
|
| 434 |
+
restant = attente_max
|
| 435 |
if semaphore is not None:
|
| 436 |
+
if attente_max is None:
|
| 437 |
+
await semaphore.acquire()
|
| 438 |
+
else:
|
| 439 |
+
debut = time.monotonic()
|
| 440 |
+
try:
|
| 441 |
+
await asyncio.wait_for(semaphore.acquire(), timeout=attente_max)
|
| 442 |
+
except asyncio.TimeoutError:
|
| 443 |
+
raise AttenteTropLongue(self.nom, attente_max, "concurrence") from None
|
| 444 |
+
restant = max(0.0, attente_max - (time.monotonic() - debut))
|
| 445 |
try:
|
| 446 |
+
yield await self.attendre(jetons, restant, jetons_entree=jetons_entree)
|
| 447 |
finally:
|
| 448 |
if semaphore is not None:
|
| 449 |
semaphore.release()
|
|
@@ -161,7 +161,7 @@ class ExplanationService:
|
|
| 161 |
prompt = self._construire_prompt(law, chunks, numero_resolu, langue)
|
| 162 |
systeme = get_system_prompt(EXPLANATION_PERSONA, langue)
|
| 163 |
|
| 164 |
-
texte = await self._generer(prompt, systeme)
|
| 165 |
|
| 166 |
duree_ms = int((time.perf_counter() - debut) * 1000)
|
| 167 |
logger.info(
|
|
@@ -341,7 +341,7 @@ class ExplanationService:
|
|
| 341 |
|
| 342 |
return "\n".join(entete) + "\n\n" + contexte + "\n" + tache
|
| 343 |
|
| 344 |
-
async def _generer(self, prompt: str, systeme: str) -> str:
|
| 345 |
"""
|
| 346 |
Appelle le modele et traduit ses echecs.
|
| 347 |
|
|
@@ -367,5 +367,6 @@ class ExplanationService:
|
|
| 367 |
if not texte.strip():
|
| 368 |
raise ExplanationError("Le modèle n'a produit aucune explication.")
|
| 369 |
# Une explication coupee le dit : sinon sa derniere phrase, inachevee,
|
| 370 |
-
# passerait pour la fin du raisonnement.
|
| 371 |
-
|
|
|
|
|
|
| 161 |
prompt = self._construire_prompt(law, chunks, numero_resolu, langue)
|
| 162 |
systeme = get_system_prompt(EXPLANATION_PERSONA, langue)
|
| 163 |
|
| 164 |
+
texte = await self._generer(prompt, systeme, langue)
|
| 165 |
|
| 166 |
duree_ms = int((time.perf_counter() - debut) * 1000)
|
| 167 |
logger.info(
|
|
|
|
| 341 |
|
| 342 |
return "\n".join(entete) + "\n\n" + contexte + "\n" + tache
|
| 343 |
|
| 344 |
+
async def _generer(self, prompt: str, systeme: str, langue: str = "fr") -> str:
|
| 345 |
"""
|
| 346 |
Appelle le modele et traduit ses echecs.
|
| 347 |
|
|
|
|
| 367 |
if not texte.strip():
|
| 368 |
raise ExplanationError("Le modèle n'a produit aucune explication.")
|
| 369 |
# Une explication coupee le dit : sinon sa derniere phrase, inachevee,
|
| 370 |
+
# passerait pour la fin du raisonnement. Sans conseil : il n'y a pas de
|
| 371 |
+
# question a reformuler derriere le bouton « Expliquer ».
|
| 372 |
+
return texte + mention_de_reponse(reponse, langue, conseil=False)
|
|
@@ -279,14 +279,19 @@ class GroqService:
|
|
| 279 |
repousse), ou une exception. Commun aux chemins sync et async.
|
| 280 |
"""
|
| 281 |
limiteur = self.limiteur(modele)
|
| 282 |
-
self._recaler(limiteur, reponse.headers)
|
| 283 |
code = reponse.status_code
|
| 284 |
-
|
| 285 |
if code == 200:
|
| 286 |
corps = reponse.json()
|
| 287 |
usage = corps.get("usage") or {}
|
|
|
|
|
|
|
|
|
|
| 288 |
if usage.get("total_tokens") is not None:
|
| 289 |
reservation.corriger(usage["total_tokens"], usage.get("prompt_tokens"))
|
|
|
|
|
|
|
|
|
|
| 290 |
choix = corps["choices"][0]
|
| 291 |
fin = choix.get("finish_reason")
|
| 292 |
contenu = (choix.get("message") or {}).get("content") or ""
|
|
|
|
| 279 |
repousse), ou une exception. Commun aux chemins sync et async.
|
| 280 |
"""
|
| 281 |
limiteur = self.limiteur(modele)
|
|
|
|
| 282 |
code = reponse.status_code
|
| 283 |
+
corps = None
|
| 284 |
if code == 200:
|
| 285 |
corps = reponse.json()
|
| 286 |
usage = corps.get("usage") or {}
|
| 287 |
+
# Corriger AVANT de recaler : le recalage compare la fenetre a ce
|
| 288 |
+
# que Groq annonce, et l'estimation laissee en place y masquait la
|
| 289 |
+
# consommation des autres processus.
|
| 290 |
if usage.get("total_tokens") is not None:
|
| 291 |
reservation.corriger(usage["total_tokens"], usage.get("prompt_tokens"))
|
| 292 |
+
self._recaler(limiteur, reponse.headers)
|
| 293 |
+
|
| 294 |
+
if code == 200:
|
| 295 |
choix = corps["choices"][0]
|
| 296 |
fin = choix.get("finish_reason")
|
| 297 |
contenu = (choix.get("message") or {}).get("content") or ""
|
|
@@ -18,6 +18,7 @@ from typing import Any, Dict, Optional
|
|
| 18 |
|
| 19 |
from app.core.config import settings
|
| 20 |
from app.services.gemini_service import (
|
|
|
|
| 21 |
GeminiOverloadedError,
|
| 22 |
GeminiQuotaError,
|
| 23 |
GeminiServiceError,
|
|
@@ -38,39 +39,57 @@ ERREURS_QUOTA = (GeminiQuotaError, MistralQuotaError)
|
|
| 38 |
ERREURS_SATURATION = (GeminiOverloadedError, MistralOverloadedError)
|
| 39 |
# Toute erreur de generation, y compris les deux familles ci-dessus.
|
| 40 |
ERREURS_LLM = (GeminiServiceError, MistralServiceError)
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
#
|
| 43 |
-
# coupee ressemble a une reponse complete.
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
)
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
|
| 56 |
-
def mention_de_fin(fin: Optional[str]) -> str:
|
| 57 |
"""
|
| 58 |
La mention a ajouter a une reponse selon sa fin, ou "" si elle est
|
| 59 |
complete. `fin` suit le vocabulaire commun (« STOP », « MAX_TOKENS »...) ;
|
| 60 |
Gemini et Mistral le partagent.
|
| 61 |
"""
|
| 62 |
-
if fin
|
| 63 |
-
return
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
| 67 |
|
| 68 |
|
| 69 |
-
def mention_de_reponse(reponse: Dict[str, Any]) -> str:
|
| 70 |
"""La mention d'une reponse de `generate` : coupee si elle le dit."""
|
| 71 |
if not (reponse or {}).get("tronquee"):
|
| 72 |
return ""
|
| 73 |
-
return mention_de_fin(reponse.get("fin") or "MAX_TOKENS")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
|
| 76 |
def get_llm_service() -> Any:
|
|
|
|
| 18 |
|
| 19 |
from app.core.config import settings
|
| 20 |
from app.services.gemini_service import (
|
| 21 |
+
GeminiBudgetEpuiseError,
|
| 22 |
GeminiOverloadedError,
|
| 23 |
GeminiQuotaError,
|
| 24 |
GeminiServiceError,
|
|
|
|
| 39 |
ERREURS_SATURATION = (GeminiOverloadedError, MistralOverloadedError)
|
| 40 |
# Toute erreur de generation, y compris les deux familles ci-dessus.
|
| 41 |
ERREURS_LLM = (GeminiServiceError, MistralServiceError)
|
| 42 |
+
# Budget epuise avant toute sortie : un budget plus grand peut suffire.
|
| 43 |
+
ERREURS_BUDGET = (GeminiBudgetEpuiseError,)
|
| 44 |
|
| 45 |
+
# Ajoutees a une reponse que le modele n'a pas pu finir : sans elles, une
|
| 46 |
+
# phrase coupee ressemble a une reponse complete. Deux causes, deux remedes :
|
| 47 |
+
# la LONGUEUR (poser une question plus precise) et l'INTERRUPTION — flux
|
| 48 |
+
# rompu, ou fin « error » du fournisseur — (reposer la question). Dans la
|
| 49 |
+
# langue de la reponse ; sans conseil quand il n'y a pas de question a
|
| 50 |
+
# reformuler (l'explication d'un article).
|
| 51 |
+
_MENTIONS = {
|
| 52 |
+
"fr": {
|
| 53 |
+
"longueur": "Réponse interrompue : longueur maximale atteinte.",
|
| 54 |
+
"conseil_longueur": "Posez une question plus précise pour obtenir la suite.",
|
| 55 |
+
"interruption": "Réponse interrompue : la connexion avec le modèle a été coupée.",
|
| 56 |
+
"conseil_interruption": "Reposez la question pour obtenir une réponse complète.",
|
| 57 |
+
},
|
| 58 |
+
"en": {
|
| 59 |
+
"longueur": "Answer cut short: maximum length reached.",
|
| 60 |
+
"conseil_longueur": "Ask a more specific question to get the rest.",
|
| 61 |
+
"interruption": "Answer interrupted: the connection with the model was lost.",
|
| 62 |
+
"conseil_interruption": "Ask again to get a complete answer.",
|
| 63 |
+
},
|
| 64 |
+
}
|
| 65 |
+
# Fins dues a une interruption, et non a la longueur.
|
| 66 |
+
_FINS_INTERROMPUES = frozenset({FIN_INTERROMPUE, "ERROR"})
|
| 67 |
|
| 68 |
|
| 69 |
+
def mention_de_fin(fin: Optional[str], langue: str = "fr", conseil: bool = True) -> str:
|
| 70 |
"""
|
| 71 |
La mention a ajouter a une reponse selon sa fin, ou "" si elle est
|
| 72 |
complete. `fin` suit le vocabulaire commun (« STOP », « MAX_TOKENS »...) ;
|
| 73 |
Gemini et Mistral le partagent.
|
| 74 |
"""
|
| 75 |
+
if fin not in FINS_INCOMPLETES:
|
| 76 |
+
return ""
|
| 77 |
+
textes = _MENTIONS.get(langue, _MENTIONS["fr"])
|
| 78 |
+
cause = "interruption" if fin in _FINS_INTERROMPUES else "longueur"
|
| 79 |
+
phrase = textes[cause] + (" " + textes[f"conseil_{cause}"] if conseil else "")
|
| 80 |
+
return f"\n\n*({phrase})*"
|
| 81 |
|
| 82 |
|
| 83 |
+
def mention_de_reponse(reponse: Dict[str, Any], langue: str = "fr", conseil: bool = True) -> str:
|
| 84 |
"""La mention d'une reponse de `generate` : coupee si elle le dit."""
|
| 85 |
if not (reponse or {}).get("tronquee"):
|
| 86 |
return ""
|
| 87 |
+
return mention_de_fin(reponse.get("fin") or "MAX_TOKENS", langue, conseil)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# Les mentions francaises completes, telles que le chat les affiche.
|
| 91 |
+
MENTION_REPONSE_TRONQUEE = mention_de_fin("MAX_TOKENS")
|
| 92 |
+
MENTION_REPONSE_INTERROMPUE = mention_de_fin(FIN_INTERROMPUE)
|
| 93 |
|
| 94 |
|
| 95 |
def get_llm_service() -> Any:
|
|
@@ -332,13 +332,16 @@ FORBIDDEN:
|
|
| 332 |
reponse.status_code, reponse.content, reponse.headers, essai
|
| 333 |
)
|
| 334 |
cause = f"HTTP {reponse.status_code}"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
if self._abandonner(limiteur, debut, essai, attente):
|
| 336 |
break
|
| 337 |
logger.warning(
|
| 338 |
"⚠️ Mistral %s : %s, nouvel essai dans %.1f s (%d/%d)",
|
| 339 |
modele, cause, attente, essai, ESSAIS,
|
| 340 |
)
|
| 341 |
-
limiteur.repousser(attente, cause)
|
| 342 |
|
| 343 |
secours = self._secours(modele) if secours_permis else None
|
| 344 |
if secours:
|
|
@@ -401,13 +404,13 @@ FORBIDDEN:
|
|
| 401 |
except AttenteTropLongue as e:
|
| 402 |
cause = f"attente {e.attente:.0f} s ({e.raison})"
|
| 403 |
break
|
|
|
|
| 404 |
if self._abandonner(limiteur, debut, essai, attente):
|
| 405 |
break
|
| 406 |
logger.warning(
|
| 407 |
"⚠️ Mistral %s (flux) : %s, nouvel essai dans %.1f s (%d/%d)",
|
| 408 |
modele, cause, attente, essai, ESSAIS,
|
| 409 |
)
|
| 410 |
-
limiteur.repousser(attente, cause)
|
| 411 |
|
| 412 |
secours = self._secours(modele) if secours_permis else None
|
| 413 |
if secours:
|
|
|
|
| 332 |
reponse.status_code, reponse.content, reponse.headers, essai
|
| 333 |
)
|
| 334 |
cause = f"HTTP {reponse.status_code}"
|
| 335 |
+
# Le delai impose est inscrit AVANT de decider d'abandonner :
|
| 336 |
+
# sinon un Retry-After plus long que le budget etait perdu, et
|
| 337 |
+
# l'appel suivant repartait aussitot vers le modele sanctionne.
|
| 338 |
+
limiteur.repousser(attente, cause)
|
| 339 |
if self._abandonner(limiteur, debut, essai, attente):
|
| 340 |
break
|
| 341 |
logger.warning(
|
| 342 |
"⚠️ Mistral %s : %s, nouvel essai dans %.1f s (%d/%d)",
|
| 343 |
modele, cause, attente, essai, ESSAIS,
|
| 344 |
)
|
|
|
|
| 345 |
|
| 346 |
secours = self._secours(modele) if secours_permis else None
|
| 347 |
if secours:
|
|
|
|
| 404 |
except AttenteTropLongue as e:
|
| 405 |
cause = f"attente {e.attente:.0f} s ({e.raison})"
|
| 406 |
break
|
| 407 |
+
limiteur.repousser(attente, cause)
|
| 408 |
if self._abandonner(limiteur, debut, essai, attente):
|
| 409 |
break
|
| 410 |
logger.warning(
|
| 411 |
"⚠️ Mistral %s (flux) : %s, nouvel essai dans %.1f s (%d/%d)",
|
| 412 |
modele, cause, attente, essai, ESSAIS,
|
| 413 |
)
|
|
|
|
| 414 |
|
| 415 |
secours = self._secours(modele) if secours_permis else None
|
| 416 |
if secours:
|
|
@@ -457,7 +457,7 @@ class RAGService:
|
|
| 457 |
max_tokens=ANSWER_MAX_TOKENS, reflexion=settings.GEMINI_REFLEXION_REPONSE,
|
| 458 |
)
|
| 459 |
generation_time_ms = int((time.time() - generation_start) * 1000)
|
| 460 |
-
answer = llm_response["response"] + mention_de_reponse(llm_response)
|
| 461 |
|
| 462 |
# Extract citations
|
| 463 |
citations = self._extract_citations(answer, search_results)
|
|
@@ -526,7 +526,7 @@ class RAGService:
|
|
| 526 |
generation_time_ms = int((time.time() - generation_start) * 1000)
|
| 527 |
# Deux phrases attendues, mais un budget de jetons tout de meme : une
|
| 528 |
# reponse coupee le dit, ici comme ailleurs.
|
| 529 |
-
answer = llm_response["response"] + mention_de_reponse(llm_response)
|
| 530 |
|
| 531 |
# `_calculate_confidence` n'est PAS appele : ses quatre facteurs
|
| 532 |
# mesurent l'ancrage documentaire d'une reponse (citations, pertinence
|
|
@@ -694,7 +694,7 @@ class RAGService:
|
|
| 694 |
|
| 695 |
# Une reponse restee incomplete le dit, comme dans `ask` : coupee
|
| 696 |
# par le budget malgre la suite demandee, ou flux rompu.
|
| 697 |
-
mention = mention_de_fin(raison.get("fin"))
|
| 698 |
if mention:
|
| 699 |
answer_parts.append(mention)
|
| 700 |
yield RAGStreamChunk(chunk=mention, done=False).model_dump_json(exclude_none=True)
|
|
|
|
| 457 |
max_tokens=ANSWER_MAX_TOKENS, reflexion=settings.GEMINI_REFLEXION_REPONSE,
|
| 458 |
)
|
| 459 |
generation_time_ms = int((time.time() - generation_start) * 1000)
|
| 460 |
+
answer = llm_response["response"] + mention_de_reponse(llm_response, request.language)
|
| 461 |
|
| 462 |
# Extract citations
|
| 463 |
citations = self._extract_citations(answer, search_results)
|
|
|
|
| 526 |
generation_time_ms = int((time.time() - generation_start) * 1000)
|
| 527 |
# Deux phrases attendues, mais un budget de jetons tout de meme : une
|
| 528 |
# reponse coupee le dit, ici comme ailleurs.
|
| 529 |
+
answer = llm_response["response"] + mention_de_reponse(llm_response, request.language)
|
| 530 |
|
| 531 |
# `_calculate_confidence` n'est PAS appele : ses quatre facteurs
|
| 532 |
# mesurent l'ancrage documentaire d'une reponse (citations, pertinence
|
|
|
|
| 694 |
|
| 695 |
# Une reponse restee incomplete le dit, comme dans `ask` : coupee
|
| 696 |
# par le budget malgre la suite demandee, ou flux rompu.
|
| 697 |
+
mention = mention_de_fin(raison.get("fin"), request.language)
|
| 698 |
if mention:
|
| 699 |
answer_parts.append(mention)
|
| 700 |
yield RAGStreamChunk(chunk=mention, done=False).model_dump_json(exclude_none=True)
|
|
@@ -209,6 +209,25 @@ class TestRecaler:
|
|
| 209 |
assert lim.reserver(jetons=50).attente == 0.0
|
| 210 |
assert lim.reserver(jetons=200).attente == pytest.approx(60.0)
|
| 211 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
def test_requetes_du_jour_recalees(self, horloge):
|
| 213 |
lim = limiteur(horloge, requetes_par_jour=10)
|
| 214 |
|
|
@@ -220,6 +239,41 @@ class TestRecaler:
|
|
| 220 |
|
| 221 |
|
| 222 |
class TestAttendre:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
def test_synchrone_dort_l_attente(self, horloge):
|
| 224 |
lim = limiteur(horloge, requetes_par_minute=30)
|
| 225 |
|
|
@@ -260,15 +314,45 @@ class TestConcurrence:
|
|
| 260 |
assert dedans == ["a", "b"]
|
| 261 |
|
| 262 |
def test_un_semaphore_par_boucle(self, horloge):
|
| 263 |
-
"""
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
async with lim.creneau():
|
| 268 |
-
await
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
|
| 270 |
-
|
| 271 |
-
|
|
|
|
| 272 |
|
| 273 |
async def test_le_refus_rend_la_place(self, horloge):
|
| 274 |
lim = limiteur(horloge, requetes_par_minute=30, concurrence=1)
|
|
|
|
| 209 |
assert lim.reserver(jetons=50).attente == 0.0
|
| 210 |
assert lim.reserver(jetons=200).attente == pytest.approx(60.0)
|
| 211 |
|
| 212 |
+
def test_l_ecart_s_inscrit_a_sa_place_dans_le_temps(self, horloge):
|
| 213 |
+
"""
|
| 214 |
+
Le recalage tombe a « maintenant », AVANT un depart deja reserve pour
|
| 215 |
+
plus tard. Ajoute en queue, il faisait attendre 88 s au lieu de 59.
|
| 216 |
+
"""
|
| 217 |
+
lim = limiteur(horloge, jetons_par_minute=8000)
|
| 218 |
+
horloge.t = 99.0
|
| 219 |
+
lim.reserver(jetons=1000)
|
| 220 |
+
lim.repousser(31)
|
| 221 |
+
assert lim.reserver(jetons=3000).depart == pytest.approx(130.0)
|
| 222 |
+
horloge.t = 101.0
|
| 223 |
+
lim.recaler(jetons_restants_minute=2000) # les autres ont pris 2 000
|
| 224 |
+
|
| 225 |
+
horloge.t = 102.0
|
| 226 |
+
reservation = lim.reserver(jetons=4000)
|
| 227 |
+
|
| 228 |
+
# A 161, l'ecart (inscrit a 101) est sorti de la fenetre : 3 000 + 4 000.
|
| 229 |
+
assert reservation.depart == pytest.approx(161.0)
|
| 230 |
+
|
| 231 |
def test_requetes_du_jour_recalees(self, horloge):
|
| 232 |
lim = limiteur(horloge, requetes_par_jour=10)
|
| 233 |
|
|
|
|
| 239 |
|
| 240 |
|
| 241 |
class TestAttendre:
|
| 242 |
+
def test_un_429_pendant_le_sommeil_retarde_le_depart(self, horloge):
|
| 243 |
+
"""
|
| 244 |
+
Un autre appel recoit un 429 pendant que celui-ci dort : partir a
|
| 245 |
+
l'heure reservee, c'etait partir en pleine penalite.
|
| 246 |
+
"""
|
| 247 |
+
lim = limiteur(horloge, requetes_par_minute=30)
|
| 248 |
+
lim.reserver()
|
| 249 |
+
dormir = horloge.dormir
|
| 250 |
+
|
| 251 |
+
def dormir_et_subir_un_429(secondes):
|
| 252 |
+
dormir(secondes)
|
| 253 |
+
if len(horloge.sommeils) == 1:
|
| 254 |
+
lim.repousser(10, "429")
|
| 255 |
+
|
| 256 |
+
lim._dormir = dormir_et_subir_un_429
|
| 257 |
+
|
| 258 |
+
lim.attendre_sync()
|
| 259 |
+
|
| 260 |
+
assert horloge.sommeils == [2.0, 10.0]
|
| 261 |
+
assert horloge.t == pytest.approx(1012.0)
|
| 262 |
+
|
| 263 |
+
def test_le_supplement_respecte_attente_max(self, horloge):
|
| 264 |
+
lim = limiteur(horloge, requetes_par_minute=30)
|
| 265 |
+
lim.reserver()
|
| 266 |
+
dormir = horloge.dormir
|
| 267 |
+
|
| 268 |
+
def dormir_et_subir_un_429(secondes):
|
| 269 |
+
dormir(secondes)
|
| 270 |
+
lim.repousser(60, "429")
|
| 271 |
+
|
| 272 |
+
lim._dormir = dormir_et_subir_un_429
|
| 273 |
+
|
| 274 |
+
with pytest.raises(AttenteTropLongue):
|
| 275 |
+
lim.attendre_sync(attente_max=5)
|
| 276 |
+
|
| 277 |
def test_synchrone_dort_l_attente(self, horloge):
|
| 278 |
lim = limiteur(horloge, requetes_par_minute=30)
|
| 279 |
|
|
|
|
| 314 |
assert dedans == ["a", "b"]
|
| 315 |
|
| 316 |
def test_un_semaphore_par_boucle(self, horloge):
|
| 317 |
+
"""
|
| 318 |
+
Partage entre deux boucles, un asyncio.Semaphore leverait RuntimeError.
|
| 319 |
+
Il ne se lie a sa boucle que lorsqu'un appel doit ATTENDRE : d'ou deux
|
| 320 |
+
appels concurrents pour une seule place, dans chaque boucle.
|
| 321 |
+
"""
|
| 322 |
+
lim = limiteur(horloge, concurrence=1)
|
| 323 |
+
|
| 324 |
+
async def deux_appels():
|
| 325 |
+
async def appel():
|
| 326 |
+
async with lim.creneau():
|
| 327 |
+
await asyncio.sleep(0)
|
| 328 |
+
|
| 329 |
+
await asyncio.gather(appel(), appel())
|
| 330 |
+
|
| 331 |
+
asyncio.run(deux_appels())
|
| 332 |
+
asyncio.run(deux_appels())
|
| 333 |
|
| 334 |
+
async def test_l_attente_d_une_place_est_bornee(self, horloge):
|
| 335 |
+
"""
|
| 336 |
+
Toutes les places prises par des generations longues : l'appel
|
| 337 |
+
attendait sans limite, sans secours ni 503.
|
| 338 |
+
"""
|
| 339 |
+
lim = limiteur(horloge, concurrence=1)
|
| 340 |
+
liberer = asyncio.Event()
|
| 341 |
+
|
| 342 |
+
async def occuper():
|
| 343 |
async with lim.creneau():
|
| 344 |
+
await liberer.wait()
|
| 345 |
+
|
| 346 |
+
occupant = asyncio.create_task(occuper())
|
| 347 |
+
await asyncio.sleep(0)
|
| 348 |
+
|
| 349 |
+
with pytest.raises(AttenteTropLongue) as erreur:
|
| 350 |
+
async with lim.creneau(attente_max=0.05):
|
| 351 |
+
pass
|
| 352 |
|
| 353 |
+
assert erreur.value.raison == "concurrence"
|
| 354 |
+
liberer.set()
|
| 355 |
+
await occupant
|
| 356 |
|
| 357 |
async def test_le_refus_rend_la_place(self, horloge):
|
| 358 |
lim = limiteur(horloge, requetes_par_minute=30, concurrence=1)
|
|
@@ -24,13 +24,18 @@ from app.services.groq_service import GroqService
|
|
| 24 |
from app.services.mistral_service import MistralService
|
| 25 |
from tests.conftest import CLE_FACTICE
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
def _garde(module):
|
| 29 |
"""Le gardien derriere le transport pose par conftest."""
|
| 30 |
return module.transport_http.handler.__self__
|
| 31 |
|
| 32 |
|
| 33 |
-
@pytest.mark.skipif(os.environ.get("JURIX_E2E") == "1", reason="JURIX_E2E garde les vraies cles")
|
| 34 |
@pytest.mark.parametrize("cle", ["GEMINI_API_KEY", "GROQ_API_KEY", "MISTRAL_API_KEY"])
|
| 35 |
def test_les_cles_sont_factices(cle):
|
| 36 |
assert getattr(settings, cle) == CLE_FACTICE
|
|
|
|
| 24 |
from app.services.mistral_service import MistralService
|
| 25 |
from tests.conftest import CLE_FACTICE
|
| 26 |
|
| 27 |
+
# JURIX_E2E=1 ouvre tout, vraies cles comprises : il n'y a alors aucune garde
|
| 28 |
+
# a verifier, et ces tests echouaient sur un transport absent.
|
| 29 |
+
pytestmark = pytest.mark.skipif(
|
| 30 |
+
os.environ.get("JURIX_E2E") == "1", reason="JURIX_E2E : aucune garde posee"
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
|
| 34 |
def _garde(module):
|
| 35 |
"""Le gardien derriere le transport pose par conftest."""
|
| 36 |
return module.transport_http.handler.__self__
|
| 37 |
|
| 38 |
|
|
|
|
| 39 |
@pytest.mark.parametrize("cle", ["GEMINI_API_KEY", "GROQ_API_KEY", "MISTRAL_API_KEY"])
|
| 40 |
def test_les_cles_sont_factices(cle):
|
| 41 |
assert getattr(settings, cle) == CLE_FACTICE
|
|
@@ -387,15 +387,21 @@ class TestEchecsDuModele:
|
|
| 387 |
with pytest.raises(ExplanationError):
|
| 388 |
await service.explain(loi.id, "2", "fr")
|
| 389 |
|
| 390 |
-
|
| 391 |
-
""
|
| 392 |
-
|
| 393 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
llm.generate.return_value = {
|
| 395 |
"response": "**En clair**\n\nCet article impose", "tronquee": True, "fin": "MAX_TOKENS",
|
| 396 |
}
|
| 397 |
service = ExplanationService(db_session, llm=llm)
|
| 398 |
|
| 399 |
-
reponse = await service.explain(loi.id, "2",
|
| 400 |
|
| 401 |
-
assert reponse.explanation.endswith(
|
|
|
|
| 387 |
with pytest.raises(ExplanationError):
|
| 388 |
await service.explain(loi.id, "2", "fr")
|
| 389 |
|
| 390 |
+
@pytest.mark.parametrize("langue, mention", [
|
| 391 |
+
("fr", "*(Réponse interrompue : longueur maximale atteinte.)*"),
|
| 392 |
+
("en", "*(Answer cut short: maximum length reached.)*"),
|
| 393 |
+
])
|
| 394 |
+
async def test_explication_coupee_le_dit(self, db_session, loi, llm, langue, mention):
|
| 395 |
+
"""
|
| 396 |
+
Sa derniere phrase, inachevee, passerait pour la fin du raisonnement.
|
| 397 |
+
Dans la langue demandee, et sans « posez une question plus precise » :
|
| 398 |
+
il n'y a pas de question derriere le bouton « Expliquer ».
|
| 399 |
+
"""
|
| 400 |
llm.generate.return_value = {
|
| 401 |
"response": "**En clair**\n\nCet article impose", "tronquee": True, "fin": "MAX_TOKENS",
|
| 402 |
}
|
| 403 |
service = ExplanationService(db_session, llm=llm)
|
| 404 |
|
| 405 |
+
reponse = await service.explain(loi.id, "2", langue)
|
| 406 |
|
| 407 |
+
assert reponse.explanation.endswith(mention)
|
|
@@ -13,6 +13,7 @@ import json
|
|
| 13 |
import httpx
|
| 14 |
import pytest
|
| 15 |
|
|
|
|
| 16 |
from app.core.limiteur import Limiteur
|
| 17 |
from app.services import groq_service
|
| 18 |
from app.services.groq_service import (
|
|
@@ -92,6 +93,8 @@ def horloge():
|
|
| 92 |
@pytest.fixture
|
| 93 |
def groq(monkeypatch, horloge):
|
| 94 |
"""Un service sur faux transport, avec des limiteurs sur horloge factice."""
|
|
|
|
|
|
|
| 95 |
limiteurs = {}
|
| 96 |
|
| 97 |
def limiteur_pour(modele):
|
|
@@ -261,6 +264,23 @@ class TestLimiteur:
|
|
| 261 |
assert erreur.value.retry_after == pytest.approx(150)
|
| 262 |
assert len(faux.requetes) == 1
|
| 263 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
async def test_usage_reel_corrige_la_reservation(self, groq):
|
| 265 |
"""L'estimation (entree + max_jetons) est remplacee par l'usage reel."""
|
| 266 |
service, faux = groq(ok({"intention": "juridique"}, jetons=200))
|
|
|
|
| 13 |
import httpx
|
| 14 |
import pytest
|
| 15 |
|
| 16 |
+
from app.core.config import settings
|
| 17 |
from app.core.limiteur import Limiteur
|
| 18 |
from app.services import groq_service
|
| 19 |
from app.services.groq_service import (
|
|
|
|
| 93 |
@pytest.fixture
|
| 94 |
def groq(monkeypatch, horloge):
|
| 95 |
"""Un service sur faux transport, avec des limiteurs sur horloge factice."""
|
| 96 |
+
# Lu a chaque appel : un .env qui le changerait changerait ces tests.
|
| 97 |
+
monkeypatch.setattr(settings, "GROQ_ATTENTE_MAX_429_S", 120.0)
|
| 98 |
limiteurs = {}
|
| 99 |
|
| 100 |
def limiteur_pour(modele):
|
|
|
|
| 264 |
assert erreur.value.retry_after == pytest.approx(150)
|
| 265 |
assert len(faux.requetes) == 1
|
| 266 |
|
| 267 |
+
async def test_le_recalage_voit_les_autres_processus(self, groq):
|
| 268 |
+
"""
|
| 269 |
+
Groq annonce 7 000 jetons restants sur 8 000 ; cet appel en a consomme
|
| 270 |
+
200 (estime a bien plus). Les 800 autres viennent d'un autre processus :
|
| 271 |
+
la fenetre doit totaliser ce que Groq compte. Recaler AVANT de corriger
|
| 272 |
+
laissait l'estimation masquer cette consommation.
|
| 273 |
+
"""
|
| 274 |
+
service, faux = groq(ok(
|
| 275 |
+
{"intention": "juridique"}, jetons=200,
|
| 276 |
+
entetes={"x-ratelimit-remaining-tokens": "7000"},
|
| 277 |
+
))
|
| 278 |
+
lim = service.limiteur(QWEN)
|
| 279 |
+
|
| 280 |
+
await _completer(service, max_jetons=3000)
|
| 281 |
+
|
| 282 |
+
assert lim._jetons_minute.total(lim.maintenant()) == 1000
|
| 283 |
+
|
| 284 |
async def test_usage_reel_corrige_la_reservation(self, groq):
|
| 285 |
"""L'estimation (entree + max_jetons) est remplacee par l'usage reel."""
|
| 286 |
service, faux = groq(ok({"intention": "juridique"}, jetons=200))
|
|
@@ -67,6 +67,7 @@ def groq(monkeypatch):
|
|
| 67 |
"""
|
| 68 |
monkeypatch.setattr(settings, "INTENT_CLASSIFIER", "groq")
|
| 69 |
monkeypatch.setattr(settings, "GROQ_API_KEY", "cle-test")
|
|
|
|
| 70 |
intent_classifier.cache_des_verdicts.vider()
|
| 71 |
horloge = Horloge()
|
| 72 |
limiteur = Limiteur(
|
|
|
|
| 67 |
"""
|
| 68 |
monkeypatch.setattr(settings, "INTENT_CLASSIFIER", "groq")
|
| 69 |
monkeypatch.setattr(settings, "GROQ_API_KEY", "cle-test")
|
| 70 |
+
monkeypatch.setattr(settings, "GROQ_ATTENTE_MAX_429_S", 120.0)
|
| 71 |
intent_classifier.cache_des_verdicts.vider()
|
| 72 |
horloge = Horloge()
|
| 73 |
limiteur = Limiteur(
|
|
@@ -88,3 +88,29 @@ def test_quota_et_saturation_sont_aussi_des_erreurs_de_generation(erreur):
|
|
| 88 |
"""
|
| 89 |
assert issubclass(erreur, ERREURS_LLM)
|
| 90 |
assert issubclass(erreur, ERREURS_QUOTA + ERREURS_SATURATION)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
"""
|
| 89 |
assert issubclass(erreur, ERREURS_LLM)
|
| 90 |
assert issubclass(erreur, ERREURS_QUOTA + ERREURS_SATURATION)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@pytest.mark.parametrize("fin, attendu", [
|
| 94 |
+
("STOP", ""),
|
| 95 |
+
("UNKNOWN", ""),
|
| 96 |
+
("MAX_TOKENS", "longueur maximale atteinte"),
|
| 97 |
+
("INTERROMPUE", "connexion avec le modèle a été coupée"),
|
| 98 |
+
# Une fin « error » du fournisseur n'est pas une affaire de longueur :
|
| 99 |
+
# « posez une question plus precise » serait le mauvais remede.
|
| 100 |
+
("ERROR", "connexion avec le modèle a été coupée"),
|
| 101 |
+
])
|
| 102 |
+
def test_mention_selon_la_fin(fin, attendu):
|
| 103 |
+
from app.services.llm import mention_de_fin
|
| 104 |
+
|
| 105 |
+
mention = mention_de_fin(fin)
|
| 106 |
+
|
| 107 |
+
assert (attendu in mention) if attendu else mention == ""
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def test_mention_dans_la_langue_de_la_reponse():
|
| 111 |
+
from app.services.llm import mention_de_fin
|
| 112 |
+
|
| 113 |
+
assert "maximum length reached" in mention_de_fin("MAX_TOKENS", "en")
|
| 114 |
+
assert "Ask again" in mention_de_fin("ERROR", "en")
|
| 115 |
+
# Langue inconnue : le francais, jamais une mention vide.
|
| 116 |
+
assert "longueur maximale" in mention_de_fin("MAX_TOKENS", "de")
|
|
@@ -16,6 +16,7 @@ Usage:
|
|
| 16 |
pytest tests/test_services/test_mistral_service.py -m mistral_live
|
| 17 |
"""
|
| 18 |
|
|
|
|
| 19 |
import json
|
| 20 |
|
| 21 |
import httpx
|
|
@@ -349,6 +350,45 @@ class TestSaturation:
|
|
| 349 |
assert (await service.generate("p"))["response"] == "Oui."
|
| 350 |
assert faux.modeles() == [SECOURS]
|
| 351 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
async def test_sans_secours(self, mistral, monkeypatch):
|
| 353 |
monkeypatch.setattr(settings, "MISTRAL_MODEL_SECOURS", "")
|
| 354 |
service, faux = mistral(ok("Oui."))
|
|
@@ -437,12 +477,14 @@ class TestFlux:
|
|
| 437 |
await _tout_lire(service)
|
| 438 |
|
| 439 |
async def test_429_avant_le_premier_octet(self, mistral, horloge):
|
| 440 |
-
|
|
|
|
|
|
|
| 441 |
|
| 442 |
morceaux, fins = await _tout_lire(service)
|
| 443 |
|
| 444 |
assert morceaux == ["Oui."]
|
| 445 |
-
assert horloge.sommeils == [
|
| 446 |
|
| 447 |
async def test_secours_en_flux(self, mistral):
|
| 448 |
trop = refus(429, "Rate limit", {"retry-after": "6"})
|
|
|
|
| 16 |
pytest tests/test_services/test_mistral_service.py -m mistral_live
|
| 17 |
"""
|
| 18 |
|
| 19 |
+
import asyncio
|
| 20 |
import json
|
| 21 |
|
| 22 |
import httpx
|
|
|
|
| 350 |
assert (await service.generate("p"))["response"] == "Oui."
|
| 351 |
assert faux.modeles() == [SECOURS]
|
| 352 |
|
| 353 |
+
async def test_un_long_retry_after_est_retenu_meme_en_abandonnant(self, mistral):
|
| 354 |
+
"""
|
| 355 |
+
30 s depassent le budget : l'appel passe au secours. Le delai doit
|
| 356 |
+
rester inscrit, sinon l'appel suivant retourne frapper le modele
|
| 357 |
+
sanctionne.
|
| 358 |
+
"""
|
| 359 |
+
service, faux = mistral(
|
| 360 |
+
refus(429, "Rate limit", {"retry-after": "30"}),
|
| 361 |
+
ok("Oui.", modele=SECOURS),
|
| 362 |
+
ok("Encore.", modele=SECOURS),
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
await service.generate("p")
|
| 366 |
+
await service.generate("p")
|
| 367 |
+
|
| 368 |
+
assert faux.modeles() == [PRINCIPAL, SECOURS, SECOURS]
|
| 369 |
+
|
| 370 |
+
async def test_toutes_les_places_prises_va_au_secours(self, mistral, monkeypatch):
|
| 371 |
+
"""L'attente d'une place etait sans limite : ni secours, ni 503."""
|
| 372 |
+
monkeypatch.setattr(settings, "MISTRAL_ATTENTE_MAX_S", 0.05)
|
| 373 |
+
service, faux = mistral(ok("Oui.", modele=SECOURS))
|
| 374 |
+
principal = service.limiteur(PRINCIPAL)
|
| 375 |
+
principal.concurrence = 1
|
| 376 |
+
liberer = asyncio.Event()
|
| 377 |
+
|
| 378 |
+
async def occuper():
|
| 379 |
+
async with principal.creneau():
|
| 380 |
+
await liberer.wait()
|
| 381 |
+
|
| 382 |
+
occupant = asyncio.create_task(occuper())
|
| 383 |
+
await asyncio.sleep(0)
|
| 384 |
+
|
| 385 |
+
resultat = await service.generate("p")
|
| 386 |
+
|
| 387 |
+
assert resultat["response"] == "Oui."
|
| 388 |
+
assert faux.modeles() == [SECOURS]
|
| 389 |
+
liberer.set()
|
| 390 |
+
await occupant
|
| 391 |
+
|
| 392 |
async def test_sans_secours(self, mistral, monkeypatch):
|
| 393 |
monkeypatch.setattr(settings, "MISTRAL_MODEL_SECOURS", "")
|
| 394 |
service, faux = mistral(ok("Oui."))
|
|
|
|
| 477 |
await _tout_lire(service)
|
| 478 |
|
| 479 |
async def test_429_avant_le_premier_octet(self, mistral, horloge):
|
| 480 |
+
# 5 s, et non 2 : l'espacement du limiteur (2 s) masquerait un
|
| 481 |
+
# Retry-After ignore.
|
| 482 |
+
service, faux = mistral(refus(429, "Rate limit", {"retry-after": "5"}), flux("Oui."))
|
| 483 |
|
| 484 |
morceaux, fins = await _tout_lire(service)
|
| 485 |
|
| 486 |
assert morceaux == ["Oui."]
|
| 487 |
+
assert horloge.sommeils == [5.0]
|
| 488 |
|
| 489 |
async def test_secours_en_flux(self, mistral):
|
| 490 |
trop = refus(429, "Rate limit", {"retry-after": "6"})
|
|
@@ -977,6 +977,21 @@ class TestFluxAligneSurAsk:
|
|
| 977 |
texte = "".join(e["chunk"] for e in evenements)
|
| 978 |
assert texte.endswith(MENTION_REPONSE_INTERROMPUE)
|
| 979 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 980 |
@pytest.mark.asyncio
|
| 981 |
async def test_reponse_complete_sans_mention(
|
| 982 |
self, rag_service, sample_rag_request, mock_search_results, mock_db_session
|
|
|
|
| 977 |
texte = "".join(e["chunk"] for e in evenements)
|
| 978 |
assert texte.endswith(MENTION_REPONSE_INTERROMPUE)
|
| 979 |
|
| 980 |
+
@pytest.mark.asyncio
|
| 981 |
+
async def test_mention_dans_la_langue_de_la_question(
|
| 982 |
+
self, rag_service, sample_rag_request, mock_search_results, mock_db_session
|
| 983 |
+
):
|
| 984 |
+
"""Une reponse en anglais ne se termine pas par une mention en francais."""
|
| 985 |
+
requete = sample_rag_request.model_copy(update={"language": "en"})
|
| 986 |
+
self._preparer(rag_service, mock_db_session, mock_search_results,
|
| 987 |
+
["A soldier who abuses "], raison="MAX_TOKENS")
|
| 988 |
+
|
| 989 |
+
evenements = await self._evenements(rag_service, requete)
|
| 990 |
+
|
| 991 |
+
texte = "".join(e["chunk"] for e in evenements)
|
| 992 |
+
assert texte.endswith("*(Answer cut short: maximum length reached. "
|
| 993 |
+
"Ask a more specific question to get the rest.)*")
|
| 994 |
+
|
| 995 |
@pytest.mark.asyncio
|
| 996 |
async def test_reponse_complete_sans_mention(
|
| 997 |
self, rag_service, sample_rag_request, mock_search_results, mock_db_session
|