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:
dom
2026-02-20 10:06:26 +01:00
parent e760b12961
commit 3c070f3c1d
6 changed files with 1553 additions and 833 deletions

View File

@@ -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