Node.js
Line
bot
AI

AIが入ったBotの作り方を学ぼう - Part4 形態素解析と食品データベースで食品とその栄養価を特定する

More than 1 year has passed since last update.

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

概要

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

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

今回はユーザーからのメッセージを形態素解析にかけ食品らしき単語を抽出し、さらに食品データベースに問い合わせて食品とその栄養価を特定する機能を加えていきます。

所要時間

60分

形態素解析と食品データベースによる食品特定機能を加える

Mecabを利用するためのモジュールをインストールする

Mecabは形態素解析エンジンとして歴史あるオープンソースソフトウェアで、様々な辞書をプラグインして機能を強化することができます。通常はMecabを利用するためにはMecab本体と辞書をコンパイルしてインストールする必要がありますが、今回のようにプログラムがPaaSで稼働している場合、必ずしもこのようなカスタマイズができない可能性があります。

厳密にはHerokuにはBuildpackという実行環境のカスタマイズ方法が提供されており、他のPaaSでも同様の方法が用意されていることが少なくありませんが、今回利用するmecab-ipadic-NEologdという辞書はコンパイルに結構なメモリを必要とし、ストレージ容量も消費するので追加が難しいという背景があります。

したがって今回はMecabの形態素解析機能をクラウドサービスとして利用することにします。具体的にはユーザーから受け取ったメッセージをこのMecabクラウドサービスに送信し、そのレスポンスとして形態素解析された結果がJSON形式で返されるという形です。

このMecabクラウドサービスをNodeアプリから簡単にアクセス可能にするNPMモジュールとしてmecabaas-clientを利用します。
スクリーンショット 2016-11-29 10.42.34.png

まずnpmでmecabaas-clientをインストールします。

$ npm install mecabaas-client --save

次にindex.jsを編集してこのモジュールをインポートします。

/index.js
// -----------------------------------------------------------------------------
// モジュールのインポート
var express = require('express');
var bodyParser = require('body-parser');
var request = require('request');
var mecab = require('mecabaas-client'); // 追加
var app = express();

これでMecabのクラウドサービスにアクセスする準備ができました。

Mecabのクラウドサービスはデモ目的で稼働させており、本番環境で使っていただくことは想定していません。本番環境で利用する場合はMecab, mecab-ipadic-NEologd, mecabaasなどを活用し別途環境構築されることをお薦めします。

メッセージをMecabで形態素解析にかける

引き続きindex.jsを編集して受け取ったメッセージをmecabaas-clientを利用して形態素解析します。

/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){
             // Mecabクラウドサービスでメッセージを解析
            mecab.parse(event.message.text)
            .then(
                function(response){
                      // 解析結果を出力
                    console.log(response);
                }
            );

        }
    }
});

mecab.parse()メソッドは引数のテキストを非同期で形態素解析してくれます。Javascriptではこういった非同期の処理を順次実施する場合にコールバック関数がよく用いられますが、mecab.parse()はPromiseと呼ばれる機構を採用しています。

非同期処理を複数逐次実行する場合、コールバックを使っているとどんどんネストが深くなり、コードの可読性が低下します。また、後に遭遇しますが、ループ処理で複数の非同期処理を同時実行する場合に実装が難しくなります。ここでPromiseが役立ちます。

Promiseを使うと「これが終わったら、これ。その次これ。その次これ。」と処理をどんどんつなげていくことができ、ネストにならないのでコードも読み焼くなります。また、前述の非同期処理の複数同時実行も簡単に実装するすべが用意されています。

mecab.parse()は実行結果としてPromiseオブジェクトを返し、このオブジェクトはthen()というメソッドを備えています。then()はその名の通り、前の処理が終わったらコレ、という形で前後の処理をつなげてくれます。現時点ではmecab.parse()が終わったら、console.log()を実行というだけですが、今後この処理が追加されてくると重宝することになります。

特にBotのようなプログラムは複数のクラウドサービスとAPIで通信しながら処理を進めるようなケースが多分に想定されますので使い方を覚えておいて損はないでしょう。

さて、更新したコードをHerokuにデプロイし、ログを眺めておきます。

$ git add . && git commit -m "added mecabaas-client" && git push heroku master
$ heroku logs --tail

LINEでBotに「ハンバーグを食べました。」と報告してみます。
スクリーンショット 2016-11-29 10.58.19.png

するとログ上に次のような解析結果が出力されるはずです。

2016-11-29T01:55:41.452271+00:00 app[web.1]: [ [ 'ハンバーグ', '名詞', '一般', '*', '*', '*', '*', 'ハンバーグ', 'ハンバーグ', 'ハンバーグ' ],
2016-11-29T01:55:41.454859+00:00 app[web.1]:   [ 'を', '助詞', '格助詞', '一般', '*', '*', '*', 'を', 'ヲ', 'ヲ' ],
2016-11-29T01:55:41.454861+00:00 app[web.1]:   [ '食べ', '動詞', '自立', '*', '*', '一段', '連用形', '食べる', 'タベ', 'タベ' ],
2016-11-29T01:55:41.454862+00:00 app[web.1]:   [ 'まし', '助動詞', '*', '*', '*', '特殊・マス', '連用形', 'ます', 'マシ', 'マシ' ],
2016-11-29T01:55:41.454863+00:00 app[web.1]:   [ 'た', '助動詞', '*', '*', '*', '特殊・タ', '基本形', 'た', 'タ', 'タ' ],
2016-11-29T01:55:41.454865+00:00 app[web.1]:   [ '。', '記号', '句点', '*', '*', '*', '*', '。', '。', '。' ] ]

食品を特定する

解析された形態素が配列で返ってきていますが、この中で食品は配列の最初の要素であるハンバーグ、ですよね。

[ 'ハンバーグ', '名詞', '一般', '*', '*', '*', '*', 'ハンバーグ', 'ハンバーグ', 'ハンバーグ' ]

他の文面になっても言えることですが、食品は名詞なので、品詞によって食品に近しい単語を絞り込むことができそうです。これは配列をループ処理して名詞だけを抽出すればよいので簡単に実装できます。

名詞というだけでは当然食品ではない単語も拾ってしまいますが、この時点では問題ありません。抽出した 食品っぽい 単語を、次のステップとして食品データベースで検索します。この時点で食品とは全く関係ない単語は結果がヒットしないので除外することができます。

食品データベースですが、今回は文部科学省が公開している日本食品標準成分表なるものを利用します。

この日本食品標準成分表には2000個強の食品とその栄養価のデータが収録されており、ExcelまたはPDFファイルとして提供されています。当然このままではプログラムからは利用できないので、このデータを若干整形しつつ、クラウドデータベースに保存してMecabと同様クラウドサービスとしてアクセスできるようにします。

今回はこのデータをすでにクラウドデータベースに保存し、それをNodeアプリから簡単にアクセスできるshokuhin-dbというNPMモジュールが用意されているのでまずはこれを利用してみます。
スクリーンショット 2016-11-29 11.23.11.png

shokuhin-dbをインストールします。

$ npm install shokuhin-db --save

次にindex.jsを編集してこのモジュールをインポートします。

/index.js
// -----------------------------------------------------------------------------
// モジュールのインポート
var express = require('express');
var bodyParser = require('body-parser');
var request = require('request');
var mecab = require('mecabaas-client');
var shokuhin = require('shokuhin-db');
var app = express();

次にindex.jsのWebhookの処理を編集して食品っぽい単語を食品データベースで検索し、栄養価を取得するように実装してみます。

/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){
            mecab.parse(event.message.text)
            .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(response){
                    console.log(response);
                }
            );
        }
    }
});

ここで複数の非同期処理の同時実行が入っています。ループ処理の中で食品データベースに問い合わせを行っている部分がそれです。それぞれのPromiseをgotAllNutritionという配列に収めて、すべての問い合わせが終わったら次の処理に進むようにPromise.all()というメソッドを利用しています。

この更新したコードをHerokuにデプロイし、ログを眺めておきます。

$ git add . && git commit -m "added shokuhin-db" && git push heroku master
$ heroku logs --tail

先ほどと同様、LINEでBotに「ハンバーグを食べました。」と報告してみます。
スクリーンショット 2016-11-29 10.58.19.png

するとログ上に次のような解析結果が出力されるはずです。

2016-11-29T03:38:11.582853+00:00 app[web.1]: [ [ { food_id: 18013,
2016-11-29T03:38:11.582878+00:00 app[web.1]:       food_name: 'ハンバーグ 冷凍   ',
2016-11-29T03:38:11.582879+00:00 app[web.1]:       calorie: 223,
2016-11-29T03:38:11.582879+00:00 app[web.1]:       carb: 12.3,
2016-11-29T03:38:11.582880+00:00 app[web.1]:       protein: 13.3,
2016-11-29T03:38:11.582881+00:00 app[web.1]:       fat: 13.4 } ] ]

ハンバーグの栄養価が取得できていることがわかります。同時にハンバーグは食品であることが確認できました。

しかし注意が必要です。食品データベースは検索した食品名を含むレコードをすべて返してきます。今回はたまたま結果が1レコードだけでしたが、複数の結果が返ってくる可能性があります。

例えば「納豆も食べたよ」とメッセージを送ると、7個ほど食品がひっかかります。こういったケースにはユーザーとさらに対話をおこない、どの食品が最も適切なのか特定する必要があります。

上記の食品データベースを自分で作りたい場合は下記の記事を参考にしていただければ数分で作成できます。
食品の栄養価をAPIで取得できるクラウド・データベースを構築する

ユーザーに確認する

では複数食品が引っかかった場合に、どの食品が最も近しいかユーザーに質問する仕組みを入れてみましょう。

対話をおこなうにあたり、Botはユーザーとの会話を覚えておく必要があります。その記憶機能として今回は簡易的なmemory-cacheを利用することにします。

$ npm install memory-cache --save

例によってindex.jsを編集してこのモジュールをインポートします。

/index.js
// -----------------------------------------------------------------------------
// モジュールのインポート
var express = require('express');
var bodyParser = require('body-parser');
var request = require('request');
var mecab = require('mecabaas-client');
var shokuhin = require('shokuhin-db');
var memory = require('memory-cache'); // 追加
var app = express();

これで記憶が使えるようになりました。
今回憶えておかなければならないのは、下記の3種のデータです。

  • 食べたことが確定した食品のリスト
  • どれを食べたのか確認する食品のリスト
  • 現在ユーザーに確認している食品

このデータを下記のように表現し、ユーザーIDをキーにして記憶に保存することにします。

{
    confirmedFoodList: [ // 食べたことが確定した食品のリスト
        { food_id: 18013, food_name: "ハンバーグ", calorie: 223, carb: 12.3, protein: 13.3, fat: 13.4 }
    ], 
    toConfirmFoodList: [ // どれを食べたのか確認する食品のリスト
        [
            { food_id: 4046, food_name: "だいず [納豆類] 糸引き納豆", calorie: 200, carb: 12.1, protein: 16.5, fat: 10, fiber: 6.7},
            { food_id: 4047, food_name: "だいず [納豆類] 挽きわり納豆", calorie: 194, carb: 10.5, protein: 16.6, fat: 10, fiber: 5.9}
        ]
    ],
    confirmingFood: [] // 現在確認している食品のリスト
}

上記はあくまでもオブジェクトの構造を表しているだけです。そのままソースコードにはいれないで下さい。

食品データベースに問い合わせた結果、候補となる食品が1つだけだった場合にはconfirmedFoodListに保存し、ユーザーに合計カロリーを伝えます。

候補となる食品2つ以上あればtoConfirmFoodListに保存することにします。そしてtoConfirmFoodListにデータがある限り、ユーザーにどの食品が正しいかを質問し、質問中の食品はconfirmingFoodに保存するという形にします。

この考え方でindex.jsのWebhook処理を編集していきますが、このままindex.jsに全部のコードを入れていくとこのソースがどんどん肥大化し、見通しが悪くなっていくのでユーザーに返信する処理はdietitian.jsというファイルを新規作成してこちらに処理をまとめることにします。

/dietitian.js
const LINE_CHANNEL_ACCESS_TOKEN = 'あなたのLINE Channel Access Token';

var request = require('request');

module.exports = class dietitian {
    static replyTotalCalorie(replyToken, foodList){
        var totalCalorie = 0;
        for (var food of foodList){
            totalCalorie += food.calorie;
        }

        var headers = {
            'Content-Type': 'application/json',
            'Authorization': 'Bearer ' + LINE_CHANNEL_ACCESS_TOKEN
        }
        var body = {
            replyToken: replyToken,
            messages: [{
                type: 'text',
                text: 'カロリーは合計' + totalCalorie + 'kcalです!'
            }]
        }
        var url = 'https://api.line.me/v2/bot/message/reply';
        request({
            url: url,
            method: 'POST',
            headers: headers,
            body: body,
            json: true
        });
    }

    static askWhichFood(replyToken, foodList){
        var headers = {
            'Content-Type': 'application/json',
            'Authorization': 'Bearer ' + LINE_CHANNEL_ACCESS_TOKEN
        }
        var body = {
            replyToken: replyToken,
            messages: [{
                type: 'template',
                altText: 'どの食品が最も近いですか?',
                template: {
                    type: 'buttons',
                    text: 'どの食品が最も近いですか?',
                    actions: []
                }
            }]
        }

        // Templateメッセージのactionsに確認すべき食品を追加。
        for (var food of foodList){
            body.messages[0].template.actions.push({
                type: 'postback',
                label: food.food_name,
                data: JSON.stringify(food)
            });

            // 現在Templateメッセージに付加できるactionは4つまでのため、5つ以上の候補はカット。
            if (body.messages[0].template.actions.length == 4){
                break;
            }
        }

        var url = 'https://api.line.me/v2/bot/message/reply';
        request({
            url: url,
            method: 'POST',
            headers: headers,
            body: body,
            json: true
        });
    }
}

上記ファイルには2つの返信処理が含まれています。一つ目のreplyTotalCalorie()は合計カロリーを計算してユーザーに伝える関数です。二つ目のaskWhichFoodはどの食品が正しいかユーザーに尋ねる関数で、Templateメッセージを利用しています。これはいくつかの選択肢の中からユーザーがタップして答えを選択できるタイプのメッセージです。

現在デスクトップ版のLINEクライアントではTemplateメッセージに対応していないようです。

どちらの返信処理もLINEのAPIをコールするため、このファイルの先頭にLINEのChannel Access Tokenを設定しています。

また、この機能を他のファイルから使えるようにdietitianクラスをmodule.exportsに入れています。

次にindex.jsから今作成したdietian.jsを使えるようインポートします。

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

NPMパッケージとおなじようにrequire()でインポートできますが、正式なNPMパッケージではない場合、ファイルへのパスを含める必要がある点に注意してください。

ではindex.jsのWebhookを編集して記憶と返信の機能を盛り込みます。

/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){
            mecab.parse(event.message.text)
            .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){
                        console.log('Going to reply the total calorie.');

                        // 確認事項はないので、確定した食品のカロリーの合計を返信して終了。
                        dietitian.replyTotalCalorie(event.replyToken, botMemory.confirmedFoodList);

                    } else if (botMemory.toConfirmFoodList.length > 0){
                        console.log('Going to ask which food the user had');

                        // どの食品が正しいか確認する。
                        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にデプロイします。

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

Botに「ハンバーグと納豆を食べました。」と報告してみます。すると下図のように食べた食品を絞り込む質問が返ってきます。

IMG_0105.PNG

しかし、ご覧の通りあまりラベルが長いと選択肢を見てもよくわからないですね・・
このあたりはまだまだ改善の余地がありますが、今回はこれで良しとして先に進みます。

Postbackメッセージを処理する

先ほどのメッセージでユーザーが選択肢をタップすると、「Postback」と呼ばれるイベントがWebhookに通知されます。このイベントが発生したときのハンドラーを追加していきます。先ほどまではevent.typeがmessageのイベントのみを扱っていましたが、event.typeがpostbackのイベントを処理するようにします。

/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){
            mecab.parse(event.message.text)
            .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){
                        console.log('Going to reply the total calorie.');

                        // 確認事項はないので、確定した食品のカロリーの合計を返信して終了。
                        dietitian.replyTotalCalorie(event.replyToken, botMemory.confirmedFoodList);

                    } else if (botMemory.toConfirmFoodList.length > 0){
                        console.log('Going to ask which food the user had');

                        // どの食品が正しいか確認する。
                        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){
                console.log('Going to reply the total calorie.');

                // 確認事項はないので、確定した食品のカロリーの合計を返信して終了。
                dietitian.replyTotalCalorie(event.replyToken, botMemory.confirmedFoodList);
            } else if (botMemory.toConfirmFoodList.length > 0){
                console.log('Going to ask which food the user had');

                // どの食品が正しいか確認する。
                dietitian.askWhichFood(event.replyToken, botMemory.toConfirmFoodList[0]);

                // ユーザーに確認している食品は確認中のリストに入れ、確認すべきリストからは削除。
                botMemory.confirmingFood = botMemory.toConfirmFoodList[0];
                botMemory.toConfirmFoodList.splice(0, 1);

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

次のステップ

http://qiita.com/nkjm/items/fe2db6b8c4ee2980e2b4

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