feat: règles métier T2A Phase 1 — exclusions diagnostiques, sévérité CMA et alertes codage

Ajout des règles d'exclusion symptôme (R00-R99) vs diagnostic précis (Chapitres I-XIV),
détection heuristique de sévérité CMA sur 25 racines CIM-10, et affichage des alertes
de codage dans le viewer Flask. 153 tests, 0 régression.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-11 08:53:14 +01:00
parent 12f4479cd2
commit 9df4465fef
8 changed files with 911 additions and 42 deletions

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import logging
import re
from typing import Optional
import requests
@@ -15,6 +16,12 @@ logger = logging.getLogger(__name__)
# Singleton pour le modèle d'embedding (chargé une seule fois)
_embed_model = None
# Score minimum de similarité FAISS pour retenir un résultat
_MIN_SCORE = 0.3
# Marqueur de fin de raisonnement dans la réponse Ollama
_RESULT_MARKER = "###RESULT###"
def _get_embed_model():
"""Charge le modèle d'embedding (singleton)."""
@@ -27,7 +34,7 @@ def _get_embed_model():
return _embed_model
def search_similar(query: str, top_k: int = 5) -> list[dict]:
def search_similar(query: str, top_k: int = 10) -> list[dict]:
"""Recherche les passages les plus similaires dans l'index FAISS.
Args:
@@ -35,7 +42,8 @@ def search_similar(query: str, top_k: int = 5) -> list[dict]:
top_k: Nombre de résultats à retourner.
Returns:
Liste de dicts avec les métadonnées + score de similarité.
Liste de dicts avec les métadonnées + score de similarité,
filtrés par score minimum et priorisant les sources CIM-10.
"""
from .rag_index import get_index
import numpy as np
@@ -51,21 +59,93 @@ def search_similar(query: str, top_k: int = 5) -> list[dict]:
query_vec = model.encode([query], normalize_embeddings=True)
query_vec = np.array(query_vec, dtype=np.float32)
scores, indices = faiss_index.search(query_vec, min(top_k, faiss_index.ntotal))
# Chercher plus de résultats que top_k pour pouvoir filtrer ensuite
fetch_k = min(top_k * 2, faiss_index.ntotal)
scores, indices = faiss_index.search(query_vec, fetch_k)
results = []
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)
results.append(meta)
raw_results.append(meta)
return results
# Prioriser les sources CIM-10 (au moins 6 sur top_k)
cim10_results = [r for r in raw_results if r["document"] == "cim10"]
other_results = [r for r in raw_results if r["document"] != "cim10"]
min_cim10 = min(6, len(cim10_results))
final = cim10_results[:min_cim10]
remaining_slots = top_k - len(final)
# Remplir le reste avec les meilleurs résultats (CIM-10 restants + autres)
remaining = cim10_results[min_cim10:] + other_results
remaining.sort(key=lambda r: r["score"], reverse=True)
final.extend(remaining[:remaining_slots])
return final
def _build_prompt(texte: str, sources: list[dict], contexte: dict) -> str:
"""Construit le prompt pour Ollama."""
def _format_contexte(contexte: dict) -> str:
"""Formate le contexte patient de manière structurée pour le prompt."""
lines = []
sexe = contexte.get("sexe")
age = contexte.get("age")
imc = contexte.get("imc")
patient_parts = []
if sexe:
patient_parts.append(sexe)
if age:
patient_parts.append(f"{age} ans")
if imc:
patient_parts.append(f"IMC {imc}")
if patient_parts:
lines.append(f"- Patient : {', '.join(patient_parts)}")
duree = contexte.get("duree_sejour")
if duree:
lines.append(f"- Durée séjour : {duree} jours")
antecedents = contexte.get("antecedents")
if antecedents:
lines.append(f"- Antécédents : {', '.join(antecedents[:5])}")
biologie = contexte.get("biologie_cle")
if biologie:
bio_parts = []
for b in biologie:
test, valeur, anomalie = b if isinstance(b, (list, tuple)) else (b.get("test"), b.get("valeur"), b.get("anomalie"))
marker = " (\u2191)" if anomalie else ""
bio_parts.append(f"{test} {valeur}{marker}")
lines.append(f"- Biologie : {', '.join(bio_parts)}")
imagerie = contexte.get("imagerie")
if imagerie:
for img in imagerie:
img_type, conclusion = img if isinstance(img, (list, tuple)) else (img.get("type"), img.get("conclusion"))
if conclusion:
lines.append(f"- Imagerie : {img_type}{conclusion[:200]}")
complications = contexte.get("complications")
if complications:
lines.append(f"- Complications : {', '.join(complications)}")
dp_texte = contexte.get("dp_texte")
if dp_texte:
lines.append(f"- DP du séjour : {dp_texte}")
das_codes = contexte.get("das_codes_existants")
if das_codes:
lines.append(f"- DAS déjà codés : {', '.join(das_codes)}")
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é."""
sources_text = ""
for i, src in enumerate(sources, 1):
doc_name = {
@@ -80,24 +160,94 @@ def _build_prompt(texte: str, sources: list[dict], contexte: dict) -> str:
sources_text += f"--- Source {i}: {doc_name}{code_info}{page_info} ---\n"
sources_text += (src.get("extrait", "")[:800]) + "\n\n"
ctx_parts = []
if contexte.get("sexe"):
ctx_parts.append(f"sexe: {contexte['sexe']}")
if contexte.get("age"):
ctx_parts.append(f"âge: {contexte['age']} ans")
ctx_str = ", ".join(ctx_parts) if ctx_parts else "non précisé"
type_diag = "DP (diagnostic principal)" if est_dp else "DAS (diagnostic associé significatif)"
ctx_str = _format_contexte(contexte)
return f"""Tu es un expert en codage CIM-10 pour le PMSI en France. Suggère le code CIM-10 le plus précis pour le diagnostic suivant, en te basant UNIQUEMENT sur les sources officielles fournies.
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.
Diagnostic à coder : "{texte}"
Contexte patient : {ctx_str}
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
Sources de référence :
DIAGNOSTIC À CODER : "{texte}"
TYPE : {type_diag}
CONTEXTE CLINIQUE :
{ctx_str}
SOURCES CIM-10 :
{sources_text}
Réponds UNIQUEMENT au format JSON suivant, sans texte avant ou après :
RAISONNE ÉTAPE PAR ÉTAPE :
1. ANALYSE CLINIQUE : Que signifie ce diagnostic sur le plan médical ?
2. CODES CANDIDATS : Quels codes des sources fournies sont compatibles ?
3. DISCRIMINATION : Pourquoi choisir un code plutôt qu'un autre ? (inclusions/exclusions, spécificité)
4. RÈGLE PMSI : Ce code est-il conforme pour un {type_diag} ? (guide méthodologique)
Après ton raisonnement, conclus OBLIGATOIREMENT par le JSON suivant sur une ligne séparée :
{_RESULT_MARKER}
{{"code": "X99.9", "confidence": "high|medium|low", "justification": "explication courte en français"}}"""
def _parse_ollama_response(raw: str) -> dict | None:
"""Parse la réponse Ollama en extrayant le JSON après le marqueur ###RESULT###.
Fallback sur la recherche d'accolades si le marqueur est absent.
Retourne un dict avec les clés code/confidence/justification + raisonnement.
"""
raisonnement = None
json_str = None
# Stratégie 1 : chercher le marqueur ###RESULT###
marker_pos = raw.find(_RESULT_MARKER)
if marker_pos != -1:
raisonnement = raw[:marker_pos].strip()
after_marker = raw[marker_pos + len(_RESULT_MARKER):]
brace_start = after_marker.find("{")
brace_end = after_marker.rfind("}")
if brace_start != -1 and brace_end != -1:
json_str = after_marker[brace_start:brace_end + 1]
else:
# Fallback : chercher le dernier bloc JSON dans la réponse
# (le raisonnement peut contenir des accolades intermédiaires)
last_brace = raw.rfind("}")
if last_brace != -1:
# Chercher l'accolade ouvrante correspondante en remontant
depth = 0
start = -1
for i in range(last_brace, -1, -1):
if raw[i] == "}":
depth += 1
elif raw[i] == "{":
depth -= 1
if depth == 0:
start = i
break
if start != -1:
json_str = raw[start:last_brace + 1]
raisonnement = raw[:start].strip()
if not json_str:
logger.warning("Ollama : réponse sans JSON valide : %s", raw[:200])
return None
try:
parsed = json.loads(json_str)
except json.JSONDecodeError:
logger.warning("Ollama : JSON invalide : %s", json_str[:200])
return None
if raisonnement:
parsed["raisonnement"] = raisonnement
return parsed
def _call_ollama(prompt: str) -> dict | None:
"""Appelle Ollama et parse la réponse JSON."""
try:
@@ -109,27 +259,14 @@ def _call_ollama(prompt: str) -> dict | None:
"stream": False,
"options": {
"temperature": 0.1,
"num_predict": 300,
"num_predict": 1200,
},
},
timeout=OLLAMA_TIMEOUT,
)
response.raise_for_status()
raw = response.json().get("response", "")
# Extraire le JSON de la réponse (peut contenir du texte autour)
json_match = None
# Chercher un bloc JSON entre accolades
brace_start = raw.find("{")
brace_end = raw.rfind("}")
if brace_start != -1 and brace_end != -1:
json_match = raw[brace_start:brace_end + 1]
if json_match:
return json.loads(json_match)
else:
logger.warning("Ollama : réponse sans JSON valide : %s", raw[:200])
return None
return _parse_ollama_response(raw)
except requests.ConnectionError:
logger.warning("Ollama non disponible (connexion refusée)")
@@ -145,13 +282,14 @@ def _call_ollama(prompt: str) -> dict | None:
def enrich_diagnostic(
diagnostic: Diagnostic,
contexte: dict,
est_dp: bool = True,
) -> None:
"""Enrichit un Diagnostic avec le RAG (FAISS + Ollama).
Modifie le diagnostic en place. Fallback gracieux si FAISS ou Ollama échouent.
"""
# 1. Recherche FAISS
sources = search_similar(diagnostic.texte, top_k=5)
sources = search_similar(diagnostic.texte, top_k=10)
if not sources:
logger.debug("Aucune source RAG trouvée pour : %s", diagnostic.texte)
@@ -168,14 +306,15 @@ def enrich_diagnostic(
for s in sources
]
# 3. Appel Ollama pour justification
prompt = _build_prompt(diagnostic.texte, sources, contexte)
# 3. 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
@@ -183,6 +322,8 @@ def enrich_diagnostic(
diagnostic.cim10_confidence = confidence
if justification:
diagnostic.justification = justification
if raisonnement:
diagnostic.raisonnement = raisonnement
else:
logger.info("Ollama non disponible — sources FAISS conservées sans justification LLM")
@@ -192,12 +333,27 @@ def enrich_dossier(dossier: DossierMedical) -> None:
contexte = {
"sexe": dossier.sejour.sexe,
"age": dossier.sejour.age,
"duree_sejour": dossier.sejour.duree_sejour,
"imc": dossier.sejour.imc,
"antecedents": dossier.antecedents[:5],
"biologie_cle": [(b.test, b.valeur, b.anomalie) for b in dossier.biologie_cle],
"imagerie": [(i.type, (i.conclusion or "")[:200]) for i in dossier.imagerie],
"complications": dossier.complications,
}
if dossier.diagnostic_principal:
logger.info("RAG enrichissement DP : %s", dossier.diagnostic_principal.texte)
enrich_diagnostic(dossier.diagnostic_principal, contexte)
enrich_diagnostic(dossier.diagnostic_principal, contexte, est_dp=True)
# Pour les DAS, ajouter le DP et les DAS existants au contexte pour cohérence
if dossier.diagnostic_principal:
contexte["dp_texte"] = dossier.diagnostic_principal.texte
contexte["das_codes_existants"] = [
f"{d.cim10_suggestion} ({d.texte})"
for d in dossier.diagnostics_associes
if d.cim10_suggestion
]
for das in dossier.diagnostics_associes:
logger.info("RAG enrichissement DAS : %s", das.texte)
enrich_diagnostic(das, contexte)
enrich_diagnostic(das, contexte, est_dp=False)