"""Tests pour la génération de contre-argumentation CPAM.""" from unittest.mock import patch, call import pytest from src.config import ( ActeCCAM, BiologieCle, ControleCPAM, Diagnostic, DossierMedical, Imagerie, RAGSource, Sejour, Traitement, ) from src.control.cpam_response import ( _build_cpam_prompt, _format_response, _get_code_label, _search_rag_for_control, _validate_references, generate_cpam_response, ) def _make_dossier() -> DossierMedical: """Crée un dossier médical de test.""" return DossierMedical( source_file="test.pdf", document_type="crh", sejour=Sejour(sexe="M", age=65, duree_sejour=5), diagnostic_principal=Diagnostic( texte="Cholécystite aiguë", cim10_suggestion="K81.0", ), diagnostics_associes=[ Diagnostic(texte="Iléus réflexe", cim10_suggestion="K56.0"), ], ) def _make_dossier_complet() -> DossierMedical: """Crée un dossier médical enrichi avec traitements, imagerie, antécédents.""" return DossierMedical( source_file="test.pdf", document_type="crh", sejour=Sejour(sexe="M", age=65, duree_sejour=5, imc=31.2), diagnostic_principal=Diagnostic( texte="Cholécystite aiguë", cim10_suggestion="K81.0", ), diagnostics_associes=[ Diagnostic(texte="Iléus réflexe", cim10_suggestion="K56.0"), ], actes_ccam=[ ActeCCAM(texte="Cholécystectomie", code_ccam_suggestion="HMFC004"), ], biologie_cle=[ BiologieCle(test="CRP", valeur="180 mg/L", anomalie=True), BiologieCle(test="Créatinine", valeur="450 µmol/L", anomalie=True), ], imagerie=[ Imagerie(type="Scanner abdominal", conclusion="Lithiase cholédocienne confirmée"), ], traitements_sortie=[ Traitement(medicament="Augmentin IV", posologie="3g/j"), Traitement(medicament="Morphine SC"), ], antecedents=["HTA", "Diabète type 2"], ) def _make_controle() -> ControleCPAM: """Crée un contrôle CPAM de test.""" return ControleCPAM( numero_ogc=17, titre="Désaccord sur les DAS", arg_ucr="L'UCR confirme l'avis des médecins contrôleurs au motif que le DAS K56.0 n'est pas justifié.", decision_ucr="UCR confirme avis médecins contrôleurs", dp_ucr=None, da_ucr="K56.0", ) class TestBuildPrompt: def test_prompt_contains_dossier_info(self): dossier = _make_dossier() controle = _make_controle() prompt = _build_cpam_prompt(dossier, controle, []) assert "Cholécystite aiguë" in prompt assert "K81.0" in prompt assert "Iléus réflexe" in prompt assert "65 ans" in prompt def test_prompt_contains_cpam_argument(self): dossier = _make_dossier() controle = _make_controle() prompt = _build_cpam_prompt(dossier, controle, []) assert controle.arg_ucr in prompt assert controle.decision_ucr in prompt def test_prompt_contains_codes_contestes(self): dossier = _make_dossier() controle = _make_controle() prompt = _build_cpam_prompt(dossier, controle, []) assert "DA proposés par UCR : K56.0" in prompt def test_prompt_contains_rag_sources(self): dossier = _make_dossier() controle = _make_controle() sources = [ {"document": "guide_methodo", "page": 64, "extrait": "Texte du guide..."}, {"document": "cim10", "code": "K56.0", "extrait": "Iléus paralytique..."}, ] prompt = _build_cpam_prompt(dossier, controle, sources) assert "Guide Méthodologique MCO 2026" in prompt assert "CIM-10 FR 2026" in prompt assert "page 64" in prompt def test_prompt_contains_three_axes(self): dossier = _make_dossier() controle = _make_controle() prompt = _build_cpam_prompt(dossier, controle, []) assert "AXE MÉDICAL" in prompt assert "AXE ASYMÉTRIE D'INFORMATION" in prompt assert "AXE RÉGLEMENTAIRE" in prompt def test_prompt_contains_traitements_imagerie_when_present(self): dossier = _make_dossier_complet() controle = _make_controle() prompt = _build_cpam_prompt(dossier, controle, []) assert "Augmentin IV 3g/j" in prompt assert "Morphine SC" in prompt assert "Scanner abdominal" in prompt assert "Lithiase cholédocienne confirmée" in prompt assert "HTA" in prompt assert "Diabète type 2" in prompt assert "IMC : 31.2" in prompt def test_prompt_asymetrie_section_when_data_present(self): dossier = _make_dossier_complet() controle = _make_controle() prompt = _build_cpam_prompt(dossier, controle, []) assert "ÉLÉMENTS DU DOSSIER NON TRANSMIS À LA CPAM" in prompt assert "CRP: 180 mg/L (anormale)" in prompt assert "Cholécystectomie (HMFC004)" in prompt def test_prompt_no_asymetrie_section_when_no_data(self): dossier = DossierMedical( source_file="test.pdf", document_type="crh", sejour=Sejour(), diagnostic_principal=Diagnostic(texte="Test", cim10_suggestion="Z00"), ) controle = _make_controle() prompt = _build_cpam_prompt(dossier, controle, []) assert "ÉLÉMENTS DU DOSSIER NON TRANSMIS À LA CPAM" not in prompt def test_prompt_json_format_new_fields(self): dossier = _make_dossier() controle = _make_controle() prompt = _build_cpam_prompt(dossier, controle, []) assert "contre_arguments_medicaux" in prompt assert "contre_arguments_asymetrie" in prompt assert "contre_arguments_reglementaires" in prompt def test_prompt_contains_cite_exacts(self): """Le prompt renforcé demande des preuves exactes.""" dossier = _make_dossier() controle = _make_controle() prompt = _build_cpam_prompt(dossier, controle, []) assert "CITE" in prompt assert "EXACTS" in prompt def test_prompt_contains_interdiction(self): """Le prompt interdit les références inventées.""" dossier = _make_dossier() controle = _make_controle() prompt = _build_cpam_prompt(dossier, controle, []) assert "INTERDICTION ABSOLUE" in prompt def test_prompt_contains_preuves_dossier_field(self): """Le format JSON demandé inclut preuves_dossier.""" dossier = _make_dossier() controle = _make_controle() prompt = _build_cpam_prompt(dossier, controle, []) assert "preuves_dossier" in prompt @patch("src.control.cpam_response.validate_code", return_value=(True, "Iléus paralytique et obstruction intestinale")) @patch("src.control.cpam_response.normalize_code", return_value="K56.0") def test_prompt_codes_with_cim10_labels(self, mock_norm, mock_valid): """Les codes contestés affichent le libellé CIM-10.""" dossier = _make_dossier() controle = _make_controle() # da_ucr="K56.0" prompt = _build_cpam_prompt(dossier, controle, []) assert "Iléus paralytique" in prompt assert "DA proposés par UCR" in prompt @patch("src.control.cpam_response.validate_code", return_value=(False, "")) @patch("src.control.cpam_response.normalize_code", return_value="Z99.9") def test_prompt_codes_invalid_graceful(self, mock_norm, mock_valid): """Les codes invalides ne crashent pas, juste pas de libellé.""" dossier = _make_dossier() controle = ControleCPAM( numero_ogc=1, titre="Test", arg_ucr="Test", decision_ucr="Rejet", dp_ucr="Z99.9", da_ucr=None, ) prompt = _build_cpam_prompt(dossier, controle, []) assert "Z99.9" in prompt # Pas de crash @patch("src.control.cpam_response.validate_code", return_value=(True, "Ajustement et entretien d'un dispositif implantable")) @patch("src.control.cpam_response.normalize_code", return_value="Z45.8") def test_prompt_dp_fallback_from_ucr(self, mock_norm, mock_valid): """DP absent + dp_ucr → contexte injecté dans le prompt.""" dossier = DossierMedical( source_file="test.pdf", document_type="crh", sejour=Sejour(), diagnostic_principal=None, ) controle = ControleCPAM( numero_ogc=1, titre="Désaccord DP", arg_ucr="Test", decision_ucr="Rejet", dp_ucr="Z45.8", da_ucr=None, ) prompt = _build_cpam_prompt(dossier, controle, []) assert "codé par l'établissement" in prompt assert "contesté par la CPAM" in prompt assert "Z45.8" in prompt class TestFormatResponse: def test_full_response_new_format(self): parsed = { "analyse_contestation": "La CPAM conteste le DAS K56.0", "points_accord": "Aucun", "contre_arguments_medicaux": "Le guide méthodologique précise...", "contre_arguments_asymetrie": "La biologie montre une CRP à 180...", "contre_arguments_reglementaires": "L'UCR interprète restrictivement...", "references": "Guide métho p.64, CIM-10 K56.0", "conclusion": "Le DAS est justifié", } text = _format_response(parsed) assert "ANALYSE DE LA CONTESTATION" in text assert "CONTRE-ARGUMENTS MÉDICAUX" in text assert "ASYMÉTRIE D'INFORMATION" in text assert "CONTRE-ARGUMENTS RÉGLEMENTAIRES" in text assert "REFERENCES" in text assert "CONCLUSION" in text # "Aucun" ne doit pas générer la section points d'accord assert "POINTS D'ACCORD" not in text # L'ancien champ ne doit pas apparaître assert "CONTRE-ARGUMENTS\n" not in text def test_fallback_old_format(self): """L'ancien champ contre_arguments est toujours géré (réponses en cache).""" parsed = { "analyse_contestation": "Analyse...", "contre_arguments": "Arguments anciens...", "conclusion": "Conclusion...", } text = _format_response(parsed) assert "CONTRE-ARGUMENTS\nArguments anciens..." in text assert "CONCLUSION" in text def test_new_fields_override_fallback(self): """Si les nouveaux champs existent, l'ancien contre_arguments est ignoré.""" parsed = { "contre_arguments_medicaux": "Médicaux...", "contre_arguments": "Ancien fallback...", "conclusion": "Conclusion...", } text = _format_response(parsed) assert "CONTRE-ARGUMENTS MÉDICAUX" in text assert "Ancien fallback" not in text def test_partial_response(self): parsed = { "contre_arguments_medicaux": "Arguments médicaux...", "conclusion": "Conclusion...", } text = _format_response(parsed) assert "CONTRE-ARGUMENTS MÉDICAUX" in text assert "CONCLUSION" in text def test_empty_response(self): text = _format_response({}) assert text == "" def test_preuves_dossier_formatting(self): """Le nouveau champ preuves_dossier est formaté correctement.""" parsed = { "contre_arguments_medicaux": "Arguments...", "preuves_dossier": [ {"element": "biologie", "valeur": "CRP 180 mg/L", "signification": "inflammation sévère"}, {"element": "imagerie", "valeur": "lithiase cholédocienne", "signification": "confirme le diagnostic"}, ], "conclusion": "Conclusion...", } text = _format_response(parsed) assert "PREUVES DU DOSSIER" in text assert "CRP 180 mg/L" in text assert "[biologie]" in text assert "[imagerie]" in text def test_structured_references_formatting(self): """Les références structurées sont formatées correctement.""" parsed = { "contre_arguments_medicaux": "Arguments...", "references": [ {"document": "Guide Méthodologique MCO 2026", "page": "64", "citation": "Le DAS doit être..."}, ], "conclusion": "Conclusion...", } text = _format_response(parsed) assert "REFERENCES" in text assert "Guide Méthodologique MCO 2026" in text assert "p.64" in text assert "Le DAS doit être..." in text def test_ref_warnings_appended(self): """Les avertissements de références non vérifiées apparaissent.""" parsed = {"conclusion": "Conclusion..."} warnings = ["Référence non vérifiable : Manuel Imaginaire 2025"] text = _format_response(parsed, ref_warnings=warnings) assert "AVERTISSEMENT" in text assert "Manuel Imaginaire 2025" in text class TestValidateReferences: def test_valid_reference_no_warning(self): parsed = { "references": [ {"document": "Guide Méthodologique MCO 2026", "page": "64", "citation": "..."}, ] } sources = [{"document": "guide_methodo", "page": 64, "extrait": "..."}] warnings = _validate_references(parsed, sources) assert len(warnings) == 0 def test_invented_reference_detected(self): parsed = { "references": [ {"document": "Manuel Inventé 2025", "page": "12", "citation": "..."}, ] } sources = [{"document": "guide_methodo", "page": 64, "extrait": "..."}] warnings = _validate_references(parsed, sources) assert len(warnings) == 1 assert "Manuel Inventé" in warnings[0] def test_old_format_string_no_crash(self): """L'ancien format string pour references ne cause pas de crash.""" parsed = {"references": "Guide méthodo p.64"} sources = [{"document": "guide_methodo"}] warnings = _validate_references(parsed, sources) assert len(warnings) == 0 # pas de validation sur l'ancien format def test_no_sources_no_validation(self): parsed = { "references": [ {"document": "Quelque chose", "page": "1", "citation": "..."}, ] } warnings = _validate_references(parsed, []) assert len(warnings) == 0 class TestGenerateResponse: @patch("src.control.cpam_response.call_ollama") @patch("src.control.cpam_response.call_anthropic") @patch("src.control.cpam_response._search_rag_for_control") def test_generate_success_ollama_cpam(self, mock_rag, mock_anthropic, mock_ollama): """Ollama disponible → utilisé en premier, retourne triplet.""" mock_rag.return_value = [ {"document": "guide_methodo", "page": 64, "extrait": "Texte guide"}, ] mock_ollama.return_value = { "analyse_contestation": "Analyse...", "contre_arguments_medicaux": "Contre-arguments médicaux...", "contre_arguments_asymetrie": "Asymétrie...", "conclusion": "Conclusion...", } dossier = _make_dossier() controle = _make_controle() text, response_data, sources = generate_cpam_response(dossier, controle) assert "Contre-arguments médicaux..." in text assert response_data is not None assert response_data["analyse_contestation"] == "Analyse..." assert response_data["conclusion"] == "Conclusion..." assert len(sources) == 1 assert sources[0].document == "guide_methodo" mock_ollama.assert_called_once() mock_anthropic.assert_not_called() @patch("src.control.cpam_response.call_ollama") @patch("src.control.cpam_response.call_anthropic") @patch("src.control.cpam_response._search_rag_for_control") def test_generate_fallback_haiku(self, mock_rag, mock_anthropic, mock_ollama): """Ollama indisponible → fallback Haiku, retourne triplet.""" mock_rag.return_value = [ {"document": "guide_methodo", "page": 64, "extrait": "Texte guide"}, ] mock_ollama.return_value = None mock_anthropic.return_value = { "analyse_contestation": "Analyse Haiku...", "contre_arguments_medicaux": "Contre-args Haiku...", "conclusion": "Conclusion Haiku...", } dossier = _make_dossier() controle = _make_controle() text, response_data, sources = generate_cpam_response(dossier, controle) assert "Contre-args Haiku..." in text assert response_data is not None assert response_data["contre_arguments_medicaux"] == "Contre-args Haiku..." mock_ollama.assert_called_once() mock_anthropic.assert_called_once() @patch("src.control.cpam_response.call_ollama") @patch("src.control.cpam_response.call_anthropic") @patch("src.control.cpam_response._search_rag_for_control") def test_generate_all_unavailable(self, mock_rag, mock_anthropic, mock_ollama): """Tous LLMs indisponibles → texte vide, response_data None.""" mock_rag.return_value = [] mock_anthropic.return_value = None mock_ollama.return_value = None dossier = _make_dossier() controle = _make_controle() text, response_data, sources = generate_cpam_response(dossier, controle) assert text == "" assert response_data is None assert sources == [] class TestSearchRagForControl: """Tests pour la logique de recherche RAG multi-requêtes.""" @patch("src.medical.rag_search.search_similar_cpam") def test_multiple_queries_with_da_ucr(self, mock_search): """Avec da_ucr, on doit avoir au moins 2 requêtes (codes + argument).""" mock_search.return_value = [ {"document": "guide_methodo", "page": 10, "code": None, "score": 0.6, "extrait": "Texte guide"}, ] dossier = _make_dossier() controle = _make_controle() # da_ucr="K56.0" results = _search_rag_for_control(controle, dossier) # Au moins 2 appels : codes contestés + argument CPAM assert mock_search.call_count >= 2 @patch("src.medical.rag_search.search_similar_cpam") def test_query_codes_contains_cma(self, mock_search): """La requête codes contestés doit contenir 'CMA' pour un DA.""" mock_search.return_value = [] dossier = _make_dossier() controle = _make_controle() # da_ucr="K56.0" _search_rag_for_control(controle, dossier) # Premier appel = requête codes first_call_query = mock_search.call_args_list[0][0][0] assert "K56.0" in first_call_query assert "CMA" in first_call_query @patch("src.medical.rag_search.search_similar_cpam") def test_query_argument_contains_titre(self, mock_search): """La requête argument doit contenir le titre du contrôle.""" mock_search.return_value = [] dossier = _make_dossier() controle = _make_controle() _search_rag_for_control(controle, dossier) # Deuxième appel = requête argument second_call_query = mock_search.call_args_list[1][0][0] assert controle.titre in second_call_query @patch("src.medical.rag_search.search_similar_cpam") def test_deduplication_by_document_code_page(self, mock_search): """Les résultats dupliqués (même document/code/page) sont fusionnés.""" # Les deux requêtes retournent le même résultat shared_result = { "document": "guide_methodo", "page": 64, "code": None, "score": 0.55, "extrait": "Texte partagé", } mock_search.return_value = [shared_result.copy()] dossier = _make_dossier() controle = _make_controle() results = _search_rag_for_control(controle, dossier) # Le résultat ne doit apparaître qu'une seule fois malgré les requêtes multiples guide_results = [r for r in results if r["document"] == "guide_methodo" and r.get("page") == 64] assert len(guide_results) == 1 @patch("src.medical.rag_search.search_similar_cpam") def test_dedup_keeps_best_score(self, mock_search): """La déduplication garde le meilleur score.""" def side_effect(query, top_k=8): if "CMA" in query: return [{"document": "cim10", "code": "K56.0", "page": None, "score": 0.5, "extrait": "Iléus"}] else: return [{"document": "cim10", "code": "K56.0", "page": None, "score": 0.7, "extrait": "Iléus"}] mock_search.side_effect = side_effect dossier = _make_dossier() controle = _make_controle() results = _search_rag_for_control(controle, dossier) k56_results = [r for r in results if r.get("code") == "K56.0"] assert len(k56_results) == 1 assert k56_results[0]["score"] == 0.7 @patch("src.medical.rag_search.search_similar_cpam") def test_no_codes_contestes_only_argument_query(self, mock_search): """Sans codes contestés, seule la requête argument est lancée.""" mock_search.return_value = [] dossier = _make_dossier() controle = ControleCPAM( numero_ogc=1, titre="Désaccord sur la durée", arg_ucr="Séjour trop long selon l'UCR.", decision_ucr="Rejet", dp_ucr=None, da_ucr=None, ) _search_rag_for_control(controle, dossier) # Un seul appel : requête argument (pas de codes contestés) assert mock_search.call_count == 1 @patch("src.medical.rag_search.search_similar_cpam") def test_dp_ucr_query_contains_diagnostic_principal(self, mock_search): """Avec dp_ucr, la requête codes mentionne 'diagnostic principal'.""" mock_search.return_value = [] dossier = _make_dossier() controle = ControleCPAM( numero_ogc=2, titre="Désaccord sur le DP", arg_ucr="Le DP devrait être K80.1", decision_ucr="Rejet", dp_ucr="K81.0", da_ucr=None, ) _search_rag_for_control(controle, dossier) first_call_query = mock_search.call_args_list[0][0][0] assert "K81.0" in first_call_query assert "diagnostic principal" in first_call_query @patch("src.medical.rag_search.search_similar_cpam") def test_max_12_results(self, mock_search): """Le résultat final est limité à 12 entrées.""" mock_search.return_value = [ {"document": "guide_methodo", "page": i, "code": None, "score": 0.9 - i * 0.01, "extrait": f"Texte {i}"} for i in range(8) ] dossier = _make_dossier() controle = _make_controle() results = _search_rag_for_control(controle, dossier) assert len(results) <= 12 @patch("src.medical.rag_search.search_similar_cpam") def test_arg_ucr_not_truncated_200(self, mock_search): """La requête RAG argument utilise jusqu'à 500 chars, pas 200.""" mock_search.return_value = [] dossier = _make_dossier() long_arg = "A" * 400 controle = ControleCPAM( numero_ogc=1, titre="Test", arg_ucr=long_arg, decision_ucr="Rejet", dp_ucr=None, da_ucr=None, ) _search_rag_for_control(controle, dossier) # La requête argument doit contenir les 400 chars (pas tronquée à 200) arg_call_query = mock_search.call_args_list[0][0][0] assert len(arg_call_query) > 200 @patch("src.control.cpam_response.validate_code", return_value=(True, "Iléus paralytique")) @patch("src.control.cpam_response.normalize_code", return_value="K56.0") @patch("src.medical.rag_search.search_similar_cpam") def test_query_cim10_definitions(self, mock_search, mock_norm, mock_valid): """Requête 4 exécutée quand codes contestés présents.""" mock_search.return_value = [] dossier = _make_dossier() controle = _make_controle() # da_ucr="K56.0" _search_rag_for_control(controle, dossier) # Chercher la requête contenant "CIM-10" et "définition" cim10_queries = [ c[0][0] for c in mock_search.call_args_list if "CIM-10" in c[0][0] and "définition" in c[0][0] ] assert len(cim10_queries) >= 1 assert "K56.0" in cim10_queries[0] @patch("src.medical.rag_search.search_similar_cpam") def test_query_rule_extraction(self, mock_search): """Requête 5 exécutée quand arg_ucr contient une règle nommée.""" mock_search.return_value = [] dossier = _make_dossier() controle = ControleCPAM( numero_ogc=1, titre="Désaccord DAS", arg_ucr="Selon la RègleT7 et l'Annexe-4B, le DAS n'est pas justifié.", decision_ucr="Rejet", dp_ucr=None, da_ucr=None, ) _search_rag_for_control(controle, dossier) # Chercher la requête contenant les règles extraites rule_queries = [ c[0][0] for c in mock_search.call_args_list if "guide méthodologique" in c[0][0] ] assert len(rule_queries) >= 1 assert "RègleT7" in rule_queries[0] or "Annexe" in rule_queries[0] @patch("src.medical.rag_search.search_similar_cpam") def test_clinical_query_when_das_match(self, mock_search): """Requête clinique lancée quand da_ucr matche un DAS du dossier.""" mock_search.return_value = [] dossier = _make_dossier() # DAS K56.0 "Iléus réflexe" controle = _make_controle() # da_ucr="K56.0" _search_rag_for_control(controle, dossier) # Au moins 4 appels : codes + argument + clinique + CIM-10 définitions assert mock_search.call_count >= 4 # La requête clinique contient DP + DAS textes clinique_queries = [ c[0][0] for c in mock_search.call_args_list if "Iléus réflexe" in c[0][0] and "Cholécystite aiguë" in c[0][0] ] assert len(clinique_queries) >= 1