feat: règles métier T2A Phase 1 — exclusions diagnostiques, sévérité CMA et alertes codage

Ajout des règles d'exclusion symptôme (R00-R99) vs diagnostic précis (Chapitres I-XIV),
détection heuristique de sévérité CMA sur 25 racines CIM-10, et affichage des alertes
de codage dans le viewer Flask. 153 tests, 0 régression.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-11 08:53:14 +01:00
parent 12f4479cd2
commit 9df4465fef
8 changed files with 911 additions and 42 deletions

View File

@@ -0,0 +1,123 @@
"""Tests pour les règles d'exclusion diagnostique (symptôme vs diagnostic précis)."""
import pytest
from src.config import Diagnostic
from src.medical.exclusion_rules import (
is_symptom_code,
is_precise_diagnosis,
check_exclusions,
EXCLUSION_MAP,
)
class TestIsSymptomCode:
def test_r_codes(self):
assert is_symptom_code("R10.4") is True
assert is_symptom_code("R17") is True
assert is_symptom_code("R50.9") is True
def test_non_symptom(self):
assert is_symptom_code("K85.1") is False
assert is_symptom_code("I10") is False
assert is_symptom_code("E11.9") is False
def test_empty_none(self):
assert is_symptom_code("") is False
assert is_symptom_code(None) is False
class TestIsPreciseDiagnosis:
def test_precise(self):
assert is_precise_diagnosis("K85.1") is True
assert is_precise_diagnosis("A41.9") is True
assert is_precise_diagnosis("I10") is True
def test_not_precise(self):
assert is_precise_diagnosis("R10.4") is False
assert is_precise_diagnosis("Z03") is False # Chapitre XXI
class TestCheckExclusions:
def test_symptom_excluded_when_precise_present(self):
"""R10.4 (douleur abdo) exclu quand K85.1 (pancréatite) est présent."""
dp = Diagnostic(texte="Pancréatite aiguë biliaire", cim10_suggestion="K85.1")
das = [
Diagnostic(texte="Douleur abdominale", cim10_suggestion="R10.4"),
Diagnostic(texte="Obésité", cim10_suggestion="E66.0"),
]
result = check_exclusions(dp, das)
assert len(result.cleaned_das) == 1
assert result.cleaned_das[0].cim10_suggestion == "E66.0"
assert len(result.excluded) == 1
assert result.excluded[0].cim10_suggestion == "R10.4"
assert len(result.warnings) >= 1
def test_symptom_kept_when_alone(self):
"""R10.4 (douleur abdo) conservé si aucun diagnostic précis l'expliquant."""
dp = Diagnostic(texte="Douleur abdominale", cim10_suggestion="R10.4")
das = [
Diagnostic(texte="Hypertension", cim10_suggestion="I10"),
]
result = check_exclusions(dp, das)
assert len(result.cleaned_das) == 1
assert result.cleaned_das[0].cim10_suggestion == "I10"
assert len(result.excluded) == 0
def test_multiple_exclusions(self):
"""Plusieurs symptômes exclus par différents diagnostics précis."""
dp = Diagnostic(texte="Pancréatite aiguë", cim10_suggestion="K85.9")
das = [
Diagnostic(texte="Douleur abdominale", cim10_suggestion="R10.4"),
Diagnostic(texte="Nausées", cim10_suggestion="R11"),
Diagnostic(texte="Diabète type 2", cim10_suggestion="E11.9"),
]
result = check_exclusions(dp, das)
# R10.4 exclu par K85, R11 exclu par K85
assert len(result.excluded) == 2
assert len(result.cleaned_das) == 1
assert result.cleaned_das[0].cim10_suggestion == "E11.9"
def test_non_symptom_never_excluded(self):
"""Un diagnostic précis (non R-code) n'est jamais exclu."""
dp = Diagnostic(texte="Pancréatite aiguë", cim10_suggestion="K85.1")
das = [
Diagnostic(texte="Lithiase cholédoque", cim10_suggestion="K80.5"),
Diagnostic(texte="Cholécystite", cim10_suggestion="K81.0"),
]
result = check_exclusions(dp, das)
assert len(result.cleaned_das) == 2
assert len(result.excluded) == 0
def test_dp_symptom_triggers_warning(self):
"""Alerte si le DP est un symptôme alors qu'un diagnostic précis existe."""
dp = Diagnostic(texte="Douleur abdominale", cim10_suggestion="R10.4")
das = [
Diagnostic(texte="Pancréatite aiguë", cim10_suggestion="K85.1"),
]
result = check_exclusions(dp, das)
# Le DAS K85.1 n'est pas un symptôme, il est conservé
assert len(result.cleaned_das) == 1
# Mais un warning est émis sur le DP
assert any("ALERTE DP" in w for w in result.warnings)
def test_warnings_generated(self):
"""Vérifie que les warnings contiennent le texte et les codes."""
dp = Diagnostic(texte="Cholécystite", cim10_suggestion="K81.0")
das = [
Diagnostic(texte="Fièvre", cim10_suggestion="R50.9"),
]
result = check_exclusions(dp, das)
assert len(result.excluded) == 1
assert "R50.9" in result.warnings[0]
assert "K81.0" in result.warnings[0]
def test_ictere_excluded_by_lithiase(self):
"""R17 (ictère) exclu quand K80 (lithiase biliaire) est présent."""
dp = Diagnostic(texte="Lithiase cholédoque", cim10_suggestion="K80.5")
das = [
Diagnostic(texte="Ictère", cim10_suggestion="R17"),
]
result = check_exclusions(dp, das)
assert len(result.excluded) == 1
assert result.excluded[0].cim10_suggestion == "R17"

109
tests/test_severity.py Normal file
View File

@@ -0,0 +1,109 @@
"""Tests pour le module de sévérité heuristique (CMA/CMS)."""
import pytest
from src.config import Diagnostic
from src.medical.severity import (
evaluate_severity,
enrich_dossier_severity,
_detect_severity_markers,
_is_heuristic_cma,
)
class TestDetectSeverityMarkers:
def test_severe_markers(self):
niveau, marqueurs = _detect_severity_markers("Pancréatite aiguë sévère")
assert niveau == "severe"
assert any(m in ("severe", "aigue") for m in marqueurs)
def test_moderate_markers(self):
niveau, marqueurs = _detect_severity_markers("Insuffisance rénale modérée")
assert niveau == "modere"
assert "modere" in marqueurs or "moderee" in marqueurs
def test_mild_markers(self):
niveau, marqueurs = _detect_severity_markers("Anémie chronique bénigne")
assert niveau == "leger"
assert any(m in ("chronique", "benin", "benigne") for m in marqueurs)
def test_no_markers(self):
niveau, marqueurs = _detect_severity_markers("Hypertension artérielle")
assert niveau == "non_evalue"
assert marqueurs == []
def test_severe_overrides_mild(self):
"""Si 'sévère' et 'chronique' sont présents, 'severe' l'emporte."""
niveau, marqueurs = _detect_severity_markers("Insuffisance cardiaque chronique décompensée")
assert niveau == "severe"
class TestHeuristicCMA:
def test_e11_is_cma(self):
assert _is_heuristic_cma("E11.9") is True
def test_i48_is_cma(self):
assert _is_heuristic_cma("I48.9") is True
def test_a41_is_cma(self):
assert _is_heuristic_cma("A41.9") is True
def test_k85_not_cma(self):
assert _is_heuristic_cma("K85.1") is False
def test_i10_not_cma(self):
assert _is_heuristic_cma("I10") is False
def test_empty(self):
assert _is_heuristic_cma("") is False
assert _is_heuristic_cma(None) is False
class TestEvaluateSeverity:
def test_cma_code_detected(self):
diag = Diagnostic(texte="Diabète type 2", cim10_suggestion="E11.9")
info = evaluate_severity(diag)
assert info.est_cma_probable is True
def test_non_cma_code(self):
diag = Diagnostic(texte="Pancréatite aiguë biliaire", cim10_suggestion="K85.1")
info = evaluate_severity(diag)
assert info.est_cma_probable is False
def test_severity_from_text(self):
diag = Diagnostic(texte="Sepsis sévère", cim10_suggestion="A41.9")
info = evaluate_severity(diag)
assert info.niveau_severite == "severe"
assert info.est_cma_probable is True
def test_combined_text_and_dict_label(self):
"""Le label du dictionnaire CIM-10 enrichit la détection de sévérité."""
diag = Diagnostic(texte="Embolie pulmonaire", cim10_suggestion="I26.9")
info = evaluate_severity(diag)
assert info.est_cma_probable is True
class TestEnrichDossierSeverity:
def test_enriches_das_in_place(self):
dp = Diagnostic(texte="Pancréatite aiguë biliaire", cim10_suggestion="K85.1")
das = [
Diagnostic(texte="Fibrillation auriculaire", cim10_suggestion="I48.9"),
Diagnostic(texte="Obésité", cim10_suggestion="E66.0"),
]
alertes = enrich_dossier_severity(dp, das)
# I48.9 = CMA probable
assert das[0].est_cma is True
assert das[0].niveau_severite is not None
# E66.0 = non CMA
assert das[1].est_cma is None
# Au moins une alerte CMA
assert any("CMA" in a for a in alertes)
def test_dp_severity_set(self):
dp = Diagnostic(texte="Sepsis sévère", cim10_suggestion="A41.9")
alertes = enrich_dossier_severity(dp, [])
assert dp.niveau_severite == "severe"
assert dp.est_cma is True