feat: pass LLM hybride pour DAS + interface admin référentiels RAG

Chantier 1 — Extraction DAS par LLM :
- Nouveau prompt expert DIM dans rag_search.py (extract_das_llm)
- Phase 4 dans cim10_extractor.py : détection DAS supplémentaires avant enrichissement RAG
- Cache persistant (clé hash du texte), validation CIM-10, déduplication
- Activé uniquement avec use_rag=True (--no-rag le désactive)

Chantier 2 — Admin référentiels :
- Config : REFERENTIELS_DIR, UPLOAD_MAX_SIZE_MB, ALLOWED_EXTENSIONS
- Chunking générique (PDF/CSV/Excel/TXT) + ajout incrémental FAISS dans rag_index.py
- ReferentielManager CRUD dans viewer/referentiels.py
- 5 routes Flask (listing, upload, indexation, suppression, rebuild)
- Template admin avec tableau interactif + lien sidebar

Fix : if cache → if cache is not None (OllamaCache vide évaluait à False)

410 tests passent (27 nouveaux, 0 régression).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-12 23:12:39 +01:00
parent bf92a0ce3e
commit f44216b95b
10 changed files with 1197 additions and 6 deletions

View File

@@ -112,6 +112,10 @@ def extract_medical_info(
_extract_imagerie(anonymized_text, dossier)
_extract_complications(anonymized_text, dossier, edsnlp_result)
# Phase 4 : pass LLM pour détecter des DAS supplémentaires
if use_rag:
_extract_das_llm(anonymized_text, dossier)
if use_rag:
_enrich_with_rag(dossier)
@@ -133,6 +137,79 @@ def extract_medical_info(
return dossier
def _extract_das_llm(text: str, dossier: DossierMedical) -> None:
"""Extrait des DAS supplémentaires via un pass LLM (avant enrichissement RAG)."""
try:
from .rag_search import extract_das_llm
from .ollama_cache import OllamaCache
from ..config import OLLAMA_CACHE_PATH, OLLAMA_MODEL
except ImportError:
logger.warning("Module RAG non disponible pour l'extraction DAS LLM")
return
try:
cache = OllamaCache(OLLAMA_CACHE_PATH, OLLAMA_MODEL)
# Construire le contexte
contexte = {
"sexe": dossier.sejour.sexe,
"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,
}
# DAS existants (texte + code)
existing_das = []
existing_codes = 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:
label = d.texte
if d.cim10_suggestion:
label += f" ({d.cim10_suggestion})"
existing_codes.add(d.cim10_suggestion)
existing_das.append(label)
dp_texte = dossier.diagnostic_principal.texte if dossier.diagnostic_principal else ""
das_results = extract_das_llm(text, contexte, existing_das, dp_texte, cache=cache)
added = 0
for das in das_results:
texte = clean_diagnostic_text(das.get("texte", ""))
if not texte or not is_valid_diagnostic_text(texte):
continue
code = das.get("code_cim10")
if code:
code = normalize_code(code)
is_valid, _ = cim10_validate(code)
if not is_valid:
logger.info("DAS LLM : code %s invalide pour « %s », ignoré", code, texte)
continue
if code in existing_codes:
continue
existing_codes.add(code)
dossier.diagnostics_associes.append(Diagnostic(
texte=texte,
cim10_suggestion=code,
justification=das.get("justification"),
))
added += 1
if added:
logger.info("DAS LLM : %d diagnostics supplémentaires ajoutés", added)
cache.save()
except Exception:
logger.warning("Erreur lors de l'extraction DAS LLM", exc_info=True)
def _enrich_with_rag(dossier: DossierMedical) -> None:
"""Enrichit les diagnostics via le RAG (FAISS + Ollama)."""
try:

View File

@@ -11,7 +11,7 @@ from typing import Optional
import pdfplumber
from ..config import RAG_INDEX_DIR, CIM10_PDF, GUIDE_METHODO_PDF, CCAM_PDF, CCAM_DICT_PATH
from ..config import RAG_INDEX_DIR, CIM10_PDF, GUIDE_METHODO_PDF, CCAM_PDF, CCAM_DICT_PATH, REFERENTIELS_DIR
logger = logging.getLogger(__name__)
@@ -482,3 +482,183 @@ def get_index() -> tuple | None:
logger.info("Index FAISS chargé : %d vecteurs", _faiss_index.ntotal)
return _faiss_index, _metadata
# ---------------------------------------------------------------------------
# Chunking générique pour fichiers utilisateur (référentiels)
# ---------------------------------------------------------------------------
def chunk_user_file(file_path: Path, doc_name: str) -> list[Chunk]:
"""Découpe un fichier utilisateur en chunks pour indexation FAISS.
Dispatch selon l'extension :
- PDF : pages groupées par 2
- CSV/Excel : une ligne = un chunk
- TXT : paragraphes (blocs séparés par lignes vides)
Args:
file_path: Chemin du fichier.
doc_name: Nom du document (utilisé comme identifiant dans les métadonnées).
Returns:
Liste de Chunk prêts pour l'indexation.
"""
suffix = file_path.suffix.lower()
if suffix == ".pdf":
return _chunk_user_pdf(file_path, doc_name)
elif suffix in (".csv", ".xlsx", ".xls"):
return _chunk_user_tabular(file_path, doc_name)
elif suffix == ".txt":
return _chunk_user_txt(file_path, doc_name)
else:
logger.warning("Extension non supportée pour chunking : %s", suffix)
return []
def _chunk_user_pdf(file_path: Path, doc_name: str) -> list[Chunk]:
"""Découpe un PDF utilisateur en chunks de 2 pages."""
chunks: list[Chunk] = []
try:
with pdfplumber.open(file_path) as pdf:
page_texts: list[str] = []
start_page = 1
for page_num, page in enumerate(pdf.pages, start=1):
text = page.extract_text()
if text:
page_texts.append(text)
if len(page_texts) >= 2:
combined = "\n".join(page_texts)
if len(combined.split()) >= 10:
chunks.append(Chunk(
text=combined,
document=doc_name,
page=start_page,
))
page_texts = []
start_page = page_num + 1
if page_texts:
combined = "\n".join(page_texts)
if len(combined.split()) >= 10:
chunks.append(Chunk(
text=combined,
document=doc_name,
page=start_page,
))
except Exception:
logger.warning("Erreur lors du chunking PDF %s", file_path, exc_info=True)
logger.info("Référentiel PDF %s : %d chunks", doc_name, len(chunks))
return chunks
def _chunk_user_tabular(file_path: Path, doc_name: str) -> list[Chunk]:
"""Découpe un CSV/Excel : une ligne = un chunk."""
chunks: list[Chunk] = []
try:
import pandas as pd
suffix = file_path.suffix.lower()
if suffix == ".csv":
df = pd.read_csv(file_path, encoding="utf-8", on_bad_lines="skip")
else:
df = pd.read_excel(file_path)
for idx, row in df.iterrows():
text = " | ".join(str(v) for v in row.values if pd.notna(v))
if len(text.split()) >= 3:
chunks.append(Chunk(
text=text,
document=doc_name,
page=int(idx) + 1,
))
except Exception:
logger.warning("Erreur lors du chunking tabular %s", file_path, exc_info=True)
logger.info("Référentiel tabular %s : %d chunks", doc_name, len(chunks))
return chunks
def _chunk_user_txt(file_path: Path, doc_name: str) -> list[Chunk]:
"""Découpe un fichier TXT en paragraphes (blocs séparés par lignes vides)."""
chunks: list[Chunk] = []
try:
text = file_path.read_text(encoding="utf-8")
paragraphs = re.split(r"\n\s*\n", text)
for i, para in enumerate(paragraphs):
para = para.strip()
if len(para.split()) >= 5:
chunks.append(Chunk(
text=para,
document=doc_name,
page=i + 1,
))
except Exception:
logger.warning("Erreur lors du chunking TXT %s", file_path, exc_info=True)
logger.info("Référentiel TXT %s : %d chunks", doc_name, len(chunks))
return chunks
def add_chunks_to_index(chunks: list[Chunk]) -> int:
"""Ajoute des chunks à l'index FAISS existant (incrémental).
Charge l'index si nécessaire, encode les chunks, ajoute les vecteurs,
et sauvegarde le tout.
Args:
chunks: Liste de Chunk à ajouter.
Returns:
Nombre de chunks effectivement ajoutés.
"""
if not chunks:
return 0
import faiss
import numpy as np
from .rag_search import _get_embed_model
index_path = RAG_INDEX_DIR / "faiss.index"
meta_path = RAG_INDEX_DIR / "metadata.json"
# Charger l'index existant ou en créer un nouveau
if index_path.exists() and meta_path.exists():
faiss_idx = faiss.read_index(str(index_path))
metadata = json.loads(meta_path.read_text(encoding="utf-8"))
else:
model = _get_embed_model()
# Obtenir la dimension via un encodage test
test_vec = model.encode(["test"], normalize_embeddings=True)
dim = test_vec.shape[1]
faiss_idx = faiss.IndexFlatIP(dim)
metadata = []
# Encoder les nouveaux chunks
model = _get_embed_model()
texts = [c.text[:2000] for c in chunks]
embeddings = model.encode(texts, normalize_embeddings=True, batch_size=64)
embeddings = np.array(embeddings, dtype=np.float32)
# Ajouter à l'index
faiss_idx.add(embeddings)
# Ajouter les métadonnées
from dataclasses import asdict
for chunk in chunks:
meta = asdict(chunk)
meta["extrait"] = meta.pop("text")[:800]
metadata.append(meta)
# Sauvegarder
RAG_INDEX_DIR.mkdir(parents=True, exist_ok=True)
faiss.write_index(faiss_idx, str(index_path))
meta_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
# Invalider le singleton pour forcer le rechargement
reset_index()
logger.info("Index FAISS : %d chunks ajoutés (total : %d)", len(chunks), faiss_idx.ntotal)
return len(chunks)
def reset_index() -> None:
"""Invalide le singleton FAISS pour forcer le rechargement au prochain accès."""
global _faiss_index, _metadata
_faiss_index = None
_metadata = []

View File

@@ -473,6 +473,101 @@ def enrich_acte(acte: ActeCCAM, contexte: dict, cache: OllamaCache | None = None
logger.info("Ollama non disponible — sources FAISS CCAM conservées sans justification LLM")
def _build_prompt_das_extraction(text: str, contexte: dict, existing_das: list[str], dp_texte: str) -> str:
"""Construit le prompt pour l'extraction LLM de DAS supplémentaires."""
ctx_str = _format_contexte(contexte)
existing_str = "\n".join(f"- {d}" for d in existing_das) if existing_das else "Aucun"
return f"""Tu es un médecin DIM (Département d'Information Médicale) expert en codage PMSI.
Analyse le texte médical suivant et identifie les diagnostics associés significatifs (DAS) qui n'ont PAS encore été codés.
RÈGLES IMPÉRATIVES :
- Un DAS doit avoir mobilisé des ressources supplémentaires pendant le séjour
- Ne PAS proposer de doublons avec les DAS déjà codés ci-dessous
- Ne PAS proposer le diagnostic principal comme DAS
- Ne PAS coder les symptômes (R00-R99) si un diagnostic précis les explique
- Ne PAS coder les antécédents non pertinents pour le séjour
- Privilégie les codes CIM-10 les plus SPÉCIFIQUES (4e ou 5e caractère)
- Ne propose que des diagnostics CLAIREMENT mentionnés dans le texte
DIAGNOSTIC PRINCIPAL : {dp_texte or "Non identifié"}
DAS DÉJÀ CODÉS :
{existing_str}
CONTEXTE CLINIQUE :
{ctx_str}
TEXTE MÉDICAL :
{text[:4000]}
Réponds UNIQUEMENT avec un objet JSON au format suivant, sans aucun texte avant ou après :
{{
"diagnostics_supplementaires": [
{{
"texte": "description du diagnostic",
"code_cim10": "X99.9",
"justification": "pourquoi ce DAS est pertinent pour le séjour"
}}
]
}}
Si aucun DAS supplémentaire n'est pertinent, retourne : {{"diagnostics_supplementaires": []}}"""
def extract_das_llm(
text: str,
contexte: dict,
existing_das: list[str],
dp_texte: str,
cache: OllamaCache | None = None,
) -> list[dict]:
"""Extrait des DAS supplémentaires via un pass LLM.
Args:
text: Texte médical complet.
contexte: Contexte patient (sexe, age, etc.).
existing_das: Liste des DAS déjà codés (texte + code).
dp_texte: Texte du diagnostic principal.
cache: Cache Ollama optionnel.
Returns:
Liste de dicts {texte, code_cim10, justification} pour les DAS détectés.
"""
import hashlib
# Clé de cache basée sur le hash du texte
text_hash = hashlib.md5(text[:4000].encode()).hexdigest()[:16]
cache_key_text = f"das_extract::{text_hash}"
# Vérifier le cache
if cache is not None:
cached = cache.get(cache_key_text, "das_llm")
if cached is not None:
logger.info("Cache hit pour extraction DAS LLM")
return cached.get("diagnostics_supplementaires", [])
# Construire le prompt et appeler Ollama
prompt = _build_prompt_das_extraction(text, contexte, existing_das, dp_texte)
result = call_ollama(prompt, temperature=0.1, max_tokens=2000)
if result is None:
logger.warning("Extraction DAS LLM : Ollama non disponible")
return []
das_list = result.get("diagnostics_supplementaires", [])
if not isinstance(das_list, list):
logger.warning("Extraction DAS LLM : format inattendu")
return []
# Stocker dans le cache
if cache is not None:
cache.put(cache_key_text, "das_llm", result)
logger.info("Extraction DAS LLM : %d diagnostics supplémentaires détectés", len(das_list))
return das_list
def enrich_dossier(dossier: DossierMedical) -> None:
"""Enrichit le DP et tous les DAS d'un dossier via le RAG.