Files
moreminimore-chat/app/services/analytics/drilldown_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

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