From 837bdaca7697a59a130b2946c1b80cb9d5c00c05 Mon Sep 17 00:00:00 2001 From: dom Date: Fri, 13 Feb 2026 14:03:10 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20filtre=20DAS=3DDP=20+=20correction=20D55?= =?UTF-8?q?.9=E2=86=92D64.9=20+=20enrichissement=20supplements=20CIM-10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Filtre DAS identique au DP (violation règle PMSI) dans extracteur et fusion - Correction automatique D55.9 → D64.9 pour "Anémie" non qualifiée (70 cas) - 17 codes ajoutés aux supplements (K59.0, Z93.1, H92.0, A87.0, D64.9, etc.) - 436 tests OK (+14 nouveaux) Co-Authored-By: Claude Opus 4.6 --- data/cim10_supplements.json | 19 ++++++++++++++++- src/medical/cim10_extractor.py | 38 ++++++++++++++++++++++++++++++++- src/medical/das_filter.py | 24 +++++++++++++++++++++ src/medical/fusion.py | 7 ++++++ tests/test_cim10_supplements.py | 29 +++++++++++++++++++++++++ tests/test_das_filter.py | 34 ++++++++++++++++++++++++++++- tests/test_fusion.py | 31 +++++++++++++++++++++++++++ 7 files changed, 179 insertions(+), 3 deletions(-) diff --git a/data/cim10_supplements.json b/data/cim10_supplements.json index 8aec657..f4dafcb 100644 --- a/data/cim10_supplements.json +++ b/data/cim10_supplements.json @@ -38,5 +38,22 @@ "F10.6": "Troubles mentaux et du comportement liés à l'utilisation d'alcool, syndrome amnésique", "F10.7": "Troubles mentaux et du comportement liés à l'utilisation d'alcool, trouble résiduel ou psychotique de survenue tardive", "F10.8": "Troubles mentaux et du comportement liés à l'utilisation d'alcool, autres troubles mentaux et du comportement", - "F10.9": "Troubles mentaux et du comportement liés à l'utilisation d'alcool, trouble mental ou du comportement, sans précision" + "F10.9": "Troubles mentaux et du comportement liés à l'utilisation d'alcool, trouble mental ou du comportement, sans précision", + "A87.0": "Méningite à entérovirus", + "D64.9": "Anémie, sans précision", + "H15.1": "Épisclérite", + "H36.0": "Rétinopathie diabétique", + "H92.0": "Otalgie", + "H92.1": "Otorrhée", + "K59.0": "Constipation", + "K59.9": "Trouble fonctionnel de l'intestin, sans précision", + "M89.5": "Ostéolyse", + "N40.0": "Hypertrophie de la prostate sans obstruction urinaire", + "Z50.5": "Rééducation de la parole", + "Z93.1": "Gastrostomie", + "Z93.2": "Iléostomie", + "Z93.3": "Colostomie", + "Z93.0": "Trachéostomie", + "Z98.0": "État consécutif à une dérivation intestinale ou à une anastomose", + "Z98.1": "État consécutif à une arthrodèse" } diff --git a/src/medical/cim10_extractor.py b/src/medical/cim10_extractor.py index 7c48495..c8816e8 100644 --- a/src/medical/cim10_extractor.py +++ b/src/medical/cim10_extractor.py @@ -11,7 +11,7 @@ logger = logging.getLogger(__name__) from .cim10_dict import lookup as dict_lookup, normalize_text, normalize_code, validate_code as cim10_validate from .ccam_dict import lookup as ccam_lookup, validate_code as ccam_validate -from .das_filter import clean_diagnostic_text, is_valid_diagnostic_text +from .das_filter import clean_diagnostic_text, is_valid_diagnostic_text, correct_known_miscodes from ..config import ( ActeCCAM, BiologieCle, @@ -125,6 +125,9 @@ def extract_medical_info( # Post-processing : validation des codes CIM-10 contre le dictionnaire _validate_cim10(dossier) + # Post-processing : correction des codes systématiquement mal attribués + _apply_code_corrections(dossier) + # Post-processing : exclusions symptôme vs diagnostic précis _apply_exclusion_rules(dossier) @@ -134,6 +137,9 @@ def extract_medical_info( # Post-processing : détection non-cumul actes CCAM _apply_noncumul_rules(dossier) + # Post-processing : retirer DAS dont le code est identique au DP + _remove_das_equal_dp(dossier) + return dossier @@ -855,6 +861,36 @@ def _apply_severity_rules(dossier: DossierMedical) -> None: logger.warning("Erreur lors de l'évaluation de sévérité", exc_info=True) +def _apply_code_corrections(dossier: DossierMedical) -> None: + """Corrige les codes CIM-10 systématiquement mal attribués par le LLM.""" + all_diags = [] + if dossier.diagnostic_principal: + all_diags.append(dossier.diagnostic_principal) + all_diags.extend(dossier.diagnostics_associes) + + for diag in all_diags: + if not diag.cim10_suggestion: + continue + corrected = correct_known_miscodes(diag.cim10_suggestion, diag.texte) + if corrected: + logger.info(" Code corrigé : %s → %s pour « %s »", diag.cim10_suggestion, corrected, diag.texte) + diag.cim10_suggestion = corrected + + +def _remove_das_equal_dp(dossier: DossierMedical) -> None: + """Retire les DAS dont le code CIM-10 est identique au DP (violation règle PMSI).""" + dp_code = dossier.diagnostic_principal.cim10_suggestion if dossier.diagnostic_principal else None + if not dp_code: + return + before = len(dossier.diagnostics_associes) + dossier.diagnostics_associes = [ + d for d in dossier.diagnostics_associes if d.cim10_suggestion != dp_code + ] + removed = before - len(dossier.diagnostics_associes) + if removed: + logger.info(" DAS=DP : %d DAS retiré(s) (code %s identique au DP)", removed, dp_code) + + def _apply_noncumul_rules(dossier: DossierMedical) -> None: """Détecte les incompatibilités de non-cumul entre actes CCAM.""" try: diff --git a/src/medical/das_filter.py b/src/medical/das_filter.py index 28c070d..5023bc1 100644 --- a/src/medical/das_filter.py +++ b/src/medical/das_filter.py @@ -3,6 +3,16 @@ import re import unicodedata +# Corrections de codes CIM-10 systématiquement mal attribués par le LLM +# D55.9 (anémie enzymatique) est proposé pour "Anémie" non qualifiée → D64.9 +CODE_CORRECTIONS: dict[str, dict] = { + "D55.9": { + "correct_code": "D64.9", + "condition_texte": r"^an[ée]mie$", # uniquement si texte = "Anémie" seul + "reason": "Anémie non qualifiée → D64.9 (sans précision), pas D55.9 (enzymatique)", + }, +} + def clean_diagnostic_text(text: str) -> str: """Nettoie un texte de diagnostic (newlines, ponctuation trailing, espaces).""" @@ -74,3 +84,17 @@ def is_valid_diagnostic_text(text: str) -> bool: return False return True + + +def correct_known_miscodes(code: str, texte: str) -> str | None: + """Corrige les codes CIM-10 systématiquement mal attribués par le LLM. + + Returns: + Le code corrigé, ou None si pas de correction nécessaire. + """ + correction = CODE_CORRECTIONS.get(code) + if not correction: + return None + if re.match(correction["condition_texte"], texte.strip(), re.IGNORECASE): + return correction["correct_code"] + return None diff --git a/src/medical/fusion.py b/src/medical/fusion.py index 0c4d822..758e172 100644 --- a/src/medical/fusion.py +++ b/src/medical/fusion.py @@ -168,6 +168,13 @@ def merge_dossiers(dossiers: list[DossierMedical]) -> DossierMedical: merged.diagnostics_associes = _dedup_diagnostics(all_das) + # Retirer les DAS dont le code est identique au DP (violation règle PMSI) + dp_code = merged.diagnostic_principal.cim10_suggestion if merged.diagnostic_principal else None + if dp_code: + merged.diagnostics_associes = [ + d for d in merged.diagnostics_associes if d.cim10_suggestion != dp_code + ] + # Actes CCAM all_actes: list[ActeCCAM] = [] for d in dossiers: diff --git a/tests/test_cim10_supplements.py b/tests/test_cim10_supplements.py index 510ab12..b305e65 100644 --- a/tests/test_cim10_supplements.py +++ b/tests/test_cim10_supplements.py @@ -73,3 +73,32 @@ class TestCim10Supplements: is_valid, label = validate_code("e119") assert is_valid assert label # Label non vide + + # --- Nouveaux codes ajoutés (session 2026-02-13) --- + def test_k59_0_constipation(self): + """K59.0 (constipation) est reconnu.""" + is_valid, label = validate_code("K59.0") + assert is_valid + assert "constipation" in label.lower() + + def test_z93_1_gastrostomie(self): + """Z93.1 (gastrostomie) est reconnu.""" + is_valid, label = validate_code("Z93.1") + assert is_valid + assert "gastrostomie" in label.lower() + + def test_h92_0_otalgie(self): + """H92.0 (otalgie) est reconnu.""" + is_valid, label = validate_code("H92.0") + assert is_valid + + def test_a87_0_meningite(self): + """A87.0 (méningite à entérovirus) est reconnu.""" + is_valid, label = validate_code("A87.0") + assert is_valid + + def test_d64_9_anemie(self): + """D64.9 (anémie sans précision) est reconnu.""" + is_valid, label = validate_code("D64.9") + assert is_valid + assert "anémie" in label.lower() diff --git a/tests/test_das_filter.py b/tests/test_das_filter.py index e9356be..009a1d1 100644 --- a/tests/test_das_filter.py +++ b/tests/test_das_filter.py @@ -2,7 +2,7 @@ import pytest -from src.medical.das_filter import clean_diagnostic_text, is_valid_diagnostic_text +from src.medical.das_filter import clean_diagnostic_text, is_valid_diagnostic_text, correct_known_miscodes class TestCleanDiagnosticText: @@ -160,3 +160,35 @@ class TestIsValidDiagnosticText: def test_accept_long_fragment(self): """Un fragment long commençant par 'Dans' peut être légitime.""" assert is_valid_diagnostic_text("Dans le cadre d'une insuffisance rénale chronique terminale") + + +class TestCorrectKnownMiscodes: + """Tests pour la correction des codes systématiquement mal attribués.""" + + def test_d55_9_anemie_simple(self): + """D55.9 + texte 'Anémie' → D64.9.""" + assert correct_known_miscodes("D55.9", "Anémie") == "D64.9" + + def test_d55_9_anemie_casse(self): + """D55.9 + texte 'anémie' (minuscule) → D64.9.""" + assert correct_known_miscodes("D55.9", "anémie") == "D64.9" + + def test_d55_9_anemie_spaces(self): + """D55.9 + texte avec espaces → D64.9.""" + assert correct_known_miscodes("D55.9", " Anémie ") == "D64.9" + + def test_d55_9_anemie_qualifiee_pas_corrige(self): + """D55.9 + texte qualifié → pas de correction.""" + assert correct_known_miscodes("D55.9", "Anémie ferriprive") is None + + def test_d55_9_anemie_hemolytique_pas_corrige(self): + """D55.9 + texte spécifique → pas de correction.""" + assert correct_known_miscodes("D55.9", "Anémie hémolytique enzymatique") is None + + def test_code_inconnu_pas_corrige(self): + """Code non listé → pas de correction.""" + assert correct_known_miscodes("I10", "HTA") is None + + def test_d64_9_pas_corrige(self): + """D64.9 lui-même → pas de correction.""" + assert correct_known_miscodes("D64.9", "Anémie") is None diff --git a/tests/test_fusion.py b/tests/test_fusion.py index 44ef07d..c814f7f 100644 --- a/tests/test_fusion.py +++ b/tests/test_fusion.py @@ -155,6 +155,37 @@ class TestSourceFilesPopulated: assert result.source_files == ["a.pdf", "b.pdf"] +class TestDasEqualDpRemoved: + """Vérifie que les DAS dont le code est identique au DP sont retirés après fusion.""" + + def test_das_same_code_as_dp_removed(self): + d1 = DossierMedical( + diagnostic_principal=Diagnostic(texte="HTA", cim10_suggestion="I10"), + diagnostics_associes=[ + Diagnostic(texte="Hypertension artérielle", cim10_suggestion="I10"), + Diagnostic(texte="Diabète", cim10_suggestion="E11.9"), + ], + ) + d2 = DossierMedical( + diagnostic_principal=Diagnostic(texte="HTA essentielle", cim10_suggestion="I10"), + ) + result = merge_dossiers([d1, d2]) + das_codes = [d.cim10_suggestion for d in result.diagnostics_associes] + assert "I10" not in das_codes, "DAS=DP doit être retiré" + assert "E11.9" in das_codes + + def test_das_different_code_kept(self): + d1 = DossierMedical( + diagnostic_principal=Diagnostic(texte="Cholécystite", cim10_suggestion="K81.0"), + diagnostics_associes=[ + Diagnostic(texte="HTA", cim10_suggestion="I10"), + ], + ) + result = merge_dossiers([d1]) + das_codes = [d.cim10_suggestion for d in result.diagnostics_associes] + assert "I10" in das_codes + + class TestFullMergeCROTrackare: def test_full_merge_cro_trackare(self): """Cas réel : fusion Trackare + CRO."""