Files
t2a_v2/src/medical/ghm.py
dom 4e2b4bd946 refactor: réorganisation référentiels, nouveaux modules extraction, nettoyage code obsolète
- 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>
2026-03-07 16:48:10 +01:00

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,
)