Compare commits
14
Commits
develop
...
feat/ruby-llm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a54124f38 | ||
|
|
94b81b49f9 | ||
|
|
45afde970b | ||
|
|
3e52518f73 | ||
|
|
f22c5859a0 | ||
|
|
d13437dc88 | ||
|
|
74db596b22 | ||
|
|
0d0df0eead | ||
|
|
368853c46f | ||
|
|
62ff1bd883 | ||
|
|
4938e9847e | ||
|
|
46c69cb1b0 | ||
|
|
933fcb7d1f | ||
|
|
731d616e7c |
@@ -174,6 +174,7 @@ gem 'pgvector'
|
|||||||
gem 'reverse_markdown'
|
gem 'reverse_markdown'
|
||||||
|
|
||||||
gem 'iso-639'
|
gem 'iso-639'
|
||||||
|
gem 'ruby_llm', '1.1.0rc1'
|
||||||
gem 'ruby-openai'
|
gem 'ruby-openai'
|
||||||
|
|
||||||
gem 'shopify_api'
|
gem 'shopify_api'
|
||||||
|
|||||||
@@ -698,6 +698,13 @@ 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.1.0rc1)
|
||||||
|
base64
|
||||||
|
event_stream_parser (~> 1)
|
||||||
|
faraday (~> 2)
|
||||||
|
faraday-multipart (~> 1)
|
||||||
|
faraday-retry (~> 2)
|
||||||
|
zeitwerk (~> 2)
|
||||||
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)
|
||||||
@@ -964,6 +971,7 @@ DEPENDENCIES
|
|||||||
rubocop-rails
|
rubocop-rails
|
||||||
rubocop-rspec
|
rubocop-rspec
|
||||||
ruby-openai
|
ruby-openai
|
||||||
|
ruby_llm (= 1.1.0rc1)
|
||||||
scout_apm
|
scout_apm
|
||||||
scss_lint
|
scss_lint
|
||||||
seed_dump
|
seed_dump
|
||||||
|
|||||||
@@ -1,28 +1,37 @@
|
|||||||
require 'openai'
|
class Captain::Llm::AssistantChatService < Llm::BaseService
|
||||||
|
|
||||||
class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
|
|
||||||
include Captain::ChatHelper
|
|
||||||
|
|
||||||
def initialize(assistant: nil)
|
def initialize(assistant: nil)
|
||||||
super()
|
super()
|
||||||
|
|
||||||
@assistant = assistant
|
@assistant = assistant
|
||||||
@messages = [system_message]
|
search_tool = Captain::Tools::DocumentationSearch.new(assistant)
|
||||||
@response = ''
|
@chat = ::RubyLLM.chat(model: @model).with_tool(search_tool)
|
||||||
|
@chat.with_instructions(system_message)
|
||||||
end
|
end
|
||||||
|
|
||||||
def generate_response(input, previous_messages = [], role = 'user')
|
def generate_response(input, previous_messages = [], _role = 'user')
|
||||||
@messages += previous_messages
|
previous_messages.each do |msg|
|
||||||
@messages << { role: role, content: input } if input.present?
|
@chat.add_message(role: msg[:role], content: msg[:content])
|
||||||
request_chat_completion
|
end
|
||||||
|
|
||||||
|
response = @chat.ask(input) if input.present?
|
||||||
|
format_response(response)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def system_message
|
def system_message
|
||||||
{
|
Captain::Llm::SystemPromptsService.assistant_response_generator(@assistant.config['product_name'])
|
||||||
role: 'system',
|
end
|
||||||
content: Captain::Llm::SystemPromptsService.assistant_response_generator(@assistant.config['product_name'])
|
|
||||||
}
|
def format_response(response)
|
||||||
|
return '' if response.nil?
|
||||||
|
|
||||||
|
content = response.content
|
||||||
|
return 'conversation_handoff' if content.include?('conversation_handoff')
|
||||||
|
|
||||||
|
begin
|
||||||
|
::JSON.parse(content)
|
||||||
|
rescue ::JSON::ParserError
|
||||||
|
content
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
class Captain::Tools::DocumentationSearch < RubyLLM::Tool
|
||||||
|
description 'Search through the documentation to find relevant answers'
|
||||||
|
|
||||||
|
param :search_query,
|
||||||
|
type: :string,
|
||||||
|
desc: 'The search query to find relevant documentation'
|
||||||
|
|
||||||
|
def initialize(assistant)
|
||||||
|
super()
|
||||||
|
@assistant = assistant
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute(query)
|
||||||
|
@assistant
|
||||||
|
.responses
|
||||||
|
.approved
|
||||||
|
.search(query)
|
||||||
|
.map { |response| format_response(response) }.join
|
||||||
|
|
||||||
|
format_responses(responses)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def format_response(response)
|
||||||
|
formatted_response = "
|
||||||
|
Question: #{response.question}
|
||||||
|
Answer: #{response.answer}
|
||||||
|
"
|
||||||
|
if response.documentable.present? && response.documentable.try(:external_link)
|
||||||
|
formatted_response += "
|
||||||
|
Source: #{response.documentable.external_link}
|
||||||
|
"
|
||||||
|
end
|
||||||
|
|
||||||
|
formatted_response
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
class Llm::BaseService
|
||||||
|
DEFAULT_MODEL = 'gpt-4o-mini'.freeze
|
||||||
|
|
||||||
|
def initialize
|
||||||
|
setup_ruby_llm
|
||||||
|
setup_model
|
||||||
|
rescue StandardError => e
|
||||||
|
raise "Failed to initialize LLM client: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def setup_ruby_llm
|
||||||
|
api_key = InstallationConfig.find_by!(name: 'CAPTAIN_OPEN_AI_API_KEY').value
|
||||||
|
::RubyLLM.configure do |config|
|
||||||
|
config.openai_api_key = api_key
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def setup_model
|
||||||
|
config_value = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value
|
||||||
|
@model = (config_value.presence || DEFAULT_MODEL)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
require 'ruby_llm'
|
||||||
|
|
||||||
|
RSpec.describe Captain::Llm::AssistantChatService do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:assistant) { create(:captain_assistant, account: account, config: { 'product_name' => 'Chatwoot' }) }
|
||||||
|
let(:service) { described_class.new(assistant: assistant) }
|
||||||
|
let(:chat) { instance_double(RubyLLM::Chat) }
|
||||||
|
let(:chat_response) { instance_double(RubyLLM::Message) }
|
||||||
|
let(:embedding) { Array.new(1536) { rand(-1.0..1.0) } }
|
||||||
|
let(:embedding_service) { instance_double(Captain::Llm::EmbeddingService) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||||
|
allow(RubyLLM).to receive(:chat).and_return(chat)
|
||||||
|
allow(chat).to receive(:with_tool).with(Captain::Tools::DocumentationSearch).and_return(chat)
|
||||||
|
allow(chat).to receive(:with_instructions)
|
||||||
|
allow(chat).to receive(:add_message)
|
||||||
|
allow(Captain::Llm::UpdateEmbeddingJob).to receive(:perform_later)
|
||||||
|
allow(Captain::Llm::EmbeddingService).to receive(:new).and_return(embedding_service)
|
||||||
|
allow(embedding_service).to receive(:get_embedding).and_return(embedding)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#initialize' do
|
||||||
|
it 'sets up RubyLLM chat with DocumentationSearch tool' do
|
||||||
|
expect(RubyLLM).to receive(:chat).with(model: anything)
|
||||||
|
expect(chat).to receive(:with_tool).with(Captain::Tools::DocumentationSearch)
|
||||||
|
service
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sets system message' do
|
||||||
|
expect(chat).to receive(:with_instructions).with(
|
||||||
|
Captain::Llm::SystemPromptsService.assistant_response_generator('Chatwoot')
|
||||||
|
)
|
||||||
|
service
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#generate_response' do
|
||||||
|
let(:input) { 'How do I configure inbox?' }
|
||||||
|
let(:previous_messages) do
|
||||||
|
[
|
||||||
|
{ role: 'user', content: 'Hello' },
|
||||||
|
{ role: 'assistant', content: 'Hi there!' }
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(chat).to receive(:ask).and_return(chat_response)
|
||||||
|
previous_messages.each do |msg|
|
||||||
|
if msg[:role] == 'system'
|
||||||
|
allow(chat).to receive(:with_instructions).with(msg[:content])
|
||||||
|
else
|
||||||
|
allow(chat).to receive(:add_message).with(role: msg[:role], content: msg[:content])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when response is valid JSON' do
|
||||||
|
let(:json_response) do
|
||||||
|
{
|
||||||
|
'reasoning' => 'Found in documentation',
|
||||||
|
'response' => 'Here are the steps...'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(chat_response).to receive(:content).and_return(json_response.to_json)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns parsed JSON response' do
|
||||||
|
result = service.generate_response(input, previous_messages)
|
||||||
|
expect(result).to eq(json_response)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when response indicates conversation handoff' do
|
||||||
|
before do
|
||||||
|
allow(chat_response).to receive(:content).and_return('conversation_handoff')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns conversation_handoff' do
|
||||||
|
result = service.generate_response(input, previous_messages)
|
||||||
|
expect(result).to eq('conversation_handoff')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when response is not valid JSON' do
|
||||||
|
let(:plain_response) { 'Here are the steps...' }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(chat_response).to receive(:content).and_return(plain_response)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'wraps response in JSON format' do
|
||||||
|
result = service.generate_response(input, previous_messages)
|
||||||
|
expect(result).to eq({
|
||||||
|
'reasoning' => '',
|
||||||
|
'response' => plain_response
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when input is blank' do
|
||||||
|
let(:input) { '' }
|
||||||
|
|
||||||
|
it 'does not send user message' do
|
||||||
|
expect(chat).not_to receive(:ask)
|
||||||
|
service.generate_response(input, previous_messages)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when no previous messages' do
|
||||||
|
let(:response_content) { 'Here are the steps...' }
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Clear the service instance to avoid system message being counted
|
||||||
|
allow(chat).to receive(:with_instructions).with(
|
||||||
|
Captain::Llm::SystemPromptsService.assistant_response_generator('Chatwoot')
|
||||||
|
)
|
||||||
|
allow(chat_response).to receive(:content).and_return(response_content)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'only sends new input' do
|
||||||
|
expect(chat).not_to receive(:add_message)
|
||||||
|
expect(chat).to receive(:ask).with(input)
|
||||||
|
service.generate_response(input)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Captain::Llm::ContactNotesService do
|
||||||
|
let(:captain_assistant) { create(:captain_assistant) }
|
||||||
|
let(:account) { create(:account, locale: 'en') }
|
||||||
|
let(:conversation) { create(:conversation, account: account) }
|
||||||
|
let(:contact) { conversation.contact }
|
||||||
|
let(:service) { described_class.new(captain_assistant, conversation) }
|
||||||
|
let(:client) { instance_double(OpenAI::Client) }
|
||||||
|
let(:system_prompt) { 'Test system prompt' }
|
||||||
|
|
||||||
|
before do
|
||||||
|
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||||
|
allow(OpenAI::Client).to receive(:new).and_return(client)
|
||||||
|
allow(Captain::Llm::SystemPromptsService).to receive(:notes_generator)
|
||||||
|
.with(account.locale_english_name).and_return(system_prompt)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#generate_and_update_notes' do
|
||||||
|
let(:openai_response) do
|
||||||
|
{
|
||||||
|
'choices' => [
|
||||||
|
{
|
||||||
|
'message' => {
|
||||||
|
'content' => {
|
||||||
|
notes: [
|
||||||
|
'Customer reported an issue with login',
|
||||||
|
'Follow up needed on billing concerns'
|
||||||
|
]
|
||||||
|
}.to_json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when successful' do
|
||||||
|
before do
|
||||||
|
allow(client).to receive(:chat).and_return(openai_response)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates notes for the contact' do
|
||||||
|
expect { service.generate_and_update_notes }.to change(contact.notes, :count).by(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates notes with correct content' do
|
||||||
|
service.generate_and_update_notes
|
||||||
|
expect(contact.notes.pluck(:content)).to contain_exactly('Customer reported an issue with login', 'Follow up needed on billing concerns')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes conversation and contact context in chat parameters' do
|
||||||
|
service.generate_and_update_notes
|
||||||
|
expect(client).to have_received(:chat) do |params|
|
||||||
|
messages = params[:parameters][:messages]
|
||||||
|
content = messages.find { |m| m[:role] == 'user' }[:content]
|
||||||
|
expect(content).to include('#Contact')
|
||||||
|
expect(content).to include('#Conversation')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes system message in chat parameters' do
|
||||||
|
service.generate_and_update_notes
|
||||||
|
expect(client).to have_received(:chat) do |params|
|
||||||
|
messages = params[:parameters][:messages]
|
||||||
|
system_message = messages.find { |m| m[:role] == 'system' }
|
||||||
|
expect(system_message[:content]).to eq(system_prompt)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when OpenAI API fails' do
|
||||||
|
before do
|
||||||
|
allow(client).to receive(:chat).and_raise(OpenAI::Error.new('API Error'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs error and returns empty array' do
|
||||||
|
expect(Rails.logger).to receive(:error).with('OpenAI API Error: API Error')
|
||||||
|
expect { service.generate_and_update_notes }.not_to change(contact.notes, :count)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when response parsing fails' do
|
||||||
|
let(:invalid_response) do
|
||||||
|
{
|
||||||
|
'choices' => [
|
||||||
|
{
|
||||||
|
'message' => {
|
||||||
|
'content' => 'Invalid JSON'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(client).to receive(:chat).and_return(invalid_response)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs error and returns empty array' do
|
||||||
|
expect(Rails.logger).to receive(:error).with(/Error in parsing GPT processed response/)
|
||||||
|
expect { service.generate_and_update_notes }.not_to change(contact.notes, :count)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when response is missing content' do
|
||||||
|
let(:empty_response) do
|
||||||
|
{
|
||||||
|
'choices' => [
|
||||||
|
{
|
||||||
|
'message' => {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(client).to receive(:chat).and_return(empty_response)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns empty array' do
|
||||||
|
expect { service.generate_and_update_notes }.not_to change(contact.notes, :count)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#chat_parameters' do
|
||||||
|
before do
|
||||||
|
allow(client).to receive(:chat).and_return({
|
||||||
|
'choices' => [
|
||||||
|
{
|
||||||
|
'message' => {
|
||||||
|
'content' => { notes: [] }.to_json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes correct model and response format' do
|
||||||
|
service.generate_and_update_notes
|
||||||
|
expect(client).to have_received(:chat) do |params|
|
||||||
|
parameters = params[:parameters]
|
||||||
|
expect(parameters[:model]).to eq('gpt-4o-mini')
|
||||||
|
expect(parameters[:response_format]).to eq({ type: 'json_object' })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Captain::Llm::EmbeddingService do
|
||||||
|
let(:service) { described_class.new }
|
||||||
|
let(:client) { instance_double(OpenAI::Client) }
|
||||||
|
let(:content) { 'Test content for embedding' }
|
||||||
|
|
||||||
|
before do
|
||||||
|
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||||
|
allow(OpenAI::Client).to receive(:new).and_return(client)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#get_embedding' do
|
||||||
|
let(:embedding) { [0.1, 0.2, 0.3] }
|
||||||
|
let(:openai_response) do
|
||||||
|
{
|
||||||
|
'data' => [
|
||||||
|
{
|
||||||
|
'embedding' => embedding
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when successful' do
|
||||||
|
before do
|
||||||
|
allow(client).to receive(:embeddings).and_return(openai_response)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns embedding array' do
|
||||||
|
expect(service.get_embedding(content)).to eq(embedding)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses default model' do
|
||||||
|
service.get_embedding(content)
|
||||||
|
expect(client).to have_received(:embeddings).with(
|
||||||
|
parameters: {
|
||||||
|
model: described_class::DEFAULT_MODEL,
|
||||||
|
input: content
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'accepts custom model' do
|
||||||
|
custom_model = 'text-embedding-ada-002'
|
||||||
|
service.get_embedding(content, model: custom_model)
|
||||||
|
expect(client).to have_received(:embeddings).with(
|
||||||
|
parameters: {
|
||||||
|
model: custom_model,
|
||||||
|
input: content
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when API call fails' do
|
||||||
|
before do
|
||||||
|
allow(client).to receive(:embeddings).and_raise(StandardError.new('API Error'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises EmbeddingsError with message' do
|
||||||
|
expect { service.get_embedding(content) }.to raise_error(
|
||||||
|
Captain::Llm::EmbeddingService::EmbeddingsError,
|
||||||
|
'Failed to create an embedding: API Error'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Captain::Llm::FaqGeneratorService do
|
||||||
|
let(:content) { 'How do I reset my password? Click on forgot password link.' }
|
||||||
|
let(:service) { described_class.new(content) }
|
||||||
|
let(:client) { instance_double(OpenAI::Client) }
|
||||||
|
let(:system_prompt) { 'Test system prompt' }
|
||||||
|
|
||||||
|
before do
|
||||||
|
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||||
|
allow(OpenAI::Client).to receive(:new).and_return(client)
|
||||||
|
allow(Captain::Llm::SystemPromptsService).to receive(:faq_generator).and_return(system_prompt)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#generate' do
|
||||||
|
let(:openai_response) do
|
||||||
|
{
|
||||||
|
'choices' => [
|
||||||
|
{
|
||||||
|
'message' => {
|
||||||
|
'content' => {
|
||||||
|
faqs: [
|
||||||
|
{
|
||||||
|
question: 'How do I reset my password?',
|
||||||
|
answer: 'Click on forgot password link.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Where is the forgot password link?',
|
||||||
|
answer: 'The forgot password link is on the login page.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}.to_json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when successful' do
|
||||||
|
before do
|
||||||
|
allow(client).to receive(:chat).and_return(openai_response)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'generates FAQs from content' do
|
||||||
|
faqs = service.generate
|
||||||
|
expect(faqs).to contain_exactly(
|
||||||
|
{
|
||||||
|
'question' => 'How do I reset my password?',
|
||||||
|
'answer' => 'Click on forgot password link.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'question' => 'Where is the forgot password link?',
|
||||||
|
'answer' => 'The forgot password link is on the login page.'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes content in chat parameters' do
|
||||||
|
service.generate
|
||||||
|
expect(client).to have_received(:chat) do |params|
|
||||||
|
messages = params[:parameters][:messages]
|
||||||
|
user_message = messages.find { |m| m[:role] == 'user' }
|
||||||
|
expect(user_message[:content]).to eq(content)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes system message in chat parameters' do
|
||||||
|
service.generate
|
||||||
|
expect(client).to have_received(:chat) do |params|
|
||||||
|
messages = params[:parameters][:messages]
|
||||||
|
system_message = messages.find { |m| m[:role] == 'system' }
|
||||||
|
expect(system_message[:content]).to eq(system_prompt)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when OpenAI API fails' do
|
||||||
|
before do
|
||||||
|
allow(client).to receive(:chat).and_raise(OpenAI::Error.new('API Error'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs error and returns empty array' do
|
||||||
|
expect(Rails.logger).to receive(:error).with('OpenAI API Error: API Error')
|
||||||
|
expect(service.generate).to eq([])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when response parsing fails' do
|
||||||
|
let(:invalid_response) do
|
||||||
|
{
|
||||||
|
'choices' => [
|
||||||
|
{
|
||||||
|
'message' => {
|
||||||
|
'content' => 'Invalid JSON'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(client).to receive(:chat).and_return(invalid_response)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs error and returns empty array' do
|
||||||
|
expect(Rails.logger).to receive(:error).with(/Error in parsing GPT processed response/)
|
||||||
|
expect(service.generate).to eq([])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when response is missing content' do
|
||||||
|
let(:empty_response) do
|
||||||
|
{
|
||||||
|
'choices' => [
|
||||||
|
{
|
||||||
|
'message' => {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(client).to receive(:chat).and_return(empty_response)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns empty array' do
|
||||||
|
expect(service.generate).to eq([])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#chat_parameters' do
|
||||||
|
before do
|
||||||
|
allow(client).to receive(:chat).and_return({
|
||||||
|
'choices' => [
|
||||||
|
{
|
||||||
|
'message' => {
|
||||||
|
'content' => { faqs: [] }.to_json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes correct model and response format' do
|
||||||
|
service.generate
|
||||||
|
expect(client).to have_received(:chat) do |params|
|
||||||
|
parameters = params[:parameters]
|
||||||
|
expect(parameters[:model]).to eq('gpt-4o-mini')
|
||||||
|
expect(parameters[:response_format]).to eq({ type: 'json_object' })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Captain::Tools::DocumentationSearch do
|
||||||
|
let(:tool) { described_class.new }
|
||||||
|
let(:search_query) { 'how to configure inbox' }
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:assistant) { create(:captain_assistant, account: account) }
|
||||||
|
let(:embedding) { Array.new(1536) { rand(-1.0..1.0) } }
|
||||||
|
let(:embedding_service) { instance_double(Captain::Llm::EmbeddingService) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Captain::Llm::UpdateEmbeddingJob).to receive(:perform_later)
|
||||||
|
allow(Captain::Llm::EmbeddingService).to receive(:new).and_return(embedding_service)
|
||||||
|
allow(embedding_service).to receive(:get_embedding).with(search_query).and_return(embedding)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#execute' do
|
||||||
|
context 'when matching responses exist' do
|
||||||
|
let!(:response) do
|
||||||
|
create(:captain_assistant_response,
|
||||||
|
assistant: assistant,
|
||||||
|
account: account,
|
||||||
|
question: 'How do I configure my inbox?',
|
||||||
|
answer: 'Follow these steps...',
|
||||||
|
status: :approved,
|
||||||
|
embedding: embedding)
|
||||||
|
end
|
||||||
|
|
||||||
|
let!(:response_with_source) do
|
||||||
|
document = create(:captain_document,
|
||||||
|
assistant: assistant,
|
||||||
|
account: account,
|
||||||
|
external_link: 'https://example.com/docs')
|
||||||
|
create(:captain_assistant_response,
|
||||||
|
assistant: assistant,
|
||||||
|
account: account,
|
||||||
|
question: 'What are inbox settings?',
|
||||||
|
answer: 'Inbox settings include...',
|
||||||
|
documentable: document,
|
||||||
|
status: :approved,
|
||||||
|
embedding: embedding)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns formatted responses' do
|
||||||
|
result = tool.execute(search_query: search_query)
|
||||||
|
|
||||||
|
expect(result).to be_an(Array)
|
||||||
|
expect(result.size).to eq(2)
|
||||||
|
|
||||||
|
# First response without source
|
||||||
|
expect(result[0]).to include(
|
||||||
|
question: response.question,
|
||||||
|
answer: response.answer
|
||||||
|
)
|
||||||
|
expect(result[0]).not_to have_key(:source)
|
||||||
|
|
||||||
|
# Second response with source
|
||||||
|
expect(result[1]).to include(
|
||||||
|
question: response_with_source.question,
|
||||||
|
answer: response_with_source.answer,
|
||||||
|
source: 'https://example.com/docs'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when no matching responses exist' do
|
||||||
|
it 'returns an empty array' do
|
||||||
|
result = tool.execute(search_query: search_query)
|
||||||
|
expect(result).to eq([])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.description' do
|
||||||
|
it 'has a description' do
|
||||||
|
expect(described_class.description).to eq('Search through the documentation to find relevant answers')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.parameters' do
|
||||||
|
it 'defines search_query parameter' do
|
||||||
|
param = described_class.parameters[:search_query]
|
||||||
|
expect(param).to be_a(RubyLLM::Parameter)
|
||||||
|
expect(param.name).to eq(:search_query)
|
||||||
|
expect(param.type).to eq(:string)
|
||||||
|
expect(param.description).to eq('The search query to find relevant documentation')
|
||||||
|
expect(param.required).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Llm::BaseService do
|
||||||
|
describe '#initialize' do
|
||||||
|
let(:api_key) { 'test-key' }
|
||||||
|
let(:model) { 'custom-model' }
|
||||||
|
|
||||||
|
before do
|
||||||
|
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: api_key)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'configures RubyLLM with the API key' do
|
||||||
|
expect(RubyLLM).to receive(:configure) do |&block|
|
||||||
|
config = OpenStruct.new
|
||||||
|
block.call(config)
|
||||||
|
expect(config.openai_api_key).to eq(api_key)
|
||||||
|
end
|
||||||
|
|
||||||
|
described_class.new
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when API key is missing' do
|
||||||
|
before do
|
||||||
|
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY').destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises an error' do
|
||||||
|
expect { described_class.new }.to raise_error(
|
||||||
|
RuntimeError,
|
||||||
|
/Failed to initialize LLM client: Couldn't find InstallationConfig/
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when model config exists' do
|
||||||
|
before do
|
||||||
|
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: model)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses the configured model' do
|
||||||
|
allow(RubyLLM).to receive(:configure)
|
||||||
|
service = described_class.new
|
||||||
|
expect(service.instance_variable_get(:@model)).to eq(model)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when model config is missing' do
|
||||||
|
it 'uses the default model' do
|
||||||
|
allow(RubyLLM).to receive(:configure)
|
||||||
|
service = described_class.new
|
||||||
|
expect(service.instance_variable_get(:@model)).to eq(described_class::DEFAULT_MODEL)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -3,5 +3,6 @@ FactoryBot.define do
|
|||||||
sequence(:name) { |n| "Assistant #{n}" }
|
sequence(:name) { |n| "Assistant #{n}" }
|
||||||
description { 'Test description' }
|
description { 'Test description' }
|
||||||
association :account
|
association :account
|
||||||
|
config { { 'product_name' => 'Test Product', 'feature_memory' => true, 'feature_faq' => true } }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user