feat: Add article messages along with bot responses (#7993)

ref: https://linear.app/chatwoot/issue/CW-2464/bot-should-also-return-links-to-the-information
This commit is contained in:
Sojan Jose
2023-10-04 15:40:59 -07:00
committed by GitHub
parent 5c9ab21617
commit 6f19546c3c
5 changed files with 60 additions and 48 deletions

View File

@@ -3,8 +3,8 @@ class Enterprise::MessageTemplates::ResponseBotService
def perform def perform
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
response = get_response(conversation.messages.last.content) @response = get_response(conversation.messages.last.content)
process_response(response['response']) process_response
end end
rescue StandardError => e rescue StandardError => e
process_action('handoff') # something went wrong, pass to agent process_action('handoff') # something went wrong, pass to agent
@@ -44,11 +44,11 @@ class Enterprise::MessageTemplates::ResponseBotService
message.message_type == 'incoming' ? 'user' : 'system' message.message_type == 'incoming' ? 'user' : 'system'
end end
def process_response(response) def process_response
if response == 'conversation_handoff' if @response['response'] == 'conversation_handoff'
process_action('handoff') process_action('handoff')
else else
create_messages(response) create_messages
end end
end end
@@ -61,61 +61,47 @@ class Enterprise::MessageTemplates::ResponseBotService
end end
end end
def create_messages(response) def create_messages
response = process_response_content(response).first message_content = @response['response']
create_outgoing_message(response)
message_content = append_message_with_sources(message_content)
create_outgoing_message(message_content)
end end
def process_response_content(response) def append_message_with_sources(message_content)
# Regular expression to match '{context_ids: [ids]}' article_ids = @response['context_ids']
regex = /{context_ids: \[(\d+(?:, *\d+)*)\]}/ return message_content if article_ids.blank?
# Extract ids from string message_content += "\n \n \n **Sources** \n"
id_string = response[regex, 1] # This will give you '42, 43' articles_hash = get_article_hash(article_ids.uniq)
article_ids = id_string.split(',').map(&:to_i) if id_string # This will give you [42, 43]
# Remove '{context_ids: [ids]}' from string articles_hash.first(3).each do |article_hash|
response = response.sub(regex, '') message_content += " - [#{article_hash[:response].question}](#{article_hash[:response_document].document_link}) \n"
end
[response, article_ids] message_content
end end
def create_outgoing_message(response) def create_outgoing_message(message_content)
conversation.messages.create!( conversation.messages.create!(
{ {
message_type: :outgoing, message_type: :outgoing,
account_id: conversation.account_id, account_id: conversation.account_id,
inbox_id: conversation.inbox_id, inbox_id: conversation.inbox_id,
content: response content: message_content
}
)
end
def create_outgoing_message_with_cards(article_ids, conversation)
content_attributes = get_article_hash(article_ids.uniq)
return if content_attributes.blank?
conversation.messages.create!(
{
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: 'suggested articles',
content_type: 'article',
content_attributes: content_attributes
} }
) )
end end
def get_article_hash(article_ids) def get_article_hash(article_ids)
items = [] seen_documents = Set.new
article_ids.each do |article_id| article_ids.uniq.filter_map do |article_id|
response = Response.find(article_id) response = Response.find(article_id)
next if response.nil? response_document = response.response_document
next if response_document.blank? || seen_documents.include?(response_document)
items << { title: response.question, description: response.answer[0, 120], link: response.response_document.document_link } seen_documents << response_document
{ response: response, response_document: response_document }
end end
items.present? ? { items: items } : {}
end end
end end

View File

@@ -8,7 +8,7 @@ class Openai::EmbeddingsService
def fetch_embeddings(input) def fetch_embeddings(input)
url = 'https://api.openai.com/v1/embeddings' url = 'https://api.openai.com/v1/embeddings'
headers = { headers = {
'Authorization' => "Bearer #{ENV.fetch('OPENAI_API_KEY')}", 'Authorization' => "Bearer #{ENV.fetch('OPENAI_API_KEY', '')}",
'Content-Type' => 'application/json' 'Content-Type' => 'application/json'
} }
data = { data = {
@@ -17,6 +17,6 @@ class Openai::EmbeddingsService
} }
response = Net::HTTP.post(URI(url), data.to_json, headers) response = Net::HTTP.post(URI(url), data.to_json, headers)
JSON.parse(response.body)['data'].pick('embedding') JSON.parse(response.body)['data']&.pick('embedding')
end end
end end

View File

@@ -4,27 +4,32 @@ RSpec.describe Enterprise::MessageTemplates::ResponseBotService, type: :service
let!(:conversation) { create(:conversation, status: :pending) } let!(:conversation) { create(:conversation, status: :pending) }
let(:service) { described_class.new(conversation: conversation) } let(:service) { described_class.new(conversation: conversation) }
let(:chat_gpt_double) { instance_double(ChatGpt) } let(:chat_gpt_double) { instance_double(ChatGpt) }
let(:response_source) { create(:response_source, account: conversation.account) }
let(:response_object) { instance_double(Response, id: 1, question: 'Q1', answer: 'A1') } let(:response_object) { instance_double(Response, id: 1, question: 'Q1', answer: 'A1') }
before do before do
# Uncomment if you want to run the spec in your local machine # Uncomment if you want to run the spec in your local machine
# Features::ResponseBotService.new.enable_in_installation # Features::ResponseBotService.new.enable_in_installation
skip('Skipping since vector is not enabled in this environment') unless Features::ResponseBotService.new.vector_extension_enabled? skip('Skipping since vector is not enabled in this environment') unless Features::ResponseBotService.new.vector_extension_enabled?
stub_request(:post, 'https://api.openai.com/v1/embeddings').to_return(status: 200, body: {}.to_json,
headers: { Content_Type: 'application/json' })
create(:message, message_type: :incoming, conversation: conversation, content: 'Hi') create(:message, message_type: :incoming, conversation: conversation, content: 'Hi')
4.times { create(:response, response_source: response_source) }
allow(ChatGpt).to receive(:new).and_return(chat_gpt_double) allow(ChatGpt).to receive(:new).and_return(chat_gpt_double)
allow(chat_gpt_double).to receive(:generate_response).and_return({ 'response' => 'some_response', :context_ids => [1, 2] }) allow(chat_gpt_double).to receive(:generate_response).and_return({ 'response' => 'some_response', 'context_ids' => Response.all.map(&:id) })
allow(conversation.inbox).to receive(:get_responses).and_return([response_object]) allow(conversation.inbox).to receive(:get_responses).and_return([response_object])
end end
describe '#perform' do describe '#perform' do
context 'when successful' do context 'when successful' do
it 'creates an outgoing message' do it 'creates an outgoing message along with article references' do
expect do expect do
service.perform service.perform
end.to change { conversation.messages.where(message_type: :outgoing).count }.by(1) end.to change { conversation.messages.where(message_type: :outgoing).count }.by(1)
expect(conversation.messages.last.content).to eq('some_response') last_message = conversation.messages.last
expect(last_message.content).to include('some_response')
expect(last_message.content).to include(Response.first.question)
end end
end end

View File

@@ -0,0 +1,11 @@
# frozen_string_literal: true
FactoryBot.define do
factory :response do
response_document
response_source
question { Faker::Lorem.sentence }
answer { Faker::Lorem.paragraph }
account
end
end

View File

@@ -0,0 +1,10 @@
# frozen_string_literal: true
FactoryBot.define do
factory :response_document do
response_source
content { Faker::Lorem.paragraph }
document_link { Faker::Internet.url }
account
end
end