discourse-ai/lib/utils/ai_staff_action_logger.rb
Keegan George 9be1049de6
DEV: Log AI related configuration to staff action log (#1416)
is update adds logging for changes made in the AI admin panel. When making configuration changes to Embeddings, LLMs, Personas, Tools, or Spam that aren't site setting related, changes will now be logged in Admin > Logs & Screening. This will help admins debug issues related to AI. In this update a helper lib is created called `AiStaffActionLogger` which can be easily used in the future to add logging support for any other admin config we need logged for AI.
2025-06-12 12:39:58 -07:00

207 lines
8.5 KiB
Ruby

# frozen_string_literal: true
module DiscourseAi
module Utils
class AiStaffActionLogger
## Maximum length for text fields before truncation/simplification
MAX_TEXT_LENGTH = 100
def initialize(current_user)
@current_user = current_user
@staff_logger = ::StaffActionLogger.new(current_user)
end
## Logs the creation of an AI entity (LLM model or persona)
## @param entity_type [Symbol] The type of AI entity being created
## @param entity [Object] The entity object being created
## @param field_config [Hash] Configuration for how to handle different entity fields
## @param entity_details [Hash] Additional details about the entity to be logged
def log_creation(entity_type, entity, field_config = {}, entity_details = {})
# Start with provided entity details (id, name, etc.)
# Convert all keys to strings for consistent handling in StaffActionLogger
log_details = {}
# Extract subject for StaffActionLogger.base_attrs
subject =
entity_details[:subject] ||
(entity.respond_to?(:display_name) ? entity.display_name : nil)
# Add the entity details but preserve subject as a top-level attribute
entity_details.each { |k, v| log_details[k.to_s] = v unless k == :subject }
# Extract attributes based on field configuration and ensure string keys
extract_entity_attributes(entity, field_config).each do |key, value|
log_details[key.to_s] = value
end
@staff_logger.log_custom("create_ai_#{entity_type}", log_details.merge(subject: subject))
end
## Logs an update to an AI entity with before/after comparison
## @param entity_type [Symbol] The type of AI entity being updated
## @param entity [Object] The entity object after update
## @param initial_attributes [Hash] The attributes of the entity before update
## @param field_config [Hash] Configuration for how to handle different entity fields
## @param entity_details [Hash] Additional details about the entity to be logged
def log_update(
entity_type,
entity,
initial_attributes,
field_config = {},
entity_details = {}
)
current_attributes = entity.attributes
changes = {}
# Process changes based on field configuration
field_config
.except(:json_fields)
.each do |field, options|
# Skip if field is not to be tracked
next if options[:track] == false
initial_value = initial_attributes[field.to_s]
current_value = current_attributes[field.to_s]
# Only process if there's an actual change
if initial_value != current_value
# Format the change based on field type
changes[field.to_s] = format_field_change(
field,
initial_value,
current_value,
options,
)
end
end
# Process simple JSON fields (arrays, hashes) that should be tracked as "updated"
if field_config[:json_fields].present?
field_config[:json_fields].each do |field|
field_str = field.to_s
if initial_attributes[field_str].to_s != current_attributes[field_str].to_s
changes[field_str] = I18n.t("discourse_ai.ai_staff_action_logger.updated")
end
end
end
# Only log if there are actual changes
if changes.any?
# Extract subject for StaffActionLogger.base_attrs
subject =
entity_details[:subject] ||
(entity.respond_to?(:display_name) ? entity.display_name : nil)
log_details = {}
# Convert entity_details keys to strings, but preserve subject as a top-level attribute
entity_details.each { |k, v| log_details[k.to_s] = v unless k == :subject }
# Merge changes (already with string keys)
log_details.merge!(changes)
@staff_logger.log_custom("update_ai_#{entity_type}", log_details.merge(subject: subject))
end
end
## Logs the deletion of an AI entity
## @param entity_type [Symbol] The type of AI entity being deleted
## @param entity_details [Hash] Details about the entity being deleted
def log_deletion(entity_type, entity_details)
# Extract subject for StaffActionLogger.base_attrs
subject = entity_details[:subject]
# Convert all keys to strings for consistent handling in StaffActionLogger
string_details = {}
entity_details.each { |k, v| string_details[k.to_s] = v unless k == :subject }
@staff_logger.log_custom("delete_ai_#{entity_type}", string_details.merge(subject: subject))
end
## Direct custom logging for complex cases
## @param action_type [String] The type of action being logged
## @param log_details [Hash] Details to be logged
def log_custom(action_type, log_details)
# Extract subject for StaffActionLogger.base_attrs if present
subject = log_details[:subject]
# Convert all keys to strings for consistent handling in StaffActionLogger
string_details = {}
log_details.each { |k, v| string_details[k.to_s] = v unless k == :subject }
@staff_logger.log_custom(action_type, string_details.merge(subject: subject))
end
private
## Formats the change in a field's value for logging
## @param field [Symbol] The field that changed
## @param initial_value [Object] The original value
## @param current_value [Object] The new value
## @param options [Hash] Options for formatting
## @return [String] Formatted representation of the change
def format_field_change(field, initial_value, current_value, options = {})
if options[:type] == :sensitive
return format_sensitive_field_change(initial_value, current_value)
elsif options[:type] == :large_text ||
(initial_value.is_a?(String) && initial_value.length > MAX_TEXT_LENGTH) ||
(current_value.is_a?(String) && current_value.length > MAX_TEXT_LENGTH)
return I18n.t("discourse_ai.ai_staff_action_logger.updated")
end
# Default formatting: "old_value → new_value"
"#{initial_value}#{current_value}"
end
## Formats changes to sensitive fields without exposing actual values
## @param initial_value [Object] The original value
## @param current_value [Object] The new value
## @return [String] Description of the change (updated/set/removed)
def format_sensitive_field_change(initial_value, current_value)
if initial_value.present? && current_value.present?
I18n.t("discourse_ai.ai_staff_action_logger.updated")
elsif current_value.present?
I18n.t("discourse_ai.ai_staff_action_logger.set")
else
I18n.t("discourse_ai.ai_staff_action_logger.removed")
end
end
## Extracts relevant attributes from an entity based on field configuration
## @param entity [Object] The entity to extract attributes from
## @param field_config [Hash] Configuration for how to handle different entity fields
## @return [Hash] The extracted attributes
def extract_entity_attributes(entity, field_config)
result = {}
field_config.each do |field, options|
# Skip special keys like :json_fields which are arrays, not field configurations
next if field == :json_fields
# Skip if options is not a hash or if explicitly marked as not to be extracted
next if !options.is_a?(Hash) || options[:extract] == false
# Get the actual field value
field_sym = field.to_sym
value = entity.respond_to?(field_sym) ? entity.public_send(field_sym) : nil
# Apply field-specific handling
if options[:type] == :sensitive
result[field] = value.present? ? "[FILTERED]" : nil
elsif options[:type] == :large_text && value.is_a?(String) &&
value.length > MAX_TEXT_LENGTH
result[field] = value.truncate(MAX_TEXT_LENGTH)
else
result[field] = value
end
end
# Always include dimensions if it exists on the entity
# This is important for embeddings which are tested for dimensions value
if entity.respond_to?(:dimensions) && !result.key?(:dimensions)
result[:dimensions] = entity.dimensions
end
result
end
end
end
end