LINE WORKS で予約投稿ボットを作成する件ですが、ボットと対話して相手先や投稿時間を指定できるようにしたいと思います。 ですが、AWS が用意しているチャットボット(など)のサービス Amazon Lex がいまだ(2019/6/18現在)に日本語未対応なので、他のサービスを使おうと思います。
一番下の参考に挙げたように、調べてみるといろいろなサービスが世の中にはあるようです。正直、何を使って実現するか迷うところです。
いろいろと調べて Dialogflow と Watson の2択まで絞ったのち、 両方簡単に試してみて、Dialogflow のほうが自分には合ってるっぽい印象だったので、Dialogflow を使ってみることに決めました。
無料で簡単に試せるのってありがたいですね。 Google, IBM さんともに感謝です。
まずは、 LINE WORKS とつなげて使う前に、 Dialogflow そのものをいろいろ試したので、それについてメモっときます。
チュートリアルについて
いろいろと、 Dialogflow サンプルもありますが、ここでは Dialogflow 公式にあるチュートリアルを試してみます。
Tutorials & samples | Dialogflow ES | Google Cloud
Tutorials & samples | Dialogflow ES | Google Cloud
このチュートリアルでは、
- パラメータの問い合わせ(Slot filling)
- パラメータが埋まった際の fulfillment の呼び出し
- コンテキスト(context)の切り替え
などを扱っています。また、チュートリアルが実現している機能として、
- 予約の確認
- 予約が取れなかった際の提案
をコンテキストを使い実現している当たりが参考になるかと思い選びました。
Webhook と Inline Editor
Dialogflow では、パラメータがすべてそろったら、指定した URLを呼び出して、何かを実行させたい場合、 fulfillment という機能が使えます(詳しくは、公式ドキュメントをお読みください)。
チュートリアルでは fulfillment の部分( Create a webhook with the inline editor および Build an agent from scratch using best practices (Advanced concepts) に記載されている処理)については、 Inline Editor (+ Dialogflow fulfillment library) で実現していますが、これを Webhook と JSON で書き換えてみました。
Inline Editor も無料で使えるのになんでわざわざWebhook を使うの?という理由の一つに、 Dialogflow の inline editor は無料プランでは外部API(外部のURLへのアクセス)が使えない、というのがあります。
【Dialogflowメモ】 Dialogflowを用いた外部APIの利用は無料ではできない!? | ゆたかみわーく
最終的には、 fulfuillment が呼ばれたときに、外部APIを使って情報を取得し、それを返そうと思っているので、このような構成で試すことにしました。
Dialogflow の Agent の作成
まずは Dialoflow の Agent を作成しないと始まりません。
適当な Agent を作成します。 Agent 作成時に GCP のプロジェクトをどうするか?という選択肢があるので、
デフォルトの 『Create a new Google project』を選んでおきます。あと、 default language は日本語にしときます。この default language は変更できないようなのでご注意ください(対応言語を追加することはできます)。
Agent ができたら、チュートリアルを先頭から実施していきます。
とりあえず、 fulfillment の手前 Create an intent with parameters まで実施します。 ここまで設定して、コンソール上(Dialogflow のシミュレータ)で思うような動作になっていることを確認しておきます。
問題がなければ、いよいよ fulfillment を試していきます。
fulfillment を設定
fulfillment は intent 単位で有効・無効を設定します。なので、ある Intent に対してはパラメータがそろったら fulfillment を呼び出すが、別の Intent に対しては呼ばない、といった設定が可能です。
Intent の設定画面の下のほうに、Fulfillment というところがあるので、そこを開いて、『Enable webhook call for this intent』を有効にします。
設定が終わったら Intent を保存しておきます。
次に、左側のメニューより Fulfillment を選び、Webhook か Inline Editor のどちらか一方を有効にします(どちらか一方しか有効にできません)。
ちなみに、Webhook は指定した URL (https) を POST で呼び出します(なお、 webhook の呼び出しの詳細は公式ドキュメントのここに載ってます)。
Inline Editor で定義した処理は GCP の Cloud Function で実行されます。
参考にしたこのチュートリアルでは(だけに限らず他の多くのサンプルでも) Inline Editor を使っていることが多い印象です。今回はここを Webhook (AWS Lambda) にしてみます。
ということで、 Webhook を有効にしたら、Lambda (実際には API Gateway )のURLを入力しておきます。
あと、 fulfillment の注意としては、呼び出し先( webhook にしても、Inline Editor にしても)は、Agent に対して一つだけが設定可能です。 つまり、複数のIntent からの fulfillment 呼び出しを有効にした場合、呼び出された側で処理の振り分け(ルーティング?っぽい処理)をしてやる必要があります。
呼び出し元(Intent)を区別するために必要な Intent 名などの情報は パラメータとして受け渡されるのでそれを利用して処理を記述します。
webhook の実装
Webhook の実装の前に残念なお知らせがあります。
実は、 Fulfillment 処理を簡単に書くための Node.js 用のライブラリがあります。
Dialogflow が Integration で対応している8つのチャットプラットフォームと Dialogflow 上のシミュレータに対応しているそうです。
Lambda 上の Node.js でも、これを使えば簡単に実装できそうに思えたのですが、このライブラリのメインで使うオブジェクトである WebhookClient オブジェクトを生成するために必要な request / response オブジェクトが Express のhttpオブジェクトとなってました。
Lambda の場合、どうやってこれに対応すればよいのか改めて調べるのもちょっと面倒だなと思ってしまいました(aws-serverless-express とか使うのか?)。
なので、今回はこのライブラリを使うのをあきらめました。 で、直接レスポンスで返す JSON を編集する方法で対応しました。ま、試用ですしね、今回はこれでいいかな、と。
以下では、JSONで処理する際のポイントについて説明していきます。最終的なコードは GitHubのリポジトリ をご覧ください。
なお、Dialogflow の試用が目的なので、予約が空いているかの確認や実際の予約処理は Google Calendar API は使わずに、適当に乱数で成否を返すようにします。
リクエストとレスポンス
API Gateway 経由で Lambda が受け取るった POST リクエストは、 event.body にJSON 文字列として渡されます。なので、
let body = JSON.parse(event.body);
として、JSONオブジェクトとして取得しておきます。
また、戻り値も JSON オブジェクトで返すので
// 戻り値 let response; function createResponse() { let response = { fulfillmentText: "This is a text response", fulfillmentMessages: [], source: "example.com", payload: {}, outputContexts: [ { name: "projects/${PROJECT_ID}/agent/sessions/${SESSION_ID}/contexts/context name", lifespanCount: 5, parameters: { param: "param value" } } ], followupEventInput: {} }; return response; }
として、あらかじめレスポンスオブジェクトを定義したうえで、
response = createResponse();
requestBody = body;
// レスポンスにコンテキストを(ディープ)コピー
response.outputContexts = JSON.parse(JSON.stringify(body.queryResult.outputContexts));
のように、outputContexts をリクエスト body からコピー(ディープコピー)しておきます。
もし、 fulfillment 処理において outputContexts を変更する必要がなければ、リクエストと同じ値を戻し、 必要があれば、後続する処理で書き換えるようにするためです。
なお、これらのリクエスト/レスポンスの JSON の定義は、公式ドキュメントに記載されているので、そちらをご覧ください(上記のレスポンス JSON は、このページのレスポンスから fulfillmentMessages と payload を削除して作成しました)。
Lambda での実装時の注意
あと、 Lambda を使う上での注意として、Lambda は関数を実行する際のインスタンスを再利用します。
このため、下記にあるように、再利用時にモジュールレベルの変数が使いまわされる場合があります(初期化状態が維持されます)。
毎回、初期化されるのではないので、実装時は注意が必要ですね(今回、実装しててトラブるまで気づいてませんでした・・・)。
intent の振り分け
exports.dialogflowFirebaseFulfillment = functions.https.onRequest((request, response) => { const agent = new WebhookClient({ request, response }); function makeAppointment (agent) { ・・・(中略)・・・ }); } let intentMap = new Map(); intentMap.set('Make Appointment', makeAppointment); // It maps the intent 'Make Appointment' to the function 'makeAppointment()' agent.handleRequest(intentMap); });
のように WebhookClient を作成して、 handleRequest を呼べば、 intentMap に従って、処理を振り分けてくれます。
JSON で処理する場合もやり方をまねします。 webhook 関数を呼び出した元の intent は リクエストオブジェクトの queryResult.intent.displayName に書かれています。
なので、上記と同様に、 Intent 名に応じて呼び出す関数を map オブジェクトに登録します。
// intent と対応するハンドラ関数のマップを生成 let intentMap = new Map(); intentMap.set('reservation', checkAppointment); intentMap.set('reservation - yes', makeAppointment); intentMap.set('reservation - suggestion - yes', suggestAppointment);
※ Intent 名が チュートリアルは 『Make Appointment』となっているところを 『reservation』に変えていますが、特に理由があるわけではなく単なる気分です。
実際の振り分け処理用の関数はこんな感じになります。
async function handleRequest(intentMap, intent) { const intentName = intent.displayName; // 呼び出し if (intentMap.has(intentName)) { return intentMap.get(intent.displayName)(); } console.log("no intent handler, intent: " + intentName); throw new Error("no intent hander"); }
この振り分け関数を下記のように呼び出せば、intentに応じた処理が呼び出せます。
// intent に応じて処理 let ret; try { ret = await handleRequest(intentMap, intent); } catch(e) { console.error("error occured: " + JSON.stringify(e)); throw e; }
Dialogflow からの応答
チュートリアルの MakeAppointment 関数を見ると、
function makeAppointment (agent) { ・・・(中略)・・・ // Check the availability of the time slot and set up an appointment if the time slot is available on the calendar return createCalendarEvent(dateTimeStart, dateTimeEnd).then(() => { agent.add(`Got it. I have your appointment scheduled on ${appointmentDateString} at ${appointmentTimeString}. See you soon. Good-bye.`); }).catch(() => { agent.add(`Sorry, we're booked on ${appointmentDateString} at ${appointmentTimeString}. Is there anything else I can do for you?`); }); }
のように add メソッドを呼ぶことで対応しています。
JSON であれば、
response.fulfillmentText = `予約が完了しました。 ${date} 日の ${time} 時に予約をお取りしました。`;
のように、 fulfillmentText に応答分をセットすればOKです。
context の取り出し
次に context を区別するために、contextを取り出す方法です。
チュートリアルの Create a confirmation step を見ると、
function makeAppointment (agent) { // Get the context 'MakeAppointment-followup' const context = agent.context.get('makeappointment-followup');
のように、context 名をパラメータにして get メソッドを呼ぶことで context を取得しています。
これを JSON の処理で行うために、 context が受け渡されているフォーマットを確認すると、下記のようにスラッシュ区切りの構造になっています。
"outputContexts": [ { "name": "projects/${PROJECT_ID}/agent/sessions/${SESSION_ID}/contexts/context name",
なので、 outputContexts の配列を調べ、nameプロパティの最後が求める context 名になっているかを見れば、 context を取得できそうです。
例えば、こんな風にしてみました。
function getContext(response, contextName) { for (let context of response.outputContexts) { const ary = context.name.split("/"); if (ary[ary.length-1] === contextName) { return context; } } return; }
呼び出す側は、
async function makeAppointment() { console.log("makeAppointment called"); const context = getContext(response, "reservation-followup"); if (!context) { console.warn("コンテキスト, " + "reservation-followup" + " が見つかりません。"); throw new Error("no context"); } const date = context.parameters.date; const time = context.parameters.time; // コンテキストをクリア context.lifespanCount = 0; // 予約 OK/NG を想定 const flg = trueOrFalse(1); if (flg) { response.fulfillmentText = `予約が完了しました。 ${date} 日の ${time} 時に予約をお取りしました。`; } else { response.fulfillmentText = `すいません。なぜか予約に失敗しました。 ${date} 日の ${time} 時の予約を取り直してください。`; } return response; }
のようにして、取得した context およびそれに紐づくパラメータを利用することができます。
なお、ここで一点注意が必要なのが、 Lambda 関数からのレスポンスの JSON に含まれている outputContexts が最終的に Dialogflow に渡され、それが対応する(fulfillmenを呼び出した) Intent の output context になります。
なので、contextを操作する際は、request オブジェクトではなく、 response オブジェクトに対して操作を行っています(最初に outputContexts をディープコピーでレスポンスオブジェクトにコピーしている理由です)。
context の削除
context を削除するには、 上記の例にもあるように、その context の lifespanCount を 0 にした JSON を戻してやればOKです。
context の切り替え
新しい context を追加するためには、新規の context オブジェクトを生成し、 outputContexts 配列に追加すればOKです。 そのうえで、既存の context を削除(lifespanCount を 0 に設定)すれば、切り替えができます。
今回だと、指定日時に予約があるかどうかをチェックする部分で、先に予約があった場合(試しなので乱数で決定してます)、コンテキストを reservation - suggestion に切り替えています。
// intent に応じた処理 async function checkAppointment() { console.log("checkAppointment called"); const context = getContext(response, "reservation-followup"); if (!context) { console.warn("コンテキスト, " + "reservation-followup" + " が見つかりません。"); throw new Error("no context"); } const date = requestBody.queryResult.parameters.date; const time = requestBody.queryResult.parameters.time; console.log("context before: " + JSON.stringify(response)); // check OK/NG を想定 const flg = trueOrFalse(1); if (flg) { response.fulfillmentText = `了解しました。 ${date} 日の ${time} 時の予約でよろしいでしょうか?`; } else { response.fulfillmentText = `ごめんなさい。 ${date} 日の ${time} 時は、既に予約が埋まってました。別の日に予約しますか?`; // コンテキストをクリア context.lifespanCount = 0; // suggestionコンテキストに切り替え let newContext = JSON.parse(JSON.stringify(context)); newContext.name = setNewContext(newContext.name, "reservation-suggestion"); newContext.lifespanCount = 3; newContext.parameters = { suggested_time: "2019-07-22T13:00:00+9:00" }; console.log("new context: " + JSON.stringify(newContext)); response.outputContexts.push(newContext); } console.log("context after: " + JSON.stringify(response)); return response; } function setNewContext(oldContextName, newContextName) { let ary = oldContextName.split("/"); ary[ary.length-1] = newContextName; return ary.join("/"); }
上記の処理では新しい context を生成する際、 context オブジェクトの name プロパティを正しい形式に合わせるのを簡単にするため、既存の context オブジェクトをコピーし、 context 名だけ変更するようにしています。
ちなみに、contextは複数持てますので、常に一つにする必要はありません(contextの管理は頑張ってやってください)。
なお、intent や context をどのように JSON で扱えばよいかは、
Manage contexts | Dialogflow ES | Google Cloud
に記載されているので参考にしてください。
実装した結果
チュートリアルの内容を一通り実装したものは、 GitHub のリポジトリ をご覧ください。
Dialogflow の Agent の内容は基本的にチュートリアルと同じです(一部 Intent 名が異なっています)が、これもわかりやすいように、Export した zipファイルも置いておきます。
zip ファイルの使い方は、 公式ドキュメントを見てもらうほうが早いのですが、簡単に書いておくと、
- Dialogflow で新規に Agent を作成
- Agent の設定画面を開く
- zip ファイルを import する
と設定内容が再現できます。ただし、 fulfillment の webhook の URL はダミーになっているので、ご自分のサーバー等の URL を入力してください。
なお、Agent で zip ファイルの設定を import する場合も、 default language は変更されません(追加は可能)ので、ご注意ください。
まとめ
これで Dialogflow を一通り使ってみて、 fulfillment の呼び出し、 context の切り替えも試しました。
ただ、この状態だと
- context の操作が Dialogflow 側の Intent の設定と fulfillment に分かれている
のが気になります。ちょっと大きな Agent を作ると context の流れが混乱して破綻しそうな雰囲気が漂ってます。
これへの対応方法として Event を使った方法が使えないか試してみようと思います。
参考
ボット開発用のサービス・ツールを紹介してくれているまとめ記事
- チャットボット開発ツール比べをしてみた! | kintone hive online
- プログラミングなしで出来るチャットボット開発│無料・有料ツールを紹介|トラムシステム
- 会話もできる!高性能チャットボットが作れるサービスまとめ | Web Design Trends
- チャットボットの対話設計ができる対話サービスまとめ 〜Docomo対話サービスからAmazon Lexまで〜 - Qiita
最初の2つの記事は、いろんなサービス・ツールを4つに分類して紹介してくれてますが、正直、チャット提供企業のAPIとそれ以外の違いはわかるのですが、残り3つの中の中の分類の違いはよく分かんなかったです。いろいろと試すと見えてくるのかもしれませんね。
最後の記事は、サービス・ツールという観点ではなく、対話サービスそのものがどういうものなのか、という点について解説してます。対話サービスを実現するとはどういうことなのかちょっとわかった気がしました。
Dialogflow のサンプル
整理しきれないぐらい大量のサンプルやチュートリアルがありますが、私が参考にしたものを挙げておきます。
Watson のサンプル
- Watson Assistant(旧 Conversation)で行こう ③コンテキスト(Context)を活用した、ホテル予約チャットボット - Qiita
- 概説チュートリアル
- チュートリアル: 複雑なダイアログの作成
3番目のWatson のチュートリアル試してて、挫折しました。もうちょっとやったらピンと来るのかな?