171 lines
5.7 KiB
Python
171 lines
5.7 KiB
Python
"""Moteur de décisions (post-traitement qualité).
|
||
|
||
But: conserver la proposition du modèle (cim10_suggestion) tout en produisant une
|
||
*sortie finale* plus défendable (cim10_final + cim10_decision).
|
||
|
||
Ce module est déterministe, court, et auditable.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import unicodedata
|
||
from typing import Optional
|
||
|
||
from ..config import CodeDecision, Diagnostic, DossierMedical
|
||
|
||
|
||
# --- Règles "étiologiques" : ne pas affirmer sans preuve spécifique ---
|
||
|
||
IRON_MARKERS = (
|
||
"ferrit", # ferritine
|
||
"transferr", # transferrine
|
||
"saturation", # saturation transferrine
|
||
"cst", # coefficient de saturation
|
||
"carence mart",
|
||
"martiale",
|
||
"ferripr", # ferriprive
|
||
"fer intraveineux",
|
||
"fer iv",
|
||
"traitement martial",
|
||
)
|
||
|
||
|
||
def _norm(s: str) -> str:
|
||
s = s.replace("’", "'")
|
||
s = unicodedata.normalize("NFKD", s)
|
||
s = "".join(ch for ch in s if not unicodedata.combining(ch))
|
||
s = s.lower()
|
||
return re.sub(r"\s+", " ", s).strip()
|
||
|
||
|
||
def _first_float(text: str) -> Optional[float]:
|
||
m = re.search(r"(-?\d+(?:[\.,]\d+)?)", text)
|
||
if not m:
|
||
return None
|
||
return float(m.group(1).replace(",", "."))
|
||
|
||
|
||
def _parse_normal_range(text: str) -> tuple[Optional[float], Optional[float]]:
|
||
# Ex: "[N: 12-17]" / "[N: 12 - 17]"
|
||
m = re.search(r"\[\s*N\s*:\s*([0-9]+(?:[\.,][0-9]+)?)\s*-\s*([0-9]+(?:[\.,][0-9]+)?)\s*\]", text)
|
||
if not m:
|
||
return None, None
|
||
lo = float(m.group(1).replace(",", "."))
|
||
hi = float(m.group(2).replace(",", "."))
|
||
return lo, hi
|
||
|
||
|
||
def _anemia_bio(diag: Diagnostic) -> bool:
|
||
# 1) via preuves_cliniques (souvent déjà interprétées)
|
||
for p in diag.preuves_cliniques or []:
|
||
blob = f"{p.element} {p.interpretation}".lower()
|
||
if "hemoglob" in blob or "hémoglob" in blob or blob.strip().startswith("hb"):
|
||
val = _first_float(p.element) or _first_float(p.interpretation)
|
||
lo, _ = _parse_normal_range(p.element)
|
||
lo = lo if lo is not None else 12.0
|
||
if val is not None and val < lo:
|
||
return True
|
||
if "confirm" in blob and "anemie" in blob:
|
||
return True
|
||
# 2) fallback : le texte mentionne une anémie chiffrée
|
||
ex = _norm(diag.source_excerpt or "")
|
||
if "hemoglob" in ex or "hémoglob" in ex:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _iron_evidence_blob(dossier: DossierMedical, diag: Diagnostic) -> str:
|
||
parts: list[str] = []
|
||
|
||
# Preuves patient (extraits + éléments structurés)
|
||
if diag.source_excerpt:
|
||
parts.append(str(diag.source_excerpt))
|
||
|
||
for p in diag.preuves_cliniques or []:
|
||
parts.append(f"{p.element} {p.interpretation}")
|
||
|
||
# Biologie clé globale (si ferritine/fer a été capté ailleurs)
|
||
for b in dossier.biologie_cle or []:
|
||
parts.append(f"{b.test} {b.valeur or ''}")
|
||
|
||
# Traitements (si supplémentation martiale documentée)
|
||
for t in dossier.traitements_sortie or []:
|
||
parts.append(f"{t.medicament} {t.posologie or ''}")
|
||
|
||
return _norm("\n".join(parts))
|
||
|
||
|
||
def apply_decisions(dossier: DossierMedical) -> None:
|
||
"""Applique des décisions finales sur DP/DAS.
|
||
|
||
- Ne supprime pas la suggestion du modèle.
|
||
- Remplit cim10_final systématiquement quand une suggestion existe.
|
||
- Remplit cim10_decision uniquement si action != KEEP (pour garder le JSON lisible).
|
||
"""
|
||
|
||
def _set_default_final(diag: Diagnostic):
|
||
if diag.cim10_suggestion and diag.cim10_final is None:
|
||
diag.cim10_final = diag.cim10_suggestion
|
||
|
||
# DP
|
||
if dossier.diagnostic_principal:
|
||
_set_default_final(dossier.diagnostic_principal)
|
||
|
||
# DAS
|
||
for das in dossier.diagnostics_associes or []:
|
||
_set_default_final(das)
|
||
|
||
# --- Règle: D50 sans preuve martiale -> downgrade D64.9 + needs_info ---
|
||
for das in dossier.diagnostics_associes or []:
|
||
if das.cim10_suggestion != "D50":
|
||
continue
|
||
|
||
blob = _iron_evidence_blob(dossier, das)
|
||
has_iron = any(m in blob for m in IRON_MARKERS)
|
||
has_anemia = _anemia_bio(das)
|
||
|
||
# Si on n'a même pas d'anémie biologique, on n'automatise pas.
|
||
if not has_anemia:
|
||
continue
|
||
|
||
if not has_iron:
|
||
das.cim10_final = "D64.9"
|
||
das.cim10_decision = CodeDecision(
|
||
action="DOWNGRADE",
|
||
final_code="D64.9",
|
||
downgraded_from="D50",
|
||
reason="Anémie biologique sans preuve d'étiologie ferriprive (bilan martial absent/insuffisant).",
|
||
needs_info=[
|
||
"Bilan martial disponible ? (ferritine, fer, CST/transferrine)",
|
||
"Mention explicite 'anémie ferriprive' ou carence martiale ?",
|
||
"Traitement martial (fer per os/IV) documenté ?",
|
||
],
|
||
applied_rules=["RULE-D50-NEEDS-IRON"],
|
||
)
|
||
|
||
|
||
|
||
def decision_summaries(dossier: DossierMedical) -> list[str]:
|
||
"""Retourne une liste de lignes lisibles à injecter dans alertes_codage."""
|
||
lines: list[str] = []
|
||
|
||
def _summ(where: str, d: Diagnostic):
|
||
dec = d.cim10_decision
|
||
if not dec or dec.action == "KEEP":
|
||
return
|
||
if dec.action == "DOWNGRADE":
|
||
lines.append(f"DECISION: {where} {dec.downgraded_from}→{dec.final_code} ({', '.join(dec.applied_rules)})")
|
||
for ni in dec.needs_info[:3]:
|
||
lines.append(f"DECISION: besoin_info: {ni}")
|
||
elif dec.action == "REMOVE":
|
||
lines.append(f"DECISION: {where} {d.cim10_suggestion} supprimé ({', '.join(dec.applied_rules)})")
|
||
|
||
if dossier.diagnostic_principal:
|
||
_summ("diagnostic_principal", dossier.diagnostic_principal)
|
||
|
||
for i, das in enumerate(dossier.diagnostics_associes or []):
|
||
_summ(f"diagnostics_associes[{i}]", das)
|
||
|
||
return lines
|