# Pull Request Template ## Description Routes Enterprise assistant, copilot, FAQ, contact memory, action-classifier, and false-promise detector LLM paths through feature-specific model resolution. `Llm::BaseAiService` now accepts feature/account context and uses `Llm::FeatureRouter` when that context is present, while retaining the installation-model fallback for unmigrated callers. This also adds a `document_faq_generation` feature default for generative FAQ/document content. Linear: https://linear.app/chatwoot/issue/CW-7425/test-new-models Depends on #14840 ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? - `bundle exec rspec spec/lib/llm/models_spec.rb spec/enterprise/services/llm/base_ai_service_spec.rb spec/enterprise/services/captain/copilot/chat_service_spec.rb spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb spec/enterprise/services/captain/llm/faq_generator_service_spec.rb spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb spec/enterprise/services/captain/llm/assistant_action_classifier_service_spec.rb spec/enterprise/services/captain/llm/assistant_false_promise_service_spec.rb spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb` passed with 112 examples, 0 failures. - `bundle exec rspec spec/models/concerns/captain_featurable_spec.rb spec/models/account_spec.rb spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb spec/lib/llm/feature_router_spec.rb` passed with 87 examples, 0 failures. - `bundle exec rubocop enterprise/app/services/llm/base_ai_service.rb enterprise/app/services/captain/copilot/chat_service.rb enterprise/app/services/captain/llm/assistant_chat_service.rb enterprise/app/services/captain/llm/faq_generator_service.rb enterprise/app/services/captain/llm/conversation_faq_service.rb enterprise/app/services/captain/llm/contact_notes_service.rb enterprise/app/services/captain/llm/contact_attributes_service.rb enterprise/app/services/captain/llm/assistant_action_classifier_service.rb enterprise/app/services/captain/llm/assistant_false_promise_service.rb spec/enterprise/services/llm/base_ai_service_spec.rb spec/enterprise/services/captain/copilot/chat_service_spec.rb spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb spec/enterprise/services/captain/llm/faq_generator_service_spec.rb spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb spec/enterprise/services/captain/llm/assistant_action_classifier_service_spec.rb spec/enterprise/services/captain/llm/assistant_false_promise_service_spec.rb spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb` passed with no offenses. - `bundle exec ruby -e "require 'yaml'; config = YAML.load_file('config/llm.yml'); abort('missing document_faq_generation') unless config.dig('features', 'document_faq_generation'); abort('missing default') unless config.dig('features', 'document_faq_generation', 'default'); puts 'llm.yml ok'"` passed. - `git diff --check` passed. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules
200 lines
7.5 KiB
Ruby
200 lines
7.5 KiB
Ruby
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 'uses the copilot feature model' do
|
|
account.update!(captain_models: { 'copilot' => 'gpt-5.2' })
|
|
|
|
expect(RubyLLM).to receive(:chat).with(model: 'gpt-5.2').and_return(mock_chat)
|
|
|
|
described_class.new(assistant, config).generate_response('Hello')
|
|
end
|
|
|
|
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
|