前回は Luis 応用編ということでエンティティやフォームフローとの連携を紹介しました。今回は開発中のボットアプリに組み込むところとユニットテストについて見ていきます。
多言語対応
将来的に日本語で datetimeV2 が出れば別ですが、今回は英語版の LUIS を使います。よってユーザーからのメッセージは一旦翻訳しましょう。
翻訳サービス
Cognitive の翻訳サービスを使います。詳細はこちら。ただ、探し方が悪いのか C# SDK がなかった。。
1. Azure ポータルから Translator Text API のキーを取得します。
2. ボットアプリプロジェクトの Services フォルダに ITranslationService.cs を追加し、コードを差し替え。
using System.Threading.Tasks; namespace O365Bot.Services { public interface ITranslationService { TaskTranslate(string content, string from, string to); } }
3. TranslationService.cs を追加し、コードを差し替え。
using System; using System.Configuration; using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; using System.Xml.Linq; namespace O365Bot.Services { public class TranslationService : ITranslationService { private const string translateUri = "https://api.microsofttranslator.com/V2/Http.svc/Translate?text="; private const string authUri = "https://api.cognitive.microsoft.com/sts/v1.0/issueToken"; public async TaskTranslate(string content, string from, string to) { var token = await GetAccessToken(); if (string.IsNullOrEmpty(token)) throw new Exception("failed to auth translate API"); using (HttpClient client = new HttpClient()) { client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); var res = await client.GetAsync($"{translateUri}{Uri.EscapeUriString(content)}&from={from}&to={to}"); if (res.IsSuccessStatusCode) { XDocument xdoc = XDocument.Parse(await res.Content.ReadAsStringAsync()); return (xdoc.FirstNode as XElement).Value; } else return ""; } } /// /// アクセストークンの取得 /// private async TaskGetAccessToken() { using(HttpClient client = new HttpClient()) { client.DefaultRequestHeaders.TryAddWithoutValidation("Ocp-Apim-Subscription-Key", ConfigurationManager.AppSettings["TranslationKey"]); var res = await client.PostAsync(authUri, null); if (res.IsSuccessStatusCode) return await res.Content.ReadAsStringAsync(); else return ""; } } } }
4. Global.asax.cs の Application_Start を以下に差し替え。ITranslationService を追加しています。
protected void Application_Start() { this.RegisterBotModules(); GlobalConfiguration.Configure(WebApiConfig.Register); AuthBot.Models.AuthSettings.Mode = ConfigurationManager.AppSettings["ActiveDirectory.Mode"]; AuthBot.Models.AuthSettings.EndpointUrl = ConfigurationManager.AppSettings["ActiveDirectory.EndpointUrl"]; AuthBot.Models.AuthSettings.Tenant = ConfigurationManager.AppSettings["ActiveDirectory.Tenant"]; AuthBot.Models.AuthSettings.RedirectUrl = ConfigurationManager.AppSettings["ActiveDirectory.RedirectUrl"]; AuthBot.Models.AuthSettings.ClientId = ConfigurationManager.AppSettings["ActiveDirectory.ClientId"]; AuthBot.Models.AuthSettings.ClientSecret = ConfigurationManager.AppSettings["ActiveDirectory.ClientSecret"]; var builder = new ContainerBuilder(); builder.RegisterType().As (); builder.RegisterType ().As (); builder.RegisterType ().As (); Container = builder.Build(); }
LUIS ダイアログでの翻訳
LUIS ダイアログに翻訳した文章を渡したい場合、 GetLuisQueryTextAsync をオーバーライドすることで実現できます。LuisRootDiaog.cs に以下のメソッドを追加します。
////// LUIS にわたるテキストの翻訳 /// protected override async TaskGetLuisQueryTextAsync(IDialogContext context, IMessageActivity message) { using (var scope = WebApiApplication.Container.BeginLifetimeScope()) { ITranslationService service = scope.Resolve (); return await service.Translate(message.Text, message.Locale, "en"); } }
この場合 LUIS に対する処理だけが翻訳され、元の文章は保持されます。
エンティティの解析と処理フローの検討
LUIS により一部のフィールドはエンティティとして取得が可能となります。そのことを踏まえて処理フローも検討の余地が出てきます。エンティティの解析と、処理フローを一旦以下の様に変更してみました。CreateEventDialog.cs を差し替えます。
using Autofac; using Microsoft.Bot.Builder.Dialogs; using Microsoft.Bot.Builder.FormFlow; using Microsoft.Bot.Builder.Luis.Models; using Microsoft.Bot.Connector; 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// このダイアログが完了時に返す型 { 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.datetimerange": case "builtin.datetimeV2.datetime": case "builtin.datetimeV2.duration": case "builtin.datetimeV2.date": case "builtin.datetimeV2.time": foreach (var vals in entity.Resolution.Values) { switch(((JArray)vals).First.SelectToken("type").ToString()) { // 範囲の場合、スタートと期間を処理 case "datetimerange": var start = (DateTime)((JArray)vals).First["start"]; var end = (DateTime)((JArray)vals).Last["end"]; @event.Start = start; @event.Hours = end.Hour - start.Hour; break; case "datetime": case "date": @event.Start = (DateTime)((JArray)vals).Last["value"]; break; case "time": @event.Start =DateTime.Now.Date.Add(((DateTime)((JArray)vals).Last["value"]).TimeOfDay); break; // Duration は秒で返ってくる case "duration": @event.Hours = (int)((JArray)vals).Last["value"] / 60 * 60; break; } } break; } } // 結果から全日イベントか確認 if (@event.Start.Hour == 0 && @event.Hours == 0) @event.IsAllDay = true; @event.Description = (context.Activity as Activity).Text; if (string.IsNullOrEmpty(@event.Subject)) @event.Subject = (context.Activity as Activity).Text; // FormFlow に初期値を渡して実行 var outlookEventFormDialog = new FormDialog (@event, BuildOutlookEventForm, FormOptions.PromptInStart); context.Call(outlookEventFormDialog, this.ResumeAfterDialog); } private async Task ResumeAfterDialog(IDialogContext context, IAwaitable result) { await context.PostAsync(O365BotLabel.Event_Created); // ダイアログの完了を宣言 context.Done(true); } public static IForm BuildOutlookEventForm() { OnCompletionAsyncDelegate processOutlookEventCreate = async (context, state) => { using (var scope = WebApiApplication.Container.BeginLifetimeScope()) { IEventService service = scope.Resolve (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 () .Message(O365BotLabel.Event_Create) .Field(nameof(OutlookEvent.Subject)) .Field(nameof(OutlookEvent.Start), active: (state) => { // Start に値があるかは初期値と比較 if (state.Start == default(DateTime)) return true; else return false; }) .Field(nameof(OutlookEvent.Hours), active: (state) => { // 期間に値があるかは初期値と比較 if (state.Hours == default(double) && !state.IsAllDay) return true; else return false; }) .Field(nameof(OutlookEvent.Description)) .OnCompletion(processOutlookEventCreate) .Build(); } } }
不要なファイルの削除
もう RootDialog.cs は使わないため、削除するか全体をコメントアウトします。テストプロジェクトが RootDialog を参照しているため、ボットアプリだけをビルドして実行します。
エミュレーターでの検証とデバッグ
1. LuisRootDiaog.cs の GetLuisQueryTextAsync メソッドと CreateEventDialog の StartAsync にブレークポイントを置きます。
2. エミュレータで接続して、「金曜日午後7時から、同僚とステーキを食べに行く」と入力して送ります。ブレークしたらテキストを確認。
3. 認証が必要な場合は認証を行って続行。CreateEventDialog に来ても、元の文章が保持されていることを確認。
4. LuisResult では翻訳された文章が渡されていることを確認。
5. 実行してエミュレータに表示される質問を確認。
6. この時点ですでに入っているデータを確認するため、「ステータス」と送信。Subject が取れているため、英語になっていますが、とりあえず進めます。
7. ステーキ食べるのはきっと 3 時間くらいなので、「3」を送信。
8. 作成されたイベントを確認。
テストの作成
ユニットテスト
1. UnitTest1.cs はもう不要なのでコメントアウトしておきます。
2. Mock された LUIS を LUIS ダイアログに渡せるよう、LuisUnitTest1.cs にコンストラクターを追加します。
public LuisRootDialog(params ILuisService[] services) :base(services) { }
3. LuisUnitTest1.cs を以下のコードと差し替えます。基本的には UnitTest1.cs でやっていた事と同じですが、LUIS で Entity を返すテストが入ります。簡略化のため、他のテストはここには入れていませんが、自分で書いてみてください。また datetimeV2 に対応していないため、ちょっと書くのが面倒です。。
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 Newtonsoft.Json.Linq; using O365Bot.Dialogs; using O365Bot.Handlers; using O365Bot.Resources; using O365Bot.Services; using System; using System.Linq; using System.Collections.Generic; using System.Globalization; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; 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(); // 他依存サービスのモック var mockEventService = new Mock (); mockEventService.Setup(x => x.GetEvents()).ReturnsAsync(new List () { 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 (); mockNotificationService.Setup(x => x.SubscribeEventChange()).ReturnsAsync(subscriptionId); mockNotificationService.Setup(x => x.RenewSubscribeEventChange(It.IsAny ())).Returns(Task.FromResult(true)); var builder = new ContainerBuilder(); builder.RegisterInstance(mockEventService.Object).As (); builder.RegisterInstance(mockNotificationService.Object).As (); WebApiApplication.Container = builder.Build(); // テストしたい LUIS ダイアログのインスタンス作成 LuisRootDialog rootDialog = new LuisRootDialog(luis1.Object); // メモリ内で実行できる環境を作成 Func > MakeRoot = () => rootDialog; using (new FiberTestBase.ResolveMoqAssembly(luis1.Object)) using (var container = Build(Options.ResolveDialogFromContainer, luis1.Object)) { var dialogBuilder = new ContainerBuilder(); dialogBuilder .RegisterInstance(rootDialog) .As >(); dialogBuilder.Update(container); // グローバルメッセージ登録 RegisterBotModules(container); // LUIS の結果を GetEvents に指定 SetupLuis (luis1, d => d.GetEvents(null, null, null), 1.0, null); // 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")); } } } [TestMethod] public async Task ShouldCreateAllDayEvent() { // Fakes を使うためにコンテキストを作成 using (ShimsContext.Create()) { // AuthBot の GetAccessToken メソッドを実行した際、dummyToken というトークンが返るよう設定 AuthBot.Fakes.ShimContextExtensions.GetAccessTokenIBotContextString = async (a, e) => { return "dummyToken"; }; // LUIS サービスのモック var luis1 = new Mock (); // 他依存サービスのモック // サービスのモック var mockEventService = new Mock (); mockEventService.Setup(x => x.CreateEvent(It.IsAny ())).Returns(Task.FromResult(true)); var subscriptionId = Guid.NewGuid().ToString(); var mockNotificationService = new Mock (); mockNotificationService.Setup(x => x.SubscribeEventChange()).ReturnsAsync(subscriptionId); mockNotificationService.Setup(x => x.RenewSubscribeEventChange(It.IsAny ())).Returns(Task.FromResult(true)); var mockTranslationService = new Mock (); mockTranslationService.Setup(x => x.Translate(It.IsAny (), It.IsAny (), It.IsAny ())).ReturnsAsync("find sentence"); var builder = new ContainerBuilder(); builder.RegisterInstance(mockEventService.Object).As (); builder.RegisterInstance(mockNotificationService.Object).As (); builder.RegisterInstance(mockTranslationService.Object).As (); WebApiApplication.Container = builder.Build(); // テストしたい LUIS ダイアログのインスタンス作成 LuisRootDialog rootDialog = new LuisRootDialog(luis1.Object); // メモリ内で実行できる環境を作成 Func > MakeRoot = () => rootDialog; using (new FiberTestBase.ResolveMoqAssembly(luis1.Object)) using (var container = Build(Options.ResolveDialogFromContainer, luis1.Object)) { var dialogBuilder = new ContainerBuilder(); dialogBuilder .RegisterInstance(rootDialog) .As >(); dialogBuilder.Update(container); // グローバルメッセージ登録 RegisterBotModules(container); // datetimeV2 の resolution 作成 Dictionary resolution = new Dictionary (); JArray values = new JArray(); Dictionary resolutionData = new Dictionary (); resolutionData.Add("type", "date"); resolutionData.Add("value", DateTime.Now); values.Add(JToken.FromObject(resolutionData)); resolution.Add("values", values); // LUIS の結果を CreateEvent に指定し、Entity を設定 SetupLuis (luis1, d => d.CreateEvent(null, null, null), 1.0, EntityFor("builtin.datetimeV2.date", "", resolution), EntityFor("Calendar.Subject", "dummy subject")); // Bot に送るメッセージを作成 var toBot = DialogTestBase.MakeTestMessage(); // ロケールで日本語を指定 toBot.Locale = locale; toBot.From.Id = Guid.NewGuid().ToString(); toBot.Text = "7月1日に食事に行く"; // メッセージを送信して、結果を受信 var toUser = await GetResponses(container, MakeRoot, toBot); // 結果の検証 Assert.IsTrue(toUser.Last().Text.Equals(O365Bot_Models_OutlookEvent.Hours_promptDefinition_LIST)); toBot.Text = "3"; toUser = await GetResponses(container, MakeRoot, toBot); Assert.IsTrue(toUser.First().Text.Equals(O365BotLabel.Event_Created)); } } } [TestMethod] public async Task ShouldCreateEvent() { // Fakes を使うためにコンテキストを作成 using (ShimsContext.Create()) { // AuthBot の GetAccessToken メソッドを実行した際、dummyToken というトークンが返るよう設定 AuthBot.Fakes.ShimContextExtensions.GetAccessTokenIBotContextString = async (a, e) => { return "dummyToken"; }; // LUIS サービスのモック var luis1 = new Mock (); // 他依存サービスのモック // サービスのモック var mockEventService = new Mock (); mockEventService.Setup(x => x.CreateEvent(It.IsAny ())).Returns(Task.FromResult(true)); var subscriptionId = Guid.NewGuid().ToString(); var mockNotificationService = new Mock (); mockNotificationService.Setup(x => x.SubscribeEventChange()).ReturnsAsync(subscriptionId); mockNotificationService.Setup(x => x.RenewSubscribeEventChange(It.IsAny ())).Returns(Task.FromResult(true)); var mockTranslationService = new Mock (); mockTranslationService.Setup(x => x.Translate(It.IsAny (), It.IsAny (), It.IsAny ())).ReturnsAsync("find sentence"); var builder = new ContainerBuilder(); builder.RegisterInstance(mockEventService.Object).As (); builder.RegisterInstance(mockNotificationService.Object).As (); builder.RegisterInstance(mockTranslationService.Object).As (); WebApiApplication.Container = builder.Build(); // テストしたい LUIS ダイアログのインスタンス作成 LuisRootDialog rootDialog = new LuisRootDialog(luis1.Object); // メモリ内で実行できる環境を作成 Func > MakeRoot = () => rootDialog; using (new FiberTestBase.ResolveMoqAssembly(luis1.Object)) using (var container = Build(Options.ResolveDialogFromContainer, luis1.Object)) { var dialogBuilder = new ContainerBuilder(); dialogBuilder .RegisterInstance(rootDialog) .As >(); dialogBuilder.Update(container); // グローバルメッセージ登録 RegisterBotModules(container); // datetimeV2 の resolution 作成 Dictionary resolution = new Dictionary (); JArray values = new JArray(); Dictionary resolutionData = new Dictionary (); resolutionData.Add("type", "date"); resolutionData.Add("value", DateTime.Now.Date); values.Add(JToken.FromObject(resolutionData)); resolution.Add("values", values); // LUIS の結果を CreateEvent に指定し、Entity を設定 SetupLuis (luis1, d => d.CreateEvent(null, null, null), 1.0, EntityFor("builtin.datetimeV2.date", "", resolution), EntityFor("Calendar.Subject", "dummy subject")); // Bot に送るメッセージを作成 var toBot = DialogTestBase.MakeTestMessage(); // ロケールで日本語を指定 toBot.Locale = locale; toBot.From.Id = Guid.NewGuid().ToString(); toBot.Text = "7月1日に食事に行く"; // メッセージを送信して、結果を受信 var toUser = await GetResponses(container, MakeRoot, toBot); // 結果の検証 Assert.IsTrue(toUser.Last().Text.Equals(O365BotLabel.Event_Created)); } } } /// /// グローバルメッセージおよびインターセプター登録 /// private void RegisterBotModules(IContainer container) { var builder = new ContainerBuilder(); // グローバルメッセージの処理登録 builder.RegisterModule(new ReflectionSurrogateModule()); builder.RegisterModule(); // インターセプトの登録 builder.RegisterType().AsImplementedInterfaces().InstancePerDependency(); builder.Update(container); } /// /// Bot にメッセージを送って、結果を受信 /// public async TaskGetResponse(IContainer container, Func > 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 (); await task.PostAsync(toBot, CancellationToken.None); } //await Conversation.SendAsync(toBot, makeRoot, CancellationToken.None); return scope.Resolve >().Dequeue(); } } /// /// Bot にメッセージを送って、結果を受信 /// public async Task> GetResponses(IContainer container, Func
> makeRoot, IMessageActivity toBot) { using (var scope = DialogModule.BeginLifetimeScope(container, toBot)) { var results = new List (); DialogModule_MakeRoot.Register(scope, makeRoot); // act: sending the message using (new LocalizedScope(toBot.Locale)) { var task = scope.Resolve (); await task.PostAsync(toBot, CancellationToken.None); } //await Conversation.SendAsync(toBot, makeRoot, CancellationToken.None); var queue= scope.Resolve >(); while(queue.Count != 0) { results.Add(queue.Dequeue()); } return results; } } /// /// プロアクティブ通知でダイアログ差し込みを行い、結果を受信 /// public async Task> Resume(IContainer container, IDialog
ファンクションテスト
ファンクションテストも同様に変更してください。
まとめ
LUIS を使うと一気に高度になった感じがあります。BotBuilder で datatimeV2 に対応してくれると、テストコードもシンプルに書けそうです。
This post first appeared on MSDN Blogs | Get The Latest Information, Insights, Announcements, And News From Microsoft Experts And Developers In The MSDN Blogs., please read the originial post: here