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)
|
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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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é"
|
||||||
|
|||||||
Reference in New Issue
Block a user