feat(csat): Add WhatsApp utility template analyzer with rewrite guidance (#13575)

CSAT templates for WhatsApp are submitted as Utility, but Meta may
reclassify them as Marketing based on content, which can significantly
increase messaging costs.
This PR introduces a Captain-powered CSAT template analyzer for
WhatsApp/Twilio WhatsApp that predicts utility fit, explains likely
risks, and suggests safer rewrites before submission. The flow is manual
(button-triggered), Captain-gated, and applies rewrites only on explicit
user action. It also updates UX copy to clearly set expectations: the
system submits as Utility, Meta makes the final categorization decision.

Fixes
https://linear.app/chatwoot/issue/CW-6424/ai-powered-whatsapp-template-classifier-for-csat-submissions


https://github.com/user-attachments/assets/8fd1d6db-2f91-447c-9771-3de271b16fd9
This commit is contained in:
Muhsin Keloth
2026-02-24 15:11:04 +04:00
committed by GitHub
parent 2b85275e26
commit 6be95e79f8
16 changed files with 711 additions and 6 deletions

View File

@@ -42,6 +42,12 @@ class Inboxes extends CacheEnabledApiClient {
getCSATTemplateStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/csat_template`);
}
analyzeCSATTemplateUtility(inboxId, template) {
return axios.post(`${this.url}/${inboxId}/csat_template/analyze`, {
template,
});
}
}
export default new Inboxes();

View File

@@ -890,6 +890,20 @@
"CONFIRM": "Create new template",
"CANCEL": "Go back"
},
"UTILITY_ANALYZER": {
"ACTION": "Check utility fit",
"HELPER_NOTE": "Check this message before submission to improve Utility fit. The system creates a dedicated CSAT template with buttons for reporting and submits it as Utility; Meta may still reclassify it as Marketing based on content.",
"RESULT_LABEL": "Meta category prediction",
"GUIDANCE_NOTE": "This is a guidance check, not a guarantee of Meta approval.",
"SUGGESTION_LABEL": "Suggested utility-safe rewrite",
"APPLY": "Use this rewrite",
"ERROR_MESSAGE": "Couldn't analyze the message. Please try again.",
"CLASSIFICATION": {
"LIKELY_UTILITY": "Likely Utility",
"LIKELY_MARKETING": "Likely Marketing",
"UNCLEAR": "Needs clarification"
}
},
"SURVEY_RULE": {
"LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
@@ -901,7 +915,7 @@
"SELECT_PLACEHOLDER": "select labels"
},
"NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
"WHATSAPP_NOTE": "Note: When you save, the system creates a dedicated CSAT template in WhatsApp (used to capture rating and feedback in reports) and submits it as Utility for approval. Meta may still classify it as Marketing based on content. After approval, surveys are sent only once per conversation as per the survey rule.",
"API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."

View File

@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useInbox } from 'dashboard/composables/useInbox';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
import Icon from 'dashboard/components-next/icon/Icon.vue';
@@ -26,6 +27,7 @@ const props = defineProps({
const { t } = useI18n();
const store = useStore();
const labels = useMapGetter('labels/getLabels');
const { captainEnabled } = useCaptain();
const { isAWhatsAppChannel, isATwilioWhatsAppChannel } = useInbox(
props.inbox?.id
@@ -37,6 +39,8 @@ const isAnyWhatsAppChannel = computed(
);
const isUpdating = ref(false);
const utilityAnalysisLoading = ref(false);
const utilityAnalysisResult = ref(null);
const selectedLabelValues = ref([]);
const currentLabel = ref('');
@@ -46,7 +50,7 @@ const state = reactive({
message: '',
templateButtonText: 'Please rate us',
surveyRuleOperator: 'contains',
templateLanguage: '',
templateLanguage: 'en',
});
const templateStatus = ref(null);
@@ -89,6 +93,9 @@ const messagePreviewData = computed(() => ({
const shouldShowTemplateStatus = computed(
() => templateStatus.value && !templateLoading.value
);
const showUtilityAnalyzer = computed(
() => isAnyWhatsAppChannel.value && captainEnabled.value
);
const templateApprovalStatus = computed(() => {
const statusMap = {
@@ -218,6 +225,85 @@ const updateDisplayType = type => {
state.displayType = type;
};
const resetUtilityAnalysis = () => {
utilityAnalysisResult.value = null;
};
const analyzeTemplateUtility = async () => {
if (!showUtilityAnalyzer.value || !state.message?.trim()) return;
utilityAnalysisLoading.value = true;
resetUtilityAnalysis();
try {
const response = await store.dispatch(
'inboxes/analyzeCSATTemplateUtility',
{
inboxId: props.inbox.id,
template: {
message: state.message,
button_text: state.templateButtonText,
language: state.templateLanguage,
},
}
);
utilityAnalysisResult.value = response;
} catch (error) {
const errorMessage =
error.response?.data?.error ||
t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.ERROR_MESSAGE');
useAlert(errorMessage);
} finally {
utilityAnalysisLoading.value = false;
}
};
const applyUtilitySuggestion = () => {
const suggestion = utilityAnalysisResult.value?.optimized_message;
if (!suggestion) return;
state.message = suggestion;
resetUtilityAnalysis();
};
watch(
() => [state.message, state.templateButtonText, state.templateLanguage],
(newValues, oldValues) => {
if (!oldValues || !utilityAnalysisResult.value) {
return;
}
const changed = newValues.some(
(value, index) => value !== oldValues[index]
);
if (changed) {
resetUtilityAnalysis();
}
}
);
const getUtilityClassificationLabel = classification => {
if (classification === 'LIKELY_UTILITY') {
return t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.LIKELY_UTILITY');
}
if (classification === 'LIKELY_MARKETING') {
return t(
'INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.LIKELY_MARKETING'
);
}
return t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.UNCLEAR');
};
const getUtilityClassificationClass = classification => {
if (classification === 'LIKELY_UTILITY') {
return 'bg-n-teal-3 text-n-teal-11';
}
if (classification === 'LIKELY_MARKETING') {
return 'bg-n-ruby-3 text-n-ruby-11';
}
return 'bg-n-amber-3 text-n-amber-11';
};
const updateSurveyRuleOperator = operator => {
state.surveyRuleOperator = operator;
};
@@ -450,6 +536,70 @@ const handleConfirmTemplateUpdate = async () => {
class="w-full"
/>
</WithLabel>
<div v-if="showUtilityAnalyzer" class="flex flex-col gap-2">
<NextButton
sm
slate
:label="$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.ACTION')"
:is-loading="utilityAnalysisLoading"
:disabled="!state.message?.trim()"
@click="analyzeTemplateUtility"
/>
<p class="text-xs text-n-slate-11">
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.HELPER_NOTE') }}
</p>
</div>
<div
v-if="utilityAnalysisResult"
class="flex flex-col gap-3 p-3 rounded-xl outline outline-1 outline-n-weak bg-n-alpha-1"
>
<div class="flex gap-2 items-center">
<span class="text-sm font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.RESULT_LABEL') }}
</span>
<span
class="px-2 py-0.5 text-xs font-medium rounded-full"
:class="
getUtilityClassificationClass(
utilityAnalysisResult.classification
)
"
>
{{
getUtilityClassificationLabel(
utilityAnalysisResult.classification
)
}}
</span>
</div>
<p class="text-xs text-n-slate-11">
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.GUIDANCE_NOTE') }}
</p>
<div
v-if="
utilityAnalysisResult.optimized_message &&
utilityAnalysisResult.classification !== 'LIKELY_UTILITY'
"
class="flex flex-col gap-2"
>
<p class="text-xs font-medium text-n-slate-12">
{{
$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.SUGGESTION_LABEL')
}}
</p>
<p class="text-sm text-n-slate-12">
{{ utilityAnalysisResult.optimized_message }}
</p>
<NextButton
sm
faded
slate
:label="$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.APPLY')"
@click="applyUtilitySuggestion"
/>
</div>
</div>
<Input
v-model="state.templateButtonText"
:label="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.LABEL')"

View File

@@ -360,6 +360,13 @@ export const actions = {
const response = await InboxesAPI.getCSATTemplateStatus(inboxId);
return response.data;
},
analyzeCSATTemplateUtility: async (_, { inboxId, template }) => {
const response = await InboxesAPI.analyzeCSATTemplateUtility(
inboxId,
template
);
return response.data;
},
};
export const mutations = {