feat: separate service and controller for editor tasks (#13085)

Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
This commit is contained in:
Shivam Mishra
2025-12-18 14:23:33 +05:30
committed by GitHub
co-authored by iamsivin Sivin Varghese Aakash Bakhle
parent 1a95a99710
commit 8f1cd32fab
25 changed files with 1219 additions and 565 deletions
@@ -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();
@@ -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();
@@ -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',
});
+4 -12
View File
@@ -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<boolean>}
*/
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<string|undefined>}
*/
const hookId = computed(() => aiIntegration.value?.id);
/**
* Computed property for the conversation ID.
* @type {import('vue').ComputedRef<string|undefined>}
@@ -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,
+4 -7
View File
@@ -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?
+17
View File
@@ -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
+6
View File
@@ -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
@@ -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
@@ -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
@@ -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**
+121
View File
@@ -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
+89
View File
@@ -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
+18
View File
@@ -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
+67
View File
@@ -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
+19
View File
@@ -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
@@ -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.
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.
@@ -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.
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**
@@ -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
+250
View File
@@ -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
@@ -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
@@ -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
+138
View File
@@ -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
+51
View File
@@ -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
@@ -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
-21
View File
@@ -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) }