AIが入ったBotの作り方を学ぼう - Part5 自然言語解析でメッセージの意図を解釈しBotのスキルを高める


> この記事はやや古くなっています。下記の新しいチュートリアルがベターです。
http://qiita.com/nkjm/items/38808bbc97d6927837cd

# 概要

この記事はLINEで動作する栄養士Botを開発するチュートリアルのPart5です。

前回までの記事はこちら。

- Part1 http://qiita.com/nkjm/items/0e9d24b2f3429bd33c8d
- Part2 http://qiita.com/nkjm/items/daa4e34b26ef937446c6
- Part3 http://qiita.com/nkjm/items/27d0131003a4b7ef02b9
- Part4 http://qiita.com/nkjm/items/d46bd91e1784adf1434b

今回はapi.aiというサービスを利用して自然言語からユーザーの意図を解釈し、食品の栄養価を調べる以外にも様々な適切な処理をおこなうようにBotのスキルを高める実装をおこなっていきます。

自然言語から発話者の意図を特定する、というプロセスは自然言語解析においては極めて一般的な考え方で、様々なアプリケーションにおいて処理の初期フェーズで実行したいと思うプロセスだと思います。

自然言語は一つの目的をあらわすのに様々なバリエーションがあります。例えば「お薦めの食事を教えて欲しい」という意思表示をおこなう場合、下記のような表現が考えられます。

- お薦めを教えてください。
- お薦めは何？
- このお店のイチオシは？

人間はこういう表記ゆれに対応するのは得意で、さして有能な店員でなくても顧客から上記のような要望を受ければお薦めの食事を提示することができるでしょう。ですが、条件式や正規表現といった手法を基本とするプログラムは極めて融通がききません。よってプログラムにとって上記のような3つの表現が同じ意図を表していることを特定するのは難しい。

しかしながら今回のように自然言語で対話するBotを開発する場合、この意図を解釈する機能なしにはBotは有能なアシスタントにはなれません。ここで登場するのがDeep Learningと呼ばれる機械学習手法を採用した自然言語解析です。この機能を加えることで、プログラムを融通の利くヤツにすることができます。

# 所要時間

40分

# Botに自然言語処理を加える

## api.aiのアカウントを作成する

api.aiは前述の「表記ゆれ」を吸収するサービスと考えることができます。先ほどのお薦めの食事を知りたい場合の例で言えば、下記3つの文章をapi.aiの処理にかけるとすべて「意図はお薦めの食事だ」と返してくれます。

- お薦めを教えてください。
- お薦めは何？
- このお店のイチオシは？

ということでまずは無償アカウントを作成します。下記のサイトにアクセスし、必要情報を入力してサインアップを完了してください。

https://console.api.ai/api-client/#/signup
![apiai_signup.png](https://qiita-image-store.s3.amazonaws.com/0/26079/d7e4b071-07e8-cc21-6c02-95299474dede.png "apiai_signup.png")


サインアップが完了したらすぐにコンソールに遷移するはずです。（素晴らしいユーザービリティですね・・）
![apiai_getting_started.png](https://qiita-image-store.s3.amazonaws.com/0/26079/7b197dd9-b384-d316-c668-1352c1e2debf.png "apiai_getting_started.png")

## Agentを作成する

まずAgentを作成します。これは一つのアプリケーション、と捉えて問題ないでしょう。今回は栄養士Botを作っていますので、「栄養士」というAgentを作成します。画面上部のCreate Agentという青いボタンをクリックします。
![apiai_getting_started.png](https://qiita-image-store.s3.amazonaws.com/0/26079/f09d02a9-5257-c970-b88e-924de97ab327.png "apiai_getting_started.png")

下記のように必要項目を入力してSaveボタンをクリックします。

- Agent name: 栄養士
- AGENT TYPE: Public
- DESCRIPTION: LINEで動作する栄養士Botのエージェント
- LANGUAGE: 日本語
- DEFAULT TIME ZONE: JST

![create_agent_form.png](https://qiita-image-store.s3.amazonaws.com/0/26079/edadfee7-4ae4-2059-bad6-c621850882ba.png "create_agent_form.png")


## Intent（意図）を登録する

エージェントが作成され、次にIntent（意図）の管理画面に遷移します。
![intent.png](https://qiita-image-store.s3.amazonaws.com/0/26079/239e63a1-82b8-68c4-c072-4876d55a3137.png "intent.png")


ここでBotに理解させたい意図を順次登録していきます。Create Intentという青いボタンをクリックしてください。

Intentの登録では次の3つを入力します。

- Intent name => この意図の名前です。
- User says => この意図を表現する例文。
- Action => プログラムに返される文字列。意図を端的にあらわす文字列を設定します。
 
今回は「お薦めの食事を教えて」という意図を登録します。

- Intent name: **お薦めの食事**
- User says: 
	- **お薦めの食事を教えてください。**
	- **お薦めを教えて。**
	- **お薦めは？**
	- **イチオシは？**
	- **何を食べたらいいですか？**

- Action: **recommendation**

これでSaveボタンをクリックします。するとapi.aiが例文を学習していきます。

## テストと学習

さて、ここで実際に自然言語でapi.aiが今登録した意図を解釈してくれるかどうかテストしてみましょう。右サイドバーのTry it nowに文章を入力してEnterするとテストできます。

まずは例文そのままの文章を一つ。

**「お薦めを教えて。」**
![try_recommendation_easy.png](https://qiita-image-store.s3.amazonaws.com/0/26079/47d020f6-e5ea-9191-e67c-240bd743318c.png "try_recommendation_easy.png")


INTENTのところに「お薦めの食事」と出ています。これは文章を解釈し、意図を特定できたことを意味しています。成功ですね。（例文そのままなので当然ですが）

次に例文とわずかに異なる文面でテストしてみます。

**「お薦めを教えてください。」**
![try_recommendation_moderate.png](https://qiita-image-store.s3.amazonaws.com/0/26079/f7252b18-b9e1-9f19-657d-dcbadaa9bafc.png "try_recommendation_moderate.png")


これも成功ですね。次。

**「おすすめのメシ、教えてくれ」**
![try_recommendation_hard.png](https://qiita-image-store.s3.amazonaws.com/0/26079/3678f772-0fd8-5e8b-8809-d80adf593854.png "try_recommendation_hard.png")


残念ながら失敗です。当然こういうこともあります。重要なのはこういう文章を次々に例文登録し、どんどん学習させていくことです。左サイドメニューからTrainingを開くと、これまでの解釈の履歴が出てきます。
![training.png](https://qiita-image-store.s3.amazonaws.com/0/26079/0a73617d-8c49-c2c3-cddc-967ef6a38be2.png "training.png")


ここで理解するべき文章が理解できていなければ、その文章を例文として登録していけばよいわけです。

そんな感じでテストと学習を繰り返し、精度を高めていきます。今回は一旦ここで終了し、次にBotからapi.aiにリクエストを送信する部分を実装していきます。

## api.aiのSDKをインストールする

幸いにもapi.aiにnode.jsからアクセスするためのSDKがNPMモジュールとして提供されています。これをインストールします。

また、あわせてもう2つほどモジュールを追加します。一つはnode-uuid。これはapi.aiへのリクエスト発行に際し、一意なセッションIDを付加する必要があるため、このセッションIDを生成するためのモジュールです。もう一つはbluebird。こちらはECMA6のネイティブなPromiseを拡張したPromiseの仕組みを提供するモジュールです。

このbluebirdが提供するPromiseではPromiseチェーンのキャンセルが可能になります。これは結構便利で、今回も必要になるのでインストールします。ちなみに、ミドルウェア設定でPromiseチェーンのキャンセルを有効にする設定を入れています。

```
$ npm install apiai node-uuid bluebird --save
```

例によってindex.jsを編集してモジュールをインポートします。また、下図の画面でClient Access Tokenを取得し、これを定数で設定します。
![client_access_token.png](https://qiita-image-store.s3.amazonaws.com/0/26079/d9e6a8a8-ac95-9630-01e3-bfd377d0d60b.png "client_access_token.png")


```javascript:/index.js
const APIAI_CLIENT_ACCESS_TOKEN = 'あなたのClient Access Token';

// -----------------------------------------------------------------------------
// モジュールのインポート
var express = require('express');
var bodyParser = require('body-parser');
var mecab = require('mecabaas-client');
var shokuhin = require('shokuhin-db');
var memory = require('memory-cache');
var apiai = require('apiai'); // 追加
var uuid = require('node-uuid'); // 追加
var Promise = require('bluebird'); // 追加
var dietitian = require('./dietitian');
var app = express();

// -----------------------------------------------------------------------------
// ミドルウェア設定

// リクエストのbodyをJSONとしてパースし、req.bodyからそのデータにアクセス可能にします。
app.use(bodyParser.json());

// 追加：Promiseチェーンのキャンセルを有効にします。
Promise.config({
    cancellation: true
});
```

## お薦めの食事を回答する

まずお薦めの食事を訊かれた際に回答する機能を追加しておきましょう。かなり手抜きですが、下記のような回答メソッドをdietitian.jsに追加します。

```javascript:/dietitian.js
static replyRecommendation(replyToken){
    var headers = {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + LINE_CHANNEL_ACCESS_TOKEN
    }
    var body = {
        replyToken: replyToken,
        messages: [{
            type: 'text',
            text: 'カレーライスでもどうですか？'
        }]
    }
    var url = 'https://api.line.me/v2/bot/message/reply';
    request({
        url: url,
        method: 'POST',
        headers: headers,
        body: body,
        json: true
    });
}
```

お薦めと言いつつ常にカレーを提案というポンコツな仕組みですが、今回はとりあえずこんな感じでとどめておきます。本来的にはユーザーの栄養摂取状況を鑑みて食事を提案するような処理になるでしょう。

## api.aiでメッセージを処理する

index.jsでWebhookのメッセージイベントのハンドラーを編集し、まずapi.aiでメッセージの意図を特定するように変更します。そしてもしユーザーがお薦めの食事を訊いていたら、それを回答する。そうでなければデフォルトの挙動としてメッセージは食事の報告だと仮定し、形態素解析処理へ進みます。

```javascript:/index.js
app.post('/webhook', function(req, res, next){
    res.status(200).end();
    for (var event of req.body.events){
        if (event.type == 'message' && event.message.text){

            /*
             * api.aiでメッセージを処理。レスポンスとしてgotIntentというPromiseを返すように実装。
             */
            var aiInstance = apiai(APIAI_CLIENT_ACCESS_TOKEN, {language:'ja'});
            var aiRequest = aiInstance.textRequest(event.message.text, {sessionId: uuid.v1()});
            var gotIntent = new Promise(function(resolve, reject){
                aiRequest.on('response', function(response){
                    resolve(response);
                });
                aiRequest.end();
            });

            /*
             * api.aiからレスポンスが帰ってきたらこの処理を開始。
             */
            var main = gotIntent.then(
                function(response){
                    console.log(response.result.action);
                    switch (response.result.action) {
                        case 'recommendation':
                            // 意図は「お薦めの食事」だと特定。お薦めの食事を回答します。
                            dietitian.replyRecommendation(event.replyToken);

                            // ここで処理は終了
                            main.cancel();
                            break;
                        default:
                            // 意図が特定されなかった場合は食事の報告だと仮定して形態素解析処理へ移る。
                            return mecab.parse(event.message.text);
                            break;
                    }
                }
            ).then(
                function(response){
                    var foodList = [];
                    for (var elem of response){
                        if (elem.length > 2 && elem[1] == '名詞'){
                            foodList.push(elem);
                        }
                    }
                    var gotAllNutrition = [];
                    if (foodList.length > 0){
                        for (var food of foodList){
                            gotAllNutrition.push(shokuhin.getNutrition(food[0]));
                        }
                        return Promise.all(gotAllNutrition);
                    }
                }
            ).then(
                function(responseList){
                    var botMemory = {
                        confirmedFoodList: [],
                        toConfirmFoodList: [],
                        confirmingFood: null
                    }
                    for (var nutritionList of responseList){
                        if (nutritionList.length == 0){
                            // 少なくとも今回の食品DBでは食品と判断されなかったのでスキップ。
                            continue;
                        } else if (nutritionList.length == 1){
                            // 該当する食品が一つだけ見つかったのでこれで確定した食品リストに入れる。
                            botMemory.confirmedFoodList.push(nutritionList[0]);
                        } else if (nutritionList.length > 1){
                            // 複数の該当食品が見つかったのでユーザーに確認するリストに入れる。
                            botMemory.toConfirmFoodList.push(nutritionList);
                        }
                    }

                    /*
                     * もし確認事項がなければ、合計カロリーを返信して終了。
                     * もし確認すべき食品があれば、質問して現在までの状態を記憶に保存。
                     */
                    if (botMemory.toConfirmFoodList.length == 0 && botMemory.confirmedFoodList.length > 0){
                        // 確認事項はないので、確定した食品のカロリーの合計を返信して終了。
                        dietitian.replyTotalCalorie(event.replyToken, botMemory.confirmedFoodList);
                    } else if (botMemory.toConfirmFoodList.length > 0){
                        // どの食品が正しいか確認する。
                        dietitian.askWhichFood(event.replyToken, botMemory.toConfirmFoodList[0]);

                        // 質問した食品は確認中のリストに入れ、質問リストからは削除。
                        botMemory.confirmingFood = botMemory.toConfirmFoodList[0];
                        botMemory.toConfirmFoodList.splice(0, 1);

                        // Botの記憶に保存
                        memory.put(event.source.userId, botMemory);
                    }
                }
            );
        } else if (event.type == 'postback'){
            // リクエストからデータを抽出。
            var answeredFood = JSON.parse(event.postback.data);

            // 記憶を取り出す。
            var botMemory = memory.get(event.source.userId);

            // 回答された食品を確定リストに追加
            botMemory.confirmedFoodList.push(answeredFood);

            /*
             * もし確認事項がなければ、合計カロリーを返信して終了。
             * もし確認すべき食品があれば、質問して現在までの状態を記憶に保存。
             */
            if (botMemory.toConfirmFoodList.length == 0 && botMemory.confirmedFoodList.length > 0){
                // 確認事項はないので、確定した食品のカロリーの合計を返信して終了。
                dietitian.replyTotalCalorie(event.replyToken, botMemory.confirmedFoodList);
            } else if (botMemory.toConfirmFoodList.length > 0){
                // どの食品が正しいか確認する。
                dietitian.askWhichFood(event.replyToken, botMemory.toConfirmFoodList[0]);

                // 質問した食品は確認中のリストに入れ、質問リストからは削除。
                botMemory.confirmingFood = botMemory.toConfirmFoodList[0];
                botMemory.toConfirmFoodList.splice(0, 1);

                // Botの記憶に保存
                memory.put(event.source.userId, botMemory);
            }
        }
    }
});
```

更新したコードをHerokuにデプロイしてLINEからメッセージを送ってテストしてみましょう。

```
$ git add . && git commit -m "added recommendation" && git push heroku master
```

![スクリーンショット 2016-11-30 14.37.14.png](https://qiita-image-store.s3.amazonaws.com/0/26079/b5c00ad5-5e33-b344-dee9-d96c3e0f9635.png "スクリーンショット 2016-11-30 14.37.14.png")

調子は良いようです。こんな形でapi.aiでIntentを追加し、Intentに応じたハンドラーをBot本体に実装していくことでBotのスキルを高めていくことができます。

最終的なコードをGithubに公開しておきました。よければ参考にご覧ください。 

https://github.com/nkjm/qiita-dietitian

*＊アクセストークン等、固有の情報は環境変数にオフロードしています。*

# まとめ

Bot本体のコードは複雑です。条件分岐が迷路のようでともすれば簡単にスパゲッティーコードになってしまいます。ここに絶対の解は持ち合わせていませんが、開発者の腕のみせどころかな、と思っています。同時に、BDDなどのテストを重要視した開発スタイルがますます欠かせないものになっていくと感じます。

また、会話の文脈をどのように保存しておくか、処理の完結に必要な情報をどのように収集・管理するかが肝要ですね。こういった課題をいち早く整理して有用なBotを開発していきたいですね。