diff --git a/.env.example b/.env.example index 149642f..c80187e 100644 --- a/.env.example +++ b/.env.example @@ -5,19 +5,25 @@ # === Ollama === # OLLAMA_URL=http://localhost:11434 -# OLLAMA_MODEL=gemma3:27b-cloud -# OLLAMA_TIMEOUT=120 +# OLLAMA_MODEL=gemma3:27b +# OLLAMA_TIMEOUT=600 # OLLAMA_MAX_PARALLEL=2 # === Modèles par rôle LLM === -# T2A_MODEL_CODING=gemma3:27b-cloud # Codage CIM-10/CCAM, extraction DAS -# T2A_MODEL_CPAM=deepseek-v3.2:cloud # CPAM passe 1 + passe 2 -# T2A_MODEL_VALIDATION=deepseek-v3.2:cloud # Validation adversariale +# T2A_MODEL_CODING=gemma3:27b # Codage CIM-10/CCAM, extraction DAS +# T2A_MODEL_CPAM=mistral-small3.2:24b # CPAM passe 1 + passe 2 (TIM complet, bonne précision bio) +# T2A_MODEL_VALIDATION=qwen3:32b # Validation adversariale (rapide, modèle différent → LOGIC-3 actif) # T2A_MODEL_QC=gemma3:12b # QC batch justifications # # IMPORTANT : T2A_MODEL_CPAM et T2A_MODEL_VALIDATION DOIVENT être différents # en production pour que la validation adversariale soit réellement indépendante. # Si identiques, la validation adversariale est automatiquement dégradée (LOGIC-3). +# +# Benchmark (dossier 183_23087212, machine locale) : +# mistral-small3.2:24b → TIM complet, meilleure précision bio, 430s +# qwen3:32b → TIM complet, rapide (302s), JSON fiable +# gemma3:27b → hallucinations bio, format TIM non respecté +# llama3.3:70b → riche mais trop lent (1743s), nécessite DGX Spark # === Sélecteur DP (NUKE-3) === # T2A_DP_RANKER_LLM=1 # 1/true/yes = LLM tiebreaker actif, 0/false/no = pré-ranker déterministe uniquement diff --git a/src/config.py b/src/config.py index 554a90b..30031fb 100644 --- a/src/config.py +++ b/src/config.py @@ -53,17 +53,17 @@ NER_CONFIDENCE_THRESHOLD = float(os.environ.get("T2A_NER_THRESHOLD", "0.80")) # --- Configuration Ollama --- OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434") -OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "gemma3:27b-cloud") -OLLAMA_TIMEOUT = int(os.environ.get("OLLAMA_TIMEOUT", "120")) +OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "gemma3:27b") +OLLAMA_TIMEOUT = int(os.environ.get("OLLAMA_TIMEOUT", "600")) OLLAMA_CACHE_PATH = BASE_DIR / "data" / "ollama_cache.json" OLLAMA_MAX_PARALLEL = int(os.environ.get("OLLAMA_MAX_PARALLEL", "2")) # --- Modèles par rôle LLM --- OLLAMA_MODELS: dict[str, str] = { - "coding": os.environ.get("T2A_MODEL_CODING", "gemma3:27b-cloud"), - "cpam": os.environ.get("T2A_MODEL_CPAM", "gemma3:27b-cloud"), - "validation": os.environ.get("T2A_MODEL_VALIDATION", "deepseek-v3.2:cloud"), + "coding": os.environ.get("T2A_MODEL_CODING", "gemma3:27b"), + "cpam": os.environ.get("T2A_MODEL_CPAM", "mistral-small3.2:24b"), + "validation": os.environ.get("T2A_MODEL_VALIDATION", "qwen3:32b"), "qc": os.environ.get("T2A_MODEL_QC", "gemma3:12b"), } diff --git a/src/control/cpam_response.py b/src/control/cpam_response.py index 3ce2ebe..9c67a41 100644 --- a/src/control/cpam_response.py +++ b/src/control/cpam_response.py @@ -30,6 +30,7 @@ from .cpam_validation import ( _build_correction_prompt, _format_response, _assess_quality_tier, + _guardian_deterministic, ) # Backward compat — sera retiré dans un commit futur @@ -172,6 +173,9 @@ def generate_cpam_response( logger.info(" CPAM : %d code(s) hors périmètre supprimé(s) : %s", len(sanitized), ", ".join(sanitized)) + # 6b. Gardien déterministe — corrige hallucinations bio, force R3 + result = _guardian_deterministic(result, dossier, controle, tag_map) + # 7. Validation des références RAG ref_warnings = _validate_references(result, sources) if ref_warnings: @@ -239,6 +243,7 @@ def generate_cpam_response( result = corrected validation = validation2 _sanitize_unauthorized_codes(result, dossier, controle) + result = _guardian_deterministic(result, dossier, controle, tag_map) ref_warnings = _validate_references(result, sources) grounding_warnings = _validate_grounding(result, tag_map) code_warnings = _validate_codes_in_response(result, dossier, controle) diff --git a/src/control/cpam_validation.py b/src/control/cpam_validation.py index 403e090..83dc1fc 100644 --- a/src/control/cpam_validation.py +++ b/src/control/cpam_validation.py @@ -603,6 +603,350 @@ def _is_new_tim_format(parsed: dict) -> bool: return "moyens_defense" in parsed +# --------------------------------------------------------------------------- +# Gardien déterministe — validation/correction post-LLM sans appel modèle +# --------------------------------------------------------------------------- + +def _guardian_deterministic( + result: dict, + dossier: DossierMedical, + controle: ControleCPAM, + tag_map: dict[str, str], +) -> dict: + """Gardien déterministe post-LLM : corrige les hallucinations factuelles. + + Opérations (0 appel LLM, < 1ms) : + 1. Corrige les valeurs bio hallucinées dans confrontation_bio + 2. Force la cohérence R3 (bio NON CONFIRMÉ → codes_non_defendables) + 3. Retire des moyens_defense les codes bio-contredits + 4. Vérifie les tags dans les preuves + 5. Calcule un score factuel déterministe + + Returns: + dict avec les corrections appliquées + champ "guardian_report" ajouté. + """ + from .cpam_context import _BIO_THRESHOLDS + + if not _is_new_tim_format(result): + return result + + report: dict = { + "bio_corrections": [], + "codes_moved_to_nd": [], + "preuves_invalid_tags": [], + "score_factuel": 10, + } + penalties = 0 + + # --- Indexer les valeurs bio réelles du dossier --- + bio_reelles: dict[str, float] = {} + for b in dossier.biologie_cle: + if b.test and b.valeur_num is not None: + bio_reelles[b.test] = b.valeur_num + elif b.test and b.valeur: + try: + bio_reelles[b.test] = float(b.valeur.replace(",", ".").split()[0]) + except (ValueError, AttributeError): + pass + + # ===== 1. Corriger confrontation_bio ===== + confrontation = result.get("confrontation_bio", []) + codes_infirmes: set[str] = set() # codes dont la bio est normale + codes_confirmes: set[str] = set() # codes dont la bio est pathologique + + if isinstance(confrontation, list): + for i, entry in enumerate(confrontation): + if not isinstance(entry, dict): + continue + test_name = str(entry.get("test", "")) + valeur_llm = entry.get("valeur") + diagnostic = str(entry.get("diagnostic", "")) + + # --- 1b. Vérifier l'association test↔diagnostic via _BIO_THRESHOLDS --- + code_match = re.match(r"([A-Z]\d{2}(?:\.\d{1,2})?)", diagnostic) + if code_match: + diag_code = code_match.group(1) + # Chercher dans _BIO_THRESHOLDS (préfixe 3 ou 5 chars) + expected = (_BIO_THRESHOLDS.get(diag_code) + or _BIO_THRESHOLDS.get(diag_code[:3])) + if expected: + expected_test = expected["test"].lower() + if expected_test not in test_name.lower() and test_name.lower() not in expected_test: + # Le LLM a associé le mauvais test → corriger + old_test = test_name + entry["test"] = expected["test"] + test_name = expected["test"] + # Chercher la bonne valeur dans le dossier + for bio_key, bio_val in bio_reelles.items(): + if expected_test in bio_key.lower() or bio_key.lower() in expected_test: + entry["valeur"] = bio_val + valeur_llm = bio_val + entry["seuil"] = expected.get("condition", "") + break + report["bio_corrections"].append({ + "test": old_test, + "correction": f"mauvais test pour {diag_code} : {old_test} → {expected['test']}", + "type": "wrong_test_mapping", + }) + penalties += 2 + logger.info(" Gardien : %s → test corrigé de '%s' vers '%s'", + diag_code, old_test, expected["test"]) + + # Chercher la valeur réelle dans le dossier + valeur_reelle = None + for bio_key, bio_val in bio_reelles.items(): + if bio_key.lower() in test_name.lower() or test_name.lower() in bio_key.lower(): + valeur_reelle = bio_val + break + + if valeur_reelle is not None: + # Vérifier si le LLM a inventé une valeur différente + try: + v_llm = float(str(valeur_llm).replace(",", ".").split()[0]) if valeur_llm else None + except (ValueError, TypeError): + v_llm = None + + if v_llm is not None and abs(v_llm - valeur_reelle) > 0.1: + report["bio_corrections"].append({ + "test": test_name, + "valeur_llm": v_llm, + "valeur_reelle": valeur_reelle, + "diagnostic": diagnostic, + }) + entry["valeur"] = valeur_reelle + penalties += 2 + + # Recalculer le verdict avec la valeur réelle + matched_bio_key = None + for bio_key in bio_reelles: + if bio_key.lower() in test_name.lower() or test_name.lower() in bio_key.lower(): + matched_bio_key = bio_key + break + + if matched_bio_key and matched_bio_key in BIO_NORMALS: + lo, hi = BIO_NORMALS[matched_bio_key] + is_normal = lo <= valeur_reelle <= hi + + old_verdict = str(entry.get("verdict", "")).upper() + if is_normal and "CONFIRMÉ" in old_verdict and "NON" not in old_verdict: + # Verdict CONFIRMÉ mais valeur normale → correction + entry["verdict"] = "NON CONFIRMÉ — valeur NORMALE" + report["bio_corrections"].append({ + "test": test_name, + "verdict_corrige": "CONFIRMÉ → NON CONFIRMÉ", + "valeur": valeur_reelle, + "norme": f"[{lo}-{hi}]", + }) + penalties += 2 + elif not is_normal and "NON" in old_verdict and "CONFIRMÉ" in old_verdict: + # Verdict NON CONFIRMÉ mais valeur pathologique → correction inverse + entry["verdict"] = "CONFIRMÉ — valeur PATHOLOGIQUE" + report["bio_corrections"].append({ + "test": test_name, + "verdict_corrige": "NON CONFIRMÉ → CONFIRMÉ", + "valeur": valeur_reelle, + "norme": f"[{lo}-{hi}]", + }) + penalties += 2 + + code_match_v = re.match(r"([A-Z]\d{2}(?:\.\d{1,2})?)", diagnostic) + if is_normal: + # Extraire le code CIM-10 du champ diagnostic + if code_match_v: + codes_infirmes.add(code_match_v.group(1)) + elif not is_normal and code_match_v: + # Bio pathologique → ce code est CONFIRMÉ, pas infirmé + codes_confirmes.add(code_match_v.group(1)) + + # ===== 2. Force R3 : codes infirmés → codes_non_defendables ===== + codes_nd = result.get("codes_non_defendables", []) + if not isinstance(codes_nd, list): + codes_nd = [] + result["codes_non_defendables"] = codes_nd + + codes_nd_existants = { + nd.get("code", "") for nd in codes_nd if isinstance(nd, dict) + } + + for code_infirme in codes_infirmes: + if code_infirme not in codes_nd_existants: + # Chercher la valeur bio pour le message + bio_detail = "" + for bio_key, bio_val in bio_reelles.items(): + if bio_key in BIO_NORMALS: + lo, hi = BIO_NORMALS[bio_key] + # Vérifier si ce test est lié au code via _BIO_THRESHOLDS + prefix = code_infirme[:3] + threshold = _BIO_THRESHOLDS.get(prefix) or _BIO_THRESHOLDS.get(code_infirme[:5] if len(code_infirme) >= 5 else code_infirme) + if threshold and bio_key.lower() in threshold.get("test", "").lower(): + bio_detail = f"{bio_key} = {bio_val} [norme {lo}-{hi}] — valeur NORMALE" + break + + is_valid, label = validate_code(normalize_code(code_infirme)) + label_str = f" ({label})" if is_valid and label else "" + + codes_nd.append({ + "code": code_infirme, + "raison": f"{bio_detail or 'Bio normale'}, diagnostic non confirmé biologiquement", + "recommandation": "Retrait recommandé — code indéfendable (gardien déterministe)", + "_source": "guardian", + }) + report["codes_moved_to_nd"].append(code_infirme) + penalties += 1 + + # ===== 2b. Retirer de codes_non_defendables les codes bio-CONFIRMÉS ===== + if codes_confirmes and codes_nd: + codes_nd_before = len(codes_nd) + result["codes_non_defendables"] = [ + nd for nd in codes_nd + if not isinstance(nd, dict) + or nd.get("code", "") not in codes_confirmes + ] + codes_nd = result["codes_non_defendables"] + removed = codes_nd_before - len(codes_nd) + if removed: + report.setdefault("codes_rescued_from_nd", list(codes_confirmes)) + logger.info(" Gardien : %d code(s) retirés de non-défendables (bio pathologique) : %s", + removed, ", ".join(codes_confirmes)) + + # ===== 3. Retirer des moyens_defense les codes bio-contredits ===== + moyens = result.get("moyens_defense", []) + if isinstance(moyens, list) and codes_infirmes: + moyens_filtres = [] + for moyen in moyens: + if not isinstance(moyen, dict): + moyens_filtres.append(moyen) + continue + titre = str(moyen.get("titre", "")).upper() + argument = str(moyen.get("argument", "")).upper() + moyen_text = titre + " " + argument + + # Vérifier si ce moyen défend un code infirmé + defends_infirme = False + for code_inf in codes_infirmes: + if code_inf in moyen_text: + defends_infirme = True + logger.info(" Gardien : moyen retiré (défend %s, bio normale)", code_inf) + penalties += 1 + break + + if not defends_infirme: + moyens_filtres.append(moyen) + + result["moyens_defense"] = moyens_filtres + + # Renuméroter + for idx, moyen in enumerate(result["moyens_defense"]): + if isinstance(moyen, dict): + moyen["numero"] = idx + 1 + + # ===== 4. Vérifier les tags dans les preuves ===== + if tag_map: + for moyen in result.get("moyens_defense", []): + if not isinstance(moyen, dict): + continue + for preuve in moyen.get("preuves", []): + if not isinstance(preuve, dict): + continue + ref = str(preuve.get("ref", "")) + # Extraire le tag (ex: [BIO-1] → BIO-1) + tag_match = re.findall(r"\[([A-Z]+-\d+)\]", ref) + for tag in tag_match: + if tag not in tag_map: + report["preuves_invalid_tags"].append(tag) + penalties += 0.5 + + # ===== 5. Nettoyage des champs texte libre ===== + # Remplacer les valeurs bio hallucinées dans les strings (conclusion, rappel, etc.) + text_fields = [ + "rappel_faits", "conclusion_dispositive", "reponse_points_cpam", + "asymetrie_information", + ] + bio_replacements: list[tuple[str, str]] = [] # (pattern_llm, valeur_reelle) + for corr in report["bio_corrections"]: + v_llm = corr.get("valeur_llm") + v_reelle = corr.get("valeur_reelle") + if v_llm is not None and v_reelle is not None: + fmt_llm = str(int(v_llm)) if v_llm == int(v_llm) else str(v_llm) + fmt_reel = str(int(v_reelle)) if v_reelle == int(v_reelle) else str(v_reelle) + if fmt_llm != fmt_reel: + bio_replacements.append((fmt_llm, fmt_reel)) + + def _safe_bio_replace(text: str, old: str, new: str) -> str: + """Remplace une valeur bio en évitant les contextes de norme [X-Y]. + + Ne remplace PAS si le nombre est précédé de '-' (borne haute d'une norme) + ou suivi de '-' sans espace (borne basse d'une norme). + Utilise des word boundaries pour plus de sécurité. + """ + # Pattern : old_value précédé de word boundary ou espace, pas de tiret collé + pattern = r"(? 130 µmol/L", "verdict": "CONFIRMÉ"}} ], "asymetrie_information": "Éléments cliniques que la CPAM n'avait PAS (bio, imagerie, actes) — brièvement", - "reponse_points_cpam": "Pour chaque point légitime de la CPAM : reconnaissance CLAIRE + réfutation factuelle OU concession si indéfendable", + "reponse_points_cpam": "Pour chaque point légitime de la CPAM : reconnaissance CLAIRE + réfutation factuelle OU concession explicite si indéfendable", "codes_non_defendables": [ {{"code": "D50.9", "raison": "Hb = 13.5 g/dL [norme > 12 F] — valeur NORMALE, anémie non confirmée biologiquement", "recommandation": "Retrait recommandé — code indéfendable face à la CPAM"}} ], diff --git a/test_cpam_quality.py b/test_cpam_quality.py new file mode 100644 index 0000000..ad3d8f6 --- /dev/null +++ b/test_cpam_quality.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Test qualité CPAM — format TIM (mémoire en défense) sur dossiers réels. + +Charge des dossiers JSON existants et appelle generate_cpam_response() +pour valider le nouveau format TIM sans relancer le pipeline complet. +""" + +import json +import logging +import sys +import time +from pathlib import Path + +# Ajouter le répertoire racine au path +sys.path.insert(0, str(Path(__file__).parent)) + +from src.config import DossierMedical, ControleCPAM +from src.control.cpam_response import generate_cpam_response +from src.control.cpam_validation import _is_new_tim_format + +# Configurer logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)-5s %(name)s — %(message)s", + datefmt="%H:%M:%S", +) +logger = logging.getLogger("test_cpam_quality") + +# Dossiers à tester (variété de cas) +DOSSIERS_TEST = [ + "183_23087212", # Désaccord DP+DAS + "116_23065570", # DAS + "143_23096917", # DP+DAS + "132_23080179", # Facturation +] + + +def load_dossier(name: str) -> DossierMedical | None: + """Charge un dossier JSON depuis output/structured/.""" + base = Path(__file__).parent / "output" / "structured" / name + # Préférer le fichier fusionné + fusionne = list(base.glob("*_fusionne_cim10.json")) + json_files = fusionne if fusionne else sorted(base.glob("*.json")) + if not json_files: + logger.error("Aucun JSON trouvé pour %s", name) + return None + with open(json_files[0], encoding="utf-8") as f: + data = json.load(f) + return DossierMedical(**data) + + +def test_dossier(name: str) -> dict: + """Teste generate_cpam_response sur un dossier et retourne les métriques.""" + logger.info("=" * 70) + logger.info("DOSSIER : %s", name) + logger.info("=" * 70) + + dossier = load_dossier(name) + if not dossier: + return {"name": name, "error": "Dossier non trouvé"} + + if not dossier.controles_cpam: + return {"name": name, "error": "Pas de contrôle CPAM"} + + controle = dossier.controles_cpam[0] + logger.info("Contrôle : OGC %d — %s", controle.numero_ogc, controle.titre) + logger.info("DP UCR : %s | DA UCR : %s", controle.dp_ucr or "-", controle.da_ucr or "-") + + # Appeler generate_cpam_response + t0 = time.time() + text, result, rag_sources = generate_cpam_response(dossier, controle) + elapsed = time.time() - t0 + + metrics = { + "name": name, + "titre": controle.titre, + "elapsed_s": round(elapsed, 1), + "text_len": len(text), + "rag_sources": len(rag_sources), + "tier": controle.quality_tier or "?", + } + + if result: + is_tim = _is_new_tim_format(result) + metrics["format"] = "TIM" if is_tim else "legacy" + + if is_tim: + # Nouveau format TIM + moyens = result.get("moyens_defense", []) + confrontation = result.get("confrontation_bio", []) + codes_nd = result.get("codes_non_defendables", []) + refs = result.get("references", []) + conclusion = result.get("conclusion_dispositive", "") + + # Compter les preuves dans les moyens + total_preuves = 0 + preuves_with_ref = 0 + for m in moyens: + if isinstance(m, dict): + for p in m.get("preuves", []): + if isinstance(p, dict): + total_preuves += 1 + if p.get("ref"): + preuves_with_ref += 1 + + metrics["moyens_count"] = len(moyens) + metrics["preuves_count"] = total_preuves + metrics["preuves_with_ref"] = preuves_with_ref + metrics["confrontation_count"] = len(confrontation) + metrics["codes_nd_count"] = len(codes_nd) + metrics["refs_count"] = len(refs) if isinstance(refs, list) else 0 + metrics["conclusion_len"] = len(conclusion) + metrics["has_rappel_faits"] = bool(result.get("rappel_faits")) + metrics["has_reponse_cpam"] = bool(result.get("reponse_points_cpam")) + + logger.info("-" * 40) + logger.info("FORMAT : TIM (mémoire en défense)") + logger.info("RÉSULTAT : %d chars, %.1fs, tier %s", len(text), elapsed, metrics["tier"]) + logger.info(" Moyens de défense : %d", len(moyens)) + logger.info(" Preuves : %d (dont %d avec tag)", total_preuves, preuves_with_ref) + logger.info(" Confrontation bio : %d entrées", len(confrontation)) + logger.info(" Codes non défendables : %d", len(codes_nd)) + logger.info(" Références : %d", metrics["refs_count"]) + logger.info(" Sources RAG : %d", len(rag_sources)) + if confrontation: + for row in confrontation: + if isinstance(row, dict): + logger.info(" Bio: %s → %s = %s → %s", + row.get("diagnostic", "?"), row.get("test", "?"), + row.get("valeur", "?"), row.get("verdict", "?")) + if codes_nd: + for nd in codes_nd: + if isinstance(nd, dict): + logger.info(" ⚠ Non défendable: %s — %s", + nd.get("code", "?"), nd.get("raison", "?")[:80]) + + # --- Guardian report --- + guardian = result.get("guardian_report", {}) + if guardian: + bio_corr = guardian.get("bio_corrections", []) + codes_moved = guardian.get("codes_moved_to_nd", []) + text_repl = guardian.get("text_replacements", 0) + score_f = guardian.get("score_factuel", "?") + metrics["guardian_bio_corrections"] = len(bio_corr) + metrics["guardian_codes_moved"] = len(codes_moved) + metrics["guardian_text_replacements"] = int(text_repl) if text_repl else 0 + metrics["guardian_score_factuel"] = score_f + logger.info(" --- GUARDIAN REPORT ---") + logger.info(" Score factuel : %s/10", score_f) + logger.info(" Bio corrections : %d", len(bio_corr)) + for c in bio_corr: + logger.info(" %s : LLM=%s → réel=%s", + c.get("test", "?"), c.get("valeur_llm", c.get("llm_value", "?")), + c.get("valeur_reelle", c.get("real_value", "?"))) + if codes_moved: + logger.info(" Codes déplacés vers non-défendables : %s", + ", ".join(codes_moved)) + if text_repl: + logger.info(" Remplacements texte : %s", text_repl) + else: + metrics["guardian_bio_corrections"] = 0 + metrics["guardian_codes_moved"] = 0 + metrics["guardian_text_replacements"] = 0 + metrics["guardian_score_factuel"] = "N/A" + else: + # Ancien format (fallback) + preuves = result.get("preuves_dossier", []) + refs = result.get("references", []) + conclusion = result.get("conclusion", "") + + metrics["moyens_count"] = 0 + metrics["preuves_count"] = len(preuves) if isinstance(preuves, list) else 0 + metrics["preuves_with_ref"] = sum(1 for p in (preuves or []) if isinstance(p, dict) and p.get("ref")) + metrics["confrontation_count"] = 0 + metrics["codes_nd_count"] = 0 + metrics["refs_count"] = len(refs) if isinstance(refs, list) else 0 + metrics["conclusion_len"] = len(conclusion) + + logger.info("-" * 40) + logger.info("FORMAT : legacy (ancien)") + logger.info("RÉSULTAT : %d chars, %.1fs, tier %s", len(text), elapsed, metrics["tier"]) + else: + metrics["error"] = "LLM a retourné None" + metrics["format"] = "N/A" + logger.error("LLM n'a retourné aucun résultat !") + + # Afficher la contre-argumentation complète + print("\n" + "~" * 70) + print("CONTRE-ARGUMENTATION :") + print("~" * 70) + print(text[:5000] if text else "(vide)") + if len(text) > 5000: + print(f"\n... [tronqué, {len(text)} chars au total]") + + return metrics + + +def main(): + dossiers = sys.argv[1:] if len(sys.argv) > 1 else DOSSIERS_TEST + results = [] + + for name in dossiers: + try: + metrics = test_dossier(name) + results.append(metrics) + except Exception as e: + logger.exception("Erreur sur %s", name) + results.append({"name": name, "error": str(e)}) + + # Résumé final + print("\n" + "=" * 70) + print("RÉSUMÉ — FORMAT TIM") + print("=" * 70) + print(f"{'Dossier':<20} {'Fmt':>5} {'Tier':>4} {'Temps':>6} {'Chars':>6} {'Moyens':>7} {'Bio':>4} {'ND':>3} {'Refs':>5} {'RAG':>4} {'G.Fix':>5} {'G.Mv':>4} {'G.Txt':>5} {'G.Sc':>4}") + print("-" * 105) + for r in results: + if "error" in r: + print(f"{r['name']:<20} ERREUR: {r['error']}") + else: + print( + f"{r['name']:<20} " + f"{r.get('format', '?'):>5} " + f"{r.get('tier', '?'):>4} " + f"{r['elapsed_s']:>5.1f}s " + f"{r['text_len']:>6} " + f"{r.get('moyens_count', 0):>7} " + f"{r.get('confrontation_count', 0):>4} " + f"{r.get('codes_nd_count', 0):>3} " + f"{r.get('refs_count', 0):>5} " + f"{r['rag_sources']:>4} " + f"{r.get('guardian_bio_corrections', 0):>5} " + f"{r.get('guardian_codes_moved', 0):>4} " + f"{r.get('guardian_text_replacements', 0):>5} " + f"{str(r.get('guardian_score_factuel', 'N/A')):>4}" + ) + + +if __name__ == "__main__": + main()