All checks were successful
- Possibilité de discuter avec un PDF ; RAG ou analyse approfondie. Enlèvement de l'autre outil PDF de discussion qui analysait d'abord un PDF en proposant directement une intégration sans attendre qu'on pose de question - Mise en place de l'import directement dans les outils dans la sidebar - Mise en place d'un outil pour créer des tables aléatoires avec possibilité d'utiliser pendant la partie - Mise en place d'un outil pour mettre en place des PNJ, scènes, chapitre.... directement à partir de la discussion avec le PDF - Mise en place RAG avec mistal-embeding ou nomic si on utilise ollama - Mise en place mistral, google en fournisseurs alternatifs pour l'IA dans le cloud - version 0.11.0-bêta
80 lines
3.3 KiB
Python
80 lines
3.3 KiB
Python
"""Use case RAG des notebooks : indexer une source PDF et retrouver les passages
|
|
pertinents pour une question.
|
|
|
|
Chaîne d'indexation : PDF → extraction texte (+OCR) → découpage en extraits courts
|
|
→ embeddings → stockage vectoriel (fichier). À la requête : on embed la question
|
|
et on récupère les extraits les plus proches (cosinus) pour ancrer le chat.
|
|
|
|
Extraits PLUS COURTS que pour l'import (recopie) : ici on veut une granularité fine
|
|
pour que la recherche pointe un passage précis, pas un demi-chapitre.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from app.application.chunking import chunk_text
|
|
from app.application.embeddings import EmbeddingProvider
|
|
from app.domain.ports import PdfTextExtractor
|
|
from app.infrastructure import vector_store
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_RAG_CHUNK_TOKENS = 600
|
|
# Un extrait avec quasi aucun texte réel (en-tête/pied de page, fragment de numéro
|
|
# de page isolé « 249 250 ») ne sert à rien en RAG → on l'écarte. Seuil bas et
|
|
# conservateur : on ne coupe QUE les fragments quasi-vides, jamais une vraie phrase.
|
|
_MIN_LETTERS = 15
|
|
|
|
|
|
def _has_enough_text(piece: str) -> bool:
|
|
return sum(c.isalpha() for c in piece) >= _MIN_LETTERS
|
|
|
|
|
|
class NotebookRagUseCase:
|
|
def __init__(
|
|
self,
|
|
extractor: PdfTextExtractor,
|
|
embedder: EmbeddingProvider,
|
|
chunk_target_tokens: int = _RAG_CHUNK_TOKENS,
|
|
) -> None:
|
|
self._extractor = extractor
|
|
self._embedder = embedder
|
|
self._chunk_target_tokens = chunk_target_tokens
|
|
|
|
async def index_source(self, source_id: str, pdf_bytes: bytes) -> dict:
|
|
"""Extrait, découpe PAR PAGE (pour garder le n° de page → citations), embed
|
|
et stocke une source. Renvoie un récap."""
|
|
doc = self._extractor.extract(pdf_bytes)
|
|
chunks: list[str] = []
|
|
pages: list[int] = []
|
|
for page in doc.pages:
|
|
for piece in chunk_text(page.text, self._chunk_target_tokens):
|
|
if not _has_enough_text(piece):
|
|
continue # fragment quasi-vide (en-tête/pied/numéro) → ignoré
|
|
chunks.append(piece)
|
|
pages.append(page.index + 1) # n° de page 1-based pour l'affichage
|
|
logger.info(
|
|
"Indexation notebook source=%s : %s page(s) (%s OCR), %s extrait(s).",
|
|
source_id, doc.page_count, doc.ocr_page_count, len(chunks),
|
|
)
|
|
if not chunks:
|
|
vector_store.save(source_id, [], [])
|
|
return {"chunks": 0, "page_count": doc.page_count, "ocr_page_count": doc.ocr_page_count}
|
|
vectors = await self._embedder.embed(chunks)
|
|
count = vector_store.save(source_id, chunks, vectors, pages)
|
|
return {
|
|
"chunks": count,
|
|
"page_count": doc.page_count,
|
|
"ocr_page_count": doc.ocr_page_count,
|
|
}
|
|
|
|
async def retrieve(self, source_ids: list[str], query: str, top_k: int = 6) -> list[dict]:
|
|
"""Passages les plus pertinents (toutes sources) pour `query`."""
|
|
ids = [s for s in source_ids if vector_store.exists(s)]
|
|
if not ids or not query.strip():
|
|
return []
|
|
query_vectors = await self._embedder.embed([query])
|
|
if not query_vectors:
|
|
return []
|
|
return vector_store.search(ids, query_vectors[0], top_k)
|