Google Home が料理中にレシピを教えてくれる recipeech を作っている話

速攻で Amazon Echo 購入の応募をし１週間後には招待メールも貰ったが、こちらの手違いで一旦キャンセルとなって以降、再応募したものの一向に連絡がない私です。

Amazon の品薄戦略にはもううんざりだ、と Google Home でスマートホーム化を進めている今日この頃。

そんな中、当初期待していたのに無くて残念だった機能が、**レシピの読み上げ機能**。

# 料理をアシスト

料理って、調べたいのに手が離せない事ナンバーワンじゃないだろうか。

そんなときこそ、スマートスピーカが助けてくれるもんなんじゃないのか。

ということで、料理するときにアシストしてくれるような  Actions on Google を作りたい。

# 動作環境

- Google Home ( 大きい方 )
- Dialigflow
    - 会話の基本的なエンジン
- Cloud Functions for Firebase
    - レシピの保存・取得・フロー制御

# 必要機能

とにかく、目指すのは **料理をしながら、快適にレシピを知れる事**。

以下、Dialigflow のプロジェクトを作りながら進めていく。


## ■ やらせたい事の定義 - Intents

**歌丸**「私がここで〇〇をしますから、皆さんは△△してください。 そしたら私が「□□」と言いますので、そこで何か返してください。はい、Dialigflow さん」みたいなやつ。

ユーザが指示を出す時、"〇〇 を △△ して欲しい" の **『 △△ して欲しい』** を取り出し、それに対応した返答を定義していくのが基本。

この、ユーザの意図を表すのが、Dialigflow では **Intents** にあたる。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/20198/82ebba0f-5fcf-f1d7-4804-43c48d712d75.png)

### Intents: メニューを決める

『今日は **ロールキャベツ** が良いな』

これを、`decide.menu` アクションと認識するよう定義している。
<font color="silver">※ アクションについては後述</font>


### Intents: 今の工程の確認

ロールキャベツを作るために、まず何をするのか。


『何をすれば良い？』
『何するんだっけ？』

これを `explain.now` アクションと認識するように定義する。

### Intents: 次の工程の確認

次に進まなければ完成しない。

『次は何をすれば良い？』

これを `explain.next` アクションと認識するように定義する。

### Intents: 材料の分量

フライパンで野菜を炒めていて、ここで鶏がらスープの素を入れるはずなんだけど、どれくらい入れるんだったか思い出せない。

なんて時に、『**鶏がらスープの素** ってどれくらい？』って聞きたくなる。

これは `ask.ingredients` アクションと認識するよう定義する。

## ■ 認識する必要があるトピック - Enities

ユーザが指示を出す時、"〇〇 を △△ して欲しい" の **『〇〇』** を抽出して変数とすることで、変数を元にした処理が可能となる。

この、認識対象を表すのが、Dialigflow では **Enities** になる。

### Enities: レシピ

まずは、レシピが無いと始まらないので、`decide.menu` でメニューを取得する。

### Enities: 材料

材料も何度も確認することになるものなので、`ask.ingredients` 時に認識する。

## ■ 処理フロー

ここまでで、与えられた動作と変数で、固定の返答を返すことはできる。
しかし、現実の会話は **ステートフル** であり継続性がある。

Dialogflow にも継続性を持たせる機構として、 **Context** というい機能がある。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/20198/295a6944-3884-3816-fd73-6263008a70cd.png)

この画像では、材料の分量確認は、menu という context を持っていないと動作しないということになる。
メニューが分からなければ分量なぞ分からない、と考えれば別に難しいことではない。

詳しくは、以下の記事が参考になる 
[Dialogflowで天気情報を呼び出す](https://qiita.com/ume1126/items/f60335179b1cee25fa2f)

### 実用には限界がある

しかし、この Context で扱えるのは決めたレシピ程度であり、自分が今どの工程にいるのか、のような状態は管理できない。

### 自分で管理する

そこで、状態管理を自分でやってしまう。と言うかそれしかできなかった。

## ■ 処理を外部サービスに任せる

Dialigflow では、Intent や Entities の抽出だけして、処理を外部に委譲する **Fulfillment** という機能がある。

この Fulfillment の凄いところは、**Cloud Functions for Firebase** を扱えるということ。
しかも、動作するサンプルコードをワンクリックで有効化でき、ブラウザ上で編集もでき、そして Deploy もボタン一つでできる。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/20198/9983d028-5b8a-0001-64c6-c20ecfee0906.png)

### 判断を Fulfillment に飛ばす

intents の設定画面には、Fulfillment という項目があり、ここの `Use webhook` を有効にすると、判断を Fulfillment に飛ばして委ねるようになる。
Intents にわざわざ action を定義していたのは、ここで Fulfillment に渡すため。

<img src="https://qiita-image-store.s3.amazonaws.com/0/20198/6e749c9b-399c-d1ce-dfbe-3f65be7c41ef.png" width="400px" />

### Fulfillment の編集に失敗

受け取って処理をする部分を追加していきたいが、firestore を利用するためには firebase-functions と firebase-admin のバージョンが古い。

package.json でバージョン上げて見たが、上手く動かず。
どうやら、Inline Editor では出来ることが限られているらしい。

ので、一旦コードの右上にあるボタンよりソースをダウンロードし、

![image.png](https://qiita-image-store.s3.amazonaws.com/0/20198/f6454830-d079-2711-2dfe-039a154c1f9e.png)

ローカルで展開、通常の firebase と同様の操作で変更していく。

```ps1
PS> firebase login
PS> firebase init functions
PS> cd functions
PS> micro package.json
```

```diff
...
  "dependencies": {
    "actions-on-google": "^1.5.x",
    "apiai": "^4.0.3",
-    "firebase-admin": "^4.2.1",
-    "firebase-functions": "^0.5.7"
+    "firebase-admin": "^5.4.2",
+    "firebase-functions": "^0.7.3"
  }
...
```

```ps1
PS> npm install
PS> cd ..
PS> firebase deploy --only functions
```

で、ようやく Deploy ができる。
ちなみに、以降は Inline Editor は使えないようだ

![image.png](https://qiita-image-store.s3.amazonaws.com/0/20198/26fb1732-78af-c281-6a8a-940fd89afe4b.png)


### 改めて、fulfillment を編集

コードは [ここ](https://github.com/kentork/recipeech) に置いた

以下、ポイントだけ見ていくと、

#### firestore を有効化

```diff:index.js
'use strict';

const functions = require('firebase-functions');
+const admin = require('firebase-admin');
+admin.initializeApp(functions.config().firebase);
+const db = admin.firestore();

...
```

#### セッション管理

状態をマニュアルで管理するために、セッション管理をしている。
セッション ID 自体はリクエストに含まれているのでそれを利用する。

```diff:index.js
...

function processV1Request (request, response) {
  let action = request.body.result.action; // https://dialogflow.com/docs/actions-and-parameters
  let parameters = request.body.result.parameters; // https://dialogflow.com/docs/actions-and-parameters
  let inputContexts = request.body.result.contexts; // https://dialogflow.com/docs/contexts
  let requestSource = (request.body.originalRequest) ? request.body.originalRequest.source : undefined;
+  let sessionId = (request.body.sessionId) ? request.body.sessionId : undefined;

...
```

これを、firestore の `sessions` コレクションに入れる。
古いセッションの削除等は後で考える。

```js:index.js
...

  function getSession (id) {
    return db.collection('sessions').doc(id).get()
      .then(doc => {
        if(doc.exists) {
          return doc.data();
        } else {
          return setSession(id, initSession)
        }
      })
      .catch((err) => {
        console.log('Error getting the session document', err);
      });
  }
  function setSession (id, data) {
    return db.collection('sessions').doc(id).set(data, { merge: true })
      .catch((err) => {
        console.log('Error setting the session document', err);
      });
  }

...
```

#### イベントリスナ登録

アクション毎に、ハンドラを登録していく。
サンプルの `actionHandlers ` に追加していけばいいだけ。

```diff:index.js
...

  const actionHandlers = {
    // The default welcome intent has been matched, welcome the user (https://dialogflow.com/docs/events#default_welcome_intent)
    'input.welcome': () => {
      send(isGoogle, 'ようこそ、レシピーチへ');
    },
+    'decide.menu': () => {
+      db.collection('menu').doc(parameters.menu).get()
+        .then(doc => {
+            if(doc.exists) {
+                getSession(sessionId)
+                  .then(session => {
+                    const data = doc.data()
+                    const ingredients = data.ingredients.map(i => `${i.name}、${i.quantity}。`).join('')
+                    send(isGoogle, `${data.name} ですね。ではこのレシピにしましょう。${data.description}。材料は、${ingredients}です。`);
+                    return setSession(sessionId, Object.assign(session, { menu: data.name, process: 1, recipe: data }))
+                  })
+            } else {
+                send(isGoogle, 'メニューは見つかりませんでした');
+            }
+        })
+        .catch((err) => {
+            console.log('Error getting documents', err);
+        });
+    },
+    'ask.ingredients': () => {
+        getSession(sessionId)
+          .then(session => {
...
```


# 試験してみる

今回は、以下を参考に手で『よだれ鶏』のレシピを firestore に登録した。

[簡単すぎるタレで手軽に！よだれ鶏](https://cookpad.com/recipe/4037795)

## firestore のデータ

● Menu コレクション

本当は、API で何処かから取ってきて入れ込みたいけど、[難しかったので](#) 手で入れている。

```json
{
  "menu" : [
    "よだれ鶏" : {
      "name" : "よだれ鶏",
      "description" : "家にある材料で本格的なよだれ鶏が作れます。",
      "ingredients" : [
        { "name" : "鶏胸肉", "quantity" : "一枚" },
        { "name" : "酒", "quantity" : "大さじ一杯" },
        ...
      ],
      "procedures" : [
        { "id" : 1, "description" : "万能ねぎは小口切りにしておく。生姜とにんにくは、すりおろしておきます。" },
        { "id" : 2, "description" : "鍋に鶏肉がかぶるくらいの水を入れ、火にかける。沸騰したら、もやしを入れて10秒ほど茹でる。" }
        ...
      ]
    }
  ]
}
```

● Session コレクション

```json
{
  "sessions" : [
    "<<session id>>" : {
      "menu" : "よだれ鶏",
      "process" : 1,
      "recipe" : {  << menu のレシピが入る >> }
    }
  ]
}
```

## デモ

Dialogflow は、Web 用のデモを持っているので、まずはそれで試してみる。

<img src="https://qiita-image-store.s3.amazonaws.com/0/20198/1ff8ee3a-3e8b-4ba1-caed-f23041808541.png" width="400px" />

### Let's cooking

![image.png](https://qiita-image-store.s3.amazonaws.com/0/20198/d2d7c5d0-ca88-86d2-f4c9-e751e5d6ae53.png)

![image.png](https://qiita-image-store.s3.amazonaws.com/0/20198/af9589b5-0a1f-e986-b73c-5be7b57a43eb.png)

![image.png](https://qiita-image-store.s3.amazonaws.com/0/20198/b11f91e7-d6bf-bb44-e4ae-72ce7dc6a724.png)

何となくできた。

# Google Home で実際によだれ鶏を作ってみる

残念ながら WIP...


# 課題点

## 料理中他の用途に使えない

会話が始まると、Google Home は会話以外のことができなくなる。
例えば、ボンゴレビアンコを作ろうとして、工程その 1 が『アサリを塩水につけて、30分置く』だったら暫く使えない。

## 並行しての料理ができない

さっきの問題と同じだけど、料理ってひとつずつシーケンシャルにするものじゃなく、幾つかを同時並行するのが普通なので、これだとちょっと使えない。

## スマートフォン・タブレットとの連携が無いと辛い

最初に材料一覧を一気に読み上げられても、そんなの覚えられない。
初めはスマートフォン・タブレットにレシピを表示して、料理中はサポートという切り分けがベストだと思う。

ただその為にはアプリ連携というこれまた辛そうな何かが待っている。

まぁこの辺は、Echo Show や Chrome Cast で各社何とかカバーしようとしているので、いずれは公式に SDK サポートしてくれるでしょう。

しかし、家がスマートになるまでに一体いくらかかるんだと思わなくはない。

## レシピ情報をどうやって集めて登録するか

現実的な問題としてはこれが大きい。
現在、レシピの API を手頃なお値段で公開しているサービス自体が少ない。
楽天 API では、ランキング上位の概要だけでレシピの詳しい内容までは取得できない。

## 既存のレシピサイトの情報は Voice User Interface に最適化されていない

工程が微妙に長くて、聞いてるうちに最初の方を忘れてしまう。
調味料も 5 ~ 6 種類を一気に読み上げられても、分量も不明なのについていけない。
データをスクレイピングするにしても、データそのまま使えるとは思えない。
( というか、どこか API で便利にレシピ使えるようにしてくれないかなぁ。くれないよなぁ。 )

使ってみて初めて分かることではあるけど、まだまだ考えなければいけないことが多い。


# 参考

[Dialogflow入門](https://qiita.com/kenz_firespeed/items/0979ceb05e4e3299f313)