rahim237cm Claude Opus 5.5 commited on
Commit
ecf0802
·
1 Parent(s): f508b99

fix(limiteur): attente bornee, 429 retenus, mentions dans la langue de la reponse

Browse files

Issus 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 CHANGED
@@ -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.append(entree)
 
 
 
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
- await semaphore.acquire()
 
 
 
 
 
 
 
 
390
  try:
391
- yield await self.attendre(jetons, attente_max, jetons_entree=jetons_entree)
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()
app/services/explanation_service.py CHANGED
@@ -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
- return texte + mention_de_reponse(reponse)
 
 
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)
app/services/groq_service.py CHANGED
@@ -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 ""
app/services/llm.py CHANGED
@@ -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
- # Ajoutee a une reponse que le modele n'a pas pu finir : sans elle, une phrase
43
- # coupee ressemble a une reponse complete.
44
- MENTION_REPONSE_TRONQUEE = (
45
- "\n\n*(Réponse interrompue : longueur maximale atteinte. "
46
- "Posez une question plus précise pour obtenir la suite.)*"
47
- )
48
- # Le flux s'est rompu en cours de reponse, et la suite demandee a echoue :
49
- # incomplete, mais pas a cause de sa longueur.
50
- MENTION_REPONSE_INTERROMPUE = (
51
- "\n\n*(Réponse interrompue : la connexion avec le modèle a été coupée. "
52
- "Reposez la question pour obtenir une réponse complète.)*"
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 == FIN_INTERROMPUE:
63
- return MENTION_REPONSE_INTERROMPUE
64
- if fin in FINS_INCOMPLETES:
65
- return MENTION_REPONSE_TRONQUEE
66
- return ""
 
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:
app/services/mistral_service.py CHANGED
@@ -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:
app/services/rag_service.py CHANGED
@@ -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)
tests/test_core/test_limiteur.py CHANGED
@@ -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
- """Partage entre deux boucles, un asyncio.Semaphore leverait RuntimeError."""
264
- lim = limiteur(horloge, concurrence=2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
 
266
- async def appel():
 
 
 
 
 
 
 
 
267
  async with lim.creneau():
268
- await asyncio.sleep(0)
 
 
 
 
 
 
 
269
 
270
- asyncio.run(appel())
271
- asyncio.run(appel())
 
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)
tests/test_qualite/test_garde_reseau.py CHANGED
@@ -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
tests/test_services/test_explanation_service.py CHANGED
@@ -387,15 +387,21 @@ class TestEchecsDuModele:
387
  with pytest.raises(ExplanationError):
388
  await service.explain(loi.id, "2", "fr")
389
 
390
- async def test_explication_coupee_le_dit(self, db_session, loi, llm):
391
- """Sa derniere phrase, inachevee, passerait pour la fin du raisonnement."""
392
- from app.services.llm import MENTION_REPONSE_TRONQUEE
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", "fr")
400
 
401
- assert reponse.explanation.endswith(MENTION_REPONSE_TRONQUEE)
 
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)
tests/test_services/test_groq_service.py CHANGED
@@ -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))
tests/test_services/test_intent_groq.py CHANGED
@@ -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(
tests/test_services/test_llm.py CHANGED
@@ -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")
tests/test_services/test_mistral_service.py CHANGED
@@ -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
- service, faux = mistral(refus(429, "Rate limit", {"retry-after": "2"}), flux("Oui."))
 
 
441
 
442
  morceaux, fins = await _tout_lire(service)
443
 
444
  assert morceaux == ["Oui."]
445
- assert horloge.sommeils == [2.0]
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"})
tests/test_services/test_rag_service.py CHANGED
@@ -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