diff --git a/config/lab_value_sanity.yaml b/config/lab_value_sanity.yaml index f6b7943..41abe6c 100644 --- a/config/lab_value_sanity.yaml +++ b/config/lab_value_sanity.yaml @@ -60,3 +60,85 @@ tests: bilirubine totale: hard_min: 0 hard_max: 2000 + + # --- Nouveaux tests (Phase 5) --- + + bilirubine directe: + hard_min: 0 + hard_max: 1000 + + ldh: + hard_min: 0 + hard_max: 10000 + + vs: + hard_min: 0 + hard_max: 200 + + tp: + hard_min: 0 + hard_max: 150 + + tca: + hard_min: 10 + hard_max: 200 + + ferritine: + hard_min: 0 + hard_max: 100000 # surcharge massive possible (hémochromatose) + + uree: + hard_min: 0 + hard_max: 200 + + troponine: + hard_min: 0 + hard_max: 1000 + + bnp: + hard_min: 0 + hard_max: 50000 + + nt-probnp: + hard_min: 0 + hard_max: 100000 + + d-dimeres: + hard_min: 0 + hard_max: 100000 + + inr: + hard_min: 0.1 + hard_max: 20 + + fibrinogene: + hard_min: 0 + hard_max: 20 + + procalcitonine: + hard_min: 0 + hard_max: 1000 + + lactate: + hard_min: 0 + hard_max: 30 + + glycemie: + hard_min: 0 + hard_max: 100 + + hba1c: + hard_min: 2 + hard_max: 20 + + albumine: + hard_min: 0 + hard_max: 100 + + acide urique: + hard_min: 0 + hard_max: 2000 + + tsh: + hard_min: 0 + hard_max: 500 diff --git a/src/control/cpam_context.py b/src/control/cpam_context.py index 4b93c6f..b1d9033 100644 --- a/src/control/cpam_context.py +++ b/src/control/cpam_context.py @@ -165,18 +165,48 @@ def _build_tagged_context(dossier: DossierMedical) -> tuple[str, dict[str, str]] # Interprétations cliniques pour le résumé bio déterministe _BIO_INTERPRETATION: dict[str, dict[str, str]] = { - "CRP": {"high": "infection/inflammation active", "low": "normal", "normal": "pas d'inflammation"}, - "Hémoglobine": {"high": "polyglobulie", "low": "anémie", "normal": "pas d'anémie"}, - "Plaquettes": {"high": "thrombocytose", "low": "thrombopénie", "normal": "numération normale"}, - "Leucocytes": {"high": "hyperleucocytose", "low": "leucopénie", "normal": "numération normale"}, - "Créatinine": {"high": "insuffisance rénale", "low": "normal", "normal": "fonction rénale conservée"}, - "Potassium": {"high": "hyperkaliémie", "low": "hypokaliémie", "normal": "kaliémie normale"}, - "Sodium": {"high": "hypernatrémie", "low": "hyponatrémie", "normal": "natrémie normale"}, + # --- Hépatique / digestif --- "Lipasémie": {"high": "pancréatite probable", "low": "normal", "normal": "pas de pancréatite"}, "ASAT": {"high": "cytolyse hépatique", "low": "normal", "normal": "pas de cytolyse"}, "ALAT": {"high": "cytolyse hépatique", "low": "normal", "normal": "pas de cytolyse"}, "GGT": {"high": "cholestase/atteinte hépatique", "low": "normal", "normal": "pas de cholestase"}, + "PAL": {"high": "cholestase/atteinte osseuse", "low": "normal", "normal": "pas de cholestase"}, "Bilirubine totale": {"high": "ictère/cholestase", "low": "normal", "normal": "pas d'ictère"}, + "Bilirubine directe": {"high": "cholestase/obstruction biliaire", "low": "normal", "normal": "pas de cholestase"}, + "LDH": {"high": "cytolyse/hémolyse", "low": "normal", "normal": "pas de cytolyse"}, + # --- Inflammatoire --- + "CRP": {"high": "infection/inflammation active", "low": "normal", "normal": "pas d'inflammation"}, + "VS": {"high": "inflammation", "low": "normal", "normal": "pas d'inflammation"}, + # --- Ionogramme --- + "Sodium": {"high": "hypernatrémie", "low": "hyponatrémie", "normal": "natrémie normale"}, + "Potassium": {"high": "hyperkaliémie", "low": "hypokaliémie", "normal": "kaliémie normale"}, + # --- Hématologie --- + "Hémoglobine": {"high": "polyglobulie", "low": "anémie", "normal": "pas d'anémie"}, + "Plaquettes": {"high": "thrombocytose", "low": "thrombopénie", "normal": "numération normale"}, + "Leucocytes": {"high": "hyperleucocytose", "low": "leucopénie", "normal": "numération normale"}, + "TP": {"high": "normal", "low": "insuffisance hépatique/CIVD", "normal": "coagulation normale"}, + "TCA": {"high": "hypocoagulabilité", "low": "normal", "normal": "coagulation normale"}, + "Ferritine": {"high": "surcharge en fer/inflammation", "low": "carence en fer", "normal": "réserves en fer normales"}, + # --- Rénal --- + "Créatinine": {"high": "insuffisance rénale", "low": "normal", "normal": "fonction rénale conservée"}, + "Urée": {"high": "insuffisance rénale/catabolisme", "low": "normal", "normal": "fonction rénale conservée"}, + # --- Cardiologie --- + "Troponine": {"high": "nécrose myocardique (SCA/IDM)", "low": "normal", "normal": "pas de souffrance myocardique"}, + "BNP": {"high": "insuffisance cardiaque", "low": "normal", "normal": "pas d'insuffisance cardiaque"}, + "NT-proBNP": {"high": "insuffisance cardiaque", "low": "normal", "normal": "pas d'insuffisance cardiaque"}, + "D-dimères": {"high": "activation coagulation (EP/TVP possible)", "low": "normal", "normal": "EP/TVP peu probable"}, + "INR": {"high": "hypocoagulabilité/surdosage AVK", "low": "hypercoagulabilité", "normal": "coagulation normale"}, + "Fibrinogène": {"high": "inflammation/risque thrombotique", "low": "CIVD/insuffisance hépatique", "normal": "normal"}, + # --- Infectiologie --- + "Procalcitonine": {"high": "infection bactérienne", "low": "normal", "normal": "pas d'infection bactérienne"}, + "Lactate": {"high": "hypoperfusion/choc", "low": "normal", "normal": "pas d'hypoperfusion"}, + # --- Métabolisme --- + "Glycémie": {"high": "hyperglycémie/diabète", "low": "hypoglycémie", "normal": "glycémie normale"}, + "HbA1c": {"high": "diabète mal équilibré", "low": "normal", "normal": "équilibre glycémique correct"}, + "Albumine": {"high": "déshydratation", "low": "dénutrition/insuffisance hépatique", "normal": "état nutritionnel conservé"}, + "Acide urique": {"high": "hyperuricémie/goutte", "low": "normal", "normal": "uricémie normale"}, + # --- Thyroïde --- + "TSH": {"high": "hypothyroïdie", "low": "hyperthyroïdie", "normal": "fonction thyroïdienne normale"}, } @@ -243,6 +273,7 @@ def _check_das_bio_coherence(dossier: DossierMedical) -> list[str]: # Patterns DAS → (test bio attendu, direction attendue) _DAS_BIO_CHECKS: dict[str, tuple[str, str]] = { + # Hématologie "leucocytose": ("Leucocytes", "high"), "leucopénie": ("Leucocytes", "low"), "leucopenie": ("Leucocytes", "low"), @@ -254,8 +285,42 @@ def _check_das_bio_coherence(dossier: DossierMedical) -> list[str]: "anémie": ("Hémoglobine", "low"), "anemie": ("Hémoglobine", "low"), "polyglobulie": ("Hémoglobine", "high"), + "carence en fer": ("Ferritine", "low"), + "carence martiale": ("Ferritine", "low"), + # Ionogramme "hyperkaliémie": ("Potassium", "high"), "hypokaliémie": ("Potassium", "low"), + "hypernatrémie": ("Sodium", "high"), + "hyponatrémie": ("Sodium", "low"), + "hyponatremie": ("Sodium", "low"), + # Rénal + "insuffisance rénale": ("Créatinine", "high"), + "insuffisance renale": ("Créatinine", "high"), + # Digestif + "pancréatite": ("Lipasémie", "high"), + "pancreatite": ("Lipasémie", "high"), + # Infectiologie + "sepsis": ("CRP", "high"), + "choc septique": ("Lactate", "high"), + # Cardiologie + "infarctus": ("Troponine", "high"), + "syndrome coronarien": ("Troponine", "high"), + "embolie pulmonaire": ("D-dimères", "high"), + "insuffisance cardiaque": ("BNP", "high"), + # Métabolisme / nutrition + "dénutrition": ("Albumine", "low"), + "denutrition": ("Albumine", "low"), + "diabète": ("Glycémie", "high"), + "diabete": ("Glycémie", "high"), + "hyperuricémie": ("Acide urique", "high"), + "goutte": ("Acide urique", "high"), + # Thyroïde + "hypothyroïdie": ("TSH", "high"), + "hypothyroidie": ("TSH", "high"), + "hyperthyroïdie": ("TSH", "low"), + "hyperthyroidie": ("TSH", "low"), + # Coagulation + "civd": ("Fibrinogène", "low"), } # Indexer les valeurs bio disponibles diff --git a/src/medical/bio_normals.py b/src/medical/bio_normals.py index b0f6d3d..5ad1526 100644 --- a/src/medical/bio_normals.py +++ b/src/medical/bio_normals.py @@ -6,20 +6,48 @@ from __future__ import annotations # 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]] = { + # --- Hépatique / digestif --- "Lipasémie": (0, 60), - "CRP": (0, 5), "ASAT": (0, 40), "ALAT": (0, 40), "GGT": (0, 60), "PAL": (0, 150), "Bilirubine totale": (0, 17), - # Ionogramme (fallback adulte ; les règles de décision utilisent reference_ranges.yaml) + "Bilirubine directe": (0, 5), # µmol/L + "LDH": (120, 250), # UI/L + # --- Inflammatoire --- + "CRP": (0, 5), + "VS": (0, 20), # mm/h + # --- Ionogramme (fallback adulte ; les règles de décision utilisent reference_ranges.yaml) --- "Sodium": (135, 145), "Potassium": (3.5, 5.0), + # --- Hématologie --- "Hémoglobine": (12, 17), "Plaquettes": (150, 400), "Leucocytes": (4, 10), + "TP": (70, 100), # % + "TCA": (25, 35), # secondes + "Ferritine": (20, 300), # µg/L + # --- Rénal --- "Créatinine": (50, 120), + "Urée": (2.5, 7.5), # mmol/L + # --- Cardiologie --- + "Troponine": (0, 0.04), # ng/mL (seuil hs-TnI) + "BNP": (0, 100), # pg/mL + "NT-proBNP": (0, 300), # pg/mL + "D-dimères": (0, 500), # ng/mL + "INR": (0.8, 1.2), # ratio + "Fibrinogène": (2, 4), # g/L + # --- Infectiologie --- + "Procalcitonine": (0, 0.5), # ng/mL + "Lactate": (0.5, 2.0), # mmol/L + # --- Métabolisme --- + "Glycémie": (3.9, 5.5), # mmol/L (à jeun) + "HbA1c": (4.0, 6.0), # % + "Albumine": (35, 50), # g/L + "Acide urique": (150, 420), # µmol/L + # --- Thyroïde --- + "TSH": (0.4, 4.0), # mUI/L } diff --git a/tests/test_bio_normals.py b/tests/test_bio_normals.py new file mode 100644 index 0000000..b3e1a8c --- /dev/null +++ b/tests/test_bio_normals.py @@ -0,0 +1,134 @@ +"""Tests unitaires pour les plages de référence biologiques.""" + +import pytest + +from src.medical.bio_normals import BIO_NORMALS, _is_abnormal +from src.control.cpam_context import _BIO_INTERPRETATION + + +class TestBioNormalsCompleteness: + """Vérifie la complétude et la cohérence de BIO_NORMALS.""" + + def test_has_33_analytes(self): + assert len(BIO_NORMALS) == 33 + + def test_all_tuples_are_valid(self): + for name, (lo, hi) in BIO_NORMALS.items(): + assert isinstance(lo, (int, float)), f"{name}: lo doit être numérique" + assert isinstance(hi, (int, float)), f"{name}: hi doit être numérique" + assert lo <= hi, f"{name}: lo ({lo}) > hi ({hi})" + + def test_known_analytes_present(self): + expected = { + "CRP", "Hémoglobine", "Plaquettes", "Leucocytes", "Créatinine", + "Sodium", "Potassium", "ASAT", "ALAT", "GGT", "PAL", + "Bilirubine totale", "Lipasémie", + # Nouveaux Phase 5 + "Troponine", "BNP", "NT-proBNP", "D-dimères", "INR", "Fibrinogène", + "Procalcitonine", "Lactate", "Glycémie", "HbA1c", "Albumine", + "Urée", "Acide urique", "TP", "TCA", "Ferritine", "LDH", + "Bilirubine directe", "TSH", "VS", + } + assert set(BIO_NORMALS.keys()) == expected + + +class TestBioInterpretationConcordance: + """Vérifie que BIO_NORMALS et _BIO_INTERPRETATION sont synchronisés.""" + + def test_every_normal_has_interpretation(self): + missing = set(BIO_NORMALS) - set(_BIO_INTERPRETATION) + assert not missing, f"BIO_NORMALS sans interprétation: {missing}" + + def test_every_interpretation_has_normal(self): + extra = set(_BIO_INTERPRETATION) - set(BIO_NORMALS) + assert not extra, f"Interprétation sans BIO_NORMALS: {extra}" + + def test_all_interpretations_have_three_keys(self): + for name, interp in _BIO_INTERPRETATION.items(): + assert "high" in interp, f"{name}: manque 'high'" + assert "low" in interp, f"{name}: manque 'low'" + assert "normal" in interp, f"{name}: manque 'normal'" + + +class TestIsAbnormal: + """Tests pour _is_abnormal() sur les nouveaux et anciens analytes.""" + + def test_crp_high(self): + assert _is_abnormal("CRP", "180") is True + + def test_crp_normal(self): + assert _is_abnormal("CRP", "3") is False + + def test_hemoglobine_low(self): + assert _is_abnormal("Hémoglobine", "8.5") is True + + def test_hemoglobine_normal(self): + assert _is_abnormal("Hémoglobine", "14.5") is False + + # Nouveaux analytes + def test_troponine_high(self): + assert _is_abnormal("Troponine", "0.15") is True + + def test_troponine_normal(self): + assert _is_abnormal("Troponine", "0.02") is False + + def test_bnp_high(self): + assert _is_abnormal("BNP", "500") is True + + def test_bnp_normal(self): + assert _is_abnormal("BNP", "50") is False + + def test_lactate_high(self): + assert _is_abnormal("Lactate", "4.5") is True + + def test_lactate_normal(self): + assert _is_abnormal("Lactate", "1.2") is False + + def test_glycemie_high(self): + assert _is_abnormal("Glycémie", "8.0") is True + + def test_glycemie_low(self): + assert _is_abnormal("Glycémie", "2.5") is True + + def test_glycemie_normal(self): + assert _is_abnormal("Glycémie", "4.5") is False + + def test_tsh_high(self): + assert _is_abnormal("TSH", "10.0") is True + + def test_tsh_low(self): + assert _is_abnormal("TSH", "0.1") is True + + def test_tsh_normal(self): + assert _is_abnormal("TSH", "2.5") is False + + def test_inr_high(self): + assert _is_abnormal("INR", "3.5") is True + + def test_inr_normal(self): + assert _is_abnormal("INR", "1.0") is False + + def test_albumine_low(self): + assert _is_abnormal("Albumine", "20") is True + + def test_albumine_normal(self): + assert _is_abnormal("Albumine", "42") is False + + def test_ferritine_low(self): + assert _is_abnormal("Ferritine", "5") is True + + def test_ferritine_normal(self): + assert _is_abnormal("Ferritine", "150") is False + + # Valeurs textuelles + def test_text_negative(self): + assert _is_abnormal("CRP", "négative") is False + + def test_text_positive(self): + assert _is_abnormal("CRP", "positive") is True + + def test_unknown_test(self): + assert _is_abnormal("TestInconnu", "42") is None + + def test_unparseable_value(self): + assert _is_abnormal("CRP", "non dosé") is None