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

@@ -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)