feat: architecture multi-modèles LLM + quality engine + benchmark
- Multi-modèles : 4 rôles LLM (coding=gemma3:27b-cloud, cpam=gemma3:27b-cloud, validation=deepseek-v3.2:cloud, qc=gemma3:12b) avec get_model(role) - Prompts externalisés : 7 templates dans src/prompts/templates.py - Cache Ollama : modèle stocké par entrée (migration auto ancien format) - call_ollama() : paramètre role= (priorité: model > role > global) - Quality engine : veto_engine + decision_engine + rules_router (YAML) - Benchmark qualité : scripts/benchmark_quality.py (A/B, métriques CIM-10) - Fix biologie : valeurs qualitatives (troponine négative) non filtrées - Fix CPAM : gemma3:27b-cloud au lieu de deepseek (JSON tronqué par thinking) - CPAM max_tokens 4000→6000, viewer admin multi-modèles - Benchmark 10 dossiers : 100% DAS valides, 10/10 CPAM, 243s/dossier Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
609
src/quality/decision_engine.py
Normal file
609
src/quality/decision_engine.py
Normal file
@@ -0,0 +1,609 @@
|
||||
"""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,
|
||||
VetoIssue,
|
||||
load_reference_ranges,
|
||||
load_bio_rules,
|
||||
rule_enabled,
|
||||
)
|
||||
|
||||
|
||||
# --- 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 _parse_float(v: str | None) -> float | None:
|
||||
if v is None:
|
||||
return None
|
||||
s = str(v).strip().replace(",", ".")
|
||||
m = re.search(r"(-?\d+(?:\.\d+)?)", s)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return float(m.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _age_band(dossier: DossierMedical, cfg: dict) -> str:
|
||||
age = getattr(dossier.sejour, "age", None)
|
||||
adult_min = (cfg.get("age_bands") or {}).get("adult_min_years", 18)
|
||||
if age is None:
|
||||
return "unknown"
|
||||
return "adult" if age >= adult_min else "child"
|
||||
|
||||
|
||||
def _threshold(cfg: dict, test: str, age_band: str, doc_lo: float | None) -> float:
|
||||
"""Retourne un seuil 'normal' conservateur pour déclencher un RULED_OUT.
|
||||
|
||||
Priorité:
|
||||
- doc_lo si présent (norme du document = vérité du dossier)
|
||||
- safe zone si âge inconnu ou enfant (conservateur)
|
||||
- fallback YAML sinon (adult)
|
||||
"""
|
||||
if doc_lo is not None:
|
||||
return float(doc_lo)
|
||||
|
||||
safe = cfg.get("safe_zones_unknown_age") or {}
|
||||
fallback = cfg.get("fallback_ranges") or {}
|
||||
|
||||
if age_band in ("unknown", "child"):
|
||||
# Seuils safe si dispo, sinon fallback adult
|
||||
key_map = {
|
||||
"platelets": "platelets_ruled_out_low",
|
||||
"sodium": "sodium_ruled_out_low",
|
||||
"potassium_high": "potassium_ruled_out_high",
|
||||
"potassium_low": "potassium_ruled_out_low",
|
||||
}
|
||||
k = key_map.get(test)
|
||||
if k and k in safe:
|
||||
return float(safe[k])
|
||||
|
||||
band = "adult" if age_band == "unknown" else age_band
|
||||
band_cfg = fallback.get(band) or fallback.get("adult") or {}
|
||||
test_cfg = band_cfg.get(test.replace("_high", "").replace("_low", "")) or {}
|
||||
lo = test_cfg.get("low")
|
||||
if lo is None:
|
||||
# dernier recours
|
||||
return 0.0
|
||||
return float(lo)
|
||||
|
||||
|
||||
def _threshold_high(cfg: dict, test: str, age_band: str, doc_hi: float | None) -> float:
|
||||
"""Retourne un seuil 'normal haut' conservateur.
|
||||
|
||||
Utilisé pour écarter des diagnostics de type "hyper-" quand la valeur est
|
||||
clairement ≤ la borne haute normale.
|
||||
|
||||
Priorité:
|
||||
- doc_hi si présent (norme du document)
|
||||
- safe zone si âge inconnu/enfant (conservateur)
|
||||
- fallback YAML sinon (adult)
|
||||
"""
|
||||
|
||||
if doc_hi is not None:
|
||||
return float(doc_hi)
|
||||
|
||||
safe = cfg.get("safe_zones_unknown_age") or {}
|
||||
fallback = cfg.get("fallback_ranges") or {}
|
||||
|
||||
if age_band in ("unknown", "child"):
|
||||
# safe zone dédiée si dispo
|
||||
if test == "potassium" and "potassium_ruled_out_high" in safe:
|
||||
return float(safe["potassium_ruled_out_high"])
|
||||
|
||||
band = "adult" if age_band == "unknown" else age_band
|
||||
band_cfg = fallback.get(band) or fallback.get("adult") or {}
|
||||
test_cfg = band_cfg.get(test) or {}
|
||||
hi = test_cfg.get("high")
|
||||
if hi is None:
|
||||
# dernier recours
|
||||
return 0.0
|
||||
return float(hi)
|
||||
|
||||
|
||||
def _is_sodium_test(test: str) -> bool:
|
||||
t = (test or "").lower().strip()
|
||||
# 'na' est trop générique: on privilégie sodium/natrémie
|
||||
if "sodium" in t or "natr" in t:
|
||||
return True
|
||||
return bool(re.fullmatch(r"na\+?", t))
|
||||
|
||||
|
||||
def _is_potassium_test(test: str) -> bool:
|
||||
t = (test or "").lower().strip()
|
||||
if "potassium" in t or "kali" in t:
|
||||
return True
|
||||
return bool(re.fullmatch(r"k\+?", t))
|
||||
|
||||
|
||||
def _bio_values(
|
||||
dossier: DossierMedical,
|
||||
matcher,
|
||||
) -> tuple[list[float], float | None, float | None]:
|
||||
"""Collecte des valeurs biologiques et une éventuelle norme [N: lo-hi].
|
||||
|
||||
- Les entrées BiologieCle peuvent être marquées quality=ok|suspect|discarded.
|
||||
- Par défaut, on **privilégie** les valeurs 'ok'. Si aucune valeur ok n'existe,
|
||||
on retombe sur les valeurs 'suspect' (audit), afin de ne pas perdre l'info.
|
||||
|
||||
Retour:
|
||||
- liste de valeurs (float)
|
||||
- norme basse (si trouvée)
|
||||
- norme haute (si trouvée)
|
||||
"""
|
||||
ok_values: list[float] = []
|
||||
suspect_values: list[float] = []
|
||||
lo_doc: float | None = None
|
||||
hi_doc: float | None = None
|
||||
|
||||
for b in dossier.biologie_cle or []:
|
||||
if not matcher(getattr(b, "test", "") or ""):
|
||||
continue
|
||||
|
||||
q = getattr(b, "quality", None) or "ok"
|
||||
if q == "discarded":
|
||||
continue
|
||||
|
||||
# Priorité: valeur_num si disponible (plus fiable que reparsing)
|
||||
val = getattr(b, "valeur_num", None)
|
||||
if val is None:
|
||||
raw = str(getattr(b, "valeur", "") or "")
|
||||
val = _parse_float(raw)
|
||||
|
||||
if val is None:
|
||||
continue
|
||||
|
||||
if q == "suspect":
|
||||
suspect_values.append(val)
|
||||
else:
|
||||
ok_values.append(val)
|
||||
|
||||
# Normes éventuelles dans la chaîne
|
||||
if lo_doc is None and hi_doc is None:
|
||||
raw = str(getattr(b, "valeur", "") or "")
|
||||
lo, hi = _parse_normal_range(raw)
|
||||
if lo is not None or hi is not None:
|
||||
lo_doc, hi_doc = lo, hi
|
||||
|
||||
values = ok_values if ok_values else suspect_values
|
||||
return values, lo_doc, hi_doc
|
||||
|
||||
|
||||
def _get_platelets_context(dossier: DossierMedical) -> tuple[float | None, float | None, float | None]:
|
||||
"""Retourne (valeur_plaquettes, norme_basse, norme_haute) si disponible.
|
||||
|
||||
Politique:
|
||||
- privilégie une valeur qualité=ok
|
||||
- sinon retombe sur une valeur qualité=suspect
|
||||
- ignore discarded
|
||||
"""
|
||||
best_val: float | None = None
|
||||
best_q: str | None = None
|
||||
best_raw: str | None = None
|
||||
best_lo: float | None = None
|
||||
best_hi: float | None = None
|
||||
|
||||
for b in dossier.biologie_cle or []:
|
||||
test = (b.test or "").lower()
|
||||
if "plaquette" not in test and "platelet" not in test:
|
||||
continue
|
||||
|
||||
q = getattr(b, "quality", None) or "ok"
|
||||
if q == "discarded":
|
||||
continue
|
||||
|
||||
raw = str(b.valeur or "")
|
||||
val = getattr(b, "valeur_num", None)
|
||||
if val is None:
|
||||
val = _parse_float(raw)
|
||||
|
||||
if val is None:
|
||||
continue
|
||||
|
||||
lo, hi = _parse_normal_range(raw)
|
||||
|
||||
if best_val is None:
|
||||
best_val, best_q, best_raw, best_lo, best_hi = val, q, raw, lo, hi
|
||||
continue
|
||||
|
||||
# Remplacer un suspect par un ok
|
||||
if best_q == "suspect" and q != "suspect":
|
||||
best_val, best_q, best_raw, best_lo, best_hi = val, q, raw, lo, hi
|
||||
|
||||
return best_val, best_lo, best_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 ---
|
||||
if rule_enabled("RULE-D50-NEEDS-IRON"):
|
||||
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"],
|
||||
)
|
||||
|
||||
# --- Règle: thrombopénie (D69.6) incompatible avec plaquettes normales -> ruled_out (visible mais barré)
|
||||
# Objectif: éviter un FAIL "dur" sur incohérence biologique quand la biologie contredit clairement.
|
||||
if rule_enabled("RULE-D69.6-PLT-NORMAL"):
|
||||
cfg_ranges = load_reference_ranges()
|
||||
plaquettes, plt_lo_doc, _plt_hi_doc = _get_platelets_context(dossier)
|
||||
age_band = _age_band(dossier, cfg_ranges)
|
||||
plt_threshold = _threshold(cfg_ranges, "platelets", age_band, plt_lo_doc)
|
||||
if plaquettes is not None and plaquettes >= plt_threshold:
|
||||
for das in dossier.diagnostics_associes or []:
|
||||
if das.cim10_suggestion != "D69.6":
|
||||
continue
|
||||
# Visible mais barré : on conserve la suggestion, mais on retire le code final
|
||||
das.status = "ruled_out"
|
||||
das.ruled_out_reason = f"Contradiction biologique: plaquettes={plaquettes} (≥{plt_threshold}, valeur normale)" \
|
||||
" — thrombopénie non retenue sans preuve explicite."
|
||||
das.cim10_final = None
|
||||
das.cim10_decision = CodeDecision(
|
||||
action="RULED_OUT",
|
||||
final_code=None,
|
||||
downgraded_from="D69.6",
|
||||
reason=das.ruled_out_reason,
|
||||
needs_info=[
|
||||
"Mention explicite de thrombopénie confirmée dans le CR (malgré plaquettes normales) ?",
|
||||
"Valeurs de plaquettes sur d'autres dates (trend) ?",
|
||||
"Cause/iatrogénie documentée (héparine, hémopathie, etc.) ?",
|
||||
],
|
||||
applied_rules=["RULE-D69.6-PLT-NORMAL"],
|
||||
)
|
||||
|
||||
# --- Pack "bio": contradictions simples Na/K -> ruled_out (piloté par config/bio_rules.yaml)
|
||||
# Objectif: réduire VETO-09 en écartant les diagnostics "hyper/hypo" quand la valeur est clairement normale.
|
||||
bio_cfg = load_bio_rules() or {}
|
||||
rules = (bio_cfg.get("rules") or {}) if isinstance(bio_cfg, dict) else {}
|
||||
|
||||
missing_cfg = (bio_cfg.get("missing_evidence") or {}) if isinstance(bio_cfg, dict) else {}
|
||||
def _push_need_info_veto(where: str, message: str) -> None:
|
||||
"""Ajoute un VETO non-bloquant quand la preuve biologique est manquante."""
|
||||
if dossier.veto_report is None:
|
||||
return
|
||||
vr = dossier.veto_report
|
||||
veto = str(missing_cfg.get("veto") or "VETO-17")
|
||||
# Désactivation globale par YAML (config/rules)
|
||||
if not rule_enabled(veto):
|
||||
return
|
||||
severity = str(missing_cfg.get("severity") or "LOW")
|
||||
penalty = int(missing_cfg.get("score_penalty") or 0)
|
||||
|
||||
# Anti-doublon
|
||||
if any((it.veto == veto and it.where == where and (it.message or "") == message) for it in (vr.issues or [])):
|
||||
return
|
||||
|
||||
vr.issues.append(VetoIssue(veto=veto, severity=severity, where=where, message=message))
|
||||
if (vr.verdict or "") == "PASS":
|
||||
vr.verdict = "NEED_INFO"
|
||||
if penalty:
|
||||
vr.score_contestabilite = max(0, int(vr.score_contestabilite or 0) - penalty)
|
||||
|
||||
|
||||
# Sodium (hyponatrémie)
|
||||
r = rules.get("hyponatremia") or {}
|
||||
if r.get("enabled", True):
|
||||
codes = set(r.get("codes") or ["E87.1"])
|
||||
na_values, na_lo_doc, _na_hi_doc = _bio_values(dossier, _is_sodium_test)
|
||||
if (not na_values) and bool(missing_cfg.get("enabled", False)) and rule_enabled("RULE-E87.1-MISSING-NA"):
|
||||
for i, das in enumerate(dossier.diagnostics_associes or []):
|
||||
if (das.cim10_suggestion or "") not in codes:
|
||||
continue
|
||||
if das.cim10_decision and (das.cim10_decision.action or "") in ("RULED_OUT", "REMOVE"):
|
||||
continue
|
||||
|
||||
reason = "Preuve manquante: natrémie (sodium) non extraite — impossible de valider E87.1 de façon défendable."
|
||||
where = f"diagnostics_associes[{i}]"
|
||||
das.status = "needs_info"
|
||||
das.cim10_final = None
|
||||
das.cim10_decision = CodeDecision(
|
||||
action="NEED_INFO",
|
||||
final_code=None,
|
||||
downgraded_from=das.cim10_suggestion,
|
||||
reason=reason,
|
||||
needs_info=[
|
||||
"Valeur(s) de sodium (natrémie) + date(s) ?",
|
||||
"Normes du laboratoire si disponibles ?",
|
||||
],
|
||||
applied_rules=["RULE-E87.1-MISSING-NA"],
|
||||
)
|
||||
_push_need_info_veto(where, "E87.1 suggérée mais aucune natrémie (Na) n'a été extraite des résultats biologiques.")
|
||||
|
||||
if na_values and rule_enabled("RULE-E87.1-NA-NORMAL"):
|
||||
na_threshold = _threshold(cfg_ranges, "sodium", age_band, na_lo_doc)
|
||||
# Ne ruled_out que si AUCUNE valeur n'est sous la borne basse normale.
|
||||
if min(na_values) >= na_threshold:
|
||||
na_val = min(na_values)
|
||||
for das in dossier.diagnostics_associes or []:
|
||||
if (das.cim10_suggestion or "") not in codes:
|
||||
continue
|
||||
das.status = "ruled_out"
|
||||
das.ruled_out_reason = (
|
||||
f"Contradiction biologique: sodium={na_val} (≥{na_threshold}, valeur normale) "
|
||||
"— hyponatrémie non retenue sans preuve explicite."
|
||||
)
|
||||
das.cim10_final = None
|
||||
das.cim10_decision = CodeDecision(
|
||||
action="RULED_OUT",
|
||||
final_code=None,
|
||||
downgraded_from=das.cim10_suggestion,
|
||||
reason=das.ruled_out_reason,
|
||||
needs_info=[
|
||||
"Valeurs de natrémie sur d'autres dates (trend) ?",
|
||||
"Mention explicite d'hyponatrémie confirmée malgré valeurs normales ?",
|
||||
"Contexte (perfusions, diurétiques, SIADH, etc.) documenté ?",
|
||||
],
|
||||
applied_rules=["RULE-E87.1-NA-NORMAL"],
|
||||
)
|
||||
|
||||
# Potassium (hyper/hypo)
|
||||
k_values, k_lo_doc, k_hi_doc = _bio_values(dossier, _is_potassium_test)
|
||||
if (not k_values) and bool(missing_cfg.get("enabled", False)):
|
||||
# Valeur de kaliémie manquante : on refuse de valider E87.5/E87.6 sans preuve.
|
||||
codes_hyper = set((rules.get("hyperkalemia") or {}).get("codes") or ["E87.5"])
|
||||
codes_hypo = set((rules.get("hypokalemia") or {}).get("codes") or ["E87.6"])
|
||||
codes = codes_hyper.union(codes_hypo)
|
||||
|
||||
for i, das in enumerate(dossier.diagnostics_associes or []):
|
||||
if (das.cim10_suggestion or "") not in codes:
|
||||
continue
|
||||
if das.cim10_decision and (das.cim10_decision.action or "") in ("RULED_OUT", "REMOVE"):
|
||||
continue
|
||||
|
||||
code = das.cim10_suggestion or ""
|
||||
rule_id = f"RULE-{code}-MISSING-K"
|
||||
if not rule_enabled(rule_id):
|
||||
continue
|
||||
reason = f"Preuve manquante: kaliémie (potassium) non extraite — impossible de valider {code} de façon défendable."
|
||||
where = f"diagnostics_associes[{i}]"
|
||||
das.status = "needs_info"
|
||||
das.cim10_final = None
|
||||
das.cim10_decision = CodeDecision(
|
||||
action="NEED_INFO",
|
||||
final_code=None,
|
||||
downgraded_from=code,
|
||||
reason=reason,
|
||||
needs_info=[
|
||||
"Valeur(s) de potassium (kaliémie) + date(s) ?",
|
||||
"Normes du laboratoire si disponibles ?",
|
||||
],
|
||||
applied_rules=[f"RULE-{code}-MISSING-K"],
|
||||
)
|
||||
_push_need_info_veto(where, f"{code} suggéré mais aucune kaliémie (K) n'a été extraite des résultats biologiques.")
|
||||
|
||||
if k_values:
|
||||
# Hyperkaliémie
|
||||
r = rules.get("hyperkalemia") or {}
|
||||
if r.get("enabled", True) and rule_enabled("RULE-E87.5-K-NORMAL"):
|
||||
codes = set(r.get("codes") or ["E87.5"])
|
||||
k_high = _threshold_high(cfg_ranges, "potassium", age_band, k_hi_doc)
|
||||
# Ruled_out si AUCUNE valeur ne dépasse la borne haute normale.
|
||||
if max(k_values) <= k_high:
|
||||
k_val = max(k_values)
|
||||
for das in dossier.diagnostics_associes or []:
|
||||
if (das.cim10_suggestion or "") not in codes:
|
||||
continue
|
||||
das.status = "ruled_out"
|
||||
das.ruled_out_reason = (
|
||||
f"Contradiction biologique: potassium={k_val} (≤{k_high}, valeur normale) "
|
||||
"— hyperkaliémie non retenue sans preuve explicite."
|
||||
)
|
||||
das.cim10_final = None
|
||||
das.cim10_decision = CodeDecision(
|
||||
action="RULED_OUT",
|
||||
final_code=None,
|
||||
downgraded_from=das.cim10_suggestion,
|
||||
reason=das.ruled_out_reason,
|
||||
needs_info=[
|
||||
"Valeurs de kaliémie sur d'autres dates (trend) ?",
|
||||
"Mention explicite d'hyperkaliémie confirmée malgré valeurs normales ?",
|
||||
"Contexte (IRA, IEC/ARA2, spironolactone, hémolyse) documenté ?",
|
||||
],
|
||||
applied_rules=["RULE-E87.5-K-NORMAL"],
|
||||
)
|
||||
|
||||
# Hypokaliémie
|
||||
r = rules.get("hypokalemia") or {}
|
||||
if r.get("enabled", True) and rule_enabled("RULE-E87.6-K-NORMAL"):
|
||||
codes = set(r.get("codes") or ["E87.6"])
|
||||
k_low = _threshold(cfg_ranges, "potassium_low", age_band, k_lo_doc)
|
||||
# Ruled_out si AUCUNE valeur n'est sous la borne basse normale.
|
||||
if min(k_values) >= k_low:
|
||||
k_val = min(k_values)
|
||||
for das in dossier.diagnostics_associes or []:
|
||||
if (das.cim10_suggestion or "") not in codes:
|
||||
continue
|
||||
das.status = "ruled_out"
|
||||
das.ruled_out_reason = (
|
||||
f"Contradiction biologique: potassium={k_val} (≥{k_low}, valeur normale) "
|
||||
"— hypokaliémie non retenue sans preuve explicite."
|
||||
)
|
||||
das.cim10_final = None
|
||||
das.cim10_decision = CodeDecision(
|
||||
action="RULED_OUT",
|
||||
final_code=None,
|
||||
downgraded_from=das.cim10_suggestion,
|
||||
reason=das.ruled_out_reason,
|
||||
needs_info=[
|
||||
"Valeurs de kaliémie sur d'autres dates (trend) ?",
|
||||
"Mention explicite d'hypokaliémie confirmée malgré valeurs normales ?",
|
||||
"Contexte (diurétiques, diarrhées, pertes rénales) documenté ?",
|
||||
],
|
||||
applied_rules=["RULE-E87.6-K-NORMAL"],
|
||||
)
|
||||
|
||||
|
||||
|
||||
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)})")
|
||||
elif dec.action == "RULED_OUT":
|
||||
lines.append(
|
||||
f"DECISION: {where} {d.cim10_suggestion} écarté (ruled_out) ({', '.join(dec.applied_rules)})"
|
||||
)
|
||||
if dec.reason:
|
||||
lines.append(f"DECISION: raison: {dec.reason}")
|
||||
|
||||
|
||||
elif dec.action == "NEED_INFO":
|
||||
lines.append(
|
||||
f"DECISION: {where} {d.cim10_suggestion} non retenu (NEED_INFO) ({', '.join(dec.applied_rules)})"
|
||||
)
|
||||
if dec.reason:
|
||||
lines.append(f"DECISION: raison: {dec.reason}")
|
||||
if dec.needs_info:
|
||||
for q in dec.needs_info:
|
||||
lines.append(f"DECISION: besoin_info: {q}")
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user