# Pull Request Template ## Description Routes OSS reply-box and small Captain tasks through feature-specific LLM model resolution. Rewrite, reply suggestion, summary, follow-up, and CSAT utility analysis now resolve through the `editor` feature; label suggestion resolves through `label_suggestion`. Existing credentials, account OpenAI hook behavior, and instrumentation event names remain unchanged. Linear: https://linear.app/chatwoot/issue/CW-7425/test-new-models Depends on #14839 ## 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/captain/base_task_service_spec.rb spec/lib/captain/rewrite_service_spec.rb spec/lib/captain/reply_suggestion_service_spec.rb spec/lib/captain/summary_service_spec.rb spec/lib/captain/label_suggestion_service_spec.rb spec/lib/captain/csat_utility_analysis_service_spec.rb spec/lib/captain/follow_up_service_spec.rb` passed with 81 examples, 0 failures. - `bundle exec rubocop lib/captain/base_task_service.rb lib/captain/rewrite_service.rb lib/captain/reply_suggestion_service.rb lib/captain/summary_service.rb lib/captain/label_suggestion_service.rb lib/captain/csat_utility_analysis_service.rb lib/captain/follow_up_service.rb enterprise/lib/enterprise/captain/reply_suggestion_service.rb spec/lib/captain/base_task_service_spec.rb spec/lib/captain/rewrite_service_spec.rb spec/lib/captain/reply_suggestion_service_spec.rb spec/lib/captain/summary_service_spec.rb spec/lib/captain/label_suggestion_service_spec.rb spec/lib/captain/csat_utility_analysis_service_spec.rb spec/lib/captain/follow_up_service_spec.rb` passed with no offenses. - `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
171 lines
5.7 KiB
Ruby
171 lines
5.7 KiB
Ruby
require 'rails_helper'
|
|
|
|
RSpec.describe Captain::LabelSuggestionService do
|
|
let(:account) { create(:account) }
|
|
let(:inbox) { create(:inbox, account: account) }
|
|
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
|
let(:label1) { create(:label, account: account, title: 'bug') }
|
|
let(:label2) { create(:label, account: account, title: 'feature-request') }
|
|
let(:service) { described_class.new(account: account, conversation_display_id: conversation.display_id) }
|
|
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
|
let(:mock_context) { instance_double(RubyLLM::Context, chat: mock_chat) }
|
|
let(:mock_response) { instance_double(RubyLLM::Message, content: 'bug, feature-request', input_tokens: 100, output_tokens: 20) }
|
|
|
|
before do
|
|
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
|
label1
|
|
label2
|
|
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
|
|
allow(mock_chat).to receive(:with_instructions)
|
|
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
|
# Stub captain enabled check to allow specs to test base functionality
|
|
# without enterprise module interference
|
|
allow(account).to receive(:feature_enabled?).and_call_original
|
|
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
|
end
|
|
|
|
describe '#label_suggestion_message' do
|
|
context 'with valid conversation' do
|
|
before do
|
|
# Create enough incoming messages to pass validation
|
|
3.times do |i|
|
|
create(:message, conversation: conversation, message_type: :incoming,
|
|
content: "Message #{i}", created_at: i.minutes.ago)
|
|
end
|
|
end
|
|
|
|
it 'returns label suggestions' do
|
|
result = service.perform
|
|
|
|
expect(result[:message]).to eq('bug, feature-request')
|
|
end
|
|
|
|
it 'removes "Labels:" prefix from response' do
|
|
allow(mock_response).to receive(:content).and_return('Labels: bug, feature-request')
|
|
|
|
result = service.perform
|
|
|
|
expect(result[:message]).to eq(' bug, feature-request')
|
|
end
|
|
|
|
it 'removes "Label:" prefix (singular) from response' do
|
|
allow(mock_response).to receive(:content).and_return('label: bug')
|
|
|
|
result = service.perform
|
|
|
|
expect(result[:message]).to eq(' bug')
|
|
end
|
|
|
|
it 'builds labels_with_messages format correctly' do
|
|
expect(service).to receive(:make_api_call) do |args|
|
|
expect(args[:feature]).to eq('label_suggestion')
|
|
user_message = args[:messages].find { |m| m[:role] == 'user' }[:content]
|
|
|
|
expect(user_message).to include('Messages:')
|
|
expect(user_message).to include('Labels:')
|
|
expect(user_message).to include('bug, feature-request')
|
|
{ message: 'bug' }
|
|
end
|
|
|
|
service.perform
|
|
end
|
|
end
|
|
|
|
context 'with invalid conversation' do
|
|
it 'returns nil when conversation has less than 3 incoming messages' do
|
|
create(:message, conversation: conversation, message_type: :incoming, content: 'Message 1')
|
|
create(:message, conversation: conversation, message_type: :incoming, content: 'Message 2')
|
|
|
|
result = service.perform
|
|
|
|
expect(result).to be_nil
|
|
end
|
|
|
|
it 'returns nil when conversation has more than 100 messages' do
|
|
101.times do |i|
|
|
create(:message, conversation: conversation, message_type: :incoming, content: "Message #{i}")
|
|
end
|
|
|
|
result = service.perform
|
|
|
|
expect(result).to be_nil
|
|
end
|
|
|
|
it 'returns nil when conversation has >20 messages and last is not incoming' do
|
|
21.times do |i|
|
|
create(:message, conversation: conversation, message_type: :incoming, content: "Message #{i}")
|
|
end
|
|
create(:message, conversation: conversation, message_type: :outgoing, content: 'Agent reply')
|
|
|
|
result = service.perform
|
|
|
|
expect(result).to be_nil
|
|
end
|
|
end
|
|
|
|
context 'when caching' do
|
|
before do
|
|
3.times do |i|
|
|
create(:message, conversation: conversation, message_type: :incoming,
|
|
content: "Message #{i}", created_at: i.minutes.ago)
|
|
end
|
|
end
|
|
|
|
it 'reads from cache on cache hit' do
|
|
# Warm up cache
|
|
service.perform
|
|
|
|
# Create new service instance to test cache read
|
|
new_service = described_class.new(account: account, conversation_display_id: conversation.display_id)
|
|
|
|
expect(new_service).not_to receive(:make_api_call)
|
|
result = new_service.perform
|
|
|
|
expect(result[:message]).to eq('bug, feature-request')
|
|
end
|
|
|
|
it 'writes to cache on cache miss' do
|
|
expect(Redis::Alfred).to receive(:setex).and_call_original
|
|
|
|
service.perform
|
|
end
|
|
|
|
it 'returns nil for invalid cached JSON' do
|
|
# Set invalid JSON in cache
|
|
cache_key = service.send(:cache_key)
|
|
Redis::Alfred.set(cache_key, 'invalid json')
|
|
|
|
result = service.perform
|
|
|
|
# Should make API call since cache read failed
|
|
expect(result[:message]).to eq('bug, feature-request')
|
|
end
|
|
|
|
it 'does not cache error responses' do
|
|
error_response = { error: 'API Error', request_messages: [] }
|
|
allow(service).to receive(:make_api_call).and_return(error_response)
|
|
|
|
expect(Redis::Alfred).not_to receive(:setex)
|
|
|
|
service.perform
|
|
end
|
|
end
|
|
|
|
context 'when no labels exist' do
|
|
before do
|
|
Label.destroy_all
|
|
3.times do |i|
|
|
create(:message, conversation: conversation, message_type: :incoming,
|
|
content: "Message #{i}")
|
|
end
|
|
end
|
|
|
|
it 'returns nil' do
|
|
result = service.perform
|
|
|
|
expect(result).to be_nil
|
|
end
|
|
end
|
|
end
|
|
end
|