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:
@@ -154,6 +154,21 @@ class Diagnostic(BaseModel):
|
||||
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):
|
||||
code: Optional[str] = None
|
||||
label: str
|
||||
|
||||
@@ -21,6 +21,8 @@ from ..config import (
|
||||
DP_REVIEW_THRESHOLD,
|
||||
DP_SCORING_WEIGHTS,
|
||||
DP_Z_CODE_WHITELIST,
|
||||
PreuveSynthese,
|
||||
SynthesePMSI,
|
||||
)
|
||||
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"
|
||||
|
||||
|
||||
def _build_motif(parsed: dict, dossier: DossierMedical) -> str:
|
||||
"""Extrait le motif d'hospitalisation pour le prompt LLM."""
|
||||
motif = ""
|
||||
# Regex pour extraire le motif d'hospitalisation par fallback lexical
|
||||
_MOTIF_FALLBACK_RE = re.compile(
|
||||
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:
|
||||
motif = dossier.sejour.mode_entree
|
||||
if not motif:
|
||||
motif = parsed.get("sections", {}).get("motif_hospitalisation", "")[:300] or "Non renseigné"
|
||||
return motif
|
||||
motif = dossier.sejour.mode_entree.strip()
|
||||
if 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:
|
||||
@@ -777,7 +825,7 @@ def llm_dp_fallback(
|
||||
return DPSelection(verdict="review", winner_reason="LLM non disponible")
|
||||
|
||||
# Contexte
|
||||
motif = _build_motif(parsed, dossier)
|
||||
motif = _build_motif(parsed, dossier, full_text=text)
|
||||
sections_fortes = _build_strong_sections_text(parsed)
|
||||
actes = _build_actes(dossier)
|
||||
|
||||
@@ -842,3 +890,257 @@ def llm_dp_fallback(
|
||||
|
||||
# Appliquer les garde-fous déterministes
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ from .templates import (
|
||||
CPAM_ARGUMENTATION,
|
||||
DP_TIEBREAK,
|
||||
DP_LLM_ONESHOT,
|
||||
SYNTHESE_PMSI,
|
||||
CPAM_ADVERSARIAL,
|
||||
)
|
||||
|
||||
@@ -21,5 +22,6 @@ __all__ = [
|
||||
"CPAM_ARGUMENTATION",
|
||||
"DP_TIEBREAK",
|
||||
"DP_LLM_ONESHOT",
|
||||
"SYNTHESE_PMSI",
|
||||
"CPAM_ADVERSARIAL",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
# Fichier d'origine : src/control/cpam_response.py → _validate_adversarial()
|
||||
|
||||
@@ -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