feat: architecture multi-modèles LLM + externalisation des prompts

- Ajout OLLAMA_MODELS (coding/cpam/validation/qc) dans config.py avec get_model()
- Paramètre role= dans call_ollama() pour dispatch par rôle
- Cache Ollama : modèle stocké par entrée (migration auto de l'ancien format)
- 7 prompts externalisés dans src/prompts/templates.py (format str.format)
- Viewer : admin multi-modèles, endpoint PDF avec redaction, source texte
- Documentation prompts dans docs/prompts.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-19 20:51:52 +01:00
parent 5c8c2817ec
commit 540e0cb400
17 changed files with 1221 additions and 353 deletions

View File

@@ -168,13 +168,13 @@ def _extract_das_llm(text: str, dossier: DossierMedical) -> None:
try:
from .rag_search import extract_das_llm
from .ollama_cache import OllamaCache
from ..config import OLLAMA_CACHE_PATH, OLLAMA_MODEL
from ..config import OLLAMA_CACHE_PATH, get_model
except ImportError:
logger.warning("Module RAG non disponible pour l'extraction DAS LLM")
return
try:
cache = OllamaCache(OLLAMA_CACHE_PATH, OLLAMA_MODEL)
cache = OllamaCache(OLLAMA_CACHE_PATH, get_model("coding"))
# Construire le contexte
contexte = {
@@ -1126,6 +1126,7 @@ def _validate_justifications(dossier: DossierMedical) -> None:
try:
from .ollama_client import call_ollama
from .clinical_context import build_enriched_context, format_enriched_context
from ..prompts import QC_VALIDATION
except ImportError:
logger.warning("Module clinical_context non disponible pour la validation QC")
return
@@ -1152,36 +1153,10 @@ def _validate_justifications(dossier: DossierMedical) -> None:
ctx = build_enriched_context(dossier)
ctx_str = format_enriched_context(ctx)
prompt = f"""Tu es un médecin DIM contrôleur qualité PMSI.
Vérifie la cohérence et la justification de ce codage complet.
DOSSIER CLINIQUE :
{ctx_str}
CODAGE À VALIDER :
{codes_section}
Pour CHAQUE code, vérifie :
1. Existe-t-il une preuve clinique concrète dans le dossier ?
2. Le code est-il le plus spécifique possible ?
3. Y a-t-il des conflits ou redondances avec d'autres codes ?
Réponds avec un JSON :
{{
"validations": [
{{
"numero": 1,
"code": "X99.9",
"verdict": "maintenir|reclasser|supprimer",
"confidence_recommandee": "high|medium|low",
"commentaire": "explication courte"
}}
],
"alertes_globales": ["..."]
}}"""
prompt = QC_VALIDATION.format(ctx_str=ctx_str, codes_section=codes_section)
try:
result = call_ollama(prompt, temperature=0.1, max_tokens=2500)
result = call_ollama(prompt, temperature=0.1, max_tokens=2500, role="qc")
except Exception:
logger.warning("Erreur lors de l'appel Ollama pour validation QC", exc_info=True)
return

View File

@@ -14,32 +14,58 @@ class OllamaCache:
"""Cache JSON persistant pour éviter les appels Ollama redondants.
Clé = (texte_diagnostic_normalisé, type).
Le modèle Ollama est stocké dans les métadonnées : si le modèle change,
le cache est automatiquement invalidé.
Le modèle Ollama est stocké PAR ENTRÉE : un cache hit ne se produit
que si le modèle correspond à celui demandé.
Backward compat : si le fichier contient l'ancien format (modèle global),
les entrées sont migrées à la lecture avec le modèle global comme modèle
par entrée.
"""
def __init__(self, cache_path: Path, model: str):
def __init__(self, cache_path: Path, model: str | None = None):
self._path = cache_path
self._model = model
self._default_model = model
self._lock = threading.Lock()
self._data: dict[str, dict] = {}
self._dirty = False
self._load()
def _load(self) -> None:
"""Charge le cache depuis le disque."""
"""Charge le cache depuis le disque (avec migration ancien format)."""
if not self._path.exists():
logger.info("Cache Ollama : nouveau cache (%s)", self._path)
return
try:
raw = json.loads(self._path.read_text(encoding="utf-8"))
if raw.get("model") != self._model:
logger.info(
"Cache Ollama : modèle changé (%s%s), cache invalidé",
raw.get("model"), self._model,
)
# Détection ancien format : clé "model" globale + "entries" sans "model" par entrée
global_model = raw.get("model")
entries = raw.get("entries", {})
if not entries:
return
self._data = raw.get("entries", {})
# Vérifier si c'est l'ancien format (entrées sans clé "model")
sample_entry = next(iter(entries.values()), None)
is_old_format = sample_entry is not None and "model" not in sample_entry
if is_old_format:
if global_model:
# Migrer : injecter le modèle global dans chaque entrée
logger.info(
"Cache Ollama : migration ancien format → modèle par entrée (%s, %d entrées)",
global_model, len(entries),
)
for key, value in entries.items():
self._data[key] = {"model": global_model, "result": value}
self._dirty = True # Réécrire au prochain save()
else:
logger.warning("Cache Ollama : ancien format sans modèle global, cache ignoré")
return
else:
# Nouveau format : chaque entrée a déjà {"model": ..., "result": ...}
self._data = entries
logger.info("Cache Ollama : %d entrées chargées", len(self._data))
except (json.JSONDecodeError, KeyError) as e:
logger.warning("Cache Ollama : fichier corrompu (%s), réinitialisé", e)
@@ -50,17 +76,24 @@ class OllamaCache:
"""Construit une clé normalisée."""
return f"{diag_type}::{texte.strip().lower()}"
def get(self, texte: str, diag_type: str) -> dict | None:
"""Récupère un résultat caché, ou None si absent."""
def get(self, texte: str, diag_type: str, model: str | None = None) -> dict | None:
"""Récupère un résultat caché, ou None si absent ou modèle différent."""
key = self._make_key(texte, diag_type)
use_model = model or self._default_model
with self._lock:
return self._data.get(key)
entry = self._data.get(key)
if entry is None:
return None
if use_model and entry.get("model") != use_model:
return None
return entry.get("result")
def put(self, texte: str, diag_type: str, result: dict) -> None:
"""Stocke un résultat dans le cache."""
def put(self, texte: str, diag_type: str, result: dict, model: str | None = None) -> None:
"""Stocke un résultat dans le cache avec le modèle associé."""
key = self._make_key(texte, diag_type)
use_model = model or self._default_model or "unknown"
with self._lock:
self._data[key] = result
self._data[key] = {"model": use_model, "result": result}
self._dirty = True
def save(self) -> None:
@@ -69,10 +102,7 @@ class OllamaCache:
if not self._dirty:
return
self._path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"model": self._model,
"entries": self._data,
}
payload = {"entries": self._data}
self._path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",

View File

@@ -8,7 +8,7 @@ import os
import requests
from ..config import OLLAMA_URL, OLLAMA_MODEL, OLLAMA_TIMEOUT
from ..config import OLLAMA_URL, OLLAMA_MODEL, OLLAMA_TIMEOUT, get_model
logger = logging.getLogger(__name__)
@@ -83,6 +83,7 @@ def call_ollama(
temperature: float = 0.1,
max_tokens: int = 2500,
model: str | None = None,
role: str | None = None,
timeout: int | None = None,
) -> dict | None:
"""Appelle Ollama en mode JSON natif, avec fallback Anthropic si indisponible.
@@ -91,13 +92,14 @@ def call_ollama(
prompt: Le prompt à envoyer.
temperature: Température de génération (défaut: 0.1).
max_tokens: Nombre max de tokens (défaut: 2500).
model: Modèle Ollama à utiliser (défaut: OLLAMA_MODEL global).
model: Modèle Ollama explicite (prioritaire sur role).
role: Rôle LLM (coding, cpam, validation, qc) → résout le modèle via config.
timeout: Timeout en secondes (défaut: OLLAMA_TIMEOUT global).
Returns:
Le dict JSON parsé, ou None en cas d'erreur.
"""
use_model = model or OLLAMA_MODEL
use_model = model or (get_model(role) if role else OLLAMA_MODEL)
use_timeout = timeout or OLLAMA_TIMEOUT
for attempt in range(2):
try:

View File

@@ -8,8 +8,8 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
from ..config import (
ActeCCAM, Diagnostic, DossierMedical, PreuveClinique, RAGSource,
OLLAMA_CACHE_PATH, OLLAMA_MAX_PARALLEL, OLLAMA_MODEL,
EMBEDDING_MODEL, RERANKER_MODEL,
OLLAMA_CACHE_PATH, OLLAMA_MAX_PARALLEL,
EMBEDDING_MODEL, RERANKER_MODEL, get_model,
)
from .cim10_dict import normalize_code, validate_code as cim10_validate, fallback_parent_code
from .cim10_extractor import BIO_NORMALS
@@ -17,6 +17,7 @@ from .clinical_context import build_enriched_context, format_enriched_context
from .ccam_dict import validate_code as ccam_validate
from .ollama_client import call_ollama, parse_json_response
from .ollama_cache import OllamaCache
from ..prompts import CODING_CIM10, CODING_CCAM, DAS_EXTRACTION
logger = logging.getLogger(__name__)
@@ -357,8 +358,8 @@ def _format_contexte(contexte: dict) -> str:
return "\n".join(lines) if lines else "Non précisé"
def _build_prompt(texte: str, sources: list[dict], contexte: dict, est_dp: bool = True) -> str:
"""Construit le prompt expert DIM avec raisonnement structuré."""
def _format_sources(sources: list[dict]) -> str:
"""Formate les sources RAG pour injection dans un prompt."""
sources_text = ""
for i, src in enumerate(sources, 1):
doc_name = {
@@ -373,91 +374,27 @@ def _build_prompt(texte: str, sources: list[dict], contexte: dict, est_dp: bool
sources_text += f"--- Source {i}: {doc_name}{code_info}{page_info} ---\n"
sources_text += (src.get("extrait", "")[:800]) + "\n\n"
return sources_text
def _build_prompt(texte: str, sources: list[dict], contexte: dict, est_dp: bool = True) -> str:
"""Construit le prompt expert DIM avec raisonnement structuré."""
type_diag = "DP (diagnostic principal)" if est_dp else "DAS (diagnostic associé significatif)"
ctx_str = format_enriched_context(contexte)
return f"""Tu es un médecin DIM (Département d'Information Médicale) expert en codage PMSI.
Tu dois coder le diagnostic suivant en respectant STRICTEMENT les règles de l'ATIH.
RÈGLES IMPÉRATIVES :
- Le code doit provenir UNIQUEMENT des sources CIM-10 fournies
- Distingue la DESCRIPTION CLINIQUE (ce que le médecin écrit) de la LOGIQUE DE CODAGE (ce que l'ATIH impose)
- Privilégie le code le plus SPÉCIFIQUE disponible (4e ou 5e caractère)
- Vérifie les notes d'inclusion/exclusion de chaque code candidat
- Si le diagnostic est un DP, il doit refléter le motif principal de prise en charge du séjour
- Si c'est un DAS, il doit avoir mobilisé des ressources supplémentaires pendant le séjour
- EXCLUSION SYMPTÔME : Si le diagnostic est un symptôme (R00-R99) et qu'un diagnostic précis (Chapitres I-XIV, A00-N99) expliquant ce symptôme est présent, le symptôme ne doit PAS être codé comme DAS
DIAGNOSTIC À CODER : "{texte}"
TYPE : {type_diag}
CONTEXTE CLINIQUE :
{ctx_str}
SOURCES CIM-10 :
{sources_text}
Réponds UNIQUEMENT avec un objet JSON au format suivant, sans aucun texte avant ou après :
{{
"analyse_clinique": "que signifie ce diagnostic sur le plan médical",
"codes_candidats": "quels codes CIM-10 des sources sont compatibles",
"discrimination": "pourquoi choisir ce code plutôt qu'un autre (inclusions/exclusions, spécificité)",
"regle_pmsi": "conformité aux règles PMSI pour un {type_diag} (guide méthodologique)",
"code": "X99.9",
"confidence": "high ou medium ou low",
"justification": "explication courte en français",
"preuves_cliniques": [
{{"type": "biologie|imagerie|traitement|acte|clinique", "element": "élément concret du dossier", "interpretation": "signification clinique justifiant le code"}}
]
}}"""
return CODING_CIM10.format(
texte=texte,
type_diag=type_diag,
ctx_str=format_enriched_context(contexte),
sources_text=_format_sources(sources),
)
def _build_prompt_ccam(texte: str, sources: list[dict], contexte: dict) -> str:
"""Construit le prompt expert DIM pour le codage CCAM avec raisonnement structuré."""
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["document"], src["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"
ctx_str = format_enriched_context(contexte)
return f"""Tu es un médecin DIM (Département d'Information Médicale) expert en codage CCAM PMSI.
Tu dois coder l'acte chirurgical/médical suivant en respectant STRICTEMENT la nomenclature CCAM.
RÈGLES IMPÉRATIVES :
- Le code doit provenir UNIQUEMENT des sources CCAM fournies
- Un code CCAM est composé de 4 lettres + 3 chiffres (ex: HMFC004)
- Vérifie l'activité (1=acte technique, 4=anesthésie) et le regroupement
- Tiens compte du tarif secteur 1 pour valider la cohérence
- Si plusieurs codes sont possibles, choisis le plus spécifique à l'acte décrit
- En cas de doute, indique confidence "low" plutôt que de proposer un code inadapté
ACTE À CODER : "{texte}"
CONTEXTE CLINIQUE :
{ctx_str}
SOURCES CCAM :
{sources_text}
Réponds UNIQUEMENT avec un objet JSON au format suivant, sans aucun texte avant ou après :
{{
"analyse_acte": "que décrit cet acte sur le plan technique/chirurgical",
"codes_candidats": "quels codes CCAM des sources sont compatibles",
"discrimination": "pourquoi choisir ce code plutôt qu'un autre (activité, regroupement, tarif)",
"code": "ABCD123",
"confidence": "high ou medium ou low",
"justification": "explication courte en français"
}}"""
return CODING_CCAM.format(
texte=texte,
ctx_str=format_enriched_context(contexte),
sources_text=_format_sources(sources),
)
def _parse_ollama_response(raw: str) -> dict | None:
@@ -481,7 +418,7 @@ def _parse_ollama_response(raw: str) -> dict | None:
def _call_ollama(prompt: str) -> dict | None:
"""Appelle Ollama (mode JSON) et parse la réponse avec reconstitution du raisonnement."""
result = call_ollama(prompt, temperature=0.1, max_tokens=2500)
result = call_ollama(prompt, temperature=0.1, max_tokens=2500, role="coding")
if result is None:
return None
# Reconstituer le raisonnement structuré
@@ -666,45 +603,12 @@ def enrich_acte(acte: ActeCCAM, contexte: dict, cache: OllamaCache | None = None
def _build_prompt_das_extraction(text: str, contexte: dict, existing_das: list[str], dp_texte: str) -> str:
"""Construit le prompt pour l'extraction LLM de DAS supplémentaires."""
ctx_str = format_enriched_context(contexte)
existing_str = "\n".join(f"- {d}" for d in existing_das) if existing_das else "Aucun"
return f"""Tu es un médecin DIM (Département d'Information Médicale) expert en codage PMSI.
Analyse le texte médical suivant et identifie les diagnostics associés significatifs (DAS) qui n'ont PAS encore été codés.
RÈGLES IMPÉRATIVES :
- Un DAS doit avoir mobilisé des ressources supplémentaires pendant le séjour
- Ne PAS proposer de doublons avec les DAS déjà codés ci-dessous
- Ne PAS proposer le diagnostic principal comme DAS
- Ne PAS coder les symptômes (R00-R99) si un diagnostic précis les explique
- Ne PAS coder les antécédents non pertinents pour le séjour
- Privilégie les codes CIM-10 les plus SPÉCIFIQUES (4e ou 5e caractère)
- Ne propose que des diagnostics CLAIREMENT mentionnés dans le texte
- ATTENTION aux valeurs biologiques : ne code PAS un diagnostic si les valeurs sont dans les normes indiquées entre crochets [N: min-max]. Exemple : Créatinine 76 [N: 50-120] = NORMAL, pas d'insuffisance rénale.
DIAGNOSTIC PRINCIPAL : {dp_texte or "Non identifié"}
DAS DÉJÀ CODÉS :
{existing_str}
CONTEXTE CLINIQUE :
{ctx_str}
TEXTE MÉDICAL :
{text[:4000]}
Réponds UNIQUEMENT avec un objet JSON au format suivant, sans aucun texte avant ou après :
{{
"diagnostics_supplementaires": [
{{
"texte": "description du diagnostic",
"code_cim10": "X99.9",
"justification": "pourquoi ce DAS est pertinent pour le séjour"
}}
]
}}
Si aucun DAS supplémentaire n'est pertinent, retourne : {{"diagnostics_supplementaires": []}}"""
return DAS_EXTRACTION.format(
dp_texte=dp_texte or "Non identifié",
existing_str="\n".join(f"- {d}" for d in existing_das) if existing_das else "Aucun",
ctx_str=format_enriched_context(contexte),
text_medical=text[:4000],
)
def extract_das_llm(
@@ -741,7 +645,7 @@ def extract_das_llm(
# Construire le prompt et appeler Ollama
prompt = _build_prompt_das_extraction(text, contexte, existing_das, dp_texte)
result = call_ollama(prompt, temperature=0.1, max_tokens=2000)
result = call_ollama(prompt, temperature=0.1, max_tokens=2000, role="coding")
if result is None:
logger.warning("Extraction DAS LLM : Ollama non disponible")
@@ -766,7 +670,7 @@ def enrich_dossier(dossier: DossierMedical) -> None:
Utilise un cache persistant et parallélise les appels Ollama
pour les DAS et actes CCAM (max_workers = OLLAMA_MAX_PARALLEL).
"""
cache = OllamaCache(OLLAMA_CACHE_PATH, OLLAMA_MODEL)
cache = OllamaCache(OLLAMA_CACHE_PATH, get_model("coding"))
contexte = build_enriched_context(dossier)