DynamoDB
lambda
Alexa
AlexaSkillsKit
スマートスピーカー

VUIアプリケーションのフィードバック機能をAlexaスキルで公開した

はじめに

AmazonのAlexaのスキルにしろ、GoogleのGoogleアシスタントでのアプリにしろ、VUIのアプリケーションでユーザからのフィードバックを得られる機会が現状では乏しいと考えていて、VUIでのフィードバック機能をAlexaスキルに搭載して、本日(2018年6月13日)公開されたので、それについて記載しておく。

ソースコードも貼っておく。

そもそも現状はフィードバックを得にくい

例えば、Amazonでスキルを公開しても、そのスキルが良かったのか悪かったのか、それをいちいちAmazonのスキルストアを開いて星をつけてコメントするのは手間で仕方がない。
なんで声でスキルを利用しているのにフィードバックはWebページを開く必要があるのか。

TwitterだってFacebookだって、投稿をみたら、その場で「いいね」をする仕組みのはず。
なのでVUIのアプリケーションだって使ったらその場でフィードバックできれば良いのではなかろうか。

ということで作ってみてリリースされたのが今回のお話。

ちなみに公開されている該当のスキルは
「IT業界の深い闇」

IT業界の深い闇のスキルのページ

環境

・LambdaのNode.jsは8.10
・V2
・DynamoDBを使用
・sdkの一部を書き換えている

DynamoDBのテーブル構成

これはリリースされて数時間後の状態。

審査前はすべての属性は0にしておいた。
よってAmazonさんが審査時にどれをチェックしたのかも実はわかる。

DynamoDB.png

ソースコード、ほとんどそのまま

このソースコードにはバグがあって、事前にDynamoDBでテーブルを作っておかないと、スキルを呼び出した一発目はエラーになる。

申し訳ない。

そして設計的にイマイチな気もするけれど、ともあれ動いてはいます。

ITdarknessSkillwithFeedback.js
'use strict';

const Alexa = require('ask-sdk');

const SKILL_NAME = 'IT業界の深い闇';
const WELCOME_MESSAGE = 'ようこそ。';

var yamiData = [
    '闇の話1',
    '闇の話2',
    '闇の話3'
];

const BEFORE_STOP_GOODorBAD_MESSAGE = 'まずは「ストップ」と言ってください。';

const GOOD_MESSAGE_PREFIX = 'いいね。<break time="0.2s"/>で、このスキルのアイデアにフィードバックしました。<break time="0.2s"/>';
const BAD_MESSAGE_PREFIX = 'いまいち。<break time="0.2s"/>で、このスキルのアイデアにフィードバックしました。<break time="0.2s"/>';
const NOFEEDBACK_MESSAGE_PREFIX = 'ストップ。で、このスキルのアイデアにフィードバックしました。<break time="0.2s"/>';

const THANKS = 'ありがとうございました。またね。';

const HELP_MESSAGE = 'ヘルプですね。<break time="0.2s"/>' + WELCOME_MESSAGE;
const HELP_MESSAGE2 = 'ストップ後のヘルプですね。<break time="0.2s"/>ここで、「いいね」<break time="0.2s"/>「いまいち」<break time="0.2s"/>「ストップ」<break time="0.2s"/>のどれかを話せば、それぞれのフィードバックが完了します。どのフィードバックをしますか?';

const HELP_REPROMPT = WELCOME_MESSAGE;
const HELP_REPROMPT2 = HELP_MESSAGE2;

const CONTINUE_MESSAGE = '<break time="0.5s"/>続けて、ほかの話を聞きますか?<break time="0.2s"/>聞く場合は、「はい」<break time="0.2s"/>やめるなら、「ストップ」<break time="0.2s"/>と話しかけてください。';

const UNDEFINED_MESSAGE = '想定外の事象が発生しました。しばらく経ってから、もう一度このスキルを呼び出してください。';
const R_MESSAGE = 'すいません。聞き取れませんでした。もう一度、<break time="0.2s"/>「いいね」<break time="0.2s"/>もしくは「いまいち」<break time="0.2s"/>もしくは「ストップ」<break time="0.2s"/>と話しかけてください。';


let skill;
exports.handler = async function (event, context) {
    if (!skill) {

      // standard()で宣言
      // addRequestHandlersの中のHandlerの順番も考慮すること
      skill = Alexa.SkillBuilders.standard() 
        .addRequestHandlers(
            LaunchRequestHandler,
            YesIntentHandler,
            BeforeStopGoodIntentHandler,
            BeforeStopBadIntentHandler,
            StopIntentHandler,
            GoodIntentHandler,
            BadIntentHandler,
            StopIntentHandler2,
            CancelIntentHandler,
            HelpIntentHandler,
            HelpIntentHandler2,
            ErrorHandler)
        .withTableName("voiceFeedbackTable") // テーブル名を宣言
        .withAutoCreateTable(true) //テーブル作成もスキルから行う場合ならこれも追加
        .create();
    }
    return skill.invoke(event);
};

//response(応答)はこの関数にまとめておく。フィードバックの内容を条件分岐させる
function response(handlerInput, addResult,feedbackKind) {


        switch (feedbackKind) {

        //ユーザが「はい」と言った場合に闇の話を配列からランダムで選択してspeak
        case 'Yes':

            var factArr = yamiData;
            var factIndex = Math.floor(Math.random() * factArr.length);
            var randomFact = factArr[factIndex];
            var speechOutput = randomFact + CONTINUE_MESSAGE;
            var reprompt = HELP_REPROMPT;

            return handlerInput.responseBuilder
                .speak(speechOutput)
                .reprompt(reprompt)
                .getResponse();


        //ユーザがストップを言う前に「ヘルプ」を言った場合
        case 'help':

            return handlerInput.responseBuilder
                .speak(HELP_MESSAGE)
                .reprompt(HELP_REPROMPT)
                .getResponse();

        //ユーザがストップを言った後のフィードバック状態にて「ヘルプ」を言った場合
        case 'help2':

            var attribute_help = {
            "STATE": "stateKey"
            };
            handlerInput.attributesManager.setSessionAttributes(attribute_help);

            return handlerInput.responseBuilder
                .speak(HELP_MESSAGE2)
                .reprompt(HELP_REPROMPT2)
                .getResponse();


        //ユーザがストップを言う前に「いいね」や「いまいち」を言った場合
        case 'beforeStopGoodorBad':

            return handlerInput.responseBuilder
                .speak(BEFORE_STOP_GOODorBAD_MESSAGE)
                .reprompt(BEFORE_STOP_GOODorBAD_MESSAGE)
                .getResponse();


        //ユーザが「いいね」のフィードバックをした場合
        //テキスト文章を入れる際に" " ではなく、` `で囲むことで、${addResult}にDynamoDBの結果を読ませることができる
        case 'good':

            const goodSpeechText = GOOD_MESSAGE_PREFIX + `ヤバさが伝わったようですね。これで、このスキルの、いいねのフィードバック回数が${addResult}になりました。` + THANKS;

            return handlerInput.responseBuilder
                .speak(goodSpeechText)
                .getResponse();

        //ユーザが「いまいち」のフィードバックをした場合
        //テキスト文章を入れる際に" " ではなく、` `に囲むことで、${addResult}にDynamoDBの結果を読ませることができる        
        case 'bad':

            const BadSpeechText = BAD_MESSAGE_PREFIX + `これで、このスキルの、いまいちのフィードバック回数が${addResult}になりました。` + THANKS;

            return handlerInput.responseBuilder
                .speak(BadSpeechText)
                .getResponse();


        //ユーザが1回目のストップを言って、フィードバックモードに移行させる場合
        case 'stop':

            //フィードバックモードに移行するため、セッションアトリビュートを付与
            var attribute_stop = {
            "STATE": "stateKey"
            };
            handlerInput.attributesManager.setSessionAttributes(attribute_stop);

            const StopSpeechText = 'スキルを終了するまえに、IT業界の闇の深さが伝わったかフィードバックをください。<break time="0.2s"/>「いいね」<break time="0.2s"/>「いまいち」<break time="0.2s"/>もしくは、もう一度「ストップ」<break time="0.2s"/>のどれかを話しかけてください。';

            return handlerInput.responseBuilder
                .speak(StopSpeechText)
                .reprompt(HELP_REPROMPT)
                .getResponse();


        //ユーザがフィードバックモードの中で、再度「ストップ」と言った場合
        //stopIntentを再利用できる
        case 'stop2':

            const StopSpeechText2 = NOFEEDBACK_MESSAGE_PREFIX + `これで、このスキルの、ストップのフィードバック回数が${addResult}になりました。` + THANKS;

            return handlerInput.responseBuilder
                .speak(StopSpeechText2)
                .getResponse();


        //ユーザが「キャンセル」と言った場合。今回はどのタイミングでも「キャンセル」が言われたら終了する。
        case 'cancel':

            const CancelSpeechText =  `まさかのキャンセル。キャンセルは、このスキルのアイデアにフィードバックを実施しません。` + THANKS;

            return handlerInput.responseBuilder
                .speak(CancelSpeechText)
                .getResponse();        


        //明確なエラーになった場合    
        case 'err':

            const ErrSpeechText =  UNDEFINED_MESSAGE;

            return handlerInput.responseBuilder
                .speak(ErrSpeechText)
                .getResponse(); 


        //予期せぬ何かしらの不具合が発生した場合
        default:
            const OtherSpeechText = UNDEFINED_MESSAGE;

            return handlerInput.responseBuilder
                .speak(OtherSpeechText)
                .getResponse();

    }

}

//asyncで attributes(属性)を付与する
//DynamoDBは永続化されるので、getPersistentAttributesでDynamoDBの状態を操作する
//セッションアトリビュートもここの関数で操作する
async function addAttributes(handlerInput, feedbackKind) {

    //永続化情報の取得 awaitがポイント
    let attributes = await handlerInput.attributesManager.getPersistentAttributes();


    //セッション情報はawaitなし
    let sessionAttributes = handlerInput.attributesManager.getSessionAttributes();


    // 初期化する
    //ここではDynamoDBの各属性の初期化を行う

    //スキルが呼ばれた回数を記録するNo(ナンバー)
    if(!attributes.no){ 
        attributes.no = 0; 
    }

    //いいねの数    
    if(!attributes.good){ 
        attributes.good= 0; 
    }

    //いまいちの数    
    if(!attributes.bad){ 
        attributes.bad= 0; 
    }

    //ストップで切られたの数    
    if(!attributes.stop){ 
        attributes.stop= 0; 
    }


    //フィードバックの種類によって条件分岐し、DynamoDBの値を取得してあるので加算してreturn
    switch (feedbackKind) {


        //今回の1回目のストップの場合はDynamoDBに影響させないのでセッションのみ
        //1回目のストップはフィードバック状態に遷移するのみなのでstopを返すだけ
        case 'stop':

            // 永続化情報の保存
            handlerInput.attributesManager.setSessionAttributes(sessionAttributes);
            return attributes.stop;


        //「いいね」のフィードバックをする場合、スキルが呼ばれた数と、「いいね」が呼ばれた数を加算して
        //DynamoDBとsessionに保存
        case 'good':

            attributes.no++;
            attributes.good++;

            // 永続化情報の保存
            handlerInput.attributesManager.setPersistentAttributes(attributes);
            await handlerInput.attributesManager.savePersistentAttributes();
            return attributes.good;


        //「いまいち」のフィードバックをする場合、スキルが呼ばれた数と、「いまいち」が呼ばれた数を加算して
        //DynamoDBとsessionに保存    
        case 'bad':

            attributes.no++;
            attributes.bad++;

            // 永続化情報の保存
            handlerInput.attributesManager.setPersistentAttributes(attributes);
            await handlerInput.attributesManager.savePersistentAttributes();
            return attributes.bad;


        //「ストップ」のフィードバックをする場合、スキルが呼ばれた数と、「ストップ」が呼ばれた数を加算して
        //DynamoDBとsessionに保存            
        case 'stop2':

            attributes.no++;
            attributes.stop++;

            // 永続化情報の保存
            handlerInput.attributesManager.setPersistentAttributes(attributes);
            await handlerInput.attributesManager.savePersistentAttributes();
            return attributes.stop;



        //LaunchRequest、ヘルプ、キャンセル、ストップ前、エラーは加算しない
        default:

            // 永続化情報の保存
            handlerInput.attributesManager.setPersistentAttributes(attributes);
            await handlerInput.attributesManager.savePersistentAttributes();
            return attributes.no;

    }

}

// スキル起動時の処理
const LaunchRequestHandler = {

    //canHandleで、このHandlerが呼ばれるかどうかを判定する。以後同様。
    canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'LaunchRequest';
    },
    async handle(handlerInput) {

        const feedbackKind = 'LaunchRequest';

        //DynamoDBの初期化対応は行う
        await addAttributes(handlerInput, feedbackKind);

        return handlerInput.responseBuilder
        .speak(WELCOME_MESSAGE)
        .reprompt(HELP_REPROMPT)
        .getResponse();

    }
};


// 「はい」と答えたら深い闇の話をする
//  Yesはフィードバック用ではないが、後から闇の話スキルに対応するためここに追記した
const YesIntentHandler = {
    canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'IntentRequest'
            && handlerInput.requestEnvelope.request.intent.name === 'AMAZON.YesIntent';
    },
    async handle(handlerInput) {

        const feedbackKind = 'Yes';
        const addResult = await addAttributes(handlerInput, feedbackKind);
        return response(handlerInput, addResult,feedbackKind);
    }
};

/*「いいえ」はユーザに明示的に「ストップ」と言わせたいために最終的に外した

// 「いいえ」と答えたらSTOPと同じ扱いをする
const NoIntentIntentHandler = {
    canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'IntentRequest'
            && handlerInput.requestEnvelope.request.intent.name === 'AMAZON.NoIntent';
    },
    async handle(handlerInput) {

        const feedbackKind = 'stop';
        const addResult = await addAttributes(handlerInput, feedbackKind);
        return response(handlerInput, addResult,feedbackKind);
    }
};
*/


// 「ストップ」の前に「いいね」と答えた場合の処理
const BeforeStopGoodIntentHandler = {
    canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'IntentRequest'
            && handlerInput.requestEnvelope.request.intent.name === 'goodIntent'
            && handlerInput.attributesManager.getSessionAttributes().STATE != 'stateKey';
    },
    async handle(handlerInput) {

        const feedbackKind = 'beforeStopGoodorBad';
        const addResult = await addAttributes(handlerInput, feedbackKind);
        return response(handlerInput, addResult,feedbackKind);
    }
};

// 「ストップ」の前に「いまいち」と答えた場合の処理
const BeforeStopBadIntentHandler = {
    canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'IntentRequest'
            && handlerInput.requestEnvelope.request.intent.name === 'badIntent'
            && handlerInput.attributesManager.getSessionAttributes().STATE != 'stateKey';
    },
    async handle(handlerInput) {

        const feedbackKind = 'beforeStopGoodorBad';
        const addResult = await addAttributes(handlerInput, feedbackKind);
        return response(handlerInput, addResult,feedbackKind);
    }
};



// 最初に「ストップ」と答えた場合の処理
const StopIntentHandler = {
    canHandle(handlerInput) {

        return handlerInput.requestEnvelope.request.type === 'IntentRequest'
            && handlerInput.requestEnvelope.request.intent.name === 'AMAZON.StopIntent'
            && handlerInput.attributesManager.getSessionAttributes().STATE != 'stateKey';
    },
    async handle(handlerInput) {

        const feedbackKind = 'stop';
        const addResult = await addAttributes(handlerInput, feedbackKind);
        return response(handlerInput, addResult,feedbackKind);
    }
};


// 「ストップ」の後に「いいね」と答えた場合の処理
const GoodIntentHandler = {
    canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'IntentRequest'
            && handlerInput.requestEnvelope.request.intent.name === 'goodIntent'
            && handlerInput.attributesManager.getSessionAttributes().STATE == 'stateKey';
    },
    async handle(handlerInput) {

        const feedbackKind = 'good';
        const addResult = await addAttributes(handlerInput, feedbackKind);
        return response(handlerInput, addResult,feedbackKind);
    }
};


// 「ストップ」の後に「いまいち」と答えた場合の処理
const BadIntentHandler = {
    canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'IntentRequest'
            && handlerInput.requestEnvelope.request.intent.name === 'badIntent'
            && handlerInput.attributesManager.getSessionAttributes().STATE == 'stateKey';
    },
    async handle(handlerInput) {

        const feedbackKind = 'bad';
        const addResult = await addAttributes(handlerInput, feedbackKind);
        return response(handlerInput, addResult,feedbackKind);
    }
};


// 2回目(ストップの後)に「ストップ」と答えた場合の処理
const StopIntentHandler2 = {
    canHandle(handlerInput) {

        return handlerInput.requestEnvelope.request.type === 'IntentRequest'
            && handlerInput.requestEnvelope.request.intent.name === 'AMAZON.StopIntent'
            && handlerInput.attributesManager.getSessionAttributes().STATE == 'stateKey';
    },
    async handle(handlerInput) {

        const feedbackKind = 'stop2';
        const addResult = await addAttributes(handlerInput, feedbackKind);
        return response(handlerInput, addResult,feedbackKind);
    }
};



// 「キャンセル」と答えた場合の処理
// キャンセルは今回はセッション情報を無関係とする
const CancelIntentHandler = {
    canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'IntentRequest'
            && handlerInput.requestEnvelope.request.intent.name === 'AMAZON.CancelIntent';
    },
    async handle(handlerInput) {

        const feedbackKind = 'cancel';
        const addResult = await addAttributes(handlerInput, feedbackKind);
        return response(handlerInput, addResult,feedbackKind);
    }
};


// 「ストップ」を答える前に「ヘルプ」と答えた場合の処理
const HelpIntentHandler = {
    canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'IntentRequest'
            && handlerInput.requestEnvelope.request.intent.name === 'AMAZON.HelpIntent'
            && handlerInput.attributesManager.getSessionAttributes().STATE != 'stateKey';
    },
    async handle(handlerInput) {
        // 
        const feedbackKind = 'help';

        //DynamoDB初期化は実施する
        await addAttributes(handlerInput, feedbackKind);
        const addResult = await addAttributes(handlerInput, feedbackKind);
        return response(handlerInput, addResult,feedbackKind);

    }
};

// 「ストップ」を答えた後に「ヘルプ」と答えた場合の処理
const HelpIntentHandler2 = {
    canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'IntentRequest'
            && handlerInput.requestEnvelope.request.intent.name === 'AMAZON.HelpIntent'
            && handlerInput.attributesManager.getSessionAttributes().STATE == 'stateKey';
    },
    async handle(handlerInput) {
        // 
        const feedbackKind = 'help2';

        //DynamoDB初期化は実施する
        await addAttributes(handlerInput, feedbackKind);

        const addResult = await addAttributes(handlerInput, feedbackKind);
        return response(handlerInput, addResult,feedbackKind);


    }
};


//今まで書いてきたHandlerに一致しなかったらErrとする
const ErrorHandler = {
    canHandle() {
        return true;
    },
    async handle(handlerInput, error) {

        const feedbackKind = 'err';
        const addResult = await addAttributes(handlerInput, feedbackKind);
        return response(handlerInput, addResult,feedbackKind);

    },
};

上のソースコードで基本的には「IT業界の深い闇」スキルは動いている。

ただ、これだけだと今回のスキルの呼び出された回数とそれぞれのフィードバック数を格納するという仕様ではDynamoDBで不具合が起きるのでsdkの一部を改編している。

当たり前っちゃ当たり前だけど、キー値が統一されない。

改編したsdkのコード

上記のNode.jsのコードのみだと、DynamoDBのキー値に、スキルを呼び出したユーザIDが格納される。
Alexaスキルの申請時に「個人情報は一切とらない」と宣言しているし、何よりキー値がユーザIDごとに異なると、スキルの累計フィードバック数の計算がめんどくなる。

よって、以下のようにreturnの箇所を書き換えた。
改編を最小限にするにはどうするか考えた結果、戻り値を強制的に文字列にした。
本来はラップするようにする等、別の方法の方がいいだろうし、今回の実装だからこそこの方法で対処した。

ask-sdk-dynamodb-persistence-adapter/dist/attributes/persistencePartitionKeyGenerators.js
'use strict';

/*
 * Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * Licensed under the Apache License, Version 2.0 (the "License").
 * You may not use this file except in compliance with the License.
 * A copy of the License is located at
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * or in the "license" file accompanying this file. This file is distributed
 * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
 * express or implied. See the License for the specific language governing
 * permissions and limitations under the License.
 */
'use strict';
Object.defineProperty(exports, "__esModule", { value: true });
var AskSdkUtils_1 = require("../../utils/AskSdkUtils");
/**
 * Object containing implementations of {@link PartitionKeyGenerator}.
 */
exports.PartitionKeyGenerators = {
    /**
     * Gets attributes id using user id.
     * @param {RequestEnvelope} requestEnvelope
     * @returns {string}
     */
    userId: function (requestEnvelope) {
        if (!(requestEnvelope
            && requestEnvelope.context
            && requestEnvelope.context.System
            && requestEnvelope.context.System.user
            && requestEnvelope.context.System.user.userId)) {
            throw AskSdkUtils_1.createAskSdkError('PartitionKeyGenerators', 'Cannot retrieve user id from request envelope!');
        }

        //何がなんでもキー値を user1 という文字列で返すように書き換え
        return 'user1';

        //本来書いてあった戻り値
        //return requestEnvelope.context.System.user.userId;
    },
    /**
     * Gets attributes id using device id.
     * @param {RequestEnvelope} requestEnvelope
     * @returns {string}
     */
    deviceId: function (requestEnvelope) {
        if (!(requestEnvelope
            && requestEnvelope.context
            && requestEnvelope.context.System
            && requestEnvelope.context.System.device
            && requestEnvelope.context.System.device.deviceId)) {
            throw AskSdkUtils_1.createAskSdkError('PartitionKeyGenerators', 'Cannot retrieve device id from request envelope!');
        }

        //何がなんでもキー値を device1 という文字列で返すように書き換え
        return 'device1';

        //本来書いてあった戻り値
        //return requestEnvelope.context.System.device.deviceId;
    },
};
//# sourceMappingURL=PartitionKeyGenerators.js.map


なんでDynamoDBにしたの?

データの永続化を考えた時にRDSにしようとは考えたけれど、RDSとLambda(というかAlexa)の相性が非常によろしくないと言われている。(言われていた。)
コネクション数とか、立ち上げに時間かかるとか、などなど。

なので、DynamoDBで今回はあえて対応した。

参考サイト

ask-sdk v2でのstateがない
[日本語Alexa] Alexa SDK for Node.js Ver2入門(その1)はじめの一歩
[日本語Alexa] Alexa-SDK Ver2(その4) スキル属性