From 56c38c3d9879080c2bb3921a8951a62675d73ee3 Mon Sep 17 00:00:00 2001 From: dom Date: Tue, 24 Feb 2026 00:01:51 +0100 Subject: [PATCH] feat(pmsi): add SynthesePMSI + anti-comorbidity + motif fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/config.py | 15 ++ src/medical/dp_scoring.py | 318 +++++++++++++++++++++++++++++++++++++- src/prompts/__init__.py | 2 + src/prompts/templates.py | 44 +++++- tests/test_dp_scoring.py | 235 ++++++++++++++++++++++++++++ 5 files changed, 605 insertions(+), 9 deletions(-) diff --git a/src/config.py b/src/config.py index fbf3663..c2853f5 100644 --- a/src/config.py +++ b/src/config.py @@ -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 diff --git a/src/medical/dp_scoring.py b/src/medical/dp_scoring.py index b344a2f..0bb49b0 100644 --- a/src/medical/dp_scoring.py +++ b/src/medical/dp_scoring.py @@ -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, + ) diff --git a/src/prompts/__init__.py b/src/prompts/__init__.py index ced4388..a3f3752 100644 --- a/src/prompts/__init__.py +++ b/src/prompts/__init__.py @@ -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", ] diff --git a/src/prompts/templates.py b/src/prompts/templates.py index aaa112b..481231f 100644 --- a/src/prompts/templates.py +++ b/src/prompts/templates.py @@ -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() diff --git a/tests/test_dp_scoring.py b/tests/test_dp_scoring.py index 0ff8849..af8a81d 100644 --- a/tests/test_dp_scoring.py +++ b/tests/test_dp_scoring.py @@ -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é"