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:
dom
2026-02-12 13:44:34 +01:00
parent a00e5f1147
commit a58398f5d4
25 changed files with 2872 additions and 97 deletions

View 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