OpenAI APIを使って、Code InterPreterで添付したファイルについての質問をし、回答をPDFファイルとしてローカルでダウンロードする

## 前書き
OpenAIのAssistantsのCode Interpreterツールを利用してPDFに書き出す処理を実装したのですが、PDFで書き出す処理やassistant,thread,fileを削除する処理が書いてある記事があまり見つからなかったので、メモ書きレベルですが記事にさせていただきました。
Assistants、CodeInterPreterの理解、実装の助けになれれば幸いです。

## CodeInterpreterとは
https://platform.openai.com/docs/assistants/tools/code-interpreter
> OpenAIのAssistantsのツールの一つで、コードインタープリタを使用することで、OpenAIのサンドボックス化された実行環境でPythonコードを実行することができます。そのため、さまざまなデータとフォーマットのファイルを処理し、データとグラフの画像を含むファイルを生成することができます。

## CodeInterpreter(Assistants)にまつわる各種用語
### 公式ドキュメント
https://platform.openai.com/docs/assistants/overview/objects
> - Assistant
    - OpenAIのモデルとコールツールを使用した専用AI
> - Thread
    - アシスタントとユーザー間の会話セッション。 スレッドはメッセージを保存し、コンテンツをモデルのコンテキストに合わせるために自動的に切り捨てを処理します。
> - Message
    - アシスタントまたはユーザーが作成したメッセージ。 メッセージにはテキスト、画像、その他のファイルを含めることができます。 メッセージはスレッドにリストとして保存されます。
> - Run
    - スレッド上でのアシスタントの呼び出し。 アシスタントは設定とスレッドのメッセージを使用して、モデルやツールを呼び出してタスクを実行します。 実行の一環として、アシスタントはスレッドにメッセージを追加します。
> - Run Step
    - アシスタントが実行の一部として行ったステップの詳細リスト。 アシスタントは実行中にツールを呼び出したり、メッセージを作成したりできます。 実行ステップを調べることで、アシスタントがどのように最終結果に到達しているかを調べることができます。

### ざっくりとした理解
1. アシスタントという一つの役割を設定した専用AIを作成する
2. Threadという一連の会話をまとめるグループを作成
3. MessageというUserの質問と、assistantの回答を設定する一つ一つの会話のことで、Threadに紐づけていく
4. 会話がまとめられたThreadをAssistantに渡して、実行(Run)することで、その会話の後に適切な回答をGPTが出してくれる

## 実装
FastAPIを利用して簡単な実装になります。
ユーザーが質問とファイルを渡すと、その質問について回答を行い、
かつ、その回答をPDF化してローカルに保存させるというシナリオで作成しています。
AssistantやThread、Fileなど、本来は使い回してより会話で深ぼっていくかと思いますが、挙動確認のための実装なので明示的に消すようにしています。(割と転がっている記事だと作りっぱなしになっていて、参考にコピペして動かしてると、どんどん溜まっていくんじゃないかと思います。。)

### ソースコード
```router.py
from fastapi import APIRouter, UploadFile
from openai import OpenAI

router = APIRouter()


@router.post(
    "/v1/beta/gpt/code_interpreter",
    tags=["beta/chat"],
    summary="GPTのコードインタープリターを実装(ベータ版)",
)
# assistants APIのfile_searchを利用
async def code_interpreter(
    question: str,
    attached_file: UploadFile | None,
):
    client = OpenAI()

    print(f"attached_file: {attached_file}")

    # ファイルの読み込みとOpenAI環境への登録
    if attached_file:
        filename = attached_file.filename
        content = await attached_file.read()
        file_obj = BytesIO(content)
        file_obj.name = filename
        file_obj.seek(0)
        file = client.files.create(
            file=file_obj,
            purpose="assistants",
        )

    _logger.info(f"file: {file}")

    # アシスタントの作成
    assistant = client.beta.assistants.create(
        instructions="あなたはファイルに記載されたことについて適切な応答を返すAIアシスタントです",
        model="gpt-4o",
        tools=[{"type": "code_interpreter"}],
        # ファイルはassistantでもツールでも設定可能
        # おそらく、アシスタントは(instructionsが同じでよいなら)前ユーザーに同じものを利用して、
        # tool_resourcesは全ユーザー、全Threadで共通で使うものはassistant、
        # 会話ごとに変えるものはthreadごとに足していく形になる
        # tool_resources={"code_interpreter": {"file_ids": [file.id]}},
    )

    print(f"assistant: {assistant}")

    # スレッドの作成
    thread = client.beta.threads.create(
        messages=[
            {
                "role": "user",
                "content": question,
                "attachments": [
                    {
                        "file_id": file.id,
                        "tools": [{"type": "code_interpreter"}],
                    }
                ],
            },
        ]
    )
    print(f"thread: {thread}")

    # スレッドの実行
    stream = client.beta.threads.runs.create(
        thread_id=thread.id,
        assistant_id=assistant.id,
        stream=True,
        # 指定した場合、スレッドのものを上書きする
        # model="gpt-4o",
        # 指定した場合、スレッドのものを上書きする
        # instructions="New instructions that override the Assistant instructions",
        # 指定した場合、スレッドのものを上書きする
        # tools=[{"type": "code_interpreter"}, {"type": "file_search"}],
    )
    print(f"stream: {stream}")
    chat_response = []
    for event in stream:
        # print(f"object名: {event.__class__.__name__}")
        if event.__class__.__name__ == "ThreadMessageDelta":
            print(f"streamの中身: {event.data.delta.content[0].text.value}")
            if event.data.delta.content[0].text.value:
                chat_response.append(event.data.delta.content[0].text.value)

    # 回答をthreadに追加
    add_answer_to_thread = client.beta.threads.messages.create(
        thread.id,
        role="assistant",
        content=("").join(chat_response),
    )
    print(add_answer_to_thread)

    # 2回目の会話
    # 本来はthreadIDをDBに保存させて、
    # 追加の質問とthreadIDを元に会話を続けさせる必要があるが、
    # 動作を確認するためのものであるため固定文を2回目の会話として加えている
    add_question_to_thread = client.beta.threads.messages.create(
        thread.id,
        role="user",
        content="結果を英語に変換した上で確実にPDFにして",
    )
    print(add_question_to_thread)

    # スレッドの実行
    next_stream = client.beta.threads.runs.create(
        thread_id=thread.id,
        assistant_id=assistant.id,
        stream=True,
        # 指定した場合、スレッドのものを上書きする
        # model="gpt-4o",
        # 指定した場合、スレッドのものを上書きする
        # instructions="New instructions that override the Assistant instructions",
        # 指定した場合、スレッドのものを上書きする
        # tools=[{"type": "code_interpreter"}, {"type": "file_search"}],
    )
    print(f"next_stream: {next_stream}")
    created_file_id = None
    for event in next_stream:
        # print(f"object名: {event.__class__.__name__}")
        if event.__class__.__name__ == "ThreadMessageDelta":
            print(f"streamの中身: {event.data.delta}")
            if event.data.delta.content[0].text.value:
                chat_response.append(event.data.delta.content[0].text.value)
            # PDFやCSV、画像ファイルを作成した場合はfile_pathが含まれるようで、取り合えず無理やり判別した
            # https://platform.openai.com/docs/assistants/tools/code-interpreter/reading-images-and-files-generated-by-code-interpreter
            elif (
                event.data.delta.content[0].text.annotations[0]
                and event.data.delta.content[0].text.annotations[0].file_path
            ):
                created_file_id = (
                    event.data.delta.content[0]
                    .text.annotations[0]
                    .file_path.file_id
                )
                print(f"created_file_id: {created_file_id}")

    # PDFのローカルへの保存
    if created_file_id:
        content = client.files.content(created_file_id)
        content_bytes = content.read()
        with open("./code_interpreter.pdf", "wb") as code_interpreter_file:
            code_interpreter_file.write(content_bytes)

    print(f"chat_response_join: {"".join(chat_response)}")

    # スレッドの削除
    # スレッドを残して会話をつつけていくことができるが、挙動確認のため都度消す運用とする
    threads_delete_response = client.beta.threads.delete(thread.id)
    print(f"threads_delete_response: {threads_delete_response}")

    # アシスタントの削除
    # アシスタントを残して、他のthreadなどに紐づけて続けていくこともできるが、挙動確認のため都度消す運用とする
    assistant_delete_response = client.beta.assistants.delete(assistant.id)
    print(assistant_delete_response)

    # ファイルの削除
    # fileを他のthread,assistant,他のtoolにも利用することができるが、挙動確認のため都度消す運用とする
    file_delete_response = client.files.delete(file.id)
    print(file_delete_response)

    return "".join(chat_response)
```

### 実行結果
- リクエスト
    - 質問として「回答を教えて」、ファイルに以下のquestion.txtを添付しました。
```qiestion.txt
1. 縄文時代における生活様式として正しいものはどれですか？
A. 農耕生活
B. 狩猟・採集生活
C. 商業活動
D. 鉄器使用

2. 「大化の改新」に関連する改革はどれですか？
A. 幕府の設立
B. 土地制度の改革
C. 鎖国政策の開始
D. 海外貿易の拡大

3. 卑弥呼が使者を送った国はどれですか？
A. 朝鮮
B. 中国
C. インド
D. モンゴル

4. 遣隋使の目的として正しいものはどれですか？
A. 戦争の回避
B. 仏教の伝来
C. 先進文化の学習
D. 貿易の独占

5. 鎌倉幕府を開いたのは誰ですか？
A. 源頼朝
B. 足利尊氏
C. 豊臣秀吉
D. 織田信長
```

- レスポンス
    - レスポンス
        ```
        "ファイルを確認し、内容に基づいて適切に回答するために、まずファイルを読み込みます。少々お待ちください。ファイルの拡張子が認識されませんでしたが、内容を読み取れるか試してみます。以下の方法でファイルを確認します。ファイルの内容は日本の歴史に関する選択肢問題のようです。それぞれの問題に対する正しい答えを教えて欲しいというリクエストと理解しました。以下が問題とその選択肢、そして正しい答えです。\n\n1. **縄文時代における生活様式として正しいものはどれですか？**\n   - A. 農耕生活\n   - B. 狩猟・採集生活\n   - C. 商業活動\n   - D. 鉄器使用\n   - **正解: B. 狩猟・採集生活**\n\n2. **「大化の改新」に関連する改革はどれですか？**\n   - A. 幕府の設立\n   - B. 土地制度の改革\n   - C. 鎖国政策の開始\n   - D. 海外貿易の拡大\n   - **正解: B. 土地制度の改革**\n\n3. **卑弥呼が使者を送った国はどれですか？**\n   - A. 朝鮮\n   - B. 中国\n   - C. インド\n   - D. モンゴル\n   - **正解: B. 中国**\n\n4. **遣隋使の目的として正しいものはどれですか？**\n   - A. 戦争の回避\n   - B. 仏教の伝来\n   - C. 先進文化の学習\n   - D. 貿易の独占\n   - **正解: C. 先進文化の学習**\n\n5. **鎌倉幕府を開いたのは誰ですか？**\n   - A. 源頼朝\n   - B. 足利尊氏\n   - C. 豊臣秀吉\n   - D. 織田信長\n   - **正解: A. 源頼朝**\n\nこれが各問題に対する正しい答えです。もしさらに詳しい説明が必要であれば教えてください。了解しました。まず、結果を英語に翻訳し、その後、PDFに変換します。\n\n### 翻訳:\n1. **Which lifestyle is correct for the Jomon period?**\n   - A. Farming life\n   - B. Hunting and gathering life\n   - C. Commercial activities\n   - D. Use of iron tools\n   - **Correct answer: B. Hunting and gathering life**\n\n2. **Which reform is associated with the \"Taika Reform\"?**\n   - A. Establishment of the shogunate\n   - B. Reform of the land system\n   - C. Start of the national isolation policy\n   - D. Expansion of foreign trade\n   - **Correct answer: B. Reform of the land system**\n\n3. **Which country did Himiko send envoys to?**\n   - A. Korea\n   - B. China\n   - C. India\n   - D. Mongolia\n   - **Correct answer: B. China**\n\n4. **What was the correct purpose of the envoy to Sui China?**\n   - A. Avoidance of war\n   - B. Transmission of Buddhism\n   - C. Learning advanced culture\n   - D. Trade monopoly\n   - **Correct answer: C. Learning advanced culture**\n\n5. **Who founded the Kamakura shogunate?**\n   - A. Minamoto no Yoritomo\n   - B. Ashikaga Takauji\n   - C. Toyotomi Hideyoshi\n   - D. Oda Nobunaga\n   - **Correct answer: A. Minamoto no Yoritomo**\n\nこれを基にPDFを作成します。少々お待ちください。英語に翻訳した結果をPDFにまとめました。以下のリンクからPDFをダウンロードできます。\n\n[Download PDF](sandbox:/mnt/data/result.pdf)"
        ```
    - 作成されたPDF(実行したローカル環境にできます)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2710784/04f78f4c-b783-3c91-4449-a9c70d08cb1f.png)

## 参考
- 公式ドキュメント
https://platform.openai.com/docs/assistants/tools/code-interpreter

- 公式ドキュメント(APIリファレンス)
https://platform.openai.com/docs/api-reference/files

- クラメソ様
    - OpenAI Assistants API利用時の注意を書いてくださっていて大変ありがたい。。
https://dev.classmethod.jp/articles/note-for-using-openai-assistants-api/

## 最後に
雑記事ですが、どなたかのお役に立てれば幸いです。