今日も窓辺でプログラム

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

Microsoft Bot Framework と LUIS で複数の状態を持ったBotを作ってみる (完結編)

この記事について

以前投稿していたMicrosoft Bot Framework シリーズの完結編です。期間も空きましたし、その間にSDKのバージョンが上がったりLUISが日本語対応したりといろいろあったので、今までやってきたことの復習も含めて書いていきます。
Microsoft Bot FrameworkやLUISが何をするものかは知っている前提で話を進めていくので、知らない方はシリーズ初回の記事を先に読んでいただくと良いかもしれません。
Microsoft Bot Frameworkで簡単なBotを作ってみる (1) ~Microsoft Bot Frameworkとは?~ - 今日も窓辺でプログラム


以前の記事たちはこちら。
Microsoft Bot Frameworkで簡単なBotを作ってみる (1) ~Microsoft Bot Frameworkとは?~ - 今日も窓辺でプログラム
Microsoft Bot Frameworkで簡単なBotを作ってみる (2) ~1つのフレーズだけに答えてくれるBot~ - 今日も窓辺でプログラム
Microsoft Bot Frameworkで簡単なBotを作ってみる (3) ~Dialogを使った簡単な対話~ - 今日も窓辺でプログラム
Microsoft Bot Frameworkで簡単なBotを作ってみる (4) ~FormFlowを使った対話~ - 今日も窓辺でプログラム

どんなBotを作るか?

各電力会社の電力使用状況を教えてくれる、電力使用状況Botを作成します。データは、今までと同様Yahoo!さんのAPIを使用して持ってきます。APIの使用方法などはシリーズ第2回の記事に詳しく書いてあります。
震災関連情報:電力使用状況API - Yahoo!デベロッパーネットワーク

また、せっかくLUISが日本語対応したので、そのパワーを見るためにもう一つシナリオを追加してみます。同じYahoo!さんのAPIに東日本大震災 写真保存プロジェクト内の写真を検索できる機能があります。このAPIを使って写真検索という全く別のシナリオをBotに追加して、LUISのパワーを確認してみましょう。
震災関連情報:写真保存プロジェクト 写真検索API - Yahoo!デベロッパーネットワーク

具体的には、次のようなシナリオに対応させます。

  1. 「東京電力の電力使用状況は?」のように電力会社と指定して使用状況を聞くと、現在の電力使用状況を教えてくれる
  2. 「電力使用状況は?」と電力会社を指定しないで聞くと、どの電力会社の情報を知りたいかを会話で確認してくれる
  3. 「避難所の写真みせて」と写真を見せてくれるようお願いすると、キーワードに関連した写真を表示してくれる
  4. 「写真みせて」とキーワードを指定しないで写真を見せるようお願いした場合は、キーワードを確認したうえで写真を表示してくれる
  5. それ以外の話題を話しかけると「てへぺろ」する

※実はこの記事を準備している途中、2016年9月30日をもって電力使用状況APIの提供が終了してしまいました。記事を公開している時点では既に電力使用状況を聞く部分は数値が取得できない状況になっているのですが、もうコードを書いてしまったのでこのままいきます。写真の部分は記事公開時点でも問題なく動きます。

環境準備

この記事は、Microsoft.Bot.Builder v3.2.1の情報に基づいて作成しています。

開発環境の準備は、第2回記事、もしくは下記公式ドキュメント(英語)を参照してください。
Getting started with the Connector | Bot Builder SDK C# Reference Library | Bot Framework

デザイン

Botのステート

Botには以下の3つのステートを持たせます。

  1. デフォルト、会話の区切りがついている状態 (Default)
  2. 電力会社確認中 (RequestCompany)
  3. 写真のキーワード確認中 (RequestPhotoKeyword)

RequestCompanyステートは2つ目のシナリオ、RequestPhotoKeywordステートは4つ目のシナリオで使用します。

LUISの使い方

LUISは、Defaultステートの時に受け取ったクエリの意図を分類するのに使用します。今回の場合だと、クエリをElectricity, Photo, Noneの3つの意図(intent)に分類します。
ElectricityとPhotoの意図の場合は、それぞれ電力会社(Company)とキーワード(PhotoKeyword)というエンティティも同時に取得します。これらのエンティティが空だった場合に、ステートをRequestCompanyまたはRequestPhotoKeywordに切り替えて、必要な情報を明示的にユーザに催促します。

LUISでモデルを訓練する

新しいモデルの作成

LUISのMy ApplicationsページからNew App -> New Applicationとクリックして新しいモデルを作成します。名前は任意でいいですが、今回は日本語のbotを作るので、一番下の"Choose Application Culture"は Japanese を選択します。
f:id:kanohk:20161004180203p:plain

少し待つとモデルが新しく作成され、次のような画面が表示されると思います。
f:id:kanohk:20161004180429p:plain

意図とエンティティの追加

左側の画面にIntentsというメニューから意図を追加することができます。Noneはデフォルトで設定されているので、ここにElectricityとPhotoという意図を追加します。下の画像のように意図と例文を追加して、Saveをクリックします。
f:id:kanohk:20161004180609p:plain

Intentsの下のEntitiesというメニューから、エンティティーを追加することができます。ここからCompanyとPhotoKeywordエンティティを設定します。

設定後の画面は次のようになります。

f:id:kanohk:20161004180837p:plain

クエリのアノテーション

モデルを訓練するためには、自分の手でいくつかの文をアノテーションしてあげる必要があります。
画面中央のNew utterancesをクリックし、その下のテキストボックスに文を入力します。
すると、画像のように文にアノテーションする画面が出てくるので、正しい意図とエンティティを設定したあと、Submitを押します。
f:id:kanohk:20161004180945p:plain

SearchやSuggestなどのタブは、チャットのログをもとにアノテーションするときに使用します(後述)。

いくつか例文をアノテーションすると、画面左下にこのようにトレーニングが完了した旨が表示されるかと思います。これが表示されない場合は、この画像のTrainという部分をクリックするとトレーニングが始まります。
f:id:kanohk:20161004181959p:plain

トレーニングが終わったら、左のメニューの Publish -> Publish web serviceとクリックしてモデルを公開します。

LUISの結果を使ってシナリオ1, 3, 5を実装する

LuisDialog の定義

では、実際に実装していきます。環境準備やデバッグ方法は以前の記事で簡単に解説しています。

では、まずはLUISの結果が出たあとに結果が渡されるクラスの定義をします。
LUISの結果の処理は、LuisDialogというクラスを継承することで簡単に行うことができます。例えば、次のコードを見てください。

    [LuisModel("YourModelID", "YourSubscriptionKey")]
    [Serializable]
    public class ElectricityDialog : LuisDialog<object>
    {
        [LuisIntent("")]
        public async Task None(IDialogContext context, LuisResult result)
        {
            string message = "None";
            await context.PostAsync(message);
            context.Wait(MessageReceived);
        }

        [LuisIntent("Electricity")]
        public async Task Electricity(IDialogContext context, LuisResult result)
        {
            string message = "電気";
            await context.PostAsync(message);
            context.Wait(MessageReceived);
        }

        [LuisIntent("Photo")]
        public async Task Photo(IDialogContext context, LuisResult result)
        {
            string message = "写真";
            await context.PostAsync(message);
            context.Wait(MessageReceived);
        }

        public ElectricityDialog()
        {
        }
        public ElectricityDialog(ILuisService service)
            : base(service)
        {
        }
    }

まず1行目、[LuisModel("YourModelID", "YourSubscriptionKey")]という箇所で使用するLUISのIDを指定します。
ここで使用するModelIDとSubscriptionKeyは、LUISでアノテーションをする画面の左メニューのPublishを押したら出てくる画面に表示されるURLから知ることができます。
f:id:kanohk:20161005002701p:plain

https://api.projectoxford.ai/luis/v1/application?id=ModelID&subscription-key=SubscriptionKey
という形でIDとKeyが確認できます。

LUISがNoneという意図を返した時は、None関数が呼ばれます。ElectricityとPhotoに関しても同様です。
さらに意図を追加した場合も、[LuisIntent("hogehoge")]という一文を関数の前に着けてあげると、LUISがhogehogeという意図を返した場合にその関数が呼ばれるようになります。

プロジェクトのテンプレートに入っているMessageController.csのPost()関数を次のように変更すると、先ほど定義したElectricityDialogが呼び出せるようになります。

        public async Task<HttpResponseMessage> Post([FromBody]Activity activity)
        {
            if (activity.Type == ActivityTypes.Message)
            {
                await Conversation.SendAsync(activity, () => new ElectricityDialog());
            }
            else
            {
                HandleSystemMessage(activity);
            }
            var response = Request.CreateResponse(HttpStatusCode.OK);
            return response;
        }

この状態で実際にEmulatorを使用してテストしてみます。
f:id:kanohk:20161005003356p:plain

ちゃんと、私の入力(右側)に応じて電力や写真といった異なる返答をしてくれていることがわかるかと思います。
あとは、「電力」とだけ返すところを、実際の数値を取ってくるように変更してあげます。引数のLuisResultから検出されたエンティティ(例えば「東京電力」)を引っ張ってきて、APIに渡す文字列(例えば「tokyo」)に変換して、APIをたたきます。

コードはこんな感じです。ElectricityUsageAPIの定義についてはGitHubのコードを見ていただければと思います。

        [LuisIntent("Electricity")]
        public async Task Electricity(IDialogContext context, LuisResult result)
        {
            string area = null;
            foreach (EntityRecommendation entity in result.Entities)
            {
                if (entity.Type == "Company")
                {
                    area = ElectricityUsageAPI.GetAreaFromCompanyEntity(entity.Entity.Replace(" ", ""));
                }
            }

            string message;
            if (area != null)
            {
                var usage = ElectricityUsageAPI.GetElectricityUsage(area);
                message = (usage != null) ? string.Format("{0}kWです。", usage) : "取得できませんでした。。";
            }
            else
            {
                message = "電力会社が見つかりませんでした";
            }
            await context.PostAsync(message);
            context.Wait(MessageReceived);
        }

写真の表示

写真を表示するシナリオでは、写真検索APIから写真のURLを取得し、その写真をユーザーに送信します。写真の送信方法は下記ページにドキュメントがあります。
Attachments, Cards and Actions | Bot Builder SDK C# Reference Library | Bot Framework

コードはこんな感じになります。Photoという意図の場合は、PhotoKeywordエンティティを取得し、写真検索APIで検索をかけ、得られたURLをIMessageActivityのAttachmentsに追加してあげます。

[LuisIntent("Photo")]
public async Task Photo(IDialogContext context, LuisResult result)
{
    string keyword = null;
    foreach (EntityRecommendation entity in result.Entities)
    {
        if (entity.Type == "PhotoKeyword")
        {
            keyword = entity.Entity.Replace(" ", "");
        }
    }

    if (keyword != null)
    {
        var uri = PhotoSearchAPI.GetPhotoByKeyword(keyword);

        if (uri == null)
        {
            await context.PostAsync("写真を取得できませんでした。。");
        }
        else
        {
            // この部分が実際に写真を返答に追加する箇所
            // まずはcontextからIMessageActivityを取得する
            var photoMessage = context.MakeMessage();

            // テキストも送る場合はTextに文字列を設定
            photoMessage.Text = keyword + "の写真だよ!";

            // 画像はこのような形式でAttachmentsに追加する
            photoMessage.Attachments.Add(new Attachment()
            {
                ContentType = "image/jpg",
                ContentUrl = uri,
                Name = "photo.jpg"
            });

            // 画像とテキストを設定したメッセージをユーザーに送信
            await context.PostAsync(photoMessage);
        }
    }
    else
    {
        await context.PostAsync("キーワードがわかりませんでした。。");
    }
    context.Wait(MessageReceived);
}

これを実際に動かしてみると、こんな感じになります*1
f:id:kanohk:20161005232329p:plain

Botの状態を管理してシナリオ2, 4を実装する

状態の保持

シナリオ2と4を実装するためには、Botの状態を記憶しておかないといけません。
というわけで、次のようなenumを会話ごとに保持する機能を実装します。

public enum ElectricityBotState
{
    Default,
    RequestCompany,
    RequestPhotoKeyword
};

LuisDialogから状態を保存する

LuisDialogで電力会社の名前や写真のキーワードが見つからない場合は、それぞれRequestCompanyやRequestPhotoKeywordに状態遷移して、足りない情報をユーザーに尋ねます。
そのためには、LuisDialog内からElectricityBotStateを保存してあげる必要があります。

Bot BuilderにはBot State Serviceと呼ばれるBotの状態を操作するための機能が提供されています。
Bot State Service | Documentation | Bot Framework

詳細は上記ページを見ていただくといいのですが、ユーザーごとに状態を保持したり、会話ごとに状態を保持したり、と様々な状況に応じて必要な状態を保持することができます。

今回は会話ごとに状態を保存したいと思います。LuisDialogから会話ごとの状態を変更は、次のように行います。

context.ConversationData.SetValue<ElectricityBotState>("state", ElectricityBotState.RequestCompany);

状態を変更したら、「どの電力会社について知りたいですか?」というような電力会社名を催促する文章をユーザーに送り返答を催促します。
ここまで実装したクラス全体のコードは、Dialog.csで見られます。

MessageControllerから状態を保存・取得する

LuisDialogだけでなくて、MessageControllerのPostの中で状態を取得・保存する必要もあります。
それは次のような形でできます。

public async Task<HttpResponseMessage> Post([FromBody]Activity activity)
{
    if (activity.Type == ActivityTypes.Message)
    {
        // Botの現在の状態を取得
        StateClient stateClient = activity.GetStateClient();
        BotData conversationData = stateClient.BotState.GetConversationData(activity.ChannelId, activity.Conversation.Id);
        var state = conversationData.GetProperty<ElectricityBotState>("state");

        if (state == ElectricityBotState.Default)
        {
            // Default状態の場合はLuisDialogを呼ぶ
            await Conversation.SendAsync(activity, () => new ElectricityDialog());
        }
        else if (state == ElectricityBotState.RequestCompany)
        {
            // RequestCompanyステートの時の処理
            // (省略)

            // 処理が終わったら、BotをDefault状態に戻す
            conversationData.SetProperty<ElectricityBotState>("state", ElectricityBotState.Default);
            await stateClient.BotState.SetConversationDataAsync(activity.ChannelId, activity.Conversation.Id, conversationData);
        }
        else if (state == ElectricityBotState.RequestPhotoKeyword)
        {
            // RequestPhotoKeywordステートの時の処理
        }
    }
    else
    {
        HandleSystemMessage(activity);
    }
    var response = Request.CreateResponse(HttpStatusCode.OK);
    return response;
}

省略が多いので、完全なコードを見たい方は下記のGitHubに上げてあるコードを見てください。
Controllers/MessagesControllers.cs

動作確認

ここまでを実装すると、こんな感じに動きます。ちゃんと、現在の状態を記憶して動きが分岐しているのが確認できます。
f:id:kanohk:20161006004752p:plain

ソースコード

この時点でのソースコードをこちらにアップロードしておきます。
github.com

LuisDialog関連のコードはDialog.cs、現在の状態によって返答を振り分ける部分はControllers/MessagesControllers.cs、Yahoo!のAPIをたたいている部分はUtility.csにまとめてあり、この3ファイルさえ見れば全体像が把握できるはずです。


*1:これって避難所の写真なんですかね?よくわからないですが、APIが返してきているので良しとします。