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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user