diff --git a/.claude/projects/-home-dom-ai-t2a/memory/regles_metier_t2a.md b/.claude/projects/-home-dom-ai-t2a/memory/regles_metier_t2a.md deleted file mode 100644 index c4f01cc..0000000 --- a/.claude/projects/-home-dom-ai-t2a/memory/regles_metier_t2a.md +++ /dev/null @@ -1,32 +0,0 @@ -# Règles métier T2A — Connaissances critiques - -## 1. Index alphabétique CIM-10 -- Ne pas se contenter de vectoriser les codes (liste analytique) -- Vectoriser les **index alphabétiques** : un médecin cherche "Gastrite", pas "K29.7" -- Le lien langage naturel → code est bien plus riche dans l'index alphabétique - -## 2. Validité temporelle des codes CCAM -- Chaque code CCAM a une date de début et de fin de validité -- Si un acte est hors période de validité (supprimé ou remplacé dans une version), le **groupage plantera** -- Le RAG doit toujours vérifier les dates de validité des codes dans les tables de référence -- Version actuelle : CCAM V4 2025 - -## 3. Diagnostics d'exclusion (piège IA classique) -- Si le patient a un symptôme (R10.4 "Douleur abdominale") ET un diagnostic précis (K35.8 "Appendicite"), - le symptôme est **exclu** au profit du diagnostic précis -- Règle : les codes **Chapitres I à XIV** de la CIM-10 priment sur les codes **Chapitre XVIII** (symptômes) -- Le reranker doit implémenter cette priorisation - -## 4. Hiérarchie des actes CCAM (non-cumul) -- La CCAM n'est pas que du texte, c'est de la **combinatoire** -- Règles de non-cumul : deux actes anatomiquement incompatibles ou inclus l'un dans l'autre → **alerte** -- Doit être vérifié selon le référentiel CCAM - -## 5. Sévérité CMA/CMS (nerf de la guerre GHM) -- CMA = Complications ou Morbidités Associées -- CMS = Complications ou Morbidités Associées Sévères -- La détection des CMA/CMS détermine le passage du **niveau 1 au niveau 4 du GHM** -- Différence de valorisation financière énorme -- Le NLP doit chercher spécifiquement les **marqueurs de sévérité** - - Ex: "Insuffisance rénale **aiguë**" vs "**chronique**" → codes et niveaux différents - - Ex: "Dénutrition **sévère**" vs "modérée" diff --git a/benchmark_cpam_models.py b/benchmark_cpam_models.py deleted file mode 100644 index 4fc9225..0000000 --- a/benchmark_cpam_models.py +++ /dev/null @@ -1,506 +0,0 @@ -#!/usr/bin/env python3 -"""Benchmark CPAM TIM — test complet multi-modèles sur dossiers réels. - -Teste generate_cpam_response() avec chaque modèle local candidat -pour évaluer : validité JSON, compliance TIM, cohérence bio, codes inventés. - -Usage: - python benchmark_cpam_models.py [dossier_name] -""" - -import json -import logging -import os -import sys -import time -import importlib -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(levelname)-5s %(name)s — %(message)s", - datefmt="%H:%M:%S", -) -logger = logging.getLogger("benchmark_cpam") - -# Modèles locaux à tester (pas de cloud) -MODELS_TO_TEST = [ - "gemma3:27b", - "gemma3:27b-it-qat", - "qwen3:32b", - "qwen3:14b", - "mistral-small3.2:24b", - "llama3.3:70b", -] - -# Dossier de test par défaut -DEFAULT_DOSSIER = "183_23087212" - -# Seuils bio connus (ground truth pour vérification) -BIO_GROUND_TRUTH = { - "Créatinine": {"valeur": 84, "norme_min": 50, "norme_max": 120, "status": "NORMAL"}, - "Sodium": {"valeur": 140, "norme_min": 135, "norme_max": 145, "status": "NORMAL"}, - "Potassium": {"valeur": 3.9, "norme_min": 3.5, "norme_max": 5.0, "status": "NORMAL"}, - "Hémoglobine": {"valeur": 12.6, "norme_min": 12, "norme_max": 17, "status": "NORMAL"}, - "Plaquettes": {"valeur": 268, "norme_min": 150, "norme_max": 400, "status": "NORMAL"}, - "Glycémie": {"valeur": 4.8, "norme_min": 3.9, "norme_max": 5.5, "status": "NORMAL"}, -} - - -def load_dossier(name: str): - """Charge un dossier JSON depuis output/structured/.""" - from src.config import DossierMedical - base = Path(__file__).parent / "output" / "structured" / name - 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 set_model(model_name: str): - """Force le modèle CPAM dans la config au runtime.""" - import src.config as cfg - import src.medical.ollama_client as oc - cfg.OLLAMA_MODELS["cpam"] = model_name - # Timeout adapté aux gros modèles locaux (600s = 10 min) - cfg.OLLAMA_TIMEOUT = 600 - oc.OLLAMA_TIMEOUT = 600 # Propagation directe (importé par valeur) - logger.info("Modèle CPAM forcé → %s (timeout=600s)", model_name) - - -def check_model_available(model_name: str) -> bool: - """Vérifie si le modèle est disponible localement dans Ollama.""" - import requests - try: - resp = requests.get(f"{os.environ.get('OLLAMA_URL', 'http://localhost:11434')}/api/tags", timeout=5) - if resp.status_code == 200: - models = [m["name"] for m in resp.json().get("models", [])] - # Vérifier correspondance exacte ou avec :latest - for m in models: - if m == model_name or m == f"{model_name}:latest": - return True - # Gérer les cas comme "gemma3:27b" qui match "gemma3:27b" - if model_name in m: - return True - return False - except Exception: - return False - - -def is_tim_format(result: dict) -> bool: - """Vérifie si le résultat est au format TIM.""" - return isinstance(result, dict) and "moyens_defense" in result - - -def check_bio_coherence(result: dict) -> list[dict]: - """Vérifie la cohérence bio/diagnostic dans les sorties du modèle. - - Returns: - Liste d'erreurs trouvées avec détails. - """ - errors = [] - if not isinstance(result, dict): - return errors - - # Sérialiser tout le résultat en texte pour chercher les erreurs - full_text = json.dumps(result, ensure_ascii=False).lower() - - # Vérification 1: Créatinine 84 qualifiée d'anormale - creat_patterns = [ - "insuffisance rénale", - "ira", "irc", - "fonction rénale altérée", "fonction rénale dégradée", - "créatinine élevée", "creatinine élevée", - "créatinine augmentée", "hypercréatininémie", - ] - - # Chercher si créatinine 84 est associée à un diagnostic d'IR - if "84" in full_text and "créatinine" in full_text: - # Chercher dans les arguments et preuves - for pattern in creat_patterns: - if pattern in full_text: - errors.append({ - "type": "BIO_HALLUCINATION", - "severity": "CRITICAL", - "detail": f"Créatinine 84 µmol/L (NORMAL 50-120) qualifiée comme '{pattern}'", - "ground_truth": "Créatinine 84 = NORMAL", - }) - break - - # Vérification 2: confrontation_bio cohérente - confrontation = result.get("confrontation_bio", []) - for entry in confrontation: - if not isinstance(entry, dict): - continue - verdict = str(entry.get("verdict", "")).upper() - test = str(entry.get("test", "")).lower() - valeur = entry.get("valeur") - - # Vérifier contre ground truth - for gt_test, gt_data in BIO_GROUND_TRUTH.items(): - if gt_test.lower() in test: - if gt_data["status"] == "NORMAL" and "confirmé" in verdict.lower(): - errors.append({ - "type": "CONFRONTATION_ERROR", - "severity": "CRITICAL", - "detail": f"{gt_test} = {gt_data['valeur']} (NORMAL) mais verdict = {verdict}", - "ground_truth": f"{gt_test} norme [{gt_data['norme_min']}-{gt_data['norme_max']}]", - }) - - # Vérification 3: codes_non_defendables - codes_nd = result.get("codes_non_defendables", []) - if isinstance(codes_nd, list): - # Vérifier que N17.9 (IRA) est signalé comme non défendable - # car créatinine 84 = NORMAL - nd_codes = [c.get("code", "") for c in codes_nd if isinstance(c, dict)] - - # Chercher si le modèle défend N17.9 malgré bio normale - moyens = result.get("moyens_defense", []) - for m in moyens: - if not isinstance(m, dict): - continue - titre = str(m.get("titre", "")).upper() - argument = str(m.get("argument", "")).upper() - for code in ["N17", "N19"]: - if code in titre or code in argument: - # Le modèle défend un code d'IR — vérifier la créatinine - if code not in " ".join(nd_codes): - errors.append({ - "type": "DEFENDS_UNDEFENDABLE", - "severity": "HIGH", - "detail": f"Code {code} (IRA/IR) défendu dans moyens_defense malgré créatinine 84 (NORMAL)", - "ground_truth": "Créatinine 84 = NORMAL → N17/N19 non défendable sur base bio", - }) - - return errors - - -def check_code_validity(result: dict) -> list[dict]: - """Vérifie que les codes CIM-10 utilisés sont plausibles.""" - import re - errors = [] - if not isinstance(result, dict): - return errors - - full_text = json.dumps(result, ensure_ascii=False) - # Extraire tous les codes CIM-10 mentionnés - codes = set(re.findall(r'\b([A-Z]\d{2}(?:\.\d{1,2})?)\b', full_text)) - - # Codes suspects connus - suspicious_codes = { - "Q61.9": "Maladie polykystique — probablement inventé pour Bricker fragile", - "Z45.80": "Code Z45.8 existe mais Z45.80 est suspect (vérifier)", - } - - for code in codes: - if code in suspicious_codes: - errors.append({ - "type": "SUSPICIOUS_CODE", - "severity": "MEDIUM", - "detail": f"Code {code}: {suspicious_codes[code]}", - }) - - return errors - - -def evaluate_tim_structure(result: dict) -> dict: - """Évalue la complétude de la structure TIM.""" - scores = {} - - if not is_tim_format(result): - return {"format": "LEGACY", "tim_compliant": False} - - scores["format"] = "TIM" - scores["tim_compliant"] = True - - # Champs obligatoires TIM - required_fields = [ - "objet", "rappel_faits", "moyens_defense", "confrontation_bio", - "asymetrie_information", "reponse_points_cpam", "codes_non_defendables", - "references", "conclusion_dispositive", - ] - - present = [] - missing = [] - for field in required_fields: - if result.get(field): - present.append(field) - else: - missing.append(field) - - scores["fields_present"] = len(present) - scores["fields_total"] = len(required_fields) - scores["fields_missing"] = missing - - # Qualité des moyens de défense - moyens = result.get("moyens_defense", []) - scores["moyens_count"] = len(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 - - scores["preuves_count"] = total_preuves - scores["preuves_with_ref"] = preuves_with_ref - - # Confrontation bio - confrontation = result.get("confrontation_bio", []) - scores["confrontation_count"] = len(confrontation) if isinstance(confrontation, list) else 0 - - # Codes non défendables - codes_nd = result.get("codes_non_defendables", []) - scores["codes_nd_count"] = len(codes_nd) if isinstance(codes_nd, list) else 0 - - # Références - refs = result.get("references", []) - scores["refs_count"] = len(refs) if isinstance(refs, list) else 0 - - # Conclusion dispositive - conclusion = result.get("conclusion_dispositive", "") - scores["conclusion_len"] = len(conclusion) - scores["has_maintien"] = "maintien" in conclusion.lower() if conclusion else False - - return scores - - -def run_benchmark_for_model(model_name: str, dossier_name: str) -> dict: - """Lance le pipeline CPAM complet pour un modèle donné.""" - from src.control.cpam_response import generate_cpam_response - from src.control.cpam_validation import _is_new_tim_format - - result_data = { - "model": model_name, - "dossier": dossier_name, - "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), - } - - # Charger le dossier - dossier = load_dossier(dossier_name) - if not dossier: - result_data["error"] = "Dossier non trouvé" - return result_data - - if not dossier.controles_cpam: - result_data["error"] = "Pas de contrôle CPAM" - return result_data - - controle = dossier.controles_cpam[0] - result_data["ogc"] = controle.numero_ogc - result_data["titre"] = controle.titre - - # Forcer le modèle - set_model(model_name) - - # Lancer le pipeline complet - logger.info("=" * 70) - logger.info("BENCHMARK : %s → dossier %s", model_name, dossier_name) - logger.info("=" * 70) - - t0 = time.time() - try: - text, parsed, rag_sources = generate_cpam_response(dossier, controle) - elapsed = time.time() - t0 - except Exception as e: - elapsed = time.time() - t0 - result_data["error"] = str(e) - result_data["elapsed_s"] = round(elapsed, 1) - logger.exception("Erreur pipeline pour %s", model_name) - return result_data - - result_data["elapsed_s"] = round(elapsed, 1) - result_data["text_len"] = len(text) - result_data["rag_sources"] = len(rag_sources) - result_data["quality_tier"] = controle.quality_tier or "?" - result_data["requires_review"] = controle.requires_review - - if parsed is None: - result_data["error"] = "LLM a retourné None" - result_data["json_valid"] = False - return result_data - - result_data["json_valid"] = True - result_data["is_tim"] = is_tim_format(parsed) - - # Évaluation structure TIM - tim_eval = evaluate_tim_structure(parsed) - result_data["tim_eval"] = tim_eval - - # Vérification cohérence bio - bio_errors = check_bio_coherence(parsed) - result_data["bio_errors"] = bio_errors - result_data["bio_errors_count"] = len(bio_errors) - result_data["bio_critical_count"] = len([e for e in bio_errors if e["severity"] == "CRITICAL"]) - - # Vérification codes - code_errors = check_code_validity(parsed) - result_data["code_errors"] = code_errors - result_data["code_errors_count"] = len(code_errors) - - # Sauvegarder la sortie brute - result_data["parsed_response"] = parsed - result_data["text_output"] = text[:3000] # Tronquer pour lisibilité - - return result_data - - -def print_summary(results: list[dict]): - """Affiche un tableau résumé comparatif.""" - print("\n" + "=" * 100) - print("BENCHMARK CPAM TIM — RÉSUMÉ COMPARATIF") - print("=" * 100) - - # En-tête - header = ( - f"{'Modèle':<25} {'JSON':>4} {'TIM':>4} {'Tier':>4} {'Temps':>7} " - f"{'Moyens':>6} {'Bio':>4} {'ND':>3} {'Refs':>4} {'Chars':>6} " - f"{'BioErr':>6} {'CritE':>5}" - ) - print(header) - print("-" * 100) - - for r in results: - if "error" in r and r.get("json_valid") is None: - print(f"{r['model']:<25} ERREUR: {r['error']}") - continue - - tim_eval = r.get("tim_eval", {}) - print( - f"{r['model']:<25} " - f"{'OK' if r.get('json_valid') else 'FAIL':>4} " - f"{'OK' if r.get('is_tim') else 'NO':>4} " - f"{r.get('quality_tier', '?'):>4} " - f"{r.get('elapsed_s', 0):>6.0f}s " - f"{tim_eval.get('moyens_count', 0):>6} " - f"{tim_eval.get('confrontation_count', 0):>4} " - f"{tim_eval.get('codes_nd_count', 0):>3} " - f"{tim_eval.get('refs_count', 0):>4} " - f"{r.get('text_len', 0):>6} " - f"{r.get('bio_errors_count', 0):>6} " - f"{r.get('bio_critical_count', 0):>5}" - ) - - # Détail des erreurs bio par modèle - print("\n" + "=" * 100) - print("DÉTAIL DES ERREURS BIOLOGIQUES") - print("=" * 100) - - for r in results: - errors = r.get("bio_errors", []) - if not errors: - print(f"\n{r['model']}: ✓ Aucune erreur bio détectée") - continue - - print(f"\n{r['model']}: ✗ {len(errors)} erreur(s)") - for e in errors: - severity_icon = "🔴" if e["severity"] == "CRITICAL" else "🟡" if e["severity"] == "HIGH" else "⚪" - print(f" {severity_icon} [{e['severity']}] {e['type']}: {e['detail']}") - if "ground_truth" in e: - print(f" Vérité terrain: {e['ground_truth']}") - - # Détail codes suspects - print("\n" + "=" * 100) - print("CODES CIM-10 SUSPECTS") - print("=" * 100) - - for r in results: - code_errors = r.get("code_errors", []) - if not code_errors: - print(f"\n{r['model']}: ✓ Aucun code suspect") - continue - print(f"\n{r['model']}: ✗ {len(code_errors)} code(s) suspect(s)") - for e in code_errors: - print(f" ⚠ {e['detail']}") - - # Champs TIM manquants - print("\n" + "=" * 100) - print("COMPLIANCE FORMAT TIM") - print("=" * 100) - - for r in results: - tim_eval = r.get("tim_eval", {}) - if not tim_eval: - print(f"\n{r['model']}: N/A") - continue - - missing = tim_eval.get("fields_missing", []) - total = tim_eval.get("fields_total", 9) - present = tim_eval.get("fields_present", 0) - - status = "✓ COMPLET" if not missing else f"✗ {present}/{total} champs" - print(f"\n{r['model']}: {status}") - if missing: - print(f" Manquants: {', '.join(missing)}") - - if tim_eval.get("has_maintien"): - print(f" ✓ Conclusion dispositive avec demande de maintien") - elif tim_eval.get("conclusion_len", 0) > 0: - print(f" ⚠ Conclusion présente ({tim_eval['conclusion_len']} chars) mais sans 'maintien'") - else: - print(f" ✗ Pas de conclusion dispositive") - - -def main(): - dossier_name = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_DOSSIER - - # Vérifier quels modèles sont disponibles - available = [] - unavailable = [] - for model in MODELS_TO_TEST: - if check_model_available(model): - available.append(model) - else: - unavailable.append(model) - - print(f"Modèles disponibles: {len(available)}/{len(MODELS_TO_TEST)}") - for m in available: - print(f" ✓ {m}") - for m in unavailable: - print(f" ✗ {m} (non trouvé)") - - if not available: - print("ERREUR: Aucun modèle local disponible") - sys.exit(1) - - print(f"\nDossier de test: {dossier_name}") - print(f"Début du benchmark...\n") - - results = [] - for model in available: - try: - result = run_benchmark_for_model(model, dossier_name) - results.append(result) - - # Sauvegarder les résultats intermédiaires - output_path = Path(__file__).parent / "output" / "benchmark_cpam_tim.json" - output_path.parent.mkdir(parents=True, exist_ok=True) - with open(output_path, "w", encoding="utf-8") as f: - json.dump(results, f, ensure_ascii=False, indent=2, default=str) - - except Exception as e: - logger.exception("Erreur fatale pour %s", model) - results.append({"model": model, "error": str(e)}) - - # Résumé comparatif - print_summary(results) - - # Sauvegarder les résultats finaux - output_path = Path(__file__).parent / "output" / "benchmark_cpam_tim.json" - with open(output_path, "w", encoding="utf-8") as f: - json.dump(results, f, ensure_ascii=False, indent=2, default=str) - print(f"\nRésultats détaillés sauvegardés dans: {output_path}") - - -if __name__ == "__main__": - main() diff --git a/config/completude_rules.yaml b/config/completude_rules.yaml new file mode 100644 index 0000000..11d06c4 --- /dev/null +++ b/config/completude_rules.yaml @@ -0,0 +1,437 @@ +# Règles de complétude documentaire DIM +# Pour chaque famille diagnostique : éléments obligatoires/recommandés +# qui doivent être présents dans le dossier pour justifier le code. +# +# Catégories : biologie | imagerie | document | acte | clinique +# Importance : obligatoire | recommande +# match_type : bio (biologie_cle.test), imagerie (imagerie.type), document (doc_types), +# clinique (sejour.imc/poids/taille), acte (actes_ccam) +# +# Seuils (optionnel) : confrontation valeur ↔ diagnostic +# type: below | above | range +# value / value_m / value_f / range_min / range_max +# message_ok / message_ko + +version: 2 + +# --- Règles par préfixe CIM-10 --- +diagnostics: + + denutrition: + prefixes: ["E43", "E44", "E46"] + libelle_famille: "Dénutrition" + items: + - categorie: biologie + element: Albumine + match_bio: ["albumine"] + importance: obligatoire + impact_cpam: "Albumine exigée par la CPAM pour valider une dénutrition (critère ATIH)" + seuil: + code_filter: "E43" + type: below + value: 30 + message_ok: "Albumine < 30 g/L confirme la dénutrition sévère" + message_ko: "Albumine ≥ 30 g/L : dénutrition sévère non confirmée biologiquement" + - categorie: biologie + element: Albumine + match_bio: ["albumine"] + importance: obligatoire + impact_cpam: "Albumine exigée pour dénutrition modérée" + seuil: + code_filter: "E44" + type: range + range_min: 30 + range_max: 35 + message_ok: "Albumine entre 30-35 g/L confirme la dénutrition modérée" + message_ko: "Albumine hors plage 30-35 g/L pour dénutrition modérée" + - categorie: clinique + element: IMC + match_clinique: imc + importance: obligatoire + impact_cpam: "IMC nécessaire pour classifier le degré de dénutrition" + seuil: + code_filter: "E43" + type: below + value: 18.5 + message_ok: "IMC < 18.5 confirme la dénutrition sévère" + message_ko: "IMC ≥ 18.5 : dénutrition sévère non confirmée" + - categorie: clinique + element: IMC + match_clinique: imc + importance: obligatoire + impact_cpam: "IMC nécessaire pour dénutrition modérée" + seuil: + code_filter: "E44" + type: range + range_min: 18.5 + range_max: 21 + message_ok: "IMC entre 18.5-21 confirme la dénutrition modérée" + message_ko: "IMC hors plage 18.5-21 pour dénutrition modérée" + - categorie: biologie + element: Préalbumine + match_bio: ["prealbumine", "préalbumine", "transthyretine", "transthyrétine"] + importance: recommande + impact_cpam: "Renforce la preuve de dénutrition si albumine limite" + + anemie: + prefixes: ["D50", "D62", "D63", "D64"] + libelle_famille: "Anémie" + items: + - categorie: biologie + element: Hémoglobine + match_bio: ["hemoglobine", "hémoglobine", "hb"] + importance: obligatoire + impact_cpam: "Hémoglobine indispensable pour confirmer et qualifier une anémie" + seuil: + type: below + value_m: 13 + value_f: 12 + message_ok: "Hémoglobine basse confirme l'anémie" + message_ko: "Hémoglobine normale : anémie non confirmée biologiquement" + - categorie: biologie + element: Ferritine + match_bio: ["ferritine"] + importance: recommande + impact_cpam: "Permet de typer l'anémie (carentielle vs inflammatoire)" + - categorie: biologie + element: VGM + match_bio: ["vgm", "volume globulaire moyen"] + importance: recommande + impact_cpam: "Oriente l'étiologie (microcytaire/macrocytaire)" + + insuffisance_renale: + prefixes: ["N17", "N18", "N19"] + libelle_famille: "Insuffisance rénale" + items: + - categorie: biologie + element: Créatinine + match_bio: ["creatinine", "créatinine", "creat"] + importance: obligatoire + impact_cpam: "Créatinine obligatoire pour confirmer une insuffisance rénale" + seuil: + type: above + value: 120 + message_ok: "Créatinine > 120 µmol/L confirme l'insuffisance rénale" + message_ko: "Créatinine ≤ 120 µmol/L : IR non confirmée biologiquement" + - categorie: biologie + element: DFG + match_bio: ["dfg", "clairance", "dfge", "débit de filtration"] + importance: recommande + impact_cpam: "Permet de stadifier l'IR selon KDIGO" + - categorie: biologie + element: Urée + match_bio: ["uree", "urée"] + importance: recommande + impact_cpam: "Élément complémentaire de la fonction rénale" + + sepsis: + prefixes: ["A40", "A41"] + libelle_famille: "Sepsis / Septicémie" + items: + - categorie: biologie + element: CRP + match_bio: ["crp", "proteine c reactive", "protéine c réactive"] + importance: obligatoire + impact_cpam: "Marqueur inflammatoire essentiel pour documenter un sepsis" + seuil: + type: above + value: 50 + message_ok: "CRP > 50 mg/L confirme le syndrome inflammatoire" + message_ko: "CRP ≤ 50 mg/L : syndrome inflammatoire non significatif" + - categorie: biologie + element: Leucocytes + match_bio: ["leucocytes", "gb", "globules blancs"] + importance: obligatoire + impact_cpam: "Leucocytose ou leucopénie attendue dans le sepsis" + seuil: + type: outside_range + range_min: 4 + range_max: 10 + message_ok: "Leucocytes hors norme (< 4 ou > 10 G/L) : compatible avec sepsis" + message_ko: "Leucocytes normaux (4-10 G/L) : sepsis non confirmé biologiquement" + - categorie: biologie + element: Procalcitonine + match_bio: ["procalcitonine", "pct"] + importance: recommande + impact_cpam: "Marqueur spécifique d'infection bactérienne, renforce la preuve" + - categorie: biologie + element: Hémocultures + match_bio: ["hemoculture", "hémoculture", "hemocultures", "hémocultures"] + importance: recommande + impact_cpam: "Documentation bactériologique du sepsis" + + troubles_electrolytiques: + prefixes: ["E87"] + libelle_famille: "Troubles électrolytiques" + items: + - categorie: biologie + element: Sodium + match_bio: ["sodium", "natremie", "natrémie", "na"] + importance: obligatoire + impact_cpam: "Ionogramme obligatoire pour justifier un trouble électrolytique" + seuil: + type: below + value: 135 + message_ok: "Sodium < 135 mmol/L confirme l'hyponatrémie" + message_ko: "Sodium ≥ 135 mmol/L : hyponatrémie non confirmée" + - categorie: biologie + element: Potassium + match_bio: ["potassium", "kaliemie", "kaliémie", "k"] + importance: obligatoire + impact_cpam: "Ionogramme obligatoire pour justifier un trouble électrolytique" + seuil: + type: outside_range + range_min: 3.5 + range_max: 5.0 + message_ok: "Potassium hors norme : trouble confirmé" + message_ko: "Potassium normal (3.5-5.0) : trouble non confirmé" + + diabete: + prefixes: ["E10", "E11"] + libelle_famille: "Diabète" + items: + - categorie: biologie + element: HbA1c + match_bio: ["hba1c", "hemoglobine glyquee", "hémoglobine glyquée"] + importance: recommande + impact_cpam: "HbA1c attendue pour documenter l'équilibre glycémique" + - categorie: biologie + element: Glycémie + match_bio: ["glycemie", "glycémie", "glucose"] + importance: recommande + impact_cpam: "Glycémie de base pour confirmer le diagnostic" + + pancreatite: + prefixes: ["K85"] + libelle_famille: "Pancréatite aiguë" + items: + - categorie: biologie + element: Lipasémie + match_bio: ["lipase", "lipasemie", "lipasémie"] + importance: obligatoire + impact_cpam: "Lipase > 3N est le critère diagnostique de référence" + seuil: + type: above + value: 180 + message_ok: "Lipase > 180 UI/L (3× la normale) confirme la pancréatite" + message_ko: "Lipase ≤ 180 UI/L : critère diagnostique non atteint" + - categorie: imagerie + element: Scanner abdominal + match_imagerie: ["scanner", "tdm", "tomodensitometrie"] + importance: recommande + impact_cpam: "Scanner recommandé pour évaluer la sévérité (Balthazar)" + + embolie_pulmonaire: + prefixes: ["I26"] + libelle_famille: "Embolie pulmonaire" + items: + - categorie: imagerie + element: Angioscanner thoracique + match_imagerie: ["angioscanner", "scanner", "tdm", "angiotdm"] + importance: obligatoire + impact_cpam: "Imagerie indispensable pour confirmer une EP" + - categorie: biologie + element: D-dimères + match_bio: ["d-dimeres", "d-dimères", "ddimeres", "d dimeres"] + importance: recommande + impact_cpam: "D-dimères utiles si négatifs pour exclure, non suffisants seuls" + + tumeurs_malignes: + prefixes: ["C"] + libelle_famille: "Tumeur maligne" + items: + - categorie: document + element: ANAPATH + match_document: ["anapath", "anatomopathologie", "biopsie"] + importance: obligatoire + impact_cpam: "Compte-rendu anatomopathologique exigé pour tout code C (tumeur maligne)" + + pathologies_hepatiques: + prefixes: ["K70", "K71", "K72", "K73", "K74", "K75", "K76", "K77"] + libelle_famille: "Pathologie hépatique" + items: + - categorie: biologie + element: ASAT + match_bio: ["asat", "got", "aspartate aminotransferase"] + importance: obligatoire + impact_cpam: "Bilan hépatique obligatoire pour documenter une atteinte hépatique" + seuil: + type: above + value: 40 + message_ok: "ASAT > 40 UI/L confirme la cytolyse hépatique" + message_ko: "ASAT ≤ 40 UI/L : cytolyse non confirmée" + - categorie: biologie + element: ALAT + match_bio: ["alat", "gpt", "alanine aminotransferase"] + importance: obligatoire + impact_cpam: "Bilan hépatique obligatoire" + seuil: + type: above + value: 40 + message_ok: "ALAT > 40 UI/L confirme la cytolyse hépatique" + message_ko: "ALAT ≤ 40 UI/L : cytolyse non confirmée" + - categorie: biologie + element: Bilirubine + match_bio: ["bilirubine", "bili"] + importance: recommande + impact_cpam: "Bilirubine renforce la documentation d'une atteinte hépatique" + + obesite: + prefixes: ["E66"] + libelle_famille: "Obésité" + items: + - categorie: clinique + element: IMC + match_clinique: imc + importance: obligatoire + impact_cpam: "IMC ≥ 30 indispensable pour coder une obésité" + seuil: + type: above + value: 30 + message_ok: "IMC ≥ 30 confirme l'obésité" + message_ko: "IMC < 30 : obésité non confirmée" + - categorie: clinique + element: Poids + match_clinique: poids + importance: obligatoire + impact_cpam: "Poids nécessaire pour calculer l'IMC" + + insuffisance_cardiaque: + prefixes: ["I50"] + libelle_famille: "Insuffisance cardiaque" + items: + - categorie: biologie + element: BNP / NT-proBNP + match_bio: ["bnp", "nt-probnp", "ntprobnp", "pro-bnp"] + importance: obligatoire + impact_cpam: "BNP/NT-proBNP attendu pour confirmer une insuffisance cardiaque" + seuil: + type: above + value: 100 + message_ok: "BNP > 100 pg/mL (ou NT-proBNP > 300) confirme l'IC" + message_ko: "BNP ≤ 100 pg/mL : IC non confirmée biologiquement" + - categorie: imagerie + element: Échographie cardiaque + match_imagerie: ["echographie cardiaque", "échocardiographie", "echo coeur", "ett", "eto"] + importance: recommande + impact_cpam: "ETT recommandée pour documenter la FEVG" + + # --- 8 NOUVELLES FAMILLES --- + + avc_ait: + prefixes: ["I60", "I61", "I62", "I63", "I64", "G45"] + libelle_famille: "AVC / AIT" + items: + - categorie: imagerie + element: Scanner/IRM cérébral + match_imagerie: ["scanner cerebral", "irm cerebral", "irm cérébral", "scanner cérébral", "tdm cerebral", "tdm cérébral", "irm encephalique", "irm encéphalique"] + importance: obligatoire + impact_cpam: "Imagerie cérébrale indispensable pour confirmer un AVC/AIT" + - categorie: biologie + element: ECG + match_bio: ["ecg", "electrocardiogramme", "électrocardiogramme"] + importance: recommande + impact_cpam: "ECG recommandé pour rechercher une cause cardioembolique" + + idm: + prefixes: ["I21", "I22"] + libelle_famille: "Infarctus du myocarde" + items: + - categorie: biologie + element: Troponine + match_bio: ["troponine", "tnc", "tni", "tnt", "troponine i", "troponine t", "troponine us"] + importance: obligatoire + impact_cpam: "Troponine obligatoire pour confirmer un IDM" + seuil: + type: above + value: 0.04 + message_ok: "Troponine > 0.04 confirme la nécrose myocardique" + message_ko: "Troponine ≤ 0.04 : IDM non confirmé biologiquement" + - categorie: biologie + element: ECG + match_bio: ["ecg", "electrocardiogramme", "électrocardiogramme"] + importance: recommande + impact_cpam: "ECG recommandé pour caractériser l'IDM (ST+/ST-)" + - categorie: imagerie + element: Coronarographie + match_imagerie: ["coronarographie", "coronaro", "coro"] + importance: recommande + impact_cpam: "Coronarographie recommandée pour documenter les lésions" + + pneumopathie: + prefixes: ["J12", "J13", "J14", "J15", "J16", "J17", "J18"] + libelle_famille: "Pneumopathie" + items: + - categorie: imagerie + element: Radio/Scanner thoracique + match_imagerie: ["radio thorax", "radiographie thoracique", "scanner thoracique", "tdm thoracique", "rx thorax", "radio pulmonaire"] + importance: obligatoire + impact_cpam: "Imagerie thoracique indispensable pour confirmer une pneumopathie" + - categorie: biologie + element: CRP + match_bio: ["crp", "proteine c reactive", "protéine c réactive"] + importance: recommande + impact_cpam: "CRP recommandée pour documenter le syndrome inflammatoire" + + tvp: + prefixes: ["I80"] + libelle_famille: "Thrombose veineuse profonde" + items: + - categorie: imagerie + element: Écho-doppler veineux + match_imagerie: ["echo-doppler", "écho-doppler", "echo doppler", "écho doppler", "doppler veineux", "echodoppler"] + importance: obligatoire + impact_cpam: "Écho-doppler veineux indispensable pour confirmer une TVP" + + insuffisance_respiratoire: + prefixes: ["J96"] + libelle_famille: "Insuffisance respiratoire" + items: + - categorie: biologie + element: Gaz du sang + match_bio: ["gaz du sang", "gazometrie", "gazométrie", "gds", "pao2", "paco2"] + importance: obligatoire + impact_cpam: "Gazométrie artérielle obligatoire pour confirmer une insuffisance respiratoire" + + fractures: + prefixes: ["S02", "S12", "S22", "S32", "S42", "S52", "S62", "S72", "S82", "S92"] + libelle_famille: "Fracture" + items: + - categorie: imagerie + element: Imagerie osseuse + match_imagerie: ["radio", "radiographie", "scanner", "tdm", "irm", "rx"] + importance: obligatoire + impact_cpam: "Imagerie indispensable pour confirmer une fracture" + + infection_urinaire: + prefixes: ["N39.0"] + libelle_famille: "Infection urinaire" + items: + - categorie: biologie + element: ECBU + match_bio: ["ecbu", "examen cytobacteriologique", "examen cytobactériologique"] + importance: obligatoire + impact_cpam: "ECBU obligatoire pour documenter une infection urinaire" + + fa_flutter: + prefixes: ["I48"] + libelle_famille: "Fibrillation auriculaire / Flutter" + items: + - categorie: biologie + element: ECG + match_bio: ["ecg", "electrocardiogramme", "électrocardiogramme"] + importance: obligatoire + impact_cpam: "ECG obligatoire pour documenter une FA/flutter" + +# --- Règles par préfixe CCAM (actes) --- +actes: + + chirurgie: + description: "Acte chirurgical nécessitant un CRO" + prefixes: ["H", "J", "K", "L"] + items: + - categorie: document + element: CRO + match_document: ["cro", "compte rendu operatoire", "compte-rendu opératoire"] + importance: obligatoire + impact_cpam: "Compte-rendu opératoire obligatoire pour tout acte chirurgical" diff --git a/config/diagnostic_conflicts.yaml b/config/diagnostic_conflicts.yaml index 1311ef5..a519202 100644 --- a/config/diagnostic_conflicts.yaml +++ b/config/diagnostic_conflicts.yaml @@ -26,9 +26,9 @@ mutual_exclusions: incompatibilities: - pair: ["E66", "E40", "E41", "E42", "E43", "E44", "E45", "E46"] - atih_ref: "Guide Méthodologique MCO : Incompatibilité clinique Obésité / Dénutrition" - message: "Obésité (E66) et Dénutrition/Malnutrition (E40-E46) sont cliniquement incompatibles" - severity: "HARD" + atih_ref: "HAS/FFN nov 2021 : un patient obèse peut être dénutri" + message: "Obésité et Dénutrition coexistent — vérifier critères HAS 2021 (perte de poids, sarcopénie, albumine)" + severity: "MEDIUM" - pair: ["I10", "I95"] atih_ref: "Guide Méthodologique MCO : Incohérence Hypertension / Hypotension sur le même séjour" diff --git a/data/referentiels/05a0fa6fce4c_doc.txt b/data/referentiels/05a0fa6fce4c_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/05a0fa6fce4c_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/069835fec62e_doc.txt b/data/referentiels/069835fec62e_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/069835fec62e_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/0888d9578448_doc.txt b/data/referentiels/0888d9578448_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/0888d9578448_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/125445ca1a8c_doc.txt b/data/referentiels/125445ca1a8c_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/125445ca1a8c_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/137b40623e82_doc.txt b/data/referentiels/137b40623e82_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/137b40623e82_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/1703dd92913f_doc.txt b/data/referentiels/1703dd92913f_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/1703dd92913f_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/194272ef6cb6_doc.txt b/data/referentiels/194272ef6cb6_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/194272ef6cb6_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/1a40d11c7ce2_doc.txt b/data/referentiels/1a40d11c7ce2_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/1a40d11c7ce2_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/1d2ca361391c_doc.txt b/data/referentiels/1d2ca361391c_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/1d2ca361391c_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/1dcd51d112fa_doc.txt b/data/referentiels/1dcd51d112fa_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/1dcd51d112fa_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/2959a910a2c7_doc.txt b/data/referentiels/2959a910a2c7_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/2959a910a2c7_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/296a7659371b_doc.txt b/data/referentiels/296a7659371b_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/296a7659371b_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/2cc26c4814fa_doc.txt b/data/referentiels/2cc26c4814fa_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/2cc26c4814fa_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/34a82abfeed8_doc.txt b/data/referentiels/34a82abfeed8_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/34a82abfeed8_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/355efa6754f3_doc.txt b/data/referentiels/355efa6754f3_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/355efa6754f3_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/38db1deeb44b_doc.txt b/data/referentiels/38db1deeb44b_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/38db1deeb44b_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/3b4263ae98ed_doc.txt b/data/referentiels/3b4263ae98ed_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/3b4263ae98ed_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/3be5e4086d28_doc.txt b/data/referentiels/3be5e4086d28_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/3be5e4086d28_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/3cf652bb995b_doc.txt b/data/referentiels/3cf652bb995b_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/3cf652bb995b_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/3d4843057817_doc.txt b/data/referentiels/3d4843057817_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/3d4843057817_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/3d66e17ba0d0_doc.txt b/data/referentiels/3d66e17ba0d0_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/3d66e17ba0d0_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/40ee4a3989a8_doc.txt b/data/referentiels/40ee4a3989a8_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/40ee4a3989a8_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/4482c0366139_doc.txt b/data/referentiels/4482c0366139_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/4482c0366139_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/494f0cbfc6c9_doc.txt b/data/referentiels/494f0cbfc6c9_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/494f0cbfc6c9_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/4cbf5a2f32e9_doc.txt b/data/referentiels/4cbf5a2f32e9_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/4cbf5a2f32e9_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/4cd7ef13d52b_doc.txt b/data/referentiels/4cd7ef13d52b_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/4cd7ef13d52b_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/54e1b1dd4a67_doc.txt b/data/referentiels/54e1b1dd4a67_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/54e1b1dd4a67_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/56f212d01d3b_doc.txt b/data/referentiels/56f212d01d3b_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/56f212d01d3b_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/5730828b446e_doc.txt b/data/referentiels/5730828b446e_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/5730828b446e_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/58861d936da0_doc.txt b/data/referentiels/58861d936da0_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/58861d936da0_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/58c9917aadc8_doc.txt b/data/referentiels/58c9917aadc8_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/58c9917aadc8_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/5bf7f469fb6e_doc.txt b/data/referentiels/5bf7f469fb6e_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/5bf7f469fb6e_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/5cb62535997e_doc.txt b/data/referentiels/5cb62535997e_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/5cb62535997e_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/5d8a689f1382_doc.txt b/data/referentiels/5d8a689f1382_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/5d8a689f1382_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/6399e1eb44f6_doc.txt b/data/referentiels/6399e1eb44f6_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/6399e1eb44f6_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/64fc5d4c3823_doc.txt b/data/referentiels/64fc5d4c3823_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/64fc5d4c3823_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/66d2a8635226_doc.txt b/data/referentiels/66d2a8635226_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/66d2a8635226_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/672069a8f736_doc.txt b/data/referentiels/672069a8f736_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/672069a8f736_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/6722da4cf32d_doc.txt b/data/referentiels/6722da4cf32d_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/6722da4cf32d_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/6bfb8994f705_doc.txt b/data/referentiels/6bfb8994f705_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/6bfb8994f705_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/729f6ee656dd_doc.txt b/data/referentiels/729f6ee656dd_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/729f6ee656dd_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/7301f806827c_doc.txt b/data/referentiels/7301f806827c_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/7301f806827c_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/76e115a64038_doc.txt b/data/referentiels/76e115a64038_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/76e115a64038_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/7a4733e0d5e4_doc.txt b/data/referentiels/7a4733e0d5e4_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/7a4733e0d5e4_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/8cb453139f03_doc.txt b/data/referentiels/8cb453139f03_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/8cb453139f03_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/8d328f086dca_doc.txt b/data/referentiels/8d328f086dca_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/8d328f086dca_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/8f013169f274_doc.txt b/data/referentiels/8f013169f274_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/8f013169f274_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/92678916f30a_doc.txt b/data/referentiels/92678916f30a_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/92678916f30a_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/95ff88932e4b_doc.txt b/data/referentiels/95ff88932e4b_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/95ff88932e4b_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/9868a897db14_doc.txt b/data/referentiels/9868a897db14_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/9868a897db14_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/99b5655320a3_doc.txt b/data/referentiels/99b5655320a3_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/99b5655320a3_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/9a744b5be9d9_doc.txt b/data/referentiels/9a744b5be9d9_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/9a744b5be9d9_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/9acdc87d76eb_doc.txt b/data/referentiels/9acdc87d76eb_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/9acdc87d76eb_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/9d7b10eb80ae_doc.txt b/data/referentiels/9d7b10eb80ae_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/9d7b10eb80ae_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/a2085a755e76_doc.txt b/data/referentiels/a2085a755e76_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/a2085a755e76_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/a7890918b179_doc.txt b/data/referentiels/a7890918b179_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/a7890918b179_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/aa47e9df4c87_doc.txt b/data/referentiels/aa47e9df4c87_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/aa47e9df4c87_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/acdc545f7084_doc.txt b/data/referentiels/acdc545f7084_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/acdc545f7084_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/af465fd5f46c_doc.txt b/data/referentiels/af465fd5f46c_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/af465fd5f46c_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/b91b3d1137ea_doc.txt b/data/referentiels/b91b3d1137ea_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/b91b3d1137ea_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/b99eab98827b_doc.txt b/data/referentiels/b99eab98827b_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/b99eab98827b_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/baad196b3922_doc.txt b/data/referentiels/baad196b3922_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/baad196b3922_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/c23aaacb2500_doc.txt b/data/referentiels/c23aaacb2500_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/c23aaacb2500_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/c2416aa58551_doc.txt b/data/referentiels/c2416aa58551_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/c2416aa58551_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/c374b1367f9b_doc.txt b/data/referentiels/c374b1367f9b_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/c374b1367f9b_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/c727c72f961f_doc.txt b/data/referentiels/c727c72f961f_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/c727c72f961f_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/ceb092e53be9_doc.txt b/data/referentiels/ceb092e53be9_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/ceb092e53be9_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/d730316cc3c9_doc.txt b/data/referentiels/d730316cc3c9_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/d730316cc3c9_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/d90325a71d7f_doc.txt b/data/referentiels/d90325a71d7f_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/d90325a71d7f_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/de4ddff34d9d_doc.txt b/data/referentiels/de4ddff34d9d_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/de4ddff34d9d_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/ccam_dict.json b/data/referentiels/dicts/ccam_dict.json similarity index 100% rename from data/ccam_dict.json rename to data/referentiels/dicts/ccam_dict.json diff --git a/data/cim10_dict.json b/data/referentiels/dicts/cim10_dict.json similarity index 100% rename from data/cim10_dict.json rename to data/referentiels/dicts/cim10_dict.json diff --git a/data/cim10_supplements.json b/data/referentiels/dicts/cim10_supplements.json similarity index 100% rename from data/cim10_supplements.json rename to data/referentiels/dicts/cim10_supplements.json diff --git a/data/referentiels/e05e34fcbd72_doc.txt b/data/referentiels/e05e34fcbd72_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/e05e34fcbd72_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/e1c432b8205c_doc.txt b/data/referentiels/e1c432b8205c_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/e1c432b8205c_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/e7d7bc0d6442_doc.txt b/data/referentiels/e7d7bc0d6442_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/e7d7bc0d6442_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/eb0097daee70_doc.txt b/data/referentiels/eb0097daee70_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/eb0097daee70_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/ed2f9419f888_doc.txt b/data/referentiels/ed2f9419f888_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/ed2f9419f888_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/ed9b09aa85d1_doc.txt b/data/referentiels/ed9b09aa85d1_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/ed9b09aa85d1_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/f0f535fff6dd_doc.txt b/data/referentiels/f0f535fff6dd_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/f0f535fff6dd_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/f8df0a0c88ae_doc.txt b/data/referentiels/f8df0a0c88ae_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/f8df0a0c88ae_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/fafe9322c8b6_doc.txt b/data/referentiels/fafe9322c8b6_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/fafe9322c8b6_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/fd9ce3d5ab4a_doc.txt b/data/referentiels/fd9ce3d5ab4a_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/fd9ce3d5ab4a_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/fdca908e8386_doc.txt b/data/referentiels/fdca908e8386_doc.txt deleted file mode 100644 index 08cf610..0000000 --- a/data/referentiels/fdca908e8386_doc.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file diff --git a/data/referentiels/index.json b/data/referentiels/index.json deleted file mode 100644 index 719ace8..0000000 --- a/data/referentiels/index.json +++ /dev/null @@ -1,962 +0,0 @@ -[ - { - "id": "ac8013d3cc19", - "filename": "cocoa_2025.pdf", - "stored_name": "ac8013d3cc19_cocoa_2025.pdf", - "extension": ".pdf", - "size_bytes": 10957912, - "date_added": "2026-02-13T18:49:21.460420", - "status": "indexed", - "chunks_count": 1451 - }, - { - "id": "74a91bddbbc5", - "filename": "Annexe-4_Diagnostics_classes_CMA_V11e_1.pdf", - "stored_name": "74a91bddbbc5_Annexe-4_Diagnostics_classes_CMA_V11e_1.pdf", - "extension": ".pdf", - "size_bytes": 2628897, - "date_added": "2026-02-13T18:56:50.591977", - "status": "indexed", - "chunks_count": 119 - }, - { - "id": "a41d8e848d83", - "filename": "Arbre_decision_GHM_V11e.pdf", - "stored_name": "a41d8e848d83_Arbre_decision_GHM_V11e.pdf", - "extension": ".pdf", - "size_bytes": 865310, - "date_added": "2026-02-13T18:57:38.442496", - "status": "indexed", - "chunks_count": 39 - }, - { - "id": "eac6ef00d4b1", - "filename": "Fascicule_01_Generalites.pdf", - "stored_name": "eac6ef00d4b1_Fascicule_01_Generalites.pdf", - "extension": ".pdf", - "size_bytes": 227388, - "date_added": "2026-02-13T18:57:57.012085", - "status": "indexed", - "chunks_count": 15 - }, - { - "id": "98796a23a254", - "filename": "Fascicule_02_Maladies_digestives.pdf", - "stored_name": "98796a23a254_Fascicule_02_Maladies_digestives.pdf", - "extension": ".pdf", - "size_bytes": 1997744, - "date_added": "2026-02-13T18:58:00.128820", - "status": "indexed", - "chunks_count": 11 - }, - { - "id": "d84f93e576e2", - "filename": "Fascicule_03_Tumeurs.pdf", - "stored_name": "d84f93e576e2_Fascicule_03_Tumeurs.pdf", - "extension": ".pdf", - "size_bytes": 190486, - "date_added": "2026-02-13T18:58:02.888607", - "status": "indexed", - "chunks_count": 8 - }, - { - "id": "b7fc87c608fb", - "filename": "Fascicule_04_Metabolisme.pdf", - "stored_name": "b7fc87c608fb_Fascicule_04_Metabolisme.pdf", - "extension": ".pdf", - "size_bytes": 215837, - "date_added": "2026-02-13T18:58:04.955721", - "status": "indexed", - "chunks_count": 9 - }, - { - "id": "d684a79fbe67", - "filename": "Fascicule_05_Gyneco_Obstetrique.pdf", - "stored_name": "d684a79fbe67_Fascicule_05_Gyneco_Obstetrique.pdf", - "extension": ".pdf", - "size_bytes": 202202, - "date_added": "2026-02-13T18:58:07.846589", - "status": "indexed", - "chunks_count": 13 - }, - { - "id": "4104b8e4fd8a", - "filename": "Fascicule_06_Neonatalogie.pdf", - "stored_name": "4104b8e4fd8a_Fascicule_06_Neonatalogie.pdf", - "extension": ".pdf", - "size_bytes": 210932, - "date_added": "2026-02-13T18:58:11.260714", - "status": "indexed", - "chunks_count": 3 - }, - { - "id": "0abf0d45e8a1", - "filename": "Fascicule_07_Evolutions_2010.pdf", - "stored_name": "0abf0d45e8a1_Fascicule_07_Evolutions_2010.pdf", - "extension": ".pdf", - "size_bytes": 186738, - "date_added": "2026-02-13T18:58:14.876505", - "status": "indexed", - "chunks_count": 8 - }, - { - "id": "9263bc2f01a3", - "filename": "Fascicule_08_Maladies_infectieuses.pdf", - "stored_name": "9263bc2f01a3_Fascicule_08_Maladies_infectieuses.pdf", - "extension": ".pdf", - "size_bytes": 350016, - "date_added": "2026-02-13T18:58:16.917217", - "status": "indexed", - "chunks_count": 13 - }, - { - "id": "f2ad7f16b33c", - "filename": "Fascicule_09_AVC.pdf", - "stored_name": "f2ad7f16b33c_Fascicule_09_AVC.pdf", - "extension": ".pdf", - "size_bytes": 232964, - "date_added": "2026-02-13T18:58:20.531239", - "status": "indexed", - "chunks_count": 12 - }, - { - "id": "c3d95a59830d", - "filename": "Fascicule_10_SCA_Coronariens.pdf", - "stored_name": "c3d95a59830d_Fascicule_10_SCA_Coronariens.pdf", - "extension": ".pdf", - "size_bytes": 1154051, - "date_added": "2026-02-13T18:58:23.595682", - "status": "indexed", - "chunks_count": 14 - }, - { - "id": "e3f9b22577e9", - "filename": "Instruction_DGOS_controle_T2A_2025.pdf", - "stored_name": "e3f9b22577e9_Instruction_DGOS_controle_T2A_2025.pdf", - "extension": ".pdf", - "size_bytes": 183370, - "date_added": "2026-02-13T18:58:27.824485", - "status": "indexed", - "chunks_count": 6 - }, - { - "id": "ec634b8c41b7", - "filename": "Racines_GHM_V11e.pdf", - "stored_name": "ec634b8c41b7_Racines_GHM_V11e.pdf", - "extension": ".pdf", - "size_bytes": 1038277, - "date_added": "2026-02-13T18:58:29.172516", - "status": "indexed", - "chunks_count": 28 - }, - { - "id": "3d66e17ba0d0", - "filename": "doc.txt", - "stored_name": "3d66e17ba0d0_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-13T19:19:27.599390", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "aa47e9df4c87", - "filename": "doc.txt", - "stored_name": "aa47e9df4c87_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-15T09:32:01.870981", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "76e115a64038", - "filename": "doc.txt", - "stored_name": "76e115a64038_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-15T11:01:08.160353", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "4482c0366139", - "filename": "doc.txt", - "stored_name": "4482c0366139_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-15T11:16:42.415804", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "2cc26c4814fa", - "filename": "doc.txt", - "stored_name": "2cc26c4814fa_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-15T11:34:08.451232", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "64fc5d4c3823", - "filename": "doc.txt", - "stored_name": "64fc5d4c3823_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-16T09:06:21.240083", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "acdc545f7084", - "filename": "doc.txt", - "stored_name": "acdc545f7084_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-16T09:22:08.212507", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "ed9b09aa85d1", - "filename": "doc.txt", - "stored_name": "ed9b09aa85d1_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-16T10:00:36.048389", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "40ee4a3989a8", - "filename": "doc.txt", - "stored_name": "40ee4a3989a8_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-16T16:13:58.459392", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "b91b3d1137ea", - "filename": "doc.txt", - "stored_name": "b91b3d1137ea_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-16T18:05:10.123359", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "4cbf5a2f32e9", - "filename": "doc.txt", - "stored_name": "4cbf5a2f32e9_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-17T15:35:18.585381", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "e1c432b8205c", - "filename": "doc.txt", - "stored_name": "e1c432b8205c_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-17T15:38:12.028798", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "5d8a689f1382", - "filename": "doc.txt", - "stored_name": "5d8a689f1382_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-17T17:50:27.245228", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "56f212d01d3b", - "filename": "doc.txt", - "stored_name": "56f212d01d3b_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-17T20:45:27.099123", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "8f013169f274", - "filename": "doc.txt", - "stored_name": "8f013169f274_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-17T21:38:16.120598", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "1703dd92913f", - "filename": "doc.txt", - "stored_name": "1703dd92913f_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-17T21:38:38.344136", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "54e1b1dd4a67", - "filename": "doc.txt", - "stored_name": "54e1b1dd4a67_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-17T22:38:56.083967", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "34a82abfeed8", - "filename": "doc.txt", - "stored_name": "34a82abfeed8_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-17T23:06:34.960186", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "c2416aa58551", - "filename": "doc.txt", - "stored_name": "c2416aa58551_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-18T01:14:46.112702", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "1d2ca361391c", - "filename": "doc.txt", - "stored_name": "1d2ca361391c_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-18T01:33:22.494456", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "d90325a71d7f", - "filename": "doc.txt", - "stored_name": "d90325a71d7f_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-18T16:14:48.107850", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "069835fec62e", - "filename": "doc.txt", - "stored_name": "069835fec62e_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-18T16:59:24.203902", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "fdca908e8386", - "filename": "doc.txt", - "stored_name": "fdca908e8386_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-18T17:09:19.975724", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "1a40d11c7ce2", - "filename": "doc.txt", - "stored_name": "1a40d11c7ce2_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-18T17:16:38.902039", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "c23aaacb2500", - "filename": "doc.txt", - "stored_name": "c23aaacb2500_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-18T17:47:58.846313", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "194272ef6cb6", - "filename": "doc.txt", - "stored_name": "194272ef6cb6_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-18T18:12:01.478708", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "ceb092e53be9", - "filename": "doc.txt", - "stored_name": "ceb092e53be9_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-18T19:07:00.583443", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "7a4733e0d5e4", - "filename": "doc.txt", - "stored_name": "7a4733e0d5e4_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-18T20:08:57.513991", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "137b40623e82", - "filename": "doc.txt", - "stored_name": "137b40623e82_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-18T20:52:56.476132", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "ed2f9419f888", - "filename": "doc.txt", - "stored_name": "ed2f9419f888_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-18T20:56:16.490848", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "8cb453139f03", - "filename": "doc.txt", - "stored_name": "8cb453139f03_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-18T22:02:43.776660", - "status": "empty", - "chunks_count": 0 - }, - { - "id": "99b5655320a3", - "filename": "doc.txt", - "stored_name": "99b5655320a3_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-19T21:49:41.866454", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "1dcd51d112fa", - "filename": "doc.txt", - "stored_name": "1dcd51d112fa_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-19T21:55:50.222804", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "f0f535fff6dd", - "filename": "doc.txt", - "stored_name": "f0f535fff6dd_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-20T00:32:36.876520", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "95ff88932e4b", - "filename": "doc.txt", - "stored_name": "95ff88932e4b_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-20T00:37:19.557847", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "c374b1367f9b", - "filename": "doc.txt", - "stored_name": "c374b1367f9b_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-20T00:54:58.766985", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "3d4843057817", - "filename": "doc.txt", - "stored_name": "3d4843057817_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-20T07:53:19.319030", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "a7890918b179", - "filename": "doc.txt", - "stored_name": "a7890918b179_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-20T08:34:04.709457", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "2959a910a2c7", - "filename": "doc.txt", - "stored_name": "2959a910a2c7_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-20T08:58:43.251311", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "6bfb8994f705", - "filename": "doc.txt", - "stored_name": "6bfb8994f705_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-20T09:41:44.435821", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "af465fd5f46c", - "filename": "doc.txt", - "stored_name": "af465fd5f46c_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-20T09:49:12.702389", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "9acdc87d76eb", - "filename": "doc.txt", - "stored_name": "9acdc87d76eb_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-20T10:05:41.099435", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "fd9ce3d5ab4a", - "filename": "doc.txt", - "stored_name": "fd9ce3d5ab4a_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-20T10:13:19.388621", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "d730316cc3c9", - "filename": "doc.txt", - "stored_name": "d730316cc3c9_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-20T11:00:27.449271", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "729f6ee656dd", - "filename": "doc.txt", - "stored_name": "729f6ee656dd_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-20T13:05:32.351072", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "5cb62535997e", - "filename": "doc.txt", - "stored_name": "5cb62535997e_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-20T13:09:17.380478", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "e05e34fcbd72", - "filename": "doc.txt", - "stored_name": "e05e34fcbd72_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-20T13:25:48.234786", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "3be5e4086d28", - "filename": "doc.txt", - "stored_name": "3be5e4086d28_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-20T13:28:18.714479", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "05a0fa6fce4c", - "filename": "doc.txt", - "stored_name": "05a0fa6fce4c_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-20T13:30:49.983388", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "355efa6754f3", - "filename": "doc.txt", - "stored_name": "355efa6754f3_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-20T13:33:19.957742", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "296a7659371b", - "filename": "doc.txt", - "stored_name": "296a7659371b_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-20T13:54:25.789025", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "a2085a755e76", - "filename": "doc.txt", - "stored_name": "a2085a755e76_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-20T15:18:26.416055", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "0888d9578448", - "filename": "doc.txt", - "stored_name": "0888d9578448_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-20T15:36:22.929880", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "9868a897db14", - "filename": "doc.txt", - "stored_name": "9868a897db14_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-23T10:52:01.419915", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "92678916f30a", - "filename": "doc.txt", - "stored_name": "92678916f30a_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-23T11:58:45.783806", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "672069a8f736", - "filename": "doc.txt", - "stored_name": "672069a8f736_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-24T00:57:54.123633", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "58c9917aadc8", - "filename": "doc.txt", - "stored_name": "58c9917aadc8_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-24T01:28:00.491272", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "c727c72f961f", - "filename": "doc.txt", - "stored_name": "c727c72f961f_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-24T01:32:52.129247", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "eb0097daee70", - "filename": "doc.txt", - "stored_name": "eb0097daee70_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-24T09:08:38.795651", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "3cf652bb995b", - "filename": "doc.txt", - "stored_name": "3cf652bb995b_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-24T09:48:18.303211", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "baad196b3922", - "filename": "doc.txt", - "stored_name": "baad196b3922_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-24T11:23:58.954922", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "fafe9322c8b6", - "filename": "doc.txt", - "stored_name": "fafe9322c8b6_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-24T11:44:19.935971", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "125445ca1a8c", - "filename": "doc.txt", - "stored_name": "125445ca1a8c_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-24T12:10:28.989034", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "4cd7ef13d52b", - "filename": "doc.txt", - "stored_name": "4cd7ef13d52b_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-24T12:17:00.137647", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "9d7b10eb80ae", - "filename": "doc.txt", - "stored_name": "9d7b10eb80ae_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-24T12:20:52.693361", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "66d2a8635226", - "filename": "doc.txt", - "stored_name": "66d2a8635226_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-24T12:23:23.223763", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "3b4263ae98ed", - "filename": "doc.txt", - "stored_name": "3b4263ae98ed_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-24T12:25:52.874338", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "494f0cbfc6c9", - "filename": "doc.txt", - "stored_name": "494f0cbfc6c9_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-24T12:28:23.446703", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "6722da4cf32d", - "filename": "doc.txt", - "stored_name": "6722da4cf32d_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-24T12:30:52.978054", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "e7d7bc0d6442", - "filename": "doc.txt", - "stored_name": "e7d7bc0d6442_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-24T12:33:24.030133", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "38db1deeb44b", - "filename": "doc.txt", - "stored_name": "38db1deeb44b_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-24T12:36:50.360844", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "b99eab98827b", - "filename": "doc.txt", - "stored_name": "b99eab98827b_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-24T13:13:46.029012", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "58861d936da0", - "filename": "doc.txt", - "stored_name": "58861d936da0_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-24T13:33:03.652242", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "8d328f086dca", - "filename": "doc.txt", - "stored_name": "8d328f086dca_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-24T14:04:15.520073", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "5bf7f469fb6e", - "filename": "doc.txt", - "stored_name": "5bf7f469fb6e_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-24T14:24:17.970507", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "f8df0a0c88ae", - "filename": "doc.txt", - "stored_name": "f8df0a0c88ae_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-24T14:29:08.330734", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "6399e1eb44f6", - "filename": "doc.txt", - "stored_name": "6399e1eb44f6_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-24T17:43:59.407587", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "de4ddff34d9d", - "filename": "doc.txt", - "stored_name": "de4ddff34d9d_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-24T17:48:19.167531", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "7301f806827c", - "filename": "doc.txt", - "stored_name": "7301f806827c_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-02-25T13:55:10.377377", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "9a744b5be9d9", - "filename": "doc.txt", - "stored_name": "9a744b5be9d9_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-03-04T10:47:16.045845", - "status": "uploaded", - "chunks_count": 0 - }, - { - "id": "5730828b446e", - "filename": "doc.txt", - "stored_name": "5730828b446e_doc.txt", - "extension": ".txt", - "size_bytes": 12, - "date_added": "2026-03-04T10:49:41.399210", - "status": "uploaded", - "chunks_count": 0 - } -] \ No newline at end of file diff --git a/data/referentiels/user/index.json b/data/referentiels/user/index.json new file mode 100644 index 0000000..19333ac --- /dev/null +++ b/data/referentiels/user/index.json @@ -0,0 +1,302 @@ +[ + { + "id": "ac8013d3cc19", + "filename": "cocoa_2025.pdf", + "stored_name": "pdfs/cocoa_2025.pdf", + "extension": ".pdf", + "size_bytes": 10957912, + "date_added": "2026-02-13T18:49:21.460420", + "status": "indexed", + "chunks_count": 1451 + }, + { + "id": "74a91bddbbc5", + "filename": "Annexe-4_Diagnostics_classes_CMA_V11e_1.pdf", + "stored_name": "pdfs/Annexe-4_Diagnostics_classes_CMA_V11e_1.pdf", + "extension": ".pdf", + "size_bytes": 2628897, + "date_added": "2026-02-13T18:56:50.591977", + "status": "indexed", + "chunks_count": 119 + }, + { + "id": "a41d8e848d83", + "filename": "Arbre_decision_GHM_V11e.pdf", + "stored_name": "pdfs/Arbre_decision_GHM_V11e.pdf", + "extension": ".pdf", + "size_bytes": 865310, + "date_added": "2026-02-13T18:57:38.442496", + "status": "indexed", + "chunks_count": 39 + }, + { + "id": "eac6ef00d4b1", + "filename": "Fascicule_01_Generalites.pdf", + "stored_name": "pdfs/Fascicule_01_Generalites.pdf", + "extension": ".pdf", + "size_bytes": 227388, + "date_added": "2026-02-13T18:57:57.012085", + "status": "indexed", + "chunks_count": 15 + }, + { + "id": "98796a23a254", + "filename": "Fascicule_02_Maladies_digestives.pdf", + "stored_name": "pdfs/Fascicule_02_Maladies_digestives.pdf", + "extension": ".pdf", + "size_bytes": 1997744, + "date_added": "2026-02-13T18:58:00.128820", + "status": "indexed", + "chunks_count": 11 + }, + { + "id": "d84f93e576e2", + "filename": "Fascicule_03_Tumeurs.pdf", + "stored_name": "pdfs/Fascicule_03_Tumeurs.pdf", + "extension": ".pdf", + "size_bytes": 190486, + "date_added": "2026-02-13T18:58:02.888607", + "status": "indexed", + "chunks_count": 8 + }, + { + "id": "b7fc87c608fb", + "filename": "Fascicule_04_Metabolisme.pdf", + "stored_name": "pdfs/Fascicule_04_Metabolisme.pdf", + "extension": ".pdf", + "size_bytes": 215837, + "date_added": "2026-02-13T18:58:04.955721", + "status": "indexed", + "chunks_count": 9 + }, + { + "id": "d684a79fbe67", + "filename": "Fascicule_05_Gyneco_Obstetrique.pdf", + "stored_name": "pdfs/Fascicule_05_Gyneco_Obstetrique.pdf", + "extension": ".pdf", + "size_bytes": 202202, + "date_added": "2026-02-13T18:58:07.846589", + "status": "indexed", + "chunks_count": 13 + }, + { + "id": "4104b8e4fd8a", + "filename": "Fascicule_06_Neonatalogie.pdf", + "stored_name": "pdfs/Fascicule_06_Neonatalogie.pdf", + "extension": ".pdf", + "size_bytes": 210932, + "date_added": "2026-02-13T18:58:11.260714", + "status": "indexed", + "chunks_count": 3 + }, + { + "id": "0abf0d45e8a1", + "filename": "Fascicule_07_Evolutions_2010.pdf", + "stored_name": "pdfs/Fascicule_07_Evolutions_2010.pdf", + "extension": ".pdf", + "size_bytes": 186738, + "date_added": "2026-02-13T18:58:14.876505", + "status": "indexed", + "chunks_count": 8 + }, + { + "id": "9263bc2f01a3", + "filename": "Fascicule_08_Maladies_infectieuses.pdf", + "stored_name": "pdfs/Fascicule_08_Maladies_infectieuses.pdf", + "extension": ".pdf", + "size_bytes": 350016, + "date_added": "2026-02-13T18:58:16.917217", + "status": "indexed", + "chunks_count": 13 + }, + { + "id": "f2ad7f16b33c", + "filename": "Fascicule_09_AVC.pdf", + "stored_name": "pdfs/Fascicule_09_AVC.pdf", + "extension": ".pdf", + "size_bytes": 232964, + "date_added": "2026-02-13T18:58:20.531239", + "status": "indexed", + "chunks_count": 12 + }, + { + "id": "c3d95a59830d", + "filename": "Fascicule_10_SCA_Coronariens.pdf", + "stored_name": "pdfs/Fascicule_10_SCA_Coronariens.pdf", + "extension": ".pdf", + "size_bytes": 1154051, + "date_added": "2026-02-13T18:58:23.595682", + "status": "indexed", + "chunks_count": 14 + }, + { + "id": "e3f9b22577e9", + "filename": "Instruction_DGOS_controle_T2A_2025.pdf", + "stored_name": "pdfs/Instruction_DGOS_controle_T2A_2025.pdf", + "extension": ".pdf", + "size_bytes": 183370, + "date_added": "2026-02-13T18:58:27.824485", + "status": "indexed", + "chunks_count": 6 + }, + { + "id": "ec634b8c41b7", + "filename": "Racines_GHM_V11e.pdf", + "stored_name": "pdfs/Racines_GHM_V11e.pdf", + "extension": ".pdf", + "size_bytes": 1038277, + "date_added": "2026-02-13T18:58:29.172516", + "status": "indexed", + "chunks_count": 28 + }, + { + "id": "8d141123df27", + "filename": "CIM10_Annexe6_Codes_Etendus.pdf", + "stored_name": "pdfs/CIM10_Annexe6_Codes_Etendus.pdf", + "extension": ".pdf", + "size_bytes": 617770, + "date_added": "2026-03-07T15:28:28.027628", + "status": "indexed", + "chunks_count": 20 + }, + { + "id": "fb17b67c2def", + "filename": "Consignes_Maladies_infectieuses_Sepsis_2023.pdf", + "stored_name": "pdfs/Consignes_Maladies_infectieuses_Sepsis_2023.pdf", + "extension": ".pdf", + "size_bytes": 350016, + "date_added": "2026-03-07T15:28:28.086201", + "status": "indexed", + "chunks_count": 13 + }, + { + "id": "dc7433384451", + "filename": "Guide_Auditabilite_Recettes_T2A_MCO_DGOS.pdf", + "stored_name": "pdfs/Guide_Auditabilite_Recettes_T2A_MCO_DGOS.pdf", + "extension": ".pdf", + "size_bytes": 518327, + "date_added": "2026-03-07T15:28:28.095581", + "status": "indexed", + "chunks_count": 22 + }, + { + "id": "641736ac58d3", + "filename": "Guide_Controle_Externe_T2A_MCO_2018.pdf", + "stored_name": "pdfs/Guide_Controle_Externe_T2A_MCO_2018.pdf", + "extension": ".pdf", + "size_bytes": 1396760, + "date_added": "2026-03-07T15:28:28.102021", + "status": "indexed", + "chunks_count": 85 + }, + { + "id": "69e3549934f7", + "filename": "Manuel_GHM_V2025_Vol1.pdf", + "stored_name": "pdfs/Manuel_GHM_V2025_Vol1.pdf", + "extension": ".pdf", + "size_bytes": 2585227, + "date_added": "2026-03-07T15:28:28.129026", + "status": "indexed", + "chunks_count": 197 + }, + { + "id": "a1bf62b5ab59", + "filename": "Modifications_CIM10_2025.pdf", + "stored_name": "pdfs/Modifications_CIM10_2025.pdf", + "extension": ".pdf", + "size_bytes": 148331, + "date_added": "2026-03-07T15:28:28.160163", + "status": "indexed", + "chunks_count": 1 + }, + { + "id": "2b5d80882f11", + "filename": "Notice_Technique_PMSI_2025_Complement.pdf", + "stored_name": "pdfs/Notice_Technique_PMSI_2025_Complement.pdf", + "extension": ".pdf", + "size_bytes": 809283, + "date_added": "2026-03-07T15:28:28.166667", + "status": "indexed", + "chunks_count": 33 + }, + { + "id": "4711362e73e0", + "filename": "Notice_Technique_PMSI_2026.pdf", + "stored_name": "pdfs/Notice_Technique_PMSI_2026.pdf", + "extension": ".pdf", + "size_bytes": 500882, + "date_added": "2026-03-07T15:28:28.179923", + "status": "indexed", + "chunks_count": 25 + }, + { + "id": "c4b9e698487f", + "filename": "Precisions_Emploi_Codes_CIM10.pdf", + "stored_name": "pdfs/Precisions_Emploi_Codes_CIM10.pdf", + "extension": ".pdf", + "size_bytes": 107249, + "date_added": "2026-03-07T15:28:28.194446", + "status": "indexed", + "chunks_count": 3 + }, + { + "id": "0484aaaa7894", + "filename": "CIM10_Annexe7_Codes_Etendus.pdf", + "stored_name": "pdfs/CIM10_Annexe7_Codes_Etendus.pdf", + "extension": ".pdf", + "size_bytes": 320255, + "date_added": "2026-03-07T15:28:28.034227", + "status": "indexed", + "chunks_count": 10 + }, + { + "id": "bc5f2ddb482d", + "filename": "Consignes_COVID19_2021.pdf", + "stored_name": "pdfs/Consignes_COVID19_2021.pdf", + "extension": ".pdf", + "size_bytes": 410286, + "date_added": "2026-03-07T15:28:28.057809", + "status": "indexed", + "chunks_count": 22 + }, + { + "id": "04f31fa9c177", + "filename": "Consignes_Evenements_Thromboemboliques_2018.pdf", + "stored_name": "pdfs/Consignes_Evenements_Thromboemboliques_2018.pdf", + "extension": ".pdf", + "size_bytes": 670783, + "date_added": "2026-03-07T15:28:28.064644", + "status": "indexed", + "chunks_count": 4 + }, + { + "id": "a26a76317880", + "filename": "Consignes_Infections_Site_Operatoire_2018.pdf", + "stored_name": "pdfs/Consignes_Infections_Site_Operatoire_2018.pdf", + "extension": ".pdf", + "size_bytes": 662484, + "date_added": "2026-03-07T15:28:28.070314", + "status": "indexed", + "chunks_count": 4 + }, + { + "id": "7f51d4aa2563", + "filename": "Instruction_DGOS_Controle_T2A_Campagne_2025.pdf", + "stored_name": "pdfs/Instruction_DGOS_Controle_T2A_Campagne_2025.pdf", + "extension": ".pdf", + "size_bytes": 183370, + "date_added": "2026-03-07T15:28:28.109600", + "status": "indexed", + "chunks_count": 6 + }, + { + "id": "0e7f5da15e05", + "filename": "Manuel_GHM_V2026_Vol1_Provisoire.pdf", + "stored_name": "pdfs/Manuel_GHM_V2026_Vol1_Provisoire.pdf", + "extension": ".pdf", + "size_bytes": 2645392, + "date_added": "2026-03-07T15:28:28.144893", + "status": "indexed", + "chunks_count": 193 + } +] \ No newline at end of file diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..6dc07e3 --- /dev/null +++ b/gunicorn.conf.py @@ -0,0 +1,8 @@ +"""Configuration Gunicorn pour T2A Viewer.""" + +bind = "0.0.0.0:7500" +workers = 2 +timeout = 300 +accesslog = "-" +errorlog = "-" +loglevel = "info" diff --git a/output/benchmark_cpam_tim.json b/output/benchmark_cpam_tim.json deleted file mode 100644 index cc31d5e..0000000 --- a/output/benchmark_cpam_tim.json +++ /dev/null @@ -1,1105 +0,0 @@ -[ - { - "model": "gemma3:27b", - "dossier": "183_23087212", - "timestamp": "2026-03-04 14:24:33", - "ogc": 183, - "titre": "Désaccord sur le DP et DAS", - "elapsed_s": 849.6, - "text_len": 3759, - "rag_sources": 0, - "quality_tier": "C", - "requires_review": true, - "json_valid": true, - "is_tim": false, - "tim_eval": { - "format": "LEGACY", - "tim_compliant": false - }, - "bio_errors": [], - "bio_errors_count": 0, - "bio_critical_count": 0, - "code_errors": [], - "code_errors_count": 0, - "parsed_response": { - "rappel_faits": "Le patient a été hospitalisé pour une obstruction des voies urinaires et une hydronéphrose préexistante. Face à l'impossibilité de rétablir le drainage naturel, une néphrostomie a été posée. L'hospitalisation a duré [durée non précisée]. L'objectif était de décomprimer le système urinaire et de gérer les complications potentielles.", - "asymetrie_information": "La CPAM n'avait pas accès aux détails de l'imagerie [[IMG-1]] montrant l'obstruction et la nécessité de la néphrostomie, ni aux comptes rendus opératoires détaillant la complexité de l'intervention.", - "reponse_points_cpam": "Nous reconnaissons que la pose d'une néphrostomie est bien une intervention réalisée pendant l'hospitalisation. Cependant, nous maintenons que l'hydronéphrose préexistante est la cause sous-jacente de l'obstruction et que la néphrostomie est une solution palliative.", - "conclusion_dispositive": "Par conséquent, au vu des éléments cliniques objectifs (imagerie [[IMG-1]]), des règles CIM-10 applicables, et des informations complémentaires non transmises à l'UCR, nous demandons le MAINTIEN du codage : DP E66.04 (Obésité), DAS Z00.0, N10, N19, T83.1, I10, N17.9, Z43.3. Nous reconnaissons que le code N13.1 ne dispose pas d'un support documentaire suffisant et demandons son retrait.", - "codes_non_defendables": [ - { - "code": "N13.1", - "raison": "Ce code n'apparaît pas dans les diagnostics de l'établissement.", - "recommandation": "Retrait recommandé — code indéfendable face à la CPAM" - } - ], - "references": [ - { - "document": "IMG-1", - "page": "N/A", - "citation": "Absence totale de passage entre l'uretère gauche et le Bricker, Bricker extrêmement fragile, très légèrement endommagé." - } - ], - "confrontation_bio": [ - { - "valeur": "Créatinine", - "résultat": "84", - "normal": "Oui" - }, - { - "valeur": "Leucocytes", - "résultat": "8.18", - "normal": "Oui" - }, - { - "valeur": "Sodium", - "résultat": "140", - "normal": "Oui" - } - ] - }, - "text_output": "⚠ REVUE MANUELLE REQUISE (Qualité : C)\n\nREFERENCES\n- [IMG-1, p.N/A] Absence totale de passage entre l'uretère gauche et le Bricker, Bricker extrêmement fragile, très légèrement endommagé.\n\nAVERTISSEMENTS CRITIQUES\n- [CRITIQUE] Score adversarial très bas : 2/10\n\nAVERTISSEMENTS MINEURS\n- [MINEUR] Incohérence : ERREUR CRITIQUE 1 : Incohérence majeure sur le DP. La réponse défend E66.04 (Obésité) comme DP, mais les codes contestés mentionnent Z45.80 (suivi de dispositif implanté). Le DP réel du dossier devrait être lié à l'obstruction urinaire/hydronéphrose/néphrostomie, pas l'obésité qui est un facteur comorbide.\n- [MINEUR] Incohérence : ERREUR CRITIQUE 2 : Le code N13.1 (Hydronéphrose avec obstruction) est reconnu comme 'indéfendable' dans 'codes_non_defendables', mais la réponse le MAINTIENT dans la conclusion dispositive en demandant son 'retrait recommandé'. Cette contradiction logique invalide la conclusion.\n- [MINEUR] Incohérence : ERREUR CRITIQUE 3 : Absence totale de justification du DP E66.04. L'IMC 31.231 justifie une obésité, mais dans un contexte d'hospitalisation pour obstruction urinaire/néphrostomie, ce diagnostic ne peut être DP. Aucune documentation ne montre que l'obésité était le motif principal d'hospitalisation.\n- [MINEUR] Incohérence : ERREUR 4 : Le code Z43.3 (colostomie) apparaît dans les DAS, mais aucun élément du dossier ne mentionne une colostomie. Le patient a un Bricker (dérivation urinaire), pas une colostomie. Code potentiellement frauduleux.\n- [MINEUR] Incohérence : ERREUR 5 : Asymétrie d'information mal présentée. La réponse prétend que 'la CPAM n'avait pas accès' à IMG-1, mais IMG-1 est citée dans les références du dossier lui-même. Cet argument est factuellement faux.\n- [MINEUR] Incohérence : ERREUR 6 : DAS-1 codé Z00.0 (examen médical sans anomalie) est incohérent avec DAS-2 (pyélonéphrite sévère N10). Un patient avec pyélonéphrite n'a pas un examen 'sans anomalie'.\n- [MINEUR] Incohérence : ERREUR 7 : Redondance diagnostique non justifiée : N19 (insuffisance rénale non spécifiée) ET N17.9 (insuffisance rénale) sont tous deux codés. Seul un code d'insuffisance rénale devrait être utilisé, avec spécification du stade.\n- [MINEUR] Incohérence : ERREUR 8 : La confrontation_bio cite 3 valeurs (Créatinine, Leucocytes, Sodium) comme normales, ce qui est exact, mais omet de mentionner que TOUTES les valeurs biologiques du dossier sont normales. Cela ne soutient PAS une pyélonéphrite sévère (N10) qui devrait montrer une CRP élevée, une leucocytose, ou une créatinine élevée.\n- [MINEUR] Incohérence : ERREUR 9 : Incohérence clinique majeure : le diagnostic de 'pyélonéphrite sévère' (N10) n'est soutenu par AUCUN marqueur biologique. CRP, leucocytes, créatinine : tous normaux. Ceci est factuellement indéfendable.\n- [MINEUR] Incohérence : ERREUR 10 : Le code T83.1 (lésion traumatique du Bricker) est justifié par IMG-1 qui mentionne un Bricker 'très légèrement endommagé', mais coder une lésion 'très légère' comme T83.1 (traumatism" - }, - { - "model": "gemma3:27b-it-qat", - "dossier": "183_23087212", - "timestamp": "2026-03-04 14:38:43", - "ogc": 183, - "titre": "Désaccord sur le DP et DAS", - "elapsed_s": 655.3, - "text_len": 8673, - "rag_sources": 0, - "quality_tier": "C", - "requires_review": true, - "json_valid": true, - "is_tim": true, - "tim_eval": { - "format": "TIM", - "tim_compliant": true, - "fields_present": 6, - "fields_total": 9, - "fields_missing": [ - "confrontation_bio", - "codes_non_defendables", - "references" - ], - "moyens_count": 4, - "preuves_count": 4, - "preuves_with_ref": 4, - "confrontation_count": 0, - "codes_nd_count": 0, - "refs_count": 0, - "conclusion_len": 507, - "has_maintien": true - }, - "bio_errors": [], - "bio_errors_count": 0, - "bio_critical_count": 0, - "code_errors": [ - { - "type": "SUSPICIOUS_CODE", - "severity": "MEDIUM", - "detail": "Code Z45.80: Code Z45.8 existe mais Z45.80 est suspect (vérifier)" - } - ], - "code_errors_count": 1, - "parsed_response": { - "objet": "Contestation Désaccord sur le DP et DAS — OGC 183 — Mémoire en défense", - "rappel_faits": "Le patient a été hospitalisé pour la pose d'une néphrostomie suite à une obstruction de l'uretère gauche. Les interventions réalisées incluent une urétéroscopie et une Brickeroscopie. L'hospitalisation a duré 7 jours et s'est terminée par la pose d'une néphrostomie fonctionnelle. Le patient est conscient du risque de destruction progressive du rein gauche en cas de retrait de la néphrostomie.", - "moyens_defense": [ - { - "numero": 1, - "titre": "Le Diagnostic Principal Z45.80 (Ajustement et entretien d’une prothèse interne) est justifié par l’acte principal réalisé pendant l’hospitalisation", - "argument": "L'intervention principale réalisée pendant l'hospitalisation est la pose d'une néphrostomie, suivie de sa surveillance et de son entretien. L'imagerie [[IMG-1]] démontre clairement l'absence de passage entre l'uretère gauche et le Bricker, justifiant la nécessité de cette intervention. Le code Z45.80 reflète donc l'acte majeur pris en charge durant le séjour. La Règle T7 du guide méthodologique préconise de coder l'acte principal réalisé pendant l'hospitalisation.", - "preuves": [ - { - "ref": "[IMG-1]", - "fait": "Absence de passage entre l'uretère gauche et le Bricker", - "signification": "Justification de la pose de la néphrostomie" - } - ], - "source_reglementaire": "null" - }, - { - "numero": 2, - "titre": "Le Diagnostic Associé N13.1 (Hydronéphrose avec rétrécissement urétéral, non classée ailleurs) est pertinent en raison de l'obstruction urétérale préexistante", - "argument": "L'hydronéphrose avec rétrécissement urétéral est la cause sous-jacente de l'obstruction qui a nécessité la pose de la néphrostomie. Bien que préexistante, cette condition a directement conduit à l'hospitalisation et aux interventions réalisées. Le code N13.1 permet de documenter cette pathologie contributive.", - "preuves": [ - { - "ref": "[IMG-1]", - "fait": "Absence de passage entre l'uretère gauche et le Bricker", - "signification": "Indication d'une obstruction urétérale" - } - ], - "source_reglementaire": "null" - }, - { - "numero": 3, - "titre": "Le Diagnostic Associé I10 (Hypertension essentielle (primitive)) est pertinent en tant que comorbidité", - "argument": "L'hypertension est une comorbidité significative qui peut influencer la prise en charge du patient et le risque de complications post-opératoires. Sa présence doit être documentée pour une meilleure gestion globale du patient.", - "preuves": [ - { - "ref": "[COMPL-N]", - "fait": "Mention de l'hypertension dans les antécédents du patient", - "signification": "Comorbidité pertinente" - } - ], - "source_reglementaire": "null" - }, - { - "numero": 4, - "titre": "Le Diagnostic Associé T83.1 (Complication mécanique d'autres prothèses et implants urinaires) est pertinent en raison de la lésion traumatique du Bricker suite à la Brickeroscopie", - "argument": "La Brickeroscopie a entraîné une lésion traumatique du Bricker, constituant une complication iatrogène de l'intervention. Le code T83.1 permet de documenter cet événement indésirable et sa prise en charge.", - "preuves": [ - { - "ref": "[DAS-4]", - "fait": "Mention de la lésion traumatique du Bricker suite à la Brickeroscopie", - "signification": "Complication iatrogène" - } - ], - "source_reglementaire": "null" - } - ], - "confrontation_bio": [], - "asymetrie_information": "La CPAM n'avait pas accès aux détails précis de l'imagerie [[IMG-1]] montrant l'obstruction urétérale, ni aux comptes rendus opératoires détaillant les complications de la Brickeroscopie. De plus, elle n'avait pas connaissance de la présence d'une hypertension chez le patient.", - "reponse_points_cpam": "La CPAM a raison de souligner que la pose de la néphrostomie est l'acte principal réalisé pendant l'hospitalisation. Cependant, le code Z43.6 (Surveillance d'autres stomies des voies urinaires) ne reflète pas l'ensemble de la prise en charge, notamment la gestion de l'obstruction urétérale sous-jacente et les complications de l'intervention. Nous maintenons donc le code Z45.80 (Ajustement et entretien d’une prothèse interne) comme DP plus approprié.", - "codes_non_defendables": [], - "references": [], - "conclusion_dispositive": "Par conséquent, au vu des éléments cliniques objectifs (obstruction urétérale confirmée par l'imagerie [[IMG-1]]), des règles CIM-10 applicables, et des informations complémentaires non transmises à l'UCR, nous demandons le MAINTIEN du codage : DP Z45.80 (Ajustement et entretien d’une prothèse interne), DAS N13.1 (Hydronéphrose avec rétrécissement urétéral, non classée ailleurs), DAS I10 (Hypertension essentielle (primitive)), DAS T83.1 (Complication mécanique d'autres prothèses et implants urinaires)." - }, - "text_output": "═══════════════════════════════════════════════════════\nMÉMOIRE EN DÉFENSE — Contestation Désaccord sur le DP et DAS — OGC 183 — Mémoire en défense\n═══════════════════════════════════════════════════════\n\n⚠ REVUE MANUELLE REQUISE (Qualité : C)\n\nRAPPEL DES FAITS\nLe patient a été hospitalisé pour la pose d'une néphrostomie suite à une obstruction de l'uretère gauche. Les interventions réalisées incluent une urétéroscopie et une Brickeroscopie. L'hospitalisation a duré 7 jours et s'est terminée par la pose d'une néphrostomie fonctionnelle. Le patient est conscient du risque de destruction progressive du rein gauche en cas de retrait de la néphrostomie.\n\n───────────────────────────────────────────────────────\n\nMOYEN N°1 — Le Diagnostic Principal Z45.80 (Ajustement et entretien d’une prothèse interne) est justifié par l’acte principal réalisé pendant l’hospitalisation\nL'intervention principale réalisée pendant l'hospitalisation est la pose d'une néphrostomie, suivie de sa surveillance et de son entretien. L'imagerie [[IMG-1]] démontre clairement l'absence de passage entre l'uretère gauche et le Bricker, justifiant la nécessité de cette intervention. Le code Z45.80 reflète donc l'acte majeur pris en charge durant le séjour. La Règle T7 du guide méthodologique préconise de coder l'acte principal réalisé pendant l'hospitalisation.\n Preuve : [IMG-1] Absence de passage entre l'uretère gauche et le Bricker → Justification de la pose de la néphrostomie\n\nMOYEN N°2 — Le Diagnostic Associé N13.1 (Hydronéphrose avec rétrécissement urétéral, non classée ailleurs) est pertinent en raison de l'obstruction urétérale préexistante\nL'hydronéphrose avec rétrécissement urétéral est la cause sous-jacente de l'obstruction qui a nécessité la pose de la néphrostomie. Bien que préexistante, cette condition a directement conduit à l'hospitalisation et aux interventions réalisées. Le code N13.1 permet de documenter cette pathologie contributive.\n Preuve : [IMG-1] Absence de passage entre l'uretère gauche et le Bricker → Indication d'une obstruction urétérale\n\nMOYEN N°3 — Le Diagnostic Associé I10 (Hypertension essentielle (primitive)) est pertinent en tant que comorbidité\nL'hypertension est une comorbidité significative qui peut influencer la prise en charge du patient et le risque de complications post-opératoires. Sa présence doit être documentée pour une meilleure gestion globale du patient.\n Preuve : [COMPL-N] Mention de l'hypertension dans les antécédents du patient → Comorbidité pertinente\n\nMOYEN N°4 — Le Diagnostic Associé T83.1 (Complication mécanique d'autres prothèses et implants urinaires) est pertinent en raison de la lésion traumatique du Bricker suite à la Brickeroscopie\nLa Brickeroscopie a entraîné une lésion traumatique du Bricker, constituant une complication iatrogène de l'intervention. Le code T83.1 permet de documenter cet événement indésirable et sa prise en charge.\n Preuve : [DAS-4] Mention de la lésion traumatique du Bricker suite à la Brickeroscopie " - }, - { - "model": "qwen3:32b", - "dossier": "183_23087212", - "timestamp": "2026-03-04 14:49:38", - "ogc": 183, - "titre": "Désaccord sur le DP et DAS", - "elapsed_s": 302.0, - "text_len": 6715, - "rag_sources": 0, - "quality_tier": "C", - "requires_review": true, - "json_valid": true, - "is_tim": true, - "tim_eval": { - "format": "TIM", - "tim_compliant": true, - "fields_present": 9, - "fields_total": 9, - "fields_missing": [], - "moyens_count": 1, - "preuves_count": 2, - "preuves_with_ref": 2, - "confrontation_count": 1, - "codes_nd_count": 1, - "refs_count": 1, - "conclusion_len": 467, - "has_maintien": true - }, - "bio_errors": [ - { - "type": "CONFRONTATION_ERROR", - "severity": "CRITICAL", - "detail": "Créatinine = 84 (NORMAL) mais verdict = CONFIRMÉ", - "ground_truth": "Créatinine norme [50-120]" - } - ], - "bio_errors_count": 1, - "bio_critical_count": 1, - "code_errors": [ - { - "type": "SUSPICIOUS_CODE", - "severity": "MEDIUM", - "detail": "Code Z45.80: Code Z45.8 existe mais Z45.80 est suspect (vérifier)" - } - ], - "code_errors_count": 1, - "parsed_response": { - "objet": "Contestation Désaccord sur le DP et DAS — OGC 183 — Mémoire en défense", - "rappel_faits": "Le patient a été hospitalisé pour une hydronéphrose liée à un rétrécissement urétéral. Des actes d'exploration ont été réalisés, notamment une échographie et une urographie. La durée du séjour était de 5 jours, et l'issue a été la mise en place d'une néphrostomie.", - "moyens_defense": [ - { - "numero": 1, - "titre": "Le DP Z45.80 est justifié par l'entretien de la prothèse urinaire", - "argument": "Le patient a nécessité l'ajustement et l'entretien d'une prothèse interne urinaire, ce qui justifie le code Z45.80. Cela est confirmé par les actes réalisés et les complications mécaniques constatées.", - "preuves": [ - { - "ref": "[ACTE-1]", - "fait": "Mise en place d'une néphrostomie", - "signification": "Nécessité d'entretien de la prothèse" - }, - { - "ref": "[DAS-1]", - "fait": "Complication mécanique d'autres prothèses et implants urinaires (T83.1)", - "signification": "Justification de l'entretien de la prothèse" - } - ], - "source_reglementaire": "[Document - page 12] 'Z45.80 — Ajustement et entretien d’une prothèse interne urinaire'" - } - ], - "confrontation_bio": [ - { - "diagnostic": "Z45.80", - "test": "Créatinine", - "valeur": 120, - "seuil": "50-120", - "verdict": "CONFIRMÉ" - } - ], - "asymetrie_information": "La CPAM n'avait pas accès aux résultats complets de l'imagerie et aux détails des actes chirurgicaux réalisés.", - "reponse_points_cpam": "La CPAM a raison de souligner que l'hydronéphrose connue avant l'hospitalisation ne peut pas être le DP. Cependant, le code Z45.80 est justifié par l'entretien de la prothèse urinaire, confirmé par les actes réalisés.", - "codes_non_defendables": [ - { - "code": "N13.1", - "raison": "L'hydronéphrose est connue avant l'hospitalisation et ne peut pas être le DP", - "recommandation": "Retrait recommandé — code indéfendable face à la CPAM" - } - ], - "references": [ - { - "document": "Dictionnaire CIM-10", - "page": "45", - "citation": "Z45.80 — Ajustement et entretien d’une prothèse interne urinaire" - } - ], - "conclusion_dispositive": "Par conséquent, au vu des éléments cliniques objectifs (citer les preu1ves clés), des règles CIM-10 applicables (citer les sources), et des informations complémentaires non transmises à l'UCR, nous demandons le MAINTIEN du codage : DP Z45.80 — Ajustement et entretien d’une prothèse interne urinaire, DAS N13.1 — Hydronéphrose avec rétrécissement urétéral, non classée ailleurs. Nous reconnaissons que le code N13.1 ne dispose pas d'un support documentaire suffisant." - }, - "text_output": "═══════════════════════════════════════════════════════\nMÉMOIRE EN DÉFENSE — Contestation Désaccord sur le DP et DAS — OGC 183 — Mémoire en défense\n═══════════════════════════════════════════════════════\n\n⚠ REVUE MANUELLE REQUISE (Qualité : C)\n\nRAPPEL DES FAITS\nLe patient a été hospitalisé pour une hydronéphrose liée à un rétrécissement urétéral. Des actes d'exploration ont été réalisés, notamment une échographie et une urographie. La durée du séjour était de 5 jours, et l'issue a été la mise en place d'une néphrostomie.\n\n───────────────────────────────────────────────────────\n\nMOYEN N°1 — Le DP Z45.80 est justifié par l'entretien de la prothèse urinaire\nLe patient a nécessité l'ajustement et l'entretien d'une prothèse interne urinaire, ce qui justifie le code Z45.80. Cela est confirmé par les actes réalisés et les complications mécaniques constatées.\n Preuve : [ACTE-1] Mise en place d'une néphrostomie → Nécessité d'entretien de la prothèse\n Preuve : [DAS-1] Complication mécanique d'autres prothèses et implants urinaires (T83.1) → Justification de l'entretien de la prothèse\n Source : [Document - page 12] 'Z45.80 — Ajustement et entretien d’une prothèse interne urinaire'\n\n───────────────────────────────────────────────────────\n\nCONFRONTATION BIOLOGIE / DIAGNOSTIC\n┌─────────────────┬─────────────┬──────────────┬───────────┬───────────────┐\n│ Diagnostic │ Test requis │ Seuil │ Valeur │ Verdict │\n├─────────────────┼─────────────┼──────────────┼───────────┼───────────────┤\n│ Z45.80 │ Créatinine │ 50-120 │ 120 │ CONFIRMÉ │\n└─────────────────┴─────────────┴──────────────┴───────────┴───────────────┘\n\n───────────────────────────────────────────────────────\n\n⚠ CODES NON DÉFENDABLES (honnêteté intellectuelle)\n- N13.1 : L'hydronéphrose est connue avant l'hospitalisation et ne peut pas être le DP\n → Retrait recommandé — code indéfendable face à la CPAM\n\n───────────────────────────────────────────────────────\n\nASYMÉTRIE D'INFORMATION\nLa CPAM n'avait pas accès aux résultats complets de l'imagerie et aux détails des actes chirurgicaux réalisés.\n\n───────────────────────────────────────────────────────\n\nRÉPONSE AUX POINTS DE LA CPAM\nLa CPAM a raison de souligner que l'hydronéphrose connue avant l'hospitalisation ne peut pas être le DP. Cependant, le code Z45.80 est justifié par l'entretien de la prothèse urinaire, confirmé par les actes réalisés.\n\n───────────────────────────────────────────────────────\n\nRÉFÉRENCES RÉGLEMENTAIRES\n- [Dictionnaire CIM-10, p.45] Z45.80 — Ajustement et entretien d’une prothèse interne urinaire\n\n═══════════════════════════════════════════════════════\n\nCONCLUSION\nPar conséquent, au vu des éléments cliniques objectifs (citer les preu1ves clés), des règles CIM-10 applicables (citer les sources), et des informations complémentaires non transmises à l'UCR, nous demandons le MAINTIEN du codage : DP Z45.80 — Ajustement et entretien d’une prothèse interne urinaire, DAS N13.1 — Hydronéphr" - }, - { - "model": "qwen3:14b", - "dossier": "183_23087212", - "timestamp": "2026-03-04 14:54:40", - "ogc": 183, - "titre": "Désaccord sur le DP et DAS", - "elapsed_s": 27.6, - "text_len": 2436, - "rag_sources": 0, - "quality_tier": "C", - "requires_review": true, - "json_valid": true, - "is_tim": false, - "tim_eval": { - "format": "LEGACY", - "tim_compliant": false - }, - "bio_errors": [], - "bio_errors_count": 0, - "bio_critical_count": 0, - "code_errors": [], - "code_errors_count": 0, - "parsed_response": {}, - "text_output": "⚠ REVUE MANUELLE REQUISE (Qualité : C)\n\nAVERTISSEMENTS CRITIQUES\n- [CRITIQUE] Score adversarial très bas : 1/10\n\nAVERTISSEMENTS MINEURS\n- [MINEUR] Incohérence : ABSENCE CRITIQUE : Le champ 'RÉPONSE GÉNÉRÉE' est vide ({}). Aucun mémoire en défense n'a été fourni pour relecture. Impossible de vérifier les moyens de défense, la cohérence argumentative ou la qualité de la défense CPAM.\n- [MINEUR] Incohérence : INCOHÉRENCE CODES : Les codes contestés (DP UCR: Z45.80 et DA UCR: N13.1) ne sont pas présents dans le diagnostic principal [DP] ni dans les diagnostics associés [DAS-1 à DAS-7]. Le DP documenté est E66.04 (Obésité), pas Z45.80. Cette absence rend impossible la vérification de la défense sur ces codes spécifiques.\n- [MINEUR] Incohérence : DONNÉES BIOLOGIQUES INCOMPLÈTES : Plusieurs valeurs biologiques critiques manquent (urée, CRP, VS, ASAT, ALAT, etc.) alors qu'elles seraient pertinentes pour justifier une pyélonéphrite sévère [DAS-2: N10] et une insuffisance rénale [DAS-6: N17.9].\n- [MINEUR] Incohérence : CHLORE NON INTERPRÉTÉ : [BIO-3] Chlore: 110 — aucune norme fournie dans les normes de référence. Impossible de vérifier si cette valeur est normale ou pathologique.\n- [MINEUR] Incohérence : VGM NON INTERPRÉTÉ : [BIO-5] VGM: 91.8 — aucune norme fournie. Impossible de qualifier cette valeur.\n- [MINEUR] Incohérence : DOCUMENTATION CLINIQUE FRAGMENTÉE : [IMG-1] contient du texte tronqué ('permettant de de', 'a permis de c') rendant l'interprétation de l'imagerie impossible. Les conclusions cliniques ne peuvent pas être vérifiées.\n- [MINEUR] Incohérence : ANTÉCÉDENTS INCOMPLETS : [ANT-1] à [ANT-7] sont des fragments de texte sans contexte clair. [ANT-7] est vide. Impossible de vérifier la cohérence du consentement éclairé ou des options thérapeutiques documentées.\n- [MINEUR] Incohérence : ABSENCE DE JUSTIFICATION DIAGNOSTIQUE : Aucune preuve traçable ne lie les valeurs biologiques normales aux diagnostics graves codifiés (pyélonéphrite sévère N10, insuffisance rénale N17.9). Les biologiques [BIO-1] à [BIO-9] sont tous normaux, ce qui contredit potentiellement la sévérité clinique revendiquée.\n- [MINEUR] Incohérence : INCOHÉRENCE CLINIQUE MAJEURE : Un patient avec une 'pyélonéphrite sévère' et une 'insuffisance rénale' devrait présenter une créatinine élevée, une urée élevée, une CRP élevée et une VS élevée. [BIO-8] Créatinine: 84 est NORMAL. Cette normalité n'est pas expliquée dans le dossier." - }, - { - "model": "mistral-small3.2:24b", - "dossier": "183_23087212", - "timestamp": "2026-03-04 14:55:08", - "ogc": 183, - "titre": "Désaccord sur le DP et DAS", - "elapsed_s": 429.9, - "text_len": 8655, - "rag_sources": 0, - "quality_tier": "C", - "requires_review": true, - "json_valid": true, - "is_tim": true, - "tim_eval": { - "format": "TIM", - "tim_compliant": true, - "fields_present": 9, - "fields_total": 9, - "fields_missing": [], - "moyens_count": 3, - "preuves_count": 6, - "preuves_with_ref": 6, - "confrontation_count": 2, - "codes_nd_count": 2, - "refs_count": 1, - "conclusion_len": 616, - "has_maintien": true - }, - "bio_errors": [ - { - "type": "CONFRONTATION_ERROR", - "severity": "CRITICAL", - "detail": "Créatinine = 84 (NORMAL) mais verdict = NON CONFIRMÉ (VALEUR NORMALE)", - "ground_truth": "Créatinine norme [50-120]" - }, - { - "type": "CONFRONTATION_ERROR", - "severity": "CRITICAL", - "detail": "Créatinine = 84 (NORMAL) mais verdict = NON CONFIRMÉ (VALEUR NORMALE)", - "ground_truth": "Créatinine norme [50-120]" - } - ], - "bio_errors_count": 2, - "bio_critical_count": 2, - "code_errors": [], - "code_errors_count": 0, - "parsed_response": { - "objet": "Contestation Désaccord sur le DP et DAS — OGC 183 — Mémoire en défense", - "rappel_faits": "Patient hospitalisé pour hydronéphrose obstructive secondaire à une complication mécanique d'une prothèse urinaire (Bricker). Actes réalisés : urétéroscopie et Brickeroscopie. Issue : mise en place d'une néphrostomie. Durée : non précisée.", - "moyens_defense": [ - { - "numero": 1, - "titre": "Le DP E66.04 (Obésité) est justifié par l'IMC et son rôle dans la complication", - "argument": "L'obésité (IMC = 35) est un facteur de risque avéré pour les complications mécaniques des prothèses urinaires. Le compte-rendu clinique mentionne explicitement son implication dans la pathologie actuelle.", - "preuves": [ - { - "ref": "[DP]", - "fait": "Obésité documentée avec IMC = 35", - "signification": "Facteur de risque majeur pour la complication mécanique" - }, - { - "ref": "[ANT-1]", - "fait": "Antécédents d'obésité et de complications associées", - "signification": "Contexte clinique cohérent" - } - ], - "source_reglementaire": null - }, - { - "numero": 2, - "titre": "Le DAS T83.1 (Complication mécanique de prothèse urinaire) est objectivement documenté", - "argument": "Le TDM abdominal et les actes d'urétéroscopie/Brickeroscopie confirment la complication mécanique de la prothèse urinaire, justifiant ce code.", - "preuves": [ - { - "ref": "[IMG-1]", - "fait": "TDM montrant obstruction urétérale liée à la prothèse", - "signification": "Preuve objective de la complication" - }, - { - "ref": "[ACTE-1]", - "fait": "Actes d'urétéroscopie et Brickeroscopie pour gestion de la complication", - "signification": "Preuve de la prise en charge spécifique" - } - ], - "source_reglementaire": null - }, - { - "numero": 3, - "titre": "Le DAS N13.1 (Hydronéphrose) est un diagnostic préexistant et justifie la néphrostomie", - "argument": "L'hydronéphrose était connue avant l'hospitalisation et a nécessité la mise en place d'une néphrostomie, ce qui justifie son codage en DAS.", - "preuves": [ - { - "ref": "[DAS-3]", - "fait": "Hydronéphrose documentée comme diagnostic préexistant", - "signification": "Contexte clinique cohérent" - }, - { - "ref": "[TRT-1]", - "fait": "Nécessité de néphrostomie pour gestion de l'hydronéphrose", - "signification": "Preuve de la pertinence du code" - } - ], - "source_reglementaire": null - } - ], - "confrontation_bio": [ - { - "diagnostic": "N19 (Insuffisance rénale)", - "test": "Créatinine", - "valeur": "non précisée", - "seuil": "non applicable", - "verdict": "NON CONFIRMÉ (valeur normale)" - }, - { - "diagnostic": "N17.9 (IRA)", - "test": "Créatinine", - "valeur": "non précisée", - "seuil": "non applicable", - "verdict": "NON CONFIRMÉ (valeur normale)" - } - ], - "asymetrie_information": "La CPAM n'avait pas accès aux détails des actes d'urétéroscopie et Brickeroscopie, ni aux antécédents complets d'obésité et de complications associées.", - "reponse_points_cpam": "Nous reconnaissons que le code Z43.6 (Surveillance de néphrostomie) pourrait être pertinent, mais le DP E66.04 (Obésité) est justifié par son rôle causal dans la complication mécanique. L'hydronéphrose (N13.1) reste un diagnostic associé pertinent.", - "codes_non_defendables": [ - { - "code": "N19", - "raison": "Créatinine normale — insuffisance rénale non confirmée biologiquement", - "recommandation": "Retrait recommandé — code indéfendable face à la CPAM" - }, - { - "code": "N17.9", - "raison": "Créatinine normale — IRA non confirmée biologiquement", - "recommandation": "Retrait recommandé — code indéfendable face à la CPAM" - } - ], - "references": [ - { - "document": "Guide de codage CIM-10", - "page": "non précisée", - "citation": "Les diagnostics doivent être justifiés par des preuves objectives ou des antécédents documentés." - } - ], - "conclusion_dispositive": "Par conséquent, au vu des éléments cliniques objectifs (obésité documentée, complication mécanique prouvée, hydronéphrose préexistante), des règles CIM-10 applicables, et des informations complémentaires non transmises à l'UCR, nous demandons le MAINTIEN du codage : DP E66.04 (Obésité due à un excès calorique de l'adulte avec indice de masse corporelle égal), DAS T83.1 (Complication mécanique d'autres prothèses et implants urinaires), DAS N13.1 (Hydronéphrose avec rétrécissement urétéral, non classée ailleurs). Nous reconnaissons que les codes N19 et N17.9 ne disposent pas d'un support documentaire suffisant." - }, - "text_output": "═══════════════════════════════════════════════════════\nMÉMOIRE EN DÉFENSE — Contestation Désaccord sur le DP et DAS — OGC 183 — Mémoire en défense\n═══════════════════════════════════════════════════════\n\n⚠ REVUE MANUELLE REQUISE (Qualité : C)\n\nRAPPEL DES FAITS\nPatient hospitalisé pour hydronéphrose obstructive secondaire à une complication mécanique d'une prothèse urinaire (Bricker). Actes réalisés : urétéroscopie et Brickeroscopie. Issue : mise en place d'une néphrostomie. Durée : non précisée.\n\n───────────────────────────────────────────────────────\n\nMOYEN N°1 — Le DP E66.04 (Obésité) est justifié par l'IMC et son rôle dans la complication\nL'obésité (IMC = 35) est un facteur de risque avéré pour les complications mécaniques des prothèses urinaires. Le compte-rendu clinique mentionne explicitement son implication dans la pathologie actuelle.\n Preuve : [DP] Obésité documentée avec IMC = 35 → Facteur de risque majeur pour la complication mécanique\n Preuve : [ANT-1] Antécédents d'obésité et de complications associées → Contexte clinique cohérent\n\nMOYEN N°2 — Le DAS T83.1 (Complication mécanique de prothèse urinaire) est objectivement documenté\nLe TDM abdominal et les actes d'urétéroscopie/Brickeroscopie confirment la complication mécanique de la prothèse urinaire, justifiant ce code.\n Preuve : [IMG-1] TDM montrant obstruction urétérale liée à la prothèse → Preuve objective de la complication\n Preuve : [ACTE-1] Actes d'urétéroscopie et Brickeroscopie pour gestion de la complication → Preuve de la prise en charge spécifique\n\nMOYEN N°3 — Le DAS N13.1 (Hydronéphrose) est un diagnostic préexistant et justifie la néphrostomie\nL'hydronéphrose était connue avant l'hospitalisation et a nécessité la mise en place d'une néphrostomie, ce qui justifie son codage en DAS.\n Preuve : [DAS-3] Hydronéphrose documentée comme diagnostic préexistant → Contexte clinique cohérent\n Preuve : [TRT-1] Nécessité de néphrostomie pour gestion de l'hydronéphrose → Preuve de la pertinence du code\n\n───────────────────────────────────────────────────────\n\nCONFRONTATION BIOLOGIE / DIAGNOSTIC\n┌─────────────────┬─────────────┬──────────────┬───────────┬───────────────┐\n│ Diagnostic │ Test requis │ Seuil │ Valeur │ Verdict │\n├─────────────────┼─────────────┼──────────────┼───────────┼───────────────┤\n│ N19 (Insuffisance│ Créatinine │ non applicable│ non précisé│ NON CONFIRMÉ (v│\n│ N17.9 (IRA) │ Créatinine │ non applicable│ non précisé│ NON CONFIRMÉ (v│\n└─────────────────┴─────────────┴──────────────┴───────────┴───────────────┘\n\n───────────────────────────────────────────────────────\n\n⚠ CODES NON DÉFENDABLES (honnêteté intellectuelle)\n- N19 : Créatinine normale — insuffisance rénale non confirmée biologiquement\n → Retrait recommandé — code indéfendable face à la CPAM\n- N17.9 : Créatinine normale — IRA non confirmée biologiquement\n → Retrait recommandé — code indéfendable face à la CPAM\n\n───────────────────────────────────────────────────────\n\nASYMÉ" - }, - { - "model": "llama3.3:70b", - "dossier": "183_23087212", - "timestamp": "2026-03-04 15:02:18", - "ogc": 183, - "titre": "Désaccord sur le DP et DAS", - "elapsed_s": 1742.9, - "text_len": 30499, - "rag_sources": 0, - "quality_tier": "C", - "requires_review": true, - "json_valid": true, - "is_tim": true, - "tim_eval": { - "format": "TIM", - "tim_compliant": true, - "fields_present": 9, - "fields_total": 9, - "fields_missing": [], - "moyens_count": 9, - "preuves_count": 22, - "preuves_with_ref": 22, - "confrontation_count": 4, - "codes_nd_count": 6, - "refs_count": 5, - "conclusion_len": 3322, - "has_maintien": false - }, - "bio_errors": [ - { - "type": "BIO_HALLUCINATION", - "severity": "CRITICAL", - "detail": "Créatinine 84 µmol/L (NORMAL 50-120) qualifiée comme 'insuffisance rénale'", - "ground_truth": "Créatinine 84 = NORMAL" - }, - { - "type": "CONFRONTATION_ERROR", - "severity": "CRITICAL", - "detail": "Créatinine = 84 (NORMAL) mais verdict = NON CONFIRMÉ — VALEUR NORMALE", - "ground_truth": "Créatinine norme [50-120]" - }, - { - "type": "CONFRONTATION_ERROR", - "severity": "CRITICAL", - "detail": "Créatinine = 84 (NORMAL) mais verdict = NON CONFIRMÉ — VALEUR NORMALE", - "ground_truth": "Créatinine norme [50-120]" - } - ], - "bio_errors_count": 3, - "bio_critical_count": 3, - "code_errors": [ - { - "type": "SUSPICIOUS_CODE", - "severity": "MEDIUM", - "detail": "Code Z45.80: Code Z45.8 existe mais Z45.80 est suspect (vérifier)" - } - ], - "code_errors_count": 1, - "parsed_response": { - "objet": "Contestation Désaccord sur le DP et DAS — Mémoire en défense structuré", - "rappel_faits": "Patient masculin, 68 ans, hospitalisé 3 jours pour prise en charge d'une obstruction urétéro-iléale (absence de passage entre uretère gauche et Bricker) diagnostiquée en imagerie [IMG-1]. Actes réalisés : TDM abdominal [ACTE-1], urétéroscopie descendante, Brickeroscopie avec lésion traumatique iatrogène du Bricker [IMG-1]. Mise en place d'une néphrostomie. Sortie après décision thérapeutique partagée.", - "moyens_defense": [ - { - "numero": 1, - "titre": "La CPAM a raison sur le DP : Z43.6 (Surveillance de néphrostomie) est plus pertinent que E66.04 (Obésité)", - "argument": "L'établissement a codé en DP l'obésité [DP] (E66.04 — Obésité due à un excès calorique de l'adulte avec IMC égal à 31.231), alors que l'acte médical principal et consommateur de ressources du séjour est la mise en place d'une néphrostomie suite à l'obstruction urétéro-iléale [IMG-1]. Selon la règle hiérarchique CIM-10, le DP doit être le diagnostic qui a CONSOMMÉ LE PLUS DE RESSOURCES pendant le séjour, non le diagnostic comorbide le plus grave. L'obésité est une condition chronique préexistante qui n'a motivé ni traitement spécifique, ni allongement de durée, ni modification de surveillance pendant ces 3 jours. En revanche, l'obstruction urétéro-iléale et la mise en place de néphrostomie ont justifié l'hospitalisation, les actes d'imagerie [ACTE-1], les gestes endoscopiques [IMG-1], et la décision thérapeutique documentée [ANT-3] à [ANT-6]. Le code Z43.6 (Surveillance d'autres stomies des voies urinaires — Néphrostomie) est donc le DP approprié, conformément à la logique T2A qui valorise l'acte thérapeutique principal.", - "preuves": [ - { - "ref": "[DP]", - "fait": "DP établissement = E66.04 (Obésité, IMC 31.231)", - "signification": "Diagnostic comorbide, non motif d'hospitalisation" - }, - { - "ref": "[IMG-1]", - "fait": "Absence totale de passage entre uretère gauche et Bricker ; urétéroscopie descendante et Brickeroscopie réalisées ; Bricker endommagé iatrogènement", - "signification": "Acte thérapeutique principal justifiant l'hospitalisation" - }, - { - "ref": "[ACTE-1]", - "fait": "TDM abdominal (ZCQK002) réalisée", - "signification": "Imagerie diagnostique directement liée à l'obstruction, non à l'obésité" - }, - { - "ref": "[ANT-3] à [ANT-6]", - "fait": "Documentation de la décision thérapeutique : retrait de néphrostomie vs. maintien vs. reconstruction", - "signification": "Preuve que la néphrostomie est le centre du séjour" - } - ], - "source_reglementaire": "Guide méthodologique T2A — Règle hiérarchique DP : 'Le diagnostic principal est celui qui a consommé le plus de ressources pendant le séjour, indépendamment de sa gravité relative'. La mise en place d'une stomie urinaire (néphrostomie) se code en DP avec un code Z43.x ou Z45.x selon le contexte (surveillance vs. ajustement)." - }, - { - "numero": 2, - "titre": "Reconnaissance du bien-fondé de la proposition UCR : Z43.6 vs. Z45.80 — clarification du code exact", - "argument": "L'UCR propose Z45.80 (Ajustement et entretien d'une prothèse interne urinaire) comme DP. Cependant, le dossier clinique [IMG-1] décrit une MISE EN PLACE de néphrostomie (acte de création de stomie), non un ajustement ou entretien d'une prothèse préexistante. Selon la CIM-10 officielle : Z43.6 s'applique à la 'Surveillance d'autres stomies des voies urinaires — Néphrostomie', tandis que Z45.80 s'applique à l' 'Ajustement et entretien d'une prothèse interne urinaire' (implant préexistant). Le contexte clinique [IMG-1] indique que la néphrostomie a été créée PENDANT ce séjour en réponse à l'obstruction urétéro-iléale, ce qui correspond à Z43.6 plutôt qu'à Z45.80. Toutefois, nous reconnaissons que la CPAM a raison de rejeter E66.04 comme DP et que le codage doit être centré sur la néphrostomie. La différence entre Z43.6 et Z45.80 est mineure et ne modifie pas le groupage GHM/GHS selon la règle T7 citée par l'UCR.", - "preuves": [ - { - "ref": "[IMG-1]", - "fait": "Mise en place de néphrostomie (acte de création, non d'ajustement)", - "signification": "Justifie Z43.6 plutôt que Z45.80" - }, - { - "ref": "Définition CIM-10 Z43.6", - "fait": "Surveillance d'autres stomies des voies urinaires — Néphrostomie", - "signification": "Code approprié pour création/surveillance de néphrostomie" - }, - { - "ref": "Définition CIM-10 Z45.80", - "fait": "Ajustement et entretien d'une prothèse interne urinaire", - "signification": "Code pour entretien de prothèse préexistante, non création" - } - ], - "source_reglementaire": "CIM-10 — Codes Z43.x (Surveillance de stomies) vs. Z45.x (Ajustement de prothèses) — distinction basée sur le type d'acte (création vs. entretien)" - }, - { - "numero": 3, - "titre": "Rejet des codes d'insuffisance rénale (N17.9 et N19) : incohérence biologique majeure", - "argument": "L'établissement a codé deux diagnostics d'insuffisance rénale : N17.9 (Insuffisance rénale aigüe, sans précision) [DAS-6] et N19 (Insuffisance rénale, sans précision) [DAS-3]. Or, la biologie du dossier montre une créatinine = 84 µmol/L [BIO-8], valeur NORMALE (norme 50-120 µmol/L). L'insuffisance rénale aiguë (N17.9) requiert une créatinine ÉLEVÉE (> 130 µmol/L chez l'homme) et/ou une augmentation rapide de la créatinine basale. L'insuffisance rénale chronique (N19) requiert également une créatinine pathologique. La créatinine normale [BIO-8] CONTREDIT formellement ces deux diagnostics. Ces codes ne disposent d'AUCUN support biologique et doivent être RETIRÉS. Nous reconnaissons que cette incohérence rend ces codes indéfendables face à la CPAM.", - "preuves": [ - { - "ref": "[BIO-8]", - "fait": "Créatinine = 84 µmol/L [norme 50-120]", - "signification": "Valeur NORMALE — fonction rénale conservée" - }, - { - "ref": "[DAS-6]", - "fait": "N17.9 — Insuffisance rénale aigüe, sans précision", - "signification": "Requiert créatinine > 130 µmol/L — NON CONFIRMÉ" - }, - { - "ref": "[DAS-3]", - "fait": "N19 — Insuffisance rénale, sans précision", - "signification": "Requiert créatinine pathologique — NON CONFIRMÉ" - } - ], - "source_reglementaire": "CIM-10 — Seuils biologiques pour insuffisance rénale : créatinine > 130 µmol/L (H) / > 110 µmol/L (F). Valeur normale EXCLUT le diagnostic d'insuffisance rénale." - }, - { - "numero": 4, - "titre": "Rejet du code N10 (Pyélonéphrite sévère) : absence de preuve clinique et biologique", - "argument": "L'établissement a codé N10 (Néphrite tubulo-interstitielle aigüe) [DAS-2] comme diagnostic associé. Or, le dossier ne contient AUCUNE preuve objective de pyélonéphrite : (1) Pas de fièvre documentée ; (2) Pas de leucocyturie ou bactériurie mentionnée ; (3) Leucocytes = 8.18 × 10⁹/L [BIO-7], valeur NORMALE (norme 4-10) — pas de leucocytose ; (4) Pas de culture d'urine positive citée ; (5) L'imagerie [IMG-1] décrit une obstruction urétéro-iléale, non une pyélonéphrite. Le code N10 repose sur le seul jugement clinique sans preuve biologique ou paraclinique. Il doit être RETIRÉ.", - "preuves": [ - { - "ref": "[BIO-7]", - "fait": "Leucocytes = 8.18 × 10⁹/L [norme 4-10]", - "signification": "Valeur NORMALE — pas de leucocytose infectieuse" - }, - { - "ref": "[IMG-1]", - "fait": "TDM abdominal : absence de passage urétéro-iléal, Bricker fragile endommagé", - "signification": "Obstruction mécanique, non infection rénale" - }, - { - "ref": "[DAS-2]", - "fait": "N10 — Néphrite tubulo-interstitielle aigüe", - "signification": "Aucune preuve biologique (leucocytes normaux) ou clinique (pas de fièvre documentée)" - } - ], - "source_reglementaire": "CIM-10 — N10 requiert preuve d'infection rénale (fièvre, leucocytose, bactériurie, imagerie compatible). Absence de preuve = code non justifié." - }, - { - "numero": 5, - "titre": "Rejet du code Z00.0 (Examen médical général) : code générique inapproprié en DP/DAS", - "argument": "L'établissement a codé Z00.0 (Examen médical général) [DAS-1]. Ce code R/Z (symptôme ou raison de consultation) est INTERDIT en DP si une étiologie est identifiée, et son utilisation en DAS est inappropriée quand un diagnostic spécifique existe. Le séjour n'était pas une visite de dépistage générale, mais une prise en charge d'une obstruction urétéro-iléale [IMG-1] avec mise en place de néphrostomie. Le code Z00.0 doit être RETIRÉ.", - "preuves": [ - { - "ref": "[DAS-1]", - "fait": "Z00.0 — Examen médical général", - "signification": "Code générique inapproprié — étiologie spécifique identifiée (obstruction urétéro-iléale)" - }, - { - "ref": "[IMG-1]", - "fait": "Diagnostic spécifique : obstruction urétéro-iléale avec mise en place de néphrostomie", - "signification": "Justifie le rejet de Z00.0" - } - ], - "source_reglementaire": "Guide méthodologique T2A — Codes R/Z interdits en DP si étiologie identifiée. Z00.0 est un code de raison de consultation générale, non applicable à une hospitalisation pour acte thérapeutique spécifique." - }, - { - "numero": 6, - "titre": "Maintien du code T83.1 (Lésion traumatique du Bricker) : preuve documentée en imagerie", - "argument": "L'établissement a codé T83.1 (Complication mécanique d'autres prothèses et implants urinaires) [DAS-4]. Le dossier clinique [IMG-1] documente explicitement : 'Bricker extrêmement fragile, qui a été très légèrement endommagé au cours de cette Brickeroscopie'. Cette lésion iatrogène est une complication mécanique directe du Bricker (prothèse urinaire), justifiant pleinement le code T83.1. Ce code doit être MAINTENU.", - "preuves": [ - { - "ref": "[IMG-1]", - "fait": "Bricker endommagé iatrogènement lors de la Brickeroscopie", - "signification": "Complication mécanique de prothèse urinaire — T83.1 justifié" - }, - { - "ref": "[DAS-4]", - "fait": "T83.1 — Complication mécanique d'autres prothèses et implants urinaires", - "signification": "Code approprié et documenté" - } - ], - "source_reglementaire": "CIM-10 — T83.1 s'applique aux complications mécaniques de prothèses urinaires. La lésion du Bricker documentée en imagerie justifie ce code." - }, - { - "numero": 7, - "titre": "Maintien du code I10 (Hypertension artérielle) : diagnostic comorbide documenté", - "argument": "L'établissement a codé I10 (Hypertension essentielle primitive) [DAS-5]. Bien que le dossier ne contienne pas de valeurs tensionnelles explicites, l'hypertension est un diagnostic comorbide fréquent chez un patient de 68 ans et peut justifier un traitement antihypertenseur pendant l'hospitalisation. En l'absence de preuve CONTRE ce diagnostic (pas de tension normale documentée), et en présence d'une indication clinique plausible, ce code peut être MAINTENU comme diagnostic associé. Toutefois, si la CPAM conteste l'absence de preuve documentée, nous reconnaissons que ce code repose sur le jugement clinique sans valeur tensionnelle citée.", - "preuves": [ - { - "ref": "[DAS-5]", - "fait": "I10 — Hypertension essentielle (primitive)", - "signification": "Diagnostic comorbide plausible chez patient 68 ans, sans preuve contre" - } - ], - "source_reglementaire": "CIM-10 — I10 s'applique à l'hypertension essentielle. Absence de preuve documentée, mais absence de preuve contre. Maintien possible en DAS si cliniquement justifié." - }, - { - "numero": 8, - "titre": "Rejet du code Z43.3 (Surveillance de colostomie) : absence de colostomie dans le dossier", - "argument": "L'établissement a codé Z43.3 (Surveillance de colostomie) [DAS-7]. Or, le dossier clinique ne mentionne AUCUNE colostomie. L'acte réalisé est une NÉPHROSTOMIE (stomie urinaire), non une colostomie (stomie digestive). Z43.3 s'applique à la surveillance de colostomie, code qui n'a aucune pertinence dans ce séjour. Ce code doit être RETIRÉ. Il s'agit probablement d'une erreur de codage (confusion entre néphrostomie et colostomie).", - "preuves": [ - { - "ref": "[DAS-7]", - "fait": "Z43.3 — Surveillance de colostomie", - "signification": "Code inapproprié — aucune colostomie mentionnée dans le dossier" - }, - { - "ref": "[IMG-1]", - "fait": "Acte réalisé : néphrostomie (stomie urinaire), non colostomie", - "signification": "Justifie le rejet de Z43.3" - } - ], - "source_reglementaire": "CIM-10 — Z43.3 s'applique à la surveillance de colostomie (stomie digestive). Néphrostomie se code en Z43.6 ou Z45.80, non Z43.3." - }, - { - "numero": 9, - "titre": "Proposition de code manquant : N13.1 (Hydronéphrose avec rétrécissement urétéral) comme diagnostic associé", - "argument": "L'UCR propose N13.1 (Hydronéphrose avec rétrécissement urétéral, non classée ailleurs) comme diagnostic associé. Le dossier clinique [IMG-1] documente une 'absence totale de passage entre l'uretère gauche et le Bricker', ce qui constitue une obstruction urétérale fonctionnelle pouvant entraîner une hydronéphrose. Bien que le terme 'hydronéphrose' ne soit pas explicitement cité dans le compte rendu d'imagerie fourni, l'obstruction urétéro-iléale est la cause directe d'une hydronéphrose potentielle. Le code N13.1 est cliniquement pertinent et peut être AJOUTÉ comme diagnostic associé pour refléter la pathologie obstructive rénale.", - "preuves": [ - { - "ref": "[IMG-1]", - "fait": "Absence totale de passage entre uretère gauche et Bricker", - "signification": "Obstruction urétérale — cause d'hydronéphrose" - }, - { - "ref": "Définition CIM-10 N13.1", - "fait": "Hydronéphrose avec rétrécissement urétéral, non classée ailleurs", - "signification": "Code approprié pour obstruction urétérale" - } - ], - "source_reglementaire": "CIM-10 — N13.1 s'applique à l'hydronéphrose secondaire à rétrécissement ou obstruction urétérale. Pertinent dans ce contexte." - } - ], - "confrontation_bio": [ - { - "diagnostic": "N17.9 — Insuffisance rénale aigüe, sans précision", - "test": "Créatinine", - "valeur": 84, - "seuil": "> 130 µmol/L (H)", - "verdict": "NON CONFIRMÉ — valeur NORMALE" - }, - { - "diagnostic": "N19 — Insuffisance rénale, sans précision", - "test": "Créatinine", - "valeur": 84, - "seuil": "> 130 µmol/L (H)", - "verdict": "NON CONFIRMÉ — valeur NORMALE" - }, - { - "diagnostic": "N10 — Néphrite tubulo-interstitielle aigüe", - "test": "Leucocytes", - "valeur": 8.18, - "seuil": "4-10 × 10⁹/L", - "verdict": "NON CONFIRMÉ — valeur NORMALE, pas de leucocytose" - }, - { - "diagnostic": "E66.04 — Obésité", - "test": "IMC", - "valeur": 31.231, - "seuil": "> 30", - "verdict": "CONFIRMÉ biologiquement, mais NON PERTINENT comme DP (diagnostic comorbide, non motif d'hospitalisation)" - } - ], - "asymetrie_information": "L'UCR n'a reçu que le CRH et les codes, sans accès aux éléments cliniques détaillés suivants : (1) Biologie complète [BIO-1] à [BIO-9] montrant créatinine normale et leucocytes normaux, contredisant les diagnostics d'insuffisance rénale et pyélonéphrite ; (2) Compte rendu d'imagerie détaillé [IMG-1] décrivant l'obstruction urétéro-iléale, la Brickeroscopie, et la lésion iatrogène du Bricker ; (3) Documentation de la décision thérapeutique partagée [ANT-3] à [ANT-6] montrant que la néphrostomie est le centre du séjour. Ces informations manquantes ont probablement conduit l'UCR à accepter les codes proposés par l'établissement sans vérification biologique.", - "reponse_points_cpam": [ - { - "point_cpam": "Le DP doit être Z43.6 (Surveillance de néphrostomie), non E66.04 (Obésité)", - "reconnaissance": "CORRECT. La CPAM a raison. L'obésité est une comorbidité chronique qui n'a motivé ni traitement spécifique, ni allongement de durée, ni modification de surveillance pendant ce séjour de 3 jours. L'acte médical principal et consommateur de ressources est la mise en place de néphrostomie suite à l'obstruction urétéro-iléale [IMG-1]. Selon la hiérarchie CIM-10, le DP doit être le diagnostic qui a consommé le plus de ressources, ce qui est clairement la néphrostomie, non l'obésité.", - "refutation_ou_concession": "CONCESSION PARTIELLE : Nous reconnaissons que E66.04 ne doit pas être DP. Cependant, nous proposons Z43.6 plutôt que Z45.80 proposé par l'UCR, car le dossier décrit une MISE EN PLACE de néphrostomie (création), non un ajustement de prothèse préexistante. Z43.6 (Surveillance d'autres stomies des voies urinaires — Néphrostomie) est plus approprié que Z45.80 (Ajustement et entretien d'une prothèse interne urinaire)." - }, - { - "point_cpam": "L'ajout des autres codes DAS ne modifie pas le groupage GHM/GHS (Règle T7)", - "reconnaissance": "ACCEPTÉ. La CPAM cite correctement la Règle T7 du guide méthodologique : 'Situation de soins spécifique d'autres appareils'. Le groupage est déterminé par le DP et les actes CCAM, non par la totalité des DAS. Nous reconnaissons que le changement de DP de E66.04 à Z43.6 entraîne un groupage identique à celui proposé par les contrôleurs.", - "refutation_ou_concession": "ACCEPTATION COMPLÈTE sur le point de groupage. Cependant, cela ne justifie pas le maintien de codes DAS biologiquement indéfendables (N17.9, N19, N10, Z00.0, Z43.3)." - } - ], - "codes_non_defendables": [ - { - "code": "N17.9 — Insuffisance rénale aigüe, sans précision", - "raison": "Créatinine = 84 µmol/L [norme 50-120] — valeur NORMALE. L'insuffisance rénale aiguë requiert créatinine > 130 µmol/L (H). Aucune preuve biologique. INCOHÉRENCE MAJEURE.", - "recommandation": "RETRAIT OBLIGATOIRE — code indéfendable face à la CPAM" - }, - { - "code": "N19 — Insuffisance rénale, sans précision", - "raison": "Créatinine = 84 µmol/L [norme 50-120] — valeur NORMALE. L'insuffisance rénale requiert créatinine pathologique. Aucune preuve biologique. INCOHÉRENCE MAJEURE.", - "recommandation": "RETRAIT OBLIGATOIRE — code indéfendable face à la CPAM" - }, - { - "code": "N10 — Néphrite tubulo-interstitielle aigüe", - "raison": "Leucocytes = 8.18 × 10⁹/L [norme 4-10] — valeur NORMALE. Pas de fièvre documentée, pas de bactériurie citée, pas de culture positive. Imagerie [IMG-1] décrit obstruction mécanique, non infection rénale. Aucune preuve objective.", - "recommandation": "RETRAIT OBLIGATOIRE — code repose sur jugement clinique sans preuve biologique ou paraclinique" - }, - { - "code": "Z00.0 — Examen médical général", - "raison": "Code générique inapproprié. Le séjour n'était pas une visite de dépistage, mais une prise en charge d'obstruction urétéro-iléale [IMG-1] avec mise en place de néphrostomie. Étiologie spécifique identifiée.", - "recommandation": "RETRAIT OBLIGATOIRE — code R/Z interdit en DAS quand diagnostic spécifique existe" - }, - { - "code": "Z43.3 — Surveillance de colostomie", - "raison": "Aucune colostomie mentionnée dans le dossier. L'acte réalisé est une NÉPHROSTOMIE (stomie urinaire), non colostomie (stomie digestive). Erreur de codage probable.", - "recommandation": "RETRAIT OBLIGATOIRE — code sans pertinence clinique" - }, - { - "code": "E66.04 — Obésité due à un excès calorique de l'adulte", - "raison": "Bien que biologiquement confirmée (IMC 31.231), ce code ne doit PAS être DP. L'obésité est une comorbidité chronique qui n'a motivé ni traitement spécifique, ni allongement de durée, ni modification de surveillance pendant ce séjour. Le DP doit être la néphrostomie [IMG-1].", - "recommandation": "RETRAIT EN DP — peut être maintenu en DAS si pertinent, mais ne doit pas être DP" - } - ], - "references": [ - { - "document": "Dossier médical — Compte rendu d'imagerie (TDM abdominal)", - "page": "Non spécifiée", - "citation": "Une absence totale de passage entre l'uretère gauche et le Bricker. [...] Bricker extrêmement fragile, qui a été très légèrement endommagé au cours de cette Brickeroscopie." - }, - { - "document": "Dossier médical — Biologie", - "page": "Non spécifiée", - "citation": "Créatinine: 84 µmol/L [norme 50-120] — NORMAL. Leucocytes: 8.18 × 10⁹/L [norme 4-10] — NORMAL." - }, - { - "document": "Guide méthodologique T2A", - "page": "Non spécifiée", - "citation": "Le diagnostic principal est celui qui a consommé le plus de ressources pendant le séjour, indépendamment de sa gravité relative." - }, - { - "document": "CIM-10 — Codes Z43.x et Z45.x", - "page": "Non spécifiée", - "citation": "Z43.6 — Surveillance d'autres stomies des voies urinaires (Néphrostomie). Z45.80 — Ajustement et entretien d'une prothèse interne urinaire." - }, - { - "document": "CIM-10 — Codes d'insuffisance rénale", - "page": "Non spécifiée", - "citation": "N17.9 — Insuffisance rénale aigüe, sans précision (requiert créatinine > 130 µmol/L chez l'homme). N19 — Insuffisance rénale, sans précision (requiert créatinine pathologique)." - } - ], - "conclusion_dispositive": "Par conséquent, au vu des éléments cliniques objectifs et des règles CIM-10 applicables, nous demandons les modifications suivantes au codage :\n\n**DP PROPOSÉ : Z43.6 — Surveillance d'autres stomies des voies urinaires (Néphrostomie)**\nJustification : L'acte médical principal et consommateur de ressources du séjour est la mise en place d'une néphrostomie suite à l'obstruction urétéro-iléale documentée en imagerie [IMG-1]. Selon la hiérarchie CIM-10, le DP doit être le diagnostic qui a consommé le plus de ressources, ce qui est clairement la néphrostomie, non l'obésité [DP] proposée initialement. La CPAM a raison de rejeter E66.04 comme DP.\n\n**DAS À MAINTENIR :**\n- T83.1 — Complication mécanique d'autres prothèses et implants urinaires [DAS-4] : JUSTIFIÉ par la lésion iatrogène du Bricker documentée en imagerie [IMG-1]\n- I10 — Hypertension essentielle (primitive) [DAS-5] : MAINTENU comme diagnostic comorbide plausible chez patient 68 ans (sans preuve contre)\n- N13.1 — Hydronéphrose avec rétrécissement urétéral, non classée ailleurs : À AJOUTER (pertinent pour refléter l'obstruction urétéro-iléale [IMG-1])\n\n**DAS À RETIRER :**\n- N17.9 — Insuffisance rénale aigüe, sans précision [DAS-6] : RETIRÉ — Créatinine = 84 µmol/L [BIO-8] (NORMALE, norme 50-120). Aucune preuve biologique d'insuffisance rénale aiguë.\n- N19 — Insuffisance rénale, sans précision [DAS-3] : RETIRÉ — Créatinine = 84 µmol/L [BIO-8] (NORMALE). Aucune preuve biologique d'insuffisance rénale.\n- N10 — Néphrite tubulo-interstitielle aigüe [DAS-2] : RETIRÉ — Leucocytes = 8.18 × 10⁹/L [BIO-7] (NORMAL, norme 4-10). Pas de fièvre documentée, pas de bactériurie citée. Imagerie [IMG-1] décrit obstruction mécanique, non infection rénale.\n- Z00.0 — Examen médical général [DAS-1] : RETIRÉ — Code générique inapproprié. Étiologie spécifique identifiée (obstruction urétéro-iléale).\n- Z43.3 — Surveillance de colostomie [DAS-7] : RETIRÉ — Aucune colostomie dans le dossier. Acte réalisé : néphrostomie (stomie urinaire), non colostomie.\n- E66.04 — Obésité [DP] : RETIRÉ EN DP — Peut être maintenu en DAS si pertinent, mais ne doit pas être DP (diagnostic comorbide, non motif d'hospitalisation).\n\n**RECONNAISSANCE DES POINTS CPAM :**\nNous reconnaissons que la CPAM a raison sur le point fondamental : le DP doit être centré sur la néphrostomie, non sur l'obésité. La Règle T7 du guide méthodologique (Situation de soins spécifique d'autres appareils) s'applique correctement, et le groupage GHM/GHS ne sera pas modifié par ces corrections.\n\n**ASYMÉTRIE D'INFORMATION :**\nL'UCR n'a reçu que le CRH et les codes, sans accès à la biologie complète [BIO-1] à [BIO-9] et au compte rendu d'imagerie détaillé [IMG-1]. Ces informations manquantes ont probablement conduit à l'acceptation de codes biologiquement indéfendables (N17.9, N19, N10). La transmission de ces éléments cliniques objectifs aurait permis à l'UCR de détecter les incohérences et de proposer un codage plus rigoureux.\n\n**CODAGE FINAL PROPOSÉ :**\n- **DP : Z43.6** — Surveillance d'autres stomies des voies urinaires (Néphrostomie)\n- **DAS : T83.1, I10, N13.1**\n- **Retrait : N17.9, N19, N10, Z00.0, Z43.3, E66.04 (en DP)**\n\nCe codage reflète fidèlement la réalité clinique du séjour, est soutenu par les preuves objectives du dossier, et respecte les règles CIM-10 et T2A applicables." - }, - "text_output": "═══════════════════════════════════════════════════════\nMÉMOIRE EN DÉFENSE — Contestation Désaccord sur le DP et DAS — Mémoire en défense structuré\n═══════════════════════════════════════════════════════\n\n⚠ REVUE MANUELLE REQUISE (Qualité : C)\n\nRAPPEL DES FAITS\nPatient masculin, 68 ans, hospitalisé 3 jours pour prise en charge d'une obstruction urétéro-iléale (absence de passage entre uretère gauche et Bricker) diagnostiquée en imagerie [IMG-1]. Actes réalisés : TDM abdominal [ACTE-1], urétéroscopie descendante, Brickeroscopie avec lésion traumatique iatrogène du Bricker [IMG-1]. Mise en place d'une néphrostomie. Sortie après décision thérapeutique partagée.\n\n───────────────────────────────────────────────────────\n\nMOYEN N°1 — La CPAM a raison sur le DP : Z43.6 (Surveillance de néphrostomie) est plus pertinent que E66.04 (Obésité)\nL'établissement a codé en DP l'obésité [DP] (E66.04 — Obésité due à un excès calorique de l'adulte avec IMC égal à 31.231), alors que l'acte médical principal et consommateur de ressources du séjour est la mise en place d'une néphrostomie suite à l'obstruction urétéro-iléale [IMG-1]. Selon la règle hiérarchique CIM-10, le DP doit être le diagnostic qui a CONSOMMÉ LE PLUS DE RESSOURCES pendant le séjour, non le diagnostic comorbide le plus grave. L'obésité est une condition chronique préexistante qui n'a motivé ni traitement spécifique, ni allongement de durée, ni modification de surveillance pendant ces 3 jours. En revanche, l'obstruction urétéro-iléale et la mise en place de néphrostomie ont justifié l'hospitalisation, les actes d'imagerie [ACTE-1], les gestes endoscopiques [IMG-1], et la décision thérapeutique documentée [ANT-3] à [ANT-6]. Le code Z43.6 (Surveillance d'autres stomies des voies urinaires — Néphrostomie) est donc le DP approprié, conformément à la logique T2A qui valorise l'acte thérapeutique principal.\n Preuve : [DP] DP établissement = E66.04 (Obésité, IMC 31.231) → Diagnostic comorbide, non motif d'hospitalisation\n Preuve : [IMG-1] Absence totale de passage entre uretère gauche et Bricker ; urétéroscopie descendante et Brickeroscopie réalisées ; Bricker endommagé iatrogènement → Acte thérapeutique principal justifiant l'hospitalisation\n Preuve : [ACTE-1] TDM abdominal (ZCQK002) réalisée → Imagerie diagnostique directement liée à l'obstruction, non à l'obésité\n Preuve : [ANT-3] à [ANT-6] Documentation de la décision thérapeutique : retrait de néphrostomie vs. maintien vs. reconstruction → Preuve que la néphrostomie est le centre du séjour\n Source : Guide méthodologique T2A — Règle hiérarchique DP : 'Le diagnostic principal est celui qui a consommé le plus de ressources pendant le séjour, indépendamment de sa gravité relative'. La mise en place d'une stomie urinaire (néphrostomie) se code en DP avec un code Z43.x ou Z45.x selon le contexte (surveillance vs. ajustement).\n\nMOYEN N°2 — Reconnaissance du bien-fondé de la proposition UCR : Z43.6 vs. Z45.80 — clarification du code exact\nL'UCR propose Z45" - }, - { - "model": "gpt-oss:latest", - "dossier": "183_23087212", - "timestamp": "2026-03-04 15:33:47", - "ogc": 183, - "titre": "Désaccord sur le DP et DAS", - "elapsed_s": 319.6, - "text_len": 29124, - "rag_sources": 0, - "quality_tier": "B", - "requires_review": false, - "json_valid": true, - "is_tim": true, - "tim_eval": { - "format": "TIM", - "tim_compliant": true, - "fields_present": 9, - "fields_total": 9, - "fields_missing": [], - "moyens_count": 8, - "preuves_count": 16, - "preuves_with_ref": 16, - "confrontation_count": 4, - "codes_nd_count": 5, - "refs_count": 4, - "conclusion_len": 2941, - "has_maintien": true - }, - "bio_errors": [ - { - "type": "BIO_HALLUCINATION", - "severity": "CRITICAL", - "detail": "Créatinine 84 µmol/L (NORMAL 50-120) qualifiée comme 'insuffisance rénale'", - "ground_truth": "Créatinine 84 = NORMAL" - }, - { - "type": "CONFRONTATION_ERROR", - "severity": "CRITICAL", - "detail": "Créatinine = 84 (NORMAL) mais verdict = NON CONFIRMÉ — VALEUR NORMALE", - "ground_truth": "Créatinine norme [50-120]" - }, - { - "type": "CONFRONTATION_ERROR", - "severity": "CRITICAL", - "detail": "Créatinine = 84 (NORMAL) mais verdict = NON CONFIRMÉ — VALEUR NORMALE", - "ground_truth": "Créatinine norme [50-120]" - } - ], - "bio_errors_count": 3, - "bio_critical_count": 3, - "code_errors": [ - { - "type": "SUSPICIOUS_CODE", - "severity": "MEDIUM", - "detail": "Code Z45.80: Code Z45.8 existe mais Z45.80 est suspect (vérifier)" - } - ], - "code_errors_count": 1, - "parsed_response": { - "objet": "Contestation Désaccord sur le DP et DAS — Mémoire en défense structuré", - "rappel_faits": "Patient masculin, 68 ans, hospitalisé 3 jours pour obstruction urétéro-iléale sur Bricker (antécédent de dérivation urinaire). Diagnostic d'entrée : pyélonéphrite sévère avec hydronéphrose. Actes réalisés : TDM abdominal [ACTE-1], urétéroscopie descendante, Brickeroscopie avec lésion iatrogène du Bricker [IMG-1]. Créatinine normale [BIO-8]. Sortie avec néphrostomie temporaire en attente de décision thérapeutique définitive.", - "moyens_defense": [ - { - "numero": 1, - "titre": "Le DP doit refléter le motif principal du séjour (obstruction urétéro-iléale aiguë avec pyélonéphrite), non une comorbidité pré-existante", - "argument": "L'établissement a codé E66.04 (Obésité) en DP, alors que l'IMC = 31.0 [DP] est une donnée anthropométrique stable, pré-existante, et n'a pas motivé l'hospitalisation. Le motif réel du séjour est l'urgence urologique : obstruction complète du passage urétéro-iléal [IMG-1] entraînant une pyélonéphrite sévère [DAS-2] et une lésion traumatique du Bricker [DAS-4] lors de la tentative de rétablissement du passage. Selon la règle CIM-10 fondamentale, le DP est le diagnostic qui a consommé le plus de ressources et justifie la durée de séjour. Ici, c'est l'intervention urologique d'urgence (urétéroscopie + Brickeroscopie) et la mise en place d'un drainage urinaire (néphrostomie), non la gestion de l'obésité. L'obésité n'a reçu aucun traitement spécifique, n'a pas allongé la durée de séjour, et n'a pas modifié la surveillance. Elle doit être codée en DAS si elle a une pertinence clinique documentée, mais ne peut être DP.", - "preuves": [ - { - "ref": "[IMG-1]", - "fait": "TDM abdominal : absence totale de passage entre l'uretère gauche et le Bricker ; urétéroscopie descendante jusqu'à la jonction urétéro-iléale sans passage possible ; Brickeroscopie révélant un Bricker extrêmement fragile, endommagé lors de la manœuvre", - "signification": "Obstruction urétéro-iléale complète justifiant l'intervention d'urgence" - }, - { - "ref": "[DAS-2]", - "fait": "Pyélonéphrite sévère (N10) codée en DAS", - "signification": "Complication infectieuse aiguë justifiant l'urgence de l'intervention" - }, - { - "ref": "[DAS-4]", - "fait": "Lésion traumatique du Bricker (T83.1) codée en DAS", - "signification": "Complication iatrogène directe de l'acte réalisé lors du séjour" - }, - { - "ref": "[DP]", - "fait": "IMC = 31.0 (obésité légère), valeur stable et pré-existante", - "signification": "Comorbidité chronique, non motif d'hospitalisation" - } - ], - "source_reglementaire": "Guide méthodologique T2A — Règle générale : Le DP est le diagnostic qui a consommé le plus de ressources et justifie la durée de séjour et les actes réalisés. Une comorbidité pré-existante sans traitement spécifique ne peut être DP." - }, - { - "numero": 2, - "titre": "L'UCR reconnaît que Z43.6 (Surveillance de néphrostomie) ou Z45.80 (Ajustement d'une prothèse urinaire) est l'acte effectivement réalisé, mais ce codage occulte la complication iatrogène majeure", - "argument": "L'UCR propose Z45.80 (Ajustement et entretien d'une prothèse interne urinaire) en DP, arguant que c'est l'acte effectivement réalisé. Nous reconnaissons que la mise en place d'une néphrostomie a bien eu lieu et que Z43.6 ou Z45.80 est pertinent. CEPENDANT, le codage en DP de Z45.80 seul masque la réalité clinique du séjour : il ne s'agit pas d'un simple ajustement de routine, mais d'une intervention d'urgence pour obstruction complète avec complication iatrogène (lésion du Bricker). Le code Z45.80 décrit une action (ajustement), pas le motif clinique qui a justifié cette action. Selon la hiérarchie diagnostique CIM-10, lorsqu'une complication aiguë (pyélonéphrite + obstruction) justifie une intervention, le DP doit refléter cette complication, pas seulement l'acte de surveillance/ajustement. Z45.80 est un code de surveillance/suivi, approprié pour un séjour de routine ; ici, le séjour est motivé par une urgence aiguë. Le DP doit être N13.1 (Hydronéphrose avec rétrécissement urétéral) ou N10 (Pyélonéphrite), avec Z45.80 ou Z43.6 en DAS pour documenter l'acte réalisé.", - "preuves": [ - { - "ref": "[IMG-1]", - "fait": "Absence totale de passage urétéro-iléal, Bricker fragile endommagé lors de la Brickeroscopie", - "signification": "Urgence aiguë, non routine d'ajustement" - }, - { - "ref": "[DAS-2]", - "fait": "Pyélonéphrite sévère (N10)", - "signification": "Complication infectieuse aiguë justifiant l'intervention" - } - ], - "source_reglementaire": "CIM-10 — Z45.80 est défini comme 'Ajustement et entretien d'une prothèse interne urinaire', code de surveillance/suivi. Il ne décrit pas le motif clinique d'urgence. Selon le guide méthodologique, le DP doit être le diagnostic qui a consommé le plus de ressources, pas l'acte de surveillance." - }, - { - "numero": 3, - "titre": "La lésion traumatique du Bricker (T83.1) est une complication iatrogène documentée et doit être maintenue en DAS", - "argument": "Le dossier d'imagerie [IMG-1] documente explicitement que le Bricker a été 'très légèrement endommagé au cours de cette Brickeroscopie'. Cette lésion est une complication mécanique directe de l'acte réalisé lors du séjour. Le code T83.1 (Complication mécanique d'autres prothèses et implants urinaires) est approprié et doit être maintenu en DAS. Cette complication a justifié la mise en place d'une néphrostomie temporaire et a modifié la prise en charge post-opératoire (surveillance accrue, décision thérapeutique différée). Elle répond aux critères de pertinence DAS : (1) traitement spécifique (drainage urinaire alternatif), (2) allongement potentiel de la durée de séjour, (3) modification de la surveillance. L'UCR ne conteste pas cette complication ; elle ne la mentionne pas dans sa proposition de codage, ce qui est une omission.", - "preuves": [ - { - "ref": "[IMG-1]", - "fait": "Brickeroscopie : 'Bricker extrêmement fragile, qui a été très légèrement endommagé au cours de cette Brickeroscopie'", - "signification": "Complication mécanique iatrogène documentée" - }, - { - "ref": "[DAS-4]", - "fait": "Lésion traumatique du Bricker (T83.1) codée en DAS", - "signification": "Code approprié pour cette complication" - } - ], - "source_reglementaire": "CIM-10 — T83.1 : Complication mécanique d'autres prothèses et implants urinaires. Applicable à toute lésion mécanique d'une dérivation urinaire (Bricker) survenue lors d'une intervention." - }, - { - "numero": 4, - "titre": "Les codes d'insuffisance rénale (N17.9, N19) ne sont pas défendables biologiquement et doivent être retirés", - "argument": "L'établissement a codé deux diagnostics d'insuffisance rénale : N17.9 (Insuffisance rénale aiguë, sans précision) et N19 (Insuffisance rénale, sans précision) en DAS. Or, la créatinine du patient est 84 µmol/L [BIO-8], valeur NORMALE (norme 50-120 µmol/L). Une créatinine normale CONTREDIT formellement le diagnostic d'insuffisance rénale aiguë ou chronique. Selon les seuils CIM-10, une IRA requiert une créatinine > 130 µmol/L (chez l'homme) ; ici, la valeur est normale. Ces deux codes ne disposent d'aucun support biologique et doivent être retirés. L'obstruction urétéro-iléale a bien créé un risque d'insuffisance rénale (hydronéphrose), mais la fonction rénale est restée préservée lors du séjour. Si une insuffisance rénale était présente avant l'hospitalisation, elle n'est pas documentée dans le dossier fourni. Nous recommandons le retrait de N17.9 et N19.", - "preuves": [ - { - "ref": "[BIO-8]", - "fait": "Créatinine = 84 µmol/L [norme 50-120 µmol/L]", - "signification": "Fonction rénale NORMALE, insuffisance rénale NON CONFIRMÉE" - } - ], - "source_reglementaire": "CIM-10 — N17.9 (IRA) et N19 (IR sans précision) requièrent une élévation de la créatinine. Seuil diagnostic : créatinine > 130 µmol/L (H) ou > 110 µmol/L (F). Valeur du dossier = 84 µmol/L → diagnostic non confirmé biologiquement." - }, - { - "numero": 5, - "titre": "La pyélonéphrite sévère (N10) est justifiée cliniquement et doit être maintenue en DAS", - "argument": "L'établissement a codé N10 (Néphrite tubulo-interstitielle aiguë / Pyélonéphrite sévère) en DAS. Ce diagnostic est documenté comme motif d'entrée et justifie l'urgence de l'intervention. Bien que la biologie fournie ne contienne pas de marqueurs inflammatoires (CRP, procalcitonine, leucocytes), les leucocytes sont normaux [BIO-7 = 8.18, norme 4-10], ce qui ne contredit pas une pyélonéphrite (la leucocytose n'est pas obligatoire). Le diagnostic repose sur le contexte clinique : obstruction urétéro-iléale [IMG-1] + symptomatologie infectieuse (implicite dans le motif d'hospitalisation) + nécessité d'une intervention d'urgence. N10 est approprié en DAS et doit être maintenu.", - "preuves": [ - { - "ref": "[IMG-1]", - "fait": "Obstruction urétéro-iléale complète créant un contexte de stase urinaire et d'infection", - "signification": "Contexte clinique justifiant le diagnostic de pyélonéphrite" - }, - { - "ref": "[BIO-7]", - "fait": "Leucocytes = 8.18 [norme 4-10]", - "signification": "Valeur normale, mais n'exclut pas une infection localisée (pyélonéphrite)" - }, - { - "ref": "[DAS-2]", - "fait": "Pyélonéphrite sévère (N10) codée en DAS", - "signification": "Code approprié pour l'infection du haut appareil urinaire" - } - ], - "source_reglementaire": "CIM-10 — N10 : Néphrite tubulo-interstitielle aiguë. Diagnostic clinique justifié par l'obstruction + contexte infectieux, même sans marqueurs inflammatoires élevés." - }, - { - "numero": 6, - "titre": "L'hydronéphrose (N13.1) est pré-existante et ne peut être DP, mais doit être maintenue en DAS comme diagnostic étiologique", - "argument": "L'UCR propose N13.1 (Hydronéphrose avec rétrécissement urétéral) en DA (diagnostic associé). Nous reconnaissons que l'hydronéphrose est pré-existante (antécédent de dérivation urinaire avec obstruction chronique progressive). Selon la règle CIM-10, un diagnostic pré-existant ne peut être DP. CEPENDANT, N13.1 doit être maintenu en DAS car il décrit l'étiologie de la pyélonéphrite aiguë et justifie l'intervention. L'obstruction urétéro-iléale [IMG-1] est la cause directe de l'hydronéphrose et de la stase urinaire ayant entraîné l'infection. N13.1 est pertinent en DAS pour documenter le contexte anatomique de la complication infectieuse aiguë.", - "preuves": [ - { - "ref": "[IMG-1]", - "fait": "Absence totale de passage urétéro-iléal, hydronéphrose secondaire", - "signification": "N13.1 décrit l'étiologie de la pyélonéphrite aiguë" - } - ], - "source_reglementaire": "CIM-10 — N13.1 : Hydronéphrose avec rétrécissement urétéral. Approprié en DAS pour documenter l'étiologie de la complication infectieuse aiguë, même si pré-existant." - }, - { - "numero": 7, - "titre": "L'hypertension artérielle (I10) et la colostomie (Z43.3) ne disposent pas de support documentaire suffisant dans le dossier fourni", - "argument": "L'établissement a codé I10 (Hypertension essentielle) et Z43.3 (Surveillance de colostomie) en DAS. Or, le dossier fourni ne contient aucune mention explicite d'hypertension artérielle (pas de valeurs tensionnelles, pas de traitement antihypertenseur documenté, pas de diagnostic d'HTA dans le texte clinique). De même, Z43.3 (colostomie) n'est pas mentionné dans le dossier ; le patient a un Bricker (dérivation urinaire), pas une colostomie (dérivation fécale). Z43.3 est un code erroné. I10 peut être une comorbidité, mais sans documentation explicite, elle ne peut être codée. Nous recommandons le retrait de I10 et Z43.3, ou leur remplacement par Z43.6 (Surveillance de néphrostomie) si une surveillance de stomie urinaire est pertinente.", - "preuves": [ - { - "ref": "[Dossier]", - "fait": "Aucune mention d'hypertension artérielle, aucune valeur tensionnelle, aucune mention de colostomie", - "signification": "Codes non documentés, non défendables" - } - ], - "source_reglementaire": "Règle CIM-10 : Un diagnostic ne peut être codé que s'il est explicitement documenté dans le dossier ou déduit de preuves objectives (biologie, imagerie, actes). Absence de documentation = code non défendable." - }, - { - "numero": 8, - "titre": "L'asymétrie informationnelle : la CPAM n'avait pas accès aux éléments cliniques objectifs justifiant les codes", - "argument": "L'UCR a rendu son avis en ne disposant que du CRH (compte rendu d'hospitalisation) et des codes, SANS accès à la biologie complète, à l'imagerie détaillée, ni aux actes CCAM. Les éléments suivants n'ont pas été transmis à la CPAM et justifient le codage établissement : (1) [IMG-1] : description détaillée de l'obstruction urétéro-iléale et de la lésion du Bricker, justifiant N10, T83.1, et la nécessité d'une intervention d'urgence ; (2) [BIO-8] : créatinine normale, contredisant les codes d'insuffisance rénale ; (3) [ACTE-1] : TDM abdominal, acte diagnostique justifiant la durée de séjour. Sans ces éléments, l'UCR a conclu que le DP était Z45.80 (ajustement de routine), alors que le dossier complet montre une urgence aiguë avec complication iatrogène. Cette asymétrie informationnelle a biaisé l'avis de l'UCR.", - "preuves": [ - { - "ref": "[IMG-1]", - "fait": "Imagerie détaillée non transmise à la CPAM", - "signification": "Justifie la nature urgente du séjour" - }, - { - "ref": "[BIO-8]", - "fait": "Créatinine normale non transmise à la CPAM", - "signification": "Contredit les codes d'insuffisance rénale" - } - ], - "source_reglementaire": "Principe de transparence informationnelle : L'UCR doit disposer de tous les éléments cliniques objectifs pour rendre un avis motivé. Une décision fondée sur des informations incomplètes peut être contestée." - } - ], - "confrontation_bio": [ - { - "diagnostic": "N17.9 — Insuffisance rénale aiguë, sans précision", - "test": "Créatinine", - "valeur": 84, - "seuil": "> 130 µmol/L (H)", - "verdict": "NON CONFIRMÉ — valeur NORMALE" - }, - { - "diagnostic": "N19 — Insuffisance rénale, sans précision", - "test": "Créatinine", - "valeur": 84, - "seuil": "> 130 µmol/L (H)", - "verdict": "NON CONFIRMÉ — valeur NORMALE" - }, - { - "diagnostic": "N10 — Pyélonéphrite sévère", - "test": "Leucocytes", - "valeur": 8.18, - "seuil": "4-10 (normal)", - "verdict": "COMPATIBLE — leucocytose non obligatoire pour pyélonéphrite localisée" - }, - { - "diagnostic": "I10 — Hypertension essentielle", - "test": "Aucune valeur tensionnelle documentée", - "valeur": "N/A", - "seuil": "N/A", - "verdict": "NON DOCUMENTÉ — code non défendable" - } - ], - "asymetrie_information": "La CPAM n'avait pas accès à : (1) [IMG-1] — description détaillée de l'obstruction urétéro-iléale complète et de la lésion du Bricker lors de la Brickeroscopie, élément central justifiant l'urgence et la complication iatrogène ; (2) [BIO-8] — créatinine = 84 µmol/L (NORMALE), contredisant les codes d'insuffisance rénale ; (3) [ACTE-1] — TDM abdominal, acte diagnostique justifiant la durée de séjour. Sans ces éléments objectifs, l'UCR a conclu que le séjour était une routine d'ajustement (Z45.80), alors que le dossier complet montre une urgence aiguë avec complication iatrogène majeure.", - "reponse_points_cpam": [ - { - "point_cpam": "Le DP doit être Z45.80 (Ajustement et entretien d'une prothèse interne urinaire) car c'est l'acte effectivement réalisé", - "reconnaissance": "PARTIELLEMENT JUSTE — Une néphrostomie a bien été mise en place, et Z45.80 ou Z43.6 décrit cet acte.", - "refutation": "CEPENDANT, Z45.80 est un code de surveillance/suivi, approprié pour un séjour de routine. Ici, le séjour est motivé par une URGENCE AIGUË : obstruction complète [IMG-1] + pyélonéphrite sévère [DAS-2] + complication iatrogène (lésion du Bricker) [DAS-4]. Selon la hiérarchie CIM-10, le DP doit refléter le motif clinique qui a justifié l'intervention, pas seulement l'acte de surveillance. Le DP doit être N13.1 (Hydronéphrose avec rétrécissement urétéral) ou N10 (Pyélonéphrite), avec Z45.80 en DAS pour documenter l'acte réalisé." - }, - { - "point_cpam": "L'hydronéphrose (N13.1) est pré-existante et ne peut être DP", - "reconnaissance": "JUSTE — L'hydronéphrose est effectivement pré-existante (antécédent de dérivation urinaire).", - "refutation": "MAIS N13.1 doit être maintenu en DAS car il décrit l'étiologie de la pyélonéphrite aiguë et justifie l'intervention. L'obstruction urétéro-iléale [IMG-1] est la cause directe de la stase urinaire et de l'infection. N13.1 est pertinent en DAS pour documenter le contexte anatomique." - }, - { - "point_cpam": "L'obésité (E66.04) n'est pas le DP car elle n'a pas consommé de ressources spécifiques", - "reconnaissance": "JUSTE — L'obésité est une comorbidité pré-existante sans traitement spécifique lors du séjour.", - "refutation": "Nous reconnaissons que E66.04 ne doit pas être DP. Le DP doit refléter le motif principal du séjour : l'urgence urologique (obstruction + pyélonéphrite + intervention). E66.04 peut être codé en DAS si pertinent cliniquement, mais ne peut être DP." - }, - { - "point_cpam": "L'ajout des autres codes DAS ne modifie pas le groupage GHM/GHS (Règle T7)", - "reconnaissance": "TECHNIQUEMENT JUSTE — La Règle T7 du guide méthodologique stipule que certains DAS n'impactent pas le groupage.", - "refutation": "CEPENDANT, cet argument ne justifie pas le retrait de codes cliniquement pertinents. La Règle T7 est une règle de groupage, pas une règle de codage clinique. Les codes doivent refléter la réalité clinique du séjour, indépendamment de leur impact sur le groupage. De plus, la lésion du Bricker (T83.1) est une complication iatrogène majeure qui doit être documentée, même si elle n'impacte pas le groupage." - } - ], - "codes_non_defendables": [ - { - "code": "N17.9 — Insuffisance rénale aiguë, sans précision", - "raison": "Créatinine = 84 µmol/L [norme 50-120] — valeur NORMALE. Diagnostic d'IRA requiert créatinine > 130 µmol/L (H). Biologie contredit formellement le diagnostic.", - "recommandation": "RETRAIT RECOMMANDÉ — Code indéfendable face à la CPAM. Aucun support biologique." - }, - { - "code": "N19 — Insuffisance rénale, sans précision", - "raison": "Créatinine = 84 µmol/L [norme 50-120] — valeur NORMALE. Diagnostic d'insuffisance rénale requiert élévation de la créatinine. Biologie contredit formellement le diagnostic.", - "recommandation": "RETRAIT RECOMMANDÉ — Code indéfendable face à la CPAM. Aucun support biologique." - }, - { - "code": "I10 — Hypertension essentielle (primitive)", - "raison": "Aucune mention d'hypertension artérielle dans le dossier fourni. Pas de valeurs tensionnelles documentées, pas de traitement antihypertenseur mentionné. Code non documenté.", - "recommandation": "RETRAIT RECOMMANDÉ — Code non documenté, non défendable. Si HTA est présente, elle doit être explicitement mentionnée dans le CRH." - }, - { - "code": "Z43.3 — Surveillance de colostomie", - "raison": "Patient a un Bricker (dérivation urinaire), pas une colostomie (dérivation fécale). Code anatomiquement erroné. Aucune mention de colostomie dans le dossier.", - "recommandation": "RETRAIT RECOMMANDÉ — Code erroné. Remplacer par Z43.6 (Surveillance de néphrostomie) ou Z45.80 (Ajustement de prothèse urinaire) si pertinent." - }, - { - "code": "Z00.0 — Examen médical général", - "raison": "Code générique de surveillance/examen de routine. Inapproprié pour un séjour d'urgence avec intervention. Aucune justification clinique documentée.", - "recommandation": "RETRAIT RECOMMANDÉ — Code non pertinent pour ce séjour d'urgence. Remplacer par codes spécifiques de la pathologie (N10, N13.1, T83.1)." - } - ], - "references": [ - { - "document": "Dossier médical — Imagerie (TDM abdominal + Urétéroscopie + Brickeroscopie)", - "page": "N/A", - "citation": "TDM abdominal — une absence totale de passage entre l'uretère gauche et le Bricker. Urétéroscopie descendante jusqu'en bas de l'uretère à la jonction urétéro-iléale. Aucun passage possible jusqu'au Bricker. Brickeroscopie : Bricker extrêmement fragile, très légèrement endommagé au cours de cette Brickeroscopie." - }, - { - "document": "Dossier médical — Biologie", - "page": "N/A", - "citation": "Créatinine = 84 µmol/L [norme 50-120] — NORMALE. Leucocytes = 8.18 [norme 4-10] — NORMAL. Sodium = 140 [norme 135-145] — NORMAL. Potassium = 3.9 [norme 3.5-5.0] — NORMAL." - }, - { - "document": "CIM-10 — Définitions officielles", - "page": "N/A", - "citation": "N17.9 : Insuffisance rénale aiguë, sans précision. N19 : Insuffisance rénale, sans précision. N10 : Néphrite tubulo-interstitielle aiguë. N13.1 : Hydronéphrose avec rétrécissement urétéral, non classée ailleurs. T83.1 : Complication mécanique d'autres prothèses et implants urinaires. Z45.80 : Ajustement et entretien d'une prothèse interne urinaire. Z43.6 : Surveillance de néphrostomie." - }, - { - "document": "Guide méthodologique T2A — Règles de codage", - "page": "N/A", - "citation": "Le DP est le diagnostic qui a consommé le plus de ressources et justifie la durée de séjour et les actes réalisés. Un diagnostic pré-existant sans traitement spécifique ne peut être DP. Un code ne peut être utilisé que s'il est explicitement documenté ou déduit de preuves objectives." - } - ], - "conclusion_dispositive": "Par conséquent, au vu des éléments cliniques objectifs fournis (obstruction urétéro-iléale complète [IMG-1], pyélonéphrite sévère [DAS-2], lésion traumatique du Bricker [DAS-4], créatinine normale [BIO-8]), des règles CIM-10 applicables (hiérarchie diagnostique, pertinence clinique, support biologique), et des informations complémentaires non transmises à l'UCR lors de son avis initial, nous demandons la MODIFICATION du codage comme suit :\n\n**DP PROPOSÉ : N13.1 — Hydronéphrose avec rétrécissement urétéral, non classée ailleurs**\n(Justification : Étiologie de la pyélonéphrite aiguë et motif principal de l'intervention d'urgence. Bien que pré-existante, elle justifie le séjour et la durée de prise en charge.)\n\n**DAS À MAINTENIR :**\n- N10 — Néphrite tubulo-interstitielle aiguë (Pyélonéphrite sévère) — JUSTIFIÉ cliniquement\n- T83.1 — Complication mécanique d'autres prothèses et implants urinaires (Lésion du Bricker) — DOCUMENTÉ en imagerie\n- Z45.80 — Ajustement et entretien d'une prothèse interne urinaire (Mise en place de néphrostomie) — ACTE RÉALISÉ\n- Z43.6 — Surveillance de néphrostomie (si pertinent pour documenter la surveillance post-opératoire) — ALTERNATIF à Z45.80\n\n**DAS À RETIRER :**\n- N17.9 — Insuffisance rénale aiguë, sans précision — NON CONFIRMÉ biologiquement (créatinine = 84 µmol/L, NORMALE)\n- N19 — Insuffisance rénale, sans précision — NON CONFIRMÉ biologiquement (créatinine = 84 µmol/L, NORMALE)\n- I10 — Hypertension essentielle — NON DOCUMENTÉ dans le dossier\n- Z43.3 — Surveillance de colostomie — CODE ERRONÉ (patient a Bricker, pas colostomie)\n- Z00.0 — Examen médical général — NON PERTINENT pour urgence aiguë\n- E66.04 — Obésité — COMORBIDITÉ PRÉ-EXISTANTE, ne peut être DP\n\n**RECONNAISSANCE DES POINTS LÉGITIMES DE L'UCR :**\nNous reconnaissons que (1) l'hydronéphrose est pré-existante et ne peut être DP selon la règle stricte ; (2) une néphrostomie a bien été mise en place et doit être codée ; (3) la Règle T7 s'applique au groupage. CEPENDANT, ces points ne justifient pas le retrait de codes cliniquement pertinents ni l'omission de la complication iatrogène majeure (lésion du Bricker).\n\n**ALTERNATIVE DÉFENSIVE (si N13.1 en DP est jugé inacceptable par la CPAM) :**\nSi l'UCR maintient son position que N13.1 ne peut être DP (pré-existant), nous acceptons Z45.80 en DP, À CONDITION que les codes suivants soient maintenus en DAS : N10 (pyélonéphrite justifiant l'urgence), T83.1 (complication iatrogène), et que les codes d'insuffisance rénale (N17.9, N19) soient retirés (non confirmés biologiquement).\n\n**DEMANDE FORMELLE :**\nNous demandons à l'UCR de reconsidérer son avis en tenant compte de l'imagerie détaillée [IMG-1], de la biologie complète [BIO-8], et de la complication iatrogène documentée [DAS-4], éléments qui n'étaient pas disponibles lors de l'avis initial. Le codage proposé par l'établissement reflète mieux la réalité clinique du séjour qu'une simple routine d'ajustement." - }, - "text_output": "═══════════════════════════════════════════════════════\nMÉMOIRE EN DÉFENSE — Contestation Désaccord sur le DP et DAS — Mémoire en défense structuré\n═══════════════════════════════════════════════════════\n\nRAPPEL DES FAITS\nPatient masculin, 68 ans, hospitalisé 3 jours pour obstruction urétéro-iléale sur Bricker (antécédent de dérivation urinaire). Diagnostic d'entrée : pyélonéphrite sévère avec hydronéphrose. Actes réalisés : TDM abdominal [ACTE-1], urétéroscopie descendante, Brickeroscopie avec lésion iatrogène du Bricker [IMG-1]. Créatinine normale [BIO-8]. Sortie avec néphrostomie temporaire en attente de décision thérapeutique définitive.\n\n───────────────────────────────────────────────────────\n\nMOYEN N°1 — Le DP doit refléter le motif principal du séjour (obstruction urétéro-iléale aiguë avec pyélonéphrite), non une comorbidité pré-existante\nL'établissement a codé E66.04 (Obésité) en DP, alors que l'IMC = 31.0 [DP] est une donnée anthropométrique stable, pré-existante, et n'a pas motivé l'hospitalisation. Le motif réel du séjour est l'urgence urologique : obstruction complète du passage urétéro-iléal [IMG-1] entraînant une pyélonéphrite sévère [DAS-2] et une lésion traumatique du Bricker [DAS-4] lors de la tentative de rétablissement du passage. Selon la règle CIM-10 fondamentale, le DP est le diagnostic qui a consommé le plus de ressources et justifie la durée de séjour. Ici, c'est l'intervention urologique d'urgence (urétéroscopie + Brickeroscopie) et la mise en place d'un drainage urinaire (néphrostomie), non la gestion de l'obésité. L'obésité n'a reçu aucun traitement spécifique, n'a pas allongé la durée de séjour, et n'a pas modifié la surveillance. Elle doit être codée en DAS si elle a une pertinence clinique documentée, mais ne peut être DP.\n Preuve : [IMG-1] TDM abdominal : absence totale de passage entre l'uretère gauche et le Bricker ; urétéroscopie descendante jusqu'à la jonction urétéro-iléale sans passage possible ; Brickeroscopie révélant un Bricker extrêmement fragile, endommagé lors de la manœuvre → Obstruction urétéro-iléale complète justifiant l'intervention d'urgence\n Preuve : [DAS-2] Pyélonéphrite sévère (N10) codée en DAS → Complication infectieuse aiguë justifiant l'urgence de l'intervention\n Preuve : [DAS-4] Lésion traumatique du Bricker (T83.1) codée en DAS → Complication iatrogène directe de l'acte réalisé lors du séjour\n Preuve : [DP] IMC = 31.0 (obésité légère), valeur stable et pré-existante → Comorbidité chronique, non motif d'hospitalisation\n Source : Guide méthodologique T2A — Règle générale : Le DP est le diagnostic qui a consommé le plus de ressources et justifie la durée de séjour et les actes réalisés. Une comorbidité pré-existante sans traitement spécifique ne peut être DP.\n\nMOYEN N°2 — L'UCR reconnaît que Z43.6 (Surveillance de néphrostomie) ou Z45.80 (Ajustement d'une prothèse urinaire) est l'acte effectivement réalisé, mais ce codage occulte la complication iatrogène majeure\nL'UCR propose Z45.80 (Aj" - } -] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 1e003c7..09b16a9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,4 +15,7 @@ python-dotenv>=1.0.0 openpyxl>=3.0.0 pandas>=2.0.0 PyMuPDF>=1.24.0 +python-docx>=1.0.0 PyYAML>=6.0 +gunicorn>=22.0.0 +flask-httpauth>=4.0.0 diff --git a/scripts/benchmark_models.py b/scripts/benchmark_models.py deleted file mode 100644 index f865ee7..0000000 --- a/scripts/benchmark_models.py +++ /dev/null @@ -1,313 +0,0 @@ -#!/usr/bin/env python3 -"""Benchmark A/B : gemma3:12b (base) vs pmsi-coder-v2 (fine-tuné). - -Compare les codes CIM-10 produits par les deux modèles sur N dossiers. -Teste DP + DAS (échantillon) pour chaque dossier. - -Usage: python scripts/benchmark_models.py [--n 50] [--das-max 5] -""" - -from __future__ import annotations - -import json -import random -import sys -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from src.config import STRUCTURED_DIR, OLLAMA_URL, DossierMedical -from src.medical.cim10_dict import load_dict, normalize_code, validate_code - -import requests - -MODEL_BASE = "gemma3:12b" -MODEL_FINETUNED = "pmsi-coder-v2" - -PROMPT_TEMPLATE = """Tu es un médecin DIM expert en codage PMSI. -Code le diagnostic suivant en CIM-10. Choisis le code le plus spécifique possible. - -DIAGNOSTIC : "{texte}" -TYPE : {type_diag} - -{contexte} - -Réponds UNIQUEMENT avec un objet JSON : -{{"code": "X99.9", "confidence": "high|medium|low", "justification": "explication courte"}}""" - - -def call_model(prompt: str, model: str, timeout: int = 120) -> tuple[dict | None, float]: - """Appelle un modèle Ollama et retourne (résultat, durée_s).""" - t0 = time.time() - try: - resp = requests.post( - f"{OLLAMA_URL}/api/generate", - json={ - "model": model, - "prompt": prompt, - "stream": False, - "format": "json", - "options": {"temperature": 0.1, "num_predict": 500}, - }, - timeout=timeout, - ) - resp.raise_for_status() - raw = resp.json().get("response", "") - duration = time.time() - t0 - try: - return json.loads(raw), duration - except json.JSONDecodeError: - return None, duration - except Exception as e: - return None, time.time() - t0 - - -def load_dossiers(n: int) -> list[dict]: - """Charge N dossiers fusionnés diversifiés.""" - dossiers = [] - for subdir in sorted(STRUCTURED_DIR.iterdir()): - if not subdir.is_dir(): - continue - for f in subdir.glob("*fusionne*.json"): - if ".gemma_" in f.name or ".bak" in f.name: - continue - try: - data = json.loads(f.read_text(encoding="utf-8")) - d = DossierMedical.model_validate(data) - if d.diagnostic_principal and d.diagnostic_principal.cim10_suggestion: - dossiers.append({ - "name": subdir.name, - "dossier": d, - "path": str(f), - }) - except Exception: - continue - break - random.seed(42) - random.shuffle(dossiers) - return dossiers[:n] - - -def build_contexte(d: DossierMedical) -> str: - """Construit un contexte clinique résumé.""" - parts = [] - s = d.sejour - if s.age is not None: - parts.append(f"Patient {s.sexe or '?'}, {s.age} ans") - if s.duree_sejour is not None: - parts.append(f"Durée séjour : {s.duree_sejour}j") - if d.diagnostic_principal: - parts.append(f"DP : {d.diagnostic_principal.texte}") - bio = [f"{b.test}={b.valeur}" for b in d.biologie_cle[:5] if b.valeur] - if bio: - parts.append(f"Bio : {', '.join(bio)}") - return "CONTEXTE : " + " | ".join(parts) if parts else "" - - -def code_match_level(code_a: str, code_b: str) -> str: - """Retourne le niveau de correspondance entre deux codes.""" - if code_a == code_b: - return "exact" - if code_a[:3] == code_b[:3]: - return "categorie" - return "diff" - - -def run_benchmark(n: int = 50, das_max: int = 5): - print(f"=== Benchmark A/B : {MODEL_BASE} vs {MODEL_FINETUNED} ===") - print(f" Dossiers : {n}, DAS max/dossier : {das_max}\n") - - # Vérifier que les deux modèles sont disponibles - for model in [MODEL_BASE, MODEL_FINETUNED]: - try: - resp = requests.post( - f"{OLLAMA_URL}/api/generate", - json={"model": model, "prompt": "test", "stream": False, - "options": {"num_predict": 1}}, - timeout=60, - ) - resp.raise_for_status() - print(f" {model} : OK") - except Exception as e: - print(f" {model} : ERREUR — {e}") - sys.exit(1) - - dossiers = load_dossiers(n) - print(f"\nDossiers chargés : {len(dossiers)}\n") - - cim10 = load_dict() - t_global_start = time.time() - - dp_results = [] - das_results = [] - - for i, item in enumerate(dossiers, 1): - d = item["dossier"] - dp = d.diagnostic_principal - name = item["name"] - ctx = build_contexte(d) - - # === DP === - prompt_dp = PROMPT_TEMPLATE.format( - texte=dp.texte, - type_diag="DP (diagnostic principal)", - contexte=ctx, - ) - res_base, t_base = call_model(prompt_dp, MODEL_BASE) - res_ft, t_ft = call_model(prompt_dp, MODEL_FINETUNED) - - code_base = normalize_code(res_base.get("code", "")) if res_base else "ERREUR" - code_ft = normalize_code(res_ft.get("code", "")) if res_ft else "ERREUR" - conf_base = res_base.get("confidence", "?") if res_base else "?" - conf_ft = res_ft.get("confidence", "?") if res_ft else "?" - valid_base = validate_code(code_base)[0] if code_base != "ERREUR" else False - valid_ft = validate_code(code_ft)[0] if code_ft != "ERREUR" else False - - pipeline_code = dp.cim10_suggestion - match_level = code_match_level(code_base, code_ft) - - dp_result = { - "dossier": name, - "texte": dp.texte[:80], - "pipeline": pipeline_code, - "base": code_base, - "ft": code_ft, - "conf_base": conf_base, - "conf_ft": conf_ft, - "valid_base": valid_base, - "valid_ft": valid_ft, - "match": match_level, - "t_base": round(t_base, 2), - "t_ft": round(t_ft, 2), - } - dp_results.append(dp_result) - - tag = {"exact": "=", "categorie": "~", "diff": "X"}[match_level] - print(f" [{i:2d}/{len(dossiers)}] {name:<20s} DP=\"{dp.texte[:35]:<35s}\" " - f"base={code_base:<7s} ft={code_ft:<7s} [{tag}] " - f"({t_base:.1f}s / {t_ft:.1f}s)") - - # === DAS (échantillon) === - das_list = [das for das in d.diagnostics_associes - if das.texte and das.cim10_suggestion] - if len(das_list) > das_max: - random.seed(hash(name)) - das_list = random.sample(das_list, das_max) - - for das in das_list: - prompt_das = PROMPT_TEMPLATE.format( - texte=das.texte, - type_diag="DAS (diagnostic associé significatif)", - contexte=ctx, - ) - res_b, tb = call_model(prompt_das, MODEL_BASE) - res_f, tf = call_model(prompt_das, MODEL_FINETUNED) - - cb = normalize_code(res_b.get("code", "")) if res_b else "ERREUR" - cf = normalize_code(res_f.get("code", "")) if res_f else "ERREUR" - vb = validate_code(cb)[0] if cb != "ERREUR" else False - vf = validate_code(cf)[0] if cf != "ERREUR" else False - - das_results.append({ - "dossier": name, - "texte": das.texte[:80], - "pipeline": das.cim10_suggestion, - "base": cb, - "ft": cf, - "conf_base": (res_b or {}).get("confidence", "?"), - "conf_ft": (res_f or {}).get("confidence", "?"), - "valid_base": vb, - "valid_ft": vf, - "match": code_match_level(cb, cf), - "t_base": round(tb, 2), - "t_ft": round(tf, 2), - }) - - t_global = time.time() - t_global_start - - # === RÉSUMÉ === - print(f"\n{'='*75}") - print(f"RÉSUMÉ — {len(dp_results)} dossiers, {len(das_results)} DAS testés") - print(f"Durée totale : {t_global/60:.1f} min\n") - - for label, results in [("DP", dp_results), ("DAS", das_results)]: - if not results: - continue - nt = len(results) - n_exact = sum(1 for r in results if r["match"] == "exact") - n_cat = sum(1 for r in results if r["match"] == "categorie") - n_diff = sum(1 for r in results if r["match"] == "diff") - n_vb = sum(1 for r in results if r["valid_base"]) - n_vf = sum(1 for r in results if r["valid_ft"]) - avg_tb = sum(r["t_base"] for r in results) / nt - avg_tf = sum(r["t_ft"] for r in results) / nt - - # Confiance - conf_b = {} - conf_f = {} - for r in results: - conf_b[r["conf_base"]] = conf_b.get(r["conf_base"], 0) + 1 - conf_f[r["conf_ft"]] = conf_f.get(r["conf_ft"], 0) + 1 - - # Concordance avec pipeline (gemma run original) - n_base_eq_pipe = sum(1 for r in results if r["base"] == r["pipeline"]) - n_ft_eq_pipe = sum(1 for r in results if r["ft"] == r["pipeline"]) - n_base_cat_pipe = sum(1 for r in results - if r["base"][:3] == r["pipeline"][:3]) - n_ft_cat_pipe = sum(1 for r in results - if r["ft"][:3] == r["pipeline"][:3]) - - print(f" --- {label} ({nt} diagnostics) ---") - print(f" Concordance base↔ft :") - print(f" Exact : {n_exact}/{nt} ({100*n_exact/nt:.0f}%)") - print(f" Catégorie : {n_exact+n_cat}/{nt} ({100*(n_exact+n_cat)/nt:.0f}%)") - print(f" Différent : {n_diff}/{nt} ({100*n_diff/nt:.0f}%)") - print(f" Codes valides :") - print(f" base : {n_vb}/{nt} ({100*n_vb/nt:.0f}%)") - print(f" ft : {n_vf}/{nt} ({100*n_vf/nt:.0f}%)") - print(f" vs pipeline (gemma original) :") - print(f" base=pipe : {n_base_eq_pipe}/{nt} exact, {n_base_cat_pipe}/{nt} catégorie") - print(f" ft=pipe : {n_ft_eq_pipe}/{nt} exact, {n_ft_cat_pipe}/{nt} catégorie") - print(f" Temps moyen : base={avg_tb:.2f}s ft={avg_tf:.2f}s (Δ={100*(avg_tf-avg_tb)/avg_tb:+.0f}%)") - print(f" Confiance base : {conf_b}") - print(f" Confiance ft : {conf_f}") - print() - - # Lister les différences DP - diffs_dp = [r for r in dp_results if r["match"] == "diff"] - if diffs_dp: - print(f" Différences DP ({len(diffs_dp)}) :") - for r in diffs_dp: - vb = "✓" if r["valid_base"] else "✗" - vf = "✓" if r["valid_ft"] else "✗" - print(f" {r['dossier']:<18s} \"{r['texte'][:40]}\"") - print(f" base={r['base']:<7s}{vb} ft={r['ft']:<7s}{vf} pipe={r['pipeline']}") - - # Sauvegarder - out = { - "meta": { - "date": time.strftime("%Y-%m-%dT%H:%M:%S"), - "model_base": MODEL_BASE, - "model_ft": MODEL_FINETUNED, - "n_dossiers": len(dp_results), - "n_das": len(das_results), - "duration_min": round(t_global / 60, 1), - }, - "dp": dp_results, - "das": das_results, - } - out_path = Path(__file__).parent.parent / "output" / "benchmark_ab.json" - out_path.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8") - print(f"\nRésultats détaillés : {out_path}") - - -if __name__ == "__main__": - import argparse - parser = argparse.ArgumentParser() - parser.add_argument("--n", type=int, default=50, - help="Nombre de dossiers à tester") - parser.add_argument("--das-max", type=int, default=5, - help="Max DAS testés par dossier") - args = parser.parse_args() - run_benchmark(args.n, args.das_max) diff --git a/scripts/benchmark_nuke3_compare.py b/scripts/benchmark_nuke3_compare.py deleted file mode 100644 index dc3509a..0000000 --- a/scripts/benchmark_nuke3_compare.py +++ /dev/null @@ -1,874 +0,0 @@ -#!/usr/bin/env python3 -"""Benchmark NUKE-3 — rapport comparatif LLM off vs on. - -Analyse les dossiers JSON existants (output/structured/) pour produire -des métriques DIM-like sur la sélection DP (NUKE-3). - -Mode 1 (par défaut) : analyse les JSON existants (pas d'Ollama requis). -Mode 2 (--rerun) : relance le pipeline 2× (LLM off puis LLM on) — - nécessite Ollama pour le mode "on". - -Usage: - python scripts/benchmark_nuke3_compare.py # analyse offline - python scripts/benchmark_nuke3_compare.py --n 10 # top 10 dossiers - python scripts/benchmark_nuke3_compare.py --rerun --n 5 # relance pipeline - python scripts/benchmark_nuke3_compare.py --dossiers A,B,C # dossiers spécifiques - python scripts/benchmark_nuke3_compare.py --gold data/gold_crh/gold_crh.jsonl - python scripts/benchmark_nuke3_compare.py --offline --case-id 74_23141536 - python scripts/benchmark_nuke3_compare.py --offline --top-errors 20 - python scripts/benchmark_nuke3_compare.py --offline --dim-pack 20 -""" - -from __future__ import annotations - -import argparse -import csv -import json -import os -import subprocess -import sys -import time -from datetime import datetime -from pathlib import Path -from statistics import mean - -ROOT = Path(__file__).resolve().parent.parent -sys.path.insert(0, str(ROOT)) - -OUTPUT_DIR = ROOT / "output" / "structured" -INPUT_DIR = ROOT / "input" -REPORT_PATH = ROOT / "docs" / "NUKE3_BENCHMARK_REPORT.md" -PY = str(ROOT / ".venv" / "bin" / "python3") - - -# --------------------------------------------------------------------------- -# Chargement JSON -# --------------------------------------------------------------------------- - -def find_merged_json(dossier_id: str) -> Path | None: - """Trouve le JSON fusionné d'un dossier.""" - d = OUTPUT_DIR / dossier_id - if not d.exists(): - return None - fusions = list(d.glob("*fusionne_cim10.json")) - if fusions: - return fusions[0] - cim10s = list(d.glob("*_cim10.json")) - return cim10s[0] if cim10s else None - - -def load_dossier_json(dossier_id: str) -> dict | None: - """Charge le JSON d'un dossier.""" - path = find_merged_json(dossier_id) - if not path: - return None - try: - return json.loads(path.read_text("utf-8")) - except (json.JSONDecodeError, OSError): - return None - - -def select_dossiers(n: int, specific: list[str] | None) -> list[str]: - """Sélectionne les dossiers à analyser.""" - if specific: - return [d.strip() for d in specific if d.strip()] - - all_dirs = sorted( - d.name for d in OUTPUT_DIR.iterdir() - if d.is_dir() and find_merged_json(d.name) is not None - ) - return all_dirs[:n] if n > 0 else all_dirs - - -# --------------------------------------------------------------------------- -# Analyse NUKE-3 d'un dossier -# --------------------------------------------------------------------------- - -def analyze_dp_selection(data: dict) -> dict: - """Extrait les métriques NUKE-3 d'un dossier.""" - dp_sel = data.get("dp_selection") - - result = { - "has_dp_selection": dp_sel is not None, - "verdict": None, - "confidence": None, - "chosen_code": None, - "n_candidates": 0, - "n_evidence": 0, - "is_comorbidity_dp": False, - "is_symptom_dp": False, - "is_act_only_dp": False, - "has_evidence": False, - "delta": None, - "reason": None, - } - - if not dp_sel: - return result - - result["verdict"] = dp_sel.get("verdict") - result["confidence"] = dp_sel.get("confidence") - result["chosen_code"] = dp_sel.get("chosen_code") - - candidates = dp_sel.get("candidates", []) - result["n_candidates"] = len(candidates) - - evidence = dp_sel.get("evidence", []) - result["n_evidence"] = len(evidence) - result["has_evidence"] = len(evidence) > 0 - - result["reason"] = dp_sel.get("reason") - - # Debug scores - debug = dp_sel.get("debug_scores") or {} - result["delta"] = debug.get("delta") - - # Flags du gagnant - if candidates: - winner = candidates[0] - result["is_comorbidity_dp"] = winner.get("is_comorbidity_like", False) - result["is_symptom_dp"] = winner.get("is_symptom_like", False) - result["is_act_only_dp"] = winner.get("is_act_only", False) - - return result - - -# --------------------------------------------------------------------------- -# Agrégation -# --------------------------------------------------------------------------- - -def compute_metrics(analyses: list[dict]) -> dict: - """Calcule les métriques agrégées DIM-like.""" - n = len(analyses) - if n == 0: - return {"n": 0} - - with_selection = [a for a in analyses if a["has_dp_selection"]] - n_sel = len(with_selection) - - confirmed = [a for a in with_selection if a["verdict"] == "CONFIRMED"] - review = [a for a in with_selection if a["verdict"] == "REVIEW"] - - # Métriques principales - confirmed_rate = len(confirmed) / n_sel if n_sel else 0 - - # Evidence - confirmed_with_evidence = sum(1 for a in confirmed if a["has_evidence"]) - confirmed_evidence_rate = confirmed_with_evidence / len(confirmed) if confirmed else 0 - - # Codes problématiques en DP - symptom_count = sum(1 for a in with_selection if a["is_symptom_dp"]) - comorbidity_count = sum(1 for a in with_selection if a["is_comorbidity_dp"]) - act_only_count = sum(1 for a in with_selection if a["is_act_only_dp"]) - - # Confidence - conf_high = sum(1 for a in with_selection if a["confidence"] == "high") - conf_med = sum(1 for a in with_selection if a["confidence"] == "medium") - conf_low = sum(1 for a in with_selection if a["confidence"] == "low") - - # R-codes en DP (symptômes) - r_code_count = sum( - 1 for a in with_selection - if a["chosen_code"] and a["chosen_code"].startswith("R") - ) - - return { - "n_total": n, - "n_with_selection": n_sel, - "confirmed_count": len(confirmed), - "review_count": len(review), - "confirmed_rate": round(confirmed_rate, 3), - "review_rate": round(1 - confirmed_rate, 3) if n_sel else 0, - "confirmed_evidence_rate": round(confirmed_evidence_rate, 3), - "dp_symptom_rate": round(symptom_count / n_sel, 3) if n_sel else 0, - "dp_comorbidity_rate": round(comorbidity_count / n_sel, 3) if n_sel else 0, - "dp_act_only_rate": round(act_only_count / n_sel, 3) if n_sel else 0, - "dp_r_code_rate": round(r_code_count / n_sel, 3) if n_sel else 0, - "confidence": { - "high": conf_high, - "medium": conf_med, - "low": conf_low, - }, - "confidence_high_rate": round(conf_high / n_sel, 3) if n_sel else 0, - } - - -# --------------------------------------------------------------------------- -# Évaluation gold CRH -# --------------------------------------------------------------------------- - -def load_gold(gold_path: str | Path) -> dict: - """Charge le gold JSONL et retourne un index case_id → GoldCRHCase.""" - from src.eval.gold_models import load_gold_index - return load_gold_index(Path(gold_path)) - - -def evaluate_gold_cases( - dossier_details: list[dict], - gold_index: dict, -) -> list[dict]: - """Évalue les dossiers présents dans le gold. Retourne une liste d'évaluations.""" - from src.eval.gold_models import evaluate_dp - - evals: list[dict] = [] - for d in dossier_details: - case_id = d["id"] - if case_id not in gold_index: - continue - gold_case = gold_index[case_id] - sel = d.get("dp_selection") or {} - chosen_code = sel.get("chosen_code") - verdict = sel.get("verdict") - confidence = sel.get("confidence") - - ev = evaluate_dp(chosen_code, gold_case) - ev["verdict"] = verdict - ev["confidence_nuke3"] = confidence - evals.append(ev) - return evals - - -def compute_gold_metrics(evals: list[dict]) -> dict: - """Calcule les métriques agrégées sur les cas gold.""" - n = len(evals) - if n == 0: - return {"n": 0} - - strict = sum(1 for e in evals if e["exact_match_strict"]) - tolerant = sum(1 for e in evals if e["exact_match_tolerant_codes"]) - family3 = sum(1 for e in evals if e["family3_match_tolerant"]) - acceptable = sum(1 for e in evals if e["acceptable_match"]) - symptom_bad = sum(1 for e in evals if e["symptom_not_allowed"]) - - # Confirmed-only accuracy - confirmed_evals = [e for e in evals if e["verdict"] == "CONFIRMED"] - n_conf = len(confirmed_evals) - conf_acceptable = sum(1 for e in confirmed_evals if e["acceptable_match"]) - - return { - "n": n, - "exact_match_strict": strict, - "exact_match_strict_rate": round(strict / n, 3), - "exact_match_tolerant": tolerant, - "exact_match_tolerant_rate": round(tolerant / n, 3), - "family3_match": family3, - "family3_match_rate": round(family3 / n, 3), - "acceptable_match": acceptable, - "acceptable_match_rate": round(acceptable / n, 3), - "confirmed_accuracy_tolerant": round(conf_acceptable / n_conf, 3) if n_conf else None, - "confirmed_count": n_conf, - "symptom_not_allowed": symptom_bad, - "symptom_not_allowed_rate": round(symptom_bad / n, 3), - } - - -def write_gold_eval_csv(evals: list[dict], csv_path: Path) -> None: - """Écrit le CSV d'évaluation gold.""" - cols = [ - "case_id", "chosen_code", "verdict", "confidence_nuke3", - "dp_expected_code", "acceptable_match", "exact_match_strict", - "symptom_not_allowed", "allow_symptom_dp", "confidence_gold", - ] - csv_path.parent.mkdir(parents=True, exist_ok=True) - with open(csv_path, "w", newline="", encoding="utf-8") as f: - writer = csv.DictWriter(f, fieldnames=cols, extrasaction="ignore") - writer.writeheader() - for ev in evals: - row = { - "case_id": ev["case_id"], - "chosen_code": ev["chosen_code"] or "", - "verdict": ev["verdict"] or "", - "confidence_nuke3": ev["confidence_nuke3"] or "", - "dp_expected_code": ev["dp_expected_code"], - "acceptable_match": ev["acceptable_match"], - "exact_match_strict": ev["exact_match_strict"], - "symptom_not_allowed": ev["symptom_not_allowed"], - "allow_symptom_dp": ev["allow_symptom_dp"], - "confidence_gold": ev["confidence_gold"], - } - writer.writerow(row) - - -# --------------------------------------------------------------------------- -# Re-run pipeline (mode --rerun) -# --------------------------------------------------------------------------- - -def check_ollama() -> bool: - """Vérifie que Ollama est joignable.""" - try: - import urllib.request - url = os.environ.get("OLLAMA_URL", "http://localhost:11434") - req = urllib.request.Request(f"{url}/api/tags", method="GET") - urllib.request.urlopen(req, timeout=5) - return True - except Exception: - return False - - -def run_pipeline_with_env(dossier_id: str, llm_flag: str) -> bool: - """Lance le pipeline sur un dossier avec T2A_DP_RANKER_LLM=flag.""" - env = os.environ.copy() - env["T2A_DP_RANKER_LLM"] = llm_flag - - try: - result = subprocess.run( - [PY, "-m", "src.main", str(INPUT_DIR / dossier_id)], - capture_output=True, text=True, cwd=str(ROOT), - timeout=600, env=env, - ) - return result.returncode == 0 - except Exception as e: - print(f" ERREUR: {e}") - return False - - -# --------------------------------------------------------------------------- -# Rapport Markdown -# --------------------------------------------------------------------------- - -def _pct(v: float) -> str: - return f"{v * 100:.1f}%" - - -def generate_report( - metrics_off: dict, - metrics_on: dict | None, - dossier_details: list[dict], - args: argparse.Namespace, - gold_metrics: dict | None = None, - gold_evals: list[dict] | None = None, -) -> str: - """Génère le rapport Markdown.""" - lines: list[str] = [] - now = datetime.now().strftime("%Y-%m-%d %H:%M") - - # Commit hash - try: - commit = subprocess.check_output( - ["git", "rev-parse", "--short", "HEAD"], - cwd=str(ROOT), text=True, stderr=subprocess.DEVNULL, - ).strip() - except Exception: - commit = "?" - - lines.append("# NUKE-3 — Benchmark Report") - lines.append("") - lines.append(f"**Date** : {now} ") - lines.append(f"**Commit** : `{commit}` ") - lines.append(f"**Dossiers analysés** : {metrics_off['n_total']} ") - lines.append(f"**Mode** : {'rerun pipeline' if args.rerun else 'analyse offline (JSON existants)'} ") - lines.append("") - - # Table comparative - lines.append("## Métriques DIM-like") - lines.append("") - - if metrics_on: - lines.append("| Métrique | LLM OFF | LLM ON | Delta |") - lines.append("|----------|---------|--------|-------|") - - rows = [ - ("CONFIRMED rate", "confirmed_rate"), - ("REVIEW rate", "review_rate"), - ("CONFIRMED + evidence", "confirmed_evidence_rate"), - ("DP symptôme (R*)", "dp_symptom_rate"), - ("DP comorbidité", "dp_comorbidity_rate"), - ("DP acte-seul", "dp_act_only_rate"), - ("DP R-code", "dp_r_code_rate"), - ("Confidence high", "confidence_high_rate"), - ] - for label, key in rows: - v_off = metrics_off.get(key, 0) - v_on = metrics_on.get(key, 0) - delta = v_on - v_off - sign = "+" if delta > 0 else "" - lines.append( - f"| {label} | {_pct(v_off)} | {_pct(v_on)} | {sign}{_pct(delta)} |" - ) - else: - lines.append("| Métrique | Valeur |") - lines.append("|----------|--------|") - rows_single = [ - ("CONFIRMED rate", "confirmed_rate"), - ("REVIEW rate", "review_rate"), - ("CONFIRMED + evidence", "confirmed_evidence_rate"), - ("DP symptôme (R*)", "dp_symptom_rate"), - ("DP comorbidité", "dp_comorbidity_rate"), - ("DP acte-seul", "dp_act_only_rate"), - ("DP R-code", "dp_r_code_rate"), - ("Confidence high", "confidence_high_rate"), - ] - for label, key in rows_single: - v = metrics_off.get(key, 0) - lines.append(f"| {label} | {_pct(v)} |") - - lines.append("") - - # Volumes - lines.append("## Volumes") - lines.append("") - lines.append(f"- Dossiers avec dp_selection : {metrics_off['n_with_selection']}/{metrics_off['n_total']}") - lines.append(f"- CONFIRMED : {metrics_off['confirmed_count']}") - lines.append(f"- REVIEW : {metrics_off['review_count']}") - c = metrics_off.get("confidence", {}) - lines.append(f"- Confidence — high: {c.get('high', 0)}, medium: {c.get('medium', 0)}, low: {c.get('low', 0)}") - lines.append("") - - # Détail par dossier - lines.append("## Détail par dossier") - lines.append("") - lines.append("| Dossier | Verdict | Code | Confidence | Evidence | Candidats | Reason |") - lines.append("|---------|---------|------|------------|----------|-----------|--------|") - for d in dossier_details: - sel = d.get("dp_selection", {}) - if not sel: - lines.append(f"| {d['id']} | - | - | - | - | - | pas de dp_selection |") - continue - lines.append( - f"| {d['id']} " - f"| {sel.get('verdict', '-')} " - f"| {sel.get('chosen_code', '-')} " - f"| {sel.get('confidence', '-')} " - f"| {sel.get('n_evidence', 0)} " - f"| {sel.get('n_candidates', 0)} " - f"| {(sel.get('reason') or '-')[:60]} |" - ) - - # Section gold CRH - if gold_metrics and gold_metrics.get("n", 0) > 0: - gm = gold_metrics - lines.append("") - lines.append("## Évaluation Gold CRH") - lines.append("") - lines.append(f"**Cas gold évalués** : {gm['n']} ") - lines.append("") - lines.append("| Métrique | Valeur |") - lines.append("|----------|--------|") - lines.append(f"| Exact match (strict) | {_pct(gm['exact_match_strict_rate'])} ({gm['exact_match_strict']}/{gm['n']}) |") - lines.append(f"| Exact match (codes tolérants) | {_pct(gm['exact_match_tolerant_rate'])} ({gm['exact_match_tolerant']}/{gm['n']}) |") - lines.append(f"| Family3 match (tolérant) | {_pct(gm['family3_match_rate'])} ({gm['family3_match']}/{gm['n']}) |") - lines.append(f"| Acceptable match (codes OU family3) | {_pct(gm['acceptable_match_rate'])} ({gm['acceptable_match']}/{gm['n']}) |") - if gm["confirmed_accuracy_tolerant"] is not None: - lines.append(f"| Confirmed accuracy (tolérant) | {_pct(gm['confirmed_accuracy_tolerant'])} ({gm['confirmed_count']} CONFIRMED) |") - lines.append(f"| Symptôme non autorisé | {gm['symptom_not_allowed']}/{gm['n']} |") - lines.append("") - - # Détail par cas gold - if gold_evals: - lines.append("### Détail par cas gold") - lines.append("") - lines.append("| Case ID | Choisi | Attendu | Strict | Acceptable | Symptôme interdit | Verdict |") - lines.append("|---------|--------|---------|--------|------------|-------------------|---------|") - for ev in gold_evals: - ok_s = "OK" if ev["exact_match_strict"] else "FAIL" - ok_a = "OK" if ev["acceptable_match"] else "FAIL" - sym = "OUI" if ev["symptom_not_allowed"] else "-" - lines.append( - f"| {ev['case_id']} " - f"| {ev['chosen_code'] or '-'} " - f"| {ev['dp_expected_code']} " - f"| {ok_s} " - f"| {ok_a} " - f"| {sym} " - f"| {ev['verdict'] or '-'} |" - ) - lines.append("") - - lines.append("") - lines.append("---") - lines.append(f"*Généré par `scripts/benchmark_nuke3_compare.py` — {now}*") - - # Règle DIM rappel - lines.append("") - lines.append("> **Règle DIM** : `CONFIRMED` ⇒ `evidence` obligatoirement non vide.") - lines.append("> Un DP sans preuve exploitable est automatiquement `REVIEW`.") - - return "\n".join(lines) - - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - -def _rebuild_and_select(data: dict) -> dict: - """Reconstruit un DossierMedical depuis le JSON et exécute select_dp() offline. - - Utile quand les JSON n'ont pas de champ dp_selection (générés avant NUKE-3). - """ - from src.config import DossierMedical, Diagnostic, Sejour - from src.medical.dp_selector import select_dp - - dp_raw = data.get("diagnostic_principal", {}) - das_raw = data.get("diagnostics_associes", []) - doc_type = data.get("document_type", "crh") - sej_raw = data.get("sejour", {}) - - dp_diag = None - if dp_raw and dp_raw.get("texte"): - dp_diag = Diagnostic( - texte=dp_raw.get("texte", ""), - cim10_suggestion=dp_raw.get("cim10_suggestion") or dp_raw.get("cim10_final"), - cim10_confidence=dp_raw.get("cim10_confidence"), - source=dp_raw.get("source"), - ) - das_list = [] - for d_item in das_raw: - code = d_item.get("cim10_suggestion") or d_item.get("cim10_final") - if not code: - continue - das_list.append(Diagnostic( - texte=d_item.get("texte", ""), - cim10_suggestion=code, - cim10_confidence=d_item.get("cim10_confidence"), - source=d_item.get("source"), - status=d_item.get("status"), - )) - - safe_sej = {k: v for k, v in sej_raw.items() if k in Sejour.model_fields} - dossier = DossierMedical( - document_type=doc_type, - sejour=Sejour(**safe_sej), - diagnostic_principal=dp_diag, - diagnostics_associes=das_list, - ) - - # Construire synthese depuis les champs disponibles. - # Les JSONs pré-NUKE-3 n'ont pas de sections CRH stockées. - # On récupère le texte de conclusion depuis les source_excerpt si besoin. - conclusion = data.get("conclusion_medicale", "") - if not conclusion: - # Chercher "CONCLUSION" dans source_excerpt des DAS ou traitements. - # Prendre l'extrait le plus long (les courts sont souvent tronqués). - best = "" - for container in (das_raw, data.get("traitements_sortie", [])): - for item in container: - excerpt = item.get("source_excerpt", "") - up = excerpt.upper() - if "CONCLUSION" in up: - idx = up.index("CONCLUSION") - candidate = excerpt[idx:] - if len(candidate) > len(best): - best = candidate - conclusion = best - - synthese = { - "motif": data.get("motif_hospitalisation", ""), - "conclusion": conclusion, - "diag_sortie": data.get("synthese_medicale", {}).get("diag_sortie", ""), - "diag_principal": data.get("synthese_medicale", {}).get("diag_principal", ""), - "synthese": data.get("synthese_medicale", {}).get("synthese", ""), - } - - selection = select_dp(dossier, synthese, config={"llm_enabled": False}) - dossier.dp_selection = selection - - # Finalizer DP (arbitrage Trackare vs CRH, traçabilité) - try: - from src.medical.dp_finalizer import finalize_dp - finalize_dp(dossier) - except Exception: - pass - - # Utiliser dp_final si disponible, sinon dp_selection - final = dossier.dp_final or selection - - # Convertir en dict compatible analyze_dp_selection - cands = [c.model_dump() for c in final.candidates] - result = { - "dp_selection": { - "verdict": final.verdict, - "confidence": final.confidence, - "chosen_code": final.chosen_code, - "chosen_term": final.chosen_term, - "candidates": cands, - "evidence": final.evidence, - "reason": final.reason, - "debug_scores": final.debug_scores, - } - } - if dossier.dp_final: - result["dp_final"] = dossier.dp_final.model_dump(exclude_none=True) - if dossier.quality_flags: - result["quality_flags"] = dossier.quality_flags - return result - - -def _run_debug_reports( - args: argparse.Namespace, - dossier_ids: list[str], - dossier_details: list[dict], - gold_index: dict | None, - gold_evals: list[dict] | None, - out_dir: Path, -) -> None: - """Exécute les modes --case-id, --top-errors, --dim-pack.""" - from src.eval.gold_debug import ( - build_case_report, - write_case_report, - build_error_entry, - sort_error_entries, - write_top_errors_csv, - write_top_errors_md, - write_top_errors_jsonl, - select_dim_pack_cases, - write_dim_pack, - ) - from src.eval.gold_models import evaluate_dp - - has_debug = args.case_id or args.top_errors > 0 or args.dim_pack > 0 - if not has_debug: - return - - # Helper : build full report for a case - def _build_report_for(case_id: str) -> dict | None: - data = load_dossier_json(case_id) - if not data: - return None - - # Offline rebuild si nécessaire - if args.offline and not data.get("dp_selection"): - rebuilt = _rebuild_and_select(data) - data["dp_selection"] = rebuilt["dp_selection"] - - dp_sel = data.get("dp_selection") - - gold_case_dict = None - eval_result = None - if gold_index and case_id in gold_index: - gc = gold_index[case_id] - gold_case_dict = gc.model_dump() - chosen_code = (dp_sel or {}).get("chosen_code") - eval_result = evaluate_dp(chosen_code, gc) - - return build_case_report(case_id, data, dp_sel, gold_case_dict, eval_result) - - # --case-id - if args.case_id: - cid = args.case_id.strip() - data = load_dossier_json(cid) - if not data: - print(f"ERREUR: output JSON introuvable pour {cid}") - print(f" Suggestion : relancer le pipeline avec --rerun ou vérifier output/structured/{cid}/") - sys.exit(1) - if gold_index and cid not in gold_index: - print(f"ERREUR: {cid} absent du gold ({len(gold_index)} cas chargés)") - sys.exit(1) - - report = _build_report_for(cid) - if report: - jp, mp = write_case_report(report, out_dir) - print(f"\n=== Case debug: {cid} ===") - print(f" JSON : {jp}") - print(f" MD : {mp}") - - # --top-errors - if args.top_errors > 0: - if not gold_index: - print("ERREUR: --top-errors requiert --gold (ou auto-détection gold_crh.jsonl)") - sys.exit(1) - - # Build reports for all gold cases - all_reports: list[dict] = [] - gold_case_ids = set(gold_index.keys()) - for cid in dossier_ids: - if cid not in gold_case_ids: - continue - r = _build_report_for(cid) - if r: - all_reports.append(r) - - entries = [build_error_entry(r) for r in all_reports] - entries = sort_error_entries(entries) - entries = entries[:args.top_errors] - - csv_p = out_dir / "NUKE3_GOLD_TOP_ERRORS.csv" - md_p = out_dir / "NUKE3_GOLD_TOP_ERRORS.md" - jsonl_p = out_dir / "NUKE3_GOLD_TOP_ERRORS.jsonl" - - write_top_errors_csv(entries, csv_p) - write_top_errors_md(entries, md_p) - write_top_errors_jsonl(entries, jsonl_p) - - print(f"\n=== Top {len(entries)} erreurs gold ===") - print(f" CSV : {csv_p}") - print(f" MD : {md_p}") - print(f" JSONL : {jsonl_p}") - - # --dim-pack - if args.dim_pack > 0: - # Build reports for all CRH (non-trackare) dossiers - all_reports_dim: list[dict] = [] - for cid in dossier_ids: - r = _build_report_for(cid) - if r and r["document_type"] != "trackare": - all_reports_dim.append(r) - elif r and r["prediction"]["verdict"] == "REVIEW": - # Include trackare-sans-DP too (they go through scoring) - all_reports_dim.append(r) - - selected = select_dim_pack_cases(all_reports_dim, args.dim_pack) - csv_p, cases_dir = write_dim_pack(selected, out_dir) - - print(f"\n=== DIM Pack ({len(selected)} cas) ===") - print(f" CSV : {csv_p}") - print(f" Cas JSON : {cases_dir}/") - - -def main(): - parser = argparse.ArgumentParser(description="Benchmark NUKE-3 comparatif") - parser.add_argument("--n", type=int, default=0, help="Nombre de dossiers (0=tous)") - parser.add_argument("--dossiers", type=str, default="", help="IDs séparés par virgules") - parser.add_argument("--rerun", action="store_true", help="Relancer le pipeline (nécessite Ollama pour LLM on)") - parser.add_argument("--offline", action="store_true", - help="Exécuter NUKE-3 offline (reconstruit DossierMedical depuis JSON, LLM off)") - parser.add_argument("--gold", type=str, default="", - help="Fichier JSONL gold CRH (évaluation tolérante)") - parser.add_argument("--case-id", type=str, default="", - help="Rapport détaillé pour un cas (ex: 74_23141536)") - parser.add_argument("--top-errors", type=int, default=0, - help="Top N erreurs gold (ex: 20)") - parser.add_argument("--dim-pack", type=int, default=0, - help="Pack DIM de N cas CRH à annoter (ex: 20)") - parser.add_argument("--out-dir", type=str, default=str(ROOT / "docs" / "gold_debug"), - help="Dossier de sortie pour debug reports") - parser.add_argument("--output", type=str, default=str(REPORT_PATH), help="Chemin du rapport") - args = parser.parse_args() - - specific = [d.strip() for d in args.dossiers.split(",") if d.strip()] if args.dossiers else None - dossier_ids = select_dossiers(args.n, specific) - - if not dossier_ids: - print("ERREUR: aucun dossier trouvé") - sys.exit(1) - - print(f"NUKE-3 benchmark — {len(dossier_ids)} dossiers") - - # Mode rerun - if args.rerun: - ollama_ok = check_ollama() - print(f" Ollama: {'OK' if ollama_ok else 'INDISPONIBLE'}") - - # Pass 1 : LLM OFF - print("\n=== Pass 1 : T2A_DP_RANKER_LLM=0 ===") - for did in dossier_ids: - ok = run_pipeline_with_env(did, "0") - status = "OK" if ok else "FAIL" - print(f" {did}: {status}") - - # Analyse JSON existants (ou résultat du pass 1) - print("\n=== Analyse des dossiers ===") - analyses_off: list[dict] = [] - dossier_details: list[dict] = [] - - for did in dossier_ids: - data = load_dossier_json(did) - if not data: - print(f" {did}: JSON introuvable") - dossier_details.append({"id": did, "dp_selection": None}) - continue - - # Mode offline : reconstruire le DossierMedical et exécuter select_dp - if args.offline and not data.get("dp_selection"): - rebuilt = _rebuild_and_select(data) - data["dp_selection"] = rebuilt["dp_selection"] - - analysis = analyze_dp_selection(data) - analyses_off.append(analysis) - dossier_details.append({"id": did, "dp_selection": analysis}) - - verdict = analysis["verdict"] or "-" - code = analysis["chosen_code"] or "-" - print(f" {did}: {verdict} — {code} (evidence: {analysis['n_evidence']})") - - metrics_off = compute_metrics(analyses_off) - - # Pass 2 : LLM ON (si rerun + Ollama dispo) - metrics_on = None - if args.rerun: - if not check_ollama(): - print("\nWARN: Ollama indisponible — pass LLM ON ignorée") - print(" Le rapport ne contiendra que les métriques LLM OFF") - else: - print("\n=== Pass 2 : T2A_DP_RANKER_LLM=1 ===") - for did in dossier_ids: - ok = run_pipeline_with_env(did, "1") - status = "OK" if ok else "FAIL" - print(f" {did}: {status}") - - analyses_on: list[dict] = [] - for did in dossier_ids: - data = load_dossier_json(did) - if data: - analyses_on.append(analyze_dp_selection(data)) - metrics_on = compute_metrics(analyses_on) - - # Gold CRH - gold_metrics = None - gold_evals = None - gold_index = None - - gold_path = args.gold - if not gold_path: - # Auto-détection - default_gold = ROOT / "data" / "gold_crh" / "gold_crh.jsonl" - if default_gold.exists(): - gold_path = str(default_gold) - - if gold_path: - try: - gold_index = load_gold(gold_path) - print(f"\n=== Évaluation Gold CRH ({len(gold_index)} cas) ===") - gold_evals = evaluate_gold_cases(dossier_details, gold_index) - gold_metrics = compute_gold_metrics(gold_evals) - - for ev in gold_evals: - match_str = "OK" if ev["acceptable_match"] else "FAIL" - sym_str = " [R* interdit]" if ev["symptom_not_allowed"] else "" - print(f" {ev['case_id']}: {ev['chosen_code'] or '-'} vs {ev['dp_expected_code']}" - f" → {match_str}{sym_str}") - - # CSV évaluation - csv_out = ROOT / "docs" / "NUKE3_GOLD_EVAL.csv" - write_gold_eval_csv(gold_evals, csv_out) - print(f"\nCSV évaluation : {csv_out}") - except Exception as e: - print(f"\nERREUR gold : {e}") - gold_metrics = None - gold_evals = None - - # --- Debug reports (--case-id, --top-errors, --dim-pack) --- - out_dir = Path(args.out_dir) - _run_debug_reports(args, dossier_ids, dossier_details, gold_index, gold_evals, out_dir) - - # Rapport - report = generate_report( - metrics_off, metrics_on, dossier_details, args, - gold_metrics=gold_metrics, gold_evals=gold_evals, - ) - output_path = Path(args.output) - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(report, encoding="utf-8") - print(f"\nRapport écrit : {output_path}") - - # Résumé console - print(f"\n{'='*50}") - print(f"CONFIRMED : {metrics_off['confirmed_count']}/{metrics_off['n_with_selection']}" - f" ({_pct(metrics_off['confirmed_rate'])})") - print(f"REVIEW : {metrics_off['review_count']}/{metrics_off['n_with_selection']}" - f" ({_pct(metrics_off['review_rate'])})") - print(f"Evidence : {_pct(metrics_off['confirmed_evidence_rate'])} des CONFIRMED") - print(f"DP symptôme : {_pct(metrics_off['dp_symptom_rate'])}") - print(f"DP comorbidité: {_pct(metrics_off['dp_comorbidity_rate'])}") - if gold_metrics and gold_metrics.get("n", 0) > 0: - gm = gold_metrics - print(f"\n--- Gold CRH ({gm['n']} cas) ---") - print(f"Strict match : {_pct(gm['exact_match_strict_rate'])}") - print(f"Acceptable match : {_pct(gm['acceptable_match_rate'])}") - if gm['confirmed_accuracy_tolerant'] is not None: - print(f"Confirmed acc. : {_pct(gm['confirmed_accuracy_tolerant'])}") - print(f"Symptôme interdit: {gm['symptom_not_allowed']}") - print(f"{'='*50}") - - -if __name__ == "__main__": - main() diff --git a/scripts/benchmark_quality.py b/scripts/benchmark_quality.py deleted file mode 100644 index c6fb564..0000000 --- a/scripts/benchmark_quality.py +++ /dev/null @@ -1,722 +0,0 @@ -#!/usr/bin/env python3 -"""Benchmark qualité T2A — validation end-to-end sur vrais dossiers. - -Compare la qualité des codes CIM-10, vetos, downgrades et CPAM -entre runs successifs. Chaque run est sauvegardé dans un répertoire -isolé pour permettre des comparaisons A/B. - -Usage: - python scripts/benchmark_quality.py --n 10 - python scripts/benchmark_quality.py --n 10 --compare RUN_ID - python scripts/benchmark_quality.py --dossiers 116_23065570,45_23183041 - python scripts/benchmark_quality.py --gold-standard -""" - -from __future__ import annotations - -import argparse -import json -import os -import shutil -import subprocess -import sys -import time -from datetime import datetime -from pathlib import Path -from statistics import mean, median - -ROOT = Path(__file__).resolve().parent.parent -sys.path.insert(0, str(ROOT)) - -BENCHMARKS_DIR = ROOT / "output" / "benchmarks" -GOLD_STANDARD_FILE = ROOT / "data" / "gold_standard" / "_selection.json" -INPUT_DIR = ROOT / "input" -OUTPUT_DIR = ROOT / "output" / "structured" -PY = str(ROOT / ".venv" / "bin" / "python3") - - -# --------------------------------------------------------------------------- -# Sélection des dossiers -# --------------------------------------------------------------------------- - -def _gold_standard_ids() -> list[str]: - """Charge les IDs du gold standard.""" - if not GOLD_STANDARD_FILE.exists(): - print(f"ERREUR: {GOLD_STANDARD_FILE} introuvable") - sys.exit(1) - data = json.loads(GOLD_STANDARD_FILE.read_text("utf-8")) - # Format: "116_23065570/116_23065570_fusionne_cim10" → on prend la partie avant / - return [d.split("/")[0] for d in data["dossiers"]] - - -def select_dossiers(n: int, gold_standard: bool, specific: list[str] | None, seed: int = 42) -> list[str]: - """Sélectionne les dossiers à benchmarker.""" - if specific: - # Vérifier que les dossiers existent - valid = [] - for d in specific: - if (INPUT_DIR / d).is_dir(): - valid.append(d) - else: - print(f" WARN: dossier {d} introuvable dans input/") - return valid - - if gold_standard: - ids = _gold_standard_ids() - return ids[:n] if n < len(ids) else ids - - # Sinon : prendre N dossiers depuis input/ (tri déterministe + seed pour reproductibilité) - all_dirs = sorted( - d.name for d in INPUT_DIR.iterdir() - if d.is_dir() and any(d.glob("*.pdf")) - ) - if not all_dirs: - print("ERREUR: aucun dossier avec PDF dans input/") - sys.exit(1) - - import random - rng = random.Random(seed) - rng.shuffle(all_dirs) - return all_dirs[:n] - - -# --------------------------------------------------------------------------- -# Exécution pipeline -# --------------------------------------------------------------------------- - -def run_pipeline(dossier_id: str, clean: bool) -> tuple[float, bool]: - """Exécute le pipeline sur un dossier. Retourne (durée_s, succès).""" - input_path = INPUT_DIR / dossier_id - - if clean: - for subdir in ["structured", "reports", "anonymized"]: - target = ROOT / "output" / subdir / dossier_id - if target.exists(): - shutil.rmtree(target) - - t0 = time.time() - try: - result = subprocess.run( - [PY, "-m", "src.main", str(input_path)], - capture_output=True, - text=True, - cwd=str(ROOT), - timeout=600, # 10 min max par dossier - ) - duration = time.time() - t0 - if result.returncode != 0: - print(f" STDERR: {result.stderr[-500:]}") - return duration, False - return duration, True - except subprocess.TimeoutExpired: - return time.time() - t0, False - except Exception as e: - print(f" EXCEPTION: {e}") - return time.time() - t0, False - - -# --------------------------------------------------------------------------- -# Chargement dictionnaire CIM-10 -# --------------------------------------------------------------------------- - -def load_cim10_dict() -> dict[str, str]: - """Charge le dictionnaire CIM-10 (sans passer par le singleton).""" - dict_path = ROOT / "data" / "cim10_dict.json" - supp_path = ROOT / "data" / "cim10_supplements.json" - d = {} - if dict_path.exists(): - d = json.loads(dict_path.read_text("utf-8")) - if supp_path.exists(): - for code, label in json.loads(supp_path.read_text("utf-8")).items(): - d.setdefault(code, label) - return d - - -def normalize_code(code: str) -> str: - """K810 → K81.0, k85.1 → K85.1.""" - code = code.strip().upper() - if len(code) > 3 and "." not in code: - code = code[:3] + "." + code[3:] - return code - - -def is_valid_code(code: str, cim10: dict[str, str]) -> bool: - """Vérifie si un code CIM-10 existe dans le dictionnaire.""" - nc = normalize_code(code) - return nc in cim10 or code.upper().strip() in cim10 - - -# --------------------------------------------------------------------------- -# Analyse d'un dossier -# --------------------------------------------------------------------------- - -def find_merged_json(dossier_id: str) -> Path | None: - """Trouve le JSON fusionné d'un dossier.""" - d = OUTPUT_DIR / dossier_id - if not d.exists(): - return None - # Chercher le fusionné d'abord - fusions = list(d.glob("*fusionne_cim10.json")) - if fusions: - return fusions[0] - # Sinon premier _cim10.json - cim10s = list(d.glob("*_cim10.json")) - return cim10s[0] if cim10s else None - - -def analyze_dossier(dossier_id: str, cim10: dict[str, str], duration: float) -> dict: - """Analyse le JSON de sortie d'un dossier et extrait les métriques.""" - result = { - "dossier_id": dossier_id, - "processing_time_s": round(duration, 1), - "success": False, - } - - json_path = find_merged_json(dossier_id) - if not json_path: - return result - - try: - data = json.loads(json_path.read_text("utf-8")) - except (json.JSONDecodeError, OSError): - return result - - result["success"] = True - - # --- DP --- - dp = data.get("diagnostic_principal", {}) - dp_code = dp.get("cim10_final") or dp.get("cim10_suggestion") or "" - dp_suggestion = dp.get("cim10_suggestion") or "" - result["dp"] = { - "texte": (dp.get("texte") or "")[:80], - "code_suggestion": dp_suggestion, - "code_final": dp_code, - "confidence": dp.get("cim10_confidence", ""), - "has_code": bool(dp_code), - "valid_code": is_valid_code(dp_code, cim10) if dp_code else False, - "downgraded": bool(dp_code and dp_suggestion and dp_code != dp_suggestion), - } - - # --- DAS --- - das_list = data.get("diagnostics_associes", []) - das_codes = [] - das_conf = {"high": 0, "medium": 0, "low": 0} - das_valid = 0 - das_no_code = 0 - das_downgraded = 0 - - for d_item in das_list: - code = d_item.get("cim10_final") or d_item.get("cim10_suggestion") or "" - suggestion = d_item.get("cim10_suggestion") or "" - conf = d_item.get("cim10_confidence", "low") - - if not code: - das_no_code += 1 - continue - - das_codes.append(code) - if conf in das_conf: - das_conf[conf] += 1 - - if is_valid_code(code, cim10): - das_valid += 1 - - if code and suggestion and code != suggestion: - das_downgraded += 1 - - n_das_with_code = len(das_codes) - result["das"] = { - "total": len(das_list), - "with_code": n_das_with_code, - "no_code": das_no_code, - "valid": das_valid, - "validity_rate": round(das_valid / n_das_with_code, 3) if n_das_with_code else 0, - "confidence": das_conf, - "downgraded": das_downgraded, - "downgrade_rate": round(das_downgraded / n_das_with_code, 3) if n_das_with_code else 0, - "codes_uniques": sorted(set(das_codes)), - } - - # --- Metrics du dossier --- - metrics = data.get("metrics", {}) - result["metrics"] = { - "das_active": metrics.get("das_active", 0), - "das_removed": metrics.get("das_removed", 0), - "das_ruled_out": metrics.get("das_ruled_out", 0), - } - - # --- Veto --- - veto = data.get("veto_report", {}) - issues = veto.get("issues", []) - result["veto"] = { - "verdict": veto.get("verdict", "NO_REPORT"), - "score": veto.get("score_contestabilite", 0), - "issues_count": len(issues), - "hard_count": sum(1 for i in issues if i.get("severity") == "HARD"), - "top_issues": [i.get("veto", i.get("type", "?")) for i in issues[:5]], - } - - # --- GHM --- - ghm = data.get("ghm_estimation") - result["ghm"] = { - "estimated": ghm is not None and bool(ghm), - "cmd": ghm.get("cmd") if ghm else None, - "severity": ghm.get("severity") if ghm else None, - "ghm": ghm.get("ghm") if ghm else None, - } - - # --- CPAM --- - cpam = data.get("controles_cpam", []) - result["cpam"] = { - "controls_count": len(cpam), - "has_response": any(bool(c.get("contre_argumentation")) for c in cpam), - "sources_count": sum(len(c.get("sources_reponse", [])) for c in cpam), - } - - # --- Biologie --- - bio = data.get("biologie_cle", []) - result["biologie"] = { - "tests_count": len(bio), - "anomalies": sum(1 for b in bio if b.get("anomalie")), - } - - # --- Codes CIM-10 invalides (détail) --- - invalid_codes = [] - if dp_code and not is_valid_code(dp_code, cim10): - invalid_codes.append(f"DP:{dp_code}") - for code in das_codes: - if not is_valid_code(code, cim10): - invalid_codes.append(f"DAS:{code}") - result["invalid_codes"] = invalid_codes - - return result - - -# --------------------------------------------------------------------------- -# Agrégation -# --------------------------------------------------------------------------- - -def compute_aggregate(per_dossier: list[dict]) -> dict: - """Calcule les métriques agrégées sur tous les dossiers.""" - successful = [d for d in per_dossier if d.get("success")] - n = len(successful) - if n == 0: - return {"n_total": len(per_dossier), "n_success": 0} - - # DP - dp_has_code = sum(1 for d in successful if d["dp"]["has_code"]) - dp_valid = sum(1 for d in successful if d["dp"]["valid_code"]) - dp_conf = {"high": 0, "medium": 0, "low": 0} - for d in successful: - c = d["dp"]["confidence"] - if c in dp_conf: - dp_conf[c] += 1 - dp_downgraded = sum(1 for d in successful if d["dp"]["downgraded"]) - - # DAS - total_das = sum(d["das"]["total"] for d in successful) - total_das_with_code = sum(d["das"]["with_code"] for d in successful) - total_das_valid = sum(d["das"]["valid"] for d in successful) - total_das_downgraded = sum(d["das"]["downgraded"] for d in successful) - das_conf_agg = {"high": 0, "medium": 0, "low": 0} - for d in successful: - for k in das_conf_agg: - das_conf_agg[k] += d["das"]["confidence"].get(k, 0) - - # Veto - verdicts = {} - total_hard = 0 - for d in successful: - v = d["veto"]["verdict"] - verdicts[v] = verdicts.get(v, 0) + 1 - total_hard += d["veto"]["hard_count"] - - # GHM - ghm_estimated = sum(1 for d in successful if d["ghm"]["estimated"]) - - # CPAM - cpam_total = sum(d["cpam"]["controls_count"] for d in successful) - cpam_with_response = sum(1 for d in successful if d["cpam"]["has_response"]) - - # Temps - times = [d["processing_time_s"] for d in successful] - times_sorted = sorted(times) - p90_idx = int(len(times_sorted) * 0.9) - - # Codes invalides - all_invalid = [] - for d in successful: - all_invalid.extend(d.get("invalid_codes", [])) - - return { - "n_total": len(per_dossier), - "n_success": n, - "n_failed": len(per_dossier) - n, - "dp": { - "has_code_rate": round(dp_has_code / n, 3), - "valid_code_rate": round(dp_valid / n, 3), - "confidence": dp_conf, - "downgraded": dp_downgraded, - }, - "das": { - "total": total_das, - "mean_per_dossier": round(total_das / n, 1), - "with_code": total_das_with_code, - "valid": total_das_valid, - "validity_rate": round(total_das_valid / total_das_with_code, 3) if total_das_with_code else 0, - "confidence": das_conf_agg, - "confidence_high_rate": round(das_conf_agg["high"] / total_das_with_code, 3) if total_das_with_code else 0, - "downgraded": total_das_downgraded, - "downgrade_rate": round(total_das_downgraded / total_das_with_code, 3) if total_das_with_code else 0, - }, - "veto": { - "verdicts": verdicts, - "hard_total": total_hard, - "dossiers_with_hard": sum(1 for d in successful if d["veto"]["hard_count"] > 0), - }, - "ghm": { - "estimated_rate": round(ghm_estimated / n, 3), - }, - "cpam": { - "controls_total": cpam_total, - "with_response": cpam_with_response, - }, - "timing": { - "mean_s": round(mean(times), 1), - "median_s": round(median(times), 1), - "p90_s": round(times_sorted[p90_idx], 1) if times_sorted else 0, - "total_s": round(sum(times), 1), - }, - "invalid_codes": all_invalid, - "invalid_codes_count": len(all_invalid), - } - - -# --------------------------------------------------------------------------- -# Rapport texte -# --------------------------------------------------------------------------- - -def _pct(val: float) -> str: - return f"{val * 100:.1f}%" - - -def _bar(val: float, width: int = 20) -> str: - filled = int(val * width) - return "█" * filled + "░" * (width - filled) - - -def generate_report(run_id: str, config: dict, agg: dict, per_dossier: list[dict]) -> str: - """Génère un rapport lisible.""" - lines = [] - w = 66 - - lines.append("=" * w) - lines.append(f" BENCHMARK QUALITÉ T2A — {run_id}") - lines.append("=" * w) - lines.append(f" Date : {config['timestamp']}") - lines.append(f" Modèles : coding={config['models'].get('coding','?')} cpam={config['models'].get('cpam','?')}") - lines.append(f" validation={config['models'].get('validation','?')} qc={config['models'].get('qc','?')}") - lines.append(f" Dossiers : {agg['n_success']}/{agg['n_total']} traités ({agg.get('n_failed',0)} échecs)") - lines.append(f" Durée : {agg['timing']['total_s']:.0f}s ({agg['timing']['mean_s']:.1f}s/dossier)") - lines.append("-" * w) - - # DP - dp = agg["dp"] - lines.append("") - lines.append(" DIAGNOSTIC PRINCIPAL (DP)") - lines.append(f" Code obtenu : {_bar(dp['has_code_rate'])} {_pct(dp['has_code_rate'])}") - lines.append(f" Code CIM-10 valide : {_bar(dp['valid_code_rate'])} {_pct(dp['valid_code_rate'])}") - lines.append(f" Confiance high : {dp['confidence'].get('high',0)}/{agg['n_success']} " - f"medium: {dp['confidence'].get('medium',0)} low: {dp['confidence'].get('low',0)}") - lines.append(f" Downgrades : {dp['downgraded']}") - - # DAS - das = agg["das"] - lines.append("") - lines.append(" DIAGNOSTICS ASSOCIÉS (DAS)") - lines.append(f" Total : {das['total']} (moy {das['mean_per_dossier']}/dossier)") - lines.append(f" Avec code : {das['with_code']}/{das['total']}") - lines.append(f" Codes valides : {_bar(das['validity_rate'])} {_pct(das['validity_rate'])}") - lines.append(f" Confiance : high={das['confidence']['high']} " - f"medium={das['confidence']['medium']} low={das['confidence']['low']}") - lines.append(f" Confiance high : {_bar(das['confidence_high_rate'])} {_pct(das['confidence_high_rate'])}") - lines.append(f" Downgrades : {das['downgraded']} ({_pct(das['downgrade_rate'])})") - - # Veto - veto = agg["veto"] - lines.append("") - lines.append(" VETOS / QUALITÉ") - for v, count in sorted(veto["verdicts"].items(), key=lambda x: -x[1]): - lines.append(f" {v:12s} : {count}") - lines.append(f" Issues HARD : {veto['hard_total']} (dans {veto['dossiers_with_hard']} dossiers)") - - # GHM - lines.append("") - lines.append(" GHM") - lines.append(f" Estimé : {_bar(agg['ghm']['estimated_rate'])} {_pct(agg['ghm']['estimated_rate'])}") - - # CPAM - if agg["cpam"]["controls_total"] > 0: - lines.append("") - lines.append(" CPAM") - lines.append(f" Contrôles : {agg['cpam']['controls_total']}") - lines.append(f" Avec réponse : {agg['cpam']['with_response']}") - - # Temps - lines.append("") - lines.append(" TEMPS DE TRAITEMENT") - lines.append(f" Moyen : {agg['timing']['mean_s']:.1f}s") - lines.append(f" Médian : {agg['timing']['median_s']:.1f}s") - lines.append(f" P90 : {agg['timing']['p90_s']:.1f}s") - lines.append(f" Total : {agg['timing']['total_s']:.0f}s") - - # Codes invalides - if agg["invalid_codes"]: - lines.append("") - lines.append(f" CODES CIM-10 INVALIDES ({agg['invalid_codes_count']})") - for code in agg["invalid_codes"][:20]: - lines.append(f" {code}") - if agg["invalid_codes_count"] > 20: - lines.append(f" ... et {agg['invalid_codes_count'] - 20} autres") - - # Détail par dossier - lines.append("") - lines.append("-" * w) - lines.append(" DÉTAIL PAR DOSSIER") - lines.append("-" * w) - lines.append(f" {'Dossier':<25s} {'DP':>6s} {'DAS':>4s} {'Valid%':>7s} {'Veto':>10s} {'Temps':>6s}") - lines.append(f" {'-'*25:<25s} {'-'*6:>6s} {'-'*4:>4s} {'-'*7:>7s} {'-'*10:>10s} {'-'*6:>6s}") - - for d in sorted(per_dossier, key=lambda x: x["dossier_id"]): - if not d.get("success"): - lines.append(f" {d['dossier_id']:<25s} {'ÉCHEC':>6s}") - continue - dp_code = d["dp"]["code_final"] or "-" - dp_mark = "✓" if d["dp"]["valid_code"] else "✗" - n_das = d["das"]["total"] - vr = f"{d['das']['validity_rate']*100:.0f}%" if d["das"]["with_code"] else "-" - verdict = d["veto"]["verdict"] - t = f"{d['processing_time_s']:.0f}s" - lines.append(f" {d['dossier_id']:<25s} {dp_code:>5s}{dp_mark} {n_das:>4d} {vr:>7s} {verdict:>10s} {t:>6s}") - - lines.append("") - lines.append("=" * w) - return "\n".join(lines) - - -# --------------------------------------------------------------------------- -# Comparaison entre runs -# --------------------------------------------------------------------------- - -def compare_runs(current_agg: dict, baseline_agg: dict, baseline_id: str) -> str: - """Compare deux runs et génère un rapport diff.""" - lines = [] - w = 66 - lines.append("") - lines.append("=" * w) - lines.append(f" COMPARAISON avec {baseline_id}") - lines.append("=" * w) - - def _delta(cur: float, base: float, is_pct: bool = True) -> str: - d = cur - base - sign = "+" if d >= 0 else "" - if is_pct: - return f"{sign}{d*100:.1f}%" - return f"{sign}{d:.1f}" - - def _row(label: str, cur_val: float, base_val: float, is_pct: bool = True): - if is_pct: - cur_s = _pct(cur_val) - base_s = _pct(base_val) - else: - cur_s = f"{cur_val:.1f}" - base_s = f"{base_val:.1f}" - delta_s = _delta(cur_val, base_val, is_pct) - lines.append(f" {label:<24s} {base_s:>10s} {cur_s:>10s} {delta_s:>10s}") - - lines.append(f" {'Métrique':<24s} {'Baseline':>10s} {'Actuel':>10s} {'Delta':>10s}") - lines.append(f" {'-'*24:<24s} {'-'*10:>10s} {'-'*10:>10s} {'-'*10:>10s}") - - _row("DP code valide", current_agg["dp"]["valid_code_rate"], baseline_agg["dp"]["valid_code_rate"]) - _row("DAS validité", current_agg["das"]["validity_rate"], baseline_agg["das"]["validity_rate"]) - _row("DAS confiance high", current_agg["das"]["confidence_high_rate"], baseline_agg["das"]["confidence_high_rate"]) - _row("DAS downgrade", current_agg["das"]["downgrade_rate"], baseline_agg["das"]["downgrade_rate"]) - _row("GHM estimé", current_agg["ghm"]["estimated_rate"], baseline_agg["ghm"]["estimated_rate"]) - _row("DAS moy/dossier", current_agg["das"]["mean_per_dossier"], baseline_agg["das"]["mean_per_dossier"], is_pct=False) - _row("Temps moyen (s)", current_agg["timing"]["mean_s"], baseline_agg["timing"]["mean_s"], is_pct=False) - - # Codes invalides - cur_inv = set(current_agg.get("invalid_codes", [])) - base_inv = set(baseline_agg.get("invalid_codes", [])) - new_inv = cur_inv - base_inv - fixed_inv = base_inv - cur_inv - if new_inv: - lines.append(f"\n Nouveaux codes invalides : {', '.join(sorted(new_inv))}") - if fixed_inv: - lines.append(f" Codes corrigés : {', '.join(sorted(fixed_inv))}") - - lines.append("=" * w) - return "\n".join(lines) - - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - -def get_current_config() -> dict: - """Récupère la configuration modèle actuelle.""" - try: - from src.config import OLLAMA_MODELS, OLLAMA_MODEL, OLLAMA_URL - return { - "models": dict(OLLAMA_MODELS), - "ollama_model": OLLAMA_MODEL, - "ollama_url": OLLAMA_URL, - } - except ImportError: - return { - "models": { - "coding": os.environ.get("T2A_MODEL_CODING", "?"), - "cpam": os.environ.get("T2A_MODEL_CPAM", "?"), - "validation": os.environ.get("T2A_MODEL_VALIDATION", "?"), - "qc": os.environ.get("T2A_MODEL_QC", "?"), - }, - "ollama_model": os.environ.get("OLLAMA_MODEL", "?"), - } - - -def main(): - parser = argparse.ArgumentParser(description="Benchmark qualité T2A") - parser.add_argument("--n", type=int, default=10, help="Nombre de dossiers") - parser.add_argument("--dossiers", type=str, help="IDs séparés par des virgules") - parser.add_argument("--gold-standard", action="store_true", help="Utiliser les 50 dossiers gold standard") - parser.add_argument("--compare", type=str, help="Run ID à comparer") - parser.add_argument("--label", type=str, default="", help="Label pour ce run") - parser.add_argument("--no-reprocess", action="store_true", help="Analyser les outputs existants sans relancer le pipeline") - parser.add_argument("--clean", action="store_true", help="Supprimer les outputs avant retraitement") - parser.add_argument("--seed", type=int, default=42, help="Seed pour la sélection aléatoire") - parser.add_argument("--workers", type=int, default=1, help="Nombre de dossiers traités en parallèle") - args = parser.parse_args() - - # Sélection dossiers - specific = args.dossiers.split(",") if args.dossiers else None - dossiers = select_dossiers(args.n, args.gold_standard, specific, args.seed) - print(f"\n Dossiers sélectionnés : {len(dossiers)}") - for d in dossiers: - print(f" - {d}") - - # Config - config = get_current_config() - run_id = datetime.now().strftime("%Y%m%d_%H%M%S") - if args.label: - run_id = f"{run_id}_{args.label}" - config["timestamp"] = datetime.now().isoformat() - config["run_id"] = run_id - config["dossiers"] = dossiers - config["args"] = { - "n": args.n, - "gold_standard": args.gold_standard, - "clean": args.clean, - "no_reprocess": args.no_reprocess, - "seed": args.seed, - "label": args.label, - } - - print(f"\n Run ID : {run_id}") - print(f" Modèles : {config['models']}") - print(f" Reprocess: {'NON' if args.no_reprocess else 'OUI (clean=' + str(args.clean) + ')'}") - print() - - # Charger dictionnaire CIM-10 - cim10 = load_cim10_dict() - print(f" Dictionnaire CIM-10 : {len(cim10)} codes") - print() - - # Traitement - per_dossier = [] - total = len(dossiers) - - if args.workers > 1 and not args.no_reprocess: - # Mode parallèle : exécuter les pipelines en parallèle puis analyser - from concurrent.futures import ThreadPoolExecutor, as_completed - print(f" Mode parallèle : {args.workers} workers") - pipeline_results: dict[str, tuple[float, bool]] = {} - done = 0 - with ThreadPoolExecutor(max_workers=args.workers) as executor: - futures = { - executor.submit(run_pipeline, dossier_id, args.clean): dossier_id - for dossier_id in dossiers - } - for future in as_completed(futures): - dossier_id = futures[future] - try: - duration, success = future.result() - except Exception as e: - print(f" EXCEPTION {dossier_id}: {e}") - duration, success = 0.0, False - pipeline_results[dossier_id] = (duration, success) - done += 1 - mark = "✓" if success else "✗" - print(f" [{done}/{total}] {dossier_id} — {duration:.1f}s {mark}") - - # Analyse séquentielle (ordre stable) - for dossier_id in dossiers: - duration, success = pipeline_results[dossier_id] - metrics = analyze_dossier(dossier_id, cim10, duration) - per_dossier.append(metrics) - else: - # Mode séquentiel (ou --no-reprocess) - for i, dossier_id in enumerate(dossiers, 1): - print(f" [{i}/{total}] {dossier_id}", end="", flush=True) - - if args.no_reprocess: - duration = 0.0 - success = find_merged_json(dossier_id) is not None - if not success: - print(" — pas de JSON") - else: - print(" — analyse existant") - else: - print(" — traitement...", end="", flush=True) - duration, success = run_pipeline(dossier_id, args.clean) - print(f" {duration:.1f}s {'✓' if success else '✗'}") - - metrics = analyze_dossier(dossier_id, cim10, duration) - per_dossier.append(metrics) - - # Agrégation - agg = compute_aggregate(per_dossier) - - # Rapport - report = generate_report(run_id, config, agg, per_dossier) - print(report) - - # Comparaison si demandée - comparison = "" - if args.compare: - baseline_path = BENCHMARKS_DIR / args.compare / "metrics.json" - if baseline_path.exists(): - baseline = json.loads(baseline_path.read_text("utf-8")) - comparison = compare_runs(agg, baseline["aggregate"], args.compare) - print(comparison) - else: - print(f"\n WARN: run baseline {args.compare} introuvable ({baseline_path})") - - # Sauvegarde - run_dir = BENCHMARKS_DIR / run_id - run_dir.mkdir(parents=True, exist_ok=True) - - (run_dir / "config.json").write_text( - json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8" - ) - (run_dir / "metrics.json").write_text( - json.dumps({"aggregate": agg, "per_dossier": per_dossier}, ensure_ascii=False, indent=2), - encoding="utf-8", - ) - (run_dir / "report.txt").write_text(report + comparison, encoding="utf-8") - - print(f"\n Résultats sauvegardés dans : {run_dir}") - print(f" Pour comparer un futur run : python scripts/benchmark_quality.py --compare {run_id}") - - -if __name__ == "__main__": - main() diff --git a/scripts/regenerate_tier_c.py b/scripts/regenerate_tier_c.py deleted file mode 100644 index 329b3e3..0000000 --- a/scripts/regenerate_tier_c.py +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env python3 -"""Régénération ciblée des contrôles CPAM classés Tier C ou sans response_data. - -Usage : - cd /home/dom/ai/t2a_v2 - .venv/bin/python3 scripts/regenerate_tier_c.py [--dry-run] - -Le script : -1. Scanne output/structured/ pour trouver les contrôles Tier C + ceux sans response_data -2. Pour chaque contrôle, relance generate_cpam_response() avec le pipeline corrigé -3. Sauvegarde le JSON mis à jour (backup automatique .bak) - -Options : - --dry-run Affiche les contrôles ciblés sans régénérer -""" - -from __future__ import annotations - -import json -import logging -import shutil -import sys -import time -from pathlib import Path - -# Ajouter le répertoire racine au path -ROOT = Path(__file__).resolve().parent.parent -sys.path.insert(0, str(ROOT)) - -from src.config import DossierMedical -from src.control.cpam_response import generate_cpam_response - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(levelname)-7s %(message)s", - datefmt="%H:%M:%S", -) -logger = logging.getLogger(__name__) - -STRUCTURED_DIR = ROOT / "output" / "structured" - - -def find_targets() -> list[tuple[Path, int]]: - """Trouve les fichiers JSON contenant des contrôles Tier C ou sans response_data. - - Returns: - Liste de (chemin_json, index_du_controle_dans_la_liste). - """ - targets: list[tuple[Path, int]] = [] - - for sub in sorted(STRUCTURED_DIR.iterdir()): - if not sub.is_dir(): - continue - for jf in sub.glob("*_fusionne_cim10.json"): - data = json.loads(jf.read_text(encoding="utf-8")) - controles = data.get("controles_cpam", []) - for i, ctrl in enumerate(controles): - tier = ctrl.get("quality_tier") - has_resp = ctrl.get("response_data") is not None - if tier == "C" or not has_resp: - targets.append((jf, i)) - - return targets - - -def regenerate(targets: list[tuple[Path, int]]) -> dict[str, int]: - """Régénère les contrôles CPAM ciblés. - - Returns: - Statistiques {tier_A, tier_B, tier_C, errors}. - """ - stats = {"A": 0, "B": 0, "C": 0, "errors": 0} - # Grouper par fichier pour ne charger/sauver qu'une fois par dossier - by_file: dict[Path, list[int]] = {} - for path, idx in targets: - by_file.setdefault(path, []).append(idx) - - total = len(targets) - done = 0 - - for json_path, indices in by_file.items(): - dossier_id = json_path.parent.name - logger.info("=== Dossier %s (%d contrôle(s) à régénérer) ===", dossier_id, len(indices)) - - # Charger le dossier - data = json.loads(json_path.read_text(encoding="utf-8")) - dossier = DossierMedical.model_validate(data) - - modified = False - - for idx in indices: - ctrl = dossier.controles_cpam[idx] - done += 1 - old_tier = ctrl.quality_tier or "?" - logger.info("[%d/%d] OGC %d — %s (ancien tier: %s)", - done, total, ctrl.numero_ogc, ctrl.titre[:60], old_tier) - - t0 = time.time() - try: - text, response_data, sources = generate_cpam_response(dossier, ctrl) - elapsed = time.time() - t0 - - ctrl.contre_argumentation = text - ctrl.response_data = response_data - ctrl.sources_reponse = sources - - new_tier = ctrl.quality_tier or "?" - stats[new_tier] = stats.get(new_tier, 0) + 1 - modified = True - - logger.info(" Résultat : tier %s → %s (%d chars, %.1fs)", - old_tier, new_tier, len(text), elapsed) - except Exception: - logger.exception(" ERREUR sur OGC %d", ctrl.numero_ogc) - stats["errors"] += 1 - - if modified: - # Backup + sauvegarde - backup_path = json_path.with_suffix(".json.bak") - shutil.copy2(json_path, backup_path) - json_path.write_text( - dossier.model_dump_json(indent=2, exclude_none=True), - encoding="utf-8", - ) - logger.info(" Sauvegardé : %s (backup: %s)", json_path.name, backup_path.name) - - return stats - - -def main() -> None: - dry_run = "--dry-run" in sys.argv - - logger.info("Recherche des contrôles Tier C et sans response_data...") - targets = find_targets() - - if not targets: - logger.info("Aucun contrôle à régénérer.") - return - - logger.info("Trouvé %d contrôle(s) à régénérer :", len(targets)) - for path, idx in targets: - data = json.loads(path.read_text(encoding="utf-8")) - ctrl = data["controles_cpam"][idx] - tier = ctrl.get("quality_tier", "?") - has_resp = "oui" if ctrl.get("response_data") else "NON" - logger.info(" %s OGC %d — tier %s, response_data: %s", - path.parent.name, ctrl["numero_ogc"], tier, has_resp) - - if dry_run: - logger.info("Mode dry-run — aucune régénération effectuée.") - return - - t0 = time.time() - stats = regenerate(targets) - elapsed = time.time() - t0 - - logger.info("=== TERMINÉ en %.1f min ===", elapsed / 60) - logger.info("Distribution : A=%d, B=%d, C=%d, erreurs=%d", - stats.get("A", 0), stats.get("B", 0), stats.get("C", 0), stats["errors"]) - - -if __name__ == "__main__": - main() diff --git a/scripts/select_validation_dossiers.py b/scripts/select_validation_dossiers.py deleted file mode 100644 index 5b70516..0000000 --- a/scripts/select_validation_dossiers.py +++ /dev/null @@ -1,231 +0,0 @@ -#!/usr/bin/env python3 -"""Sélectionne 50 dossiers pour le gold standard de validation DIM. - -- 25 dossiers CPAM (cas complexes, déjà contrôlés) -- 25 dossiers non-CPAM stratifiés par CMD, confiance DP, nombre de DAS - -Crée data/gold_standard/_selection.json et initialise les annotations vides. -""" - -from __future__ import annotations - -import json -import random -import sys -from pathlib import Path - -# Ajouter le répertoire racine au path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from src.config import STRUCTURED_DIR, BASE_DIR, DossierMedical - -GOLD_DIR = BASE_DIR / "data" / "gold_standard" -TARGET_TOTAL = 50 -TARGET_CPAM = 25 - - -def load_all_dossiers() -> list[dict]: - """Charge tous les dossiers fusionnés depuis output/structured/.""" - dossiers = [] - for subdir in sorted(STRUCTURED_DIR.iterdir()): - if not subdir.is_dir(): - continue - # Chercher le fichier fusionné - fusionne = None - for f in subdir.glob("*fusionne*.json"): - fusionne = f - break - if not fusionne: - # Prendre le premier JSON du dossier - jsons = sorted(subdir.glob("*.json")) - if jsons: - fusionne = jsons[0] - if not fusionne: - continue - - try: - data = json.loads(fusionne.read_text(encoding="utf-8")) - dossier = DossierMedical.model_validate(data) - rel_path = str(fusionne.relative_to(STRUCTURED_DIR)) - group_name = subdir.name - dossiers.append({ - "dossier_id": f"{group_name}/{fusionne.stem}", - "group_name": group_name, - "path_rel": rel_path, - "dossier": dossier, - }) - except Exception as e: - print(f" Erreur chargement {fusionne.name}: {e}") - return dossiers - - -def select_dossiers(all_dossiers: list[dict]) -> list[dict]: - """Sélectionne les 50 dossiers selon la stratégie définie.""" - # Séparer CPAM / non-CPAM - cpam = [d for d in all_dossiers if d["dossier"].controles_cpam] - non_cpam = [d for d in all_dossiers if not d["dossier"].controles_cpam] - - print(f"Dossiers CPAM disponibles : {len(cpam)}") - print(f"Dossiers non-CPAM disponibles : {len(non_cpam)}") - - # Prendre tous les CPAM (ou max TARGET_CPAM) - selected_cpam = cpam[:TARGET_CPAM] - remaining_target = TARGET_TOTAL - len(selected_cpam) - - # Stratifier les non-CPAM - selected_non_cpam = stratified_sample(non_cpam, remaining_target) - - selected = selected_cpam + selected_non_cpam - print(f"\nSélection finale : {len(selected)} dossiers") - print(f" - CPAM : {len(selected_cpam)}") - print(f" - Non-CPAM : {len(selected_non_cpam)}") - - return selected - - -def stratified_sample(dossiers: list[dict], n: int) -> list[dict]: - """Échantillonnage stratifié par CMD, confiance DP et nombre de DAS.""" - if len(dossiers) <= n: - return dossiers - - # Grouper par CMD - by_cmd: dict[str, list[dict]] = {} - for d in dossiers: - ghm = d["dossier"].ghm_estimation - cmd = ghm.cmd if ghm else "inconnu" - by_cmd.setdefault(cmd or "inconnu", []).append(d) - - selected = [] - seen_ids = set() - - # Phase 1 : 1 dossier par CMD (diversité maximale) - cmds = sorted(by_cmd.keys()) - random.seed(42) # Reproductible - for cmd in cmds: - if len(selected) >= n: - break - candidates = by_cmd[cmd] - # Préférer un mix de confiances - random.shuffle(candidates) - d = candidates[0] - selected.append(d) - seen_ids.add(d["dossier_id"]) - - # Phase 2 : compléter avec diversité confiance DP - if len(selected) < n: - remaining = [d for d in dossiers if d["dossier_id"] not in seen_ids] - # Trier par confiance DP (low > medium > high pour surreprésenter les cas difficiles) - conf_order = {"low": 0, "medium": 1, "high": 2, None: 3} - remaining.sort(key=lambda d: ( - conf_order.get( - d["dossier"].diagnostic_principal.cim10_confidence - if d["dossier"].diagnostic_principal else None, - 3 - ), - -len(d["dossier"].diagnostics_associes), # beaucoup de DAS d'abord - )) - for d in remaining: - if len(selected) >= n: - break - selected.append(d) - - return selected[:n] - - -def create_empty_annotation(dossier_id: str, dossier: DossierMedical) -> dict: - """Crée une annotation vide pour un dossier.""" - dp = dossier.diagnostic_principal - das_list = [] - for i, das in enumerate(dossier.diagnostics_associes): - das_list.append({ - "index": i, - "texte_original": das.texte, - "code_pipeline": das.cim10_suggestion or "", - "confidence": das.cim10_confidence or "", - "source": das.source or "", - "statut": "correct", - "code_corrige": None, - "commentaire": "", - }) - - return { - "dossier_id": dossier_id, - "validateur": "", - "date_validation": "", - "statut": "non_commence", - "dp": { - "texte_original": dp.texte if dp else "", - "code_pipeline": dp.cim10_suggestion if dp else "", - "confidence": dp.cim10_confidence if dp else "", - "statut": "correct", - "code_corrige": None, - "commentaire": "", - }, - "das": das_list, - "das_ajoutes": [], - "commentaire_general": "", - } - - -def main(): - print("=== Sélection des dossiers pour validation DIM ===\n") - - all_dossiers = load_all_dossiers() - print(f"Total dossiers chargés : {len(all_dossiers)}\n") - - if not all_dossiers: - print("Aucun dossier trouvé dans output/structured/") - sys.exit(1) - - selected = select_dossiers(all_dossiers) - - # Créer le répertoire gold standard - GOLD_DIR.mkdir(parents=True, exist_ok=True) - - # Sauvegarder la sélection - selection = { - "date_selection": __import__("datetime").datetime.now().isoformat(timespec="seconds"), - "total": len(selected), - "cpam": sum(1 for d in selected if d["dossier"].controles_cpam), - "non_cpam": sum(1 for d in selected if not d["dossier"].controles_cpam), - "dossiers": [d["dossier_id"] for d in selected], - } - selection_path = GOLD_DIR / "_selection.json" - selection_path.write_text( - json.dumps(selection, ensure_ascii=False, indent=2), - encoding="utf-8", - ) - print(f"\nSélection sauvegardée : {selection_path}") - - # Initialiser les annotations vides - created = 0 - for d in selected: - dossier_id = d["dossier_id"] - safe_name = dossier_id.replace("/", "__") + ".json" - annot_path = GOLD_DIR / safe_name - if not annot_path.exists(): - annotation = create_empty_annotation(dossier_id, d["dossier"]) - annot_path.write_text( - json.dumps(annotation, ensure_ascii=False, indent=2), - encoding="utf-8", - ) - created += 1 - - print(f"Annotations vides créées : {created}") - print(f"Annotations existantes préservées : {len(selected) - created}") - - # Résumé - print(f"\n--- Résumé ---") - for i, d in enumerate(selected, 1): - dos = d["dossier"] - dp_code = dos.diagnostic_principal.cim10_suggestion if dos.diagnostic_principal else "?" - dp_conf = (dos.diagnostic_principal.cim10_confidence or "?") if dos.diagnostic_principal else "?" - n_das = len(dos.diagnostics_associes) - cpam_flag = " [CPAM]" if dos.controles_cpam else "" - ghm = dos.ghm_estimation - cmd = ghm.cmd if ghm else "?" - print(f" {i:2d}. {d['group_name']:<20s} DP={dp_code:<6s} conf={dp_conf:<7s} DAS={n_das:2d} CMD={cmd}{cpam_flag}") - - -if __name__ == "__main__": - main() diff --git a/scripts_t2a_v2/benchmark_models.py b/scripts_t2a_v2/benchmark_models.py deleted file mode 100644 index f865ee7..0000000 --- a/scripts_t2a_v2/benchmark_models.py +++ /dev/null @@ -1,313 +0,0 @@ -#!/usr/bin/env python3 -"""Benchmark A/B : gemma3:12b (base) vs pmsi-coder-v2 (fine-tuné). - -Compare les codes CIM-10 produits par les deux modèles sur N dossiers. -Teste DP + DAS (échantillon) pour chaque dossier. - -Usage: python scripts/benchmark_models.py [--n 50] [--das-max 5] -""" - -from __future__ import annotations - -import json -import random -import sys -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from src.config import STRUCTURED_DIR, OLLAMA_URL, DossierMedical -from src.medical.cim10_dict import load_dict, normalize_code, validate_code - -import requests - -MODEL_BASE = "gemma3:12b" -MODEL_FINETUNED = "pmsi-coder-v2" - -PROMPT_TEMPLATE = """Tu es un médecin DIM expert en codage PMSI. -Code le diagnostic suivant en CIM-10. Choisis le code le plus spécifique possible. - -DIAGNOSTIC : "{texte}" -TYPE : {type_diag} - -{contexte} - -Réponds UNIQUEMENT avec un objet JSON : -{{"code": "X99.9", "confidence": "high|medium|low", "justification": "explication courte"}}""" - - -def call_model(prompt: str, model: str, timeout: int = 120) -> tuple[dict | None, float]: - """Appelle un modèle Ollama et retourne (résultat, durée_s).""" - t0 = time.time() - try: - resp = requests.post( - f"{OLLAMA_URL}/api/generate", - json={ - "model": model, - "prompt": prompt, - "stream": False, - "format": "json", - "options": {"temperature": 0.1, "num_predict": 500}, - }, - timeout=timeout, - ) - resp.raise_for_status() - raw = resp.json().get("response", "") - duration = time.time() - t0 - try: - return json.loads(raw), duration - except json.JSONDecodeError: - return None, duration - except Exception as e: - return None, time.time() - t0 - - -def load_dossiers(n: int) -> list[dict]: - """Charge N dossiers fusionnés diversifiés.""" - dossiers = [] - for subdir in sorted(STRUCTURED_DIR.iterdir()): - if not subdir.is_dir(): - continue - for f in subdir.glob("*fusionne*.json"): - if ".gemma_" in f.name or ".bak" in f.name: - continue - try: - data = json.loads(f.read_text(encoding="utf-8")) - d = DossierMedical.model_validate(data) - if d.diagnostic_principal and d.diagnostic_principal.cim10_suggestion: - dossiers.append({ - "name": subdir.name, - "dossier": d, - "path": str(f), - }) - except Exception: - continue - break - random.seed(42) - random.shuffle(dossiers) - return dossiers[:n] - - -def build_contexte(d: DossierMedical) -> str: - """Construit un contexte clinique résumé.""" - parts = [] - s = d.sejour - if s.age is not None: - parts.append(f"Patient {s.sexe or '?'}, {s.age} ans") - if s.duree_sejour is not None: - parts.append(f"Durée séjour : {s.duree_sejour}j") - if d.diagnostic_principal: - parts.append(f"DP : {d.diagnostic_principal.texte}") - bio = [f"{b.test}={b.valeur}" for b in d.biologie_cle[:5] if b.valeur] - if bio: - parts.append(f"Bio : {', '.join(bio)}") - return "CONTEXTE : " + " | ".join(parts) if parts else "" - - -def code_match_level(code_a: str, code_b: str) -> str: - """Retourne le niveau de correspondance entre deux codes.""" - if code_a == code_b: - return "exact" - if code_a[:3] == code_b[:3]: - return "categorie" - return "diff" - - -def run_benchmark(n: int = 50, das_max: int = 5): - print(f"=== Benchmark A/B : {MODEL_BASE} vs {MODEL_FINETUNED} ===") - print(f" Dossiers : {n}, DAS max/dossier : {das_max}\n") - - # Vérifier que les deux modèles sont disponibles - for model in [MODEL_BASE, MODEL_FINETUNED]: - try: - resp = requests.post( - f"{OLLAMA_URL}/api/generate", - json={"model": model, "prompt": "test", "stream": False, - "options": {"num_predict": 1}}, - timeout=60, - ) - resp.raise_for_status() - print(f" {model} : OK") - except Exception as e: - print(f" {model} : ERREUR — {e}") - sys.exit(1) - - dossiers = load_dossiers(n) - print(f"\nDossiers chargés : {len(dossiers)}\n") - - cim10 = load_dict() - t_global_start = time.time() - - dp_results = [] - das_results = [] - - for i, item in enumerate(dossiers, 1): - d = item["dossier"] - dp = d.diagnostic_principal - name = item["name"] - ctx = build_contexte(d) - - # === DP === - prompt_dp = PROMPT_TEMPLATE.format( - texte=dp.texte, - type_diag="DP (diagnostic principal)", - contexte=ctx, - ) - res_base, t_base = call_model(prompt_dp, MODEL_BASE) - res_ft, t_ft = call_model(prompt_dp, MODEL_FINETUNED) - - code_base = normalize_code(res_base.get("code", "")) if res_base else "ERREUR" - code_ft = normalize_code(res_ft.get("code", "")) if res_ft else "ERREUR" - conf_base = res_base.get("confidence", "?") if res_base else "?" - conf_ft = res_ft.get("confidence", "?") if res_ft else "?" - valid_base = validate_code(code_base)[0] if code_base != "ERREUR" else False - valid_ft = validate_code(code_ft)[0] if code_ft != "ERREUR" else False - - pipeline_code = dp.cim10_suggestion - match_level = code_match_level(code_base, code_ft) - - dp_result = { - "dossier": name, - "texte": dp.texte[:80], - "pipeline": pipeline_code, - "base": code_base, - "ft": code_ft, - "conf_base": conf_base, - "conf_ft": conf_ft, - "valid_base": valid_base, - "valid_ft": valid_ft, - "match": match_level, - "t_base": round(t_base, 2), - "t_ft": round(t_ft, 2), - } - dp_results.append(dp_result) - - tag = {"exact": "=", "categorie": "~", "diff": "X"}[match_level] - print(f" [{i:2d}/{len(dossiers)}] {name:<20s} DP=\"{dp.texte[:35]:<35s}\" " - f"base={code_base:<7s} ft={code_ft:<7s} [{tag}] " - f"({t_base:.1f}s / {t_ft:.1f}s)") - - # === DAS (échantillon) === - das_list = [das for das in d.diagnostics_associes - if das.texte and das.cim10_suggestion] - if len(das_list) > das_max: - random.seed(hash(name)) - das_list = random.sample(das_list, das_max) - - for das in das_list: - prompt_das = PROMPT_TEMPLATE.format( - texte=das.texte, - type_diag="DAS (diagnostic associé significatif)", - contexte=ctx, - ) - res_b, tb = call_model(prompt_das, MODEL_BASE) - res_f, tf = call_model(prompt_das, MODEL_FINETUNED) - - cb = normalize_code(res_b.get("code", "")) if res_b else "ERREUR" - cf = normalize_code(res_f.get("code", "")) if res_f else "ERREUR" - vb = validate_code(cb)[0] if cb != "ERREUR" else False - vf = validate_code(cf)[0] if cf != "ERREUR" else False - - das_results.append({ - "dossier": name, - "texte": das.texte[:80], - "pipeline": das.cim10_suggestion, - "base": cb, - "ft": cf, - "conf_base": (res_b or {}).get("confidence", "?"), - "conf_ft": (res_f or {}).get("confidence", "?"), - "valid_base": vb, - "valid_ft": vf, - "match": code_match_level(cb, cf), - "t_base": round(tb, 2), - "t_ft": round(tf, 2), - }) - - t_global = time.time() - t_global_start - - # === RÉSUMÉ === - print(f"\n{'='*75}") - print(f"RÉSUMÉ — {len(dp_results)} dossiers, {len(das_results)} DAS testés") - print(f"Durée totale : {t_global/60:.1f} min\n") - - for label, results in [("DP", dp_results), ("DAS", das_results)]: - if not results: - continue - nt = len(results) - n_exact = sum(1 for r in results if r["match"] == "exact") - n_cat = sum(1 for r in results if r["match"] == "categorie") - n_diff = sum(1 for r in results if r["match"] == "diff") - n_vb = sum(1 for r in results if r["valid_base"]) - n_vf = sum(1 for r in results if r["valid_ft"]) - avg_tb = sum(r["t_base"] for r in results) / nt - avg_tf = sum(r["t_ft"] for r in results) / nt - - # Confiance - conf_b = {} - conf_f = {} - for r in results: - conf_b[r["conf_base"]] = conf_b.get(r["conf_base"], 0) + 1 - conf_f[r["conf_ft"]] = conf_f.get(r["conf_ft"], 0) + 1 - - # Concordance avec pipeline (gemma run original) - n_base_eq_pipe = sum(1 for r in results if r["base"] == r["pipeline"]) - n_ft_eq_pipe = sum(1 for r in results if r["ft"] == r["pipeline"]) - n_base_cat_pipe = sum(1 for r in results - if r["base"][:3] == r["pipeline"][:3]) - n_ft_cat_pipe = sum(1 for r in results - if r["ft"][:3] == r["pipeline"][:3]) - - print(f" --- {label} ({nt} diagnostics) ---") - print(f" Concordance base↔ft :") - print(f" Exact : {n_exact}/{nt} ({100*n_exact/nt:.0f}%)") - print(f" Catégorie : {n_exact+n_cat}/{nt} ({100*(n_exact+n_cat)/nt:.0f}%)") - print(f" Différent : {n_diff}/{nt} ({100*n_diff/nt:.0f}%)") - print(f" Codes valides :") - print(f" base : {n_vb}/{nt} ({100*n_vb/nt:.0f}%)") - print(f" ft : {n_vf}/{nt} ({100*n_vf/nt:.0f}%)") - print(f" vs pipeline (gemma original) :") - print(f" base=pipe : {n_base_eq_pipe}/{nt} exact, {n_base_cat_pipe}/{nt} catégorie") - print(f" ft=pipe : {n_ft_eq_pipe}/{nt} exact, {n_ft_cat_pipe}/{nt} catégorie") - print(f" Temps moyen : base={avg_tb:.2f}s ft={avg_tf:.2f}s (Δ={100*(avg_tf-avg_tb)/avg_tb:+.0f}%)") - print(f" Confiance base : {conf_b}") - print(f" Confiance ft : {conf_f}") - print() - - # Lister les différences DP - diffs_dp = [r for r in dp_results if r["match"] == "diff"] - if diffs_dp: - print(f" Différences DP ({len(diffs_dp)}) :") - for r in diffs_dp: - vb = "✓" if r["valid_base"] else "✗" - vf = "✓" if r["valid_ft"] else "✗" - print(f" {r['dossier']:<18s} \"{r['texte'][:40]}\"") - print(f" base={r['base']:<7s}{vb} ft={r['ft']:<7s}{vf} pipe={r['pipeline']}") - - # Sauvegarder - out = { - "meta": { - "date": time.strftime("%Y-%m-%dT%H:%M:%S"), - "model_base": MODEL_BASE, - "model_ft": MODEL_FINETUNED, - "n_dossiers": len(dp_results), - "n_das": len(das_results), - "duration_min": round(t_global / 60, 1), - }, - "dp": dp_results, - "das": das_results, - } - out_path = Path(__file__).parent.parent / "output" / "benchmark_ab.json" - out_path.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8") - print(f"\nRésultats détaillés : {out_path}") - - -if __name__ == "__main__": - import argparse - parser = argparse.ArgumentParser() - parser.add_argument("--n", type=int, default=50, - help="Nombre de dossiers à tester") - parser.add_argument("--das-max", type=int, default=5, - help="Max DAS testés par dossier") - args = parser.parse_args() - run_benchmark(args.n, args.das_max) diff --git a/scripts_t2a_v2/benchmark_quality.py b/scripts_t2a_v2/benchmark_quality.py deleted file mode 100644 index c6fb564..0000000 --- a/scripts_t2a_v2/benchmark_quality.py +++ /dev/null @@ -1,722 +0,0 @@ -#!/usr/bin/env python3 -"""Benchmark qualité T2A — validation end-to-end sur vrais dossiers. - -Compare la qualité des codes CIM-10, vetos, downgrades et CPAM -entre runs successifs. Chaque run est sauvegardé dans un répertoire -isolé pour permettre des comparaisons A/B. - -Usage: - python scripts/benchmark_quality.py --n 10 - python scripts/benchmark_quality.py --n 10 --compare RUN_ID - python scripts/benchmark_quality.py --dossiers 116_23065570,45_23183041 - python scripts/benchmark_quality.py --gold-standard -""" - -from __future__ import annotations - -import argparse -import json -import os -import shutil -import subprocess -import sys -import time -from datetime import datetime -from pathlib import Path -from statistics import mean, median - -ROOT = Path(__file__).resolve().parent.parent -sys.path.insert(0, str(ROOT)) - -BENCHMARKS_DIR = ROOT / "output" / "benchmarks" -GOLD_STANDARD_FILE = ROOT / "data" / "gold_standard" / "_selection.json" -INPUT_DIR = ROOT / "input" -OUTPUT_DIR = ROOT / "output" / "structured" -PY = str(ROOT / ".venv" / "bin" / "python3") - - -# --------------------------------------------------------------------------- -# Sélection des dossiers -# --------------------------------------------------------------------------- - -def _gold_standard_ids() -> list[str]: - """Charge les IDs du gold standard.""" - if not GOLD_STANDARD_FILE.exists(): - print(f"ERREUR: {GOLD_STANDARD_FILE} introuvable") - sys.exit(1) - data = json.loads(GOLD_STANDARD_FILE.read_text("utf-8")) - # Format: "116_23065570/116_23065570_fusionne_cim10" → on prend la partie avant / - return [d.split("/")[0] for d in data["dossiers"]] - - -def select_dossiers(n: int, gold_standard: bool, specific: list[str] | None, seed: int = 42) -> list[str]: - """Sélectionne les dossiers à benchmarker.""" - if specific: - # Vérifier que les dossiers existent - valid = [] - for d in specific: - if (INPUT_DIR / d).is_dir(): - valid.append(d) - else: - print(f" WARN: dossier {d} introuvable dans input/") - return valid - - if gold_standard: - ids = _gold_standard_ids() - return ids[:n] if n < len(ids) else ids - - # Sinon : prendre N dossiers depuis input/ (tri déterministe + seed pour reproductibilité) - all_dirs = sorted( - d.name for d in INPUT_DIR.iterdir() - if d.is_dir() and any(d.glob("*.pdf")) - ) - if not all_dirs: - print("ERREUR: aucun dossier avec PDF dans input/") - sys.exit(1) - - import random - rng = random.Random(seed) - rng.shuffle(all_dirs) - return all_dirs[:n] - - -# --------------------------------------------------------------------------- -# Exécution pipeline -# --------------------------------------------------------------------------- - -def run_pipeline(dossier_id: str, clean: bool) -> tuple[float, bool]: - """Exécute le pipeline sur un dossier. Retourne (durée_s, succès).""" - input_path = INPUT_DIR / dossier_id - - if clean: - for subdir in ["structured", "reports", "anonymized"]: - target = ROOT / "output" / subdir / dossier_id - if target.exists(): - shutil.rmtree(target) - - t0 = time.time() - try: - result = subprocess.run( - [PY, "-m", "src.main", str(input_path)], - capture_output=True, - text=True, - cwd=str(ROOT), - timeout=600, # 10 min max par dossier - ) - duration = time.time() - t0 - if result.returncode != 0: - print(f" STDERR: {result.stderr[-500:]}") - return duration, False - return duration, True - except subprocess.TimeoutExpired: - return time.time() - t0, False - except Exception as e: - print(f" EXCEPTION: {e}") - return time.time() - t0, False - - -# --------------------------------------------------------------------------- -# Chargement dictionnaire CIM-10 -# --------------------------------------------------------------------------- - -def load_cim10_dict() -> dict[str, str]: - """Charge le dictionnaire CIM-10 (sans passer par le singleton).""" - dict_path = ROOT / "data" / "cim10_dict.json" - supp_path = ROOT / "data" / "cim10_supplements.json" - d = {} - if dict_path.exists(): - d = json.loads(dict_path.read_text("utf-8")) - if supp_path.exists(): - for code, label in json.loads(supp_path.read_text("utf-8")).items(): - d.setdefault(code, label) - return d - - -def normalize_code(code: str) -> str: - """K810 → K81.0, k85.1 → K85.1.""" - code = code.strip().upper() - if len(code) > 3 and "." not in code: - code = code[:3] + "." + code[3:] - return code - - -def is_valid_code(code: str, cim10: dict[str, str]) -> bool: - """Vérifie si un code CIM-10 existe dans le dictionnaire.""" - nc = normalize_code(code) - return nc in cim10 or code.upper().strip() in cim10 - - -# --------------------------------------------------------------------------- -# Analyse d'un dossier -# --------------------------------------------------------------------------- - -def find_merged_json(dossier_id: str) -> Path | None: - """Trouve le JSON fusionné d'un dossier.""" - d = OUTPUT_DIR / dossier_id - if not d.exists(): - return None - # Chercher le fusionné d'abord - fusions = list(d.glob("*fusionne_cim10.json")) - if fusions: - return fusions[0] - # Sinon premier _cim10.json - cim10s = list(d.glob("*_cim10.json")) - return cim10s[0] if cim10s else None - - -def analyze_dossier(dossier_id: str, cim10: dict[str, str], duration: float) -> dict: - """Analyse le JSON de sortie d'un dossier et extrait les métriques.""" - result = { - "dossier_id": dossier_id, - "processing_time_s": round(duration, 1), - "success": False, - } - - json_path = find_merged_json(dossier_id) - if not json_path: - return result - - try: - data = json.loads(json_path.read_text("utf-8")) - except (json.JSONDecodeError, OSError): - return result - - result["success"] = True - - # --- DP --- - dp = data.get("diagnostic_principal", {}) - dp_code = dp.get("cim10_final") or dp.get("cim10_suggestion") or "" - dp_suggestion = dp.get("cim10_suggestion") or "" - result["dp"] = { - "texte": (dp.get("texte") or "")[:80], - "code_suggestion": dp_suggestion, - "code_final": dp_code, - "confidence": dp.get("cim10_confidence", ""), - "has_code": bool(dp_code), - "valid_code": is_valid_code(dp_code, cim10) if dp_code else False, - "downgraded": bool(dp_code and dp_suggestion and dp_code != dp_suggestion), - } - - # --- DAS --- - das_list = data.get("diagnostics_associes", []) - das_codes = [] - das_conf = {"high": 0, "medium": 0, "low": 0} - das_valid = 0 - das_no_code = 0 - das_downgraded = 0 - - for d_item in das_list: - code = d_item.get("cim10_final") or d_item.get("cim10_suggestion") or "" - suggestion = d_item.get("cim10_suggestion") or "" - conf = d_item.get("cim10_confidence", "low") - - if not code: - das_no_code += 1 - continue - - das_codes.append(code) - if conf in das_conf: - das_conf[conf] += 1 - - if is_valid_code(code, cim10): - das_valid += 1 - - if code and suggestion and code != suggestion: - das_downgraded += 1 - - n_das_with_code = len(das_codes) - result["das"] = { - "total": len(das_list), - "with_code": n_das_with_code, - "no_code": das_no_code, - "valid": das_valid, - "validity_rate": round(das_valid / n_das_with_code, 3) if n_das_with_code else 0, - "confidence": das_conf, - "downgraded": das_downgraded, - "downgrade_rate": round(das_downgraded / n_das_with_code, 3) if n_das_with_code else 0, - "codes_uniques": sorted(set(das_codes)), - } - - # --- Metrics du dossier --- - metrics = data.get("metrics", {}) - result["metrics"] = { - "das_active": metrics.get("das_active", 0), - "das_removed": metrics.get("das_removed", 0), - "das_ruled_out": metrics.get("das_ruled_out", 0), - } - - # --- Veto --- - veto = data.get("veto_report", {}) - issues = veto.get("issues", []) - result["veto"] = { - "verdict": veto.get("verdict", "NO_REPORT"), - "score": veto.get("score_contestabilite", 0), - "issues_count": len(issues), - "hard_count": sum(1 for i in issues if i.get("severity") == "HARD"), - "top_issues": [i.get("veto", i.get("type", "?")) for i in issues[:5]], - } - - # --- GHM --- - ghm = data.get("ghm_estimation") - result["ghm"] = { - "estimated": ghm is not None and bool(ghm), - "cmd": ghm.get("cmd") if ghm else None, - "severity": ghm.get("severity") if ghm else None, - "ghm": ghm.get("ghm") if ghm else None, - } - - # --- CPAM --- - cpam = data.get("controles_cpam", []) - result["cpam"] = { - "controls_count": len(cpam), - "has_response": any(bool(c.get("contre_argumentation")) for c in cpam), - "sources_count": sum(len(c.get("sources_reponse", [])) for c in cpam), - } - - # --- Biologie --- - bio = data.get("biologie_cle", []) - result["biologie"] = { - "tests_count": len(bio), - "anomalies": sum(1 for b in bio if b.get("anomalie")), - } - - # --- Codes CIM-10 invalides (détail) --- - invalid_codes = [] - if dp_code and not is_valid_code(dp_code, cim10): - invalid_codes.append(f"DP:{dp_code}") - for code in das_codes: - if not is_valid_code(code, cim10): - invalid_codes.append(f"DAS:{code}") - result["invalid_codes"] = invalid_codes - - return result - - -# --------------------------------------------------------------------------- -# Agrégation -# --------------------------------------------------------------------------- - -def compute_aggregate(per_dossier: list[dict]) -> dict: - """Calcule les métriques agrégées sur tous les dossiers.""" - successful = [d for d in per_dossier if d.get("success")] - n = len(successful) - if n == 0: - return {"n_total": len(per_dossier), "n_success": 0} - - # DP - dp_has_code = sum(1 for d in successful if d["dp"]["has_code"]) - dp_valid = sum(1 for d in successful if d["dp"]["valid_code"]) - dp_conf = {"high": 0, "medium": 0, "low": 0} - for d in successful: - c = d["dp"]["confidence"] - if c in dp_conf: - dp_conf[c] += 1 - dp_downgraded = sum(1 for d in successful if d["dp"]["downgraded"]) - - # DAS - total_das = sum(d["das"]["total"] for d in successful) - total_das_with_code = sum(d["das"]["with_code"] for d in successful) - total_das_valid = sum(d["das"]["valid"] for d in successful) - total_das_downgraded = sum(d["das"]["downgraded"] for d in successful) - das_conf_agg = {"high": 0, "medium": 0, "low": 0} - for d in successful: - for k in das_conf_agg: - das_conf_agg[k] += d["das"]["confidence"].get(k, 0) - - # Veto - verdicts = {} - total_hard = 0 - for d in successful: - v = d["veto"]["verdict"] - verdicts[v] = verdicts.get(v, 0) + 1 - total_hard += d["veto"]["hard_count"] - - # GHM - ghm_estimated = sum(1 for d in successful if d["ghm"]["estimated"]) - - # CPAM - cpam_total = sum(d["cpam"]["controls_count"] for d in successful) - cpam_with_response = sum(1 for d in successful if d["cpam"]["has_response"]) - - # Temps - times = [d["processing_time_s"] for d in successful] - times_sorted = sorted(times) - p90_idx = int(len(times_sorted) * 0.9) - - # Codes invalides - all_invalid = [] - for d in successful: - all_invalid.extend(d.get("invalid_codes", [])) - - return { - "n_total": len(per_dossier), - "n_success": n, - "n_failed": len(per_dossier) - n, - "dp": { - "has_code_rate": round(dp_has_code / n, 3), - "valid_code_rate": round(dp_valid / n, 3), - "confidence": dp_conf, - "downgraded": dp_downgraded, - }, - "das": { - "total": total_das, - "mean_per_dossier": round(total_das / n, 1), - "with_code": total_das_with_code, - "valid": total_das_valid, - "validity_rate": round(total_das_valid / total_das_with_code, 3) if total_das_with_code else 0, - "confidence": das_conf_agg, - "confidence_high_rate": round(das_conf_agg["high"] / total_das_with_code, 3) if total_das_with_code else 0, - "downgraded": total_das_downgraded, - "downgrade_rate": round(total_das_downgraded / total_das_with_code, 3) if total_das_with_code else 0, - }, - "veto": { - "verdicts": verdicts, - "hard_total": total_hard, - "dossiers_with_hard": sum(1 for d in successful if d["veto"]["hard_count"] > 0), - }, - "ghm": { - "estimated_rate": round(ghm_estimated / n, 3), - }, - "cpam": { - "controls_total": cpam_total, - "with_response": cpam_with_response, - }, - "timing": { - "mean_s": round(mean(times), 1), - "median_s": round(median(times), 1), - "p90_s": round(times_sorted[p90_idx], 1) if times_sorted else 0, - "total_s": round(sum(times), 1), - }, - "invalid_codes": all_invalid, - "invalid_codes_count": len(all_invalid), - } - - -# --------------------------------------------------------------------------- -# Rapport texte -# --------------------------------------------------------------------------- - -def _pct(val: float) -> str: - return f"{val * 100:.1f}%" - - -def _bar(val: float, width: int = 20) -> str: - filled = int(val * width) - return "█" * filled + "░" * (width - filled) - - -def generate_report(run_id: str, config: dict, agg: dict, per_dossier: list[dict]) -> str: - """Génère un rapport lisible.""" - lines = [] - w = 66 - - lines.append("=" * w) - lines.append(f" BENCHMARK QUALITÉ T2A — {run_id}") - lines.append("=" * w) - lines.append(f" Date : {config['timestamp']}") - lines.append(f" Modèles : coding={config['models'].get('coding','?')} cpam={config['models'].get('cpam','?')}") - lines.append(f" validation={config['models'].get('validation','?')} qc={config['models'].get('qc','?')}") - lines.append(f" Dossiers : {agg['n_success']}/{agg['n_total']} traités ({agg.get('n_failed',0)} échecs)") - lines.append(f" Durée : {agg['timing']['total_s']:.0f}s ({agg['timing']['mean_s']:.1f}s/dossier)") - lines.append("-" * w) - - # DP - dp = agg["dp"] - lines.append("") - lines.append(" DIAGNOSTIC PRINCIPAL (DP)") - lines.append(f" Code obtenu : {_bar(dp['has_code_rate'])} {_pct(dp['has_code_rate'])}") - lines.append(f" Code CIM-10 valide : {_bar(dp['valid_code_rate'])} {_pct(dp['valid_code_rate'])}") - lines.append(f" Confiance high : {dp['confidence'].get('high',0)}/{agg['n_success']} " - f"medium: {dp['confidence'].get('medium',0)} low: {dp['confidence'].get('low',0)}") - lines.append(f" Downgrades : {dp['downgraded']}") - - # DAS - das = agg["das"] - lines.append("") - lines.append(" DIAGNOSTICS ASSOCIÉS (DAS)") - lines.append(f" Total : {das['total']} (moy {das['mean_per_dossier']}/dossier)") - lines.append(f" Avec code : {das['with_code']}/{das['total']}") - lines.append(f" Codes valides : {_bar(das['validity_rate'])} {_pct(das['validity_rate'])}") - lines.append(f" Confiance : high={das['confidence']['high']} " - f"medium={das['confidence']['medium']} low={das['confidence']['low']}") - lines.append(f" Confiance high : {_bar(das['confidence_high_rate'])} {_pct(das['confidence_high_rate'])}") - lines.append(f" Downgrades : {das['downgraded']} ({_pct(das['downgrade_rate'])})") - - # Veto - veto = agg["veto"] - lines.append("") - lines.append(" VETOS / QUALITÉ") - for v, count in sorted(veto["verdicts"].items(), key=lambda x: -x[1]): - lines.append(f" {v:12s} : {count}") - lines.append(f" Issues HARD : {veto['hard_total']} (dans {veto['dossiers_with_hard']} dossiers)") - - # GHM - lines.append("") - lines.append(" GHM") - lines.append(f" Estimé : {_bar(agg['ghm']['estimated_rate'])} {_pct(agg['ghm']['estimated_rate'])}") - - # CPAM - if agg["cpam"]["controls_total"] > 0: - lines.append("") - lines.append(" CPAM") - lines.append(f" Contrôles : {agg['cpam']['controls_total']}") - lines.append(f" Avec réponse : {agg['cpam']['with_response']}") - - # Temps - lines.append("") - lines.append(" TEMPS DE TRAITEMENT") - lines.append(f" Moyen : {agg['timing']['mean_s']:.1f}s") - lines.append(f" Médian : {agg['timing']['median_s']:.1f}s") - lines.append(f" P90 : {agg['timing']['p90_s']:.1f}s") - lines.append(f" Total : {agg['timing']['total_s']:.0f}s") - - # Codes invalides - if agg["invalid_codes"]: - lines.append("") - lines.append(f" CODES CIM-10 INVALIDES ({agg['invalid_codes_count']})") - for code in agg["invalid_codes"][:20]: - lines.append(f" {code}") - if agg["invalid_codes_count"] > 20: - lines.append(f" ... et {agg['invalid_codes_count'] - 20} autres") - - # Détail par dossier - lines.append("") - lines.append("-" * w) - lines.append(" DÉTAIL PAR DOSSIER") - lines.append("-" * w) - lines.append(f" {'Dossier':<25s} {'DP':>6s} {'DAS':>4s} {'Valid%':>7s} {'Veto':>10s} {'Temps':>6s}") - lines.append(f" {'-'*25:<25s} {'-'*6:>6s} {'-'*4:>4s} {'-'*7:>7s} {'-'*10:>10s} {'-'*6:>6s}") - - for d in sorted(per_dossier, key=lambda x: x["dossier_id"]): - if not d.get("success"): - lines.append(f" {d['dossier_id']:<25s} {'ÉCHEC':>6s}") - continue - dp_code = d["dp"]["code_final"] or "-" - dp_mark = "✓" if d["dp"]["valid_code"] else "✗" - n_das = d["das"]["total"] - vr = f"{d['das']['validity_rate']*100:.0f}%" if d["das"]["with_code"] else "-" - verdict = d["veto"]["verdict"] - t = f"{d['processing_time_s']:.0f}s" - lines.append(f" {d['dossier_id']:<25s} {dp_code:>5s}{dp_mark} {n_das:>4d} {vr:>7s} {verdict:>10s} {t:>6s}") - - lines.append("") - lines.append("=" * w) - return "\n".join(lines) - - -# --------------------------------------------------------------------------- -# Comparaison entre runs -# --------------------------------------------------------------------------- - -def compare_runs(current_agg: dict, baseline_agg: dict, baseline_id: str) -> str: - """Compare deux runs et génère un rapport diff.""" - lines = [] - w = 66 - lines.append("") - lines.append("=" * w) - lines.append(f" COMPARAISON avec {baseline_id}") - lines.append("=" * w) - - def _delta(cur: float, base: float, is_pct: bool = True) -> str: - d = cur - base - sign = "+" if d >= 0 else "" - if is_pct: - return f"{sign}{d*100:.1f}%" - return f"{sign}{d:.1f}" - - def _row(label: str, cur_val: float, base_val: float, is_pct: bool = True): - if is_pct: - cur_s = _pct(cur_val) - base_s = _pct(base_val) - else: - cur_s = f"{cur_val:.1f}" - base_s = f"{base_val:.1f}" - delta_s = _delta(cur_val, base_val, is_pct) - lines.append(f" {label:<24s} {base_s:>10s} {cur_s:>10s} {delta_s:>10s}") - - lines.append(f" {'Métrique':<24s} {'Baseline':>10s} {'Actuel':>10s} {'Delta':>10s}") - lines.append(f" {'-'*24:<24s} {'-'*10:>10s} {'-'*10:>10s} {'-'*10:>10s}") - - _row("DP code valide", current_agg["dp"]["valid_code_rate"], baseline_agg["dp"]["valid_code_rate"]) - _row("DAS validité", current_agg["das"]["validity_rate"], baseline_agg["das"]["validity_rate"]) - _row("DAS confiance high", current_agg["das"]["confidence_high_rate"], baseline_agg["das"]["confidence_high_rate"]) - _row("DAS downgrade", current_agg["das"]["downgrade_rate"], baseline_agg["das"]["downgrade_rate"]) - _row("GHM estimé", current_agg["ghm"]["estimated_rate"], baseline_agg["ghm"]["estimated_rate"]) - _row("DAS moy/dossier", current_agg["das"]["mean_per_dossier"], baseline_agg["das"]["mean_per_dossier"], is_pct=False) - _row("Temps moyen (s)", current_agg["timing"]["mean_s"], baseline_agg["timing"]["mean_s"], is_pct=False) - - # Codes invalides - cur_inv = set(current_agg.get("invalid_codes", [])) - base_inv = set(baseline_agg.get("invalid_codes", [])) - new_inv = cur_inv - base_inv - fixed_inv = base_inv - cur_inv - if new_inv: - lines.append(f"\n Nouveaux codes invalides : {', '.join(sorted(new_inv))}") - if fixed_inv: - lines.append(f" Codes corrigés : {', '.join(sorted(fixed_inv))}") - - lines.append("=" * w) - return "\n".join(lines) - - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - -def get_current_config() -> dict: - """Récupère la configuration modèle actuelle.""" - try: - from src.config import OLLAMA_MODELS, OLLAMA_MODEL, OLLAMA_URL - return { - "models": dict(OLLAMA_MODELS), - "ollama_model": OLLAMA_MODEL, - "ollama_url": OLLAMA_URL, - } - except ImportError: - return { - "models": { - "coding": os.environ.get("T2A_MODEL_CODING", "?"), - "cpam": os.environ.get("T2A_MODEL_CPAM", "?"), - "validation": os.environ.get("T2A_MODEL_VALIDATION", "?"), - "qc": os.environ.get("T2A_MODEL_QC", "?"), - }, - "ollama_model": os.environ.get("OLLAMA_MODEL", "?"), - } - - -def main(): - parser = argparse.ArgumentParser(description="Benchmark qualité T2A") - parser.add_argument("--n", type=int, default=10, help="Nombre de dossiers") - parser.add_argument("--dossiers", type=str, help="IDs séparés par des virgules") - parser.add_argument("--gold-standard", action="store_true", help="Utiliser les 50 dossiers gold standard") - parser.add_argument("--compare", type=str, help="Run ID à comparer") - parser.add_argument("--label", type=str, default="", help="Label pour ce run") - parser.add_argument("--no-reprocess", action="store_true", help="Analyser les outputs existants sans relancer le pipeline") - parser.add_argument("--clean", action="store_true", help="Supprimer les outputs avant retraitement") - parser.add_argument("--seed", type=int, default=42, help="Seed pour la sélection aléatoire") - parser.add_argument("--workers", type=int, default=1, help="Nombre de dossiers traités en parallèle") - args = parser.parse_args() - - # Sélection dossiers - specific = args.dossiers.split(",") if args.dossiers else None - dossiers = select_dossiers(args.n, args.gold_standard, specific, args.seed) - print(f"\n Dossiers sélectionnés : {len(dossiers)}") - for d in dossiers: - print(f" - {d}") - - # Config - config = get_current_config() - run_id = datetime.now().strftime("%Y%m%d_%H%M%S") - if args.label: - run_id = f"{run_id}_{args.label}" - config["timestamp"] = datetime.now().isoformat() - config["run_id"] = run_id - config["dossiers"] = dossiers - config["args"] = { - "n": args.n, - "gold_standard": args.gold_standard, - "clean": args.clean, - "no_reprocess": args.no_reprocess, - "seed": args.seed, - "label": args.label, - } - - print(f"\n Run ID : {run_id}") - print(f" Modèles : {config['models']}") - print(f" Reprocess: {'NON' if args.no_reprocess else 'OUI (clean=' + str(args.clean) + ')'}") - print() - - # Charger dictionnaire CIM-10 - cim10 = load_cim10_dict() - print(f" Dictionnaire CIM-10 : {len(cim10)} codes") - print() - - # Traitement - per_dossier = [] - total = len(dossiers) - - if args.workers > 1 and not args.no_reprocess: - # Mode parallèle : exécuter les pipelines en parallèle puis analyser - from concurrent.futures import ThreadPoolExecutor, as_completed - print(f" Mode parallèle : {args.workers} workers") - pipeline_results: dict[str, tuple[float, bool]] = {} - done = 0 - with ThreadPoolExecutor(max_workers=args.workers) as executor: - futures = { - executor.submit(run_pipeline, dossier_id, args.clean): dossier_id - for dossier_id in dossiers - } - for future in as_completed(futures): - dossier_id = futures[future] - try: - duration, success = future.result() - except Exception as e: - print(f" EXCEPTION {dossier_id}: {e}") - duration, success = 0.0, False - pipeline_results[dossier_id] = (duration, success) - done += 1 - mark = "✓" if success else "✗" - print(f" [{done}/{total}] {dossier_id} — {duration:.1f}s {mark}") - - # Analyse séquentielle (ordre stable) - for dossier_id in dossiers: - duration, success = pipeline_results[dossier_id] - metrics = analyze_dossier(dossier_id, cim10, duration) - per_dossier.append(metrics) - else: - # Mode séquentiel (ou --no-reprocess) - for i, dossier_id in enumerate(dossiers, 1): - print(f" [{i}/{total}] {dossier_id}", end="", flush=True) - - if args.no_reprocess: - duration = 0.0 - success = find_merged_json(dossier_id) is not None - if not success: - print(" — pas de JSON") - else: - print(" — analyse existant") - else: - print(" — traitement...", end="", flush=True) - duration, success = run_pipeline(dossier_id, args.clean) - print(f" {duration:.1f}s {'✓' if success else '✗'}") - - metrics = analyze_dossier(dossier_id, cim10, duration) - per_dossier.append(metrics) - - # Agrégation - agg = compute_aggregate(per_dossier) - - # Rapport - report = generate_report(run_id, config, agg, per_dossier) - print(report) - - # Comparaison si demandée - comparison = "" - if args.compare: - baseline_path = BENCHMARKS_DIR / args.compare / "metrics.json" - if baseline_path.exists(): - baseline = json.loads(baseline_path.read_text("utf-8")) - comparison = compare_runs(agg, baseline["aggregate"], args.compare) - print(comparison) - else: - print(f"\n WARN: run baseline {args.compare} introuvable ({baseline_path})") - - # Sauvegarde - run_dir = BENCHMARKS_DIR / run_id - run_dir.mkdir(parents=True, exist_ok=True) - - (run_dir / "config.json").write_text( - json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8" - ) - (run_dir / "metrics.json").write_text( - json.dumps({"aggregate": agg, "per_dossier": per_dossier}, ensure_ascii=False, indent=2), - encoding="utf-8", - ) - (run_dir / "report.txt").write_text(report + comparison, encoding="utf-8") - - print(f"\n Résultats sauvegardés dans : {run_dir}") - print(f" Pour comparer un futur run : python scripts/benchmark_quality.py --compare {run_id}") - - -if __name__ == "__main__": - main() diff --git a/scripts_t2a_v2/regenerate_tier_c.py b/scripts_t2a_v2/regenerate_tier_c.py deleted file mode 100644 index 329b3e3..0000000 --- a/scripts_t2a_v2/regenerate_tier_c.py +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env python3 -"""Régénération ciblée des contrôles CPAM classés Tier C ou sans response_data. - -Usage : - cd /home/dom/ai/t2a_v2 - .venv/bin/python3 scripts/regenerate_tier_c.py [--dry-run] - -Le script : -1. Scanne output/structured/ pour trouver les contrôles Tier C + ceux sans response_data -2. Pour chaque contrôle, relance generate_cpam_response() avec le pipeline corrigé -3. Sauvegarde le JSON mis à jour (backup automatique .bak) - -Options : - --dry-run Affiche les contrôles ciblés sans régénérer -""" - -from __future__ import annotations - -import json -import logging -import shutil -import sys -import time -from pathlib import Path - -# Ajouter le répertoire racine au path -ROOT = Path(__file__).resolve().parent.parent -sys.path.insert(0, str(ROOT)) - -from src.config import DossierMedical -from src.control.cpam_response import generate_cpam_response - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(levelname)-7s %(message)s", - datefmt="%H:%M:%S", -) -logger = logging.getLogger(__name__) - -STRUCTURED_DIR = ROOT / "output" / "structured" - - -def find_targets() -> list[tuple[Path, int]]: - """Trouve les fichiers JSON contenant des contrôles Tier C ou sans response_data. - - Returns: - Liste de (chemin_json, index_du_controle_dans_la_liste). - """ - targets: list[tuple[Path, int]] = [] - - for sub in sorted(STRUCTURED_DIR.iterdir()): - if not sub.is_dir(): - continue - for jf in sub.glob("*_fusionne_cim10.json"): - data = json.loads(jf.read_text(encoding="utf-8")) - controles = data.get("controles_cpam", []) - for i, ctrl in enumerate(controles): - tier = ctrl.get("quality_tier") - has_resp = ctrl.get("response_data") is not None - if tier == "C" or not has_resp: - targets.append((jf, i)) - - return targets - - -def regenerate(targets: list[tuple[Path, int]]) -> dict[str, int]: - """Régénère les contrôles CPAM ciblés. - - Returns: - Statistiques {tier_A, tier_B, tier_C, errors}. - """ - stats = {"A": 0, "B": 0, "C": 0, "errors": 0} - # Grouper par fichier pour ne charger/sauver qu'une fois par dossier - by_file: dict[Path, list[int]] = {} - for path, idx in targets: - by_file.setdefault(path, []).append(idx) - - total = len(targets) - done = 0 - - for json_path, indices in by_file.items(): - dossier_id = json_path.parent.name - logger.info("=== Dossier %s (%d contrôle(s) à régénérer) ===", dossier_id, len(indices)) - - # Charger le dossier - data = json.loads(json_path.read_text(encoding="utf-8")) - dossier = DossierMedical.model_validate(data) - - modified = False - - for idx in indices: - ctrl = dossier.controles_cpam[idx] - done += 1 - old_tier = ctrl.quality_tier or "?" - logger.info("[%d/%d] OGC %d — %s (ancien tier: %s)", - done, total, ctrl.numero_ogc, ctrl.titre[:60], old_tier) - - t0 = time.time() - try: - text, response_data, sources = generate_cpam_response(dossier, ctrl) - elapsed = time.time() - t0 - - ctrl.contre_argumentation = text - ctrl.response_data = response_data - ctrl.sources_reponse = sources - - new_tier = ctrl.quality_tier or "?" - stats[new_tier] = stats.get(new_tier, 0) + 1 - modified = True - - logger.info(" Résultat : tier %s → %s (%d chars, %.1fs)", - old_tier, new_tier, len(text), elapsed) - except Exception: - logger.exception(" ERREUR sur OGC %d", ctrl.numero_ogc) - stats["errors"] += 1 - - if modified: - # Backup + sauvegarde - backup_path = json_path.with_suffix(".json.bak") - shutil.copy2(json_path, backup_path) - json_path.write_text( - dossier.model_dump_json(indent=2, exclude_none=True), - encoding="utf-8", - ) - logger.info(" Sauvegardé : %s (backup: %s)", json_path.name, backup_path.name) - - return stats - - -def main() -> None: - dry_run = "--dry-run" in sys.argv - - logger.info("Recherche des contrôles Tier C et sans response_data...") - targets = find_targets() - - if not targets: - logger.info("Aucun contrôle à régénérer.") - return - - logger.info("Trouvé %d contrôle(s) à régénérer :", len(targets)) - for path, idx in targets: - data = json.loads(path.read_text(encoding="utf-8")) - ctrl = data["controles_cpam"][idx] - tier = ctrl.get("quality_tier", "?") - has_resp = "oui" if ctrl.get("response_data") else "NON" - logger.info(" %s OGC %d — tier %s, response_data: %s", - path.parent.name, ctrl["numero_ogc"], tier, has_resp) - - if dry_run: - logger.info("Mode dry-run — aucune régénération effectuée.") - return - - t0 = time.time() - stats = regenerate(targets) - elapsed = time.time() - t0 - - logger.info("=== TERMINÉ en %.1f min ===", elapsed / 60) - logger.info("Distribution : A=%d, B=%d, C=%d, erreurs=%d", - stats.get("A", 0), stats.get("B", 0), stats.get("C", 0), stats["errors"]) - - -if __name__ == "__main__": - main() diff --git a/scripts_t2a_v2/select_validation_dossiers.py b/scripts_t2a_v2/select_validation_dossiers.py deleted file mode 100644 index 5b70516..0000000 --- a/scripts_t2a_v2/select_validation_dossiers.py +++ /dev/null @@ -1,231 +0,0 @@ -#!/usr/bin/env python3 -"""Sélectionne 50 dossiers pour le gold standard de validation DIM. - -- 25 dossiers CPAM (cas complexes, déjà contrôlés) -- 25 dossiers non-CPAM stratifiés par CMD, confiance DP, nombre de DAS - -Crée data/gold_standard/_selection.json et initialise les annotations vides. -""" - -from __future__ import annotations - -import json -import random -import sys -from pathlib import Path - -# Ajouter le répertoire racine au path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from src.config import STRUCTURED_DIR, BASE_DIR, DossierMedical - -GOLD_DIR = BASE_DIR / "data" / "gold_standard" -TARGET_TOTAL = 50 -TARGET_CPAM = 25 - - -def load_all_dossiers() -> list[dict]: - """Charge tous les dossiers fusionnés depuis output/structured/.""" - dossiers = [] - for subdir in sorted(STRUCTURED_DIR.iterdir()): - if not subdir.is_dir(): - continue - # Chercher le fichier fusionné - fusionne = None - for f in subdir.glob("*fusionne*.json"): - fusionne = f - break - if not fusionne: - # Prendre le premier JSON du dossier - jsons = sorted(subdir.glob("*.json")) - if jsons: - fusionne = jsons[0] - if not fusionne: - continue - - try: - data = json.loads(fusionne.read_text(encoding="utf-8")) - dossier = DossierMedical.model_validate(data) - rel_path = str(fusionne.relative_to(STRUCTURED_DIR)) - group_name = subdir.name - dossiers.append({ - "dossier_id": f"{group_name}/{fusionne.stem}", - "group_name": group_name, - "path_rel": rel_path, - "dossier": dossier, - }) - except Exception as e: - print(f" Erreur chargement {fusionne.name}: {e}") - return dossiers - - -def select_dossiers(all_dossiers: list[dict]) -> list[dict]: - """Sélectionne les 50 dossiers selon la stratégie définie.""" - # Séparer CPAM / non-CPAM - cpam = [d for d in all_dossiers if d["dossier"].controles_cpam] - non_cpam = [d for d in all_dossiers if not d["dossier"].controles_cpam] - - print(f"Dossiers CPAM disponibles : {len(cpam)}") - print(f"Dossiers non-CPAM disponibles : {len(non_cpam)}") - - # Prendre tous les CPAM (ou max TARGET_CPAM) - selected_cpam = cpam[:TARGET_CPAM] - remaining_target = TARGET_TOTAL - len(selected_cpam) - - # Stratifier les non-CPAM - selected_non_cpam = stratified_sample(non_cpam, remaining_target) - - selected = selected_cpam + selected_non_cpam - print(f"\nSélection finale : {len(selected)} dossiers") - print(f" - CPAM : {len(selected_cpam)}") - print(f" - Non-CPAM : {len(selected_non_cpam)}") - - return selected - - -def stratified_sample(dossiers: list[dict], n: int) -> list[dict]: - """Échantillonnage stratifié par CMD, confiance DP et nombre de DAS.""" - if len(dossiers) <= n: - return dossiers - - # Grouper par CMD - by_cmd: dict[str, list[dict]] = {} - for d in dossiers: - ghm = d["dossier"].ghm_estimation - cmd = ghm.cmd if ghm else "inconnu" - by_cmd.setdefault(cmd or "inconnu", []).append(d) - - selected = [] - seen_ids = set() - - # Phase 1 : 1 dossier par CMD (diversité maximale) - cmds = sorted(by_cmd.keys()) - random.seed(42) # Reproductible - for cmd in cmds: - if len(selected) >= n: - break - candidates = by_cmd[cmd] - # Préférer un mix de confiances - random.shuffle(candidates) - d = candidates[0] - selected.append(d) - seen_ids.add(d["dossier_id"]) - - # Phase 2 : compléter avec diversité confiance DP - if len(selected) < n: - remaining = [d for d in dossiers if d["dossier_id"] not in seen_ids] - # Trier par confiance DP (low > medium > high pour surreprésenter les cas difficiles) - conf_order = {"low": 0, "medium": 1, "high": 2, None: 3} - remaining.sort(key=lambda d: ( - conf_order.get( - d["dossier"].diagnostic_principal.cim10_confidence - if d["dossier"].diagnostic_principal else None, - 3 - ), - -len(d["dossier"].diagnostics_associes), # beaucoup de DAS d'abord - )) - for d in remaining: - if len(selected) >= n: - break - selected.append(d) - - return selected[:n] - - -def create_empty_annotation(dossier_id: str, dossier: DossierMedical) -> dict: - """Crée une annotation vide pour un dossier.""" - dp = dossier.diagnostic_principal - das_list = [] - for i, das in enumerate(dossier.diagnostics_associes): - das_list.append({ - "index": i, - "texte_original": das.texte, - "code_pipeline": das.cim10_suggestion or "", - "confidence": das.cim10_confidence or "", - "source": das.source or "", - "statut": "correct", - "code_corrige": None, - "commentaire": "", - }) - - return { - "dossier_id": dossier_id, - "validateur": "", - "date_validation": "", - "statut": "non_commence", - "dp": { - "texte_original": dp.texte if dp else "", - "code_pipeline": dp.cim10_suggestion if dp else "", - "confidence": dp.cim10_confidence if dp else "", - "statut": "correct", - "code_corrige": None, - "commentaire": "", - }, - "das": das_list, - "das_ajoutes": [], - "commentaire_general": "", - } - - -def main(): - print("=== Sélection des dossiers pour validation DIM ===\n") - - all_dossiers = load_all_dossiers() - print(f"Total dossiers chargés : {len(all_dossiers)}\n") - - if not all_dossiers: - print("Aucun dossier trouvé dans output/structured/") - sys.exit(1) - - selected = select_dossiers(all_dossiers) - - # Créer le répertoire gold standard - GOLD_DIR.mkdir(parents=True, exist_ok=True) - - # Sauvegarder la sélection - selection = { - "date_selection": __import__("datetime").datetime.now().isoformat(timespec="seconds"), - "total": len(selected), - "cpam": sum(1 for d in selected if d["dossier"].controles_cpam), - "non_cpam": sum(1 for d in selected if not d["dossier"].controles_cpam), - "dossiers": [d["dossier_id"] for d in selected], - } - selection_path = GOLD_DIR / "_selection.json" - selection_path.write_text( - json.dumps(selection, ensure_ascii=False, indent=2), - encoding="utf-8", - ) - print(f"\nSélection sauvegardée : {selection_path}") - - # Initialiser les annotations vides - created = 0 - for d in selected: - dossier_id = d["dossier_id"] - safe_name = dossier_id.replace("/", "__") + ".json" - annot_path = GOLD_DIR / safe_name - if not annot_path.exists(): - annotation = create_empty_annotation(dossier_id, d["dossier"]) - annot_path.write_text( - json.dumps(annotation, ensure_ascii=False, indent=2), - encoding="utf-8", - ) - created += 1 - - print(f"Annotations vides créées : {created}") - print(f"Annotations existantes préservées : {len(selected) - created}") - - # Résumé - print(f"\n--- Résumé ---") - for i, d in enumerate(selected, 1): - dos = d["dossier"] - dp_code = dos.diagnostic_principal.cim10_suggestion if dos.diagnostic_principal else "?" - dp_conf = (dos.diagnostic_principal.cim10_confidence or "?") if dos.diagnostic_principal else "?" - n_das = len(dos.diagnostics_associes) - cpam_flag = " [CPAM]" if dos.controles_cpam else "" - ghm = dos.ghm_estimation - cmd = ghm.cmd if ghm else "?" - print(f" {i:2d}. {d['group_name']:<20s} DP={dp_code:<6s} conf={dp_conf:<7s} DAS={n_das:2d} CMD={cmd}{cpam_flag}") - - -if __name__ == "__main__": - main() diff --git a/src/config.py b/src/config.py index 30031fb..e8e0a32 100644 --- a/src/config.py +++ b/src/config.py @@ -8,11 +8,14 @@ from functools import lru_cache from pathlib import Path from typing import Optional, Any, Dict +import logging import yaml from dotenv import load_dotenv from pydantic import BaseModel, Field, field_validator +_cfg_logger = logging.getLogger(__name__) + load_dotenv() @@ -33,6 +36,7 @@ DIAGNOSTIC_CONFLICTS_PATH = CONFIG_DIR / "diagnostic_conflicts.yaml" PROCEDURE_DIAGNOSIS_RULES_PATH = CONFIG_DIR / "procedure_diagnosis_rules.yaml" TEMPORAL_RULES_PATH = CONFIG_DIR / "temporal_rules.yaml" PARCOURS_RULES_PATH = CONFIG_DIR / "parcours_rules.yaml" +COMPLETUDE_RULES_PATH = CONFIG_DIR / "completude_rules.yaml" RULES_DIR = CONFIG_DIR / "rules" RULES_BASE_PATH = RULES_DIR / "base.yaml" RULES_ENABLED_PATH = RULES_DIR / "enabled.yaml" @@ -131,14 +135,16 @@ RAG_INDEX_DIR = BASE_DIR / "data" / "rag_index" REFERENTIELS_DIR = BASE_DIR / "data" / "referentiels" UPLOAD_MAX_SIZE_MB = 50 ALLOWED_EXTENSIONS = {".pdf", ".csv", ".xlsx", ".xls", ".txt"} -CIM10_DICT_PATH = BASE_DIR / "data" / "cim10_dict.json" -CIM10_SUPPLEMENTS_PATH = BASE_DIR / "data" / "cim10_supplements.json" +_DICTS_DIR = REFERENTIELS_DIR / "dicts" +_PDFS_DIR = REFERENTIELS_DIR / "pdfs" +CIM10_DICT_PATH = _DICTS_DIR / "cim10_dict.json" +CIM10_SUPPLEMENTS_PATH = _DICTS_DIR / "cim10_supplements.json" BIO_CONCEPTS_PATH = BASE_DIR / "data" / "bio_concepts.json" CMA_LEVELS_PATH = BASE_DIR / "data" / "cma_levels.json" -CCAM_DICT_PATH = BASE_DIR / "data" / "ccam_dict.json" -CIM10_PDF = Path(os.environ.get("T2A_CIM10_PDF", "/home/dom/ai/aivanov_CIM/cim-10-fr_2026_a_usage_pmsi_version_provisoire_111225.pdf")) -GUIDE_METHODO_PDF = Path(os.environ.get("T2A_GUIDE_METHODO_PDF", "/home/dom/ai/aivanov_CIM/guide_methodo_mco_2026_version_provisoire.pdf")) -CCAM_PDF = Path(os.environ.get("T2A_CCAM_PDF", "/home/dom/ai/aivanov_CIM/actualisation_ccam_descriptive_a_usage_pmsi_v4_2025.pdf")) +CCAM_DICT_PATH = _DICTS_DIR / "ccam_dict.json" +CIM10_PDF = Path(os.environ.get("T2A_CIM10_PDF", str(_PDFS_DIR / "cim-10-fr_2026_a_usage_pmsi_version_provisoire_111225.pdf"))) +GUIDE_METHODO_PDF = Path(os.environ.get("T2A_GUIDE_METHODO_PDF", str(_PDFS_DIR / "guide_methodo_mco_2026_version_provisoire.pdf"))) +CCAM_PDF = Path(os.environ.get("T2A_CCAM_PDF", str(_PDFS_DIR / "actualisation_ccam_descriptive_a_usage_pmsi_v4_2025.pdf"))) # --- Modèle d'embedding --- @@ -150,18 +156,37 @@ RERANKER_MODEL = os.environ.get("T2A_RERANKER_MODEL", "cross-encoder/ms-marco-Mi # --- Références biologiques (fallback) --- +def _load_yaml_config(path: Path, defaults: Dict[str, Any], label: str) -> Dict[str, Any]: + """Helper : charge un YAML config avec merge sur defaults et logging explicite. + + - Si le fichier n'existe pas : retourne defaults (info log). + - Si le YAML est invalide : retourne defaults + log error. + - Sinon : merge YAML sur defaults. + """ + if not path.exists(): + _cfg_logger.debug("Config %s : fichier absent (%s), defaults utilisés", label, path) + return defaults + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + if not isinstance(data, dict): + _cfg_logger.error("Config %s : contenu invalide (attendu dict, reçu %s) dans %s", + label, type(data).__name__, path) + return defaults + merged = dict(defaults) + for k, v in data.items(): + merged[k] = v + return merged + except yaml.YAMLError as e: + _cfg_logger.error("Config %s : erreur de syntaxe YAML dans %s — %s", label, path, e) + return defaults + except Exception as e: + _cfg_logger.error("Config %s : erreur lecture %s — %s", label, path, e) + return defaults + + @lru_cache(maxsize=1) def load_reference_ranges() -> Dict[str, Any]: - """Charge les intervalles de référence biologiques depuis config/reference_ranges.yaml. - - Hiérarchie d'usage recommandée dans les règles : - 1) Normes présentes dans le document (ex: [N: 135-145]) - 2) Table YAML (par bande d'âge) - 3) "Safe zones" conservatrices si âge inconnu - - Le YAML est volontairement éditable par des non-informaticiens (future UI). - """ - # Defaults minimalistes (adultes) si YAML absent + """Charge les intervalles de référence biologiques depuis config/reference_ranges.yaml.""" defaults: Dict[str, Any] = { "version": 1, "age_bands": {"adult_min_years": 18}, @@ -171,8 +196,6 @@ def load_reference_ranges() -> Dict[str, Any]: "sodium": {"low": 135, "high": 145, "unit": "mmol/L"}, "potassium": {"low": 3.5, "high": 5.0, "unit": "mmol/L"}, }, - # Valeurs pédiatriques: à affiner (par bandes d'âge) si besoin. - # Pour les règles "ruled_out" on utilise plutôt les safe_zones_unknown_age "child": { "platelets": {"low": 150, "high": 450, "unit": "G/L"}, "sodium": {"low": 135, "high": 145, "unit": "mmol/L"}, @@ -186,28 +209,7 @@ def load_reference_ranges() -> Dict[str, Any]: "potassium_ruled_out_low": 3.7, }, } - - path = REFERENCE_RANGES_PATH - if not path.exists(): - return defaults - - try: - import yaml # type: ignore - except Exception: - # PyYAML absent: on garde les valeurs par défaut - return defaults - - try: - data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - if not isinstance(data, dict): - return defaults - # Merge léger: defaults comme socle, YAML surcharge - merged = dict(defaults) - for k, v in data.items(): - merged[k] = v - return merged - except Exception: - return defaults + return _load_yaml_config(REFERENCE_RANGES_PATH, defaults, "reference_ranges") # --- Règles biologiques (pilotées par YAML) --- @@ -215,14 +217,7 @@ def load_reference_ranges() -> Dict[str, Any]: @lru_cache(maxsize=1) def load_bio_rules() -> Dict[str, Any]: - """Charge les règles biologiques depuis config/bio_rules.yaml. - - Objectif: permettre d'activer/désactiver et de paramétrer les règles - de type "contradiction bio ⇒ ruled_out" sans modifier le code. - - Le fichier est volontairement simple (future UI). - """ - + """Charge les règles biologiques depuis config/bio_rules.yaml.""" defaults: Dict[str, Any] = { "version": 1, "rules": { @@ -231,144 +226,55 @@ def load_bio_rules() -> Dict[str, Any]: "hypokalemia": {"enabled": True, "codes": ["E87.6"], "analyte": "potassium"}, }, } - - path = BIO_RULES_PATH - if not path.exists(): - return defaults - - try: - import yaml # type: ignore - except Exception: - return defaults - - try: - data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - if not isinstance(data, dict): - return defaults - merged = dict(defaults) - for k, v in data.items(): - merged[k] = v - return merged - except Exception: - return defaults + return _load_yaml_config(BIO_RULES_PATH, defaults, "bio_rules") @lru_cache(maxsize=1) def load_demographic_rules() -> Dict[str, Any]: """Charge les règles démographiques (sexe/âge) depuis config/demographic_rules.yaml.""" - defaults: Dict[str, Any] = { - "version": 1, - "sex_rules": {}, - "age_rules": {}, - } - path = DEMOGRAPHIC_RULES_PATH - if not path.exists(): - return defaults - try: - import yaml # type: ignore - data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - if not isinstance(data, dict): - return defaults - merged = dict(defaults) - for k, v in data.items(): - merged[k] = v - return merged - except Exception: - return defaults + return _load_yaml_config(DEMOGRAPHIC_RULES_PATH, { + "version": 1, "sex_rules": {}, "age_rules": {}, + }, "demographic_rules") @lru_cache(maxsize=1) def load_diagnostic_conflicts() -> Dict[str, Any]: """Charge les conflits diagnostics depuis config/diagnostic_conflicts.yaml.""" - defaults: Dict[str, Any] = { - "version": 1, - "mutual_exclusions": [], - "incompatibilities": [], - } - path = DIAGNOSTIC_CONFLICTS_PATH - if not path.exists(): - return defaults - try: - import yaml # type: ignore - data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - if not isinstance(data, dict): - return defaults - merged = dict(defaults) - for k, v in data.items(): - merged[k] = v - return merged - except Exception: - return defaults + return _load_yaml_config(DIAGNOSTIC_CONFLICTS_PATH, { + "version": 1, "mutual_exclusions": [], "incompatibilities": [], + }, "diagnostic_conflicts") @lru_cache(maxsize=1) def load_procedure_diagnosis_rules() -> Dict[str, Any]: """Charge les règles de corrélation actes/diagnostics depuis config/procedure_diagnosis_rules.yaml.""" - defaults: Dict[str, Any] = { - "version": 1, - "rules": [], - } - path = PROCEDURE_DIAGNOSIS_RULES_PATH - if not path.exists(): - return defaults - try: - import yaml # type: ignore - data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - if not isinstance(data, dict): - return defaults - merged = dict(defaults) - for k, v in data.items(): - merged[k] = v - return merged - except Exception: - return defaults + return _load_yaml_config(PROCEDURE_DIAGNOSIS_RULES_PATH, { + "version": 1, "rules": [], + }, "procedure_diagnosis_rules") @lru_cache(maxsize=1) def load_temporal_rules() -> Dict[str, Any]: """Charge les règles temporelles depuis config/temporal_rules.yaml.""" - defaults: Dict[str, Any] = { - "version": 1, - "rules": [], - } - path = TEMPORAL_RULES_PATH - if not path.exists(): - return defaults - try: - import yaml # type: ignore - data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - if not isinstance(data, dict): - return defaults - merged = dict(defaults) - for k, v in data.items(): - merged[k] = v - return merged - except Exception: - return defaults + return _load_yaml_config(TEMPORAL_RULES_PATH, { + "version": 1, "rules": [], + }, "temporal_rules") @lru_cache(maxsize=1) def load_parcours_rules() -> Dict[str, Any]: """Charge les règles de parcours patient depuis config/parcours_rules.yaml.""" - defaults: Dict[str, Any] = { - "version": 1, - "documentary_rules": {}, - "pathway_rules": {}, - } - path = PARCOURS_RULES_PATH - if not path.exists(): - return defaults - try: - import yaml # type: ignore - data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - if not isinstance(data, dict): - return defaults - merged = dict(defaults) - for k, v in data.items(): - merged[k] = v - return merged - except Exception: - return defaults + return _load_yaml_config(PARCOURS_RULES_PATH, { + "version": 1, "documentary_rules": {}, "pathway_rules": {}, + }, "parcours_rules") + + +@lru_cache(maxsize=1) +def load_completude_rules() -> Dict[str, Any]: + """Charge les règles de complétude documentaire depuis config/completude_rules.yaml.""" + return _load_yaml_config(COMPLETUDE_RULES_PATH, { + "version": 1, "diagnostics": {}, "actes": {}, + }, "completude_rules") # --- Garde-fous de parsing des valeurs biologiques (anti-OCR) --- @@ -418,25 +324,7 @@ def load_lab_value_sanity() -> Dict[str, Any]: }, } - path = LAB_SANITY_PATH - if not path.exists(): - return defaults - - try: - import yaml # type: ignore - except Exception: - return defaults - - try: - data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - if not isinstance(data, dict): - return defaults - merged = dict(defaults) - for k, v in data.items(): - merged[k] = v - return merged - except Exception: - return defaults + return _load_yaml_config(LAB_SANITY_PATH, defaults, "lab_value_sanity") # --- Catalogue de règles (vetos + décisions), piloté par YAML --- @@ -506,11 +394,6 @@ def load_rules_catalog() -> Dict[str, Dict[str, Any]]: (=> ne casse pas le comportement historique) """ - try: - import yaml # type: ignore - except Exception: - return {} - catalog: Dict[str, Dict[str, Any]] = {} # 1) base @@ -519,7 +402,8 @@ def load_rules_catalog() -> Dict[str, Dict[str, Any]]: base_data = yaml.safe_load(RULES_BASE_PATH.read_text(encoding="utf-8")) or {} if isinstance(base_data, dict): catalog = _flatten_rules_yaml(base_data) - except Exception: + except (yaml.YAMLError, Exception) as e: + _cfg_logger.error("Rules catalog : erreur lecture base.yaml — %s", e) catalog = {} # 2) enabled overlays @@ -537,8 +421,8 @@ def load_rules_catalog() -> Dict[str, Dict[str, Any]]: extra = active.get("extra") if isinstance(extra, list): extra_files = [str(x) for x in extra if str(x).strip()] - except Exception: - pass + except (yaml.YAMLError, Exception) as e: + _cfg_logger.error("Rules catalog : erreur lecture enabled.yaml — %s", e) else: # fallback env active_site = os.environ.get("T2A_SITE", "").strip() @@ -552,8 +436,8 @@ def load_rules_catalog() -> Dict[str, Dict[str, Any]]: data = yaml.safe_load(p.read_text(encoding="utf-8")) or {} if isinstance(data, dict): catalog = _merge_rule_catalog(catalog, _flatten_rules_yaml(data)) - except Exception: - pass + except (yaml.YAMLError, Exception) as e: + _cfg_logger.error("Rules catalog : erreur overlay spécialité %s — %s", active_specialty, e) # 4) site overlay if active_site: @@ -563,8 +447,8 @@ def load_rules_catalog() -> Dict[str, Dict[str, Any]]: data = yaml.safe_load(p.read_text(encoding="utf-8")) or {} if isinstance(data, dict): catalog = _merge_rule_catalog(catalog, _flatten_rules_yaml(data)) - except Exception: - pass + except (yaml.YAMLError, Exception) as e: + _cfg_logger.error("Rules catalog : erreur overlay site %s — %s", active_site, e) # 5) extra overlays for rel in extra_files: @@ -574,8 +458,8 @@ def load_rules_catalog() -> Dict[str, Dict[str, Any]]: data = yaml.safe_load(p.read_text(encoding="utf-8")) or {} if isinstance(data, dict): catalog = _merge_rule_catalog(catalog, _flatten_rules_yaml(data)) - except Exception: - pass + except (yaml.YAMLError, Exception) as e: + _cfg_logger.error("Rules catalog : erreur overlay %s — %s", rel, e) return catalog @@ -611,17 +495,7 @@ def load_rules_router() -> Dict[str, Any]: "defaults": {"enabled_packs": ["vetos_core", "decisions_core"]}, "triggers": [], } - path = RULES_ROUTER_PATH - if not path.exists(): - return defaults - try: - data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - # merge conservateur - if isinstance(data, dict): - defaults.update({k: v for k, v in data.items() if v is not None}) - return defaults - except Exception: - return defaults + return _load_yaml_config(RULES_ROUTER_PATH, defaults, "rules_router") def rule_enabled(rule_id: str) -> bool: @@ -877,6 +751,7 @@ class DossierMedical(BaseModel): ghm_estimation: Optional[GHMEstimation] = None controles_cpam: list[ControleCPAM] = Field(default_factory=list) veto_report: Optional["VetoReport"] = None + completude: Optional["CompletudeDossier"] = None processing_time_s: float | None = None metrics: Optional[DossierMetrics] = None rules_runtime: Optional[dict] = None @@ -924,6 +799,14 @@ class GHMEstimation(BaseModel): alertes: list[str] = Field(default_factory=list) +class FinancialImpact(BaseModel): + """Estimation de l'impact financier d'un contrôle UCR.""" + delta_severite: int = 0 # ex: -2 (perte 2 niveaux) + impact_estime_euros: int = 0 # estimation grossière + priorite: str = "normale" # "critique" | "haute" | "normale" | "faible" + raison: str = "" + + class ControleCPAM(BaseModel): numero_ogc: int titre: str = "" @@ -933,12 +816,22 @@ class ControleCPAM(BaseModel): da_ucr: Optional[str] = None dr_ucr: Optional[str] = None actes_ucr: Optional[str] = None + type_desaccord: Optional[str] = None # "DP" | "DAS" | "DP+DAS" | "Actes" + financial_impact: Optional[FinancialImpact] = None contre_argumentation: Optional[str] = None response_data: Optional[dict] = None sources_reponse: list[RAGSource] = Field(default_factory=list) quality_tier: Optional[str] = None # "A" | "B" | "C" requires_review: bool = False quality_warnings: list[str] = Field(default_factory=list) + # Délais réglementaires + date_notification: Optional[str] = None # JJ/MM/AAAA + date_limite_reponse: Optional[str] = None # calculé : notification + 30j + statut_reponse: str = "a_traiter" # "a_traiter" | "en_cours" | "envoye" | "hors_delai" + # Workflow validation DIM + validation_dim: str = "non_valide" # "non_valide" | "en_revision" | "valide" | "rejete" + commentaire_dim: Optional[str] = None + date_validation: Optional[str] = None # --- Qualité / Vetos (contestabilité) --- @@ -962,6 +855,43 @@ class VetoReport(BaseModel): issues: list[VetoIssue] = Field(default_factory=list) +# --- Complétude documentaire DIM --- + + +class ItemCompletude(BaseModel): + """Élément requis/recommandé pour justifier un code.""" + + categorie: str # "biologie" | "imagerie" | "document" | "acte" | "clinique" + element: str # "Albumine" | "CRO" | "Scanner abdominal" + statut: str # "present" | "absent" | "present_confirme" | "present_non_confirme" | "present_indirect" + valeur: Optional[str] = None # "28 g/L" si présent + importance: str # "obligatoire" | "recommande" + impact_cpam: str = "" # explication du risque + confirmation_detail: Optional[str] = None # "Albumine 28 g/L < 30 → confirme E43" + + +class CheckCompletude(BaseModel): + """Vérification de complétude pour un code diagnostique.""" + + code: str # "E43" + libelle: str # "Dénutrition sévère" + type_diag: str # "DP" | "DAS" + items: list[ItemCompletude] = Field(default_factory=list) + score: int = 100 # 0-100 + verdict: str = "defendable" # "defendable" | "fragile" | "indefendable" + resume: str = "" # "2/3 éléments obligatoires présents" + + +class CompletudeDossier(BaseModel): + """Rapport global de complétude documentaire pour un dossier.""" + + checks: list[CheckCompletude] = Field(default_factory=list) + score_global: int = 100 + verdict_global: str = "defendable" + documents_presents: list[str] = Field(default_factory=list) + documents_manquants: list[str] = Field(default_factory=list) + + class AnonymizationReport(BaseModel): source_file: str total_replacements: int = 0 diff --git a/src/control/cpam_context.py b/src/control/cpam_context.py index 4143274..026f7a8 100644 --- a/src/control/cpam_context.py +++ b/src/control/cpam_context.py @@ -568,6 +568,39 @@ def _assess_dossier_strength(dossier: DossierMedical) -> dict: } +def _build_strategie_type(controle: ControleCPAM) -> str: + """Construit le bloc de stratégie conditionnel selon le type de désaccord.""" + td = controle.type_desaccord or "DP" + blocs: dict[str, str] = { + "DP": ( + "STRATÉGIE DE CONTESTATION — TYPE : DP (Diagnostic Principal)\n" + "Démontrer que le DP retenu par l'UCR ne correspond pas au motif réel " + "d'hospitalisation. S'appuyer sur le CRH, les règles D1 (symptôme si cause " + "non identifiée) et D2 (cause si identifiée). Le DP doit refléter le diagnostic " + "ayant consommé le plus de ressources pendant le séjour." + ), + "DAS": ( + "STRATÉGIE DE CONTESTATION — TYPE : DAS (Diagnostics Associés)\n" + "Prouver que la comorbidité a bien été prise en charge pendant le séjour : " + "prescription active, acte spécifique, surveillance documentée, ou allongement " + "de durée. Chaque DAS doit mobiliser des ressources supplémentaires documentées." + ), + "Actes": ( + "STRATÉGIE DE CONTESTATION — TYPE : Actes CCAM\n" + "Vérifier le code CCAM exact, la date de réalisation, et la concordance avec " + "le compte-rendu opératoire. S'appuyer sur la nomenclature CCAM et les notes " + "d'inclusion/exclusion des codes concernés." + ), + } + if td == "DP+DAS": + return ( + "STRATÉGIE DE CONTESTATION — TYPE : DP + DAS (contestation combinée)\n" + + blocs["DP"].split("\n", 1)[1] + "\n" + + blocs["DAS"].split("\n", 1)[1] + ) + return blocs.get(td, blocs["DP"]) + + def _build_cpam_prompt( dossier: DossierMedical, controle: ControleCPAM, @@ -844,6 +877,9 @@ def _build_cpam_prompt( + "\n".join(ext_lines) ) + # Bloc de stratégie conditionnel selon le type de désaccord + strategie_type_str = _build_strategie_type(controle) + tags_disponibles_str = ( ", ".join(f"[{t}]" for t in sorted(tag_map.keys())) if tag_map else "(aucun)" @@ -863,5 +899,6 @@ def _build_cpam_prompt( bio_confrontation_str=bio_confrontation, numero_ogc=controle.numero_ogc, tags_disponibles_str=tags_disponibles_str, + strategie_type_str=strategie_type_str, ) return prompt, tag_map diff --git a/src/control/cpam_parser.py b/src/control/cpam_parser.py index 1ed7147..b46eada 100644 --- a/src/control/cpam_parser.py +++ b/src/control/cpam_parser.py @@ -6,6 +6,8 @@ import logging import re from pathlib import Path +from datetime import datetime, timedelta + import openpyxl from ..config import ControleCPAM @@ -15,6 +17,9 @@ logger = logging.getLogger(__name__) # Colonnes attendues dans le fichier Excel _EXPECTED_COLUMNS = ("N° OGC", "Titre", "Arg_UCR", "Décision_UCR", "DP_UCR", "DA_UCR", "DR_UCR", "Actes_UCR") +# Colonnes optionnelles de dates +_DATE_COLUMNS = ("Date_notification", "Date_limite") + def parse_cpam_excel(path: str | Path) -> dict[int, list[ControleCPAM]]: """Lit le fichier Excel de contrôle CPAM et retourne un dict OGC -> liste de contrôles. @@ -76,6 +81,22 @@ def parse_cpam_excel(path: str | Path) -> dict[int, list[ControleCPAM]]: dr_ucr=_clean_optional(row, col_map.get("DR_UCR")), actes_ucr=_clean_optional(row, col_map.get("Actes_UCR")), ) + controle.type_desaccord = _infer_type_desaccord(controle) + + # Dates réglementaires (optionnelles) + date_notif_raw = _clean_optional(row, col_map.get("Date_notification")) + date_limite_raw = _clean_optional(row, col_map.get("Date_limite")) + if date_notif_raw: + controle.date_notification = _parse_date(date_notif_raw) + if controle.date_notification and not date_limite_raw: + # Calculer la date limite (notification + 30 jours) + try: + dt = datetime.strptime(controle.date_notification, "%d/%m/%Y") + controle.date_limite_reponse = (dt + timedelta(days=30)).strftime("%d/%m/%Y") + except ValueError: + pass + if date_limite_raw: + controle.date_limite_reponse = _parse_date(date_limite_raw) result.setdefault(numero_ogc, []).append(controle) count += 1 @@ -84,6 +105,41 @@ def parse_cpam_excel(path: str | Path) -> dict[int, list[ControleCPAM]]: return result +def _parse_date(raw: str) -> str | None: + """Parse une date depuis l'Excel (formats courants) vers JJ/MM/AAAA.""" + if not raw: + return None + raw = raw.strip() + # Si c'est un objet datetime (openpyxl peut retourner un datetime) + if hasattr(raw, "strftime"): + return raw.strftime("%d/%m/%Y") + for fmt in ("%d/%m/%Y", "%Y-%m-%d", "%d-%m-%Y", "%d.%m.%Y"): + try: + return datetime.strptime(raw, fmt).strftime("%d/%m/%Y") + except ValueError: + continue + return raw # retourner tel quel si format inconnu + + +def _infer_type_desaccord(controle: ControleCPAM) -> str | None: + """Déduit le type de désaccord depuis les champs UCR renseignés. + + Retourne None si aucun champ UCR n'est renseigné (données incomplètes). + """ + has_dp = bool(controle.dp_ucr) + has_das = bool(controle.da_ucr) + has_actes = bool(controle.actes_ucr) + if has_dp and has_das: + return "DP+DAS" + if has_dp: + return "DP" + if has_das: + return "DAS" + if has_actes: + return "Actes" + return None + + def _clean_optional(row: tuple, idx: int | None) -> str | None: """Extrait une valeur optionnelle depuis une ligne Excel.""" if idx is None or idx >= len(row): @@ -95,21 +151,58 @@ def _clean_optional(row: tuple, idx: int | None) -> str | None: return val if val else None -def match_dossier_ogc(source_name: str, cpam_data: dict[int, list[ControleCPAM]]) -> list[ControleCPAM]: +def match_dossier_ogc( + source_name: str, + cpam_data: dict[int, list[ControleCPAM]], + structured_dir: Path | None = None, +) -> list[ControleCPAM]: """Cherche les contrôles CPAM correspondant à un dossier par préfixe OGC. - Le nom du dossier suit le format "17_23100690" où 17 est le N° OGC. + Stratégie de matching (par ordre de priorité) : + 1. Regex sur le nom du répertoire (format "17_23100690" → OGC 17) + 2. Fallback : chercher l'OGC dans les métadonnées du JSON fusionné Args: source_name: Nom du sous-dossier (ex: "17_23100690"). cpam_data: Dict OGC -> contrôles retourné par parse_cpam_excel(). + structured_dir: Répertoire structured/ pour le fallback JSON (optionnel). Returns: Liste des contrôles CPAM pour cet OGC, ou liste vide. """ + # 1. Match par nom de répertoire (méthode existante) match = re.match(r"^(\d+)_", source_name) - if not match: - return [] + if match: + ogc = int(match.group(1)) + result = cpam_data.get(ogc, []) + if result: + return result - ogc = int(match.group(1)) - return cpam_data.get(ogc, []) + # 2. Fallback : chercher l'OGC dans le JSON fusionné + if structured_dir is not None: + dossier_dir = structured_dir / source_name + if dossier_dir.is_dir(): + import json + for json_file in dossier_dir.glob("*_fusionne_cim10.json"): + try: + data = json.loads(json_file.read_text(encoding="utf-8")) + # Chercher dans controles_cpam existants + for ctrl in data.get("controles_cpam", []): + ctrl_ogc = ctrl.get("numero_ogc") + if ctrl_ogc and ctrl_ogc in cpam_data: + logger.info( + "OGC %d trouvé via fallback JSON pour dossier '%s'", + ctrl_ogc, source_name, + ) + return cpam_data[ctrl_ogc] + except Exception: + pass + + # Log des OGC non matchés + if cpam_data: + available_ogcs = sorted(cpam_data.keys()) + logger.warning( + "OGC non trouvé pour dossier '%s'. OGC disponibles : %s", + source_name, available_ogcs, + ) + return [] diff --git a/src/control/cpam_rag.py b/src/control/cpam_rag.py index 2c6deb2..c60fe2d 100644 --- a/src/control/cpam_rag.py +++ b/src/control/cpam_rag.py @@ -26,15 +26,15 @@ def _search_rag_for_control(controle: ControleCPAM, dossier: DossierMedical) -> """ try: from ..medical.rag_search import search_similar_cpam - except Exception: - logger.warning("Index RAG non disponible pour la contre-argumentation") + except ImportError: + logger.error("CPAM RAG : module rag_search non disponible (faiss-cpu manquant ?)") return [] try: return _search_rag_queries(controle, dossier, search_similar_cpam) except Exception: - logger.warning("Erreur RAG pour la contre-argumentation — génération sans sources", - exc_info=True) + logger.error("CPAM RAG : erreur recherche — contre-argumentation sans sources", + exc_info=True) return [] diff --git a/src/control/cpam_response.py b/src/control/cpam_response.py index 9c67a41..7a6523e 100644 --- a/src/control/cpam_response.py +++ b/src/control/cpam_response.py @@ -8,9 +8,13 @@ Orchestrateur principal — délègue aux sous-modules : from __future__ import annotations +import json import logging +import os +from datetime import datetime +from pathlib import Path -from ..config import ControleCPAM, DossierMedical, RAGSource, rule_enabled +from ..config import ControleCPAM, DossierMedical, RAGSource, STRUCTURED_DIR, rule_enabled from ..medical.ollama_client import call_anthropic, call_ollama from ..prompts import CPAM_EXTRACTION @@ -50,6 +54,70 @@ from .cpam_validation import _CIM10_CODE_RE, _validate_adversarial as _validate_ logger = logging.getLogger(__name__) +def _save_version( + dossier: DossierMedical, + controle: ControleCPAM, +) -> None: + """Sauvegarde la version actuelle de l'argumentaire avant régénération. + + Stocke dans output/structured/{dossier}/_cpam_versions/{ogc}_{timestamp}.json + """ + if not controle.contre_argumentation and not controle.response_data: + return # rien à versionner + + # Trouver le dossier structuré (depuis source_files ou source_file) + dossier_dir = None + if not STRUCTURED_DIR.is_dir(): + logger.debug("Versioning : STRUCTURED_DIR inexistant, skip") + return + + structured_dirs = [d for d in STRUCTURED_DIR.iterdir() if d.is_dir()] + + # Tentative 1 : matcher un source_file contre les noms de sous-dossiers + candidates = list(dossier.source_files or []) + if dossier.source_file and dossier.source_file not in candidates: + candidates.append(dossier.source_file) + + for src in candidates: + src_stem = Path(src).stem.replace(" ", "_") + for d in structured_dirs: + if src_stem in d.name: + dossier_dir = d + break + if dossier_dir: + break + + if not dossier_dir: + logger.debug("Versioning : pas de dossier structuré trouvé, skip") + return + + versions_dir = dossier_dir / "_cpam_versions" + versions_dir.mkdir(exist_ok=True) + + # Compter les versions existantes pour cet OGC + existing = sorted(versions_dir.glob(f"{controle.numero_ogc}_*.json")) + version_num = len(existing) + 1 + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{controle.numero_ogc}_{timestamp}_v{version_num}.json" + + version_data = { + "numero_ogc": controle.numero_ogc, + "version": version_num, + "timestamp": timestamp, + "contre_argumentation": controle.contre_argumentation, + "response_data": controle.response_data, + "quality_tier": controle.quality_tier, + "validation_dim": controle.validation_dim, + } + + (versions_dir / filename).write_text( + json.dumps(version_data, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + logger.info(" Version %d sauvegardée : %s", version_num, filename) + + def _extraction_pass( dossier: DossierMedical, controle: ControleCPAM, @@ -121,6 +189,9 @@ def generate_cpam_response( logger.info("CPAM : génération contre-argumentation pour OGC %d — %s", controle.numero_ogc, controle.titre) + # 0. Versioning — sauvegarder la version précédente avant d'écraser + _save_version(dossier, controle) + # 1. Passe 1 — Extraction structurée (compréhension avant argumentation) extraction = _extraction_pass(dossier, controle) degraded_pass1 = extraction is None @@ -137,12 +208,12 @@ def generate_cpam_response( prompt, tag_map = _build_cpam_prompt(dossier, controle, sources, extraction) # 4. Appel LLM — Ollama (rôle cpam) > Haiku fallback - result = call_ollama(prompt, temperature=0.1, max_tokens=16000, role="cpam") + result = call_ollama(prompt, temperature=0.1, max_tokens=8000, role="cpam") if result is not None: logger.info(" Contre-argumentation via Ollama") else: logger.info(" Ollama indisponible → fallback Anthropic Haiku") - result = call_anthropic(prompt, temperature=0.1, max_tokens=16000) + result = call_anthropic(prompt, temperature=0.1, max_tokens=8000) if result is not None: logger.info(" Contre-argumentation via Anthropic Haiku") @@ -213,8 +284,8 @@ def generate_cpam_response( if adversarial_warnings: adversarial_warnings.append(f"Score de confiance : {score}/10") - # 8b. Boucle de correction (max 2 retries) - max_corrections = 2 + # 8b. Boucle de correction (configurable via T2A_CPAM_MAX_CORRECTIONS, défaut 2) + max_corrections = int(os.environ.get("T2A_CPAM_MAX_CORRECTIONS", "2")) for attempt in range(max_corrections): if not (validation and not validation.get("coherent", True) diff --git a/src/extraction/__init__.py b/src/extraction/__init__.py index e69de29..9bbcdf8 100644 --- a/src/extraction/__init__.py +++ b/src/extraction/__init__.py @@ -0,0 +1,2 @@ +from .document_router import SUPPORTED_EXTENSIONS, extract_document_with_pages +from .pdf_extractor import ExtractionMethod, ExtractionStats diff --git a/src/extraction/document_router.py b/src/extraction/document_router.py new file mode 100644 index 0000000..83c8d5b --- /dev/null +++ b/src/extraction/document_router.py @@ -0,0 +1,62 @@ +"""Router d'extraction multi-format. + +Point d'entrée unique qui dispatch vers le bon extracteur selon l'extension du fichier. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from .page_tracker import PageTracker +from .pdf_extractor import ExtractionStats + +logger = logging.getLogger(__name__) + +SUPPORTED_EXTENSIONS = {".pdf", ".jpg", ".jpeg", ".png", ".tiff", ".tif", ".docx"} + +_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tiff", ".tif"} + + +def extract_document_with_pages( + file_path: str | Path, +) -> tuple[str, PageTracker, ExtractionStats]: + """Extrait le texte de n'importe quel format supporté. + + Dispatch automatique selon l'extension du fichier : + - .pdf → extraction native (pdfplumber/PyMuPDF) avec fallback OCR optionnel + - .jpg/.jpeg/.png/.tiff/.tif → OCR via docTR + - .docx → extraction via python-docx + + Args: + file_path: Chemin vers le document. + + Returns: + (texte_complet, page_tracker, extraction_stats) + + Raises: + ValueError: Si l'extension n'est pas supportée. + FileNotFoundError: Si le fichier n'existe pas. + """ + file_path = Path(file_path) + ext = file_path.suffix.lower() + + if ext == ".pdf": + from .pdf_extractor import extract_text_with_pages + + return extract_text_with_pages(file_path) + + if ext in _IMAGE_EXTENSIONS: + from .image_extractor import extract_text_from_image + + return extract_text_from_image(file_path) + + if ext == ".docx": + from .docx_extractor import extract_text_from_docx + + return extract_text_from_docx(file_path) + + raise ValueError( + f"Format non supporté : {ext}. " + f"Formats acceptés : {', '.join(sorted(SUPPORTED_EXTENSIONS))}" + ) diff --git a/src/extraction/docx_extractor.py b/src/extraction/docx_extractor.py new file mode 100644 index 0000000..4cab17c --- /dev/null +++ b/src/extraction/docx_extractor.py @@ -0,0 +1,106 @@ +"""Extraction de texte depuis des fichiers DOCX via python-docx.""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from .page_tracker import PageTracker +from .pdf_extractor import ExtractionMethod, ExtractionStats + +logger = logging.getLogger(__name__) + + +def extract_text_from_docx( + docx_path: str | Path, +) -> tuple[str, PageTracker, ExtractionStats]: + """Extrait le texte d'un fichier DOCX. + + Détecte les sauts de page via les éléments dans les runs. + Chaque section entre sauts de page est considérée comme une "page". + + Args: + docx_path: Chemin vers le fichier DOCX. + + Returns: + (texte_complet, page_tracker, extraction_stats) + """ + from docx import Document + from docx.oxml.ns import qn + + docx_path = Path(docx_path) + if not docx_path.exists(): + raise FileNotFoundError(f"DOCX non trouvé : {docx_path}") + + logger.info("Extraction de %s", docx_path.name) + + doc = Document(str(docx_path)) + + # Collecter le texte par "page" (séparé par les sauts de page) + pages_text: list[str] = [] + current_page_lines: list[str] = [] + + for paragraph in doc.paragraphs: + # Vérifier les sauts de page dans les runs + has_page_break = False + for run in paragraph.runs: + for br in run._element.findall(qn("w:br")): + if br.get(qn("w:type")) == "page": + has_page_break = True + break + if has_page_break: + break + + if has_page_break and current_page_lines: + pages_text.append("\n".join(current_page_lines)) + current_page_lines = [] + + text = paragraph.text.strip() + if text: + current_page_lines.append(text) + + # Dernière page + if current_page_lines: + pages_text.append("\n".join(current_page_lines)) + + # Si aucun saut de page détecté, tout est sur une seule "page" + if not pages_text: + pages_text = [""] + + # Construire le texte complet avec séparateurs + separator = "\n\n" + page_offsets: list[tuple[int, int]] = [] + offset = 0 + for page_text in pages_text: + start = offset + end = offset + len(page_text) + page_offsets.append((start, end)) + offset = end + len(separator) + + full_text = separator.join(pages_text) + + # Stats + total_chars = sum(len(p.strip()) for p in pages_text) + chars_per_page = [len(p.strip()) for p in pages_text] + empty_pages = [i + 1 for i, n in enumerate(chars_per_page) if n == 0] + + stats = ExtractionStats( + total_pages=len(pages_text), + empty_pages=empty_pages, + chars_per_page=chars_per_page, + total_chars=total_chars, + methods=[ExtractionMethod.DOCX] * len(pages_text), + native_pages=len(pages_text), + ocr_pages=0, + backend="python-docx", + source_format="docx", + ) + + tracker = PageTracker(page_offsets) + + logger.info( + " DOCX : %d page(s), %d caractères", + len(pages_text), + total_chars, + ) + return full_text, tracker, stats diff --git a/src/extraction/image_extractor.py b/src/extraction/image_extractor.py new file mode 100644 index 0000000..3a2f42a --- /dev/null +++ b/src/extraction/image_extractor.py @@ -0,0 +1,56 @@ +"""Extraction de texte depuis des images (JPEG, PNG, TIFF) via docTR OCR.""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import numpy as np +from PIL import Image + +from .ocr_engine import ocr_image +from .page_tracker import PageTracker +from .pdf_extractor import ExtractionMethod, ExtractionStats + +logger = logging.getLogger(__name__) + + +def extract_text_from_image( + image_path: str | Path, +) -> tuple[str, PageTracker, ExtractionStats]: + """Extrait le texte d'une image via docTR OCR. + + Args: + image_path: Chemin vers l'image (JPEG, PNG, TIFF). + + Returns: + (texte_complet, page_tracker, extraction_stats) + """ + image_path = Path(image_path) + if not image_path.exists(): + raise FileNotFoundError(f"Image non trouvée : {image_path}") + + logger.info("Extraction OCR de %s", image_path.name) + + img = Image.open(image_path).convert("RGB") + img_array = np.array(img) + text = ocr_image(img_array) + + n_chars = len(text.strip()) + stats = ExtractionStats( + total_pages=1, + empty_pages=[1] if n_chars == 0 else [], + chars_per_page=[n_chars], + total_chars=n_chars, + methods=[ExtractionMethod.IMAGE], + native_pages=0, + ocr_pages=1, + backend="doctr", + source_format="image", + ) + + page_offsets = [(0, len(text))] + tracker = PageTracker(page_offsets) + + logger.info(" OCR image : %d caractères extraits", n_chars) + return text, tracker, stats diff --git a/src/extraction/ocr_engine.py b/src/extraction/ocr_engine.py new file mode 100644 index 0000000..af96b45 --- /dev/null +++ b/src/extraction/ocr_engine.py @@ -0,0 +1,54 @@ +"""Moteur OCR partagé basé sur docTR (lazy loading).""" + +from __future__ import annotations + +import logging + +import numpy as np + +logger = logging.getLogger(__name__) + +_doctr_predictor = None + + +def get_ocr_model(): + """Charge le modèle docTR une seule fois (lazy). + + Le chargement est coûteux (~1-2s + mémoire), d'où le singleton. + """ + global _doctr_predictor + if _doctr_predictor is None: + logger.info("Chargement du modèle docTR...") + from doctr.models import ocr_predictor + + _doctr_predictor = ocr_predictor( + det_arch="db_resnet50", + reco_arch="crnn_vgg16_bn", + pretrained=True, + assume_straight_pages=True, + ) + logger.info("Modèle docTR chargé.") + return _doctr_predictor + + +def ocr_image(image: np.ndarray) -> str: + """OCR une image numpy (RGB, HxWx3) et retourne le texte extrait. + + Args: + image: Array numpy RGB (H, W, 3). + + Returns: + Texte extrait, lignes séparées par '\\n'. + """ + predictor = get_ocr_model() + result = predictor([image]) + + lines: list[str] = [] + for page in result.pages: + for block in page.blocks: + for line in block.lines: + words = [w.value for w in line.words if w.confidence >= 0.3] + if words: + lines.append(" ".join(words)) + + return "\n".join(lines) diff --git a/src/extraction/pdf_extractor.py b/src/extraction/pdf_extractor.py index 18a407d..a18130d 100644 --- a/src/extraction/pdf_extractor.py +++ b/src/extraction/pdf_extractor.py @@ -1,66 +1,281 @@ -"""Extraction de texte et tableaux depuis les PDF via pdfplumber.""" +"""Extraction de texte et tableaux depuis les PDF via pdfplumber / PyMuPDF.""" from __future__ import annotations +import io +import logging +import os +from dataclasses import dataclass, field +from enum import Enum from pathlib import Path from typing import Optional -import pdfplumber - from .page_tracker import PageTracker from .text_cleaner import clean_extracted_text +logger = logging.getLogger(__name__) -def extract_text(pdf_path: str | Path) -> str: +# Seuil en caractères : en-dessous, une page est considérée "pauvre" +_MIN_CHARS_USEFUL = 30 + +# --- Configuration multi-backend --- +# Backend PDF : "pdfplumber" (défaut) ou "pymupdf" +PDF_BACKEND = os.environ.get("T2A_PDF_BACKEND", "pdfplumber") +# OCR fallback pour pages vides dans les PDF (désactivé par défaut) +OCR_FALLBACK_ENABLED = os.environ.get("T2A_OCR_FALLBACK", "0") == "1" +# Seuil min de caractères pour déclencher le fallback OCR +OCR_FALLBACK_MIN_CHARS = int(os.environ.get("T2A_OCR_MIN_CHARS", "30")) + + +class ExtractionMethod(str, Enum): + """Méthode d'extraction utilisée pour une page.""" + + NATIVE_PDFPLUMBER = "native_pdfplumber" + NATIVE_PYMUPDF = "native_pymupdf" + OCR_DOCTR = "ocr_doctr" + DOCX = "docx" + IMAGE = "image_ocr" + + +@dataclass +class ExtractionStats: + """Statistiques de qualité d'extraction.""" + + total_pages: int = 0 + empty_pages: list[int] = field(default_factory=list) # 1-indexed + low_content_pages: list[int] = field(default_factory=list) # < _MIN_CHARS_USEFUL + chars_per_page: list[int] = field(default_factory=list) + total_chars: int = 0 + methods: list[ExtractionMethod] = field(default_factory=list) # par page + native_pages: int = 0 + ocr_pages: int = 0 + backend: str = "pdfplumber" + source_format: str = "pdf" + + @property + def usable_pages(self) -> int: + return self.total_pages - len(self.empty_pages) + + @property + def coverage_ratio(self) -> float: + """Ratio de pages avec contenu exploitable (0.0 → 1.0).""" + if self.total_pages == 0: + return 1.0 + return self.usable_pages / self.total_pages + + def has_quality_issues(self) -> bool: + return len(self.empty_pages) > 0 + + def to_alert(self) -> str | None: + """Génère une alerte lisible si des pages sont vides.""" + if not self.empty_pages: + return None + pages_str = ", ".join(str(p) for p in self.empty_pages) + pct = round((1 - self.coverage_ratio) * 100) + return ( + f"EXTRACTION : {len(self.empty_pages)}/{self.total_pages} page(s) " + f"sans texte extractible (p. {pages_str}) — {pct}% du document ignoré, " + f"possibles pages scannées ou images" + ) + + def to_flags(self) -> dict: + """Retourne un dict pour quality_flags.""" + if not self.empty_pages: + return {} + return { + "extraction_empty_pages": self.empty_pages, + "extraction_total_pages": self.total_pages, + "extraction_coverage": round(self.coverage_ratio, 2), + } + + +def _compute_extraction_stats( + pages_text: list[str], + methods: list[ExtractionMethod] | None = None, + backend: str = "pdfplumber", +) -> ExtractionStats: + """Analyse la qualité d'extraction page par page.""" + stats = ExtractionStats( + total_pages=len(pages_text), + backend=backend, + source_format="pdf", + ) + if methods: + stats.methods = methods + for i, text in enumerate(pages_text): + n = len(text.strip()) + stats.chars_per_page.append(n) + stats.total_chars += n + if n == 0: + stats.empty_pages.append(i + 1) # 1-indexed + elif n < _MIN_CHARS_USEFUL: + stats.low_content_pages.append(i + 1) + + # Compteurs native/ocr + for m in stats.methods: + if m == ExtractionMethod.OCR_DOCTR: + stats.ocr_pages += 1 + else: + stats.native_pages += 1 + + return stats + + +def _open_pdf(pdf_path: str | Path, backend: str): + """Ouvre un PDF avec le backend choisi.""" + if backend == "pymupdf": + import fitz + return fitz.open(str(pdf_path)) + import pdfplumber + return pdfplumber.open(pdf_path) + + +def _extract_page_native(page, backend: str) -> str: + """Extrait le texte natif d'une page selon le backend.""" + if backend == "pymupdf": + return page.get_text() or "" + return page.extract_text() or "" + + +def _page_to_image_array(page, backend: str): + """Convertit une page PDF en array numpy RGB pour OCR.""" + import numpy as np + from PIL import Image + + if backend == "pymupdf": + zoom = 300 / 72 # 300 DPI + import fitz + mat = fitz.Matrix(zoom, zoom) + pix = page.get_pixmap(matrix=mat) + img = Image.open(io.BytesIO(pix.tobytes("png"))).convert("RGB") + else: + # pdfplumber → PIL Image + img_obj = page.to_image(resolution=300) + img = img_obj.original.convert("RGB") + return np.array(img) + + +def _get_pages(pdf, backend: str): + """Retourne la liste des pages selon le backend.""" + if backend == "pymupdf": + return [pdf[i] for i in range(len(pdf))] + return pdf.pages + + +def extract_text(pdf_path: str | Path, backend: str | None = None) -> str: """Extrait le texte de toutes les pages d'un PDF.""" + backend = backend or PDF_BACKEND pages_text: list[str] = [] - with pdfplumber.open(pdf_path) as pdf: - for page in pdf.pages: - text = page.extract_text() or "" + with _open_pdf(pdf_path, backend) as pdf: + for page in _get_pages(pdf, backend): + text = _extract_page_native(page, backend) + text = clean_extracted_text(text) pages_text.append(text) return "\n\n".join(pages_text) -def extract_text_with_pages(pdf_path: str | Path) -> tuple[str, PageTracker]: - """Extrait le texte avec un tracker de pages pour la traçabilité. +def extract_text_with_pages( + pdf_path: str | Path, + backend: str | None = None, +) -> tuple[str, PageTracker, ExtractionStats]: + """Extrait le texte avec un tracker de pages et des statistiques de qualité. + + Supporte pdfplumber et PyMuPDF, avec fallback OCR optionnel (T2A_OCR_FALLBACK=1). Returns: - (texte_complet, page_tracker) où page_tracker permet de retrouver - la page source de chaque position de caractère. + (texte_complet, page_tracker, extraction_stats) """ - pages_text: list[str] = [] - with pdfplumber.open(pdf_path) as pdf: - for page in pdf.pages: - text = page.extract_text() or "" - text = clean_extracted_text(text) - pages_text.append(text) + backend = backend or PDF_BACKEND + native_method = ( + ExtractionMethod.NATIVE_PYMUPDF + if backend == "pymupdf" + else ExtractionMethod.NATIVE_PDFPLUMBER + ) - # Construire le texte complet avec "\n\n" comme séparateur (identique à extract_text) + pages_text_final: list[str] = [] + methods_final: list[ExtractionMethod] = [] + + with _open_pdf(pdf_path, backend) as pdf: + pages = _get_pages(pdf, backend) + for i, page in enumerate(pages): + text = _extract_page_native(page, backend) + text = clean_extracted_text(text) + method = native_method + + # OCR fallback si activé et page pauvre + if ( + OCR_FALLBACK_ENABLED + and len(text.strip()) < OCR_FALLBACK_MIN_CHARS + ): + try: + from .ocr_engine import ocr_image + + img_array = _page_to_image_array(page, backend) + ocr_text = ocr_image(img_array) + if len(ocr_text.strip()) > len(text.strip()): + logger.info( + " Page %d : fallback OCR (%d → %d chars)", + i + 1, + len(text.strip()), + len(ocr_text.strip()), + ) + text = ocr_text + method = ExtractionMethod.OCR_DOCTR + except Exception: + logger.warning( + " Page %d : échec OCR fallback", i + 1, exc_info=True + ) + + pages_text_final.append(text) + methods_final.append(method) + + stats = _compute_extraction_stats(pages_text_final, methods_final, backend) + + if stats.empty_pages: + logger.warning( + " %s : %d/%d pages vides (p. %s) — possibles scans/images", + Path(pdf_path).name, + len(stats.empty_pages), + stats.total_pages, + ", ".join(str(p) for p in stats.empty_pages), + ) + if stats.ocr_pages: + logger.info( + " %s : %d page(s) via OCR fallback", + Path(pdf_path).name, + stats.ocr_pages, + ) + + # Construire le texte complet avec "\n\n" comme séparateur separator = "\n\n" page_offsets: list[tuple[int, int]] = [] offset = 0 - for i, page_text in enumerate(pages_text): + for page_text in pages_text_final: start = offset end = offset + len(page_text) page_offsets.append((start, end)) offset = end + len(separator) - full_text = separator.join(pages_text) - return full_text, PageTracker(page_offsets) + full_text = separator.join(pages_text_final) + return full_text, PageTracker(page_offsets), stats -def extract_pages(pdf_path: str | Path) -> list[str]: +def extract_pages(pdf_path: str | Path, backend: str | None = None) -> list[str]: """Extrait le texte page par page.""" + backend = backend or PDF_BACKEND pages: list[str] = [] - with pdfplumber.open(pdf_path) as pdf: - for page in pdf.pages: - pages.append(page.extract_text() or "") + with _open_pdf(pdf_path, backend) as pdf: + for page in _get_pages(pdf, backend): + text = _extract_page_native(page, backend) + text = clean_extracted_text(text) + pages.append(text) return pages def extract_tables(pdf_path: str | Path) -> list[list[list[str | None]]]: - """Extrait tous les tableaux détectés dans le PDF.""" + """Extrait tous les tableaux détectés dans le PDF (pdfplumber uniquement).""" all_tables: list[list[list[str | None]]] = [] + import pdfplumber with pdfplumber.open(pdf_path) as pdf: for page in pdf.pages: tables = page.extract_tables() or [] diff --git a/src/extraction/trackare_parser.py b/src/extraction/trackare_parser.py index c9b0786..b8a3588 100644 --- a/src/extraction/trackare_parser.py +++ b/src/extraction/trackare_parser.py @@ -4,7 +4,7 @@ from __future__ import annotations import re -from src.medical.das_filter import clean_diagnostic_text, is_valid_diagnostic_text +from ..medical.das_filter import clean_diagnostic_text, is_valid_diagnostic_text def parse_trackare(text: str) -> dict: diff --git a/src/main.py b/src/main.py index bf7b830..6da3d75 100644 --- a/src/main.py +++ b/src/main.py @@ -26,12 +26,14 @@ from .config import ( from .extraction.document_classifier import classify from .extraction.crh_parser import parse_crh from .extraction.document_splitter import split_documents +from .extraction.document_router import SUPPORTED_EXTENSIONS, extract_document_with_pages from .extraction.pdf_extractor import extract_text, extract_text_with_pages from .extraction.trackare_parser import parse_trackare from .medical.cim10_extractor import extract_medical_info from .medical.ghm import estimate_ghm from .quality.veto_engine import apply_vetos from .quality.decision_engine import apply_decisions, decision_summaries +from .quality.completude import build_completude_checklist from .quality.rules_router import build_rules_runtime_context logging.basicConfig( @@ -141,17 +143,19 @@ _use_edsnlp = True _use_rag = True -def process_pdf(pdf_path: Path) -> list[tuple[str, DossierMedical, AnonymizationReport]]: - """Traite un PDF : extraction → splitting → parsing → anonymisation → extraction CIM-10. +def process_document(file_path: Path) -> list[tuple[str, DossierMedical, AnonymizationReport]]: + """Traite un document : extraction → splitting → parsing → anonymisation → extraction CIM-10. + + Supporte PDF, images (JPEG/PNG/TIFF) et DOCX via le router d'extraction. Retourne une liste de (texte_anonymisé, dossier, rapport) — un par dossier détecté. """ t0 = time.time() - logger.info("Traitement de %s", pdf_path.name) + logger.info("Traitement de %s", file_path.name) - # 1. Extraction texte avec pages - raw_text, page_tracker = extract_text_with_pages(pdf_path) - logger.info(" Texte extrait : %d caractères", len(raw_text)) + # 1. Extraction texte avec pages (multi-format) + raw_text, page_tracker, extraction_stats = extract_document_with_pages(file_path) + logger.info(" Texte extrait : %d caractères (%d pages, format=%s)", len(raw_text), extraction_stats.total_pages, extraction_stats.source_format) # 2. Classification doc_type = classify(raw_text) @@ -160,7 +164,7 @@ def process_pdf(pdf_path: Path) -> list[tuple[str, DossierMedical, Anonymization # 3. Splitting multi-dossiers chunks = split_documents(raw_text, doc_type) if len(chunks) > 1: - logger.info(" Découpage : %d dossiers détectés dans %s", len(chunks), pdf_path.name) + logger.info(" Découpage : %d dossiers détectés dans %s", len(chunks), file_path.name) results: list[tuple[str, DossierMedical, AnonymizationReport]] = [] for i, chunk_text in enumerate(chunks): @@ -177,7 +181,7 @@ def process_pdf(pdf_path: Path) -> list[tuple[str, DossierMedical, Anonymization anonymizer = Anonymizer(parsed_data=parsed) anonymized_text = anonymizer.anonymize(chunk_text) report = anonymizer.report - report.source_file = pdf_path.name + report.source_file = file_path.name logger.info( " Anonymisation%s : %d remplacements (regex=%d, ner=%d, sweep=%d)", part_label, @@ -197,10 +201,18 @@ def process_pdf(pdf_path: Path) -> list[tuple[str, DossierMedical, Anonymization parsed, anonymized_text, edsnlp_result, use_rag=_use_rag, page_tracker=page_tracker, raw_text=raw_text, ) - dossier.source_file = pdf_path.name + dossier.source_file = file_path.name dossier.document_type = doc_type logger.info(" DP%s : %s", part_label, dossier.diagnostic_principal) + # Injection des stats d'extraction dans quality_flags + extraction_flags = extraction_stats.to_flags() + if extraction_flags: + dossier.quality_flags.update(extraction_flags) + extraction_alert = extraction_stats.to_alert() + if extraction_alert: + dossier.alertes_codage.append(extraction_alert) + # 8. Vetos (contestabilité) + décisions (post-traitement) # Routage des règles (packs) : par défaut, on garde le socle vetos/decisions, # et on active des packs additionnels selon les signaux du dossier (codes/labs/extraits). @@ -216,14 +228,17 @@ def process_pdf(pdf_path: Path) -> list[tuple[str, DossierMedical, Anonymization if rules_ctx.get("triggers_fired"): logger.info(" Règles%s : triggers=%s", part_label, ",".join(rules_ctx["triggers_fired"])) except Exception: - logger.warning(" Routage règles : erreur", exc_info=True) + logger.error(" Routage règles : erreur", exc_info=True) + dossier.quality_flags["rules_routing"] = "error" veto = None try: veto = apply_vetos(dossier) dossier.veto_report = veto except Exception: - logger.warning(" Vetos : erreur lors du contrôle", exc_info=True) + logger.error(" Vetos : erreur lors du contrôle", exc_info=True) + dossier.quality_flags["veto_engine"] = "error" + dossier.alertes_codage.append("QUALITE DEGRADEE : moteur de vetos en erreur") try: apply_decisions(dossier) @@ -231,11 +246,18 @@ def process_pdf(pdf_path: Path) -> list[tuple[str, DossierMedical, Anonymization if veto is not None: _inject_veto_alerts(dossier, veto, scope="PDF") except Exception: - logger.warning(" Décisions : erreur lors du post-traitement", exc_info=True) + logger.error(" Décisions : erreur lors du post-traitement", exc_info=True) + dossier.quality_flags["decision_engine"] = "error" finally: if rules_token is not None: reset_rules_runtime(rules_token) + try: + dossier.completude = build_completude_checklist(dossier) + except Exception: + logger.error(" Complétude : erreur lors du contrôle", exc_info=True) + dossier.quality_flags["completude"] = "error" + # 9. Estimation GHM (sur codes finaux) + métriques (actifs vs écartés) try: metrics = _compute_metrics(dossier) @@ -260,14 +282,17 @@ def process_pdf(pdf_path: Path) -> list[tuple[str, DossierMedical, Anonymization ghm.ghm_approx or "?", ) except Exception: - logger.warning(" Erreur estimation GHM/metrics", exc_info=True) + logger.error(" Erreur estimation GHM/metrics", exc_info=True) + dossier.quality_flags["ghm_estimation"] = "error" + dossier.alertes_codage.append("QUALITE DEGRADEE : estimation GHM en erreur") # 10. Finalizer DP (arbitrage Trackare vs CRH, traçabilité) try: from .medical.dp_finalizer import finalize_dp finalize_dp(dossier) except Exception: - logger.warning(" Finalizer DP : erreur", exc_info=True) + logger.error(" Finalizer DP : erreur", exc_info=True) + dossier.quality_flags["dp_finalizer"] = "error" dossier.processing_time_s = round(time.time() - t0, 2) results.append((anonymized_text, dossier, report)) @@ -276,6 +301,10 @@ def process_pdf(pdf_path: Path) -> list[tuple[str, DossierMedical, Anonymization return results +# Alias backward-compatible +process_pdf = process_document + + def _run_edsnlp(text: str): """Exécute l'analyse edsnlp avec fallback gracieux.""" try: @@ -351,13 +380,13 @@ def main(input_path: str | None = None) -> None: global _use_edsnlp, _use_rag parser = argparse.ArgumentParser( - description="Anonymisation de documents médicaux PDF et extraction CIM-10", + description="Anonymisation de documents médicaux et extraction CIM-10 (PDF, images, DOCX)", ) parser.add_argument( "input", nargs="*", default=[input_path or "input/"], - help="Chemin(s) vers des PDFs, dossiers patients, ou le dossier racine (défaut: input/)", + help="Chemin(s) vers des documents, dossiers patients, ou le dossier racine (défaut: input/)", ) parser.add_argument( "--no-ner", @@ -459,6 +488,24 @@ def main(input_path: str | None = None) -> None: if args.no_rag: _use_rag = False + # Vérification FAISS obligatoire si RAG actif + if _use_rag: + from .medical.rag_index import check_faiss_ready + faiss_status = check_faiss_ready() + if faiss_status["ok"]: + total_vecs = faiss_status["ref"] + faiss_status["proc"] + faiss_status["bio"] + faiss_status["legacy"] + logger.info("FAISS OK : %d vecteurs (ref=%d, proc=%d, bio=%d)", + total_vecs, faiss_status["ref"], faiss_status["proc"], faiss_status["bio"]) + else: + for err in faiss_status["errors"]: + logger.error("FAISS : %s", err) + logger.error("FAISS non fonctionnel — le codage CIM-10 sera dégradé. " + "Lancez : python3 -m src.main --rebuild-index") + print("\n*** ATTENTION : Index FAISS absent ou invalide ***") + print("*** Le RAG est désactivé — qualité de codage dégradée ***") + print("*** Corrigez avec : python3 -m src.main --rebuild-index ***\n") + _use_rag = False + export_rum_flag = args.export_rum # Chargement contrôle CPAM (auto-détection ou flag explicite) @@ -480,7 +527,14 @@ def main(input_path: str | None = None) -> None: input_paths = args.input - # Collecte des groupes (pdfs, subdir) à traiter + def _glob_supported(directory: Path) -> list[Path]: + """Collecte tous les fichiers supportés dans un dossier.""" + files: list[Path] = [] + for ext in sorted(SUPPORTED_EXTENSIONS): + files.extend(directory.glob(f"*{ext}")) + return sorted(set(files)) + + # Collecte des groupes (documents, subdir) à traiter groups: list[tuple[list[Path], str | None]] = [] for p in input_paths: @@ -490,47 +544,47 @@ def main(input_path: str | None = None) -> None: subdir = input_p.parent.name if input_p.parent.name != "input" else None groups.append(([input_p], subdir)) elif input_p.is_dir(): - # Vérifier s'il y a des PDFs directement dans ce dossier - root_pdfs = sorted(input_p.glob("*.pdf")) - # Vérifier s'il y a des sous-dossiers avec PDFs - sub_dirs = [c for c in sorted(input_p.iterdir()) if c.is_dir() and list(c.glob("*.pdf"))] + # Vérifier s'il y a des documents directement dans ce dossier + root_docs = _glob_supported(input_p) + # Vérifier s'il y a des sous-dossiers avec des documents + sub_dirs = [c for c in sorted(input_p.iterdir()) if c.is_dir() and _glob_supported(c)] if sub_dirs: # C'est un dossier racine (comme input/) → traiter chaque sous-dossier for child in sub_dirs: - sub_pdfs = sorted(child.glob("*.pdf")) - groups.append((sub_pdfs, child.name)) - elif root_pdfs: + sub_docs = _glob_supported(child) + groups.append((sub_docs, child.name)) + elif root_docs: # C'est un dossier patient directement → utiliser son nom comme subdir - groups.append((root_pdfs, input_p.name)) + groups.append((root_docs, input_p.name)) else: logger.error("Chemin introuvable : %s", input_p) sys.exit(1) - total = sum(len(pdfs) for pdfs, _ in groups) + total = sum(len(docs) for docs, _ in groups) if total == 0: - logger.warning("Aucun PDF trouvé dans %s", input_p) + logger.warning("Aucun document supporté trouvé dans %s", input_p) sys.exit(0) - logger.info("Traitement de %d PDF(s)...", total) + logger.info("Traitement de %d document(s)...", total) - def _process_group(pdfs: list[Path], subdir: str | None) -> None: - """Traite un groupe de PDFs (un dossier patient).""" + def _process_group(docs: list[Path], subdir: str | None) -> None: + """Traite un groupe de documents (un dossier patient).""" if subdir: - logger.info("--- Dossier %s (%d PDFs) ---", subdir, len(pdfs)) + logger.info("--- Dossier %s (%d documents) ---", subdir, len(docs)) group_dossiers: list[DossierMedical] = [] - for pdf_path in pdfs: + for doc_path in docs: try: - pdf_results = process_pdf(pdf_path) - stem = pdf_path.stem.replace(" ", "_") - multi = len(pdf_results) > 1 - for part_idx, (anonymized_text, dossier, report) in enumerate(pdf_results): + doc_results = process_document(doc_path) + stem = doc_path.stem.replace(" ", "_") + multi = len(doc_results) > 1 + for part_idx, (anonymized_text, dossier, report) in enumerate(doc_results): part_stem = f"{stem}_part{part_idx + 1}" if multi else stem write_outputs(part_stem, anonymized_text, dossier, report, subdir=subdir, export_rum_flag=export_rum_flag) group_dossiers.append(dossier) except Exception: - logger.exception("Erreur lors du traitement de %s", pdf_path.name) + logger.exception("Erreur lors du traitement de %s", doc_path.name) # Fusion multi-PDFs si plusieurs documents dans le même groupe merged = None @@ -611,6 +665,11 @@ def main(input_path: str | None = None) -> None: if rules_token is not None: reset_rules_runtime(rules_token) + try: + merged.completude = build_completude_checklist(merged) + except Exception: + logger.warning(" Complétude fusionné : erreur lors du contrôle", exc_info=True) + # Re-estimer le GHM (sur codes finaux) + métriques (actifs vs écartés) try: metrics = _compute_metrics(merged) @@ -660,8 +719,8 @@ def main(input_path: str | None = None) -> None: logger.info("Mode parallèle : %d workers", args.workers) with ThreadPoolExecutor(max_workers=args.workers) as executor: futures = { - executor.submit(_process_group, pdfs, subdir): subdir - for pdfs, subdir in groups + executor.submit(_process_group, docs, subdir): subdir + for docs, subdir in groups } for future in as_completed(futures): try: @@ -669,8 +728,8 @@ def main(input_path: str | None = None) -> None: except Exception: logger.exception("Erreur groupe %s", futures[future]) else: - for pdfs, subdir in groups: - _process_group(pdfs, subdir) + for docs, subdir in groups: + _process_group(docs, subdir) logger.info("Terminé.") diff --git a/src/medical/bio_extraction.py b/src/medical/bio_extraction.py index 91fd612..f7a7e6b 100644 --- a/src/medical/bio_extraction.py +++ b/src/medical/bio_extraction.py @@ -202,6 +202,9 @@ def _extract_biologie(text: str, dossier: DossierMedical) -> None: (r"(?:[Gg]lyc[ée]mie|[Gg]lucose)\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:mmol/L|g/L)?", "Glycémie"), (r"\bHbA1c\b\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:%)?", "HbA1c"), (r"\bTSH\b\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:mUI/L)?", "TSH"), + # Albumine / Préalbumine (critère de sévérité HAS 2021 dénutrition) + (r"(?:[Aa]lbumin[ée]?(?:mie)?|[Aa]lb(?:u)?[ée]?(?:mie)?)\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:g/[Ll])?", "Albumine"), + (r"(?:[Pp]r[ée]albumine|[Tt]ransthyr[ée]tine)\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:mg/[Ll]|g/[Ll])?", "Préalbumine"), ] diff --git a/src/medical/cim10_extractor.py b/src/medical/cim10_extractor.py index 6edd2dc..f960aef 100644 --- a/src/medical/cim10_extractor.py +++ b/src/medical/cim10_extractor.py @@ -38,6 +38,7 @@ from .bio_extraction import _extract_biologie from .diagnostic_extraction import ( _extract_diagnostics, _extract_actes, + _detect_nutrition_has2021, CIM10_MAP, CCAM_MAP, ) @@ -58,6 +59,7 @@ from .bio_normals import BIO_NORMALS, _is_abnormal # noqa: F401 from .validation_pipeline import _is_dp_family_redundant # noqa: F401 from .diagnostic_extraction import _lookup_cim10 # noqa: F401 from .diagnostic_extraction import _DAS_PATTERNS # noqa: F401 +from .diagnostic_extraction import _detect_nutrition_has2021 # noqa: F401 def extract_medical_info( @@ -86,6 +88,7 @@ def extract_medical_info( _extract_antecedents(anonymized_text, dossier) _extract_traitements(parsed_data, anonymized_text, dossier, edsnlp_result) _extract_biologie(anonymized_text, dossier) + _detect_nutrition_has2021(dossier) _extract_imagerie(anonymized_text, dossier) _extract_complications(anonymized_text, dossier, edsnlp_result) @@ -134,7 +137,9 @@ def extract_medical_info( f"NUKE-3 REVIEW: DP ambigu — {selection.reason}" ) except Exception: - logger.warning("NUKE-3: erreur sélection DP", exc_info=True) + logger.error("NUKE-3: erreur sélection DP", exc_info=True) + dossier.quality_flags["dp_selection_status"] = "error" + dossier.alertes_codage.append("QUALITE DEGRADEE : sélection DP (NUKE-3) en erreur") # Post-processing : validation des codes CCAM contre le dictionnaire _validate_ccam(dossier) @@ -240,7 +245,8 @@ def _extract_das_llm(text: str, dossier: DossierMedical) -> None: cache.save() except Exception: - logger.warning("Erreur lors de l'extraction DAS LLM", exc_info=True) + logger.error("Erreur lors de l'extraction DAS LLM", exc_info=True) + dossier.quality_flags["das_llm_status"] = "error" def _enrich_with_rag(dossier: DossierMedical) -> None: @@ -249,9 +255,13 @@ def _enrich_with_rag(dossier: DossierMedical) -> None: from .rag_search import enrich_dossier enrich_dossier(dossier) except ImportError: - logger.warning("Module RAG non disponible (faiss-cpu ou sentence-transformers manquant)") + logger.error("RAG INDISPONIBLE : faiss-cpu ou sentence-transformers manquant") + dossier.quality_flags["rag_status"] = "unavailable" + dossier.alertes_codage.append("QUALITE DEGRADEE : RAG indisponible — codage sans référentiels") except Exception: - logger.warning("Erreur lors de l'enrichissement RAG", exc_info=True) + logger.error("RAG EN ERREUR : enrichissement échoué", exc_info=True) + dossier.quality_flags["rag_status"] = "error" + dossier.alertes_codage.append("QUALITE DEGRADEE : erreur RAG — codage sans référentiels") def _extract_sejour(parsed: dict, dossier: DossierMedical) -> None: diff --git a/src/medical/diagnostic_extraction.py b/src/medical/diagnostic_extraction.py index c1e3612..4621329 100644 --- a/src/medical/diagnostic_extraction.py +++ b/src/medical/diagnostic_extraction.py @@ -104,7 +104,9 @@ _DAS_PATTERNS: list[tuple[str, str, str]] = [ (r"diabete\s+(?:sucre\s+)?(?:de\s+)?type\s+2|diabete\s+type\s*2", "Diabète de type 2", "E11.9"), (r"diabete\s+(?:sucre\s+)?(?:de\s+)?type\s+1|diabete\s+type\s*1", "Diabète de type 1", "E10.9"), (r"dyslipidemie|hypercholesterolemie", "Dyslipidémie", "E78.5"), - (r"denutrition|malnutrition", "Dénutrition", "E46"), + (r"denutrition\s+severe|malnutrition\s+severe|denutrition\s+grade\s+(?:3|iii|III)", "Dénutrition sévère", "E43"), + (r"denutrition\s+moderee?|malnutrition\s+moderee?|denutrition\s+grade\s+(?:2|ii|II)", "Dénutrition modérée", "E44.0"), + (r"denutrition|malnutrition|hypoalbuminemie\s+severe", "Dénutrition", "E46"), # Infectieux (r"pneumopathie|pneumonie", "Pneumopathie", "J18.9"), (r"infection\s+urinaire|pyelonephrite", "Infection urinaire", "N39.0"), @@ -271,6 +273,91 @@ def _find_diagnostics_associes( return das +def _detect_nutrition_has2021(dossier: DossierMedical) -> None: + """Détecte la dénutrition selon les critères HAS/FFN novembre 2021. + + Logique déterministe basée sur données structurées (IMC + âge + albumine). + - Critère phénotypique : IMC < seuil (âge-dépendant) + - Critère de sévérité : albumine < 30 g/L → sévère, 30-35 → modéré + - Code final : max(sévérité IMC, sévérité albumine) → E43 ou E44.0 + + Ref: HAS/FFN nov 2021 « Diagnostic de la dénutrition chez l'enfant, + l'adulte, et la personne de 70 ans et plus » + """ + # 1. Vérifier qu'aucun code E40-E46 n'est déjà codé + existing_codes: set[str] = set() + if dossier.diagnostic_principal and dossier.diagnostic_principal.cim10_suggestion: + existing_codes.add(dossier.diagnostic_principal.cim10_suggestion) + for d in dossier.diagnostics_associes: + if d.cim10_suggestion: + existing_codes.add(d.cim10_suggestion) + + for code in existing_codes: + if code.startswith(("E4",)) and code[:3] in ("E40", "E41", "E42", "E43", "E44", "E45", "E46"): + return # Déjà codé + + # 2. Vérifier qu'on a un IMC (critère phénotypique obligatoire) + imc = dossier.sejour.imc if dossier.sejour else None + if imc is None: + return + + age = dossier.sejour.age if dossier.sejour else None + + # 3. Seuils IMC HAS 2021 (âge-dépendants) + if age is not None and age >= 70: + # Personne âgée ≥ 70 ans + if imc >= 22: + return # Au-dessus du seuil + imc_severe = imc < 20 + imc_moderate = not imc_severe # 20 ≤ IMC < 22 + else: + # Adulte 18-69 ans (ou âge inconnu → seuils adulte par défaut) + if imc >= 18.5: + return # Au-dessus du seuil + imc_severe = imc <= 17 + imc_moderate = not imc_severe # 17 < IMC < 18.5 + + # 4. Critère de sévérité : albumine + albumine_val = None + for bio in dossier.biologie_cle: + if bio.test == "Albumine" and bio.valeur_num is not None: + if bio.quality != "discarded": + albumine_val = bio.valeur_num + break + + albumine_severe = albumine_val is not None and albumine_val < 30 + albumine_moderate = albumine_val is not None and 30 <= albumine_val < 35 + + # 5. Code final : max(sévérité IMC, sévérité albumine) + is_severe = imc_severe or albumine_severe + is_moderate = imc_moderate or albumine_moderate + + if is_severe: + code = "E43" + label = "Dénutrition sévère" + elif is_moderate: + code = "E44.0" + label = "Dénutrition modérée" + else: + return # Ne devrait pas arriver vu les checks précédents + + # 6. Construire l'alerte explicative + parts = [] + if age is not None and age >= 70: + parts.append(f"IMC {imc} (seuil ≥70 ans : <22 modéré, <20 sévère)") + else: + parts.append(f"IMC {imc} (seuil adulte : <18.5 modéré, ≤17 sévère)") + if albumine_val is not None: + parts.append(f"Albumine {albumine_val} g/L (<30 sévère, 30-35 modéré)") + alerte = f"HAS 2021 — {label} ({code}) : {' ; '.join(parts)}" + + dossier.diagnostics_associes.append( + Diagnostic(texte=label, cim10_suggestion=code, source="has2021") + ) + dossier.alertes_codage.append(alerte) + logger.info("HAS 2021 dénutrition : %s ajouté (%s)", code, alerte) + + def _extract_actes(text: str, dossier: DossierMedical) -> None: """Extrait les actes CCAM.""" text_lower = text.lower() diff --git a/src/medical/dp_finalizer.py b/src/medical/dp_finalizer.py index 13a46ab..e62add6 100644 --- a/src/medical/dp_finalizer.py +++ b/src/medical/dp_finalizer.py @@ -11,7 +11,7 @@ Principes : from __future__ import annotations -from src.config import DossierMedical, DPSelection +from ..config import DossierMedical, DPSelection # Whitelist Z-codes admis en DP CONFIRMED (même que dp_selector) _Z_CODE_DP_WHITELIST = frozenset({ diff --git a/src/medical/ghm.py b/src/medical/ghm.py index d845740..21d9db3 100644 --- a/src/medical/ghm.py +++ b/src/medical/ghm.py @@ -13,7 +13,7 @@ from __future__ import annotations import bisect from typing import Optional -from ..config import DossierMedical, GHMEstimation +from ..config import DossierMedical, FinancialImpact, GHMEstimation # --------------------------------------------------------------------------- @@ -229,3 +229,99 @@ def estimate_ghm(dossier: DossierMedical) -> GHMEstimation: estimation.ghm_approx = f"{estimation.cmd}{estimation.type_ghm}??{estimation.severite}" return estimation + + +# --------------------------------------------------------------------------- +# Tarifs moyens par CMD (source ATIH open data 2024, valeurs arrondies) +# Utilisé pour le tri relatif, pas pour la facturation. +# Format : cmd -> (tarif_base_euros, supplement_par_niveau_severite) +# --------------------------------------------------------------------------- + +_CMD_TARIFS: dict[str, tuple[int, int]] = { + "01": (5500, 1200), # Neurologie + "02": (2800, 600), # Ophtalmologie + "03": (2500, 550), # ORL + "04": (3800, 900), # Pneumologie + "05": (4800, 1100), # Cardiologie + "06": (3500, 800), # Digestif (tube) + "07": (3200, 900), # Hépatobiliaire + "08": (4200, 950), # Ostéo-articulaire + "09": (2400, 500), # Peau + "10": (3000, 700), # Endocrinologie + "11": (3300, 800), # Rein/urinaire + "12": (2800, 650), # Génital masculin + "13": (2600, 600), # Génital féminin + "14": (3100, 700), # Obstétrique + "15": (4500, 1000), # Néonat/périnat + "16": (3400, 800), # Hémato/tumeurs bénignes + "17": (5200, 1100), # Tumeurs malignes + "18": (3600, 850), # Infectieux + "19": (2800, 600), # Psychiatrie + "20": (2200, 500), # Alcool/toxiques + "21": (3500, 800), # Traumatismes + "22": (5800, 1300), # Brûlures + "23": (2000, 400), # Symptômes/Z + "24": (2500, 500), # Causes externes + "25": (4200, 950), # VIH + "26": (3000, 700), # Catégories spéciales +} + +_DEFAULT_TARIF = (3000, 800) + + +def estimate_financial_impact( + ghm_etab: GHMEstimation | None, + ghm_ucr: GHMEstimation | None = None, +) -> FinancialImpact: + """Estime l'impact financier entre le GHM établissement et le GHM UCR. + + Si ghm_ucr est None, on estime l'impact de perdre le codage actuel + vers une sévérité 1 (scénario conservateur). + """ + if not ghm_etab: + return FinancialImpact(raison="GHM établissement non estimé") + + cmd = ghm_etab.cmd or "" + base, supplement = _CMD_TARIFS.get(cmd, _DEFAULT_TARIF) + sev_etab = ghm_etab.severite or 1 + type_etab = ghm_etab.type_ghm or "M" + + if ghm_ucr: + sev_ucr = ghm_ucr.severite or 1 + type_ucr = ghm_ucr.type_ghm or "M" + else: + sev_ucr = 1 + type_ucr = type_etab + + delta_sev = sev_ucr - sev_etab # négatif = perte de sévérité + impact = abs(delta_sev) * supplement + + # Changement de type (C→M = perte importante) + changement_type = type_etab != type_ucr + if changement_type and type_etab == "C" and type_ucr == "M": + impact += base # perte du GHS chirurgical + raison = f"Changement C→M + delta sévérité {delta_sev}" + elif changement_type: + impact += supplement + raison = f"Changement type {type_etab}→{type_ucr} + delta sévérité {delta_sev}" + elif delta_sev == 0: + raison = "Pas de différence de sévérité estimée" + else: + raison = f"Delta sévérité {delta_sev} (CMD {cmd})" + + # Classification priorité + if impact >= 2000 or (changement_type and type_etab == "C"): + priorite = "critique" + elif impact >= 1000 or abs(delta_sev) >= 2: + priorite = "haute" + elif impact > 0: + priorite = "normale" + else: + priorite = "faible" + + return FinancialImpact( + delta_severite=delta_sev, + impact_estime_euros=impact, + priorite=priorite, + raison=raison, + ) diff --git a/src/medical/ollama_cache.py b/src/medical/ollama_cache.py index c4b5ba0..a6ce494 100644 --- a/src/medical/ollama_cache.py +++ b/src/medical/ollama_cache.py @@ -5,6 +5,7 @@ from __future__ import annotations import json import logging import threading +import time from pathlib import Path logger = logging.getLogger(__name__) @@ -20,9 +21,15 @@ class OllamaCache: Migration automatique depuis l'ancien format (model global) au chargement. """ - def __init__(self, cache_path: Path, model: str | None = None): + # TTL par défaut : 30 jours (en secondes) + DEFAULT_TTL = 30 * 24 * 3600 + + def __init__(self, cache_path: Path, model: str | None = None, max_entries: int = 5000, + ttl: int | None = None): self._path = cache_path self._default_model = model + self._max_entries = max_entries + self._ttl = ttl if ttl is not None else self.DEFAULT_TTL self._lock = threading.Lock() self._data: dict[str, dict] = {} self._dirty = False @@ -70,7 +77,7 @@ class OllamaCache: return f"{diag_type}::{texte.strip().lower()}" def get(self, texte: str, diag_type: str, model: str | None = None) -> dict | None: - """Récupère un résultat caché, ou None si absent ou modèle différent.""" + """Récupère un résultat caché, ou None si absent, modèle différent ou expiré.""" key = self._make_key(texte, diag_type) use_model = model or self._default_model with self._lock: @@ -79,15 +86,32 @@ class OllamaCache: return None if use_model and entry.get("model") != use_model: return None + # Vérifier TTL + ts = entry.get("ts") + if self._ttl and ts and (time.time() - ts) > self._ttl: + del self._data[key] + self._dirty = True + return None return entry.get("result") def put(self, texte: str, diag_type: str, result: dict, model: str | None = None) -> None: - """Stocke un résultat dans le cache avec le modèle utilisé.""" + """Stocke un résultat dans le cache avec le modèle utilisé et un timestamp.""" key = self._make_key(texte, diag_type) use_model = model or self._default_model with self._lock: - self._data[key] = {"model": use_model, "result": result} + self._data[key] = {"model": use_model, "result": result, "ts": time.time()} self._dirty = True + self._evict_if_needed() + + def _evict_if_needed(self) -> None: + """Éviction LRU : supprime les 20% plus anciens si seuil dépassé.""" + if self._max_entries and len(self._data) > self._max_entries: + to_remove = int(len(self._data) * 0.2) + keys_to_remove = list(self._data.keys())[:to_remove] + for k in keys_to_remove: + del self._data[k] + logger.info("Cache Ollama : éviction LRU de %d entrées (restant : %d)", + to_remove, len(self._data)) def save(self) -> None: """Persiste le cache sur disque si modifié.""" diff --git a/src/medical/ollama_client.py b/src/medical/ollama_client.py index f2caf38..ee124a4 100644 --- a/src/medical/ollama_client.py +++ b/src/medical/ollama_client.py @@ -28,10 +28,14 @@ def _get_anthropic_client(): return None try: import anthropic + except ImportError: + logger.warning("Anthropic SDK non installé (pip install anthropic)") + return None + try: _anthropic_client = anthropic.Anthropic(api_key=api_key) return _anthropic_client except Exception as e: - logger.warning("Anthropic SDK non disponible : %s", e) + logger.error("Anthropic SDK erreur d'initialisation (clé API invalide ?) : %s", e) return None @@ -165,20 +169,25 @@ def call_ollama( """ use_model = model or (get_model(role) if role else OLLAMA_MODEL) use_timeout = timeout or OLLAMA_TIMEOUT + + messages: list[dict] = [{"role": "user", "content": prompt}] + for attempt in range(3): try: + payload: dict = { + "model": use_model, + "messages": messages, + "stream": False, + "format": "json", + "think": False, + "options": { + "temperature": temperature, + "num_predict": max_tokens, + }, + } response = requests.post( f"{OLLAMA_URL}/api/chat", - json={ - "model": use_model, - "messages": [{"role": "user", "content": prompt}], - "stream": False, - "format": "json", - "options": { - "temperature": temperature, - "num_predict": max_tokens, - }, - }, + json=payload, timeout=use_timeout, ) # 429 rate limit → retry avec backoff exponentiel diff --git a/src/medical/rag_index.py b/src/medical/rag_index.py index 5be451d..f98f303 100644 --- a/src/medical/rag_index.py +++ b/src/medical/rag_index.py @@ -30,6 +30,55 @@ _loaded: dict[str, tuple] = {} _loaded_lock = threading.Lock() +def check_faiss_ready() -> dict: + """Vérifie que les index FAISS sont présents et valides. + + Returns: + {"ok": bool, "ref": int, "proc": int, "bio": int, "legacy": int, "errors": [str]} + Les int = nombre de vecteurs chargés (0 si absent). + """ + result = {"ok": False, "ref": 0, "proc": 0, "bio": 0, "legacy": 0, "errors": []} + + if not RAG_INDEX_DIR.exists(): + result["errors"].append(f"Répertoire FAISS absent : {RAG_INDEX_DIR}") + return result + + try: + import faiss + except ImportError: + result["errors"].append("Module faiss-cpu non installé") + return result + + has_any = False + for kind in ("ref", "proc", "bio", "all"): + idx_path, meta_path = _paths(kind) + key = kind if kind != "all" else "legacy" + if idx_path.exists() and meta_path.exists(): + try: + idx = faiss.read_index(str(idx_path)) + meta = json.loads(meta_path.read_text(encoding="utf-8")) + n_vectors = idx.ntotal + n_meta = len(meta) + if n_vectors == 0: + result["errors"].append(f"Index {kind} vide (0 vecteurs)") + elif n_vectors != n_meta: + result["errors"].append( + f"Index {kind} désynchronisé : {n_vectors} vecteurs vs {n_meta} métadonnées" + ) + else: + has_any = True + result[key] = n_vectors + except Exception as e: + result["errors"].append(f"Index {kind} corrompu : {e}") + + if not has_any: + result["errors"].append("Aucun index FAISS valide trouvé — lancez build_index()") + else: + result["ok"] = True + + return result + + @dataclass class Chunk: text: str @@ -593,6 +642,16 @@ def build_index(force: bool = False) -> None: # Invalider les singletons reset_index() + # Invalider le cache LLM (les résultats ont été générés avec l'ancien index) + try: + from ..config import OLLAMA_CACHE_PATH + if OLLAMA_CACHE_PATH.exists(): + backup = OLLAMA_CACHE_PATH.with_suffix(".pre_rebuild.json") + OLLAMA_CACHE_PATH.rename(backup) + logger.info("Cache LLM invalidé (sauvegardé → %s) — les résultats seront régénérés avec le nouvel index", backup) + except Exception: + logger.warning("Impossible d'invalider le cache LLM après rebuild", exc_info=True) + def get_index(kind: str = "ref") -> tuple | None: """Charge un index FAISS et ses métadonnées (singleton lazy-loaded). diff --git a/src/medical/rag_search.py b/src/medical/rag_search.py index 22892f6..5f35b70 100644 --- a/src/medical/rag_search.py +++ b/src/medical/rag_search.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import os import threading from concurrent.futures import ThreadPoolExecutor, as_completed @@ -56,7 +57,7 @@ def _get_embed_model(): raise RuntimeError("Modèle d'embedding indisponible (échec précédent)") from sentence_transformers import SentenceTransformer import torch - _device = "cuda" if torch.cuda.is_available() else "cpu" + _device = "cpu" if os.environ.get("T2A_EMBED_CPU") else ("cuda" if torch.cuda.is_available() else "cpu") _model_kwargs = {"low_cpu_mem_usage": False} try: logger.info("Chargement du modèle d'embedding (%s)...", _device) diff --git a/src/medical/severity.py b/src/medical/severity.py index cfe19d9..310c9b5 100644 --- a/src/medical/severity.py +++ b/src/medical/severity.py @@ -50,7 +50,9 @@ _HEURISTIC_CMA_ROOTS: set[str] = { # Hématologie / nutrition "D64", # Anémie "D65", # CIVD - "E46", # Dénutrition + "E43", # Dénutrition sévère (CMA niveau 3) + "E44", # Dénutrition modérée + "E46", # Dénutrition sans précision "E87", # Troubles hydro-électrolytiques "E86", # Déshydratation # Métabolique diff --git a/src/prompts/templates.py b/src/prompts/templates.py index dbd8483..e6d4adb 100644 --- a/src/prompts/templates.py +++ b/src/prompts/templates.py @@ -15,7 +15,8 @@ Variables par template : CPAM_ARGUMENTATION : dossier_str, asymetrie_str, tagged_str, titre, arg_ucr, decision_ucr, codes_str, definitions_str, codes_autorises_str, sources_text, extraction_str, - bio_confrontation_str, numero_ogc + bio_confrontation_str, numero_ogc, + strategie_type_str CPAM_ADVERSARIAL : response_json, factual_section, normes_section, dp_ucr_line, da_ucr_line DP_RANKER_CONSTRAINED : candidates_str, ctx_str, n_candidates @@ -119,6 +120,14 @@ RÈGLES IMPÉRATIVES : - Ne propose que des diagnostics CLAIREMENT mentionnés dans le texte - ATTENTION aux valeurs biologiques : ne code PAS un diagnostic si les valeurs sont dans les normes indiquées entre crochets [N: min-max]. Exemple : Créatinine 76 [N: 50-120] = NORMAL, pas d'insuffisance rénale. +DÉNUTRITION — CRITÈRES HAS/FFN 2021 : +- Diagnostic = 1 critère phénotypique + 1 critère étiologique +- Seuils IMC : adulte <18.5 modéré / ≤17 sévère ; ≥70 ans <22 modéré / <20 sévère +- Perte de poids : ≥5%/1mois ou ≥10%/6mois modéré ; ≥10%/1mois ou ≥15%/6mois sévère +- L'albumine est un critère de SÉVÉRITÉ uniquement : 30-35 g/L → E44.0 ; <30 g/L → E43 +- Un patient OBÈSE peut être dénutri +- Codes : E44.0 (modéré), E43 (sévère), E46 seulement si sévérité non précisable + DIAGNOSTIC PRINCIPAL : {dp_texte} DAS DÉJÀ CODÉS : @@ -268,6 +277,8 @@ Objet : {titre} Argument UCR : {arg_ucr} Décision UCR : {decision_ucr} +{strategie_type_str} + CODES EN JEU : {codes_str} {definitions_str} {codes_autorises_str} @@ -293,6 +304,10 @@ PASSE 2 — MOTIF D'HOSPITALISATION RÉEL : - Pourquoi CE patient a été hospitalisé CE JOUR (événement déclencheur) - Quel acte thérapeutique principal a été réalisé - Le DP retenu est-il cohérent avec cet acte et la durée de séjour +- RÈGLES D1/D2 DU GUIDE MÉTHODOLOGIQUE : + D1 : Si seul un symptôme persiste sans cause identifiée dans le dossier, le symptôme reste DP légitime + D2 : Si une cause est identifiée (confirmée par examens), la cause doit devenir DP + Appliquer D1/D2 dans le raisonnement si le désaccord porte sur le DP PASSE 3 — CONFRONTATION BIOLOGIE / DIAGNOSTIC (appliquer R1 et R3) : Pour CHAQUE diagnostic contesté, comparer aux seuils ci-dessus. diff --git a/src/quality/completude.py b/src/quality/completude.py new file mode 100644 index 0000000..4004b94 --- /dev/null +++ b/src/quality/completude.py @@ -0,0 +1,529 @@ +"""Checklist de complétude documentaire DIM. + +Pour chaque code diagnostique (DP + DAS), vérifie la présence des éléments +cliniques nécessaires (biologie, imagerie, documents, données cliniques), +confronte les valeurs aux seuils diagnostiques, croise les preuves cliniques, +et calcule un score de défendabilité CPAM. +""" + +from __future__ import annotations + +import logging +import re +import unicodedata +from typing import Optional + +from ..config import ( + CheckCompletude, + CompletudeDossier, + Diagnostic, + DossierMedical, + ItemCompletude, + load_completude_rules, +) + +logger = logging.getLogger(__name__) + +# Poids par statut pour le scoring pondéré +_STATUT_WEIGHTS: dict[str, float] = { + "present_confirme": 1.0, + "present": 1.0, + "present_indirect": 0.5, + "present_non_confirme": 0.25, + "absent": 0.0, +} + + +def _normalize(text: str) -> str: + """Minuscule + suppression accents pour matching souple.""" + text = text.lower().strip() + return unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode() + + +def _collect_doc_types(dossier: DossierMedical) -> set[str]: + """Types de documents présents dans le dossier (même logique que VETO-29).""" + doc_types = set() + if dossier.document_type: + doc_types.add(dossier.document_type.lower()) + for sf in dossier.source_files or []: + sf_up = sf.upper() + if "CRO" in sf_up: + doc_types.add("cro") + if "ANAPATH" in sf_up: + doc_types.add("anapath") + if "CRH" in sf_up: + doc_types.add("crh") + if "TRACKARE" in sf_up: + doc_types.add("trackare") + return doc_types + + +def _collect_codes(dossier: DossierMedical) -> list[tuple[str, str, str, Optional[Diagnostic]]]: + """Retourne [(code, libellé, type_diag, diagnostic_obj)] pour DP + DAS actifs.""" + codes = [] + dp = dossier.diagnostic_principal + if dp: + code = dp.cim10_final or dp.cim10_suggestion + if code: + codes.append((code, dp.texte, "DP", dp)) + + for das in dossier.diagnostics_associes: + if das.status == "ruled_out": + continue + code = das.cim10_final or das.cim10_suggestion + if code: + codes.append((code, das.texte, "DAS", das)) + return codes + + +def _match_bio(dossier: DossierMedical, match_keys: list[str]) -> tuple[Optional[str], Optional[float]]: + """Cherche une valeur bio correspondant aux clés fournies. + + Retourne (valeur_str, valeur_num) si trouvée. + """ + normalized_keys = [_normalize(k) for k in match_keys] + for bio in dossier.biologie_cle: + bio_norm = _normalize(bio.test) + for key in normalized_keys: + if key in bio_norm or bio_norm in key: + return (bio.valeur or "présent", bio.valeur_num) + return None, None + + +def _match_imagerie(dossier: DossierMedical, match_keys: list[str]) -> Optional[str]: + """Cherche une imagerie correspondant aux mots-clés.""" + normalized_keys = [_normalize(k) for k in match_keys] + for img in dossier.imagerie: + img_norm = _normalize(img.type) + for key in normalized_keys: + if key in img_norm or img_norm in key: + return img.conclusion or "présent" + return None + + +def _match_document(doc_types: set[str], match_keys: list[str]) -> bool: + """Vérifie si un type de document est présent.""" + normalized_types = {_normalize(dt) for dt in doc_types} + for key in match_keys: + key_norm = _normalize(key) + if any(key_norm in dt or dt in key_norm for dt in normalized_types): + return True + return False + + +def _match_clinique(dossier: DossierMedical, field: str) -> tuple[Optional[str], Optional[float]]: + """Vérifie la présence d'un champ clinique (imc, poids, taille). + + Retourne (valeur_str, valeur_num). + """ + val = getattr(dossier.sejour, field, None) + if val is not None: + return str(val), float(val) + return None, None + + +def _find_rules_for_code(code: str, rules_diag: dict) -> list[dict]: + """Trouve toutes les règles applicables pour un code CIM-10 donné.""" + matched = [] + for _family_id, family in rules_diag.items(): + prefixes = family.get("prefixes", []) + for prefix in prefixes: + if code.startswith(prefix): + matched.append(family) + break + return matched + + +def _find_acte_rules(dossier: DossierMedical, rules_actes: dict) -> list[dict]: + """Trouve les règles CCAM applicables aux actes du dossier.""" + matched = [] + for _rule_id, rule in rules_actes.items(): + prefixes = rule.get("prefixes", []) + for acte in dossier.actes_ccam: + acte_code = acte.code_ccam_suggestion or "" + if any(acte_code.startswith(p) for p in prefixes): + matched.append(rule) + break + return matched + + +def _evaluate_seuil( + seuil: dict, + valeur_num: Optional[float], + sexe: Optional[str] = None, +) -> tuple[Optional[bool], str]: + """Évalue si une valeur numérique satisfait un seuil. + + Returns: + (confirme, detail_message) + - confirme=True: la valeur confirme le diagnostic + - confirme=False: la valeur ne confirme pas + - confirme=None: pas de valeur numérique à comparer + """ + if valeur_num is None: + return None, "" + + seuil_type = seuil.get("type", "") + message_ok = seuil.get("message_ok", "Seuil atteint") + message_ko = seuil.get("message_ko", "Seuil non atteint") + + if seuil_type == "below": + # Seuil sex-dépendant ? + if "value_m" in seuil and "value_f" in seuil: + s = (sexe or "").upper() + if s in ("M", "MASCULIN", "HOMME"): + threshold = float(seuil["value_m"]) + elif s in ("F", "FEMININ", "FÉMININ", "FEMME"): + threshold = float(seuil["value_f"]) + else: + # sexe inconnu → utilise le seuil le plus bas (plus conservateur) + threshold = min(float(seuil["value_m"]), float(seuil["value_f"])) + else: + threshold = float(seuil["value"]) + return (valeur_num < threshold, message_ok if valeur_num < threshold else message_ko) + + elif seuil_type == "above": + if "value_m" in seuil and "value_f" in seuil: + s = (sexe or "").upper() + if s in ("M", "MASCULIN", "HOMME"): + threshold = float(seuil["value_m"]) + elif s in ("F", "FEMININ", "FÉMININ", "FEMME"): + threshold = float(seuil["value_f"]) + else: + threshold = max(float(seuil["value_m"]), float(seuil["value_f"])) + else: + threshold = float(seuil["value"]) + return (valeur_num > threshold, message_ok if valeur_num > threshold else message_ko) + + elif seuil_type == "range": + rmin = float(seuil.get("range_min", 0)) + rmax = float(seuil.get("range_max", 999999)) + in_range = rmin <= valeur_num <= rmax + return (in_range, message_ok if in_range else message_ko) + + elif seuil_type == "outside_range": + rmin = float(seuil.get("range_min", 0)) + rmax = float(seuil.get("range_max", 999999)) + outside = valeur_num < rmin or valeur_num > rmax + return (outside, message_ok if outside else message_ko) + + return None, "" + + +def _search_preuves_cliniques( + diag: Optional[Diagnostic], + categorie: str, + element: str, +) -> Optional[tuple[str, str]]: + """Cherche dans les preuves_cliniques du diagnostic une correspondance. + + Returns: + (valeur, detail) ou None + """ + if diag is None or not diag.preuves_cliniques: + return None + + element_norm = _normalize(element) + # Mots-clés à chercher dans les preuves + element_words = set(element_norm.split()) + + for preuve in diag.preuves_cliniques: + preuve_type_norm = _normalize(preuve.type) + preuve_elem_norm = _normalize(preuve.element) + + # Vérifier la catégorie + cat_match = False + if categorie == "biologie" and preuve_type_norm in ("biologie", "biologique", "bio"): + cat_match = True + elif categorie == "imagerie" and preuve_type_norm in ("imagerie", "radiologie", "radio"): + cat_match = True + elif categorie == "clinique" and preuve_type_norm in ("clinique", "examen"): + cat_match = True + elif categorie == "document" and preuve_type_norm in ("document", "compte-rendu", "rapport"): + cat_match = True + + if not cat_match: + # Fallback: chercher les mots-clés de l'élément dans le texte de la preuve + if any(w in preuve_elem_norm for w in element_words if len(w) > 2): + cat_match = True + + if cat_match: + # Vérifier que l'élément matche + if any(w in preuve_elem_norm for w in element_words if len(w) > 2): + detail = f"Mentionné dans les preuves cliniques : {preuve.interpretation}" + return preuve.element, detail + + return None + + +def _check_item( + dossier: DossierMedical, + doc_types: set[str], + item_def: dict, + code: str, + diag: Optional[Diagnostic] = None, +) -> ItemCompletude: + """Évalue un item de la checklist avec confrontation seuil et preuves.""" + categorie = item_def["categorie"] + element = item_def["element"] + importance = item_def.get("importance", "recommande") + impact_cpam = item_def.get("impact_cpam", "") + seuil_def = item_def.get("seuil") + + # Vérifier le code_filter si présent sur le seuil + seuil_applicable = None + if seuil_def: + code_filter = seuil_def.get("code_filter") + if code_filter is None or code.startswith(code_filter): + seuil_applicable = seuil_def + + valeur = None + valeur_num = None + statut = "absent" + confirmation_detail = None + + if categorie == "biologie": + val_str, val_num = _match_bio(dossier, item_def.get("match_bio", [])) + if val_str: + statut = "present" + valeur = val_str + valeur_num = val_num + + elif categorie == "imagerie": + val = _match_imagerie(dossier, item_def.get("match_imagerie", [])) + if val: + statut = "present" + valeur = val + + elif categorie == "document": + if _match_document(doc_types, item_def.get("match_document", [])): + statut = "present" + + elif categorie == "clinique": + field = item_def.get("match_clinique", "") + val_str, val_num = _match_clinique(dossier, field) + if val_str: + statut = "present" + valeur = val_str + valeur_num = val_num + + # --- Confrontation seuil (si valeur trouvée et seuil applicable) --- + if statut == "present" and seuil_applicable and valeur_num is not None: + confirme, detail = _evaluate_seuil( + seuil_applicable, valeur_num, dossier.sejour.sexe + ) + if confirme is True: + statut = "present_confirme" + confirmation_detail = detail + elif confirme is False: + statut = "present_non_confirme" + confirmation_detail = detail + # confirme=None → reste "present" + + # --- Croisement preuves_cliniques (si absent) --- + if statut == "absent" and diag is not None: + result = _search_preuves_cliniques(diag, categorie, element) + if result: + preuve_val, preuve_detail = result + statut = "present_indirect" + valeur = preuve_val + confirmation_detail = preuve_detail + + return ItemCompletude( + categorie=categorie, + element=element, + statut=statut, + valeur=valeur, + importance=importance, + impact_cpam=impact_cpam, + confirmation_detail=confirmation_detail, + ) + + +def _compute_check_score(items: list[ItemCompletude]) -> tuple[int, str, str]: + """Calcule score, verdict et résumé pour un check donné. + + Scoring pondéré par statut : + - present_confirme / present (sans seuil) → 1.0 + - present_indirect → 0.5 + - present_non_confirme → 0.25 + - absent → 0.0 + """ + obligatoires = [i for i in items if i.importance == "obligatoire"] + recommandes = [i for i in items if i.importance == "recommande"] + + def _weight(item: ItemCompletude) -> float: + return _STATUT_WEIGHTS.get(item.statut, 0.0) + + oblig_score = sum(_weight(i) for i in obligatoires) + oblig_total = len(obligatoires) + reco_score = sum(_weight(i) for i in recommandes) + reco_total = len(recommandes) + + if oblig_total == 0: + if reco_total == 0: + return 100, "defendable", "Aucun élément requis" + pct = reco_score / reco_total + score = int(70 + 30 * pct) + verdict = "defendable" if pct >= 0.5 else "fragile" + resume = _build_resume(items) + return score, verdict, resume + + pct_oblig = oblig_score / oblig_total + pct_reco = reco_score / reco_total if reco_total > 0 else 1.0 + + # Score : 70% basé sur obligatoires, 30% sur recommandés + score = int(70 * pct_oblig + 30 * pct_reco) + + # Verdict + oblig_presents = sum(1 for i in obligatoires if i.statut != "absent") + if oblig_presents == 0: + verdict = "indefendable" + elif oblig_presents < oblig_total: + verdict = "fragile" + elif any(i.statut == "present_non_confirme" for i in obligatoires): + verdict = "fragile" + else: + verdict = "defendable" + + resume = _build_resume(items) + return score, verdict, resume + + +def _build_resume(items: list[ItemCompletude]) -> str: + """Construit le résumé texte du check.""" + obligatoires = [i for i in items if i.importance == "obligatoire"] + recommandes = [i for i in items if i.importance == "recommande"] + + oblig_ok = sum(1 for i in obligatoires if i.statut not in ("absent",)) + oblig_confirmed = sum(1 for i in obligatoires if i.statut == "present_confirme") + reco_ok = sum(1 for i in recommandes if i.statut not in ("absent",)) + + parts = [] + if obligatoires: + txt = f"{oblig_ok}/{len(obligatoires)} obligatoires" + if oblig_confirmed: + txt += f" ({oblig_confirmed} confirmé{'s' if oblig_confirmed > 1 else ''})" + parts.append(txt) + if recommandes: + parts.append(f"{reco_ok}/{len(recommandes)} recommandés") + return ", ".join(parts) if parts else "Aucun élément requis" + + +def build_completude_checklist(dossier: DossierMedical) -> CompletudeDossier: + """Construit la checklist de complétude documentaire pour un dossier. + + Pour chaque code (DP + DAS), cherche les règles applicables, + vérifie la présence de chaque élément requis, confronte les valeurs + aux seuils diagnostiques, et calcule les scores pondérés. + """ + try: + rules = load_completude_rules() + except Exception: + logger.warning("Complétude : impossible de charger les règles", exc_info=True) + return CompletudeDossier() + + rules_diag = rules.get("diagnostics", {}) + rules_actes = rules.get("actes", {}) + doc_types = _collect_doc_types(dossier) + codes = _collect_codes(dossier) + + checks: list[CheckCompletude] = [] + + # 1. Vérification par code diagnostique + for code, libelle, type_diag, diag_obj in codes: + families = _find_rules_for_code(code, rules_diag) + if not families: + continue + + all_items: list[ItemCompletude] = [] + seen_elements: set[str] = set() + + for family in families: + for item_def in family.get("items", []): + # Filtrer par code_filter (seuils spécifiques E43 vs E44) + seuil = item_def.get("seuil") + if seuil and seuil.get("code_filter"): + if not code.startswith(seuil["code_filter"]): + continue + + elem_key = f"{item_def['categorie']}:{item_def['element']}" + if elem_key in seen_elements: + continue + seen_elements.add(elem_key) + + item = _check_item(dossier, doc_types, item_def, code, diag_obj) + all_items.append(item) + + if not all_items: + continue + + score, verdict, resume = _compute_check_score(all_items) + checks.append(CheckCompletude( + code=code, + libelle=libelle, + type_diag=type_diag, + items=all_items, + score=score, + verdict=verdict, + resume=resume, + )) + + # 2. Vérification des actes CCAM (CRO, etc.) + acte_families = _find_acte_rules(dossier, rules_actes) + for family in acte_families: + desc = family.get("description", "Acte") + all_items = [] + for item_def in family.get("items", []): + item = _check_item(dossier, doc_types, item_def, "") + if not any(existing.element == item.element for existing in all_items): + all_items.append(item) + + if all_items: + score, verdict, resume = _compute_check_score(all_items) + # Un seul check pour l'ensemble des actes chirurgicaux + acte_codes = [a.code_ccam_suggestion or "?" for a in dossier.actes_ccam + if a.code_ccam_suggestion and any(a.code_ccam_suggestion.startswith(p) for p in family.get("prefixes", []))] + code_label = ", ".join(acte_codes[:3]) or "CCAM" + if not any(c.code == code_label for c in checks): + checks.append(CheckCompletude( + code=code_label, + libelle=desc, + type_diag="Acte", + items=all_items, + score=score, + verdict=verdict, + resume=resume, + )) + + # 3. Score global + if not checks: + return CompletudeDossier( + documents_presents=sorted(doc_types), + ) + + scores = [c.score for c in checks] + score_global = sum(scores) // len(scores) + + verdicts = [c.verdict for c in checks] + if "indefendable" in verdicts: + verdict_global = "indefendable" + elif "fragile" in verdicts: + verdict_global = "fragile" + else: + verdict_global = "defendable" + + # Documents manquants + docs_manquants = set() + for check in checks: + for item in check.items: + if item.categorie == "document" and item.statut == "absent" and item.importance == "obligatoire": + docs_manquants.add(item.element) + + return CompletudeDossier( + checks=checks, + score_global=score_global, + verdict_global=verdict_global, + documents_presents=sorted(doc_types), + documents_manquants=sorted(docs_manquants), + ) diff --git a/src/quality/veto_engine.py b/src/quality/veto_engine.py index 8a558ae..68864e0 100644 --- a/src/quality/veto_engine.py +++ b/src/quality/veto_engine.py @@ -252,17 +252,17 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport: # Trackare = codage établissement, source d'autorité : pas de VETO-02 logger.debug("VETO-02 skip: DP %s issu de Trackare (source d'autorité)", dp.cim10_suggestion) elif not _has_evidence(dp): - add("VETO-02", "HARD", "diagnostic_principal", f"DP {dp.cim10_suggestion} sans preuve exploitable") + add("VETO-02", "HARD", "diagnostic_principal", f"DP {dp.cim10_suggestion} sans preuve exploitable", citation="Principe de preuve : tout diagnostic/acte doit être étayé par une trace dans le dossier médical (Guide Méthodologique MCO)") for i, das in enumerate(dossier.diagnostics_associes): if _is_ruled_out(das): continue if das.cim10_suggestion and not _has_evidence(das): - add("VETO-02", "MEDIUM", f"diagnostics_associes[{i}]", f"DAS {das.cim10_suggestion} sans preuve exploitable") + add("VETO-02", "MEDIUM", f"diagnostics_associes[{i}]", f"DAS {das.cim10_suggestion} sans preuve exploitable", citation="Principe de preuve : tout diagnostic/acte doit être étayé par une trace dans le dossier médical (Guide Méthodologique MCO)") for i, acte in enumerate(dossier.actes_ccam): if acte.code_ccam_suggestion and not _has_evidence(acte): - add("VETO-02", "HARD", f"actes_ccam[{i}]", f"Acte {acte.code_ccam_suggestion} sans preuve exploitable") + add("VETO-02", "HARD", f"actes_ccam[{i}]", f"Acte {acte.code_ccam_suggestion} sans preuve exploitable", citation="Principe de preuve : tout diagnostic/acte doit être étayé par une trace dans le dossier médical (Guide Méthodologique MCO)") # ------------------------------------------------- # VETO-03 : négation / conditionnel DANS LES PREUVES @@ -272,11 +272,11 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport: excerpts = _evidence_excerpts(dp) neg, cond, contra, pos = _analyze_neg_cond(excerpts, dp.texte or dp.cim10_suggestion) if neg and not pos: - add("VETO-03", "HARD", "diagnostic_principal", f"DP {dp.cim10_suggestion} contredit par la preuve (négation)") + add("VETO-03", "HARD", "diagnostic_principal", f"DP {dp.cim10_suggestion} contredit par la preuve (négation)", citation="Guide Méthodologique MCO : Un diagnostic ne peut être retenu si le compte-rendu le contredit explicitement") elif contra: - add("VETO-03", "MEDIUM", "diagnostic_principal", f"DP {dp.cim10_suggestion} preuves contradictoires (positif vs négatif)") + add("VETO-03", "MEDIUM", "diagnostic_principal", f"DP {dp.cim10_suggestion} preuves contradictoires (positif vs négatif)", citation="Guide Méthodologique MCO : En cas de preuves contradictoires, le diagnostic doit être confirmé par le médecin") elif cond and dp.cim10_confidence == "high": - add("VETO-03", "MEDIUM", "diagnostic_principal", f"DP {dp.cim10_suggestion} basé sur du conditionnel") + add("VETO-03", "MEDIUM", "diagnostic_principal", f"DP {dp.cim10_suggestion} basé sur du conditionnel", citation="Guide Méthodologique MCO : Un diagnostic conditionnel (suspecté, à éliminer) ne doit pas être codé comme confirmé") for i, das in enumerate(dossier.diagnostics_associes): if _is_ruled_out(das): @@ -289,11 +289,11 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport: if neg and not pos: # En contrôle CPAM : une négation explicite = bloquant, surtout si le modèle est « high ». severity = "HARD" if das.cim10_confidence == "high" else "MEDIUM" - add("VETO-03", severity, where, f"DAS {das.cim10_suggestion} contredit par la preuve (négation)") + add("VETO-03", severity, where, f"DAS {das.cim10_suggestion} contredit par la preuve (négation)", citation="Guide Méthodologique MCO : Un diagnostic ne peut être retenu si le compte-rendu le contredit explicitement") elif contra: - add("VETO-03", "MEDIUM", where, f"DAS {das.cim10_suggestion} preuves contradictoires") + add("VETO-03", "MEDIUM", where, f"DAS {das.cim10_suggestion} preuves contradictoires", citation="Guide Méthodologique MCO : En cas de preuves contradictoires, le diagnostic doit être confirmé par le médecin") elif cond and das.cim10_confidence == "high": - add("VETO-03", "LOW", where, f"DAS {das.cim10_suggestion} potentiellement conditionnel") + add("VETO-03", "LOW", where, f"DAS {das.cim10_suggestion} potentiellement conditionnel", citation="Guide Méthodologique MCO : Un diagnostic conditionnel (suspecté, à éliminer) ne doit pas être codé comme confirmé") # ------------------------------------------------- # VETO-15 : preuve de type "score/test" (risque élevé de sur-codage) diff --git a/src/viewer/__main__.py b/src/viewer/__main__.py index 1f0bbbd..cc28c6e 100644 --- a/src/viewer/__main__.py +++ b/src/viewer/__main__.py @@ -1,4 +1,4 @@ -"""Point d'entrée : python -m src.viewer [--host 127.0.0.1] [--port 5000] [--debug].""" +"""Point d'entrée : python -m src.viewer [--host 127.0.0.1] [--port 7500] [--debug].""" import argparse @@ -8,7 +8,7 @@ from .app import create_app def main(): parser = argparse.ArgumentParser(description="Viewer CIM-10 T2A") parser.add_argument("--host", default="127.0.0.1") - parser.add_argument("--port", type=int, default=5000) + parser.add_argument("--port", type=int, default=7500) parser.add_argument("--debug", action="store_true") args = parser.parse_args() diff --git a/src/viewer/app.py b/src/viewer/app.py index b43373f..c8f30a9 100644 --- a/src/viewer/app.py +++ b/src/viewer/app.py @@ -4,11 +4,14 @@ from __future__ import annotations import json import logging +import os import re +import time from pathlib import Path import requests from flask import Flask, Response, abort, render_template, request, jsonify +from flask_httpauth import HTTPBasicAuth from markupsafe import Markup from werkzeug.utils import secure_filename @@ -23,6 +26,7 @@ from ..config import ( ) from .. import config as cfg from ..control.cpam_context import _assess_dossier_strength +from ..medical.bio_normals import BIO_NORMALS from .referentiels import ReferentielManager from .validation import ValidationManager @@ -143,32 +147,239 @@ def compute_dashboard_stats(groups: dict[str, list[dict]]) -> dict: } +def compute_dim_synthesis(groups: dict[str, list[dict]]) -> dict: + """Calcule les indicateurs de synthèse pour la vue médecin DIM.""" + # --- DP Arbitrage --- + dp_total = 0 + dp_confirmed = 0 + dp_review = 0 + dp_modified = 0 # finalizer a changé le DP + dp_conf_dist: Counter = Counter() # high/medium/low + dp_source_dist: Counter = Counter() # trackare/crh/override + + # --- DAS Qualité --- + das_total = 0 + das_kept = 0 + das_downgraded = 0 + das_removed = 0 + das_ruled_out = 0 + das_cma = 0 + das_no_code = 0 + + # --- Contestabilité (Veto) --- + veto_dist: Counter = Counter() # PASS/NEED_INFO/FAIL + veto_scores: list[int] = [] + top_vetos: Counter = Counter() + + # --- Complétude --- + completude_dist: Counter = Counter() # defendable/fragile/indefendable + completude_scores: list[int] = [] + + # --- CPAM --- + cpam_total = 0 + cpam_impact_total = 0 + cpam_by_priority: Counter = Counter() + cpam_by_status: Counter = Counter() + + # --- Alertes prioritaires --- + dossiers_review: list[dict] = [] + dossiers_fail: list[dict] = [] + dossiers_indefendable: list[dict] = [] + + for group_name, items in groups.items(): + for item in items: + d = item["dossier"] + dname = format_dossier_name(group_name) + dpath = item["path_rel"] + + # DP + dp_final = d.dp_final + dp_track = d.dp_trackare + if dp_final: + dp_total += 1 + dp_conf_dist[dp_final.confidence or "none"] += 1 + if dp_final.verdict == "CONFIRMED": + dp_confirmed += 1 + else: + dp_review += 1 + dossiers_review.append({"name": dname, "path": dpath, + "reason": dp_final.reason or "DP à valider", + "code": dp_final.chosen_code or "?"}) + # Modification DP + if dp_track and dp_final.chosen_code and dp_track.chosen_code: + if dp_final.chosen_code != dp_track.chosen_code: + dp_modified += 1 + # Source + flags = d.quality_flags or {} + if flags.get("trackare_only_mode"): + dp_source_dist["trackare"] += 1 + elif flags.get("crh_only_mode"): + dp_source_dist["crh"] += 1 + elif flags.get("override_trackare_by_crh_confirmed") or flags.get("trackare_symptom_overridden"): + dp_source_dist["override_crh"] += 1 + elif flags.get("trackare_confirmed_by_crh"): + dp_source_dist["confirmé"] += 1 + else: + dp_source_dist["autre"] += 1 + elif d.diagnostic_principal: + dp_total += 1 + dp_conf_dist[d.diagnostic_principal.cim10_confidence or "none"] += 1 + + # DAS + for das in d.diagnostics_associes: + das_total += 1 + dec = das.cim10_decision + if dec: + action = dec.action + if action == "KEEP": + das_kept += 1 + elif action == "DOWNGRADE": + das_downgraded += 1 + elif action == "REMOVE": + das_removed += 1 + elif action == "RULED_OUT": + das_ruled_out += 1 + else: + das_kept += 1 + else: + das_kept += 1 + if das.est_cma: + das_cma += 1 + if not das.cim10_final and not das.cim10_suggestion: + das_no_code += 1 + + # Veto + vr = d.veto_report + if vr: + veto_dist[vr.verdict] += 1 + veto_scores.append(vr.score_contestabilite) + for issue in (vr.issues or []): + top_vetos[issue.veto] += 1 + if vr.verdict == "FAIL": + dossiers_fail.append({"name": dname, "path": dpath, + "score": vr.score_contestabilite, + "issues": len(vr.issues or [])}) + + # Complétude + comp = d.completude + if comp: + completude_dist[comp.verdict_global] += 1 + completude_scores.append(comp.score_global) + if comp.verdict_global == "indefendable": + dossiers_indefendable.append({"name": dname, "path": dpath, + "score": comp.score_global, + "manquants": len(comp.documents_manquants or [])}) + + # CPAM + for ctrl in d.controles_cpam: + cpam_total += 1 + fi = ctrl.financial_impact + if fi: + cpam_impact_total += fi.impact_estime_euros or 0 + cpam_by_priority[fi.priorite or "normale"] += 1 + cpam_by_status[ctrl.validation_dim or "non_valide"] += 1 + + avg_veto = round(sum(veto_scores) / len(veto_scores)) if veto_scores else 0 + avg_completude = round(sum(completude_scores) / len(completude_scores)) if completude_scores else 0 + + return { + "dp": { + "total": dp_total, + "confirmed": dp_confirmed, + "review": dp_review, + "modified": dp_modified, + "confidence": dict(dp_conf_dist), + "source": dict(dp_source_dist), + }, + "das": { + "total": das_total, + "kept": das_kept, + "downgraded": das_downgraded, + "removed": das_removed, + "ruled_out": das_ruled_out, + "cma": das_cma, + "no_code": das_no_code, + "taux_modification": round((das_downgraded + das_removed + das_ruled_out) / das_total * 100, 1) if das_total else 0, + }, + "veto": { + "distribution": dict(veto_dist), + "avg_score": avg_veto, + "top_issues": top_vetos.most_common(10), + }, + "completude": { + "distribution": dict(completude_dist), + "avg_score": avg_completude, + }, + "cpam": { + "total": cpam_total, + "impact_total": cpam_impact_total, + "by_priority": dict(cpam_by_priority), + "by_status": dict(cpam_by_status), + }, + "alertes": { + "review": dossiers_review[:20], + "fail": dossiers_fail[:20], + "indefendable": dossiers_indefendable[:20], + }, + } + + +def _compute_jours_restants(ctrl) -> int | None: + """Calcule les jours restants avant la date limite de réponse.""" + if not ctrl.date_limite_reponse: + return None + from datetime import datetime + try: + limite = datetime.strptime(ctrl.date_limite_reponse, "%d/%m/%Y") + return (limite - datetime.now()).days + except (ValueError, TypeError): + return None + + def collect_cpam_controls(groups: dict[str, list[dict]]) -> list[dict]: - """Collecte tous les contrôles CPAM de tous les dossiers.""" + """Collecte tous les contrôles CPAM de tous les dossiers, avec impact financier.""" + from ..medical.ghm import estimate_financial_impact + + _PRIORITE_ORDER = {"critique": 0, "haute": 1, "normale": 2, "faible": 3} + controls = [] for group_name, items in groups.items(): for item in items: d = item["dossier"] dp_code = d.diagnostic_principal.cim10_suggestion if d.diagnostic_principal else None for ctrl in d.controles_cpam: + # Calculer l'impact financier si absent + if ctrl.financial_impact is None and d.ghm_estimation: + ctrl.financial_impact = estimate_financial_impact(d.ghm_estimation) controls.append({ "group_name": group_name, "filepath": item["path_rel"], "ctrl": ctrl, "dp_code": dp_code, + "jours_restants": _compute_jours_restants(ctrl), }) - controls.sort(key=lambda c: c["ctrl"].numero_ogc) + # Tri : 1) priorité financière, 2) désaccords (confirme) avant accords (retient), 3) OGC + controls.sort(key=lambda c: ( + _PRIORITE_ORDER.get( + c["ctrl"].financial_impact.priorite if c["ctrl"].financial_impact else "normale", + 2, + ), + 0 if "confirme" in (c["ctrl"].decision_ucr or "").lower() else 1, + c["ctrl"].numero_ogc, + )) return controls def get_builtin_referentiels() -> list[dict]: """Retourne les infos sur les référentiels intégrés (PDFs + dicts).""" - rag_index_meta = Path(STRUCTURED_DIR).parent / "data" / "rag_index" / "metadata.json" + from ..config import BASE_DIR + rag_index_dir = BASE_DIR / "data" / "rag_index" + + # Charger les chunks depuis TOUS les metadata (ref, proc, bio, legacy) chunks_by_doc: dict[str, int] = {} - if rag_index_meta.exists(): + for meta_file in rag_index_dir.glob("metadata*.json"): try: - import json as _json - meta = _json.loads(rag_index_meta.read_text(encoding="utf-8")) + meta = json.loads(meta_file.read_text(encoding="utf-8")) for m in meta: doc = m.get("document", "") chunks_by_doc[doc] = chunks_by_doc.get(doc, 0) + 1 @@ -176,16 +387,27 @@ def get_builtin_referentiels() -> list[dict]: pass refs = [] + # (nom, path, ext, doc_keys pour compter les chunks, edition, validité) builtin_sources = [ - ("CIM-10 FR 2026", CIM10_PDF, ".pdf", ["cim10", "cim10_alpha"]), - ("Guide Méthodologique MCO 2026", GUIDE_METHODO_PDF, ".pdf", ["guide_methodo"]), - ("CCAM 2025", CCAM_PDF, ".pdf", ["ccam"]), - ("Dictionnaire CIM-10", CIM10_DICT_PATH, ".json", []), - ("Suppléments CIM-10", CIM10_SUPPLEMENTS_PATH, ".json", []), - ("Dictionnaire CCAM", CCAM_DICT_PATH, ".json", []), + ("CIM-10 FR 2026", CIM10_PDF, ".pdf", ["cim10", "cim10_alpha"], + "11/12/2025", "2026 (provisoire)"), + ("Guide Méthodologique MCO 2026", GUIDE_METHODO_PDF, ".pdf", ["guide_methodo"], + "2025", "2026 (provisoire)"), + ("CCAM descriptive PMSI V4", CCAM_PDF, ".pdf", ["ccam"], + "2025", "V4 2025"), + ("Dictionnaire CIM-10", CIM10_DICT_PATH, ".json", [], + "", ""), + ("Suppléments CIM-10", CIM10_SUPPLEMENTS_PATH, ".json", [], + "", ""), + ("Dictionnaire CCAM", CCAM_DICT_PATH, ".json", [], + "", ""), ] - for name, path, ext, doc_keys in builtin_sources: + for name, path, ext, doc_keys, edition, validite in builtin_sources: size_mb = path.stat().st_size / (1024 * 1024) if path.exists() else 0 + mtime = "" + if path.exists(): + import datetime as _dt + mtime = _dt.datetime.fromtimestamp(path.stat().st_mtime).strftime("%d/%m/%Y") chunks = sum(chunks_by_doc.get(k, 0) for k in doc_keys) refs.append({ "name": name, @@ -194,10 +416,73 @@ def get_builtin_referentiels() -> list[dict]: "size_mb": size_mb, "chunks": chunks, "exists": path.exists(), + "edition": edition, + "validite": validite, + "file_date": mtime, }) + + # Référentiels supplémentaires indexés (ref:*.pdf dans les metadata) + from ..config import REFERENTIELS_DIR + pdfs_dir = REFERENTIELS_DIR / "pdfs" + for doc_name, count in sorted(chunks_by_doc.items()): + if doc_name.startswith("ref:") or doc_name.startswith("proc:"): + prefix, fname = doc_name.split(":", 1) + pdf_path = pdfs_dir / fname + size_mb = pdf_path.stat().st_size / (1024 * 1024) if pdf_path.exists() else 0 + mtime = "" + if pdf_path.exists(): + import datetime as _dt + mtime = _dt.datetime.fromtimestamp(pdf_path.stat().st_mtime).strftime("%d/%m/%Y") + refs.append({ + "name": fname.replace("_", " ").replace(".pdf", ""), + "filename": fname, + "extension": ".pdf", + "size_mb": size_mb, + "chunks": count, + "exists": pdf_path.exists(), + "edition": "", + "validite": "", + "file_date": mtime, + "category": prefix, + }) + return refs +def get_faiss_index_info() -> dict: + """Retourne les informations détaillées sur les index FAISS.""" + from ..config import BASE_DIR + from ..medical.rag_index import check_faiss_ready + rag_dir = BASE_DIR / "data" / "rag_index" + + info = {"ok": False, "indexes": [], "total_vectors": 0, "last_build": ""} + + status = check_faiss_ready() + info["ok"] = status["ok"] + info["total_vectors"] = status["ref"] + status["proc"] + status["bio"] + status["legacy"] + + for kind, label in [("ref", "Référentiels CIM-10"), ("proc", "Procédures/Guides"), + ("bio", "Biologie"), ("all", "Legacy (combiné)")]: + idx_file = rag_dir / f"faiss_{kind}.index" if kind != "all" else rag_dir / "faiss.index" + meta_file = rag_dir / f"metadata_{kind}.json" if kind != "all" else rag_dir / "metadata.json" + count = status.get(kind, status.get("legacy", 0)) if kind == "all" else status.get(kind, 0) + mtime = "" + size_mb = 0 + if idx_file.exists(): + import datetime as _dt + mtime = _dt.datetime.fromtimestamp(idx_file.stat().st_mtime).strftime("%d/%m/%Y %H:%M") + size_mb = idx_file.stat().st_size / (1024 * 1024) + info["indexes"].append({ + "kind": kind, "label": label, + "vectors": count, "size_mb": round(size_mb, 1), + "last_build": mtime, "exists": idx_file.exists(), + }) + if mtime and (not info["last_build"] or mtime > info["last_build"]): + info["last_build"] = mtime + + return info + + def load_ccam_dict() -> dict[str, dict]: """Charge le dictionnaire CCAM pour les regroupements.""" if CCAM_DICT_PATH.exists(): @@ -209,13 +494,23 @@ def load_ccam_dict() -> dict[str, dict]: return {} +_scan_cache: dict[str, object] = {"data": None, "ts": 0.0} +_SCAN_TTL = 30 # secondes + + def scan_dossiers() -> dict[str, list[dict]]: """Scanne output/structured/ et retourne les fichiers groupés par sous-dossier. + Résultat mis en cache pendant 30s pour éviter de re-scanner le FS à chaque requête. + Returns: {"racine": [{name, path_rel, dossier}, ...], "sous-dossier": [...]} Chaque groupe contient aussi une clé "stats" avec les compteurs agrégés. """ + now = time.monotonic() + if _scan_cache["data"] is not None and (now - _scan_cache["ts"]) < _SCAN_TTL: + return _scan_cache["data"] + groups: dict[str, list[dict]] = {} for json_path in sorted(STRUCTURED_DIR.rglob("*.json")): @@ -240,6 +535,8 @@ def scan_dossiers() -> dict[str, list[dict]]: "dossier": dossier, }) + _scan_cache["data"] = groups + _scan_cache["ts"] = now return groups @@ -349,7 +646,7 @@ def cma_level_badge(value: int | None) -> Markup: title = {1: "Pas CMA", 2: "CMA niveau 2", 3: "CMA niveau 3", 4: "CMA niveau 4"}.get(level, "") return Markup( f'' + f'font-size:0.75rem;font-weight:600;white-space:nowrap;color:{fg};background:{bg}">' f'CMA {label}' ) @@ -452,9 +749,152 @@ def human_where(value: str | None) -> str: return value +def _date_to_iso(date_fr: str) -> str: + """Convertit JJ/MM/AAAA → YYYY-MM-DD pour les inputs HTML date.""" + try: + parts = date_fr.strip().split("/") + if len(parts) == 3: + return f"{parts[2]}-{parts[1]}-{parts[0]}" + except Exception: + pass + return "" + + +_status_cache: dict[str, object] = {"data": None, "ts": 0.0} +_STATUS_TTL = 120 # secondes + + +def _get_system_status() -> list[dict]: + """Détecte l'état des composants du pipeline T2A (cache 120s).""" + now = time.monotonic() + if _status_cache["data"] is not None and (now - _status_cache["ts"]) < _STATUS_TTL: + return _status_cache["data"] + + from ..config import OLLAMA_URL, OLLAMA_MODELS + + components = [] + + # 1. Moteur de règles (VetoEngine) + components.append({"name": "Moteur de règles (VetoEngine)", "status": True, "detail": "Actif"}) + + # 2. LLM Ollama + ollama_ok = False + ollama_detail = "Non disponible" + try: + r = requests.get(f"{OLLAMA_URL}/api/tags", timeout=3) + if r.status_code == 200: + ollama_ok = True + models_info = ", ".join(f"{role}={model}" for role, model in OLLAMA_MODELS.items()) + ollama_detail = models_info + except Exception: + pass + components.append({"name": "LLM Ollama", "status": ollama_ok, "detail": ollama_detail}) + + # 3. Fallback Anthropic + api_key = os.environ.get("ANTHROPIC_API_KEY", "") + components.append({ + "name": "Fallback Anthropic (Haiku)", + "status": bool(api_key), + "detail": "Clé configurée" if api_key else "Clé absente", + }) + + # 4. Index FAISS (RAG) + try: + from ..medical.rag_index import check_faiss_ready + faiss_check = check_faiss_ready() + if faiss_check["ok"]: + total = faiss_check["ref"] + faiss_check["proc"] + faiss_check["bio"] + faiss_check["legacy"] + parts = [] + if faiss_check["ref"]: + parts.append(f"ref={faiss_check['ref']}") + if faiss_check["proc"]: + parts.append(f"proc={faiss_check['proc']}") + if faiss_check["bio"]: + parts.append(f"bio={faiss_check['bio']}") + detail = f"{total} vecteurs ({', '.join(parts)})" + else: + detail = "; ".join(faiss_check["errors"][:2]) + components.append({ + "name": "Index FAISS (RAG)", + "status": faiss_check["ok"], + "detail": detail, + }) + except Exception as e: + components.append({ + "name": "Index FAISS (RAG)", + "status": False, + "detail": f"Erreur vérification : {e}", + }) + + # 5. Extraction PDF + components.append({"name": "Extraction PDF (pdfplumber)", "status": True, "detail": "Actif"}) + + # 6. Anonymisation NER + ner_ok = False + try: + from transformers import AutoTokenizer + AutoTokenizer.from_pretrained("Jean-Baptiste/camembert-ner", local_files_only=True) + ner_ok = True + except Exception: + pass + components.append({ + "name": "Anonymisation NER (CamemBERT)", + "status": ner_ok, + "detail": "Modèle en cache" if ner_ok else "Modèle non trouvé", + }) + + # 7. Embeddings — vérifier le cache HuggingFace sans charger le modèle + emb_ok = False + try: + from huggingface_hub import try_to_load_from_cache + result = try_to_load_from_cache("dangvantuan/sentence-camembert-large", "config.json") + emb_ok = result is not None and isinstance(result, str) + except Exception: + pass + components.append({ + "name": "Embeddings (sentence-camembert-large)", + "status": emb_ok, + "detail": "Modèle en cache" if emb_ok else "Modèle non trouvé", + }) + + _status_cache["data"] = components + _status_cache["ts"] = now + return components + + +def _sort_qc_alerts(alerts: list[str]) -> list[str]: + """Trie les alertes QC : DP d'abord, puis critiques, puis le reste.""" + def _key(a: str) -> tuple[int, int]: + text = a.lower() + # DP en premier + dp = 0 if " dp " in text or text.startswith("dp ") or "diagnostic principal" in text else 1 + # Critiques ensuite + critical = 0 if any(k in text for k in ("high→low", "high → low", "à reconsidérer", "reconsider")) else 1 + return (dp, critical) + return sorted(alerts, key=_key) + + def create_app() -> Flask: app = Flask(__name__) + # --- Authentification HTTP Basic (optionnelle, activée via env) --- + auth = HTTPBasicAuth() + demo_user = os.environ.get("T2A_DEMO_USER", "") + demo_pass = os.environ.get("T2A_DEMO_PASS", "") + + @auth.verify_password + def verify_password(username, password): + if not demo_user: + return True # Auth désactivée si pas de user configuré + if username == demo_user and password == demo_pass: + return True + return False + + @app.before_request + def require_auth(): + if demo_user: + return auth.login_required(lambda: None)() + app.jinja_env.filters["confidence_badge"] = confidence_badge app.jinja_env.filters["confidence_label"] = confidence_label app.jinja_env.filters["severity_badge"] = severity_badge @@ -465,14 +905,46 @@ def create_app() -> Flask: app.jinja_env.filters["format_cpam_text"] = format_cpam_text app.jinja_env.filters["decision_badge"] = decision_badge app.jinja_env.filters["human_where"] = human_where + app.jinja_env.filters["date_to_iso"] = _date_to_iso + app.jinja_env.filters["sort_qc_alerts"] = _sort_qc_alerts ccam_dict = load_ccam_dict() + # Vérification FAISS au démarrage du viewer + try: + from ..medical.rag_index import check_faiss_ready + _faiss_status = check_faiss_ready() + if _faiss_status["ok"]: + total = _faiss_status["ref"] + _faiss_status["proc"] + _faiss_status["bio"] + _faiss_status["legacy"] + logger.info("FAISS OK : %d vecteurs chargés", total) + else: + for err in _faiss_status["errors"]: + logger.error("FAISS : %s", err) + except Exception as e: + logger.error("Vérification FAISS échouée : %s", e) + + ref_manager = ReferentielManager() + + @app.context_processor + def inject_dossier_list(): + """Injecte la liste des dossiers pour l'autocomplétion sidebar.""" + groups = scan_dossiers() + dossier_list = [] + for group_name, items in groups.items(): + rep = items[0] + for item in items: + if "fusionne" in item["name"]: + rep = item + break + dossier_list.append({"name": format_dossier_name(group_name), "path": rep["path_rel"]}) + return {"dossier_list": dossier_list} + @app.route("/") def index(): groups = scan_dossiers() group_stats = {name: compute_group_stats(items) for name, items in groups.items()} - return render_template("index.html", groups=groups, group_stats=group_stats) + stats = compute_dashboard_stats(groups) if groups else {} + return render_template("index.html", groups=groups, group_stats=group_stats, stats=stats) @app.route("/dossier/") def detail(filepath: str): @@ -495,13 +967,30 @@ def create_app() -> Flask: siblings=siblings, current_group=current_group, dossier_strength=dossier_strength, + groups=groups, + bio_normals=BIO_NORMALS, ) @app.route("/dashboard") def dashboard(): groups = scan_dossiers() stats = compute_dashboard_stats(groups) - return render_template("dashboard.html", stats=stats, groups=groups) + system_status = _get_system_status() + all_refs = get_builtin_referentiels() + core_refs = [r for r in all_refs if "category" not in r] + ref_refs = [r for r in all_refs if r.get("category") == "ref"] + proc_refs = [r for r in all_refs if r.get("category") == "proc"] + faiss_info = get_faiss_index_info() + return render_template("dashboard.html", stats=stats, groups=groups, + system_status=system_status, + core_refs=core_refs, ref_refs=ref_refs, proc_refs=proc_refs, + total_refs=len(all_refs), faiss_info=faiss_info) + + @app.route("/dim") + def dim_synthesis(): + groups = scan_dossiers() + dim = compute_dim_synthesis(groups) + return render_template("dim.html", dim=dim) @app.route("/cpam") def cpam_list(): @@ -509,6 +998,116 @@ def create_app() -> Flask: controls = collect_cpam_controls(groups) return render_template("cpam.html", controls=controls, total=len(controls), groups=groups) + @app.route("/api/cpam///versions") + def cpam_versions(dossier_id: str, ogc: int): + """Retourne la liste des versions précédentes d'un argumentaire.""" + # dossier_id est le path relatif du JSON ; extraire le répertoire parent + parts = Path(dossier_id).parts + if len(parts) > 1: + subdir = str(Path(*parts[:-1])) + else: + return jsonify({"versions": []}) + + versions_dir = STRUCTURED_DIR / subdir / "_cpam_versions" + if not versions_dir.is_dir(): + return jsonify({"versions": []}) + + versions = [] + for f in sorted(versions_dir.glob(f"{ogc}_*.json"), reverse=True): + try: + data = json.loads(f.read_text(encoding="utf-8")) + versions.append({ + "filename": f.name, + "version": data.get("version", 0), + "timestamp": data.get("timestamp", ""), + "quality_tier": data.get("quality_tier"), + "validation_dim": data.get("validation_dim"), + "contre_argumentation": data.get("contre_argumentation", "")[:200], + }) + except Exception: + pass + return jsonify({"versions": versions}) + + @app.route("/api/cpam///deadline", methods=["POST"]) + def cpam_deadline(dossier_id: str, ogc: int): + """Saisie manuelle de la date de notification pour un contrôle.""" + from datetime import datetime as dt, timedelta + + data = request.get_json(silent=True) or {} + date_notif = data.get("date_notification", "").strip() + if not date_notif: + return jsonify({"error": "date_notification requis (JJ/MM/AAAA)"}), 400 + + safe_path = (STRUCTURED_DIR / dossier_id).resolve() + if not safe_path.is_relative_to(STRUCTURED_DIR.resolve()): + abort(403) + if not safe_path.exists(): + abort(404) + + dossier_data = json.loads(safe_path.read_text(encoding="utf-8")) + dossier = DossierMedical.model_validate(dossier_data) + + found = False + for ctrl in dossier.controles_cpam: + if ctrl.numero_ogc == ogc: + ctrl.date_notification = date_notif + try: + notif_dt = dt.strptime(date_notif, "%d/%m/%Y") + ctrl.date_limite_reponse = (notif_dt + timedelta(days=30)).strftime("%d/%m/%Y") + except ValueError: + ctrl.date_limite_reponse = None + found = True + break + + if not found: + return jsonify({"error": f"OGC {ogc} non trouvé"}), 404 + + safe_path.write_text( + dossier.model_dump_json(indent=2, exclude_none=True), + encoding="utf-8", + ) + return jsonify({"ok": True, "date_limite": ctrl.date_limite_reponse}) + + @app.route("/api/cpam///validate", methods=["POST"]) + def cpam_validate(dossier_id: str, ogc: int): + """Valide ou rejette un argumentaire CPAM (workflow DIM).""" + from datetime import datetime + + data = request.get_json(silent=True) or {} + statut = data.get("statut", "") + if statut not in ("valide", "rejete", "en_revision", "non_valide"): + return jsonify({"error": "Statut invalide"}), 400 + + # Charger le JSON du dossier + safe_path = (STRUCTURED_DIR / dossier_id).resolve() + if not safe_path.is_relative_to(STRUCTURED_DIR.resolve()): + abort(403) + if not safe_path.exists(): + abort(404) + + dossier_data = json.loads(safe_path.read_text(encoding="utf-8")) + dossier = DossierMedical.model_validate(dossier_data) + + # Trouver le contrôle par OGC + found = False + for ctrl in dossier.controles_cpam: + if ctrl.numero_ogc == ogc: + ctrl.validation_dim = statut + ctrl.commentaire_dim = data.get("commentaire") or None + ctrl.date_validation = datetime.now().strftime("%d/%m/%Y %H:%M") + found = True + break + + if not found: + return jsonify({"error": f"OGC {ogc} non trouvé"}), 404 + + # Sauvegarder + safe_path.write_text( + dossier.model_dump_json(indent=2, exclude_none=True), + encoding="utf-8", + ) + return jsonify({"ok": True, "statut": statut}) + @app.route("/admin/models", methods=["GET"]) def list_models(): models = fetch_ollama_models() @@ -535,14 +1134,14 @@ def create_app() -> Flask: logger.info("Modèle Ollama global changé : %s", new_model) return jsonify({"ok": True, "model": cfg.OLLAMA_MODEL}) - @app.route("/reprocess/", methods=["POST"]) + @app.route("/admin/reprocess/", methods=["POST"]) def reprocess(filepath: str): """Relance le pipeline complet : process PDFs + fusion + GHM + CPAM.""" from ..main import process_pdf, write_outputs from ..medical.ghm import estimate_ghm dossier = load_dossier(filepath) - input_dir = Path(__file__).parent.parent.parent / "input" + input_dir = INPUT_DIR # Collecter les PDFs sources (fusionné → source_files, simple → source_file) source_names = [] @@ -559,10 +1158,20 @@ def create_app() -> Flask: missing = [] for name in source_names: found = None + # Essai 1 : nom exact for p in input_dir.rglob(name): if p.is_file(): found = p break + # Essai 2 : retirer le préfixe "{num}_{nip}_" ajouté par la réorg + if not found: + import re + stripped = re.sub(r"^\d+_\d+_", "", name) + if stripped != name: + for p in input_dir.rglob(stripped): + if p.is_file(): + found = p + break if found: pdf_paths.append(found) else: @@ -635,12 +1244,18 @@ def create_app() -> Flask: struct_dir = STRUCTURED_DIR / subdir struct_dir.mkdir(parents=True, exist_ok=True) merged_path = struct_dir / f"{subdir}_fusionne_cim10.json" - merged_path.write_text( - merged.model_dump_json(indent=2, exclude_none=True), - encoding="utf-8", - ) + merged_json = merged.model_dump_json(indent=2, exclude_none=True) + merged_path.write_text(merged_json, encoding="utf-8") logger.info("Dossier fusionné réécrit : %s", merged_path) + # Sync vers le répertoire du viewer si différent + viewer_dir = STRUCTURED_DIR / Path(filepath).parts[0] + if viewer_dir.resolve() != struct_dir.resolve(): + viewer_dir.mkdir(parents=True, exist_ok=True) + viewer_fusionne = viewer_dir / Path(filepath).name + viewer_fusionne.write_text(merged_json, encoding="utf-8") + logger.info("Fusionné copié vers viewer : %s", viewer_fusionne) + msg = f"Traitement terminé ({len(group_dossiers)} dossier(s)" if merged: msg += ", fusionné" @@ -654,6 +1269,44 @@ def create_app() -> Flask: logger.exception("Erreur lors du retraitement") return jsonify({"error": str(e)}), 500 + @app.route("/admin/upload-document/", methods=["POST"]) + def upload_document(filepath: str): + """Upload un PDF dans input// puis relance le retraitement.""" + if "file" not in request.files: + return jsonify({"error": "Aucun fichier fourni"}), 400 + f = request.files["file"] + if not f.filename or not f.filename.lower().endswith(".pdf"): + return jsonify({"error": "Seuls les fichiers PDF sont acceptés"}), 400 + + # Déterminer le sous-dossier input + dossier = load_dossier(filepath) + input_dir = INPUT_DIR + rel_parts = Path(filepath).parts + subdir = str(Path(*rel_parts[:-1])) if len(rel_parts) > 1 else None + target_dir = input_dir / subdir if subdir else input_dir + target_dir.mkdir(parents=True, exist_ok=True) + + # Sauvegarder le PDF + safe_name = secure_filename(f.filename) + dest = target_dir / safe_name + f.save(str(dest)) + logger.info("Document uploadé : %s", dest) + + # Relancer le retraitement via la route existante + try: + with app.test_request_context(): + resp = reprocess(filepath) + if hasattr(resp, "get_json"): + data = resp.get_json() + elif isinstance(resp, tuple): + data = resp[0].get_json() + else: + data = {"ok": True} + return jsonify({"ok": True, "message": f"PDF '{safe_name}' ajouté. {data.get('message', '')}"}) + except Exception as e: + logger.exception("Erreur après upload + reprocess") + return jsonify({"ok": True, "message": f"PDF '{safe_name}' ajouté mais erreur retraitement : {e}"}) + # ------------------------------------------------------------------ # API texte source anonymisé # ------------------------------------------------------------------ @@ -717,13 +1370,14 @@ def create_app() -> Flask: # Routes admin référentiels # ------------------------------------------------------------------ - ref_manager = ReferentielManager() - @app.route("/admin/referentiels") def admin_referentiels(): refs = ref_manager.list_all() builtin = get_builtin_referentiels() - return render_template("admin_referentiels.html", referentiels=refs, builtin_refs=builtin, max_size=UPLOAD_MAX_SIZE_MB) + faiss_info = get_faiss_index_info() + return render_template("admin_referentiels.html", + referentiels=refs, builtin_refs=builtin, + faiss_info=faiss_info, max_size=UPLOAD_MAX_SIZE_MB) @app.route("/admin/referentiels/upload", methods=["POST"]) def upload_referentiel(): diff --git a/src/viewer/referentiels.py b/src/viewer/referentiels.py index 026f24b..2e252cd 100644 --- a/src/viewer/referentiels.py +++ b/src/viewer/referentiels.py @@ -22,7 +22,8 @@ class ReferentielManager: """ def __init__(self, referentiels_dir: Path | None = None): - self._dir = referentiels_dir or REFERENTIELS_DIR + self._base = referentiels_dir or REFERENTIELS_DIR + self._dir = self._base / "user" self._dir.mkdir(parents=True, exist_ok=True) self._index_path = self._dir / "index.json" self._index: list[dict] = self._load_index() @@ -75,14 +76,14 @@ class ReferentielManager: ref_id = uuid.uuid4().hex[:12] safe_name = f"{ref_id}_{Path(filename).stem}{ext}" - file_path = self._dir / safe_name + file_path = self._dir / safe_name # user/ subdirectory file_path.write_bytes(file_data) ref = { "id": ref_id, "filename": filename, - "stored_name": safe_name, + "stored_name": f"user/{safe_name}", "extension": ext, "size_bytes": len(file_data), "date_added": datetime.now().isoformat(), @@ -105,7 +106,7 @@ class ReferentielManager: if not ref: return False - file_path = self._dir / ref["stored_name"] + file_path = self._base / ref["stored_name"] if file_path.exists(): file_path.unlink() @@ -131,7 +132,7 @@ class ReferentielManager: if not ref: raise ValueError(f"Référentiel {ref_id} introuvable") - file_path = self._dir / ref["stored_name"] + file_path = self._base / ref["stored_name"] if not file_path.exists(): raise ValueError(f"Fichier {ref['stored_name']} introuvable") diff --git a/src/viewer/templates/admin_referentiels.html b/src/viewer/templates/admin_referentiels.html index bdbce7c..0a84c04 100644 --- a/src/viewer/templates/admin_referentiels.html +++ b/src/viewer/templates/admin_referentiels.html @@ -1,22 +1,212 @@ {% extends "base.html" %} -{% block title %}Référentiels RAG{% endblock %} +{% block title %}Referentiels RAG{% endblock %} {% block sidebar %}
Admin
-Référentiels RAG +Referentiels RAG +Dashboard Retour aux dossiers {% endblock %} {% block content %} -

Référentiels RAG

-

- Ajoutez des documents de référence (PDF, CSV, Excel, TXT) pour enrichir la base de connaissances du RAG. -

+← Dashboard +

Referentiels & Index RAG

- +{# ---- Cartes FAISS synthese ---- #} +
+
+
Statut FAISS
+
+ {% if faiss_info.ok %} + Actif + {% else %} + Inactif + {% endif %} +
+
+
+
Vecteurs totaux
+
{{ "{:,}".format(faiss_info.total_vectors).replace(",", " ") }}
+
+
+
Derniere indexation
+
{{ faiss_info.last_build or '—' }}
+
+
+
Documents indexes
+
{{ builtin_refs | length }}
+
+
+ +{# ---- Detail index FAISS ---- #}
-

Ajouter un référentiel

+

Index FAISS

+

+ Detail des index vectoriels par type. Chaque index sert un role different dans le pipeline RAG. +

+ + + + + + + + + + + + + {% for idx in faiss_info.indexes %} + + + + + + + + + {% endfor %} + +
IndexRoleVecteursTailleDerniere MAJStatut
{{ idx.label }} + {% if idx.kind == 'ref' %}Codage CIM-10, index alphabetique + {% elif idx.kind == 'proc' %}Guide methodo, procedures, regles ATIH + {% elif idx.kind == 'bio' %}Normes biologiques, seuils + {% else %}Index combine (compat) + {% endif %} + {{ "{:,}".format(idx.vectors).replace(",", " ") }}{{ idx.size_mb }} Mo{{ idx.last_build or '—' }} + {% if not idx.exists %} + Absent + {% elif idx.vectors == 0 %} + Vide + {% else %} + OK + {% endif %} +
+
+ +{# ---- Referentiels integres ---- #} +
+

Referentiels integres

+

+ Sources officielles ATIH/DGOS integrees automatiquement dans l'index FAISS. + Les dates de validite indiquent la campagne tarifaire couverte. +

+ + + + + + + + + + + + + + + {% for ref in builtin_refs %} + {% if not ref.get('category') %} + + + + + + + + + + + {% endif %} + {% endfor %} + +
NomFichierEditionValiditeTailleChunksFichier duStatut
{{ ref.name }} + {{ ref.filename }} + {{ ref.edition or '—' }} + {% if ref.validite %} + {% if 'provisoire' in ref.validite %} + {{ ref.validite }} + {% else %} + {{ ref.validite }} + {% endif %} + {% else %} + + {% endif %} + {{ "%.1f"|format(ref.size_mb) }} Mo + {% if ref.chunks %} + {{ ref.chunks }} + {% else %} + + {% endif %} + {{ ref.file_date or '—' }} + {% if not ref.exists %} + Fichier absent + {% elif ref.chunks %} + Indexe + {% else %} + Dictionnaire + {% endif %} +
+
+ +{# ---- Referentiels supplementaires (ref:*.pdf indexes) ---- #} +{% set extra_refs = builtin_refs | selectattr('category', 'defined') | list %} +{% if extra_refs %} +
+

Referentiels supplementaires indexes

+

+ Documents de reference supplementaires (annexes CIM-10, consignes ATIH, manuels GHM) + automatiquement detectes et indexes dans FAISS. +

+ + + + + + + + + + + + + {% for ref in extra_refs %} + + + + + + + + + {% endfor %} + +
DocumentTypeTailleChunksFichier duStatut
{{ ref.name }} + {% if ref.category == 'proc' %} + Procedure + {% else %} + Reference + {% endif %} + {{ "%.1f"|format(ref.size_mb) }} Mo{{ ref.chunks }}{{ ref.file_date or '—' }} + {% if ref.exists %} + Indexe + {% elif ref.chunks %} + Indexe (PDF supprime) + {% else %} + Non indexe + {% endif %} +
+
+{% endif %} + +{# ---- Zone upload ---- #} +
+

Ajouter un referentiel

+

+ Uploadez un document de reference (PDF, CSV, Excel, TXT) pour enrichir la base RAG. + Le document sera indexe dans FAISS et utilisable immediatement pour le codage. +

@@ -34,59 +224,13 @@

- +{# ---- Referentiels utilisateur ---- #}
-

Référentiels intégrés

-

- Sources intégrées automatiquement dans l'index FAISS au build. -

- - - - - - - - - - - - - {% for ref in builtin_refs %} - - - - - - - - - {% endfor %} - -
NomFichierTypeTailleChunksStatut
{{ ref.name }}{{ ref.filename }}{{ ref.extension }}{{ "%.1f"|format(ref.size_mb) }} Mo - {% if ref.chunks %} - {{ ref.chunks }} - {% else %} - - {% endif %} - - {% if not ref.exists %} - Fichier absent - {% elif ref.chunks %} - Indexé - {% else %} - Dictionnaire - {% endif %} -
-
- - -
-

Référentiels utilisateur

+

Referentiels utilisateur

@@ -96,7 +240,7 @@ Nom Type Taille - Date + Date ajout Chunks Statut Actions @@ -112,11 +256,11 @@ {{ ref.chunks_count }} {% if ref.status == 'indexed' %} - Indexé + Indexe {% elif ref.status == 'empty' %} Vide {% else %} - Uploadé + Uploade {% endif %} @@ -133,72 +277,127 @@ {% endfor %} {% if not referentiels %} - Aucun référentiel + Aucun referentiel utilisateur {% endif %}
+{# ---- Aide referentiels ATIH ---- #} +
+

Referentiels ATIH — Mises a jour

+

+ Les referentiels T2A sont publies par l'ATIH selon le calendrier suivant : +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ReferentielFrequencePublicationSource
CIM-10 FR a usage PMSIAnnuelleDecembre N-1 (provisoire), Mars N (definitif)atih.sante.fr
Guide methodologique MCOAnnuelleDecembre N-1 (provisoire), Mars N (definitif)atih.sante.fr
CCAM descriptive a usage PMSI~2/anMars et Septembreatih.sante.fr
Manuel des GHMAnnuelleMarsatih.sante.fr
Consignes de codage (COVID, sepsis...)VariableAu fil des publications ATIH/DGOSatih.sante.fr/consignes
Instructions controle T2A (campagnes)AnnuelleAvril-Mai (debut campagne)ameli.fr
+

+ Pour mettre a jour : uploadez le nouveau PDF ci-dessus, puis cliquez "Indexer". + Reconstruisez l'index FAISS complet apres mise a jour des referentiels principaux (CIM-10, guide methodo). +

+
+
{% endblock %} {% block scripts %} diff --git a/src/viewer/templates/cpam.html b/src/viewer/templates/cpam.html index 145aa12..e1d3d88 100644 --- a/src/viewer/templates/cpam.html +++ b/src/viewer/templates/cpam.html @@ -1,48 +1,52 @@ {% extends "base.html" %} -{% block title %}Contrôles CPAM{% endblock %} - -{% block sidebar %} -{% for group_name, items in groups.items() %} -
{{ group_name | format_dossier_name }}
- {% for item in items %} - {% if 'fusionne' in item.name %} - ★ Fusionné - {% else %} - {{ item.name | format_doc_name }} - {% endif %} - {% endfor %} -{% endfor %} -{% endblock %} +{% block title %}Contrôles UCR{% endblock %} {% block content %} ← Retour à la liste
-

Contrôles CPAM

+

Contrôles UCR

{{ total }}
{% if not controls %}
-

Aucun contrôle CPAM trouvé dans les dossiers.

+

Aucun contrôle UCR trouvé dans les dossiers.

{% else %}
+ - + + + {% for c in controls %} + - + + + {% if c.ctrl.contre_argumentation %} + + + + {% endif %} {% endfor %}
Priorité Dossier OGC Qualité TitreDécisionDécision UCR Codes contestésDélaiValidation Contre-argumentation
+ {% set fi = c.ctrl.financial_impact %} + {% if fi and fi.priorite == 'critique' %} + Critique +
~{{ fi.impact_estime_euros }}€
+ {% elif fi and fi.priorite == 'haute' %} + Haute +
~{{ fi.impact_estime_euros }}€
+ {% elif fi and fi.priorite == 'faible' %} + Faible + {% else %} + Normale + {% endif %} +
{{ c.group_name | format_dossier_name }} @@ -79,22 +83,105 @@ {% if c.ctrl.da_ucr %}DA: {{ c.ctrl.da_ucr }}{% endif %} {% if c.ctrl.dr_ucr %}DR: {{ c.ctrl.dr_ucr }}{% endif %} {% if c.ctrl.actes_ucr %}Actes: {{ c.ctrl.actes_ucr }}{% endif %} + {% if not c.ctrl.dp_ucr and not c.ctrl.da_ucr and not c.ctrl.dr_ucr and not c.ctrl.actes_ucr %} + {% if c.ctrl.contre_argumentation %} + + {% else %} + + {% endif %} + {% endif %} + + {% if c.jours_restants is not none %} + {% if c.jours_restants < 0 %} + Hors délai + {% elif c.jours_restants < 7 %} + {{ c.jours_restants }}j + {% elif c.jours_restants < 15 %} + {{ c.jours_restants }}j + {% else %} + {{ c.jours_restants }}j + {% endif %} + {% if c.ctrl.date_limite_reponse %} +
{{ c.ctrl.date_limite_reponse }}
+ {% endif %} + {% else %} + + {% endif %} +
+ {% if c.ctrl.validation_dim == 'valide' %} + Validé + {% elif c.ctrl.validation_dim == 'rejete' %} + Rejeté + {% elif c.ctrl.validation_dim == 'en_revision' %} + En révision + {% else %} + + {% endif %} + {% if c.ctrl.contre_argumentation %} -
- {{ c.ctrl.contre_argumentation[:80] }}{% if c.ctrl.contre_argumentation|length > 80 %}…{% endif %} -
{{ c.ctrl.contre_argumentation }}
-
+ {% else %} {% endif %}
{% endif %} {% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/src/viewer/templates/dashboard.html b/src/viewer/templates/dashboard.html index c80c729..f5d3fa0 100644 --- a/src/viewer/templates/dashboard.html +++ b/src/viewer/templates/dashboard.html @@ -1,18 +1,6 @@ {% extends "base.html" %} {% block title %}Dashboard{% endblock %} -{% block sidebar %} -{% for group_name, items in groups.items() %} -
{{ group_name | format_dossier_name }}
- {% for item in items %} - {% if 'fusionne' in item.name %} - ★ Fusionné - {% else %} - {{ item.name | format_doc_name }} - {% endif %} - {% endfor %} -{% endfor %} -{% endblock %} {% block content %} ← Retour à la liste @@ -142,4 +130,106 @@
{% endif %} +{# ---- État du système ---- #} +{% if system_status %} +
+

État du système

+
+ {% for comp in system_status %} +
+ +
+
{{ comp.name }}
+
{{ comp.detail }}
+
+
+ {% endfor %} +
+
+{% endif %} + +{# ---- Référentiels ---- #} +{% if core_refs or ref_refs or proc_refs %} +
+

Référentiels ({{ total_refs }} documents)

+ {% if faiss_info %} +
+ Index FAISS + {{ faiss_info.total_vectors }} vecteurs + {% for idx in faiss_info.indexes %} + {% if idx.exists %} + {{ idx.label }}: {{ idx.vectors }} ({{ idx.size_mb }} Mo) + {% endif %} + {% endfor %} + {% if faiss_info.last_build %} + MAJ : {{ faiss_info.last_build }} + {% endif %} +
+ {% endif %} + + + + + + + + + + + + + {# --- Groupe : Base de données --- #} + + {% for ref in core_refs %} + + + + + + + + + {% endfor %} + {# --- Groupe : Fascicules & Consignes CIM-10 --- #} + {% if ref_refs %} + + {% for ref in ref_refs %} + + + + + + + + + {% endfor %} + {% endif %} + {# --- Groupe : Guides & Procédures --- #} + {% if proc_refs %} + + {% for ref in proc_refs %} + + + + + + + + + {% endfor %} + {% endif %} + +
DocumentDateValiditéTailleChunksStatut
Base de données
{{ ref.name }}{{ ref.edition or ref.file_date or '-' }}{{ ref.validite or '-' }}{{ '%.1f Mo' | format(ref.size_mb) if ref.size_mb else '-' }}{{ ref.chunks if ref.chunks else '-' }} + {% if ref.exists %} + {% else %}{% endif %} +
Fascicules, consignes & référentiels CIM-10
{{ ref.name }}{{ ref.file_date or '-' }}-{{ '%.1f Mo' | format(ref.size_mb) if ref.size_mb else '-' }}{{ ref.chunks if ref.chunks else '-' }} + {% if ref.exists %} + {% else %}{% endif %} +
Guides & procédures
{{ ref.name }}{{ ref.file_date or '-' }}-{{ '%.1f Mo' | format(ref.size_mb) if ref.size_mb else '-' }}{{ ref.chunks if ref.chunks else '-' }} + {% if ref.exists %} + {% else %}{% endif %} +
+
+{% endif %} + {% endblock %} diff --git a/src/viewer/templates/detail.html b/src/viewer/templates/detail.html index 9d8de63..41f484a 100644 --- a/src/viewer/templates/detail.html +++ b/src/viewer/templates/detail.html @@ -1,459 +1,262 @@ {% extends "base.html" %} {% block title %}{{ dossier.source_file or filepath }}{% endblock %} -{% block sidebar %} -
Navigation
-Retour à la liste -{% if siblings %} -
{{ current_group }}
-{% for sib in siblings %} - - {{ sib.name }} - -{% endfor %} -{% endif %} -
Actions
- -
-{% endblock %} - {% block content %} ← Retour à la liste -{# ---- En-tête ---- #} -
-

{{ dossier.source_file or filepath }}

-
+{# ==================================================================== #} +{# 1. BANDEAU PATIENT — Identité + Séjour + Codage DP + GHM + Score #} +{# ==================================================================== #} +{% set s = dossier.sejour %} +{% set dp = dossier.diagnostic_principal %} +{% set ghm = dossier.ghm_estimation %} +{% set vr = dossier.veto_report %} + +
+ {# Titre patient #} +
+

{{ current_group | format_dossier_name if current_group else (dossier.source_file or filepath) }}

{% if dossier.document_type %} -
- {{ dossier.document_type }} -
+ {% endif %} + {% if dossier.source_files %} + fusionné {% endif %} {% if dossier.processing_time_s is not none %} -
- - {{ dossier.processing_time_s|format_duration }} -
+ {{ dossier.processing_time_s|format_duration }} {% endif %}
- {% if dossier.source_files %} -
- -
- {% for sf in dossier.source_files %} - {{ sf }}{% if not loop.last %}, {% endif %} - {% endfor %} -
-
- {% endif %} -
-{# ---- Synthèse Expert (Refonte) ---- #} -
- - {# 1. Sécurité & Conformité (Vetos) #} -
-

🛡️ Sécurité & Conformité

-
- {% if dossier.veto_report and dossier.veto_report.issues %} - {% for issue in dossier.veto_report.issues if issue.severity in ['HARD', 'MEDIUM'] %} -
- [{{ issue.severity|replace('HARD', 'Bloquant')|replace('MEDIUM', 'À vérifier') }}] {{ issue.message }} - {% if issue.citation %}
ATIH: {{ issue.citation }}{% endif %} -
- {% endfor %} - {% else %} -
✅ Aucune anomalie majeure détectée.
- {% endif %} -
-
- - {# 2. Optimisation de la Recette (CMA) #} -
-

💰 Valorisation (CMA)

-
- {% set cma_alerts = [] %} - {% for alerte in dossier.alertes_codage if alerte.startswith('CMA') %}{% set _ = cma_alerts.append(alerte) %}{% endfor %} - {% if cma_alerts %} -
    - {% for alerte in cma_alerts %} -
  • {{ alerte }}
  • - {% endfor %} -
- {% else %} -
Aucune comorbidité (CMA) détectée.
- {% endif %} -
-
- - {# 3. Audit & Analyse IA (QC) #} -
-

🔍 Audit de l'Expert IA

-
- {% set qc_alerts = [] %} - {% for alerte in dossier.alertes_codage if alerte.startswith('QC:') %}{% set _ = qc_alerts.append(alerte) %}{% endfor %} - {% if qc_alerts %} - {% for alerte in qc_alerts %} -
- {{ alerte|replace('QC: ', '') }} -
- {% endfor %} - {% else %} -
Aucune recommandation particulière.
- {% endif %} -
-
- -
- -{# ---- Séjour ---- #} -{% set s = dossier.sejour %} -{% if s.sexe or s.age or s.date_entree or s.date_sortie or s.duree_sejour is not none or s.imc or s.poids or s.taille %} -
-

Séjour

-
- {% if s.sexe %}
{{ s.sexe }}
{% endif %} - {% if s.age is not none %}
{{ s.age }} ans
{% endif %} - {% if s.date_entree %}
{{ s.date_entree }}
{% endif %} - {% if s.date_sortie %}
{{ s.date_sortie }}
{% endif %} - {% if s.duree_sejour is not none %}
{{ s.duree_sejour }} jour(s)
{% endif %} - {% if s.mode_entree %}
{{ s.mode_entree }}
{% endif %} - {% if s.mode_sortie %}
{{ s.mode_sortie }}
{% endif %} - {% if s.poids %}
{{ s.poids }} kg
{% endif %} - {% if s.taille %}
{{ s.taille }} cm
{% endif %} - {% if s.imc %}
{{ s.imc }}
{% endif %} -
-
-{% endif %} - -{# ---- Estimation GHM ---- #} -{% if dossier.ghm_estimation %} -{% set ghm = dossier.ghm_estimation %} -
-

Estimation GHM

-
- {% if ghm.cmd %} -
- - {{ ghm.cmd }}{% if ghm.cmd_libelle %} — {{ ghm.cmd_libelle }}{% endif %} -
- {% endif %} -
- - {% if ghm.type_ghm == 'C' %} - C — Chirurgical - {% elif ghm.type_ghm == 'K' %} - K — Interventionnel - {% elif ghm.type_ghm == 'M' %} - M — Médical - {% endif %} -
-
- - {% if ghm.severite <= 1 %} - Niveau {{ ghm.severite }} - {% elif ghm.severite == 2 %} - Niveau {{ ghm.severite }} - {% elif ghm.severite == 3 %} - Niveau {{ ghm.severite }} - {% else %} - Niveau {{ ghm.severite }} - {% endif %} -
- {% if ghm.ghm_approx %} -
- - {{ ghm.ghm_approx }} -
- {% endif %} -
- - {{ ghm.cma_count }} CMA, {{ ghm.cms_count }} CMS -
-
- {% if ghm.alertes %} -
- {% for alerte in ghm.alertes %} -
{{ alerte }}
- {% endfor %} -
- {% endif %} -
- Estimation heuristique — le GHM définitif nécessite le groupeur officiel ATIH -
-
-{% endif %} - -{# ---- Contrôle CPAM ---- #} -{% if dossier.controles_cpam %} -
-

Contrôle CPAM ({{ dossier.controles_cpam|length }})

- {% if dossier_strength and dossier_strength.is_weak %} -
- Dossier à preuves limitées (score {{ dossier_strength.score }}/10) — manque : {{ dossier_strength.missing|join(', ') }} -
- {% endif %} - {% for ctrl in dossier.controles_cpam %} -
-
- OGC {{ ctrl.numero_ogc }} — {{ ctrl.titre }} - {% if 'retient' in ctrl.decision_ucr|lower %} - {{ ctrl.decision_ucr }} - {% elif 'confirme' in ctrl.decision_ucr|lower %} - {{ ctrl.decision_ucr }} - {% else %} - {{ ctrl.decision_ucr }} - {% endif %} - {% if ctrl.quality_tier == 'A' %} - Qualité A - {% elif ctrl.quality_tier == 'B' %} - Qualité B - {% elif ctrl.quality_tier == 'C' %} - Qualité C - {% endif %} -
- - {# Argument CPAM #} - {% if ctrl.arg_ucr %} -
-
Argument CPAM
- {{ ctrl.arg_ucr }} -
- {% endif %} - - {# Codes contestés #} - {% if ctrl.dp_ucr or ctrl.da_ucr or ctrl.dr_ucr or ctrl.actes_ucr %} -
-
Codes contestés
-
- {% if ctrl.dp_ucr %}DP: {{ ctrl.dp_ucr }}{% endif %} - {% if ctrl.da_ucr %}DA: {{ ctrl.da_ucr }}{% endif %} - {% if ctrl.dr_ucr %}DR: {{ ctrl.dr_ucr }}{% endif %} - {% if ctrl.actes_ucr %}Actes: {{ ctrl.actes_ucr }}{% endif %} +
+ {# Colonne gauche — Identité et séjour #} +
+
Patient & Séjour
+
+ {% if s.sexe %}
{{ s.sexe }}
{% endif %} + {% if s.age is not none %}
{{ s.age }} ans
{% endif %} + {% if s.date_entree %}
{{ s.date_entree }}
{% endif %} + {% if s.date_sortie %}
{{ s.date_sortie }}
{% endif %} + {% if s.duree_sejour is not none %}
{{ s.duree_sejour }} j
{% endif %} + {% if s.mode_entree %}
{{ s.mode_entree }}
{% endif %} + {% if s.mode_sortie %}
{{ s.mode_sortie }}
{% endif %} + {% if s.poids %}
{{ s.poids }} kg
{% endif %} + {% if s.taille %}
{{ s.taille }} cm
{% endif %} + {% if s.imc %}
{{ s.imc }}
{% endif %}
- {% endif %} - {# Bandeau revue manuelle si Tier C #} - {% if ctrl.requires_review %} -
- ⚠ Revue manuelle requise — la contre-argumentation contient des incohérences détectées -
- {% endif %} + {# Colonne droite — Codage #} +
+
Codage
- {# Contre-argumentation structurée ou fallback texte brut #} - {% if ctrl.response_data %} -
-
Contre-argumentation
- - {% if ctrl.response_data.analyse_contestation %} -
-
Analyse de la contestation
- {{ ctrl.response_data.analyse_contestation | format_cpam_text }} -
- {% endif %} - - {% if ctrl.response_data.points_accord and ctrl.response_data.points_accord|lower not in ['aucun', 'non applicable', 'n/a', ''] %} -
-
Points d'accord
- {{ ctrl.response_data.points_accord | format_cpam_text }} -
- {% endif %} - - {% if ctrl.response_data.contre_arguments_medicaux %} -
-
Contre-arguments médicaux
- {{ ctrl.response_data.contre_arguments_medicaux | format_cpam_text }} -
- {% endif %} - - {% if ctrl.response_data.preuves_dossier %} -
-
Preuves du dossier
-
    - {% for p in ctrl.response_data.preuves_dossier %} - {% if p is mapping %} -
  • - {{ p.element or p.get('type', '') }} - {{ p.valeur or '' }} → {{ p.signification or '' }} -
  • + {# DP en gros #} + {% if dp %} +
    +
    DIAGNOSTIC PRINCIPAL
    +
    + {% if dp.cim10_suggestion %} + {% if dp.cim10_final and dp.cim10_final != dp.cim10_suggestion %} + {{ dp.cim10_suggestion }} + + {{ dp.cim10_final }} + {% else %} + {{ dp.cim10_suggestion }} + {% endif %} + {{ dp.cim10_confidence | confidence_badge }} {% endif %} - {% endfor %} -
+ {% if dp.niveau_cma and dp.niveau_cma > 1 %}{{ dp.niveau_cma | cma_level_badge }}{% endif %} + {{ dp.niveau_severite | severity_badge }} +
+
+ {% if dp.status == 'ruled_out' %}{{ dp.texte }}{% else %}{{ dp.texte }}{% endif %} + {% if dp.source_page %}{% endif %} +
{% endif %} - {% if ctrl.response_data.contre_arguments_asymetrie %} -
-
Asymétrie d'information
- {{ ctrl.response_data.contre_arguments_asymetrie | format_cpam_text }} -
- {% endif %} - - {% if ctrl.response_data.contre_arguments_reglementaires %} -
-
Contre-arguments réglementaires
- {{ ctrl.response_data.contre_arguments_reglementaires | format_cpam_text }} -
- {% endif %} - - {% if ctrl.response_data.references %} -
-
Références
- {% for ref in ctrl.response_data.references %} - {% if ref is mapping %} -
- [{{ ref.document or '' }}{% if ref.page %}, p.{{ ref.page }}{% endif %}] - {{ ref.citation or '' }} -
- {% elif ref is string %} -

{{ ref }}

+ {# GHM estimé #} + {% if ghm %} +
+
GHM ESTIMÉ
+
+ {% if ghm.ghm_approx %} + {{ ghm.ghm_approx }} {% endif %} - {% endfor %} -
- {% endif %} - - {% if ctrl.response_data.conclusion %} -
-
Conclusion
- {{ ctrl.response_data.conclusion | format_cpam_text }} -
- {% endif %} -
- {% elif ctrl.contre_argumentation %} -
-
Contre-argumentation
-
{{ ctrl.contre_argumentation }}
-
- {% endif %} - - {# Sources RAG #} - {% if ctrl.sources_reponse %} -
- Sources RAG ({{ ctrl.sources_reponse|length }}) - {% for src in ctrl.sources_reponse %} -
{{ src.document }}{% if src.code %} — {{ src.code }}{% endif %}{% if src.page %} [p.{{ src.page }}]{% endif %}
-{{ src.extrait or '' }}
- {% endfor %} -
- {% endif %} - - {# Avertissements qualité #} - {% if ctrl.quality_warnings %} -
- Avertissements qualité ({{ ctrl.quality_warnings|length }}) -
    - {% for w in ctrl.quality_warnings %} - {% if w.startswith('[CRITIQUE]') %} -
  • {{ w }}
  • + {% if ghm.type_ghm == 'C' %} + C — Chirurgical + {% elif ghm.type_ghm == 'K' %} + K — Interventionnel + {% elif ghm.type_ghm == 'M' %} + M — Médical + {% endif %} + {% if ghm.severite <= 1 %} + Niv. {{ ghm.severite }} + {% elif ghm.severite == 2 %} + Niv. {{ ghm.severite }} + {% elif ghm.severite == 3 %} + Niv. {{ ghm.severite }} {% else %} -
  • {{ w }}
  • + Niv. {{ ghm.severite }} {% endif %} - {% endfor %} -
-
+ {{ ghm.cma_count }} CMA, {{ ghm.cms_count }} CMS +
+
+ {% endif %} + + {# Score de contestabilité #} + {% if vr %} +
+
CONTESTABILITÉ
+ {% if vr.verdict == 'PASS' %}{% set vr_color = '#22c55e' %} + {% elif vr.verdict == 'NEED_INFO' %}{% set vr_color = '#f59e0b' %} + {% else %}{% set vr_color = '#ef4444' %}{% endif %} +
+ {% if vr.verdict == 'PASS' %} + CONFORME + {% elif vr.verdict == 'NEED_INFO' %} + À COMPLÉTER + {% else %} + NON CONFORME + {% endif %} +
+
+
+ {{ vr.score_contestabilite }}/100 +
+
+ {% endif %} +
+
+
+ +{# Actions (compact, sous le bandeau) #} +
+ + + + +
+ +{# ==================================================================== #} +{# 1b. COMPLÉTUDE DOCUMENTAIRE DIM #} +{# ==================================================================== #} +{% set compl = dossier.completude %} +{% if compl and compl.checks %} +
+
+

Complétude Documentaire DIM

+ {% if compl.verdict_global == 'defendable' %} + DÉFENDABLE + {% set compl_color = '#22c55e' %} + {% elif compl.verdict_global == 'fragile' %} + FRAGILE + {% set compl_color = '#f59e0b' %} + {% else %} + INDÉFENDABLE + {% set compl_color = '#ef4444' %} {% endif %} +
+
+
+
+ {{ compl.score_global }}/100 +
+
+ + {% if compl.documents_manquants %} +
+ Documents manquants : {{ compl.documents_manquants | join(', ') }} +
+ {% endif %} + + {% for check in compl.checks %} +
+
+ {{ check.code }} + {{ check.libelle | truncate(60) }} + {{ check.type_diag }} + {% if check.verdict == 'defendable' %} + ✓ {{ check.resume }} + {% elif check.verdict == 'fragile' %} + ⚠ {{ check.resume }} + {% else %} + ✗ {{ check.resume }} + {% endif %} +
+
+ {% for item in check.items %} + {% if item.statut == 'present_confirme' %} + + ✓ {{ item.element }}{% if item.valeur %} ({{ item.valeur | truncate(20) }}){% endif %} + {% if item.confirmation_detail %} — confirmé{% endif %} + + {% elif item.statut == 'present_non_confirme' %} + + ⚠ {{ item.element }}{% if item.valeur %} ({{ item.valeur | truncate(20) }}){% endif %} + {% if item.confirmation_detail %} — seuil non atteint{% endif %} + + {% elif item.statut == 'present_indirect' %} + + ~ {{ item.element }}{% if item.valeur %} ({{ item.valeur | truncate(25) }}){% endif %} + — preuve clinique + + {% elif item.statut == 'present' %} + + ✓ {{ item.element }}{% if item.valeur %} ({{ item.valeur | truncate(20) }}){% endif %} + + {% elif item.importance == 'obligatoire' %} + + ✗ {{ item.element }} (obligatoire) + + {% else %} + + — {{ item.element }} (recommandé) + + {% endif %} + {% endfor %} +
{% endfor %} -
-{% endif %} -{# ---- Contestabilité (VetoReport) ---- #} -{% if dossier.veto_report %} -{% set vr = dossier.veto_report %} -{% if vr.verdict == 'PASS' %} - {% set vr_color = '#22c55e' %} -{% elif vr.verdict == 'NEED_INFO' %} - {% set vr_color = '#f59e0b' %} -{% else %} - {% set vr_color = '#ef4444' %} -{% endif %} -
-

Contestabilité du dossier (Qualité PMSI)

-
- {% if vr.verdict == 'PASS' %} - CONFORME - {% elif vr.verdict == 'NEED_INFO' %} - À COMPLÉTER - {% else %} - NON CONFORME - {% endif %} -
-
-
- Score : {{ vr.score_contestabilite }}/100 + {% if compl.documents_presents %} +
+ Documents présents : {{ compl.documents_presents | join(', ') }}
- {% if vr.issues %} -
- Détail des anomalies détectées ({{ vr.issues|length }}) - - - - {% for issue in vr.issues %} - - - - - - - - {% endfor %} - -
Code RègleSévéritéLocalisationMessage d'alerteRéférence ATIH
{{ issue.veto }} - {% if issue.severity == 'HARD' %}Bloquant - {% elif issue.severity == 'MEDIUM' %}À vérifier - {% else %}Optimisation{% endif %} - {{ issue.where|human_where }}{{ issue.message }}{{ issue.citation or '—' }}
-
{% endif %}
{% endif %} -{# ---- Diagnostic principal ---- #} -{% if dossier.diagnostic_principal %} -{% set dp = dossier.diagnostic_principal %} -
-

Diagnostic principal

-
- {% if dp.status == 'ruled_out' %}{{ dp.texte }}{% else %}{{ dp.texte }}{% endif %} - {% if dp.source_page %}{% endif %} -
- {% if dp.cim10_suggestion %} - {% if dp.cim10_final and dp.cim10_final != dp.cim10_suggestion %} - {{ dp.cim10_suggestion }} - - {{ dp.cim10_final }} - {% elif dp.status == 'ruled_out' %} - {{ dp.cim10_suggestion }} - {% else %} - {{ dp.cim10_suggestion }} - {% endif %} - {{ dp.cim10_confidence | confidence_badge }} - {% if dp.niveau_cma and dp.niveau_cma > 1 %} - {{ dp.niveau_cma | cma_level_badge }} - {% elif dp.est_cma %} - CMA - {% endif %} - {{ dp.niveau_severite | severity_badge }} - {% if dp.cim10_decision and dp.cim10_decision.action != 'KEEP' %} +{# ==================================================================== #} +{# 2. DAS + ACTES CCAM #} +{# ==================================================================== #} + +{# ---- Diagnostic principal (détail) ---- #} +{% set dp_has_detail = dp and (dp.justification or dp.preuves_cliniques or dp.raisonnement or dp.sources_rag or (dp.cim10_decision and dp.cim10_decision.action != 'KEEP') or dp.status == 'ruled_out') %} +{% if dp_has_detail %} +
+

Diagnostic principal — Détail

+ {% if dp.status == 'ruled_out' and dp.ruled_out_reason %} +
{{ dp.ruled_out_reason }}
+ {% endif %} + {% if dp.cim10_decision and dp.cim10_decision.action != 'KEEP' %} +
{{ dp.cim10_decision | decision_badge }} {% for rule in dp.cim10_decision.applied_rules %} {{ rule }} {% endfor %} - {% endif %} - {% endif %} - {% if dp.status == 'ruled_out' and dp.ruled_out_reason %} -
{{ dp.ruled_out_reason }}
- {% endif %} - {% if dp.cim10_decision and dp.cim10_decision.action != 'KEEP' and dp.cim10_decision.reason %} -
{{ dp.cim10_decision.reason }}
+ {% if dp.cim10_decision.reason %} +
{{ dp.cim10_decision.reason }}
+ {% endif %} +
{% endif %} {% if dp.justification %} -
{{ dp.justification }}
+
{{ dp.justification }}
{% endif %} {% if dp.preuves_cliniques %} -
+
Preuves cliniques ({{ dp.preuves_cliniques|length }})
    {% for p in dp.preuves_cliniques %} @@ -478,6 +281,11 @@
{% endif %}
+{% elif dp and not dp_has_detail and dp.source == 'trackare' %} +
+

Diagnostic principal — Détail

+
Codage issu de Trackare — pas de détail IA disponible.
+
{% endif %} {# ---- Diagnostics associés ---- #} @@ -485,11 +293,10 @@

Diagnostics associés ({{ dossier.diagnostics_associes|length }})

- + {% for das in dossier.diagnostics_associes %} - - - - + + + {% if das.status == 'ruled_out' and das.ruled_out_reason %} - - - + {% endif %} {% if das.cim10_decision and das.cim10_decision.action != 'KEEP' and das.cim10_decision.reason and das.status != 'ruled_out' %} - - - + {% endif %} {% if das.raisonnement %} - - - + {% endif %} {% if das.sources_rag %} - - - + {% endif %} {% endfor %} @@ -604,27 +394,23 @@

Actes CCAM ({{ dossier.actes_ccam|length }})

TexteCIM-10ConfianceCMASourceJustification
Code CIM-10LibelléCMAConfianceSource
{{ das.texte }} {% if das.cim10_suggestion %} {% if das.cim10_final and das.cim10_final != das.cim10_suggestion %} @@ -525,26 +332,11 @@ {% endif %} {% endif %} {{ das.cim10_confidence | confidence_badge }} - {% if das.niveau_cma and das.niveau_cma > 1 %} - {{ das.niveau_cma | cma_level_badge }} - {% elif das.est_cma %} - CMA - {% else %} - — + {{ das.texte }} + {% if das.justification %} +
{{ das.justification }}
{% endif %} -
- {% if das.source %} - {{ das.source }} - {% endif %} - {% if das.source_page %} - - {% endif %} - - {{ das.justification or '' }} {% if das.preuves_cliniques %}
preuves ({{ das.preuves_cliniques|length }})
    @@ -555,43 +347,41 @@
{% endif %}
+ {% if das.niveau_cma and das.niveau_cma > 1 %} + {{ das.niveau_cma | cma_level_badge }} + {% elif das.est_cma %} + CMA + {% else %} + — + {% endif %} + {{ das.cim10_confidence | confidence_badge }} + {% if das.source %} + {{ das.source }} + {% endif %} + {% if das.source_page %} + + {% endif %} +
-
{{ das.ruled_out_reason }}
-
{{ das.ruled_out_reason }}
-
{{ das.cim10_decision.reason }}
-
{{ das.cim10_decision.reason }}
-
- Raisonnement LLM -
{{ das.raisonnement }}
-
-
Raisonnement LLM
{{ das.raisonnement }}
-
- Sources RAG ({{ das.sources_rag|length }}) - {% for src in das.sources_rag %} -
{{ src.document }}{% if src.code %} — {{ src.code }}{% endif %}{% if src.page %} [p.{{ src.page }}]{% endif %}
-{{ src.extrait or '' }}
- {% endfor %} -
-
+
Sources RAG ({{ das.sources_rag|length }}) + {% for src in das.sources_rag %}
{{ src.document }}{% if src.code %} — {{ src.code }}{% endif %}{% if src.page %} [p.{{ src.page }}]{% endif %}
+{{ src.extrait or '' }}
{% endfor %} +
+
- + {% for a in dossier.actes_ccam %} - + @@ -634,10 +420,322 @@ {% endif %} +{# ==================================================================== #} +{# 3. CONTRÔLE QUALITÉ CODAGE (section repliable) #} +{# ==================================================================== #} +
+

Contrôle Qualité Codage

+ +
+ + {# Anomalies de codage #} +
+

Anomalies de codage

+
+ {% if dossier.veto_report and dossier.veto_report.issues %} + {% for issue in dossier.veto_report.issues if issue.severity in ['HARD', 'MEDIUM'] %} +
+ [{{ issue.severity|replace('HARD', 'Bloquant')|replace('MEDIUM', 'À vérifier') }}] {{ issue.message }} + {% if issue.citation %}
ATIH: {{ issue.citation }}{% endif %} +
+ {% endfor %} + {% else %} +
Aucune anomalie majeure détectée.
+ {% endif %} +
+
+ + {# Valorisation CMA #} +
+

Valorisation (CMA)

+
+ {% set cma_alerts = [] %} + {% for alerte in dossier.alertes_codage if alerte.startswith('CMA') %}{% set _ = cma_alerts.append(alerte) %}{% endfor %} + {% if cma_alerts %} +
    + {% for alerte in cma_alerts %}
  • {{ alerte }}
  • {% endfor %} +
+ {% else %} +
Aucune comorbidité (CMA) détectée.
+ {% endif %} +
+
+ + {# Audit IA (QC) #} +
+

Audit de l'Expert IA

+
+ {% set qc_alerts = [] %} + {% for alerte in dossier.alertes_codage if alerte.startswith('QC:') %}{% set _ = qc_alerts.append(alerte) %}{% endfor %} + {% if qc_alerts %} + {% for alerte in qc_alerts | sort_qc_alerts %} +
+ {{ alerte|replace('QC: ', '') }} +
+ {% endfor %} + {% else %} +
Aucune recommandation particulière.
+ {% endif %} +
+
+
+ + {# Détail Contestabilité (VetoReport) #} + {% if vr and vr.issues %} +
+ Détail des contrôles qualité ({{ vr.issues|length }}) +
TexteCode CCAMRegroupementDateValiditéSource
Code CCAMLibelléRegroupementDateValiditéSource
{{ a.texte }} {% if a.code_ccam_suggestion %}{{ a.code_ccam_suggestion }}{% endif %}{{ a.texte }} {% if a.code_ccam_suggestion and ccam_dict.get(a.code_ccam_suggestion, {}).get('regroupement') %} {{ ccam_dict[a.code_ccam_suggestion]['regroupement'] }} - {% else %} - — - {% endif %} + {% else %}—{% endif %} {{ a.date or '' }} {% if a.validite == 'valide' %}Valide {% elif a.validite == 'obsolete' %}Obsolète {% else %}—{% endif %} - {% for alerte in a.alertes %} -
{{ alerte }}
- {% endfor %} + {% for alerte in a.alertes %}
{{ alerte }}
{% endfor %}
{% if a.source_page %}{% endif %}
+ + + {% for issue in vr.issues %} + + + + + + + + {% endfor %} + +
RègleSévéritéLocalisationMessage d'alerteSource / Référence
{{ issue.veto }} + {% if issue.severity == 'HARD' %}Bloquant + {% elif issue.severity == 'MEDIUM' %}À vérifier + {% else %}Optimisation{% endif %} + {{ issue.where|human_where }}{{ issue.message }}{{ issue.citation or '—' }}
+ + {% endif %} + + {# GHM alertes #} + {% if ghm and ghm.alertes %} +
+ {% for alerte in ghm.alertes %} +
{{ alerte }}
+ {% endfor %} +
+ {% endif %} + + + +{# ==================================================================== #} +{# 4. CONTRÔLE CPAM (si applicable) #} +{# ==================================================================== #} +{% if dossier.controles_cpam %} +
+

Contrôle UCR ({{ dossier.controles_cpam|length }})

+ {% if dossier_strength and dossier_strength.is_weak %} +
+ Dossier à preuves limitées (score {{ dossier_strength.score }}/10) — manque : {{ dossier_strength.missing|join(', ') }} +
+ {% endif %} + {% for ctrl in dossier.controles_cpam %} +
+
+ OGC {{ ctrl.numero_ogc }} — {{ ctrl.titre }} + {% if 'retient' in ctrl.decision_ucr|lower %} + {{ ctrl.decision_ucr }} + {% elif 'confirme' in ctrl.decision_ucr|lower %} + {{ ctrl.decision_ucr }} + {% else %} + {{ ctrl.decision_ucr }} + {% endif %} + {% if ctrl.quality_tier == 'A' %} + Qualité A + {% elif ctrl.quality_tier == 'B' %} + Qualité B + {% elif ctrl.quality_tier == 'C' %} + Qualité C + {% endif %} +
+ + {% if ctrl.arg_ucr %} +
+
Argument UCR
+ {{ ctrl.arg_ucr }} +
+ {% endif %} + + {% if ctrl.dp_ucr or ctrl.da_ucr or ctrl.dr_ucr or ctrl.actes_ucr %} +
+
Codes contestés
+
+ {% if ctrl.dp_ucr %}DP: {{ ctrl.dp_ucr }}{% endif %} + {% if ctrl.da_ucr %}DA: {{ ctrl.da_ucr }}{% endif %} + {% if ctrl.dr_ucr %}DR: {{ ctrl.dr_ucr }}{% endif %} + {% if ctrl.actes_ucr %}Actes: {{ ctrl.actes_ucr }}{% endif %} +
+
+ {% endif %} + + {% if ctrl.requires_review %} +
+ Revue manuelle requise — la contre-argumentation contient des incohérences détectées +
+ {% endif %} + + {% if ctrl.response_data %} +
+
Contre-argumentation
+ {% if ctrl.response_data.analyse_contestation %} +
+
Analyse de la contestation
+ {{ ctrl.response_data.analyse_contestation | format_cpam_text }} +
+ {% endif %} + {% if ctrl.response_data.points_accord and ctrl.response_data.points_accord|lower not in ['aucun', 'non applicable', 'n/a', ''] %} +
+
Points d'accord
+ {{ ctrl.response_data.points_accord | format_cpam_text }} +
+ {% endif %} + {% if ctrl.response_data.contre_arguments_medicaux %} +
+
Contre-arguments médicaux
+ {{ ctrl.response_data.contre_arguments_medicaux | format_cpam_text }} +
+ {% endif %} + {% if ctrl.response_data.preuves_dossier %} +
+
Preuves du dossier
+
    + {% for p in ctrl.response_data.preuves_dossier %} + {% if p is mapping %} +
  • + {{ p.element or p.get('type', '') }} + {{ p.valeur or '' }} → {{ p.signification or '' }} +
  • + {% endif %} + {% endfor %} +
+
+ {% endif %} + {% if ctrl.response_data.contre_arguments_asymetrie %} +
+
Asymétrie d'information
+ {{ ctrl.response_data.contre_arguments_asymetrie | format_cpam_text }} +
+ {% endif %} + {% if ctrl.response_data.contre_arguments_reglementaires %} +
+
Contre-arguments réglementaires
+ {{ ctrl.response_data.contre_arguments_reglementaires | format_cpam_text }} +
+ {% endif %} + {% if ctrl.response_data.references %} +
+
Références
+ {% for ref in ctrl.response_data.references %} + {% if ref is mapping %} +
+ [{{ ref.document or '' }}{% if ref.page %}, p.{{ ref.page }}{% endif %}] + {{ ref.citation or '' }} +
+ {% elif ref is string %} +

{{ ref }}

+ {% endif %} + {% endfor %} +
+ {% endif %} + {% if ctrl.response_data.conclusion %} +
+
Conclusion
+ {{ ctrl.response_data.conclusion | format_cpam_text }} +
+ {% endif %} +
+ {% elif ctrl.contre_argumentation %} +
+
Contre-argumentation
+
{{ ctrl.contre_argumentation }}
+
+ {% endif %} + + {% if ctrl.sources_reponse %} +
+ Sources RAG ({{ ctrl.sources_reponse|length }}) + {% for src in ctrl.sources_reponse %} +
{{ src.document }}{% if src.code %} — {{ src.code }}{% endif %}{% if src.page %} [p.{{ src.page }}]{% endif %}
+{{ src.extrait or '' }}
+ {% endfor %} +
+ {% endif %} + + {% if ctrl.quality_warnings %} +
+ Avertissements qualité ({{ ctrl.quality_warnings|length }}) +
    + {% for w in ctrl.quality_warnings %} + {% if w.startswith('[CRITIQUE]') %} +
  • {{ w }}
  • + {% else %} +
  • {{ w }}
  • + {% endif %} + {% endfor %} +
+
+ {% endif %} + +
+
Délai réglementaire
+
+ + + + + {% if ctrl.date_limite_reponse %}Limite : {{ ctrl.date_limite_reponse }}{% endif %} + +
+
+ +
+
Validation DIM
+
+ + {% if ctrl.validation_dim == 'valide' %} + Validé + {% elif ctrl.validation_dim == 'rejete' %} + Rejeté + {% elif ctrl.validation_dim == 'en_revision' %} + En révision + {% else %} + Non validé + {% endif %} + + {% if ctrl.date_validation %} + {{ ctrl.date_validation }} + {% endif %} +
+
+ + + +
+ +
+ Versions précédentes +
+ Chargement... +
+
+
+
+ {% endfor %} +
+{% endif %} + +{# ==================================================================== #} +{# Sections complémentaires (repliables) #} +{# ==================================================================== #} + {# ---- Biologie clé ---- #} {% if dossier.biologie_cle %} -
-

Biologie clé ({{ dossier.biologie_cle|length }})

+
+

Biologie clé ({{ dossier.biologie_cle|length }})

@@ -647,12 +745,21 @@ @@ -672,30 +779,28 @@
TestValeurAnomalieSource
{{ b.valeur or '' }} {% if b.quality == 'suspect' %} - ⚠ Suspect + Suspect {% elif b.anomalie %} Oui - {% else %} - — - {% endif %} + {% if b.test in bio_normals and b.valeur_num is not none %} + {% set lo, hi = bio_normals[b.test] %} + {% if b.valeur_num > hi %} + ↑ {{ b.valeur }} > {{ hi }} (N: {{ lo }}–{{ hi }}) + {% elif b.valeur_num < lo %} + ↓ {{ b.valeur }} < {{ lo }} (N: {{ lo }}–{{ hi }}) + {% endif %} + {% elif b.test in bio_normals %} + {% set lo, hi = bio_normals[b.test] %} + (N: {{ lo }}–{{ hi }}) + {% endif %} + {% else %}—{% endif %} {% if b.source_page %}{% endif %}
{% endif %} -
+ {% endif %} {# ---- Imagerie ---- #} {% if dossier.imagerie %} -
-

Imagerie ({{ dossier.imagerie|length }})

+
+

Imagerie ({{ dossier.imagerie|length }})

{% for img in dossier.imagerie %}
{{ img.type }} {% if img.score %} — Score : {{ img.score }}{% endif %} {% if img.source_page %}{% endif %} - {% if img.conclusion %} -
{{ img.conclusion }}
- {% endif %} + {% if img.conclusion %}
{{ img.conclusion }}
{% endif %}
{% endfor %} -
+ {% endif %} {# ---- Traitements de sortie ---- #} {% if dossier.traitements_sortie %} -
-

Traitements de sortie ({{ dossier.traitements_sortie|length }})

+
+

Traitements de sortie ({{ dossier.traitements_sortie|length }})

@@ -709,19 +814,19 @@ {% endfor %}
MédicamentPosologieCode ATCSource
-
+ {% endif %} {# ---- Antécédents ---- #} {% if dossier.antecedents %} -
-

Antécédents ({{ dossier.antecedents|length }})

+
+

Antécédents ({{ dossier.antecedents|length }})

    {% for a in dossier.antecedents %}
  • {{ a.texte }}{% if a.source_page %} {% endif %}
  • {% endfor %}
-
+ {% endif %} {# ---- Complications ---- #} @@ -736,6 +841,36 @@
{% endif %} +{# ==================================================================== #} +{# 5. DOCUMENTS SOURCES (nouveau — en bas de page) #} +{# ==================================================================== #} +{% if siblings and siblings|length > 1 %} +
+

Documents du dossier

+ + + + {% for sib in siblings %} + + + + + + + {% endfor %} + +
DocumentTypeDASActes
+ {{ sib.name | format_doc_name }} + {% if sib.path_rel == filepath %} + actuel + {% endif %} + {% if 'fusionne' in sib.name %} + fusionné + {% endif %} + {% if sib.dossier.document_type %}{{ sib.dossier.document_type }}{% endif %}{{ sib.dossier.diagnostics_associes|length }}{{ sib.dossier.actes_ccam|length }}
+
+{% endif %} + {# ---- Modal source ---- #}
@@ -751,6 +886,82 @@ {% block scripts %} {% endblock %} diff --git a/src/viewer/templates/dim.html b/src/viewer/templates/dim.html new file mode 100644 index 0000000..f20d0cb --- /dev/null +++ b/src/viewer/templates/dim.html @@ -0,0 +1,243 @@ +{% extends "base.html" %} +{% block title %}Synthese DIM{% endblock %} + +{% block content %} +← Retour +

Synthèse DIM

+ +{# ============================================================ #} +{# SECTION 1 : Vue d'ensemble DP + DAS #} +{# ============================================================ #} +
+ + {# --- Carte DP --- #} +
+

Diagnostic Principal

+
+
+
{{ dim.dp.confirmed }}
+
CONFIRMED
+
+
+
{{ dim.dp.review }}
+
REVIEW
+
+
+
{{ dim.dp.modified }}
+
Modifiés
+
+
+ {% if dim.dp.total %} +
+ Taux de modification DP : {{ ((dim.dp.modified / dim.dp.total) * 100) | round(1) }}% + ({{ dim.dp.modified }}/{{ dim.dp.total }}) +
+ {% endif %} + {# Confiance DP #} +
Confiance DP
+
+ {% set dp_tot = dim.dp.total or 1 %} + {% if dim.dp.confidence.get('high', 0) %} +
+ {% endif %} + {% if dim.dp.confidence.get('medium', 0) %} +
+ {% endif %} + {% if dim.dp.confidence.get('low', 0) %} +
+ {% endif %} + {% if dim.dp.confidence.get('none', 0) %} +
+ {% endif %} +
+ {# Source DP #} +
Source DP
+
+ {% for src, cnt in dim.dp.source.items() %} + {{ src }}: {{ cnt }} + {% endfor %} +
+
+ + {# --- Carte DAS --- #} +
+

Diagnostics Associés

+
+
+
{{ dim.das.kept }}
+
Conservés
+
+
+
{{ dim.das.downgraded }}
+
Dégradés
+
+
+
{{ dim.das.removed }}
+
Supprimés
+
+
+
{{ dim.das.ruled_out }}
+
Exclus
+
+
+
+ Total : {{ dim.das.total }} DAS | + Taux de modification : {{ dim.das.taux_modification }}% +
+ {# Barre DAS #} + {% set das_tot = dim.das.total or 1 %} +
+
+ {% if dim.das.downgraded %} +
+ {% endif %} + {% if dim.das.removed %} +
+ {% endif %} + {% if dim.das.ruled_out %} +
+ {% endif %} +
+
+ CMA : {{ dim.das.cma }} + {% if dim.das.no_code %} + Sans code : {{ dim.das.no_code }} + {% endif %} +
+
+
+ +{# ============================================================ #} +{# SECTION 2 : Qualité & Contestabilité #} +{# ============================================================ #} +
+ + {# --- Contestabilité (Vetos) --- #} +
+

Contestabilité (Veto Engine)

+
+
+
{{ dim.veto.distribution.get('PASS', 0) }}
+
PASS
+
+
+
{{ dim.veto.distribution.get('NEED_INFO', 0) }}
+
NEED_INFO
+
+
+
{{ dim.veto.distribution.get('FAIL', 0) }}
+
FAIL
+
+
+
+ Score moyen de défendabilité : {{ dim.veto.avg_score }}/100 +
+ {% if dim.veto.top_issues %} +
Alertes les plus fréquentes
+ {% for veto_id, count in dim.veto.top_issues %} +
+ {{ veto_id }} +
+
+
+ {{ count }} +
+ {% endfor %} + {% endif %} +
+ + {# --- Complétude documentaire --- #} +
+

Complétude documentaire

+
+
+
{{ dim.completude.distribution.get('defendable', 0) }}
+
Défendable
+
+
+
{{ dim.completude.distribution.get('fragile', 0) }}
+
Fragile
+
+
+
{{ dim.completude.distribution.get('indefendable', 0) }}
+
Indéfendable
+
+
+
+ Score moyen : {{ dim.completude.avg_score }}/100 +
+ {# --- Synthèse CPAM --- #} + {% if dim.cpam.total %} +
+
Contrôles CPAM
+
+ {{ dim.cpam.total }} contrôles + {% if dim.cpam.impact_total %} + Impact estimé : {{ "{:,.0f}".format(dim.cpam.impact_total).replace(",", " ") }} € + {% endif %} +
+
+ {% for prio, cnt in dim.cpam.by_priority.items() %} + {{ prio }}: {{ cnt }} + {% endfor %} +
+
+ {% endif %} +
+
+ +{# ============================================================ #} +{# SECTION 3 : Alertes prioritaires DIM #} +{# ============================================================ #} +{% if dim.alertes.review or dim.alertes.fail or dim.alertes.indefendable %} +
+

Alertes prioritaires

+ + {% if dim.alertes.fail %} +
+
+ Veto FAIL — Codage contestable ({{ dim.alertes.fail | length }}) +
+ {% for d in dim.alertes.fail %} +
+ {{ d.name }} + Score {{ d.score }}/100 + {{ d.issues }} issues +
+ {% endfor %} +
+ {% endif %} + + {% if dim.alertes.review %} +
+
+ DP en REVIEW — À valider ({{ dim.alertes.review | length }}) +
+ {% for d in dim.alertes.review %} +
+ {{ d.name }} + {{ d.code }} + {{ d.reason | truncate(80) }} +
+ {% endfor %} +
+ {% endif %} + + {% if dim.alertes.indefendable %} +
+
+ Complétude indéfendable ({{ dim.alertes.indefendable | length }}) +
+ {% for d in dim.alertes.indefendable %} +
+ {{ d.name }} + Score {{ d.score }}/100 + {{ d.manquants }} docs manquants +
+ {% endfor %} +
+ {% endif %} +
+{% endif %} + +{% endblock %} diff --git a/src/viewer/templates/index.html b/src/viewer/templates/index.html index 16c1364..e0c35e7 100644 --- a/src/viewer/templates/index.html +++ b/src/viewer/templates/index.html @@ -1,22 +1,24 @@ {% extends "base.html" %} {% block title %}Accueil{% endblock %} -{% block sidebar %} -{% for group_name, items in groups.items() %} -
{{ group_name | format_dossier_name }}
- {% for item in items %} - {% if 'fusionne' in item.name %} - ★ Fusionné - {% else %} - {{ item.name | format_doc_name }} - {% endif %} - {% endfor %} -{% endfor %} -{% endblock %} - {% block content %}

Dossiers médicaux traités

+{% if stats %} +
+ Vue globale + {{ stats.total_dossiers }} dossiers + {{ stats.total_das }} DAS + {{ stats.total_actes }} actes + {% if stats.total_alertes %}{{ stats.total_alertes }} alertes{% endif %} + {% if stats.total_cma %}{{ stats.total_cma }} CMA{% endif %} + {% if stats.total_cpam %}{{ stats.total_cpam }} CPAM{% endif %} + {% if stats.processing_time_total %} + Total : {{ stats.processing_time_total|format_duration }} + {% endif %} +
+{% endif %} + {% if not groups %}

Aucun dossier trouvé dans output/structured/.

@@ -24,77 +26,98 @@ Lancez le pipeline avec python -m src.main pour générer des fichiers.

-{% endif %} - -{% for group_name, items in groups.items() %} -
- {% set ns = namespace(total=0.0, count=0) %} - {% for item in items %} - {% if item.dossier.processing_time_s is not none %} - {% set ns.total = ns.total + item.dossier.processing_time_s %} - {% set ns.count = ns.count + 1 %} - {% endif %} - {% endfor %} - {% set stats = group_stats.get(group_name, {}) %} -

- {{ group_name | format_dossier_name }} - - {{ items|length }} fichier(s){% if ns.count %} — total : {{ ns.total|format_duration }}{% endif %} - - {% if stats %} - {{ stats.das_count }} DAS - {{ stats.actes_count }} actes - {% if stats.alertes_count %}{{ stats.alertes_count }} alertes{% endif %} - {% if stats.cma_count %}{{ stats.cma_count }} CMA{% endif %} - {% endif %} -

- {% if items|length > 1 %} - {% for item in items if 'fusionne' in item.name %} - {% if loop.first %} - - {% endif %} - {% endfor %} - {% endif %} -
- {% for item in items %} - -
-
- {{ item.name | format_doc_name }} -
-
- {% if item.dossier.document_type %} - {{ item.dossier.document_type }} - {% endif %} - {% if item.dossier.source_files %}fusionné{% endif %} - {% if item.dossier.diagnostics_associes %}{{ item.dossier.diagnostics_associes|length }} DAS{% endif %} - {% if item.dossier.actes_ccam %}{{ item.dossier.actes_ccam|length }} actes{% endif %} - {% if item.dossier.alertes_codage %}{{ item.dossier.alertes_codage|length }} alertes{% endif %} -
- {% if item.dossier.diagnostic_principal %} -
- DP : {{ item.dossier.diagnostic_principal.texte[:80] }}{% if item.dossier.diagnostic_principal.texte|length > 80 %}…{% endif %} -
- {% if item.dossier.diagnostic_principal.cim10_suggestion %} -
- {{ item.dossier.diagnostic_principal.cim10_suggestion }} - {{ item.dossier.diagnostic_principal.cim10_confidence | confidence_badge }} -
+{% else %} +
+ + + + + + + + + + + + + + {% for group_name, items in groups.items() %} + {# Sélection du dossier représentatif : fusionné en priorité, sinon premier #} + {% set ns = namespace(rep=none) %} + {% for item in items %} + {% if 'fusionne' in item.name %} + {% set ns.rep = item %} {% endif %} + {% endfor %} + {% if ns.rep is none %} + {% set ns.rep = items[0] %} {% endif %} - {% if item.dossier.processing_time_s is not none %} -
- Traitement : {{ item.dossier.processing_time_s|format_duration }} -
- {% endif %} - - - {% endfor %} - + {% set d = ns.rep.dossier %} + {% set dp = d.diagnostic_principal %} + {% set ghm = d.ghm_estimation %} + {% set gstats = group_stats.get(group_name, {}) %} + + + + + + + + + + {% endfor %} + +
PatientDPDASActesSévéritéAlertesCPAM
+ {{ group_name | format_dossier_name }} + {% if d.document_type %} + {{ d.document_type }} + {% endif %} + {% if items|length > 1 %} + {{ items|length }} docs + {% endif %} + + {% if dp and dp.cim10_suggestion %} + {{ dp.cim10_suggestion }} + {{ dp.cim10_confidence | confidence_badge }} +
+ {{ dp.texte[:60] }}{% if dp.texte|length > 60 %}…{% endif %} +
+ {% elif dp %} + {{ dp.texte[:40] }}… + {% else %} + + {% endif %} +
+ {% if gstats.das_count is defined and gstats.das_count > 0 %} + {{ gstats.das_count }} + {% else %} + 0 + {% endif %} + + {% if gstats.actes_count is defined and gstats.actes_count > 0 %} + {{ gstats.actes_count }} + {% else %} + 0 + {% endif %} + + {% if ghm and ghm.severite %} + {{ ghm.severite | string | severity_badge }} + {% else %} + + {% endif %} + + {% if gstats.alertes_count is defined and gstats.alertes_count > 0 %} + {{ gstats.alertes_count }} + {% else %} + 0 + {% endif %} + + {% if d.controles_cpam %} + {{ d.controles_cpam|length }} + {% else %} + + {% endif %} +
-{% endfor %} +{% endif %} {% endblock %} diff --git a/src/viewer/templates/validation_detail.html b/src/viewer/templates/validation_detail.html index 3063ee3..c4030ce 100644 --- a/src/viewer/templates/validation_detail.html +++ b/src/viewer/templates/validation_detail.html @@ -1,18 +1,6 @@ {% extends "base.html" %} {% block title %}Validation — {{ group_name }}{% endblock %} -{% block sidebar %} -{% for gn, items in groups.items() %} -
{{ gn | format_dossier_name }}
- {% for item in items %} - {% if 'fusionne' in item.name %} -
★ Fusionné - {% else %} - {{ item.name | format_doc_name }} - {% endif %} - {% endfor %} -{% endfor %} -{% endblock %} {% block content %} - - - - - - - -
- {% block content %}{% endblock %} -
- - -{% block scripts %}{% endblock %} - - diff --git a/t2a_install_rag_cleanup/src/viewer/templates/cpam.html b/t2a_install_rag_cleanup/src/viewer/templates/cpam.html deleted file mode 100644 index 1416929..0000000 --- a/t2a_install_rag_cleanup/src/viewer/templates/cpam.html +++ /dev/null @@ -1,88 +0,0 @@ -{% extends "base.html" %} -{% block title %}Contrôles CPAM{% endblock %} - -{% block sidebar %} -{% for group_name, items in groups.items() %} -
{{ group_name | format_dossier_name }}
- {% for item in items %} - {% if 'fusionne' in item.name %} - ★ Fusionné - {% else %} - {{ item.name | format_doc_name }} - {% endif %} - {% endfor %} -{% endfor %} -{% endblock %} - -{% block content %} -← Retour à la liste - -
-

Contrôles CPAM

- {{ total }} -
- -{% if not controls %} -
-

Aucun contrôle CPAM trouvé dans les dossiers.

-
-{% else %} -
- - - - - - - - - - - - - {% for c in controls %} - - - - - - - - - {% endfor %} - -
DossierOGCTitreDécisionCodes contestésContre-argumentation
- - {{ c.group_name | format_dossier_name }} - - {% if c.dp_code %} -
DP: {{ c.dp_code }}
- {% endif %} -
{{ c.ctrl.numero_ogc }}{{ c.ctrl.titre }} - {% if 'retient' in c.ctrl.decision_ucr|lower %} - {{ c.ctrl.decision_ucr }} - {% elif 'confirme' in c.ctrl.decision_ucr|lower %} - {{ c.ctrl.decision_ucr }} - {% else %} - {{ c.ctrl.decision_ucr }} - {% endif %} - -
- {% if c.ctrl.dp_ucr %}DP: {{ c.ctrl.dp_ucr }}{% endif %} - {% if c.ctrl.da_ucr %}DA: {{ c.ctrl.da_ucr }}{% endif %} - {% if c.ctrl.dr_ucr %}DR: {{ c.ctrl.dr_ucr }}{% endif %} - {% if c.ctrl.actes_ucr %}Actes: {{ c.ctrl.actes_ucr }}{% endif %} -
-
- {% if c.ctrl.contre_argumentation %} -
- {{ c.ctrl.contre_argumentation[:80] }}{% if c.ctrl.contre_argumentation|length > 80 %}…{% endif %} -
{{ c.ctrl.contre_argumentation }}
-
- {% else %} - - {% endif %} -
-
-{% endif %} -{% endblock %} diff --git a/t2a_install_rag_cleanup/src/viewer/templates/dashboard.html b/t2a_install_rag_cleanup/src/viewer/templates/dashboard.html deleted file mode 100644 index c80c729..0000000 --- a/t2a_install_rag_cleanup/src/viewer/templates/dashboard.html +++ /dev/null @@ -1,145 +0,0 @@ -{% extends "base.html" %} -{% block title %}Dashboard{% endblock %} - -{% block sidebar %} -{% for group_name, items in groups.items() %} -
{{ group_name | format_dossier_name }}
- {% for item in items %} - {% if 'fusionne' in item.name %} - ★ Fusionné - {% else %} - {{ item.name | format_doc_name }} - {% endif %} - {% endfor %} -{% endfor %} -{% endblock %} - -{% block content %} -← Retour à la liste -

Dashboard

- -{# ---- Cartes métriques ---- #} -
- {% set cards = [ - ("Dossiers", stats.total_dossiers, "#3b82f6", "#dbeafe"), - ("Fichiers", stats.total_fichiers, "#6366f1", "#e0e7ff"), - ("DAS total", stats.total_das, "#1d4ed8", "#dbeafe"), - ("Actes total", stats.total_actes, "#3730a3", "#e0e7ff"), - ("Alertes", stats.total_alertes, "#c2410c", "#ffedd5"), - ("CMA", stats.total_cma, "#dc2626", "#fee2e2"), - ("Contrôles CPAM", stats.total_cpam, "#b45309", "#fef3c7"), - ("Temps total", stats.processing_time_total | format_duration, "#065f46", "#d1fae5"), - ] %} - {% for label, value, fg, bg in cards %} -
-
{{ label }}
-
{{ value }}
-
- {% endfor %} -
- -{# ---- Temps moyen ---- #} -{% if stats.processing_time_avg %} -
-
Temps moyen par fichier : {{ stats.processing_time_avg | format_duration }}
-
-{% endif %} - -{# ---- Distribution confiance DP ---- #} -{% set conf = stats.dp_confidence %} -{% set conf_total = (conf.get('high', 0) + conf.get('medium', 0) + conf.get('low', 0) + conf.get('none', 0)) or 1 %} -
-

Confiance DP

-
- {% if conf.get('high', 0) %} -
- {% endif %} - {% if conf.get('medium', 0) %} -
- {% endif %} - {% if conf.get('low', 0) %} -
- {% endif %} - {% if conf.get('none', 0) %} -
- {% endif %} -
-
- Haute : {{ conf.get('high', 0) }} - Moyenne : {{ conf.get('medium', 0) }} - Basse : {{ conf.get('low', 0) }} - Aucune : {{ conf.get('none', 0) }} -
-
- -{# ---- Top 15 codes CIM-10 ---- #} -{% if stats.top_codes %} -
-

Top 15 codes CIM-10

- {% for code, count in stats.top_codes %} -
- {{ code }} -
-
-
- {{ count }} -
- {% endfor %} -
-{% endif %} - -{# ---- Distribution GHM types ---- #} -{% set ghm = stats.ghm_types %} -{% set ghm_total = (ghm.get('C', 0) + ghm.get('M', 0) + ghm.get('K', 0)) or 1 %} -{% if ghm.get('C', 0) or ghm.get('M', 0) or ghm.get('K', 0) %} -
-

Types GHM

-
- {% if ghm.get('C', 0) %} -
- {% endif %} - {% if ghm.get('M', 0) %} -
- {% endif %} - {% if ghm.get('K', 0) %} -
- {% endif %} -
-
- C — Chirurgical : {{ ghm.get('C', 0) }} - M — Médical : {{ ghm.get('M', 0) }} - K — Interventionnel : {{ ghm.get('K', 0) }} -
-
-{% endif %} - -{# ---- Distribution sévérité ---- #} -{% set sev = stats.severity_dist %} -{% set sev_total = (sev.get(1, 0) + sev.get(2, 0) + sev.get(3, 0) + sev.get(4, 0)) or 1 %} -{% if sev.get(1, 0) or sev.get(2, 0) or sev.get(3, 0) or sev.get(4, 0) %} -
-

Sévérité GHM

-
- {% if sev.get(1, 0) %} -
- {% endif %} - {% if sev.get(2, 0) %} -
- {% endif %} - {% if sev.get(3, 0) %} -
- {% endif %} - {% if sev.get(4, 0) %} -
- {% endif %} -
-
- Niveau 1 : {{ sev.get(1, 0) }} - Niveau 2 : {{ sev.get(2, 0) }} - Niveau 3 : {{ sev.get(3, 0) }} - Niveau 4 : {{ sev.get(4, 0) }} -
-
-{% endif %} - -{% endblock %} diff --git a/t2a_install_rag_cleanup/src/viewer/templates/detail.html b/t2a_install_rag_cleanup/src/viewer/templates/detail.html deleted file mode 100644 index 53e77a8..0000000 --- a/t2a_install_rag_cleanup/src/viewer/templates/detail.html +++ /dev/null @@ -1,757 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ dossier.source_file or filepath }}{% endblock %} - -{% block sidebar %} -
Navigation
-Retour à la liste -{% if siblings %} -
{{ current_group }}
-{% for sib in siblings %} - - {{ sib.name }} - -{% endfor %} -{% endif %} -
Actions
- -
-{% endblock %} - -{% block content %} -← Retour à la liste - -{# ---- En-tête ---- #} -
-

{{ dossier.source_file or filepath }}

-
- {% if dossier.document_type %} -
- - {{ dossier.document_type }} -
- {% endif %} - {% if dossier.processing_time_s is not none %} -
- - {{ dossier.processing_time_s|format_duration }} -
- {% endif %} -
- {% if dossier.source_files %} -
- -
- {% for sf in dossier.source_files %} - {{ sf }}{% if not loop.last %}, {% endif %} - {% endfor %} -
-
- {% endif %} -
- -{# ---- Séjour ---- #} -{% set s = dossier.sejour %} -{% if s.sexe or s.age or s.date_entree or s.date_sortie or s.duree_sejour is not none or s.imc or s.poids or s.taille %} -
-

Séjour

-
- {% if s.sexe %}
{{ s.sexe }}
{% endif %} - {% if s.age is not none %}
{{ s.age }} ans
{% endif %} - {% if s.date_entree %}
{{ s.date_entree }}
{% endif %} - {% if s.date_sortie %}
{{ s.date_sortie }}
{% endif %} - {% if s.duree_sejour is not none %}
{{ s.duree_sejour }} jour(s)
{% endif %} - {% if s.mode_entree %}
{{ s.mode_entree }}
{% endif %} - {% if s.mode_sortie %}
{{ s.mode_sortie }}
{% endif %} - {% if s.poids %}
{{ s.poids }} kg
{% endif %} - {% if s.taille %}
{{ s.taille }} cm
{% endif %} - {% if s.imc %}
{{ s.imc }}
{% endif %} -
-
-{% endif %} - -{# ---- Estimation GHM ---- #} -{% if dossier.ghm_estimation %} -{% set ghm = dossier.ghm_estimation %} -
-

Estimation GHM

-
- {% if ghm.cmd %} -
- - {{ ghm.cmd }}{% if ghm.cmd_libelle %} — {{ ghm.cmd_libelle }}{% endif %} -
- {% endif %} -
- - {% if ghm.type_ghm == 'C' %} - C — Chirurgical - {% elif ghm.type_ghm == 'K' %} - K — Interventionnel - {% elif ghm.type_ghm == 'M' %} - M — Médical - {% endif %} -
-
- - {% if ghm.severite <= 1 %} - Niveau {{ ghm.severite }} - {% elif ghm.severite == 2 %} - Niveau {{ ghm.severite }} - {% elif ghm.severite == 3 %} - Niveau {{ ghm.severite }} - {% else %} - Niveau {{ ghm.severite }} - {% endif %} -
- {% if ghm.ghm_approx %} -
- - {{ ghm.ghm_approx }} -
- {% endif %} -
- - {{ ghm.cma_count }} CMA, {{ ghm.cms_count }} CMS -
-
- {% if ghm.alertes %} -
- {% for alerte in ghm.alertes %} -
{{ alerte }}
- {% endfor %} -
- {% endif %} -
- Estimation heuristique — le GHM définitif nécessite le groupeur officiel ATIH -
-
-{% endif %} - -{# ---- Contrôle CPAM ---- #} -{% if dossier.controles_cpam %} -
-

Contrôle CPAM ({{ dossier.controles_cpam|length }})

- {% for ctrl in dossier.controles_cpam %} -
-
- OGC {{ ctrl.numero_ogc }} — {{ ctrl.titre }} - {% if 'retient' in ctrl.decision_ucr|lower %} - {{ ctrl.decision_ucr }} - {% elif 'confirme' in ctrl.decision_ucr|lower %} - {{ ctrl.decision_ucr }} - {% else %} - {{ ctrl.decision_ucr }} - {% endif %} -
- - {# Argument CPAM #} - {% if ctrl.arg_ucr %} -
-
Argument CPAM
- {{ ctrl.arg_ucr }} -
- {% endif %} - - {# Codes contestés #} - {% if ctrl.dp_ucr or ctrl.da_ucr or ctrl.dr_ucr or ctrl.actes_ucr %} -
-
Codes contestés
-
- {% if ctrl.dp_ucr %}DP: {{ ctrl.dp_ucr }}{% endif %} - {% if ctrl.da_ucr %}DA: {{ ctrl.da_ucr }}{% endif %} - {% if ctrl.dr_ucr %}DR: {{ ctrl.dr_ucr }}{% endif %} - {% if ctrl.actes_ucr %}Actes: {{ ctrl.actes_ucr }}{% endif %} -
-
- {% endif %} - - {# Contre-argumentation structurée ou fallback texte brut #} - {% if ctrl.response_data %} -
-
Contre-argumentation
- - {% if ctrl.response_data.analyse_contestation %} -
-
Analyse de la contestation
- {{ ctrl.response_data.analyse_contestation | format_cpam_text }} -
- {% endif %} - - {% if ctrl.response_data.points_accord and ctrl.response_data.points_accord|lower not in ['aucun', 'non applicable', 'n/a', ''] %} -
-
Points d'accord
- {{ ctrl.response_data.points_accord | format_cpam_text }} -
- {% endif %} - - {% if ctrl.response_data.contre_arguments_medicaux %} -
-
Contre-arguments médicaux
- {{ ctrl.response_data.contre_arguments_medicaux | format_cpam_text }} -
- {% endif %} - - {% if ctrl.response_data.preuves_dossier %} -
-
Preuves du dossier
-
    - {% for p in ctrl.response_data.preuves_dossier %} - {% if p is mapping %} -
  • - {{ p.element or p.get('type', '') }} - {{ p.valeur or '' }} → {{ p.signification or '' }} -
  • - {% endif %} - {% endfor %} -
-
- {% endif %} - - {% if ctrl.response_data.contre_arguments_asymetrie %} -
-
Asymétrie d'information
- {{ ctrl.response_data.contre_arguments_asymetrie | format_cpam_text }} -
- {% endif %} - - {% if ctrl.response_data.contre_arguments_reglementaires %} -
-
Contre-arguments réglementaires
- {{ ctrl.response_data.contre_arguments_reglementaires | format_cpam_text }} -
- {% endif %} - - {% if ctrl.response_data.references %} -
-
Références
- {% for ref in ctrl.response_data.references %} - {% if ref is mapping %} -
- [{{ ref.document or '' }}{% if ref.page %}, p.{{ ref.page }}{% endif %}] - {{ ref.citation or '' }} -
- {% elif ref is string %} -

{{ ref }}

- {% endif %} - {% endfor %} -
- {% endif %} - - {% if ctrl.response_data.conclusion %} -
-
Conclusion
- {{ ctrl.response_data.conclusion | format_cpam_text }} -
- {% endif %} -
- {% elif ctrl.contre_argumentation %} -
-
Contre-argumentation
-
{{ ctrl.contre_argumentation }}
-
- {% endif %} - - {# Sources RAG #} - {% if ctrl.sources_reponse %} -
- Sources RAG ({{ ctrl.sources_reponse|length }}) - {% for src in ctrl.sources_reponse %} -
{{ src.document }}{% if src.code %} — {{ src.code }}{% endif %}{% if src.page %} [p.{{ src.page }}]{% endif %}
-{{ src.extrait or '' }}
- {% endfor %} -
- {% endif %} -
- {% endfor %} -
-{% endif %} - -{# ---- Alertes de codage ---- #} -{% if dossier.alertes_codage %} -
-

Alertes de codage ({{ dossier.alertes_codage|length }})

-
    - {% for alerte in dossier.alertes_codage %} - {% if alerte.startswith('NON-CUMUL') %} -
  • {{ alerte }}
  • - {% else %} -
  • {{ alerte }}
  • - {% endif %} - {% endfor %} -
-
-{% endif %} - -{# ---- Diagnostic principal ---- #} -{% if dossier.diagnostic_principal %} -{% set dp = dossier.diagnostic_principal %} -
-

Diagnostic principal

-
- {{ dp.texte }} - {% if dp.source_page %}{% endif %} -
- {% if dp.cim10_suggestion %} - {{ dp.cim10_suggestion }} - {{ dp.cim10_confidence | confidence_badge }} - {% if dp.niveau_cma and dp.niveau_cma > 1 %} - {{ dp.niveau_cma | cma_level_badge }} - {% elif dp.est_cma %} - CMA - {% endif %} - {{ dp.niveau_severite | severity_badge }} - {% endif %} - {% if dp.justification %} -
{{ dp.justification }}
- {% endif %} - {% if dp.preuves_cliniques %} -
- Preuves cliniques ({{ dp.preuves_cliniques|length }}) -
    - {% for p in dp.preuves_cliniques %} -
  • {{ p.type }} {{ p.element }} → {{ p.interpretation }}
  • - {% endfor %} -
-
- {% endif %} - {% if dp.raisonnement %} -
- Raisonnement LLM -
{{ dp.raisonnement }}
-
- {% endif %} - {% if dp.sources_rag %} -
- Sources RAG ({{ dp.sources_rag|length }}) - {% for src in dp.sources_rag %} -
{{ src.document }}{% if src.code %} — {{ src.code }}{% endif %}{% if src.page %} [p.{{ src.page }}]{% endif %}
-{{ src.extrait or '' }}
- {% endfor %} -
- {% endif %} -
-{% endif %} - -{# ---- Diagnostics associés ---- #} -{% if dossier.diagnostics_associes %} -
-

Diagnostics associés ({{ dossier.diagnostics_associes|length }})

- - - - {% for das in dossier.diagnostics_associes %} - - - - - - - - - {% if das.raisonnement %} - - - - {% endif %} - {% if das.sources_rag %} - - - - {% endif %} - {% endfor %} - -
TexteCIM-10ConfianceCMASourceJustification
{{ das.texte }}{% if das.cim10_suggestion %}{{ das.cim10_suggestion }}{% endif %}{{ das.cim10_confidence | confidence_badge }} - {% if das.niveau_cma and das.niveau_cma > 1 %} - {{ das.niveau_cma | cma_level_badge }} - {% elif das.est_cma %} - CMA - {% else %} - — - {% endif %} - - {% if das.source %} - {{ das.source }} - {% endif %} - {% if das.source_page %} - - {% endif %} - - {{ das.justification or '' }} - {% if das.preuves_cliniques %} -
preuves ({{ das.preuves_cliniques|length }}) -
    - {% for p in das.preuves_cliniques %} -
  • [{{ p.type }}] {{ p.element }} → {{ p.interpretation }}
  • - {% endfor %} -
-
- {% endif %} -
-
- Raisonnement LLM -
{{ das.raisonnement }}
-
-
-
- Sources RAG ({{ das.sources_rag|length }}) - {% for src in das.sources_rag %} -
{{ src.document }}{% if src.code %} — {{ src.code }}{% endif %}{% if src.page %} [p.{{ src.page }}]{% endif %}
-{{ src.extrait or '' }}
- {% endfor %} -
-
-
-{% endif %} - -{# ---- Actes CCAM ---- #} -{% if dossier.actes_ccam %} -
-

Actes CCAM ({{ dossier.actes_ccam|length }})

- - - - {% for a in dossier.actes_ccam %} - - - - - - - - - {% endfor %} - -
TexteCode CCAMRegroupementDateValiditéSource
{{ a.texte }}{% if a.code_ccam_suggestion %}{{ a.code_ccam_suggestion }}{% endif %} - {% if a.code_ccam_suggestion and ccam_dict.get(a.code_ccam_suggestion, {}).get('regroupement') %} - {{ ccam_dict[a.code_ccam_suggestion]['regroupement'] }} - {% else %} - — - {% endif %} - {{ a.date or '' }} - {% if a.validite == 'valide' %}Valide - {% elif a.validite == 'obsolete' %}Obsolète - {% else %}—{% endif %} - {% for alerte in a.alertes %} -
{{ alerte }}
- {% endfor %} -
{% if a.source_page %}{% endif %}
-
-{% endif %} - -{# ---- Biologie clé ---- #} -{% if dossier.biologie_cle %} -
-

Biologie clé ({{ dossier.biologie_cle|length }})

- - - - {% for b in dossier.biologie_cle %} - - - - - - - {% endfor %} - -
TestValeurAnomalieSource
{{ b.test }}{{ b.valeur or '' }}{% if b.anomalie %}Oui{% else %}—{% endif %}{% if b.source_page %}{% endif %}
-
-{% endif %} - -{# ---- Imagerie ---- #} -{% if dossier.imagerie %} -
-

Imagerie ({{ dossier.imagerie|length }})

- {% for img in dossier.imagerie %} -
- {{ img.type }} - {% if img.score %} — Score : {{ img.score }}{% endif %} - {% if img.source_page %}{% endif %} - {% if img.conclusion %} -
{{ img.conclusion }}
- {% endif %} -
- {% endfor %} -
-{% endif %} - -{# ---- Traitements de sortie ---- #} -{% if dossier.traitements_sortie %} -
-

Traitements de sortie ({{ dossier.traitements_sortie|length }})

- - - - {% for t in dossier.traitements_sortie %} - - - - - - - {% endfor %} - -
MédicamentPosologieCode ATCSource
{{ t.medicament }}{{ t.posologie or '' }}{% if t.code_atc %}{{ t.code_atc }}{% endif %}{% if t.source_page %}{% endif %}
-
-{% endif %} - -{# ---- Antécédents ---- #} -{% if dossier.antecedents %} -
-

Antécédents ({{ dossier.antecedents|length }})

-
    - {% for a in dossier.antecedents %} -
  • {{ a.texte }}{% if a.source_page %} {% endif %}
  • - {% endfor %} -
-
-{% endif %} - -{# ---- Complications ---- #} -{% if dossier.complications %} -
-

Complications ({{ dossier.complications|length }})

-
    - {% for c in dossier.complications %} -
  • {{ c.texte }}{% if c.source_page %} {% endif %}
  • - {% endfor %} -
-
-{% endif %} - -{# ---- Modal source ---- #} -
-
-
- Document source - -
-
-
-
- -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/t2a_install_rag_cleanup/src/viewer/templates/index.html b/t2a_install_rag_cleanup/src/viewer/templates/index.html deleted file mode 100644 index 16c1364..0000000 --- a/t2a_install_rag_cleanup/src/viewer/templates/index.html +++ /dev/null @@ -1,100 +0,0 @@ -{% extends "base.html" %} -{% block title %}Accueil{% endblock %} - -{% block sidebar %} -{% for group_name, items in groups.items() %} -
{{ group_name | format_dossier_name }}
- {% for item in items %} - {% if 'fusionne' in item.name %} - ★ Fusionné - {% else %} - {{ item.name | format_doc_name }} - {% endif %} - {% endfor %} -{% endfor %} -{% endblock %} - -{% block content %} -

Dossiers médicaux traités

- -{% if not groups %} -
-

Aucun dossier trouvé dans output/structured/.

-

- Lancez le pipeline avec python -m src.main pour générer des fichiers. -

-
-{% endif %} - -{% for group_name, items in groups.items() %} -
- {% set ns = namespace(total=0.0, count=0) %} - {% for item in items %} - {% if item.dossier.processing_time_s is not none %} - {% set ns.total = ns.total + item.dossier.processing_time_s %} - {% set ns.count = ns.count + 1 %} - {% endif %} - {% endfor %} - {% set stats = group_stats.get(group_name, {}) %} -

- {{ group_name | format_dossier_name }} - - {{ items|length }} fichier(s){% if ns.count %} — total : {{ ns.total|format_duration }}{% endif %} - - {% if stats %} - {{ stats.das_count }} DAS - {{ stats.actes_count }} actes - {% if stats.alertes_count %}{{ stats.alertes_count }} alertes{% endif %} - {% if stats.cma_count %}{{ stats.cma_count }} CMA{% endif %} - {% endif %} -

- {% if items|length > 1 %} - {% for item in items if 'fusionne' in item.name %} - {% if loop.first %} - - {% endif %} - {% endfor %} - {% endif %} -
- {% for item in items %} - -
-
- {{ item.name | format_doc_name }} -
-
- {% if item.dossier.document_type %} - {{ item.dossier.document_type }} - {% endif %} - {% if item.dossier.source_files %}fusionné{% endif %} - {% if item.dossier.diagnostics_associes %}{{ item.dossier.diagnostics_associes|length }} DAS{% endif %} - {% if item.dossier.actes_ccam %}{{ item.dossier.actes_ccam|length }} actes{% endif %} - {% if item.dossier.alertes_codage %}{{ item.dossier.alertes_codage|length }} alertes{% endif %} -
- {% if item.dossier.diagnostic_principal %} -
- DP : {{ item.dossier.diagnostic_principal.texte[:80] }}{% if item.dossier.diagnostic_principal.texte|length > 80 %}…{% endif %} -
- {% if item.dossier.diagnostic_principal.cim10_suggestion %} -
- {{ item.dossier.diagnostic_principal.cim10_suggestion }} - {{ item.dossier.diagnostic_principal.cim10_confidence | confidence_badge }} -
- {% endif %} - {% endif %} - {% if item.dossier.processing_time_s is not none %} -
- Traitement : {{ item.dossier.processing_time_s|format_duration }} -
- {% endif %} -
-
- {% endfor %} -
-
-{% endfor %} -{% endblock %} diff --git a/t2a_install_rag_cleanup/src/viewer/templates/validation_detail.html b/t2a_install_rag_cleanup/src/viewer/templates/validation_detail.html deleted file mode 100644 index 3063ee3..0000000 --- a/t2a_install_rag_cleanup/src/viewer/templates/validation_detail.html +++ /dev/null @@ -1,404 +0,0 @@ -{% extends "base.html" %} -{% block title %}Validation — {{ group_name }}{% endblock %} - -{% block sidebar %} -{% for gn, items in groups.items() %} -
{{ gn | format_dossier_name }}
- {% for item in items %} - {% if 'fusionne' in item.name %} - ★ Fusionné - {% else %} - {{ item.name | format_doc_name }} - {% endif %} - {% endfor %} -{% endfor %} -{% endblock %} - -{% block content %} - - - -
- Retour à la liste - | - {% if prev_id %} - Précédent - {% endif %} - {% if next_id %} - Suivant - {% endif %} - - Voir le dossier complet -
- -

Validation : {{ group_name }}

- -{% if dossier %} - -
-

Séjour

-
- {% if dossier.sejour.sexe %}
{{ dossier.sejour.sexe }}
{% endif %} - {% if dossier.sejour.age is not none %}
{{ dossier.sejour.age }} ans
{% endif %} - {% if dossier.sejour.date_entree %}
{{ dossier.sejour.date_entree }}
{% endif %} - {% if dossier.sejour.date_sortie %}
{{ dossier.sejour.date_sortie }}
{% endif %} - {% if dossier.sejour.duree_sejour is not none %}
{{ dossier.sejour.duree_sejour }}j
{% endif %} -
-
- - -
-

Diagnostic Principal

- {% set dp = annotation.dp %} -
- {{ dp.texte_original }} -
-
- {{ dp.code_pipeline }} - {{ dp.confidence | confidence_badge }} - - -
- Correct - Modifier - Supprimer -
- - - - - -
-
- - -
-

Diagnostics Associés ({{ annotation.das|length }})

- - - - - - - - - - - - - - - {% for das in annotation.das %} - - - - - - - - - - - {% endfor %} - -
#TexteCode pipelineConf.SourceValidationCode corrigéCommentaire
{{ loop.index }}{{ das.texte_original }}{{ das.code_pipeline }}{{ das.confidence | confidence_badge }} - {% if das.source %} - {{ das.source }} - {% endif %} - -
- OK - Mod - Sup -
-
-
- -
-
-
- -
- - -
-

DAS manquants (ajoutés par le DIM)

-
- {% for aj in annotation.das_ajoutes %} -
- -
- -
-
- - -
- {% endfor %} -
- -
-
- - -
-

Commentaire général

- -
-{% endif %} - - -
- - - - - Dossier : {{ dossier_id }} -
- -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/t2a_install_rag_cleanup/src/viewer/templates/validation_list.html b/t2a_install_rag_cleanup/src/viewer/templates/validation_list.html deleted file mode 100644 index 1fd34f5..0000000 --- a/t2a_install_rag_cleanup/src/viewer/templates/validation_list.html +++ /dev/null @@ -1,179 +0,0 @@ -{% extends "base.html" %} -{% block title %}Validation DIM{% endblock %} - -{% block sidebar %} -{% for group_name, items in groups.items() %} -
{{ group_name | format_dossier_name }}
- {% for item in items %} - {% if 'fusionne' in item.name %} - ★ Fusionné - {% else %} - {{ item.name | format_doc_name }} - {% endif %} - {% endfor %} -{% endfor %} -{% endblock %} - -{% block content %} -
-

Validation DIM

- Voir les métriques -
- - -
-
- Progression - {{ valides }} / {{ total }} validés - {% if en_cours > 0 %} - {{ en_cours }} en cours - {% endif %} -
-
- {% set pct_valide = (valides / total * 100) if total > 0 else 0 %} - {% set pct_encours = (en_cours / total * 100) if total > 0 else 0 %} -
-
-
-
-
-
- - -
-
- - -
-
- - -
- - - - -
- - -
- - - - - - - - - - - - - - - {% for item in items %} - - - - - - - - - - - {% endfor %} - -
#DossierDPConf. DPDASCPAMStatutValidateur
{{ loop.index }} - - {{ item.group_name }} - - - {{ item.dp_code or '—' }} - {% if item.dp_texte %} -
{{ item.dp_texte[:50] }}{% if item.dp_texte|length > 50 %}…{% endif %} - {% endif %} -
{{ item.dp_confidence | confidence_badge }}{{ item.nb_das }} - {% if item.has_cpam %} - CPAM - {% endif %} - - {% if item.statut == 'valide' %} - Validé - {% elif item.statut == 'en_cours' %} - En cours - {% else %} - Non commencé - {% endif %} - {{ item.validateur }}
-
- -{% if not items %} -
-

Aucun dossier sélectionné pour validation.

-

- Lancez python scripts/select_validation_dossiers.py pour sélectionner les dossiers. -

-
-{% endif %} - -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/t2a_install_rag_cleanup/src/viewer/templates/validation_metrics.html b/t2a_install_rag_cleanup/src/viewer/templates/validation_metrics.html deleted file mode 100644 index 2c22897..0000000 --- a/t2a_install_rag_cleanup/src/viewer/templates/validation_metrics.html +++ /dev/null @@ -1,243 +0,0 @@ -{% extends "base.html" %} -{% block title %}Métriques Validation DIM{% endblock %} - -{% block sidebar %} -{% for group_name, items in groups.items() %} -
{{ group_name | format_dossier_name }}
- {% for item in items %} - {% if 'fusionne' in item.name %} - ★ Fusionné - {% else %} - {{ item.name | format_doc_name }} - {% endif %} - {% endfor %} -{% endfor %} -{% endblock %} - -{% block content %} -
- Retour à la liste -

Métriques de qualité

-
- -{% if metrics.total_valides == 0 %} -
-

Aucun dossier validé pour le moment.

-

- Validez des dossiers depuis la liste de validation pour voir les métriques. -

-
-{% else %} - - -
-
- Progression - {{ metrics.total_valides }} / {{ total_selection }} dossiers validés -
-
- {% set pct = (metrics.total_valides / total_selection * 100) if total_selection > 0 else 0 %} -
-
-
- - -
- - -
-

Diagnostic Principal

-
-
- - {{ "%.1f" | format(metrics.dp.accuracy * 100) }}% -
-
- - {{ metrics.dp.correct }} / {{ metrics.dp.total }} -
-
- - {{ metrics.dp.modifie }} -
-
- - {{ metrics.dp.supprime }} -
-
- -
- {% set dp_t = metrics.dp.total or 1 %} -
-
-
-
-
- Correct - Modifié - Supprimé -
-
- - -
-

Diagnostics Associés

-
-
- - {{ "%.1f" | format(metrics.das.precision * 100) }}% -
-
- - {{ "%.1f" | format(metrics.das.recall * 100) }}% -
-
- - {{ "%.1f" | format(metrics.das.f1 * 100) }}% -
-
- - {{ "%.1f" | format(metrics.das.hallucination_rate * 100) }}% -
-
-
-
- Pipeline : {{ metrics.das.total_pipeline }} DAS - Référence DIM : {{ metrics.das.reference }} - Correct : {{ metrics.das.correct }} - Modifié : {{ metrics.das.modifie }} - Supprimé : {{ metrics.das.supprime }} - Ajouté : {{ metrics.das.ajoutes }} -
-
- -
- Taux DAS manqués : - {{ "%.1f" | format(metrics.das.miss_rate * 100) }}% - ({{ metrics.das.ajoutes }} ajoutés / {{ metrics.das.reference }} référence) -
-
-
- - -{% if metrics.by_confidence %} -
-

Par niveau de confiance

- - - - - - - - - - - - - - {% for conf, bucket in metrics.by_confidence.items() %} - - - - - - - - - - {% endfor %} - -
ConfianceTotal DASCorrectModifiéSuppriméPrecisionHallucination
{{ conf | confidence_badge }}{{ bucket.total }}{{ bucket.correct }}{{ bucket.modifie }}{{ bucket.supprime }}{{ "%.1f" | format(bucket.precision * 100) }}%{{ "%.1f" | format(bucket.hallucination * 100) }}%
-
-{% endif %} - - -{% if metrics.by_source %} -
-

Par source d'extraction

- - - - - - - - - - - - - - {% for source, bucket in metrics.by_source.items() %} - - - - - - - - - - {% endfor %} - -
SourceTotal DASCorrectModifiéSuppriméPrecisionHallucination
{{ source }}{{ bucket.total }}{{ bucket.correct }}{{ bucket.modifie }}{{ bucket.supprime }}{{ "%.1f" | format(bucket.precision * 100) }}%{{ "%.1f" | format(bucket.hallucination * 100) }}%
-
-{% endif %} - - -{% if metrics.top_corrections %} -
-

Top corrections DAS (code pipeline → code DIM)

- - - - - - - - - - - {% for (code_from, code_to), count in metrics.top_corrections %} - - - - - - - {% endfor %} - -
Code pipelineCode corrigéOccurrences
{{ code_from }}{{ code_to }}{{ count }}
-
-{% endif %} - - -{% if metrics.dp_corrections %} -
-

Top corrections DP (code pipeline → code DIM)

- - - - - - - - - - - {% for (code_from, code_to), count in metrics.dp_corrections %} - - - - - - - {% endfor %} - -
Code pipelineCode corrigéOccurrences
{{ code_from }}{{ code_to }}{{ count }}
-
-{% endif %} - -{% endif %} -{% endblock %} diff --git a/t2a_install_rag_cleanup/src/viewer/validation.py b/t2a_install_rag_cleanup/src/viewer/validation.py deleted file mode 100644 index a0856e6..0000000 --- a/t2a_install_rag_cleanup/src/viewer/validation.py +++ /dev/null @@ -1,272 +0,0 @@ -"""Gestionnaire de données pour la validation DIM (gold standard).""" - -from __future__ import annotations - -import json -import logging -import os -import tempfile -from datetime import datetime -from pathlib import Path - -from ..config import BASE_DIR - -logger = logging.getLogger(__name__) - -GOLD_DIR = BASE_DIR / "data" / "gold_standard" - - -class ValidationManager: - """Gère les annotations de validation DIM (fichiers JSON par dossier).""" - - def __init__(self, gold_dir: Path | None = None): - self.gold_dir = gold_dir or GOLD_DIR - self.gold_dir.mkdir(parents=True, exist_ok=True) - - # ------------------------------------------------------------------ - # Helpers - # ------------------------------------------------------------------ - - def _annotation_path(self, dossier_id: str) -> Path: - """Chemin du fichier annotation pour un dossier donné.""" - safe_name = dossier_id.replace("/", "__") + ".json" - return self.gold_dir / safe_name - - # ------------------------------------------------------------------ - # CRUD - # ------------------------------------------------------------------ - - def load_annotation(self, dossier_id: str) -> dict | None: - """Charge l'annotation existante pour un dossier.""" - path = self._annotation_path(dossier_id) - if not path.exists(): - return None - try: - return json.loads(path.read_text(encoding="utf-8")) - except Exception: - logger.warning("Impossible de charger l'annotation %s", path) - return None - - def save_annotation(self, dossier_id: str, data: dict) -> None: - """Sauvegarde atomique (write temp + rename) d'une annotation.""" - path = self._annotation_path(dossier_id) - data["dossier_id"] = dossier_id - data.setdefault("date_validation", datetime.now().isoformat(timespec="seconds")) - - fd, tmp_path = tempfile.mkstemp( - dir=str(self.gold_dir), suffix=".tmp", prefix=".annot_" - ) - try: - with os.fdopen(fd, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - os.replace(tmp_path, path) - except Exception: - # Nettoyage en cas d'erreur - try: - os.unlink(tmp_path) - except OSError: - pass - raise - - def list_annotations(self) -> list[dict]: - """Liste toutes les annotations avec métadonnées (statut, validateur, date).""" - results = [] - for path in sorted(self.gold_dir.glob("*.json")): - if path.name.startswith("_"): - continue - try: - data = json.loads(path.read_text(encoding="utf-8")) - results.append({ - "dossier_id": data.get("dossier_id", path.stem.replace("__", "/")), - "statut": data.get("statut", "non_commence"), - "validateur": data.get("validateur", ""), - "date_validation": data.get("date_validation", ""), - }) - except Exception: - logger.warning("Annotation illisible : %s", path) - return results - - def load_selection(self) -> list[str]: - """Charge la liste des dossiers sélectionnés depuis _selection.json.""" - selection_path = self.gold_dir / "_selection.json" - if not selection_path.exists(): - return [] - try: - data = json.loads(selection_path.read_text(encoding="utf-8")) - return data.get("dossiers", []) - except Exception: - return [] - - # ------------------------------------------------------------------ - # Métriques - # ------------------------------------------------------------------ - - def compute_metrics(self, groups: dict) -> dict: - """Calcule precision, recall, F1 et hallucination rate depuis les annotations. - - Args: - groups: résultat de scan_dossiers() pour accéder aux données pipeline. - - Returns: - Dictionnaire de métriques globales et ventilées. - """ - annotations = [] - for path in sorted(self.gold_dir.glob("*.json")): - if path.name.startswith("_"): - continue - try: - data = json.loads(path.read_text(encoding="utf-8")) - if data.get("statut") == "valide": - annotations.append(data) - except Exception: - continue - - total = len(annotations) - if total == 0: - return {"total_valides": 0} - - # --- Métriques DP --- - dp_correct = 0 - dp_modifie = 0 - dp_supprime = 0 - dp_corrections: list[tuple[str, str]] = [] # (pipeline, corrige) - - # --- Métriques DAS --- - das_correct = 0 - das_modifie = 0 - das_supprime = 0 - das_ajoutes = 0 - das_total_pipeline = 0 - das_corrections: list[tuple[str, str]] = [] - - # --- Ventilation par confiance --- - by_confidence: dict[str, dict] = {} - # --- Ventilation par source --- - by_source: dict[str, dict] = {} - - for annot in annotations: - dossier_id = annot.get("dossier_id", "") - - # Trouver les données pipeline correspondantes - pipeline_data = self._find_pipeline_data(dossier_id, groups) - - # DP - dp = annot.get("dp", {}) - dp_statut = dp.get("statut", "correct") - if dp_statut == "correct": - dp_correct += 1 - elif dp_statut == "modifie": - dp_modifie += 1 - code_orig = dp.get("code_pipeline", "") - code_corr = dp.get("code_corrige", "") - if code_orig and code_corr: - dp_corrections.append((code_orig, code_corr)) - elif dp_statut == "supprime": - dp_supprime += 1 - - # DAS - das_list = annot.get("das", []) - das_aj = annot.get("das_ajoutes", []) - das_total_pipeline += len(das_list) - das_ajoutes += len(das_aj) - - for das in das_list: - das_statut = das.get("statut", "correct") - conf = das.get("confidence", "") - source = das.get("source", "") - - if das_statut == "correct": - das_correct += 1 - elif das_statut == "modifie": - das_modifie += 1 - code_orig = das.get("code_pipeline", "") - code_corr = das.get("code_corrige", "") - if code_orig and code_corr: - das_corrections.append((code_orig, code_corr)) - elif das_statut == "supprime": - das_supprime += 1 - - # Ventilation par confiance - if conf: - bucket = by_confidence.setdefault(conf, { - "correct": 0, "modifie": 0, "supprime": 0, "total": 0 - }) - bucket["total"] += 1 - bucket[das_statut] = bucket.get(das_statut, 0) + 1 - - # Ventilation par source - if source: - bucket = by_source.setdefault(source, { - "correct": 0, "modifie": 0, "supprime": 0, "total": 0 - }) - bucket["total"] += 1 - bucket[das_statut] = bucket.get(das_statut, 0) + 1 - - # --- Calculs --- - # DAS reference = correct + modifié + ajoutés (les vrais DAS selon le DIM) - das_reference = das_correct + das_modifie + das_ajoutes - # DAS pipeline valides = correct + modifié (non supprimés) - das_pipeline_valides = das_correct + das_modifie - - precision = das_pipeline_valides / das_total_pipeline if das_total_pipeline > 0 else 0 - recall = das_pipeline_valides / das_reference if das_reference > 0 else 0 - f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0 - hallucination_rate = das_supprime / das_total_pipeline if das_total_pipeline > 0 else 0 - miss_rate = das_ajoutes / das_reference if das_reference > 0 else 0 - - # Top corrections DAS - from collections import Counter - correction_counter = Counter(das_corrections) - top_corrections = correction_counter.most_common(10) - - # Ventilation par confiance : calculer precision par bucket - for bucket in by_confidence.values(): - t = bucket["total"] - valides = bucket.get("correct", 0) + bucket.get("modifie", 0) - bucket["precision"] = valides / t if t > 0 else 0 - bucket["hallucination"] = bucket.get("supprime", 0) / t if t > 0 else 0 - - for bucket in by_source.values(): - t = bucket["total"] - valides = bucket.get("correct", 0) + bucket.get("modifie", 0) - bucket["precision"] = valides / t if t > 0 else 0 - bucket["hallucination"] = bucket.get("supprime", 0) / t if t > 0 else 0 - - return { - "total_valides": total, - "dp": { - "total": total, - "correct": dp_correct, - "modifie": dp_modifie, - "supprime": dp_supprime, - "accuracy": dp_correct / total if total > 0 else 0, - }, - "das": { - "total_pipeline": das_total_pipeline, - "correct": das_correct, - "modifie": das_modifie, - "supprime": das_supprime, - "ajoutes": das_ajoutes, - "reference": das_reference, - "precision": precision, - "recall": recall, - "f1": f1, - "hallucination_rate": hallucination_rate, - "miss_rate": miss_rate, - }, - "by_confidence": by_confidence, - "by_source": by_source, - "top_corrections": top_corrections, - "dp_corrections": Counter(dp_corrections).most_common(10), - } - - def _find_pipeline_data(self, dossier_id: str, groups: dict) -> dict | None: - """Trouve les données pipeline pour un dossier_id donné.""" - # dossier_id est de la forme "45_23183041/fusionne" - parts = dossier_id.split("/") - group_name = parts[0] if parts else "" - items = groups.get(group_name, []) - for item in items: - if "fusionne" in item["name"]: - return item - return items[0] if items else None diff --git a/tests/test_atih_rules.py b/tests/test_atih_rules.py index afd70d5..0739803 100644 --- a/tests/test_atih_rules.py +++ b/tests/test_atih_rules.py @@ -135,46 +135,46 @@ class TestVeto22SameCategory: # ================================================================ -# VETO-23 : Exclusions mutuelles +# VETO-25 : Exclusions mutuelles (ex-VETO-23, refactorisé via diagnostic_conflicts.yaml) # ================================================================ -class TestVeto23MutualExclusions: +class TestVeto25MutualExclusions: def test_e10_e11_mutual(self): - """E10 + E11 = diabète type 1 et 2 → VETO-23.""" + """E10 + E11 = diabète type 1 et 2 → VETO-25.""" d = _make_dossier(dp_code="E10.9", das_codes=["E11.9", "I10"]) report = apply_vetos(d) - v23 = [i for i in report.issues if i.veto == "VETO-23"] - assert len(v23) == 1 - assert "Diabète" in v23[0].message + v25 = [i for i in report.issues if i.veto == "VETO-25"] + assert len(v25) == 1 + assert "Diabète" in v25[0].message def test_i10_i11_mutual(self): - """I10 + I11 = HTA essentielle + secondaire → VETO-23.""" + """I10 + I11 = HTA essentielle + secondaire → VETO-25.""" d = _make_dossier(dp_code="I10", das_codes=["I11.9"]) report = apply_vetos(d) - v23 = [i for i in report.issues if i.veto == "VETO-23"] - assert len(v23) == 1 - assert "HTA" in v23[0].message + v25 = [i for i in report.issues if i.veto == "VETO-25"] + assert len(v25) == 1 + assert "HTA" in v25[0].message def test_i10_i13_mutual(self): - """I10 + I13 (HTA cardiorénale) → VETO-23.""" + """I10 + I13 (HTA cardiorénale) → VETO-25.""" d = _make_dossier(dp_code="K35.8", das_codes=["I10", "I13.0"]) report = apply_vetos(d) - v23 = [i for i in report.issues if i.veto == "VETO-23"] - assert len(v23) == 1 + v25 = [i for i in report.issues if i.veto == "VETO-25"] + assert len(v25) == 1 def test_no_mutual_exclusion(self): - """Pas de conflit → pas de VETO-23.""" + """Pas de conflit → pas de VETO-25.""" d = _make_dossier(dp_code="E11.9", das_codes=["I10", "K35.8"]) report = apply_vetos(d) - v23 = [i for i in report.issues if i.veto == "VETO-23"] - assert len(v23) == 0 + v25 = [i for i in report.issues if i.veto == "VETO-25"] + assert len(v25) == 0 def test_e10_alone_no_veto(self): - """E10 seul → pas de VETO-23.""" + """E10 seul → pas de VETO-25.""" d = _make_dossier(dp_code="E10.9", das_codes=["I10"]) report = apply_vetos(d) - v23 = [i for i in report.issues if i.veto == "VETO-23"] - assert len(v23) == 0 + v25 = [i for i in report.issues if i.veto == "VETO-25"] + assert len(v25) == 0 # ================================================================ @@ -237,7 +237,7 @@ class TestVerdictIntegration: veto_ids = {i.veto for i in report.issues} # Z45 interdit en DP → VETO-20 assert "VETO-20" in veto_ids - # E10+E11 → VETO-23 - assert "VETO-23" in veto_ids + # E10+E11 → VETO-25 (ex-VETO-23, via diagnostic_conflicts.yaml) + assert "VETO-25" in veto_ids # S72 sans externe → VETO-24 assert "VETO-24" in veto_ids diff --git a/tests/test_completude.py b/tests/test_completude.py new file mode 100644 index 0000000..9637677 --- /dev/null +++ b/tests/test_completude.py @@ -0,0 +1,885 @@ +"""Tests de la checklist de complétude documentaire DIM.""" + +import pytest + +from src.config import ( + ActeCCAM, + BiologieCle, + CheckCompletude, + CompletudeDossier, + Diagnostic, + DossierMedical, + Imagerie, + ItemCompletude, + PreuveClinique, + Sejour, + load_completude_rules, +) +from src.quality.completude import build_completude_checklist + + +# ── Helpers ────────────────────────────────────────────────────────── + + +def _make_dossier(**kwargs) -> DossierMedical: + """Crée un DossierMedical minimal avec des valeurs par défaut.""" + return DossierMedical( + sejour=kwargs.get("sejour", Sejour()), + diagnostic_principal=kwargs.get("dp", None), + diagnostics_associes=kwargs.get("das", []), + biologie_cle=kwargs.get("bio", []), + imagerie=kwargs.get("imagerie", []), + actes_ccam=kwargs.get("actes", []), + document_type=kwargs.get("document_type", "crh"), + source_files=kwargs.get("source_files", []), + ) + + +# ── Tests du chargement YAML ──────────────────────────────────────── + + +class TestLoadRules: + def test_load_completude_rules(self): + rules = load_completude_rules() + assert "diagnostics" in rules + assert "actes" in rules + assert len(rules["diagnostics"]) >= 10 # Au moins 10 familles + + def test_rules_structure(self): + rules = load_completude_rules() + for family_id, family in rules["diagnostics"].items(): + assert "prefixes" in family, f"Famille {family_id} sans prefixes" + assert "items" in family, f"Famille {family_id} sans items" + for item in family["items"]: + assert "categorie" in item + assert "element" in item + assert "importance" in item + assert item["importance"] in ("obligatoire", "recommande") + + def test_yaml_version_2(self): + """Le YAML enrichi doit être en version 2.""" + rules = load_completude_rules() + assert rules.get("version") == 2 + + def test_seuils_present_in_yaml(self): + """Vérifier que les seuils sont bien chargés sur certains items.""" + rules = load_completude_rules() + denut = rules["diagnostics"]["denutrition"] + items_with_seuil = [i for i in denut["items"] if "seuil" in i] + assert len(items_with_seuil) >= 2, "Dénutrition doit avoir au moins 2 items avec seuil" + + +# ── Tests dénutrition ──────────────────────────────────────────────── + + +class TestDenutrition: + def test_denutrition_complete(self): + """E43 avec albumine + IMC → defendable, score élevé.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Dénutrition sévère", cim10_suggestion="E43"), + sejour=Sejour(imc=16.5), + bio=[BiologieCle(test="Albumine", valeur="28 g/L", valeur_num=28.0)], + ) + result = build_completude_checklist(dossier) + assert len(result.checks) >= 1 + check_e43 = next(c for c in result.checks if c.code == "E43") + assert check_e43.verdict == "defendable" + assert check_e43.score >= 70 + + # Vérifier les items + alb = next(i for i in check_e43.items if i.element == "Albumine") + assert alb.statut == "present_confirme" # 28 < 30 → confirmé + assert alb.confirmation_detail is not None + + imc = next(i for i in check_e43.items if i.element == "IMC") + assert imc.statut == "present_confirme" # 16.5 < 18.5 → confirmé + + def test_denutrition_albumine_haute(self): + """E43 avec albumine 38 (> 30) → present_non_confirme.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Dénutrition sévère", cim10_suggestion="E43"), + sejour=Sejour(imc=16.5), + bio=[BiologieCle(test="Albumine", valeur="38 g/L", valeur_num=38.0)], + ) + result = build_completude_checklist(dossier) + check_e43 = next(c for c in result.checks if c.code == "E43") + alb = next(i for i in check_e43.items if i.element == "Albumine") + assert alb.statut == "present_non_confirme" + assert alb.confirmation_detail is not None + # Verdict doit refléter la non-confirmation + assert check_e43.verdict == "fragile" + + def test_denutrition_sans_albumine(self): + """E43 sans albumine → fragile.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Dénutrition sévère", cim10_suggestion="E43"), + sejour=Sejour(imc=16.5), + ) + result = build_completude_checklist(dossier) + check_e43 = next(c for c in result.checks if c.code == "E43") + assert check_e43.verdict == "fragile" + alb = next(i for i in check_e43.items if i.element == "Albumine") + assert alb.statut == "absent" + + def test_denutrition_sans_rien(self): + """E43 sans albumine ni IMC → indefendable.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Dénutrition sévère", cim10_suggestion="E43"), + ) + result = build_completude_checklist(dossier) + check_e43 = next(c for c in result.checks if c.code == "E43") + assert check_e43.verdict == "indefendable" + assert check_e43.score < 30 + + def test_e44_match_aussi(self): + """E44.0 (dénutrition modérée) doit aussi matcher les règles de dénutrition.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Dénutrition modérée", cim10_suggestion="E44.0"), + sejour=Sejour(imc=19.5), + bio=[BiologieCle(test="Albumine", valeur="32 g/L", valeur_num=32.0)], + ) + result = build_completude_checklist(dossier) + assert any(c.code == "E44.0" for c in result.checks) + check = next(c for c in result.checks if c.code == "E44.0") + # 32 est dans [30-35] et 19.5 est dans [18.5-21] + alb = next(i for i in check.items if i.element == "Albumine") + assert alb.statut == "present_confirme" + + def test_e44_with_e43_seuils_not_applied(self): + """E44 ne doit pas appliquer les seuils E43 (code_filter).""" + dossier = _make_dossier( + dp=Diagnostic(texte="Dénutrition modérée", cim10_suggestion="E44.1"), + sejour=Sejour(imc=19.0), + bio=[BiologieCle(test="Albumine", valeur="32 g/L", valeur_num=32.0)], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "E44.1") + # Les items avec code_filter=E43 ne doivent PAS apparaître pour E44 + # Les items avec code_filter=E44 DOIVENT apparaître + alb = next((i for i in check.items if i.element == "Albumine"), None) + assert alb is not None + # IMC doit utiliser les seuils E44 (range 18.5-21) + imc = next((i for i in check.items if i.element == "IMC"), None) + assert imc is not None + assert imc.statut == "present_confirme" # 19.0 in [18.5, 21] + + +# ── Tests anémie ───────────────────────────────────────────────────── + + +class TestAnemie: + def test_anemie_sans_hb(self): + """D50 sans hémoglobine → indefendable (Hb est obligatoire).""" + dossier = _make_dossier( + das=[Diagnostic(texte="Anémie ferriprive", cim10_suggestion="D50.9")], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "D50.9") + assert check.verdict == "indefendable" + + def test_anemie_avec_hb_basse(self): + """D64 avec Hb basse (homme) → present_confirme.""" + dossier = _make_dossier( + sejour=Sejour(sexe="M"), + das=[Diagnostic(texte="Anémie", cim10_suggestion="D64.9")], + bio=[BiologieCle(test="Hémoglobine", valeur="9.5 g/dL", valeur_num=9.5)], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "D64.9") + assert check.verdict == "defendable" + hb = next(i for i in check.items if i.element == "Hémoglobine") + assert hb.statut == "present_confirme" # 9.5 < 13 + + def test_anemie_hb_normale(self): + """D64 avec Hb 14 (homme) → present_non_confirme.""" + dossier = _make_dossier( + sejour=Sejour(sexe="M"), + das=[Diagnostic(texte="Anémie", cim10_suggestion="D64.9")], + bio=[BiologieCle(test="Hémoglobine", valeur="14 g/dL", valeur_num=14.0)], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "D64.9") + hb = next(i for i in check.items if i.element == "Hémoglobine") + assert hb.statut == "present_non_confirme" + + def test_anemie_seuil_femme(self): + """D50 avec Hb 12.5 (femme) → non_confirme (seuil femme: < 12).""" + dossier = _make_dossier( + sejour=Sejour(sexe="F"), + das=[Diagnostic(texte="Anémie", cim10_suggestion="D50.9")], + bio=[BiologieCle(test="Hb", valeur="12.5 g/dL", valeur_num=12.5)], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "D50.9") + hb = next(i for i in check.items if i.element == "Hémoglobine") + assert hb.statut == "present_non_confirme" + + +# ── Tests insuffisance rénale ──────────────────────────────────────── + + +class TestInsuffisanceRenale: + def test_ir_avec_creatinine_haute(self): + """N18 avec créatinine 180 (> 120) → present_confirme.""" + dossier = _make_dossier( + das=[Diagnostic(texte="IRC stade 3", cim10_suggestion="N18.3")], + bio=[BiologieCle(test="Créatinine", valeur="180 µmol/L", valeur_num=180.0)], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "N18.3") + assert check.verdict == "defendable" + creat = next(i for i in check.items if i.element == "Créatinine") + assert creat.statut == "present_confirme" + + def test_ir_creatinine_normale(self): + """N18 avec créatinine 90 (≤ 120) → present_non_confirme.""" + dossier = _make_dossier( + das=[Diagnostic(texte="IRC", cim10_suggestion="N18.3")], + bio=[BiologieCle(test="Créatinine", valeur="90 µmol/L", valeur_num=90.0)], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "N18.3") + creat = next(i for i in check.items if i.element == "Créatinine") + assert creat.statut == "present_non_confirme" + + +# ── Tests sepsis ───────────────────────────────────────────────────── + + +class TestSepsis: + def test_sepsis_complet_confirme(self): + """A41 avec CRP > 50 + leucocytes > 10 → confirme.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Sepsis", cim10_suggestion="A41.9"), + bio=[ + BiologieCle(test="CRP", valeur="180 mg/L", valeur_num=180.0), + BiologieCle(test="Leucocytes", valeur="15 G/L", valeur_num=15.0), + ], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "A41.9") + assert check.verdict == "defendable" + crp = next(i for i in check.items if i.element == "CRP") + assert crp.statut == "present_confirme" # 180 > 50 + leuco = next(i for i in check.items if i.element == "Leucocytes") + assert leuco.statut == "present_confirme" # 15 hors [4-10] + + def test_sepsis_leucocytes_normaux(self): + """A41 avec leucocytes 7 (dans norme) → present_non_confirme.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Sepsis", cim10_suggestion="A41.9"), + bio=[ + BiologieCle(test="CRP", valeur="180 mg/L", valeur_num=180.0), + BiologieCle(test="Leucocytes", valeur="7 G/L", valeur_num=7.0), + ], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "A41.9") + leuco = next(i for i in check.items if i.element == "Leucocytes") + assert leuco.statut == "present_non_confirme" + + def test_sepsis_sans_bio(self): + """A41 sans CRP ni leucocytes → indefendable.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Sepsis", cim10_suggestion="A41.9"), + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "A41.9") + assert check.verdict == "indefendable" + + +# ── Tests seuils spécifiques ───────────────────────────────────────── + + +class TestSeuils: + def test_pancreatite_lipase_haute(self): + """K85 avec lipase 250 (> 180) → present_confirme.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Pancréatite aiguë", cim10_suggestion="K85"), + bio=[BiologieCle(test="Lipase", valeur="250 UI/L", valeur_num=250.0)], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "K85") + lip = next(i for i in check.items if i.element == "Lipasémie") + assert lip.statut == "present_confirme" + + def test_pancreatite_lipase_basse(self): + """K85 avec lipase 120 (≤ 180) → present_non_confirme.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Pancréatite aiguë", cim10_suggestion="K85"), + bio=[BiologieCle(test="Lipase", valeur="120 UI/L", valeur_num=120.0)], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "K85") + lip = next(i for i in check.items if i.element == "Lipasémie") + assert lip.statut == "present_non_confirme" + + def test_obesite_imc_confirme(self): + """E66 avec IMC 42 (> 30) → present_confirme.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Obésité morbide", cim10_suggestion="E66.0"), + sejour=Sejour(imc=42.0, poids=130.0, taille=176.0), + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "E66.0") + imc = next(i for i in check.items if i.element == "IMC") + assert imc.statut == "present_confirme" + + def test_obesite_imc_non_confirme(self): + """E66 avec IMC 25 (< 30) → present_non_confirme.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Obésité", cim10_suggestion="E66.9"), + sejour=Sejour(imc=25.0, poids=75.0), + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "E66.9") + imc = next(i for i in check.items if i.element == "IMC") + assert imc.statut == "present_non_confirme" + + def test_hepatique_transaminases(self): + """K72 avec ASAT 85 + ALAT 92 → present_confirme.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Insuffisance hépatique", cim10_suggestion="K72.0"), + bio=[ + BiologieCle(test="ASAT", valeur="85 UI/L", valeur_num=85.0), + BiologieCle(test="ALAT", valeur="92 UI/L", valeur_num=92.0), + ], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "K72.0") + asat = next(i for i in check.items if i.element == "ASAT") + alat = next(i for i in check.items if i.element == "ALAT") + assert asat.statut == "present_confirme" + assert alat.statut == "present_confirme" + + def test_ic_bnp_confirme(self): + """I50 avec BNP 450 (> 100) → present_confirme.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Insuffisance cardiaque", cim10_suggestion="I50.0"), + bio=[BiologieCle(test="BNP", valeur="450 pg/mL", valeur_num=450.0)], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "I50.0") + bnp = next(i for i in check.items if "BNP" in i.element) + assert bnp.statut == "present_confirme" + + def test_electrolytes_sodium_bas(self): + """E87 avec Na 128 (< 135) → present_confirme.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Hyponatrémie", cim10_suggestion="E87.1"), + bio=[ + BiologieCle(test="Sodium", valeur="128 mmol/L", valeur_num=128.0), + BiologieCle(test="Potassium", valeur="4.2 mmol/L", valeur_num=4.2), + ], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "E87.1") + na = next(i for i in check.items if i.element == "Sodium") + assert na.statut == "present_confirme" + # K normal (4.2 dans [3.5-5.0]) → non confirmé + k = next(i for i in check.items if i.element == "Potassium") + assert k.statut == "present_non_confirme" + + def test_bio_sans_valeur_num(self): + """Bio présente mais sans valeur_num → statut 'present' (pas de confrontation seuil).""" + dossier = _make_dossier( + dp=Diagnostic(texte="IRC", cim10_suggestion="N18.3"), + bio=[BiologieCle(test="Créatinine", valeur="élevée")], # pas de valeur_num + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "N18.3") + creat = next(i for i in check.items if i.element == "Créatinine") + assert creat.statut == "present" + + +# ── Tests preuves cliniques ────────────────────────────────────────── + + +class TestPreuvesCliniques: + def test_preuve_clinique_indirect(self): + """Élément absent mais mentionné dans preuves_cliniques → present_indirect.""" + dossier = _make_dossier( + dp=Diagnostic( + texte="Sepsis", + cim10_suggestion="A41.9", + preuves_cliniques=[ + PreuveClinique( + type="biologie", + element="CRP 180 mg/L", + interpretation="syndrome inflammatoire majeur", + ), + ], + ), + bio=[ + # Pas de CRP dans biologie_cle, mais leucocytes oui + BiologieCle(test="Leucocytes", valeur="15 G/L", valeur_num=15.0), + ], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "A41.9") + crp = next(i for i in check.items if i.element == "CRP") + assert crp.statut == "present_indirect" + assert crp.valeur == "CRP 180 mg/L" + assert "preuves cliniques" in crp.confirmation_detail.lower() + + def test_preuve_clinique_ne_remplace_pas_present(self): + """Si l'élément est déjà présent, les preuves ne sont pas utilisées.""" + dossier = _make_dossier( + dp=Diagnostic( + texte="Sepsis", + cim10_suggestion="A41.9", + preuves_cliniques=[ + PreuveClinique( + type="biologie", + element="CRP 180 mg/L", + interpretation="syndrome inflammatoire majeur", + ), + ], + ), + bio=[ + BiologieCle(test="CRP", valeur="180 mg/L", valeur_num=180.0), + BiologieCle(test="Leucocytes", valeur="15 G/L", valeur_num=15.0), + ], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "A41.9") + crp = next(i for i in check.items if i.element == "CRP") + # Doit être present_confirme, pas present_indirect + assert crp.statut == "present_confirme" + + def test_preuve_imagerie_indirect(self): + """Imagerie absente mais mentionnée dans preuves → present_indirect.""" + dossier = _make_dossier( + dp=Diagnostic( + texte="AVC ischémique", + cim10_suggestion="I63.9", + preuves_cliniques=[ + PreuveClinique( + type="imagerie", + element="IRM cérébral avec lésion ischémique", + interpretation="AVC ischémique confirmé à l'IRM", + ), + ], + ), + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "I63.9") + img = next(i for i in check.items if "Scanner/IRM" in i.element) + assert img.statut == "present_indirect" + + +# ── Tests scoring pondéré ──────────────────────────────────────────── + + +class TestScoringPondere: + def test_present_confirme_full_weight(self): + """present_confirme compte pour 1.0.""" + dossier = _make_dossier( + dp=Diagnostic(texte="IRC", cim10_suggestion="N18.3"), + bio=[BiologieCle(test="Créatinine", valeur="200 µmol/L", valeur_num=200.0)], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "N18.3") + assert check.score >= 70 + + def test_present_non_confirme_reduced_weight(self): + """present_non_confirme compte pour 0.25 → score réduit.""" + dossier = _make_dossier( + dp=Diagnostic(texte="IRC", cim10_suggestion="N18.3"), + bio=[BiologieCle(test="Créatinine", valeur="90 µmol/L", valeur_num=90.0)], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "N18.3") + # present_non_confirme → 0.25 weight + assert check.score < 70 + + def test_present_indirect_half_weight(self): + """present_indirect compte pour 0.5.""" + dossier = _make_dossier( + dp=Diagnostic( + texte="Sepsis", + cim10_suggestion="A41.9", + preuves_cliniques=[ + PreuveClinique(type="biologie", element="CRP 200 mg/L", interpretation="CRP élevée"), + PreuveClinique(type="biologie", element="Leucocytes 18 G/L", interpretation="hyperleucocytose"), + ], + ), + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "A41.9") + # Les deux obligatoires sont indirect (0.5 chacun) + assert 20 <= check.score <= 60 + + +# ── Tests tumeurs ──────────────────────────────────────────────────── + + +class TestTumeurs: + def test_tumeur_sans_anapath(self): + """C34 sans ANAPATH → indefendable.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Cancer bronchique", cim10_suggestion="C34.1"), + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "C34.1") + assert check.verdict == "indefendable" + assert "ANAPATH" in result.documents_manquants + + def test_tumeur_avec_anapath(self): + """C34 avec ANAPATH dans les fichiers sources → defendable.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Cancer bronchique", cim10_suggestion="C34.1"), + source_files=["CRH_patient.pdf", "ANAPATH_biopsie.pdf"], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "C34.1") + assert check.verdict == "defendable" + + +# ── Tests actes chirurgicaux ───────────────────────────────────────── + + +class TestActesChirurgicaux: + def test_chirurgie_sans_cro(self): + """Acte CCAM chirurgical sans CRO → indefendable.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Cholécystite", cim10_suggestion="K80.1"), + actes=[ActeCCAM(texte="Cholécystectomie", code_ccam_suggestion="HMFC004")], + ) + result = build_completude_checklist(dossier) + acte_check = next((c for c in result.checks if c.type_diag == "Acte"), None) + assert acte_check is not None + assert acte_check.verdict == "indefendable" + assert "CRO" in result.documents_manquants + + def test_chirurgie_avec_cro(self): + """Acte chirurgical avec CRO → defendable.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Cholécystite", cim10_suggestion="K80.1"), + actes=[ActeCCAM(texte="Cholécystectomie", code_ccam_suggestion="HMFC004")], + source_files=["CRH_patient.pdf", "CRO_cholecystectomie.pdf"], + ) + result = build_completude_checklist(dossier) + acte_check = next((c for c in result.checks if c.type_diag == "Acte"), None) + assert acte_check is not None + assert acte_check.verdict == "defendable" + + +# ── Tests embolie pulmonaire ───────────────────────────────────────── + + +class TestEmboliePulmonaire: + def test_ep_avec_scanner(self): + """I26 avec angioscanner → defendable.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Embolie pulmonaire", cim10_suggestion="I26.0"), + imagerie=[Imagerie(type="Angioscanner thoracique", conclusion="EP bilatérale")], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "I26.0") + assert check.verdict == "defendable" + + def test_ep_sans_imagerie(self): + """I26 sans imagerie → indefendable.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Embolie pulmonaire", cim10_suggestion="I26.9"), + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "I26.9") + assert check.verdict == "indefendable" + + +# ── Tests 8 nouvelles familles ─────────────────────────────────────── + + +class TestNouvellesFamilles: + def test_avc_avec_scanner(self): + """I63 avec scanner cérébral → defendable.""" + dossier = _make_dossier( + dp=Diagnostic(texte="AVC ischémique", cim10_suggestion="I63.3"), + imagerie=[Imagerie(type="Scanner cérébral", conclusion="Ischémie sylvienne gauche")], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "I63.3") + assert check.verdict == "defendable" + + def test_avc_sans_imagerie(self): + """I63 sans imagerie → indefendable.""" + dossier = _make_dossier( + dp=Diagnostic(texte="AVC ischémique", cim10_suggestion="I63.3"), + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "I63.3") + assert check.verdict == "indefendable" + + def test_idm_avec_troponine_haute(self): + """I21 avec troponine 0.5 (> 0.04) → present_confirme.""" + dossier = _make_dossier( + dp=Diagnostic(texte="IDM", cim10_suggestion="I21.0"), + bio=[BiologieCle(test="Troponine I", valeur="0.5 ng/mL", valeur_num=0.5)], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "I21.0") + tropo = next(i for i in check.items if i.element == "Troponine") + assert tropo.statut == "present_confirme" + + def test_idm_sans_troponine(self): + """I21 sans troponine → indefendable.""" + dossier = _make_dossier( + dp=Diagnostic(texte="IDM", cim10_suggestion="I21.0"), + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "I21.0") + assert check.verdict == "indefendable" + + def test_pneumopathie_avec_radio(self): + """J18 avec radio thorax → defendable.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Pneumopathie", cim10_suggestion="J18.9"), + imagerie=[Imagerie(type="Radio thorax", conclusion="Foyer alvéolaire droit")], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "J18.9") + assert check.verdict == "defendable" + + def test_tvp_avec_echodoppler(self): + """I80 avec écho-doppler → defendable.""" + dossier = _make_dossier( + dp=Diagnostic(texte="TVP", cim10_suggestion="I80.2"), + imagerie=[Imagerie(type="Écho-doppler veineux MI", conclusion="TVP fémorale")], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "I80.2") + assert check.verdict == "defendable" + + def test_tvp_sans_imagerie(self): + """I80 sans écho-doppler → indefendable.""" + dossier = _make_dossier( + dp=Diagnostic(texte="TVP", cim10_suggestion="I80.2"), + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "I80.2") + assert check.verdict == "indefendable" + + def test_insuff_resp_avec_gds(self): + """J96 avec gaz du sang → defendable.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Insuffisance respiratoire", cim10_suggestion="J96.0"), + bio=[BiologieCle(test="Gaz du sang", valeur="PaO2 55 mmHg")], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "J96.0") + assert check.verdict == "defendable" + + def test_fracture_avec_radio(self): + """S72 avec imagerie → defendable.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Fracture col fémoral", cim10_suggestion="S72.0"), + imagerie=[Imagerie(type="Radiographie bassin", conclusion="Fracture cervicale vraie")], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "S72.0") + assert check.verdict == "defendable" + + def test_fracture_sans_imagerie(self): + """S72 sans imagerie → indefendable.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Fracture col fémoral", cim10_suggestion="S72.0"), + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "S72.0") + assert check.verdict == "indefendable" + + def test_iu_avec_ecbu(self): + """N39.0 avec ECBU → defendable.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Infection urinaire", cim10_suggestion="N39.0"), + bio=[BiologieCle(test="ECBU", valeur="E.coli > 10^5")], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "N39.0") + assert check.verdict == "defendable" + + def test_iu_sans_ecbu(self): + """N39.0 sans ECBU → indefendable.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Infection urinaire", cim10_suggestion="N39.0"), + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "N39.0") + assert check.verdict == "indefendable" + + def test_fa_avec_ecg(self): + """I48 avec ECG → defendable.""" + dossier = _make_dossier( + dp=Diagnostic(texte="FA", cim10_suggestion="I48.0"), + bio=[BiologieCle(test="ECG", valeur="FA rapide")], + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "I48.0") + assert check.verdict == "defendable" + + def test_fa_sans_ecg(self): + """I48 sans ECG → indefendable.""" + dossier = _make_dossier( + dp=Diagnostic(texte="FA", cim10_suggestion="I48.0"), + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "I48.0") + assert check.verdict == "indefendable" + + def test_ait_g45(self): + """G45 (AIT) doit aussi matcher la famille AVC/AIT.""" + dossier = _make_dossier( + dp=Diagnostic(texte="AIT", cim10_suggestion="G45.9"), + imagerie=[Imagerie(type="IRM cérébral", conclusion="Pas de lésion récente")], + ) + result = build_completude_checklist(dossier) + assert any(c.code == "G45.9" for c in result.checks) + check = next(c for c in result.checks if c.code == "G45.9") + assert check.verdict == "defendable" + + +# ── Tests sans règle applicable ────────────────────────────────────── + + +class TestSansRegle: + def test_code_sans_regle(self): + """Code sans règle applicable → pas de check.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Grippe", cim10_suggestion="J11.1"), + ) + result = build_completude_checklist(dossier) + assert not any(c.code == "J11.1" for c in result.checks) + # Score global par défaut (pas de checks → pas de verdict) + assert result.score_global == 100 + + def test_dossier_vide(self): + """Dossier sans codes → pas de checks.""" + dossier = _make_dossier() + result = build_completude_checklist(dossier) + assert result.checks == [] + assert result.score_global == 100 + + +# ── Tests verdict global ───────────────────────────────────────────── + + +class TestVerdictGlobal: + def test_mix_defendable_et_fragile(self): + """Un code defendable + un fragile → verdict global fragile.""" + dossier = _make_dossier( + dp=Diagnostic(texte="IRC", cim10_suggestion="N18.3"), + das=[Diagnostic(texte="Dénutrition", cim10_suggestion="E43")], + bio=[BiologieCle(test="Créatinine", valeur="180", valeur_num=180.0)], + # E43 n'a ni albumine ni IMC → indefendable + ) + result = build_completude_checklist(dossier) + assert result.verdict_global in ("fragile", "indefendable") + + def test_tous_defendables(self): + """Tous les codes defendables → verdict global defendable.""" + dossier = _make_dossier( + dp=Diagnostic(texte="IRC", cim10_suggestion="N18.3"), + bio=[BiologieCle(test="Créatinine", valeur="180", valeur_num=180.0)], + ) + result = build_completude_checklist(dossier) + assert result.verdict_global == "defendable" + + +# ── Tests DAS ruled_out (ignorés) ──────────────────────────────────── + + +class TestDasRuledOut: + def test_das_ruled_out_ignore(self): + """Un DAS ruled_out ne doit pas apparaître dans les checks.""" + dossier = _make_dossier( + das=[ + Diagnostic(texte="Anémie", cim10_suggestion="D50.9", status="ruled_out"), + ], + ) + result = build_completude_checklist(dossier) + assert not any(c.code == "D50.9" for c in result.checks) + + +# ── Tests documents présents ───────────────────────────────────────── + + +class TestDocumentsPresents: + def test_documents_listes(self): + """Les types de documents sont listés.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Test", cim10_suggestion="J18.9"), + document_type="crh", + source_files=["CRH_1.pdf", "CRO_op.pdf"], + imagerie=[Imagerie(type="Radio thorax", conclusion="Normal")], + ) + result = build_completude_checklist(dossier) + assert "crh" in result.documents_presents + assert "cro" in result.documents_presents + + +# ── Tests obésité ──────────────────────────────────────────────────── + + +class TestObesite: + def test_obesite_avec_imc_et_poids(self): + """E66 avec IMC 42 + poids → defendable, score élevé.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Obésité morbide", cim10_suggestion="E66.0"), + sejour=Sejour(imc=42.0, poids=130.0, taille=176.0), + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "E66.0") + assert check.verdict == "defendable" + assert check.score == 100 + + def test_obesite_sans_imc(self): + """E66 sans IMC → fragile/indefendable.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Obésité", cim10_suggestion="E66.9"), + sejour=Sejour(poids=130.0), # Poids mais pas d'IMC + ) + result = build_completude_checklist(dossier) + check = next(c for c in result.checks if c.code == "E66.9") + assert check.verdict in ("fragile", "indefendable") + + +# ── Test intégration pipeline ──────────────────────────────────────── + + +class TestIntegration: + def test_completude_dans_dossier_medical(self): + """Le champ completude existe et accepte un CompletudeDossier.""" + dossier = DossierMedical() + assert dossier.completude is None + dossier.completude = build_completude_checklist(dossier) + assert isinstance(dossier.completude, CompletudeDossier) + + def test_serialization_json(self): + """Le résultat se sérialise en JSON sans erreur.""" + dossier = _make_dossier( + dp=Diagnostic(texte="Dénutrition", cim10_suggestion="E43"), + sejour=Sejour(imc=16.5), + bio=[BiologieCle(test="Albumine", valeur="28 g/L", valeur_num=28.0)], + ) + result = build_completude_checklist(dossier) + json_str = result.model_dump_json() + assert "E43" in json_str + assert "defendable" in json_str + # Le nouveau champ doit apparaître + assert "confirmation_detail" in json_str + + def test_confirmation_detail_in_model(self): + """Le champ confirmation_detail est bien sérialisé.""" + item = ItemCompletude( + categorie="biologie", + element="Albumine", + statut="present_confirme", + valeur="28 g/L", + importance="obligatoire", + confirmation_detail="Albumine 28 g/L < 30 → confirme E43", + ) + data = item.model_dump() + assert data["confirmation_detail"] == "Albumine 28 g/L < 30 → confirme E43" + assert data["statut"] == "present_confirme" diff --git a/tests/test_cpam_response.py b/tests/test_cpam_response.py index f3a88c2..4c3ea57 100644 --- a/tests/test_cpam_response.py +++ b/tests/test_cpam_response.py @@ -1,5 +1,6 @@ """Tests pour la génération de contre-argumentation CPAM.""" +import os from unittest.mock import patch, call import pytest @@ -1659,6 +1660,7 @@ class TestBuildBioSummary: assert "CRP" not in summary +@patch.dict(os.environ, {"T2A_CPAM_MAX_CORRECTIONS": "2"}) class TestCorrectionLoop: """Tests pour la boucle de correction adversariale.""" diff --git a/tests/test_extraction_multiformat.py b/tests/test_extraction_multiformat.py new file mode 100644 index 0000000..84812c2 --- /dev/null +++ b/tests/test_extraction_multiformat.py @@ -0,0 +1,417 @@ +"""Tests pour l'extraction multi-format (PDF, images, DOCX).""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from src.extraction.document_router import ( + SUPPORTED_EXTENSIONS, + extract_document_with_pages, +) +from src.extraction.page_tracker import PageTracker +from src.extraction.pdf_extractor import ( + ExtractionMethod, + ExtractionStats, + _compute_extraction_stats, +) + + +# --------------------------------------------------------------------------- +# Tests du router — dispatch par extension +# --------------------------------------------------------------------------- + + +class TestDocumentRouter: + """Tests unitaires du dispatch par extension.""" + + def test_router_pdf_dispatches_correctly(self, tmp_path): + """Un fichier .pdf est dispatché vers extract_text_with_pages.""" + pdf_file = tmp_path / "test.pdf" + pdf_file.touch() + + mock_stats = ExtractionStats(total_pages=1, source_format="pdf") + mock_tracker = PageTracker([(0, 10)]) + mock_return = ("texte pdf", mock_tracker, mock_stats) + + with patch( + "src.extraction.pdf_extractor.extract_text_with_pages", + return_value=mock_return, + ): + result = extract_document_with_pages(pdf_file) + assert len(result) == 3 + assert result[0] == "texte pdf" + assert result[2].source_format == "pdf" + + def test_router_image_dispatches_correctly(self, tmp_path): + """Un fichier .png est dispatché vers extract_text_from_image.""" + png_file = tmp_path / "test.png" + png_file.touch() + + mock_stats = ExtractionStats( + total_pages=1, source_format="image", + methods=[ExtractionMethod.IMAGE], + ) + mock_tracker = PageTracker([(0, 10)]) + mock_return = ("texte ocr", mock_tracker, mock_stats) + + with patch( + "src.extraction.image_extractor.extract_text_from_image", + return_value=mock_return, + ): + result = extract_document_with_pages(png_file) + assert len(result) == 3 + assert result[0] == "texte ocr" + assert result[2].source_format == "image" + + def test_router_docx_dispatches_correctly(self, tmp_path): + """Un fichier .docx est dispatché vers extract_text_from_docx.""" + docx_file = tmp_path / "test.docx" + docx_file.touch() + + mock_stats = ExtractionStats( + total_pages=1, source_format="docx", + methods=[ExtractionMethod.DOCX], + ) + mock_tracker = PageTracker([(0, 10)]) + mock_return = ("texte docx", mock_tracker, mock_stats) + + with patch( + "src.extraction.docx_extractor.extract_text_from_docx", + return_value=mock_return, + ): + result = extract_document_with_pages(docx_file) + assert len(result) == 3 + assert result[0] == "texte docx" + assert result[2].source_format == "docx" + + def test_router_unsupported_extension_raises(self, tmp_path): + """Une extension non supportée lève ValueError.""" + xyz_file = tmp_path / "test.xyz" + xyz_file.touch() + + with pytest.raises(ValueError, match="Format non supporté"): + extract_document_with_pages(xyz_file) + + def test_router_supported_extensions_complete(self): + """Vérifie que SUPPORTED_EXTENSIONS contient tous les formats prévus.""" + expected = {".pdf", ".jpg", ".jpeg", ".png", ".tiff", ".tif", ".docx"} + assert SUPPORTED_EXTENSIONS == expected + + @pytest.mark.parametrize("ext", [".jpg", ".jpeg", ".tiff", ".tif"]) + def test_router_all_image_extensions(self, tmp_path, ext): + """Toutes les extensions image sont reconnues.""" + img_file = tmp_path / f"test{ext}" + img_file.touch() + + mock_stats = ExtractionStats(total_pages=1, source_format="image") + mock_tracker = PageTracker([(0, 5)]) + + with patch( + "src.extraction.image_extractor.extract_text_from_image", + return_value=("texte", mock_tracker, mock_stats), + ): + result = extract_document_with_pages(img_file) + assert result[2].source_format == "image" + + +# --------------------------------------------------------------------------- +# Tests ExtractionStats enrichi +# --------------------------------------------------------------------------- + + +class TestExtractionStats: + """Tests des nouveaux champs d'ExtractionStats.""" + + def test_stats_tracks_method(self): + """Vérifie que methods/backend/source_format sont renseignés.""" + methods = [ + ExtractionMethod.NATIVE_PDFPLUMBER, + ExtractionMethod.NATIVE_PDFPLUMBER, + ExtractionMethod.OCR_DOCTR, + ] + stats = _compute_extraction_stats( + ["page 1 avec du texte", "page 2 avec du texte", "page 3 ocr"], + methods=methods, + backend="pdfplumber", + ) + + assert stats.methods == methods + assert stats.backend == "pdfplumber" + assert stats.source_format == "pdf" + assert stats.native_pages == 2 + assert stats.ocr_pages == 1 + + def test_stats_default_values(self): + """Les valeurs par défaut sont correctes.""" + stats = ExtractionStats() + assert stats.methods == [] + assert stats.native_pages == 0 + assert stats.ocr_pages == 0 + assert stats.backend == "pdfplumber" + assert stats.source_format == "pdf" + + def test_stats_image_format(self): + """ExtractionStats pour une image.""" + stats = ExtractionStats( + total_pages=1, + source_format="image", + methods=[ExtractionMethod.IMAGE], + backend="doctr", + ) + assert stats.source_format == "image" + assert stats.methods[0] == ExtractionMethod.IMAGE + + def test_stats_docx_format(self): + """ExtractionStats pour un DOCX.""" + stats = ExtractionStats( + total_pages=3, + source_format="docx", + methods=[ExtractionMethod.DOCX] * 3, + backend="python-docx", + native_pages=3, + ) + assert stats.source_format == "docx" + assert len(stats.methods) == 3 + assert all(m == ExtractionMethod.DOCX for m in stats.methods) + + def test_compute_stats_with_methods(self): + """_compute_extraction_stats calcule correctement native_pages et ocr_pages.""" + pages = ["Hello world" * 10, "", "Texte OCR récupéré"] + methods = [ + ExtractionMethod.NATIVE_PDFPLUMBER, + ExtractionMethod.OCR_DOCTR, + ExtractionMethod.OCR_DOCTR, + ] + stats = _compute_extraction_stats(pages, methods, "pdfplumber") + + assert stats.total_pages == 3 + assert stats.native_pages == 1 + assert stats.ocr_pages == 2 + assert 2 in stats.empty_pages # page 2 (1-indexed) est vide + + def test_extraction_method_enum_values(self): + """Vérifie les valeurs de l'enum ExtractionMethod.""" + assert ExtractionMethod.NATIVE_PDFPLUMBER.value == "native_pdfplumber" + assert ExtractionMethod.NATIVE_PYMUPDF.value == "native_pymupdf" + assert ExtractionMethod.OCR_DOCTR.value == "ocr_doctr" + assert ExtractionMethod.DOCX.value == "docx" + assert ExtractionMethod.IMAGE.value == "image_ocr" + + +# --------------------------------------------------------------------------- +# Tests OCR fallback +# --------------------------------------------------------------------------- + + +class TestOCRFallback: + """Tests du mécanisme de fallback OCR.""" + + def test_ocr_fallback_disabled_by_default(self): + """Le fallback OCR est désactivé par défaut.""" + from src.extraction.pdf_extractor import OCR_FALLBACK_ENABLED + + # Par défaut (sans variable d'environnement), le fallback est désactivé + # Note : ce test vérifie le comportement par défaut, pas une variable statique + # car elle peut être modifiée par les variables d'environnement du CI + assert isinstance(OCR_FALLBACK_ENABLED, bool) + + def test_ocr_fallback_config_values(self): + """Les constantes de config sont cohérentes.""" + from src.extraction.pdf_extractor import OCR_FALLBACK_MIN_CHARS, PDF_BACKEND + + assert isinstance(OCR_FALLBACK_MIN_CHARS, int) + assert OCR_FALLBACK_MIN_CHARS > 0 + assert PDF_BACKEND in ("pdfplumber", "pymupdf") + + +# --------------------------------------------------------------------------- +# Tests DOCX extracteur (avec fixture) +# --------------------------------------------------------------------------- + + +class TestDocxExtractor: + """Tests de l'extracteur DOCX.""" + + @pytest.fixture + def sample_docx(self, tmp_path): + """Crée un petit DOCX de test.""" + try: + from docx import Document + except ImportError: + pytest.skip("python-docx non installé") + + doc = Document() + doc.add_paragraph("Premier paragraphe du document médical.") + doc.add_paragraph("Diagnostic principal : Pneumopathie J18.9") + doc.add_paragraph("Traitement de sortie : Amoxicilline 1g x3/j") + docx_path = tmp_path / "test_medical.docx" + doc.save(str(docx_path)) + return docx_path + + @pytest.fixture + def docx_with_page_breaks(self, tmp_path): + """Crée un DOCX avec des sauts de page.""" + try: + from docx import Document + from docx.oxml.ns import qn + from docx.oxml import OxmlElement + except ImportError: + pytest.skip("python-docx non installé") + + doc = Document() + doc.add_paragraph("Page 1 : Antécédents du patient.") + + # Ajouter un saut de page + p = doc.add_paragraph() + run = p.add_run() + br = OxmlElement("w:br") + br.set(qn("w:type"), "page") + run._element.append(br) + + doc.add_paragraph("Page 2 : Compte-rendu opératoire.") + docx_path = tmp_path / "test_pages.docx" + doc.save(str(docx_path)) + return docx_path + + def test_extract_docx_basic(self, sample_docx): + """Extraction basique d'un DOCX.""" + from src.extraction.docx_extractor import extract_text_from_docx + + text, tracker, stats = extract_text_from_docx(sample_docx) + + assert "Pneumopathie" in text + assert "Amoxicilline" in text + assert stats.source_format == "docx" + assert stats.total_pages >= 1 + assert stats.total_chars > 0 + assert all(m == ExtractionMethod.DOCX for m in stats.methods) + + def test_extract_docx_with_page_breaks(self, docx_with_page_breaks): + """Extraction d'un DOCX avec sauts de page.""" + from src.extraction.docx_extractor import extract_text_from_docx + + text, tracker, stats = extract_text_from_docx(docx_with_page_breaks) + + assert stats.total_pages == 2 + assert "Antécédents" in text + assert "Compte-rendu" in text + # PageTracker fonctionne + assert tracker.char_to_page(0) == 1 + + def test_extract_docx_file_not_found(self, tmp_path): + """FileNotFoundError si le fichier n'existe pas.""" + from src.extraction.docx_extractor import extract_text_from_docx + + with pytest.raises(FileNotFoundError): + extract_text_from_docx(tmp_path / "inexistant.docx") + + def test_extract_docx_stats_backend(self, sample_docx): + """Le backend est bien 'python-docx'.""" + from src.extraction.docx_extractor import extract_text_from_docx + + _, _, stats = extract_text_from_docx(sample_docx) + assert stats.backend == "python-docx" + + +# --------------------------------------------------------------------------- +# Tests image extracteur (mock OCR) +# --------------------------------------------------------------------------- + + +class TestImageExtractor: + """Tests de l'extracteur d'images (avec OCR mocké).""" + + def test_extract_image_file_not_found(self, tmp_path): + """FileNotFoundError si l'image n'existe pas.""" + from src.extraction.image_extractor import extract_text_from_image + + with pytest.raises(FileNotFoundError): + extract_text_from_image(tmp_path / "inexistant.png") + + def test_extract_image_stats_format(self, tmp_path): + """Vérifie le format des stats pour une image.""" + # Créer une petite image PNG + from PIL import Image + + img = Image.new("RGB", (100, 50), color="white") + img_path = tmp_path / "test.png" + img.save(str(img_path)) + + with patch("src.extraction.image_extractor.ocr_image", return_value="Texte OCR extrait"): + from src.extraction.image_extractor import extract_text_from_image + + text, tracker, stats = extract_text_from_image(img_path) + + assert text == "Texte OCR extrait" + assert stats.source_format == "image" + assert stats.total_pages == 1 + assert stats.ocr_pages == 1 + assert stats.native_pages == 0 + assert stats.methods == [ExtractionMethod.IMAGE] + assert stats.backend == "doctr" + + def test_extract_image_empty_result(self, tmp_path): + """Image sans texte détectable.""" + from PIL import Image + + img = Image.new("RGB", (100, 50), color="white") + img_path = tmp_path / "blank.png" + img.save(str(img_path)) + + with patch("src.extraction.image_extractor.ocr_image", return_value=""): + from src.extraction.image_extractor import extract_text_from_image + + text, tracker, stats = extract_text_from_image(img_path) + + assert text == "" + assert stats.empty_pages == [1] + assert stats.total_chars == 0 + + +# --------------------------------------------------------------------------- +# Tests de non-régression +# --------------------------------------------------------------------------- + + +class TestBackwardCompat: + """Tests de rétrocompatibilité.""" + + def test_process_pdf_alias_exists(self): + """process_pdf est un alias de process_document.""" + from src.main import process_document, process_pdf + + assert process_pdf is process_document + + def test_extraction_stats_existing_properties(self): + """Les propriétés existantes d'ExtractionStats fonctionnent toujours.""" + stats = ExtractionStats( + total_pages=5, + empty_pages=[2, 4], + chars_per_page=[100, 0, 200, 0, 300], + total_chars=600, + ) + assert stats.usable_pages == 3 + assert stats.coverage_ratio == 0.6 + assert stats.has_quality_issues() is True + alert = stats.to_alert() + assert alert is not None + assert "2/5" in alert + + flags = stats.to_flags() + assert flags["extraction_empty_pages"] == [2, 4] + assert flags["extraction_total_pages"] == 5 + assert flags["extraction_coverage"] == 0.6 + + def test_extraction_stats_no_issues(self): + """Pas d'alerte quand tout va bien.""" + stats = ExtractionStats( + total_pages=3, + chars_per_page=[100, 200, 300], + total_chars=600, + ) + assert not stats.has_quality_issues() + assert stats.to_alert() is None + assert stats.to_flags() == {} diff --git a/tests/test_nutrition_has2021.py b/tests/test_nutrition_has2021.py new file mode 100644 index 0000000..178d5b6 --- /dev/null +++ b/tests/test_nutrition_has2021.py @@ -0,0 +1,198 @@ +"""Tests unitaires pour la détection dénutrition HAS/FFN 2021.""" + +import re +import pytest + +from src.config import BiologieCle, Diagnostic, DossierMedical, Sejour +from src.medical.diagnostic_extraction import _detect_nutrition_has2021, _DAS_PATTERNS +from src.medical.cim10_dict import normalize_text + + +# ── Helpers ────────────────────────────────────────────────────────── + + +def _make_dossier(age=None, imc=None, albumine=None, existing_codes=None): + """Construit un DossierMedical minimal pour les tests.""" + dossier = DossierMedical() + dossier.sejour = Sejour(age=age, imc=imc) + if albumine is not None: + dossier.biologie_cle.append( + BiologieCle( + test="Albumine", + valeur=str(albumine), + valeur_num=float(albumine), + anomalie=True, + quality="ok", + ) + ) + for code in (existing_codes or []): + dossier.diagnostics_associes.append( + Diagnostic(texte="existant", cim10_suggestion=code, source="test") + ) + return dossier + + +# ── Tests _detect_nutrition_has2021 ────────────────────────────────── + + +class TestDetectNutritionHAS2021: + """Tests de la détection déterministe basée sur IMC/âge/albumine.""" + + def test_adulte_imc17_albumine28_gives_E43(self): + """Adulte IMC 17.0 + albumine 28 → E43 (sévère via IMC ≤17 ET albumine <30).""" + dossier = _make_dossier(age=50, imc=17.0, albumine=28) + _detect_nutrition_has2021(dossier) + codes = [d.cim10_suggestion for d in dossier.diagnostics_associes] + assert "E43" in codes + + def test_adulte_imc18_sans_albumine_gives_E44(self): + """Adulte IMC 18.0 sans albumine → E44.0 (modéré, 17 < IMC < 18.5).""" + dossier = _make_dossier(age=45, imc=18.0) + _detect_nutrition_has2021(dossier) + codes = [d.cim10_suggestion for d in dossier.diagnostics_associes] + assert "E44.0" in codes + + def test_personne_agee_75_imc21_gives_E44(self): + """≥70 ans IMC 21.0 → E44.0 (seuil gériatrique < 22).""" + dossier = _make_dossier(age=75, imc=21.0) + _detect_nutrition_has2021(dossier) + codes = [d.cim10_suggestion for d in dossier.diagnostics_associes] + assert "E44.0" in codes + + def test_personne_agee_75_imc19_gives_E43(self): + """≥70 ans IMC 19.0 → E43 (sévère, IMC < 20).""" + dossier = _make_dossier(age=75, imc=19.0) + _detect_nutrition_has2021(dossier) + codes = [d.cim10_suggestion for d in dossier.diagnostics_associes] + assert "E43" in codes + + def test_adulte_imc25_no_das(self): + """Adulte IMC 25.0 → aucun DAS (au-dessus du seuil).""" + dossier = _make_dossier(age=40, imc=25.0) + _detect_nutrition_has2021(dossier) + codes = [d.cim10_suggestion for d in dossier.diagnostics_associes] + assert not any(c in codes for c in ("E43", "E44.0", "E46")) + + def test_e46_deja_code_no_ajout(self): + """E46 déjà codé → aucun ajout.""" + dossier = _make_dossier(age=50, imc=16.0, existing_codes=["E46"]) + _detect_nutrition_has2021(dossier) + e_codes = [d.cim10_suggestion for d in dossier.diagnostics_associes + if d.cim10_suggestion in ("E43", "E44.0", "E46")] + # Seul le E46 existant doit être présent + assert e_codes == ["E46"] + + def test_pas_imc_no_ajout(self): + """Pas d'IMC → aucun ajout (dégradation gracieuse).""" + dossier = _make_dossier(age=50, imc=None) + _detect_nutrition_has2021(dossier) + codes = [d.cim10_suggestion for d in dossier.diagnostics_associes] + assert not any(c in codes for c in ("E43", "E44.0", "E46")) + + def test_albumine_upgrade_severity(self): + """IMC modéré + albumine < 30 → upgrade vers E43.""" + dossier = _make_dossier(age=50, imc=18.0, albumine=25) + _detect_nutrition_has2021(dossier) + codes = [d.cim10_suggestion for d in dossier.diagnostics_associes] + assert "E43" in codes # Albumine < 30 → sévère + + def test_alerte_codage_added(self): + """Vérifie qu'une alerte codage est ajoutée.""" + dossier = _make_dossier(age=50, imc=17.0) + _detect_nutrition_has2021(dossier) + assert any("HAS 2021" in a for a in dossier.alertes_codage) + + def test_source_is_has2021(self): + """Vérifie que la source est 'has2021'.""" + dossier = _make_dossier(age=50, imc=17.0) + _detect_nutrition_has2021(dossier) + has_diags = [d for d in dossier.diagnostics_associes if d.source == "has2021"] + assert len(has_diags) == 1 + + def test_age_inconnu_seuils_adulte(self): + """Âge inconnu → seuils adulte par défaut.""" + dossier = _make_dossier(age=None, imc=17.0) + _detect_nutrition_has2021(dossier) + codes = [d.cim10_suggestion for d in dossier.diagnostics_associes] + assert "E43" in codes # IMC ≤ 17 → sévère (seuils adulte) + + def test_personne_agee_70_exact_seuil(self): + """70 ans exactement → utilise les seuils gériatriques.""" + dossier = _make_dossier(age=70, imc=21.5) + _detect_nutrition_has2021(dossier) + codes = [d.cim10_suggestion for d in dossier.diagnostics_associes] + assert "E44.0" in codes # < 22 → modéré avec seuils ≥70 + + +# ── Tests regex albumine (bio_extraction) ──────────────────────────── + + +class TestAlbumineRegex: + """Vérifie l'extraction regex de l'albumine.""" + + ALBUMINE_PATTERN = r"(?:[Aa]lbumin[ée]?(?:mie)?|[Aa]lb(?:u)?[ée]?(?:mie)?)\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:g/[Ll])?" + PREALBUMINE_PATTERN = r"(?:[Pp]r[ée]albumine|[Tt]ransthyr[ée]tine)\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:mg/[Ll]|g/[Ll])?" + + def test_albumine_standard(self): + m = re.search(self.ALBUMINE_PATTERN, "Albumine = 28 g/L") + assert m and m.group(1) == "28" + + def test_albumine_colon(self): + m = re.search(self.ALBUMINE_PATTERN, "albumine: 32.5 g/L") + assert m and m.group(1) == "32.5" + + def test_albumine_sans_unite(self): + m = re.search(self.ALBUMINE_PATTERN, "Albumine 28") + assert m and m.group(1) == "28" + + def test_albuminemie(self): + m = re.search(self.ALBUMINE_PATTERN, "Albuminémie à 25 g/L") + assert m and m.group(1) == "25" + + def test_prealbumine(self): + m = re.search(self.PREALBUMINE_PATTERN, "Préalbumine = 0.15 g/L") + assert m and m.group(1) == "0.15" + + def test_transthyretine(self): + m = re.search(self.PREALBUMINE_PATTERN, "Transthyrétine: 180 mg/L") + assert m and m.group(1) == "180" + + +# ── Tests regex texte dénutrition (DAS patterns) ──────────────────── + + +class TestDenutritionRegexSeverity: + """Vérifie que les patterns textuels de dénutrition détectent la sévérité.""" + + def _match_pattern(self, text): + """Retourne le (label, code) du premier pattern DAS matché.""" + text_norm = normalize_text(text.lower()) + for pat, label, code in _DAS_PATTERNS: + if re.search(pat, text_norm): + return label, code + return None, None + + def test_denutrition_severe_gives_E43(self): + _, code = self._match_pattern("denutrition severe") + assert code == "E43" + + def test_denutrition_moderee_gives_E44(self): + _, code = self._match_pattern("denutrition moderee") + assert code == "E44.0" + + def test_denutrition_generic_gives_E46(self): + _, code = self._match_pattern("denutrition") + assert code == "E46" + + def test_malnutrition_severe_gives_E43(self): + _, code = self._match_pattern("malnutrition severe") + assert code == "E43" + + def test_denutrition_grade_iii_gives_E43(self): + _, code = self._match_pattern("denutrition grade III") + assert code == "E43" + + def test_hypoalbuminemie_severe_gives_E46(self): + """hypoalbuminemie severe → E46 (pattern générique).""" + _, code = self._match_pattern("hypoalbuminemie severe") + assert code == "E46" diff --git a/tests/test_nutrition_has2021_integration.py b/tests/test_nutrition_has2021_integration.py new file mode 100644 index 0000000..80eb356 --- /dev/null +++ b/tests/test_nutrition_has2021_integration.py @@ -0,0 +1,364 @@ +"""Tests d'intégration : détection dénutrition HAS/FFN 2021 sur dossiers réalistes. + +4 cas cliniques réalistes passés dans le pipeline complet extract_medical_info. +Vérifie l'interaction entre regex bio, détection HAS 2021, conflits, et sévérité CMA. +""" + +import pytest + +from src.config import DossierMedical +from src.medical.cim10_extractor import extract_medical_info + + +# ── Cas 1 : Personne âgée dénutrie (≥70 ans, seuils gériatriques) ─── + + +class TestCas1PersonneAgeeDenutrie: + """Mme D., 81 ans, hospitalisée pour pneumopathie. + + IMC 20.5 → sous le seuil gériatrique < 22 → dénutrition modérée. + Albumine 27 g/L → < 30 → critère de sévérité → upgrade vers E43. + Attendu : E43 (dénutrition sévère) détecté par HAS 2021. + """ + + @pytest.fixture + def dossier(self) -> DossierMedical: + parsed = { + "type": "crh", + "patient": { + "sexe": "F", + "date_naissance": "15/03/1943", + }, + "sejour": { + "date_entree": "10/01/2025", + "date_sortie": "20/01/2025", + }, + "diagnostics": [], + "signes_vitaux": {"imc": 20.5, "poids_kg": 48, "taille_cm": 153}, + } + text = """\ +Votre patiente née le 15/03/1943 a été hospitalisée du 10/01/2025 au 20/01/2025. + +Antécédents : +- Hypertension artérielle +- Fibrillation auriculaire paroxystique +- Arthrose invalidante + +Au total : Pneumopathie basale droite d'évolution favorable sous antibiothérapie. + +Biologie d'entrée : +CRP = 145 mg/L +Albumine = 27 g/L +Créatinine = 95 µmol/L +Sodium = 138 mmol/L +Potassium = 4.1 mmol/L +Hémoglobine = 10.2 g/dL + +IMC: 20.5 + +TTT de sortie : +Amoxicilline 1g matin et soir pendant 5 jours +Paracétamol si besoin + +Devenir : retour à domicile.""" + return extract_medical_info(parsed, text) + + def test_age_81_ans(self, dossier): + assert dossier.sejour.age == 81 + + def test_imc_extrait(self, dossier): + assert dossier.sejour.imc == 20.5 + + def test_albumine_extraite(self, dossier): + """L'albumine doit être extraite par le nouveau regex.""" + alb = [b for b in dossier.biologie_cle if b.test == "Albumine"] + assert len(alb) >= 1 + assert alb[0].valeur_num == 27.0 + + def test_denutrition_severe_E43(self, dossier): + """IMC 20.5 (modéré ≥70 ans) + albumine 27 (<30) → E43 sévère.""" + codes = {d.cim10_suggestion for d in dossier.diagnostics_associes} + assert "E43" in codes, f"E43 attendu, trouvé : {codes}" + + def test_source_has2021(self, dossier): + has_diags = [d for d in dossier.diagnostics_associes if d.source == "has2021"] + assert len(has_diags) == 1 + assert has_diags[0].cim10_suggestion == "E43" + + def test_alerte_has2021(self, dossier): + assert any("HAS 2021" in a for a in dossier.alertes_codage) + + def test_pneumopathie_detectee(self, dossier): + """Le DP/DAS pneumopathie ne doit pas être impacté.""" + codes = {d.cim10_suggestion for d in dossier.diagnostics_associes} + # Pneumopathie J18.9 ou DP + all_codes = codes.copy() + if dossier.diagnostic_principal and dossier.diagnostic_principal.cim10_suggestion: + all_codes.add(dossier.diagnostic_principal.cim10_suggestion) + assert "J18.9" in all_codes + + def test_hta_detectee(self, dossier): + codes = {d.cim10_suggestion for d in dossier.diagnostics_associes} + assert "I10" in codes + + +# ── Cas 2 : Patient obèse ET dénutri (conflit E66 + E43 = MEDIUM) ─── + + +class TestCas2ObeseDenutri: + """M. B., 58 ans, hospitalisé pour pancréatite aiguë sur obésité. + + IMC 35.2 → obèse (E66.0). Dénutrition sévère mentionnée dans le texte (E43). + Le conflit E66/dénutrition doit être MEDIUM (pas HARD) selon HAS 2021. + La coexistence est cliniquement possible (sarcopénie de l'obèse). + + DP fourni via Trackare (K85.9) pour éviter que NUKE-3 ne réorganise les codes. + """ + + @pytest.fixture + def dossier(self) -> DossierMedical: + parsed = { + "type": "trackare", + "patient": { + "sexe": "M", + "date_naissance": "22/07/1967", + }, + "sejour": { + "date_entree": "05/02/2025", + "date_sortie": "12/02/2025", + }, + "diagnostics": [ + { + "type": "Principal", + "statut": "actif", + "code_cim10": "K85.9", + "libelle": "Pancréatite aiguë", + } + ], + "signes_vitaux": {"imc": 35.2, "poids_kg": 110, "taille_cm": 177}, + } + text = """\ +Pancréatite aiguë sur terrain d'obésité morbide. +Dénutrition sévère protéique avec sarcopénie documentée, +perte de poids de 15 kg en 3 mois. + +Antécédents : +- Diabète type 2 +- Tabagisme actif + +Biologie : +CRP = 145 mg/L +Albumine = 25 g/L +Lipasémie = 1200 UI/L +HbA1c = 7.8 % +Hémoglobine = 13.5 g/dL + +IMC: 35.2 + +TTT de sortie : +Metformine 1000mg matin et soir +Paracétamol si besoin + +Devenir : retour à domicile.""" + return extract_medical_info(parsed, text) + + def test_dp_pancreatite(self, dossier): + """DP = K85.9 (fourni par Trackare).""" + assert dossier.diagnostic_principal is not None + assert dossier.diagnostic_principal.cim10_suggestion == "K85.9" + + def test_obesite_E66_detectee(self, dossier): + """L'obésité doit être détectée via IMC ≥ 30.""" + codes = {d.cim10_suggestion for d in dossier.diagnostics_associes} + assert "E66.0" in codes + + def test_denutrition_severe_regex_E43(self, dossier): + """'Dénutrition sévère' dans le texte → E43 via regex.""" + codes = {d.cim10_suggestion for d in dossier.diagnostics_associes} + assert "E43" in codes, f"E43 attendu via regex 'denutrition severe', trouvé : {codes}" + + def test_coexistence_E66_E43(self, dossier): + """E66.0 et E43 doivent coexister (pas de blocage HARD).""" + codes = {d.cim10_suggestion for d in dossier.diagnostics_associes} + assert "E66.0" in codes and "E43" in codes + + def test_albumine_extraite(self, dossier): + alb = [b for b in dossier.biologie_cle if b.test == "Albumine"] + assert len(alb) >= 1 + assert alb[0].valeur_num == 25.0 + + def test_diabete_detecte(self, dossier): + codes = {d.cim10_suggestion for d in dossier.diagnostics_associes} + assert "E11.9" in codes + + def test_tabagisme_detecte(self, dossier): + codes = {d.cim10_suggestion for d in dossier.diagnostics_associes} + assert "F17.2" in codes + + +# ── Cas 3 : Adulte dénutri modéré sans albumine (IMC seul) ────────── + + +class TestCas3AdulteDenutriModere: + """Mme L., 45 ans, hospitalisée pour angiocholite. + + IMC 17.8 → entre 17 et 18.5 → dénutrition modérée (E44.0) par HAS 2021. + Pas d'albumine → pas d'upgrade de sévérité. + """ + + @pytest.fixture + def dossier(self) -> DossierMedical: + parsed = { + "type": "crh", + "patient": { + "sexe": "F", + "date_naissance": "12/09/1979", + }, + "sejour": { + "date_entree": "15/03/2025", + "date_sortie": "21/03/2025", + }, + "diagnostics": [], + "signes_vitaux": {"imc": 17.8, "poids_kg": 48, "taille_cm": 164}, + } + text = """\ +Votre patiente née le 12/09/1979 a été hospitalisée du 15/03/2025 au 21/03/2025. + +Antécédents : +- Lithiases vésiculaires +- Anorexie restrictive ancienne (adolescence) + +Au total : Angiocholite sur lithiase du cholédoque traitée par CPRE +puis cholécystectomie par cœlioscopie. + +Biologie d'entrée : +CRP = 92 mg/L +ASAT = 180 UI/L +ALAT = 210 UI/L +Bilirubine totale = 45 µmol/L +Lipasémie = 890 UI/L +Hémoglobine = 11.8 g/dL +Leucocytes = 12.5 G/L + +IMC: 17.8 + +TTT de sortie : +Paracétamol 1g x3/jour +Spasfon si besoin + +Devenir : retour à domicile.""" + return extract_medical_info(parsed, text) + + def test_imc_178(self, dossier): + assert dossier.sejour.imc == 17.8 + + def test_denutrition_moderee_E44(self, dossier): + """IMC 17.8 adulte → E44.0 (modéré, 17 < IMC < 18.5).""" + codes = {d.cim10_suggestion for d in dossier.diagnostics_associes} + assert "E44.0" in codes, f"E44.0 attendu, trouvé : {codes}" + + def test_pas_E43(self, dossier): + """Pas de sévère sans albumine basse ni IMC ≤ 17.""" + codes = {d.cim10_suggestion for d in dossier.diagnostics_associes} + assert "E43" not in codes + + def test_pas_albumine_extraite(self, dossier): + """Pas d'albumine dans le texte → pas d'extraction.""" + alb = [b for b in dossier.biologie_cle if b.test == "Albumine"] + assert len(alb) == 0 + + def test_angiocholite_detectee(self, dossier): + codes = {d.cim10_suggestion for d in dossier.diagnostics_associes} + all_codes = codes.copy() + if dossier.diagnostic_principal and dossier.diagnostic_principal.cim10_suggestion: + all_codes.add(dossier.diagnostic_principal.cim10_suggestion) + assert "K83.0" in all_codes + + def test_cholecystectomie_detectee(self, dossier): + acte_codes = {a.code_ccam_suggestion for a in dossier.actes_ccam} + assert "HMFC004" in acte_codes + + def test_alerte_has2021(self, dossier): + assert any("HAS 2021" in a for a in dossier.alertes_codage) + + +# ── Cas 4 : IMC normal, pas de dénutrition (contrôle négatif) ─────── + + +class TestCas4ControleNegatif: + """M. R., 55 ans, hospitalisé pour colique hépatique. + + IMC 26.3 → au-dessus de tous les seuils → aucune dénutrition. + Albumine 38 g/L → normale. + Vérifie que la détection HAS 2021 ne produit pas de faux positif. + """ + + @pytest.fixture + def dossier(self) -> DossierMedical: + parsed = { + "type": "crh", + "patient": { + "sexe": "M", + "date_naissance": "30/11/1969", + }, + "sejour": { + "date_entree": "01/04/2025", + "date_sortie": "03/04/2025", + }, + "diagnostics": [], + "signes_vitaux": {"imc": 26.3, "poids_kg": 82, "taille_cm": 176}, + } + text = """\ +Votre patient né le 30/11/1969 a été hospitalisé du 01/04/2025 au 03/04/2025. + +Antécédents : +- Hypertension artérielle +- Dyslipidémie + +Au total : Colique hépatique sur lithiase vésiculaire. +Bonne évolution. Cholécystectomie programmée à distance. + +Biologie : +CRP = 12 mg/L +Albumine = 38 g/L +ASAT = 35 UI/L +ALAT = 42 UI/L +Hémoglobine = 14.5 g/dL +Créatinine = 78 µmol/L + +IMC: 26.3 + +TTT de sortie : +Paracétamol si besoin +Spasfon si besoin + +Devenir : retour à domicile.""" + return extract_medical_info(parsed, text) + + def test_imc_normal(self, dossier): + assert dossier.sejour.imc == 26.3 + + def test_albumine_normale_extraite(self, dossier): + """Albumine 38 g/L → extraite mais normale.""" + alb = [b for b in dossier.biologie_cle if b.test == "Albumine"] + assert len(alb) >= 1 + assert alb[0].valeur_num == 38.0 + + def test_pas_de_denutrition(self, dossier): + """IMC 26.3 + albumine 38 → aucun code E40-E46.""" + codes = {d.cim10_suggestion for d in dossier.diagnostics_associes} + nutrition_codes = {c for c in codes if c and c.startswith("E4") and c[:3] in + ("E40", "E41", "E42", "E43", "E44", "E45", "E46")} + assert not nutrition_codes, f"Faux positif dénutrition : {nutrition_codes}" + + def test_pas_alerte_has2021(self, dossier): + """Aucune alerte HAS 2021 ne doit apparaître.""" + assert not any("HAS 2021" in a for a in dossier.alertes_codage) + + def test_hta_detectee(self, dossier): + codes = {d.cim10_suggestion for d in dossier.diagnostics_associes} + assert "I10" in codes + + def test_dyslipidemie_detectee(self, dossier): + codes = {d.cim10_suggestion for d in dossier.diagnostics_associes} + assert "E78.5" in codes diff --git a/tests/test_splitter.py b/tests/test_splitter.py index 7b2d365..e34adbe 100644 --- a/tests/test_splitter.py +++ b/tests/test_splitter.py @@ -192,7 +192,7 @@ class TestSplitDocuments: # --- Test intégration process_pdf --- class TestProcessPdfMulti: - @patch("src.main.extract_text_with_pages") + @patch("src.main.extract_document_with_pages") @patch("src.main.extract_medical_info") @patch("src.main._run_edsnlp", return_value=None) @patch("src.main._use_edsnlp", False) @@ -203,9 +203,14 @@ class TestProcessPdfMulti: from src.main import process_pdf from src.config import DossierMedical, Diagnostic from src.extraction.page_tracker import PageTracker + from src.extraction.pdf_extractor import ExtractionStats - # Mock extract_text_with_pages retournant un texte multi-épisodes Trackare - mock_extract.return_value = (TRACKARE_MULTI, PageTracker([(0, len(TRACKARE_MULTI))])) + # Mock extract_document_with_pages retournant un texte multi-épisodes Trackare + mock_extract.return_value = ( + TRACKARE_MULTI, + PageTracker([(0, len(TRACKARE_MULTI))]), + ExtractionStats(total_pages=1, chars_per_page=[len(TRACKARE_MULTI)], total_chars=len(TRACKARE_MULTI)), + ) # Mock extract_medical_info retournant un DossierMedical minimal mock_medical.return_value = DossierMedical( diff --git a/tests/test_viewer.py b/tests/test_viewer.py index 85c1923..95b835b 100644 --- a/tests/test_viewer.py +++ b/tests/test_viewer.py @@ -1,6 +1,7 @@ """Tests pour le viewer Flask.""" import json +import os import pytest from pathlib import Path from unittest.mock import patch @@ -12,9 +13,10 @@ from src.config import DossierMedical, Diagnostic, ActeCCAM @pytest.fixture def app(): - app = create_app() - app.config["TESTING"] = True - return app + with patch.dict(os.environ, {"T2A_DEMO_USER": "", "T2A_DEMO_PASS": ""}): + app = create_app() + app.config["TESTING"] = True + yield app @pytest.fixture