- Réorganisation data/referentiels/ : pdfs/, dicts/, user/ (structure unifiée) - Fix badges "Source absente" sur page admin référentiels - Ré-indexation COCOA 2025 (555 → 1451 chunks, couverture 94%) - Fix VRAM OOM : embeddings forcés CPU via T2A_EMBED_CPU - Nouveaux modules : document_router, docx_extractor, image_extractor, ocr_engine - Module complétude (quality/completude.py + config YAML) - Template DIM (synthèse dimensionnelle) - Gunicorn config + systemd service t2a-viewer - Suppression t2a_install_rag_cleanup/ (copie obsolète) - Suppression scripts/ et scripts_t2a_v2/ (anciens benchmarks) - Suppression 81 fichiers _doc.txt de test - Cache Ollama : TTL configurable, corrections loader YAML - Dashboard : améliorations templates (base, index, detail, cpam, validation) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
328 lines
12 KiB
Python
328 lines
12 KiB
Python
"""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, FinancialImpact, 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.
|
|
|
|
Utilise le max des niveau_cma officiels ATIH quand disponibles,
|
|
avec fallback sur le comptage CMA/CMS.
|
|
|
|
Returns:
|
|
(niveau, cma_count, cms_count)
|
|
"""
|
|
cma_count = 0
|
|
cms_count = 0
|
|
max_cma_level = 1
|
|
|
|
for das in das_list:
|
|
# Exclure les diagnostics "barrés" / retirés du calcul de sévérité
|
|
dec = getattr(das, "cim10_decision", None)
|
|
if getattr(das, "status", None) == "ruled_out":
|
|
continue
|
|
if dec is not None and getattr(dec, "action", None) in ("REMOVE", "RULED_OUT"):
|
|
continue
|
|
niveau_cma = getattr(das, "niveau_cma", None)
|
|
if niveau_cma and niveau_cma > 1:
|
|
max_cma_level = max(max_cma_level, niveau_cma)
|
|
if getattr(das, "est_cma", False):
|
|
cma_count += 1
|
|
if getattr(das, "est_cms", False):
|
|
cms_count += 1
|
|
|
|
# Priorité au niveau CMA officiel ATIH
|
|
if max_cma_level > 1:
|
|
niveau = max_cma_level
|
|
elif 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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tarifs moyens par CMD (source ATIH open data 2024, valeurs arrondies)
|
|
# Utilisé pour le tri relatif, pas pour la facturation.
|
|
# Format : cmd -> (tarif_base_euros, supplement_par_niveau_severite)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_CMD_TARIFS: dict[str, tuple[int, int]] = {
|
|
"01": (5500, 1200), # Neurologie
|
|
"02": (2800, 600), # Ophtalmologie
|
|
"03": (2500, 550), # ORL
|
|
"04": (3800, 900), # Pneumologie
|
|
"05": (4800, 1100), # Cardiologie
|
|
"06": (3500, 800), # Digestif (tube)
|
|
"07": (3200, 900), # Hépatobiliaire
|
|
"08": (4200, 950), # Ostéo-articulaire
|
|
"09": (2400, 500), # Peau
|
|
"10": (3000, 700), # Endocrinologie
|
|
"11": (3300, 800), # Rein/urinaire
|
|
"12": (2800, 650), # Génital masculin
|
|
"13": (2600, 600), # Génital féminin
|
|
"14": (3100, 700), # Obstétrique
|
|
"15": (4500, 1000), # Néonat/périnat
|
|
"16": (3400, 800), # Hémato/tumeurs bénignes
|
|
"17": (5200, 1100), # Tumeurs malignes
|
|
"18": (3600, 850), # Infectieux
|
|
"19": (2800, 600), # Psychiatrie
|
|
"20": (2200, 500), # Alcool/toxiques
|
|
"21": (3500, 800), # Traumatismes
|
|
"22": (5800, 1300), # Brûlures
|
|
"23": (2000, 400), # Symptômes/Z
|
|
"24": (2500, 500), # Causes externes
|
|
"25": (4200, 950), # VIH
|
|
"26": (3000, 700), # Catégories spéciales
|
|
}
|
|
|
|
_DEFAULT_TARIF = (3000, 800)
|
|
|
|
|
|
def estimate_financial_impact(
|
|
ghm_etab: GHMEstimation | None,
|
|
ghm_ucr: GHMEstimation | None = None,
|
|
) -> FinancialImpact:
|
|
"""Estime l'impact financier entre le GHM établissement et le GHM UCR.
|
|
|
|
Si ghm_ucr est None, on estime l'impact de perdre le codage actuel
|
|
vers une sévérité 1 (scénario conservateur).
|
|
"""
|
|
if not ghm_etab:
|
|
return FinancialImpact(raison="GHM établissement non estimé")
|
|
|
|
cmd = ghm_etab.cmd or ""
|
|
base, supplement = _CMD_TARIFS.get(cmd, _DEFAULT_TARIF)
|
|
sev_etab = ghm_etab.severite or 1
|
|
type_etab = ghm_etab.type_ghm or "M"
|
|
|
|
if ghm_ucr:
|
|
sev_ucr = ghm_ucr.severite or 1
|
|
type_ucr = ghm_ucr.type_ghm or "M"
|
|
else:
|
|
sev_ucr = 1
|
|
type_ucr = type_etab
|
|
|
|
delta_sev = sev_ucr - sev_etab # négatif = perte de sévérité
|
|
impact = abs(delta_sev) * supplement
|
|
|
|
# Changement de type (C→M = perte importante)
|
|
changement_type = type_etab != type_ucr
|
|
if changement_type and type_etab == "C" and type_ucr == "M":
|
|
impact += base # perte du GHS chirurgical
|
|
raison = f"Changement C→M + delta sévérité {delta_sev}"
|
|
elif changement_type:
|
|
impact += supplement
|
|
raison = f"Changement type {type_etab}→{type_ucr} + delta sévérité {delta_sev}"
|
|
elif delta_sev == 0:
|
|
raison = "Pas de différence de sévérité estimée"
|
|
else:
|
|
raison = f"Delta sévérité {delta_sev} (CMD {cmd})"
|
|
|
|
# Classification priorité
|
|
if impact >= 2000 or (changement_type and type_etab == "C"):
|
|
priorite = "critique"
|
|
elif impact >= 1000 or abs(delta_sev) >= 2:
|
|
priorite = "haute"
|
|
elif impact > 0:
|
|
priorite = "normale"
|
|
else:
|
|
priorite = "faible"
|
|
|
|
return FinancialImpact(
|
|
delta_severite=delta_sev,
|
|
impact_estime_euros=impact,
|
|
priorite=priorite,
|
|
raison=raison,
|
|
)
|