feat: cache Ollama + parallélisation ThreadPool + filtrage DAS renforcé + modules GHM/CPAM/export RUM
- Cache persistant JSON thread-safe pour les résultats Ollama (invalidation par modèle) - Parallélisation des appels Ollama (ThreadPoolExecutor, 2 workers) - 6 nouvelles règles de filtrage DAS parasites (doublons, ponctuation, OCR, labo, fragments) - Client Ollama centralisé (mode JSON natif + retry) - Module GHM (estimation CMD/sévérité) - Module contrôle CPAM (parser + contre-argumentation RAG) - Export RUM (format RSS) - Viewer enrichi (détail dossier) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
228
src/control/cpam_response.py
Normal file
228
src/control/cpam_response.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""Génération de contre-argumentation pour les contrôles CPAM via RAG + Ollama."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from ..config import ControleCPAM, DossierMedical, RAGSource
|
||||
from ..medical.ollama_client import 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."""
|
||||
try:
|
||||
from ..medical.rag_search import search_similar
|
||||
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 = []
|
||||
|
||||
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 ""
|
||||
if arg_short:
|
||||
query_parts.append(arg_short)
|
||||
|
||||
query = " ".join(query_parts)
|
||||
if not query.strip():
|
||||
return []
|
||||
|
||||
return search_similar(query, top_k=8)
|
||||
|
||||
|
||||
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}")
|
||||
|
||||
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 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.complications:
|
||||
dossier_lines.append(f"- Complications : {', '.join(dossier.complications)}")
|
||||
|
||||
dossier_str = "\n".join(dossier_lines) if dossier_lines else "Non disponible"
|
||||
|
||||
# Codes contestés par la CPAM
|
||||
codes_contestes = []
|
||||
if controle.dp_ucr:
|
||||
codes_contestes.append(f"DP proposé par UCR : {controle.dp_ucr}")
|
||||
if controle.da_ucr:
|
||||
codes_contestes.append(f"DA proposés par UCR : {controle.da_ucr}")
|
||||
if controle.dr_ucr:
|
||||
codes_contestes.append(f"DR proposé par UCR : {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 contre-argumenter la décision de la CPAM (UCR) point par point, en t'appuyant sur le guide méthodologique et la CIM-10.
|
||||
|
||||
DOSSIER MÉDICAL DE L'ÉTABLISSEMENT :
|
||||
{dossier_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 :
|
||||
- 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
|
||||
|
||||
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",
|
||||
"conclusion": "Synthèse et position recommandée"
|
||||
}}"""
|
||||
|
||||
|
||||
def _format_response(parsed: dict) -> 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}")
|
||||
|
||||
contre = parsed.get("contre_arguments")
|
||||
if contre:
|
||||
sections.append(f"CONTRE-ARGUMENTS\n{contre}")
|
||||
|
||||
refs = parsed.get("references")
|
||||
if refs:
|
||||
sections.append(f"REFERENCES\n{refs}")
|
||||
|
||||
conclusion = parsed.get("conclusion")
|
||||
if conclusion:
|
||||
sections.append(f"CONCLUSION\n{conclusion}")
|
||||
|
||||
return "\n\n".join(sections)
|
||||
|
||||
|
||||
def generate_cpam_response(
|
||||
dossier: DossierMedical,
|
||||
controle: ControleCPAM,
|
||||
) -> tuple[str, 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, 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 Ollama
|
||||
result = call_ollama(prompt, temperature=0.1, max_tokens=3000)
|
||||
|
||||
# 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(" Ollama non disponible — contre-argumentation non générée")
|
||||
return "", rag_sources
|
||||
|
||||
# 5. Formater la réponse
|
||||
text = _format_response(result)
|
||||
logger.info(" Contre-argumentation générée (%d caractères)", len(text))
|
||||
|
||||
return text, rag_sources
|
||||
Reference in New Issue
Block a user