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:
@@ -74,12 +74,17 @@ class Diagnostic(BaseModel):
|
|||||||
justification: Optional[str] = None
|
justification: Optional[str] = None
|
||||||
raisonnement: Optional[str] = None
|
raisonnement: Optional[str] = None
|
||||||
sources_rag: list[RAGSource] = Field(default_factory=list)
|
sources_rag: list[RAGSource] = Field(default_factory=list)
|
||||||
|
est_cma: Optional[bool] = None
|
||||||
|
est_cms: Optional[bool] = None
|
||||||
|
niveau_severite: Optional[str] = None # "leger" | "modere" | "severe" | "non_evalue"
|
||||||
|
|
||||||
|
|
||||||
class ActeCCAM(BaseModel):
|
class ActeCCAM(BaseModel):
|
||||||
texte: str
|
texte: str
|
||||||
code_ccam_suggestion: Optional[str] = None
|
code_ccam_suggestion: Optional[str] = None
|
||||||
date: Optional[str] = None
|
date: Optional[str] = None
|
||||||
|
validite: Optional[str] = None # "valide" | "obsolete" | "non_verifie"
|
||||||
|
alertes: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class Traitement(BaseModel):
|
class Traitement(BaseModel):
|
||||||
@@ -112,6 +117,7 @@ class DossierMedical(BaseModel):
|
|||||||
biologie_cle: list[BiologieCle] = Field(default_factory=list)
|
biologie_cle: list[BiologieCle] = Field(default_factory=list)
|
||||||
imagerie: list[Imagerie] = Field(default_factory=list)
|
imagerie: list[Imagerie] = Field(default_factory=list)
|
||||||
complications: list[str] = Field(default_factory=list)
|
complications: list[str] = Field(default_factory=list)
|
||||||
|
alertes_codage: list[str] = Field(default_factory=list)
|
||||||
processing_time_s: float | None = None
|
processing_time_s: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,12 @@ def extract_medical_info(
|
|||||||
if use_rag:
|
if use_rag:
|
||||||
_enrich_with_rag(dossier)
|
_enrich_with_rag(dossier)
|
||||||
|
|
||||||
|
# Post-processing : exclusions symptôme vs diagnostic précis
|
||||||
|
_apply_exclusion_rules(dossier)
|
||||||
|
|
||||||
|
# Post-processing : enrichissement sévérité (CMA/CMS heuristique)
|
||||||
|
_apply_severity_rules(dossier)
|
||||||
|
|
||||||
return dossier
|
return dossier
|
||||||
|
|
||||||
|
|
||||||
@@ -641,6 +647,34 @@ def _find_act_date(text: str, act_pattern: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_exclusion_rules(dossier: DossierMedical) -> None:
|
||||||
|
"""Applique les règles d'exclusion symptôme vs diagnostic précis."""
|
||||||
|
try:
|
||||||
|
from .exclusion_rules import check_exclusions
|
||||||
|
result = check_exclusions(dossier.diagnostic_principal, dossier.diagnostics_associes)
|
||||||
|
dossier.diagnostics_associes = result.cleaned_das
|
||||||
|
dossier.alertes_codage.extend(result.warnings)
|
||||||
|
if result.excluded:
|
||||||
|
logger.info(
|
||||||
|
" Exclusions : %d DAS symptomatiques exclus",
|
||||||
|
len(result.excluded),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Erreur lors de l'application des règles d'exclusion", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
||||||
|
dossier.diagnostic_principal, dossier.diagnostics_associes,
|
||||||
|
)
|
||||||
|
dossier.alertes_codage.extend(alertes)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Erreur lors de l'évaluation de sévérité", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
def _lookup_cim10(text: str) -> str | None:
|
def _lookup_cim10(text: str) -> str | None:
|
||||||
"""Cherche un code CIM-10 pour un texte donné.
|
"""Cherche un code CIM-10 pour un texte donné.
|
||||||
|
|
||||||
|
|||||||
169
src/medical/exclusion_rules.py
Normal file
169
src/medical/exclusion_rules.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""Règles d'exclusion diagnostique : symptôme (Chapitre XVIII) vs diagnostic précis.
|
||||||
|
|
||||||
|
Lorsqu'un symptôme (R00-R99) et un diagnostic précis (Chapitres I-XIV, A00-N99)
|
||||||
|
coexistent et que le symptôme est expliqué par le diagnostic précis, le symptôme
|
||||||
|
ne doit PAS être codé comme DAS (règle ATIH de non-redondance).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
def is_symptom_code(code: str) -> bool:
|
||||||
|
"""Vérifie si un code CIM-10 appartient au Chapitre XVIII (R00-R99 = Symptômes)."""
|
||||||
|
if not code:
|
||||||
|
return False
|
||||||
|
return bool(re.match(r"^R\d{2}", code, re.IGNORECASE))
|
||||||
|
|
||||||
|
|
||||||
|
def is_precise_diagnosis(code: str) -> bool:
|
||||||
|
"""Vérifie si un code CIM-10 appartient aux Chapitres I-XIV (A00-N99)."""
|
||||||
|
if not code:
|
||||||
|
return False
|
||||||
|
return bool(re.match(r"^[A-N]\d{2}", code, re.IGNORECASE))
|
||||||
|
|
||||||
|
|
||||||
|
# Mapping R-code → set de codes précis qui excluent le symptôme.
|
||||||
|
# Chaque R-code est exclu si l'un des codes précis (ou un code commençant par
|
||||||
|
# l'une de ces racines) est présent parmi les diagnostics du séjour.
|
||||||
|
EXCLUSION_MAP: dict[str, set[str]] = {
|
||||||
|
# R10 — Douleur abdominale → exclu par pathologies digestives précises
|
||||||
|
"R10": {"K35", "K80", "K81", "K83", "K85", "K86", "K56", "K57", "K25", "K26", "K29"},
|
||||||
|
"R10.1": {"K80", "K81", "K83"}, # Douleur hypochondre droit
|
||||||
|
"R10.3": {"K35", "K36", "K37"}, # Douleur hypogastre
|
||||||
|
"R10.4": {"K35", "K80", "K85", "K56", "K57"}, # Douleur abdominale autre/non précisée
|
||||||
|
|
||||||
|
# R11 — Nausées et vomissements
|
||||||
|
"R11": {"K29", "K80", "K81", "K85", "K56", "K91"},
|
||||||
|
|
||||||
|
# R17 — Ictère → exclu par pathologies hépatobiliaires
|
||||||
|
"R17": {"K80", "K83", "K70", "K71", "K72", "K73", "K74", "B15", "B16", "B17", "B18", "B19", "C22"},
|
||||||
|
|
||||||
|
# R50 — Fièvre → exclu par infections précises
|
||||||
|
"R50": {"A41", "J18", "J15", "J13", "J14", "J06", "N10", "N39", "K81", "K83",
|
||||||
|
"L03", "T81", "A09", "A04"},
|
||||||
|
"R50.9": {"A41", "J18", "J15", "J13", "J14", "N10", "N39", "K81"},
|
||||||
|
|
||||||
|
# R07 — Douleur thoracique → exclu par pathologies cardiaques/pulmonaires
|
||||||
|
"R07": {"I20", "I21", "I22", "I23", "I24", "I25", "I26", "J18", "J93"},
|
||||||
|
"R07.4": {"I20", "I21", "I24", "I25"},
|
||||||
|
|
||||||
|
# R06 — Dyspnée → exclu par pathologies respiratoires/cardiaques
|
||||||
|
"R06": {"J18", "J44", "J45", "J96", "I50", "I26"},
|
||||||
|
"R06.0": {"J18", "J44", "J45", "J96", "I50", "I26"},
|
||||||
|
|
||||||
|
# R31 — Hématurie → exclu par pathologies urologiques/rénales
|
||||||
|
"R31": {"N20", "N13", "C64", "C67", "N02", "N00", "N01"},
|
||||||
|
|
||||||
|
# R04 — Hémoptysie → exclu par pathologies pulmonaires
|
||||||
|
"R04": {"J18", "C34", "I26", "A16"},
|
||||||
|
|
||||||
|
# R63.4 — Perte de poids → exclu par tumeurs, infections chroniques
|
||||||
|
"R63.4": {"C15", "C16", "C18", "C19", "C20", "C22", "C25", "C34", "C50",
|
||||||
|
"A15", "A16", "B20", "B21", "B22", "B23", "B24", "E46"},
|
||||||
|
|
||||||
|
# R00 — Anomalies du rythme cardiaque → exclu par troubles du rythme précis
|
||||||
|
"R00": {"I47", "I48", "I49"},
|
||||||
|
"R00.0": {"I47", "I48"}, # Tachycardie
|
||||||
|
"R00.1": {"I49.5", "I49.8"}, # Bradycardie
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _code_matches(code: str, roots: set[str]) -> bool:
|
||||||
|
"""Vérifie si un code CIM-10 commence par l'une des racines données."""
|
||||||
|
if not code:
|
||||||
|
return False
|
||||||
|
code_upper = code.upper()
|
||||||
|
for root in roots:
|
||||||
|
if code_upper.startswith(root.upper()):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExclusionResult:
|
||||||
|
"""Résultat de l'application des règles d'exclusion."""
|
||||||
|
cleaned_das: list # Diagnostics DAS conservés
|
||||||
|
excluded: list # Diagnostics DAS exclus
|
||||||
|
warnings: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def check_exclusions(dp, das_list: list) -> ExclusionResult:
|
||||||
|
"""Applique les règles d'exclusion symptôme vs diagnostic précis.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dp: Diagnostic principal (objet avec attribut cim10_suggestion).
|
||||||
|
das_list: Liste des diagnostics associés (même type).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ExclusionResult avec les DAS nettoyés, exclus, et les warnings.
|
||||||
|
"""
|
||||||
|
# Collecter tous les codes du séjour (DP + DAS)
|
||||||
|
all_codes: list[str] = []
|
||||||
|
if dp and dp.cim10_suggestion:
|
||||||
|
all_codes.append(dp.cim10_suggestion)
|
||||||
|
for das in das_list:
|
||||||
|
if das.cim10_suggestion:
|
||||||
|
all_codes.append(das.cim10_suggestion)
|
||||||
|
|
||||||
|
# Identifier les codes précis présents (Chapitres I-XIV)
|
||||||
|
precise_codes = [c for c in all_codes if is_precise_diagnosis(c)]
|
||||||
|
|
||||||
|
cleaned = []
|
||||||
|
excluded = []
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
for das in das_list:
|
||||||
|
code = das.cim10_suggestion
|
||||||
|
if not code or not is_symptom_code(code):
|
||||||
|
# Non-symptôme : toujours conservé
|
||||||
|
cleaned.append(das)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Vérifier si ce symptôme est exclu par un diagnostic précis
|
||||||
|
should_exclude = False
|
||||||
|
excluding_code = None
|
||||||
|
|
||||||
|
# Chercher dans EXCLUSION_MAP : d'abord le code exact, puis la racine (3 chars)
|
||||||
|
exclusion_roots = EXCLUSION_MAP.get(code.upper())
|
||||||
|
if exclusion_roots is None:
|
||||||
|
# Essayer la racine 3 caractères (ex: R10.4 → R10)
|
||||||
|
root3 = code.upper()[:3]
|
||||||
|
exclusion_roots = EXCLUSION_MAP.get(root3)
|
||||||
|
|
||||||
|
if exclusion_roots:
|
||||||
|
for precise in precise_codes:
|
||||||
|
if _code_matches(precise, exclusion_roots):
|
||||||
|
should_exclude = True
|
||||||
|
excluding_code = precise
|
||||||
|
break
|
||||||
|
|
||||||
|
if should_exclude:
|
||||||
|
excluded.append(das)
|
||||||
|
warnings.append(
|
||||||
|
f"DAS '{das.texte}' ({code}) exclu : symptôme redondant avec "
|
||||||
|
f"le diagnostic précis {excluding_code}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cleaned.append(das)
|
||||||
|
|
||||||
|
# Vérifier aussi si le DP est un symptôme avec un diagnostic précis en DAS
|
||||||
|
if dp and dp.cim10_suggestion and is_symptom_code(dp.cim10_suggestion):
|
||||||
|
dp_code = dp.cim10_suggestion
|
||||||
|
exclusion_roots = EXCLUSION_MAP.get(dp_code.upper())
|
||||||
|
if exclusion_roots is None:
|
||||||
|
exclusion_roots = EXCLUSION_MAP.get(dp_code.upper()[:3])
|
||||||
|
|
||||||
|
if exclusion_roots:
|
||||||
|
for precise in precise_codes:
|
||||||
|
if _code_matches(precise, exclusion_roots):
|
||||||
|
warnings.append(
|
||||||
|
f"ALERTE DP : le DP '{dp.texte}' ({dp_code}) est un symptôme "
|
||||||
|
f"alors qu'un diagnostic précis {precise} est présent — "
|
||||||
|
f"vérifier si le DP devrait être changé"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
return ExclusionResult(cleaned_das=cleaned, excluded=excluded, warnings=warnings)
|
||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -15,6 +16,12 @@ logger = logging.getLogger(__name__)
|
|||||||
# Singleton pour le modèle d'embedding (chargé une seule fois)
|
# Singleton pour le modèle d'embedding (chargé une seule fois)
|
||||||
_embed_model = None
|
_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():
|
def _get_embed_model():
|
||||||
"""Charge le modèle d'embedding (singleton)."""
|
"""Charge le modèle d'embedding (singleton)."""
|
||||||
@@ -27,7 +34,7 @@ def _get_embed_model():
|
|||||||
return _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.
|
"""Recherche les passages les plus similaires dans l'index FAISS.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -35,7 +42,8 @@ def search_similar(query: str, top_k: int = 5) -> list[dict]:
|
|||||||
top_k: Nombre de résultats à retourner.
|
top_k: Nombre de résultats à retourner.
|
||||||
|
|
||||||
Returns:
|
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
|
from .rag_index import get_index
|
||||||
import numpy as np
|
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 = model.encode([query], normalize_embeddings=True)
|
||||||
query_vec = np.array(query_vec, dtype=np.float32)
|
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]):
|
for score, idx in zip(scores[0], indices[0]):
|
||||||
if idx < 0:
|
if idx < 0:
|
||||||
continue
|
continue
|
||||||
|
if float(score) < _MIN_SCORE:
|
||||||
|
continue
|
||||||
meta = metadata[idx].copy()
|
meta = metadata[idx].copy()
|
||||||
meta["score"] = float(score)
|
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:
|
def _format_contexte(contexte: dict) -> str:
|
||||||
"""Construit le prompt pour Ollama."""
|
"""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 = ""
|
sources_text = ""
|
||||||
for i, src in enumerate(sources, 1):
|
for i, src in enumerate(sources, 1):
|
||||||
doc_name = {
|
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 += f"--- Source {i}: {doc_name}{code_info}{page_info} ---\n"
|
||||||
sources_text += (src.get("extrait", "")[:800]) + "\n\n"
|
sources_text += (src.get("extrait", "")[:800]) + "\n\n"
|
||||||
|
|
||||||
ctx_parts = []
|
type_diag = "DP (diagnostic principal)" if est_dp else "DAS (diagnostic associé significatif)"
|
||||||
if contexte.get("sexe"):
|
ctx_str = _format_contexte(contexte)
|
||||||
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é"
|
|
||||||
|
|
||||||
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}"
|
RÈGLES IMPÉRATIVES :
|
||||||
Contexte patient : {ctx_str}
|
- 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}
|
{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"}}"""
|
{{"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:
|
def _call_ollama(prompt: str) -> dict | None:
|
||||||
"""Appelle Ollama et parse la réponse JSON."""
|
"""Appelle Ollama et parse la réponse JSON."""
|
||||||
try:
|
try:
|
||||||
@@ -109,27 +259,14 @@ def _call_ollama(prompt: str) -> dict | None:
|
|||||||
"stream": False,
|
"stream": False,
|
||||||
"options": {
|
"options": {
|
||||||
"temperature": 0.1,
|
"temperature": 0.1,
|
||||||
"num_predict": 300,
|
"num_predict": 1200,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
timeout=OLLAMA_TIMEOUT,
|
timeout=OLLAMA_TIMEOUT,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
raw = response.json().get("response", "")
|
raw = response.json().get("response", "")
|
||||||
|
return _parse_ollama_response(raw)
|
||||||
# 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
|
|
||||||
|
|
||||||
except requests.ConnectionError:
|
except requests.ConnectionError:
|
||||||
logger.warning("Ollama non disponible (connexion refusée)")
|
logger.warning("Ollama non disponible (connexion refusée)")
|
||||||
@@ -145,13 +282,14 @@ def _call_ollama(prompt: str) -> dict | None:
|
|||||||
def enrich_diagnostic(
|
def enrich_diagnostic(
|
||||||
diagnostic: Diagnostic,
|
diagnostic: Diagnostic,
|
||||||
contexte: dict,
|
contexte: dict,
|
||||||
|
est_dp: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Enrichit un Diagnostic avec le RAG (FAISS + Ollama).
|
"""Enrichit un Diagnostic avec le RAG (FAISS + Ollama).
|
||||||
|
|
||||||
Modifie le diagnostic en place. Fallback gracieux si FAISS ou Ollama échouent.
|
Modifie le diagnostic en place. Fallback gracieux si FAISS ou Ollama échouent.
|
||||||
"""
|
"""
|
||||||
# 1. Recherche FAISS
|
# 1. Recherche FAISS
|
||||||
sources = search_similar(diagnostic.texte, top_k=5)
|
sources = search_similar(diagnostic.texte, top_k=10)
|
||||||
|
|
||||||
if not sources:
|
if not sources:
|
||||||
logger.debug("Aucune source RAG trouvée pour : %s", diagnostic.texte)
|
logger.debug("Aucune source RAG trouvée pour : %s", diagnostic.texte)
|
||||||
@@ -168,14 +306,15 @@ def enrich_diagnostic(
|
|||||||
for s in sources
|
for s in sources
|
||||||
]
|
]
|
||||||
|
|
||||||
# 3. Appel Ollama pour justification
|
# 3. Appel Ollama pour justification avec raisonnement structuré
|
||||||
prompt = _build_prompt(diagnostic.texte, sources, contexte)
|
prompt = _build_prompt(diagnostic.texte, sources, contexte, est_dp=est_dp)
|
||||||
llm_result = _call_ollama(prompt)
|
llm_result = _call_ollama(prompt)
|
||||||
|
|
||||||
if llm_result:
|
if llm_result:
|
||||||
code = llm_result.get("code")
|
code = llm_result.get("code")
|
||||||
confidence = llm_result.get("confidence")
|
confidence = llm_result.get("confidence")
|
||||||
justification = llm_result.get("justification")
|
justification = llm_result.get("justification")
|
||||||
|
raisonnement = llm_result.get("raisonnement")
|
||||||
|
|
||||||
if code:
|
if code:
|
||||||
diagnostic.cim10_suggestion = code
|
diagnostic.cim10_suggestion = code
|
||||||
@@ -183,6 +322,8 @@ def enrich_diagnostic(
|
|||||||
diagnostic.cim10_confidence = confidence
|
diagnostic.cim10_confidence = confidence
|
||||||
if justification:
|
if justification:
|
||||||
diagnostic.justification = justification
|
diagnostic.justification = justification
|
||||||
|
if raisonnement:
|
||||||
|
diagnostic.raisonnement = raisonnement
|
||||||
else:
|
else:
|
||||||
logger.info("Ollama non disponible — sources FAISS conservées sans justification LLM")
|
logger.info("Ollama non disponible — sources FAISS conservées sans justification LLM")
|
||||||
|
|
||||||
@@ -192,12 +333,27 @@ def enrich_dossier(dossier: DossierMedical) -> None:
|
|||||||
contexte = {
|
contexte = {
|
||||||
"sexe": dossier.sejour.sexe,
|
"sexe": dossier.sejour.sexe,
|
||||||
"age": dossier.sejour.age,
|
"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:
|
if dossier.diagnostic_principal:
|
||||||
logger.info("RAG enrichissement DP : %s", dossier.diagnostic_principal.texte)
|
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:
|
for das in dossier.diagnostics_associes:
|
||||||
logger.info("RAG enrichissement DAS : %s", das.texte)
|
logger.info("RAG enrichissement DAS : %s", das.texte)
|
||||||
enrich_diagnostic(das, contexte)
|
enrich_diagnostic(das, contexte, est_dp=False)
|
||||||
|
|||||||
201
src/medical/severity.py
Normal file
201
src/medical/severity.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"""Détection heuristique de sévérité et CMA/CMS pour le codage GHM.
|
||||||
|
|
||||||
|
Phase 1 : heuristique basée sur des marqueurs textuels et des racines CIM-10.
|
||||||
|
Phase 2 (future) : tables CMA/CMS officielles ATIH.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .cim10_dict import load_dict, normalize_text
|
||||||
|
|
||||||
|
|
||||||
|
# --- Marqueurs de sévérité dans le texte ---
|
||||||
|
|
||||||
|
_SEVERE_MARKERS = {
|
||||||
|
"aigu", "aigue", "severe", "grave", "maligne", "malin",
|
||||||
|
"foudroyant", "foudroyante", "necrosant", "necrosante",
|
||||||
|
"septique", "decompense", "decompensee", "choc",
|
||||||
|
"defaillance", "hemorragique",
|
||||||
|
"fulminant", "fulminante", "massif", "massive", "critique",
|
||||||
|
}
|
||||||
|
|
||||||
|
_MODERATE_MARKERS = {
|
||||||
|
"modere", "moderee", "moderes", "moderees",
|
||||||
|
"subaigu", "subaigue", "subaiguë",
|
||||||
|
"persistant", "persistante", "recidivant", "recidivante",
|
||||||
|
}
|
||||||
|
|
||||||
|
_MILD_MARKERS = {
|
||||||
|
"chronique", "leger", "legere",
|
||||||
|
"benin", "benigne", "mineur", "mineure",
|
||||||
|
"superficiel", "superficielle", "stable",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Racines CIM-10 fréquemment CMA (heuristique Phase 1) ---
|
||||||
|
# Ces racines sont connues pour être souvent classées CMA dans les tables ATIH.
|
||||||
|
|
||||||
|
_HEURISTIC_CMA_ROOTS: set[str] = {
|
||||||
|
# Infectieux
|
||||||
|
"A41", # Sepsis
|
||||||
|
"A40", # Septicémie streptococcique
|
||||||
|
# Hématologie / nutrition
|
||||||
|
"D64", # Anémie
|
||||||
|
"D65", # CIVD
|
||||||
|
"E46", # Dénutrition
|
||||||
|
"E87", # Troubles hydro-électrolytiques
|
||||||
|
"E86", # Déshydratation
|
||||||
|
# Métabolique
|
||||||
|
"E11", # Diabète type 2 (avec complications)
|
||||||
|
"E10", # Diabète type 1 (avec complications)
|
||||||
|
# Cardiovasculaire
|
||||||
|
"I48", # Fibrillation auriculaire
|
||||||
|
"I50", # Insuffisance cardiaque
|
||||||
|
"I26", # Embolie pulmonaire
|
||||||
|
"I80", # Thrombose veineuse
|
||||||
|
# Respiratoire
|
||||||
|
"J18", # Pneumopathie
|
||||||
|
"J96", # Insuffisance respiratoire
|
||||||
|
"J69", # Pneumopathie d'inhalation
|
||||||
|
# Rénal
|
||||||
|
"N17", # Insuffisance rénale aiguë
|
||||||
|
"N18", # Insuffisance rénale chronique
|
||||||
|
"N39", # Infection urinaire
|
||||||
|
# Hépatique
|
||||||
|
"K72", # Insuffisance hépatique
|
||||||
|
# Infectieux nosocomial
|
||||||
|
"T81", # Complications d'actes (infection post-op)
|
||||||
|
"T80", # Complications post-perfusion
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SeverityInfo:
|
||||||
|
"""Résultat de l'évaluation de sévérité d'un diagnostic."""
|
||||||
|
est_cma_probable: bool = False
|
||||||
|
niveau_severite: str = "non_evalue" # "leger" | "modere" | "severe" | "non_evalue"
|
||||||
|
marqueurs_trouves: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_severity_markers(text: str) -> tuple[str, list[str]]:
|
||||||
|
"""Détecte les marqueurs de sévérité dans un texte normalisé.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(niveau, marqueurs_trouves) où niveau est "severe", "modere", "leger" ou "non_evalue".
|
||||||
|
"""
|
||||||
|
text_norm = normalize_text(text)
|
||||||
|
words = set(text_norm.split())
|
||||||
|
|
||||||
|
found_severe = words & _SEVERE_MARKERS
|
||||||
|
found_moderate = words & _MODERATE_MARKERS
|
||||||
|
found_mild = words & _MILD_MARKERS
|
||||||
|
|
||||||
|
all_found = list(found_severe | found_moderate | found_mild)
|
||||||
|
|
||||||
|
if found_severe:
|
||||||
|
return "severe", all_found
|
||||||
|
if found_moderate:
|
||||||
|
return "modere", all_found
|
||||||
|
if found_mild:
|
||||||
|
return "leger", all_found
|
||||||
|
return "non_evalue", []
|
||||||
|
|
||||||
|
|
||||||
|
def _is_heuristic_cma(code: str) -> bool:
|
||||||
|
"""Vérifie si un code CIM-10 est probablement CMA selon les racines heuristiques."""
|
||||||
|
if not code:
|
||||||
|
return False
|
||||||
|
code_upper = code.upper()
|
||||||
|
for root in _HEURISTIC_CMA_ROOTS:
|
||||||
|
if code_upper.startswith(root):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_severity(diagnostic) -> SeverityInfo:
|
||||||
|
"""Évalue la sévérité d'un diagnostic (texte + code CIM-10).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
diagnostic: Objet avec attributs texte, cim10_suggestion.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SeverityInfo avec est_cma_probable, niveau_severite, marqueurs_trouves.
|
||||||
|
"""
|
||||||
|
info = SeverityInfo()
|
||||||
|
|
||||||
|
# 1. Marqueurs textuels depuis le texte du diagnostic
|
||||||
|
texte = diagnostic.texte or ""
|
||||||
|
niveau, marqueurs = _detect_severity_markers(texte)
|
||||||
|
|
||||||
|
# 2. Chercher aussi dans le label du dictionnaire CIM-10
|
||||||
|
code = diagnostic.cim10_suggestion
|
||||||
|
if code:
|
||||||
|
cim10_dict = load_dict()
|
||||||
|
label = cim10_dict.get(code, "")
|
||||||
|
if label:
|
||||||
|
niveau_label, marqueurs_label = _detect_severity_markers(label)
|
||||||
|
# Prendre le niveau le plus sévère
|
||||||
|
severity_order = {"severe": 3, "modere": 2, "leger": 1, "non_evalue": 0}
|
||||||
|
if severity_order.get(niveau_label, 0) > severity_order.get(niveau, 0):
|
||||||
|
niveau = niveau_label
|
||||||
|
marqueurs = list(set(marqueurs + marqueurs_label))
|
||||||
|
|
||||||
|
info.niveau_severite = niveau
|
||||||
|
info.marqueurs_trouves = marqueurs
|
||||||
|
|
||||||
|
# 3. Heuristique CMA basée sur la racine CIM-10
|
||||||
|
if code and _is_heuristic_cma(code):
|
||||||
|
info.est_cma_probable = True
|
||||||
|
|
||||||
|
# Un diagnostic sévère avec un code CMA-probable = forte indication
|
||||||
|
if niveau == "severe" and info.est_cma_probable:
|
||||||
|
info.est_cma_probable = True
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_dossier_severity(dp, das_list: list) -> list[str]:
|
||||||
|
"""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).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dp: Diagnostic principal.
|
||||||
|
das_list: Liste des diagnostics associés.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste d'alertes de sévérité générées.
|
||||||
|
"""
|
||||||
|
alertes = []
|
||||||
|
|
||||||
|
# Évaluer le DP
|
||||||
|
if dp and dp.cim10_suggestion:
|
||||||
|
info = evaluate_severity(dp)
|
||||||
|
dp.niveau_severite = info.niveau_severite
|
||||||
|
if info.est_cma_probable:
|
||||||
|
dp.est_cma = True
|
||||||
|
|
||||||
|
# Évaluer chaque DAS
|
||||||
|
cma_count = 0
|
||||||
|
for das in das_list:
|
||||||
|
if not das.cim10_suggestion:
|
||||||
|
continue
|
||||||
|
info = evaluate_severity(das)
|
||||||
|
das.niveau_severite = info.niveau_severite
|
||||||
|
if info.est_cma_probable:
|
||||||
|
das.est_cma = True
|
||||||
|
cma_count += 1
|
||||||
|
alertes.append(
|
||||||
|
f"CMA probable : '{das.texte}' ({das.cim10_suggestion}) — "
|
||||||
|
f"sévérité {info.niveau_severite}"
|
||||||
|
+ (f", marqueurs : {', '.join(info.marqueurs_trouves)}" if info.marqueurs_trouves else "")
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
{% block sidebar %}
|
{% block sidebar %}
|
||||||
<div class="group-title">Navigation</div>
|
<div class="group-title">Navigation</div>
|
||||||
<a href="/">Retour à la liste</a>
|
<a href="/">Retour à la liste</a>
|
||||||
|
<div class="group-title" style="margin-top:1.5rem;">Actions</div>
|
||||||
|
<button id="reprocess-btn" style="width:100%;padding:0.6rem;background:#3b82f6;color:white;border:none;border-radius:0.375rem;cursor:pointer;font-size:0.875rem;font-weight:600;margin-bottom:0.5rem;">🔄 Relancer l'étude</button>
|
||||||
|
<div id="reprocess-status" style="font-size:0.75rem;padding:0.25rem;"></div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -48,6 +51,18 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# ---- Alertes de codage ---- #}
|
||||||
|
{% if dossier.alertes_codage %}
|
||||||
|
<div class="card section" style="border-left:4px solid #f97316;background:#fff7ed;">
|
||||||
|
<h3 style="color:#c2410c;">Alertes de codage ({{ dossier.alertes_codage|length }})</h3>
|
||||||
|
<ul style="margin:0;padding-left:1.2rem;">
|
||||||
|
{% for alerte in dossier.alertes_codage %}
|
||||||
|
<li style="font-size:0.85rem;color:#9a3412;margin-bottom:0.25rem;">{{ alerte }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# ---- Diagnostic principal ---- #}
|
{# ---- Diagnostic principal ---- #}
|
||||||
{% if dossier.diagnostic_principal %}
|
{% if dossier.diagnostic_principal %}
|
||||||
{% set dp = dossier.diagnostic_principal %}
|
{% set dp = dossier.diagnostic_principal %}
|
||||||
@@ -57,6 +72,10 @@
|
|||||||
{% if dp.cim10_suggestion %}
|
{% if dp.cim10_suggestion %}
|
||||||
<span class="badge" style="background:#dbeafe;color:#1d4ed8;font-size:0.85rem;">{{ dp.cim10_suggestion }}</span>
|
<span class="badge" style="background:#dbeafe;color:#1d4ed8;font-size:0.85rem;">{{ dp.cim10_suggestion }}</span>
|
||||||
{{ dp.cim10_confidence | confidence_badge }}
|
{{ dp.cim10_confidence | confidence_badge }}
|
||||||
|
{% if dp.est_cma %}<span class="badge" style="background:#fee2e2;color:#dc2626;font-size:0.75rem;">CMA</span>{% endif %}
|
||||||
|
{% if dp.niveau_severite == 'severe' %}<span class="badge" style="background:#fee2e2;color:#dc2626;font-size:0.75rem;">Sévère</span>
|
||||||
|
{% elif dp.niveau_severite == 'modere' %}<span class="badge" style="background:#fef3c7;color:#92400e;font-size:0.75rem;">Modéré</span>
|
||||||
|
{% elif dp.niveau_severite == 'leger' %}<span class="badge" style="background:#d1fae5;color:#065f46;font-size:0.75rem;">Léger</span>{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if dp.justification %}
|
{% if dp.justification %}
|
||||||
<div style="margin-top:0.5rem;font-size:0.8rem;color:#475569;">{{ dp.justification }}</div>
|
<div style="margin-top:0.5rem;font-size:0.8rem;color:#475569;">{{ dp.justification }}</div>
|
||||||
@@ -78,13 +97,22 @@
|
|||||||
<div class="card section">
|
<div class="card section">
|
||||||
<h3>Diagnostics associés ({{ dossier.diagnostics_associes|length }})</h3>
|
<h3>Diagnostics associés ({{ dossier.diagnostics_associes|length }})</h3>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Texte</th><th>CIM-10</th><th>Confiance</th><th>Justification</th></tr></thead>
|
<thead><tr><th>Texte</th><th>CIM-10</th><th>Confiance</th><th>Sévérité</th><th>Justification</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for das in dossier.diagnostics_associes %}
|
{% for das in dossier.diagnostics_associes %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ das.texte }}</td>
|
<td>
|
||||||
|
{{ das.texte }}
|
||||||
|
{% if das.est_cma %}<span class="badge" style="background:#fee2e2;color:#dc2626;font-size:0.7rem;margin-left:0.3rem;">CMA</span>{% endif %}
|
||||||
|
</td>
|
||||||
<td>{% if das.cim10_suggestion %}<span class="badge" style="background:#dbeafe;color:#1d4ed8;">{{ das.cim10_suggestion }}</span>{% endif %}</td>
|
<td>{% if das.cim10_suggestion %}<span class="badge" style="background:#dbeafe;color:#1d4ed8;">{{ das.cim10_suggestion }}</span>{% endif %}</td>
|
||||||
<td>{{ das.cim10_confidence | confidence_badge }}</td>
|
<td>{{ das.cim10_confidence | confidence_badge }}</td>
|
||||||
|
<td>
|
||||||
|
{% if das.niveau_severite == 'severe' %}<span class="badge" style="background:#fee2e2;color:#dc2626;">Sévère</span>
|
||||||
|
{% elif das.niveau_severite == 'modere' %}<span class="badge" style="background:#fef3c7;color:#92400e;">Modéré</span>
|
||||||
|
{% elif das.niveau_severite == 'leger' %}<span class="badge" style="background:#d1fae5;color:#065f46;">Léger</span>
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
<td style="font-size:0.8rem;color:#475569;">{{ das.justification or '' }}</td>
|
<td style="font-size:0.8rem;color:#475569;">{{ das.justification or '' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% if das.sources_rag %}
|
{% if das.sources_rag %}
|
||||||
@@ -111,13 +139,21 @@
|
|||||||
<div class="card section">
|
<div class="card section">
|
||||||
<h3>Actes CCAM ({{ dossier.actes_ccam|length }})</h3>
|
<h3>Actes CCAM ({{ dossier.actes_ccam|length }})</h3>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Texte</th><th>Code CCAM</th><th>Date</th></tr></thead>
|
<thead><tr><th>Texte</th><th>Code CCAM</th><th>Date</th><th>Validité</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for a in dossier.actes_ccam %}
|
{% for a in dossier.actes_ccam %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ a.texte }}</td>
|
<td>{{ a.texte }}</td>
|
||||||
<td>{% if a.code_ccam_suggestion %}<span class="badge" style="background:#e0e7ff;color:#3730a3;">{{ a.code_ccam_suggestion }}</span>{% endif %}</td>
|
<td>{% if a.code_ccam_suggestion %}<span class="badge" style="background:#e0e7ff;color:#3730a3;">{{ a.code_ccam_suggestion }}</span>{% endif %}</td>
|
||||||
<td>{{ a.date or '' }}</td>
|
<td>{{ a.date or '' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if a.validite == 'valide' %}<span class="badge" style="background:#d1fae5;color:#065f46;">Valide</span>
|
||||||
|
{% elif a.validite == 'obsolete' %}<span class="badge" style="background:#fee2e2;color:#dc2626;">Obsolète</span>
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
{% for alerte in a.alertes %}
|
||||||
|
<div style="font-size:0.7rem;color:#dc2626;">{{ alerte }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -204,3 +240,38 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.getElementById('reprocess-btn').addEventListener('click', async () => {
|
||||||
|
const btn = document.getElementById('reprocess-btn');
|
||||||
|
const status = document.getElementById('reprocess-status');
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Traitement en cours...';
|
||||||
|
status.textContent = '';
|
||||||
|
status.style.color = '#3b82f6';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/reprocess/{{ filepath }}', { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.ok) {
|
||||||
|
status.textContent = '✓ ' + data.message;
|
||||||
|
status.style.color = '#16a34a';
|
||||||
|
setTimeout(() => location.reload(), 1500);
|
||||||
|
} else {
|
||||||
|
status.textContent = '✗ ' + (data.error || 'Erreur');
|
||||||
|
status.style.color = '#dc2626';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Relancer l\'étude';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
status.textContent = '✗ Erreur réseau';
|
||||||
|
status.style.color = '#dc2626';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Relancer l\'étude';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
123
tests/test_exclusion_rules.py
Normal file
123
tests/test_exclusion_rules.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"""Tests pour les règles d'exclusion diagnostique (symptôme vs diagnostic précis)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.config import Diagnostic
|
||||||
|
from src.medical.exclusion_rules import (
|
||||||
|
is_symptom_code,
|
||||||
|
is_precise_diagnosis,
|
||||||
|
check_exclusions,
|
||||||
|
EXCLUSION_MAP,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsSymptomCode:
|
||||||
|
def test_r_codes(self):
|
||||||
|
assert is_symptom_code("R10.4") is True
|
||||||
|
assert is_symptom_code("R17") is True
|
||||||
|
assert is_symptom_code("R50.9") is True
|
||||||
|
|
||||||
|
def test_non_symptom(self):
|
||||||
|
assert is_symptom_code("K85.1") is False
|
||||||
|
assert is_symptom_code("I10") is False
|
||||||
|
assert is_symptom_code("E11.9") is False
|
||||||
|
|
||||||
|
def test_empty_none(self):
|
||||||
|
assert is_symptom_code("") is False
|
||||||
|
assert is_symptom_code(None) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsPreciseDiagnosis:
|
||||||
|
def test_precise(self):
|
||||||
|
assert is_precise_diagnosis("K85.1") is True
|
||||||
|
assert is_precise_diagnosis("A41.9") is True
|
||||||
|
assert is_precise_diagnosis("I10") is True
|
||||||
|
|
||||||
|
def test_not_precise(self):
|
||||||
|
assert is_precise_diagnosis("R10.4") is False
|
||||||
|
assert is_precise_diagnosis("Z03") is False # Chapitre XXI
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckExclusions:
|
||||||
|
def test_symptom_excluded_when_precise_present(self):
|
||||||
|
"""R10.4 (douleur abdo) exclu quand K85.1 (pancréatite) est présent."""
|
||||||
|
dp = Diagnostic(texte="Pancréatite aiguë biliaire", cim10_suggestion="K85.1")
|
||||||
|
das = [
|
||||||
|
Diagnostic(texte="Douleur abdominale", cim10_suggestion="R10.4"),
|
||||||
|
Diagnostic(texte="Obésité", cim10_suggestion="E66.0"),
|
||||||
|
]
|
||||||
|
result = check_exclusions(dp, das)
|
||||||
|
assert len(result.cleaned_das) == 1
|
||||||
|
assert result.cleaned_das[0].cim10_suggestion == "E66.0"
|
||||||
|
assert len(result.excluded) == 1
|
||||||
|
assert result.excluded[0].cim10_suggestion == "R10.4"
|
||||||
|
assert len(result.warnings) >= 1
|
||||||
|
|
||||||
|
def test_symptom_kept_when_alone(self):
|
||||||
|
"""R10.4 (douleur abdo) conservé si aucun diagnostic précis l'expliquant."""
|
||||||
|
dp = Diagnostic(texte="Douleur abdominale", cim10_suggestion="R10.4")
|
||||||
|
das = [
|
||||||
|
Diagnostic(texte="Hypertension", cim10_suggestion="I10"),
|
||||||
|
]
|
||||||
|
result = check_exclusions(dp, das)
|
||||||
|
assert len(result.cleaned_das) == 1
|
||||||
|
assert result.cleaned_das[0].cim10_suggestion == "I10"
|
||||||
|
assert len(result.excluded) == 0
|
||||||
|
|
||||||
|
def test_multiple_exclusions(self):
|
||||||
|
"""Plusieurs symptômes exclus par différents diagnostics précis."""
|
||||||
|
dp = Diagnostic(texte="Pancréatite aiguë", cim10_suggestion="K85.9")
|
||||||
|
das = [
|
||||||
|
Diagnostic(texte="Douleur abdominale", cim10_suggestion="R10.4"),
|
||||||
|
Diagnostic(texte="Nausées", cim10_suggestion="R11"),
|
||||||
|
Diagnostic(texte="Diabète type 2", cim10_suggestion="E11.9"),
|
||||||
|
]
|
||||||
|
result = check_exclusions(dp, das)
|
||||||
|
# R10.4 exclu par K85, R11 exclu par K85
|
||||||
|
assert len(result.excluded) == 2
|
||||||
|
assert len(result.cleaned_das) == 1
|
||||||
|
assert result.cleaned_das[0].cim10_suggestion == "E11.9"
|
||||||
|
|
||||||
|
def test_non_symptom_never_excluded(self):
|
||||||
|
"""Un diagnostic précis (non R-code) n'est jamais exclu."""
|
||||||
|
dp = Diagnostic(texte="Pancréatite aiguë", cim10_suggestion="K85.1")
|
||||||
|
das = [
|
||||||
|
Diagnostic(texte="Lithiase cholédoque", cim10_suggestion="K80.5"),
|
||||||
|
Diagnostic(texte="Cholécystite", cim10_suggestion="K81.0"),
|
||||||
|
]
|
||||||
|
result = check_exclusions(dp, das)
|
||||||
|
assert len(result.cleaned_das) == 2
|
||||||
|
assert len(result.excluded) == 0
|
||||||
|
|
||||||
|
def test_dp_symptom_triggers_warning(self):
|
||||||
|
"""Alerte si le DP est un symptôme alors qu'un diagnostic précis existe."""
|
||||||
|
dp = Diagnostic(texte="Douleur abdominale", cim10_suggestion="R10.4")
|
||||||
|
das = [
|
||||||
|
Diagnostic(texte="Pancréatite aiguë", cim10_suggestion="K85.1"),
|
||||||
|
]
|
||||||
|
result = check_exclusions(dp, das)
|
||||||
|
# Le DAS K85.1 n'est pas un symptôme, il est conservé
|
||||||
|
assert len(result.cleaned_das) == 1
|
||||||
|
# Mais un warning est émis sur le DP
|
||||||
|
assert any("ALERTE DP" in w for w in result.warnings)
|
||||||
|
|
||||||
|
def test_warnings_generated(self):
|
||||||
|
"""Vérifie que les warnings contiennent le texte et les codes."""
|
||||||
|
dp = Diagnostic(texte="Cholécystite", cim10_suggestion="K81.0")
|
||||||
|
das = [
|
||||||
|
Diagnostic(texte="Fièvre", cim10_suggestion="R50.9"),
|
||||||
|
]
|
||||||
|
result = check_exclusions(dp, das)
|
||||||
|
assert len(result.excluded) == 1
|
||||||
|
assert "R50.9" in result.warnings[0]
|
||||||
|
assert "K81.0" in result.warnings[0]
|
||||||
|
|
||||||
|
def test_ictere_excluded_by_lithiase(self):
|
||||||
|
"""R17 (ictère) exclu quand K80 (lithiase biliaire) est présent."""
|
||||||
|
dp = Diagnostic(texte="Lithiase cholédoque", cim10_suggestion="K80.5")
|
||||||
|
das = [
|
||||||
|
Diagnostic(texte="Ictère", cim10_suggestion="R17"),
|
||||||
|
]
|
||||||
|
result = check_exclusions(dp, das)
|
||||||
|
assert len(result.excluded) == 1
|
||||||
|
assert result.excluded[0].cim10_suggestion == "R17"
|
||||||
109
tests/test_severity.py
Normal file
109
tests/test_severity.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""Tests pour le module de sévérité heuristique (CMA/CMS)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.config import Diagnostic
|
||||||
|
from src.medical.severity import (
|
||||||
|
evaluate_severity,
|
||||||
|
enrich_dossier_severity,
|
||||||
|
_detect_severity_markers,
|
||||||
|
_is_heuristic_cma,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetectSeverityMarkers:
|
||||||
|
def test_severe_markers(self):
|
||||||
|
niveau, marqueurs = _detect_severity_markers("Pancréatite aiguë sévère")
|
||||||
|
assert niveau == "severe"
|
||||||
|
assert any(m in ("severe", "aigue") for m in marqueurs)
|
||||||
|
|
||||||
|
def test_moderate_markers(self):
|
||||||
|
niveau, marqueurs = _detect_severity_markers("Insuffisance rénale modérée")
|
||||||
|
assert niveau == "modere"
|
||||||
|
assert "modere" in marqueurs or "moderee" in marqueurs
|
||||||
|
|
||||||
|
def test_mild_markers(self):
|
||||||
|
niveau, marqueurs = _detect_severity_markers("Anémie chronique bénigne")
|
||||||
|
assert niveau == "leger"
|
||||||
|
assert any(m in ("chronique", "benin", "benigne") for m in marqueurs)
|
||||||
|
|
||||||
|
def test_no_markers(self):
|
||||||
|
niveau, marqueurs = _detect_severity_markers("Hypertension artérielle")
|
||||||
|
assert niveau == "non_evalue"
|
||||||
|
assert marqueurs == []
|
||||||
|
|
||||||
|
def test_severe_overrides_mild(self):
|
||||||
|
"""Si 'sévère' et 'chronique' sont présents, 'severe' l'emporte."""
|
||||||
|
niveau, marqueurs = _detect_severity_markers("Insuffisance cardiaque chronique décompensée")
|
||||||
|
assert niveau == "severe"
|
||||||
|
|
||||||
|
|
||||||
|
class TestHeuristicCMA:
|
||||||
|
def test_e11_is_cma(self):
|
||||||
|
assert _is_heuristic_cma("E11.9") is True
|
||||||
|
|
||||||
|
def test_i48_is_cma(self):
|
||||||
|
assert _is_heuristic_cma("I48.9") is True
|
||||||
|
|
||||||
|
def test_a41_is_cma(self):
|
||||||
|
assert _is_heuristic_cma("A41.9") is True
|
||||||
|
|
||||||
|
def test_k85_not_cma(self):
|
||||||
|
assert _is_heuristic_cma("K85.1") is False
|
||||||
|
|
||||||
|
def test_i10_not_cma(self):
|
||||||
|
assert _is_heuristic_cma("I10") is False
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
assert _is_heuristic_cma("") is False
|
||||||
|
assert _is_heuristic_cma(None) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvaluateSeverity:
|
||||||
|
def test_cma_code_detected(self):
|
||||||
|
diag = Diagnostic(texte="Diabète type 2", cim10_suggestion="E11.9")
|
||||||
|
info = evaluate_severity(diag)
|
||||||
|
assert info.est_cma_probable is True
|
||||||
|
|
||||||
|
def test_non_cma_code(self):
|
||||||
|
diag = Diagnostic(texte="Pancréatite aiguë biliaire", cim10_suggestion="K85.1")
|
||||||
|
info = evaluate_severity(diag)
|
||||||
|
assert info.est_cma_probable is False
|
||||||
|
|
||||||
|
def test_severity_from_text(self):
|
||||||
|
diag = Diagnostic(texte="Sepsis sévère", cim10_suggestion="A41.9")
|
||||||
|
info = evaluate_severity(diag)
|
||||||
|
assert info.niveau_severite == "severe"
|
||||||
|
assert info.est_cma_probable is True
|
||||||
|
|
||||||
|
def test_combined_text_and_dict_label(self):
|
||||||
|
"""Le label du dictionnaire CIM-10 enrichit la détection de sévérité."""
|
||||||
|
diag = Diagnostic(texte="Embolie pulmonaire", cim10_suggestion="I26.9")
|
||||||
|
info = evaluate_severity(diag)
|
||||||
|
assert info.est_cma_probable is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnrichDossierSeverity:
|
||||||
|
def test_enriches_das_in_place(self):
|
||||||
|
dp = Diagnostic(texte="Pancréatite aiguë biliaire", cim10_suggestion="K85.1")
|
||||||
|
das = [
|
||||||
|
Diagnostic(texte="Fibrillation auriculaire", cim10_suggestion="I48.9"),
|
||||||
|
Diagnostic(texte="Obésité", cim10_suggestion="E66.0"),
|
||||||
|
]
|
||||||
|
alertes = enrich_dossier_severity(dp, das)
|
||||||
|
|
||||||
|
# I48.9 = CMA probable
|
||||||
|
assert das[0].est_cma is True
|
||||||
|
assert das[0].niveau_severite is not None
|
||||||
|
|
||||||
|
# E66.0 = non CMA
|
||||||
|
assert das[1].est_cma is None
|
||||||
|
|
||||||
|
# Au moins une alerte CMA
|
||||||
|
assert any("CMA" in a for a in alertes)
|
||||||
|
|
||||||
|
def test_dp_severity_set(self):
|
||||||
|
dp = Diagnostic(texte="Sepsis sévère", cim10_suggestion="A41.9")
|
||||||
|
alertes = enrich_dossier_severity(dp, [])
|
||||||
|
assert dp.niveau_severite == "severe"
|
||||||
|
assert dp.est_cma is True
|
||||||
Reference in New Issue
Block a user