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:
Sivin Varghese
2026-10-06 16:58:12 -07:00
committed by GitHub
parent ab0182a9eb
commit bc60ffc312
30 changed files with 33 additions and 843 deletions

View File

@@ -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,

View File

@@ -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.

View File

@@ -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"

View File

@@ -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>

View File

@@ -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,
};
}

View File

@@ -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',

View File

@@ -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',

View File

@@ -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.",

View File

@@ -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": {

View File

@@ -46,9 +46,6 @@ const modelFeatures = computed(() => [
]);
const featureToggles = computed(() => [
{
key: 'label_suggestion',
},
{
key: 'help_center_search',
enterprise: true,

View File

@@ -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 '';
});

View File

@@ -11,10 +11,6 @@ class Captain::TasksPolicy < ApplicationPolicy
true
end
def label_suggestion?
true
end
def follow_up?
true
end

View File

@@ -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

View File

@@ -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:
[

View File

@@ -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'

View File

@@ -132,7 +132,6 @@ Rails.application.routes.draw do
post :rewrite
post :summarize
post :reply_suggestion
post :label_suggestion
post :follow_up
end
end

View File

@@ -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

View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_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"

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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