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