PythonでGPT-4 Turboを使用したシンプルな対話型AIチャットボットの構築


# はじめに
GPT-4 Turbo(128K)早く使いたいですよね。
PythonでGPT-4 Turbo(gpt-4-1106-preview)を使用したシンプルな対話型AIチャットボットを作成しました。
応用編として、いくつか機能追加したソースも上げておきますので、皆様のお役に立てれば幸いです。

OpenAI API Reference

https://platform.openai.com/docs/api-reference/chat/create

ReadMe (OpenAI Python API library)

https://github.com/openai/openai-python

# ソースコードについて
## ソース概要
- basic_chatbot.py
  - 物凄くシンプルな（コンソール上での）対話型AIチャットボット
  - チャットを終えるには`exit`と入力すれば抜けられます

- chatbot.py
  - basic_chatbot.py を応用して、以下の機能追加したものになります
    - カラーテキスト出力
    - 通信中のインジケーター表示
    - 通信中の応答時間計測
    - ファイルのプリローディング機能（文字数計測付き）
    - 環境変数とコマンドライン引数による設定管理
    - チャット履歴の保存（デバッグ用）
    - 役割に応じたクラス分け（それっぽく）

:::note warn
注意点
- 本ツールでは、チャットボットとの対話毎に全ての対話履歴を（OpenAI APIで）送信することで対話を成立させています。
- そのため、対話回数が増えると（一度に送るトークン数が増えるため）、通信料金がそれなりにかかるので注意して下さい。
- 利用する際には、定期的に（exitで）チャットを終了して、対話セッションをリセットすることをお勧めします。（ツールを再起動すれば、対話履歴もリセットされます）
:::

## chatbot.py の利用イメージ

![chatbotの使用例.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1170961/c6835271-a873-477e-a710-c8718102cc38.png)

## ソースコード
- シンプル版

```python:basic_chatbot.py
from openai import OpenAI

def main():
    client = OpenAI(
        # api_key="YOUR_OPENAI_API_KEY", # Replace YOUR_OPENAI_API_KEY
    )
    messages = []

    while True:
        user_input = input("\n[Input](or 'exit'): ")
        if user_input.lower() == "exit":
            break

        messages.append({"role": "user", "content": user_input})
        completion = client.chat.completions.create(
            model="gpt-4-1106-preview",
            messages=messages,
        )
        bot_response = completion.choices[0].message.content
        messages.append({"role": "assistant", "content": bot_response})

        print(bot_response)

if __name__ == "__main__":
    main()
```

- 応用版

```python:chatbot.py
import argparse
import json
import os
import readline
import threading
import time
from openai import OpenAI

class ANSIColor:
    RED = "\033[31m"
    GREEN = "\033[32m"
    BLUE = "\033[34m"
    RESET = "\033[0m"
    BOLD = '\033[1m'

class Color:
    USER = ANSIColor.RED
    SYSTEM = ANSIColor.BLUE
    BOT = ANSIColor.GREEN

class Role:
    USER = "user"
    ASSISTANT = "assistant"
    SYSTEM = "system"

class OpenAIChatBot:
    def __init__(self, model_name="gpt-4-1106-preview"):
        self.client = OpenAI()
        self.model_name = model_name
        self.messages = []

    def add_message(self, role, content):
        self.messages.append({"role": role, "content": content})

    def get_bot_response(self, user_input):
        try:
            self.add_message(Role.USER, user_input)
            completion = self.client.chat.completions.create(
                model=self.model_name,
                messages=self.messages,
            )
            bot_response = completion.choices[0].message.content
            self.add_message(Role.ASSISTANT, bot_response)
            return bot_response
        except Exception as e:
            print(f"{ANSIColor.RED}[Error]: {e}{ANSIColor.RESET}")
            return ""

class PreLoader:
    def __init__(self, folder_path, extensions):
        self.folder_path = folder_path
        self.extensions = extensions

    def combine_files(self):
        combined_text = ""
        for root, dirs, files in os.walk(self.folder_path):
            for file in files:
                if any(file.endswith(ext) for ext in self.extensions):
                    with open(os.path.join(root, file), 'r') as infile:
                        combined_text += f"// Begin of {file}\n{infile.read()}\n// End of {file}\n\n"
        return combined_text

class ChatLogger:
    def __init__(self, history_file='chatbot_history.json'):
        self.history_file = history_file

    def save_history(self, messages):
        with open(self.history_file, 'w') as file:
            json.dump(messages, file, ensure_ascii=False, indent=4)

    def print(self, color, message):
        print(f"\n{ANSIColor.BOLD}{color}{message}{ANSIColor.RESET}")

class Indicator:
    def __init__(self, sleep_time=0.2):
        self.sleep_time = sleep_time
        self.indicator_thread = threading.Thread(target=self.show_indicator)
        self.indicator_active = False
        self.indicators = ["-", "\\", "|", "/"]

    def start(self):
        self.indicator_active = True
        if not self.indicator_thread.is_alive():
            self.indicator_thread = threading.Thread(target=self.show_indicator)
            self.indicator_thread.start()

    def stop(self):
        self.indicator_active = False
        if self.indicator_thread.is_alive():
            self.indicator_thread.join()

    def show_indicator(self):
        elapsed_time = 0.0
        indicator_msg = ""
        i = 0
        while self.indicator_active:
            indicator_msg = f"{self.indicators[i % len(self.indicators)]} [Waiting Time]: {elapsed_time:.1f} seconds"
            print(f"\r{ANSIColor.BOLD}{Color.SYSTEM}{indicator_msg}{ANSIColor.RESET}", end="")
            time.sleep(self.sleep_time)
            elapsed_time += self.sleep_time
            i += 1
        print("\r" + " " * len(indicator_msg) + "\r", end="") # Clear Indicator

class Configuration:
    def __init__(self):
        self.api_key = self.get_env_variable("OPENAI_API_KEY")
        self.args = self.parse_command_line_arguments()

    def get_env_variable(self, var_name):
        try:
            return os.environ[var_name]
        except KeyError:
            raise SystemExit(f"{ANSIColor.RED}[Error]: Environment variable '{var_name}' is not set.{ANSIColor.RESET}")

    def parse_command_line_arguments(self):
        parser = argparse.ArgumentParser(description='Terminal interface for OpenAI Chatbot')
        parser.add_argument('--use-preload', action='store_true',
                            help='Please specify if you want to preload a specific file.')
        parser.add_argument('--path', type=str, default=".",
                            help='Path to the folder for preloading. Default is the current directory.[Example]: --path /path/to/directory')
        parser.add_argument('--ext', nargs='*', default=[".py"],
                            help='Specify multiple file extensions for preloading, separated by spaces. Default is ".py". [Example]: --ext .py .md .txt')
        return parser.parse_args()

class Chatbot:
    def __init__(self):
        self.config = Configuration()
        self.chatbot = OpenAIChatBot()
        self.preloader = PreLoader(folder_path=self.config.args.path, extensions=self.config.args.ext)
        self.logger = ChatLogger()
        self.indicator = Indicator()

    def wake_up(self):
        self.preload_if_needed()
        while True:
            if self.start_chat() == "exit":
                break

    def preload_if_needed(self):
        if self.config.args.use_preload:
            preload_text = self.preloader.combine_files()
            if preload_text:
                self.chatbot.add_message(Role.SYSTEM, preload_text)
            self.logger.print(Color.SYSTEM, f"[Preloaded char count]: {len(preload_text):,}")

    def start_chat(self):
        # Waiting User Input
        self.logger.print(Color.USER, "[User Input](or 'exit'): ")
        user_input = input().strip()
        if user_input.lower() == "exit":
            return user_input
        elif user_input == "":
            return user_input

        # Start Log
        start_time = time.time()
        self.logger.print(Color.SYSTEM, "[Bot Output]: ")

        # Get Response
        self.indicator.start()
        bot_response = self.chatbot.get_bot_response(user_input)
        self.indicator.stop()
        self.logger.print(Color.BOT, bot_response)

        # End Log
        response_time = time.time() - start_time
        self.logger.print(Color.SYSTEM, f"[Response Time]: {response_time:.2f} seconds")

        # Save History
        self.logger.save_history(self.chatbot.messages)

def main():
    chatbot = Chatbot()
    chatbot.wake_up()

if __name__ == "__main__":
    main()
```

## クラス概要

chatbot.py:
- `OpenAIChatBot`クラス：OpenAI APIを使用してユーザーの入力を処理し、ボットの応答を生成する。
- `ANSIColor`, `Color`, `Role`クラス：ターミナルのテキスト出力に色とスタイルを付けるための定義を含んでいる。
- `PreLoader`クラス：特定の拡張子を持つファイルを事前に読み込むための機能を提供する。
- `ChatLogger`クラス：チャット履歴をJSONファイルとして保存し、メッセージをカラフルに出力するメソッドを備えている。
- `Indicator`クラス：処理中のインジケーターをコンソールに表示するためのスレッドベースの機能を提供する。
- `Configuration`クラス：環境変数やコマンドライン引数を解析し、アプリケーション設定を管理する。
- `Chatbot`クラス：上記のすべての機能を組み合わせて実際のインタラクティブなチャットボットインタフェースを形成する。

# ソースコードの使い方
## 事前準備
- OpenAIのAPIキーを取得
  - OpenAIのWebサイトからアカウントを作成し、APIアクセスのためのキーを取得
- Pythonのインストール
  - Python3以上
- OpenAI Pythonパッケージをインストール
  - ターミナルで`pip install openai`を実行
  - openaiのバージョンが 1.0 未満の場合は upgrade して下さい。`pip install --upgrade openai`
- Pythonスクリプト（`basic_chatbot.py` or `chatbot.py`）の準備
  - 必要なファイルをダウンロードまたはコピーして、実行環境に配置
- 必要に応じて事前読み込みしたいファイルの準備 (chatbot.py のオプション)
  - `--use-preload`オプションを使用する場合は、プリロードするファイルを準備し、指定するフォルダーに配置

## 使い方
basic_chatbot.py の使い方

1. OPENAI_API_KEY の設定
    - `basic_chatbot.py` スクリプト内の`YOUR_OPENAI_API_KEY`を、有効なOpenAI APIキーに置き換えて、コメントを外してください。（exportで指定済みの場合はこの作業は不要です）
    - `api_key = "YOUR_OPENAI_API_KEY"`
2. スクリプトの実行:
   - ターミナルで basic_chatbot.py が保存されているディレクトリに移動します。
   - ファイルのあるフォルダを右クリックして「フォルダに新規ターミナル」を選択すると簡単です。
   - `python basic_chatbot.py` を実行して、チャットボットを起動します。
3. チャットの開始:
   - スクリプトが起動すると、`[Input](or 'exit'):` と表示されます。
   - ユーザーはこのプロンプトにテキストを入力して、チャットボットに送信します。
4. チャットの終了:
   - `exit`と入力すると、チャットボットが終了します。

:::note
ツールを起動するたびに新しいチャットセッションが開始され、前のセッションの履歴は保持されません。
:::

chatbot.py の使い方

1. 環境変数の設定:
   - `OPENAI_API_KEY`という環境変数にOpenAIのAPIキーを設定します。
   - 例：LinuxやMacの場合、ターミナルで`export OPENAI_API_KEY='your_api_key'`を実行します。
2. スクリプトの実行:
   - ターミナルで chatbot.py が保存されているディレクトリに移動します。
   - `python chatbot.py` を実行して、チャットボットを起動します。
3. プリロードオプションの使用（任意）:
   - 詳細は「コマンドライン引数のサンプル」参照
   - 特定のファイルやディレクトリから情報をプリロードするには、追加のコマンドライン引数を使用します。
   - `--use-preload`を指定してプリロード機能を有効にします。
   - `--path`を指定して、プリロードするファイルがあるディレクトリを指定します。
   - `--ext`を指定して、プリロードするファイルの拡張子を指定します。
4. チャットの開始:
   - スクリプトが起動すると、`[User Input](or 'exit'):` と表示されます。
   - ユーザーはこのプロンプトにテキストを入力して、チャットボットに送信します。
5. チャットの終了:
   - `exit`と入力すると、チャットボットが終了します。
6. チャット履歴の保存:
   - 自動的にチャットボットとの会話は、`chatbot_history.json`という名前のJSONファイルに保存されます。

:::note warn
プリロードオプションを利用する場合は、読み込み過ぎないように注意して下さい。（一応プリロードをした文字数カウントは出力しているので、128Kを過ぎないように、自己責任でお願いします）
:::

### コマンドライン引数のサンプル
- プリロード機能を使わずにスクリプトを実行する例：
    - `python chatbot.py`
- 特定のディレクトリから`.md`ファイルをプリロードする例：
    - `python chatbot.py --use-preload --path /path/to/directory --ext .md`
    - 上記コマンドでは、`--use-preload`でプリロード機能を有効にし、`--path`でプリロードするファイルが存在するディレクトリを指定し、`--ext`でプリロードするファイル拡張子（ここでは`.md`）を指定しています。
- さまざまな種類の拡張子のファイルをプリロードする例：
    - `python chatbot.py --use-preload --path /path/to/directory --ext .py .txt .md`
    - このコマンドにより、`.py`、`.txt`、`.md`のファイルがプリロードされます。
- 現在のディレクトリから`.py`をプリロードするだけの例：（デバッグ用）
    - `python chatbot.py --use-preload`
    - `--path`と`--ext`を指定しないと、（デフォルト値の）現在のディレクトリから`.py`のファイルをプリロードすることになります。

:::note
`/path/to/directory`は実際のファイルのあるディレクトリパスに置き換えてください。
:::

## 利用時の注意点

1. **OpenAIのAPIキーの扱い**: OpenAIのAPIキーは秘密情報です。それをソースコード内で直接記述したり、他人と共有したりするべきではありません。APIキーは環境変数や別の設定ファイルから読み込むようにし、決してリポジトリにコミットしないでください。

2. **API利用料金**: OpenAIのAPIを利用する際には使用量に応じて料金がかかります。無料枠を超えると課金されるので、使用量を監視し、予算を超えないように計画してください。

3. **モデル選択**: 利用するモデルはデフォルトで"gpt-4-1106-preview"に設定されています。これはOpenAIが提供するモデルの1つであり、将来的には更新されたり、利用できなくなったりする可能性があります。利用可能なモデルとその特徴を理解し、目的に合ったものを選択してください。

4. **レートリミット**: OpenAI APIはリクエストのレートリミットが設定されています。連続したリクエストを大量に送ると、一時的に利用制限を受ける可能性があります。

5. **セキュリティ**: チャットボットを公開する場合は、入力内容のバリデーションやエスケープ処理を確実に行い、悪意のある入力によってシステムが危険にさらされないように対策しましょう。

6. **エラーハンドリング**: ソースコード内でAPIのレスポンスが失敗した場合の例外処理が行われていますが、エラーの原因に応じた詳細な対処方法やリトライロジックを実装することが推奨されます。

7. **プリロードコード**: `PreLoader` クラスがファイルを読み込んでプリロードする機能を持っていますが、大量のデータを読み込む際にはメモリ容量に注意し、システムリソースを過度に消費しないようにしてください。

8. **非同期動作への対応**: インジケーターの表示などの非同期動作が含まれています。クラッシュや不測の動作をすることがあっても許してください。

9. **改変可能性**: ソースコードは改変を行うことができますが、OpenAIのライセンス条項やガイドラインを遵守し、適切なライセンスの範囲内で改変や再配布を行うようにしてください。

10. **別のコマンドライン引数**: コマンドライン引数を使用してプリロードの設定やパスの指定などを行います。これらの引数に不正な値を指定すると、期待しない動作やエラーにつながる場合があるので注意が必要です。

# 参考サイト

https://dev.classmethod.jp/articles/chatgpt-console-app-using-python/

https://namileriblog.com/python/chatgpt-api/

上記の素晴らしい記事を参考にさせて頂きました。誠に有難うございます！

# おわりに
この記事とソースコードは、本ツールを利用してbot君と対話しながら作成したものになります。
ある程度の手動調整は必要でしたが、大部分はbot君が作成してくれました。
AIの力は凄いですね。皆様も楽しみながらご利用頂けると大変嬉しく思います。