Files
moreminimore-chat/app/services/analytics/persona_approval_service.rb
Moreminimore b86b0c59f6 feat(analytics): M2 self-improving chatbot analytics
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)
2026-08-25 15:56:11 +07:00

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