feat: méthode TIM experte CPAM + moteur de règles étendu
CPAM — Méthode TIM (mémoire en défense) : - Réécriture CPAM_ARGUMENTATION avec raisonnement 5 passes TIM (contexte admin → motif réel → confrontation bio → hiérarchie → validation défensive) - _BIO_THRESHOLDS (19 entrées) + _build_bio_confrontation() pour confrontation biologie/diagnostic avec seuils chiffrés et verdicts - _format_response() dual format : nouveau TIM (moyens numérotés, tableau bio, codes non défendables, conclusion dispositive) + rétrocompat legacy - CPAM_ADVERSARIAL mis à jour pour vérifier honnêteté intellectuelle - Tests adaptés + 12 nouveaux tests (bio confrontation, format TIM) Moteur de règles : - Nouvelles règles YAML : demographic, diagnostic_conflicts, procedure_diagnosis, temporal, parcours - Bio extraction FAISS (synonymes vectoriels) - Veto engine enrichi (citations, Trackare skip, règles démographiques) - Decision engine : _apply_bio_rules_gen() + matchers analytiques Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -161,7 +161,6 @@ def _threshold_high(cfg: dict, test: str, age_band: str, doc_hi: float | None) -
|
||||
|
||||
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))
|
||||
@@ -174,6 +173,131 @@ def _is_potassium_test(test: str) -> bool:
|
||||
return bool(re.fullmatch(r"k\+?", t))
|
||||
|
||||
|
||||
def _get_bio_matcher(analyte: str):
|
||||
"""Retourne une fonction de matching pour l'analyte demandé."""
|
||||
a = analyte.lower()
|
||||
if a == "sodium": return _is_sodium_test
|
||||
if a == "potassium": return _is_potassium_test
|
||||
if a == "hemoglobin": return lambda t: "hemoglob" in t.lower() or "hb" in t.lower().split()
|
||||
if a == "platelets": return lambda t: "plaquette" in t.lower() or "platelet" in t.lower()
|
||||
if a == "creatinine": return lambda t: "creatinine" in t.lower()
|
||||
if a == "glucose": return lambda t: "glucose" in t.lower() or "glycemie" in t.lower()
|
||||
if a == "hba1c": return lambda t: "hba1c" in t.lower()
|
||||
if a == "tsh": return lambda t: "tsh" in t.lower()
|
||||
# Fallback: simple inclusion
|
||||
return lambda t: a in t.lower()
|
||||
|
||||
|
||||
def _apply_bio_rules_gen(dossier: DossierMedical, cfg_ranges: dict) -> None:
|
||||
"""Applique les règles de validation biologique définies dans config/bio_rules.yaml."""
|
||||
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 {}
|
||||
age_band = _age_band(dossier, cfg_ranges)
|
||||
|
||||
def _push_need_info_veto(where: str, message: str) -> None:
|
||||
if dossier.veto_report is None: return
|
||||
vr = dossier.veto_report
|
||||
veto = str(missing_cfg.get("veto") or "VETO-17")
|
||||
if not rule_enabled(veto): return
|
||||
severity = str(missing_cfg.get("severity") or "LOW")
|
||||
penalty = int(missing_cfg.get("score_penalty") or 0)
|
||||
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)
|
||||
|
||||
for rule_id, r in rules.items():
|
||||
if not r.get("enabled", True):
|
||||
continue
|
||||
|
||||
analyte = r.get("analyte")
|
||||
if not analyte: continue
|
||||
|
||||
codes = set(r.get("codes") or [])
|
||||
matcher = _get_bio_matcher(analyte)
|
||||
values, lo_doc, hi_doc = _bio_values(dossier, matcher)
|
||||
t_type = r.get("threshold_type", "low") # 'low' pour hypo/anémie, 'high' pour hyper/insuffisance
|
||||
|
||||
# 1) PREUVE MANQUANTE
|
||||
if not values and bool(missing_cfg.get("enabled", False)):
|
||||
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
|
||||
|
||||
rule_key = f"RULE-{rule_id.upper()}-MISSING"
|
||||
if not rule_enabled(rule_key): continue
|
||||
|
||||
reason = f"Preuve manquante: {analyte} non extrait — impossible de valider {das.cim10_suggestion} de façon défendable."
|
||||
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=[f"Valeur(s) de {analyte} + date(s) ?", "Normes du laboratoire si disponibles ?"],
|
||||
applied_rules=[rule_key],
|
||||
)
|
||||
_push_need_info_veto(f"diagnostics_associes[{i}]", f"{das.cim10_suggestion} suggéré mais aucune preuve de {analyte} n'a été extraite.")
|
||||
|
||||
# 2) CONTRADICTION (RULED_OUT)
|
||||
if values:
|
||||
is_conflict = False
|
||||
found_val = 0.0
|
||||
threshold = 0.0
|
||||
|
||||
if t_type == "low":
|
||||
# Pour un diagnostic de type "Bas" (hypo, anémie), on écarte si la valeur est >= seuil bas normal
|
||||
threshold = _threshold(cfg_ranges, analyte, age_band, lo_doc)
|
||||
if min(values) >= threshold:
|
||||
is_conflict = True
|
||||
found_val = min(values)
|
||||
else:
|
||||
# Pour un diagnostic de type "Haut" (hyper, insuff), on écarte si la valeur est <= seuil haut normal
|
||||
threshold = _threshold_high(cfg_ranges, analyte, age_band, hi_doc)
|
||||
if max(values) <= threshold:
|
||||
is_conflict = True
|
||||
found_val = max(values)
|
||||
|
||||
# Cas particulier : seuil fixe dans le YAML (ex: HbA1c > 9)
|
||||
if r.get("threshold_value") is not None:
|
||||
fixed_t = float(r["threshold_value"])
|
||||
if t_type == "high" and max(values) < fixed_t:
|
||||
is_conflict = True
|
||||
found_val = max(values)
|
||||
threshold = fixed_t
|
||||
elif t_type == "low" and min(values) > fixed_t:
|
||||
is_conflict = True
|
||||
found_val = min(values)
|
||||
threshold = fixed_t
|
||||
|
||||
if is_conflict:
|
||||
rule_key = f"RULE-{rule_id.upper()}-NORMAL"
|
||||
if not rule_enabled(rule_key): continue
|
||||
|
||||
op = "≥" if t_type == "low" else "≤"
|
||||
reason = f"Contradiction biologique: {analyte}={found_val} ({op}{threshold}, valeur normale) — {r.get('message', 'diagnostic non retenu')}."
|
||||
|
||||
for das in dossier.diagnostics_associes or []:
|
||||
if (das.cim10_suggestion or "") not in codes: continue
|
||||
das.status = "ruled_out"
|
||||
das.ruled_out_reason = reason
|
||||
das.cim10_final = None
|
||||
das.cim10_decision = CodeDecision(
|
||||
action="RULED_OUT",
|
||||
final_code=None,
|
||||
downgraded_from=das.cim10_suggestion,
|
||||
reason=reason,
|
||||
needs_info=[
|
||||
f"Valeurs de {analyte} sur d'autres dates (trend) ?",
|
||||
f"Mention explicite de {das.cim10_suggestion} confirmée malgré valeurs normales ?",
|
||||
],
|
||||
applied_rules=[rule_key],
|
||||
)
|
||||
|
||||
|
||||
def _bio_values(
|
||||
dossier: DossierMedical,
|
||||
matcher,
|
||||
@@ -369,6 +493,30 @@ def apply_decisions(dossier: DossierMedical) -> None:
|
||||
for das in dossier.diagnostics_associes or []:
|
||||
_set_default_final(das)
|
||||
|
||||
# --- Règle: nettoyage hiérarchique (VETO-22bis) ---
|
||||
# Si un code spécifique (ex: K81.0) est présent, on retire le code générique (K81.9)
|
||||
all_final_codes = set()
|
||||
if dossier.diagnostic_principal and dossier.diagnostic_principal.cim10_final:
|
||||
all_final_codes.add(dossier.diagnostic_principal.cim10_final)
|
||||
for das in dossier.diagnostics_associes or []:
|
||||
if das.cim10_final:
|
||||
all_final_codes.add(das.cim10_final)
|
||||
|
||||
for das in dossier.diagnostics_associes or []:
|
||||
if das.cim10_final and das.cim10_final.endswith(".9"):
|
||||
cat3 = das.cim10_final[:3]
|
||||
# Chercher s'il existe un autre code plus spécifique dans la même catégorie
|
||||
if any(c.startswith(cat3) and c != das.cim10_final for c in all_final_codes):
|
||||
das.status = "removed"
|
||||
das.cim10_decision = CodeDecision(
|
||||
action="REMOVE",
|
||||
final_code=None,
|
||||
downgraded_from=das.cim10_final,
|
||||
reason=f"Code générique {das.cim10_final} retiré car un code plus spécifique de la catégorie {cat3} est présent.",
|
||||
applied_rules=["RULE-HIERARCHY-CLEANUP"],
|
||||
)
|
||||
das.cim10_final = None
|
||||
|
||||
# --- 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 []:
|
||||
@@ -427,186 +575,9 @@ def apply_decisions(dossier: DossierMedical) -> None:
|
||||
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"],
|
||||
)
|
||||
# --- Pack "bio": contradictions pilotées par config/bio_rules.yaml
|
||||
cfg_ranges = load_reference_ranges()
|
||||
_apply_bio_rules_gen(dossier, cfg_ranges)
|
||||
|
||||
# --- Règle: promotion DAS→DP quand aucun DP n'a été extrait ---
|
||||
if rule_enabled("RULE-DAS-TO-DP"):
|
||||
@@ -638,6 +609,10 @@ def apply_decisions(dossier: DossierMedical) -> None:
|
||||
),
|
||||
)
|
||||
dossier.diagnostics_associes.remove(best)
|
||||
# Traçabilité : alerte DIM lisible pour audit
|
||||
dossier.alertes_codage.append(
|
||||
f"RULE-DAS-TO-DP: DP absent → DAS {best.cim10_final} ({best.texte}) promu en DP"
|
||||
)
|
||||
logger.warning(
|
||||
"PROMOTE_DP: DAS %s (%s) promu en DP — aucun DP extrait",
|
||||
best.cim10_final, best.texte,
|
||||
|
||||
Reference in New Issue
Block a user