feat: scoring DP déterministe + parser CPAM nouveau format + sections CRH
- Nouveau module dp_scoring.py : shortlist, scoring multi-critères, select_dp, LLM one-shot fallback avec garde-fous (négation, comorbidité, Z/R-codes) - Parser CPAM : auto-détection format legacy/ucr_extract, 6 nouveaux champs ControleCPAM (codes_etablissement, libelle, codes_retenus, ghm_ghs) - CRH parser : 3 nouvelles sections (diag_sortie, diag_principal, synthese) - Prompt DP_LLM_ONESHOT externalisé dans templates.py - Propagation dp_selection dans fusion.py - 808 tests passent (dont 21 nouveaux CPAM + 77 dp_scoring + 8 CRH) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,13 +9,32 @@ import pytest
|
||||
from src.config import ControleCPAM
|
||||
from src.control.cpam_parser import match_dossier_ogc, parse_cpam_excel
|
||||
|
||||
# En-têtes
|
||||
_LEGACY_HEADER = ("N° OGC", "Titre", "Arg_UCR", "Décision_UCR", "DP_UCR", "DA_UCR", "DR_UCR", "Actes_UCR")
|
||||
_NEW_HEADER = (
|
||||
"N° OGC", "Type désaccord", "Codes Établissement", "Libellé Établissement",
|
||||
"Codes Contrôleurs", "Libellé Contrôleurs", "Décision UCR", "Codes retenus",
|
||||
"GHM / GHS", "Texte décision",
|
||||
)
|
||||
|
||||
|
||||
def _create_test_xlsx(rows: list[tuple], path: Path) -> None:
|
||||
"""Crée un fichier xlsx de test avec les lignes données."""
|
||||
"""Crée un fichier xlsx de test au format legacy."""
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "OGC Contrôle T2A"
|
||||
ws.append(("N° OGC", "Titre", "Arg_UCR", "Décision_UCR", "DP_UCR", "DA_UCR", "DR_UCR", "Actes_UCR"))
|
||||
ws.append(_LEGACY_HEADER)
|
||||
for row in rows:
|
||||
ws.append(row)
|
||||
wb.save(path)
|
||||
|
||||
|
||||
def _create_new_format_xlsx(rows: list[tuple], path: Path) -> None:
|
||||
"""Crée un fichier xlsx de test au format ucr_extract (nouveau)."""
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "UCR Extract"
|
||||
ws.append(_NEW_HEADER)
|
||||
for row in rows:
|
||||
ws.append(row)
|
||||
wb.save(path)
|
||||
@@ -128,3 +147,292 @@ class TestControleCPAMModel:
|
||||
assert ctrl.numero_ogc == 21
|
||||
assert ctrl.contre_argumentation == "Ma réponse"
|
||||
assert ctrl.sources_reponse == []
|
||||
|
||||
def test_new_fields_defaults(self):
|
||||
"""Les 6 nouveaux champs ucr_extract sont None par défaut."""
|
||||
ctrl = ControleCPAM(numero_ogc=1)
|
||||
assert ctrl.codes_etablissement is None
|
||||
assert ctrl.libelle_etablissement is None
|
||||
assert ctrl.codes_controleurs is None
|
||||
assert ctrl.libelle_controleurs is None
|
||||
assert ctrl.codes_retenus is None
|
||||
assert ctrl.ghm_ghs is None
|
||||
|
||||
def test_new_fields_serialization(self):
|
||||
"""Les champs ucr_extract apparaissent dans model_dump."""
|
||||
ctrl = ControleCPAM(
|
||||
numero_ogc=10,
|
||||
titre="Désaccord sur le DP",
|
||||
codes_etablissement="K85.1",
|
||||
libelle_etablissement="Pancréatite aiguë biliaire",
|
||||
codes_controleurs="K85.9",
|
||||
libelle_controleurs="Pancréatite aiguë, sans précision",
|
||||
codes_retenus="K85.1",
|
||||
ghm_ghs="06M091 / 1854",
|
||||
)
|
||||
data = ctrl.model_dump()
|
||||
assert data["codes_etablissement"] == "K85.1"
|
||||
assert data["libelle_etablissement"] == "Pancréatite aiguë biliaire"
|
||||
assert data["codes_controleurs"] == "K85.9"
|
||||
assert data["libelle_controleurs"] == "Pancréatite aiguë, sans précision"
|
||||
assert data["codes_retenus"] == "K85.1"
|
||||
assert data["ghm_ghs"] == "06M091 / 1854"
|
||||
|
||||
|
||||
class TestParseNewFormat:
|
||||
"""Tests pour le format ucr_extract (nouveau)."""
|
||||
|
||||
def test_parse_basic_dp(self, tmp_path):
|
||||
"""Parsing basique — désaccord DP avec Codes Contrôleurs."""
|
||||
xlsx = tmp_path / "new.xlsx"
|
||||
_create_new_format_xlsx([
|
||||
# N° OGC, Type, Codes Étab, Lib Étab, Codes Ctrl, Lib Ctrl, Décision, Codes ret, GHM, Texte
|
||||
(17, "DP", "K85.1", "Pancréatite aiguë biliaire", "K85.9",
|
||||
"Pancréatite aiguë SAI", "Défavorable", "K85.9", "06M091 / 1854",
|
||||
"Le contrôleur ne retient pas K85.1"),
|
||||
], xlsx)
|
||||
|
||||
result = parse_cpam_excel(xlsx)
|
||||
|
||||
assert 17 in result
|
||||
ctrl = result[17][0]
|
||||
assert ctrl.numero_ogc == 17
|
||||
assert ctrl.titre == "Désaccord sur le DP"
|
||||
assert ctrl.dp_ucr == "K85.9"
|
||||
assert ctrl.da_ucr is None
|
||||
assert ctrl.arg_ucr == "Le contrôleur ne retient pas K85.1"
|
||||
assert ctrl.decision_ucr == "UCR confirme avis médecins contrôleurs"
|
||||
|
||||
def test_parse_basic_das(self, tmp_path):
|
||||
"""Parsing — désaccord DAS."""
|
||||
xlsx = tmp_path / "new.xlsx"
|
||||
_create_new_format_xlsx([
|
||||
(21, "DAS", "E11.40,G63.2", "Diabète+neuropathie", "E11.40",
|
||||
"Diabète type 2", "Favorable", "E11.40,G63.2", None,
|
||||
"L'UCR retient les codes initiaux"),
|
||||
], xlsx)
|
||||
|
||||
result = parse_cpam_excel(xlsx)
|
||||
|
||||
ctrl = result[21][0]
|
||||
assert ctrl.titre == "Désaccord sur les DAS"
|
||||
assert ctrl.dp_ucr is None
|
||||
assert ctrl.da_ucr == "E11.40"
|
||||
assert ctrl.decision_ucr == "UCR retient"
|
||||
|
||||
def test_parse_dp_plus_das(self, tmp_path):
|
||||
"""DP+DAS : premier code → dp_ucr, reste → da_ucr."""
|
||||
xlsx = tmp_path / "new.xlsx"
|
||||
_create_new_format_xlsx([
|
||||
(30, "DP+DAS", "K85.1,E11.40", "...", "K85.9,G63.2,I10",
|
||||
"...", "Défavorable", "K85.9,G63.2,I10", None, "Texte"),
|
||||
], xlsx)
|
||||
|
||||
result = parse_cpam_excel(xlsx)
|
||||
|
||||
ctrl = result[30][0]
|
||||
assert ctrl.titre == "Désaccord sur le DP et les DAS"
|
||||
assert ctrl.dp_ucr == "K85.9"
|
||||
assert ctrl.da_ucr == "G63.2,I10"
|
||||
|
||||
def test_parse_dp_plus_das_single_code(self, tmp_path):
|
||||
"""DP+DAS avec un seul code → tout en dp_ucr, pas de da_ucr."""
|
||||
xlsx = tmp_path / "new.xlsx"
|
||||
_create_new_format_xlsx([
|
||||
(31, "DP+DAS", "K85.1", "...", "K85.9",
|
||||
"...", "Favorable", None, None, "Texte"),
|
||||
], xlsx)
|
||||
|
||||
result = parse_cpam_excel(xlsx)
|
||||
|
||||
ctrl = result[31][0]
|
||||
assert ctrl.dp_ucr == "K85.9"
|
||||
assert ctrl.da_ucr is None
|
||||
|
||||
def test_new_fields_populated(self, tmp_path):
|
||||
"""Les 6 champs enrichis sont bien remplis depuis les colonnes."""
|
||||
xlsx = tmp_path / "new.xlsx"
|
||||
_create_new_format_xlsx([
|
||||
(42, "DP", "E11.40", "Diabète type 2 avec complications",
|
||||
"E11.9", "Diabète type 2 sans complication",
|
||||
"Défavorable", "E11.9", "05M092 / 1780", "Argumentation contrôleur"),
|
||||
], xlsx)
|
||||
|
||||
result = parse_cpam_excel(xlsx)
|
||||
|
||||
ctrl = result[42][0]
|
||||
assert ctrl.codes_etablissement == "E11.40"
|
||||
assert ctrl.libelle_etablissement == "Diabète type 2 avec complications"
|
||||
assert ctrl.codes_controleurs == "E11.9"
|
||||
assert ctrl.libelle_controleurs == "Diabète type 2 sans complication"
|
||||
assert ctrl.codes_retenus == "E11.9"
|
||||
assert ctrl.ghm_ghs == "05M092 / 1780"
|
||||
|
||||
def test_decision_favorable(self, tmp_path):
|
||||
"""Favorable → 'UCR retient'."""
|
||||
xlsx = tmp_path / "new.xlsx"
|
||||
_create_new_format_xlsx([
|
||||
(10, "DP", None, None, None, None, "Favorable", None, None, "OK"),
|
||||
], xlsx)
|
||||
|
||||
result = parse_cpam_excel(xlsx)
|
||||
assert result[10][0].decision_ucr == "UCR retient"
|
||||
|
||||
def test_decision_defavorable(self, tmp_path):
|
||||
"""Défavorable → 'UCR confirme avis médecins contrôleurs'."""
|
||||
xlsx = tmp_path / "new.xlsx"
|
||||
_create_new_format_xlsx([
|
||||
(11, "DAS", None, None, None, None, "Défavorable", None, None, "KO"),
|
||||
], xlsx)
|
||||
|
||||
result = parse_cpam_excel(xlsx)
|
||||
assert result[11][0].decision_ucr == "UCR confirme avis médecins contrôleurs"
|
||||
|
||||
def test_decision_defavorable_no_accent(self, tmp_path):
|
||||
"""Defavorable (sans accent) → même mapping."""
|
||||
xlsx = tmp_path / "new.xlsx"
|
||||
_create_new_format_xlsx([
|
||||
(12, "DP", None, None, None, None, "Defavorable", None, None, "KO"),
|
||||
], xlsx)
|
||||
|
||||
result = parse_cpam_excel(xlsx)
|
||||
assert result[12][0].decision_ucr == "UCR confirme avis médecins contrôleurs"
|
||||
|
||||
def test_decision_unknown_passthrough(self, tmp_path):
|
||||
"""Décision inconnue → passée telle quelle."""
|
||||
xlsx = tmp_path / "new.xlsx"
|
||||
_create_new_format_xlsx([
|
||||
(13, "DP", None, None, None, None, "Partielle", None, None, "Texte"),
|
||||
], xlsx)
|
||||
|
||||
result = parse_cpam_excel(xlsx)
|
||||
assert result[13][0].decision_ucr == "Partielle"
|
||||
|
||||
def test_type_desaccord_unknown(self, tmp_path):
|
||||
"""Type désaccord inconnu → titre 'Désaccord : XXX'."""
|
||||
xlsx = tmp_path / "new.xlsx"
|
||||
_create_new_format_xlsx([
|
||||
(14, "Actes", None, None, None, None, "Favorable", None, None, "Texte"),
|
||||
], xlsx)
|
||||
|
||||
result = parse_cpam_excel(xlsx)
|
||||
assert result[14][0].titre == "Désaccord : Actes"
|
||||
|
||||
def test_type_desaccord_empty(self, tmp_path):
|
||||
"""Type désaccord vide → titre vide."""
|
||||
xlsx = tmp_path / "new.xlsx"
|
||||
_create_new_format_xlsx([
|
||||
(15, "", None, None, None, None, "Favorable", None, None, "Texte"),
|
||||
], xlsx)
|
||||
|
||||
result = parse_cpam_excel(xlsx)
|
||||
assert result[15][0].titre == ""
|
||||
|
||||
def test_multiple_ogc_new_format(self, tmp_path):
|
||||
"""Plusieurs OGC dans le nouveau format."""
|
||||
xlsx = tmp_path / "new.xlsx"
|
||||
_create_new_format_xlsx([
|
||||
(10, "DP", None, None, "K85.9", None, "Favorable", None, None, "Arg 1"),
|
||||
(20, "DAS", None, None, "E11.40", None, "Défavorable", None, None, "Arg 2"),
|
||||
(10, "DAS", None, None, "G63.2", None, "Favorable", None, None, "Arg 3"),
|
||||
], xlsx)
|
||||
|
||||
result = parse_cpam_excel(xlsx)
|
||||
|
||||
assert len(result) == 2
|
||||
assert len(result[10]) == 2
|
||||
assert len(result[20]) == 1
|
||||
assert result[10][0].dp_ucr == "K85.9"
|
||||
assert result[10][1].da_ucr == "G63.2"
|
||||
|
||||
def test_empty_new_format(self, tmp_path):
|
||||
"""Fichier nouveau format vide (seulement en-têtes)."""
|
||||
xlsx = tmp_path / "new.xlsx"
|
||||
_create_new_format_xlsx([], xlsx)
|
||||
|
||||
result = parse_cpam_excel(xlsx)
|
||||
assert result == {}
|
||||
|
||||
def test_ogc_none_skipped(self, tmp_path):
|
||||
"""Lignes avec N° OGC None sont ignorées."""
|
||||
xlsx = tmp_path / "new.xlsx"
|
||||
_create_new_format_xlsx([
|
||||
(None, "DP", None, None, None, None, "Favorable", None, None, "Texte"),
|
||||
(10, "DP", None, None, "K85.1", None, "Favorable", None, None, "OK"),
|
||||
], xlsx)
|
||||
|
||||
result = parse_cpam_excel(xlsx)
|
||||
assert len(result) == 1
|
||||
assert 10 in result
|
||||
|
||||
def test_ogc_invalid_skipped(self, tmp_path):
|
||||
"""N° OGC non-numérique est ignoré."""
|
||||
xlsx = tmp_path / "new.xlsx"
|
||||
_create_new_format_xlsx([
|
||||
("ABC", "DP", None, None, None, None, "Favorable", None, None, "Texte"),
|
||||
], xlsx)
|
||||
|
||||
result = parse_cpam_excel(xlsx)
|
||||
assert result == {}
|
||||
|
||||
|
||||
class TestAutoDetection:
|
||||
"""Tests pour l'auto-détection du format."""
|
||||
|
||||
def test_detects_legacy(self, tmp_path):
|
||||
"""Format legacy détecté par ses en-têtes."""
|
||||
xlsx = tmp_path / "legacy.xlsx"
|
||||
_create_test_xlsx([
|
||||
(17, "Titre", "Arg", "Décision", None, None, None, None),
|
||||
], xlsx)
|
||||
|
||||
result = parse_cpam_excel(xlsx)
|
||||
assert 17 in result
|
||||
assert result[17][0].titre == "Titre"
|
||||
|
||||
def test_detects_new(self, tmp_path):
|
||||
"""Format nouveau détecté par ses en-têtes."""
|
||||
xlsx = tmp_path / "new.xlsx"
|
||||
_create_new_format_xlsx([
|
||||
(17, "DP", "K85.1", "Label", "K85.9", "Label2",
|
||||
"Favorable", "K85.1", None, "Texte"),
|
||||
], xlsx)
|
||||
|
||||
result = parse_cpam_excel(xlsx)
|
||||
assert 17 in result
|
||||
assert result[17][0].titre == "Désaccord sur le DP"
|
||||
|
||||
def test_unknown_format_returns_empty(self, tmp_path):
|
||||
"""En-têtes non reconnues → dict vide."""
|
||||
xlsx = tmp_path / "unknown.xlsx"
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append(("Col1", "Col2", "Col3"))
|
||||
ws.append((1, "val", "val"))
|
||||
wb.save(xlsx)
|
||||
|
||||
result = parse_cpam_excel(xlsx)
|
||||
assert result == {}
|
||||
|
||||
def test_new_format_priority_over_legacy(self, tmp_path):
|
||||
"""Si les deux jeux de colonnes sont présents, le nouveau format prime."""
|
||||
xlsx = tmp_path / "both.xlsx"
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
# En-têtes contenant les deux formats
|
||||
ws.append((
|
||||
"N° OGC", "Titre", "Arg_UCR", "Décision_UCR",
|
||||
"Type désaccord", "Décision UCR", "Texte décision",
|
||||
"DP_UCR", "DA_UCR", "DR_UCR", "Actes_UCR",
|
||||
))
|
||||
ws.append((17, "Titre", "Arg", "Déc legacy", "DP", "Favorable", "Texte nouveau",
|
||||
"K85.1", None, None, None))
|
||||
wb.save(xlsx)
|
||||
|
||||
result = parse_cpam_excel(xlsx)
|
||||
|
||||
assert 17 in result
|
||||
# Le nouveau format est prioritaire → titre construit depuis Type désaccord
|
||||
assert result[17][0].titre == "Désaccord sur le DP"
|
||||
# arg_ucr vient de Texte décision (nouveau), pas de Arg_UCR (legacy)
|
||||
assert result[17][0].arg_ucr == "Texte nouveau"
|
||||
|
||||
710
tests/test_dp_scoring.py
Normal file
710
tests/test_dp_scoring.py
Normal file
@@ -0,0 +1,710 @@
|
||||
"""Tests pour le module de scoring DP (Diagnostic Principal)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.config import (
|
||||
DossierMedical,
|
||||
Diagnostic,
|
||||
DPCandidate,
|
||||
DPSelection,
|
||||
DP_SCORING_WEIGHTS,
|
||||
DP_REVIEW_THRESHOLD,
|
||||
Sejour,
|
||||
)
|
||||
from src.medical.dp_scoring import (
|
||||
build_dp_shortlist,
|
||||
score_candidates,
|
||||
select_dp,
|
||||
_get_context_window,
|
||||
_is_z_code_whitelisted,
|
||||
_is_comorbidity_code,
|
||||
_has_explicit_pec_proof,
|
||||
_dedup_by_code,
|
||||
_normalize_evidence_section,
|
||||
)
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
def _make_parsed(sections: dict | None = None, diagnostics: list | None = None) -> dict:
|
||||
return {
|
||||
"type": "crh",
|
||||
"patient": {"sexe": "M"},
|
||||
"sejour": {},
|
||||
"diagnostics": diagnostics or [],
|
||||
"sections": sections or {},
|
||||
}
|
||||
|
||||
|
||||
def _make_candidate(
|
||||
code: str = "K85.1",
|
||||
label: str = "Pancréatite aiguë biliaire",
|
||||
source_section: str = "diag_sortie",
|
||||
**kwargs,
|
||||
) -> DPCandidate:
|
||||
return DPCandidate(code=code, label=label, source_section=source_section, **kwargs)
|
||||
|
||||
|
||||
# === Tests build_dp_shortlist ===
|
||||
|
||||
class TestBuildDPShortlist:
|
||||
def test_from_diag_sortie_with_cim10_code(self):
|
||||
parsed = _make_parsed(sections={
|
||||
"diag_sortie": "Pancréatite aiguë biliaire K85.1",
|
||||
})
|
||||
dossier = DossierMedical()
|
||||
candidates = build_dp_shortlist(parsed, "", None, dossier)
|
||||
codes = [c.code for c in candidates]
|
||||
assert "K85.1" in codes
|
||||
|
||||
def test_from_diag_principal_section(self):
|
||||
parsed = _make_parsed(sections={
|
||||
"diag_principal": "Embolie pulmonaire I26.9",
|
||||
})
|
||||
dossier = DossierMedical()
|
||||
candidates = build_dp_shortlist(parsed, "", None, dossier)
|
||||
codes = [c.code for c in candidates]
|
||||
assert "I26.9" in codes
|
||||
|
||||
def test_from_conclusion_via_cim10_map(self):
|
||||
parsed = _make_parsed(sections={
|
||||
"conclusion": "pancréatite aiguë biliaire, bonne évolution",
|
||||
})
|
||||
dossier = DossierMedical()
|
||||
candidates = build_dp_shortlist(parsed, "", None, dossier)
|
||||
codes = [c.code for c in candidates]
|
||||
assert "K85.1" in codes
|
||||
|
||||
def test_from_regex_fallback(self):
|
||||
parsed = _make_parsed(sections={})
|
||||
text = "Au total : pancréatite aiguë biliaire.\nDevenir : retour."
|
||||
dossier = DossierMedical()
|
||||
candidates = build_dp_shortlist(parsed, text, None, dossier)
|
||||
codes = [c.code for c in candidates]
|
||||
assert "K85.1" in codes
|
||||
|
||||
def test_from_edsnlp(self):
|
||||
from src.medical.edsnlp_pipeline import EdsnlpResult, CIM10Entity
|
||||
|
||||
parsed = _make_parsed(sections={})
|
||||
edsnlp = EdsnlpResult(cim10_entities=[
|
||||
CIM10Entity(texte="douleur abdominale", code="R10.4", negation=False),
|
||||
])
|
||||
dossier = DossierMedical()
|
||||
candidates = build_dp_shortlist(parsed, "", edsnlp, dossier)
|
||||
codes = [c.code for c in candidates]
|
||||
assert "R10.4" in codes
|
||||
|
||||
def test_edsnlp_negated_excluded(self):
|
||||
from src.medical.edsnlp_pipeline import EdsnlpResult, CIM10Entity
|
||||
|
||||
parsed = _make_parsed(sections={})
|
||||
edsnlp = EdsnlpResult(cim10_entities=[
|
||||
CIM10Entity(texte="fièvre", code="R50.9", negation=True),
|
||||
])
|
||||
dossier = DossierMedical()
|
||||
candidates = build_dp_shortlist(parsed, "", edsnlp, dossier)
|
||||
codes = [c.code for c in candidates]
|
||||
assert "R50.9" not in codes
|
||||
|
||||
def test_dedup_keeps_strongest_section(self):
|
||||
"""Si le même code vient de diag_sortie et conclusion, garder diag_sortie."""
|
||||
parsed = _make_parsed(sections={
|
||||
"diag_sortie": "Pancréatite K85.1",
|
||||
"conclusion": "pancréatite K85.1 bonne évolution",
|
||||
})
|
||||
dossier = DossierMedical()
|
||||
candidates = build_dp_shortlist(parsed, "", None, dossier)
|
||||
k85_candidates = [c for c in candidates if c.code == "K85.1"]
|
||||
assert len(k85_candidates) == 1
|
||||
assert k85_candidates[0].source_section == "diag_sortie"
|
||||
|
||||
def test_empty_sections_returns_empty(self):
|
||||
parsed = _make_parsed(sections={})
|
||||
dossier = DossierMedical()
|
||||
candidates = build_dp_shortlist(parsed, "Patient en bon état.", None, dossier)
|
||||
assert candidates == []
|
||||
|
||||
|
||||
# === Tests score_candidates ===
|
||||
|
||||
class TestScoreCandidates:
|
||||
def test_section_bonus_diag_sortie(self):
|
||||
c = _make_candidate(source_section="diag_sortie")
|
||||
scored = score_candidates([c], DossierMedical())
|
||||
assert scored[0].score_details.get("section") == DP_SCORING_WEIGHTS["section_diag_sortie"]
|
||||
|
||||
def test_section_bonus_conclusion(self):
|
||||
c = _make_candidate(source_section="conclusion")
|
||||
scored = score_candidates([c], DossierMedical())
|
||||
assert scored[0].score_details.get("section") == DP_SCORING_WEIGHTS["section_conclusion"]
|
||||
|
||||
def test_section_bonus_edsnlp(self):
|
||||
c = _make_candidate(source_section="edsnlp")
|
||||
scored = score_candidates([c], DossierMedical())
|
||||
assert scored[0].score_details.get("section") == DP_SCORING_WEIGHTS["section_edsnlp"]
|
||||
|
||||
def test_proof_excerpt_bonus(self):
|
||||
c = _make_candidate(source_excerpt="Pancréatite aiguë biliaire confirmée au scanner")
|
||||
scored = score_candidates([c], DossierMedical())
|
||||
assert scored[0].score_details.get("proof_excerpt") == DP_SCORING_WEIGHTS["proof_excerpt"]
|
||||
|
||||
def test_no_proof_bonus_without_excerpt(self):
|
||||
c = _make_candidate(source_excerpt=None)
|
||||
scored = score_candidates([c], DossierMedical())
|
||||
assert "proof_excerpt" not in scored[0].score_details
|
||||
|
||||
def test_negation_penalty(self):
|
||||
c = _make_candidate(label="Fièvre")
|
||||
text = "Pas de fièvre constatée."
|
||||
scored = score_candidates([c], DossierMedical(), full_text=text)
|
||||
assert scored[0].is_negated is True
|
||||
assert scored[0].score_details.get("negation") == DP_SCORING_WEIGHTS["negation"]
|
||||
|
||||
def test_conditional_penalty(self):
|
||||
c = _make_candidate(label="Embolie pulmonaire", code="I26.9")
|
||||
text = "Embolie pulmonaire suspectée, à confirmer par angioscanner."
|
||||
scored = score_candidates([c], DossierMedical(), full_text=text)
|
||||
assert scored[0].is_conditional is True
|
||||
assert scored[0].score_details.get("conditional") == DP_SCORING_WEIGHTS["conditional"]
|
||||
|
||||
def test_z_code_penalty(self):
|
||||
c = _make_candidate(code="Z76.0", label="Bilan de santé", source_section="conclusion")
|
||||
scored = score_candidates([c], DossierMedical())
|
||||
assert scored[0].score_details.get("z_code_dp") == DP_SCORING_WEIGHTS["z_code_dp"]
|
||||
|
||||
def test_z_code_whitelist_no_penalty(self):
|
||||
c = _make_candidate(code="Z51.1", label="Chimiothérapie", source_section="conclusion")
|
||||
scored = score_candidates([c], DossierMedical())
|
||||
assert "z_code_dp" not in scored[0].score_details
|
||||
|
||||
def test_r_code_penalty(self):
|
||||
c = _make_candidate(code="R10.4", label="Douleur abdominale", source_section="edsnlp")
|
||||
scored = score_candidates([c], DossierMedical())
|
||||
assert scored[0].score_details.get("r_code_dp") == DP_SCORING_WEIGHTS["r_code_dp"]
|
||||
|
||||
def test_sort_by_score_descending(self):
|
||||
c1 = _make_candidate(code="K85.1", source_section="diag_sortie")
|
||||
c2 = _make_candidate(code="R10.4", label="Douleur", source_section="edsnlp")
|
||||
scored = score_candidates([c2, c1], DossierMedical())
|
||||
assert scored[0].code == "K85.1" # diag_sortie score > edsnlp
|
||||
|
||||
def test_combined_scoring(self):
|
||||
"""Score = section bonus + proof - negation penalties."""
|
||||
c = _make_candidate(
|
||||
code="K85.1",
|
||||
source_section="diag_sortie",
|
||||
source_excerpt="Pancréatite aiguë",
|
||||
)
|
||||
scored = score_candidates([c], DossierMedical())
|
||||
expected = DP_SCORING_WEIGHTS["section_diag_sortie"] + DP_SCORING_WEIGHTS["proof_excerpt"]
|
||||
assert scored[0].score == expected
|
||||
|
||||
|
||||
# === Tests select_dp ===
|
||||
|
||||
class TestSelectDP:
|
||||
def test_no_candidates_returns_review(self):
|
||||
sel = select_dp([], DossierMedical())
|
||||
assert sel.verdict == "review"
|
||||
|
||||
def test_single_candidate_confirmed(self):
|
||||
c = _make_candidate()
|
||||
c.score = 6
|
||||
sel = select_dp([c], DossierMedical())
|
||||
assert sel.verdict == "confirmed"
|
||||
assert sel.winner_reason == "candidat unique"
|
||||
|
||||
def test_clear_winner_confirmed(self):
|
||||
c1 = _make_candidate(code="K85.1")
|
||||
c1.score = 6
|
||||
c2 = _make_candidate(code="R10.4", label="Douleur", source_section="edsnlp")
|
||||
c2.score = 1
|
||||
sel = select_dp([c1, c2], DossierMedical())
|
||||
assert sel.verdict == "confirmed"
|
||||
assert "delta" in sel.winner_reason
|
||||
|
||||
def test_close_scores_returns_review(self):
|
||||
c1 = _make_candidate(code="K85.1")
|
||||
c1.score = 3
|
||||
c2 = _make_candidate(code="K80.5", label="Lithiase", source_section="conclusion")
|
||||
c2.score = 2
|
||||
sel = select_dp([c1, c2], DossierMedical())
|
||||
assert sel.verdict == "review"
|
||||
|
||||
def test_review_returns_top3(self):
|
||||
candidates = [
|
||||
_make_candidate(code=f"K8{i}.{i}", label=f"Diag {i}")
|
||||
for i in range(5)
|
||||
]
|
||||
for i, c in enumerate(candidates):
|
||||
c.score = 5 - i
|
||||
# delta between top1 and top2 = 1, < DP_REVIEW_THRESHOLD
|
||||
sel = select_dp(candidates, DossierMedical())
|
||||
assert sel.verdict == "review"
|
||||
assert len(sel.candidates) <= 3
|
||||
|
||||
|
||||
# === Tests utilitaires ===
|
||||
|
||||
class TestContextWindow:
|
||||
def test_finds_label_in_text(self):
|
||||
text = "Patient admis pour pancréatite aiguë biliaire confirmée."
|
||||
window = _get_context_window(text, "pancréatite aiguë", radius=50)
|
||||
assert "pancréatite" in window.lower()
|
||||
|
||||
def test_returns_empty_when_not_found(self):
|
||||
text = "Patient en bon état."
|
||||
window = _get_context_window(text, "embolie pulmonaire")
|
||||
assert window == ""
|
||||
|
||||
|
||||
class TestZCodeWhitelist:
|
||||
def test_z51_1_whitelisted(self):
|
||||
assert _is_z_code_whitelisted("Z51.1") is True
|
||||
|
||||
def test_z45_prefix_whitelisted(self):
|
||||
assert _is_z_code_whitelisted("Z45.80") is True
|
||||
|
||||
def test_z76_not_whitelisted(self):
|
||||
assert _is_z_code_whitelisted("Z76.0") is False
|
||||
|
||||
|
||||
class TestDedupByCode:
|
||||
def test_dedup_same_code_keeps_strongest(self):
|
||||
c1 = _make_candidate(code="K85.1", source_section="conclusion")
|
||||
c2 = _make_candidate(code="K85.1", source_section="diag_sortie")
|
||||
priority = ["diag_sortie", "diag_principal", "motif_hospitalisation", "conclusion", "synthese"]
|
||||
result = _dedup_by_code([c1, c2], priority)
|
||||
assert len(result) == 1
|
||||
assert result[0].source_section == "diag_sortie"
|
||||
|
||||
def test_dedup_different_codes_kept(self):
|
||||
c1 = _make_candidate(code="K85.1")
|
||||
c2 = _make_candidate(code="K80.5", label="Lithiase")
|
||||
priority = ["diag_sortie"]
|
||||
result = _dedup_by_code([c1, c2], priority)
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
# === Tests intégration légère ===
|
||||
|
||||
class TestDPScoringIntegration:
|
||||
def test_crh_with_diag_sortie_section(self):
|
||||
"""Un CRH avec section 'Diagnostic de sortie' produit un dp_selection."""
|
||||
from src.medical.cim10_extractor import extract_medical_info
|
||||
|
||||
parsed = {
|
||||
"type": "crh",
|
||||
"patient": {"sexe": "M"},
|
||||
"sejour": {},
|
||||
"diagnostics": [],
|
||||
"sections": {
|
||||
"diag_sortie": "Pancréatite aiguë biliaire K85.1",
|
||||
},
|
||||
}
|
||||
text = "Diagnostic de sortie :\nPancréatite aiguë biliaire K85.1\n\nTraitement de sortie :\nParacétamol"
|
||||
|
||||
dossier = extract_medical_info(parsed, text)
|
||||
assert dossier.diagnostic_principal is not None
|
||||
assert dossier.diagnostic_principal.cim10_suggestion == "K85.1"
|
||||
assert dossier.dp_selection is not None
|
||||
assert dossier.dp_selection.verdict == "confirmed"
|
||||
|
||||
def test_llm_fallback_confirmed_high_strong_section(self):
|
||||
"""LLM one-shot CONFIRMED : high confidence + section forte."""
|
||||
from unittest.mock import patch
|
||||
from src.medical.cim10_extractor import extract_medical_info
|
||||
|
||||
parsed = {
|
||||
"type": "crh",
|
||||
"patient": {"sexe": "M"},
|
||||
"sejour": {},
|
||||
"diagnostics": [],
|
||||
"sections": {
|
||||
"conclusion": "Pancréatite aiguë biliaire avec HTA connue.",
|
||||
},
|
||||
}
|
||||
text = "Conclusion : Pancréatite aiguë biliaire avec HTA connue."
|
||||
|
||||
mock_result = {
|
||||
"dp_code": "K85.1",
|
||||
"dp_label": "Pancréatite aiguë biliaire",
|
||||
"evidence_section": "conclusion",
|
||||
"evidence_excerpt": "Pancréatite aiguë biliaire",
|
||||
"confidence": "high",
|
||||
}
|
||||
with patch("src.medical.ollama_client.call_ollama", return_value=mock_result):
|
||||
dossier = extract_medical_info(parsed, text, use_rag=True)
|
||||
|
||||
assert dossier.dp_selection is not None
|
||||
assert dossier.dp_selection.verdict == "confirmed"
|
||||
assert dossier.diagnostic_principal is not None
|
||||
assert dossier.diagnostic_principal.cim10_suggestion == "K85.1"
|
||||
|
||||
def test_llm_fallback_confirmed_conclusion_section(self):
|
||||
"""LLM one-shot CONFIRMED : conclusion est section forte."""
|
||||
from unittest.mock import patch
|
||||
from src.medical.cim10_extractor import extract_medical_info
|
||||
|
||||
parsed = {
|
||||
"type": "crh",
|
||||
"patient": {"sexe": "M"},
|
||||
"sejour": {},
|
||||
"diagnostics": [],
|
||||
"sections": {"conclusion": "Pneumopathie avec insuffisance rénale aiguë."},
|
||||
}
|
||||
text = "Conclusion : Pneumopathie avec insuffisance rénale aiguë."
|
||||
|
||||
mock_result = {
|
||||
"dp_code": "J18.9",
|
||||
"dp_label": "Pneumopathie, sans précision",
|
||||
"evidence_section": "conclusion",
|
||||
"evidence_excerpt": "Pneumopathie avec insuffisance rénale aiguë",
|
||||
"confidence": "high",
|
||||
}
|
||||
with patch("src.medical.ollama_client.call_ollama", return_value=mock_result):
|
||||
dossier = extract_medical_info(parsed, text, use_rag=True)
|
||||
|
||||
assert dossier.dp_selection is not None
|
||||
assert dossier.dp_selection.verdict == "confirmed"
|
||||
assert dossier.diagnostic_principal is not None
|
||||
|
||||
def test_llm_fallback_review_weak_section(self):
|
||||
"""LLM one-shot REVIEW : evidence de histoire_maladie (section faible) → guardrail."""
|
||||
from unittest.mock import patch
|
||||
from src.medical.dp_scoring import llm_dp_fallback
|
||||
from src.config import DossierMedical, DPCandidate
|
||||
|
||||
parsed = {"type": "crh", "sections": {"histoire_maladie": "Dyspnée aiguë."}}
|
||||
text = "Histoire de la maladie : Dyspnée aiguë."
|
||||
dossier = DossierMedical()
|
||||
dp_candidates = [DPCandidate(code="R06.0", label="Dyspnée", source_section="edsnlp")]
|
||||
|
||||
mock_result = {
|
||||
"dp_code": "R06.0",
|
||||
"dp_label": "Dyspnée",
|
||||
"evidence_section": "histoire_maladie",
|
||||
"evidence_excerpt": "Dyspnée aiguë",
|
||||
"confidence": "high",
|
||||
}
|
||||
with patch("src.medical.ollama_client.call_ollama", return_value=mock_result):
|
||||
selection = llm_dp_fallback(parsed, text, dossier, dp_candidates=dp_candidates)
|
||||
|
||||
assert selection.verdict == "review"
|
||||
assert len(selection.candidates) >= 1
|
||||
|
||||
def test_llm_fallback_review_low_confidence(self):
|
||||
"""LLM one-shot REVIEW : confidence=medium → guardrail."""
|
||||
from unittest.mock import patch
|
||||
from src.medical.dp_scoring import llm_dp_fallback
|
||||
from src.config import DossierMedical, DPCandidate
|
||||
|
||||
parsed = {"type": "crh", "sections": {"conclusion": "HTA connue, diabète équilibré."}}
|
||||
text = "Conclusion : HTA connue, diabète équilibré."
|
||||
dossier = DossierMedical()
|
||||
dp_candidates = [DPCandidate(code="I10", label="HTA", source_section="edsnlp")]
|
||||
|
||||
mock_result = {
|
||||
"dp_code": "I10",
|
||||
"dp_label": "Hypertension essentielle",
|
||||
"evidence_section": "conclusion",
|
||||
"evidence_excerpt": "HTA connue",
|
||||
"confidence": "medium",
|
||||
}
|
||||
with patch("src.medical.ollama_client.call_ollama", return_value=mock_result):
|
||||
selection = llm_dp_fallback(parsed, text, dossier, dp_candidates=dp_candidates)
|
||||
|
||||
assert selection.verdict == "review"
|
||||
assert "confidence medium" in selection.winner_reason
|
||||
|
||||
def test_llm_fallback_guardrail_no_evidence(self):
|
||||
"""Garde-fou : LLM renvoie evidence vide → REVIEW."""
|
||||
from unittest.mock import patch
|
||||
from src.medical.dp_scoring import llm_dp_fallback
|
||||
from src.config import DossierMedical, DPCandidate
|
||||
|
||||
parsed = {"type": "crh", "sections": {"conclusion": "Pancréatite."}}
|
||||
text = "Conclusion : Pancréatite."
|
||||
dossier = DossierMedical()
|
||||
dp_candidates = [DPCandidate(code="K85.9", label="Pancréatite", source_section="edsnlp")]
|
||||
|
||||
mock_result = {
|
||||
"dp_code": "K85.9",
|
||||
"dp_label": "Pancréatite aiguë",
|
||||
"evidence_section": "conclusion",
|
||||
"evidence_excerpt": "",
|
||||
"confidence": "high",
|
||||
}
|
||||
with patch("src.medical.ollama_client.call_ollama", return_value=mock_result):
|
||||
selection = llm_dp_fallback(parsed, text, dossier, dp_candidates=dp_candidates)
|
||||
|
||||
assert selection.verdict == "review"
|
||||
|
||||
def test_llm_fallback_guardrail_comorbidity_weak_section(self):
|
||||
"""Garde-fou : HTA en section non-forte → REVIEW."""
|
||||
from unittest.mock import patch
|
||||
from src.medical.dp_scoring import llm_dp_fallback
|
||||
from src.config import DossierMedical, DPCandidate
|
||||
|
||||
parsed = {"type": "crh", "sections": {"histoire_maladie": "Patient hypertendu."}}
|
||||
text = "Histoire de la maladie : Patient hypertendu."
|
||||
dossier = DossierMedical()
|
||||
dp_candidates = [DPCandidate(code="I10", label="HTA", source_section="edsnlp")]
|
||||
|
||||
mock_result = {
|
||||
"dp_code": "I10",
|
||||
"dp_label": "Hypertension essentielle",
|
||||
"evidence_section": "histoire_maladie",
|
||||
"evidence_excerpt": "Patient hypertendu",
|
||||
"confidence": "high",
|
||||
}
|
||||
with patch("src.medical.ollama_client.call_ollama", return_value=mock_result):
|
||||
selection = llm_dp_fallback(parsed, text, dossier, dp_candidates=dp_candidates)
|
||||
|
||||
assert selection.verdict == "review"
|
||||
|
||||
def test_llm_fallback_comorbidity_in_strong_section(self):
|
||||
"""I10 en section forte + high confidence → CONFIRMED (garde-fou GF-2 ne bloque pas)."""
|
||||
from unittest.mock import patch
|
||||
from src.medical.dp_scoring import llm_dp_fallback
|
||||
from src.config import DossierMedical, DPCandidate
|
||||
|
||||
parsed = {"type": "crh", "sections": {"motif_hospitalisation": "HTA maligne."}}
|
||||
text = "Motif d'hospitalisation : HTA maligne."
|
||||
dossier = DossierMedical()
|
||||
dp_candidates = [DPCandidate(code="I10", label="HTA", source_section="edsnlp")]
|
||||
|
||||
mock_result = {
|
||||
"dp_code": "I10",
|
||||
"dp_label": "Hypertension essentielle",
|
||||
"evidence_section": "motif_hospitalisation",
|
||||
"evidence_excerpt": "HTA maligne",
|
||||
"confidence": "high",
|
||||
}
|
||||
with patch("src.medical.ollama_client.call_ollama", return_value=mock_result):
|
||||
selection = llm_dp_fallback(parsed, text, dossier, dp_candidates=dp_candidates)
|
||||
|
||||
assert selection.verdict == "confirmed"
|
||||
assert selection.candidates[0].code == "I10"
|
||||
|
||||
def test_no_llm_fallback_without_use_rag(self):
|
||||
"""Sans use_rag, le fallback LLM ne se déclenche PAS."""
|
||||
from src.medical.cim10_extractor import extract_medical_info
|
||||
|
||||
parsed = {
|
||||
"type": "crh",
|
||||
"patient": {"sexe": "M"},
|
||||
"sejour": {},
|
||||
"diagnostics": [],
|
||||
"sections": {"conclusion": "Bonne évolution."},
|
||||
}
|
||||
text = "Conclusion : Bonne évolution."
|
||||
|
||||
dossier = extract_medical_info(parsed, text, use_rag=False)
|
||||
# Sans use_rag → pas de fallback LLM → verdict review
|
||||
assert dossier.dp_selection is not None
|
||||
assert dossier.dp_selection.verdict == "review"
|
||||
|
||||
def test_trackare_dp_bypasses_scoring(self):
|
||||
"""Un Trackare avec DP codé ne déclenche PAS le scoring."""
|
||||
from src.medical.cim10_extractor import extract_medical_info
|
||||
|
||||
parsed = {
|
||||
"type": "trackare",
|
||||
"patient": {"sexe": "F"},
|
||||
"sejour": {"date_entree": "01/01/2024", "date_sortie": "05/01/2024"},
|
||||
"diagnostics": [
|
||||
{"type": "Principal", "code_cim10": "K80.5", "libelle": "Calcul des canaux biliaires"},
|
||||
],
|
||||
}
|
||||
text = "Calcul des canaux biliaires."
|
||||
|
||||
dossier = extract_medical_info(parsed, text)
|
||||
assert dossier.diagnostic_principal is not None
|
||||
assert dossier.diagnostic_principal.cim10_suggestion == "K80.5"
|
||||
assert dossier.dp_selection is None # Trackare DP, pas de scoring
|
||||
|
||||
|
||||
# === Tests comorbidité-banale DP ===
|
||||
|
||||
class TestComorbidityGuard:
|
||||
"""Règle comorbidité-banale : I10/E66.x/E78.x/E11.x/D64.9 en DP → REVIEW
|
||||
sauf preuve explicite de PEC principale."""
|
||||
|
||||
def test_is_comorbidity_expanded(self):
|
||||
"""La liste élargie couvre I10, E66.*, E78.*, E11.*, D64.9."""
|
||||
assert _is_comorbidity_code("I10") is True
|
||||
assert _is_comorbidity_code("E66.0") is True
|
||||
assert _is_comorbidity_code("E66.9") is True
|
||||
assert _is_comorbidity_code("E78.0") is True
|
||||
assert _is_comorbidity_code("E11.9") is True
|
||||
assert _is_comorbidity_code("E11.0") is True
|
||||
assert _is_comorbidity_code("D64.9") is True
|
||||
# Pas comorbidité
|
||||
assert _is_comorbidity_code("D64.0") is False
|
||||
assert _is_comorbidity_code("E10.9") is False
|
||||
assert _is_comorbidity_code("K85.1") is False
|
||||
|
||||
def test_sole_comorbidity_review(self):
|
||||
"""Candidat unique comorbidité → REVIEW (même section forte)."""
|
||||
c = _make_candidate(code="E66.0", label="Obésité", source_section="conclusion")
|
||||
c.score = 4
|
||||
c.score_details = {"section": 2, "proof_excerpt": 2, "comorbidity_weak": -3}
|
||||
sel = select_dp([c], DossierMedical())
|
||||
assert sel.verdict == "review"
|
||||
assert "comorbidité banale" in sel.winner_reason
|
||||
|
||||
def test_comorbidity_top1_multi_review(self):
|
||||
"""Comorbidité top1 parmi plusieurs → REVIEW."""
|
||||
c1 = _make_candidate(code="I10", label="Hta", source_section="motif_hospitalisation")
|
||||
c1.score = 3
|
||||
c1.score_details = {"section": 3, "comorbidity_weak": -3}
|
||||
c2 = _make_candidate(code="K85.1", label="Pancréatite", source_section="edsnlp")
|
||||
c2.score = 1
|
||||
sel = select_dp([c1, c2], DossierMedical())
|
||||
assert sel.verdict == "review"
|
||||
assert "comorbidité banale" in sel.winner_reason
|
||||
|
||||
def test_comorbidity_with_pec_proof_confirmed(self):
|
||||
"""Comorbidité + preuve PEC → CONFIRMED."""
|
||||
c = _make_candidate(code="I10", label="Hta", source_section="motif_hospitalisation")
|
||||
c.score = 3
|
||||
c.score_details = {"section": 3, "comorbidity_weak": -3, "comorbidity_pec_proof": 3}
|
||||
sel = select_dp([c], DossierMedical())
|
||||
assert sel.verdict == "confirmed"
|
||||
assert sel.winner_reason == "candidat unique"
|
||||
|
||||
def test_non_comorbidity_sole_confirmed(self):
|
||||
"""Candidat unique non-comorbidité → CONFIRMED (pas affecté)."""
|
||||
c = _make_candidate(code="K85.1", label="Pancréatite", source_section="conclusion")
|
||||
c.score = 4
|
||||
sel = select_dp([c], DossierMedical())
|
||||
assert sel.verdict == "confirmed"
|
||||
|
||||
def test_score_comorbidity_penalty_strong_section(self):
|
||||
"""Comorbidité pénalisée même en section forte (conclusion)."""
|
||||
c = _make_candidate(code="E66.0", label="Obésité", source_section="conclusion")
|
||||
scored = score_candidates([c], DossierMedical())
|
||||
assert "comorbidity_weak" in scored[0].score_details
|
||||
assert scored[0].score_details["comorbidity_weak"] == DP_SCORING_WEIGHTS["comorbidity_weak"]
|
||||
|
||||
def test_score_comorbidity_penalty_motif(self):
|
||||
"""Comorbidité pénalisée en motif_hospitalisation."""
|
||||
c = _make_candidate(code="I10", label="Hta", source_section="motif_hospitalisation")
|
||||
scored = score_candidates([c], DossierMedical())
|
||||
assert "comorbidity_weak" in scored[0].score_details
|
||||
|
||||
def test_pec_proof_detected(self):
|
||||
"""PEC proof détectée dans le texte → bonus dans score_details."""
|
||||
c = _make_candidate(code="I10", label="Hta", source_section="motif_hospitalisation")
|
||||
text = "Patient hospitalisé pour hta maligne résistante au traitement."
|
||||
scored = score_candidates([c], DossierMedical(), full_text=text)
|
||||
assert "comorbidity_pec_proof" in scored[0].score_details
|
||||
assert scored[0].score_details["comorbidity_pec_proof"] > 0
|
||||
|
||||
def test_pec_proof_not_found(self):
|
||||
"""Pas de PEC proof → pas de bonus."""
|
||||
c = _make_candidate(code="E66.0", label="Obésité", source_section="conclusion")
|
||||
text = "Patient obèse, pneumopathie communautaire."
|
||||
scored = score_candidates([c], DossierMedical(), full_text=text)
|
||||
assert "comorbidity_pec_proof" not in scored[0].score_details
|
||||
|
||||
def test_has_explicit_pec_proof_hospitalized(self):
|
||||
"""Détection 'hospitalisé pour' + label."""
|
||||
assert _has_explicit_pec_proof("hta", "Patient hospitalisé pour HTA maligne.") is True
|
||||
|
||||
def test_has_explicit_pec_proof_prise_en_charge(self):
|
||||
"""Détection 'prise en charge' + label."""
|
||||
assert _has_explicit_pec_proof("obésité", "Prise en charge de l'obésité morbide.") is True
|
||||
|
||||
def test_has_explicit_pec_proof_absent(self):
|
||||
"""Pas de PEC proof pour un label non mentionné."""
|
||||
assert _has_explicit_pec_proof("hta", "Patient admis pour douleur thoracique.") is False
|
||||
|
||||
def test_has_explicit_pec_proof_admission(self):
|
||||
"""Détection 'admission pour' + label."""
|
||||
assert _has_explicit_pec_proof("diabète", "Admission pour diabète déséquilibré.") is True
|
||||
|
||||
|
||||
class TestSectionNormalization:
|
||||
"""Tests pour _normalize_evidence_section — normalisation robuste."""
|
||||
|
||||
# --- Correspondances exactes existantes ---
|
||||
|
||||
def test_exact_conclusion(self):
|
||||
assert _normalize_evidence_section("conclusion") == "conclusion"
|
||||
|
||||
def test_exact_synthese(self):
|
||||
assert _normalize_evidence_section("synthèse") == "synthese"
|
||||
|
||||
def test_exact_motif_hospitalisation(self):
|
||||
assert _normalize_evidence_section("motif_hospitalisation") == "motif_hospitalisation"
|
||||
|
||||
# --- Nouveaux alias exacts ---
|
||||
|
||||
def test_synthese_du_sejour(self):
|
||||
assert _normalize_evidence_section("synthèse du séjour") == "synthese"
|
||||
|
||||
def test_synthese_du_sejour_ascii(self):
|
||||
assert _normalize_evidence_section("synthese du sejour") == "synthese"
|
||||
|
||||
def test_conclusions_pluriel(self):
|
||||
assert _normalize_evidence_section("conclusions") == "conclusion"
|
||||
|
||||
def test_secretariat_to_autres(self):
|
||||
assert _normalize_evidence_section("secrétariat") == "autres"
|
||||
|
||||
def test_medecine_interne_to_autres(self):
|
||||
assert _normalize_evidence_section("médecine interne") == "autres"
|
||||
|
||||
def test_sections_cliniques_to_autres(self):
|
||||
assert _normalize_evidence_section("sections cliniques") == "autres"
|
||||
|
||||
# --- Nettoyage crochets/guillemets ---
|
||||
|
||||
def test_brackets_conclusion(self):
|
||||
assert _normalize_evidence_section("[conclusion]") == "conclusion"
|
||||
|
||||
def test_brackets_motif(self):
|
||||
assert _normalize_evidence_section("[motif_hospitalisation]") == "motif_hospitalisation"
|
||||
|
||||
def test_colon_conclusion(self):
|
||||
assert _normalize_evidence_section("conclusion:") == "conclusion"
|
||||
|
||||
def test_quotes_synthese(self):
|
||||
assert _normalize_evidence_section('"synthèse"') == "synthese"
|
||||
|
||||
# --- Fallback par mots-clés ---
|
||||
|
||||
def test_keyword_conclusion_du_sejour(self):
|
||||
assert _normalize_evidence_section("conclusion du séjour") == "conclusion"
|
||||
|
||||
def test_keyword_synthese_medicale(self):
|
||||
assert _normalize_evidence_section("synthèse médicale du dossier") == "synthese"
|
||||
|
||||
def test_keyword_diagnostic_de_sortie_variant(self):
|
||||
assert _normalize_evidence_section("diagnostic(s) de sortie") == "diag_sortie"
|
||||
|
||||
def test_keyword_diagnostic_retenu_variant(self):
|
||||
assert _normalize_evidence_section("diagnostics retenus à la sortie") == "diagnostics_retenus"
|
||||
|
||||
def test_keyword_motif_admission(self):
|
||||
assert _normalize_evidence_section("motif d'admission aux urgences") == "motif_hospitalisation"
|
||||
|
||||
# --- Cas limites ---
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _normalize_evidence_section("") == ""
|
||||
|
||||
def test_none_like_empty(self):
|
||||
assert _normalize_evidence_section(" ") == ""
|
||||
|
||||
def test_unknown_section_passthrough(self):
|
||||
"""Section inconnue sans mot-clé → passthrough nettoyé."""
|
||||
result = _normalize_evidence_section("biologie")
|
||||
assert result == "biologie"
|
||||
|
||||
def test_sections_fortes_du_dossier(self):
|
||||
"""Alias administratif observé en benchmark."""
|
||||
assert _normalize_evidence_section("sections fortes du dossier") == "autres"
|
||||
@@ -109,6 +109,139 @@ de masse 34.370"""
|
||||
assert result["signes_vitaux"]["imc"] == 34.370
|
||||
|
||||
|
||||
class TestCRHParserDiagSections:
|
||||
"""Tests pour les nouvelles sections à fort signal DP."""
|
||||
|
||||
def test_parse_diag_sortie(self):
|
||||
text = """Mon cher confrère,
|
||||
Votre patient a été hospitalisé du 01/01/2024 au 05/01/2024.
|
||||
|
||||
Diagnostic de sortie :
|
||||
Pancréatite aiguë biliaire (K85.1)
|
||||
|
||||
Traitement de sortie :
|
||||
Paracétamol"""
|
||||
result = parse_crh(text)
|
||||
assert "diag_sortie" in result["sections"]
|
||||
assert "K85.1" in result["sections"]["diag_sortie"]
|
||||
|
||||
def test_parse_diagnostics_retenus(self):
|
||||
text = """Conclusion :
|
||||
Bonne évolution.
|
||||
|
||||
Diagnostics retenus :
|
||||
- Cholécystite aiguë lithiasique
|
||||
- Lithiase vésiculaire
|
||||
|
||||
Traitement de sortie :
|
||||
Paracétamol"""
|
||||
result = parse_crh(text)
|
||||
assert "diag_sortie" in result["sections"]
|
||||
assert "Cholécystite" in result["sections"]["diag_sortie"]
|
||||
|
||||
def test_parse_diag_principal(self):
|
||||
text = """Examen clinique :
|
||||
Abdomen souple.
|
||||
|
||||
Diagnostic principal :
|
||||
Embolie pulmonaire segmentaire droite
|
||||
|
||||
Diagnostics de sortie :
|
||||
EP + TVP"""
|
||||
result = parse_crh(text)
|
||||
assert "diag_principal" in result["sections"]
|
||||
assert "Embolie pulmonaire" in result["sections"]["diag_principal"]
|
||||
|
||||
def test_parse_probleme_principal(self):
|
||||
text = """Examen clinique :
|
||||
Patient stable.
|
||||
|
||||
Problème principal :
|
||||
Insuffisance cardiaque décompensée
|
||||
|
||||
Devenir : retour à domicile."""
|
||||
result = parse_crh(text)
|
||||
assert "diag_principal" in result["sections"]
|
||||
assert "Insuffisance cardiaque" in result["sections"]["diag_principal"]
|
||||
|
||||
def test_parse_synthese(self):
|
||||
text = """Examen clinique :
|
||||
RAS.
|
||||
|
||||
Synthèse :
|
||||
Patient de 75 ans hospitalisé pour AVC ischémique sylvien droit.
|
||||
|
||||
Traitement de sortie :
|
||||
Aspirine"""
|
||||
result = parse_crh(text)
|
||||
assert "synthese" in result["sections"]
|
||||
assert "AVC" in result["sections"]["synthese"]
|
||||
|
||||
def test_existing_sections_preserved(self):
|
||||
"""Les 7 sections existantes sont toujours capturées."""
|
||||
text = """pour le motif suivant:
|
||||
Pancréatite aiguë
|
||||
|
||||
Antécédents :
|
||||
HTA, diabète
|
||||
|
||||
Histoire de la maladie
|
||||
Douleur abdominale brutale
|
||||
|
||||
Examen clinique
|
||||
Abdomen défense en HCD
|
||||
|
||||
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_diag_sortie_multiline(self):
|
||||
text = """Au total :
|
||||
Bonne évolution.
|
||||
|
||||
Diagnostic de sortie :
|
||||
- Pancréatite aiguë biliaire K85.1
|
||||
- Lithiase vésiculaire K80.2
|
||||
- Obésité E66.0
|
||||
|
||||
Traitement de sortie :
|
||||
Paracétamol"""
|
||||
result = parse_crh(text)
|
||||
assert "diag_sortie" in result["sections"]
|
||||
section = result["sections"]["diag_sortie"]
|
||||
assert "K85.1" in section
|
||||
assert "K80.2" in section
|
||||
assert "E66.0" in section
|
||||
|
||||
def test_conclusion_does_not_overflow_into_diag_sortie(self):
|
||||
text = """Au total :
|
||||
Pancréatite aiguë biliaire, évolution favorable.
|
||||
|
||||
Diagnostic de sortie :
|
||||
Pancréatite aiguë biliaire K85.1
|
||||
|
||||
Traitement de sortie :
|
||||
Paracétamol"""
|
||||
result = parse_crh(text)
|
||||
assert "conclusion" in result["sections"]
|
||||
assert "diag_sortie" in result["sections"]
|
||||
# La conclusion ne doit PAS contenir le texte de diag_sortie
|
||||
assert "K85.1" not in result["sections"]["conclusion"]
|
||||
|
||||
|
||||
class TestCleanPersonName:
|
||||
def test_clean_simple(self):
|
||||
assert _clean_person_name("Sarah DUTREY") == "Sarah DUTREY"
|
||||
|
||||
@@ -653,6 +653,38 @@ class TestBackwardCompatAntecedent:
|
||||
assert all(isinstance(c, Complication) for c in dossier.complications)
|
||||
|
||||
|
||||
class TestDPSelectionIntegration:
|
||||
"""Tests d'intégration du scoring DP dans le pipeline d'extraction."""
|
||||
|
||||
def test_crh_dp_selection_populated(self):
|
||||
"""Un CRH sans DP Trackare déclenche le scoring et peuple dp_selection."""
|
||||
parsed = {
|
||||
"type": "crh",
|
||||
"patient": {"sexe": "M"},
|
||||
"sejour": {},
|
||||
"diagnostics": [],
|
||||
}
|
||||
text = "Pancréatite aiguë biliaire.\nTTT de sortie :\nParacétamol\n\nDevenir : retour."
|
||||
dossier = extract_medical_info(parsed, text)
|
||||
assert dossier.diagnostic_principal is not None
|
||||
assert dossier.diagnostic_principal.cim10_suggestion == "K85.1"
|
||||
assert dossier.dp_selection is not None
|
||||
assert len(dossier.dp_selection.candidates) >= 1
|
||||
|
||||
def test_dp_selection_serialization(self):
|
||||
"""dp_selection est sérialisable en JSON via model_dump()."""
|
||||
from src.config import DPCandidate, DPSelection
|
||||
sel = DPSelection(
|
||||
verdict="confirmed",
|
||||
candidates=[DPCandidate(code="K85.1", label="Test", source_section="regex")],
|
||||
winner_reason="candidat unique",
|
||||
)
|
||||
data = sel.model_dump()
|
||||
assert data["verdict"] == "confirmed"
|
||||
assert len(data["candidates"]) == 1
|
||||
assert data["candidates"][0]["code"] == "K85.1"
|
||||
|
||||
|
||||
class TestSourceTrackingFields:
|
||||
"""Tests que les champs source_page/source_excerpt existent sur les modèles."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user