- Réorganisation data/referentiels/ : pdfs/, dicts/, user/ (structure unifiée) - Fix badges "Source absente" sur page admin référentiels - Ré-indexation COCOA 2025 (555 → 1451 chunks, couverture 94%) - Fix VRAM OOM : embeddings forcés CPU via T2A_EMBED_CPU - Nouveaux modules : document_router, docx_extractor, image_extractor, ocr_engine - Module complétude (quality/completude.py + config YAML) - Template DIM (synthèse dimensionnelle) - Gunicorn config + systemd service t2a-viewer - Suppression t2a_install_rag_cleanup/ (copie obsolète) - Suppression scripts/ et scripts_t2a_v2/ (anciens benchmarks) - Suppression 81 fichiers _doc.txt de test - Cache Ollama : TTL configurable, corrections loader YAML - Dashboard : améliorations templates (base, index, detail, cpam, validation) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
199 lines
8.2 KiB
Python
199 lines
8.2 KiB
Python
"""Tests unitaires pour la détection dénutrition HAS/FFN 2021."""
|
|
|
|
import re
|
|
import pytest
|
|
|
|
from src.config import BiologieCle, Diagnostic, DossierMedical, Sejour
|
|
from src.medical.diagnostic_extraction import _detect_nutrition_has2021, _DAS_PATTERNS
|
|
from src.medical.cim10_dict import normalize_text
|
|
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def _make_dossier(age=None, imc=None, albumine=None, existing_codes=None):
|
|
"""Construit un DossierMedical minimal pour les tests."""
|
|
dossier = DossierMedical()
|
|
dossier.sejour = Sejour(age=age, imc=imc)
|
|
if albumine is not None:
|
|
dossier.biologie_cle.append(
|
|
BiologieCle(
|
|
test="Albumine",
|
|
valeur=str(albumine),
|
|
valeur_num=float(albumine),
|
|
anomalie=True,
|
|
quality="ok",
|
|
)
|
|
)
|
|
for code in (existing_codes or []):
|
|
dossier.diagnostics_associes.append(
|
|
Diagnostic(texte="existant", cim10_suggestion=code, source="test")
|
|
)
|
|
return dossier
|
|
|
|
|
|
# ── Tests _detect_nutrition_has2021 ──────────────────────────────────
|
|
|
|
|
|
class TestDetectNutritionHAS2021:
|
|
"""Tests de la détection déterministe basée sur IMC/âge/albumine."""
|
|
|
|
def test_adulte_imc17_albumine28_gives_E43(self):
|
|
"""Adulte IMC 17.0 + albumine 28 → E43 (sévère via IMC ≤17 ET albumine <30)."""
|
|
dossier = _make_dossier(age=50, imc=17.0, albumine=28)
|
|
_detect_nutrition_has2021(dossier)
|
|
codes = [d.cim10_suggestion for d in dossier.diagnostics_associes]
|
|
assert "E43" in codes
|
|
|
|
def test_adulte_imc18_sans_albumine_gives_E44(self):
|
|
"""Adulte IMC 18.0 sans albumine → E44.0 (modéré, 17 < IMC < 18.5)."""
|
|
dossier = _make_dossier(age=45, imc=18.0)
|
|
_detect_nutrition_has2021(dossier)
|
|
codes = [d.cim10_suggestion for d in dossier.diagnostics_associes]
|
|
assert "E44.0" in codes
|
|
|
|
def test_personne_agee_75_imc21_gives_E44(self):
|
|
"""≥70 ans IMC 21.0 → E44.0 (seuil gériatrique < 22)."""
|
|
dossier = _make_dossier(age=75, imc=21.0)
|
|
_detect_nutrition_has2021(dossier)
|
|
codes = [d.cim10_suggestion for d in dossier.diagnostics_associes]
|
|
assert "E44.0" in codes
|
|
|
|
def test_personne_agee_75_imc19_gives_E43(self):
|
|
"""≥70 ans IMC 19.0 → E43 (sévère, IMC < 20)."""
|
|
dossier = _make_dossier(age=75, imc=19.0)
|
|
_detect_nutrition_has2021(dossier)
|
|
codes = [d.cim10_suggestion for d in dossier.diagnostics_associes]
|
|
assert "E43" in codes
|
|
|
|
def test_adulte_imc25_no_das(self):
|
|
"""Adulte IMC 25.0 → aucun DAS (au-dessus du seuil)."""
|
|
dossier = _make_dossier(age=40, imc=25.0)
|
|
_detect_nutrition_has2021(dossier)
|
|
codes = [d.cim10_suggestion for d in dossier.diagnostics_associes]
|
|
assert not any(c in codes for c in ("E43", "E44.0", "E46"))
|
|
|
|
def test_e46_deja_code_no_ajout(self):
|
|
"""E46 déjà codé → aucun ajout."""
|
|
dossier = _make_dossier(age=50, imc=16.0, existing_codes=["E46"])
|
|
_detect_nutrition_has2021(dossier)
|
|
e_codes = [d.cim10_suggestion for d in dossier.diagnostics_associes
|
|
if d.cim10_suggestion in ("E43", "E44.0", "E46")]
|
|
# Seul le E46 existant doit être présent
|
|
assert e_codes == ["E46"]
|
|
|
|
def test_pas_imc_no_ajout(self):
|
|
"""Pas d'IMC → aucun ajout (dégradation gracieuse)."""
|
|
dossier = _make_dossier(age=50, imc=None)
|
|
_detect_nutrition_has2021(dossier)
|
|
codes = [d.cim10_suggestion for d in dossier.diagnostics_associes]
|
|
assert not any(c in codes for c in ("E43", "E44.0", "E46"))
|
|
|
|
def test_albumine_upgrade_severity(self):
|
|
"""IMC modéré + albumine < 30 → upgrade vers E43."""
|
|
dossier = _make_dossier(age=50, imc=18.0, albumine=25)
|
|
_detect_nutrition_has2021(dossier)
|
|
codes = [d.cim10_suggestion for d in dossier.diagnostics_associes]
|
|
assert "E43" in codes # Albumine < 30 → sévère
|
|
|
|
def test_alerte_codage_added(self):
|
|
"""Vérifie qu'une alerte codage est ajoutée."""
|
|
dossier = _make_dossier(age=50, imc=17.0)
|
|
_detect_nutrition_has2021(dossier)
|
|
assert any("HAS 2021" in a for a in dossier.alertes_codage)
|
|
|
|
def test_source_is_has2021(self):
|
|
"""Vérifie que la source est 'has2021'."""
|
|
dossier = _make_dossier(age=50, imc=17.0)
|
|
_detect_nutrition_has2021(dossier)
|
|
has_diags = [d for d in dossier.diagnostics_associes if d.source == "has2021"]
|
|
assert len(has_diags) == 1
|
|
|
|
def test_age_inconnu_seuils_adulte(self):
|
|
"""Âge inconnu → seuils adulte par défaut."""
|
|
dossier = _make_dossier(age=None, imc=17.0)
|
|
_detect_nutrition_has2021(dossier)
|
|
codes = [d.cim10_suggestion for d in dossier.diagnostics_associes]
|
|
assert "E43" in codes # IMC ≤ 17 → sévère (seuils adulte)
|
|
|
|
def test_personne_agee_70_exact_seuil(self):
|
|
"""70 ans exactement → utilise les seuils gériatriques."""
|
|
dossier = _make_dossier(age=70, imc=21.5)
|
|
_detect_nutrition_has2021(dossier)
|
|
codes = [d.cim10_suggestion for d in dossier.diagnostics_associes]
|
|
assert "E44.0" in codes # < 22 → modéré avec seuils ≥70
|
|
|
|
|
|
# ── Tests regex albumine (bio_extraction) ────────────────────────────
|
|
|
|
|
|
class TestAlbumineRegex:
|
|
"""Vérifie l'extraction regex de l'albumine."""
|
|
|
|
ALBUMINE_PATTERN = r"(?:[Aa]lbumin[ée]?(?:mie)?|[Aa]lb(?:u)?[ée]?(?:mie)?)\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:g/[Ll])?"
|
|
PREALBUMINE_PATTERN = r"(?:[Pp]r[ée]albumine|[Tt]ransthyr[ée]tine)\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:mg/[Ll]|g/[Ll])?"
|
|
|
|
def test_albumine_standard(self):
|
|
m = re.search(self.ALBUMINE_PATTERN, "Albumine = 28 g/L")
|
|
assert m and m.group(1) == "28"
|
|
|
|
def test_albumine_colon(self):
|
|
m = re.search(self.ALBUMINE_PATTERN, "albumine: 32.5 g/L")
|
|
assert m and m.group(1) == "32.5"
|
|
|
|
def test_albumine_sans_unite(self):
|
|
m = re.search(self.ALBUMINE_PATTERN, "Albumine 28")
|
|
assert m and m.group(1) == "28"
|
|
|
|
def test_albuminemie(self):
|
|
m = re.search(self.ALBUMINE_PATTERN, "Albuminémie à 25 g/L")
|
|
assert m and m.group(1) == "25"
|
|
|
|
def test_prealbumine(self):
|
|
m = re.search(self.PREALBUMINE_PATTERN, "Préalbumine = 0.15 g/L")
|
|
assert m and m.group(1) == "0.15"
|
|
|
|
def test_transthyretine(self):
|
|
m = re.search(self.PREALBUMINE_PATTERN, "Transthyrétine: 180 mg/L")
|
|
assert m and m.group(1) == "180"
|
|
|
|
|
|
# ── Tests regex texte dénutrition (DAS patterns) ────────────────────
|
|
|
|
|
|
class TestDenutritionRegexSeverity:
|
|
"""Vérifie que les patterns textuels de dénutrition détectent la sévérité."""
|
|
|
|
def _match_pattern(self, text):
|
|
"""Retourne le (label, code) du premier pattern DAS matché."""
|
|
text_norm = normalize_text(text.lower())
|
|
for pat, label, code in _DAS_PATTERNS:
|
|
if re.search(pat, text_norm):
|
|
return label, code
|
|
return None, None
|
|
|
|
def test_denutrition_severe_gives_E43(self):
|
|
_, code = self._match_pattern("denutrition severe")
|
|
assert code == "E43"
|
|
|
|
def test_denutrition_moderee_gives_E44(self):
|
|
_, code = self._match_pattern("denutrition moderee")
|
|
assert code == "E44.0"
|
|
|
|
def test_denutrition_generic_gives_E46(self):
|
|
_, code = self._match_pattern("denutrition")
|
|
assert code == "E46"
|
|
|
|
def test_malnutrition_severe_gives_E43(self):
|
|
_, code = self._match_pattern("malnutrition severe")
|
|
assert code == "E43"
|
|
|
|
def test_denutrition_grade_iii_gives_E43(self):
|
|
_, code = self._match_pattern("denutrition grade III")
|
|
assert code == "E43"
|
|
|
|
def test_hypoalbuminemie_severe_gives_E46(self):
|
|
"""hypoalbuminemie severe → E46 (pattern générique)."""
|
|
_, code = self._match_pattern("hypoalbuminemie severe")
|
|
assert code == "E46"
|