$shibayu36->blog;

クラスター株式会社のソフトウェアエンジニアです。エンジニアリングや読書などについて書いています。

直近の会話を考慮してChat Completion APIを呼ぶお試し

いろんな人がサンプルコードを上げているけど自分も理解するためにお試しした。

動作の雰囲気

Image from Gyazo

コード

chat_example.py

from functools import reduce
import os
import openai
import tiktoken

openai.api_key = os.environ["OPENAI_API_KEY"]

encoding = tiktoken.get_encoding("cl100k_base")


def get_total_token_count(messages):
    return reduce(
        lambda acc, message: acc + len(encoding.encode(message["content"])), messages, 0
    )


messages = [
    {"role": "system", "content": "関西弁で話して"},
    {"role": "system", "content": "発言の最後に「知らんけど。」をつけて"},
]


while True:
    user_message = input("You: ")
    messages.append({"role": "user", "content": user_message})

    print("Assistant: ", end="", flush=True)
    response = openai.ChatCompletion.create(
        model="gpt-3.5-turbo",
        messages=messages,
        temperature=0.5,
        stream=True,
    )
    current_message = ""
    for chunk in response:
        delta_content = chunk["choices"][0]["delta"].get("content", "")
        print(delta_content, end="", flush=True)
        current_message += delta_content
    print()

    messages.append({"role": "assistant", "content": current_message})

    total_token_count = get_total_token_count(messages)
    print(f"Total token count: {total_token_count}")

    # Delete old messages if messages are about to exceed the limit
    while total_token_count > 2500:
        print("Delete old context")
        del messages[2]
        total_token_count = get_total_token_count(messages)
        print(f"Total token count: {total_token_count}")

ポイントとしては

  • ユーザー入力と回答をmessagesにどんどん保存していき、全てを合わせてChatCompletion.createへ渡していく
  • stream=Trueを入れるだけで簡単にストリーム出力ができる
  • トークン数上限は自分でハンドリングする必要がある。今回は2500トークンを超えたら、それを下回るまで過去のメッセージを削除していく方式にした

Pythonで作ったCLIツールをGitHubから直接pipでinstallできるようにする方法

chat-hatenablogをpip installでインストール可能にした - $shibayu36->blog; にて、pip installで直接CLIツールをインストールできるようにした。

pip install git+https://github.com/shibayu36/chat-hatenablog.git

この時に調べたことをメモしておく。

やったこと

setup.pyを配置し、entry_points.console_scriptsにCLIとして動かしたいものを指定するだけ。

import os
from setuptools import setup, find_packages

here = os.path.abspath(os.path.dirname(__file__))

about = {}
with open(os.path.join(here, "chat_hatenablog", "__version__.py")) as f:
    exec(f.read(), about)

setup(
    name="chat_hatenablog",
    version=about["__version__"],
    author="Yuki Shibazaki",
    author_email="shibayu36@gmail.com",
    description="AI-powered software to interact with your HatenaBlog",
    license="MIT",
    packages=find_packages(),
    install_requires=[
        "python-dotenv==1.0.0",
        "html2text==2020.1.16",
        "langchain==0.0.132",
        "tqdm==4.65.0",
        "openai==0.27.4",
        "tenacity==8.2.2",
        "tiktoken==0.3.3",
        "numpy==1.24.2",
    ],
    extras_require={
        "dev": [
            "pytest==7.3.1",
            "mypy==1.2.0",
            "pytest-mypy-plugins==1.10.1",
            "types-tqdm==4.65.0.1",
        ],
    },
    entry_points={
        "console_scripts": [
            "chat-hatenablog = chat_hatenablog.main:main",
        ]
    },
)

このように配置するだけで、pip install git+https://github.com/shibayu36/chat-hatenablog.git するとchat-hatenablogコマンドがインストールされる。

もう少し詳しい説明をする。

基本はsetup.pyを置いておくだけでGitHubからインストールできる

そもそもpip installがgit repositoryに対応しているため、setup.pyを置いてpipが認識できるようにしたら良い。meta情報系を色々書き、依存パッケージをinstall_requiresに入れておく。

CLIツールで重要なポイントは、entry_points.console_scriptsへの指定だ。ここに コマンド名 = (package名):(関数名) という指定を加えておくことで、そのコマンドを実行したときにどの関数を呼び出すか設定できる。chat-hatenablogの場合、chat_hatenablog/main.pyのdef mainの中にエントリーポイントとしての処理を書き、setup.pyからそこを指定した。

ちなみに最近の形式としてpyproject.tomlを使った方式もあるが、機能が多くていまいちよくわからず、一旦シンプルにsetup.py形式でやってみた。もちろん色々便利なグッズは揃っているはずなので、また試してみたい。 参考: https://qiita.com/ieiringoo/items/4bef4fc9975803b08671

extras_requireに開発向けの依存パッケージを置く

pytestなどはインストール時には不要で、開発向けに必要な依存パッケージである。このようなものもsetup.pyで管理ができる。具体的にはextras_requireにラベル付きで依存パッケージを書ける。

    extras_require={
        "dev": [
            "pytest==7.3.1",
            "mypy==1.2.0",
            "pytest-mypy-plugins==1.10.1",
            "types-tqdm==4.65.0.1",
        ],
    },

このようにしておくと次のように指定することで、editorialモードでdevも含めてインストールし、開発を始めることができる。

pip install -e '.[dev]'

version情報を一箇所にまとめる

ツール内でバージョン情報を使うために、現在のchat-hatenablogのバージョン情報をchat_hatenablog/__version__.py というファイル内に入れていた。setup.pyからもこれをimportして使えば良いだけと考えた。しかしこの状態でpip install -e '.[dev]'すると、次のようなエラーでうまくいかなかった。

ModuleNotFoundError: No module named 'numpy'

おそらくこれはfrom chat_hatenablog/__version__ import __version__したタイミングでいろんなモジュールの読み込みは走ってしまっており1、pip install時には依存モジュールは入っていないためエラーになってしまっているようだ。

これに関してはpipenvの例が参考になった。__version__.pyに定義されているデータだけ読み込むためにexecするという技。

here = os.path.abspath(os.path.dirname(__file__))

about = {}

with open(os.path.join(here, "pipenv", "__version__.py")) as f:
    exec(f.read(), about)

これを参考にしてversion情報は__version__.pyで管理し、setup.pyではそれを利用することができた。この辺りはもしかすると別ツールを使うと簡単にできるかもしれないので、また調べてみたい。

まとめ

今回はchat-hatenablogをpip installできるようにした時に調べたことをまとめてみた。こうするともっと便利という情報があれば知りたい。

参考資料


  1. もちろんchat_hatenablog/__version__.pyにはversion情報しか入れておらず、他のimport文はなかったが、ロードのなんらかの仕組みで__init__.pyなど別ファイルも読み込まれてしまっているのだろう

CLIツールを作るとき、ユーザー設定ファイルやデータをどこに配置するか

chat-hatenablogをpip installでインストール可能にした - $shibayu36->blog;にてchat-hatenablogをpip installできるようにするとき、ユーザー設定ファイルやデータをどこに配置するかに迷った。このツールでは、環境変数の設定として.envファイルを、ブログデータのインデックスとしてindex.pickleファイルを使っている。

これらのファイルの置き場所について少しだけ調べたので、現状分かったことをメモしておく。

まず選択肢としては二つありそうだった。

今回はディレクトリを二つに分けるの面倒だなと思い、~/.chat-hatenablog/配下に置くという選択を取った。

一方でXDG Base Directoryの仕様に沿うと、利用ユーザーがXDG_CONFIG_HOME環境変数などを用いて設定ファイルの配置場所をコントロールできるというメリットがありそうだ。例えばpecoだとこのようにXDG_CONFIG_HOMEやXDG_CONFIG_DIRSを参照している

そういうわけで、ちゃんとしたツールを作るならXDG Base Directoryの仕様に沿って作ると良いと分かった。またRaycastといったツールも同じようなディレクトリ構成になっているため、CLIツールに限らず、このような配置を使えば良さそうだ。