AlexaでBedrockに質問をするスキル開発

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

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

Alexaとのやりとりのイメージは前回からの続きで、以下のような形を目指します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918398/3292d12f-a089-441f-688d-9974c7d79d31.png)

# 必要となる知識
## Alexaによる自由発話
今回、ユーザーからの自由な質問を受けることから、自由発話をAlexaで受け取る必要があります。
しかしながらAlexaは基本的には自由発話は想定しておらず、スロットのAMAZON.SearchQueryではキャリアフレーズと呼ばれる、文章の中に特定のキーワードを含むことが必要です。
そのため自由発話を受け取るためには少し工夫が必要になります。

Alexaで自由発話の受け取る方法についてはこれまで様々な議論が上がっておりましたが、以下のブログにあるように、ダイアログのデリケート(後述)を活用することで取得することが望ましいようです。

https://zenn.dev/kun432/scraps/904ebc3874e0df

## Alexa ダイアログのデリケート
オートデリケートとはインテントの処理を呼び出す前に、Alexa側でスロットの値が入っているのかを確かめるための機能です。値が入っていない場合にはあらかじめ設定して置いたフレーズをユーザーに返し、値を取得してからまとめてインテントの処理に送っていきます。

https://dev.classmethod.jp/articles/alexa-dialog-with-auto-delegate/

通常では以下のようなユースケースで効果を発揮します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918398/3ea6c42d-3779-8b96-8d06-bb7981a19b80.png)


通常の使い方とはやや異なりますが、本記事に記載するアプリでもこの機能を活用します。
質問を投げかけるインテント処理の中で、質問処理を行うインテントのダイアログを展開し、スロット値を受け入れる状態にしておきます。（通常ユースケースで言うところの「いくついりますか？」の状態）
その状態でユーザーからの発話を受けることで、ユーザーの発話を全て取得することが可能になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918398/4f71fdd1-2005-feed-e177-c41c491dc4c7.png)

# 開発
## Alexaインテントの追加
**インテントの作成**
GUIからインテントを作っていきます。
※ JSONからも可能ですが、後続のダイアログの部分で自動でidが割り振られるため、GUIでの設定をお勧めします
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918398/8c8980c8-4c1b-4b87-2a5c-12ff23e47a36.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918398/fb111e19-4744-305a-377b-40b894cb7f16.png)

**スロットの作成**
スロット名は``question``, スロットタイプには``AMAZON.SearchQuery``を選択します。設定が完了したらダイアログの編集をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918398/adb27ecd-ae63-d8b4-45d6-1de38b593aeb.png)

**ダイアログの編集**
スロット入力をONにします。これをONにすることでオートデリケート機能を使うことができます。
Alexaの音声プロンプトは前述通常ケースの「いくついりますか？」に該当する部分ですが、インテント内から直接呼び出す場合にはこちらは作動しません。設定しないとエラーが出てしまうため、形式上設定しておきます。
ユーザーの発話には``{question}``というようにスロット名のみを入れておきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918398/78ba794f-e08f-3e2d-a2b7-46442c5b9b11.png)

## AWS Lambdaの構築
※前回の記事と重複する部分は一部省略

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


**Lambdaコードのデプロイ**

<details><summary>lambda_function.py</summary><div>

```python:lambda_function.py
# myfile
import dynamo
import bedrock
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 = False
        
        # 特定のスロットの値を訪ねる
        # https://developer.amazon.com/en-US/docs/alexa/custom-skills/dialog-interface-reference.html#elicitslot
        directive = ElicitSlotDirective(
                slot_to_elicit="question",
                updated_intent = Intent(
                    name = "QuestionIntent",
                    confirmation_status = IntentConfirmationStatus.NONE,
                    slots ={
                        "question": Slot(name= "question", value = "", confirmation_status = SlotConfirmationStatus.NONE)
                    }
                )
            )
            
            
        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("QuestionIntent"))
def news_topic_intent_handler(handler_input):
    # ユーザーから得た質問文の取得
    question = get_slot_value(handler_input=handler_input, slot_name="question")
    
    # セッションスコープから指定番号のトピックを取得
    attr = handler_input.attributes_manager.session_attributes
    index = int(attr["topic_number"]) - 1
    topic = attr["topics"][index]
    
    # bedrockに質問をリクエストして回答を得る
    answer = bedrock.ask_bedrock(topic, question)
    
    speech_text = answer + "こちらで終了します。"
    end_session = True
    
    handler_input.response_builder.speak(speech_text).set_card(
        SimpleCard("Fortune Telling", speech_text)).set_should_end_session(end_session)
    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>bedrock.py</summary><div>

```python:bedrock.py
import boto3
import json

# Amazon Bedrock
bedrock = boto3.client('bedrock-runtime')

def ask_bedrock(topic, question):
    link = topic["link"]
    
    text = "Human: " + link + " \n上記のリンクを学習してください。学習した情報をもとに以下の質問に300文字程度で答えてください。\n「" + question + "」\nAssistant:"
    print(text)
    
    body = json.dumps({
        "prompt": text,
        "max_tokens_to_sample": 2014,
        "temperature": 0.1,
        "top_p": 0.9,
    })
    
    modelId = 'anthropic.claude-instant-v1'
    accept = 'application/json'
    contentType = 'application/json'

    response = bedrock.invoke_model(body=body, modelId=modelId, accept=accept, contentType=contentType)
    answer = json.loads(response.get('body').read())["completion"]
    
    print(answer)

    #回答にハイフンが含まれることがあり、Alexaが読んでしまうため削除。
    return answer.replace('-', '')

```

</div></details>

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

**ダイアログの呼び出し**
以下のコードを使用して前段のインテント(NewsTopicIntent)で処理インテント(QuestionIntent)のスロットを呼び出しています。

https://developer.amazon.com/en-US/docs/alexa/custom-skills/dialog-interface-reference.html#elicitslot

```python
@sb.request_handler(can_handle_func=is_intent_name("NewsTopicIntent"))
def news_topic_intent_handler(handler_input):
    :
    directive = ElicitSlotDirective(
        slot_to_elicit="question",
        updated_intent = Intent(
            name = "QuestionIntent",
            confirmation_status = IntentConfirmationStatus.NONE,
            slots ={
                "question": Slot(name= "question", value = "", confirmation_status = SlotConfirmationStatus.NONE)
            }
        )
    )
    :

```

**Bedrock呼び出し**
boto3のリファレンスに従って、bedrockのモデルを実行しています。ただしlambdaに入っているboto3ではbedrockのAPIに対応していないため、Lambdaレイヤーとして別途導入する必要があるので注意。

https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/bedrock.html

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/lambda-runtimes.html

</div></details>


**ロール設定**
以下のポリシーを追加します。
- Bedrockのモデル実行用ポリシー
```json:bedrock-policy
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": "bedrock:InvokeModel",
            "Resource": "*"
        }
    ]
}
```

## Amazon Bedrock
**使用するモデル**
東京リージョンでも使用可能な``Claude Instant v1.2``を使用しました。初めて使う際には有効化申請が必要になります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918398/e2942b35-3c25-c659-bbcd-57e0e08cb1ee.png)


**有効化**
モデルアクセスから使いたいモデルを有効化します。Claudeモデルは初回に利用用途等のアンケートがあるので、それに回答をしてsumbitします。しばらくすると承認されてAccess Generatedになれば完了です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918398/513cba02-a1f8-1677-7fdc-1eafc9d8b0b1.png)


**コンソールから実行**
TEXTからモデルを検証することができます。Lambdaから送信するメッセージをプロンプト経由でチューニングできます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918398/a4bd1647-4c11-28d4-26d5-7f037c3d89c1.png)


# おわりに
以上でユーザーからの自由な発話をBedrockに送り、回答を得ることができました。
Alexaからbedrockが呼び出せるようになるとなかなか用途が広がりそうです。