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

@@ -1,6 +1,7 @@
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :validate_whatsapp_channel
before_action :validate_captain_enabled, only: [:analyze]
def show
service = CsatTemplateManagementService.new(@inbox)
@@ -24,6 +25,23 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
end
def analyze
template_params = extract_template_params
return render_missing_message_error if template_params[:message].blank?
result = CsatTemplateUtilityAnalysisService.new(
account: Current.account,
inbox: @inbox,
message: template_params[:message],
button_text: template_params[:button_text],
language: template_params[:language]
).perform
render json: result
rescue ActionController::ParameterMissing
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
end
private
def fetch_inbox
@@ -46,6 +64,12 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Message is required' }, status: :unprocessable_entity
end
def validate_captain_enabled
return if Current.account.feature_enabled?('captain_integration')
render json: { error: 'Captain is required for template analysis' }, status: :forbidden
end
def render_template_creation_result(result)
if result[:success]
render_successful_template_creation(result)

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 = {

View File

@@ -110,6 +110,7 @@ class CsatTemplateManagementService
template_service = Twilio::CsatTemplateService.new(@inbox.channel)
status_result = template_service.get_template_status(content_sid)
return { template_exists: false, error: 'Template not found' } unless status_result.is_a?(Hash)
if status_result[:success]
{
@@ -130,6 +131,7 @@ class CsatTemplateManagementService
def get_whatsapp_template_status(template)
template_name = template['name'] || CsatTemplateNameService.csat_template_name(@inbox.id)
status_result = Whatsapp::CsatTemplateService.new(@inbox.channel).get_template_status(template_name)
return { template_exists: false, error: 'Template not found' } unless status_result.is_a?(Hash)
if status_result[:success]
{

View File

@@ -0,0 +1,86 @@
class CsatTemplateUtilityAnalysisService
include CsatTemplateUtilityRubric
pattr_initialize [:account!, :inbox!, :message!, { button_text: nil, language: 'en' }]
def perform
baseline = rule_based_result
return baseline if baseline[:classification] == 'LIKELY_MARKETING'
llm_result = llm_result_or_nil(baseline)
llm_result || baseline
end
private
def llm_result_or_nil(baseline)
llm_output = Captain::CsatUtilityAnalysisService.new(
account: account,
message: message,
button_text: button_text,
language: language,
baseline: baseline
).perform
return nil if llm_output[:error]
normalize_llm_result(llm_output, baseline: baseline)
rescue StandardError => e
Rails.logger.error("CSAT utility LLM analysis failed for inbox #{inbox.id}: #{e.message}")
nil
end
def normalize_llm_result(result, baseline:)
classification = normalized_classification(result[:classification], baseline: baseline)
optimized_message = result[:optimized_message].presence || baseline[:optimized_message]
optimized_message = baseline[:optimized_message] if baseline[:classification] == 'LIKELY_MARKETING'
{
classification: classification,
optimized_message: optimized_message
}
end
def normalized_classification(value, baseline:)
raw = value.to_s
return 'LIKELY_MARKETING' if baseline[:classification] == 'LIKELY_MARKETING'
raw
end
def rule_based_result
text = sanitized_message
marketing_hits_count = MARKETING_PATTERNS.count { |pattern| pattern.match?(text) }
utility_hits_count = UTILITY_PATTERNS.count { |pattern| pattern.match?(text) }
criteria = evaluate_criteria(text: text, marketing_hits_count: marketing_hits_count)
classification = classify(criteria: criteria, utility_hits_count: utility_hits_count)
build_rule_payload(
classification: classification
)
end
def build_rule_payload(payload)
{
classification: payload[:classification],
optimized_message: optimized_message_for(payload[:classification])
}
end
def sanitized_message
message.to_s.squish
end
def classify(criteria:, utility_hits_count:)
return 'LIKELY_MARKETING' unless criteria[:marketing_prohibition]
return 'LIKELY_MARKETING' unless criteria[:prohibited_content]
return 'LIKELY_UTILITY' if criteria.values.all? && utility_hits_count >= 2
'UNCLEAR'
end
def optimized_message_for(classification)
return sanitized_message if classification == 'LIKELY_UTILITY'
build_input_aware_utility_message
end
end

View File

@@ -0,0 +1,125 @@
# rubocop:disable Metrics/ModuleLength
module CsatTemplateUtilityRubric
LANGUAGE_FALLBACKS = {
'en' => {
support_request: 'support request',
support_ticket: 'support ticket',
support_conversation: 'support conversation',
status_closed: 'closed',
status_resolved: 'resolved',
status_completed: 'completed',
line_status: 'Your %<subject>s has been %<status>s.',
line_help: 'If you still need help, simply reply to this message.',
line_rate: 'To rate this support interaction, please use the button below.'
}
}.freeze
MARKETING_PATTERNS = [
/\b(discounts?|offers?|promos?|promotions?|deals?|sales?|buy|shop|subscribe)\b/i,
/\b(limited\s*time|don't\s*miss|exclusive|special\s*offer)\b/i,
/\b(click\s*(here|below)\s*to\s*(buy|get|shop))\b/i,
/\b(new\s*(plans?|products?|services?))\b/i
].freeze
TRANSACTION_TRIGGER_PATTERNS = [
/\b(closed|closing|resolved|completed)\b/i,
/\b(ticket|request|case|conversation|support)\b/i
].freeze
TRANSACTIONAL_CONTENT_PATTERNS = [
/\b(ticket|request|case|conversation)\b/i,
/\b(reply\s+to\s+this\s+message|if\s+you\s+still\s+need\s+help)\b/i,
/\b(rate|califica|calificar)\b/i
].freeze
PROHIBITED_CONTENT_PATTERNS = [
/\b(contest|sweepstake|lottery|quiz)\b/i,
/\b(password|otp|pin|cvv|credit\s*card)\b/i,
/\b(weapon|drugs|gambling)\b/i
].freeze
STATUS_PATTERNS = {
'closed' => /\b(closed|closing)\b/i,
'resolved' => /\b(resolved|resolve[sd]?)\b/i,
'completed' => /\b(completed|complete[sd]?)\b/i
}.freeze
SUBJECT_PATTERNS = {
'support ticket' => /\b(ticket)\b/i,
'support conversation' => /\b(conversation|chat)\b/i,
'support request' => /\b(request|case|support)\b/i
}.freeze
UTILITY_PATTERNS = [
/\b(support|ticket|request|conversation|case)\b/i,
/\b(closed|resolved|completed)\b/i,
/\b(reply\s+to\s+this\s+message\b)/i,
/\b(if\s+you\s+still\s+need\s+help)\b/i,
/\b(rate\s+this\s+(support|interaction|conversation))\b/i
].freeze
private
def build_input_aware_utility_message
text = translation_pack
subject = detected_subject
status = detected_status
intro = extracted_intro_sentence
parts = []
parts << intro if intro.present?
parts << format(text[:line_status], subject: subject, status: status)
parts << text[:line_help]
parts << text[:line_rate]
parts.join(' ')
end
def detected_status
matched = STATUS_PATTERNS.find { |_key, pattern| pattern.match?(sanitized_message) }
status_key = matched&.first || 'closed'
translation_pack[:"status_#{status_key}"]
end
def detected_subject
matched = SUBJECT_PATTERNS.find { |_key, pattern| pattern.match?(sanitized_message) }
subject_key = matched&.first&.tr(' ', '_') || 'support_request'
translation_pack[subject_key.to_sym]
end
def extracted_intro_sentence
first_sentence = sanitized_message.split(/(?<=[.!?])\s+/).first.to_s
return nil if first_sentence.blank?
return nil if MARKETING_PATTERNS.any? { |pattern| pattern.match?(first_sentence) }
return nil unless first_sentence.match?(/\b(thanks|thank you|hello|hi)\b/i)
normalized = first_sentence.gsub(/\s+/, ' ').strip
normalized.ends_with?('.', '!', '?') ? normalized : "#{normalized}."
end
def translation_pack
LANGUAGE_FALLBACKS.fetch(primary_language_code, LANGUAGE_FALLBACKS['en'])
end
def primary_language_code
language.to_s.downcase.split(/[-_]/).first
end
def evaluate_criteria(text:, marketing_hits_count:)
{
trigger: TRANSACTION_TRIGGER_PATTERNS.any? { |pattern| pattern.match?(text) },
transactional_content: TRANSACTIONAL_CONTENT_PATTERNS.count { |pattern| pattern.match?(text) } >= 2,
marketing_prohibition: marketing_hits_count.zero?,
prohibited_content: PROHIBITED_CONTENT_PATTERNS.none? { |pattern| pattern.match?(text) },
clarity_and_utility: clear_utility_intent?(text)
}
end
def clear_utility_intent?(text)
has_support_context = text.match?(/\b(support|ticket|request|case|conversation)\b/i)
has_actionable_next_step = text.match?(/\b(reply\s+to\s+this\s+message)\b/i) ||
text.match?(/\b(if\s+you\s+still\s+need\s+help)\b/i) ||
text.match?(/\b(rate\s+this\s+(support|interaction|conversation))\b/i)
has_support_context && has_actionable_next_step
end
end
# rubocop:enable Metrics/ModuleLength

View File

@@ -2,7 +2,7 @@ class Whatsapp::CsatTemplateService
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
DEFAULT_LANGUAGE = 'en'.freeze
WHATSAPP_API_VERSION = 'v14.0'.freeze
TEMPLATE_CATEGORY = 'MARKETING'.freeze
TEMPLATE_CATEGORY = 'UTILITY'.freeze
TEMPLATE_STATUS_PENDING = 'PENDING'.freeze
def initialize(whatsapp_channel)

View File

@@ -224,7 +224,9 @@ Rails.application.routes.draw do
end
end
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates'
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates' do
post :analyze, on: :collection
end
end
resources :inbox_members, only: [:create, :show], param: :inbox_id do

View File

@@ -0,0 +1,66 @@
class Captain::CsatUtilityAnalysisService < Captain::BaseTaskService
pattr_initialize [:account!, :message!, { button_text: nil, language: 'en', baseline: {} }]
def perform
api_response = make_api_call(
model: GPT_MODEL,
messages: [
{ role: 'system', content: system_prompt },
{ role: 'user', content: message }
]
)
return api_response if api_response[:error]
build_result(api_response[:message])
end
private
def build_result(response_message)
parsed = parse_json_response(response_message)
return { error: 'Invalid LLM response format' } if parsed.blank?
core_result(parsed).merge(message: response_message)
end
def core_result(parsed)
{
classification: normalize_classification(parsed['classification']),
optimized_message: parsed['optimized_message'].presence || baseline[:optimized_message]
}
end
def system_prompt
template = prompt_from_file('csat_utility_analysis')
Liquid::Template.parse(template).render(prompt_variables)
end
def prompt_variables
{
'message' => message.to_s,
'button_text' => button_text.to_s,
'language' => language.to_s,
'baseline_classification' => baseline[:classification].to_s
}
end
def parse_json_response(content)
raw = content.to_s.strip
json = raw.match(/```json\s*(.*?)\s*```/m)&.captures&.first || raw
JSON.parse(json)
rescue JSON::ParserError
nil
end
def normalize_classification(value)
normalized = value.to_s.upcase
return normalized if %w[LIKELY_UTILITY LIKELY_MARKETING UNCLEAR].include?(normalized)
baseline[:classification].presence || 'UNCLEAR'
end
def event_name
'csat_utility_analysis'
end
end

View File

@@ -0,0 +1,27 @@
You are a WhatsApp template compliance assistant.
Your task is to evaluate whether a CSAT template message is likely to be approved as UTILITY vs MARKETING under Meta policy.
Rules:
1. Prefer UTILITY only when the message is tied to an existing support or transactional event.
2. Avoid promotional language, upsell, cross-sell, offers, discounts, or purchase intent.
3. Keep the rewritten message concise, explicit, and purely transactional.
4. Do not invent product offers or marketing phrases.
Input:
- Message: {{ message }}
- Button text: {{ button_text }}
- Language code: {{ language }}
Baseline heuristic:
- Classification: {{ baseline_classification }}
Return ONLY valid JSON with this shape (example):
{
"classification": "LIKELY_UTILITY",
"optimized_message": "rewritten utility-safe message"
}
Allowed values for "classification": "LIKELY_UTILITY", "LIKELY_MARKETING", or "UNCLEAR".
Important:
- Write `optimized_message` in the same language as `Language code`.

View File

@@ -10,10 +10,12 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
let(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account) }
let(:web_widget_inbox) { create(:inbox, account: account) }
let(:mock_service) { instance_double(Whatsapp::CsatTemplateService) }
let(:analysis_service) { instance_double(CsatTemplateUtilityAnalysisService) }
before do
create(:inbox_member, user: agent, inbox: whatsapp_inbox)
allow(Whatsapp::CsatTemplateService).to receive(:new).and_return(mock_service)
allow(CsatTemplateUtilityAnalysisService).to receive(:new).and_return(analysis_service)
end
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/csat_template' do
@@ -380,4 +382,93 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
end
end
end
describe 'POST /api/v1/accounts/{account.id}/inboxes/{inbox.id}/csat_template/analyze' do
let(:valid_template_params) do
{
template: {
message: 'How would you rate your experience?',
button_text: 'Rate Us',
language: 'en'
}
}
end
context 'when captain_integration feature is disabled' do
before do
account.disable_features!('captain_integration')
end
it 'returns forbidden' do
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/analyze",
headers: admin.create_new_auth_token,
params: valid_template_params,
as: :json
expect(response).to have_http_status(:forbidden)
expect(response.parsed_body['error']).to eq('Captain is required for template analysis')
end
end
context 'when captain_integration feature is enabled' do
before do
account.enable_features!('captain_integration')
account.reload
end
it 'returns analysis response' do
allow(analysis_service).to receive(:perform).and_return({
classification: 'LIKELY_UTILITY',
optimized_message: 'Your support request has been closed.'
})
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/analyze",
headers: admin.create_new_auth_token,
params: valid_template_params,
as: :json
expect(response).to have_http_status(:success)
response_data = response.parsed_body
expect(response_data['classification']).to eq('LIKELY_UTILITY')
expect(response_data['optimized_message']).to eq('Your support request has been closed.')
end
it 'returns error when message is missing' do
invalid_params = { template: { button_text: 'Rate Us', language: 'en' } }
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/analyze",
headers: admin.create_new_auth_token,
params: invalid_params,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Message is required')
end
it 'returns unauthorized when agent is not assigned to inbox' do
other_agent = create(:user, account: account, role: :agent)
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/analyze",
headers: other_agent.create_new_auth_token,
params: valid_template_params,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'allows access when agent is assigned to inbox' do
allow(analysis_service).to receive(:perform).and_return({
classification: 'LIKELY_UTILITY',
optimized_message: 'Your support request has been closed.'
})
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/analyze",
headers: agent.create_new_auth_token,
params: valid_template_params,
as: :json
expect(response).to have_http_status(:success)
end
end
end
end

View File

@@ -0,0 +1,24 @@
require 'rails_helper'
RSpec.describe Captain::CsatUtilityAnalysisService do
let(:account) { create(:account) }
let(:service) { described_class.new(account: account, message: 'Test message', language: 'en', baseline: {}) }
describe '#perform' do
before do
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
allow(service).to receive(:make_api_call).and_return({
message: '{"classification":"LIKELY_UTILITY","optimized_message":"Utility-safe message"}'
})
end
it 'returns parsed payload and preserves raw message for usage metering' do
result = service.perform
expect(result[:classification]).to eq('LIKELY_UTILITY')
expect(result[:optimized_message]).to eq('Utility-safe message')
expect(result[:message]).to eq('{"classification":"LIKELY_UTILITY","optimized_message":"Utility-safe message"}')
end
end
end

View File

@@ -0,0 +1,81 @@
require 'rails_helper'
RSpec.describe CsatTemplateUtilityAnalysisService do
let(:account) { build_stubbed(:account) }
let(:inbox) { build_stubbed(:inbox) }
let(:llm_service) { instance_double(Captain::CsatUtilityAnalysisService) }
before do
allow(Captain::CsatUtilityAnalysisService).to receive(:new).and_return(llm_service)
allow(llm_service).to receive(:perform).and_return({ error: 'LLM unavailable' })
end
describe '#perform' do
context 'when message is utility-compatible' do
it 'returns likely utility classification and keeps original message' do
message = 'Your support request has been closed. If you still need help, reply to this message.'
result = described_class.new(account: account, inbox: inbox, message: message, language: 'en').perform
expect(result[:classification]).to eq('LIKELY_UTILITY')
expect(result[:optimized_message]).to eq(message)
expect(result.keys).to contain_exactly(:classification, :optimized_message)
end
end
context 'when message contains marketing intent' do
it 'returns likely marketing classification with utility-safe rewrite' do
message = 'Please rate us and check out our special offer with a discount.'
result = described_class.new(account: account, inbox: inbox, message: message, language: 'en').perform
expect(result[:classification]).to eq('LIKELY_MARKETING')
expect(result[:optimized_message]).to include('support request')
expect(result[:optimized_message]).to include('reply to this message')
expect(result.keys).to contain_exactly(:classification, :optimized_message)
end
end
context 'when language is non-English and fallback rewrite is used' do
it 'returns English rewrite content' do
message = 'Tu caso está cerrado. Califícanos y no te pierdas nuestra oferta.'
result = described_class.new(account: account, inbox: inbox, message: message, language: 'es').perform
expect(result[:optimized_message]).to include('Your support request has been closed.')
expect(result[:optimized_message]).to include('If you still need help')
end
end
context 'when llm returns inconsistent marketing classification' do
it 'keeps likely marketing classification' do
allow(llm_service).to receive(:perform).and_return({
classification: 'LIKELY_MARKETING',
optimized_message: 'Your support request has been closed.'
})
message = "Your case is closed. Don't miss our limited-time premium offer. Rate us below."
result = described_class.new(account: account, inbox: inbox, message: message, language: 'en').perform
expect(result[:classification]).to eq('LIKELY_MARKETING')
end
end
context 'when rules classify as marketing' do
it 'short-circuits without calling llm' do
expect(llm_service).not_to receive(:perform)
message = 'Your request is closed. Special offer: subscribe now and save.'
result = described_class.new(account: account, inbox: inbox, message: message, language: 'en').perform
expect(result[:classification]).to eq('LIKELY_MARKETING')
end
end
context 'when rules classify as marketing for plural promo terms' do
it 'keeps likely marketing classification from baseline rules' do
message = 'Thanks for contacting us. Rate us and check out our new plans with special discounts.'
result = described_class.new(account: account, inbox: inbox, message: message, language: 'en').perform
expect(result[:classification]).to eq('LIKELY_MARKETING')
end
end
end
end

View File

@@ -119,7 +119,7 @@ RSpec.describe Whatsapp::CsatTemplateService do
expect(result).to eq({
name: expected_template_name,
language: 'en',
category: 'MARKETING',
category: 'UTILITY',
components: [
{
type: 'BODY',
@@ -169,7 +169,7 @@ RSpec.describe Whatsapp::CsatTemplateService do
expected_body = {
name: expected_template_name,
language: 'en',
category: 'MARKETING',
category: 'UTILITY',
components: [
{
type: 'BODY',