File size: 27,448 Bytes
f565440
 
 
 
 
 
 
 
 
 
 
 
 
 
84f70cd
 
 
 
f565440
 
 
 
 
b16c746
 
 
 
 
df97adf
 
 
 
84f70cd
df97adf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f565440
 
 
b16c746
 
df97adf
 
 
 
 
f565440
 
 
 
 
 
 
df97adf
f565440
 
 
 
 
 
 
 
 
 
 
df97adf
f565440
 
 
 
df97adf
f565440
 
 
 
b16c746
 
 
 
 
 
 
 
 
 
 
 
 
 
 
df97adf
 
b16c746
f565440
df97adf
 
 
 
f565440
 
 
 
 
 
 
 
 
84f70cd
f565440
 
 
84f70cd
 
 
f565440
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
df97adf
406ec90
 
 
 
 
df97adf
84f70cd
 
 
df97adf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84f70cd
 
 
 
df97adf
 
 
 
 
 
 
 
 
 
 
 
 
 
84f70cd
 
 
 
 
df97adf
84f70cd
 
df97adf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84f70cd
 
 
 
 
 
 
 
 
df97adf
 
 
 
 
 
 
f565440
 
df97adf
b16c746
f565440
 
 
 
df97adf
 
f565440
 
 
 
df97adf
 
f565440
 
df97adf
f565440
 
 
 
 
 
 
df97adf
 
 
 
 
 
f565440
df97adf
 
 
 
b16c746
df97adf
 
f565440
df97adf
 
f565440
b16c746
f565440
 
b16c746
f565440
 
 
 
 
 
 
 
 
 
 
 
b16c746
f565440
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
df97adf
f565440
b16c746
 
 
 
 
 
 
 
 
 
f565440
 
 
 
 
df97adf
 
 
 
 
 
 
b16c746
f565440
 
 
 
 
df97adf
f565440
 
 
df97adf
 
 
 
 
 
f565440
 
b16c746
 
 
f565440
 
 
 
 
b16c746
f565440
b16c746
 
 
f565440
 
 
 
 
 
84f70cd
 
 
 
 
f565440
 
 
 
 
df97adf
 
 
 
 
 
 
84f70cd
 
 
 
 
 
 
 
df97adf
f565440
84f70cd
 
f565440
b16c746
 
 
df97adf
 
 
 
84f70cd
 
df97adf
 
 
 
b16c746
f565440
df97adf
 
f565440
df97adf
 
 
 
 
 
 
 
 
 
84f70cd
 
 
 
 
df97adf
 
f565440
 
 
 
 
84f70cd
 
 
f565440
 
 
 
 
df97adf
f565440
 
b16c746
f565440
 
df97adf
 
f565440
 
 
 
 
 
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
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
"""
Compare ministral-14b et ministral-8b sur le chat et sur la comparaison.

PROTOCOLE, FIXE AVANT LA MESURE :
- 20 questions juridiques, prises dans le jeu d'evaluation RELU (difficulte
  « paraphrase »), et 4 comparaisons de regimes ;
- le MEME contexte pour les deux modeles : la recherche est faite une fois,
  et les extraits sont ecrits avec les reponses. Aucun appel a Groq : pas de
  classement d'intention, la question part droit au chat. Le modele de
  secours est coupe, pour que chaque reponse vienne du modele mesure ;
- mesures : premier morceau, duree, jetons, fin ; articles cites presents
  dans les extraits ; pour les comparaisons, JSON lisible et lignes remplies ;
- les reponses sont ecrites ANONYMISEES (X et Y, tires au sort par question)
  dans un fichier de relecture a l'aveugle des ajouts non sources ;
- budget : 48 taches (24 questions ou comparaisons, 2 modeles), soit 96
  appels au pire : une reponse coupee demande une suite, une grille illisible
  est redemandee. Le bras facultatif (temperature 0,3 contre 0,7) n'est pas
  lance : il depasserait ce budget.

REGLE DE DECISION, ECRITE AVANT : on garde le 14b, sauf si le 8b egale la
justesse des citations (a 2 points pres) sans ajouter plus de contenu non
source. Le 8b reste le modele de secours dans tous les cas.

VARIANTES. `--modeles` et `--consignes` font varier le modele et le prompt
systeme du chat : « actuelles » (celui de la production) ou « renforcees »
(des regles de fond en tete, pour un petit modele qui s'ecarte des extraits).
Les comparaisons de regimes passent une fois par modele, avec leur prompt.

PROFILS (--personas). Meme protocole, un seul modele : chaque question est
posee sous chaque profil, avec le MEME contexte. --temoin rejoue le premier
profil une seconde fois : l'ecart entre deux tirages du meme prompt, a
temperature 0,7, est le plancher de bruit auquel comparer l'ecart entre
profils. --budget borne le nombre d'appels, au pire cas, AVANT le premier.

REGLE DE DECISION DES PROFILS, ECRITE AVANT LA MESURE. Les nouveaux prompts
sont gardes si, sur la mesure APRES :
1. justesse des citations cumulee >= 0,94, et aucun profil sous 0,90 parmi
   ceux qui citent au moins 8 fois (reference : 0,962 sur 26 citations,
   ministral-14b-2512, prompts actuels, 2026-10-07) ;
2. forme_relue >= 0,90 pour chaque profil (les sources affichees sous la
   reponse dependent de CITATION_REGEX) ;
3. 0 erreur, 0 reponse incomplete, duree mediane <= 1,5 fois celle de citoyen ;
4. similarite mediane entre profils inferieure a celle du temoin, et plus
   basse que sur la mesure AVANT ;
5. l'avocat ne renvoie jamais vers un avocat ; aucun profil ne cite de
   jurisprudence ;
6. relecture a l'aveugle de 15 reponses (5 profils x 3 questions) : profil
   devine au moins 9 fois sur 15 (le hasard en donne 3).

SUGGESTIONS (--questions-libres). Avec --dry-run, chaque question passe par
la recherche du chat, sans aucun appel au modele : nombre d'extraits et trois
meilleurs textes, pour juger qu'une suggestion du front a une reponse dans le
corpus. Sans --dry-run, ces questions remplacent le jeu relu.

Usage:
    python -m scripts.eval.comparer_mistral --dry-run      # contextes seuls, aucun appel
    python -m scripts.eval.comparer_mistral
    python -m scripts.eval.comparer_mistral --modeles ministral-3b-2512 \
        --consignes actuelles renforcees --label 3b
    python -m scripts.eval.comparer_mistral --modeles ministral-14b-2512 --questions 8 \
        --personas citoyen avocat entrepreneur etudiant --temoin --comparaisons 0 \
        --label personas_avant
    python -m scripts.eval.comparer_mistral --dry-run --questions-libres \
        "Quel est le role du Conseil constitutionnel ?" "Comment obtenir un titre foncier ?"
"""

import argparse
import asyncio
import json
import random
import re
import statistics
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence

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

from app.core.config import settings
from app.core.database import AsyncSessionLocal
from app.core.personas import normaliser_profil
from app.schemas.comparison import CRITERES_PAR_DEFAUT, mention_absence
from app.services.article_reference import normalize_number
from app.services.comparison_service import ComparisonError, ComparisonService
from app.services.mistral_service import MistralService
from app.services.prompts import SYSTEM_PROMPTS, get_system_prompt
from app.services.rag_service import ANSWER_MAX_TOKENS, RAGService

JEU = RACINE / "tests" / "fixtures" / "eval" / "retrieval_eval_v1.json"
SORTIES = RACINE / "data" / "eval_runs"
MODELES = ("ministral-14b-2512", "ministral-8b-2512")

# Regles de fond placees EN TETE du prompt systeme, pour la variante
# « renforcees ». Un petit modele suit mal des consignes noyees dans la forme :
# celles-ci disent d'abord d'ou viennent les faits, et rien d'autre.
REGLES_DE_FOND = """RÈGLES DE FOND — elles passent avant toutes les autres.
1. Ta seule source est le bloc « Documents juridiques pertinents » du message. Ce que tu sais par ailleurs du droit, camerounais ou d'un autre pays, ne compte pas.
2. Chaque montant, délai, date, condition, sanction, autorité ou démarche que tu écris doit se lire dans un extrait. Sinon, ne l'écris pas : ni « en général », ni « habituellement », ni étape « à prévoir ».
3. Repère d'abord le ou les extraits qui répondent, puis appuie chaque affirmation sur l'un d'eux, avec son numéro d'article.
4. Si les extraits ne répondent qu'en partie, dis ce qu'ils disent, puis ce qu'ils ne disent pas. S'ils ne répondent pas, dis-le en une phrase.
5. Ne cite que des numéros d'article qui figurent dans les extraits.

"""


def systeme_pour(consignes: str, persona: str = "citoyen") -> str:
    base = get_system_prompt(persona, "fr")
    return REGLES_DE_FOND + base if consignes == "renforcees" else base


# Suffixe de la variante temoin : le premier profil, rejoue a l'identique.
TEMOIN = "#2"

COMPARAISONS = [
    ("permis de recherche minière", "permis d'exploitation minière"),
    ("contrat de travail à durée déterminée", "contrat de travail à durée indéterminée"),
    ("garde à vue", "détention provisoire"),
    ("titre foncier", "concession provisoire du domaine national"),
]

# « article 32 », « articles 40.1 et 41 », « art. 1er », « l'article premier ».
_CITATION = re.compile(
    r"\bart(?:icle)?s?\.?\s+((?:\d+(?:[.-]\d+)*(?:\s*(?:er|bis|ter|quater|septies))?|premier)"
    r"(?:\s*(?:,|et|à)\s*\d+(?:[.-]\d+)*)*)",
    re.IGNORECASE,
)
# Memes suffixes que CITATION_REGEX : « 94 septies » lu par le chat et compte
# ici « 94 » faisait passer une citation lue pour illisible.
_NUMERO = re.compile(r"\d+(?:[.-]\d+)*(?:\s*(?:er|bis|ter|quater|septies))?|premier", re.IGNORECASE)


def numeros_cites(texte: str) -> List[str]:
    """Les numeros d'article cites dans une reponse, normalises, dans l'ordre."""
    vus: List[str] = []
    for groupe in _CITATION.findall(texte or ""):
        for numero in _NUMERO.findall(groupe):
            cle = normalize_number(numero)
            if cle and cle not in vus:
                vus.append(cle)
    return vus


def questions_relues(limite: int) -> List[dict]:
    if not JEU.exists():
        raise SystemExit(f"Jeu d'evaluation absent : {JEU} (lancer generate_eval_set, puis relire)")
    items = json.loads(JEU.read_text(encoding="utf-8"))["items"]
    retenues = [i for i in items if i.get("reviewed") and i.get("difficulty") == "paraphrase"]
    return retenues[:limite]


async def repondre(modele: str, prompt: str, systeme: str) -> Dict[str, Any]:
    """Une reponse en flux, chronometree."""
    service = MistralService(model_name=modele)
    fins: List[str] = []
    usages: List[dict] = []
    morceaux: List[str] = []
    debut = time.monotonic()
    premier = None
    try:
        async for morceau in service.generate_stream(
            prompt, system=systeme, temperature=0.7, max_tokens=ANSWER_MAX_TOKENS,
            fin=fins.append, usage=usages.append,
        ):
            if premier is None:
                premier = time.monotonic() - debut
            morceaux.append(morceau)
        erreur = None
    except Exception as exc:
        erreur = repr(exc)
    usage = usages[0] if usages else {}
    return {
        "modele": modele,
        "texte": "".join(morceaux),
        "fin": fins[0] if fins else None,
        "premier_morceau_s": round(premier, 2) if premier is not None else None,
        "duree_s": round(time.monotonic() - debut, 2),
        "jetons_sortie": usage.get("completion_tokens"),
        "modele_servi": usage.get("modele"),
        "erreur": erreur,
    }


def citations(texte: str, numeros_du_contexte: set) -> Dict[str, Any]:
    cites = numeros_cites(texte)
    presents = [n for n in cites if n in numeros_du_contexte]
    return {"cites": cites, "presents": presents, "absents": [n for n in cites if n not in presents]}


# Les citations que le chat sait relire : ce sont elles qui deviennent les
# sources affichees sous la reponse. Meme drapeau que _extract_citations
# (IGNORECASE) : sans lui, « l'article 3 du décret n°2015/405 » comptait pour
# illisible alors que le chat le relit. « Code Minier, art. 33 » (le texte
# avant le numero) reste illisible.
_CITATION_CHAT = re.compile(RAGService.CITATION_REGEX, re.IGNORECASE)
_RENVOI_AVOCAT = re.compile(
    r"consult(?:ez|er|e)\s+(?:un|une|votre|l['’]\s*)\s*(?:avocat|juriste|professionnel du droit)"
    r"|(?:faire appel|vous adresser|s['’]adresser)\s+à\s+un\s+avocat"
    r"|consult(?:ing)?\s+(?:a|an|your)\s+(?:lawyer|attorney|legal professional)",
    re.IGNORECASE,
)
_JURISPRUDENCE = re.compile(r"jurisprudence|case[- ]law", re.IGNORECASE)
_DEFINITION = re.compile(
    r"\b(?:désigne|se définit|on entend par|est défini|signifie|means|is defined as|refers to)\b",
    re.IGNORECASE,
)


def forme_relue(texte: str) -> Dict[str, int]:
    """Numeros d'article cites, et parmi eux ceux que le chat sait relire."""
    vus = set(numeros_cites(texte))
    relus = {normalize_number(m.group(2)) for m in _CITATION_CHAT.finditer(texte or "")}
    return {"vus": len(vus), "relus": len(vus & relus)}


def traits(texte: str) -> Dict[str, Any]:
    """Ce qui distingue une reponse de profil : longueur, forme, renvois."""
    lignes = (texte or "").splitlines()
    return {
        "mots": len(re.findall(r"\w+", texte or "")),
        "liste_numerotee": any(re.match(r"\s*\d+[.)]\s", ligne) for ligne in lignes),
        "puces": any(re.match(r"\s*[-*•]\s", ligne) for ligne in lignes),
        "renvoi_avocat": bool(_RENVOI_AVOCAT.search(texte or "")),
        # Une MENTION : « les textes ne contiennent pas de jurisprudence » en est
        # une aussi. A relire avant de conclure.
        "jurisprudence": bool(_JURISPRUDENCE.search(texte or "")),
        "definition": bool(_DEFINITION.search(texte or "")),
    }


def _trigrammes(texte: str) -> set:
    mots = re.findall(r"\w+", (texte or "").lower())
    return {tuple(mots[i:i + 3]) for i in range(len(mots) - 2)}


def similarite(a: str, b: str) -> float:
    """Jaccard sur les trigrammes de mots : 1 pour deux textes identiques."""
    ta, tb = _trigrammes(a), _trigrammes(b)
    if not ta and not tb:
        # Moins de trois mots de part et d'autre : identiques ou non, sans plus.
        # Rendre 1 faisait de « a b » et « c d » deux textes identiques.
        mots = lambda t: re.findall(r"\w+", (t or "").lower())  # noqa: E731
        return 1.0 if mots(a) == mots(b) else 0.0
    return len(ta & tb) / len(ta | tb)


def distinction(resultats: Dict[str, Any]) -> Dict[str, Any]:
    """
    Par question, la similarite mediane entre profils differents, et celle du
    temoin (le meme profil rejoue). Des profils vraiment distincts se
    ressemblent MOINS entre eux que deux tirages du meme prompt.
    """
    entre, temoin = [], []
    for question in resultats["questions"]:
        textes = {r["variante"]: r["texte"] for r in question["reponses"]
                  if r.get("variante") and not r.get("erreur")}
        profils = [cle for cle in textes if not cle.endswith(TEMOIN)]
        # Seules les variantes de meme modele et memes consignes different par
        # le PROFIL : comparer 14b|citoyen a 8b|citoyen mesurait le modele.
        def reglage(cle: str) -> str:
            return cle.rsplit("|", 1)[0]

        paires = [similarite(textes[a], textes[b])
                  for i, a in enumerate(profils) for b in profils[i + 1:]
                  if reglage(a) == reglage(b)]
        if paires:
            entre.append(statistics.median(paires))
        for cle in textes:
            if cle.endswith(TEMOIN) and cle[: -len(TEMOIN)] in textes:
                temoin.append(similarite(textes[cle], textes[cle[: -len(TEMOIN)]]))

    def mediane(valeurs):
        return round(statistics.median(valeurs), 3) if valeurs else None

    return {"entre_profils": mediane(entre), "temoin": mediane(temoin), "questions": len(entre)}


def variantes_de(args) -> List[tuple]:
    """(modele, consignes, profil) ; le temoin rejoue la premiere sous TEMOIN."""
    variantes = [(m, c, p) for m in args.modeles for c in args.consignes for p in args.personas]
    if args.temoin and variantes:
        m, c, p = variantes[0]
        variantes.append((m, c, p + TEMOIN))
    return variantes


def appels_prevus(n_questions: int, n_variantes: int, n_comparaisons: int, n_modeles: int) -> int:
    return n_questions * n_variantes + n_comparaisons * n_modeles


def appels_au_pire(n_questions: int, n_variantes: int, n_comparaisons: int, n_modeles: int) -> int:
    """
    Le budget porte sur le PIRE cas : une reponse coupee demande une suite
    (mistral_service), une grille illisible est redemandee (comparison_service).
    Chaque tache peut donc couter deux appels.
    """
    return 2 * appels_prevus(n_questions, n_variantes, n_comparaisons, n_modeles)


def questions_de(args) -> List[dict]:
    if args.questions_libres:
        return [{"question": q, "expected_law_id": None, "expected_article_number": None}
                for q in args.questions_libres]
    return questions_relues(args.questions)


async def evaluer(args) -> Dict[str, Any]:
    resultats: Dict[str, Any] = {"questions": [], "comparaisons": []}
    variantes = variantes_de(args)
    etiquettes = [chr(ord("X") + i) if i < 3 else f"R{i}" for i in range(len(variantes))]
    hasard = random.Random(args.graine)

    async with AsyncSessionLocal() as session:
        rag = RAGService(session)
        for position, item in enumerate(questions_de(args), start=1):
            chunks = await rag._retrieve_chunks(item["question"], args.langue)
            prompt = rag._build_prompt(
                question=item["question"], search_results=chunks, history=[], persona="citoyen"
            )
            contexte = {normalize_number(str(c.number or "")) for c in chunks if c.number}
            attendu = (f"{item['expected_law_id']}/{item['expected_article_number']}"
                       if item.get("expected_law_id") else None)
            entree = {
                "question": item["question"],
                "attendu": attendu,
                "extraits": [
                    {"loi": c.reference, "numero": c.number, "texte": (c.content or "")[:600]}
                    for c in chunks
                ],
                "reponses": [],
            }
            print(f"[{position}] {item['question'][:80]} ({len(chunks)} extraits)", flush=True)
            if args.dry_run and args.questions_libres:
                # Validation d'une suggestion : y a-t-il une reponse dans le corpus ?
                if not chunks:
                    print("    REJETEE : aucun extrait", flush=True)
                for c in chunks[:3]:
                    print(f"    - {c.reference} art. {c.number} : {(c.law_title or '')[:80]}", flush=True)
            if not args.dry_run:
                for modele, consignes, persona in variantes:
                    reponse = await repondre(
                        modele, prompt, systeme_pour(consignes, persona.removesuffix(TEMOIN))
                    )
                    reponse["consignes"] = consignes
                    reponse["persona"] = persona
                    reponse["variante"] = f"{modele}|{consignes}|{persona}"
                    reponse["citations"] = citations(reponse["texte"], contexte)
                    reponse["forme"] = forme_relue(reponse["texte"])
                    reponse["traits"] = traits(reponse["texte"])
                    entree["reponses"].append(reponse)
                    print(f"    {reponse['variante']}: {reponse['duree_s']} s, fin={reponse['fin']}, "
                          f"cites={len(reponse['citations']['cites'])}, "
                          f"absents={reponse['citations']['absents']}", flush=True)
                entree["aveugle"] = hasard.sample(etiquettes, len(etiquettes))
            resultats["questions"].append(entree)

        for sujet_a, sujet_b in COMPARAISONS[: args.comparaisons]:
            # Le service n'est construit que pour sa recherche et son analyse :
            # son modele est remplace a chaque mesure.
            service = ComparisonService(session, llm=object())
            chunks_a, chunks_b = await service._recuperer(sujet_a, sujet_b, None, 8)
            entree = {"sujets": [sujet_a, sujet_b], "extraits": [len(chunks_a), len(chunks_b)],
                      "reponses": []}
            print(f"[comparaison] {sujet_a} / {sujet_b} : {len(chunks_a)} + {len(chunks_b)} extraits",
                  flush=True)
            if not args.dry_run and chunks_a and chunks_b:
                for modele in args.modeles:
                    service.llm = MistralService(model_name=modele)
                    debut = time.monotonic()
                    try:
                        brut = await service._generer(
                            sujet_a, sujet_b, chunks_a, chunks_b, CRITERES_PAR_DEFAUT, "fr"
                        )
                        lignes, orphelines = service._assembler(
                            brut, CRITERES_PAR_DEFAUT, service._indexer(chunks_a),
                            service._indexer(chunks_b), "fr",
                        )
                        absence = mention_absence("fr")
                        remplies = sum(1 for ligne in lignes
                                       if ligne.a.value != absence or ligne.b.value != absence)
                        mesure = {"json": True, "lignes_rendues": len(brut.get("lignes") or []),
                                  "lignes_remplies": remplies, "orphelines": orphelines}
                    except ComparisonError as exc:
                        mesure = {"json": False, "erreur": str(exc)}
                    mesure.update({"modele": modele, "duree_s": round(time.monotonic() - debut, 2)})
                    entree["reponses"].append(mesure)
                    print(f"    {modele}: {mesure}", flush=True)
            resultats["comparaisons"].append(entree)
    return resultats


def synthese(resultats: Dict[str, Any]) -> Dict[str, Any]:
    """Par variante (modele, consignes, profil) ; les comparaisons, par modele."""
    par_modele: Dict[str, Dict[str, Any]] = {}
    variantes = []
    for q in resultats["questions"]:
        for r in q["reponses"]:
            cle = r.get("variante") or r["modele"]
            if cle not in variantes:
                variantes.append(cle)
    for variante in variantes:
        modele = variante.split("|")[0]
        reponses = [r for q in resultats["questions"] for r in q["reponses"]
                    if (r.get("variante") or r["modele"]) == variante]
        cites = sum(len(r["citations"]["cites"]) for r in reponses)
        presents = sum(len(r["citations"]["presents"]) for r in reponses)
        premiers = sorted(r["premier_morceau_s"] for r in reponses if r["premier_morceau_s"] is not None)
        durees = sorted(r["duree_s"] for r in reponses)
        comparaisons = [m for c in resultats["comparaisons"] for m in c["reponses"] if m["modele"] == modele]
        formes = [r["forme"] for r in reponses if r.get("forme")]
        vus = sum(f["vus"] for f in formes)
        lus = [r["traits"] for r in reponses if r.get("traits")]

        def part(trait, lus=lus):
            return round(sum(1 for t in lus if t[trait]) / len(lus), 2) if lus else None

        par_modele[variante] = {
            "reponses": len(reponses),
            "erreurs": sum(1 for r in reponses if r["erreur"]),
            "incompletes": sum(1 for r in reponses if r["fin"] not in (None, "STOP")),
            "justesse_citations": round(presents / cites, 3) if cites else None,
            "citations": cites,
            "forme_relue": round(sum(f["relus"] for f in formes) / vus, 3) if vus else None,
            "premier_morceau_median_s": premiers[len(premiers) // 2] if premiers else None,
            "duree_mediane_s": durees[len(durees) // 2] if durees else None,
            "jetons_sortie": sum(r["jetons_sortie"] or 0 for r in reponses),
            "mots_median": statistics.median([t["mots"] for t in lus]) if lus else None,
            "part_liste_numerotee": part("liste_numerotee"),
            "part_puces": part("puces"),
            "part_renvoi_avocat": part("renvoi_avocat"),
            "part_jurisprudence": part("jurisprudence"),
            "part_definition": part("definition"),
            "comparaisons_json": sum(1 for m in comparaisons if m.get("json")),
            "comparaisons_7_sur_7": sum(1 for m in comparaisons if m.get("lignes_remplies") == 7),
            # Une case remplie ne vaut que si ses citations tiennent : le 3b
            # remplissait les 28 cases, avec 37 articles absents des extraits.
            "comparaisons_citations_orphelines": sum(len(m.get("orphelines") or []) for m in comparaisons),
        }
    return par_modele


def fichier_a_l_aveugle(resultats: Dict[str, Any], chemin: Path) -> None:
    """Les reponses de chaque question sous X, Y..., sans le nom du modele."""
    lignes = ["# Relecture a l'aveugle : ajouts non sources", "",
              "Pour chaque question, compter dans chaque reponse les affirmations",
              "qu'aucun extrait ne soutient. La correspondance etiquette -> modele",
              "est dans le fichier JSON, champ `aveugle`, a ne lire qu'apres.", ""]
    for numero, question in enumerate(resultats["questions"], start=1):
        if not question["reponses"]:
            continue
        lignes += [f"## {numero}. {question['question']}", "", "### Extraits", ""]
        for extrait in question["extraits"]:
            lignes.append(f"- {extrait['loi']} art. {extrait['numero']} : {extrait['texte']}")
        # Dans l'ordre des etiquettes, tirees au hasard par question : dans
        # l'ordre des variantes, la position trahissait le profil (citoyen
        # toujours premier, temoin toujours dernier).
        for etiquette, reponse in sorted(zip(question["aveugle"], question["reponses"]),
                                         key=lambda paire: paire[0]):
            lignes += ["", f"### Reponse {etiquette}", "", reponse["texte"] or f"(erreur : {reponse['erreur']})"]
        lignes.append("")
    chemin.write_text("\n".join(lignes), encoding="utf-8")


def _profil(valeur: str) -> str:
    try:
        return normaliser_profil(valeur)
    except ValueError as exc:
        raise argparse.ArgumentTypeError(str(exc)) from exc


def _positif(valeur: str) -> int:
    nombre = int(valeur)
    if nombre < 0:
        # `--comparaisons -1` retranchait un appel au budget et en lancait trois.
        raise argparse.ArgumentTypeError("doit etre positif ou nul")
    return nombre


def _parseur() -> argparse.ArgumentParser:
    parseur = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parseur.add_argument("--questions", type=_positif, default=20)
    parseur.add_argument("--comparaisons", type=_positif, default=4)
    parseur.add_argument("--graine", type=int, default=7)
    parseur.add_argument("--modeles", nargs="+", default=list(MODELES))
    parseur.add_argument("--consignes", nargs="+", default=["actuelles"],
                         choices=["actuelles", "renforcees"])
    parseur.add_argument("--personas", nargs="+", type=_profil, default=["citoyen"],
                         help="Profils compares, alias acceptes (etudiant, general)")
    parseur.add_argument("--temoin", action="store_true",
                         help="Rejoue le premier profil : plancher de bruit")
    parseur.add_argument("--budget", type=int, default=96,
                         help="Nombre maximal d'appels, au pire cas ; au-dela, rien n'est lance")
    parseur.add_argument("--questions-libres", nargs="+", default=None,
                         help="Remplace le jeu relu ; avec --dry-run, valide des suggestions")
    parseur.add_argument("--langue", default="fr", choices=["fr", "en"],
                         help="Langue de la recherche (questions libres)")
    parseur.add_argument("--label", default="14b_8b")
    parseur.add_argument("--dry-run", action="store_true", help="Recherche seule, aucun appel a Mistral")
    return parseur


def main(argv: Optional[Sequence[str]] = None) -> int:
    args = _parseur().parse_args(argv)

    sans_prompt = [p for p in args.personas if p.removesuffix(TEMOIN) not in SYSTEM_PROMPTS["fr"]]
    if sans_prompt:
        # get_system_prompt se replierait en silence : on mesurerait un autre profil.
        print(f"Profil sans prompt : {sans_prompt}")
        return 2

    n_questions = len(questions_de(args))
    taches = (n_questions, len(variantes_de(args)),
              min(args.comparaisons, len(COMPARAISONS)), len(args.modeles))
    prevus, au_pire = appels_prevus(*taches), appels_au_pire(*taches)
    print(f"Appels prevus : {prevus}, au pire {au_pire} (budget {args.budget})")
    if not args.dry_run and au_pire > args.budget:
        print("Budget depasse : rien n'est lance. Reduire --questions, --personas ou --modeles.")
        return 2
    if not args.dry_run and not settings.MISTRAL_API_KEY:
        print("MISTRAL_API_KEY absente du .env.")
        return 2
    # Chaque reponse doit venir du modele mesure : pas de bascule.
    settings.MISTRAL_MODEL_SECOURS = ""
    # Le reclassement par le modele ajouterait un appel par question, hors
    # budget, et en ferait un dans --dry-run, qui promet de n'en faire aucun.
    settings.RERANK_LLM_ENABLED = False

    resultats = asyncio.run(evaluer(args))
    if args.dry_run:
        return 0
    resultats["synthese"] = synthese(resultats)
    resultats["distinction"] = distinction(resultats)
    horodatage = datetime.now().strftime("%Y%m%d_%H%M")
    SORTIES.mkdir(parents=True, exist_ok=True)
    chemin = SORTIES / f"mistral_{args.label}_{horodatage}.json"
    chemin.write_text(json.dumps(resultats, ensure_ascii=False, indent=2), encoding="utf-8")
    fichier_a_l_aveugle(resultats, chemin.with_suffix(".aveugle.md"))
    print(json.dumps({"synthese": resultats["synthese"], "distinction": resultats["distinction"]},
                     ensure_ascii=False, indent=2))
    print(f"Ecrit : {chemin} et {chemin.with_suffix('.aveugle.md')}")
    return 0


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