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

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