feat: RAG CPAM dédié avec requêtes multi-ciblées + prompt 3 axes
- Nouvelle search_similar_cpam() : priorité Guide Méthodo, seuil 0.40, déduplication par code CIM-10, fetch élargi top_k*3 - _search_rag_for_control() refactoré : 2-3 requêtes ciblées (codes contestés, argument CPAM, contexte clinique) au lieu d'1 fourre-tout - Fusion dédupliquée par (document, code, page), top 10 résultats - Prompt CPAM enrichi : 3 axes (médical, asymétrie, réglementaire), section asymétrie d'information, format réponse structuré - 9 nouveaux tests unitaires pour la logique RAG multi-requêtes Élimine les sources CIM-10 hors-sujet (F45, F98.1, F50.5 sur pancréatite) au profit de résultats Guide Méthodo et référentiels pertinents. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,35 +11,75 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
def _search_rag_for_control(controle: ControleCPAM, dossier: DossierMedical) -> list[dict]:
|
def _search_rag_for_control(controle: ControleCPAM, dossier: DossierMedical) -> list[dict]:
|
||||||
"""Recherche RAG ciblée pour le sujet du désaccord."""
|
"""Recherche RAG ciblée pour le sujet du désaccord.
|
||||||
|
|
||||||
|
Effectue 2-3 recherches ciblées au lieu d'une requête fourre-tout :
|
||||||
|
1. Codes contestés → règles de codage spécifiques
|
||||||
|
2. Argument CPAM → passages Guide Méthodo contradictoires
|
||||||
|
3. Contexte clinique (optionnel) → définitions CIM-10 des codes en jeu
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
from ..medical.rag_search import search_similar
|
from ..medical.rag_search import search_similar_cpam
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Index RAG non disponible pour la contre-argumentation")
|
logger.warning("Index RAG non disponible pour la contre-argumentation")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Construire une requête combinant l'argument CPAM et le diagnostic concerné
|
all_results: list[dict] = []
|
||||||
|
|
||||||
|
# Requête 1 — Codes contestés (règles de codage)
|
||||||
|
if controle.dp_ucr or controle.da_ucr:
|
||||||
query_parts = []
|
query_parts = []
|
||||||
|
|
||||||
if controle.titre:
|
|
||||||
query_parts.append(controle.titre)
|
|
||||||
|
|
||||||
# Ajouter les codes contestés pour cibler la recherche
|
|
||||||
if controle.dp_ucr:
|
if controle.dp_ucr:
|
||||||
query_parts.append(f"diagnostic principal {controle.dp_ucr}")
|
query_parts.append(f"règles codage {controle.dp_ucr} diagnostic principal")
|
||||||
if controle.da_ucr:
|
if controle.da_ucr:
|
||||||
query_parts.append(f"diagnostic associé {controle.da_ucr}")
|
query_parts.append(f"diagnostic associé significatif {controle.da_ucr} CMA")
|
||||||
|
query_codes = " ".join(query_parts)
|
||||||
|
results_codes = search_similar_cpam(query_codes, top_k=6)
|
||||||
|
logger.debug(" RAG requête codes : %d résultats", len(results_codes))
|
||||||
|
all_results.extend(results_codes)
|
||||||
|
|
||||||
# Tronquer l'argument CPAM pour ne garder que le coeur
|
# Requête 2 — Argument CPAM (recherche dans le Guide Méthodo)
|
||||||
arg_short = controle.arg_ucr[:300] if controle.arg_ucr else ""
|
query_parts_arg = []
|
||||||
|
if controle.titre:
|
||||||
|
query_parts_arg.append(controle.titre)
|
||||||
|
arg_short = controle.arg_ucr[:200] if controle.arg_ucr else ""
|
||||||
if arg_short:
|
if arg_short:
|
||||||
query_parts.append(arg_short)
|
query_parts_arg.append(arg_short)
|
||||||
|
query_arg = " ".join(query_parts_arg)
|
||||||
|
if query_arg.strip():
|
||||||
|
results_arg = search_similar_cpam(query_arg, top_k=6)
|
||||||
|
logger.debug(" RAG requête argument : %d résultats", len(results_arg))
|
||||||
|
all_results.extend(results_arg)
|
||||||
|
|
||||||
query = " ".join(query_parts)
|
# Requête 3 — Contexte clinique (définitions CIM-10 des codes en jeu)
|
||||||
if not query.strip():
|
if controle.da_ucr and dossier.diagnostic_principal:
|
||||||
|
dp_text = dossier.diagnostic_principal.texte
|
||||||
|
das_texts = [
|
||||||
|
d.texte for d in dossier.diagnostics_associes
|
||||||
|
if d.cim10_suggestion and controle.da_ucr
|
||||||
|
and d.cim10_suggestion in controle.da_ucr
|
||||||
|
]
|
||||||
|
if das_texts:
|
||||||
|
query_clinique = f"{dp_text} {' '.join(das_texts)}"
|
||||||
|
results_clinique = search_similar_cpam(query_clinique, top_k=4)
|
||||||
|
logger.debug(" RAG requête clinique : %d résultats", len(results_clinique))
|
||||||
|
all_results.extend(results_clinique)
|
||||||
|
|
||||||
|
if not all_results:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
return search_similar(query, top_k=8)
|
# Fusion : dédupliquer par (document, code, page), garder le meilleur score
|
||||||
|
seen: dict[tuple, dict] = {}
|
||||||
|
for r in all_results:
|
||||||
|
key = (r.get("document"), r.get("code"), r.get("page"))
|
||||||
|
if key in seen:
|
||||||
|
if r["score"] > seen[key]["score"]:
|
||||||
|
seen[key] = r
|
||||||
|
else:
|
||||||
|
seen[key] = r
|
||||||
|
|
||||||
|
merged = sorted(seen.values(), key=lambda r: r["score"], reverse=True)
|
||||||
|
return merged[:10]
|
||||||
|
|
||||||
|
|
||||||
def _build_cpam_prompt(
|
def _build_cpam_prompt(
|
||||||
@@ -78,17 +118,80 @@ def _build_cpam_prompt(
|
|||||||
if sejour.age is not None:
|
if sejour.age is not None:
|
||||||
patient_info.append(f"{sejour.age} ans")
|
patient_info.append(f"{sejour.age} ans")
|
||||||
dossier_lines.append(f"- Patient : {', '.join(patient_info)}")
|
dossier_lines.append(f"- Patient : {', '.join(patient_info)}")
|
||||||
|
if sejour.imc is not None:
|
||||||
|
dossier_lines.append(f"- IMC : {sejour.imc}")
|
||||||
|
|
||||||
if dossier.biologie_cle:
|
if dossier.biologie_cle:
|
||||||
bio = [f"{b.test}: {b.valeur}" for b in dossier.biologie_cle[:5] if b.valeur]
|
bio = [f"{b.test}: {b.valeur}" for b in dossier.biologie_cle[:5] if b.valeur]
|
||||||
if bio:
|
if bio:
|
||||||
dossier_lines.append(f"- Biologie clé : {', '.join(bio)}")
|
dossier_lines.append(f"- Biologie clé : {', '.join(bio)}")
|
||||||
|
|
||||||
|
if dossier.imagerie:
|
||||||
|
img_parts = []
|
||||||
|
for im in dossier.imagerie:
|
||||||
|
conclusion = f" — {im.conclusion}" if im.conclusion else ""
|
||||||
|
img_parts.append(f"{im.type}{conclusion}")
|
||||||
|
dossier_lines.append(f"- Imagerie : {', '.join(img_parts)}")
|
||||||
|
|
||||||
|
if dossier.traitements_sortie:
|
||||||
|
trt_parts = []
|
||||||
|
for t in dossier.traitements_sortie[:10]:
|
||||||
|
posologie = f" {t.posologie}" if t.posologie else ""
|
||||||
|
trt_parts.append(f"{t.medicament}{posologie}")
|
||||||
|
dossier_lines.append(f"- Traitements de sortie : {', '.join(trt_parts)}")
|
||||||
|
|
||||||
|
if dossier.antecedents:
|
||||||
|
dossier_lines.append(f"- Antécédents : {', '.join(dossier.antecedents[:10])}")
|
||||||
|
|
||||||
if dossier.complications:
|
if dossier.complications:
|
||||||
dossier_lines.append(f"- Complications : {', '.join(dossier.complications)}")
|
dossier_lines.append(f"- Complications : {', '.join(dossier.complications)}")
|
||||||
|
|
||||||
dossier_str = "\n".join(dossier_lines) if dossier_lines else "Non disponible"
|
dossier_str = "\n".join(dossier_lines) if dossier_lines else "Non disponible"
|
||||||
|
|
||||||
|
# Section asymétrie : éléments que la CPAM n'avait pas
|
||||||
|
asymetrie_lines = []
|
||||||
|
|
||||||
|
if dossier.biologie_cle:
|
||||||
|
bio_details = []
|
||||||
|
for b in dossier.biologie_cle if len(dossier.biologie_cle) <= 10 else dossier.biologie_cle[:10]:
|
||||||
|
anomalie = " (anormale)" if b.anomalie else ""
|
||||||
|
if b.valeur:
|
||||||
|
bio_details.append(f"{b.test}: {b.valeur}{anomalie}")
|
||||||
|
if bio_details:
|
||||||
|
asymetrie_lines.append(f"- Biologie : {', '.join(bio_details)}")
|
||||||
|
|
||||||
|
if dossier.imagerie:
|
||||||
|
img_details = []
|
||||||
|
for im in dossier.imagerie:
|
||||||
|
conclusion = f" — {im.conclusion}" if im.conclusion else ""
|
||||||
|
img_details.append(f"{im.type}{conclusion}")
|
||||||
|
if img_details:
|
||||||
|
asymetrie_lines.append(f"- Imagerie : {', '.join(img_details)}")
|
||||||
|
|
||||||
|
if dossier.traitements_sortie:
|
||||||
|
trt_details = []
|
||||||
|
for t in dossier.traitements_sortie[:10]:
|
||||||
|
posologie = f" {t.posologie}" if t.posologie else ""
|
||||||
|
trt_details.append(f"{t.medicament}{posologie}")
|
||||||
|
if trt_details:
|
||||||
|
asymetrie_lines.append(f"- Traitements : {', '.join(trt_details)}")
|
||||||
|
|
||||||
|
if dossier.actes_ccam:
|
||||||
|
actes_details = [
|
||||||
|
f"{a.texte} ({a.code_ccam_suggestion})" if a.code_ccam_suggestion else a.texte
|
||||||
|
for a in dossier.actes_ccam
|
||||||
|
]
|
||||||
|
if actes_details:
|
||||||
|
asymetrie_lines.append(f"- Actes CCAM : {', '.join(actes_details)}")
|
||||||
|
|
||||||
|
asymetrie_str = ""
|
||||||
|
if asymetrie_lines:
|
||||||
|
asymetrie_str = (
|
||||||
|
"\n\nÉLÉMENTS DU DOSSIER NON TRANSMIS À LA CPAM "
|
||||||
|
"(l'UCR n'a eu que le CRH et les codes) :\n"
|
||||||
|
+ "\n".join(asymetrie_lines)
|
||||||
|
)
|
||||||
|
|
||||||
# Codes contestés par la CPAM
|
# Codes contestés par la CPAM
|
||||||
codes_contestes = []
|
codes_contestes = []
|
||||||
if controle.dp_ucr:
|
if controle.dp_ucr:
|
||||||
@@ -118,10 +221,11 @@ def _build_cpam_prompt(
|
|||||||
sources_text += (src.get("extrait", "")[:800]) + "\n\n"
|
sources_text += (src.get("extrait", "")[:800]) + "\n\n"
|
||||||
|
|
||||||
return f"""Tu es un médecin DIM (Département d'Information Médicale) expert en contentieux T2A.
|
return f"""Tu es un médecin DIM (Département d'Information Médicale) expert en contentieux T2A.
|
||||||
Tu dois contre-argumenter la décision de la CPAM (UCR) point par point, en t'appuyant sur le guide méthodologique et la CIM-10.
|
Tu dois contre-argumenter la décision de la CPAM (UCR) en mobilisant trois axes : médical, asymétrie d'information, et réglementaire.
|
||||||
|
|
||||||
DOSSIER MÉDICAL DE L'ÉTABLISSEMENT :
|
DOSSIER MÉDICAL DE L'ÉTABLISSEMENT :
|
||||||
{dossier_str}
|
{dossier_str}
|
||||||
|
{asymetrie_str}
|
||||||
|
|
||||||
OBJET DU DÉSACCORD : {controle.titre}
|
OBJET DU DÉSACCORD : {controle.titre}
|
||||||
|
|
||||||
@@ -137,18 +241,31 @@ SOURCES RÉGLEMENTAIRES (Guide méthodologique, CIM-10) :
|
|||||||
{sources_text}
|
{sources_text}
|
||||||
|
|
||||||
CONSIGNES :
|
CONSIGNES :
|
||||||
- Analyse objectivement l'argument de la CPAM
|
|
||||||
- Identifie les points où la CPAM a raison (le cas échéant)
|
AXE MÉDICAL :
|
||||||
- Contre-argumente point par point en citant le guide méthodologique et la CIM-10
|
- Analyse le bien-fondé médical du codage de l'établissement
|
||||||
- Cite les références précises (pages, articles, fascicules)
|
- Confronte l'argumentation CPAM aux sources CIM-10 et Guide Méthodologique fournies
|
||||||
- Propose une conclusion et la position recommandée
|
- Identifie les points où la CPAM a éventuellement raison
|
||||||
|
|
||||||
|
AXE ASYMÉTRIE D'INFORMATION :
|
||||||
|
- La CPAM a fondé son analyse uniquement sur le CRH et les codes transmis
|
||||||
|
- Démontre en quoi les éléments cliniques complémentaires (biologie, imagerie, traitements, actes) justifient le codage contesté
|
||||||
|
- Pour chaque élément clinique pertinent, explique pourquoi il invalide ou nuance l'argumentation CPAM
|
||||||
|
|
||||||
|
AXE RÉGLEMENTAIRE :
|
||||||
|
- Identifie si l'UCR fait une interprétation restrictive non fondée d'une règle
|
||||||
|
- Confronte le raisonnement CPAM au texte EXACT des sources fournies
|
||||||
|
- Relève les contradictions entre l'argumentation CPAM et les règles officielles
|
||||||
|
- NE CITE AUCUNE référence qui ne figure pas dans les sources fournies
|
||||||
|
|
||||||
Réponds UNIQUEMENT avec un objet JSON au format suivant :
|
Réponds UNIQUEMENT avec un objet JSON au format suivant :
|
||||||
{{
|
{{
|
||||||
"analyse_contestation": "Résumé de ce que conteste la CPAM",
|
"analyse_contestation": "Résumé de ce que conteste la CPAM et sur quelle base",
|
||||||
"points_accord": "Points où la CPAM a raison (ou 'Aucun' si non applicable)",
|
"points_accord": "Points où la CPAM a raison (ou 'Aucun')",
|
||||||
"contre_arguments": "Arguments point par point en faveur de l'établissement",
|
"contre_arguments_medicaux": "Argumentation médicale en faveur du codage",
|
||||||
"references": "Références guide méthodologique / CIM-10 citées",
|
"contre_arguments_asymetrie": "Éléments cliniques que la CPAM n'avait pas et qui justifient le codage",
|
||||||
|
"contre_arguments_reglementaires": "Erreurs d'interprétation réglementaire de la CPAM, avec citations des sources",
|
||||||
|
"references": "Références EXACTES tirées des sources fournies (document, page, code)",
|
||||||
"conclusion": "Synthèse et position recommandée"
|
"conclusion": "Synthèse et position recommandée"
|
||||||
}}"""
|
}}"""
|
||||||
|
|
||||||
@@ -165,6 +282,21 @@ def _format_response(parsed: dict) -> str:
|
|||||||
if accord and accord.lower() not in ("aucun", "non applicable", "n/a", ""):
|
if accord and accord.lower() not in ("aucun", "non applicable", "n/a", ""):
|
||||||
sections.append(f"POINTS D'ACCORD\n{accord}")
|
sections.append(f"POINTS D'ACCORD\n{accord}")
|
||||||
|
|
||||||
|
# Nouveaux champs structurés par axe
|
||||||
|
contre_med = parsed.get("contre_arguments_medicaux")
|
||||||
|
if contre_med:
|
||||||
|
sections.append(f"CONTRE-ARGUMENTS MÉDICAUX\n{contre_med}")
|
||||||
|
|
||||||
|
contre_asym = parsed.get("contre_arguments_asymetrie")
|
||||||
|
if contre_asym:
|
||||||
|
sections.append(f"ASYMÉTRIE D'INFORMATION\n{contre_asym}")
|
||||||
|
|
||||||
|
contre_regl = parsed.get("contre_arguments_reglementaires")
|
||||||
|
if contre_regl:
|
||||||
|
sections.append(f"CONTRE-ARGUMENTS RÉGLEMENTAIRES\n{contre_regl}")
|
||||||
|
|
||||||
|
# Fallback : ancien champ unique (réponses en cache existantes)
|
||||||
|
if not contre_med and not contre_asym and not contre_regl:
|
||||||
contre = parsed.get("contre_arguments")
|
contre = parsed.get("contre_arguments")
|
||||||
if contre:
|
if contre:
|
||||||
sections.append(f"CONTRE-ARGUMENTS\n{contre}")
|
sections.append(f"CONTRE-ARGUMENTS\n{contre}")
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ _embed_model = None
|
|||||||
|
|
||||||
# Score minimum de similarité FAISS pour retenir un résultat
|
# Score minimum de similarité FAISS pour retenir un résultat
|
||||||
_MIN_SCORE = 0.3
|
_MIN_SCORE = 0.3
|
||||||
|
# Seuil rehaussé pour le contexte CPAM (filtrage plus agressif du bruit)
|
||||||
|
_MIN_SCORE_CPAM = 0.40
|
||||||
|
|
||||||
|
|
||||||
def _get_embed_model():
|
def _get_embed_model():
|
||||||
@@ -149,6 +151,73 @@ def search_similar_ccam(query: str, top_k: int = 8) -> list[dict]:
|
|||||||
return final
|
return final
|
||||||
|
|
||||||
|
|
||||||
|
def search_similar_cpam(query: str, top_k: int = 8) -> list[dict]:
|
||||||
|
"""Recherche RAG spécifique au contexte CPAM (contre-argumentation).
|
||||||
|
|
||||||
|
Différences avec search_similar() :
|
||||||
|
- Priorité Guide Méthodologique (min 3 résultats) plutôt que CIM-10
|
||||||
|
- Seuil de score rehaussé (0.40 vs 0.30) pour éliminer le bruit
|
||||||
|
- Fetch élargi (top_k * 3) car filtrage plus agressif
|
||||||
|
- Déduplication par code CIM-10 (garde le meilleur score par code)
|
||||||
|
"""
|
||||||
|
from .rag_index import get_index
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
result = get_index()
|
||||||
|
if result is None:
|
||||||
|
logger.warning("Index FAISS non disponible")
|
||||||
|
return []
|
||||||
|
|
||||||
|
faiss_index, metadata = result
|
||||||
|
|
||||||
|
model = _get_embed_model()
|
||||||
|
query_vec = model.encode([query], normalize_embeddings=True)
|
||||||
|
query_vec = np.array(query_vec, dtype=np.float32)
|
||||||
|
|
||||||
|
# Fetch élargi pour compenser le filtrage agressif
|
||||||
|
fetch_k = min(top_k * 3, faiss_index.ntotal)
|
||||||
|
scores, indices = faiss_index.search(query_vec, fetch_k)
|
||||||
|
|
||||||
|
raw_results = []
|
||||||
|
for score, idx in zip(scores[0], indices[0]):
|
||||||
|
if idx < 0:
|
||||||
|
continue
|
||||||
|
if float(score) < _MIN_SCORE_CPAM:
|
||||||
|
continue
|
||||||
|
meta = metadata[idx].copy()
|
||||||
|
meta["score"] = float(score)
|
||||||
|
raw_results.append(meta)
|
||||||
|
|
||||||
|
# Dédupliquer par code CIM-10 (garder meilleur score par code)
|
||||||
|
seen_codes: dict[str, dict] = {}
|
||||||
|
deduped = []
|
||||||
|
for r in raw_results:
|
||||||
|
code = r.get("code")
|
||||||
|
if code:
|
||||||
|
if code in seen_codes:
|
||||||
|
if r["score"] > seen_codes[code]["score"]:
|
||||||
|
seen_codes[code] = r
|
||||||
|
else:
|
||||||
|
seen_codes[code] = r
|
||||||
|
else:
|
||||||
|
deduped.append(r) # pas de code → garder (guide_methodo, etc.)
|
||||||
|
deduped.extend(seen_codes.values())
|
||||||
|
deduped.sort(key=lambda r: r["score"], reverse=True)
|
||||||
|
|
||||||
|
# Prioriser le Guide Méthodologique (min 3 résultats)
|
||||||
|
guide_results = [r for r in deduped if r["document"] == "guide_methodo"]
|
||||||
|
other_results = [r for r in deduped if r["document"] != "guide_methodo"]
|
||||||
|
|
||||||
|
min_guide = min(3, len(guide_results))
|
||||||
|
final = guide_results[:min_guide]
|
||||||
|
remaining_slots = top_k - len(final)
|
||||||
|
remaining = guide_results[min_guide:] + other_results
|
||||||
|
remaining.sort(key=lambda r: r["score"], reverse=True)
|
||||||
|
final.extend(remaining[:remaining_slots])
|
||||||
|
|
||||||
|
return final
|
||||||
|
|
||||||
|
|
||||||
def _format_contexte(contexte: dict) -> str:
|
def _format_contexte(contexte: dict) -> str:
|
||||||
"""Formate le contexte patient de manière structurée pour le prompt."""
|
"""Formate le contexte patient de manière structurée pour le prompt."""
|
||||||
lines = []
|
lines = []
|
||||||
|
|||||||
@@ -1,11 +1,26 @@
|
|||||||
"""Tests pour la génération de contre-argumentation CPAM."""
|
"""Tests pour la génération de contre-argumentation CPAM."""
|
||||||
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch, call
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.config import ControleCPAM, Diagnostic, DossierMedical, RAGSource, Sejour
|
from src.config import (
|
||||||
from src.control.cpam_response import _build_cpam_prompt, _format_response, generate_cpam_response
|
ActeCCAM,
|
||||||
|
BiologieCle,
|
||||||
|
ControleCPAM,
|
||||||
|
Diagnostic,
|
||||||
|
DossierMedical,
|
||||||
|
Imagerie,
|
||||||
|
RAGSource,
|
||||||
|
Sejour,
|
||||||
|
Traitement,
|
||||||
|
)
|
||||||
|
from src.control.cpam_response import (
|
||||||
|
_build_cpam_prompt,
|
||||||
|
_format_response,
|
||||||
|
_search_rag_for_control,
|
||||||
|
generate_cpam_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _make_dossier() -> DossierMedical:
|
def _make_dossier() -> DossierMedical:
|
||||||
@@ -24,6 +39,37 @@ def _make_dossier() -> DossierMedical:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_dossier_complet() -> DossierMedical:
|
||||||
|
"""Crée un dossier médical enrichi avec traitements, imagerie, antécédents."""
|
||||||
|
return DossierMedical(
|
||||||
|
source_file="test.pdf",
|
||||||
|
document_type="crh",
|
||||||
|
sejour=Sejour(sexe="M", age=65, duree_sejour=5, imc=31.2),
|
||||||
|
diagnostic_principal=Diagnostic(
|
||||||
|
texte="Cholécystite aiguë",
|
||||||
|
cim10_suggestion="K81.0",
|
||||||
|
),
|
||||||
|
diagnostics_associes=[
|
||||||
|
Diagnostic(texte="Iléus réflexe", cim10_suggestion="K56.0"),
|
||||||
|
],
|
||||||
|
actes_ccam=[
|
||||||
|
ActeCCAM(texte="Cholécystectomie", code_ccam_suggestion="HMFC004"),
|
||||||
|
],
|
||||||
|
biologie_cle=[
|
||||||
|
BiologieCle(test="CRP", valeur="180 mg/L", anomalie=True),
|
||||||
|
BiologieCle(test="Créatinine", valeur="450 µmol/L", anomalie=True),
|
||||||
|
],
|
||||||
|
imagerie=[
|
||||||
|
Imagerie(type="Scanner abdominal", conclusion="Lithiase cholédocienne confirmée"),
|
||||||
|
],
|
||||||
|
traitements_sortie=[
|
||||||
|
Traitement(medicament="Augmentin IV", posologie="3g/j"),
|
||||||
|
Traitement(medicament="Morphine SC"),
|
||||||
|
],
|
||||||
|
antecedents=["HTA", "Diabète type 2"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _make_controle() -> ControleCPAM:
|
def _make_controle() -> ControleCPAM:
|
||||||
"""Crée un contrôle CPAM de test."""
|
"""Crée un contrôle CPAM de test."""
|
||||||
return ControleCPAM(
|
return ControleCPAM(
|
||||||
@@ -75,32 +121,115 @@ class TestBuildPrompt:
|
|||||||
assert "CIM-10 FR 2026" in prompt
|
assert "CIM-10 FR 2026" in prompt
|
||||||
assert "page 64" in prompt
|
assert "page 64" in prompt
|
||||||
|
|
||||||
|
def test_prompt_contains_three_axes(self):
|
||||||
|
dossier = _make_dossier()
|
||||||
|
controle = _make_controle()
|
||||||
|
prompt = _build_cpam_prompt(dossier, controle, [])
|
||||||
|
|
||||||
|
assert "AXE MÉDICAL" in prompt
|
||||||
|
assert "AXE ASYMÉTRIE D'INFORMATION" in prompt
|
||||||
|
assert "AXE RÉGLEMENTAIRE" in prompt
|
||||||
|
|
||||||
|
def test_prompt_contains_traitements_imagerie_when_present(self):
|
||||||
|
dossier = _make_dossier_complet()
|
||||||
|
controle = _make_controle()
|
||||||
|
prompt = _build_cpam_prompt(dossier, controle, [])
|
||||||
|
|
||||||
|
assert "Augmentin IV 3g/j" in prompt
|
||||||
|
assert "Morphine SC" in prompt
|
||||||
|
assert "Scanner abdominal" in prompt
|
||||||
|
assert "Lithiase cholédocienne confirmée" in prompt
|
||||||
|
assert "HTA" in prompt
|
||||||
|
assert "Diabète type 2" in prompt
|
||||||
|
assert "IMC : 31.2" in prompt
|
||||||
|
|
||||||
|
def test_prompt_asymetrie_section_when_data_present(self):
|
||||||
|
dossier = _make_dossier_complet()
|
||||||
|
controle = _make_controle()
|
||||||
|
prompt = _build_cpam_prompt(dossier, controle, [])
|
||||||
|
|
||||||
|
assert "ÉLÉMENTS DU DOSSIER NON TRANSMIS À LA CPAM" in prompt
|
||||||
|
assert "CRP: 180 mg/L (anormale)" in prompt
|
||||||
|
assert "Cholécystectomie (HMFC004)" in prompt
|
||||||
|
|
||||||
|
def test_prompt_no_asymetrie_section_when_no_data(self):
|
||||||
|
dossier = DossierMedical(
|
||||||
|
source_file="test.pdf",
|
||||||
|
document_type="crh",
|
||||||
|
sejour=Sejour(),
|
||||||
|
diagnostic_principal=Diagnostic(texte="Test", cim10_suggestion="Z00"),
|
||||||
|
)
|
||||||
|
controle = _make_controle()
|
||||||
|
prompt = _build_cpam_prompt(dossier, controle, [])
|
||||||
|
|
||||||
|
assert "ÉLÉMENTS DU DOSSIER NON TRANSMIS À LA CPAM" not in prompt
|
||||||
|
|
||||||
|
def test_prompt_json_format_new_fields(self):
|
||||||
|
dossier = _make_dossier()
|
||||||
|
controle = _make_controle()
|
||||||
|
prompt = _build_cpam_prompt(dossier, controle, [])
|
||||||
|
|
||||||
|
assert "contre_arguments_medicaux" in prompt
|
||||||
|
assert "contre_arguments_asymetrie" in prompt
|
||||||
|
assert "contre_arguments_reglementaires" in prompt
|
||||||
|
|
||||||
|
|
||||||
class TestFormatResponse:
|
class TestFormatResponse:
|
||||||
def test_full_response(self):
|
def test_full_response_new_format(self):
|
||||||
parsed = {
|
parsed = {
|
||||||
"analyse_contestation": "La CPAM conteste le DAS K56.0",
|
"analyse_contestation": "La CPAM conteste le DAS K56.0",
|
||||||
"points_accord": "Aucun",
|
"points_accord": "Aucun",
|
||||||
"contre_arguments": "Le guide méthodologique précise...",
|
"contre_arguments_medicaux": "Le guide méthodologique précise...",
|
||||||
"references": "Guide métho p.64",
|
"contre_arguments_asymetrie": "La biologie montre une CRP à 180...",
|
||||||
|
"contre_arguments_reglementaires": "L'UCR interprète restrictivement...",
|
||||||
|
"references": "Guide métho p.64, CIM-10 K56.0",
|
||||||
"conclusion": "Le DAS est justifié",
|
"conclusion": "Le DAS est justifié",
|
||||||
}
|
}
|
||||||
text = _format_response(parsed)
|
text = _format_response(parsed)
|
||||||
|
|
||||||
assert "ANALYSE DE LA CONTESTATION" in text
|
assert "ANALYSE DE LA CONTESTATION" in text
|
||||||
assert "CONTRE-ARGUMENTS" in text
|
assert "CONTRE-ARGUMENTS MÉDICAUX" in text
|
||||||
|
assert "ASYMÉTRIE D'INFORMATION" in text
|
||||||
|
assert "CONTRE-ARGUMENTS RÉGLEMENTAIRES" in text
|
||||||
|
assert "REFERENCES" in text
|
||||||
assert "CONCLUSION" in text
|
assert "CONCLUSION" in text
|
||||||
# "Aucun" ne doit pas générer la section points d'accord
|
# "Aucun" ne doit pas générer la section points d'accord
|
||||||
assert "POINTS D'ACCORD" not in text
|
assert "POINTS D'ACCORD" not in text
|
||||||
|
# L'ancien champ ne doit pas apparaître
|
||||||
|
assert "CONTRE-ARGUMENTS\n" not in text
|
||||||
|
|
||||||
def test_partial_response(self):
|
def test_fallback_old_format(self):
|
||||||
|
"""L'ancien champ contre_arguments est toujours géré (réponses en cache)."""
|
||||||
parsed = {
|
parsed = {
|
||||||
"contre_arguments": "Arguments...",
|
"analyse_contestation": "Analyse...",
|
||||||
|
"contre_arguments": "Arguments anciens...",
|
||||||
"conclusion": "Conclusion...",
|
"conclusion": "Conclusion...",
|
||||||
}
|
}
|
||||||
text = _format_response(parsed)
|
text = _format_response(parsed)
|
||||||
|
|
||||||
assert "CONTRE-ARGUMENTS" in text
|
assert "CONTRE-ARGUMENTS\nArguments anciens..." in text
|
||||||
|
assert "CONCLUSION" in text
|
||||||
|
|
||||||
|
def test_new_fields_override_fallback(self):
|
||||||
|
"""Si les nouveaux champs existent, l'ancien contre_arguments est ignoré."""
|
||||||
|
parsed = {
|
||||||
|
"contre_arguments_medicaux": "Médicaux...",
|
||||||
|
"contre_arguments": "Ancien fallback...",
|
||||||
|
"conclusion": "Conclusion...",
|
||||||
|
}
|
||||||
|
text = _format_response(parsed)
|
||||||
|
|
||||||
|
assert "CONTRE-ARGUMENTS MÉDICAUX" in text
|
||||||
|
assert "Ancien fallback" not in text
|
||||||
|
|
||||||
|
def test_partial_response(self):
|
||||||
|
parsed = {
|
||||||
|
"contre_arguments_medicaux": "Arguments médicaux...",
|
||||||
|
"conclusion": "Conclusion...",
|
||||||
|
}
|
||||||
|
text = _format_response(parsed)
|
||||||
|
|
||||||
|
assert "CONTRE-ARGUMENTS MÉDICAUX" in text
|
||||||
assert "CONCLUSION" in text
|
assert "CONCLUSION" in text
|
||||||
|
|
||||||
def test_empty_response(self):
|
def test_empty_response(self):
|
||||||
@@ -117,7 +246,8 @@ class TestGenerateResponse:
|
|||||||
]
|
]
|
||||||
mock_ollama.return_value = {
|
mock_ollama.return_value = {
|
||||||
"analyse_contestation": "Analyse...",
|
"analyse_contestation": "Analyse...",
|
||||||
"contre_arguments": "Contre-arguments...",
|
"contre_arguments_medicaux": "Contre-arguments médicaux...",
|
||||||
|
"contre_arguments_asymetrie": "Asymétrie...",
|
||||||
"conclusion": "Conclusion...",
|
"conclusion": "Conclusion...",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +256,7 @@ class TestGenerateResponse:
|
|||||||
|
|
||||||
text, sources = generate_cpam_response(dossier, controle)
|
text, sources = generate_cpam_response(dossier, controle)
|
||||||
|
|
||||||
assert "Contre-arguments..." in text
|
assert "Contre-arguments médicaux..." in text
|
||||||
assert len(sources) == 1
|
assert len(sources) == 1
|
||||||
assert sources[0].document == "guide_methodo"
|
assert sources[0].document == "guide_methodo"
|
||||||
mock_ollama.assert_called_once()
|
mock_ollama.assert_called_once()
|
||||||
@@ -144,3 +274,165 @@ class TestGenerateResponse:
|
|||||||
|
|
||||||
assert text == ""
|
assert text == ""
|
||||||
assert sources == []
|
assert sources == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearchRagForControl:
|
||||||
|
"""Tests pour la logique de recherche RAG multi-requêtes."""
|
||||||
|
|
||||||
|
@patch("src.medical.rag_search.search_similar_cpam")
|
||||||
|
def test_multiple_queries_with_da_ucr(self, mock_search):
|
||||||
|
"""Avec da_ucr, on doit avoir au moins 2 requêtes (codes + argument)."""
|
||||||
|
mock_search.return_value = [
|
||||||
|
{"document": "guide_methodo", "page": 10, "code": None, "score": 0.6,
|
||||||
|
"extrait": "Texte guide"},
|
||||||
|
]
|
||||||
|
|
||||||
|
dossier = _make_dossier()
|
||||||
|
controle = _make_controle() # da_ucr="K56.0"
|
||||||
|
|
||||||
|
results = _search_rag_for_control(controle, dossier)
|
||||||
|
|
||||||
|
# Au moins 2 appels : codes contestés + argument CPAM
|
||||||
|
assert mock_search.call_count >= 2
|
||||||
|
|
||||||
|
@patch("src.medical.rag_search.search_similar_cpam")
|
||||||
|
def test_query_codes_contains_cma(self, mock_search):
|
||||||
|
"""La requête codes contestés doit contenir 'CMA' pour un DA."""
|
||||||
|
mock_search.return_value = []
|
||||||
|
|
||||||
|
dossier = _make_dossier()
|
||||||
|
controle = _make_controle() # da_ucr="K56.0"
|
||||||
|
|
||||||
|
_search_rag_for_control(controle, dossier)
|
||||||
|
|
||||||
|
# Premier appel = requête codes
|
||||||
|
first_call_query = mock_search.call_args_list[0][0][0]
|
||||||
|
assert "K56.0" in first_call_query
|
||||||
|
assert "CMA" in first_call_query
|
||||||
|
|
||||||
|
@patch("src.medical.rag_search.search_similar_cpam")
|
||||||
|
def test_query_argument_contains_titre(self, mock_search):
|
||||||
|
"""La requête argument doit contenir le titre du contrôle."""
|
||||||
|
mock_search.return_value = []
|
||||||
|
|
||||||
|
dossier = _make_dossier()
|
||||||
|
controle = _make_controle()
|
||||||
|
|
||||||
|
_search_rag_for_control(controle, dossier)
|
||||||
|
|
||||||
|
# Deuxième appel = requête argument
|
||||||
|
second_call_query = mock_search.call_args_list[1][0][0]
|
||||||
|
assert controle.titre in second_call_query
|
||||||
|
|
||||||
|
@patch("src.medical.rag_search.search_similar_cpam")
|
||||||
|
def test_deduplication_by_document_code_page(self, mock_search):
|
||||||
|
"""Les résultats dupliqués (même document/code/page) sont fusionnés."""
|
||||||
|
# Les deux requêtes retournent le même résultat
|
||||||
|
shared_result = {
|
||||||
|
"document": "guide_methodo", "page": 64, "code": None,
|
||||||
|
"score": 0.55, "extrait": "Texte partagé",
|
||||||
|
}
|
||||||
|
mock_search.return_value = [shared_result.copy()]
|
||||||
|
|
||||||
|
dossier = _make_dossier()
|
||||||
|
controle = _make_controle()
|
||||||
|
|
||||||
|
results = _search_rag_for_control(controle, dossier)
|
||||||
|
|
||||||
|
# Le résultat ne doit apparaître qu'une seule fois malgré les requêtes multiples
|
||||||
|
guide_results = [r for r in results if r["document"] == "guide_methodo" and r.get("page") == 64]
|
||||||
|
assert len(guide_results) == 1
|
||||||
|
|
||||||
|
@patch("src.medical.rag_search.search_similar_cpam")
|
||||||
|
def test_dedup_keeps_best_score(self, mock_search):
|
||||||
|
"""La déduplication garde le meilleur score."""
|
||||||
|
def side_effect(query, top_k=8):
|
||||||
|
if "CMA" in query:
|
||||||
|
return [{"document": "cim10", "code": "K56.0", "page": None,
|
||||||
|
"score": 0.5, "extrait": "Iléus"}]
|
||||||
|
else:
|
||||||
|
return [{"document": "cim10", "code": "K56.0", "page": None,
|
||||||
|
"score": 0.7, "extrait": "Iléus"}]
|
||||||
|
mock_search.side_effect = side_effect
|
||||||
|
|
||||||
|
dossier = _make_dossier()
|
||||||
|
controle = _make_controle()
|
||||||
|
|
||||||
|
results = _search_rag_for_control(controle, dossier)
|
||||||
|
|
||||||
|
k56_results = [r for r in results if r.get("code") == "K56.0"]
|
||||||
|
assert len(k56_results) == 1
|
||||||
|
assert k56_results[0]["score"] == 0.7
|
||||||
|
|
||||||
|
@patch("src.medical.rag_search.search_similar_cpam")
|
||||||
|
def test_no_codes_contestes_only_argument_query(self, mock_search):
|
||||||
|
"""Sans codes contestés, seule la requête argument est lancée."""
|
||||||
|
mock_search.return_value = []
|
||||||
|
|
||||||
|
dossier = _make_dossier()
|
||||||
|
controle = ControleCPAM(
|
||||||
|
numero_ogc=1,
|
||||||
|
titre="Désaccord sur la durée",
|
||||||
|
arg_ucr="Séjour trop long selon l'UCR.",
|
||||||
|
decision_ucr="Rejet",
|
||||||
|
dp_ucr=None,
|
||||||
|
da_ucr=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
_search_rag_for_control(controle, dossier)
|
||||||
|
|
||||||
|
# Un seul appel : requête argument (pas de codes contestés)
|
||||||
|
assert mock_search.call_count == 1
|
||||||
|
|
||||||
|
@patch("src.medical.rag_search.search_similar_cpam")
|
||||||
|
def test_dp_ucr_query_contains_diagnostic_principal(self, mock_search):
|
||||||
|
"""Avec dp_ucr, la requête codes mentionne 'diagnostic principal'."""
|
||||||
|
mock_search.return_value = []
|
||||||
|
|
||||||
|
dossier = _make_dossier()
|
||||||
|
controle = ControleCPAM(
|
||||||
|
numero_ogc=2,
|
||||||
|
titre="Désaccord sur le DP",
|
||||||
|
arg_ucr="Le DP devrait être K80.1",
|
||||||
|
decision_ucr="Rejet",
|
||||||
|
dp_ucr="K81.0",
|
||||||
|
da_ucr=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
_search_rag_for_control(controle, dossier)
|
||||||
|
|
||||||
|
first_call_query = mock_search.call_args_list[0][0][0]
|
||||||
|
assert "K81.0" in first_call_query
|
||||||
|
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."""
|
||||||
|
mock_search.return_value = [
|
||||||
|
{"document": "guide_methodo", "page": i, "code": None,
|
||||||
|
"score": 0.9 - i * 0.01, "extrait": f"Texte {i}"}
|
||||||
|
for i in range(8)
|
||||||
|
]
|
||||||
|
|
||||||
|
dossier = _make_dossier()
|
||||||
|
controle = _make_controle()
|
||||||
|
|
||||||
|
results = _search_rag_for_control(controle, dossier)
|
||||||
|
|
||||||
|
assert len(results) <= 10
|
||||||
|
|
||||||
|
@patch("src.medical.rag_search.search_similar_cpam")
|
||||||
|
def test_clinical_query_when_das_match(self, mock_search):
|
||||||
|
"""Requête clinique lancée quand da_ucr matche un DAS du dossier."""
|
||||||
|
mock_search.return_value = []
|
||||||
|
|
||||||
|
dossier = _make_dossier() # DAS K56.0 "Iléus réflexe"
|
||||||
|
controle = _make_controle() # da_ucr="K56.0"
|
||||||
|
|
||||||
|
_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
|
||||||
|
|||||||
Reference in New Issue
Block a user