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)
169 lines
6.2 KiB
Ruby
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
|