Cet article est le premier d’une longue série. Il a pour vocation d’être plus théorique que pratique. Nous allons voir ensemble comment créer et déployer un bot stateless, lui rajouter de l’intelligence et enfin pour les plus intéressés d’entre vous, aller encore plus loin en créant des composants réutilisables tels que des graphes de conversation à circuit variable.

  1. Partie I : La face cachée
  2. Partie II : Le réveil de la force
  3. Parte III: Un Bot intelligent (A venir)
  4. Partie IV: Donner des ailes au Bot (A venir)

Euh Bot ? De quoi parlez vous ?

Pour la petite histoire, un Bot au départ, n’était ni plus ni moins qu’un programme informatique permettant d’automatiser certaines taches telles que l’indexation de page web, la génération de contenu, etc. Au fil des années, grâce notamment à notre puissance de calcul (ordinateurs) qui ne cesse de croître, les bots sont devenus de plus en plus intelligents. Par exemple, dans le domaine des jeux vidéo, nous constatons souvent qu’un adversaire virtuel est capable de simuler le comportement d’un joueur humain et même de l’optimiser dans certains cas. Aussi, nous avons de plus en plus d’assistants personnels permettant de gérer notre agenda, d’effectuer des réservations, de nous guider en voiture, etc.

Tout cela pour dire que les domaines d’application d’un bot ne sont bornés que par les différentes activités humaines. Aujourd’hui, avec l’émergence du machine learning et du deep learning d’une part, et de notre gain exponentielle de puissance de calcul d’autre part, nous sommes en mesure d’aller encore plus loin. Tout ce qu’il nous faut, c’est de l’imagination.

Faut il être un pro du machine learning ?

Non, rassurez-vous. Le but de cet article est de vous montrer comment créer rapidement un bot en vous fournissant les outils nécessaires. Vous n’aurez pas à coder d’algorithmes complexes mais uniquement à les utiliser sous forme de service. Nous le verrons plus en détails dans les chapitres suivants, mais gardez en tête qu’il existe déjà pas mal d’outils permettant de faire du machine learning.

De quoi avons nous besoin ?

Il vous faudra disposer d’un ordinateur équipé de Visual Studio (à partir de 2015), d’un émulateur et de lire la première partie de la doc afin d’installer Microsoft Bot Builder SDK et de télécharger le projet template qui vous servira de base pour commencer. Veuillez à ce que tous vos packages soient à jour et que le SDK soit au moins à sa version 3.5 .

Ce qu’il faut savoir sur MS Bot Framework

Ce Framework permet de développer un bot intelligent très rapidement, de le connecter à plusieurs canaux de communication et surtout d’intégrer aisément des outils de machine de learning afin de rajouter de l’intelligence au bot. Le développeur n’a plus à se préoccuper de la façon dont les données transitent sur les différents channels mais se concentre sur la partie la plus importante à mon sens : l’automatisation et l’intelligence. Tout le reste est géré par le framework. On peut développer nos bots soit en c# ou en Node.js.

En résumé, vous pourrez donc:

  • Créer ou générer différents types de dialogues pour votre bot;
  • Les interconnecter de façon intelligente;
  • Sauvegarder des contextes de discussion en ram ou dans une base de données;
  • Restaurer une conversation interrompue précédemment;
  • Démarrer de nouvelles conversations à partir d’événements externes (Azure bus par exemple)
  • Avoir des intercepteurs de messages;
  • Intégrer les cognitives API de Microsoft (tels que Luis par exemple);
  • Logger toutes les activités du bot avec Application Insight;

La face cachée du Bot : Comment ca marche ?

Avant tout, je me permets de vous présenter trois notions indispensables pour bien comprendre comment fonctionne le framework.

La sérialisation:

Tous les dialogues que vous allez créer doivent être sérialisables. Cela permet au bot de se souvenir d’une conversation précédemment interrompue et de reprendre correctement le fil de la discussion. Les données sont stockées dans un data store soit en RAM ou sur une API Rest de Microsoft. Vous pourrez également implémenter votre propre store qui lui stockera les données sérialisées dans un azure table storage par exemple.

Les delegates (nos handlers):

A chaque fois que le Bot s’endormira (lorsqu’il attendra qu’un utilisateur réponde), il faudra fournir son code de réveil. Cela se fait par le biais d’un delegate. Pour rappel, un delegate permet d’encapsuler une méthode; c’est un pointeur de fonction pour les habitués du C/C++. Voici de la bonne lecture 🙂

Le Producteur / consommateur:

C’est un modèle classique en programmation. Il permet de synchroniser le partage d’une ressource afin de s’assurer qu’un élément produit n’est consommé qu’une et une seule fois. Prenons un exemple tout simple, lorsqu’un message est envoyé au bot, on parle de production d’événement; le bot doit consommer cet événement via un handler. On s’assure ainsi qu’aucun message ne sera perdu une fois produit.

Et c’est tout ?

Pour les plus crafts d’entre vous, je rajouterais l’injection de dépendance. Le framework fonctionne exclusivement avec AutoFac pour l’instant. Toutes les classes indispensables au développement d’un bot sont enregistrées dans un container sous forme de module appelé DialogModule. On y trouve plusieurs interfaces intéressantes telles que :

IDialogSystem:

C’est la plus haute interface dans la hiérarchie des taches liées au dialogue. Elle gère la boucle d’événements, les consommateurs (les fameux delegates) et les push dans la file de messages. En fait, l’unique implémentation de cette interface repose sur le pattern de composition. Elle délègue l’ensemble de tous ses traitements à d’autres interfaces que sont: IDialogTask, IEventLoop et IEventProducer<IActivity>

IEventProducer<IActivity>:

Pousse des nouvelles activités dans la queue ainsi qu’une fonction de callback qui permet d’être notifié lorsque le message a été effectivement consommé;

IDialogTask:

Contient les morceaux de code à exécuter  dans une stack et qui sont en attentes de nouvelles activités. Il gère toute la logique d’endormissement et de réveil du bot. Cela se fait notamment en maintenant un pointeur sur un delegate dont la signature est la suivante:

Task ResumeAfter<in T>(IDialogContext ctx, IAwaitable<T>);

L’interface IDialogContext représente le contexte d’exécution du bot. Elle contient toutes les données relatives à la conversation actuelle ainsi que plusieurs méthodes pour envoyer un message à l’utilisateur ou pour lancer un nouveau dialogue. Nous verrons tout cela en détails plus tard.

IEventLoop:

Lorsqu’on reçoit une nouvelle activité et que le prochain code à exécuter a été identifié, elle se charge de le placer sur le haut de la pile et de l’exécuter. Le prochain état (les waits) est alors programmé.

Dans le cas d’un bot réactif, c’est à dire un bot qui démarre uniquement à partir du premier message reçu, il existe une implémentation “ReactiveDialogTask” qui se charge de boucler sur le dialogue de votre bot. Vous verrez plus tard que lorsque le bot arrive à la fin de la conversation, il reprend automatiquement son dialogue.

IPostToBot :

Fournit une méthode PostAsync  permettant de poster une nouvelle activité dans le pipeline de traitement du bot. Plusieurs implémentations sont fournies et sont enregistrées dans le container de DI comme une chaîne de responsabilité . La figure ci-dessous illustre le pipeline de traitement des messages entrants:

Figure 1 : Traitement des messages entrants

Comme vous pouvez le voir, c’est une chaîne de responsabilité assez classique. Examinons un peu cette figure:

  • Activity Logger: Log toutes les activités entrantes. On pourra par exemple rediriger les logs dans app insight et faire des analyses avancées.
  • Exception handler: Formate le message renvoyé à l’utilisateur si une exception se produit durant le traitement. Il peut par exemple renvoyer un message approprié en fonction de la langue du client.
  • Thread Culture Handler: Permet de s’adapter à la culture (langue) du client. Par exemple, si le message entrant est en anglais, il faudra utiliser le bon fichier de ressources.
  • Conversation Serializer: Sérialise l’état actuelle de la conversion, c’est à dire le dernier message reçu et les informations sur le client (le channel, l’id de l’utilisateur, un timestamp, etc.)
  • Exception Translation: Traduit les exceptions fermées du SDK  en des erreurs visibles et compréhensibles d’un point de vue développeur.
  • Persitent dialog Task: C’est lui qui est responsable de charger les données d’une conversation et d’initier l’opération d’écriture dans le data store.
  • Event Loop : Nous l’étudierons plus en détail plus tard, mais gardez en tête que c’est elle qui est responsable de poster les activités reçues aux différents consommateurs : les dialogs et les scorables.
Qu’est ce qu’un Dialog au final ?

Un dialogue est une sorte d’automate fini dont les états sont représentés par des delegates et dont les transitions s’effectuent lorsqu’une nouvelle activité est postée dans le pipeline. Voici un dialogue de réservation de restaurant par exemple:

Figure 2 : Exemple de dialogue

Dans le framework, chaque état est représenté par un delegate ou une méthode pour faire simple. Cette façon de faire permet de décomposer la conversation en des petites unités de traitement qui sont testables, exécutables et sérialisables. On peut également imaginer implanter de la navigation dans des dialogues. Typiquement, si l’utilisateur s’est trompé et ne veut plus manger Indien, on peut très bien imaginer un scénario qui nous permettrait de revenir à un état précédent en l’occurrence dans notre cas, celui du choix de la cuisine. Tous les dialogues doivent implémenter l’interface IDialog<out T>.

Le Framework fournit plusieurs implémentations permettant de créer assez rapidement des dialogues notamment PromptDialog. Cette classe utilitaire fournit pas mal de méthodes statiques pour créer des dialogues de confirmation (oui/non), des dialogues de saisie d’entier ou de double, etc.

Pour définir son graphe de conversation , le développeur dispose d’un contexte particulier représenté par une interface IDialogContext. L’une des méthodes que vous utiliserez souvent est PostAsync; elle vous permet d’envoyer un message à un utilisateur. Voici un exemple de code qui représente un état particulier de la conversation :

private async Task OnchoiceMade(IDialogContext context,IAwaitable<bool> rs)
{
  var choice = await rs;

  if(choice){
     await context.PostAsync("j'ai reservé pour deux au radisson blue");
   }else{
     await context.PostAsync("A quelle heure veux tu manger alors ?");
   }  

   //code d'endormissement du bot et de chainage vers un autre état
    context.Wait(NextState);
  }

Ce code fait deux choses :

  • Envoie un message à l’utilisateur selon le choix qu’il a fait à l’étape précédente;
  • Indique le code à exécuter lors du prochain réveil du bot grâce à la méthode Wait. En fait, le bot n’est vraiment pas endormi au sens d’un Thread.Sleep , il est juste dans un état d’attente d’activités. Si vous placez du code après l’appel à la méthode Wait, ce code sera exécuté (dès l’instant où le context n’est pas utilisé, sinon une erreur de produira).
Un exemple de dialogue : Réserver un restaurant

On souhaite coder le graphe de la figure 2. Commençons par créer une classe qui représentera notre dialogue et faisons la implémenter l’interface IDialog<T>. Le type T indique la valeur de retour de votre dialogue; moi j’ai choisi de renvoyer vrai si la réservation a été effectuée avec succès ou faux dans le cas contraire.

[Serializable]
public class BookDialog : IDialog<object>{

   private readonly IRestaurantService _restaurantService; 

   public BookDialog(IRestaurantService restaurantService){
      _restaurantService = restaurantService;
   }

   public async Task StartAsync(IDialogContext context){
      context.Wait(State1);
   }

   ...
}

J’ai injecté une interface IRestaurantService à la construction du dialogue pour simuler l’appel à une API de réservation. La méthode StartAsync est le point d’entrée de votre automate. Dans l’exemple précédent, au démarrage du dialogue je me mets en attente dans l’état State1. Le code de cet état est le suivant:


private async Task State1(IDialogContext context, IAwaitable<IMessageActivity> msg){

  await context.PostAsync("Que veux tu manger ce soir?");
  context.Wait(State2);
}

Ici, on pose une question à l’utilisateur et on se met en attente dans l’état 2.

private async Task State2(IDialogContext context,IAwaitable<IMessageActivity> rs)
{
  var message = await rs;
  
  if (!await _restaurantService.ValidateChoice(message.Text)){
    await context.PostAsync("Votre réponse est invalide.");
    context.Wait(State2); //on reste dans cet état
  }
  else {
   PromptDialog.Confirm(context, State3, "Très bon choix. Disons 19h30 ?",promptStyle:PromptStyle.None);
  }
}
 Ça devient intéressant, on a notre première boucle 🙂 . Le code reste dans le même état indéfiniment tant que le choix de l’utilisateur n’est pas validé par le service de restauration. Dans le cas contraire, on utilise la classe PromptDialog pour demander une confirmation à l’utilisateur et en précisant quel est le code de réveil du bot : ici c’est le code de State3 qui sera exécuté. Examinons le code de cette méthode :

 private async Task State3(IDialogContext context,IAwaitable<IMessageActivity> rs) {
   var choice = await rs;
   if (choice){
     await StartBooking(context, DateTime.Today.AddHours(19).AddMinutes(30));
   }
   else{
    await context.PostAsync("A quelle heure veux tu manger alors (hh:mm)?");
    context.Wait(State4);
   }
}

Si l’utilisateur dis oui à la question posée à l’état State2 c’est à dire réserver pour 19h30 alors on lance la réservation; sinon, on lui demande de saisir l’heure à laquelle il souhaiterait dîner et on se met en attente. Voici le code du dernier état du bot et de la méthode StartBooking :


private async Task State4(IDialogContext context, IAwaitable<IMessageActivity> rs)
{
   var message = await rs;
 
   if (!DateTime.TryParseExact(message.Text, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var value))
{
   await context.PostAsync($"{value} est invalide. Le bon format est hh:mm");
   context.Wait(State4); //reste dans le même etat
}
 else{
    await StartBooking(context, value);
 }
}

private async Task StartBooking(IDialogContext context, DateTime time){

  var hasBooked = await _restaurantService.Book(time);
  
  if (hasBooked){
      await context.PostAsync("Ok parfait. J'ai reservé pour deux au Radisson Blue Hotel.");
  }
  else {
   await context.PostAsync("j'ai pas pu reserver malheuresement. Appelez directement   l'établissement!");
  }
  
  context.Done(hasBooked);
}

 Testons le code !

Si on essaie de tester le dialogue tel qu’il a été défini précédemment, une exception sera levée au milieu de la conversation disant que la classe RestaurantService n’est pas sérialisable. Rappelez-vous, tout dialogue déclaré doit pouvoir être sérialisé (attributs y compris). Il existe une façon élégante de résoudre ce problème que nous verrons plus tard.

Mais il ne suffit pas de déclarer RestaurantService comme étant Serialisazable ?

On aurait pu, c’est vrai. Mais sémantiquement parlant, il n’y a aucun intérêt à rendre un service sérialisable dans la mesure où ce service est censé être stateless; c’est à dire fournir le même service à tous ses utilisateurs. Pour l’instant remplacer le code de la classe BookDialog par celui ci :

[Serializable]
public class BookDialog : IDialog<object>{

   private readonly Func<IRestaurantService> _restaurantServiceFactory;

   public BookDialog(Func<IRestaurantService> restaurantServiceFactory){
       _restaurantServiceFactory = restaurantServiceFactory;
   }
   //suite du code
 }

Modifions également les deux méthodes qui font appel au RestaurantService:

private async Task State2(IDialogContext context,IAwaitable<IMessageActivity> rs) {
   
    var message = await rs;
    if (!await _restaurantServiceFactory().ValidateChoice(message.Text)){
        ...
    }
}

Ainsi que :


private async Task StartBooking(IDialogContext context, DateTime time){

var hasBooked = await _restaurantServiceFactory().Book(time);
...

}

Il ne nous reste plus qu’à remplacer le code de la méthode Post du controller pour activer notre nouveau dialogue. Let’s do it:

public async Task<HttpResponseMessage> Post([FromBody]Activity activity)
{
   if (activity.Type == ActivityTypes.Message){
      await Conversation.SendAsync(activity, () =&amp;gt;; new BookDialog(()=&amp;gt; new RestaurantService()));
   }
   else{
        //Ignore tout le reste pour l'instant
   }
   var response = Request.CreateResponse(HttpStatusCode.OK);
   return response;
}

Voici un bouchon du IRestaurantService:


public interface IRestaurantService
{
    Task&amp;lt;bool&amp;gt; ValidateChoice(string choice);
    Task&amp;lt;bool&amp;gt; Book(DateTime time);
}

public class RestaurantService : IRestaurantService
{
    public Task<bool> ValidateChoice(string choice)
    {
       return Task.Delay(1500).ContinueWith(_ => true);
    }

    public Task<bool> Book(DateTime time)
    {
       return Task.FromResult(time.DayOfWeek != DayOfWeek.Saturday && time.DayOfWeek != DayOfWeek.Sunday);
    }
}

Exécuter le projet et démarrer l’émulateur si ce n’est pas déjà le cas. Veuillez à bien faire pointer votre émulateur sur l’url de votre bot (ex: http://localhost:3933/api/messages). Envoyer un message bidon dans l’émulateur pour démarrer la conversation (le bot est dit réactif).

Nous voilà arrivés à la fin de cette première partie plus théorique que pratique. Il est intéressant de savoir comment fonctionne le framework en interne afin de progresser et surtout de comprendre les prochains chapitres. Je vous invite donc à lire la seconde partie Le reveil de la force qui présentera plusieurs outils intéressants tels que le générateur de dialogue, les intercepteurs d’événements,les interruptions, etc.