🦓

[Rails]turboとopenAI APIでチャットボットを作る

2023/11/11に公開

はじめに

TurboとopenAI APIを使ってChatGPT風チャットボットを作っていきます。

ユーザーがメッセージを入力し、フォームを送信したら、turbo_streamを使って送信したメッセージを非同期でページ上に表示します。そして、open AIからのレスポンスをTurboのBroadcastでリアルタイムでページ上に表示する流れです。

openAIへのリクエストはsidekiqでバックグラウンドジョブとして実行します。

https://github.com/alexrudall/ruby-openai

環境

Ruby 3.2.1
Rails 7.0.8
TailwindCSS
Redis

tl;dr

  1. openAI APIキーを取得する
  2. gemをインストールする
  3. テーブルを作成する
  4. ルートを追加する
  5. コントローラーを作成する
  6. ジョブを作成する
  7. ビューを作成する

openAI APIキーを取得する

OpenAI APIは有料なのでご注意くださいー
新規アカウントを作成すると5ドル分のトライアルクレジットをもらえるので新規アカウントを作成し、
作成されたキーをコピーし、アプリの.envファイルに環境変数として追加します。
こちらのURLからAPIキーを作成します。
https://platform.openai.com/api-keys

.env
OPENAI_ACCESS_TOKEN=************

config/openai.rb内に設定を追加します。

config/openai.rb
OpenAI.configure do |config|
    config.access_token = ENV.fetch("OPENAI_ACCESS_TOKEN")
end

gemをインストールする

アプリに必要なgemをインストールします。

Gemfile
gem "ruby-openai" # openAIと通信するgem
gem "sidekiq" # 非同期ジョブの処理をサポートするgem
bundle install

https://github.com/alexrudall/ruby-openai
https://github.com/sidekiq/sidekiq

Procfileにsidekiqとredis用コマンドを追加します。

Procfile.dev
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を実行します。

モデルの関連付けを設定する

app/models/chat.rb
class Chat < ApplicationRecord
  belongs_to :user
  has_many :messages, dependent: :destroy
end

各メッセージがユーザーからのものか、open AIからのものか、システムからのものかを区別するためにenumを使ってroleを定義します。

app/models/message.rb
class Message < ApplicationRecord
  enum role: { system: 0, assistant: 10, user: 20 }
  belongs_to :chat
end
app/models/user.rb
class User < ApplicationRecord
  has_many :chats, dependent: :destroy
end

ルートを追加する

config/routes.rb
resources :chats do
  resources :messages, only: %i[new create]
end

コントローラーを作成する

ChatコントローラーではCRUDアクションを定義しています。

Messagesコントローラーでは、メッセージを送ったら非同期でページ上に表示させたいのでcreateアクションのレスポンスをturbo_streamにします。

app/controllers/messages_controller.rb
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

ジョブを作成する

app/jobs/get_ai_response.rb
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: 高いランダム性があり、生成されるテキストは非常に多様で予測不可能です。

https://platform.openai.com/docs/api-reference/making-requests

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へのリクエストを構築する際に、対話のコンテキストを適切な形式で提供するためにメッセージのフォーマットを変換するメソッドを作成します。

app/models/message.rb
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' }] のようになります。

https://github.com/alexrudall/ruby-openai#chat

ブロードキャストメソッドを作成する

AIからのレスポンをページ上にブロードキャストするメソッドを作成します。
新しいメッセージがデータベースに保存された後に、関連するチャットに対してそのメッセージが表示されるように通知を送信します。これにより、リアルタイムでチャットメッセージが更新されます。

app/models/message.rb
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()メソッドを呼び出し、フォームを送信させます。

app/views/messages/_form.html.erb
<%= 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を適用して表示します。
ユーザーの場合、青い背景で表示します。それ以外の場合、灰色背景で表示します。

app/views/messages/_message.html.erb
<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し、メッセージフォームも置き換えます。

app/views/messages/create.turbo_stream.erb
<%= 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 は、メッセージ入力フォームです。メッセージを送信された後、現在のメッセージフォームが置き換えら、新しいメッセージ入力フォームを表示されます。

チャットの詳細ページ

チャットの詳細ページでは、チャットに紐づくメッセージとメッセージフォームを表示します。

app/views/chats/show.html.erb
<%= turbo_stream_from "#{dom_id(@chat)}_messages" %>
<div id="<%= dom_id(@chat) %>_messages">
    <%= render @chat.messages %>
</div>
<%= render partial: "messages/form", locals: { chat: @chat } %>

出来上がったチャットボット:

Image from Gyazo

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でチャットボットを作ってみました。

https://gist.github.com/alexrudall/cb5ee1e109353ef358adb4e66631799d
https://techracho.bpsinc.jp/hachi8833/2023_07_31/132198

Discussion