feat: mode hybride Ollama — gemma3:27b pour CPAM, 12b pour codage
Le pipeline utilise désormais gemma3:12b (rapide) pour le codage CIM-10 et gemma3:27b (meilleur raisonnement) pour la contre-argumentation CPAM. Configurable via OLLAMA_MODEL_CPAM et OLLAMA_TIMEOUT_CPAM. Inclut aussi : traçabilité source/page DAS, niveaux CMA ATIH, sévérité, page tracker PDF, améliorations fusion et filtres DAS. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ from src.control.cpam_response import (
|
||||
_build_cpam_prompt,
|
||||
_format_response,
|
||||
_search_rag_for_control,
|
||||
_validate_references,
|
||||
generate_cpam_response,
|
||||
)
|
||||
|
||||
@@ -173,6 +174,31 @@ class TestBuildPrompt:
|
||||
assert "contre_arguments_asymetrie" in prompt
|
||||
assert "contre_arguments_reglementaires" in prompt
|
||||
|
||||
def test_prompt_contains_cite_exacts(self):
|
||||
"""Le prompt renforcé demande des preuves exactes."""
|
||||
dossier = _make_dossier()
|
||||
controle = _make_controle()
|
||||
prompt = _build_cpam_prompt(dossier, controle, [])
|
||||
|
||||
assert "CITE" in prompt
|
||||
assert "EXACTS" in prompt
|
||||
|
||||
def test_prompt_contains_interdiction(self):
|
||||
"""Le prompt interdit les références inventées."""
|
||||
dossier = _make_dossier()
|
||||
controle = _make_controle()
|
||||
prompt = _build_cpam_prompt(dossier, controle, [])
|
||||
|
||||
assert "INTERDICTION ABSOLUE" in prompt
|
||||
|
||||
def test_prompt_contains_preuves_dossier_field(self):
|
||||
"""Le format JSON demandé inclut preuves_dossier."""
|
||||
dossier = _make_dossier()
|
||||
controle = _make_controle()
|
||||
prompt = _build_cpam_prompt(dossier, controle, [])
|
||||
|
||||
assert "preuves_dossier" in prompt
|
||||
|
||||
|
||||
class TestFormatResponse:
|
||||
def test_full_response_new_format(self):
|
||||
@@ -236,11 +262,94 @@ class TestFormatResponse:
|
||||
text = _format_response({})
|
||||
assert text == ""
|
||||
|
||||
def test_preuves_dossier_formatting(self):
|
||||
"""Le nouveau champ preuves_dossier est formaté correctement."""
|
||||
parsed = {
|
||||
"contre_arguments_medicaux": "Arguments...",
|
||||
"preuves_dossier": [
|
||||
{"element": "biologie", "valeur": "CRP 180 mg/L", "signification": "inflammation sévère"},
|
||||
{"element": "imagerie", "valeur": "lithiase cholédocienne", "signification": "confirme le diagnostic"},
|
||||
],
|
||||
"conclusion": "Conclusion...",
|
||||
}
|
||||
text = _format_response(parsed)
|
||||
|
||||
assert "PREUVES DU DOSSIER" in text
|
||||
assert "CRP 180 mg/L" in text
|
||||
assert "[biologie]" in text
|
||||
assert "[imagerie]" in text
|
||||
|
||||
def test_structured_references_formatting(self):
|
||||
"""Les références structurées sont formatées correctement."""
|
||||
parsed = {
|
||||
"contre_arguments_medicaux": "Arguments...",
|
||||
"references": [
|
||||
{"document": "Guide Méthodologique MCO 2026", "page": "64", "citation": "Le DAS doit être..."},
|
||||
],
|
||||
"conclusion": "Conclusion...",
|
||||
}
|
||||
text = _format_response(parsed)
|
||||
|
||||
assert "REFERENCES" in text
|
||||
assert "Guide Méthodologique MCO 2026" in text
|
||||
assert "p.64" in text
|
||||
assert "Le DAS doit être..." in text
|
||||
|
||||
def test_ref_warnings_appended(self):
|
||||
"""Les avertissements de références non vérifiées apparaissent."""
|
||||
parsed = {"conclusion": "Conclusion..."}
|
||||
warnings = ["Référence non vérifiable : Manuel Imaginaire 2025"]
|
||||
text = _format_response(parsed, ref_warnings=warnings)
|
||||
|
||||
assert "AVERTISSEMENT" in text
|
||||
assert "Manuel Imaginaire 2025" in text
|
||||
|
||||
|
||||
class TestValidateReferences:
|
||||
def test_valid_reference_no_warning(self):
|
||||
parsed = {
|
||||
"references": [
|
||||
{"document": "Guide Méthodologique MCO 2026", "page": "64", "citation": "..."},
|
||||
]
|
||||
}
|
||||
sources = [{"document": "guide_methodo", "page": 64, "extrait": "..."}]
|
||||
warnings = _validate_references(parsed, sources)
|
||||
assert len(warnings) == 0
|
||||
|
||||
def test_invented_reference_detected(self):
|
||||
parsed = {
|
||||
"references": [
|
||||
{"document": "Manuel Inventé 2025", "page": "12", "citation": "..."},
|
||||
]
|
||||
}
|
||||
sources = [{"document": "guide_methodo", "page": 64, "extrait": "..."}]
|
||||
warnings = _validate_references(parsed, sources)
|
||||
assert len(warnings) == 1
|
||||
assert "Manuel Inventé" in warnings[0]
|
||||
|
||||
def test_old_format_string_no_crash(self):
|
||||
"""L'ancien format string pour references ne cause pas de crash."""
|
||||
parsed = {"references": "Guide méthodo p.64"}
|
||||
sources = [{"document": "guide_methodo"}]
|
||||
warnings = _validate_references(parsed, sources)
|
||||
assert len(warnings) == 0 # pas de validation sur l'ancien format
|
||||
|
||||
def test_no_sources_no_validation(self):
|
||||
parsed = {
|
||||
"references": [
|
||||
{"document": "Quelque chose", "page": "1", "citation": "..."},
|
||||
]
|
||||
}
|
||||
warnings = _validate_references(parsed, [])
|
||||
assert len(warnings) == 0
|
||||
|
||||
|
||||
class TestGenerateResponse:
|
||||
@patch("src.control.cpam_response.call_ollama")
|
||||
@patch("src.control.cpam_response.call_anthropic")
|
||||
@patch("src.control.cpam_response._search_rag_for_control")
|
||||
def test_generate_success(self, mock_rag, mock_ollama):
|
||||
def test_generate_success_ollama_cpam(self, mock_rag, mock_anthropic, mock_ollama):
|
||||
"""Mode hybride : Ollama CPAM (27b) disponible → utilisé en premier."""
|
||||
mock_rag.return_value = [
|
||||
{"document": "guide_methodo", "page": 64, "extrait": "Texte guide"},
|
||||
]
|
||||
@@ -259,12 +368,42 @@ class TestGenerateResponse:
|
||||
assert "Contre-arguments médicaux..." in text
|
||||
assert len(sources) == 1
|
||||
assert sources[0].document == "guide_methodo"
|
||||
# Ollama CPAM appelé en premier (avec model= et timeout=)
|
||||
mock_ollama.assert_called_once()
|
||||
mock_anthropic.assert_not_called()
|
||||
|
||||
@patch("src.control.cpam_response.call_ollama")
|
||||
@patch("src.control.cpam_response.call_anthropic")
|
||||
@patch("src.control.cpam_response._search_rag_for_control")
|
||||
def test_generate_ollama_unavailable(self, mock_rag, mock_ollama):
|
||||
def test_generate_fallback_haiku(self, mock_rag, mock_anthropic, mock_ollama):
|
||||
"""Ollama CPAM indisponible → fallback Haiku."""
|
||||
mock_rag.return_value = [
|
||||
{"document": "guide_methodo", "page": 64, "extrait": "Texte guide"},
|
||||
]
|
||||
mock_ollama.return_value = None
|
||||
mock_anthropic.return_value = {
|
||||
"analyse_contestation": "Analyse Haiku...",
|
||||
"contre_arguments_medicaux": "Contre-args Haiku...",
|
||||
"conclusion": "Conclusion Haiku...",
|
||||
}
|
||||
|
||||
dossier = _make_dossier()
|
||||
controle = _make_controle()
|
||||
|
||||
text, sources = generate_cpam_response(dossier, controle)
|
||||
|
||||
assert "Contre-args Haiku..." in text
|
||||
# Ollama CPAM appelé d'abord (échec), puis Haiku
|
||||
mock_ollama.assert_called_once()
|
||||
mock_anthropic.assert_called_once()
|
||||
|
||||
@patch("src.control.cpam_response.call_ollama")
|
||||
@patch("src.control.cpam_response.call_anthropic")
|
||||
@patch("src.control.cpam_response._search_rag_for_control")
|
||||
def test_generate_all_unavailable(self, mock_rag, mock_anthropic, mock_ollama):
|
||||
"""Ollama CPAM, Haiku et Ollama défaut tous indisponibles → texte vide."""
|
||||
mock_rag.return_value = []
|
||||
mock_anthropic.return_value = None
|
||||
mock_ollama.return_value = None
|
||||
|
||||
dossier = _make_dossier()
|
||||
|
||||
Reference in New Issue
Block a user