feat(pmsi): add SynthesePMSI + anti-comorbidity + motif fallback
- SynthesePMSI model + PreuveSynthese in config.py - SYNTHESE_PMSI prompt template with anti-comorbidity rules - generate_synthese_pmsi() LLM call + structured parsing - _postprocess_synthese() deterministic anti-comorbidity correction - _COMORBIDITES_BANALES (8 patterns with negative lookaheads) - _PEC_MARKERS_RE (decompensation, urgency markers) - _build_motif() 3-level cascade (mode_entree → section → lexical fallback) - 36 tests: anti-comorb, PEC markers, acute problem, postprocess, motif Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,18 +9,26 @@ from src.config import (
|
||||
DPSelection,
|
||||
DP_SCORING_WEIGHTS,
|
||||
DP_REVIEW_THRESHOLD,
|
||||
PreuveSynthese,
|
||||
SynthesePMSI,
|
||||
Sejour,
|
||||
)
|
||||
from src.medical.dp_scoring import (
|
||||
build_dp_shortlist,
|
||||
score_candidates,
|
||||
select_dp,
|
||||
generate_synthese_pmsi,
|
||||
_get_context_window,
|
||||
_is_z_code_whitelisted,
|
||||
_is_comorbidity_code,
|
||||
_has_explicit_pec_proof,
|
||||
_dedup_by_code,
|
||||
_normalize_evidence_section,
|
||||
_is_comorbidite_banale,
|
||||
_has_pec_marker,
|
||||
_has_acute_problem,
|
||||
_postprocess_synthese,
|
||||
_build_motif,
|
||||
)
|
||||
|
||||
|
||||
@@ -708,3 +716,230 @@ class TestSectionNormalization:
|
||||
def test_sections_fortes_du_dossier(self):
|
||||
"""Alias administratif observé en benchmark."""
|
||||
assert _normalize_evidence_section("sections fortes du dossier") == "autres"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Anti-comorbidité SynthesePMSI
|
||||
# ===========================================================================
|
||||
|
||||
class TestIsComorbBanale:
|
||||
"""Tests de détection des comorbidités banales."""
|
||||
|
||||
def test_hta(self):
|
||||
assert _is_comorbidite_banale("HTA") == "HTA"
|
||||
assert _is_comorbidite_banale("hypertension artérielle") == "HTA"
|
||||
|
||||
def test_diabete_stable(self):
|
||||
assert _is_comorbidite_banale("diabète de type 2") == "diabète"
|
||||
assert _is_comorbidite_banale("diabète") == "diabète"
|
||||
|
||||
def test_diabete_decompense_not_banal(self):
|
||||
"""Diabète déséquilibré ne doit PAS être considéré banal."""
|
||||
assert _is_comorbidite_banale("diabète déséquilibré") is None
|
||||
assert _is_comorbidite_banale("diabète décompensé") is None
|
||||
assert _is_comorbidite_banale("acidocétose diabétique") is None
|
||||
|
||||
def test_obesite(self):
|
||||
assert _is_comorbidite_banale("obésité") == "obésité"
|
||||
|
||||
def test_anemie_chronique(self):
|
||||
assert _is_comorbidite_banale("anémie chronique") == "anémie"
|
||||
|
||||
def test_anemie_severe_not_banal(self):
|
||||
"""Anémie sévère ne doit PAS être banale."""
|
||||
assert _is_comorbidite_banale("anémie sévère") is None
|
||||
assert _is_comorbidite_banale("anémie aiguë") is None
|
||||
|
||||
def test_bpco_stable(self):
|
||||
assert _is_comorbidite_banale("BPCO") == "BPCO"
|
||||
|
||||
def test_bpco_exacerbation_not_banal(self):
|
||||
"""BPCO exacerbée ne doit PAS être banale."""
|
||||
assert _is_comorbidite_banale("BPCO exacerbée") is None
|
||||
assert _is_comorbidite_banale("BPCO surinfectée") is None
|
||||
|
||||
def test_non_comorbidite(self):
|
||||
assert _is_comorbidite_banale("pneumothorax") is None
|
||||
assert _is_comorbidite_banale("cholécystite aiguë") is None
|
||||
assert _is_comorbidite_banale("méningite à entérovirus") is None
|
||||
|
||||
|
||||
class TestHasPecMarker:
|
||||
"""Tests des marqueurs de PEC principale."""
|
||||
|
||||
def test_hospitalise_pour(self):
|
||||
assert _has_pec_marker("diabète", "hospitalisé pour diabète déséquilibré") is True
|
||||
|
||||
def test_desequilibre(self):
|
||||
assert _has_pec_marker("diabète déséquilibré", "") is True
|
||||
|
||||
def test_acidocetose(self):
|
||||
assert _has_pec_marker("", "acidocétose diabétique") is True
|
||||
|
||||
def test_transfusion(self):
|
||||
assert _has_pec_marker("anémie", "transfusion de 2 CGR") is True
|
||||
|
||||
def test_no_marker(self):
|
||||
assert _has_pec_marker("diabète", "diabète type 2 équilibré") is False
|
||||
|
||||
def test_hta_maligne(self):
|
||||
assert _has_pec_marker("HTA maligne", "") is True
|
||||
|
||||
|
||||
class TestHasAcuteProblem:
|
||||
"""Tests de détection de problème aigu."""
|
||||
|
||||
def test_with_diagnostic_retenu(self):
|
||||
result = {
|
||||
"diagnostic_retenu": "pneumothorax spontané",
|
||||
"complications": [],
|
||||
"actes_ou_traitements_majeurs": [],
|
||||
}
|
||||
assert _has_acute_problem(result) is True
|
||||
|
||||
def test_with_complications(self):
|
||||
result = {
|
||||
"diagnostic_retenu": "",
|
||||
"complications": ["insuffisance rénale aiguë"],
|
||||
"actes_ou_traitements_majeurs": [],
|
||||
}
|
||||
assert _has_acute_problem(result) is True
|
||||
|
||||
def test_with_surgical_acte(self):
|
||||
result = {
|
||||
"diagnostic_retenu": "",
|
||||
"complications": [],
|
||||
"actes_ou_traitements_majeurs": ["cholécystectomie"],
|
||||
}
|
||||
assert _has_acute_problem(result) is True
|
||||
|
||||
def test_only_surveillance(self):
|
||||
result = {
|
||||
"diagnostic_retenu": "",
|
||||
"complications": [],
|
||||
"actes_ou_traitements_majeurs": ["surveillance", "bilan biologique"],
|
||||
}
|
||||
assert _has_acute_problem(result) is False
|
||||
|
||||
def test_diag_retenu_is_comorb(self):
|
||||
"""Si diagnostic_retenu est aussi une comorbidité banale, pas de problème aigu via ce champ."""
|
||||
result = {
|
||||
"diagnostic_retenu": "diabète",
|
||||
"complications": [],
|
||||
"actes_ou_traitements_majeurs": [],
|
||||
}
|
||||
assert _has_acute_problem(result) is False
|
||||
|
||||
|
||||
class TestPostprocessSynthese:
|
||||
"""Tests du post-traitement anti-comorbidité."""
|
||||
|
||||
def test_non_comorbidite_untouched(self):
|
||||
"""Un problème non-banal ne doit pas être modifié."""
|
||||
result = {
|
||||
"probleme_pris_en_charge": "pneumothorax spontané",
|
||||
"diagnostic_retenu": "pneumothorax spontané",
|
||||
"terrain_comorbidites": ["HTA"],
|
||||
"complications": [],
|
||||
"actes_ou_traitements_majeurs": ["drainage"],
|
||||
"preuves": [],
|
||||
}
|
||||
processed = _postprocess_synthese(result, "texte")
|
||||
assert processed["probleme_pris_en_charge"] == "pneumothorax spontané"
|
||||
|
||||
def test_comorbidite_with_acute_problem_promoted(self):
|
||||
"""Comorbidité banale + problème aigu → diagnostic retenu promu."""
|
||||
result = {
|
||||
"probleme_pris_en_charge": "diabète",
|
||||
"diagnostic_retenu": "décompensation cardiaque globale",
|
||||
"terrain_comorbidites": [],
|
||||
"complications": [],
|
||||
"actes_ou_traitements_majeurs": ["diurétiques IV"],
|
||||
"preuves": [],
|
||||
}
|
||||
processed = _postprocess_synthese(result, "texte complet")
|
||||
assert processed["probleme_pris_en_charge"] == "décompensation cardiaque globale"
|
||||
assert "diabète" in processed["terrain_comorbidites"]
|
||||
|
||||
def test_comorbidite_with_pec_marker_kept(self):
|
||||
"""Comorbidité banale avec marqueur PEC → conservée."""
|
||||
result = {
|
||||
"probleme_pris_en_charge": "diabète",
|
||||
"diagnostic_retenu": "diabète déséquilibré",
|
||||
"terrain_comorbidites": [],
|
||||
"complications": [],
|
||||
"actes_ou_traitements_majeurs": [],
|
||||
"preuves": [],
|
||||
}
|
||||
context = "hospitalisé pour diabète déséquilibré avec insulinothérapie IV"
|
||||
processed = _postprocess_synthese(result, context)
|
||||
# Marqueur "hospitalisé pour" + "déséquilibré" trouvé → conservé
|
||||
assert processed["probleme_pris_en_charge"] == "diabète"
|
||||
|
||||
def test_comorbidite_no_acute_indeterminate(self):
|
||||
"""Comorbidité banale sans aigu ni marqueur → indéterminé."""
|
||||
result = {
|
||||
"probleme_pris_en_charge": "HTA",
|
||||
"diagnostic_retenu": "",
|
||||
"terrain_comorbidites": [],
|
||||
"complications": [],
|
||||
"actes_ou_traitements_majeurs": [],
|
||||
"preuves": [],
|
||||
}
|
||||
processed = _postprocess_synthese(result, "texte")
|
||||
assert "indéterminé" in processed["probleme_pris_en_charge"]
|
||||
assert "HTA" in processed["terrain_comorbidites"]
|
||||
|
||||
def test_proof_added_on_correction(self):
|
||||
"""Une preuve de post-traitement est ajoutée lors de correction."""
|
||||
result = {
|
||||
"probleme_pris_en_charge": "diabète",
|
||||
"diagnostic_retenu": "pneumopathie bactérienne",
|
||||
"terrain_comorbidites": [],
|
||||
"complications": [],
|
||||
"actes_ou_traitements_majeurs": ["antibiothérapie IV"],
|
||||
"preuves": [],
|
||||
}
|
||||
processed = _postprocess_synthese(result, "texte")
|
||||
sections = [p["section"] for p in processed["preuves"]]
|
||||
assert "post-traitement" in sections
|
||||
|
||||
|
||||
class TestBuildMotifFallback:
|
||||
"""Tests du fallback motif admission."""
|
||||
|
||||
def test_mode_entree_priority(self):
|
||||
"""Le mode_entree du séjour a priorité."""
|
||||
parsed = _make_parsed()
|
||||
dossier = DossierMedical()
|
||||
dossier.sejour = Sejour(mode_entree="Urgences")
|
||||
assert _build_motif(parsed, dossier) == "Urgences"
|
||||
|
||||
def test_section_motif_hospitalisation(self):
|
||||
"""Section motif_hospitalisation utilisée si mode_entree vide."""
|
||||
parsed = _make_parsed(sections={"motif_hospitalisation": "Douleur thoracique"})
|
||||
dossier = DossierMedical()
|
||||
assert _build_motif(parsed, dossier) == "Douleur thoracique"
|
||||
|
||||
def test_fallback_lexical_conclusion(self):
|
||||
"""Fallback lexical sur la conclusion."""
|
||||
parsed = _make_parsed(sections={
|
||||
"conclusion": "Patient hospitalisé pour pneumothorax spontané."
|
||||
})
|
||||
dossier = DossierMedical()
|
||||
result = _build_motif(parsed, dossier)
|
||||
assert "pneumothorax" in result.lower()
|
||||
|
||||
def test_fallback_lexical_full_text(self):
|
||||
"""Fallback lexical sur le texte complet."""
|
||||
parsed = _make_parsed()
|
||||
dossier = DossierMedical()
|
||||
text = "Compte-rendu\nMotif d'hospitalisation : ictère choléstatique.\nExamen..."
|
||||
result = _build_motif(parsed, dossier, full_text=text)
|
||||
assert "ictère" in result.lower()
|
||||
|
||||
def test_non_renseigne_when_nothing(self):
|
||||
"""Pas de motif trouvé → 'Non renseigné'."""
|
||||
parsed = _make_parsed()
|
||||
dossier = DossierMedical()
|
||||
assert _build_motif(parsed, dossier) == "Non renseigné"
|
||||
|
||||
Reference in New Issue
Block a user