feat: migrate editor to ruby-llm (#12961)

Co-authored-by: aakashb95 <aakash@chatwoot.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
This commit is contained in:
Aakash Bakhle
2025-12-04 12:51:35 +05:30
committed by GitHub
parent 5c3b85334b
commit 87fe1e9ad7
9 changed files with 333 additions and 235 deletions

View File

@@ -194,6 +194,7 @@ gem 'ruby-openai'
gem 'ai-agents', '>= 0.4.3' gem 'ai-agents', '>= 0.4.3'
# TODO: Move this gem as a dependency of ai-agents # TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.9.1'
gem 'ruby_llm-schema' gem 'ruby_llm-schema'
# OpenTelemetry for LLM observability # OpenTelemetry for LLM observability

View File

@@ -819,7 +819,7 @@ GEM
ruby2ruby (2.5.0) ruby2ruby (2.5.0)
ruby_parser (~> 3.1) ruby_parser (~> 3.1)
sexp_processor (~> 4.6) sexp_processor (~> 4.6)
ruby_llm (1.5.1) ruby_llm (1.9.1)
base64 base64
event_stream_parser (~> 1) event_stream_parser (~> 1)
faraday (>= 1.10.0) faraday (>= 1.10.0)
@@ -827,8 +827,9 @@ GEM
faraday-net_http (>= 1) faraday-net_http (>= 1)
faraday-retry (>= 1) faraday-retry (>= 1)
marcel (~> 1.0) marcel (~> 1.0)
ruby_llm-schema (~> 0.2.1)
zeitwerk (~> 2) zeitwerk (~> 2)
ruby_llm-schema (0.1.0) ruby_llm-schema (0.2.5)
ruby_parser (3.20.0) ruby_parser (3.20.0)
sexp_processor (~> 4.16) sexp_processor (~> 4.16)
sass (3.7.4) sass (3.7.4)
@@ -1119,6 +1120,7 @@ DEPENDENCIES
rubocop-rails rubocop-rails
rubocop-rspec rubocop-rspec
ruby-openai ruby-openai
ruby_llm (>= 1.9.1)
ruby_llm-schema ruby_llm-schema
scout_apm scout_apm
scss_lint scss_lint

View File

@@ -1,4 +1,4 @@
class Integrations::OpenaiBaseService class Integrations::LlmBaseService
include Integrations::LlmInstrumentation include Integrations::LlmInstrumentation
# gpt-4o-mini supports 128,000 tokens # gpt-4o-mini supports 128,000 tokens
@@ -6,8 +6,7 @@ class Integrations::OpenaiBaseService
# sticking with 120000 to be safe # sticking with 120000 to be safe
# 120000 * 4 = 480,000 characters (rounding off downwards to 400,000 to be safe) # 120000 * 4 = 480,000 characters (rounding off downwards to 400,000 to be safe)
TOKEN_LIMIT = 400_000 TOKEN_LIMIT = 400_000
GPT_MODEL = ENV.fetch('OPENAI_GPT_MODEL', 'gpt-4o-mini').freeze GPT_MODEL = Llm::Config::DEFAULT_MODEL
ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion fix_spelling_grammar shorten expand make_friendly make_formal simplify].freeze ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion fix_spelling_grammar shorten expand make_friendly make_formal simplify].freeze
CACHEABLE_EVENTS = %w[].freeze CACHEABLE_EVENTS = %w[].freeze
@@ -82,10 +81,10 @@ class Integrations::OpenaiBaseService
self.class::CACHEABLE_EVENTS.include?(event_name) self.class::CACHEABLE_EVENTS.include?(event_name)
end end
def api_url def api_base
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/' endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/'
endpoint = endpoint.chomp('/') endpoint = endpoint.chomp('/')
"#{endpoint}/v1/chat/completions" "#{endpoint}/v1"
end end
def make_api_call(body) def make_api_call(body)
@@ -93,10 +92,65 @@ class Integrations::OpenaiBaseService
instrumentation_params = build_instrumentation_params(parsed_body) instrumentation_params = build_instrumentation_params(parsed_body)
instrument_llm_call(instrumentation_params) do instrument_llm_call(instrumentation_params) do
execute_api_request(body, parsed_body['messages']) execute_ruby_llm_request(parsed_body)
end end
end end
def execute_ruby_llm_request(parsed_body)
messages = parsed_body['messages']
model = parsed_body['model']
Llm::Config.with_api_key(hook.settings['api_key'], api_base: api_base) do |context|
chat = context.chat(model: model)
setup_chat_with_messages(chat, messages)
end
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: hook.account).capture_exception
build_error_response_from_exception(e, messages)
end
def setup_chat_with_messages(chat, messages)
apply_system_instructions(chat, messages)
response = send_conversation_messages(chat, messages)
return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if response.nil?
build_ruby_llm_response(response, messages)
end
def apply_system_instructions(chat, messages)
system_msg = messages.find { |m| m['role'] == 'system' }
chat.with_instructions(system_msg['content']) if system_msg
end
def send_conversation_messages(chat, messages)
conversation_messages = messages.reject { |m| m['role'] == 'system' }
return nil if conversation_messages.empty?
return chat.ask(conversation_messages.first['content']) if conversation_messages.length == 1
add_conversation_history(chat, conversation_messages[0...-1])
chat.ask(conversation_messages.last['content'])
end
def add_conversation_history(chat, messages)
messages.each do |msg|
chat.add_message(role: msg['role'].to_sym, content: msg['content'])
end
end
def build_ruby_llm_response(response, messages)
{
message: response.content,
usage: {
'prompt_tokens' => response.input_tokens,
'completion_tokens' => response.output_tokens,
'total_tokens' => (response.input_tokens || 0) + (response.output_tokens || 0)
},
request_messages: messages
}
end
def build_instrumentation_params(parsed_body) def build_instrumentation_params(parsed_body)
{ {
span_name: "llm.#{event_name}", span_name: "llm.#{event_name}",
@@ -109,37 +163,7 @@ class Integrations::OpenaiBaseService
} }
end end
def execute_api_request(body, messages) def build_error_response_from_exception(error, messages)
Rails.logger.info("OpenAI API request: #{body}") { error: error.message, error_code: 500, request_messages: messages }
response = HTTParty.post(api_url, headers: api_headers, body: body)
Rails.logger.info("OpenAI API response: #{response.body}")
parse_api_response(response, messages)
end
def api_headers
{
'Content-Type' => 'application/json',
'Authorization' => "Bearer #{hook.settings['api_key']}"
}
end
def parse_api_response(response, messages)
return build_error_response(response, messages) unless response.success?
parsed_response = JSON.parse(response.body)
build_success_response(parsed_response, messages)
end
def build_error_response(response, messages)
{ error: response.parsed_response, error_code: response.code, request_messages: messages }
end
def build_success_response(parsed_response, messages)
choices = parsed_response['choices']
usage = parsed_response['usage']
message_content = choices.present? ? choices.first['message']['content'] : nil
{ message: message_content, usage: usage, request_messages: messages }
end end
end end

View File

@@ -2,9 +2,19 @@
require 'opentelemetry_config' require 'opentelemetry_config'
require_relative 'llm_instrumentation_constants' require_relative 'llm_instrumentation_constants'
require_relative 'llm_instrumentation_helpers'
module Integrations::LlmInstrumentation module Integrations::LlmInstrumentation
include Integrations::LlmInstrumentationConstants include Integrations::LlmInstrumentationConstants
include Integrations::LlmInstrumentationHelpers
PROVIDER_PREFIXES = {
'openai' => %w[gpt- o1 o3 o4 text-embedding- whisper- tts-],
'anthropic' => %w[claude-],
'google' => %w[gemini-],
'mistral' => %w[mistral- codestral-],
'deepseek' => %w[deepseek-]
}.freeze
def tracer def tracer
@tracer ||= OpentelemetryConfig.tracer @tracer ||= OpentelemetryConfig.tracer
@@ -13,33 +23,38 @@ module Integrations::LlmInstrumentation
def instrument_llm_call(params) def instrument_llm_call(params)
return yield unless ChatwootApp.otel_enabled? return yield unless ChatwootApp.otel_enabled?
result = nil
executed = false
tracer.in_span(params[:span_name]) do |span| tracer.in_span(params[:span_name]) do |span|
setup_span_attributes(span, params) setup_span_attributes(span, params)
result = yield result = yield
executed = true
record_completion(span, result) record_completion(span, result)
result result
end end
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e, account: params[:account]).capture_exception ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception
yield executed ? result : yield
end end
def instrument_agent_session(params) def instrument_agent_session(params)
return yield unless ChatwootApp.otel_enabled? return yield unless ChatwootApp.otel_enabled?
result = nil
executed = false
tracer.in_span(params[:span_name]) do |span| tracer.in_span(params[:span_name]) do |span|
set_metadata_attributes(span, params) set_metadata_attributes(span, params)
# By default, the input and output of a trace are set from the root observation # By default, the input and output of a trace are set from the root observation
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json) span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json)
result = yield result = yield
executed = true
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json) span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
result result
end end
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e, account: params[:account]).capture_exception ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception
yield executed ? result : yield
end end
def instrument_tool_call(tool_name, arguments) def instrument_tool_call(tool_name, arguments)
@@ -55,8 +70,27 @@ module Integrations::LlmInstrumentation
end end
end end
def determine_provider(model_name)
return 'openai' if model_name.blank?
model = model_name.to_s.downcase
PROVIDER_PREFIXES.each do |provider, prefixes|
return provider if prefixes.any? { |prefix| model.start_with?(prefix) }
end
'openai'
end
private private
def resolve_account(params)
return params[:account] if params[:account].is_a?(Account)
return Account.find_by(id: params[:account_id]) if params[:account_id].present?
nil
end
def setup_span_attributes(span, params) def setup_span_attributes(span, params)
set_request_attributes(span, params) set_request_attributes(span, params)
set_prompt_messages(span, params[:messages]) set_prompt_messages(span, params[:messages])
@@ -68,7 +102,8 @@ module Integrations::LlmInstrumentation
end end
def set_request_attributes(span, params) def set_request_attributes(span, params)
span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai') provider = determine_provider(params[:model])
span.set_attribute(ATTR_GEN_AI_PROVIDER, provider)
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model]) span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model])
span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, params[:temperature]) if params[:temperature] span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, params[:temperature]) if params[:temperature]
end end
@@ -95,37 +130,4 @@ module Integrations::LlmInstrumentation
span.set_attribute(format(ATTR_LANGFUSE_METADATA, key), value.to_s) span.set_attribute(format(ATTR_LANGFUSE_METADATA, key), value.to_s)
end end
end end
def set_completion_attributes(span, result)
set_completion_message(span, result)
set_usage_metrics(span, result)
set_error_attributes(span, result)
end
def set_completion_message(span, result)
message = result[:message] || result.dig('choices', 0, 'message', 'content')
return if message.blank?
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant')
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message)
end
def set_usage_metrics(span, result)
usage = result[:usage] || result['usage']
return if usage.blank?
span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, usage['prompt_tokens']) if usage['prompt_tokens']
span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, usage['completion_tokens']) if usage['completion_tokens']
span.set_attribute(ATTR_GEN_AI_USAGE_TOTAL_TOKENS, usage['total_tokens']) if usage['total_tokens']
end
def set_error_attributes(span, result)
error = result[:error] || result['error']
return if error.blank?
error_code = result[:error_code] || result['error_code']
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json)
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR_CODE, error_code) if error_code
span.status = OpenTelemetry::Trace::Status.error("API Error: #{error_code}")
end
end end

View File

@@ -0,0 +1,40 @@
# frozen_string_literal: true
module Integrations::LlmInstrumentationHelpers
include Integrations::LlmInstrumentationConstants
private
def set_completion_attributes(span, result)
set_completion_message(span, result)
set_usage_metrics(span, result)
set_error_attributes(span, result)
end
def set_completion_message(span, result)
message = result[:message] || result.dig('choices', 0, 'message', 'content')
return if message.blank?
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant')
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message)
end
def set_usage_metrics(span, result)
usage = result[:usage] || result['usage']
return if usage.blank?
span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, usage['prompt_tokens']) if usage['prompt_tokens']
span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, usage['completion_tokens']) if usage['completion_tokens']
span.set_attribute(ATTR_GEN_AI_USAGE_TOTAL_TOKENS, usage['total_tokens']) if usage['total_tokens']
end
def set_error_attributes(span, result)
error = result[:error] || result['error']
return if error.blank?
error_code = result[:error_code] || result['error_code']
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json)
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR_CODE, error_code) if error_code
span.status = OpenTelemetry::Trace::Status.error("API Error: #{error_code}")
end
end

View File

@@ -1,4 +1,4 @@
class Integrations::Openai::ProcessorService < Integrations::OpenaiBaseService class Integrations::Openai::ProcessorService < Integrations::LlmBaseService
AGENT_INSTRUCTION = 'You are a helpful support agent.'.freeze AGENT_INSTRUCTION = 'You are a helpful support agent.'.freeze
LANGUAGE_INSTRUCTION = 'Ensure that the reply should be in user language.'.freeze LANGUAGE_INSTRUCTION = 'Ensure that the reply should be in user language.'.freeze
def reply_suggestion_message def reply_suggestion_message

47
lib/llm/config.rb Normal file
View File

@@ -0,0 +1,47 @@
require 'ruby_llm'
module Llm::Config
DEFAULT_MODEL = 'gpt-4o-mini'.freeze
class << self
def initialized?
@initialized ||= false
end
def initialize!
return if @initialized
configure_ruby_llm
@initialized = true
end
def reset!
@initialized = false
end
def with_api_key(api_key, api_base: nil)
context = RubyLLM.context do |config|
config.openai_api_key = api_key
config.openai_api_base = api_base
end
yield context
end
private
def configure_ruby_llm
RubyLLM.configure do |config|
config.openai_api_key = system_api_key if system_api_key.present?
config.openai_api_base = openai_endpoint.chomp('/') if openai_endpoint.present?
end
end
def system_api_key
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
end
def openai_endpoint
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value
end
end
end

View File

@@ -5,21 +5,39 @@ RSpec.describe Integrations::Openai::ProcessorService do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:hook) { create(:integrations_hook, :openai, account: account) } let(:hook) { create(:integrations_hook, :openai, account: account) }
let(:expected_headers) { { 'Authorization' => "Bearer #{hook.settings['api_key']}" } }
let(:openai_response) do # Mock RubyLLM objects
{ let(:mock_chat) { instance_double(RubyLLM::Chat) }
'choices' => [ let(:mock_context) { instance_double(RubyLLM::Context) }
{ let(:mock_config) { OpenStruct.new }
'message' => { let(:mock_response) do
'content' => 'This is a reply from openai.' instance_double(
} RubyLLM::Message,
} content: 'This is a reply from openai.',
] input_tokens: nil,
}.to_json output_tokens: nil
)
end
let(:mock_empty_response) do
instance_double(
RubyLLM::Message,
content: '',
input_tokens: nil,
output_tokens: nil
)
end end
let(:conversation) { create(:conversation, account: account) } let(:conversation) { create(:conversation, account: account) }
before do
allow(RubyLLM).to receive(:context).and_yield(mock_config).and_return(mock_context)
allow(mock_context).to receive(:chat).and_return(mock_chat)
allow(mock_chat).to receive(:with_instructions).and_return(mock_chat)
allow(mock_chat).to receive(:add_message).and_return(mock_chat)
allow(mock_chat).to receive(:ask).and_return(mock_response)
end
describe '#perform' do describe '#perform' do
context 'when event name is label_suggestion with labels with < 3 messages' do context 'when event name is label_suggestion with labels with < 3 messages' do
let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } } let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } }
@@ -49,21 +67,15 @@ RSpec.describe Integrations::Openai::ProcessorService do
end end
it 'returns the label suggestions' do it 'returns the label suggestions' do
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
.with(body: anything, headers: expected_headers)
.to_return(status: 200, body: openai_response, headers: {})
result = subject.perform result = subject.perform
expect(result).to eq({ :message => 'This is a reply from openai.' }) expect(result).to eq({ message: 'This is a reply from openai.' })
end end
it 'returns empty string if openai response is blank' do it 'returns empty string if openai response is blank' do
stub_request(:post, 'https://api.openai.com/v1/chat/completions') allow(mock_chat).to receive(:ask).and_return(mock_empty_response)
.with(body: anything, headers: expected_headers)
.to_return(status: 200, body: '{}', headers: {})
result = subject.perform result = subject.perform
expect(result).to eq({ :message => '' }) expect(result[:message]).to eq('')
end end
end end

View File

@@ -5,135 +5,104 @@ RSpec.describe Integrations::Openai::ProcessorService do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:hook) { create(:integrations_hook, :openai, account: account) } let(:hook) { create(:integrations_hook, :openai, account: account) }
let(:expected_headers) { { 'Authorization' => "Bearer #{hook.settings['api_key']}" } }
let(:openai_response) do # Mock RubyLLM objects
{ let(:mock_chat) { instance_double(RubyLLM::Chat) }
'choices' => [{ 'message' => { 'content' => 'This is a reply from openai.' } }] let(:mock_context) { instance_double(RubyLLM::Context) }
}.to_json let(:mock_config) { OpenStruct.new }
let(:mock_response) do
instance_double(
RubyLLM::Message,
content: 'This is a reply from openai.',
input_tokens: nil,
output_tokens: nil
)
end end
let(:openai_response_with_usage) do let(:mock_response_with_usage) do
{ instance_double(
'choices' => [{ 'message' => { 'content' => 'This is a reply from openai.' } }], RubyLLM::Message,
'usage' => { content: 'This is a reply from openai.',
'prompt_tokens' => 50, input_tokens: 50,
'completion_tokens' => 20, output_tokens: 20
'total_tokens' => 70 )
} end
}.to_json
before do
allow(RubyLLM).to receive(:context).and_yield(mock_config).and_return(mock_context)
allow(mock_context).to receive(:chat).and_return(mock_chat)
allow(mock_chat).to receive(:with_instructions).and_return(mock_chat)
allow(mock_chat).to receive(:add_message).and_return(mock_chat)
allow(mock_chat).to receive(:ask).and_return(mock_response)
end end
describe '#perform' do describe '#perform' do
shared_examples 'text transformation operation' do |event_name, system_prompt|
let(:event) { { 'name' => event_name, 'data' => { 'content' => 'This is a test' } } }
let(:expected_request_body) do
{
'model' => 'gpt-4o-mini',
'messages' => [
{ 'role' => 'system', 'content' => system_prompt },
{ 'role' => 'user', 'content' => 'This is a test' }
]
}.to_json
end
it "returns the #{event_name.tr('_', ' ')} text" do
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
.with(body: expected_request_body, headers: expected_headers)
.to_return(status: 200, body: openai_response)
result = service.perform
expect(result[:message]).to eq('This is a reply from openai.')
end
end
shared_examples 'successful openai response' do
it 'returns the expected message' do
result = service.perform
expect(result[:message]).to eq('This is a reply from openai.')
end
end
describe 'text transformation operations' do describe 'text transformation operations' do
base_prompt = 'You are a helpful support agent. ' shared_examples 'text transformation operation' do |event_name|
language_suffix = 'Ensure that the reply should be in user language.' let(:event) { { 'name' => event_name, 'data' => { 'content' => 'This is a test' } } }
it_behaves_like 'text transformation operation', 'rephrase', it 'returns the transformed text' do
"#{base_prompt}Please rephrase the following response. #{language_suffix}" result = service.perform
it_behaves_like 'text transformation operation', 'fix_spelling_grammar', expect(result[:message]).to eq('This is a reply from openai.')
"#{base_prompt}Please fix the spelling and grammar of the following response. #{language_suffix}" end
it_behaves_like 'text transformation operation', 'shorten',
"#{base_prompt}Please shorten the following response. #{language_suffix}" it 'sends the user content to the LLM' do
it_behaves_like 'text transformation operation', 'expand', service.perform
"#{base_prompt}Please expand the following response. #{language_suffix}" expect(mock_chat).to have_received(:ask).with('This is a test')
it_behaves_like 'text transformation operation', 'make_friendly', end
"#{base_prompt}Please make the following response more friendly. #{language_suffix}"
it_behaves_like 'text transformation operation', 'make_formal', it 'sets system instructions' do
"#{base_prompt}Please make the following response more formal. #{language_suffix}" service.perform
it_behaves_like 'text transformation operation', 'simplify', expect(mock_chat).to have_received(:with_instructions).with(a_string_including('You are a helpful support agent'))
"#{base_prompt}Please simplify the following response. #{language_suffix}" end
end
it_behaves_like 'text transformation operation', 'rephrase'
it_behaves_like 'text transformation operation', 'fix_spelling_grammar'
it_behaves_like 'text transformation operation', 'shorten'
it_behaves_like 'text transformation operation', 'expand'
it_behaves_like 'text transformation operation', 'make_friendly'
it_behaves_like 'text transformation operation', 'make_formal'
it_behaves_like 'text transformation operation', 'simplify'
end end
describe 'conversation-based operations' do describe 'conversation-based operations' do
let!(:conversation) { create(:conversation, account: account) } let!(:conversation) { create(:conversation, account: account) }
let!(:customer_message) do
before do
create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent') create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent')
end
let!(:agent_message) do
create(:message, account: account, conversation: conversation, message_type: :outgoing, content: 'hello customer') create(:message, account: account, conversation: conversation, message_type: :outgoing, content: 'hello customer')
end end
context 'with reply_suggestion event' do context 'with reply_suggestion event' do
let(:event) { { 'name' => 'reply_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } } let(:event) { { 'name' => 'reply_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } }
let(:expected_request_body) do
{ it 'returns the suggested reply' do
'model' => 'gpt-4o-mini', result = service.perform
'messages' => [ expect(result[:message]).to eq('This is a reply from openai.')
{ role: 'system', content: Rails.root.join('lib/integrations/openai/openai_prompts/reply.txt').read },
{ role: 'user', content: customer_message.content },
{ role: 'assistant', content: agent_message.content }
]
}.to_json
end end
before do it 'adds conversation history before asking' do
stub_request(:post, 'https://api.openai.com/v1/chat/completions') service.perform
.with(body: expected_request_body, headers: expected_headers) # Should add the first message as history, then ask with the last message
.to_return(status: 200, body: openai_response) expect(mock_chat).to have_received(:add_message).with(role: :user, content: 'hello agent')
expect(mock_chat).to have_received(:ask).with('hello customer')
end end
it_behaves_like 'successful openai response'
end end
context 'with summarize event' do context 'with summarize event' do
let(:event) { { 'name' => 'summarize', 'data' => { 'conversation_display_id' => conversation.display_id } } } let(:event) { { 'name' => 'summarize', 'data' => { 'conversation_display_id' => conversation.display_id } } }
let(:conversation_messages) do
"Customer #{customer_message.sender.name} : #{customer_message.content}\n" \ it 'returns the summary' do
"Agent #{agent_message.sender.name} : #{agent_message.content}\n" result = service.perform
end expect(result[:message]).to eq('This is a reply from openai.')
let(:summary_prompt) do
if ChatwootApp.enterprise?
Rails.root.join('enterprise/lib/enterprise/integrations/openai_prompts/summary.txt').read
else
'Please summarize the key points from the following conversation between support agents and customer as bullet points ' \
"for the next support agent looking into the conversation. Reply in the user's language."
end
end
let(:expected_request_body) do
{
'model' => 'gpt-4o-mini',
'messages' => [
{ 'role' => 'system', 'content' => summary_prompt },
{ 'role' => 'user', 'content' => conversation_messages }
]
}.to_json
end end
before do it 'sends formatted conversation as a single message' do
stub_request(:post, 'https://api.openai.com/v1/chat/completions') service.perform
.with(body: expected_request_body, headers: expected_headers) # Summarize sends conversation as a formatted string in one user message
.to_return(status: 200, body: openai_response) expect(mock_chat).to have_received(:ask).with(a_string_matching(/Customer.*hello agent.*Agent.*hello customer/m))
end end
it_behaves_like 'successful openai response'
end end
context 'with label_suggestion event and no labels' do context 'with label_suggestion event and no labels' do
@@ -160,37 +129,37 @@ RSpec.describe Integrations::Openai::ProcessorService do
context 'when response includes usage data' do context 'when response includes usage data' do
before do before do
stub_request(:post, 'https://api.openai.com/v1/chat/completions') allow(mock_chat).to receive(:ask).and_return(mock_response_with_usage)
.with(body: anything, headers: expected_headers)
.to_return(status: 200, body: openai_response_with_usage)
end end
it 'returns message, usage, and request_messages' do it 'returns message with usage data' do
result = service.perform result = service.perform
expect(result[:message]).to eq('This is a reply from openai.') expect(result[:message]).to eq('This is a reply from openai.')
expect(result[:usage]).to eq({ expect(result[:usage]['prompt_tokens']).to eq(50)
'prompt_tokens' => 50, expect(result[:usage]['completion_tokens']).to eq(20)
'completion_tokens' => 20, expect(result[:usage]['total_tokens']).to eq(70)
'total_tokens' => 70 end
})
it 'includes request_messages in response' do
result = service.perform
expect(result[:request_messages]).to be_an(Array) expect(result[:request_messages]).to be_an(Array)
expect(result[:request_messages].length).to eq(2) expect(result[:request_messages].length).to eq(2)
end end
end end
context 'when response does not include usage data' do context 'when response does not include usage data' do
before do it 'returns message with zero total tokens' do
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
.with(body: anything, headers: expected_headers)
.to_return(status: 200, body: openai_response)
end
it 'returns message and request_messages with nil usage' do
result = service.perform result = service.perform
expect(result[:message]).to eq('This is a reply from openai.') expect(result[:message]).to eq('This is a reply from openai.')
expect(result[:usage]).to be_nil expect(result[:usage]['total_tokens']).to eq(0)
end
it 'includes request_messages in response' do
result = service.perform
expect(result[:request_messages]).to be_an(Array) expect(result[:request_messages]).to be_an(Array)
end end
end end
@@ -199,23 +168,17 @@ RSpec.describe Integrations::Openai::ProcessorService do
describe 'endpoint configuration' do describe 'endpoint configuration' do
let(:event) { { 'name' => 'rephrase', 'data' => { 'content' => 'test message' } } } let(:event) { { 'name' => 'rephrase', 'data' => { 'content' => 'test message' } } }
shared_examples 'endpoint request' do |endpoint_url|
it "makes request to #{endpoint_url}" do
stub_request(:post, "#{endpoint_url}/v1/chat/completions")
.with(body: anything, headers: expected_headers)
.to_return(status: 200, body: openai_response)
result = service.perform
expect(result[:message]).to eq('This is a reply from openai.')
expect(result[:request_messages]).to be_an(Array)
expect(result[:usage]).to be_nil
end
end
context 'without CAPTAIN_OPEN_AI_ENDPOINT configured' do context 'without CAPTAIN_OPEN_AI_ENDPOINT configured' do
before { InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy } before { InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy }
it_behaves_like 'endpoint request', 'https://api.openai.com' it 'uses default OpenAI endpoint' do
expect(Llm::Config).to receive(:with_api_key).with(
hook.settings['api_key'],
api_base: 'https://api.openai.com/v1'
).and_call_original
service.perform
end
end end
context 'with CAPTAIN_OPEN_AI_ENDPOINT configured' do context 'with CAPTAIN_OPEN_AI_ENDPOINT configured' do
@@ -224,7 +187,14 @@ RSpec.describe Integrations::Openai::ProcessorService do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://custom.azure.com/') create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://custom.azure.com/')
end end
it_behaves_like 'endpoint request', 'https://custom.azure.com' it 'uses custom endpoint' do
expect(Llm::Config).to receive(:with_api_key).with(
hook.settings['api_key'],
api_base: 'https://custom.azure.com/v1'
).and_call_original
service.perform
end
end end
end end
end end