# 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