Compare commits

...
16 Commits
Author SHA1 Message Date
Aakash BakhleandGitHub 0427daace9 Merge branch 'feature/ai-137' into feature/ai-164 2026-05-04 18:44:17 +05:30
aakashb95 9852dd25df add: clarifying comment on credit usage 2026-05-04 18:41:37 +05:30
Aakash BakhleandGitHub a4e3363edd Merge branch 'develop' into feature/ai-137 2026-05-04 17:58:35 +05:30
aakashb95 9f2076664e test(captain): simplify classifier model spec 2026-05-04 17:14:57 +05:30
aakashb95 563be4ac2f fix(captain): respect configured classifier model 2026-05-04 17:11:57 +05:30
aakashb95 d956a023da fix(captain): conditionally include custom instruction policy 2026-05-04 17:08:41 +05:30
aakashb95 ccb9349bad refactor(captain): use schema for action classifier 2026-05-04 16:45:29 +05:30
aakashb95 0973b6b8ea test(captain): avoid brittle prompt wording checks 2026-05-04 16:39:36 +05:30
aakashb95 74980760b4 fix(captain): separate custom instructions in prompts 2026-05-04 16:20:26 +05:30
Aakash BakhleandGitHub 98bccc7ee1 Merge branch 'develop' into feature/ai-137 2026-05-04 16:09:27 +05:30
aakashb95 89b05138af fix(captain): log specific error handoff reason 2026-05-04 12:13:18 +05:30
aakashb95 e89d8567fd feat(widget): Add captain handoff request 2026-05-01 23:01:05 +05:30
aakashb95 68cd725a62 fix(captain): Skip classifier call when conversation is pending 2026-05-01 02:54:58 +05:30
aakashb95 4a1462c01d db: Remove report_v4 from account defaults 2026-05-01 02:54:58 +05:30
Aakash BakhleandGitHub 56e8c699b1 Merge branch 'develop' into feature/ai-137 2026-05-01 02:31:31 +05:30
aakashb95 6dfb873db2 fix(captain): add v1 handoff classifier 2026-05-01 00:56:34 +05:30
19 changed files with 697 additions and 9 deletions
@@ -1,6 +1,7 @@
class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
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
@conversation = conversation
@@ -64,6 +65,11 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
head :ok
end
def request_handoff
perform_handoff if conversation.pending?
head :ok
end
def set_custom_attributes
conversation.update!(custom_attributes: permitted_params[:custom_attributes])
end
@@ -99,4 +105,40 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
message: [:content, :referer_url, :timestamp, :echo_id],
custom_attributes: {})
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
@@ -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 => {
return API.post(
`/api/v1/widget/conversations/set_custom_attributes${window.location.search}`,
@@ -90,6 +96,7 @@ export {
setUserLastSeenAt,
sendEmailTranscript,
toggleStatus,
requestHandoff,
setCustomAttributes,
deleteCustomAttribute,
};
@@ -5,12 +5,15 @@ import CustomButton from 'shared/components/Button.vue';
import FooterReplyTo from 'widget/components/FooterReplyTo.vue';
import ChatInputWrap from 'widget/components/ChatInputWrap.vue';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CONVERSATION_STATUS } from 'shared/constants/messages';
import { sendEmailTranscript } from 'widget/api/conversation';
import { useRouter } from 'vue-router';
import { IFrameHelper } from '../helpers/utils';
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
import { emitter } from 'shared/helpers/mitt';
const CAPTAIN_HANDOFF_REPLY_THRESHOLD = 5;
export default {
components: {
ChatInputWrap,
@@ -24,6 +27,7 @@ export default {
data() {
return {
inReplyTo: null,
isRequestingHandoff: false,
};
},
computed: {
@@ -31,6 +35,7 @@ export default {
conversationAttributes: 'conversationAttributes/getConversationParams',
widgetColor: 'appConfig/getWidgetColor',
conversationSize: 'conversation/getConversationSize',
captainReplyCount: 'conversation/getCaptainReplyCount',
currentUser: 'contacts/getCurrentUser',
isWidgetStyleFlat: 'appConfig/isWidgetStyleFlat',
}),
@@ -45,6 +50,12 @@ export default {
showEmailTranscriptButton() {
return this.hasEmail;
},
showCaptainHandoffButton() {
return (
this.conversationAttributes.status === CONVERSATION_STATUS.PENDING &&
this.captainReplyCount >= CAPTAIN_HANDOFF_REPLY_THRESHOLD
);
},
hasEmail() {
return this.currentUser && this.currentUser.has_email;
},
@@ -58,7 +69,11 @@ export default {
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo);
},
methods: {
...mapActions('conversation', ['sendMessage', 'sendAttachment']),
...mapActions('conversation', [
'sendMessage',
'sendAttachment',
'requestHandoff',
]),
...mapActions('conversationAttributes', ['getAttributes']),
async handleSendMessage(content) {
await this.sendMessage({
@@ -90,6 +105,18 @@ export default {
toggleReplyTo(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() {
if (this.hasEmail) {
try {
@@ -124,6 +151,17 @@ export default {
:in-reply-to="inReplyTo"
@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
class="shadow-sm"
:on-send-message="handleSendMessage"
@@ -47,6 +47,10 @@
"CONTINUE_CONVERSATION": "Continue conversation",
"YOU": "You",
"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",
"UNREAD_VIEW": {
"VIEW_MESSAGES_BUTTON": "See new messages",
@@ -6,6 +6,7 @@ import {
toggleTyping,
setUserLastSeenAt,
toggleStatus,
requestHandoff,
setCustomAttributes,
deleteCustomAttribute,
} from 'widget/api/conversation';
@@ -212,6 +213,11 @@ export const actions = {
await toggleStatus();
},
requestHandoff: async ({ dispatch }) => {
await requestHandoff();
dispatch('conversationAttributes/getAttributes', {}, { root: true });
},
setCustomAttributes: async (
{ commit, rootGetters },
customAttributes = {}
@@ -39,6 +39,15 @@ export const getters = {
getMessageCount: _state => {
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 => {
const { userLastSeenAt } = _state.meta;
return Object.values(_state.conversations).filter(chat => {
+4 -3
View File
@@ -144,10 +144,11 @@
- name: chatwoot_v4
display_name: Chatwoot V4
enabled: true
- name: report_v4
display_name: Report V4
- name: captain_v1_action_classifier
display_name: Captain V1 Action Classifier
enabled: false
deprecated: true
premium: true
chatwoot_internal: true
- name: contact_chatwoot_support_team
display_name: Contact Chatwoot Support Team
enabled: true
+1
View File
@@ -419,6 +419,7 @@ Rails.application.routes.draw do
post :update_last_seen
post :toggle_typing
post :transcript
post :request_handoff
get :toggle_status
end
end
@@ -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
View File
@@ -10,7 +10,7 @@
#
# 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
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -35,7 +35,10 @@ module Captain::ChatResponseHelper
def credit_used_for_response?(parsed_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
def captain_v1_assistant?
@@ -1,4 +1,6 @@
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
include Captain::Conversation::V1ActionClassifier
MAX_MESSAGE_LENGTH = 10_000
retry_on ActiveStorage::FileNotFoundError, 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
def generate_and_process_response
message_history = collect_previous_messages
@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
end
@@ -102,6 +106,14 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end
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'
end
@@ -111,8 +123,13 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
def process_v1_handoff
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
@conversation.bot_handoff!
report_v1_handoff_not_executed if conversation_pending?
send_out_of_office_message_if_applicable
end
end
@@ -166,6 +183,9 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
def handle_error(error)
log_error(error)
@response ||= {}
@response['action_source'] ||= 'error'
@response['action_reason'] ||= error_action_reason(error)
process_v1_handoff if conversation_pending?
true
end
@@ -174,10 +194,23 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
ChatwootExceptionTracker.new(error, account: account).capture_exception
end
def error_action_reason(error)
error.class.name.underscore.tr('/', '_')
end
def captain_v2_enabled?
account.feature_enabled?('captain_integration_v2')
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?
status = Conversation.uncached { Conversation.where(id: @conversation.id).pick(:status) }
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
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
def copilot_response_generator(product_name, available_tools, config = {})
citation_guidelines = if config['feature_citation']
@@ -208,7 +252,9 @@ class Captain::Llm::SystemPromptsService
- Do not share anything outside of the context provided.
- 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.
#{config['instructions'] || ''}
#{build_custom_instructions_section(config['instructions'])}
```json
{
reasoning: '',
@@ -322,6 +368,17 @@ class Captain::Llm::SystemPromptsService
TOOLS
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)
return '' if contact.nil?
@@ -331,6 +388,18 @@ class Captain::Llm::SystemPromptsService
"[Contact Information]\n#{lines.join("\n")}\n\n"
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)
[
(["- 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(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) }
let(:mock_agent_runner_service) { instance_double(Captain::Assistant::AgentRunnerService) }
let(:mock_action_classifier_service) { instance_double(Captain::Llm::AssistantActionClassifierService) }
before do
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(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(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
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)
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
conversation.open!
@@ -292,9 +398,11 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
it 'handles API errors and triggers handoff' do
allow(mock_llm_chat_service).to receive(:generate_response)
.and_raise(Faraday::BadRequestError, 'Bad request to image service')
allow(Rails.logger).to receive(:info).and_call_original
described_class.perform_now(conversation, assistant)
expect(conversation.reload.status).to eq('open')
expect(Rails.logger).to have_received(:info).with(include('source=error reason=faraday_bad_request_error'))
end
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
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