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:
dom
2026-02-15 11:08:15 +01:00
parent aa397d5360
commit 50a77c9f61
3 changed files with 536 additions and 43 deletions

View File

@@ -11,35 +11,75 @@ logger = logging.getLogger(__name__)
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:
from ..medical.rag_search import search_similar
from ..medical.rag_search import search_similar_cpam
except Exception:
logger.warning("Index RAG non disponible pour la contre-argumentation")
return []
# Construire une requête combinant l'argument CPAM et le diagnostic concerné
query_parts = []
all_results: list[dict] = []
# Requête 1 — Codes contestés (règles de codage)
if controle.dp_ucr or controle.da_ucr:
query_parts = []
if controle.dp_ucr:
query_parts.append(f"règles codage {controle.dp_ucr} diagnostic principal")
if 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)
# Requête 2 — Argument CPAM (recherche dans le Guide Méthodo)
query_parts_arg = []
if controle.titre:
query_parts.append(controle.titre)
# Ajouter les codes contestés pour cibler la recherche
if controle.dp_ucr:
query_parts.append(f"diagnostic principal {controle.dp_ucr}")
if controle.da_ucr:
query_parts.append(f"diagnostic associé {controle.da_ucr}")
# Tronquer l'argument CPAM pour ne garder que le coeur
arg_short = controle.arg_ucr[:300] if controle.arg_ucr else ""
query_parts_arg.append(controle.titre)
arg_short = controle.arg_ucr[:200] if controle.arg_ucr else ""
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)
if not query.strip():
# Requête 3 — Contexte clinique (définitions CIM-10 des codes en jeu)
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 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(
@@ -78,17 +118,80 @@ def _build_cpam_prompt(
if sejour.age is not None:
patient_info.append(f"{sejour.age} ans")
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:
bio = [f"{b.test}: {b.valeur}" for b in dossier.biologie_cle[:5] if b.valeur]
if 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:
dossier_lines.append(f"- Complications : {', '.join(dossier.complications)}")
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_contestes = []
if controle.dp_ucr:
@@ -118,10 +221,11 @@ def _build_cpam_prompt(
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.
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_str}
{asymetrie_str}
OBJET DU DÉSACCORD : {controle.titre}
@@ -137,18 +241,31 @@ SOURCES RÉGLEMENTAIRES (Guide méthodologique, CIM-10) :
{sources_text}
CONSIGNES :
- Analyse objectivement l'argument de la CPAM
- Identifie les points où la CPAM a raison (le cas échéant)
- Contre-argumente point par point en citant le guide méthodologique et la CIM-10
- Cite les références précises (pages, articles, fascicules)
- Propose une conclusion et la position recommandée
AXE MÉDICAL :
- Analyse le bien-fondé médical du codage de l'établissement
- Confronte l'argumentation CPAM aux sources CIM-10 et Guide Méthodologique fournies
- 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 :
{{
"analyse_contestation": "Résumé de ce que conteste la CPAM",
"points_accord": "Points où la CPAM a raison (ou 'Aucun' si non applicable)",
"contre_arguments": "Arguments point par point en faveur de l'établissement",
"references": "Références guide méthodologique / CIM-10 citées",
"analyse_contestation": "Résumé de ce que conteste la CPAM et sur quelle base",
"points_accord": "Points où la CPAM a raison (ou 'Aucun')",
"contre_arguments_medicaux": "Argumentation médicale en faveur du codage",
"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"
}}"""
@@ -165,9 +282,24 @@ def _format_response(parsed: dict) -> str:
if accord and accord.lower() not in ("aucun", "non applicable", "n/a", ""):
sections.append(f"POINTS D'ACCORD\n{accord}")
contre = parsed.get("contre_arguments")
if contre:
sections.append(f"CONTRE-ARGUMENTS\n{contre}")
# 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")
if contre:
sections.append(f"CONTRE-ARGUMENTS\n{contre}")
refs = parsed.get("references")
if refs:

View File

@@ -23,6 +23,8 @@ _embed_model = None
# Score minimum de similarité FAISS pour retenir un résultat
_MIN_SCORE = 0.3
# Seuil rehaussé pour le contexte CPAM (filtrage plus agressif du bruit)
_MIN_SCORE_CPAM = 0.40
def _get_embed_model():
@@ -149,6 +151,73 @@ def search_similar_ccam(query: str, top_k: int = 8) -> list[dict]:
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:
"""Formate le contexte patient de manière structurée pour le prompt."""
lines = []