Construisez l'application de discussion

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)

Configurez l’architecture du projet

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.

Gérez la mémoire courte et la cohérence des réponses

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

1. Importation des bibliothèques et configuration

import streamlit as st
import os
from mistralai.client import MistralClient
from mistralai.models.chat_completion import ChatMessage

Nous 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"

2. Initialisation de l'historique des conversations

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

3. Construction du prompt avec l'historique

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_messages

Cette 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

4. Génération de réponses via l'API Mistral

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

5. Interface utilisateur Streamlit

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

6. Traitement des entrées utilisateur et génération de réponses

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.

Ajoutez la recherche sémantique et la réinjection contextuelle

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.

1. Indexation de la base de connaissance

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.

2. Encodage de la question en vecteur (embedding)

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}")

3. Recherche des segments pertinents

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_pertinents

Cette fonction :

  1. Convertit la question en vecteur (embedding)

  2. Effectue une recherche des k vecteurs les plus proches dans l'index Faiss

  3. Récupère les segments de texte correspondants

4. Réinjection des résultats dans le prompt

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_messages

Ce qui est particulièrement intéressant ici, c'est l'utilisation du "system prompt" pour injecter le contexte. Nous :

  1. Créons un message système qui définit le rôle de l'assistant

  2. Ajoutons les segments pertinents trouvés dans la base de connaissances

  3. Donnons une instruction claire au modèle pour utiliser ces informations

  4. Incluons l'historique récent de la conversation

5. Intégration dans l'application Streamlit

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.

À vous de jouer

Contexte

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").

Consignes

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

En résumé

  • 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.

Ever considered an OpenClassrooms diploma?
  • Up to 100% of your training program funded
  • Flexible start date
  • Career-focused projects
  • Individual mentoring
Find the training program and funding option that suits you best