[Rails]turboとopenAI APIでチャットボットを作る
はじめに
TurboとopenAI APIを使ってChatGPT風チャットボットを作っていきます。
ユーザーがメッセージを入力し、フォームを送信したら、turbo_stream
を使って送信したメッセージを非同期でページ上に表示します。そして、open AIからのレスポンスをTurboのBroadcastでリアルタイムでページ上に表示する流れです。
openAIへのリクエストはsidekiqでバックグラウンドジョブとして実行します。
環境
Ruby 3.2.1
Rails 7.0.8
TailwindCSS
Redis
tl;dr
- openAI APIキーを取得する
- gemをインストールする
- テーブルを作成する
- ルートを追加する
- コントローラーを作成する
- ジョブを作成する
- ビューを作成する
openAI APIキーを取得する
OpenAI APIは有料なのでご注意くださいー
新規アカウントを作成すると5ドル分のトライアルクレジットをもらえるので新規アカウントを作成し、
作成されたキーをコピーし、アプリの.env
ファイルに環境変数として追加します。
こちらのURLからAPIキーを作成します。
OPENAI_ACCESS_TOKEN=************
config/openai.rb
内に設定を追加します。
OpenAI.configure do |config|
config.access_token = ENV.fetch("OPENAI_ACCESS_TOKEN")
end
gemをインストールする
アプリに必要なgemをインストールします。
gem "ruby-openai" # openAIと通信するgem
gem "sidekiq" # 非同期ジョブの処理をサポートするgem
bundle install
Procfile
にsidekiqとredis用コマンドを追加します。
web: bin/rails server -p 3000
css: bin/rails tailwindcss:watch
+ sidekiq: bundle exec sidekiq -c 2 # 二つのジョブまで同時に処理する
+ queue: redis-server
bin/dev
コマンドを実行します。
テーブルを作成する
Deviseでユーザー認証を実装しています。
ChatテーブルとMessageテーブルを作成します。
bin/rails generate migration CreateChats user:references
invoke active_record
create db/migrate/20231109123319_create_chats.rb
bin/rails generate migration CreateMessages chat:references role:integer content:string response_number:integer
invoke active_record
create db/migrate/20231109130945_create_messages.rb
bin/rails db:migrate
を実行します。
モデルの関連付けを設定する
class Chat < ApplicationRecord
belongs_to :user
has_many :messages, dependent: :destroy
end
各メッセージがユーザーからのものか、open AIからのものか、システムからのものかを区別するためにenumを使ってroleを定義します。
class Message < ApplicationRecord
enum role: { system: 0, assistant: 10, user: 20 }
belongs_to :chat
end
class User < ApplicationRecord
has_many :chats, dependent: :destroy
end
ルートを追加する
resources :chats do
resources :messages, only: %i[new create]
end
コントローラーを作成する
ChatコントローラーではCRUDアクションを定義しています。
Messagesコントローラーでは、メッセージを送ったら非同期でページ上に表示させたいのでcreate
アクションのレスポンスをturbo_stream
にします。
class MessagesController < ApplicationController
before_action :authenticate_user!
def new
@message = Message.new
end
def create
@chat = Chat.find_or_create_by(id: params[:chat_id])
@message = @chat.messages.create(message_params.merge(role: "user"))
# ジョブを定義する
# perform_syncはジョブを非同期で実行するためsidekiqのメソッド
GetAiResponse.perform_async(@message.chat_id)
respond_to do |format|
format.turbo_stream
end
end
private
def message_params
params.require(:message).permit(:content)
end
end
ジョブを作成する
class GetAiResponse
include Sidekiq::Worker
RESPONSES_PER_MESSAGE = 1
MODEL_NAME = "gpt-3.5-turbo"
TEMPERATURE = 0.8
def perform(chat_id)
chat = Chat.find(chat_id)
call_openai(chat)
end
private
def call_openai(chat)
OpenAI::Client.new.chat(
parameters: {
model: MODEL_NAME,
messages: Message.for_openai(chat.messages), # フォーマットを変換する
temperature: TEMPERATURE,
stream: stream_proc(chat), # stream_procメソッドでストリーミングデータを処理
n: RESPONSES_PER_MESSAGE
}
)
end
def create_messages(chat)
Array.new(RESPONSES_PER_MESSAGE) do |i|
message = chat.messages.create(role: "assistant", content: "", response_number: i) # Messageに保存する
message.broadcast_created # 新しいメッセージが作成された後に、そのメッセージに対して broadcast_created メソッドが呼び出される
message
end
end
def stream_proc(chat)
messages = create_messages(chat)
proc do |chunk, _bytesize|
new_content = chunk.dig("choices", 0, "delta", "content")
message = messages.find { |m| m.response_number == chunk.dig("choices", 0, "index") }
message.update(content: message.content + new_content) if new_content
end
end
end
RESPONSES_PER_MESSAGE = 1
: 1つのメッセージに対して一つのAIの応答を生成します。
temperature
は、OpenAI GPTモデルが生成するテキストのランダム性を制御するパラメータの一つです。このパラメータは、新しいテキストを生成する際にモデルがどれだけランダム性を持たせるかを指定します。
temperature
の値が高いほど、生成されるテキストはよりランダムで多様になります。一方で、temperature
の値が低いほど、モデルの予測がより確定的になります。ランダム性が低い場合、モデルはより予測可能で一貫性のあるテキストを生成します。
例:
temperature: 0.2
: 生成されるテキストは非常に確定的で、予測が限定されたものになります。
temperature: 0.8
: 中程度のランダム性があり、多様性が増します。
temperature: 1.0
: 高いランダム性があり、生成されるテキストは非常に多様で予測不可能です。
perform(chat_id)
: Sidekiqジョブが実際に実行されるメソッドです。指定されたchat_id
に基づいて Chat
インスタンスを取得し、そのチャットに対してOpenAIによる応答を呼び出します。
call_openai(chat:)
: OpenAIのAPIを呼び出してチャットの応答を取得するメソッドです。OpenAI::Client
を使用し、client.chat
メソッドを呼び出して対話を進めます。
create_messages(chat:)
: 新しいメッセージを作成し、それらのメッセージを配列に追加するメソッドです。これは、OpenAIのstream
オプションを利用するために必要なもので、各メッセージには一意のresponse_number
が割り当てられます。
stream_proc(chat:)
: OpenAI APIからのストリーミングデータを処理するためのプロシージャです。新しいデータが届くたびに、メッセージを更新しています。大きなデータセットを一度に処理せず、逐次的に処理していくことでメモリの使用量を抑えつつ、ストリームデータを効率的に扱うことができます。
Messageに送信するフォーマットに変換する
OpenAI APIへのリクエストを構築する際に、対話のコンテキストを適切な形式で提供するためにメッセージのフォーマットを変換するメソッドを作成します。
class Message < ApplicationRecord
def self.for_openai(messages)
messages.map { |message| { role: message.role, content: message.content } }
end
end
message
ブロック内では、各メッセージの role
プロパティ(役割)と content
プロパティ(内容)を取り出し、それをハッシュとして表現しています。
例えば、[{ role: 'user', content: 'Hello' }, { role: 'assistant', content: 'Hi there' }]
のようになります。
ブロードキャストメソッドを作成する
AIからのレスポンをページ上にブロードキャストするメソッドを作成します。
新しいメッセージがデータベースに保存された後に、関連するチャットに対してそのメッセージが表示されるように通知を送信します。これにより、リアルタイムでチャットメッセージが更新されます。
class Message < ApplicationRecord
after_create_commit -> { broadcast_created }
def broadcast_created
broadcast_append_later_to(
"#{dom_id(chat)}_messages",
partial: "messages/message",
locals: { message: self, scroll_to: true },
target: "#{dom_id(chat)}_messages"
)
end
end
after_create_commit
コールバックを使用して新しいメッセージが作成されたときにリアルタイムにブロードキャストするように定義しています。
"#{dom_id(chat)}_messages"
: ブロードキャスト対象となるDOMエレメントのID。クライアント側で特定のメッセージを指定して更新できます。
partial: "messages/message"
: メッセージの部分テンプレートを指定して、どのビューファイルを利用するか指定しています。
locals: { message: self, scroll_to: true }
: 部分テンプレートに渡すローカル変数を指定しています。scroll_to: true
は新しいメッセージが表示されるときにスクロールするかどうかを指定します。
target: "#{dom_id(chat)}_messages"
: ブロードキャストされたデータをどのDOMエレメントに追加するかを指定しています。
ビューを作成する
メッセージフォーム
チャットのフォームを作成します。
cmd+ENTER
キーを押してフォームを送信できるようにJavaScriptコードを追加します。
コマンド(Cmd)キーとEnterキーが同時に押されたときに、$event
オブジェクトのtarget
プロパティ(イベントを発生させた要素)の親要素のフォームに対してrequestSubmit()
メソッドを呼び出し、フォームを送信させます。
<%= turbo_frame_tag "#{dom_id(chat)}_message_form" do %>
<%= form_with(model: Message.new, url: chat_messages_path(chat)) do |form| %>
<%= form.text_area :content, rows: 4, "x-on:keydown.cmd.enter" => "$event.target.form.requestSubmit();" %>
<%= form.button, '送信', type: :submit %>
<% end %>
<% end %>
<% end %>
メッセージパーシャル
メッセージパーシャルを作成します。
ユーザーとユーザー以外のメッセージを区別し、CSSを適用して表示します。
ユーザーの場合、青い背景で表示します。それ以外の場合、灰色背景で表示します。
<div id="<%= dom_id(message) %>_messages">
<% if message.user? %>
<div class="bg-sky-400 rounded-lg m-8 text-white p-4">
<%= message.content %>
</div>
<% else %>
<div class="bg-gray-200 rounded-lg m-8 p-4">
<%= message.content %>
</div>
<% end %>
</div>
turbo_stream
パーシャル
新しいメッセージを送信されたら指定したDOM要素にappend
し、メッセージフォームも置き換えます。
<%= turbo_stream.append "#{dom_id(@message.chat)}_messages" do %>
<%= render "message", message: @message, scroll_to: true %>
<% end %>
<%= turbo_stream.replace "#{dom_id(@message.chat)}_message_form" do %>
<%= render "form", chat: @message.chat %>
<% end %>
turbo_stream.append
は、指定されたDOM要素に対して新しい要素を追加します。
#{dom_id(@message.chat)}_messages
で指定されたDOMエレメント内に新しいメッセージが追加されます。
turbo_stream.replace
は、指定されたDOM要素を新しい内容で置き換えます。
#{dom_id(@message.chat)}_message_form
は、メッセージ入力フォームです。メッセージを送信された後、現在のメッセージフォームが置き換えら、新しいメッセージ入力フォームを表示されます。
チャットの詳細ページ
チャットの詳細ページでは、チャットに紐づくメッセージとメッセージフォームを表示します。
<%= turbo_stream_from "#{dom_id(@chat)}_messages" %>
<div id="<%= dom_id(@chat) %>_messages">
<%= render @chat.messages %>
</div>
<%= render partial: "messages/form", locals: { chat: @chat } %>
出来上がったチャットボット:
GetAiResponse
クラスの非同期ジョブがTurbo Streamsのアクションをブロードキャストするためのジョブをエンキューしています。chat_1_messages
というターゲットに対して :append
アクションを行い、指定されたパーシャルとローカル変数を利用しブロードキャストします。
20:08:07 sidekiq.1 | 2023-11-11T11:08:07.536Z pid=51411 tid=rir class=GetAiResponse jid=3ca134a0671b1fe44d82eacc INFO: Enqueued Turbo::Streams::ActionBroadcastJob (Job ID: 242034db-9d82-42ad-8c6f-82f1e9d02c7e) to Async(default) with arguments: "chat_1_messages", {:action=>:append, :target=>"chat_1_messages", :targets=>nil, :partial=>"messages/message", :locals=>{:message=>#<GlobalID:0x00000001120606e0 @uri=#<URI::GID gid://chatapp/Message/42>>, :scroll_to=>true}}
レスポンスをストリーミングし、Messageに保存します。
20:08:10 web.1 | Turbo::StreamsChannel transmitting "<turbo-stream action=\"append\" target=\"chat_1_messages\"><template><div id=\"message_42_messages\">\n <div class=\"bg-gray-200 rounded-lg m-8 p-4\">\n こんにちは!なにかお手伝いできますか?\n </div>\n</div>\n</template></turbo-stream>" (via streamed from chat_1_messages)
...
20:08:48 web.1 | Message Create (0.3ms) INSERT INTO "messages" ("chat_id", "role", "content", "created_at", "updated_at", "response_number") VALUES (?, ?, ?, ?, ?, ?) [["chat_id", 1], ["role", 20], ["content", "あなたは誰ですか"], ["created_at", "2023-11-11 11:08:48.209999"], ["updated_at", "2023-11-11 11:08:48.209999"], ["response_number", 0]]
20:08:48 web.1 | ↳ app/controllers/messages_controller.rb:12:in `create'
終わりに
turboとopenAI APIでチャットボットを作ってみました。
Discussion