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>
This commit is contained in:
dom
2026-03-07 16:48:10 +01:00
parent 2578afb6ff
commit 4e2b4bd946
210 changed files with 6939 additions and 22104 deletions

View File

@@ -202,6 +202,9 @@ def _extract_biologie(text: str, dossier: DossierMedical) -> None:
(r"(?:[Gg]lyc[ée]mie|[Gg]lucose)\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:mmol/L|g/L)?", "Glycémie"),
(r"\bHbA1c\b\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:%)?", "HbA1c"),
(r"\bTSH\b\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:mUI/L)?", "TSH"),
# Albumine / Préalbumine (critère de sévérité HAS 2021 dénutrition)
(r"(?:[Aa]lbumin[ée]?(?:mie)?|[Aa]lb(?:u)?[ée]?(?:mie)?)\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:g/[Ll])?", "Albumine"),
(r"(?:[Pp]r[ée]albumine|[Tt]ransthyr[ée]tine)\s*[=:àa]?\s*(\d+(?:[.,]\d+)?)\s*(?:mg/[Ll]|g/[Ll])?", "Préalbumine"),
]

View File

@@ -38,6 +38,7 @@ from .bio_extraction import _extract_biologie
from .diagnostic_extraction import (
_extract_diagnostics,
_extract_actes,
_detect_nutrition_has2021,
CIM10_MAP,
CCAM_MAP,
)
@@ -58,6 +59,7 @@ from .bio_normals import BIO_NORMALS, _is_abnormal # noqa: F401
from .validation_pipeline import _is_dp_family_redundant # noqa: F401
from .diagnostic_extraction import _lookup_cim10 # noqa: F401
from .diagnostic_extraction import _DAS_PATTERNS # noqa: F401
from .diagnostic_extraction import _detect_nutrition_has2021 # noqa: F401
def extract_medical_info(
@@ -86,6 +88,7 @@ def extract_medical_info(
_extract_antecedents(anonymized_text, dossier)
_extract_traitements(parsed_data, anonymized_text, dossier, edsnlp_result)
_extract_biologie(anonymized_text, dossier)
_detect_nutrition_has2021(dossier)
_extract_imagerie(anonymized_text, dossier)
_extract_complications(anonymized_text, dossier, edsnlp_result)
@@ -134,7 +137,9 @@ def extract_medical_info(
f"NUKE-3 REVIEW: DP ambigu — {selection.reason}"
)
except Exception:
logger.warning("NUKE-3: erreur sélection DP", exc_info=True)
logger.error("NUKE-3: erreur sélection DP", exc_info=True)
dossier.quality_flags["dp_selection_status"] = "error"
dossier.alertes_codage.append("QUALITE DEGRADEE : sélection DP (NUKE-3) en erreur")
# Post-processing : validation des codes CCAM contre le dictionnaire
_validate_ccam(dossier)
@@ -240,7 +245,8 @@ def _extract_das_llm(text: str, dossier: DossierMedical) -> None:
cache.save()
except Exception:
logger.warning("Erreur lors de l'extraction DAS LLM", exc_info=True)
logger.error("Erreur lors de l'extraction DAS LLM", exc_info=True)
dossier.quality_flags["das_llm_status"] = "error"
def _enrich_with_rag(dossier: DossierMedical) -> None:
@@ -249,9 +255,13 @@ def _enrich_with_rag(dossier: DossierMedical) -> None:
from .rag_search import enrich_dossier
enrich_dossier(dossier)
except ImportError:
logger.warning("Module RAG non disponible (faiss-cpu ou sentence-transformers manquant)")
logger.error("RAG INDISPONIBLE : faiss-cpu ou sentence-transformers manquant")
dossier.quality_flags["rag_status"] = "unavailable"
dossier.alertes_codage.append("QUALITE DEGRADEE : RAG indisponible — codage sans référentiels")
except Exception:
logger.warning("Erreur lors de l'enrichissement RAG", exc_info=True)
logger.error("RAG EN ERREUR : enrichissement échoué", exc_info=True)
dossier.quality_flags["rag_status"] = "error"
dossier.alertes_codage.append("QUALITE DEGRADEE : erreur RAG — codage sans référentiels")
def _extract_sejour(parsed: dict, dossier: DossierMedical) -> None:

View File

@@ -104,7 +104,9 @@ _DAS_PATTERNS: list[tuple[str, str, str]] = [
(r"diabete\s+(?:sucre\s+)?(?:de\s+)?type\s+2|diabete\s+type\s*2", "Diabète de type 2", "E11.9"),
(r"diabete\s+(?:sucre\s+)?(?:de\s+)?type\s+1|diabete\s+type\s*1", "Diabète de type 1", "E10.9"),
(r"dyslipidemie|hypercholesterolemie", "Dyslipidémie", "E78.5"),
(r"denutrition|malnutrition", "Dénutrition", "E46"),
(r"denutrition\s+severe|malnutrition\s+severe|denutrition\s+grade\s+(?:3|iii|III)", "Dénutrition sévère", "E43"),
(r"denutrition\s+moderee?|malnutrition\s+moderee?|denutrition\s+grade\s+(?:2|ii|II)", "Dénutrition modérée", "E44.0"),
(r"denutrition|malnutrition|hypoalbuminemie\s+severe", "Dénutrition", "E46"),
# Infectieux
(r"pneumopathie|pneumonie", "Pneumopathie", "J18.9"),
(r"infection\s+urinaire|pyelonephrite", "Infection urinaire", "N39.0"),
@@ -271,6 +273,91 @@ def _find_diagnostics_associes(
return das
def _detect_nutrition_has2021(dossier: DossierMedical) -> None:
"""Détecte la dénutrition selon les critères HAS/FFN novembre 2021.
Logique déterministe basée sur données structurées (IMC + âge + albumine).
- Critère phénotypique : IMC < seuil (âge-dépendant)
- Critère de sévérité : albumine < 30 g/L → sévère, 30-35 → modéré
- Code final : max(sévérité IMC, sévérité albumine) → E43 ou E44.0
Ref: HAS/FFN nov 2021 « Diagnostic de la dénutrition chez l'enfant,
l'adulte, et la personne de 70 ans et plus »
"""
# 1. Vérifier qu'aucun code E40-E46 n'est déjà codé
existing_codes: set[str] = set()
if dossier.diagnostic_principal and dossier.diagnostic_principal.cim10_suggestion:
existing_codes.add(dossier.diagnostic_principal.cim10_suggestion)
for d in dossier.diagnostics_associes:
if d.cim10_suggestion:
existing_codes.add(d.cim10_suggestion)
for code in existing_codes:
if code.startswith(("E4",)) and code[:3] in ("E40", "E41", "E42", "E43", "E44", "E45", "E46"):
return # Déjà codé
# 2. Vérifier qu'on a un IMC (critère phénotypique obligatoire)
imc = dossier.sejour.imc if dossier.sejour else None
if imc is None:
return
age = dossier.sejour.age if dossier.sejour else None
# 3. Seuils IMC HAS 2021 (âge-dépendants)
if age is not None and age >= 70:
# Personne âgée ≥ 70 ans
if imc >= 22:
return # Au-dessus du seuil
imc_severe = imc < 20
imc_moderate = not imc_severe # 20 ≤ IMC < 22
else:
# Adulte 18-69 ans (ou âge inconnu → seuils adulte par défaut)
if imc >= 18.5:
return # Au-dessus du seuil
imc_severe = imc <= 17
imc_moderate = not imc_severe # 17 < IMC < 18.5
# 4. Critère de sévérité : albumine
albumine_val = None
for bio in dossier.biologie_cle:
if bio.test == "Albumine" and bio.valeur_num is not None:
if bio.quality != "discarded":
albumine_val = bio.valeur_num
break
albumine_severe = albumine_val is not None and albumine_val < 30
albumine_moderate = albumine_val is not None and 30 <= albumine_val < 35
# 5. Code final : max(sévérité IMC, sévérité albumine)
is_severe = imc_severe or albumine_severe
is_moderate = imc_moderate or albumine_moderate
if is_severe:
code = "E43"
label = "Dénutrition sévère"
elif is_moderate:
code = "E44.0"
label = "Dénutrition modérée"
else:
return # Ne devrait pas arriver vu les checks précédents
# 6. Construire l'alerte explicative
parts = []
if age is not None and age >= 70:
parts.append(f"IMC {imc} (seuil ≥70 ans : <22 modéré, <20 sévère)")
else:
parts.append(f"IMC {imc} (seuil adulte : <18.5 modéré, ≤17 sévère)")
if albumine_val is not None:
parts.append(f"Albumine {albumine_val} g/L (<30 sévère, 30-35 modéré)")
alerte = f"HAS 2021 — {label} ({code}) : {' ; '.join(parts)}"
dossier.diagnostics_associes.append(
Diagnostic(texte=label, cim10_suggestion=code, source="has2021")
)
dossier.alertes_codage.append(alerte)
logger.info("HAS 2021 dénutrition : %s ajouté (%s)", code, alerte)
def _extract_actes(text: str, dossier: DossierMedical) -> None:
"""Extrait les actes CCAM."""
text_lower = text.lower()

View File

@@ -11,7 +11,7 @@ Principes :
from __future__ import annotations
from src.config import DossierMedical, DPSelection
from ..config import DossierMedical, DPSelection
# Whitelist Z-codes admis en DP CONFIRMED (même que dp_selector)
_Z_CODE_DP_WHITELIST = frozenset({

View File

@@ -13,7 +13,7 @@ from __future__ import annotations
import bisect
from typing import Optional
from ..config import DossierMedical, GHMEstimation
from ..config import DossierMedical, FinancialImpact, GHMEstimation
# ---------------------------------------------------------------------------
@@ -229,3 +229,99 @@ def estimate_ghm(dossier: DossierMedical) -> GHMEstimation:
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,
)

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import json
import logging
import threading
import time
from pathlib import Path
logger = logging.getLogger(__name__)
@@ -20,9 +21,15 @@ class OllamaCache:
Migration automatique depuis l'ancien format (model global) au chargement.
"""
def __init__(self, cache_path: Path, model: str | None = None):
# TTL par défaut : 30 jours (en secondes)
DEFAULT_TTL = 30 * 24 * 3600
def __init__(self, cache_path: Path, model: str | None = None, max_entries: int = 5000,
ttl: int | None = None):
self._path = cache_path
self._default_model = model
self._max_entries = max_entries
self._ttl = ttl if ttl is not None else self.DEFAULT_TTL
self._lock = threading.Lock()
self._data: dict[str, dict] = {}
self._dirty = False
@@ -70,7 +77,7 @@ class OllamaCache:
return f"{diag_type}::{texte.strip().lower()}"
def get(self, texte: str, diag_type: str, model: str | None = None) -> dict | None:
"""Récupère un résultat caché, ou None si absent ou modèle différent."""
"""Récupère un résultat caché, ou None si absent, modèle différent ou expiré."""
key = self._make_key(texte, diag_type)
use_model = model or self._default_model
with self._lock:
@@ -79,15 +86,32 @@ class OllamaCache:
return None
if use_model and entry.get("model") != use_model:
return None
# Vérifier TTL
ts = entry.get("ts")
if self._ttl and ts and (time.time() - ts) > self._ttl:
del self._data[key]
self._dirty = True
return None
return entry.get("result")
def put(self, texte: str, diag_type: str, result: dict, model: str | None = None) -> None:
"""Stocke un résultat dans le cache avec le modèle utilisé."""
"""Stocke un résultat dans le cache avec le modèle utilisé et un timestamp."""
key = self._make_key(texte, diag_type)
use_model = model or self._default_model
with self._lock:
self._data[key] = {"model": use_model, "result": result}
self._data[key] = {"model": use_model, "result": result, "ts": time.time()}
self._dirty = True
self._evict_if_needed()
def _evict_if_needed(self) -> None:
"""Éviction LRU : supprime les 20% plus anciens si seuil dépassé."""
if self._max_entries and len(self._data) > self._max_entries:
to_remove = int(len(self._data) * 0.2)
keys_to_remove = list(self._data.keys())[:to_remove]
for k in keys_to_remove:
del self._data[k]
logger.info("Cache Ollama : éviction LRU de %d entrées (restant : %d)",
to_remove, len(self._data))
def save(self) -> None:
"""Persiste le cache sur disque si modifié."""

View File

@@ -28,10 +28,14 @@ def _get_anthropic_client():
return None
try:
import anthropic
except ImportError:
logger.warning("Anthropic SDK non installé (pip install anthropic)")
return None
try:
_anthropic_client = anthropic.Anthropic(api_key=api_key)
return _anthropic_client
except Exception as e:
logger.warning("Anthropic SDK non disponible : %s", e)
logger.error("Anthropic SDK erreur d'initialisation (clé API invalide ?) : %s", e)
return None
@@ -165,20 +169,25 @@ def call_ollama(
"""
use_model = model or (get_model(role) if role else OLLAMA_MODEL)
use_timeout = timeout or OLLAMA_TIMEOUT
messages: list[dict] = [{"role": "user", "content": prompt}]
for attempt in range(3):
try:
payload: dict = {
"model": use_model,
"messages": messages,
"stream": False,
"format": "json",
"think": False,
"options": {
"temperature": temperature,
"num_predict": max_tokens,
},
}
response = requests.post(
f"{OLLAMA_URL}/api/chat",
json={
"model": use_model,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
"format": "json",
"options": {
"temperature": temperature,
"num_predict": max_tokens,
},
},
json=payload,
timeout=use_timeout,
)
# 429 rate limit → retry avec backoff exponentiel

View File

@@ -30,6 +30,55 @@ _loaded: dict[str, tuple] = {}
_loaded_lock = threading.Lock()
def check_faiss_ready() -> dict:
"""Vérifie que les index FAISS sont présents et valides.
Returns:
{"ok": bool, "ref": int, "proc": int, "bio": int, "legacy": int, "errors": [str]}
Les int = nombre de vecteurs chargés (0 si absent).
"""
result = {"ok": False, "ref": 0, "proc": 0, "bio": 0, "legacy": 0, "errors": []}
if not RAG_INDEX_DIR.exists():
result["errors"].append(f"Répertoire FAISS absent : {RAG_INDEX_DIR}")
return result
try:
import faiss
except ImportError:
result["errors"].append("Module faiss-cpu non installé")
return result
has_any = False
for kind in ("ref", "proc", "bio", "all"):
idx_path, meta_path = _paths(kind)
key = kind if kind != "all" else "legacy"
if idx_path.exists() and meta_path.exists():
try:
idx = faiss.read_index(str(idx_path))
meta = json.loads(meta_path.read_text(encoding="utf-8"))
n_vectors = idx.ntotal
n_meta = len(meta)
if n_vectors == 0:
result["errors"].append(f"Index {kind} vide (0 vecteurs)")
elif n_vectors != n_meta:
result["errors"].append(
f"Index {kind} désynchronisé : {n_vectors} vecteurs vs {n_meta} métadonnées"
)
else:
has_any = True
result[key] = n_vectors
except Exception as e:
result["errors"].append(f"Index {kind} corrompu : {e}")
if not has_any:
result["errors"].append("Aucun index FAISS valide trouvé — lancez build_index()")
else:
result["ok"] = True
return result
@dataclass
class Chunk:
text: str
@@ -593,6 +642,16 @@ def build_index(force: bool = False) -> None:
# Invalider les singletons
reset_index()
# Invalider le cache LLM (les résultats ont été générés avec l'ancien index)
try:
from ..config import OLLAMA_CACHE_PATH
if OLLAMA_CACHE_PATH.exists():
backup = OLLAMA_CACHE_PATH.with_suffix(".pre_rebuild.json")
OLLAMA_CACHE_PATH.rename(backup)
logger.info("Cache LLM invalidé (sauvegardé → %s) — les résultats seront régénérés avec le nouvel index", backup)
except Exception:
logger.warning("Impossible d'invalider le cache LLM après rebuild", exc_info=True)
def get_index(kind: str = "ref") -> tuple | None:
"""Charge un index FAISS et ses métadonnées (singleton lazy-loaded).

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
import os
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -56,7 +57,7 @@ def _get_embed_model():
raise RuntimeError("Modèle d'embedding indisponible (échec précédent)")
from sentence_transformers import SentenceTransformer
import torch
_device = "cuda" if torch.cuda.is_available() else "cpu"
_device = "cpu" if os.environ.get("T2A_EMBED_CPU") else ("cuda" if torch.cuda.is_available() else "cpu")
_model_kwargs = {"low_cpu_mem_usage": False}
try:
logger.info("Chargement du modèle d'embedding (%s)...", _device)

View File

@@ -50,7 +50,9 @@ _HEURISTIC_CMA_ROOTS: set[str] = {
# Hématologie / nutrition
"D64", # Anémie
"D65", # CIVD
"E46", # Dénutrition
"E43", # Dénutrition sévère (CMA niveau 3)
"E44", # Dénutrition modérée
"E46", # Dénutrition sans précision
"E87", # Troubles hydro-électrolytiques
"E86", # Déshydratation
# Métabolique