Avant de faire ce petit projet je me suis dis que ça pouvait être une bonne idée d’avoir un endroit sur webex où, avec les autres développeuses et développeurs de l’équipe ont auraient un genre de stack overflow interactif sous la main. Ça se trouve il ne sera utilisé que 3 jours pour demander pourquoi le style lofi a explosé ces dernières années sur youtube mais ce n’est pas grave, le chemin pour créer ce bot reste intéressant 🙂

Photo chatGPT-illustration

La stack est dans le titre mais on va faire l’inventaire de ce que vous avez besoin pour la réalisation de ce projet.

Webex Teams:

  • Un compte Webex for Developers
  • Créez une nouvelle Webex App qui sera un Bot, gardez bien au chaud le bot access token, on en aura besoin pour s’authentifier auprès de l’API Webex, et pour la création du webhook.
  • Ajoutez le Bot dans la room webex de votre choix avec son adresse email qui ressemble à xxxx@webex.bot

OpenAI:

⚠️ Il faut savoir que l’utilisation de leurs services sont payants !

  • Un compte OpenAI
  • Récupérez votre clé API, idem on en aura besoin pour s’authentifier dans notre application.

FastAPI:

  • Créez un dossier avec le nom de votre projet
  • Générez votre plus bel environnement virtuel et c’est parti !

.01 Initialisation du projet

Dans un premier temps on va juste tester notre serveur, voir si aucun souci de réseau ou autre n’est présent.

# Terminal

# Création du dossier et installation de la librairie fastapi avec son serveur uvicorn
$ mkdir bot_webex
$ cd bot_webex
$ python -m venv venv
$ source /venv/bin/activate
$ pip install "fastapi[all]"

# Création du point d'entrée de notre application
$ touch main.py
# main.py

from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def read_root():
    return {"Hello": "World"}
# Terminal

$ uvicorn main:app --reload

Rendez vous sur http://localhost:8000, vous devriez voir un beau Hello World.

Ok, projet initialisé, maintenant on va créer notre webhook avant de connecter nos deux APIs avec:

.02 Création du webhook Webex Teams

On va utiliser ce fameux webhook pour qu’a chaque message envoyé dans la room où sera notre bot, cela notifiera notre application d’un évènement de création de message.

Pour ce faire, on va le créer via leur interface qui est, je trouve, excellente →

Webhooks - Create a Webhook

Photo Webhook-webex

N’oubliez pas d’utiliser le bot access token qu’on a créé plus haut comment clé d’Authorization, car si vous laissez cocher “Use personal access token” votre bot ne pourra avoir tous les accès à l’API.

Pour la targetUrl, vous devez ajouter une ip accessible depuis internet pour que webex vous notifie; le port correspond à uvicorn et /message est la route vers notre fonction qui traitera les messages reçus.

resource, on met messages car c’est la ressource qui nous intérresse.

created, car c’est le type d’evenement qu’on veut écouter.

Vous aurez besoin du roomId de la room où se trouve le bot, pour le récupérer vous pouvez faire une requête API également qui permet de lister toutes les rooms où se trouve le bot → ici

  • Utilisez le bot access token, et récupérez l’id de la room dans la réponse.

Puis → RUN.

.03 Authentification auprès de Webex & OpenAI

Maintenant que webex va notifier notre application dès qu’un message arrivera dans la room où est notre bot, on passe côté python !

Installons nos beaux paquets:

# Terminal

$ pip install webexteamssdk openai

On va maintenant dans notre main.py.

Alors on va faire les choses sales au début, c’est à dire qu’on va pas se faire chier avec les tokens et mettre tout en clair dans le fichier principal.

🚨⚠️ Du coup, ne publiez pas votre projet pour l’instant, vous allez exposer vos précieuses clés au monde impitoyable de l’internet.

# main.py

import openai
from fastapi import FastAPI
from webexteamssdk import WebexTeamsAPI

app = FastAPI()
api_webex = WebexTeamsAPI(access_token="XXX_JE_SUIS_UN_TOKEN_XXX")
openai.api_key = "XXX_MA_CLE_API_XXX"

@app.get("/")
def read_root():
    return {"Hello": "World"}

Ensuite on va créer la route sur laquelle on a configuré notre webhook.

# main.py

import openai
from fastapi import FastAPI
from webexteamssdk import WebexTeamsAPI

app = FastAPI()
api_webex = WebexTeamsAPI(access_token="XXX_JE_SUIS_UN_TOKEN_XXX")
openai.api_key = "XXX_MA_CLE_API_XXX"

@app.post("/message")
async def read_message_from_webex(message: WebexMessage):
    ...

On va récupérer nos données via le paramètre message. Et on va jeter un oeil à la structure de cet objet qui ressemble à ça:

{
  "id": "Y2lzY29zcGFyazovL3VzL1dFQkhPT0svZjRlNjA1NjAtNjYwMi00ZmIwLWEyNWEtOTQ5ODgxNjA5NDk3",
  "name": "New message in 'Project Unicorn' room",
  "resource": "messages",
  "event": "created",
  "filter": "roomId=Y2lzY29zcGFyazovL3VzL1JPT00vYmJjZWIxYWQtNDNmMS0zYjU4LTkxNDctZjE0YmIwYzRkMTU0",
  "orgId": "OTZhYmMyYWEtM2RjYy0xMWU1LWExNTItZmUzNDgxOWNkYzlh",
  "createdBy": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS9mNWIzNjE4Ny1jOGRkLTQ3MjctOGIyZi1mOWM0NDdmMjkwNDY",
  "appId": "Y2lzY29zcGFyazovL3VzL0FQUExJQ0FUSU9OL0MyNzljYjMwYzAyOTE4MGJiNGJkYWViYjA2MWI3OTY1Y2RhMzliNjAyOTdjODUwM2YyNjZhYmY2NmM5OTllYzFm",
  "ownedBy": "creator",
  "status": "active",
  "actorId": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS9mNWIzNjE4Ny1jOGRkLTQ3MjctOGIyZi1mOWM0NDdmMjkwNDY",
  "data":{
    "id": "Y2lzY29zcGFyazovL3VzL01FU1NBR0UvOTJkYjNiZTAtNDNiZC0xMWU2LThhZTktZGQ1YjNkZmM1NjVk",
    "roomId": "Y2lzY29zcGFyazovL3VzL1JPT00vYmJjZWIxYWQtNDNmMS0zYjU4LTkxNDctZjE0YmIwYzRkMTU0",
    "personId": "Y2lzY29zcGFyazovL3VzL1BFT1BMRS9mNWIzNjE4Ny1jOGRkLTQ3MjctOGIyZi1mOWM0NDdmMjkwNDY",
    "personEmail": "matt@example.com",
    "created": "2015-10-18T14:26:16.000Z"
  }
}

Super, mais là de-dans on ne voit pas le contenu du message envoyé ! On nous ment !

On va devoir récupérer l’id du message envoyé, et demander à webex de nous envoyer les détails relatif à ce message ID. Let’s go

Ah et tiens, on va garder le roomId au chaud aussi, on risque d’en avoir besoin plus tard.

.04 Traiter nos premiers messages

FastAPI va recevoir un objet dans son corps de message, et on va devoir déclarer le type de son contenu pour que FastAPI soit gentil avec nous.

Je mets tout en optional, parce que j’ai la flemme de voir si certain paramètres sont parfois omis par l’API ou pas. C’est sale, je sais. Mais je ne suis qu’amour au fond, je vous jure.

(Dans les prochains extraits de codes, je ne mettrais pas tout le contenu de DataObject et WebexMessage car je trouve ça trop polluant)

# main.py

from typing import Optional

import openai
from fastapi import FastAPI
from pydantic import BaseModel
from webexteamssdk import WebexTeamsAPI

app = FastAPI()
api_webex = WebexTeamsAPI(access_token="XXX_JE_SUIS_UN_TOKEN_XXX")
openai.api_key = "XXX_MA_CLE_API_XXX"

class DataObject(BaseModel):
    id: Optional[str] = None
    roomId: Optional[str] = None
    personId: Optional[str] = None
    personEmail: Optional[str] = None
    created: Optional[str] = None

class WebexMessage(BaseModel):
    id: Optional[str] = None
    name: Optional[str] = None
    ressource: Optional[str] = None
    event: Optional[str] = None
    filter: Optional[str] = None
    orgId: Optional[str] = None
    createBy: Optional[str] = None
    appId: Optional[str] = None
    ownedBy: Optional[str] = None
    status: Optional[str] = None
    actorId: Optional[str] = None
    data: Optional[DataObject] = None

@app.post("/message")
async def read_message_from_webex(message: WebexMessage):
		room_id = message.data.roomId    
		message_id = message.data.id
		message_details = api_webex.messages.get(messageId=message_id)

Webex va nous renvoyer un objet Message et dans ce dernier et on voit dans la doc qu’on a bien l’attribut text. Super 🥳.

On va faire un test simple, et envoyer une réponse seulement si on envoi “coucou” dans webex.

# main.py

import openai
from fastapi import FastAPI
from webexteamssdk import WebexTeamsAPI

app = FastAPI()
api_webex = WebexTeamsAPI(access_token="XXX_JE_SUIS_UN_TOKEN_XXX")
openai.api_key = "XXX_MA_CLE_API_XXX"

class DataObject(BaseModel):
		...
class WebexMessage(BaseModel):
		...

@app.post("/message")
async def read_message_from_webex(message: WebexMessage):
		room_id = message.data.roomId    
		message_id = message.data.id
		message_details = api_webex.messages.get(messageId=message_id)
		
		if message_details.text == "coucou":
				api_webex.messages.create(roomId=room_id, text="Bien reçu"

Je n’ai plus le projet à ce niveau, et flemme de relancer le tout pour envoyer un “Bien reçu” 🙂 du coup je ne mettrais pas de capture qui illustre ce cas. Pardonnez ma flemme. Mais normalement, pas de souci, si vous envoyez coucou dans webex, il vous répondra par un Bien reçu discipliné.

En cas de problème de votre côté, n’hésitez pas à venir m’en parler par ici.

Avant de le connecter à openAI, on va s’assurer que notre code ne prenne en compte que les messages qui ne proviennent pas du bot.

Puis ce serait bien qu’il ne soit déclenché uniquement lorsqu’on mentionne le bot dans un message.

# main.py

import openai
from fastapi import FastAPI
from webexteamssdk import WebexTeamsAPI

app = FastAPI()
api_webex = WebexTeamsAPI(access_token="XXX_JE_SUIS_UN_TOKEN_XXX")
openai.api_key = "XXX_MA_CLE_API_XXX"
BOT_ID = api_webex.people.me().id

class DataObject(BaseModel):
		...
class WebexMessage(BaseModel):
		...

@app.post("/message")
async def read_message_from_webex(message: WebexMessage):  
		message_details = api_webex.messages.get(messageId=message.data.id)
		
		if (
		    message_details.mentionedPeople
        and BOT_ID in message_details.mentionedPeople
    ):
				api_webex.messages.create(roomId=message.data.roomId, text="Bien reçu"

Maintenant si vous @citer votre bot, il vous renverra Bien reçu.

.05 Fonctionnement du modèle gpt-3.5-turbo

On va choisir un modèle qui sera capable de comprendre et de générer un language naturel, et pour cela selon la doc openAI, notre bonheur se tournera vers gpt-3.5-turbo. Il y a text-davinci-003 mais le fine-tuning n’est pas pour nous, restons simple et économe 👀.

Afin d’avoir le meilleur assistant python possible, chatGPT inclut un format de message afin que le modèle sache sur quel pied danser.

import openai

openai.ChatCompletion.create(
  model="gpt-3.5-turbo",
  messages=[
        {"role": "system", "content": "You are a helpful python assistant and you help me to improve my skills."},
        {"role": "user", "content": "Can you give me some lambda examples ?"},
        {"role": "assistant", "content": "Here is some examples of lambda [...]"},
        {"role": "user", "content": "Can you improve this one with a func which returns strings instead ?"}
    ]
)

Le rôle system définit au début permet au modèle de savoir vers quel axe partir principalement, et de centrer un peu le sujet principal.

Ensuite user c’est nous, et assistant sont ses réponses.

Le fait de garder en mémoire les échanges, permet de garder le fil de la discussion au modèle et de pouvoir affiner ses réponses.

Cependant, il faut comprendre quelque chose, c’est le principe des tokens.

Photo Tokenizer-openAPI

C’est grâce à ces fameux tokens que les coûts d’usages sont déterminés. Plus vous envoyez de tokens, plus vous aurez à payer.

Avec le modèle gpt-3.5 on a une limite de 4 096 tokens pour un appel.

Donc si on revient rapidement à notre notion de sauvegarde des messages.

Lorsque j’envois un message à chatGPT, je vais lui envoyer la liste des messages précédents pour qu’il garde le contexte. En conséquence, plus je communique avec lui, plus il y aura de messages dans la liste que je vais lui envoyer, donc plus de tokens, donc plus le coût d’envoi sera chère.

⚠️ C’est une notion à garder en tête, et je vous invite fortement à bien vérifier les limites d’utilisations sur votre compte openAI.

.06 Intégration d’openAI dans notre programme

Utilisons enfin notre fameux attribut text de tout à l’heure afin d’envoyer le contenu du message vers chatGPT.

# main.py

import openai
from fastapi import FastAPI
from webexteamssdk import WebexTeamsAPI

app = FastAPI()
api_webex = WebexTeamsAPI(access_token="XXX_JE_SUIS_UN_TOKEN_XXX")
openai.api_key = "XXX_MA_CLE_API_XXX"
BOT_ID = api_webex.people.me().id
MESSAGES = [{
		           "role": "system",
               "content": "You are a coding tutor bot to help user write and optimize python code.",
					 }]

class DataObject(BaseModel):
		...
class WebexMessage(BaseModel):
		...

@app.post("/message")
async def read_message_from_webex(message: WebexMessage):
		global MESSAGES
		message_details = api_webex.messages.get(messageId=message.data.id)
		
		if (
		    message_details.mentionedPeople
        and BOT_ID in message_details.mentionedPeople
    ):
         MESSAGES.append({"role": "user", "content": message_details.text})

         completion = openai.ChatCompletion.create(
             model="gpt-3.5-turbo",
		         max_tokens=400,
             messages=MESSAGES,
         )

Le format du retour, soit le contenu de notre variable completion, va ressembler à cela:

{
 'id': 'chatcmpl-6p9XYPYSTTRi0xEviKjjilqrWU2Ve',
 'object': 'chat.completion',
 'created': 1677649420,
 'model': 'gpt-3.5-turbo',
 'usage': {'prompt_tokens': 56, 'completion_tokens': 31, 'total_tokens': 87},
 'choices': [
   {
    'message': {
      'role': 'assistant',
      'content': 'The 2020 World Series was played in Arlington, Texas at the Globe Life Field, which was the new home stadium for the Texas Rangers.'},
    'finish_reason': 'stop',
    'index': 0
   }
  ]
}

On retrouve le content du message, et également on peut y voir le nombre total de tokens qu’à consommé cet échange (prompt_tokens étant la question et completion_tokens, la réponse).

Ajouter la réponse de chatGPT et puis dernière étape, on envoit la réponse vers Webex Teams.

# main.py

import openai
from fastapi import FastAPI
from webexteamssdk import WebexTeamsAPI

app = FastAPI()
api_webex = WebexTeamsAPI(access_token="XXX_JE_SUIS_UN_TOKEN_XXX")
openai.api_key = "XXX_MA_CLE_API_XXX"
BOT_ID = api_webex.people.me().id
MESSAGES = [{
	"role": "system",
	"content": "You are a coding tutor bot to help user write and optimize python code.",
}]

class DataObject(BaseModel):
		...
class WebexMessage(BaseModel):
		...

@app.post("/message")
async def read_message_from_webex(message: WebexMessage):
		global MESSAGES
		message_details = api_webex.messages.get(messageId=message.data.id)
		
		if (
		    message_details.mentionedPeople
        and BOT_ID in message_details.mentionedPeople
    ):
         MESSAGES.append({"role": "user", "content": message_details.text})

         completion = openai.ChatCompletion.create(
             model="gpt-3.5-turbo",
		         max_tokens=400,
             messages=MESSAGES,
         )

         response = completion.choices[0].message.content
         MESSAGES.append({"role": "assistant", "content": response}

.07 Dernière ligne droite, la réponse vers Webex Teams

L’API webex nous permet soit de créer un message avec du texte classique ou du markdown. Sachant que notre bot gère le markdown, et enverra des bouts de codes en l’utilisant, on part là dessus direct.

# main.py

import openai
from fastapi import FastAPI
from webexteamssdk import WebexTeamsAPI

app = FastAPI()
api_webex = WebexTeamsAPI(access_token="XXX_JE_SUIS_UN_TOKEN_XXX")
openai.api_key = "XXX_MA_CLE_API_XXX"
BOT_ID = api_webex.people.me().id
MESSAGES = [{
	"role": "system",
	"content": "You are a coding tutor bot to help user write and optimize python code.",
}]


class DataObject(BaseModel):
    id: Optional[str] = None
    roomId: Optional[str] = None
    personId: Optional[str] = None
    personEmail: Optional[str] = None
    created: Optional[str] = None


class WebexMessage(BaseModel):
    id: Optional[str] = None
    name: Optional[str] = None
    ressource: Optional[str] = None
    event: Optional[str] = None
    filter: Optional[str] = None
    orgId: Optional[str] = None
    createBy: Optional[str] = None
    appId: Optional[str] = None
    ownedBy: Optional[str] = None
    status: Optional[str] = None
    actorId: Optional[str] = None
    data: Optional[DataObject] = None


@app.post("/message")
async def read_message_from_webex(message: WebexMessage):
		global MESSAGES
		message_details = api_webex.messages.get(messageId=message.data.id)
		
		if (
		    message_details.mentionedPeople
        and BOT_ID in message_details.mentionedPeople
    ):
        MESSAGES.append({"role": "user", "content": message_details.text})

        completion = openai.ChatCompletion.create(
            model="gpt-3.5-turbo",
	        max_tokens=400,
            messages=MESSAGES,
        )

        response = completion.choices[0].message.content
        MESSAGES.append({"role": "assistant", "content": response}

		api_webex.messages.create(roomId=message.data.roomId, markdown=text)

Relancez uvicorn si il était coupé, et ça part !

Si vous voulez voir le projet au complet sur gitlab, cliquez donc.

J’ai ajouté de la configuration, des petits logs, un fichier json en guise de base de donnée pour stocker les messages, et d’autres petites choses.

J’espère que ce petit moment ensemble vous aura plu, et je vous fais une bise.

\o