diff --git a/.env.example b/.env.example index bb8101d..149642f 100644 --- a/.env.example +++ b/.env.example @@ -5,10 +5,24 @@ # === Ollama === # OLLAMA_URL=http://localhost:11434 -# OLLAMA_MODEL=gemma3:12b +# OLLAMA_MODEL=gemma3:27b-cloud # OLLAMA_TIMEOUT=120 # OLLAMA_MAX_PARALLEL=2 +# === Modèles par rôle LLM === +# T2A_MODEL_CODING=gemma3:27b-cloud # Codage CIM-10/CCAM, extraction DAS +# T2A_MODEL_CPAM=deepseek-v3.2:cloud # CPAM passe 1 + passe 2 +# T2A_MODEL_VALIDATION=deepseek-v3.2:cloud # Validation adversariale +# T2A_MODEL_QC=gemma3:12b # QC batch justifications +# +# IMPORTANT : T2A_MODEL_CPAM et T2A_MODEL_VALIDATION DOIVENT être différents +# en production pour que la validation adversariale soit réellement indépendante. +# Si identiques, la validation adversariale est automatiquement dégradée (LOGIC-3). + +# === Sélecteur DP (NUKE-3) === +# T2A_DP_RANKER_LLM=1 # 1/true/yes = LLM tiebreaker actif, 0/false/no = pré-ranker déterministe uniquement +# Note : l'ancien nom DP_RANKER_LLM_ENABLED est accepté mais déprécié. + # === Modèles IA === # T2A_EMBEDDING_MODEL=dangvantuan/sentence-camembert-large # T2A_NER_MODEL=Jean-Baptiste/camembert-ner diff --git a/config/bio_rules.yaml b/config/bio_rules.yaml index f87c84e..658fb19 100644 --- a/config/bio_rules.yaml +++ b/config/bio_rules.yaml @@ -1,15 +1,10 @@ version: 2 # Règles biologiques (contradiction bio ⇒ ruled_out) -# + garde-fou "preuve manquante" (diag d'ionogramme sans valeur extraite ⇒ NEED_INFO) -# -# Objectif: éviter des FAIL "bêtes" quand la biologie contredit clairement un diagnostic, -# et éviter des PASS "trop optimistes" quand on n'a même pas la valeur biologique. -# -# Hiérarchie des seuils: -# - Priorité aux normes du document (ex: [N: 135-145]) -# - Sinon fallback config/reference_ranges.yaml -# - Si âge inconnu/enfant: safe zones conservatrices (reference_ranges.yaml) +# ------------------------------------------------ +# Ces règles permettent d'écarter un diagnostic (status: ruled_out) +# si les preuves biologiques extraites le contredisent formellement. +# Elles génèrent aussi des alertes VETO-17 si la preuve est manquante. missing_evidence: enabled: true @@ -18,17 +13,78 @@ missing_evidence: score_penalty: 2 rules: + # --- IONOGRAMME --- hyponatremia: enabled: true - codes: ["E87.1"] # hyponatrémie + codes: ["E87.1"] analyte: sodium + threshold_type: low + message: "natrémie normale" hyperkalemia: enabled: true - codes: ["E87.5"] # hyperkaliémie + codes: ["E87.5"] analyte: potassium + threshold_type: high + message: "kaliémie normale" hypokalemia: enabled: true - codes: ["E87.6"] # hypokaliémie + codes: ["E87.6"] analyte: potassium + threshold_type: low + message: "kaliémie normale" + + # --- HÉMATOLOGIE --- + anemia: + enabled: true + codes: ["D50", "D50.0", "D50.1", "D50.8", "D50.9", "D51", "D52", "D53", "D55", "D56", "D57", "D58", "D59", "D60", "D61", "D62", "D63", "D64"] + analyte: hemoglobin + threshold_type: low + message: "taux d'hémoglobine normal" + + thrombopenia: + enabled: true + codes: ["D69.4", "D69.5", "D69.6"] + analyte: platelets + threshold_type: low + message: "taux de plaquettes normal" + + # --- RÉNAL --- + acute_kidney_injury: + enabled: true + codes: ["N17", "N17.0", "N17.1", "N17.2", "N17.8", "N17.9", "N19"] + analyte: creatinine + threshold_type: high + message: "créatininémie normale" + + # --- MÉTABOLISME --- + hypoglycemia: + enabled: true + codes: ["E16.0", "E16.1", "E16.2"] + analyte: glucose + threshold_type: low + message: "glycémie normale" + + diabetes_uncontrolled: + enabled: true + codes: ["E10.1", "E11.1"] # Avec complications aiguës (acidocétose, etc.) + analyte: hba1c + threshold_type: high + threshold_value: 9.0 + message: "HbA1c < 9% (diabète non considéré comme déséquilibré selon HbA1c)" + + # --- THYROÏDE --- + hypothyroidism: + enabled: true + codes: ["E02", "E03", "E03.0", "E03.1", "E03.2", "E03.8", "E03.9"] + analyte: tsh + threshold_type: high # TSH élevée = Hypo + message: "TSH normale ou basse" + + hyperthyroidism: + enabled: true + codes: ["E05", "E05.0", "E05.1", "E05.2", "E05.8", "E05.9"] + analyte: tsh + threshold_type: low # TSH basse = Hyper + message: "TSH normale ou élevée" diff --git a/requirements.txt b/requirements.txt index e2b8b17..1e003c7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,11 @@ pdfplumber>=0.10.0 -transformers>=4.35.0,<5.0.0 +transformers>=4.35.0,<6.0.0 torch>=2.1.0 -protobuf>=3.20.0,<4.0.0 +protobuf>=3.20.0,<7.0.0 regex>=2023.0 pydantic>=2.5.0 pytest>=7.4.0 -sentencepiece>=0.1.99,<0.2.0 +sentencepiece>=0.1.99,<0.3.0 edsnlp[ml]>=0.17.0 faiss-cpu>=1.7.0 sentence-transformers>=2.2.0 diff --git a/src/config.py b/src/config.py index 711ec24..554a90b 100644 --- a/src/config.py +++ b/src/config.py @@ -28,6 +28,11 @@ CONFIG_DIR = BASE_DIR / "config" REFERENCE_RANGES_PATH = CONFIG_DIR / "reference_ranges.yaml" BIO_RULES_PATH = CONFIG_DIR / "bio_rules.yaml" LAB_SANITY_PATH = CONFIG_DIR / "lab_value_sanity.yaml" +DEMOGRAPHIC_RULES_PATH = CONFIG_DIR / "demographic_rules.yaml" +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" RULES_DIR = CONFIG_DIR / "rules" RULES_BASE_PATH = RULES_DIR / "base.yaml" RULES_ENABLED_PATH = RULES_DIR / "enabled.yaml" @@ -128,6 +133,7 @@ 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" +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")) @@ -247,6 +253,124 @@ def load_bio_rules() -> Dict[str, Any]: return defaults +@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 + + +@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 + + +@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 + + +@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 + + +@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 + + # --- Garde-fous de parsing des valeurs biologiques (anti-OCR) --- @@ -827,6 +951,7 @@ class VetoIssue(BaseModel): severity: str # HARD | MEDIUM | LOW where: str message: str + citation: Optional[str] = None class VetoReport(BaseModel): diff --git a/src/control/cpam_context.py b/src/control/cpam_context.py index 6447afc..4441a97 100644 --- a/src/control/cpam_context.py +++ b/src/control/cpam_context.py @@ -195,6 +195,118 @@ def _build_tagged_context(dossier: DossierMedical) -> tuple[str, dict[str, str]] return text, tag_map +# --------------------------------------------------------------------------- +# Seuils biologiques par code CIM-10 (Table 8 du référentiel TIM) +# Utilisé pour la confrontation biologie/diagnostic dans le mémoire de défense. +# --------------------------------------------------------------------------- +_BIO_THRESHOLDS: dict[str, dict] = { + "D50": {"test": "Hémoglobine", "condition": "< 13 (H) / < 12 (F)", "also": ["Ferritine < 30"]}, + "D62": {"test": "Hémoglobine", "condition": "< 13 (H) / < 12 (F)"}, + "D64": {"test": "Hémoglobine", "condition": "< 13 (H) / < 12 (F)"}, + "D69.6": {"test": "Plaquettes", "condition": "< 150 G/L"}, + "E03": {"test": "TSH", "condition": "> 4 mUI/L"}, + "E05": {"test": "TSH", "condition": "< 0.4 mUI/L"}, + "E10": {"test": "Glycémie", "condition": "> 7 mmol/L ou HbA1c > 6.5%"}, + "E11": {"test": "Glycémie", "condition": "> 7 mmol/L ou HbA1c > 6.5%"}, + "E87.1": {"test": "Sodium", "condition": "< 135 mmol/L"}, + "E87.0": {"test": "Sodium", "condition": "> 145 mmol/L"}, + "E87.5": {"test": "Potassium", "condition": "> 5.0 mmol/L"}, + "E87.6": {"test": "Potassium", "condition": "< 3.5 mmol/L"}, + "K72": {"test": "ASAT", "condition": "> 120 UI/L (3x norme)", "also": ["ALAT > 120"]}, + "K85": {"test": "Lipasémie", "condition": "> 180 UI/L (3x norme)"}, + "N17": {"test": "Créatinine", "condition": "> 130 (H) / > 110 (F)", "note": "avec élévation aiguë"}, + "N18": {"test": "Créatinine", "condition": "DFG calculé"}, + "I50": {"test": "BNP", "condition": "> 100 pg/mL ou NT-proBNP > 300"}, + "I21": {"test": "Troponine", "condition": "> 0.04 ng/mL"}, + "I26": {"test": "D-dimères", "condition": "> 500 ng/mL"}, +} + + +def _build_bio_confrontation(dossier: DossierMedical, controle: ControleCPAM) -> str: + """Construit le tableau de confrontation biologie/diagnostic pour les codes contestés. + + Utilise _BIO_THRESHOLDS (Table 8 TIM) et les valeurs de dossier.biologie_cle + pour produire un verdict par diagnostic : CONFIRMÉ / NON CONFIRMÉ / NON DISPONIBLE. + """ + # Collecter tous les codes en jeu (3 premiers chars pour match préfixe) + codes_in_play: list[str] = [] + if dossier.diagnostic_principal and dossier.diagnostic_principal.cim10_suggestion: + codes_in_play.append(normalize_code(dossier.diagnostic_principal.cim10_suggestion)) + for das in dossier.diagnostics_associes: + if das.cim10_suggestion: + codes_in_play.append(normalize_code(das.cim10_suggestion)) + for field in (controle.dp_ucr, controle.da_ucr, controle.dr_ucr): + if not field: + continue + for raw in re.split(r"[,;\s]+", field.strip()): + raw = raw.strip() + if raw: + codes_in_play.append(normalize_code(raw)) + + if not codes_in_play: + return "(Aucun code en jeu pour la confrontation biologique)" + + # Indexer les valeurs bio disponibles + bio_values: dict[str, float] = {} + for b in dossier.biologie_cle: + if b.test and b.valeur: + try: + bio_values[b.test] = float(b.valeur.replace(",", ".").split()[0]) + except (ValueError, AttributeError): + pass + + lines: list[str] = [] + matched_any = False + for code in codes_in_play: + prefix = code[:3] if len(code) >= 3 else code + threshold = _BIO_THRESHOLDS.get(prefix) + if not threshold: + # Essayer avec code complet (ex: E87.1) + threshold = _BIO_THRESHOLDS.get(code[:5] if len(code) >= 5 else code) + if not threshold: + continue + + matched_any = True + test_name = threshold["test"] + condition = threshold["condition"] + is_valid, label = validate_code(code) + label_str = f" ({label})" if is_valid and label else "" + + if test_name in bio_values: + val = bio_values[test_name] + # Vérifier si la valeur est dans les normes + if test_name in BIO_NORMALS: + lo, hi = BIO_NORMALS[test_name] + is_normal = lo <= val <= hi + verdict = "NON CONFIRMÉ (valeur NORMALE)" if is_normal else "CONFIRMÉ" + else: + verdict = "À VÉRIFIER" + lines.append( + f" {code}{label_str} : {test_name} requis {condition} → " + f"valeur dossier = {val} → {verdict}" + ) + else: + lines.append( + f" {code}{label_str} : {test_name} requis {condition} → " + f"NON DISPONIBLE dans le dossier" + ) + + # Tests complémentaires (also) + for also_test in threshold.get("also", []): + parts = also_test.split() + if len(parts) >= 1: + also_name = parts[0] + if also_name in bio_values: + lines.append(f" + {also_test} → valeur dossier = {bio_values[also_name]}") + else: + lines.append(f" + {also_test} → NON DISPONIBLE") + + if not matched_any: + return "(Aucun seuil biologique applicable aux codes en jeu)" + + return "\n".join(lines) + + # Interprétations cliniques pour le résumé bio déterministe _BIO_INTERPRETATION: dict[str, dict[str, str]] = { # --- Hépatique / digestif --- @@ -648,6 +760,9 @@ def _build_cpam_prompt( "le manque de données au lieu d'inventer des preuves." ) + # Confrontation biologie / diagnostic (méthode TIM) + bio_confrontation = _build_bio_confrontation(dossier, controle) + # Résumé biologique déterministe (interprétations non modifiables par le LLM) bio_summary = _build_bio_summary(dossier) if bio_summary: @@ -736,5 +851,7 @@ def _build_cpam_prompt( codes_autorises_str=codes_autorises_str, sources_text=sources_text, extraction_str=extraction_str, + bio_confrontation_str=bio_confrontation, + numero_ogc=controle.numero_ogc, ) return prompt, tag_map diff --git a/src/control/cpam_response.py b/src/control/cpam_response.py index cadc4bc..3ce2ebe 100644 --- a/src/control/cpam_response.py +++ b/src/control/cpam_response.py @@ -38,8 +38,10 @@ from .cpam_context import ( # noqa: F401 _get_code_label, _get_cim10_definitions, _BIO_INTERPRETATION, + _BIO_THRESHOLDS, _assess_dossier_strength, _build_bio_summary, + _build_bio_confrontation, _check_das_bio_coherence, ) from .cpam_validation import _CIM10_CODE_RE, _validate_adversarial as _validate_adversarial, _assess_quality_tier as _assess_quality_tier, _fuzzy_match_ref as _fuzzy_match_ref, _sanitize_unauthorized_codes as _sanitize_unauthorized_codes # noqa: F401 @@ -120,6 +122,11 @@ def generate_cpam_response( # 1. Passe 1 — Extraction structurée (compréhension avant argumentation) extraction = _extraction_pass(dossier, controle) + degraded_pass1 = extraction is None + if degraded_pass1: + dossier.alertes_codage.append( + "CPAM: passe 1 (extraction structurée) échouée → mode dégradé" + ) # 2. Recherche RAG ciblée sources = _search_rag_for_control(controle, dossier) @@ -153,6 +160,12 @@ def generate_cpam_response( logger.warning(" LLM non disponible — contre-argumentation non générée") return "", None, rag_sources + # 5b. LOGIC-2 — Marquer le mode dégradé dans le résultat + if degraded_pass1: + result.setdefault("quality_flags", {}) + result["quality_flags"]["cpam_pass1_failed"] = True + result["quality_flags"]["degraded_mode"] = True + # 6. Sanitisation déterministe — supprime les codes CIM-10 hors périmètre sanitized = _sanitize_unauthorized_codes(result, dossier, controle) if sanitized: @@ -175,6 +188,16 @@ def generate_cpam_response( logger.warning(" CPAM : %d code(s) hors périmètre", len(code_warnings)) # 8. Validation adversariale (cohérence factuelle) + # LOGIC-3 : détecter si modèles identiques AVANT l'appel + from ..config import check_adversarial_model_config + same_model, model_msg = check_adversarial_model_config() + if same_model: + result.setdefault("quality_flags", {}) + result["quality_flags"]["adversarial_disabled_same_model"] = True + dossier.alertes_codage.append( + "Validation adversariale désactivée (modèles identiques)" + ) + adversarial_warnings: list[str] = [] validation = _validate_adversarial(result, tag_map, controle) if validation and not validation.get("coherent", True): @@ -186,48 +209,51 @@ def generate_cpam_response( if adversarial_warnings: adversarial_warnings.append(f"Score de confiance : {score}/10") - # 8b. Boucle de correction (max 1 retry) - if (validation - and not validation.get("coherent", True) - and validation.get("score_confiance", 10) <= 5 - and rule_enabled("RULE-CPAM-CORRECTION-LOOP")): + # 8b. Boucle de correction (max 2 retries) + max_corrections = 2 + for attempt in range(max_corrections): + if not (validation + and not validation.get("coherent", True) + and validation.get("score_confiance", 10) <= 5 + and rule_enabled("RULE-CPAM-CORRECTION-LOOP")): + break erreurs_v = validation.get("erreurs", []) - logger.warning(" Score adversarial %s/10 — correction en cours (%d erreur(s))", - validation.get("score_confiance"), len(erreurs_v)) + logger.warning(" Score adversarial %s/10 — correction %d/%d (%d erreur(s))", + validation.get("score_confiance"), attempt + 1, max_corrections, len(erreurs_v)) correction_prompt = _build_correction_prompt(prompt, result, validation) corrected = call_ollama(correction_prompt, temperature=0.0, max_tokens=16000, role="cpam") if corrected is None: corrected = call_anthropic(correction_prompt, temperature=0.0, max_tokens=16000) - if corrected: - # Re-valider la correction - validation2 = _validate_adversarial(corrected, tag_map, controle) - score2 = validation2.get("score_confiance", 0) if validation2 else 0 - score1 = validation.get("score_confiance", 0) + if not corrected: + break - if score2 > score1: - logger.info(" Correction acceptée (score %s → %s)", score1, score2) - result = corrected - validation = validation2 - # Sanitiser + recalculer les warnings - _sanitize_unauthorized_codes(result, dossier, controle) - ref_warnings = _validate_references(result, sources) - grounding_warnings = _validate_grounding(result, tag_map) - code_warnings = _validate_codes_in_response(result, dossier, controle) - adversarial_warnings = [] - if validation and not validation.get("coherent", True): - for e in validation.get("erreurs", []): - if isinstance(e, str) and e.strip(): - adversarial_warnings.append(f"Incohérence détectée : {e}") - if adversarial_warnings: - adversarial_warnings.append( - f"Score de confiance : {validation.get('score_confiance', '?')}/10" - ) - else: - logger.warning(" Correction rejetée (score %s → %s) — conserve l'original", - score1, score2) + validation2 = _validate_adversarial(corrected, tag_map, controle) + score2 = validation2.get("score_confiance", 0) if validation2 else 0 + score1 = validation.get("score_confiance", 0) + + if score2 > score1: + logger.info(" Correction %d acceptée (score %s → %s)", attempt + 1, score1, score2) + result = corrected + validation = validation2 + _sanitize_unauthorized_codes(result, dossier, controle) + ref_warnings = _validate_references(result, sources) + grounding_warnings = _validate_grounding(result, tag_map) + code_warnings = _validate_codes_in_response(result, dossier, controle) + adversarial_warnings = [] + if validation and not validation.get("coherent", True): + for e in validation.get("erreurs", []): + if isinstance(e, str) and e.strip(): + adversarial_warnings.append(f"Incohérence détectée : {e}") + if adversarial_warnings: + adversarial_warnings.append( + f"Score de confiance : {validation.get('score_confiance', '?')}/10" + ) + else: + logger.warning(" Correction %d rejetée (score %s → %s)", attempt + 1, score1, score2) + break all_warnings = ref_warnings + grounding_warnings + code_warnings + adversarial_warnings diff --git a/src/control/cpam_validation.py b/src/control/cpam_validation.py index b466348..403e090 100644 --- a/src/control/cpam_validation.py +++ b/src/control/cpam_validation.py @@ -33,7 +33,11 @@ def _fuzzy_match_ref(ref: str, tag_map: dict[str, str]) -> str | None: def _validate_grounding(response_data: dict, tag_map: dict[str, str]) -> list[str]: - """Vérifie que les références dans preuves_dossier correspondent à des tags existants. + """Vérifie que les références dans preuves correspondent à des tags existants. + + Supporte les deux formats : + - Ancien : response_data["preuves_dossier"][].ref + - Nouveau TIM : response_data["moyens_defense"][].preuves[].ref Applique un fuzzy matching par code CIM-10 avant de flaguer un warning. @@ -44,24 +48,40 @@ def _validate_grounding(response_data: dict, tag_map: dict[str, str]) -> list[st return [] warnings: list[str] = [] - preuves = response_data.get("preuves_dossier") - if not preuves or not isinstance(preuves, list): - return warnings - for p in preuves: - if not isinstance(p, dict): - continue - ref = p.get("ref", "") + def _check_ref(ref: str, context: str) -> None: if not ref: - continue - if ref not in tag_map: - matched_tag = _fuzzy_match_ref(ref, tag_map) - if matched_tag: - logger.info("Grounding : ref [%s] résolue vers [%s]", ref, matched_tag) - continue # pas de warning - valeur = p.get("valeur", "?") - warnings.append(f"Preuve [{ref}] non traçable (« {valeur} »)") - logger.warning("Grounding : preuve [%s] introuvable dans les tags du dossier", ref) + return + # Nettoyer les crochets si présents (nouveau format utilise "[BIO-1]") + clean_ref = ref.strip("[]") + if clean_ref in tag_map or ref in tag_map: + return + matched_tag = _fuzzy_match_ref(clean_ref, tag_map) + if matched_tag: + logger.info("Grounding : ref [%s] résolue vers [%s]", ref, matched_tag) + return + warnings.append(f"Preuve [{ref}] non traçable (« {context} »)") + logger.warning("Grounding : preuve [%s] introuvable dans les tags du dossier", ref) + + # Ancien format : preuves_dossier + preuves = response_data.get("preuves_dossier") + if preuves and isinstance(preuves, list): + for p in preuves: + if isinstance(p, dict): + _check_ref(p.get("ref", ""), p.get("valeur", "?")) + + # Nouveau format TIM : moyens_defense[].preuves + moyens = response_data.get("moyens_defense") + if moyens and isinstance(moyens, list): + for moyen in moyens: + if not isinstance(moyen, dict): + continue + moyen_preuves = moyen.get("preuves") + if not moyen_preuves or not isinstance(moyen_preuves, list): + continue + for p in moyen_preuves: + if isinstance(p, dict): + _check_ref(p.get("ref", ""), p.get("fait", "?")) return warnings @@ -111,12 +131,19 @@ def _validate_references(parsed: dict, sources: list[dict]) -> list[str]: _CIM10_CODE_RE = re.compile(r"\b([A-Z]\d{2}\.?\d{0,2})\b") # Champs textuels de la réponse LLM à scanner pour les codes CIM-10 +# Supporte les deux formats : ancien (contre_arguments_*) et nouveau (moyens_defense TIM) _TEXT_FIELDS = ( + # Ancien format "analyse_contestation", "contre_arguments_medicaux", "contre_arguments_asymetrie", "contre_arguments_reglementaires", "conclusion", + # Nouveau format TIM + "rappel_faits", + "asymetrie_information", + "reponse_points_cpam", + "conclusion_dispositive", ) @@ -220,7 +247,7 @@ def _sanitize_unauthorized_codes( if new_val != val: parsed[key] = new_val - # Sanitiser aussi les preuves_dossier.valeur + # Sanitiser aussi les preuves_dossier.valeur (ancien format) preuves = parsed.get("preuves_dossier") if preuves and isinstance(preuves, list): for p in preuves: @@ -240,6 +267,27 @@ def _sanitize_unauthorized_codes( if new_v != v: p["valeur"] = new_v + # Sanitiser les moyens_defense[].argument (nouveau format TIM) + moyens = parsed.get("moyens_defense") + if moyens and isinstance(moyens, list): + for moyen in moyens: + if not isinstance(moyen, dict): + continue + for field_key in ("argument", "titre"): + val = moyen.get(field_key, "") + if not val or not isinstance(val, str): + continue + new_val = val + for pattern in _SANITIZE_PATTERNS: + new_val = pattern.sub( + lambda m, _p=pattern: _replace_code(m), + new_val, + ) + new_val = re.sub(r"\(\s*\)", "", new_val) + new_val = re.sub(r" +", " ", new_val).strip() + if new_val != val: + moyen[field_key] = new_val + if removed: for code in removed: norm = normalize_code(code) @@ -275,7 +323,7 @@ def _validate_codes_in_response( if val and isinstance(val, str): text_fields.append(val) - # Preuves du dossier — valeurs + # Preuves du dossier — valeurs (ancien format) preuves = parsed.get("preuves_dossier") if preuves and isinstance(preuves, list): for p in preuves: @@ -284,6 +332,16 @@ def _validate_codes_in_response( if v and isinstance(v, str): text_fields.append(v) + # Moyens de défense (nouveau format TIM) + moyens = parsed.get("moyens_defense") + if moyens and isinstance(moyens, list): + for moyen in moyens: + if isinstance(moyen, dict): + for mkey in ("argument", "titre"): + v = moyen.get(mkey, "") + if v and isinstance(v, str): + text_fields.append(v) + combined_text = "\n".join(text_fields) found_codes = _CIM10_CODE_RE.findall(combined_text) @@ -330,6 +388,18 @@ def _validate_adversarial( """ import json as _json + # LOGIC-3 — Vérifier si les modèles CPAM et validation sont identiques + from ..config import check_adversarial_model_config + + same_model, model_msg = check_adversarial_model_config() + if same_model: + logger.warning("LOGIC-3: %s", model_msg) + return { + "coherent": True, + "erreurs": [f"Validation adversariale dégradée : {model_msg}"], + "score_confiance": 0, + } + # Construire le résumé des éléments factuels disponibles if tag_map: factual_lines = "\n".join(f" [{tag}] {content}" for tag, content in tag_map.items()) @@ -341,8 +411,8 @@ def _validate_adversarial( try: response_json = _json.dumps(response_data, ensure_ascii=False, indent=None) # Tronquer si trop long pour le prompt de validation - if len(response_json) > 3000: - response_json = response_json[:3000] + "..." + if len(response_json) > 10000: + response_json = response_json[:10000] + "..." except (TypeError, ValueError): logger.warning("Validation adversariale : impossible de sérialiser la réponse") return None @@ -365,9 +435,9 @@ def _validate_adversarial( ) logger.debug(" Validation adversariale") - result = call_ollama(prompt, temperature=0.0, max_tokens=3000, role="validation") + result = call_ollama(prompt, temperature=0.0, max_tokens=6000, role="validation") if result is None: - result = call_anthropic(prompt, temperature=0.0, max_tokens=3000) + result = call_anthropic(prompt, temperature=0.0, max_tokens=6000) if result is None: logger.warning(" Validation adversariale échouée — LLM indisponible") return None @@ -407,14 +477,20 @@ def _build_correction_prompt( erreurs = adversarial_result.get("erreurs", []) erreurs_text = "\n".join(f" {i}. {e}" for i, e in enumerate(erreurs, 1)) - # Résumé compact de la réponse problématique + # Résumé compact de la réponse problématique (supporte les deux formats) summary_fields = {} + # Ancien format for key in ("analyse_contestation", "contre_arguments_medicaux", "contre_arguments_asymetrie", "contre_arguments_reglementaires", "conclusion"): val = original_response.get(key) if val and isinstance(val, str): - # Tronquer chaque champ à 400 chars + summary_fields[key] = val[:400] + ("..." if len(val) > 400 else "") + # Nouveau format TIM + for key in ("rappel_faits", "asymetrie_information", "reponse_points_cpam", + "conclusion_dispositive"): + val = original_response.get(key) + if val and isinstance(val, str): summary_fields[key] = val[:400] + ("..." if len(val) > 400 else "") try: @@ -522,13 +598,182 @@ def _assess_quality_tier( return tier, requires_review, categorized +def _is_new_tim_format(parsed: dict) -> bool: + """Détecte si la réponse LLM utilise le nouveau format TIM (moyens_defense).""" + return "moyens_defense" in parsed + + def _format_response( parsed: dict, ref_warnings: list[str] | None = None, quality_tier: str | None = None, categorized_warnings: list[str] | None = None, ) -> str: - """Formate la réponse LLM en texte lisible.""" + """Formate la réponse LLM en texte lisible. + + Supporte deux formats via duck-typing : + - Nouveau TIM : moyens_defense, confrontation_bio, conclusion_dispositive + - Ancien : contre_arguments_medicaux, points_accord, conclusion + """ + if _is_new_tim_format(parsed): + return _format_response_tim(parsed, ref_warnings, quality_tier, categorized_warnings) + return _format_response_legacy(parsed, ref_warnings, quality_tier, categorized_warnings) + + +def _format_response_tim( + parsed: dict, + ref_warnings: list[str] | None = None, + quality_tier: str | None = None, + categorized_warnings: list[str] | None = None, +) -> str: + """Formate la réponse LLM au format mémoire en défense TIM.""" + sections: list[str] = [] + sep = "───────────────────────────────────────────────────────" + sep_heavy = "═══════════════════════════════════════════════════════" + + # En-tête + objet = parsed.get("objet", "Mémoire en défense") + sections.append(f"{sep_heavy}\nMÉMOIRE EN DÉFENSE — {objet}\n{sep_heavy}") + + # Bandeau qualité si tier C + if quality_tier == "C": + sections.append("⚠ REVUE MANUELLE REQUISE (Qualité : C)") + + # Rappel des faits + rappel = parsed.get("rappel_faits") + if rappel: + sections.append(f"RAPPEL DES FAITS\n{rappel}") + + sections.append(sep) + + # Moyens de défense numérotés + moyens = parsed.get("moyens_defense") + if moyens and isinstance(moyens, list): + for moyen in moyens: + if not isinstance(moyen, dict): + continue + num = moyen.get("numero", "?") + titre = moyen.get("titre", "") + argument = moyen.get("argument", "") + + moyen_lines = [f"MOYEN N°{num} — {titre}"] + if argument: + moyen_lines.append(argument) + + # Preuves intégrées dans chaque moyen + moyen_preuves = moyen.get("preuves") + if moyen_preuves and isinstance(moyen_preuves, list): + for p in moyen_preuves: + if isinstance(p, dict): + ref = p.get("ref", "") + fait = p.get("fait", "") + signif = p.get("signification", "") + moyen_lines.append(f" Preuve : {ref} {fait} → {signif}") + + # Source réglementaire du moyen + src_regl = moyen.get("source_reglementaire") + if src_regl and src_regl != "null": + moyen_lines.append(f" Source : {src_regl}") + + sections.append("\n".join(moyen_lines)) + + sections.append(sep) + + # Confrontation biologie / diagnostic (tableau) + confrontation = parsed.get("confrontation_bio") + if confrontation and isinstance(confrontation, list): + table_lines = ["CONFRONTATION BIOLOGIE / DIAGNOSTIC"] + table_lines.append( + "┌─────────────────┬─────────────┬──────────────┬───────────┬───────────────┐" + ) + table_lines.append( + "│ Diagnostic │ Test requis │ Seuil │ Valeur │ Verdict │" + ) + table_lines.append( + "├─────────────────┼─────────────┼──────────────┼───────────┼───────────────┤" + ) + for row in confrontation: + if not isinstance(row, dict): + continue + diag = str(row.get("diagnostic", ""))[:17].ljust(17) + test = str(row.get("test", ""))[:13].ljust(13) + seuil = str(row.get("seuil", ""))[:14].ljust(14) + valeur = str(row.get("valeur", ""))[:11].ljust(11) + verdict = str(row.get("verdict", ""))[:15].ljust(15) + table_lines.append(f"│ {diag}│ {test}│ {seuil}│ {valeur}│ {verdict}│") + table_lines.append( + "└─────────────────┴─────────────┴──────────────┴───────────┴───────────────┘" + ) + sections.append("\n".join(table_lines)) + + sections.append(sep) + + # Codes non défendables (honnêteté intellectuelle) + codes_nd = parsed.get("codes_non_defendables") + if codes_nd and isinstance(codes_nd, list) and len(codes_nd) > 0: + nd_lines = ["⚠ CODES NON DÉFENDABLES (honnêteté intellectuelle)"] + for nd in codes_nd: + if isinstance(nd, dict): + code = nd.get("code", "?") + raison = nd.get("raison", "") + reco = nd.get("recommandation", "") + nd_lines.append(f"- {code} : {raison}") + if reco: + nd_lines.append(f" → {reco}") + sections.append("\n".join(nd_lines)) + sections.append(sep) + + # Asymétrie d'information + asymetrie = parsed.get("asymetrie_information") + if asymetrie: + sections.append(f"ASYMÉTRIE D'INFORMATION\n{asymetrie}") + sections.append(sep) + + # Réponse aux points CPAM + reponse_cpam = parsed.get("reponse_points_cpam") + if reponse_cpam: + sections.append(f"RÉPONSE AUX POINTS DE LA CPAM\n{reponse_cpam}") + sections.append(sep) + + # Références réglementaires + refs = parsed.get("references") + if refs: + if isinstance(refs, list): + ref_lines = ["RÉFÉRENCES RÉGLEMENTAIRES"] + for r in refs: + if isinstance(r, dict): + doc = r.get("document", "") + page = r.get("page", "") + citation = r.get("citation", "") + ref_lines.append(f"- [{doc}, p.{page}] {citation}") + else: + ref_lines.append(f"- {r}") + sections.append("\n".join(ref_lines)) + else: + sections.append(f"RÉFÉRENCES RÉGLEMENTAIRES\n{refs}") + + sections.append(sep_heavy) + + # Conclusion dispositive + conclusion = parsed.get("conclusion_dispositive") + if conclusion: + sections.append(f"CONCLUSION\n{conclusion}") + + sections.append(sep_heavy) + + # Avertissements + sections.extend(_format_warnings(categorized_warnings, ref_warnings)) + + return "\n\n".join(sections) + + +def _format_response_legacy( + parsed: dict, + ref_warnings: list[str] | None = None, + quality_tier: str | None = None, + categorized_warnings: list[str] | None = None, +) -> str: + """Formate la réponse LLM au format hérité (rétro-compatibilité cache).""" sections = [] # Bandeau qualité si tier C @@ -543,12 +788,12 @@ def _format_response( if accord and accord.lower() not in ("aucun", "non applicable", "n/a", ""): sections.append(f"POINTS D'ACCORD\n{accord}") - # Nouveaux champs structurés par axe + # Champs structurés par axe contre_med = parsed.get("contre_arguments_medicaux") if contre_med: sections.append(f"CONTRE-ARGUMENTS MÉDICAUX\n{contre_med}") - # Preuves du dossier (nouveau champ structuré) + # Preuves du dossier preuves = parsed.get("preuves_dossier") if preuves and isinstance(preuves, list): preuves_lines = [] @@ -577,7 +822,7 @@ def _format_response( if contre: sections.append(f"CONTRE-ARGUMENTS\n{contre}") - # Références structurées (nouveau format liste) ou ancien format string + # Références structurées ou ancien format string refs = parsed.get("references") if refs: if isinstance(refs, list): @@ -599,7 +844,18 @@ def _format_response( if conclusion: sections.append(f"CONCLUSION\n{conclusion}") - # Avertissements catégorisés (nouveau format) + # Avertissements + sections.extend(_format_warnings(categorized_warnings, ref_warnings)) + + return "\n\n".join(sections) + + +def _format_warnings( + categorized_warnings: list[str] | None = None, + ref_warnings: list[str] | None = None, +) -> list[str]: + """Formate les avertissements qualité (partagé entre les deux formats).""" + sections: list[str] = [] if categorized_warnings: critiques = [w for w in categorized_warnings if w.startswith("[CRITIQUE]")] mineurs = [w for w in categorized_warnings if w.startswith("[MINEUR]")] @@ -612,8 +868,6 @@ def _format_response( "AVERTISSEMENTS MINEURS\n" + "\n".join(f"- {w}" for w in mineurs) ) elif ref_warnings: - # Fallback ancien format warning_text = "\n".join(f"- {w}" for w in ref_warnings) sections.append(f"AVERTISSEMENT — REFERENCES NON VÉRIFIÉES\n{warning_text}") - - return "\n\n".join(sections) + return sections diff --git a/src/medical/bio_extraction.py b/src/medical/bio_extraction.py index 163d470..91fd612 100644 --- a/src/medical/bio_extraction.py +++ b/src/medical/bio_extraction.py @@ -4,10 +4,14 @@ from __future__ import annotations import re import unicodedata +import logging +import numpy as np from ..config import BiologieCle, DossierMedical, load_lab_value_sanity from .bio_normals import BIO_NORMALS, _is_abnormal +logger = logging.getLogger(__name__) + def _norm_key(s: str) -> str: """Normalise une clé (minuscules, sans accents) pour index YAML.""" @@ -68,6 +72,100 @@ def _sanitize_bio_value(test_name: str, raw_value: str, sanity_cfg: dict) -> tup return token, val, quality, reason +def _extract_biologie_faiss(text: str, dossier: DossierMedical) -> None: + """Extraction biologique via recherche vectorielle FAISS pour les synonymes. + + Complète les regex pour les termes non prévus ou les variations complexes. + """ + from .rag_index import get_index + from .rag_search import _get_embed_model + + res = get_index(kind="bio") + if not res: + return + faiss_index, metadata = res + + try: + model = _get_embed_model() + except Exception as e: + logger.warning("FAISS Bio: modèle d'embedding indisponible (%s)", e) + return + + # 1. Découpage du texte en segments glissants (phrases ou groupes de mots) + lines = [l.strip() for l in text.split("\n") if len(l.strip()) > 5] + if not lines: + return + + segments = [] + for line in lines: + if len(line.split()) > 15: + words = line.split() + for i in range(0, len(words), 10): + segments.append(" ".join(words[i:i+12])) + else: + segments.append(line) + + if not segments: + return + + # 2. Encodage des segments + try: + embeddings = model.encode(segments, normalize_embeddings=True, show_progress_bar=False) + embeddings = np.array(embeddings, dtype=np.float32) + except Exception as e: + logger.warning("FAISS Bio: erreur encodage segments (%s)", e) + return + + # 3. Recherche dans l'index bio + MIN_SCORE_BIO = 0.82 + scores, indices = faiss_index.search(embeddings, 1) + + sanity_cfg = load_lab_value_sanity() + seen_faiss = set() + + for i, (score, idx) in enumerate(zip(scores, indices)): + s = float(score[0]) + if s < MIN_SCORE_BIO or idx[0] < 0: + continue + + meta = metadata[idx[0]] + concept_name = meta.get("code") + synonym_matched = meta.get("extrait") + segment = segments[i] + + # 4. Capture de la valeur numérique + val_match = re.search(r"(?:[=àa:]\s*)?(\d+(?:[.,]\d+)?)\s*(?:[a-zA-Z/%/µ/mm3/G/L/U/I]+)?", segment) + if not val_match: + continue + + raw_value = val_match.group(1) + entry_key = (concept_name, raw_value) + if entry_key in seen_faiss: + continue + seen_faiss.add(entry_key) + + sanitized = _sanitize_bio_value(concept_name, raw_value, sanity_cfg) + if sanitized: + token, val_num, quality, reason = sanitized + anomalie = _is_abnormal(concept_name, token) + + is_dup = any(b.test == concept_name and b.valeur == raw_value for b in dossier.biologie_cle) + if is_dup: + continue + + dossier.biologie_cle.append( + BiologieCle( + test=concept_name, + valeur=raw_value, + valeur_num=val_num, + anomalie=anomalie, + quality=quality, + discard_reason=reason, + ) + ) + logger.debug("FAISS Bio match: %s (%s) = %s dans '%s'", concept_name, synonym_matched, raw_value, segment) + + def _extract_biologie(text: str, dossier: DossierMedical) -> None: """Extrait des résultats biologiques clés. @@ -90,12 +188,20 @@ def _extract_biologie(text: str, dossier: DossierMedical) -> None: # Ionogramme / électrolytes (r"(?:[Ss]odium|[Nn]atr[ée]mie|(? None: discard_reason=reason, ) ) + + # --- Complément par recherche vectorielle (Synonymes) --- + _extract_biologie_faiss(text, dossier) diff --git a/src/medical/cim10_extractor.py b/src/medical/cim10_extractor.py index bc6d6c1..6edd2dc 100644 --- a/src/medical/cim10_extractor.py +++ b/src/medical/cim10_extractor.py @@ -96,6 +96,46 @@ def extract_medical_info( if use_rag: _enrich_with_rag(dossier) + # NUKE-3 : sélection DP type DIM (CRH uniquement) + if dossier.document_type != "trackare": + try: + from .dp_selector import select_dp, build_synthese + + synthese = build_synthese(dossier, parsed_data) + selection = select_dp( + dossier, synthese, config={"llm_enabled": use_rag}, + ) + dossier.dp_selection = selection + + if selection.chosen_code: + current_code = ( + dossier.diagnostic_principal.cim10_suggestion + if dossier.diagnostic_principal else None + ) + has_multiple = len(selection.candidates) >= 2 + # MAJ DP si : + # - DP existant et NUKE-3 sélectionne un code différent + # - Pas de DP mais plusieurs candidats (choix non trivial) + # Le cas "1 seul candidat, pas de DP" est géré par RULE-DAS-TO-DP + should_update = ( + (current_code and selection.chosen_code != current_code) + or (not current_code and has_multiple) + ) + if should_update: + dossier.diagnostic_principal = Diagnostic( + texte=selection.chosen_term or "", + cim10_suggestion=selection.chosen_code, + cim10_confidence=selection.confidence, + source="nuke3", + ) + + if selection.verdict == "REVIEW": + dossier.alertes_codage.append( + f"NUKE-3 REVIEW: DP ambigu — {selection.reason}" + ) + except Exception: + logger.warning("NUKE-3: erreur sélection DP", exc_info=True) + # Post-processing : validation des codes CCAM contre le dictionnaire _validate_ccam(dossier) diff --git a/src/medical/rag_index.py b/src/medical/rag_index.py index fabeebf..5be451d 100644 --- a/src/medical/rag_index.py +++ b/src/medical/rag_index.py @@ -21,7 +21,7 @@ from typing import Optional import pdfplumber -from ..config import RAG_INDEX_DIR, CIM10_PDF, GUIDE_METHODO_PDF, CCAM_PDF, CCAM_DICT_PATH, REFERENTIELS_DIR, EMBEDDING_MODEL +from ..config import RAG_INDEX_DIR, CIM10_PDF, GUIDE_METHODO_PDF, CCAM_PDF, CCAM_DICT_PATH, BIO_CONCEPTS_PATH, REFERENTIELS_DIR, EMBEDDING_MODEL logger = logging.getLogger(__name__) @@ -112,11 +112,14 @@ def _paths(kind: str) -> tuple[Path, Path]: kind: - "ref" : référentiels - "proc" : procédures + - "bio" : concepts biologiques - "all" : legacy (faiss.index) """ kind = (kind or "ref").lower() if kind == "proc": return (RAG_INDEX_DIR / "faiss_proc.index", RAG_INDEX_DIR / "metadata_proc.json") + if kind == "bio": + return (RAG_INDEX_DIR / "faiss_bio.index", RAG_INDEX_DIR / "metadata_bio.json") if kind == "all": return (RAG_INDEX_DIR / "faiss.index", RAG_INDEX_DIR / "metadata.json") # ref (default) @@ -470,6 +473,25 @@ def _chunk_cim10_alpha(pdf_path: Path) -> list[Chunk]: return chunks +def _chunk_bio_concepts() -> list[Chunk]: + """Génère des chunks à partir de bio_concepts.json pour la recherche sémantique de tests.""" + if not BIO_CONCEPTS_PATH.exists(): + return [] + with open(BIO_CONCEPTS_PATH, encoding="utf-8") as f: + concepts = json.load(f) + chunks = [] + for item in concepts: + concept_name = item["concept"] + # On indexe le nom du concept + tous les synonymes + for syn in ([concept_name] + item.get("synonyms", [])): + chunks.append(Chunk( + text=syn, + document="bio_concepts", + code=concept_name, # On stocke le nom du concept "pivot" dans 'code' + )) + return chunks + + # --------------------------------------------------------------------------- # Construction de l'index FAISS # --------------------------------------------------------------------------- @@ -489,18 +511,24 @@ def build_index(force: bool = False) -> None: ref_index_path, ref_meta_path = _paths("ref") proc_index_path, proc_meta_path = _paths("proc") + bio_index_path, bio_meta_path = _paths("bio") # Si tout existe déjà et pas de force ref_ok = ref_index_path.exists() and ref_meta_path.exists() proc_ok = proc_index_path.exists() and proc_meta_path.exists() + bio_ok = bio_index_path.exists() and bio_meta_path.exists() guide_expected = GUIDE_METHODO_PDF.exists() - if not force and ref_ok and ((not guide_expected) or proc_ok): + if not force and ref_ok and bio_ok and ((not guide_expected) or proc_ok): logger.info("Index FAISS déjà existants dans %s (use force=True pour reconstruire)", RAG_INDEX_DIR) return # Collecter les chunks ref_chunks: list[Chunk] = [] proc_chunks: list[Chunk] = [] + bio_chunks: list[Chunk] = [] + + # Concepts biologiques + bio_chunks.extend(_chunk_bio_concepts()) # CIM-10 (référentiel) if CIM10_PDF.exists(): @@ -560,6 +588,7 @@ def build_index(force: bool = False) -> None: _write_index(ref_chunks, ref_index_path, ref_meta_path, "ref") _write_index(proc_chunks, proc_index_path, proc_meta_path, "proc") + _write_index(bio_chunks, bio_index_path, bio_meta_path, "bio") # Invalider les singletons reset_index() @@ -569,7 +598,7 @@ def get_index(kind: str = "ref") -> tuple | None: """Charge un index FAISS et ses métadonnées (singleton lazy-loaded). Args: - kind: "ref" | "proc" | "all". + kind: "ref" | "proc" | "bio" | "all". Returns: Tuple (faiss_index, metadata_list) ou None si l'index n'existe pas. @@ -586,8 +615,8 @@ def get_index(kind: str = "ref") -> tuple | None: index_path, meta_path = _paths(kind) - # Backwards compat : si ref/proc absent, fallback sur all - if kind in ("ref", "proc") and (not index_path.exists() or not meta_path.exists()): + # Backwards compat : si ref/proc/bio absent, fallback sur all + if kind in ("ref", "proc", "bio") and (not index_path.exists() or not meta_path.exists()): legacy_idx, legacy_meta = _paths("all") if legacy_idx.exists() and legacy_meta.exists(): logger.warning("Index %s absent — fallback legacy faiss.index", kind) diff --git a/src/medical/rag_search.py b/src/medical/rag_search.py index 5d75828..22892f6 100644 --- a/src/medical/rag_search.py +++ b/src/medical/rag_search.py @@ -561,7 +561,13 @@ def enrich_diagnostic( sources = search_similar(diagnostic.texte, top_k=10) if not sources: - logger.debug("Aucune source RAG trouvée pour : %s", diagnostic.texte) + # Toujours initialiser sources_rag (même vide) pour traçabilité + diagnostic.sources_rag = [] + logger.debug("RAG: 0 résultat FAISS pour « %s »", diagnostic.texte) + # Si un cache hit existe, appliquer le résultat LLM malgré l'absence de sources + if cached is not None: + logger.info("Cache hit (sans sources FAISS) pour %s : « %s »", diag_type.upper(), diagnostic.texte) + _apply_llm_result_diagnostic(diagnostic, cached) return # 3. Stocker les sources RAG diff --git a/src/prompts/__init__.py b/src/prompts/__init__.py index f3aa2ff..d0141a6 100644 --- a/src/prompts/__init__.py +++ b/src/prompts/__init__.py @@ -8,6 +8,7 @@ from .templates import ( CPAM_EXTRACTION, CPAM_ARGUMENTATION, CPAM_ADVERSARIAL, + DP_RANKER_CONSTRAINED, ) __all__ = [ @@ -18,4 +19,5 @@ __all__ = [ "CPAM_EXTRACTION", "CPAM_ARGUMENTATION", "CPAM_ADVERSARIAL", + "DP_RANKER_CONSTRAINED", ] diff --git a/src/prompts/templates.py b/src/prompts/templates.py index 15f8f74..cf930af 100644 --- a/src/prompts/templates.py +++ b/src/prompts/templates.py @@ -14,9 +14,11 @@ Variables par template : decision_ucr, dp_ucr_line, da_ucr_line CPAM_ARGUMENTATION : dossier_str, asymetrie_str, tagged_str, titre, arg_ucr, decision_ucr, codes_str, definitions_str, - codes_autorises_str, sources_text, extraction_str + codes_autorises_str, sources_text, extraction_str, + bio_confrontation_str, numero_ogc CPAM_ADVERSARIAL : response_json, factual_section, normes_section, dp_ucr_line, da_ucr_line + DP_RANKER_CONSTRAINED : candidates_str, ctx_str, n_candidates """ # --------------------------------------------------------------------------- @@ -215,96 +217,122 @@ Réponds UNIQUEMENT en JSON : # --------------------------------------------------------------------------- -# 6. CPAM_ARGUMENTATION — Passe 2 contre-argumentation CPAM +# 6. CPAM_ARGUMENTATION — Passe 2 contre-argumentation CPAM (méthode TIM) # Source : cpam_response.py _build_cpam_prompt() -# Rôle : cpam | Température : 0.1 | max_tokens : 4000 +# Rôle : cpam | Température : 0.1 | max_tokens : 16000 # --------------------------------------------------------------------------- CPAM_ARGUMENTATION = """\ -Tu es un médecin DIM (Département d'Information Médicale) expert en contentieux T2A. -Tu dois produire une analyse ÉQUILIBRÉE ET CRÉDIBLE de la contestation CPAM, puis contre-argumenter en mobilisant trois axes : médical, asymétrie d'information, et réglementaire. +Tu es un médecin DIM senior expert en contentieux T2A. Tu rédiges un MÉMOIRE EN DÉFENSE \ +structuré et argumenté pour répondre à la contestation CPAM ci-dessous. -IMPORTANT — CRÉDIBILITÉ DE L'ANALYSE : -Une contre-argumentation crédible reconnaît TOUJOURS au moins un point valide dans le raisonnement adverse. -Répondre "Aucun point d'accord" décrédibilise l'ensemble de l'argumentation. Tu DOIS identifier au moins un élément où la CPAM a un point légitime (même partiel), puis expliquer pourquoi cela ne suffit pas à invalider le codage. +Ta méthode suit les 5 passes de raisonnement expert TIM : -IMPORTANT — CODES CIM-10 : -Ne parle JAMAIS de « codage initial » ou « codage contesté » sans citer explicitement le code CIM-10 et son libellé (ex: Z45.80 — Ajustement et entretien d'un dispositif implantable). -Chaque argument doit désigner précisément quel code est défendu ou contesté, avec son libellé complet. +PASSE 1 — CONTEXTE ADMINISTRATIF : +Analyse le contexte du séjour (âge, sexe, durée, mode d'entrée/sortie, actes) pour cadrer \ +ton raisonnement. En pédiatrie (< 18 ans), les normes biologiques et codages diffèrent. \ +Une admission en urgence implique un contexte aigu influençant le DP. -DOSSIER MÉDICAL DE L'ÉTABLISSEMENT : -{dossier_str} +PASSE 2 — MOTIF D'HOSPITALISATION RÉEL : +Distingue le motif d'entrée déclaré du motif réel en te posant : +- 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 + +PASSE 3 — CONFRONTATION BIOLOGIE / DIAGNOSTIC : +Pour chaque diagnostic contesté, confronte aux seuils biologiques : +{bio_confrontation_str} +- Une valeur normale CONTREDIT un diagnostic actif basé sur cette biologie +- Une valeur pathologique SANS diagnostic est un sous-codage potentiel +- CITE les seuils exacts et les valeurs du dossier + +PASSE 4 — HIÉRARCHIE DIAGNOSTIQUE : +- Le DP est le diagnostic qui a CONSOMMÉ LE PLUS DE RESSOURCES (pas le plus grave) +- Spécifique exclut générique (K81.0 présent → retirer K81.9) +- Codes R (symptômes) INTERDITS en DP si étiologie identifiée +- Chaque DAS doit répondre OUI à au moins une question : + 1. Traitement spécifique pendant ce séjour ? + 2. Allongement de la durée de séjour ? + 3. Modification de la surveillance ou des examens ? + +PASSE 5 — VALIDATION DÉFENSIVE (regard CPAM) : +Pour CHAQUE code défendu, tu DOIS répondre aux 4 questions : +1. Ce diagnostic est-il documenté EXPLICITEMENT dans le dossier, ou DÉDUIT ? +2. Y a-t-il une preuve OBJECTIVE (valeur bio, imagerie, acte CCAM) ? +3. Le code est-il COHÉRENT avec la durée de séjour et les actes réalisés ? +4. Quel DOCUMENT du dossier cite-t-on en premier face à la CPAM ? + +DOSSIER MÉDICAL : {dossier_str} {asymetrie_str} {tagged_str} -OBJET DU DÉSACCORD : {titre} +CONTESTATION CPAM : +Objet : {titre} +Argument UCR : {arg_ucr} +Décision UCR : {decision_ucr} -ARGUMENTATION DE LA CPAM (UCR) : -{arg_ucr} - -DÉCISION UCR : {decision_ucr} - -CODES CONTESTÉS : -{codes_str} +CODES EN JEU : {codes_str} {definitions_str} {codes_autorises_str} -SOURCES RÉGLEMENTAIRES (Guide méthodologique, CIM-10) : -{sources_text} +SOURCES RÉGLEMENTAIRES : {sources_text} {extraction_str} -CONSIGNES : +RÈGLE ABSOLUE — HONNÊTETÉ INTELLECTUELLE : +Un mémoire crédible ne force JAMAIS un argument que le dossier ne soutient pas. +- Si une valeur biologique est NORMALE alors que le diagnostic l'exige pathologique → \ +tu DOIS le signaler et NE PAS défendre ce code sur cet axe +- Si un diagnostic n'a AUCUNE preuve objective (pas de bio, pas d'imagerie, pas d'acte) → \ +tu écris : "Ce diagnostic repose sur le seul jugement clinique, sans preuve biologique ou \ +paraclinique dans le dossier" +- Si la confrontation bio CONTREDIT un diagnostic → tu NE LE DÉFENDS PAS et tu le signales \ +dans le champ "codes_non_defendables" +- Si la CPAM a RAISON sur un point → tu le reconnais clairement. Mieux vaut concéder un \ +point indéfendable et gagner en crédibilité sur les points solides +- Principe TIM : "Mieux vaut un code moins précis mais défendable qu'un code précis mais \ +indéfendable" -CONTEXTE CLINIQUE : -- Prends en compte l'ÂGE du patient (pédiatrie < 18 ans, personne âgée >= 80 ans), le MODE D'ENTRÉE (urgence vs programmé), et la DURÉE DE SÉJOUR pour contextualiser ton analyse -- En pédiatrie, les normes biologiques et les codages peuvent différer de l'adulte -- Une admission en urgence implique un contexte clinique aigu qui influence le choix du DP +CONSIGNES DE RÉDACTION : -ÉTAPE 1 — ANALYSE HONNÊTE (avant de contre-argumenter) : -- Identifie ce que la CPAM a compris correctement dans le dossier -- Reconnais les points où leur raisonnement est fondé, même partiellement -- Explique ENSUITE pourquoi ces points ne justifient pas leur conclusion +1. STRUCTURE EN MOYENS DE DÉFENSE NUMÉROTÉS (pas de prose libre) +2. Chaque moyen = un argument autonome avec sa preuve FORMELLEMENT DOCUMENTÉE dans le dossier +3. CITE les codes CIM-10 avec libellé complet (ex: N17.8 — Autre insuffisance rénale aiguë) +4. CITE les valeurs bio EXACTES avec seuils normatifs (ex: "CRP = 145 mg/L [norme < 5]") +5. CITE les sources réglementaires au format [Document - page N] "citation verbatim" +6. JAMAIS d'argument sans preuve traçable — si tu n'as pas la preuve, NE FAIS PAS l'argument +7. Ton ASSERTIF mais factuel — pas de formules creuses ("il convient de noter que...") +8. Si un point CPAM est légitime, le reconnaître CLAIREMENT — la crédibilité globale en dépend +9. N'invente AUCUN tag, code ou source qui n'est pas fourni ci-dessus +10. NE JAMAIS qualifier une valeur NORMALE comme pathologique ni extrapoler au-delà des faits +11. Tags valides : [DP], [DAS-N], [BIO-N], [IMG-N], [TRT-N], [ACTE-N], [ANT-N], [COMPL-N] -AXE MÉDICAL : -- Analyse le bien-fondé médical du codage de l'établissement -- CITE les éléments cliniques EXACTS du dossier en utilisant UNIQUEMENT les tags [XX-N] fournis dans la section ÉLÉMENTS CLINIQUES RÉFÉRENCÉS -- Tags valides : [DP], [DAS-N], [BIO-N], [IMG-N], [TRT-N], [ACTE-N], [ANT-N], [COMPL-N] -- N'invente JAMAIS un tag qui ne figure pas dans la liste ci-dessus. Si un élément n'a pas de tag, décris-le en texte libre SANS crochets. -- Confronte l'argumentation CPAM aux sources CIM-10 et Guide Méthodologique fournies -- Ne mentionne AUCUN élément qui ne figure pas dans les éléments référencés ci-dessus - -AXE ASYMÉTRIE D'INFORMATION : -- La CPAM a fondé son analyse uniquement sur le CRH et les codes transmis -- Pour CHAQUE élément clinique pertinent, cite les VALEURS EXACTES et explique leur signification clinique -- Démontre en quoi ces éléments complémentaires (biologie, imagerie, traitements, actes) justifient le codage contesté -- Ne mentionne AUCUN élément qui n'est pas dans le dossier fourni - -MISE EN FORME : -- Structure chaque section avec des tirets pour lister les arguments distincts -- Un argument par puce, avec la preuve ou la référence associée - -AXE RÉGLEMENTAIRE : -- Identifie si l'UCR fait une interprétation restrictive non fondée d'une règle -- Confronte le raisonnement CPAM au texte EXACT des sources fournies -- Format OBLIGATOIRE pour chaque référence : [Document - page N] suivi d'une CITATION VERBATIM du passage pertinent -- INTERDICTION ABSOLUE de citer une référence qui ne figure pas dans les sources fournies ci-dessus -- Si aucune source pertinente n'est disponible → écrire explicitement "Pas de source réglementaire disponible" -- Relève les contradictions entre l'argumentation CPAM et les règles officielles - -Réponds UNIQUEMENT avec un objet JSON au format suivant : +Réponds UNIQUEMENT avec un objet JSON : {{ - "analyse_contestation": "Résumé de ce que conteste la CPAM et sur quelle base", - "points_accord": "Points CONCRETS où la CPAM a raison ou partiellement raison (JAMAIS 'Aucun' — il y a toujours au moins un point légitime à reconnaître)", - "contre_arguments_medicaux": "Argumentation médicale en faveur du codage, en expliquant pourquoi les points d'accord ne suffisent pas à invalider le codage", - "preuves_dossier": [ - {{"ref": "BIO-1 ou DAS-3 ou DP (UNIQUEMENT un tag existant de la section ÉLÉMENTS CLINIQUES RÉFÉRENCÉS)", "element": "biologie|imagerie|traitement|acte|diagnostic|antécédent|complication", "valeur": "valeur exacte du dossier", "signification": "explication clinique"}} + "objet": "Contestation {titre} — OGC {numero_ogc} — Mémoire en défense", + "rappel_faits": "Résumé factuel du séjour en 3-5 lignes : motif, actes, durée, issue", + "moyens_defense": [ + {{ + "numero": 1, + "titre": "Titre court du moyen (ex: Le DP N17.8 est justifié par la biologie)", + "argument": "Développement avec preuves tagées [XX-N], valeurs bio avec seuils, sources réglementaires", + "preuves": [ + {{"ref": "[BIO-1]", "fait": "Créatinine = 280 µmol/L [norme 50-120]", "signification": "IRA confirmée"}} + ], + "source_reglementaire": "[Document - page N] citation verbatim ou null" + }} + ], + "confrontation_bio": [ + {{"diagnostic": "N17.8 IRA", "test": "Créatinine", "valeur": 280, "seuil": "> 130 µmol/L", "verdict": "CONFIRMÉ"}} + ], + "asymetrie_information": "Éléments cliniques que la CPAM n'avait PAS (bio, imagerie, actes) — brièvement", + "reponse_points_cpam": "Pour chaque point légitime de la CPAM : reconnaissance CLAIRE + réfutation factuelle OU concession si indéfendable", + "codes_non_defendables": [ + {{"code": "D50.9", "raison": "Hb = 13.5 g/dL [norme > 12 F] — valeur NORMALE, anémie non confirmée biologiquement", "recommandation": "Retrait recommandé — code indéfendable face à la CPAM"}} ], - "contre_arguments_asymetrie": "Éléments cliniques que la CPAM n'avait pas et qui justifient le codage", - "contre_arguments_reglementaires": "Erreurs d'interprétation réglementaire de la CPAM, avec citations verbatim des sources", "references": [ {{"document": "nom du document source", "page": "numéro de page", "citation": "citation verbatim du passage"}} ], - "conclusion": "Synthèse en citant EXPLICITEMENT les codes CIM-10 défendus (ex: DP Z45.80 — libellé) : points reconnus à la CPAM, puis pourquoi ce codage précis est néanmoins justifié" + "conclusion_dispositive": "Par conséquent, au vu des éléments cliniques objectifs (citer les preuves 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 [CODE — libellé], DAS [CODE — libellé]. [Si code non défendable :] Nous reconnaissons que le code [CODE] ne dispose pas d'un support documentaire suffisant." }}""" @@ -315,7 +343,8 @@ Réponds UNIQUEMENT avec un objet JSON au format suivant : # --------------------------------------------------------------------------- CPAM_ADVERSARIAL = """\ -Tu es un relecteur critique. Vérifie la cohérence de cette contre-argumentation CPAM. +Tu es un relecteur critique expert en codage PMSI. Vérifie la cohérence et l'honnêteté \ +intellectuelle de ce mémoire en défense CPAM. RÉPONSE GÉNÉRÉE : {response_json} @@ -329,11 +358,17 @@ CODES CONTESTÉS : {da_ucr_line} Vérifie STRICTEMENT : -1. Chaque valeur bio/imagerie/traitement citée dans les preuves existe dans les éléments factuels -2. Si une valeur bio est qualifiée de "élevée", "basse" ou "anormale", vérifie qu'elle est RÉELLEMENT hors normes selon les normes ci-dessus (ex: CRP 5 = NORMAL, pas élevé) -3. La conclusion est cohérente avec l'argumentation développée -4. Les points d'accord ne contredisent pas les contre-arguments -5. Les codes CIM-10 mentionnés dans la conclusion sont cohérents avec le reste +1. Chaque moyen de défense a une preuve traçable FORMELLEMENT documentée dans les éléments factuels +2. Si une valeur bio est qualifiée de "élevée", "basse" ou "anormale", vérifie qu'elle est \ +RÉELLEMENT hors normes selon les normes ci-dessus (ex: CRP 5 = NORMAL, pas élevé) +3. AUCUNE valeur NORMALE n'est présentée comme pathologique +4. La confrontation bio (champ "confrontation_bio") est cohérente avec les valeurs du dossier \ +et les seuils normatifs +5. Les codes signalés dans "codes_non_defendables" ne sont PAS défendus dans "moyens_defense" +6. La conclusion dispositive cite les bons codes et reconnaît les concessions +7. Les seuils bio cités correspondent aux normes officielles ci-dessus +8. Les codes CIM-10 mentionnés dans la conclusion sont cohérents avec le reste +9. Le champ "reponse_points_cpam" répond factuellement aux arguments CPAM (pas de déni) Réponds UNIQUEMENT en JSON : {{ @@ -341,3 +376,36 @@ Réponds UNIQUEMENT en JSON : "erreurs": ["description précise de chaque incohérence trouvée"], "score_confiance": 0 à 10 }}""" + + +# --------------------------------------------------------------------------- +# 8. DP_RANKER_CONSTRAINED — NUKE-3 sélection DP dans une liste fermée +# Source : dp_selector.py _llm_rank() +# Rôle : coding | Température : 0.0 | max_tokens : 1000 +# --------------------------------------------------------------------------- + +DP_RANKER_CONSTRAINED = """\ +Tu es un médecin DIM expert en codage PMSI. Tu dois choisir le Diagnostic Principal (DP) \ +parmi la liste FERMÉE de {n_candidates} candidats ci-dessous. + +RÈGLES STRICTES : +1. Le DP reflète le MOTIF PRINCIPAL de prise en charge pendant ce séjour +2. Un acte seul (cholécystectomie, biopsie…) NE PEUT PAS être DP s'il existe un candidat textuel +3. Un symptôme (R00-R99) NE PEUT PAS être DP si une étiologie candidate existe dans la liste +4. Une comorbidité chronique (HTA, diabète, BPCO) NE PEUT PAS être DP sauf prise en charge ACTIVE +5. Tu DOIS choisir un index de la liste — JAMAIS de réponse hors liste + +CANDIDATS : +{candidates_str} + +CONTEXTE CLINIQUE : +{ctx_str} + +Réponds UNIQUEMENT en JSON : +{{ + "chosen_index": N, + "confidence": "high|medium|low", + "verdict": "CONFIRMED|REVIEW", + "evidence": ["raison 1", "raison 2"], + "reason": "explication courte justifiant le choix" +}}""" diff --git a/src/quality/decision_engine.py b/src/quality/decision_engine.py index 2ad6c0b..c5e3ca4 100644 --- a/src/quality/decision_engine.py +++ b/src/quality/decision_engine.py @@ -161,7 +161,6 @@ def _threshold_high(cfg: dict, test: str, age_band: str, doc_hi: float | None) - def _is_sodium_test(test: str) -> bool: t = (test or "").lower().strip() - # 'na' est trop générique: on privilégie sodium/natrémie if "sodium" in t or "natr" in t: return True return bool(re.fullmatch(r"na\+?", t)) @@ -174,6 +173,131 @@ def _is_potassium_test(test: str) -> bool: return bool(re.fullmatch(r"k\+?", t)) +def _get_bio_matcher(analyte: str): + """Retourne une fonction de matching pour l'analyte demandé.""" + a = analyte.lower() + if a == "sodium": return _is_sodium_test + if a == "potassium": return _is_potassium_test + if a == "hemoglobin": return lambda t: "hemoglob" in t.lower() or "hb" in t.lower().split() + if a == "platelets": return lambda t: "plaquette" in t.lower() or "platelet" in t.lower() + if a == "creatinine": return lambda t: "creatinine" in t.lower() + if a == "glucose": return lambda t: "glucose" in t.lower() or "glycemie" in t.lower() + if a == "hba1c": return lambda t: "hba1c" in t.lower() + if a == "tsh": return lambda t: "tsh" in t.lower() + # Fallback: simple inclusion + return lambda t: a in t.lower() + + +def _apply_bio_rules_gen(dossier: DossierMedical, cfg_ranges: dict) -> None: + """Applique les règles de validation biologique définies dans config/bio_rules.yaml.""" + bio_cfg = load_bio_rules() or {} + rules = (bio_cfg.get("rules") or {}) if isinstance(bio_cfg, dict) else {} + missing_cfg = (bio_cfg.get("missing_evidence") or {}) if isinstance(bio_cfg, dict) else {} + age_band = _age_band(dossier, cfg_ranges) + + def _push_need_info_veto(where: str, message: str) -> None: + if dossier.veto_report is None: return + vr = dossier.veto_report + veto = str(missing_cfg.get("veto") or "VETO-17") + if not rule_enabled(veto): return + severity = str(missing_cfg.get("severity") or "LOW") + penalty = int(missing_cfg.get("score_penalty") or 0) + if any((it.veto == veto and it.where == where and (it.message or "") == message) for it in (vr.issues or [])): + return + vr.issues.append(VetoIssue(veto=veto, severity=severity, where=where, message=message)) + if (vr.verdict or "") == "PASS": vr.verdict = "NEED_INFO" + if penalty: vr.score_contestabilite = max(0, int(vr.score_contestabilite or 0) - penalty) + + for rule_id, r in rules.items(): + if not r.get("enabled", True): + continue + + analyte = r.get("analyte") + if not analyte: continue + + codes = set(r.get("codes") or []) + matcher = _get_bio_matcher(analyte) + values, lo_doc, hi_doc = _bio_values(dossier, matcher) + t_type = r.get("threshold_type", "low") # 'low' pour hypo/anémie, 'high' pour hyper/insuffisance + + # 1) PREUVE MANQUANTE + if not values and bool(missing_cfg.get("enabled", False)): + for i, das in enumerate(dossier.diagnostics_associes or []): + if (das.cim10_suggestion or "") not in codes: continue + if das.cim10_decision and (das.cim10_decision.action or "") in ("RULED_OUT", "REMOVE"): continue + + rule_key = f"RULE-{rule_id.upper()}-MISSING" + if not rule_enabled(rule_key): continue + + reason = f"Preuve manquante: {analyte} non extrait — impossible de valider {das.cim10_suggestion} de façon défendable." + das.status = "needs_info" + das.cim10_final = None + das.cim10_decision = CodeDecision( + action="NEED_INFO", + final_code=None, + downgraded_from=das.cim10_suggestion, + reason=reason, + needs_info=[f"Valeur(s) de {analyte} + date(s) ?", "Normes du laboratoire si disponibles ?"], + applied_rules=[rule_key], + ) + _push_need_info_veto(f"diagnostics_associes[{i}]", f"{das.cim10_suggestion} suggéré mais aucune preuve de {analyte} n'a été extraite.") + + # 2) CONTRADICTION (RULED_OUT) + if values: + is_conflict = False + found_val = 0.0 + threshold = 0.0 + + if t_type == "low": + # Pour un diagnostic de type "Bas" (hypo, anémie), on écarte si la valeur est >= seuil bas normal + threshold = _threshold(cfg_ranges, analyte, age_band, lo_doc) + if min(values) >= threshold: + is_conflict = True + found_val = min(values) + else: + # Pour un diagnostic de type "Haut" (hyper, insuff), on écarte si la valeur est <= seuil haut normal + threshold = _threshold_high(cfg_ranges, analyte, age_band, hi_doc) + if max(values) <= threshold: + is_conflict = True + found_val = max(values) + + # Cas particulier : seuil fixe dans le YAML (ex: HbA1c > 9) + if r.get("threshold_value") is not None: + fixed_t = float(r["threshold_value"]) + if t_type == "high" and max(values) < fixed_t: + is_conflict = True + found_val = max(values) + threshold = fixed_t + elif t_type == "low" and min(values) > fixed_t: + is_conflict = True + found_val = min(values) + threshold = fixed_t + + if is_conflict: + rule_key = f"RULE-{rule_id.upper()}-NORMAL" + if not rule_enabled(rule_key): continue + + op = "≥" if t_type == "low" else "≤" + reason = f"Contradiction biologique: {analyte}={found_val} ({op}{threshold}, valeur normale) — {r.get('message', 'diagnostic non retenu')}." + + for das in dossier.diagnostics_associes or []: + if (das.cim10_suggestion or "") not in codes: continue + das.status = "ruled_out" + das.ruled_out_reason = reason + das.cim10_final = None + das.cim10_decision = CodeDecision( + action="RULED_OUT", + final_code=None, + downgraded_from=das.cim10_suggestion, + reason=reason, + needs_info=[ + f"Valeurs de {analyte} sur d'autres dates (trend) ?", + f"Mention explicite de {das.cim10_suggestion} confirmée malgré valeurs normales ?", + ], + applied_rules=[rule_key], + ) + + def _bio_values( dossier: DossierMedical, matcher, @@ -369,6 +493,30 @@ def apply_decisions(dossier: DossierMedical) -> None: for das in dossier.diagnostics_associes or []: _set_default_final(das) + # --- Règle: nettoyage hiérarchique (VETO-22bis) --- + # Si un code spécifique (ex: K81.0) est présent, on retire le code générique (K81.9) + all_final_codes = set() + if dossier.diagnostic_principal and dossier.diagnostic_principal.cim10_final: + all_final_codes.add(dossier.diagnostic_principal.cim10_final) + for das in dossier.diagnostics_associes or []: + if das.cim10_final: + all_final_codes.add(das.cim10_final) + + for das in dossier.diagnostics_associes or []: + if das.cim10_final and das.cim10_final.endswith(".9"): + cat3 = das.cim10_final[:3] + # Chercher s'il existe un autre code plus spécifique dans la même catégorie + if any(c.startswith(cat3) and c != das.cim10_final for c in all_final_codes): + das.status = "removed" + das.cim10_decision = CodeDecision( + action="REMOVE", + final_code=None, + downgraded_from=das.cim10_final, + reason=f"Code générique {das.cim10_final} retiré car un code plus spécifique de la catégorie {cat3} est présent.", + applied_rules=["RULE-HIERARCHY-CLEANUP"], + ) + das.cim10_final = None + # --- Règle: D50 sans preuve martiale -> downgrade D64.9 + needs_info --- if rule_enabled("RULE-D50-NEEDS-IRON"): for das in dossier.diagnostics_associes or []: @@ -427,186 +575,9 @@ def apply_decisions(dossier: DossierMedical) -> None: applied_rules=["RULE-D69.6-PLT-NORMAL"], ) - # --- Pack "bio": contradictions simples Na/K -> ruled_out (piloté par config/bio_rules.yaml) - # Objectif: réduire VETO-09 en écartant les diagnostics "hyper/hypo" quand la valeur est clairement normale. - bio_cfg = load_bio_rules() or {} - rules = (bio_cfg.get("rules") or {}) if isinstance(bio_cfg, dict) else {} - - missing_cfg = (bio_cfg.get("missing_evidence") or {}) if isinstance(bio_cfg, dict) else {} - def _push_need_info_veto(where: str, message: str) -> None: - """Ajoute un VETO non-bloquant quand la preuve biologique est manquante.""" - if dossier.veto_report is None: - return - vr = dossier.veto_report - veto = str(missing_cfg.get("veto") or "VETO-17") - # Désactivation globale par YAML (config/rules) - if not rule_enabled(veto): - return - severity = str(missing_cfg.get("severity") or "LOW") - penalty = int(missing_cfg.get("score_penalty") or 0) - - # Anti-doublon - if any((it.veto == veto and it.where == where and (it.message or "") == message) for it in (vr.issues or [])): - return - - vr.issues.append(VetoIssue(veto=veto, severity=severity, where=where, message=message)) - if (vr.verdict or "") == "PASS": - vr.verdict = "NEED_INFO" - if penalty: - vr.score_contestabilite = max(0, int(vr.score_contestabilite or 0) - penalty) - - - # Sodium (hyponatrémie) - r = rules.get("hyponatremia") or {} - if r.get("enabled", True): - codes = set(r.get("codes") or ["E87.1"]) - na_values, na_lo_doc, _na_hi_doc = _bio_values(dossier, _is_sodium_test) - if (not na_values) and bool(missing_cfg.get("enabled", False)) and rule_enabled("RULE-E87.1-MISSING-NA"): - for i, das in enumerate(dossier.diagnostics_associes or []): - if (das.cim10_suggestion or "") not in codes: - continue - if das.cim10_decision and (das.cim10_decision.action or "") in ("RULED_OUT", "REMOVE"): - continue - - reason = "Preuve manquante: natrémie (sodium) non extraite — impossible de valider E87.1 de façon défendable." - where = f"diagnostics_associes[{i}]" - das.status = "needs_info" - das.cim10_final = None - das.cim10_decision = CodeDecision( - action="NEED_INFO", - final_code=None, - downgraded_from=das.cim10_suggestion, - reason=reason, - needs_info=[ - "Valeur(s) de sodium (natrémie) + date(s) ?", - "Normes du laboratoire si disponibles ?", - ], - applied_rules=["RULE-E87.1-MISSING-NA"], - ) - _push_need_info_veto(where, "E87.1 suggérée mais aucune natrémie (Na) n'a été extraite des résultats biologiques.") - - if na_values and rule_enabled("RULE-E87.1-NA-NORMAL"): - na_threshold = _threshold(cfg_ranges, "sodium", age_band, na_lo_doc) - # Ne ruled_out que si AUCUNE valeur n'est sous la borne basse normale. - if min(na_values) >= na_threshold: - na_val = min(na_values) - for das in dossier.diagnostics_associes or []: - if (das.cim10_suggestion or "") not in codes: - continue - das.status = "ruled_out" - das.ruled_out_reason = ( - f"Contradiction biologique: sodium={na_val} (≥{na_threshold}, valeur normale) " - "— hyponatrémie non retenue sans preuve explicite." - ) - das.cim10_final = None - das.cim10_decision = CodeDecision( - action="RULED_OUT", - final_code=None, - downgraded_from=das.cim10_suggestion, - reason=das.ruled_out_reason, - needs_info=[ - "Valeurs de natrémie sur d'autres dates (trend) ?", - "Mention explicite d'hyponatrémie confirmée malgré valeurs normales ?", - "Contexte (perfusions, diurétiques, SIADH, etc.) documenté ?", - ], - applied_rules=["RULE-E87.1-NA-NORMAL"], - ) - - # Potassium (hyper/hypo) - k_values, k_lo_doc, k_hi_doc = _bio_values(dossier, _is_potassium_test) - if (not k_values) and bool(missing_cfg.get("enabled", False)): - # Valeur de kaliémie manquante : on refuse de valider E87.5/E87.6 sans preuve. - codes_hyper = set((rules.get("hyperkalemia") or {}).get("codes") or ["E87.5"]) - codes_hypo = set((rules.get("hypokalemia") or {}).get("codes") or ["E87.6"]) - codes = codes_hyper.union(codes_hypo) - - for i, das in enumerate(dossier.diagnostics_associes or []): - if (das.cim10_suggestion or "") not in codes: - continue - if das.cim10_decision and (das.cim10_decision.action or "") in ("RULED_OUT", "REMOVE"): - continue - - code = das.cim10_suggestion or "" - rule_id = f"RULE-{code}-MISSING-K" - if not rule_enabled(rule_id): - continue - reason = f"Preuve manquante: kaliémie (potassium) non extraite — impossible de valider {code} de façon défendable." - where = f"diagnostics_associes[{i}]" - das.status = "needs_info" - das.cim10_final = None - das.cim10_decision = CodeDecision( - action="NEED_INFO", - final_code=None, - downgraded_from=code, - reason=reason, - needs_info=[ - "Valeur(s) de potassium (kaliémie) + date(s) ?", - "Normes du laboratoire si disponibles ?", - ], - applied_rules=[f"RULE-{code}-MISSING-K"], - ) - _push_need_info_veto(where, f"{code} suggéré mais aucune kaliémie (K) n'a été extraite des résultats biologiques.") - - if k_values: - # Hyperkaliémie - r = rules.get("hyperkalemia") or {} - if r.get("enabled", True) and rule_enabled("RULE-E87.5-K-NORMAL"): - codes = set(r.get("codes") or ["E87.5"]) - k_high = _threshold_high(cfg_ranges, "potassium", age_band, k_hi_doc) - # Ruled_out si AUCUNE valeur ne dépasse la borne haute normale. - if max(k_values) <= k_high: - k_val = max(k_values) - for das in dossier.diagnostics_associes or []: - if (das.cim10_suggestion or "") not in codes: - continue - das.status = "ruled_out" - das.ruled_out_reason = ( - f"Contradiction biologique: potassium={k_val} (≤{k_high}, valeur normale) " - "— hyperkaliémie non retenue sans preuve explicite." - ) - das.cim10_final = None - das.cim10_decision = CodeDecision( - action="RULED_OUT", - final_code=None, - downgraded_from=das.cim10_suggestion, - reason=das.ruled_out_reason, - needs_info=[ - "Valeurs de kaliémie sur d'autres dates (trend) ?", - "Mention explicite d'hyperkaliémie confirmée malgré valeurs normales ?", - "Contexte (IRA, IEC/ARA2, spironolactone, hémolyse) documenté ?", - ], - applied_rules=["RULE-E87.5-K-NORMAL"], - ) - - # Hypokaliémie - r = rules.get("hypokalemia") or {} - if r.get("enabled", True) and rule_enabled("RULE-E87.6-K-NORMAL"): - codes = set(r.get("codes") or ["E87.6"]) - k_low = _threshold(cfg_ranges, "potassium_low", age_band, k_lo_doc) - # Ruled_out si AUCUNE valeur n'est sous la borne basse normale. - if min(k_values) >= k_low: - k_val = min(k_values) - for das in dossier.diagnostics_associes or []: - if (das.cim10_suggestion or "") not in codes: - continue - das.status = "ruled_out" - das.ruled_out_reason = ( - f"Contradiction biologique: potassium={k_val} (≥{k_low}, valeur normale) " - "— hypokaliémie non retenue sans preuve explicite." - ) - das.cim10_final = None - das.cim10_decision = CodeDecision( - action="RULED_OUT", - final_code=None, - downgraded_from=das.cim10_suggestion, - reason=das.ruled_out_reason, - needs_info=[ - "Valeurs de kaliémie sur d'autres dates (trend) ?", - "Mention explicite d'hypokaliémie confirmée malgré valeurs normales ?", - "Contexte (diurétiques, diarrhées, pertes rénales) documenté ?", - ], - applied_rules=["RULE-E87.6-K-NORMAL"], - ) + # --- Pack "bio": contradictions pilotées par config/bio_rules.yaml + cfg_ranges = load_reference_ranges() + _apply_bio_rules_gen(dossier, cfg_ranges) # --- Règle: promotion DAS→DP quand aucun DP n'a été extrait --- if rule_enabled("RULE-DAS-TO-DP"): @@ -638,6 +609,10 @@ def apply_decisions(dossier: DossierMedical) -> None: ), ) dossier.diagnostics_associes.remove(best) + # Traçabilité : alerte DIM lisible pour audit + dossier.alertes_codage.append( + f"RULE-DAS-TO-DP: DP absent → DAS {best.cim10_final} ({best.texte}) promu en DP" + ) logger.warning( "PROMOTE_DP: DAS %s (%s) promu en DP — aucun DP extrait", best.cim10_final, best.texte, diff --git a/src/quality/veto_engine.py b/src/quality/veto_engine.py index d1af0a4..8a558ae 100644 --- a/src/quality/veto_engine.py +++ b/src/quality/veto_engine.py @@ -9,10 +9,13 @@ audit-able, et indépendant des modèles. from __future__ import annotations +import logging import re import unicodedata from typing import Iterable +logger = logging.getLogger(__name__) + from ..config import ( ActeCCAM, BiologieCle, @@ -22,6 +25,11 @@ from ..config import ( VetoReport, rule_enabled, rule_force_severity, + load_demographic_rules, + load_diagnostic_conflicts, + load_procedure_diagnosis_rules, + load_temporal_rules, + load_parcours_rules, ) @@ -221,7 +229,7 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport: issues: list[VetoIssue] = [] seen_issue_keys: set[tuple[str, str, str]] = set() # (veto, where, message) - def add(veto: str, severity: str, where: str, message: str): + def add(veto: str, severity: str, where: str, message: str, citation: str | None = None): # Désactivation globale par YAML (config/rules) if not rule_enabled(veto): return @@ -233,14 +241,17 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport: if key in seen_issue_keys: return seen_issue_keys.add(key) - issues.append(VetoIssue(veto=veto, severity=severity, where=where, message=message)) + issues.append(VetoIssue(veto=veto, severity=severity, where=where, message=message, citation=citation)) # ----------------------------- # VETO-02 : code sans preuve # ----------------------------- dp = dossier.diagnostic_principal if dp and dp.cim10_suggestion: - if not _has_evidence(dp): + if getattr(dp, "source", None) == "trackare": + # 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") for i, das in enumerate(dossier.diagnostics_associes): @@ -362,6 +373,48 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport: if das.cim10_suggestion and das.cim10_suggestion.startswith(("N17", "N18", "N19")) and creat < 110 and das.cim10_confidence == "high": add("VETO-09", "LOW", f"diagnostics_associes[{i}]", f"IR {das.cim10_suggestion} à confirmer (créat={creat})") + # ------------------------------------------------- + # VETO-18 & VETO-19 : Règles démographiques (Âge / Sexe) + # ------------------------------------------------- + demo_cfg = load_demographic_rules() + patient_sex = (dossier.sejour.sexe or "").upper() + patient_age = dossier.sejour.age + + def _check_demo(d: Diagnostic, where: str): + code = str(d.cim10_suggestion or "") + if not code: return + cat3 = code[:3] + + # Sexe + for rule_name, rule in demo_cfg.get("sex_rules", {}).items(): + if cat3 in rule.get("codes", []): + req_sex = rule.get("required_sex") + if req_sex and patient_sex and patient_sex != req_sex: + add("VETO-19", rule.get("severity", "HARD"), where, + f"Incohérence Sexe: {code} réservé au sexe {req_sex} (patient: {patient_sex})", + citation=rule.get("atih_ref")) + + # Âge + for rule_name, rule in demo_cfg.get("age_rules", {}).items(): + if cat3 in rule.get("codes", []): + if patient_age is not None: + max_age = rule.get("max_age_years") + min_age = rule.get("min_age_years") + if max_age is not None and patient_age > max_age: + add("VETO-18", rule.get("severity", "HARD"), where, + f"Incohérence Âge: {code} réservé à l'âge ≤ {max_age} ans (patient: {patient_age} ans)", + citation=rule.get("atih_ref")) + if min_age is not None and patient_age < min_age: + add("VETO-18", rule.get("severity", "HARD"), where, + f"Incohérence Âge: {code} réservé à l'âge ≥ {min_age} ans (patient: {patient_age} ans)", + citation=rule.get("atih_ref")) + + if dp: + _check_demo(dp, "diagnostic_principal") + for i, das in enumerate(dossier.diagnostics_associes): + if not _is_ruled_out(das): + _check_demo(das, f"diagnostics_associes[{i}]") + # ------------------------------------------------- # VETO-12 : sur-confiance # ------------------------------------------------- @@ -383,7 +436,8 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport: if z3 not in _Z_DP_WHITELIST: add("VETO-20", "MEDIUM", "diagnostic_principal", f"DP {dp.cim10_suggestion} est un code Z interdit en DP (catégorie {z3}). " - "Les codes Z ne sont autorisés en DP que pour certains motifs (Z51 chimio, Z09 suivi, etc.).") + "Les codes Z ne sont autorisés en DP que pour certains motifs (Z51 chimio, Z09 suivi, etc.).", + citation="Guide Méthodologique MCO : Règles de sélection du Diagnostic Principal (Chapitre Z)") # ------------------------------------------------- # VETO-21 : Code R (symptôme) en DP → CMD 23, tarification faible @@ -399,7 +453,8 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport: severity = "LOW" if has_precise else "MEDIUM" add("VETO-21", severity, "diagnostic_principal", f"DP {dp.cim10_suggestion} est un code symptôme (chapitre R) → CMD 23. " - "Un diagnostic étiologique précis devrait être recherché comme DP.") + "Un diagnostic étiologique précis devrait être recherché comme DP.", + citation="Guide Méthodologique MCO : Sélection du DP et codes de symptômes (Chapitre XVIII)") # ------------------------------------------------- # VETO-22 : Même catégorie CIM-10 3 chars en DP + DAS @@ -415,26 +470,39 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport: if das_cat == dp_cat and das.cim10_suggestion != dp.cim10_suggestion: add("VETO-22", "LOW", f"diagnostics_associes[{i}]", f"DAS {das.cim10_suggestion} même catégorie que DP {dp.cim10_suggestion} " - f"({dp_cat}). Vérifier si la sous-catégorie DAS est pertinente ou redondante.") + f"({dp_cat}). Vérifier si la sous-catégorie DAS est pertinente ou redondante.", + citation="Guide Méthodologique MCO : Règle de non-redondance du codage") # ------------------------------------------------- - # VETO-23 : Exclusions mutuelles (diabète type 1 vs type 2, HTA) - # Règle PMSI : codes incompatibles dans le même séjour. + # VETO-25 & VETO-26 : Cohérence inter-diagnostics (conflits) # ------------------------------------------------- - all_codes = set() + conflict_cfg = load_diagnostic_conflicts() + codes_full = set() if dp and dp.cim10_suggestion: - all_codes.add(str(dp.cim10_suggestion)[:3]) + codes_full.add(str(dp.cim10_suggestion)) for das in dossier.diagnostics_associes: if not _is_ruled_out(das) and das.cim10_suggestion: - all_codes.add(str(das.cim10_suggestion)[:3]) + codes_full.add(str(das.cim10_suggestion)) + + # Exclusions mutuelles + for rule in conflict_cfg.get("mutual_exclusions", []): + matches = [c for c in codes_full if any(c.startswith(prefix) for prefix in rule.get("codes", []))] + if len(set(c[:3] for c in matches)) > 1: + add("VETO-25", rule.get("severity", "MEDIUM"), "diagnostics_associes", + f"{rule.get('name')}: {rule.get('message')} ({', '.join(matches)})", + citation=rule.get("atih_ref")) - _MUTUAL_EXCLUSIONS = [ - ({"E10"}, {"E11"}, "Diabète type 1 (E10) et type 2 (E11) mutuellement exclusifs"), - ({"I10"}, {"I11", "I12", "I13"}, "HTA essentielle (I10) incompatible avec HTA secondaire (I11/I12/I13)"), - ] - for group_a, group_b, msg in _MUTUAL_EXCLUSIONS: - if (all_codes & group_a) and (all_codes & group_b): - add("VETO-23", "MEDIUM", "diagnostics_associes", msg) + # Incompatibilités + for rule in conflict_cfg.get("incompatibilities", []): + pair = rule.get("pair", []) + if len(pair) >= 2: + p1_prefixes = [pair[0]] if isinstance(pair[0], str) else pair[0] + p2_prefixes = pair[1:] + part1 = any(any(c.startswith(p) for p in p1_prefixes) for c in codes_full) + part2 = any(any(c.startswith(p) for p in p2_prefixes) for c in codes_full) + if part1 and part2: + add("VETO-26", rule.get("severity", "HARD"), "diagnostics_associes", rule.get("message"), + citation=rule.get("atih_ref")) # ------------------------------------------------- # VETO-24 : Lésion traumatique (S/T) sans cause externe (V/W/X/Y) @@ -443,13 +511,125 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport: # ------------------------------------------------- has_injury = any( str(c).startswith(("S", "T")) and not str(c).startswith(("T80", "T81", "T82", "T83", "T84", "T85", "T86", "T87", "T88")) - for c in all_codes + for c in codes_full ) - has_external = any(str(c).startswith(("V", "W", "X", "Y")) for c in all_codes) + has_external = any(str(c).startswith(("V", "W", "X", "Y")) for c in codes_full) if has_injury and not has_external: add("VETO-24", "LOW", "diagnostics_associes", "Lésion traumatique (S/T) sans code de cause externe (V/W/X/Y). " - "La réglementation PMSI exige un code de circonstance pour les traumatismes.") + "La réglementation PMSI exige un code de circonstance pour les traumatismes.", + citation="Guide Méthodologique MCO : Codage des lésions et causes externes (Chapitre XIX/XX)") + + # ------------------------------------------------- + # VETO-27 : Corrélation Actes CCAM / Diagnostics CIM-10 + # ------------------------------------------------- + proc_diag_cfg = load_procedure_diagnosis_rules() + actes_codes = {str(a.code_ccam_suggestion) for a in dossier.actes_ccam if a.code_ccam_suggestion} + diags_codes = set() + if dp and dp.cim10_suggestion: + diags_codes.add(str(dp.cim10_suggestion)) + for das in dossier.diagnostics_associes: + if not _is_ruled_out(das) and das.cim10_suggestion: + diags_codes.add(str(das.cim10_suggestion)) + + for rule in proc_diag_cfg.get("rules", []): + patterns = rule.get("procedure_patterns", []) + matching_actes = [ac for ac in actes_codes if any(ac.startswith(p) for p in patterns)] + + if matching_actes: + required = rule.get("required_diagnosis", []) + has_justification = any(any(dc.startswith(r) for r in required) for dc in diags_codes) + + if not has_justification: + for i, acte in enumerate(dossier.actes_ccam): + if str(acte.code_ccam_suggestion) in matching_actes: + add("VETO-27", rule.get("severity", "HARD"), f"actes_ccam[{i}]", + f"Acte {acte.code_ccam_suggestion} ({rule.get('name')}) sans diagnostic justificatif requis ({', '.join(required)})", + citation=rule.get("atih_ref")) + + # ------------------------------------------------- + # VETO-28 : Validation Temporelle (Durée de séjour) + # ------------------------------------------------- + temporal_cfg = load_temporal_rules() + duree_reelle = dossier.sejour.duree_sejour + + if duree_reelle is not None: + # Collecter tous les codes du séjour + all_stay_codes = set() + if dp and dp.cim10_suggestion: all_stay_codes.add(str(dp.cim10_suggestion)) + for d in dossier.diagnostics_associes: + if not _is_ruled_out(d) and d.cim10_suggestion: all_stay_codes.add(str(d.cim10_suggestion)) + for a in dossier.actes_ccam: + if a.code_ccam_suggestion: all_stay_codes.add(str(a.code_ccam_suggestion)) + + for rule in temporal_cfg.get("rules", []): + patterns = rule.get("codes", []) + # Vérifier si un des codes de la règle est présent dans le dossier + if any(any(c.startswith(p) for p in patterns) for c in all_stay_codes): + min_d = rule.get("min_stay_days") + max_d = rule.get("max_stay_days") + + if min_d is not None and duree_reelle < min_d: + add("VETO-28", rule.get("severity", "MEDIUM"), "sejour", + f"{rule.get('name')}: {rule.get('message')} (réel: {duree_reelle}j, attendu min: {min_d}j)", + citation=rule.get("atih_ref")) + + if max_d is not None and duree_reelle > max_d: + add("VETO-28", rule.get("severity", "LOW"), "sejour", + f"{rule.get('name')}: {rule.get('message')} (réel: {duree_reelle}j, attendu max: {max_d}j)", + citation=rule.get("atih_ref")) + + # ------------------------------------------------- + # VETO-29, 30, 31 : Parcours Patient et Inventaire (Expertise DIM) + # ------------------------------------------------- + parcours_cfg = load_parcours_rules() + + # VETO-29 : Inventaire des pièces + doc_rules = parcours_cfg.get("documentary_rules", {}).get("required_documents", []) + current_doc_types = {dossier.document_type.lower()} + # En cas de dossier fusionné, on peut avoir plusieurs types dans source_files (heuristique) + for sf in (dossier.source_files or []): + if "CRO" in sf.upper(): current_doc_types.add("cro") + if "ANAPATH" in sf.upper(): current_doc_types.add("anapath") + if "CRH" in sf.upper(): current_doc_types.add("crh") + + for rule in doc_rules: + # Condition sur les actes + if rule.get("if_has_procedure_prefix"): + has_proc = any(any(str(a.code_ccam_suggestion).startswith(p) for p in rule["if_has_procedure_prefix"]) + for a in dossier.actes_ccam if a.code_ccam_suggestion) + if has_proc: + if not any(req in current_doc_types for req in rule.get("require_one_of", [])): + add("VETO-29", rule.get("severity", "HARD"), "Global", rule.get("message"), citation=rule.get("atih_ref")) + + # Condition sur les diagnostics + if rule.get("if_has_diagnosis_prefix"): + has_diag = any(any(str(c).startswith(p) for p in rule["if_has_diagnosis_prefix"]) for c in codes_full) + if has_diag: + if not any(req in current_doc_types for req in rule.get("require_one_of", [])): + add("VETO-29", rule.get("severity", "MEDIUM"), "Global", rule.get("message"), citation=rule.get("atih_ref")) + + # VETO-30 : Cohérence Urgences + urg_cfg = parcours_cfg.get("pathway_rules", {}).get("emergency_admission", {}) + is_urgences = "URGENCES" in (dossier.sejour.mode_entree or "").upper() + if is_urgences and urg_cfg.get("check_urgences_match"): + # On vérifie si le DP est mentionné dans un document typé 'urgences' (si disponible) + if dossier.diagnostic_principal and dossier.diagnostic_principal.source != "urgences" and dossier.document_type != "urgences": + # Alerte légère car le DP peut évoluer, mais le TIM doit vérifier + add("VETO-30", urg_cfg.get("severity", "LOW"), "diagnostic_principal", + "Patient admis via les Urgences : vérifier que le DP correspond bien au motif d'admission initial.", + citation=urg_cfg.get("atih_ref")) + + # VETO-31 : Cohérence Gravité / Mode de sortie + grav_rules = parcours_cfg.get("pathway_rules", {}).get("gravity_coherence", []) + mode_sortie = (dossier.sejour.mode_sortie or "").upper() + max_sev = 1 + if dossier.ghm_estimation: max_sev = dossier.ghm_estimation.severite + + for rule in grav_rules: + if any(m.upper() in mode_sortie for m in rule.get("if_mode_sortie", [])): + if max_sev < rule.get("require_min_severity", 2): + add("VETO-31", rule.get("severity", "MEDIUM"), "sejour", rule.get("message"), citation=rule.get("atih_ref")) # ------------------------------------------------- # Post-traitement : si un veto HARD existe pour un même 'where', @@ -481,9 +661,9 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport: if it.severity == "HARD": score -= 30 elif it.severity == "MEDIUM": - score -= 10 + score -= 15 else: - score -= 3 + score -= 5 score = max(0, min(100, score)) return VetoReport(verdict=verdict, score_contestabilite=score, issues=issues) diff --git a/src/viewer/app.py b/src/viewer/app.py index c012e8e..b43373f 100644 --- a/src/viewer/app.py +++ b/src/viewer/app.py @@ -385,9 +385,9 @@ def decision_badge(decision) -> Markup: labels = { "DOWNGRADE": ("Rétrogradé", "#fef3c7", "#92400e"), "REMOVE": ("Supprimé", "#fee2e2", "#dc2626"), - "RULED_OUT": ("Écarté", "#f1f5f9", "#64748b"), - "NEED_INFO": ("Info requise", "#fff7ed", "#c2410c"), - "PROMOTE_DP": ("Promu DP", "#dbeafe", "#1d4ed8"), + "RULED_OUT": ("Écarté (Contradiction)", "#f1f5f9", "#64748b"), + "NEED_INFO": ("Preuve manquante", "#fff7ed", "#c2410c"), + "PROMOTE_DP": ("Promu en DP", "#dbeafe", "#1d4ed8"), } label, bg, fg = labels.get(action, (action, "#f1f5f9", "#64748b")) return Markup(f'{label}') @@ -428,6 +428,30 @@ def format_cpam_text(text: str | None) -> Markup: # App factory # --------------------------------------------------------------------------- +def human_where(value: str | None) -> str: + """Rend une localisation technique lisible (ex: diagnostics_associes[0] -> DAS n°1).""" + if not value: + return "Global" + if value == "diagnostic_principal": + return "Diagnostic Principal" + if value == "diagnostics_associes": + return "Diagnostics Associés" + if value == "sejour": + return "Séjour" + + # Matching diagnostics_associes[i] + m = re.match(r"diagnostics_associes\[(\d+)\]", value) + if m: + return f"DAS n°{int(m.group(1)) + 1}" + + # Matching actes_ccam[i] + m = re.match(r"actes_ccam\[(\d+)\]", value) + if m: + return f"Acte n°{int(m.group(1)) + 1}" + + return value + + def create_app() -> Flask: app = Flask(__name__) @@ -440,6 +464,7 @@ def create_app() -> Flask: app.jinja_env.filters["format_doc_name"] = format_doc_name 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 ccam_dict = load_ccam_dict() diff --git a/src/viewer/templates/detail.html b/src/viewer/templates/detail.html index 7c7f6d4..9d8de63 100644 --- a/src/viewer/templates/detail.html +++ b/src/viewer/templates/detail.html @@ -49,6 +49,64 @@ {% 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 %} @@ -301,22 +359,6 @@ {% endif %} -{# ---- Alertes de codage ---- #} -{% if dossier.alertes_codage %} -
-

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

- -
-{% endif %} - {# ---- Contestabilité (VetoReport) ---- #} {% if dossier.veto_report %} {% set vr = dossier.veto_report %} @@ -328,36 +370,37 @@ {% set vr_color = '#ef4444' %} {% endif %}
-

Contestabilité du dossier

+

Contestabilité du dossier (Qualité PMSI)

{% if vr.verdict == 'PASS' %} - PASS + CONFORME {% elif vr.verdict == 'NEED_INFO' %} - NEED_INFO + À COMPLÉTER {% else %} - FAIL + NON CONFORME {% endif %}
- {{ vr.score_contestabilite }}/100 + Score : {{ vr.score_contestabilite }}/100
{% if vr.issues %}
- Problèmes détectés ({{ vr.issues|length }}) + Détail des anomalies détectées ({{ vr.issues|length }}) - + {% for issue in vr.issues %} - + + {% endfor %} @@ -506,7 +549,7 @@
preuves ({{ das.preuves_cliniques|length }})
    {% for p in das.preuves_cliniques %} -
  • [{{ p.type }}] {{ p.element }} → {{ p.interpretation }}
  • +
  • [{{ p.type }}] {{ p.element }} → {{ p.interpretation }}
  • {% endfor %}
diff --git a/tests/conftest.py b/tests/conftest.py index 325f575..e76af0d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -67,6 +67,24 @@ def dossier_complet() -> DossierMedical: ) +@pytest.fixture +def dossier_trackare_dp() -> DossierMedical: + """Dossier Trackare : DP pré-codé sans preuve RAG (source d'autorité).""" + return DossierMedical( + source_file="trackare-TEST.pdf", + document_type="trackare", + sejour=Sejour(sexe="F", age=72, duree_sejour=3), + diagnostic_principal=Diagnostic( + texte="Pancréatite aiguë biliaire", + cim10_suggestion="K85.1", + cim10_confidence="high", + source="trackare", + # Pas de source_excerpt, sources_rag, preuves_cliniques + # → c'est normal pour un Trackare + ), + ) + + @pytest.fixture def controle_cpam() -> ControleCPAM: """Contrôle CPAM de test avec codes contestés.""" diff --git a/tests/test_cpam_response.py b/tests/test_cpam_response.py index c6aa5cc..f3a88c2 100644 --- a/tests/test_cpam_response.py +++ b/tests/test_cpam_response.py @@ -19,10 +19,12 @@ from src.config import ( ) from src.control.cpam_response import ( _assess_dossier_strength, + _build_bio_confrontation, _build_bio_summary, _build_correction_prompt, _build_cpam_prompt, _build_tagged_context, + _BIO_THRESHOLDS, _check_das_bio_coherence, _extraction_pass, _format_response, @@ -138,14 +140,18 @@ class TestBuildPrompt: assert "CIM-10 FR 2026" in prompt assert "page 64" in prompt - def test_prompt_contains_three_axes(self): + def test_prompt_contains_tim_passes(self): + """Le prompt TIM contient les 5 passes de raisonnement.""" dossier = _make_dossier() controle = _make_controle() prompt, _ = _build_cpam_prompt(dossier, controle, []) - assert "AXE MÉDICAL" in prompt - assert "AXE ASYMÉTRIE D'INFORMATION" in prompt - assert "AXE RÉGLEMENTAIRE" in prompt + assert "PASSE 1" in prompt + assert "PASSE 2" in prompt + assert "PASSE 3" in prompt + assert "PASSE 4" in prompt + assert "PASSE 5" in prompt + assert "MÉMOIRE EN DÉFENSE" in prompt def test_prompt_contains_traitements_imagerie_when_present(self): dossier = _make_dossier_complet() @@ -181,39 +187,44 @@ class TestBuildPrompt: assert "ÉLÉMENTS DU DOSSIER NON TRANSMIS À LA CPAM" not in prompt - def test_prompt_json_format_new_fields(self): + def test_prompt_json_format_tim_fields(self): + """Le format JSON demandé inclut les champs TIM.""" dossier = _make_dossier() controle = _make_controle() prompt, _ = _build_cpam_prompt(dossier, controle, []) - assert "contre_arguments_medicaux" in prompt - assert "contre_arguments_asymetrie" in prompt - assert "contre_arguments_reglementaires" in prompt + assert "moyens_defense" in prompt + assert "confrontation_bio" in prompt + assert "conclusion_dispositive" in prompt + assert "codes_non_defendables" in prompt + assert "rappel_faits" in prompt - def test_prompt_contains_cite_exacts(self): - """Le prompt renforcé demande des preuves exactes.""" + def test_prompt_contains_honesty_rules(self): + """Le prompt TIM contient les règles d'honnêteté intellectuelle.""" dossier = _make_dossier() controle = _make_controle() prompt, _ = _build_cpam_prompt(dossier, controle, []) + assert "HONNÊTETÉ INTELLECTUELLE" in prompt assert "CITE" in prompt - assert "EXACTS" in prompt + assert "JAMAIS" in prompt - def test_prompt_contains_interdiction(self): - """Le prompt interdit les références inventées.""" + def test_prompt_contains_redaction_consignes(self): + """Le prompt TIM contient les consignes de rédaction numérotées.""" dossier = _make_dossier() controle = _make_controle() prompt, _ = _build_cpam_prompt(dossier, controle, []) - assert "INTERDICTION ABSOLUE" in prompt + assert "MOYENS DE DÉFENSE NUMÉROTÉS" in prompt + assert "N'invente AUCUN tag" in prompt - def test_prompt_contains_preuves_dossier_field(self): - """Le format JSON demandé inclut preuves_dossier.""" - dossier = _make_dossier() + def test_prompt_contains_bio_confrontation(self): + """Le prompt TIM inclut la section confrontation biologie.""" + dossier = _make_dossier_complet() controle = _make_controle() prompt, _ = _build_cpam_prompt(dossier, controle, []) - assert "preuves_dossier" in prompt + assert "CONFRONTATION BIOLOGIE" in prompt @patch("src.control.cpam_context.validate_code", return_value=(True, "Iléus paralytique et obstruction intestinale")) @patch("src.control.cpam_context.normalize_code", return_value="K56.0") @@ -365,6 +376,96 @@ class TestFormatResponse: assert "AVERTISSEMENT" in text assert "Manuel Imaginaire 2025" in text + # --- Tests nouveau format TIM --- + + def test_tim_format_memoire_defense(self): + """Le format TIM produit un mémoire en défense structuré.""" + parsed = { + "objet": "Contestation DAS — OGC 17 — Mémoire en défense", + "rappel_faits": "Patient M, 65 ans, hospitalisé 5j pour cholécystite aiguë.", + "moyens_defense": [ + { + "numero": 1, + "titre": "Le DP K81.0 est justifié par la biologie", + "argument": "CRP à 180 mg/L confirme l'inflammation aiguë.", + "preuves": [ + {"ref": "[BIO-1]", "fait": "CRP = 180 mg/L [norme < 5]", "signification": "inflammation sévère"} + ], + "source_reglementaire": "[Guide Méthodologique MCO 2026 - p.45] citation", + } + ], + "confrontation_bio": [ + {"diagnostic": "K81.0", "test": "CRP", "valeur": 180, "seuil": "> 5", "verdict": "CONFIRMÉ"} + ], + "asymetrie_information": "Bio non transmise à l'UCR", + "reponse_points_cpam": "La CPAM a raison sur X, mais...", + "codes_non_defendables": [], + "references": [ + {"document": "Guide Méthodologique MCO 2026", "page": "45", "citation": "Le DAS doit..."} + ], + "conclusion_dispositive": "Par conséquent, nous demandons le MAINTIEN du codage.", + } + text = _format_response(parsed) + + assert "MÉMOIRE EN DÉFENSE" in text + assert "RAPPEL DES FAITS" in text + assert "MOYEN N°1" in text + assert "K81.0" in text + assert "Preuve" in text + assert "Source" in text + assert "CONFRONTATION BIOLOGIE" in text + assert "CONFIRMÉ" in text + assert "ASYMÉTRIE D'INFORMATION" in text + assert "RÉPONSE AUX POINTS DE LA CPAM" in text + assert "RÉFÉRENCES RÉGLEMENTAIRES" in text + assert "CONCLUSION" in text + assert "MAINTIEN" in text + + def test_tim_format_codes_non_defendables(self): + """Les codes non défendables apparaissent dans le format TIM.""" + parsed = { + "moyens_defense": [], + "codes_non_defendables": [ + {"code": "D50.9", "raison": "Hb = 13.5, valeur NORMALE", "recommandation": "Retrait recommandé"} + ], + "conclusion_dispositive": "Nous reconnaissons...", + } + text = _format_response(parsed) + + assert "CODES NON DÉFENDABLES" in text + assert "D50.9" in text + assert "Retrait recommandé" in text + + def test_tim_format_confrontation_table(self): + """Le tableau de confrontation bio est formaté en grille.""" + parsed = { + "moyens_defense": [], + "confrontation_bio": [ + {"diagnostic": "N17.8 IRA", "test": "Créatinine", "valeur": 280, "seuil": "> 130", "verdict": "CONFIRMÉ"}, + {"diagnostic": "E87.1 HypoNa", "test": "Sodium", "valeur": 138, "seuil": "< 135", "verdict": "NON CONFIRMÉ"}, + ], + "conclusion_dispositive": "Conclusion...", + } + text = _format_response(parsed) + + assert "N17.8" in text + assert "Créatinine" in text + assert "CONFIRMÉ" in text + assert "NON CONFIRMÉ" in text + assert "┌" in text # table border + + def test_tim_retrocompat_legacy_format(self): + """L'ancien format (sans moyens_defense) utilise le rendu legacy.""" + parsed = { + "analyse_contestation": "Analyse...", + "contre_arguments_medicaux": "Arguments médicaux...", + "conclusion": "Conclusion...", + } + text = _format_response(parsed) + + assert "CONTRE-ARGUMENTS MÉDICAUX" in text + assert "MÉMOIRE EN DÉFENSE" not in text + class TestValidateReferences: def test_valid_reference_no_warning(self): @@ -1061,6 +1162,81 @@ class TestCheckDasBioCoherence: assert "NORMAL" in prompt +class TestBioConfrontation: + """Tests pour la confrontation biologie/diagnostic TIM.""" + + def test_confrontation_with_matching_bio(self): + """Code avec bio disponible et pathologique → CONFIRMÉ.""" + dossier = DossierMedical( + source_file="test.pdf", + sejour=Sejour(sexe="M", age=65), + diagnostic_principal=Diagnostic(texte="IRA", cim10_suggestion="N17.8"), + biologie_cle=[ + BiologieCle(test="Créatinine", valeur="280 µmol/L", anomalie=True), + ], + ) + controle = ControleCPAM( + numero_ogc=1, titre="Test", arg_ucr="Test", + decision_ucr="Rejet", dp_ucr=None, da_ucr=None, + ) + result = _build_bio_confrontation(dossier, controle) + + assert "N17" in result + assert "Créatinine" in result + assert "280" in result + assert "CONFIRMÉ" in result + + def test_confrontation_normal_value(self): + """Code avec bio NORMALE → NON CONFIRMÉ.""" + dossier = DossierMedical( + source_file="test.pdf", + sejour=Sejour(sexe="F", age=70), + diagnostic_principal=Diagnostic(texte="Hyponatrémie", cim10_suggestion="E87.1"), + biologie_cle=[ + BiologieCle(test="Sodium", valeur="138 mmol/L", anomalie=False), + ], + ) + controle = ControleCPAM( + numero_ogc=1, titre="Test", arg_ucr="Test", + decision_ucr="Rejet", dp_ucr=None, da_ucr=None, + ) + result = _build_bio_confrontation(dossier, controle) + + assert "E87.1" in result + assert "NON CONFIRMÉ" in result + + def test_confrontation_missing_bio(self): + """Code avec bio absente → NON DISPONIBLE.""" + dossier = DossierMedical( + source_file="test.pdf", + sejour=Sejour(sexe="M", age=50), + diagnostic_principal=Diagnostic(texte="IRA", cim10_suggestion="N17.8"), + biologie_cle=[], + ) + controle = ControleCPAM( + numero_ogc=1, titre="Test", arg_ucr="Test", + decision_ucr="Rejet", dp_ucr=None, da_ucr=None, + ) + result = _build_bio_confrontation(dossier, controle) + + assert "NON DISPONIBLE" in result + + def test_confrontation_no_threshold(self): + """Code sans seuil dans _BIO_THRESHOLDS → message par défaut.""" + dossier = DossierMedical( + source_file="test.pdf", + sejour=Sejour(), + diagnostic_principal=Diagnostic(texte="Fracture", cim10_suggestion="S72.0"), + ) + controle = ControleCPAM( + numero_ogc=1, titre="Test", arg_ucr="Test", + decision_ucr="Rejet", dp_ucr=None, da_ucr=None, + ) + result = _build_bio_confrontation(dossier, controle) + + assert "Aucun seuil" in result + + class TestPatientContext: """Tests pour le contexte patient dans le prompt.""" @@ -1103,14 +1279,14 @@ class TestPatientContext: assert "ADMISSION EN URGENCE" in prompt def test_context_consigne_in_prompt(self): - """Le prompt contient une consigne sur le contexte clinique.""" + """Le prompt TIM contient les consignes sur le contexte patient.""" dossier = _make_dossier() controle = _make_controle() prompt, _ = _build_cpam_prompt(dossier, controle, []) - assert "CONTEXTE CLINIQUE" in prompt - assert "ÂGE" in prompt - assert "MODE D'ENTRÉE" in prompt + assert "CONTEXTE ADMINISTRATIF" in prompt + assert "pédiatrie" in prompt.lower() or "Pédiatrie" in prompt + assert "urgence" in prompt.lower() class TestExtractionPass:
VetoSévéritéLocalisationMessage
Code RègleSévéritéLocalisationMessage d'alerteRéférence ATIH
{{ issue.veto }} - {% if issue.severity == 'HARD' %}HARD - {% elif issue.severity == 'MEDIUM' %}MEDIUM - {% else %}LOW{% endif %} + {% if issue.severity == 'HARD' %}Bloquant + {% elif issue.severity == 'MEDIUM' %}À vérifier + {% else %}Optimisation{% endif %} {{ issue.where }}{{ issue.where|human_where }} {{ issue.message }}{{ issue.citation or '—' }}