Alexa:ステートフルなサービスと聞き取りやすい音声応答

2018年9月20日(木)
畠中 幸司(はたなか・こうじ)
連載6回目は、ステートフルなサービスの構築と、Alexaの音声応答を聞き取りやすくする手法を紹介します。

今回のテーマはこちらです。

  • DynamoDBでステートフルなサービスを実現する
  • SSMLを使って音声応答を聞き取りやすくする

ステートフルなサービスを実装する

これまでのサンプルプログラムでは、ステートレスなスキル・サービスのみを扱ってきました。ステートレスなスキルでは、ユーザーがスキルを開くとまっさらな状態からスタートしますので、毎回すべての必要な情報を伝える必要があります。ダイアログの機能を使うと、ステートフルなセッション管理をスキル・インターフェースに任せることができます。これを使ってユーザーとスキルの間での複数回に渡るやりとりを実現しました。しかしセッションが終わると、次のセッション開始時にはまた初めからユーザーが情報を伝え直す必要があります。

ユーザーが前回スキルを使った際の情報を覚えておき、それに基づいた応答を返すには、専用の記憶機能が必要になります。AWS Lambda関数の中からはあらゆる種類のデータベースを利用することができますので、なじみのある方法があればそれを用いることができます。ここではSDKに用意された永続アトリビュート(persistent attributes)という機能を使ってみることにします。

抽選スキルを作る

ステートフルなスキルの例として、今回は抽選を行うスキルを作ってみます。予め用意された参加者リストの中から、抽選で任意の人数を選び出します。各参加者には番号が割り当てられており、当選者の番号が読み上げられます。抽選は何度でも行えますが、一度当選した人は次の抽選の対象からは外れます。また、実際に使うシーンを想定すると、当選者の読み上げを繰り返させることができた方が良さそうです。では対話モデルの設計からスタートしましょう。

(1)対話モデル

次のような対話モデルを実現します。

ユーザー:「Alexa、抽選スキルを開いて」
Alexa:  「対象者は10名です。抽選を始めますか?」
ユーザー:「はい」(「いいえ」で終了)
Alexa:  「当選者数は何件にしますか?」
ユーザー:「3 件にします」
Alexa:  「3 件で良いですか?」
ユーザー:「はい」
Alexa:  「抽選しています…」
     「当選者を発表します。当選者は2、5、7です」
     「もう一度当選者を読み上げますか?」
ユーザー:「はい」(「いいえ」で終了)
Alexa:  (当選者を読み上げる)
     「もう一度当選者を読み上げますか?」

今回は、次のように対話モデルのスキーマを定義しました。

リスト1:コード例(対話モデルのスキーマ定義):lottery/models/ja-JP.json

01{
02    "interactionModel": {
03        "languageModel": {
04            "invocationName": "抽選スキル",
05            "intents": [
06                {
07                    "name": "AMAZON.CancelIntent",
08                    "samples": []
09                },
10                {
11                    "name": "AMAZON.HelpIntent",
12                    "samples": []
13                },
14                {
15                    "name": "AMAZON.StopIntent",
16                    "samples": []
17                },
18                {
19                    "name": "DrawLotsIntent",
20                    "slots": [
21                        {
22                            "name": "winnerCount",
23                            "type": "AMAZON.NUMBER",
24                            "samples": [
25                                "{winnerCount}",
26                                "{winnerCount} 件にします",
27                                "{winnerCount} 件にしてください",
28                                "{winnerCount} 件",
29                                "{winnerCount} 件です"
30                            ]
31                        }
32                    ],
33                    "samples": [
34                        "はい",
35                        "抽選 して",
36                        "抽選 を 始めて"
37                    ]
38                },
39                {
40                    "name": "AMAZON.NoIntent",
41                    "samples": [
42                        "いいえ"
43                    ]
44                }
45            ],
46            "types": []
47        },
48        "dialog": {
49            "intents": [
50                {
51                    "name": "DrawLotsIntent",
52                    "confirmationRequired": false,
53                    "prompts": {},
54                    "slots": [
55                        {
56                            "name": "winnerCount",
57                            "type": "AMAZON.NUMBER",
58                            "confirmationRequired": true,
59                            "elicitationRequired": true,
60                            "prompts": {
61                                "confirmation": "Confirm.Slot.1292434666682.1990326609",
62                                "elicitation": "Elicit.Slot.1424358568121.1250545639225"
63                            }
64                        }
65                    ]
66                }
67            ]
68        },
69        "prompts": [
70            {
71                "id": "Confirm.Slot.1292434666682.1990326609",
72                "variations": [
73                    {
74                        "type": "PlainText",
75                        "value": "{winnerCount} けんで良いですか?"
76                    }
77                ]
78            },
79            {
80                "id": "Elicit.Slot.1424358568121.1250545639225",
81                "variations": [
82                    {
83                        "type": "PlainText",
84                        "value": "当選者数は何件にしますか?"
85                    }
86                ]
87            }
88        ]
89    }
90}

「当選者数は何件にしますか?」という問い合わせに対する回答を処理するのに、今回は抽選を実際に行うインテント「DrawLotsIntent」のサンプル発話に「はい」を追加しています。「いいえ」の処理には標準インテントの「Amazon.NoIntent」を利用します。

(2)データ構造

このスキルで扱うデータは以下のようになるでしょう。

  • 参加者の一覧(整数のリスト)
  • 参加者のうち、抽選の対象となる人の一覧(整数のリスト)
  • 抽選ごとの当選者(整数のリスト)
  • 現在の状態(初期状態、当選者読み上げ中)

ユーザーによる「はい」という回答はDrawLotsIntentで処理しますが、状態によって抽選か当選者の最読み上げかを選択する必要があります。これを管理するのが「現在の状態」です。

これらのデータを、以下のような構造で保存することにします。JSON 形式に説明を加える形で表現しています。

リスト2:データの保存の仕方

01// 抽選の対象となる人の一覧
02validApplicants: [
03    1, 4, 7, 9, 10
04],
05// 抽選回ごとの当選者
06winnerHistory: [
07    {
08        timestamp: 1535100000000, // getTime() の値
09        winners: [3, 8, 6]
10    },
11    {
12        // 省略
13    }
14],
15// 状態
16lastState: {
17    state: 'NONE',  // 初期状態は NONE, リピート受付中は REPEAT
18    timestamp: 0    // getTime() の値。初期状態は 0
19}

参加者は10名いて、それぞれ1から10までの番号を割り当てられており、2回の抽選が終わった状態です。1回目は3名、2回目は2名が当選しました。状態はlastStateで、タイムスタンプとともに記録しています。抽選を始める前の参加者の一覧はハードコードすることにします。

永続アトリビュート(persistent attributes)とPersistenceAdapter

Alexa Skills Kit SDKには、スキルとの対話が終了しても状態を覚えておくための仕組みが用意されています。永続アトリビュート(persistent attributes)と呼ばれる機能で、データを外部のデータベースに保存します。永続アトリビュートからの読み出しは、次のコード例のようにして行います。

リスト3:永続アトリビュートからの読み出し例

1handlerInput.attributesManager.getPersistentAttributes()
2.then((attributes) => {
3    // (1)成功時に行う処理をここに記述
4    // attributes に結果が格納され、attributes.name で
5    // name という名前のアトリビュートにアクセスできる
6})
7.catch((error) => {
8    // (2)エラー処理をここに記述
9})

ここで(1)と(2)の部分は非同期に呼び出されます。非同期処理の結果を待って次の処理を行う場合、コールバック関数が何重ものネスト(入れ子構造)になりコードの見通しが悪くなりがちです。ここではPromiseオブジェクトを用いて、次のように記述します。

リスト4:Promiseオブジェクトの利用

01let promise = new Promise((resolve, reject) => {
02    handlerInput.attributesManager.getPersistentAttributes()
03    .then((attributes) => {
04        resolve(attributes.name);
05    })
06    .catch((error) => {
07        reject(error);
08    })
09});
10promise.then((name) => {
11    // nameを使った処理
12});

getPersistentAttributes()の成功時にresolve()関数を呼び出しています。この関数に渡した値がpromise.then()を通じて呼び出される関数に渡されます。また失敗時には、reject()関数を呼び出します。

Promiseは非同期処理を行う際に、その処理結果のプロキシの役割をするオブジェクトです。Promiseを使うことで非同期関数が同期関数のように結果を返すことができ、コードを簡潔にできます。データベースの読み書きは非同期処理となるので、永続アトリビュートを扱う際にはPromiseを使いましょう。書き込みの手順もこれと似ています。

リスト5:Promiseオブジェクトを用いた書き込みの手順

01let promise = new Promise((resolve, reject) => {
02    handlerInput.attributesManager.getPersistentAttributes()
03    .then((attributes) => {
04        attributes.name = 'value';
05        handlerInput.attributesManager.setPersistentAttributes(attributes);
06        return handlerInput.attributesManager.savePersistentAttributes();
07    })
08    .then(() => {
09        resolve();
10    })
11    .catch((error) => {
12        reject(error);
13    });
14});

この例では、getPersistentAttributes()で永続アトリビュートを取得し、さらに取得した値を変更して保存しています。非同期処理の連続ですが、1つ目のthen()関数がsavePersistentAttributes()の戻り値(Promise オブジェクト)を返すようにして、ネストが深くならないようにしています。

Amazon DynamoDBとPersistentAdapter

今回は永続アトリビュートを保存する先としてAmazon DynamoDBを使用します。Amazon DynamoDBは、非常に高速かつフレキシブルなNoSQLデータベースサービスです。NoSQLは、リレーショナルデータベースとは異なり、柔軟なデータ構造を扱えるデータベースの総称です。今回のようにアトリビュートの名称と値を対応付けて保存する場合、つまりKVS(キーバリューストア)として利用するのに適しています。またDynamoDBはフルマネージドなサービスですので、テーブルを作成するだけですぐに利用することができ、サーバーのメンテナンスをする必要もありません。さらに1ヶ月あたり25GBなどの制限内であれば、無料で利用できます。AWSのコンソールからDynamoDBの画面を開いてみましょう。

DynamoDBのホーム画面

DynamoDBのホーム画面

上記の画面で「テーブルの作成」をクリックしてみてください。テーブル名とプライマリーキーを入力するだけで、すぐにテーブルを作成することができます。ここでは以下のようにします。

  • テーブル名:LotteryTable
  • プライマリーキー:id

管理の単位はテーブルで、RDBのようなスキーマの概念はありません。

テーブルの作成

テーブルの作成

テーブルができると次のような画面になります。テーブルの一覧と現在選択されているテーブルの概要が表示されており、ここからデータの確認等の操作が行えます。この画面には後で戻ってくることにしましょう。

テーブルの概要

テーブルの概要

さて、永続アトリビュートの機能と実際のデータベースとを結びつけるのがPersistentAdapterの役割です。使用するデータベースに適したPersistentAdapterを用意する必要がありますが、Alexa Skills Kit SDKには予めAmazon DynamoDBのアダプターが用意されています。これを利用するには、コードの先頭で以下のように記述します。

リスト6:Amazon DynamoDBのアダプターを利用する

1const { DynamoDbPersistenceAdapter } = require('ask-sdk-dynamodb-persistence-adapter');
2const dynamoDbPersistenceAdapter = new DynamoDbPersistenceAdapter({
3    tableName: 'FooTable'
4    });

「FooTable」の部分には、実際に使用するAmazon DynamoDBのテーブル名を入れます。このDynamoDbPersistenceAdapter オブジェクトのコンストラクターには、以下のパラメーターを渡すことができます。

コンストラクターに渡すパラメーター

パラメーター(型)既定値説明
tableName(string)なし(必須)テーブル名
partitionKeyName(string)idパーティションキー(プライマリーキー)のカラム名
attributesName(string)attributesアトリビュートを保存するカラム名
createTable(boolean)falseテーブルがないときに自動的に作成したい場合はtrueをセットする
partitionKeyGenerator(function)userIdを元に生成する関数リクエスト情報を元にパーティションキーを生成する関数
dynamoDBClient(DynamoDB)DocumentClientDynamoDBに接続するためのクライアントオブジェクト

抽選スキルのコード

それではコーディングです。初めにPC上でNode.jsのプロジェクトを作成します。npm initの設定値は、すべて初期値とします。

mkdir lottery
cd lottery
npm init

Alexa Skills Kit SDK をインストールします。

npm install ask-sdk

コード例は、以下の通りです。

リスト7:コード例:lottery/index.js(expand sourceをクリックして表示)

主要な部分を順に解説していきます。

(1)PersistentAdapterを読み込む

永続アトリビュートをAmazon DynamoDBに保存するため、アダプターを読み込みます。使用するDynamoDBのテーブル名もここでセットします。

リスト8:PersistentAdapterの読み込み

1const { DynamoDbPersistenceAdapter } = require('ask-sdk-dynamodb-persistence-adapter');
2const dynamoDbPersistenceAdapter = new DynamoDbPersistenceAdapter({
3  tableName: 'LotteryTable'
4});

(2)永続アトリビュートを取得する

永続アトリビュートを取得する操作はいくつかの箇所で行いますので、ここでは専用の関数を定義しておきます。テーブルに初期値がない場合には、ハードコードされた参加者リストを返すようにしています。この関数はPromiseオブジェクトを返しますので、呼び出し元では戻り値の.then()メソッドを呼び出して利用します。

リスト9:永続アトリビュートの取得

01function getAttributes(attributesManager) {
02  return new Promise((resolve, reject) => {
03    attributesManager.getPersistentAttributes()
04      .then((attributes) => {
05        if (attributes.validApplicants == undefined) {
06          attributes.validApplicants = initialApplicants;
07        }
08        resolve(attributes);
09      })
10      .catch((error) => {
11        resolve({ validApplicants: initialApplicants });
12      });
13  });
14}

(3)当選者読み上げの文言を生成する

当選者リストから応答メッセージを生成する関数です。応答を聞き取りやすくするために後から書き換える箇所ですので、ここに示しておきます。

リスト10:当選者読み上げの文言

1function getWinnersSpeech(winners) {
2  const winnersString = winners.join(',');
3  const winnersSpeechSingle = '当選者は、' + winnersString + 'です。';
4  return winnersSpeechSingle + '繰り返します。' + winnersSpeechSingle;
5}

(4)不足している情報を取得する

当選者数を何件にするか決めないと、このスキルは抽選を行いません。そのための仕組みをダイアログの機能を使って実装しています。ここではダイアログの状態が「COMPLETED」でなければ、Delegateディレクティブを返答します。

リスト11:不足している情報の取得

1// (4)不足している情報を収集する
2if (handlerInput.requestEnvelope.request.dialogState !== 'COMPLETED') {
3  return handlerInput.responseBuilder
4    .addDelegateDirective()
5    .getResponse();
6}

(5)スロットの値を取得する

新しいSDKでも、スロットの値を取得する方法は以前と大きくは変わりません。ここでは当選者の件数が格納されたスロットwinnerCountの値を取得しています。

リスト12:スロットの値を取得

1let winnerCount = Number(handlerInput.requestEnvelope.request.intent.slots.winnerCount.value);

(6)永続アトリビュートを保存する

ここではAttributesManagerを使用して永続アトリビュートを保存しています。.then()メソッドの中で応答を生成して返しています。少しややこしいですが、この使い方を覚えましょう。

リスト13:永続アトリビュートの保存

01handlerInput.attributesManager.setPersistentAttributes(attributes);
02return handlerInput.attributesManager.savePersistentAttributes()
03  .then(() => {
04    ~ 中略 ~
05  return handlerInput.responseBuilder
06      .speak(speechOutput)
07      .withSimpleCard(SKILL_NAME, cardString)
08      .reprompt(repromptSpeech)
09      .getResponse();
10  });

(7)DrawLotsIntentハンドラー

抽選スキルのメインの動作を行うDrawLotsIntentのハンドラーです。初めに永続アトリビュートを取得し、そこに記録された状態とタイムスタンプを見て最読み上げと抽選のどちらを行うか決定します。

リスト14:DrawLotsIntentのハンドラー

01const DrawLotsIntentHandler = {
02  canHandle(handlerInput) {
03    return handlerInput.requestEnvelope.request.type === 'IntentRequest'
04      && handlerInput.requestEnvelope.request.intent.name === 'DrawLotsIntent';
05  },
06  handle(handlerInput) {
07    return getAttributes(handlerInput.attributesManager)
08      .then((attributes) => {
09        // 状態を判別する
10        let nowDate = new Date();
11        let now = nowDate.getTime();
12        if (attributes.lastState !== undefined &&
13            attributes.lastState.state == STATE_REPEAT &&
14            now - attributes.lastState.timestamp <= REPEAT_THRESHOLD &&
15            attributes.winnerHistory !== undefined) {
16            ~ 中略 ~
17        } else {
18          // 抽選を行う
19          return drawLots(handlerInput, attributes);
20        }
21      })
22  }
23};

(8)Lambda関数ハンドラーを定義する

Lambda関数ハンドラーの定義はSkillBuilderを使って行いますが、その際にwithPersistenceAdapter()メソッドを呼び出します。引数は、プログラムの冒頭に定義したDynamoDBのアダプターオブジェクトです。

リスト15:Lambda関数ハンドラーの定義

1const skillBuilder = Alexa.SkillBuilders.custom();
2exports.handler = skillBuilder
3  .withPersistenceAdapter(dynamoDbPersistenceAdapter)
4  .addRequestHandlers(
5    LaunchRequestHandler,
6  ~ 後略 ~

動作確認

それでは動作を確認してみましょう。

(1)AWS Lambda関数を作成する

以下の仕様で、AWS Lambda関数を新たに作成します。

作成するAWS Lambda関数の仕様

項目内容
作成方法一から作成
名前lotterySkillFunction
ランタイムNode.js 8.10
ロールテンプレートから新しいロールを作成
ロール名lotterySkillRole

作成後、Node.js のプロジェクトフォルダーにあるnode_modulesとindex.jsの2つをzipファイルにまとめてアップロードします。

(2)IAMロールにポリシーを追加する

IAMとはAWSの各サービスおよびリソースへのアクセス権を管理する仕組みです。ユーザー、グループ、ロールの単位でアクセス元を分類し、それぞれに対して個別のアクセスポリシーを適用することができます。ここではLambda関数がlotterySkillRoleというロールを持っていますので、このロールにDynamoDBへのアクセスポリシーを追加しましょう。この操作を行わないと、Lambda 関数の実行は失敗します。

AWSのコンソールでIAM のページを開いて「ロール」メニューを開き、Lambda 関数と一緒に作成されたlotterySkillRoleをクリックします。

ロールの指定

ロールの指定

するとlotterySkillRoleに、現在割り当てられているポリシーの一覧が表示されます。ここに新たにポリシーを追加しますので、「ポリシーをアタッチします」をクリックします。

ポリシーのアタッチの選択

ポリシーのアタッチの選択

今度は「lotterySkillRoleにアクセス権限を追加する」というページが表示されるので、追加したいポリシーを検索します。「ポリシーのフィルタ」欄に「DynamoDB」と入力しましょう。下の一覧に、DynamoDB に関するポリシーの一覧が表示されます。ここではポリシーの一覧から「AmazonDynamoDBFullAccess」を選び、「ポリシーのアタッチ」をクリックします。

AmazonDynamoDBFullAccess

AmazonDynamoDBFullAccess

以上の操作で、lotterySkillRoleに必要なポリシーが追加されました。

追加されたポリシーの確認

追加されたポリシーの確認

(3)スキルを作成する

開発者コンソールでスキルを作成します。作成に必要な情報は以下の通りです。

項目内容
スキル名抽選スキル
言語日本語
モデルカスタム

対話モデルのスキーマ定義(json形式)を読み込んで、Lambda関数のエンドポイントもセットします。

(4)テスト

「アレクサ、抽選スキルを開いて」…

うまく動きましたか?このスキルを実際に動かすと、先ほど作成したDynamoDBのテーブルにデータが記録されます。AWSのコンソールからDynamoDBのページを開いて確認してみましょう。

LotteryTableを選択すると、「amzn1…」のような文字列で始まる項目が追加されていることがわかります。これはAlexaの利用者を内部で識別するUserIdの値です。今回使った方法では、テーブルのプライマリーキーであるidカラムにこのUserIdの値がセットされます。

LotteryTableの確認

LotteryTableの確認

項目をクリックすると、以下のような画面が表示されます。コードで扱うJavaScriptのオブジェクトと同じ構造でデータが格納されていることがわかります。

項目内データの確認

項目内データの確認

著者
畠中 幸司(はたなか・こうじ)

音楽と自然と猫を愛するソフトウェア&インフラエンジニア。日本ヒューレット・パッカード株式会社でクラウドネイティブなアプリケーションのためのインフラ提案、および構築業務に従事。2000年にウェブスタートアップでエンジニアとしてのキャリアをスタートして以来、メガソフト株式会社の3Dマイホームデザイナーシリーズの開発や、マイクロソフト日本法人にて Windows Phone、Microsoft Officeシリーズの開発など、数多くの国内およびグローバルな開発プロジェクトに携わる。建設業向けモバイルアプリSTUCCO(スタッコ)のスタートアップ起業経験、500 KOBE Pre-Acceleratorへの参加等を経て2017年より現職。

連載バックナンバー

開発ツール技術解説
第6回

Alexa:ステートフルなサービスと聞き取りやすい音声応答

2018/9/20
連載6回目は、ステートフルなサービスの構築と、Alexaの音声応答を聞き取りやすくする手法を紹介します。
開発ツール技術解説
第5回

Alexaのためのローカル開発環境を整備する

2018/9/4
連載5回目は、より高度なプログラム作成に先駆けて、Alexaのためのローカル開発環境を整備していきます
開発ツール技術解説
第4回

Alexa Skills Kit SDK for Node.jsについて知る

2018/8/21
連載4回目は、スキル・サービス側のコードに着目して、フレームワークの理解を深めていきます。

Think ITメルマガ会員登録受付中

Think ITでは、技術情報が詰まったメールマガジン「Think IT Weekly」の配信サービスを提供しています。メルマガ会員登録を済ませれば、メルマガだけでなく、さまざまな限定特典を入手できるようになります。

Think ITメルマガ会員のサービス内容を見る

他にもこの記事が読まれています