663 lines
20 KiB
Python
663 lines
20 KiB
Python
"""
|
|
Tests pour le Vérificateur.
|
|
|
|
Ces tests vérifient que le Vérificateur:
|
|
- Utilise un prompt différent du Codeur (Exigence 4.1)
|
|
- Détecte les erreurs sensibles DIM (Exigences 4.2, 4.3, 4.4)
|
|
- Génère des vetos pour contradictions bloquantes (Exigence 4.5)
|
|
- Marque "à_revoir" pour contradictions non-bloquantes (Exigence 4.6)
|
|
- Fournit des alternatives (Exigence 4.6)
|
|
"""
|
|
|
|
import hashlib
|
|
from datetime import datetime
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from pipeline_mco_pmsi.models.clinical import (
|
|
ClinicalFact,
|
|
Evidence,
|
|
Qualifier,
|
|
Span,
|
|
)
|
|
from pipeline_mco_pmsi.models.coding import (
|
|
Code,
|
|
CodingProposal,
|
|
DIMError,
|
|
)
|
|
from pipeline_mco_pmsi.models.metadata import ModelVersion, StayMetadata
|
|
from pipeline_mco_pmsi.verifiers.verificateur import Verificateur
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_rag_engine():
|
|
"""Crée un mock du RAG Engine."""
|
|
return MagicMock()
|
|
|
|
|
|
@pytest.fixture
|
|
def verificateur(mock_rag_engine):
|
|
"""Crée une instance du Vérificateur avec un RAG Engine mocké."""
|
|
return Verificateur(
|
|
rag_engine=mock_rag_engine,
|
|
model_name="mock-llm",
|
|
model_version="1.0.0",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def stay_metadata():
|
|
"""Crée des métadonnées de séjour pour les tests."""
|
|
return StayMetadata(
|
|
stay_id="stay_001",
|
|
admission_date=datetime(2024, 1, 1),
|
|
discharge_date=datetime(2024, 1, 5),
|
|
specialty="Chirurgie",
|
|
unit="Bloc opératoire",
|
|
age=45,
|
|
sex="M",
|
|
)
|
|
|
|
|
|
def create_evidence(doc_id="doc_001", start=100, end=120, text="Appendicite aiguë"):
|
|
"""Helper pour créer une preuve."""
|
|
return Evidence(
|
|
document_id=doc_id,
|
|
span=Span(start=start, end=end),
|
|
text=text,
|
|
context=f"Le patient présente {text}",
|
|
)
|
|
|
|
|
|
def create_qualifier(certainty="affirmé", markers=None, confidence=0.95):
|
|
"""Helper pour créer un qualificateur."""
|
|
return Qualifier(
|
|
certainty=certainty,
|
|
markers=markers or [],
|
|
confidence=confidence,
|
|
)
|
|
|
|
|
|
def create_fact(
|
|
fact_id="f_001",
|
|
fact_type="diagnostic",
|
|
text="Appendicite aiguë",
|
|
certainty="affirmé",
|
|
temporality="actuel",
|
|
evidence=None,
|
|
):
|
|
"""Helper pour créer un fait clinique."""
|
|
if evidence is None:
|
|
evidence = create_evidence(text=text)
|
|
|
|
return ClinicalFact(
|
|
fact_id=fact_id,
|
|
type=fact_type,
|
|
text=text,
|
|
qualifier=create_qualifier(certainty=certainty),
|
|
temporality=temporality,
|
|
evidence=evidence,
|
|
confidence=0.90,
|
|
)
|
|
|
|
|
|
def create_code(
|
|
code="K35.8",
|
|
label="Appendicite aiguë",
|
|
code_type="dp",
|
|
evidence=None,
|
|
confidence=0.85,
|
|
):
|
|
"""Helper pour créer un code."""
|
|
if evidence is None:
|
|
evidence = [create_evidence(text=label)]
|
|
|
|
return Code(
|
|
code=code,
|
|
label=label,
|
|
type=code_type,
|
|
evidence=evidence,
|
|
confidence=confidence,
|
|
reasoning=f"Code {code_type} proposé pour {label}",
|
|
referentiel_version="2026",
|
|
)
|
|
|
|
|
|
def create_proposal(
|
|
stay_id="stay_001",
|
|
dp=None,
|
|
dr=None,
|
|
das=None,
|
|
ccam=None,
|
|
prompt_version="codeur-1.0.0",
|
|
):
|
|
"""Helper pour créer une proposition de codage."""
|
|
model_version = ModelVersion(
|
|
model_name="mock-llm",
|
|
model_tag="1.0.0",
|
|
model_digest=hashlib.sha256(b"mock-llm:1.0.0").hexdigest(),
|
|
quantization=None,
|
|
)
|
|
|
|
return CodingProposal(
|
|
stay_id=stay_id,
|
|
dp=dp,
|
|
dr=dr,
|
|
das=das or [],
|
|
ccam=ccam or [],
|
|
reasoning="Proposition de codage basée sur les faits cliniques",
|
|
model_version=model_version,
|
|
prompt_version=prompt_version,
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Tests d'initialisation
|
|
# ============================================================================
|
|
|
|
def test_verificateur_initialization(verificateur):
|
|
"""Test l'initialisation du Vérificateur."""
|
|
assert verificateur.model_name == "mock-llm"
|
|
assert verificateur.model_version_str == "1.0.0"
|
|
assert verificateur.VERIFICATEUR_PROMPT_VERSION == "verificateur-1.0.0"
|
|
assert verificateur.model_digest is not None
|
|
|
|
|
|
def test_verificateur_prompt_version_different_from_codeur(verificateur):
|
|
"""
|
|
Test que le Vérificateur utilise un prompt différent du Codeur.
|
|
|
|
Exigence 4.1: Le Vérificateur DOIT utiliser un prompt différent du Codeur.
|
|
"""
|
|
codeur_prompt_version = "codeur-1.0.0"
|
|
assert verificateur.VERIFICATEUR_PROMPT_VERSION != codeur_prompt_version
|
|
|
|
|
|
# ============================================================================
|
|
# Tests de verify_proposal()
|
|
# ============================================================================
|
|
|
|
def test_verify_proposal_accepts_valid_proposal(verificateur):
|
|
"""
|
|
Test que le Vérificateur accepte une proposition valide.
|
|
|
|
Exigence 4.8: Quand le Vérificateur confirme la proposition,
|
|
le système doit accepter la proposition.
|
|
"""
|
|
# Créer une proposition valide
|
|
evidence = create_evidence()
|
|
fact = create_fact(evidence=evidence)
|
|
dp = create_code(evidence=[evidence])
|
|
proposal = create_proposal(dp=dp)
|
|
|
|
# Vérifier
|
|
result = verificateur.verify_proposal(proposal, [fact])
|
|
|
|
# Assertions
|
|
assert result.decision == "accept"
|
|
assert len(result.dim_errors) == 0
|
|
assert len(result.contradictions) == 0
|
|
assert result.stay_id == "stay_001"
|
|
assert result.prompt_version == "verificateur-1.0.0"
|
|
|
|
|
|
def test_verify_proposal_rejects_same_prompt_version(verificateur):
|
|
"""
|
|
Test que le Vérificateur rejette une proposition avec le même prompt.
|
|
|
|
Exigence 4.1: Le Vérificateur DOIT utiliser un prompt différent du Codeur.
|
|
"""
|
|
evidence = create_evidence()
|
|
fact = create_fact(evidence=evidence)
|
|
dp = create_code(evidence=[evidence])
|
|
|
|
# Créer une proposition avec le même prompt que le Vérificateur
|
|
proposal = create_proposal(
|
|
dp=dp,
|
|
prompt_version="verificateur-1.0.0" # MÊME prompt!
|
|
)
|
|
|
|
# Vérifier que cela lève une erreur
|
|
with pytest.raises(ValueError, match="DOIT utiliser un prompt différent"):
|
|
verificateur.verify_proposal(proposal, [fact])
|
|
|
|
|
|
# ============================================================================
|
|
# Tests de detect_dim_errors() - Diagnostics niés
|
|
# ============================================================================
|
|
|
|
def test_detect_dim_errors_negated_diagnostic(verificateur):
|
|
"""
|
|
Test la détection de diagnostic nié codé comme affirmé.
|
|
|
|
Exigence 4.2, 19.1: Le Vérificateur DOIT détecter les diagnostics niés
|
|
codés comme affirmés.
|
|
"""
|
|
# Créer un fait nié
|
|
evidence = create_evidence(text="Pas d'appendicite")
|
|
fact = create_fact(
|
|
text="Pas d'appendicite",
|
|
certainty="nié",
|
|
evidence=evidence,
|
|
)
|
|
|
|
# Créer un code basé sur ce fait nié
|
|
dp = create_code(
|
|
code="K35.8",
|
|
label="Appendicite",
|
|
evidence=[evidence],
|
|
)
|
|
proposal = create_proposal(dp=dp)
|
|
|
|
# Détecter les erreurs
|
|
errors = verificateur.detect_dim_errors(proposal, [fact])
|
|
|
|
# Assertions
|
|
assert len(errors) == 1
|
|
assert errors[0].error_type == "negated_as_affirmed"
|
|
assert errors[0].severity == "bloquant"
|
|
assert "K35.8" in errors[0].affected_codes
|
|
|
|
|
|
def test_detect_dim_errors_suspected_as_dp(verificateur):
|
|
"""
|
|
Test la détection de diagnostic suspecté codé comme DP.
|
|
|
|
Exigence 19.2: Le Vérificateur DOIT détecter la transformation
|
|
de suspicion en certitude.
|
|
"""
|
|
# Créer un fait suspecté
|
|
evidence = create_evidence(text="Appendicite possible")
|
|
fact = create_fact(
|
|
text="Appendicite possible",
|
|
certainty="suspecté",
|
|
evidence=evidence,
|
|
)
|
|
|
|
# Créer un DP basé sur ce fait suspecté
|
|
dp = create_code(
|
|
code="K35.8",
|
|
label="Appendicite",
|
|
code_type="dp",
|
|
evidence=[evidence],
|
|
)
|
|
proposal = create_proposal(dp=dp)
|
|
|
|
# Détecter les erreurs
|
|
errors = verificateur.detect_dim_errors(proposal, [fact])
|
|
|
|
# Assertions
|
|
assert len(errors) == 1
|
|
assert errors[0].error_type == "suspected_as_certain"
|
|
assert errors[0].severity == "bloquant"
|
|
|
|
|
|
# ============================================================================
|
|
# Tests de detect_dim_errors() - Antécédents
|
|
# ============================================================================
|
|
|
|
def test_detect_dim_errors_history_as_dp(verificateur):
|
|
"""
|
|
Test la détection d'antécédent codé comme DP.
|
|
|
|
Exigence 4.4, 19.4: Le Vérificateur DOIT détecter les antécédents
|
|
codés comme épisode actuel.
|
|
"""
|
|
# Créer un fait antécédent
|
|
evidence = create_evidence(text="Antécédent d'appendicectomie")
|
|
fact = create_fact(
|
|
text="Antécédent d'appendicectomie",
|
|
temporality="antecedent",
|
|
evidence=evidence,
|
|
)
|
|
|
|
# Créer un DP basé sur cet antécédent
|
|
dp = create_code(
|
|
code="Z90.4",
|
|
label="Absence d'appendice",
|
|
code_type="dp",
|
|
evidence=[evidence],
|
|
)
|
|
proposal = create_proposal(dp=dp)
|
|
|
|
# Détecter les erreurs
|
|
errors = verificateur.detect_dim_errors(proposal, [fact])
|
|
|
|
# Assertions
|
|
assert len(errors) == 1
|
|
assert errors[0].error_type == "history_as_current"
|
|
assert errors[0].severity == "bloquant"
|
|
|
|
|
|
# ============================================================================
|
|
# Tests de detect_dim_errors() - Actes CCAM
|
|
# ============================================================================
|
|
|
|
def test_detect_dim_errors_ccam_without_evidence(verificateur):
|
|
"""
|
|
Test la détection d'acte CCAM sans preuve explicite.
|
|
|
|
Exigence 4.3, 19.3: Le Vérificateur DOIT détecter les actes CCAM
|
|
sans preuve explicite.
|
|
"""
|
|
# Créer un fait diagnostic (pas un acte)
|
|
evidence = create_evidence(text="Appendicite")
|
|
fact = create_fact(
|
|
fact_type="diagnostic", # Pas un acte!
|
|
text="Appendicite",
|
|
evidence=evidence,
|
|
)
|
|
|
|
# Créer un code CCAM basé sur ce fait diagnostic
|
|
ccam = create_code(
|
|
code="HHFA001",
|
|
label="Appendicectomie",
|
|
code_type="ccam",
|
|
evidence=[evidence],
|
|
)
|
|
proposal = create_proposal(ccam=[ccam])
|
|
|
|
# Détecter les erreurs
|
|
errors = verificateur.detect_dim_errors(proposal, [fact])
|
|
|
|
# Assertions
|
|
assert len(errors) == 1
|
|
assert errors[0].error_type == "act_without_evidence"
|
|
assert errors[0].severity == "bloquant"
|
|
|
|
|
|
def test_detect_dim_errors_ccam_with_valid_evidence(verificateur):
|
|
"""
|
|
Test qu'un acte CCAM avec preuve valide n'est pas signalé.
|
|
"""
|
|
# Créer un fait acte
|
|
evidence = create_evidence(text="Appendicectomie réalisée")
|
|
fact = create_fact(
|
|
fact_type="acte", # Type correct
|
|
text="Appendicectomie réalisée",
|
|
evidence=evidence,
|
|
)
|
|
|
|
# Créer un code CCAM basé sur ce fait acte
|
|
ccam = create_code(
|
|
code="HHFA001",
|
|
label="Appendicectomie",
|
|
code_type="ccam",
|
|
evidence=[evidence],
|
|
)
|
|
proposal = create_proposal(ccam=[ccam])
|
|
|
|
# Détecter les erreurs
|
|
errors = verificateur.detect_dim_errors(proposal, [fact])
|
|
|
|
# Assertions
|
|
assert len(errors) == 0
|
|
|
|
|
|
# ============================================================================
|
|
# Tests de detect_dim_errors() - Inversions DP/DAS
|
|
# ============================================================================
|
|
|
|
def test_detect_dim_errors_dp_das_inversion(verificateur):
|
|
"""
|
|
Test la détection d'inversion DP/DAS.
|
|
|
|
Exigence 19.5: Le Vérificateur DOIT détecter les inversions
|
|
grossières d'assignation DP/DAS.
|
|
"""
|
|
# Créer des faits
|
|
evidence_dp = create_evidence(text="Diabète")
|
|
fact_dp = create_fact(fact_id="f_001", text="Diabète", evidence=evidence_dp)
|
|
|
|
evidence_das = create_evidence(start=200, end=220, text="Appendicite aiguë")
|
|
fact_das = create_fact(fact_id="f_002", text="Appendicite aiguë", evidence=evidence_das)
|
|
|
|
# Créer un DP avec faible confiance et un DAS avec forte confiance
|
|
dp = create_code(
|
|
code="E11.9",
|
|
label="Diabète",
|
|
code_type="dp",
|
|
evidence=[evidence_dp],
|
|
confidence=0.60, # Faible
|
|
)
|
|
das = create_code(
|
|
code="K35.8",
|
|
label="Appendicite aiguë",
|
|
code_type="das",
|
|
evidence=[evidence_das],
|
|
confidence=0.95, # Forte (> DP + 0.1)
|
|
)
|
|
proposal = create_proposal(dp=dp, das=[das])
|
|
|
|
# Détecter les erreurs
|
|
errors = verificateur.detect_dim_errors(proposal, [fact_dp, fact_das])
|
|
|
|
# Assertions
|
|
assert len(errors) == 1
|
|
assert errors[0].error_type == "dp_das_inversion"
|
|
assert errors[0].severity == "a_revoir"
|
|
assert "E11.9" in errors[0].affected_codes
|
|
assert "K35.8" in errors[0].affected_codes
|
|
|
|
|
|
# ============================================================================
|
|
# Tests de décision (accept/veto/review)
|
|
# ============================================================================
|
|
|
|
def test_verify_proposal_veto_on_blocking_error(verificateur):
|
|
"""
|
|
Test que le Vérificateur génère un veto pour une erreur bloquante.
|
|
|
|
Exigence 4.5: Quand le Vérificateur détecte une contradiction bloquante,
|
|
le système doit opposer un veto.
|
|
"""
|
|
# Créer un fait nié
|
|
evidence = create_evidence(text="Pas d'appendicite")
|
|
fact = create_fact(
|
|
text="Pas d'appendicite",
|
|
certainty="nié",
|
|
evidence=evidence,
|
|
)
|
|
|
|
# Créer un code basé sur ce fait nié (erreur bloquante)
|
|
dp = create_code(evidence=[evidence])
|
|
proposal = create_proposal(dp=dp)
|
|
|
|
# Vérifier
|
|
result = verificateur.verify_proposal(proposal, [fact])
|
|
|
|
# Assertions
|
|
assert result.decision == "veto"
|
|
assert len(result.dim_errors) == 1
|
|
assert result.dim_errors[0].severity == "bloquant"
|
|
|
|
|
|
def test_verify_proposal_review_on_non_blocking_error(verificateur):
|
|
"""
|
|
Test que le Vérificateur marque "à_revoir" pour une erreur non-bloquante.
|
|
|
|
Exigence 4.6: Quand le Vérificateur détecte une contradiction non-bloquante,
|
|
le système doit marquer le code comme "à_revoir".
|
|
"""
|
|
# Créer des faits pour inversion DP/DAS
|
|
evidence_dp = create_evidence(text="Diabète")
|
|
fact_dp = create_fact(fact_id="f_001", text="Diabète", evidence=evidence_dp)
|
|
|
|
evidence_das = create_evidence(start=200, end=220, text="Appendicite")
|
|
fact_das = create_fact(fact_id="f_002", text="Appendicite", evidence=evidence_das)
|
|
|
|
# Créer une inversion DP/DAS (erreur non-bloquante)
|
|
dp = create_code(
|
|
code="E11.9",
|
|
label="Diabète",
|
|
code_type="dp",
|
|
evidence=[evidence_dp],
|
|
confidence=0.60,
|
|
)
|
|
das = create_code(
|
|
code="K35.8",
|
|
label="Appendicite",
|
|
code_type="das",
|
|
evidence=[evidence_das],
|
|
confidence=0.95,
|
|
)
|
|
proposal = create_proposal(dp=dp, das=[das])
|
|
|
|
# Vérifier
|
|
result = verificateur.verify_proposal(proposal, [fact_dp, fact_das])
|
|
|
|
# Assertions
|
|
assert result.decision == "review"
|
|
assert len(result.dim_errors) == 1
|
|
assert result.dim_errors[0].severity == "a_revoir"
|
|
|
|
|
|
# ============================================================================
|
|
# Tests d'alternatives
|
|
# ============================================================================
|
|
|
|
def test_verify_proposal_provides_alternatives(verificateur):
|
|
"""
|
|
Test que le Vérificateur fournit des alternatives.
|
|
|
|
Exigence 4.6: Le Vérificateur doit fournir des alternatives.
|
|
"""
|
|
# Créer un fait nié pour le DP
|
|
evidence_dp = create_evidence(text="Pas d'appendicite")
|
|
fact_dp = create_fact(
|
|
fact_id="f_001",
|
|
text="Pas d'appendicite",
|
|
certainty="nié",
|
|
evidence=evidence_dp,
|
|
)
|
|
|
|
# Créer un fait valide pour le DAS
|
|
evidence_das = create_evidence(start=200, end=220, text="Gastrite")
|
|
fact_das = create_fact(
|
|
fact_id="f_002",
|
|
text="Gastrite",
|
|
evidence=evidence_das,
|
|
)
|
|
|
|
# Créer une proposition avec DP invalide et DAS valide
|
|
dp = create_code(
|
|
code="K35.8",
|
|
label="Appendicite",
|
|
code_type="dp",
|
|
evidence=[evidence_dp],
|
|
)
|
|
das = create_code(
|
|
code="K29.7",
|
|
label="Gastrite",
|
|
code_type="das",
|
|
evidence=[evidence_das],
|
|
confidence=0.90,
|
|
)
|
|
proposal = create_proposal(dp=dp, das=[das])
|
|
|
|
# Vérifier
|
|
result = verificateur.verify_proposal(proposal, [fact_dp, fact_das])
|
|
|
|
# Assertions
|
|
assert result.decision == "veto"
|
|
assert len(result.alternatives) > 0
|
|
# L'alternative devrait être le DAS comme nouveau DP
|
|
assert result.alternatives[0].code == "K29.7"
|
|
assert result.alternatives[0].type == "dp"
|
|
|
|
|
|
# ============================================================================
|
|
# Tests de raisonnement
|
|
# ============================================================================
|
|
|
|
def test_verify_proposal_generates_reasoning(verificateur):
|
|
"""
|
|
Test que le Vérificateur génère un raisonnement détaillé.
|
|
"""
|
|
evidence = create_evidence()
|
|
fact = create_fact(evidence=evidence)
|
|
dp = create_code(evidence=[evidence])
|
|
proposal = create_proposal(dp=dp)
|
|
|
|
# Vérifier
|
|
result = verificateur.verify_proposal(proposal, [fact])
|
|
|
|
# Assertions
|
|
assert result.reasoning is not None
|
|
assert len(result.reasoning) > 0
|
|
assert "Vérification indépendante" in result.reasoning
|
|
assert "verificateur-1.0.0" in result.reasoning
|
|
assert "codeur-1.0.0" in result.reasoning
|
|
|
|
|
|
# ============================================================================
|
|
# Tests de cas complexes
|
|
# ============================================================================
|
|
|
|
def test_verify_proposal_multiple_errors(verificateur):
|
|
"""
|
|
Test la détection de plusieurs erreurs simultanées.
|
|
"""
|
|
# Créer plusieurs faits avec erreurs
|
|
evidence1 = create_evidence(text="Pas d'appendicite")
|
|
fact1 = create_fact(
|
|
fact_id="f_001",
|
|
text="Pas d'appendicite",
|
|
certainty="nié",
|
|
evidence=evidence1,
|
|
)
|
|
|
|
evidence2 = create_evidence(start=200, end=220, text="Diabète ancien")
|
|
fact2 = create_fact(
|
|
fact_id="f_002",
|
|
text="Diabète ancien",
|
|
temporality="antecedent",
|
|
evidence=evidence2,
|
|
)
|
|
|
|
# Créer des codes avec erreurs
|
|
dp = create_code(
|
|
code="K35.8",
|
|
label="Appendicite",
|
|
code_type="dp",
|
|
evidence=[evidence1],
|
|
)
|
|
das = create_code(
|
|
code="E11.9",
|
|
label="Diabète",
|
|
code_type="das",
|
|
evidence=[evidence2],
|
|
)
|
|
proposal = create_proposal(dp=dp, das=[das])
|
|
|
|
# Vérifier
|
|
result = verificateur.verify_proposal(proposal, [fact1, fact2])
|
|
|
|
# Assertions
|
|
assert result.decision == "veto"
|
|
assert len(result.dim_errors) >= 1 # Au moins l'erreur du DP nié
|
|
|
|
|
|
def test_verify_proposal_no_errors_with_das_only(verificateur):
|
|
"""
|
|
Test qu'une proposition sans DP mais avec DAS valides est acceptée.
|
|
"""
|
|
evidence = create_evidence(text="Diabète")
|
|
fact = create_fact(text="Diabète", evidence=evidence)
|
|
|
|
das = create_code(
|
|
code="E11.9",
|
|
label="Diabète",
|
|
code_type="das",
|
|
evidence=[evidence],
|
|
)
|
|
proposal = create_proposal(dp=None, das=[das])
|
|
|
|
# Vérifier
|
|
result = verificateur.verify_proposal(proposal, [fact])
|
|
|
|
# Assertions
|
|
assert result.decision == "accept"
|
|
assert len(result.dim_errors) == 0
|