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:
dom
2026-03-04 11:57:07 +01:00
parent 795110d2e6
commit ce7a9650af
19 changed files with 1681 additions and 418 deletions

View File

@@ -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,