From b35f9bcc7c8f028b408c5570ba3450a7ea2771cd Mon Sep 17 00:00:00 2001 From: Roman Rizzi Date: Fri, 27 Jun 2025 10:35:47 -0300 Subject: [PATCH] FEATURE: Use Persona's when scanning posts for spam (#1465) --- .../discourse_ai/admin/ai_spam_controller.rb | 41 ++- app/models/ai_moderation_setting.rb | 14 +- app/serializers/ai_spam_serializer.rb | 15 +- .../discourse/components/ai-spam.gjs | 20 ++ .../stylesheets/modules/llms/common/spam.scss | 2 + config/locales/client.en.yml | 1 + config/locales/server.en.yml | 4 + ...5_add_persona_to_ai_moderation_settings.rb | 6 + lib/ai_moderation/spam_scanner.rb | 239 +++++++++--------- lib/completions/prompt.rb | 8 +- lib/completions/structured_output.rb | 2 +- lib/personas/bot.rb | 11 +- lib/personas/bot_context.rb | 21 +- lib/personas/persona.rb | 1 + lib/personas/spam_detector.rb | 62 +++++ .../ai_moderation/spam_scanner_spec.rb | 10 +- .../requests/admin/ai_spam_controller_spec.rb | 61 ++++- 17 files changed, 375 insertions(+), 143 deletions(-) create mode 100644 db/migrate/20250619105705_add_persona_to_ai_moderation_settings.rb create mode 100644 lib/personas/spam_detector.rb diff --git a/app/controllers/discourse_ai/admin/ai_spam_controller.rb b/app/controllers/discourse_ai/admin/ai_spam_controller.rb index 9b8d72da..19490391 100644 --- a/app/controllers/discourse_ai/admin/ai_spam_controller.rb +++ b/app/controllers/discourse_ai/admin/ai_spam_controller.rb @@ -11,6 +11,13 @@ module DiscourseAi def update initial_settings = AiModerationSetting.spam + + initial_data = { + custom_instructions: initial_settings&.data&.dig("custom_instructions"), + llm_model_id: initial_settings&.llm_model_id, + ai_persona_id: initial_settings&.ai_persona_id, + } + initial_custom_instructions = initial_settings&.data&.dig("custom_instructions") initial_llm_model_id = initial_settings&.llm_model_id @@ -29,6 +36,22 @@ module DiscourseAi ) end end + + if allowed_params.key?(:ai_persona_id) + updated_params[:ai_persona_id] = allowed_params[:ai_persona_id] + persona = AiPersona.find_by(id: allowed_params[:ai_persona_id]) + if persona.nil? || + persona.response_format.to_a.none? { |rf| + rf["key"] == "spam" && rf["type"] == "boolean" + } + return( + render_json_error( + I18n.t("discourse_ai.llm.configuration.invalid_persona_response_format"), + status: 422, + ) + ) + end + end updated_params[:data] = { custom_instructions: allowed_params[:custom_instructions], } if allowed_params.key?(:custom_instructions) @@ -41,7 +64,7 @@ module DiscourseAi AiModerationSetting.create!(updated_params.merge(setting_type: :spam)) end - log_ai_spam_update(initial_llm_model_id, initial_custom_instructions, allowed_params) + log_ai_spam_update(initial_data, allowed_params) end is_enabled = ActiveModel::Type::Boolean.new.cast(allowed_params[:is_enabled]) @@ -119,9 +142,10 @@ module DiscourseAi private - def log_ai_spam_update(initial_llm_model_id, initial_custom_instructions, params) + def log_ai_spam_update(initial_data, params) changes_to_log = {} + initial_llm_model_id = initial_data[:llm_model_id] 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 @@ -131,11 +155,22 @@ module DiscourseAi changes_to_log[:llm_model_id] = "#{old_model_name} → #{new_model_name}" end + initial_custom_instructions = initial_data[:custom_instructions] if params.key?(:custom_instructions) && initial_custom_instructions != params[:custom_instructions] changes_to_log[:custom_instructions] = params[:custom_instructions] end + initial_ai_persona_id = initial_data[:ai_persona_id] + if params.key?(:ai_persona_id) && initial_ai_persona_id.to_s != params[:ai_persona_id].to_s + old_persona_name = + AiPersona.find_by(id: initial_ai_persona_id)&.name || initial_ai_persona_id + new_persona_name = + AiPersona.find_by(id: params[:ai_persona_id])&.name || params[:ai_persona_id] + + changes_to_log[:ai_persona_id] = "#{old_persona_name} → #{new_persona_name}" + 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) @@ -144,7 +179,7 @@ module DiscourseAi end def allowed_params - params.permit(:is_enabled, :llm_model_id, :custom_instructions) + params.permit(:is_enabled, :llm_model_id, :custom_instructions, :ai_persona_id) end def spam_config diff --git a/app/models/ai_moderation_setting.rb b/app/models/ai_moderation_setting.rb index 8b440725..596ba131 100644 --- a/app/models/ai_moderation_setting.rb +++ b/app/models/ai_moderation_setting.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class AiModerationSetting < ActiveRecord::Base belongs_to :llm_model + belongs_to :ai_persona validates :llm_model_id, presence: true validates :setting_type, presence: true @@ -19,12 +20,13 @@ end # # Table name: ai_moderation_settings # -# id :bigint not null, primary key -# setting_type :enum not null -# data :jsonb -# llm_model_id :bigint not null -# created_at :datetime not null -# updated_at :datetime not null +# id :bigint not null, primary key +# setting_type :enum not null +# data :jsonb +# llm_model_id :bigint not null +# created_at :datetime not null +# updated_at :datetime not null +# ai_persona_id :bigint default(-31), not null # # Indexes # diff --git a/app/serializers/ai_spam_serializer.rb b/app/serializers/ai_spam_serializer.rb index 022d1dac..dc1212d3 100644 --- a/app/serializers/ai_spam_serializer.rb +++ b/app/serializers/ai_spam_serializer.rb @@ -8,7 +8,9 @@ class AiSpamSerializer < ApplicationSerializer :stats, :flagging_username, :spam_score_type, - :spam_scanning_user + :spam_scanning_user, + :ai_persona_id, + :available_personas def is_enabled object[:enabled] @@ -18,6 +20,11 @@ class AiSpamSerializer < ApplicationSerializer settings&.llm_model&.id end + def ai_persona_id + settings&.ai_persona&.id || + DiscourseAi::Personas::Persona.system_personas[DiscourseAi::Personas::SpamDetector] + end + def custom_instructions settings&.custom_instructions end @@ -28,6 +35,12 @@ class AiSpamSerializer < ApplicationSerializer .map { |hash| { id: hash[:value], name: hash[:name] } } end + def available_personas + DiscourseAi::Configuration::PersonaEnumerator.values.map do |h| + { id: h[:value], name: h[:name] } + end + end + def flagging_username object[:flagging_username] end diff --git a/assets/javascripts/discourse/components/ai-spam.gjs b/assets/javascripts/discourse/components/ai-spam.gjs index 5c6d883a..068c715e 100644 --- a/assets/javascripts/discourse/components/ai-spam.gjs +++ b/assets/javascripts/discourse/components/ai-spam.gjs @@ -35,6 +35,7 @@ export default class AiSpam extends Component { }; @tracked isEnabled = false; @tracked selectedLLM = null; + @tracked selectedPersonaId = null; @tracked customInstructions = ""; @tracked errors = []; @@ -98,6 +99,7 @@ export default class AiSpam extends Component { } this.customInstructions = model.custom_instructions; this.stats = model.stats; + this.selectedPersonaId = model.ai_persona_id; } get availableLLMs() { @@ -133,6 +135,11 @@ export default class AiSpam extends Component { this.selectedLLM = value; } + @action + async updatePersona(value) { + this.selectedPersonaId = value; + } + @action async save() { try { @@ -141,6 +148,7 @@ export default class AiSpam extends Component { data: { llm_model_id: this.llmId, custom_instructions: this.customInstructions, + ai_persona_id: this.selectedPersonaId, }, }); this.toasts.success({ @@ -256,6 +264,18 @@ export default class AiSpam extends Component { {{/if}} +
+ + +
+