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

@@ -154,6 +154,21 @@ class Diagnostic(BaseModel):
source_excerpt: Optional[str] = None # extrait du texte source (~200 chars) source_excerpt: Optional[str] = None # extrait du texte source (~200 chars)
class PreuveSynthese(BaseModel):
section: str
excerpt: str
class SynthesePMSI(BaseModel):
motif_admission: str = ""
probleme_pris_en_charge: str = ""
diagnostic_retenu: str = ""
actes_ou_traitements_majeurs: list[str] = Field(default_factory=list)
complications: list[str] = Field(default_factory=list)
terrain_comorbidites: list[str] = Field(default_factory=list)
preuves: list[PreuveSynthese] = Field(default_factory=list)
class DPCandidate(BaseModel): class DPCandidate(BaseModel):
code: Optional[str] = None code: Optional[str] = None
label: str label: str

View File

@@ -21,6 +21,8 @@ from ..config import (
DP_REVIEW_THRESHOLD, DP_REVIEW_THRESHOLD,
DP_SCORING_WEIGHTS, DP_SCORING_WEIGHTS,
DP_Z_CODE_WHITELIST, DP_Z_CODE_WHITELIST,
PreuveSynthese,
SynthesePMSI,
) )
from .cim10_dict import normalize_code, normalize_text, validate_code as cim10_validate from .cim10_dict import normalize_code, normalize_text, validate_code as cim10_validate
@@ -640,14 +642,60 @@ def _build_strong_sections_text(parsed: dict) -> str:
return "\n".join(parts) or "Aucune section forte" return "\n".join(parts) or "Aucune section forte"
def _build_motif(parsed: dict, dossier: DossierMedical) -> str: # Regex pour extraire le motif d'hospitalisation par fallback lexical
"""Extrait le motif d'hospitalisation pour le prompt LLM.""" _MOTIF_FALLBACK_RE = re.compile(
motif = "" r"(?:"
r"motif\s*(?:d[e']\s*)?(?:hospitalisation|admission|entrée|entree|consultation)\s*:\s*"
r"|hospitalis[ée]e?\s+pour\s+"
r"|admise?\s+pour\s+"
r"|adress[ée]e?\s+pour\s+"
r"|entr[ée]e?\s+pour\s+"
r"|consultation\s+pour\s+"
r")"
r"(.{5,200}?)(?:[.\n]|$)",
re.IGNORECASE,
)
def _build_motif(parsed: dict, dossier: DossierMedical, full_text: str = "") -> str:
"""Extrait le motif d'hospitalisation pour le prompt LLM.
Cascade :
1. dossier.sejour.mode_entree (info administrative)
2. Section motif_hospitalisation du CRH parser
3. Fallback lexical : phrases "hospitalisé pour", "motif :" dans
conclusion, synthese, diag_sortie, diagnostics_retenus, texte complet
"""
# 1. Info administrative
if dossier.sejour and dossier.sejour.mode_entree: if dossier.sejour and dossier.sejour.mode_entree:
motif = dossier.sejour.mode_entree motif = dossier.sejour.mode_entree.strip()
if not motif: if motif:
motif = parsed.get("sections", {}).get("motif_hospitalisation", "")[:300] or "Non renseigné" return motif
return motif
sections = parsed.get("sections", {})
# 2. Section dédiée
motif_section = sections.get("motif_hospitalisation", "").strip()
if motif_section:
return motif_section[:300]
# 3. Fallback lexical sur sections fortes puis texte complet
search_texts = []
for key in ("conclusion", "synthese", "diag_sortie", "diagnostics_retenus"):
val = sections.get(key, "")
if val:
search_texts.append(val)
if full_text:
search_texts.append(full_text)
for search_text in search_texts:
m = _MOTIF_FALLBACK_RE.search(search_text)
if m:
motif = m.group(1).strip().rstrip(",.;:!")
if len(motif) >= 5:
return motif[:200]
return "Non renseigné"
def _build_actes(dossier: DossierMedical) -> str: def _build_actes(dossier: DossierMedical) -> str:
@@ -777,7 +825,7 @@ def llm_dp_fallback(
return DPSelection(verdict="review", winner_reason="LLM non disponible") return DPSelection(verdict="review", winner_reason="LLM non disponible")
# Contexte # Contexte
motif = _build_motif(parsed, dossier) motif = _build_motif(parsed, dossier, full_text=text)
sections_fortes = _build_strong_sections_text(parsed) sections_fortes = _build_strong_sections_text(parsed)
actes = _build_actes(dossier) actes = _build_actes(dossier)
@@ -842,3 +890,257 @@ def llm_dp_fallback(
# Appliquer les garde-fous déterministes # Appliquer les garde-fous déterministes
return _apply_guardrails(dp_code, candidate, evidence_section, evidence_excerpt, confidence) return _apply_guardrails(dp_code, candidate, evidence_section, evidence_excerpt, confidence)
# ---------------------------------------------------------------------------
# 6. Synthèse PMSI — raisonnement clinique structuré avant codage DP
# ---------------------------------------------------------------------------
# Comorbidités banales : NE DOIVENT PAS être probleme_pris_en_charge
# sauf marqueur explicite de PEC principale (décompensation, urgence, etc.)
# Chaque entrée : (pattern_regex, label_humain)
_COMORBIDITES_BANALES: list[tuple[re.Pattern, str]] = [
(re.compile(r"\bhta\b|hypertension\s+art[ée]rielle", re.I), "HTA"),
(re.compile(r"\bdiab[èeé]te\b(?!\s+(?:d[ée]s[ée]quilibr|d[ée]comp|acido|insipide))", re.I), "diabète"),
(re.compile(r"\bob[ée]sit[ée]\b", re.I), "obésité"),
(re.compile(r"\ban[ée]mie\s+chronique\b|\ban[ée]mie\b(?!\s+(?:s[ée]v[èeé]re|aigu[ëe]|h[ée]morragique|profonde))", re.I), "anémie"),
(re.compile(r"\bdyslipid[ée]mie\b|hypercholest[ée]rol[ée]mie|hypertriglyc[ée]rid[ée]mie", re.I), "dyslipidémie"),
(re.compile(r"\binsuffisance\s+r[ée]nale\s+chronique\b|\birc\b|\bmrc\b", re.I), "IRC"),
(re.compile(r"\bbpco\b(?!\s+(?:exacerb|d[ée]comp|aigu|surinfect))", re.I), "BPCO"),
(re.compile(r"\bsaos\b|\bapn[ée]es?\s+du\s+sommeil\b", re.I), "SAOS"),
]
# Marqueurs de PEC principale qui AUTORISENT une comorbidité banale comme problème
_PEC_MARKERS_RE = re.compile(
r"(?:"
# Marqueurs génériques de PEC
r"hospitalis[ée]e?\s+pour"
r"|admission\s+pour"
r"|pris(?:e)?\s+en\s+charge\s+(?:pour|d[e'u])"
r"|motif\s+principal"
# Décompensations / urgences
r"|d[ée]s[ée]quilibr[ée]"
r"|d[ée]compens"
r"|acido[ck][ée]tose"
r"|coma\s+(?:diab|hypo|hyper)"
r"|hypoglyc[ée]mie\s+s[ée]v[èeé]re"
r"|insulinoth[ée]rapie\s+iv"
r"|urgence\s+hypertensive"
r"|hta\s+maligne"
r"|pouss[ée]e\s+hypertensive\s+s[ée]v[èeé]re"
# Anémie grave
r"|transfusion"
r"|an[ée]mie\s+s[ée]v[èeé]re"
r"|h[ée]morragie"
r"|an[ée]mie\s+aigu[ëe]"
# BPCO décompensée
r"|exacerbation"
r"|surinfection\s+bronchique"
# Insuffisance rénale aiguë
r"|insuffisance\s+r[ée]nale\s+aigu[ëe]"
r"|ira\b"
r"|dialyse\s+en\s+urgence"
r")",
re.IGNORECASE,
)
def _is_comorbidite_banale(text: str) -> str | None:
"""Retourne le label de la comorbidité banale si le texte en est une, None sinon."""
text_lower = text.lower().strip()
for pattern, label in _COMORBIDITES_BANALES:
if pattern.search(text_lower):
return label
return None
def _has_pec_marker(text: str, full_context: str) -> bool:
"""Vérifie si un marqueur de PEC principale est présent dans le contexte."""
if _PEC_MARKERS_RE.search(text):
return True
if _PEC_MARKERS_RE.search(full_context):
return True
return False
def _has_acute_problem(synthese_dict: dict) -> bool:
"""Vérifie si la synthèse contient au moins un problème aigu/actif
distinct d'une comorbidité banale (dans diagnostic_retenu, complications, actes).
"""
# Vérifier diagnostic_retenu
diag = str(synthese_dict.get("diagnostic_retenu", ""))
if diag and _is_comorbidite_banale(diag) is None and len(diag) > 3:
return True
# Vérifier complications
for c in synthese_dict.get("complications", []):
if isinstance(c, str) and c.strip():
return True
# Vérifier actes (un acte chirurgical/invasif implique un problème aigu)
actes = synthese_dict.get("actes_ou_traitements_majeurs", [])
if isinstance(actes, list) and len(actes) > 0:
for a in actes:
a_str = str(a).lower()
# Exclure les "actes" qui sont juste de la surveillance banale
if any(kw in a_str for kw in ("bilan", "surveillance", "contrôle",
"éducation", "dépistage")):
continue
return True
return False
def _postprocess_synthese(
result: dict,
full_context: str,
) -> dict:
"""Post-traitement déterministe de la synthèse LLM.
Corrige le piège comorbidité : si probleme_pris_en_charge est une
comorbidité banale ET qu'un problème aigu existe ET qu'aucun marqueur
PEC n'est trouvé → déplace la comorbidité dans terrain_comorbidites
et promeut le diagnostic_retenu ou met "indéterminé".
"""
pec = str(result.get("probleme_pris_en_charge", "")).strip()
if not pec:
return result
comorbidite_label = _is_comorbidite_banale(pec)
if comorbidite_label is None:
return result # pas une comorbidité banale → OK
# C'est une comorbidité banale. Vérifier les marqueurs PEC
if _has_pec_marker(pec, full_context):
logger.info("SynthesePMSI: comorbidité '%s' retenue (marqueur PEC trouvé)", pec)
return result
# Vérifier s'il y a un problème aigu distinct
has_acute = _has_acute_problem(result)
if has_acute:
# Promouvoir diagnostic_retenu comme probleme_pris_en_charge
diag_retenu = str(result.get("diagnostic_retenu", "")).strip()
diag_is_comorb = _is_comorbidite_banale(diag_retenu) is not None
if diag_retenu and not diag_is_comorb:
new_pec = diag_retenu
else:
new_pec = "indéterminé (comorbidité banale déplacée)"
logger.info(
"SynthesePMSI: comorbidité '%s' → terrain, promotion '%s' comme PEC",
pec, new_pec,
)
# Déplacer la comorbidité
comorbidites = result.get("terrain_comorbidites", [])
if isinstance(comorbidites, str):
comorbidites = [comorbidites] if comorbidites.strip() else []
if pec not in comorbidites:
comorbidites.append(pec)
result["terrain_comorbidites"] = comorbidites
result["probleme_pris_en_charge"] = new_pec
# Ajouter preuve de correction
preuves = result.get("preuves", [])
if isinstance(preuves, list):
preuves.append({
"section": "post-traitement",
"excerpt": f"Comorbidité banale '{pec}' déplacée : problème aigu détecté dans le dossier",
})
result["preuves"] = preuves
else:
# Aucun problème aigu → marquer indéterminé
logger.info(
"SynthesePMSI: comorbidité '%s' sans problème aigu ni marqueur PEC → indéterminé",
pec,
)
comorbidites = result.get("terrain_comorbidites", [])
if isinstance(comorbidites, str):
comorbidites = [comorbidites] if comorbidites.strip() else []
if pec not in comorbidites:
comorbidites.append(pec)
result["terrain_comorbidites"] = comorbidites
result["probleme_pris_en_charge"] = "indéterminé (aucun problème aigu identifié)"
preuves = result.get("preuves", [])
if isinstance(preuves, list):
preuves.append({
"section": "post-traitement",
"excerpt": f"Comorbidité banale '{pec}' sans marqueur de PEC principale ni problème aigu",
})
result["preuves"] = preuves
return result
def generate_synthese_pmsi(
parsed: dict,
text: str,
dossier: DossierMedical,
) -> SynthesePMSI | None:
"""Génère une synthèse clinique PMSI structurée via LLM.
Étape intermédiaire de raisonnement : le LLM produit une analyse clinique
sans codes CIM-10, séparant problème principal, comorbidités et preuves.
Returns:
SynthesePMSI si le LLM répond correctement, None sinon.
"""
try:
from .ollama_client import call_ollama
from ..prompts import SYNTHESE_PMSI
except ImportError:
logger.warning("Module ollama_client non disponible pour la synthèse PMSI")
return None
motif = _build_motif(parsed, dossier, full_text=text)
sections_fortes = _build_strong_sections_text(parsed)
actes = _build_actes(dossier)
prompt = SYNTHESE_PMSI.format(
motif=motif, sections_fortes=sections_fortes, actes=actes,
)
try:
result = call_ollama(prompt, temperature=0.0, max_tokens=1200, role="coding")
except Exception:
logger.warning("Erreur LLM synthèse PMSI", exc_info=True)
return None
if not result or not isinstance(result, dict):
logger.warning("Réponse LLM synthèse PMSI invalide : %s", type(result))
return None
# Post-traitement déterministe : anti-comorbidité + corrections
result = _postprocess_synthese(result, text)
# Parser les preuves
preuves_raw = result.get("preuves", [])
preuves: list[PreuveSynthese] = []
if isinstance(preuves_raw, list):
for p in preuves_raw:
if isinstance(p, dict) and p.get("section") and p.get("excerpt"):
preuves.append(PreuveSynthese(
section=str(p["section"]).strip(),
excerpt=str(p["excerpt"]).strip()[:300],
))
# Normaliser les listes (robustesse si le LLM renvoie une string)
def _to_list(val) -> list[str]:
if isinstance(val, list):
return [str(v).strip() for v in val if v]
if isinstance(val, str) and val.strip():
return [val.strip()]
return []
return SynthesePMSI(
motif_admission=str(result.get("motif_admission", "")).strip(),
probleme_pris_en_charge=str(result.get("probleme_pris_en_charge", "")).strip(),
diagnostic_retenu=str(result.get("diagnostic_retenu", "")).strip(),
actes_ou_traitements_majeurs=_to_list(result.get("actes_ou_traitements_majeurs")),
complications=_to_list(result.get("complications")),
terrain_comorbidites=_to_list(result.get("terrain_comorbidites")),
preuves=preuves,
)

View File

@@ -9,6 +9,7 @@ from .templates import (
CPAM_ARGUMENTATION, CPAM_ARGUMENTATION,
DP_TIEBREAK, DP_TIEBREAK,
DP_LLM_ONESHOT, DP_LLM_ONESHOT,
SYNTHESE_PMSI,
CPAM_ADVERSARIAL, CPAM_ADVERSARIAL,
) )
@@ -21,5 +22,6 @@ __all__ = [
"CPAM_ARGUMENTATION", "CPAM_ARGUMENTATION",
"DP_TIEBREAK", "DP_TIEBREAK",
"DP_LLM_ONESHOT", "DP_LLM_ONESHOT",
"SYNTHESE_PMSI",
"CPAM_ADVERSARIAL", "CPAM_ADVERSARIAL",
] ]

View File

@@ -372,7 +372,49 @@ Réponds UNIQUEMENT en JSON :
}}""" }}"""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 8. CPAM passe 3 — validation adversariale (relecture critique) # 8. Synthèse PMSI — raisonnement clinique structuré avant codage DP
# ---------------------------------------------------------------------------
# Rôle : coding | Temperature : 0.0 | Max tokens : 1200
# Fichier d'origine : src/medical/dp_scoring.py → generate_synthese_pmsi()
# Variables : motif, sections_fortes, actes
SYNTHESE_PMSI = """\
Tu es un médecin DIM (Département d'Information Médicale) senior.
À partir du compte-rendu d'hospitalisation ci-dessous, produis une synthèse clinique structurée pour préparer le codage PMSI.
RÈGLES IMPÉRATIVES :
1. "motif_admission" = la raison concrète de l'entrée à l'hôpital telle que décrite dans le document (symptôme, circonstance, demande de bilan). Si le motif est explicitement mentionné ("hospitalisé pour", "admission pour", "motif :"), REPRENDS-LE.
2. "probleme_pris_en_charge" = la pathologie AIGUË ou l'épisode de soins qui a mobilisé l'essentiel des ressources pendant CE séjour. Ce n'est PAS le symptôme d'entrée si un diagnostic a été posé.
3. PIÈGE COMORBIDITÉ : les pathologies chroniques stables suivantes NE SONT PAS le problème principal SAUF si le séjour leur est SPÉCIFIQUEMENT dédié (décompensation, déséquilibre, urgence) :
- HTA, diabète type 2 équilibré, obésité, dyslipidémie, anémie chronique, BPCO stable, SAOS, IRC stable.
Si le patient est hospitalisé pour un problème aigu (infection, embolie, fracture, décompensation cardiaque, chirurgie, cancer...) ET a aussi un diabète ou une HTA, le problème principal est l'épisode AIGU.
4. NE PRODUIS AUCUN CODE CIM-10 ou CCAM. Utilise uniquement des termes médicaux en français.
5. Cite des EXTRAITS TEXTUELS EXACTS (copier-coller) du document dans "preuves". Indique la section d'origine.
6. Si le document ne contient pas assez d'information pour un champ, laisse une chaîne vide ou une liste vide.
MOTIF D'HOSPITALISATION : {motif}
SECTIONS CLINIQUES :
{sections_fortes}
ACTES RÉALISÉS : {actes}
Réponds UNIQUEMENT en JSON :
{{
"motif_admission": "raison de l'entrée à l'hôpital (symptôme ou circonstance)",
"probleme_pris_en_charge": "pathologie principale aiguë/active ayant mobilisé les ressources du séjour",
"diagnostic_retenu": "diagnostic final retenu à la sortie",
"actes_ou_traitements_majeurs": ["acte ou traitement 1", "acte ou traitement 2"],
"complications": ["complication survenue pendant le séjour"],
"terrain_comorbidites": ["comorbidité chronique 1", "comorbidité chronique 2"],
"preuves": [
{{"section": "nom_section", "excerpt": "extrait exact du texte"}},
{{"section": "nom_section", "excerpt": "extrait exact du texte"}}
]
}}"""
# ---------------------------------------------------------------------------
# 9. CPAM passe 3 — validation adversariale (relecture critique)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Rôle : validation | Temperature : 0.0 | Max tokens : 800 # Rôle : validation | Temperature : 0.0 | Max tokens : 800
# Fichier d'origine : src/control/cpam_response.py → _validate_adversarial() # Fichier d'origine : src/control/cpam_response.py → _validate_adversarial()

View File

@@ -9,18 +9,26 @@ from src.config import (
DPSelection, DPSelection,
DP_SCORING_WEIGHTS, DP_SCORING_WEIGHTS,
DP_REVIEW_THRESHOLD, DP_REVIEW_THRESHOLD,
PreuveSynthese,
SynthesePMSI,
Sejour, Sejour,
) )
from src.medical.dp_scoring import ( from src.medical.dp_scoring import (
build_dp_shortlist, build_dp_shortlist,
score_candidates, score_candidates,
select_dp, select_dp,
generate_synthese_pmsi,
_get_context_window, _get_context_window,
_is_z_code_whitelisted, _is_z_code_whitelisted,
_is_comorbidity_code, _is_comorbidity_code,
_has_explicit_pec_proof, _has_explicit_pec_proof,
_dedup_by_code, _dedup_by_code,
_normalize_evidence_section, _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): def test_sections_fortes_du_dossier(self):
"""Alias administratif observé en benchmark.""" """Alias administratif observé en benchmark."""
assert _normalize_evidence_section("sections fortes du dossier") == "autres" 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é"