From ffc621b6e6856f4f4ce64ca8365e868cef36ecab Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 17 Dec 2025 16:38:41 +0530 Subject: [PATCH] feat: remove old processor service --- app/models/integrations/hook.rb | 11 +- .../integrations/openai_processor_service.rb | 82 ------- lib/integrations/openai/processor_service.rb | 151 ------------- .../openai/processor_service_spec.rb | 120 ---------- .../openai/processor_service_spec.rb | 205 ------------------ 5 files changed, 4 insertions(+), 565 deletions(-) delete mode 100644 enterprise/lib/enterprise/integrations/openai_processor_service.rb delete mode 100644 lib/integrations/openai/processor_service.rb delete mode 100644 spec/enterprise/lib/integrations/openai/processor_service_spec.rb delete mode 100644 spec/lib/integrations/openai/processor_service_spec.rb diff --git a/app/models/integrations/hook.rb b/app/models/integrations/hook.rb index 97d3f91ae..518b405da 100644 --- a/app/models/integrations/hook.rb +++ b/app/models/integrations/hook.rb @@ -64,13 +64,10 @@ class Integrations::Hook < ApplicationRecord update(status: 'disabled') end - def process_event(event) - case app_id - when 'openai' - Integrations::Openai::ProcessorService.new(hook: self, event: event).perform if app_id == 'openai' - else - { error: 'No processor found' } - end + def process_event(_event) + # OpenAI integration migrated to Captain::EditorService + # Other integrations (slack, dialogflow, etc.) handled via HookJob + { error: 'No processor found' } end def feature_allowed? diff --git a/enterprise/lib/enterprise/integrations/openai_processor_service.rb b/enterprise/lib/enterprise/integrations/openai_processor_service.rb deleted file mode 100644 index 2a241d7a3..000000000 --- a/enterprise/lib/enterprise/integrations/openai_processor_service.rb +++ /dev/null @@ -1,82 +0,0 @@ -module Enterprise::Integrations::OpenaiProcessorService - ALLOWED_EVENT_NAMES = %w[summarize reply_suggestion label_suggestion fix_spelling_grammar - friendly casual professional confident straightforward improve].freeze - CACHEABLE_EVENTS = %w[label_suggestion].freeze - - def label_suggestion_message - payload = label_suggestion_body - return nil if payload.blank? - - response = make_api_call(label_suggestion_body) - - return response if response[:error].present? - - # LLMs are not deterministic, so this is bandaid solution - # To what you ask? Sometimes, the response includes - # "Labels:" in it's response in some format. This is a hacky way to remove it - # TODO: Fix with with a better prompt - { message: response[:message] ? response[:message].gsub(/^(label|labels):/i, '') : '' } - end - - private - - def labels_with_messages - return nil unless valid_conversation?(conversation) - - labels = hook.account.labels.pluck(:title).join(', ') - character_count = labels.length - - messages = init_messages_body(false) - add_messages_until_token_limit(conversation, messages, false, character_count) - - return nil if messages.blank? || labels.blank? - - "Messages:\n#{messages}\nLabels:\n#{labels}" - end - - def valid_conversation?(conversation) - return false if conversation.nil? - return false if conversation.messages.incoming.count < 3 - - # Think Mark think, at this point the conversation is beyond saving - return false if conversation.messages.count > 100 - - # if there are more than 20 messages, only trigger this if the last message is from the client - return false if conversation.messages.count > 20 && !conversation.messages.last.incoming? - - true - end - - def summarize_body - { - model: self.class::GPT_MODEL, - messages: [ - { role: 'system', - content: prompt_from_file('summary', enterprise: true) }, - { role: 'user', content: conversation_messages } - ] - }.to_json - end - - def label_suggestion_body - return unless label_suggestions_enabled? - - content = labels_with_messages - return value_from_cache if content.blank? - - { - model: self.class::GPT_MODEL, - messages: [ - { - role: 'system', - content: prompt_from_file('label_suggestion', enterprise: true) - }, - { role: 'user', content: content } - ] - }.to_json - end - - def label_suggestions_enabled? - hook.settings['label_suggestion'].present? - end -end diff --git a/lib/integrations/openai/processor_service.rb b/lib/integrations/openai/processor_service.rb deleted file mode 100644 index 13d41350d..000000000 --- a/lib/integrations/openai/processor_service.rb +++ /dev/null @@ -1,151 +0,0 @@ -class Integrations::Openai::ProcessorService < Integrations::LlmBaseService - def reply_suggestion_message - make_api_call(reply_suggestion_body) - end - - def summarize_message - make_api_call(summarize_body) - end - - def fix_spelling_grammar_message - call_llm_with_prompt(fix_spelling_grammar_prompt) - end - - def confident_message - call_llm_with_prompt(tone_rewrite_prompt('confident')) - end - - def straightforward_message - call_llm_with_prompt(tone_rewrite_prompt('straightforward')) - end - - def casual_message - call_llm_with_prompt(tone_rewrite_prompt('casual')) - end - - def friendly_message - call_llm_with_prompt(tone_rewrite_prompt('friendly')) - end - - def professional_message - call_llm_with_prompt(tone_rewrite_prompt('professional')) - end - - def improve_message - template = prompt_from_file('improve') - - system_prompt = render_liquid_template(template, { - 'conversation_context' => conversation.to_llm_text(include_contact_details: true), - 'draft_message' => event['data']['content'] - }) - - call_llm_with_prompt(system_prompt, event['data']['content']) - end - - private - - def call_llm_with_prompt(system_content, user_content = event['data']['content']) - body = { - model: GPT_MODEL, - messages: [ - { role: 'system', content: system_content }, - { role: 'user', content: user_content } - ] - }.to_json - make_api_call(body) - end - - def prompt_from_file(file_name, enterprise: false) - path = enterprise ? 'enterprise/lib/enterprise/integrations/openai_prompts' : 'lib/integrations/openai/openai_prompts' - Rails.root.join(path, "#{file_name}.liquid").read - end - - def render_liquid_template(template_content, variables = {}) - Liquid::Template.parse(template_content).render(variables) - end - - def tone_rewrite_prompt(tone) - template = prompt_from_file('tone_rewrite') - render_liquid_template(template, 'tone' => tone) - end - - def fix_spelling_grammar_prompt - prompt_from_file('fix_spelling_grammar') - end - - # TODO: Replace with LlmFormattable or enterprise/lib/captain/prompts/snippets/conversation.liquid - def conversation_messages(in_array_format: false) - messages = init_messages_body(in_array_format) - - add_messages_until_token_limit(conversation, messages, in_array_format) - end - - def add_messages_until_token_limit(conversation, messages, in_array_format, start_from = 0) - character_count = start_from - conversation.messages.where(message_type: [:incoming, :outgoing]).where(private: false).reorder('id desc').each do |message| - character_count, message_added = add_message_if_within_limit(character_count, message, messages, in_array_format) - break unless message_added - end - messages - end - - def add_message_if_within_limit(character_count, message, messages, in_array_format) - content = message.content_for_llm - if valid_message?(content, character_count) - add_message_to_list(message, messages, in_array_format, content) - character_count += content.length - [character_count, true] - else - [character_count, false] - end - end - - def valid_message?(content, character_count) - content.present? && character_count + content.length <= TOKEN_LIMIT - end - - def add_message_to_list(message, messages, in_array_format, content) - formatted_message = format_message(message, in_array_format, content) - messages.prepend(formatted_message) - end - - def init_messages_body(in_array_format) - in_array_format ? [] : '' - end - - def format_message(message, in_array_format, content) - in_array_format ? format_message_in_array(message, content) : format_message_in_string(message, content) - end - - def format_message_in_array(message, content) - { role: (message.incoming? ? 'user' : 'assistant'), content: content } - end - - def format_message_in_string(message, content) - sender_type = message.incoming? ? 'Customer' : 'Agent' - "#{sender_type} #{message.sender&.name} : #{content}\n" - end - - def summarize_body - { - model: GPT_MODEL, - messages: [ - { role: 'system', - content: prompt_from_file('summary', enterprise: false) }, - { role: 'user', content: conversation_messages } - ] - }.to_json - end - - def reply_suggestion_body - { - model: GPT_MODEL, - messages: [ - { role: 'system', - content: prompt_from_file('reply', enterprise: false) } - ].concat(conversation_messages(in_array_format: true)) - }.to_json - end -end - -Integrations::Openai::ProcessorService.prepend_mod_with('Integrations::OpenaiProcessorService') diff --git a/spec/enterprise/lib/integrations/openai/processor_service_spec.rb b/spec/enterprise/lib/integrations/openai/processor_service_spec.rb deleted file mode 100644 index 88e75ea07..000000000 --- a/spec/enterprise/lib/integrations/openai/processor_service_spec.rb +++ /dev/null @@ -1,120 +0,0 @@ -require 'rails_helper' - -RSpec.describe Integrations::Openai::ProcessorService do - subject { described_class.new(hook: hook, event: event) } - - let(:account) { create(:account) } - let(:hook) { create(:integrations_hook, :openai, account: account) } - - # Mock RubyLLM objects - let(:mock_chat) { instance_double(RubyLLM::Chat) } - let(:mock_context) { instance_double(RubyLLM::Context) } - let(:mock_config) { OpenStruct.new } - let(:mock_response) do - instance_double( - RubyLLM::Message, - content: 'This is a reply from openai.', - input_tokens: nil, - output_tokens: nil - ) - end - let(:mock_empty_response) do - instance_double( - RubyLLM::Message, - content: '', - input_tokens: nil, - output_tokens: nil - ) - end - - let(:conversation) { create(:conversation, account: account) } - - before do - allow(RubyLLM).to receive(:context).and_yield(mock_config).and_return(mock_context) - allow(mock_context).to receive(:chat).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(:ask).and_return(mock_response) - end - - describe '#perform' do - context 'when event name is label_suggestion with labels with < 3 messages' do - let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } } - - it 'returns nil' do - create(:label, account: account) - create(:label, account: account) - - expect(subject.perform).to be_nil - end - end - - context 'when event name is label_suggestion with labels with >3 messages' do - let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } } - - before do - create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent') - create(:message, account: account, conversation: conversation, message_type: :outgoing, content: 'hello customer') - create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 2') - create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 3') - create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 4') - - create(:label, account: account) - create(:label, account: account) - - hook.settings['label_suggestion'] = 'true' - end - - it 'returns the label suggestions' do - result = subject.perform - expect(result).to eq({ message: 'This is a reply from openai.' }) - end - - it 'returns empty string if openai response is blank' do - allow(mock_chat).to receive(:ask).and_return(mock_empty_response) - - result = subject.perform - expect(result[:message]).to eq('') - end - end - - context 'when event name is label_suggestion with no labels' do - let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } } - - it 'returns nil' do - result = subject.perform - expect(result).to be_nil - end - end - - context 'when event name is not one that can be processed' do - let(:event) { { 'name' => 'unknown', 'data' => {} } } - - it 'returns nil' do - expect(subject.perform).to be_nil - end - end - - context 'when hook is not enabled' do - let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } } - - before do - create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent') - create(:message, account: account, conversation: conversation, message_type: :outgoing, content: 'hello customer') - create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 2') - create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 3') - create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 4') - - create(:label, account: account) - create(:label, account: account) - - hook.settings['label_suggestion'] = nil - end - - it 'returns nil' do - expect(subject.perform).to be_nil - end - end - end -end diff --git a/spec/lib/integrations/openai/processor_service_spec.rb b/spec/lib/integrations/openai/processor_service_spec.rb deleted file mode 100644 index cf1860c72..000000000 --- a/spec/lib/integrations/openai/processor_service_spec.rb +++ /dev/null @@ -1,205 +0,0 @@ -require 'rails_helper' - -RSpec.describe Integrations::Openai::ProcessorService do - subject(:service) { described_class.new(hook: hook, event: event) } - - let(:account) { create(:account) } - let(:hook) { create(:integrations_hook, :openai, account: account) } - - # Mock RubyLLM objects - let(:mock_chat) { instance_double(RubyLLM::Chat) } - let(:mock_context) { instance_double(RubyLLM::Context) } - let(:mock_config) { OpenStruct.new } - let(:mock_response) do - instance_double( - RubyLLM::Message, - content: 'This is a reply from openai.', - input_tokens: nil, - output_tokens: nil - ) - end - let(:mock_response_with_usage) do - instance_double( - RubyLLM::Message, - content: 'This is a reply from openai.', - input_tokens: 50, - output_tokens: 20 - ) - end - - before do - allow(RubyLLM).to receive(:context).and_yield(mock_config).and_return(mock_context) - allow(mock_context).to receive(:chat).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(:ask).and_return(mock_response) - end - - describe '#perform' do - describe 'text transformation operations' do - shared_examples 'text transformation operation' do |event_name| - let(:event) { { 'name' => event_name, 'data' => { 'content' => 'This is a test' } } } - - it 'returns the transformed text' do - result = service.perform - expect(result[:message]).to eq('This is a reply from openai.') - end - - it 'sends the user content to the LLM' do - service.perform - expect(mock_chat).to have_received(:ask).with('This is a test') - end - - it 'sets system instructions' do - service.perform - expect(mock_chat).to have_received(:with_instructions) - .with(a_string_including('You are an AI writing assistant integrated into Chatwoot')) - end - end - - it_behaves_like 'text transformation operation', 'confident' - it_behaves_like 'text transformation operation', 'fix_spelling_grammar' - it_behaves_like 'text transformation operation', 'casual' - it_behaves_like 'text transformation operation', 'professional' - it_behaves_like 'text transformation operation', 'friendly' - it_behaves_like 'text transformation operation', 'straightforward' - end - - describe 'conversation-based operations' do - let!(:conversation) { create(:conversation, account: account) } - - before do - create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent') - create(:message, account: account, conversation: conversation, message_type: :outgoing, content: 'hello customer') - end - - context 'with reply_suggestion event' do - let(:event) { { 'name' => 'reply_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } } - - it 'returns the suggested reply' do - result = service.perform - expect(result[:message]).to eq('This is a reply from openai.') - end - - it 'adds conversation history before asking' do - service.perform - # Should add the first message as history, then ask with the last message - expect(mock_chat).to have_received(:add_message).with(role: :user, content: 'hello agent') - expect(mock_chat).to have_received(:ask).with('hello customer') - end - end - - context 'with summarize event' do - let(:event) { { 'name' => 'summarize', 'data' => { 'conversation_display_id' => conversation.display_id } } } - - it 'returns the summary' do - result = service.perform - expect(result[:message]).to eq('This is a reply from openai.') - end - - it 'sends formatted conversation as a single message' do - service.perform - # Summarize sends conversation as a formatted string in one user message - expect(mock_chat).to have_received(:ask).with(a_string_matching(/Customer.*hello agent.*Agent.*hello customer/m)) - end - end - - context 'with label_suggestion event and no labels' do - let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } } - - it 'returns nil' do - expect(service.perform).to be_nil - end - end - end - - describe 'edge cases' do - context 'with unknown event name' do - let(:event) { { 'name' => 'unknown', 'data' => {} } } - - it 'returns nil' do - expect(service.perform).to be_nil - end - end - end - - describe 'response structure' do - let(:event) { { 'name' => 'confident', 'data' => { 'content' => 'test message' } } } - - context 'when response includes usage data' do - before do - allow(mock_chat).to receive(:ask).and_return(mock_response_with_usage) - end - - it 'returns message with usage data' do - result = service.perform - - expect(result[:message]).to eq('This is a reply from openai.') - expect(result[:usage]['prompt_tokens']).to eq(50) - expect(result[:usage]['completion_tokens']).to eq(20) - expect(result[:usage]['total_tokens']).to eq(70) - end - - it 'includes request_messages in response' do - result = service.perform - - expect(result[:request_messages]).to be_an(Array) - expect(result[:request_messages].length).to eq(2) - end - end - - context 'when response does not include usage data' do - it 'returns message with zero total tokens' do - result = service.perform - - expect(result[:message]).to eq('This is a reply from openai.') - expect(result[:usage]['total_tokens']).to eq(0) - end - - it 'includes request_messages in response' do - result = service.perform - - expect(result[:request_messages]).to be_an(Array) - end - end - end - - describe 'endpoint configuration' do - let(:event) { { 'name' => 'confident', 'data' => { 'content' => 'test message' } } } - - context 'without CAPTAIN_OPEN_AI_ENDPOINT configured' do - before do - InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy - allow(Llm::Config).to receive(:with_api_key).and_call_original - end - - it 'uses default OpenAI endpoint' do - expect(Llm::Config).to receive(:with_api_key).with( - hook.settings['api_key'], - api_base: 'https://api.openai.com/v1' - ).and_call_original - - service.perform - end - end - - context 'with CAPTAIN_OPEN_AI_ENDPOINT configured' do - before do - InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy - create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://custom.azure.com/') - allow(Llm::Config).to receive(:with_api_key).and_call_original - end - - it 'uses custom endpoint' do - expect(Llm::Config).to receive(:with_api_key).with( - hook.settings['api_key'], - api_base: 'https://custom.azure.com/v1' - ).and_call_original - - service.perform - end - end - end - end -end