[Amazon BedrockとSlackで作る生成AIチャットボット] アップロードした画像ファイルを解析してくれるチャットボットを作る

前回に引き続き「画像」を扱える生成AIチャットボットを作ってみました。
2024.05.14

みなさん、こんにちは!
福岡オフィスの青柳です。

以前2回に渡って公開したブログ記事「Amazon BedrockとSlackで生成AIチャットボットアプリを作る」を応用して、「アップロードした画像ファイルを解析してくれるチャットボット」を作りたいと思います。

Slackでチャットボットを作るのは初めてという方は、まずこちらの記事からどうぞ。

今回作成するチャットボット

チャットボットをメンションして、画像ファイルを添付します。

添付した画像についての解説をしてくれました。 (どうやら、福岡県民のローカルソウルフードについての知識は持っていなかったようです)

今度は、画像ファイルを添付するのと併せて、この画像に関する質問も書き込んでみます。

なるほど、新しい発見がありました。

チャットボットを作る

今回作成するチャットボットは、次のようなアーキテクチャです。

以前のブログと全体の構成は同じですが、Slackに添付したファイルを取り扱うために「Slack向けHTTP APIサーバー」「Bedrockを呼び出すバックエンド」両方のLambdaの処理内容を少し変更します。

ベースとなるSlackチャットボットを作る

まず、以前のブログを参考にして、シンプルな「生成AIチャットボット」を作成します。

Slackの設定変更

ここから、このチャットボットを「アップロードした画像ファイルを解析してくれるチャットボット」に進化させるための設定を行なっていきます。

「シンプルな生成AIチャットボット」ではSlackアプリに「応答のテキストを書き込む」ための権限としてchat:writeのスコープを設定していました。

今回は、これに加えて「チャンネルに添付されたファイルをSlackアプリが参照 (ダウンロード) する」ための権限が必要になります。

Slack Appの設定画面で「OAuth & Permissions」を開き、「Bot Token Scopes」にfiles:readのスコープを追加します。

スコープを追加した後、Slack Appの再インストールを忘れないようにしてください。

Lambda関数の更新 (Slack向けHTTP APIサーバー)

Lambda関数を修正します。 まずは、API Gatewayと統合する「Slack向けHTTP APIサーバー」のLambda関数です。

Slackイベントハンドラーhandle_app_mention_eventsのコード内容を中心に、少し変更しています。 (この後、解説します)

なお、冒頭でSlackのトークン・シークレットを環境変数から取得する箇所を少し変えています。 また、「SQSのキューURL」についても環境変数から取得するように変更しました。 (環境変数SQS_QUEUE_URLを設定してください)

from slack_bolt import App
from slack_bolt.adapter.aws_lambda import SlackRequestHandler
import boto3
import json
import os
import re


sqs_queue_url = os.environ.get("SQS_QUEUE_URL")
slack_bot_token = os.environ.get("SLACK_BOT_TOKEN")
slack_signing_secret = os.environ.get("SLACK_SIGNING_SECRET")

sqs = boto3.client("sqs")


# アプリの初期化
app = App(
    token=slack_bot_token,
    signing_secret=slack_signing_secret,
    process_before_response=True,
)


# Slackイベントハンドラー:Slackアプリがメンションされた時
@app.event("app_mention")
def handle_app_mention_events(event, say):
    channel_id = event["channel"]
    input_text = re.sub("<@.+>", "", event["text"]).strip()

    files = event.get("files")
    if files is None:
        say("画像ファイルが添付されていません。")
        return

    file_type = files[0]["mimetype"]
    if file_type not in ["image/jpeg", "image/png", "image/gif", "image/webp"]:
        say("これは画像ファイルではありません。")
        return

    say("少々お待ちください...")
    file_url = files[0]["url_private_download"]
    sqs.send_message(
        QueueUrl=sqs_queue_url,
        MessageBody=json.dumps({
            "channel_id": channel_id,
            "input_text": input_text,
            "file_type": file_type,
            "file_url": file_url,
        }),
    )


# Lambdaイベントハンドラー
def lambda_handler(event, context):
    slack_handler = SlackRequestHandler(app=app)
    return slack_handler.handle(event, context)

Slackから渡されるイベントデータ

Slackアプリがメンションされた時にファイルが添付されている場合、イベントハンドラーの引数eventの中身は以下のようになっています。(一部抜粋)

{
    "type": "app_mention",
    "channel": "Cxxxxxxxxxx",
    "text": "<@Uxxxxxxxxxx> これは何の絵ですか?",
    "files": [
        {
            "name": "image1.jpg",
            "title": "image1.jpg",
            "mimetype": "image/jpeg",
            "url_private_download": "https://files.slack.com/files-pri/XXXXXXXXX-XXXXXXXXXXX/download/image1.jpg",
            (省略)
        }
    ],
    (省略)
}

files[]には、添付されているファイルの情報が配列として格納されています。

添付ファイルのデータは、イベントデータに直接含まれる訳では無く「ダウンロードURL」(url_private_download) の形で情報が格納されています。

また、添付ファイルの形式 (MIMEタイプ) がmimetypeにセットされています。

画像ファイルが添付されているかどうかのチェック

今回のチャットボットが機能するための前提条件は、「画像ファイルが添付されている」ことです。

ですので、条件に合致しないパターンを処理対象から除外しています。

  • そもそもファイルが添付されていない場合 (30-33行目)
  • ファイルは添付されているが「画像ファイル」ではない場合 (35-38行目)

後者の条件について、今回使用する「Claude 3」モデルのVision機能が扱える画像形式が「jpeg」「png」「gif」「webp」の4種類のみであるため、これらの画像形式でない場合は処理対象外としています。

この時点でチャットボットが機能する条件をクリアしましたので、ここで「少々お待ちください」のメッセージを返しています。(40行目)
(元のコードではSlackイベントハンドラーの先頭に記述されていました)

画像ファイルのダウンロードはバックエンドLambdaで行う

イベントデータから画像ファイルのダウンロードURLを取得します。(41行目)

ダウンロードURLを取得しましたが、実際のダウンロードはここでは行いません。 ダウンロードURLをSQSキュー経由で「バックエンドLambda」へ渡して、バックエンドLambda内で画像ファイルのダウンロードを行います。

Lambda関数の更新 (Bedrockを呼び出すバックエンド処理)

次に、「Bedrockを呼び出してSlackへ応答を返す」処理を行うLambda関数です。

こちらは、大幅に変更していますので、この後、詳しく解説します。

from slack_sdk import WebClient
import base64
import boto3
import json
import os
import urllib.request


slack_bot_token = os.environ.get("SLACK_BOT_TOKEN")

bedrock_runtime = boto3.client("bedrock-runtime", region_name="us-east-1")

client = WebClient(token=slack_bot_token)


# Slackから画像をダウンロードする
def download_image(image_url):
    # URLとトークンを与えてダウンロードを行う
    request = urllib.request.Request(
        url=image_url,
        headers={"Authorization": f'Bearer {slack_bot_token}'},
    )
    response = urllib.request.urlopen(request)

    # レスポンスから画像データを取り出す
    body = response.read()

    # 画像を戻り値として返す
    return body


# Bedrockを使って応答テキストを生成する
def generate_answer(input_text, media_type, image_data):
    # 入力テキストが空の場合、固定のテキストを設定する
    if input_text == "":
        input_text = "この画像に描かれているものを教えてください。"

    # メッセージをセット
    messages = [
        {
            "role": "user",
            "content": [
                {
                    "type": "image",
                    "source": {
                        "type": "base64",
                        "media_type": media_type,
                        "data": base64.b64encode(image_data).decode("utf-8"),
                    },
                },
                {
                    "type": "text",
                    "text": input_text,
                },
            ],
        },
    ]

    # リクエストBODYをセット
    request_body = json.dumps({
        "messages": messages,
        "anthropic_version": "bedrock-2023-05-31",
        "max_tokens": 1000,
    })

    # Bedrock APIを呼び出す
    response = bedrock_runtime.invoke_model(
        modelId="anthropic.claude-3-haiku-20240307-v1:0",
        accept="application/json",
        contentType="application/json",
        body=request_body,
    )

    # レスポンスBODYから応答テキストを取り出す
    response_body = json.loads(response.get("body").read())
    output_text = response_body.get("content")[0].get("text")

    # 応答テキストを戻り値として返す
    return output_text


# Lambdaイベントハンドラー
def lambda_handler(event, context):
    # SQSキューから情報を取り出す
    body = json.loads(event["Records"][0]["body"])
    channel_id = body.get("channel_id")
    input_text = body.get("input_text")
    file_type = body.get("file_type")
    file_url = body.get("file_url")

    # Slackから画像をダウンロードする
    image_data = download_image(file_url)

    # Bedrockを呼び出して入力テキストおよび画像に対する応答テキストを生成する
    output_text = generate_answer(input_text, file_type, image_data)

    # Slackへ応答テキストを書き込む
    result = client.chat_postMessage(
        channel=channel_id,
        text=output_text,
    )

入力されたテキストから画像を生成してSlackへ返すには、以下の順番に処理を行います。

  • SQSキューから情報を取り出す
  • Slackから画像ファイルをダウンロードする (def download_image)
  • Bedrockの「Claude 3 Haiku」モデルを使って、テキストと画像を与えて回答を生成する (generate_answer)
  • 生成された回答テキストをSlackのチャンネルに書き込む

各処理のポイントを解説します。

Slackからの画像ファイルのダウンロード

Slackに添付されたファイルをダウンロードする方法ですが、Slack SDKを使うのではなく、「requests」モジュールや「urllib」モジュールなどの一般的なHTTP処理で行います。 (今回は「urllib」を使用しています)

url_private_downloadで示されたダウンロード用URLは、誰でもアクセスできる訳ではなく、認証が必要となっています。 認証を行うために、以下のヘッダ情報を指定します。

Authorization: Bearer <「Bot User OAuth Token」のトークン文字列>

Bedrockを使った画像の解析

今回、画像を解析するためにAnthropic社の「Claude 3」モデルを使用しています。 (いわゆる「マルチモーダル」あるいは「Vision」と呼ばれる機能)

「Claude 3」を使ってVision機能を使用する際は、以下の内容をリクエストボディに与える必要があります。

{
    "anthropic_version": "bedrock-2023-05-31",
    "max_tokens": <最大トークン数>,
    "messages": [
        {
            "role": "user",
            "content": [
                {
                    "type": "image",
                    "source": {
                        "type": "base64",
                        "media_type": "<画像のMIMEタイプ (例: image/jpeg)>",
                        "data": "<Base64エンコードされた画像データ>"
                    }
                },
                {
                    "type": "text",
                    "text": "<プロンプトテキスト>"
                }
            ]
        }
    ]
}

contentの配列要素に、画像データとプロンプトテキストを格納します。 media_typeに適切なMIMEタイプをセットすることと、dataにはBase64エンコードされた画像データをセットする必要があることに注意すれば、それほど難しくないと思います。

パラメーターの詳細はAWS公式ドキュメントを参照してください。

AnthropicClaudeメッセージ API - Amazon Bedrock
https://docs.aws.amazon.com/ja_jp/bedrock/latest/userguide/model-parameters-anthropic-claude-messages.html

処理のポイントとしては、ユーザーから何らかの入力テキストが与えられている場合は入力テキストをそのままモデルに渡しますが、入力テキストが未指定 (空っぽ) だった場合には代わりに「この画像に描かれているものを教えてください」というプロンプトテキストを使用している点です。

これで、「Claude 3」は与えられた画像データとプロンプトテキストに基づいて、回答テキストを生成します。

あとは、「シンプルな生成AIチャットボット」の時と同様に、生成された回答テキストをSlackに書き込みます。

これで「アップロードした画像ファイルを分析してくれるチャットボット」が完成しました。

おわりに

前回のブログに引き続き、Amazon BedrockとSlackを使った生成AIチャットボットで「画像の取り扱い」を実践しました。

Slackにおける画像ファイルの扱い方は、理解するまでに少し苦労しましたが、前回と今回のブログで「Bedrockで生成した画像ファイルをSlack上に表示する」「Slackに添付した画像ファイルを取得してBedrockで処理する」という2つのパターンの実装方法が分かりましたので、今後いろいろな応用ができるのではないかと思います。

みなさんも、Bedrockで「画像」を使った便利なアプリ、面白いアプリを考えてみてくださいね。