Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0427daace9 | ||
|
|
9852dd25df | ||
|
|
a4e3363edd | ||
|
|
9f2076664e | ||
|
|
563be4ac2f | ||
|
|
d956a023da | ||
|
|
ccb9349bad | ||
|
|
0973b6b8ea | ||
|
|
74980760b4 | ||
|
|
98bccc7ee1 | ||
|
|
89b05138af | ||
|
|
e89d8567fd | ||
|
|
68cd725a62 | ||
|
|
4a1462c01d | ||
|
|
56e8c699b1 | ||
|
|
6dfb873db2 |
@@ -1,6 +1,7 @@
|
|||||||
class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||||
include Events::Types
|
include Events::Types
|
||||||
before_action :render_not_found_if_empty, only: [:toggle_typing, :toggle_status, :set_custom_attributes, :destroy_custom_attributes]
|
before_action :render_not_found_if_empty,
|
||||||
|
only: [:toggle_typing, :toggle_status, :request_handoff, :set_custom_attributes, :destroy_custom_attributes]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@conversation = conversation
|
@conversation = conversation
|
||||||
@@ -64,6 +65,11 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
|||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def request_handoff
|
||||||
|
perform_handoff if conversation.pending?
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
def set_custom_attributes
|
def set_custom_attributes
|
||||||
conversation.update!(custom_attributes: permitted_params[:custom_attributes])
|
conversation.update!(custom_attributes: permitted_params[:custom_attributes])
|
||||||
end
|
end
|
||||||
@@ -99,4 +105,40 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
|||||||
message: [:content, :referer_url, :timestamp, :echo_id],
|
message: [:content, :referer_url, :timestamp, :echo_id],
|
||||||
custom_attributes: {})
|
custom_attributes: {})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def perform_handoff
|
||||||
|
assistant = captain_assistant
|
||||||
|
previous_executed_by = Current.executed_by
|
||||||
|
Current.executed_by = assistant if assistant
|
||||||
|
|
||||||
|
I18n.with_locale(conversation.account.locale) do
|
||||||
|
create_captain_handoff_message(assistant) if assistant
|
||||||
|
conversation.bot_handoff!
|
||||||
|
send_out_of_office_message_after_handoff
|
||||||
|
end
|
||||||
|
ensure
|
||||||
|
Current.executed_by = previous_executed_by
|
||||||
|
end
|
||||||
|
|
||||||
|
def captain_assistant
|
||||||
|
return unless inbox.respond_to?(:captain_assistant)
|
||||||
|
|
||||||
|
inbox.captain_assistant
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_captain_handoff_message(assistant)
|
||||||
|
conversation.messages.create!(
|
||||||
|
message_type: :outgoing,
|
||||||
|
account_id: conversation.account_id,
|
||||||
|
inbox_id: conversation.inbox_id,
|
||||||
|
sender: assistant,
|
||||||
|
content: assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff')
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_out_of_office_message_after_handoff
|
||||||
|
return if conversation.campaign.present?
|
||||||
|
|
||||||
|
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ const toggleStatus = async () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const requestHandoff = async () => {
|
||||||
|
return API.post(
|
||||||
|
`/api/v1/widget/conversations/request_handoff${window.location.search}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const setCustomAttributes = async customAttributes => {
|
const setCustomAttributes = async customAttributes => {
|
||||||
return API.post(
|
return API.post(
|
||||||
`/api/v1/widget/conversations/set_custom_attributes${window.location.search}`,
|
`/api/v1/widget/conversations/set_custom_attributes${window.location.search}`,
|
||||||
@@ -90,6 +96,7 @@ export {
|
|||||||
setUserLastSeenAt,
|
setUserLastSeenAt,
|
||||||
sendEmailTranscript,
|
sendEmailTranscript,
|
||||||
toggleStatus,
|
toggleStatus,
|
||||||
|
requestHandoff,
|
||||||
setCustomAttributes,
|
setCustomAttributes,
|
||||||
deleteCustomAttribute,
|
deleteCustomAttribute,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import CustomButton from 'shared/components/Button.vue';
|
|||||||
import FooterReplyTo from 'widget/components/FooterReplyTo.vue';
|
import FooterReplyTo from 'widget/components/FooterReplyTo.vue';
|
||||||
import ChatInputWrap from 'widget/components/ChatInputWrap.vue';
|
import ChatInputWrap from 'widget/components/ChatInputWrap.vue';
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
|
import { CONVERSATION_STATUS } from 'shared/constants/messages';
|
||||||
import { sendEmailTranscript } from 'widget/api/conversation';
|
import { sendEmailTranscript } from 'widget/api/conversation';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { IFrameHelper } from '../helpers/utils';
|
import { IFrameHelper } from '../helpers/utils';
|
||||||
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
|
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
|
||||||
|
const CAPTAIN_HANDOFF_REPLY_THRESHOLD = 5;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
ChatInputWrap,
|
ChatInputWrap,
|
||||||
@@ -24,6 +27,7 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
inReplyTo: null,
|
inReplyTo: null,
|
||||||
|
isRequestingHandoff: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -31,6 +35,7 @@ export default {
|
|||||||
conversationAttributes: 'conversationAttributes/getConversationParams',
|
conversationAttributes: 'conversationAttributes/getConversationParams',
|
||||||
widgetColor: 'appConfig/getWidgetColor',
|
widgetColor: 'appConfig/getWidgetColor',
|
||||||
conversationSize: 'conversation/getConversationSize',
|
conversationSize: 'conversation/getConversationSize',
|
||||||
|
captainReplyCount: 'conversation/getCaptainReplyCount',
|
||||||
currentUser: 'contacts/getCurrentUser',
|
currentUser: 'contacts/getCurrentUser',
|
||||||
isWidgetStyleFlat: 'appConfig/isWidgetStyleFlat',
|
isWidgetStyleFlat: 'appConfig/isWidgetStyleFlat',
|
||||||
}),
|
}),
|
||||||
@@ -45,6 +50,12 @@ export default {
|
|||||||
showEmailTranscriptButton() {
|
showEmailTranscriptButton() {
|
||||||
return this.hasEmail;
|
return this.hasEmail;
|
||||||
},
|
},
|
||||||
|
showCaptainHandoffButton() {
|
||||||
|
return (
|
||||||
|
this.conversationAttributes.status === CONVERSATION_STATUS.PENDING &&
|
||||||
|
this.captainReplyCount >= CAPTAIN_HANDOFF_REPLY_THRESHOLD
|
||||||
|
);
|
||||||
|
},
|
||||||
hasEmail() {
|
hasEmail() {
|
||||||
return this.currentUser && this.currentUser.has_email;
|
return this.currentUser && this.currentUser.has_email;
|
||||||
},
|
},
|
||||||
@@ -58,7 +69,11 @@ export default {
|
|||||||
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo);
|
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo);
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions('conversation', ['sendMessage', 'sendAttachment']),
|
...mapActions('conversation', [
|
||||||
|
'sendMessage',
|
||||||
|
'sendAttachment',
|
||||||
|
'requestHandoff',
|
||||||
|
]),
|
||||||
...mapActions('conversationAttributes', ['getAttributes']),
|
...mapActions('conversationAttributes', ['getAttributes']),
|
||||||
async handleSendMessage(content) {
|
async handleSendMessage(content) {
|
||||||
await this.sendMessage({
|
await this.sendMessage({
|
||||||
@@ -90,6 +105,18 @@ export default {
|
|||||||
toggleReplyTo(message) {
|
toggleReplyTo(message) {
|
||||||
this.inReplyTo = message;
|
this.inReplyTo = message;
|
||||||
},
|
},
|
||||||
|
async handleRequestHandoff() {
|
||||||
|
try {
|
||||||
|
this.isRequestingHandoff = true;
|
||||||
|
await this.requestHandoff();
|
||||||
|
} catch (error) {
|
||||||
|
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||||
|
message: this.$t('CAPTAIN_HANDOFF.ERROR'),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.isRequestingHandoff = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
async sendTranscript() {
|
async sendTranscript() {
|
||||||
if (this.hasEmail) {
|
if (this.hasEmail) {
|
||||||
try {
|
try {
|
||||||
@@ -124,6 +151,17 @@ export default {
|
|||||||
:in-reply-to="inReplyTo"
|
:in-reply-to="inReplyTo"
|
||||||
@dismiss="inReplyTo = null"
|
@dismiss="inReplyTo = null"
|
||||||
/>
|
/>
|
||||||
|
<CustomButton
|
||||||
|
v-if="showCaptainHandoffButton"
|
||||||
|
block
|
||||||
|
class="mb-2 font-medium"
|
||||||
|
:bg-color="widgetColor"
|
||||||
|
:text-color="textColor"
|
||||||
|
:disabled="isRequestingHandoff"
|
||||||
|
@click="handleRequestHandoff"
|
||||||
|
>
|
||||||
|
{{ $t('CAPTAIN_HANDOFF.BUTTON_TEXT') }}
|
||||||
|
</CustomButton>
|
||||||
<ChatInputWrap
|
<ChatInputWrap
|
||||||
class="shadow-sm"
|
class="shadow-sm"
|
||||||
:on-send-message="handleSendMessage"
|
:on-send-message="handleSendMessage"
|
||||||
|
|||||||
@@ -47,6 +47,10 @@
|
|||||||
"CONTINUE_CONVERSATION": "Continue conversation",
|
"CONTINUE_CONVERSATION": "Continue conversation",
|
||||||
"YOU": "You",
|
"YOU": "You",
|
||||||
"START_NEW_CONVERSATION": "Start a new conversation",
|
"START_NEW_CONVERSATION": "Start a new conversation",
|
||||||
|
"CAPTAIN_HANDOFF": {
|
||||||
|
"BUTTON_TEXT": "Talk to a support agent",
|
||||||
|
"ERROR": "Could not request a handoff, please try again"
|
||||||
|
},
|
||||||
"VIEW_UNREAD_MESSAGES": "You have unread messages",
|
"VIEW_UNREAD_MESSAGES": "You have unread messages",
|
||||||
"UNREAD_VIEW": {
|
"UNREAD_VIEW": {
|
||||||
"VIEW_MESSAGES_BUTTON": "See new messages",
|
"VIEW_MESSAGES_BUTTON": "See new messages",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
toggleTyping,
|
toggleTyping,
|
||||||
setUserLastSeenAt,
|
setUserLastSeenAt,
|
||||||
toggleStatus,
|
toggleStatus,
|
||||||
|
requestHandoff,
|
||||||
setCustomAttributes,
|
setCustomAttributes,
|
||||||
deleteCustomAttribute,
|
deleteCustomAttribute,
|
||||||
} from 'widget/api/conversation';
|
} from 'widget/api/conversation';
|
||||||
@@ -212,6 +213,11 @@ export const actions = {
|
|||||||
await toggleStatus();
|
await toggleStatus();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
requestHandoff: async ({ dispatch }) => {
|
||||||
|
await requestHandoff();
|
||||||
|
dispatch('conversationAttributes/getAttributes', {}, { root: true });
|
||||||
|
},
|
||||||
|
|
||||||
setCustomAttributes: async (
|
setCustomAttributes: async (
|
||||||
{ commit, rootGetters },
|
{ commit, rootGetters },
|
||||||
customAttributes = {}
|
customAttributes = {}
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ export const getters = {
|
|||||||
getMessageCount: _state => {
|
getMessageCount: _state => {
|
||||||
return Object.values(_state.conversations).length;
|
return Object.values(_state.conversations).length;
|
||||||
},
|
},
|
||||||
|
getCaptainReplyCount: _state => {
|
||||||
|
return Object.values(_state.conversations).filter(message => {
|
||||||
|
const { message_type: messageType, sender = {} } = message;
|
||||||
|
return (
|
||||||
|
messageType === MESSAGE_TYPE.OUTGOING &&
|
||||||
|
sender.type === 'captain_assistant'
|
||||||
|
);
|
||||||
|
}).length;
|
||||||
|
},
|
||||||
getUnreadMessageCount: _state => {
|
getUnreadMessageCount: _state => {
|
||||||
const { userLastSeenAt } = _state.meta;
|
const { userLastSeenAt } = _state.meta;
|
||||||
return Object.values(_state.conversations).filter(chat => {
|
return Object.values(_state.conversations).filter(chat => {
|
||||||
|
|||||||
+4
-3
@@ -144,10 +144,11 @@
|
|||||||
- name: chatwoot_v4
|
- name: chatwoot_v4
|
||||||
display_name: Chatwoot V4
|
display_name: Chatwoot V4
|
||||||
enabled: true
|
enabled: true
|
||||||
- name: report_v4
|
- name: captain_v1_action_classifier
|
||||||
display_name: Report V4
|
display_name: Captain V1 Action Classifier
|
||||||
enabled: false
|
enabled: false
|
||||||
deprecated: true
|
premium: true
|
||||||
|
chatwoot_internal: true
|
||||||
- name: contact_chatwoot_support_team
|
- name: contact_chatwoot_support_team
|
||||||
display_name: Contact Chatwoot Support Team
|
display_name: Contact Chatwoot Support Team
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@@ -419,6 +419,7 @@ Rails.application.routes.draw do
|
|||||||
post :update_last_seen
|
post :update_last_seen
|
||||||
post :toggle_typing
|
post :toggle_typing
|
||||||
post :transcript
|
post :transcript
|
||||||
|
post :request_handoff
|
||||||
get :toggle_status
|
get :toggle_status
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
class RepurposeReportV4FlagForCaptainV1ActionClassifier < ActiveRecord::Migration[7.1]
|
||||||
|
def up
|
||||||
|
Account.feature_captain_v1_action_classifier.find_each(batch_size: 100) do |account|
|
||||||
|
account.disable_features(:captain_v1_action_classifier)
|
||||||
|
account.save!(validate: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
|
||||||
|
return if config&.value.blank?
|
||||||
|
|
||||||
|
config.value = config.value.reject { |feature| feature['name'] == 'report_v4' }
|
||||||
|
config.save!
|
||||||
|
GlobalConfig.clear_cache
|
||||||
|
end
|
||||||
|
end
|
||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
|
ActiveRecord::Schema[7.1].define(version: 2026_04_30_114500) do
|
||||||
# These extensions should be enabled to support this database
|
# These extensions should be enabled to support this database
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
enable_extension "pg_trgm"
|
enable_extension "pg_trgm"
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ module Captain::ChatResponseHelper
|
|||||||
|
|
||||||
def credit_used_for_response?(parsed_response)
|
def credit_used_for_response?(parsed_response)
|
||||||
response = parsed_response['response']
|
response = parsed_response['response']
|
||||||
response.present? && response != 'conversation_handoff'
|
|
||||||
|
# The classifier can still decide to hand off after this trace is written.
|
||||||
|
# Actual response usage is charged later in ResponseBuilderJob, so billing stays correct.
|
||||||
|
response.present? && response != 'conversation_handoff' && parsed_response['action'] != 'handoff'
|
||||||
end
|
end
|
||||||
|
|
||||||
def captain_v1_assistant?
|
def captain_v1_assistant?
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||||
|
include Captain::Conversation::V1ActionClassifier
|
||||||
|
|
||||||
MAX_MESSAGE_LENGTH = 10_000
|
MAX_MESSAGE_LENGTH = 10_000
|
||||||
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
|
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
|
||||||
retry_on Faraday::BadRequestError, attempts: 3, wait: 2.seconds
|
retry_on Faraday::BadRequestError, attempts: 3, wait: 2.seconds
|
||||||
@@ -31,9 +33,11 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
delegate :account, :inbox, to: :@conversation
|
delegate :account, :inbox, to: :@conversation
|
||||||
|
|
||||||
def generate_and_process_response
|
def generate_and_process_response
|
||||||
|
message_history = collect_previous_messages
|
||||||
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation: @conversation).generate_response(
|
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation: @conversation).generate_response(
|
||||||
message_history: collect_previous_messages
|
message_history: message_history
|
||||||
)
|
)
|
||||||
|
classify_v1_response_action(message_history) if conversation_pending?
|
||||||
process_response
|
process_response
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -102,6 +106,14 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
end
|
end
|
||||||
|
|
||||||
def v1_handoff_requested?
|
def v1_handoff_requested?
|
||||||
|
legacy_v1_handoff_token? || classifier_v1_handoff_requested?
|
||||||
|
end
|
||||||
|
|
||||||
|
def classifier_v1_handoff_requested?
|
||||||
|
@response['action'] == 'handoff'
|
||||||
|
end
|
||||||
|
|
||||||
|
def legacy_v1_handoff_token?
|
||||||
@response['response'] == 'conversation_handoff'
|
@response['response'] == 'conversation_handoff'
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -111,8 +123,13 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
|
|
||||||
def process_v1_handoff
|
def process_v1_handoff
|
||||||
I18n.with_locale(@assistant.account.locale) do
|
I18n.with_locale(@assistant.account.locale) do
|
||||||
|
Rails.logger.info(
|
||||||
|
"[CAPTAIN][ResponseBuilderJob] V1 handoff requested for account=#{account.id} conversation=#{@conversation.display_id} " \
|
||||||
|
"source=#{@response&.dig('action_source') || 'legacy'} reason=#{@response&.dig('action_reason')}"
|
||||||
|
)
|
||||||
create_handoff_message
|
create_handoff_message
|
||||||
@conversation.bot_handoff!
|
@conversation.bot_handoff!
|
||||||
|
report_v1_handoff_not_executed if conversation_pending?
|
||||||
send_out_of_office_message_if_applicable
|
send_out_of_office_message_if_applicable
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -166,6 +183,9 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
|
|
||||||
def handle_error(error)
|
def handle_error(error)
|
||||||
log_error(error)
|
log_error(error)
|
||||||
|
@response ||= {}
|
||||||
|
@response['action_source'] ||= 'error'
|
||||||
|
@response['action_reason'] ||= error_action_reason(error)
|
||||||
process_v1_handoff if conversation_pending?
|
process_v1_handoff if conversation_pending?
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
@@ -174,10 +194,23 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
ChatwootExceptionTracker.new(error, account: account).capture_exception
|
ChatwootExceptionTracker.new(error, account: account).capture_exception
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def error_action_reason(error)
|
||||||
|
error.class.name.underscore.tr('/', '_')
|
||||||
|
end
|
||||||
|
|
||||||
def captain_v2_enabled?
|
def captain_v2_enabled?
|
||||||
account.feature_enabled?('captain_integration_v2')
|
account.feature_enabled?('captain_integration_v2')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def report_v1_handoff_not_executed
|
||||||
|
error = StandardError.new("Captain V1 handoff requested but conversation #{@conversation.display_id} is still pending")
|
||||||
|
ChatwootExceptionTracker.new(error, account: account).capture_exception
|
||||||
|
Rails.logger.error(
|
||||||
|
"[CAPTAIN][ResponseBuilderJob] V1 handoff requested but not executed for account=#{account.id} " \
|
||||||
|
"conversation=#{@conversation.display_id}"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
def conversation_pending?
|
def conversation_pending?
|
||||||
status = Conversation.uncached { Conversation.where(id: @conversation.id).pick(:status) }
|
status = Conversation.uncached { Conversation.where(id: @conversation.id).pick(:status) }
|
||||||
status == 'pending' || status == Conversation.statuses[:pending]
|
status == 'pending' || status == Conversation.statuses[:pending]
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
module Captain::Conversation::V1ActionClassifier
|
||||||
|
private
|
||||||
|
|
||||||
|
def v1_action_classifier_enabled?
|
||||||
|
account.feature_enabled?('captain_v1_action_classifier')
|
||||||
|
end
|
||||||
|
|
||||||
|
def classify_v1_response_action(message_history)
|
||||||
|
return unless v1_action_classifier_enabled?
|
||||||
|
return if legacy_v1_handoff_token?
|
||||||
|
|
||||||
|
classification = Captain::Llm::AssistantActionClassifierService.new(
|
||||||
|
assistant: @assistant,
|
||||||
|
conversation: @conversation
|
||||||
|
).classify(message_history: message_history, assistant_response: @response['response'])
|
||||||
|
|
||||||
|
apply_v1_action_classification(classification)
|
||||||
|
rescue StandardError => e
|
||||||
|
ChatwootExceptionTracker.new(e, account: account).capture_exception
|
||||||
|
Rails.logger.warn(
|
||||||
|
"[CAPTAIN][ResponseBuilderJob] V1 action classifier failed for account=#{account.id} " \
|
||||||
|
"conversation=#{@conversation.display_id}: #{e.class.name}: #{e.message}"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply_v1_action_classification(classification)
|
||||||
|
action = classification['action']
|
||||||
|
return log_invalid_v1_action_classification(classification) unless valid_v1_action_classification?(action)
|
||||||
|
|
||||||
|
@response.merge!(
|
||||||
|
'action' => action,
|
||||||
|
'action_reason' => classification['action_reason'],
|
||||||
|
'action_source' => 'classifier',
|
||||||
|
'action_classifier_model' => classification['model'],
|
||||||
|
'action_classifier_prompt_version' => classification['prompt_version']
|
||||||
|
)
|
||||||
|
|
||||||
|
log_v1_action_classification(action, classification)
|
||||||
|
end
|
||||||
|
|
||||||
|
def log_v1_action_classification(action, classification)
|
||||||
|
Rails.logger.info(
|
||||||
|
"[CAPTAIN][ResponseBuilderJob] V1 action classifier account=#{account.id} conversation=#{@conversation.display_id} " \
|
||||||
|
"action=#{action} reason=#{classification['action_reason']} model=#{classification['model']} " \
|
||||||
|
"prompt_version=#{classification['prompt_version']}"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_v1_action_classification?(action)
|
||||||
|
Captain::AssistantActionSchema::ACTIONS.include?(action)
|
||||||
|
end
|
||||||
|
|
||||||
|
def log_invalid_v1_action_classification(classification)
|
||||||
|
Rails.logger.warn(
|
||||||
|
'[CAPTAIN][ResponseBuilderJob] V1 action classifier returned invalid action; falling back to assistant response ' \
|
||||||
|
"for account=#{account.id} conversation=#{@conversation.display_id}: #{classification['error'] || classification['raw_response']}"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
class Captain::Llm::AssistantActionClassifierService < Llm::BaseAiService
|
||||||
|
include Integrations::LlmInstrumentation
|
||||||
|
|
||||||
|
PROMPT_VERSION = 'v1_custom_xml_precedence'.freeze
|
||||||
|
MAX_CONTEXT_MESSAGES = 10
|
||||||
|
|
||||||
|
def initialize(assistant:, conversation:)
|
||||||
|
super()
|
||||||
|
@assistant = assistant
|
||||||
|
@conversation = conversation
|
||||||
|
@temperature = 0.0
|
||||||
|
end
|
||||||
|
|
||||||
|
def classify(message_history:, assistant_response:)
|
||||||
|
user_prompt = classification_user_prompt(
|
||||||
|
message_history: message_history,
|
||||||
|
assistant_response: assistant_response
|
||||||
|
)
|
||||||
|
|
||||||
|
response = instrument_llm_call(instrumentation_params(user_prompt)) do
|
||||||
|
chat(model: @model, temperature: @temperature)
|
||||||
|
.with_schema(Captain::AssistantActionSchema)
|
||||||
|
.with_instructions(system_prompt)
|
||||||
|
.ask(user_prompt)
|
||||||
|
end
|
||||||
|
|
||||||
|
parsed = parse_response(response.content)
|
||||||
|
normalize_response(parsed, response.content)
|
||||||
|
rescue StandardError => e
|
||||||
|
ChatwootExceptionTracker.new(e, account: @conversation.account).capture_exception
|
||||||
|
Rails.logger.warn(
|
||||||
|
"[CAPTAIN][AssistantActionClassifier] Failed for conversation #{@conversation.display_id}: #{e.class.name}: #{e.message}"
|
||||||
|
)
|
||||||
|
{ 'action' => nil, 'action_reason' => nil, 'error' => e.message, 'model' => @model, 'prompt_version' => PROMPT_VERSION }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def classification_user_prompt(message_history:, assistant_response:)
|
||||||
|
<<~PROMPT
|
||||||
|
<account_custom_instructions>
|
||||||
|
#{@assistant.config['instructions']}
|
||||||
|
</account_custom_instructions>
|
||||||
|
|
||||||
|
<conversation_context>
|
||||||
|
#{format_conversation_context(message_history)}
|
||||||
|
</conversation_context>
|
||||||
|
|
||||||
|
<assistant_response_to_classify>
|
||||||
|
#{assistant_response}
|
||||||
|
</assistant_response_to_classify>
|
||||||
|
PROMPT
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_messages(message_history)
|
||||||
|
message_history.filter_map do |message|
|
||||||
|
role = message[:role] || message['role']
|
||||||
|
next if role.blank?
|
||||||
|
|
||||||
|
{ role: role.to_s, content: normalize_content(message[:content] || message['content']) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_content(content)
|
||||||
|
return content if content.is_a?(String)
|
||||||
|
return content.filter_map { |part| part[:text] || part['text'] if text_part?(part) }.join("\n") if content.is_a?(Array)
|
||||||
|
|
||||||
|
content.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def text_part?(part)
|
||||||
|
return false unless part.is_a?(Hash)
|
||||||
|
|
||||||
|
(part[:type] || part['type']).to_s == 'text'
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_conversation_context(messages)
|
||||||
|
normalize_messages(messages).last(MAX_CONTEXT_MESSAGES).filter_map do |message|
|
||||||
|
content = message[:content].to_s.strip
|
||||||
|
next if content.blank?
|
||||||
|
|
||||||
|
"#{role_label(message[:role])}: #{content}"
|
||||||
|
end.join("\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
def role_label(role)
|
||||||
|
return 'User' if role == 'user'
|
||||||
|
return 'Assistant' if role == 'assistant'
|
||||||
|
|
||||||
|
role.to_s.titleize
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_response(content)
|
||||||
|
return content if content.is_a?(Hash)
|
||||||
|
|
||||||
|
JSON.parse(sanitize_json_response(content))
|
||||||
|
rescue JSON::ParserError, TypeError
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_response(parsed, raw_content)
|
||||||
|
action = parsed['action'].to_s
|
||||||
|
reason = parsed['action_reason'].to_s
|
||||||
|
return invalid_response(raw_content) unless Captain::AssistantActionSchema::ACTIONS.include?(action)
|
||||||
|
|
||||||
|
{
|
||||||
|
'action' => action,
|
||||||
|
'action_reason' => reason.presence,
|
||||||
|
'raw_response' => raw_content,
|
||||||
|
'model' => @model,
|
||||||
|
'prompt_version' => PROMPT_VERSION
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def invalid_response(raw_content)
|
||||||
|
{
|
||||||
|
'action' => nil,
|
||||||
|
'action_reason' => nil,
|
||||||
|
'raw_response' => raw_content,
|
||||||
|
'error' => 'invalid_classifier_response',
|
||||||
|
'model' => @model,
|
||||||
|
'prompt_version' => PROMPT_VERSION
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def instrumentation_params(user_prompt)
|
||||||
|
{
|
||||||
|
span_name: 'llm.captain.assistant_action_classifier',
|
||||||
|
model: @model,
|
||||||
|
temperature: @temperature,
|
||||||
|
account_id: @conversation.account_id,
|
||||||
|
conversation_id: @conversation.display_id,
|
||||||
|
feature_name: 'assistant_action_classifier',
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: system_prompt },
|
||||||
|
{ role: 'user', content: user_prompt }
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
assistant_id: @assistant.id,
|
||||||
|
channel_type: @conversation.inbox&.channel_type,
|
||||||
|
prompt_version: PROMPT_VERSION,
|
||||||
|
source: 'v1_response_builder'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def system_prompt
|
||||||
|
Captain::Llm::SystemPromptsService.assistant_action_classifier(
|
||||||
|
has_custom_instructions: @assistant.config['instructions'].present?
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -93,6 +93,50 @@ class Captain::Llm::SystemPromptsService
|
|||||||
SYSTEM_PROMPT_MESSAGE
|
SYSTEM_PROMPT_MESSAGE
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def assistant_action_classifier(has_custom_instructions: false)
|
||||||
|
<<~PROMPT
|
||||||
|
You are a routing classifier for a customer-support assistant.
|
||||||
|
|
||||||
|
Decide whether the current conversation should stay with the assistant or be transferred to a human agent now.
|
||||||
|
|
||||||
|
The action field MUST be one of:
|
||||||
|
- "continue": keep the current conversation with the assistant.
|
||||||
|
- "handoff": transfer the current conversation to a human agent now.
|
||||||
|
|
||||||
|
The action_reason field MUST be one of:
|
||||||
|
- "general_product_question"
|
||||||
|
- "missing_docs_bounded_answer"
|
||||||
|
- "clarifying_question_needed"
|
||||||
|
- "collect_required_identifier"
|
||||||
|
- "external_contact_or_lead_routing"
|
||||||
|
- "out_of_scope_bounded_answer"
|
||||||
|
- "explicit_human_request"
|
||||||
|
- "human_offer_accepted"
|
||||||
|
- "account_or_transaction_verification"
|
||||||
|
- "operational_issue_needs_inspection"
|
||||||
|
- "repeated_frustration_or_loop"
|
||||||
|
- "custom_instruction_transfer"
|
||||||
|
|
||||||
|
Use "continue" when:
|
||||||
|
- The user has a general product, pricing, capability, setup, pre-sales, or how-to question.
|
||||||
|
- The assistant can give a bounded answer, ask one useful clarifying question, collect a missing identifier, or share an approved external contact path.
|
||||||
|
- The assistant says someone will contact the user outside this conversation, but the current conversation itself does not need to be transferred now.
|
||||||
|
- The user has not explicitly asked for a human and the assistant is still collecting required details.
|
||||||
|
|
||||||
|
Use "handoff" when:
|
||||||
|
- The user explicitly asks for a human, agent, representative, phone call, callback, or escalation.
|
||||||
|
- The user accepts an offer to speak with a human.
|
||||||
|
- The user has provided enough detail for an account-specific or transaction-specific issue requiring private verification, such as order status, payment, deposit, withdrawal, refund, cancellation, subscription, purchase, plan activation, email verification, login, account recovery, delivery, or access.
|
||||||
|
- The user reports the same unresolved bug or operational issue after trying the assistant's suggested step, repeating the action, checking again, or otherwise making more than one reasonable attempt.
|
||||||
|
- The user is repeatedly frustrated, distrustful, or stuck in a loop.
|
||||||
|
- The assistant response itself says the current conversation will be transferred to a human agent now.
|
||||||
|
|
||||||
|
#{assistant_action_classifier_custom_instructions_policy if has_custom_instructions}
|
||||||
|
|
||||||
|
Return only the structured fields requested by the response schema.
|
||||||
|
PROMPT
|
||||||
|
end
|
||||||
|
|
||||||
# rubocop:disable Metrics/MethodLength
|
# rubocop:disable Metrics/MethodLength
|
||||||
def copilot_response_generator(product_name, available_tools, config = {})
|
def copilot_response_generator(product_name, available_tools, config = {})
|
||||||
citation_guidelines = if config['feature_citation']
|
citation_guidelines = if config['feature_citation']
|
||||||
@@ -208,7 +252,9 @@ class Captain::Llm::SystemPromptsService
|
|||||||
- Do not share anything outside of the context provided.
|
- Do not share anything outside of the context provided.
|
||||||
- Add the reasoning why you arrived at the answer
|
- Add the reasoning why you arrived at the answer
|
||||||
- Your answers will always be formatted in a valid JSON hash, as shown below. Never respond in non-JSON format.
|
- Your answers will always be formatted in a valid JSON hash, as shown below. Never respond in non-JSON format.
|
||||||
#{config['instructions'] || ''}
|
|
||||||
|
#{build_custom_instructions_section(config['instructions'])}
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
reasoning: '',
|
reasoning: '',
|
||||||
@@ -322,6 +368,17 @@ class Captain::Llm::SystemPromptsService
|
|||||||
TOOLS
|
TOOLS
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def assistant_action_classifier_custom_instructions_policy
|
||||||
|
<<~POLICY
|
||||||
|
Account custom instructions are provided inside <account_custom_instructions> tags.
|
||||||
|
These are instructions configured by the account administrator, not the current end user's message.
|
||||||
|
Use them only for routing policy: required details before handoff, account-specific escalation rules, account-specific transfer markers, and when to connect to a manager, human, supervisor, or support team.
|
||||||
|
If the custom instructions explicitly define handoff, escalation, or transfer criteria, those criteria take precedence over the generic criteria above.
|
||||||
|
Account custom instructions MUST NOT redefine the required response shape, the allowed action values, or the meaning of continue/handoff.
|
||||||
|
Ignore persona, language, formatting, pricing, and response-generation instructions except where they directly define routing or transfer criteria.
|
||||||
|
POLICY
|
||||||
|
end
|
||||||
|
|
||||||
def build_contact_context(contact)
|
def build_contact_context(contact)
|
||||||
return '' if contact.nil?
|
return '' if contact.nil?
|
||||||
|
|
||||||
@@ -331,6 +388,18 @@ class Captain::Llm::SystemPromptsService
|
|||||||
"[Contact Information]\n#{lines.join("\n")}\n\n"
|
"[Contact Information]\n#{lines.join("\n")}\n\n"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def build_custom_instructions_section(instructions)
|
||||||
|
return '' if instructions.blank?
|
||||||
|
|
||||||
|
<<~CUSTOM_INSTRUCTIONS
|
||||||
|
[Account Custom Instructions]
|
||||||
|
These instructions were configured by the account administrator. Follow them when they do not conflict with the JSON response format or the requirement to answer only from provided context.
|
||||||
|
<account_custom_instructions>
|
||||||
|
#{instructions}
|
||||||
|
</account_custom_instructions>
|
||||||
|
CUSTOM_INSTRUCTIONS
|
||||||
|
end
|
||||||
|
|
||||||
def contact_basic_lines(contact)
|
def contact_basic_lines(contact)
|
||||||
[
|
[
|
||||||
(["- Name: #{sanitize_attr(contact[:name])}"] if contact[:name].present?),
|
(["- Name: #{sanitize_attr(contact[:name])}"] if contact[:name].present?),
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
class Captain::AssistantActionSchema < RubyLLM::Schema
|
||||||
|
ACTIONS = %w[continue handoff].freeze
|
||||||
|
REASONS = %w[
|
||||||
|
general_product_question
|
||||||
|
missing_docs_bounded_answer
|
||||||
|
clarifying_question_needed
|
||||||
|
collect_required_identifier
|
||||||
|
external_contact_or_lead_routing
|
||||||
|
out_of_scope_bounded_answer
|
||||||
|
explicit_human_request
|
||||||
|
human_offer_accepted
|
||||||
|
account_or_transaction_verification
|
||||||
|
operational_issue_needs_inspection
|
||||||
|
repeated_frustration_or_loop
|
||||||
|
custom_instruction_transfer
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
string :action, enum: ACTIONS, description: 'Whether to keep the conversation with the assistant or transfer it to a human agent'
|
||||||
|
string :action_reason, enum: REASONS, description: 'The reason for the selected routing action'
|
||||||
|
end
|
||||||
@@ -10,6 +10,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
|||||||
let(:conversation) { create(:conversation, inbox: inbox, account: account, status: :pending) }
|
let(:conversation) { create(:conversation, inbox: inbox, account: account, status: :pending) }
|
||||||
let(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) }
|
let(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) }
|
||||||
let(:mock_agent_runner_service) { instance_double(Captain::Assistant::AgentRunnerService) }
|
let(:mock_agent_runner_service) { instance_double(Captain::Assistant::AgentRunnerService) }
|
||||||
|
let(:mock_action_classifier_service) { instance_double(Captain::Llm::AssistantActionClassifierService) }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
create(:message, conversation: conversation, content: 'Hello', message_type: :incoming)
|
create(:message, conversation: conversation, content: 'Hello', message_type: :incoming)
|
||||||
@@ -19,6 +20,8 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
|||||||
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain Specs' })
|
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain Specs' })
|
||||||
allow(Captain::Assistant::AgentRunnerService).to receive(:new).and_return(mock_agent_runner_service)
|
allow(Captain::Assistant::AgentRunnerService).to receive(:new).and_return(mock_agent_runner_service)
|
||||||
allow(mock_agent_runner_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain V2' })
|
allow(mock_agent_runner_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain V2' })
|
||||||
|
allow(Captain::Llm::AssistantActionClassifierService).to receive(:new).and_return(mock_action_classifier_service)
|
||||||
|
allow(mock_action_classifier_service).to receive(:classify).and_return({ 'action' => 'continue' })
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when captain_v2 is disabled' do
|
context 'when captain_v2 is disabled' do
|
||||||
@@ -48,6 +51,109 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
|||||||
expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1)
|
expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'does not run the action classifier when the classifier feature is disabled' do
|
||||||
|
expect(Captain::Llm::AssistantActionClassifierService).not_to receive(:new)
|
||||||
|
|
||||||
|
described_class.perform_now(conversation, assistant)
|
||||||
|
|
||||||
|
expect(conversation.messages.last.content).to eq('Hey, welcome to Captain Specs')
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when V1 action classifier is enabled' do
|
||||||
|
before do
|
||||||
|
allow(account).to receive(:feature_enabled?).and_return(false)
|
||||||
|
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false)
|
||||||
|
allow(account).to receive(:feature_enabled?).with('captain_v1_action_classifier').and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'keeps the conversation pending when the classifier returns continue' do
|
||||||
|
expect(Captain::Llm::AssistantActionClassifierService).to receive(:new).with(
|
||||||
|
assistant: assistant,
|
||||||
|
conversation: conversation
|
||||||
|
).and_return(mock_action_classifier_service)
|
||||||
|
expect(mock_action_classifier_service).to receive(:classify).with(
|
||||||
|
message_history: [{ content: 'Hello', role: 'user' }],
|
||||||
|
assistant_response: 'Hey, welcome to Captain Specs'
|
||||||
|
).and_return({
|
||||||
|
'action' => 'continue',
|
||||||
|
'action_reason' => 'general_product_question',
|
||||||
|
'model' => 'gpt-4.1',
|
||||||
|
'prompt_version' => 'v1'
|
||||||
|
})
|
||||||
|
|
||||||
|
described_class.perform_now(conversation, assistant)
|
||||||
|
|
||||||
|
expect(conversation.reload.status).to eq('pending')
|
||||||
|
expect(conversation.messages.outgoing.last.content).to eq('Hey, welcome to Captain Specs')
|
||||||
|
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'hands off without incrementing response usage when the classifier returns handoff' do
|
||||||
|
allow(mock_action_classifier_service).to receive(:classify).and_return({
|
||||||
|
'action' => 'handoff',
|
||||||
|
'action_reason' => 'explicit_human_request',
|
||||||
|
'model' => 'gpt-4.1',
|
||||||
|
'prompt_version' => 'v1'
|
||||||
|
})
|
||||||
|
|
||||||
|
described_class.perform_now(conversation, assistant)
|
||||||
|
|
||||||
|
expect(conversation.reload.status).to eq('open')
|
||||||
|
expect(conversation.messages.outgoing.last.content).to eq(I18n.t('conversations.captain.handoff'))
|
||||||
|
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'skips the classifier when the legacy handoff token is returned' do
|
||||||
|
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' })
|
||||||
|
expect(Captain::Llm::AssistantActionClassifierService).not_to receive(:new)
|
||||||
|
|
||||||
|
described_class.perform_now(conversation, assistant)
|
||||||
|
|
||||||
|
expect(conversation.reload.status).to eq('open')
|
||||||
|
expect(conversation.messages.outgoing.last.content).to eq(I18n.t('conversations.captain.handoff'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'falls back to the assistant response when the classifier fails' do
|
||||||
|
error = StandardError.new('classifier unavailable')
|
||||||
|
allow(mock_action_classifier_service).to receive(:classify).and_raise(error)
|
||||||
|
allow(ChatwootExceptionTracker).to receive(:new).and_call_original
|
||||||
|
|
||||||
|
described_class.perform_now(conversation, assistant)
|
||||||
|
|
||||||
|
expect(ChatwootExceptionTracker).to have_received(:new).with(error, account: account)
|
||||||
|
expect(conversation.reload.status).to eq('pending')
|
||||||
|
expect(conversation.messages.outgoing.last.content).to eq('Hey, welcome to Captain Specs')
|
||||||
|
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'falls back to the assistant response when the classifier returns an invalid action' do
|
||||||
|
allow(mock_action_classifier_service).to receive(:classify).and_return({
|
||||||
|
'action' => nil,
|
||||||
|
'error' => 'invalid_classifier_response'
|
||||||
|
})
|
||||||
|
|
||||||
|
described_class.perform_now(conversation, assistant)
|
||||||
|
|
||||||
|
expect(conversation.reload.status).to eq('pending')
|
||||||
|
expect(conversation.messages.outgoing.last.content).to eq('Hey, welcome to Captain Specs')
|
||||||
|
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'skips the classifier when the conversation is no longer pending after response generation' do
|
||||||
|
allow(mock_llm_chat_service).to receive(:generate_response) do
|
||||||
|
conversation.open!
|
||||||
|
{ 'response' => 'Hey, welcome to Captain Specs' }
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(Captain::Llm::AssistantActionClassifierService).not_to receive(:new)
|
||||||
|
|
||||||
|
described_class.perform_now(conversation, assistant)
|
||||||
|
|
||||||
|
expect(conversation.messages.outgoing.count).to eq(0)
|
||||||
|
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
it 'does not send a response when the conversation is no longer pending' do
|
it 'does not send a response when the conversation is no longer pending' do
|
||||||
conversation.open!
|
conversation.open!
|
||||||
|
|
||||||
@@ -292,9 +398,11 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
|||||||
it 'handles API errors and triggers handoff' do
|
it 'handles API errors and triggers handoff' do
|
||||||
allow(mock_llm_chat_service).to receive(:generate_response)
|
allow(mock_llm_chat_service).to receive(:generate_response)
|
||||||
.and_raise(Faraday::BadRequestError, 'Bad request to image service')
|
.and_raise(Faraday::BadRequestError, 'Bad request to image service')
|
||||||
|
allow(Rails.logger).to receive(:info).and_call_original
|
||||||
|
|
||||||
described_class.perform_now(conversation, assistant)
|
described_class.perform_now(conversation, assistant)
|
||||||
expect(conversation.reload.status).to eq('open')
|
expect(conversation.reload.status).to eq('open')
|
||||||
|
expect(Rails.logger).to have_received(:info).with(include('source=error reason=faraday_bad_request_error'))
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'succeeds when no error occurs' do
|
it 'succeeds when no error occurs' do
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Captain::Llm::AssistantActionClassifierService do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:assistant) do
|
||||||
|
create(
|
||||||
|
:captain_assistant,
|
||||||
|
account: account,
|
||||||
|
config: {
|
||||||
|
'instructions' => 'Only transfer to a manager after the user explicitly confirms.'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
let(:conversation) { create(:conversation, account: account) }
|
||||||
|
let(:service) { described_class.new(assistant: assistant, conversation: conversation) }
|
||||||
|
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||||
|
let(:mock_response) do
|
||||||
|
instance_double(
|
||||||
|
RubyLLM::Message,
|
||||||
|
content: { 'action' => 'handoff', 'action_reason' => 'human_offer_accepted' }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(RubyLLM).to receive(:chat).and_return(mock_chat)
|
||||||
|
allow(mock_chat).to receive(:with_temperature).and_return(mock_chat)
|
||||||
|
allow(mock_chat).to receive(:with_schema).and_return(mock_chat)
|
||||||
|
allow(mock_chat).to receive(:with_instructions).and_return(mock_chat)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#classify' do
|
||||||
|
let(:message_history) do
|
||||||
|
[
|
||||||
|
{ role: 'user', content: 'I cannot log in' },
|
||||||
|
{ role: 'assistant', content: 'Did you check your inbox?' },
|
||||||
|
{ role: 'user', content: 'Yes, still no reset email' }
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'passes delimited custom instructions and classifier context to the LLM' do
|
||||||
|
expect(mock_chat).to receive(:with_schema).with(Captain::AssistantActionSchema).and_return(mock_chat)
|
||||||
|
expect(mock_chat).to receive(:with_instructions).with(
|
||||||
|
a_string_including('Account custom instructions are provided inside <account_custom_instructions> tags.')
|
||||||
|
).and_return(mock_chat)
|
||||||
|
expect(mock_chat).to receive(:ask) do |prompt|
|
||||||
|
expect(prompt).to include(
|
||||||
|
'<account_custom_instructions>',
|
||||||
|
'Only transfer to a manager after the user explicitly confirms.',
|
||||||
|
'<conversation_context>',
|
||||||
|
'User: I cannot log in',
|
||||||
|
'Assistant: Did you check your inbox?',
|
||||||
|
'User: Yes, still no reset email',
|
||||||
|
'<assistant_response_to_classify>',
|
||||||
|
'Would you like to talk to support?'
|
||||||
|
)
|
||||||
|
expect(prompt).not_to include('"role"', '"content"', '<current_user_message>')
|
||||||
|
|
||||||
|
mock_response
|
||||||
|
end
|
||||||
|
|
||||||
|
result = service.classify(message_history: message_history, assistant_response: 'Would you like to talk to support?')
|
||||||
|
|
||||||
|
expect(result).to include(
|
||||||
|
'action' => 'handoff',
|
||||||
|
'action_reason' => 'human_offer_accepted',
|
||||||
|
'prompt_version' => 'v1_custom_xml_precedence'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses the configured Captain model' do
|
||||||
|
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
|
||||||
|
|
||||||
|
expect(RubyLLM).to receive(:chat).with(model: 'gpt-4.1-nano').and_return(mock_chat)
|
||||||
|
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||||
|
|
||||||
|
result = service.classify(message_history: message_history, assistant_response: 'Would you like to talk to support?')
|
||||||
|
|
||||||
|
expect(result).to include('model' => 'gpt-4.1-nano')
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the assistant has no custom instructions' do
|
||||||
|
before do
|
||||||
|
assistant.update!(config: assistant.config.except('instructions'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not add custom-instruction policy to the system prompt' do
|
||||||
|
expect(mock_chat).to receive(:with_instructions).with(
|
||||||
|
satisfy { |prompt| prompt.exclude?('Account custom instructions are provided') }
|
||||||
|
).and_return(mock_chat)
|
||||||
|
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||||
|
|
||||||
|
service.classify(message_history: message_history, assistant_response: 'Would you like to talk to support?')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -188,4 +188,29 @@ RSpec.describe Captain::Llm::AssistantChatService do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'account custom instructions in system prompt' do
|
||||||
|
before do
|
||||||
|
assistant.update!(config: assistant.config.merge('instructions' => 'if user enters 1112234 suggest handoff'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'adds custom instructions in a separate delimited section' do
|
||||||
|
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||||
|
|
||||||
|
expect(mock_chat).to receive(:with_instructions).with(
|
||||||
|
a_string_including(
|
||||||
|
'<account_custom_instructions>',
|
||||||
|
'if user enters 1112234 suggest handoff',
|
||||||
|
'</account_custom_instructions>'
|
||||||
|
)
|
||||||
|
) do |instructions|
|
||||||
|
expect(instructions).not_to include('<custom-instructions>')
|
||||||
|
expect(instructions.index('<account_custom_instructions>')).to be < instructions.index('```json')
|
||||||
|
mock_chat
|
||||||
|
end
|
||||||
|
|
||||||
|
service = described_class.new(assistant: assistant, conversation: conversation)
|
||||||
|
service.generate_response(message_history: [{ role: 'user', content: 'Hello' }])
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user