LINE の Bot 開発 C# 超入門（後編） メッセージの内容と文脈を意識した会話を実現する

LINE の Bot 開発 C# 超入門（後編） メッセージの内容と文脈を意識した会話を実現する

[前編](https://qiita.com/kenakamu/items/e0b194a82e4141b30c95)に続いて Bot を開発していきます。今回は自然言語解析を用いたメッセージの理解と、文脈に応じて対話をおこなうところまでをカバーします。

# 開発の流れ

- 自然言語解析サービスの Language Understanding Intelligent Service (LUIS) を設定し、理解すべきメッセージを理解できるように学習させます。
- Bot が受け取ったメッセージを LUIS と連携してユーザーの意図を判定できるようにします。
- 文脈に応じて対話するための機能を Bot に盛り込みます。

# 手順

## 自然言語解析の設定

LUIS はマイクロソフトの自然言語処理のサービスです。ユーザーが発話した文章が何を意図しているのかを特定するために利用します。また、その文章の中からパラメーターを抽出する機能も備えています。

1 下記 LUIS のサイトにアクセスしてマイクロソフトアカウントでログインします。
[LUIS https://www.luis.ai](https://www.luis.ai)
![Screen Shot 2018-01-20 at 1.15.04 AM.png](https://qiita-image-store.s3.amazonaws.com/0/214116/68844824-b239-63fa-26fc-44d23e0363b0.png)

2 ログインしたら Create new app をクリックし、設定します。Culture は後から変えられないので注意。
    - Name: 任意
    - Culture: Japanese

3 まず Entity を登録します。Entity は文章の中から抜き出したい項目として扱います。左サイドバーから Entities を選択し、Create new entity をクリックします。<br>![Screen Shot 2018-01-20 at 1.29.45 AM.png](https://qiita-image-store.s3.amazonaws.com/0/214116/7cc7df22-6450-9ef7-5a47-63104463423b.png)

4 名前を menu、タイプを Simple で作成します。<br>![Screen Shot 2018-01-20 at 1.36.46 AM.png](https://qiita-image-store.s3.amazonaws.com/0/214116/42cb1ef3-dd5b-afcf-0e45-4db58dddacf5.png)

5 次に Intent を登録します。Intent は文章の意図です。左のサイドバーから Intents を選択し、Create new intent をクリックします。名前を order として作成します。

6 例文 (Utterance) として「松をお願いします。」と入力して Enter を押下します。追加された例文の「松」をクリックして「menu」を選択します。<br>![Screen Shot 2018-01-20 at 1.48.42 AM.png](https://qiita-image-store.s3.amazonaws.com/0/214116/0010b0d9-a247-06b8-f57a-ed86bd23edca.png)

7 松が menu と変更されます。<br>![Screen Shot 2018-01-20 at 1.50.06 AM.png](https://qiita-image-store.s3.amazonaws.com/0/214116/bc9c9002-fa93-ffbc-6a66-14f7ec004e3c.png)

8 同じように例題を合計で 5 つ入れます。<br>![Screen Shot 2018-01-20 at 1.54.03 AM.png](https://qiita-image-store.s3.amazonaws.com/0/214116/b69388b7-6c07-f5a6-6690-ab99871f3d2c.png)

9 次に greeting という名前で Intent を追加します。

10 例文として 5 つ以上挨拶を登録します。<br>![Screen Shot 2018-01-20 at 5.42.30 AM.png](https://qiita-image-store.s3.amazonaws.com/0/214116/34f9ddc5-5a0b-bea1-ad2e-a914b3e6ea4d.png)

11 最後に Train ボタンをクリックして学習させます。<br>![Screen Shot 2018-01-20 at 1.57.08 AM.png](https://qiita-image-store.s3.amazonaws.com/0/214116/2ee8eb1f-aab4-3146-5e26-53e404884eb3.png)

これで LUIS の学習は完了です。以下の手順で呼び出すためのキーとアドレスを取得します。

## LUIS の公開とキーの取得

1 上部メニューより PUBLISH を選択します。<br>![Screen Shot 2018-01-20 at 5.02.40 AM.png](https://qiita-image-store.s3.amazonaws.com/0/214116/e38e11a3-fe75-6c1e-90b8-c5d80db71a2e.png)

2 Publish to production slot をクリックして公開します。![Screen Shot 2018-01-20 at 5.16.46 AM.png](https://qiita-image-store.s3.amazonaws.com/0/214116/32e92d89-65c9-9642-1469-62976634e92e.png)

3 画面下部に出るキーをコピーしておきます。

4 上部メニューより SETTINGS を選択して、画面に表示される Application ID をコピーしておきます。

# Bot に自然言語解析の追加
ユーザーがテキストを送って来た時に解析をできるようにコードを追加します。

1 Visual Studio Code でビューメニューより統合ターミナルを開きます。

2 以下コマンドで LUIS 用のパッケージをインストールします。

```console
dotnet add package microsoft.cognitive.luis
dotnet restore
```

3 LineBotApp.cs コードの上部に以下 using ステートメントを追加します。

```c#
using Microsoft.Cognitive.LUIS;
```

4 コンストラクターより前に LuisClient プロパティを追加します。Application ID、キーを自分のものと差し替えます。

```c#
private LuisClient luisClient = new LuisClient(
            appId: "12819323-19e6-4103-aaa0-352d14fc2cfa", 
            appKey: "4501df87a8944328b3bd07ed8adb6508");
```

5 HandleTextAsync メソッドを以下のコードに変更します。
 
```c#
private async Task HandleTextAsync(string replyToken, string userMessage, string userId)
{  
    var replyMessage = new TextMessage($"You said: {userMessage}");
    // LUIS で文章を解析
    var luisResult = await luisClient.Predict(userMessage);
    if(luisResult.TopScoringIntent.Name == "greeting")
    {
        replyMessage.Text = "どうもどうも"; 
    }
    else if(luisResult.TopScoringIntent.Name == "order")
    {
        // メニューエンティティがある場合
        if(luisResult.Entities.ContainsKey("menu"))
        {
            replyMessage.Text = $"毎度！{luisResult.Entities["menu"].First().Value} ですね！";
        }
        else
        {
            replyMessage.Text = "毎度！注文ですね。何にしましょう？"; 
        }
    }
    
    await messagingClient.ReplyMessageAsync(replyToken, new List<ISendMessage> { replyMessage });
}
```

6 luisResult を取得した後にブレークポイントを置いて、F5 を押下してデバッグを開始します。<br>![Screen Shot 2018-01-20 at 6.38.49 AM.png](https://qiita-image-store.s3.amazonaws.com/0/214116/3e9ac0ec-970c-b9d5-b04e-16e7475ae12b.png)

7 シミュレーターから「どうも」など、挨拶と認識されるはずのテキストを送ります。ブレークポイントがヒットしたら、ウォッチ式などで luisResult の中身を確認し、greeting が意図として認識されていることを確認します。<br>![Screen Shot 2018-01-20 at 6.41.10 AM.png](https://qiita-image-store.s3.amazonaws.com/0/214116/64044782-6f1a-ddbf-7a9a-9cf98dda2c91.png)

8 同様に「松をお願い」と送って、order と、認識されるかと、menu エンティティが取得できているか確認します。<br>![Screen Shot 2018-01-20 at 6.43.39 AM.png](https://qiita-image-store.s3.amazonaws.com/0/214116/c422f10d-9cec-543a-645f-12b6c444389b.png)

# 自然言語解析とフローの改善
一見うまく行っているように見えますが、「注文をおねがい」と送るとどうなるでしょう。この場合「注文」が menu と認識されます。これは LUIS で文章の位置関係などからエンティティを推測しているためです。メニューのように決まった者の場合、LIST を使う方が良いです。

1 LUIS の画面より BUILD を選択し、Entities をクリックします。

2 既存の menu エンティティを選択し Delete Entity をクリックして削除します。

3 Create new entity をクリックして、List タイプで menu を作り直します。<br>![Screen Shot 2018-01-20 at 7.28.38 AM.png](https://qiita-image-store.s3.amazonaws.com/0/214116/bc38df27-2486-4f85-4d29-cd6e58c650bd.png)

4 Values に「松」「竹」「梅」をそれぞれ追加します。類義語を追加することもできます。<br>![Screen Shot 2018-01-20 at 7.31.33 AM.png](https://qiita-image-store.s3.amazonaws.com/0/214116/c47f39be-74de-c76d-4f97-4e8067315b1c.png)

5 Train ボタンをクリックして学習させます。Simple Entity と異なり、List は例文に対して指定する必要はありません。学習が終わったら、PUBLISH メニューより再度パブリッシュします。

6 注文の意図はあるがメニューがない場合の対応をコードに追加します。

```c#
private async Task HandleTextAsync(string replyToken, string userMessage, string userId)
{  
    ISendMessage replyMessage = new TextMessage("");
    // LUIS で文章を解析
    var luisResult = await luisClient.Predict(userMessage);
    if(luisResult.TopScoringIntent.Name == "greeting")
    {
        replyMessage = new TextMessage("どうもどうも"); 
    }
    else if(luisResult.TopScoringIntent.Name == "order")
    {
        // メニューエンティティがある場合
        if(luisResult.Entities.ContainsKey("menu"))
        {
            replyMessage = new TextMessage($"毎度！{luisResult.Entities["menu"].First().Value} ですね！");
        }
        else
        {
            // 注文だがメニューがわからない場合はメニューをボタンで提示
            replyMessage = new TemplateMessage("menu", new ButtonsTemplate(
                title: "注文",
                text: "毎度！注文ですね。何にしましょう？",
                actions: new List<ITemplateAction>(){
                    new MessageTemplateAction("松","松"),
                    new MessageTemplateAction("竹","竹"),
                    new MessageTemplateAction("梅","梅"),
                    }));
        }
    }
    
    await messagingClient.ReplyMessageAsync(replyToken, new List<ISendMessage> { replyMessage });
}
```

7 再度デバッグを実行して、シミュレーターから「注文をおねがい」と送ります。ボタンテンプレートが返ってくることを確認します。<br>![Screen Shot 2018-01-20 at 7.55.33 AM.png](https://qiita-image-store.s3.amazonaws.com/0/214116/a195bbe5-3398-8be1-769b-1a5d73d5e6bc.png)

8 これで注文を受ける箇所はうまくいきそうです。次に出前先の住所を確認します。ここで重要な点はすでに聞いた注文を忘れないように保存しておくことです。テンプレートは EventSourceState というデータ構造をストレージに格納できるようになっているのでこれを拡張します。まず注文と出前先を格納するクラスを追加します。Models フォルダに Order.cs を追加します。

```c#:Order.cs
using Microsoft.WindowsAzure.Storage.Table;

namespace sushibot.Models
{
    public class Order : EventSourceState
    {
        public string Menu { get; set; }
        public string Location_Address { get; set; }
        public string Location_Title { get; set; }

        public Order() 
        {
            SourceType = "order";
        }
    }
}
```

9 テーブルストレージが Order を使うように Controllers¥LineBotControllers.cs の Post メソッドを変更します。

```c#:Controllers¥LineBotController.cs-Postメソッド
public async Task<IActionResult> Post([FromBody]JToken req)
{ 
    var events = WebhookEventParser.Parse(req.ToString());
    var connectionString = appsettings.LineSettings.StorageConnectionString;
    var blobStorage = await BlobStorage.CreateAsync(connectionString, "linebotcontainer");
    var orderState = await TableStorage<Order>.CreateAsync(connectionString, "eventsourcestate");
    var app = new LineBotApp(lineMessagingClient, orderState, blobStorage);
    await app.RunAsync(events);
    return new OkResult();
}
```

10 LineBotApp.cs を以下のコードに差し替えます。主な変更は以下の通りです。
- Order を使うようにプロパティとコンストラクターの変更
- 位置情報をハンドルするように OnMessageAsync での処理と HandleLocationAsync の追加。
- HandleTextAsync メソッド内で Order の保存や、位置情報の確認ロジックの追加。
尚、位置情報の確認は地図を利用してもらうように[URL スキーム](https://developers.line.me/ja/docs/messaging-api/using-line-url-scheme/#opening-the-location-screen)を利用しています。
* LuisClient に渡す AppId と AppKey はそれぞれ自身のものに変更してください。

```c#:LineBotApp.cs
using Line.Messaging;
using Line.Messaging.Webhooks;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using sushibot.CloudStorage;
using sushibot.Models;
using Microsoft.Cognitive.LUIS;

namespace sushibot
{
    internal class LineBotApp : WebhookApplication
    {
        private LineMessagingClient messagingClient { get; }
        private TableStorage<Order> orderState { get; }
        private BlobStorage blobStorage { get; }
        private LuisClient luisClient = new LuisClient(
            appId: "12819323-19e6-4103-aaa0-352d14fc2cfa",
            appKey: "4501df87a8944328b3bd07ed8adb6508");

        public LineBotApp(LineMessagingClient lineMessagingClient, TableStorage<Order> tableStorage, BlobStorage blobStorage)
        {
            this.messagingClient = lineMessagingClient;
            this.orderState = tableStorage;
            this.blobStorage = blobStorage;
        }

        protected override async Task OnMessageAsync(MessageEvent ev)
        {
            switch (ev.Message.Type)
            {
                case EventMessageType.Text:
                    await HandleTextAsync(ev.ReplyToken, ((TextEventMessage)ev.Message).Text, ev.Source.UserId);
                    break;
                case EventMessageType.Location:
                    var location = ((LocationEventMessage)ev.Message);
                    await HandleLocationAsync(ev.ReplyToken, location, ev.Source.Id);
                    break;
            }
        }

        private async Task HandleLocationAsync(string replyToken, LocationEventMessage location, string userId)
        {
            // Order ステートを取得
            var order = await orderState.FindAsync("order", userId);
            // 住所を設定後保存
            order.Location_Address = location.Address;
            order.Location_Title = location.Title;
            await orderState.UpdateAsync(order);
            await messagingClient.ReplyMessageAsync(replyToken, new[] {
                        new TextMessage($"あいよ！{order.Menu}を{order.Location_Title}にだね！")
                    });
        }

        private async Task HandleTextAsync(string replyToken, string userMessage, string userId)
        {
            ISendMessage replyMessage = new TextMessage("");

            // LUIS で文章を解析
            var luisResult = await luisClient.Predict(userMessage);
            if (luisResult.TopScoringIntent.Name == "greeting")
            {
                replyMessage = new TextMessage("どうもどうも");
            }
            else if (luisResult.TopScoringIntent.Name == "order")
            {
                // メニューエンティティがある場合
                if (luisResult.Entities.ContainsKey("menu"))
                {
                    // メニューを取得して、Order を Azure テーブルに保存
                    var menu = luisResult.Entities["menu"].First().Value;
                    var order = new Order(){ Menu = menu, SourceId = userId };
                    await orderState.UpdateAsync(order);
                    // 住所の確認に地図を起動するスキームを送信
                    replyMessage = new TemplateMessage("location", new ButtonsTemplate(
                        title: "住所の送信",
                        text: $"毎度！{menu}ですね。どちらにお送りしましょう？",
                        actions: new List<ITemplateAction>(){
                            new UriTemplateAction("住所を送る","line://nv/location")
                            }));
                }
                else
                {
                    // 注文だがメニューがわからない場合はメニューをボタンで提示
                    replyMessage = new TemplateMessage("menu", new ButtonsTemplate(
                        title: "注文",
                        text: "毎度！注文ですね。何にしましょう？",
                        actions: new List<ITemplateAction>(){
                            new MessageTemplateAction("松","松"),
                            new MessageTemplateAction("竹","竹"),
                            new MessageTemplateAction("梅","梅"),
                            }));
                }
            }

            await messagingClient.ReplyMessageAsync(replyToken, new List<ISendMessage> { replyMessage });
        }
    }
}
```

# クラウドへのデプロイとテスト
最後に最新のコードをデプロイしてテストしてみましょう。

1 以下のコマンドで変更をコミットして、デプロイをします。

```
git add .
git commit -m update
git push
```

2 プッシュが完了したらモバイル端末で LINE を起動します。まず挨拶と出前依頼を送ってみます。<br>![Screen Shot 2018-01-20 at 9.58.30 AM.png](https://qiita-image-store.s3.amazonaws.com/0/214116/aa320e16-61fb-00eb-4dba-2cd5ce3558a2.png)

3 メニューを選ぶと住所を聞かれます。<br>![Screen Shot 2018-01-20 at 10.00.08 AM.png](https://qiita-image-store.s3.amazonaws.com/0/214116/9e67dacd-9eba-a70d-d123-308d8e291523.png)

4 住所を送るボタンをクリックすると地図が開きます。任意の住所を送ります。<br>![Screen Shot 2018-01-20 at 10.01.41 AM.png](https://qiita-image-store.s3.amazonaws.com/0/214116/7b6dd08a-fa4d-f3ad-8dc8-e8a7aca3aca4.png)

5 注文と住所が正しく認識されることを確認します。<br>![Screen Shot 2018-01-20 at 10.02.18 AM.png](https://qiita-image-store.s3.amazonaws.com/0/214116/eff3bdf0-7311-1acf-bd97-af9e462579b0.png)

# まとめ

今回は Bot が自然言語からユーザーが求めていることを理解し、文脈も加味しながら対話できるまでを実装しました。

この後はいろいろなメッセージに対して適切に反応できるように理解力とスキルを広げていく、精度をあげていくということが求められると思います。Bot 開発はまだまだ確立した開発手法というものは存在せず、開発者の腕にかかっている分野で、そこがまた魅力的なところですね。

# 付録

LINEのBot開発にあたり有用なライブラリ・ツールをご紹介しておきます。

- [Messaging API - 公式SDK](https://developers.line.me/ja/docs/messaging-api/line-bot-sdk/)
- [Messaging API - Community SDK - C#](https://github.com/pierre3/LineMessagingApi)
- [LINE Simulator for BOT developer](https://www.npmjs.com/package/line-simulator)
- [LINE Rich Menus Manager](https://www.npmjs.com/package/line-richmenus-manager)
- [LINE Bot Template](https://www.npmjs.com/package/generator-line-bot)
- [bot-express](https://github.com/nkjm/bot-express)