Function Calling

Episode #574 by Teacher's Avatar David Kimura

Summary

In this episode, we look at adding function calling or tool use to our Rails application when making generative text LLM requests.
rails ai llm 12:11

Chapters

  • Introduction (0:00)
  • Setting up the gem (1:48)
  • Generating the model (2:42)
  • Creating the assistant controller (2:56)
  • Assistant class (4:51)
  • Demo (10:24)
  • Final Thoughts (11:12)

Resources

Download Source Code

Summary

# Terminal
bundle add ruby-openai
bin/rails credentials:edit
bin/rails g model products name stock:integer
bin/rails g controller assistants index

# config/initializers/openai.rb
OpenAI.configure do |config|
  config.access_token = Rails.application.credentials.dig(:openai, :access_token)
end

# config/routes.rb
Rails.application.routes.draw do
  resources :assistants, only: [ :index, :create ]
  root to: "assistants#index"
  get "up" => "rails/health#show", as: :rails_health_check
end

# Credentials File
openai:
  access_token: YOURTOKEN

# app/controllers/assistants_controller.rb
class AssistantsController < ApplicationController
  def index
  end

  def create
    @question = params[:question]
    @answer = Assistant.new.ask(@question)
    render :index, status: :see_other
  end
end

# app/views/assistants/index.html.erb
<div class="mx-auto mt-16 max-w-xl px-4">
  <h1 class="text-2xl font-bold">Inventory Assistant</h1>

  <%= form_with url: assistants_path, method: :post, class: "mt-6 glex gap-2" do |form| %>
    <%= form.text_field :question,
          value: @question,
          placeholder: "How many road helmets do we have?",
          class: "flex-1 rounded border border-gray-300 px-3 py-2" %>
    <%= form.submit "Ask", class: "rounded bg-blue-600 px-4 py-2 font-medium text-white cursor-pointer" %>
  <% end %>

  <% if @answer %>
    <div class="mt-6 rounded border border-gray-200 bg-gray-50 p-4">
      <%= @answer %>
    </div>
  <% end %>
</div>


# app/models/assistant.rb
class Assistant
  SYSTEM_PROMPT = "You are an inventory assistant for our store. " \
    "Always use the check_inventory tool to answer questions about products " \
    "and stock levels. Only discuss products that the tool returns."

  TOOLS = [
    {
      type: "function",
      function: {
        name: "check_inventory",
        description: "Search the store's products by name and return matching products with their stock levels. Use a broad term like 'helmet' to list all helmets.",
        parameters: {
          type: "object",
          properties: { product_name: { type: "string" } },
          required: [ "product_name" ]
        }
      }
    }
  ]

  def initialize
    @client = OpenAI::Client.new
  end

  def ask(question)
    messages = [
      { role: "system", content: SYSTEM_PROMPT },
      { role: "user", content: question }
    ]
    message = chat(messages).dig("choices", 0, "message")

    if (tool_call = message.dig("tool_calls", 0))
      Rails.logger.info "✅✅✅✅✅✅ #{tool_call}"
      messages << message
      messages << tool_result(tool_call)
      message = chat(messages).dig("choices", 0, "message")
    end

    message["content"]
  end

  private

  def chat(messages)
    @client.chat(parameters: { model: "gpt-4o-mini", messages:, tools: TOOLS })
  end

  def tool_result(tool_call)
    args = JSON.parse(tool_call.dig("function", "arguments"))
    products = Product.where("name LIKE ?", "%#{args["product_name"]}%")

    {
      role: "tool",
      tool_call_id: tool_call["id"],
      content: products.any? ? products.map { |p| "#{p.name}: #{p.stock} in stock" }.join("\n") : "not found"
    }
  end
end