From f44216b95bb4b1e359875c6fa2f6212823678a41 Mon Sep 17 00:00:00 2001 From: dom Date: Thu, 12 Feb 2026 23:12:39 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20pass=20LLM=20hybride=20pour=20DAS=20+?= =?UTF-8?q?=20interface=20admin=20r=C3=A9f=C3=A9rentiels=20RAG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/config.py | 3 + src/medical/cim10_extractor.py | 77 +++++++ src/medical/rag_index.py | 182 ++++++++++++++- src/medical/rag_search.py | 95 ++++++++ src/viewer/app.py | 73 +++++- src/viewer/referentiels.py | 155 +++++++++++++ src/viewer/templates/admin_referentiels.html | 220 +++++++++++++++++++ src/viewer/templates/base.html | 6 + tests/test_das_llm.py | 213 ++++++++++++++++++ tests/test_referentiels.py | 179 +++++++++++++++ 10 files changed, 1197 insertions(+), 6 deletions(-) create mode 100644 src/viewer/referentiels.py create mode 100644 src/viewer/templates/admin_referentiels.html create mode 100644 tests/test_das_llm.py create mode 100644 tests/test_referentiels.py diff --git a/src/config.py b/src/config.py index 7fd1482..44afe9d 100644 --- a/src/config.py +++ b/src/config.py @@ -46,6 +46,9 @@ NUM_UM = "0000" # --- Configuration RAG --- RAG_INDEX_DIR = BASE_DIR / "data" / "rag_index" +REFERENTIELS_DIR = BASE_DIR / "data" / "referentiels" +UPLOAD_MAX_SIZE_MB = 50 +ALLOWED_EXTENSIONS = {".pdf", ".csv", ".xlsx", ".xls", ".txt"} CIM10_DICT_PATH = BASE_DIR / "data" / "cim10_dict.json" CCAM_DICT_PATH = BASE_DIR / "data" / "ccam_dict.json" CIM10_PDF = Path("/home/dom/ai/aivanov_CIM/cim-10-fr_2026_a_usage_pmsi_version_provisoire_111225.pdf") diff --git a/src/medical/cim10_extractor.py b/src/medical/cim10_extractor.py index 0b31926..b45f37b 100644 --- a/src/medical/cim10_extractor.py +++ b/src/medical/cim10_extractor.py @@ -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: diff --git a/src/medical/rag_index.py b/src/medical/rag_index.py index 9691f07..183ab0a 100644 --- a/src/medical/rag_index.py +++ b/src/medical/rag_index.py @@ -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 = [] diff --git a/src/medical/rag_search.py b/src/medical/rag_search.py index c4b221d..cca37c3 100644 --- a/src/medical/rag_search.py +++ b/src/medical/rag_search.py @@ -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. diff --git a/src/viewer/app.py b/src/viewer/app.py index 270dbfc..5723685 100644 --- a/src/viewer/app.py +++ b/src/viewer/app.py @@ -11,8 +11,11 @@ import requests from flask import Flask, abort, render_template, request, jsonify from markupsafe import Markup -from ..config import STRUCTURED_DIR, OLLAMA_URL, CCAM_DICT_PATH, DossierMedical +from werkzeug.utils import secure_filename + +from ..config import STRUCTURED_DIR, OLLAMA_URL, CCAM_DICT_PATH, DossierMedical, ALLOWED_EXTENSIONS, UPLOAD_MAX_SIZE_MB from .. import config as cfg +from .referentiels import ReferentielManager logger = logging.getLogger(__name__) @@ -271,12 +274,12 @@ def create_app() -> Flask: def reprocess(filepath: str): """Relance le traitement d'un dossier.""" from ..main import process_pdf, write_outputs - + dossier = load_dossier(filepath) source_file = dossier.source_file if not source_file: return jsonify({"error": "Fichier source introuvable"}), 400 - + # Chercher le PDF source dans input/ input_dir = Path(__file__).parent.parent.parent / "input" pdf_path = None @@ -284,10 +287,10 @@ def create_app() -> Flask: if p.is_file(): pdf_path = p break - + if not pdf_path: return jsonify({"error": f"PDF source '{source_file}' introuvable"}), 404 - + try: anonymized_text, new_dossier, report = process_pdf(pdf_path) stem = pdf_path.stem.replace(" ", "_") @@ -300,4 +303,64 @@ def create_app() -> Flask: logger.exception("Erreur lors du retraitement") return jsonify({"error": str(e)}), 500 + # ------------------------------------------------------------------ + # Routes admin référentiels + # ------------------------------------------------------------------ + + ref_manager = ReferentielManager() + + @app.route("/admin/referentiels") + def admin_referentiels(): + refs = ref_manager.list_all() + return render_template("admin_referentiels.html", referentiels=refs, max_size=UPLOAD_MAX_SIZE_MB) + + @app.route("/admin/referentiels/upload", methods=["POST"]) + def upload_referentiel(): + if "file" not in request.files: + return jsonify({"error": "Aucun fichier envoyé"}), 400 + f = request.files["file"] + if not f.filename: + return jsonify({"error": "Nom de fichier vide"}), 400 + + filename = secure_filename(f.filename) + try: + file_data = f.read() + ref = ref_manager.add_file(filename, file_data) + return jsonify({"ok": True, "referentiel": ref}) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + + @app.route("/admin/referentiels//index", methods=["POST"]) + def index_referentiel(ref_id: str): + try: + count = ref_manager.index_referentiel(ref_id) + return jsonify({"ok": True, "chunks": count}) + except ValueError as e: + return jsonify({"error": str(e)}), 404 + except Exception as e: + logger.exception("Erreur lors de l'indexation du référentiel %s", ref_id) + return jsonify({"error": str(e)}), 500 + + @app.route("/admin/referentiels/", methods=["DELETE"]) + def delete_referentiel(ref_id: str): + if ref_manager.remove(ref_id): + return jsonify({"ok": True}) + return jsonify({"error": "Référentiel introuvable"}), 404 + + @app.route("/admin/referentiels/rebuild-index", methods=["POST"]) + def rebuild_index(): + try: + from ..medical.rag_index import build_index + build_index(force=True) + # Réindexer tous les référentiels actifs + reindexed = 0 + for ref in ref_manager.list_all(): + if ref["status"] == "indexed": + ref_manager.index_referentiel(ref["id"]) + reindexed += 1 + return jsonify({"ok": True, "reindexed": reindexed}) + except Exception as e: + logger.exception("Erreur lors du rebuild de l'index") + return jsonify({"error": str(e)}), 500 + return app diff --git a/src/viewer/referentiels.py b/src/viewer/referentiels.py new file mode 100644 index 0000000..6aa1c16 --- /dev/null +++ b/src/viewer/referentiels.py @@ -0,0 +1,155 @@ +"""Gestionnaire de référentiels utilisateur pour le RAG.""" + +from __future__ import annotations + +import json +import logging +import shutil +import uuid +from datetime import datetime +from pathlib import Path + +from ..config import REFERENTIELS_DIR, ALLOWED_EXTENSIONS, UPLOAD_MAX_SIZE_MB + +logger = logging.getLogger(__name__) + + +class ReferentielManager: + """CRUD pour les fichiers de référentiels utilisateur. + + Stocke les fichiers dans REFERENTIELS_DIR avec un index.json + pour les métadonnées. + """ + + def __init__(self, referentiels_dir: Path | None = None): + self._dir = referentiels_dir or REFERENTIELS_DIR + self._dir.mkdir(parents=True, exist_ok=True) + self._index_path = self._dir / "index.json" + self._index: list[dict] = self._load_index() + + def _load_index(self) -> list[dict]: + if self._index_path.exists(): + try: + return json.loads(self._index_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, KeyError): + logger.warning("Index référentiels corrompu, réinitialisé") + return [] + + def _save_index(self) -> None: + self._index_path.write_text( + json.dumps(self._index, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + def list_all(self) -> list[dict]: + """Retourne la liste de tous les référentiels.""" + return list(self._index) + + def get(self, ref_id: str) -> dict | None: + """Retourne un référentiel par son ID.""" + for ref in self._index: + if ref["id"] == ref_id: + return ref + return None + + def add_file(self, filename: str, file_data: bytes) -> dict: + """Ajoute un fichier de référentiel. + + Args: + filename: Nom original du fichier. + file_data: Contenu binaire du fichier. + + Returns: + Métadonnées du référentiel créé. + + Raises: + ValueError: Extension non autorisée ou taille dépassée. + """ + ext = Path(filename).suffix.lower() + if ext not in ALLOWED_EXTENSIONS: + raise ValueError(f"Extension '{ext}' non autorisée. Extensions valides : {ALLOWED_EXTENSIONS}") + + size_mb = len(file_data) / (1024 * 1024) + if size_mb > UPLOAD_MAX_SIZE_MB: + raise ValueError(f"Fichier trop volumineux ({size_mb:.1f} Mo > {UPLOAD_MAX_SIZE_MB} Mo)") + + ref_id = uuid.uuid4().hex[:12] + safe_name = f"{ref_id}_{Path(filename).stem}{ext}" + file_path = self._dir / safe_name + + file_path.write_bytes(file_data) + + ref = { + "id": ref_id, + "filename": filename, + "stored_name": safe_name, + "extension": ext, + "size_bytes": len(file_data), + "date_added": datetime.now().isoformat(), + "status": "uploaded", + "chunks_count": 0, + } + self._index.append(ref) + self._save_index() + + logger.info("Référentiel ajouté : %s (%s)", filename, ref_id) + return ref + + def remove(self, ref_id: str) -> bool: + """Supprime un référentiel (fichier + métadonnées). + + Returns: + True si trouvé et supprimé, False sinon. + """ + ref = self.get(ref_id) + if not ref: + return False + + file_path = self._dir / ref["stored_name"] + if file_path.exists(): + file_path.unlink() + + self._index = [r for r in self._index if r["id"] != ref_id] + self._save_index() + + logger.info("Référentiel supprimé : %s (%s)", ref["filename"], ref_id) + return True + + def index_referentiel(self, ref_id: str) -> int: + """Indexe un référentiel dans FAISS. + + Args: + ref_id: ID du référentiel à indexer. + + Returns: + Nombre de chunks indexés. + + Raises: + ValueError: Référentiel introuvable. + """ + ref = self.get(ref_id) + if not ref: + raise ValueError(f"Référentiel {ref_id} introuvable") + + file_path = self._dir / ref["stored_name"] + if not file_path.exists(): + raise ValueError(f"Fichier {ref['stored_name']} introuvable") + + from ..medical.rag_index import chunk_user_file, add_chunks_to_index + + doc_name = f"ref:{ref['filename']}" + chunks = chunk_user_file(file_path, doc_name) + + if not chunks: + ref["status"] = "empty" + ref["chunks_count"] = 0 + self._save_index() + return 0 + + count = add_chunks_to_index(chunks) + ref["status"] = "indexed" + ref["chunks_count"] = count + self._save_index() + + logger.info("Référentiel indexé : %s → %d chunks", ref["filename"], count) + return count diff --git a/src/viewer/templates/admin_referentiels.html b/src/viewer/templates/admin_referentiels.html new file mode 100644 index 0000000..c84b5e2 --- /dev/null +++ b/src/viewer/templates/admin_referentiels.html @@ -0,0 +1,220 @@ +{% extends "base.html" %} + +{% block title %}Référentiels RAG{% endblock %} + +{% block sidebar %} +
Admin
+Référentiels RAG +Retour aux dossiers +{% endblock %} + +{% block content %} +

Référentiels RAG

+

+ Ajoutez des documents de référence (PDF, CSV, Excel, TXT) pour enrichir la base de connaissances du RAG. +

+ + +
+

Ajouter un référentiel

+
+
+ + +
+ + +
+

+ Extensions : .pdf, .csv, .xlsx, .xls, .txt — Max {{ max_size }} Mo +

+
+ + +
+
+

Référentiels indexés

+ +
+ + + + + + + + + + + + + + + {% for ref in referentiels %} + + + + + + + + + + {% endfor %} + {% if not referentiels %} + + + + {% endif %} + +
NomTypeTailleDateChunksStatutActions
{{ ref.filename }}{{ ref.extension }}{{ "%.1f"|format(ref.size_bytes / 1024 / 1024) }} Mo{{ ref.date_added[:10] }}{{ ref.chunks_count }} + {% if ref.status == 'indexed' %} + Indexé + {% elif ref.status == 'empty' %} + Vide + {% else %} + Uploadé + {% endif %} + + + +
Aucun référentiel
+
+ +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/src/viewer/templates/base.html b/src/viewer/templates/base.html index 855ae8f..3585ce0 100644 --- a/src/viewer/templates/base.html +++ b/src/viewer/templates/base.html @@ -227,6 +227,12 @@ +