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
fletet 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
POSTune requête http au serveur avec le chemin/api/generateen lui passant une donnée qui contientmodel: le nom du modèleprompt: 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:
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.pyet 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
fletvient avec son propre modèle de programmation; vous remarquez qu’on ne lance pas le programme comme d’habitudeen 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.ElevatedButtonpour les objets visiblesft.Rowpour la logique d’assemblage
vous aurez envie de bookmark ces entrées dans la doc, pour plus d’info:
https://flet.dev le point d’entrée principal
https://flet.dev/docs/controls pour les détails des objets disponibles
python ou flet run
on peut aussi lancer le programme de manière plus traditionnelle avec juste python chatbot.py
mais dans ce cas on n’a pas le hot reload et à l’usage, c’est une grosse différence de confort !
en outre il est conseillé de regarder les possibilités offertes par la CLI (i.e. le programme flet)
qui permet de faire aussi d’autres choses très utiles:
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#
pour vous familiariser avec le modèle de lignes et colonnes de flet, ajoutez un titre principal, comme sur l’illustration
regardez
ft.Columnet
flet.Textet à 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
ChatbotApphérite deft.Columnde cette façon on se retrouverait avec un
mainqui ne fait plus que
Je vous propose de procéder en deux temps
étape 3a: on crée la classe
ChatbotApp; le code demainse retrouve essentiellement dans le constructeur deChatbotApp
(et souvenez-vous comment on utilisesuper()pour initialiser la superclasse, iciColumnétape 3b: la fonction
send_requestdevient une méthode de la classe (au lieu d’être une fontion incluse dans le constructeur)
v04: une classe History#
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.Columnc’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
ChatbotAppainsi cet objet - qui rappelons-le est uneColumn- va voir maintenant 3 fils:le titre
la
Rowavec les différents réglageset 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’utilisateuradd_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 promptet au dessus on va conserver la trace des échanges: question1, réponse1, etc…
et pour cela on utiliseraadd_message(some_text), dont le job donc est d’insérer un objetft.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
History4b: 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#
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
Historyune méthodeadd_chunk(token), qui permet d’ajouter juste un mot dans la réponse du robotet 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#
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
pour 7a: de la couleur
mettre un fond de couleur à notre
TextField(le prompt)enrichir un peu l’interface de
History, et remplacer l’unique méthodeadd_message()par deux méthodes différentesadd_prompt(text)etadd_answer(text)
pour 7b: le scrolling
j’ai eu un peu du mal avec cette partie; (revenez dessus plus tard si nécessaire), mais il est important que notre chatbot scroll correctement:
c’est-à-dire qu’après plusieurs questions/réponses on voie toujours le bas du dialogue
et pour ça sachez qu’il faut procéder comme ceci
cl = ft.Column(
[....], # the children
# required so the column knows it is supposed to take all the vertical space of its father
expand=True,
# so that the widget activates scrolling when needed
scroll=ft.ScrollMode.AUTO,
# so that we're always seeing the bottom area
auto_scroll=True,
)
enfin, remarquez qu’on peut avoir envie d’activer le scrolling
sur la
Columnprincipale (notreChatbotApp), mais dans ce cas les widgets de mode (streaming, server…) vont scroller aussi…
c’est mieux que pas de scroll, mais pas forcément idéal encoresur la
History, et dans ce cas les widgets de mode vont rester fixes;
dans ce cas-là toutefois, pensez à mettre tout de mêmeexpand=Truesur laChatbotApppour que les changements de la taille de l’app se propagent jusqu’à l’History
pour 7c: éviter plusieurs Send en parallèle
le sujet c’est que si le code ne fait rien de particulier, rien n’empêche l’utilisateur de cliquer 3 fois de suite sur le bouton Send, et que ça envoie 3 requêtes essentiellement en même temps
pour éviter ça, vous faites en sorte de disable les deux moyens d’envoyer la requête (le bouton Send et la touche Entrée dans le prompt)
je vous recommande du coup d’ajouter les méthodes enable_prompt() et disable_prompt() dans la classe History
et ensuite d’implémenter une logique dans la méthode send_request() pour désactiver / réactiver l’interface au bon moment; c’est peut-être d’ailleurs le moment de couper cette méthode en plus petits morceaux..
v08: supporter le mode streaming#
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#
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èlesall-minilm:22m-l6-v2-fp16' et 'all-minilm:33m-l12-v2-fp16(entre autres sans doute) ne supportent pas l’interfacegenerate
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èleon pourrait le savoir par programme ?
sans doute; dans ollama/ollama on nous montre comment obtenir des informations plus fines sur les modèles…
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#
je vous signale une page pleine de tutoriels intéressants, et un peu du même genre, à propos de la librairie
flet: https://flet.dev/docs/tutorials