Alexa Skills Kit でスロットを使ったスキルを実装する

 前回は Alexa Skills Kit で、起動のリクエストを受け付けると決まった処理をしてレスポンスを返すだけのシンプルなスキルを実装してみましたが、今回はユーザからの入力値を使って処理をするスキルを実装してみたいと思います。具体的には、前回は順番決めスキルの中で固定で持っていた対象者リストを、ユーザから入力された名前を使用するように変更してみます。

対話モデルの変更

 まずは対話モデルを変更します。 Alexa Skills Kit では、ユーザからの入力値はスロットという引数で扱うことができますので、インテントスキーマでスロットの利用を定義します。

{
  "intents": [
    {
      "intent": "DecideOrderIntent",
      "slots": [
        { "name": "NameA", "type": "AMAZON.FirstName" },
        { "name": "NameB", "type": "AMAZON.FirstName" },
        { "name": "NameC", "type": "AMAZON.FirstName" },
        { "name": "NameD", "type": "AMAZON.FirstName" }
      ]
    },
    { "intent": "AMAZON.HelpIntent" },
    { "intent": "AMAZON.StopIntent" },
    { "intent": "AMAZON.CancelIntent" }
  ]
}

 今回の実装では、対象者を4人まで指定できるようにしたいと思いますので、カスタムインテントの DecideOrderIntent にスロットを4つ定義しています。スロットを定義する際にはスロットタイプも指定する必要があり、独自のタイプを定義することもできますが、あらかじめ用意されている標準スロットタイプで該当するものがあればそちらを使った方が何かと便利です。今回は名前を扱うためのスロットなので、標準ライブラリの AMAZON.FirstName を使用しています。

developer.amazon.com

 次に、インテントスキーマで定義したスロットを使えるように、サンプル発話を変更します。

DecideOrderIntent 順番決め
DecideOrderIntent 順番決めて
DecideOrderIntent 順番を決めて
DecideOrderIntent 順番決めを開いて
DecideOrderIntent 誰の順番
DecideOrderIntent 誰の番
DecideOrderIntent 誰が先
DecideOrderIntent {NameA} と {NameB} の順番を決めて
DecideOrderIntent {NameA} と {NameB} と {NameC} の順番を決めて
DecideOrderIntent {NameA} と {NameB} と {NameC} と {NameD} の順番を決めて
DecideOrderIntent {NameA} と {NameB} の順番
DecideOrderIntent {NameA} と {NameB} と {NameC} の順番
DecideOrderIntent {NameA} と {NameB} と {NameC} と {NameD} の順番
DecideOrderIntent {NameA} と {NameB}
DecideOrderIntent {NameA} と {NameB} と {NameC}
DecideOrderIntent {NameA} と {NameB} と {NameC} と {NameD}

 8行目以降が今回追加した内容です。スロットはサンプル発話の中にプレースホルダーのような形で配置します。今回の4つのスロットは全て人の名前で、2人〜4人で可変なので、リスト形式で受け取れると良いのですが、それはできないようだったので、2人、3人、4人のそれぞれのパターンを定義しています。ちなみにスロット名とその前後のテキストの間に半角スペースを入れないと対話モデルの保存時にパースエラーになってしまいました。

 基本的には対話モデルの変更はここまででOKですが、今回使用している標準スロットタイプの AMAZON.FirstName は日本語を話すユーザが一般的に使用する数千個の名前を集めたものなので、マイナーな名前やニックネームを使いたい場合には候補を追加して拡張することができます。そのためには対話モデルの設定画面のカスタムスロットタイプの項目で下記のように入力します。(下記の例で使っている名前はあらかじめ含まれているとは思いますが。。)

f:id:akanuma-hiroaki:20171123162106p:plain

 更新 ボタンをクリックすると値が登録され、下記のような表示に変わります。

f:id:akanuma-hiroaki:20171123162137p:plain

 複数の標準スロットタイプを使っていて他のスロットタイプも拡張したい場合には スロットタイプの追加 をクリックするとさらにフォームが表示されるので、追加で登録することができます。

 ここまでの内容を 保存 して対話モデルの変更は終了です。

Lambda ファンクションの変更

 スロットの内容を使えるように、 Lambda ファンクションを変更します。変更後の実装内容は下記のようになります。

# -*- coding: utf-8 -*-
from __future__ import print_function
from random import shuffle

# --------------- Helpers that build all of the responses ----------------------

def build_speechlet_response_with_card(title, output, reprompt_text, should_end_session):
    return {
        'outputSpeech': {
            'type': 'PlainText',
            'text': output
        },
        'card': {
            'type': 'Simple',
            'title': title,
            'content': output
        },
        'reprompt': {
            'outputSpeech': {
                'type': 'PlainText',
                'text': reprompt_text
            }
        },
        'shouldEndSession': should_end_session
    }

def build_speechlet_response_without_card(output, reprompt_text, should_end_session):
    return {
        'outputSpeech': {
            'type': 'PlainText',
            'text': output
        },
        'reprompt': {
            'outputSpeech': {
                'type': 'PlainText',
                'text': reprompt_text
            }
        },
        'shouldEndSession': should_end_session
    }

def build_response(session_attributes, speechlet_response):
    return {
        'version': '1.0',
        'sessionAttributes': session_attributes,
        'response': speechlet_response
    }

# --------------- Functions that control the skill's behavior ------------------

def decide_order(names):
    shuffle(names)
    return "今回は、" + '、'.join(names) + "の順番です。"

def get_welcome_response():
    session_attributes = {}
    speech_output = '対象の名前を四人まで言ってください'
    reprompt_text = '聞き取れませんでした。' + speech_output

    should_end_session = False
    return build_response(session_attributes, build_speechlet_response_without_card(
        speech_output, reprompt_text, should_end_session))

def get_help_response():
    session_attributes = {}
    speech_output = 'このスキルでは、四人までの対象者の順番をシャッフルして決定します。' + \
                    '「誰と誰と誰」、という形で、対象の名前を四人まで言ってください'
    reprompt_text = '聞き取れませんでした。対象の名前を四人まで言ってください'

    should_end_session = False
    return build_response(session_attributes, build_speechlet_response_without_card(
        speech_output, reprompt_text, should_end_session))

def handle_session_end_request():
    speech_output = None
    reprompt_text = None
    should_end_session = True
    return build_response({}, build_speechlet_response_without_card(
        speech_output, reprompt_text, should_end_session))

def get_order(intent, session):
    session_attributes = {}

    slots = intent['slots']
    
    if 'NameA' not in slots or 'NameB' not in slots or 'NameC' not in slots or 'NameD' not in slots:
        speech_output = '名前がわかりませんでした。もう一度言ってください。'
        reprompt_text = speech_output
        should_end_session = False

        return build_response(session_attributes, build_speechlet_response_without_card(
            speech_output, reprompt_text, should_end_session))

    else:
        reprompt_text = None
        should_end_session = True
        card_title = "順番を決めました"
        names = []

        if 'value' in intent['slots']['NameA']:
            names.append(intent['slots']['NameA']['value'])
        if 'value' in intent['slots']['NameB']:
            names.append(intent['slots']['NameB']['value'])
        if 'value' in intent['slots']['NameC']:
            names.append(intent['slots']['NameC']['value'])
        if 'value' in intent['slots']['NameD']:
            names.append(intent['slots']['NameD']['value'])

        speech_output = decide_order(names)

        return build_response(session_attributes, build_speechlet_response_with_card(
            card_title, speech_output, reprompt_text, should_end_session))

# --------------- Events ------------------

def on_session_started(session_started_request, session):
    print("on_session_started requestId=" + session_started_request['requestId']
          + ", sessionId=" + session['sessionId'])

def on_launch(launch_request, session):
    print("on_launch requestId=" + launch_request['requestId'] +
          ", sessionId=" + session['sessionId'])

    return get_welcome_response()

def on_intent(intent_request, session):
    print("on_intent requestId=" + intent_request['requestId'] +
          ", sessionId=" + session['sessionId'])

    intent = intent_request['intent']
    intent_name = intent_request['intent']['name']

    if intent_name == "DecideOrderIntent":
        return get_order(intent, session)
    elif intent_name == "AMAZON.HelpIntent":
        return get_help_response()
    elif intent_name == "AMAZON.CancelIntent" or intent_name == "AMAZON.StopIntent":
        return handle_session_end_request()
    else:
        raise ValueError("Invalid intent")

def on_session_ended(session_ended_request, session):
    print("on_session_ended requestId=" + session_ended_request['requestId'] +
          ", sessionId=" + session['sessionId'])

# --------------- Main handler ------------------

def lambda_handler(event, context):
    print("event.session.application.applicationId=" +
          event['session']['application']['applicationId'])

    if event['session']['new']:
        on_session_started({'requestId': event['request']['requestId']},
                           event['session'])

    if event['request']['type'] == "LaunchRequest":
        return on_launch(event['request'], event['session'])
    elif event['request']['type'] == "IntentRequest":
        return on_intent(event['request'], event['session'])
    elif event['request']['type'] == "SessionEndedRequest":
        return on_session_ended(event['request'], event['session'])

 主に変更したのは get_order() メソッドです。

def get_order(intent, session):
    session_attributes = {}

    slots = intent['slots']
    
    if 'NameA' not in slots or 'NameB' not in slots or 'NameC' not in slots or 'NameD' not in slots:
        speech_output = '名前がわかりませんでした。もう一度言ってください。'
        reprompt_text = speech_output
        should_end_session = False

        return build_response(session_attributes, build_speechlet_response_without_card(
            speech_output, reprompt_text, should_end_session))

    else:
        reprompt_text = None
        should_end_session = True
        card_title = "順番を決めました"
        names = []

        if 'value' in intent['slots']['NameA']:
            names.append(intent['slots']['NameA']['value'])
        if 'value' in intent['slots']['NameB']:
            names.append(intent['slots']['NameB']['value'])
        if 'value' in intent['slots']['NameC']:
            names.append(intent['slots']['NameC']['value'])
        if 'value' in intent['slots']['NameD']:
            names.append(intent['slots']['NameD']['value'])

        speech_output = decide_order(names)

        return build_response(session_attributes, build_speechlet_response_with_card(
            card_title, speech_output, reprompt_text, should_end_session))

 Lambda ファンクションに送信される JSON の内容で、スロットに関する部分を抜粋すると下記のような形になります。

{
  "request": {
    "type": "IntentRequest",
    "requestId": "EdwRequestId.2ea90888-5a5f-4fa1-982d-xxxxxxxxxxxx",
    "intent": {
      "name": "DecideOrderIntent",
      "slots": {
        "NameC": {
          "name": "NameC",
          "value": "じろう"
        },
        "NameD": {
          "name": "NameD"
        },
        "NameA": {
          "name": "NameA",
          "value": "たろう"
        },
        "NameB": {
          "name": "NameB",
          "value": "はなこ"
        }
      }
    },
    "locale": "ja-JP",
    "timestamp": "2017-11-23T07:30:42Z"
  },
}

 get_order() メソッドには intent 以下を渡していますので、そこから slots を取り出し、NameA から NameD までの値を取り出しています。

 今回は対象者数は可変なので、値が設定されていないスロットもありますが、その場合にも上記サンプルの NameD のように、 value の項目がないだけで、 name の項目は入った状態で渡されますので、NameA から NameD のいずれかのキー自体が存在しない場合には、名前を読み取れていないとしてもう一度入力を促すようにしています。

 全てのキーがあった場合は、その中の value のキーがある項目の値を対象者リストに追加して、 decide_order() メソッドに渡しています。

 decide_order() メソッドでは渡された対象者リストをシャッフルしてレスポンスを返しています。

def decide_order(names):
    shuffle(names)
    return "今回は、" + '、'.join(names) + "の順番です。"

 また、ユーザからの対象者名の入力を受け付けるために、スキルの起動リクエストだけを受け取った場合にはユーザに対象者名の入力を促すようにし、セッションを継続するようにしています。

def get_welcome_response():
    session_attributes = {}
    speech_output = '対象の名前を四人まで言ってください'
    reprompt_text = '聞き取れませんでした。' + speech_output

    should_end_session = False
    return build_response(session_attributes, build_speechlet_response_without_card(
        speech_output, reprompt_text, should_end_session))

 そのほかにも AMAZON.HelpIntent を受け取った時の説明内容や、Alexa アプリに表示されるカードの内容などを少し整理しています。

動作確認

 ここまででひとまず実装としては完了なので、動作確認をしてみます。前回の記事でやったようにシミュレータを使ってテストをすることもできますが、Amazon Echo などの実機があれば、実機でテストをすることも可能です。Alexa の管理画面もしくは Alexa アプリでスキルストアにアクセスし、右上の '''Your Skills''' をクリックします。

f:id:akanuma-hiroaki:20171123170025p:plain

 すると追加済みのスキル一覧ページが表示されますので、その中に開発中のスキルが表示されて入れば実機でのテストが可能な状態です。

f:id:akanuma-hiroaki:20171123165944p:plain

 この状態で例えば下記のようなやりとりができれば想定通り実装できています。

ユーザ 「アレクサ、順番決めを開いて」 アレクサ 「対象の名前を四人まで言ってください。」 ユーザ「太郎と花子と二郎」 アレクサ「今回の順番は、花子、二郎、一郎です。」

まとめ

 今回はユーザからの入力内容を扱うようにしたとはいえまだまだシンプルな内容です。それでもやはり固定の処理を行うだけの時と比べると、正常ケースだけでも複数のパターンを考慮する必要がありますし、エラーケースや中断のケースなども含めるとかなり考えることが増えています。また、Webやスマートフォンアプリと違い、入力内容(発話内容)を正しく解釈できるかどうかという点も不安定さはありますので、インタフェース設計は音声入力の特徴をよく考慮した上で設計することが重要と感じました。