tests: CRH sections + DP diag bonus + case 74 regression + fusion propagation

- 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 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-24 13:28:54 +01:00
parent 2701efb1d2
commit 07c267539c
7 changed files with 1383 additions and 0 deletions

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

811
tests/test_dp_selector.py Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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