Bot Framework と Microsoft Graph で DevOps その 17 : 自然言語処理を試す LUIS 入門編

今回は LUIS (Language Understanding Intelligent Service) の入門編を。LUIS を使うことで、文章の意図(インテント)とキーワード(エンティティ)を取得することが出来ます。まずはイベント取得とイベント作成のインテントと取れるようにしてみます。

LUIS の登録と作成

1. https://luis.ai にアクセス。アカウントがない場合はアカウントの作成。ある場合はサインイン。

2. New App をクリックして新しいアプリを登録。

image

3. 日本語用の LUIS アプリをまず作成。

image

4. Create Intent をクリックして、Add Intent をクリック。

imageimage

5. インテント名を GetEvents として保存。

image

6. テキストボックスに例文を入力。最低 5 個は入力して、保存。

image

7. 同じ手順で CreateEvent インテントも作成。

image

8. 左のメニューより Train & Test をクリックして、Train Application をクリック。

image

9. 例文を入力して、結果を確認。登録時とは異なる言い方をしましたが、無事インテント取得出来ました。

image

10. 左メニューより Publish App を選択して、Add a new key to your account をクリック。

image

11. とりあえず Programmatic API Key をコピーして、Add a new key よりキーを追加。コピーしたキーを入れておきます。

image

12. 元の画面に戻って登録したキーを選択。体験版のキーのため月 1000 回までと警告出てますが、無視して Publish.

image

13. 左のメニューから Settings をクリックして、アプリケーションID を確認。キーと ID は後で使います。

image

LUIS ダイアログ

LUIS を Bot Framework で使う便利な方法として、LUIS ダイアログがあります。まずこの使い方から。

1. Visual Studio でボットアプリプロジェクトの Dialogs フォルダに LuisRootDialog.cs ファイルを追加し、以下コードと差し替え。

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("LUIS Application ID", "LUIS Key")]
    [Serializable]
    public class LuisRootDialog : LuisDialog<object>
    {
        LuisResult luisResult;

        [LuisIntent("GetEvents")]
        public async Task GetEvents(IDialogContext context, IAwaitable<IMessageActivity> activity, LuisResult result)
        {
            this.luisResult = result;
            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("CreateEvent")]
        public async Task CreateEvent(IDialogContext context, IAwaitable<IMessageActivity> activity, LuisResult result)
        {
            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(), 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 "GetEvents":
                    await context.Forward(new GetEventsDialog(), ResumeAfterDialog, message, CancellationToken.None);
                    break;
                case "CreateEvent":
                    context.Call(new CreateEventDialog(), ResumeAfterDialog);
                    break;
                case "None":
                    await context.PostAsync("Cannot understand");
                    break;
            }
        }
    }
}

2. MessagesController.cs ファイルで RootDialog.cs を呼び出す箇所を LuisRootDialog に差し替え。

if (activity.Type == ActivityTypes.Message)
{
    // 常に LuisRootDialog を実行
    await Conversation.SendAsync(activity, () => new Dialogs.LuisRootDialog());
}

エミュレーターで検証

1. F5 でアプリを起動。

2. エミュレーターで接続して、認証から予定の取得、作成までテスト。

image

多言語対応

LUIS ダイアログは複数のモデルを同時に指定できます。日本語用の LUIS を作成したので、次は英語用を作成。同じインテント名で作成します。

1. 英語用で LUIS アプリを作成。

image

2. GetEvents と CreateEvent インテント追加。

imageimage

3. Train および Publish をします。また Application ID 確認しておきます。

4. LuisRootDialog.cs のクラスレベル属性に、新しく作ったモデルを追記します。

image

エミュレーターで検証

エミュレーターで接続して、日本語と英語どちらでも意図通り動くことを確認。

image

課題
なんとなくうまく実行できている気がしますが、実際は言語設定に関わらず 2 つの LUIS モデルにアクセスし、確度が高いほうが採用されます。応用編では言語ごとに使う LUIS アプリを変えてみます。

ユニットテスト

LUIS の呼び出しと結果のマップをモックする必要があります。今回はサンプルとしてとりあえずテスト 1 だけ紹介しておきます。

1. ユニットテストプロジェクトの Helper フォルダに LuisTestBase.cs を追加して、コードを差し替えます。このクラスは BotBuilder の GitHub にあるものと同じです。

using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Builder.Luis;
using Microsoft.Bot.Builder.Luis.Models;
using Microsoft.Bot.Builder.Tests;
using Moq;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;

namespace Microsoft.Bot.Builder.Tests
{
    public abstract class LuisTestBase : DialogTestBase
    {
        public static IntentRecommendation[] IntentsFor<D>(Expression<Func<D, Task>> expression, double? score)
        {
            var body = (MethodCallExpression)expression.Body;
            var attributes = body.Method.GetCustomAttributes<LuisIntentAttribute>();
            var intents = attributes
                .Select(attribute => new IntentRecommendation(attribute.IntentName, score))
                .ToArray();
            return intents;
        }

        public static EntityRecommendation EntityFor(string type, string entity, IDictionary<string, object> resolution = null)
        {
            return new EntityRecommendation(type: type) { Entity = entity, Resolution = resolution };
        }

        public static EntityRecommendation EntityForDate(string type, DateTime date)
        {
            return EntityFor(type,
                date.ToString("d", DateTimeFormatInfo.InvariantInfo),
                new Dictionary<string, object>()
                {
                    { "resolution_type", "builtin.datetime.date" },
                    { "date", date.ToString("yyyy-MM-dd", DateTimeFormatInfo.InvariantInfo) }
                });
        }

        public static EntityRecommendation EntityForTime(string type, DateTime time)
        {
            return EntityFor(type,
                time.ToString("t", DateTimeFormatInfo.InvariantInfo),
                new Dictionary<string, object>()
                {
                    { "resolution_type", "builtin.datetime.time" },
                    { "time", time.ToString("THH:mm:ss", DateTimeFormatInfo.InvariantInfo) }
                });
        }

        public static void SetupLuis<D>(
            Mock<ILuisService> luis,
            Expression<Func<D, Task>> expression,
            double? score,
            params EntityRecommendation[] entities
            )
        {
            luis
                .Setup(l => l.QueryAsync(It.IsAny<Uri>(), It.IsAny<CancellationToken>()))
                .ReturnsAsync(new LuisResult()
                {
                    Intents = IntentsFor(expression, score),
                    Entities = entities
                });
        }

        public static void SetupLuis<D>(
            Mock<ILuisService> luis,
            string utterance,
            Expression<Func<D, Task>> expression,
            double? score,
            params EntityRecommendation[] entities
            )
        {
            var uri = new UriBuilder() { Query = utterance }.Uri;
            luis
                .Setup(l => l.BuildUri(It.Is<LuisRequest>(r => r.Query == utterance)))
                .Returns(uri);

            luis.Setup(l => l.ModifyRequest(It.IsAny<LuisRequest>()))
                .Returns<LuisRequest>(r => r);

            luis
                .Setup(l => l.QueryAsync(uri, It.IsAny<CancellationToken>()))
                .Returns<Uri, CancellationToken>(async (_, token) =>
                {
                    return new LuisResult()
                    {
                        Intents = IntentsFor(expression, score),
                        Entities = entities
                    };
                });
        }
    }
}

2. プロジェクトに新しく LuisUnitTest1.cs を作成し、コードを差し替えます。

using Autofac;
using Microsoft.Bot.Builder.Base;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Builder.Dialogs.Internals;
using Microsoft.Bot.Builder.Internals.Fibers;
using Microsoft.Bot.Builder.Luis;
using Microsoft.Bot.Builder.Luis.Models;
using Microsoft.Bot.Builder.Tests;
using Microsoft.Bot.Connector;
using Microsoft.Graph;
using Microsoft.QualityTools.Testing.Fakes;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using O365Bot.Dialogs;
using O365Bot.Handlers;
using O365Bot.Resources;
using O365Bot.Services;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;

namespace O365Bot.UnitTests
{
    [TestClass]
    public class SampleLuisTest : LuisTestBase

    {
        private string locale = "ja-JP";

        public SampleLuisTest()
        {            
            Thread.CurrentThread.CurrentCulture = new CultureInfo(locale);
            Thread.CurrentThread.CurrentUICulture = new CultureInfo(locale);
        }

        [TestMethod]
        public async Task ShouldReturnEvents()
        {
            // Fakes を使うためにコンテキストを作成
            using (ShimsContext.Create())
            {
                // AuthBot の GetAccessToken メソッドを実行した際、dummyToken というトークンが返るよう設定
                AuthBot.Fakes.ShimContextExtensions.GetAccessTokenIBotContextString = 
                    async (a, e) => { return "dummyToken"; };


                // LUIS サービスのモック
                var luis1 = new Mock<ILuisService>();
                // 他依存サービスのモック
                var mockEventService = new Mock<IEventService>();
                mockEventService.Setup(x => x.GetEvents()).ReturnsAsync(new List<Event>()
                {
                    new Event
                    {
                        Subject = "dummy event",
                        Start = new DateTimeTimeZone()
                        {
                            DateTime = "2017-05-31 12:00",
                            TimeZone = "Standard Tokyo Time"
                        },
                        End = new DateTimeTimeZone()
                        {
                            DateTime = "2017-05-31 13:00",
                            TimeZone = "Standard Tokyo Time"
                        }
                    }
                });
                var subscriptionId = Guid.NewGuid().ToString();
                var mockNotificationService = new Mock<INotificationService>();
                mockNotificationService.Setup(x => x.SubscribeEventChange()).ReturnsAsync(subscriptionId);
                mockNotificationService.Setup(x => x.RenewSubscribeEventChange(It.IsAny<string>())).Returns(Task.FromResult(true));

                var builder = new ContainerBuilder();
                builder.RegisterInstance(mockEventService.Object).As<IEventService>();
                builder.RegisterInstance(mockNotificationService.Object).As<INotificationService>();
                WebApiApplication.Container = builder.Build();

                // テストしたい LUIS ダイアログのインスタンス作成
                LuisRootDialog rootDialog = new LuisRootDialog();

                // メモリ内で実行できる環境を作成
                Func<IDialog<object>> MakeRoot = () => rootDialog;
                using (new FiberTestBase.ResolveMoqAssembly(luis1.Object))
                using (var container = Build(Options.ResolveDialogFromContainer, luis1.Object))
                {
                    var dialogBuilder = new ContainerBuilder();
                    dialogBuilder
                        .RegisterInstance(rootDialog)
                        .As<IDialog<object>>();
                    dialogBuilder.Update(container);

                     // グローバルメッセージ登録
                    RegisterBotModules(container);

                    // LUIS の結果を GetEvents に指定
                    SetupLuis<LuisRootDialog>(luis1, d => d.GetEvents(null, null, null), 1.0, new EntityRecommendation(type: "GetEvents"));
                    
                    // Bot に送るメッセージを作成
                    var toBot = DialogTestBase.MakeTestMessage();
                    toBot.From.Id = Guid.NewGuid().ToString();
                    // Locale 設定
                    toBot.Locale = locale;
                    toBot.Text = "予定一覧";

                    // メッセージを送信して、結果を受信
                    IMessageActivity toUser = await GetResponse(container, MakeRoot, toBot);

                    // 結果の検証
                    Assert.IsTrue(toUser.Text.Equals("2017-05-31 12:00-2017-05-31 13:00: dummy event"));
                }
            }
        }

    
        /// <summary>
        /// グローバルメッセージおよびインターセプター登録
        /// </summary>
        private void RegisterBotModules(IContainer container)
        {
            var builder = new ContainerBuilder();
            // グローバルメッセージの処理登録
            builder.RegisterModule(new ReflectionSurrogateModule());
            builder.RegisterModule<GlobalMessageHandlers>();
            // インターセプトの登録
            builder.RegisterType<ActivityLogger>().AsImplementedInterfaces().InstancePerDependency();
            builder.Update(container);
        }

        /// <summary>
        /// Bot にメッセージを送って、結果を受信
        /// </summary>
        public async Task<IMessageActivity> GetResponse(IContainer container, Func<IDialog<object>> makeRoot, IMessageActivity toBot)
        {
            using (var scope = DialogModule.BeginLifetimeScope(container, toBot))
            {
                DialogModule_MakeRoot.Register(scope, makeRoot);

                // act: sending the message
                using (new LocalizedScope(toBot.Locale))
                {
                    var task = scope.Resolve<IPostToBot>();
                    await task.PostAsync(toBot, CancellationToken.None);
                }
                //await Conversation.SendAsync(toBot, makeRoot, CancellationToken.None);
                return scope.Resolve<Queue<IMessageActivity>>().Dequeue();
            }
        }

        /// <summary>
        /// Bot にメッセージを送って、結果を受信
        /// </summary>
        public async Task<List<IMessageActivity>> GetResponses(IContainer container, Func<IDialog<object>> makeRoot, IMessageActivity toBot)
        {
            using (var scope = DialogModule.BeginLifetimeScope(container, toBot))
            {
                var results = new List<IMessageActivity>();
                DialogModule_MakeRoot.Register(scope, makeRoot);

                // act: sending the message
                using (new LocalizedScope(toBot.Locale))
                {
                    var task = scope.Resolve<IPostToBot>();
                    await task.PostAsync(toBot, CancellationToken.None);
                }
                //await Conversation.SendAsync(toBot, makeRoot, CancellationToken.None);
                var queue= scope.Resolve<Queue<IMessageActivity>>();
                while(queue.Count != 0)
                {
                    results.Add(queue.Dequeue());
                }

                return results;
            }
        }

        /// <summary>
        /// プロアクティブ通知でダイアログ差し込みを行い、結果を受信
        /// </summary>
        public async Task<List<IMessageActivity>> Resume(IContainer container, IDialog<object> dialog, IMessageActivity toBot)
        {
            using (var scope = DialogModule.BeginLifetimeScope(container, toBot))
            {
                var results = new List<IMessageActivity>();

                var botData = scope.Resolve<IBotData>();
                await botData.LoadAsync(CancellationToken.None);
                var task = scope.Resolve<IDialogTask>();

                //現在の会話にダイアログを差し込み
                task.Call(dialog.Void<object, IMessageActivity>(), null);
                await task.PollAsync(CancellationToken.None);
                await botData.FlushAsync(CancellationToken.None);
                
                // 結果の取得
                var queue = scope.Resolve<Queue<IMessageActivity>>();
                while (queue.Count != 0)
                {
                    results.Add(queue.Dequeue());
                }

                return results;
            }
        }        
    }
}

3. テストを実行して結果を確認します。

まとめ

LUIS ダイアログを使うと簡単に LUIS を利用できることを紹介しました。次回は応用編として、これまでの開発に影響をあまり与えない他の使い方を紹介します。コードはチェックしておいてください。ただしテストは完成していないのでテストは失敗するでしょう。