Files
t2a/tests/test_rag.py
dom 7e69f994b0 feat: dictionnaire CCAM complet (8 257 codes) + index FAISS enrichi + validation actes
Phase 2 (CCAM) :
- Nouveau src/medical/ccam_dict.py : build depuis CCAM_V81.xls via xlrd, lookup 3 niveaux, validation codes
- Intégration dans l'extracteur : fallback ccam_lookup + _validate_ccam() avec alertes
- CLI : --build-ccam-dict, --rebuild-index

Phase 3 (FAISS) :
- Chunks CCAM depuis le dictionnaire JSON (priorité sur le PDF)
- Chunks CIM-10 index alphabétique (terme → code)
- Priorisation cim10_alpha dans la recherche RAG

Viewer : endpoint reprocess + bloc scripts
Tests : 8 tests CCAM + tests raisonnement RAG (161 passed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 11:41:39 +01:00

599 lines
23 KiB
Python

"""Tests pour le RAG CIM-10 (modèles, chunking, intégration)."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
from src.config import RAGSource, Diagnostic, DossierMedical, CIM10_PDF, GUIDE_METHODO_PDF, CCAM_PDF
class TestRAGSource:
def test_create_minimal(self):
src = RAGSource(document="cim10")
assert src.document == "cim10"
assert src.page is None
assert src.code is None
assert src.extrait is None
def test_create_full(self):
src = RAGSource(
document="guide_methodo",
page=42,
code="K85",
extrait="Pancréatite aiguë biliaire...",
)
assert src.document == "guide_methodo"
assert src.page == 42
assert src.code == "K85"
assert src.extrait == "Pancréatite aiguë biliaire..."
def test_serialization(self):
src = RAGSource(document="ccam", page=1, code="HMFC004")
data = src.model_dump(exclude_none=True)
assert data == {"document": "ccam", "page": 1, "code": "HMFC004"}
class TestDiagnosticExtended:
def test_backward_compatible(self):
"""Les nouveaux champs sont optionnels — rétrocompatible."""
d = Diagnostic(texte="Pancréatite aiguë", cim10_suggestion="K85.9")
assert d.texte == "Pancréatite aiguë"
assert d.cim10_suggestion == "K85.9"
assert d.cim10_confidence is None
assert d.justification is None
assert d.raisonnement is None
assert d.sources_rag == []
def test_with_rag_fields(self):
d = Diagnostic(
texte="Lithiase cholédoque",
cim10_suggestion="K80.5",
cim10_confidence="high",
justification="Code K80.5 correspond à la lithiase du cholédoque",
raisonnement="1. ANALYSE CLINIQUE : La lithiase cholédoque est...",
sources_rag=[
RAGSource(document="cim10", page=480, code="K80"),
],
)
assert d.cim10_confidence == "high"
assert d.justification is not None
assert d.raisonnement is not None
assert d.raisonnement.startswith("1. ANALYSE CLINIQUE")
assert len(d.sources_rag) == 1
assert d.sources_rag[0].code == "K80"
def test_serialization_exclude_none(self):
"""Vérifier que le JSON n'inclut pas les champs None."""
d = Diagnostic(texte="Test", cim10_suggestion="K85.9")
data = d.model_dump(exclude_none=True)
assert "cim10_confidence" not in data
assert "justification" not in data
assert "raisonnement" not in data
assert "sources_rag" in data # list vide incluse
def test_dossier_with_extended_diagnostic(self):
"""Un DossierMedical avec des diagnostics enrichis par le RAG."""
dossier = DossierMedical(
diagnostic_principal=Diagnostic(
texte="Pancréatite aiguë biliaire",
cim10_suggestion="K85.1",
cim10_confidence="high",
justification="Confirmé par CIM-10 FR 2026",
raisonnement="Le DP K85.1 est le code le plus spécifique...",
sources_rag=[
RAGSource(document="cim10", page=496, code="K85"),
RAGSource(document="guide_methodo", page=30),
],
),
)
assert dossier.diagnostic_principal.cim10_confidence == "high"
assert dossier.diagnostic_principal.raisonnement is not None
assert len(dossier.diagnostic_principal.sources_rag) == 2
class TestExtractMedicalInfoRAGFlag:
def test_use_rag_false_no_change(self):
"""use_rag=False ne modifie pas le comportement existant."""
from src.medical.cim10_extractor import extract_medical_info
parsed = {
"type": "crh",
"patient": {"sexe": "M"},
"sejour": {},
"diagnostics": [],
}
text = "Pancréatite aiguë biliaire.\nTTT de sortie :\nParacétamol\n\nDevenir : retour."
dossier = extract_medical_info(parsed, text, use_rag=False)
assert dossier.diagnostic_principal is not None
assert dossier.diagnostic_principal.cim10_suggestion == "K85.1"
# Pas de sources RAG
assert dossier.diagnostic_principal.sources_rag == []
assert dossier.diagnostic_principal.justification is None
def test_use_rag_true_calls_enrich(self):
"""use_rag=True appelle _enrich_with_rag (mocké)."""
from src.medical.cim10_extractor import extract_medical_info
parsed = {
"type": "crh",
"patient": {"sexe": "M"},
"sejour": {},
"diagnostics": [],
}
text = "Pancréatite aiguë biliaire.\nTTT de sortie :\nParacétamol\n\nDevenir : retour."
with patch("src.medical.cim10_extractor._enrich_with_rag") as mock_enrich:
dossier = extract_medical_info(parsed, text, use_rag=True)
mock_enrich.assert_called_once_with(dossier)
def test_use_rag_default_false(self):
"""Par défaut, use_rag=False."""
from src.medical.cim10_extractor import extract_medical_info
parsed = {
"type": "crh",
"patient": {"sexe": "M"},
"sejour": {},
"diagnostics": [],
}
text = "Test simple."
with patch("src.medical.cim10_extractor._enrich_with_rag") as mock_enrich:
extract_medical_info(parsed, text)
mock_enrich.assert_not_called()
class TestChunkingCIM10:
@pytest.mark.skipif(
not CIM10_PDF.exists(),
reason=f"PDF CIM-10 non trouvé : {CIM10_PDF}",
)
def test_chunks_contain_known_codes(self):
from src.medical.rag_index import _chunk_cim10
chunks = _chunk_cim10(CIM10_PDF)
assert len(chunks) > 100, f"Trop peu de chunks : {len(chunks)}"
codes = {c.code for c in chunks if c.code}
# Codes parents 3-char
assert "K85" in codes, "K85 (pancréatite) non trouvé"
assert "K80" in codes, "K80 (lithiase biliaire) non trouvé"
assert "E66" in codes, "E66 (obésité) non trouvé"
@pytest.mark.skipif(
not CIM10_PDF.exists(),
reason=f"PDF CIM-10 non trouvé : {CIM10_PDF}",
)
def test_double_chunking_subcodes(self):
"""Le double chunking produit des chunks sous-codes (X99.9) en plus des parents."""
from src.medical.rag_index import _chunk_cim10
chunks = _chunk_cim10(CIM10_PDF)
codes = {c.code for c in chunks if c.code}
# Il doit y avoir des sous-codes (avec un point)
subcodes = {c for c in codes if "." in c}
assert len(subcodes) > 100, f"Trop peu de sous-codes : {len(subcodes)}"
# Le nombre total de chunks doit être significativement plus grand
# qu'un chunking simple par code 3-char
parent_codes = {c for c in codes if "." not in c}
assert len(chunks) > len(parent_codes) * 2, \
f"Double chunking inefficace : {len(chunks)} chunks pour {len(parent_codes)} codes parents"
@pytest.mark.skipif(
not CIM10_PDF.exists(),
reason=f"PDF CIM-10 non trouvé : {CIM10_PDF}",
)
def test_chunk_content(self):
from src.medical.rag_index import _chunk_cim10
chunks = _chunk_cim10(CIM10_PDF)
k85_chunks = [c for c in chunks if c.code and c.code.startswith("K85")]
assert len(k85_chunks) >= 2, "Il devrait y avoir au moins un chunk parent K85 + des sous-codes"
texts_lower = " ".join(c.text.lower() for c in k85_chunks)
assert "pancréatite" in texts_lower or "pancreatite" in texts_lower
class TestChunkingGuideMethodo:
@pytest.mark.skipif(
not GUIDE_METHODO_PDF.exists(),
reason=f"PDF Guide Métho non trouvé : {GUIDE_METHODO_PDF}",
)
def test_chunks_extracted(self):
from src.medical.rag_index import _chunk_guide_methodo
chunks = _chunk_guide_methodo(GUIDE_METHODO_PDF)
assert len(chunks) >= 10, f"Trop peu de chunks : {len(chunks)}"
assert all(c.document == "guide_methodo" for c in chunks)
class TestChunkingCCAM:
@pytest.mark.skipif(
not CCAM_PDF.exists(),
reason=f"PDF CCAM non trouvé : {CCAM_PDF}",
)
def test_chunks_extracted(self):
from src.medical.rag_index import _chunk_ccam
chunks = _chunk_ccam(CCAM_PDF)
assert len(chunks) >= 1, f"Aucun chunk CCAM extrait"
assert all(c.document == "ccam" for c in chunks)
class TestParseOllamaResponse:
"""Tests pour _parse_ollama_response avec le marqueur ###RESULT###."""
def test_parse_with_marker(self):
from src.medical.rag_search import _parse_ollama_response
raw = """1. ANALYSE CLINIQUE : La pancréatite aiguë biliaire est une inflammation...
2. CODES CANDIDATS : K85.0, K85.1, K85.9
3. DISCRIMINATION : K85.1 est spécifique à l'origine biliaire
4. RÈGLE PMSI : Conforme pour un DP
###RESULT###
{"code": "K85.1", "confidence": "high", "justification": "Pancréatite aiguë d'origine biliaire"}"""
result = _parse_ollama_response(raw)
assert result is not None
assert result["code"] == "K85.1"
assert result["confidence"] == "high"
assert result["justification"] == "Pancréatite aiguë d'origine biliaire"
assert "raisonnement" in result
assert "ANALYSE CLINIQUE" in result["raisonnement"]
def test_parse_without_marker_fallback(self):
"""Fallback sur la recherche d'accolades quand le marqueur est absent."""
from src.medical.rag_search import _parse_ollama_response
raw = """Voici mon analyse...
{"code": "E66.0", "confidence": "medium", "justification": "Obésité due à un excès calorique"}"""
result = _parse_ollama_response(raw)
assert result is not None
assert result["code"] == "E66.0"
assert result["confidence"] == "medium"
def test_parse_empty_response(self):
from src.medical.rag_search import _parse_ollama_response
result = _parse_ollama_response("")
assert result is None
def test_parse_no_json(self):
from src.medical.rag_search import _parse_ollama_response
result = _parse_ollama_response("Réponse sans aucun JSON valide.")
assert result is None
def test_parse_invalid_json(self):
from src.medical.rag_search import _parse_ollama_response
raw = """###RESULT###
{code: K85.1, invalid json}"""
result = _parse_ollama_response(raw)
assert result is None
def test_parse_marker_with_raisonnement_containing_braces(self):
"""Le raisonnement peut contenir des accolades (ex: listes, exemples)."""
from src.medical.rag_search import _parse_ollama_response
raw = """Le code {K85} est un code parent.
Sous-codes : {K85.0, K85.1, K85.2, K85.3}
###RESULT###
{"code": "K85.1", "confidence": "high", "justification": "Biliaire confirmé"}"""
result = _parse_ollama_response(raw)
assert result is not None
assert result["code"] == "K85.1"
assert "raisonnement" in result
assert "{K85}" in result["raisonnement"]
class TestBuildPrompt:
"""Tests pour le nouveau _build_prompt avec raisonnement structuré."""
def test_prompt_contains_diagnostic(self):
from src.medical.rag_search import _build_prompt
sources = [{"document": "cim10", "code": "K85", "page": 1, "extrait": "K85 Pancréatite"}]
contexte = {"sexe": "F", "age": 43}
prompt = _build_prompt("Pancréatite aiguë biliaire", sources, contexte, est_dp=True)
assert "Pancréatite aiguë biliaire" in prompt
assert "DP (diagnostic principal)" in prompt
assert "ANALYSE CLINIQUE" in prompt
assert "###RESULT###" in prompt
def test_prompt_das_type(self):
from src.medical.rag_search import _build_prompt
sources = [{"document": "cim10", "code": "E66", "page": 1, "extrait": "E66 Obésité"}]
contexte = {"sexe": "F", "age": 43}
prompt = _build_prompt("Obésité", sources, contexte, est_dp=False)
assert "DAS (diagnostic associé significatif)" in prompt
def test_prompt_enriched_context(self):
from src.medical.rag_search import _build_prompt
sources = [{"document": "cim10", "code": "K85", "page": 1, "extrait": "K85"}]
contexte = {
"sexe": "F",
"age": 43,
"imc": 34.4,
"duree_sejour": 6,
"antecedents": ["HTA", "diabète type 2"],
"biologie_cle": [("Lipasémie", "850", True), ("CRP", "45", True)],
"imagerie": [("TDM abdominal", "pancréatite stade C Balthazar")],
"complications": ["éruption cutanée"],
"dp_texte": "Pancréatite aiguë biliaire",
}
prompt = _build_prompt("Éruption cutanée", sources, contexte, est_dp=False)
assert "IMC 34.4" in prompt
assert "6 jours" in prompt
assert "HTA" in prompt
assert "Lipasémie" in prompt
assert "TDM abdominal" in prompt
assert "éruption cutanée" in prompt
assert "Pancréatite aiguë biliaire" in prompt
class TestSearchSimilar:
"""Tests pour search_similar avec score minimum et priorisation CIM-10."""
def test_filters_low_scores(self):
"""Les résultats avec score < 0.3 sont éliminés."""
from src.medical.rag_search import search_similar
import numpy as np
mock_metadata = [
{"document": "cim10", "code": "K85", "page": 1, "extrait": "K85"},
{"document": "cim10", "code": "K86", "page": 2, "extrait": "K86"},
]
mock_index = MagicMock()
mock_index.ntotal = 2
# Premier résultat score=0.9 (bon), second score=0.1 (sous le seuil)
mock_index.search.return_value = (
np.array([[0.9, 0.1]], dtype=np.float32),
np.array([[0, 1]], dtype=np.int64),
)
with patch("src.medical.rag_index.get_index", return_value=(mock_index, mock_metadata)), \
patch("src.medical.rag_search._get_embed_model") as mock_model:
mock_model.return_value.encode.return_value = np.array([[0.1] * 768], dtype=np.float32)
results = search_similar("pancréatite")
assert len(results) == 1
assert results[0]["code"] == "K85"
def test_prioritizes_cim10(self):
"""Les sources CIM-10 sont priorisées (au moins 6 sur 10)."""
from src.medical.rag_search import search_similar
import numpy as np
# 8 sources CIM-10 + 8 sources guide_methodo, toutes avec bon score
mock_metadata = []
for i in range(8):
mock_metadata.append({"document": "cim10", "code": f"K8{i}", "page": i, "extrait": f"K8{i}"})
for i in range(8):
mock_metadata.append({"document": "guide_methodo", "page": i + 10, "extrait": f"Guide {i}"})
mock_index = MagicMock()
mock_index.ntotal = 16
scores = np.array([[0.9 - i * 0.03 for i in range(16)]], dtype=np.float32)
indices = np.array([list(range(16))], dtype=np.int64)
mock_index.search.return_value = (scores, indices)
with patch("src.medical.rag_index.get_index", return_value=(mock_index, mock_metadata)), \
patch("src.medical.rag_search._get_embed_model") as mock_model:
mock_model.return_value.encode.return_value = np.array([[0.1] * 768], dtype=np.float32)
results = search_similar("pancréatite", top_k=10)
cim10_count = sum(1 for r in results if r["document"] == "cim10")
assert cim10_count >= 6, f"Seulement {cim10_count} sources CIM-10 sur {len(results)}"
class TestRAGSearchMocked:
def test_search_similar_no_index(self):
"""search_similar retourne une liste vide si l'index n'existe pas."""
from src.medical.rag_search import search_similar
with patch("src.medical.rag_index.get_index", return_value=None):
results = search_similar("pancréatite aiguë")
assert results == []
def test_enrich_diagnostic_no_sources(self):
"""enrich_diagnostic ne plante pas si aucune source trouvée."""
from src.medical.rag_search import enrich_diagnostic
diag = Diagnostic(texte="test quelconque", cim10_suggestion="Z99.9")
with patch("src.medical.rag_search.search_similar", return_value=[]):
enrich_diagnostic(diag, {"sexe": "M", "age": 50})
assert diag.sources_rag == []
assert diag.justification is None
assert diag.raisonnement is None
def test_enrich_diagnostic_with_sources_no_ollama(self):
"""Enrichissement avec sources FAISS mais sans Ollama."""
from src.medical.rag_search import enrich_diagnostic
diag = Diagnostic(texte="Pancréatite aiguë", cim10_suggestion="K85.9")
mock_sources = [
{
"document": "cim10",
"page": 496,
"code": "K85",
"extrait": "K85 Pancréatite aiguë...",
"score": 0.92,
},
]
with patch("src.medical.rag_search.search_similar", return_value=mock_sources), \
patch("src.medical.rag_search._call_ollama", return_value=None):
enrich_diagnostic(diag, {"sexe": "M", "age": 50})
assert len(diag.sources_rag) == 1
assert diag.sources_rag[0].document == "cim10"
assert diag.sources_rag[0].code == "K85"
assert diag.justification is None
assert diag.raisonnement is None
def test_enrich_diagnostic_with_ollama(self):
"""Enrichissement complet avec sources + Ollama + raisonnement."""
from src.medical.rag_search import enrich_diagnostic
diag = Diagnostic(texte="Pancréatite aiguë biliaire")
mock_sources = [
{
"document": "cim10",
"page": 496,
"code": "K85",
"extrait": "K85 Pancréatite aiguë...",
"score": 0.95,
},
]
mock_llm = {
"code": "K85.1",
"confidence": "high",
"justification": "Pancréatite aiguë d'origine biliaire = K85.1",
"raisonnement": "1. ANALYSE CLINIQUE : La pancréatite...",
}
with patch("src.medical.rag_search.search_similar", return_value=mock_sources), \
patch("src.medical.rag_search._call_ollama", return_value=mock_llm):
enrich_diagnostic(diag, {"sexe": "F", "age": 43})
assert diag.cim10_suggestion == "K85.1"
assert diag.cim10_confidence == "high"
assert diag.justification == "Pancréatite aiguë d'origine biliaire = K85.1"
assert diag.raisonnement == "1. ANALYSE CLINIQUE : La pancréatite..."
assert len(diag.sources_rag) == 1
def test_enrich_diagnostic_est_dp_flag(self):
"""Le flag est_dp est bien passé à _build_prompt."""
from src.medical.rag_search import enrich_diagnostic
diag = Diagnostic(texte="Obésité")
mock_sources = [
{"document": "cim10", "page": 1, "code": "E66", "extrait": "E66 Obésité", "score": 0.9},
]
with patch("src.medical.rag_search.search_similar", return_value=mock_sources), \
patch("src.medical.rag_search._call_ollama", return_value=None) as mock_ollama, \
patch("src.medical.rag_search._build_prompt", return_value="prompt") as mock_prompt:
enrich_diagnostic(diag, {"sexe": "F", "age": 43}, est_dp=False)
mock_prompt.assert_called_once_with("Obésité", mock_sources, {"sexe": "F", "age": 43}, est_dp=False)
class TestEnrichDossier:
"""Tests pour enrich_dossier avec le contexte enrichi."""
def test_enriched_context(self):
"""enrich_dossier passe le contexte enrichi (bio, imagerie, etc.)."""
from src.medical.rag_search import enrich_dossier
from src.config import Sejour, BiologieCle, Imagerie
dossier = DossierMedical(
sejour=Sejour(sexe="F", age=43, duree_sejour=6, imc=34.4),
diagnostic_principal=Diagnostic(texte="Pancréatite aiguë biliaire"),
antecedents=["HTA", "diabète type 2"],
biologie_cle=[
BiologieCle(test="Lipasémie", valeur="850", anomalie=True),
],
imagerie=[
Imagerie(type="TDM abdominal", conclusion="pancréatite stade C"),
],
complications=["éruption cutanée"],
)
captured_contexts = []
def mock_enrich(diag, contexte, est_dp=True):
captured_contexts.append(contexte.copy())
with patch("src.medical.rag_search.enrich_diagnostic", side_effect=mock_enrich):
enrich_dossier(dossier)
assert len(captured_contexts) == 1 # DP seulement (pas de DAS)
ctx = captured_contexts[0]
assert ctx["sexe"] == "F"
assert ctx["age"] == 43
assert ctx["duree_sejour"] == 6
assert ctx["imc"] == 34.4
assert ctx["antecedents"] == ["HTA", "diabète type 2"]
assert ctx["biologie_cle"] == [("Lipasémie", "850", True)]
assert ctx["imagerie"] == [("TDM abdominal", "pancréatite stade C")]
assert ctx["complications"] == ["éruption cutanée"]
def test_das_gets_dp_context(self):
"""Les DAS reçoivent le texte du DP dans leur contexte."""
from src.medical.rag_search import enrich_dossier
dossier = DossierMedical(
diagnostic_principal=Diagnostic(texte="Pancréatite aiguë biliaire"),
diagnostics_associes=[
Diagnostic(texte="Obésité"),
],
)
captured = []
def mock_enrich(diag, contexte, est_dp=True):
captured.append({"texte": diag.texte, "est_dp": est_dp, "dp_texte": contexte.get("dp_texte")})
with patch("src.medical.rag_search.enrich_diagnostic", side_effect=mock_enrich):
enrich_dossier(dossier)
assert len(captured) == 2
# DP n'a pas dp_texte dans son contexte
assert captured[0]["est_dp"] is True
assert captured[0]["dp_texte"] is None
# DAS a dp_texte
assert captured[1]["est_dp"] is False
assert captured[1]["dp_texte"] == "Pancréatite aiguë biliaire"
class TestFormatContexte:
"""Tests pour _format_contexte."""
def test_minimal_context(self):
from src.medical.rag_search import _format_contexte
result = _format_contexte({})
assert result == "Non précisé"
def test_full_context(self):
from src.medical.rag_search import _format_contexte
ctx = {
"sexe": "F",
"age": 43,
"imc": 34.4,
"duree_sejour": 6,
"antecedents": ["HTA", "diabète type 2"],
"biologie_cle": [("Lipasémie", "850", True), ("CRP", "45", True)],
"imagerie": [("TDM abdominal", "pancréatite stade C Balthazar")],
"complications": ["éruption cutanée"],
"dp_texte": "Pancréatite aiguë biliaire",
}
result = _format_contexte(ctx)
assert "F, 43 ans, IMC 34.4" in result
assert "6 jours" in result
assert "HTA" in result
assert "Lipasémie 850" in result
assert "TDM abdominal" in result
assert "éruption cutanée" in result
assert "Pancréatite aiguë biliaire" in result