ChatGPT API 使って Slackbot で遊ぶ 

# はじめに

すでに ChatGPT API と Slackbot に関する優れた記事はたくさんありますが、APIを使った実装というのは自分でやってみると個人的な気づきがあるもので 備忘録として残しておきたいと思います。

__記事の内容__

- ChatGPT API の概要
- ChatGPT API の Role
- Slack の会話履歴取得
- ChatGPT API リクエスト
- Slack アプリのメッセージハンドリング

:::note info
事前準備として、Slack bolt を使用したアプリの作成と、OpenAI API Keyの作成が必要になりますが この記事では割愛しています。
1. [Getting started with Bolt for Python](https://slack.dev/bolt-python/tutorial/getting-started)
2. [Where do I find my Secret API Key?](https://help.openai.com/en/articles/4936850-where-do-i-find-my-secret-api-key)
:::


# 作成したもの

メッセージを投稿すると Slack アプリが ChatGPT API にリクエストを送信し、API から受け取った回答を スレッドに返信します。
ChatGPT と会話を再開する場合は、スレッドにある 会話履歴 を利用します。


https://github.com/kodamap/slackbot-gpt-x

__会話の様子（大喜利）__
![chat.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/118309/a5ebd5ff-61b7-e9d7-b9d7-83ea9c384f04.jpeg)


# 実装ポイント

ChatGPT API を使った Slack アプリの実装のポイントを記載します。

## ChatGPT API の概要

参考: [OpenAI Documentation - Chat completions - ](https://platform.openai.com/docs/guides/chat/introduction) より

```py
# Note: you need to be using OpenAI Python v0.27.0 for the code below to work
import openai

openai.ChatCompletion.create(
  model="gpt-3.5-turbo",
  messages=[
        {"role": "system", "content": "You are a helpful assistant."},
        {"role": "user", "content": "Who won the world series in 2020?"},
        {"role": "assistant", "content": "The Los Angeles Dodgers won the World Series in 2020."},
        {"role": "user", "content": "Where was it played?"}
    ]
)
```

上の例では、ChatGPT API にリクエストを送信する例を示しています。モデル自体は過去履歴を保持しないため リクエストを送信する際は  **以前の会話を参照する仕組み** が必要になります。

>`System`: 「あなたは役に立つアシスタントです」（基本的な挙動を定義）
`User:` 「2020年のワールドシリーズを制したのは誰か？」（**質問1**）
`assistant:` 「ロサンゼルス・ドジャースがワールドシリーズを制覇しました」（**ChatGPTの回答1**）
`User:`  「**それ** はどこで行われたのですが？」（**質問2**）

**質問2** 「**それ**はどこで行われたのか？」は **回答1** を指しています。API に 会話履歴 を送信することによって  **"「それ」"** を解釈した 回答を得ることができます。

このリクエストに対する回答は 以下のようになります。
> The 2020 World Series was held at Globe Life Field in Arlington, Texas.

仮に「会話履歴」なしに リクエストを送信した場合は、文脈がないまま 「**それ**はどこで？」と聞かれたことになるため、ChatGPT は回答できません。

```py
openai.ChatCompletion.create(
  model="gpt-3.5-turbo",
  messages=[
        {"role": "system", "content": "You are a helpful assistant."},
        {"role": "user", "content": "Where was it played?"}
    ]
)
```

> I apologize, but I'm not sure what you are referring to. Could you please provide more context or clarify your question? 

つまり 冒頭の例のように 会話の履歴から `user` と `assistant` ロールを交互に結合して リクエストを送信する必要があります。

## ChatGPT API の "Role" について

ChatGPT API のリクエスト時に使う `role` は `system`, `user`, `assistant` の 3種類があります。

`system` ロールは、Chatアシスタント の基本挙動を設定します。 

>システムメッセージは、アシスタントの行動を設定するのに役立ちます。
The system message helps set the behavior of the assistant. In the example above, the assistant was instructed with "You are a helpful assistant."

`user` ロールは　私たちの質問(`prompt`) を設定します。 

>ユーザーメッセージは、アシスタントに指示を出すのに役立ちます。アプリケーションのエンドユーザーによって生成されたり、開発者が指示として設定したりします。
The user messages help instruct the assistant. They can be generated by the end users of an application, or set by a developer as an instruction.

そして `assistant` ロールには、質問に対する回答を設定します。

>アシスタントメッセージは、事前の応答を保存するのに役立ちます。また、望ましい動作の例を示すために、開発者が書くこともできます。
The assistant messages help store prior responses. They can also be written by a developer to help give examples of desired behavior.


## Slack の会話履歴取得

Slack のスレッドには ChatGPT との会話履歴が残ります。この履歴を取得して ChatGPT API に送信することで会話のつながりを保持して会話が再開できます。Slack の会話履歴を取得するには、Slack API の [`app.client.conversations_replies`](https://api.slack.com/methods/conversations.replies) を使用[^1]します。

[^1]:最初は Slack API のことを良く知らず、データベースに保存する方法にしていたため かなり複雑な作りになっていました。。

例えば 以下のような会話があったとして
![thread2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/118309/4f668ad9-426b-aa8d-0329-3fa6eed99727.jpeg)

APIで取得するには、チャンネルとスレッドを指定します。
```py
# app.py
response = client.conversations_replies(channel=message["channel"], ts=message['thread_ts'])
```

以下のような出力となります。（主要部分を抜粋）
```json
 'messages': [{ ..
               'text': '新しい会話',
               'thread_ts': '1679491978.737129',
               'ts': '1679491978.737129',
               'user': 'XXXXXXX'},
              {'app_id': 'AAAAAAAAA',
　　　　　　　　 ..
               'text': 'こんにちは！何かお話しましょうか？',
               'thread_ts': '1679491978.737129',
               'ts': '1679491981.020769',
               'user': 'XXXXXXX'},
              {..
　　　　　　　　'text': '１つ目の会話',
               'thread_ts': '1679491978.737129',
               'ts': '1679491997.450219',
               'user': 'XXXXXXX'},
              {'app_id': 'AAAAAAAAA',
             　..
               'text': 'いいですよ。何についてお話しましょうか？趣味や仕事、最近興味を持っていることなど、教えてください。',
               'thread_ts': '1679491978.737129',
               'ts': '1679492000.758959',
               'type': 'message',
               'user': 'XXXXXXX'},
              {..
　　　　　　　　'text': '２つ目の会話',
　　　　　　　　..
　　　　　　　　..
```

このデータから ChatGPT に渡すフォーマットに整形します。ChatGPT からの回答には  `app_id` が付与されるので、`app_id` があれば `assistant`, なければ `user` を指定してメッセージを作ります。[^2]

[^2]: `time_stamp` は メッセージを作成する際に必要ありません。

```py
# app.py
def create_chat_history(response, thread_ts):
    """
    Create a message for ChatGPT API from the thread content.
    If app_id is present, it is assumed to be a response from ChatGPT,
    set "assistant" role. If not, set "user" role.
    """
    content = {"role": "system", "content": system_content}
    chat_history = {thread_ts: {"message": [content], "time_stamp": []}}

    for res in response.data["messages"]:
        app_id = res.get("app_id")
        role = "assistant" if app_id else "user"
        text = res.get("text")
        time_stamp = res.get("ts")
        content = {"role": role, "content": text}
        chat_history[thread_ts]["message"].append(content)
        chat_history[thread_ts]["time_stamp"].append(time_stamp)

    return chat_history
```

__例：リクエストに送信する会話履歴__
```python
# chat_history[thread_ts]['message']
[{'content': '\nあなたは役に立つアシスタントです。\n', 'role': 'system'},
 {'content': '新しい会話', 'role': 'user'},
 {'content': 'こんにちは！何かお話しましょうか？', 'role': 'assistant'},
 {'content': '１つ目の会話', 'role': 'user'},
 {'content': 'いいですよ。何についてお話しましょうか？趣味や仕事、最近興味を持っていることなど、教えてください。',
  'role': 'assistant'},
 {'content': '２つ目の会話','role':'user'}]
```

## ChatGPT API リクエスト

APIにリクエストを送るメソッドはシンプルです。`openai.ChatCompletion.create` では `model` と `max_tokens` を指定します。

```py
# chatgpt.py

class ChatGPT:
    def __init__(self, max_tokens, model):
        self.max_tokens = max_tokens
        self.model = model

    def request(self, messages):
        """Send an API request
        Pass a message (chatgpt_message) created by merging thread messages to the API
        """

        try:
            response = openai.ChatCompletion.create(
                model=self.model,
                max_tokens=self.max_tokens,
                messages=messages,
            )
            answer = response["choices"][0]["message"]["content"]
        except BaseException as e:
            traceback.print_exc()
            error = f":fried_shrimp: An error has occurred.\n ```{e}```"
            return error
        return answer
```

`system` ロールに指定するコンテンツや、 `model` と `max_tokens` は 設定ファイルで指定するようにしました。

```conf
# config.ini

[CHATGPT]
# system: The system message helps set the behavior of the assistant.
# see example: https://platform.openai.com/docs/guides/chat/introduction
system = """
    You are the robot 'TARS' from the movie Interstellar.
    The default value of the joke level is 100 percent.
    """
# https://platform.openai.com/docs/api-reference/completions/create
# The maximum number of tokens to generate in the completion.
max_tokens = 1024

# ID of the model to use. 
# You can use the List models API to see all of your available models, 
# or see our Model overview for descriptions of them.
model = "gpt-3.5-turbo"
```

## Slack アプリの メッセージハンドリング

Slack が メッセージを受け取って、ChatGPT に送るメッセージを 会話履歴から作成し API にリクエストを送ります。

メッセージを削除した際にも イベントが発生するようで、`message['user']` が `KeyError`となるので `if message.get('user') is None:` で判定しました。

また、新規でスレッドを立てる場合は `message.get['thread_ts']` がなく、 同様に `KeyError` エラーとなるため `client.conversations_replies` に渡す前に、`if message.get('thread_ts'):` で、新規 or 既存スレッドの判定を入れました。

```py
# app.py

@app.event("message")
def handle_message_events(client, message):
    """
    New messages will be threaded.
    Messages in threads are retrieved with `conversatins_replies`,
    and then requesed in the format passed to ChatGPT API.
    The replies are returned to the same thread.
    """

    # message does not contain 'user' when deleting message
    if message.get("user") is None:
        return

    # Create ChatGPT instances
    chatgpt = ChatGPT(max_tokens, model)

    # An existing thread has thread_ts key
    if message.get("thread_ts"):
        thread_ts = message.get("thread_ts")
    # New thread
    else:
        thread_ts = message.get("ts")

    # Get thread messages and create messages for chatgpt request
    response = client.conversations_replies(channel=message["channel"], ts=thread_ts)
    chat_history = create_chat_history(response, thread_ts)

    # Add created chatgpt message for api request as a User instance's property
    chat_history = chat_history[thread_ts]["message"]
    logger.info(f"user_id:{message['user']} thread_ts:{thread_ts} chat:{chat_history}")

    # Sent request to chatgpt
    answer = chatgpt.request(chat_history)
    logger.info(f"user_id:{message['user']} thread_ts:{thread_ts}  chat:{answer}")
    client.chat_postMessage(
        channel=message["channel"], thread_ts=thread_ts, text=answer
    )

    del chatgpt
```


# 最後に

ChatGPTの「なりきり」タスクの使い方は 皆様のたくさんやられていますが、とても楽しいですね。無茶ぶりにも 実直に遂行してくれるので、そこが面白くて可愛らしいです。

# 参考

https://platform.openai.com/docs/guides/chat/introduction

https://github.com/sifue/chatgpt-slackbot

https://umiwave.work/ippon-grand-prix/