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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user