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")