chore: remove old Captain label suggestions (#16056)
# Pull Request Template ## Description This PR removes the old Captain label suggestion card above the reply box. Label suggestions now come from the Captain suggestions in the conversation sidebar. This also removes the Label Suggestion toggle in Captain settings and the "Show label suggestions" option on the OpenAI integration. A migration clears old saved values so existing accounts keep saving. ## Type of change - [x] Breaking change (fix or feature that would cause existing functionality not to work as expected) ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
This commit is contained in:
@@ -31,15 +31,6 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr
|
||||
render_result(result)
|
||||
end
|
||||
|
||||
def label_suggestion
|
||||
result = Captain::LabelSuggestionService.new(
|
||||
account: Current.account,
|
||||
conversation_display_id: params[:conversation_display_id]
|
||||
).perform
|
||||
|
||||
render_result(result)
|
||||
end
|
||||
|
||||
def follow_up
|
||||
result = Captain::FollowUpService.new(
|
||||
account: Current.account,
|
||||
|
||||
@@ -66,22 +66,6 @@ class TasksAPI extends ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets label suggestions for a conversation.
|
||||
* @param {string} conversationId - The conversation ID.
|
||||
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
|
||||
* @returns {Promise} A promise that resolves with label suggestions.
|
||||
*/
|
||||
labelSuggestion(conversationId, signal) {
|
||||
return axios.post(
|
||||
`${this.url}/label_suggestion`,
|
||||
{
|
||||
conversation_display_id: conversationId,
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a follow-up message to continue refining a previous task result.
|
||||
* @param {Object} options - The follow-up options.
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from '@vueuse/core';
|
||||
// composable
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useLabelSuggestions } from 'dashboard/composables/useLabelSuggestions';
|
||||
import { useCampaignHistory } from 'dashboard/composables/useCampaignHistory';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
@@ -25,7 +24,6 @@ import { CONTACT_CONVERSATION_NAVIGATION } from 'dashboard/composables/useContac
|
||||
// components
|
||||
import ReplyBox from './ReplyBox.vue';
|
||||
import MessageList from 'next/message/MessageList.vue';
|
||||
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
|
||||
import ContactConversationLink from './ContactConversationLink.vue';
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
@@ -49,7 +47,6 @@ import {
|
||||
getUnreadScrollTop,
|
||||
} from './helpers/campaignScrollAnchor';
|
||||
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import {
|
||||
filterDuplicateSourceMessages,
|
||||
getReadMessages,
|
||||
@@ -60,10 +57,7 @@ import {
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
|
||||
import { REPLY_POLICY } from 'shared/constants/links';
|
||||
import wootConstants, {
|
||||
META_RESTRICTION_STATUS_URL,
|
||||
} from 'dashboard/constants/globals';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
|
||||
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
@@ -84,7 +78,6 @@ export default {
|
||||
MessageList,
|
||||
ReplyBox,
|
||||
Banner,
|
||||
ConversationLabelSuggestion,
|
||||
ContactConversationLink,
|
||||
Spinner,
|
||||
NextButton,
|
||||
@@ -102,12 +95,6 @@ export default {
|
||||
const { height: containerHeight } = useElementSize(messagesViewRef);
|
||||
const { height: topBannerHeight } = useElementSize(topBannerRef);
|
||||
|
||||
const {
|
||||
captainTasksEnabled,
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
getLabelSuggestions,
|
||||
} = useLabelSuggestions();
|
||||
|
||||
const {
|
||||
olderConversation,
|
||||
newerConversation,
|
||||
@@ -179,9 +166,6 @@ export default {
|
||||
|
||||
return {
|
||||
...useCampaignHistory(),
|
||||
captainTasksEnabled,
|
||||
getLabelSuggestions,
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
olderConversation,
|
||||
newerConversation,
|
||||
openConversation,
|
||||
@@ -211,8 +195,6 @@ export default {
|
||||
conversationPanel: null,
|
||||
hasUserScrolled: false,
|
||||
isProgrammaticScroll: false,
|
||||
messageSentSinceOpened: false,
|
||||
labelSuggestions: [],
|
||||
};
|
||||
},
|
||||
|
||||
@@ -221,20 +203,8 @@ export default {
|
||||
currentChat: 'getSelectedChat',
|
||||
currentUserId: 'getCurrentUserID',
|
||||
listLoadingStatus: 'getAllMessagesLoaded',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
isMetaMessageSendingDisabled: 'globalConfig/isMetaMessageSendingDisabled',
|
||||
}),
|
||||
isOpen() {
|
||||
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
|
||||
},
|
||||
shouldShowLabelSuggestions() {
|
||||
return (
|
||||
this.isOpen &&
|
||||
this.captainTasksEnabled &&
|
||||
this.isLabelSuggestionFeatureEnabled &&
|
||||
!this.messageSentSinceOpened
|
||||
);
|
||||
},
|
||||
inboxId() {
|
||||
return this.currentChat.inbox_id;
|
||||
},
|
||||
@@ -414,8 +384,6 @@ export default {
|
||||
return;
|
||||
}
|
||||
this.fetchAllAttachmentsFromCurrentChat();
|
||||
this.fetchSuggestions();
|
||||
this.messageSentSinceOpened = false;
|
||||
this.resetReplyEditorHeight();
|
||||
},
|
||||
// The link is appended once the neighbours arrive, below an already scrolled list.
|
||||
@@ -431,11 +399,6 @@ export default {
|
||||
|
||||
created() {
|
||||
emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
|
||||
// when a message is sent we set the flag to true this hides the label suggestions,
|
||||
// until the chat is changed and the flag is reset in the watch for currentChat
|
||||
emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
|
||||
this.messageSentSinceOpened = true;
|
||||
});
|
||||
// Anything that wants to write must bring the folded editor back first.
|
||||
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.showReplyBox);
|
||||
emitter.on(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, this.showReplyBox);
|
||||
@@ -445,7 +408,6 @@ export default {
|
||||
mounted() {
|
||||
this.addScrollListener();
|
||||
this.fetchAllAttachmentsFromCurrentChat();
|
||||
this.fetchSuggestions();
|
||||
},
|
||||
|
||||
unmounted() {
|
||||
@@ -454,47 +416,6 @@ export default {
|
||||
},
|
||||
|
||||
methods: {
|
||||
async fetchSuggestions() {
|
||||
// start empty, this ensures that the label suggestions are not shown
|
||||
this.labelSuggestions = [];
|
||||
|
||||
if (this.isLabelSuggestionDismissed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Early exit if conversation already has labels - no need to suggest more
|
||||
const existingLabels = this.currentChat?.labels || [];
|
||||
if (existingLabels.length > 0) return;
|
||||
|
||||
if (!this.captainTasksEnabled || !this.isLabelSuggestionFeatureEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.labelSuggestions = await this.getLabelSuggestions();
|
||||
|
||||
// once the labels are fetched, we need to scroll to bottom
|
||||
// but we need to wait for the DOM to be updated
|
||||
// so we use the nextTick method
|
||||
this.$nextTick(() => {
|
||||
// this param is added to route, telling the UI to navigate to the message
|
||||
// it is triggered by the SCROLL_TO_MESSAGE method
|
||||
// see setActiveChat on ConversationView.vue for more info
|
||||
const { messageId } = this.$route.query;
|
||||
|
||||
// only trigger the scroll to bottom if the user has not scrolled
|
||||
// and there's no active messageId that is selected in view
|
||||
if (!messageId && !this.hasUserScrolled) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
});
|
||||
},
|
||||
isLabelSuggestionDismissed() {
|
||||
return LocalStorage.getFlag(
|
||||
LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS,
|
||||
this.currentAccountId,
|
||||
this.currentChat.id
|
||||
);
|
||||
},
|
||||
fetchAllAttachmentsFromCurrentChat() {
|
||||
this.$store.dispatch('fetchAllAttachments', this.currentChat.id);
|
||||
},
|
||||
@@ -529,12 +450,6 @@ export default {
|
||||
},
|
||||
scrollToBottom() {
|
||||
this.isProgrammaticScroll = true;
|
||||
let relevantMessages = [];
|
||||
|
||||
// label suggestions are not part of the messages list
|
||||
// so we need to handle them separately
|
||||
let labelSuggestions =
|
||||
this.conversationPanel.querySelector('.label-suggestion');
|
||||
|
||||
// if there are unread messages, scroll to the first unread message
|
||||
if (this.unreadMessageCount > 0) {
|
||||
@@ -547,21 +462,12 @@ export default {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (labelSuggestions) {
|
||||
// when scrolling to the bottom, the label suggestions is below the last message
|
||||
// so we scroll there if there are no unread messages
|
||||
// Unread messages always take the highest priority
|
||||
relevantMessages = [labelSuggestions];
|
||||
} else {
|
||||
// if there are no unread messages or label suggestion, scroll to the last message
|
||||
// capturing last message from the messages list
|
||||
relevantMessages = getTimelineEntries(this.conversationPanel).slice(-1);
|
||||
}
|
||||
|
||||
// otherwise scroll to the last message in the timeline
|
||||
this.conversationPanel.scrollTop = calculateScrollTop(
|
||||
this.conversationPanel.scrollHeight,
|
||||
this.$el.scrollHeight,
|
||||
relevantMessages
|
||||
getTimelineEntries(this.conversationPanel).slice(-1)
|
||||
);
|
||||
},
|
||||
setScrollParams() {
|
||||
@@ -715,12 +621,6 @@ export default {
|
||||
</li>
|
||||
</template>
|
||||
<template #after>
|
||||
<ConversationLabelSuggestion
|
||||
v-if="shouldShowLabelSuggestions"
|
||||
:suggested-labels="labelSuggestions"
|
||||
:chat-labels="currentChat.labels"
|
||||
:conversation-id="currentChat.id"
|
||||
/>
|
||||
<ContactConversationLink
|
||||
v-if="newerConversation"
|
||||
direction="newer"
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
<script>
|
||||
// components
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
|
||||
// composables
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
|
||||
// store & api
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
// utils & constants
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { CAPTAIN_EVENTS } from '../../../../helper/AnalyticsHelper/events';
|
||||
|
||||
export default {
|
||||
name: 'LabelSuggestion',
|
||||
components: {
|
||||
Avatar,
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
suggestedLabels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
chatLabels: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { captainTasksEnabled } = useCaptain();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
return { captainTasksEnabled, replaceInstallationName };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isDismissed: false,
|
||||
isHovered: false,
|
||||
selectedLabels: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
allLabels: 'labels/getLabels',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
currentChat: 'getSelectedChat',
|
||||
}),
|
||||
conversationId() {
|
||||
return this.currentChat?.id;
|
||||
},
|
||||
labelTooltip() {
|
||||
if (this.preparedLabels.length > 1) {
|
||||
return this.$t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.MULTIPLE_SUGGESTION');
|
||||
}
|
||||
|
||||
return this.$t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.SINGLE_SUGGESTION');
|
||||
},
|
||||
addButtonText() {
|
||||
if (this.selectedLabels.length === 1) {
|
||||
return this.$t('LABEL_MGMT.SUGGESTIONS.ADD_SELECTED_LABEL');
|
||||
}
|
||||
|
||||
if (this.selectedLabels.length > 1) {
|
||||
return this.$t('LABEL_MGMT.SUGGESTIONS.ADD_SELECTED_LABELS');
|
||||
}
|
||||
|
||||
return this.$t('LABEL_MGMT.SUGGESTIONS.ADD_ALL_LABELS');
|
||||
},
|
||||
preparedLabels() {
|
||||
return this.allLabels.filter(label =>
|
||||
this.suggestedLabels.includes(label.title)
|
||||
);
|
||||
},
|
||||
shouldShowSuggestions() {
|
||||
if (this.isDismissed) return false;
|
||||
if (!this.captainTasksEnabled) return false;
|
||||
|
||||
return this.preparedLabels.length && this.chatLabels.length === 0;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
conversationId: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
this.selectedLabels = [];
|
||||
this.isDismissed = this.isConversationDismissed();
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
pushOrAddLabel(label) {
|
||||
if (this.preparedLabels.length === 1) {
|
||||
this.addAllLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.selectedLabels.includes(label)) {
|
||||
this.selectedLabels.push(label);
|
||||
} else {
|
||||
this.selectedLabels = this.selectedLabels.filter(l => l !== label);
|
||||
}
|
||||
},
|
||||
dismissSuggestions() {
|
||||
LocalStorage.setFlag(
|
||||
LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS,
|
||||
this.currentAccountId,
|
||||
this.conversationId
|
||||
);
|
||||
|
||||
// dismiss this once the values are set
|
||||
this.isDismissed = true;
|
||||
this.trackLabelEvent(CAPTAIN_EVENTS.LABEL_SUGGESTION_DISMISSED);
|
||||
},
|
||||
isConversationDismissed() {
|
||||
return LocalStorage.getFlag(
|
||||
LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS,
|
||||
this.currentAccountId,
|
||||
this.conversationId
|
||||
);
|
||||
},
|
||||
addAllLabels() {
|
||||
let labelsToAdd = this.selectedLabels;
|
||||
if (!labelsToAdd.length) {
|
||||
labelsToAdd = this.preparedLabels.map(label => label.title);
|
||||
}
|
||||
this.$store.dispatch('conversationLabels/update', {
|
||||
conversationId: this.conversationId,
|
||||
labels: labelsToAdd,
|
||||
});
|
||||
this.trackLabelEvent(CAPTAIN_EVENTS.LABEL_SUGGESTION_APPLIED);
|
||||
},
|
||||
trackLabelEvent(event) {
|
||||
const payload = {
|
||||
conversationId: this.conversationId,
|
||||
account: this.currentAccountId,
|
||||
suggestions: this.suggestedLabels,
|
||||
labelsApplied: this.selectedLabels.length
|
||||
? this.selectedLabels
|
||||
: this.suggestedLabels,
|
||||
};
|
||||
|
||||
useTrack(event, payload);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<li
|
||||
v-if="shouldShowSuggestions"
|
||||
class="label-suggestion right list-none"
|
||||
@mouseover="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
>
|
||||
<div class="wrap">
|
||||
<div class="label-suggestion--container">
|
||||
<h6 class="label-suggestion--title">
|
||||
{{ $t('LABEL_MGMT.SUGGESTIONS.SUGGESTED_LABELS') }}
|
||||
</h6>
|
||||
<div class="label-suggestion--options">
|
||||
<button
|
||||
v-for="label in preparedLabels"
|
||||
:key="label.title"
|
||||
v-tooltip.top="{
|
||||
content: selectedLabels.includes(label.title)
|
||||
? $t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.DESELECT')
|
||||
: labelTooltip,
|
||||
delay: { show: 600, hide: 0 },
|
||||
hideOnClick: true,
|
||||
}"
|
||||
class="label-suggestion--option !px-0"
|
||||
@click="pushOrAddLabel(label.title)"
|
||||
>
|
||||
<woot-label
|
||||
variant="dashed"
|
||||
v-bind="label"
|
||||
:bg-color="selectedLabels.includes(label.title) ? '#2781F6' : ''"
|
||||
/>
|
||||
</button>
|
||||
<NextButton
|
||||
v-if="preparedLabels.length === 1"
|
||||
v-tooltip.top="{
|
||||
content: $t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.DISMISS'),
|
||||
delay: { show: 600, hide: 0 },
|
||||
hideOnClick: true,
|
||||
}"
|
||||
faded
|
||||
xs
|
||||
icon="i-lucide-x"
|
||||
class="flex-shrink-0"
|
||||
:color="isHovered ? 'ruby' : 'blue'"
|
||||
@click="dismissSuggestions"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="preparedLabels.length > 1"
|
||||
class="inline-flex items-center gap-1"
|
||||
>
|
||||
<NextButton
|
||||
xs
|
||||
icon="i-lucide-plus"
|
||||
class="flex-shrink-0"
|
||||
:variant="selectedLabels.length === 0 ? 'faded' : 'solid'"
|
||||
:label="addButtonText"
|
||||
@click="addAllLabels"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip.top="{
|
||||
content: $t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.DISMISS'),
|
||||
delay: { show: 600, hide: 0 },
|
||||
hideOnClick: true,
|
||||
}"
|
||||
faded
|
||||
xs
|
||||
icon="i-lucide-x"
|
||||
class="flex-shrink-0"
|
||||
:color="isHovered ? 'ruby' : 'blue'"
|
||||
@click="dismissSuggestions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sender--info has-tooltip" data-original-title="null">
|
||||
<Avatar
|
||||
v-tooltip.top="{
|
||||
content: replaceInstallationName(
|
||||
$t('LABEL_MGMT.SUGGESTIONS.POWERED_BY')
|
||||
),
|
||||
delay: { show: 600, hide: 0 },
|
||||
hideOnClick: true,
|
||||
}"
|
||||
:size="16"
|
||||
name="chatwoot-ai"
|
||||
icon-name="i-lucide-sparkles"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wrap {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.label-suggestion {
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1rem;
|
||||
|
||||
.label-suggestion--container {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.label-suggestion--options {
|
||||
@apply gap-0.5 text-end flex items-center;
|
||||
|
||||
button.label-suggestion--option {
|
||||
.label {
|
||||
cursor: pointer;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label-suggestion--title {
|
||||
@apply text-n-slate-11 mt-0.5 text-xxs;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,80 +0,0 @@
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import TasksAPI from 'dashboard/api/captain/tasks';
|
||||
|
||||
/**
|
||||
* Cleans and normalizes a list of labels.
|
||||
* @param {string} labels - A comma-separated string of labels.
|
||||
* @returns {string[]} An array of cleaned and unique labels.
|
||||
*/
|
||||
const cleanLabels = labels => {
|
||||
return labels
|
||||
.toLowerCase()
|
||||
.split(',')
|
||||
.filter(label => label.trim())
|
||||
.map(label => label.trim())
|
||||
.filter((label, index, self) => self.indexOf(label) === index);
|
||||
};
|
||||
|
||||
export function useLabelSuggestions() {
|
||||
const store = useStore();
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
const appIntegrations = useMapGetter('integrations/getAppIntegrations');
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const conversationId = computed(() => currentChat.value?.id);
|
||||
|
||||
const captainTasksEnabled = computed(() => {
|
||||
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS);
|
||||
});
|
||||
|
||||
const aiIntegration = computed(
|
||||
() =>
|
||||
appIntegrations.value.find(
|
||||
integration => integration.id === 'openai' && !!integration.hooks.length
|
||||
)?.hooks[0]
|
||||
);
|
||||
|
||||
const isLabelSuggestionFeatureEnabled = computed(() => {
|
||||
if (aiIntegration.value) {
|
||||
const { settings = {} } = aiIntegration.value || {};
|
||||
return !!settings.label_suggestion;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const fetchIntegrationsIfRequired = async () => {
|
||||
if (!appIntegrations.value.length) {
|
||||
await store.dispatch('integrations/get');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets label suggestions for the current conversation.
|
||||
* @returns {Promise<string[]>} An array of suggested labels.
|
||||
*/
|
||||
const getLabelSuggestions = async () => {
|
||||
if (!conversationId.value) return [];
|
||||
|
||||
try {
|
||||
const result = await TasksAPI.labelSuggestion(conversationId.value);
|
||||
const {
|
||||
data: { message: labels },
|
||||
} = result;
|
||||
return cleanLabels(labels);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchIntegrationsIfRequired();
|
||||
});
|
||||
|
||||
return {
|
||||
captainTasksEnabled,
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
getLabelSuggestions,
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,6 @@ export const LOCAL_STORAGE_KEYS = {
|
||||
WIDGET_BUILDER: 'widgetBubble_',
|
||||
DRAFT_MESSAGES: 'draftMessages',
|
||||
COLOR_SCHEME: 'color_scheme',
|
||||
DISMISSED_LABEL_SUGGESTIONS: 'labelSuggestionsDismissed',
|
||||
MESSAGE_REPLY_TO: 'messageReplyTo',
|
||||
RECENT_SEARCHES: 'recentSearches',
|
||||
SIDEBAR_MINIMIZED_SECTIONS: 'sidebarMinimizedSections',
|
||||
|
||||
@@ -110,10 +110,6 @@ export const CAPTAIN_EVENTS = Object.freeze({
|
||||
// Follow-up events
|
||||
FOLLOW_UP_SENT: 'Captain: Follow-up sent',
|
||||
|
||||
// Label suggestions
|
||||
LABEL_SUGGESTION_APPLIED: 'Captain: Label suggestion applied',
|
||||
LABEL_SUGGESTION_DISMISSED: 'Captain: Label suggestion dismissed',
|
||||
|
||||
// Conversation sidebar suggestions
|
||||
LABEL_SUGGESTIONS_REQUESTED: 'Captain: Label suggestions requested',
|
||||
PRIORITY_SUGGESTION_REQUESTED: 'Captain: Priority suggestion requested',
|
||||
|
||||
@@ -43,20 +43,6 @@
|
||||
"DELETE": "Delete",
|
||||
"CANCEL": "Cancel"
|
||||
},
|
||||
"SUGGESTIONS": {
|
||||
"TOOLTIP": {
|
||||
"SINGLE_SUGGESTION": "Add label to conversation",
|
||||
"MULTIPLE_SUGGESTION": "Select this label",
|
||||
"DESELECT": "Deselect label",
|
||||
"DISMISS": "Dismiss suggestion"
|
||||
},
|
||||
"POWERED_BY": "Chatwoot AI",
|
||||
"DISMISS": "Dismiss",
|
||||
"ADD_SELECTED_LABELS": "Add selected labels",
|
||||
"ADD_SELECTED_LABEL": "Add selected label",
|
||||
"ADD_ALL_LABELS": "Add all labels",
|
||||
"SUGGESTED_LABELS": "Suggested labels"
|
||||
},
|
||||
"ADD": {
|
||||
"TITLE": "Add label",
|
||||
"DESC": "Labels let you group the conversations together.",
|
||||
|
||||
@@ -569,12 +569,6 @@
|
||||
"HELP_CENTER_SEARCH": {
|
||||
"TITLE": "Help Center Search Indexing",
|
||||
"DESCRIPTION": "Use AI for context aware search inside your help center articles."
|
||||
},
|
||||
"LABEL_SUGGESTION": {
|
||||
"TITLE": "Label Suggestion",
|
||||
"DESCRIPTION": "Automatically suggest relevant labels and tags for conversations based on content analysis and context.",
|
||||
"MODEL_TITLE": "Label Suggestion Model",
|
||||
"MODEL_DESCRIPTION": "Select the AI model to use for analyzing conversations and suggesting appropriate labels"
|
||||
}
|
||||
},
|
||||
"API": {
|
||||
|
||||
@@ -46,9 +46,6 @@ const modelFeatures = computed(() => [
|
||||
]);
|
||||
|
||||
const featureToggles = computed(() => [
|
||||
{
|
||||
key: 'label_suggestion',
|
||||
},
|
||||
{
|
||||
key: 'help_center_search',
|
||||
enterprise: true,
|
||||
|
||||
@@ -46,9 +46,6 @@ const title = computed(() => {
|
||||
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.TITLE');
|
||||
}
|
||||
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.TITLE');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
@@ -59,9 +56,6 @@ const description = computed(() => {
|
||||
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.DESCRIPTION');
|
||||
}
|
||||
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.DESCRIPTION');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
@@ -72,9 +66,6 @@ const modelTitle = computed(() => {
|
||||
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.MODEL_TITLE');
|
||||
}
|
||||
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.MODEL_TITLE');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
@@ -85,9 +76,6 @@ const modelDescription = computed(() => {
|
||||
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.MODEL_DESCRIPTION');
|
||||
}
|
||||
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.MODEL_DESCRIPTION');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
|
||||
@@ -11,10 +11,6 @@ class Captain::TasksPolicy < ApplicationPolicy
|
||||
true
|
||||
end
|
||||
|
||||
def label_suggestion?
|
||||
true
|
||||
end
|
||||
|
||||
def follow_up?
|
||||
true
|
||||
end
|
||||
|
||||
@@ -36,7 +36,6 @@ openai:
|
||||
'properties':
|
||||
{
|
||||
'api_key': { 'type': 'string' },
|
||||
'label_suggestion': { 'type': 'boolean' },
|
||||
},
|
||||
'required': ['api_key'],
|
||||
'additionalProperties': false,
|
||||
@@ -49,14 +48,7 @@ openai:
|
||||
'name': 'api_key',
|
||||
'validation': 'required',
|
||||
},
|
||||
{
|
||||
'label': 'Show label suggestions',
|
||||
'type': 'checkbox',
|
||||
'name': 'label_suggestion',
|
||||
'validation': '',
|
||||
},
|
||||
]
|
||||
visible_properties: ['label_suggestion']
|
||||
stripe:
|
||||
id: stripe
|
||||
logo: stripe.svg
|
||||
|
||||
@@ -122,10 +122,6 @@ features:
|
||||
gemini-3-pro,
|
||||
]
|
||||
default: gpt-4.1
|
||||
label_suggestion:
|
||||
models:
|
||||
[gpt-4.1-nano, gpt-4.1-mini, gpt-5-mini, gemini-3-flash, claude-haiku-4.5]
|
||||
default: gpt-4.1-mini
|
||||
document_faq_generation:
|
||||
models:
|
||||
[
|
||||
|
||||
@@ -461,7 +461,7 @@ en:
|
||||
openai:
|
||||
name: 'OpenAI'
|
||||
short_description: 'AI-powered reply suggestions, summarization, and message enhancement.'
|
||||
description: 'Leverage the power of large language models from OpenAI with the features such as reply suggestions, summarization, message rephrasing, spell-checking, and label classification.'
|
||||
description: 'Leverage the power of large language models from OpenAI with the features such as reply suggestions, summarization, message rephrasing, and spell-checking.'
|
||||
linear:
|
||||
name: 'Linear'
|
||||
short_description: 'Create and link Linear issues directly from conversations.'
|
||||
@@ -755,7 +755,6 @@ en:
|
||||
editor: 'Editor'
|
||||
assistant: 'Assistant'
|
||||
copilot: 'Copilot'
|
||||
label_suggestion: 'Label suggestion'
|
||||
document_faq_generation: 'Document FAQ generation'
|
||||
conversation_faq_generation: 'Conversation FAQ generation'
|
||||
conversation_faq_matching: 'Conversation FAQ matching'
|
||||
|
||||
@@ -132,7 +132,6 @@ Rails.application.routes.draw do
|
||||
post :rewrite
|
||||
post :summarize
|
||||
post :reply_suggestion
|
||||
post :label_suggestion
|
||||
post :follow_up
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
class RemoveLabelSuggestionSettings < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
Account.where("settings -> 'captain_models' ? 'label_suggestion' OR settings -> 'captain_features' ? 'label_suggestion'")
|
||||
.update_all("settings = settings #- '{captain_models,label_suggestion}' #- '{captain_features,label_suggestion}'")
|
||||
Integrations::Hook.where(app_id: 'openai')
|
||||
.where("settings ? 'label_suggestion'")
|
||||
.update_all("settings = settings - 'label_suggestion'")
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
end
|
||||
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_09_29_100000) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_10_06_233107) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
|
||||
@@ -11,7 +11,6 @@ class Captain::FollowUpService < Captain::BaseTaskService
|
||||
improve
|
||||
summarize
|
||||
reply_suggestion
|
||||
label_suggestion
|
||||
].freeze
|
||||
|
||||
def perform
|
||||
@@ -64,8 +63,6 @@ class Captain::FollowUpService < Captain::BaseTaskService
|
||||
'conversation summary'
|
||||
when 'reply_suggestion'
|
||||
'reply suggestion'
|
||||
when 'label_suggestion'
|
||||
'label suggestion'
|
||||
else
|
||||
event_name
|
||||
end
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
class Captain::LabelSuggestionService < Captain::BaseTaskService
|
||||
pattr_initialize [:account!, :conversation_display_id!]
|
||||
|
||||
def perform
|
||||
# Check cache first
|
||||
cached_response = read_from_cache
|
||||
return cached_response if cached_response.present?
|
||||
|
||||
# Build content
|
||||
content = labels_with_messages
|
||||
return nil if content.blank?
|
||||
|
||||
# Make API call
|
||||
response = make_api_call(
|
||||
feature: 'label_suggestion',
|
||||
messages: [
|
||||
{ role: 'system', content: prompt_from_file('label_suggestion') },
|
||||
{ role: 'user', content: content }
|
||||
]
|
||||
)
|
||||
return response if response[:error].present?
|
||||
|
||||
# Clean up response
|
||||
result = { message: response[:message] ? response[:message].gsub(/^(label|labels):/i, '') : '' }
|
||||
|
||||
# Cache successful result
|
||||
write_to_cache(result)
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def cache_key
|
||||
return nil unless conversation
|
||||
|
||||
format(
|
||||
::Redis::Alfred::OPENAI_CONVERSATION_KEY,
|
||||
event_name: 'label_suggestion',
|
||||
conversation_id: conversation.id,
|
||||
updated_at: conversation.last_activity_at.to_i
|
||||
)
|
||||
end
|
||||
|
||||
def read_from_cache
|
||||
return nil unless cache_key
|
||||
|
||||
cached = Redis::Alfred.get(cache_key)
|
||||
JSON.parse(cached, symbolize_names: true) if cached.present?
|
||||
rescue JSON::ParserError
|
||||
nil
|
||||
end
|
||||
|
||||
def write_to_cache(response)
|
||||
Redis::Alfred.setex(cache_key, response.to_json) if cache_key
|
||||
end
|
||||
|
||||
def labels_with_messages
|
||||
return nil unless valid_conversation?(conversation)
|
||||
|
||||
labels = account.labels.pluck(:title).join(', ')
|
||||
messages = format_messages_as_string(start_from: labels.length)
|
||||
|
||||
return nil if messages.blank? || labels.blank?
|
||||
|
||||
"Messages:\n#{messages}\nLabels:\n#{labels}"
|
||||
end
|
||||
|
||||
def format_messages_as_string(start_from: 0)
|
||||
messages = conversation_messages(start_from: start_from)
|
||||
messages.map do |msg|
|
||||
sender_type = msg[:role] == 'user' ? 'Customer' : 'Agent'
|
||||
"#{sender_type}: #{msg[:content]}\n"
|
||||
end.join
|
||||
end
|
||||
|
||||
def valid_conversation?(conversation)
|
||||
return false if conversation.nil?
|
||||
return false if conversation.messages.incoming.count < 3
|
||||
return false if conversation.messages.count > 100
|
||||
return false if conversation.messages.count > 20 && !conversation.messages.last.incoming?
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def event_name
|
||||
'label_suggestion'
|
||||
end
|
||||
|
||||
def use_account_openai_hook?
|
||||
true
|
||||
end
|
||||
|
||||
def build_follow_up_context?
|
||||
false
|
||||
end
|
||||
end
|
||||
@@ -1 +0,0 @@
|
||||
Your role is as an assistant to a customer support agent. You will be provided with a transcript of a conversation between a customer and the support agent, along with a list of potential labels. Your task is to analyze the conversation and select the two labels from the given list that most accurately represent the themes or issues discussed. Ensure you preserve the exact casing of the labels as they are provided in the list. Do not create new labels; only choose from those provided. Once you have made your selections, please provide your response as a comma-separated list of the provided labels. Remember, your response should only contain the labels you've selected,in their original casing, and nothing else.
|
||||
@@ -61,9 +61,9 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
|
||||
provider: 'openai',
|
||||
source: 'account_override'
|
||||
)
|
||||
expect(json_response.dig(:features, :label_suggestion)).to include(
|
||||
model: Llm::Models.default_model_for('label_suggestion'),
|
||||
selected: Llm::Models.default_model_for('label_suggestion'),
|
||||
expect(json_response.dig(:features, :help_center_query_translation)).to include(
|
||||
model: Llm::Models.default_model_for('help_center_query_translation'),
|
||||
selected: Llm::Models.default_model_for('help_center_query_translation'),
|
||||
provider: 'openai',
|
||||
source: 'default'
|
||||
)
|
||||
@@ -171,11 +171,11 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
|
||||
it 'rejects invalid captain model values for the feature' do
|
||||
put "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { captain_models: { label_suggestion: 'gpt-5.1' } },
|
||||
params: { captain_models: { help_center_query_translation: 'gpt-5.1' } },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json_response[:message]).to include('not a valid model for label_suggestion')
|
||||
expect(json_response[:message]).to include('not a valid model for help_center_query_translation')
|
||||
expect(account.reload.captain_models).to be_nil
|
||||
end
|
||||
|
||||
|
||||
@@ -102,14 +102,14 @@ RSpec.describe 'Integration Apps API', type: :request do
|
||||
:integrations_hook,
|
||||
:openai,
|
||||
account: account,
|
||||
settings: { api_key: 'sk-secret', label_suggestion: true }
|
||||
settings: { api_key: 'sk-secret' }
|
||||
)
|
||||
get api_v1_account_integrations_apps_url(account),
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
app = response.parsed_body['payload'].find { |int_app| int_app['id'] == openai.app.id }
|
||||
expect(app['hooks'].first['settings']).to eq('label_suggestion' => true)
|
||||
expect(app['hooks'].first['settings']).to eq({})
|
||||
end
|
||||
|
||||
it 'keeps slack channel display settings while redacting unspecified settings' do
|
||||
|
||||
@@ -43,7 +43,7 @@ RSpec.describe 'Super Admin accounts API', type: :request do
|
||||
expect(summaries).not_to include('All features', 'Captain models')
|
||||
expect(routing_panel.text.squish).to include('Customer features', 'Internal features')
|
||||
expect(completion_card.text.squish).to include('Inactive conversation completion evaluator', 'GPT-5.2', 'Account override')
|
||||
expect(response.body).to include('Editor', 'OpenAI', 'openai', 'gpt-4.1', 'Label suggestion', 'Default')
|
||||
expect(response.body).to include('Editor', 'OpenAI', 'openai', 'gpt-4.1', 'Help center query translation', 'Default')
|
||||
end
|
||||
|
||||
it 'shows the installation model for internal routing on self-hosted Enterprise', if: ChatwootApp.enterprise? do
|
||||
@@ -155,13 +155,13 @@ RSpec.describe 'Super Admin accounts API', type: :request do
|
||||
locale: account.locale,
|
||||
status: account.status,
|
||||
captain_models: {
|
||||
label_suggestion: 'gpt-5.1'
|
||||
help_center_query_translation: 'gpt-5.1'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.body).to include('not a valid model for label_suggestion')
|
||||
expect(response.body).to include('not a valid model for help_center_query_translation')
|
||||
expect(account.reload.captain_models).to eq(existing_captain_models)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -352,7 +352,6 @@ RSpec.describe Captain::BaseTaskService do
|
||||
Captain::RewriteService.new(account: account, content: 'Text', operation: 'improve', conversation_display_id: conversation.display_id),
|
||||
Captain::SummaryService.new(account: account, conversation_display_id: conversation.display_id),
|
||||
Captain::ReplySuggestionService.new(account: account, conversation_display_id: conversation.display_id, user: user),
|
||||
Captain::LabelSuggestionService.new(account: account, conversation_display_id: conversation.display_id),
|
||||
Captain::FollowUpService.new(
|
||||
account: account,
|
||||
follow_up_context: follow_up_context,
|
||||
|
||||
@@ -126,13 +126,6 @@ RSpec.describe Captain::FollowUpService do
|
||||
expect(prompt).to include('reply suggestion')
|
||||
end
|
||||
|
||||
it 'describes label_suggestion action' do
|
||||
session = { 'event_name' => 'label_suggestion' }
|
||||
prompt = service.send(:build_follow_up_system_prompt, session)
|
||||
|
||||
expect(prompt).to include('label suggestion')
|
||||
end
|
||||
|
||||
it 'uses event_name directly for unknown actions' do
|
||||
session = { 'event_name' => 'custom_action' }
|
||||
prompt = service.send(:build_follow_up_system_prompt, session)
|
||||
@@ -155,7 +148,6 @@ RSpec.describe Captain::FollowUpService do
|
||||
expect(service.send(:describe_previous_action, 'improve')).to eq('message improvement')
|
||||
expect(service.send(:describe_previous_action, 'summarize')).to eq('conversation summary')
|
||||
expect(service.send(:describe_previous_action, 'reply_suggestion')).to eq('reply suggestion')
|
||||
expect(service.send(:describe_previous_action, 'label_suggestion')).to eq('label suggestion')
|
||||
end
|
||||
|
||||
it 'returns event name for unknown operations' do
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::LabelSuggestionService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:label1) { create(:label, account: account, title: 'bug') }
|
||||
let(:label2) { create(:label, account: account, title: 'feature-request') }
|
||||
let(:service) { described_class.new(account: account, conversation_display_id: conversation.display_id) }
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
let(:mock_context) { instance_double(RubyLLM::Context, chat: mock_chat) }
|
||||
let(:mock_response) { instance_double(RubyLLM::Message, content: 'bug, feature-request', input_tokens: 100, output_tokens: 20) }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
label1
|
||||
label2
|
||||
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:with_instructions)
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
# Stub captain enabled check to allow specs to test base functionality
|
||||
# without enterprise module interference
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
end
|
||||
|
||||
describe '#label_suggestion_message' do
|
||||
context 'with valid conversation' do
|
||||
before do
|
||||
# Create enough incoming messages to pass validation
|
||||
3.times do |i|
|
||||
create(:message, conversation: conversation, message_type: :incoming,
|
||||
content: "Message #{i}", created_at: i.minutes.ago)
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns label suggestions' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:message]).to eq('bug, feature-request')
|
||||
end
|
||||
|
||||
it 'removes "Labels:" prefix from response' do
|
||||
allow(mock_response).to receive(:content).and_return('Labels: bug, feature-request')
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result[:message]).to eq(' bug, feature-request')
|
||||
end
|
||||
|
||||
it 'removes "Label:" prefix (singular) from response' do
|
||||
allow(mock_response).to receive(:content).and_return('label: bug')
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result[:message]).to eq(' bug')
|
||||
end
|
||||
|
||||
it 'builds labels_with_messages format correctly' do
|
||||
expect(service).to receive(:make_api_call) do |args|
|
||||
expect(args[:feature]).to eq('label_suggestion')
|
||||
user_message = args[:messages].find { |m| m[:role] == 'user' }[:content]
|
||||
|
||||
expect(user_message).to include('Messages:')
|
||||
expect(user_message).to include('Labels:')
|
||||
expect(user_message).to include('bug, feature-request')
|
||||
{ message: 'bug' }
|
||||
end
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid conversation' do
|
||||
it 'returns nil when conversation has less than 3 incoming messages' do
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Message 1')
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Message 2')
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil when conversation has more than 100 messages' do
|
||||
101.times do |i|
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: "Message #{i}")
|
||||
end
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil when conversation has >20 messages and last is not incoming' do
|
||||
21.times do |i|
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: "Message #{i}")
|
||||
end
|
||||
create(:message, conversation: conversation, message_type: :outgoing, content: 'Agent reply')
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when caching' do
|
||||
before do
|
||||
3.times do |i|
|
||||
create(:message, conversation: conversation, message_type: :incoming,
|
||||
content: "Message #{i}", created_at: i.minutes.ago)
|
||||
end
|
||||
end
|
||||
|
||||
it 'reads from cache on cache hit' do
|
||||
# Warm up cache
|
||||
service.perform
|
||||
|
||||
# Create new service instance to test cache read
|
||||
new_service = described_class.new(account: account, conversation_display_id: conversation.display_id)
|
||||
|
||||
expect(new_service).not_to receive(:make_api_call)
|
||||
result = new_service.perform
|
||||
|
||||
expect(result[:message]).to eq('bug, feature-request')
|
||||
end
|
||||
|
||||
it 'writes to cache on cache miss' do
|
||||
expect(Redis::Alfred).to receive(:setex).and_call_original
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'returns nil for invalid cached JSON' do
|
||||
# Set invalid JSON in cache
|
||||
cache_key = service.send(:cache_key)
|
||||
Redis::Alfred.set(cache_key, 'invalid json')
|
||||
|
||||
result = service.perform
|
||||
|
||||
# Should make API call since cache read failed
|
||||
expect(result[:message]).to eq('bug, feature-request')
|
||||
end
|
||||
|
||||
it 'does not cache error responses' do
|
||||
error_response = { error: 'API Error', request_messages: [] }
|
||||
allow(service).to receive(:make_api_call).and_return(error_response)
|
||||
|
||||
expect(Redis::Alfred).not_to receive(:setex)
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no labels exist' do
|
||||
before do
|
||||
Label.destroy_all
|
||||
3.times do |i|
|
||||
create(:message, conversation: conversation, message_type: :incoming,
|
||||
content: "Message #{i}")
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
result = service.perform
|
||||
|
||||
expect(result).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -508,14 +508,14 @@ RSpec.describe Account do
|
||||
|
||||
describe 'validation' do
|
||||
it 'rejects invalid model for a feature' do
|
||||
account.captain_models = { 'label_suggestion' => 'gpt-5.1' }
|
||||
account.captain_models = { 'help_center_query_translation' => 'gpt-5.1' }
|
||||
|
||||
expect(account).not_to be_valid
|
||||
expect(account.errors[:captain_models].first).to include('not a valid model for label_suggestion')
|
||||
expect(account.errors[:captain_models].first).to include('not a valid model for help_center_query_translation')
|
||||
end
|
||||
|
||||
it 'accepts valid model for a feature' do
|
||||
account.captain_models = { 'editor' => 'gpt-4.1-mini', 'label_suggestion' => 'gpt-4.1-nano' }
|
||||
account.captain_models = { 'editor' => 'gpt-4.1-mini', 'help_center_query_translation' => 'gpt-4.1-nano' }
|
||||
|
||||
expect(account).to be_valid
|
||||
end
|
||||
|
||||
@@ -40,7 +40,7 @@ RSpec.describe CaptainFeaturable do
|
||||
|
||||
it 'returns false for disabled features' do
|
||||
expect(account.captain_copilot_enabled?).to be false
|
||||
expect(account.captain_label_suggestion_enabled?).to be false
|
||||
expect(account.captain_help_center_query_translation_enabled?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
@@ -63,14 +63,14 @@ RSpec.describe CaptainFeaturable do
|
||||
account.update!(captain_models: {
|
||||
'editor' => 'gpt-4.1-mini',
|
||||
'assistant' => 'gpt-5.1',
|
||||
'label_suggestion' => 'gpt-4.1-nano'
|
||||
'help_center_query_translation' => 'gpt-4.1-mini'
|
||||
})
|
||||
end
|
||||
|
||||
it 'returns configured models for configured features' do
|
||||
expect(account.captain_editor_model).to eq('gpt-4.1-mini')
|
||||
expect(account.captain_assistant_model).to eq('gpt-5.1')
|
||||
expect(account.captain_label_suggestion_model).to eq('gpt-4.1-nano')
|
||||
expect(account.captain_help_center_query_translation_model).to eq('gpt-4.1-mini')
|
||||
end
|
||||
|
||||
it 'returns default models for unconfigured features' do
|
||||
|
||||
@@ -178,10 +178,9 @@ RSpec.describe Integrations::Hook do
|
||||
|
||||
it 'skips validation when an enabled openai hook is saved without changing the api key' do
|
||||
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
|
||||
hook = create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'sk-good', 'label_suggestion' => false })
|
||||
hook = create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'sk-good' })
|
||||
|
||||
allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(false)
|
||||
hook.settings['label_suggestion'] = true
|
||||
|
||||
expect(hook.save).to be true
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user