OpenAI Assistant API で画像を扱うのに奮闘した話

# OpenAI Assistant API

ChatGPT とやりとりを行うAPIは複数ある．
代表的なものが，Chat Completion API と Assistant API になる．
その名の通りで，チャットのみを行うなら実装が容易な前者だが，Assistantをカスタマイズし拡張性を持たせようとすると専門的な実装を行う後者になる．

Assistant API が行える Assistant Tool の実装に手間取ったので，備忘録として実装を交えながら書き残す．

* 参考
    * [Difference between Chat APi and Assistant Api | OpenAI Developer Forum](https://community.openai.com/t/difference-between-chat-api-and-assistant-api/721127)
    * [What the difference between open ai assistant api and chat api | OpenAI Developer Forum](https://community.openai.com/t/what-the-difference-between-open-ai-assistant-api-and-chat-api/602740)

## [Assistant Tool](https://platform.openai.com/docs/assistants/tools/assistant-tools)

Assistant API を使用して作成できる Assistant を拡張できる機能．
ファイルの中を検索させたり，Python を実行したり，外部サーバーと通信させたりできる．

### [File Search](https://platform.openai.com/docs/assistants/tools/file-search)

いわゆるRAG(Retrieval-Augmented Generation)と呼ばれるファイル検索を行う機能．

> ファイル検索は、独自の製品情報やユーザーから提供されたドキュメントなど、モデル外の知識でアシスタントを補強します。 OpenAIは自動的にドキュメントを解析してチャンクし、埋め込みを作成して保存し、ベクトル検索とキーワード検索の両方を使用して、ユーザーのクエリに答えるために関連するコンテンツを取得します。

[対応ファイルフォーマット](https://platform.openai.com/docs/assistants/tools/file-search/supported-files#supported-files)はプログラミングコードからPDF,tex,docx,pptxなどもいける．
基本的には文字情報が載っている系のものが多く，ファイルフォーマットも画像ファイルには対応していない．
多分PDFもフォント埋め込みを前提としている気がする．

### [Code Interpreter](https://platform.openai.com/docs/assistants/tools/code-interpreter)

プログラミングコードの生成・実行が可能になる機能

> Code Interpreterは、アシスタントがサンドボックス化された実行環境でPythonコードを記述し、実行することを可能にします。 このツールは、多様なデータやフォーマットのファイルを処理し、データやグラフの画像を含むファイルを生成することができます。 Code Interpreterを使用すると、アシスタントはコードを繰り返し実行して、難しいコードや数学の問題を解くことができます。 アシスタントが実行に失敗するコードを記述した場合、コードの実行が成功するまで別のコードを実行して、このコードを繰り返し実行することができます。

[対応ファイルフォーマット](https://platform.openai.com/docs/assistants/tools/code-interpreter/supported-filesfile-search#supported-files)はFileSearchに加えて画像系(jpg,gif,png)がある上にzipまでいけるっぽい．

### [Function Calling](https://platform.openai.com/docs/assistants/tools/function-calling/function-calling-beta)

GPT が状況に応じて特定の外部のシステムとやりとりを行う機能（ユーザの入力（プロンプトの内容）から引数っぽいものを探索する機能付き）[^cracking]

(e.g.) 天気取得APIを予め設定することで，天気の情報を持っていないため通常は天気を訊かれても答えられない ChatGPT に天気を返答させることができる．
ユーザの問いかけに答えるには設定したFunctionが有用であることを判断し，そのFunctionの実行のための引数の推測と Function の実行・結果の口語的設計などを一括して行う．

[^cracking]:Message送信と同時に特定の動作を実行する機能を悪用したカスタムGPTの[例](https://www.mbsd.jp/research/20240301/gpts/#a07)もあるので注意

> Chat Completions API (※Assistant API 以前のAPI)と同様に、Assistant API は Function Calling をサポートしています。
> Function Calling では、 Assistants API に関数を記述し、呼び出す必要のある関数を引数とともにインテリジェントに返すことができます。

* [ChatGPT APIの新機能Function callingを使って、天気を聞いてみた | 株式会社ラック](https://www.lac.co.jp/lacwatch/report/20230901_003482.html)

## GUI (API PlayGround ではなく Web版) と API

### fileのアップロード

ChatGPT を GUI で使っていると📎でなんでもアップロードするが，API的には，いくつか種類がある．

とりわけ躓いたのは，画像ファイルの取り扱い．

「この写真に描かれているものはなんですか？」というような，内部的には（おそらく）*画像埋め込みモデルが動作するような message を送る* のであればになるので，ChatGPT には message に付随させる必要がある．
よって，file の `purpose` は `vision` になる．

しかし，「この画像を文字起こししてください」「これらの画像を読み取ってください」「写真からCSVに変換してください」などの文字起こし系や，「回転させてください」「縮小してください」「背景を抜き取ってください」などの画像処理系のような *特定の専門的な動作系(OpenCVやTesseract)を使用するような message を送る* のであれば，内部的に使用するのは（おそらく）Code Interpreter になるので，Assistant のAssistant Tool に Code Interpreter 部分に付随させる必要がある．
よって，file の `purpose` は `assistants` になる．

以上から，**message を送られた ChatGPT が Python を動作させるか否かで，file の `purpose` を `vision` か `assistant` に調整するのみならず，file を付帯させる場所も `assistant tool` か `message` かで調整する必要がある．**

<!-- |機能|用途|`purpose=`|fileが付随する対象|
|:--|:--|:--|:--|
|`code_interpreter_files`|サンドボックスの環境でpythonを実行し，結果を得る機能|`assistants`|Assistantのtools|
|`file_search_files`|添付ファイルを探索し機能|`assistants`|Assistantのfile_id|

GUI だと写真を投稿して，これはなんですか？と問うのであれば．API的には

* 実行するためのPythonファイル
* 分析のための画像ファイル
* 分析のための非画像ファイル

`code_interpreter_files` および `file_search_files` に使用される `assistants` ではなく，`vision` を用いて画像を Upload する．
（`batch API` のためのファイルには `batch`，fine-tune のためのファイルには `fine-tune` を用いる． > [API-Reference](https://platform.openai.com/docs/api-reference/files/create#files-create-purpose)） -->

## 実装

### packageの準備

```bash
! pip install --upgrade pip
! pip install --upgrade openai python-dotenv
! pip freeze > requirements.txt
```

## API準備

```python
import os
import openai
from dotenv import load_dotenv

# .envファイルからAPIキーを読み込む
load_dotenv()
client = openai.OpenAI()
```

### ファイル系処理

```python
# ファイル名のリストを指定する
file_names = ['hoge.png','fuga.png']

# アップロード済みのファイルのリストを取得する
files = client.files.list()
uploaded_files = {f.filename: f.id for f in files.data}

# ファイルIDのリストを格納するための配列を初期化
file_ids = []

# 各ファイル名について処理を行う
for file_name in file_names:
    # ファイルパスを指定する
    image_dir = './../hoge/fuga'
    filepath = os.path.join(image_dir, file_name)

    # 同名ファイルのIDを取得
    file_id = uploaded_files.get(file_name)

    if file_id:
        # 同名ファイルがある場合、そのファイルを取得
        file = client.files.retrieve(file_id)
        print('retrieve')
    else:
        # ファイルをアップロードする
        file = client.files.create(
            file=open(filepath, 'rb'),
            purpose='assistants'
        )
        file_id = file.id
        print('create')

    # ファイルIDを配列に追加
    file_ids.append(file_id)

# 最終的なファイルIDの配列を出力
print('File IDs:', file_ids)
```

### Assistant作成

```python
# list
assistants_list = client.beta.assistants.list(
    order='desc',
    # limit='20',
)
# print([asst.name for asst in assistants_list.data])

# 作成したい名前
new_name = 'test_fig-fit'

# 同じ名前のものが存在するかチェック
existing_assistant = next((assistant for assistant in assistants_list.data if assistant.name == new_name), None)

if existing_assistant:
    # 同名のAssistantが存在する場合、そのAssistantを取得
    assistant = client.beta.assistants.retrieve(existing_assistant.id)
    print('retrieve')
else:
    # 同名のAssistantが存在しない場合、新規作成
    assistant = client.beta.assistants.create(
        # instructions='You are a personal math tutor. When asked a question, write and run Python code to answer the question.',
        name=new_name,
        tools=[{'type': 'code_interpreter'}],
        tool_resource={
        "code_interpreter": {
        "file_ids":[id for id in file_ids]
        }
        },
        model='gpt-4o',
        # file_ids=[file.id for file in files],
    )
    print('create')

print(assistant)
```

### thread関連

```python
# Thread作成
thread = client.beta.threads.create()
print(thread)
```

```python
thread_messages = client.beta.threads.messages.list(thread.id)
thread_messages.data
```

messageを削除したいとき

```python
# deleted_message = client.beta.threads.messages.delete(
#   message_id='msg_xxxxxxxx',
#   thread_id=thread.id,
# )
def de(client,thread):
    for message in thread_messages.data:
        for content in message.content:
        if content.tyoe=text
   de 
```

### message関連

```python
theme = ''

instruction='''
あなたはMBTI診断で仲介者（INFP）のような性格の人です。仲介者（INFP）は控えめ、または静かそうに見えるかもしれませんが、心の中は情熱であふれ、生き生きとしている人です。独創的かつ想像力豊かなので、色々な空想をしながら、さまざまな会話やストーリを作り上げることが好きなタイプです。繊細な気質の持ち主として知られていて、音楽、芸術、自然、そして周りの人に対して、深く感情的に反応する人です。
'''

instruction = instruction.replace('$theme', theme)

print(instruction)

# Message作成
# thread_message = client.beta.threads.messages.create(
#   thread.id,
#   role='user',
#   content=[
#       {'type': 'text', 'text': instruction},
#       {'type': 'image_file', 'image_file':{'file_id': file_ids[0]}},
#   ],
# )

# 初期のコンテンツに指示文を追加
content = [{'type': 'text', 'text': instruction}]

# 最大で10回までのループにする
for file_id in file_ids[:10]:
    content.append({'type': 'image_file', 'image_file': {'file_id': file_id}})

# contentリストを使ってメッセージを作成
thread_message = client.beta.threads.messages.create(
  thread.id,
  role='user',
  content=content,
)

print(thread_message)
```

### Run関連

method化する

```python
import time
# Run を作成する
run = client.beta.threads.runs.create(
	thread_id=thread.id,
	assistant_id=assistant.id,
)

# Run のステータスが`queued`もしくは`in_progress`の状態では待機
while (run.status == 'queued') or (run.status == 'in_progress'):
	run = client.beta.threads.runs.retrieve(
		thread_id=thread.id,
		run_id=run.id
	)
	time.sleep(0.5)
	print(run.status)

# Thread 上の message のリストを降順で取得する
messages = client.beta.threads.messages.list(
  thread_id=thread.id,
  order='desc'
#   order='asc'
)

# Assistant によって追加された 内容を取得する
assistant_messages = []
for thread_message in messages.data:
	role = thread_message.role
	if role == 'assistant':
		for content in thread_message.content:
			content = content.text.value
			assistant_messages.append({'role': role, 'content': content})
	break
```

### 出力関連

method化する

```python
messages.data
```

```python
assistant_messages
```

```python
print(theme)
print(file_names)
print(thread.id)
print(assistant_messages[0]['content'])
```

```python
import os
import json
from datetime import datetime

# # Example variables
# theme = 'Example Theme'
# file_names = ['file1.txt', 'file2.txt']
# thread = type('Thread', (object,), {'id': 12345})()
# assistant_messages = [{'content': 'This is a message from the assistant'}]

# Create the logs directory if it doesn't exist
log_dir = './logs'
os.makedirs(log_dir, exist_ok=True)

# Prepare the log data
log_data = {
    'theme': theme,
    'file_names': file_names,
    'thread_id': thread.id,
    'assistant_message': assistant_messages[0]['content']
}

# Get the current timestamp and create a filename
current_time = datetime.now().strftime('%Y%m%d_%H%M%S')
log_file_path = os.path.join(log_dir, f'{current_time}.json')

# Write the log data to a JSON file
with open(log_file_path, 'w', encoding='utf-8') as log_file:
    json.dump(log_data, log_file, ensure_ascii=False, indent=4)

print(f'Log saved to {log_file_path}')
```