feat: résumé clinique enrichi + preuves cliniques + validation QC batch
Améliore la qualité du codage CIM-10 sur 3 axes : - Contexte clinique enrichi (interprétations bio, traitements indicatifs, marqueurs sévérité) - Preuves cliniques structurées par diagnostic (evidence linking dans le prompt LLM) - Validation batch post-codage (1 appel LLM/dossier, ajustement confiance, alertes QC) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
264
tests/test_clinical_context.py
Normal file
264
tests/test_clinical_context.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""Tests pour le module d'enrichissement du contexte clinique."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.medical.clinical_context import (
|
||||
BIO_INTERPRETATIONS,
|
||||
TREATMENT_INDICATORS,
|
||||
interpret_bio_value,
|
||||
detect_treatment_indicators,
|
||||
detect_severity_markers,
|
||||
build_enriched_context,
|
||||
format_enriched_context,
|
||||
)
|
||||
from src.config import (
|
||||
BiologieCle,
|
||||
Diagnostic,
|
||||
DossierMedical,
|
||||
Imagerie,
|
||||
Sejour,
|
||||
Traitement,
|
||||
)
|
||||
|
||||
|
||||
# --- interpret_bio_value ---
|
||||
|
||||
class TestInterpretBioValue:
|
||||
def test_crp_major(self):
|
||||
assert interpret_bio_value("CRP", "180", True) == "syndrome inflammatoire majeur"
|
||||
|
||||
def test_crp_moderate(self):
|
||||
assert interpret_bio_value("CRP", "45", True) == "syndrome inflammatoire modéré"
|
||||
|
||||
def test_crp_minor(self):
|
||||
assert interpret_bio_value("CRP", "8", True) == "syndrome inflammatoire mineur"
|
||||
|
||||
def test_crp_normal(self):
|
||||
assert interpret_bio_value("CRP", "3", False) is None
|
||||
|
||||
def test_lipase_pancreatite(self):
|
||||
assert interpret_bio_value("Lipasémie", "450", True) == "pancréatite biologique (>3N)"
|
||||
|
||||
def test_lipase_moderee(self):
|
||||
assert interpret_bio_value("Lipasémie", "90", True) == "élévation modérée de la lipase"
|
||||
|
||||
def test_hemoglobine_severe(self):
|
||||
assert interpret_bio_value("Hémoglobine", "6.5", True) == "anémie sévère (transfusion probable)"
|
||||
|
||||
def test_hemoglobine_moderee(self):
|
||||
assert interpret_bio_value("Hémoglobine", "9", True) == "anémie modérée"
|
||||
|
||||
def test_plaquettes_severe(self):
|
||||
assert interpret_bio_value("Plaquettes", "30", True) == "thrombopénie sévère"
|
||||
|
||||
def test_leucocytes_high(self):
|
||||
assert interpret_bio_value("Leucocytes", "25", True) == "hyperleucocytose majeure (infection, inflammation)"
|
||||
|
||||
def test_leucocytes_low(self):
|
||||
assert interpret_bio_value("Leucocytes", "1.5", True) == "leucopénie sévère (aplasie, immunodépression)"
|
||||
|
||||
def test_creatinine_severe(self):
|
||||
assert interpret_bio_value("Créatinine", "350", True) == "insuffisance rénale sévère"
|
||||
|
||||
def test_unknown_test(self):
|
||||
assert interpret_bio_value("Glycémie", "2.5", True) is None
|
||||
|
||||
def test_invalid_value(self):
|
||||
assert interpret_bio_value("CRP", "positive", True) is None
|
||||
|
||||
def test_comma_separator(self):
|
||||
assert interpret_bio_value("Hémoglobine", "6,5", True) == "anémie sévère (transfusion probable)"
|
||||
|
||||
def test_bilirubine_ictere(self):
|
||||
assert interpret_bio_value("Bilirubine totale", "55", True) == "ictère franc"
|
||||
|
||||
def test_asat_cytolyse_majeure(self):
|
||||
assert interpret_bio_value("ASAT", "250", True) == "cytolyse hépatique majeure (>5N)"
|
||||
|
||||
def test_asat_cytolyse_moderee(self):
|
||||
assert interpret_bio_value("ASAT", "100", True) == "cytolyse hépatique modérée (>2N)"
|
||||
|
||||
|
||||
# --- detect_treatment_indicators ---
|
||||
|
||||
class TestDetectTreatmentIndicators:
|
||||
def test_insuline(self):
|
||||
traitements = [Traitement(medicament="INSULINE LANTUS 20UI")]
|
||||
result = detect_treatment_indicators(traitements)
|
||||
assert len(result) == 1
|
||||
assert result[0]["condition"] == "diabète insulino-traité"
|
||||
|
||||
def test_antibiotique_iv(self):
|
||||
traitements = [Traitement(medicament="CEFTRIAXONE 1g IV")]
|
||||
result = detect_treatment_indicators(traitements)
|
||||
assert len(result) == 1
|
||||
assert result[0]["condition"] == "antibiothérapie IV"
|
||||
|
||||
def test_multiple(self):
|
||||
traitements = [
|
||||
Traitement(medicament="Metformine 1000mg"),
|
||||
Traitement(medicament="Enoxaparine 4000UI"),
|
||||
Traitement(medicament="Paracétamol 1g"),
|
||||
]
|
||||
result = detect_treatment_indicators(traitements)
|
||||
assert len(result) == 2
|
||||
conditions = {r["condition"] for r in result}
|
||||
assert "diabète type 2" in conditions
|
||||
assert "anticoagulation (HBPM)" in conditions
|
||||
|
||||
def test_no_match(self):
|
||||
traitements = [Traitement(medicament="Paracétamol 1g")]
|
||||
result = detect_treatment_indicators(traitements)
|
||||
assert result == []
|
||||
|
||||
def test_dedup_conditions(self):
|
||||
traitements = [
|
||||
Traitement(medicament="Enoxaparine 4000UI"),
|
||||
Traitement(medicament="Lovenox 4000UI"),
|
||||
]
|
||||
result = detect_treatment_indicators(traitements)
|
||||
# Les deux sont HBPM, mais une seule condition
|
||||
assert len(result) == 1
|
||||
|
||||
def test_dict_input(self):
|
||||
traitements = [{"medicament": "morphine 10mg"}]
|
||||
result = detect_treatment_indicators(traitements)
|
||||
assert len(result) == 1
|
||||
assert result[0]["condition"] == "analgésie palier 3 (douleur sévère)"
|
||||
|
||||
|
||||
# --- detect_severity_markers ---
|
||||
|
||||
class TestDetectSeverityMarkers:
|
||||
def test_sejour_prolonge(self):
|
||||
dossier = DossierMedical(sejour=Sejour(duree_sejour=20))
|
||||
markers = detect_severity_markers(dossier)
|
||||
assert any("séjour prolongé" in m for m in markers)
|
||||
|
||||
def test_sejour_gt7(self):
|
||||
dossier = DossierMedical(sejour=Sejour(duree_sejour=10))
|
||||
markers = detect_severity_markers(dossier)
|
||||
assert any("séjour >7 jours" in m for m in markers)
|
||||
|
||||
def test_patient_tres_age(self):
|
||||
dossier = DossierMedical(sejour=Sejour(age=85))
|
||||
markers = detect_severity_markers(dossier)
|
||||
assert any("très âgé" in m for m in markers)
|
||||
|
||||
def test_patient_age(self):
|
||||
dossier = DossierMedical(sejour=Sejour(age=72))
|
||||
markers = detect_severity_markers(dossier)
|
||||
assert any("patient âgé" in m for m in markers)
|
||||
|
||||
def test_obesite_morbide(self):
|
||||
dossier = DossierMedical(sejour=Sejour(imc=42.0))
|
||||
markers = detect_severity_markers(dossier)
|
||||
assert any("obésité morbide" in m for m in markers)
|
||||
|
||||
def test_complications(self):
|
||||
dossier = DossierMedical(complications=["Fièvre", "Hématome"])
|
||||
markers = detect_severity_markers(dossier)
|
||||
assert any("2 complication(s)" in m for m in markers)
|
||||
|
||||
def test_no_markers(self):
|
||||
dossier = DossierMedical(sejour=Sejour(age=45, duree_sejour=3))
|
||||
markers = detect_severity_markers(dossier)
|
||||
assert markers == []
|
||||
|
||||
|
||||
# --- build_enriched_context ---
|
||||
|
||||
class TestBuildEnrichedContext:
|
||||
def test_basic_context(self):
|
||||
dossier = DossierMedical(
|
||||
sejour=Sejour(sexe="M", age=65, duree_sejour=5),
|
||||
biologie_cle=[
|
||||
BiologieCle(test="CRP", valeur="150", anomalie=True),
|
||||
],
|
||||
traitements_sortie=[
|
||||
Traitement(medicament="Ceftriaxone 1g"),
|
||||
],
|
||||
)
|
||||
ctx = build_enriched_context(dossier)
|
||||
|
||||
assert ctx["sexe"] == "M"
|
||||
assert ctx["age"] == 65
|
||||
assert len(ctx["interpretations_bio"]) == 1
|
||||
assert ctx["interpretations_bio"][0]["interpretation"] == "syndrome inflammatoire majeur"
|
||||
assert len(ctx["conditions_traitements"]) == 1
|
||||
assert ctx["conditions_traitements"][0]["condition"] == "antibiothérapie IV"
|
||||
|
||||
def test_no_abnormal_bio(self):
|
||||
dossier = DossierMedical(
|
||||
biologie_cle=[
|
||||
BiologieCle(test="CRP", valeur="3", anomalie=False),
|
||||
],
|
||||
)
|
||||
ctx = build_enriched_context(dossier)
|
||||
assert ctx["interpretations_bio"] == []
|
||||
|
||||
|
||||
# --- format_enriched_context ---
|
||||
|
||||
class TestFormatEnrichedContext:
|
||||
def test_with_interpretations(self):
|
||||
ctx = {
|
||||
"sexe": "F",
|
||||
"age": 70,
|
||||
"imc": None,
|
||||
"duree_sejour": 10,
|
||||
"antecedents": ["HTA", "Diabète"],
|
||||
"biologie_cle": [("CRP", "180", True)],
|
||||
"imagerie": [],
|
||||
"complications": [],
|
||||
"dp_texte": None,
|
||||
"das_codes_existants": None,
|
||||
"interpretations_bio": [
|
||||
{"test": "CRP", "valeur": "180 mg/L", "interpretation": "syndrome inflammatoire majeur"},
|
||||
],
|
||||
"conditions_traitements": [
|
||||
{"medicament": "Insuline", "condition": "diabète insulino-traité"},
|
||||
],
|
||||
"marqueurs_severite": ["séjour >7 jours (10 jours)"],
|
||||
}
|
||||
result = format_enriched_context(ctx)
|
||||
assert "Patient : F, 70 ans" in result
|
||||
assert "Durée séjour : 10 jours" in result
|
||||
assert "HTA" in result
|
||||
assert "CRP 180" in result
|
||||
assert "INTERPRÉTATION CLINIQUE" in result
|
||||
assert "syndrome inflammatoire majeur" in result
|
||||
assert "diabète insulino-traité" in result
|
||||
assert "séjour >7 jours" in result
|
||||
|
||||
def test_empty_context(self):
|
||||
ctx = {
|
||||
"sexe": None, "age": None, "imc": None,
|
||||
"duree_sejour": None, "antecedents": [],
|
||||
"biologie_cle": [], "imagerie": [],
|
||||
"complications": [], "dp_texte": None,
|
||||
"das_codes_existants": None,
|
||||
"interpretations_bio": [],
|
||||
"conditions_traitements": [],
|
||||
"marqueurs_severite": [],
|
||||
}
|
||||
result = format_enriched_context(ctx)
|
||||
assert result == "Non précisé"
|
||||
|
||||
def test_backward_compat_bio_format(self):
|
||||
"""Le format bio tuple (test, valeur, anomalie) doit rester compatible."""
|
||||
ctx = {
|
||||
"sexe": None, "age": None, "imc": None,
|
||||
"duree_sejour": None, "antecedents": [],
|
||||
"biologie_cle": [("CRP", "180", True)],
|
||||
"imagerie": [],
|
||||
"complications": [],
|
||||
"dp_texte": None,
|
||||
"das_codes_existants": None,
|
||||
"interpretations_bio": [],
|
||||
"conditions_traitements": [],
|
||||
"marqueurs_severite": [],
|
||||
}
|
||||
result = format_enriched_context(ctx)
|
||||
assert "CRP 180" in result
|
||||
assert "(\u2191)" in result
|
||||
245
tests/test_justification.py
Normal file
245
tests/test_justification.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""Tests pour la validation batch des justifications (QC post-codage)."""
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.config import (
|
||||
Diagnostic,
|
||||
DossierMedical,
|
||||
PreuveClinique,
|
||||
Sejour,
|
||||
BiologieCle,
|
||||
)
|
||||
|
||||
|
||||
class TestPreuveClinique:
|
||||
def test_create(self):
|
||||
p = PreuveClinique(type="biologie", element="CRP 180 mg/L", interpretation="syndrome inflammatoire majeur")
|
||||
assert p.type == "biologie"
|
||||
assert p.element == "CRP 180 mg/L"
|
||||
assert p.interpretation == "syndrome inflammatoire majeur"
|
||||
|
||||
def test_diagnostic_with_preuves(self):
|
||||
d = Diagnostic(
|
||||
texte="Pancréatite aiguë",
|
||||
cim10_suggestion="K85.9",
|
||||
preuves_cliniques=[
|
||||
PreuveClinique(type="biologie", element="Lipasémie 450 UI/L", interpretation="pancréatite biologique"),
|
||||
PreuveClinique(type="imagerie", element="TDM: pancréatite stade D", interpretation="confirmation"),
|
||||
],
|
||||
)
|
||||
assert len(d.preuves_cliniques) == 2
|
||||
assert d.preuves_cliniques[0].type == "biologie"
|
||||
|
||||
def test_diagnostic_default_empty_preuves(self):
|
||||
d = Diagnostic(texte="Test")
|
||||
assert d.preuves_cliniques == []
|
||||
|
||||
def test_serialization_round_trip(self):
|
||||
d = Diagnostic(
|
||||
texte="Test",
|
||||
preuves_cliniques=[
|
||||
PreuveClinique(type="clinique", element="fièvre 39°C", interpretation="syndrome infectieux"),
|
||||
],
|
||||
)
|
||||
data = d.model_dump()
|
||||
assert data["preuves_cliniques"][0]["type"] == "clinique"
|
||||
d2 = Diagnostic(**data)
|
||||
assert d2.preuves_cliniques[0].element == "fièvre 39°C"
|
||||
|
||||
|
||||
class TestApplyLlmResultPreuves:
|
||||
"""Teste le stockage des preuves cliniques dans _apply_llm_result_diagnostic."""
|
||||
|
||||
def test_preuves_stored(self):
|
||||
from src.medical.rag_search import _apply_llm_result_diagnostic
|
||||
|
||||
diag = Diagnostic(texte="Pneumopathie")
|
||||
llm_result = {
|
||||
"code": "J18.9",
|
||||
"confidence": "high",
|
||||
"justification": "Pneumopathie confirmée",
|
||||
"preuves_cliniques": [
|
||||
{"type": "biologie", "element": "CRP 120 mg/L", "interpretation": "syndrome inflammatoire"},
|
||||
{"type": "imagerie", "element": "Radio thorax: opacité", "interpretation": "foyer pulmonaire"},
|
||||
],
|
||||
}
|
||||
_apply_llm_result_diagnostic(diag, llm_result)
|
||||
assert len(diag.preuves_cliniques) == 2
|
||||
assert diag.preuves_cliniques[0].type == "biologie"
|
||||
assert diag.preuves_cliniques[1].element == "Radio thorax: opacité"
|
||||
|
||||
def test_preuves_empty_list(self):
|
||||
from src.medical.rag_search import _apply_llm_result_diagnostic
|
||||
|
||||
diag = Diagnostic(texte="Test")
|
||||
llm_result = {"code": "K85.9", "confidence": "medium", "preuves_cliniques": []}
|
||||
_apply_llm_result_diagnostic(diag, llm_result)
|
||||
assert diag.preuves_cliniques == []
|
||||
|
||||
def test_preuves_missing(self):
|
||||
from src.medical.rag_search import _apply_llm_result_diagnostic
|
||||
|
||||
diag = Diagnostic(texte="Test")
|
||||
llm_result = {"code": "K85.9", "confidence": "medium"}
|
||||
_apply_llm_result_diagnostic(diag, llm_result)
|
||||
assert diag.preuves_cliniques == []
|
||||
|
||||
def test_preuves_malformed_skipped(self):
|
||||
from src.medical.rag_search import _apply_llm_result_diagnostic
|
||||
|
||||
diag = Diagnostic(texte="Test")
|
||||
llm_result = {
|
||||
"code": "K85.9",
|
||||
"confidence": "high",
|
||||
"preuves_cliniques": [
|
||||
{"type": "bio"}, # manque 'element' → ignoré
|
||||
{"type": "imagerie", "element": "TDM ok", "interpretation": "normal"},
|
||||
"not a dict", # ignoré
|
||||
],
|
||||
}
|
||||
_apply_llm_result_diagnostic(diag, llm_result)
|
||||
assert len(diag.preuves_cliniques) == 1
|
||||
assert diag.preuves_cliniques[0].element == "TDM ok"
|
||||
|
||||
|
||||
class TestValidateJustifications:
|
||||
"""Teste la fonction _validate_justifications."""
|
||||
|
||||
@patch("src.medical.ollama_client.call_ollama")
|
||||
def test_confidence_adjusted(self, mock_ollama):
|
||||
from src.medical.cim10_extractor import _validate_justifications
|
||||
|
||||
mock_ollama.return_value = {
|
||||
"validations": [
|
||||
{
|
||||
"numero": 1,
|
||||
"code": "K85.9",
|
||||
"verdict": "maintenir",
|
||||
"confidence_recommandee": "high",
|
||||
"commentaire": "bien justifié",
|
||||
},
|
||||
{
|
||||
"numero": 2,
|
||||
"code": "I10",
|
||||
"verdict": "maintenir",
|
||||
"confidence_recommandee": "low",
|
||||
"commentaire": "pas de preuve tensionnelle",
|
||||
},
|
||||
],
|
||||
"alertes_globales": [],
|
||||
}
|
||||
|
||||
dossier = DossierMedical(
|
||||
sejour=Sejour(sexe="M", age=60),
|
||||
diagnostic_principal=Diagnostic(
|
||||
texte="Pancréatite aiguë",
|
||||
cim10_suggestion="K85.9",
|
||||
cim10_confidence="medium",
|
||||
),
|
||||
diagnostics_associes=[
|
||||
Diagnostic(
|
||||
texte="HTA",
|
||||
cim10_suggestion="I10",
|
||||
cim10_confidence="high",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
_validate_justifications(dossier)
|
||||
|
||||
# DP: medium → high
|
||||
assert dossier.diagnostic_principal.cim10_confidence == "high"
|
||||
# DAS: high → low
|
||||
assert dossier.diagnostics_associes[0].cim10_confidence == "low"
|
||||
# Alertes de confiance
|
||||
assert any("QC:" in a and "I10" in a for a in dossier.alertes_codage)
|
||||
|
||||
@patch("src.medical.ollama_client.call_ollama")
|
||||
def test_das_supprimer_alerte(self, mock_ollama):
|
||||
from src.medical.cim10_extractor import _validate_justifications
|
||||
|
||||
mock_ollama.return_value = {
|
||||
"validations": [
|
||||
{
|
||||
"numero": 1,
|
||||
"code": "K85.9",
|
||||
"verdict": "maintenir",
|
||||
"confidence_recommandee": "high",
|
||||
"commentaire": "ok",
|
||||
},
|
||||
{
|
||||
"numero": 2,
|
||||
"code": "R10.4",
|
||||
"verdict": "supprimer",
|
||||
"confidence_recommandee": "low",
|
||||
"commentaire": "symptôme couvert par le DP",
|
||||
},
|
||||
],
|
||||
"alertes_globales": ["Vérifier la spécificité du DP"],
|
||||
}
|
||||
|
||||
dossier = DossierMedical(
|
||||
diagnostic_principal=Diagnostic(
|
||||
texte="Pancréatite aiguë",
|
||||
cim10_suggestion="K85.9",
|
||||
cim10_confidence="high",
|
||||
),
|
||||
diagnostics_associes=[
|
||||
Diagnostic(
|
||||
texte="Douleur abdominale",
|
||||
cim10_suggestion="R10.4",
|
||||
cim10_confidence="medium",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
_validate_justifications(dossier)
|
||||
|
||||
# Le DAS n'est pas supprimé automatiquement, mais une alerte est ajoutée
|
||||
assert any("à reconsidérer" in a for a in dossier.alertes_codage)
|
||||
assert any("Vérifier la spécificité" in a for a in dossier.alertes_codage)
|
||||
|
||||
@patch("src.medical.ollama_client.call_ollama")
|
||||
def test_ollama_returns_none(self, mock_ollama):
|
||||
from src.medical.cim10_extractor import _validate_justifications
|
||||
|
||||
mock_ollama.return_value = None
|
||||
dossier = DossierMedical(
|
||||
diagnostic_principal=Diagnostic(
|
||||
texte="Test",
|
||||
cim10_suggestion="K85.9",
|
||||
cim10_confidence="high",
|
||||
),
|
||||
)
|
||||
_validate_justifications(dossier)
|
||||
assert dossier.alertes_codage == []
|
||||
|
||||
def test_no_diags(self):
|
||||
from src.medical.cim10_extractor import _validate_justifications
|
||||
|
||||
dossier = DossierMedical()
|
||||
_validate_justifications(dossier)
|
||||
assert dossier.alertes_codage == []
|
||||
|
||||
@patch("src.medical.ollama_client.call_ollama")
|
||||
def test_invalid_validation_nums_skipped(self, mock_ollama):
|
||||
from src.medical.cim10_extractor import _validate_justifications
|
||||
|
||||
mock_ollama.return_value = {
|
||||
"validations": [
|
||||
{"numero": 0, "code": "X", "verdict": "supprimer", "confidence_recommandee": "low", "commentaire": "oob"},
|
||||
{"numero": 99, "code": "Y", "verdict": "supprimer", "confidence_recommandee": "low", "commentaire": "oob"},
|
||||
{"numero": "abc", "code": "Z", "verdict": "supprimer", "confidence_recommandee": "low", "commentaire": "type"},
|
||||
],
|
||||
"alertes_globales": [],
|
||||
}
|
||||
|
||||
dossier = DossierMedical(
|
||||
diagnostic_principal=Diagnostic(texte="T", cim10_suggestion="A00", cim10_confidence="high"),
|
||||
)
|
||||
_validate_justifications(dossier)
|
||||
# Aucune modification, tous les numéros sont invalides
|
||||
assert dossier.diagnostic_principal.cim10_confidence == "high"
|
||||
assert dossier.alertes_codage == []
|
||||
Reference in New Issue
Block a user