feat: fix extraction DP Trackare + 5 règles ATIH (veto engine)
- Fix DP : les diagnostics Trackare marqués "principal" ne sont plus filtrés par is_valid_diagnostic_text() (3 dossiers récupérés) - VETO-20 : Z code interdit en DP (sauf whitelist Z09/Z51/Z54/Z75...) - VETO-21 : Code R (symptôme) en DP → alerte CMD 23 - VETO-22 : Même catégorie 3 chars en DP+DAS (redondance) - VETO-23 : Exclusions mutuelles (E10↔E11, I10↔I11-I13) - VETO-24 : Lésion traumatique (S/T) sans cause externe (V/W/X/Y) - 24 tests unitaires, 699 tests passent sans régression Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -74,6 +74,25 @@ packs:
|
||||
enabled: true
|
||||
description: "E87.6 suggérée mais K absent => NEED_INFO"
|
||||
|
||||
atih_core:
|
||||
enabled: true
|
||||
rules:
|
||||
VETO-20:
|
||||
enabled: true
|
||||
description: "Z code interdit en DP (sauf whitelist Z09/Z51/Z54/Z75/Z03/Z04/Z38/Z50/Z08)"
|
||||
VETO-21:
|
||||
enabled: true
|
||||
description: "Code R (symptôme) en DP → CMD 23, tarification faible"
|
||||
VETO-22:
|
||||
enabled: true
|
||||
description: "Même catégorie CIM-10 3 chars en DP + DAS (redondance)"
|
||||
VETO-23:
|
||||
enabled: true
|
||||
description: "Exclusions mutuelles (E10/E11 diabète, I10/I11-I13 HTA)"
|
||||
VETO-24:
|
||||
enabled: true
|
||||
description: "Lésion traumatique (S/T) sans cause externe (V/W/X/Y)"
|
||||
|
||||
placeholders_future:
|
||||
enabled: false
|
||||
rules:
|
||||
|
||||
@@ -315,14 +315,17 @@ def _extract_diagnostics(
|
||||
# Diagnostics codés depuis Trackare (prioritaires)
|
||||
for diag in parsed.get("diagnostics", []):
|
||||
texte = clean_diagnostic_text(diag.get("libelle", ""))
|
||||
if not is_valid_diagnostic_text(texte):
|
||||
is_principal = diag.get("type", "").lower() == "principal"
|
||||
# Le DP Trackare est toujours accepté (pré-codé avec CIM-10 validé).
|
||||
# Seuls les DAS passent le filtre anti-bruit.
|
||||
if not is_principal and not is_valid_diagnostic_text(texte):
|
||||
continue
|
||||
d = Diagnostic(
|
||||
texte=texte,
|
||||
cim10_suggestion=diag.get("code_cim10"),
|
||||
source="trackare",
|
||||
)
|
||||
if diag.get("type", "").lower() == "principal":
|
||||
if is_principal:
|
||||
dossier.diagnostic_principal = d
|
||||
else:
|
||||
dossier.diagnostics_associes.append(d)
|
||||
|
||||
@@ -372,6 +372,84 @@ def apply_vetos(dossier: DossierMedical) -> VetoReport:
|
||||
if dp and dp.cim10_suggestion and _overconf(dp):
|
||||
add("VETO-12", "HARD", "diagnostic_principal", f"DP {dp.cim10_suggestion} en high sans preuve")
|
||||
|
||||
# -------------------------------------------------
|
||||
# VETO-20 : Z code interdit en DP (sauf whitelist ATIH)
|
||||
# Règle PMSI : les codes Z ne sont autorisés en DP que pour un
|
||||
# nombre limité de motifs (chimiothérapie, suivi post-traitement, etc.)
|
||||
# -------------------------------------------------
|
||||
_Z_DP_WHITELIST = {"Z09", "Z51", "Z54", "Z75", "Z03", "Z04", "Z38", "Z50", "Z08"}
|
||||
if dp and dp.cim10_suggestion and str(dp.cim10_suggestion).startswith("Z"):
|
||||
z3 = str(dp.cim10_suggestion)[:3]
|
||||
if z3 not in _Z_DP_WHITELIST:
|
||||
add("VETO-20", "MEDIUM", "diagnostic_principal",
|
||||
f"DP {dp.cim10_suggestion} est un code Z interdit en DP (catégorie {z3}). "
|
||||
"Les codes Z ne sont autorisés en DP que pour certains motifs (Z51 chimio, Z09 suivi, etc.).")
|
||||
|
||||
# -------------------------------------------------
|
||||
# VETO-21 : Code R (symptôme) en DP → CMD 23, tarification faible
|
||||
# Règle PMSI : un symptôme en DP indique un bilan incomplet.
|
||||
# -------------------------------------------------
|
||||
if dp and dp.cim10_suggestion and str(dp.cim10_suggestion).startswith("R"):
|
||||
# Vérifier si un diagnostic précis existe dans les DAS
|
||||
has_precise = any(
|
||||
das.cim10_suggestion and not str(das.cim10_suggestion).startswith(("R", "Z"))
|
||||
and not _is_ruled_out(das)
|
||||
for das in dossier.diagnostics_associes
|
||||
)
|
||||
severity = "LOW" if has_precise else "MEDIUM"
|
||||
add("VETO-21", severity, "diagnostic_principal",
|
||||
f"DP {dp.cim10_suggestion} est un code symptôme (chapitre R) → CMD 23. "
|
||||
"Un diagnostic étiologique précis devrait être recherché comme DP.")
|
||||
|
||||
# -------------------------------------------------
|
||||
# VETO-22 : Même catégorie CIM-10 3 chars en DP + DAS
|
||||
# Règle PMSI : redondance de codage suspecte.
|
||||
# -------------------------------------------------
|
||||
if dp and dp.cim10_suggestion and len(str(dp.cim10_suggestion)) >= 3:
|
||||
dp_cat = str(dp.cim10_suggestion)[:3]
|
||||
for i, das in enumerate(dossier.diagnostics_associes):
|
||||
if _is_ruled_out(das):
|
||||
continue
|
||||
if das.cim10_suggestion and len(str(das.cim10_suggestion)) >= 3:
|
||||
das_cat = str(das.cim10_suggestion)[:3]
|
||||
if das_cat == dp_cat and das.cim10_suggestion != dp.cim10_suggestion:
|
||||
add("VETO-22", "LOW", f"diagnostics_associes[{i}]",
|
||||
f"DAS {das.cim10_suggestion} même catégorie que DP {dp.cim10_suggestion} "
|
||||
f"({dp_cat}). Vérifier si la sous-catégorie DAS est pertinente ou redondante.")
|
||||
|
||||
# -------------------------------------------------
|
||||
# VETO-23 : Exclusions mutuelles (diabète type 1 vs type 2, HTA)
|
||||
# Règle PMSI : codes incompatibles dans le même séjour.
|
||||
# -------------------------------------------------
|
||||
all_codes = set()
|
||||
if dp and dp.cim10_suggestion:
|
||||
all_codes.add(str(dp.cim10_suggestion)[:3])
|
||||
for das in dossier.diagnostics_associes:
|
||||
if not _is_ruled_out(das) and das.cim10_suggestion:
|
||||
all_codes.add(str(das.cim10_suggestion)[:3])
|
||||
|
||||
_MUTUAL_EXCLUSIONS = [
|
||||
({"E10"}, {"E11"}, "Diabète type 1 (E10) et type 2 (E11) mutuellement exclusifs"),
|
||||
({"I10"}, {"I11", "I12", "I13"}, "HTA essentielle (I10) incompatible avec HTA secondaire (I11/I12/I13)"),
|
||||
]
|
||||
for group_a, group_b, msg in _MUTUAL_EXCLUSIONS:
|
||||
if (all_codes & group_a) and (all_codes & group_b):
|
||||
add("VETO-23", "MEDIUM", "diagnostics_associes", msg)
|
||||
|
||||
# -------------------------------------------------
|
||||
# VETO-24 : Lésion traumatique (S/T) sans cause externe (V/W/X/Y)
|
||||
# Règle PMSI : les codes de lésion doivent être associés
|
||||
# à un code de cause externe.
|
||||
# -------------------------------------------------
|
||||
has_injury = any(
|
||||
str(c).startswith(("S", "T")) and not str(c).startswith(("T80", "T81", "T82", "T83", "T84", "T85", "T86", "T87", "T88"))
|
||||
for c in all_codes
|
||||
)
|
||||
has_external = any(str(c).startswith(("V", "W", "X", "Y")) for c in all_codes)
|
||||
if has_injury and not has_external:
|
||||
add("VETO-24", "LOW", "diagnostics_associes",
|
||||
"Lésion traumatique (S/T) sans code de cause externe (V/W/X/Y). "
|
||||
"La réglementation PMSI exige un code de circonstance pour les traumatismes.")
|
||||
|
||||
# -------------------------------------------------
|
||||
# Post-traitement : si un veto HARD existe pour un même 'where',
|
||||
|
||||
243
tests/test_atih_rules.py
Normal file
243
tests/test_atih_rules.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""Tests unitaires pour les règles ATIH (veto engine).
|
||||
|
||||
Couvre : VETO-20 (Z en DP), VETO-21 (R en DP), VETO-22 (même catégorie DP+DAS),
|
||||
VETO-23 (exclusions mutuelles), VETO-24 (traumatisme sans cause externe).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.config import Diagnostic, DossierMedical, Sejour
|
||||
from src.quality.veto_engine import apply_vetos
|
||||
|
||||
|
||||
def _make_dossier(dp_code=None, das_codes=None, dp_texte="test", das_textes=None):
|
||||
"""Helper : crée un DossierMedical minimal avec DP + DAS."""
|
||||
dossier = DossierMedical(sejour=Sejour())
|
||||
if dp_code:
|
||||
dossier.diagnostic_principal = Diagnostic(
|
||||
texte=dp_texte, cim10_suggestion=dp_code,
|
||||
source_excerpt="preuve test", cim10_confidence="medium",
|
||||
)
|
||||
for i, code in enumerate(das_codes or []):
|
||||
texte = (das_textes[i] if das_textes and i < len(das_textes)
|
||||
else f"das test {code}")
|
||||
dossier.diagnostics_associes.append(Diagnostic(
|
||||
texte=texte, cim10_suggestion=code,
|
||||
source_excerpt="preuve test", cim10_confidence="medium",
|
||||
))
|
||||
return dossier
|
||||
|
||||
|
||||
# ================================================================
|
||||
# VETO-20 : Z code interdit en DP
|
||||
# ================================================================
|
||||
|
||||
class TestVeto20ZCodeDP:
|
||||
def test_z_code_forbidden_dp(self):
|
||||
"""Z00 en DP doit déclencher VETO-20."""
|
||||
d = _make_dossier(dp_code="Z00.8", das_codes=["I10"])
|
||||
report = apply_vetos(d)
|
||||
v20 = [i for i in report.issues if i.veto == "VETO-20"]
|
||||
assert len(v20) == 1
|
||||
assert "Z00" in v20[0].message
|
||||
|
||||
def test_z51_whitelist_dp(self):
|
||||
"""Z51 (chimiothérapie) est autorisé en DP — pas de VETO-20."""
|
||||
d = _make_dossier(dp_code="Z51.1", das_codes=["C50.9"])
|
||||
report = apply_vetos(d)
|
||||
v20 = [i for i in report.issues if i.veto == "VETO-20"]
|
||||
assert len(v20) == 0
|
||||
|
||||
def test_z09_whitelist_dp(self):
|
||||
"""Z09 (suivi post-traitement) est autorisé en DP."""
|
||||
d = _make_dossier(dp_code="Z09.0", das_codes=["C50.9"])
|
||||
report = apply_vetos(d)
|
||||
v20 = [i for i in report.issues if i.veto == "VETO-20"]
|
||||
assert len(v20) == 0
|
||||
|
||||
def test_z54_whitelist_dp(self):
|
||||
"""Z54 (convalescence) est autorisé en DP."""
|
||||
d = _make_dossier(dp_code="Z54.0")
|
||||
report = apply_vetos(d)
|
||||
v20 = [i for i in report.issues if i.veto == "VETO-20"]
|
||||
assert len(v20) == 0
|
||||
|
||||
def test_z_in_das_no_veto(self):
|
||||
"""Z code en DAS ne déclenche PAS VETO-20."""
|
||||
d = _make_dossier(dp_code="I10", das_codes=["Z87.1"])
|
||||
report = apply_vetos(d)
|
||||
v20 = [i for i in report.issues if i.veto == "VETO-20"]
|
||||
assert len(v20) == 0
|
||||
|
||||
def test_z45_forbidden(self):
|
||||
"""Z45 (ajustement de dispositif) n'est pas dans la whitelist."""
|
||||
d = _make_dossier(dp_code="Z45.0")
|
||||
report = apply_vetos(d)
|
||||
v20 = [i for i in report.issues if i.veto == "VETO-20"]
|
||||
assert len(v20) == 1
|
||||
|
||||
|
||||
# ================================================================
|
||||
# VETO-21 : Code R (symptôme) en DP
|
||||
# ================================================================
|
||||
|
||||
class TestVeto21RCodeDP:
|
||||
def test_r_code_dp_no_precise_das(self):
|
||||
"""R code en DP sans diagnostic précis en DAS → MEDIUM."""
|
||||
d = _make_dossier(dp_code="R10.9", das_codes=["R50.9", "Z87.1"])
|
||||
report = apply_vetos(d)
|
||||
v21 = [i for i in report.issues if i.veto == "VETO-21"]
|
||||
assert len(v21) == 1
|
||||
assert v21[0].severity == "MEDIUM"
|
||||
|
||||
def test_r_code_dp_with_precise_das(self):
|
||||
"""R code en DP avec diagnostic précis en DAS → LOW (informatif)."""
|
||||
d = _make_dossier(dp_code="R10.9", das_codes=["K35.8"])
|
||||
report = apply_vetos(d)
|
||||
v21 = [i for i in report.issues if i.veto == "VETO-21"]
|
||||
assert len(v21) == 1
|
||||
assert v21[0].severity == "LOW"
|
||||
|
||||
def test_non_r_dp_no_veto(self):
|
||||
"""DP hors chapitre R → pas de VETO-21."""
|
||||
d = _make_dossier(dp_code="K35.8", das_codes=["R10.9"])
|
||||
report = apply_vetos(d)
|
||||
v21 = [i for i in report.issues if i.veto == "VETO-21"]
|
||||
assert len(v21) == 0
|
||||
|
||||
|
||||
# ================================================================
|
||||
# VETO-22 : Même catégorie 3 chars DP + DAS
|
||||
# ================================================================
|
||||
|
||||
class TestVeto22SameCategory:
|
||||
def test_same_3char_dp_das(self):
|
||||
"""J45.0 en DP + J45.9 en DAS → VETO-22."""
|
||||
d = _make_dossier(dp_code="J45.0", das_codes=["J45.9", "I10"])
|
||||
report = apply_vetos(d)
|
||||
v22 = [i for i in report.issues if i.veto == "VETO-22"]
|
||||
assert len(v22) == 1
|
||||
assert "J45" in v22[0].message
|
||||
|
||||
def test_exact_duplicate_no_veto22(self):
|
||||
"""Code identique DP=DAS est VETO-06, pas VETO-22."""
|
||||
d = _make_dossier(dp_code="J45.0", das_codes=["J45.0"])
|
||||
report = apply_vetos(d)
|
||||
v22 = [i for i in report.issues if i.veto == "VETO-22"]
|
||||
assert len(v22) == 0 # traité par VETO-06
|
||||
|
||||
def test_different_categories(self):
|
||||
"""Catégories différentes → pas de VETO-22."""
|
||||
d = _make_dossier(dp_code="K35.8", das_codes=["I10", "E11.9"])
|
||||
report = apply_vetos(d)
|
||||
v22 = [i for i in report.issues if i.veto == "VETO-22"]
|
||||
assert len(v22) == 0
|
||||
|
||||
|
||||
# ================================================================
|
||||
# VETO-23 : Exclusions mutuelles
|
||||
# ================================================================
|
||||
|
||||
class TestVeto23MutualExclusions:
|
||||
def test_e10_e11_mutual(self):
|
||||
"""E10 + E11 = diabète type 1 et 2 → VETO-23."""
|
||||
d = _make_dossier(dp_code="E10.9", das_codes=["E11.9", "I10"])
|
||||
report = apply_vetos(d)
|
||||
v23 = [i for i in report.issues if i.veto == "VETO-23"]
|
||||
assert len(v23) == 1
|
||||
assert "Diabète" in v23[0].message
|
||||
|
||||
def test_i10_i11_mutual(self):
|
||||
"""I10 + I11 = HTA essentielle + secondaire → VETO-23."""
|
||||
d = _make_dossier(dp_code="I10", das_codes=["I11.9"])
|
||||
report = apply_vetos(d)
|
||||
v23 = [i for i in report.issues if i.veto == "VETO-23"]
|
||||
assert len(v23) == 1
|
||||
assert "HTA" in v23[0].message
|
||||
|
||||
def test_i10_i13_mutual(self):
|
||||
"""I10 + I13 (HTA cardiorénale) → VETO-23."""
|
||||
d = _make_dossier(dp_code="K35.8", das_codes=["I10", "I13.0"])
|
||||
report = apply_vetos(d)
|
||||
v23 = [i for i in report.issues if i.veto == "VETO-23"]
|
||||
assert len(v23) == 1
|
||||
|
||||
def test_no_mutual_exclusion(self):
|
||||
"""Pas de conflit → pas de VETO-23."""
|
||||
d = _make_dossier(dp_code="E11.9", das_codes=["I10", "K35.8"])
|
||||
report = apply_vetos(d)
|
||||
v23 = [i for i in report.issues if i.veto == "VETO-23"]
|
||||
assert len(v23) == 0
|
||||
|
||||
def test_e10_alone_no_veto(self):
|
||||
"""E10 seul → pas de VETO-23."""
|
||||
d = _make_dossier(dp_code="E10.9", das_codes=["I10"])
|
||||
report = apply_vetos(d)
|
||||
v23 = [i for i in report.issues if i.veto == "VETO-23"]
|
||||
assert len(v23) == 0
|
||||
|
||||
|
||||
# ================================================================
|
||||
# VETO-24 : Traumatisme sans cause externe
|
||||
# ================================================================
|
||||
|
||||
class TestVeto24InjuryExternalCause:
|
||||
def test_injury_without_external(self):
|
||||
"""S72.0 sans V/W/X/Y → VETO-24."""
|
||||
d = _make_dossier(dp_code="S72.0", das_codes=["I10"])
|
||||
report = apply_vetos(d)
|
||||
v24 = [i for i in report.issues if i.veto == "VETO-24"]
|
||||
assert len(v24) == 1
|
||||
|
||||
def test_injury_with_external(self):
|
||||
"""S72.0 + W19 → pas de VETO-24."""
|
||||
d = _make_dossier(dp_code="S72.0", das_codes=["W19.0"])
|
||||
report = apply_vetos(d)
|
||||
v24 = [i for i in report.issues if i.veto == "VETO-24"]
|
||||
assert len(v24) == 0
|
||||
|
||||
def test_t_complication_no_veto(self):
|
||||
"""T82 (complication de dispositif) n'est PAS un traumatisme → pas de VETO-24."""
|
||||
d = _make_dossier(dp_code="T82.1", das_codes=["I10"])
|
||||
report = apply_vetos(d)
|
||||
v24 = [i for i in report.issues if i.veto == "VETO-24"]
|
||||
assert len(v24) == 0
|
||||
|
||||
def test_no_injury_no_veto(self):
|
||||
"""Pas de code S/T → pas de VETO-24."""
|
||||
d = _make_dossier(dp_code="K35.8", das_codes=["I10"])
|
||||
report = apply_vetos(d)
|
||||
v24 = [i for i in report.issues if i.veto == "VETO-24"]
|
||||
assert len(v24) == 0
|
||||
|
||||
def test_injury_in_das_without_external(self):
|
||||
"""Code S en DAS sans externe → VETO-24."""
|
||||
d = _make_dossier(dp_code="K35.8", das_codes=["S06.0"])
|
||||
report = apply_vetos(d)
|
||||
v24 = [i for i in report.issues if i.veto == "VETO-24"]
|
||||
assert len(v24) == 1
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Tests de non-régression : verdicts globaux
|
||||
# ================================================================
|
||||
|
||||
class TestVerdictIntegration:
|
||||
def test_clean_dossier_pass(self):
|
||||
"""Dossier propre → PASS (ou NEED_INFO si VETO-02 capte)."""
|
||||
d = _make_dossier(dp_code="K35.8", das_codes=["I10", "E11.9"])
|
||||
report = apply_vetos(d)
|
||||
# Pas de HARD → pas de FAIL
|
||||
assert report.verdict != "FAIL"
|
||||
|
||||
def test_multiple_atih_vetos(self):
|
||||
"""Plusieurs vetos ATIH combinés."""
|
||||
d = _make_dossier(dp_code="Z45.0", das_codes=["E10.9", "E11.9", "S72.0"])
|
||||
report = apply_vetos(d)
|
||||
veto_ids = {i.veto for i in report.issues}
|
||||
# Z45 interdit en DP → VETO-20
|
||||
assert "VETO-20" in veto_ids
|
||||
# E10+E11 → VETO-23
|
||||
assert "VETO-23" in veto_ids
|
||||
# S72 sans externe → VETO-24
|
||||
assert "VETO-24" in veto_ids
|
||||
Reference in New Issue
Block a user