mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-06-30 11:32:18 +00:00
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.
207 lines
8.5 KiB
Ruby
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
|