feat: qualité DP Phase 2 — filtre OCR étendu, abréviations médicales, promotion DAS→DP

- Filtre OCR : regex étendu (opérateurs +-*/), artefacts temporels (années),
  seuil digits abaissé 0.50→0.48
- Dictionnaire 41 abréviations médicales françaises (BMR, BPCO, SDRA, OAP,
  IDM, SCA, AVC, ACFA, SIDA, TDAH, etc.) avec expand_medical_abbreviations()
  appelé sur diagnostics Trackare et DAS LLM
- Promotion DAS→DP : si aucun DP extrait, le meilleur DAS (scoring
  pertinence/confiance/spécificité) est promu avec traçabilité RULE-DAS-TO-DP
- 95 nouveaux tests (OCR, abréviations, promotion, scoring, non-régression)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-20 08:37:10 +01:00
parent 6c036ed7f1
commit 1b680e9592
6 changed files with 360 additions and 5 deletions

View File

@@ -6,6 +6,7 @@ from src.medical.das_filter import (
clean_diagnostic_text,
is_valid_diagnostic_text,
correct_known_miscodes,
expand_medical_abbreviations,
SEMANTIC_REDUNDANCIES,
)
@@ -258,3 +259,74 @@ class TestSemanticRedundanciesStructure:
assert "I10" in prefixes
assert "N30" in prefixes
assert "J18" in prefixes
class TestOCRFilterExtended:
"""Tests Phase 2 : extension filtre OCR (opérateurs, années, seuil digits)."""
# --- Règle 3 étendue : opérateurs ---
def test_reject_d_minus_200(self):
assert not is_valid_diagnostic_text("D - 200")
def test_reject_w_plus_400(self):
assert not is_valid_diagnostic_text("W + 400")
def test_reject_x_dash_2(self):
assert not is_valid_diagnostic_text("X-2")
def test_reject_h_4_digits(self):
assert not is_valid_diagnostic_text("H 1234")
# --- Règle 3b : références temporelles ---
def test_reject_year_reference(self):
assert not is_valid_diagnostic_text("X 2 en 2013")
def test_reject_year_reference_2020(self):
assert not is_valid_diagnostic_text("A depuis 2020")
# --- Non-régression : vrais diagnostics toujours acceptés ---
def test_accept_diabete_type_2(self):
assert is_valid_diagnostic_text("Diabète de type 2")
def test_accept_fracture_col_femur(self):
assert is_valid_diagnostic_text("Fracture du col du fémur")
def test_accept_pancreatite_aigue(self):
assert is_valid_diagnostic_text("Pancréatite aiguë biliaire")
class TestExpandMedicalAbbreviations:
"""Tests Phase 2 : expansion des abréviations médicales."""
def test_bmr(self):
assert expand_medical_abbreviations("BMR") == "Bactérie multi-résistante"
def test_sdra(self):
assert expand_medical_abbreviations("SDRA") == "Syndrome de détresse respiratoire aiguë"
def test_bpco(self):
assert expand_medical_abbreviations("BPCO") == "Bronchopneumopathie chronique obstructive"
def test_hta(self):
assert expand_medical_abbreviations("HTA") == "Hypertension artérielle"
def test_fa(self):
assert expand_medical_abbreviations("FA") == "Fibrillation auriculaire"
def test_case_insensitive(self):
assert expand_medical_abbreviations("bpco") == "Bronchopneumopathie chronique obstructive"
def test_with_spaces(self):
assert expand_medical_abbreviations(" BMR ") == "Bactérie multi-résistante"
def test_compound_unchanged(self):
"""FA paroxystique ne doit PAS être expansé."""
assert expand_medical_abbreviations("FA paroxystique") == "FA paroxystique"
def test_compound_bpco_unchanged(self):
"""BPCO sévère ne doit PAS être expansé."""
assert expand_medical_abbreviations("BPCO sévère") == "BPCO sévère"
def test_unknown_unchanged(self):
"""Texte non-abréviation reste inchangé."""
assert expand_medical_abbreviations("Pancréatite aiguë") == "Pancréatite aiguë"

View File

@@ -0,0 +1,150 @@
"""Tests unitaires pour le moteur de décisions (promotion DAS→DP)."""
from unittest.mock import patch
import pytest
from src.config import CodeDecision, Diagnostic, DossierMedical
from src.quality.decision_engine import (
_das_promotion_score,
apply_decisions,
decision_summaries,
)
def _make_dossier(dp=None, das_list=None):
"""Helper : crée un DossierMedical minimal."""
d = DossierMedical()
d.diagnostic_principal = dp
d.diagnostics_associes = das_list or []
return d
def _make_diag(texte, code, confidence="high", source="trackare", status=None, cim10_final=None):
"""Helper : crée un Diagnostic avec suggestion et optionnellement un final pré-rempli."""
return Diagnostic(
texte=texte,
cim10_suggestion=code,
cim10_confidence=confidence,
source=source,
status=status,
cim10_final=cim10_final,
)
# --- Scoring ---
class TestDasPromotionScore:
def test_pathology_beats_symptom(self):
patho = _make_diag("Pancréatite", "K85.9", cim10_final="K85.9")
symptom = _make_diag("Douleur abdominale", "R10.4", cim10_final="R10.4")
assert _das_promotion_score(patho) > _das_promotion_score(symptom)
def test_symptom_beats_zcode(self):
symptom = _make_diag("Douleur abdominale", "R10.4", cim10_final="R10.4")
zcode = _make_diag("Antécédent", "Z87.1", cim10_final="Z87.1")
assert _das_promotion_score(symptom) > _das_promotion_score(zcode)
def test_high_confidence_beats_medium(self):
high = _make_diag("Pancréatite", "K85.9", confidence="high", cim10_final="K85.9")
med = _make_diag("Pancréatite", "K85.9", confidence="medium", cim10_final="K85.9")
assert _das_promotion_score(high) > _das_promotion_score(med)
def test_longer_code_more_specific(self):
short = _make_diag("Pancréatite", "K85", cim10_final="K85")
long = _make_diag("Pancréatite biliaire", "K85.1", cim10_final="K85.1")
assert _das_promotion_score(long) > _das_promotion_score(short)
# --- Promotion DAS→DP ---
@patch("src.quality.decision_engine.load_reference_ranges", return_value={})
@patch("src.quality.decision_engine.load_bio_rules", return_value={})
class TestPromotionDasToDP:
@patch("src.quality.decision_engine.rule_enabled", return_value=True)
@patch("src.quality.decision_engine.cim10_validate", return_value=(True, "label"))
def test_promote_best_das_when_no_dp(self, mock_validate, mock_rule, mock_bio, mock_ref):
"""DP absent + DAS valides → meilleur DAS promu (pathologie > symptôme > Z)."""
das1 = _make_diag("Douleur abdominale", "R10.4", confidence="high")
das2 = _make_diag("Pancréatite aiguë", "K85.9", confidence="high")
das3 = _make_diag("Antécédent chirurgical", "Z87.1", confidence="medium")
dossier = _make_dossier(dp=None, das_list=[das1, das2, das3])
apply_decisions(dossier)
assert dossier.diagnostic_principal is not None
assert dossier.diagnostic_principal.cim10_final == "K85.9"
assert dossier.diagnostic_principal.cim10_decision.action == "PROMOTE_DP"
assert "RULE-DAS-TO-DP" in dossier.diagnostic_principal.cim10_decision.applied_rules
# Le DAS promu est retiré de la liste
codes_das = [d.cim10_suggestion for d in dossier.diagnostics_associes]
assert "K85.9" not in codes_das
assert len(dossier.diagnostics_associes) == 2
@patch("src.quality.decision_engine.rule_enabled", return_value=True)
@patch("src.quality.decision_engine.cim10_validate", return_value=(True, "label"))
def test_no_promotion_when_dp_present(self, mock_validate, mock_rule, mock_bio, mock_ref):
"""DP déjà présent → pas de promotion."""
dp = _make_diag("Cholécystite aiguë", "K81.0", confidence="high")
das1 = _make_diag("HTA", "I10", confidence="high")
dossier = _make_dossier(dp=dp, das_list=[das1])
apply_decisions(dossier)
assert dossier.diagnostic_principal.cim10_suggestion == "K81.0"
assert len(dossier.diagnostics_associes) == 1
@patch("src.quality.decision_engine.rule_enabled", return_value=True)
@patch("src.quality.decision_engine.cim10_validate", return_value=(True, "label"))
def test_no_promotion_for_ruled_out(self, mock_validate, mock_rule, mock_bio, mock_ref):
"""DAS ruled_out ne doit pas être promu."""
das1 = _make_diag("Thrombopénie", "D69.6", status="ruled_out")
dossier = _make_dossier(dp=None, das_list=[das1])
apply_decisions(dossier)
# D69.6 est ruled_out donc cim10_final est None → pas candidat
assert dossier.diagnostic_principal is None
@patch("src.quality.decision_engine.rule_enabled", return_value=True)
@patch("src.quality.decision_engine.cim10_validate", return_value=(False, None))
def test_no_promotion_without_cim10_final(self, mock_validate, mock_rule, mock_bio, mock_ref):
"""DAS sans cim10_final (code invalide) ne doit pas être promu."""
das1 = _make_diag("Diagnostic inconnu", "XXX.X")
dossier = _make_dossier(dp=None, das_list=[das1])
apply_decisions(dossier)
# Code invalide → cim10_final non rempli → pas candidat
assert dossier.diagnostic_principal is None
def test_no_promotion_when_rule_disabled(self, mock_bio, mock_ref):
"""RULE-DAS-TO-DP désactivée → pas de promotion."""
def _rule_enabled_selective(rule_id):
if rule_id == "RULE-DAS-TO-DP":
return False
return True
with patch("src.quality.decision_engine.rule_enabled", side_effect=_rule_enabled_selective):
with patch("src.quality.decision_engine.cim10_validate", return_value=(True, "label")):
das1 = _make_diag("Pancréatite aiguë", "K85.9", confidence="high")
dossier = _make_dossier(dp=None, das_list=[das1])
apply_decisions(dossier)
assert dossier.diagnostic_principal is None
# --- Summary handler ---
class TestDecisionSummaryPromoteDP:
def test_promote_dp_summary(self):
dp = _make_diag("Pancréatite aiguë", "K85.9", cim10_final="K85.9")
dp.cim10_decision = CodeDecision(
action="PROMOTE_DP",
final_code="K85.9",
applied_rules=["RULE-DAS-TO-DP"],
)
dossier = _make_dossier(dp=dp)
lines = decision_summaries(dossier)
assert any("PROMOTE_DP" in line or "promu en DP" in line for line in lines)
assert any("K85.9" in line for line in lines)