Daily LLM classification of every chat (topics + product tags + deal won/lost/undecided) aggregated into immutable daily metrics, with a filterable admin report dashboard, product-catalog import (text/CSV/XLSX), weekly persona summary, and an approval flow (LINE -> Telegram -> webhook) for applying persona recommendations. - Llm::AnalyticsClassifier: per-account openai -> Captain fallback cascade - AccountDailyProcessor + Conversation/CustomerDailyMetric aggregation - ReportService + DrilldownService (summary + deep filterable drilldown) - AnalyticsReports.vue + productCatalog import UI (admin-only) - WeeklyPersonaEvaluator + PersonaApprovalService (LINE/Telegram/webhook) - Weekly cron (Mon 10:00) + daily cron (02:30)
201 lines
6.7 KiB
Ruby
201 lines
6.7 KiB
Ruby
# Approval-flow delivery for the weekly persona evaluation (phase 3).
|
|
#
|
|
# Delivers the weekly LLM summary + recommendations to the admin via LINE
|
|
# (primary, using quick-reply buttons), Telegram (inline keyboard), or a webhook
|
|
# (fallback), then records the admin's decision. The full persona system prompt is
|
|
# intentionally NOT included in the delivery body — it is only sent to the webhook
|
|
# AFTER the admin approves.
|
|
#
|
|
# Delivery configuration lives on Account#custom_attributes (jsonb):
|
|
# persona_line_user_id - the admin's LINE user id (for LINE push) [primary]
|
|
# persona_telegram_chat_id - the admin's Telegram chat id (for Telegram push)
|
|
# persona_webhook_url - fallback webhook URL
|
|
# persona_webhook_secret - shared secret signed into the webhook body / header
|
|
#
|
|
# Decision keys match LINE quick-reply / webhook actions:
|
|
# 'approve' | 'reject' | 'view_full_prompt'
|
|
class Analytics::PersonaApprovalService
|
|
# @param account [Account]
|
|
# @param evaluation [Analytics::WeeklyPersonaEvaluator::Result]
|
|
# @return [Hash] { delivered:, channel:, error: }
|
|
def self.deliver(account:, evaluation:)
|
|
new(account: account, evaluation: evaluation).deliver
|
|
end
|
|
|
|
# Sends an admin approval decision to the configured webhook (webhook-only, never
|
|
# LINE). Used by the :approve path to inform the integration about the decision.
|
|
# @return [Hash] { delivered:, channel: 'webhook', error: }
|
|
def self.notify_approval(account:, evaluation:, decision:)
|
|
new(account: account, evaluation: evaluation).notify_approval(decision)
|
|
end
|
|
|
|
def initialize(account:, evaluation:)
|
|
@account = account
|
|
@evaluation = evaluation
|
|
end
|
|
|
|
def deliver
|
|
return { delivered: false, error: 'no evaluation to deliver' } if @evaluation.nil? || @evaluation.disabled?
|
|
|
|
if line_delivery_available?
|
|
deliver_via_line
|
|
elsif telegram_delivery_available?
|
|
deliver_via_telegram
|
|
elsif webhook_delivery_available?
|
|
deliver_via_webhook
|
|
else
|
|
{ delivered: false, channel: nil, error: 'no delivery channel configured (set persona_line_user_id, persona_telegram_chat_id or persona_webhook_url)' }
|
|
end
|
|
end
|
|
|
|
# Sends an admin approval decision to the configured webhook. Webhook-only (never
|
|
# LINE) — used by the :approve path so the approval is not re-sent as a LINE card.
|
|
def notify_approval(decision)
|
|
return { delivered: false, channel: 'webhook', error: 'no webhook configured' } unless webhook_delivery_available?
|
|
|
|
post_to_webhook(
|
|
type: 'persona_decision',
|
|
decision: decision
|
|
)
|
|
end
|
|
|
|
private
|
|
|
|
attr_reader :account
|
|
|
|
# -- LINE (primary) ----------------------------------------------------------
|
|
|
|
def line_delivery_available?
|
|
line_user_id.present? && account.line_channels.present?
|
|
end
|
|
|
|
def deliver_via_line
|
|
channel = account.line_channels.first
|
|
channel.client.push_message(
|
|
line_user_id,
|
|
build_line_payload
|
|
)
|
|
{ delivered: true, channel: 'line' }
|
|
rescue StandardError => e
|
|
Rails.logger.error("[PersonaApproval] LINE push failed: #{e.message}")
|
|
{ delivered: false, channel: 'line', error: e.message }
|
|
end
|
|
|
|
def build_line_payload
|
|
{
|
|
type: 'text',
|
|
text: line_text,
|
|
quickReply: {
|
|
items: [
|
|
quick_reply_item('👍 Approve', 'approve'),
|
|
quick_reply_item('Reject', 'reject'),
|
|
quick_reply_item('👁 View full prompt', 'view_full_prompt')
|
|
]
|
|
}
|
|
}
|
|
end
|
|
|
|
def quick_reply_item(label, key)
|
|
{
|
|
type: 'action',
|
|
action: {
|
|
type: 'message',
|
|
label: label,
|
|
text: "persona:#{key}"
|
|
}
|
|
}
|
|
end
|
|
|
|
def line_text
|
|
summary = @evaluation.summary.to_s
|
|
recommendations = Array(@evaluation.recommendations)
|
|
[summary, '', *recommendations.map { |r| "• #{r}" }].join("\n")
|
|
end
|
|
|
|
# -- Telegram ----------------------------------------------------------------
|
|
|
|
def telegram_delivery_available?
|
|
telegram_chat_id.present? && account.telegram_channels.present?
|
|
end
|
|
|
|
def deliver_via_telegram
|
|
channel = account.telegram_channels.first
|
|
response = HTTParty.post(
|
|
"#{channel.telegram_api_url}/sendMessage",
|
|
body: {
|
|
chat_id: telegram_chat_id,
|
|
text: line_text,
|
|
reply_markup: build_telegram_keyboard
|
|
}
|
|
)
|
|
success = response.success?
|
|
Rails.logger.error("[PersonaApproval] Telegram send failed: #{response.parsed_response}") unless success
|
|
{ delivered: success, channel: 'telegram', error: success ? nil : 'telegram send failed' }
|
|
rescue StandardError => e
|
|
Rails.logger.error("[PersonaApproval] Telegram delivery failed: #{e.message}")
|
|
{ delivered: false, channel: 'telegram', error: e.message }
|
|
end
|
|
|
|
# Inline keyboard with the three decision buttons (callback_data = persona:<key>)
|
|
def build_telegram_keyboard
|
|
{
|
|
inline_keyboard: [
|
|
[
|
|
{ text: '👍 Approve', callback_data: 'persona:approve' },
|
|
{ text: 'Reject', callback_data: 'persona:reject' },
|
|
{ text: '👁 View full prompt', callback_data: 'persona:view_full_prompt' }
|
|
]
|
|
]
|
|
}.to_json
|
|
end
|
|
|
|
# -- Webhook (fallback) ------------------------------------------------------
|
|
|
|
def webhook_delivery_available?
|
|
webhook_url.present?
|
|
end
|
|
|
|
# Shared webhook POST: builds the payload from the evaluation plus extra fields.
|
|
def post_to_webhook(extra_fields)
|
|
body = {
|
|
summary: @evaluation.summary,
|
|
recommendations: Array(@evaluation.recommendations)
|
|
}.merge(extra_fields)
|
|
response = HTTParty.post(
|
|
webhook_url,
|
|
body: body.to_json,
|
|
headers: webhook_headers
|
|
)
|
|
success = response.success?
|
|
Rails.logger.error("[PersonaApproval] webhook #{response.code}") unless success
|
|
{ delivered: success, channel: 'webhook', error: success ? nil : "webhook responded #{response.code}" }
|
|
rescue StandardError => e
|
|
Rails.logger.error("[PersonaApproval] webhook failed: #{e.message}")
|
|
{ delivered: false, channel: 'webhook', error: e.message }
|
|
end
|
|
|
|
def deliver_via_webhook
|
|
post_to_webhook(
|
|
type: 'persona_evaluation',
|
|
actions: %w[approve reject view_full_prompt]
|
|
)
|
|
end
|
|
|
|
def webhook_headers
|
|
headers = { 'Content-Type' => 'application/json' }
|
|
headers['X-Persona-Signature'] = signature if webhook_secret.present?
|
|
headers
|
|
end
|
|
|
|
def signature
|
|
OpenSSL::HMAC.hexdigest('sha256', webhook_secret, @evaluation.summary.to_s)
|
|
end
|
|
|
|
# -- config ------------------------------------------------------------------
|
|
|
|
def line_user_id = account.custom_attributes['persona_line_user_id']
|
|
def telegram_chat_id = account.custom_attributes['persona_telegram_chat_id']
|
|
def webhook_url = account.custom_attributes['persona_webhook_url']
|
|
def webhook_secret = account.custom_attributes['persona_webhook_secret']
|
|
end
|