ChatBotを構築する場合に最適な構成（Dialogflow）

この記事は[MDC Advent Calendar 2020 10日目](https://qiita.com/advent-calendar/2020/mdc)の記事です。

# はじめに

やりたかったことはこんなところです。

- 自然言語処理を用いた役に立つChatbotを作りたい
- Botフレームワーク（Dialogflowなど）だけで出来ることは限られているので、CRUD操作や外部APIも組み合わせたい
- LINEでいうところのLIFFなども便利だけど突き詰めるとWebアプリと変わらなくなってしまうとか、スマートスピーカーへの転用の障害になるのでBotに拘りたい
- ある程度は拡張性を犠牲にしても生産性重視で構成を選定したい

この検討結果を活かして実装してみましたので、[続きはこちら](https://qiita.com/bamiyanapp/items/8cf2d8ab7d516639f316)をご覧ください。

# 今回の成果

いつの間にか膨大な文章量になってしまったので先に結論から書いておくと、やりたかったことを実現する最適な構成はこのような着地となりました。

![Chatbot構成図 (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259832/98548deb-9aed-5626-d611-d668c48fc643.png)

今回実装にあたって使ったサービス群はこちら。

- LINE Developers
- Dialogflow
- Cloud Functions
- GoogleAppsScript

以下、この構成に至るまでの努力の過程です。
LINE Developersの設定やDialogflowを使った基礎的な会話の組み立ての実装方法は説明を省略してますので、他の投稿を参照ください。

他、若干違う観点で同じような検討を行なった記事がありましたので[リンク](https://www.isoroot.jp/blog/1503/)を置いておきます。

# 判断ポイント
机上で判断したところについて簡単に纏めてみます。

- Chatインターフェース
LINEが最適と判断しました。
Slackの方が馴染みがありますが、利用者視点で想像すると新しいワークスペースに呼び込むよりもLINEで友達追加する方がユーザビリティが高いと判断しました。
拡張性を求めるとSlack優位ですが、有償ライセンスでないと機能が限られるのでエンタープライズ向きですね。
（そしてSlackのライセンスは結構高い、、、）

- Botフレームワーク
    「○○がしたい」などの呼びかけに対して用意した処理をハンドリングしたり、自然言語特有の表現の揺れを吸収してくれる役割を担います。
    Dialogflowが最適と判断しました。
    - Microsoft：Azure Bot Service
        - Azureの音声認識などAI系サービスの精度は魅力的ですが、以前触った時はGUIがなくコードで書く必要があり取っ付き辛い感じだったのでスルーしました。が、今調べた感じだとWeb完結で作れるようにもなってるようです。
    - Google：Dialogflow
        - Google Homeの普及により、恐らく最も利用されているフレームワークではないかと思います。さらに、LINEだけでなく様々なChatサービスと連携できる機能が用意されており、接続が簡単です。また、GoogleHomeへのアプリ転用を考えても優位です。
    - AWS：Lex
        - Alexaはメジャーですがスマートスピーカー専門でLINEには利用できません。Chatの場合はLexになりますが普及度合いで言えばかなりマイナーな上、AWS特有の使いにくさがイマイチなのでスルーします。
    - IBM：Conversation
        - 昔触ったことがあり好感触でしたが、どうしても上記ラインナップではシェアが劣る以上は使い勝手など優位性は低いと判断してスルーします。強いて言えば、無料枠が大きめでタダで使う事にこだわるなら優位かもしれません。

以上から、LINE＋Dialogflowまでは決まりました。

# バックエンドをどうするか
このあたりからが本題で、そして苦労したところです。
Dialogflowでは、基本的な会話の組み立てはできるものの、例えば天気を調べたかったら天気APIにリクエストを飛ばしたり、過去のデータをストックして再利用したりはできませんので、外部に実装することになります。
Dialogflowで外部サービスの呼び出しはfulfillmentと呼ばれており、呼び出し方は二種類から選べます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259832/f24a995c-4a53-38b5-e873-a096ff1f16ef.png)


- Webhook
自前でエンドポイントを用意し、REST APIとして動かします。
この項目を有効にしてURLを設定すれば、JSON形式のパラメータが詰まったBodyでリクエストがフックされます。
後者のインラインとどちらか一方しか選べないようになっています。

- インラインエディタ
Dialogflow内に開発ツール、実行機能があり、ブラウザ内、Dialogflowのサービス完結で実装できます。
実装を試す中であまりに使いにくく、苦労したのでダメな点を列挙してみます。

# Fulfillmentインラインエディタの問題点

- インラインエディタが狭い
まさかの、たった13行の表示スペースしかなく、広げることもできません。
ちょっと試行錯誤したコードで400行超えてきましたので、しんどいと思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259832/c6fea087-d122-fcac-b1ff-2bac6ef139e2.png)
回避策として、この機能の実態はGCPのCloud Fanctionsで動いており、GCPのエディタから同じものを開くことで見やすくなります。
ランタイムもNode.jsその他色々選べるようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259832/b0376843-46cc-d84a-a664-aa8f7a74d7ea.png)

- GCPのエディタがイマイチ
    - コードチェック機能が弱く、文法エラーがスルーされてる
    - デプロイが遅く、2〜3分くらい待たされる
    - GCPのデプロイ課金で、一定以上やると課金される（今回の数十回は無料枠内で行えた）
    - デプロイでエラーが起きると保存されず、変更前のコードに戻る
    - 文法エラーで結構ミスしており時間を無駄にする
    - ログエクスプローラでログを見るが、Dialogflowから飛んでくるJSONがべたがきで見にくい
    - デプロイ直後、実行してもデプロイ前の古いバージョンが稼働し、2回目で新しいバージョンが動く
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259832/819a9296-a668-d1b2-2437-906d4dd08f8e.png)

一部、ローカル環境で実装すれば生産性上がったかもしれませんが、結局入力となる会話は実装する中でいくつかバリエーション欲しくなると思われ、そうなるとデプロイして流したくなりそうなので試しませんでした。
ということで、インラインエディタでなくWebhookで他の環境に作った方が良さそうだという結論に達しました。


# Fulfillmentの仕組み

どういう選択肢が取りうるのか分析するため、DalogflowのFulfillmentがどんな仕組みで動いているのか、わかったことをまとめてみます。
基本は、JSONでリクエストが投げられ、レスポンスのJSONに従って応答を返す、という流れです。

<details><summary>Fulfillmentから飛んできたJSONリクエスト</summary><div>

```json:json
const a = {
  responseId: "53171802-1fe4-477b-a59c-7b32346edfe1-ce5e18e2",
  queryResult: {
    queryText: "東京",
    action: "city_quiz.city_quiz-yes",
    parameters: { country: "日本", city: "日本" },
    allRequiredParamsPresent: true,
    fulfillmentText: "クイズを始めます。都市名、国名を教えてください。",
    fulfillmentMessages: [
      { text: { text: ["クイズを始めます。都市名、国名を教えてください。"] } },
    ],
    outputContexts: [
      {
        name:
          "projects/nice-limiter-191418/agent/sessions/c0027341-c2c0-3f10-8b33-8c88d8635f89/contexts/city_quiz-yes-followup",
        lifespanCount: 2,
        parameters: {
          country: "日本",
          "country.original": "日本",
          city: "日本",
          "city.original": "東京",
        },
      },

    ・・・・・・・・・・・・

    ],
    intent: {
      name:
        "projects/nice-limiter-191418/agent/intents/f89a95b4-e2b7-4c44-8312-c74c1f5a661f",
      displayName: "city_quiz - yes",
    },
    intentDetectionConfidence: 1,
    languageCode: "ja",
  },
  originalDetectIntentRequest: {
    source: "line",
    payload: {
      data: {
        message: { id: "13130673460714", text: "東京", type: "text" },
        source: { userId: "U72232d47924bf0091d85d57fb9bc4437", type: "user" },
        replyToken: "6db14aab84b14711980ced454ce0ebdf",
        timestamp: "1606878367637",
        type: "message",
      },
    },
  },
  session:
    "projects/nice-limiter-191418/agent/sessions/c0027341-c2c0-3f10-8b33-8c88d8635f89",
};
```
</div></details>

- action
Dialogfloeのactionの項目に設定した値が入ってきます。参考にしたサンプルアプリによると、「.」区切りで親のインテントも含めて表記しておくのが良いとされてるようです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259832/1fcb9202-1001-75f4-053c-a7c2b7eaca2b.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259832/0a9d556f-6e59-7efe-cc0d-523bdb680429.png)

- parameters
エンティティとして取得したパラメータが入っています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259832/d4585c94-a80e-c22d-7695-b6f2edb62842.png)

- replyToken
メッセージに返信する場合に用いるトークンが入っています。
Dialogflowの機能を使って返信する場合はレスポンスに含めて返すことになります。

- originalDetectIntentRequest
このリクエストを飛ばしたメッセージの詳細が入っています。
LINEから、ユーザー：U7223〜が、「東京」というメッセージを送った、ということがわかります。
このユーザーIDはユーザーごとに一意ではない、という点に注意が必要です。
”ユーザーと、チャネル（ボット）ごとに一意”となります。
これに気づくまでだいぶ時間をロスしました。
（ということで、ここに記載のユーザーIDだけではメッセージを送ることはできません）

- その他
このjsonを眺めると、過去の会話のやり取りを含めて返しており、まさに[ステートレスとは何か](http://yohei-y.blogspot.com/2007/10/blog-post.html?m=1)について理解が進む実例ですね。

# LINEの仕組み（メッセージ応答の方法）

同様に、LINEの視点でもやりたいことを実現するためにどんな手が取れるのか、纏めてみます。
メッセージを応答する方法にも４つほどあることがわかってきました。

- Dialogflowの応答メッセージ
固定、もしくは数パターンのランダムで返す場合はこれだけで済みます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259832/c825bd3d-b22f-0a0e-735d-03625a2f60ec.png)

- Fulfillmentの応答メッセージ
インラインエディタ上の関数を使う場合はこんな形で書けば応答してくれるので、コード内であれやこれや処理を書きます。

```javascript:node.js
let responseToUser = {
  fulfillmentText:"応答メッセージ" 
};
sendResponse(responseToUser);
```
もしくは、インラインエディタでなく外部のWebhookとする場合はこんなレスポンスを返せばいいようです。
（この例はGoogleHomeに喋らせる場合）

<details><summary>サンプルコード</summary><div>

```Response.json
{
    "fulfillmentText": "This is a text response",
    "fulfillmentMessages": [
        {
            "card": {
                "title": "card title",
                "subtitle": "card text",
                "imageUri": "https://assistant.google.com/static/images/molecule/Molecule-Formation-stop.png",
                "buttons": [
                    {
                        "text": "button text",
                        "postback": "https://assistant.google.com/"
                    }
                ]
            }
        }
    ],
    "source": "example.com",
    "payload": {
        "google": {
            "expectUserResponse": true,
            "richResponse": {
                "items": [
                    {
                        "simpleResponse": {
                            "textToSpeech": "this is a simple response"
                        }
                    }
                ]
            }
        },
        "facebook": {
            "text": "Hello, Facebook!"
        },
        "slack": {
            "text": "This is a text response for Slack."
        }
    },
    "outputContexts": [
        {
            "name": "projects/${PROJECT_ID}/agent/sessions/${SESSION_ID}/contexts/context name",
            "lifespanCount": 5,
            "parameters": {
                "param": "param value"
            }
        }
    ],
    "followupEventInput": {
        "name": "event name",
        "languageCode": "en-US",
        "parameters": {
            "param": "param value"
        }
    }
}
```
</div></details>

- Webhook（応答メッセージ）

通常はDialogflowが処理してくれている返信ですが、中身はWeb APIですので[リファレンス](https://developers.line.biz/ja/reference/messaging-api/#send-reply-message)に従ってwebhookしても返信できます。
→「channel access token」、「replyToken」があれば送れます。ただし返信なので1回のみ有効です。

<details><summary>Fulfillment request</summary><div>

```JSON:json
{
  responseId: "53171802-1fe4-477b-a59c-7b32346edfe1-ce5e18e2",
  queryResult: {
    queryText: "東京",
    action: "city_quiz.city_quiz-yes",
    parameters: { country: "日本", city: "日本" },
    allRequiredParamsPresent: true,
    fulfillmentText: "クイズを始めます。都市名、国名を教えてください。",
    fulfillmentMessages: [
      { text: { text: ["クイズを始めます。都市名、国名を教えてください。"] } },
    ],
    outputContexts: [
      {
        name:
          "projects/nice-limiter-191418/agent/sessions/c0027341-c2c0-3f10-8b33-8c88d8635f89/contexts/city_quiz-yes-followup",
        lifespanCount: 2,
        parameters: {
          country: "日本",
          "country.original": "日本",
          city: "日本",
          "city.original": "東京",
        },
      },
      {
        name:
          "projects/nice-limiter-191418/agent/sessions/c0027341-c2c0-3f10-8b33-8c88d8635f89/contexts/__system_counters__",
        parameters: {
          "no-input": 0,
          "no-match": 0,
          country: "日本",
          "country.original": "日本",
          city: "日本",
          "city.original": "東京",
        },
      },
      {
        name:
          "projects/nice-limiter-191418/agent/sessions/c0027341-c2c0-3f10-8b33-8c88d8635f89/contexts/city_quiz-followup",
        lifespanCount: 1,
        parameters: {
          country: "日本",
          "country.original": "日本",
          city: "日本",
          "city.original": "東京",
        },
      },
    ],
    intent: {
      name:
        "projects/nice-limiter-191418/agent/intents/f89a95b4-e2b7-4c44-8312-c74c1f5a661f",
      displayName: "city_quiz - yes",
    },
    intentDetectionConfidence: 1,
    languageCode: "ja",
  },
  originalDetectIntentRequest: {
    source: "line",
    payload: {
      data: {
        message: { id: "13130673460714", text: "東京", type: "text" },
        source: { userId: "U72232d47924bf0091d85d57fb9bc4437", type: "user" },
        replyToken: "6db14aab84b14711980ced454ce0ebdf",
        timestamp: "1606878367637",
        type: "message",
      },
    },
  },
  session:
    "projects/nice-limiter-191418/agent/sessions/c0027341-c2c0-3f10-8b33-8c88d8635f89",
};
```
</div></details>

- Webhook（プッシュメッセージ）

LINE APIとして、「応答」だけでなくプッシュ型で送る方法も用意されています。
厳密には「応答メッセージ」ではありませんが、ユーザーから見たら、会話しているタイミングで飛んでくれば応答にしか見えないはずです。（[リファレンス](https://developers.line.biz/ja/reference/messaging-api/#send-push-message)）
→「channel access token」、「userId」があれば送れます。

# CRUD処理や外部APIを使う視点
インラインエディタの使いにくさと以下の観点から、インラインエディタでなくGoogleAppsScript（GAS）のAPIをwebhookで繋ぐのが良いと判断しました。

- ほぼ無償
- スプレッドシートでデータ取り扱えて、IFTTTやGlideなどノーコードと相性がいい
- デプロイ操作が要らない、待ち時間がない
- URLfetchなども一部無償枠あるもののそんなにシビアじゃない

但し、GASのAPI公開には一つ欠点があり、[レスポンスの本文で任意のJSONを返すことができない](https://ikep.hatenablog.com/entry/2018/05/21/121552)ようです。
つまりDialogflowを経由して応答させることはできないため、直接LINEにWebhookで応答させる形になります。
必要な情報は、Fulfillmentからのリクエストのなかに含まれているので心配ありません。
また、この構成はDialogflowに頼らずユーザーにプッシュできるので、ユーザーから起動されなくても他のイベントをトリガーに会話をスタートできるメリットもあります。

# まとめ
以上から、こんな構成が良いという結論に達しました。
これをベースに、いろいろ作ってみて続編でサンプルアプリについて書きたいと思います。

![Chatbot構成図 (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259832/98548deb-9aed-5626-d611-d668c48fc643.png)

【ポイント】

- Fulfillmentは「Webhook」を選択
- GASでリクエストを受け取るdoPost()関数を書き、API公開する
- スプレッドシートを使ってCRUD操作をする
- GASからDialogflowに応答メッセージをレスポンスできないので、リクエストから取得した情報を使ってWebhookで応答する

【制約】

- GASのタイムアウト約5分を超えると処理が中断される
- GooglehomeはAPIでの会話応答に対応しておらず、Dialogflowから応答する必要があるため作ったアプリを転用できない
- でもウェイクワード無しのプッシュ型でスピーカーに喋らせたい場合はdialogflowでも実現できないので、なんとかそこをクリアしてこの構成の延長でGooglehomeとも連携したい