From 8f1cd32fab5be34216972676596b5a8c9f27b99f Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 18 Dec 2025 14:23:33 +0530 Subject: [PATCH] feat: separate service and controller for editor tasks (#13085) Co-authored-by: iamsivin Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> --- app/javascript/dashboard/api/captain/tasks.js | 113 ++++++++ .../dashboard/api/integrations/openapi.js | 83 ------ .../dashboard/composables/spec/useAI.spec.js | 9 +- app/javascript/dashboard/composables/useAI.js | 16 +- app/models/integrations/hook.rb | 11 +- app/policies/captain/tasks_policy.rb | 17 ++ config/routes.rb | 6 + .../v1/accounts/captain/tasks_controller.rb | 57 ++++ .../integrations/openai_processor_service.rb | 82 ------ .../openai_prompts/summary.liquid | 28 -- lib/captain/base_task_service.rb | 121 +++++++++ lib/captain/label_suggestion_service.rb | 89 +++++++ lib/captain/reply_suggestion_service.rb | 18 ++ lib/captain/rewrite_service.rb | 67 +++++ lib/captain/summary_service.rb | 19 ++ .../openai_prompts/label_suggestion.liquid | 2 +- .../openai/openai_prompts/summary.liquid | 29 +- .../openai/processor_service_spec.rb | 120 --------- spec/lib/captain/base_task_service_spec.rb | 250 ++++++++++++++++++ .../captain/label_suggestion_service_spec.rb | 165 ++++++++++++ .../captain/reply_suggestion_service_spec.rb | 67 +++++ spec/lib/captain/rewrite_service_spec.rb | 138 ++++++++++ spec/lib/captain/summary_service_spec.rb | 51 ++++ .../openai/processor_service_spec.rb | 205 -------------- spec/models/integrations/hook_spec.rb | 21 -- 25 files changed, 1219 insertions(+), 565 deletions(-) create mode 100644 app/javascript/dashboard/api/captain/tasks.js delete mode 100644 app/javascript/dashboard/api/integrations/openapi.js create mode 100644 app/policies/captain/tasks_policy.rb create mode 100644 enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb delete mode 100644 enterprise/lib/enterprise/integrations/openai_processor_service.rb delete mode 100644 enterprise/lib/enterprise/integrations/openai_prompts/summary.liquid create mode 100644 lib/captain/base_task_service.rb create mode 100644 lib/captain/label_suggestion_service.rb create mode 100644 lib/captain/reply_suggestion_service.rb create mode 100644 lib/captain/rewrite_service.rb create mode 100644 lib/captain/summary_service.rb rename {enterprise/lib/enterprise/integrations => lib/integrations/openai}/openai_prompts/label_suggestion.liquid (88%) delete mode 100644 spec/enterprise/lib/integrations/openai/processor_service_spec.rb create mode 100644 spec/lib/captain/base_task_service_spec.rb create mode 100644 spec/lib/captain/label_suggestion_service_spec.rb create mode 100644 spec/lib/captain/reply_suggestion_service_spec.rb create mode 100644 spec/lib/captain/rewrite_service_spec.rb create mode 100644 spec/lib/captain/summary_service_spec.rb delete mode 100644 spec/lib/integrations/openai/processor_service_spec.rb diff --git a/app/javascript/dashboard/api/captain/tasks.js b/app/javascript/dashboard/api/captain/tasks.js new file mode 100644 index 000000000..85fd47e39 --- /dev/null +++ b/app/javascript/dashboard/api/captain/tasks.js @@ -0,0 +1,113 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +/** + * A client for the Captain Tasks API. + * @extends ApiClient + */ +class TasksAPI extends ApiClient { + /** + * Creates a new TasksAPI instance. + */ + constructor() { + super('captain/tasks', { accountScoped: true }); + } + + /** + * Processes an event using the Captain Tasks API. + * @param {Object} options - The options for the event. + * @param {string} [options.type='improve'] - The type of event to process. + * @param {string} [options.content] - The content of the event. + * @param {string} [options.conversationId] - The ID of the conversation to process the event for. + * @param {AbortSignal} [signal] - AbortSignal to cancel the request. + * @returns {Promise} A promise that resolves with the result of the event processing. + */ + processEvent({ type = 'improve', content, conversationId }, signal) { + // Route to appropriate endpoint based on type + if (type === 'summarize') { + return this.summarize(conversationId, signal); + } + + if (type === 'reply_suggestion') { + return this.replySuggestion(conversationId, signal); + } + + if (type === 'label_suggestion') { + return this.labelSuggestion(conversationId, signal); + } + + // All other types are rewrite operations + return this.rewrite({ content, operation: type, conversationId }, signal); + } + + /** + * Rewrites content with a specific operation. + * @param {Object} options - The rewrite options. + * @param {string} options.content - The content to rewrite. + * @param {string} options.operation - The rewrite operation (fix_spelling_grammar, casual, professional, etc). + * @param {string} [options.conversationId] - The conversation ID for context (required for 'improve'). + * @param {AbortSignal} [signal] - AbortSignal to cancel the request. + * @returns {Promise} A promise that resolves with the rewritten content. + */ + rewrite({ content, operation, conversationId }, signal) { + return axios.post( + `${this.url}/rewrite`, + { + content, + operation, + conversation_display_id: conversationId, + }, + { signal } + ); + } + + /** + * Summarizes a conversation. + * @param {string} conversationId - The conversation ID to summarize. + * @param {AbortSignal} [signal] - AbortSignal to cancel the request. + * @returns {Promise} A promise that resolves with the summary. + */ + summarize(conversationId, signal) { + return axios.post( + `${this.url}/summarize`, + { + conversation_display_id: conversationId, + }, + { signal } + ); + } + + /** + * Gets a reply suggestion for a conversation. + * @param {string} conversationId - The conversation ID. + * @param {AbortSignal} [signal] - AbortSignal to cancel the request. + * @returns {Promise} A promise that resolves with the reply suggestion. + */ + replySuggestion(conversationId, signal) { + return axios.post( + `${this.url}/reply_suggestion`, + { + conversation_display_id: conversationId, + }, + { signal } + ); + } + + /** + * Gets label suggestions for a conversation. + * @param {string} conversationId - The conversation ID. + * @param {AbortSignal} [signal] - AbortSignal to cancel the request. + * @returns {Promise} A promise that resolves with label suggestions. + */ + labelSuggestion(conversationId, signal) { + return axios.post( + `${this.url}/label_suggestion`, + { + conversation_display_id: conversationId, + }, + { signal } + ); + } +} + +export default new TasksAPI(); diff --git a/app/javascript/dashboard/api/integrations/openapi.js b/app/javascript/dashboard/api/integrations/openapi.js deleted file mode 100644 index 9f075a9ef..000000000 --- a/app/javascript/dashboard/api/integrations/openapi.js +++ /dev/null @@ -1,83 +0,0 @@ -/* global axios */ - -import ApiClient from '../ApiClient'; - -/** - * Represents the data object for a OpenAI hook. - * @typedef {Object} ConversationMessageData - * @property {string} [tone] - The tone of the message. - * @property {string} [content] - The content of the message. - * @property {string} [conversation_display_id] - The display ID of the conversation (optional). - */ - -/** - * A client for the OpenAI API. - * @extends ApiClient - */ -class OpenAIAPI extends ApiClient { - /** - * Creates a new OpenAIAPI instance. - */ - constructor() { - super('integrations', { accountScoped: true }); - - /** - * The conversation events supported by the API. - * @type {string[]} - */ - this.conversation_events = [ - 'summarize', - 'reply_suggestion', - 'label_suggestion', - ]; - } - - /** - * Processes an event using the OpenAI API. - * @param {Object} options - The options for the event. - * @param {string} [options.type='improve'] - The type of event to process. - * @param {string} [options.content] - The content of the event. - * @param {string} [options.tone] - The tone of the event. - * @param {string} [options.conversationId] - The ID of the conversation to process the event for. - * @param {string} options.hookId - The ID of the hook to use for processing the event. - * @param {AbortSignal} [signal] - AbortSignal to cancel the request. - * @returns {Promise} A promise that resolves with the result of the event processing. - */ - processEvent( - { type = 'improve', content, tone, conversationId, hookId }, - signal - ) { - /** - * @type {ConversationMessageData} - */ - let data = { - tone, - content, - }; - - // Always include conversation_display_id when available for session tracking - if (conversationId) { - data.conversation_display_id = conversationId; - } - - // For conversation-level events, only send conversation_display_id - if (this.conversation_events.includes(type)) { - data = { - conversation_display_id: conversationId, - }; - } - - return axios.post( - `${this.url}/hooks/${hookId}/process_event`, - { - event: { - name: type, - data, - }, - }, - { signal } - ); - } -} - -export default new OpenAIAPI(); diff --git a/app/javascript/dashboard/composables/spec/useAI.spec.js b/app/javascript/dashboard/composables/spec/useAI.spec.js index 2431fe196..a776c1189 100644 --- a/app/javascript/dashboard/composables/spec/useAI.spec.js +++ b/app/javascript/dashboard/composables/spec/useAI.spec.js @@ -5,12 +5,12 @@ import { useMapGetter, } from 'dashboard/composables/store'; import { useI18n } from 'vue-i18n'; -import OpenAPI from 'dashboard/api/integrations/openapi'; +import TasksAPI from 'dashboard/api/captain/tasks'; import analyticsHelper from 'dashboard/helper/AnalyticsHelper/index'; vi.mock('dashboard/composables/store'); vi.mock('vue-i18n'); -vi.mock('dashboard/api/integrations/openapi'); +vi.mock('dashboard/api/captain/tasks'); vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => { const actual = await importOriginal(); actual.default = { @@ -94,7 +94,7 @@ describe('useAI', () => { }); it('fetches label suggestions', async () => { - OpenAPI.processEvent.mockResolvedValue({ + TasksAPI.processEvent.mockResolvedValue({ data: { message: 'label1, label2' }, }); @@ -111,9 +111,8 @@ describe('useAI', () => { const { fetchLabelSuggestions } = useAI(); const result = await fetchLabelSuggestions(); - expect(OpenAPI.processEvent).toHaveBeenCalledWith({ + expect(TasksAPI.processEvent).toHaveBeenCalledWith({ type: 'label_suggestion', - hookId: 'hook1', conversationId: '123', }); diff --git a/app/javascript/dashboard/composables/useAI.js b/app/javascript/dashboard/composables/useAI.js index 0465723e4..7142dc8ec 100644 --- a/app/javascript/dashboard/composables/useAI.js +++ b/app/javascript/dashboard/composables/useAI.js @@ -7,7 +7,7 @@ import { import { useAlert, useTrack } from 'dashboard/composables'; import { useI18n } from 'vue-i18n'; import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; -import OpenAPI from 'dashboard/api/integrations/openapi'; +import TasksAPI from 'dashboard/api/captain/tasks'; /** * Cleans and normalizes a list of labels. @@ -57,7 +57,7 @@ export function useAI() { * Computed property to check if AI integration is enabled. * @type {import('vue').ComputedRef} */ - const isAIIntegrationEnabled = computed(() => !!aiIntegration.value); + const isAIIntegrationEnabled = computed(() => true); /** * Computed property to check if label suggestion feature is enabled. @@ -77,12 +77,6 @@ export function useAI() { */ const isFetchingAppIntegrations = computed(() => uiFlags.value.isFetching); - /** - * Computed property for the hook ID. - * @type {import('vue').ComputedRef} - */ - const hookId = computed(() => aiIntegration.value?.id); - /** * Computed property for the conversation ID. * @type {import('vue').ComputedRef} @@ -139,9 +133,8 @@ export function useAI() { if (!conversationId.value) return []; try { - const result = await OpenAPI.processEvent({ + const result = await TasksAPI.processEvent({ type: 'label_suggestion', - hookId: hookId.value, conversationId: conversationId.value, }); @@ -165,9 +158,8 @@ export function useAI() { */ const processEvent = async (type = 'improve', content = '', options = {}) => { try { - const result = await OpenAPI.processEvent( + const result = await TasksAPI.processEvent( { - hookId: hookId.value, type, content: content || draftMessage.value, conversationId: conversationId.value, 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/app/policies/captain/tasks_policy.rb b/app/policies/captain/tasks_policy.rb new file mode 100644 index 000000000..d845da5cb --- /dev/null +++ b/app/policies/captain/tasks_policy.rb @@ -0,0 +1,17 @@ +class Captain::TasksPolicy < ApplicationPolicy + def rewrite? + true + end + + def summarize? + true + end + + def reply_suggestion? + true + end + + def label_suggestion? + true + end +end diff --git a/config/routes.rb b/config/routes.rb index 0ac001612..3de996c31 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -69,6 +69,12 @@ Rails.application.routes.draw do end resources :custom_tools resources :documents, only: [:index, :show, :create, :destroy] + resource :tasks, only: [], controller: 'tasks' do + post :rewrite + post :summarize + post :reply_suggestion + post :label_suggestion + end end resource :saml_settings, only: [:show, :create, :update, :destroy] resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do diff --git a/enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb new file mode 100644 index 000000000..9feea7c5f --- /dev/null +++ b/enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb @@ -0,0 +1,57 @@ +class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseController + before_action :check_authorization + + def rewrite + result = Captain::RewriteService.new( + account: Current.account, + content: params[:content], + operation: params[:operation], + conversation_display_id: params[:conversation_display_id] + ).perform + + render_result(result) + end + + def summarize + result = Captain::SummaryService.new( + account: Current.account, + conversation_display_id: params[:conversation_display_id] + ).perform + + render_result(result) + end + + def reply_suggestion + result = Captain::ReplySuggestionService.new( + account: Current.account, + conversation_display_id: params[:conversation_display_id] + ).perform + + render_result(result) + end + + def label_suggestion + result = Captain::LabelSuggestionService.new( + account: Current.account, + conversation_display_id: params[:conversation_display_id] + ).perform + + render_result(result) + end + + private + + def render_result(result) + if result.nil? + render json: { message: nil } + elsif result[:error] + render json: { error: result[:error] }, status: :unprocessable_entity + else + render json: { message: result[:message] } + end + end + + def check_authorization + authorize(:'captain/tasks') + end +end 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/enterprise/lib/enterprise/integrations/openai_prompts/summary.liquid b/enterprise/lib/enterprise/integrations/openai_prompts/summary.liquid deleted file mode 100644 index 5196f5b1b..000000000 --- a/enterprise/lib/enterprise/integrations/openai_prompts/summary.liquid +++ /dev/null @@ -1,28 +0,0 @@ -As an AI-powered summarization tool, your task is to condense lengthy interactions between customer support agents and customers into brief, digestible summaries. The objective of these summaries is to provide a quick overview, enabling any agent, even those without prior context, to grasp the essence of the conversation promptly. - -Make sure you strongly adhere to the following rules when generating the summary - -1. Be brief and concise. The shorter the summary the better. -2. Aim to summarize the conversation in approximately 200 words, formatted as multiple small paragraphs that are easier to read. -3. Describe the customer intent in around 50 words. -4. Remove information that is not directly relevant to the customer's problem or the agent's solution. For example, personal anecdotes, small talk, etc. -5. Don't include segments of the conversation that didn't contribute meaningful content, like greetings or farewell. -6. The 'Action Items' should be a bullet list, arranged in order of priority if possible. -7. 'Action Items' should strictly encapsulate tasks committed to by the agent or left incomplete. Any suggestions made by the agent should not be included. -8. The 'Action Items' should be brief and concise -9. Mark important words or parts of sentences as bold. -10. Apply markdown syntax to format any included code, using backticks. -11. Include a section for "Follow-up Items" or "Open Questions" if there are any unresolved issues or outstanding questions. -12. If any section does not have any content, remove that section and the heading from the response -13. Do not insert your own opinions about the conversation. - - -Reply in the user's language, as a markdown of the following format. - -**Customer Intent** - -**Conversation Summary** - -**Action Items** - -**Follow-up Items** \ No newline at end of file diff --git a/lib/captain/base_task_service.rb b/lib/captain/base_task_service.rb new file mode 100644 index 000000000..65b70d749 --- /dev/null +++ b/lib/captain/base_task_service.rb @@ -0,0 +1,121 @@ +class Captain::BaseTaskService + include Integrations::LlmInstrumentation + + # gpt-4o-mini supports 128,000 tokens + # 1 token is approx 4 characters + # sticking with 120000 to be safe + # 120000 * 4 = 480,000 characters (rounding off downwards to 400,000 to be safe) + TOKEN_LIMIT = 400_000 + GPT_MODEL = Llm::Config::DEFAULT_MODEL + + pattr_initialize [:account!, { conversation_display_id: nil }] + + private + + def event_name + raise NotImplementedError, "#{self.class} must implement #event_name" + end + + def conversation + @conversation ||= account.conversations.find_by(display_id: conversation_display_id) + end + + def api_base + endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/' + endpoint = endpoint.chomp('/') + "#{endpoint}/v1" + end + + def make_api_call(model:, messages:) + instrumentation_params = build_instrumentation_params(model, messages) + + instrument_llm_call(instrumentation_params) do + execute_ruby_llm_request(model: model, messages: messages) + end + end + + def execute_ruby_llm_request(model:, messages:) + Llm::Config.with_api_key(api_key, api_base: api_base) do |context| + chat = context.chat(model: model) + system_msg = messages.find { |m| m[:role] == 'system' } + chat.with_instructions(system_msg[:content]) if system_msg + + conversation_messages = messages.reject { |m| m[:role] == 'system' } + return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if conversation_messages.empty? + + add_messages_if_needed(chat, conversation_messages) + response = chat.ask(conversation_messages.last[:content]) + build_ruby_llm_response(response, messages) + end + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: account).capture_exception + { error: e.message, request_messages: messages } + end + + def add_messages_if_needed(chat, conversation_messages) + return if conversation_messages.length == 1 + + conversation_messages[0...-1].each do |msg| + chat.add_message(role: msg[:role].to_sym, content: msg[:content]) + end + end + + def build_ruby_llm_response(response, messages) + { + message: response.content, + usage: { + 'prompt_tokens' => response.input_tokens, + 'completion_tokens' => response.output_tokens, + 'total_tokens' => (response.input_tokens || 0) + (response.output_tokens || 0) + }, + request_messages: messages + } + end + + def build_instrumentation_params(model, messages) + { + span_name: "llm.#{event_name}", + account_id: account.id, + conversation_id: conversation&.display_id, + feature_name: event_name, + model: model, + messages: messages, + temperature: nil + } + end + + def conversation_messages(start_from: 0) + messages = [] + character_count = start_from + + conversation.messages + .where(message_type: [:incoming, :outgoing]) + .where(private: false) + .reorder('id desc') + .each do |message| + content = message.content_for_llm + break unless content.present? && character_count + content.length <= TOKEN_LIMIT + + messages.prepend({ role: (message.incoming? ? 'user' : 'assistant'), content: content }) + character_count += content.length + end + + messages + end + + def api_key + @api_key ||= openai_hook&.settings&.dig('api_key') || system_api_key + end + + def openai_hook + @openai_hook ||= account.hooks.find_by(app_id: 'openai', status: 'enabled') + end + + def system_api_key + @system_api_key ||= InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value + end + + def prompt_from_file(file_name) + Rails.root.join('lib/integrations/openai/openai_prompts', "#{file_name}.liquid").read + end +end diff --git a/lib/captain/label_suggestion_service.rb b/lib/captain/label_suggestion_service.rb new file mode 100644 index 000000000..1ca817fb5 --- /dev/null +++ b/lib/captain/label_suggestion_service.rb @@ -0,0 +1,89 @@ +class Captain::LabelSuggestionService < Captain::BaseTaskService + pattr_initialize [:account!, :conversation_display_id!] + + def perform + # Check cache first + cached_response = read_from_cache + return cached_response if cached_response.present? + + # Build content + content = labels_with_messages + return nil if content.blank? + + # Make API call + response = make_api_call( + model: GPT_MODEL, # TODO: Use separate model for label suggestion + messages: [ + { role: 'system', content: prompt_from_file('label_suggestion') }, + { role: 'user', content: content } + ] + ) + return response if response[:error].present? + + # Clean up response + result = { message: response[:message] ? response[:message].gsub(/^(label|labels):/i, '') : '' } + + # Cache successful result + write_to_cache(result) + + result + end + + private + + def cache_key + return nil unless conversation + + format( + ::Redis::Alfred::OPENAI_CONVERSATION_KEY, + event_name: 'label_suggestion', + conversation_id: conversation.id, + updated_at: conversation.last_activity_at.to_i + ) + end + + def read_from_cache + return nil unless cache_key + + cached = Redis::Alfred.get(cache_key) + JSON.parse(cached, symbolize_names: true) if cached.present? + rescue JSON::ParserError + nil + end + + def write_to_cache(response) + Redis::Alfred.setex(cache_key, response.to_json) if cache_key + end + + def labels_with_messages + return nil unless valid_conversation?(conversation) + + labels = account.labels.pluck(:title).join(', ') + messages = format_messages_as_string(start_from: labels.length) + + return nil if messages.blank? || labels.blank? + + "Messages:\n#{messages}\nLabels:\n#{labels}" + end + + def format_messages_as_string(start_from: 0) + messages = conversation_messages(start_from: start_from) + messages.map do |msg| + sender_type = msg[:role] == 'user' ? 'Customer' : 'Agent' + "#{sender_type}: #{msg[:content]}\n" + end.join + end + + def valid_conversation?(conversation) + return false if conversation.nil? + return false if conversation.messages.incoming.count < 3 + return false if conversation.messages.count > 100 + return false if conversation.messages.count > 20 && !conversation.messages.last.incoming? + + true + end + + def event_name + 'label_suggestion' + end +end diff --git a/lib/captain/reply_suggestion_service.rb b/lib/captain/reply_suggestion_service.rb new file mode 100644 index 000000000..6b80947a9 --- /dev/null +++ b/lib/captain/reply_suggestion_service.rb @@ -0,0 +1,18 @@ +class Captain::ReplySuggestionService < Captain::BaseTaskService + pattr_initialize [:account!, :conversation_display_id!] + + def perform + make_api_call( + model: GPT_MODEL, + messages: [ + { role: 'system', content: prompt_from_file('reply') } + ].concat(conversation_messages) + ) + end + + private + + def event_name + 'reply_suggestion' + end +end diff --git a/lib/captain/rewrite_service.rb b/lib/captain/rewrite_service.rb new file mode 100644 index 000000000..653b7465f --- /dev/null +++ b/lib/captain/rewrite_service.rb @@ -0,0 +1,67 @@ +class Captain::RewriteService < Captain::BaseTaskService + pattr_initialize [:account!, :content!, :operation!, { conversation_display_id: nil }] + + def perform + send(operation) + end + + private + + def fix_spelling_grammar + call_llm_with_prompt(prompt_from_file('fix_spelling_grammar')) + end + + def casual + call_llm_with_prompt(tone_rewrite_prompt('casual')) + end + + def professional + call_llm_with_prompt(tone_rewrite_prompt('professional')) + end + + def friendly + call_llm_with_prompt(tone_rewrite_prompt('friendly')) + end + + def confident + call_llm_with_prompt(tone_rewrite_prompt('confident')) + end + + def straightforward + call_llm_with_prompt(tone_rewrite_prompt('straightforward')) + end + + def improve + template = prompt_from_file('improve') + + system_prompt = render_liquid_template(template, { + 'conversation_context' => conversation.to_llm_text(include_contact_details: true), + 'draft_message' => content + }) + + call_llm_with_prompt(system_prompt, content) + end + + def call_llm_with_prompt(system_content, user_content = content) + make_api_call( + model: GPT_MODEL, + messages: [ + { role: 'system', content: system_content }, + { role: 'user', content: user_content } + ] + ) + 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 event_name + operation + end +end diff --git a/lib/captain/summary_service.rb b/lib/captain/summary_service.rb new file mode 100644 index 000000000..16ee57b51 --- /dev/null +++ b/lib/captain/summary_service.rb @@ -0,0 +1,19 @@ +class Captain::SummaryService < Captain::BaseTaskService + pattr_initialize [:account!, :conversation_display_id!] + + def perform + make_api_call( + model: GPT_MODEL, + messages: [ + { role: 'system', content: prompt_from_file('summary') }, + { role: 'user', content: conversation.to_llm_text(include_contact_details: false) } + ] + ) + end + + private + + def event_name + 'summarize' + end +end diff --git a/enterprise/lib/enterprise/integrations/openai_prompts/label_suggestion.liquid b/lib/integrations/openai/openai_prompts/label_suggestion.liquid similarity index 88% rename from enterprise/lib/enterprise/integrations/openai_prompts/label_suggestion.liquid rename to lib/integrations/openai/openai_prompts/label_suggestion.liquid index 6b0e436a4..7c76288f7 100644 --- a/enterprise/lib/enterprise/integrations/openai_prompts/label_suggestion.liquid +++ b/lib/integrations/openai/openai_prompts/label_suggestion.liquid @@ -1 +1 @@ -Your role is as an assistant to a customer support agent. You will be provided with a transcript of a conversation between a customer and the support agent, along with a list of potential labels. Your task is to analyze the conversation and select the two labels from the given list that most accurately represent the themes or issues discussed. Ensure you preserve the exact casing of the labels as they are provided in the list. Do not create new labels; only choose from those provided. Once you have made your selections, please provide your response as a comma-separated list of the provided labels. Remember, your response should only contain the labels you\'ve selected,in their original casing, and nothing else. \ No newline at end of file +Your role is as an assistant to a customer support agent. You will be provided with a transcript of a conversation between a customer and the support agent, along with a list of potential labels. Your task is to analyze the conversation and select the two labels from the given list that most accurately represent the themes or issues discussed. Ensure you preserve the exact casing of the labels as they are provided in the list. Do not create new labels; only choose from those provided. Once you have made your selections, please provide your response as a comma-separated list of the provided labels. Remember, your response should only contain the labels you've selected,in their original casing, and nothing else. diff --git a/lib/integrations/openai/openai_prompts/summary.liquid b/lib/integrations/openai/openai_prompts/summary.liquid index 3f1d93227..4ec5ffd5b 100644 --- a/lib/integrations/openai/openai_prompts/summary.liquid +++ b/lib/integrations/openai/openai_prompts/summary.liquid @@ -1 +1,28 @@ -Please summarize the key points from the following conversation between support agents and customer as bullet points for the next support agent looking into the conversation. Reply in the user's language. \ No newline at end of file +As an AI-powered summarization tool, your task is to condense lengthy interactions between customer support agents and customers into brief, digestible summaries. The objective of these summaries is to provide a quick overview, enabling any agent, even those without prior context, to grasp the essence of the conversation promptly. + +Make sure you strongly adhere to the following rules when generating the summary + +1. Be brief and concise. The shorter the summary the better. +2. Aim to summarize the conversation in approximately 200 words, formatted as multiple small paragraphs that are easier to read. +3. Describe the customer intent in around 50 words. +4. Remove information that is not directly relevant to the customer's problem or the agent's solution. For example, personal anecdotes, small talk, etc. +5. Don't include segments of the conversation that didn't contribute meaningful content, like greetings or farewell. +6. The 'Action Items' should be a bullet list, arranged in order of priority if possible. +7. 'Action Items' should strictly encapsulate tasks committed to by the agent or left incomplete. Any suggestions made by the agent should not be included. +8. The 'Action Items' should be brief and concise +9. Mark important words or parts of sentences as bold. +10. Apply markdown syntax to format any included code, using backticks. +11. Include a section for "Follow-up Items" or "Open Questions" if there are any unresolved issues or outstanding questions. +12. If any section does not have any content, remove that section and the heading from the response +13. Do not insert your own opinions about the conversation. + + +Reply in the user's language, as a markdown of the following format. + +**Customer Intent** + +**Conversation Summary** + +**Action Items** + +**Follow-up Items** 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/captain/base_task_service_spec.rb b/spec/lib/captain/base_task_service_spec.rb new file mode 100644 index 000000000..c35f651c5 --- /dev/null +++ b/spec/lib/captain/base_task_service_spec.rb @@ -0,0 +1,250 @@ +require 'rails_helper' + +RSpec.describe Captain::BaseTaskService do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:conversation) { create(:conversation, account: account, inbox: inbox) } + + # Create a concrete test service class since BaseTaskService is abstract + let(:test_service_class) do + Class.new(described_class) do + def perform + { message: 'Test response' } + end + + def event_name + 'test_event' + end + end + end + + let(:service) { test_service_class.new(account: account, conversation_display_id: conversation.display_id) } + + before do + create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') + end + + describe '#perform' do + it 'returns the expected result' do + result = service.perform + expect(result).to eq({ message: 'Test response' }) + end + end + + describe '#event_name' do + it 'raises NotImplementedError for base class' do + base_service = described_class.new(account: account, conversation_display_id: conversation.display_id) + expect { base_service.send(:event_name) }.to raise_error(NotImplementedError, /must implement #event_name/) + end + + it 'returns custom event name in subclass' do + expect(service.send(:event_name)).to eq('test_event') + end + end + + describe '#conversation' do + it 'finds conversation by display_id' do + expect(service.send(:conversation)).to eq(conversation) + end + + it 'memoizes the conversation' do + expect(account.conversations).to receive(:find_by).once.and_return(conversation) + service.send(:conversation) + service.send(:conversation) + end + end + + describe '#conversation_messages' do + let(:message1) { create(:message, conversation: conversation, message_type: :incoming, content: 'Hello', created_at: 1.hour.ago) } + let(:message2) { create(:message, conversation: conversation, message_type: :outgoing, content: 'Hi there', created_at: 30.minutes.ago) } + let(:message3) { create(:message, conversation: conversation, message_type: :incoming, content: 'How are you?', created_at: 10.minutes.ago) } + let(:private_message) { create(:message, conversation: conversation, message_type: :incoming, content: 'Private', private: true) } + + before do + message1 + message2 + message3 + private_message + end + + it 'returns messages in array format with role and content' do + messages = service.send(:conversation_messages) + + expect(messages).to be_an(Array) + expect(messages.length).to eq(3) + expect(messages[0]).to eq({ role: 'user', content: 'Hello' }) + expect(messages[1]).to eq({ role: 'assistant', content: 'Hi there' }) + expect(messages[2]).to eq({ role: 'user', content: 'How are you?' }) + end + + it 'excludes private messages' do + messages = service.send(:conversation_messages) + contents = messages.pluck(:content) + expect(contents).not_to include('Private') + end + + it 'respects token limit' do + # Create messages that collectively exceed token limit + # Message validation max is 150000, so create multiple large messages + 10.times do |i| + create(:message, conversation: conversation, message_type: :incoming, + content: 'a' * 100_000, created_at: i.minutes.ago) + end + + messages = service.send(:conversation_messages) + total_length = messages.sum { |m| m[:content].length } + expect(total_length).to be <= Captain::BaseTaskService::TOKEN_LIMIT + end + + it 'respects start_from offset for token counting' do + # With a start_from offset, fewer messages should fit + start_from = Captain::BaseTaskService::TOKEN_LIMIT - 100 + messages = service.send(:conversation_messages, start_from: start_from) + + total_length = messages.sum { |m| m[:content].length } + expect(total_length).to be <= 100 + end + end + + describe '#make_api_call' do + let(:model) { 'gpt-4' } + let(:messages) { [{ role: 'system', content: 'Test' }, { role: 'user', content: 'Hello' }] } + 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: 'Response', input_tokens: 10, output_tokens: 20) } + + before do + 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) + end + + it 'calls execute_ruby_llm_request with correct parameters' do + expect(service).to receive(:execute_ruby_llm_request).with(model: model, messages: messages).and_call_original + service.send(:make_api_call, model: model, messages: messages) + end + + it 'instruments the LLM call' do + expect(service).to receive(:instrument_llm_call).and_call_original + service.send(:make_api_call, model: model, messages: messages) + end + + it 'returns formatted response with tokens' do + result = service.send(:make_api_call, model: model, messages: messages) + + expect(result[:message]).to eq('Response') + expect(result[:usage]['prompt_tokens']).to eq(10) + expect(result[:usage]['completion_tokens']).to eq(20) + expect(result[:usage]['total_tokens']).to eq(30) + end + end + + describe 'chat setup' do + let(:model) { 'gpt-4' } + 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: 'Response', input_tokens: 10, output_tokens: 20) } + + before do + allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context) + allow(mock_response).to receive(:input_tokens).and_return(10) + allow(mock_response).to receive(:output_tokens).and_return(20) + end + + context 'with system instructions' do + let(:messages) { [{ role: 'system', content: 'You are helpful' }, { role: 'user', content: 'Hello' }] } + + it 'applies system instructions to chat' do + expect(mock_chat).to receive(:with_instructions).with('You are helpful') + expect(mock_chat).to receive(:ask).with('Hello').and_return(mock_response) + + service.send(:make_api_call, model: model, messages: messages) + end + end + + context 'with conversation history' do + let(:messages) do + [ + { role: 'system', content: 'You are helpful' }, + { role: 'user', content: 'First message' }, + { role: 'assistant', content: 'First response' }, + { role: 'user', content: 'Second message' } + ] + end + + it 'adds conversation history before asking' do + expect(mock_chat).to receive(:with_instructions).with('You are helpful') + expect(mock_chat).to receive(:add_message).with(role: :user, content: 'First message').ordered + expect(mock_chat).to receive(:add_message).with(role: :assistant, content: 'First response').ordered + expect(mock_chat).to receive(:ask).with('Second message').and_return(mock_response) + + service.send(:make_api_call, model: model, messages: messages) + end + end + + context 'with single message' do + let(:messages) { [{ role: 'system', content: 'You are helpful' }, { role: 'user', content: 'Hello' }] } + + it 'does not add conversation history' do + expect(mock_chat).to receive(:with_instructions).with('You are helpful') + expect(mock_chat).not_to receive(:add_message) + expect(mock_chat).to receive(:ask).with('Hello').and_return(mock_response) + + service.send(:make_api_call, model: model, messages: messages) + end + end + end + + describe 'error handling' do + let(:model) { 'gpt-4' } + let(:messages) { [{ role: 'user', content: 'Hello' }] } + let(:error) { StandardError.new('API Error') } + let(:exception_tracker) { instance_double(ChatwootExceptionTracker) } + + before do + allow(Llm::Config).to receive(:with_api_key).and_raise(error) + allow(ChatwootExceptionTracker).to receive(:new).with(error, account: account).and_return(exception_tracker) + allow(exception_tracker).to receive(:capture_exception) + end + + it 'tracks exceptions' do + expect(ChatwootExceptionTracker).to receive(:new).with(error, account: account).and_return(exception_tracker) + expect(exception_tracker).to receive(:capture_exception) + + service.send(:make_api_call, model: model, messages: messages) + end + + it 'returns error response' do + expect(exception_tracker).to receive(:capture_exception) + result = service.send(:make_api_call, model: model, messages: messages) + + expect(result[:error]).to eq('API Error') + expect(result[:request_messages]).to eq(messages) + end + end + + describe '#api_key' do + context 'when openai hook is configured' do + let(:hook) { create(:integrations_hook, account: account, app_id: 'openai', status: 'enabled', settings: { 'api_key' => 'hook-key' }) } + + before { hook } + + it 'uses api key from hook' do + expect(service.send(:api_key)).to eq('hook-key') + end + end + + context 'when openai hook is not configured' do + it 'uses system api key' do + expect(service.send(:api_key)).to eq('test-key') + end + end + end + + describe '#prompt_from_file' do + it 'reads prompt from file' do + allow(Rails.root).to receive(:join).and_return(instance_double(Pathname, read: 'Test prompt content')) + expect(service.send(:prompt_from_file, 'test')).to eq('Test prompt content') + end + end +end diff --git a/spec/lib/captain/label_suggestion_service_spec.rb b/spec/lib/captain/label_suggestion_service_spec.rb new file mode 100644 index 000000000..f99864288 --- /dev/null +++ b/spec/lib/captain/label_suggestion_service_spec.rb @@ -0,0 +1,165 @@ +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) + 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| + 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 diff --git a/spec/lib/captain/reply_suggestion_service_spec.rb b/spec/lib/captain/reply_suggestion_service_spec.rb new file mode 100644 index 000000000..64b1c0f11 --- /dev/null +++ b/spec/lib/captain/reply_suggestion_service_spec.rb @@ -0,0 +1,67 @@ +require 'rails_helper' + +RSpec.describe Captain::ReplySuggestionService do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:conversation) { create(:conversation, account: account, inbox: inbox) } + 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: 'Suggested reply', input_tokens: 100, output_tokens: 50) } + + before do + create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') + allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context) + allow(mock_chat).to receive(:with_instructions) + allow(mock_chat).to receive(:add_message) + allow(mock_chat).to receive(:ask).and_return(mock_response) + end + + describe '#perform' do + let(:message1) { create(:message, conversation: conversation, message_type: :incoming, content: 'Hello') } + let(:message2) { create(:message, conversation: conversation, message_type: :outgoing, content: 'Hi there') } + + before do + message1 + message2 + end + + it 'uses conversation_messages to build message history' do + expect(service).to receive(:conversation_messages).and_call_original + service.perform + end + + it 'concatenates system prompt with conversation history' do + allow(service).to receive(:prompt_from_file).with('reply').and_return('Help with reply') + + expect(service).to receive(:make_api_call) do |args| + expected_messages = [ + { role: 'system', content: 'Help with reply' }, + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there' } + ] + + expect(args[:messages]).to eq(expected_messages) + { message: 'Suggested reply' } + end + + service.perform + end + + it 'passes correct model to API' do + expect(service).to receive(:make_api_call).with( + hash_including(model: Captain::BaseTaskService::GPT_MODEL) + ).and_call_original + + service.perform + end + + it 'returns formatted response' do + result = service.perform + + expect(result[:message]).to eq('Suggested reply') + expect(result[:usage]['prompt_tokens']).to eq(100) + expect(result[:usage]['completion_tokens']).to eq(50) + end + end +end diff --git a/spec/lib/captain/rewrite_service_spec.rb b/spec/lib/captain/rewrite_service_spec.rb new file mode 100644 index 000000000..f776118c8 --- /dev/null +++ b/spec/lib/captain/rewrite_service_spec.rb @@ -0,0 +1,138 @@ +require 'rails_helper' + +RSpec.describe Captain::RewriteService do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:conversation) { create(:conversation, account: account, inbox: inbox) } + let(:content) { 'I need help with my order' } + let(:operation) { 'fix_spelling_grammar' } + let(:service) { described_class.new(account: account, content: content, operation: operation, 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: 'Rewritten text', input_tokens: 10, output_tokens: 5) } + + before do + create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') + 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) + end + + describe '#perform with fix_spelling_grammar operation' do + let(:operation) { 'fix_spelling_grammar' } + + it 'uses fix_spelling_grammar prompt' do + expect(service).to receive(:prompt_from_file).with('fix_spelling_grammar').and_return('Fix errors') + + expect(service).to receive(:make_api_call) do |args| + expect(args[:messages][0][:content]).to eq('Fix errors') + expect(args[:messages][1][:content]).to eq(content) + { message: 'Fixed' } + end + + service.perform + end + end + + describe 'tone rewrite methods' do + let(:tone_prompt_template) { 'Rewrite in {{ tone }} tone' } + + before do + allow(service).to receive(:prompt_from_file).with('tone_rewrite').and_return(tone_prompt_template) + end + + describe '#perform with casual operation' do + let(:operation) { 'casual' } + + it 'uses casual tone' do + expect(service).to receive(:make_api_call) do |args| + expect(args[:messages][0][:content]).to eq('Rewrite in casual tone') + { message: 'Hey, need help?' } + end + + service.perform + end + end + + describe '#perform with professional operation' do + let(:operation) { 'professional' } + + it 'uses professional tone' do + expect(service).to receive(:make_api_call) do |args| + expect(args[:messages][0][:content]).to eq('Rewrite in professional tone') + { message: 'Professional text' } + end + + service.perform + end + end + + describe '#perform with friendly operation' do + let(:operation) { 'friendly' } + + it 'uses friendly tone' do + expect(service).to receive(:make_api_call) do |args| + expect(args[:messages][0][:content]).to eq('Rewrite in friendly tone') + { message: 'Friendly text' } + end + + service.perform + end + end + + describe '#perform with confident operation' do + let(:operation) { 'confident' } + + it 'uses confident tone' do + expect(service).to receive(:make_api_call) do |args| + expect(args[:messages][0][:content]).to eq('Rewrite in confident tone') + { message: 'Confident text' } + end + + service.perform + end + end + + describe '#perform with straightforward operation' do + let(:operation) { 'straightforward' } + + it 'uses straightforward tone' do + expect(service).to receive(:make_api_call) do |args| + expect(args[:messages][0][:content]).to eq('Rewrite in straightforward tone') + { message: 'Straightforward text' } + end + + service.perform + end + end + end + + describe '#perform with improve operation' do + let(:operation) { 'improve' } + let(:improve_template) { 'Context: {{ conversation_context }}\nDraft: {{ draft_message }}' } + + before do + create(:message, conversation: conversation, message_type: :incoming, content: 'Customer message') + allow(service).to receive(:prompt_from_file).with('improve').and_return(improve_template) + end + + it 'uses conversation context and draft message with Liquid template' do + expect(service).to receive(:make_api_call) do |args| + system_content = args[:messages][0][:content] + + expect(system_content).to include('Context:') + expect(system_content).to include('Draft: I need help with my order') + expect(args[:messages][1][:content]).to eq(content) + { message: 'Improved text' } + end + + service.perform + end + + it 'returns formatted response' do + result = service.perform + + expect(result[:message]).to eq('Rewritten text') + end + end +end diff --git a/spec/lib/captain/summary_service_spec.rb b/spec/lib/captain/summary_service_spec.rb new file mode 100644 index 000000000..88e87f52b --- /dev/null +++ b/spec/lib/captain/summary_service_spec.rb @@ -0,0 +1,51 @@ +require 'rails_helper' + +RSpec.describe Captain::SummaryService do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:conversation) { create(:conversation, account: account, inbox: inbox) } + 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: 'Summary of conversation', input_tokens: 100, output_tokens: 50) } + + before do + create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') + 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) + end + + describe '#perform' do + it 'passes correct model to API' do + expect(service).to receive(:make_api_call).with( + hash_including(model: Captain::BaseTaskService::GPT_MODEL) + ).and_call_original + + service.perform + end + + it 'passes system prompt and conversation text as messages' do + allow(service).to receive(:prompt_from_file).with('summary').and_return('Summarize this') + + expect(service).to receive(:make_api_call) do |args| + expect(args[:messages].length).to eq(2) + expect(args[:messages][0][:role]).to eq('system') + expect(args[:messages][0][:content]).to eq('Summarize this') + expect(args[:messages][1][:role]).to eq('user') + expect(args[:messages][1][:content]).to be_a(String) + { message: 'Summary' } + end + + service.perform + end + + it 'returns formatted response' do + result = service.perform + + expect(result[:message]).to eq('Summary of conversation') + expect(result[:usage]['prompt_tokens']).to eq(100) + expect(result[:usage]['completion_tokens']).to eq(50) + 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 diff --git a/spec/models/integrations/hook_spec.rb b/spec/models/integrations/hook_spec.rb index 9c2eba73f..fd6e69bbc 100644 --- a/spec/models/integrations/hook_spec.rb +++ b/spec/models/integrations/hook_spec.rb @@ -31,27 +31,6 @@ RSpec.describe Integrations::Hook do end end - describe 'process_event' do - let(:account) { create(:account) } - let(:params) { { event: 'rephrase', payload: { test: 'test' } } } - - it 'returns no processor found for hooks with out processor defined' do - hook = create(:integrations_hook, account: account) - expect(hook.process_event(params)).to eq({ :error => 'No processor found' }) - end - - it 'returns results from procesor for openai hook' do - hook = create(:integrations_hook, :openai, account: account) - - openai_double = double - allow(Integrations::Openai::ProcessorService).to receive(:new).and_return(openai_double) - allow(openai_double).to receive(:perform).and_return('test') - expect(hook.process_event(params)).to eq('test') - expect(Integrations::Openai::ProcessorService).to have_received(:new).with(event: params, hook: hook) - expect(openai_double).to have_received(:perform) - end - end - describe 'scopes' do let(:account) { create(:account) } let(:inbox) { create(:inbox, account: account) }