朝日ネット 技術者ブログ

朝日ネットのエンジニアによるリレーブログ。今、自分が一番気になるテーマで書きます。

オリジナルの音声アシスタントを作ろう (5) - Dialogflow 後編

はじめに

こんにちは。朝日ネットでWebアプリケーションの開発を行っている tommy です。

前回 は Dialogflow を使ってお天気"占い"Botを作成しました。 今回は実際に天気予報情報を取得する部分ならびに自分のアプリケーションと Dialogflow を連携する部分の作成を行っていこうと思います。

今回の流れ

今回、主に使用するのは、以下の二つです。

  1. Fullfillment
    • Dialogflow から外部のサービス を呼び出すための仕組みです。今回はこちらを使って天気の情報を取得します。
  2. Dialogflow API
    • 外部のサービスから Dialogflow を呼び出すためのAPIです。自分のアプリケーションに Dialogflow のエージェントとの会話を組み込むことができます。

Fullfillment

インテントが Fullfillment を利用するようにする

まずは、前回作成した Weather インテントで、Fullfillment を利用するようにします。

f:id:a-tommy:20181128101315p:plain

f:id:a-tommy:20181128101324p:plain

(前回はシステムエンティティの @sys.location を利用していましたが、天気情報を取得する際に扱いづらかったので @sys.geo-city に変更しています。 )

これを有効にすることにより、ユーザの発言がこの Weatherインテント にマッチした場合に Fullfillment画面 で設定した外部のWebサービスを呼び出すようになります。 複数のインテントで Fullfillment を有効にできますが、呼び出されるサービスは同じものです。呼び出される際にマッチしたインテント名他、さまざまな情報が伝えられるので、それを元に処理を行います。

続いて、実際に呼び出すサービスの設定をします。

呼び出すサービスを設定する

左側のメニューから Fullfillment を選択し、 Fullfillment 設定のページに行きます。

f:id:a-tommy:20181120134752p:plain

ここでは Webhook と Inline Editor のどちらかを利用することができます。

Webhook を有効にすると、指定したURLに対して、設定したヘッダ情報を付与してリクエストを投げることができます。
ここで設定したヘッダ情報と、 こちら の Request format で定義される JSON を本文としたものがリクエストとなります。 それを受けとり、上記ページに定義されている、Response format の定義に従って、JSONを応答として返せば、それを元に Dialogflow が適切な処理を行ってくれます。 わかりやすいのは fulfillmentText で、こちらを返すと、ユーザにその文章が応答として返ります。その際 Intent に定義されている Text response は無視されます。

Inline Editor はやっていること自体は Webhook と同じで、自前でサーバを立てるのが手間な場合にこちらから Node.js のサーバを作成することができます。裏側では Firebase の Cloud Functions を使ってサービスを立てています。今回はこちらを利用します1

Deploy を実行して、実際に GCPコンソールの Cloud Functions のページに行くと、サービスができているのが確認できます。

f:id:a-tommy:20181120140731p:plain

ただし、GCPコンソール側からコードの修正などを行ってしまうと、Dialogflow の Inline Editor から修正することができなくなってしまうので注意してください。 また、Cloud Functions 側の制限として、課金が有効になっていないと、Google 以外のサービスを呼び出すことができません。ですので、もし今回のコードを参考にする場合、課金を有効にするか、中継するための GAEサーバを立てるなどしてください。

コードを書く

それでは、Inline Editor に実際にコードを書いてユーザの発言を処理するプログラムを作成します。

Inline Editor を Enable にするとデフォルトで簡単なサンプルが書かれています。リクエストの JSON を受け取って適切なレスポンスの JSON を返すことさえできれば自分の好きなように書いて大丈夫です。 今回は、サンプルで使っている、Dialogflow の Webhook 用のライブラリ Node.js WebhookClient を利用してコードを書いていきます。 デフォルトで、Default Welcome Intent と Default Fallback Intent 用の処理が入っていますが、それぞれのインテントで Fullfillment を有効にしてこないとこれらのコードが呼ばれることはありません。

"use strict";

const functions = require("firebase-functions");
const {
    WebhookClient
} = require("dialogflow-fulfillment");
const {
    Card, Suggestion
} = require("dialogflow-fulfillment");
const req = require("request");

process.env.DEBUG = "dialogflow:debug"; // enables lib debugging statements

exports.dialogflowFirebaseFulfillment = functions.https.onRequest(
    (request, response) => {
        const agent = new WebhookClient({
            request, response
        });
        console.log(
            "Dialogflow Request headers: " + JSON.stringify(request.headers)
        );
        console.log("Dialogflow Request body: " + JSON.stringify(request.body));

        function welcome(agent) {
            agent.add(`Welcome to my agent!`);
        }

        function fallback(agent) {
            agent.add(`I didn't understand`);
            agent.add(`I'm sorry, can you try again?`);
        }

        function weather(agent) {
            return new Promise((resolve, reject) => {
                const apikey = "*********************"; // GCP の APIキー
                const secret = "*********************";  // Dark SKI API の Secret Key
                const city = agent.parameters["geo-city"];
                const date = agent.parameters["date"];
                if (date === "") {
                    agent.add("日付指定が不正です。");
                    resolve();
                } else {
                    const targetdt = new Date(date);
                    req("https://maps.googleapis.com/maps/api/geocode/json?address=" + encodeURIComponent(city) + "&key=" + apikey,
                        function(error, response, body) {
                            let geocode = null;
                            if (!error && response.statusCode == 200) {
                                const result = JSON.parse(body);
                                if (result.results.length > 0) geocode = result.results[0].geometry.location;
                            }
                            if (geocode === null) {
                                agent.add("位置情報の取得に失敗しました。");
                                resolve();
                            } else {
                                req("https://api.darksky.net/forecast/" + secret + "/" + geocode.lat + "," + geocode.lng + "," +
                                    Math.floor(targetdt.getTime() / 1000) + "?lang=ja",
                                    function(error, response, body) {
                                        let weather = null;
                                        if (!error && response.statusCode == 200) {
                                            const result = JSON.parse(body);
                                            if (result.currently !== null) weather = result.currently.summary;
                                        }
                                        if (weather === null) agent.add("天気情報の取得に失敗しました。");
                                        else agent.add(targetdt.getDate() + "日の" + city + "の天気は" + weather + "です。(Powered by Dark Sky)");
                                        resolve();
                                    }
                                );
                            }
                        }
                    );
                }
            });
        }

        let intentMap = new Map();
        intentMap.set("Default Welcome Intent", welcome);
        intentMap.set("Default Fallback Intent", fallback);
        intentMap.set("Weather", weather);
        agent.handleRequest(intentMap);
    }
);

外部との通信にrequestモジュールを使用しているので、package.jsonを編集します。

{
  "name": "dialogflowFirebaseFulfillment",
  "description": "This is the default fulfillment for a Dialogflow agents using Cloud Functions for Firebase",
  "version": "0.0.1",
  "private": true,
  "license": "Apache Version 2.0",
  "author": "Google Inc.",
  "engines": {
    "node": "8"
  },
  "scripts": {
    "start": "firebase serve --only functions:dialogflowFirebaseFulfillment",
    "deploy": "firebase deploy --only functions:dialogflowFirebaseFulfillment"
  },
  "dependencies": {
    "actions-on-google": "^2.2.0",
    "firebase-admin": "^5.13.1",
    "firebase-functions": "^2.0.2",
    "dialogflow": "^0.6.0",
    "dialogflow-fulfillment": "^0.5.0",
    "request":"^2.88.0"
  }
}

ひとまず、外部APIを利用してユーザの発言の中の日付と都市情報から対応する天気情報を取得しています。 緯度経度情報の取得に Google Geocoding API 2、天気情報取得のために、Dark Sky API を使わせてもらっています。コードの詳細については割愛します。
WebhookClient の add を呼ぶと Response の fulfillmentText が設定されます。複数回呼ぶと、fulfillmentText に追加されていきます3

実際に Deployして試してみます4

f:id:a-tommy:20181121153909p:plain

Dialogflow API

続いて、自アプリケーションから Dialogflow で作成したエージェントを呼び出してみます。 API の詳細は こちら になります。この API を使えば、プログラムでインテントや Entity を追加して学習させることも可能です。

今回は、自アプリケーションから Dialogflow を用いて会話をしたいので、 detectIntent を使用します5。 第3回で使った、Cloud Speech API と違って APIキーの使用ができませんので、 Dialogflow を操作可能なアカウントで OAuth 認証 を行う必要があります。 今回は、同一プロジェクトの GAE から呼び出すことによって、OAuth認証部分の詳細を省略します6

Quick Start を参考に Go のライブラリを利用しユーザ発言から fulfillmentText を取得します。

ライブラリを取得

$ go get cloud.google.com/go/dialogflow/apiv2

app.go

package main

import (
        dialogflow "cloud.google.com/go/dialogflow/apiv2"
        "context"
        "errors"
        "fmt"
        dialogflowpb "google.golang.org/genproto/googleapis/cloud/dialogflow/v2"
        "net/http"
        "text/template"

        "google.golang.org/appengine"
        "google.golang.org/appengine/log"
)

func main() {
        http.HandleFunc("/", handle)
        appengine.Main()
}

func DetectIntentText(ctx context.Context, projectID, sessionID, text, languageCode string) (string, error) {
        // appengine の Context が必要なので、外から渡すように変更。
        sessionClient, err := dialogflow.NewSessionsClient(ctx)
        if err != nil {
                return "", err
        }
        defer sessionClient.Close()

        if projectID == "" || sessionID == "" {
                return "", errors.New(fmt.Sprintf("Received empty project (%s) or session (%s)", projectID, sessionID))
        }

        sessionPath := fmt.Sprintf("projects/%s/agent/sessions/%s", projectID, sessionID)
        textInput := dialogflowpb.TextInput{Text: text, LanguageCode: languageCode}
        queryTextInput := dialogflowpb.QueryInput_Text{Text: &textInput}
        queryInput := dialogflowpb.QueryInput{Input: &queryTextInput}
        request := dialogflowpb.DetectIntentRequest{Session: sessionPath, QueryInput: &queryInput}

        response, err := sessionClient.DetectIntent(ctx, &request)
        if err != nil {
                return "", err
        }

        queryResult := response.GetQueryResult()
        fulfillmentText := queryResult.GetFulfillmentText()
        return fulfillmentText, nil
}

func handle(w http.ResponseWriter, r *http.Request) {
        ctx := appengine.NewContext(r)
        params := map[string]string{}
        if r.Method == "POST" {
                say := r.FormValue("UserSay")
                fullfillmentText, err := DetectIntentText(ctx, "dev-ftth", "hoge", say, "ja")
                if err != nil {
                        params["Result"] = err.Error()
                } else {
                        params["Result"] = fullfillmentText
                }
                params["UserSay"] = say
        }
        w.WriteHeader(http.StatusOK)
        var t = template.Must(template.ParseFiles("talk.tmpl"))
        if err := t.Execute(w, params); err != nil {
                log.Infof(ctx, err.Error())
                return
        }
}

talk.tmpl

<!DOCTYPE html>
<html>
<head>
 <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, shrink-to-fit=no"/>
</head>
<body>
 <h1>とにー</h1>
 <form method="POST">
  <input type="text" name="UserSay">
 </form>
 <div>
  <h4>USER SAYS</h4>
  {{.UserSay}}
  <h4>DEFAULT RESPONSE</h4>
  {{.Result}}
 </div>
</body>
</html>

実行すると以下のようになります7

f:id:a-tommy:20181121121048p:plain

f:id:a-tommy:20181121154707p:plain

おわりに

音声アシスタントの作成は今回で一通り終了です。今までの技術を組み合わせれば自サイトに音声による会話AIや音声操作機能を組み込むことが可能になります。 あとは、実際に会話を行いながら学習データを蓄積しより自然な応対ができるように AI を育てていくのがメインとなります。

連載記事は今回で終了ですが、今後 AI をよりよくしていく過程をまた記事にするかもしれません。そのときはよろしくお願いします。

採用情報

朝日ネットでは新卒採用・キャリア採用を行っております。


  1. ただし、現状ではデプロイに時間がかかりデバックがしづらく、本格的に利用するにはおすすめできません。また、自分が使ったときにはなぜか Deploy を2回実行しないと最新コードが反映されませんでした。

  2. Geocoding API は現在無料枠が存在しません。1回あたりは安価ですが、API のたたきすぎには注意してください。

  3. addした応答のうちランダムに応答が返るというわけではないので注意。

  4. Request の parameters からは .original のパラメータを取得することができません。本来の使い方ではありませんが、Context を使えば .original を取得することもできます。

  5. 第3回で Cloud Speech API を利用しましたが、実は detectIntent API には直接音声ファイルを渡すことができます。内部的には Dialogflow が裏で Cloud Speech API をたたいているだけなのですが、より簡単に音声アシスタントを作成することが可能です。

  6. GAE内で各言語のライブラリを用いて機能を呼び出すと、自動的に、同一プロジェクト内の機能を操作可能なサービスアカウントでの認証を行ってもらえます。このアカウントは、 IAMと管理 の サービス アカウント のページに行けば確認できます。GAEを使わない場合は、適切な権限を持ったサービスアカウントの JSON鍵をダウンロードし、環境変数 GOOGLE_APPLICATION_CREDENTIALS に鍵ファイルへのパスを指定すれば同様に認証可能です。REST APIでも認証可能ですが、少々煩雑なのでライブラリを使用したほうがよいと思います。

  7. これを見るとわかるとおり、Try it now は detectIntent の結果をわかりやすく表示しているものですね。DIAGNOSTIC INFO を表示すると、何が行われているのかがわかりやすいと思います。