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:
@@ -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ë"
|
||||
|
||||
150
tests/test_decision_engine.py
Normal file
150
tests/test_decision_engine.py
Normal 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)
|
||||
Reference in New Issue
Block a user