Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a1dd481e9 | ||
|
|
a98666030b |
+6
-1
@@ -28,7 +28,12 @@ const inboxes = computed(() => {
|
||||
return {
|
||||
name: inbox.name,
|
||||
id: inbox.id,
|
||||
icon: getInboxIconByType(inbox.channelType, inbox.medium, 'line'),
|
||||
icon: getInboxIconByType(
|
||||
inbox.channelType,
|
||||
inbox.medium,
|
||||
'line',
|
||||
inbox.voiceEnabled
|
||||
),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { relativeDayTimestamp } from 'shared/helpers/timeHelper';
|
||||
import { getInboxVoiceIcon } from 'dashboard/helper/inbox';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import AudioPlayer from 'dashboard/components-next/audio/AudioPlayer.vue';
|
||||
@@ -60,7 +61,7 @@ const resultLabel = computed(() => {
|
||||
});
|
||||
|
||||
const providerIcon = computed(() =>
|
||||
props.call.provider === 'whatsapp' ? 'i-woot-whatsapp' : 'i-lucide-phone'
|
||||
getInboxVoiceIcon(props.call.inbox.channelType, props.call.inbox.medium)
|
||||
);
|
||||
|
||||
const createdAtLabel = computed(() =>
|
||||
@@ -150,7 +151,7 @@ const conversationRoute = computed(() => ({
|
||||
<div
|
||||
class="hidden items-center gap-x-1.5 gap-y-2.5 border-b border-n-weak lg:flex lg:items-center lg:gap-1.5"
|
||||
>
|
||||
<div class="flex items-center gap-2.5 min-w-0 w-40 shrink-0 py-3.5">
|
||||
<div class="flex items-center gap-2.5 min-w-0 w-52 shrink-0 py-3.5">
|
||||
<Avatar
|
||||
:src="call.contact.avatar"
|
||||
:name="contactName"
|
||||
@@ -169,9 +170,12 @@ const conversationRoute = computed(() => ({
|
||||
>
|
||||
<div class="flex items-center gap-x-2 min-w-0 lg:contents py-3.5">
|
||||
<CallStatusBadge :kind="kind" class="shrink-0" />
|
||||
<template v-if="agentActionLabel">
|
||||
<div
|
||||
v-if="agentActionLabel"
|
||||
class="gap-x-1.5 min-w-0 flex items-center"
|
||||
>
|
||||
<span
|
||||
class="text-label-small text-n-slate-10 truncate min-w-0 shrink min-w-8"
|
||||
class="text-label-small text-n-slate-10 truncate shrink min-w-8 xl:min-w-14"
|
||||
>
|
||||
{{ agentActionLabel }}
|
||||
</span>
|
||||
@@ -192,7 +196,7 @@ const conversationRoute = computed(() => ({
|
||||
{{ call.agent.name }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<span
|
||||
v-else-if="resultLabel"
|
||||
class="text-body-main truncate text-n-slate-10 min-w-0 shrink-[20]"
|
||||
@@ -212,10 +216,10 @@ const conversationRoute = computed(() => ({
|
||||
content: call.inbox.name,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="flex items-center gap-1.5 justify-start min-w-14 shrink-[100] py-3.5"
|
||||
class="flex items-center gap-1 justify-end w-40 min-w-4 shrink-[100] py-3.5"
|
||||
>
|
||||
<Icon :icon="providerIcon" class="size-4 text-n-slate-11 shrink-0" />
|
||||
<span class="text-body-main truncate text-n-slate-11">
|
||||
<span class="text-body-main truncate text-n-slate-11 min-w-0">
|
||||
{{ call.inbox.name }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -232,7 +236,7 @@ const conversationRoute = computed(() => ({
|
||||
content: createdAtLabel,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="text-label-small text-end text-n-slate-11 truncate py-3.5 tabular-nums justify-self-end w-16 shrink-0"
|
||||
class="text-label-small text-end text-n-slate-11 truncate py-3.5 tabular-nums justify-self-end min-w-16 max-w-20 shrink-0"
|
||||
>
|
||||
{{ createdAtLabel }}
|
||||
</span>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
const props = defineProps({
|
||||
// Null while a fetch is in flight so stale counts are never shown.
|
||||
@@ -117,10 +118,16 @@ const moreFiltersSections = computed(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
const selectedAssignee = computed(
|
||||
() => props.agents.find(agent => agent.id === assigneeId.value) || null
|
||||
);
|
||||
|
||||
const selectedAssigneeLabel = computed(
|
||||
() =>
|
||||
props.agents.find(agent => agent.id === assigneeId.value)?.name ||
|
||||
t('CALLS_PAGE.FILTERS.ASSIGNEE')
|
||||
() => selectedAssignee.value?.name || t('CALLS_PAGE.FILTERS.ASSIGNEE')
|
||||
);
|
||||
|
||||
const isOtherActivitySelected = computed(() =>
|
||||
OTHER_ACTIVITIES.includes(activity.value)
|
||||
);
|
||||
|
||||
const hasMoreFilters = computed(() => Boolean(inboxId.value));
|
||||
@@ -143,7 +150,7 @@ const applyMoreFilter = ({ action, value }) => {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span v-if="!activity" class="text-heading-3 text-n-slate-11 shrink-0">
|
||||
{{
|
||||
totalCount === null
|
||||
@@ -157,13 +164,13 @@ const applyMoreFilter = ({ action, value }) => {
|
||||
color="blue"
|
||||
size="sm"
|
||||
:icon="ACTIVITY_ICONS[activity]"
|
||||
class="shrink-0"
|
||||
class="shrink-0 !h-7 !px-2"
|
||||
@click="setActivity(null)"
|
||||
>
|
||||
{{ activeChipLabel }}
|
||||
<Icon icon="i-lucide-x" />
|
||||
</Button>
|
||||
<div class="w-px h-4 bg-n-strong shrink-0" />
|
||||
<div class="w-px h-3.5 mx-1 bg-n-strong shrink-0" />
|
||||
<Button
|
||||
v-for="chip in inactiveChips"
|
||||
:key="chip"
|
||||
@@ -172,7 +179,7 @@ const applyMoreFilter = ({ action, value }) => {
|
||||
size="sm"
|
||||
:icon="ACTIVITY_ICONS[chip]"
|
||||
:label="activityLabel(chip)"
|
||||
class="shrink-0 text-n-slate-12"
|
||||
class="shrink-0 text-n-slate-11 !h-7 !px-2"
|
||||
@click="setActivity(chip)"
|
||||
/>
|
||||
<OnClickOutside
|
||||
@@ -184,7 +191,10 @@ const applyMoreFilter = ({ action, value }) => {
|
||||
color="slate"
|
||||
size="sm"
|
||||
icon="i-lucide-phone"
|
||||
class="text-n-slate-12"
|
||||
class="!h-7 !px-2"
|
||||
:class="
|
||||
isOtherActivitySelected ? 'text-n-slate-12' : 'text-n-slate-11'
|
||||
"
|
||||
@click="toggleMenu('activity')"
|
||||
>
|
||||
{{ t('CALLS_PAGE.FILTERS.OTHER_ACTIVITY') }}
|
||||
@@ -208,10 +218,19 @@ const applyMoreFilter = ({ action, value }) => {
|
||||
variant="outline"
|
||||
color="slate"
|
||||
size="sm"
|
||||
icon="i-lucide-user-round-cog"
|
||||
class="max-w-52 text-n-slate-12"
|
||||
icon="i-woot-empty-assignee"
|
||||
class="max-w-52 !h-7 !px-2"
|
||||
:class="assigneeId ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
@click="toggleMenu('assignee')"
|
||||
>
|
||||
<template v-if="selectedAssignee" #icon>
|
||||
<Avatar
|
||||
:src="selectedAssignee.thumbnail"
|
||||
:name="selectedAssignee.name"
|
||||
:size="16"
|
||||
rounded-full
|
||||
/>
|
||||
</template>
|
||||
<span class="truncate">{{ selectedAssigneeLabel }}</span>
|
||||
<Icon icon="i-lucide-chevron-down" class="text-n-slate-11 shrink-0" />
|
||||
</Button>
|
||||
@@ -228,8 +247,9 @@ const applyMoreFilter = ({ action, value }) => {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-list-filter"
|
||||
class="!h-7 !px-2"
|
||||
:color="hasMoreFilters ? 'blue' : 'slate'"
|
||||
:class="hasMoreFilters ? '' : 'text-n-slate-12'"
|
||||
:class="hasMoreFilters ? '' : 'text-n-slate-11'"
|
||||
@click="toggleMenu('more')"
|
||||
>
|
||||
{{ t('CALLS_PAGE.FILTERS.MORE_FILTERS') }}
|
||||
|
||||
@@ -83,8 +83,12 @@ const campaignStatus = computed(() => {
|
||||
const inboxName = computed(() => props.inbox?.name || '');
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
const { medium, channel_type: type } = props.inbox;
|
||||
return getInboxIconByType(type, medium);
|
||||
const {
|
||||
medium,
|
||||
channel_type: type,
|
||||
voice_enabled: voiceEnabled,
|
||||
} = props.inbox;
|
||||
return getInboxIconByType(type, medium, 'fill', voiceEnabled);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
+2
-2
@@ -48,8 +48,8 @@ const inbox = computed(() => props.stateInbox);
|
||||
const inboxName = computed(() => inbox.value?.name);
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
const { channelType, medium } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium);
|
||||
const { channelType, medium, voiceEnabled } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
|
||||
});
|
||||
|
||||
const lastActivityAt = computed(() => {
|
||||
|
||||
@@ -49,8 +49,8 @@ const isUnread = computed(() => !props.inboxItem?.readAt);
|
||||
const inbox = computed(() => props.stateInbox);
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
const { channelType, medium } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium);
|
||||
const { channelType, medium, voiceEnabled } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
|
||||
});
|
||||
|
||||
const hasSlaThreshold = computed(() => {
|
||||
|
||||
+3
-1
@@ -37,10 +37,11 @@ const transformInbox = ({
|
||||
channelType,
|
||||
phoneNumber,
|
||||
medium,
|
||||
voiceEnabled,
|
||||
...rest
|
||||
}) => ({
|
||||
id,
|
||||
icon: getInboxIconByType(channelType, medium, 'line'),
|
||||
icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled),
|
||||
label: generateLabelForContactableInboxesList({
|
||||
name,
|
||||
email,
|
||||
@@ -54,6 +55,7 @@ const transformInbox = ({
|
||||
phoneNumber,
|
||||
channelType,
|
||||
medium,
|
||||
voiceEnabled,
|
||||
...rest,
|
||||
});
|
||||
|
||||
|
||||
+14
@@ -79,6 +79,20 @@ describe('composeConversationHelper', () => {
|
||||
channelType: INBOX_TYPES.EMAIL,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the voice glyph for a voice-enabled inbox', () => {
|
||||
const inboxes = [
|
||||
{
|
||||
id: 2,
|
||||
name: 'WhatsApp Cloud',
|
||||
channelType: INBOX_TYPES.WHATSAPP,
|
||||
voiceEnabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
const result = helpers.buildContactableInboxesList(inboxes);
|
||||
expect(result[0].icon).toBe('i-woot-whatsapp-voice');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCapitalizedNameFromEmail', () => {
|
||||
|
||||
@@ -117,8 +117,8 @@ const downloadRecording = () => {
|
||||
>
|
||||
<template #icon>
|
||||
<Icon
|
||||
:icon="isPlaying ? 'i-lucide-pause' : 'i-lucide-play'"
|
||||
class="size-4 flex-shrink-0"
|
||||
:icon="isPlaying ? 'i-woot-audio-pause' : 'i-woot-audio-play'"
|
||||
class="size-4 flex-shrink-0 text-n-slate-11"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
@@ -127,10 +127,10 @@ const downloadRecording = () => {
|
||||
min="0"
|
||||
:max="duration || 0"
|
||||
:value="currentTime"
|
||||
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-1 rounded-lg appearance-none cursor-pointer bg-n-slate-12/30 accent-n-slate-11"
|
||||
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-0.5 rounded-full appearance-none cursor-pointer bg-n-slate-12/30 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-2 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-n-slate-11 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:size-2 [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-n-slate-11"
|
||||
@input="seek"
|
||||
/>
|
||||
<span class="text-sm tabular-nums text-n-slate-11 shrink-0">
|
||||
<span class="text-label-small tabular-nums text-n-slate-11 shrink-0">
|
||||
{{ displayedTime }}
|
||||
</span>
|
||||
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
|
||||
|
||||
@@ -58,8 +58,12 @@ const menuItems = computed(() => [
|
||||
]);
|
||||
|
||||
const icon = computed(() => {
|
||||
const { medium, channel_type: type } = props.inbox;
|
||||
return getInboxIconByType(type, medium, 'outline');
|
||||
const {
|
||||
medium,
|
||||
channel_type: type,
|
||||
voice_enabled: voiceEnabled,
|
||||
} = props.inbox;
|
||||
return getInboxIconByType(type, medium, 'outline', voiceEnabled);
|
||||
});
|
||||
|
||||
const handleAction = ({ action, value }) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, toRef } from 'vue';
|
||||
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
import { useChannelIcon, useChannelBrandIcon } from './provider';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
@@ -21,7 +20,6 @@ defineOptions({ inheritAttrs: false });
|
||||
|
||||
const inboxRef = toRef(props, 'inbox');
|
||||
|
||||
const hasVoiceBadge = computed(() => isVoiceCallEnabled(props.inbox));
|
||||
const channelIcon = useChannelIcon(inboxRef);
|
||||
const brandIcon = useChannelBrandIcon(inboxRef);
|
||||
|
||||
@@ -31,13 +29,7 @@ const icon = computed(() =>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="relative inline-flex" v-bind="$attrs">
|
||||
<span class="inline-flex" v-bind="$attrs">
|
||||
<Icon :icon="icon" class="size-full" />
|
||||
<span
|
||||
v-if="hasVoiceBadge"
|
||||
class="absolute top-0 ltr:right-0 rtl:left-0 inline-flex items-center justify-center size-2 rounded-full bg-n-surface-1"
|
||||
>
|
||||
<Icon icon="i-lucide-audio-lines" class="size-1.5 text-n-slate-12" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||
import {
|
||||
INBOX_TYPES,
|
||||
TWILIO_CHANNEL_MEDIUM,
|
||||
isVoiceCallEnabled,
|
||||
getInboxVoiceIcon,
|
||||
} from 'dashboard/helper/inbox';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const channelTypeIconMap = {
|
||||
@@ -61,16 +66,9 @@ export function useChannelIcon(inbox) {
|
||||
icon = 'i-woot-whatsapp';
|
||||
}
|
||||
|
||||
// Native Twilio voice inbox: a TwilioSms with voice enabled (and no WhatsApp medium)
|
||||
// is presented as a Voice channel, so show the phone icon.
|
||||
const voiceEnabled =
|
||||
inboxDetails.voice_enabled || inboxDetails.voiceEnabled;
|
||||
if (
|
||||
type === INBOX_TYPES.TWILIO &&
|
||||
voiceEnabled &&
|
||||
inboxDetails.medium !== TWILIO_CHANNEL_MEDIUM.WHATSAPP
|
||||
) {
|
||||
icon = 'i-woot-voice';
|
||||
// Voice-enabled inboxes use the combined channel + voice-wave badge glyph.
|
||||
if (isVoiceCallEnabled(inboxDetails)) {
|
||||
icon = getInboxVoiceIcon(type, inboxDetails.medium);
|
||||
}
|
||||
|
||||
return icon ?? 'i-ri-global-fill';
|
||||
|
||||
@@ -19,13 +19,32 @@ describe('useChannelIcon', () => {
|
||||
expect(icon).toBe('i-woot-whatsapp');
|
||||
});
|
||||
|
||||
it('returns correct icon for voice-enabled Twilio channel', () => {
|
||||
it('returns the voice-call glyph for a voice-enabled Twilio channel', () => {
|
||||
const inbox = {
|
||||
channel_type: 'Channel::TwilioSms',
|
||||
voice_enabled: true,
|
||||
};
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-voice');
|
||||
expect(icon).toBe('i-woot-voice-call');
|
||||
});
|
||||
|
||||
it('returns the WhatsApp voice glyph for a voice-enabled WhatsApp channel', () => {
|
||||
const inbox = {
|
||||
channel_type: 'Channel::Whatsapp',
|
||||
voice_enabled: true,
|
||||
};
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-whatsapp-voice');
|
||||
});
|
||||
|
||||
it('returns the WhatsApp voice glyph for a voice-enabled Twilio WhatsApp channel', () => {
|
||||
const inbox = {
|
||||
channel_type: 'Channel::TwilioSms',
|
||||
medium: 'whatsapp',
|
||||
voice_enabled: true,
|
||||
};
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-whatsapp-voice');
|
||||
});
|
||||
|
||||
it('returns correct icon for Line channel', () => {
|
||||
|
||||
@@ -457,10 +457,11 @@ function handleReplyTo() {
|
||||
|
||||
const avatarInfo = computed(() => {
|
||||
if (props.contentAttributes?.externalEcho) {
|
||||
const { name, avatar_url, channel_type, medium } = inbox.value;
|
||||
const { name, avatar_url, channel_type, medium, voice_enabled } =
|
||||
inbox.value;
|
||||
const iconName = avatar_url
|
||||
? null
|
||||
: getInboxIconByType(channel_type, medium);
|
||||
: getInboxIconByType(channel_type, medium, 'fill', voice_enabled);
|
||||
return {
|
||||
name: iconName ? '' : name || t('CONVERSATION.NATIVE_APP'),
|
||||
src: avatar_url || '',
|
||||
|
||||
@@ -55,11 +55,30 @@ export const getVoiceCallProvider = inbox => {
|
||||
|
||||
export const isVoiceCallEnabled = inbox => getVoiceCallProvider(inbox) !== null;
|
||||
|
||||
// Combined channel + voice-wave badge glyph per voice-call provider.
|
||||
export const VOICE_CALL_ICONS = {
|
||||
[VOICE_CALL_PROVIDERS.WHATSAPP]: 'i-woot-whatsapp-voice',
|
||||
[VOICE_CALL_PROVIDERS.TWILIO]: 'i-woot-voice-call',
|
||||
};
|
||||
|
||||
export const getVoiceCallIcon = provider =>
|
||||
VOICE_CALL_ICONS[provider] ?? VOICE_CALL_ICONS[VOICE_CALL_PROVIDERS.TWILIO];
|
||||
|
||||
export const TWILIO_CHANNEL_MEDIUM = {
|
||||
WHATSAPP: 'whatsapp',
|
||||
SMS: 'sms',
|
||||
};
|
||||
|
||||
export const getInboxVoiceIcon = (channelType, medium) => {
|
||||
const isWhatsapp =
|
||||
channelType === INBOX_TYPES.WHATSAPP ||
|
||||
(channelType === INBOX_TYPES.TWILIO &&
|
||||
medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP);
|
||||
return getVoiceCallIcon(
|
||||
isWhatsapp ? VOICE_CALL_PROVIDERS.WHATSAPP : VOICE_CALL_PROVIDERS.TWILIO
|
||||
);
|
||||
};
|
||||
|
||||
const INBOX_ICON_MAP_FILL = {
|
||||
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
|
||||
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
|
||||
@@ -182,7 +201,14 @@ export const getInboxClassByType = (type, phoneNumber) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getInboxIconByType = (type, medium, variant = 'fill') => {
|
||||
export const getInboxIconByType = (
|
||||
type,
|
||||
medium,
|
||||
variant = 'fill',
|
||||
voiceEnabled = false
|
||||
) => {
|
||||
if (voiceEnabled) return getInboxVoiceIcon(type, medium);
|
||||
|
||||
const iconMap =
|
||||
variant === 'fill' ? INBOX_ICON_MAP_FILL : INBOX_ICON_MAP_LINE;
|
||||
const defaultIcon =
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import {
|
||||
INBOX_TYPES,
|
||||
VOICE_CALL_PROVIDERS,
|
||||
getInboxClassByType,
|
||||
getInboxIconByType,
|
||||
getInboxVoiceIcon,
|
||||
getInboxWarningIconClass,
|
||||
getVoiceCallIcon,
|
||||
} from '../inbox';
|
||||
|
||||
describe('#Inbox Helpers', () => {
|
||||
@@ -166,4 +169,63 @@ describe('#Inbox Helpers', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVoiceCallIcon', () => {
|
||||
it('returns the WhatsApp voice glyph for the whatsapp provider', () => {
|
||||
expect(getVoiceCallIcon(VOICE_CALL_PROVIDERS.WHATSAPP)).toBe(
|
||||
'i-woot-whatsapp-voice'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the generic voice-call glyph for the twilio provider', () => {
|
||||
expect(getVoiceCallIcon(VOICE_CALL_PROVIDERS.TWILIO)).toBe(
|
||||
'i-woot-voice-call'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the generic voice-call glyph for an unknown provider', () => {
|
||||
expect(getVoiceCallIcon('unknown')).toBe('i-woot-voice-call');
|
||||
expect(getVoiceCallIcon(undefined)).toBe('i-woot-voice-call');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInboxVoiceIcon', () => {
|
||||
it('returns the WhatsApp voice glyph for a WhatsApp inbox', () => {
|
||||
expect(getInboxVoiceIcon(INBOX_TYPES.WHATSAPP)).toBe(
|
||||
'i-woot-whatsapp-voice'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the WhatsApp voice glyph for a Twilio WhatsApp inbox', () => {
|
||||
expect(getInboxVoiceIcon(INBOX_TYPES.TWILIO, 'whatsapp')).toBe(
|
||||
'i-woot-whatsapp-voice'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the generic voice-call glyph for a Twilio voice inbox', () => {
|
||||
expect(getInboxVoiceIcon(INBOX_TYPES.TWILIO, 'sms')).toBe(
|
||||
'i-woot-voice-call'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInboxIconByType with voice enabled', () => {
|
||||
it('returns the WhatsApp voice glyph for a voice-enabled WhatsApp inbox', () => {
|
||||
expect(
|
||||
getInboxIconByType(INBOX_TYPES.WHATSAPP, undefined, 'line', true)
|
||||
).toBe('i-woot-whatsapp-voice');
|
||||
});
|
||||
|
||||
it('returns the generic voice-call glyph for a voice-enabled Twilio inbox', () => {
|
||||
expect(getInboxIconByType(INBOX_TYPES.TWILIO, 'sms', 'line', true)).toBe(
|
||||
'i-woot-voice-call'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the normal channel icon when voice is not enabled', () => {
|
||||
expect(
|
||||
getInboxIconByType(INBOX_TYPES.WHATSAPP, undefined, 'line', false)
|
||||
).toBe('i-woot-whatsapp');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,8 +87,8 @@ const inboxName = computed(() => props.inbox?.name);
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
if (!inbox.value) return null;
|
||||
const { channelType, medium } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium);
|
||||
const { channelType, medium, voiceEnabled } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@ const inboxName = computed(() => inbox.value?.name);
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
if (!inbox.value) return null;
|
||||
const { channelType, medium } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium);
|
||||
const { channelType, medium, voiceEnabled } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
|
||||
});
|
||||
|
||||
const fileAttachments = computed(() => {
|
||||
|
||||
@@ -129,22 +129,22 @@ onMounted(async () => {
|
||||
v-else
|
||||
class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1"
|
||||
>
|
||||
<header class="px-6 pt-6 pb-4 shrink-0">
|
||||
<div class="w-full">
|
||||
<header class="shrink-0">
|
||||
<div class="w-full px-6 pt-6">
|
||||
<h1 class="text-xl font-medium text-n-slate-12">
|
||||
{{ t('CALLS_PAGE.HEADER') }}
|
||||
</h1>
|
||||
<CallsFilterBar
|
||||
v-model:activity="activity"
|
||||
v-model:assignee-id="assigneeId"
|
||||
v-model:inbox-id="inboxId"
|
||||
class="mt-5"
|
||||
:total-count="isFetching ? null : meta.count"
|
||||
:agents="agents"
|
||||
:inboxes="voiceInboxes"
|
||||
:show-assignee="isAdmin"
|
||||
/>
|
||||
</div>
|
||||
<CallsFilterBar
|
||||
v-model:activity="activity"
|
||||
v-model:assignee-id="assigneeId"
|
||||
v-model:inbox-id="inboxId"
|
||||
class="mt-5 pb-4 border-b border-n-weak mx-6"
|
||||
:total-count="isFetching ? null : meta.count"
|
||||
:agents="agents"
|
||||
:inboxes="voiceInboxes"
|
||||
:show-assignee="isAdmin"
|
||||
/>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto">
|
||||
<div class="w-full">
|
||||
|
||||
+9
-7
@@ -79,13 +79,15 @@ const breadcrumbItems = computed(() => {
|
||||
});
|
||||
|
||||
const buildInboxList = allInboxes =>
|
||||
allInboxes?.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
|
||||
name,
|
||||
id,
|
||||
email,
|
||||
phoneNumber,
|
||||
icon: getInboxIconByType(channelType, medium, 'line'),
|
||||
})) || [];
|
||||
allInboxes?.map(
|
||||
({ name, id, email, phoneNumber, channelType, medium, voiceEnabled }) => ({
|
||||
name,
|
||||
id,
|
||||
email,
|
||||
phoneNumber,
|
||||
icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled),
|
||||
})
|
||||
) || [];
|
||||
|
||||
const policyInboxes = computed(() =>
|
||||
buildInboxList(selectedPolicy.value?.inboxes)
|
||||
|
||||
+17
-7
@@ -64,13 +64,23 @@ const allInboxes = computed(
|
||||
inboxes.value
|
||||
?.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
|
||||
name,
|
||||
id,
|
||||
email,
|
||||
phoneNumber,
|
||||
icon: getInboxIconByType(channelType, medium, 'line'),
|
||||
})) || []
|
||||
.map(
|
||||
({
|
||||
name,
|
||||
id,
|
||||
email,
|
||||
phoneNumber,
|
||||
channelType,
|
||||
medium,
|
||||
voiceEnabled,
|
||||
}) => ({
|
||||
name,
|
||||
id,
|
||||
email,
|
||||
phoneNumber,
|
||||
icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled),
|
||||
})
|
||||
) || []
|
||||
);
|
||||
|
||||
const formData = computed(() => ({
|
||||
|
||||
+2
-1
@@ -29,7 +29,8 @@ const inboxIcon = computed(() => {
|
||||
return getInboxIconByType(
|
||||
props.inbox.channelType,
|
||||
props.inbox.medium,
|
||||
'line'
|
||||
'line',
|
||||
props.inbox.voiceEnabled
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ export default {
|
||||
<label :class="{ error: v$.selectedAgentIds.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.AGENTS.TITLE') }}
|
||||
<div
|
||||
data-testid="agent-selector"
|
||||
class="rounded-xl outline outline-1 -outline-offset-1 outline-n-weak hover:outline-n-strong px-2 py-2"
|
||||
>
|
||||
<TagInput
|
||||
|
||||
@@ -281,8 +281,12 @@ export default {
|
||||
return this.$store.getters['inboxes/getInbox'](this.currentInboxId);
|
||||
},
|
||||
inboxIcon() {
|
||||
const { medium, channel_type: type } = this.inbox;
|
||||
return getInboxIconByType(type, medium, 'line');
|
||||
const {
|
||||
medium,
|
||||
channel_type: type,
|
||||
voice_enabled: voiceEnabled,
|
||||
} = this.inbox;
|
||||
return getInboxIconByType(type, medium, 'line', voiceEnabled);
|
||||
},
|
||||
bannerMaxWidth() {
|
||||
const narrowTabs = ['collaborators', 'bot-configuration'];
|
||||
|
||||
@@ -143,20 +143,6 @@ features:
|
||||
gemini-3-pro,
|
||||
]
|
||||
default: gpt-5.2
|
||||
conversation_faq_matching:
|
||||
models:
|
||||
[
|
||||
gpt-4.1-mini,
|
||||
gpt-5-mini,
|
||||
gpt-4.1,
|
||||
gpt-5.1,
|
||||
gpt-5.2,
|
||||
claude-haiku-4.5,
|
||||
claude-sonnet-4.5,
|
||||
gemini-3-flash,
|
||||
gemini-3-pro,
|
||||
]
|
||||
default: gpt-4.1-mini
|
||||
pdf_faq_generation:
|
||||
models: [gpt-4.1-mini, gpt-5-mini, gpt-4.1, gpt-5.1, gpt-5.2]
|
||||
default: gpt-4.1-mini
|
||||
|
||||
@@ -626,7 +626,6 @@ en:
|
||||
label_suggestion: 'Label suggestion'
|
||||
document_faq_generation: 'Document FAQ generation'
|
||||
conversation_faq_generation: 'Conversation FAQ generation'
|
||||
conversation_faq_matching: 'Conversation FAQ matching'
|
||||
help_center_article_generation: 'Help center article generation'
|
||||
onboarding_content_generation: 'Onboarding content generation'
|
||||
help_center_query_translation: 'Help center query translation'
|
||||
|
||||
@@ -79,10 +79,6 @@ Rails.application.routes.draw do
|
||||
end
|
||||
resources :agent_sessions, only: [:show]
|
||||
resources :assistant_responses
|
||||
resources :faq_suggestions, only: [:index, :show, :update] do
|
||||
post :approve, on: :member
|
||||
post :dismiss, on: :member
|
||||
end
|
||||
resources :message_reports, only: [:create]
|
||||
resources :bulk_actions, only: [:create]
|
||||
resources :copilot_threads, only: [:index, :create] do
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
class Api::V1::Accounts::Captain::FaqSuggestionsController < Api::V1::Accounts::BaseController
|
||||
before_action :current_account
|
||||
before_action -> { check_authorization(Captain::FaqSuggestion) }
|
||||
before_action :set_accessible_suggestions
|
||||
before_action :set_suggestion, except: [:index]
|
||||
|
||||
RESULTS_PER_PAGE = 25
|
||||
SOURCE_PREVIEW_LIMIT = 50
|
||||
|
||||
def index
|
||||
@current_page = permitted_params[:page] || 1
|
||||
filtered_query = apply_filters(@suggestions)
|
||||
@suggestions_count = filtered_query.count
|
||||
@suggestions = filtered_query.page(@current_page).per(RESULTS_PER_PAGE)
|
||||
end
|
||||
|
||||
def show
|
||||
@observations = @suggestion.observations
|
||||
.where(conversation_id: accessible_conversations.select(:id))
|
||||
.includes(:conversation)
|
||||
.order(created_at: :desc)
|
||||
.limit(SOURCE_PREVIEW_LIMIT)
|
||||
end
|
||||
|
||||
def update
|
||||
@suggestion.with_lock do
|
||||
raise ActiveRecord::RecordNotFound unless @suggestion.open?
|
||||
|
||||
@suggestion.update!(suggestion_params)
|
||||
end
|
||||
end
|
||||
|
||||
def approve
|
||||
attributes = params[:faq_suggestion].present? ? suggestion_params : {}
|
||||
@response = Captain::FaqSuggestionApprovalService.new(@suggestion, attributes).perform
|
||||
end
|
||||
|
||||
def dismiss
|
||||
@suggestion.with_lock do
|
||||
raise ActiveRecord::RecordNotFound unless @suggestion.open?
|
||||
|
||||
@suggestion.dismissed!
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def apply_filters(base_query)
|
||||
base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
|
||||
base_query = base_query.where(status: permitted_params[:status]) if permitted_params[:status].present?
|
||||
|
||||
if permitted_params[:search].present?
|
||||
# TODO: Move FAQ suggestion search to Elasticsearch when the records are indexed there.
|
||||
search_term = "%#{permitted_params[:search]}%"
|
||||
base_query = base_query.where('question ILIKE :search OR answer ILIKE :search', search: search_term)
|
||||
end
|
||||
|
||||
base_query
|
||||
end
|
||||
|
||||
def set_accessible_suggestions
|
||||
@suggestions = Captain::FaqSuggestionFinder.new(Current.user, Current.account).perform.includes(:assistant).ordered
|
||||
end
|
||||
|
||||
def set_suggestion
|
||||
@suggestion = @suggestions.find(permitted_params[:id])
|
||||
end
|
||||
|
||||
def accessible_conversations
|
||||
Conversations::PermissionFilterService.new(Current.account.conversations, Current.user, Current.account).perform
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:id, :assistant_id, :page, :status, :search)
|
||||
end
|
||||
|
||||
def suggestion_params
|
||||
params.require(:faq_suggestion).permit(:question, :answer)
|
||||
end
|
||||
end
|
||||
@@ -62,7 +62,7 @@ class CallFinder
|
||||
end
|
||||
|
||||
def paginated_calls
|
||||
@calls.includes(:contact, :inbox, :conversation, :accepted_by_agent)
|
||||
@calls.includes(:contact, :conversation, :accepted_by_agent, inbox: :channel)
|
||||
.order(created_at: :desc)
|
||||
.page(@params[:page] || 1)
|
||||
.per(RESULTS_PER_PAGE)
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
class Captain::FaqSuggestionFinder
|
||||
def initialize(current_user, current_account)
|
||||
@current_user = current_user
|
||||
@current_account = current_account
|
||||
end
|
||||
|
||||
def perform
|
||||
suggestions = @current_account.captain_faq_suggestions
|
||||
return suggestions if account_user&.administrator?
|
||||
|
||||
accessible_suggestion_ids = Captain::FaqObservation
|
||||
.where(conversation_id: accessible_conversations.select(:id))
|
||||
.select(:faq_suggestion_id)
|
||||
suggestions.where(id: accessible_suggestion_ids)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def accessible_conversations
|
||||
Conversations::PermissionFilterService.new(@current_account.conversations, @current_user, @current_account).perform
|
||||
end
|
||||
|
||||
def account_user
|
||||
@account_user ||= @current_account.account_users.find_by(user_id: @current_user.id)
|
||||
end
|
||||
end
|
||||
@@ -1,30 +0,0 @@
|
||||
class Captain::Llm::ConversationFaqJob < MutexApplicationJob
|
||||
queue_as :low
|
||||
|
||||
LOCK_TIMEOUT = 10.minutes
|
||||
|
||||
retry_on_lock_conflict wait: 30.seconds, attempts: 30
|
||||
|
||||
def perform(conversation, assistant)
|
||||
inbox = conversation.inbox
|
||||
|
||||
return unless conversation.resolved?
|
||||
return unless inbox.captain_active?
|
||||
|
||||
return if assistant.config['feature_faq'].blank?
|
||||
|
||||
with_lock(lock_key(assistant, conversation), LOCK_TIMEOUT) do
|
||||
Captain::Llm::ConversationFaqService.new(assistant, conversation).generate_suggestions
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def lock_key(assistant, conversation)
|
||||
format(
|
||||
::Redis::Alfred::CAPTAIN_CONVERSATION_FAQ_MUTEX,
|
||||
assistant_id: assistant.id,
|
||||
language: Captain::Llm::ConversationFaqService.language_for(conversation)
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -8,6 +8,6 @@ class CaptainListener < BaseListener
|
||||
return unless conversation.inbox.captain_active?
|
||||
|
||||
Captain::Llm::ContactNotesService.new(assistant, conversation).generate_and_update_notes if assistant.config['feature_memory'].present?
|
||||
Captain::Llm::ConversationFaqJob.perform_later(conversation, assistant) if assistant.config['feature_faq'].present?
|
||||
Captain::Llm::ConversationFaqService.new(assistant, conversation).generate_and_deduplicate if assistant.config['feature_faq'].present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -35,14 +35,6 @@ class Captain::AssistantPolicy < ApplicationPolicy
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def approve?
|
||||
update?
|
||||
end
|
||||
|
||||
def dismiss?
|
||||
update?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
class Captain::FaqSuggestionPolicy < ApplicationPolicy
|
||||
def index?
|
||||
true
|
||||
end
|
||||
|
||||
def show?
|
||||
true
|
||||
end
|
||||
|
||||
def update?
|
||||
true
|
||||
end
|
||||
|
||||
def approve?
|
||||
true
|
||||
end
|
||||
|
||||
def dismiss?
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -1,26 +0,0 @@
|
||||
class Captain::FaqSuggestionApprovalService
|
||||
def initialize(suggestion, attributes = {})
|
||||
@suggestion = suggestion
|
||||
@attributes = attributes
|
||||
end
|
||||
|
||||
def perform
|
||||
suggestion.with_lock do
|
||||
raise ActiveRecord::RecordNotFound unless suggestion.open?
|
||||
|
||||
suggestion.update!(attributes) if attributes.present?
|
||||
|
||||
response = suggestion.assistant.responses.create!(
|
||||
question: suggestion.question,
|
||||
answer: suggestion.answer,
|
||||
status: :approved
|
||||
)
|
||||
suggestion.approved!
|
||||
response
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :suggestion, :attributes
|
||||
end
|
||||
@@ -1,66 +0,0 @@
|
||||
class Captain::Llm::ConversationFaqContentService
|
||||
def initialize(assistant, conversation)
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
end
|
||||
|
||||
def generate
|
||||
[
|
||||
'Business Context:',
|
||||
JSON.pretty_generate(business_context),
|
||||
"Conversation ID: ##{conversation.display_id}",
|
||||
"Channel: #{conversation.inbox.channel.name}",
|
||||
'Message History:',
|
||||
conversation_messages
|
||||
].join("\n")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :assistant, :conversation
|
||||
|
||||
def conversation_messages
|
||||
messages = conversation
|
||||
.messages
|
||||
.where(message_type: %i[incoming outgoing], private: false)
|
||||
.order(created_at: :asc)
|
||||
|
||||
return "No messages in this conversation\n" if messages.empty?
|
||||
|
||||
messages.filter_map { |message| format_message(message) }.join
|
||||
end
|
||||
|
||||
def format_message(message)
|
||||
return unless source_message?(message)
|
||||
|
||||
message_content = message.content_for_llm
|
||||
return if message_content.blank?
|
||||
|
||||
sender = human_support_reply?(message) ? 'Support Agent' : 'User'
|
||||
"#{sender}: #{message_content}\n"
|
||||
end
|
||||
|
||||
def source_message?(message)
|
||||
return true if message.incoming? && message.sender_type == 'Contact'
|
||||
|
||||
human_support_reply?(message)
|
||||
end
|
||||
|
||||
def human_support_reply?(message)
|
||||
return false unless message.outgoing?
|
||||
return false if message.content_attributes['automation_rule_id'].present?
|
||||
return false if message.additional_attributes['campaign_id'].present?
|
||||
|
||||
message.sender_type == 'User' || message.content_attributes['external_echo'].present?
|
||||
end
|
||||
|
||||
def business_context
|
||||
{
|
||||
product_name: assistant.config['product_name'],
|
||||
assistant_description: assistant.description,
|
||||
instructions: assistant.config['instructions'],
|
||||
response_guidelines: assistant.response_guidelines,
|
||||
guardrails: assistant.guardrails
|
||||
}.compact
|
||||
end
|
||||
end
|
||||
@@ -1,75 +0,0 @@
|
||||
class Captain::Llm::ConversationFaqPromptsService
|
||||
class << self
|
||||
def generator(language = 'english')
|
||||
<<~PROMPT
|
||||
You create high-quality FAQ candidates from resolved support conversations.
|
||||
Only generate an FAQ when the conversation contains durable, reusable knowledge that would help many future customers.
|
||||
|
||||
## Source rules
|
||||
- The input starts with trusted business context. Use it to reject conversations about other businesses or topics, but never use it as the source of an FAQ answer.
|
||||
- The conversation history contains only customer messages and human support agent messages.
|
||||
- Base every FAQ strictly on information stated in the human support agent messages. Do not infer, generalize, or add external knowledge.
|
||||
- A human support agent must state every fact used in the FAQ answer. Customer messages cannot supply missing answer facts.
|
||||
- The human support agent must provide the final answer. If the agent only greets, asks clarifying questions, asks for contact details, promises to check, shares an attachment, or transfers the conversation, return: `{"faqs":[]}`.
|
||||
- For each FAQ, identify the human support agent message or messages that together provide a complete public answer to the same question. Combine facts only across related agent messages; never combine separate questions or unrelated topics. If those messages do not provide a complete public answer, remove that FAQ.
|
||||
|
||||
## Decision gate
|
||||
Return `{"faqs":[]}` unless every generated FAQ can pass all of these checks:
|
||||
1. The answer is fully stated by a human support agent, not by the customer.
|
||||
2. The answer is a public, durable rule or procedure, not a private account action, manual review, troubleshooting session, quote, file, link, or follow-up.
|
||||
3. The answer can be written without private identifiers, customer-specific facts, direct URLs, attachments, invoices, screenshots, or support-ticket steps.
|
||||
4. The question would still make sense in a help center if the original conversation, customer, and agent did not exist.
|
||||
Do not rescue a rejected conversation by rewriting it as a generic support question.
|
||||
|
||||
## Return no FAQ for
|
||||
- Spam, scams, advertisements, SEO/link-building pitches, adult/gambling/financial promotions, gibberish, abusive content, or conversations unrelated to the business being supported.
|
||||
- Account-specific, order-specific, payment-specific, subscription-specific, login/access, verification, delivery, certificate, or troubleshooting issues, even if they could be rewritten as a general support question.
|
||||
- Conversations that mainly hand off to a human, ask the customer to wait, request private identifiers or contact details, collect screenshots, attachments, or documents, or tell the customer to contact support for case review.
|
||||
- Temporary workarounds, one-off exceptions, unclear answers, unresolved problems, wrong-service conversations, complaints, greetings, or abandoned conversations.
|
||||
- Internal support workflow details, chat session rules, escalation mechanics, ticket-routing instructions, or "someone will get back to you" messages.
|
||||
- Answers that are just a direct/private link, attachment, file, invoice, one-off quote or estimate, account-specific URL, or instructions to open a support ticket.
|
||||
- Questions whose useful answer is "contact support", "wait for the team", "share your details", "we will check", or "this needs manual review".
|
||||
- Questions about whether support can help with a private issue, third-party service, transaction, payment, delivery, or account problem.
|
||||
- Pricing, policy, availability, roadmap, deadline, or legal claims unless the human support agent gives a clear and stable answer in the conversation.
|
||||
- Questions already answered only by asking the customer for more information.
|
||||
|
||||
## FAQ quality rules
|
||||
- Prefer returning no FAQ over a weak or narrow FAQ.
|
||||
- A good candidate teaches a generally reusable product, service, policy, setup, or process rule that another customer could use without contacting support.
|
||||
- Generate at most one FAQ unless the human agent clearly answered multiple distinct, reusable questions.
|
||||
- Do not create duplicate or overlapping FAQs in the same response.
|
||||
- Questions must be general enough for a help center, not personalized to the current customer.
|
||||
- Remove customer names, order numbers, invoice numbers, IDs, private URLs, phone numbers, emails, screenshots, attachments, and other personal or transaction-specific details.
|
||||
- Answers must be complete, self-contained, and supported by the human agent's messages.
|
||||
|
||||
## Examples
|
||||
- Customer mentions a price or procedure, then the human agent only greets or says they will check: return `{"faqs":[]}`.
|
||||
- Human agent shares only a private link, file, invoice, quote, screenshot, or attachment: return `{"faqs":[]}`.
|
||||
- Human agent clearly states a public rule, such as which purchases are allowed for a program or service: generate one general FAQ.
|
||||
|
||||
Generate the FAQs only in the #{language}, use no other language.
|
||||
If no suitable reusable FAQ is available, return: `{"faqs":[]}`.
|
||||
|
||||
Return only valid JSON in this exact structure:
|
||||
```json
|
||||
{ "faqs": [ { "question": "", "answer": "" } ] }
|
||||
```
|
||||
PROMPT
|
||||
end
|
||||
|
||||
def same_faq
|
||||
<<~PROMPT
|
||||
Decide whether the new FAQ and existing FAQ are the same.
|
||||
|
||||
Return `same_faq` as true only when both questions ask the same thing and both answers give the same guidance.
|
||||
Wording, grammar, level of detail, and examples may differ. Return false when either FAQ adds, removes, contradicts, or changes a condition,
|
||||
policy, procedure, audience, product, plan, time frame, or outcome. Related FAQs are not the same FAQ. When uncertain, return false.
|
||||
|
||||
Return only valid JSON in this exact structure:
|
||||
```json
|
||||
{ "same_faq": true }
|
||||
```
|
||||
PROMPT
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,208 +1,167 @@
|
||||
class Captain::Llm::ConversationFaqService < Llm::BaseAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
class SuggestionChangedError < StandardError; end
|
||||
|
||||
DISTANCE_THRESHOLD = 0.3
|
||||
MATCH_LIMIT = 5
|
||||
LLM_FEATURE = 'conversation_faq_generation'.freeze
|
||||
|
||||
def self.language_for(conversation)
|
||||
language = conversation.language.presence || conversation.account.locale.presence || I18n.default_locale.to_s
|
||||
normalize_language(language)
|
||||
end
|
||||
|
||||
def self.normalize_language(language)
|
||||
language.to_s.tr('-', '_').split('_').first.downcase
|
||||
end
|
||||
|
||||
private_class_method :normalize_language
|
||||
|
||||
def initialize(assistant, conversation)
|
||||
super(feature: LLM_FEATURE, account: conversation.account, fallback_model: Llm::Models.default_model_for(LLM_FEATURE))
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@content = Captain::Llm::ConversationFaqContentService.new(assistant, conversation).generate
|
||||
@embedding_service = Captain::Llm::EmbeddingService.new(account_id: conversation.account_id)
|
||||
@content = conversation_faq_content
|
||||
end
|
||||
|
||||
def generate_suggestions
|
||||
# Generates and deduplicates FAQs from conversation content
|
||||
# Skips processing if there was no human interaction
|
||||
def generate_and_deduplicate
|
||||
return [] if no_human_interaction?
|
||||
|
||||
generate.map { |faq| route_candidate(faq) }
|
||||
new_faqs = generate
|
||||
return [] if new_faqs.empty?
|
||||
|
||||
duplicate_faqs, unique_faqs = find_and_separate_duplicates(new_faqs)
|
||||
save_new_faqs(unique_faqs)
|
||||
log_duplicate_faqs(duplicate_faqs) if Rails.env.development?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :content, :conversation, :assistant, :embedding_service
|
||||
attr_reader :content, :conversation, :assistant
|
||||
|
||||
def conversation_faq_content
|
||||
[
|
||||
"Conversation ID: ##{conversation.display_id}",
|
||||
"Channel: #{conversation.inbox.channel.name}",
|
||||
'Message History:',
|
||||
conversation_faq_messages
|
||||
].join("\n")
|
||||
end
|
||||
|
||||
def conversation_faq_messages
|
||||
messages = conversation
|
||||
.messages
|
||||
.where(message_type: %i[incoming outgoing], private: false)
|
||||
.order(created_at: :asc)
|
||||
|
||||
return "No messages in this conversation\n" if messages.empty?
|
||||
|
||||
messages.filter_map { |message| format_conversation_faq_message(message) }.join
|
||||
end
|
||||
|
||||
def format_conversation_faq_message(message)
|
||||
return unless faq_source_message?(message)
|
||||
|
||||
content = message.content_for_llm
|
||||
return if content.blank?
|
||||
|
||||
sender = human_support_reply?(message) ? 'Support Agent' : 'User'
|
||||
"#{sender}: #{content}\n"
|
||||
end
|
||||
|
||||
def faq_source_message?(message)
|
||||
return true if message.incoming? && message.sender_type == 'Contact'
|
||||
|
||||
human_support_reply?(message)
|
||||
end
|
||||
|
||||
def human_support_reply?(message)
|
||||
return false unless message.outgoing?
|
||||
return false if message.content_attributes['automation_rule_id'].present?
|
||||
return false if message.additional_attributes['campaign_id'].present?
|
||||
|
||||
message.sender_type == 'User' || message.content_attributes['external_echo'].present?
|
||||
end
|
||||
|
||||
def no_human_interaction?
|
||||
conversation.first_reply_created_at.nil?
|
||||
end
|
||||
|
||||
def route_candidate(faq)
|
||||
embedding = embedding_service.get_embedding(candidate_text(faq))
|
||||
def find_and_separate_duplicates(faqs)
|
||||
duplicate_faqs = []
|
||||
unique_faqs = []
|
||||
|
||||
return discard_observation(faq) if matching_record(approved_faqs, faq, embedding)
|
||||
return discard_observation(faq) if matching_record(dismissed_suggestions_for_language, faq, embedding)
|
||||
faqs.each do |faq|
|
||||
combined_text = "#{faq['question']}: #{faq['answer']}"
|
||||
embedding = Captain::Llm::EmbeddingService.new(account_id: @conversation.account_id).get_embedding(combined_text)
|
||||
similar_faqs = find_similar_faqs(embedding)
|
||||
|
||||
suggestion = matching_record(open_suggestions_for_language, faq, embedding)
|
||||
matched_content = suggestion&.slice('question', 'answer')
|
||||
suggestion ||= assistant.faq_suggestions.create!(
|
||||
question: faq.fetch('question'),
|
||||
answer: faq.fetch('answer'),
|
||||
embedding: embedding,
|
||||
language: faq_language
|
||||
)
|
||||
|
||||
attach_observation(suggestion, faq, matched_content)
|
||||
end
|
||||
|
||||
def matching_record(relation, faq, embedding)
|
||||
likely_matches(relation, embedding).find { |record| same_faq?(faq, record) }
|
||||
end
|
||||
|
||||
def likely_matches(relation, embedding)
|
||||
return [] unless relation.exists?
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
# Force an exact search because IVFFlat can miss matches after relation filters.
|
||||
# SET LOCAL keeps the planner change scoped to this transaction.
|
||||
ApplicationRecord.connection.execute('SET LOCAL enable_indexscan = off')
|
||||
relation
|
||||
.nearest_neighbors(:embedding, embedding, distance: 'cosine')
|
||||
.limit(MATCH_LIMIT)
|
||||
.select { |record| record.neighbor_distance < DISTANCE_THRESHOLD }
|
||||
end
|
||||
end
|
||||
|
||||
def same_faq?(candidate, existing_record)
|
||||
comparison = {
|
||||
candidate: candidate.slice('question', 'answer'),
|
||||
existing: { question: existing_record.question, answer: existing_record.answer }
|
||||
}
|
||||
prompt = Captain::Llm::ConversationFaqPromptsService.same_faq
|
||||
faq_match_model = Llm::FeatureRouter.resolve(feature: 'conversation_faq_matching', account: conversation.account)[:model]
|
||||
response = instrument_llm_call(match_instrumentation_params(prompt, comparison, faq_match_model)) do
|
||||
chat(model: faq_match_model)
|
||||
.with_params(response_format: { type: 'json_object' })
|
||||
.with_instructions(prompt)
|
||||
.ask(comparison.to_json)
|
||||
if similar_faqs.any?
|
||||
duplicate_faqs << { faq: faq, similar_faqs: similar_faqs }
|
||||
else
|
||||
unique_faqs << faq
|
||||
end
|
||||
end
|
||||
|
||||
same_faq = JSON.parse(sanitize_json_response(response.content)).fetch('same_faq')
|
||||
raise TypeError, 'same_faq must be a boolean' unless [true, false].include?(same_faq)
|
||||
|
||||
same_faq
|
||||
rescue JSON::ParserError, KeyError, TypeError, RubyLLM::Error => e
|
||||
Rails.logger.error "FAQ match failed: #{e.message}"
|
||||
raise
|
||||
[duplicate_faqs, unique_faqs]
|
||||
end
|
||||
|
||||
def attach_observation(suggestion, faq, matched_content)
|
||||
suggestion.with_lock do
|
||||
next unless suggestion.open?
|
||||
raise SuggestionChangedError if matched_content && suggestion.slice('question', 'answer') != matched_content
|
||||
def find_similar_faqs(embedding)
|
||||
similar_faqs = assistant
|
||||
.responses
|
||||
.nearest_neighbors(:embedding, embedding, distance: 'cosine')
|
||||
Rails.logger.debug(similar_faqs.map { |faq| [faq.question, faq.neighbor_distance] })
|
||||
similar_faqs.select { |record| record.neighbor_distance < DISTANCE_THRESHOLD }
|
||||
end
|
||||
|
||||
existing_observation = suggestion.observations.find_by(conversation: conversation)
|
||||
next existing_observation if existing_observation
|
||||
|
||||
observation = suggestion.observations.create!(
|
||||
conversation: conversation,
|
||||
generated_question: faq.fetch('question'),
|
||||
generated_answer: faq.fetch('answer'),
|
||||
language: faq_language,
|
||||
status: :attached
|
||||
def save_new_faqs(faqs)
|
||||
faqs.map do |faq|
|
||||
assistant.responses.create!(
|
||||
question: faq['question'],
|
||||
answer: faq['answer'],
|
||||
status: 'pending',
|
||||
documentable: conversation
|
||||
)
|
||||
suggestion.update!(source_count: suggestion.source_count + 1)
|
||||
observation
|
||||
end
|
||||
end
|
||||
|
||||
def discard_observation(faq)
|
||||
Captain::FaqObservation.find_or_create_by!(
|
||||
conversation: conversation,
|
||||
generated_question: faq.fetch('question'),
|
||||
generated_answer: faq.fetch('answer'),
|
||||
language: faq_language,
|
||||
status: :discarded
|
||||
)
|
||||
end
|
||||
def log_duplicate_faqs(duplicate_faqs)
|
||||
return if duplicate_faqs.empty?
|
||||
|
||||
def open_suggestions_for_language
|
||||
assistant.faq_suggestions.where(account_id: conversation.account_id).open.by_language(faq_language)
|
||||
end
|
||||
|
||||
def dismissed_suggestions_for_language
|
||||
assistant.faq_suggestions.where(account_id: conversation.account_id).dismissed.by_language(faq_language)
|
||||
end
|
||||
|
||||
def approved_faqs
|
||||
assistant.responses.approved
|
||||
end
|
||||
|
||||
def candidate_text(faq)
|
||||
"#{faq.fetch('question')}: #{faq.fetch('answer')}"
|
||||
Rails.logger.info "Found #{duplicate_faqs.length} duplicate FAQs:"
|
||||
duplicate_faqs.each do |duplicate|
|
||||
Rails.logger.info(
|
||||
"Q: #{duplicate[:faq]['question']}\n" \
|
||||
"A: #{duplicate[:faq]['answer']}\n\n" \
|
||||
"Similar existing FAQs: #{duplicate[:similar_faqs].map { |f| "Q: #{f.question} A: #{f.answer}" }.join(', ')}"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def generate
|
||||
response = instrument_llm_call(generation_instrumentation_params) do
|
||||
response = instrument_llm_call(instrumentation_params) do
|
||||
chat
|
||||
.with_params(response_format: { type: 'json_object' })
|
||||
.with_instructions(system_prompt)
|
||||
.ask(content)
|
||||
.ask(@content)
|
||||
end
|
||||
parse_generation_response(response.content)
|
||||
parse_response(response.content)
|
||||
rescue RubyLLM::Error => e
|
||||
Rails.logger.error "LLM API Error: #{e.message}"
|
||||
[]
|
||||
end
|
||||
|
||||
def generation_instrumentation_params
|
||||
def instrumentation_params
|
||||
{
|
||||
span_name: 'llm.captain.conversation_faq',
|
||||
model: model,
|
||||
temperature: temperature,
|
||||
account_id: conversation.account_id,
|
||||
conversation_id: conversation.display_id,
|
||||
model: @model,
|
||||
temperature: @temperature,
|
||||
account_id: @conversation.account_id,
|
||||
conversation_id: @conversation.display_id,
|
||||
feature_name: 'conversation_faq',
|
||||
messages: [
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: content }
|
||||
{ role: 'user', content: @content }
|
||||
],
|
||||
metadata: { assistant_id: assistant.id, language: faq_language }
|
||||
}
|
||||
end
|
||||
|
||||
def match_instrumentation_params(prompt, comparison, faq_match_model)
|
||||
{
|
||||
span_name: 'llm.captain.faq_match',
|
||||
model: faq_match_model,
|
||||
temperature: temperature,
|
||||
account_id: conversation.account_id,
|
||||
conversation_id: conversation.display_id,
|
||||
feature_name: 'conversation_faq_match',
|
||||
messages: [
|
||||
{ role: 'system', content: prompt },
|
||||
{ role: 'user', content: comparison.to_json }
|
||||
],
|
||||
metadata: { assistant_id: assistant.id, language: faq_language }
|
||||
metadata: { assistant_id: @assistant.id }
|
||||
}
|
||||
end
|
||||
|
||||
def system_prompt
|
||||
Captain::Llm::ConversationFaqPromptsService.generator(language_name(faq_language))
|
||||
account_language = @conversation.account.locale_english_name
|
||||
Captain::Llm::SystemPromptsService.conversation_faq_generator(account_language)
|
||||
end
|
||||
|
||||
def faq_language
|
||||
@faq_language ||= self.class.language_for(conversation)
|
||||
end
|
||||
|
||||
def language_name(language)
|
||||
ISO_639.find(language)&.english_name&.downcase || 'english'
|
||||
end
|
||||
|
||||
def parse_generation_response(response)
|
||||
def parse_response(response)
|
||||
return [] if response.nil?
|
||||
|
||||
JSON.parse(sanitize_json_response(response)).fetch('faqs', [])
|
||||
|
||||
@@ -51,6 +51,62 @@ class Captain::Llm::SystemPromptsService
|
||||
PROMPT
|
||||
end
|
||||
|
||||
def conversation_faq_generator(language = 'english')
|
||||
<<~SYSTEM_PROMPT_MESSAGE
|
||||
You create high-quality FAQ candidates from resolved support conversations.
|
||||
Only generate an FAQ when the conversation contains durable, reusable knowledge that would help many future customers.
|
||||
|
||||
## Source rules
|
||||
- The conversation history contains only customer messages and human support agent messages.
|
||||
- Base every FAQ strictly on information stated in the human support agent messages. Do not infer, generalize, or add external knowledge.
|
||||
- A human support agent must state every fact used in the FAQ answer. Customer messages cannot supply missing answer facts.
|
||||
- The human support agent must provide the final answer. If the agent only greets, asks clarifying questions, asks for contact details, promises to check, shares an attachment, or transfers the conversation, return: `{"faqs":[]}`.
|
||||
- For each FAQ, first identify the exact human support agent message that fully answers it. If no single human agent message gives a complete public answer, remove that FAQ.
|
||||
|
||||
## Decision gate
|
||||
Return `{"faqs":[]}` unless every generated FAQ can pass all of these checks:
|
||||
1. The answer is fully stated by a human support agent, not by the customer.
|
||||
2. The answer is a public, durable rule or procedure, not a private account action, manual review, troubleshooting session, quote, file, link, or follow-up.
|
||||
3. The answer can be written without private identifiers, customer-specific facts, direct URLs, attachments, invoices, screenshots, or support-ticket steps.
|
||||
4. The question would still make sense in a help center if the original conversation, customer, and agent did not exist.
|
||||
Do not rescue a rejected conversation by rewriting it as a generic support question.
|
||||
|
||||
## Return no FAQ for
|
||||
- Spam, scams, advertisements, SEO/link-building pitches, adult/gambling/financial promotions, gibberish, abusive content, or conversations unrelated to the business being supported.
|
||||
- Account-specific, order-specific, payment-specific, subscription-specific, login/access, verification, delivery, certificate, or troubleshooting issues, even if they could be rewritten as a general support question.
|
||||
- Conversations that mainly hand off to a human, ask the customer to wait, request private identifiers or contact details, collect screenshots, attachments, or documents, or tell the customer to contact support for case review.
|
||||
- Temporary workarounds, one-off exceptions, unclear answers, unresolved problems, wrong-service conversations, complaints, greetings, or abandoned conversations.
|
||||
- Internal support workflow details, chat session rules, escalation mechanics, ticket-routing instructions, or "someone will get back to you" messages.
|
||||
- Answers that are just a direct/private link, attachment, file, invoice, one-off quote or estimate, account-specific URL, or instructions to open a support ticket.
|
||||
- Questions whose useful answer is "contact support", "wait for the team", "share your details", "we will check", or "this needs manual review".
|
||||
- Questions about whether support can help with a private issue, third-party service, transaction, payment, delivery, or account problem.
|
||||
- Pricing, policy, availability, roadmap, deadline, or legal claims unless the human support agent gives a clear and stable answer in the conversation.
|
||||
- Questions already answered only by asking the customer for more information.
|
||||
|
||||
## FAQ quality rules
|
||||
- Prefer returning no FAQ over a weak or narrow FAQ.
|
||||
- A good candidate teaches a generally reusable product, service, policy, setup, or process rule that another customer could use without contacting support.
|
||||
- Generate at most one FAQ unless the human agent clearly answered multiple distinct, reusable questions.
|
||||
- Do not create duplicate or overlapping FAQs in the same response.
|
||||
- Questions must be general enough for a help center, not personalized to the current customer.
|
||||
- Remove customer names, order numbers, invoice numbers, IDs, private URLs, phone numbers, emails, screenshots, attachments, and other personal or transaction-specific details.
|
||||
- Answers must be complete, self-contained, and supported by the human agent's messages.
|
||||
|
||||
## Examples
|
||||
- Customer mentions a price or procedure, then the human agent only greets or says they will check: return `{"faqs":[]}`.
|
||||
- Human agent shares only a private link, file, invoice, quote, screenshot, or attachment: return `{"faqs":[]}`.
|
||||
- Human agent clearly states a public rule, such as which purchases are allowed for a program or service: generate one general FAQ.
|
||||
|
||||
Generate the FAQs only in the #{language}, use no other language.
|
||||
If no suitable reusable FAQ is available, return: `{"faqs":[]}`.
|
||||
|
||||
Return only valid JSON in this exact structure:
|
||||
```json
|
||||
{ "faqs": [ { "question": "", "answer": "" } ] }
|
||||
```
|
||||
SYSTEM_PROMPT_MESSAGE
|
||||
end
|
||||
|
||||
def notes_generator(language = 'english')
|
||||
<<~SYSTEM_PROMPT_MESSAGE
|
||||
You are a note taker looking to convert the conversation with a contact into actionable notes for the CRM.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
json.partial! 'api/v1/models/captain/assistant_response', formats: [:json], resource: @response
|
||||
@@ -1 +0,0 @@
|
||||
json.partial! 'api/v1/models/captain/faq_suggestion', formats: [:json], resource: @suggestion
|
||||
@@ -1,10 +0,0 @@
|
||||
json.payload do
|
||||
json.array! @suggestions do |suggestion|
|
||||
json.partial! 'api/v1/models/captain/faq_suggestion', formats: [:json], resource: suggestion
|
||||
end
|
||||
end
|
||||
|
||||
json.meta do
|
||||
json.total_count @suggestions_count
|
||||
json.page @current_page
|
||||
end
|
||||
@@ -1,6 +0,0 @@
|
||||
json.partial! 'api/v1/models/captain/faq_suggestion', formats: [:json], resource: @suggestion
|
||||
json.observations do
|
||||
json.array! @observations do |observation|
|
||||
json.partial! 'api/v1/models/captain/faq_observation', formats: [:json], resource: observation
|
||||
end
|
||||
end
|
||||
@@ -1 +0,0 @@
|
||||
json.partial! 'api/v1/models/captain/faq_suggestion', formats: [:json], resource: @suggestion
|
||||
@@ -19,6 +19,8 @@ end
|
||||
json.inbox do
|
||||
json.id call.inbox_id
|
||||
json.name call.inbox.name
|
||||
json.channel_type call.inbox.channel_type
|
||||
json.medium call.inbox.channel.try(:medium)
|
||||
end
|
||||
|
||||
if call.accepted_by_agent
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
json.id resource.id
|
||||
json.generated_question resource.generated_question
|
||||
json.generated_answer resource.generated_answer
|
||||
json.language resource.language
|
||||
json.status resource.status
|
||||
json.created_at resource.created_at.to_i
|
||||
json.conversation do
|
||||
json.id resource.conversation.id
|
||||
json.display_id resource.conversation.display_id
|
||||
end
|
||||
@@ -1,12 +0,0 @@
|
||||
json.id resource.id
|
||||
json.account_id resource.account_id
|
||||
json.question resource.question
|
||||
json.answer resource.answer
|
||||
json.language resource.language
|
||||
json.source_count resource.source_count
|
||||
json.status resource.status
|
||||
json.created_at resource.created_at.to_i
|
||||
json.updated_at resource.updated_at.to_i
|
||||
json.assistant do
|
||||
json.partial! 'api/v1/models/captain/assistant', formats: [:json], resource: resource.assistant
|
||||
end
|
||||
@@ -89,7 +89,6 @@ module Redis::RedisKeys
|
||||
WHATSAPP_MESSAGE_MUTEX = 'WHATSAPP_MESSAGE_CREATE_LOCK::%<inbox_id>s::%<sender_id>s'.freeze
|
||||
CRM_PROCESS_MUTEX = 'CRM_PROCESS_MUTEX::%<hook_id>s'.freeze
|
||||
CAPTAIN_DOCUMENT_SYNC_MUTEX = 'CAPTAIN_DOCUMENT_SYNC_LOCK::%<document_id>s'.freeze
|
||||
CAPTAIN_CONVERSATION_FAQ_MUTEX = 'CAPTAIN_CONVERSATION_FAQ_LOCK::%<assistant_id>s::%<language>s'.freeze
|
||||
|
||||
## Auto Assignment Keys
|
||||
# Track conversation assignments to agents for rate limiting
|
||||
|
||||
@@ -32,6 +32,7 @@ RSpec.describe 'Calls API', type: :request do
|
||||
item = body['payload'].find { |c| c['id'] == agent_call.id }
|
||||
expect(item['transcript']).to eq('hello world')
|
||||
expect(item['contact']['phone_number']).to eq(contact.phone_number)
|
||||
expect(item['inbox']).to include('id' => inbox.id, 'name' => inbox.name, 'channel_type' => inbox.channel_type)
|
||||
end
|
||||
|
||||
it 'scopes the list to calls the agent accepted' do
|
||||
|
||||
-216
@@ -1,216 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::V1::Accounts::Captain::FaqSuggestions', type: :request do
|
||||
let(:account) { create(:account, locale: 'en') }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:suggestion) do
|
||||
assistant.faq_suggestions.create!(
|
||||
question: 'How do I enable the feature?',
|
||||
answer: 'Turn it on in settings.',
|
||||
source_count: 1
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
suggestion.observations.create!(
|
||||
conversation: conversation,
|
||||
generated_question: suggestion.question,
|
||||
generated_answer: suggestion.answer,
|
||||
language: suggestion.language
|
||||
)
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/captain/faq_suggestions' do
|
||||
it 'returns suggestions and their count to an administrator' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/faq_suggestions",
|
||||
params: { assistant_id: assistant.id },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['payload']).to contain_exactly(
|
||||
include('id' => suggestion.id, 'question' => suggestion.question, 'status' => 'open')
|
||||
)
|
||||
expect(response.parsed_body['meta']).to include('total_count' => 1)
|
||||
end
|
||||
|
||||
it 'returns only suggestions backed by conversations the agent can access' do
|
||||
create(:inbox_member, user: agent, inbox: inbox)
|
||||
hidden_inbox = create(:inbox, account: account)
|
||||
hidden_conversation = create(:conversation, account: account, inbox: hidden_inbox)
|
||||
hidden_suggestion = assistant.faq_suggestions.create!(question: 'Hidden question', answer: 'Hidden answer')
|
||||
hidden_suggestion.observations.create!(
|
||||
conversation: hidden_conversation,
|
||||
generated_question: hidden_suggestion.question,
|
||||
generated_answer: hidden_suggestion.answer,
|
||||
language: hidden_suggestion.language
|
||||
)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/captain/faq_suggestions",
|
||||
params: { assistant_id: assistant.id },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['payload'].pluck('id')).to contain_exactly(suggestion.id)
|
||||
expect(response.parsed_body['meta']).to include('total_count' => 1)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/captain/faq_suggestions/:id' do
|
||||
it 'returns the suggestion with its source conversation' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body).to include('id' => suggestion.id, 'question' => suggestion.question)
|
||||
expect(response.parsed_body['observations']).to contain_exactly(
|
||||
include('conversation' => include('id' => conversation.id, 'display_id' => conversation.display_id))
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns only source conversations the agent can access' do
|
||||
create(:inbox_member, user: agent, inbox: inbox)
|
||||
hidden_conversation = create(:conversation, account: account, inbox: create(:inbox, account: account))
|
||||
suggestion.observations.create!(
|
||||
conversation: hidden_conversation,
|
||||
generated_question: suggestion.question,
|
||||
generated_answer: suggestion.answer,
|
||||
language: suggestion.language
|
||||
)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['observations'].pluck('conversation').pluck('id')).to contain_exactly(conversation.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /api/v1/accounts/:account_id/captain/faq_suggestions/:id' do
|
||||
it 'lets an administrator edit an open suggestion' do
|
||||
patch "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}",
|
||||
params: { faq_suggestion: { question: 'Updated question' } },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['question']).to eq('Updated question')
|
||||
expect(suggestion.reload.question).to eq('Updated question')
|
||||
end
|
||||
|
||||
it 'lets an agent edit an accessible suggestion' do
|
||||
create(:inbox_member, user: agent, inbox: inbox)
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}",
|
||||
params: { faq_suggestion: { question: 'Updated question' } },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(suggestion.reload.question).to eq('Updated question')
|
||||
end
|
||||
|
||||
it 'does not let an agent edit an inaccessible suggestion' do
|
||||
patch "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}",
|
||||
params: { faq_suggestion: { question: 'Updated question' } },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(suggestion.reload.question).to eq('How do I enable the feature?')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/captain/faq_suggestions/:id/approve' do
|
||||
it 'lets an administrator approve an edited suggestion as an FAQ' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/approve",
|
||||
params: { faq_suggestion: { answer: 'Enable it in account settings.' } },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(assistant.responses.approved, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['answer']).to eq('Enable it in account settings.')
|
||||
expect(suggestion.reload).to be_approved
|
||||
expect(suggestion.observations.pluck(:conversation_id)).to contain_exactly(conversation.id)
|
||||
end
|
||||
|
||||
it 'lets an agent approve an accessible suggestion' do
|
||||
create(:inbox_member, user: agent, inbox: inbox)
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/approve",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(assistant.responses.approved, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(suggestion.reload).to be_approved
|
||||
end
|
||||
|
||||
it 'approves a suggestion written in a language other than the account locale' do
|
||||
suggestion.update!(language: 'pt')
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/approve",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(assistant.responses.approved, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(suggestion.reload).to be_approved
|
||||
end
|
||||
|
||||
it 'does not let an agent approve an inaccessible suggestion' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/approve",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end.not_to change(assistant.responses, :count)
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(suggestion.reload).to be_open
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/captain/faq_suggestions/:id/dismiss' do
|
||||
it 'lets an administrator dismiss an open suggestion without creating an FAQ' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/dismiss",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.not_to change(assistant.responses, :count)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(suggestion.reload).to be_dismissed
|
||||
end
|
||||
|
||||
it 'lets an agent dismiss an accessible suggestion' do
|
||||
create(:inbox_member, user: agent, inbox: inbox)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/dismiss",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(suggestion.reload).to be_dismissed
|
||||
end
|
||||
|
||||
it 'does not let an agent dismiss an inaccessible suggestion' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/dismiss",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(suggestion.reload).to be_open
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,57 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Llm::ConversationFaqJob, type: :job do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account, config: { feature_faq: true }) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, first_reply_created_at: Time.zone.now) }
|
||||
let(:faq_service) { instance_double(Captain::Llm::ConversationFaqService, generate_suggestions: []) }
|
||||
let(:lock_manager) { instance_double(Redis::LockManager, lock: true, unlock: true) }
|
||||
let(:lock_key) { "CAPTAIN_CONVERSATION_FAQ_LOCK::#{assistant.id}::en" }
|
||||
|
||||
before do
|
||||
create(:captain_inbox, inbox: inbox, captain_assistant: assistant)
|
||||
conversation.update!(status: :resolved)
|
||||
allow(Redis::LockManager).to receive(:new).and_return(lock_manager)
|
||||
allow(Captain::Llm::ConversationFaqService).to receive(:new).and_return(faq_service)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'uses the assistant captured when the job was enqueued' do
|
||||
replacement_assistant = create(:captain_assistant, account: account, config: { feature_faq: true })
|
||||
inbox.captain_inbox.update!(captain_assistant: replacement_assistant)
|
||||
|
||||
expect(inbox.reload.captain_assistant).to eq(replacement_assistant)
|
||||
expect(Captain::Llm::ConversationFaqService).to receive(:new)
|
||||
.with(assistant, conversation)
|
||||
.and_return(faq_service)
|
||||
expect(faq_service).to receive(:generate_suggestions)
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end
|
||||
|
||||
it 'locks FAQ grouping for the assistant and normalized language' do
|
||||
conversation.update!(additional_attributes: { conversation_language: 'pt-BR' })
|
||||
expected_key = "CAPTAIN_CONVERSATION_FAQ_LOCK::#{assistant.id}::pt"
|
||||
|
||||
expect(lock_manager).to receive(:lock).with(expected_key, described_class::LOCK_TIMEOUT).and_return(true)
|
||||
expect(lock_manager).to receive(:unlock).with(expected_key)
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end
|
||||
|
||||
context 'when another job holds the grouping lock' do
|
||||
before do
|
||||
allow(lock_manager).to receive(:lock).with(lock_key, described_class::LOCK_TIMEOUT).and_return(false)
|
||||
end
|
||||
|
||||
it 'does not generate suggestions concurrently' do
|
||||
expect(Captain::Llm::ConversationFaqService).not_to receive(:new)
|
||||
|
||||
expect do
|
||||
described_class.new.perform(conversation, assistant)
|
||||
end.to raise_error(MutexApplicationJob::LockAcquisitionError)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -29,7 +29,7 @@ describe CaptainListener do
|
||||
.to receive(:new)
|
||||
.with(assistant, conversation)
|
||||
.and_return(instance_double(Captain::Llm::ContactNotesService, generate_and_update_notes: nil))
|
||||
expect(Captain::Llm::ConversationFaqJob).not_to receive(:perform_later)
|
||||
expect(Captain::Llm::ConversationFaqService).not_to receive(:new)
|
||||
|
||||
listener.conversation_resolved(event)
|
||||
end
|
||||
@@ -42,8 +42,11 @@ describe CaptainListener do
|
||||
assistant.save!
|
||||
end
|
||||
|
||||
it 'enqueues FAQ suggestion generation' do
|
||||
expect(Captain::Llm::ConversationFaqJob).to receive(:perform_later).with(conversation, assistant)
|
||||
it 'generates and deduplicates FAQs' do
|
||||
expect(Captain::Llm::ConversationFaqService)
|
||||
.to receive(:new)
|
||||
.with(assistant, conversation)
|
||||
.and_return(instance_double(Captain::Llm::ConversationFaqService, generate_and_deduplicate: false))
|
||||
expect(Captain::Llm::ContactNotesService).not_to receive(:new)
|
||||
|
||||
listener.conversation_resolved(event)
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Llm::ConversationFaqPromptsService do
|
||||
describe '.generator' do
|
||||
it 'allows a complete FAQ answer to use several related agent messages' do
|
||||
prompt = described_class.generator
|
||||
|
||||
expect(prompt).to include('message or messages that together provide a complete public answer')
|
||||
expect(prompt).to include('Combine facts only across related agent messages')
|
||||
expect(prompt).not_to include('no single human agent message')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,7 +2,7 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
let(:captain_assistant) { create(:captain_assistant) }
|
||||
let(:conversation) { create(:conversation, account: captain_assistant.account, first_reply_created_at: Time.zone.now) }
|
||||
let(:conversation) { create(:conversation, first_reply_created_at: Time.zone.now) }
|
||||
let(:service) { described_class.new(captain_assistant, conversation) }
|
||||
let(:embedding_service) { instance_double(Captain::Llm::EmbeddingService) }
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
@@ -15,8 +15,6 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
let(:mock_response) do
|
||||
instance_double(RubyLLM::Message, content: { faqs: sample_faqs }.to_json)
|
||||
end
|
||||
let(:embedding_one) { [1.0] + Array.new(1535, 0.0) }
|
||||
let(:embedding_two) { [0.0, 1.0] + Array.new(1534, 0.0) }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
@@ -28,10 +26,11 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
|
||||
describe '#generate_suggestions' do
|
||||
describe '#generate_and_deduplicate' do
|
||||
context 'when successful' do
|
||||
before do
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one, embedding_two)
|
||||
allow(embedding_service).to receive(:get_embedding).and_return([0.1, 0.2, 0.3])
|
||||
allow(captain_assistant.responses).to receive(:nearest_neighbors).and_return([])
|
||||
end
|
||||
|
||||
it 'uses the conversation FAQ generation feature model' do
|
||||
@@ -39,7 +38,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
model: Llm::Models.default_model_for('conversation_faq_generation')
|
||||
).and_return(mock_chat)
|
||||
|
||||
described_class.new(captain_assistant, conversation).generate_suggestions
|
||||
described_class.new(captain_assistant, conversation).generate_and_deduplicate
|
||||
end
|
||||
|
||||
it 'uses the conversation FAQ default ahead of the legacy global installation model' do
|
||||
@@ -49,7 +48,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
model: Llm::Models.default_model_for('conversation_faq_generation')
|
||||
).and_return(mock_chat)
|
||||
|
||||
described_class.new(captain_assistant, conversation).generate_suggestions
|
||||
described_class.new(captain_assistant, conversation).generate_and_deduplicate
|
||||
end
|
||||
|
||||
it 'keeps account conversation FAQ model overrides ahead of the feature default' do
|
||||
@@ -58,7 +57,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
|
||||
expect(RubyLLM).to receive(:chat).with(model: 'gpt-4.1-mini').and_return(mock_chat)
|
||||
|
||||
described_class.new(captain_assistant, conversation).generate_suggestions
|
||||
described_class.new(captain_assistant, conversation).generate_and_deduplicate
|
||||
end
|
||||
|
||||
it 'resolves the feature model from the conversation account' do
|
||||
@@ -67,7 +66,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
account: conversation.account
|
||||
).and_call_original
|
||||
|
||||
described_class.new(captain_assistant, conversation).generate_suggestions
|
||||
described_class.new(captain_assistant, conversation).generate_and_deduplicate
|
||||
end
|
||||
|
||||
it 'sends only customer and human support agent messages to the LLM' do
|
||||
@@ -85,7 +84,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
||||
message_type: :activity, content: 'Activity message')
|
||||
|
||||
service.generate_suggestions
|
||||
service.generate_and_deduplicate
|
||||
|
||||
expected_content = satisfy do |content|
|
||||
content.include?('User: Customer question') &&
|
||||
@@ -105,7 +104,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
sender: nil, message_type: :outgoing, content: 'Human replied from the native app',
|
||||
content_attributes: { external_echo: true })
|
||||
|
||||
service.generate_suggestions
|
||||
service.generate_and_deduplicate
|
||||
|
||||
expected_content = satisfy do |content|
|
||||
content.include?('User: Customer asks in a native channel') &&
|
||||
@@ -134,27 +133,22 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
block.call
|
||||
end
|
||||
|
||||
service.generate_suggestions
|
||||
service.generate_and_deduplicate
|
||||
end
|
||||
|
||||
it 'creates suggestions instead of trusted FAQs for valid conversation content' do
|
||||
it 'creates new FAQs for valid conversation content' do
|
||||
expect do
|
||||
service.generate_suggestions
|
||||
end.to change(captain_assistant.faq_suggestions, :count).by(2)
|
||||
expect(Captain::FaqObservation.count).to eq(2)
|
||||
expect(captain_assistant.responses.count).to be_zero
|
||||
service.generate_and_deduplicate
|
||||
end.to change(captain_assistant.responses, :count).by(2)
|
||||
end
|
||||
|
||||
it 'saves open suggestions with one attached source each' do
|
||||
service.generate_suggestions
|
||||
it 'saves FAQs with pending status linked to conversation' do
|
||||
service.generate_and_deduplicate
|
||||
expect(
|
||||
captain_assistant.faq_suggestions.pluck(:question, :answer, :status, :source_count, :language)
|
||||
captain_assistant.responses.pluck(:question, :answer, :status, :documentable_id)
|
||||
).to contain_exactly(
|
||||
['What is the purpose?', 'To help users.', 'open', 1, 'en'],
|
||||
['How does it work?', 'Through AI.', 'open', 1, 'en']
|
||||
)
|
||||
expect(Captain::FaqObservation.attached.pluck(:conversation_id, :language)).to contain_exactly(
|
||||
[conversation.id, 'en'], [conversation.id, 'en']
|
||||
['What is the purpose?', 'To help users.', 'pending', conversation.id],
|
||||
['How does it work?', 'Through AI.', 'pending', conversation.id]
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -163,309 +157,37 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
let(:conversation) { create(:conversation) }
|
||||
|
||||
it 'returns an empty array without generating FAQs' do
|
||||
expect(service.generate_suggestions).to eq([])
|
||||
expect(service.generate_and_deduplicate).to eq([])
|
||||
end
|
||||
|
||||
it 'does not call the LLM API' do
|
||||
expect(RubyLLM).not_to receive(:chat)
|
||||
service.generate_suggestions
|
||||
service.generate_and_deduplicate
|
||||
end
|
||||
end
|
||||
|
||||
context 'when finding duplicates' do
|
||||
let(:existing_response) do
|
||||
create(:captain_assistant_response, assistant: captain_assistant, account: captain_assistant.account,
|
||||
question: 'Similar question', answer: 'Similar answer', embedding: embedding_one)
|
||||
create(:captain_assistant_response, assistant: captain_assistant, question: 'Similar question', answer: 'Similar answer')
|
||||
end
|
||||
let(:match_response) { instance_double(RubyLLM::Message, content: { same_faq: true }.to_json) }
|
||||
|
||||
before do
|
||||
existing_response
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one)
|
||||
allow(mock_chat).to receive(:ask) do |input|
|
||||
input.start_with?('{') ? match_response : mock_response
|
||||
end
|
||||
end
|
||||
|
||||
it 'discards candidates the LLM confirms are covered by an approved FAQ' do
|
||||
expect do
|
||||
service.generate_suggestions
|
||||
end.to change(Captain::FaqObservation.discarded, :count).by(2)
|
||||
expect(captain_assistant.faq_suggestions.count).to be_zero
|
||||
end
|
||||
|
||||
it 'uses the conversation FAQ matching feature model' do
|
||||
expect(RubyLLM).to receive(:chat).with(
|
||||
model: Llm::Models.default_model_for('conversation_faq_matching')
|
||||
).at_least(:once).and_return(mock_chat)
|
||||
|
||||
service.generate_suggestions
|
||||
end
|
||||
|
||||
it 'uses the account model override for conversation FAQ matching' do
|
||||
conversation.account.update!(captain_models: { 'conversation_faq_matching' => 'gpt-5-mini' })
|
||||
|
||||
expect(RubyLLM).to receive(:chat).with(model: 'gpt-5-mini').at_least(:once).and_return(mock_chat)
|
||||
|
||||
service.generate_suggestions
|
||||
end
|
||||
|
||||
it 'resolves the matching feature model from the conversation account' do
|
||||
allow(Llm::FeatureRouter).to receive(:resolve).and_call_original
|
||||
expect(Llm::FeatureRouter).to receive(:resolve).with(
|
||||
feature: 'conversation_faq_matching',
|
||||
account: conversation.account
|
||||
).and_call_original
|
||||
|
||||
service.generate_suggestions
|
||||
end
|
||||
end
|
||||
|
||||
context 'when FAQ comparison cannot be completed' do
|
||||
let(:existing_response) do
|
||||
create(:captain_assistant_response, assistant: captain_assistant, account: captain_assistant.account,
|
||||
question: 'Similar question', answer: 'Similar answer', embedding: embedding_one)
|
||||
end
|
||||
let(:comparison_response) { instance_double(RubyLLM::Message, content: comparison_response_content) }
|
||||
let(:comparison_response_content) { 'invalid json' }
|
||||
|
||||
before do
|
||||
existing_response
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one)
|
||||
allow(mock_chat).to receive(:ask) do |input|
|
||||
input.start_with?('{') ? comparison_response : mock_response
|
||||
end
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'raises when the comparison response is malformed' do
|
||||
expect do
|
||||
service.generate_suggestions
|
||||
end.to raise_error(JSON::ParserError)
|
||||
expect(captain_assistant.faq_suggestions.count).to be_zero
|
||||
end
|
||||
|
||||
context 'when the response omits the comparison result' do
|
||||
let(:comparison_response_content) { {}.to_json }
|
||||
|
||||
it 'raises instead of treating the response as a non-match' do
|
||||
expect do
|
||||
service.generate_suggestions
|
||||
end.to raise_error(KeyError)
|
||||
expect(captain_assistant.faq_suggestions.count).to be_zero
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the comparison result is not a boolean' do
|
||||
let(:comparison_response_content) { { same_faq: 'false' }.to_json }
|
||||
|
||||
it 'raises instead of treating the response as a non-match' do
|
||||
expect do
|
||||
service.generate_suggestions
|
||||
end.to raise_error(TypeError, 'same_faq must be a boolean')
|
||||
expect(captain_assistant.faq_suggestions.count).to be_zero
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the comparison provider fails' do
|
||||
before do
|
||||
allow(mock_chat).to receive(:ask) do |input|
|
||||
raise RubyLLM::Error.new(nil, 'API Error') if input.start_with?('{')
|
||||
|
||||
mock_response
|
||||
end
|
||||
end
|
||||
|
||||
it 'raises instead of treating the failure as a non-match' do
|
||||
expect do
|
||||
service.generate_suggestions
|
||||
end.to raise_error(RubyLLM::Error)
|
||||
expect(captain_assistant.faq_suggestions.count).to be_zero
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the classifier confirms a non-match' do
|
||||
let(:sample_faqs) { [{ 'question' => 'How can I use the feature?', 'answer' => 'Enable it in settings.' }] }
|
||||
let(:match_response) { instance_double(RubyLLM::Message, content: { same_faq: false }.to_json) }
|
||||
|
||||
before do
|
||||
create(:captain_assistant_response, assistant: captain_assistant, account: captain_assistant.account,
|
||||
question: 'How do I enable the feature?', answer: 'Turn it on in settings.',
|
||||
embedding: embedding_one)
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one)
|
||||
allow(mock_chat).to receive(:ask) do |input|
|
||||
input.start_with?('{') ? match_response : mock_response
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates a new suggestion' do
|
||||
expect do
|
||||
service.generate_suggestions
|
||||
end.to change(captain_assistant.faq_suggestions, :count).by(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an open suggestion is the same FAQ' do
|
||||
let(:sample_faqs) { [{ 'question' => 'How can I use the feature?', 'answer' => 'Enable it in settings.' }] }
|
||||
let(:existing_suggestion) do
|
||||
captain_assistant.faq_suggestions.create!(
|
||||
question: 'How do I enable the feature?',
|
||||
answer: 'Turn it on in settings.',
|
||||
embedding: embedding_one
|
||||
).tap do |suggestion|
|
||||
suggestion.observations.create!(
|
||||
conversation: create(:conversation, account: captain_assistant.account),
|
||||
generated_question: suggestion.question,
|
||||
generated_answer: suggestion.answer,
|
||||
language: suggestion.language
|
||||
)
|
||||
suggestion.update!(source_count: suggestion.observations.attached.count)
|
||||
end
|
||||
end
|
||||
let(:match_response) { instance_double(RubyLLM::Message, content: { same_faq: true }.to_json) }
|
||||
|
||||
before do
|
||||
existing_suggestion
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one)
|
||||
allow(mock_chat).to receive(:ask) do |input|
|
||||
input.start_with?('{') ? match_response : mock_response
|
||||
end
|
||||
end
|
||||
|
||||
it 'attaches the observation and increments the source count' do
|
||||
expect do
|
||||
service.generate_suggestions
|
||||
end.to change(existing_suggestion.observations, :count).by(1)
|
||||
expect(existing_suggestion.reload.source_count).to eq(2)
|
||||
expect(captain_assistant.faq_suggestions.count).to eq(1)
|
||||
end
|
||||
|
||||
it 'does not attach the observation when the suggestion changes after classification' do
|
||||
allow(mock_chat).to receive(:ask) do |input|
|
||||
if input.start_with?('{')
|
||||
existing_suggestion.update!(question: 'Edited after classification started')
|
||||
match_response
|
||||
else
|
||||
mock_response
|
||||
end
|
||||
end
|
||||
|
||||
expect do
|
||||
service.generate_suggestions
|
||||
end.to raise_error(described_class::SuggestionChangedError)
|
||||
expect(existing_suggestion.observations.count).to eq(1)
|
||||
expect(existing_suggestion.reload.source_count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a similar open suggestion uses another language' do
|
||||
let(:sample_faqs) { [{ 'question' => 'Como ativo o recurso?', 'answer' => 'Ative nas configuracoes.' }] }
|
||||
let!(:existing_suggestion) do
|
||||
captain_assistant.faq_suggestions.create!(question: 'How do I enable the feature?', answer: 'Turn it on in settings.',
|
||||
embedding: embedding_one, language: 'en', source_count: 1)
|
||||
end
|
||||
|
||||
before do
|
||||
conversation.update!(additional_attributes: { conversation_language: 'pt-BR' })
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one)
|
||||
end
|
||||
|
||||
it 'creates a separate suggestion in the conversation language' do
|
||||
expect do
|
||||
service.generate_suggestions
|
||||
end.to change(captain_assistant.faq_suggestions, :count).by(1)
|
||||
|
||||
expect(captain_assistant.faq_suggestions.pluck(:language)).to contain_exactly('en', 'pt')
|
||||
expect(existing_suggestion.reload.source_count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an open suggestion uses another locale variant of the same language' do
|
||||
let(:account) { create(:account, locale: 'pt_BR') }
|
||||
let(:captain_assistant) { create(:captain_assistant, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, first_reply_created_at: Time.zone.now) }
|
||||
let(:sample_faqs) { [{ 'question' => 'Como ativo o recurso?', 'answer' => 'Ative nas configuracoes.' }] }
|
||||
let(:existing_suggestion) do
|
||||
captain_assistant.faq_suggestions.create!(
|
||||
question: 'Como habilito o recurso?',
|
||||
answer: 'Ative nas configuracoes.',
|
||||
embedding: embedding_one,
|
||||
language: 'pt',
|
||||
source_count: 1
|
||||
let(:similar_neighbor) do
|
||||
OpenStruct.new(
|
||||
id: 1,
|
||||
question: existing_response.question,
|
||||
answer: existing_response.answer,
|
||||
neighbor_distance: 0.1
|
||||
)
|
||||
end
|
||||
let(:match_response) { instance_double(RubyLLM::Message, content: { same_faq: true }.to_json) }
|
||||
|
||||
before do
|
||||
existing_suggestion
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one)
|
||||
allow(mock_chat).to receive(:ask) do |input|
|
||||
input.start_with?('{') ? match_response : mock_response
|
||||
end
|
||||
allow(embedding_service).to receive(:get_embedding).and_return([0.1, 0.2, 0.3])
|
||||
allow(captain_assistant.responses).to receive(:nearest_neighbors).and_return([similar_neighbor])
|
||||
end
|
||||
|
||||
it 'attaches the observation to the existing base-language suggestion' do
|
||||
it 'filters out duplicate FAQs based on embedding similarity' do
|
||||
expect do
|
||||
service.generate_suggestions
|
||||
end.to change(existing_suggestion.observations, :count).by(1)
|
||||
|
||||
expect(existing_suggestion.reload.source_count).to eq(2)
|
||||
expect(captain_assistant.faq_suggestions.count).to eq(1)
|
||||
expect(existing_suggestion.observations.last.language).to eq('pt')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a similar approved FAQ uses another language' do
|
||||
let(:sample_faqs) { [{ 'question' => 'Como ativo o recurso?', 'answer' => 'Ative nas configuracoes.' }] }
|
||||
let(:match_response) { instance_double(RubyLLM::Message, content: { same_faq: true }.to_json) }
|
||||
|
||||
before do
|
||||
create(:captain_assistant_response, assistant: captain_assistant, account: captain_assistant.account,
|
||||
question: 'How do I enable the feature?', answer: 'Turn it on in settings.',
|
||||
embedding: embedding_one)
|
||||
conversation.update!(additional_attributes: { conversation_language: 'pt-BR' })
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one)
|
||||
allow(mock_chat).to receive(:ask) do |input|
|
||||
input.start_with?('{') ? match_response : mock_response
|
||||
end
|
||||
end
|
||||
|
||||
it 'deduplicates against the approved FAQ' do
|
||||
expect do
|
||||
service.generate_suggestions
|
||||
end.to change(Captain::FaqObservation.discarded, :count).by(1)
|
||||
expect(captain_assistant.faq_suggestions.count).to be_zero
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation and account locales share a base language' do
|
||||
let(:account) { create(:account, locale: 'pt_BR') }
|
||||
let(:captain_assistant) { create(:captain_assistant, account: account) }
|
||||
let(:conversation) do
|
||||
create(:conversation, account: account, first_reply_created_at: Time.zone.now,
|
||||
additional_attributes: { conversation_language: 'pt' })
|
||||
end
|
||||
let!(:existing_response) do
|
||||
create(:captain_assistant_response, assistant: captain_assistant, account: account,
|
||||
question: 'Como ativo o recurso?', answer: 'Ative nas configuracoes.',
|
||||
embedding: embedding_one)
|
||||
end
|
||||
let(:match_response) { instance_double(RubyLLM::Message, content: { same_faq: true }.to_json) }
|
||||
|
||||
before do
|
||||
existing_response
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one)
|
||||
allow(mock_chat).to receive(:ask) do |input|
|
||||
input.start_with?('{') ? match_response : mock_response
|
||||
end
|
||||
end
|
||||
|
||||
it 'deduplicates against approved FAQs in the same base language' do
|
||||
expect do
|
||||
service.generate_suggestions
|
||||
end.to change(Captain::FaqObservation.discarded, :count).by(2)
|
||||
expect(captain_assistant.faq_suggestions.count).to be_zero
|
||||
service.generate_and_deduplicate
|
||||
end.not_to change(captain_assistant.responses, :count)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -477,7 +199,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
|
||||
it 'returns empty array and logs the error' do
|
||||
expect(Rails.logger).to receive(:error).with('LLM API Error: API Error')
|
||||
expect(service.generate_suggestions).to eq([])
|
||||
expect(service.generate_and_deduplicate).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -492,7 +214,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
|
||||
it 'handles JSON parsing errors gracefully' do
|
||||
expect(Rails.logger).to receive(:error).with(/Error in parsing GPT processed response:/)
|
||||
expect(service.generate_suggestions).to eq([])
|
||||
expect(service.generate_and_deduplicate).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -506,7 +228,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
end
|
||||
|
||||
it 'returns empty array' do
|
||||
expect(service.generate_suggestions).to eq([])
|
||||
expect(service.generate_and_deduplicate).to eq([])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -514,44 +236,22 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
describe 'language handling' do
|
||||
context 'when conversation has different language' do
|
||||
let(:account) { create(:account, locale: 'fr') }
|
||||
let(:captain_assistant) { create(:captain_assistant, account: account) }
|
||||
let(:conversation) do
|
||||
create(:conversation, account: account, first_reply_created_at: Time.zone.now)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one, embedding_two)
|
||||
allow(embedding_service).to receive(:get_embedding).and_return([0.1, 0.2, 0.3])
|
||||
allow(captain_assistant.responses).to receive(:nearest_neighbors).and_return([])
|
||||
end
|
||||
|
||||
it 'uses account language for system prompt' do
|
||||
expect(Captain::Llm::ConversationFaqPromptsService).to receive(:generator)
|
||||
expect(Captain::Llm::SystemPromptsService).to receive(:conversation_faq_generator)
|
||||
.with('french')
|
||||
.at_least(:once)
|
||||
.and_call_original
|
||||
|
||||
service.generate_suggestions
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation language differs from account language' do
|
||||
let(:account) { create(:account, locale: 'en') }
|
||||
let(:captain_assistant) { create(:captain_assistant, account: account) }
|
||||
let(:conversation) do
|
||||
create(:conversation, account: account, first_reply_created_at: Time.zone.now,
|
||||
additional_attributes: { conversation_language: 'pt-BR' })
|
||||
end
|
||||
|
||||
before do
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one, embedding_two)
|
||||
end
|
||||
|
||||
it 'uses the conversation language for the system prompt' do
|
||||
expect(Captain::Llm::ConversationFaqPromptsService).to receive(:generator)
|
||||
.with('portuguese')
|
||||
.at_least(:once)
|
||||
.and_call_original
|
||||
|
||||
service.generate_suggestions
|
||||
service.generate_and_deduplicate
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -26,10 +26,9 @@ RSpec.describe Llm::Models do
|
||||
end
|
||||
end
|
||||
|
||||
it 'routes each FAQ operation independently' do
|
||||
it 'routes document and conversation FAQ generation independently' do
|
||||
expect(described_class.default_model_for('document_faq_generation')).to eq('gpt-4.1-mini')
|
||||
expect(described_class.default_model_for('conversation_faq_generation')).to eq('gpt-5.2')
|
||||
expect(described_class.default_model_for('conversation_faq_matching')).to eq('gpt-4.1-mini')
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export class AddAgentModal {
|
||||
private page: Page;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
getModalTitle() {
|
||||
return this.page.locator('[data-test-id="modal-header-title"]');
|
||||
}
|
||||
|
||||
getAgentNameInput() {
|
||||
return this.page.getByRole('textbox', { name: 'Agent Name' });
|
||||
}
|
||||
|
||||
getEmailInput() {
|
||||
return this.page.getByRole('textbox', { name: 'Email Address' });
|
||||
}
|
||||
|
||||
getRoleCombobox() {
|
||||
return this.page.getByRole('combobox', { name: 'Role' });
|
||||
}
|
||||
|
||||
getSubmitButton() {
|
||||
return this.page.locator('form').getByRole('button', { name: 'Add Agent' });
|
||||
}
|
||||
|
||||
getCancelButton() {
|
||||
return this.page.getByRole('button', { name: 'Cancel' });
|
||||
}
|
||||
|
||||
getSuccessMessage() {
|
||||
return this.page.getByText('Agent added successfully');
|
||||
}
|
||||
|
||||
async fillAgentName(name: string) {
|
||||
await this.getAgentNameInput().fill(name);
|
||||
await this.page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
async fillEmail(email: string) {
|
||||
await this.getEmailInput().fill(email);
|
||||
await this.page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
async submitForm() {
|
||||
await this.getSubmitButton().click();
|
||||
}
|
||||
|
||||
async cancelForm() {
|
||||
await this.getCancelButton().click();
|
||||
}
|
||||
|
||||
async createAgent(name: string, email: string) {
|
||||
await this.fillAgentName(name);
|
||||
await this.fillEmail(email);
|
||||
await this.submitForm();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export class AddAgentsForm {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
getPageHeading() {
|
||||
return this.page
|
||||
.locator('form')
|
||||
.getByRole('heading', { name: 'Agents', level: 2, exact: true });
|
||||
}
|
||||
|
||||
getAgentDropdown() {
|
||||
return this.page.getByPlaceholder('Pick agents for the inbox');
|
||||
}
|
||||
|
||||
getAgentSelector() {
|
||||
return this.page.getByTestId('agent-selector');
|
||||
}
|
||||
|
||||
getAgentOption(agentName: string) {
|
||||
return this.getAgentSelector().getByRole('button', {
|
||||
name: agentName,
|
||||
exact: true,
|
||||
});
|
||||
}
|
||||
|
||||
getDropdownButtons() {
|
||||
return this.getAgentSelector().getByRole('button');
|
||||
}
|
||||
|
||||
getSubmitButton() {
|
||||
return this.page.getByRole('button', { name: 'Add agents' });
|
||||
}
|
||||
|
||||
async openAgentDropdown() {
|
||||
await this.getAgentDropdown().click();
|
||||
}
|
||||
|
||||
async selectAgent(agentName: string) {
|
||||
await this.openAgentDropdown();
|
||||
await this.getAgentOption(agentName).waitFor({ state: 'visible' });
|
||||
await this.getAgentOption(agentName).click();
|
||||
}
|
||||
|
||||
async selectAgentByIndex(index: number = 0) {
|
||||
await this.openAgentDropdown();
|
||||
const buttons = this.getDropdownButtons();
|
||||
await buttons.first().waitFor({ state: 'visible' });
|
||||
await buttons.nth(index).click();
|
||||
}
|
||||
|
||||
async closeDropdown() {
|
||||
await this.page.keyboard.press('Escape');
|
||||
}
|
||||
|
||||
async submitForm() {
|
||||
await this.getSubmitButton().click();
|
||||
}
|
||||
|
||||
async addAgents(agentNames: string[]) {
|
||||
for (const agentName of agentNames) {
|
||||
await this.selectAgent(agentName);
|
||||
}
|
||||
await this.closeDropdown();
|
||||
await this.submitForm();
|
||||
}
|
||||
|
||||
async addFirstAgent() {
|
||||
await this.selectAgentByIndex(0);
|
||||
await this.closeDropdown();
|
||||
await this.submitForm();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export class AgentPage {
|
||||
private page: Page;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
async navigate(accountId: number = 1) {
|
||||
await this.page.goto(`/app/accounts/${accountId}/settings/agents/list`);
|
||||
}
|
||||
|
||||
getPageHeading() {
|
||||
return this.page.getByRole('heading', { name: 'Agents', level: 1 });
|
||||
}
|
||||
|
||||
getDescriptionText() {
|
||||
return this.page.getByText(
|
||||
'An agent is a member of your customer support team who can view and respond to user messages.'
|
||||
);
|
||||
}
|
||||
|
||||
getLearnLink() {
|
||||
return this.page.getByRole('link', { name: 'Learn about user roles' });
|
||||
}
|
||||
|
||||
getAddAgentButton() {
|
||||
return this.page.getByRole('button', { name: 'Add Agent' });
|
||||
}
|
||||
|
||||
async openAddAgentModal() {
|
||||
await this.getAddAgentButton().click();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export class ApiChannelForm {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
getChannelNameInput() {
|
||||
return this.page.getByRole('textbox', { name: 'Channel Name' });
|
||||
}
|
||||
|
||||
getWebhookUrlInput() {
|
||||
return this.page.getByRole('textbox', { name: 'Webhook URL' });
|
||||
}
|
||||
|
||||
getSubmitButton() {
|
||||
return this.page.getByRole('button', { name: 'Create API Channel' });
|
||||
}
|
||||
|
||||
async fillChannelName(name: string) {
|
||||
await this.getChannelNameInput().fill(name);
|
||||
}
|
||||
|
||||
async fillWebhookUrl(url: string) {
|
||||
await this.getWebhookUrlInput().fill(url);
|
||||
}
|
||||
|
||||
async submitForm() {
|
||||
await this.getSubmitButton().click();
|
||||
}
|
||||
|
||||
async createApiChannel(channelName: string, webhookUrl?: string) {
|
||||
await this.fillChannelName(channelName);
|
||||
if (webhookUrl) {
|
||||
await this.fillWebhookUrl(webhookUrl);
|
||||
}
|
||||
await this.submitForm();
|
||||
}
|
||||
|
||||
getValidationError() {
|
||||
return this.page.locator('.message, .error-message').first();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export class ChannelSelector {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
getPageHeading() {
|
||||
return this.page.getByRole('heading', { name: /choose channel/i });
|
||||
}
|
||||
|
||||
getApiChannelCard() {
|
||||
return this.page.getByRole('button', { name: /API.*Make a custom channel/i });
|
||||
}
|
||||
|
||||
getWebsiteChannelCard() {
|
||||
return this.page.getByRole('button', { name: /Website.*Create a live-chat widget/i });
|
||||
}
|
||||
|
||||
async selectApiChannel() {
|
||||
await this.getApiChannelCard().click();
|
||||
}
|
||||
|
||||
async selectWebsiteChannel() {
|
||||
await this.getWebsiteChannelCard().click();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export class FinishSetup {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
getPageHeading() {
|
||||
return this.page.getByRole('heading', {
|
||||
name: 'Your Inbox is ready!',
|
||||
exact: true,
|
||||
});
|
||||
}
|
||||
|
||||
getGoToInboxButton() {
|
||||
return this.page.getByRole('button', { name: /go to inbox|view inbox/i });
|
||||
}
|
||||
|
||||
getMoreSettingsButton() {
|
||||
return this.page.getByRole('button', { name: /more settings|settings/i });
|
||||
}
|
||||
|
||||
getWebhookUrl() {
|
||||
return this.page.locator('code, pre').filter({ hasText: /http/i }).first();
|
||||
}
|
||||
|
||||
async goToInbox() {
|
||||
await this.getGoToInboxButton().click();
|
||||
}
|
||||
|
||||
async goToSettings() {
|
||||
await this.getMoreSettingsButton().click();
|
||||
}
|
||||
}
|
||||
@@ -1 +1,8 @@
|
||||
export { Login } from './login.component';
|
||||
export { AgentPage } from './agent-page.component';
|
||||
export { AddAgentModal } from './add-agent-modal.component';
|
||||
export { AddAgentsForm } from './add-agents-form.component';
|
||||
export { SettingsInboxPage } from './settings-inbox-page.component';
|
||||
export { ChannelSelector } from './channel-selector.component';
|
||||
export { ApiChannelForm } from './api-channel-form.component';
|
||||
export { FinishSetup } from './finish-setup.component';
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
type DashboardApi = {
|
||||
delete: (url: string) => Promise<unknown>;
|
||||
get: (url: string) => Promise<{
|
||||
data: {
|
||||
payload: Array<{ id: number }>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export class SettingsInboxPage {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async navigate(accountId: number = 1) {
|
||||
await this.page.goto(`/app/accounts/${accountId}/settings/inboxes/list`);
|
||||
}
|
||||
|
||||
getAddInboxButton() {
|
||||
return this.page.getByRole('link', { name: 'Add Inbox' });
|
||||
}
|
||||
|
||||
async clickAddInboxButton() {
|
||||
await this.getAddInboxButton().click();
|
||||
}
|
||||
|
||||
getPageHeading() {
|
||||
return this.page.getByRole('heading', { name: /inboxes/i });
|
||||
}
|
||||
|
||||
async deleteInbox(accountId: number, inboxId: number) {
|
||||
await this.page.evaluate(
|
||||
async ({ accountId: currentAccountId, inboxId: currentInboxId }) => {
|
||||
const api = (window as typeof window & { axios: DashboardApi }).axios;
|
||||
await api.delete(
|
||||
`/api/v1/accounts/${currentAccountId}/inboxes/${currentInboxId}`
|
||||
);
|
||||
},
|
||||
{ accountId, inboxId }
|
||||
);
|
||||
}
|
||||
|
||||
async isInboxPresent(accountId: number, inboxId: number) {
|
||||
return this.page.evaluate(
|
||||
async ({ accountId: currentAccountId, inboxId: currentInboxId }) => {
|
||||
const api = (window as typeof window & { axios: DashboardApi }).axios;
|
||||
const response = await api.get(
|
||||
`/api/v1/accounts/${currentAccountId}/inboxes`
|
||||
);
|
||||
return response.data.payload.some(
|
||||
inbox => inbox.id === currentInboxId
|
||||
);
|
||||
},
|
||||
{ accountId, inboxId }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { AddAgentModal, AgentPage, Login } from '@components/ui';
|
||||
|
||||
const TEST_EMAIL = process.env.TEST_USER_EMAIL || 'admin@chatwoot.com';
|
||||
const TEST_PASSWORD = process.env.TEST_USER_PASSWORD || 'Password123@#';
|
||||
|
||||
test.describe('Agent Onboarding - UI', () => {
|
||||
let loginComponent: Login;
|
||||
let agentPage: AgentPage;
|
||||
let addAgentModal: AddAgentModal;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
loginComponent = new Login(page);
|
||||
agentPage = new AgentPage(page);
|
||||
addAgentModal = new AddAgentModal(page);
|
||||
|
||||
await loginComponent.navigate();
|
||||
await loginComponent.login(TEST_EMAIL, TEST_PASSWORD);
|
||||
|
||||
await expect(page).toHaveURL(/\/app\/accounts\/\d+\/dashboard/);
|
||||
const accountId = Number(page.url().match(/\/app\/accounts\/(\d+)\//)![1]);
|
||||
await agentPage.navigate(accountId);
|
||||
});
|
||||
|
||||
test('should validate all UI elements on agents page', async () => {
|
||||
await expect(agentPage.getPageHeading()).toBeVisible();
|
||||
await expect(agentPage.getDescriptionText()).toBeVisible();
|
||||
|
||||
const learnLink = agentPage.getLearnLink();
|
||||
await expect(learnLink).toBeVisible();
|
||||
await expect(learnLink).toHaveAttribute('href', 'https://chwt.app/hc/agents');
|
||||
|
||||
await expect(agentPage.getAddAgentButton()).toBeVisible();
|
||||
await agentPage.openAddAgentModal();
|
||||
|
||||
await expect(addAgentModal.getModalTitle()).toBeVisible();
|
||||
await expect(addAgentModal.getModalTitle()).toHaveText('Add agent to your team');
|
||||
|
||||
await expect(addAgentModal.getAgentNameInput()).toBeVisible();
|
||||
await expect(addAgentModal.getEmailInput()).toBeVisible();
|
||||
await expect(addAgentModal.getRoleCombobox()).toBeVisible();
|
||||
await expect(addAgentModal.getSubmitButton()).toBeVisible();
|
||||
await expect(addAgentModal.getCancelButton()).toBeVisible();
|
||||
|
||||
await expect(addAgentModal.getSubmitButton()).toBeDisabled();
|
||||
|
||||
await addAgentModal.getAgentNameInput().fill('Test');
|
||||
await expect(addAgentModal.getSubmitButton()).toBeDisabled();
|
||||
|
||||
await addAgentModal.getAgentNameInput().clear();
|
||||
await addAgentModal.getEmailInput().fill('test@example.com');
|
||||
await expect(addAgentModal.getSubmitButton()).toBeDisabled();
|
||||
|
||||
await addAgentModal.getAgentNameInput().fill('Test');
|
||||
await expect(addAgentModal.getSubmitButton()).toBeEnabled();
|
||||
|
||||
await addAgentModal.cancelForm();
|
||||
await expect(addAgentModal.getModalTitle()).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
AddAgentsForm,
|
||||
ApiChannelForm,
|
||||
ChannelSelector,
|
||||
FinishSetup,
|
||||
Login,
|
||||
SettingsInboxPage,
|
||||
} from '@components/ui';
|
||||
|
||||
const TEST_EMAIL = process.env.TEST_USER_EMAIL || 'admin@chatwoot.com';
|
||||
const TEST_PASSWORD = process.env.TEST_USER_PASSWORD || 'Password123@#';
|
||||
|
||||
test.describe('Inbox Creation - UI Flow', () => {
|
||||
const testInbox = {
|
||||
name: `Test Inbox ${Date.now()}`,
|
||||
webhookUrl: 'https://example.com/webhook',
|
||||
};
|
||||
|
||||
let accountId: number | undefined;
|
||||
let inboxId: number | undefined;
|
||||
|
||||
test.beforeEach(() => {
|
||||
accountId = undefined;
|
||||
inboxId = undefined;
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
const currentAccountId = accountId;
|
||||
const currentInboxId = inboxId;
|
||||
if (!currentAccountId || !currentInboxId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const settingsInboxPage = new SettingsInboxPage(page);
|
||||
await settingsInboxPage.deleteInbox(currentAccountId, currentInboxId);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
settingsInboxPage.isInboxPresent(currentAccountId, currentInboxId),
|
||||
{
|
||||
message: `Inbox ${currentInboxId} was not deleted`,
|
||||
timeout: 30_000,
|
||||
}
|
||||
)
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
test('should complete full inbox creation flow with UI validation', async ({
|
||||
page,
|
||||
}) => {
|
||||
const loginComponent = new Login(page);
|
||||
await loginComponent.navigate();
|
||||
await loginComponent.login(TEST_EMAIL, TEST_PASSWORD);
|
||||
await page.waitForURL(/\/app\/accounts\/\d+\/dashboard/);
|
||||
|
||||
accountId = Number(page.url().match(/\/app\/accounts\/(\d+)\//)![1]);
|
||||
const settingsInboxPage = new SettingsInboxPage(page);
|
||||
await settingsInboxPage.navigate(accountId);
|
||||
|
||||
await expect(settingsInboxPage.getPageHeading()).toBeVisible();
|
||||
await expect(settingsInboxPage.getAddInboxButton()).toBeVisible();
|
||||
|
||||
await settingsInboxPage.clickAddInboxButton();
|
||||
await page.waitForURL(/\/settings\/inboxes\/new/);
|
||||
|
||||
const channelSelector = new ChannelSelector(page);
|
||||
await expect(channelSelector.getPageHeading()).toBeVisible();
|
||||
await channelSelector.selectApiChannel();
|
||||
|
||||
page.on('response', async response => {
|
||||
if (
|
||||
response.url().includes('/api/v1/accounts/') &&
|
||||
response.url().includes('/inboxes') &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.status() === 200
|
||||
) {
|
||||
try {
|
||||
const responseData = await response.json();
|
||||
if (responseData.id) {
|
||||
inboxId = responseData.id;
|
||||
}
|
||||
} catch {
|
||||
// ignore non-JSON responses
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const apiChannelForm = new ApiChannelForm(page);
|
||||
await apiChannelForm.fillChannelName(testInbox.name);
|
||||
await apiChannelForm.fillWebhookUrl(testInbox.webhookUrl);
|
||||
await apiChannelForm.submitForm();
|
||||
await expect.poll(() => inboxId).toBeTruthy();
|
||||
|
||||
const addAgentsForm = new AddAgentsForm(page);
|
||||
await expect(addAgentsForm.getPageHeading()).toBeVisible();
|
||||
await addAgentsForm.addFirstAgent();
|
||||
|
||||
await page.waitForURL(/\/settings\/inboxes\/.*\/finish/);
|
||||
const finishSetup = new FinishSetup(page);
|
||||
await expect(finishSetup.getPageHeading()).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -269,6 +269,16 @@ export const icons = {
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
'voice-call': {
|
||||
body: `<mask id="cvc" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16"><rect width="16" height="16" fill="#fff"/><circle cx="12" cy="4" r="4" fill="#000"/></mask><g mask="url(#cvc)"><path d="M7.916 10.784a.5.5 0 0 0 .607-.152L8.7 10.4a1 1 0 0 1 .8-.4H11a1 1 0 0 1 1 1v1.5a1 1 0 0 1-1 1 9 9 0 0 1-9-9 1 1 0 0 1 1-1h1.5a1 1 0 0 1 1 1V6a1 1 0 0 1-.4.8l-.234.176a.5.5 0 0 0-.146.616 7 7 0 0 0 3.196 3.192" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"/></g><circle cx="12" cy="4" r="4" fill="currentColor" fill-opacity="0.15"/><path d="M10.4 3.2v1.6M12 2.4v3.2m1.6-2.4v1.6" stroke="currentColor" stroke-width=".833" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
'whatsapp-voice': {
|
||||
body: `<mask id="cwv" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16"><rect width="16" height="16" fill="#fff"/><circle cx="12" cy="4.5" r="4" fill="#000"/></mask><g mask="url(#cwv)"><path fill-rule="evenodd" clip-rule="evenodd" d="M12.349 3.655A5.6 5.6 0 0 0 8.357 2a5.65 5.65 0 0 0-5.643 5.642c0 .995.26 1.966.753 2.821l-.512 1.873a.63.63 0 0 0 .767.775l1.936-.508a5.64 5.64 0 0 0 2.697.687h.002A5.65 5.65 0 0 0 14 7.647a5.6 5.6 0 0 0-1.652-3.992m-3.992 8.682h-.002a4.7 4.7 0 0 1-2.387-.654l-.171-.102-1.776.466.474-1.73-.111-.178a4.7 4.7 0 0 1-.717-2.496 4.697 4.697 0 0 1 4.692-4.69c1.253 0 2.43.489 3.316 1.376a4.66 4.66 0 0 1 1.372 3.317 4.697 4.697 0 0 1-4.69 4.69m2.573-3.513c-.141-.07-.835-.411-.964-.458-.13-.047-.223-.07-.317.07a8 8 0 0 1-.446.553c-.083.094-.165.106-.306.035s-.595-.22-1.134-.7a4.3 4.3 0 0 1-.784-.976c-.082-.141-.009-.218.061-.288.064-.063.141-.165.212-.247.07-.082.094-.141.141-.235s.024-.176-.012-.247c-.035-.07-.317-.765-.434-1.047-.115-.275-.231-.237-.318-.242a6 6 0 0 0-.27-.005.52.52 0 0 0-.376.177c-.13.14-.493.482-.493 1.176 0 .693.505 1.364.575 1.458.071.094.995 1.518 2.409 2.13.336.145.599.232.804.297.337.107.645.092.888.056.27-.041.834-.342.951-.671.118-.33.118-.612.083-.67-.035-.06-.13-.095-.27-.166" fill="currentColor"/></g><circle cx="12" cy="4.5" r="4" fill="currentColor" fill-opacity="0.15"/><path d="M10.4 3.7v1.6M12 2.9v3.2m1.6-2.4v1.6" stroke="currentColor" stroke-width=".833" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
instagram: {
|
||||
body: `<g fill="none" stroke="currentColor"><path d="M12.0003 15.3329C13.8412 15.3329 15.3337 13.8405 15.3337 11.9996C15.3337 10.1586 13.8412 8.66626 12.0003 8.66626C10.1594 8.66626 8.66699 10.1586 8.66699 11.9996C8.66699 13.8405 10.1594 15.3329 12.0003 15.3329Z" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.5 15.3333V8.66667C4.5 6.36548 6.36548 4.5 8.66667 4.5H15.3333C17.6345 4.5 19.5 6.36548 19.5 8.66667V15.3333C19.5 17.6345 17.6345 19.5 15.3333 19.5H8.66667C6.36548 19.5 4.5 17.6345 4.5 15.3333Z" stroke-width="1.5"/><path d="M16.583 7.42552L16.5913 7.41626" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></g>`,
|
||||
width: 24,
|
||||
@@ -470,5 +480,15 @@ export const icons = {
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
'audio-play': {
|
||||
body: `<path d="M3.31445 11.3998V2.6002C3.31445 2.35931 3.39947 2.15725 3.56951 1.99401C3.73955 1.83077 3.93793 1.74944 4.16465 1.75C4.2355 1.75 4.31004 1.76049 4.38826 1.78146C4.46647 1.80243 4.54073 1.83446 4.61101 1.87753L11.5401 6.27732C11.6677 6.36234 11.7635 6.46862 11.8275 6.59615C11.8916 6.72368 11.9233 6.85829 11.9227 6.99999C11.9222 7.14169 11.8904 7.27631 11.8275 7.40384C11.7646 7.53137 11.6688 7.63764 11.5401 7.72266L4.61101 12.1224C4.54016 12.165 4.46591 12.197 4.38826 12.2185C4.3106 12.2401 4.23607 12.2505 4.16465 12.25C3.93793 12.25 3.73955 12.1684 3.56951 12.0051C3.39947 11.8419 3.31445 11.6401 3.31445 11.3998Z" fill="currentColor"/>`,
|
||||
width: 14,
|
||||
height: 14,
|
||||
},
|
||||
'audio-pause': {
|
||||
body: `<path d="M10.5001 1.75H8.75008C8.42792 1.75 8.16675 2.01117 8.16675 2.33333V11.6667C8.16675 11.9888 8.42792 12.25 8.75008 12.25H10.5001C10.8222 12.25 11.0834 11.9888 11.0834 11.6667V2.33333C11.0834 2.01117 10.8222 1.75 10.5001 1.75Z" fill="currentColor"/><path d="M5.25008 1.75H3.50008C3.17792 1.75 2.91675 2.01117 2.91675 2.33333V11.6667C2.91675 11.9888 3.17792 12.25 3.50008 12.25H5.25008C5.57225 12.25 5.83342 11.9888 5.83342 11.6667V2.33333C5.83342 2.01117 5.57225 1.75 5.25008 1.75Z" fill="currentColor"/>`,
|
||||
width: 14,
|
||||
height: 14,
|
||||
},
|
||||
/** Ends */
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user