""" Tests pour le Codeur. Ces tests vérifient que le Codeur propose correctement les codes DP, DR, DAS et CCAM avec justifications, preuves et scores de confiance. """ import hashlib from datetime import datetime, timedelta from unittest.mock import MagicMock, Mock import pytest from pipeline_mco_pmsi.coders.codeur import Codeur from pipeline_mco_pmsi.models.clinical import ( ClinicalFact, Evidence, Qualifier, Span, ) from pipeline_mco_pmsi.models.coding import Code, CodeCandidate from pipeline_mco_pmsi.models.metadata import StayMetadata @pytest.fixture def mock_rag_engine(): """Crée un mock du RAG Engine.""" mock = MagicMock() return mock @pytest.fixture def codeur(mock_rag_engine): """Crée une instance du Codeur avec un RAG Engine mocké.""" return Codeur( rag_engine=mock_rag_engine, model_name="mock-llm", model_version="1.0.0", prompt_version="1.0.0", conservative_mode=True, ) @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", ) @pytest.fixture def sample_evidence(): """Crée une preuve d'exemple.""" return Evidence( document_id="doc_001", span=Span(start=100, end=120), text="Appendicite aiguë", context="Le patient présente une appendicite aiguë nécessitant une intervention", ) @pytest.fixture def sample_qualifier_affirmed(): """Crée un qualificateur affirmé.""" return Qualifier( certainty="affirmé", markers=[], confidence=0.95, ) @pytest.fixture def sample_qualifier_negated(): """Crée un qualificateur nié.""" return Qualifier( certainty="nié", markers=["pas de", "absence de"], confidence=0.90, ) @pytest.fixture def sample_qualifier_suspected(): """Crée un qualificateur suspecté.""" return Qualifier( certainty="suspecté", markers=["possible", "suspecté"], confidence=0.70, ) def test_codeur_initialization(codeur): """Test l'initialisation du Codeur.""" assert codeur.model_name == "mock-llm" assert codeur.model_version_str == "1.0.0" assert codeur.prompt_version == "1.0.0" assert codeur.conservative_mode is True assert len(codeur.model_digest) == 64 # SHA-256 def test_filter_facts_conservative_removes_negated( codeur, sample_evidence, sample_qualifier_negated ): """Test que les faits niés sont filtrés en mode conservateur.""" # Exigence 2.4 facts = [ ClinicalFact( fact_id="f_001", type="diagnostic", text="Appendicite", qualifier=sample_qualifier_negated, temporality="actuel", evidence=sample_evidence, confidence=0.9, ) ] filtered = codeur._filter_facts_conservative(facts) assert len(filtered) == 0 def test_filter_facts_conservative_keeps_affirmed( codeur, sample_evidence, sample_qualifier_affirmed ): """Test que les faits affirmés sont conservés en mode conservateur.""" facts = [ ClinicalFact( fact_id="f_001", type="diagnostic", text="Appendicite aiguë", qualifier=sample_qualifier_affirmed, temporality="actuel", evidence=sample_evidence, confidence=0.95, ) ] filtered = codeur._filter_facts_conservative(facts) assert len(filtered) == 1 assert filtered[0].fact_id == "f_001" def test_select_dp_rejects_negated_facts( codeur, sample_evidence, sample_qualifier_negated, mock_rag_engine ): """Test que le DP ne peut pas être un fait nié.""" # Exigence 2.4 facts = [ ClinicalFact( fact_id="f_001", type="diagnostic", text="Appendicite", qualifier=sample_qualifier_negated, temporality="actuel", evidence=sample_evidence, confidence=0.9, ) ] # Mock des candidats mock_rag_engine.search_icd10.return_value = [ CodeCandidate( code="K35.8", label="Appendicite aiguë", similarity_score=0.95, source="reranked", chunk_id="chunk_001", chunk_text="K35.8 Appendicite aiguë", ) ] fact_candidates = {"f_001": mock_rag_engine.search_icd10.return_value} dp = codeur._select_dp(facts, fact_candidates, "2026") assert dp is None def test_select_dp_rejects_suspected_facts( codeur, sample_evidence, sample_qualifier_suspected, mock_rag_engine ): """Test que le DP ne peut pas être un fait suspecté.""" # Exigence 2.5 facts = [ ClinicalFact( fact_id="f_001", type="diagnostic", text="Appendicite", qualifier=sample_qualifier_suspected, temporality="actuel", evidence=sample_evidence, confidence=0.7, ) ] mock_rag_engine.search_icd10.return_value = [ CodeCandidate( code="K35.8", label="Appendicite aiguë", similarity_score=0.95, source="reranked", chunk_id="chunk_001", chunk_text="K35.8 Appendicite aiguë", ) ] fact_candidates = {"f_001": mock_rag_engine.search_icd10.return_value} dp = codeur._select_dp(facts, fact_candidates, "2026") assert dp is None def test_select_dp_rejects_history_facts( codeur, sample_evidence, sample_qualifier_affirmed, mock_rag_engine ): """Test que le DP ne peut pas être un antécédent.""" # Exigence 2.6 facts = [ ClinicalFact( fact_id="f_001", type="diagnostic", text="Diabète", qualifier=sample_qualifier_affirmed, temporality="antecedent", evidence=sample_evidence, confidence=0.9, ) ] mock_rag_engine.search_icd10.return_value = [ CodeCandidate( code="E11.9", label="Diabète sucré de type 2", similarity_score=0.95, source="reranked", chunk_id="chunk_001", chunk_text="E11.9 Diabète sucré de type 2", ) ] fact_candidates = {"f_001": mock_rag_engine.search_icd10.return_value} dp = codeur._select_dp(facts, fact_candidates, "2026") assert dp is None def test_select_dp_selects_affirmed_current_diagnostic( codeur, sample_evidence, sample_qualifier_affirmed, mock_rag_engine ): """Test que le DP est correctement sélectionné pour un diagnostic affirmé actuel.""" # Exigence 8.1 facts = [ ClinicalFact( fact_id="f_001", type="diagnostic", text="Appendicite aiguë", qualifier=sample_qualifier_affirmed, temporality="actuel", evidence=sample_evidence, confidence=0.95, ) ] mock_rag_engine.search_icd10.return_value = [ CodeCandidate( code="K35.8", label="Appendicite aiguë", similarity_score=0.95, source="reranked", chunk_id="chunk_001", chunk_text="K35.8 Appendicite aiguë", ) ] fact_candidates = {"f_001": mock_rag_engine.search_icd10.return_value} dp = codeur._select_dp(facts, fact_candidates, "2026") assert dp is not None assert dp.code == "K35.8" assert dp.type == "dp" assert dp.label == "Appendicite aiguë" assert len(dp.evidence) >= 1 # Exigence 1.1 assert 0.0 <= dp.confidence <= 1.0 # Exigence 8.5 assert len(dp.reasoning) > 0 # Exigence 8.6 def test_select_dp_prioritizes_complications( codeur, sample_evidence, sample_qualifier_affirmed, mock_rag_engine ): """Test que les complications sont priorisées pour le DP.""" facts = [ ClinicalFact( fact_id="f_001", type="diagnostic", text="Diabète", qualifier=sample_qualifier_affirmed, temporality="actuel", evidence=sample_evidence, confidence=0.9, ), ClinicalFact( fact_id="f_002", type="complication", text="Péritonite", qualifier=sample_qualifier_affirmed, temporality="actuel", evidence=sample_evidence, confidence=0.85, ), ] mock_rag_engine.search_icd10.return_value = [ CodeCandidate( code="K65.0", label="Péritonite aiguë", similarity_score=0.90, source="reranked", chunk_id="chunk_002", chunk_text="K65.0 Péritonite aiguë", ) ] fact_candidates = { "f_001": [ CodeCandidate( code="E11.9", label="Diabète", similarity_score=0.95, source="reranked", chunk_id="chunk_001", chunk_text="E11.9 Diabète", ) ], "f_002": mock_rag_engine.search_icd10.return_value, } dp = codeur._select_dp(facts, fact_candidates, "2026") assert dp is not None assert dp.code == "K65.0" # La complication est sélectionnée def test_create_code_has_required_evidence( codeur, sample_evidence, sample_qualifier_affirmed ): """Test que chaque code créé a 1-3 preuves.""" # Exigence 1.1, 1.2 candidate = CodeCandidate( code="K35.8", label="Appendicite aiguë", similarity_score=0.95, source="reranked", chunk_id="chunk_001", chunk_text="K35.8 Appendicite aiguë", ) fact = ClinicalFact( fact_id="f_001", type="diagnostic", text="Appendicite aiguë", qualifier=sample_qualifier_affirmed, temporality="actuel", evidence=sample_evidence, confidence=0.95, ) code = codeur._create_code(candidate, fact, "dp", "2026") assert 1 <= len(code.evidence) <= 3 assert code.evidence[0].document_id == "doc_001" assert code.evidence[0].span.start == 100 assert code.evidence[0].span.end == 120 def test_create_code_has_confidence_score( codeur, sample_evidence, sample_qualifier_affirmed ): """Test que chaque code a un score de confiance.""" # Exigence 8.5 candidate = CodeCandidate( code="K35.8", label="Appendicite aiguë", similarity_score=0.95, source="reranked", chunk_id="chunk_001", chunk_text="K35.8 Appendicite aiguë", ) fact = ClinicalFact( fact_id="f_001", type="diagnostic", text="Appendicite aiguë", qualifier=sample_qualifier_affirmed, temporality="actuel", evidence=sample_evidence, confidence=0.95, ) code = codeur._create_code(candidate, fact, "dp", "2026") assert 0.0 <= code.confidence <= 1.0 def test_create_code_has_reasoning( codeur, sample_evidence, sample_qualifier_affirmed ): """Test que chaque code a un raisonnement.""" # Exigence 8.6 candidate = CodeCandidate( code="K35.8", label="Appendicite aiguë", similarity_score=0.95, source="reranked", chunk_id="chunk_001", chunk_text="K35.8 Appendicite aiguë", ) fact = ClinicalFact( fact_id="f_001", type="diagnostic", text="Appendicite aiguë", qualifier=sample_qualifier_affirmed, temporality="actuel", evidence=sample_evidence, confidence=0.95, ) code = codeur._create_code(candidate, fact, "dp", "2026") assert len(code.reasoning) > 0 assert "Diagnostic Principal" in code.reasoning def test_assign_confidence_penalizes_suspected( codeur, sample_evidence, sample_qualifier_suspected ): """Test que les faits suspectés ont une confiance réduite.""" # Exigence 2.2 candidate = CodeCandidate( code="K35.8", label="Appendicite aiguë", similarity_score=0.95, source="reranked", chunk_id="chunk_001", chunk_text="K35.8 Appendicite aiguë", ) fact_suspected = ClinicalFact( fact_id="f_001", type="diagnostic", text="Appendicite", qualifier=sample_qualifier_suspected, temporality="actuel", evidence=sample_evidence, confidence=0.7, ) fact_affirmed = ClinicalFact( fact_id="f_002", type="diagnostic", text="Appendicite aiguë", qualifier=Qualifier(certainty="affirmé", markers=[], confidence=0.95), temporality="actuel", evidence=sample_evidence, confidence=0.95, ) confidence_suspected = codeur.assign_confidence(candidate, fact_suspected) confidence_affirmed = codeur.assign_confidence(candidate, fact_affirmed) assert confidence_suspected < confidence_affirmed def test_assign_confidence_penalizes_history( codeur, sample_evidence, sample_qualifier_affirmed ): """Test que les antécédents ont une confiance réduite.""" # Exigence 2.3 candidate = CodeCandidate( code="E11.9", label="Diabète", similarity_score=0.95, source="reranked", chunk_id="chunk_001", chunk_text="E11.9 Diabète", ) fact_history = ClinicalFact( fact_id="f_001", type="diagnostic", text="Diabète", qualifier=sample_qualifier_affirmed, temporality="antecedent", evidence=sample_evidence, confidence=0.9, ) fact_current = ClinicalFact( fact_id="f_002", type="diagnostic", text="Diabète", qualifier=sample_qualifier_affirmed, temporality="actuel", evidence=sample_evidence, confidence=0.9, ) confidence_history = codeur.assign_confidence(candidate, fact_history) confidence_current = codeur.assign_confidence(candidate, fact_current) assert confidence_history < confidence_current def test_select_ccam_selects_acts( codeur, sample_evidence, sample_qualifier_affirmed, mock_rag_engine ): """Test que les actes CCAM sont correctement sélectionnés.""" # Exigence 8.4 facts = [ ClinicalFact( fact_id="f_001", type="acte", text="Appendicectomie", qualifier=sample_qualifier_affirmed, temporality="actuel", evidence=sample_evidence, confidence=0.95, ) ] mock_rag_engine.search_ccam.return_value = [ CodeCandidate( code="HHFA001", label="Appendicectomie", similarity_score=0.95, source="reranked", chunk_id="chunk_001", chunk_text="HHFA001 Appendicectomie", ) ] fact_candidates = {"f_001": mock_rag_engine.search_ccam.return_value} ccam_codes = codeur._select_ccam(facts, fact_candidates, "2025") assert len(ccam_codes) == 1 assert ccam_codes[0].code == "HHFA001" assert ccam_codes[0].type == "ccam" assert len(ccam_codes[0].evidence) >= 1 # Exigence 1.2 def test_propose_codes_returns_complete_proposal( codeur, stay_metadata, sample_evidence, sample_qualifier_affirmed, mock_rag_engine ): """Test que propose_codes retourne une proposition complète.""" # Exigences 8.1, 8.2, 8.3, 8.4 facts = [ ClinicalFact( fact_id="f_001", type="diagnostic", text="Appendicite aiguë", qualifier=sample_qualifier_affirmed, temporality="actuel", evidence=sample_evidence, confidence=0.95, ), ClinicalFact( fact_id="f_002", type="acte", text="Appendicectomie", qualifier=sample_qualifier_affirmed, temporality="actuel", evidence=sample_evidence, confidence=0.95, ), ] # Mock des recherches RAG mock_rag_engine.search_icd10.return_value = [ CodeCandidate( code="K35.8", label="Appendicite aiguë", similarity_score=0.95, source="reranked", chunk_id="chunk_001", chunk_text="K35.8 Appendicite aiguë", ) ] mock_rag_engine.search_ccam.return_value = [ CodeCandidate( code="HHFA001", label="Appendicectomie", similarity_score=0.95, source="reranked", chunk_id="chunk_002", chunk_text="HHFA001 Appendicectomie", ) ] proposal = codeur.propose_codes(facts, stay_metadata) # Vérifier la structure de la proposition assert proposal.stay_id == "stay_001" assert proposal.dp is not None assert proposal.dp.code == "K35.8" assert len(proposal.ccam) == 1 assert proposal.ccam[0].code == "HHFA001" assert len(proposal.reasoning) > 0 # Exigence 8.6 assert proposal.model_version.model_name == "mock-llm" assert proposal.prompt_version == "1.0.0" def test_propose_codes_handles_no_dp( codeur, stay_metadata, sample_evidence, sample_qualifier_negated, mock_rag_engine ): """Test que propose_codes gère l'absence de DP.""" facts = [ ClinicalFact( fact_id="f_001", type="diagnostic", text="Appendicite", qualifier=sample_qualifier_negated, temporality="actuel", evidence=sample_evidence, confidence=0.9, ) ] mock_rag_engine.search_icd10.return_value = [] proposal = codeur.propose_codes(facts, stay_metadata) assert proposal.dp is None assert "Aucun Diagnostic Principal" in proposal.reasoning def test_select_das_excludes_dp_and_dr( codeur, sample_evidence, sample_qualifier_affirmed, mock_rag_engine ): """Test que les DAS n'incluent pas le DP ou le DR.""" # Exigence 8.3 facts = [ ClinicalFact( fact_id="f_001", type="diagnostic", text="Appendicite aiguë", qualifier=sample_qualifier_affirmed, temporality="actuel", evidence=sample_evidence, confidence=0.95, ), ClinicalFact( fact_id="f_002", type="diagnostic", text="Diabète", qualifier=sample_qualifier_affirmed, temporality="antecedent", evidence=sample_evidence, confidence=0.9, ), ] # Mock des candidats dp_candidate = CodeCandidate( code="K35.8", label="Appendicite aiguë", similarity_score=0.95, source="reranked", chunk_id="chunk_001", chunk_text="K35.8 Appendicite aiguë", ) das_candidate = CodeCandidate( code="E11.9", label="Diabète", similarity_score=0.90, source="reranked", chunk_id="chunk_002", chunk_text="E11.9 Diabète", ) fact_candidates = { "f_001": [dp_candidate], "f_002": [das_candidate], } # Créer le DP dp = codeur._create_code(dp_candidate, facts[0], "dp", "2026") # Sélectionner les DAS das = codeur._select_das(facts, fact_candidates, dp, None, "2026") # Vérifier que le DAS ne contient pas le code du DP das_codes = [d.code for d in das] assert "K35.8" not in das_codes assert "E11.9" in das_codes def test_generate_code_reasoning_includes_evidence( codeur, sample_evidence, sample_qualifier_affirmed ): """Test que le raisonnement inclut la preuve.""" # Exigence 8.6 candidate = CodeCandidate( code="K35.8", label="Appendicite aiguë", similarity_score=0.95, source="reranked", chunk_id="chunk_001", chunk_text="K35.8 Appendicite aiguë", ) fact = ClinicalFact( fact_id="f_001", type="diagnostic", text="Appendicite aiguë", qualifier=sample_qualifier_affirmed, temporality="actuel", evidence=sample_evidence, confidence=0.95, ) reasoning = codeur._generate_code_reasoning(candidate, fact, "dp") assert "Appendicite aiguë" in reasoning assert "doc_001" in reasoning assert "Preuve textuelle" in reasoning def test_generate_global_reasoning_includes_summary( codeur, stay_metadata, sample_evidence, sample_qualifier_affirmed ): """Test que le raisonnement global inclut un résumé.""" # Exigence 8.6 dp = Code( code="K35.8", label="Appendicite aiguë", type="dp", evidence=[sample_evidence], confidence=0.95, reasoning="Test reasoning", referentiel_version="2026", ) facts = [ ClinicalFact( fact_id="f_001", type="diagnostic", text="Appendicite aiguë", qualifier=sample_qualifier_affirmed, temporality="actuel", evidence=sample_evidence, confidence=0.95, ) ] reasoning = codeur._generate_global_reasoning( dp, None, [], [], facts, stay_metadata ) assert "stay_001" in reasoning assert "Chirurgie" in reasoning assert "K35.8" in reasoning assert "Appendicite aiguë" in reasoning assert "conservative" in reasoning.lower() assert "preuves textuelles" in reasoning.lower()