Tool call implementation and test
This commit is contained in:
@@ -66,15 +66,50 @@ class Assistant
|
||||
functions: available_functions
|
||||
)
|
||||
|
||||
if response.success?
|
||||
response.data.messages.each do |message|
|
||||
message.chat = chat
|
||||
message.save!
|
||||
end
|
||||
else
|
||||
unless response.success?
|
||||
Rails.logger.error("Assistant failed to respond to user: #{response.error}")
|
||||
chat.update!(error: response.error)
|
||||
return
|
||||
end
|
||||
|
||||
# If no tool calls, create a plain message for the chat
|
||||
unless response.data.tool_calls.any?
|
||||
message = response.data.message
|
||||
message.save!
|
||||
return
|
||||
end
|
||||
|
||||
# Step 1: Saving a "pending" message with incomplete tool call definitions
|
||||
message = response.data.message
|
||||
message.status = "pending"
|
||||
message.save!
|
||||
|
||||
# Step 2: Call the functions, add to message and save
|
||||
tool_calls = message.tool_calls.map do |tool_call|
|
||||
result = call_tool_function(tool_call.function_name, tool_call.function_arguments)
|
||||
tool_call.function_result = result
|
||||
tool_call
|
||||
end
|
||||
|
||||
message.tool_calls = tool_calls
|
||||
message.save!
|
||||
|
||||
# Step 3: Call LLM again with tool call results and update the message with response
|
||||
second_response = provider.chat_response(
|
||||
model: latest_message.ai_model,
|
||||
instructions: instructions,
|
||||
messages: chat_history,
|
||||
)
|
||||
|
||||
unless second_response.success?
|
||||
Rails.logger.error("Assistant failed to process tool call results: #{second_response.error}")
|
||||
chat.update!(error: second_response.error)
|
||||
return
|
||||
end
|
||||
|
||||
second_message = second_response.data.message
|
||||
second_message.status = "complete"
|
||||
second_message.save!
|
||||
end
|
||||
|
||||
private
|
||||
@@ -82,10 +117,10 @@ class Assistant
|
||||
chat.messages.ordered.where(role: [ :user, :assistant, :developer ], status: "complete", kind: "text")
|
||||
end
|
||||
|
||||
def call_function(name, params = {})
|
||||
fn = available_functions.find { |fn| fn.name == name }
|
||||
raise "Assistant does not implement function: #{name}" if fn.nil?
|
||||
fn.call(params)
|
||||
def call_tool_function(fn_name, fn_params = {})
|
||||
fn = available_functions.find { |fn| fn.name == fn_name }
|
||||
raise "Assistant does not implement function: #{fn_name}" if fn.nil?
|
||||
fn.call(fn_params)
|
||||
end
|
||||
|
||||
def instructions
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class Assistant::Function::ComparePeriods < Assistant::Function
|
||||
class Assistant::Functions::ComparePeriods
|
||||
include Assistant::Functions::Toolable
|
||||
|
||||
class << self
|
||||
def name
|
||||
"compare_periods"
|
||||
@@ -1,4 +1,6 @@
|
||||
class Assistant::Function::GetAccountBalances < Assistant::Function
|
||||
class Assistant::Functions::GetAccountBalances
|
||||
include Assistant::Functions::Toolable
|
||||
|
||||
class << self
|
||||
def name
|
||||
"get_account_balances"
|
||||
@@ -1,4 +1,6 @@
|
||||
class Assistant::Function::GetBalanceSheet < Assistant::Function
|
||||
class Assistant::Functions::GetBalanceSheet
|
||||
include Assistant::Functions::Toolable
|
||||
|
||||
class << self
|
||||
def name
|
||||
"get_balance_sheet"
|
||||
@@ -1,4 +1,6 @@
|
||||
class Assistant::Function::GetExpenseCategories < Assistant::Function
|
||||
class Assistant::Functions::GetExpenseCategories
|
||||
include Assistant::Functions::Toolable
|
||||
|
||||
class << self
|
||||
def name
|
||||
"get_expense_categories"
|
||||
@@ -1,4 +1,6 @@
|
||||
class Assistant::Function::GetIncomeStatement < Assistant::Function
|
||||
class Assistant::Functions::GetIncomeStatement
|
||||
include Assistant::Functions::Toolable
|
||||
|
||||
class << self
|
||||
def name
|
||||
"get_income_statement"
|
||||
@@ -1,4 +1,6 @@
|
||||
class Assistant::Function::GetTransactions < Assistant::Function
|
||||
class Assistant::Functions::GetTransactions
|
||||
include Assistant::Functions::Toolable
|
||||
|
||||
class << self
|
||||
def name
|
||||
"get_transactions"
|
||||
@@ -1,5 +1,7 @@
|
||||
class Assistant::Function
|
||||
class << self
|
||||
module Assistant::Functions::Toolable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def name
|
||||
raise NotImplementedError, "Subclasses must implement the name class method"
|
||||
end
|
||||
@@ -1,11 +1,7 @@
|
||||
module Assistant::Provideable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
ChatResponse = Data.define(:messages)
|
||||
|
||||
def supports_model?(model)
|
||||
raise NotImplementedError, "Subclasses must implement #supports_model?"
|
||||
end
|
||||
ChatResponse = Data.define(:message, :tool_calls)
|
||||
|
||||
def chat_response(messages:, model: nil, functions: [], instructions: nil)
|
||||
raise NotImplementedError, "Subclasses must implement #chat_response"
|
||||
|
||||
@@ -26,7 +26,7 @@ class Message < ApplicationRecord
|
||||
}
|
||||
|
||||
validates :ai_model, presence: { if: -> { assistant? || user? } }
|
||||
validates :content, presence: true
|
||||
validates :content, presence: true, allow_blank: true
|
||||
validate :kind_valid_for_role
|
||||
validate :status_valid_for_role
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
class Provider::OpenAI < Provider
|
||||
include Assistant::Provideable
|
||||
|
||||
AVAILABLE_MODELS = %w[gpt-4o]
|
||||
|
||||
def initialize(access_token)
|
||||
@client = ::OpenAI::Client.new(access_token: access_token)
|
||||
end
|
||||
@@ -11,29 +9,26 @@ class Provider::OpenAI < Provider
|
||||
AVAILABLE_MODELS.include?(model)
|
||||
end
|
||||
|
||||
def chat_response(messages:, model: nil, functions: [], instructions: nil)
|
||||
def chat_response(messages:, model: nil, instructions: nil, functions: [])
|
||||
provider_response do
|
||||
validate_model!(model)
|
||||
|
||||
response = client.responses.create(
|
||||
parameters: {
|
||||
model: model,
|
||||
input: messages.map { |msg| { role: msg.role, content: msg.content } },
|
||||
tools: build_tools(functions),
|
||||
input: build_input(messages),
|
||||
tools: build_available_tools(functions),
|
||||
instructions: instructions
|
||||
}
|
||||
)
|
||||
|
||||
Assistant::Provideable::ChatResponse.new(
|
||||
messages: response.dig("output").filter { |item| item.dig("type") == "message" }.map do |item|
|
||||
Message.new(
|
||||
ai_model: response.dig("model"),
|
||||
provider_id: item.dig("id"),
|
||||
status: normalize_status(item.dig("status")),
|
||||
role: "assistant",
|
||||
content: item.dig("content").map { |content| content.dig("text") }.join("\n")
|
||||
)
|
||||
end
|
||||
message: Message.new(
|
||||
ai_model: response.dig("model"),
|
||||
provider_id: response.dig("id"),
|
||||
status: normalize_status(response.dig("status")),
|
||||
role: "assistant",
|
||||
content: extract_content(response),
|
||||
),
|
||||
tool_calls: extract_tool_calls(response)
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -41,12 +36,27 @@ class Provider::OpenAI < Provider
|
||||
private
|
||||
attr_reader :client, :model, :functions
|
||||
|
||||
def validate_model!(model)
|
||||
raise "Model #{model} not supported for Provider::OpenAI" unless AVAILABLE_MODELS.include?(model)
|
||||
model
|
||||
# Builds input based on chat history and nested tool calls within messages
|
||||
def build_input(messages)
|
||||
input = []
|
||||
|
||||
messages.each do |msg|
|
||||
# Append completed messages. Messages with tool calls will be "pending"
|
||||
if msg.complete?
|
||||
input << { role: msg.role, content: msg.content }
|
||||
end
|
||||
|
||||
# Append both the tool call and the tool call result with its correlation id
|
||||
msg.tool_calls.each do |tc|
|
||||
input << { type: "function_call", id: tc.provider_id, call_id: tc.provider_fn_call_id, name: tc.function_name, arguments: tc.function_arguments }
|
||||
input << { type: "function_call_output", call_id: tc.provider_fn_call_id, output: tc.function_result }
|
||||
end
|
||||
end
|
||||
|
||||
input
|
||||
end
|
||||
|
||||
def build_tools(functions = [])
|
||||
def build_available_tools(functions = [])
|
||||
functions.map do |fn|
|
||||
{
|
||||
type: "function",
|
||||
@@ -58,6 +68,28 @@ class Provider::OpenAI < Provider
|
||||
end
|
||||
end
|
||||
|
||||
def extract_content(response)
|
||||
response.dig("output").filter { |item| item.dig("type") == "message" }.map do |item|
|
||||
item.dig("content").map do |content|
|
||||
text = content.dig("text")
|
||||
refusal = content.dig("refusal")
|
||||
|
||||
text || refusal
|
||||
end
|
||||
end.flatten.compact.join("\n")
|
||||
end
|
||||
|
||||
def extract_tool_calls(response)
|
||||
response.dig("output").filter { |item| item.dig("type") == "function_call" }.map do |item|
|
||||
ToolCall::Function.new(
|
||||
provider_id: item.dig("id"),
|
||||
provider_fn_call_id: item.dig("call_id"),
|
||||
function_name: item.dig("name"),
|
||||
function_arguments: item.dig("arguments"),
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Normalize to our internal message status values
|
||||
def normalize_status(status)
|
||||
case status
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
class ToolCall::Function < ToolCall
|
||||
validates :function_name, presence: true
|
||||
validates :function_name, :function_arguments, :function_result, presence: true
|
||||
end
|
||||
|
||||
@@ -22,12 +22,14 @@ class CreateAiChats < ActiveRecord::Migration[7.2]
|
||||
|
||||
create_table :tool_calls, id: :uuid do |t|
|
||||
t.references :message, null: false, foreign_key: true, type: :uuid
|
||||
t.string :provider_id, null: false
|
||||
t.string :provider_fn_call_id, null: false
|
||||
t.string :type, null: false
|
||||
t.string :status, null: false
|
||||
|
||||
# Function specific fields
|
||||
t.string :function_name
|
||||
t.jsonb :function_arguments
|
||||
t.jsonb :function_result
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
4
db/schema.rb
generated
4
db/schema.rb
generated
@@ -569,10 +569,12 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_19_212839) do
|
||||
|
||||
create_table "tool_calls", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "message_id", null: false
|
||||
t.string "provider_id", null: false
|
||||
t.string "provider_fn_call_id", null: false
|
||||
t.string "type", null: false
|
||||
t.string "status", null: false
|
||||
t.string "function_name"
|
||||
t.jsonb "function_arguments"
|
||||
t.jsonb "function_result"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["message_id"], name: "index_tool_calls_on_message_id"
|
||||
|
||||
3
test/fixtures/tool_calls.yml
vendored
3
test/fixtures/tool_calls.yml
vendored
@@ -1,6 +1,7 @@
|
||||
one:
|
||||
type: ToolCall::Function
|
||||
function_name: get_user_info
|
||||
provider_id: fc_12345xyz
|
||||
provider_fn_call_id: call_12345xyz
|
||||
function_arguments: {}
|
||||
message: assistant
|
||||
status: complete
|
||||
|
||||
@@ -2,4 +2,27 @@ require "test_helper"
|
||||
|
||||
module LLMInterfaceTest
|
||||
extend ActiveSupport::Testing::Declarative
|
||||
|
||||
test "provides basic chat response" do
|
||||
VCR.use_cassette("#{vcr_key_prefix}/chat/basic_response") do
|
||||
response = @subject.chat_response(
|
||||
model: @subject_model,
|
||||
messages: [
|
||||
Message.new(
|
||||
role: "user",
|
||||
content: "This is a chat test. If it's working, respond with a single word: Yes"
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
assert response.success?
|
||||
assert_includes response.data.message.ai_model, @subject_model
|
||||
assert_equal "Yes", response.data.message.content
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def vcr_key_prefix
|
||||
@subject.class.name.demodulize.underscore
|
||||
end
|
||||
end
|
||||
|
||||
@@ -29,72 +29,4 @@ class AssistantTest < ActiveSupport::TestCase
|
||||
@assistant.respond_to_user
|
||||
end
|
||||
end
|
||||
|
||||
test "can execute get_balance_sheet function" do
|
||||
result = @financial_assistant.send(:execute_get_balance_sheet)
|
||||
|
||||
assert_kind_of Hash, result
|
||||
assert_includes result.keys, :net_worth
|
||||
assert_includes result.keys, :total_assets
|
||||
assert_includes result.keys, :total_liabilities
|
||||
end
|
||||
|
||||
test "can execute get_income_statement function" do
|
||||
result = @financial_assistant.send(:execute_get_income_statement, { "period" => "current_month" })
|
||||
|
||||
assert_kind_of Hash, result
|
||||
assert_includes result.keys, :total_income
|
||||
assert_includes result.keys, :total_expenses
|
||||
assert_includes result.keys, :net_income
|
||||
end
|
||||
|
||||
test "processes OpenAI response with direct content" do
|
||||
response = {
|
||||
"choices" => [
|
||||
{
|
||||
"message" => {
|
||||
"content" => "This is a direct response."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
messages = []
|
||||
result = @financial_assistant.send(:process_response, response, "Test question", messages)
|
||||
assert_equal "This is a direct response.", result
|
||||
end
|
||||
|
||||
test "processes OpenAI response with function calls" do
|
||||
# This test is a bit tricky since we need to mock both OpenAI API calls
|
||||
# We'll skip the actual implementation and just test that the class
|
||||
# has the necessary methods and structure
|
||||
|
||||
assert_respond_to @financial_assistant, :query
|
||||
assert_respond_to @financial_assistant, :financial_function_definitions
|
||||
|
||||
# Create a direct response for testing
|
||||
direct_response = "Your net worth is $100,000."
|
||||
|
||||
# Instead of calling the actual method, we'll mock everything
|
||||
@financial_assistant.stubs(:query).returns(direct_response)
|
||||
|
||||
# Test the query method via our stub
|
||||
result = @financial_assistant.query("What's my net worth?")
|
||||
assert_equal direct_response, result
|
||||
end
|
||||
|
||||
test "handles function calls in OpenAI response" do
|
||||
# Create a simplified version of the test that mocks the full process_response method
|
||||
expected_response = "Based on your balance sheet, your net worth is $150,000."
|
||||
|
||||
# Setup the query expectation - this is the top-level method
|
||||
@financial_assistant.expects(:process_response).returns(expected_response)
|
||||
@mock_client.expects(:chat).returns("mock_response")
|
||||
|
||||
# Call the query method
|
||||
result = @financial_assistant.query("What is my net worth?")
|
||||
|
||||
# Verify the result
|
||||
assert_equal expected_response, result
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,57 +5,83 @@ class Provider::OpenAITest < ActiveSupport::TestCase
|
||||
|
||||
setup do
|
||||
@subject = @openai = Provider::OpenAI.new(ENV.fetch("OPENAI_ACCESS_TOKEN"))
|
||||
@subject_model = "gpt-4o"
|
||||
end
|
||||
|
||||
test "verifies model support" do
|
||||
assert_not @subject.supports_model?("unknown-for-test-that-returns-false")
|
||||
test "openai errors are automatically raised" do
|
||||
VCR.use_cassette("open_ai/chat/error") do
|
||||
response = @openai.chat_response(
|
||||
model: "invalid-model-key",
|
||||
messages: [ Message.new(role: "user", content: "Error test") ]
|
||||
)
|
||||
|
||||
assert_not response.success?
|
||||
assert_kind_of Faraday::BadRequestError, response.error
|
||||
|
||||
# Adheres to openai response schema
|
||||
assert_equal "model_not_found", response.error.response[:body].dig("error", "code")
|
||||
end
|
||||
end
|
||||
|
||||
test "provides basic chat response" do
|
||||
VCR.use_cassette("#{vcr_key_prefix}/chat/basic_response") do
|
||||
response = @subject.chat_response(
|
||||
test "handles chat response with tool calls" do
|
||||
VCR.use_cassette("open_ai/chat/tool_calls", record: :all) do
|
||||
class TestFn
|
||||
include Assistant::Functions::Toolable
|
||||
|
||||
class << self
|
||||
def name
|
||||
"get_net_worth"
|
||||
end
|
||||
|
||||
def description
|
||||
"Gets user net worth data"
|
||||
end
|
||||
end
|
||||
|
||||
def call(params = {})
|
||||
"$124,200"
|
||||
end
|
||||
end
|
||||
|
||||
initial_message = Message.new(role: "user", content: "What is my net worth?")
|
||||
|
||||
response = @openai.chat_response(
|
||||
model: "gpt-4o",
|
||||
messages: [
|
||||
Message.new(
|
||||
role: "user",
|
||||
content: "This is a chat test. If it's working, respond with a single word: Yes"
|
||||
instructions: Assistant.instructions,
|
||||
functions: [ TestFn ],
|
||||
messages: [ initial_message ]
|
||||
)
|
||||
|
||||
assert response.success?
|
||||
assert response.data.tool_calls.size == 1
|
||||
|
||||
tool_call = response.data.tool_calls.first
|
||||
tool_call_result = TestFn.new.call(JSON.parse(tool_call.function_arguments))
|
||||
|
||||
message_with_tool_calls = Message.new(
|
||||
role: "assistant",
|
||||
status: "pending",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
ToolCall::Function.new(
|
||||
provider_id: tool_call.provider_id,
|
||||
provider_fn_call_id: tool_call.provider_fn_call_id,
|
||||
function_name: tool_call.function_name,
|
||||
function_arguments: tool_call.function_arguments,
|
||||
function_result: tool_call_result
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
assert response.success?
|
||||
assert response.data.messages.size > 0
|
||||
assert_equal "gpt-4o-2024-08-06", response.data.messages.first.ai_model
|
||||
assert_equal "Yes", response.data.messages.first.content
|
||||
end
|
||||
end
|
||||
|
||||
test "provides response with tool calls" do
|
||||
VCR.use_cassette("#{vcr_key_prefix}/chat/tool_calls") do
|
||||
# A prompt that should use multiple tools
|
||||
prompt = <<~PROMPT
|
||||
Can you show me a breakdown of the following?
|
||||
|
||||
- My net worth over the last 30 days
|
||||
- My income over the last 30 days
|
||||
- My spending over the last 30 days
|
||||
PROMPT
|
||||
|
||||
response = @subject.chat_response(
|
||||
second_response = @openai.chat_response(
|
||||
model: "gpt-4o",
|
||||
instructions: Assistant.instructions,
|
||||
functions: Assistant.available_functions,
|
||||
messages: [
|
||||
Message.new(role: "user", content: prompt)
|
||||
]
|
||||
messages: [ initial_message, message_with_tool_calls ]
|
||||
)
|
||||
|
||||
assert response.success?
|
||||
assert second_response.success?
|
||||
assert second_response.data.message.complete?
|
||||
assert second_response.data.message.content.include?(tool_call_result) # Somewhere in the response should be the tool call result value
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def vcr_key_prefix
|
||||
@subject.class.name.demodulize.underscore
|
||||
end
|
||||
end
|
||||
|
||||
72
test/vcr_cassettes/open_ai/chat/error.yml
Normal file
72
test/vcr_cassettes/open_ai/chat/error.yml
Normal file
@@ -0,0 +1,72 @@
|
||||
---
|
||||
http_interactions:
|
||||
- request:
|
||||
method: post
|
||||
uri: https://api.openai.com/v1/responses
|
||||
body:
|
||||
encoding: UTF-8
|
||||
string: '{"model":"invalid-model-key","input":[{"role":"user","content":"Error
|
||||
test"}],"tools":[],"instructions":null}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
Authorization:
|
||||
- Bearer <OPENAI_ACCESS_TOKEN>
|
||||
Accept-Encoding:
|
||||
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
|
||||
Accept:
|
||||
- "*/*"
|
||||
User-Agent:
|
||||
- Ruby
|
||||
response:
|
||||
status:
|
||||
code: 400
|
||||
message: Bad Request
|
||||
headers:
|
||||
Date:
|
||||
- Fri, 21 Mar 2025 18:02:59 GMT
|
||||
Content-Type:
|
||||
- application/json
|
||||
Content-Length:
|
||||
- '183'
|
||||
Connection:
|
||||
- keep-alive
|
||||
Openai-Version:
|
||||
- '2020-10-01'
|
||||
Openai-Organization:
|
||||
- "<OPENAI_ORGANIZATION_ID>"
|
||||
X-Request-Id:
|
||||
- req_8b96d2c4a5fd9f6867321a59f2c83871
|
||||
Openai-Processing-Ms:
|
||||
- '114'
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000; includeSubDomains; preload
|
||||
Cf-Cache-Status:
|
||||
- DYNAMIC
|
||||
Set-Cookie:
|
||||
- __cf_bm=7QPY9MNmXzC1Cn40g1tf2.vUHSJgLK2H6Qyr6yPu_ag-1742580179-1.0.1.1-i5yiiXqowBLqwvQ6.h3UPAvErH3joLsvg8xOOgP.d6m2KzNfOA8IDSQ95h2qJsQRDuhwtMXlicW2Ua29l7xS4hWFTt4dOV09fotpUz4YKYw;
|
||||
path=/; expires=Fri, 21-Mar-25 18:32:59 GMT; domain=.api.openai.com; HttpOnly;
|
||||
Secure; SameSite=None
|
||||
- _cfuvid=OiJrpU7qflYudc_3Ur.z0HcymtnD5.UeNdtTm8Ygi9U-1742580179526-0.0.1.1-604800000;
|
||||
path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
Server:
|
||||
- cloudflare
|
||||
Cf-Ray:
|
||||
- 923f5d08bf81cf75-CMH
|
||||
Alt-Svc:
|
||||
- h3=":443"; ma=86400
|
||||
body:
|
||||
encoding: UTF-8
|
||||
string: |-
|
||||
{
|
||||
"error": {
|
||||
"message": "The requested model 'invalid-model-key' does not exist.",
|
||||
"type": "invalid_request_error",
|
||||
"param": "model",
|
||||
"code": "model_not_found"
|
||||
}
|
||||
}
|
||||
recorded_at: Fri, 21 Mar 2025 18:02:59 GMT
|
||||
recorded_with: VCR 6.3.1
|
||||
@@ -5,25 +5,9 @@ http_interactions:
|
||||
uri: https://api.openai.com/v1/responses
|
||||
body:
|
||||
encoding: UTF-8
|
||||
string: '{"model":"gpt-4o","input":[{"role":"user","content":"Can you show me
|
||||
a breakdown of the following?\n\n- My net worth over the last 30 days\n- My
|
||||
income over the last 30 days\n- My spending over the last 30 days\n"}],"tools":[{"type":"function","name":"get_balance_sheet","description":"Get
|
||||
current balance sheet information including net worth, assets, and liabilities","parameters":{"type":"object","properties":{},"required":[],"additionalProperties":false},"strict":true},{"type":"function","name":"get_income_statement","description":"Get
|
||||
income statement data for a specific time period","parameters":{"type":"object","properties":{"period":{"type":"string","enum":["current_month","previous_month","year_to_date","previous_year"],"description":"The
|
||||
time period for the income statement data"}},"required":["period"],"additionalProperties":false},"strict":true},{"type":"function","name":"get_expense_categories","description":"Get
|
||||
top expense categories for a specific time period","parameters":{"type":"object","properties":{"period":{"type":"string","enum":["current_month","previous_month","year_to_date","previous_year"],"description":"The
|
||||
time period for the expense categories data"},"limit":{"type":"integer","description":"Number
|
||||
of top categories to return"}},"required":["period","limit"],"additionalProperties":false},"strict":true},{"type":"function","name":"get_account_balances","description":"Get
|
||||
balances for all accounts or by account type","parameters":{"type":"object","properties":{"account_type":{"type":"string","enum":["asset","liability","all"],"description":"Type
|
||||
of accounts to get balances for"}},"required":["account_type"],"additionalProperties":false},"strict":true},{"type":"function","name":"get_transactions","description":"Get
|
||||
transactions filtered by date range and/or category","parameters":{"type":"object","properties":{"start_date":{"type":"string","description":"Start
|
||||
date for transactions in YYYY-MM-DD format"},"end_date":{"type":"string","description":"End
|
||||
date for transactions in YYYY-MM-DD format"},"category_name":{"type":"string","description":"Filter
|
||||
transactions by category name"},"limit":{"type":"integer","description":"Maximum
|
||||
number of transactions to return (defaults to 10)"}},"required":["start_date","end_date","category_name","limit"],"additionalProperties":false},"strict":true},{"type":"function","name":"compare_periods","description":"Compare
|
||||
financial data between two periods","parameters":{"type":"object","properties":{"period1":{"type":"string","enum":["current_month","previous_month","year_to_date","previous_year"],"description":"First
|
||||
period for comparison"},"period2":{"type":"string","enum":["current_month","previous_month","year_to_date","previous_year"],"description":"Second
|
||||
period for comparison"}},"required":["period1","period2"],"additionalProperties":false},"strict":true}],"instructions":"You
|
||||
string: '{"model":"gpt-4o","input":[{"role":"user","content":"What is my net
|
||||
worth?"}],"tools":[{"type":"function","name":"get_net_worth","description":"Gets
|
||||
user net worth data","parameters":{"type":"object","properties":{},"required":[],"additionalProperties":false},"strict":true}],"instructions":"You
|
||||
are a helpful financial assistant for Maybe, a personal finance app.\nYou
|
||||
help users understand their financial data by answering questions about their
|
||||
accounts, transactions, income, expenses, and net worth.\n\nWhen users ask
|
||||
@@ -51,7 +35,7 @@ http_interactions:
|
||||
message: OK
|
||||
headers:
|
||||
Date:
|
||||
- Fri, 21 Mar 2025 14:18:42 GMT
|
||||
- Fri, 21 Mar 2025 20:56:14 GMT
|
||||
Content-Type:
|
||||
- application/json
|
||||
Transfer-Encoding:
|
||||
@@ -63,34 +47,34 @@ http_interactions:
|
||||
Openai-Organization:
|
||||
- "<OPENAI_ORGANIZATION_ID>"
|
||||
X-Request-Id:
|
||||
- req_434f08736b7c91b1ea07bf81cef21507
|
||||
- req_d0dcb7a2bb6b188cc992f81b1171ec71
|
||||
Openai-Processing-Ms:
|
||||
- '11758'
|
||||
- '807'
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000; includeSubDomains; preload
|
||||
Cf-Cache-Status:
|
||||
- DYNAMIC
|
||||
Set-Cookie:
|
||||
- __cf_bm=TDrrkraYJtMJkBDpwTFRHszMh5.44PObkaR_DPTn7bs-1742566722-1.0.1.1-5yNYBgBtwECMJLuLhfogtiDW1.XdsVBSnit.VpOjHitkB4OOfQLLaHO_dLR4d4HDG8zkNL1RNXI07vOirY10o1FxcZ1oR8YR0UMUrNJ2_nw;
|
||||
path=/; expires=Fri, 21-Mar-25 14:48:42 GMT; domain=.api.openai.com; HttpOnly;
|
||||
- __cf_bm=mm1LOM_CqqDbpNxg5U9POkF8mZmbwy93TakM0UNW79Y-1742590574-1.0.1.1-xEzx9bxl_Ql_u0SX6Artx49KLfEaj2odnlOpyzz8igb8wqVDvALU53jeepQtphRu53x4gCnq6Vafxmchv7oh3nb36_iH_i5kU105C10gfyk;
|
||||
path=/; expires=Fri, 21-Mar-25 21:26:14 GMT; domain=.api.openai.com; HttpOnly;
|
||||
Secure; SameSite=None
|
||||
- _cfuvid=OKVFDrnX270p7.zncd1f.vstTBf11_gfDGJVz6qq_lQ-1742566722134-0.0.1.1-604800000;
|
||||
- _cfuvid=OmEvdCnIjJ7f7pONp9es_4f0YJTR7ZzOa5JffY6t7.8-1742590574225-0.0.1.1-604800000;
|
||||
path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
Server:
|
||||
- cloudflare
|
||||
Cf-Ray:
|
||||
- 923e14334bbc633e-ORD
|
||||
- 92405acb3c99cf56-CMH
|
||||
Alt-Svc:
|
||||
- h3=":443"; ma=86400
|
||||
body:
|
||||
encoding: ASCII-8BIT
|
||||
string: |-
|
||||
{
|
||||
"id": "resp_67dd753662dc8192b5b25f9666f06d940cbbaa24c6c80cad",
|
||||
"id": "resp_67ddd26d66e081928243bb73ccf4f17d051b843636768b97",
|
||||
"object": "response",
|
||||
"created_at": 1742566710,
|
||||
"created_at": 1742590573,
|
||||
"status": "completed",
|
||||
"error": null,
|
||||
"incomplete_details": null,
|
||||
@@ -100,27 +84,11 @@ http_interactions:
|
||||
"output": [
|
||||
{
|
||||
"type": "function_call",
|
||||
"id": "fc_67dd75416cf08192b67279b3c06bb84b0cbbaa24c6c80cad",
|
||||
"call_id": "call_Be2crDZFo1QBcyPpo9SivVVk",
|
||||
"name": "get_balance_sheet",
|
||||
"id": "fc_67ddd26de5f881929ee7297fb8cee1db051b843636768b97",
|
||||
"call_id": "call_58gKqckPCeWSPwYdbP3EBpAY",
|
||||
"name": "get_net_worth",
|
||||
"arguments": "{}",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"id": "fc_67dd754197b881929875c127b46daea20cbbaa24c6c80cad",
|
||||
"call_id": "call_ncxXWtAIV0lGpgIeiHUcquAS",
|
||||
"name": "get_income_statement",
|
||||
"arguments": "{\"period\":\"current_month\"}",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"id": "fc_67dd7541b3ec8192bf57bbcf406aeaad0cbbaa24c6c80cad",
|
||||
"call_id": "call_tgWrZssOt6UE2BOoQMkul3lg",
|
||||
"name": "get_expense_categories",
|
||||
"arguments": "{\"period\":\"current_month\",\"limit\":5}",
|
||||
"status": "completed"
|
||||
}
|
||||
],
|
||||
"parallel_tool_calls": true,
|
||||
@@ -140,8 +108,8 @@ http_interactions:
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"description": "Get current balance sheet information including net worth, assets, and liabilities",
|
||||
"name": "get_balance_sheet",
|
||||
"description": "Gets user net worth data",
|
||||
"name": "get_net_worth",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
@@ -149,172 +117,150 @@ http_interactions:
|
||||
"additionalProperties": false
|
||||
},
|
||||
"strict": true
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"description": "Get income statement data for a specific time period",
|
||||
"name": "get_income_statement",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"period": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"current_month",
|
||||
"previous_month",
|
||||
"year_to_date",
|
||||
"previous_year"
|
||||
],
|
||||
"description": "The time period for the income statement data"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"period"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"strict": true
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"description": "Get top expense categories for a specific time period",
|
||||
"name": "get_expense_categories",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"period": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"current_month",
|
||||
"previous_month",
|
||||
"year_to_date",
|
||||
"previous_year"
|
||||
],
|
||||
"description": "The time period for the expense categories data"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Number of top categories to return"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"period",
|
||||
"limit"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"strict": true
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"description": "Get balances for all accounts or by account type",
|
||||
"name": "get_account_balances",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"account_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"asset",
|
||||
"liability",
|
||||
"all"
|
||||
],
|
||||
"description": "Type of accounts to get balances for"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"account_type"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"strict": true
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"description": "Get transactions filtered by date range and/or category",
|
||||
"name": "get_transactions",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start_date": {
|
||||
"type": "string",
|
||||
"description": "Start date for transactions in YYYY-MM-DD format"
|
||||
},
|
||||
"end_date": {
|
||||
"type": "string",
|
||||
"description": "End date for transactions in YYYY-MM-DD format"
|
||||
},
|
||||
"category_name": {
|
||||
"type": "string",
|
||||
"description": "Filter transactions by category name"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of transactions to return (defaults to 10)"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"start_date",
|
||||
"end_date",
|
||||
"category_name",
|
||||
"limit"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"strict": true
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"description": "Compare financial data between two periods",
|
||||
"name": "compare_periods",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"period1": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"current_month",
|
||||
"previous_month",
|
||||
"year_to_date",
|
||||
"previous_year"
|
||||
],
|
||||
"description": "First period for comparison"
|
||||
},
|
||||
"period2": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"current_month",
|
||||
"previous_month",
|
||||
"year_to_date",
|
||||
"previous_year"
|
||||
],
|
||||
"description": "Second period for comparison"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"period1",
|
||||
"period2"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"strict": true
|
||||
}
|
||||
],
|
||||
"top_p": 1.0,
|
||||
"truncation": "disabled",
|
||||
"usage": {
|
||||
"input_tokens": 0,
|
||||
"input_tokens": 400,
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": 0
|
||||
},
|
||||
"output_tokens": 0,
|
||||
"output_tokens": 13,
|
||||
"output_tokens_details": {
|
||||
"reasoning_tokens": 0
|
||||
},
|
||||
"total_tokens": 0
|
||||
"total_tokens": 413
|
||||
},
|
||||
"user": null,
|
||||
"metadata": {}
|
||||
}
|
||||
recorded_at: Fri, 21 Mar 2025 14:18:42 GMT
|
||||
recorded_at: Fri, 21 Mar 2025 20:56:14 GMT
|
||||
- request:
|
||||
method: post
|
||||
uri: https://api.openai.com/v1/responses
|
||||
body:
|
||||
encoding: UTF-8
|
||||
string: '{"model":"gpt-4o","input":[{"role":"user","content":"What is my net
|
||||
worth?"},{"type":"function_call","id":"fc_67ddd26de5f881929ee7297fb8cee1db051b843636768b97","call_id":"call_58gKqckPCeWSPwYdbP3EBpAY","name":"get_net_worth","arguments":"{}"},{"type":"function_call_output","call_id":"call_58gKqckPCeWSPwYdbP3EBpAY","output":"$124,200"}],"tools":[],"instructions":"You
|
||||
are a helpful financial assistant for Maybe, a personal finance app.\nYou
|
||||
help users understand their financial data by answering questions about their
|
||||
accounts, transactions, income, expenses, and net worth.\n\nWhen users ask
|
||||
financial questions:\n1. Use the provided functions to retrieve the relevant
|
||||
data\n2. Provide ONLY the most important numbers and insights\n3. Eliminate
|
||||
all unnecessary words and context\n4. Use simple markdown for formatting\n5.
|
||||
Ask follow-up questions to keep the conversation going. Help educate the user
|
||||
about their own data and entice them to ask more questions.\n\nDO NOT:\n-
|
||||
Add introductions or conclusions\n- Apologize or explain limitations\n\nPresent
|
||||
monetary values using the format provided by the functions.\n"}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
Authorization:
|
||||
- Bearer <OPENAI_ACCESS_TOKEN>
|
||||
Accept-Encoding:
|
||||
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
|
||||
Accept:
|
||||
- "*/*"
|
||||
User-Agent:
|
||||
- Ruby
|
||||
response:
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
headers:
|
||||
Date:
|
||||
- Fri, 21 Mar 2025 20:56:15 GMT
|
||||
Content-Type:
|
||||
- application/json
|
||||
Transfer-Encoding:
|
||||
- chunked
|
||||
Connection:
|
||||
- keep-alive
|
||||
Openai-Version:
|
||||
- '2020-10-01'
|
||||
Openai-Organization:
|
||||
- "<OPENAI_ORGANIZATION_ID>"
|
||||
X-Request-Id:
|
||||
- req_f3be656bce149275f2368834bfd83be9
|
||||
Openai-Processing-Ms:
|
||||
- '632'
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000; includeSubDomains; preload
|
||||
Cf-Cache-Status:
|
||||
- DYNAMIC
|
||||
Set-Cookie:
|
||||
- __cf_bm=.lL8VlbRrdmeXay2X_ltzgMNGI5tbIvf.LUlQku9BRE-1742590575-1.0.1.1-2c6E2_eVRbrOSWoqnj3ROv59Ay1i9fc.dNT5rDGXOjyjISNTsJSeExl3lnng.4MgQUx0nzt5xAeH3kHonwDS4nNQtHJGtIx2Vyc1qiV.zHk;
|
||||
path=/; expires=Fri, 21-Mar-25 21:26:15 GMT; domain=.api.openai.com; HttpOnly;
|
||||
Secure; SameSite=None
|
||||
- _cfuvid=Puw5FtUzW08qv5IvAztCHaMlK0JB3huEVX8tx_G7wJ4-1742590575078-0.0.1.1-604800000;
|
||||
path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
Server:
|
||||
- cloudflare
|
||||
Cf-Ray:
|
||||
- 92405ad1b9fecf5e-CMH
|
||||
Alt-Svc:
|
||||
- h3=":443"; ma=86400
|
||||
body:
|
||||
encoding: ASCII-8BIT
|
||||
string: |-
|
||||
{
|
||||
"id": "resp_67ddd26e6c048192bb1e41606aa6b68e051b843636768b97",
|
||||
"object": "response",
|
||||
"created_at": 1742590574,
|
||||
"status": "completed",
|
||||
"error": null,
|
||||
"incomplete_details": null,
|
||||
"instructions": "You are a helpful financial assistant for Maybe, a personal finance app.\nYou help users understand their financial data by answering questions about their accounts, transactions, income, expenses, and net worth.\n\nWhen users ask financial questions:\n1. Use the provided functions to retrieve the relevant data\n2. Provide ONLY the most important numbers and insights\n3. Eliminate all unnecessary words and context\n4. Use simple markdown for formatting\n5. Ask follow-up questions to keep the conversation going. Help educate the user about their own data and entice them to ask more questions.\n\nDO NOT:\n- Add introductions or conclusions\n- Apologize or explain limitations\n\nPresent monetary values using the format provided by the functions.\n",
|
||||
"max_output_tokens": null,
|
||||
"model": "gpt-4o-2024-08-06",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_67ddd26eb1748192b9f506f5cdd73300051b843636768b97",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "Your net worth is **$124,200**.\n\nWould you like to see a breakdown of your assets and liabilities?",
|
||||
"annotations": []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"parallel_tool_calls": true,
|
||||
"previous_response_id": null,
|
||||
"reasoning": {
|
||||
"effort": null,
|
||||
"generate_summary": null
|
||||
},
|
||||
"store": true,
|
||||
"temperature": 1.0,
|
||||
"text": {
|
||||
"format": {
|
||||
"type": "text"
|
||||
}
|
||||
},
|
||||
"tool_choice": "auto",
|
||||
"tools": [],
|
||||
"top_p": 1.0,
|
||||
"truncation": "disabled",
|
||||
"usage": {
|
||||
"input_tokens": 203,
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": 0
|
||||
},
|
||||
"output_tokens": 25,
|
||||
"output_tokens_details": {
|
||||
"reasoning_tokens": 0
|
||||
},
|
||||
"total_tokens": 228
|
||||
},
|
||||
"user": null,
|
||||
"metadata": {}
|
||||
}
|
||||
recorded_at: Fri, 21 Mar 2025 20:56:15 GMT
|
||||
recorded_with: VCR 6.3.1
|
||||
|
||||
Reference in New Issue
Block a user