Dialogflowのドキュメントにも書かれているように、Googleアシスタントは現在Dialogflowとは統合されていない。しかし、Googleアシスタントの新しいプラットフォーム(会話アクション:Conversational Actions)はまだ日本語に対応していないらしい。そこで、Googleアシスタントのレガシー統合という従来の統合機能を利用することになるのだが、公式サイトは既に新しいプラットフォームを前提にドキュメントや解説を改訂しており、ネットを検索して得られる情報(操作手順)と画面が一致していないなど混乱をきたしている感がある。今は過渡期でそのうち会話アクションも日本語対応になって利用できるようになるのかもしれないが、現時点では無理なので、何とかレガシー統合を実現しなければならない。
このブログは、そのようなモチベーションでDialogflowをGoogleアシスタント(ひいてはGoogle Home)で利用すべくまとめあげたものである。題材としては「Google Home, Nest Hub向けにアクションを作ろう」 という全10回にわたる力作をベースにしたもので、この記事を現時点での画面や手順に置き換え、さらにこの記事ではFulfillmentのWebhookをFirebase (つまりNode.js)で構築しているところを果敢にもGAS (Google Apps Script)で実装した。何故GASかというと、一番の理由は手軽だからである。Node.jsを使うとなるとクライアント側にもnpmなどの開発環境が必要だし、それでなくともActions on GoogleやDialogflowコンソールを行き来する開発環境にさらにFirebaseコンソールが加わることになり、煩雑なので使い慣れたGASを利用することにした(追記:FirebaseのFirestoreは無料プランのホスティングが出来ない!)。ただ、Node.jsではクライアントライブラリが利用でき、WebhookのJSONフォーマットを意識することなく開発できるが、GASにはそういったライブラリがないので全部自力で開発する必要があり、この点が最大の難点である(DialogflowのJSONフォーマットについてはGitHubに詳細がある)。
#3プロジェクトの作成
「Google Home, Nest Hub向けにアクションを作ろう」の第1回目~第2回目は用語や概念の解説なので飛ばし、早速、第3回目の「プロジェクトの作成」から始める。
パーミッションの設定
パーミッションの設定はデフォルトでOKのようだった(下図)。
図3-1.パーミッションの設定 |
アクションプロジェクトの作成
プロジェクトの作成は、Actions on Google (https://console.actions.google.com)コンソールを開いて[New Project]をクリックして行う。
図3-2.Actions on Googleコンソール |
プリジェクト名に「parrot」と入力し、言語と国をそれぞれ「Japanese」と「Japan」に設定して[Create project]をクリックする。
図3-3.プロジェクトの作成画面 |
参考サイトには「次に表示される下のようなページで、一番下の右端にあるConversationalを選択してください。」と書いてあるが、どこを探しても「Conversational」などない。そこで、下図に示すように「learn more」をクリックする。
図3-4.アクションの種類を選択する画面 |
すると「Integrate with Google Assistant」という画面が表示されるのでスクロールダウンする。
図3-5.Googleアシスタント統合画面 |
すると、最後の方に「See all documentations」という見出しが現われるので、その下の[Get started]をクリックする。
図3-6.すべての文書 |
すると、下図のような画面が表示されるので、スクロールダウンする。
図3-7.文書カタログ? |
スクロールダウンするうち、下図のように「Build with Dialogflow or legacy Actions SDK」という見出しが現れたら、その下の[Build with Dialogflow]をクリックする。
図3-8.「Dialogflowを使って構築」画面 |
これでActions on Googleのプロジェクト「parrot」が作成され、下図のようなドキュメント画面が表示されるので、左上の[Google Assistant]をクリックする。
図3-9.ドキュメント画面 |
再び「Integrate with Google Assistant」画面が表示されるので、今度は右上の[Go to Actions Console]をクリックする。
図3-10.Googleアシスタントとの統合画面 |
表示されたActions Console画面には作成したプロジェクト「parrot」が表示されているので、これをクリックする。
図3-11.アクションコンソール画面 |
[Build your Action]をクリックする。
図3-12.アクションの構築 |
すると、詳細メニューが展開されるので[Add Action(s)]をクリックする。
図3-13.アクションの追加 |
すると、「Build your first Action」という画面が表示されるので[Get started]をクリックする。
図3-14.アクションの追加 |
すると、下図に示すように[CREATE ACTION]というポップアップ画面が表示されるので右下の[BUID]をクリックする。
図3-15.「CREATE ACTION」画面 |
すると、下図に示すように会話の受け皿であるIntentがDialogflow上で作成され、Dialogflowコンソール画面に飛んでいく。
図3-16.Dialogflowコンソール画面 |
Dialogflowプロジェクトの作成
Dialogflowコンソール画面で[DEFAULT LANGAGE]に「Japanese-ja」、[DEFAULT TIME ZONE]に「Asia/Tokyo」と入力して右上の[CREATE]をクリックする。
図3-17.言語と時刻を設定 |
図3-18.Intentの作成完了 |
これで第3回目の「プロジェクトの作成」が終わった。
#4会話シナリオの作成
次は第4回目の会話シナリオの作成に進むことにする。まず、Default Welcome IntentのResponseを追加修正して[SAVE]をクリックする。
図4-1.デフォルトの挨拶インテントの修正 |
次に、修正した挨拶インテントをテストするためにメニューから[Integrations]を選択して[Google Assistant]カードの右下にある[integration]をクリックする。
図4-2.Googleアシスタントとの統合 |
すると、下図に示すようなポップアップ画面が現れるので一番下の[Auto-preview change]の横のボタンを「オン」にして、右下の[TEST]をクリックする。
図4-3.Googleアシスタント統合設定画面 |
すると、しばらくの間、下図のような画面が表示される。
図4-4. 更新中の画面 |
更新が終わると下図のようなテスト画面が表示されるので、左上のテキストボックスから「テスト用アプリにつないで」と入力してエンターキーを押す(あるいはその下の[テスト用アプリにつないで]ボタンを押下しても同じ)。
図4-5.テスト画面 |
すると、下図に示すように、左側に「わかりました。テスト用アプリのテストバージョンです。カッカドゥー」と表示されたらテスト完了である。
#5 Intentの作成
第5回目はIntentの作成を行う。まず、Dialogflowコンソールから左メニューの[Intents]をクリックして、右画面に表示される右上の[CREATE INTENT]をクリックする。
図5-1.Intent作成画面 |
次に、[Intent name]に「what_is_this」と入力して、Training phrasesの[ADD TRAINING PHRASES]をクリックする。
図5-2.Intent名の設定 |
Training phrasesに図に示すようないくつかのフレーズを入力して[SAVE]をクリックする。
図5-3.訓練フレーズの入力 |
次にEntityを作成する。左メニューから[Entities]を選択して右画面に表示される右上の[CREATE ENTITY]をクリックする。
図5-4.Entityの追加 |
図5-5.Entity「animal」を追加 |
同様にEntity「fish」を作成する(下図)。
図5-6. Entity「fish」を追加 |
次に、作成したEntityを使ってIntentが受けられる会話の幅を広げる。具体的には、先ほど入力した訓練フレーズの語にEntityを割り当てる。たとえば、下図では訓練フレーズ「マグロは何ですか?」の「マグロ」の部分を反転させ、ポップアップされるリストから「@fish」という先ほど入力したEntityを割り当てている。これによって「@fishは何ですか?」といったより抽象的なフレーズ(「マグロ」だけでなく「鮪」や「アジ」などEntityに割り付けた魚(@fish)もカバーする)を検知できる。
図5-7.訓練フレーズにEntityを割り付ける |
さらに、作成したEntity「@animal」と「@fish」以外は「@sys.any」というシステム組み込み型のEntityを割り当てることができる。
図5-8. システム組み込み型Entityの割り当て |
Fulfillmentを有効に設定
DialogflowのFulfillmentのWebhookを有効に設定するために、Fulfillmentまでスクロールダウンして[ENABLE FULFILLMENT]をクリックする。
図5-9. Fulfillmentの設定 |
現れたFulfillmentの[Enable webhook call for intent]を「オン」にする。
図5-10. FulfillmentのWebhookを有効にする |
#6 Webhookの作成
第6回目はWebhookの作成を行う。冒頭でも書いたが、参考サイトはWebhookをNode.jsを使ってFirebaseのFunctionで実装しているが、ここではGAS (Google Apps Script)で実装する。
まず、GASで下記のWebAPIを作成する。
/** * POSTメソッド */ function doPost(e) { const data = e.postData.getDataAsString(); const req = JSON.parse(data); const res = handleWebhook(req); return ContentService.createTextOutput(JSON.stringify(res)); } /** * Webhookリクエストハンドラ * Dialogflowから送信されてきた HTTPS POST Webhook リクエストを処理する関数 * https://cloud.google.com/dialogflow/es/docs/fulfillment-webhook?hl=ja */ function handleWebhook(req) { const parameters = req.queryResult.outputContexts[0].parameters; const intent = req.queryResult.intent; var text = 'intent=' + intent.displayName + '\n'; if (parameters.animal) { text = text + parameters['animal.original'] + 'は動物ですね'; } else if (parameters.fish) { text = text + parameters['fish.original'] + 'は魚ですね'; } else { text = text + parameters['any.original'] + 'は動物でも魚でもありません'; } const res = { // テキスト レスポンス "fulfillmentMessages": [ { "text": { "text": [ text ] } } ], // Google アシスタント レスポンス "payload": { "google": { "expectUserResponse": true, "richResponse": { "items": [ { "simpleResponse": { "textToSpeech": text } } ] } } } }; return res; }
リスト1 WebAPI
関数handleWebhook()はDialogflowから送られたJSONフォーマットのリクエストを入力に受け取ってレスポンスメッセージを返す関数である。その際、テキストレスポンスとGoogleアシスタントレスポンスの2種類を返している。前者はDialogflowのテストコンソールで返されるメッセージで、後者はGoogleアシスタントが解釈できる会話フォーマットのレスポンスメッセージである。
次に、これを下図に示すようにデプロイする。
図6-1.GASでWebAPIを作成 |
デプロイしたウェブアプリのURLをコピーする。
図6-2.デプロイ管理画面 |
Dialogflowコンソール画面に戻って左メニューから[Fulfillment]を選択し、WebhookのURLに先ほどコピーしたURLを貼り付け、右上の[ENABLED]を「オン」にする。
図6-3.FulfillmentのWebhookのURLを設定して有効にする |
次に、スクロールダウンして[SAVE]ボタンをクリックする。
図6-4. Webhook設定の保存 |
Webhookのテスト
左メニューから[Intents]を選択し、右側の[Try it now]に「わんちゃんは何ですか?」と入力するとレスポンスが返される。
図6-5. Try it now画面 |
右下の[DIAGNOSTIC INFO]をクリックすると、このときDialogflowがWebhookに送ったり受信したりしたJSONフォーマットのリクエスト/レスポンスメッセージが確認できる。
図6-6. Diagnostic情報(生APIレスポンス) |
下図はFulfillment requestタブを選んだところである。
図6-7. Diagnostic情報(Fulfillment request) |
この内容は以下のようになっている。
{ "responseId": "f4797514-90e7-4a77-825b-ae80ff6f8295-d8906a81", "queryResult": { "queryText": "わんちゃんは何ですか?", "parameters": { "animal": "イヌ", "fish": "", "any": "" }, "allRequiredParamsPresent": true, "fulfillmentMessages": [ { "text": { "text": [ "" ] } } ], "outputContexts": [ { "name": "projects/parrot-5b7e0/agent/sessions/2633e845-7df3-4f5b-989d-211fb76ce884/contexts/__system_counters__", "parameters": { "no-input": 0, "no-match": 0, "animal": "イヌ", "animal.original": "わんちゃん", "fish": "", "fish.original": "", "any": "", "any.original": "" } } ], "intent": { "name": "projects/parrot-5b7e0/agent/intents/241cbcad-a526-4a5a-b0c9-e6b8fc3eda4f", "displayName": "what_is_this" }, "intentDetectionConfidence": 1, "languageCode": "ja" }, "originalDetectIntentRequest": { "source": "DIALOGFLOW_CONSOLE", "payload": {} }, "session": "projects/parrot-5b7e0/agent/sessions/2633e845-7df3-4f5b-989d-211fb76ce884" }
リスト2 Fulfillment request
このJSONフォーマットの詳細はGoogle AssistantのreferenceであるDialogflow webhook formatに詳しい。また、リクエストのひな形やサンプルはGitHubから入手できる。
次に、下図はFulfillment responseタブを表示したものである。
図6-8. Diagnostic情報(Fulfillment response) |
Fulfillment responseの内容を以下に示す。
{ "fulfillmentMessages": [ { "text": { "text": [ "わんちゃんは動物ですね" ] } } ], "payload": { "google": { "expectUserResponse": true, "richResponse": { "items": [ { "simpleResponse": { "textToSpeech": "わんちゃんは動物ですね" } } ] } } } }
リスト3 Fulfillment response
レスポンスのひな形はGitHubに掲載されているので参照するとよい。
最後に、下図は、シミュレータでwhat_is_this Intentをテストしているところである。
図6-9. what_is_this Intentテスト画面 |
左上に入力された「わんちゃんは何ですか?」に対して「わんちゃんは動物です」というレスポンスが出力され、そのときのJSONフォーマットのリクエスト/レスポンスメッセージが右下に表示されている。
なお、ここまでくれば実機(スマホのGoogle AssistantやGoogle Home)でも「OK Google テスト用アプリにつないで」と声をかければテストができる。
後日譚
GASでFulfillmentのWebhook URLの作成が成功したので、念のため、FirebaseでWebhookの作成を試みた。結論から言えば、これは無料アカウントでは無理だった。
図6-10.Firebase Functionsのデプロイでエラー |
これは「FirebaseのFirestoreは無料プランのホスティングが出来ない!」にも書いてあった。ということで、無料でWebhookを実現するには現時点ではここでやったようなGASしか選択肢がなかったのかもしれない。もちろん、Herokuなどを利用すればできたのかもしれないが、かなり大掛かりな環境を整えねばならず、とても手軽に試すというわけにはいかないだろう。
アクションの呼び出し名の設定
これまで、作成したアクションを呼び出す際に「テスト用アプリにつないで」と呼びかけて(入力して)いたが、本番環境ではきちんとした呼び名を設定しておきたい。そのための方法を下図に示す。
図6-11.Display nameの設定 |
会話を終わらせるWebhookレスポンス
Node.jsのクライアントライブラリでレスポンスを返すメソッドにはconv.ask()とconv.close()の2通りのメソッドがある。前者は会話を継続する場合に用い、後者は会話を終了する場合に用いる(例)。クライアントライブラリを用いず、これに相当する制御をするには 「DialogflowのフルフィルメントWebhookで会話をすぐに終了させる方法」によれば、expectUserResponse にfalseを設定すればよい。
{ "payload": { "google": { "expectUserResponse": false, "richResponse": { "items": [ { "simpleResponse": { "textToSpeech": "Goodbye!" } } ] } } } }
リスト4 expectUserResponseフラグ
これは公式ドキュメントにも書いてある(Conversation exits)。
Webhookリクエストハンドラの整理
リスト1のWebhookリクエストハンドラ関数handleWebhook(req)は、(req.queryResult.intent.displayNameによって識別できる)インテントの種類に応じて様々な処理を実行する関数の呼び出しを担う、いわば仕分け関数として位置づけ、例えば血圧測定の記録など、個々の処理は別途独立した関数にした方が、今後の拡張性や保守性のためには都合が良い。たとえば、下記リスト5に示すように、血圧測定を意味するインテントが'measuring-blood-pressure'である場合、処理関数intent_measuring_blood_pressure(req)を呼び出す。このようにすれば、インテントと対応する処理関数を追加するだけで機能拡張できる。
/** * Webhookリクエストハンドラ * Dialogflowから送信されてきた HTTPS POST Webhook リクエストを処理する関数 * https://cloud.google.com/dialogflow/es/docs/fulfillment-webhook?hl=ja */ function handleWebhook(req) { const intent = req.queryResult.intent.displayName; switch(intent) { case 'what_is_this': return intent_what_is_this(req); case 'measuring-blood-pressure': return intent_measuring_blood_pressure(req); case 'verify-blood-pressure': return intent_verify_blood_pressure(req); case 'delete-blood-pressure': return intent_delete_blood_pressure(req); default: return tell('intent=' + intent + 'がフックされていません。'); } }
リスト5 handleWebhook関数
血圧測定の記録を実行する処理関数intent_measuring_blood_pressure(req)は次のようになる。
/** * 血圧測定の記録 * Intent=measuring-blood-pressure */ function intent_measuring_blood_pressure(req) { const parameters = req.queryResult.parameters; const systolic = parameters['systolic']; const diastolic = parameters['diastolic']; const pulse = parameters['pulse']; const text = '了解しました。収縮期は ' + systolic + ' 、拡張期は ' + diastolic + ' で、脈拍は ' + pulse + ' ですね。記録しておきます。'; recordBloodPressure(systolic, diastolic, pulse); return tell(text); }
リスト6 血圧測定
この関数は、リスト2に示すFulfillment requestのqueryResult.parametersから収縮期血圧(Entityに付けた変数をsystolicとしている)と拡張期血圧(〃diastolic)、そして脈拍数(〃pulse)を取り出して、確認メッセージを組み立て、関数recordBloodPressureを使ってスプレッドシートへ記録するとともに、関数tellを使って確認メッセージをスマートスピーカに発話している。
スプレッドシートに記録する関数recordBloodPressureを以下に示す。
/** * 血圧手帳の操作 */ const HEALTH_RECORD_ID = 'スプレッドシートのID'; // 記録 function recordBloodPressure(systolic, diastolic, pulse) { var spreadsheet = SpreadsheetApp.openById(HEALTH_RECORD_ID); var sheet = spreadsheet.getSheetByName('血圧手帳'); sheet.appendRow([new Date(), systolic, diastolic, pulse]) }
リスト7 スプレッドシートに記録する関数
ここで、血圧を記録するスプレッドシートはIDを指定して取得するようになっているが、このスクリプトがGoogleスプレッドシートのコンテナバインド型スクリプトになっている場合、以下のようにアクティブスプレッドシートとしてIDを指定せずに取得できる。
var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
リスト8 アクティブスプレッドシートの取得
下図はリスト7のプログラムで記録された血圧手帳シートである。
図7.血圧手帳 |
血圧手帳シートには計測日時、収縮期血圧、拡張期血圧、脈拍数の4項目が1回の測定につき1行で記録される。これを行っているのがリスト7のプログラムの最後の命令sheet.appendRow関数である。この関数は1行に格納する列項目を配列で指定する。
会話を終わらせるWebhookレスポンス(リスト4)で説明したように、レスポンスメッセージを送信する際に会話を終わらせる場合とそのまま継続させる場合がある。両者を関数として準備しておき、必要になった時点で呼び出せるとプログラムがすっきりする。そう言った趣旨で作った関数がリスト6の最終行にあるtell()関数である。この関数は以下に示すようにexpectUserResponseをfalseに設定してレスポンスメッセージを出力している。反対に、このフラグをtrueに設定して会話を継続させる関数がask()である(リスト9)。
/** * レスポンス送信後もセッションが続く */ function ask(speech) { return _speech(speech, true); } /** * レスポンス送信後にセッションを終了 */ function tell(speech) { return _speech(speech, false); } /** * レスポンスJSON編集 */ function _speech(speech, expectUserResponse) { return { // テキスト レスポンス "fulfillmentMessages": [ { "text": { "text": [ speech ] } } ], // Google アシスタント レスポンス "payload": { "google": { "expectUserResponse": expectUserResponse, "richResponse": { "items": [ { "simpleResponse": { "textToSpeech": speech } } ] } } } }; }
リスト9 会話を終わらせる関数tellと継続させる関数ask
血圧記録にまつわる様々な機能
Dialogflowが音声認識を誤って収縮期血圧143を間違えて43と認識した場合、スプレッドシートに書き込まれた行を削除しなければならない。また、今朝測定した血圧や昨夜測定した血圧、はたまた1週間前に測定した血圧を確認したい場合があるかもしれない。そのような場合に対応するのがリスト5のswitch文におけるintentがverify-blood-pressureの場合に呼び出される関数intent_verify_blood_pressure(血圧の確認)であり、intentがdelete-blood-pressureの場合に呼び出される関数intent_delete_blood_pressure(血圧の削除)である。
血圧の削除
Dialogflowで血圧の削除を行うためのインテントを作成する。インテント名をdelete-blood-pressureとし、訓練フレーズに「血圧の削除」といったキーワードを登録する。血圧を削除するには直近に記録された行をスプレッドシートから削除する。以下のような関数になる。
/** * 血圧測定の記録を削除 * Intent=delete-blood-pressure */ function intent_delete_blood_pressure(req) { const text = deleteBloodPressure(); return tell(text); } // 削除 function deleteBloodPressure() { const spreadsheet = SpreadsheetApp.openById(HEALTH_RECORD_ID); const sheet = spreadsheet.getSheetByName('血圧手帳'); const lastRow = sheet.getLastRow(); const measuring_date = sheet.getRange(lastRow, 1).getValue(); const text = Utilities.formatDate(measuring_date, 'JST', 'M月d日のH時m分に計測した血圧記録を削除しました。'); sheet.deleteRows(lastRow); return text; }
リスト10 直近の血圧記録を削除する関数
sheet.getLastRow()関数で最終行を取得し、sheet.deleteRows関数でその行を削除する。ただし、どのデータを削除したかを音声で通知するために、削除前のデータを読みとって計測日時をメッセージに反映させる。
血圧の確認
血圧の確認を行うインテントを作成する。インテント名はverify-blood-pressureとする。このインテントには、いつの血圧を確認するかといった日付に関連するパラメタが必要となる。そこでDialogflowのTraining phrasesで「今日の血圧を教えて」と入力して「今日」をtarget_dateという名前で@sys.date-timeというENTITYでパラメタとして登録する(下図)。ただし、必須パラメタにはしない。このパラメタが省略された場合は今日の測定結果を確認するものとみなす。
図8.Dialogflow 血圧の確認を行うインテント |
血圧の確認を行う関数は以下のようになる。
/** * 血圧測定の記録を確認 * Intent=verify-blood-pressure */ function intent_verify_blood_pressure(req) { const parameters = req.queryResult.parameters; const target_date = parameters['target_date']; const text = verifyBloodPressure(target_date); return tell(text); }
リスト11 血圧記録を確認する関数
パラメタtarget_dateに確認したい血圧の日付が格納されている。それを取り出してverifyBloodPressure関数の引数として渡す。関数verifyBloodPressurは日付を引数として血圧手帳シートから該当する測定記録を抽出して確認メッセージを組み立てて返す関数である(下記)。
// 確認 function verifyBloodPressure(target_date) { const spreadsheet = SpreadsheetApp.openById(HEALTH_RECORD_ID); const sheet = spreadsheet.getSheetByName('血圧手帳'); const lastRow = sheet.getLastRow(); if (target_date) { const target_date_JST = Utilities.formatDate(new Date(target_date), 'JST', 'yyyyMMdd'); const numColumns = sheet.getLastColumn(); const data = sheet.getRange(1, 1, lastRow, numColumns).getValues(); const texts = []; for (let i = 1; i < lastRow; i++) { const measuring_date = data[i][0]; const measuring_date_JST = Utilities.formatDate(measuring_date, 'JST', 'yyyyMMdd'); if (measuring_date_JST == target_date_JST) { const systolic = data[i][1]; const diastolic = data[i][2]; const pulse = data[i][3]; const text = Utilities.formatDate(measuring_date, 'JST', 'M月d日のH時m分') + 'に計測した血圧記録は、うえが' + systolic + 'mmHg、したが' + diastolic + 'mmHgで、脈拍数が1分間に' + pulse + '拍でした。'; texts.push(text); } } if(texts.length > 0) return texts.join(''); return Utilities.formatDate(new Date(target_date), 'JST', 'M月d日') + 'は血圧測定をやっていせん。'; } else { const measuring_date = sheet.getRange(lastRow, 1).getValue(); const systolic = sheet.getRange(lastRow, 2).getValue(); const diastolic = sheet.getRange(lastRow, 3).getValue(); const pulse = sheet.getRange(lastRow, 4).getValue(); const text = Utilities.formatDate(measuring_date, 'JST', 'M月d日のH時m分') + 'に計測した血圧記録は、うえが' + systolic + 'mmHg、したが' + diastolic + 'mmHgで、脈拍数が1分間に' + pulse + '拍でした。'; return text; } }
リスト11 関数verifyBloodPressure
0 件のコメント:
コメントを投稿