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

0
src/export/__init__.py Normal file
View File

190
src/export/rum_export.py Normal file
View File

@@ -0,0 +1,190 @@
"""Export au format RUM (Résumé d'Unité Médicale) V016 pour le groupeur ATIH.
Génère une ligne fixe de 165 caractères suivie de zones variables
(DAS en 8 chars, actes CCAM en 29 chars chacun).
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from ..config import FINESS, NUM_UM, DossierMedical
@dataclass
class RUMConfig:
finess: str = FINESS
num_um: str = NUM_UM
def _format_cim10(code: str | None) -> str:
"""Formate un code CIM-10 sur 8 caractères (sans point, padded)."""
if not code:
return " " * 8
clean = code.upper().replace(".", "").strip()
return clean.ljust(8)[:8]
def _format_date(date_str: str | None) -> str:
"""Convertit une date DD/MM/YYYY ou YYYY-MM-DD en DDMMYYYY (8 chars)."""
if not date_str:
return " " * 8
date_str = date_str.strip()
# Format DD/MM/YYYY
m = re.match(r"(\d{2})/(\d{2})/(\d{4})", date_str)
if m:
return f"{m.group(1)}{m.group(2)}{m.group(3)}"
# Format YYYY-MM-DD
m = re.match(r"(\d{4})-(\d{2})-(\d{2})", date_str)
if m:
return f"{m.group(3)}{m.group(2)}{m.group(1)}"
return " " * 8
def _format_sex(sexe: str | None) -> str:
"""Convertit le sexe en code RUM (1=M, 2=F)."""
if not sexe:
return " "
s = sexe.strip().upper()
if s in ("M", "MASCULIN", "HOMME", "H"):
return "1"
if s in ("F", "FEMININ", "FÉMININ", "FEMME"):
return "2"
return " "
def _map_mode_entree(text: str | None) -> str:
"""Convertit le mode d'entrée textuel en code RUM (1 char)."""
if not text:
return " "
t = text.strip().lower()
mapping = {
"domicile": "8",
"mutation": "6",
"transfert": "7",
"urgences": "8",
"urgence": "8",
}
for key, code in mapping.items():
if key in t:
return code
return " "
def _map_mode_sortie(text: str | None) -> str:
"""Convertit le mode de sortie textuel en code RUM (1 char)."""
if not text:
return " "
t = text.strip().lower()
mapping = {
"domicile": "8",
"mutation": "6",
"transfert": "7",
"deces": "9",
"décès": "9",
"décédé": "9",
"decede": "9",
}
for key, code in mapping.items():
if key in t:
return code
return " "
def _format_ccam_act(acte) -> str:
"""Formate un acte CCAM sur 29 caractères.
Structure : code(7) + phase(1) + activité(1) + date(8) + doc/extension(12)
"""
code = (acte.code_ccam_suggestion or "").upper().replace(" ", "")
code = code.ljust(7)[:7]
phase = "1"
activite = "1"
date = _format_date(acte.date)
extension = " " * 12
return f"{code}{phase}{activite}{date}{extension}"
def export_rum(dossier: DossierMedical, config: RUMConfig | None = None) -> str:
"""Génère le texte RUM complet pour un dossier médical.
Returns:
Chaîne texte au format RUM V016 (165 chars fixes + zones variables).
"""
if config is None:
config = RUMConfig()
sejour = dossier.sejour
dp = dossier.diagnostic_principal
# Compteurs
das_list = dossier.diagnostics_associes
actes_list = dossier.actes_ccam
nb_das = len(das_list)
nb_actes = len(actes_list)
# Numéros générés
source = dossier.source_file or "UNKNOWN"
num_rss = source.replace(".pdf", "").replace(" ", "_").ljust(20)[:20]
num_admin = num_rss
num_rum = source[:10].ljust(10)[:10]
# Construction de la zone fixe (165 caractères)
parts = [
" " * 2, # 1-2 : Version classification (vide)
" " * 6, # 3-8 : GHM (vide, rempli par groupeur)
" ", # 9 : Filler
"016", # 10-12 : Version format
" " * 3, # 13-15 : Code retour
config.finess.ljust(9)[:9], # 16-24 : FINESS
"016", # 25-27 : Version RUM
num_rss, # 28-47 : N° RSS
num_admin, # 48-67 : N° admin
num_rum, # 68-77 : N° RUM
_format_date(None), # 78-85 : Date naissance (non disponible)
_format_sex(sejour.sexe), # 86 : Sexe
config.num_um.ljust(4)[:4], # 87-90 : N° UM
" " * 2, # 91-92 : Type autorisation
_format_date(sejour.date_entree), # 93-100: Date entrée UM
_map_mode_entree(sejour.mode_entree), # 101 : Mode entrée
" ", # 102 : Provenance
_format_date(sejour.date_sortie), # 103-110: Date sortie UM
_map_mode_sortie(sejour.mode_sortie), # 111 : Mode sortie
" ", # 112 : Destination
" " * 5, # 113-117: CP résidence
" " * 4, # 118-121: Poids nné
" " * 2, # 122-123: Âge gestationnel
"00", # 124-125: Nb séances
str(nb_das).zfill(2)[-2:], # 126-127: Nb DAS
"00", # 128-129: Nb DAD
str(nb_actes).zfill(2)[-2:], # 130-131: Nb actes
_format_cim10(dp.cim10_suggestion if dp else None), # 132-139: DP
" " * 8, # 140-147: DR
" " * 3, # 148-150: IGS2
" " * 15, # 151-165: Réservé
]
fixed = "".join(parts)
assert len(fixed) == 165, f"Zone fixe RUM: attendu 165, obtenu {len(fixed)}"
# Zones variables
variable_parts: list[str] = []
# DAS (8 chars chacun)
for das in das_list:
variable_parts.append(_format_cim10(das.cim10_suggestion))
# Actes CCAM (29 chars chacun)
for acte in actes_list:
variable_parts.append(_format_ccam_act(acte))
return fixed + "".join(variable_parts)
def save_rum(dossier: DossierMedical, path: Path, config: RUMConfig | None = None) -> None:
"""Exporte un dossier au format RUM dans un fichier."""
rum_text = export_rum(dossier, config)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(rum_text, encoding="utf-8")