"""Tests NUKE-3 — Sélecteur DP type DIM. Sans mocks : mini-fixtures JSON + mode LLM désactivé. """ from __future__ import annotations import json from pathlib import Path import pytest from src.config import ( Diagnostic, DossierMedical, DPCandidate, DPSelection, Sejour, ) from src.medical.dp_selector import ( COMORBIDITY_PREFIXES, Z_CODE_DP_WHITELIST, _is_act_only, _is_comorbidity_like, _is_symptom_like, build_candidates, score_candidates, select_dp, ) RESOURCES = Path(__file__).parent / "resources" # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _load_fixture(name: str) -> dict: """Charge un mini-dossier JSON depuis tests/resources/.""" path = RESOURCES / name return json.loads(path.read_text(encoding="utf-8")) def _build_dossier(data: dict) -> DossierMedical: """Construit un DossierMedical depuis le dict de fixture.""" d = data["dossier"] dp = None if d.get("diagnostic_principal"): dp = Diagnostic(**d["diagnostic_principal"]) das = [Diagnostic(**x) for x in d.get("diagnostics_associes", [])] return DossierMedical( document_type=d.get("document_type", "crh"), sejour=Sejour(**d.get("sejour", {})), diagnostic_principal=dp, diagnostics_associes=das, ) # --------------------------------------------------------------------------- # Tests : flags de classification # --------------------------------------------------------------------------- class TestDPFlags: """Détection comorbidité / symptôme / acte-seul.""" def test_comorbidity_hta(self): assert _is_comorbidity_like("I10") is True def test_comorbidity_diabetes(self): assert _is_comorbidity_like("E11.9") is True def test_not_comorbidity_pancreatitis(self): assert _is_comorbidity_like("K85.1") is False def test_symptom_r_code(self): assert _is_symptom_like("R10.4") is True def test_not_symptom_k_code(self): assert _is_symptom_like("K85.1") is False def test_symptom_none(self): assert _is_symptom_like(None) is False def test_act_only_cholecystectomie(self): assert _is_act_only("Cholécystectomie") is True def test_not_act_long_text(self): assert _is_act_only("Cholécystectomie pour cholécystite aiguë lithiasique avec iléus réflexe") is False def test_not_act_diagnostic(self): assert _is_act_only("Pancréatite aiguë biliaire") is False # --------------------------------------------------------------------------- # Tests : construction des candidats # --------------------------------------------------------------------------- class TestBuildCandidates: """Construction du pool de candidats depuis le dossier.""" def test_dp_and_das(self): dossier = DossierMedical( sejour=Sejour(), diagnostic_principal=Diagnostic(texte="DP", cim10_suggestion="K85.1", source="regex"), diagnostics_associes=[ Diagnostic(texte="DAS1", cim10_suggestion="R10.4", source="edsnlp"), Diagnostic(texte="DAS2", cim10_suggestion="I10", source="edsnlp"), ], ) candidates = build_candidates(dossier) assert len(candidates) == 3 assert candidates[0].term == "DP" assert candidates[0].index == 0 def test_no_dp_das_only(self): dossier = DossierMedical( sejour=Sejour(), diagnostics_associes=[ Diagnostic(texte="DAS1", cim10_suggestion="K85.1"), ], ) candidates = build_candidates(dossier) assert len(candidates) == 1 def test_ruled_out_excluded(self): dossier = DossierMedical( sejour=Sejour(), diagnostics_associes=[ Diagnostic(texte="Exclu", cim10_suggestion="R10.4", status="ruled_out"), Diagnostic(texte="Gardé", cim10_suggestion="K85.1"), ], ) candidates = build_candidates(dossier) assert len(candidates) == 1 assert candidates[0].term == "Gardé" def test_no_code_excluded(self): dossier = DossierMedical( sejour=Sejour(), diagnostics_associes=[ Diagnostic(texte="Sans code"), Diagnostic(texte="Avec code", cim10_suggestion="K85.1"), ], ) candidates = build_candidates(dossier) assert len(candidates) == 1 def test_empty_dossier(self): dossier = DossierMedical(sejour=Sejour()) candidates = build_candidates(dossier) assert candidates == [] # --------------------------------------------------------------------------- # Tests : scoring déterministe # --------------------------------------------------------------------------- class TestScoring: """Vérification du scoring déterministe.""" def test_high_confidence_beats_medium(self): candidates = [ DPCandidate(index=0, term="A", code="K85.1", confidence="high", section_strength=1), DPCandidate(index=1, term="B", code="J18.9", confidence="medium", section_strength=1), ] scored = score_candidates(candidates, {}) assert scored[0].code == "K85.1" assert scored[0].score > scored[1].score def test_comorbidity_malus(self): candidates = [ DPCandidate(index=0, term="HTA", code="I10", confidence="high", is_comorbidity_like=True, section_strength=2), DPCandidate(index=1, term="EP", code="I26.9", confidence="high", section_strength=1), ] scored = score_candidates(candidates, {}) # I26.9 doit gagner malgré section_strength plus faible (malus comorbidité -3) assert scored[0].code == "I26.9" def test_symptom_malus(self): candidates = [ DPCandidate(index=0, term="Douleur", code="R10.4", confidence="medium", is_symptom_like=True, section_strength=2), DPCandidate(index=1, term="Pancréatite", code="K85.1", confidence="high", section_strength=1), ] scored = score_candidates(candidates, {}) assert scored[0].code == "K85.1" def test_motif_alignment_bonus(self): candidates = [ DPCandidate(index=0, term="Pancréatite", code="K85.1", confidence="high", section_strength=1), DPCandidate(index=1, term="Cholécystite", code="K81.0", confidence="high", section_strength=1), ] synthese = {"motif": "Pancréatite aiguë biliaire"} scored = score_candidates(candidates, synthese) # "Pancréatite" est dans le motif → bonus +2 assert scored[0].code == "K85.1" assert scored[0].score_details.get("motif_align") == 2 def test_z_code_malus_non_whitelisted(self): candidates = [ DPCandidate(index=0, term="Suivi Z", code="Z80.0", confidence="high", section_strength=1), DPCandidate(index=1, term="Pathologie", code="K85.1", confidence="high", section_strength=1), ] scored = score_candidates(candidates, {}) # À confiance et section égales, Z80 non whitelisté perd (malus -2) assert scored[0].code == "K85.1" def test_z_code_whitelisted_no_malus(self): candidates = [ DPCandidate(index=0, term="Chimio", code="Z51.1", confidence="high", section_strength=3), ] scored = score_candidates(candidates, {}) assert "z_code_malus" not in scored[0].score_details def test_diag_sortie_bonus(self): """Un candidat mentionné dans 'diag_sortie' reçoit +4.""" candidates = [ DPCandidate(index=0, term="Anémie", code="D50", confidence="medium", section_strength=3), DPCandidate(index=1, term="SCA", code="I25.1", confidence="high", section_strength=1), ] synthese = {"diag_sortie": "SCA ST+ antérieur I25.1"} scored = score_candidates(candidates, synthese) # SCA doit gagner grâce au bonus diag_sortie +4 assert scored[0].code == "I25.1" assert scored[0].score_details.get("diag_section_bonus") == 4 def test_diag_principal_bonus(self): """Un candidat mentionné dans 'diag_principal' reçoit +4.""" candidates = [ DPCandidate(index=0, term="Pneumopathie", code="J18.9", confidence="medium", section_strength=1), DPCandidate(index=1, term="Anémie", code="D50", confidence="medium", section_strength=3), ] synthese = {"diag_principal": "Pneumopathie J18.9"} scored = score_candidates(candidates, synthese) assert scored[0].code == "J18.9" assert scored[0].score_details.get("diag_section_bonus") == 4 def test_synthese_section_bonus(self): """Un candidat mentionné dans 'synthese' reçoit +2.""" candidates = [ DPCandidate(index=0, term="Embolie pulmonaire", code="I26.9", confidence="medium", section_strength=1), ] synthese = {"synthese": "Patient admis pour embolie pulmonaire massive."} scored = score_candidates(candidates, synthese) assert scored[0].score_details.get("diag_section_bonus") == 2 def test_diag_sortie_code_match(self): """Le matching fonctionne aussi par code CIM-10.""" candidates = [ DPCandidate(index=0, term="Syndrome coronarien aigu", code="I25.1", confidence="medium", section_strength=1), ] synthese = {"diag_sortie": "I25.1 — cardiopathie ischémique"} scored = score_candidates(candidates, synthese) assert scored[0].score_details.get("diag_section_bonus") == 4 def test_no_diag_section_no_bonus(self): """Sans sections diagnostiques, pas de bonus.""" candidates = [ DPCandidate(index=0, term="Test", code="K85.1", confidence="medium", section_strength=1), ] scored = score_candidates(candidates, {"motif": "Douleur abdominale"}) assert "diag_section_bonus" not in scored[0].score_details # --------------------------------------------------------------------------- # Tests : select_dp (pré-ranker, LLM off) # --------------------------------------------------------------------------- class TestSelectDP: """Tests de bout en bout du sélecteur DP (LLM désactivé).""" def test_etiology_beats_symptom(self): """Fixture : étiologie > symptôme.""" fixture = _load_fixture("dp_etiology_vs_symptom.json") dossier = _build_dossier(fixture) synthese = fixture["synthese_nuke1"] selection = select_dp(dossier, synthese, config={"llm_enabled": False}) assert selection.chosen_code == fixture["expected"]["chosen_code"] assert selection.verdict == fixture["expected"]["verdict"] def test_acute_beats_comorbidity(self): """Fixture : aigu > comorbidité.""" fixture = _load_fixture("dp_acute_vs_comorbidity.json") dossier = _build_dossier(fixture) synthese = fixture["synthese_nuke1"] selection = select_dp(dossier, synthese, config={"llm_enabled": False}) assert selection.chosen_code == fixture["expected"]["chosen_code"] assert selection.verdict == fixture["expected"]["verdict"] def test_ambiguous_returns_review(self): """Fixture : scores proches + LLM off → REVIEW.""" fixture = _load_fixture("dp_ambiguous.json") dossier = _build_dossier(fixture) synthese = fixture["synthese_nuke1"] selection = select_dp(dossier, synthese, config={"llm_enabled": False}) assert selection.verdict == fixture["expected"]["verdict"] assert selection.chosen_code is not None # suggestion quand même assert len(selection.candidates) == 2 def test_trackare_bypasses_selection(self): """Trackare DP → CONFIRMED sans scoring.""" dossier = DossierMedical( document_type="trackare", sejour=Sejour(sexe="F", age=72), diagnostic_principal=Diagnostic( texte="Pancréatite", cim10_suggestion="K85.1", source="trackare", ), ) selection = select_dp(dossier, {}, config={"llm_enabled": False}) assert selection.verdict == "CONFIRMED" assert selection.chosen_code == "K85.1" assert "Trackare" in (selection.reason or "") assert selection.candidates == [] # pas de scoring def test_no_candidates_returns_review(self): """Aucun candidat → REVIEW.""" dossier = DossierMedical( document_type="crh", sejour=Sejour(), ) selection = select_dp(dossier, {}, config={"llm_enabled": False}) assert selection.verdict == "REVIEW" assert "Aucun candidat" in (selection.reason or "") def test_single_candidate_confirmed(self): """Candidat unique → CONFIRMED.""" dossier = DossierMedical( document_type="crh", sejour=Sejour(), diagnostics_associes=[ Diagnostic(texte="Pneumopathie", cim10_suggestion="J18.9", cim10_confidence="high"), ], ) selection = select_dp(dossier, {}, config={"llm_enabled": False}) assert selection.verdict == "CONFIRMED" assert selection.chosen_code == "J18.9" assert "unique" in (selection.reason or "").lower() def test_textual_beats_act_only(self): """Diagnostic textuel > acte seul.""" dossier = DossierMedical( document_type="crh", sejour=Sejour(), diagnostics_associes=[ Diagnostic(texte="Cholécystectomie", cim10_suggestion="K80.2", cim10_confidence="high", source="edsnlp"), Diagnostic(texte="Cholécystite aiguë lithiasique avec angiocholite", cim10_suggestion="K81.0", cim10_confidence="high", source="llm_das"), ], ) selection = select_dp(dossier, {}, config={"llm_enabled": False}) # K81.0 (diagnostic) doit battre K80.2 (décrit par un acte seul) assert selection.chosen_code == "K81.0" def test_dp_selection_has_candidates(self): """Le résultat contient la liste des candidats scorés.""" dossier = DossierMedical( document_type="crh", sejour=Sejour(), diagnostics_associes=[ Diagnostic(texte="A", cim10_suggestion="R10.4", cim10_confidence="medium"), Diagnostic(texte="B", cim10_suggestion="K85.1", cim10_confidence="high"), ], ) selection = select_dp(dossier, {}, config={"llm_enabled": False}) assert len(selection.candidates) >= 1 assert all(isinstance(c, DPCandidate) for c in selection.candidates) assert all(c.score_details for c in selection.candidates) def test_dp_selection_serializable(self): """DPSelection est sérialisable en JSON (Pydantic).""" dossier = DossierMedical( document_type="crh", sejour=Sejour(), diagnostics_associes=[ Diagnostic(texte="Test", cim10_suggestion="K85.1", cim10_confidence="high"), ], ) selection = select_dp(dossier, {}, config={"llm_enabled": False}) data = selection.model_dump() assert isinstance(data, dict) assert "verdict" in data assert "candidates" in data # Roundtrip restored = DPSelection(**data) assert restored.verdict == selection.verdict # --------------------------------------------------------------------------- # Tests : hardening DIM (A1/A2/A3) # --------------------------------------------------------------------------- class TestDPHardening: """Tests des gardes-fous DIM : evidence, mono-candidat fragile, confidence cap.""" def test_confirmed_requires_evidence(self): """A1 — CONFIRMED avec evidence vide → downgrade REVIEW.""" # Construire une DPSelection CONFIRMED sans evidence from src.medical.dp_selector import _enforce_confirmed_rules selection = DPSelection( chosen_index=0, chosen_term="Test", chosen_code="K85.1", confidence="high", verdict="CONFIRMED", evidence=[], # vide ! reason="Test", candidates=[DPCandidate(index=0, term="Test", code="K85.1", section_strength=3, confidence="high")], ) result = _enforce_confirmed_rules(selection, {}) assert result.verdict == "REVIEW" assert result.confidence != "high" assert "preuve" in (result.reason or "").lower() def test_confirmed_with_evidence_stays_confirmed(self): """A1 — CONFIRMED avec evidence non vide reste CONFIRMED.""" from src.medical.dp_selector import _enforce_confirmed_rules selection = DPSelection( chosen_index=0, chosen_term="Pancréatite", chosen_code="K85.1", confidence="high", verdict="CONFIRMED", evidence=["Score 6.0 — source: regex (section forte)"], reason="Écart net", candidates=[DPCandidate(index=0, term="Pancréatite", code="K85.1", section_strength=3, confidence="high")], ) result = _enforce_confirmed_rules(selection, {}) assert result.verdict == "CONFIRMED" assert result.confidence == "high" def test_mono_candidate_fragile_returns_review(self): """A2 — Mono-candidat comorbidité → REVIEW.""" dossier = DossierMedical( document_type="crh", sejour=Sejour(), diagnostics_associes=[ Diagnostic(texte="Hypertension artérielle", cim10_suggestion="I10", cim10_confidence="high", source="edsnlp"), ], ) selection = select_dp(dossier, {}, config={"llm_enabled": False}) assert selection.verdict == "REVIEW" assert "fragile" in (selection.reason or "").lower() def test_mono_candidate_symptom_returns_review(self): """A2 — Mono-candidat symptôme (R-code) → REVIEW.""" dossier = DossierMedical( document_type="crh", sejour=Sejour(), diagnostics_associes=[ Diagnostic(texte="Douleur abdominale", cim10_suggestion="R10.4", cim10_confidence="medium", source="edsnlp"), ], ) selection = select_dp(dossier, {}, config={"llm_enabled": False}) assert selection.verdict == "REVIEW" assert "fragile" in (selection.reason or "").lower() def test_mono_candidate_strong_stays_confirmed(self): """A2 — Mono-candidat non-fragile avec evidence → CONFIRMED.""" dossier = DossierMedical( document_type="crh", sejour=Sejour(), diagnostics_associes=[ Diagnostic(texte="Pneumopathie infectieuse", cim10_suggestion="J18.9", cim10_confidence="high", source="regex"), ], ) synthese = {"motif": "Pneumopathie infectieuse fébrile"} selection = select_dp(dossier, synthese, config={"llm_enabled": False}) assert selection.verdict == "CONFIRMED" assert len(selection.evidence) >= 1 def test_confidence_high_not_allowed_on_review(self): """A3 — confidence='high' impossible si verdict='REVIEW'.""" dossier = DossierMedical( document_type="crh", sejour=Sejour(), diagnostics_associes=[ Diagnostic(texte="Pneumopathie", cim10_suggestion="J18.9", cim10_confidence="high", source="llm_das"), Diagnostic(texte="Insuffisance cardiaque", cim10_suggestion="I50.1", cim10_confidence="high", source="llm_das"), ], ) selection = select_dp(dossier, {}, config={"llm_enabled": False}) # Scores proches → REVIEW assert selection.verdict == "REVIEW" # confidence ne peut PAS être "high" en REVIEW assert selection.confidence != "high" def test_confidence_high_requires_strong_section(self): """A3 — confidence='high' requiert section_strength >= 2.""" from src.medical.dp_selector import _enforce_confirmed_rules # Candidat avec section_strength=1 (llm_das) mais confidence high selection = DPSelection( chosen_index=0, chosen_term="Test", chosen_code="K85.1", confidence="high", verdict="CONFIRMED", evidence=["Score 4.0 — source: llm_das"], reason="Candidat unique", candidates=[DPCandidate(index=0, term="Test", code="K85.1", section_strength=1, confidence="high")], ) result = _enforce_confirmed_rules(selection, {}) # section_strength=1 < 2 → confidence downgrade assert result.confidence != "high" def test_env_var_t2a_dp_ranker_llm_controls_flag(self): """B — T2A_DP_RANKER_LLM contrôle get_dp_ranker_llm_enabled().""" import os from src.config import get_dp_ranker_llm_enabled old = os.environ.get("T2A_DP_RANKER_LLM") try: os.environ["T2A_DP_RANKER_LLM"] = "0" assert get_dp_ranker_llm_enabled() is False os.environ["T2A_DP_RANKER_LLM"] = "1" assert get_dp_ranker_llm_enabled() is True os.environ["T2A_DP_RANKER_LLM"] = "false" assert get_dp_ranker_llm_enabled() is False os.environ["T2A_DP_RANKER_LLM"] = "true" assert get_dp_ranker_llm_enabled() is True finally: if old is None: os.environ.pop("T2A_DP_RANKER_LLM", None) else: os.environ["T2A_DP_RANKER_LLM"] = old def test_legacy_env_var_accepted(self): """B — DP_RANKER_LLM_ENABLED (ancien nom) fonctionne en fallback.""" import os from src.config import get_dp_ranker_llm_enabled old_canonical = os.environ.pop("T2A_DP_RANKER_LLM", None) old_legacy = os.environ.get("DP_RANKER_LLM_ENABLED") try: os.environ["DP_RANKER_LLM_ENABLED"] = "0" assert get_dp_ranker_llm_enabled() is False finally: os.environ.pop("DP_RANKER_LLM_ENABLED", None) if old_canonical is not None: os.environ["T2A_DP_RANKER_LLM"] = old_canonical if old_legacy is not None: os.environ["DP_RANKER_LLM_ENABLED"] = old_legacy # --------------------------------------------------------------------------- # Tests : scoring bonus sections diagnostiques fortes (B) # --------------------------------------------------------------------------- class TestDiagSectionScoring: """Validation du bonus +4/+2 quand un candidat apparaît dans diag_sortie/diag_principal/synthese.""" def test_bonus_diag_sortie_beats_bio_noise(self): """I25.1 dans diag_sortie bat D50 qui n'est PAS dans la section diagnostique.""" candidates = [ DPCandidate(index=0, term="Anémie", code="D50", confidence="medium", section_strength=3, source="regex"), DPCandidate(index=1, term="SCA", code="I25.1", confidence="high", section_strength=1, source="llm_das"), ] synthese = { # Seul SCA est dans le diagnostic de sortie (Anémie = biologie, pas diagnostic) "diag_sortie": "SCA ST+ antérieur traité par angioplastie — I25.1", "motif": "Douleur thoracique", } scored = score_candidates(candidates, synthese) # I25.1 : section=1 + confidence=3 + diag_sortie=4 = 8 # D50 : section=3 + confidence=1 + pas de bonus = 4 assert scored[0].code == "I25.1" assert scored[0].score_details.get("diag_section_bonus") == 4 assert "diag_section_bonus" not in scored[1].score_details # Delta >= 3 (écart suffisant pour CONFIRMED) assert scored[0].score - scored[1].score >= 3 def test_bonus_diag_principal_match_code(self): """Bonus +4 via match code dans diag_principal.""" candidates = [ DPCandidate(index=0, term="Embolie pulmonaire", code="I26.9", confidence="medium", section_strength=1, source="edsnlp"), DPCandidate(index=1, term="Thrombose veineuse", code="I80.2", confidence="medium", section_strength=1, source="edsnlp"), ] synthese = {"diag_principal": "I26.9 Embolie pulmonaire bilatérale"} scored = score_candidates(candidates, synthese) assert scored[0].code == "I26.9" assert scored[0].score_details.get("diag_section_bonus") == 4 # I80.2 ne doit PAS avoir le bonus i802 = next(c for c in scored if c.code == "I80.2") assert "diag_section_bonus" not in i802.score_details def test_bonus_diag_principal_match_term(self): """Bonus +4 via match terme (sans code CIM-10 dans la section).""" candidates = [ DPCandidate(index=0, term="Pancréatite aiguë", code="K85.1", confidence="medium", section_strength=1), ] synthese = {"diag_principal": "Pancréatite aiguë biliaire"} scored = score_candidates(candidates, synthese) assert scored[0].score_details.get("diag_section_bonus") == 4 def test_bonus_synthese_is_weaker_than_diag_sortie(self): """Candidat A en synthese (+2) perd contre candidat B en diag_sortie (+4).""" candidates = [ DPCandidate(index=0, term="Pneumopathie", code="J18.9", confidence="medium", section_strength=1, source="edsnlp"), DPCandidate(index=1, term="Sepsis", code="A41.9", confidence="medium", section_strength=1, source="edsnlp"), ] synthese = { "synthese": "Pneumopathie traitée, évolution favorable", "diag_sortie": "Sepsis à point de départ pulmonaire A41.9", } scored = score_candidates(candidates, synthese) # A41.9 diag_sortie +4, J18.9 synthese +2 → A41.9 gagne assert scored[0].code == "A41.9" assert scored[0].score_details.get("diag_section_bonus") == 4 j189 = next(c for c in scored if c.code == "J18.9") assert j189.score_details.get("diag_section_bonus") == 2 def test_no_bonus_when_sections_absent(self): """Aucune section diagnostique → aucun bonus diag_section_bonus.""" candidates = [ DPCandidate(index=0, term="Test", code="K85.1", confidence="high", section_strength=2), DPCandidate(index=1, term="Autre", code="J18.9", confidence="medium", section_strength=1), ] synthese = {"motif": "Douleur abdominale", "conclusion": "Pancréatite aiguë"} scored = score_candidates(candidates, synthese) for c in scored: assert "diag_section_bonus" not in c.score_details def test_evidence_includes_diag_sortie_excerpt(self): """_collect_evidence() cite un extrait de diag_sortie si le gagnant y apparaît.""" from src.medical.dp_selector import _collect_evidence winner = DPCandidate( index=0, term="SCA", code="I25.1", confidence="high", section_strength=1, source="llm_das", score=8.0, ) runner = DPCandidate( index=1, term="Anémie", code="D50", confidence="medium", section_strength=3, source="regex", score=4.0, ) synthese = { "diag_sortie": "SCA ST+ antérieur traité par angioplastie coronaire percutanée", "motif": "Douleur thoracique", } evidence = _collect_evidence(winner, [winner, runner], synthese) # L'evidence doit contenir un excerpt de diag_sortie assert len(evidence) >= 1 diag_ev = [e for e in evidence if "Diagnostic de sortie" in e] assert len(diag_ev) == 1, f"Attendu 1 evidence 'Diagnostic de sortie', got: {evidence}" # L'excerpt doit contenir le texte réel, pas juste le label assert "SCA" in diag_ev[0] assert len(diag_ev[0]) >= 20 def test_evidence_includes_diag_principal_excerpt(self): """_collect_evidence() cite diag_principal si présent et gagnant y figure.""" from src.medical.dp_selector import _collect_evidence winner = DPCandidate( index=0, term="Embolie pulmonaire", code="I26.9", confidence="high", section_strength=2, source="edsnlp", score=9.0, ) synthese = { "diag_principal": "Embolie pulmonaire massive bilatérale avec défaillance VD", } evidence = _collect_evidence(winner, [winner], synthese) diag_ev = [e for e in evidence if "Diagnostic principal" in e] assert len(diag_ev) == 1 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) # --------------------------------------------------------------------------- class TestCase74Regression: """Prouve que le patch diag_sortie corrige le bug du cas 74. Bug original : D50 (Anémie, section_strength=3 regex) et I25.1 (SCA, section_strength=1 llm_das + confidence=3) avaient un score ex-aequo de 4.0. Sans section diagnostique, D50 gagnait par position. Correction : la section 'Diagnostic de sortie' contient 'SCA' et 'I25.1', ce qui donne +4 à I25.1 → I25.1 gagne. """ def test_case74_i25_beats_d50(self): """select_dp() choisit I25.1 grâce au bonus diag_sortie.""" 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.chosen_code == "I25.1", ( f"Attendu I25.1, obtenu {selection.chosen_code}. " f"Scores: {[(c.code, c.score) for c in selection.candidates]}" ) 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.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_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 evidence if "Diagnostic de sortie" in e] assert len(diag_ev) >= 1, ( f"_collect_evidence ne cite pas 'Diagnostic de sortie': {evidence}" ) assert "SCA" in diag_ev[0] def test_case74_score_details(self): """Vérification fine des scores pour traçabilité.""" fixture = _load_fixture("case_74_min.json") dossier = _build_dossier(fixture) synthese = fixture["synthese_nuke1"] selection = select_dp(dossier, synthese, config={"llm_enabled": False}) # Trouver I25.1 et D50 dans les candidats i25 = next((c for c in selection.candidates if c.code == "I25.1"), None) d50 = next((c for c in selection.candidates if c.code == "D50"), None) assert i25 is not None, "I25.1 absent des candidats" assert d50 is not None, "D50 absent des candidats" # I25.1 : section=1 + confidence=3(high) + diag_sortie=4 = 8 assert i25.score_details.get("diag_section_bonus") == 4 assert i25.score_details.get("confidence") == 3 # D50 : section=3(regex) + confidence=1(medium) + diag_sortie=4 = 8 # MAIS I25.1 a +2 de plus via confidence (3 vs 1) assert i25.score > d50.score, ( f"I25.1 ({i25.score}) doit battre D50 ({d50.score})" ) def test_case74_z95_not_dp(self): """Z95.5 (stent) ne doit pas être choisi comme DP (malus Z-code).""" 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.chosen_code != "Z95.5" z95 = next((c for c in selection.candidates if c.code == "Z95.5"), None) if z95: assert "z_code_malus" in z95.score_details