cloudFunctionsでChatGPTを用い最新の情報を答えてくれるslackBotを作る

# はじめに
こんにちは。ponponnsanです。LLMの進化が止まらないですね✨
個人的にはllama2の公開がアツかったです！
オープンソースのLLMがもっと広まることで、大学や企業独自のカスタマイズができたり、使える幅がもっと広がるというのは楽しみですね！！

さて、今回は皆さんやっているChatGPTのslackbotを作成してみました！
経緯としては、2022年の情報ってもう古くない？？という思いがあったからです。
例えば、LangChainの使い方探してもプラグイン入れないと探せないのがちょっと億劫になってきた自分もいて、、、ならば自分で作ってみようというお気持ちでした。

# やりたいこと
- 最新の天気や情報をslackbotで返して欲しい。
- 過去の会話を覚えて、返答して欲しい。
- GCP使ってみたい
- LangChain使ってみたい


# OpenAI APIの取得
まず、OpenAIのAPIを取得します。
取得方法は以下の記事が参考になります。

https://programming-zero.net/openai-api-key/

＊GPT4が解禁になったという話でしたが、なぜか私のAPIでは使えなかったです。
仕方なく、先輩のAPIをお借りしています。


# slack Botの設定

- Basic Information > App-Level Tokens
    アプリレベルのトークンによって、アプリは複数（またはすべて）のインストールに適用されるプラットフォーム機能を使用できるようになります。機能には個別のスコープがあるため、必要な機能のスコープのみをリクエストしてください。各アプリは、一度に最大 10 個のアプリレベル トークンを持つことができます。

    設定したスコープ：connections:write

- Interactivity & Shortcuts
    ショートカット、モーダル、インタラクティブコンポーネント（ボタン、セレクトメニュー、日付ピッカーなど）とのインタラクションはすべて、あなたが指定したURLに送信されます。
    
    Request URLにCloudFunctionsのURLを設定します。

- OAuth & Permissions > Scopes
    Slackアプリの機能とパーミッションは、そのアプリが要求するスコープによって管理されます。必要のない権限もありますが、ご容赦ください。


![スクリーンショット 2023-07-22 12.34.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1159060/3640b661-ae4c-e0cd-203d-4d6b38a0adc3.png)

- Event Subscriptions
    あなたのアプリは、Slackのイベント（例えば、ユーザーがリアクションを追加した時やファイルを作成した時など）を、あなたが選んだURLで通知されるようにサブスクライブすることができます。

    Request URLにCloudFunctionsのURLを設定します。

- Event Subscriptions>Subscribe to bot events
    アプリは、ボットユーザーがアクセスできるイベント（チャンネルの新着メッセージなど）を受信するためにサブスクライブすることができます。ここにイベントを追加すると、必要なOAuthスコープが追加されます。必要のない権限もありますが、ご容赦ください。

![スクリーンショット 2023-07-22 12.39.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1159060/006debef-3bc5-8b20-524a-5b6b81699be8.png)



# Cloud Functionsの設定

OpenAIの設定やSlackBotの設定なども書いてくれており、さらにCloudFunctionで動かしているいい記事を見つけました！！

https://zenn.dev/kanaotto/articles/eafc85f296bbae
 
## 実際のコード
コードの参照は、[参考資料](#参考資料)にあります。

```requirements.txt
functions-framework==3.3.0
openai == 0.27.0 # ChatCompletion は 0.27.0 以降で対応
slack-bolt == 1.16.1
google-cloud-logging == 3.5.0
flask == 2.2.2
python-box == 7.0.0
firebase_admin == 6.2.0
langchain == 0.0.235
google-api-python-client== 2.94.0
```

```main.py
import json
import logging
import os
import re
from typing import Union
import datetime
import functions_framework
import google.cloud.logging
import openai
from box import Box
from flask import Request
from slack_bolt import App, context
from slack_bolt.adapter.google_cloud_functions import SlackRequestHandler

import firebase_admin
from firebase_admin import credentials
from firebase_admin import firestore

from langchain import OpenAI, ConversationChain
from langchain.agents import load_tools
from langchain.agents import initialize_agent
from langchain.memory import ConversationBufferWindowMemory


# Google Cloud Logging クライアント ライブラリを設定
logging_client = google.cloud.logging.Client()
logging_client.setup_logging(log_level=logging.DEBUG)

# Use a service account
# Application Default credentials are automatically created.
firebase_app = firebase_admin.initialize_app()
db = firestore.client()

# 環境変数からシークレットを取得
google_src_id = os.environ.get("GOOGLE_CSE_ID")
google_api = os.environ.get("GOOGLE_API_KEY")
slack_token = os.environ.get("SLACK_BOT_TOKEN")
openai_api_key = os.environ.get("OPENAI_API_KEY")
openai.api_key = openai_api_key

# FaaS で実行する場合、応答速度が遅いため process_before_response は True でなければならない
app = App(token=slack_token, process_before_response=True)
handler = SlackRequestHandler(app)



# LLMの設定
llm = OpenAI(model_name="gpt-4",temperature=0.7)

# 使用するツールをロード
tools = load_tools(["google-search"], llm=llm)


memory = ConversationBufferWindowMemory(k= 5)

# Agent用 prefix, suffix
prefix = """Anser the following questions as best you can, but speaking Japanese. You have access to the following tools:"""
suffix = """Begin! Remember to speak Japanese when giving your final answer. Use lots of "Args"""

# エージェントを初期化
agent = initialize_agent(tools, llm, agent="zero-shot-react-description", memory=memory, verbose=True, prefix=prefix, suffix=suffix)


def save_conversation(user: str, user_input: str, bot_output: str):
    """firestoreにユーザーの入力とOpenAIの出力を保存する関数

    Args:
        users: スラックのユーザーID
        user_input: ユーザーの入力
        bot_output: スラックボットの出力

    Returns:
        なし
    """
    doc_ref = db.collection("testChat").document(user)  # you can also set your own document name
    doc_ref.set(       
                {
                "input": user_input,
                "response": bot_output,
                "timestamp" : datetime.datetime.now()
                }
    )


# Bot アプリにメンションしたイベントに対する応答
@app.event("message")
def handle_app_mention_events(body: dict, say: context.say.say.Say):
    """アプリへのメンションに対する応答を生成する関数

    Args:
        body: HTTP リクエストのボディ
        say: 返信内容を Slack に送信
    """
    logging.debug(type(body))
    logging.debug(body)
    box = Box(body)
    user = box.event.user
    text = box.event.text
    only_text = re.sub("<@[a-zA-Z0-9]{11}>", "", text)
    logging.debug(only_text)

    # OpenAI から AIモデルの回答を生成する
    openai_response = agent.run(input=only_text)

    logging.debug(openai_response)

    # DBに会話を保存する
    save_conversation(user, only_text, openai_response)

    # 返信する
    say(openai_response)




# Cloud Functions で呼び出されるエントリポイント
@functions_framework.http
def slack_bot(request: Request):
    """slack のイベントリクエストを受信して各処理を実行する関数

    Args:
        request: Slack のイベントリクエスト

    Returns:
        SlackRequestHandler への接続
    """
    header = request.headers
    logging.debug(f"header: {header}")
    body = request.get_json()
    logging.debug(f"body: {body}")

    # URL確認を通すとき
    if body.get("type") == "url_verification":
        logging.info("url verification started")
        headers = {"Content-Type": "application/json"}
        res = json.dumps({"challenge": body["challenge"]})
        logging.debug(f"res: {res}")
        return (res, 200, headers)
    # 応答が遅いと Slack からリトライを何度も受信してしまうため、リトライ時は処理しない
    elif header.get("x-slack-retry-num"):
        logging.info("slack retry received")
        return {"statusCode": 200, "body": json.dumps({"message": "No need to resend"})}

    # handler への接続 class: flask.wrappers.Response
    return handler.handle(request)
```

# 困ったこと
・**会話が記憶されない問題**
CloudFunction上にデプロイしてしまうことで、LangChainのメモリに会話履歴が残らない。。。サーバーレスの設計がいいと過信していた。LLMを用いる時は、サーバーを立てっぱなしにしておく方がいい。

・**FireStoreの仕様**
プライマリーキーがドキュメントという括りになっており、その中でフィールどにデータを追加していく。ただ、setというメソッドは、全体を置き換える方式なので、データを追加するということではない。
データを追加していきたい場合は、リスト型に前のデータをとってきてそれとともに現在の会話も記録しておくということをやってみた。所感としては、LangChainのメモリ機能を手動でやっている感覚なので、おすすめしない。

# 今後やっていきたいこと
会話を記憶させて答えさせたい場合、サーバーレス設計よりは、自分のローカルでサーバーを立てっぱなしにしておく方がいいという知見を得た。

pythonanywhereとFlaskでやるやり方がおすすめらしいので、今度やってみようと思う。

https://www.pythonanywhere.com/


# 参考資料
https://tech.connehito.com/entry/2023/05/24/182929#Models--Chains

https://ict-worker.com/ai/llm-memory-googlesearch.html

https://ict-worker.com/ai/langchain-memory-with-gpt-3-5.html

https://book.st-hakky.com/docs/memory-of-langchain/#conversationbufferwindowmemory-%E3%81%AE%E4%BD%BF%E3%81%84%E6%96%B9