feat: mode hybride Ollama — gemma3:27b pour CPAM, 12b pour codage

Le pipeline utilise désormais gemma3:12b (rapide) pour le codage CIM-10
et gemma3:27b (meilleur raisonnement) pour la contre-argumentation CPAM.
Configurable via OLLAMA_MODEL_CPAM et OLLAMA_TIMEOUT_CPAM.

Inclut aussi : traçabilité source/page DAS, niveaux CMA ATIH, sévérité,
page tracker PDF, améliorations fusion et filtres DAS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-17 17:53:53 +01:00
parent 4ef42dd3d3
commit 01d47f3c4b
20 changed files with 1025 additions and 98 deletions

View File

@@ -4,8 +4,8 @@ from __future__ import annotations
import logging
from ..config import ControleCPAM, DossierMedical, RAGSource
from ..medical.ollama_client import call_ollama
from ..config import ControleCPAM, DossierMedical, RAGSource, OLLAMA_MODEL_CPAM, OLLAMA_TIMEOUT_CPAM
from ..medical.ollama_client import call_anthropic, call_ollama
logger = logging.getLogger(__name__)
@@ -244,33 +244,84 @@ CONSIGNES :
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
- Identifie les points où la CPAM a éventuellement raison
- 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
- 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
- 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
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
- 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 et sur quelle base",
"points_accord": "Points où la CPAM a raison (ou 'Aucun')",
"contre_arguments_medicaux": "Argumentation médicale en faveur du 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 des sources",
"references": "Références EXACTES tirées des sources fournies (document, page, code)",
"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 et position recommandée"
}}"""
def _format_response(parsed: dict) -> str:
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 = []
@@ -287,6 +338,19 @@ def _format_response(parsed: dict) -> str:
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}")
@@ -301,14 +365,33 @@ def _format_response(parsed: dict) -> str:
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:
sections.append(f"REFERENCES\n{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)
@@ -335,8 +418,24 @@ def generate_cpam_response(
# 2. Construction du prompt
prompt = _build_cpam_prompt(dossier, controle, sources)
# 3. Appel Ollama
result = call_ollama(prompt, temperature=0.1, max_tokens=3000)
# 3. Appel LLM — Mode hybride : Ollama CPAM (27b) > Haiku > Ollama défaut
result = None
if OLLAMA_MODEL_CPAM:
logger.info(" Contre-argumentation via Ollama %s (mode hybride)", OLLAMA_MODEL_CPAM)
result = call_ollama(
prompt, temperature=0.1, max_tokens=4000,
model=OLLAMA_MODEL_CPAM, timeout=OLLAMA_TIMEOUT_CPAM,
)
if result is not None:
logger.info(" Contre-argumentation via Ollama %s", OLLAMA_MODEL_CPAM)
else:
logger.info(" Ollama CPAM 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")
else:
logger.info(" Haiku indisponible → fallback Ollama défaut")
result = call_ollama(prompt, temperature=0.1, max_tokens=3000)
# 4. Conversion des sources RAG
rag_sources = [
@@ -350,11 +449,16 @@ def generate_cpam_response(
]
if result is None:
logger.warning(" Ollama non disponible — contre-argumentation non générée")
logger.warning(" LLM non disponible — contre-argumentation non générée")
return "", rag_sources
# 5. Formater la réponse
text = _format_response(result)
# 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, rag_sources