Spaces:
Runtime error
Runtime error
File size: 16,046 Bytes
153dd7c 21d412f 153dd7c 21d412f 153dd7c 21d412f 153dd7c 21d412f 153dd7c 21d412f 153dd7c 21d412f 153dd7c 21d412f c84fadf 52efadf 21d412f 52efadf 21d412f 52efadf 21d412f 52efadf 21d412f 52efadf 21d412f 52efadf 21d412f 52efadf 21d412f 52efadf 21d412f 52efadf 21d412f 52efadf 21d412f 52efadf 21d412f 52efadf 21d412f 52efadf 21d412f 52efadf 21d412f 52efadf 21d412f 52efadf 21d412f 52efadf 21d412f 52efadf 153dd7c 26dddaf 153dd7c 21d412f 153dd7c 21d412f 52efadf 21d412f 153dd7c 148f7c1 153dd7c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 | """
Aiguillage d'un message utilisateur : question de droit, ou conversation ?
POURQUOI CE MODULE EXISTE. `RAGService.ask()` envoyait TOUTE question a la
recherche hybride, sans exception. Une salutation remonte quand meme des
chunks — le plein texte et le vectoriel trouvent toujours quelque chose — si
bien que le garde-fou « aucun resultat » ne se declenchait jamais. Mesure sur
une vraie session : « comment vas tu ? » a produit un pave a rubriques
(« **Source legale** L'information demandee n'est pas presente dans les
documents fournis, notamment la Loi N°2023/014 portant Code Minier ») et un
bloc « Sources » citant un article du Code Minier. Le cout n'etait pas
qu'esthetique : chaque salutation payait un appel d'embedding Gemini, occupait
`embedding_cache` sept jours et laissait une ligne dans `search_events`.
CE MODULE NE CLASSE PAS DES DOCUMENTS. `legal_domain_classifier.py` range un
texte du corpus dans un domaine juridique a l'ingestion ; ici on lit la
question de l'utilisateur, ce qui n'a ni les memes entrees ni le meme verdict.
LA MECANIQUE EST CELLE DE `reranker.rerank_with_llm` : l'appel est borne par
`asyncio.wait_for`, et AUCUNE exception ne sort. Le repli d'un composant
optionnel, c'est l'absence du composant : « juridique ».
DEUX MODES (INTENT_CLASSIFIER). « groq », le defaut : consignes courtes,
schema strict, verdicts en cache, et « juridique » des que Groq ne peut pas
repondre dans les temps (quota, attente, panne). « llm » : le modele du chat,
passe en parametre. Le classement local par embeddings a ete retire.
Author: JuriX Team
"""
import asyncio
import json
import logging
import time
from collections import OrderedDict
from dataclasses import dataclass
from typing import Any, List, Optional, Tuple
from app.core.config import settings
from app.services.prompts import (
CLASSIFICATION_SYSTEM,
CONSIGNES_INTENTION,
ECHANGES_POUR_L_INTENTION,
LONGUEUR_ECHANGE_INTENTION,
construire_prompt_de_classification,
format_conversation_history,
message_d_intention,
)
from app.services.reranker import _article_number_in
from app.services.text_features import fold_accents
logger = logging.getLogger(__name__)
# Ordre explicite, jamais l'ordre d'insertion d'un dictionnaire.
INTENTS: Tuple[str, ...] = ("juridique", "smalltalk", "meta", "hors_sujet")
# Le seul qui emprunte le RAG. Les trois autres partagent le chemin
# conversationnel, avec un prompt different chacun.
INTENT_JURIDIQUE = INTENTS[0]
# Messages dont la TOTALITE est une formule de politesse. Le rapprochement se
# fait sur le message ENTIER normalise, jamais par sous-chaine : « bonjour »
# court-circuite, « bonjour, puis-je divorcer sans avocat ? » non — c'est
# precisement le piege qu'un filtre lexical naif tend, et il classerait une
# vraie question de droit en salutation.
#
# Le gain n'est pas theorique : sur le palier gratuit, chaque message coute
# desormais deux appels au modele, et « bonjour » ouvre une conversation sur
# deux. Ceux-la n'en coutent plus aucun.
_SALUTATIONS_EXACTES = frozenset({
"bonjour", "bonsoir", "salut", "coucou", "hello", "hi", "hey",
"merci", "merci beaucoup", "merci bien", "thanks", "thank you",
"au revoir", "bye", "a bientot", "bonne journee", "bonne soiree",
"ok", "d'accord", "tres bien", "parfait",
})
# Ponctuation et accents retires avant comparaison : « Bonjour ! », « bonjour »
# et « BONJOUR. » sont le meme message.
_PONCTUATION = str.maketrans("", "", "!?.,;:…\"'«»()")
def _est_une_salutation_pure(question: str) -> bool:
"""Le message ENTIER est-il une formule de politesse, et rien d'autre ?"""
from app.services.text_features import fold_accents
nettoye = fold_accents((question or "").strip().lower()).translate(_PONCTUATION)
return " ".join(nettoye.split()) in _SALUTATIONS_EXACTES
@dataclass(frozen=True)
class IntentResult:
"""
Verdict de routage.
`intent` vaut TOUJOURS l'un de INTENTS, jamais None : l'appelant n'a pas de
branche « je ne sais pas » a ecrire. `rule` nomme ce qui a decide, ce qui
rend chaque erreur diagnosticable dans les journaux sans rejouer la
requete — un `defaut-timeout` massif et un `defaut-json` massif appellent
des corrections opposees.
"""
intent: str
confidence: float
rule: str
# Schema impose a la sortie du modele. `gemini_service.generate` expose
# `response_schema` explicitement, precisement pour ce genre d'appel.
_INTENT_SCHEMA = {
"type": "object",
"properties": {
"intention": {"type": "string", "enum": list(INTENTS)},
"confiance": {"type": "number"},
},
"required": ["intention"],
}
def _parse_intent(raw: Any) -> Optional[Tuple[str, float]]:
"""
Lit le verdict du modele. Rend None sur tout ce qui n'est pas exploitable.
Fonction PURE : elle ne journalise pas et ne leve pas, ce qui la rend
testable sans doublure.
"""
if not raw or not isinstance(raw, str):
return None
try:
charge = json.loads(raw)
except (ValueError, TypeError):
return None
return _verdict_de(charge)
def _verdict_de(charge: Any) -> Optional[Tuple[str, float]]:
"""Le verdict d'un objet JSON deja decode, ou None. Pure, comme _parse_intent."""
if not isinstance(charge, dict):
return None
intention = charge.get("intention")
if intention not in INTENTS:
return None
try:
confiance = float(charge.get("confiance", 0.5))
except (TypeError, ValueError):
confiance = 0.5
return intention, max(0.0, min(1.0, confiance))
# ==================== CLASSEMENT PAR GROQ ====================
# Schema STRICT : la sortie ne peut etre que cet objet, rien d'autre.
_SCHEMA_GROQ = {
"type": "object",
"properties": {
"intention": {"type": "string", "enum": list(INTENTS)},
"confiance": {"type": "number"},
},
"required": ["intention", "confiance"],
"additionalProperties": False,
}
# Un verdict tient en une quinzaine de jetons, mais le modele en depense
# d'autres avant, invisibles, meme sans reflexion : mesure sur
# qwen3.8-27b, 128 jetons factures pour un verdict de 15. A 40, deux
# messages sur soixante revenaient VIDES (HTTP 400 json_validate_failed),
# donc en « juridique » par defaut. Le quota compte l'usage reel, pas ce
# plafond.
_JETONS_DE_SORTIE = 256
# Attente acceptee devant le limiteur. Au-dela, la question part tout de suite
# en « juridique » : faire patienter l'utilisateur pour savoir s'il dit
# bonjour couterait plus qu'une recherche inutile.
ATTENTE_MAX_INTENTION_S = 0.5
class _CacheDeVerdicts:
"""
Les derniers verdicts, par message et echanges precedents.
« bonjour », « merci » ou « que dit le code du travail ? » reviennent
souvent a l'identique : chacun coutait une requete sur les 1 000 du jour.
Taille et duree bornees ; seuls les verdicts du modele y entrent, jamais
un defaut.
"""
def __init__(self, taille: int = 2000, duree_s: float = 86_400.0, horloge=time.monotonic):
self.taille = taille
self.duree_s = duree_s
self._horloge = horloge
self._entrees: "OrderedDict[Tuple[str, str], Tuple[float, IntentResult]]" = OrderedDict()
def lire(self, cle: Tuple[str, str]) -> Optional[IntentResult]:
entree = self._entrees.get(cle)
if entree is None:
return None
if self._horloge() - entree[0] > self.duree_s:
del self._entrees[cle]
return None
self._entrees.move_to_end(cle)
return entree[1]
def ecrire(self, cle: Tuple[str, str], verdict: IntentResult) -> None:
self._entrees[cle] = (self._horloge(), verdict)
self._entrees.move_to_end(cle)
while len(self._entrees) > self.taille:
self._entrees.popitem(last=False)
def vider(self) -> None:
self._entrees.clear()
def __len__(self) -> int:
return len(self._entrees)
cache_des_verdicts = _CacheDeVerdicts()
def _normaliser(texte: str) -> str:
return " ".join(fold_accents((texte or "").lower()).split())
def _echanges(history: Optional[List[Any]]) -> List[Tuple[str, str]]:
"""Les derniers echanges (role, texte tronque), du plus ancien au plus recent."""
echanges = []
for message in (history or [])[-ECHANGES_POUR_L_INTENTION:]:
role = "utilisateur" if getattr(message, "role", "") == "user" else "assistant"
texte = " ".join((getattr(message, "content", "") or "").split())
echanges.append((role, texte[:LONGUEUR_ECHANGE_INTENTION]))
return echanges
async def _classer_par_groq(
question: str, history: Optional[List[Any]], timeout: Optional[float]
) -> IntentResult:
"""
Classement par Groq. Ne leve jamais : toute impossibilite rend
« juridique », avec une regle `defaut-groq-<raison>` qui dit laquelle.
"""
from app.services.groq_service import (
GroqLimiteError,
GroqQuotaError,
GroqReponseInvalideError,
get_groq_service,
)
if not settings.GROQ_API_KEY:
return IntentResult(INTENT_JURIDIQUE, 0.0, "defaut-groq-cle")
echanges = _echanges(history)
cle = (_normaliser(question), _normaliser(" | ".join(t for _, t in echanges)))
en_cache = cache_des_verdicts.lire(cle)
if en_cache is not None:
return IntentResult(en_cache.intent, en_cache.confidence, "groq-cache")
delai = timeout if timeout is not None else settings.GROQ_INTENTION_TIMEOUT_S
raison = None
try:
reponse = await asyncio.wait_for(
get_groq_service().completer_json(
systeme=CONSIGNES_INTENTION,
message=message_d_intention(question, echanges),
schema=_SCHEMA_GROQ,
nom_schema="intention",
max_jetons=_JETONS_DE_SORTIE,
attente_max=ATTENTE_MAX_INTENTION_S,
timeout=delai,
),
timeout=delai,
)
verdict = _verdict_de(reponse.donnees)
if verdict is None:
raison = "json"
except asyncio.TimeoutError:
raison = "timeout"
except GroqQuotaError:
raison = "quota"
except GroqLimiteError:
raison = "limite"
except GroqReponseInvalideError:
raison = "json"
except Exception as exc:
# Panne, cle refusee, schema refuse : tout est avale. Une defaillance
# du routeur ne doit jamais faire echouer la question.
logger.warning("🧭 Groq en echec (%r)", exc)
raison = "erreur"
if raison is not None:
logger.warning("🧭 Classement d'intention impossible (%s) : repli sur juridique", raison)
return IntentResult(INTENT_JURIDIQUE, 0.0, f"defaut-groq-{raison}")
intention, confiance = verdict
resultat = IntentResult(intention, confiance, "groq")
cache_des_verdicts.ecrire(cle, resultat)
return resultat
async def classify_intent(
question: str,
*,
llm: Optional[Any] = None,
history: Optional[List[Any]] = None,
timeout: Optional[float] = None,
max_tokens: Optional[int] = None,
) -> IntentResult:
"""
Classe un message. Ne leve jamais.
Args:
question: le message de l'utilisateur, tel qu'il l'a ecrit
llm: service de generation du mode « llm ». PARAMETRE et non
`get_llm_service()` en dur, meme raison que `rerank_with_llm` : un
test injecte une doublure, le harnais d'evaluation peut changer de
modele. Le mode « groq » ne s'en sert pas.
history: les derniers messages, pour les questions de suivi. « et
l'article 12 ? » n'est juridique que par ce qui precede.
Returns:
IntentResult, toujours. Sur echec : `juridique`, ce qui redonne le
comportement anterieur.
POURQUOI LE DEFAUT EST « juridique ». L'erreur n'est pas symetrique.
Classer une salutation en juridique coute un pave disgracieux — le bug
d'hier, donc une degradation vers l'etat anterieur. Classer « quelles sont
les conditions du permis de recherche ? » en conversation coute une reponse
INVENTEE et SANS SOURCE sur une question de droit, et l'utilisateur n'a
aucun moyen de s'en apercevoir : on lui a justement retire le bloc Sources
qui l'aurait alerte. S'y ajoute que les pannes sont correlees a la charge,
pas au contenu : un defaut conversationnel ferait basculer TOUTE la
plateforme en mode bavardage pendant une saturation du fournisseur.
"""
if not settings.INTENT_ROUTING_ENABLED:
return IntentResult(INTENT_JURIDIQUE, 1.0, "desactive")
# Court-circuit deterministe. « que dit l'article 33 du Code Minier ? » n'a
# pas besoin d'un modele pour etre reconnue comme juridique, et le
# re-ranking documente cette forme comme dominante sur ce produit. La regex
# est celle du re-ranking, pas une seconde : deux auraient diverge.
#
# Pas de court-circuit lexical symetrique cote salutation (une liste
# « bonjour / merci / salut ») : il classerait « Bonjour, puis-je
# divorcer ? » en smalltalk. L'asymetrie est voulue — on ne court-circuite
# que dans le sens qui degrade vers le comportement actuel.
if _article_number_in(question or ""):
return IntentResult(INTENT_JURIDIQUE, 1.0, "court-circuit-article")
# Symetrique, et sur une egalite STRICTE du message entier : c'est ce qui
# le rend sur. Voir _SALUTATIONS_EXACTES.
if _est_une_salutation_pure(question):
return IntentResult("smalltalk", 1.0, "court-circuit-salutation")
if settings.INTENT_CLASSIFIER == "groq":
return await _classer_par_groq(question, history, timeout)
# « llm » : le modele du chat, passe en parametre.
prompt = construire_prompt_de_classification(
question, format_conversation_history(history or [])
)
try:
reponse = await asyncio.wait_for(
llm.generate(
prompt=prompt,
# Systeme court et neutre, OBLIGATOIRE : `generate` retombe sur
# `GeminiService.SYSTEM_INSTRUCTION` quand `system` est absent,
# et cette instruction-la impose de citer des articles et de
# terminer par « Sources: ». Sur une tache de classification,
# elle produirait de la prose au lieu du JSON demande.
system=CLASSIFICATION_SYSTEM,
temperature=0.0,
max_tokens=max_tokens or settings.INTENT_MAX_TOKENS,
reflexion=settings.GEMINI_REFLEXION_CLASSIFICATION,
response_mime_type="application/json",
response_schema=_INTENT_SCHEMA,
),
timeout=timeout if timeout is not None else settings.INTENT_TIMEOUT_S,
)
except asyncio.TimeoutError:
logger.warning("🧭 Classification expiree : repli sur juridique")
return IntentResult(INTENT_JURIDIQUE, 0.0, "defaut-timeout")
except Exception as exc:
# Quota, saturation, panne reseau : tout est avale. Une defaillance du
# routeur ne doit jamais faire echouer la question de l'utilisateur.
logger.warning("🧭 Classification en echec (%s) : repli sur juridique", exc)
return IntentResult(INTENT_JURIDIQUE, 0.0, "defaut-erreur")
# Extraction TOTALE, jamais `reponse["response"]`. `generate` rend un dict,
# mais ce module promet de ne jamais lever : une doublure ou une future
# variante rendant une chaine nue ne doit pas transformer le routeur en
# panne. Tout ce qui n'est pas exploitable retombe sur le defaut.
brut = reponse.get("response") if isinstance(reponse, dict) else reponse
verdict = _parse_intent(brut)
if verdict is None:
logger.warning("🧭 Verdict illisible : repli sur juridique")
return IntentResult(INTENT_JURIDIQUE, 0.0, "defaut-json")
intention, confiance = verdict
return IntentResult(intention, confiance, "llm")
|