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-04-14 16:36:06 +05:30
committed by aakashb95
parent be6c3ae8b8
commit f6913f88a1
16 changed files with 711 additions and 6 deletions
@@ -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
@@ -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
@@ -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
@@ -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',