Self hosted AWS LambdaによるAlexaスキル開発

# はじめに
本記事は
- [Alexa×BedrockでAWS最新ニュースキャッチアップアプリ コンセプト・設計](https://qiita.com/SawaShuya/private/a22645e54d75667c5148)
- [AWS Lambdaで自作RSSリーダー開発](https://qiita.com/SawaShuya/private/aa007d310bfd12c751af)
- **Self hosted AWS LambdaによるAlexaスキル開発 (本記事)**
- [AlexaでBedrockに質問をするスキル開発](https://qiita.com/SawaShuya/private/46c47068dd2573359c8b)

の連載記事です。前段の項目が完了していることを前提として書かれております。
本記事では以下のアーキテクチャの作成を目指して解説をしていきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918398/2062f960-382e-3b15-9628-9b4d6c668e41.png)

Alexaとのやりとりのイメージは以下のような形を目指します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918398/92e421a7-6ae5-733c-1b0b-78a7214e4a75.png)

内容としては以下になりますので、部分的に活用することも可能です。

- Alexa開発の基本
- Self hosted LambdaによるAlexa開発
- DynamoDBのデータスキャン

# Alexa開発の基本
## 会話の流れと基本用語
### Step1 呼び出し
Alexaの機能は"スキル"といった単位で定義されます。Alexaにスキルを認識させるには、呼び出しフレーズを伝える必要があります。呼び出しフレーズを参照し、Alexaは適切なスキルを呼び出します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918398/b9d723fa-ded0-7239-501e-ab3b71e97ad9.png)

### Step2 スキル内でユーザーからの発話受付
スキルが呼び出されると、基本的にはユーザーからの発話を受け付けます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918398/553c01f1-36d1-3de2-2fb3-8f7643641084.png)


**サンプル発話**
ユーザからの発話を予測してあらかじめいくつかの例を設定しておきます。
基本的にサンプル発話：インテントは多：1の関係性であり、どの発話に引っかけるのかをうまく調整することが重要です。
サンプル発話の設定時には、どの部分が後述のスロットになるのかも併せて設定をしていくことになります。

**スロット**
ユーザーからの発話に対して、一部をスロットとして変数に格納します。
スロットはカスタムで設定することもできますが、Amazonが既に用意しているはスロットも数多くあり、これらを活用することで素早く設定をすることができます。

https://developer.amazon.com/ja-JP/docs/alexa/custom-skills/slot-type-reference.html

例えば地名を使いたい場合には``AMAZON.City``、数値のみを取得したい場合には``AMAZON.NUMBER``を使用することができます。

**インテント**
実際の処理を担います。Lambdaを使用して、実際に処理したいことを記載していきます。
デフォルトでもAmazonの組み込みのインテントもあり、それらを活用することも可能です。

※今回はイメージとしてインテントを分けていますが、サンプル発話やスロットもインテント設定の一部でもあります

これら上記の3セットは一つのコンポーネントとして定義され、これらを複数準備しておくことであらゆる発話に対して柔軟に処理を行うことができます。GUIから一つずつ設定することも可能であり、JSON形式で一括で定義することも可能です。


### Step3 継続した会話
インテントでセッションを有効にすることで会話を継続することができます。また、セッション中にのみ使える変数としてセッションアトリビュートを使うことで、セッションで共通した変数を使用することが可能になります。


## 開発の進め方
基本的にはAlexaのDeveloperコンソールからスキルを作成していきます。

https://developer.amazon.com/ja-JP/docs/alexa/devconsole/about-the-developer-console.html

インテントについてはAWS Lambdaで処理を書いていきます。
Lambdaのプロビジョニングの方法には2種類あり、ユースケースに合わせて選択することができます。

|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;| Alexa-hosted | 独自のプロビジョニング |
| :-- | :-- | :-- |
| メリット | <ul><li>Alexa側でLambdaとDynamoDB1テーブルまで管理をしてくれる。</li><li>Alexa Developerコンソールからコードを修正することが可能</li><li>一つのコードで完結する場合にお手軽</li></ul> | <ul><li>独自のAWSアカウントのLambdaを使用することが可能</li><li>自由な設定が可能</li><li>他AWSサービスを使用したい場合に便利</li></ul> |
| デメリット |<ul><li>使用量に制限がある</li><li>リージョンに制限がある</li><li>他のAWSサービスを使用する場合には別途セキュリティやネットワーク設計を行う必要がある</li></ul> |<ul><li>AWSアカウントが必要</li><li>ユーザーがリソースを管理（lambdaであれば基本的にはAWS管理になるので、特に負荷はないです）</li>|

本アプリでは他AWSソリューションとの連携を行うことから、独自プロビジョニングを行います。

# 開発
## Alexaスキルの新規作成
[AlexaのDeveloperコンソール](https://developer.amazon.com/alexa/console/ask)にアクセスをしてスキルを作成していきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918398/822e4222-d493-a6c4-fa9b-6b691b654277.png)

**名前・言語設定**
必要な名前と言語を選択します。また、コンソール自体を日本語化したい場合には左下から言語選択も可能です。スキル作成後も変更可能です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918398/18fd4bfb-c0dd-800b-16a5-4a2dab5a3c98.png)


**エクスペリエンス、モデル、ホスティングサービス設定**
インテントの設定をしていきます。
エクスペリエンスを選択すると、既にAWSによって組み込まれたモデルを選択することが可能ですが、今回はカスタムモデルを使用するので、どれを選択しても問題ないです。
モデルをカスタムとし、ホスティングサービスとして独自のプロビジョニングを設定します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918398/4285afb1-4c6a-faab-6151-fe7d1f67a15d.png)


**テンプレート選択**
今回はテンプレートを使用しないので、どれを選択しても問題ないです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918398/4b607cb3-3efd-9c13-19b6-de4c37e8e059.png)

**審査**
設定の問題がないか確認をしてスキルを作成ボタンをクリックします。
数分経つとスキルが作成されます。

## Alexaインテントの作成
GUIからでもインテントの設定は可能ですが、今回はJSONで記述をしていきます。
（GUIで設定したものもJSONに反映されます）

ナビゲーションバーからJSONエディターを開きます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918398/ab2e94a5-b14d-f547-0167-e1469b7af591.png)

以下のJSONを配置します

```json:Intent json
{
    "interactionModel": {
        "languageModel": {
            "invocationName": "レポートくん",
            "intents": [
                {
                    "name": "AMAZON.CancelIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.HelpIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.StopIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.NavigateHomeIntent",
                    "samples": []
                },
                {
                    "name": "NewsTopicIntent",
                    "slots": [
                        {
                            "name": "topic_number",
                            "type": "AMAZON.NUMBER"
                        }
                    ],
                    "samples": [
                        "{topic_number} 番",
                        "{topic_number} 番目",
                        "{topic_number} こ目",
                        "{topic_number}"
                    ]
                }
            ],
            "types": []
        }
    }
}

```

<details><summary>インテントの解説</summary><div>

**invocationName**
``invocationName``は呼び出し名になります。スキルを呼び出すときの名前をここで設定可能です。ここの値を修正すると、スキル構築時に指定したスキル名も変化します

**追加したインテント**
追加したインテントは``NewsTopicIntent``です。その他はAlexaにデフォルトで備わているものです。

スロットとして``AMAZON.NUMBER``を受け付けています。``AMAZON.NUMBER``はAlexaに備わるマネージドのスロットです。発話の中の数字を含む項目に対して作動し、数値のみを取得してインテントに渡すことができます。

今回はユーザーから「○○番目！」や「○○こ目！」という返事を想定しているので、サンプルにそれらの発話を登録をしています。

これらの設定により、ユーザーからの「○○番目！」という発話に対して``NewsTopicIntent``を起動することができ、数値の部分のみをパラメータとしてインテントに渡すことが可能になります。


</div></details>



## AWS Lambdaの構築
**関数の作成**
通常のLambdaと同様に関数を作成していきます。
SDKの都合上ランタイムはPythonとNode.jsから選択していきます。本記事ではPython3.10を使用しています。

**Lambdaレイヤー**
- ask_sdk_core
    - Alexa SDK for Python
- urllib3<2
    - botocoreがurllib3 v2.0以降に対応していないためバージョンを抑えたurllib3の導入が必要
    - [参考](https://qiita.com/HLHHS11/items/c26f1632a141a1dcc7a7)


**Lambdaコードのデプロイ**
それぞれ機能によってファイルを分離して、以下の3ファイルを使用しました。

<details><summary>lambda_function.py (インテントの設定)</summary><div>

```python:lambda_function.py
# myfile
import dynamo
import speech_text_util

# library
from ask_sdk_core.handler_input import HandlerInput
from ask_sdk_core.skill_builder import SkillBuilder
from ask_sdk_core.utils import get_slot_value, is_intent_name, is_request_type
from ask_sdk_model import Response
from ask_sdk_model.ui import SimpleCard
from ask_sdk_model.dialog import ElicitSlotDirective
from ask_sdk_model import (Intent , IntentConfirmationStatus, Slot, SlotConfirmationStatus)

sb = SkillBuilder()


@sb.request_handler(can_handle_func=is_request_type("LaunchRequest"))
def launch_request_handler(handler_input):
    # type: (HandlerInput) -> Response
    speech_text = "本日の最新ニュースをお知らせします。"
    
    topics = dynamo.get_topics()
    count = len(topics)
    
    if count == 0:
        speech_text = "本日のトピックはありませんでした。"
    else:
        # セッションスコープとして値を格納
        attr = handler_input.attributes_manager.session_attributes
        attr["topics"] = topics
        
        # 返答の生成
        topic_titles = speech_text_util.get_topic_titles(topics)
        speech_text = speech_text + "本日は" + str(count) + "件のニュースがあります。\n" + topic_titles + "何番目のトピックを知りたいですか。"
    

    handler_input.response_builder.speak(speech_text).set_card(
        SimpleCard("AWS News", speech_text)).set_should_end_session(
        False)
    return handler_input.response_builder.response



@sb.request_handler(can_handle_func=is_intent_name("NewsTopicIntent"))
def news_topic_intent_handler(handler_input):
    # ユーザーから得た指定番号を取得
    topic_number = int(get_slot_value(handler_input=handler_input, slot_name="topic_number"))
    
    # セッションスコープにユーザーからの指定番号を格納
    attr = handler_input.attributes_manager.session_attributes
    topics_count = len(attr["topics"])
    
    if topic_number <= topics_count:
        attr["topic_number"] = topic_number
        
        # 指定番号の"description"を取得
        description = speech_text_util.get_description(topic_number, attr["topics"])
    
        # 返答の生成
        speech_text = str(topic_number) + "番ですね。" + description
        end_session = True
        
        handler_input.response_builder.speak(speech_text).add_directive(directive)
    else:
        speech_text = str(topic_number) + "番は範囲外です。" + str(topics_count) + "以下の番号で指定してください。"
        handler_input.response_builder.speak(speech_text)
    
    return handler_input.response_builder.response
    

@sb.request_handler(can_handle_func=is_intent_name("AMAZON.HelpIntent"))
def help_intent_handler(handler_input):
    # type: (HandlerInput) -> Response
    speech_text = "こんにちは。と言ってみてください。"

    handler_input.response_builder.speak(speech_text).ask(speech_text).set_card(
        SimpleCard("Fortune Telling", speech_text))
    return handler_input.response_builder.response


@sb.request_handler(
    can_handle_func=lambda handler_input:
        is_intent_name("AMAZON.CancelIntent")(handler_input) or
        is_intent_name("AMAZON.StopIntent")(handler_input))
def cancel_and_stop_intent_handler(handler_input):
    # type: (HandlerInput) -> Response
    speech_text = "またいつでも聞いてください"
    end_session = True

    handler_input.response_builder.speak(speech_text).set_card(
        SimpleCard("レポートくん", speech_text)).set_should_end_session(end_session)
    return handler_input.response_builder.response


@sb.request_handler(can_handle_func=is_request_type("SessionEndedRequest"))
def session_ended_request_handler(handler_input):
    # type: (HandlerInput) -> Response

    return handler_input.response_builder.response


@sb.exception_handler(can_handle_func=lambda i, e: True)
def all_exception_handler(handler_input, exception):
    # type: (HandlerInput, Exception) -> Response
    print(exception)

    speech = "すみません、わかりませんでした。もう一度言ってください。"
    handler_input.response_builder.speak(speech).ask(speech)
    return handler_input.response_builder.response

lambda_handler = sb.lambda_handler()

```
</div></details>

<details><summary>dynamo.py (DynamoDB関連の処理)</summary><div>

```python:dynamo.py
import boto3
import json
import datetime
from dateutil import tz

# date
timezone = tz.gettz('Asia/Tokyo')
today = datetime.datetime.now(timezone).replace(hour=0,minute=0,second=0,microsecond=0)
yesterday = today + datetime.timedelta(days=-1)

# DynamoDb
dynamodb = boto3.client('dynamodb')
TABLE_NAME = 'rss_feeds'
COLUMS = ["link", "ja_title", "ja_description"]

def get_topics():
    print("Get topics from : " + yesterday.isoformat())
    
    options = {
        'TableName': TABLE_NAME,
        'ScanFilter': {
            'published':{
                'AttributeValueList': [
                    {'S': yesterday.isoformat()}
                ],
                'ComparisonOperator': 'GE'
            }
        }
    }
    # スキャン
    response = dynamodb.scan(**options)
    
    items = response['Items']
    print("Items Count : " + str(len(items)))
    
    topics = []
    for item in items:
        topic = {}
        for column in COLUMS:

            topic[column] = item[column]["S"]
        topics.append(topic)
    return topics

```

</div></details>

<details><summary>speech_text_util.py (Alexaからの返答作成関連)</summary><div>

```python:speech_text_util.py
# トピック一覧からタイトルを取得
def get_topic_titles(topics):
    text = ""
    for i, topic in enumerate(topics):
        text = text + str(i+1) + ". " + topic["ja_title"] + "。\n"
    
    return text
    
# トピック番号から要約を取得
def get_description(number, topics):
    index = number - 1
    description = topics[index]['ja_description']
    return description

```

</div></details>


<details><summary>コード解説</summary><div>

**実装スタイルについて**
インテントの処理を記述する方法は2通りあります。本記事ではデコーダータイプを使用して記述をしています。
- [ハンドラークラス](https://alexa-skills-kit-python-sdk.readthedocs.io/ja/latest/DEVELOPING_YOUR_FIRST_SKILL.html#id6)
- [デコレーター](https://alexa-skills-kit-python-sdk.readthedocs.io/ja/latest/DEVELOPING_YOUR_FIRST_SKILL.html#id14) (本記事で使用)

**LaunchRequest**
LaunchRequestインテントはスキルが呼び出された時に最初に必ず呼び出されるインテントです。
記事冒頭のAlexaやり取りイメージにある通り、このLaunchRequestが呼び出された時に、最新の記事のタイトルを返答するように設定します。したがって、ここの記述でDynamoDBからデータを取得します。
今回対象となるテーブル``rss_feeds``のパーティションキーを``link``にしているため、dynamoDBのスキャンを使用して日本時間の前日00:00以降のデータを取得しています。

※スキャンの場合、全件操作になるのでクエリに比べるとキャパシティを多く消費します。そのため、TTLの設定によって古い情報を削除することでキャパシティ消費を抑えた設計にしています。

https://bbh.bz/2019/07/21/dynamodb-scanapi-queryapi/

また、1回のスキル呼び出しで1回のスキャンに抑えるため、[セッションアトリビュート](https://developer.amazon.com/ja-JP/docs/alexa/custom-skills/manage-skill-session-and-session-attributes.html)を活用しています。これを使用することでセッション中（スキルが開始から終了するまで）に使用可能な変数となり、別のインテントでも変数を使用することができるようになります。

```
# セッションアトリビュートして値を格納
attr = handler_input.attributes_manager.session_attributes
attr["topics"] = topics
```

**NewsTopicIntent**
NewsTopicIntentではユーザーの発話から取得した数値を取得し、その数値と一致するトピックについてRSSから取得した要約文を返します。トピックはセッションアトリビュートに格納された値を参照し、再度DynamoDBをスキャンすることはありません。

</div></details>

**タイムアウト設定**
- Lambdaのデフォルトは3秒であり、今回の処理は3秒を上回る可能性があるため1分程度にする。

**ロール設定**
以下のポリシーを設定する。
- Lambda作成時にデフォルトで作成されるポリシー
- DynamoDBスキャン用ポリシー

```json:dynamodb-scan
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": "dynamodb:Scan",
            "Resource": "arn:aws:dynamodb:<YOUR REGION>:<ACCOUNT ID>:table/rss_feeds"
        }
    ]
}
```

## Alexa - Self hosted Lambdaの接続設定
### Alexa側の設定
コンソールのトップから``4.エンドポイント``を選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918398/9a73d935-b15e-e33e-c387-3776244b4f7f.png)

スキルIDはAlexaスキルの識別子です。こちらはLambda側の設定で使うので、値を控えておきます。
デフォルトの地域には作成したLambdaのARNを入力します。ARNはLambdaのAWSコンソールから確認可能です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918398/ec43bd4f-41c1-3e79-5d85-1de3357f2480.png)



### Lambda側の設定
**トリガ設定**
トリガ設定から``Alexa``を選択します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918398/d4fe3862-9571-dcb0-a0df-553dc34c5cac.png)

AlexaのスキルIDを入力して保存します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918398/39e363d1-9e9c-2ee3-4a83-350b33b0ae45.png)


# 動作確認
## コンソール上でのテスト
コンソールのテストタブからテストをすることが可能です。
音声でもテキストでも可能です。
その時に表示されるInput JSONはLambdaに送信されるJSONになるため、これを参考にLambda側でテストを行うことでスムーズな開発を行う都ができます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918398/f3fd3f58-124d-6d88-bc5a-df7a39fbd96f.png)




## 限定公開で実際のAlexaから呼び出す
実際のアレクサで動作させるには、公開をする必要があります。
全ユーザーへの公開をする場合には、Amazonによる審査が必要になりますが、ベータテストしての限定公開であれば審査がなく実機でのテストをすることができます。

コンソールから公開を開き、必須項目を全て埋めます。画像も必要になりますが、コンソールから作成することもできます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918398/07e4ac78-332e-4e97-bcd5-258d2dd16e73.png)

公開範囲の項目ではベータテストを選択します。ベータテストでは最大500人まで招待をすることが可能です。
以下のフォームに招待するユーザーのEmailを入力します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918398/749f8f44-cf2b-978b-f9a2-cee9ff863c4a.png)

なおベータテストは90日限定になるので、お気を付けください。

https://developer.amazon.com/ja-JP/docs/alexa/custom-skills/skills-beta-testing-for-alexa-skills.html


# おわりに
以上で設定は終了です。
今回はAWSの最新ニュースを知らせてくれるスキルを開発しましたが、ご紹介したの機能を活用することで様々な用途のスキルを開発できるかと思います。ぜひご活用ください。