OpenAIのAssistants APIを使用してチェック業務を自動化してみた

# はじめに
昨年11月に行われた[OpenAI DevDay 2023](https://devday.openai.com/)で発表された[Assistants API](https://platform.openai.com/docs/assistants/overview)を使用して業務自動化しました。

具体的には自社のCSチームがユーザーのプロフィールをチェックし承認 or 否認という作業を手動で行っていましたが、Assistants APIを使用して自動で承認, 否認を行うようにしました。

実装内容を備忘録も兼ねて記事として残したいと思います。

# Assistants APIとは
実装に取り掛かる前にAssistants APIについて軽く紹介しようと思います。

Assistants APIとは一言で言うと **「指定した用途専用のAI AgentのAPI」** です。

事前に「あなたはWebサービスの審査役を行うAIですよ〜」みたいな感じでそのAIの用途を指定することができるAIです。

また、以下3つのToolを利用可能です。
- [Code Interpreter](https://platform.openai.com/docs/assistants/tools/code-interpreter)
- [Function calling](https://platform.openai.com/docs/assistants/tools/function-calling)
- [Knowledge Retrieval](https://platform.openai.com/docs/assistants/tools/knowledge-retrieval)

toolsについて詳しくは[こちら](https://platform.openai.com/docs/assistants/tools)


API利用は無料期間もありますが、基本的に従量課金で料金が発生しますので注意して下さい。
Assistants APIの利用料金は[こちら](https://openai.com/pricing)

[OpenAI Playground](https://platform.openai.com/playground)を使用するとGUIベースで気軽に試すことも可能ですので是非。

## Assistants APIで使用する用語とその概念
Assistants APIを使用するには用語とその概念について事前知識が必要です。
それぞれ解説していきます。

### Assistant
「Assistant」とは、**「指定した用途専用のAI Agent」** です。

「あなたはWebサービスの審査役を行うAIですよ〜」みたいな用途の指定はAPI経由でもPlaygroundからでも可能です。

### Thread
「Thread」とは、一連のやり取りを指します。
一連のメッセージが繋がって一つの会話となり、これをスレッドと呼びます。

**ChatGPTでは新しいメッセージを何度でも作成できると思います。**
**あの、作成する一連のメッセージが「Thread」です。**

### Message
「Message」とは、ユーザーやアシスタントによって送信される個々の発言やテキストを指します。
一つ一つのメッセージが集まってスレッド（会話の流れ）を構成します。

**ChatGPTで言うと、やり取りしているテキスト一つ一つのことです。**

### Run
「Run」とは、アシスタントが特定のタスクを処理するプロセスを指します。

**ChatGPTでは、テキストを入力して送信ボタンを押すと思います。**
**その送信ボタンを押すことを「Run」と言います。**

# 実装していく
では、さっそく実装してきましょう。
OpenAI Assistantsは発表されたばかり&まだbeta版ということもあり記事がそこまで多くない印象です。

Assistants APIの公式ドキュメントを見ながら実装しました。

https://platform.openai.com/docs/assistants/overview

結局公式ドキュメントが一番確実でわかりやすいですね。。

## Assistants APIを使用する流れ
全体の流れは次のようになります。
1. Assistant作成
1. Thread作成
1. ThreadへMessageを追加
1. Assistantsの実行
1. Run Statusの確認
1. Responseを取得

手順1のAssistantの作成については[OpenAI Playground](https://platform.openai.com/playground)から作成しましたので実装していません。

## クライアントライブラリ選定
今回はJavaとKotlinで実装していくのですが、公式からOpenAIクライアントJavaライブラリは出ていないようです。

公式サイトで紹介されている、OpenAIコミュニティの方が作成してくれたJavaライブラリを使用することにしました。

かなりstartsも付いていて安心ですね！

https://github.com/TheoKanning/openai-java

OpenAIクライアントライブラリ一覧
公式からはPythonとTypeScript / JavaScriptのライブラリのみ公開されているみたい。


https://platform.openai.com/docs/libraries/community-libraries

## OpenAI Serviceを初期化する
アプリケーションのライフサイクルの早い段階でAssistants APIを実行するServiceクラスを初期化しておきます。
以降このServiceクラスを通じてAssistants APIを実行します。

シングルトンなクラスでopenAiServiceのインスタンスを保持して、アプリケーション実行中はずっと同じインスタンスを使用するようにしました。
```kotlin
    /**
     * OpenAIServiceとAssistant IDを初期化するメソッド。
     *
     * @param token OpenAIのAPIにアクセスするために必要な認証トークン。
     * @param assistantId OpenAI Assistantの識別子。「Profile Checker」などの特定のAssistantを指定する。
     */
    fun init(token: String, assistantId: String) {
        // APIトークンを用いてOpenAiServiceクラスのインスタンスを生成する。
        openAiService = OpenAiService(token)
        // OpenAIのAssistant IDを設定する。
        // このIDはOpenAIの特定のAssistantに対してリクエストを送る際に使用される。
        openAiProfileCheckerAssistantId = assistantId
    }
```

## ThreadとMessageを作成する
次にThreadとMessageを作成します。
`openAiProfileCheckerAssistantId`でどのAssistantを使用するかを指定します。
```kotlin
    /**
     * ユーザー入力を基にOpenAI Assistantへのリクエストを生成する。
     * このメソッドは、メッセージとスレッドのリクエストを作成し、
     * それらを組み合わせてAssistantへの完全なリクエストを生成する。
     *
     * @param input ユーザーからの入力文字列。
     * @return OpenAI Assistantに送信するためのリクエストオブジェクト。
     */
    private fun createRequestForAssistant(input: String): CreateThreadAndRunRequest {
        val message = createMessage(input)
        val thread = creteThread(message)
        return createThreadAndRun(thread)
    }

    /**
     * 与えられたスレッドリクエストを用いて、ThreadとRunを作成するためのリクエストを生成する。
     *
     * @param thread OpenAI Assistantに送信するスレッドリクエスト。
     * @return ThreadとRunを作成するためのリクエストオブジェクト。
     */
    private fun createThreadAndRun(thread: ThreadRequest): CreateThreadAndRunRequest {
        return CreateThreadAndRunRequest.builder()
            .assistantId(openAiProfileCheckerAssistantId)
            .thread(thread)
            .build()
    }

    /**
     * 与えられたコンテンツを含むメッセージリクエストを生成する。
     *
     * @param content メッセージの内容。
     * @return OpenAI Assistantに送信するメッセージリクエスト。
     */
    private fun createMessage(content: String): MessageRequest {
        return MessageRequest.builder()
            .role(Role.USER)
            .content(content)
            .build()
    }

    /**
     * 与えられたメッセージリクエストを含むスレッドリクエストを生成する。
     *
     * @param message OpenAI Assistantに送信するメッセージリクエスト。
     * @return スレッドリクエストオブジェクト。
     */
    private fun creteThread(message: MessageRequest): ThreadRequest {
        return ThreadRequest.builder()
            .messages(listOf(message))
            .build()
    }
```

## 作成したThreadとMessageを実行する
先程作成したThreadとMessageを実行します。
`createThreadAndRun`メソッドを使用してThreadの作成→実行までをまとめて処理します。

返ってきたRunオブジェクトは実行中のRunのステータスなどが格納されています。
```kotlin
    // 生成したリクエストをOpenAIに送信し、Runオブジェクトを取得する。
    val run = openAiService.createThreadAndRun(request)

    // Runの完了を待機し、OpenAIからのレスポンスを取得する。
    return waitForRunCompletion(run)
```

## Run Statusを確認してResponseを取得する
ChatGPTを使用したことがある方ならわかると思いますが、実行しても瞬時に結果が返ってくるわけではありません。

**Run Statusを確認して実行が完了したかどうかを監視する必要があります。**

Run Statusが「completed」になっていれば実行完了です。

今回は1秒ごとにRun Statusを確認するようにしました。
そこまでレスポンスの速さを求める要件ではなかったので1秒にしましたが、早くレスポンスが欲しい場合は適宜間隔を調節すると良いと思います。

### 実行完了を待っていると遅いため高速化した

とはいえ、実行完了を待っているとまあまあ時間がかかり少し遅かったので、高速化しました。
[Assistant](#assistant)にレスポンスで返してほしい形式(JSON)を指定していて、特定の形式でメッセージが存在すればそれ以降のメッセージは必要ないので、途中で中断するように実装しました。

1秒ごとにRun Statusを確認するだけではなく、その時点でのメッセージを取得して特定の形式のメッセージが存在するかを確認しています。

存在する場合は、実行中のRunを中断して途中経過のメッセージをレスポンスとして返すようにしました。

```kotlin
    /**
     * 特定のRunの完了を待機し、OpenAIからの完全なレスポンスを取得する。
     * この関数は1秒ごとにRunのステータスを確認し、ステータスが「completed」になるまで待機する。
     * Runが完了したら、関連するメッセージを文字列のリストとして返す。
     * 指定している形式でメッセージが存在する場合は実行を中断して途中経過のメッセージを返す。
     *
     * @param run 完了を待つRunの情報を含むオブジェクト。
     * @return 完了したRunに関連するメッセージの内容を含むStringのList。
     */
    private fun waitForRunCompletion(run: Run):List<String> {
        // OpenAIからのレスポンスが完了するまでループを実行する。
        while (true) {
            // 現在のRunの情報を取得する。
            val runInfo = openAiService.retrieveRun(run.threadId, run.id)

            // Runのステータスが「completed」になっていたら、ループを終了する。
            if (runInfo.status == "completed") break

            // 現在のメッセージを取得する
            val currentMessages = openAiService.listMessages(run.threadId).getData().map { it.content[0].text.value }
            // 現在のメッセージが指定している形式であれば、Runを中断して現在のメッセージを返す。
            if (OpenAIConstants.findValidJsonObject(currentMessages)) {
                // Runを中断する。
                openAiService.cancelRun(run.threadId, run.id)
                // 現在のメッセージを返す。
                return currentMessages
            }

            // 1秒待機してから再度ステータスを確認する。
            Thread.sleep(1000)
        }

        // 完了したRunに関連するメッセージを取得し、それらの内容を文字列のリストとして返す。
        return openAiService.listMessages(run.threadId).getData().map { it.content[0].text.value }
    }

```

### 【2024/1/29 追記】
Runのステータスが「completed」になるまでループを回すと、Runが失敗した場合に無限ループに入ってしまうことが判明しました。
Runが失敗した場合にErrorをThrowしてくれると思っていたのですが、テスト不足でした。
```kotlin
            // Runのステータスが「completed」になっていたら、ループを終了する。
            if (runInfo.status == "completed") break
```

以下のようにRunの失敗のステータスも検知するようにして回避することができました。
```kotlin
            // Runのステータスに応じて処理を行う。
            // 「completed」Runが完了した場合はループを抜ける。
            // 「failed」Runが失敗した場合は例外をthrowする。
            when(runInfo.status) {
                "completed" -> break@loop
                "failed" -> throw Exception(runInfo.lastError.message)
            }
```

## Assistants APIのレスポンスを元に承認・否認・中立を決定する
Assistants APIのレスポンスを元に審査結果を判定しています。
今は初期段階なのでAssistants APIの結果に対して承認には厳しめ、否認には緩めにしています。

AIでの審査結果をすべて保存しておいて、今後のAssistants APIの精度を上げるための情報として活用してく予定です。

中立の場合はこれまで通りCSチームが手動で審査するようになっています。
```kotlin
    /**
     * プロフィール項目の安全度を判定する
     * @param input: ユーザーからの入力
     * @return ProfileCheckResult: 承認(APPROVAL) or 否認(DENIAL) or 中立(NEUTRAL)
     */
    fun checkSafety(input: String): ProfileChecker.Result {
        val response = OpenAIServer.executeRequest(input) ?: return ProfileChecker.Result.NEUTRAL
        val profileCheckerResponse = parseResponse(response)
        val result = checkContentApproval(profileCheckerResponse)
        // AIでの審査結果をログに記録する
        logAiProfileCheckDAO.insert(profileCheckerResponse, result, input, response.size)
        return result
    }

    
    /**
     * プロフィール項目の安全度を判定する
     * 判定できない場合は中立とする
     */
    private fun checkContentApproval(
        response: ProfileChecker.ResponseData
    ): ProfileChecker.Result {
        // OpenAIからのレスポンスが明らかに不正な場合は否認を返す
        if (response.xxxLevel >= ProfileChecker.Score.DANGEROUS ||
            response.xxxLevel >= ProfileChecker.Score.DANGEROUS ||
            response.xxxLevel >= ProfileChecker.Score.DANGEROUS) {
            return ProfileChecker.Result.DENIAL
        }

        // 全ての項目が安全な場合は承認を返す
        if (response.xxxLevel < ProfileChecker.Score.SAFE &&
            response.xxxLevel < ProfileChecker.Score.SAFE &&
            response.xxxLevel < ProfileChecker.Score.SAFE) {
            return ProfileChecker.Result.APPROVAL
        }

        // それ以外の場合はAIでの判定はできないため中立のステータスを返す
        return ProfileChecker.Result.NEUTRAL
    }
```

# さいごに
OpenAI Assistantsの精度がかなり高くびっくりしました。
様々なニーズにあったAIを簡単に作成→システム導入することができ、AIの無限大の可能性を再度認識させられる良い経験でした。

これからも様々な場面でAIを活用できそうなので積極的に取り入れていきたいと思っています。

ライブラリとか作ってみたいなあとか思ったり。。

弊社では、経験の有無を問わず採用を行っています。
興味のある方は是非カジュアル面談しましょう！

https://jambo-support.com/recruit_engineer/

ではまたっ！

# 追記(2025/3/18)
以前は夜勤スタッフがユーザーのチェック業務をしていましたが、AIチェックの最適化を進めた結果、**最終的に夜勤自体をなくすことになりました。**

理由としては、人件費の削減 だけでなく、「単純なチェック作業は自動化して、人間はもっとクリエイティブなことに集中できるようにする」という考えがあったからです。これにより、業務の効率も上がり、より価値のある仕事にリソースを割けるようになりました。

# 参考
https://platform.openai.com/docs/assistants/overview