TP - un chatbot avec flet#

Licence CC BY-NC-ND, Thierry Parmentelat

introduction#

dans ce TP nous allons

  • découvrir (très superficiellement) la librairie flet

  • et l’utiliser pour implémenter un début de simulation de chatbot

contexte#

on met à votre disposition deux serveurs ollama:

  • chacun des deux sait mettre en oeuvre plusieurs modèles d’IA (notamment mistral, mais pas que…)

  • l’un des deux ne possède qu’un CPU; du coup il est relativement lent mais on peut y accéder sans login/password

  • par contre l’autre possède un GPU, il est plus rapide pour notre application, mais le code pour s’en servir est un peu plus compliqué car il faut lui fournir un login/password

  • les détails de ces deux serveurs sont dans le starter code

  • pour les utiliser, essentiellement on POST une requête http au serveur avec le chemin /api/generate en lui passant une donnée qui contient

    • model: le nom du modèle

    • prompt: la question

    les détails se trouvent ici ollama/ollama

objectif#

ce qu’on veut faire, c’est fabriquer une UI sommaire qui permet

  • de choisir le serveur,

  • de choisir le modèle,

  • et de poser ensuite des questions comme avec chatGPT

  • enfin sur cette implémentation on a également un bouton qui permet d’enabler le streaming l’idée consiste à afficher les résultats “au fur et à mesure” plutôt qu’en une seule fois à la fin de l’échange avec le serveur, on en reparlera…

ça pourrait ressembler à ceci:

../../_images/chatbot-sample.png

v01: starter code#

"""
starter code for a chatbot - made with flet 0.25.2 (https://flet.dev)

starter code has the dialogs for choosing the model, the server, and the
streaming option, plus a button to send the request
none of this is actually connected to anything yet
"""


import flet as ft

SERVERS = {
    # this one is fast because it has GPUs,
    # but it requires a login / password
    'GPU': {
        "name": "GPU fast",
        "url": "https://ollama-sam.inria.fr",
        "username": "Bob",
        "password": "hiccup",
    },
    # this one is slow because it has no GPUs,
    # but it does not require a login / password
    'CPU': {
        "name": "CPU slow",
        "url": "http://ollama.pl.sophia.inria.fr:8080",
    },
}


# a hardwired list of models
MODELS = [
    "gemma2:2b",
    "mistral:7b",
    "deepseek-r1:7b",
]


TITLE = "My first Chatbot 01"


def main(page: ft.Page):
    # set the overall window title
    page.title = TITLE

    ### the visual pieces
    # a checkbox to select "streaming" mode or not - default is false
    streaming = ft.Checkbox(label="streaming", value=False)

    # choose the model
    model = ft.Dropdown(
        options=[ft.dropdown.Option(model) for model in MODELS],
        value=MODELS[0],
        width=300,
    )
    # choose the server
    server = ft.Dropdown(
        options=[ft.dropdown.Option(server) for server in ("CPU", "GPU")],
        value="CPU",
        width=100,
    )

    # the submit button

    # what do we want to happen when we click the button ?
    def send_request(_event):
        """
        the callback that fires when clicking the 'submit' button
        """
        # NOTE that we can use the variables that are local to 'main'
        # i.e. model, server, streaming...
        # for now, just show current settings
        print("Your current settings :")
        print(f"{streaming.value=}")
        print(f"{model.value=}")
        print(f"{server.value=}")

    # send_request is the callback function defined above
    # it MUST accept one parameter which is the event that triggered the callback
    submit = ft.ElevatedButton("Send", on_click=send_request)


    # arrange these pieces in a single row
    page.add(
        ft.Row(
            [streaming, model, server, submit],
            # for a row: main axis is horizontal
            # and cross axis is vertical
            alignment=ft.MainAxisAlignment.CENTER,
        )
    )

ft.app(main)
  • installez la librairie

  • copiez le code ci-dessus dans un fichier chatbot.py

  • et lancez-le depuis le terminal avec

flet run chatbot.py

vous devez voir une UI un peu tristoune, avec seulement

  • un checkbox pour choisir le mode streaming ou pas

  • un dropdown pour choisir entre 3 modèles

  • un dropdown pour choisir entre 2 serveurs

  • un bouton ‘send’

à ce stade, cette UI est totalement inerte, on va la construire pas à pas

ce qu’on découvre dans la v01#

dans ce code, on utilise le fait que

  • le module flet vient avec son propre modèle de programmation; vous remarquez qu’on ne lance pas le programme comme d’habitude

  • en fait c’est pour offrir un mode de développement dit de hot reload, c’est-à-dire qu’il suffit de modifier un des fichiers sources, le programme s’en rend compte et recharge tout seul le nouveau code; c’est extrêmement pratique à l’usage !

  • et ça se concrétise aussi par le point d’entrée dans le programme, qui est ft.app(main)

ce qu’on voit également dans ce code:

  • les différents morceaux de l’interface sont construits à base d’objets de la librairie; vous en voyez déjà quelques spécimens

    • ft.Checkbox, ft.Dropdown, ft.ElevatedButton pour les objets visibles

    • ft.Row pour la logique d’assemblage

vous aurez envie de bookmark ces entrées dans la doc, pour plus d’info:

pour les forts#

si cet énoncé vous inspire, vous pouvez simplement suivre votre voie pour développer l’application
sinon pour les autres, voici un chemin possible pour y arriver; évidemment je vous donne ces étapes entièrement à titre indicatif
bref, dans tous les cas, n’hésitez pas à faire comme vous le sentez…

v02: ajoutons un titre#

../../_images/chatbot-02.png

pour vous familiariser avec le modèle de lignes et colonnes de flet, ajoutez un titre principal, comme sur l’illustration

  • regardez ft.Column

  • et flet.Text

  • et à chaque fois les différents attributs disponibles pour contrôler le look et le comportement de l’UI

v03: avec un peu de classe: ChatbotApp#

ceci est une étape totalement optionnelle, mais je vous recommande de créer une classe, qui pourrait s’appeler ChatbotApp, pour regrouper la logique de notre application, et éviter de mettre tout notre code en vrac dans le main

  • on pourrait envisager par exemple que ChatbotApp hérite de ft.Column

  • de cette façon on se retrouverait avec un main qui ne fait plus que

Je vous propose de procéder en deux temps

  • étape 3a: on crée la classe ChatbotApp; le code de main se retrouve essentiellement dans le constructeur de ChatbotApp
    (et souvenez-vous comment on utilise super() pour initialiser la superclasse, ici Column

  • étape 3b: la fonction send_request devient une méthode de la classe (au lieu d’être une fontion incluse dans le constructeur)

v04: une classe History#

../../_images/chatbot-04.png

toujours pour éviter de finir avec un gros paquet de spaguettis, on va imaginer à ce stade d’écrire une classe History ( nouveau tout ceci est totalement indicatif…) qui:

  • hérite, là encore de ft.Column

  • c’est elle qui est responsable de créer la zone de prompt

  • et d’afficher au fur et à mesure, et au bon endroit, les échanges avec le robot

  • de cette façon on pourra l’insérer simplement en bas dans l’objet ChatbotApp ainsi cet objet - qui rappelons-le est une Column - va voir maintenant 3 fils:

    • le titre

    • la Row avec les différents réglages

    • et une instance de History()

la logique de la classe History

pour fixer les idées, disons qu’à ce stade cette classe possède les méthodes

  • current_prompt() qui renvoie le prompt tapé par l’utilisateur

  • add_message(some_text) pour insérer les questions et les réponses au fur et à mesure

l’idée est que l’objet History possède:

  • en dernier (tout en bas donc) un objet de type ft.TextField (qui est éditable); dans lequel on va taper notre prompt

  • et au dessus on va conserver la trace des échanges: question1, réponse1, etc…
    et pour cela on utilisera add_message(some_text), dont le job donc est d’insérer un objet ft.Text
    (non modifiable par l’utilisateur cette fois)
    en avant-dernière position - c’est-à-dire juste au dessus du prompt

pour être bien clair, à ce stade on ne fait pas encore usage du réseau pour quoi que ce soit, on veut juste mettre en place la structure de l’UI

ici encore je vous conseille de procéder par petites étapes:

  • 4a: la trame de la classe History

  • 4b: faites en sorte que le fait de taper “Entrée” dans la zone de prompt fasse le même effet que le bouton “Send”

```{admonition} regardez la classe TextField :class: tip

pour le titre on utilise la classe Text qui est en read-only; pour la zone de prompt, il est préferable d’utiliser TextField qui est editable et qui offre plus de flexibilité


+++

## v05: un peu de réseau

c'est seulement maintenant que l'on va effectivement **interagir via le réseau avec les serveurs** ollama  
je vous propose pour commencer de simplement:

- fabriquer la requête,
- et simplement afficher la réponse **dans le terminal**

quelques indices:

- la librairie qu'on va utiliser pour cela s'appelle `requests`;
- vous pouvez commencer par regarder ceci pour quelques exemples <https://requests.readthedocs.io/en/latest/user/quickstart/>
- notre objectif ici et de bien comprendre la structure de la réponse
  posez-vous notamment la question de savoir quand est-ce que c'est terminé, et regardez bien la fin de la réponse
- pour l'instant aussi, on ignore le flag *streaming*: on poste une requête et on attend le retour

à nouveau on pourra procéder par étapes:

- 5a: en commençant par le serveur CPU uniquement
- 5b: ajouter l'authentification lorsque c'est nécessaire, de façon à pouvoir utiliser indifféremment les deux serveurs

+++

````{admonition} un petit exemple
:class: dropdown tip

voici comment on pourrait dire `hey` au modèle `gemma2:2b`  
ce code peut s'exécuter par exemple directement dans ipython 

```python
import requests
import json

url = "http://ollama.pl.sophia.inria.fr:8080/api/generate"

# c'est expliqué dans la doc ollama: l'API /api/generate
# s'attend à ce qu'on lui passe ces deux paramètres:
payload = {'model': 'gemma2:2b', 'prompt': 'hey'}

# pour envoyer une requête POST 
# avec comme paramètre ce payload encodé en JSON:

# cette ligne peut prendre un moment à s'exécuter...
response = requests.post(url, json=payload)

# pour voir le status HTTP (devrait être 200)
response.status_code

# pour accéder au corps de la réponse (sans les headers HTTP)
body = response.text

# et regardez bien à quoi ça ressemble
print(body)

+++

````{admonition} avec authentification
:class: dropdown tip

dans le cas du serveur GPU qui attend une authentification:  
vous pouvez simplement aménager le code ci-dessus en remplaçant cette ligne

```python
response = requests.post(url, json=payload)
```

par celles-ci
```python
login_password = ('the-login', 'the-password')
response = requests.post(url, json=payload, auth=login_password)
```

si bien que vous pouvez envisager un code un peu unifié en faisant quelque chose dans le genre de 
```python
auth_args = {}
if need_authentication:
    auth_args['auth'] = ('the-login', 'the-password')
# voir le cours: on ajoute les éléments du dictionnaire
# sous la forme d'arguments nommés dans l'appel de la fonction
response = requests.post(url, json=payload, **auth_args)
```

v06: on affiche la réponse#

../../_images/chatbot-06.png

dans cette version, on utilise la réponse du serveur pour afficher le dialogue dans notre application et non plus dans le terminal

pour cela on va devoir faire quelques modifications à la classe History; en effet vous devez avoir observé à ce stade que la réponse vient “en petits morceaux”, ce que l’on n’a pas encore prévu

du coup pour aboutir à une version à peu près fonctionnelle il devrait vous suffire de

  • ajouter à la classe History une méthode add_chunk(token), qui permet d’ajouter juste un mot dans la réponse du robot

  • et au lieu d’afficher la réponse du robot en bloc, de la traiter proprement pour en extraire les différents petits morceaux, puis les afficher dans l’interface grâce donc à add_chunk()

update()

avec flet il faut penser à flush les changements avec un flet_object.update()
car sinon les changements que l’on fait en mémoire ne sont pas répercutés dans l’affichage
(si vous avez le TP sur le snake, c’est la même logique ici avec flet que ça l’était avec pygame)
il faut rafraichir explicitement la page pour que vos modifications se voient à l’écran

pour faire ça flet fournit sur tous ses objets une méthode update()
et comme notre History hérite de ft.Column, vous pouvez simplement lui envoyer la méthode update()

v07: un peu de cosmétique#

../../_images/chatbot-07.png

ici on va simplement ajouter un peu de relief pour qu’on s’y retrouve entre les questions et les réponses

ici aussi on peut imaginer procéder en deux étapes

  • 7a: juste la cosmétique: montrer de manière plus distinte les 3 groupes (questions, réponses, et prompt)

  • 7b: faire en sorte que la fenêtre scroll automatiquement vers le bas, lorsque le dialogue remplit toute la page

  • 7c: faire en sorte qu’on ne puisse pas envoyer plusieurs requêtes en parallèle

v08: supporter le mode streaming#

../../_images/chatbot-08.png

une requête HTTP “classique” est d’une grande simplicité: on envoie une requête, on reçoit une réponse
dans notre cas toutefois, ce modèle n’est pas tout à fait adapté, car l’IA met du temps à élaborer sa réponse, et on aimerait mieux voir la réponse au fur et à mesure, plutôt que de devoir attendre la fin, qui est le comportement que vous obeservez si vous avez suivi mes indications jusqu’ici
c’est ce à quoi on va s’attacher maintenant
il se trouve que le serveur ollama retourne ce qu’on appelle une réponse HTTP qui est un stream

du coup on peut facilement modifier notre code pour en tirer parti en écrivant quelque chose comme:

    with requests.post(url, json=data, stream=True) as answer:
        print("HTTP status code:", answer.status_code)
        for line in answer.iter_lines():
            # do something with the line...

ne gardez qu’un seul mode

dans mon code j’ai conservé les deux modes (streaming et non-streaming) pour pouvoir montrer la différence de comportement, mais honnêtement le mode non-streaming ne présente pas d’intérêt en pratique, donc n’hésitez pas à ne garder que le mode non-streaming, ce sera plus lisible

v09 (optionnel): acquérir la liste des modèles#

../../_images/chatbot-09.png

plutôt que de proposer une liste de modèles “en dur” comme dans le starter code, on pourrait à ce stade acquérir, auprès du serveur choisi, la liste des modèles connus; pour cela ollama met à notre disposition l’API /api/tags

dans mon implémentation j’ai choisi de “cacher” ce résultat, pour ne pas redemander plusieurs fois cette liste à un même serveur (cette liste bouge très très peu…); mais c’est optionnel; par contre ce serait sympa pour les utilisateurs de conserver, lorsque c’est possible, le modèle choisi lorsqu’on change de serveur…

plein d’améliorations possibles#

en vrac:

  • une fois que vous faites l’acquisition des modèles disponibles, il se peut qu’on vous retourne des valeurs de modèle qui ne fonctionnent pas;
    notamment les modèles all-minilm:22m-l6-v2-fp16' et 'all-minilm:33m-l12-v2-fp16 (entre autres sans doute) ne supportent pas l’interface generate
    et comme - pas de bol - ils apparaissent en premier dans la liste des tags, c’est sans doute habile d’éviter de les choisir comme défaut du modèle

  • ajouter un bouton “Cancel” - en fait idéalement on en aurait besoin le plus tôt possible car le développement peut vite devenir fastidieux (ne pas hésiter à quitter et relancer); mais le truc c’est que c’est non trivial à faire en fait !

  • ou pourrait imaginer soumettre le même prompt à plusieurs modèles pour les comparer

  • etc…

pour aller plus loin#