Amazon Bedrock(Claude3)+LangChain(LCEL)で画像チャットWebアプリを作る

LangChain（のチャットモデル）からBedrockのClaude3 Sonnet、Haikuが呼び出せるようになったので、画像チャットアプリをLCELで作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3573242/c67801d9-ec03-6b56-b9db-94fe97457b24.png)

よく読むとハルシネーションしてますね。Sonnetだとこんなものでしょうか。

以下をフュージョンしてLangChainの記法でClaude3を呼び出すように変更しました。

https://qiita.com/cyberBOSE/items/89ff1e91f99eb79f83bc

https://qiita.com/cyberBOSE/items/35fd2aed5a42b963ccb6

:::note
Haikuを使用する場合はモデルIDを`anthropic.claude-3-haiku-20240307-v1:0`に変更してください
:::

# 前提作業
- DynamoDBに`SessionTable`を作成してPython実行環境からの権限を付与しておく
- 必要ライブラリのインストール（上記の両方を合わせた感じ）
```
pip install -U boto3 langchain langchain-community streamlit streamlit_authenticator numpy
```

# プログラム

```Python:hogehoge.py
from langchain.globals import set_debug
set_debug(False) # debug時はTrue

import base64
import copy
from PIL import Image
import numpy as np
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.messages import HumanMessage
from langchain_community.chat_message_histories import DynamoDBChatMessageHistory
from langchain_community.chat_models import BedrockChat
import streamlit as st
import streamlit_authenticator as sa

# 認証情報を作成
authenticator = sa.Authenticate(
    credentials={"usernames":{
        "user1":{"name":"user1","password":"pass"},
        "user2":{"name":"user2","password":"pass"},
        "user3":{"name":"user3","password":"pass"}}},
    cookie_name="streamlit_cookie",
    key="signature_key",
    cookie_expiry_days=1
)

# ログイン画面描画
authenticator.login()

if st.session_state["authentication_status"] is False: #ログイン失敗
    st.error('Username/password is incorrect')
elif st.session_state["authentication_status"] is None: #未入力
    st.warning('Please enter your username and password')
else: #ログイン成功

    st.sidebar.title("LangChain(LCEL) x Bedrock(Claude3 Sonnet)")
    authenticator.logout(location="sidebar") #ログアウトボタンをサイドバーに表示

    # ログイン直後っぽい時は過去のメッセージをクリアする
    if "FormSubmitter:Login-Login" in st.session_state:
        if "messages" in st.session_state:
            st.session_state["messages"].clear()

    # usernameをセッションIDのKeyとする
    session_id = st.session_state["username"]

    # DynamoDBの会話履歴（テーブル名"SessionTable"、TTL=3600秒）
    message_history = DynamoDBChatMessageHistory(table_name="SessionTable", session_id=session_id, ttl=3600)

    # プロンプトテンプレートを作成
    prompt = ChatPromptTemplate.from_messages(
        [
            ("system","あなたはAIチャットボットです"),
            MessagesPlaceholder(variable_name="messages"),  #ここにDynamoDBから取得した会話履歴を入れる
            MessagesPlaceholder(variable_name="human_message")
        ]
    )

    # LLMの定義
    LLM = BedrockChat(
        model_id="anthropic.claude-3-sonnet-20240229-v1:0",
        model_kwargs={"max_tokens": 1000},
    )

    # chainの定義
    chain = prompt | LLM

    # 画面表示用チャット履歴を初期化する
    if "messages" not in st.session_state:
        # 辞書形式で定義
        st.session_state["messages"] = []

    # これまでのチャット履歴を全て表示する 
    for message in st.session_state.messages:
        with st.chat_message(message["role"]):
            st.markdown(message["content"])

    # サイドバーに画像アップロード欄の作成
    img_file = st.sidebar.file_uploader("Jpegファイルアップロード", type='jpg')
    if img_file: # 画像がアップロードされている場合
        img_file_copy = copy.copy(img_file) # コピーしてからサムネイル表示
        image = Image.open(img_file_copy)
        img_array = np.array(image)
        st.sidebar.image(img_array,width = 300)

    # 入力を求める
    if input_text := st.chat_input("会話しましょう"):

        # ユーザの入力を表示する
        with st.chat_message("user"):
            st.write(input_text)

        # HumanMessageの組み立て
        content = []
        if img_file: # 画像がアップロードされている場合
            image = img_file.read() # 読み込み
            b64 = base64.b64encode(image).decode("utf-8") # Base64変換

            # プロンプトの形式にしてcontent配列に追加
            content_image={"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,"+b64}}
            content.append(content_image)

        # 入力テキストをcontent配列に追加
        content_text = {"type": "text", "text": input_text}
        content.append(content_text)

        # chainの実行
        result = chain.invoke({"messages": message_history.messages, "human_message": [HumanMessage(content=content)]})
        
        # ChatBotの返答を表示する 
        with st.chat_message("assistant"):
            st.write(result.content)

        # セッション上のチャット履歴の更新
        st.session_state.messages.append({"role": "user", "content": input_text})
        st.session_state.messages.append({"role": "assistant", "content": result.content})

        # DynamoDBのチャット履歴の更新(画像は登録しない)
        message_history.add_user_message(input_text)
        message_history.add_ai_message(result.content)
```

画像をJSONに設定している部分ですが、上記の記法でも、boto3の記法（Claude3のプロンプト仕様）でもどちらでも通ります。
あと補足すると、チャット履歴をDynamoDBに登録する際には画像は登録していません。チャット履歴はテキストのみで、最新の問い合わせにのみ画像のBase64形式を挿入しています。同じ画像でチャットを続ける場合は、常に最新の問い合わせに同じ画像が入ります。（この為、一度アップロードした画像を削除すると、Bedrockからは画像が見えない状態になる）
履歴に入れる事も出来そうなんですが、それをやると同じ画像でチャットを続けるとプロンプトサイズがえらいことになるのでこうしています。履歴に1件のみ入れる案もありますがStreamlitだと作りにくいので試していません。

他の部分の説明は過去のエントリを確認ください。

# 起動する

```
python -m streamlit run hogehoge.py
```

user1/passかuser2/passかuser3/passでログインしてください。

# プロンプトを確認する

まずは画像無しでLangChainがどのようなプロンプトを生成しているか確認します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3573242/299ce378-b1fb-54c5-e398-9a4da85acaf9.png)

上記の会話のプロンプトをClaudeWatch Logsで確認します。
```JSON
    "operation": "InvokeModel",
    "modelId": "anthropic.claude-3-sonnet-20240229-v1:0",
    "input": {
        "inputContentType": "application/json",
        "inputBodyJson": {
            "max_tokens": 1000,
            "anthropic_version": "bedrock-2023-05-31",
            "messages": [
                {
                    "role": "user",
                    "content": "初めまして"
                },
                {
                    "role": "assistant",
                    "content": "はい、初めまして。私はClaudeと申します。どのようなお話ができればよいでしょうか?あなたの質問にお答えしたり、話を楽しくしたりするのが私の役割です。ぜひ気軽に質問や要望を話してくださいね。"
                },
                {
                    "role": "user",
                    "content": [
                        {
                            "type": "text",
                            "text": "アップロードする画像に関する会話をしたいです"
                        }
                    ]
                }
            ],
            "system": "あなたはAIチャットボットです"
        },
        "inputTokenCount": 135
    },
    "output": {
        "outputContentType": "application/json",
        "outputBodyJson": {
            "type": "message",
            "role": "assistant",
            "content": [
                {
                    "type": "text",
                    "text": "はい、画像に関する会話をさせていただきます。画像をアップロードしていただければ、その画像の内容を分析し、詳しく説明することができます。物体の認識、シーンの理解、テキストの読み取りなど、画像から得られる情報を活用して、的確なフィードバックを心がけます。画像のアップロードをお待ちしております。"
                }
            ],
            "model": "claude-3-sonnet-28k-20240229",
            "stop_reason": "end_turn",
            "stop_sequence": null,
            "usage": {
                "input_tokens": 135,
                "output_tokens": 121
            }
        },
        "outputTokenCount": 121
    }
```
`ChatPromptTemplate`に与えた`("system","あなたはAIチャットボットです")`が`"system"`とシステムプロンプトとして与えられています。その他、`"anthropic_version"`など、プロンプトの仕様に従って設定されている事が分かります。
ロールの部分がどうなっているかを確認すると、以下のようにこちらもプロンプトの仕様に従って展開・設定されています。
```JSON
"messages": [
    {"role": "user", "content": "初めまして"},
    {"role": "assistant", "content": "はい、初めまして。私はClaudeと申します。どのようなお話ができればよいでしょうか?あなたの質問にお答えしたり、話を楽しくしたりするのが私の役割です。ぜひ気軽に質問や要望を話してくださいね。"},
    {"role": "user", "content": [{"type": "text","text": "アップロードする画像に関する会話をしたいです"}]}
```
最後の`content`を配列にしているのは、画像を設定する場合にimageのみ追加すれば良いようにする為です。

次は画像をアップロードして確認します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3573242/d62f3f6a-7ebc-739e-11a5-ac8e6c0cbc28.png)

```JSON
{
    "role": "user",
    "content": [
        {
            "type": "image",
            "source": {
                "type": "base64",
                "media_type": "image/jpeg",
                "data": "/9j/4…（Base64エンコードされた画像ファイル）"
            }
        },
        {
            "type": "text",
            "text": "画像の説明をしてください"
        }
    ]
}
],
"system": "あなたはAIチャットボットです"
},
"inputTokenCount": 494
},
```
こちらもプロンプトの仕様に従って展開・設定されています。
余談ですがJpegファイルをBase64エンコードすると先頭は必ず`/9j/4`になるそうです。

画像ファイルも入力トークン数としてカウントされているように見えますが、そこのカウント方法はちょっと分かりません。

入力が大きくなると（？）CloudWatch Logsでは確認できない事があるので、その場合はデバッグモードである程度は確認する事が出来ます。他に方法無いのかな。

```デバッグモードの例
[{
    'type': 'image_url',
    'image_url': {
        'url': 'data:image/jpeg;base64,/9j/4…（Base64エンコードされた画像ファイル）
    }
},
{
    'type': 'text', 'text': '画像の説明をしてください'
}]"
```
この段階だとClaude3のプロンプト仕様には変換されていないので、そこは注意が必要です。

# インターネットに公開したい場合

自己責任で以下

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


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