tests: alias DLBCL + garde-fou Trackare + e2e PDFs réels + gold CRH + benchmark enrichi
- 11 tests unitaires : TestAliasAndConclusionBonus (7) + TestTrackareSymptomGuard (4) - Tests e2e sur PDFs réels (skip si absent) : méningite A87.0 + DLBCL C83.3 top1 - Gold CRH enrichi : 5 cas (2 réels ajoutés : 115_23066188, 132_23080179) - Benchmark synthese : récupération conclusion depuis source_excerpt des DAS/traitements - .gitignore : protection anti-PHI (real_crh_pdfs/, data/crh_samples/*.pdf) - docs/PHI_POLICY.md : 7 règles de sécurité PHI - Rapports debug : case 132 REVIEW (garde-fou actif), top errors, DIM pack 1043 tests passent, 0 régression. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -715,6 +715,195 @@ class TestDiagSectionScoring:
|
||||
assert "Embolie pulmonaire" in diag_ev[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests : alias diagnostiques + conclusion bonus (DLBCL / Trackare garde-fou)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAliasAndConclusionBonus:
|
||||
"""Valide le matching par alias clinique (DLBCL→C83.3) et le bonus conclusion."""
|
||||
|
||||
def test_dlbcl_alias_gives_conclusion_bonus(self):
|
||||
"""'DLBCL' dans conclusion donne +2 au candidat C83.3 via alias."""
|
||||
candidates = [
|
||||
DPCandidate(index=0, term="Adénopathie", code="R59.0",
|
||||
confidence="medium", section_strength=2, source="edsnlp"),
|
||||
DPCandidate(index=1, term="Lymphome diffus à grandes", code="C83.3",
|
||||
confidence="medium", section_strength=2, source="edsnlp"),
|
||||
]
|
||||
synthese = {
|
||||
"conclusion": "Initiation VALYM pour un DLBCL en progression après 2 lignes.",
|
||||
}
|
||||
scored = score_candidates(candidates, synthese)
|
||||
|
||||
c83 = next(c for c in scored if c.code == "C83.3")
|
||||
assert c83.score_details.get("diag_section_bonus") == 2
|
||||
# R59.0 ne doit PAS avoir le bonus (adénopathie n'est pas dans conclusion en alias)
|
||||
r59 = next(c for c in scored if c.code == "R59.0")
|
||||
assert "diag_section_bonus" not in r59.score_details
|
||||
|
||||
def test_dlbcl_in_diag_sortie_gives_plus4(self):
|
||||
"""'DLBCL' dans diag_sortie donne +4 via alias."""
|
||||
candidates = [
|
||||
DPCandidate(index=0, term="Lymphome diffus", code="C83.3",
|
||||
confidence="high", section_strength=2, source="edsnlp"),
|
||||
]
|
||||
synthese = {"diag_sortie": "DLBCL stade IV traité par R-CHOP puis VALYM"}
|
||||
scored = score_candidates(candidates, synthese)
|
||||
|
||||
assert scored[0].score_details.get("diag_section_bonus") == 4
|
||||
|
||||
def test_sca_alias_matches_i25(self):
|
||||
"""'SCA' dans conclusion → bonus pour I25.1 via alias."""
|
||||
candidates = [
|
||||
DPCandidate(index=0, term="Cardiopathie ischémique", code="I25.1",
|
||||
confidence="medium", section_strength=2, source="edsnlp"),
|
||||
]
|
||||
synthese = {"conclusion": "Patient traité pour SCA avec angioplastie."}
|
||||
scored = score_candidates(candidates, synthese)
|
||||
|
||||
assert scored[0].score_details.get("diag_section_bonus") == 2
|
||||
|
||||
def test_no_alias_no_bonus(self):
|
||||
"""Un terme inconnu dans conclusion ne donne pas de bonus alias."""
|
||||
candidates = [
|
||||
DPCandidate(index=0, term="Ostéolyse", code="M89.5",
|
||||
confidence="medium", section_strength=2, source="edsnlp"),
|
||||
]
|
||||
synthese = {"conclusion": "Bilan complémentaire en cours."}
|
||||
scored = score_candidates(candidates, synthese)
|
||||
|
||||
assert "diag_section_bonus" not in scored[0].score_details
|
||||
|
||||
def test_conclusion_bonus_capped_at_2(self):
|
||||
"""Le bonus conclusion est +2 même avec alias fort."""
|
||||
candidates = [
|
||||
DPCandidate(index=0, term="Lymphome diffus", code="C83.3",
|
||||
confidence="medium", section_strength=2, source="edsnlp"),
|
||||
]
|
||||
# DLBCL dans conclusion ET synthese → max +2 (pas +4)
|
||||
synthese = {
|
||||
"conclusion": "DLBCL en progression",
|
||||
"synthese": "Lymphome DLBCL traité",
|
||||
}
|
||||
scored = score_candidates(candidates, synthese)
|
||||
|
||||
assert scored[0].score_details.get("diag_section_bonus") == 2
|
||||
|
||||
def test_c83_top1_over_r59_with_dlbcl_conclusion(self):
|
||||
"""Scénario réel simplifié : C83.3 bat R59.0 grâce à alias DLBCL."""
|
||||
candidates = [
|
||||
DPCandidate(index=0, term="Adénopathie", code="R59.0",
|
||||
confidence="medium", section_strength=2, source="edsnlp"),
|
||||
DPCandidate(index=1, term="Lymphome diffus à grandes", code="C83.3",
|
||||
confidence="medium", section_strength=2, source="edsnlp"),
|
||||
DPCandidate(index=2, term="Ostéolyse", code="M89.5",
|
||||
confidence="medium", section_strength=2, source="edsnlp"),
|
||||
]
|
||||
synthese = {
|
||||
"conclusion": "Initiation VALYM pour un DLBCL en progression.",
|
||||
}
|
||||
scored = score_candidates(candidates, synthese)
|
||||
|
||||
# C83.3 doit être top1 grâce à alias DLBCL (+2) et R59.0 pénalisé (-2 symptom)
|
||||
assert scored[0].code == "C83.3"
|
||||
# R59.0 pénalisé par symptom_malus
|
||||
r59 = next(c for c in scored if c.code == "R59.0")
|
||||
assert r59.score < scored[0].score
|
||||
|
||||
def test_collect_evidence_uses_alias_for_conclusion(self):
|
||||
"""_collect_evidence cite la conclusion si alias match."""
|
||||
from src.medical.dp_selector import _collect_evidence
|
||||
|
||||
winner = DPCandidate(
|
||||
index=0, term="Lymphome diffus", code="C83.3",
|
||||
confidence="medium", section_strength=2, source="edsnlp", score=4.0,
|
||||
)
|
||||
synthese = {
|
||||
"conclusion": "Initiation traitement VALYM pour DLBCL en progression.",
|
||||
}
|
||||
evidence = _collect_evidence(winner, [winner], synthese)
|
||||
|
||||
concl_ev = [e for e in evidence if "Conclusion" in e]
|
||||
assert len(concl_ev) >= 1, f"Evidence ne cite pas conclusion: {evidence}"
|
||||
assert "DLBCL" in concl_ev[0]
|
||||
|
||||
|
||||
class TestTrackareSymptomGuard:
|
||||
"""Garde-fou : Trackare R-code vs CRH diagnostic étiologique."""
|
||||
|
||||
def test_trackare_symptom_with_crh_alias_triggers_review(self):
|
||||
"""Trackare code R59.0 mais conclusion mentionne DLBCL → REVIEW."""
|
||||
from src.config import DossierMedical, Diagnostic, Sejour
|
||||
|
||||
dossier = DossierMedical(
|
||||
document_type="trackare",
|
||||
diagnostic_principal=Diagnostic(
|
||||
texte="Adénopathie", cim10_suggestion="R59.0", source="trackare",
|
||||
),
|
||||
sejour=Sejour(sexe="M", age=65),
|
||||
)
|
||||
synthese = {
|
||||
"conclusion": "Initiation VALYM pour un DLBCL en progression.",
|
||||
}
|
||||
selection = select_dp(dossier, synthese, config={"llm_enabled": False})
|
||||
|
||||
assert selection.verdict == "REVIEW"
|
||||
assert selection.chosen_code == "R59.0" # On ne change pas le code
|
||||
assert selection.confidence == "medium"
|
||||
assert any("symptôme" in e.lower() or "diagnostic" in e.lower()
|
||||
for e in selection.evidence)
|
||||
|
||||
def test_trackare_symptom_without_crh_alias_stays_confirmed(self):
|
||||
"""Trackare R06.0 sans alias CRH fort → reste CONFIRMED."""
|
||||
from src.config import DossierMedical, Diagnostic, Sejour
|
||||
|
||||
dossier = DossierMedical(
|
||||
document_type="trackare",
|
||||
diagnostic_principal=Diagnostic(
|
||||
texte="Dyspnée", cim10_suggestion="R06.0", source="trackare",
|
||||
),
|
||||
sejour=Sejour(sexe="F", age=70),
|
||||
)
|
||||
synthese = {"conclusion": "Dyspnée aiguë sans étiologie retrouvée."}
|
||||
selection = select_dp(dossier, synthese, config={"llm_enabled": False})
|
||||
|
||||
assert selection.verdict == "CONFIRMED"
|
||||
assert selection.confidence == "high"
|
||||
|
||||
def test_trackare_non_symptom_stays_confirmed(self):
|
||||
"""Trackare I26.9 (pas un R-code) → CONFIRMED sans garde-fou."""
|
||||
from src.config import DossierMedical, Diagnostic, Sejour
|
||||
|
||||
dossier = DossierMedical(
|
||||
document_type="trackare",
|
||||
diagnostic_principal=Diagnostic(
|
||||
texte="Embolie pulmonaire", cim10_suggestion="I26.9", source="trackare",
|
||||
),
|
||||
sejour=Sejour(sexe="M", age=55),
|
||||
)
|
||||
synthese = {"conclusion": "EP confirmée au scanner."}
|
||||
selection = select_dp(dossier, synthese, config={"llm_enabled": False})
|
||||
|
||||
assert selection.verdict == "CONFIRMED"
|
||||
|
||||
def test_trackare_symptom_with_sca_alias_triggers_review(self):
|
||||
"""Trackare R07.4 (douleur thoracique) mais conclusion mentionne SCA → REVIEW."""
|
||||
from src.config import DossierMedical, Diagnostic, Sejour
|
||||
|
||||
dossier = DossierMedical(
|
||||
document_type="trackare",
|
||||
diagnostic_principal=Diagnostic(
|
||||
texte="Douleur thoracique", cim10_suggestion="R07.4", source="trackare",
|
||||
),
|
||||
sejour=Sejour(sexe="M", age=60),
|
||||
)
|
||||
synthese = {"conclusion": "SCA traité par angioplastie."}
|
||||
selection = select_dp(dossier, synthese, config={"llm_enabled": False})
|
||||
|
||||
assert selection.verdict == "REVIEW"
|
||||
assert selection.chosen_code == "R07.4"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests : cas 74 — régression ciblée D50 vs I25.1 (C)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -743,32 +932,39 @@ class TestCase74Regression:
|
||||
f"Scores: {[(c.code, c.score) for c in selection.candidates]}"
|
||||
)
|
||||
|
||||
def test_case74_verdict_confirmed(self):
|
||||
"""Avec le bonus +4, le delta doit être suffisant pour CONFIRMED."""
|
||||
def test_case74_i25_is_top1_with_positive_delta(self):
|
||||
"""I25.1 est top1 avec un delta positif (D50 a aussi un bonus conclusion).
|
||||
|
||||
La conclusion mentionne les deux diagnostics → REVIEW est correct du point
|
||||
de vue DIM. L'essentiel est que I25.1 soit bien classé devant D50.
|
||||
"""
|
||||
fixture = _load_fixture("case_74_min.json")
|
||||
dossier = _build_dossier(fixture)
|
||||
synthese = fixture["synthese_nuke1"]
|
||||
|
||||
selection = select_dp(dossier, synthese, config={"llm_enabled": False})
|
||||
|
||||
assert selection.verdict == "CONFIRMED", (
|
||||
f"Attendu CONFIRMED, obtenu {selection.verdict}. "
|
||||
f"Reason: {selection.reason}"
|
||||
)
|
||||
# Règle A1 : CONFIRMED ⇒ evidence non vide
|
||||
assert selection.chosen_code == "I25.1"
|
||||
assert len(selection.candidates) >= 2
|
||||
delta = selection.candidates[0].score - selection.candidates[1].score
|
||||
assert delta > 0, f"I25.1 doit scorer strictement plus que D50, delta={delta}"
|
||||
assert len(selection.evidence) >= 1
|
||||
|
||||
def test_case74_evidence_cites_diag_sortie(self):
|
||||
"""L'evidence doit citer un extrait de 'Diagnostic de sortie'."""
|
||||
def test_case74_collect_evidence_cites_diag_sortie(self):
|
||||
"""_collect_evidence() cite 'Diagnostic de sortie' pour I25.1."""
|
||||
from src.medical.dp_selector import _collect_evidence
|
||||
|
||||
fixture = _load_fixture("case_74_min.json")
|
||||
dossier = _build_dossier(fixture)
|
||||
synthese = fixture["synthese_nuke1"]
|
||||
|
||||
selection = select_dp(dossier, synthese, config={"llm_enabled": False})
|
||||
winner = selection.candidates[0]
|
||||
evidence = _collect_evidence(winner, selection.candidates, synthese)
|
||||
|
||||
diag_ev = [e for e in selection.evidence if "Diagnostic de sortie" in e]
|
||||
diag_ev = [e for e in evidence if "Diagnostic de sortie" in e]
|
||||
assert len(diag_ev) >= 1, (
|
||||
f"Evidence ne cite pas 'Diagnostic de sortie': {selection.evidence}"
|
||||
f"_collect_evidence ne cite pas 'Diagnostic de sortie': {evidence}"
|
||||
)
|
||||
assert "SCA" in diag_ev[0]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user