refactor: split cpam_response → cpam_rag, cpam_context, cpam_validation
Découpe le monolithe cpam_response.py (1207L) en 3 modules spécialisés : - cpam_rag.py : recherche RAG ciblée (5 requêtes, dédup) - cpam_context.py : construction prompt, définitions CIM-10, bio summary - cpam_validation.py : grounding, références, codes fermée, adversariale Le cpam_response.py reste orchestrateur (~230L) avec re-exports backward-compat. Mocks des tests mis à jour pour cibler les bons modules. Ajout RULE-CPAM-CORRECTION-LOOP dans base.yaml. 748 tests passent. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,8 @@ from src.config import (
|
||||
Traitement,
|
||||
)
|
||||
from src.control.cpam_response import (
|
||||
_build_bio_summary,
|
||||
_build_correction_prompt,
|
||||
_build_cpam_prompt,
|
||||
_build_tagged_context,
|
||||
_check_das_bio_coherence,
|
||||
@@ -25,6 +27,7 @@ from src.control.cpam_response import (
|
||||
_get_code_label,
|
||||
_search_rag_for_control,
|
||||
_validate_adversarial,
|
||||
_validate_codes_in_response,
|
||||
_validate_grounding,
|
||||
_validate_references,
|
||||
generate_cpam_response,
|
||||
@@ -206,8 +209,8 @@ class TestBuildPrompt:
|
||||
|
||||
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")
|
||||
@patch("src.control.cpam_context.validate_code", return_value=(True, "Iléus paralytique et obstruction intestinale"))
|
||||
@patch("src.control.cpam_context.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()
|
||||
@@ -217,8 +220,8 @@ class TestBuildPrompt:
|
||||
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")
|
||||
@patch("src.control.cpam_context.validate_code", return_value=(False, ""))
|
||||
@patch("src.control.cpam_context.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()
|
||||
@@ -231,8 +234,8 @@ class TestBuildPrompt:
|
||||
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")
|
||||
@patch("src.control.cpam_context.validate_code", return_value=(True, "Ajustement et entretien d'un dispositif implantable"))
|
||||
@patch("src.control.cpam_context.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(
|
||||
@@ -397,10 +400,11 @@ class TestValidateReferences:
|
||||
|
||||
|
||||
class TestGenerateResponse:
|
||||
@patch("src.control.cpam_validation.call_ollama")
|
||||
@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):
|
||||
def test_generate_success_ollama_cpam(self, mock_rag, mock_anthropic, mock_ollama, mock_val_ollama):
|
||||
"""Ollama disponible → 3 passes (extraction + argumentation + validation)."""
|
||||
mock_rag.return_value = [
|
||||
{"document": "guide_methodo", "page": 64, "extrait": "Texte guide"},
|
||||
@@ -422,6 +426,7 @@ class TestGenerateResponse:
|
||||
return {"coherent": True, "erreurs": [], "score_confiance": 9}
|
||||
|
||||
mock_ollama.side_effect = ollama_side_effect
|
||||
mock_val_ollama.side_effect = ollama_side_effect
|
||||
|
||||
dossier = _make_dossier()
|
||||
controle = _make_controle()
|
||||
@@ -434,18 +439,21 @@ class TestGenerateResponse:
|
||||
assert len(sources) == 1
|
||||
assert sources[0].document == "guide_methodo"
|
||||
# 3 appels Ollama : extraction + argumentation + validation
|
||||
assert mock_ollama.call_count == 3
|
||||
assert call_count["n"] == 3
|
||||
mock_anthropic.assert_not_called()
|
||||
|
||||
@patch("src.control.cpam_validation.call_anthropic")
|
||||
@patch("src.control.cpam_validation.call_ollama")
|
||||
@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):
|
||||
def test_generate_fallback_haiku(self, mock_rag, mock_anthropic, mock_ollama, mock_val_ollama, mock_val_anthropic):
|
||||
"""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_val_ollama.return_value = None
|
||||
call_count = {"n": 0}
|
||||
|
||||
def anthropic_side_effect(prompt, temperature=0.1, max_tokens=4000, **kwargs):
|
||||
@@ -462,6 +470,7 @@ class TestGenerateResponse:
|
||||
return {"coherent": True, "erreurs": [], "score_confiance": 8}
|
||||
|
||||
mock_anthropic.side_effect = anthropic_side_effect
|
||||
mock_val_anthropic.side_effect = anthropic_side_effect
|
||||
|
||||
dossier = _make_dossier()
|
||||
controle = _make_controle()
|
||||
@@ -470,15 +479,15 @@ class TestGenerateResponse:
|
||||
|
||||
assert "Contre-args Haiku..." in text
|
||||
assert response_data is not None
|
||||
# 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
|
||||
# 3 appels Ollama (retourne None) + 3 Anthropic en fallback
|
||||
assert call_count["n"] == 3
|
||||
|
||||
@patch("src.control.cpam_validation.call_anthropic", return_value=None)
|
||||
@patch("src.control.cpam_validation.call_ollama", return_value=None)
|
||||
@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):
|
||||
def test_generate_all_unavailable(self, mock_rag, mock_anthropic, mock_ollama, _mock_val_ollama, _mock_val_anthropic):
|
||||
"""Tous LLMs indisponibles → texte vide, response_data None."""
|
||||
mock_rag.return_value = []
|
||||
mock_anthropic.return_value = None
|
||||
@@ -657,8 +666,8 @@ class TestSearchRagForControl:
|
||||
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.control.cpam_rag.validate_code", return_value=(True, "Iléus paralytique"))
|
||||
@patch("src.control.cpam_rag.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."""
|
||||
@@ -722,8 +731,8 @@ class TestSearchRagForControl:
|
||||
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())
|
||||
@patch("src.control.cpam_context.validate_code")
|
||||
@patch("src.control.cpam_context.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: {
|
||||
@@ -742,8 +751,8 @@ class TestGetCim10Definitions:
|
||||
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())
|
||||
@patch("src.control.cpam_context.validate_code")
|
||||
@patch("src.control.cpam_context.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: {
|
||||
@@ -769,8 +778,8 @@ class TestGetCim10Definitions:
|
||||
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())
|
||||
@patch("src.control.cpam_context.validate_code", return_value=(False, ""))
|
||||
@patch("src.control.cpam_context.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(
|
||||
@@ -1149,9 +1158,10 @@ class TestExtractionPass:
|
||||
|
||||
assert "PRÉ-ANALYSE" not in prompt
|
||||
|
||||
@patch("src.control.cpam_validation.call_ollama")
|
||||
@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):
|
||||
def test_generate_calls_three_passes(self, mock_rag, mock_ollama, mock_val_ollama):
|
||||
"""L'orchestrateur appelle extraction + argumentation + validation."""
|
||||
call_count = {"n": 0}
|
||||
|
||||
@@ -1174,6 +1184,7 @@ class TestExtractionPass:
|
||||
return {"coherent": True, "erreurs": [], "score_confiance": 9}
|
||||
|
||||
mock_ollama.side_effect = ollama_side_effect
|
||||
mock_val_ollama.side_effect = ollama_side_effect
|
||||
mock_rag.return_value = []
|
||||
|
||||
dossier = _make_dossier()
|
||||
@@ -1181,7 +1192,7 @@ class TestExtractionPass:
|
||||
text, response_data, sources = generate_cpam_response(dossier, controle)
|
||||
|
||||
# 3 appels Ollama : extraction + argumentation + validation
|
||||
assert mock_ollama.call_count == 3
|
||||
assert call_count["n"] == 3
|
||||
assert response_data is not None
|
||||
assert "Arguments..." in text
|
||||
|
||||
@@ -1189,7 +1200,7 @@ class TestExtractionPass:
|
||||
class TestValidateAdversarial:
|
||||
"""Tests pour la validation adversariale."""
|
||||
|
||||
@patch("src.control.cpam_response.call_ollama")
|
||||
@patch("src.control.cpam_validation.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}
|
||||
@@ -1208,7 +1219,7 @@ class TestValidateAdversarial:
|
||||
assert result["coherent"] is True
|
||||
assert len(result["erreurs"]) == 0
|
||||
|
||||
@patch("src.control.cpam_response.call_ollama")
|
||||
@patch("src.control.cpam_validation.call_ollama")
|
||||
def test_hallucinated_bio_detected(self, mock_ollama):
|
||||
"""Valeur bio halluccinée → coherent=false avec erreur."""
|
||||
mock_ollama.return_value = {
|
||||
@@ -1231,8 +1242,8 @@ class TestValidateAdversarial:
|
||||
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)
|
||||
@patch("src.control.cpam_validation.call_anthropic", return_value=None)
|
||||
@patch("src.control.cpam_validation.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"}
|
||||
@@ -1243,9 +1254,10 @@ class TestValidateAdversarial:
|
||||
|
||||
assert result is None
|
||||
|
||||
@patch("src.control.cpam_validation.call_ollama")
|
||||
@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):
|
||||
def test_adversarial_warnings_in_output(self, mock_rag, mock_ollama, mock_val_ollama):
|
||||
"""Incohérences détectées → avertissements dans le texte formaté."""
|
||||
call_count = {"n": 0}
|
||||
|
||||
@@ -1267,6 +1279,7 @@ class TestValidateAdversarial:
|
||||
}
|
||||
|
||||
mock_ollama.side_effect = ollama_side_effect
|
||||
mock_val_ollama.side_effect = ollama_side_effect
|
||||
mock_rag.return_value = []
|
||||
|
||||
dossier = _make_dossier()
|
||||
@@ -1278,7 +1291,7 @@ class TestValidateAdversarial:
|
||||
|
||||
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:
|
||||
with patch("src.control.cpam_validation.call_ollama") as mock_ollama:
|
||||
mock_ollama.return_value = {"coherent": True, "erreurs": [], "score_confiance": 7}
|
||||
|
||||
result = _validate_adversarial(
|
||||
@@ -1287,3 +1300,380 @@ class TestValidateAdversarial:
|
||||
|
||||
assert result is not None
|
||||
assert result["coherent"] is True
|
||||
|
||||
|
||||
class TestValidateCodesInResponse:
|
||||
"""Tests pour la validation codes fermée (périmètre dossier + UCR)."""
|
||||
|
||||
def test_code_in_dossier_no_warning(self):
|
||||
"""Code du dossier cité → pas de warning."""
|
||||
parsed = {"conclusion": "Le code K81.0 est justifié par la cholécystite."}
|
||||
dossier = _make_dossier() # DP K81.0, DAS K56.0
|
||||
controle = _make_controle()
|
||||
warnings = _validate_codes_in_response(parsed, dossier, controle)
|
||||
assert len(warnings) == 0
|
||||
|
||||
def test_code_from_ucr_no_warning(self):
|
||||
"""Code proposé par l'UCR cité → pas de warning."""
|
||||
parsed = {"conclusion": "Le code K56.0 contesté par l'UCR est bien justifié."}
|
||||
dossier = _make_dossier()
|
||||
controle = _make_controle() # da_ucr="K56.0"
|
||||
warnings = _validate_codes_in_response(parsed, dossier, controle)
|
||||
assert len(warnings) == 0
|
||||
|
||||
def test_invented_code_detected(self):
|
||||
"""Code absent du dossier et de l'UCR → warning."""
|
||||
parsed = {"conclusion": "Le code Z45.8 confirme la nécessité du séjour."}
|
||||
dossier = _make_dossier() # DP K81.0, DAS K56.0
|
||||
controle = _make_controle() # da_ucr=K56.0
|
||||
warnings = _validate_codes_in_response(parsed, dossier, controle)
|
||||
assert len(warnings) >= 1
|
||||
assert any("Z45" in w for w in warnings)
|
||||
|
||||
def test_subcode_tolerated(self):
|
||||
"""K81.09 toléré quand K81.0 est dans la whitelist (même préfixe 3 chars)."""
|
||||
parsed = {"contre_arguments_medicaux": "Le sous-code K81.09 est une précision de K81.0."}
|
||||
dossier = _make_dossier() # DP K81.0
|
||||
controle = _make_controle()
|
||||
warnings = _validate_codes_in_response(parsed, dossier, controle)
|
||||
# K81.09 partage le préfixe K81 avec K81.0 → toléré
|
||||
assert len(warnings) == 0
|
||||
|
||||
def test_codes_in_citations_excluded(self):
|
||||
"""Codes dans references[].citation → pas de validation."""
|
||||
parsed = {
|
||||
"conclusion": "Le codage est justifié.",
|
||||
"references": [
|
||||
{"document": "CIM-10", "citation": "Z45.8 — Ajustement d'un dispositif"},
|
||||
],
|
||||
}
|
||||
dossier = _make_dossier()
|
||||
controle = _make_controle()
|
||||
warnings = _validate_codes_in_response(parsed, dossier, controle)
|
||||
# Z45.8 est dans references, pas dans les champs textuels → pas flaggé
|
||||
assert len(warnings) == 0
|
||||
|
||||
def test_no_codes_in_response_no_warning(self):
|
||||
"""Réponse sans codes CIM-10 → 0 warnings."""
|
||||
parsed = {"conclusion": "Le séjour est justifié par la gravité clinique."}
|
||||
dossier = _make_dossier()
|
||||
controle = _make_controle()
|
||||
warnings = _validate_codes_in_response(parsed, dossier, controle)
|
||||
assert len(warnings) == 0
|
||||
|
||||
def test_multiple_invented_codes(self):
|
||||
"""Plusieurs codes hors périmètre → autant de warnings."""
|
||||
parsed = {
|
||||
"contre_arguments_medicaux": "Les codes Z45.8 et E11.9 confirment le diagnostic.",
|
||||
}
|
||||
dossier = _make_dossier() # K81.0, K56.0
|
||||
controle = _make_controle()
|
||||
warnings = _validate_codes_in_response(parsed, dossier, controle)
|
||||
assert len(warnings) >= 2
|
||||
|
||||
def test_no_whitelist_no_validation(self):
|
||||
"""Aucun code dans le dossier ni l'UCR → pas de validation (0 warnings)."""
|
||||
parsed = {"conclusion": "Le code Z45.8 est justifié."}
|
||||
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,
|
||||
)
|
||||
warnings = _validate_codes_in_response(parsed, dossier, controle)
|
||||
assert len(warnings) == 0
|
||||
|
||||
|
||||
class TestBuildBioSummary:
|
||||
"""Tests pour le résumé biologique déterministe."""
|
||||
|
||||
def test_bio_summary_interpretation(self):
|
||||
"""CRP élevée, Hb basse → résumé correct avec interprétations cliniques."""
|
||||
dossier = DossierMedical(
|
||||
source_file="test.pdf",
|
||||
biologie_cle=[
|
||||
BiologieCle(test="CRP", valeur="180 mg/L", anomalie=True),
|
||||
BiologieCle(test="Hémoglobine", valeur="8.5 g/dL", anomalie=True),
|
||||
],
|
||||
)
|
||||
summary = _build_bio_summary(dossier)
|
||||
|
||||
assert "CRP" in summary
|
||||
assert "ÉLEVÉ" in summary
|
||||
assert "infection/inflammation active" in summary
|
||||
assert "Hémoglobine" in summary
|
||||
assert "BAS" in summary
|
||||
assert "anémie" in summary
|
||||
|
||||
def test_bio_summary_normal_values(self):
|
||||
"""Valeurs normales → interprétation 'normal' affichée."""
|
||||
dossier = DossierMedical(
|
||||
source_file="test.pdf",
|
||||
biologie_cle=[
|
||||
BiologieCle(test="Plaquettes", valeur="250 G/L", anomalie=False),
|
||||
],
|
||||
)
|
||||
summary = _build_bio_summary(dossier)
|
||||
|
||||
assert "NORMAL" in summary
|
||||
assert "numération normale" in summary
|
||||
|
||||
def test_bio_summary_in_prompt(self):
|
||||
"""Le résumé bio apparaît dans le prompt CPAM."""
|
||||
dossier = _make_dossier_complet() # CRP 180, Créatinine 450
|
||||
controle = _make_controle()
|
||||
prompt, _ = _build_cpam_prompt(dossier, controle, [])
|
||||
|
||||
assert "FAITS BIOLOGIQUES VÉRIFIÉS" in prompt
|
||||
assert "NE PAS MODIFIER" in prompt
|
||||
assert "RÈGLE STRICTE" in prompt
|
||||
|
||||
def test_bio_summary_empty_no_bio(self):
|
||||
"""Pas de biologie → résumé vide."""
|
||||
dossier = DossierMedical(source_file="test.pdf")
|
||||
summary = _build_bio_summary(dossier)
|
||||
assert summary == ""
|
||||
|
||||
def test_bio_summary_unknown_test(self):
|
||||
"""Test bio non reconnu (hors BIO_NORMALS) → omis du résumé."""
|
||||
dossier = DossierMedical(
|
||||
source_file="test.pdf",
|
||||
biologie_cle=[
|
||||
BiologieCle(test="Ferritine", valeur="15 µg/L", anomalie=True),
|
||||
],
|
||||
)
|
||||
summary = _build_bio_summary(dossier)
|
||||
assert summary == ""
|
||||
|
||||
def test_bio_summary_unparseable_value(self):
|
||||
"""Valeur bio non parseable → omise sans crash."""
|
||||
dossier = DossierMedical(
|
||||
source_file="test.pdf",
|
||||
biologie_cle=[
|
||||
BiologieCle(test="CRP", valeur="positif", anomalie=True),
|
||||
BiologieCle(test="Hémoglobine", valeur="8.5 g/dL", anomalie=True),
|
||||
],
|
||||
)
|
||||
summary = _build_bio_summary(dossier)
|
||||
# CRP "positif" non parseable → omis, mais Hb présente
|
||||
assert "Hémoglobine" in summary
|
||||
assert "CRP" not in summary
|
||||
|
||||
|
||||
class TestCorrectionLoop:
|
||||
"""Tests pour la boucle de correction adversariale."""
|
||||
|
||||
@patch("src.control.cpam_response.rule_enabled", return_value=True)
|
||||
@patch("src.control.cpam_validation.call_ollama")
|
||||
@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_correction_triggered_when_score_low(self, mock_rag, mock_anthropic, mock_ollama, mock_val_ollama, mock_rule):
|
||||
"""Score adversarial ≤ 5 → correction relancée (5 appels LLM total)."""
|
||||
mock_rag.return_value = []
|
||||
call_count = {"n": 0}
|
||||
|
||||
def ollama_side_effect(prompt, temperature=0.1, max_tokens=4000, **kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
# Passe 1 extraction
|
||||
return {"comprehension_contestation": "Extraction", "elements_cliniques_pertinents": [], "points_accord_potentiels": [], "codes_en_jeu": {}}
|
||||
elif call_count["n"] == 2:
|
||||
# Passe 2 argumentation
|
||||
return {
|
||||
"analyse_contestation": "Analyse...",
|
||||
"contre_arguments_medicaux": "Arguments erronés...",
|
||||
"conclusion": "Conclusion avec erreurs...",
|
||||
}
|
||||
elif call_count["n"] == 3:
|
||||
# Passe 3 validation adversariale → score bas
|
||||
return {"coherent": False, "erreurs": ["CRP citée à 250 mais vaut 180"], "score_confiance": 3}
|
||||
elif call_count["n"] == 4:
|
||||
# Passe 4 correction
|
||||
return {
|
||||
"analyse_contestation": "Analyse corrigée...",
|
||||
"contre_arguments_medicaux": "Arguments corrigés...",
|
||||
"conclusion": "Conclusion corrigée...",
|
||||
}
|
||||
else:
|
||||
# Passe 5 re-validation
|
||||
return {"coherent": True, "erreurs": [], "score_confiance": 8}
|
||||
|
||||
mock_ollama.side_effect = ollama_side_effect
|
||||
mock_val_ollama.side_effect = ollama_side_effect
|
||||
|
||||
dossier = _make_dossier()
|
||||
controle = _make_controle()
|
||||
text, response_data, sources = generate_cpam_response(dossier, controle)
|
||||
|
||||
# 5 appels Ollama : extraction + argumentation + validation + correction + re-validation
|
||||
assert call_count["n"] == 5
|
||||
# La correction a été acceptée (score 8 > 3)
|
||||
assert "corrigé" in text.lower()
|
||||
|
||||
@patch("src.control.cpam_response.rule_enabled", return_value=True)
|
||||
@patch("src.control.cpam_validation.call_ollama")
|
||||
@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_no_correction_when_score_high(self, mock_rag, mock_anthropic, mock_ollama, mock_val_ollama, mock_rule):
|
||||
"""Score adversarial > 5 → pas de correction (3 appels LLM)."""
|
||||
mock_rag.return_value = []
|
||||
call_count = {"n": 0}
|
||||
|
||||
def ollama_side_effect(prompt, temperature=0.1, max_tokens=4000, **kwargs):
|
||||
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": True, "erreurs": [], "score_confiance": 8}
|
||||
|
||||
mock_ollama.side_effect = ollama_side_effect
|
||||
mock_val_ollama.side_effect = ollama_side_effect
|
||||
|
||||
dossier = _make_dossier()
|
||||
controle = _make_controle()
|
||||
text, response_data, sources = generate_cpam_response(dossier, controle)
|
||||
|
||||
# Seulement 3 appels : extraction + argumentation + validation
|
||||
assert call_count["n"] == 3
|
||||
|
||||
@patch("src.control.cpam_response.rule_enabled", return_value=True)
|
||||
@patch("src.control.cpam_validation.call_ollama")
|
||||
@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_correction_accepted_when_score_improves(self, mock_rag, mock_anthropic, mock_ollama, mock_val_ollama, mock_rule):
|
||||
"""Score passe de 3 à 7 → correction acceptée."""
|
||||
mock_rag.return_value = []
|
||||
call_count = {"n": 0}
|
||||
|
||||
def ollama_side_effect(prompt, temperature=0.1, max_tokens=4000, **kwargs):
|
||||
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 originale...",
|
||||
"contre_arguments_medicaux": "Arguments originaux...",
|
||||
"conclusion": "Conclusion originale...",
|
||||
}
|
||||
elif call_count["n"] == 3:
|
||||
return {"coherent": False, "erreurs": ["Erreur bio"], "score_confiance": 3}
|
||||
elif call_count["n"] == 4:
|
||||
return {
|
||||
"analyse_contestation": "Analyse améliorée...",
|
||||
"contre_arguments_medicaux": "Arguments améliorés...",
|
||||
"conclusion": "Conclusion améliorée...",
|
||||
}
|
||||
else:
|
||||
return {"coherent": True, "erreurs": [], "score_confiance": 7}
|
||||
|
||||
mock_ollama.side_effect = ollama_side_effect
|
||||
mock_val_ollama.side_effect = ollama_side_effect
|
||||
|
||||
dossier = _make_dossier()
|
||||
controle = _make_controle()
|
||||
text, response_data, sources = generate_cpam_response(dossier, controle)
|
||||
|
||||
# Le résultat final est la correction
|
||||
assert response_data["conclusion"] == "Conclusion améliorée..."
|
||||
|
||||
@patch("src.control.cpam_response.rule_enabled", return_value=True)
|
||||
@patch("src.control.cpam_validation.call_ollama")
|
||||
@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_correction_rejected_when_score_same(self, mock_rag, mock_anthropic, mock_ollama, mock_val_ollama, mock_rule):
|
||||
"""Score ne s'améliore pas → original conservé."""
|
||||
mock_rag.return_value = []
|
||||
call_count = {"n": 0}
|
||||
|
||||
def ollama_side_effect(prompt, temperature=0.1, max_tokens=4000, **kwargs):
|
||||
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 originale...",
|
||||
"contre_arguments_medicaux": "Arguments originaux...",
|
||||
"conclusion": "Conclusion originale...",
|
||||
}
|
||||
elif call_count["n"] == 3:
|
||||
return {"coherent": False, "erreurs": ["Erreur bio"], "score_confiance": 4}
|
||||
elif call_count["n"] == 4:
|
||||
return {
|
||||
"analyse_contestation": "Correction pire...",
|
||||
"contre_arguments_medicaux": "Arguments pires...",
|
||||
"conclusion": "Conclusion pire...",
|
||||
}
|
||||
else:
|
||||
return {"coherent": False, "erreurs": ["Encore des erreurs"], "score_confiance": 3}
|
||||
|
||||
mock_ollama.side_effect = ollama_side_effect
|
||||
mock_val_ollama.side_effect = ollama_side_effect
|
||||
|
||||
dossier = _make_dossier()
|
||||
controle = _make_controle()
|
||||
text, response_data, sources = generate_cpam_response(dossier, controle)
|
||||
|
||||
# Score correction (3) <= score original (4) → original conservé
|
||||
assert response_data["conclusion"] == "Conclusion originale..."
|
||||
|
||||
@patch("src.control.cpam_response.rule_enabled", return_value=False)
|
||||
@patch("src.control.cpam_validation.call_ollama")
|
||||
@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_correction_disabled_by_rule(self, mock_rag, mock_anthropic, mock_ollama, mock_val_ollama, mock_rule):
|
||||
"""RULE-CPAM-CORRECTION-LOOP désactivée → pas de retry."""
|
||||
mock_rag.return_value = []
|
||||
call_count = {"n": 0}
|
||||
|
||||
def ollama_side_effect(prompt, temperature=0.1, max_tokens=4000, **kwargs):
|
||||
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": ["Erreur bio"], "score_confiance": 2}
|
||||
|
||||
mock_ollama.side_effect = ollama_side_effect
|
||||
mock_val_ollama.side_effect = ollama_side_effect
|
||||
|
||||
dossier = _make_dossier()
|
||||
controle = _make_controle()
|
||||
text, response_data, sources = generate_cpam_response(dossier, controle)
|
||||
|
||||
# Seulement 3 appels, pas de correction (règle désactivée)
|
||||
assert call_count["n"] == 3
|
||||
|
||||
def test_build_correction_prompt_format(self):
|
||||
"""Le prompt de correction contient les erreurs et la réponse originale."""
|
||||
original_prompt = "Prompt d'argumentation original..."
|
||||
original_response = {
|
||||
"analyse_contestation": "Analyse avec erreur CRP 250",
|
||||
"conclusion": "Conclusion erronée",
|
||||
}
|
||||
adversarial_result = {
|
||||
"coherent": False,
|
||||
"erreurs": ["CRP citée à 250 mg/L mais le dossier indique 180 mg/L"],
|
||||
"score_confiance": 3,
|
||||
}
|
||||
|
||||
correction = _build_correction_prompt(original_prompt, original_response, adversarial_result)
|
||||
|
||||
assert "CORRECTION REQUISE" in correction
|
||||
assert "CRP citée à 250" in correction
|
||||
assert "Prompt d'argumentation original" in correction
|
||||
assert "Corrige UNIQUEMENT" in correction
|
||||
|
||||
Reference in New Issue
Block a user