diff --git a/app/components/DS/menu_item.rb b/app/components/DS/menu_item.rb index 5b099e7af2b..5aa3959b376 100644 --- a/app/components/DS/menu_item.rb +++ b/app/components/DS/menu_item.rb @@ -50,7 +50,8 @@ def merged_opts data = merged_opts.delete(:data) || {} if confirm.present? - data = data.merge(turbo_confirm: confirm.to_data_attribute) + confirm_value = confirm.respond_to?(:to_data_attribute) ? confirm.to_data_attribute : confirm + data = data.merge(turbo_confirm: confirm_value) end if frame.present? diff --git a/app/controllers/rules_controller.rb b/app/controllers/rules_controller.rb index 41a5cce64e2..8c260365524 100644 --- a/app/controllers/rules_controller.rb +++ b/app/controllers/rules_controller.rb @@ -78,6 +78,11 @@ def destroy_all redirect_to rules_path, notice: "All rules deleted" end + def clear_ai_cache + ClearAiCacheJob.perform_later + redirect_to rules_path, notice: "AI cache is being cleared. This may take a few moments." + end + private def set_rule @rule = Current.family.rules.find(params[:id]) diff --git a/app/jobs/clear_ai_cache_job.rb b/app/jobs/clear_ai_cache_job.rb new file mode 100644 index 00000000000..867ad2ea26d --- /dev/null +++ b/app/jobs/clear_ai_cache_job.rb @@ -0,0 +1,15 @@ +class ClearAiCacheJob < ApplicationJob + queue_as :low_priority + + def perform + Rails.logger.info("Clearing AI cache for all transactions and entries") + + # Clear AI cache for all transactions + Transaction.clear_ai_cache + Rails.logger.info("Cleared AI cache for transactions") + + # Clear AI cache for all entries + Entry.clear_ai_cache + Rails.logger.info("Cleared AI cache for entries") + end +end diff --git a/app/models/concerns/enrichable.rb b/app/models/concerns/enrichable.rb index be813066de0..2f1415ccd6a 100644 --- a/app/models/concerns/enrichable.rb +++ b/app/models/concerns/enrichable.rb @@ -15,6 +15,8 @@ module Enrichable InvalidAttributeError = Class.new(StandardError) included do + has_many :data_enrichments, as: :enrichable, dependent: :destroy + scope :enrichable, ->(attrs) { attrs = Array(attrs).map(&:to_s) json_condition = attrs.each_with_object({}) { |attr, hash| hash[attr] = true } @@ -22,6 +24,37 @@ module Enrichable } end + class_methods do + # Clear AI-sourced locked attributes for all records + def clear_ai_cache + transaction do + # Find all AI enrichments for this model + ai_enrichments = DataEnrichment.where(enrichable_type: name, source: "ai") + + # Get all enrichable_ids and load records in one query + enrichable_ids = ai_enrichments.distinct.pluck(:enrichable_id) + records = where(id: enrichable_ids).index_by(&:id) + enrichments_by_id = ai_enrichments.group_by(&:enrichable_id) + + enrichments_by_id.each do |enrichable_id, enrichments| + record = records[enrichable_id] + next unless record + + # Unlock all AI-locked attributes + new_locked_attributes = record.locked_attributes.dup + enrichments.each do |enrichment| + new_locked_attributes.delete(enrichment.attribute_name) + end + + record.update_column(:locked_attributes, new_locked_attributes) + end + + # Delete all AI enrichment records + ai_enrichments.delete_all + end + end + end + # Convenience method for a single attribute def enrich_attribute(attr, value, source:, metadata: {}) enrich_attributes({ attr => value }, source:, metadata:) @@ -72,6 +105,25 @@ def lock_saved_attributes! end end + # Clear AI-sourced locked attributes for this record + def clear_ai_cache + self.class.transaction do + # Find all AI enrichments for this record + ai_enrichments = data_enrichments.where(source: "ai") + + # Unlock all AI-locked attributes + new_locked_attributes = locked_attributes.dup + ai_enrichments.each do |enrichment| + new_locked_attributes.delete(enrichment.attribute_name) + end + + update_column(:locked_attributes, new_locked_attributes) + + # Delete all AI enrichment records + ai_enrichments.delete_all + end + end + private def log_enrichment(attribute_name:, attribute_value:, source:, metadata: {}) de = DataEnrichment.find_or_create_by( diff --git a/app/models/family/auto_categorizer.rb b/app/models/family/auto_categorizer.rb index 7e74817ce9e..d7e462bf70a 100644 --- a/app/models/family/auto_categorizer.rb +++ b/app/models/family/auto_categorizer.rb @@ -38,9 +38,8 @@ def auto_categorize category_id, source: "ai" ) + transaction.lock_attr!(:category_id) end - - transaction.lock_attr!(:category_id) end end diff --git a/app/models/family/auto_merchant_detector.rb b/app/models/family/auto_merchant_detector.rb index 96bfa414495..7831212692e 100644 --- a/app/models/family/auto_merchant_detector.rb +++ b/app/models/family/auto_merchant_detector.rb @@ -50,11 +50,9 @@ def auto_detect merchant_id, source: "ai" ) - + # We lock the attribute so that this Rule doesn't try to run again + transaction.lock_attr!(:merchant_id) end - - # We lock the attribute so that this Rule doesn't try to run again - transaction.lock_attr!(:merchant_id) end end diff --git a/app/models/setting.rb b/app/models/setting.rb index 043b1789594..2bcf9d198ab 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -35,6 +35,7 @@ def self.validate_onboarding_state!(state) class << self alias_method :raw_onboarding_state, :onboarding_state alias_method :raw_onboarding_state=, :onboarding_state= + alias_method :raw_openai_model=, :openai_model= def onboarding_state value = raw_onboarding_state @@ -49,6 +50,17 @@ def onboarding_state=(state) self.raw_onboarding_state = state end + def openai_model=(value) + old_value = openai_model + self.raw_openai_model = value + + # Clear AI cache when model changes + if old_value != value && old_value.present? + Rails.logger.info("OpenAI model changed from #{old_value} to #{value}, clearing AI cache") + ClearAiCacheJob.perform_later + end + end + # Support dynamic field access via bracket notation # First checks if it's a declared field, then falls back to dynamic_fields hash def [](key) diff --git a/app/views/rules/index.html.erb b/app/views/rules/index.html.erb index 49062f69184..cefabeb0eac 100644 --- a/app/views/rules/index.html.erb +++ b/app/views/rules/index.html.erb @@ -3,6 +3,17 @@
<% if @rules.any? %> <%= render DS::Menu.new do |menu| %> + <% menu.with_item( + variant: "button", + text: "Reset AI cache", + href: clear_ai_cache_rules_path, + icon: "refresh-cw", + method: :post, + confirm: CustomConfirm.new( + title: "Reset AI cache?", + body: "Are you sure you want to reset the AI cache? This will allow AI rules to re-process all transactions.", + btn_text: "Reset Cache" + )) %> <% menu.with_item( variant: "button", text: "Delete all rules", diff --git a/config/routes.rb b/config/routes.rb index 72a0abdb7dd..e07471f9365 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -167,6 +167,7 @@ collection do delete :destroy_all + post :clear_ai_cache end end diff --git a/test/models/family/auto_categorizer_test.rb b/test/models/family/auto_categorizer_test.rb index c9440919d50..a8f5bcd2038 100644 --- a/test/models/family/auto_categorizer_test.rb +++ b/test/models/family/auto_categorizer_test.rb @@ -33,8 +33,9 @@ class Family::AutoCategorizerTest < ActiveSupport::TestCase assert_equal test_category, txn2.reload.category assert_nil txn3.reload.category - # After auto-categorization, all transactions are locked and no longer enrichable - assert_equal 0, @account.transactions.reload.enrichable(:category_id).count + # After auto-categorization, only successfully categorized transactions are locked + # txn3 should still be enrichable since it wasn't categorized + assert_equal 1, @account.transactions.reload.enrichable(:category_id).count end private diff --git a/test/models/family/auto_merchant_detector_test.rb b/test/models/family/auto_merchant_detector_test.rb index aa4a56be611..7e962230495 100644 --- a/test/models/family/auto_merchant_detector_test.rb +++ b/test/models/family/auto_merchant_detector_test.rb @@ -34,8 +34,9 @@ class Family::AutoMerchantDetectorTest < ActiveSupport::TestCase assert_equal "https://cdn.brandfetch.io/chipotle.com/icon/fallback/lettermark/w/40/h/40?c=123", txn2.reload.merchant.logo_url assert_nil txn3.reload.merchant - # After auto-detection, all transactions are locked and no longer enrichable - assert_equal 0, @account.transactions.reload.enrichable(:merchant_id).count + # After auto-detection, only successfully detected merchants are locked + # txn3 should still be enrichable since no merchant was detected + assert_equal 1, @account.transactions.reload.enrichable(:merchant_id).count end private