Google Home と Lambda で「"あ"のつくポケモンなーんだ」

この記事は Livesense - 自 Advent Calendar 2017 20日目の記事です。

# 前置き

## 娘のクイズ
私には5歳の娘がいるのですが、ときどきポケモンクイズを出してくれます。
「"あ"の付くポケモンなーんだ」と聞かれて、
「アーボック」と答えると、「残念、アンノーンでした」とか
「アンノーン」と答えると、「残念、アリアドスでした」と
気分次第で答えが変わる難解なクイズです。

## Google Home Mini がやってきたぞ
そんな我が家に、Google Home miniがやってきました。

さっそく、娘のおもちゃになったのですが、なんと質問すれば良いのか分からないので、ひたすら「明日の天気は？」と聞いています。

さすがに連続で明日の天気を聞かせられるのが辛い（もとい娘にもっと楽しく質問させたい）のと、理不尽なポケモンクイズを改善するために、Google Home Miniに「◯のつくポケモンは？」と聞いたらポケモンを教えてくれる仕組みを作ることにしました。


# 構成
今回は、DialogflowからAWS Lambdaで作成したAPIを呼ぶ形で下記のような構成を考えました。

![AWS Icons.png](https://qiita-image-store.s3.amazonaws.com/0/105402/246f35a9-61ee-dbea-d0fb-151c2c3026f3.png)


# Dialogflow
まず、Dialogflowの設定を行います。
今回は、画面の`Intents`、`Entities`、`Fulfillment`を使用しました。

![Dialogflow.jpg](https://qiita-image-store.s3.amazonaws.com/0/105402/257d6d54-9f5e-4579-cdd2-9328b362a6dd.jpeg)


## Entities
Entitiesを使用すると、同じ意味を持つ単語、例えば、「犬」でも「イヌ」でもどちらも認識するように設定することができます。
今回は、「◯のつく〜」の一文字目を抽出したいので、単語ではないのですが、ひらがなでもカタカナでも引っかかるようにしたかったので使用しました。

![Dialogflow.jpg](https://qiita-image-store.s3.amazonaws.com/0/105402/1fe68877-54f7-8f66-b8e0-29a6a20947ab.jpeg)

こんな感じで50音セットして、「ア」でも「あ」でも「ア」と認識するようにしました。
（カタカナにしたのは、単純にポケモンがカタカナで後の処理が楽だったからです）

## Intents
続いて、Intentsの設定です。
この画面で、実際に話しかける内容を設定します。

### Example mode 
2種類の設定方法がありますが、デフォルトでは`Example mode`になっています。
まずは愚直に「ア のつくポケモンは」と入れてみます。

![Dialogflow.jpg](https://qiita-image-store.s3.amazonaws.com/0/105402/b2860a0d-cf1a-5765-5299-aa4f0fe8cd7a.jpeg)

色のついたところが単語認識したところですが、なんということでしょう、`Entity`が一文字なのでほぼ全て認識してしまいました。

不要な箇所はクリックで消していけるのですが、かなり面倒なのと、このままだと50パターン入れないといけないのでもう一つの`Template mode`を使います。


### Template mode
`Template mode`に切り替えるには、同じ入力フォームの端にある、ダブルクォートをクリックします。

![Dialogflow.jpg](https://qiita-image-store.s3.amazonaws.com/0/105402/925a93a6-7171-940c-ffc3-de6df4904122.jpeg)

`@`に切り替わればOKです。

![Dialogflow.jpg](https://qiita-image-store.s3.amazonaws.com/0/105402/efd2419b-c1df-f3bd-60fa-82a474b0e89f.jpeg)

`Template mode`だと、`Entity`に該当する箇所を、動的に埋め込むことができます。
`@`を入れると補完されます。今回は`Entity`を`kana`という名前にしたので下記のようになりました。

![Dialogflow.jpg](https://qiita-image-store.s3.amazonaws.com/0/105402/40234b0f-9632-b69b-bc1a-7c75e508f99b.jpeg)

先ほどと違って、頭文字だけが認識されました。
また、この`Entity`で50音全てカバーできるので、あとは考えられるパターンの文章を入力していけばOKです。

最終的に下記のような文章を登録しました。

![Dialogflow.jpg](https://qiita-image-store.s3.amazonaws.com/0/105402/8e30473e-095b-1547-e1f1-57013a0ae0e3.jpeg)


## Fulfillment
`Fulfillment`を使用すると`Webhook`の設定から外部のAPIを呼び出すことができます。
今回は、`Lambda`で作ったAPIを呼びたいので、そのURLと必要なヘッダ情報を設定しています。

![Dialogflow.jpg](https://qiita-image-store.s3.amazonaws.com/0/105402/0aaae9e5-3a7e-2cbe-1a46-5081a26fa7b2.jpeg)


## テスト
ここまでできると、右カラムにあるフォームから実際に文章を投げて、どのように文章解析されるか、また実際にやり取りされる`JSON`を確認することができます。

![Dialogflow.jpg](https://qiita-image-store.s3.amazonaws.com/0/105402/c46832d5-9b90-9c2a-bf81-03ec64e6427f.jpeg)

下部にある、`kana`が認識された`Entity`です。
「あ のつくポケモンは」とひらがなで聞きましたが、`Entities`の設定通りちゃんとカタカナに置き換わっています。

いったんここまでで、`Dialogflow`の設定は終わりです。

ポケモンを探せるようになるまでもう一息です。


# Lambda
次に、実際にポケモンを探して返すAPIを`Lambda`関数を使って実装します。
`Lambda`関数を新規作成して、`API Gateway`を下記の図のように配置します。
※今回`API Gateway`の詳細は割愛します。

![Lambda_Management_Console.png](https://qiita-image-store.s3.amazonaws.com/0/105402/ddf9342f-b657-e0a2-f1da-8ec3759af2f8.png)

今回は、`Lambda`関数を`Node.js`で実装しました。


## APIとしてのレスポンス
まず、`Dialogflow`とか関係なしに、APIとしてレスポンスを返せるようにします。
`Lambda`関数を新規作成した直後は、下記のようになっています。

```js
exports.handler = (event, context, callback) => {
    // TODO implement
    callback(null, 'Hello from Lambda');
};
```

ただ、このままだと、`API Gateway`でエラーになってしまうので、適切なレスポンスを返すように変更します。
フォーマットはこの辺りを参考にしました。
- http://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html

```js
callback(null, {
  "statusCode": 200, 
  "body": json_data
})
```

これで`API Gateway`に適切にレスポンスが返せるようになります。


## Dialogflowへのレスポンス
続いて、`Dialogflow`に対してのレスポンスですが、こちらはAPIの返却値で、`body`に含まれる`JSON`のデータがそれにあたります。

こちらもフォーマットがあるのでそちらを参考にします。
- https://developers.google.com/actions/reference/v1/dialogflow-webhook#response

最低限、`speech`と`displayText`というパラメータを返せれば良さそうなので、ここに返したいメッセージ入れています。
以下が、メッセージテキストから、レスポンスに含める`JSON`を作成するメソッドです。

```js
const build_callback_data = (message) => {
  const json = {
    speech: message,
    displayText: message
  }
    
  return JSON.stringify(json)
}

exports.handler = (event, context, callback) => {
  const message = "こんにちは"
  callback(null, {
    "statusCode": 200, 
    "body": build_callback_data(message)
  })
};
```


## 実装の詳細
これで好きなメッセージを返せるようになったので、あとはポケモンを探して返すだけです。

まず、`Dialogflow`から渡されるパラメータに頭文字が入っているので取得します。

パラメータは、`event`という引数の`body`から取得できます。
ただし、`event.body`はJSON文字列なので、一度`parse`を挟む必要があります。
具体的には下記のようになります。

```js
const parameters = JSON.parse(event.body).result.parameters
const kana = parameters && parameters.kana ? parameters.kana : null
```

これで頭文字が取れたので、次に検索をしますが、今回はかなり雑で、同じ`Lambda`関数内に、あらかじめ用意しておいたJSONデータからランダムに取得するという形を取りました。

ここまで書いておいてなんですが、私はポケモンにあんまり詳しくないので、元データは [ポケモンWiki](https://wiki.xn--rckteqa2e.com/wiki/%E3%83%9D%E3%82%B1%E3%83%A2%E3%83%B3%E4%B8%80%E8%A6%A7) から引用させていただきました。

```json
exports = module.exports = {
  "ア":["アイアント","アギルダー","アクジキング",...],
  "イ":["イシズマイ","イシツブテ","イトマル",...],
  "ウ":["ウインディ","ウォーグル","ウソッキー",...],
  "エ":["エアームド","エイパム","エテボース",...],
  "オ":["オオスバメ","オオタチ","オクタン",...],
  //...
}
```

最終的なコードは多少のエラーチェックも加えて、下記のようになりました。

```js
const pokemonList = require("data/pokemon")

const build_callback_data = (message) => {
    const json = {
        speech: message,
        displayText: message
    }
    
    return JSON.stringify(json)
}

exports.handler = (event, context, callback) => {
    const parameters = JSON.parse(event.body).result.parameters
    const kana = parameters && parameters.kana ? parameters.kana : null
    
    if (kana === null || !pokemonList[kana]) {
        const message = "すいません、見つかりませんでした。"
        callback(null, {
            "statusCode": 200, 
            "body": build_callback_data(message)
        })
        return
    }
    
    const suggestPokemonList = pokemonList[kana]
    const suggestPokemon = suggestPokemonList[Math.floor(Math.random() * suggestPokemonList.length)]
    const message = `${suggestPokemon}`
    callback(null, {
        "statusCode": 200, 
        "body": build_callback_data(message)
    })
};
```

# テストをする

これで準備が整ったので、実際にテキストを投げてテストをしてみます。
まず、Dialogflowから投げてみます。

## Dialogflowからテスト

![Dialogflow.png](https://qiita-image-store.s3.amazonaws.com/0/105402/d00e98cb-9e4b-c349-5ad9-ea74d6af9408.png)

実際に投げてみた結果です。
ポケモンの名前が返ってきているのが分かります。
ランダムに返しているので実行するたびに結果は変わります。


## Actions on Googleからテスト
続いて、Actions on GoogleのSimulatorからテストをします。
テキストでも入力できますが、音声でもテストをすることができます。

テスト用アプリにつなぐには、「テスト用アプリにつないで」と言います。
また、Google Home実機でも同じようにテスト用アプリにつなぐことができます。

ちなみに、最初実機でテスト用アプリにつないだら、戻し方が分からなくてちょっと焦りました。
「やめたい」と言うとテストをやめることができます。

![Actions_on_Google.png](https://qiita-image-store.s3.amazonaws.com/0/105402/e2fb536b-49cf-3f93-b670-d14bfcd7e0c9.png)

質問を投げてみます。

![Actions_on_Google.png](https://qiita-image-store.s3.amazonaws.com/0/105402/ee90f89c-e164-a35d-dfe4-de0df5bc7a3a.png)

成功です！ :thumbsup: 

音声でやると下記のように文章解析している様子が見えておもしろいです。

![googlehome2.gif](https://qiita-image-store.s3.amazonaws.com/0/105402/b8ec5c45-7559-50b6-83b5-f6d534b0d200.gif)


## 娘でテスト　
完成したので、Google Home Miniからも試してみました。
私が試すと、感覚的に7〜8割、意識して話すとほぼ確実に返ってくるようになったので、娘に話してみてもらいました。

が、娘はちょっと舌たらずなところがあり、全く認識しない！
「なんでお父さんだけ返事してくれるの :cry:」と悲しい結果となりました。。
「お父さんすごーい」というのを期待していたので残念です(´・ω・`)


# まとめ＆反省点＆改善点
- `Dialogflow`と`Lambda`で、`Google Home Mini`に好きに喋らせることができました。
- `Google Home Mini`なので`Google Cloud Functions`使った方が親和性あるのだろうか？とも思ったけど、`Lambda`でもシンプルに書けると分かって良かったです。
- ある程度、予測変換が入るので、一文字認識させるが難しかったです。
- ポケモンデータを`Lambda`内に持ってしまったので、DynamoDBとかに入れられると良かったです（今回は時間切れ）
- 最初、`Intents`の登録で「あのつくポケモンは？」と最後にクエスチョンを付けていたのですが、これだとテキストでテストしているうちは問題なかったのですが、音声入力した際に`?`がつかないので認識しないというのに少々はまりました。音声で早めにテストをするのがおすすめです。
- 最後に、娘があんまり喜んでくれなかったのが心残りです（お父さんは精進します）。

# 参考
- [ポケモン一覧 - ポケモンWiki](https://wiki.xn--rckteqa2e.com/wiki/%E3%83%9D%E3%82%B1%E3%83%A2%E3%83%B3%E4%B8%80%E8%A6%A7)
- http://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html
- https://developers.google.com/actions/reference/v1/dialogflow-webhook#response
- https://developers.google.com/actions/reference/rest/Shared.Types/AppRequest