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:
co-authored by
iamsivin
Sivin Varghese
Aakash Bakhle
parent
1a95a99710
commit
8f1cd32fab
@@ -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',
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
@@ -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**
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user