Files
t2a_v2/src/control/cpam_response.py
dom bc0ccbef7c 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>
2026-02-17 23:24:10 +01:00

535 lines
22 KiB
Python

"""Génération de contre-argumentation pour les contrôles CPAM via RAG + Ollama."""
from __future__ import annotations
import logging
import re
from ..config import ControleCPAM, DossierMedical, RAGSource
from ..medical.cim10_dict import normalize_code, validate_code
from ..medical.ollama_client import call_anthropic, call_ollama
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.
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_cpam
except Exception:
logger.warning("Index RAG non disponible pour la contre-argumentation")
return []
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_arg.append(controle.titre)
arg_short = controle.arg_ucr[:500] if controle.arg_ucr else ""
if 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)
# 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)
# Requête 4 — Définitions CIM-10 des codes contestés
contested_codes = []
for field in (controle.dp_ucr, controle.da_ucr, controle.dr_ucr):
if field:
contested_codes.extend(re.split(r"[,;\s]+", field.strip()))
for raw_code in contested_codes:
raw_code = raw_code.strip()
if not raw_code:
continue
norm = normalize_code(raw_code)
is_valid, label = validate_code(norm)
if is_valid and label:
query_def = f"CIM-10 {norm} {label} définition inclusion exclusion"
else:
query_def = f"CIM-10 {norm} définition codage"
results_def = search_similar_cpam(query_def, top_k=3)
logger.debug(" RAG requête CIM-10 %s : %d résultats", norm, len(results_def))
all_results.extend(results_def)
# Requête 5 — Règles explicitement citées dans l'argument CPAM
if controle.arg_ucr:
rule_patterns = [
r'(?:R[eè]gle\s*T?\s*\d+)',
r'(?:Annexe[\s-]*\d+[A-Za-z]*)',
r'(?:Situation de soins?\s+[^.]{5,40})',
]
rules_found = []
for pattern in rule_patterns:
rules_found.extend(re.findall(pattern, controle.arg_ucr, re.IGNORECASE))
if rules_found:
rules_unique = list(dict.fromkeys(rules_found))
query_rules = " ".join(rules_unique) + " guide méthodologique codage PMSI"
results_rules = search_similar_cpam(query_rules, top_k=4)
logger.debug(" RAG requête règles (%s) : %d résultats",
", ".join(rules_unique), len(results_rules))
all_results.extend(results_rules)
if not all_results:
return []
# 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[:12]
def _get_code_label(code_str: str) -> str:
"""Résout le libellé CIM-10 pour un ou plusieurs codes."""
codes = re.split(r"[,;\s]+", code_str.strip())
labels = []
for raw in codes:
raw = raw.strip()
if not raw:
continue
norm = normalize_code(raw)
is_valid, label = validate_code(norm)
if is_valid and label:
labels.append(f"{norm}{label}")
else:
labels.append(norm)
if not labels:
return ""
if len(labels) == 1:
parts = labels[0].split("", 1)
return f"{parts[1]}" if len(parts) > 1 else ""
return "\n " + "\n ".join(labels)
def _build_cpam_prompt(
dossier: DossierMedical,
controle: ControleCPAM,
sources: list[dict],
) -> str:
"""Construit le prompt pour la contre-argumentation CPAM."""
# Résumé du dossier médical
dossier_lines = []
if dossier.diagnostic_principal:
dp = dossier.diagnostic_principal
dp_code = f" ({dp.cim10_suggestion})" if dp.cim10_suggestion else ""
dossier_lines.append(f"- DP : {dp.texte}{dp_code}")
elif controle.dp_ucr:
dp_label = _get_code_label(controle.dp_ucr)
dossier_lines.append(
f"- DP : code {controle.dp_ucr}{dp_label} "
f"(codé par l'établissement, contesté par la CPAM)"
)
if dossier.diagnostics_associes:
das_parts = []
for das in dossier.diagnostics_associes:
code = f" ({das.cim10_suggestion})" if das.cim10_suggestion else ""
das_parts.append(f"{das.texte}{code}")
dossier_lines.append(f"- DAS : {', '.join(das_parts)}")
if dossier.actes_ccam:
actes = [f"{a.texte} ({a.code_ccam_suggestion})" if a.code_ccam_suggestion else a.texte
for a in dossier.actes_ccam]
dossier_lines.append(f"- Actes CCAM : {', '.join(actes)}")
sejour = dossier.sejour
if sejour.duree_sejour is not None:
dossier_lines.append(f"- Durée séjour : {sejour.duree_sejour} jours")
if sejour.sexe or sejour.age is not None:
patient_info = []
if sejour.sexe:
patient_info.append(sejour.sexe)
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 (avec libellés CIM-10 résolus)
codes_contestes = []
if controle.dp_ucr:
codes_contestes.append(f"DP proposé par UCR : {controle.dp_ucr}{_get_code_label(controle.dp_ucr)}")
if controle.da_ucr:
codes_contestes.append(f"DA proposés par UCR : {controle.da_ucr}{_get_code_label(controle.da_ucr)}")
if controle.dr_ucr:
codes_contestes.append(f"DR proposé par UCR : {controle.dr_ucr}{_get_code_label(controle.dr_ucr)}")
if controle.actes_ucr:
codes_contestes.append(f"Actes proposés par UCR : {controle.actes_ucr}")
codes_str = "\n".join(codes_contestes) if codes_contestes else "Aucun code spécifique proposé"
# Sources RAG
sources_text = ""
for i, src in enumerate(sources, 1):
doc_name = {
"cim10": "CIM-10 FR 2026",
"cim10_alpha": "CIM-10 Index Alphabétique 2026",
"guide_methodo": "Guide Méthodologique MCO 2026",
"ccam": "CCAM PMSI V4 2025",
}.get(src.get("document", ""), src.get("document", ""))
code_info = f" (code: {src['code']})" if src.get("code") else ""
page_info = f" [page {src['page']}]" if src.get("page") else ""
sources_text += f"--- Source {i}: {doc_name}{code_info}{page_info} ---\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.
Tu dois produire une analyse ÉQUILIBRÉE ET CRÉDIBLE de la contestation CPAM, puis contre-argumenter en mobilisant trois axes : médical, asymétrie d'information, et réglementaire.
IMPORTANT — CRÉDIBILITÉ DE L'ANALYSE :
Une contre-argumentation crédible reconnaît TOUJOURS au moins un point valide dans le raisonnement adverse.
Répondre "Aucun point d'accord" décrédibilise l'ensemble de l'argumentation. Tu DOIS identifier au moins un élément où la CPAM a un point légitime (même partiel), puis expliquer pourquoi cela ne suffit pas à invalider le codage.
DOSSIER MÉDICAL DE L'ÉTABLISSEMENT :
{dossier_str}
{asymetrie_str}
OBJET DU DÉSACCORD : {controle.titre}
ARGUMENTATION DE LA CPAM (UCR) :
{controle.arg_ucr}
DÉCISION UCR : {controle.decision_ucr}
CODES CONTESTÉS :
{codes_str}
SOURCES RÉGLEMENTAIRES (Guide méthodologique, CIM-10) :
{sources_text}
CONSIGNES :
ÉTAPE 1 — ANALYSE HONNÊTE (avant de contre-argumenter) :
- Identifie ce que la CPAM a compris correctement dans le dossier
- Reconnais les points où leur raisonnement est fondé, même partiellement
- Explique ENSUITE pourquoi ces points ne justifient pas leur conclusion
AXE MÉDICAL :
- Analyse le bien-fondé médical du codage de l'établissement
- CITE les éléments cliniques EXACTS du dossier : valeurs bio précises (ex: CRP 180 mg/L), résultats imagerie verbatim, traitements avec molécules et posologies
- Confronte l'argumentation CPAM aux sources CIM-10 et Guide Méthodologique fournies
- Ne mentionne que les éléments réellement présents dans le dossier fourni
AXE ASYMÉTRIE D'INFORMATION :
- La CPAM a fondé son analyse uniquement sur le CRH et les codes transmis
- Pour CHAQUE élément clinique pertinent, cite les VALEURS EXACTES et explique leur signification clinique
- Démontre en quoi ces éléments complémentaires (biologie, imagerie, traitements, actes) justifient le codage contesté
- Ne mentionne AUCUN élément qui n'est pas dans le dossier fourni
MISE EN FORME :
- Structure chaque section avec des tirets pour lister les arguments distincts
- Un argument par puce, avec la preuve ou la référence associée
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
- Format OBLIGATOIRE pour chaque référence : [Document - page N] suivi d'une CITATION VERBATIM du passage pertinent
- INTERDICTION ABSOLUE de citer une référence qui ne figure pas dans les sources fournies ci-dessus
- Si aucune source pertinente n'est disponible → écrire explicitement "Pas de source réglementaire disponible"
- Relève les contradictions entre l'argumentation CPAM et les règles officielles
Réponds UNIQUEMENT avec un objet JSON au format suivant :
{{
"analyse_contestation": "Résumé de ce que conteste la CPAM et sur quelle base",
"points_accord": "Points CONCRETS où la CPAM a raison ou partiellement raison (JAMAIS 'Aucun' — il y a toujours au moins un point légitime à reconnaître)",
"contre_arguments_medicaux": "Argumentation médicale en faveur du codage, en expliquant pourquoi les points d'accord ne suffisent pas à invalider le codage",
"preuves_dossier": [
{{"element": "biologie|imagerie|traitement|acte|clinique", "valeur": "valeur exacte du dossier", "signification": "explication clinique"}}
],
"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 verbatim des sources",
"references": [
{{"document": "nom du document source", "page": "numéro de page", "citation": "citation verbatim du passage"}}
],
"conclusion": "Synthèse : points reconnus à la CPAM, mais pourquoi le codage initial est néanmoins justifié"
}}"""
def _validate_references(parsed: dict, sources: list[dict]) -> list[str]:
"""Vérifie que les références citées correspondent aux sources RAG fournies.
Returns:
Liste d'avertissements pour les références non vérifiables.
"""
warnings = []
refs = parsed.get("references")
if not refs or not isinstance(refs, list):
return warnings
# Construire un set des documents sources disponibles
source_docs = set()
for src in sources:
doc_name = src.get("document", "")
source_docs.add(doc_name)
# Ajouter les noms lisibles aussi
readable = {
"cim10": "CIM-10 FR 2026",
"cim10_alpha": "CIM-10 Index Alphabétique 2026",
"guide_methodo": "Guide Méthodologique MCO 2026",
"ccam": "CCAM PMSI V4 2025",
}.get(doc_name, "")
if readable:
source_docs.add(readable)
source_docs.add(readable.lower())
if not source_docs:
return warnings
for ref in refs:
if not isinstance(ref, dict):
continue
doc = ref.get("document", "")
if doc and not any(sd in doc.lower() or doc.lower() in sd.lower() for sd in source_docs if sd):
warnings.append(f"Référence non vérifiable : {doc}")
logger.warning("CPAM : référence non vérifiable « %s »", doc)
return warnings
def _format_response(parsed: dict, ref_warnings: list[str] | None = None) -> str:
"""Formate la réponse LLM en texte lisible."""
sections = []
analyse = parsed.get("analyse_contestation")
if analyse:
sections.append(f"ANALYSE DE LA CONTESTATION\n{analyse}")
accord = parsed.get("points_accord")
if accord and accord.lower() not in ("aucun", "non applicable", "n/a", ""):
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}")
# Preuves du dossier (nouveau champ structuré)
preuves = parsed.get("preuves_dossier")
if preuves and isinstance(preuves, list):
preuves_lines = []
for p in preuves:
if isinstance(p, dict):
elem = p.get("element", "")
valeur = p.get("valeur", "")
signif = p.get("signification", "")
preuves_lines.append(f"- [{elem}] {valeur}{signif}")
if preuves_lines:
sections.append(f"PREUVES DU DOSSIER\n" + "\n".join(preuves_lines))
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}")
# Références structurées (nouveau format liste) ou ancien format string
refs = parsed.get("references")
if refs:
if isinstance(refs, list):
ref_lines = []
for r in refs:
if isinstance(r, dict):
doc = r.get("document", "")
page = r.get("page", "")
citation = r.get("citation", "")
ref_lines.append(f"- [{doc}, p.{page}] {citation}")
else:
ref_lines.append(f"- {r}")
if ref_lines:
sections.append(f"REFERENCES\n" + "\n".join(ref_lines))
else:
sections.append(f"REFERENCES\n{refs}")
conclusion = parsed.get("conclusion")
if conclusion:
sections.append(f"CONCLUSION\n{conclusion}")
# Avertissements sur les références non vérifiables
if ref_warnings:
warning_text = "\n".join(f"- {w}" for w in ref_warnings)
sections.append(f"AVERTISSEMENT — REFERENCES NON VÉRIFIÉES\n{warning_text}")
return "\n\n".join(sections)
def generate_cpam_response(
dossier: DossierMedical,
controle: ControleCPAM,
) -> tuple[str, dict | None, list[RAGSource]]:
"""Génère une contre-argumentation pour un contrôle CPAM.
Args:
dossier: Le dossier médical analysé.
controle: Le contrôle CPAM à contester.
Returns:
Tuple (texte de contre-argumentation, dict LLM structuré ou None, sources RAG utilisées).
"""
logger.info("CPAM : génération contre-argumentation pour OGC %d%s",
controle.numero_ogc, controle.titre)
# 1. Recherche RAG ciblée
sources = _search_rag_for_control(controle, dossier)
logger.info(" RAG : %d sources trouvées", len(sources))
# 2. Construction du prompt
prompt = _build_cpam_prompt(dossier, controle, sources)
# 3. Appel LLM — Ollama (modèle par défaut) > Haiku fallback
result = call_ollama(prompt, temperature=0.1, max_tokens=4000)
if result is not None:
logger.info(" Contre-argumentation via Ollama")
else:
logger.info(" Ollama indisponible → fallback Anthropic Haiku")
result = call_anthropic(prompt, temperature=0.1, max_tokens=4000)
if result is not None:
logger.info(" Contre-argumentation via Anthropic Haiku")
# 4. Conversion des sources RAG
rag_sources = [
RAGSource(
document=s.get("document", ""),
page=s.get("page"),
code=s.get("code"),
extrait=s.get("extrait", "")[:200],
)
for s in sources
]
if result is None:
logger.warning(" LLM non disponible — contre-argumentation non générée")
return "", None, rag_sources
# 5. Validation des références
ref_warnings = _validate_references(result, sources)
if ref_warnings:
logger.warning(" CPAM : %d référence(s) non vérifiable(s)", len(ref_warnings))
# 6. Formater la réponse
text = _format_response(result, ref_warnings)
logger.info(" Contre-argumentation générée (%d caractères)", len(text))
return text, result, rag_sources