LINE WebhookとVertexAIを使って、生成AIチャットボットを作ってみた③

# はじめに
本記事は以下の続編になります。②で終了予定でしたが、使ってもらった友人家族から改善要望がありましたので続けます。

https://qiita.com/piapepper/items/ae76bd2c0722db2680f9

# 今回やりたいこと
会話履歴をデータベースに保存し、過去の会話を踏まえた応答ができるチャットボットに改善します。
これまでにクラウド上で動作するようになったものの、一回限りの会話しかできず、会話の記憶を保持できていませんでした。そのため、会話の内容を記録し、継続的な対話ができるようにします。

# データベースの選定
今回も Google Cloud にお世話になります。以下のサイトを参考にし、Firestore を採用することにしました。
Firestore はリレーショナルデータベースではなく、JSON のような柔軟なデータ形式を保存できるドキュメント型データベースです。
（データベースの種類についても、近いうちにまとめる予定！）

https://book.st-hakky.com/data-science/streamlit-chat-ui-with-firestore-example/

Firestoreの料金体系は以下に記載されています。

https://cloud.google.com/firestore/pricing?hl=ja#pricing_example

# Firestore準備
1. Google Cloud の対象プロジェクトで「APIとサービス」から Cloud Firestore の API を有効にする。
2. Firestore のサービスを選択し、(default) のデータベースを作成する（まだ作成していない場合）。
※ (default) のデータベースが存在しないとデータを格納できないため、事前に作成が必要。

# Firestoreに会話履歴を残すため、コードを修正
### webhook.py
```python webhook.py
def handle_message(event):

    # ユーザーが送ったメッセージ
    user_message = event.message.text

    if not user_message.startswith("ちいくん"):
        return

    id = 0
    source_type = event.source.type  # "user", "group", "room" など

    if source_type == "user":
        id = event.source.user_id
    elif source_type == "group":
        id = event.source.group_id
    elif source_type == "room":
        id = event.source.room_id
    
    response = google_api_ins.generate(id, user_message)

    # 返信メッセージを作成
    reply_message = TextSendMessage(text=response)

    # 返信API呼び出し
    line_bot_api.reply_message(event.reply_token, reply_message)
```
#### 解説
会話履歴をIDごとにデータベース化するため、LINEのイベントからIDを取得できるようにしました。LINEには個人チャット、グループチャット、ルームチャットがありますが、どこのIDでもほとんど被ることはない、との想定の上でチャット種別によるIDの棲み分けはしないようにしました。

### google_api.py

```python google_api.py
class google_api:

    def __init__(self):
        self.project_id = "PROJECT_ID"
        self.location = "LOCATION"
        self.db = firestore.Client(project=self.project_id)

    def get_secret_value(self, secret_id: str) -> str:
        """
        Secret Managerからシークレットの最新バージョンの値を取得する。
        """
        client = secretmanager.SecretManagerServiceClient()

        # "projects/<PROJECT_ID>/secrets/<SECRET_ID>/versions/latest"
        name = f"projects/{self.project_id}/secrets/{secret_id}/versions/latest"
        response = client.access_secret_version(name=name)
        secret_string = response.payload.data.decode("UTF-8")
        return secret_string
    
    def get_chat_history(self, id, limit=100):
        """
        Firestore から最新の N 件の会話履歴を取得する
        """
        docs = (
            self.db.collection("users")
            .document(id)
            .collection("messages")
            .order_by("timestamp", direction=firestore.Query.ASCENDING)
            .limit(limit)
            .stream()
        )

        history = []
        for doc in docs:
            data = doc.to_dict()
            history.append({
                "role": data["role"],  # "user" または "model"
                "parts": [{"text": data["text"]}]
            })

        return history)

    def write_chat_history(self, id, role, message):
        """
        Firestore に会話履歴を保存する
        """
        doc_ref = self.db.collection("users").document(id).collection("messages").document()
        doc_ref.set(
            {
                "role": role,  # "user" または "model"
                "text": message,
                "timestamp": datetime.utcnow(),
            }
        )

    def enforce_chat_history_limit(self, id, max_messages=110):
        """
        Firestore の会話履歴を最大 max_messages 件に制限する
        古いメッセージは削除する
        """
        messages_ref = self.db.collection("users").document(id).collection("messages")
        
        # 古い順（timestamp 昇順）に並べて取得
        docs = messages_ref.order_by("timestamp", direction=firestore.Query.ASCENDING).stream()

        # メッセージ数が max_messages を超えていたら削除
        messages = list(docs)
        if len(messages) > max_messages:
            for doc in messages[:len(messages) - max_messages]:  # 超過分だけ削除
                doc.reference.delete()

    def generate(self, id, message):

        vertexai.init(project=self.project_id, location=self.location)

        model = GenerativeModel(
                model_name="gemini-1.5-flash-002",
                system_instruction=[
                    "あなたは「ちいくん」という名前の可愛らしく、しかし賢いAIです。"
                ],
            )
        
        # 過去の会話履歴を Firestore から取得
        history = self.get_chat_history(id, limit=10)

        # 最新のユーザーの発話を追加
        history.append({
            "role": "user",
            "parts": [{"text": message}]
        })

        generation_config = {
            "max_output_tokens": 8192,
            "temperature": 0,
            "top_p": 0.95,
        }

        response = model.generate_content(
            contents=history,
            generation_config=generation_config
        )

        ai_response = response.text

        # Firestore にユーザーのメッセージとAIの応答を保存
        self.write_chat_history(id, "user", message)
        self.write_chat_history(id, "model", ai_response)

        # データベースの上限を超えると削除する
        self.enforce_chat_history_limit(id)

        return ai_response
```
前回の投稿から大きく修正しましたので、クラスそのまま貼りました。

#### 解説


##### write_chat_history関数
Firestoreに会話履歴を書きこむ関数です。
- userコレクションにはチャットIDが、そのチャットIDの中にメッセージコレクションがあり、その中にメッセージ履歴を保存します。

##### get_chat_history関数
Firestoreから会話履歴を取得する関数です。
- MAXで100件の会話履歴を取得できるようにしています。
- .order_by("timestamp", direction=firestore.Query.ASCENDING)で最も新しい会話が下になるように(昇順)しています。

##### enforce_chat_history_limit関数
Firestore の会話履歴を最大 110 件に制限する関数
- 各チャット ID ごとのメッセージ数を調査し、110 件を超えた場合は、古いメッセージから順に削除するようにしています。
- Firestore の無料枠内で運用できるようにするための措置です。

参考：

https://firebase.google.com/docs/firestore/manage-data/delete-data?hl=ja#python_4

##### generate関数
- GenerativeModel の初期化時に、システムプロンプトを入力できる仕様だったため、その点を考慮して修正しました。
- get_chat_history を用いて過去の会話履歴を取得し、そこに入力されたプロンプトを追加したリストを gemini に送信しています。

# 実行結果
### LINEのチャット画面
会話履歴も含めてgeminiに送信されているため、pepperという名前も、ちいくん自身がした質問も覚えていることが確認できます。
![S__43819197.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4009423/34bfdcd6-c01a-4fe9-900a-39efb69e1246.jpeg)


### Firestore
先ほどの会話履歴がFirestoreに格納されている様子も確認できました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4009423/bb201fd1-9d83-46cf-a331-18ea730db111.png)