File size: 5,694 Bytes
21d412f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c84fadf
21d412f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c84fadf
 
21d412f
 
 
 
 
 
 
 
c84fadf
 
21d412f
 
 
 
 
 
 
 
 
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
"""
Mesure le classement d'intention par Groq sur le jeu etiquete.

tests/fixtures/intent_eval.json porte 122 messages etiquetes (juridique,
smalltalk, meta, hors_sujet), dont des questions de suivi (champ
`precedente`). Le script les passe par `classify_intent`, en mode groq, et
compare.

UNE EXIGENCE, ET SA RAISON. Le rappel « juridique » doit atteindre 0,98 : une
question de droit classee en conversation recoit une reponse SANS SOURCE, et
l'utilisateur n'a aucun moyen de s'en apercevoir. L'erreur inverse (un
bonjour envoye a la recherche) ne coute qu'un paragraphe inutile.

COUT. Chaque message est une requete Groq (~330 jetons) sur le quota du chat :
--limite borne la depense (60 messages : ~20 000 jetons, 10 % du quota du
jour). Les messages sont espaces (--pause) : sans cela, le limiteur ferait
attendre plus de 0,5 s, et le classement rendrait « juridique » par defaut —
ce qui mesurerait le limiteur, pas le modele. Les replis (`defaut-groq-*`)
sont comptes a part.

Usage:
    python -m scripts.eval.evaluer_intention --limite 60
    python -m scripts.eval.evaluer_intention --depuis 60 --limite 62   # le reste du jeu
"""

import argparse
import asyncio
import json
import sys
from collections import Counter, defaultdict
from pathlib import Path
from typing import Dict, List, Optional, Sequence

RACINE = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(RACINE))

from app.core.config import settings
from app.services.intent_classifier import INTENTS, cache_des_verdicts, classify_intent

JEU = RACINE / "tests" / "fixtures" / "intent_eval.json"
RAPPEL_JURIDIQUE_EXIGE = 0.98


class _Message:
    def __init__(self, role: str, content: str):
        self.role = role
        self.content = content


def echantillon(messages: List[dict], limite: Optional[int]) -> List[dict]:
    """
    Les `limite` premiers messages, en alternant les intentions : un jeu
    tronque garde ainsi des exemples de chaque categorie.
    """
    par_intention: Dict[str, List[dict]] = defaultdict(list)
    for message in messages:
        par_intention[message["intention"]].append(message)
    choisis: List[dict] = []
    while any(par_intention.values()) and (limite is None or len(choisis) < limite):
        for intention in INTENTS:
            if par_intention[intention] and (limite is None or len(choisis) < limite):
                choisis.append(par_intention[intention].pop(0))
    return choisis


async def evaluer(messages: Sequence[dict], pause: float) -> dict:
    cache_des_verdicts.vider()
    confusion: Counter = Counter()
    replis: Counter = Counter()
    erreurs = []
    for position, cas in enumerate(messages, start=1):
        historique = [_Message("user", cas["precedente"])] if cas.get("precedente") else None
        verdict = await classify_intent(cas["message"], history=historique)
        if verdict.rule.startswith("defaut"):
            replis[verdict.rule] += 1
        confusion[(cas["intention"], verdict.intent)] += 1
        if verdict.intent != cas["intention"]:
            erreurs.append((cas["message"], cas["intention"], verdict.intent, verdict.rule))
        print(f"[{position}/{len(messages)}] {cas['intention']:>10} -> {verdict.intent:<10} "
              f"({verdict.rule}) {cas['message'][:60]}", flush=True)
        if position < len(messages):
            await asyncio.sleep(pause)
    return {"confusion": confusion, "replis": replis, "erreurs": erreurs, "total": len(messages)}


def rapport(resultat: dict) -> float:
    confusion, total = resultat["confusion"], resultat["total"]
    justes = sum(n for (attendu, rendu), n in confusion.items() if attendu == rendu)
    juridiques = sum(n for (attendu, _), n in confusion.items() if attendu == "juridique")
    rappel = (confusion[("juridique", "juridique")] / juridiques) if juridiques else 1.0

    print("\nMatrice (lignes : attendu, colonnes : rendu)")
    print(f"{'':>11}" + "".join(f"{i:>11}" for i in INTENTS))
    for attendu in INTENTS:
        print(f"{attendu:>11}" + "".join(f"{confusion[(attendu, r)]:>11}" for r in INTENTS))
    print(f"\nJustes : {justes}/{total} ({justes / total:.1%})")
    print(f"Rappel juridique : {rappel:.3f} (exige : {RAPPEL_JURIDIQUE_EXIGE})")
    if resultat["replis"]:
        print(f"Replis par defaut (infrastructure, pas le modele) : {dict(resultat['replis'])}")
    for message, attendu, rendu, regle in resultat["erreurs"]:
        print(f"  ERREUR {attendu} -> {rendu} ({regle}) : {message}")
    return rappel


def main(argv: Optional[Sequence[str]] = None) -> int:
    parseur = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parseur.add_argument("--limite", type=int, default=60, help="Messages evalues (defaut 60)")
    parseur.add_argument("--depuis", type=int, default=0,
                         help="Saute les N premiers messages de l'echantillon (une seconde passe)")
    parseur.add_argument("--pause", type=float, default=3.0,
                         help="Secondes entre deux messages (defaut 3 : sous 30 req/min et 7 000 jetons/min)")
    args = parseur.parse_args(argv)

    if not settings.GROQ_API_KEY:
        print("GROQ_API_KEY absente du .env : rien a mesurer.")
        return 2
    settings.INTENT_CLASSIFIER = "groq"
    tous = echantillon(json.loads(JEU.read_text(encoding="utf-8"))["messages"], None)
    messages = tous[args.depuis: args.depuis + args.limite]
    print(f"{len(messages)} messages, modele {settings.GROQ_MODEL}")

    resultat = asyncio.run(evaluer(messages, args.pause))
    rappel = rapport(resultat)
    return 0 if rappel >= RAPPEL_JURIDIQUE_EXIGE else 1


if __name__ == "__main__":
    sys.exit(main())