class Family::AutoCategorizer Error = Class.new(StandardError) def initialize(family, transaction_ids: []) @family = family @transaction_ids = transaction_ids end def auto_categorize raise Error, "No LLM provider for auto-categorization" unless llm_provider if scope.none? Rails.logger.info("No transactions to auto-categorize for family #{family.id}") return 0 else Rails.logger.info("Auto-categorizing #{scope.count} transactions for family #{family.id}") end categories_input = user_categories_input if categories_input.empty? Rails.logger.error("Cannot auto-categorize transactions for family #{family.id}: no categories available") return 0 end result = llm_provider.auto_categorize( transactions: transactions_input, user_categories: categories_input, family: family ) unless result.success? Rails.logger.error("Failed to auto-categorize transactions for family #{family.id}: #{result.error.message}") return 0 end modified_count = 0 scope.each do |transaction| auto_categorization = result.data.find { |c| c.transaction_id == transaction.id } category_id = categories_input.find { |c| c[:name] == auto_categorization&.category_name }&.dig(:id) if category_id.present? was_modified = transaction.enrich_attribute( :category_id, category_id, source: "ai" ) transaction.lock_attr!(:category_id) # enrich_attribute returns true if the transaction was actually modified modified_count += 1 if was_modified end end modified_count end private attr_reader :family, :transaction_ids # For now, OpenAI only, but this should work with any LLM concept provider def llm_provider Provider::Registry.get_provider(:openai) end def user_categories_input family.categories.map do |category| { id: category.id, name: category.name, is_subcategory: category.subcategory?, parent_id: category.parent_id, classification: category.classification } end end def transactions_input scope.map do |transaction| { id: transaction.id, amount: transaction.entry.amount.abs, classification: transaction.entry.classification, description: [ transaction.entry.name, transaction.entry.notes ].compact.reject(&:empty?).join(" "), merchant: transaction.merchant&.name } end end def scope family.transactions.where(id: transaction_ids, category_id: nil) .enrichable(:category_id) .includes(:category, :merchant, :entry) end end