Nouvelles Chroniques d'Amethyste

Penser au Sens, pas au Verbe

Un vrai dialogue avec un bot

Poster un commentaire

Maintenant nous avons en main tous les outils pour construire une vraie interaction entre le bot et nous.

Il existe plusieurs méthodes, je vais commencerai par la plus sophistiquée, mais c’est celle qui mettra le mieux en évidence ce qui se passe sans que la technique masque tout.

Avant de poursuivre cet article, soyez certain de lire celui-ci auparavant:

https://amethyste16.wordpress.com/2017/01/25/sauvegarde-des-etats-par-un-bot/

Etude de Activity

Etudions le contenu du paramètre activity tel qu’il est reçu par le contrôleur vu dans l’article précédent.

Commençons par lancer l’émulateur, puis on se connecte. Un premier message arrive jusqu’au contrôleur.

2017-03-05_10-47-38

Passons en revue quelques propriétés importantes.

 

ChannelId identifie le canal auquel on a affaire, ici l’émulateur.

 

  • From
    L’émetteur de l’activité (le message entre le bot et le canal)
  • Recipient
    Le destinataire de l’activité
  • Conversation
    La conversation courante

From et Recipient sont des instances de ChannelAccount. tandis que Conversation est une instance de ConversationAccount. ConversationAccount est un ChannelAccount avec des paramètres en plus comme l’indicateur de conversation de groupe.

Ces paramètres sont réclamés par de nombreuses méthodes du SDK à commencer par les méthodes de sauvegarde des états. Retenez ce tableau extrait de la doc:

2017-01-29_18-17-22

 

On remarque aussi Type qui indique le type de la conversation. Dans notre cas on créée une nouvelle conversation.

 

La propriété ServiceUrl permet de renvoyer un objet Activity de réponse au canal qui a lancé la conversation. L’émulateur est sur une url en localhost

On retrouve la valeur dans les logs de l’émulateur:

2017-01-25_21-58-29

 

Note: La documentation conseille de se servir de cette propriété plutôt qu’essayer de la reconstituer par le code.

 

On émet un message:

2017-01-25_22-00-41

Le type de l’activité est maintenant message indiquant qu’un message a été reçu. De fait on remarque un contenu dans la propriété Text qui ne se trouvait pas auparavant.

Essayez divers messages, par exemple un Ping:

2017-01-25_22-05-09

 

Intéressons nous maintenant à la propriété Conversation:

2017-01-25_22-06-21

Conversation.Id est l’id d’un élément important dont nous allons parler dans cet article: une conversation.

Le point intéressant à retenir est que cette valeur est la même dans tous les messages que nous avons émis jusqu’à présent. Il s’agit d’un d’un point constant qui va nous permettre de relier les uns aux autres les éléments d’une même conversation. On va donc pouvoir créer et entretenir un contexte afin d’éviter qu’un utilisateur ne reçoive les réponses d’une autre conversation.

 

Un dernier point à noter.

Votre instance d’Activity doit toujours être sérialisable (en JSON). De plus la taille de l’objet sérialisé ne doit pas dépasser 256K. Activity n’est donc pas fait pour passer des images dans un Attachement. Une image ne peut être passée que via une url. c’est de plus le seul format que l’on est a peu près sûr que le canal ciblé saura lire.

 

Cela nous donne quoi en terme d’architecture?

Architecture d’une conversation

La brique de base d’une conversation est le dialogue. Un dialogue est une instance de IDialog définit ainsi:


Using Microsoft.Bot.Builder.Dialogs;

[Serializable]
public class MyDialog: IDialog<object>
{
   public async Task StartAsync(IDialogContext context)
   {
   }
}

 

Un dialogue doit donc être décoré de SerializableAttribute, expose une unique méthode qui est asynchrone: StartAsync.

StartAsync est le dialogue parent de la conversation.

 

Un dialogue dispose d’un contexte qui lui permet de se situer dans la conversation et de maintenir ses états. Le dialogue peut émettre des messages vers l’utilisateur et se mettre en attente d’une réponse.

Afin de mieux comprendre, essayons une représentation visuelle.

 

Voici à quoi pourrait correspondre une conversation entre un bot et un utilisateur:

2017-01-27_21-43-02

Comme on le voit la conversation est une succession d’étapes. Chaque étape correspond à une progression dans la conversation ainsi qu’un état particulier (je connais, le nom, je connais l’âge).

 

Combien a t’on de dialogue?

On en a au moins un. Tout passe par des dialogues dans BotBuilder, peut importe la façon dont il est codé.

 

C’est au choix du développeur de décider comment cette conversation sera découpée en IDialog. On pourrait par exemple se dire que cette conversation fait partie d’un bloc fonctionnel unique et un seul dialogue est nécessaire.

Mais on peut aussi argumenter qu’il y a en fait deux dialogues:

  1. Un dialogue de présentation
  2. Un dialogue de demande d’informations au sujet de l’utilisateur

Cela a du sens aussi. Le premier pourrait être pris en charge par LUIS, le second par un FormFlow. On peut aussi décider de lancer ou pas le second dialogue si l’utilisateur est déjà connu du système.

Donc c’est à vous de décider. BotBuilder permet d’enchaîner des dialogues sans problèmes.

 

Les modèles de développement d’un bot

Le SDK proposé par Microsoft est une boîte à outils destinées à développer des bots. Il existe au moins 4 modèles de développement qui feront tous l’objet d’un article:

  1. Développer une instance de IDialog
  2. Utiliser les PromptDialog
  3. Utiliser les FormFlow
  4. Utiliser l’interface fluent Chain

Chaque méthode à ses qualités et ses limites, il est donc important de les connaître. Elles peuvent parfaitement se combiner ce qui est en général le cas.

Nous allons commencer par IDialog qui est la méthode la plus générale.

Premier exemple

Essayons de réécrire l’exemple précédent dans l’architecture de dialogue. On a tout d’abord besoin d’une instance de IDialog. Voici notre premier essai:


[Serializable]
public class SimpleDialog : IDialog
{
   public async Task StartAsync(IDialogContext context)
   {
      await context.PostAsync("Salut Amethyste!");
   }
}

 

Ce n’est que du code que l’on connait déjà.

Comment lance t’on un dialogue? Il s’agit d’un élément de conversation, on ne s’étonnera donc pas d’avoir besoin d’un objet Conversation. Cet objet permet de mettre en relation un bot et l’utilisateur qui souhaite entrer en contact avec lui. On se sert de cet objet pour initialiser une conversation de la façon qui suit dans le contrôleur


public async Task<HttpResponseMessage> Post([FromBody]Activity activity)
{
   if (activity.Type == ActivityTypes.Message)
   {
      await Conversation.SendAsync(activity, () => new SimpleDialog());
   }
   else
   {
      HandleSystemMessage(activity);
   }
   var response = Request.CreateResponse(HttpStatusCode.OK);
   return response;
}

 

La méthode Conversation.SendAsync() est la méthode standard pour faire traiter un message d’entrée (une Activity) par la conversation en cours à travers un IDialog fournit. Il n’y a qu’un seul IDialog dans le projet, on peut donc le lancer directement.

Des bots plus complexes peuvent disposer de plus de dialogues que cela, dans ce cas on écrirait une factory avec un mécanisme de sélection du IDialog.

 

Il ne reste plus qu’à tester avec l’émulateur.

Premier essai:

capture1

Tout à l’air de bien se passer, même si les logs signalent un poblème. Relançons un deuxième message:

capture2

 

Les choses se passent mal, le bot ne répond pas.

Faite un ‘New Conversation’ dans l’émulateur et réessayez:

capture4

 

Cela remarche.

 

Que se passe t’il?

Les logs nous donnent des informations:


{
"message": "An error has occurred.",
"exceptionMessage": "invalid need: expected Wait, have Done",
"exceptionType": "Microsoft.Bot.Builder.Internals.Fibers.InvalidNeedException",
"stackTrace": "   à Microsoft.Bot.Builder.Internals.Fibers.Wait`2.ValidateNeed(Need need)\r\n  .... "
}

 

C’est quoi le Wait qui est réclamé?

Dans une conversation il ne suffit pas de renvoyer une réponse, il faut également dire ce que doit faire le bot ensuite. En l’occurrence on veut qu’il mette la discussion en attente d’un message suivant de l’utilisateur. Nous allons réécrire le dialogue de la façon suivante:


[Serializable]
public class SimpleDialog : IDialog
{
   public async Task StartAsync(IDialogContext context)
   {
      await context.PostAsync("Salut Amethyste!");
      context.Wait(MessageReceivedAsync);
   }

   private async Task MessageReceivedAsync(IDialogContext context, IAwaitable<IMessageActivity> argument)
   {
      IMessageActivity message = await argument;
      int length = (message.Text ?? string.Empty).Length;

      await context.PostAsync($"You sent **{message.Text}** which was **{length}** characters");
      context.Wait(MessageReceivedAsync);
   }
}

La méthode Wait() du contexte suspend le dialogue en cours jusqu’à ce que l’utilisateur envoie un message. Une fois le message reçu, la méthode appelée MessageReceivedAsync est lancée.

L’exécution de StartAsync lance tout de suite MessageReceivedAsync bien que l’on fasse un appel à Wait(). La raison est qu’un message est déjà arrivé si on est arrivé jusque là, il n’est donc plus nécessaire d’attendre.

 

Note: relisez une ou plusieurs fois le paragraphe qui précède. C’est le truc subtil que l’on met du temps à comprendre et qui est important pour comprendre comment fonctionne un IDialog. En tout cas ce fut pour moi une pierre d’achoppement.

Donc MessageReceivedAsync est la méthode où commencent vraiment les choses intéressantes.

 

Dans StartAsync je récupère le message reçu, notez la syntaxe, IAwaitable oblige et renvoie également le résultat du traitement comme avant.

On fait un deuxième appel à Wait() pour réamorcer la boucle. Visuellement:

 

capture5

 

Contrairement au cas précédent on a pas de message d’erreur.

Si je renvoie un deuxième message, cela fonctionne:

capture6

 

Vous notez que cette fois on ne repasse pas par StartAsync, le message « Salut Amethyste » n’est pas réaffiché. Peut-être qu’un message de présentation du bot aurait été plus astucieux…

Faites ‘New Conversation’ depuis l’émulateur et relancez un message:

capture7

 

Le message de présentation réapparaît cette fois. Je pense que maintenant la relation entre dialogue et conversation devrait être claire.

 

On a construit une conversation avec un unique dialogue. Pour l’instant notre bot n’est pas vraiment meilleurs que la première version, par contre on a mis en place un socle technique. Comment s’en servir?

Une conversation plus sophistiquée

Il serait bien que le bot puisse demander le nom de l’utilisateur et lui répondre sous son nom. On va donc avoir besoin de mettre en place un échange et aussi de sauvegarder un état.

C’est déjà nettement plus intéressant et nous permettra de découvrir quelques techniques de codage d’usage courant.

 

Commençons par le code:


public class SimpleDialog : IDialog
{
   public async Task StartAsync(IDialogContext context)
   {
      await context.PostAsync("Salut, je suis SuperBot à votre service!");
      context.Wait(HelloMessageAsync);
   }

   private async Task HelloMessageAsync(IDialogContext context, IAwaitable<IMessageActivity> argument)
   {
       IMessageActivity message = await argument;
       string userName = "";
       context.UserData.TryGetValue("name", out userName);

      // surveille si on a ou pas réclamé le nom
      bool hasName = false;
      context.UserData.TryGetValue("hasName", out hasName);

      if (hasName)
      {
          userName = message.Text;
          context.UserData.SetValue("name", userName);
      }

      if (string.IsNullOrWhiteSpace(userName))
      {
          await context.PostAsync("Quel est votre nom?");
          context.UserData.SetValue("hasName", true);
          context.Wait(HelloMessageAsync);
      }
      else
      {
          await context.PostAsync($"Bonjour **{userName}** quelle est votre phrase?");
          context.Wait(MessageReceivedAsync);
      }
   }

   private async Task MessageReceivedAsync(IDialogContext context, IAwaitable<IMessageActivity> argument)
   {
      string userName = "";
      context.UserData.TryGetValue("name", out userName);

      IMessageActivity message = await argument;
      int length = (message.Text ?? string.Empty).Length;
      await context.PostAsync($"{userName} sent **{message.Text}** which was **{length}** characters");

      context.Wait(MessageReceivedAsync);
   }
}

 

StartAsync n’a pas changé à part le message de présentation et le fait que maintenant le Wait() porte sur la methode HelloMessageAsync. Cette méthode gère toute la partie de la conversation qui récupère le nom de l’utilisateur.

Remarquez comment le contexte fournit un accès au service d’états.

 

On commence par récupérer deux valeurs dans les états du bot:

  1. le nom utilisateur
  2. savoir si on a déjà demandé son nom à l’utilisateur

 

La première fois que l’on arrive dans HelloMessageAsync, on ne connait pas le nom de l’utilisateur et hasName vaut false. On entre donc ligne 27.

Le bloc de code effectue 3 actions:

  1. demande à l’utilisateur son nom
  2. bascule à true la propriété hasName
  3. reboucle sur HelloMessageAsync

 

L’utilisateur saisit son nom et envoie le message. Celui-ci est traité par HelloMessageAsync. Cette fois hasName vaut true. On sait donc que l’on reçoit le nom du client et on doit le sauvegarder dans le service (lignes 21-22).

Cette fois, lorsque l’on arrive ligne 25, UserName a une valeur. C’est donc les lignes 33-34 qui sont exécutées. Le bot réclame une phrase au client et reboute (via Wait) sur l’ancienne méthode cette fois.

MessageReceivedAsync n’a pas changée si ce n’est qu’elle affiche le nom de l’utilisateur maintenant.

capture9

Vous constatez que cette fois nous avons un dialogue un peu plus sophistiqué avec un échange complet bot/utilisateur.

 

On aurait pu compléter HelloMessageAsync pour réclamer d’autres informations comme l’âge de l’utilisateur. On voit tout de suite le problème que ça pose: à chaque fois on a besoin de créer et gérer des drapeaux supplémentaires tels hasName. La complexité du code va donc vite s’accélérer. Nous verrons d’autres façons de faire dans le prochain article.

 

En attendant il reste un petit bug à corriger. Faite ‘New Conversation’ puis saisissez Hello:

capture10

On bien le message de présentation qui indique que l’on est entré dans StartAsync comme prévu. Mais le bot prend le message pour le nom de l’utilisateur.

La raison est qu’ouvrir une nouvelle conversation ne vide pas les états pour autant. La valeur en cours de hasName reste toujours true et donc les lignes 21-22 sont exécutées de façon incorrectes.

Nous verrons dans un prochain article comment réagir sur un changement de conversation, en attendant on va modifier les lignes 32-34 comme suit:


await context.PostAsync($"Bonjour **{userName}** quelle est votre phrase?");
context.UserData.SetValue("hasName", false);
context.Wait(MessageReceivedAsync);

 

Et tout rentre dans l’ordre.

 

On pourrait aussi compléter StartAsync avec:


context.UserData.Clear();

 

Dans ce cas on perd toutes les informations. Le bot réclamera le nom de l’utilisateur à nouveau.

Sauvegarde des états dans un dialogue

Le problème de nettoyage évoqué précédemment peut être résolu autrement en employant un conteneur dont la portée est la conversation.

Voici un exemple:


[Serializable]
public class SimpleDialog:IDialog
{

   protected int count = 0;

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

   public virtual async Task MessageReceivedAsync(IDialogContext context, IAwaitable<IMessageActivity> argument)
   {
      var message = await argument;
      count = count + message.Text.Length;

      await context.PostAsync($"{count}: You said **{message.Text}**");

      context.Wait(MessageReceivedAsync);
   }
}

 

Remarquez la présence de la propriété count dans le dialogue.

Le dialogue attend un message et incrémente count du nombre de caractères du message:

c2

Mais si on créée une nouvelle conversation:

c3

La propriété, bien que non publique, est donc sérialisée en même temps que le dialogue et retransmise dans tous les échanges entre bot et canal. C’est pour cette raison que le dialogue doit être Serializable.

Bien entendu une telle propriété n’est pas thread safe. Il n’est donc pas possible de mettre n’importe quoi de cette façon.

 

Si vous vous intéressez à d’autre méthodes de sauvegarde des états, (re)lisez l’article précédent de la série:

https://amethyste16.wordpress.com/2017/01/25/sauvegarde-des-etats-par-un-bot/

 

Un dernier exemple

Un dernier exemple dans lequel on va implémenter un jeu et une boucle.

[Serializable]
public class GameDialog : IDialog
{
   int secret;
 
   public async Task StartAsync(IDialogContext context)
   {
      await context.PostAsync("Je vous propose un jeu");
      context.Wait(StartNewGameAsync);
   }
 
   private async Task StartNewGameAsync(IDialogContext context, IAwaitable<IMessageActivity> result)
   {
      Random rnd = new Random();
      secret = rnd.Next(1, 10);
 
      await context.PostAsync("Devinez un nombre entre 1 et 10");
      context.Wait(TheGame);
   }
 
   private async Task TheGame(IDialogContext context, IAwaitable<IMessageActivity> result)
   {
      IMessageActivity message = await result;
 
      int i = Convert.ToInt32(message.Text);
 
      if (i < secret)
      {
         await context.PostAsync($"{i} est trop petit");
         context.Wait(TheGame);
      }
      else if (i == secret)
      {
         await context.PostAsync("Vous avez trouvé. Bravo!!!!");
         context.Wait(StartNewGameAsync);
      }
      else
      {
         await context.PostAsync($"{i} est trop grand");
         context.Wait(TheGame);
      }
  }
}

Le jeu consiste à deviner un nombre entre 1 et 10:

2017-02-05_00-34-16

Rien de très extraordinaire dans le code si ce n’est la démonstration d’une technique pour générer une boucle et un arbre de décision. On en verra d’autres plus tard.

 

Bibliographie

Un bouquin déjà pour moins de $10.:

http://aihelpwebsite.com/Market/Introduction

 

 

Publicités

Laisser un commentaire

Entrez vos coordonnées ci-dessous ou cliquez sur une icône pour vous connecter:

Logo WordPress.com

Vous commentez à l'aide de votre compte WordPress.com. Déconnexion / Changer )

Image Twitter

Vous commentez à l'aide de votre compte Twitter. Déconnexion / Changer )

Photo Facebook

Vous commentez à l'aide de votre compte Facebook. Déconnexion / Changer )

Photo Google+

Vous commentez à l'aide de votre compte Google+. Déconnexion / Changer )

Connexion à %s