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 @@