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:
dom
2026-02-24 00:01:51 +01:00
parent aa501789fd
commit 56c38c3d98
5 changed files with 605 additions and 9 deletions

View File

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