From 07c267539c594ff4978ca3485401cbf7e54d65b9 Mon Sep 17 00:00:00 2001 From: dom Date: Tue, 24 Feb 2026 13:28:54 +0100 Subject: [PATCH] tests: CRH sections + DP diag bonus + case 74 regression + fusion propagation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_extraction: +21 tests (sections diag_sortie/diag_principal/synthese, variantes titres, terminaisons, faux positifs mid-sentence, biosynthèse) - test_dp_selector: +55 tests (flags, candidates, scoring, hardening DIM, bonus +4/+2, evidence excerpt, cas 74 D50→I25.1 corrigé) - test_fusion: +39 tests (propagation dp_selection evidence/reason/verdict, source 2e dossier, pas de crash si aucun DP) - fixtures: case_74_min.json + 3 fixtures DP existantes Aucun mock utilisé — données synthétiques uniquement. Le test cas 74 passe : I25.1 gagne sur D50 grâce au bonus diag_sortie +4. Co-Authored-By: Claude Opus 4.6 --- tests/resources/case_74_min.json | 38 + tests/resources/dp_acute_vs_comorbidity.json | 36 + tests/resources/dp_ambiguous.json | 29 + tests/resources/dp_etiology_vs_symptom.json | 30 + tests/test_dp_selector.py | 811 +++++++++++++++++++ tests/test_extraction.py | 279 +++++++ tests/test_fusion.py | 160 ++++ 7 files changed, 1383 insertions(+) create mode 100644 tests/resources/case_74_min.json create mode 100644 tests/resources/dp_acute_vs_comorbidity.json create mode 100644 tests/resources/dp_ambiguous.json create mode 100644 tests/resources/dp_etiology_vs_symptom.json create mode 100644 tests/test_dp_selector.py diff --git a/tests/resources/case_74_min.json b/tests/resources/case_74_min.json new file mode 100644 index 0000000..257da57 --- /dev/null +++ b/tests/resources/case_74_min.json @@ -0,0 +1,38 @@ +{ + "description": "Cas 74 — D50 Anémie vs I25.1 SCA. Le patch diag_sortie doit faire gagner I25.1.", + "dossier": { + "document_type": "crh", + "sejour": {"sexe": "M", "age": 68, "duree_sejour": 5}, + "diagnostic_principal": { + "texte": "Anémie", + "cim10_suggestion": "D50", + "cim10_confidence": "medium", + "source": "regex" + }, + "diagnostics_associes": [ + { + "texte": "Stent vasculaire", + "cim10_suggestion": "Z95.5", + "cim10_confidence": "high", + "source": "edsnlp" + }, + { + "texte": "SCA (Syndrome Coronarien Aigu)", + "cim10_suggestion": "I25.1", + "cim10_confidence": "high", + "source": "llm_das" + } + ] + }, + "synthese_nuke1": { + "motif": "Douleur thoracique", + "conclusion": "Anémie ferriprive sur syndrome coronarien aigu traité par angioplastie.", + "diag_sortie": "SCA ST+ antérieur traité par angioplastie coronaire — I25.1", + "diag_principal": "", + "synthese": "" + }, + "expected": { + "chosen_code": "I25.1", + "verdict": "CONFIRMED" + } +} diff --git a/tests/resources/dp_acute_vs_comorbidity.json b/tests/resources/dp_acute_vs_comorbidity.json new file mode 100644 index 0000000..ab9feef --- /dev/null +++ b/tests/resources/dp_acute_vs_comorbidity.json @@ -0,0 +1,36 @@ +{ + "description": "Aigu > comorbidité : embolie pulmonaire vs HTA + diabète", + "dossier": { + "document_type": "crh", + "sejour": {"sexe": "F", "age": 72, "duree_sejour": 8}, + "diagnostic_principal": null, + "diagnostics_associes": [ + { + "texte": "Hypertension artérielle", + "cim10_suggestion": "I10", + "cim10_confidence": "high", + "source": "edsnlp" + }, + { + "texte": "Diabète de type 2", + "cim10_suggestion": "E11.9", + "cim10_confidence": "high", + "source": "edsnlp" + }, + { + "texte": "Embolie pulmonaire", + "cim10_suggestion": "I26.9", + "cim10_confidence": "high", + "source": "regex" + } + ] + }, + "synthese_nuke1": { + "motif": "Dyspnée aiguë avec douleur thoracique", + "conclusion": "Embolie pulmonaire confirmée à l'angioscanner" + }, + "expected": { + "chosen_code": "I26.9", + "verdict": "CONFIRMED" + } +} diff --git a/tests/resources/dp_ambiguous.json b/tests/resources/dp_ambiguous.json new file mode 100644 index 0000000..73f0f87 --- /dev/null +++ b/tests/resources/dp_ambiguous.json @@ -0,0 +1,29 @@ +{ + "description": "Ambigu : deux pathologies aigues de score similaire → REVIEW", + "dossier": { + "document_type": "crh", + "sejour": {"sexe": "M", "age": 55, "duree_sejour": 6}, + "diagnostic_principal": null, + "diagnostics_associes": [ + { + "texte": "Pneumopathie infectieuse", + "cim10_suggestion": "J18.9", + "cim10_confidence": "high", + "source": "llm_das" + }, + { + "texte": "Insuffisance cardiaque décompensée", + "cim10_suggestion": "I50.1", + "cim10_confidence": "high", + "source": "llm_das" + } + ] + }, + "synthese_nuke1": { + "motif": "Dyspnée fébrile", + "conclusion": "Décompensation cardiaque sur pneumopathie infectieuse" + }, + "expected": { + "verdict": "REVIEW" + } +} diff --git a/tests/resources/dp_etiology_vs_symptom.json b/tests/resources/dp_etiology_vs_symptom.json new file mode 100644 index 0000000..8585eb1 --- /dev/null +++ b/tests/resources/dp_etiology_vs_symptom.json @@ -0,0 +1,30 @@ +{ + "description": "Étiologie > symptôme : pancréatite aiguë vs douleur abdominale", + "dossier": { + "document_type": "crh", + "sejour": {"sexe": "M", "age": 65, "duree_sejour": 5}, + "diagnostic_principal": null, + "diagnostics_associes": [ + { + "texte": "Douleur abdominale", + "cim10_suggestion": "R10.4", + "cim10_confidence": "medium", + "source": "edsnlp" + }, + { + "texte": "Pancréatite aiguë biliaire", + "cim10_suggestion": "K85.1", + "cim10_confidence": "high", + "source": "llm_das" + } + ] + }, + "synthese_nuke1": { + "motif": "Urgence abdominale", + "conclusion": "Pancréatite aiguë biliaire confirmée au scanner" + }, + "expected": { + "chosen_code": "K85.1", + "verdict": "CONFIRMED" + } +} diff --git a/tests/test_dp_selector.py b/tests/test_dp_selector.py new file mode 100644 index 0000000..430c6d1 --- /dev/null +++ b/tests/test_dp_selector.py @@ -0,0 +1,811 @@ +"""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 : 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_verdict_confirmed(self): + """Avec le bonus +4, le delta doit être suffisant pour CONFIRMED.""" + 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 len(selection.evidence) >= 1 + + def test_case74_evidence_cites_diag_sortie(self): + """L'evidence doit citer un extrait de 'Diagnostic de 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}) + + diag_ev = [e for e in selection.evidence if "Diagnostic de sortie" in e] + assert len(diag_ev) >= 1, ( + f"Evidence ne cite pas 'Diagnostic de sortie': {selection.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 diff --git a/tests/test_extraction.py b/tests/test_extraction.py index 25c3440..2685ddf 100644 --- a/tests/test_extraction.py +++ b/tests/test_extraction.py @@ -64,6 +64,285 @@ Pancréatite aiguë""" assert any("AUDEMAR" in m for m in result["medecins"]) +class TestCRHSectionsDiagnostic: + """Tests Patch 0 — nouvelles sections CRH à fort signal DP.""" + + def test_parse_diag_sortie(self): + """'Diagnostic de sortie :' capturé dans sections.""" + text = """Au total : +Syndrome coronarien aigu traité par angioplastie. + +Diagnostic de sortie : +SCA ST+ antérieur I25.1 + +Traitement de sortie : +Aspirine 100mg""" + result = parse_crh(text) + assert "diag_sortie" in result["sections"] + assert "SCA" in result["sections"]["diag_sortie"] + + def test_parse_diagnostics_retenus(self): + """'Diagnostics retenus :' capturé comme diag_sortie.""" + text = """Au total : +Bilan. + +Diagnostics retenus : +- Embolie pulmonaire I26.9 +- Thrombose veineuse profonde I80.2 + +Devenir : +Retour à domicile""" + result = parse_crh(text) + assert "diag_sortie" in result["sections"] + assert "Embolie pulmonaire" in result["sections"]["diag_sortie"] + + def test_parse_diagnostics_retenus_a_la_sortie(self): + """'Diagnostics retenus à la sortie :' capturé.""" + text = """Conclusion : +Amélioration. + +Diagnostics retenus à la sortie : +Pneumopathie J18.9 + +TTT de sortie : +Amoxicilline""" + result = parse_crh(text) + assert "diag_sortie" in result["sections"] + assert "Pneumopathie" in result["sections"]["diag_sortie"] + + def test_parse_diag_principal(self): + """'Diagnostic principal :' capturé.""" + text = """Examen clinique : +Patient fébrile. + +Diagnostic principal : +Pancréatite aiguë biliaire K85.1 + +Diagnostics associés : +Lithiase vésiculaire K80.2""" + result = parse_crh(text) + assert "diag_principal" in result["sections"] + assert "Pancréatite" in result["sections"]["diag_principal"] + + def test_parse_probleme_principal(self): + """'Problème principal :' capturé comme diag_principal.""" + text = """Antécédents : +HTA, diabète. + +Problème principal : +Insuffisance cardiaque décompensée I50.0 + +Devenir : +Hospitalisation prolongée""" + result = parse_crh(text) + assert "diag_principal" in result["sections"] + assert "Insuffisance cardiaque" in result["sections"]["diag_principal"] + + def test_parse_synthese(self): + """'Synthèse :' capturé.""" + text = """Au total : +Bilan complet. + +Synthèse : +Patient de 65 ans admis pour SCA. Angioplastie réalisée. + +Traitement de sortie : +Brilique""" + result = parse_crh(text) + assert "synthese" in result["sections"] + assert "SCA" in result["sections"]["synthese"] + + def test_existing_sections_preserved(self): + """Les 7 sections existantes sont toujours captées (non-régression).""" + text = """pour le motif suivant: +Douleur abdominale + +Antécédents : +HTA traitée + +Histoire de la maladie : +Douleur depuis 3 jours + +Examen clinique : +Abdomen souple + +Au total : +Pancréatite aiguë biliaire + +TTT de sortie : +Paracétamol + +Devenir : +Retour à domicile""" + result = parse_crh(text) + assert "motif_hospitalisation" in result["sections"] + assert "antecedents" in result["sections"] + assert "histoire_maladie" in result["sections"] + assert "examen_clinique" in result["sections"] + assert "conclusion" in result["sections"] + assert "traitement_sortie" in result["sections"] + assert "devenir" in result["sections"] + + def test_conclusion_does_not_overflow_into_diag_sortie(self): + """La section conclusion s'arrête avant 'Diagnostic de sortie'.""" + text = """Au total : +Bilan complet réalisé. Évolution favorable. + +Diagnostic de sortie : +SCA ST+ antérieur""" + result = parse_crh(text) + assert "conclusion" in result["sections"] + assert "diag_sortie" in result["sections"] + # La conclusion ne doit PAS contenir le texte du diagnostic de sortie + assert "SCA" not in result["sections"]["conclusion"] + assert "SCA" in result["sections"]["diag_sortie"] + + +class TestCRHSectionsRobustness: + """Tests robustesse Patch 0 — variantes de titres, terminaisons, cas pièges.""" + + # --- A1 : Variantes de titres --- + + def test_diagnostics_de_sortie_plural(self): + """'Diagnostics de sortie' (pluriel) capturé.""" + text = "Diagnostics de sortie :\nSCA I25.1\n\nDevenir :\nRAD" + result = parse_crh(text) + assert "diag_sortie" in result["sections"] + assert "SCA" in result["sections"]["diag_sortie"] + + def test_diagnostics_retenus_en_sortie(self): + """'Diagnostics retenus en sortie' capturé.""" + text = "Diagnostics retenus en sortie :\nPneumopathie J18.9\n\nDevenir :\nRAD" + result = parse_crh(text) + assert "diag_sortie" in result["sections"] + assert "Pneumopathie" in result["sections"]["diag_sortie"] + + def test_en_resume(self): + """'En résumé' capturé comme synthese.""" + text = "En résumé :\nPatient opéré avec succès.\n\nTraitement de sortie :\nParacétamol" + result = parse_crh(text) + assert "synthese" in result["sections"] + assert "opéré" in result["sections"]["synthese"] + + def test_en_synthese(self): + """'En synthèse' capturé comme synthese.""" + text = "En synthèse :\nAmélioration clinique rapide.\n\nDevenir :\nRAD" + result = parse_crh(text) + assert "synthese" in result["sections"] + assert "Amélioration" in result["sections"]["synthese"] + + def test_titre_avec_deux_points_colles(self): + """'Diagnostic principal:' (sans espace avant ':') fonctionne.""" + text = "Diagnostic principal:\nPancréatite aiguë K85.1\n\nDevenir :\nRAD" + result = parse_crh(text) + assert "diag_principal" in result["sections"] + assert "Pancréatite" in result["sections"]["diag_principal"] + + def test_titre_synthese_sans_deux_points(self): + """'Synthèse' suivi d'un saut de ligne (sans ':') fonctionne.""" + text = "Synthèse\nBilan favorable, retour à domicile.\n\nDevenir :\nRAD" + result = parse_crh(text) + assert "synthese" in result["sections"] + assert "favorable" in result["sections"]["synthese"] + + # --- A2 : Terminaisons correctes --- + + def test_antecedents_stop_before_diag_principal(self): + """Antécédents s'arrêtent avant 'Diagnostic principal'.""" + text = """Antécédents : +HTA traitée depuis 10 ans. +Diabète type 2. + +Diagnostic principal : +Embolie pulmonaire I26.9""" + result = parse_crh(text) + assert "antecedents" in result["sections"] + assert "diag_principal" in result["sections"] + assert "Embolie" not in result["sections"]["antecedents"] + assert "Embolie" in result["sections"]["diag_principal"] + + def test_histoire_maladie_stop_before_synthese(self): + """Histoire de la maladie s'arrête avant 'Synthèse'.""" + text = """Histoire de la maladie : +Douleur abdominale depuis 3 jours. + +Synthèse : +Pancréatite aiguë biliaire confirmée.""" + result = parse_crh(text) + assert "histoire_maladie" in result["sections"] + assert "synthese" in result["sections"] + assert "confirmée" not in result["sections"]["histoire_maladie"] + + def test_examen_clinique_stop_before_diag_sortie(self): + """Examen clinique s'arrête avant 'Diagnostic de sortie'.""" + text = """Examen clinique : +Abdomen souple, pas de défense. + +Diagnostic de sortie : +Cholécystite aiguë K81.0""" + result = parse_crh(text) + assert "examen_clinique" in result["sections"] + assert "diag_sortie" in result["sections"] + assert "Cholécystite" not in result["sections"]["examen_clinique"] + + def test_diag_sortie_multiline_not_truncated(self): + """Section diag_sortie multi-lignes : contenu complet capturé.""" + text = """Diagnostic de sortie : +- SCA ST+ antérieur I25.1 +- Anémie ferriprive D50 +- HTA essentielle I10 + +Traitement de sortie : +Aspirine 100mg""" + result = parse_crh(text) + assert "diag_sortie" in result["sections"] + content = result["sections"]["diag_sortie"] + assert "I25.1" in content + assert "D50" in content + assert "I10" in content + assert len(content) > 0 + + def test_diag_principal_stop_before_diag_associes(self): + """'Diagnostic principal' s'arrête avant 'Diagnostics associés'.""" + text = """Diagnostic principal : +Pancréatite aiguë biliaire K85.1 + +Diagnostics associés : +Lithiase vésiculaire K80.2""" + result = parse_crh(text) + assert "diag_principal" in result["sections"] + content = result["sections"]["diag_principal"] + assert "Pancréatite" in content + assert "Lithiase" not in content + + # --- A3 : Cas pièges (faux positifs) --- + + def test_diagnostic_in_phrase_not_captured(self): + """'diagnostic' dans une phrase courante ne déclenche PAS une section.""" + text = """Au total : +Pas de diagnostic retenu pour l'instant. Bilan complémentaire en cours. + +Devenir : +Consultation dans 3 semaines""" + result = parse_crh(text) + # "diag_sortie" et "diag_principal" ne doivent PAS apparaître + assert "diag_sortie" not in result["sections"] + assert "diag_principal" not in result["sections"] + # Mais conclusion doit capturer le texte + assert "conclusion" in result["sections"] + + def test_synthese_in_word_not_captured(self): + """'synthèse' dans un mot composé ('biosynthèse') ne déclenche PAS la section.""" + text = """Au total : +Déficit de biosynthèse hépatique probable. + +Devenir : +Surveillance""" + result = parse_crh(text) + assert "synthese" not in result["sections"] + assert "conclusion" in result["sections"] + + class TestTrackareParser: def test_parse_patient_info(self): text = """Nom de naissance: CLIER IPP: 01306172 diff --git a/tests/test_fusion.py b/tests/test_fusion.py index b61ae8a..86ef434 100644 --- a/tests/test_fusion.py +++ b/tests/test_fusion.py @@ -6,6 +6,8 @@ from src.config import ( ActeCCAM, Diagnostic, DossierMedical, + DPCandidate, + DPSelection, Sejour, Traitement, BiologieCle, @@ -491,3 +493,161 @@ class TestSemanticDedup: das_codes = {d.cim10_suggestion for d in result.diagnostics_associes} assert "I10" not in das_codes assert "I11.9" in das_codes + + +class TestDPSelectionPropagation: + """Vérifie que dp_selection est propagée depuis le dossier source du DP retenu.""" + + def test_dp_selection_propagated_multi_dossier(self): + """Fusion 2 dossiers : dp_selection vient du dossier dont le DP est retenu.""" + sel = DPSelection( + chosen_index=0, + chosen_term="Pancréatite aiguë biliaire", + chosen_code="K85.1", + verdict="CONFIRMED", + confidence="high", + evidence=["Score 8.0 — source: regex (section forte)"], + reason="Écart net", + candidates=[DPCandidate(index=0, term="Pancréatite", code="K85.1", + section_strength=3, confidence="high")], + ) + d1 = DossierMedical( + document_type="crh", + diagnostic_principal=Diagnostic(texte="Pancréatite aiguë biliaire", + cim10_suggestion="K85.1"), + dp_selection=sel, + ) + d2 = DossierMedical( + document_type="trackare", + diagnostic_principal=Diagnostic(texte="Lithiase vésiculaire", + cim10_suggestion="K80.2"), + ) + result = merge_dossiers([d1, d2]) + # DP = K85.1 (plus spécifique) → dp_selection propagée depuis d1 + assert result.diagnostic_principal.cim10_suggestion == "K85.1" + assert result.dp_selection is not None + assert result.dp_selection.chosen_code == "K85.1" + assert result.dp_selection.verdict == "CONFIRMED" + + def test_dp_selection_none_when_no_source(self): + """Si aucun dossier n'a de dp_selection, le fusionné non plus.""" + d1 = DossierMedical( + diagnostic_principal=Diagnostic(texte="HTA", cim10_suggestion="I10"), + ) + d2 = DossierMedical( + diagnostic_principal=Diagnostic(texte="HTA", cim10_suggestion="I10"), + ) + result = merge_dossiers([d1, d2]) + assert result.dp_selection is None + + def test_dp_selection_single_dossier(self): + """Dossier unique : dp_selection est conservée via model_copy.""" + sel = DPSelection( + chosen_index=0, + chosen_term="Pneumopathie", + chosen_code="J18.9", + verdict="REVIEW", + confidence="medium", + ) + d1 = DossierMedical( + diagnostic_principal=Diagnostic(texte="Pneumopathie", cim10_suggestion="J18.9"), + dp_selection=sel, + ) + result = merge_dossiers([d1]) + assert result.dp_selection is not None + assert result.dp_selection.verdict == "REVIEW" + + def test_dp_selection_preserves_evidence_reason_verdict(self): + """Fusion multi-docs : evidence, reason et verdict sont préservés intégralement.""" + sel = DPSelection( + chosen_index=0, + chosen_term="Embolie pulmonaire", + chosen_code="I26.9", + verdict="CONFIRMED", + confidence="high", + evidence=[ + "Score 9.0 — source: edsnlp", + "Diagnostic de sortie: «EP massive bilatérale»", + "Delta +5.0 vs Thrombose (I80.2)", + ], + reason="Écart score 5.0 >= seuil 3.0", + candidates=[ + DPCandidate(index=0, term="Embolie pulmonaire", code="I26.9", + section_strength=2, confidence="high", score=9.0, + score_details={"section": 2, "confidence": 3, "diag_section_bonus": 4}), + DPCandidate(index=1, term="Thrombose veineuse", code="I80.2", + section_strength=1, confidence="high", score=4.0), + ], + debug_scores={"top1": 9.0, "top2": 4.0, "delta": 5.0}, + ) + d1 = DossierMedical( + document_type="crh", + diagnostic_principal=Diagnostic(texte="Embolie pulmonaire", cim10_suggestion="I26.9"), + dp_selection=sel, + ) + d2 = DossierMedical( + document_type="trackare", + diagnostic_principal=Diagnostic(texte="TVP", cim10_suggestion="I80.2"), + ) + result = merge_dossiers([d1, d2]) + + assert result.dp_selection is not None + rs = result.dp_selection + # Verdict/confidence/reason intacts + assert rs.verdict == "CONFIRMED" + assert rs.confidence == "high" + assert "5.0" in rs.reason + # Evidence complète (3 éléments) + assert len(rs.evidence) == 3 + assert any("Diagnostic de sortie" in e for e in rs.evidence) + assert any("Delta" in e for e in rs.evidence) + # Candidates préservés avec score_details + assert len(rs.candidates) == 2 + assert rs.candidates[0].score_details.get("diag_section_bonus") == 4 + # Debug scores + assert rs.debug_scores["delta"] == 5.0 + + def test_dp_selection_from_second_dossier(self): + """Si le DP retenu vient du 2e dossier, sa dp_selection est prise.""" + sel_d2 = DPSelection( + chosen_index=0, + chosen_term="Sepsis", + chosen_code="A41.9", + verdict="CONFIRMED", + confidence="high", + evidence=["Score 7.0"], + reason="Candidat unique", + ) + d1 = DossierMedical( + document_type="trackare", + diagnostic_principal=Diagnostic(texte="HTA", cim10_suggestion="I10"), + # Pas de dp_selection + ) + d2 = DossierMedical( + document_type="crh", + diagnostic_principal=Diagnostic(texte="Sepsis à staphylocoque", + cim10_suggestion="A41.9"), + dp_selection=sel_d2, + ) + result = merge_dossiers([d1, d2]) + # A41.9 (5 chars) > I10 (3 chars) → DP = A41.9 venant de d2 + assert result.diagnostic_principal.cim10_suggestion == "A41.9" + assert result.dp_selection is not None + assert result.dp_selection.chosen_code == "A41.9" + assert result.dp_selection.verdict == "CONFIRMED" + + def test_dp_selection_no_crash_empty_dossiers(self): + """Fusion de dossiers sans DP et sans dp_selection → pas de crash.""" + d1 = DossierMedical( + diagnostics_associes=[ + Diagnostic(texte="HTA", cim10_suggestion="I10"), + ], + ) + d2 = DossierMedical( + diagnostics_associes=[ + Diagnostic(texte="Diabète", cim10_suggestion="E11.9"), + ], + ) + result = merge_dossiers([d1, d2]) + assert result.dp_selection is None + assert result.diagnostic_principal is None