diff --git a/data/cim10_supplements.json b/data/cim10_supplements.json new file mode 100644 index 0000000..8aec657 --- /dev/null +++ b/data/cim10_supplements.json @@ -0,0 +1,42 @@ +{ + "E10.0": "Diabète de type 1, avec coma", + "E10.1": "Diabète de type 1, avec acidocétose", + "E10.2": "Diabète de type 1, avec complications rénales", + "E10.3": "Diabète de type 1, avec complications ophtalmologiques", + "E10.4": "Diabète de type 1, avec complications neurologiques", + "E10.5": "Diabète de type 1, avec complications vasculaires périphériques", + "E10.6": "Diabète de type 1, avec autres complications précisées", + "E10.7": "Diabète de type 1, avec complications multiples", + "E10.8": "Diabète de type 1, avec complications non précisées", + "E10.9": "Diabète de type 1, sans complication", + "E11.0": "Diabète de type 2, avec coma", + "E11.1": "Diabète de type 2, avec acidocétose", + "E11.2": "Diabète de type 2, avec complications rénales", + "E11.3": "Diabète de type 2, avec complications ophtalmologiques", + "E11.4": "Diabète de type 2, avec complications neurologiques", + "E11.5": "Diabète de type 2, avec complications vasculaires périphériques", + "E11.6": "Diabète de type 2, avec autres complications précisées", + "E11.7": "Diabète de type 2, avec complications multiples", + "E11.8": "Diabète de type 2, avec complications non précisées", + "E11.9": "Diabète de type 2, sans complication", + "E13.0": "Autres diabètes sucrés précisés, avec coma", + "E13.1": "Autres diabètes sucrés précisés, avec acidocétose", + "E13.2": "Autres diabètes sucrés précisés, avec complications rénales", + "E13.3": "Autres diabètes sucrés précisés, avec complications ophtalmologiques", + "E13.4": "Autres diabètes sucrés précisés, avec complications neurologiques", + "E13.5": "Autres diabètes sucrés précisés, avec complications vasculaires périphériques", + "E13.6": "Autres diabètes sucrés précisés, avec autres complications précisées", + "E13.7": "Autres diabètes sucrés précisés, avec complications multiples", + "E13.8": "Autres diabètes sucrés précisés, avec complications non précisées", + "E13.9": "Autres diabètes sucrés précisés, sans complication", + "F10.0": "Troubles mentaux et du comportement liés à l'utilisation d'alcool, intoxication aiguë", + "F10.1": "Troubles mentaux et du comportement liés à l'utilisation d'alcool, utilisation nocive pour la santé", + "F10.2": "Troubles mentaux et du comportement liés à l'utilisation d'alcool, syndrome de dépendance", + "F10.3": "Troubles mentaux et du comportement liés à l'utilisation d'alcool, syndrome de sevrage", + "F10.4": "Troubles mentaux et du comportement liés à l'utilisation d'alcool, syndrome de sevrage avec delirium", + "F10.5": "Troubles mentaux et du comportement liés à l'utilisation d'alcool, trouble psychotique", + "F10.6": "Troubles mentaux et du comportement liés à l'utilisation d'alcool, syndrome amnésique", + "F10.7": "Troubles mentaux et du comportement liés à l'utilisation d'alcool, trouble résiduel ou psychotique de survenue tardive", + "F10.8": "Troubles mentaux et du comportement liés à l'utilisation d'alcool, autres troubles mentaux et du comportement", + "F10.9": "Troubles mentaux et du comportement liés à l'utilisation d'alcool, trouble mental ou du comportement, sans précision" +} diff --git a/src/config.py b/src/config.py index 44afe9d..615e238 100644 --- a/src/config.py +++ b/src/config.py @@ -50,6 +50,7 @@ REFERENTIELS_DIR = BASE_DIR / "data" / "referentiels" UPLOAD_MAX_SIZE_MB = 50 ALLOWED_EXTENSIONS = {".pdf", ".csv", ".xlsx", ".xls", ".txt"} CIM10_DICT_PATH = BASE_DIR / "data" / "cim10_dict.json" +CIM10_SUPPLEMENTS_PATH = BASE_DIR / "data" / "cim10_supplements.json" CCAM_DICT_PATH = BASE_DIR / "data" / "ccam_dict.json" CIM10_PDF = Path("/home/dom/ai/aivanov_CIM/cim-10-fr_2026_a_usage_pmsi_version_provisoire_111225.pdf") GUIDE_METHODO_PDF = Path("/home/dom/ai/aivanov_CIM/guide_methodo_mco_2026_version_provisoire.pdf") diff --git a/src/medical/cim10_dict.py b/src/medical/cim10_dict.py index a58ec0f..33d15ec 100644 --- a/src/medical/cim10_dict.py +++ b/src/medical/cim10_dict.py @@ -13,7 +13,7 @@ import unicodedata from pathlib import Path from typing import Optional -from ..config import CIM10_DICT_PATH, RAG_INDEX_DIR +from ..config import CIM10_DICT_PATH, CIM10_SUPPLEMENTS_PATH, RAG_INDEX_DIR logger = logging.getLogger(__name__) @@ -90,6 +90,7 @@ def load_dict() -> dict[str, str]: """Charge le dictionnaire CIM-10 (singleton lazy-loaded). Si le fichier JSON n'existe pas, tente de le construire depuis metadata.json. + Fusionne ensuite les suppléments (sous-codes manquants) sans écraser le dict principal. """ global _dict_cache if _dict_cache is not None: @@ -102,6 +103,18 @@ def load_dict() -> dict[str, str]: logger.info("Dictionnaire CIM-10 absent, construction depuis metadata.json...") _dict_cache = build_dict() + # Fusionner les suppléments (ne remplace pas les entrées existantes) + if CIM10_SUPPLEMENTS_PATH.exists(): + with open(CIM10_SUPPLEMENTS_PATH, encoding="utf-8") as f: + supplements = json.load(f) + added = 0 + for code, label in supplements.items(): + if code not in _dict_cache: + _dict_cache[code] = label + added += 1 + if added: + logger.info("Suppléments CIM-10 : %d codes ajoutés depuis %s", added, CIM10_SUPPLEMENTS_PATH.name) + return _dict_cache diff --git a/src/medical/cim10_extractor.py b/src/medical/cim10_extractor.py index b45f37b..7c48495 100644 --- a/src/medical/cim10_extractor.py +++ b/src/medical/cim10_extractor.py @@ -873,6 +873,23 @@ def _lookup_cim10(text: str) -> str | None: return dict_lookup(text, domain_overrides=CIM10_MAP) +# Plages de référence biologiques (min, max) — utilisées par _is_abnormal() +# et exportées pour le formatage du contexte LLM dans rag_search.py +BIO_NORMALS: dict[str, tuple[float, float]] = { + "Lipasémie": (0, 60), + "CRP": (0, 5), + "ASAT": (0, 40), + "ALAT": (0, 40), + "GGT": (0, 60), + "PAL": (0, 150), + "Bilirubine totale": (0, 17), + "Hémoglobine": (12, 17), + "Plaquettes": (150, 400), + "Leucocytes": (4, 10), + "Créatinine": (50, 120), +} + + def _is_abnormal(test: str, value: str) -> bool | None: """Détermine si un résultat biologique est anormal.""" try: @@ -884,21 +901,7 @@ def _is_abnormal(test: str, value: str) -> bool | None: return True return None - normals: dict[str, tuple[float, float]] = { - "Lipasémie": (0, 60), - "CRP": (0, 5), - "ASAT": (0, 40), - "ALAT": (0, 40), - "GGT": (0, 60), - "PAL": (0, 150), - "Bilirubine totale": (0, 17), - "Hémoglobine": (12, 17), - "Plaquettes": (150, 400), - "Leucocytes": (4, 10), - "Créatinine": (50, 120), - } - - if test in normals: - lo, hi = normals[test] + if test in BIO_NORMALS: + lo, hi = BIO_NORMALS[test] return val > hi or val < lo return None diff --git a/src/medical/rag_search.py b/src/medical/rag_search.py index cca37c3..358bb8e 100644 --- a/src/medical/rag_search.py +++ b/src/medical/rag_search.py @@ -10,6 +10,7 @@ from ..config import ( OLLAMA_CACHE_PATH, OLLAMA_MAX_PARALLEL, OLLAMA_MODEL, ) from .cim10_dict import normalize_code, validate_code as cim10_validate +from .cim10_extractor import BIO_NORMALS from .ccam_dict import validate_code as ccam_validate from .ollama_client import call_ollama, parse_json_response from .ollama_cache import OllamaCache @@ -166,8 +167,15 @@ def _format_contexte(contexte: dict) -> str: 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")) + # Ajouter la plage de référence si connue + 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}{marker}") + bio_parts.append(f"{test} {valeur}{norme_str}{marker}") lines.append(f"- Biologie : {', '.join(bio_parts)}") imagerie = contexte.get("imagerie") @@ -489,6 +497,7 @@ RÈGLES IMPÉRATIVES : - Ne PAS coder les antécédents non pertinents pour le séjour - Privilégie les codes CIM-10 les plus SPÉCIFIQUES (4e ou 5e caractère) - Ne propose que des diagnostics CLAIREMENT mentionnés dans le texte +- ATTENTION aux valeurs biologiques : ne code PAS un diagnostic si les valeurs sont dans les normes indiquées entre crochets [N: min-max]. Exemple : Créatinine 76 [N: 50-120] = NORMAL, pas d'insuffisance rénale. DIAGNOSTIC PRINCIPAL : {dp_texte or "Non identifié"} diff --git a/tests/test_cim10_supplements.py b/tests/test_cim10_supplements.py new file mode 100644 index 0000000..510ab12 --- /dev/null +++ b/tests/test_cim10_supplements.py @@ -0,0 +1,75 @@ +"""Tests pour la fusion des suppléments CIM-10 dans cim10_dict.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from src.medical.cim10_dict import load_dict, validate_code, reset_cache + + +@pytest.fixture(autouse=True) +def _reset(): + """Reset le cache singleton avant/après chaque test.""" + reset_cache() + yield + reset_cache() + + +class TestCim10Supplements: + """Tests pour la fusion des sous-codes supplémentaires.""" + + def test_e11_9_valid(self): + """E11.9 (diabète type 2 sans complication) est reconnu.""" + is_valid, label = validate_code("E11.9") + assert is_valid + assert "diabète" in label.lower() or "type 2" in label.lower() + + def test_e10_9_valid(self): + """E10.9 (diabète type 1 sans complication) est reconnu.""" + is_valid, label = validate_code("E10.9") + assert is_valid + assert "type 1" in label.lower() + + def test_f10_2_valid(self): + """F10.2 (alcool, syndrome de dépendance) est reconnu.""" + is_valid, label = validate_code("F10.2") + assert is_valid + assert "alcool" in label.lower() + + def test_e13_9_valid(self): + """E13.9 (autres diabètes sans complication) est reconnu.""" + is_valid, label = validate_code("E13.9") + assert is_valid + + def test_main_dict_not_overwritten(self): + """Les entrées du dict principal ne sont PAS écrasées par les suppléments.""" + d = load_dict() + # E11 existe dans le dict principal avec un label issu de FAISS + assert "E11" in d + # Le label doit venir du dict principal, pas des suppléments + # (les suppléments ne contiennent que E11.0-E11.9, pas E11) + + def test_supplements_file_missing(self, tmp_path): + """load_dict() fonctionne normalement si le fichier suppléments n'existe pas.""" + fake_supplements = tmp_path / "nonexistent.json" + with patch("src.medical.cim10_dict.CIM10_SUPPLEMENTS_PATH", fake_supplements): + d = load_dict() + assert len(d) > 0 # Le dict principal est chargé + + def test_supplement_codes_count(self): + """Les suppléments ajoutent les sous-codes attendus.""" + d = load_dict() + # Vérifier que E11.0 à E11.9 existent tous + for i in range(10): + code = f"E11.{i}" + assert code in d, f"{code} manquant du dictionnaire" + + def test_validate_code_normalized(self): + """validate_code normalise le format (e119 → E11.9).""" + is_valid, label = validate_code("e119") + assert is_valid + assert label # Label non vide diff --git a/tests/test_das_llm.py b/tests/test_das_llm.py index 54763d6..9d7f0c8 100644 --- a/tests/test_das_llm.py +++ b/tests/test_das_llm.py @@ -126,6 +126,65 @@ class TestExtractDasLlm: assert "Patient hypertendu diabétique" in prompt +class TestBioNormesInContext: + """Tests pour l'inclusion des normes biologiques dans le contexte LLM.""" + + def test_format_contexte_includes_normes(self): + """_format_contexte() affiche les normes [N: min-max] pour chaque résultat bio.""" + from src.medical.rag_search import _format_contexte + + contexte = { + "biologie_cle": [ + ("Créatinine", "76", False), + ("CRP", "250", True), + ("Lipasémie", "1200", True), + ], + } + result = _format_contexte(contexte) + assert "[N: 50-120]" in result + assert "[N: 0-5]" in result + assert "[N: 0-60]" in result + # Créatinine normale → pas de marqueur ↑ + assert "Créatinine 76 [N: 50-120]" in result + # CRP anormale → marqueur ↑ + assert "CRP 250 [N: 0-5] (↑)" in result + + def test_format_contexte_no_norme_for_unknown_test(self): + """Les tests sans norme connue n'affichent pas de [N: ...].""" + from src.medical.rag_search import _format_contexte + + contexte = { + "biologie_cle": [ + ("Test inconnu", "42", None), + ], + } + result = _format_contexte(contexte) + assert "Test inconnu 42" in result + assert "[N:" not in result + + def test_prompt_das_includes_bio_norms_rule(self): + """Le prompt DAS contient la règle sur les normes biologiques.""" + from src.medical.rag_search import _build_prompt_das_extraction + + prompt = _build_prompt_das_extraction( + text="Patient avec créatinine normale", + contexte={"biologie_cle": [("Créatinine", "76", False)]}, + existing_das=[], + dp_texte="Pancréatite aiguë", + ) + assert "ATTENTION aux valeurs biologiques" in prompt + assert "[N: min-max]" in prompt + + def test_bio_normals_exported(self): + """BIO_NORMALS est bien exporté depuis cim10_extractor.""" + from src.medical.cim10_extractor import BIO_NORMALS + + assert "Créatinine" in BIO_NORMALS + assert BIO_NORMALS["Créatinine"] == (50, 120) + assert "CRP" in BIO_NORMALS + assert BIO_NORMALS["CRP"] == (0, 5) + + class TestExtractDasLlmIntegration: """Tests d'intégration pour le pass LLM DAS dans cim10_extractor.py."""