Ateliers : reecriture de la question en question autonome avant recherche
Sur une relance conversationnelle (et ses faiblesses ?), l embedding du dernier message seul ne contient pas le sujet -> retrieval aveugle. Un appel LLM leger (temperature 0, uniquement a partir du 2e tour) resout les references implicites depuis l historique ; la question autonome sert a la RECHERCHE (chat RAG + phase MAP de l analyse approfondie), la reponse finale voit toujours l historique complet. Best-effort : tout echec retombe sur la question brute (comportement historique). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
from typing import AsyncIterator
|
from typing import AsyncIterator
|
||||||
|
|
||||||
from app.application.notebook_rag import NotebookRagUseCase
|
from app.application.notebook_rag import NotebookRagUseCase
|
||||||
|
from app.application.query_rewrite import standalone_question
|
||||||
from app.domain.models import ChatMessage
|
from app.domain.models import ChatMessage
|
||||||
from app.domain.ports import LLMChatProvider
|
from app.domain.ports import LLMChatProvider
|
||||||
|
|
||||||
@@ -68,8 +69,12 @@ class NotebookChatUseCase:
|
|||||||
context: str = "",
|
context: str = "",
|
||||||
top_k: int = 6,
|
top_k: int = 6,
|
||||||
) -> AsyncIterator[str]:
|
) -> AsyncIterator[str]:
|
||||||
last_user = next((m.content for m in reversed(messages) if m.role == "user"), "")
|
# Question AUTONOME pour la recherche : sur une relance (« et ses
|
||||||
passages = await self._rag.retrieve(source_ids, last_user, top_k=top_k)
|
# 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 = (
|
sources_block = (
|
||||||
"\n\n".join(self._format_passage(p) for p in passages)
|
"\n\n".join(self._format_passage(p) for p in passages)
|
||||||
if passages else "(aucun passage pertinent trouvé dans les sources)"
|
if passages else "(aucun passage pertinent trouvé dans les sources)"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from typing import AsyncIterator
|
|||||||
import tiktoken
|
import tiktoken
|
||||||
|
|
||||||
from app.application.llm_retry import generate_with_retry
|
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.models import ChatMessage
|
||||||
from app.domain.ports import LLMChatProvider, LLMProvider, LLMProviderError
|
from app.domain.ports import LLMChatProvider, LLMProvider, LLMProviderError
|
||||||
from app.infrastructure import vector_store
|
from app.infrastructure import vector_store
|
||||||
@@ -85,7 +86,10 @@ class NotebookDeepUseCase:
|
|||||||
SYNTHÈSE (reduce) reçoit les `history_limit` derniers messages → les relances
|
SYNTHÈSE (reduce) reçoit les `history_limit` derniers messages → les relances
|
||||||
conversationnelles (« et pour les autres ? ») fonctionnent aussi en approfondi.
|
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] = []
|
chunks: list[dict] = []
|
||||||
for sid in source_ids:
|
for sid in source_ids:
|
||||||
chunks.extend(vector_store.all_chunks(sid))
|
chunks.extend(vector_store.all_chunks(sid))
|
||||||
|
|||||||
66
brain/app/application/query_rewrite.py
Normal file
66
brain/app/application/query_rewrite.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user