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

@@ -173,6 +173,32 @@ def lookup(
return None
def normalize_code(code: str) -> str:
"""Normalise un code CIM-10 : K810 → K81.0, k85.1 → K85.1."""
code = code.strip().upper()
# Insérer le point si absent : K810 → K81.0
if len(code) > 3 and "." not in code:
code = code[:3] + "." + code[3:]
return code
def validate_code(code: str) -> tuple[bool, str]:
"""Vérifie si un code CIM-10 existe dans le dictionnaire.
Returns:
(is_valid, label) — label vide si invalide.
"""
d = load_dict()
normalized = normalize_code(code)
if normalized in d:
return True, d[normalized]
# Tenter aussi le code brut (3 caractères sans point)
raw = code.upper().strip()
if raw in d:
return True, d[raw]
return False, ""
def reset_cache() -> None:
"""Réinitialise les caches (utile pour les tests)."""
global _dict_cache, _normalized_cache

View File

@@ -9,7 +9,7 @@ from typing import Optional
logger = logging.getLogger(__name__)
from .cim10_dict import lookup as dict_lookup, normalize_text
from .cim10_dict import lookup as dict_lookup, normalize_text, normalize_code, validate_code as cim10_validate
from .ccam_dict import lookup as ccam_lookup, validate_code as ccam_validate
from .das_filter import clean_diagnostic_text, is_valid_diagnostic_text
from ..config import (
@@ -118,6 +118,9 @@ def extract_medical_info(
# Post-processing : validation des codes CCAM contre le dictionnaire
_validate_ccam(dossier)
# Post-processing : validation des codes CIM-10 contre le dictionnaire
_validate_cim10(dossier)
# Post-processing : exclusions symptôme vs diagnostic précis
_apply_exclusion_rules(dossier)
@@ -663,6 +666,68 @@ def _validate_ccam(dossier: DossierMedical) -> None:
)
_INVALID_CODE_PATTERNS = {"aucun", "none", "n/a", "non_codable", "aucun_code_valide", "inconnu"}
def _fallback_cim10(texte: str) -> str | None:
"""Tente de trouver un code CIM-10 via le dictionnaire à partir du texte diagnostic."""
code = dict_lookup(texte, domain_overrides=CIM10_MAP)
if code:
is_valid, _ = cim10_validate(code)
if is_valid:
return code
return None
def _validate_cim10(dossier: DossierMedical) -> None:
"""Valide les codes CIM-10 suggérés par Ollama contre le dictionnaire."""
diags: list[tuple[str, Diagnostic]] = []
if dossier.diagnostic_principal:
diags.append(("DP", dossier.diagnostic_principal))
for das in dossier.diagnostics_associes:
diags.append(("DAS", das))
for type_diag, diag in diags:
if not diag.cim10_suggestion:
continue
# Rejeter les hallucinations
if diag.cim10_suggestion.lower().strip() in _INVALID_CODE_PATTERNS:
fallback = _fallback_cim10(diag.texte)
if fallback:
dossier.alertes_codage.append(
f"CIM-10 {type_diag} ({diag.texte}) : code rejeté « {diag.cim10_suggestion} » → fallback {fallback}"
)
diag.cim10_suggestion = fallback
diag.cim10_confidence = "medium"
else:
dossier.alertes_codage.append(
f"CIM-10 {type_diag} ({diag.texte}) : code rejeté « {diag.cim10_suggestion} »"
)
diag.cim10_suggestion = None
diag.cim10_confidence = None
continue
# Normaliser le format (K810 → K81.0)
diag.cim10_suggestion = normalize_code(diag.cim10_suggestion)
# Valider contre le dictionnaire
is_valid, label = cim10_validate(diag.cim10_suggestion)
if not is_valid:
fallback = _fallback_cim10(diag.texte)
if fallback:
dossier.alertes_codage.append(
f"CIM-10 {type_diag} {diag.cim10_suggestion} ({diag.texte}) : code invalide → fallback {fallback}"
)
diag.cim10_suggestion = fallback
diag.cim10_confidence = "medium"
else:
dossier.alertes_codage.append(
f"CIM-10 {type_diag} {diag.cim10_suggestion} ({diag.texte}) : code absent du dictionnaire CIM-10"
)
diag.cim10_confidence = "low"
def _find_act_date(text: str, act_pattern: str) -> str | None:
"""Trouve la date associée à un acte."""
# Chercher "acte le DD/MM" ou "acte le DD/MM/YYYY"
@@ -705,7 +770,7 @@ def _apply_severity_rules(dossier: DossierMedical) -> None:
"""Enrichit les diagnostics avec les informations de sévérité heuristique."""
try:
from .severity import enrich_dossier_severity
alertes = enrich_dossier_severity(
alertes, _cma_count, _cms_count = enrich_dossier_severity(
dossier.diagnostic_principal, dossier.diagnostics_associes,
)
dossier.alertes_codage.extend(alertes)

View File

@@ -33,9 +33,12 @@ def is_valid_diagnostic_text(text: str) -> bool:
if re.match(r"^([a-zà-ÿ]{3,})\1+[a-zà-ÿ]*$", t, re.IGNORECASE):
return False
# 5. Mots répétés ≥ 3 fois : "Spontanée spontanée spontanée spontanée"
# 5. Mots répétés : tous identiques ("Absence absence", "Anticoagulant anticoagulant")
# ou ≥ 3 occurrences du même mot
words = t.lower().split()
if words:
if len(words) >= 2:
if len(set(words)) == 1:
return False
from collections import Counter
counts = Counter(words)
if counts.most_common(1)[0][1] >= 3:
@@ -47,4 +50,27 @@ def is_valid_diagnostic_text(text: str) -> bool:
if t in {"Isolement", "Pp 500"}:
return False
# 7. Ponctuation initiale (artefacts OCR) : ", sans précision"
if re.match(r'^[,.\-;:!)\]]\s', t):
return False
# 8. Pattern "À X.X" / "A X.X" (valeurs numériques OCR)
if re.match(r'^[ÀA]\s+\d+([.,]\d+)?$', t):
return False
# 9. Crochets (artefacts OCR) : "Episode [episode"
if '[' in t or ']' in t:
return False
# 10. Termes de laboratoire isolés (un seul mot ≠ diagnostic)
_LAB_TERMS = {"hémoglobine", "créatinine", "plaquettes", "leucocytes", "glycémie",
"natrémie", "kaliémie", "calcémie", "bilirubine", "albumine",
"fibrinogène", "hématocrite", "cétonurie", "glycosurie"}
if t.lower() in _LAB_TERMS:
return False
# 11. Fragments anatomiques courts sans pathologie : "Dans la vessie", "Le rein"
if re.match(r'^(Dans |La |Le |Les |Au |Aux )', t) and len(t) < 30:
return False
return True

215
src/medical/ghm.py Normal file
View File

@@ -0,0 +1,215 @@
"""Estimation heuristique du GHM (Groupe Homogène de Malades).
L'algorithme officiel (ATIH FG-MCO) est propriétaire. Ce module fournit une
estimation approximative utile comme pré-codage / aide au DIM :
1. CMD depuis le DP (table de plages CIM-10)
2. Type de prise en charge depuis les actes CCAM
3. Sévérité depuis les CMA/CMS
4. Construction du code GHM approximatif
"""
from __future__ import annotations
import bisect
from typing import Optional
from ..config import DossierMedical, GHMEstimation
# ---------------------------------------------------------------------------
# Table CIM-10 → CMD (Catégorie Majeure de Diagnostic)
# Triée par borne inférieure pour lookup par bisect.
# Format : (debut, fin, cmd, libelle)
# ---------------------------------------------------------------------------
_CMD_RANGES: list[tuple[str, str, str, str]] = [
("A00", "A99", "18", "Maladies infectieuses et parasitaires"),
("B00", "B19", "18", "Maladies infectieuses et parasitaires"),
("B20", "B24", "25", "Maladies dues au VIH"),
("B25", "B99", "18", "Maladies infectieuses et parasitaires"),
("C00", "C97", "17", "Tumeurs malignes"),
("D00", "D09", "17", "Tumeurs malignes"),
("D10", "D48", "16", "Tumeurs bénignes, hémopathies"),
("D50", "D89", "16", "Tumeurs bénignes, hémopathies"),
("E00", "E07", "10", "Maladies endocriniennes"),
("E10", "E14", "10", "Maladies endocriniennes"),
("E15", "E46", "10", "Maladies endocriniennes"),
("E47", "E90", "10", "Maladies endocriniennes"),
("F00", "F09", "19", "Maladies mentales"),
("F10", "F19", "20", "Troubles mentaux liés à l'alcool et aux toxiques"),
("F20", "F99", "19", "Maladies mentales"),
("G00", "G99", "01", "Affections du système nerveux"),
("H00", "H59", "02", "Affections de l'oeil"),
("H60", "H95", "03", "Affections ORL"),
("I00", "I99", "05", "Affections de l'appareil circulatoire"),
("J00", "J99", "04", "Affections de l'appareil respiratoire"),
("K00", "K67", "06", "Affections du tube digestif"),
("K70", "K87", "07", "Affections hépatobiliaires et pancréatiques"),
("K90", "K93", "06", "Affections du tube digestif"),
("L00", "L99", "09", "Affections de la peau"),
("M00", "M99", "08", "Affections du système ostéo-articulaire"),
("N00", "N39", "11", "Affections du rein et des voies urinaires"),
("N40", "N51", "12", "Affections de l'appareil génital masculin"),
("N60", "N98", "13", "Affections de l'appareil génital féminin"),
("N99", "N99", "11", "Affections du rein et des voies urinaires"),
("O00", "O99", "14", "Grossesses, accouchements, post-partum"),
("P00", "P96", "15", "Nouveau-nés, période périnatale"),
("Q00", "Q99", "15", "Nouveau-nés, période périnatale"),
("R00", "R99", "23", "Facteurs influençant l'état de santé (symptômes)"),
("S00", "S99", "21", "Traumatismes"),
("T00", "T19", "21", "Traumatismes"),
("T20", "T32", "22", "Brûlures"),
("T33", "T98", "21", "Traumatismes"),
("U00", "U99", "26", "Catégories spéciales"),
("V00", "Y98", "24", "Causes externes"),
("Z00", "Z99", "23", "Facteurs influençant l'état de santé"),
]
# Pré-calcul : liste triée des bornes inférieures pour bisect
_CMD_STARTS = [r[0] for r in _CMD_RANGES]
def find_cmd(code_cim10: str) -> tuple[Optional[str], Optional[str]]:
"""Trouve la CMD correspondant à un code CIM-10.
Returns:
(cmd, libelle) ou (None, None) si non trouvé.
"""
if not code_cim10:
return None, None
# Normaliser : majuscules, retirer le point
code = code_cim10.upper().replace(".", "").strip()
if len(code) < 3:
return None, None
# Prendre les 3 premiers caractères pour le lookup
code3 = code[:3]
# bisect pour trouver la plage candidate
idx = bisect.bisect_right(_CMD_STARTS, code3) - 1
if idx < 0:
return None, None
debut, fin, cmd, libelle = _CMD_RANGES[idx]
if debut <= code3 <= fin:
return cmd, libelle
return None, None
# ---------------------------------------------------------------------------
# Préfixes CCAM classants (chirurgicaux)
# Les codes CCAM commençant par ces lettres correspondent à des organes
# et sont considérés chirurgicaux quand ils désignent un acte opératoire.
# ---------------------------------------------------------------------------
_CCAM_CHIRURGICAL_PREFIXES = {"H", "J", "K", "L", "N", "P", "Q"}
# Préfixes interventionnels (imagerie, endoscopie)
_CCAM_INTERVENTIONNEL_PREFIXES = {"Z", "Y"}
def _detect_type_ghm(actes_ccam: list) -> str:
"""Détermine le type de prise en charge depuis les actes CCAM.
Returns:
"C" (chirurgical), "K" (interventionnel) ou "M" (médical).
"""
has_chirurgical = False
has_interventionnel = False
for acte in actes_ccam:
code = acte.code_ccam_suggestion
if not code or len(code) < 4:
continue
prefix = code[0].upper()
if prefix in _CCAM_CHIRURGICAL_PREFIXES:
has_chirurgical = True
break
if prefix in _CCAM_INTERVENTIONNEL_PREFIXES:
has_interventionnel = True
if has_chirurgical:
return "C"
if has_interventionnel:
return "K"
return "M"
def _compute_severity(das_list: list) -> tuple[int, int, int]:
"""Calcule le niveau de sévérité à partir des DAS.
Returns:
(niveau, cma_count, cms_count)
"""
cma_count = 0
cms_count = 0
for das in das_list:
if getattr(das, "est_cma", False):
cma_count += 1
if getattr(das, "est_cms", False):
cms_count += 1
if cms_count >= 2:
niveau = 4
elif cms_count >= 1 or cma_count >= 3:
niveau = 3
elif cma_count >= 2:
niveau = 2
else:
niveau = 1
return niveau, cma_count, cms_count
def estimate_ghm(dossier: DossierMedical) -> GHMEstimation:
"""Estime le GHM d'un dossier médical.
Heuristique en 4 étapes :
1. CMD depuis le DP
2. Type de prise en charge depuis les actes CCAM
3. Sévérité depuis les CMA/CMS
4. Construction du code approximatif
"""
estimation = GHMEstimation()
# 1. CMD depuis le DP
dp = dossier.diagnostic_principal
dp_code = dp.cim10_suggestion if dp else None
if not dp:
estimation.alertes.append("DP absent — CMD non déterminable")
elif not dp_code:
estimation.alertes.append("DP sans code CIM-10 — CMD non déterminable")
else:
cmd, libelle = find_cmd(dp_code)
if cmd:
estimation.cmd = cmd
estimation.cmd_libelle = libelle
else:
estimation.alertes.append(f"CMD inconnue pour le code {dp_code}")
# Alerte DP symptomatique
code_letter = dp_code.upper().replace(".", "").strip()[:1]
if code_letter in ("R", "Z"):
estimation.alertes.append(
f"DP symptomatique ({dp_code}) — risque de CMD 23, impact tarif"
)
# 2. Type de prise en charge
estimation.type_ghm = _detect_type_ghm(dossier.actes_ccam)
# 3. Sévérité
niveau, cma_count, cms_count = _compute_severity(dossier.diagnostics_associes)
estimation.severite = niveau
estimation.cma_count = cma_count
estimation.cms_count = cms_count
# 4. Code approximatif
if estimation.cmd and estimation.type_ghm:
estimation.ghm_approx = f"{estimation.cmd}{estimation.type_ghm}??{estimation.severite}"
return estimation

View File

@@ -0,0 +1,85 @@
"""Cache persistant thread-safe pour les résultats Ollama."""
from __future__ import annotations
import json
import logging
import threading
from pathlib import Path
logger = logging.getLogger(__name__)
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é.
"""
def __init__(self, cache_path: Path, model: str):
self._path = cache_path
self._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."""
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,
)
return
self._data = raw.get("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)
self._data = {}
@staticmethod
def _make_key(texte: str, diag_type: str) -> str:
"""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."""
key = self._make_key(texte, diag_type)
with self._lock:
return self._data.get(key)
def put(self, texte: str, diag_type: str, result: dict) -> None:
"""Stocke un résultat dans le cache."""
key = self._make_key(texte, diag_type)
with self._lock:
self._data[key] = result
self._dirty = True
def save(self) -> None:
"""Persiste le cache sur disque si modifié."""
with self._lock:
if not self._dirty:
return
self._path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"model": self._model,
"entries": self._data,
}
self._path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
self._dirty = False
logger.info("Cache Ollama : %d entrées sauvegardées", len(self._data))
def __len__(self) -> int:
with self._lock:
return len(self._data)

View File

@@ -0,0 +1,80 @@
"""Client Ollama partagé — appel LLM en mode JSON natif."""
from __future__ import annotations
import json
import logging
import requests
from ..config import OLLAMA_URL, OLLAMA_MODEL, OLLAMA_TIMEOUT
logger = logging.getLogger(__name__)
def parse_json_response(raw: str) -> dict | None:
"""Parse une réponse JSON d'Ollama, en gérant les blocs markdown."""
text = raw.strip()
if text.startswith("```"):
first_nl = text.find("\n")
if first_nl != -1:
text = text[first_nl + 1:]
if text.rstrip().endswith("```"):
text = text.rstrip()[:-3]
text = text.strip()
try:
return json.loads(text)
except json.JSONDecodeError:
logger.warning("Ollama : JSON invalide : %s", raw[:200])
return None
def call_ollama(
prompt: str,
temperature: float = 0.1,
max_tokens: int = 2500,
) -> dict | None:
"""Appelle Ollama en mode JSON natif avec retry.
Args:
prompt: Le prompt à envoyer.
temperature: Température de génération (défaut: 0.1).
max_tokens: Nombre max de tokens (défaut: 2500).
Returns:
Le dict JSON parsé, ou None en cas d'erreur.
"""
for attempt in range(2):
try:
response = requests.post(
f"{OLLAMA_URL}/api/generate",
json={
"model": OLLAMA_MODEL,
"prompt": prompt,
"stream": False,
"format": "json",
"options": {
"temperature": temperature,
"num_predict": max_tokens,
},
},
timeout=OLLAMA_TIMEOUT,
)
response.raise_for_status()
raw = response.json().get("response", "")
result = parse_json_response(raw)
if result is not None:
return result
if attempt == 0:
logger.info("Ollama : retry après échec de parsing")
except requests.ConnectionError:
logger.warning("Ollama non disponible (connexion refusée)")
return None
except requests.Timeout:
logger.warning("Ollama timeout après %ds", OLLAMA_TIMEOUT)
return None
except (requests.RequestException, json.JSONDecodeError) as e:
logger.warning("Ollama erreur : %s", e)
return None
return None

View File

@@ -2,12 +2,17 @@
from __future__ import annotations
import json
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
from ..config import Diagnostic, DossierMedical, RAGSource, OLLAMA_URL, OLLAMA_MODEL, OLLAMA_TIMEOUT
from ..config import (
ActeCCAM, Diagnostic, DossierMedical, RAGSource,
OLLAMA_CACHE_PATH, OLLAMA_MAX_PARALLEL, OLLAMA_MODEL,
)
from .cim10_dict import normalize_code, validate_code as cim10_validate
from .ccam_dict import validate_code as ccam_validate
from .ollama_client import call_ollama, parse_json_response
from .ollama_cache import OllamaCache
logger = logging.getLogger(__name__)
@@ -85,6 +90,52 @@ def search_similar(query: str, top_k: int = 10) -> list[dict]:
return final
def search_similar_ccam(query: str, top_k: int = 8) -> list[dict]:
"""Recherche les passages CCAM les plus similaires dans l'index FAISS.
Même logique que search_similar() mais priorise les sources CCAM.
"""
from .rag_index import get_index
import numpy as np
result = get_index()
if result is None:
logger.warning("Index FAISS non disponible")
return []
faiss_index, metadata = result
model = _get_embed_model()
query_vec = model.encode([query], normalize_embeddings=True)
query_vec = np.array(query_vec, dtype=np.float32)
fetch_k = min(top_k * 2, faiss_index.ntotal)
scores, indices = faiss_index.search(query_vec, fetch_k)
raw_results = []
for score, idx in zip(scores[0], indices[0]):
if idx < 0:
continue
if float(score) < _MIN_SCORE:
continue
meta = metadata[idx].copy()
meta["score"] = float(score)
raw_results.append(meta)
# Prioriser les sources CCAM (au moins 5 sur top_k)
ccam_results = [r for r in raw_results if r["document"] == "ccam"]
other_results = [r for r in raw_results if r["document"] != "ccam"]
min_ccam = min(5, len(ccam_results))
final = ccam_results[:min_ccam]
remaining_slots = top_k - len(final)
remaining = ccam_results[min_ccam:] + other_results
remaining.sort(key=lambda r: r["score"], reverse=True)
final.extend(remaining[:remaining_slots])
return final
def _format_contexte(contexte: dict) -> str:
"""Formate le contexte patient de manière structurée pour le prompt."""
lines = []
@@ -193,31 +244,63 @@ Réponds UNIQUEMENT avec un objet JSON au format suivant, sans aucun texte avant
}}"""
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_contexte(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"
}}"""
def _parse_ollama_response(raw: str) -> dict | None:
"""Parse la réponse JSON d'Ollama (mode JSON).
Reconstitue le raisonnement à partir des champs structurés.
"""
# Stripper les blocs markdown ```json ... ``` que certains modèles ajoutent
text = raw.strip()
if text.startswith("```"):
first_nl = text.find("\n")
if first_nl != -1:
text = text[first_nl + 1:]
# Retirer la fence fermante seulement si elle existe en fin de texte
if text.rstrip().endswith("```"):
text = text.rstrip()[:-3]
text = text.strip()
try:
parsed = json.loads(text)
except json.JSONDecodeError:
logger.warning("Ollama : JSON invalide : %s", raw[:200])
"""Parse la réponse JSON d'Ollama et reconstitue le raisonnement structuré."""
parsed = parse_json_response(raw)
if parsed is None:
return None
# Reconstituer le raisonnement à partir des champs structurés
reasoning_parts = []
for key in ("analyse_clinique", "codes_candidats", "discrimination", "regle_pmsi"):
for key in ("analyse_clinique", "analyse_acte", "codes_candidats", "discrimination", "regle_pmsi"):
val = parsed.pop(key, None)
if val:
titre = key.replace("_", " ").upper()
@@ -229,59 +312,70 @@ def _parse_ollama_response(raw: str) -> dict | None:
def _call_ollama(prompt: str) -> dict | None:
"""Appelle Ollama (mode JSON) et parse la réponse. Retry une fois si parsing échoue."""
for attempt in range(2):
try:
response = requests.post(
f"{OLLAMA_URL}/api/generate",
json={
"model": OLLAMA_MODEL,
"prompt": prompt,
"stream": False,
"format": "json",
"options": {
"temperature": 0.1,
"num_predict": 2500,
},
},
timeout=OLLAMA_TIMEOUT,
"""Appelle Ollama (mode JSON) et parse la réponse avec reconstitution du raisonnement."""
result = call_ollama(prompt, temperature=0.1, max_tokens=2500)
if result is None:
return None
# Reconstituer le raisonnement structuré
reasoning_parts = []
for key in ("analyse_clinique", "analyse_acte", "codes_candidats", "discrimination", "regle_pmsi"):
val = result.pop(key, None)
if val:
titre = key.replace("_", " ").upper()
reasoning_parts.append(f"{titre} :\n{val}")
if reasoning_parts:
result["raisonnement"] = "\n\n".join(reasoning_parts)
return result
def _apply_llm_result_diagnostic(diagnostic: Diagnostic, llm_result: dict) -> None:
"""Applique un résultat LLM (frais ou caché) à un Diagnostic."""
code = llm_result.get("code")
confidence = llm_result.get("confidence")
justification = llm_result.get("justification")
raisonnement = llm_result.get("raisonnement")
if code:
code = normalize_code(code)
is_valid, _ = cim10_validate(code)
if is_valid:
diagnostic.cim10_suggestion = code
else:
logger.warning(
"RAG : code Ollama %s invalide pour « %s », code ignoré",
code, diagnostic.texte,
)
response.raise_for_status()
raw = response.json().get("response", "")
result = _parse_ollama_response(raw)
if result is not None:
return result
if attempt == 0:
logger.info("Ollama : retry après échec de parsing")
except requests.ConnectionError:
logger.warning("Ollama non disponible (connexion refusée)")
return None
except requests.Timeout:
logger.warning("Ollama timeout après %ds", OLLAMA_TIMEOUT)
return None
except (requests.RequestException, json.JSONDecodeError) as e:
logger.warning("Ollama erreur : %s", e)
return None
return None
if confidence in ("high", "medium", "low"):
diagnostic.cim10_confidence = confidence
if justification:
diagnostic.justification = justification
if raisonnement:
diagnostic.raisonnement = raisonnement
def enrich_diagnostic(
diagnostic: Diagnostic,
contexte: dict,
est_dp: bool = True,
cache: OllamaCache | None = None,
) -> None:
"""Enrichit un Diagnostic avec le RAG (FAISS + Ollama).
Modifie le diagnostic en place. Fallback gracieux si FAISS ou Ollama échouent.
"""
# 1. Recherche FAISS
diag_type = "dp" if est_dp else "das"
# 1. Vérifier le cache
cached = cache.get(diagnostic.texte, diag_type) if cache else None
# 2. Recherche FAISS (toujours, pour les sources_rag fraîches)
sources = search_similar(diagnostic.texte, top_k=10)
if not sources:
logger.debug("Aucune source RAG trouvée pour : %s", diagnostic.texte)
return
# 2. Stocker les sources RAG
# 3. Stocker les sources RAG
diagnostic.sources_rag = [
RAGSource(
document=s["document"],
@@ -292,30 +386,101 @@ def enrich_diagnostic(
for s in sources
]
# 3. Appel Ollama pour justification avec raisonnement structuré
# 4. Si cache hit, appliquer et court-circuiter Ollama
if cached is not None:
logger.info("Cache hit pour %s : « %s »", diag_type.upper(), diagnostic.texte)
_apply_llm_result_diagnostic(diagnostic, cached)
return
# 5. Appel Ollama pour justification avec raisonnement structuré
prompt = _build_prompt(diagnostic.texte, sources, contexte, est_dp=est_dp)
llm_result = _call_ollama(prompt)
if llm_result:
code = llm_result.get("code")
confidence = llm_result.get("confidence")
justification = llm_result.get("justification")
raisonnement = llm_result.get("raisonnement")
if code:
diagnostic.cim10_suggestion = code
if confidence in ("high", "medium", "low"):
diagnostic.cim10_confidence = confidence
if justification:
diagnostic.justification = justification
if raisonnement:
diagnostic.raisonnement = raisonnement
_apply_llm_result_diagnostic(diagnostic, llm_result)
if cache:
cache.put(diagnostic.texte, diag_type, llm_result)
else:
logger.info("Ollama non disponible — sources FAISS conservées sans justification LLM")
def _apply_llm_result_acte(acte: ActeCCAM, llm_result: dict) -> None:
"""Applique un résultat LLM (frais ou caché) à un ActeCCAM."""
code = llm_result.get("code")
confidence = llm_result.get("confidence")
justification = llm_result.get("justification")
raisonnement = llm_result.get("raisonnement")
if code:
code = code.strip().upper()
is_valid, _ = ccam_validate(code)
if is_valid:
acte.code_ccam_suggestion = code
else:
logger.warning(
"RAG : code CCAM Ollama %s invalide pour « %s », code ignoré",
code, acte.texte,
)
if confidence in ("high", "medium", "low"):
acte.ccam_confidence = confidence
if justification:
acte.justification = justification
if raisonnement:
acte.raisonnement = raisonnement
def enrich_acte(acte: ActeCCAM, contexte: dict, cache: OllamaCache | None = None) -> None:
"""Enrichit un ActeCCAM avec le RAG (FAISS + Ollama).
Modifie l'acte en place. Fallback gracieux si FAISS ou Ollama échouent.
"""
# 1. Vérifier le cache
cached = cache.get(acte.texte, "ccam") if cache else None
# 2. Recherche FAISS (sources CCAM priorisées)
sources = search_similar_ccam(acte.texte, top_k=8)
if not sources:
logger.debug("Aucune source RAG CCAM trouvée pour : %s", acte.texte)
return
# 3. Stocker les sources RAG
acte.sources_rag = [
RAGSource(
document=s["document"],
page=s.get("page"),
code=s.get("code"),
extrait=s.get("extrait", "")[:200],
)
for s in sources
]
# 4. Si cache hit, appliquer et court-circuiter Ollama
if cached is not None:
logger.info("Cache hit pour CCAM : « %s »", acte.texte)
_apply_llm_result_acte(acte, cached)
return
# 5. Appel Ollama pour justification avec raisonnement structuré
prompt = _build_prompt_ccam(acte.texte, sources, contexte)
llm_result = _call_ollama(prompt)
if llm_result:
_apply_llm_result_acte(acte, llm_result)
if cache:
cache.put(acte.texte, "ccam", llm_result)
else:
logger.info("Ollama non disponible — sources FAISS CCAM conservées sans justification LLM")
def enrich_dossier(dossier: DossierMedical) -> None:
"""Enrichit le DP et tous les DAS d'un dossier via le RAG."""
"""Enrichit le DP et tous les DAS d'un dossier via le RAG.
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)
contexte = {
"sexe": dossier.sejour.sexe,
"age": dossier.sejour.age,
@@ -327,11 +492,12 @@ def enrich_dossier(dossier: DossierMedical) -> None:
"complications": dossier.complications,
}
# Phase 1 : DP seul (le contexte DAS en dépend)
if dossier.diagnostic_principal:
logger.info("RAG enrichissement DP : %s", dossier.diagnostic_principal.texte)
enrich_diagnostic(dossier.diagnostic_principal, contexte, est_dp=True)
enrich_diagnostic(dossier.diagnostic_principal, contexte, est_dp=True, cache=cache)
# Pour les DAS, ajouter le DP et les DAS existants au contexte pour cohérence
# Mettre à jour le contexte avec le DP pour les DAS
if dossier.diagnostic_principal:
contexte["dp_texte"] = dossier.diagnostic_principal.texte
contexte["das_codes_existants"] = [
@@ -340,6 +506,20 @@ def enrich_dossier(dossier: DossierMedical) -> None:
if d.cim10_suggestion
]
for das in dossier.diagnostics_associes:
logger.info("RAG enrichissement DAS : %s", das.texte)
enrich_diagnostic(das, contexte, est_dp=False)
# Phase 2 : DAS + Actes en parallèle
das_list = dossier.diagnostics_associes
actes_list = dossier.actes_ccam
if das_list or actes_list:
with ThreadPoolExecutor(max_workers=OLLAMA_MAX_PARALLEL) as executor:
futures = []
for das in das_list:
logger.info("RAG enrichissement DAS : %s", das.texte)
futures.append(executor.submit(enrich_diagnostic, das, contexte, False, cache))
for acte in actes_list:
logger.info("RAG enrichissement CCAM : %s", acte.texte)
futures.append(executor.submit(enrich_acte, acte, contexte, cache))
for f in as_completed(futures):
f.result() # propage les exceptions
cache.save()

View File

@@ -158,7 +158,7 @@ def evaluate_severity(diagnostic) -> SeverityInfo:
return info
def enrich_dossier_severity(dp, das_list: list) -> list[str]:
def enrich_dossier_severity(dp, das_list: list) -> tuple[list[str], int, int]:
"""Enrichit les diagnostics d'un dossier avec les informations de sévérité.
Modifie les diagnostics en place (attributs est_cma, est_cms, niveau_severite).
@@ -168,7 +168,7 @@ def enrich_dossier_severity(dp, das_list: list) -> list[str]:
das_list: Liste des diagnostics associés.
Returns:
Liste d'alertes de sévérité générées.
(alertes, cma_count, cms_count).
"""
alertes = []
@@ -181,6 +181,7 @@ def enrich_dossier_severity(dp, das_list: list) -> list[str]:
# Évaluer chaque DAS
cma_count = 0
cms_count = 0
for das in das_list:
if not das.cim10_suggestion:
continue
@@ -189,6 +190,10 @@ def enrich_dossier_severity(dp, das_list: list) -> list[str]:
if info.est_cma_probable:
das.est_cma = True
cma_count += 1
# CMS = CMA sévère
if info.niveau_severite == "severe":
das.est_cms = True
cms_count += 1
alertes.append(
f"CMA probable : '{das.texte}' ({das.cim10_suggestion}) — "
f"sévérité {info.niveau_severite}"
@@ -198,4 +203,4 @@ def enrich_dossier_severity(dp, das_list: list) -> list[str]:
if cma_count >= 2:
alertes.insert(0, f"{cma_count} CMA probables détectées — impact potentiel sur le niveau de sévérité GHM")
return alertes
return alertes, cma_count, cms_count