はじめてのGoogle Assistant向けアプリ開発

## はじめに
本記事は、Googleアシスタントの[Interactive Canvas](https://developers.google.com/assistant/interactivecanvas)というフレームワークを使用してみて、
色々覚えることが多くてすぐ忘れそうになるので
記事として残しておこうと記載したものです。

想定は、[Google Nest Hub](https://store.google.com/jp/product/google_nest_hub)で動作するゲームです。

公式ドキュメントは基本全て英語である上に、欲しい情報が全て載っていないこともあったので、実践してみないと分からないことが多くありました。

本記事は実践してみたほんの一部の内容ですが、同じように情報少ないと感じている人に役立てれば幸いです。

## 本記事の内容
今回はこちらの公式のサンプルを例に、説明を付加しながら記載していきます。
https://github.com/actions-on-google/actions-builder-canvas-codelab-nodejs

CodeLabも用意されていますので、興味ある方はそちらを見てやっても良いでしょう。
本記事にも一部重複している内容がございます。
https://codelabs.developers.google.com/codelabs/actions-canvas/

なお、このサンプルは2DフレームワークのPhaserが使用されています。
この部分の構築については本題から逸れますので、本記事では割愛させていただきます。

## アプリの見た目と注意点
開発には、シミュレータが用意されていますので、実機のNest Hubがなくても動作確認は一応できます。

が、下の画像の通り、実機とシミュレータではだいぶ表示が異なることが分かりますので、この辺りは注意が必要です。
![nesthub.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/499855/7d887bad-47a3-5a2b-9394-bb575b166c62.jpeg)
![simulator.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/499855/90be3c24-334f-f0e5-d996-a0eaa61fa377.png)


見た目の部分は、Firebase Hostingにデプロイする形となります。

## 環境構築
ここは公式に分かりやすく記載されているので、簡潔に書きます。
なお、GCPのプロジェクトの作成方法やAPIの有効化などのGCPの基礎知識に関する内容は割愛しています。

1. `node`: 
私の環境は`v12.16.1`でおこなっています。
2. `ライブラリ`: 
`npm install @assistant/conversation`
3. `Firebase CLI`:
`npm install -g firebase-tools`
4. `gactions`コマンドラインツール:
下記リンクから使用しているOSのパッケージをダウンロード

[Actions SDK and Builder quick start guide](https://developers.google.com/assistant/conversational/quickstart)

以下はmacでダウンロード後に行っている処理。

```console
tar -zxvf gactions-sdk_linux.tar.gz
cp ./aog_cli/gactions /usr/local/bin
```

`gactions login`
![gactionslogin.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/499855/7f513d5b-2069-dd37-577b-b5e87164c654.png)


## ファイル構成
今回参考にしているのは[上記](https://github.com/actions-on-google/actions-builder-canvas-codelab-nodejs)の完成しているプロジェクト`complete`というディレクトリ内にあるファイルです。(2020/10/8のソースコード)


```console
├── firebase.json
├── package.json
├── public【Firebaseにデプロイされる部分）】
│   ├── assets
│   │   ├── 見た目に使用されている画像（詳細略）
│   ├── css
│   │   └── main.css
│   ├── index.html
│   └── js
│       ├── action.js
│       ├── log.js
│       ├── main.js
│       └── scene.js
└── sdk【gactionsでデプロイされる部分】
    ├── actions
    │   └── actions.yaml
    ├── custom
    │   ├── global
    │   │   ├── actions.intent.CANCEL.yaml
    │   │   ├── actions.intent.MAIN.yaml
    │   │   ├── actions.intent.NO_INPUT_1.yaml
    │   │   ├── actions.intent.NO_INPUT_2.yaml
    │   │   ├── actions.intent.NO_INPUT_FINAL.yaml
    │   │   ├── actions.intent.NO_MATCH_1.yaml
    │   │   ├── actions.intent.NO_MATCH_2.yaml
    │   │   ├── actions.intent.NO_MATCH_FINAL.yaml
    │   │   └── actions.intent.PLAY_GAME.yaml
    │   ├── intents
    │   │   ├── guess.yaml
    │   │   ├── instructions.yaml
    │   │   ├── play_again.yaml
    │   │   └── start_game.yaml
    │   ├── scenes
    │   │   ├── Game.yaml
    │   │   └── Welcome.yaml
    │   └── types
    │       ├── letter.yaml
    │       └── word.yaml
    ├── manifest.yaml
    ├── settings
    │   └── settings.yaml
    └── webhooks
        ├── ActionsOnGoogleFulfillment
        │   ├── index.js
        │   └── package.json
        └── ActionsOnGoogleFulfillment.yaml
```

## 各種設定と説明
ここからは断片的になりますが、必要な設定をどこでどのように書けば良いかをメモ的にまとめています。

### アプリの表示名と呼び名の設定
`sdk/setting/setting.yaml`

```yaml:setting.yaml

category: GAMES_AND_TRIVIA
defaultLocale: en
localizedSettings:
  displayName: Snow Pal sample
  pronunciation: Snow Pal sample
projectId: <PROJECT_ID>
usesInteractiveCanvas: true
```

アプリの呼び名はこちらで設定します。
projectIdには使用しているGCPのプロジェクトIDを入力して下さい。
defaultLocaleは日本語にしたいならjaでOKです。
ちなみにアプリ名が英語だと、なぜか実機から呼び出せなかったので、私は日本語にして動作確認しています。

### 見た目の部分（FirebaseのURL）を設定
`sdk/custom/global/actions.intent.MAIN.yaml`

```yaml:actions.intent.MAIN.yaml

handler:
  staticPrompt:
    candidates:
    - promptResponse:
        canvas:
          url: https://PROJECT_ID.web.app
transitionToScene: Welcome
```

FirebaseのURLは、こちらのurlの部分に記載します。
アプリが起動すると、初めにこのMAIN.yamlに記載されている設定が実行されます。
[staticPrompt](https://developers.google.com/assistant/actionssdk/reference/rest/Shared.Types/StaticPrompt#staticpromptresponse)には色々設定できますが、ここでのポイントは、
1. canvasで指定したURLが表示され、
2. 次にWelcomeというシーンに移動する

という内容であることを押さえておけば大丈夫かと思います。

### intent（インテント）とは？
[intent](https://developers.google.com/assistant/conversational/intents)は、アプリを声で操作する際のトリガーとなるキーワードを設定する場所、というイメージです。

インテントには、大きく次の2種類あります。

* `システムインテント` : 初めから定義されている標準的な非会話型のインテント。1番初めに呼び出されるMain, キャンセルした時のCancel, 入力がなかったりどのインテントにもマッチしなかったときの挙動、これらが主に定義されています。
NO_INPUTとNO_MATCHが3つずつ存在するのは、3回ダメならアプリを終了するという挙動にするためだそうです。
`sdk/custom/global`にそれらのyamlファイルがあります。

* `ユーザーインテント` : 自分で定義できるカスタムトレーニングフレーズです。詳細は次に記載します。
`sdk/custom/intents`に追加していきます。

#### 単純なインテント
例えば、タイトル画面からゲームを始めるためのキーワードを、このプロジェクトでは以下のように設定しています。

`sdk/custom/intents/start_game.yaml`

```yaml:start_game.yaml

trainingPhrases:
- "yes"
- let's play
- okay
- let's do it
- go for it
- "y"
```

全部Yes的な内容ですよね。

これ、実はGoogleさんの自然言語処理の賢さが発揮出来るところでして、どういうことかというと、ここに<b>提供するフレーズの例が多いほど、インテントが正しく一致する可能性が高くなる、</b>と公式のヘルプに書いてあるんです。

ここに書いた文例集（？）から、言語モデルが生成されて、その言語モデルはアシスタントNLUを強化し、さらに理解する能力を高める、と公式に記載されています。

つまり、完全にこれらのフレーズに一致していなくても、似たような言葉でも反応してくれるようになるということです。
例えば、このリストにはない`Let's Go`でもちゃんと認識してくれます。

#### 複雑なインテント
インテントは変数（parameter）に置き換えることもできます。

以下のファイルは、正解の単語を当てるために設定されたインテントです。

`sdk/custom/intents/guess.yaml`（一部抜粋）

```yaml:guess.yaml

parameters:
- name: letter
  type:
    name: letter
- name: word
  type:
    name: word
trainingPhrases:
- ($letter 'A' auto=true)
- letter ($letter 'Y' auto=true)
- the word is ($word 'home' auto=false)
- what about ($word 'water' auto=false)
- it's ($word 'Google' auto=false)
- I guess its ($word 'red' auto=false)
- I guess it's ($word 'house' auto=false)
- its ($letter 'B' auto=true)
- it is ($letter 'A' auto=true)
```

これは`letter`と`word`の2つのパラメータを設定しています。
typeには、そのパラメータがどのような内容かを定義されたyamlファイル名を指定しています。
typeは`sdk/custom/types/`内にyamlファイルとして保存します。

letterには各種アルファベットが定義されています。

`sdk/custom/types/letter.yaml`（一部抜粋）

```yaml:letter.yaml

synonym:
  entities:
    a:
      synonyms:
      - a
    b:
      synonyms:
      - b
    (中略)
    "y":
      synonyms:
      - "y"
    z:
      synonyms:
      - z
  matchType: EXACT_MATCH
```

wordは自由な単語です。

`sdk/custom/types/word.yaml`

```yaml:word.yaml

freeText: {}
```

インテントには、
```yaml
- ($letter 'A' auto=true)
- it's ($word 'Google' auto=false)
```

`フレーズ例 $変数名 値の例 auto=曖昧さを許容する(True)/しない(False)`
こんな感じの組み合わせで書きます。

もしも上記を

```
- ($letter 'A' auto=false)
```

とauto=falseにしてしまうと、「A」しか受け付けてくれなくなりますが、auto=trueなら設定したtypeの候補A〜Zどれでも受け付けてくれるようになる、
とこう書くと曖昧さの許容の意味が理解できそうでしょうか。

なお、ここで設定したパラメータ名は、後に記載するWebhook（CloudFunction）で以下のように拾うことが出来ます。

```js
app.handle('guess', (conv) => {
  const word = conv.intent.params.word;
  conv.add(`You Said ${word}`);
}
```

上記は、こちらが例えば「眠い」と喋ったら、アプリが「You Said 眠い」と答える、みたいな処理をイメージして書いています。
（実際のソースコードではありません）

#### 良いインテントの設計には
公式の説明より、抜粋して記載しておきます。
* 会話の設計を行う
    * 会話を開始するトリガー文を決める
    * 会話を成立させるための条件を決める
    * レスポンスを決める
* １つの目的につき１つのIntentを作成する
    * 挨拶をするIntent
    * 計算結果を返すIntent
    * 検索して結果を返すIntent

#### インテントにMatchした際の内容をWebhookで拾うには？

公式のサンプルと違う内容で申し訳ないですが、
例えば次のようなtypesの設定でnumberというパラメータを取得したいとします。

```yaml:number.yaml
synonym:
  entities:
    "1":
      synonyms:
      - "1"
      - "いち"
      - "first"
      - "one"
      - "ワン"
    "2":
      synonyms:
      - "2"
      - "に"
      - "second"
      - "two"
      - "ツー"
  matchType: EXACT_MATCH
```

このtypeのインテントにマッチした際に、Webhook上で取得して何か処理したい場合はどうすれば良いでしょう？
答えは、以下のように書けばOKです。

ここで、「ツー」と受け取った時、

```js:index.js
app.handle('hoge',(conv) => {
  let original = conv.intent.params.number.original;
  let resolved = conv.intent.params.number.resolved;

  functions.logger.log(`original = ${original}`); // original = ツー
  functions.logger.log(`resolved = ${resolved}`); // resolved = 2
}
```

originalとresolvedの違いが見てお分かりになるでしょうか？

上記のログはFirebaseのFunctionsのログで見ることが出来ます。
また、シミュレータでも下記のように確認ができます。

```json:シミュレータのログ（interactionMatch）
{
  "interactionMatch": {
    "intentId": "インテント名が表示",
    "nextSceneId": "シーン名が表示",
    "intentParameters": [
      {
        "key": "number",
        "value": {
          "original": "ツー",
          "resolved": "2"
        }
      }
    ]
  },
  "responses": [
    {
      "canvas": {}
    }
  ]
}
```

これで、聞き取った生の言葉を処理するのか、紐付けているentityを使用するのかを選んで設計出来ますね。


### Scenes（シーン）とは
[Scenes](https://developers.google.com/assistant/conversational/scenes)は、イメージするなら場面設定です。
今回参考にしているサンプルは、
* `Welcome`というアプリ起動時のスタート画面の場面
* `Game`というゲーム中の場面
の2つのシーンで構成されています。

昔のRPGで例えるなら、
* タイトル画面
* セーブデータを選択する場面
* フィールドを自由に歩く場面
* 敵に遭遇した時の戦闘場面
などと大まかにシーンが分けられますよね。

そしてそれぞれのシーンに対応した設定をしたいですよね。
そんなイメージがこの<b>Scenes（シーン）</b>であると私は認識しておりますが、正しい文章で覚えたい方は[公式](https://developers.google.com/assistant/conversational/scenes)を読んで下さいね。

#### Scenesの構成
シーンにはライフサイクルというものがあります。
[公式の説明](https://developers.google.com/assistant/conversational/scenes#execution_lifecycle)をコピペしても意味がないので、ざっくりイメージしやすいように記載します。

##### `１．On enter`
[On Enter](https://developers.google.com/assistant/conversational/scenes#on_enter)は、シーンが呼び出されて最初に呼び出されるフェーズ。

例えば`sdk/custom/scenes/Welcome.yaml`を見てみると、最後の2行にこのように書いています。

```yaml:Welcome.yaml

onEnter:
  webhookHandler: greeting
```

これは、シーン名「Welcome」に入ったら、webhookの「greeting」という関数を実行してね、という意味です。

実際にgreetingでは以下のような処理が記載されています。
(説明のために抜粋しています)

`sdk/webhooks/ActionsOnGoogleFulfillment/index.js`

```js:index.js

const RETURNING_GREETINGS = [`Hey, you're back to Snow Pal!`,
  `Welcome back to Snow Pal!`,
  `I'm glad you're back to play!`,
  `Hey there, you made it! Let's play Snow Pal`];

app.handle('greeting', (conv) => {
    conv.add(`<speak>${randomArrayItem(RETURNING_GREETINGS)}</speak>`);
});
```

Welcomeシーンに入ると、ランダムに選択されたセリフを喋ってくれるようになっていることが分かりますね。

##### `２．Conditions`
[Conditions](https://developers.google.com/assistant/conversational/scenes#conditions)は、
条件が整ったのか会話として情報が足りないのかなど状況を判断するフェーズ。

このフェーズを私は利用したことがないのですが、条件によって処理を分岐したいときに使用するものだそうです。
例えば、次に説明するSlotが埋まっていればこの処理をする、というような分岐が出来ると公式には説明されています。
詳しい文法は下記リンクに記載されています。

[Conditions](https://developers.google.com/assistant/conversational/conditions)

##### `３．Slot filling`
[Slot filling](https://developers.google.com/assistant/conversational/scenes#slot_filling)は、ユーザから情報収集する場面で使用します。

このフェーズも私は利用したことないのですが、例えば都道府県・名前・年齢を聞くシーンであれば、その3つの情報を聞いたかどうかといった判定に使えるようです。

設定できる項目を見ると、以下のようになっています。
* `Slot name` : WebhookロジックおよびConditionsで使用される一意のスロット名。
* `Type` : Slotの値に使用する定義
* `This slot is required`: Slotを必須にするかどうか
* `Assign default value to this slot`: デフォルト値を設定するかどうか
* `Customize slot value writeback`:スロットがいっぱいになると、セッションパラメータに書き戻される値がカスタマイズされる

```js

app.handle('hoge', conv => {
 // 全てのスロットに値が入ると"FINAL"になる
 conv.scene.slotFillingStatus
 // 各スロットの値にアクセスするには
 conv.scene.slots['slot_name'].<property_name>
});
```

##### `４．Promts`
[Promts](https://developers.google.com/assistant/conversational/scenes#prompts)は、ユーザへの応答。
文字や画像、カードや音声など様々なコンテンツを設定できます。

例えば単純な音声の応答（Simple responses）を書いている部分。
参考のプロジェクトでは、初めのシーン`Welcome`で以下のように設定されています。

```yaml:Welcome.yaml

- handler:
    staticPrompt:
      candidates:
      - promptResponse:
          canvas:
            sendStateDataToCanvasApp: true
          firstSimple:
            variants:
            - speech: Try guessing a letter in the word, or guess the entire word
                if you think you know what it is.
            - speech: Try guessing a letter in the word, or guess the entire word
                if you're feeling confident!
            - speech: Try guessing a letter in the word or guessing the word.
```

見るところは`firstSimple`の`speech`です。
この場合、設定された3つの文章のうち、1つがランダムに選択されて応答される、という動作になります。

[`Rich responses`](https://developers.google.com/assistant/conversational/prompts-rich)や[`Visual selection responses`](https://developers.google.com/assistant/conversational/prompts-selection)なら、カードや画像イメージ、テーブルの表示が可能です。
今回のサンプルには使用されていませんが、実際にカード形式とリスト形式を用いた画面は次のようになります。

<b>[Basic Card](https://developers.google.com/assistant/conversational/prompts-rich#basic_card)</b>

```yaml:card形式
onEnter:
  staticPrompt:
    candidates:
    - promptResponse:
        content:
          card:
            image:
              url: https://{image's URL}.png
            subtitle: SubTitle
            text: This is the Demo Application
            title: Demo Application
```

下の画像の通り、こちらも実機とシミュレータでは表示や色合いが異なります。。
![nesthub2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/499855/e19f6ee5-c834-5a6e-638b-816f9a127d6e.jpeg)
![simulator2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/499855/cfe20b41-051b-5914-dd19-47971b2b368a.png)


<b>[List](https://developers.google.com/assistant/conversational/prompts-selection#list)</b>

```yaml:list形式

onEnter:
  staticPrompt:
    candidates:
    - promptResponse:
        content:
          list:
            items:
            - description: 1問目
              image:
                url: https://{image's URL}/sample01.png
              title: "1"
            - description: 2問目
              image:
                url: https://{image's URL}/sample02.png
              title: "2"
            - description: 3問目
              image:
                url: https://{image's URL}/sample03.png
              title: "3"
            - description: 4問目
              image:
                url: https://{image's URL}/sample04.png
              title: "4"
            title: Quiz
        firstSimple:
          variants:
          - speech: クイズを選択してください。
        suggestions:
        - title: "1"
        - title: "2"
        - title: "3"
        - title: "4"
```

こちらも実機とシミュレータで表示が異なります。
解像度問題はともかく、画像の位置くらいシミュレータも同じように表示してくれても良いのに...と思うのは私だけでしょうか？

![nesthub3.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/499855/5b6928b5-d11d-26ab-d561-ec33c7a9e35b.jpeg)
![simulator3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/499855/017dba22-22de-1a94-43b9-1f16160ba99f.png)


[Promts](https://developers.google.com/assistant/conversational/prompts)

##### `５．Input`

最後に[Input](https://developers.google.com/assistant/conversational/scenes#input)

ユーザが何かしら声や画面へのタッチで入力するフェーズです。
ここで各Intentに合致したのか、Slotに合致したのか、そもそも入力がない場合など、様々な入力によって動作が分岐するフェーズですね。


#### シーンを遷移するには

シーンを遷移する方法は、大きく2つに分かれます。
1. yamlファイルで設定
2. Webhookの中に記載

例えば、SecondSceneという名前のシーンに遷移する場合、
yamlに書く場合は、次のように記載します。

```yaml
transitionToScene: SecondScene
```

Webhookに記載する場合は、例えばhogeというhandler内に次のように書きます。

```js
app.handle('hoge', (conv) => {
  conv.scene.next.name = 'SecondScene';
}
```

### WebHookを増やしていく上で注意点

色々Webhookを増やしたときに躓きやすい点と感じた点です。
それは、増やしたらそのWebhook名を下記のファイルに追記して置かなければ動作しないという点です。

動作しないというより、Deploy出来ません。

`sdk/webhooks/ActionsOnGoogleFulfillment.yaml`

```yaml:ActionsOnGoogleFulfillment.yaml

handlers:
- name: hoge
- name: fuga
inlineCloudFunction:
  executeFunction: ActionsOnGoogleFulfillment

```

このように、handlersに<b>name: {Webhook名}</b>を追加しておく必要があります。
結構忘れそうになる部分なので、1つ増やすたびに追記しておくのが望ましいでしょう。

## おわりに

ざっと粗削りでまとめてしまいましたが、以上が簡単ではありますがGoogleアシスタントでアプリを作る上での基本的な知識でした。

公式に色々書いてあっても、実機とシミュレータの見た目の違いまでは記載されていないので、知りたい方には少しでもお役に立てたのではないかと思います。

さらに詳しく良い情報をご存じの方がいらっしゃいましたら、コメント等いただけるとありがたいです。