Spaces:
Runtime error
Runtime error
File size: 10,614 Bytes
153dd7c a9c1b19 153dd7c a9c1b19 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 | """
Tests du routage d'intention.
Le modele est TOUJOURS double : on teste ce que le code fait de la reponse du
modele, jamais le jugement du modele lui-meme. La qualite du classement reel se
verifie a la main avec une vraie cle (voir la procedure dans le plan), pas ici.
Author: JuriX Team
"""
import asyncio
import pytest
from app.services.gemini_service import GeminiOverloadedError, GeminiQuotaError
from app.services.intent_classifier import (
INTENT_JURIDIQUE,
_parse_intent,
classify_intent,
)
class _LLM:
"""Doublure : rend ce qu'on lui dit, leve ce qu'on lui dit, tarde si on veut."""
def __init__(self, payload=None, raises=None, delay=0.0):
self.payload = payload
self.raises = raises
self.delay = delay
self.calls = []
async def generate(self, **kwargs):
self.calls.append(kwargs)
if self.delay:
await asyncio.sleep(self.delay)
if self.raises:
raise self.raises
return {"response": self.payload}
def _verdict(intention, confiance=0.95):
return '{"intention": "%s", "confiance": %s}' % (intention, confiance)
class _Message:
"""Message d'historique minimal, tel que `format_conversation_history` le lit."""
def __init__(self, role, content):
self.role = role
self.content = content
# ==================== LES QUATRE CATEGORIES ====================
@pytest.mark.asyncio
@pytest.mark.parametrize(
"question", ["comment vas tu ?", "tu as passe une bonne journee ?", "ca roule pour toi ?"]
)
async def test_bavardage_est_smalltalk(question):
"""
Des messages de conversation courante que le court-circuit lexical ne
reconnait PAS : ils passent donc bien par le modele.
"""
llm = _LLM(_verdict("smalltalk"))
resultat = await classify_intent(question, llm=llm)
assert resultat.intent == "smalltalk"
assert resultat.rule == "llm"
@pytest.mark.asyncio
async def test_question_sur_le_produit_est_meta():
resultat = await classify_intent("qui es-tu ?", llm=_LLM(_verdict("meta")))
assert resultat.intent == "meta"
@pytest.mark.asyncio
async def test_question_etrangere_est_hors_sujet():
resultat = await classify_intent(
"ecris-moi un poeme sur la pluie", llm=_LLM(_verdict("hors_sujet"))
)
assert resultat.intent == "hors_sujet"
@pytest.mark.asyncio
async def test_question_de_droit_est_juridique():
resultat = await classify_intent(
"quelles sont les conditions du permis de recherche ?",
llm=_LLM(_verdict("juridique")),
)
assert resultat.intent == INTENT_JURIDIQUE
assert resultat.rule == "llm"
# ==================== CE QUI PART AU MODELE ====================
@pytest.mark.asyncio
async def test_l_historique_est_transmis_au_modele():
"""
On teste la TRANSMISSION, pas le verdict : le modele est double, son
jugement ne prouverait rien. Ce qui doit etre garanti ici, c'est que « et
l'article 12 ? » arrive au classificateur avec de quoi le comprendre.
"""
llm = _LLM(_verdict("juridique"))
historique = [
_Message("user", "quelles sont les conditions du permis minier ?"),
_Message("assistant", "Les conditions figurent au Code Minier."),
]
await classify_intent("et pour une SARL ?", llm=llm, history=historique)
assert "permis minier" in llm.calls[0]["prompt"]
@pytest.mark.asyncio
async def test_sortie_structuree_demandee():
llm = _LLM(_verdict("smalltalk"))
await classify_intent("comment vas tu ?", llm=llm)
assert llm.calls[0]["response_mime_type"] == "application/json"
assert "intention" in llm.calls[0]["response_schema"]["properties"]
@pytest.mark.asyncio
async def test_temperature_nulle():
llm = _LLM(_verdict("smalltalk"))
await classify_intent("comment vas tu ?", llm=llm)
assert llm.calls[0]["temperature"] == 0.0
@pytest.mark.asyncio
async def test_systeme_neutre_impose():
"""
Sans `system`, `GeminiService.generate` retombe sur SYSTEM_INSTRUCTION, qui
ordonne de citer des articles et de terminer par « Sources: ». Sur une
tache de classement, elle produirait de la prose au lieu du JSON demande.
"""
llm = _LLM(_verdict("smalltalk"))
await classify_intent("comment vas tu ?", llm=llm)
assert "classificateur" in llm.calls[0]["system"].lower()
def test_delai_superieur_a_la_latence_mesuree():
"""
Verrouille la valeur contre un resserrage bien intentionne.
Mesure sur gemini-3-flash-preview, six classifications reelles : mediane
3,3 s, maximum 6,5 s. La valeur posee au depart, 3,0 s, faisait expirer
cinq appels sur six — et une expiration retombe sur « juridique » sans
erreur, sans journal, sans test rouge : le routeur cessait de router en
silence. Toute valeur sous le maximum observe reintroduit ce mode de
panne.
"""
from app.core.config import settings
assert settings.INTENT_TIMEOUT_S >= 7.0
@pytest.mark.asyncio
async def test_budget_de_jetons_suffisant_pour_un_modele_a_raisonnement():
"""
Verrouille le piege le plus couteux du lot. Un budget de quelques jetons
parait logique pour une sortie d'un seul mot, mais la reflexion du modele
est facturee AVANT la premiere ligne : la reponse revient vide, le
classificateur retombe sur « juridique », et le routeur ne route jamais —
sans qu'aucun test ne le voie, puisqu'ils doublent tous le modele.
"""
llm = _LLM(_verdict("smalltalk"))
await classify_intent("comment vas tu ?", llm=llm)
assert llm.calls[0]["max_tokens"] >= 512
# ==================== COURT-CIRCUIT ====================
@pytest.mark.asyncio
@pytest.mark.parametrize(
"question",
[
"que dit l'article 33 du Code Minier ?",
"explique moi l article 12",
"Article L 5 du code du travail",
],
)
async def test_numero_d_article_court_circuite_l_appel(question):
llm = _LLM(_verdict("smalltalk")) # le modele dirait le contraire : ignore
resultat = await classify_intent(question, llm=llm)
assert resultat.intent == INTENT_JURIDIQUE
assert resultat.rule == "court-circuit-article"
assert llm.calls == []
@pytest.mark.asyncio
@pytest.mark.parametrize(
"question", ["bonjour", "Bonjour !", "MERCI BEAUCOUP.", "bonne journée", " salut "]
)
async def test_salutation_pure_court_circuite_l_appel(question):
"""Le message le plus frequent d'une conversation ne coute plus aucun appel."""
llm = _LLM(_verdict("juridique")) # le modele dirait le contraire : ignore
resultat = await classify_intent(question, llm=llm)
assert resultat.intent == "smalltalk"
assert resultat.rule == "court-circuit-salutation"
assert llm.calls == []
@pytest.mark.asyncio
@pytest.mark.parametrize(
"question",
[
"bonjour, puis-je licencier un salarie malade ?",
"merci de me dire l age legal du travail",
"salut, ca va ? j ai une question sur mon bail",
],
)
async def test_la_politesse_en_ouverture_ne_court_circuite_pas(question):
"""
Le rapprochement porte sur le message ENTIER. Un filtre par sous-chaine
classerait ces trois-la en salutation, et c'est le pire echec possible :
une vraie question de droit repondue sans source.
"""
llm = _LLM(_verdict("juridique"))
resultat = await classify_intent(question, llm=llm)
assert resultat.rule == "llm"
assert llm.calls != []
@pytest.mark.asyncio
async def test_pas_de_court_circuit_symetrique_sur_la_politesse():
"""
« Bonjour, puis-je licencier un salarie malade ? » est une question de
droit. Un court-circuit lexical sur « bonjour » la classerait en
salutation : l'asymetrie du court-circuit est voulue.
"""
llm = _LLM(_verdict("juridique"))
resultat = await classify_intent(
"bonjour, puis-je licencier un salarie malade ?", llm=llm
)
assert resultat.intent == INTENT_JURIDIQUE
assert llm.calls != []
# ==================== LE REPLI ====================
@pytest.mark.asyncio
@pytest.mark.parametrize(
"payload",
["pas du json", "", None, '{"intention": "cuisine"}', '["une", "liste"]'],
)
async def test_verdict_inexploitable_replie_sur_juridique(payload):
resultat = await classify_intent("comment vas tu ?", llm=_LLM(payload))
assert resultat.intent == INTENT_JURIDIQUE
assert resultat.rule == "defaut-json"
@pytest.mark.asyncio
async def test_expiration_replie_sur_juridique():
resultat = await classify_intent(
"comment vas tu ?", llm=_LLM(_verdict("smalltalk"), delay=0.5), timeout=0.01
)
assert resultat.intent == INTENT_JURIDIQUE
assert resultat.rule == "defaut-timeout"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"erreur",
[
GeminiQuotaError("quota epuise"),
GeminiOverloadedError("503"),
RuntimeError("boom"),
],
)
async def test_aucune_exception_ne_sort(erreur):
"""
Une defaillance du routeur ne doit JAMAIS faire echouer la question de
l'utilisateur : le repli d'un composant optionnel, c'est son absence.
"""
resultat = await classify_intent("comment vas tu ?", llm=_LLM(raises=erreur))
assert resultat.intent == INTENT_JURIDIQUE
assert resultat.rule == "defaut-erreur"
@pytest.mark.asyncio
async def test_reponse_nue_sans_enveloppe_replie_sur_juridique():
"""Une doublure qui rend une chaine au lieu d'un dict ne doit pas lever."""
class _Nu:
async def generate(self, **kwargs):
return "juste du texte"
resultat = await classify_intent("comment vas tu ?", llm=_Nu())
assert resultat.intent == INTENT_JURIDIQUE
@pytest.mark.asyncio
async def test_desactive_par_reglage(monkeypatch):
from app.core.config import settings
monkeypatch.setattr(settings, "INTENT_ROUTING_ENABLED", False)
llm = _LLM(_verdict("smalltalk"))
resultat = await classify_intent("comment vas tu ?", llm=llm)
assert resultat.intent == INTENT_JURIDIQUE
assert resultat.rule == "desactive"
assert llm.calls == []
# ==================== LE LECTEUR DE VERDICT, ISOLE ====================
def test_parse_intent_borne_la_confiance():
assert _parse_intent('{"intention": "meta", "confiance": 5}')[1] == 1.0
assert _parse_intent('{"intention": "meta", "confiance": -2}')[1] == 0.0
def test_parse_intent_defaut_de_confiance():
assert _parse_intent('{"intention": "meta"}') == ("meta", 0.5)
def test_parse_intent_confiance_illisible():
assert _parse_intent('{"intention": "meta", "confiance": "beaucoup"}') == ("meta", 0.5)
|