LINEからのBedrockエージェント呼び出し② 音声で答えてもらう

前回作った以下のAgentアプリケーションについて、せっかくなので回答内容を音声でしゃべってもらい、偽クサっぽくしてみます。

https://qiita.com/cyberBOSE/items/4a09d4593b44bb5ac49f

乗換案内だけをしゃべらせるのもアレなので、今日のニュースも読み上げられるようにしてみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3573242/e304b63f-383e-0129-4763-a268246a84fe.png)

Agentにニュースを取得するAction groupを追加します。
Agentの生成結果をPollyに渡し音声を生成してmp3ファイルを生成、S3 Express One Zoneに書き込んで署名付きURLを発行してLINEに返却します。

以下のテキストを音声に変更する感じです。

これが
<img width="30%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3573242/cd35ba87-ff5b-76fc-1133-9cca7715a3dc.jpeg">　<img width="30%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3573242/e921d3ff-3212-0db7-204d-1a286cd635bf.jpeg">
↓こうなります
<img width="30%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3573242/124d4c45-9d01-cc6c-1174-0c952be72552.jpeg">

LINEのスクショだと音声部分が伝わらないので、スクショのケースで返ってきた音声を以下に上げておきました。

https://youtube.com/playlist?list=PLLRaJ3hfljROVuIXHjNT4bAJ0PGnIC6S3&si=D6RtPKauTkDZv4_I

# ニュース取得用Lambda関数の作成

固定でNHKの主要ニュースのRSSフィードを取得し、リンク先の記事を1つずつ開いて上の方のサマリ部分を引っこ抜いて詰め込んで返します。
Lambdaレイヤーとしてfeedparserとrequestsとbeautifulsoup4を追加しておきます。

```Python
import feedparser
import requests
from bs4 import BeautifulSoup

def lambda_handler(event, context):

	RSS_URL = 'https://www.nhk.or.jp/rss/news/cat0.xml'
    
	feeds = feedparser.parse(RSS_URL)
	response_array=[]
	for entry in feeds.entries:
		r = requests.get(entry.link)
		soup = BeautifulSoup(r.content, 'html.parser')
		rs = str(soup.select('#main > article.module.module--detail--v3 > section > section > div > p'))
		response_array.append(rs)
    
	# agentの形式でreturn
	response_body = {"application/json": {"body": response_array}}
	action_response = {
		"actionGroup": event["actionGroup"],
		"apiPath": event["apiPath"],
		"httpMethod": event["httpMethod"],
		"httpStatusCode": 200,
		"responseBody": response_body,
	}
	api_response = {"messageVersion": "1.0", "response": action_response}
	return api_response
```
タイムアウトを1分ほどにしつつ、リソースベースのポリシーを前回同様に設定します。

# Agentの設定変更

`Working draft`から、AgentへのInstructionを更新します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3573242/74a891a0-e389-49d8-786c-5eca7b7323e5.png)

ニュースに対する生成指示を追加すると共に、後でPollyで読み上げるので、読み上げやすい文章で回答するような指示も追加します。
これでは未だ若干挙動不審ですが…

```
ユーザーからの質問に対して回答します。現在日時が必要な場合は<question>のSystem:から取得します。
Functionを呼び出す必要が無い場合は知っている知識で回答します。知識では回答出来ないものはWeb上の情報を検索するFunctionを呼び出して回答します。

言語の指定が無い場合は日本語で回答します。実行後に音声読み上げツールで読み上げるので、読み上げやすい文章で回答してください。読み上げやすいように箇条書き等は使わないようにしてください。読み上げやすいように文の区切りの句読点は入れてください。

以下はFunction毎の指示です。

乗換案内を回答する際は、時刻、路線、乗換駅、料金、所要時間を含めてください。「→」の手前にある「行」という文字は、電車の行先駅を示しているので、もし回答に含める場合は、読み上げやすいように「行き」という文字に置き換えてください。

ニュースを回答する際は、ニュースとニュースの間に「最初のニュースです」「次のニュースです」のように付与してください。
```
`Sage and exit`で保存します。

## Action groupの追加

次にニュースのLambdaを呼び出せるように、Action groupに`Add`します。
先ほど作成したLambda関数を指定して、OpenAPIスキーマは以下の様にします。

```YAML
openapi: 3.0.0
info:
  title: Lambda
  version: 1.0.0
paths:
  /search:
    get:
      summary: news
      description: ニュースを配列形式で返却します
      operationId: search
      responses:
        "200":
          description: 取得成功
          content:
            application/json:
              schema:
                type: object
                properties:
                  body:
                    type: string
```
`Add`で反映し、`Prepare`しておきます。
（Prepareの要否は良く分かってません）

## Agent Aliasの作成

Agentの選択直後の画面まで戻り、Create Aliasを実行します。
名前を適当に入れて、Create Aliasで反映します。

画面最下部のAliasesに反映されるので、Alias IDをメモしておきます。
これでAgentの設定変更は完了です。

# Polly出力用S3バケットの作成

S3 Express One Zoneを使用するので、ディレクトリバケットを作成します。
バケット名をメモしておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3573242/18a73759-8edf-0cd6-2bd2-c8bd560fe438.png)

# Agentを実行するLambda関数の更新

前回作成したLambda関数を以下で更新します。
mutagenのLambdaレイヤーを追加しておきます。

```Python
import os
import json
import boto3
import base64
import hashlib
import hmac
from linebot import LineBotApi
from linebot.models import TextSendMessage, AudioSendMessage
import datetime
from mutagen.mp3 import MP3

def lambda_handler(event, context):
    
    # LINE用 初期処理
    LINE_CHANNEL_ACCESS_TOKEN = os.environ['LINE_CHANNEL_ACCESS_TOKEN']
    CHANNEL_SECRET = os.environ['LINE_CHANNEL_SECRET']
    LINE_BOT_API = LineBotApi(LINE_CHANNEL_ACCESS_TOKEN)

    # LINEからの正規リクエスト以外は終了
    x_line_signature = event["headers"].get("x-line-signature") or event["headers"].get("X-Line-Signature")
    req_body_str = event["body"]
    hash = hmac.new(CHANNEL_SECRET.encode('utf-8'),
        req_body_str.encode('utf-8'), hashlib.sha256).digest()
    signature = base64.b64encode(hash)
    if signature != x_line_signature.encode(): raise Exception

    # リクエストの取得
    req_body = json.loads(event['body'])

    # Agentのsession_idとしてLINEのUserIdを使用する
    session_id = req_body['events'][0]['source']['userId']
    
    # LINEからのメッセージを受信
    if req_body['events'][0]['type'] == 'message':
        if req_body['events'][0]['message']['type'] == 'text':
            line_message = req_body['events'][0]['message']['text']
            
            # Agentの定義
            agent_id =  os.environ['AGENT_ID']
            agent_alias_id =  os.environ['AGENT_ALIAS_ID']

            # Agent用Client
            agent_client = boto3.client("bedrock-agent-runtime")

            # 現在日時（の取得（ISO8601形式））
            now = str(datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=9))).isoformat())

            # LINEからのメッセージに現在日時をくっ付けてAgent呼出し
            query = "System: 現在の日時は "+now+"（ISO8601形式）です。現在日付や現在時刻が必要な場合は左記から取得してください。もしチャット履歴に対して質問された場合は、現在時刻に対してではなくその前のチャット履歴に対して回答してください。\n\nHuman: "+line_message

            # Agentの定義
            response = agent_client.invoke_agent(
                inputText=query,
                agentId=agent_id,
                agentAliasId=agent_alias_id,
                sessionId=session_id,
                enableTrace=False
            )

            # Agentの実行
            answer = ''
            event_stream = response['completion']
            for event in event_stream:        
                if 'chunk' in event:
                    answer += event['chunk']['bytes'].decode("utf-8")

            # Polly用バケットの定義
            bucket_name =  os.environ['OUTPUT_BUCKET_NAME']
            s3 = boto3.resource('s3')
            bucket = s3.Bucket(bucket_name)

            # Pollyの定義
            polly = boto3.client("polly")
            response = polly.synthesize_speech(Text=answer,
                Engine="neural",
                OutputFormat="mp3",
                VoiceId="Tomoko")
                
            # 一時保存用mp3ファイル
            tmp_mp3_filename = '/tmp/speech.mp3'

            # Pollyの実行と/tmpへの保存（長さ取得用に一旦/tmpに保存）
            file = open(tmp_mp3_filename, 'wb')
            file.write(response['AudioStream'].read())
            file.close()
            
            # 長さ（秒）の取得
            audio = MP3(tmp_mp3_filename)
            length = audio.info.length
            
            # mp3ファイルをS3にアップロードし署名付きURL（有効時間10分）を発行
            # 一次的なのでExpress One Zoneにしてみる
            # S3に配置するファイル名はタイムスタンプを使っているので完全なユニークではない（単に手抜き）
            s3 = boto3.client("s3")
            args = {'StorageClass': 'EXPRESS_ONEZONE'}
            s3.upload_file(tmp_mp3_filename, bucket_name, now+'.mp3', ExtraArgs=args)
            presigned_url = s3.generate_presigned_url('get_object', Params={'Bucket': bucket_name, 'Key': now+".mp3"}, ExpiresIn=600 )

            # Agentの回答をLINEに返却
            replyToken = req_body['events'][0]['replyToken']
            
            # テキストで返却する場合
            # LINE_BOT_API.reply_message(replyToken, TextSendMessage(text=answer))

            # 音声で返却
            LINE_BOT_API.reply_message(replyToken, AudioSendMessage(original_content_url=presigned_url, duration=length*1000))
            
    return {'statusCode': 200, 'body': json.dumps('Reply ended normally.')}
```

環境変数`AGENT_ALIAS_ID`を新たに作成したAliasのIDで更新します。
また、環境変数`OUTPUT_BUCKET_NAME`として先ほど作成したS3バケット名を追加します。

# 応答時間

処理が多いのでまあまあかかってこんな感じです。
これを遅いと見るか、意外と遅くないと見るかはどうでしょうね。

- 単純な挨拶 … 10秒程度
- 乗換案内 … 20秒程度
- 今日のニュース … 20秒～30秒程度

ちなみに前回の作成時にセッションタイムアウトを1分にしてあるので、上記＋音声読み上げ時間を加えると、会話している途中で記憶を失う人みたいになります。会話を続けるbotとして扱う場合はセッションタイムアウト時間を延ばした方が良いかもしれません。

以上で完成ですが、やや挙動不審（動作が不定）なので上手く動かない時は裏でエラーが出ているかもしれません。
音声で回答するようにしたのでちょっとアレクサっぽくなりました。

乗り換え案内とニュース程度ならオフザケの域を出ませんが、その気になれば「昨日のxx会議の議事録を要約して読み上げて」もらい、通勤中に聞くみたいな事もやってやれない事は無さそうです（LINEでやるかはともかく）。

（もうちょっとだけ続くんか？）

https://qiita.com/cyberBOSE/items/fe66c2049aaa04568d61