diff --git a/brain/app/application/notebook_chat.py b/brain/app/application/notebook_chat.py index 8e88ac3..a9b33eb 100644 --- a/brain/app/application/notebook_chat.py +++ b/brain/app/application/notebook_chat.py @@ -9,6 +9,7 @@ from __future__ import annotations from typing import AsyncIterator from app.application.notebook_rag import NotebookRagUseCase +from app.application.query_rewrite import standalone_question from app.domain.models import ChatMessage from app.domain.ports import LLMChatProvider @@ -68,8 +69,12 @@ class NotebookChatUseCase: context: str = "", top_k: int = 6, ) -> AsyncIterator[str]: - last_user = next((m.content for m in reversed(messages) if m.role == "user"), "") - passages = await self._rag.retrieve(source_ids, last_user, top_k=top_k) + # Question AUTONOME pour la recherche : sur une relance (« et ses + # faiblesses ? »), l'embedding du dernier message seul ne contient pas + # le sujet → on le résout depuis l'historique (best-effort, 1 appel léger, + # uniquement à partir du 2e tour). La réponse, elle, voit tout l'historique. + search_query = await standalone_question(self._llm, messages) + passages = await self._rag.retrieve(source_ids, search_query, top_k=top_k) sources_block = ( "\n\n".join(self._format_passage(p) for p in passages) if passages else "(aucun passage pertinent trouvé dans les sources)" diff --git a/brain/app/application/notebook_deep.py b/brain/app/application/notebook_deep.py index 5fa1f7d..ca8df1c 100644 --- a/brain/app/application/notebook_deep.py +++ b/brain/app/application/notebook_deep.py @@ -20,6 +20,7 @@ from typing import AsyncIterator import tiktoken from app.application.llm_retry import generate_with_retry +from app.application.query_rewrite import standalone_question from app.domain.models import ChatMessage from app.domain.ports import LLMChatProvider, LLMProvider, LLMProviderError from app.infrastructure import vector_store @@ -85,7 +86,10 @@ class NotebookDeepUseCase: 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"), "") + # Question autonome : la phase MAP lit chaque lot avec LA question — sur + # une relance conversationnelle, il faut y résoudre les références + # implicites, sinon les lots sont filtrés sur un texte sans sujet. + question = await standalone_question(self._llm, messages) chunks: list[dict] = [] for sid in source_ids: chunks.extend(vector_store.all_chunks(sid)) diff --git a/brain/app/application/query_rewrite.py b/brain/app/application/query_rewrite.py new file mode 100644 index 0000000..2107169 --- /dev/null +++ b/brain/app/application/query_rewrite.py @@ -0,0 +1,66 @@ +"""Réécriture de la question courante en question AUTONOME (chat des ateliers). + +Problème : le retrieval (embedding) et la phase MAP de l'analyse approfondie ne +voient que le DERNIER message. Une relance comme « et ses faiblesses ? » ne +contient pas le sujet (Strahd) → recherche aveugle. La parade standard +(conversational query rewriting) : un appel LLM léger condense la conversation +en une question autonome, utilisée UNIQUEMENT pour la recherche — la réponse +finale, elle, voit toujours l'historique complet. +""" +from __future__ import annotations + +import logging + +from app.domain.models import ChatMessage + +logger = logging.getLogger(__name__) + +# Nombre de messages récents fournis au réécrivain (assez pour résoudre les +# pronoms, pas plus — la latence de cet appel doit rester négligeable). +_MAX_HISTORY = 6 + +# Garde-fou : une « question » réécrite anormalement longue est suspecte (le +# modèle a divagué) → on retombe sur la question brute. +_MAX_REWRITE_CHARS = 400 + +_REWRITE_PROMPT = """Voici la fin d'une conversation entre un Maître de Jeu et son assistant. +Réécris le DERNIER message de l'utilisateur en une question AUTONOME et complète : +remplace les pronoms et références implicites (« il », « ses », « ce lieu », « et pour +les autres ? ») par ce qu'ils désignent dans la conversation. + +Règles : +- Réponds UNIQUEMENT par la question réécrite, sans guillemets ni préfixe. +- Conserve la langue et l'intention d'origine. N'ajoute RIEN qui n'est pas demandé. +- Si le dernier message est déjà autonome, recopie-le tel quel. + +--- CONVERSATION --- +{conversation} +--- FIN --- + +Question autonome :""" + + +async def standalone_question(llm, messages: list[ChatMessage]) -> str: + """Condense `messages` en une question autonome pour la RECHERCHE. + + Best-effort : premier message de la conversation, échec LLM ou réponse + suspecte → on renvoie simplement la dernière question brute (comportement + historique). `llm` doit exposer `generate()` (duck typing des adapters). + """ + last_user = next((m.content for m in reversed(messages) if m.role == "user"), "") + user_turns = sum(1 for m in messages if m.role == "user" and m.content.strip()) + if user_turns <= 1 or not last_user.strip(): + return last_user # pas d'historique à résoudre → appel LLM inutile + + recent = [m for m in messages if m.content.strip()][-_MAX_HISTORY:] + conversation = "\n".join(f"{m.role.upper()}: {m.content.strip()}" for m in recent) + try: + raw = await llm.generate( + _REWRITE_PROMPT.format(conversation=conversation), temperature=0.0) + except Exception as exc: # noqa: BLE001 — la recherche dégradée vaut mieux que pas de réponse + logger.warning("Réécriture de question ignorée (échec LLM) : %s", exc) + return last_user + rewritten = (raw or "").strip().strip('"').strip() + if not rewritten or len(rewritten) > _MAX_REWRITE_CHARS: + return last_user + return rewritten