mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-06-30 19:42:17 +00:00
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
|