[Partie 2] Introduction au MS Bot Framework : le réveil de la force !

Cet article est la suite de l’article « [Partie 1] Introduction au MS Bot Framework : la face cachée« . Si vous n’avez aucune connaissance fondamentale sur le framework, je vous invite à le lire . Nous verrons tout au long de ce chapitre plusieurs concepts intéressants tels que la génération de dialogues basés sur des formulaires, la composition de dialogues, les intercepteurs conditionnels, les interruptions, etc.

  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)

J’ai tout ce qu’il faut, je veux coder !

A ce stade, je considère que vous avez suivi le mini wiki de la doc et installé tout ce qu’il faut. Nous allons donc pouvoir commencer à nous amuser. Tout d’abord, commençons par examiner le projet template que vous avez téléchargé. C’est un projet Web Api classique avec un seul controller MessagesController. Ouvrez ce fichier et placez vous au niveau de la méthode Post du controller. Examinons le code ensemble:

public async Task<HttpResponseMessage> Post([FromBody]Activity activity) {

 if (activity.Type == ActivityTypes.Message) {

      ConnectorClient connector = new ConnectorClient(new Uri(activity.ServiceUrl));
      int length = (activity.Text ?? string.Empty).Length;
      Activity reply = activity.CreateReply($"You sent {activity.Text} which was {length} characters");
      await connector.Conversations.ReplyToActivityAsync(reply);
  }
  else
  {
      HandleSystemMessage(activity);
  }
  
  var response = Request.CreateResponse(HttpStatusCode.OK);
  return response;
}

Ce code est assez simple, au début on s’assure que l’activité reçue est bien un message; si c’est le cas, on crée un connecteur sans se soucier du canal de communication utilisé par le client puis on lui répond en envoyant tout bêtement le nombre de caractères du texte qu’il vient de nous envoyer.

Les activités

Une activité représente un type d’action bien précis qui vient de se produire. Par exemple, lorsqu’un utilisateur nous envoie un message, on reçoit une activité de type IMessageActivity qui contient le texte qui vient de nous être envoyé. La classe Activity implémente toutes les interfaces des différentes activités. On peut facilement tester le type de l’activité reçu grâce à la classe utilitaire ActivityTypes (cf. code plus haut)

Les différents types d’activités

On distingue plusieurs types d’activités parmi lesquelles on a:

  • IMessageActivity : indique qu’un message textuel vient de nous être envoyé.
  • IContactRelationUpdateActivity: indique que le bot vient d’être ajouté ou supprimé d’une liste de contact.
  • Typing: indique que l’utilisateur est en train de taper du texte. Ce type d’activité n’est pas supporté par tous les canaux.
  • Event: Indique qu’un événement asynchrone externe vient de se produire
  • DeleteUserData: demande à ce que les données de l’utilisateur soit supprimées. Nous verrons plus tard dans quel cas l’utiliser.
  • ConversationUpdate: Se produit lorsque les propriétés de la conversation viennent de changer. Par exemple lorsqu’un utilisateur vient de rejoindre ou quitter un groupe.

Je vous invite à lire la documentation si vous voulez avoir plus de détail sur les différentes activités supportées par le SDK.

Les connecteurs

Ils permettent au BOT d’envoyer et de recevoir des messages sur les différents canaux de communication (Slack, SMS, Skype, Telegram, Facebook, etc.). Le SDK fournit une implémentation générique d’un connecteur; c’est à dire qu’on a pas à se soucier de comment sont envoyées les données sur les canaux de communication, cela relève de la responsabilité du SDK. Cool non ?

Mais si je veux personnaliser mes messages en fonction du canal ?

Ne vous inquiétez pas, tout a été prévu. Le SDK fournit pas mal de méthodes pour vérifier par exemple que le canal sur lequel le BOT discute supporte les contrôles de type carrousel, les boutons, etc. Nous le verrons au moment opportun.

Pour créer une instance d’un connecteur :

var connector = new ConnectorClient(new Uri(/*l'url*/));

Vous pouvez récupérer l’url de l’API Bot Connector via la propriété ServiceUrl de l’activité que vous venez de recevoir et qui est passé dans le controller (Cf. code plus haut). Ce service est responsable de poster vos messages sur les différents channels. Une phase d’authentification est nécessaire avant de pouvoir communiquer avec cette API. Ne vous inquiétez pas, le SDK le gère très bien. La classe ConnectorClient expose également d’autres propriétés intéressantes telles que le serializer et deserialiser  utilisés, les conversations en cours, etc. Je vous invite à lire la doc pour plus d’informations.

Le Data store

Ce qui rend le bot stateless c’est que toutes les conversations sont stockées sur le channel sur lequel il communique. Rappelez-vous du pipeline de traitements des messages entrants; avant de traiter chaque message on charge le contexte de la conversation stocké dans le store. Pour optimiser ce chargement, on a 3 types de store:

  • ConnectorStore: Stocke les données sur une API Rest à l’adresse state.botframework.com
  • CachingBotDataStore: Implémente un système de cache pour limiter le coût de chargement des données via le ConnectorStore.
  • InMemoryDataStore: Implémentation volatile donc Thread-Safe d’un store

En résumé, si vous avez des données sensibles à sauvegarder et que vous ne souhaitiez pas les stocker sur des serveurs externes, vous pouvez redéfinir votre propre store et l’injecter dans le container de DI grâce aux modules.

Générateur de dialogues : FormBuilder

Lorsque vous avez un dialogue linéaire où l’utilisateur est appelé à remplir une série de champs tels qu’un formulaire, le framework met à votre disposition un builder qui permet de créer un dialogue à partir des champs d’une classe. Le builder va parcourir votre classe par réflection et en fonction du type de chaque propriété publique de votre classe, il demandera à l’utilisateur de renseigner une valeur. Le dialogue généré effectue également des vérifications entre le type attendu et la valeur saisie par l’utilisateur. C’est typiquement le cas où vous avez par exemple un champs montant de type double et que l’utilisateur envoie une valeur incorrecte.

La navigation

Le formulaire généré par le builder expose plusieurs commandes qui permettent entre autres de naviguer dans le formulaire, en l’occurrence la commande back (ou retour en français). Cette dernière redemande la saisie du champs précédent. Un cas d’usage concret est lorsque l’utilisateur s’est trompé et qu’il souhaite modifier une valeur saisie, et bien la commande back sera votre amie. Si le back ne vous convient pas parce que le champs à modifier est à n étapes en arrière, on peut directement saisir le nom du champs que l’on souhaite modifier pour naviguer vers celui-ci.

Autres commandes

La commande help (aide lorsque votre bot est en français) affiche la liste de toutes les commandes disponibles.

Comment ça marche ?

Voici un code simple à comprendre. Nous allons générer un dialogue de réservation de restaurants à partir de la classe suivante:


[Serializable]
 public class BookLine {

    [Template(TemplateUsage.EnumSelectOne, "Vous voulez manger quel type de cuisine ? {||}", ChoiceStyle = ChoiceStyleOptions.PerLine)]
    public TypeOfFood Food { get; set; }

    [Template(TemplateUsage.DateTime, "Quand voulez-vous réserver ?")]
    public DateTime When { get; set; } 
}

public enum TypeOfFood {
    Indien, 
    Francais,
    Africain, 
    Italien 
}

L’attribut Template permet de personnaliser les messages renvoyés à l’utilisateur. Ici on définit la manière dont notre liste sera affichée en l’occurrence un choix par ligne (ChoiceStyleOptions.PerLine) ainsi que le prompt affiché à l’utilisateur. Notez la présence de {||} dans le template que nous avons définit. Ça s’appelle du Pattern Language. Il permet de générer du texte à partir d’éléments connus uniquement à l’exécution.

Rajoutons une méthode static dans la classe BookLine pour générer le formulaire:


public static IForm<BookLine> Build()
{
   return new FormBuilder<BookLine>()
   .OnCompletion((context, state) => context.PostAsync(("Ok. C'est noté!")) )
   .Build();
}

La méthode OnCompletion vous permet de lancer une action lorsque l’utilisateur a fini de remplir le formulaire. Typiquement, on aurait pu appeler notre IRestaurantService pour valider le choix de l’utilisateur avant de renvoyer le message de confirmation.

Il ne nous reste plus qu’à lancer créer un dialogue à partir de ce formulaire lorsqu’une nouvelle discussion démarre (on parle de top dialog en anglais). Ouvrez le fichier MessagesController.cs et modifiez la méthode Post :

public async Task<HttpResponseMessage> Post([FromBody]Activity activity){

   if (activity.Type == ActivityTypes.Message){
       await Conversation.SendAsync(activity, () => FormDialog.FromForm(BookLine.Build));
}
else{
     //Ignore it
 }
    
   var response = Request.CreateResponse(HttpStatusCode.OK);
   return response;
}

La ligne qui nous intéresse dans ce code est celle qui transforme notre formulaire  BookLine en un dialogue (IDialog pour être précis):

...

FormDialog.FromForm(BookLine.Build)
...

La methode factory FromForm de la classe FormDialog renvoie une instance de FormDialog qui elle se charge de générer la séquence d’échange de messages avec le client.

Définir un comportement avec les FormBuilder

C’est bien beau de générer un formulaire, mais imaginons qu’on ait un champs de type string dans notre classe BookLine qui représente un numéro de téléphone et dont on veut s’assurer qu’il respecte bien le format français avant de lancer la réservation. Rajoutez une nouvelle propriété PhoneNumber dans la classe BookLine puis remplacez la fonction Build par :

 
public static IForm<BookLine> Build() {

   return new FormBuilder<BookLine>() 
   .Message("Alors, commençons la reservation !") 
   .Field(nameof(BookLine.PhoneNumber), "Quel est votre n° de telephone ?", x => true, (state, input) => {
       var rs = new ValidateResult(); 
       var match = Regex.Match(input.ToString(), "(0|\\+33|0033)[1-9][0-9]{8}");

       if (match.Success) {
          rs.IsValid = true; rs.Value = match.Value;
       } 
       else { 
          rs.Feedback = "Le n° de tel est incorrect"; rs.IsValid = false; 
       }
    
       return Task.FromResult(rs); 
   }) 
   .AddRemainingFields()
   .OnCompletion((context, state) => context.PostAsync(("Ok. C'est noté!"))) 
   .Build(); 
}

La méthode Field du FormBuilder permet de définir manuellement un nouveau champs dans le formulaire et donc de rajouter du comportement à l’exécution. Ici on s’assure juste que la valeur renvoyée par l’utilisateur correspond bien à un numéro de téléphone en France.

La méthode AddRemainingFields permet de générer les champs restants du formulaire qui ne nécessitent aucune spécialisation particulière.

La classe FieldReflector

En fait, je ne vous ai pas tout dit à propos de la méthode Field. Si vous regardez bien le bout code ci-dessus, vous remarquerez que le premier paramètre de la méthode Field correspond au nom de la propriété dans la classe. Cela permet au builder par reflection de pouvoir lire ou écrire une valeur dans votre formulaire. Il existe également une surcharge très intéressante qui prend un objet de type IField; Intéressant parce que cela nous permet de créer des composants personnalisés, testables et surtout réutilisables. L’implémentation par défaut de cette interface est la classe FieldReflector.

Reprenons notre champs numéro de téléphone de l’exemple précédent. Ce que je veux maintenant c’est créer un composant PhoneNumber qui pourra être réutilisé dans d’autres formulaires. Voici le code:


  public class PhoneNumber<T> : FieldReflector<T> where T : class
    {
        private static readonly Regex Regex = new Regex("(0|\\+33|0033)[1-9][0-9]{8}");
        public PhoneNumber(string propertyName, string promptText) : base(propertyName)
        {
            SetPrompt(new PromptAttribute(promptText));
        }

        public override async Task<ValidateResult> ValidateAsync(T state, object input)
        {
            await base.ValidateAsync(state, input);

            var rs = new ValidateResult();

            var match = Regex.Match(input.ToString());

            if (match.Success)
            {
                rs.IsValid = true;
                rs.Value = match.Value;
            }
            else
            {
                rs.Feedback = "Le n° de tel est incorrect";
                rs.IsValid = false;
            }

            return rs;
        }
    }

On bénéficie ainsi de la puissance du FieldReflector en matière de reflection pour permettre à notre nouveau composant de demeurer générique. Le petit bémol dans ce code, c’est qu’il ne respecte pas du tout le second principe du Gof, celui de toujours préférer la composition à l’héritage. Ici, le comportement est défini dans la hiérarchie, ce qui implique que si la hiérarchie change, il faut modifier le code de notre composant. Je vous laisse réfléchir à une solution plus propre. Petit indice : l’interface IField sera votre compagnon dans cette aventure 🙂

Composition de dialogues

Corsons un peu les choses. Ce que l’on veut faire, c’est de rajouter un nouveau type de dialogue à notre bot précédemment crée qui, je rappelle, effectuait une simple réservation dans un restaurant déjà connu. On veut pouvoir permettre à l’utilisateur de renseigner ses préférences alimentaires en amont afin de lui proposer une liste de restaurants adéquats lorsqu’il souhaite réserver. On a donc deux types de dialogue ici :

  • La saisie de ses préférences alimentaires:
    • Type de cuisine (Italienne, Indienne, Africaine, Asiatique, etc.);
    • Tarifs (prix min et prix max)
  • La réservation en tant que telle:
    • Présentation de la liste de restaurants qui satisfont ses critères;
    • Nombre de personnes associées à la réservation;
    • Horaire de la réservation;
    • ….

L’utilisateur doit pouvoir à tout moment interrompre un dialogue, passer à un autre et revenir au dialogue interrompu sans problème. En plus, lorsque le bot reçoit un STOP , il doit immédiatement arrêter la discussion. Pour l’instant, nous n’allons pas implanter d’analyse sémantique avancée pour interpréter les intentions de l’utilisateur; cela fera l’objet du prochain chapitre. Nous allons tout simplement nous baser sur des commandes:

  • STOP: Arrête la discussion
  • RESERVE: Interrompt le dialogue si nécessaire et démarre le dialogue de réservation d’un restaurant. Une fois terminée, reprend le dialogue précédemment interrompu;
  • PREFERENCE: même principe que pour la commande RESERVE à la différence que celui ci démarre le dialogue de saisie des préférences alimentaires

Comment s’y prendre

Commençons par souligner les éléments essentiels de notre cahier des charges:

  • On a 3 types de commande;
  • On a 2 types de dialogue;
  • On a 1 système d’interruption;

Je vous propose de réaliser les dialogues en premier puis d’intégrer progressivement le reste des fonctionnalités. Pour la création des dialogues , nous allons combiner FormBuilder et Dialogue classique. Voici le code du dialogue de réservation:

 [Serializable]
    public class BookingDialog : IDialog<BookForm>{

        private readonly Func<IRestaurantService> _restaurantServiceFactory;
        private readonly Func<IDialogContext, FoodPreferences> _preferenceLoader;
        private IEnumerable<Restaurant> _currentView;

        public BookingDialog(Func<IRestaurantService> restaurantService, Func<IDialogContext, FoodPreferences> preferenceLoader)
        {
            _restaurantServiceFactory = restaurantService;
            _preferenceLoader = preferenceLoader;
        }

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

        private async Task OnStart(IDialogContext context, IAwaitable<IMessageActivity> result)
        {
            var msg = await result;
            var service = _restaurantServiceFactory();
            var prefs = _preferenceLoader(context);
            _currentView = service.FilterBy(prefs);
            var forms = FormDialog.FromForm(BuildForm);
            await context.Forward(forms, OnBookingDone, msg, CancellationToken.None);
        }

        private static async Task OnBookingDone(IDialogContext context, IAwaitable<BookForm> result)
        {
            context.Done(await result);
        }

        private IForm<BookForm> BuildForm()
        {
            return new BookForm(() => _currentView).Build();
        }
    }

    public enum TypeOfFood
    {
        None,
        Africain,
        Asiatique,
        Francais,
        Indien,
        Italien
    }

Et voici le code du formulaire utilisé :


 [Serializable]
    public class BookForm
    {
        private readonly Func<IEnumerable<Restaurant>> _itemsFactory;

        public BookForm(Func<IEnumerable>Restaurant>> itemsFactory)
        {
            _itemsFactory = itemsFactory;
        }

        public BookForm() { }

        [Template(TemplateUsage.NotUnderstood, "{0} n'est pas dans la liste")]
        public Restaurant Restaurant { get; set; }

        [Template(TemplateUsage.Integer, "Combien de personnes autour de la table?")]
        [Template(TemplateUsage.NotUnderstood, "10 personnes maximum autour de la table")]
        [Numeric(1, 10)]
        public short PeopleArounTable { get; set; }

        [Template(TemplateUsage.DateTime, "A quelle heure du coup ?")]
        public DateTime When { get; set; }

        [Template(TemplateUsage.String, "Je reserve à quel nom ?")]
        public string Name { get; set; }

        internal IForm<BookForm> Build()
        {
            return new FormBuilder<BookForm>()
                .Field(new ListView<BookForm, Restaurant>(nameof(BookForm.Restaurant), "Veuillez selectionner votre restaurant {||}:", _itemsFactory()))
                .AddRemainingFields()
                .Build();
        }
 }

J’ai crée un nouveau composant appelé ListView qui permet d’afficher une liste générique d’éléments.

[Serializable]
    public class ListView<TForm, TItem> : Field<TForm> where TForm : class where TItem : class
    {
        private readonly IEnumerable<TItem> _items;

        public ListView(string property, string prompt, IEnumerable<TItem> items) : base(property, FieldRole.Value)
        {
            _items = items;
            _promptDefinition = new PromptAttribute(prompt) { ChoiceStyle = ChoiceStyleOptions.PerLine };
        }

        public override async Task<bool> DefineAsync(TForm state)
        {
            await base.DefineAsync(state);
            AddTemplate(new TemplateAttribute(TemplateUsage.NotUnderstood, "{0} n'est pas dans la liste"));
            _recognizer = new RecognizeEnumeration<TForm>(this);
            _prompt = new Prompter<TForm>(_promptDefinition, Form, _recognizer);
            return true;
        }

        public override bool IsUnknown(TForm state)
        {
            return typeof(TForm).GetProperty(_name).GetValue(state) == default(TItem);
        }

        public override void SetUnknown(TForm state)
        {
            SetValueByReflection(state, default(TItem));
        }

        public override object GetValue(TForm state)
        {
            return typeof(TForm).GetProperty(_name).GetValue(state);
        }

        public override void SetValue(TForm state, object value)
        {
            SetValueByReflection(state, value);
        }

        private void SetValueByReflection(TForm obj, object value)
        {
            typeof(TForm).GetProperty(_name).SetValue(obj, value);
        }

        public override IEnumerable<object> Values => _items;
        public override IEnumerable<string> FieldTerms => _items.Select(x => x.ToString());
        public override IEnumerable<DescribeAttribute> ValueDescriptions => _items.Select(x => new DescribeAttribute(x.ToString()));
        public override IEnumerable<string> Terms(object value)
        {
            return FieldTerms.Where(x => x.Equals(value));
        }
    }

Ce que vous devez retenir c’est que la classe BookForm définit le formulaire à remplir et la classe BookingDialog nous permet d’utiliser ce formulaire et plus tard d’intégrer la commande STOP. Voici le code de notre second dialogue :


    [Serializable]
    public class FoodPreferences
    {
        [Template(TemplateUsage.EnumSelectOne, "Votre 1er choix ? {||}", ChoiceStyle = ChoiceStyleOptions.PerLine)]
        public TypeOfFood First { get; set; }

        [Template(TemplateUsage.EnumSelectOne, "Votre deuxième choix ? {||}", ChoiceStyle = ChoiceStyleOptions.PerLine)]
        public TypeOfFood Second { get; set; }

        [Template(TemplateUsage.EnumSelectOne, "Et enfin, votre dernier choix ? {||}", ChoiceStyle = ChoiceStyleOptions.PerLine)]
        public TypeOfFood Third { get; set; }

        [Template(TemplateUsage.Double, "Le montant maximum que vous souhaitiez payer ?")]
        public double PrixMax { get; set; }


        public static IForm<FoodPreferences> BuildForm()
        {
            return new FormBuilder<FoodPreferences>()
                .Message("Vous souhaitez donc saisir vos préferences alimentaires. Allons y !")
                .AddRemainingFields()
                .OnCompletion(async (context, state) =>;
                {
                    await context.PostAsync("Ok c'est noté.");
                })
                .Build();
        }
    }

Voici l’implémentation du IRestaurantService. Vous pouvez remplacer ce mock par ce que vous voulez; le but étant de comprendre le concept.


    public interface IRestaurantService
    {
        Task<bool> Book(DateTime time, Restaurant restaurant);
        IReadOnlyCollection<Restaurant> FilterBy(FoodPreferences prefs);
    }


   public class RestaurantService : IRestaurantService
    {
        private static readonly IEnumerable<Restaurant> Restaurants;

        static RestaurantService()
        {
            var priceGenerator = new Random(0);

            Restaurants = new List<Restaurant>
            {
                new Restaurant("Fouquet",priceGenerator.Next(30,100),TypeOfFood.Africain,TypeOfFood.Asiatique,TypeOfFood.Francais,TypeOfFood.Indien,TypeOfFood.Italien),
                new Restaurant("Au poulet braisé",priceGenerator.Next(10,100),TypeOfFood.Africain),
                new Restaurant("Kim Yong",priceGenerator.Next(10,100),TypeOfFood.Asiatique),
                new Restaurant("Sea plaza",priceGenerator.Next(10,100),TypeOfFood.Italien,TypeOfFood.Francais)
            };
        }

        public Task<bool> Book(DateTime time, Restaurant restaurant)
        {
            var networkLatency = 1500;
            return Task.Delay(networkLatency).ContinueWith(_ => restaurant.IsAvailableAt(time));
        }
        public IReadOnlyCollection<Restaurant> FilterBy(FoodPreferences prefs)
        {
            return Restaurants.Where(x => x.CanEat(prefs)).ToList();
        }
    }

   [Serializable]
    public class Restaurant
    {
        public string Name { get; }
        public IReadOnlyCollection<TypeOfFood> Meals { get; }
        public double AveragePrice { get; }

        public Restaurant(string name, double averagePrice, params TypeOfFood[] meals)
        {
            Name = name;
            Meals = meals;
            AveragePrice = averagePrice;
        }

        public bool CanEat(FoodPreferences preferences)
        {

            if (preferences.First == TypeOfFood.None || Meals.Contains(preferences.First) || Meals.Contains(preferences.Second) ||
                Meals.Contains(preferences.Third))
            {
                return true;
            }

            return AveragePrice <= preferences.PrixMax;
        }

        public bool IsAvailableAt(DateTime time)
        {
            return time.DayOfWeek != DayOfWeek.Saturday && time.DayOfWeek != DayOfWeek.Sunday;
        }

        public override string ToString()
        {
            return Name;
        }
    }

Il ne reste plus qu’à définir notre dialogue principal (le fameux top dialog) qui servira d’aiguillage vers les autres en fonction des commandes reçues. Il existe deux façons élégantes à mon sens de le faire; la seule différence entre les deux, c’est que l’une devient très limitée lorsqu’on souhaite rajouter une nouvelle fonctionnalité.

Methode 1 : CommandDialog

Comme son nom l’indique, cette classe vous permet de définir un dialogue basé uniquement sur des commandes. Cela nous convient parfaitement pour l’instant. Voici le code :


public static class BotApp
    {
        private static readonly Regex Reserver = new Regex("^RESERVER$");
        private static readonly Regex Preference = new Regex("^PREFERENCE$");
        private static readonly Func<IRestaurantService> RestaurantServiceFactory = () => new RestaurantService();

        public static IDialog<object> Create()
        {
            return new CommandDialog<object>()
                .On<FoodPreferences>(Preference, PreferenceHandler)
                .On<BookForm>(Reserver, BookingHandler)
                .OnDefault<object>(OnDefault);
        }

        private static async Task OnDefault(IDialogContext context, IAwaitable<IMessageActivity> result)
        {
            await context.PostAsync("Je ne comprends pas ce que tu veux faire !");
        }

        private static async Task PreferenceHandler(IDialogContext context, IAwaitable<IMessageActivity> result)
        {
            var message = await result;
            var subDialog = FormDialog.FromForm(FoodPreferences.BuildForm);
            await context.Forward(subDialog, PersistPreferences, message, CancellationToken.None);
        }

        private static async Task PersistPreferences(IDialogContext context, IAwaitable<FoodPreferences> result)
        {
            var pref = await result;
            context.PrivateConversationData.SetValue("__prefs__", pref);
            context.Done(pref);
        }

        private static async Task BookingHandler(IDialogContext context, IAwaitable<IMessageActivity> result)
        {
            LoadPreference(context);

            var message = await result;
            var subDialog = new BookingDialog(RestaurantServiceFactory, LoadPreference);
            await context.Forward(subDialog, AfterBooking, message, CancellationToken.None);
        }

        public static FoodPreferences LoadPreference(IDialogContext context)
        {
            if (!context.PrivateConversationData.TryGetValue("__prefs__", out FoodPreferences preferences))
            {
                preferences = new FoodPreferences();
                context.PrivateConversationData.SetValue("__prefs__", preferences);
            }

            return preferences;
        }

        private static async Task AfterBooking(IDialogContext context, IAwaitable<BookForm> result)
        {
            var form = await result;
            var booked = await RestaurantServiceFactory().Book(form.When, form.Restaurant);

            if (booked)
            {
                await context.PostAsync($"C'est fait. J'ai reservé pour {form.PeopleArounTable} personnes au *{form.Restaurant}* à {form.When:T} ");
            }
            else
            {
                await context.PostAsync("je n'ai pas pu reserver malheureusement. Contactez directement l'établissement!");
            }

            context.Done(form);
        }
    }

Ce qu’il faut absolument retenir:
  • Au niveau de la méthode create, on crée une instance de CommandDialog et on indique nos commandes sous forme d’expression régulière ainsi que le code à exécuter (les handlers).
  • Dans notre context, on dispose de la propriété PrivateConversationData qui nous permet de sauvegarder certaines informations utiles. Ces données seront stockées dans le data store dont je vous parlais précédemment et disponible à chaque fois que vous recevrez un message sur ce canal.

Pour tester ce code, modifier la méthode Post du MessagesController comme suit:


 public async Task<HttpResponseMessage> Post([FromBody]Activity activity){
   if (activity.Type == ActivityTypes.Message)
    {
         await Conversation.SendAsync(activity, BotApp.Create);
    }
    else
    {
         //Ignore it
    }

    var response = Request.CreateResponse(HttpStatusCode.OK);
    return response;
 }

Methode 2 : DispatchDialog

C’est celle que j’utilise pour combiner à la fois des fonctionnalités basées sur des commandes et celles sur de l’analyse sémantique. Nous la verrons un peu plus tard pour éviter de trop vous en donner d’un coup.

La commande STOP

Elle est un peu particulière. Elle doit être disponible dans tous les contextes possibles et non uniquement à la racine de notre dialogue (l’aiguilleur). On peut coder comme des bourrins et mettre plein d’instructions conditionnelles à chaque code de réveil du bot c’est à dire lorsqu’on reçoit un nouveau message. Cela serait non seulement fastidieux mais difficile à maintenir. Je vous propose donc d’utiliser les scorables.

Les Scorables

Si vous avez lu la face cachée du Framework, ce mot devrait vous rappeler quelque chose: les consommateurs d’activités. Moi je les surnomme « intercepteurs conditionnels ». Ils permettent d’attribuer une note à chaque activité reçue (messages,event, trigger, etc.) et de définir une action à exécuter si ce score est le plus élevé parmi tous les autres. Si aucun scorable ne parvient à donner une note à un message alors ce message est transmis au dialogue en cours. Ça peut paraître flou la première fois mais croyez moi, vous ne vous en passerez plus.

Pour vous aider à comprendre, imaginons un peu que l’on ait N dialogues et qu’on souhaiterait que certains types de messages dits SPAM ne soient pas pris en compte. Avec les Scorables, on peut donc définir un nouveau type de consommateurs de messages de type SPAM. Si notre scorable indique que le message reçu est un spam alors il le consomme sinon il est transmis au prochain consommateur.

Mais c’est quoi un score au fait ?

C’est là où c’est fort. Un score peut être ce que voulez du moment où deux scores sont comparables. Cela peut être un double, un entier ou un objet complexe.

Comment créer un Scorable

Il existe une classe Actions qui permet de créer un scorable à partir d’une fonction. Voici un exemple de scorable qui permet de capturer tous les messages contenant le mot viagra:


var scorable = Actions.Bind(async (IBotToUser botToUser, CancellationToken tok) => {
   await botToUser.PostAsync("Du gland, c'est un spam !");
})
.When(new Regex("(?i)viagra"))
.Normalize()
.SelectScore((r, s) => s * 0.9)

Les paramètres IBotToUSer et CancellationToken seront passés automatiquement lors de l’instanciation de ce scorable. Comme son nom l’indique, l’interface IBotToUser permet d’envoyer des messages au client. D’ailleurs l’interface IDialogContext en hérite; voilà pourquoi vous disposez d’une méthode PostAsync via le context du dialogue.

Comment l’utiliser ?

Il existe deux façons de faire :

  • Au niveau du dialogue: Tous les dialogues disposent d’une méthode WithScorable qui leurs permet de faire participer un scorable au processus de traitement des messages.
  • Avec AutoFac : On peut l’injecter directement dans le container de DI. Dans ce cas il sera appelé tout le temps. Ceci fera l’objet d’un autre chapitre.
Et la commande STOP dans tous ça ?

Pour que tous nos dialogues supportent cette commande, nous allons donc créer un scorable qui intercepte le mot « STOP » et qui arrête tout. Modifier la fonction Create de la class BotApp comme suit:

 ...
 private static readonly Regex Stop = new Regex("^STOP$");
 ...
 public static IDialog<object> Create(){
 
     var watcher = Actions.Bind(async (IBotToUser botToUser, IDialogStack task, CancellationToken tok) =>
     {
        await botToUser.PostAsync("Bye bye!", cancellationToken: tok);
        task.Fail(new OperationCanceledException());
     })
    .When(Stop)
    .Normalize()
    .SelectScore((r, s) => s * 0.99999);

    return new CommandDialog<object>()
    .On<FoodPreferences>(Preference, PreferenceHandler)
    .On<BookForm>(Reserver, BookingHandler)
    .On<OperationCanceledException>(Stop, Exit)
    .OnDefault<object>(OnDefault)
    .WithScorable(watcher)
    .DefaultIfException();
 }
  
  ...
}

Lancez le projet et taper la commande « STOP » dans n’importe lequel des trois dialogues que nous avions définis précédemment, la conversation s’arrête instantanément en renvoyant « bye bye » à l’utilisateur.

Ce qu’il nous reste à voir

Dans le troisième chapitre, nous allons terminer l’implémentation de notre bot en rajoutant les interruptions et la restauration de dialogue. Nous allons également voir le « DispatchDialog » et commencer à intégrer de la reconnaissance sémantique grâce à LUIS.

Tags: Bot, Bot Framework,

Pas de commentaire

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *