Files
moreminimore-chat/lib/llm/analytics_classifier.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

169 lines
6.2 KiB
Ruby

# Classifies a single conversation via the LLM cascade (Llm::Resolver):
# per-account OpenAI hook (with optional custom base_url) -> Captain -> nil.
#
# Produces a normalized classification used by the daily analytics batch job:
# - topics: free-form topic tags (e.g. "pricing", "installation")
# - products: matched product_catalog_entries (full tag hierarchy, '>' joined)
# - deal: won | lost | undecided
#
# This service only CLASSIFIES; it never writes to the conversation or to the
# daily-metric tables. Writing is the caller's responsibility (the batch job),
# so a failed/disabled classification can never mutate state.
#
# If no LLM is configured for the account, returns { disabled: true } without
# making any network call or sending conversation content anywhere.
module Llm::AnalyticsClassifier
SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
topics: {
type: 'array',
items: { type: 'string' },
description: 'Short topic labels describing what this conversation is about (e.g. pricing, installation, support).'
},
product_indexes: {
type: 'array',
items: { type: 'integer' },
description: 'Indexes (0-based) into the supplied product list that apply to this conversation. Empty if none apply.'
},
deal: {
type: 'string',
enum: %w[won lost undecided],
description: 'Whether this conversation reached a sale decision. won = closed sale, lost = customer declined, undecided = no decision yet.'
}
},
required: %w[topics product_indexes deal]
}.freeze
Result = Struct.new(:topics, :products, :deal, :disabled, :error, keyword_init: true) do
def disabled?
disabled == true
end
def success?
error.nil?
end
end
MODEL = Llm::Config::DEFAULT_MODEL
module_function
# @param account [Account]
# @param conversation [Conversation]
# @return [Llm::AnalyticsClassifier::Result]
def classify(account:, conversation:)
credential = Llm::Resolver.resolve(account)
return disabled_result if credential.nil?
payload = build_payload(account, conversation)
response = call_llm(credential, payload)
build_result(response, payload)
rescue StandardError => e
Rails.logger.error("[AnalyticsClassifier] account=#{account&.id} #{e.class}: #{e.message}")
Result.new(error: e.message)
end
# -- result builders -------------------------------------------------------
def disabled_result
Result.new(topics: [], products: [], deal: 'undecided', disabled: true)
end
def build_result(response, payload)
return Result.new(error: response.dig(:error, :message) || 'classification failed') if response.dig(:error)
parsed = JSON.parse(sanitize_json(response[:message]))
indexes = Array(parsed['product_indexes'])
products = payload[:catalog].values_at(*indexes).compact
Result.new(
topics: Array(parsed['topics']),
products: products,
deal: parsed['deal'] || 'undecided',
disabled: false
)
rescue JSON::ParserError, TypeError
Result.new(error: 'LLM returned an unparsable classification')
end
# Some gateways wrap structured JSON in markdown fences despite response_format
# hints — strip them before parsing (same convention as Captain::ChatResponseHelper).
def sanitize_json(content)
content.to_s.gsub('```json', '').gsub('```', '').strip
end
# -- LLM call --------------------------------------------------------------
def call_llm(credential, payload)
Llm::Config.with_api_key(credential[:api_key], api_base: credential[:api_base]) do |context|
chat = context.chat(model: MODEL).with_schema(SCHEMA)
chat.with_instructions(SYSTEM_PROMPT)
response = chat.ask(payload[:user_prompt])
{ message: response.content }
end
rescue StandardError => e
Rails.logger.error("[AnalyticsClassifier] LLM call failed #{e.class}: #{e.message}")
{ error: { message: e.message } }
end
# -- prompt construction ---------------------------------------------------
def build_payload(account, conversation)
catalog = catalog_entries(account)
{ catalog: catalog, user_prompt: build_user_prompt(catalog, conversation) }
end
# Returns an ordered Array of tag-hierarchy strings (group>subgroup>product)
# aligned with what product_indexes refers to.
def catalog_entries(account)
account.product_catalog_entries
.order(:group_name, :subgroup_name, :product_name)
.map(&:tag_hierarchy)
.map { |parts| parts.join('>') }
end
def build_user_prompt(catalog, conversation)
lines = []
lines << 'Below is a customer service chat transcript.'
lines << ''
lines << "Available products (0-based index, tag hierarchy):"
if catalog.empty?
lines << '(no product catalog configured for this account — return an empty product_indexes)'
end
catalog.each_with_index { |path, i| lines << "#{i}: #{path}" }
lines << ''
lines << 'Transcript:'
lines << transcript(conversation)
lines.join("\n")
end
def transcript(conversation)
conversation.messages
.where(message_type: %i[incoming outgoing])
.where(private: false)
.order(:id)
.inject([]) do |acc, message|
content = message.respond_to?(:content_for_llm) ? message.content_for_llm : message.content
next acc if content.blank?
sender = message.incoming? ? 'Customer' : 'Agent'
acc << "#{sender}: #{content}"
end.join("\n")
end
SYSTEM_PROMPT = <<~PROMPT.freeze
You classify customer service conversations for a business intelligence system.
Read the transcript and return:
- topics: short, specific topic labels (in the language of the conversation).
- product_indexes: the 0-based indexes into the supplied product list that match the
products discussed. Match from the MOST SPECIFIC (lowest) level first. If multiple
products apply, list all. Empty when no catalog product is discussed.
- deal: "won" when a purchase is completed or confirmed, "lost" when the customer
declines or leaves without purchasing, "undecided" when no clear decision was made.
Only return the JSON object described by the schema no extra text.
PROMPT
end