diff --git a/src/config.py b/src/config.py index 0209586..d650eab 100644 --- a/src/config.py +++ b/src/config.py @@ -92,6 +92,12 @@ class Sejour(BaseModel): taille: Optional[float] = None +class PreuveClinique(BaseModel): + type: str # "biologie" | "imagerie" | "traitement" | "acte" | "clinique" + element: str # "CRP 180 mg/L" + interpretation: str # "syndrome inflammatoire majeur" + + class Diagnostic(BaseModel): texte: str cim10_suggestion: Optional[str] = None @@ -99,6 +105,7 @@ class Diagnostic(BaseModel): justification: Optional[str] = None raisonnement: Optional[str] = None sources_rag: list[RAGSource] = Field(default_factory=list) + preuves_cliniques: list[PreuveClinique] = Field(default_factory=list) est_cma: Optional[bool] = None est_cms: Optional[bool] = None niveau_severite: Optional[str] = None # "leger" | "modere" | "severe" | "non_evalue" diff --git a/src/medical/cim10_extractor.py b/src/medical/cim10_extractor.py index 9facca8..6cc5a27 100644 --- a/src/medical/cim10_extractor.py +++ b/src/medical/cim10_extractor.py @@ -150,6 +150,10 @@ def extract_medical_info( # Post-processing : retirer DAS dont le code est identique au DP _remove_das_equal_dp(dossier) + # Post-processing : validation justifications (QC batch) + if use_rag: + _validate_justifications(dossier) + # Post-processing : traçabilité source (page + extrait) if page_tracker: _apply_source_tracking(dossier, page_tracker, search_text) @@ -1019,3 +1023,110 @@ def _apply_source_tracking(dossier: DossierMedical, page_tracker, search_text: s if tracked: logger.info(" Traçabilité source : %d/%d diagnostics localisés", tracked, len(all_diags)) + + +def _validate_justifications(dossier: DossierMedical) -> None: + """Validation croisée de tous les diagnostics via un appel LLM unique. + + Vérifie la cohérence, les preuves cliniques et la spécificité des codes. + Ajuste la confiance si la justification est faible et ajoute des alertes QC. + """ + try: + from .ollama_client import call_ollama + from .clinical_context import build_enriched_context, format_enriched_context + except ImportError: + logger.warning("Module clinical_context non disponible pour la validation QC") + return + + all_diags: list[tuple[str, Diagnostic]] = [] + if dossier.diagnostic_principal: + all_diags.append(("DP", dossier.diagnostic_principal)) + for das in dossier.diagnostics_associes: + all_diags.append(("DAS", das)) + + if not all_diags: + return + + # Construire le résumé des codes à valider + codes_section = "" + for i, (type_diag, diag) in enumerate(all_diags, 1): + code = diag.cim10_suggestion or "?" + justif = (diag.justification or "")[:150] + preuves = ", ".join(p.element for p in diag.preuves_cliniques[:3]) or "aucune" + codes_section += f"{i}. [{type_diag}] {code} — {diag.texte}\n" + codes_section += f" Justification: {justif}\n" + codes_section += f" Preuves: {preuves}\n\n" + + ctx = build_enriched_context(dossier) + ctx_str = format_enriched_context(ctx) + + prompt = f"""Tu es un médecin DIM contrôleur qualité PMSI. +Vérifie la cohérence et la justification de ce codage complet. + +DOSSIER CLINIQUE : +{ctx_str} + +CODAGE À VALIDER : +{codes_section} + +Pour CHAQUE code, vérifie : +1. Existe-t-il une preuve clinique concrète dans le dossier ? +2. Le code est-il le plus spécifique possible ? +3. Y a-t-il des conflits ou redondances avec d'autres codes ? + +Réponds avec un JSON : +{{ + "validations": [ + {{ + "numero": 1, + "code": "X99.9", + "verdict": "maintenir|reclasser|supprimer", + "confidence_recommandee": "high|medium|low", + "commentaire": "explication courte" + }} + ], + "alertes_globales": ["..."] +}}""" + + try: + result = call_ollama(prompt, temperature=0.1, max_tokens=2500) + except Exception: + logger.warning("Erreur lors de l'appel Ollama pour validation QC", exc_info=True) + return + + if result is None: + return + + # Appliquer les ajustements + validations = result.get("validations", []) + for v in validations: + if not isinstance(v, dict): + continue + num = v.get("numero") + if not isinstance(num, int) or num < 1 or num > len(all_diags): + continue + type_diag, diag = all_diags[num - 1] + conf = v.get("confidence_recommandee") + verdict = v.get("verdict") + commentaire = v.get("commentaire", "") + + if conf in ("high", "medium", "low") and conf != diag.cim10_confidence: + old = diag.cim10_confidence + diag.cim10_confidence = conf + if old and conf != old: + dossier.alertes_codage.append( + f"QC: {type_diag} {diag.cim10_suggestion} confiance {old}\u2192{conf} \u2014 {commentaire}" + ) + + if verdict == "supprimer" and type_diag == "DAS": + dossier.alertes_codage.append( + f"QC: DAS {diag.cim10_suggestion} ({diag.texte}) à reconsidérer \u2014 {commentaire}" + ) + + alertes_globales = result.get("alertes_globales", []) + for a in alertes_globales: + if isinstance(a, str) and a.strip(): + dossier.alertes_codage.append(f"QC: {a}") + + logger.info(" QC batch : %d validations, %d alertes globales", + len(validations), len(alertes_globales)) diff --git a/src/medical/clinical_context.py b/src/medical/clinical_context.py new file mode 100644 index 0000000..5758cfa --- /dev/null +++ b/src/medical/clinical_context.py @@ -0,0 +1,315 @@ +"""Enrichissement du contexte clinique pour les prompts LLM. + +Interprète les données brutes (biologie, traitements, séjour) en informations +cliniques structurées pour améliorer la qualité du codage CIM-10. +""" + +from __future__ import annotations + +from ..config import DossierMedical +from .cim10_extractor import BIO_NORMALS + +# Seuils d'interprétation biologique (test → liste de (seuil, direction, interprétation)) +# Ordre décroissant : le premier seuil franchi donne l'interprétation +BIO_INTERPRETATIONS: dict[str, list[tuple[float, str, str]]] = { + "CRP": [ + (100, "high", "syndrome inflammatoire majeur"), + (20, "high", "syndrome inflammatoire modéré"), + (5, "high", "syndrome inflammatoire mineur"), + ], + "Lipasémie": [ + (180, "high", "pancréatite biologique (>3N)"), + (60, "high", "élévation modérée de la lipase"), + ], + "ASAT": [ + (200, "high", "cytolyse hépatique majeure (>5N)"), + (80, "high", "cytolyse hépatique modérée (>2N)"), + ], + "ALAT": [ + (200, "high", "cytolyse hépatique majeure (>5N)"), + (80, "high", "cytolyse hépatique modérée (>2N)"), + ], + "Bilirubine totale": [ + (50, "high", "ictère franc"), + (17, "high", "hyperbilirubinémie modérée"), + ], + "Hémoglobine": [ + (7, "low", "anémie sévère (transfusion probable)"), + (10, "low", "anémie modérée"), + ], + "Créatinine": [ + (300, "high", "insuffisance rénale sévère"), + (150, "high", "insuffisance rénale modérée"), + ], + "Plaquettes": [ + (50, "low", "thrombopénie sévère"), + (100, "low", "thrombopénie modérée"), + ], + "Leucocytes": [ + (20, "high", "hyperleucocytose majeure (infection, inflammation)"), + (2, "low", "leucopénie sévère (aplasie, immunodépression)"), + ], +} + +# Médicaments → condition implicite (clé en lowercase) +TREATMENT_INDICATORS: dict[str, str] = { + "insuline": "diabète insulino-traité", + "metformine": "diabète type 2", + "héparine": "anticoagulation (risque thromboembolique)", + "enoxaparine": "anticoagulation (HBPM)", + "lovenox": "anticoagulation (HBPM)", + "warfarine": "anticoagulation au long cours (AVK)", + "fluindione": "anticoagulation au long cours (AVK)", + "amoxicilline": "antibiothérapie", + "ceftriaxone": "antibiothérapie IV", + "tazocilline": "antibiothérapie large spectre IV", + "morphine": "analgésie palier 3 (douleur sévère)", + "oxycodone": "analgésie palier 3 (douleur sévère)", + "oxygène": "oxygénothérapie (insuffisance respiratoire)", + "furosémide": "insuffisance cardiaque / rétention hydrique", + "lasilix": "insuffisance cardiaque / rétention hydrique", +} + + +def interpret_bio_value(test: str, value_str: str, is_abnormal: bool | None) -> str | None: + """Retourne l'interprétation clinique d'une valeur bio, ou None si normale.""" + if test not in BIO_INTERPRETATIONS: + return None + + try: + val = float(value_str.replace(",", ".").replace(" ", "")) + except (ValueError, AttributeError): + return None + + # Si la valeur est normale (pas anormale), pas d'interprétation + if is_abnormal is False: + return None + + thresholds = BIO_INTERPRETATIONS[test] + for seuil, direction, interpretation in thresholds: + if direction == "high" and val >= seuil: + return interpretation + if direction == "low" and val <= seuil: + return interpretation + + return None + + +def detect_treatment_indicators(traitements: list) -> list[dict]: + """Retourne les conditions implicites détectées via les traitements. + + Args: + traitements: Liste d'objets Traitement ou de dicts avec clé 'medicament'. + + Returns: + Liste de dicts {medicament, condition}. + """ + results = [] + seen_conditions: set[str] = set() + + for t in traitements: + med = t.medicament if hasattr(t, "medicament") else t.get("medicament", "") + med_lower = med.lower().strip() + + for keyword, condition in TREATMENT_INDICATORS.items(): + if keyword in med_lower and condition not in seen_conditions: + results.append({"medicament": med, "condition": condition}) + seen_conditions.add(condition) + break + + return results + + +def detect_severity_markers(dossier: DossierMedical) -> list[str]: + """Détecte les marqueurs de sévérité globaux.""" + markers = [] + + duree = dossier.sejour.duree_sejour + if duree is not None: + if duree > 14: + markers.append(f"séjour prolongé ({duree} jours)") + elif duree > 7: + markers.append(f"séjour >7 jours ({duree} jours)") + + age = dossier.sejour.age + if age is not None: + if age >= 80: + markers.append(f"patient très âgé ({age} ans)") + elif age >= 70: + markers.append(f"patient âgé ({age} ans)") + + imc = dossier.sejour.imc + if imc is not None: + if imc >= 40: + markers.append(f"obésité morbide (IMC {imc})") + elif imc >= 30: + markers.append(f"obésité (IMC {imc})") + + if dossier.complications: + markers.append(f"{len(dossier.complications)} complication(s)") + + return markers + + +def build_enriched_context(dossier: DossierMedical) -> dict: + """Construit le contexte clinique enrichi (appel unique par dossier). + + Returns: + Dict avec les clés : patient, duree_sejour, antecedents, + biologie (avec interprétations), imagerie, complications, + dp_texte, das_codes_existants, interpretations_bio, + conditions_traitements, marqueurs_severite. + """ + # Données de base (compatibles avec l'ancien format) + ctx: dict = { + "sexe": dossier.sejour.sexe, + "age": dossier.sejour.age, + "duree_sejour": dossier.sejour.duree_sejour, + "imc": dossier.sejour.imc, + "antecedents": dossier.antecedents[:5], + "biologie_cle": [(b.test, b.valeur, b.anomalie) for b in dossier.biologie_cle], + "imagerie": [(i.type, (i.conclusion or "")[:200]) for i in dossier.imagerie], + "complications": dossier.complications, + } + + # Interprétations biologiques + interpretations = [] + for b in dossier.biologie_cle: + interp = interpret_bio_value(b.test, b.valeur or "", b.anomalie) + if interp: + # Ajouter l'unité si connue + unit = "" + if b.test in ("CRP",): + unit = " mg/L" + elif b.test in ("Lipasémie", "ASAT", "ALAT", "GGT", "PAL"): + unit = " UI/L" + elif b.test in ("Bilirubine totale", "Créatinine"): + unit = " µmol/L" + elif b.test in ("Hémoglobine",): + unit = " g/dL" + elif b.test in ("Plaquettes", "Leucocytes"): + unit = " G/L" + interpretations.append({ + "test": b.test, + "valeur": f"{b.valeur}{unit}", + "interpretation": interp, + }) + ctx["interpretations_bio"] = interpretations + + # Conditions implicites via traitements + ctx["conditions_traitements"] = detect_treatment_indicators(dossier.traitements_sortie) + + # Marqueurs de sévérité + ctx["marqueurs_severite"] = detect_severity_markers(dossier) + + return ctx + + +def format_enriched_context(context: dict) -> str: + """Formate le contexte enrichi en texte structuré pour le prompt. + + Inclut les mêmes sections que l'ancien _format_contexte() PLUS : + interprétations bio, conditions implicites traitements, marqueurs sévérité. + """ + lines = [] + + # Patient + sexe = context.get("sexe") + age = context.get("age") + imc = context.get("imc") + patient_parts = [] + if sexe: + patient_parts.append(sexe) + if age: + patient_parts.append(f"{age} ans") + if imc: + patient_parts.append(f"IMC {imc}") + if patient_parts: + lines.append(f"- Patient : {', '.join(str(p) for p in patient_parts)}") + + # Durée de séjour + duree = context.get("duree_sejour") + if duree: + lines.append(f"- Durée séjour : {duree} jours") + + # Antécédents + antecedents = context.get("antecedents") + if antecedents: + lines.append(f"- Antécédents : {', '.join(antecedents[:5])}") + + # Biologie (avec normes) + biologie = context.get("biologie_cle") + if biologie: + bio_parts = [] + for b in biologie: + test, valeur, anomalie = ( + b if isinstance(b, (list, tuple)) + else (b.get("test"), b.get("valeur"), b.get("anomalie")) + ) + norme_str = "" + if test in BIO_NORMALS: + lo, hi = BIO_NORMALS[test] + lo_s = int(lo) if lo == int(lo) else lo + hi_s = int(hi) if hi == int(hi) else hi + norme_str = f" [N: {lo_s}-{hi_s}]" + marker = " (\u2191)" if anomalie else "" + bio_parts.append(f"{test} {valeur}{norme_str}{marker}") + lines.append(f"- Biologie : {', '.join(bio_parts)}") + + # Imagerie + imagerie = context.get("imagerie") + if imagerie: + for img in imagerie: + img_type, conclusion = ( + img if isinstance(img, (list, tuple)) + else (img.get("type"), img.get("conclusion")) + ) + if conclusion: + lines.append(f"- Imagerie : {img_type} — {conclusion[:200]}") + + # Complications + complications = context.get("complications") + if complications: + lines.append(f"- Complications : {', '.join(complications)}") + + # DP du séjour + dp_texte = context.get("dp_texte") + if dp_texte: + lines.append(f"- DP du séjour : {dp_texte}") + + # DAS déjà codés + das_codes = context.get("das_codes_existants") + if das_codes: + lines.append(f"- DAS déjà codés : {', '.join(das_codes)}") + + # --- Sections enrichies --- + + # Interprétations biologiques + interpretations = context.get("interpretations_bio", []) + if interpretations: + interp_parts = [ + f"{i['test']} {i['valeur']} \u2192 {i['interpretation']}" + for i in interpretations + ] + lines.append(f"\nINTERPRÉTATION CLINIQUE :") + lines.append(f"- Biologie : {' ; '.join(interp_parts)}") + + # Conditions implicites via traitements + conditions = context.get("conditions_traitements", []) + if conditions: + cond_parts = [ + f"{c['medicament']} \u2192 {c['condition']}" + for c in conditions + ] + if not interpretations: + lines.append(f"\nINTERPRÉTATION CLINIQUE :") + lines.append(f"- Traitements indicatifs : {' ; '.join(cond_parts)}") + + # Marqueurs de sévérité + marqueurs = context.get("marqueurs_severite", []) + if marqueurs: + if not interpretations and not conditions: + lines.append(f"\nINTERPRÉTATION CLINIQUE :") + lines.append(f"- Marqueurs de sévérité : {', '.join(marqueurs)}") + + return "\n".join(lines) if lines else "Non précisé" diff --git a/src/medical/rag_search.py b/src/medical/rag_search.py index ff9cc61..2c405c9 100644 --- a/src/medical/rag_search.py +++ b/src/medical/rag_search.py @@ -6,12 +6,13 @@ import logging from concurrent.futures import ThreadPoolExecutor, as_completed from ..config import ( - ActeCCAM, Diagnostic, DossierMedical, RAGSource, + ActeCCAM, Diagnostic, DossierMedical, PreuveClinique, RAGSource, OLLAMA_CACHE_PATH, OLLAMA_MAX_PARALLEL, OLLAMA_MODEL, EMBEDDING_MODEL, RERANKER_MODEL, ) from .cim10_dict import normalize_code, validate_code as cim10_validate, fallback_parent_code from .cim10_extractor import BIO_NORMALS +from .clinical_context import build_enriched_context, format_enriched_context from .ccam_dict import validate_code as ccam_validate from .ollama_client import call_ollama, parse_json_response from .ollama_cache import OllamaCache @@ -347,7 +348,7 @@ def _build_prompt(texte: str, sources: list[dict], contexte: dict, est_dp: bool sources_text += (src.get("extrait", "")[:800]) + "\n\n" type_diag = "DP (diagnostic principal)" if est_dp else "DAS (diagnostic associé significatif)" - ctx_str = _format_contexte(contexte) + ctx_str = format_enriched_context(contexte) return f"""Tu es un médecin DIM (Département d'Information Médicale) expert en codage PMSI. Tu dois coder le diagnostic suivant en respectant STRICTEMENT les règles de l'ATIH. @@ -377,7 +378,10 @@ Réponds UNIQUEMENT avec un objet JSON au format suivant, sans aucun texte avant "regle_pmsi": "conformité aux règles PMSI pour un {type_diag} (guide méthodologique)", "code": "X99.9", "confidence": "high ou medium ou low", - "justification": "explication courte en français" + "justification": "explication courte en français", + "preuves_cliniques": [ + {{"type": "biologie|imagerie|traitement|acte|clinique", "element": "élément concret du dossier", "interpretation": "signification clinique justifiant le code"}} + ] }}""" @@ -398,7 +402,7 @@ def _build_prompt_ccam(texte: str, sources: list[dict], contexte: dict) -> str: sources_text += f"--- Source {i}: {doc_name}{code_info}{page_info} ---\n" sources_text += (src.get("extrait", "")[:800]) + "\n\n" - ctx_str = _format_contexte(contexte) + ctx_str = format_enriched_context(contexte) return f"""Tu es un médecin DIM (Département d'Information Médicale) expert en codage CCAM PMSI. Tu dois coder l'acte chirurgical/médical suivant en respectant STRICTEMENT la nomenclature CCAM. @@ -498,6 +502,20 @@ def _apply_llm_result_diagnostic(diagnostic: Diagnostic, llm_result: dict) -> No if raisonnement: diagnostic.raisonnement = raisonnement + # Stocker les preuves cliniques + preuves = llm_result.get("preuves_cliniques", []) + if preuves and isinstance(preuves, list): + for p in preuves: + if isinstance(p, dict) and p.get("element"): + try: + diagnostic.preuves_cliniques.append(PreuveClinique( + type=p.get("type", "clinique"), + element=p["element"], + interpretation=p.get("interpretation", ""), + )) + except Exception: + pass + def enrich_diagnostic( diagnostic: Diagnostic, @@ -621,7 +639,7 @@ def enrich_acte(acte: ActeCCAM, contexte: dict, cache: OllamaCache | None = None def _build_prompt_das_extraction(text: str, contexte: dict, existing_das: list[str], dp_texte: str) -> str: """Construit le prompt pour l'extraction LLM de DAS supplémentaires.""" - ctx_str = _format_contexte(contexte) + ctx_str = format_enriched_context(contexte) existing_str = "\n".join(f"- {d}" for d in existing_das) if existing_das else "Aucun" return f"""Tu es un médecin DIM (Département d'Information Médicale) expert en codage PMSI. @@ -723,16 +741,7 @@ def enrich_dossier(dossier: DossierMedical) -> None: """ cache = OllamaCache(OLLAMA_CACHE_PATH, OLLAMA_MODEL) - contexte = { - "sexe": dossier.sejour.sexe, - "age": dossier.sejour.age, - "duree_sejour": dossier.sejour.duree_sejour, - "imc": dossier.sejour.imc, - "antecedents": dossier.antecedents[:5], - "biologie_cle": [(b.test, b.valeur, b.anomalie) for b in dossier.biologie_cle], - "imagerie": [(i.type, (i.conclusion or "")[:200]) for i in dossier.imagerie], - "complications": dossier.complications, - } + contexte = build_enriched_context(dossier) # Phase 1 : DP seul (le contexte DAS en dépend) if dossier.diagnostic_principal: diff --git a/src/viewer/templates/detail.html b/src/viewer/templates/detail.html index 713846d..cbbff14 100644 --- a/src/viewer/templates/detail.html +++ b/src/viewer/templates/detail.html @@ -223,6 +223,16 @@ {% if dp.justification %}