Initial commit: Add logistics and order_detail message types
Some checks failed
Lock Threads / action (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot EE docker images / merge (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot CE docker images / merge (push) Has been cancelled
Run Chatwoot CE spec / lint-backend (push) Has been cancelled
Run Chatwoot CE spec / lint-frontend (push) Has been cancelled
Run Chatwoot CE spec / frontend-tests (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (0, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (1, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (10, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (11, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (12, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (13, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (14, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (15, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (2, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (3, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (4, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (5, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (6, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (7, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (8, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (9, 16) (push) Has been cancelled
Run Linux nightly installer / nightly (push) Has been cancelled
Some checks failed
Lock Threads / action (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot EE docker images / merge (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot CE docker images / merge (push) Has been cancelled
Run Chatwoot CE spec / lint-backend (push) Has been cancelled
Run Chatwoot CE spec / lint-frontend (push) Has been cancelled
Run Chatwoot CE spec / frontend-tests (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (0, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (1, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (10, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (11, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (12, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (13, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (14, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (15, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (2, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (3, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (4, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (5, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (6, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (7, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (8, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (9, 16) (push) Has been cancelled
Run Linux nightly installer / nightly (push) Has been cancelled
- Add Logistics component with progress tracking - Add OrderDetail component for order information - Support data-driven steps and actions - Add blue color scale to widget SCSS - Fix node overflow and progress bar rendering issues - Add English translations for dashboard components Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:scenario) { create(:captain_scenario, assistant: assistant, enabled: true) }
|
||||
|
||||
let(:mock_runner) { instance_double(Agents::Runner) }
|
||||
let(:mock_agent) { instance_double(Agents::Agent) }
|
||||
let(:mock_scenario_agent) { instance_double(Agents::Agent) }
|
||||
let(:mock_result) { instance_double(Agents::RunResult, output: { 'response' => 'Test response' }, context: nil) }
|
||||
|
||||
let(:message_history) do
|
||||
[
|
||||
{ role: 'user', content: 'Hello there' },
|
||||
{ role: 'assistant', content: 'Hi! How can I help you?', agent_name: 'Assistant' },
|
||||
{ role: 'user', content: 'I need help with my account' }
|
||||
]
|
||||
end
|
||||
|
||||
before do
|
||||
allow(assistant).to receive(:agent).and_return(mock_agent)
|
||||
scenarios_relation = instance_double(Captain::Scenario)
|
||||
allow(scenarios_relation).to receive(:enabled).and_return([scenario])
|
||||
allow(assistant).to receive(:scenarios).and_return(scenarios_relation)
|
||||
allow(scenario).to receive(:agent).and_return(mock_scenario_agent)
|
||||
allow(Agents::Runner).to receive(:with_agents).and_return(mock_runner)
|
||||
allow(mock_runner).to receive(:run).and_return(mock_result)
|
||||
allow(mock_agent).to receive(:register_handoffs)
|
||||
allow(mock_scenario_agent).to receive(:register_handoffs)
|
||||
end
|
||||
|
||||
describe '#initialize' do
|
||||
it 'sets instance variables correctly' do
|
||||
service = described_class.new(assistant: assistant, conversation: conversation)
|
||||
|
||||
expect(service.instance_variable_get(:@assistant)).to eq(assistant)
|
||||
expect(service.instance_variable_get(:@conversation)).to eq(conversation)
|
||||
expect(service.instance_variable_get(:@callbacks)).to eq({})
|
||||
end
|
||||
|
||||
it 'accepts callbacks parameter' do
|
||||
callbacks = { on_agent_thinking: proc { |x| x } }
|
||||
service = described_class.new(assistant: assistant, callbacks: callbacks)
|
||||
|
||||
expect(service.instance_variable_get(:@callbacks)).to eq(callbacks)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#generate_response' do
|
||||
subject(:service) { described_class.new(assistant: assistant, conversation: conversation) }
|
||||
|
||||
it 'builds agents and wires them together' do
|
||||
expect(assistant).to receive(:agent).and_return(mock_agent)
|
||||
scenarios_relation = instance_double(Captain::Scenario)
|
||||
allow(scenarios_relation).to receive(:enabled).and_return([scenario])
|
||||
expect(assistant).to receive(:scenarios).and_return(scenarios_relation)
|
||||
expect(scenario).to receive(:agent).and_return(mock_scenario_agent)
|
||||
expect(mock_agent).to receive(:register_handoffs).with(mock_scenario_agent)
|
||||
expect(mock_scenario_agent).to receive(:register_handoffs).with(mock_agent)
|
||||
|
||||
service.generate_response(message_history: message_history)
|
||||
end
|
||||
|
||||
it 'creates runner with agents' do
|
||||
expect(Agents::Runner).to receive(:with_agents).with(mock_agent, mock_scenario_agent)
|
||||
|
||||
service.generate_response(message_history: message_history)
|
||||
end
|
||||
|
||||
it 'runs agent with extracted user message and context' do
|
||||
expected_context = {
|
||||
conversation_history: [
|
||||
{ role: :user, content: 'Hello there', agent_name: nil },
|
||||
{ role: :assistant, content: 'Hi! How can I help you?', agent_name: 'Assistant' },
|
||||
{ role: :user, content: 'I need help with my account', agent_name: nil }
|
||||
],
|
||||
state: hash_including(
|
||||
account_id: account.id,
|
||||
assistant_id: assistant.id,
|
||||
conversation: hash_including(id: conversation.id),
|
||||
contact: hash_including(id: contact.id)
|
||||
)
|
||||
}
|
||||
|
||||
expect(mock_runner).to receive(:run).with(
|
||||
'I need help with my account',
|
||||
context: expected_context,
|
||||
max_turns: 100
|
||||
)
|
||||
|
||||
service.generate_response(message_history: message_history)
|
||||
end
|
||||
|
||||
it 'processes and formats agent result' do
|
||||
result = service.generate_response(message_history: message_history)
|
||||
|
||||
expect(result).to eq({ 'response' => 'Test response', 'agent_name' => nil })
|
||||
end
|
||||
|
||||
context 'when no scenarios are enabled' do
|
||||
before do
|
||||
scenarios_relation = instance_double(Captain::Scenario)
|
||||
allow(scenarios_relation).to receive(:enabled).and_return([])
|
||||
allow(assistant).to receive(:scenarios).and_return(scenarios_relation)
|
||||
end
|
||||
|
||||
it 'only uses assistant agent' do
|
||||
expect(Agents::Runner).to receive(:with_agents).with(mock_agent)
|
||||
expect(mock_agent).not_to receive(:register_handoffs)
|
||||
|
||||
service.generate_response(message_history: message_history)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent result is a string' do
|
||||
let(:mock_result) { instance_double(Agents::RunResult, output: 'Simple string response', context: nil) }
|
||||
|
||||
it 'formats string response correctly' do
|
||||
result = service.generate_response(message_history: message_history)
|
||||
|
||||
expect(result).to eq({
|
||||
'response' => 'Simple string response',
|
||||
'reasoning' => 'Processed by agent',
|
||||
'agent_name' => nil
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an error occurs' do
|
||||
let(:error) { StandardError.new('Test error') }
|
||||
|
||||
before do
|
||||
allow(mock_runner).to receive(:run).and_raise(error)
|
||||
allow(ChatwootExceptionTracker).to receive(:new).and_return(
|
||||
instance_double(ChatwootExceptionTracker, capture_exception: true)
|
||||
)
|
||||
end
|
||||
|
||||
it 'captures exception and returns error response' do
|
||||
expect(ChatwootExceptionTracker).to receive(:new).with(error, account: conversation.account)
|
||||
|
||||
result = service.generate_response(message_history: message_history)
|
||||
|
||||
expect(result).to eq({
|
||||
'response' => 'conversation_handoff',
|
||||
'reasoning' => 'Error occurred: Test error'
|
||||
})
|
||||
end
|
||||
|
||||
it 'logs error details' do
|
||||
expect(Rails.logger).to receive(:error).with('[Captain V2] AgentRunnerService error: Test error')
|
||||
expect(Rails.logger).to receive(:error).with(kind_of(String))
|
||||
|
||||
service.generate_response(message_history: message_history)
|
||||
end
|
||||
|
||||
context 'when conversation is nil' do
|
||||
subject(:service) { described_class.new(assistant: assistant, conversation: nil) }
|
||||
|
||||
it 'handles missing conversation gracefully' do
|
||||
expect(ChatwootExceptionTracker).to receive(:new).with(error, account: nil)
|
||||
|
||||
result = service.generate_response(message_history: message_history)
|
||||
|
||||
expect(result).to eq({
|
||||
'response' => 'conversation_handoff',
|
||||
'reasoning' => 'Error occurred: Test error'
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_context' do
|
||||
subject(:service) { described_class.new(assistant: assistant, conversation: conversation) }
|
||||
|
||||
it 'builds context with conversation history and state' do
|
||||
context = service.send(:build_context, message_history)
|
||||
|
||||
expect(context).to include(
|
||||
conversation_history: array_including(
|
||||
{ role: :user, content: 'Hello there', agent_name: nil },
|
||||
{ role: :assistant, content: 'Hi! How can I help you?', agent_name: 'Assistant' }
|
||||
),
|
||||
state: hash_including(
|
||||
account_id: account.id,
|
||||
assistant_id: assistant.id
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
context 'with multimodal content' do
|
||||
let(:multimodal_message_history) do
|
||||
[
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'Can you help with this image?' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
it 'extracts text content from multimodal messages' do
|
||||
context = service.send(:build_context, multimodal_message_history)
|
||||
|
||||
expect(context[:conversation_history].first[:content]).to eq('Can you help with this image?')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#extract_last_user_message' do
|
||||
subject(:service) { described_class.new(assistant: assistant, conversation: conversation) }
|
||||
|
||||
it 'extracts the last user message' do
|
||||
result = service.send(:extract_last_user_message, message_history)
|
||||
|
||||
expect(result).to eq('I need help with my account')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#extract_text_from_content' do
|
||||
subject(:service) { described_class.new(assistant: assistant, conversation: conversation) }
|
||||
|
||||
it 'extracts text from string content' do
|
||||
result = service.send(:extract_text_from_content, 'Simple text')
|
||||
|
||||
expect(result).to eq('Simple text')
|
||||
end
|
||||
|
||||
it 'extracts response from hash content' do
|
||||
content = { 'response' => 'Hash response' }
|
||||
result = service.send(:extract_text_from_content, content)
|
||||
|
||||
expect(result).to eq('Hash response')
|
||||
end
|
||||
|
||||
it 'extracts text from multimodal array content' do
|
||||
content = [
|
||||
{ type: 'text', text: 'First part' },
|
||||
{ type: 'image_url', image_url: { url: 'image.jpg' } },
|
||||
{ type: 'text', text: 'Second part' }
|
||||
]
|
||||
|
||||
result = service.send(:extract_text_from_content, content)
|
||||
|
||||
expect(result).to eq('First part Second part')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_state' do
|
||||
subject(:service) { described_class.new(assistant: assistant, conversation: conversation) }
|
||||
|
||||
it 'builds state with assistant and account information' do
|
||||
state = service.send(:build_state)
|
||||
|
||||
expect(state).to include(
|
||||
account_id: account.id,
|
||||
assistant_id: assistant.id,
|
||||
assistant_config: assistant.config
|
||||
)
|
||||
end
|
||||
|
||||
it 'includes conversation attributes when conversation is present' do
|
||||
state = service.send(:build_state)
|
||||
|
||||
expect(state[:conversation]).to include(
|
||||
id: conversation.id,
|
||||
inbox_id: inbox.id,
|
||||
contact_id: contact.id,
|
||||
status: conversation.status
|
||||
)
|
||||
end
|
||||
|
||||
it 'includes contact attributes when contact is present' do
|
||||
state = service.send(:build_state)
|
||||
|
||||
expect(state[:contact]).to include(
|
||||
id: contact.id,
|
||||
name: contact.name,
|
||||
email: contact.email
|
||||
)
|
||||
end
|
||||
|
||||
context 'when conversation is nil' do
|
||||
subject(:service) { described_class.new(assistant: assistant, conversation: nil) }
|
||||
|
||||
it 'builds state without conversation and contact' do
|
||||
state = service.send(:build_state)
|
||||
|
||||
expect(state).to include(
|
||||
account_id: account.id,
|
||||
assistant_id: assistant.id,
|
||||
assistant_config: assistant.config
|
||||
)
|
||||
expect(state).not_to have_key(:conversation)
|
||||
expect(state).not_to have_key(:contact)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'constants' do
|
||||
it 'defines conversation state attributes' do
|
||||
expect(described_class::CONVERSATION_STATE_ATTRIBUTES).to include(
|
||||
:id, :display_id, :inbox_id, :contact_id, :status, :priority
|
||||
)
|
||||
end
|
||||
|
||||
it 'defines contact state attributes' do
|
||||
expect(described_class::CONTACT_STATE_ATTRIBUTES).to include(
|
||||
:id, :name, :email, :phone_number, :identifier, :contact_type
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
191
spec/enterprise/services/captain/copilot/chat_service_spec.rb
Normal file
191
spec/enterprise/services/captain/copilot/chat_service_spec.rb
Normal file
@@ -0,0 +1,191 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Copilot::ChatService do
|
||||
let(:account) { create(:account, custom_attributes: { plan_name: 'startups' }) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
|
||||
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user) }
|
||||
let!(:copilot_message) do
|
||||
create(
|
||||
:captain_copilot_message, account: account, copilot_thread: copilot_thread
|
||||
)
|
||||
end
|
||||
let(:previous_history) { [{ role: copilot_message.message_type, content: copilot_message.message['content'] }] }
|
||||
|
||||
let(:config) do
|
||||
{ user_id: user.id, copilot_thread_id: copilot_thread.id, conversation_id: conversation.display_id }
|
||||
end
|
||||
|
||||
# RubyLLM mocks
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
let(:mock_response) do
|
||||
instance_double(RubyLLM::Message, content: '{ "content": "Hey", "reasoning": "Test reasoning", "reply_suggestion": false }')
|
||||
end
|
||||
|
||||
before do
|
||||
InstallationConfig.find_or_create_by(name: 'CAPTAIN_OPEN_AI_API_KEY') do |c|
|
||||
c.value = 'test-key'
|
||||
end
|
||||
|
||||
allow(RubyLLM).to receive(:chat).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:with_temperature).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:with_params).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:with_tool).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(:on_new_message).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:on_end_message).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:on_tool_call).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:on_tool_result).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:messages).and_return([])
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
|
||||
describe '#initialize' do
|
||||
it 'sets up the service with correct instance variables' do
|
||||
service = described_class.new(assistant, config)
|
||||
|
||||
expect(service.assistant).to eq(assistant)
|
||||
expect(service.account).to eq(account)
|
||||
expect(service.user).to eq(user)
|
||||
expect(service.copilot_thread).to eq(copilot_thread)
|
||||
expect(service.previous_history).to eq(previous_history)
|
||||
end
|
||||
|
||||
it 'builds messages with system message and account context' do
|
||||
service = described_class.new(assistant, config)
|
||||
messages = service.messages
|
||||
|
||||
expect(messages.first[:role]).to eq('system')
|
||||
expect(messages.second[:role]).to eq('system')
|
||||
expect(messages.second[:content]).to include(account.id.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#generate_response' do
|
||||
let(:service) { described_class.new(assistant, config) }
|
||||
|
||||
it 'adds user input to messages when present' do
|
||||
expect do
|
||||
service.generate_response('Hello')
|
||||
end.to(change { service.messages.count }.by(1))
|
||||
|
||||
last_message = service.messages.last
|
||||
expect(last_message[:role]).to eq('user')
|
||||
expect(last_message[:content]).to eq('Hello')
|
||||
end
|
||||
|
||||
it 'does not add user input to messages when blank' do
|
||||
expect do
|
||||
service.generate_response('')
|
||||
end.not_to(change { service.messages.count })
|
||||
end
|
||||
|
||||
it 'returns the response from request_chat_completion' do
|
||||
result = service.generate_response('Hello')
|
||||
|
||||
expect(result).to eq({ 'content' => 'Hey', 'reasoning' => 'Test reasoning', 'reply_suggestion' => false })
|
||||
end
|
||||
|
||||
it 'increments response usage for the account' do
|
||||
expect do
|
||||
service.generate_response('Hello')
|
||||
end.to(change { account.reload.custom_attributes['captain_responses_usage'].to_i }.by(1))
|
||||
end
|
||||
end
|
||||
|
||||
describe 'user setup behavior' do
|
||||
it 'sets user when user_id is present in config' do
|
||||
service = described_class.new(assistant, { user_id: user.id })
|
||||
expect(service.user).to eq(user)
|
||||
end
|
||||
|
||||
it 'does not set user when user_id is not present in config' do
|
||||
service = described_class.new(assistant, {})
|
||||
expect(service.user).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe 'message history behavior' do
|
||||
context 'when copilot_thread_id is present' do
|
||||
it 'finds the copilot thread and sets previous history from it' do
|
||||
service = described_class.new(assistant, { copilot_thread_id: copilot_thread.id })
|
||||
|
||||
expect(service.copilot_thread).to eq(copilot_thread)
|
||||
expect(service.previous_history).to eq previous_history
|
||||
end
|
||||
end
|
||||
|
||||
context 'when copilot_thread_id is not present' do
|
||||
it 'uses previous_history from config if present' do
|
||||
custom_history = [{ role: 'user', content: 'Custom message' }]
|
||||
service = described_class.new(assistant, { previous_history: custom_history })
|
||||
|
||||
expect(service.copilot_thread).to be_nil
|
||||
expect(service.previous_history).to eq(custom_history)
|
||||
end
|
||||
|
||||
it 'uses empty array if previous_history is not present in config' do
|
||||
service = described_class.new(assistant, {})
|
||||
|
||||
expect(service.copilot_thread).to be_nil
|
||||
expect(service.previous_history).to eq([])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'message building behavior' do
|
||||
it 'includes system message and account context' do
|
||||
service = described_class.new(assistant, {})
|
||||
messages = service.messages
|
||||
|
||||
expect(messages.first[:role]).to eq('system')
|
||||
expect(messages.second[:role]).to eq('system')
|
||||
expect(messages.second[:content]).to include(account.id.to_s)
|
||||
end
|
||||
|
||||
it 'includes previous history when present' do
|
||||
custom_history = [{ role: 'user', content: 'Custom message' }]
|
||||
service = described_class.new(assistant, { previous_history: custom_history })
|
||||
messages = service.messages
|
||||
|
||||
expect(messages.count).to be >= 3
|
||||
expect(messages.any? { |m| m[:content] == 'Custom message' }).to be true
|
||||
end
|
||||
|
||||
it 'includes current viewing history when conversation_id is present' do
|
||||
service = described_class.new(assistant, { conversation_id: conversation.display_id })
|
||||
messages = service.messages
|
||||
|
||||
viewing_history = messages.find { |m| m[:content].include?('You are currently viewing the conversation') }
|
||||
expect(viewing_history).not_to be_nil
|
||||
expect(viewing_history[:content]).to include(conversation.display_id.to_s)
|
||||
expect(viewing_history[:content]).to include(contact.id.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'message persistence behavior' do
|
||||
context 'when copilot_thread is present' do
|
||||
it 'creates a copilot message with the response' do
|
||||
expect do
|
||||
described_class.new(assistant, { copilot_thread_id: copilot_thread.id }).generate_response('Hello')
|
||||
end.to change(CopilotMessage, :count).by(1)
|
||||
|
||||
last_message = CopilotMessage.last
|
||||
expect(last_message.message_type).to eq('assistant')
|
||||
expect(last_message.message['content']).to eq('Hey')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when copilot_thread is not present' do
|
||||
it 'does not create a copilot message' do
|
||||
expect do
|
||||
described_class.new(assistant, {}).generate_response('Hello')
|
||||
end.not_to(change(CopilotMessage, :count))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,155 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
let(:captain_assistant) { create(:captain_assistant) }
|
||||
let(:conversation) { create(:conversation, first_reply_created_at: Time.zone.now) }
|
||||
let(:service) { described_class.new(captain_assistant, conversation) }
|
||||
let(:embedding_service) { instance_double(Captain::Llm::EmbeddingService) }
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
let(:sample_faqs) do
|
||||
[
|
||||
{ 'question' => 'What is the purpose?', 'answer' => 'To help users.' },
|
||||
{ 'question' => 'How does it work?', 'answer' => 'Through AI.' }
|
||||
]
|
||||
end
|
||||
let(:mock_response) do
|
||||
instance_double(RubyLLM::Message, content: { faqs: sample_faqs }.to_json)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
allow(Captain::Llm::EmbeddingService).to receive(:new).and_return(embedding_service)
|
||||
allow(RubyLLM).to receive(:chat).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:with_temperature).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:with_params).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:with_instructions).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
|
||||
describe '#generate_and_deduplicate' do
|
||||
context 'when successful' do
|
||||
before do
|
||||
allow(embedding_service).to receive(:get_embedding).and_return([0.1, 0.2, 0.3])
|
||||
allow(captain_assistant.responses).to receive(:nearest_neighbors).and_return([])
|
||||
end
|
||||
|
||||
it 'creates new FAQs for valid conversation content' do
|
||||
expect do
|
||||
service.generate_and_deduplicate
|
||||
end.to change(captain_assistant.responses, :count).by(2)
|
||||
end
|
||||
|
||||
it 'saves FAQs with pending status linked to conversation' do
|
||||
service.generate_and_deduplicate
|
||||
expect(
|
||||
captain_assistant.responses.pluck(:question, :answer, :status, :documentable_id)
|
||||
).to contain_exactly(
|
||||
['What is the purpose?', 'To help users.', 'pending', conversation.id],
|
||||
['How does it work?', 'Through AI.', 'pending', conversation.id]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'without human interaction' do
|
||||
let(:conversation) { create(:conversation) }
|
||||
|
||||
it 'returns an empty array without generating FAQs' do
|
||||
expect(service.generate_and_deduplicate).to eq([])
|
||||
end
|
||||
|
||||
it 'does not call the LLM API' do
|
||||
expect(RubyLLM).not_to receive(:chat)
|
||||
service.generate_and_deduplicate
|
||||
end
|
||||
end
|
||||
|
||||
context 'when finding duplicates' do
|
||||
let(:existing_response) do
|
||||
create(:captain_assistant_response, assistant: captain_assistant, question: 'Similar question', answer: 'Similar answer')
|
||||
end
|
||||
let(:similar_neighbor) do
|
||||
OpenStruct.new(
|
||||
id: 1,
|
||||
question: existing_response.question,
|
||||
answer: existing_response.answer,
|
||||
neighbor_distance: 0.1
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(embedding_service).to receive(:get_embedding).and_return([0.1, 0.2, 0.3])
|
||||
allow(captain_assistant.responses).to receive(:nearest_neighbors).and_return([similar_neighbor])
|
||||
end
|
||||
|
||||
it 'filters out duplicate FAQs based on embedding similarity' do
|
||||
expect do
|
||||
service.generate_and_deduplicate
|
||||
end.not_to change(captain_assistant.responses, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when LLM API fails' do
|
||||
before do
|
||||
allow(mock_chat).to receive(:ask).and_raise(RubyLLM::Error.new(nil, 'API Error'))
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'returns empty array and logs the error' do
|
||||
expect(Rails.logger).to receive(:error).with('LLM API Error: API Error')
|
||||
expect(service.generate_and_deduplicate).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when JSON parsing fails' do
|
||||
let(:invalid_response) do
|
||||
instance_double(RubyLLM::Message, content: 'invalid json')
|
||||
end
|
||||
|
||||
before do
|
||||
allow(mock_chat).to receive(:ask).and_return(invalid_response)
|
||||
end
|
||||
|
||||
it 'handles JSON parsing errors gracefully' do
|
||||
expect(Rails.logger).to receive(:error).with(/Error in parsing GPT processed response:/)
|
||||
expect(service.generate_and_deduplicate).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when response content is nil' do
|
||||
let(:nil_response) do
|
||||
instance_double(RubyLLM::Message, content: nil)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(mock_chat).to receive(:ask).and_return(nil_response)
|
||||
end
|
||||
|
||||
it 'returns empty array' do
|
||||
expect(service.generate_and_deduplicate).to eq([])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'language handling' do
|
||||
context 'when conversation has different language' do
|
||||
let(:account) { create(:account, locale: 'fr') }
|
||||
let(:conversation) do
|
||||
create(:conversation, account: account, first_reply_created_at: Time.zone.now)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(embedding_service).to receive(:get_embedding).and_return([0.1, 0.2, 0.3])
|
||||
allow(captain_assistant.responses).to receive(:nearest_neighbors).and_return([])
|
||||
end
|
||||
|
||||
it 'uses account language for system prompt' do
|
||||
expect(Captain::Llm::SystemPromptsService).to receive(:conversation_faq_generator)
|
||||
.with('french')
|
||||
.at_least(:once)
|
||||
.and_call_original
|
||||
|
||||
service.generate_and_deduplicate
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,103 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Llm::FaqGeneratorService do
|
||||
let(:content) { 'Sample content for FAQ generation' }
|
||||
let(:language) { 'english' }
|
||||
let(:service) { described_class.new(content, language) }
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
let(:sample_faqs) do
|
||||
[
|
||||
{ 'question' => 'What is this service?', 'answer' => 'It generates FAQs.' },
|
||||
{ 'question' => 'How does it work?', 'answer' => 'Using AI technology.' }
|
||||
]
|
||||
end
|
||||
let(:mock_response) do
|
||||
instance_double(RubyLLM::Message, content: { faqs: sample_faqs }.to_json)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
allow(RubyLLM).to receive(:chat).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:with_temperature).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:with_params).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:with_instructions).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
|
||||
describe '#generate' do
|
||||
context 'when successful' do
|
||||
it 'returns parsed FAQs from the LLM response' do
|
||||
result = service.generate
|
||||
expect(result).to eq(sample_faqs)
|
||||
end
|
||||
|
||||
it 'sends content to LLM with JSON response format' do
|
||||
expect(mock_chat).to receive(:with_params).with(response_format: { type: 'json_object' }).and_return(mock_chat)
|
||||
service.generate
|
||||
end
|
||||
|
||||
it 'uses SystemPromptsService with the specified language' do
|
||||
expect(Captain::Llm::SystemPromptsService).to receive(:faq_generator).with(language).at_least(:once).and_call_original
|
||||
service.generate
|
||||
end
|
||||
end
|
||||
|
||||
context 'with different language' do
|
||||
let(:language) { 'spanish' }
|
||||
|
||||
it 'passes the correct language to SystemPromptsService' do
|
||||
expect(Captain::Llm::SystemPromptsService).to receive(:faq_generator).with('spanish').at_least(:once).and_call_original
|
||||
service.generate
|
||||
end
|
||||
end
|
||||
|
||||
context 'when LLM API fails' do
|
||||
before do
|
||||
allow(mock_chat).to receive(:ask).and_raise(RubyLLM::Error.new(nil, 'API Error'))
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'returns empty array and logs the error' do
|
||||
expect(Rails.logger).to receive(:error).with('LLM API Error: API Error')
|
||||
expect(service.generate).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when response content is nil' do
|
||||
let(:nil_response) { instance_double(RubyLLM::Message, content: nil) }
|
||||
|
||||
before do
|
||||
allow(mock_chat).to receive(:ask).and_return(nil_response)
|
||||
end
|
||||
|
||||
it 'returns empty array' do
|
||||
expect(service.generate).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when JSON parsing fails' do
|
||||
let(:invalid_response) { instance_double(RubyLLM::Message, content: 'invalid json') }
|
||||
|
||||
before do
|
||||
allow(mock_chat).to receive(:ask).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 faqs key' do
|
||||
let(:missing_key_response) { instance_double(RubyLLM::Message, content: '{"data": []}') }
|
||||
|
||||
before do
|
||||
allow(mock_chat).to receive(:ask).and_return(missing_key_response)
|
||||
end
|
||||
|
||||
it 'returns empty array via KeyError rescue' do
|
||||
expect(service.generate).to eq([])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,105 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Llm::PaginatedFaqGeneratorService do
|
||||
let(:document) { create(:captain_document) }
|
||||
let(:service) { described_class.new(document, pages_per_chunk: 5) }
|
||||
let(:openai_client) { instance_double(OpenAI::Client) }
|
||||
|
||||
before do
|
||||
# Mock OpenAI configuration
|
||||
installation_config = instance_double(InstallationConfig, value: 'test-api-key')
|
||||
allow(InstallationConfig).to receive(:find_by!)
|
||||
.with(name: 'CAPTAIN_OPEN_AI_API_KEY')
|
||||
.and_return(installation_config)
|
||||
|
||||
allow(OpenAI::Client).to receive(:new).and_return(openai_client)
|
||||
end
|
||||
|
||||
describe '#generate' do
|
||||
context 'when document lacks OpenAI file ID' do
|
||||
before do
|
||||
allow(document).to receive(:openai_file_id).and_return(nil)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.generate }.to raise_error(CustomExceptions::Pdf::FaqGenerationError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when generating FAQs from PDF pages' do
|
||||
let(:faq_response) do
|
||||
{
|
||||
'choices' => [{
|
||||
'message' => {
|
||||
'content' => JSON.generate({
|
||||
'faqs' => [
|
||||
{ 'question' => 'What is this document about?', 'answer' => 'It explains key concepts.' }
|
||||
],
|
||||
'has_content' => true
|
||||
})
|
||||
}
|
||||
}]
|
||||
}
|
||||
end
|
||||
|
||||
let(:empty_response) do
|
||||
{
|
||||
'choices' => [{
|
||||
'message' => {
|
||||
'content' => JSON.generate({
|
||||
'faqs' => [],
|
||||
'has_content' => false
|
||||
})
|
||||
}
|
||||
}]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(document).to receive(:openai_file_id).and_return('file-123')
|
||||
end
|
||||
|
||||
it 'generates FAQs from paginated content' do
|
||||
allow(openai_client).to receive(:chat).and_return(faq_response, empty_response)
|
||||
|
||||
faqs = service.generate
|
||||
|
||||
expect(faqs).to have_attributes(size: 1)
|
||||
expect(faqs.first['question']).to eq('What is this document about?')
|
||||
end
|
||||
|
||||
it 'stops when no more content' do
|
||||
allow(openai_client).to receive(:chat).and_return(empty_response)
|
||||
|
||||
faqs = service.generate
|
||||
|
||||
expect(faqs).to be_empty
|
||||
end
|
||||
|
||||
it 'respects max iterations limit' do
|
||||
allow(openai_client).to receive(:chat).and_return(faq_response)
|
||||
|
||||
# Force max iterations
|
||||
service.instance_variable_set(:@iterations_completed, 19)
|
||||
|
||||
service.generate
|
||||
expect(service.iterations_completed).to eq(20)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#should_continue_processing?' do
|
||||
it 'stops at max iterations' do
|
||||
service.instance_variable_set(:@iterations_completed, 20)
|
||||
expect(service.should_continue_processing?(faqs: ['faq'], has_content: true)).to be false
|
||||
end
|
||||
|
||||
it 'stops when no FAQs returned' do
|
||||
expect(service.should_continue_processing?(faqs: [], has_content: true)).to be false
|
||||
end
|
||||
|
||||
it 'continues when FAQs exist and under limits' do
|
||||
expect(service.should_continue_processing?(faqs: ['faq'], has_content: true)).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Llm::PdfProcessingService do
|
||||
let(:document) { create(:captain_document) }
|
||||
let(:service) { described_class.new(document) }
|
||||
|
||||
before do
|
||||
# Mock OpenAI configuration
|
||||
installation_config = instance_double(InstallationConfig, value: 'test-api-key')
|
||||
allow(InstallationConfig).to receive(:find_by!)
|
||||
.with(name: 'CAPTAIN_OPEN_AI_API_KEY')
|
||||
.and_return(installation_config)
|
||||
end
|
||||
|
||||
describe '#process' do
|
||||
context 'when document already has OpenAI file ID' do
|
||||
before do
|
||||
allow(document).to receive(:openai_file_id).and_return('existing-file-id')
|
||||
end
|
||||
|
||||
it 'skips upload' do
|
||||
expect(document).not_to receive(:store_openai_file_id)
|
||||
service.process
|
||||
end
|
||||
end
|
||||
|
||||
context 'when uploading PDF to OpenAI' do
|
||||
let(:mock_client) { instance_double(OpenAI::Client) }
|
||||
let(:pdf_content) { 'PDF content' }
|
||||
let(:blob_double) { instance_double(ActiveStorage::Blob) }
|
||||
let(:pdf_file) { instance_double(ActiveStorage::Attachment) }
|
||||
|
||||
before do
|
||||
allow(document).to receive(:openai_file_id).and_return(nil)
|
||||
allow(document).to receive(:pdf_file).and_return(pdf_file)
|
||||
allow(pdf_file).to receive(:blob).and_return(blob_double)
|
||||
allow(blob_double).to receive(:open).and_yield(StringIO.new(pdf_content))
|
||||
|
||||
allow(OpenAI::Client).to receive(:new).and_return(mock_client)
|
||||
# Use a simple double for OpenAI::Files as it may not be loaded
|
||||
files_api = double('files_api') # rubocop:disable RSpec/VerifiedDoubles
|
||||
allow(files_api).to receive(:upload).and_return({ 'id' => 'file-abc123' })
|
||||
allow(mock_client).to receive(:files).and_return(files_api)
|
||||
end
|
||||
|
||||
it 'uploads PDF and stores file ID' do
|
||||
expect(document).to receive(:store_openai_file_id).with('file-abc123')
|
||||
service.process
|
||||
end
|
||||
|
||||
it 'raises error when upload fails' do
|
||||
allow(mock_client.files).to receive(:upload).and_return({ 'id' => nil })
|
||||
|
||||
expect { service.process }.to raise_error(CustomExceptions::Pdf::UploadError)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,138 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
|
||||
let(:website_url) { 'https://example.com' }
|
||||
let(:service) { described_class.new(website_url) }
|
||||
let(:mock_crawler) { instance_double(Captain::Tools::SimplePageCrawlService) }
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
let(:business_info) do
|
||||
{
|
||||
'business_name' => 'Example Corp',
|
||||
'suggested_assistant_name' => 'Alex from Example Corp',
|
||||
'description' => 'You specialize in helping customers with business solutions and support'
|
||||
}
|
||||
end
|
||||
let(:mock_response) do
|
||||
instance_double(RubyLLM::Message, content: business_info.to_json)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
allow(Captain::Tools::SimplePageCrawlService).to receive(:new).and_return(mock_crawler)
|
||||
allow(RubyLLM).to receive(:chat).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:with_temperature).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:with_params).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:with_instructions).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
|
||||
describe '#analyze' do
|
||||
context 'when website content is available and LLM call is successful' do
|
||||
before do
|
||||
allow(mock_crawler).to receive(:body_text_content).and_return('Welcome to Example Corp')
|
||||
allow(mock_crawler).to receive(:page_title).and_return('Example Corp - Home')
|
||||
allow(mock_crawler).to receive(:meta_description).and_return('Leading provider of business solutions')
|
||||
allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico')
|
||||
end
|
||||
|
||||
it 'returns successful analysis with extracted business info' do
|
||||
result = service.analyze
|
||||
|
||||
expect(result[:success]).to be true
|
||||
expect(result[:data]).to include(
|
||||
business_name: 'Example Corp',
|
||||
suggested_assistant_name: 'Alex from Example Corp',
|
||||
description: 'You specialize in helping customers with business solutions and support',
|
||||
website_url: website_url,
|
||||
favicon_url: 'https://example.com/favicon.ico'
|
||||
)
|
||||
end
|
||||
|
||||
it 'uses low temperature for deterministic analysis' do
|
||||
expect(mock_chat).to receive(:with_temperature).with(0.1).and_return(mock_chat)
|
||||
service.analyze
|
||||
end
|
||||
end
|
||||
|
||||
context 'when website content fetch raises an error' do
|
||||
before do
|
||||
allow(mock_crawler).to receive(:body_text_content).and_raise(StandardError, 'Network error')
|
||||
end
|
||||
|
||||
it 'returns error response' do
|
||||
result = service.analyze
|
||||
|
||||
expect(result[:success]).to be false
|
||||
expect(result[:error]).to eq('Failed to fetch website content')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when website content is empty' do
|
||||
before do
|
||||
allow(mock_crawler).to receive(:body_text_content).and_return('')
|
||||
allow(mock_crawler).to receive(:page_title).and_return('')
|
||||
allow(mock_crawler).to receive(:meta_description).and_return('')
|
||||
end
|
||||
|
||||
it 'returns error for unavailable content' do
|
||||
result = service.analyze
|
||||
|
||||
expect(result[:success]).to be false
|
||||
expect(result[:error]).to eq('Failed to fetch website content')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when LLM call fails' do
|
||||
before do
|
||||
allow(mock_crawler).to receive(:body_text_content).and_return('Welcome to Example Corp')
|
||||
allow(mock_crawler).to receive(:page_title).and_return('Example Corp - Home')
|
||||
allow(mock_crawler).to receive(:meta_description).and_return('Leading provider of business solutions')
|
||||
allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico')
|
||||
allow(mock_chat).to receive(:ask).and_raise(StandardError, 'API error')
|
||||
end
|
||||
|
||||
it 'returns error response with message' do
|
||||
result = service.analyze
|
||||
|
||||
expect(result[:success]).to be false
|
||||
expect(result[:error]).to eq('API error')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when LLM returns invalid JSON' do
|
||||
let(:invalid_response) { instance_double(RubyLLM::Message, content: 'not valid json') }
|
||||
|
||||
before do
|
||||
allow(mock_crawler).to receive(:body_text_content).and_return('Welcome to Example Corp')
|
||||
allow(mock_crawler).to receive(:page_title).and_return('Example Corp - Home')
|
||||
allow(mock_crawler).to receive(:meta_description).and_return('Leading provider of business solutions')
|
||||
allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico')
|
||||
allow(mock_chat).to receive(:ask).and_return(invalid_response)
|
||||
end
|
||||
|
||||
it 'returns error for parsing failure' do
|
||||
result = service.analyze
|
||||
|
||||
expect(result[:success]).to be false
|
||||
expect(result[:error]).to eq('Failed to parse business information from website')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when URL normalization is needed' do
|
||||
let(:website_url) { 'example.com' }
|
||||
|
||||
before do
|
||||
allow(mock_crawler).to receive(:body_text_content).and_return('Welcome')
|
||||
allow(mock_crawler).to receive(:page_title).and_return('Example')
|
||||
allow(mock_crawler).to receive(:meta_description).and_return('Description')
|
||||
allow(mock_crawler).to receive(:favicon_url).and_return(nil)
|
||||
end
|
||||
|
||||
it 'normalizes URL by adding https prefix' do
|
||||
result = service.analyze
|
||||
|
||||
expect(result[:data][:website_url]).to eq('https://example.com')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,310 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::OpenAiMessageBuilderService do
|
||||
subject(:service) { described_class.new(message: message) }
|
||||
|
||||
let(:message) { create(:message, content: 'Hello world') }
|
||||
|
||||
describe '#generate_content' do
|
||||
context 'when message has only text content' do
|
||||
it 'returns the text content directly' do
|
||||
expect(service.generate_content).to eq('Hello world')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message has no content and no attachments' do
|
||||
let(:message) { create(:message, content: nil) }
|
||||
|
||||
it 'returns default message' do
|
||||
expect(service.generate_content).to eq('Message without content')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message has text content and attachments' do
|
||||
before do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
|
||||
attachment.save!
|
||||
end
|
||||
|
||||
it 'returns an array of content parts' do
|
||||
result = service.generate_content
|
||||
expect(result).to be_an(Array)
|
||||
expect(result).to include({ type: 'text', text: 'Hello world' })
|
||||
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message has only non-text attachments' do
|
||||
let(:message) { create(:message, content: nil) }
|
||||
|
||||
before do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
|
||||
attachment.save!
|
||||
end
|
||||
|
||||
it 'returns an array of content parts without text' do
|
||||
result = service.generate_content
|
||||
expect(result).to be_an(Array)
|
||||
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
|
||||
expect(result).not_to include(hash_including(type: 'text', text: 'Hello world'))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#attachment_parts' do
|
||||
let(:message) { create(:message, content: nil) }
|
||||
let(:attachments) { message.attachments }
|
||||
|
||||
context 'with image attachments' do
|
||||
before do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
|
||||
attachment.save!
|
||||
end
|
||||
|
||||
it 'includes image parts' do
|
||||
result = service.send(:attachment_parts, attachments)
|
||||
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
|
||||
end
|
||||
end
|
||||
|
||||
context 'with audio attachments' do
|
||||
let(:audio_attachment) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
|
||||
instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Audio transcription text' })
|
||||
)
|
||||
end
|
||||
|
||||
it 'includes transcription text part' do
|
||||
audio_attachment # trigger creation
|
||||
result = service.send(:attachment_parts, attachments)
|
||||
expect(result).to include({ type: 'text', text: 'Audio transcription text' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'with other file types' do
|
||||
before do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :file)
|
||||
attachment.save!
|
||||
end
|
||||
|
||||
it 'includes generic attachment message' do
|
||||
result = service.send(:attachment_parts, attachments)
|
||||
expect(result).to include({ type: 'text', text: 'User has shared an attachment' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'with mixed attachment types' do
|
||||
let(:image_attachment) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
let(:audio_attachment) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
let(:document_attachment) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :file)
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
|
||||
instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Audio text' })
|
||||
)
|
||||
end
|
||||
|
||||
it 'includes all relevant parts' do
|
||||
image_attachment # trigger creation
|
||||
audio_attachment # trigger creation
|
||||
document_attachment # trigger creation
|
||||
|
||||
result = service.send(:attachment_parts, attachments)
|
||||
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
|
||||
expect(result).to include({ type: 'text', text: 'Audio text' })
|
||||
expect(result).to include({ type: 'text', text: 'User has shared an attachment' })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#image_parts' do
|
||||
let(:message) { create(:message, content: nil) }
|
||||
|
||||
context 'with valid image attachments' do
|
||||
let(:image1) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image1.jpg')
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
let(:image2) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image2.jpg')
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
it 'returns image parts for all valid images' do
|
||||
image1 # trigger creation
|
||||
image2 # trigger creation
|
||||
|
||||
image_attachments = message.attachments.where(file_type: :image)
|
||||
result = service.send(:image_parts, image_attachments)
|
||||
|
||||
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image1.jpg' } })
|
||||
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image2.jpg' } })
|
||||
end
|
||||
end
|
||||
|
||||
context 'with image attachments without URLs' do
|
||||
let(:image_attachment) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: nil)
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
before do
|
||||
allow(image_attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: false))
|
||||
end
|
||||
|
||||
it 'skips images without valid URLs' do
|
||||
image_attachment # trigger creation
|
||||
|
||||
image_attachments = message.attachments.where(file_type: :image)
|
||||
result = service.send(:image_parts, image_attachments)
|
||||
|
||||
expect(result).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#get_attachment_url' do
|
||||
let(:attachment) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :image)
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
context 'when attachment has external_url' do
|
||||
before { attachment.update(external_url: 'https://example.com/image.jpg') }
|
||||
|
||||
it 'returns external_url' do
|
||||
expect(service.send(:get_attachment_url, attachment)).to eq('https://example.com/image.jpg')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when attachment has attached file' do
|
||||
before do
|
||||
attachment.update(external_url: nil)
|
||||
allow(attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: true))
|
||||
allow(attachment).to receive(:file_url).and_return('https://local.com/file.jpg')
|
||||
allow(attachment).to receive(:download_url).and_return('')
|
||||
end
|
||||
|
||||
it 'returns file_url' do
|
||||
expect(service.send(:get_attachment_url, attachment)).to eq('https://local.com/file.jpg')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when attachment has no URL or file' do
|
||||
before do
|
||||
attachment.update(external_url: nil)
|
||||
allow(attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: false))
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
expect(service.send(:get_attachment_url, attachment)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#extract_audio_transcriptions' do
|
||||
let(:message) { create(:message, content: nil) }
|
||||
|
||||
context 'with no audio attachments' do
|
||||
it 'returns empty string' do
|
||||
result = service.send(:extract_audio_transcriptions, message.attachments)
|
||||
expect(result).to eq('')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with successful audio transcriptions' do
|
||||
let(:audio1) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
let(:audio2) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio1).and_return(
|
||||
instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'First audio text. ' })
|
||||
)
|
||||
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio2).and_return(
|
||||
instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Second audio text.' })
|
||||
)
|
||||
end
|
||||
|
||||
it 'concatenates all successful transcriptions' do
|
||||
audio1 # trigger creation
|
||||
audio2 # trigger creation
|
||||
|
||||
attachments = message.attachments
|
||||
result = service.send(:extract_audio_transcriptions, attachments)
|
||||
expect(result).to eq('First audio text. Second audio text.')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with failed audio transcriptions' do
|
||||
let(:audio_attachment) do
|
||||
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
|
||||
attachment.save!
|
||||
attachment
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
|
||||
instance_double(Messages::AudioTranscriptionService, perform: { success: false, transcriptions: nil })
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns empty string for failed transcriptions' do
|
||||
audio_attachment # trigger creation
|
||||
|
||||
attachments = message.attachments
|
||||
result = service.send(:extract_audio_transcriptions, attachments)
|
||||
expect(result).to eq('')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'private helper methods' do
|
||||
describe '#text_part' do
|
||||
it 'returns correct text part format' do
|
||||
result = service.send(:text_part, 'Hello world')
|
||||
expect(result).to eq({ type: 'text', text: 'Hello world' })
|
||||
end
|
||||
end
|
||||
|
||||
describe '#image_part' do
|
||||
it 'returns correct image part format' do
|
||||
result = service.send(:image_part, 'https://example.com/image.jpg')
|
||||
expect(result).to eq({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
154
spec/enterprise/services/captain/tool_registry_service_spec.rb
Normal file
154
spec/enterprise/services/captain/tool_registry_service_spec.rb
Normal file
@@ -0,0 +1,154 @@
|
||||
require 'rails_helper'
|
||||
|
||||
# Test tool implementation
|
||||
class TestTool < Captain::Tools::BaseService
|
||||
attr_accessor :tool_active
|
||||
|
||||
def initialize(assistant, user: nil)
|
||||
super
|
||||
@tool_active = true
|
||||
end
|
||||
|
||||
def name
|
||||
'test_tool'
|
||||
end
|
||||
|
||||
def description
|
||||
'A test tool for specs'
|
||||
end
|
||||
|
||||
def parameters
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
test_param: {
|
||||
type: 'string'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def execute(*args)
|
||||
args
|
||||
end
|
||||
|
||||
def active?
|
||||
@tool_active
|
||||
end
|
||||
end
|
||||
|
||||
RSpec.describe Captain::ToolRegistryService do
|
||||
let(:assistant) { create(:captain_assistant) }
|
||||
let(:service) { described_class.new(assistant) }
|
||||
|
||||
describe '#initialize' do
|
||||
it 'initializes with empty tools and registered_tools' do
|
||||
expect(service.tools).to be_empty
|
||||
expect(service.registered_tools).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
describe '#register_tool' do
|
||||
let(:tool_class) { TestTool }
|
||||
|
||||
context 'when tool is active' do
|
||||
it 'registers a new tool' do
|
||||
service.register_tool(tool_class)
|
||||
expect(service.tools['test_tool']).to be_a(TestTool)
|
||||
expect(service.registered_tools).to include(
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'test_tool',
|
||||
description: 'A test tool for specs',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
test_param: {
|
||||
type: 'string'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when tool is inactive' do
|
||||
it 'does not register the tool' do
|
||||
tool = tool_class.new(assistant)
|
||||
tool.tool_active = false
|
||||
allow(tool_class).to receive(:new).and_return(tool)
|
||||
|
||||
service.register_tool(tool_class)
|
||||
|
||||
expect(service.tools['test_tool']).to be_nil
|
||||
expect(service.registered_tools).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'method_missing' do
|
||||
let(:tool_class) { TestTool }
|
||||
|
||||
before do
|
||||
service.register_tool(tool_class)
|
||||
end
|
||||
|
||||
context 'when method corresponds to a registered tool' do
|
||||
it 'executes the tool with given arguments' do
|
||||
result = service.test_tool(test_param: 'arg1')
|
||||
expect(result).to eq([{ test_param: 'arg1' }])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when method does not correspond to a registered tool' do
|
||||
it 'raises NoMethodError' do
|
||||
expect { service.unknown_tool }.to raise_error(NoMethodError)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#tools_summary' do
|
||||
let(:tool_class) { TestTool }
|
||||
|
||||
before do
|
||||
service.register_tool(tool_class)
|
||||
end
|
||||
|
||||
it 'returns formatted summary of registered tools' do
|
||||
expect(service.tools_summary).to eq('- test_tool: A test tool for specs')
|
||||
end
|
||||
|
||||
context 'when multiple tools are registered' do
|
||||
let(:another_tool_class) do
|
||||
Class.new(Captain::Tools::BaseService) do
|
||||
def name
|
||||
'another_tool'
|
||||
end
|
||||
|
||||
def description
|
||||
'Another test tool'
|
||||
end
|
||||
|
||||
def parameters
|
||||
{
|
||||
type: 'object',
|
||||
properties: {}
|
||||
}
|
||||
end
|
||||
|
||||
def active?
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'includes all tools in the summary' do
|
||||
service.register_tool(another_tool_class)
|
||||
expect(service.tools_summary).to eq("- test_tool: A test tool for specs\n- another_tool: Another test tool")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,101 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::Copilot::GetArticleService do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:service) { described_class.new(assistant, user: user) }
|
||||
|
||||
describe '#name' do
|
||||
it 'returns the correct service name' do
|
||||
expect(service.name).to eq('get_article')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#description' do
|
||||
it 'returns the service description' do
|
||||
expect(service.description).to eq('Get details of an article including its content and metadata')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#parameters' do
|
||||
it 'defines article_id parameter' do
|
||||
expect(service.parameters.keys).to contain_exactly(:article_id)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
context 'when user is an admin' do
|
||||
let(:user) { create(:user, :administrator, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has custom role with knowledge_base_manage permission' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
|
||||
|
||||
before do
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
account_user.update(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has custom role without knowledge_base_manage permission' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: []) }
|
||||
|
||||
before do
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
account_user.update(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.active?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
context 'when article_id is blank' do
|
||||
it 'returns error message' do
|
||||
expect(service.execute(article_id: nil)).to eq('Article not found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when article is not found' do
|
||||
it 'returns not found message' do
|
||||
expect(service.execute(article_id: 999)).to eq('Article not found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when article exists' do
|
||||
let(:portal) { create(:portal, account: account) }
|
||||
let(:article) { create(:article, account: account, portal: portal, author: user, title: 'Test Article', content: 'Content') }
|
||||
|
||||
it 'returns the article in llm text format' do
|
||||
result = service.execute(article_id: article.id)
|
||||
expect(result).to eq(article.to_llm_text)
|
||||
end
|
||||
|
||||
context 'when article belongs to different account' do
|
||||
let(:other_account) { create(:account) }
|
||||
let(:other_portal) { create(:portal, account: other_account) }
|
||||
let(:other_article) { create(:article, account: other_account, portal: other_portal, author: user, title: 'Other Article') }
|
||||
|
||||
it 'returns not found message' do
|
||||
expect(service.execute(article_id: other_article.id)).to eq('Article not found')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,99 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::Copilot::GetContactService do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:service) { described_class.new(assistant, user: user) }
|
||||
|
||||
describe '#name' do
|
||||
it 'returns the correct service name' do
|
||||
expect(service.name).to eq('get_contact')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#description' do
|
||||
it 'returns the service description' do
|
||||
expect(service.description).to eq('Get details of a contact including their profile information')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#parameters' do
|
||||
it 'defines contact_id parameter' do
|
||||
expect(service.parameters.keys).to contain_exactly(:contact_id)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
context 'when user is an admin' do
|
||||
let(:user) { create(:user, :administrator, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has custom role with contact_manage permission' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: ['contact_manage']) }
|
||||
|
||||
before do
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
account_user.update(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has custom role without contact_manage permission' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: []) }
|
||||
|
||||
before do
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
account_user.update(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.active?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
context 'when contact_id is blank' do
|
||||
it 'returns not found message' do
|
||||
expect(service.execute(contact_id: nil)).to eq('Contact not found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact is not found' do
|
||||
it 'returns not found message' do
|
||||
expect(service.execute(contact_id: 999)).to eq('Contact not found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact exists' do
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
|
||||
it 'returns the contact in llm text format' do
|
||||
result = service.execute(contact_id: contact.id)
|
||||
expect(result).to eq(contact.to_llm_text)
|
||||
end
|
||||
|
||||
context 'when contact belongs to different account' do
|
||||
let(:other_account) { create(:account) }
|
||||
let(:other_contact) { create(:contact, account: other_account) }
|
||||
|
||||
it 'returns not found message' do
|
||||
expect(service.execute(contact_id: other_contact.id)).to eq('Contact not found')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,148 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::Copilot::GetConversationService do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:service) { described_class.new(assistant, user: user) }
|
||||
|
||||
describe '#name' do
|
||||
it 'returns the correct service name' do
|
||||
expect(service.name).to eq('get_conversation')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#description' do
|
||||
it 'returns the service description' do
|
||||
expect(service.description).to eq('Get details of a conversation including messages and contact information')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#parameters' do
|
||||
it 'defines conversation_id parameter' do
|
||||
expect(service.parameters.keys).to contain_exactly(:conversation_id)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
context 'when user is an admin' do
|
||||
let(:user) { create(:user, :administrator, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has custom role with conversation_manage permission' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_manage']) }
|
||||
|
||||
before do
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
account_user.update(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has custom role with conversation_unassigned_manage permission' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_unassigned_manage']) }
|
||||
|
||||
before do
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
account_user.update(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has custom role with conversation_participating_manage permission' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_participating_manage']) }
|
||||
|
||||
before do
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
account_user.update(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has custom role without any conversation permissions' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: []) }
|
||||
|
||||
before do
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
account_user.update(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.active?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
context 'when conversation is not found' do
|
||||
it 'returns not found message' do
|
||||
expect(service.execute(conversation_id: 999)).to eq('Conversation not found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation exists' do
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
|
||||
it 'returns the conversation in llm text format' do
|
||||
result = service.execute(conversation_id: conversation.display_id)
|
||||
expect(result).to eq(conversation.to_llm_text)
|
||||
end
|
||||
|
||||
it 'includes private messages in the llm text format' do
|
||||
# Create a regular message
|
||||
create(:message,
|
||||
conversation: conversation,
|
||||
message_type: 'outgoing',
|
||||
content: 'Regular message',
|
||||
private: false)
|
||||
|
||||
# Create a private message
|
||||
create(:message,
|
||||
conversation: conversation,
|
||||
message_type: 'outgoing',
|
||||
content: 'Private note content',
|
||||
private: true)
|
||||
|
||||
result = service.execute(conversation_id: conversation.display_id)
|
||||
|
||||
# Verify that the result includes both regular and private messages
|
||||
expect(result).to include('Regular message')
|
||||
expect(result).to include('Private note content')
|
||||
expect(result).to include('[Private Note]')
|
||||
end
|
||||
|
||||
context 'when conversation belongs to different account' do
|
||||
let(:other_account) { create(:account) }
|
||||
let(:other_inbox) { create(:inbox, account: other_account) }
|
||||
let(:other_conversation) { create(:conversation, account: other_account, inbox: other_inbox) }
|
||||
|
||||
it 'returns not found message' do
|
||||
expect(service.execute(conversation_id: other_conversation.display_id)).to eq('Conversation not found')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,115 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::Copilot::SearchArticlesService do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:service) { described_class.new(assistant, user: user) }
|
||||
|
||||
describe '#name' do
|
||||
it 'returns the correct service name' do
|
||||
expect(service.name).to eq('search_articles')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#description' do
|
||||
it 'returns the service description' do
|
||||
expect(service.description).to eq('Search articles based on parameters')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
context 'when user is an admin' do
|
||||
let(:user) { create(:user, :administrator, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is an agent' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has custom role with knowledge_base_manage permission' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
|
||||
|
||||
before do
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
account_user.update(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has custom role without knowledge_base_manage permission' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: []) }
|
||||
|
||||
before do
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
account_user.update(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.active?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
context 'when no articles are found' do
|
||||
it 'returns no articles found message' do
|
||||
expect(service.execute(query: 'test', category_id: nil, status: nil)).to eq('No articles found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when articles are found' do
|
||||
let(:portal) { create(:portal, account: account) }
|
||||
let!(:article1) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 1', content: 'Content 1') }
|
||||
let!(:article2) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 2', content: 'Content 2') }
|
||||
|
||||
it 'returns formatted articles with count' do
|
||||
result = service.execute(query: 'Test', category_id: nil, status: nil)
|
||||
expect(result).to include('Total number of articles: 2')
|
||||
expect(result).to include(article1.to_llm_text)
|
||||
expect(result).to include(article2.to_llm_text)
|
||||
end
|
||||
|
||||
context 'when filtered by category' do
|
||||
let(:category) { create(:category, slug: 'test-category', portal: portal, account: account) }
|
||||
let!(:article3) { create(:article, account: account, portal: portal, author: user, category: category, title: 'Test Article 3') }
|
||||
|
||||
it 'returns only articles from the specified category' do
|
||||
result = service.execute(query: 'Test', category_id: category.id, status: nil)
|
||||
expect(result).to include('Total number of articles: 1')
|
||||
expect(result).to include(article3.to_llm_text)
|
||||
expect(result).not_to include(article1.to_llm_text)
|
||||
expect(result).not_to include(article2.to_llm_text)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filtered by status' do
|
||||
let!(:article3) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 3', status: 'published') }
|
||||
let!(:article4) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 4', status: 'draft') }
|
||||
|
||||
it 'returns only articles with the specified status' do
|
||||
result = service.execute(query: 'Test', category_id: nil, status: 'published')
|
||||
expect(result).to include(article3.to_llm_text)
|
||||
expect(result).not_to include(article4.to_llm_text)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,94 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::Copilot::SearchContactsService do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:service) { described_class.new(assistant, user: user) }
|
||||
|
||||
describe '#name' do
|
||||
it 'returns the correct service name' do
|
||||
expect(service.name).to eq('search_contacts')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#description' do
|
||||
it 'returns the service description' do
|
||||
expect(service.description).to eq('Search contacts based on query parameters')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#parameters' do
|
||||
it 'defines email, phone_number, and name parameters' do
|
||||
expect(service.parameters.keys).to contain_exactly(:email, :phone_number, :name)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
context 'when user has contact_manage permission' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: ['contact_manage']) }
|
||||
|
||||
before do
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
account_user.update(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user does not have contact_manage permission' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: []) }
|
||||
|
||||
before do
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
account_user.update(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.active?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
context 'when contacts are found' do
|
||||
let(:contact1) { create(:contact, account: account, email: 'test1@example.com', name: 'Test Contact 1', phone_number: '+1234567890') }
|
||||
let(:contact2) { create(:contact, account: account, email: 'test2@example.com', name: 'Test Contact 2', phone_number: '+1234567891') }
|
||||
|
||||
before do
|
||||
contact1
|
||||
contact2
|
||||
end
|
||||
|
||||
it 'returns contacts when filtered by email' do
|
||||
result = service.execute(email: 'test1@example.com')
|
||||
expect(result).to include(contact1.to_llm_text)
|
||||
expect(result).not_to include(contact2.to_llm_text)
|
||||
end
|
||||
|
||||
it 'returns contacts when filtered by phone number' do
|
||||
result = service.execute(phone_number: '+1234567890')
|
||||
expect(result).to include(contact1.to_llm_text)
|
||||
expect(result).not_to include(contact2.to_llm_text)
|
||||
end
|
||||
|
||||
it 'returns contacts when filtered by name' do
|
||||
result = service.execute(name: 'Contact 1')
|
||||
expect(result).to include(contact1.to_llm_text)
|
||||
expect(result).not_to include(contact2.to_llm_text)
|
||||
end
|
||||
|
||||
it 'returns all matching contacts when no filters are provided' do
|
||||
result = service.execute
|
||||
expect(result).to include(contact1.to_llm_text)
|
||||
expect(result).to include(contact2.to_llm_text)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,160 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::Copilot::SearchConversationsService do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, role: 'administrator', account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:service) { described_class.new(assistant, user: user) }
|
||||
|
||||
describe '#name' do
|
||||
it 'returns the correct service name' do
|
||||
expect(service.name).to eq('search_conversation')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#description' do
|
||||
it 'returns the service description' do
|
||||
expect(service.description).to eq('Search conversations based on parameters')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#parameters' do
|
||||
it 'defines the expected parameters' do
|
||||
expect(service.parameters.keys).to contain_exactly(:status, :contact_id, :priority, :labels)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
context 'when user has conversation_manage permission' do
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_manage']) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
|
||||
before do
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
account_user.update(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has conversation_unassigned_manage permission' do
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_unassigned_manage']) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
|
||||
before do
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
account_user.update(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has conversation_participating_manage permission' do
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_participating_manage']) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
|
||||
before do
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
account_user.update(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has no relevant conversation permissions' do
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: []) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
|
||||
before do
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
account_user.update(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.active?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let!(:open_conversation) { create(:conversation, account: account, contact: contact, status: 'open', priority: 'high') }
|
||||
let!(:resolved_conversation) { create(:conversation, account: account, status: 'resolved', priority: 'low') }
|
||||
|
||||
it 'returns all conversations when no filters are applied' do
|
||||
result = service.execute
|
||||
expect(result).to include('Total number of conversations: 2')
|
||||
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
|
||||
expect(result).to include(resolved_conversation.to_llm_text(include_contact_details: true))
|
||||
end
|
||||
|
||||
it 'filters conversations by status' do
|
||||
result = service.execute(status: 'open')
|
||||
expect(result).to include('Total number of conversations: 1')
|
||||
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
|
||||
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
|
||||
end
|
||||
|
||||
it 'filters conversations by contact_id' do
|
||||
result = service.execute(contact_id: contact.id)
|
||||
expect(result).to include('Total number of conversations: 1')
|
||||
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
|
||||
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
|
||||
end
|
||||
|
||||
it 'filters conversations by priority' do
|
||||
result = service.execute(priority: 'high')
|
||||
expect(result).to include('Total number of conversations: 1')
|
||||
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
|
||||
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
|
||||
end
|
||||
|
||||
it 'returns appropriate message when no conversations are found' do
|
||||
result = service.execute(status: 'snoozed')
|
||||
expect(result).to eq('No conversations found')
|
||||
end
|
||||
|
||||
context 'when invalid status is provided' do
|
||||
it 'ignores invalid status and returns all conversations' do
|
||||
result = service.execute(status: 'all')
|
||||
expect(result).to include('Total number of conversations: 2')
|
||||
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
|
||||
expect(result).to include(resolved_conversation.to_llm_text(include_contact_details: true))
|
||||
end
|
||||
|
||||
it 'ignores random invalid status values' do
|
||||
result = service.execute(status: 'invalid_status')
|
||||
expect(result).to include('Total number of conversations: 2')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when invalid priority is provided' do
|
||||
it 'ignores invalid priority and returns all conversations' do
|
||||
result = service.execute(priority: 'all')
|
||||
expect(result).to include('Total number of conversations: 2')
|
||||
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
|
||||
expect(result).to include(resolved_conversation.to_llm_text(include_contact_details: true))
|
||||
end
|
||||
|
||||
it 'ignores random invalid priority values' do
|
||||
result = service.execute(priority: 'invalid_priority')
|
||||
expect(result).to include('Total number of conversations: 2')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when combining valid and invalid parameters' do
|
||||
it 'applies valid filters and ignores invalid ones' do
|
||||
result = service.execute(status: 'all', contact_id: contact.id)
|
||||
expect(result).to include('Total number of conversations: 1')
|
||||
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
|
||||
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,139 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:service) { described_class.new(assistant, user: user) }
|
||||
|
||||
describe '#name' do
|
||||
it 'returns the correct service name' do
|
||||
expect(service.name).to eq('search_linear_issues')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#description' do
|
||||
it 'returns the service description' do
|
||||
expect(service.description).to eq('Search Linear issues based on a search term')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#parameters' do
|
||||
it 'defines term parameter' do
|
||||
expect(service.parameters.keys).to contain_exactly(:term)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
context 'when Linear integration is enabled' do
|
||||
before do
|
||||
create(:integrations_hook, :linear, account: account)
|
||||
end
|
||||
|
||||
context 'when user is present' do
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not present' do
|
||||
let(:service) { described_class.new(assistant) }
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.active?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear integration is not enabled' do
|
||||
context 'when user is present' do
|
||||
it 'returns false' do
|
||||
expect(service.active?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not present' do
|
||||
let(:service) { described_class.new(assistant) }
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.active?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
context 'when Linear integration is not enabled' do
|
||||
it 'returns error message' do
|
||||
expect(service.execute(term: 'test')).to eq('Linear integration is not enabled')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear integration is enabled' do
|
||||
let(:linear_service) { instance_double(Integrations::Linear::ProcessorService) }
|
||||
|
||||
before do
|
||||
create(:integrations_hook, :linear, account: account)
|
||||
allow(Integrations::Linear::ProcessorService).to receive(:new).and_return(linear_service)
|
||||
end
|
||||
|
||||
context 'when term is blank' do
|
||||
before do
|
||||
allow(linear_service).to receive(:search_issue).with('').and_return({ data: [] })
|
||||
end
|
||||
|
||||
it 'returns no issues found message' do
|
||||
expect(service.execute(term: '')).to eq('No issues found, I should try another similar search term')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when search returns error' do
|
||||
before do
|
||||
allow(linear_service).to receive(:search_issue).and_return({ error: 'API Error' })
|
||||
end
|
||||
|
||||
it 'returns the error message' do
|
||||
expect(service.execute(term: 'test')).to eq('API Error')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when search returns no issues' do
|
||||
before do
|
||||
allow(linear_service).to receive(:search_issue).and_return({ data: [] })
|
||||
end
|
||||
|
||||
it 'returns no issues found message' do
|
||||
expect(service.execute(term: 'test')).to eq('No issues found, I should try another similar search term')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when search returns issues' do
|
||||
let(:issues) do
|
||||
[{
|
||||
'title' => 'Test Issue',
|
||||
'id' => 'TEST-123',
|
||||
'state' => { 'name' => 'In Progress' },
|
||||
'priority' => 4,
|
||||
'assignee' => { 'name' => 'John Doe' },
|
||||
'description' => 'Test description'
|
||||
}]
|
||||
end
|
||||
|
||||
before do
|
||||
allow(linear_service).to receive(:search_issue).and_return({ data: issues })
|
||||
end
|
||||
|
||||
it 'returns formatted issues' do
|
||||
result = service.execute(term: 'test')
|
||||
expect(result).to include('Total number of issues: 1')
|
||||
expect(result).to include('Title: Test Issue')
|
||||
expect(result).to include('ID: TEST-123')
|
||||
expect(result).to include('State: In Progress')
|
||||
expect(result).to include('Priority: Low')
|
||||
expect(result).to include('Assignee: John Doe')
|
||||
expect(result).to include('Description: Test description')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
144
spec/enterprise/services/captain/tools/firecrawl_service_spec.rb
Normal file
144
spec/enterprise/services/captain/tools/firecrawl_service_spec.rb
Normal file
@@ -0,0 +1,144 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::FirecrawlService do
|
||||
let(:api_key) { 'test-api-key' }
|
||||
let(:url) { 'https://example.com' }
|
||||
let(:webhook_url) { 'https://webhook.example.com/callback' }
|
||||
let(:crawl_limit) { 15 }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
|
||||
end
|
||||
|
||||
describe '#initialize' do
|
||||
context 'when API key is configured' do
|
||||
it 'initializes successfully' do
|
||||
expect { described_class.new }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API key is missing' do
|
||||
before do
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY').destroy
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { described_class.new }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API key is nil' do
|
||||
before do
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY').update(value: nil)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { described_class.new }.to raise_error(NoMethodError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API key is empty' do
|
||||
before do
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY').update(value: '')
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { described_class.new }.to raise_error('Missing API key')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
let(:service) { described_class.new }
|
||||
let(:expected_payload) do
|
||||
{
|
||||
url: url,
|
||||
maxDepth: 50,
|
||||
ignoreSitemap: false,
|
||||
limit: crawl_limit,
|
||||
webhook: webhook_url,
|
||||
scrapeOptions: {
|
||||
onlyMainContent: false,
|
||||
formats: ['markdown'],
|
||||
excludeTags: ['iframe']
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
|
||||
let(:expected_headers) do
|
||||
{
|
||||
'Authorization' => "Bearer #{api_key}",
|
||||
'Content-Type' => 'application/json'
|
||||
}
|
||||
end
|
||||
|
||||
context 'when the API call is successful' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
|
||||
.with(
|
||||
body: expected_payload,
|
||||
headers: expected_headers
|
||||
)
|
||||
.to_return(status: 200, body: '{"status": "success"}')
|
||||
end
|
||||
|
||||
it 'makes a POST request with correct parameters' do
|
||||
service.perform(url, webhook_url, crawl_limit)
|
||||
|
||||
expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl')
|
||||
.with(
|
||||
body: expected_payload,
|
||||
headers: expected_headers
|
||||
)
|
||||
end
|
||||
|
||||
it 'uses default crawl limit when not specified' do
|
||||
default_payload = expected_payload.gsub(crawl_limit.to_s, '10')
|
||||
|
||||
stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
|
||||
.with(
|
||||
body: default_payload,
|
||||
headers: expected_headers
|
||||
)
|
||||
.to_return(status: 200, body: '{"status": "success"}')
|
||||
|
||||
service.perform(url, webhook_url)
|
||||
|
||||
expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl')
|
||||
.with(
|
||||
body: default_payload,
|
||||
headers: expected_headers
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the API call fails' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
|
||||
.to_raise(StandardError.new('Connection failed'))
|
||||
end
|
||||
|
||||
it 'raises an error with the failure message' do
|
||||
expect { service.perform(url, webhook_url, crawl_limit) }
|
||||
.to raise_error('Failed to crawl URL: Connection failed')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the API returns an error response' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
|
||||
.to_return(status: 422, body: '{"error": "Invalid URL"}')
|
||||
end
|
||||
|
||||
it 'makes the request but does not raise an error' do
|
||||
expect { service.perform(url, webhook_url, crawl_limit) }.not_to raise_error
|
||||
|
||||
expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl')
|
||||
.with(
|
||||
body: expected_payload,
|
||||
headers: expected_headers
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,66 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::SearchDocumentationService do
|
||||
let(:assistant) { create(:captain_assistant) }
|
||||
let(:service) { described_class.new(assistant) }
|
||||
let(:question) { 'How to create a new account?' }
|
||||
let(:answer) { 'You can create a new account by clicking on the Sign Up button.' }
|
||||
let(:external_link) { 'https://example.com/docs/create-account' }
|
||||
|
||||
describe '#name' do
|
||||
it 'returns the correct service name' do
|
||||
expect(service.name).to eq('search_documentation')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#description' do
|
||||
it 'returns the service description' do
|
||||
expect(service.description).to eq('Search and retrieve documentation from knowledge base')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#parameters' do
|
||||
it 'defines query parameter' do
|
||||
expect(service.parameters.keys).to contain_exactly(:query)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
let!(:response) do
|
||||
create(
|
||||
:captain_assistant_response,
|
||||
assistant: assistant,
|
||||
question: question,
|
||||
answer: answer,
|
||||
status: 'approved'
|
||||
)
|
||||
end
|
||||
|
||||
let(:documentable) { create(:captain_document, external_link: external_link) }
|
||||
|
||||
context 'when matching responses exist' do
|
||||
before do
|
||||
response.update(documentable: documentable)
|
||||
allow(Captain::AssistantResponse).to receive(:search).with(question).and_return([response])
|
||||
end
|
||||
|
||||
it 'returns formatted responses for the search query' do
|
||||
result = service.execute(query: question)
|
||||
|
||||
expect(result).to include(question)
|
||||
expect(result).to include(answer)
|
||||
expect(result).to include(external_link)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no matching responses exist' do
|
||||
before do
|
||||
allow(Captain::AssistantResponse).to receive(:search).with(question).and_return([])
|
||||
end
|
||||
|
||||
it 'returns an empty string' do
|
||||
expect(service.execute(query: question)).to eq('No FAQs found for the given query')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,187 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::SimplePageCrawlService do
|
||||
let(:base_url) { 'https://example.com' }
|
||||
let(:service) { described_class.new(base_url) }
|
||||
|
||||
before do
|
||||
WebMock.disable_net_connect!
|
||||
end
|
||||
|
||||
after do
|
||||
WebMock.allow_net_connect!
|
||||
end
|
||||
|
||||
describe '#page_title' do
|
||||
context 'when title exists' do
|
||||
before do
|
||||
stub_request(:get, base_url)
|
||||
.to_return(body: '<html><head><title>Example Page</title></head></html>')
|
||||
end
|
||||
|
||||
it 'returns the page title' do
|
||||
expect(service.page_title).to eq('Example Page')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when title does not exist' do
|
||||
before do
|
||||
stub_request(:get, base_url)
|
||||
.to_return(body: '<html><head></head></html>')
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
expect(service.page_title).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#page_links' do
|
||||
context 'with HTML page' do
|
||||
let(:html_content) do
|
||||
<<~HTML
|
||||
<html>
|
||||
<body>
|
||||
<a href="/relative">Relative Link</a>
|
||||
<a href="https://external.com">External Link</a>
|
||||
<a href="#anchor">Anchor Link</a>
|
||||
</body>
|
||||
</html>
|
||||
HTML
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, base_url).to_return(body: html_content)
|
||||
end
|
||||
|
||||
it 'extracts and absolutizes all links' do
|
||||
links = service.page_links
|
||||
expect(links).to include(
|
||||
'https://example.com/relative',
|
||||
'https://external.com',
|
||||
'https://example.com#anchor'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with sitemap XML' do
|
||||
let(:sitemap_url) { 'https://example.com/sitemap.xml' }
|
||||
let(:sitemap_service) { described_class.new(sitemap_url) }
|
||||
let(:sitemap_content) do
|
||||
<<~XML
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://example.com/page1</loc>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://example.com/page2</loc>
|
||||
</url>
|
||||
</urlset>
|
||||
XML
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, sitemap_url).to_return(body: sitemap_content)
|
||||
end
|
||||
|
||||
it 'extracts links from sitemap' do
|
||||
links = sitemap_service.page_links
|
||||
expect(links).to contain_exactly(
|
||||
'https://example.com/page1',
|
||||
'https://example.com/page2'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#body_text_content' do
|
||||
let(:html_content) do
|
||||
<<~HTML
|
||||
<html>
|
||||
<body>
|
||||
<h1>Main Title</h1>
|
||||
<p>Some <strong>formatted</strong> content.</p>
|
||||
<ul>
|
||||
<li>List item 1</li>
|
||||
<li>List item 2</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
HTML
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, base_url).to_return(body: html_content)
|
||||
allow(ReverseMarkdown).to receive(:convert).and_return("# Main Title\n\nConverted markdown")
|
||||
end
|
||||
|
||||
it 'converts body content to markdown' do
|
||||
expect(service.body_text_content).to eq("# Main Title\n\nConverted markdown")
|
||||
expect(ReverseMarkdown).to have_received(:convert).with(
|
||||
kind_of(Nokogiri::XML::Element),
|
||||
unknown_tags: :bypass,
|
||||
github_flavored: true
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#meta_description' do
|
||||
context 'when meta description exists' do
|
||||
before do
|
||||
stub_request(:get, base_url)
|
||||
.to_return(body: '<html><head><meta name="description" content="This is a test page description"></head></html>')
|
||||
end
|
||||
|
||||
it 'returns the meta description content' do
|
||||
expect(service.meta_description).to eq('This is a test page description')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when meta description does not exist' do
|
||||
before do
|
||||
stub_request(:get, base_url)
|
||||
.to_return(body: '<html><head><title>Test</title></head></html>')
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
expect(service.meta_description).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#favicon_url' do
|
||||
context 'when favicon exists with relative URL' do
|
||||
before do
|
||||
stub_request(:get, base_url)
|
||||
.to_return(body: '<html><head><link rel="icon" href="/favicon.ico"></head></html>')
|
||||
end
|
||||
|
||||
it 'returns the resolved absolute favicon URL' do
|
||||
expect(service.favicon_url).to eq('https://example.com/favicon.ico')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when favicon exists with absolute URL' do
|
||||
before do
|
||||
stub_request(:get, base_url)
|
||||
.to_return(body: '<html><head><link rel="icon" href="https://cdn.example.com/favicon.ico"></head></html>')
|
||||
end
|
||||
|
||||
it 'returns the absolute favicon URL' do
|
||||
expect(service.favicon_url).to eq('https://cdn.example.com/favicon.ico')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when favicon does not exist' do
|
||||
before do
|
||||
stub_request(:get, base_url)
|
||||
.to_return(body: '<html><head><title>Test</title></head></html>')
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
expect(service.favicon_url).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user