From 0b9429997555f96d929572c4d203b230a01feb40 Mon Sep 17 00:00:00 2001 From: dom Date: Fri, 20 Feb 2026 00:39:07 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20fix=20extraction=20DP=20Trackare=20+=20?= =?UTF-8?q?5=20r=C3=A8gles=20ATIH=20(veto=20engine)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- config/rules/base.yaml | 19 +++ src/medical/cim10_extractor.py | 7 +- src/quality/veto_engine.py | 78 +++++++++++ tests/test_atih_rules.py | 243 +++++++++++++++++++++++++++++++++ 4 files changed, 345 insertions(+), 2 deletions(-) create mode 100644 tests/test_atih_rules.py diff --git a/config/rules/base.yaml b/config/rules/base.yaml index cead8d9..9e385d8 100644 --- a/config/rules/base.yaml +++ b/config/rules/base.yaml @@ -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: diff --git a/src/medical/cim10_extractor.py b/src/medical/cim10_extractor.py index e58e7cd..76cb7ab 100644 --- a/src/medical/cim10_extractor.py +++ b/src/medical/cim10_extractor.py @@ -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) diff --git a/src/quality/veto_engine.py b/src/quality/veto_engine.py index 170da32..d1af0a4 100644 --- a/src/quality/veto_engine.py +++ b/src/quality/veto_engine.py @@ -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', diff --git a/tests/test_atih_rules.py b/tests/test_atih_rules.py new file mode 100644 index 0000000..afd70d5 --- /dev/null +++ b/tests/test_atih_rules.py @@ -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