Merge branch 'feature/ai-137' into feature/ai-164
This commit is contained in:
@@ -11,6 +11,9 @@ concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deployment_check:
|
||||
name: Check Deployment
|
||||
|
||||
@@ -8,6 +8,9 @@ on:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
@@ -10,6 +10,9 @@ concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
log_lines_check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -14,6 +14,9 @@ on:
|
||||
- cron: "0 0 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
nightly:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
@@ -3,6 +3,10 @@ name: Publish Codespace Base Image
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
publish-code-space-image:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -18,6 +18,9 @@ on:
|
||||
env:
|
||||
DOCKER_REPO: chatwoot/chatwoot
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
|
||||
@@ -18,6 +18,9 @@ on:
|
||||
env:
|
||||
DOCKER_REPO: chatwoot/chatwoot
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
|
||||
@@ -10,6 +10,9 @@ concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
@@ -7,6 +7,9 @@ on:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-build:
|
||||
strategy:
|
||||
|
||||
@@ -7,11 +7,15 @@ class UserDrop < BaseDrop
|
||||
@obj.try(:available_name)
|
||||
end
|
||||
|
||||
def email
|
||||
@obj.try(:email)
|
||||
end
|
||||
|
||||
def first_name
|
||||
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
|
||||
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1
|
||||
end
|
||||
|
||||
def last_name
|
||||
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
|
||||
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { VOICE_CALL_STATUS } from '../constants';
|
||||
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
@@ -29,14 +30,16 @@ const BG_COLOR_MAP = {
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { call, conversationId, currentUserId } = useMessageContext();
|
||||
const { call, conversationId, currentUserId, inboxId } = useMessageContext();
|
||||
const { joinCall, endCall, activeCall, hasActiveCall, isJoining } =
|
||||
useCallSession();
|
||||
|
||||
const status = computed(() => call.value?.status);
|
||||
const isOutbound = computed(() => call.value?.direction === 'outgoing');
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||
);
|
||||
const acceptedByAgentId = computed(() => call.value?.accepted_by_agent_id);
|
||||
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
|
||||
const didCurrentUserAnswer = computed(
|
||||
() =>
|
||||
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
|
||||
@@ -52,8 +55,7 @@ const conversationAssignee = computed(() => {
|
||||
return conversation?.meta?.assignee || null;
|
||||
});
|
||||
const displayAgentName = computed(() => {
|
||||
if (call.value?.accepted_by_agent_name)
|
||||
return call.value.accepted_by_agent_name;
|
||||
if (call.value?.acceptedByAgentName) return call.value.acceptedByAgentName;
|
||||
if (acceptedByAgentId.value) {
|
||||
const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value);
|
||||
if (agent?.available_name) return agent.available_name;
|
||||
@@ -104,6 +106,45 @@ const iconName = computed(() => {
|
||||
});
|
||||
|
||||
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
|
||||
const callSid = computed(() => call.value?.providerCallId);
|
||||
|
||||
// Show "Join call" when the call is still ringing, no agent has claimed it,
|
||||
// and the conversation is unassigned or assigned to the current user. Mirrors
|
||||
// the eligibility used by FloatingCallWidget so the bubble can act as a
|
||||
// recovery affordance after a refresh or missed widget.
|
||||
const canJoinCall = computed(() => {
|
||||
if (status.value !== VOICE_CALL_STATUS.RINGING) return false;
|
||||
if (isOutbound.value) return false;
|
||||
if (acceptedByAgentId.value) return false;
|
||||
if (!callSid.value || !inboxId.value || !conversationId.value) return false;
|
||||
// Suppress the button once this call is the local active session — the
|
||||
// message status webhook may lag behind, so we can't rely on `status` alone
|
||||
// to hide it after a successful join from this client.
|
||||
if (hasActiveCall.value && activeCall.value?.callSid === callSid.value)
|
||||
return false;
|
||||
const assignee = conversationAssignee.value;
|
||||
if (assignee?.id && assignee.id !== currentUserId.value) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleJoinCall = async () => {
|
||||
if (!canJoinCall.value || isJoining.value) return;
|
||||
|
||||
if (hasActiveCall.value && activeCall.value?.callSid !== callSid.value) {
|
||||
await endCall({
|
||||
conversationId: activeCall.value.conversationId,
|
||||
inboxId: activeCall.value.inboxId,
|
||||
callSid: activeCall.value.callSid,
|
||||
});
|
||||
}
|
||||
|
||||
await joinCall({
|
||||
conversationId: conversationId.value,
|
||||
inboxId: inboxId.value,
|
||||
callSid: callSid.value,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -131,6 +172,15 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ subtext }}
|
||||
</span>
|
||||
<button
|
||||
v-if="canJoinCall"
|
||||
type="button"
|
||||
class="p-0 mt-1 text-xs font-medium text-start text-n-teal-10 hover:text-n-teal-11 disabled:opacity-50"
|
||||
:disabled="isJoining"
|
||||
@click="handleJoinCall"
|
||||
>
|
||||
{{ $t('CONVERSATION.VOICE_CALL.JOIN_CALL') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -84,7 +84,8 @@
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"THEY_ANSWERED": "They answered",
|
||||
"YOU_ANSWERED": "You answered",
|
||||
"AGENT_ANSWERED": "{agentName} answered"
|
||||
"AGENT_ANSWERED": "{agentName} answered",
|
||||
"JOIN_CALL": "Join call"
|
||||
},
|
||||
"HEADER": {
|
||||
"RESOLVE_ACTION": "Resolve",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint arrow-body-style: 0 */
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
import store from '../../../store';
|
||||
import ConversationView from './ConversationView.vue';
|
||||
|
||||
const CONVERSATION_PERMISSIONS = [
|
||||
@@ -10,6 +11,37 @@ const CONVERSATION_PERMISSIONS = [
|
||||
'conversation_participating_manage',
|
||||
];
|
||||
|
||||
const isFolderAvailable = async folderId => {
|
||||
let folders = store.getters['customViews/getConversationCustomViews'];
|
||||
if (!folders.length) {
|
||||
await store.dispatch('customViews/get', 'conversation');
|
||||
folders = store.getters['customViews/getConversationCustomViews'];
|
||||
}
|
||||
return folders.some(folder => folder.id === Number(folderId));
|
||||
};
|
||||
|
||||
const redirectFolderListIfUnavailable = async (to, _from, next) => {
|
||||
if (await isFolderAvailable(to.params.id)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
next({ name: 'home', params: { accountId: to.params.accountId } });
|
||||
};
|
||||
|
||||
const redirectFolderConversationIfUnavailable = async (to, _from, next) => {
|
||||
if (await isFolderAvailable(to.params.id)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
next({
|
||||
name: 'inbox_conversation',
|
||||
params: {
|
||||
accountId: to.params.accountId,
|
||||
conversation_id: to.params.conversation_id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
@@ -113,6 +145,7 @@ export default {
|
||||
meta: {
|
||||
permissions: CONVERSATION_PERMISSIONS,
|
||||
},
|
||||
beforeEnter: redirectFolderListIfUnavailable,
|
||||
component: ConversationView,
|
||||
props: route => ({ foldersId: route.params.id }),
|
||||
},
|
||||
@@ -125,6 +158,7 @@ export default {
|
||||
permissions: CONVERSATION_PERMISSIONS,
|
||||
},
|
||||
component: ConversationView,
|
||||
beforeEnter: redirectFolderConversationIfUnavailable,
|
||||
props: route => ({
|
||||
conversationId: route.params.conversation_id,
|
||||
foldersId: route.params.id,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
class Internal::TriggerHourlyScheduledItemsJob < ApplicationJob
|
||||
queue_as :scheduled_jobs
|
||||
|
||||
def perform; end
|
||||
end
|
||||
|
||||
Internal::TriggerHourlyScheduledItemsJob.prepend_mod_with('Internal::TriggerHourlyScheduledItemsJob')
|
||||
@@ -11,7 +11,7 @@ module Liquidable
|
||||
def message_drops
|
||||
{
|
||||
'contact' => ContactDrop.new(conversation.contact),
|
||||
'agent' => UserDrop.new(sender),
|
||||
'agent' => UserDrop.new(sender || conversation.assignee),
|
||||
'conversation' => ConversationDrop.new(conversation),
|
||||
'inbox' => InboxDrop.new(inbox),
|
||||
'account' => AccountDrop.new(conversation.account)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
require 'net/imap'
|
||||
|
||||
class Imap::BaseFetchEmailService
|
||||
MAX_MESSAGES_PER_SYNC = 500
|
||||
|
||||
pattr_initialize [:channel!, :interval]
|
||||
|
||||
def fetch_emails
|
||||
@@ -77,27 +79,49 @@ class Imap::BaseFetchEmailService
|
||||
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{channel.email}, found #{seq_nums.length}."
|
||||
|
||||
message_ids_with_seq = []
|
||||
seq_nums.each_slice(10).each do |batch|
|
||||
# Fetch only message-id only without mail body or contents.
|
||||
batch_message_ids = imap_client.fetch(batch, 'BODY.PEEK[HEADER]')
|
||||
|
||||
# .fetch returns an array of Net::IMAP::FetchData or nil
|
||||
# (instead of an empty array) if there is no matching message.
|
||||
# Check
|
||||
if batch_message_ids.blank?
|
||||
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch failed for #{channel.email}."
|
||||
next
|
||||
end
|
||||
|
||||
batch_message_ids.each do |data|
|
||||
message_id = build_mail_from_string(data.attr['BODY[HEADER]']).message_id
|
||||
message_ids_with_seq.push([data.seqno, message_id])
|
||||
seq_nums.each_slice(MAX_MESSAGES_PER_SYNC).each do |batch|
|
||||
append_message_ids_for_batch(batch, message_ids_with_seq)
|
||||
if message_ids_with_seq.length >= MAX_MESSAGES_PER_SYNC
|
||||
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Reached MAX_MESSAGES_PER_SYNC=#{MAX_MESSAGES_PER_SYNC} for #{channel.email}, stopping sync."
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
message_ids_with_seq
|
||||
end
|
||||
|
||||
def append_message_ids_for_batch(batch, message_ids_with_seq)
|
||||
# Fetch only message-id only without mail body or contents.
|
||||
batch_message_ids = imap_client.fetch(batch, 'BODY.PEEK[HEADER]')
|
||||
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch for #{channel.email}. Found #{batch_message_ids&.length} messages."
|
||||
|
||||
# .fetch returns an array of Net::IMAP::FetchData or nil
|
||||
# (instead of an empty array) if there is no matching message.
|
||||
if batch_message_ids.blank?
|
||||
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch failed for #{channel.email}."
|
||||
return
|
||||
end
|
||||
|
||||
batch_message_ids.each do |data|
|
||||
entry = build_message_id_entry(data)
|
||||
next if entry.nil?
|
||||
|
||||
message_ids_with_seq.push(entry)
|
||||
break if message_ids_with_seq.length >= MAX_MESSAGES_PER_SYNC
|
||||
end
|
||||
end
|
||||
|
||||
def build_message_id_entry(data)
|
||||
mail = build_mail_from_string(data.attr['BODY[HEADER]'])
|
||||
return nil if MailPresenter.new(mail, channel.account).notification_email_from_chatwoot?
|
||||
|
||||
message_id = mail.message_id
|
||||
return nil if message_id.blank?
|
||||
return nil if email_already_present?(channel, message_id)
|
||||
|
||||
[data.seqno, message_id]
|
||||
end
|
||||
|
||||
# Sends a SEARCH command to search the mailbox for messages that were
|
||||
# created between yesterday (or given date) and today and returns message sequence numbers.
|
||||
# Return <message set>
|
||||
|
||||
@@ -205,3 +205,5 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
process_response(response, message)
|
||||
end
|
||||
end
|
||||
|
||||
Whatsapp::Providers::WhatsappCloudService.prepend_mod_with('Whatsapp::Providers::WhatsappCloudService')
|
||||
|
||||
@@ -40,11 +40,6 @@ Rails.application.reloader.to_prepare do
|
||||
if File.exist?(schedule_file) && Sidekiq.server?
|
||||
schedule = YAML.load_file(schedule_file)
|
||||
|
||||
# Merge enterprise-only cron entries when running an enterprise build.
|
||||
# Mirrors the conditional-load pattern already used for enterprise initializers.
|
||||
enterprise_schedule_file = Rails.root.join('enterprise/config/schedule.yml')
|
||||
schedule.merge!(YAML.load_file(enterprise_schedule_file)) if ChatwootApp.enterprise? && enterprise_schedule_file.exist?
|
||||
|
||||
# Cron entries removed from schedule.yml but possibly still in Redis
|
||||
# with source:'dynamic' (predating the source tag). load_from_hash!
|
||||
# only cleans up source:'schedule' entries, so these need explicit removal.
|
||||
|
||||
@@ -209,6 +209,12 @@
|
||||
description: 'The limits for the Captain AI service for different plans'
|
||||
value:
|
||||
type: code
|
||||
- name: CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS
|
||||
display_title: 'Captain Document Auto Sync Intervals'
|
||||
description: 'JSON map of plan-wise Captain document auto-sync intervals in hours. Use null to disable auto-sync for a plan.'
|
||||
value:
|
||||
locked: false
|
||||
type: code
|
||||
# End of Captain Config
|
||||
|
||||
# ------- Context.dev Config ------- #
|
||||
|
||||
@@ -245,6 +245,7 @@ en:
|
||||
deleted: This message was deleted
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
call_permission_request_body: 'We would like to call you regarding your conversation.'
|
||||
delivery_status:
|
||||
error_code: 'Error code: %{error_code}'
|
||||
activity:
|
||||
|
||||
@@ -14,6 +14,12 @@ trigger_scheduled_items_job:
|
||||
class: 'TriggerScheduledItemsJob'
|
||||
queue: scheduled_jobs
|
||||
|
||||
# executed hourly for scheduled jobs that do not need minute-level cadence
|
||||
trigger_hourly_scheduled_items_job:
|
||||
cron: '0 * * * *'
|
||||
class: 'Internal::TriggerHourlyScheduledItemsJob'
|
||||
queue: scheduled_jobs
|
||||
|
||||
# executed At every minute..
|
||||
trigger_imap_email_inboxes_job:
|
||||
cron: '*/1 * * * *'
|
||||
|
||||
@@ -35,6 +35,9 @@ module Captain::ChatResponseHelper
|
||||
|
||||
def credit_used_for_response?(parsed_response)
|
||||
response = parsed_response['response']
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
@@ -106,7 +106,11 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
end
|
||||
|
||||
def v1_handoff_requested?
|
||||
@response['action'] == 'handoff' || legacy_v1_handoff_token?
|
||||
legacy_v1_handoff_token? || classifier_v1_handoff_requested?
|
||||
end
|
||||
|
||||
def classifier_v1_handoff_requested?
|
||||
@response['action'] == 'handoff'
|
||||
end
|
||||
|
||||
def legacy_v1_handoff_token?
|
||||
@@ -179,7 +183,9 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
|
||||
def handle_error(error)
|
||||
log_error(error)
|
||||
@response ||= { 'action_source' => 'error', 'action_reason' => 'response_builder_error' }
|
||||
@response ||= {}
|
||||
@response['action_source'] ||= 'error'
|
||||
@response['action_reason'] ||= error_action_reason(error)
|
||||
process_v1_handoff if conversation_pending?
|
||||
true
|
||||
end
|
||||
@@ -188,6 +194,10 @@ 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
|
||||
|
||||
@@ -25,13 +25,7 @@ module Captain::Conversation::V1ActionClassifier
|
||||
|
||||
def apply_v1_action_classification(classification)
|
||||
action = classification['action']
|
||||
unless action.in?(%w[continue handoff])
|
||||
Rails.logger.warn(
|
||||
"[CAPTAIN][ResponseBuilderJob] V1 action classifier returned invalid action for account=#{account.id} " \
|
||||
"conversation=#{@conversation.display_id}: #{classification['error'] || classification['raw_response']}"
|
||||
)
|
||||
return
|
||||
end
|
||||
return log_invalid_v1_action_classification(classification) unless valid_v1_action_classification?(action)
|
||||
|
||||
@response.merge!(
|
||||
'action' => action,
|
||||
@@ -51,4 +45,15 @@ module Captain::Conversation::V1ActionClassifier
|
||||
"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
|
||||
|
||||
@@ -7,6 +7,7 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
|
||||
|
||||
def perform
|
||||
@remaining_global_capacity = GLOBAL_HOURLY_CAP
|
||||
sync_intervals = Enterprise::Account.captain_document_sync_intervals
|
||||
stats = { accounts_scanned: 0, accounts_enabled: 0, accounts_scheduled: 0, documents_enqueued: 0 }
|
||||
|
||||
Account.joins(:captain_documents).distinct.find_each(batch_size: 100) do |account|
|
||||
@@ -16,7 +17,7 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
|
||||
next unless account.feature_enabled?('captain_document_auto_sync')
|
||||
|
||||
stats[:accounts_enabled] += 1
|
||||
interval = account.captain_document_sync_interval
|
||||
interval = account.captain_document_sync_interval(sync_intervals)
|
||||
next unless interval
|
||||
|
||||
stats[:accounts_scheduled] += 1
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
module Enterprise::Internal::TriggerHourlyScheduledItemsJob
|
||||
def perform
|
||||
super
|
||||
|
||||
Captain::Documents::ScheduleSyncsJob.perform_later
|
||||
end
|
||||
end
|
||||
@@ -1,10 +1,22 @@
|
||||
module Enterprise::Account
|
||||
CAPTAIN_SYNC_INTERVALS = {
|
||||
'hacker' => nil,
|
||||
'startups' => 7.days,
|
||||
'business' => 1.day,
|
||||
'enterprise' => 6.hours
|
||||
}.freeze
|
||||
class << self
|
||||
def captain_document_sync_intervals
|
||||
parse_captain_document_sync_intervals(InstallationConfig.find_by(name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS')&.value)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_captain_document_sync_intervals(configured_intervals)
|
||||
return {} if configured_intervals.blank?
|
||||
|
||||
parsed_intervals = configured_intervals.is_a?(String) ? JSON.parse(configured_intervals) : configured_intervals
|
||||
return {} unless parsed_intervals.is_a?(Hash)
|
||||
|
||||
parsed_intervals.transform_keys { |plan| plan.to_s.downcase }
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
# TODO: Remove this when we upgrade administrate gem to the latest version
|
||||
# this is a temporary method since current administrate doesn't support virtual attributes
|
||||
@@ -41,12 +53,15 @@ module Enterprise::Account
|
||||
custom_attributes.delete('marked_for_deletion_at') && custom_attributes.delete('marked_for_deletion_reason') && save
|
||||
end
|
||||
|
||||
def captain_document_sync_interval
|
||||
def captain_document_sync_interval(sync_intervals = Enterprise::Account.captain_document_sync_intervals)
|
||||
plan = custom_attributes['plan_name']
|
||||
plan = 'enterprise' if plan.blank? && ChatwootApp.self_hosted_enterprise?
|
||||
return nil if plan.blank?
|
||||
|
||||
CAPTAIN_SYNC_INTERVALS[plan.downcase]
|
||||
interval_hours = sync_intervals[plan.downcase]
|
||||
return nil unless interval_hours.is_a?(Integer) && interval_hours.positive?
|
||||
|
||||
interval_hours.hours
|
||||
end
|
||||
|
||||
def saml_enabled?
|
||||
|
||||
@@ -2,25 +2,24 @@ class Captain::Llm::AssistantActionClassifierService < Llm::BaseAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
PROMPT_VERSION = 'v1_custom_xml_precedence'.freeze
|
||||
DEFAULT_MODEL = 'gpt-4.1'.freeze
|
||||
MAX_CONTEXT_MESSAGES = 10
|
||||
VALID_ACTIONS = %w[continue handoff].freeze
|
||||
|
||||
def initialize(assistant:, conversation:)
|
||||
super()
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@model = DEFAULT_MODEL
|
||||
@temperature = 0.0
|
||||
end
|
||||
|
||||
def classify(message_history:, assistant_response:)
|
||||
payload = classification_payload(message_history, assistant_response)
|
||||
user_prompt = classification_user_prompt(payload)
|
||||
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_params(response_format: { type: 'json_object' })
|
||||
.with_schema(Captain::AssistantActionSchema)
|
||||
.with_instructions(system_prompt)
|
||||
.ask(user_prompt)
|
||||
end
|
||||
@@ -37,33 +36,18 @@ class Captain::Llm::AssistantActionClassifierService < Llm::BaseAiService
|
||||
|
||||
private
|
||||
|
||||
def classification_payload(message_history, assistant_response)
|
||||
normalized_messages = normalize_messages(message_history)
|
||||
|
||||
{
|
||||
'account_custom_instructions' => account_custom_instructions,
|
||||
'conversation_context' => context_messages(normalized_messages),
|
||||
'current_user_message' => current_user_message(normalized_messages),
|
||||
'assistant_response_to_classify' => assistant_response.to_s
|
||||
}
|
||||
end
|
||||
|
||||
def classification_user_prompt(payload)
|
||||
def classification_user_prompt(message_history:, assistant_response:)
|
||||
<<~PROMPT
|
||||
<account_custom_instructions>
|
||||
#{payload['account_custom_instructions']}
|
||||
#{@assistant.config['instructions']}
|
||||
</account_custom_instructions>
|
||||
|
||||
<conversation_context>
|
||||
#{payload['conversation_context'].to_json}
|
||||
#{format_conversation_context(message_history)}
|
||||
</conversation_context>
|
||||
|
||||
<current_user_message>
|
||||
#{payload['current_user_message']}
|
||||
</current_user_message>
|
||||
|
||||
<assistant_response_to_classify>
|
||||
#{payload['assistant_response_to_classify']}
|
||||
#{assistant_response}
|
||||
</assistant_response_to_classify>
|
||||
PROMPT
|
||||
end
|
||||
@@ -90,21 +74,25 @@ class Captain::Llm::AssistantActionClassifierService < Llm::BaseAiService
|
||||
(part[:type] || part['type']).to_s == 'text'
|
||||
end
|
||||
|
||||
def current_user_message(messages)
|
||||
messages.reverse.find { |message| message[:role] == 'user' }&.dig(:content).to_s
|
||||
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 context_messages(messages)
|
||||
current_user_index = messages.rindex { |message| message[:role] == 'user' }
|
||||
prior_messages = current_user_index ? messages[0...current_user_index] : messages
|
||||
prior_messages.last(MAX_CONTEXT_MESSAGES)
|
||||
end
|
||||
def role_label(role)
|
||||
return 'User' if role == 'user'
|
||||
return 'Assistant' if role == 'assistant'
|
||||
|
||||
def account_custom_instructions
|
||||
@assistant.config['instructions'].to_s
|
||||
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
|
||||
{}
|
||||
@@ -113,7 +101,7 @@ class Captain::Llm::AssistantActionClassifierService < Llm::BaseAiService
|
||||
def normalize_response(parsed, raw_content)
|
||||
action = parsed['action'].to_s
|
||||
reason = parsed['action_reason'].to_s
|
||||
return invalid_response(raw_content) unless VALID_ACTIONS.include?(action)
|
||||
return invalid_response(raw_content) unless Captain::AssistantActionSchema::ACTIONS.include?(action)
|
||||
|
||||
{
|
||||
'action' => action,
|
||||
@@ -157,6 +145,8 @@ class Captain::Llm::AssistantActionClassifierService < Llm::BaseAiService
|
||||
end
|
||||
|
||||
def system_prompt
|
||||
Captain::Llm::SystemPromptsService.assistant_action_classifier
|
||||
Captain::Llm::SystemPromptsService.assistant_action_classifier(
|
||||
has_custom_instructions: @assistant.config['instructions'].present?
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -93,7 +93,7 @@ class Captain::Llm::SystemPromptsService
|
||||
SYSTEM_PROMPT_MESSAGE
|
||||
end
|
||||
|
||||
def assistant_action_classifier
|
||||
def assistant_action_classifier(has_custom_instructions: false)
|
||||
<<~PROMPT
|
||||
You are a routing classifier for a customer-support assistant.
|
||||
|
||||
@@ -127,22 +127,13 @@ class Captain::Llm::SystemPromptsService
|
||||
- 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 a bug or operational issue that needs support to inspect their account, setup, logs, integration, channel, or delivery state after a reasonable clarifying step.
|
||||
- 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.
|
||||
|
||||
You may receive account custom instructions 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 this JSON schema, 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.
|
||||
#{assistant_action_classifier_custom_instructions_policy if has_custom_instructions}
|
||||
|
||||
Return JSON only:
|
||||
{
|
||||
"action": "continue",
|
||||
"action_reason": "general_product_question"
|
||||
}
|
||||
Return only the structured fields requested by the response schema.
|
||||
PROMPT
|
||||
end
|
||||
|
||||
@@ -261,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: '',
|
||||
@@ -375,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?
|
||||
|
||||
@@ -384,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,85 @@
|
||||
module Enterprise::Whatsapp::Providers::WhatsappCloudService
|
||||
def pre_accept_call(call_id, sdp_answer)
|
||||
call_api('pre_accept_call', call_action_body(call_id, 'pre_accept', sdp_answer))
|
||||
end
|
||||
|
||||
def accept_call(call_id, sdp_answer)
|
||||
call_api('accept_call', call_action_body(call_id, 'accept', sdp_answer))
|
||||
end
|
||||
|
||||
def reject_call(call_id)
|
||||
call_api('reject_call', call_action_body(call_id, 'reject'))
|
||||
end
|
||||
|
||||
def terminate_call(call_id)
|
||||
call_api('terminate_call', call_action_body(call_id, 'terminate'))
|
||||
end
|
||||
|
||||
def send_call_permission_request(to_phone_number, body_text = I18n.t('conversations.messages.whatsapp.call_permission_request_body'))
|
||||
response = HTTParty.post(
|
||||
"#{phone_id_path}/messages", headers: api_headers, body: permission_request_body(to_phone_number, body_text)
|
||||
)
|
||||
|
||||
unless response.success?
|
||||
Rails.logger.error "[WHATSAPP CALL] send_call_permission_request failed: status=#{response.code} body=#{response.body}"
|
||||
return nil
|
||||
end
|
||||
|
||||
response.parsed_response
|
||||
end
|
||||
|
||||
def initiate_call(to_phone_number, sdp_offer)
|
||||
response = HTTParty.post(
|
||||
"#{phone_id_path}/calls", headers: api_headers, body: initiate_call_body(to_phone_number, sdp_offer)
|
||||
)
|
||||
process_initiate_call_response(response)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def call_action_body(call_id, action, sdp_answer = nil)
|
||||
body = { messaging_product: 'whatsapp', call_id: call_id, action: action }
|
||||
body[:session] = { sdp: sdp_answer, sdp_type: 'answer' } if sdp_answer
|
||||
body
|
||||
end
|
||||
|
||||
def call_api(action_name, body)
|
||||
url = "#{phone_id_path}/calls"
|
||||
Rails.logger.info "[WHATSAPP CALL] #{action_name} POST #{url} body=#{body.except(:session).to_json}"
|
||||
response = HTTParty.post(url, headers: api_headers, body: body.to_json)
|
||||
Rails.logger.error "[WHATSAPP CALL] #{action_name} failed: status=#{response.code} body=#{response.body}" unless response.success?
|
||||
response.success?
|
||||
end
|
||||
|
||||
def permission_request_body(to_phone_number, body_text)
|
||||
{
|
||||
messaging_product: 'whatsapp', recipient_type: 'individual', to: to_phone_number,
|
||||
type: 'interactive',
|
||||
interactive: {
|
||||
type: 'call_permission_request',
|
||||
action: { name: 'call_permission_request' },
|
||||
body: { text: body_text }
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
|
||||
def initiate_call_body(to_phone_number, sdp_offer)
|
||||
{
|
||||
messaging_product: 'whatsapp', to: to_phone_number, type: 'audio',
|
||||
session: { sdp: sdp_offer, sdp_type: 'offer' }
|
||||
}.to_json
|
||||
end
|
||||
|
||||
def process_initiate_call_response(response)
|
||||
return response.parsed_response if response.success?
|
||||
|
||||
Rails.logger.error "[WHATSAPP CALL] initiate_call failed: status=#{response.code} body=#{response.body}"
|
||||
parsed = response.parsed_response.is_a?(Hash) ? response.parsed_response : {}
|
||||
error_code = parsed.dig('error', 'code')
|
||||
error_msg = parsed.dig('error', 'error_user_msg') || 'Failed to initiate call'
|
||||
|
||||
raise Voice::CallErrors::NoCallPermission, error_msg if error_code == Voice::CallErrors::NO_CALL_PERMISSION_CODE
|
||||
|
||||
raise Voice::CallErrors::CallFailed, error_msg
|
||||
end
|
||||
end
|
||||
@@ -1,10 +0,0 @@
|
||||
# Enterprise-only Sidekiq cron schedule.
|
||||
# Loaded by config/initializers/sidekiq.rb only when ChatwootApp.enterprise? is true.
|
||||
# Add cron entries here when the referenced job class lives under enterprise/.
|
||||
|
||||
# Captain document auto-sync scheduler
|
||||
# Runs hourly, finds due documents based on plan sync intervals
|
||||
captain_documents_schedule_syncs_job:
|
||||
cron: '0 * * * *'
|
||||
class: 'Captain::Documents::ScheduleSyncsJob'
|
||||
queue: scheduled_jobs
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
module Voice::CallErrors
|
||||
# Meta WhatsApp Cloud Calling error code returned when the contact has not
|
||||
# granted call permission yet. See `initiate_call` in
|
||||
# Enterprise::Whatsapp::Providers::WhatsappCloudService.
|
||||
NO_CALL_PERMISSION_CODE = 138_006
|
||||
|
||||
class NoCallPermission < StandardError; end
|
||||
class CallFailed < StandardError; end
|
||||
class NotRinging < StandardError; end
|
||||
class AlreadyAccepted < StandardError; end
|
||||
end
|
||||
@@ -398,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
|
||||
|
||||
@@ -5,6 +5,7 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { business: 24, hacker: nil }.to_json)
|
||||
account.enable_features!('captain_document_auto_sync')
|
||||
clear_enqueued_jobs
|
||||
end
|
||||
|
||||
@@ -225,46 +225,68 @@ RSpec.describe Account, type: :model do
|
||||
describe 'captain document sync cadence' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
it 'has no cadence on the hacker plan' do
|
||||
account.update!(custom_attributes: { plan_name: 'hacker' })
|
||||
expect(account.captain_document_sync_interval).to be_nil
|
||||
end
|
||||
|
||||
it 'syncs weekly on the startups plan' do
|
||||
account.update!(custom_attributes: { plan_name: 'startups' })
|
||||
expect(account.captain_document_sync_interval).to eq(7.days)
|
||||
end
|
||||
|
||||
it 'syncs daily on the business plan' do
|
||||
it 'has no cadence when installation config is missing' do
|
||||
account.update!(custom_attributes: { plan_name: 'business' })
|
||||
expect(account.captain_document_sync_interval).to eq(1.day)
|
||||
end
|
||||
|
||||
it 'syncs every six hours on the enterprise plan' do
|
||||
account.update!(custom_attributes: { plan_name: 'enterprise' })
|
||||
expect(account.captain_document_sync_interval).to eq(6.hours)
|
||||
end
|
||||
|
||||
it 'has no cadence when plan is missing' do
|
||||
account.update!(custom_attributes: {})
|
||||
expect(account.captain_document_sync_interval).to be_nil
|
||||
end
|
||||
|
||||
it 'has no cadence for unknown plans' do
|
||||
account.update!(custom_attributes: { plan_name: 'mystery' })
|
||||
expect(account.captain_document_sync_interval).to be_nil
|
||||
it 'uses configured plan intervals from installation config' do
|
||||
intervals = {
|
||||
business: 48,
|
||||
enterprise: 24
|
||||
}
|
||||
create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: intervals.to_json)
|
||||
account.update!(custom_attributes: { plan_name: 'business' })
|
||||
|
||||
expect(account.captain_document_sync_interval).to eq(2.days)
|
||||
end
|
||||
|
||||
it 'normalizes plan name casing' do
|
||||
it 'normalizes configured plan name casing' do
|
||||
create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { business: 24 }.to_json)
|
||||
account.update!(custom_attributes: { plan_name: 'Business' })
|
||||
|
||||
expect(account.captain_document_sync_interval).to eq(1.day)
|
||||
end
|
||||
|
||||
it 'syncs every six hours on self-hosted enterprise installs without a plan_name' do
|
||||
it 'uses the enterprise cadence for self-hosted enterprise installs without a plan_name' do
|
||||
allow(ChatwootApp).to receive(:self_hosted_enterprise?).and_return(true)
|
||||
create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { enterprise: 6 }.to_json)
|
||||
account.update!(custom_attributes: {})
|
||||
|
||||
expect(account.captain_document_sync_interval).to eq(6.hours)
|
||||
end
|
||||
|
||||
it 'allows installation config to disable a plan cadence' do
|
||||
create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { business: nil }.to_json)
|
||||
account.update!(custom_attributes: { plan_name: 'business' })
|
||||
|
||||
expect(account.captain_document_sync_interval).to be_nil
|
||||
end
|
||||
|
||||
it 'has no cadence when installation config is invalid' do
|
||||
create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: 'invalid-json')
|
||||
account.update!(custom_attributes: { plan_name: 'business' })
|
||||
|
||||
expect(account.captain_document_sync_interval).to be_nil
|
||||
end
|
||||
|
||||
it 'treats invalid plan interval values as disabled' do
|
||||
intervals = {
|
||||
business: false,
|
||||
enterprise: { hours: 6 },
|
||||
startups: '168'
|
||||
}
|
||||
create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: intervals.to_json)
|
||||
|
||||
account.update!(custom_attributes: { plan_name: 'business' })
|
||||
expect(account.captain_document_sync_interval).to be_nil
|
||||
|
||||
account.update!(custom_attributes: { plan_name: 'enterprise' })
|
||||
expect(account.captain_document_sync_interval).to be_nil
|
||||
|
||||
account.update!(custom_attributes: { plan_name: 'startups' })
|
||||
expect(account.captain_document_sync_interval).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe 'account deletion' do
|
||||
|
||||
@@ -17,14 +17,14 @@ RSpec.describe Captain::Llm::AssistantActionClassifierService do
|
||||
let(:mock_response) do
|
||||
instance_double(
|
||||
RubyLLM::Message,
|
||||
content: '{"action":"handoff","action_reason":"human_offer_accepted"}'
|
||||
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_params).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
|
||||
|
||||
@@ -38,24 +38,25 @@ RSpec.describe Captain::Llm::AssistantActionClassifierService do
|
||||
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(
|
||||
'Use them only for routing policy',
|
||||
'MUST NOT redefine this JSON schema'
|
||||
)
|
||||
a_string_including('Account custom instructions are provided inside <account_custom_instructions> tags.')
|
||||
).and_return(mock_chat)
|
||||
expect(mock_chat).to receive(:ask).with(
|
||||
a_string_including(
|
||||
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>',
|
||||
'"content":"I cannot log in"',
|
||||
'<current_user_message>',
|
||||
'Yes, still no reset email',
|
||||
'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?'
|
||||
)
|
||||
).and_return(mock_response)
|
||||
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?')
|
||||
|
||||
@@ -65,5 +66,31 @@ RSpec.describe Captain::Llm::AssistantActionClassifierService do
|
||||
'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
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::Providers::WhatsappCloudService do
|
||||
subject(:service) { described_class.new(whatsapp_channel: whatsapp_channel) }
|
||||
|
||||
let(:whatsapp_channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false) }
|
||||
let(:calls_url) { 'https://graph.facebook.com/v13.0/123456789/calls' }
|
||||
let(:messages_url) { 'https://graph.facebook.com/v13.0/123456789/messages' }
|
||||
let(:headers) { { 'Content-Type' => 'application/json' } }
|
||||
|
||||
before { stub_request(:get, /message_templates/) }
|
||||
|
||||
describe 'call action methods' do
|
||||
it 'POSTs the action body with the SDP answer and returns true on success' do
|
||||
stub_request(:post, calls_url)
|
||||
.with(body: { messaging_product: 'whatsapp', call_id: 'WACALL', action: 'pre_accept',
|
||||
session: { sdp: 'sdp_answer', sdp_type: 'answer' } }.to_json)
|
||||
.to_return(status: 200, body: '{}', headers: headers)
|
||||
|
||||
expect(service.pre_accept_call('WACALL', 'sdp_answer')).to be true
|
||||
end
|
||||
|
||||
it 'returns false when Meta responds with a non-success status' do
|
||||
stub_request(:post, calls_url).to_return(status: 400, body: '{}', headers: headers)
|
||||
|
||||
expect(service.reject_call('WACALL')).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#send_call_permission_request' do
|
||||
it 'returns the parsed body on success' do
|
||||
stub_request(:post, messages_url)
|
||||
.with(body: hash_including(messaging_product: 'whatsapp', to: '15551234567', type: 'interactive'))
|
||||
.to_return(status: 200, body: { messages: [{ id: 'wamid' }] }.to_json, headers: headers)
|
||||
|
||||
expect(service.send_call_permission_request('15551234567')).to eq('messages' => [{ 'id' => 'wamid' }])
|
||||
end
|
||||
end
|
||||
|
||||
describe '#initiate_call' do
|
||||
it 'returns the parsed body on success' do
|
||||
stub_request(:post, calls_url)
|
||||
.with(body: { messaging_product: 'whatsapp', to: '15551234567', type: 'audio',
|
||||
session: { sdp: 'sdp_offer', sdp_type: 'offer' } }.to_json)
|
||||
.to_return(status: 200, body: { messages: [{ id: 'wacall_1' }] }.to_json, headers: headers)
|
||||
|
||||
expect(service.initiate_call('15551234567', 'sdp_offer')).to eq('messages' => [{ 'id' => 'wacall_1' }])
|
||||
end
|
||||
|
||||
it 'raises Voice::CallErrors::NoCallPermission when Meta returns error code 138006' do
|
||||
stub_request(:post, calls_url).to_return(
|
||||
status: 400,
|
||||
body: { error: { code: 138_006, error_user_msg: 'No call permission' } }.to_json,
|
||||
headers: headers
|
||||
)
|
||||
|
||||
expect { service.initiate_call('15551234567', 'sdp_offer') }
|
||||
.to raise_error(Voice::CallErrors::NoCallPermission, 'No call permission')
|
||||
end
|
||||
|
||||
it 'raises Voice::CallErrors::CallFailed with a fallback message when the error body is non-JSON' do
|
||||
stub_request(:post, calls_url).to_return(status: 502, body: '<html>502 Bad Gateway</html>',
|
||||
headers: { 'Content-Type' => 'text/html' })
|
||||
|
||||
expect { service.initiate_call('15551234567', 'sdp_offer') }
|
||||
.to raise_error(Voice::CallErrors::CallFailed, 'Failed to initiate call')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -7,6 +7,7 @@ RSpec.describe Imap::FetchEmailService do
|
||||
let(:imap_email_channel) { create(:channel_email, :imap_email, account: account) }
|
||||
let(:imap) { instance_double(Net::IMAP) }
|
||||
let(:eml_content_with_message_id) { Rails.root.join('spec/fixtures/files/only_text.eml').read }
|
||||
let(:eml_content_without_message_id) { eml_content_with_message_id.sub(/^Message-ID:.*\n/, '') }
|
||||
|
||||
describe '#perform' do
|
||||
before do
|
||||
@@ -63,6 +64,34 @@ RSpec.describe Imap::FetchEmailService do
|
||||
expect(imap).not_to have_received(:fetch).with(1, 'RFC822')
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not count emails without message ids toward the sync limit' do
|
||||
travel_to '26.10.2020 10:00'.to_datetime do
|
||||
email_object = create_inbound_email_from_fixture('only_text.eml')
|
||||
max_messages_per_sync = Imap::BaseFetchEmailService::MAX_MESSAGES_PER_SYNC
|
||||
empty_message_id_seq_nums = (1..max_messages_per_sync).to_a
|
||||
valid_message_seq_num = max_messages_per_sync + 1
|
||||
empty_message_id_headers = empty_message_id_seq_nums.map do |seq_num|
|
||||
Net::IMAP::FetchData.new(seq_num, 'BODY[HEADER]' => eml_content_without_message_id)
|
||||
end
|
||||
valid_email_header = Net::IMAP::FetchData.new(valid_message_seq_num, 'BODY[HEADER]' => eml_content_with_message_id)
|
||||
imap_fetch_mail = Net::IMAP::FetchData.new(valid_message_seq_num, 'RFC822' => eml_content_with_message_id)
|
||||
|
||||
allow(imap).to receive(:search).with(%w[SINCE 25-Oct-2020]).and_return(empty_message_id_seq_nums + [valid_message_seq_num])
|
||||
allow(imap).to receive(:fetch).with(empty_message_id_seq_nums, 'BODY.PEEK[HEADER]').and_return(empty_message_id_headers)
|
||||
allow(imap).to receive(:fetch).with([valid_message_seq_num], 'BODY.PEEK[HEADER]').and_return([valid_email_header])
|
||||
allow(imap).to receive(:fetch).with(valid_message_seq_num, 'RFC822').and_return([imap_fetch_mail])
|
||||
allow(imap).to receive(:logout)
|
||||
|
||||
result = described_class.new(channel: imap_email_channel).perform
|
||||
|
||||
expect(result.length).to eq 1
|
||||
expect(result[0].message_id).to eq email_object.message_id
|
||||
expect(imap).to have_received(:fetch).with(empty_message_id_seq_nums, 'BODY.PEEK[HEADER]')
|
||||
expect(imap).to have_received(:fetch).with([valid_message_seq_num], 'BODY.PEEK[HEADER]')
|
||||
expect(imap).to have_received(:fetch).with(valid_message_seq_num, 'RFC822')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user