"""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