Nouvelles Chroniques d'Amethyste

Penser au Sens, pas au Verbe

Chaîner les dialogues dans un bot

Poster un commentaire

On a vu plusieurs techniques pour créer des conversations et des dialogues. Celle qui fait l’objet de ce chapitre se situe juste après IDialog en terme de sophistication.

Il s’agit d’une interface fluent organisée à partir de la classe Chain qui propose de construire une conversation complète avec des outils d’interaction, de sélection de chemin, gestion d’exceptions…

Je vais appeler cette interface, interface de chaînage.

 

Avant de continuer ce serait bien de lire les articles qui précèdent et en particulier:

https://amethyste16.wordpress.com/2017/01/29/creer-un-dialogue-avec-les-formflow/

Et surtout:

https://amethyste16.wordpress.com/2017/01/26/un-vrai-dialogue-avec-un-bot/#more-8176

L’interface de chaînage

Voici un (très peu passionnant) exemple qui nous servira à mettre en place la plomberie. Le code prend sa place dans le contrôleur:


await Conversation.SendAsync(activity, () =>
          Chain
             .PostToChain()
             .Select(_ => "Entrez un nombre")
             .PostToUser()
             .WaitToBot()
             .Select(m => $"Vous avez répondu: {m.Text}")
             .PostToUser()
             .Select(_ => "Entrez un message")
             .PostToUser()
             .WaitToBot()
             .Select(m => m.Text.Length)
             .Select(length => $"Taille du message: {length}")
             .PostToUser()
);

 

Visuellement:

2017-02-02_21-28-57

Le dialogue est terminé, l’envoi d’un nouveau message relance la conversation du début:

2017-02-02_21-32-28

Le point de départ sera toujours la classe Chain.

PostToChain récupère le message de l’utilisateur et l’injecte dans la chaîne de dialogue. c’est en général la première ligne qui suit Chain.

Select attend que le IDialog qui précède se termine, récupère sa sortie et l’envoi dans le IDialog suivant qui est l’argument de Select. J’aurai pu rendre les choses plus lisible si j’avais écrit ceci:


.Select(m => $"{m.Text}: Entrez un nombre")

Qui aurait affiché:

2017-02-02_21-39-37

Select attend donc une fonction anonyme comme paramètre qui retourne un type T. Select produit alors un IDialog<T>.

 

WaitToBot ne pose pas de difficulté, le bot se met en attente de la réponde de l’utilisateur. PostToChain ne serait pas autorisé à cet emplacement. Fort logiquement un Select suit pour récupérer cette entrée.

Je pense que vous devriez reconstituer tout seul le code qui suit, les méthodes sont les même.

 

On ne met évidemment pas un tel code directement dans un contrôleur, on va plutôt créer une classe static utilitaire portant une propriété de ce style:


public static readonly IDialog<string> MonDialogue = Chain.....

Et côté contrôleur:


await Conversation.SendAsync(activity, () => ClientBotDialog.MonDialogue);

 

Important: La sortie d’un élément de la chaîne sera toujours l’entrée de l’élément qui suit.

C’est un chaînage.

Imbrication de chaînage

Les chaînage peuvent s’imbriquer pour créer des sous dialogues. Regardez l’exemple qui suit:


Chain
     .PostToChain()
     .Select(_ => "Combien de questions?")
     .PostToUser()
     .WaitToBot()
     .Select(m => int.Parse(m.Text))
     .Select(count =>
             Enumerable.Range(0, count)
            .Select(index => Chain.Return($"question {index + 1}?")
             .PostToUser()
             .WaitToBot()
             .Select(m => m.Text)))
     .Fold((l, r) => l + "," + r)
     .Select(answers => "Vous avez répondu: " + answers)
     .PostToUser();

 

Visuellement ça donne:

2017-02-02_21-56-02

Vous reconnaissez la plupart des commandes. Intéressons nous à la ligne 7.

Select récupère l’entrée du dialogue qui précède et l’injecte dans le dialogue qui lui est passé en paramètre. Il s’agit d’une valeur numérique.

Enumerable.Range() est du C# standard qui construit une énumération comportant count valeurs entières.

Select est assez malin pour le prendre comme entrée bien que ce ne soit pas un IDialog. Une des surcharges de Select est en effet une méthode d’extension qui s’applique à IEnumerable.

Dans ce cas Select reçoit l’un après l’autre chaque valeur de l’énumération, soit dans mon exemple: 0 et 1, exactement comme si on avait une boucle for.

Les valeurs en soit ne nous intéressent pas, on a juste besoin d’afficher un certain nombre de fois le dialogue qui demande une question. On est donc à la ligne 9.

Le dialogue exécuté par ce Select est un nouveau dialogue puisque l’on démarre par un Chain.

Return est une nouvelle commande. La méthode sert à transformer un type C# en un IDialog, ici une chaîne, afin de pouvoir l’injecter dans un autre IDialog. On aurait pu envisager là aussi une méthode d’extension comme pour IEnumerable, mais cette méthode est plus générale.

La suite se devine d’elle-même, la chaîne est injectée dans PostToUser qui la renvoi vers l’utilisateur. Le Select ligne 12 récupère l’entrée utilisateur et l’injecte dans la chaîne de dialogue.

 

On a donc joué 2 fois ce petit bloc Chain imbriqué, c’est donc une collection de 2 chaînes que va recevoir Fold. La commande va renvoyer la concaténation de toutes ces string que nous affichons par la suite.

Enchaîner avec une instance de IDialog

Jusque là on a enchaîné des IDialog très simples construits à la volée par l’interface de chaînage. Mais IDialog est une interface comme les autres et rien ne nous interdit de créer notre propre classe.

Comment l’injecter dans un chaînage?

Reprenons notre premier exemple qui présentait un dialogue en deux temps:

  1. Le bot demande à l’utilisateur de se présenter
  2. Le bot demande à l’utilisateur une phrase

Nous allons le recréer avec le chaînage.

Pour rappel le dialogue est:

[Serializable]
public class SimpleDialog : IDialog
{
   public async Task StartAsync(IDialogContext context)
   {
      var message = (IMessageActivity)context.Activity;
      await context.PostAsync($"Salut {message.Text}, je suis SuperBot à votre service!");
      await context.PostAsync("Entrez une phrase");
      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($"Vous avez envoyé: **{message.Text}**, qui fait **{length}** caractères");
      context.Wait(MessageReceivedAsync);
   }
}

 

Rien de très particulier si ce n’est que dans le root on récupère la sortie du dialogue qui précède et qui devra être le nom de l’utilisateur.

Le dialogue réclame ensuite une phrase sur laquelle il effectue une analyse linguistique extrêmement sophistiquée.

 

La Chain est faite ainsi:


public static readonly IDialog<object> Dialog = Chain.PostToChain().
           Select(_ => "Hello, quel est votre nom?").
           PostToUser().
           WaitToBot().
           ContinueWith((context, activity) =>
           {
              return Task.FromResult<IDialog<object>>(new SimpleDialog());
            });

 

Du classique, mais une nouvelle méthode: ContinueWith qui m’a causée bien des soucis.

ContinueWith est une méthode passe-plat, lorsque le dialogue qui précède se termine elle récupère sa sortie et lance le dialogue produit par la méthode anonyme. C’est exactement ce qu’il nous faut pour exécuter un IDialog personnalisé.

 

Sa signature:


public static IDialog<R> ContinueWith<T, R>(this IDialog<T> antecedent, Continuation<T, R> continuation);

Une méthode d’extension qui attend une Continuation. C’est quoi?


public delegate Task<IDialog<R>> Continuation<in T, R>(IBotContext context, IAwaitable<T> item);

Donc ContinueWith attend une méthode anonyme avec deux paramètres IBotContext et IAwaitable<T> qui remonte un Task<IDialog>. On transforme un résultat simple en Task avec un Status à TaskStatus.RanToCompletion grâce à Task.FromResult.

Visuellement le dialogue:

2017-02-08_22-02-29

Les PromptDialog?

Je vais reprendre les extraits de code démontrés ici:

https://amethyste16.wordpress.com/2017/01/29/creer-un-dialogue-avec-les-formflow/#more-8275

On peut créer un IDialog avec un code de ce style:


public static IDialog<Client> BuildDialog()
{
   return Chain.From(() => FormDialog.FromType<Client>());
}

Qui va s’insérer partout où l’on a besoin d’un IDialog comme par exemple:


context.Call(
   Chain.From(() => FormDialog.FromType<Client>())
   , DialogCompleted);

 

Le tout mit bout à bout peut donner ceci:

[Serializable]
public class HelloDialog : IDialog
{
 
   public async Task StartAsync(IDialogContext context)
   {
      await context.PostAsync("Salut, je suis SuperBot à votre service!");
 
      context.Call(
         Chain.From(() => FormDialog.FromType<Client>())
         , DialogCompleted);
   }
 
   private async Task DialogCompleted(IDialogContext context, IAwaitable<Client> result)
   {
      var message = await result;
      await context.PostAsync($"Merci bien {message.Nom}!");
      context.Done(this);
   }
}

La méthode Call() permet d’enchaîner directement avec un IDialog passé en paramètre. Une méthode de continuation (DialogCompleted) est ensuite fournie.

La méthode se termine par un appel à context.Done() qui permet de sortir du IDialog et retourner au dialogue parent. Il est possible de lui passer un paramètre, je ferai une démonstration dans le futur article consacré à LUIS.

 

2017-02-05_21-17-37

Et le dialogue repart du début.

Faire une sélection

L’exécution d’une conversation construite avec Chain peut faire des boucles, comme on l’a vu, mais aussi sélectionner un IDialog dynamiquement en fonction du contexte. C’est la méthode Switch qui fait le travail. Voici un exemple d’utilisation extrait d’un des exemples proposés par Microsoft:

 

public static readonly IDialog<string> dialog = Chain
   .PostToChain()
   .Select(msg => msg.Text)
   .Switch(
      new Case<string, IDialog<string>>(
      text =>
      {
         var regex = new Regex("^reset");
         return regex.Match(text).Success;
      }
      , (context, txt) =>
      {
         return Chain.From(() => new PromptDialog.PromptConfirm("Are you sure you want to reset the count?",
            "Didn't get that!", 3)).ContinueWith<bool, string>(async (ctx, res) =>
         {
            string reply;
            if (await res)
            {
               ctx.UserData.SetValue("count", 0);
               reply = "Reset count.";
            }
            else
            {
               reply = "Did not reset count.";
            }
            return Chain.Return(reply);
        });
      }),
 
      new RegexCase<IDialog<string>>(
         new Regex("^game", RegexOptions.IgnoreCase)
         , (context, txt) =>
         {
            return Chain
            .Return("I am a simple echo dialog with a counter! Reset my counter by typing \"reset\"!");
         }),
 
         new DefaultCase<string, IDialog<string>>(
            (context, txt) =>
            {
               int count;
               context.UserData.TryGetValue("count", out count);
               context.UserData.SetValue("count", ++count);
               string reply = string.Format("{0}: You said {1}", count, txt);
               return Chain.Return(reply);
            }))
   .Unwrap()
   .PostToUser();

 

Switch a une syntaxe complexe, mais logique. Je vais donc prendre le temps de la décortiquer pas à pas. Sa signature est la suivante:


public static IDialog<R> Switch<T, R>(this IDialog<T> antecedent, params ICase<T, R>[] cases);

La méthode attend donc une liste de taille variable de ICase. Un ICase est l’équivalent de la clause case dans le switch que nous connaissons bien en C#. C’est un cas de test. Dans notre exemple il y en a 3 que nous mettons en évidence avec un peu de couleur:

2017-02-06_23-30-28

Un ICase est composé de deux parties:

  1. un test (Condition)
  2. une méthode à exécuter si le test réussi (Selector)

public interface ICase<in T, R>
{
   Func<T, bool> Condition { get; }
   ContextualSelector<T, R> Selector { get; }
}

Actuellement le framework propose les implémentations suivantes:

  1. Case
  2. RegexCase
  3. DefaultCase

 

Ces 3 classes héritent de ICase et de Case pour les deux dernières. Ce sont celles que l’on retrouve dans notre exemple.

Puisque Case est la classe de base, intéressons nous à sa signature.


public Case(Func<T, bool> condition, ContextualSelector<T, R> selector);

On retrouve donc la condition et le sélecteur dont on parlait tout à l’heure. Voici la situation en couleur:

2017-02-06_23-36-45

 

En jaune la condition et en bleu, le sélecteur. Vous remarquez le cas particulier de DefaultCase. C’est le cas d’exécution lorsque tous ceux qui précèdent ont échoués. Il n’a donc pas besoin de test, juste d’un sélecteur.

RegexCase réagit au résultat d’une expression régulière. Il suffit d’entrer game pour voir un message de présentation s’afficher.

Case est un test générique représenté par la fonction passée en paramètre.

 

Note: lorsque vous construirez votre code, vous devrez veiller à ce que chaque sélecteur retourne le même type (un String dans notre exemple). Autrement le code ne fonctionnera pas.

 

Remarquez comment fonctionne et est utilisée la méthode Return qui est très utile dans les chaînages. Notez aussi qu’il s’agit d’une méthode de la classe Chain.

 

Il est tout de même temps de voir le bot à l’œuvre. Il y a 3 Case, dont 3 tests:

Si on entre des phrases au hasard, on déclenche DefaultCase:

2017-02-07_21-40-45

On y passerait des heures!

Le mot clef reset quand à lui:

2017-02-07_21-43-23

Et si on entre game:

2017-02-07_21-44-45

Conclusion

J’ai présenté plusieurs interface de développement de bots. Je vous encourage de rejouer ces exemples et de faire vos propres expériences. Le produit est encore jeune, sauvage et en preview.

Il y a une doc (il faut la lire), mais elle est loin d’être suffisante. Vous savez très bien que les retours d’expérience sont tout aussi importants.

Malheureusement ils sont assez maigres. On trouve pas mal d’exemple sur Github. Une autre source intéressante est bien entendu Stackoverflow:

 

Franchement, j’ai pas mal ramé sur cet article alors que les autres sont venus très vite. J’espère que cela vous aidera.

Bibliographie

 

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