feat: pipeline CPAM multi-pass + garde-fous qualité (solutions 1+2+3+6)

- Solution 1 : injection déterministe des définitions CIM-10 dans le prompt
- Solution 2 : grounding tagué [BIO-N], [IMG-N], [TRT-N], [ACTE-N] avec validation
- Solution 3 : pipeline 2 passes (extraction structurée → argumentation)
- Solution 6 : validation adversariale LLM post-génération
- Normes bio injectées dans les tags (NORMAL/ÉLEVÉ/BAS avec norme de référence)
- Cross-check DAS/biologie détecte les incohérences (leucocytose vs leucocytes bas)
- Contexte patient : flags pédiatrie, patient âgé, admission urgence
- Dossiers pauvres : avertissement explicite au lieu de spéculation
- Validation adversariale enrichie avec normes bio de référence
- 75 tests CPAM (612 total), 0 régression

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-18 18:16:34 +01:00
parent 09a251185e
commit f7d87f2602
2 changed files with 1136 additions and 48 deletions

View File

@@ -17,9 +17,15 @@ from src.config import (
)
from src.control.cpam_response import (
_build_cpam_prompt,
_build_tagged_context,
_check_das_bio_coherence,
_extraction_pass,
_format_response,
_get_cim10_definitions,
_get_code_label,
_search_rag_for_control,
_validate_adversarial,
_validate_grounding,
_validate_references,
generate_cpam_response,
)
@@ -88,7 +94,7 @@ class TestBuildPrompt:
def test_prompt_contains_dossier_info(self):
dossier = _make_dossier()
controle = _make_controle()
prompt = _build_cpam_prompt(dossier, controle, [])
prompt, _ = _build_cpam_prompt(dossier, controle, [])
assert "Cholécystite aiguë" in prompt
assert "K81.0" in prompt
@@ -98,7 +104,7 @@ class TestBuildPrompt:
def test_prompt_contains_cpam_argument(self):
dossier = _make_dossier()
controle = _make_controle()
prompt = _build_cpam_prompt(dossier, controle, [])
prompt, _ = _build_cpam_prompt(dossier, controle, [])
assert controle.arg_ucr in prompt
assert controle.decision_ucr in prompt
@@ -106,7 +112,7 @@ class TestBuildPrompt:
def test_prompt_contains_codes_contestes(self):
dossier = _make_dossier()
controle = _make_controle()
prompt = _build_cpam_prompt(dossier, controle, [])
prompt, _ = _build_cpam_prompt(dossier, controle, [])
assert "DA proposés par UCR : K56.0" in prompt
@@ -117,7 +123,7 @@ class TestBuildPrompt:
{"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)
prompt, _ = _build_cpam_prompt(dossier, controle, sources)
assert "Guide Méthodologique MCO 2026" in prompt
assert "CIM-10 FR 2026" in prompt
@@ -126,7 +132,7 @@ class TestBuildPrompt:
def test_prompt_contains_three_axes(self):
dossier = _make_dossier()
controle = _make_controle()
prompt = _build_cpam_prompt(dossier, controle, [])
prompt, _ = _build_cpam_prompt(dossier, controle, [])
assert "AXE MÉDICAL" in prompt
assert "AXE ASYMÉTRIE D'INFORMATION" in prompt
@@ -135,7 +141,7 @@ class TestBuildPrompt:
def test_prompt_contains_traitements_imagerie_when_present(self):
dossier = _make_dossier_complet()
controle = _make_controle()
prompt = _build_cpam_prompt(dossier, controle, [])
prompt, _ = _build_cpam_prompt(dossier, controle, [])
assert "Augmentin IV 3g/j" in prompt
assert "Morphine SC" in prompt
@@ -148,7 +154,7 @@ class TestBuildPrompt:
def test_prompt_asymetrie_section_when_data_present(self):
dossier = _make_dossier_complet()
controle = _make_controle()
prompt = _build_cpam_prompt(dossier, 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
@@ -162,14 +168,14 @@ class TestBuildPrompt:
diagnostic_principal=Diagnostic(texte="Test", cim10_suggestion="Z00"),
)
controle = _make_controle()
prompt = _build_cpam_prompt(dossier, 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, [])
prompt, _ = _build_cpam_prompt(dossier, controle, [])
assert "contre_arguments_medicaux" in prompt
assert "contre_arguments_asymetrie" in prompt
@@ -179,7 +185,7 @@ class TestBuildPrompt:
"""Le prompt renforcé demande des preuves exactes."""
dossier = _make_dossier()
controle = _make_controle()
prompt = _build_cpam_prompt(dossier, controle, [])
prompt, _ = _build_cpam_prompt(dossier, controle, [])
assert "CITE" in prompt
assert "EXACTS" in prompt
@@ -188,7 +194,7 @@ class TestBuildPrompt:
"""Le prompt interdit les références inventées."""
dossier = _make_dossier()
controle = _make_controle()
prompt = _build_cpam_prompt(dossier, controle, [])
prompt, _ = _build_cpam_prompt(dossier, controle, [])
assert "INTERDICTION ABSOLUE" in prompt
@@ -196,7 +202,7 @@ class TestBuildPrompt:
"""Le format JSON demandé inclut preuves_dossier."""
dossier = _make_dossier()
controle = _make_controle()
prompt = _build_cpam_prompt(dossier, controle, [])
prompt, _ = _build_cpam_prompt(dossier, controle, [])
assert "preuves_dossier" in prompt
@@ -206,7 +212,7 @@ class TestBuildPrompt:
"""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, [])
prompt, _ = _build_cpam_prompt(dossier, controle, [])
assert "Iléus paralytique" in prompt
assert "DA proposés par UCR" in prompt
@@ -220,7 +226,7 @@ class TestBuildPrompt:
numero_ogc=1, titre="Test", arg_ucr="Test",
decision_ucr="Rejet", dp_ucr="Z99.9", da_ucr=None,
)
prompt = _build_cpam_prompt(dossier, controle, [])
prompt, _ = _build_cpam_prompt(dossier, controle, [])
assert "Z99.9" in prompt
# Pas de crash
@@ -239,7 +245,7 @@ class TestBuildPrompt:
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, [])
prompt, _ = _build_cpam_prompt(dossier, controle, [])
assert "codé par l'établissement" in prompt
assert "contesté par la CPAM" in prompt
@@ -395,16 +401,27 @@ class TestGenerateResponse:
@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."""
"""Ollama disponible → 3 passes (extraction + argumentation + validation)."""
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...",
}
call_count = {"n": 0}
def ollama_side_effect(prompt, temperature=0.1, max_tokens=4000):
call_count["n"] += 1
if call_count["n"] == 1:
return {"comprehension_contestation": "Extraction...", "elements_cliniques_pertinents": [], "points_accord_potentiels": [], "codes_en_jeu": {}}
elif call_count["n"] == 2:
return {
"analyse_contestation": "Analyse...",
"contre_arguments_medicaux": "Contre-arguments médicaux...",
"contre_arguments_asymetrie": "Asymétrie...",
"conclusion": "Conclusion...",
}
else:
return {"coherent": True, "erreurs": [], "score_confiance": 9}
mock_ollama.side_effect = ollama_side_effect
dossier = _make_dossier()
controle = _make_controle()
@@ -414,26 +431,37 @@ class TestGenerateResponse:
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()
# 3 appels Ollama : extraction + argumentation + validation
assert mock_ollama.call_count == 3
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."""
"""Ollama indisponible → fallback Haiku pour les 3 passes."""
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...",
}
call_count = {"n": 0}
def anthropic_side_effect(prompt, temperature=0.1, max_tokens=4000):
call_count["n"] += 1
if call_count["n"] == 1:
return {"comprehension_contestation": "Extraction Haiku...", "elements_cliniques_pertinents": [], "points_accord_potentiels": [], "codes_en_jeu": {}}
elif call_count["n"] == 2:
return {
"analyse_contestation": "Analyse Haiku...",
"contre_arguments_medicaux": "Contre-args Haiku...",
"conclusion": "Conclusion Haiku...",
}
else:
return {"coherent": True, "erreurs": [], "score_confiance": 8}
mock_anthropic.side_effect = anthropic_side_effect
dossier = _make_dossier()
controle = _make_controle()
@@ -442,9 +470,10 @@ class TestGenerateResponse:
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()
# Ollama appelé 3 fois mais retourne None
assert mock_ollama.call_count == 3
# Anthropic appelé 3 fois en fallback
assert mock_anthropic.call_count == 3
@patch("src.control.cpam_response.call_ollama")
@patch("src.control.cpam_response.call_anthropic")
@@ -688,3 +717,573 @@ class TestSearchRagForControl:
if "Iléus réflexe" in c[0][0] and "Cholécystite aiguë" in c[0][0]
]
assert len(clinique_queries) >= 1
class TestGetCim10Definitions:
"""Tests pour l'injection déterministe des définitions CIM-10."""
@patch("src.control.cpam_response.validate_code")
@patch("src.control.cpam_response.normalize_code", side_effect=lambda c: c.upper())
def test_definitions_injected_in_prompt(self, mock_norm, mock_valid):
"""La section DÉFINITIONS CIM-10 apparaît dans le prompt avec les libellés."""
mock_valid.side_effect = lambda c: {
"K81.0": (True, "Cholécystite aiguë"),
"K56.0": (True, "Iléus paralytique et obstruction intestinale"),
}.get(c, (False, ""))
dossier = _make_dossier() # DP=K81.0, DAS=K56.0
controle = _make_controle() # da_ucr="K56.0"
prompt, _ = _build_cpam_prompt(dossier, controle, [])
assert "DÉFINITIONS CIM-10" in prompt
assert "dictionnaire officiel" in prompt
assert "Cholécystite aiguë" in prompt
assert "Iléus paralytique" in prompt
assert "DP établissement" in prompt
@patch("src.control.cpam_response.validate_code")
@patch("src.control.cpam_response.normalize_code", side_effect=lambda c: c.upper())
def test_definitions_include_dp_and_ucr_codes(self, mock_norm, mock_valid):
"""Les codes du dossier ET de l'UCR sont tous inclus."""
mock_valid.side_effect = lambda c: {
"K81.0": (True, "Cholécystite aiguë"),
"K56.0": (True, "Iléus paralytique"),
"Z45.8": (True, "Ajustement d'un dispositif implantable"),
}.get(c, (False, ""))
dossier = _make_dossier() # DP=K81.0, DAS=K56.0
controle = ControleCPAM(
numero_ogc=1, titre="Test", arg_ucr="Test",
decision_ucr="Rejet", dp_ucr="Z45.8", da_ucr="K56.0",
)
result = _get_cim10_definitions(dossier, controle)
# Codes dossier
assert "K81.0" in result
assert "DP établissement" in result
assert "K56.0" in result
# Codes UCR
assert "Z45.8" in result
assert "DP proposé UCR" in result
assert "DA proposé UCR" in result or "DAS établissement" in result
@patch("src.control.cpam_response.validate_code", return_value=(False, ""))
@patch("src.control.cpam_response.normalize_code", side_effect=lambda c: c.upper())
def test_definitions_graceful_when_code_unknown(self, mock_norm, mock_valid):
"""Un code inconnu ne crashe pas, affiche un message explicite."""
dossier = DossierMedical(
source_file="test.pdf",
diagnostic_principal=None,
)
controle = ControleCPAM(
numero_ogc=1, titre="Test", arg_ucr="Test",
decision_ucr="Rejet", dp_ucr="Z99.9", da_ucr=None,
)
result = _get_cim10_definitions(dossier, controle)
assert "Z99.9" in result
assert "non trouvé" in result
def test_definitions_empty_when_no_codes(self):
"""Aucun code → chaîne vide."""
dossier = DossierMedical(
source_file="test.pdf",
diagnostic_principal=None,
)
controle = ControleCPAM(
numero_ogc=1, titre="Test", arg_ucr="Test",
decision_ucr="Rejet", dp_ucr=None, da_ucr=None,
)
result = _get_cim10_definitions(dossier, controle)
assert result == ""
class TestBuildTaggedContext:
"""Tests pour le contexte clinique tagué (grounding)."""
def test_tagged_context_bio_img_trt(self):
"""Les tags BIO, IMG, TRT, ACTE sont correctement générés."""
dossier = _make_dossier_complet()
text, tag_map = _build_tagged_context(dossier)
assert "[BIO-1]" in text
assert "CRP" in text
assert "BIO-1" in tag_map
assert "[IMG-1]" in text
assert "Scanner abdominal" in text
assert "IMG-1" in tag_map
assert "[TRT-1]" in text
assert "Augmentin IV" in text
assert "TRT-1" in tag_map
assert "[ACTE-1]" in text
assert "Cholécystectomie" in text
assert "ACTE-1" in tag_map
def test_tagged_context_bio_norms_annotated(self):
"""Les valeurs bio sont annotées avec les normes de référence."""
dossier = DossierMedical(
source_file="test.pdf",
biologie_cle=[
BiologieCle(test="CRP", valeur="5", anomalie=False),
BiologieCle(test="CRP", valeur="180", anomalie=True),
BiologieCle(test="Hémoglobine", valeur="8.5", anomalie=True),
],
)
text, tag_map = _build_tagged_context(dossier)
# CRP 5 = normal (norme 0-5)
assert "NORMAL" in tag_map.get("BIO-1", "")
# CRP 180 = élevé
assert "ÉLEVÉ" in tag_map.get("BIO-2", "")
# Hb 8.5 = bas (norme 12-17)
assert "BAS" in tag_map.get("BIO-3", "")
def test_tagged_context_empty_dossier(self):
"""Dossier sans données cliniques → texte vide, tag_map vide."""
dossier = DossierMedical(
source_file="test.pdf",
diagnostic_principal=Diagnostic(texte="Test", cim10_suggestion="Z00"),
)
text, tag_map = _build_tagged_context(dossier)
assert text == ""
assert tag_map == {}
def test_tagged_context_in_prompt(self):
"""Le contexte tagué apparaît dans le prompt généré."""
dossier = _make_dossier_complet()
controle = _make_controle()
prompt, tag_map = _build_cpam_prompt(dossier, controle, [])
assert "ÉLÉMENTS CLINIQUES RÉFÉRENCÉS" in prompt
assert "[BIO-1]" in prompt
assert "[IMG-1]" in prompt
assert len(tag_map) > 0
def test_poor_dossier_warning_in_prompt(self):
"""Dossier sans bio/imagerie → avertissement dans le prompt."""
dossier = DossierMedical(
source_file="test.pdf",
diagnostic_principal=Diagnostic(texte="Test", cim10_suggestion="Z00"),
sejour=Sejour(sexe="M", age=70),
)
controle = _make_controle()
prompt, tag_map = _build_cpam_prompt(dossier, controle, [])
assert "DOSSIER PAUVRE" in prompt
assert "Ne spécule PAS" in prompt
assert len(tag_map) == 0
class TestValidateGrounding:
"""Tests pour la validation des preuves grounded."""
def test_grounding_valid_refs(self):
"""Toutes les refs existent → 0 warnings."""
tag_map = {"BIO-1": "CRP: 180 mg/L", "IMG-1": "Scanner abdominal"}
response_data = {
"preuves_dossier": [
{"ref": "BIO-1", "element": "biologie", "valeur": "CRP 180 mg/L", "signification": "inflammation"},
{"ref": "IMG-1", "element": "imagerie", "valeur": "Scanner", "signification": "confirme"},
]
}
warnings = _validate_grounding(response_data, tag_map)
assert len(warnings) == 0
def test_grounding_invented_ref(self):
"""Ref inventée [BIO-99] → warning détecté."""
tag_map = {"BIO-1": "CRP: 180 mg/L"}
response_data = {
"preuves_dossier": [
{"ref": "BIO-99", "element": "biologie", "valeur": "Albumine 15 g/L", "signification": "inventé"},
]
}
warnings = _validate_grounding(response_data, tag_map)
assert len(warnings) == 1
assert "BIO-99" in warnings[0]
def test_grounding_no_tag_map_no_validation(self):
"""Pas de tag_map (dossier vide) → pas de validation."""
response_data = {
"preuves_dossier": [
{"ref": "BIO-1", "element": "biologie", "valeur": "test", "signification": "test"},
]
}
warnings = _validate_grounding(response_data, {})
assert len(warnings) == 0
def test_grounding_no_ref_field_ok(self):
"""Preuves sans champ ref (ancien format) → pas de warning."""
tag_map = {"BIO-1": "CRP: 180 mg/L"}
response_data = {
"preuves_dossier": [
{"element": "biologie", "valeur": "CRP 180 mg/L", "signification": "inflammation"},
]
}
warnings = _validate_grounding(response_data, tag_map)
assert len(warnings) == 0
def test_format_response_with_ref(self):
"""Le formatage inclut le tag ref dans les preuves."""
parsed = {
"contre_arguments_medicaux": "Arguments...",
"preuves_dossier": [
{"ref": "BIO-1", "element": "biologie", "valeur": "CRP 180 mg/L", "signification": "inflammation"},
],
"conclusion": "Conclusion...",
}
text = _format_response(parsed)
assert "[BIO-1]" in text
assert "[biologie]" in text
assert "CRP 180 mg/L" in text
class TestCheckDasBioCoherence:
"""Tests pour la vérification cohérence DAS / biologie."""
def test_leucocytose_with_low_leucocytes(self):
"""DAS 'leucocytose' mais leucocytes bas → incohérence détectée."""
dossier = DossierMedical(
source_file="test.pdf",
diagnostics_associes=[
Diagnostic(texte="Leucocytose", cim10_suggestion="D72.8"),
],
biologie_cle=[
BiologieCle(test="Leucocytes", valeur="3", anomalie=True),
],
)
warnings = _check_das_bio_coherence(dossier)
assert len(warnings) == 1
assert "Leucocytose" in warnings[0]
assert "NORMAL" in warnings[0]
def test_anemie_with_normal_hb(self):
"""DAS 'anémie' mais Hb normale → incohérence détectée."""
dossier = DossierMedical(
source_file="test.pdf",
diagnostics_associes=[
Diagnostic(texte="Anémie ferriprive", cim10_suggestion="D50.9"),
],
biologie_cle=[
BiologieCle(test="Hémoglobine", valeur="14.5", anomalie=False),
],
)
warnings = _check_das_bio_coherence(dossier)
assert len(warnings) == 1
assert "anémie" in warnings[0].lower() or "Anémie" in warnings[0]
def test_coherent_das_bio_no_warnings(self):
"""DAS 'anémie' avec Hb basse → pas d'incohérence."""
dossier = DossierMedical(
source_file="test.pdf",
diagnostics_associes=[
Diagnostic(texte="Anémie", cim10_suggestion="D64.9"),
],
biologie_cle=[
BiologieCle(test="Hémoglobine", valeur="8.5", anomalie=True),
],
)
warnings = _check_das_bio_coherence(dossier)
assert len(warnings) == 0
def test_no_bio_no_crash(self):
"""Pas de biologie → pas de crash, pas de warnings."""
dossier = DossierMedical(
source_file="test.pdf",
diagnostics_associes=[
Diagnostic(texte="Leucocytose", cim10_suggestion="D72.8"),
],
)
warnings = _check_das_bio_coherence(dossier)
assert len(warnings) == 0
def test_coherence_warnings_in_prompt(self):
"""Les incohérences DAS/bio apparaissent dans le prompt."""
dossier = DossierMedical(
source_file="test.pdf",
sejour=Sejour(sexe="M", age=65),
diagnostic_principal=Diagnostic(texte="Test", cim10_suggestion="Z00"),
diagnostics_associes=[
Diagnostic(texte="Thrombocytose", cim10_suggestion="D75.9"),
],
biologie_cle=[
BiologieCle(test="Plaquettes", valeur="200", anomalie=False),
],
)
controle = _make_controle()
prompt, _ = _build_cpam_prompt(dossier, controle, [])
assert "ALERTES COHÉRENCE DAS / BIOLOGIE" in prompt
assert "Thrombocytose" in prompt
assert "NORMAL" in prompt
class TestPatientContext:
"""Tests pour le contexte patient dans le prompt."""
def test_pediatric_flag(self):
"""Patient < 18 ans → mention pédiatrie dans le prompt."""
dossier = DossierMedical(
source_file="test.pdf",
sejour=Sejour(sexe="F", age=9),
diagnostic_principal=Diagnostic(texte="Appendicite", cim10_suggestion="K35.8"),
)
controle = _make_controle()
prompt, _ = _build_cpam_prompt(dossier, controle, [])
assert "PÉDIATRIE" in prompt
assert "9 ans" in prompt
def test_elderly_flag(self):
"""Patient >= 80 ans → mention patient âgé."""
dossier = DossierMedical(
source_file="test.pdf",
sejour=Sejour(sexe="M", age=85),
diagnostic_principal=Diagnostic(texte="Test", cim10_suggestion="Z00"),
)
controle = _make_controle()
prompt, _ = _build_cpam_prompt(dossier, controle, [])
assert "patient âgé" in prompt
assert "85 ans" in prompt
def test_emergency_admission(self):
"""Admission en urgence → flag dans le prompt."""
dossier = DossierMedical(
source_file="test.pdf",
sejour=Sejour(sexe="M", age=50, mode_entree="Autres admissions urgentes"),
diagnostic_principal=Diagnostic(texte="Test", cim10_suggestion="Z00"),
)
controle = _make_controle()
prompt, _ = _build_cpam_prompt(dossier, controle, [])
assert "ADMISSION EN URGENCE" in prompt
def test_context_consigne_in_prompt(self):
"""Le prompt contient une consigne sur le contexte clinique."""
dossier = _make_dossier()
controle = _make_controle()
prompt, _ = _build_cpam_prompt(dossier, controle, [])
assert "CONTEXTE CLINIQUE" in prompt
assert "ÂGE" in prompt
assert "MODE D'ENTRÉE" in prompt
class TestExtractionPass:
"""Tests pour la passe 1 — extraction structurée."""
@patch("src.control.cpam_response.call_ollama")
def test_extraction_pass_returns_structured_json(self, mock_ollama):
"""Passe 1 retourne les champs attendus."""
mock_ollama.return_value = {
"comprehension_contestation": "La CPAM conteste le DAS K56.0",
"elements_cliniques_pertinents": [
{"tag": "BIO-1", "pertinence": "CRP élevée confirme inflammation"}
],
"points_accord_potentiels": ["Le CRH est succinct"],
"codes_en_jeu": {
"dp_etablissement": "K81.0 — Cholécystite aiguë",
"dp_ucr": "",
"difference_cle": "contestation porte sur le DAS, pas le DP",
},
}
dossier = _make_dossier()
controle = _make_controle()
result = _extraction_pass(dossier, controle)
assert result is not None
assert "comprehension_contestation" in result
assert len(result["elements_cliniques_pertinents"]) == 1
mock_ollama.assert_called_once()
@patch("src.control.cpam_response.call_anthropic", return_value=None)
@patch("src.control.cpam_response.call_ollama", return_value=None)
def test_extraction_pass_failure_returns_none(self, mock_ollama, mock_anthropic):
"""Passe 1 échoue → retourne None (fallback single-pass)."""
dossier = _make_dossier()
controle = _make_controle()
result = _extraction_pass(dossier, controle)
assert result is None
@patch("src.control.cpam_response.call_ollama")
def test_extraction_injected_in_prompt(self, mock_ollama):
"""Le résultat de passe 1 est injecté dans le prompt de passe 2."""
extraction = {
"comprehension_contestation": "La CPAM conteste le DAS K56.0",
"elements_cliniques_pertinents": [
{"tag": "BIO-1", "pertinence": "CRP élevée"}
],
"points_accord_potentiels": ["Le CRH est succinct"],
"codes_en_jeu": {
"difference_cle": "contestation porte sur le DAS",
},
}
dossier = _make_dossier()
controle = _make_controle()
prompt, _ = _build_cpam_prompt(dossier, controle, [], extraction)
assert "PRÉ-ANALYSE" in prompt
assert "La CPAM conteste le DAS K56.0" in prompt
assert "CRP élevée" in prompt
assert "contestation porte sur le DAS" in prompt
def test_prompt_without_extraction(self):
"""Sans extraction, pas de section PRÉ-ANALYSE."""
dossier = _make_dossier()
controle = _make_controle()
prompt, _ = _build_cpam_prompt(dossier, controle, [], None)
assert "PRÉ-ANALYSE" not in prompt
@patch("src.control.cpam_response.call_ollama")
@patch("src.control.cpam_response._search_rag_for_control")
def test_generate_calls_three_passes(self, mock_rag, mock_ollama):
"""L'orchestrateur appelle extraction + argumentation + validation."""
call_count = {"n": 0}
def ollama_side_effect(prompt, temperature=0.1, max_tokens=4000):
call_count["n"] += 1
if call_count["n"] == 1:
return {
"comprehension_contestation": "Contestation DAS",
"elements_cliniques_pertinents": [],
"points_accord_potentiels": [],
"codes_en_jeu": {},
}
elif call_count["n"] == 2:
return {
"analyse_contestation": "Analyse...",
"contre_arguments_medicaux": "Arguments...",
"conclusion": "Conclusion...",
}
else:
return {"coherent": True, "erreurs": [], "score_confiance": 9}
mock_ollama.side_effect = ollama_side_effect
mock_rag.return_value = []
dossier = _make_dossier()
controle = _make_controle()
text, response_data, sources = generate_cpam_response(dossier, controle)
# 3 appels Ollama : extraction + argumentation + validation
assert mock_ollama.call_count == 3
assert response_data is not None
assert "Arguments..." in text
class TestValidateAdversarial:
"""Tests pour la validation adversariale."""
@patch("src.control.cpam_response.call_ollama")
def test_coherent_response_no_warnings(self, mock_ollama):
"""Réponse cohérente → coherent=true, pas de warnings dans le texte."""
mock_ollama.return_value = {"coherent": True, "erreurs": [], "score_confiance": 9}
tag_map = {"BIO-1": "CRP: 180 mg/L"}
response_data = {
"analyse_contestation": "Analyse...",
"preuves_dossier": [{"ref": "BIO-1", "valeur": "CRP 180 mg/L"}],
"conclusion": "Conclusion...",
}
controle = _make_controle()
result = _validate_adversarial(response_data, tag_map, controle)
assert result is not None
assert result["coherent"] is True
assert len(result["erreurs"]) == 0
@patch("src.control.cpam_response.call_ollama")
def test_hallucinated_bio_detected(self, mock_ollama):
"""Valeur bio halluccinée → coherent=false avec erreur."""
mock_ollama.return_value = {
"coherent": False,
"erreurs": ["CRP citée à 250 mg/L mais le dossier indique 180 mg/L"],
"score_confiance": 3,
}
tag_map = {"BIO-1": "CRP: 180 mg/L"}
response_data = {
"preuves_dossier": [{"ref": "BIO-1", "valeur": "CRP 250 mg/L"}],
"conclusion": "Conclusion...",
}
controle = _make_controle()
result = _validate_adversarial(response_data, tag_map, controle)
assert result is not None
assert result["coherent"] is False
assert len(result["erreurs"]) == 1
assert "CRP" in result["erreurs"][0]
@patch("src.control.cpam_response.call_anthropic", return_value=None)
@patch("src.control.cpam_response.call_ollama", return_value=None)
def test_adversarial_failure_graceful(self, mock_ollama, mock_anthropic):
"""LLM indisponible → retourne None, pas de crash."""
tag_map = {"BIO-1": "CRP: 180 mg/L"}
response_data = {"conclusion": "Conclusion..."}
controle = _make_controle()
result = _validate_adversarial(response_data, tag_map, controle)
assert result is None
@patch("src.control.cpam_response.call_ollama")
@patch("src.control.cpam_response._search_rag_for_control")
def test_adversarial_warnings_in_output(self, mock_rag, mock_ollama):
"""Incohérences détectées → avertissements dans le texte formaté."""
call_count = {"n": 0}
def ollama_side_effect(prompt, temperature=0.1, max_tokens=4000):
call_count["n"] += 1
if call_count["n"] == 1:
return {"comprehension_contestation": "Extraction", "elements_cliniques_pertinents": [], "points_accord_potentiels": [], "codes_en_jeu": {}}
elif call_count["n"] == 2:
return {
"analyse_contestation": "Analyse...",
"contre_arguments_medicaux": "Arguments...",
"conclusion": "Conclusion...",
}
else:
return {
"coherent": False,
"erreurs": ["Antibiotiques mentionnés mais absents du dossier"],
"score_confiance": 4,
}
mock_ollama.side_effect = ollama_side_effect
mock_rag.return_value = []
dossier = _make_dossier()
controle = _make_controle()
text, response_data, sources = generate_cpam_response(dossier, controle)
assert "Antibiotiques mentionnés" in text
assert "Score de confiance" in text
def test_adversarial_empty_tag_map(self):
"""Dossier sans tags → validation fonctionne quand même."""
with patch("src.control.cpam_response.call_ollama") as mock_ollama:
mock_ollama.return_value = {"coherent": True, "erreurs": [], "score_confiance": 7}
result = _validate_adversarial(
{"conclusion": "Test"}, {}, _make_controle()
)
assert result is not None
assert result["coherent"] is True