
Dans ce chapitre, nous allons développer une application de chatbot avancée pour Josiane, l'assistante de la mairie. L'objectif est de créer une interface utilisateur interactive avec Streamlit, connectée à l'API Mistral, capable non seulement de converser mais aussi de puiser dans une base de connaissances spécifique à la mairie pour fournir des réponses précises et contextuelles aux administrés. Nous aborderons la gestion de l'historique, l'intégration d'une recherche sémantique (RAG - Retrieval-Augmented Generation)
Avant de coder l'interface, comprenons la structure de notre projet. Une approche modulaire rend le code plus facile à maintenir et à tester.
Structure Modulaire : Notre application est organisée en plusieurs fichiers :
MistralChat.py: Le script principal de l'application Streamlit.
indexer.py: Un script séparé pour préparer notre base de connaissances (création de l'index de recherche).
utils/: Un dossier contenant des modules pour des tâches spécifiques :
config.py: Gestion de la configuration (clés API, chemins de fichiers).
data_loader.py: Chargement et préparation des documents sources.
vector_store.py: Fonctions pour l'embedding de texte et la recherche dans l'index vectoriel (Faiss).
database.py: Gestion de la base de données pour le feedback utilisateur.
logging_config.py(ou similaire) : Configuration du logging pour suivre le comportement de l'application. (Suggestion : Le code fourni utilise logging, il serait bon d'avoir un fichier de configuration dédié ou une fonction setup comme utils.setup_logging).
pages/: Dossier pour les pages Streamlit additionnelles (ex:1_Feedback_Viewer.py).
requirements.txt: Liste des dépendances Python.
.env: Fichier (à ne pas partager) pour stocker les secrets comme la clé API Mistral.
Dépendances Clés : Assurez-vous d'installer les bibliothèques listées dansrequirements.txt, notamment :
streamlit: Pour l'interface web.
mistralai-client: Pour interagir avec l'API Mistral.
python-dotenv: Pour charger les variables d'environnement depuis le fichier.env.
numpy: Pour les opérations numériques (embeddings).
faiss-cpu: Pour la recherche de similarité vectorielle.
langchain,pypdf,langchain-text-splitters: Pour le chargement et le découpage des documents.
PyYAML: Pour lire les fichiers de configuration.
SQLAlchemy: Pour l'interaction avec la base de données de feedback.
Pour créer un chatbot efficace, il est essentiel de gérer l'historique des conversations afin de maintenir la cohérence des réponses.
Streamlit nous permet de faire cela facilement grâce àst.session_start
import streamlit as st
import os
from mistralai.client import MistralClient
from mistralai.models.chat_completion import ChatMessageNous commençons par importer les bibliothèques nécessaires :
streamlitpour créer l'interface utilisateur interactive
ospour accéder aux variables d'environnement
MistralClientpour communiquer avec l'API Mistral
ChatMessagepour formater les messages selon les attentes de l'API
Ensuite, nous configurons la connexion à l'API Mistral :
api_key = os.environ.get("MISTRAL_API_KEY", "votre_clé_api_ici")
client = MistralClient(api_key=api_key)
model = "mistral-large-latest"if "messages" not in st.session_state:
st.session_state.messages = [{"role": "assistant", "content": "Bonjour, je suis l'assistant virtuel de la mairie. Comment puis-je vous aider aujourd'hui?"}]Cette partie est cruciale pour la gestion de la mémoire :
st.session_stateest un dictionnaire spécial de Streamlit qui persiste entre les rechargements de page
Nous vérifions si la clé "messages" existe déjà, sinon nous l'initialisons
Nous ajoutons un message d'accueil qui sera affiché au démarrage de l'application
def construire_prompt_session(messages, max_messages=10):
recent_messages = messages[-max_messages:] if len(messages) > max_messages else messages
formatted_messages = [ChatMessage(role=msg["role"], content=msg["content"])
for msg in recent_messages]
return formatted_messagesCette fonction est essentielle pour maintenir la cohérence des réponses :
Elle prend l'historique complet des messages et en extrait les plus récents (paramètremax_messages)
Cette limitation est importante pour éviter de dépasser les contraintes de tokens des modèles
Elle convertit ensuite ces messages au format attendu par l'API Mistral
Le résultat est une liste deChatMessagequi contient le contexte conversationnel
def generer_reponse(prompt_messages):
try:
response = client.chat(model=model, messages=prompt_messages)
return response.choices[0].message.content
except Exception as e:
st.error(f"Erreur lors de la génération de la réponse: {e}")
return "Je suis désolé, j'ai rencontré un problème. Veuillez réessayer."Cette fonction :
Envoie les messages formatés à l'API Mistral
Récupère la première réponse générée (choices[0])
Gère les erreurs potentielles et affiche un message d'erreur si nécessaire
st.title("Assistant Virtuel de la Mairie")
# Affichage des messages précédents
for message in st.session_state.messages:
with st.chat_message(message["role"]):
st.write(message["content"])Ici, nous allons :
définir le titre de l'application
parcourir tous les messages enregistrés dans la session
utiliserst.chat_messagepour créer des bulles de chat distinctes pour chaque message
afficher le contenu de chaque message à l’aide de la fonctionst.write
if prompt := st.chat_input("Comment puis-je vous aider?"):
# Ajout du message de l'utilisateur à l'historique
st.session_state.messages.append({"role": "user", "content": prompt})
# Affichage du message de l'utilisateur
with st.chat_message("user"):
st.write(prompt)Cette partie gère l'interaction avec l'utilisateur :
st.chat_inputcrée une zone de saisie pour l'utilisateur
Lorsqu'un message est soumis, il est ajouté à l'historique et affiché dans l'interface
# Préparation du prompt avec l'historique
prompt_messages = construire_prompt_session(st.session_state.messages)
# Affichage d'un indicateur de chargement
with st.chat_message("assistant"):
message_placeholder = st.empty()
message_placeholder.text("En train de réfléchir...")
# Génération de la réponse
response = generer_reponse(prompt_messages)
# Affichage de la réponse
message_placeholder.write(response)
# Ajout de la réponse à l'historique
st.session_state.messages.append({"role": "assistant", "content": response})Ensuite :
Nous construisons le prompt enrichi avec l'historique de la conversation
Nous créons un emplacement temporaire avecst.empty()pour afficher un indicateur de chargement
Nous générons la réponse en appelant la fonctiongenerer_reponse
Nous remplaçons l'indicateur de chargement par la réponse générée
Enfin, nous ajoutons cette réponse à l'historique pour les futures interactions
Voyons à présent ce que cela donne en vidéo :
Dans cette vidéo, nous avons vu la :
Mise en place de l’historique conversationnel : On y voit comment enregistrer les messages de l’utilisateur et les réponses du système dans une liste (ou via st.session_state) pour réinjecter le contexte dans le prompt.
Réinjection du contexte dans le prompt : La vidéo explique comment combiner une partie ou l’intégralité de l’historique des échanges pour enrichir chaque nouvelle requête envoyée à l’API.
Construction de l’interface de discussion avec Streamlit : On détaille la création d’un chat interactif en Python, permettant de gérer la mémoire courte et d’assurer la cohérence des réponses générées par le modèle de langage.
Pour Josiane, cette application offre un outil pratique qui lui permettra de gérer efficacement les questions récurrentes des administrés tout en maintenant une conversation naturelle et cohérente.
Dans la section précédente, nous avons vu comment créer une interface de chat avec gestion de la mémoire pour maintenir la cohérence des conversations. Maintenant, nous allons aller plus loin en intégrant un système de recherche des segments pertinents dans une base de connaissances pour fournir des réponses plus précises et contextualisées.
Avant de pouvoir rechercher des informations pertinentes pour répondre à une question, nous devons d'abord traiter et organiser notre base de connaissances (l'ensemble des documents administratifs, ici). Ce processus s'appelle l'indexation. L'objectif est de transformer nos documents bruts en une structure de données qui permet une recherche sémantique rapide et efficace. Ce processus se déroule généralement en plusieurs étapes clés, souvent exécutées en amont via un script séparé (comme indexer.py dans notre exemple) :
Étape 1 : Chargement des données (Loading)
La première étape consiste à charger le contenu des documents depuis leur source. Ces documents peuvent être de différents formats (PDF, DOCX, CSV, pages web, etc.). Pour chaque document, nous extrayons le texte brut. Utiliser des bibliothèques comme PyPDF2 pour les PDF, python-docx pour les DOCX, pandas pour les CSV pour lire les fichiers et en extraire le contenu textuel. Dans notre application, la fonction load_documents (dans utils/data_loader.py) parcourt le dossier inputs et utilise des fonctions spécifiques pour extraire le texte de chaque type de fichier supporté (ou seulement PDF dans notre version simplifiée).
Étape 2 : Découpage en segments (Chunking / Splitting)
Les documents sont souvent trop longs pour être traités efficacement en une seule fois par les modèles d'embedding ou même pour être injectés entièrement dans le prompt final (à cause des limites de contexte des LLMs). De plus, la recherche est plus précise si elle cible des passages spécifiques plutôt qu'un document entier. Nous découpons donc le texte de chaque document en plus petits segments, appelés "chunks". Le script indexer.py utilise un TextSplitter pour diviser les textes chargés en une liste de chunks.
Étape 3 : Encodage des segments en vecteurs (Embedding)
Chaque chunk de texte est ensuite transformé en une représentation numérique dense appelée embedding (ou vecteur sémantique), exactement comme nous le ferons plus tard pour la question de l'utilisateur. Nous utilisons pour cela un modèle d'embedding (ici, mistral-embed).
Chaque chunk est passé à la fonction embed, qui appelle l'API Mistral pour obtenir son vecteur correspondant. On obtient ainsi une liste de vecteurs, où chaque vecteur représente sémantiquement un chunk de notre base de connaissances.
Étape 4 : Création et stockage de l'index vectoriel (Vector Store)
Les embeddings générés (les vecteurs) ainsi que les textes des chunks correspondants sont stockés dans une base de données vectorielle (ou un vector store). Cette base de données est spécialement conçue pour effectuer des recherches de similarité très rapides.
FAISS crée un index à partir des embeddings des chunks.
Une fois ces étapes terminées, notre base de connaissances est "indexée". Nous disposons d'un index FAISS et d'une liste de chunks prêts à être utilisés par l'application de chat pour la recherche sémantique. C'est cet index et ces chunks qui sont utilisés par la fonction rechercher_segments_pertinents décrite ensuite.
L'idée principale est la suivante : au lieu de simplement envoyer la question de l'utilisateur et l'historique de conversation au modèle, nous allons d'abord chercher dans une base de documents administratifs les passages les plus pertinents par rapport à cette question, puis les inclure dans le prompt.
La première étape consiste à transformer la question de l'utilisateur en une représentation numérique appelée embedding :
def embed(text: str):
"""
Fonction qui retourne l'embedding d'un texte en utilisant l'API Mistral.
"""
try:
response = client.embeddings(
model="mistral-embed",
input=text
)
return np.array(response.data[0].embedding)
except Exception as e:
st.error(f"Erreur lors de la génération de l'embedding: {e}")Nous pouvons rechercher les segments les plus pertinents pour une question donnée :
def rechercher_segments_pertinents(question, k=2):
def rechercher_segments_pertinents(question, k=2):
"""
Recherche les segments les plus pertinents par rapport à la question posée.
Args:
question (str): La question posée par l'utilisateur
k (int): Nombre de segments à récupérer
Returns:
list: Liste des segments pertinents
"""
# Obtenir l'embedding de la question
question_embedding = embed(question)
question_embedding = np.array([question_embedding]).astype('float32')
# Recherche dans l'index
distances, indices = index.search(question_embedding, k)
# Récupération des segments pertinents
segments_pertinents = [chunks[i] for i in indices[0]]
return segments_pertinentsCette fonction :
Convertit la question en vecteur (embedding)
Effectue une recherche des k vecteurs les plus proches dans l'index Faiss
Récupère les segments de texte correspondants
Cette étape consiste à intégrer les segments pertinents dans le prompt envoyé au modèle :
def construire_prompt_session(messages, question=None, max_messages=5) :
def construire_prompt_session(messages, question=None, max_messages=5):
"""
Construit un prompt enrichi avec les segments pertinents et l'historique récent.
"""
# Limiter le nombre de messages récents
recent_messages = messages[-max_messages:] if len(messages) > max_messages else messages
# Si une question est fournie, rechercher les segments pertinents
context_segments = []
if question:
context_segments = rechercher_segments_pertinents(question)
# Création du système prompt avec le contexte
system_prompt = "Vous êtes l'assistant virtuel de la mairie de Trifouillis-sur-Loire. "
if context_segments:
system_prompt += "Veuillez utiliser les informations suivantes pour répondre à la question:\n\n"
system_prompt += "\n\n".join(context_segments)
system_prompt += "\n\nSi les informations fournies ne sont pas suffisantes pour répondre précisément, veuillez l'indiquer."
# Création des messages formatés
formatted_messages = [ChatMessage(role="system", content=system_prompt)]
# Ajout des messages récents
for msg in recent_messages:
formatted_messages.append(ChatMessage(role=msg["role"], content=msg["content"]))
return formatted_messagesCe qui est particulièrement intéressant ici, c'est l'utilisation du "system prompt" pour injecter le contexte. Nous :
Créons un message système qui définit le rôle de l'assistant
Ajoutons les segments pertinents trouvés dans la base de connaissances
Donnons une instruction claire au modèle pour utiliser ces informations
Incluons l'historique récent de la conversation
L'intégration dans l'application se fait principalement en modifiant la façon dont nous construisons le prompt :
# Préparation du prompt avec l'historique et les segments pertinents
prompt_messages = construire_prompt_session(st.session_state.messages, question=prompt)Nous passons maintenant la question de l'utilisateur en plus de l'historique pour permettre la recherche contextuelle.
Dans cette vidéo, nous avons vu :
Encodage vectoriel des questions : Nous transformons les questions des utilisateurs en vecteurs (embeddings) qui capturent la sémantique du texte, permettant ainsi une recherche par similarité.
Recherche dans une base de connaissances : L'application identifie les segments de documents les plus pertinents en comparant l'embedding de la question avec ceux des documents préalablement indexés.
Réinjection contextuelle dans le prompt : Les segments pertinents sont intégrés au prompt envoyé au modèle, lui fournissant ainsi le contexte nécessaire pour répondre avec précision.
Grâce à ces améliorations, Josiane dispose maintenant d'un assistant conversationnel bien plus puissant. Lorsqu'un administré pose une question sur les documents nécessaires pour un mariage, par exemple, l'application ne se contente pas de générer une réponse approximative. Elle recherche d'abord les segments pertinents dans la base documentaire de la mairie, puis intègre ces informations précises dans sa réponse.

Actuellement, notre assistant effectue une recherche dans la base de connaissances (RAG) pour chaque question posée par l'utilisateur. Cependant, toutes les questions ne nécessitent pas cette recherche. Par exemple, des salutations ("Bonjour"), des remerciements ("Merci beaucoup") ou des questions très générales n'ont pas besoin d'une recherche documentaire. Effectuer une recherche systématique peut être inefficace et parfois même introduire des informations non pertinentes dans le contexte.
Pour optimiser l'assistant et le rendre plus pertinent, nous allons implémenter un classifieur d'intention. Son rôle sera d'analyser la question de l'utilisateur et de déterminer si elle vise à obtenir une information spécifique potentiellement présente dans la base de connaissances de la mairie (intention = "RAG") ou s'il s'agit d'une interaction conversationnelle classique (intention = "CHAT").
Créer une fonction de classification : Développez une fonction, par exempleclassify_query_intent, qui prend en entrée la question de l'utilisateur et le client Mistral.
Utiliser Mistral pour classifier : À l'intérieur de cette fonction, utilisez l'APIclient.chatde Mistral avec un prompt système spécifique pour demander au modèle de classifier la question comme "RAG" ou "CHAT". Définissez clairement ces catégories dans le prompt.
Retourner l'intention : La fonction doit retourner l'intention détectée (par exemple, la chaîne de caractères "RAG" ou "CHAT"). Assurez-vous de gérer les cas où la réponse du modèle n'est pas conforme.
Intégrer le classifieur : Modifiez le script principalMistralChat.py. Après avoir reçu l'entrée de l'utilisateur (prompt), appelez votre fonctionclassify_query_intent.
Adapter le flux : En fonction du résultat de la classification :
Si l'intention est "RAG", effectuez la recherche de documents similaires (search_similar_documents) et construisez le prompt avec le contexte RAG (build_prompt_with_context).
Si l'intention est "CHAT", sautez l'étape de recherche RAG et construisez un prompt plus simple, contenant uniquement le message système de base et l'historique de la conversation.
Nous avons construit une application de chat interactive qui utilise un modèle de langage avancé. Cette application gère efficacement la mémoire des conversations pour maintenir le contexte des échanges entre l'utilisateur et le système.
La recherche contextuelle fonctionne grâce à l'encodage de la question posée par l'utilisateur et sa comparaison avec des embeddings de segments de texte.
La détection de l’intention utilisateur permet une réponse adaptée à la demande de l’utilisateur.
Maintenant que nous avons construit notre application de chat et établi les fondements techniques, il est essentiel d'aborder la question cruciale de l'évaluation. Comment pouvons-nous mesurer les performances de notre système ? Quels critères utiliser pour déterminer si notre application répond véritablement aux besoins des utilisateurs ? C’est ce que nous allons voir dans le prochain chapitre.