OpenAI DevDayでAPIがリニューアル発表
11月7日のOpenAI DevDayでGPTのAPIが一新し、様々な新機能をリリースしたことを発表しました。
発表の中で個人的に、今まで有料会員(GPTプラス)でしか使えなかった自動でグラフを作ってくれるコードインタプリターを利用できるのが楽しみでした。(無課金勢じゃないですが月約3000円払うのが嫌でした…)
が
利用してみると中々しねたのでここに記事にしたいと思います。
Assistants APIの登場
Assistants APIはβ版です。今後記述方法は変更予定です。
今までAPIはChat Completionというのを利用してGPTから回答を得ていましたが、それとは別にAssistantsを利用して回答を得ることができるようになりました。
この公式の図を見てもあまりわからないかと思いますが、スレッドというものが会話を保持してくれるのでChat Completion方式で必要であった過去の会話の管理が不要となり、こちらからぽんぽんとメッセージを渡すだけで回答を返してくれます。
実装方法は公式またはnpakaさんの記事が解説していまして非常にわかりやすくてありがたいです。
ざっとメッセージ取得までのコードを記載しておきます。
from openai import OpenAI
import os
os.environ["OPENAI_API_KEY"] = 'sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
client = OpenAI()
assistant = client.beta.assistants.create(
name="assistant01",
instructions="あなたは優秀なアシスタントです。質問をされた場合は、質問に答えるコードを作成して実行します。",
model="gpt-3.5-turbo-1106", # Code Interpreterはどのモデルでも大丈夫です
tools=[{"type": "code_interpreter"}]
)
assistant_id = assistant.id
今回はtoolに"code_interpreter"としましたがお好みでかまいません。
※アシスタントは1度作成するだけで使いまわせます。忘れてもここでIDを取得できます。
https://platform.openai.com/assistants
thread = client.beta.threads.create()
# 問い合わせ内容をセット
prompt = "こんにちは!"
# メッセージの作成
message = client.beta.threads.messages.create(
thread_id=thread.id,
role="user",
content=prompt,
# file_ids=[file_id]
)
run = client.beta.threads.runs.create(
thread_id=thread.id,
assistant_id=assistant_id
# instructions="" # ここに新たにinstructionを入れることも可能
)
run = client.beta.threads.runs.retrieve(
thread_id=thread.id,
run_id=run.id
)
print(run.status)
complete
ざっと書きましたがスレッドに問い合わせ内容をセットしRUNをスタート。
run.statusが"complete"であれば完了→アシスタントから回答が返ってくる
という流れになります。
(ちなみにAssistants APIは複数の回答が返ってくることもあり、completeとなっていなくても途中までのメッセージは取得できます。)
で肝心のメッセージの取得方法は以下です。
messages = client.beta.threads.messages.list(
thread_id=thread.id,
order = "asc", # 昇順
# after = "", # これを指定で前回以降のメッセージなど抽出が可能
)
for msg in messages:
print(msg.role + ":" + msg.content[0].text.value)
user:こんにちは!
assistant:こんにちは!いつでも質問があればどうぞ。
threads.messages.listで今までのメッセージを全て取得し、for文で1つ1つのメッセージを取り出しています。それぞれのメッセージはThreadMessageオブジェクトというもので何ともわかったようなわからないものですが、.roleや.content[0].textで役割や返答の中身を取得することができます。(content[0]なのはとりあえず置いといて下さい)
生のThreadMessageをメッセージを出力するとこんな感じです。
ThreadMessage(id='msg_ypkKXnyeQQE7bTLoMZX9q5dj', assistant_id='asst_kVgHpOPgjGmVvgFlXnzMjjFN', content=[MessageContentText(text=Text(annotations=[], value='こんにちは!いつでも質問があればどうぞ。'), type='text')], created_at=1700310664, file_ids=[], metadata={}, object='thread.message', role='assistant', run_id='run_iqEtuT7ebSYyi7MGOUDoXXRG', thread_id='thread_dnLFTITx81xbQ4cG9dZ1vlra')
再び問い合わせるなら①に戻る。このような感じでAssistants APIは会話を重ねることができます。
で
どこが「しねる」かというとここからです…
アシスタントから画像の提示
コードインタープリターでは結果を画像(.pngファイル)で返すことができます。
グラフを簡単に描いて画像でくれるのでとても便利です。
試しに二次関数のグラフy=x^2を描くようお願いしてみます。
※⓪を実行してスレッドを新規作成
prompt = "y=x^2のグラフを描いてくれますか。"
# メッセージの作成
message = client.beta.threads.messages.create(
thread_id=thread.id,
role="user",
content=prompt,
)
②~⑥と全く同じコードで実行するとこうなります。
AttributeError Traceback (most recent call last)
<ipython-input-32-63ba2887ae8f> in <cell line: 2>()
1 for msg in messages:
----> 2 print(msg.role + ":" + msg.content[0].text.value)
AttributeError: 'MessageContentImageFile' object has no attribute 'text'
このようにエラーとなります。
何が不満かと言っていると ”msg.content[0]はtextなんか存在しない"とのこと。
要は描いてもらったグラフの画像情報がmsg.contentに入ることがあり、それはMessageContentImageFileというオブジェクトでtextプロパティなんか持っていないため怒られています。
MessageContentImageFileの中身はこんな感じで
MessageContentImageFile(image_file=ImageFile(file_id='file-9YXbIItSQBCBoKc82hR6amGQ'), type='image_file')
このfile_idからダウンロードができ、目的の画像が取得できます。(後ほどダウンロード実行)
ということで画像があるときと場合分けが必要となり
画像がある時はfile_id、それ以外のときはtext(.value)を取得
という処理が必要となります。
これをどう対応するか?
今思うとMessageContentImageFileやらオブジェクトで型判定してもいいかと思いましたが、こんな方法を使っていました。コードを先に書きます。
for msg in messages:
print(msg.role + ":")
if msg.role == "assistant":
for content in msg.content:
image_fileid = ""
assist_msg = ""
cont_dict = content.model_dump() # 辞書型に変換
if cont_dict.get("image_file") is not None:
image_fileid = cont_dict.get("image_file").get("file_id")
print(f"ファイルID:{image_fileid}")
if cont_dict.get("text") is not None:
assist_msg = cont_dict.get("text").get("value")
print(assist_msg)
else:
print(msg.content[0].text.value)
user:
こんにちは!
assistant:
こんにちは!いつでもお手伝いできますので、質問がありましたら遠慮なくどうぞ!
user:
y=x^2のグラフを描いてくれますか。
assistant:
ファイルID:file-T7lPeHRi3MD1uUdK89cwukkH
こちらが、y=x^2のグラフです! xの値が増加するにつれて、yの値も増加することがわかりますね。他にも何かわからないことがあれば、お知らせください!
少し長いコードとなりましたが、コードの一番の肝はcontent.model_dump() でcontentをdict型に変換していることです。
dict型にすると何がいいかというとget("キー")として値を取得すればキーがないときもエラーとならずNoneで返してくれます。(参考)
これを利用してmessagesループ→contentループしdictのキーで "image_file"があれば"file_id"をとってきて、"text"があれば"value"をとってくる
という処理を実装しています。
ファイルのダウンロード方法は以下です。
# image_fileidにファイルIDをセット
retrieve_file = client.files.with_raw_response.retrieve_content(image_fileid)
content = retrieve_file.content
# ファイル名は適当に付けて下さい
with open(image_fileid + ".png", 'wb') as f:
f.write(content)
これが画像ダウンロードまでの道のりです。
その他のファイル形式
で、これでおしまいではありません。
今度はテキストの出力をお願いしてみます。
prompt = "円周率を100桁まで計算して、それをテキストファイルに出力して下さい。"
message = client.beta.threads.messages.create(
thread_id=thread.id,
role="user",
content=prompt,
)
②~⑤は同じで"complete"後、⑦messages表示その2をやってみます。
user:
円周率を100桁まで計算して、それをテキストファイルに出力して下さい。
assistant:
円周率を100桁まで計算し、テキストファイルに出力しました。[こちらのリンク](sandbox:/mnt/data/pi_value.txt)からファイルをダウンロードできます。
どうやら出力に成功しているようですが、"image_file"には何もないようです。
([こちらのリンク](sandbox:/…はGPTプラスの名残であって使いようがありません)
アシスタントのmessagesの中身を見てみます。
for msg in messages:
if msg.role == "assistant":
print(msg)
ThreadMessage(id='msg_UoET1WPeOUOWRar1eDP5gZOb', assistant_id='asst_kVgHpOPgjGmVvgFlXnzMjjFN', content=[MessageContentText(text=Text(annotations=[TextAnnotationFilePath(end_index=70, file_path=TextAnnotationFilePathFilePath(file_id='file-KKrcZkiyRKQzLrpqu7U5O1zW'), start_index=40, text='sandbox:/mnt/data/pi_value.txt', type='file_path')], value='円周率を100桁まで計算し、テキストファイルに出力しました。[こちらのリンク](sandbox:/mnt/data/pi_value.txt)からファイルをダウンロードできます。'), type='text')], created_at=1700464560, file_ids=['file-KKrcZkiyRKQzLrpqu7U5O1zW'], metadata={}, object='thread.message', role='assistant', run_id='run_cDXgONcky7ooRJIEHuvLBSH6', thread_id='thread_AAIROK6yQjUFZscE8dRAJKYK')], object='list', first_id='msg_NcgC6eSbFTThhlYHZCxvbg4K', last_id='msg_UoET1WPeOUOWRar1eDP5gZOb', has_more=False)
今度は"text_file"に入っているのでは…と考えてしまいますが、そんなのは無いようです。
さっさと結果を言ってしまうと画像以外のファイルはtext内の"annotations"の中に入っています。
annotations=[TextAnnotationFilePath(end_index=70, file_path=TextAnnotationFilePathFilePath(file_id='file-KKrcZkiyRKQzLrpqu7U5O1zW'), start_index=40, text='sandbox:/mnt/data/pi_value.txt', type='file_path')]
ここに file_id='file-KKrcZkiyRKQzLrpqu7U5O1zW' があるので、ここからダウンロードできそうです。
annotationsは「注釈」という意味でGPTにとっては参考資料という位置づけのようです。
なぜ画像と扱いが違うのか?
はわかりませんが、とにかくこちらに入っています。
(元々ChatGPTプラスのアプリのために設計されたのかもしれません)
ということで、これも色々やってファイルIDを取得しますが"annotations"は常にキーとしては存在するので、要素数が0より大きいかどうかでファイルがあるか判定します。
for msg in messages:
print(msg.role + ":")
if msg.role == "assistant":
for content in msg.content:
file_id = ""
assist_msg = ""
cont_dict = content.model_dump() # 辞書型に変換
cont_text = cont_dict.get("text")
if cont_text is not None:
assist_msg = cont_text.get("value")
print(assist_msg)
# 注釈(参照ファイル)ががある場合取得
if len(cont_text.get("annotations")) > 0:
annotations = cont_dict.get("text").get("annotations")
# ※今回は参照ファイル1つだけ取得
ant = annotations[0]
if ant.get("file_path") is not None:
# 参照ファイルのID取得
file_id = ant.get("file_path").get("file_id")
if ant.get("text") is not None:
# ファイル形式(拡張子)取得
ext = "." + ant.get("text")[ant.get("text").rfind('.') + 1:]
print(f"file_id:{file_id} ファイル形式:{ext}")
else:
print(msg.content[0].text.value)
user:
円周率を100桁まで計算して、それをテキストファイルに出力して下さい。
assistant:
円周率を100桁まで計算し、テキストファイルに出力しました。[こちらのリンク](sandbox:/mnt/data/pi_value.txt)からファイルをダウンロードできます。
file_id:file-KKrcZkiyRKQzLrpqu7U5O1zW ファイル形式:.txt
file_idだけだったらまだいいのですが、ここのしねしねポイントはファイル形式です。
.txtだけではなく.csvや.pdfなどを出力する場合があり、それはfile_idだけではわかりません。
プログラムで使うなら自身で指定すればいいですが、アプリなどに組み込むならどの拡張子になるかを別途取得する必要があります。
ということで"text"内にあるGPT側のファイル名から拡張子を取得しています。(このファイル名をそのまま使用してもいいとも思います)
ちなみにこれで完璧ではありません。プログラム内に ※今回は参照ファイル1つだけ取得とあるようにannotation[0]で指定しているため参照ファイルが複数ある場合は取得できません。
もうそれは今回は勘弁してもらって、ファイルをダウンロードしましょう。
retrieve_file = client.files.with_raw_response.retrieve_content(file_id)
content = retrieve_file.content
with open(file_id + ext, 'wb') as f:
f.write(content)
3.141592653589793238462643383279502884197169399375105820974944592307816406286208998628034825342117068
ということでこんな感じでAPIでコードインタープリターの利用ができます!
でもこれで終わりではありません…
おわりに
タイトルの「しねる」の意味が共感出来てもらえらば幸いです。
またAssistants APIがβ版で今後変わる予定があること、色々参照しましたが私のコードは手探りで最善ではないことをご理解頂きたいです。(無駄なことばかりしていたらご指摘頂きたいです。)
で、これで終わりでないと言ったのはコードインタープリターはPythonを裏で実行しており、その実行したコードの取得もできるようになっています。これも追加で記事にしたいと思います!読んで頂きありがとうございました。
おまけですがGradioでコードインタープリタが使えるチャットアプリを作ったのでよかったら試してみてください!
ソースはこちら(現在Colabファイルのみ)
動画でのAssistants APIの紹介もあります!
(2024/1/27 追記)Pythonコードの取得方法も記事にしました