OpenAI 新APIのmessagesの中身がしねる Assistants API Code Interpreter 画像取得などtips

# OpenAI DevDayでAPIがリニューアル発表
11月7日のOpenAI DevDayでGPTのAPIが一新し、様々な新機能をリリースしたことを発表しました。

https://ai-workstyle.com/openai-devday/

<br>

発表の中で個人的に、今まで有料会員(GPTプラス)でしか使えなかった自動でグラフを作ってくれる[**コードインタプリター**](https://www.watch.impress.co.jp/docs/topic/1520290.html)を利用できるのが楽しみでした。（無課金勢じゃないですが月約3000円払うのが嫌でした…）

### が
<br>
利用してみると中々しねたのでここに記事にしたいと思います。

# Assistants APIの登場
:::note warn
Assistants APIはβ版です。今後記述方法は変更予定です。
:::

今までAPIは**Chat Completion**というのを利用してGPTから回答を得ていましたが、それとは別に**Assistants**を利用して回答を得ることができるようになりました。<br>
![assistants_overview_diagram.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3549938/4488d34b-8e1e-df16-6e3b-c9ab5adac797.png)

<br>

この公式の図を見てもあまりわからないかと思いますが、**スレッド**というものが会話を保持してくれるのでChat Completion方式で必要であった過去の会話の管理が不要となり、こちらからぽんぽんとメッセージを渡すだけで回答を返してくれます。<br>

実装方法は[公式](https://platform.openai.com/docs/assistants/overview)またはnpakaさんの記事が解説していまして非常にわかりやすくてありがたいです。

https://note.com/npaka/n/nb90306341d41

<br>
ざっとメッセージ取得までのコードを記載しておきます。

```Python:接続処理
from openai import OpenAI
import os

os.environ["OPENAI_API_KEY"] = 'sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'

client = OpenAI()
```

```Python:アシスタント作成
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
<br>

```Python:⓪スレッドの作成
thread = client.beta.threads.create()
```

```Python:①スレッドへメッセージのセット
# 問い合わせ内容をセット
prompt = "こんにちは！"

# メッセージの作成
message = client.beta.threads.messages.create(
    thread_id=thread.id,
    role="user",
    content=prompt,
    # file_ids=[file_id]
)
```

```Python:②RUN作成（アシスタントへ問い合わせ）
run = client.beta.threads.runs.create(
  thread_id=thread.id,
  assistant_id=assistant_id
  # instructions=""  # ここに新たにinstructionを入れることも可能
)
```

```Python:③RUNの状態を取得
run = client.beta.threads.runs.retrieve(
  thread_id=thread.id,
  run_id=run.id
)
```
```Python:④RUNの状態を確認
print(run.status)
```
```:表示結果
complete
```

ざっと書きましたがスレッドに問い合わせ内容をセットしRUNをスタート。
run.statusが"**complete**"であれば完了→アシスタントから回答が返ってくる

という流れになります。
（ちなみにAssistants APIは複数の回答が返ってくることもあり、completeとなっていなくても途中までのメッセージは取得できます。）



で肝心のメッセージの取得方法は以下です。

```Python:⑤メッセージリストを取得
messages = client.beta.threads.messages.list(
  thread_id=thread.id,
  order = "asc",    # 昇順
  # after = "",   # これを指定で前回以降のメッセージなど抽出が可能
)
```


```Python:⑥メッセージを表示
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]なのはとりあえず置いといて下さい）<br>
生のThreadMessageをメッセージを出力するとこんな感じです。
```:ThreadMessage（assistantのもののみ）
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は会話を重ねることができます。


### で
<br>
どこが「しねる」かというとここからです…

# アシスタントから画像の提示
コードインタープリターでは結果を画像(.pngファイル)で返すことができます。
グラフを簡単に描いて画像でくれるのでとても便利です。
試しに二次関数のグラフy=x^2を描くようお願いしてみます。
※⓪を実行してスレッドを新規作成
```Python:①スレッドへメッセージのセット
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**からダウンロードができ、目的の画像が取得できます。（後ほどダウンロード実行）
<br>ということで画像があるときと場合分けが必要となり


### 画像がある時はfile_id、それ以外のときはtext(.value)を取得

<br>
という処理が必要となります。

これをどう対応するか？

今思うとMessageContentImageFileやらオブジェクトで型判定してもいいかと思いましたが、こんな方法を使っていました。コードを先に書きます。

```Python:⑦messages表示その2
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で返してくれます。[（参考）](https://www.javadrive.jp/python/dictionary/index2.html)

これを利用してmessagesループ→contentループしdictのキーで **"image_file"があれば"file_id"をとってきて、"text"があれば"value"をとってくる**

という処理を実装しています。

ファイルのダウンロード方法は以下です。

```Python:画像ファイルダウンロード
# 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)
```
<br>

<img width="500" alt="grah.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3549938/e2f62460-91a6-e066-1d10-b5e140d7bfdc.png">

これが画像ダウンロードまでの道のりです。

# その他のファイル形式

で、これでおしまいではありません。
今度はテキストの出力をお願いしてみます。


```Python:①スレッドへメッセージのセット
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の中身を見てみます。
```Python:message出力(アシスタントのみ)
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抜粋
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より大きいかどうかでファイルがあるか判定します。


```Python:annotation内ファイル取得
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だけだったらまだいいのですが、ここのしねしねポイントは**ファイル形式**です。<br>
.txtだけではなく.csvや.pdfなどを出力する場合があり、それはfile_idだけではわかりません。
プログラムで使うなら自身で指定すればいいですが、アプリなどに組み込むならどの拡張子になるかを別途取得する必要があります。

ということで"text"内にあるGPT側のファイル名から拡張子を取得しています。（このファイル名をそのまま使用してもいいとも思います）

ちなみにこれで完璧ではありません。プログラム内に **※今回は参照ファイル1つだけ取得**とあるようにannotation[0]で指定しているため参照ファイルが複数ある場合は取得できません。

もうそれは今回は勘弁してもらって、ファイルをダウンロードしましょう。

```Python:ファイルダウンロード(拡張子指定)
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がβ版で今後変わる予定があること、色々参照しましたが私のコードは手探りで最善ではないことをご理解頂きたいです。（無駄なことばかりしていたらご指摘頂きたいです。）<br>
で、これで終わりでないと言ったのはコードインタープリターはPythonを裏で実行しており、その実行したコードの取得もできるようになっています。これも追加で記事にしたいと思います！読んで頂きありがとうございました。<br>
おまけですがGradioでコードインタープリタが使えるチャットアプリを作ったのでよかったら試してみてください！

https://huggingface.co/spaces/nekoniii3/GPT_Chat_Code_Interpreter

[ソースはこちら](https://github.com/nekoniii3/GPT_Code_Interpreter/tree/main)（現在Colabファイルのみ）

動画でのAssistants APIの紹介もあります！

https://www.youtube.com/watch?v=tFmedAM1FM8

（2024/1/27 追記）Pythonコードの取得方法も記事にしました

https://qiita.com/nekoniii3/items/99dbb6d47457bc8b7f0f