Ajout de dossiers pour la partie PNJ pour qu'on puisse les regrouper par cité par exemple et que ce soit plus lisible
136 lines
5.7 KiB
Python
136 lines
5.7 KiB
Python
"""Use case « Analyse approfondie » d'un notebook : map-reduce sur TOUT le document.
|
|
|
|
Contrairement au chat RAG (qui ne ramène que les top-k extraits), ce mode lit
|
|
l'INTÉGRALITÉ des sources par lots :
|
|
- MAP : pour chaque lot, le modèle extrait ce qui est pertinent pour la question
|
|
(ou « RAS » si rien) ;
|
|
- REDUCE : il synthétise toutes les notes en une réponse finale (streamée).
|
|
|
|
→ Répond aux questions globales/exhaustives (« liste tous les… ») quel que soit le
|
|
modèle, au prix de plusieurs appels (comme l'import). Le lot est dimensionné par
|
|
`batch_tokens` (= taille de morceau d'import) : avec un modèle gros-contexte, peu de
|
|
lots ; avec un petit modèle local, plus de lots (mais ça reste exhaustif).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import AsyncIterator
|
|
|
|
import tiktoken
|
|
|
|
from app.application.llm_retry import generate_with_retry
|
|
from app.domain.models import ChatMessage
|
|
from app.domain.ports import LLMChatProvider, LLMProvider, LLMProviderError
|
|
from app.infrastructure import vector_store
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_NO_MATCH = "RAS"
|
|
_MAP_TEMPERATURE = 0.2
|
|
|
|
_MAP_PROMPT = """Voici un EXTRAIT d'un document. Extrais UNIQUEMENT les informations
|
|
pertinentes pour répondre à la question ci-dessous. Conserve les détails utiles et
|
|
indique les numéros de page (format « p. X »). Si l'extrait ne contient RIEN de
|
|
pertinent, réponds EXACTEMENT « {no_match} » et rien d'autre.
|
|
|
|
QUESTION : {question}
|
|
|
|
--- EXTRAIT ---
|
|
{excerpt}
|
|
--- FIN EXTRAIT ---
|
|
|
|
Informations pertinentes (ou « {no_match} ») :"""
|
|
|
|
_REDUCE_SYSTEM = """Tu réponds à la question d'un MJ à partir de NOTES extraites de
|
|
l'ENSEMBLE d'un document source (donc tu as une vue COMPLÈTE, pas un simple extrait).
|
|
Synthétise ces notes en une réponse claire et structurée, cite les pages (« p. X »),
|
|
et n'invente rien qui n'y figure pas. Si une CAMPAGNE est fournie ci-dessous, relie ta
|
|
réponse à sa structure / ses PNJ pour des adaptations cohérentes.
|
|
|
|
{context_block}
|
|
--- NOTES EXTRAITES DE TOUT LE DOCUMENT ---
|
|
{notes_block}
|
|
--- FIN DES NOTES ---
|
|
|
|
Réponds en français."""
|
|
|
|
|
|
class NotebookDeepUseCase:
|
|
def __init__(self, llm: LLMProvider, batch_tokens: int = 10000) -> None:
|
|
self._llm = llm
|
|
self._batch_tokens = max(2000, batch_tokens)
|
|
|
|
async def stream(
|
|
self,
|
|
source_ids: list[str],
|
|
messages: list[ChatMessage],
|
|
context: str = "",
|
|
history_limit: int = 8,
|
|
) -> AsyncIterator[dict]:
|
|
"""Yield des évènements : {type:'progress',current,total}, {type:'token',token},
|
|
{type:'done'}. (Les erreurs LLM des lots sont tolérées : lot ignoré.)
|
|
|
|
La dernière question utilisateur sert à la LECTURE du document (map) ; la
|
|
SYNTHÈSE (reduce) reçoit les `history_limit` derniers messages → les relances
|
|
conversationnelles (« et pour les autres ? ») fonctionnent aussi en approfondi.
|
|
"""
|
|
question = next((m.content for m in reversed(messages) if m.role == "user"), "")
|
|
chunks: list[dict] = []
|
|
for sid in source_ids:
|
|
chunks.extend(vector_store.all_chunks(sid))
|
|
if not chunks:
|
|
yield {"type": "token", "token": "Aucune source indexée à analyser."}
|
|
yield {"type": "done"}
|
|
return
|
|
|
|
batches = self._group(chunks)
|
|
total = len(batches)
|
|
notes: list[str] = []
|
|
for i, batch in enumerate(batches):
|
|
yield {"type": "progress", "current": i, "total": total}
|
|
excerpt = "\n\n".join(
|
|
f"(p. {c['page']}) {c['text'].strip()}" if c.get("page") else c["text"].strip()
|
|
for c in batch
|
|
)
|
|
prompt = _MAP_PROMPT.format(no_match=_NO_MATCH, question=question, excerpt=excerpt)
|
|
try:
|
|
raw = await generate_with_retry(self._llm, prompt, temperature=_MAP_TEMPERATURE)
|
|
except LLMProviderError as exc:
|
|
logger.warning("Analyse approfondie : lot %s/%s ignoré : %s", i + 1, total, exc)
|
|
continue
|
|
answer = raw.strip()
|
|
if answer and answer.upper().rstrip(".") != _NO_MATCH:
|
|
notes.append(answer)
|
|
yield {"type": "progress", "current": total, "total": total}
|
|
|
|
notes_block = "\n\n".join(notes) if notes else "(aucune information pertinente trouvée dans le document)"
|
|
context_block = (
|
|
f"--- TA CAMPAGNE (structure, PNJ, univers) ---\n{context.strip()}\n--- FIN CAMPAGNE ---\n\n"
|
|
if context.strip() else ""
|
|
)
|
|
system_prompt = _REDUCE_SYSTEM.format(context_block=context_block, notes_block=notes_block)
|
|
# Historique récent pour la cohérence des relances ; on garantit que le
|
|
# dernier message est bien la question courante.
|
|
reduce_messages = messages[-history_limit:] if messages else [ChatMessage(role="user", content=question)]
|
|
llm_chat: LLMChatProvider = self._llm # type: ignore[assignment]
|
|
async for token in llm_chat.stream_chat(reduce_messages, system_prompt=system_prompt):
|
|
yield {"type": "token", "token": token}
|
|
yield {"type": "done"}
|
|
|
|
def _group(self, chunks: list[dict]) -> list[list[dict]]:
|
|
"""Regroupe les extraits en lots ~`batch_tokens` (compte tiktoken)."""
|
|
enc = tiktoken.get_encoding("cl100k_base")
|
|
batches: list[list[dict]] = []
|
|
current: list[dict] = []
|
|
current_tokens = 0
|
|
for c in chunks:
|
|
t = len(enc.encode(c.get("text", "")))
|
|
if current and current_tokens + t > self._batch_tokens:
|
|
batches.append(current)
|
|
current, current_tokens = [], 0
|
|
current.append(c)
|
|
current_tokens += t
|
|
if current:
|
|
batches.append(current)
|
|
return batches
|