feat: enrichissement contre-argumentation CPAM — libellés CIM-10, RAG ciblé, reprocess complet
- Résolution des libellés CIM-10 pour les codes contestés (dp_ucr, da_ucr, dr_ucr) - Fallback DP depuis dp_ucr quand le pipeline n'extrait pas de diagnostic principal - Troncature arg_ucr augmentée de 200 à 500 chars pour conserver les citations de règles - Requête RAG 4 : définitions CIM-10 (inclusion/exclusion) des codes contestés - Requête RAG 5 : extraction et recherche des règles nommées (RègleT7, Annexe, etc.) - Cap résultats RAG de 10 à 12 pour absorber les nouvelles requêtes - Reprocess viewer : pipeline complet (fusion + GHM + CPAM) pour dossiers multi-PDF - Affichage structuré response_data dans le viewer (analyse, preuves, références) - 7 nouveaux tests CPAM, 6 nouveaux tests viewer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ from src.config import (
|
||||
from src.control.cpam_response import (
|
||||
_build_cpam_prompt,
|
||||
_format_response,
|
||||
_get_code_label,
|
||||
_search_rag_for_control,
|
||||
_validate_references,
|
||||
generate_cpam_response,
|
||||
@@ -199,6 +200,51 @@ 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")
|
||||
def test_prompt_codes_with_cim10_labels(self, mock_norm, mock_valid):
|
||||
"""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, [])
|
||||
|
||||
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")
|
||||
def test_prompt_codes_invalid_graceful(self, mock_norm, mock_valid):
|
||||
"""Les codes invalides ne crashent pas, juste pas de libellé."""
|
||||
dossier = _make_dossier()
|
||||
controle = ControleCPAM(
|
||||
numero_ogc=1, titre="Test", arg_ucr="Test",
|
||||
decision_ucr="Rejet", dp_ucr="Z99.9", da_ucr=None,
|
||||
)
|
||||
prompt = _build_cpam_prompt(dossier, controle, [])
|
||||
|
||||
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")
|
||||
def test_prompt_dp_fallback_from_ucr(self, mock_norm, mock_valid):
|
||||
"""DP absent + dp_ucr → contexte injecté dans le prompt."""
|
||||
dossier = DossierMedical(
|
||||
source_file="test.pdf",
|
||||
document_type="crh",
|
||||
sejour=Sejour(),
|
||||
diagnostic_principal=None,
|
||||
)
|
||||
controle = ControleCPAM(
|
||||
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, [])
|
||||
|
||||
assert "codé par l'établissement" in prompt
|
||||
assert "contesté par la CPAM" in prompt
|
||||
assert "Z45.8" in prompt
|
||||
|
||||
|
||||
class TestFormatResponse:
|
||||
def test_full_response_new_format(self):
|
||||
@@ -349,7 +395,7 @@ 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):
|
||||
"""Mode hybride : Ollama CPAM (27b) disponible → utilisé en premier."""
|
||||
"""Ollama disponible → utilisé en premier, retourne triplet."""
|
||||
mock_rag.return_value = [
|
||||
{"document": "guide_methodo", "page": 64, "extrait": "Texte guide"},
|
||||
]
|
||||
@@ -363,12 +409,14 @@ class TestGenerateResponse:
|
||||
dossier = _make_dossier()
|
||||
controle = _make_controle()
|
||||
|
||||
text, sources = generate_cpam_response(dossier, controle)
|
||||
text, response_data, sources = generate_cpam_response(dossier, controle)
|
||||
|
||||
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"
|
||||
# Ollama CPAM appelé en premier (avec model= et timeout=)
|
||||
mock_ollama.assert_called_once()
|
||||
mock_anthropic.assert_not_called()
|
||||
|
||||
@@ -376,7 +424,7 @@ class TestGenerateResponse:
|
||||
@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 CPAM indisponible → fallback Haiku."""
|
||||
"""Ollama indisponible → fallback Haiku, retourne triplet."""
|
||||
mock_rag.return_value = [
|
||||
{"document": "guide_methodo", "page": 64, "extrait": "Texte guide"},
|
||||
]
|
||||
@@ -390,10 +438,11 @@ class TestGenerateResponse:
|
||||
dossier = _make_dossier()
|
||||
controle = _make_controle()
|
||||
|
||||
text, sources = generate_cpam_response(dossier, controle)
|
||||
text, response_data, sources = generate_cpam_response(dossier, controle)
|
||||
|
||||
assert "Contre-args Haiku..." in text
|
||||
# Ollama CPAM appelé d'abord (échec), puis Haiku
|
||||
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()
|
||||
|
||||
@@ -401,7 +450,7 @@ class TestGenerateResponse:
|
||||
@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."""
|
||||
"""Tous LLMs indisponibles → texte vide, response_data None."""
|
||||
mock_rag.return_value = []
|
||||
mock_anthropic.return_value = None
|
||||
mock_ollama.return_value = None
|
||||
@@ -409,9 +458,10 @@ class TestGenerateResponse:
|
||||
dossier = _make_dossier()
|
||||
controle = _make_controle()
|
||||
|
||||
text, sources = generate_cpam_response(dossier, controle)
|
||||
text, response_data, sources = generate_cpam_response(dossier, controle)
|
||||
|
||||
assert text == ""
|
||||
assert response_data is None
|
||||
assert sources == []
|
||||
|
||||
|
||||
@@ -545,8 +595,8 @@ class TestSearchRagForControl:
|
||||
assert "diagnostic principal" in first_call_query
|
||||
|
||||
@patch("src.medical.rag_search.search_similar_cpam")
|
||||
def test_max_10_results(self, mock_search):
|
||||
"""Le résultat final est limité à 10 entrées."""
|
||||
def test_max_12_results(self, mock_search):
|
||||
"""Le résultat final est limité à 12 entrées."""
|
||||
mock_search.return_value = [
|
||||
{"document": "guide_methodo", "page": i, "code": None,
|
||||
"score": 0.9 - i * 0.01, "extrait": f"Texte {i}"}
|
||||
@@ -558,7 +608,67 @@ class TestSearchRagForControl:
|
||||
|
||||
results = _search_rag_for_control(controle, dossier)
|
||||
|
||||
assert len(results) <= 10
|
||||
assert len(results) <= 12
|
||||
|
||||
@patch("src.medical.rag_search.search_similar_cpam")
|
||||
def test_arg_ucr_not_truncated_200(self, mock_search):
|
||||
"""La requête RAG argument utilise jusqu'à 500 chars, pas 200."""
|
||||
mock_search.return_value = []
|
||||
|
||||
dossier = _make_dossier()
|
||||
long_arg = "A" * 400
|
||||
controle = ControleCPAM(
|
||||
numero_ogc=1, titre="Test", arg_ucr=long_arg,
|
||||
decision_ucr="Rejet", dp_ucr=None, da_ucr=None,
|
||||
)
|
||||
|
||||
_search_rag_for_control(controle, dossier)
|
||||
|
||||
# La requête argument doit contenir les 400 chars (pas tronquée à 200)
|
||||
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.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."""
|
||||
mock_search.return_value = []
|
||||
|
||||
dossier = _make_dossier()
|
||||
controle = _make_controle() # da_ucr="K56.0"
|
||||
|
||||
_search_rag_for_control(controle, dossier)
|
||||
|
||||
# Chercher la requête contenant "CIM-10" et "définition"
|
||||
cim10_queries = [
|
||||
c[0][0] for c in mock_search.call_args_list
|
||||
if "CIM-10" in c[0][0] and "définition" in c[0][0]
|
||||
]
|
||||
assert len(cim10_queries) >= 1
|
||||
assert "K56.0" in cim10_queries[0]
|
||||
|
||||
@patch("src.medical.rag_search.search_similar_cpam")
|
||||
def test_query_rule_extraction(self, mock_search):
|
||||
"""Requête 5 exécutée quand arg_ucr contient une règle nommée."""
|
||||
mock_search.return_value = []
|
||||
|
||||
dossier = _make_dossier()
|
||||
controle = ControleCPAM(
|
||||
numero_ogc=1, titre="Désaccord DAS",
|
||||
arg_ucr="Selon la RègleT7 et l'Annexe-4B, le DAS n'est pas justifié.",
|
||||
decision_ucr="Rejet", dp_ucr=None, da_ucr=None,
|
||||
)
|
||||
|
||||
_search_rag_for_control(controle, dossier)
|
||||
|
||||
# Chercher la requête contenant les règles extraites
|
||||
rule_queries = [
|
||||
c[0][0] for c in mock_search.call_args_list
|
||||
if "guide méthodologique" in c[0][0]
|
||||
]
|
||||
assert len(rule_queries) >= 1
|
||||
assert "RègleT7" in rule_queries[0] or "Annexe" in rule_queries[0]
|
||||
|
||||
@patch("src.medical.rag_search.search_similar_cpam")
|
||||
def test_clinical_query_when_das_match(self, mock_search):
|
||||
@@ -570,8 +680,11 @@ class TestSearchRagForControl:
|
||||
|
||||
_search_rag_for_control(controle, dossier)
|
||||
|
||||
# 3 appels : codes + argument + clinique
|
||||
assert mock_search.call_count == 3
|
||||
third_call_query = mock_search.call_args_list[2][0][0]
|
||||
assert "Iléus réflexe" in third_call_query
|
||||
assert "Cholécystite aiguë" in third_call_query
|
||||
# Au moins 4 appels : codes + argument + clinique + CIM-10 définitions
|
||||
assert mock_search.call_count >= 4
|
||||
# La requête clinique contient DP + DAS textes
|
||||
clinique_queries = [
|
||||
c[0][0] for c in mock_search.call_args_list
|
||||
if "Iléus réflexe" in c[0][0] and "Cholécystite aiguë" in c[0][0]
|
||||
]
|
||||
assert len(clinique_queries) >= 1
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from src.viewer.app import create_app, compute_group_stats, severity_badge, format_duration
|
||||
from src.viewer.app import create_app, compute_group_stats, severity_badge, format_duration, format_cpam_text
|
||||
from src.config import DossierMedical, Diagnostic, ActeCCAM
|
||||
|
||||
|
||||
@@ -104,6 +104,40 @@ class TestIndexPageLoads:
|
||||
assert b"Dossiers" in response.data
|
||||
|
||||
|
||||
class TestFormatCpamText:
|
||||
def test_plain_text(self):
|
||||
result = format_cpam_text("Un simple paragraphe.")
|
||||
assert "<p" in result
|
||||
assert "Un simple paragraphe." in result
|
||||
|
||||
def test_bullet_list(self):
|
||||
result = format_cpam_text("- Premier argument\n- Deuxième argument")
|
||||
assert "<ul" in result
|
||||
assert "<li>Premier argument</li>" in result
|
||||
assert "<li>Deuxième argument</li>" in result
|
||||
|
||||
def test_mixed_text_and_bullets(self):
|
||||
text = "Introduction\n- Point A\n- Point B\nConclusion"
|
||||
result = format_cpam_text(text)
|
||||
assert "<p" in result
|
||||
assert "<ul" in result
|
||||
assert "<li>Point A</li>" in result
|
||||
assert "Conclusion" in result
|
||||
|
||||
def test_none_input(self):
|
||||
result = format_cpam_text(None)
|
||||
assert result == ""
|
||||
|
||||
def test_empty_input(self):
|
||||
result = format_cpam_text("")
|
||||
assert result == ""
|
||||
|
||||
def test_html_escaping(self):
|
||||
result = format_cpam_text("Test <script>alert('xss')</script>")
|
||||
assert "<script>" not in result
|
||||
assert "<script>" in result
|
||||
|
||||
|
||||
class TestDetailPageLoads:
|
||||
def test_detail_page_404(self, client):
|
||||
"""Un fichier inexistant retourne 404."""
|
||||
|
||||
Reference in New Issue
Block a user