Bot Framework と Microsoft Graph で DevOps その 18 : エンティティとフォームフローの連携!LUIS 応用編

前回 LUIS 入門編としてインテントと LUIS ダイアログの簡単な使いかたを紹介しました。今回は応用編として LUIS のエンティティとフォームフローとの密な連携を紹介します。尚、ボットアプリへの本格的な実装は次回で。

エンティティの活用

エンティティとは、文章におけるキーワードをカテゴライズしたものです。

Prebuilt domains

日本語ではまだ対応してませんが、LUIS はよくあるシナリオに関して、事前に定義したインテントとエンティティを追加できます。

1. https://luis.ai より前回作成した O365Bot_EN を開きます。

2. 左のメニューより Prebuilt domains をクリック。Calender を選択します。Learn more クリックすると追加されるインテントとエンティティの情報が表示されます。

image

3. 追加されたら左のメニューより Intents をクリック。インテント増えていることを確認します。それぞれに例文もあります。

image

4. Calnder.Add を開いて、どのような例文が登録されているか確認。[] で囲まれているのはエンティティとして認識される場所です。

image

5. Entities メニューから追加されたエンティティも確認。

image

6. 内容が重複するため、CreateEvent と GetEvents インテントを削除します。

カスタムエンティティの追加

Prebuild domain 同様、よく使うエンティティも事前定義されています。上記手順では時間についてのエンティティがないため、追加します。

1. 左のメニューより Entities をクリックします。

2. Add prebuild entity をクリック。

image

3. datetimeV2 を選択して、保存。この datetimeV2 は優れもので、 tomorrow や next Wednesday のような言葉を日付にしてくれます。開発者としては本当にうれしい。尚、日本語は現時点で datetime 型しかサポートしておらず、明日や来週の月曜日というキーワードを渡しても、日付には変換されません。

image

検証

追加したカスタムエンティティを例文に学ばせます。

1. Train & Test より Train Application を実行。

2. 例文として、go to tokyo station at 10 am tomorrow を入力して実行。

3. 以下のようにインテントとエンティティが取れていることを確認。Subject と Location はこの例では取れていないですね。何パターンか試してください。また Labels view を切り替えると元の値を見ることが出来ます。

image

4. 例文として cancel 10 am meeting today を試すと、以下のように Delete のインテントが取れました。

image

5. 例文 eat dinner with my wife at outback stakehouse at shinagawa at 7 pm next Wednesday だとエンティティが奇麗に取れました。

image

image

FormFlow に利用

取得できるようになったエンティティを FormFlow で使います。

1. Visual Studio でボットアプリプロジェクトから LuisRootDialog.cs を開き、英語用の LUIS モデル属性だけにします。

image

2. LuisRootDialog.cs を以下のコードに差し替えます。Intent の差し替えと、CreateEvent に LuisResult を渡すように変更。認証後のコールバックでインテント再確認しているところもう少しスマートにしたい。。

using AuthBot;
using AuthBot.Dialogs;
using Autofac;
using Microsoft.Bot.Builder.ConnectorEx;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Builder.Luis;
using Microsoft.Bot.Builder.Luis.Models;
using Microsoft.Bot.Connector;
using O365Bot.Services;
using System;
using System.Configuration;
using System.Threading;
using System.Threading.Tasks;

namespace O365Bot.Dialogs
{
    //[LuisModel("ad632695-4b01-4f29-bfcc-6855d41e07a5", "4501df87a8944328b3bd07ed8adb6508")]
    [LuisModel("b6e6e868-269c-4adb-92a4-2531effa6c79", "4501df87a8944328b3bd07ed8adb6508")]
    [Serializable]
    public class LuisRootDialog : LuisDialog<object>
    {
        LuisResult luisResult;

        [LuisIntent("Calendar.Find")]
        public async Task GetEvents(IDialogContext context, IAwaitable<IMessageActivity> activity, LuisResult luisResult)
        {
            this.luisResult = luisResult;
            var message = await activity;

            // 認証チェック
            if (string.IsNullOrEmpty(await context.GetAccessToken(ConfigurationManager.AppSettings["ActiveDirectory.ResourceId"])))
            {
                await Authenticate(context, message);
            }
            else
            {
                await SubscribeEventChange(context, message);
                await context.Forward(new GetEventsDialog(), ResumeAfterDialog, message, CancellationToken.None);
            }
        }

        [LuisIntent("Calendar.Add")]
        public async Task CreateEvent(IDialogContext context, IAwaitable<IMessageActivity> activity, LuisResult luisResult)
        {
            this.luisResult = luisResult;
            var message = await activity;
            
            // 認証チェック
            if (string.IsNullOrEmpty(await context.GetAccessToken(ConfigurationManager.AppSettings["ActiveDirectory.ResourceId"])))
            {
                await Authenticate(context, message);
            }
            else
            {
                await SubscribeEventChange(context, message);
                context.Call(new CreateEventDialog(luisResult), ResumeAfterDialog);
            }
        }

        [LuisIntent("None")]
        public async Task NoneHandler(IDialogContext context, IAwaitable<IMessageActivity> activity, LuisResult result)
        {
            await context.PostAsync("Cannot understand");
        }

        private async Task Authenticate(IDialogContext context, IMessageActivity message)
        {
            // 元のメッセージを記録
            context.PrivateConversationData.SetValue<Activity>("OriginalMessage", message as Activity);
            // 認証ダイアログの実行
            await context.Forward(new AzureAuthDialog(ConfigurationManager.AppSettings["ActiveDirectory.ResourceId"]), this.ResumeAfterAuth, message, CancellationToken.None);
        }


        private async Task SubscribeEventChange(IDialogContext context, IMessageActivity message)
        {
            if (message.ChannelId != "emulator")
            {
                using (var scope = WebApiApplication.Container.BeginLifetimeScope())
                {
                    var service = scope.Resolve<INotificationService>(new TypedParameter(typeof(IDialogContext), context));

                    // 変更を購読
                    var subscriptionId = context.UserData.GetValueOrDefault<string>("SubscriptionId", "");
                    if (string.IsNullOrEmpty(subscriptionId))
                    {
                        subscriptionId = await service.SubscribeEventChange();
                        context.UserData.SetValue("SubscriptionId", subscriptionId);
                    }
                    else
                        await service.RenewSubscribeEventChange(subscriptionId);

                    // 会話情報を購読 id をキーに格納
                    var conversationReference = message.ToConversationReference();

                    if (CacheService.caches.ContainsKey(subscriptionId))
                        CacheService.caches[subscriptionId] = conversationReference;
                    else
                        CacheService.caches.Add(subscriptionId, conversationReference);

                    // 会話情報はロケールを保存しないようなので、こちらも保存
                    if (!CacheService.caches.ContainsKey(message.From.Id))
                        CacheService.caches.Add(message.From.Id, Thread.CurrentThread.CurrentCulture.Name);
                }
            }
        }

        private async Task ResumeAfterDialog(IDialogContext context, IAwaitable<bool> result)
        {
            // ダイアログの実行結果
            var dialogResult = await result;
            // ルート処理に戻る
            context.Done(true);
        }

        private async Task ResumeAfterAuth(IDialogContext context, IAwaitable<string> result)
        {
            // 元のメッセージを復元
            var message = context.PrivateConversationData.GetValue<Activity>("OriginalMessage");
            await SubscribeEventChange(context, message);
            switch (luisResult.TopScoringIntent.Intent)
            {
                case "Calendar.Find":
                    await context.Forward(new GetEventsDialog(), ResumeAfterDialog, message, CancellationToken.None);
                    break;
                case "Calendar.Add":
                    context.Call(new CreateEventDialog(luisResult), ResumeAfterDialog);
                    break;
                case "None":
                    await context.PostAsync("Cannot understand");
                    break;
            }
        }
    }
}

3. CreateEventDialog.cs を以下のコードに差し替えます。ポイントはフォームフローに、初期値を持った OutlookEvent のインスタンスを渡している所。まだ builtin.datetimeV2.datetime に BotBuilder が対応していないのでこういう書き方になっていますが、将来的にはもっと簡単になるはず。

using Autofac;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Builder.FormFlow;
using Microsoft.Bot.Builder.Luis.Models;
using Microsoft.Graph;
using Newtonsoft.Json.Linq;
using O365Bot.Models;
using O365Bot.Resources;
using O365Bot.Services;
using System;
using System.Threading.Tasks;

namespace O365Bot.Dialogs
{
    [Serializable]
    public class CreateEventDialog : IDialog<bool> // このダイアログが完了時に返す型
    {
        LuisResult luisResult;
        
        public CreateEventDialog(LuisResult luisResult)
        {
            this.luisResult = luisResult;
        }

        public async Task StartAsync(IDialogContext context)
        {
            var @event = new OutlookEvent();

            // LuisResult の Entities から初期値を設定
            foreach (EntityRecommendation entity in luisResult.Entities)
            {
                switch (entity.Type)
                {
                    case "Calendar.Subject":
                        @event.Subject = entity.Entity;
                        break;
                    case "builtin.datetimeV2.datetime":
                        foreach (var vals in entity.Resolution.Values)
                        {
                            switch(((JArray)vals).First.SelectToken("type").ToString())
                            {
                                case "daterange":
                                    var start = (DateTime)((JArray)vals).First["start"];
                                    var end = (DateTime)((JArray)vals).First["end"];
                                    @event.Start = start;
                                    @event.Hours = end.Hour - start.Hour;
                                    break;
                                case "datetime":
                                    @event.Start = (DateTime)((JArray)vals).First["value"];
                                    break;
                            }                            
                        }
                        break;
                }
            }
            // FormFlow に初期値を渡して実行
            var outlookEventFormDialog = new FormDialog<OutlookEvent>(@event, BuildOutlookEventForm, FormOptions.PromptInStart);
            context.Call(outlookEventFormDialog, this.ResumeAfterDialog);
        }

        private async Task ResumeAfterDialog(IDialogContext context, IAwaitable<OutlookEvent> result)
        {
            await context.PostAsync(O365BotLabel.Event_Created);

            // ダイアログの完了を宣言
            context.Done(true);
        }

        public static IForm<OutlookEvent> BuildOutlookEventForm()
        {
            OnCompletionAsyncDelegate<OutlookEvent> processOutlookEventCreate = async (context, state) =>
            {
                using (var scope = WebApiApplication.Container.BeginLifetimeScope())
                {
                    IEventService service = scope.Resolve<IEventService>(new TypedParameter(typeof(IDialogContext), context));
                    // TimeZone は https://graph.microsoft.com/beta/me/mailboxSettings で取得可能だがここでは一旦ハードコード
                    Event @event = new Event()
                    {
                        Subject = state.Subject,
                        Start = new DateTimeTimeZone() { DateTime = state.Start.ToString(), TimeZone = "Tokyo Standard Time" },
                        IsAllDay = state.IsAllDay,
                        End = state.IsAllDay ? null : new DateTimeTimeZone() { DateTime = state.Start.AddHours(state.Hours).ToString(), TimeZone = "Tokyo Standard Time" },
                        Body = new ItemBody() { Content = state.Description, ContentType = BodyType.Text }
                    };
                    await service.CreateEvent(@event);
                }
            };

            return new FormBuilder<OutlookEvent>()
                .Message(O365BotLabel.Event_Create)
                .Field(nameof(OutlookEvent.Subject))
                .Field(nameof(OutlookEvent.Description))
                .Field(nameof(OutlookEvent.Start))
                .Field(nameof(OutlookEvent.IsAllDay))
                .Field(nameof(OutlookEvent.Hours), active: (state) =>
                {
                    // 表示するかを検証
                    if (state.IsAllDay)
                        return false;
                    else
                        return true;
                })               
                .OnCompletion(processOutlookEventCreate)
                .Build();
        }        
    }
}

エミュレーターで検証

一旦英語で検証します。日本語は次回。

1. エミュレーターの Locale を en-US に変えて接続。

2. eat dinner with my wife at outback stakehouse at shinagawa at 7 pm next Wednesday と送信。

3. 認証した後、イベント作成で件名ではなく詳細を聞かれることを確認。

image

4. 現在の値を確認するため、status と入力。件名と開始日時が入っていることを確認。尚、この status はフォームフローで提供されるメニューです。他のメニューは help と送ると表示されます。

image

5. 他の文章も色々試してください。

多言語アプリにおける LUIS 利用の考察

前回は LUIS ダイアログに複数の LUIS モデルを設定しましたが、これは本来モデルが異なるインテントを持っている場合のソリューションです。多言語をサポートするアプリの場合、LUIS を使うためには以下の方法が考えられます。

言語ごとに LUIS ダイアログを分ける

LUIS ダイアログを呼ぶ手間でユーザーの言語を確認して、対応するダイアログを呼ぶイメージです。

switch(activity.Locale)
{
    case "ja-JP":
        await Conversation.SendAsync(activity, () => new Dialogs.JALuisRootDialog());
        break;
    case "en-US":
        await Conversation.SendAsync(activity, () => new Dialogs.ENLuisRootDialog());
        break;
}

メリット: 言語ごとに適切な LUIS を使える。
デメリット: ダイアログが言語数分増える。LUIS が対応しない言語が処理できない。特定言語にしかない機能を使えない等。

言語ごとに LUIS 単体を実行する

LUIS は通常の Web サービスです。ダイアログに統合しない形であれば自由に呼び出すことが出来ます。また NuGet でパッケージが提供されているため利用も簡単。

メリット: 言語ごとに適切な LUIS を使える。
デメリット: LUIS ダイアログが使えない。LUIS が対応しない言語が処理できない。特定言語にしかない機能を使えない等。

常に単一言語に翻訳して利用する

メリット: 単一の LUIS ダイアログで処理できる。翻訳できる範囲の言語が使える。特定言語にしかない機能が使える。
デメリット: 翻訳サービスを通す必要がある。元の文章のエンティティを解析できない等。

まとめ

LUIS 強力すぎて紹介しきれませんが、応用編はいったんここまで。やはり datetimeV2 がすごいです。次回は O365 ボットにより本格的に組み込むとともに、テストもしっかり実装します。