今日も窓辺でプログラム

外資系企業勤めのエンジニアが勉強した内容をまとめておくブログ

Microsoft Bot Frameworkで簡単なBotを作ってみる (3) ~Dialogを使った簡単な対話~

前回は、サンプルコードを少しだけいじった単純なbotを、AzureにあげてSkype上で会話できるようにしました。
今回はBot Builderに用意されているDialogクラスを使って、シンプルな会話を可能にする能力を実装してみたいと思います。



Dialogを使ってみる

Dialogとは「対話」というような意味で、C#のBotBuilderでは対話を簡単に実装するためのインターフェースが提供されています。オリジナルのドキュメントはこちら
Dialogは、IDialogインターフェースを継承します。
まずは前回作成したbotの、電力使用状況を教えてくれる部分を、Dialogを使用して作り直します。
せっかくなので、Dialogを使って簡単にできる「確認」ステップや、対話の状態を記憶する機能も使ってみます。

「確認」ステップを追加する

PromptDialogクラスを使うと、botに「確認」をするためのワンステップを追加できるようになります。今回作っているbotだと、次のような形になります。

f:id:kanohk:20160620212247p:plain

「電力使用状況は?」と聞かれたときに、「~~よろしいですか?」と確認するような返答をします。はい/いいえと答えると、それぞれ用意した応答を返してくれます。
この確認に答えるための語彙はエミュレータのLocaleをja-JPにしていると「はい/いいえ」という日本語で設定されますが、ほかの言語にするとほかの言語で選択肢が用意されます。

このためのコードは次の通りです。前回も使用したMessageController.csに加え、新しくDialog.csというファイルを追加し、そこにSimpleDialogというクラスを定義しています。

MessageController.cs

        public async Task<Message> Post([FromBody]Message message)
        {
            if (message.Type == "Message")
            {

                Message reply = null;
                if (message.Text == "電力使用状況は?" || message.GetBotPerUserInConversationData<BotState>("state") == BotState.Usage)
                {
                    message.SetBotPerUserInConversationData("state", BotState.Usage);

                    // Dialog.csで定義したSimpleDialogクラスを呼び出す
                    return await Conversation.SendAsync(message, () => new SimpleDialog());
                }
                else
                {
                    reply = message.CreateReplyMessage("(。・ ω<)ゞてへぺろ♡");
                }
                return reply;
            }
            else
            {
                return HandleSystemMessage(message);
            }
        }

Dialog.cs

    [Serializable]
    public class SimpleDialog : IDialog<object>
    {
        public async Task StartAsync(IDialogContext context)
        {
            context.Wait(MessageReceivedAsync);
        }

        public virtual async Task MessageReceivedAsync(IDialogContext context, IAwaitable<Message> argument)
        {
            var message = await argument;
            var usage = GetElectricityUsage();
            PromptDialog.Confirm(
                context,
                ShowUsageAsync,
                "東京電力の電力使用状況を取得します。よろしいですか?",
                "わかりませんでした。「はい」か「いいえ」でお答えください。",
                promptStyle: PromptStyle.None);
        }

        public async Task ShowUsageAsync(IDialogContext context, IAwaitable<bool> argument)
        {
            var confirm = await argument;
            if (confirm)
            {
                var usage = GetElectricityUsage();
                await context.PostAsync(usage != null ? string.Format("{0}kWです", usage) : "取得できませんでした。。");
            }
            else
            {
                await context.PostAsync("では、やめておきますね。。");
            }
            context.PerUserInConversationData.SetValue("state", BotState.Default);
            context.Wait(MessageReceivedAsync);
        }

        private int? GetElectricityUsage()
        {
            // 前回と同じなので省略
        }
    }

電力使用状況は?と聞かれると、まずはmessage.SetBotPerUserInConversationData("state", BotState.Usage);の部分でユーザのステートを変更します。
IDialogContext.PerUserInConversationDataには、ユーザーごとのデータが保存されており、ここに現在のステートを保存することでユーザーがメッセージを送ってきた時の挙動を変えています。
使用状況を聞いている状態(=BotState.Usage)だったら、何を言っていてもDialog.csで定義したSimpleDialogに飛ばしており、そうでない場合のみてへぺろを表示するようにしています。

SimpleDialogクラスは、PromptDialog.Confirmという場所で確認ステップを処理しています。確認ステップで得られた答えはShowUsageAsync関数のconfirm関数にbool値として格納されるので、その結果によって答えを返します。
電力使用状況を返しても返さなくても、電力使用状況を聞かれている状況が終わるので、ステートをリセットするのは忘れないようにします。

対話のステートを保持する

Dialogを使用すると、対話のステートも簡単に保持することができます。例えば、電力使用状況を聞かれた時刻を記憶しておき、それを対話の中で使いたいとしましょう。あんまりいい例じゃないんですが、、だいたいこんなイメージです。
f:id:kanohk:20160620215553p:plain

これを実現するためのコードは次の通りです。

    [Serializable]
    public class SimpleDialog : IDialog<object>
    {
        // 会話を始めた時刻
        protected DateTime PrevTime;
        protected bool ResetTime = false;

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

        public virtual async Task MessageReceivedAsync(IDialogContext context, IAwaitable<Message> argument)
        {
            if (ResetTime)
            {
                this.PrevTime = DateTime.Now;
                this.ResetTime = false;
            }

            // 省略
        }

        public async Task ShowUsageAsync(IDialogContext context, IAwaitable<bool> argument)
        {
            var confirm = await argument;

            // 省略

            context.PerUserInConversationData.SetValue("state", BotState.Default);
            this.ResetTime = true;
            context.Wait(MessageReceivedAsync);
        }

        private int? GetElectricityUsage()
        {
            // 省略
        }
    }

実は、このSimpleDialogのメンバ変数は、ユーザーとの対話の間失われることなく保存されています。ここではPrevTimeというメンバ変数を定義してそれを使うことによって、ユーザーと以前会話した時の情報を記憶しています。
なぜこの情報が記憶されているかというと、実はこのDialogはエミュレータでも確認できるJSONの形でやりとりされています。botPerUserInConversationDataの中のDialogStateという値を見てもらうと、何やら長い文字列が入っているかと思いますが、それがシリアライズされたDialogクラスです。だからDialogクラスには[Serializable]属性が指定されているんですね。

Tips

Dialogのデバッグをしていると、Dialogを書き換えたのに前の状態が保持されてしまっていて、期待通りの動きをしてくれない場面に遭遇するかと思います。(僕はこれに結構悩まされました)
その場合は、エミュレータのChannelConversationIdという部分の文字列を適当に書き換えてください。
DialogはこのConversationIdごとにシリアライズして保存されているので、このIDを書き換えてしまえばDialogの状態はリセットされるはずです。

今後の流れ

今回は、Dialogを使って簡単な対話なようなものを実装しました。
ソースコードへの変更は、このコミット履歴で確認できます。

次回以降は、FormFlowを使った対話の実装、そしてそのあとはLUISとDialogを使用した対話の実装に挑戦します。
www.madopro.net