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

@@ -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 = []