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:
dom
2026-02-17 17:53:53 +01:00
parent 4ef42dd3d3
commit 01d47f3c4b
20 changed files with 1025 additions and 98 deletions

View File

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