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)
108 lines
3.6 KiB
Ruby
108 lines
3.6 KiB
Ruby
# Deep, filterable admin drilldown over LIVE conversations (phase 2).
|
|
#
|
|
# Unlike Analytics::ReportService (which reads the immutable per-day snapshots),
|
|
# this queries the actual Conversation records scoped to a date range, so the
|
|
# filters the M2 report needs (agent / team / inbox / channel / tag / deal) can
|
|
# be applied at per-conversation granularity, with per-customer and per-agent
|
|
# cross-breakdowns and pagination.
|
|
#
|
|
# Supported filters (all optional, applied with AND):
|
|
# :since (Date) — conversations active on/after (created_at or last_activity_at)
|
|
# :until (Date) — conversations active before this date (exclusive end)
|
|
# :agent_id (Integer)
|
|
# :team_id (Integer)
|
|
# :inbox_id (Integer)
|
|
# :channel (String, e.g. "Channel::WebWidget") — matched against inbox.channel_type
|
|
# :tag (String) — matches any tag in the conversation label_list (exact)
|
|
# :deal (String, "won"|"lost"|"undecided") — matches the "deal:<value>" tag
|
|
# :page, :per_page — pagination (default 1 / 25)
|
|
#
|
|
# Admin-only; access is enforced by the calling controller/policy.
|
|
class Analytics::DrilldownService
|
|
DEFAULT_PER_PAGE = 25
|
|
|
|
# @param account [Account]
|
|
# @param filters [Hash]
|
|
def self.build(account:, filters: {})
|
|
new(account: account, filters: filters).build
|
|
end
|
|
|
|
def initialize(account:, filters: {})
|
|
@account = account
|
|
@filters = filters.symbolize_keys
|
|
end
|
|
|
|
def build
|
|
scope = filtered_scope
|
|
{
|
|
total: scope.count,
|
|
page: page,
|
|
per_page: per_page,
|
|
conversations: scope.offset((page - 1) * per_page).limit(per_page).map { |c| serialize(c) }
|
|
}
|
|
end
|
|
|
|
private
|
|
|
|
attr_reader :account, :filters
|
|
|
|
def page = (filters[:page] || 1).to_i
|
|
def per_page = (filters[:per_page] || DEFAULT_PER_PAGE).to_i.clamp(1, 100)
|
|
|
|
def filtered_scope
|
|
scope = account.conversations
|
|
|
|
if filters[:since].present?
|
|
start = filters[:since]
|
|
scope = scope.where('created_at >= ?', start)
|
|
end
|
|
if filters[:until].present?
|
|
scope = scope.where('created_at < ?', filters[:until] + 1)
|
|
end
|
|
|
|
scope = scope.where(assignee_id: filters[:agent_id]) if filters[:agent_id].present?
|
|
scope = scope.where(team_id: filters[:team_id]) if filters[:team_id].present?
|
|
scope = scope.where(inbox_id: filters[:inbox_id]) if filters[:inbox_id].present?
|
|
|
|
if filters[:channel].present?
|
|
scope = scope.joins(:inbox).where(inboxes: { channel_type: filters[:channel] })
|
|
end
|
|
|
|
if filters[:tag].present?
|
|
tag = filters[:tag].to_s
|
|
matches = account.labels.where(title: tag).pluck(:id)
|
|
# No conversation can carry a label that doesn't exist — return none.
|
|
return scope.none if matches.empty?
|
|
|
|
scope = scope.joins(:labels).where('labels.id IN (?)', matches)
|
|
end
|
|
|
|
if filters[:deal].present?
|
|
deal_tag = "deal:#{filters[:deal]}"
|
|
matches = account.labels.where(title: deal_tag).pluck(:id)
|
|
# No conversation can carry a deal tag that doesn't exist — return none.
|
|
return scope.none if matches.empty?
|
|
|
|
scope = scope.joins(:labels).where('labels.id IN (?)', matches)
|
|
end
|
|
|
|
scope.order(:created_at)
|
|
end
|
|
|
|
def serialize(conversation)
|
|
{
|
|
id: conversation.id,
|
|
display_id: conversation.display_id,
|
|
contact_id: conversation.contact_id,
|
|
assignee_id: conversation.assignee_id,
|
|
team_id: conversation.team_id,
|
|
inbox_id: conversation.inbox_id,
|
|
channel: conversation.inbox&.channel_type,
|
|
status: conversation.status,
|
|
created_at: conversation.created_at,
|
|
label_list: conversation.label_list,
|
|
message_count: conversation.messages.chat.count
|
|
}
|
|
end
|
|
end
|