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)