""" 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