Open17

RustでChatGPT APIを用いてAssistantsを制御する

yunayuna

経緯・目的

ChatGPT APIを制御できて、自分で自由にカスタマイズして使えるよう
デスクトップツールをTauri(Rustのデスクトップ開発フレームワーク)で2022年頃から開発しています。
(※自分用のコード品質です)
https://github.com/generalworksinc/ai_client

2024年現在Legacyに分類される、”Completions”での実装のみとなっていて、
画像などマルチモーダルな入出力ができないので、
最新のAssistants・Threadsなどを取り入れ、文脈やマルチモーダルなInput/Outputできる機能を追加し、
よりパワフルに使えるツールにするのが今回の目的です。

参考記事

参考にした記事です。
公式をベースに、
https://platform.openai.com/docs/api-reference/assistants-v1
噛み砕いた記事
https://qiita.com/haraitai00/items/fb9e85101ab681dfd414
https://qiita.com/motonobu_ut/items/31884a184721c0c32ac3
https://qiita.com/ramutarafarm/items/f6456880a8a8a2fc48b4

yunayuna

Example1. assistants

https://github.com/64bit/async-openai/blob/main/examples/assistants/src/main.rs

用語は以下のような感じ

Client(crateの概念)

APIKeyなどのパラメータを保持し、ChatGPT APIにリクエストを投げたり、レスポンス受け取るためのオブジェクト。

Threads (chatGPT APIの概念)

ChatGPTにおける、一連のメッセージのやりとりを管理するコンテナー。ユーザーとアシスタントのやりとりを時系列に沿ってまとめたもので、特定の会話の文脈や状態を保持する。

少しややこしいが、
このcrateにおいては
struct Threads:内部にclientの情報も含みAPIとのやり取りを管理するインスタンス
struct ThreadObject: 純粋なデータ型としての、chatGPT APIから返されるthreadsデータ

となっている。
Thread::createで、ChatGPT APIに、必要に応じて文脈としてメッセージやファイルなどを渡して、ThreadObjectを新規作成するリクエストを送信し、データを受信できる。

Assistant (chatGPT APIの概念)

振る舞ってほしい指示をまとめた、文脈付きの言語モデルのようなもの。
ChatGPT APIに、名前と指示をつけてAssistantをリクエストすると、Assistantが返される

Run (chatGPT APIの概念)

どのAssistantにどんな回答をしてほしいか、こちらの問い合わせメッセージなど、
chatGPTに実行してほしい処理のこと。

chatGPTが回答を生成するには時間がかかるので、
こちらが命令をすると、いったんidが付いたRunオブジェクト(処理のステータスを持っている)を受取る。
このidをキーに、Runオブジェクトを受取、ステータスの変化をチェックできる。
処理が完了したら、自動的にThreadにAIが生成したメッセージや画像が追加されているはずなので、
該当Threadを受け取って確認する

実際のコード

準備1:Client生成

main.rs
// Clientを、OpenAIのAPIキーで生成します
let client = Client::with_config(OpenAIConfig::new().with_api_key("xxxxxxxxx");

準備2:Clientをもとに、空のThreadを新規作成

main.rs
// Clientに紐づくthreadをchatGPT APIから受信する(パラメータ無しなら新規Thread)
let thread_request = CreateThreadRequestArgs::default().build()?;
//API Call: POST "/threads" payload: threadの設定
let thread = client.threads().create(thread_request.clone()).await?;

準備3:Clientをもとに、Assistantを生成

main.rs
//Assistantsの名前
let assistant_name = "example_assistant".to_string();
//Assistantsに与える指示
let instructions = "あなたはギャルな魔王です。世界を支配する魔王として、ギャル語で質問に答えてください。".to_string();

//Assistantsに必要なパラメータをまとめ、インスタンスを生成
let assistant_request = CreateAssistantRequestArgs::default()
        .name(&assistant_name)
        .instructions(&instructions)
        .model("gpt-4o-mini")
        .build()?;
//API Call: POST "/assistants" payload: Assistant情報
let assistant = client.assistants().create(assistant_request).await?;

//AssistantsのIDを取得しておく(後で使う)
let assistant_id = &assistant.id;

準備4:質問内容(メッセージ)を作成し、Threadに追加する

main.rs
//質問内容を生成
let message = CreateMessageRequestArgs::default()
.role(MessageRole::User)
.content("世界征服で大変だったことを教えて".to_string())
.build()?;

//指定のThreadに、メッセージを追加する
//API Call: POST "/threads/{:thread_id}/messages" payload: メッセージデータ
let _message_obj = client
    .threads()
    .messages(&thread.id)
    .create(message)
    .await?;

Run(APIに依頼する特定の処理)を作成、実行する

main.rs
//Runオブジェクトのパラメータとして、どのAssistantにするかを決めて、
let run_request = CreateRunRequestArgs::default()
    .assistant_id(assistant_id)
    .build()?;

//Threadに追加のMessageを生成してもらようにRun(処理)をリクエストする
//リクエストすると、idを持ったRunObject(chatGPT APIが返す、実行に関するデータ)が返ってくる。
// API Call: POST "/threads/{:thread_id}/runs" payload: Assistantの指定や、調整パラメータ
let run = client
    .threads()
    .runs(&thread.id)
    .create(run_request)
    .await?;

//idをキーに、Runの最新状態を定期的にチェックし、Runが完了するまで待つ
let mut awaiting_response = true;
while awaiting_response {
    //最新のRunオブジェクトを取得し、ステータスtyけく
    let run = client.threads().runs(&thread.id).retrieve(&run.id).await?;
    match run.status {
        //AIの回答が完了した
        RunStatus::Completed => {
            awaiting_response = false;

            //Threadにメッセージが1つ追加されているはずなので、該当Threadの最後の1つを取得する
            let query = [("limit", "1")]; //1 メッセージだけ受け取る
            let response = client.threads().messages(&thread.id).list(&query).await?;
            //追加されたメッセージIDを受け取る
            let message_id = response.data.first().unwrap().id.clone();
            //メッセージIDをもとに、メッセージオブジェクトを取得
            let message = client
                .threads()
                .messages(&thread.id)
                .retrieve(&message_id)
                .await?;
            //メッセージオブジェクトから、テキストや画像などのContentデータを受け取る
            let content = message.content.first().unwrap();
            let text = match content {
                MessageContent::Text(text) => text.text.value.clone(),
                MessageContent::ImageFile(_) | MessageContent::ImageUrl(_) => {
                    panic!("imaged are not expected in this example");
                }
            };
            //print the text
            println!("--- Response: {}\n", text);
        }
        RunStatus::Failed => {
            awaiting_response = false;
            println!("--- Run Failed: {:#?}", run);
        }
        RunStatus::Queued => {
            println!("--- Run Queued");
        }
        RunStatus::Cancelling => {
            println!("--- Run Cancelling");
        }
        RunStatus::Cancelled => {
            println!("--- Run Cancelled");
        }
        RunStatus::Expired => {
            println!("--- Run Expired");
        }
        RunStatus::RequiresAction => {
            println!("--- Run Requires Action");
        }
        RunStatus::InProgress => {
            println!("--- In Progress ...");
        }
        RunStatus::Incomplete => {
            println!("--- Run Incomplete");
        }
    }
    //1秒Waitして、再度Runのスタータス確認
    tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}

//作成した assistantやthreadを削除する
client.assistants().delete(assistant_id).await?;
client.threads().delete(&thread.id).await?;
yunayuna

Example2. assistants-func-call-stream

Function Callと呼ばれる、質問内容によってAIが関数コールが必要だと判断したときに、
任意の処理を行える(例えば、別のAPIを呼んでデータを取得してくるなど)仕組みと、

AIからのレスポンスをStreamでIteraterのように扱えるStreamを使ったサンプル。
https://github.com/64bit/async-openai/blob/main/examples/assistants-func-call-stream/src/main.rs

以下、上記のコードと少し変えてますが、基本的に同じ処理を行っています。

FunctionCalling

ここでは、新たにFunction Callingの機能が使われています。
参考記事:
https://qiita.com/wing_man/items/788511a69b09ad3db4e3

Assistantを作る際に、toolsでFunctionObjectを指定

main.rs
//create the assistant
    let assistant_request = CreateAssistantRequestArgs::default()
        .name(&assistant_name)
        .instructions(&instructions)
        .model("gpt-4o-mini")
        .tools(vec![
            FunctionObject {
                name: "get_current_temperature".into(),
                description: Some("Get the current temperature for a specific location".into()),
                parameters: Some(serde_json::json!(
                    {
                        "type": "object",
                        "properties": {
                        "location": {
                            "type": "string",
                            "description": "The city and state, e.g., San Francisco, CA"
                        },
                        "unit": {
                            "type": "string",
                            "enum": ["Celsius", "Fahrenheit"],
                            "description": "The temperature unit to use. Infer this from the user's location."
                        }
                        },
                        "required": ["location", "unit"]
                    }
                ))
            }.into(),
    
            FunctionObject {
                name: "get_rain_probability".into(),
                description: Some("Get the probability of rain for a specific location".into()),
                parameters: Some(serde_json::json!(
                    {
                        "type": "object",
                        "properties": {
                        "location": {
                            "type": "string",
                            "description": "The city and state, e.g., San Francisco, CA"
                        }
                        },
                        "required": ["location"]
                    }
                ))
            }.into()
        ]).build()?;
    let assistant = client.assistants().create(assistant_request).await?;

処理を実行し、event Stream(処理の途中経過をStreamで受け取れる)を取得

main.rs

    //create a run for the thread
    let run_request = CreateRunRequestArgs::default()
        .assistant_id(assistant_id)
        .stream(true)
        .build()?;
    
    let mut event_stream = client
        .threads()
        .runs(&thread.id)
        .create_stream(run_request.clone())
        .await?;

event Streamの中で、ThreadRunRequiresActionを取得した場合、
関数のCallを求められます。
逆に、ThreadRunRequiresActionを取得しなかった場合、AIは関数をコールする必要が無いと判断し、
ThreadMessageDeltaで回答を返し始めます。
(動かした感じだと、関数が必要な場合は途中でメッセージを返すことは無さそうです)

main.rs
    while let Some(event) = event_stream.next().await {
        match event {
            Ok(event) => match event {
                AssistantStreamEvent::ThreadRunRequiresAction(run_object) => {
                    println!("thread.run.requires_action: run_id:{}", run_object.id);
                    let client = client.clone();
                    task_handle = Some(tokio::spawn(async move {
                        handle_requires_action(client, run_object).await
                    }));
                }
                AssistantStreamEvent::ThreadMessageDelta(delta) => {
                    if let Some(contents) = delta.delta.content {
                        for content in contents {
                            // only text is expected here and no images
                            if let MessageDeltaContent::Text(text) = content {
                                if let Some(text) = text.text {
                                    if let Some(text) = text.value {
                                        print!("{}", text);
                                    }
                                }
                            }
                        }
                    }
                }
                _ => {
                   // println!("\nEvent: {event:?}\n"),
                }
            },
            Err(e) => {
                eprintln!("Error: {e}");
            }
        }
    }

上記コードの、handle_requires_action の中で、Callを求められた関数の処理を行い、結果を返します。
具体的には、required_action.submit_tool_outputs.tool_callsで、tool_callsまとめて渡されるので、
それをfor文で回して、それぞれ必要な結果をtool_outputsに詰めて、Runに返します。
(実際に返すのはsubmit_tool_outputs関数の中で実装)

main.rs
#
async fn handle_requires_action(client: Client<OpenAIConfig>, run_object: RunObject) {
    let mut tool_outputs: Vec<ToolsOutputs> = vec![];
    if let Some(ref required_action) = run_object.required_action {
        for tool in &required_action.submit_tool_outputs.tool_calls {
            if tool.function.name == "get_current_temperature" {
                tool_outputs.push(ToolsOutputs {
                    tool_call_id: Some(tool.id.clone()),
                    output: Some("57".into()),
                })
            }

            if tool.function.name == "get_rain_probability" {
                tool_outputs.push(ToolsOutputs {
                    tool_call_id: Some(tool.id.clone()),
                    output: Some("0.06".into()),
                })
            }
        }

        if let Err(e) = submit_tool_outputs(client, run_object, tool_outputs).await {
            eprintln!("Error on submitting tool outputs: {e}");
        }
    }
}

上記、FunctionCall actionの結果をRunに返した後の処理。
再度eventstreamを取得して、
今度は回答が返ってくるので、受け取って表示しています。

main.rs
async fn submit_tool_outputs(
    client: Client<OpenAIConfig>,
    run_object: RunObject,
    tool_outputs: Vec<ToolsOutputs>,
) -> Result<(), Box<dyn Error>> {
    let mut event_stream = client
        .threads()
        .runs(&run_object.thread_id)
        .submit_tool_outputs_stream(
            &run_object.id,
            SubmitToolOutputsRunRequest {
                tool_outputs,
                stream: Some(true),
            },
        )
        .await?;

    while let Some(event) = event_stream.next().await {
        match event {
            Ok(event) => {
                if let AssistantStreamEvent::ThreadMessageDelta(delta) = event {
                    if let Some(contents) = delta.delta.content {
                        for content in contents {
                            // only text is expected here and no images
                            if let MessageDeltaContent::Text(text) = content {
                                if let Some(text) = text.text {
                                    if let Some(text) = text.value {
                                        print!("{}", text);
                                    }
                                }
                            }
                        }
                    }
                }
            }
            Err(e) => {
                eprintln!("Error: {e}");
            }
        }
    }

    Ok(())
}

この機能で、他のシステムやサービスとの連携ができるようになるので、一気にできることの可能性が広がりますね。

yunayuna

Example3. assistants-file-search

https://github.com/64bit/async-openai/blob/main/examples/assistants-file-search/src/main.rs

ファイルを、Assistant用にアップし、vector store(ファイル内のテキストや画像の"Vector"を統合したもの=かんたんに言うと、ファイルから読み取った知識)に変換して、Assistantのデータとして利用させることができる。

Assistantの生成

main.rs
    let assistant_name = "example_assistant".to_string();

    let instructions = "あなたは専門の金融アナリストです。ナレッジベースを使用して、監査済み財務諸表に関する質問に答えます。".to_string();

    //create the assistant
    let assistant_request = CreateAssistantRequestArgs::default()
        .name(&assistant_name)
        .instructions(&instructions)
        .model("gpt-4o-mini")
        .tools(vec![
            AssistantToolsFileSearch::default().into(),
        ]).build()?;
    let assistant = client.assistants().create(assistant_request).await?;

ファイルをアップして、Vector化+Assistantにセット

main.rs
// upload file to add to vector store
    let openai_file = client
        .files()
        .create(CreateFileRequest {
            file: "./input/uber-10k.pdf".into(),
            purpose: FilePurpose::Assistants,
        })
        .await?;

    // Create a vector store called "Financial Statements"
    // add uploaded file to vector store
    let vector_store = client
        .vector_stores()
        .create(CreateVectorStoreRequest {
            name: Some("Financial Statements".into()),
            file_ids: Some(vec![openai_file.id.clone()]),
            ..Default::default()
        })
        .await?;

    // Step 3: Update the assistant to to use the new Vector Store
    
    let assistant = client
        .assistants()
        .update(
            &assistant.id,
            ModifyAssistantRequest {
                tool_resources: Some(
                    AssistantToolFileSearchResources {
                        vector_store_ids: vec![vector_store.id.clone()],
                    }
                    .into(),
                ),
                ..Default::default()
            },
        )
        .await?;

Threadの生成

ファイルを添えて、質問メッセージをセットした状態のThreadを生成します。

main.rs
    // Step 4: Create a thread
    let create_message_request = CreateMessageRequestArgs::default()
        .role(MessageRole::User)
        .content("What was the total annual profit of Uber and Lyft?")
         .attachments(vec![MessageAttachment {
             file_id: message_file.id.clone(),
             tools: vec![MessageAttachmentTool::FileSearch],
         }])
        .build()?;

    let create_thread_request = CreateThreadRequest {
        messages: Some(vec![create_message_request]),
        ..Default::default()
    };

    let thread = client.threads().create(create_thread_request).await?;

Runの生成、処理の完了を制御する

main.rs
    // Step 5: Create a run and check the output
    let create_run_request = CreateRunRequest {
        assistant_id: assistant.id.clone(),
        ..Default::default()
    };

    let mut run = client
        .threads()
        .runs(&thread.id)
        .create(create_run_request)
        .await?;

    // poll the status of run until its in a terminal state
    loop {
        //check the status of the run
        match run.status {
            RunStatus::Completed => {
                println!("> Run Completed: {:#?}", run);
                let messages = client
                    .threads()
                    .messages(&thread.id)
                    .list(&[("limit", "10")])
                    .await?;

                for message_obj in messages.data {
                    let message_contents = message_obj.content;
                    for message_content in message_contents {
                        match message_content {
                            MessageContent::Text(text) => {
                                let text_data = text.text;
                                let annotations = text_data.annotations;
                                println!("{}", text_data.value);
                                println!("{annotations:?}");
                            }
                            MessageContent::ImageFile(_) | MessageContent::ImageUrl(_) => {
                                eprintln!("Images not supported on terminal");
                            }
                        }
                    }
                }

                break;
            }
            RunStatus::Failed => {
                println!("> Run Failed: {:#?}", run);
                break;
            }
            RunStatus::Queued => {
                println!("> Run Queued");
            }
            RunStatus::Cancelling => {
                println!("> Run Cancelling");
            }
            RunStatus::Cancelled => {
                println!("> Run Cancelled");
                break;
            }
            RunStatus::Expired => {
                println!("> Run Expired");
                break;
            }
            RunStatus::RequiresAction => {
                println!("> Run Requires Action");
            }
            RunStatus::InProgress => {
                println!("> In Progress ...");
            }
            RunStatus::Incomplete => {
                println!("> Run Incomplete");
            }
        }

        // wait for 1 sec before polling run object again
        tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;

        //retrieve the run
        run = client.threads().runs(&thread.id).retrieve(&run.id).await?;
    }

作成されたデータをクリア

main.rs
    // clean up
    client.threads().delete(&thread.id).await?;
    client.vector_stores().delete(&vector_store.id).await?;
    client.files().delete(&openai_file.id).await?;
    client.files().delete(&message_file.id).await?;
    client.assistants().delete(&assistant.id).await?;

pdf以外のファイル使える?画像をアップしてVector生成するとエラー発生した。
FireSearchで使えるファイルの種別は限られてるようです。

"invalid_request_error: Files with extensions [.png] are not supported for retrieval.
See https://platform.openai.com/docs/assistants/tools/file-search/supported-files
(param: file_ids) (code: unsupported_file)

yunayuna

Example4. assistants-code-interpreter

https://github.com/64bit/async-openai/blob/main/examples/assistants-code-interpreter/src/main.rs

公式
2024-08-02時点でBetaです。
https://platform.openai.com/docs/assistants/tools/code-interpreter

できること

さまざまなデータとフォーマットでファイルを処理し、データとグラフの画像を含むファイルを生成できます。
Code Interpreterを使用すると、アシスタントはコードを繰り返し実行して、
難しいコードや数学の問題を解くことができます。

Functionと違って、処理するコードをこちらが書かなくても、CodeInterpreterがよしなに処理してくれます。
ファイルはOpenAIクラウド上の環境に生成され、ダウンロードできます。

参考記事
https://aitechworld.info/code-interpreter/
https://www.watch.impress.co.jp/docs/topic/1520290.html

準備 Assistants用にファイルアップロード

アップしたファイルでAssistantsを生成します。

main.rs
    // Upload data file with "assistants" purpose
    let data_file = client
        .files()
        .create(CreateFileRequest {
            file: "./input/CASTHPI.csv".into(),
            purpose: FilePurpose::Assistants,
        })
        .await?;


let assistant_request = CreateAssistantRequestArgs::default()
        .name(&assistant_name)
        .instructions("あなたはデータ処理者です。ファイル内のデータについて質問された場合は、質問に答えるコードを記述して実行します。")
        .model("gpt-4o-mini")
        .tools(vec![
            AssistantTools::CodeInterpreter
        ])
        .tool_resources(
            AssistantToolCodeInterpreterResources { file_ids: vec![data_file.id.clone()] }
        ).build()?;
    let assistant = client.assistants().create(assistant_request).await?;

1つのMessageを含むThreadを生成し、Run(実行)します。

main.rs
let create_message_request = CreateMessageRequestArgs::default()
        .role(MessageRole::User)
        .content("価格指数と年のグラフをpng形式で生成してください")
        .build()?;

    let create_thread_request = CreateThreadRequest {
        messages: Some(vec![create_message_request]),
        ..Default::default()
    };

    let thread = client.threads().create(create_thread_request).await?;
    let create_run_request = CreateRunRequest {
        assistant_id: assistant.id.clone(),
        ..Default::default()
    };

    let mut run = client
        .threads()
        .runs(&thread.id)
        .create(create_run_request)
        .await?;

Runを制御して結果を受け取る。
適宜、ImageFileやテキストが返されるので、それぞれ適切に処理します。

main.rs
let mut generated_file_ids: Vec<String> = vec![];

    // poll the status of run until its in a terminal state
    loop {
        //check the status of the run
        match run.status {
            RunStatus::Completed => {
                let messages = client
                    .threads()
                    .messages(&thread.id)
                    .list(&[("limit", "10")])
                    .await?;

                for message_obj in messages.data {
                    let message_contents = message_obj.content;
                    for message_content in message_contents {
                        match message_content {
                            MessageContent::Text(text) => {
                                let text_data = text.text;
                                let annotations = text_data.annotations;
                                println!("てきすと:{}", text_data.value);
                                for annotation in annotations {
                                    match annotation {
                                        MessageContentTextAnnotations::FileCitation(object) => {
                                            println!("annotation: file citation : {object:?}");
                                        }
                                        MessageContentTextAnnotations::FilePath(object) => {
                                            println!("annotation: file path: {object:?}");
                                            generated_file_ids.push(object.file_path.file_id);
                                        }
                                    }
                                }
                            }
                            MessageContent::ImageFile(object) => {
                                let file_id = object.image_file.file_id;
                                println!("Retrieving image file_id: {}", file_id);
                                let contents = client.files().content(&file_id).await?;
                                let path = "./output/price_index_vs_year_graph.png";
                                tokio::fs::write(path, contents).await?;
                                print!("Graph file: {path}");
                                generated_file_ids.push(file_id);
                            }
                            MessageContent::ImageUrl(object) => {
                                eprintln!("Got Image URL instead: {object:?}");
                            }
                        }
                    }
                }

                break;
            }
            RunStatus::Failed => {
                println!("> Run Failed: {:#?}", run);
                break;
            }
            RunStatus::Queued => {
                println!("> Run Queued");
            }
            RunStatus::Cancelling => {
                println!("> Run Cancelling");
            }
            RunStatus::Cancelled => {
                println!("> Run Cancelled");
                break;
            }
            RunStatus::Expired => {
                println!("> Run Expired");
                break;
            }
            RunStatus::RequiresAction => {
                println!("> Run Requires Action");
            }
            RunStatus::InProgress => {
                println!("> In Progress ...");
            }
            RunStatus::Incomplete => {
                println!("> Run Incomplete");
            }
        }

        // wait for 1 sec before polling run object again
        tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;

        //retrieve the run
        run = client.threads().runs(&thread.id).retrieve(&run.id).await?;
    }

最後に生成したファイルなどをクリア

main.rs
    // clean up
    client.threads().delete(&thread.id).await?;
    client.files().delete(&data_file.id).await?;
    for file_id in generated_file_ids {
        client.files().delete(&file_id).await?;
    }
    client.assistants().delete(&assistant.id).await?;

この処理のポイントは、CSVを読みとり生成する処理を、
自分では書いていないところです。

Assistantを作る時、すでに組み込まれているAssistantTools::CodeInterpreterを指定することで、
AIが処理を行えるようになります。

        .tools(vec![
            AssistantTools::CodeInterpreter
        ])

料金について

Code Interpreterは、セッションあたり$ 0.03で課金されます。

Assistantが2つの異なるスレッド(エンドユーザーごとに1つのスレッドなど)で同時にCode Interpreterを呼び出すと、2つのCode Interpreterセッションが作成されます。
各セッションはデフォルトで1時間アクティブになります。
つまり、ユーザーが同じスレッドでコードインタープリターと最大1時間対話した場合、1セッションに対してのみ支払うことになります。

ということなので、同じスレッド内でインタープリターを使うのは安くすみますが、
複数スレッドでCodeInterpreterを使うと、結構な額が発生しそうなので要注意です。

yunayuna

Example5. audio-transcribe

https://github.com/64bit/async-openai/blob/main/examples/audio-transcribe/src/main.rs

transcribeは、音声データから文字起こしを行う機能。

公式より

Whisper は汎用の音声認識モデルです。これは、多様な音声の大規模なデータセットでトレーニングされており、多言語音声認識、音声翻訳、言語識別を実行できるマルチタスク モデルでもあります。

https://github.com/openai/whisper

英語・日本語も読み取り可能ということです。

参考記事によると、多少の雑音があっても読み取れる
https://aismiley.co.jp/ai_news/what-is-whisper/

なお、サンプルには含まれていないが、promptとして専門用語を追加すると、認識できるようになるとのこと
参考:
https://platform.openai.com/docs/guides/speech-to-text/prompting
https://zenn.dev/hayato94087/articles/859dea9e20901b

基本的な流れ

Clientを作成したら、CreateTranscriptionRequestArgsを用いて、

  • 入力データ
  • モデル(whisper-1)
  • 出力フォーマット

を設定して、transcribe処理を行います。

フォーマットは、以下から選択(サンプルでは上の3つが実装されています)

  1. Json:

    • 標準的なJSON形式での出力。音声認識の結果が構造化されたデータとして提供されます。通常、各トランスクリプトやメタデータが含まれます。
  2. VerboseJson:

    • 詳細なJSON形式での出力。通常のJSON形式よりもさらに多くの情報(例えば、各単語のタイミングや信頼度など)が含まれます。
  3. Srt:

    • SubRip Subtitle(SRT)形式で出力されます。主に字幕ファイルで使用される形式で、字幕のタイミング情報とともにテキストが含まれます。
  4. Text:

    • プレーンテキスト形式で出力されます。音声の認識結果が、そのままテキストとして表示されるシンプルな形式です。
  5. Vtt:

    • WebVTT(Web Video Text Tracks)形式で出力されます。これはWeb上でビデオに字幕を提供するための標準的な形式で、SRTと同様にタイミング情報を含みますが、Web向けに最適化されている点が特徴です。

Json

main.rs
async fn transcribe_json() -> Result<(), anyhow::Error> {
    let client = create_client();
    // Credits and Source for audio: https://www.youtube.com/watch?v=oQnDVqGIv4s
    let request = CreateTranscriptionRequestArgs::default()
        .file(
            "./audio/A Message From Sir David Attenborough A Perfect Planet BBC Earth_320kbps.mp3",
        )
        .model("whisper-1")
        .response_format(AudioResponseFormat::Json)
        .build()?;

    let response = client.audio().transcribe(request).await?;
    println!("{}", response.text);
    Ok(())
}

出力

Hello, I'm David Attenborough. I'm speaking to you from my home because, like many of you, I've spent much of the last year indoors, away from friends, family, and access to the natural world. It's been a challenging few months for many of us, but the reaction to these extraordinary times has proved that when we work together, there is no limit to what we can accomplish. Today, we are experiencing environmental change as never before. And the need to take action has never been more urgent. This year, the world will gather in Glasgow for the United Nations Climate Change Conference. It's a crucial moment in our history. This could be a year for positive change for ourselves, for our planet, and for the wonderful creatures with which we share it. A year the world could remember proudly and say, we made a difference. As we make our New Year's resolutions, let's think about what each of us could do. What positive changes could we make in our own lives? So here's to a brighter year ahead. Let's make 2021 a happy new year for all the inhabitants of our perfect planet.

VerboseJson

main.rs
async fn transcribe_verbose_json() -> Result<(), anyhow::Error> {
    let client = create_client();
    let request = CreateTranscriptionRequestArgs::default()
        .file(
            "./audio/A Message From Sir David Attenborough A Perfect Planet BBC Earth_320kbps.mp3",
        )
        .model("whisper-1")
        .response_format(AudioResponseFormat::VerboseJson)
        //granularities(粒度決め)
        .timestamp_granularities(vec![
            //各単語ごとに発話されたタイムスタンプを取得する
            TimestampGranularity::Word,
            //発話のセグメントごとにタイムスタンプを取得。特定のフレーズや文の開始と終了時刻を捉える
            TimestampGranularity::Segment, 
        ])
        .build()?;

    let response = client.audio().transcribe_verbose_json(request).await?;

    println!("{}", response.text);
    if let Some(words) = &response.words {
        println!("- {} words", words.len());
    }
    if let Some(segments) = &response.segments {
        println!("- {} segments", segments.len());
    }
    Ok(())
}

出力

Hello, I'm David Attenborough. I'm speaking to you from my home because, like many of you, I've spent much of the last year indoors, away from friends, family, and access to the natural world. It's been a challenging few months for many of us, but the reaction to these extraordinary times has proved that when we work together, there is no limit to what we can accomplish. Today, we are experiencing environmental change as never before. And the need to take action has never been more urgent. This year, the world will gather in Glasgow for the United Nations Climate Change Conference. It's a crucial moment in our history. This could be a year for positive change, for ourselves, for our planet, and for the wonderful creatures with which we share it. A year the world could remember proudly and say, we made a difference. As we make our New Year's resolutions, let's think about what each of us could do, what positive changes could we make in our own lives. So here's to a brighter year ahead. Let's make 2021 a happy new year for all the inhabitants of our perfect planet. Let's make 2021 a happy new year

  • 202 words
  • 28 segments

Srt

main.rs
async fn transcribe_srt() -> Result<(), anyhow::Error> {
    let client = create_client();
    let request = CreateTranscriptionRequestArgs::default()
        .file(
            "./audio/A Message From Sir David Attenborough A Perfect Planet BBC Earth_320kbps.mp3",
        )
        .model("whisper-1")
        .response_format(AudioResponseFormat::Srt)
        .build()?;

    let response = client.audio().transcribe_raw(request).await?;
    println!("{}", String::from_utf8_lossy(response.as_ref()));
    Ok(())
}

出力

1
00:00:00,000 --> 00:00:03,800
Hello, I'm David Attenborough.

2
00:00:03,800 --> 00:00:07,320
I'm speaking to you from my home because, like many of you,

3
00:00:07,320 --> 00:00:10,160
I've spent much of the last year indoors,

4
00:00:10,160 --> 00:00:15,400
away from friends, family, and access to the natural world.

5
00:00:15,400 --> 00:00:18,719
It's been a challenging few months for many of us,

6
00:00:18,719 --> 00:00:21,959
but the reaction to these extraordinary times

7
00:00:21,959 --> 00:00:24,639
has proved that when we work together,

8
00:00:24,639 --> 00:00:28,160
there is no limit to what we can accomplish.

9
00:00:28,160 --> 00:00:31,520
Today, we are experiencing environmental change

10
00:00:31,520 --> 00:00:32,720
as never before.

11
00:00:35,360 --> 00:00:38,959
And the need to take action has never been more urgent.

12
00:00:41,200 --> 00:00:44,560
This year, the world will gather in Glasgow

13
00:00:44,560 --> 00:00:49,040
for the United Nations Climate Change Conference.

14
00:00:49,040 --> 00:00:52,959
It's a crucial moment in our history.

15
00:00:52,959 --> 00:00:56,480
This could be a year for positive change

16
00:00:56,480 --> 00:00:59,880
for ourselves,

17
00:00:59,880 --> 00:01:04,000
for our planet,

18
00:01:04,000 --> 00:01:07,080
and for the wonderful creatures with which we share it.

19
00:01:11,080 --> 00:01:14,800
A year the world could remember proudly and say,

20
00:01:14,800 --> 00:01:16,599
we made a difference.

21
00:01:19,080 --> 00:01:22,160
As we make our New Year's resolutions,

22
00:01:22,160 --> 00:01:25,239
let's think about what each of us could do.

23
00:01:25,239 --> 00:01:29,360
What positive changes could we make in our own lives?

24
00:01:29,360 --> 00:01:33,720
So here's to a brighter year ahead.

25
00:01:33,720 --> 00:01:37,480
Let's make 2021 a happy new year

26
00:01:37,480 --> 00:01:41,080
for all the inhabitants of our perfect planet.

27
00:01:55,360 --> 00:01:58,320
Let's make 2021 a happy new year

28
00:01:58,320 --> 00:02:00,480
for all the inhabitants of our perfect planet.

料金

Whisper  $0.006 / minute (rounded to the nearest second)
https://www.goatman.co.jp/media/openai-api-pricing/
https://openai.com/api/pricing/

yunayuna

Example6. audio-translate

https://github.com/64bit/async-openai/blob/main/examples/audio-translate/src/main.rs

多言語の音声を読み取った後に、英語に翻訳できます。(2024/8時点で、日本語など、英語以外はNGとのこと)

main.rs
async fn translate_srt() -> Result<(), anyhow::Error> {
    let client = Client::new();
    let request = CreateTranslationRequestArgs::default()
        .file("./audio/koshish karne walon ki haar nahi hoti by amitabh bachchan_320kbps.mp3")
        .model("whisper-1")
        .response_format(AudioResponseFormat::Srt)
        .build()?;

    let response = client.audio().translate_raw(request).await?;

    println!("translate_srt:");
    println!("{}", String::from_utf8_lossy(response.as_ref()));
    Ok(())
}

async fn translate_verbose_json() -> Result<(), anyhow::Error> {
    let client = Client::new();
    // Credits and Source for audio: https://www.youtube.com/watch?v=bHWmzQ4HTS0
    let request = CreateTranslationRequestArgs::default()
        .file("./audio/koshish karne walon ki haar nahi hoti by amitabh bachchan_320kbps.mp3")
        .model("whisper-1")
        .build()?;

    let response = client.audio().translate(request).await?;

    println!("translate_verbose_json:");
    println!("{}", response.text);

    Ok(())
}

translate_verbose_json:
that the boat does not cross the waves out of fear, the one who tries never loses. When a tiny ant walks with a grain, it slips a hundred times on the climbing walls, the faith of the mind breathes in the veins, it does not climb and fall, after all, its hard work is not in vain, the one who tries never loses. A drowned man dives into a well, a drowned man dives into a well, he comes back empty-handed, he does not find pearls in the deep water, he does not find pearls in the deep water, his fist is not empty every time, the one who tries never loses. And the last verse is, Failure is a challenge, accept it. Failure is a challenge, accept it. See what is missing and improve it. As long as you are unsuccessful, give up sleep and peace. As long as you are unsuccessful, give up sleep and peace. Do not run away from the battlefield of struggle. As long as you are unsuccessful, give up sleep and peace. The one who tries never loses. The one who tries never loses.

translate_srt:
1
00:00:00,000 --> 00:00:03,500
that the boat does not cross the waves out of fear,

2
00:00:03,500 --> 00:00:07,000
the one who tries never loses.

3
00:00:07,000 --> 00:00:11,000
When a tiny ant walks with a grain,

4
00:00:11,000 --> 00:00:15,000
it slips a hundred times on the climbing walls,

5
00:00:15,000 --> 00:00:18,000
the faith of the mind breathes in the veins,

6
00:00:18,000 --> 00:00:22,000
it does not climb and fall,

7
00:00:22,000 --> 00:00:26,000
after all, its hard work is not in vain,

8
00:00:26,000 --> 00:00:29,000
the one who tries never loses.

9
00:00:30,000 --> 00:00:33,000
A drowned man dives into a well,

10
00:00:33,000 --> 00:00:37,000
a drowned man dives into a well,

11
00:00:37,000 --> 00:00:40,000
he comes back empty-handed,

12
00:00:40,000 --> 00:00:44,000
he does not find pearls in the deep water,

13
00:00:44,000 --> 00:00:48,000
he does not find pearls in the deep water,

14
00:00:48,000 --> 00:00:51,000
his fist is not empty every time,

15
00:00:51,000 --> 00:00:54,000
the one who tries never loses.

16
00:00:55,000 --> 00:00:57,000
And the last verse is,

17
00:00:57,000 --> 00:01:01,000
Failure is a challenge, accept it.

18
00:01:01,000 --> 00:01:05,000
Failure is a challenge, accept it.

19
00:01:05,000 --> 00:01:09,000
See what is missing and improve it.

20
00:01:09,000 --> 00:01:11,000
As long as you are unsuccessful,

21
00:01:11,000 --> 00:01:14,000
give up sleep and peace.

22
00:01:14,000 --> 00:01:16,000
As long as you are unsuccessful,

23
00:01:16,000 --> 00:01:18,000
give up sleep and peace.

24
00:01:18,000 --> 00:01:21,000
Do not run away from the battlefield of struggle.

25
00:01:21,000 --> 00:01:23,000
As long as you are unsuccessful,

26
00:01:23,000 --> 00:01:25,000
give up sleep and peace.

27
00:01:25,000 --> 00:01:27,000
The one who tries never loses.

28
00:01:27,000 --> 00:01:29,000
The one who tries never loses.
yunayuna

Example7. audio-speech

https://github.com/64bit/async-openai/blob/main/examples/audio-speech/src/main.rs

テキストを音声に変換する。
日本語もそこそこいけました。

main.rs
let client = create_client();
    let request = CreateSpeechRequestArgs::default()
        // .input("Today is a wonderful day to build something people love!")
        .input("CodeZine(コードジン)は、ITエンジニアの成長や課題解決に役立つ記事やイベントレポート、ニュースなどを提供する情報サイトです。ChatGPTや生成AI、Flutter、Pythonなどの注目のテーマや技術を紹介しています。")
        .voice(Voice::Alloy)
        .model(SpeechModel::Tts1)
        .build()?;

    let response = client.audio().speech(request).await?;

    response.save("./data/codezine.mp3").await?;
    

音声
https://youtu.be/lEs2aMk_DVQ

料金

Pricing starts at $0.015 per 1,000 input characters (not token)

https://help.openai.com/en/articles/8555505-tts-api

yunayuna

Example8. in-memory-file

https://github.com/64bit/async-openai/blob/main/examples/in-memory-file/src/main.rs

ファイルの読み込みをbyteコードからも取得できる、というだけのサンプル

main.rs
    let filename =
        "A Message From Sir David Attenborough A Perfect Planet BBC Earth_320kbps.mp3".to_string();
    let file_contents = fs::read(format!("./audio/{}", filename))?;

    let bytes = bytes::Bytes::from(file_contents);

    // To pass in in-memory files, you can pass either bytes::Bytes or vec[u8] to AudioInputs, FileInputs, and ImageInputs.
    let audio_input = AudioInput::from_bytes(filename, bytes);

    let client = Client::new();
    // Credits and Source for audio: https://www.youtube.com/watch?v=oQnDVqGIv4s
    let request = CreateTranscriptionRequestArgs::default()
        .file(audio_input)
        .model("whisper-1")
        .build()?;
yunayuna

Example11. vision-chat

https://github.com/64bit/async-openai/blob/main/examples/vision-chat/src/main.rs

ユーザーが画像をアップロードし、その画像に関する質問をしたり、情報を得たりできるインタラクティブな機能です。
視覚的なコンテンツに対する理解を深めたり、具体的な情報を得たりすることができます。

例えば、ユーザーが写真や図をアップロードすると、ChatGPTはその画像の内容について説明したり、
関連する質問に答えたりします。

サンプルの内容

画像と共に質問を投げかけて、回答を得ています。

main.rs
let client = create_client();
    let image_url = "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg";

    let request = CreateChatCompletionRequestArgs::default()
        .model("gpt-4o-mini")
        .max_tokens(300_u32)
        .messages([ChatCompletionRequestUserMessageArgs::default()
            .content(vec![
                ChatCompletionRequestMessageContentPartTextArgs::default()
                    .text("この絵は何ですか?")
                    .build()?
                    .into(),
                ChatCompletionRequestMessageContentPartImageArgs::default()
                    .image_url(
                        ImageUrlArgs::default()
                            .url(image_url)
                            .detail(ImageDetail::High)
                            .build()?,
                    )
                    .build()?
                    .into(),
            ])
            .build()?
            .into()])
        .build()?;

    println!("{}", serde_json::to_string(&request).unwrap());

    let response = client.chat().create(request).await?;

    println!("\nResponse:\n");
    for choice in response.choices {
        println!(
            "{}: Role: {}  Content: {:?}",
            choice.index,
            choice.message.role,
            choice.message.content.unwrap_or_default()
        );
    }

入力サンプル①

出力①

0: Role: assistant  Content: "これは自然の風景を描いた画像です。緑豊かな草原と青い空、そして木の道が見えます。全体的に穏やかでリラックスできる雰囲気が感じられますね。"

入力サンプル②

出力②

0: Role: assistant  Content: "この絵はLinuxのロゴです。Linuxはオープンソースのオペレーティングシステムであり、ペンギンのキャラクターは「Tux」と呼ばれ、Linuxの公式マスコットとして知られています。"
yunayuna

Example12. tool-call

https://github.com/64bit/async-openai/blob/main/examples/tool-call/src/main.rs

assistants-func-call-streamの中で行っているのと同じように、
Functionを作成し、それをtoolとしtAIが利用できるようにします。
今回はAssistantではないので、ThreadsやRunを使いません。

functionの定義

関数の呼び出しに必須の項目(ここではlocation)と、
単位を定義しています。

main.rs
    let whether_function =  FunctionObjectArgs::default()
    .name("get_current_weather")
    .description("特定の場所の現在の天気を取得する")
    .parameters(json!({
        "type": "object",
        "properties": {
            "location": {
                "type": "string",
                "description": "The city and state, e.g. San Francisco, CA",
            },
            "unit": { "type": "string", "enum": ["celsius", "fahrenheit"] },
        },
        "required": ["location"],
    }))
    .build()?;

Assistantを用いない、通常のチャット用にCreateChatCompletionRequestArgsから
リクエストを生成。
この時、Messageを追加後、使いたい関数をtoolsでセットします。
これで、メッセージのFunctionObjectの定義に合致する質問が来た時、
Runが回答を返す前の段階で、tool_callsが返されます。

main.rs
let request = CreateChatCompletionRequestArgs::default()
        .max_tokens(512u32)
        .model("gpt-4o-mini")
        .messages([ChatCompletionRequestUserMessageArgs::default()
            .content("ボストンとアトランタの気候は?")
            .build()?
            .into()])
        .tools(vec![ChatCompletionToolArgs::default()
            .r#type(ChatCompletionToolType::Function)
            .function(whether_function)
            .build()?])
        .build()?;

リクエストから、レスポンスを直接受け取ります

main.rs

    let response_message = client
        .chat()
        .create(request)
        .await?
        .choices
        .first()
        .unwrap()
        .message
        .clone();

レスポンスから、tool_callsを取り出して処理します。

main.rs
if let Some(tool_calls) = response_message.tool_calls {
        //tool_callsから、関数名とパラメータを取得し、関数を非同期で実行し、処理をhandleにセットします
        let mut handles = Vec::new();
        for tool_call in tool_calls {
            let name = tool_call.function.name.clone();
            let args = tool_call.function.arguments.clone();
            let tool_call_clone = tool_call.clone();

            let handle =
                tokio::spawn(async move { call_fn(&name, &args).await.unwrap_or_default() });
            handles.push((handle, tool_call_clone));
        }
        //handleの結果をfunction_responsesに詰めます
        let mut function_responses = Vec::new();

        for (handle, tool_call_clone) in handles {
            if let Ok(response_content) = handle.await {
                function_responses.push((tool_call_clone, response_content));
            }
        }

上記コードの続き。
もう一回最初からメッセージを詰め込んでいく。

main.rs
        let mut messages: Vec<ChatCompletionRequestMessage> =
            vec![ChatCompletionRequestUserMessageArgs::default()
                .content("ボストンとアトランタの気候は?")
                .build()?
                .into()];

        let tool_calls: Vec<ChatCompletionMessageToolCall> = function_responses
            .iter()
            .map(|(tool_call, _response_content)| tool_call.clone())
            .collect();

        let assistant_messages: ChatCompletionRequestMessage =
            ChatCompletionRequestAssistantMessageArgs::default()
                .tool_calls(tool_calls)
                .build()?
                .into();

        let tool_messages: Vec<ChatCompletionRequestMessage> = function_responses
            .iter()
            .map(|(tool_call, response_content)| {
                ChatCompletionRequestToolMessageArgs::default()
                    .content(response_content.to_string())
                    .tool_call_id(tool_call.id.clone())
                    .build()
                    .unwrap()
                    .into()
            })
            .collect();

        messages.push(assistant_messages);
        messages.extend(tool_messages);

        let subsequent_request = CreateChatCompletionRequestArgs::default()
            .max_tokens(512u32)
            .model("gpt-4o-mini")
            .messages(messages)
            .build()?;

        let mut stream = client.chat().create_stream(subsequent_request).await?;

        let mut response_content = String::new();
        while let Some(result) = stream.next().await {
            match result {
                Ok(response) => {
                    for chat_choice in response.choices.iter() {
                        if let Some(ref content) = chat_choice.delta.content {
                            print!("{}", content);
                            response_content.push_str(content);
                        }
                    }
                }
                Err(err) => {
                    anyhow::bail!(err);
                }
            }
        }

メッセージの構築を何度も行う必要があるLegacyなapiなので、
正直今後、これは使わないかな。

yunayuna

Example14. embedding

https://github.com/64bit/async-openai/blob/main/examples/embeddings/src/main.rs

embeddingの参考記事
https://qiita.com/Detroit/items/99ac1d5b6ec8c07eb48b
公式
https://platform.openai.com/docs/guides/embeddings

このサンプルでは、文章全体をベクトル化するまでの処理を行っています。
このベクトルデータを使った処理については公式にて。
https://platform.openai.com/docs/guides/embeddings/use-cases

料金

Embedding models

text-embedding-3-small: $0.020 / 1M tokens
text-embedding-3-large: $0.130 / 1M tokens
ada v2: $0.100 / 1M tokens

yunayuna

次にやりたいこと

  • 作成したassistantやThreadはデータとして残る?残すとデータ使用量が発生する?
  • assistantやThreadはローカルにも保存しておきたいが・・(不要になったら削除もできるように)
  • assistantsの生成、threadの保存など
  • 画像つき質問ができるようにする
  • code interpreter機能を使えるようにする
yunayuna

その他の試行錯誤メモ

filesearchの引用

filesearchで、vector_storeを登録したAssistantが回答を生成する際、
引用が合った場合は以下のように、source という文字が入る

透明性とコミュニケーション: 情報をオープンにし、同じメッセージを継続的に発信することが求められます。「説明するな、確認せよ」というアプローチにより、部下は状況を理解し、自らの役割に責任を持つようになります【10:5†source】【10:8†source】。

annotationsとして、引用元のファイルデータ情報も取れるっぽいので、
annotationデータもちゃんと取得できるようにしておく
参考:
https://developer.mamezou-tech.com/blogs/2024/04/21/openai-file-search-intro/