discourse-ai/lib/utils/ai_staff_action_logger.rb

207 lines
8.5 KiB
Ruby
Raw Permalink Normal View History

# 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