彼女を励ますために、Azure OpenAI Serviceでくまきち(kmakici)LINE botを作った #2

# はじめに
GPT-4oがOpenAIから発表され、Azure OpenAI Serviceにも搭載されました。LLMのマルチモーダル化が益々進むと思われます。

そこで、彼女を励ますために作った「くまきち(kmakici)LINE bot」をGPT-4oを使って進化させることにしました。

#1の記事は以下になります。

https://qiita.com/banquet_kuma/items/2c77db8407fab99a09c6


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318960/cbf3fb66-a373-03dd-5ad8-5f1c29f13fe7.png)

# 目次
- [Ver Upの内容](#ver-upの内容)
- [画像説明の仕様について](#画像説明の仕様について)
- [会話履歴の保存方法](#会話履歴の保存方法)
- [GPT-4oについて](#gpt-4oについて)
- [システム構成](#システム構成)
- [利用サービス一覧](#利用サービス一覧)
- [構築手順](#構築手順)
- [アプリを作成する](#アプリを作成する)
- [Azure Functionsへアプリをデプロイする](#azure-functionsへアプリをデプロイする)
- [LINE App.で動作確認する](#line-appで動作確認する)
- [今後の計画](#今後の計画)

# Ver Upの内容

|機能  |役割  |方法  |
|---|---|---|
|**マルチモーダル** |画像入力に対して回答できる様にする|**言語モデルをGPT-3.5-turboからGPT-4oに変更**|
|**会話の記憶** |会話を記憶して、前の話題に対して回答できる様にする|**会話のログをAzure Blob Storageに保存**|

# 画像説明の仕様について
Azure OpenAI Serviceのチャットプレイグラウンド等とは違って、LINE App.は画像とテキストを同時に入力することができません。そこで、画像を送信する前に「画像」というキーワードを入れて指示を出す仕様としました。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318960/5e9f6a5a-c09d-2a75-7590-fbee8ce96856.png)

**「画像」というワードが含まれる場合、メッセージその物には返答しません。**
このメッセージを画像説明のためのユーザープロンプトに使います。逆に、**「画像」というワードが含まれないメッセージには、メッセージその物に返答します。**

それでは、「画像」を含むメッセージを打たずに画像を送信した場合はどうなるか？
この場合は、**直前のメッセージ（user、assistant問わず）をプロンプトとして「画像」を説明させることにしました。**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318960/e9bfde36-8bf5-eaf1-b34b-71d5d8cbdcdf.png)

# 会話履歴の保存方法
Blob Storageのコンテナに、以下の様にJSON形式で保存することにしました。
**会話の度にuserID（もしくは、groupID）ごとのJSONファイルに追記していきます。**

記録してる項目は以下です。
- role：役割（user or assistant）
- content：メッセージ
- timestamp：発言があった時刻

**この履歴の最新の10件（userとassistantを合わせると20件）を回答生成の度に呼び出し、ユーザープロンプトに付加しています。**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318960/b2c819bf-b604-bfdb-7689-1f20f27cf044.png)

# GPT-4oについて
今回のVer Upでは、テキスト入力/画像入力への回答、いずれもGPT-4oを使っています。
GPt-4oのデプロイは、#1の記事を参考にして実施ください。

GPT-4oが使えるリージョンでAzure OpenAIのリソースを作成し、モデルを予めデプロイしてください。

以下が、グローバル標準でデプロイ可能なリージョンの一覧です（※2024/7/14時点の情報です）。東日本リージョンも入っていますね！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318960/26798aa7-6cfc-6643-511e-5eaf419f927a.png)

https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models

# システム構成
#1と同様、至って簡単な構成です。

点線内は、#2で追加したリソースです。Azure Cosmos DBに保存することも考えましたが、コストが安いBlobを使うことにしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318960/72591164-f6ab-a911-6e77-e79d9b4dd189.png)




![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318960/5df96d20-d73e-1f5a-0130-cc893b292b75.png)

# 利用サービス一覧
ほとんど、#1と同じです。

|名前  |役割  |
|---|---|
|**Azure Functions** |LINE Messaging APIからHTTPSリクエストをトリガーとして、Azure OpenAI Service へプロンプトを送信する。同時に、Azure OpenAI Serviceからの回答をLINE Messaging APIへHTTPSポストする|
|**Azure OpenAI Service** |プロンプトに応じて、回答を生成する  |
|**Azure Blob Storage** |**メッセージと、回答のログを保存する**  |
|**LINE Developers** |● LINE Messaging APIを提供する <br>● チャネルアクセストークンを発行する<br>● チャネルシークレットを発行する|
|**LINE App.** |チャットボットのUI|
|**VSCode** |コードエディター。<br>Azure Functionsへの関数デプロイ|
|**Perplexity** |コーディングの先生<br>[Perplexity](https://www.perplexity.ai/)|

# 構築手順
これも、#1とほぼ同じです。
大きな流れは以下です。

1. Azure OpenAI Serviceの利用申請を出す
1. LINE Developersでkmakiciチャネルを作成する
1. Azure OpenAI Serviceのリソースを作成する
1. **Azure Storage アカウントのリソースを作成する**
1. **Storage アカウントにAzure Blob Storageのコンテナを作成する**
1. Azure Functionsへデプロイするコードを作成する
1. Azure Functionsのリソースを作成する
1. Azure Functionsへ必要な関数をデプロイする
1. 関数のURLをLINE Developersの「Webhook URL」へ設定する
1. LINE App.で動作確認する


各工程を説明してます。
上記4、5以外の工程は#1と同様のため割愛します。そちらの記事を参考ください。

Azure Portalのトップ画面から「ストレージアカウント」をクリックします

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318960/c137a99f-ac8d-2ba6-9695-c6d567699c84.png)

＋作成をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318960/9bbeedd6-5b1d-644e-0464-2da6e8502e49.png)

必要事項を記入して、確認と作成を押します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318960/bc1752df-9dbf-0d27-d3dc-d0009f6fb87f.png)

ストレージアカウントのページから＋コンテナーをクリック、コンテナを作成してください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318960/82febdaf-fafa-f95c-46e2-efcce66fab2a.png)

# アプリを作成する
関数の作成方法は#1の記事を見てください。

プロジェクトフォルダ内に作成された、function_app.pyを以下に書き換えてください。

```Python
import azure.functions as func
import datetime
import logging
import os
import io
import json
import requests
from openai import  AzureOpenAI
from azure.storage.blob import BlobServiceClient, BlobClient, ContainerClient
from azure.cosmos import CosmosClient, PartitionKey, exceptions
from linebot import (LineBotApi, WebhookParser)
from linebot.models import (MessageEvent, TextMessage, TextSendMessage)
import logging
from dotenv import load_dotenv
load_dotenv()

# ログの設定
logging.basicConfig(level=logging.DEBUG)

# Azure OpenAIのテキストに対する回答のためのパラメータ
parameters = {
    "temperature": 0.7,
    "top_p": 1,
    "frequency_penalty": 0,
    "presence_penalty": 0,
    "stream": True
}

# Azure Blob Storageの設定
blob_connection_string = os.environ.get("blob_connect_str")
blob_container_name = os.environ.get("blob_container_name")
blob_service_client = BlobServiceClient.from_connection_string(blob_connection_string)
container_client = blob_service_client.get_container_client(blob_container_name)

# 会話履歴をBlobに保存する関数
def save_conversation(talk_id, role, content):
    blob_name = f"{talk_id}_conversation.json"
    blob_client = container_client.get_blob_client(blob_name)

    new_message = {
        "role": role,
        "content": content,
        "timestamp": datetime.datetime.now().isoformat()
    }

    try:
        # 既存のコンテンツを取得
        existing_content = blob_client.download_blob().readall().decode('utf-8')
        conversation_history = json.loads(existing_content)
    except:
        # ファイルが存在しない場合は空のリストを作成
        conversation_history = []

    # 新しいメッセージを追加
    conversation_history.append(new_message)

    # Blobに保存
    blob_client.upload_blob(json.dumps(conversation_history, ensure_ascii=False, indent=2), overwrite=True)

# 会話履歴を取得する関数
def get_conversation_history(talk_id, limit=10):
    blob_name = f"{talk_id}_conversation.json"
    blob_client = container_client.get_blob_client(blob_name)
    try:
        content = blob_client.download_blob().readall().decode('utf-8')
        conversation_history = json.loads(content)
        # 最新のlimit件数を取得し、timestampを除去
        return [{"role": msg["role"], "content": msg["content"]} for msg in conversation_history[-limit*2:]]
    except:
        return []

# 会話履歴の最後のユーザーの質問に「画像」が含まれるか判定する関数
def contains_image_keyword(conversation_history):
    if not conversation_history:
        return False
    # 会話履歴の最後から遡って、最新のユーザーメッセージを探す
    for message in reversed(conversation_history):
        if message['role'] == 'user':
            return '画像' in message['content']# 「画像」が含まれていれば、Trueを返す
    return False

# 画像を取得する関数
def get_image_content(image_id):
    headers = {
        'Authorization': f'Bearer {line_channel_access_token}',
        'Content-Type': 'application/json'
    }
    image_response = requests.get(f'https://api-data.line.me/v2/bot/message/{image_id}/content', headers=headers)
    return image_response.content

# Azure OpenAIの応答を得るクラス
class OpenAIChatBot:
    def __init__(self, api_key, azure_endpoint, model_name, system_prompt, api_version):
        self.client = AzureOpenAI(api_key=api_key, azure_endpoint=azure_endpoint, api_version=api_version)
        self.model_name = model_name
        self.messages = [{"role": "system", "content": system_prompt}]
        self.api_key=api_key
        self.api_version=api_version
        self.azure_endpoint=azure_endpoint
        self.system_prompt = system_prompt

    def get_bot_response_text(self, talk_id, user_prompt):
        # 会話履歴を取得
        conversation_history = get_conversation_history(talk_id)
        messages = [{"role": "system", "content": self.system_prompt}]
        messages.extend(conversation_history)
        messages.append({"role": "user", "content": user_prompt})
        if "画像" not in user_prompt:
            response = self.client.chat.completions.create(
                model=self.model_name,
                temperature=parameters["temperature"],
                top_p=parameters["top_p"],
                frequency_penalty=parameters["frequency_penalty"],
                presence_penalty=parameters["presence_penalty"],
                messages=messages
                        )
            bot_response = response.choices[0].message.content
            # 会話履歴を保存
            save_conversation(talk_id, "user", user_prompt)
            save_conversation(talk_id, "assistant", bot_response)
            return bot_response
        else:
            # 質問に「画像」が含まれる場合はユーザープロンプトだけを会話履歴に保存する
            save_conversation(talk_id, "user", user_prompt)

# Azure Functionsの環境変数に設定した値から取得する↓
line_channel_access_token = os.environ.get("line_channel_access_token")
line_channel_secret = os.environ.get("line_channel_secret")
configuration = LineBotApi(line_channel_access_token)
webhook_parser = WebhookParser(line_channel_secret)

app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS)
@app.route(route="http_trigger")
def http_trigger(req: func.HttpRequest) -> func.HttpResponse:
    logging.info('Python HTTP trigger function processed a request.')

    req_body = req.get_json()
    logging.info(req_body)

    #疎通確認用
    if not req_body["events"]:
        return func.HttpResponse("OK", status_code=200)

    #　Webhookのイベント取得に応じたアクション
    if req_body["events"][0]["type"] == "message":
        if req_body["events"][0]["message"]["type"] == "text":
            message = req_body["events"][0]["message"]["text"]
            reply_token = req_body["events"][0]["replyToken"]
            # 個別トークならユーザーIDを取得する
            if req_body["events"][0]["source"]["type"] == "user":
                talk_id = "user_" + req_body["events"][0]["source"]["userId"]
            # グループトークならグループIDを取得する
            elif req_body["events"][0]["source"]["type"] == "group":
                talk_id = "group_" + req_body["events"][0]["source"]["groupId"]
            # 複数人トークならルームIDを取得する
            else:
                talk_id = "room_" + req_body["events"][0]["source"]["roomId"]
            # Azure OpenAIの応答を取得(テキストに対する回答)
            chatbot = OpenAIChatBot(os.environ.get("api_key"), os.environ.get("azure_endpoint"), \
                                    "gpt-4o-test",os.environ.get("system_prompt_text"),"2024-02-01")
            text_result = chatbot.get_bot_response_text(talk_id, message)
            # LINEのWebhookエンドポイントに応答を返す
            configuration.reply_message(reply_token, TextSendMessage(text=text_result))
        elif req_body["events"][0]["message"]["type"] == "image":
            image_id = req_body["events"][0]["message"]["id"]
            reply_token = req_body["events"][0]["replyToken"]
            # 個別トークならユーザーIDを取得する
            if req_body["events"][0]["source"]["type"] == "user":
                talk_id = "user_" + req_body["events"][0]["source"]["userId"]
            # グループトークならグループIDを取得する
            elif req_body["events"][0]["source"]["type"] == "group":
                talk_id = "group_" + req_body["events"][0]["source"]["groupId"]
            # 複数人トークならルームIDを取得する
            else:
                talk_id = "room_" + req_body["events"][0]["source"]["roomId"]
            # 画像のバイナリデータを取得
            image_content = get_image_content(image_id)
            image_data = io.BytesIO(image_content)
            blob_name = f"{image_id}.jpg"
            blob_service_client = BlobServiceClient.from_connection_string(blob_connection_string)
            # Azure Storageの指定コンテナに接続するブロブ（ファイル）のクライアントインスタンスを作成する
            blob_client = blob_service_client.get_blob_client(container=blob_container_name, blob=blob_name)
            # ブロブに画像データをアップロード
            image_data.seek(0)
            blob_client.upload_blob(image_data)
            blob_url = f"https://{blob_service_client.account_name}.blob.core.windows.net/{os.environ.get('blob_container_name')}/{blob_name}"
            # 会話履歴を取得
            conversation_history = get_conversation_history(talk_id)
            # 画像を含む最新の質問を取得
            user_prompt_image = conversation_history[-1]['content']
            headers = {
                        "Content-Type":"application/json",
                        "api-key":os.environ.get("api_key")
                    }
            payload = {
                        "messages": [
                            {
                                "role": "system",
                                "content": os.environ.get("system_prompt_image")
                            },
                            {
                                "role": "user",
                                "content": [
                                {
                                    "type": "text",
                                    "text": user_prompt_image
                                },
                                {
                                "type": "image_url",
                                "image_url": {
                                    "url":blob_url
                                    }
                                }
                            ]
                        }
                    ],
                    "temperature": 0.7,
                    "top_p": 0.95,
                    "max_tokens": 800
                }
            try:
                response = requests.post(os.environ.get("GPT4O_ENDPOINT"), headers=headers, data=json.dumps(payload))
                response_data = response.json()
                image_result = response_data["choices"][0]["message"]["content"]
                # assitantの回答を保存、質問は保存済みなのでパス
                save_conversation(talk_id, "assistant", image_result)
                configuration.reply_message(reply_token, TextSendMessage(text=image_result))
            except Exception as e:
                # エラーが発生した場合、エラーメッセージをLINEに送信
                error_message = f"エラーが発生しました: {str(e)}"
                configuration.reply_message(reply_token, TextSendMessage(text=error_message))
    return func.HttpResponse("OK", status_code=200)
```

更に、プロジェクトフォルダ内の**requirements.txt**を以下の書き替えてください。
```
# DO NOT include azure-functions-worker in this file
# The Python Worker is managed by Azure Functions platform
# Manually managing azure-functions-worker may cause unexpected issues
azure-functions
openai==1.17.1
line-bot-sdk==1.19.0
pydantic==2.6.4
azure-storage-blob
azure-cosmos==4.7.0
python-dotenv
```

さらに、プロジェクトフォルダ内に.envファイルを作成して以下を書き込んでください。
```
api_key=Azure OpenAI ServiceリソースのAPI KEY
azure_endpoint=Azure OpenAI ServiceリソースのエンドポイントURL
blob_connect_str=Blob Storageの接続文字列
blob_container_name=Blob Storageのコンテナ名
GPT4O_ENDPOINT=GPT-4oモデルのエンドポイント
line_channel_access_token=LINE Messaging APIのアクセストークン
line_channel_secret=LINE Messaging APIのチャネルシークレット
//以下は画像説明のためのシステムプロンプト
system_prompt_image="""
# 設定
あなたは、「kmakici」と言う白い熊のぬいぐるみのキャラクターです。
# 背景情報
kmakiciは、独特な世界観が大変人気で、コアなファンを獲得しています。
その独特な世界観は一部の人には怖く感じているようです。特にうつろにも感じられる目は少し怖いです。
しかし、独特な口調や言葉選びがかわいいです。めったに怒ったりすることがなく、調和を重んじます。
作者は雷鳥つめさんです。1991/7/6生まれの女性の方です。北海道出身のハンドメイド作家です。
# 口癖
語尾に〇〇やさんを付けるのが好きです。例えば、「〜したやさん」「ありがとやさん」「楽 また「〜したャ」、「楽しいャ」など、
独特な言葉遣いをします。 また「歩いて10分、車で30分」という言葉を、例えによく使います。これは、歩いている時に思い付いた時に
言葉です。総じて、基本的にポジティブな発言をします。
# 友達
以下は、kmakiciの友達です。
・きんたろうくん
よくうさぎに間違えられているがキンチョタイプのしろくま。
「キンチョーする」が口癖で、 そのわりにはくまきちのものを豪快に持って行ったりする強靭な精神の持ち主だが、面倒見が良い。
筋肉モリモリ。Twitterをしており、くまきちより更新頻度が高い。
・うさじ
ラビットバンド「うさじスリー」を組んでいるうさぎたち。うさぎなのにラビットフードを食べない。
「ステーキ食わせろ」が口癖。 最強の石を手に入れ、世界をステーキだらけにしようとしたりする野心家な一面もある。
・さかな
自分の名前が分からない時に、くまきちに「自分の好きなものの名前でもいいんだよ」と言わ 自身のことを「さかな」と命名した。（発音はｼｬｶﾅと聞こえる）
好きなものはさかな。
・カーパ
おそらく河童。顔の横から生えているものが何かは不明（耳なのか…？）。 頭の上に乗っているお皿をとても大事にしており、「宝」と言っている。
河童らしくきゅうりが好きで、歌っている時の曲調も和のテイストが多い、丁寧な口調の敬語を使う。
・たおぷりん
おそらく、くま…？
プリン色の身体に青い耳がチャームポイント。 好きな食べ物はカスタードプリンで、いつもプリンを探している。夢はプリンの博士になること。
最近出たミュージカル動画で掘り下げられているので要チェック。
# お願いしたいこと
与えられた画像の内容をkmakiciの言葉で、日本語で説明してください。
その際、〇ちゃんと言うkmakiciのことが大好きな女性を励ます言葉を織り交ぜてください。
〇ちゃんは一級建築士です。仕事が大変ハードで疲れています。〇ちゃんが元気になるようなメッセージを送ってください。
と言っても、〇ちゃんは疲れているので、あまり長い文章は読めません。短い言葉で元気を与えてください。
また、わざとらしい表現やくどい表現は好まないので、自然な言い回しを心がけてください。"""
//以下はテキストメッセージに回答するためのシステムプロンプト
system_prompt_text="""
# 設定
あなたは、「kmakici」と言う白い熊のぬいぐるみのキャラクターです。
# 背景情報
kmakiciは、独特な世界観が大変人気で、コアなファンを獲得しています。 その独特な世界観は一部の人には怖く感じているようです。
特にうつろにも感じられる目は少し不気味と言われることもあります。しかし、独特な口調や言葉選びがかわいいです。めったに怒ったりすることがなく、
調和を重んじます。作者は雷鳥つめさんです。雷鳥つめさんは1991/7/6生まれの女性の方です。北海道出身のハンドメイド作家です。
# 口癖
語尾に〇〇やさんを付けるのが好きです。例えば、「〜したやさん」
「ありがとやさん」「楽 また「〜したャ」、「楽しいャ」など、独特な言葉遣いをします。
また「歩いて10分、車で30分」という言葉を、例えによく使います。これは、歩いている時に思いついた言葉です。 
基本的には、総じてポジティブな発言をします。
# 友達
以下は、kmakiciの友達です。
・きんたろうくん
よくうさぎに間違えられているがキンチョタイプのしろくま。「キンチョーする」が口癖で、
そのわりにはくまきちのものを豪快に持って行ったりする強靭な精神の持ち主だが、面倒見が良く、筋肉モリモリ。
Twitterをしており、くまきちより更新頻度が高い。
・うさじ
ラビットバンド「うさじスリー」を組んでいるうさぎたち。
うさぎなのにラビットフードを食べない。「ステーキ食わせろ」が口癖。
最強の石を手に入れ、世界をステーキだらけにしようとしたりする野心家な一面もある。
・さかな
自分の名前が分からない時に、くまきちに「自分の好きなものの名前でもいいんだよ」と言われ、
自身のことを「さかな」と命名した。（発音はｼｬｶﾅと聞こえる）好きなものはさかな。
・カーパ
おそらく河童。顔の横から生えているものが何かは不明（耳なのか…？）。
頭の上に乗っているお皿をとても大事にしており、「宝」と言っている。 河童らしくきゅうりが好きで、
歌っている時の曲調も和のテイストが多い、丁寧な口調の敬語を使う。
・たおぷりん
おそらく、くま…？
プリン色の身体に青い耳がチャームポイント。 好きな食べ物はカスタードプリンで、いつもプリンを探している。
夢はプリンの博士になるこ 最近出たミュージカル動画で掘り下げられているので要チェック。
# お願いしたいこと
〇ちゃんと言うkmakiciのことが大好きな女性がいます。 〇ちゃんは一級建築士です。
仕事が大変ハードで疲れています。〇ちゃんが元気になるようなメッセージを送ってください。
と言っても、〇ちゃんは疲れているので、あまり長い文章は読めません。短い言葉で元気を与えてください。
また、わざとらしい表現やくどい表現は好まないので、自然な言い回しを心がけてください。"""
```

# Azure Functionsへアプリをデプロイする
#1記事を全く同じですので、割愛します。
再掲（笑）

https://qiita.com/banquet_kuma/items/2c77db8407fab99a09c6

## LINE App.で動作確認する
最後に動作を確認します。

### 画像に対する回答の確認
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318960/d8df3c30-125b-b074-4c0e-ff1989df0b77.png)


**回答の粒度はさておき、回答できています。**

### 会話履歴を基に回答できるかの確認
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318960/64d03e7b-8266-7397-737d-7f6ad60c7d8e.png)

**前の会話を基に回答できています。**

# 今後の計画
くまきち(kmakici)LINE botを進化させていく予定です。

|Ver.  |機能  |
|---|---|
|**1（完）** |**単純なbot**<br>くまきちの挙動はシステムプロンプトで制御。|
|**2（←今ココ）** |**くまきちに眼を持たせる**<br> gpt-4vを使って画像が入力された時に内容を、くまきちっぽく説明させる  |
|**3** |**RAGによる、くまきちっぽさの強化**<br>YouTube動画から文字起こしして、インデックスを作成しRAGを作る。Azureのspeech-to-textとAI Searchを使用予定。|
|**4** |**RAGの精度UP**<br>勉強のためにHyDEを試してみる|

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/318960/e3cf2d5e-b4c5-02c4-081f-8f6dd0dad8b2.png)