diff --git a/Gemfile b/Gemfile
index e6c5a5250..0636b7b2b 100644
--- a/Gemfile
+++ b/Gemfile
@@ -191,7 +191,7 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
-gem 'ai-agents'
+gem 'ai-agents', '>= 0.9.1'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.8.2'
diff --git a/Gemfile.lock b/Gemfile.lock
index aad0438cf..7a7316e3c 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -126,7 +126,7 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
- ai-agents (0.9.0)
+ ai-agents (0.9.1)
ruby_llm (~> 1.9.1)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
@@ -1024,7 +1024,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
- ai-agents
+ ai-agents (>= 0.9.1)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
diff --git a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb
index bb5dab680..d7244616d 100644
--- a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb
+++ b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb
@@ -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)
diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js
index 83ba3e9ba..079f21815 100644
--- a/app/javascript/dashboard/api/inboxes.js
+++ b/app/javascript/dashboard/api/inboxes.js
@@ -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();
diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
index f4f62764b..aaa8fd5bf 100644
--- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
+++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
@@ -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."
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue
index 03cb6dada..dc247da6f 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue
@@ -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"
/>
+
+
+
+ {{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.HELPER_NOTE') }}
+
+
+
+
+
+
+ {{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.RESULT_LABEL') }}
+
+
+ {{
+ getUtilityClassificationLabel(
+ utilityAnalysisResult.classification
+ )
+ }}
+
+
+
+ {{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.GUIDANCE_NOTE') }}
+
+
+
+ {{
+ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.SUGGESTION_LABEL')
+ }}
+
+
+ {{ utilityAnalysisResult.optimized_message }}
+
+
+
+
{
+ const response = await InboxesAPI.analyzeCSATTemplateUtility(
+ inboxId,
+ template
+ );
+ return response.data;
+ },
};
export const mutations = {
diff --git a/app/services/csat_template_management_service.rb b/app/services/csat_template_management_service.rb
index 064804082..e83b3413a 100644
--- a/app/services/csat_template_management_service.rb
+++ b/app/services/csat_template_management_service.rb
@@ -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]
{
diff --git a/app/services/csat_template_utility_analysis_service.rb b/app/services/csat_template_utility_analysis_service.rb
new file mode 100644
index 000000000..b1b089b53
--- /dev/null
+++ b/app/services/csat_template_utility_analysis_service.rb
@@ -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
diff --git a/app/services/csat_template_utility_rubric.rb b/app/services/csat_template_utility_rubric.rb
new file mode 100644
index 000000000..0253a003a
--- /dev/null
+++ b/app/services/csat_template_utility_rubric.rb
@@ -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 %s has been %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
diff --git a/app/services/whatsapp/csat_template_service.rb b/app/services/whatsapp/csat_template_service.rb
index 9bdbef8ca..9dc057a10 100644
--- a/app/services/whatsapp/csat_template_service.rb
+++ b/app/services/whatsapp/csat_template_service.rb
@@ -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)
diff --git a/config/routes.rb b/config/routes.rb
index 38c93b91e..1efd9f526 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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
diff --git a/enterprise/app/services/captain/assistant/agent_runner_service.rb b/enterprise/app/services/captain/assistant/agent_runner_service.rb
index 9aeee605f..72c44024f 100644
--- a/enterprise/app/services/captain/assistant/agent_runner_service.rb
+++ b/enterprise/app/services/captain/assistant/agent_runner_service.rb
@@ -3,6 +3,8 @@ require 'agents/instrumentation'
class Captain::Assistant::AgentRunnerService
include Integrations::LlmInstrumentationConstants
+ include Captain::Assistant::RunnerCallbacksHelper
+ include Captain::Assistant::TracePayloadHelper
CONVERSATION_STATE_ATTRIBUTES = %i[
id display_id inbox_id contact_id status priority
@@ -21,13 +23,7 @@ class Captain::Assistant::AgentRunnerService
end
def generate_response(message_history: [])
- agents = build_and_wire_agents
- context = build_context(message_history)
- message_to_process = extract_last_user_message(message_history)
- runner = Agents::Runner.with_agents(*agents)
- runner = add_usage_metadata_callback(runner)
- runner = add_callbacks_to_runner(runner) if @callbacks.any?
- install_instrumentation(runner)
+ message_to_process, context = run_payload(message_history)
result = runner.run(message_to_process, context: context, max_turns: 100)
process_agent_result(result)
@@ -45,7 +41,10 @@ class Captain::Assistant::AgentRunnerService
def build_context(message_history)
conversation_history = message_history.map do |msg|
- content = extract_text_from_content(msg[:content])
+ content = msg[:content]
+ # Preserve multimodal arrays (with image_url entries) as-is for the runner to restore with attachments.
+ # Only extract text from non-array formats (hashes from agent structured output, plain strings).
+ content = extract_text_from_content(content) unless content.is_a?(Array)
{
role: msg[:role].to_sym,
@@ -63,8 +62,22 @@ class Captain::Assistant::AgentRunnerService
def extract_last_user_message(message_history)
last_user_msg = message_history.reverse.find { |msg| msg[:role] == 'user' }
+ return '' if last_user_msg.blank?
- extract_text_from_content(last_user_msg[:content])
+ content = last_user_msg[:content]
+ return extract_text_from_content(content) unless content.is_a?(Array)
+
+ text, attachments = Captain::OpenAiMessageBuilderService.extract_text_and_attachments(content)
+ return text if attachments.blank?
+
+ RubyLLM::Content.new(text, attachments)
+ end
+
+ def message_history_without_last_user_message(message_history)
+ last_user_index = message_history.rindex { |msg| msg[:role] == 'user' }
+ return message_history if last_user_index.nil?
+
+ message_history.reject.with_index { |_msg, index| index == last_user_index }
end
def extract_text_from_content(content)
@@ -143,28 +156,25 @@ class Captain::Assistant::AgentRunnerService
},
attribute_provider: ->(context_wrapper) { dynamic_trace_attributes(context_wrapper) }
)
+ register_trace_input_callback(runner)
end
def dynamic_trace_attributes(context_wrapper)
state = context_wrapper&.context&.dig(:state) || {}
conversation = state[:conversation] || {}
+ trace_input = context_wrapper&.context&.dig(:captain_v2_trace_input)
+
{
ATTR_LANGFUSE_USER_ID => state[:account_id],
format(ATTR_LANGFUSE_METADATA, 'assistant_id') => state[:assistant_id],
format(ATTR_LANGFUSE_METADATA, 'conversation_id') => conversation[:id],
format(ATTR_LANGFUSE_METADATA, 'conversation_display_id') => conversation[:display_id],
- format(ATTR_LANGFUSE_METADATA, 'channel_type') => state[:channel_type]
+ format(ATTR_LANGFUSE_METADATA, 'channel_type') => state[:channel_type],
+ ATTR_LANGFUSE_TRACE_INPUT => trace_input,
+ ATTR_LANGFUSE_OBSERVATION_INPUT => trace_input
}.compact.transform_values(&:to_s)
end
- def add_callbacks_to_runner(runner)
- runner = add_agent_thinking_callback(runner) if @callbacks[:on_agent_thinking]
- runner = add_tool_start_callback(runner) if @callbacks[:on_tool_start]
- runner = add_tool_complete_callback(runner) if @callbacks[:on_tool_complete]
- runner = add_agent_handoff_callback(runner) if @callbacks[:on_agent_handoff]
- runner
- end
-
def add_usage_metadata_callback(runner)
return runner unless ChatwootApp.otel_enabled?
@@ -195,35 +205,20 @@ class Captain::Assistant::AgentRunnerService
root_span.set_attribute(format(ATTR_LANGFUSE_METADATA, 'credit_used'), credit_used.to_s)
end
- def add_agent_thinking_callback(runner)
- runner.on_agent_thinking do |*args|
- @callbacks[:on_agent_thinking].call(*args)
- rescue StandardError => e
- Rails.logger.warn "[Captain] Callback error for agent_thinking: #{e.message}"
+ def runner
+ @runner ||= begin
+ configured_runner = Agents::Runner.with_agents(*build_and_wire_agents)
+ configured_runner = add_usage_metadata_callback(configured_runner)
+ configured_runner = add_callbacks_to_runner(configured_runner) if @callbacks.any?
+ install_instrumentation(configured_runner)
+ configured_runner
end
end
- def add_tool_start_callback(runner)
- runner.on_tool_start do |*args|
- @callbacks[:on_tool_start].call(*args)
- rescue StandardError => e
- Rails.logger.warn "[Captain] Callback error for tool_start: #{e.message}"
- end
- end
-
- def add_tool_complete_callback(runner)
- runner.on_tool_complete do |*args|
- @callbacks[:on_tool_complete].call(*args)
- rescue StandardError => e
- Rails.logger.warn "[Captain] Callback error for tool_complete: #{e.message}"
- end
- end
-
- def add_agent_handoff_callback(runner)
- runner.on_agent_handoff do |*args|
- @callbacks[:on_agent_handoff].call(*args)
- rescue StandardError => e
- Rails.logger.warn "[Captain] Callback error for agent_handoff: #{e.message}"
- end
+ def run_payload(message_history)
+ message_to_process = extract_last_user_message(message_history)
+ context = build_context(message_history_without_last_user_message(message_history))
+ enrich_context_with_trace_payload!(context, message_history, message_to_process)
+ [message_to_process, context]
end
end
diff --git a/enterprise/app/services/captain/assistant/runner_callbacks_helper.rb b/enterprise/app/services/captain/assistant/runner_callbacks_helper.rb
new file mode 100644
index 000000000..58fa0ca33
--- /dev/null
+++ b/enterprise/app/services/captain/assistant/runner_callbacks_helper.rb
@@ -0,0 +1,53 @@
+module Captain::Assistant::RunnerCallbacksHelper
+ private
+
+ def add_callbacks_to_runner(runner)
+ runner = add_agent_thinking_callback(runner) if @callbacks[:on_agent_thinking]
+ runner = add_tool_start_callback(runner) if @callbacks[:on_tool_start]
+ runner = add_tool_complete_callback(runner) if @callbacks[:on_tool_complete]
+ runner = add_agent_handoff_callback(runner) if @callbacks[:on_agent_handoff]
+ runner
+ end
+
+ def register_trace_input_callback(runner)
+ runner.on_agent_thinking do |_agent_name, _input, context_wrapper|
+ tracing = context_wrapper&.context&.dig(:__otel_tracing)
+ next unless tracing
+
+ trace_input = context_wrapper.context[:captain_v2_trace_current_input]
+ tracing[:pending_llm_input] = trace_input if trace_input.present?
+ end
+ end
+
+ def add_agent_thinking_callback(runner)
+ runner.on_agent_thinking do |*args|
+ @callbacks[:on_agent_thinking].call(*args)
+ rescue StandardError => e
+ Rails.logger.warn "[Captain] Callback error for agent_thinking: #{e.message}"
+ end
+ end
+
+ def add_tool_start_callback(runner)
+ runner.on_tool_start do |*args|
+ @callbacks[:on_tool_start].call(*args)
+ rescue StandardError => e
+ Rails.logger.warn "[Captain] Callback error for tool_start: #{e.message}"
+ end
+ end
+
+ def add_tool_complete_callback(runner)
+ runner.on_tool_complete do |*args|
+ @callbacks[:on_tool_complete].call(*args)
+ rescue StandardError => e
+ Rails.logger.warn "[Captain] Callback error for tool_complete: #{e.message}"
+ end
+ end
+
+ def add_agent_handoff_callback(runner)
+ runner.on_agent_handoff do |*args|
+ @callbacks[:on_agent_handoff].call(*args)
+ rescue StandardError => e
+ Rails.logger.warn "[Captain] Callback error for agent_handoff: #{e.message}"
+ end
+ end
+end
diff --git a/enterprise/app/services/captain/assistant/trace_payload_helper.rb b/enterprise/app/services/captain/assistant/trace_payload_helper.rb
new file mode 100644
index 000000000..3b6546c12
--- /dev/null
+++ b/enterprise/app/services/captain/assistant/trace_payload_helper.rb
@@ -0,0 +1,51 @@
+module Captain::Assistant::TracePayloadHelper
+ private
+
+ def enrich_context_with_trace_payload!(context, message_history, message_to_process)
+ context[:captain_v2_trace_input] = serialize_trace_messages(message_history)
+ context[:captain_v2_trace_current_input] = serialize_trace_content(message_to_process)
+ end
+
+ def serialize_trace_messages(message_history)
+ message_history.map do |message|
+ {
+ role: message[:role].to_s,
+ content: trace_content_payload(message[:content])
+ }
+ end.to_json
+ end
+
+ def serialize_trace_content(content)
+ payload = trace_content_payload(content)
+ return '' if payload.blank?
+
+ payload.is_a?(String) ? payload : payload.to_json
+ end
+
+ def trace_content_payload(content)
+ case content
+ when RubyLLM::Content
+ trace_parts_from_ruby_llm_content(content)
+ when Array, Hash
+ content
+ when NilClass
+ ''
+ else
+ content.to_s
+ end
+ end
+
+ def trace_parts_from_ruby_llm_content(content)
+ parts = []
+ parts << { type: 'text', text: content.text } if content.text.present?
+
+ content.attachments.each do |attachment|
+ parts << { type: 'image_url', image_url: { url: attachment.source.to_s } }
+ end
+
+ return '' if parts.blank?
+ return parts.first[:text] if parts.one? && parts.first[:type] == 'text'
+
+ parts
+ end
+end
diff --git a/lib/captain/csat_utility_analysis_service.rb b/lib/captain/csat_utility_analysis_service.rb
new file mode 100644
index 000000000..e04a98a7f
--- /dev/null
+++ b/lib/captain/csat_utility_analysis_service.rb
@@ -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
diff --git a/lib/integrations/openai/openai_prompts/csat_utility_analysis.liquid b/lib/integrations/openai/openai_prompts/csat_utility_analysis.liquid
new file mode 100644
index 000000000..a4638241e
--- /dev/null
+++ b/lib/integrations/openai/openai_prompts/csat_utility_analysis.liquid
@@ -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`.
diff --git a/spec/controllers/api/v1/accounts/inbox_csat_templates_controller_spec.rb b/spec/controllers/api/v1/accounts/inbox_csat_templates_controller_spec.rb
index d577c1b5d..ea23319fe 100644
--- a/spec/controllers/api/v1/accounts/inbox_csat_templates_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/inbox_csat_templates_controller_spec.rb
@@ -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
diff --git a/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb
index c3019b82c..13e4804ce 100644
--- a/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb
+++ b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb
@@ -74,12 +74,11 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
end
it 'runs agent with extracted user message and context' do
- expected_context = {
+ expected_context = hash_including(
session_id: "#{account.id}_#{conversation.display_id}",
conversation_history: [
{ role: :user, content: 'Hello there', agent_name: nil },
- { role: :assistant, content: 'Hi! How can I help you?', agent_name: 'Assistant' },
- { role: :user, content: 'I need help with my account', agent_name: nil }
+ { role: :assistant, content: 'Hi! How can I help you?', agent_name: 'Assistant' }
],
state: hash_including(
account_id: account.id,
@@ -87,7 +86,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
conversation: hash_including(id: conversation.id),
contact: hash_including(id: contact.id)
)
- }
+ )
expect(mock_runner).to receive(:run).with(
'I need help with my account',
@@ -98,6 +97,71 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
service.generate_response(message_history: message_history)
end
+ context 'when the latest user message is multimodal' do
+ let(:multimodal_message_history) do
+ [
+ { role: 'assistant', content: 'Please share a screenshot' },
+ {
+ role: 'user',
+ content: [
+ { type: 'text', text: 'What does this error mean?' },
+ { type: 'image_url', image_url: { url: 'https://example.com/error.png' } }
+ ]
+ }
+ ]
+ end
+
+ it 'passes image attachments to the runner input' do
+ expect(mock_runner).to receive(:run) do |input, context:, max_turns:|
+ expect(input).to be_a(RubyLLM::Content)
+ expect(input.text).to eq('What does this error mean?')
+ expect(input.attachments.first.source.to_s).to eq('https://example.com/error.png')
+ expect(context[:conversation_history]).to eq([{ role: :assistant, content: 'Please share a screenshot', agent_name: nil }])
+ expect(max_turns).to eq(100)
+ end
+
+ service.generate_response(message_history: multimodal_message_history)
+ end
+
+ it 'preserves multimodal content in earlier history messages' do
+ history_with_prior_image = [
+ {
+ role: 'user',
+ content: [
+ { type: 'text', text: 'Here is my error screenshot' },
+ { type: 'image_url', image_url: { url: 'https://example.com/error.png' } }
+ ]
+ },
+ { role: 'assistant', content: 'I see the error. Try restarting.' },
+ { role: 'user', content: 'It still does not work' }
+ ]
+
+ expect(mock_runner).to receive(:run) do |input, context:, max_turns:|
+ expect(input).to eq('It still does not work')
+ # The earlier user message with the image should preserve the multimodal array
+ first_history_msg = context[:conversation_history].first
+ expect(first_history_msg[:content]).to be_a(Array)
+ expect(first_history_msg[:content]).to include(
+ { type: 'text', text: 'Here is my error screenshot' },
+ { type: 'image_url', image_url: { url: 'https://example.com/error.png' } }
+ )
+ expect(max_turns).to eq(100)
+ end
+
+ service.generate_response(message_history: history_with_prior_image)
+ end
+
+ it 'stores multimodal trace payloads in runner context' do
+ expect(mock_runner).to receive(:run) do |_input, context:, max_turns:|
+ expect(context[:captain_v2_trace_input]).to include('image_url')
+ expect(context[:captain_v2_trace_current_input]).to include('image_url')
+ expect(max_turns).to eq(100)
+ end
+
+ service.generate_response(message_history: multimodal_message_history)
+ end
+ end
+
it 'processes and formats agent result' do
result = service.generate_response(message_history: message_history)
@@ -197,22 +261,21 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
end
context 'with multimodal content' do
- let(:multimodal_message_history) do
+ let(:multimodal_content) do
[
- {
- role: 'user',
- content: [
- { type: 'text', text: 'Can you help with this image?' },
- { type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }
- ]
- }
+ { type: 'text', text: 'Can you help with this image?' },
+ { type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }
]
end
- it 'extracts text content from multimodal messages' do
+ let(:multimodal_message_history) do
+ [{ role: 'user', content: multimodal_content }]
+ end
+
+ it 'preserves multimodal arrays in conversation history for image context retention' do
context = service.send(:build_context, multimodal_message_history)
- expect(context[:conversation_history].first[:content]).to eq('Can you help with this image?')
+ expect(context[:conversation_history].first[:content]).to eq(multimodal_content)
end
end
end
@@ -225,6 +288,24 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
expect(result).to eq('I need help with my account')
end
+
+ it 'returns multimodal content with image attachments for the runner input' do
+ multimodal_message_history = [
+ {
+ role: 'user',
+ content: [
+ { type: 'text', text: 'Can you check this screenshot?' },
+ { type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }
+ ]
+ }
+ ]
+
+ result = service.send(:extract_last_user_message, multimodal_message_history)
+
+ expect(result).to be_a(RubyLLM::Content)
+ expect(result.text).to eq('Can you check this screenshot?')
+ expect(result.attachments.first.source.to_s).to eq('https://example.com/image.jpg')
+ end
end
describe '#extract_text_from_content' do
@@ -256,6 +337,28 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
end
end
+ describe '#dynamic_trace_attributes' do
+ subject(:service) { described_class.new(assistant: assistant, conversation: conversation) }
+
+ it 'adds serialized trace input attributes when present in context' do
+ context = {
+ state: {
+ account_id: account.id,
+ assistant_id: assistant.id,
+ conversation: { id: conversation.id, display_id: conversation.display_id }
+ },
+ captain_v2_trace_input: '[{"role":"user","content":[{"type":"image_url","image_url":{"url":"https://example.com/image.jpg"}}]}]'
+ }
+ context_wrapper = Struct.new(:context).new(context)
+
+ attributes = service.send(:dynamic_trace_attributes, context_wrapper)
+
+ expect(attributes['langfuse.trace.input']).to include('image_url')
+ expect(attributes['langfuse.observation.input']).to include('image_url')
+ expect(attributes['langfuse.user.id']).to eq(account.id.to_s)
+ end
+ end
+
describe '#build_state' do
subject(:service) { described_class.new(assistant: assistant, conversation: conversation) }
diff --git a/spec/lib/captain/csat_utility_analysis_service_spec.rb b/spec/lib/captain/csat_utility_analysis_service_spec.rb
new file mode 100644
index 000000000..e4e980e01
--- /dev/null
+++ b/spec/lib/captain/csat_utility_analysis_service_spec.rb
@@ -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
diff --git a/spec/services/csat_template_utility_analysis_service_spec.rb b/spec/services/csat_template_utility_analysis_service_spec.rb
new file mode 100644
index 000000000..7f5521d4a
--- /dev/null
+++ b/spec/services/csat_template_utility_analysis_service_spec.rb
@@ -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
diff --git a/spec/services/whatsapp/csat_template_service_spec.rb b/spec/services/whatsapp/csat_template_service_spec.rb
index f21d87227..d7124529d 100644
--- a/spec/services/whatsapp/csat_template_service_spec.rb
+++ b/spec/services/whatsapp/csat_template_service_spec.rb
@@ -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',