From 9be1049de6c4d853337a09743392d2e7bf0cc5c1 Mon Sep 17 00:00:00 2001 From: Keegan George Date: Thu, 12 Jun 2025 12:39:58 -0700 Subject: [PATCH] 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. --- .../admin/ai_embeddings_controller.rb | 67 +++ .../discourse_ai/admin/ai_llms_controller.rb | 98 ++++ .../admin/ai_personas_controller.rb | 97 ++++ .../discourse_ai/admin/ai_spam_controller.rb | 30 ++ .../discourse_ai/admin/ai_tools_controller.rb | 66 +++ config/locales/client.en.yml | 16 + config/locales/server.en.yml | 6 + lib/utils/ai_staff_action_logger.rb | 206 ++++++++ spec/lib/utils/ai_staff_action_logger_spec.rb | 480 ++++++++++++++++++ .../admin/ai_embeddings_controller_spec.rb | 69 +++ .../requests/admin/ai_llms_controller_spec.rb | 45 ++ .../admin/ai_personas_controller_spec.rb | 54 ++ .../requests/admin/ai_spam_controller_spec.rb | 84 +++ .../admin/ai_tools_controller_spec.rb | 70 +++ 14 files changed, 1388 insertions(+) create mode 100644 lib/utils/ai_staff_action_logger.rb create mode 100644 spec/lib/utils/ai_staff_action_logger_spec.rb diff --git a/app/controllers/discourse_ai/admin/ai_embeddings_controller.rb b/app/controllers/discourse_ai/admin/ai_embeddings_controller.rb index 66a9c7f3..ceea81af 100644 --- a/app/controllers/discourse_ai/admin/ai_embeddings_controller.rb +++ b/app/controllers/discourse_ai/admin/ai_embeddings_controller.rb @@ -40,6 +40,7 @@ module DiscourseAi embedding_def = EmbeddingDefinition.new(ai_embeddings_params) if embedding_def.save + log_ai_embedding_creation(embedding_def) render json: AiEmbeddingDefinitionSerializer.new(embedding_def), status: :created else render_json_error embedding_def @@ -55,7 +56,10 @@ module DiscourseAi ) end + initial_attributes = embedding_def.attributes.dup + if embedding_def.update(ai_embeddings_params.except(:dimensions)) + log_ai_embedding_update(embedding_def, initial_attributes) render json: AiEmbeddingDefinitionSerializer.new(embedding_def) else render_json_error embedding_def @@ -75,7 +79,16 @@ module DiscourseAi return render_json_error(I18n.t("discourse_ai.embeddings.delete_failed"), status: 409) end + embedding_details = { + embedding_id: embedding_def.id, + display_name: embedding_def.display_name, + provider: embedding_def.provider, + dimensions: embedding_def.dimensions, + subject: embedding_def.display_name, + } + if embedding_def.destroy + log_ai_embedding_deletion(embedding_details) head :no_content else render_json_error embedding_def @@ -128,6 +141,60 @@ module DiscourseAi permitted end + + def ai_embeddings_logger_fields + { + display_name: { + }, + provider: { + }, + dimensions: { + }, + url: { + }, + tokenizer_class: { + }, + max_sequence_length: { + }, + embed_prompt: { + type: :large_text, + }, + search_prompt: { + type: :large_text, + }, + matryoshka_dimensions: { + }, + api_key: { + type: :sensitive, + }, + # JSON fields should be tracked as simple changes + json_fields: [:provider_params], + } + end + + def log_ai_embedding_creation(embedding_def) + logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user) + entity_details = { embedding_id: embedding_def.id, subject: embedding_def.display_name } + logger.log_creation("embedding", embedding_def, ai_embeddings_logger_fields, entity_details) + end + + def log_ai_embedding_update(embedding_def, initial_attributes) + logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user) + entity_details = { embedding_id: embedding_def.id, subject: embedding_def.display_name } + + logger.log_update( + "embedding", + embedding_def, + initial_attributes, + ai_embeddings_logger_fields, + entity_details, + ) + end + + def log_ai_embedding_deletion(embedding_details) + logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user) + logger.log_deletion("embedding", embedding_details) + end end end end diff --git a/app/controllers/discourse_ai/admin/ai_llms_controller.rb b/app/controllers/discourse_ai/admin/ai_llms_controller.rb index 84109220..68cb24c7 100644 --- a/app/controllers/discourse_ai/admin/ai_llms_controller.rb +++ b/app/controllers/discourse_ai/admin/ai_llms_controller.rb @@ -47,6 +47,7 @@ module DiscourseAi if llm_model.save llm_model.toggle_companion_user + log_llm_model_creation(llm_model) render json: LlmModelSerializer.new(llm_model), status: :created else render_json_error llm_model @@ -56,6 +57,10 @@ module DiscourseAi def update llm_model = LlmModel.find(params[:id]) + # Capture initial state for logging + initial_attributes = llm_model.attributes.dup + initial_quotas = llm_model.llm_quotas.map(&:attributes) + if params[:ai_llm].key?(:llm_quotas) if quota_params existing_quota_group_ids = llm_model.llm_quotas.pluck(:group_id) @@ -81,6 +86,7 @@ module DiscourseAi if llm_model.update(ai_llm_params(updating: llm_model)) llm_model.toggle_companion_user + log_llm_model_update(llm_model, initial_attributes, initial_quotas) render json: LlmModelSerializer.new(llm_model) else render_json_error llm_model @@ -109,11 +115,20 @@ module DiscourseAi ) end + # Capture model details for logging before destruction + model_details = { + model_id: llm_model.id, + display_name: llm_model.display_name, + name: llm_model.name, + provider: llm_model.provider, + } + # Clean up companion users llm_model.enabled_chat_bot = false llm_model.toggle_companion_user if llm_model.destroy + log_llm_model_deletion(model_details) head :no_content else render_json_error llm_model @@ -190,6 +205,89 @@ module DiscourseAi permitted end + + def ai_llm_logger_fields + { + display_name: { + }, + name: { + }, + provider: { + }, + tokenizer: { + }, + url: { + }, + max_prompt_tokens: { + }, + max_output_tokens: { + }, + enabled_chat_bot: { + }, + vision_enabled: { + }, + api_key: { + type: :sensitive, + }, + input_cost: { + }, + output_cost: { + }, + # JSON fields should be tracked as simple changes + json_fields: [:provider_params], + } + end + + def log_llm_model_creation(llm_model) + logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user) + entity_details = { model_id: llm_model.id, subject: llm_model.display_name } + + # Add quota information as a special case + if llm_model.llm_quotas.any? + entity_details[:quotas] = llm_model + .llm_quotas + .map do |quota| + "Group #{quota.group_id}: #{quota.max_tokens} tokens, #{quota.max_usages} usages, #{quota.duration_seconds}s" + end + .join("; ") + end + + logger.log_creation("llm_model", llm_model, ai_llm_logger_fields, entity_details) + end + + def log_llm_model_update(llm_model, initial_attributes, initial_quotas) + logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user) + entity_details = { model_id: llm_model.id, subject: llm_model.display_name } + + # Track quota changes separately as they're a special case + current_quotas = llm_model.llm_quotas.reload.map(&:attributes) + if initial_quotas != current_quotas + initial_quota_summary = + initial_quotas + .map { |q| "Group #{q["group_id"]}: #{q["max_tokens"]} tokens" } + .join("; ") + current_quota_summary = + current_quotas + .map { |q| "Group #{q["group_id"]}: #{q["max_tokens"]} tokens" } + .join("; ") + entity_details[:quotas_changed] = true + entity_details[:quotas] = "#{initial_quota_summary} → #{current_quota_summary}" + end + + logger.log_update( + "llm_model", + llm_model, + initial_attributes, + ai_llm_logger_fields, + entity_details, + ) + end + + def log_llm_model_deletion(model_details) + logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user) + model_details[:subject] = model_details[:display_name] + logger.log_deletion("llm_model", model_details) + end end end end diff --git a/app/controllers/discourse_ai/admin/ai_personas_controller.rb b/app/controllers/discourse_ai/admin/ai_personas_controller.rb index a63e7b1d..ec4e19e1 100644 --- a/app/controllers/discourse_ai/admin/ai_personas_controller.rb +++ b/app/controllers/discourse_ai/admin/ai_personas_controller.rb @@ -61,6 +61,7 @@ module DiscourseAi ai_persona = AiPersona.new(ai_persona_params.except(:rag_uploads)) if ai_persona.save RagDocumentFragment.link_target_and_uploads(ai_persona, attached_upload_ids) + log_ai_persona_creation(ai_persona) render json: { ai_persona: LocalizedAiPersonaSerializer.new(ai_persona, root: false), @@ -77,8 +78,11 @@ module DiscourseAi end def update + initial_attributes = @ai_persona.attributes.dup + if @ai_persona.update(ai_persona_params.except(:rag_uploads)) RagDocumentFragment.update_target_uploads(@ai_persona, attached_upload_ids) + log_ai_persona_update(@ai_persona, initial_attributes) render json: LocalizedAiPersonaSerializer.new(@ai_persona, root: false) else @@ -87,7 +91,14 @@ module DiscourseAi end def destroy + persona_details = { + persona_id: @ai_persona.id, + name: @ai_persona.name, + description: @ai_persona.description, + } + if @ai_persona.destroy + log_ai_persona_deletion(persona_details) head :no_content else render_json_error @ai_persona @@ -264,6 +275,92 @@ module DiscourseAi examples.map { |example_arr| example_arr.take(2).map(&:to_s) } end + + def ai_persona_logger_fields + { + name: { + }, + description: { + }, + enabled: { + }, + priority: { + }, + system_prompt: { + type: :large_text, + }, + default_llm_id: { + }, + temperature: { + }, + top_p: { + }, + user_id: { + }, + max_context_posts: { + }, + vision_enabled: { + }, + vision_max_pixels: { + }, + rag_chunk_tokens: { + }, + rag_chunk_overlap_tokens: { + }, + rag_conversation_chunks: { + }, + rag_llm_model_id: { + }, + question_consolidator_llm_id: { + }, + allow_chat_channel_mentions: { + }, + allow_chat_direct_messages: { + }, + allow_topic_mentions: { + }, + allow_personal_messages: { + }, + tool_details: { + type: :large_text, + }, + forced_tool_count: { + }, + force_default_llm: { + }, + # JSON fields + json_fields: %i[tools response_format examples allowed_group_ids], + } + end + + def log_ai_persona_creation(ai_persona) + logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user) + entity_details = { persona_id: ai_persona.id, subject: ai_persona.name } + entity_details[:tools_count] = (ai_persona.tools || []).size + + logger.log_creation("persona", ai_persona, ai_persona_logger_fields, entity_details) + end + + def log_ai_persona_update(ai_persona, initial_attributes) + logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user) + entity_details = { persona_id: ai_persona.id, subject: ai_persona.name } + entity_details[:tools_count] = ai_persona.tools.size if ai_persona.tools.present? + + logger.log_update( + "persona", + ai_persona, + initial_attributes, + ai_persona_logger_fields, + entity_details, + ) + end + + def log_ai_persona_deletion(persona_details) + logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user) + persona_details[:subject] = persona_details[:name] + + logger.log_deletion("persona", persona_details) + end end end end diff --git a/app/controllers/discourse_ai/admin/ai_spam_controller.rb b/app/controllers/discourse_ai/admin/ai_spam_controller.rb index 52ef6f4b..9b8d72da 100644 --- a/app/controllers/discourse_ai/admin/ai_spam_controller.rb +++ b/app/controllers/discourse_ai/admin/ai_spam_controller.rb @@ -10,6 +10,10 @@ module DiscourseAi end def update + initial_settings = AiModerationSetting.spam + initial_custom_instructions = initial_settings&.data&.dig("custom_instructions") + initial_llm_model_id = initial_settings&.llm_model_id + updated_params = {} if allowed_params.key?(:llm_model_id) llm_model_id = updated_params[:llm_model_id] = allowed_params[:llm_model_id] @@ -36,6 +40,8 @@ module DiscourseAi else AiModerationSetting.create!(updated_params.merge(setting_type: :spam)) end + + log_ai_spam_update(initial_llm_model_id, initial_custom_instructions, allowed_params) end is_enabled = ActiveModel::Type::Boolean.new.cast(allowed_params[:is_enabled]) @@ -113,6 +119,30 @@ module DiscourseAi private + def log_ai_spam_update(initial_llm_model_id, initial_custom_instructions, params) + changes_to_log = {} + + if params.key?(:llm_model_id) && initial_llm_model_id.to_s != params[:llm_model_id].to_s + old_model_name = + LlmModel.find_by(id: initial_llm_model_id)&.display_name || initial_llm_model_id + new_model_name = + LlmModel.find_by(id: params[:llm_model_id])&.display_name || params[:llm_model_id] + + changes_to_log[:llm_model_id] = "#{old_model_name} → #{new_model_name}" + end + + if params.key?(:custom_instructions) && + initial_custom_instructions != params[:custom_instructions] + changes_to_log[:custom_instructions] = params[:custom_instructions] + end + + if changes_to_log.present? + changes_to_log[:subject] = I18n.t("discourse_ai.spam_detection.logging_subject") + logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user) + logger.log_custom("update_ai_spam_settings", changes_to_log) + end + end + def allowed_params params.permit(:is_enabled, :llm_model_id, :custom_instructions) end diff --git a/app/controllers/discourse_ai/admin/ai_tools_controller.rb b/app/controllers/discourse_ai/admin/ai_tools_controller.rb index 6c148f1a..e4763278 100644 --- a/app/controllers/discourse_ai/admin/ai_tools_controller.rb +++ b/app/controllers/discourse_ai/admin/ai_tools_controller.rb @@ -25,6 +25,7 @@ module DiscourseAi if ai_tool.save RagDocumentFragment.link_target_and_uploads(ai_tool, attached_upload_ids) + log_ai_tool_creation(ai_tool) render_serialized(ai_tool, AiCustomToolSerializer, status: :created) else render_json_error ai_tool @@ -32,8 +33,11 @@ module DiscourseAi end def update + initial_attributes = @ai_tool.attributes.dup + if @ai_tool.update(ai_tool_params) RagDocumentFragment.update_target_uploads(@ai_tool, attached_upload_ids) + log_ai_tool_update(@ai_tool, initial_attributes) render_serialized(@ai_tool, AiCustomToolSerializer) else render_json_error @ai_tool @@ -41,7 +45,15 @@ module DiscourseAi end def destroy + tool_logger_details = { + tool_id: @ai_tool.id, + name: @ai_tool.name, + tool_name: @ai_tool.tool_name, + subject: @ai_tool.name, + } + if @ai_tool.destroy + log_ai_tool_deletion(tool_logger_details) head :no_content else render_json_error @ai_tool @@ -96,6 +108,60 @@ module DiscourseAi ) .except(:rag_uploads) end + + def ai_tool_logger_fields + { + name: { + }, + tool_name: { + }, + description: { + }, + summary: { + }, + enabled: { + }, + rag_chunk_tokens: { + }, + rag_chunk_overlap_tokens: { + }, + rag_llm_model_id: { + }, + script: { + type: :large_text, + }, + parameters: { + type: :large_text, + }, + } + end + + def log_ai_tool_creation(ai_tool) + logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user) + + entity_details = { tool_id: ai_tool.id, subject: ai_tool.name } + entity_details[:parameter_count] = ai_tool.parameters.size if ai_tool.parameters.present? + + logger.log_creation("tool", ai_tool, ai_tool_logger_fields, entity_details) + end + + def log_ai_tool_update(ai_tool, initial_attributes) + logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user) + entity_details = { tool_id: ai_tool.id, subject: ai_tool.name } + + logger.log_update( + "tool", + ai_tool, + initial_attributes, + ai_tool_logger_fields, + entity_details, + ) + end + + def log_ai_tool_deletion(tool_details) + logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user) + logger.log_deletion("tool", tool_details) + end end end end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 455d0bc1..ec111cab 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -24,6 +24,22 @@ en: label: "Sort by" tag: label: "Tag" + logs: + staff_actions: + actions: + create_ai_llm_model: "Create LLM model" + update_ai_llm_model: "Update LLM model" + delete_ai_llm_model: "Delete LLM model" + create_ai_persona: "Create AI persona" + update_ai_persona: "Update AI persona" + delete_ai_persona: "Delete AI persona" + create_ai_tool: "Create AI tool" + update_ai_tool: "Update AI tool" + delete_ai_tool: "Delete AI tool" + create_ai_embedding: "Create AI embedding" + update_ai_embedding: "Update AI embedding" + delete_ai_embedding: "Delete AI embedding" + update_ai_spam_settings: "Update AI spam settings" js: discourse_automation: diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index e816125d..073e96a7 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -284,6 +284,7 @@ en: unexpected: "An unexpected error occured" bot_user_update_failed: "Failed to update the spam scanning bot user" configuration_missing: "The AI spam detection configuration is missing. Add configuration in the 'Admin > Plugins > Discourse AI > Spam' before enabling." + logging_subject: "Spam detection" ai_bot: reply_error: "Sorry, it looks like our system encountered an unexpected issue while trying to reply.\n\n[details='Error details']\n%{details}\n[/details]" @@ -585,6 +586,11 @@ en: missing_provider_param: "%{param} can't be blank" bedrock_invalid_url: "Please complete all the fields to use this model." + ai_staff_action_logger: + updated: "updated" + set: "set" + removed: "removed" + errors: quota_exceeded: "You have exceeded the quota for this model. Please try again in %{relative_time}." quota_required: "You must specify maximum tokens or usages for this model" diff --git a/lib/utils/ai_staff_action_logger.rb b/lib/utils/ai_staff_action_logger.rb new file mode 100644 index 00000000..f6ff4213 --- /dev/null +++ b/lib/utils/ai_staff_action_logger.rb @@ -0,0 +1,206 @@ +# 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 diff --git a/spec/lib/utils/ai_staff_action_logger_spec.rb b/spec/lib/utils/ai_staff_action_logger_spec.rb new file mode 100644 index 00000000..ce981d01 --- /dev/null +++ b/spec/lib/utils/ai_staff_action_logger_spec.rb @@ -0,0 +1,480 @@ +# frozen_string_literal: true + +RSpec.describe DiscourseAi::Utils::AiStaffActionLogger do + fab!(:admin) + fab!(:llm_model) + fab!(:ai_persona) + fab!(:group) + + subject { described_class.new(admin) } + + describe "#log_creation" do + it "logs creation of an entity with field configuration" do + staff_action_logger = instance_double(StaffActionLogger) + allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) + allow(staff_action_logger).to receive(:log_custom) + + # Create field configuration + field_config = { name: {}, provider: {}, url: {}, api_key: { type: :sensitive } } + + # Create entity details + entity_details = { + model_id: llm_model.id, + model_name: llm_model.name, + display_name: llm_model.display_name, + } + + # Setup model with sensitive data + llm_model.update!(api_key: "secret_key") + + subject.log_creation("llm_model", llm_model, field_config, entity_details) + + expect(staff_action_logger).to have_received(:log_custom).with( + "create_ai_llm_model", + hash_including( + "model_id" => llm_model.id, + "name" => llm_model.name, + "provider" => llm_model.provider, + "url" => llm_model.url, + "api_key" => "[FILTERED]", + ), + ) + end + + it "handles large text fields with type declaration" do + staff_action_logger = instance_double(StaffActionLogger) + allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) + allow(staff_action_logger).to receive(:log_custom) + + # Create a persona with a large system prompt + large_prompt = "a" * 200 + ai_persona.update!(system_prompt: large_prompt) + + # Create entity details + entity_details = { persona_id: ai_persona.id, persona_name: ai_persona.name } + + field_config = { name: {}, description: {}, system_prompt: { type: :large_text } } + + subject.log_creation("persona", ai_persona, field_config, entity_details) + + # Verify with have_received + expect(staff_action_logger).to have_received(:log_custom).with( + "create_ai_persona", + hash_including( + "persona_id" => ai_persona.id, + "name" => ai_persona.name, + "system_prompt" => an_instance_of(String), + ), + ) do |action, details| + # Check that system_prompt was truncated + expect(details["system_prompt"].length).to be < 200 + end + end + + it "allows excluding fields from extraction" do + staff_action_logger = instance_double(StaffActionLogger) + allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) + allow(staff_action_logger).to receive(:log_custom) + + field_config = { + name: { + }, + display_name: { + }, + provider: { + extract: false, + }, # Should be excluded + url: { + }, + } + + # Create entity details + entity_details = { + model_id: llm_model.id, + model_name: llm_model.name, + display_name: llm_model.display_name, + } + + subject.log_creation("llm_model", llm_model, field_config, entity_details) + + expect(staff_action_logger).to have_received(:log_custom).with( + "create_ai_llm_model", + hash_including( + "model_id" => llm_model.id, + "name" => llm_model.name, + "display_name" => llm_model.display_name, + "url" => llm_model.url, + ), + ) do |action, details| + # Provider should not be present + expect(details).not_to have_key("provider") + end + end + end + + describe "#log_update" do + it "handles empty arrays and complex JSON properly" do + staff_action_logger = instance_double(StaffActionLogger) + allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) + allow(staff_action_logger).to receive(:log_custom) + + # Setup initial attributes with empty JSON arrays + initial_attributes = { "name" => "Old Name", "allowed_group_ids" => [] } + + # Update with complex JSON + ai_persona.update!(name: "New Name", allowed_group_ids: [group.id, 999]) + + field_config = { name: {}, json_fields: %w[allowed_group_ids] } + + # Create entity details + entity_details = { persona_id: ai_persona.id, persona_name: ai_persona.name } + + subject.log_update("persona", ai_persona, initial_attributes, field_config, entity_details) + + # Verify with have_received + expect(staff_action_logger).to have_received(:log_custom).with( + "update_ai_persona", + hash_including( + "persona_id" => ai_persona.id, + "persona_name" => ai_persona.name, + "name" => "Old Name → New Name", + "allowed_group_ids" => "updated", + ), + ) + end + + it "logs changes to attributes based on field configuration" do + staff_action_logger = instance_double(StaffActionLogger) + allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) + allow(staff_action_logger).to receive(:log_custom) + + initial_attributes = { + "name" => "Old Name", + "display_name" => "Old Display Name", + "provider" => "open_ai", + "api_key" => "old_secret", + } + + llm_model.update!( + name: "New Name", + display_name: "New Display Name", + provider: "anthropic", + api_key: "new_secret", + ) + + field_config = { name: {}, display_name: {}, provider: {}, api_key: { type: :sensitive } } + + # Create entity details + entity_details = { + model_id: llm_model.id, + model_name: llm_model.name, + display_name: llm_model.display_name, + } + + subject.log_update("llm_model", llm_model, initial_attributes, field_config, entity_details) + + # Verify with have_received + expect(staff_action_logger).to have_received(:log_custom).with( + "update_ai_llm_model", + hash_including( + "model_id" => llm_model.id, + "name" => "Old Name → New Name", + "display_name" => "Old Display Name → New Display Name", + "provider" => "open_ai → anthropic", + "api_key" => "updated", # Not showing actual values + ), + ) + end + + it "doesn't log when there are no changes" do + staff_action_logger = instance_double(StaffActionLogger) + allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) + allow(staff_action_logger).to receive(:log_custom) + + initial_attributes = { + "name" => llm_model.name, + "display_name" => llm_model.display_name, + "provider" => llm_model.provider, + } + + field_config = { name: {}, display_name: {}, provider: {} } + + # Create entity details + entity_details = { + model_id: llm_model.id, + model_name: llm_model.name, + display_name: llm_model.display_name, + } + + subject.log_update("llm_model", llm_model, initial_attributes, field_config, entity_details) + + # Verify log_custom was not called + expect(staff_action_logger).not_to have_received(:log_custom) + end + + it "handles fields marked as not to be tracked" do + staff_action_logger = instance_double(StaffActionLogger) + allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) + allow(staff_action_logger).to receive(:log_custom) + + initial_attributes = { + "name" => "Old Name", + "display_name" => "Old Display Name", + "provider" => "open_ai", + } + + llm_model.update!(name: "New Name", display_name: "New Display Name", provider: "anthropic") + + field_config = { + name: { + }, + display_name: { + }, + provider: { + track: false, + }, # Should not be tracked even though it changed + } + + # Create entity details + entity_details = { + model_id: llm_model.id, + model_name: llm_model.name, + display_name: llm_model.display_name, + } + + subject.log_update("llm_model", llm_model, initial_attributes, field_config, entity_details) + + # Provider should not appear in the logged changes + expect(staff_action_logger).to have_received(:log_custom).with( + "update_ai_llm_model", + hash_including( + "model_id" => llm_model.id, + "name" => "Old Name → New Name", + "display_name" => "Old Display Name → New Display Name", + ), + ) do |action, details| + expect(details).not_to have_key("provider") + end + end + + it "handles json fields properly" do + staff_action_logger = instance_double(StaffActionLogger) + allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) + allow(staff_action_logger).to receive(:log_custom) + + # Setup initial attributes with JSON fields + initial_attributes = { + "name" => "Old Name", + "tools" => [["search", { "base_query" => "test" }, true]], + } + + # Update with different JSON + ai_persona.update!( + name: "New Name", + tools: [["search", { "base_query" => "updated" }, true], ["categories", {}, false]], + ) + + field_config = { name: {}, json_fields: %w[tools] } + + # Create entity details + entity_details = { persona_id: ai_persona.id, persona_name: ai_persona.name } + + subject.log_update("persona", ai_persona, initial_attributes, field_config, entity_details) + + # Verify with have_received + expect(staff_action_logger).to have_received(:log_custom).with( + "update_ai_persona", + hash_including( + "persona_id" => ai_persona.id, + "persona_name" => ai_persona.name, + "name" => "Old Name → New Name", + "tools" => "updated", + ), + ) + end + end + + describe "#log_deletion" do + it "logs deletion with the correct entity type" do + staff_action_logger = instance_double(StaffActionLogger) + allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) + allow(staff_action_logger).to receive(:log_custom) + + details = { + model_id: llm_model.id, + display_name: llm_model.display_name, + name: llm_model.name, + } + + subject.log_deletion("llm_model", details) + + # Verify with have_received + expect(staff_action_logger).to have_received(:log_custom).with( + "delete_ai_llm_model", + hash_including( + "model_id" => details[:model_id], + "display_name" => details[:display_name], + "name" => details[:name], + ), + ) + end + end + + describe "#log_custom" do + it "delegates to StaffActionLogger#log_custom" do + staff_action_logger = instance_double(StaffActionLogger) + allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger) + allow(staff_action_logger).to receive(:log_custom) + + details = { key: "value" } + + subject.log_custom("custom_action_type", details) + + # Verify with have_received + expect(staff_action_logger).to have_received(:log_custom).with( + "custom_action_type", + hash_including("key" => details[:key]), + ) + end + end + + describe "Special cases from controllers" do + context "with EmbeddingDefinition" do + fab!(:embedding_definition) { + Fabricate( + :embedding_definition, + display_name: "Test Embedding", + dimensions: 768, + provider: "open_ai" + ) + } + + it "includes dimensions in logged data" do + # Setup + staff_logger = instance_double(StaffActionLogger) + allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_logger) + allow(staff_logger).to receive(:log_custom) + + # Create entity details + entity_details = { embedding_id: embedding_definition.id, subject: embedding_definition.display_name } + + # Field config without dimensions + field_config = { + display_name: {}, + provider: {}, + url: {} + } + + logger = DiscourseAi::Utils::AiStaffActionLogger.new(admin) + logger.log_creation("embedding", embedding_definition, field_config, entity_details) + + # Verify with have_received + expect(staff_logger).to have_received(:log_custom).with( + "create_ai_embedding", + hash_including("dimensions" => 768) + ) + end + end + + context "with LlmModel quotas" do + before do + # Create a quota for the model + @quota = Fabricate(:llm_quota, llm_model: llm_model, group: group, max_tokens: 1000) + end + + it "handles quota changes in log_llm_model_creation" do + # Setup + staff_logger = instance_double(StaffActionLogger) + allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_logger) + allow(staff_logger).to receive(:log_custom) + + # Call the method directly as it would be called from the controller + logger = DiscourseAi::Utils::AiStaffActionLogger.new(admin) + field_config = { display_name: {}, name: {} } + + # Create entity details + entity_details = { + model_id: llm_model.id, + model_name: llm_model.name, + display_name: llm_model.display_name, + } + + log_details = entity_details.dup + log_details.merge!(logger.send(:extract_entity_attributes, llm_model, field_config)) + + # Add quota information as a special case + log_details[:quotas] = llm_model + .llm_quotas + .map do |quota| + "Group #{quota.group_id}: #{quota.max_tokens} tokens, #{quota.max_usages} usages, #{quota.duration_seconds}s" + end + .join("; ") + + logger.log_custom("create_ai_llm_model", log_details) + + # Verify with have_received + expect(staff_logger).to have_received(:log_custom).with( + "create_ai_llm_model", + hash_including( + "model_id" => llm_model.id, + "model_name" => llm_model.name, + "display_name" => llm_model.display_name, + ), + ) + expect(staff_logger).to have_received(:log_custom).with( + "create_ai_llm_model", + hash_including("quotas" => a_string_including("Group #{group.id}", "1000 tokens")), + ) + end + + it "handles quota changes in log_llm_model_update" do + initial_quotas = llm_model.llm_quotas.map(&:attributes) + + # Update the quota + @quota.update!(max_tokens: 2000) + current_quotas = llm_model.llm_quotas.reload.map(&:attributes) + + # Setup + staff_logger = instance_double(StaffActionLogger) + allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_logger) + allow(staff_logger).to receive(:log_custom) + + # Simulate the special quota handling in the controller + logger = DiscourseAi::Utils::AiStaffActionLogger.new(admin) + changes = {} + + # Track quota changes separately as they're a special case + if initial_quotas != current_quotas + initial_quota_summary = + initial_quotas + .map { |q| "Group #{q["group_id"]}: #{q["max_tokens"]} tokens" } + .join("; ") + current_quota_summary = + current_quotas + .map { |q| "Group #{q["group_id"]}: #{q["max_tokens"]} tokens" } + .join("; ") + changes[:quotas] = "#{initial_quota_summary} → #{current_quota_summary}" + end + + # Create entity details + entity_details = { + model_id: llm_model.id, + model_name: llm_model.name, + display_name: llm_model.display_name, + } + + log_details = entity_details.dup.merge(changes) + logger.log_custom("update_ai_llm_model", log_details) + + # Verify with have_received + expect(staff_logger).to have_received(:log_custom).with( + "update_ai_llm_model", + hash_including( + "model_id" => llm_model.id, + "quotas" => a_string_including("1000 tokens", "2000 tokens"), + ), + ) + end + end + end +end diff --git a/spec/requests/admin/ai_embeddings_controller_spec.rb b/spec/requests/admin/ai_embeddings_controller_spec.rb index 40106b5d..d2aeb50f 100644 --- a/spec/requests/admin/ai_embeddings_controller_spec.rb +++ b/spec/requests/admin/ai_embeddings_controller_spec.rb @@ -35,6 +35,30 @@ RSpec.describe DiscourseAi::Admin::AiEmbeddingsController do expect(created_def.matryoshka_dimensions).to eq(true) end + it "logs the creation with StaffActionLogger" do + expect { + post "/admin/plugins/discourse-ai/ai-embeddings.json", + params: { + ai_embedding: valid_attrs, + } + }.to change { + UserHistory.where( + action: UserHistory.actions[:custom_staff], + custom_type: "create_ai_embedding", + ).count + }.by(1) + + history = + UserHistory.where( + action: UserHistory.actions[:custom_staff], + custom_type: "create_ai_embedding", + ).last + expect(history.details).to include("display_name: Embedding config test") + expect(history.details).to include("provider: hugging_face") + expect(history.details).to include("dimensions: 1001") + expect(history.subject).to eq("Embedding config test") # Verify subject field is included + end + it "stores provider-specific config params" do post "/admin/plugins/discourse-ai/ai-embeddings.json", params: { @@ -96,6 +120,28 @@ RSpec.describe DiscourseAi::Admin::AiEmbeddingsController do expect(embedding_definition.reload.provider).to eq(update_attrs[:provider]) end + it "logs the update with StaffActionLogger" do + expect { + put "/admin/plugins/discourse-ai/ai-embeddings/#{embedding_definition.id}.json", + params: { + ai_embedding: update_attrs, + } + }.to change { + UserHistory.where( + action: UserHistory.actions[:custom_staff], + custom_type: "update_ai_embedding", + ).count + }.by(1) + + history = + UserHistory.where( + action: UserHistory.actions[:custom_staff], + custom_type: "update_ai_embedding", + ).last + expect(history.details).to include("embedding_id: #{embedding_definition.id}") + expect(history.subject).to eq(embedding_definition.display_name) # Verify subject field is included + end + it "returns a 404 if there is no model with the given Id" do put "/admin/plugins/discourse-ai/ai-embeddings/9999999.json" @@ -142,6 +188,29 @@ RSpec.describe DiscourseAi::Admin::AiEmbeddingsController do }.to change(EmbeddingDefinition, :count).by(-1) end + it "logs the deletion with StaffActionLogger" do + embedding_id = embedding_definition.id + display_name = embedding_definition.display_name + + expect { + delete "/admin/plugins/discourse-ai/ai-embeddings/#{embedding_definition.id}.json" + }.to change { + UserHistory.where( + action: UserHistory.actions[:custom_staff], + custom_type: "delete_ai_embedding", + ).count + }.by(1) + + history = + UserHistory.where( + action: UserHistory.actions[:custom_staff], + custom_type: "delete_ai_embedding", + ).last + expect(history.details).to include("embedding_id: #{embedding_id}") + expect(history.details).to include("display_name: #{display_name}") + expect(history.subject).to eq(display_name) # Verify subject field is included + end + it "validates the model is not in use" do SiteSetting.ai_embeddings_selected_model = embedding_definition.id diff --git a/spec/requests/admin/ai_llms_controller_spec.rb b/spec/requests/admin/ai_llms_controller_spec.rb index 280204f2..4008069b 100644 --- a/spec/requests/admin/ai_llms_controller_spec.rb +++ b/spec/requests/admin/ai_llms_controller_spec.rb @@ -136,6 +136,17 @@ RSpec.describe DiscourseAi::Admin::AiLlmsController do model = LlmModel.find(created_model["id"]) expect(model.display_name).to eq(valid_attrs[:display_name]) end + + it "logs staff action when creating an LLM model" do + # Log the creation + post "/admin/plugins/discourse-ai/ai-llms.json", params: { ai_llm: valid_attrs } + expect(response.status).to eq(201) + + # Now verify the log was created with the right subject + history = UserHistory.where(action: UserHistory.actions[:custom_staff], custom_type: "create_ai_llm_model").last + expect(history).to be_present + expect(history.subject).to eq(valid_attrs[:display_name]) # Verify subject is set to display_name + end it "creates a companion user" do post "/admin/plugins/discourse-ai/ai-llms.json", @@ -329,6 +340,25 @@ RSpec.describe DiscourseAi::Admin::AiLlmsController do expect(response.status).to eq(200) expect(llm_model.reload.provider).to eq(update_attrs[:provider]) end + + it "logs staff action when updating an LLM model" do + # The initial provider is different from the update + original_provider = llm_model.provider + display_name = llm_model.display_name + + # Perform the update + put "/admin/plugins/discourse-ai/ai-llms/#{llm_model.id}.json", + params: { + ai_llm: update_attrs, + } + + expect(response.status).to eq(200) + + # Now verify the log was created with the right subject + history = UserHistory.where(action: UserHistory.actions[:custom_staff], custom_type: "update_ai_llm_model").last + expect(history).to be_present + expect(history.subject).to eq(display_name) # Verify subject is set to display_name + end it "returns a 404 if there is no model with the given Id" do put "/admin/plugins/discourse-ai/ai-llms/9999999.json" @@ -457,6 +487,21 @@ RSpec.describe DiscourseAi::Admin::AiLlmsController do expect(response).to have_http_status(:no_content) }.to change(LlmModel, :count).by(-1) end + + it "logs staff action when deleting an LLM model" do + # Capture the model details before deletion for comparison + model_id = llm_model.id + model_display_name = llm_model.display_name + + # Delete the model + delete "/admin/plugins/discourse-ai/ai-llms/#{llm_model.id}.json" + expect(response).to have_http_status(:no_content) + + # Now verify the log was created with the right subject + history = UserHistory.where(action: UserHistory.actions[:custom_staff], custom_type: "delete_ai_llm_model").last + expect(history).to be_present + expect(history.subject).to eq(model_display_name) # Verify subject is set to display_name + end it "validates the model is not in use" do fake_llm = assign_fake_provider_to(:ai_helper_model) diff --git a/spec/requests/admin/ai_personas_controller_spec.rb b/spec/requests/admin/ai_personas_controller_spec.rb index 17b7fe34..2f6a12a1 100644 --- a/spec/requests/admin/ai_personas_controller_spec.rb +++ b/spec/requests/admin/ai_personas_controller_spec.rb @@ -223,6 +223,22 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do expect(persona.temperature).to eq(0.5) }.to change(AiPersona, :count).by(1) end + + it "logs staff action when creating a persona" do + # Create the persona + post "/admin/plugins/discourse-ai/ai-personas.json", + params: { ai_persona: valid_attributes }.to_json, + headers: { + "CONTENT_TYPE" => "application/json", + } + + expect(response).to be_successful + + # Now verify the log was created with the right subject + history = UserHistory.where(action: UserHistory.actions[:custom_staff], custom_type: "create_ai_persona").last + expect(history).to be_present + expect(history.subject).to eq("superbot") # Verify subject is set to name + end end context "with invalid params" do @@ -309,6 +325,29 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do expect(persona.top_p).to eq(nil) expect(persona.temperature).to eq(nil) end + + it "logs staff action when updating a persona" do + persona = Fabricate(:ai_persona, name: "original_name", description: "original description") + + # Update the persona + put "/admin/plugins/discourse-ai/ai-personas/#{persona.id}.json", + params: { + ai_persona: { + name: "updated_name", + description: "updated description", + }, + } + + expect(response).to have_http_status(:ok) + persona.reload + expect(persona.name).to eq("updated_name") + expect(persona.description).to eq("updated description") + + # Now verify the log was created with the right subject + history = UserHistory.where(action: UserHistory.actions[:custom_staff], custom_type: "update_ai_persona").last + expect(history).to be_present + expect(history.subject).to eq("updated_name") # Verify subject is set to the new name + end it "supports updating rag params" do persona = Fabricate(:ai_persona, name: "test_bot2") @@ -461,6 +500,21 @@ RSpec.describe DiscourseAi::Admin::AiPersonasController do expect(response).to have_http_status(:no_content) }.to change(AiPersona, :count).by(-1) end + + it "logs staff action when deleting a persona" do + # Capture persona details before deletion + persona_id = ai_persona.id + persona_name = ai_persona.name + + # Delete the persona + delete "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}.json" + expect(response).to have_http_status(:no_content) + + # Now verify the log was created with the right subject + history = UserHistory.where(action: UserHistory.actions[:custom_staff], custom_type: "delete_ai_persona").last + expect(history).to be_present + expect(history.subject).to eq(persona_name) # Verify subject is set to name + end it "is not allowed to delete system personas" do expect { diff --git a/spec/requests/admin/ai_spam_controller_spec.rb b/spec/requests/admin/ai_spam_controller_spec.rb index ccf05af7..26878589 100644 --- a/spec/requests/admin/ai_spam_controller_spec.rb +++ b/spec/requests/admin/ai_spam_controller_spec.rb @@ -119,6 +119,90 @@ RSpec.describe DiscourseAi::Admin::AiSpamController do "custom instructions new", ) end + + it "logs staff action when custom_instructions change" do + put "/admin/plugins/discourse-ai/ai-spam.json", + params: { + is_enabled: true, + llm_model_id: llm_model.id, + custom_instructions: "updated instructions", + } + + expect(response.status).to eq(200) + + history = + UserHistory.where( + action: UserHistory.actions[:custom_staff], + custom_type: "update_ai_spam_settings", + ).last + expect(history).to be_present + expect(history.details).to include("custom_instructions") + end + + it "logs staff action when llm_model_id changes" do + # Create another model to change to + new_llm_model = + Fabricate(:llm_model, name: "New Test Model", display_name: "New Test Model") + + put "/admin/plugins/discourse-ai/ai-spam.json", params: { llm_model_id: new_llm_model.id } + + expect(response.status).to eq(200) + + # Verify the log was created with the right subject + history = + UserHistory.where( + action: UserHistory.actions[:custom_staff], + custom_type: "update_ai_spam_settings", + ).last + expect(history).to be_present + expect(history.details).to include("llm_model_id") + end + + it "does not log staff action when only is_enabled changes" do + # Check initial count of logs + initial_count = + UserHistory.where( + action: UserHistory.actions[:custom_staff], + custom_type: "update_ai_spam_settings", + ).count + + # Update only the is_enabled setting + put "/admin/plugins/discourse-ai/ai-spam.json", params: { is_enabled: false } + + expect(response.status).to eq(200) + + # Verify no new log was created + current_count = + UserHistory.where( + action: UserHistory.actions[:custom_staff], + custom_type: "update_ai_spam_settings", + ).count + expect(current_count).to eq(initial_count) + end + + it "logs both custom_instructions and llm_model_id changes in one entry" do + # Create another model to change to + new_llm_model = + Fabricate(:llm_model, name: "Another Test Model", display_name: "Another Test Model") + + put "/admin/plugins/discourse-ai/ai-spam.json", + params: { + llm_model_id: new_llm_model.id, + custom_instructions: "new instructions for both changes", + } + + expect(response.status).to eq(200) + + # Verify the log was created with all changes + history = + UserHistory.where( + action: UserHistory.actions[:custom_staff], + custom_type: "update_ai_spam_settings", + ).last + expect(history).to be_present + expect(history.details).to include("llm_model_id") + expect(history.details).to include("custom_instructions") + end end end end diff --git a/spec/requests/admin/ai_tools_controller_spec.rb b/spec/requests/admin/ai_tools_controller_spec.rb index b96e5361..afb0a44e 100644 --- a/spec/requests/admin/ai_tools_controller_spec.rb +++ b/spec/requests/admin/ai_tools_controller_spec.rb @@ -72,6 +72,30 @@ RSpec.describe DiscourseAi::Admin::AiToolsController do expect(response.parsed_body["ai_tool"]["tool_name"]).to eq("test_tool_1") end + it "logs the creation with StaffActionLogger" do + expect { + post "/admin/plugins/discourse-ai/ai-tools.json", + params: { ai_tool: valid_attributes }.to_json, + headers: { + "CONTENT_TYPE" => "application/json", + } + }.to change { + UserHistory.where( + action: UserHistory.actions[:custom_staff], + custom_type: "create_ai_tool", + ).count + }.by(1) + + history = + UserHistory.where( + action: UserHistory.actions[:custom_staff], + custom_type: "create_ai_tool", + ).last + expect(history.details).to include("name: Test Tool 1") + expect(history.details).to include("tool_name: test_tool_1") + expect(history.subject).to eq("Test Tool 1") # Verify subject field is included + end + context "when the parameter is a enum" do it "creates the tool with the correct parameters" do attrs = valid_attributes @@ -141,6 +165,33 @@ RSpec.describe DiscourseAi::Admin::AiToolsController do expect(ai_tool.reload.name).to eq("Updated Tool") end + it "logs the update with StaffActionLogger" do + expect { + put "/admin/plugins/discourse-ai/ai-tools/#{ai_tool.id}.json", + params: { + ai_tool: { + name: "Updated Tool", + description: "Updated description", + }, + } + }.to change { + UserHistory.where( + action: UserHistory.actions[:custom_staff], + custom_type: "update_ai_tool", + ).count + }.by(1) + + history = + UserHistory.where( + action: UserHistory.actions[:custom_staff], + custom_type: "update_ai_tool", + ).last + expect(history.details).to include("tool_id: #{ai_tool.id}") + expect(history.details).to include("name") + expect(history.details).to include("description") + expect(history.subject).to eq("Updated Tool") + end + context "when updating an enum parameters" do it "updates the enum fixed values" do put "/admin/plugins/discourse-ai/ai-tools/#{ai_tool.id}.json", @@ -172,6 +223,25 @@ RSpec.describe DiscourseAi::Admin::AiToolsController do expect(response).to have_http_status(:no_content) end + + it "logs the deletion with StaffActionLogger" do + tool_id = ai_tool.id + + expect { delete "/admin/plugins/discourse-ai/ai-tools/#{ai_tool.id}.json" }.to change { + UserHistory.where( + action: UserHistory.actions[:custom_staff], + custom_type: "delete_ai_tool", + ).count + }.by(1) + + history = + UserHistory.where( + action: UserHistory.actions[:custom_staff], + custom_type: "delete_ai_tool", + ).last + expect(history.details).to include("tool_id: #{tool_id}") + expect(history.subject).to eq("Test Tool") # Verify subject field is included + end end describe "#test" do