diff --git a/Gemfile b/Gemfile index 41f1bd3..2e4b54e 100644 --- a/Gemfile +++ b/Gemfile @@ -15,7 +15,7 @@ gem "decidim-proposals", "~> 0.29" gem "bootsnap", "~> 1.4" gem "puma", ">= 6.3" - +gem "uri", "1.0.3" group :development, :test do gem "byebug", "~> 11.0", platform: :mri diff --git a/Gemfile.lock b/Gemfile.lock index bc95c31..ecb0394 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -371,6 +371,7 @@ GEM faraday-net_http (3.4.0) net-http (>= 0.5.0) fast-stemmer (1.0.2) + ffi (1.17.1-arm64-darwin) ffi (1.17.1-x86_64-linux-gnu) file_validators (3.0.0) activemodel (>= 3.2) @@ -503,6 +504,8 @@ GEM net-smtp (0.3.4) net-protocol nio4r (2.7.4) + nokogiri (1.16.8-arm64-darwin) + racc (~> 1.4) nokogiri (1.16.8-x86_64-linux) racc (~> 1.4) oauth (1.1.0) @@ -755,7 +758,7 @@ GEM uber (0.1.0) unicode-display_width (2.6.0) uniform_notifier (1.16.0) - uri (1.0.2) + uri (1.0.3) valid_email2 (4.0.6) activemodel (>= 3.2) mail (~> 2.5) @@ -797,6 +800,7 @@ GEM zeitwerk (2.7.2) PLATFORMS + arm64-darwin-22 x86_64-linux-gnu DEPENDENCIES @@ -817,6 +821,7 @@ DEPENDENCIES rubocop-faker rubocop-performance simplecov + uri (= 1.0.3) web-console (~> 4.2) RUBY VERSION diff --git a/README.md b/README.md index 22d6dc2..b1f8d20 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,12 @@ Add the queue name to `config/sidekiq.yml` file: # The other yaml entries ``` +## Configure third-party service + +Documentations and examples are available at : +- [Examples](./examples/) +- [Docs](./docs/) + ## Contributing See [Decidim](https://github.com/decidim/decidim). diff --git a/app/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job.rb b/app/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job.rb index 219af2f..90e218b 100644 --- a/app/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job.rb +++ b/app/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job.rb @@ -8,6 +8,7 @@ class GenericSpamAnalyzerJob < ApplicationJob def perform(reportable, author, locale, fields) @author = author + @organization = reportable.organization overall_score = I18n.with_locale(locale) do fields.map do |field| classifier.classify(translated_attribute(reportable.send(field))) @@ -29,7 +30,7 @@ def form end def reporting_user - @reporting_user ||= Decidim::User.find_by!(email: Decidim::Ai::SpamDetection.reporting_user_email) + @reporting_user ||= Decidim::User.find_by!(email: Decidim::Ai::SpamDetection.reporting_user_email, organization: @organization) end end end diff --git a/app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb b/app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb new file mode 100644 index 0000000..6af117b --- /dev/null +++ b/app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + module ThirdParty + class GenericSpamAnalyzerJob < Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob + def perform(reportable, author, locale, fields) + @author = author + @organization = reportable.organization + klass = reportable.class.to_s + overall_score = I18n.with_locale(locale) do + contents = fields.map do |field| + content = translated_attribute(reportable.send(field)) + if content.present? + "### #{field}:\n#{content}" + else + "" + end + end + + classifier.classify(contents.join("\n"), @organization.host, klass) + classifier.score + end + + return unless overall_score >= Decidim::Ai::SpamDetection.resource_score_threshold + + Decidim::CreateReport.call(form, reportable) + end + end + end + end + end +end diff --git a/app/jobs/decidim/ai/spam_detection/third_party/user_spam_analyzer_job.rb b/app/jobs/decidim/ai/spam_detection/third_party/user_spam_analyzer_job.rb new file mode 100644 index 0000000..a84f3c9 --- /dev/null +++ b/app/jobs/decidim/ai/spam_detection/third_party/user_spam_analyzer_job.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + module ThirdParty + class UserSpamAnalyzerJob < Decidim::Ai::SpamDetection::UserSpamAnalyzerJob + def perform(reportable) + @author = reportable + @organization = reportable.organization + klass = reportable.class.to_s + contents = [ + "### nickname:", + reportable.nickname.to_s, + "### about:", + translated_attribute(reportable.about).to_s, + "### locale:", + reportable.locale.to_s + ] + + if reportable.personal_url.present? + contents << "### personal_url:" + contents << reportable.personal_url.to_s + end + + classifier.classify(contents.join("\n"), @organization.host, klass) + + return unless classifier.score >= Decidim::Ai::SpamDetection.user_score_threshold + + if Decidim::UserModeration.find_by(user: reporting_user).present? + Rails.logger.warn("[decidim-ai] User already moderated: ##{reportable.id} #{reportable.nickname}") + return + end + + Decidim::CreateUserReport.call(form, reportable) + end + end + end + end + end +end diff --git a/app/jobs/decidim/ai/spam_detection/user_spam_analyzer_job.rb b/app/jobs/decidim/ai/spam_detection/user_spam_analyzer_job.rb index f8ef646..0670205 100644 --- a/app/jobs/decidim/ai/spam_detection/user_spam_analyzer_job.rb +++ b/app/jobs/decidim/ai/spam_detection/user_spam_analyzer_job.rb @@ -6,6 +6,7 @@ module SpamDetection class UserSpamAnalyzerJob < GenericSpamAnalyzerJob def perform(reportable) @author = reportable + @organization = reportable.organization classifier.classify(reportable.about) diff --git a/docs/scaleway.md b/docs/scaleway.md new file mode 100644 index 0000000..6883955 --- /dev/null +++ b/docs/scaleway.md @@ -0,0 +1,76 @@ +# Configure a Scaleway Third Party AI system to detect spam + +## Introduction to Scaleway Strategy + +We've added a Scaleway strategy which inherits from the Third Party Strategy to abstract the AI system configuration to a third party service. It reduces considerably the configuration on the Decidim instance by defining only a endpoint and a secret parameters to connect to the Scaleway AI service. + +### How it works + +The Scaleway strategy uses the Scaleway AI service to analyze content for spam detection. It sends the content to the Scaleway endpoint, which processes it and returns a response indicating whether the content is considered spam or not. + +Outputs expected are JSON objects with the following structure: + +```json +{ + "SPAM": "SPAM" +} +``` +or +```json +{ + "SPAM": "NOT_SPAM" +} +``` + +Every time a contribution is made on Decidim, a POST request is sent to a serverless function endpoint. This endpoint retrieve the corresponding prompt based on the resource being analyzed (e.g., proposal, comment, etc.) and the type of analysis (resource or user). And it performs a POST request to the [Scaleway AI service](https://www.scaleway.com/en/docs/generative-apis/concepts/) with the content to be analyzed, the prompt, and the necessary parameters (temperature, top_p, etc…). + +The whole AI specifications prompts, parameters, etc… are defined in a [Langfuse](https://github.com/langfuse/langfuse) self-hosted instance which allows to get metrics on the AI usage and to improve the prompts over time. + +Every Decidim application connected to this system has the same prompts and parameters, which are defined in the Langfuse instance. This allows for a consistent spam detection experience across all applications using this strategy. + +## Infrastructure + +⚠️ We plan to share the Terraform (OpenTofu) project to deploy the serverless endpoint located at : https://github.com/OpenSourcePolitics/serverless/tree/main/faas_ai/infra + + +## Getting started + +To use a third-party AI system to detect spam, you need to configure the `decidim_ai` gem in your Decidim application. This guide will help you set up the necessary configurations. + +## Configure the AI module + +Define a decidim-ai initializer in your application configuration : `config/initializers/decidim_ai.rb`: + +```ruby +# frozen_string_literal: true + +if Decidim.module_installed?(:ai) + analyzers = [ + { + name: :scaleway, + strategy: Decidim::Ai::SpamDetection::Strategy::Scaleway, + options: { + endpoint: Rails.application.secrets.dig(:decidim, :ai, :endpoint), + secret: Rails.application.secrets.dig(:decidim, :ai, :secret), + } + } + ] + + Decidim::Ai::SpamDetection.resource_analyzers = analyzers + Decidim::Ai::SpamDetection.user_analyzers = analyzers +end +``` + +**A full example of configuration is available at examples/scaleway.rb** + + +Add the secrets to your `config/secrets.yml` file: + +```yaml +decidim: + ai: + endpoint: <%= Decidim::Env.new("DECIDIM_AI_ENDPOINT").to_s %> + secret: <%= Decidim::Env.new("DECIDIM_AI_SECRET").to_s %> +``` + +You can now run your server and start using the Scaleway AI service for spam detection ! \ No newline at end of file diff --git a/docs/third_party.md b/docs/third_party.md new file mode 100644 index 0000000..a49a630 --- /dev/null +++ b/docs/third_party.md @@ -0,0 +1,58 @@ +# Configure a Third Party AI system to detect spam + +## Getting started + +To use a third-party AI system to detect spam, you need to configure the `decidim_ai` gem in your Decidim application. This guide will help you set up the necessary configurations. + +## Configure the AI module + +Define a decidim-ai initializer in your application configuration : `config/initializers/decidim_ai.rb`: + +```ruby +# frozen_string_literal: true + +if Decidim.module_installed?(:ai) + analyzers = [ + { + name: :third_party, + strategy: Decidim::Ai::SpamDetection::Strategy::ThirdParty, + options: { + model: Rails.application.secrets.dig(:decidim, :ai, :model), + endpoint: Rails.application.secrets.dig(:decidim, :ai, :endpoint), + secret: Rails.application.secrets.dig(:decidim, :ai, :secret), + max_tokens: Rails.application.secrets.dig(:decidim, :ai, :max_tokens), + temperature: Rails.application.secrets.dig(:decidim, :ai, :temperature), + top_p: Rails.application.secrets.dig(:decidim, :ai, :top_p), + presence_penalty: Rails.application.secrets.dig(:decidim, :ai, :presence_penalty), + stream: Rails.application.secrets.dig(:decidim, :ai, :stream), + system_message: Rails.application.secrets.dig(:decidim, :ai, :system_message), + reporting_user_email: Rails.application.secrets.dig(:decidim, :ai, :reporting_user_email) + } + } + ] + + Decidim::Ai::SpamDetection.resource_analyzers = analyzers + Decidim::Ai::SpamDetection.user_analyzers = analyzers +end +``` + +**A full example of configuration is available at examples/decidim_ai_third_party.rb** + +Add the secrets to your `config/secrets.yml` file: + +```yaml +decidim: + ai: + model: <%= Decidim::Env.new("DECIDIM_AI_MODEL").to_s %> + endpoint: <%= Decidim::Env.new("DECIDIM_AI_ENDPOINT").to_s %> + secret: <%= Decidim::Env.new("DECIDIM_AI_SECRET").to_s %> + max_tokens: <%= Decidim::Env.new("DECIDIM_AI_MAX_TOKENS").to_i %> + temperature: <%= Decidim::Env.new("DECIDIM_AI_TEMPERATURE").to_f %> + top_p: <%= Decidim::Env.new("DECIDIM_AI_TOP_P").to_i %> + presence_penalty: <%= Decidim::Env.new("DECIDIM_AI_PRESENCE_PENALTY").to_i %> + stream: <%= Decidim::Env.new("DECIDIM_AI_STREAM") == "true" %> + system_message: <%= Decidim::Env.new("DECIDIM_AI_SYSTEM_MESSAGE").to_s %> + reporting_user_email: <%= Decidim::Env.new("DECIDIM_AI_REPORTING_USER_EMAIL").to_s %> +``` + +You can now run your server and start using the third-party AI service for spam detection ! \ No newline at end of file diff --git a/examples/decidim_ai_scaleway.rb b/examples/decidim_ai_scaleway.rb new file mode 100644 index 0000000..94a114c --- /dev/null +++ b/examples/decidim_ai_scaleway.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +if Decidim.module_installed?(:ai) + if Rails.application.secrets.dig(:decidim, :ai, :endpoint).blank? || Rails.application.secrets.dig(:decidim, :ai, :secret).blank? + Rails.logger.warn "[decidim-ai] Initializer - AI endpoint or secret not configured. AI features will be disabled." + return + end + + # Module configuration + Decidim::Ai::SpamDetection.reporting_user_email = Rails.application.secrets.dig(:decidim, :ai, :reporting_user_email) + Decidim::Ai::Language.formatter = "Decidim::Ai::Language::Formatter" + Decidim::Ai::SpamDetection.user_models = { + "Decidim::User" => "Decidim::Ai::SpamDetection::Resource::UserBaseEntity" + } + Decidim::Ai::SpamDetection.resource_models = begin + models = {} + models["Decidim::Comments::Comment"] = "Decidim::Ai::SpamDetection::Resource::Comment" if Decidim.module_installed?("comments") + models["Decidim::Debates::Debate"] = "Decidim::Ai::SpamDetection::Resource::Debate" if Decidim.module_installed?("debates") + models["Decidim::Initiative"] = "Decidim::Ai::SpamDetection::Resource::Initiative" if Decidim.module_installed?("initiatives") + models["Decidim::Meetings::Meeting"] = "Decidim::Ai::SpamDetection::Resource::Meeting" if Decidim.module_installed?("meetings") + models["Decidim::Proposals::Proposal"] = "Decidim::Ai::SpamDetection::Resource::Proposal" if Decidim.module_installed?("proposals") + if Decidim.module_installed?("proposals") + models["Decidim::Proposals::CollaborativeDraft"] = + "Decidim::Ai::SpamDetection::Resource::CollaborativeDraft" + end + models + end + + # Configuring Scaleway strategy + analyzers = [ + { + name: :scaleway, + strategy: Decidim::Ai::SpamDetection::Strategy::Scaleway, + options: { + endpoint: Rails.application.secrets.dig(:decidim, :ai, :endpoint), + secret: Rails.application.secrets.dig(:decidim, :ai, :secret) + } + } + ] + + Decidim::Ai::SpamDetection.resource_analyzers = analyzers + Decidim::Ai::SpamDetection.user_analyzers = analyzers + + # Configuring Third Party services + Decidim::Ai::SpamDetection.user_detection_service = "Decidim::Ai::SpamDetection::ThirdPartyService" + Decidim::Ai::SpamDetection.resource_detection_service = "Decidim::Ai::SpamDetection::ThirdPartyService" + + # Configuring Third Party jobs + Decidim::Ai::SpamDetection.user_spam_analyzer_job = "Decidim::Ai::SpamDetection::ThirdParty::UserSpamAnalyzerJob" + Decidim::Ai::SpamDetection.generic_spam_analyzer_job = "Decidim::Ai::SpamDetection::ThirdParty::GenericSpamAnalyzerJob" +end diff --git a/examples/decidim_ai_third_party.rb b/examples/decidim_ai_third_party.rb new file mode 100644 index 0000000..a8a6580 --- /dev/null +++ b/examples/decidim_ai_third_party.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +if Decidim.module_installed?(:ai) + if Rails.application.secrets.dig(:decidim, :ai, :endpoint).blank? || Rails.application.secrets.dig(:decidim, :ai, :secret).blank? + Rails.logger.warn "[decidim-ai] Initializer - AI endpoint or secret not configured. AI features will be disabled." + return + end + + # Module configuration + Decidim::Ai::SpamDetection.reporting_user_email = Rails.application.secrets.dig(:decidim, :ai, :reporting_user_email) + Decidim::Ai::Language.formatter = "Decidim::Ai::Language::Formatter" + Decidim::Ai::SpamDetection.user_models = { + "Decidim::User" => "Decidim::Ai::SpamDetection::Resource::UserBaseEntity" + } + Decidim::Ai::SpamDetection.resource_models = begin + models = {} + models["Decidim::Comments::Comment"] = "Decidim::Ai::SpamDetection::Resource::Comment" if Decidim.module_installed?("comments") + models["Decidim::Debates::Debate"] = "Decidim::Ai::SpamDetection::Resource::Debate" if Decidim.module_installed?("debates") + models["Decidim::Initiative"] = "Decidim::Ai::SpamDetection::Resource::Initiative" if Decidim.module_installed?("initiatives") + models["Decidim::Meetings::Meeting"] = "Decidim::Ai::SpamDetection::Resource::Meeting" if Decidim.module_installed?("meetings") + models["Decidim::Proposals::Proposal"] = "Decidim::Ai::SpamDetection::Resource::Proposal" if Decidim.module_installed?("proposals") + if Decidim.module_installed?("proposals") + models["Decidim::Proposals::CollaborativeDraft"] = + "Decidim::Ai::SpamDetection::Resource::CollaborativeDraft" + end + models + end + + # Configuring Third Party strategy + analyzers = [ + { + name: :third_party, + strategy: Decidim::Ai::SpamDetection::Strategy::ThirdParty, + options: { + model: Rails.application.secrets.dig(:decidim, :ai, :model), + endpoint: Rails.application.secrets.dig(:decidim, :ai, :endpoint), + secret: Rails.application.secrets.dig(:decidim, :ai, :secret), + max_tokens: Rails.application.secrets.dig(:decidim, :ai, :max_tokens), + temperature: Rails.application.secrets.dig(:decidim, :ai, :temperature), + top_p: Rails.application.secrets.dig(:decidim, :ai, :top_p), + presence_penalty: Rails.application.secrets.dig(:decidim, :ai, :presence_penalty), + stream: Rails.application.secrets.dig(:decidim, :ai, :stream), + system_message: Rails.application.secrets.dig(:decidim, :ai, :system_message), + reporting_user_email: Rails.application.secrets.dig(:decidim, :ai, :reporting_user_email) + } + } + ] + + Decidim::Ai::SpamDetection.resource_analyzers = analyzers + Decidim::Ai::SpamDetection.user_analyzers = analyzers + + # Configuring Third Party services + Decidim::Ai::SpamDetection.user_detection_service = "Decidim::Ai::SpamDetection::ThirdPartyService" + Decidim::Ai::SpamDetection.resource_detection_service = "Decidim::Ai::SpamDetection::ThirdPartyService" + + # Configuring Third Party jobs + Decidim::Ai::SpamDetection.user_spam_analyzer_job = "Decidim::Ai::SpamDetection::ThirdParty::UserSpamAnalyzerJob" + Decidim::Ai::SpamDetection.generic_spam_analyzer_job = "Decidim::Ai::SpamDetection::ThirdParty::GenericSpamAnalyzerJob" +end diff --git a/lib/decidim/ai/engine.rb b/lib/decidim/ai/engine.rb index 0c6b593..a08a862 100644 --- a/lib/decidim/ai/engine.rb +++ b/lib/decidim/ai/engine.rb @@ -30,13 +30,13 @@ class Engine < ::Rails::Engine initializer "decidim_ai.events.subscribe_profile" do config.to_prepare do Decidim::EventsManager.subscribe("decidim.update_account:after") do |_event_name, data| - Decidim::Ai::SpamDetection::UserSpamAnalyzerJob.perform_later(data[:resource]) + Decidim::Ai::SpamDetection.user_spam_analyzer_job.constantize.perform_later(data[:resource]) end Decidim::EventsManager.subscribe("decidim.update_user_group:after") do |_event_name, data| - Decidim::Ai::SpamDetection::UserSpamAnalyzerJob.perform_later(data[:resource]) + Decidim::Ai::SpamDetection.user_spam_analyzer_job.constantize.perform_later(data[:resource]) end Decidim::EventsManager.subscribe("decidim.create_user_group:after") do |_event_name, data| - Decidim::Ai::SpamDetection::UserSpamAnalyzerJob.perform_later(data[:resource]) + Decidim::Ai::SpamDetection.user_spam_analyzer_job.constantize.perform_later(data[:resource]) end end end @@ -44,10 +44,10 @@ class Engine < ::Rails::Engine initializer "decidim_ai.events.subscribe_comments" do config.to_prepare do ActiveSupport::Notifications.subscribe("decidim.comments.create_comment:after") do |_event_name, data| - Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body]) + Decidim::Ai::SpamDetection.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body]) end ActiveSupport::Notifications.subscribe("decidim.comments.update_comment:after") do |_event_name, data| - Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body]) + Decidim::Ai::SpamDetection.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body]) end end end @@ -55,12 +55,12 @@ class Engine < ::Rails::Engine initializer "decidim_ai.events.subscribe_meeting" do config.to_prepare do ActiveSupport::Notifications.subscribe("decidim.meetings.create_meeting:after") do |_event_name, data| - Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), - [:description, :title, :location_hints, :registration_terms]) + Decidim::Ai::SpamDetection.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), + [:description, :title, :location_hints, :registration_terms]) end ActiveSupport::Notifications.subscribe("decidim.meetings.update_meeting:after") do |_event_name, data| - Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), - [:description, :title, :location_hints, :registration_terms]) + Decidim::Ai::SpamDetection.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), + [:description, :title, :location_hints, :registration_terms]) end end end @@ -68,10 +68,12 @@ class Engine < ::Rails::Engine initializer "decidim_ai.events.subscribe_debate" do config.to_prepare do ActiveSupport::Notifications.subscribe("decidim.debates.create_debate:after") do |_event_name, data| - Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:description, :title]) + Decidim::Ai::SpamDetection.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), + [:description, :title]) end ActiveSupport::Notifications.subscribe("decidim.debates.update_debate:after") do |_event_name, data| - Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:description, :title]) + Decidim::Ai::SpamDetection.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), + [:description, :title]) end end end @@ -79,10 +81,12 @@ class Engine < ::Rails::Engine initializer "decidim_ai.events.subscribe_initiatives" do config.to_prepare do ActiveSupport::Notifications.subscribe("decidim.initiatives.create_initiative:after") do |_event_name, data| - Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:description, :title]) + Decidim::Ai::SpamDetection.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), + [:description, :title]) end ActiveSupport::Notifications.subscribe("decidim.initiatives.update_initiative:after") do |_event_name, data| - Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:description, :title]) + Decidim::Ai::SpamDetection.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), + [:description, :title]) end end end @@ -90,16 +94,20 @@ class Engine < ::Rails::Engine initializer "decidim_ai.events.subscribe_proposals" do config.to_prepare do ActiveSupport::Notifications.subscribe("decidim.proposals.create_proposal:after") do |_event_name, data| - Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body, :title]) + Decidim::Ai::SpamDetection.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), + [:body, :title]) end ActiveSupport::Notifications.subscribe("decidim.proposals.update_proposal:after") do |_event_name, data| - Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body, :title]) + Decidim::Ai::SpamDetection.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), + [:body, :title]) end ActiveSupport::Notifications.subscribe("decidim.proposals.create_collaborative_draft:after") do |_event_name, data| - Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body, :title]) + Decidim::Ai::SpamDetection.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), + [:body, :title]) end ActiveSupport::Notifications.subscribe("decidim.proposals.update_collaborative_draft:after") do |_event_name, data| - Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body, :title]) + Decidim::Ai::SpamDetection.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), + [:body, :title]) end end end diff --git a/lib/decidim/ai/spam_detection/spam_detection.rb b/lib/decidim/ai/spam_detection/spam_detection.rb index b0fb8d8..9461efc 100644 --- a/lib/decidim/ai/spam_detection/spam_detection.rb +++ b/lib/decidim/ai/spam_detection/spam_detection.rb @@ -6,6 +6,7 @@ module SpamDetection include ActiveSupport::Configurable autoload :Service, "decidim/ai/spam_detection/service" + autoload :ThirdPartyService, "decidim/ai/spam_detection/third_party_service" module Resource autoload :Base, "decidim/ai/spam_detection/resource/base" @@ -26,6 +27,8 @@ module Importer module Strategy autoload :Base, "decidim/ai/spam_detection/strategy/base" autoload :Bayes, "decidim/ai/spam_detection/strategy/bayes" + autoload :ThirdParty, "decidim/ai/spam_detection/strategy/third_party" + autoload :Scaleway, "decidim/ai/spam_detection/strategy/scaleway" end # This is the email address used by the spam engine to @@ -152,6 +155,16 @@ module Strategy "Decidim::Ai::SpamDetection::Service" end + # User spam analyzer job class. + config_accessor :user_spam_analyzer_job do + "Decidim::Ai::SpamDetection::UserSpamAnalyzerJob" + end + + # User spam analyzer job class. + config_accessor :generic_spam_analyzer_job do + "Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob" + end + # this is the generic resource classifier class. If you need to change your own class, please change the # configuration of `Decidim::Ai::SpamDetection.detection_service` variable. def self.resource_classifier diff --git a/lib/decidim/ai/spam_detection/strategy/base.rb b/lib/decidim/ai/spam_detection/strategy/base.rb index 6cd7153..a760237 100644 --- a/lib/decidim/ai/spam_detection/strategy/base.rb +++ b/lib/decidim/ai/spam_detection/strategy/base.rb @@ -21,6 +21,12 @@ def untrain(_classification, _content); end def log; end def score = 0.0 + + private + + def system_log(message, level: :info) + Rails.logger.send(level, message) + end end end end diff --git a/lib/decidim/ai/spam_detection/strategy/scaleway.rb b/lib/decidim/ai/spam_detection/strategy/scaleway.rb new file mode 100644 index 0000000..930dcc1 --- /dev/null +++ b/lib/decidim/ai/spam_detection/strategy/scaleway.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + module Strategy + # Scaleway third-party strategy + # doc: https://www.scaleway.com/en/docs/managed-inference/quickstart/ + class Scaleway < ThirdParty + # classify calls the third party AI system to classify content + # @param content [String] Content to classify + # @param organization_host [String] Decidim host + # @param klass [String] Stringified klass of reportable + # @return Integer + def classify(content, organization_host, klass) + system_log("classify - Classifying content with Scaleway's strategy...") + res = third_party_request(content, organization_host, klass) + body = parse_http_response(res) + + system_log("classify - HTTP response body : #{body}") + content = third_party_content(body) + + raise InvalidOutputFormat, "Unexpected value received : '#{content}'. Expected to be in #{OUTPUT}" unless valid_output_format?(content) + + @category = content.downcase + score + rescue ThirdPartyError => e + system_log("classify - Error: #{e.message}", level: :error) + raise e + end + + def third_party_request(content, organization_host, klass) + uri = URI(@endpoint) + + payload = payload(content, klass).to_json + system_log("third_party_request - HTTP Request payload: #{payload}") + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + request = Net::HTTP::Post.new(uri.to_s, "Content-Type" => "application/json", "Accept" => "application/json") + request["X-Auth-Token"] = @secret + request["X-Host"] = organization_host + request["X-Decidim-Host"] = organization_host + request["X-Decidim"] = organization_host + request["Host"] = organization_host + + request.body = payload + + http.request(request) + rescue StandardError => e + system_log("third_party_request - Error: #{e.message}", level: :error) + system_log("third_party_request - HTTP : (url/#{@endpoint}) (Host/#{organization_host})", level: :error) + raise ThirdPartyError, "Error during request to third party service: #{e.message}" + end + + def parse_http_response(response) + case response + when Net::HTTPSuccess + JSON.parse(response.body) + when Net::HTTPForbidden + raise Forbidden, "Access forbidden to the third party service. Check your API key or permissions." + when Net::HTTPRequestTimeout, Net::HTTPGatewayTimeout + raise TimeoutError, response.body || "Request timed out" + when Net::HTTPServiceUnavailable + raise InvalidEntity, response.body || "Service unavailable" + else + raise InvalidEntity, "Received unexpected response from third party service: #{response.body}" + end + end + + def third_party_content(body) + return "" if body.blank? + + body.fetch("spam", "") + end + + def payload(content, klass) + { + text: content, + type: klass + } + end + end + end + end + end +end diff --git a/lib/decidim/ai/spam_detection/strategy/third_party.rb b/lib/decidim/ai/spam_detection/strategy/third_party.rb new file mode 100644 index 0000000..a4e3750 --- /dev/null +++ b/lib/decidim/ai/spam_detection/strategy/third_party.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + module Strategy + class ThirdParty < Base + class ThirdPartyError < StandardError; end + + class InvalidOutputFormat < ThirdPartyError; end + + class InvalidEntity < ThirdPartyError; end + + class Forbidden < ThirdPartyError; end + + class TimeoutError < ThirdPartyError; end + + OUTPUT = %w(SPAM NOT_SPAM).freeze + + def initialize(options = {}) + super + @endpoint = Rails.application.secrets.dig(:decidim, :ai, :endpoint) + @secret = Rails.application.secrets.dig(:decidim, :ai, :secret) + @options = options + end + + def log + return "AI system didn't marked this content as spam" if score <= score_threshold + + "AI system marked this as spam" + end + + def classify(content) + system_log("Starting classification...") + res = third_party_request(content) + body = res.body + + system_log("Received response from third party service: #{body}") + raise InvalidEntity, res unless res.is_a?(Net::HTTPSuccess) + + content = third_party_content(body) + raise InvalidOutputFormat, "Third party service response isn't valid JSON" unless valid_output_format?(content) + + @category = content.downcase + system_log("Spam : #{score}.") + score + rescue InvalidEntity, InvalidOutputFormat => e + system_log(e, level: :error) + score + end + + def third_party_request(content) + uri = URI(@endpoint) + payload = payload(content).to_json + system_log("Sending request to third party service: #{payload}") + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.post(uri.path, payload, headers) + end + + def headers + @headers ||= { + "Authorization" => "Bearer #{@secret}", + "Content-Type" => "application/json", + "Accept" => "application/json" + } + end + + def payload(content) + { + model: @options[:model], + messages: [ + { + role: "system", + content: @options[:system_message] + }, + { + role: "user", + content: + } + ], + max_tokens: @options[:max_tokens], + temperature: @options[:temperature], + top_p: @options[:top_p], + presence_penalty: @options[:presence_penalty], + stream: @options[:stream] + } + end + + # This method should be implemented by the subclass depending on the third-party service + def third_party_content(body) + return [] if body.blank? + + choices = JSON.parse(body)&.fetch("choices", []) + choices.first&.dig("message", "content") + end + + def score + @category.presence == "spam" ? 1 : 0 + end + + private + + attr_reader :options + + def valid_output_format?(output) + output.present? && output.is_a?(String) && output.in?(OUTPUT) + end + + def score_threshold + return Decidim::Ai::SpamDetection.user_score_threshold if name == :third_party_user + + Decidim::Ai::SpamDetection.resource_score_threshold + end + + def system_log(message, level: :info) + Rails.logger.send(level, "[decidim-ai] #{self.class.name} - #{message}") + end + end + end + end + end +end diff --git a/lib/decidim/ai/spam_detection/third_party_service.rb b/lib/decidim/ai/spam_detection/third_party_service.rb new file mode 100644 index 0000000..e0aa8bc --- /dev/null +++ b/lib/decidim/ai/spam_detection/third_party_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + class ThirdPartyService < Decidim::Ai::SpamDetection::Service + # classify calls the third party AI system to classify content + # @param text [String] Content to classify + # @param klass [String] Stringified klass of reportable + # @return nil + def classify(text, organization_host = "", klass = "") + text = formatter.cleanup(text) + return if text.blank? + + @registry.each do |strategy| + strategy.classify(text, organization_host, klass) + end + end + end + end + end +end diff --git a/spec/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job_spec.rb b/spec/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job_spec.rb new file mode 100644 index 0000000..f4203ba --- /dev/null +++ b/spec/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob do + subject { described_class } + + let!(:organization_one) { create(:organization) } + let!(:organization_two) { create(:organization) } + let!(:user_one) { create(:user, email: reporting_user_email, organization: organization_one) } + let!(:user_two) { create(:user, email: reporting_user_email, organization: organization_two) } + let(:reporting_user_email) { "reporting@example.org" } + + describe "queue" do + it "is queued to spam_analysis" do + expect(subject.queue_name).to eq "spam_analysis" + end + end + + describe "#reporting_user" do + before do + allow(Decidim::Ai::SpamDetection).to receive(:reporting_user_email).and_return(reporting_user_email) + end + + it "finds the user by email" do + obj = subject.new + obj.instance_variable_set(:@organization, organization_two) + expect(obj.send(:reporting_user)).to eq user_two + end + end +end diff --git a/spec/lib/decidim/ai/spam_detection/strategy/scaleway_spec.rb b/spec/lib/decidim/ai/spam_detection/strategy/scaleway_spec.rb new file mode 100644 index 0000000..da8503e --- /dev/null +++ b/spec/lib/decidim/ai/spam_detection/strategy/scaleway_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Decidim::Ai::SpamDetection::Strategy::Scaleway do + let(:strategy) { described_class.new(options) } + let(:endpoint) { "https://example.com/api" } + let(:secret) { "secret_key" } + let(:klass) { "Decidim::Proposals::Proposal" } + let(:options) do + { + endpoint:, + secret: + } + end + + describe "#third_party_content" do + context "when body contains valid JSON with choices" do + let(:body) do + { + "choices" => [ + { + "message" => { + "content" => "SPAM" + } + } + ] + }.to_json + end + + it "returns the content of the first choice" do + expect(strategy.third_party_content(body)).to eq("SPAM") + end + end + + context "when body contains valid JSON without choices" do + let(:body) do + { + "choices" => [] + }.to_json + end + + it "returns nil" do + expect(strategy.third_party_content(body)).to be_nil + end + end + + context "when body contains invalid JSON" do + let(:body) { "invalid json" } + + it "raises a JSON::ParserError" do + expect { strategy.third_party_content(body) }.to raise_error(JSON::ParserError) + end + end + + context "when body is empty" do + let(:body) { "" } + + it "returns nil" do + expect(strategy.third_party_content(body)).to be_empty + end + end + end + + describe "#classify" do + let(:response_double) { double(Net::HTTPResponse, body: '{"choices": [{"message": {"spam": "NOT_SPAM"}}]}', is_a?: true, error: "Error message") } + let(:uri_double) { double(URI, host: "example.com", port: 443, path: "/api", method: :POST) } + let(:http_double) { double(Net::HTTP, :use_ssl= => true) } + + before do + allow(URI).to receive(:parse).and_return(uri_double) + allow(Net::HTTP).to receive(:new).and_return(http_double) + allow(http_double).to receive(:post).and_return(double(Net::HTTPResponse, code: Net::HTTPSuccess, body: '{"choices": [{"message": {"spam": "NOT_SPAM"}}]}')) + allow(http_double).to receive(:headers=).and_return({ "Accept" => "application/json", "Content-Type" => "application/json", "Decidim" => "decidim.example.org", "Host" => "decidim.example.org", "X-Auth-Token" => "secret_key" }) + allow(strategy).to receive(:request).and_return(response_double) + end + + it "classifies content as not spam" do + expect(strategy.classify("Test content", "decidim.example.org", klass)).to eq(0) + end + + context "when response is invalid" do + before do + allow(response_double).to receive(:is_a?).with(Net::HTTPSuccess).and_return(false) + allow(response_double).to receive(:error).and_return("Error message") + end + + it "raises InvalidEntity error" do + expect { strategy.classify("Test content", "decidim.example.org", klass) }.not_to raise_error(Decidim::Ai::SpamDetection::Strategy::ThirdParty::InvalidEntity) + expect(strategy.instance_variable_get(:@score)).to eq(0) + end + end + + context "when response format is invalid" do + before { allow(strategy).to receive(:valid_output_format?).and_return(false) } + + it "raises InvalidOutputFormat error" do + expect { strategy.classify("Test content", "decidim.example.org", klass) }.not_to raise_error(Decidim::Ai::SpamDetection::Strategy::ThirdParty::InvalidOutputFormat) + expect(strategy.instance_variable_get(:@score)).to eq(0) + end + end + end + + describe "#headers" do + it "returns the correct headers" do + expect(strategy.headers("decidim.example.org")).to eq( + "X-Auth-Token" => "secret_key", + "Content-Type" => "application/json", + "Accept" => "application/json", + "Host" => "decidim.example.org", + "Decidim" => "decidim.example.org" + ) + end + end + + describe "#payload" do + it "returns the correct payload" do + expect(strategy.payload("Test content", "Decidim::Proposals::Proposal")).to eq({ + text: "Test content", + type: "Decidim::Proposals::Proposal" + }) + end + end +end diff --git a/spec/lib/decidim/ai/spam_detection/strategy/third_party_spec.rb b/spec/lib/decidim/ai/spam_detection/strategy/third_party_spec.rb new file mode 100644 index 0000000..9559bb8 --- /dev/null +++ b/spec/lib/decidim/ai/spam_detection/strategy/third_party_spec.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe Decidim::Ai::SpamDetection::Strategy::ThirdParty do + let(:strategy) { described_class.new(options) } + let(:endpoint) { "https://example.com/api" } + let(:secret) { "secret_key" } + let(:options) do + { + endpoint:, + secret:, + model: "model_name", + system_message: "System message", + max_tokens: 100, + temperature: 0.7, + top_p: 0.9, + presence_penalty: 0, + stream: false + } + end + let(:content) { "Test contribution input." } + + before do + stub_request(:post, "https://example.com/api") + .to_return(status: 200, body: "", headers: {}) + end + + describe "#initialize" do + it "initializes with options" do + expect(strategy.instance_variable_get(:@endpoint)).to eq(endpoint) + expect(strategy.instance_variable_get(:@secret)).to eq(secret) + expect(strategy.instance_variable_get(:@options)).to eq(options) + end + end + + describe "train" do + it "returns nothing" do + expect(strategy.train(:spam, "text")).to be_nil + end + end + + describe "untrain" do + it "returns nothing" do + expect(strategy.untrain(:spam, "text")).to be_nil + end + end + + describe "#log" do + context "when score is below threshold" do + before { allow(strategy).to receive(:score).and_return(0) } + + it "returns a non-spam message" do + expect(strategy.log).to eq("AI system didn't marked this content as spam") + end + end + + context "when score is above threshold" do + before { allow(strategy).to receive(:score).and_return(1) } + + it "returns a spam message" do + expect(strategy.log).to eq("AI system marked this as spam") + end + end + end + + describe "#classify" do + let(:response_double) { double("Net::HTTPResponse", body: '{"category": "NOT_SPAM"}', is_a?: true) } + + before do + allow(strategy).to receive(:request).and_return(response_double) + allow(strategy).to receive(:third_party_content).and_return("NOT_SPAM") + end + + it "classifies content as not spam" do + expect(strategy.classify(content)).to eq(0) + end + + context "when response is invalid" do + before do + allow(response_double).to receive(:is_a?).with(Net::HTTPSuccess).and_return(false) + allow(response_double).to receive(:error).and_return("Error message") + end + + it "raises InvalidEntity error" do + expect { strategy.classify(content) }.not_to raise_error(Decidim::Ai::SpamDetection::Strategy::ThirdParty::InvalidEntity) + expect(strategy.instance_variable_get(:@score)).to eq(0) + end + end + + context "when response format is invalid" do + before { allow(strategy).to receive(:valid_output_format?).and_return(false) } + + it "raises InvalidOutputFormat error" do + expect { strategy.classify(content) }.not_to raise_error(Decidim::Ai::SpamDetection::Strategy::ThirdParty::InvalidOutputFormat) + expect(strategy.instance_variable_get(:@score)).to eq(0) + end + end + end + + describe "#request" do + let(:uri_double) { double("URI", host: "example.com", port: 443, path: "/api") } + let(:http_double) { double("Net::HTTP", :use_ssl= => true) } + + before do + allow(URI).to receive(:parse).and_return(uri_double) + allow(Net::HTTP).to receive(:new).and_return(http_double) + allow(http_double).to receive(:post).and_return(double("Net::HTTPResponse", body: '{"category": "NOT_SPAM"}')) + end + + it "sends a request to the third-party service" do + expect(http_double).to receive(:post).with("/api", anything, anything) + strategy.third_party_request(content) + end + end + + describe "#headers" do + it "returns the correct headers" do + expect(strategy.headers).to eq( + "Authorization" => "Bearer secret_key", + "Content-Type" => "application/json", + "Accept" => "application/json" + ) + end + end + + describe "#payload" do + it "returns the correct payload" do + expect(strategy.payload(content)).to eq( + model: "model_name", + messages: [ + { role: "system", content: "System message" }, + { role: "user", content: } + ], + max_tokens: 100, + temperature: 0.7, + top_p: 0.9, + presence_penalty: 0, + stream: false + ) + end + end + + describe "#score" do + context "when category is 'spam'" do + before { strategy.instance_variable_set(:@category, "spam") } + + it "returns 1" do + expect(strategy.score).to eq(1) + end + end + + context "when category is not 'spam'" do + before { allow(strategy).to receive(:@category).and_return("not_spam") } + + it "returns 0" do + expect(strategy.score).to eq(0) + end + end + end + + describe "#valid_output_format?" do + it "returns true for valid output" do + expect(strategy.send(:valid_output_format?, "SPAM")).to be true + expect(strategy.send(:valid_output_format?, "NOT_SPAM")).to be true + end + + it "returns false for invalid output" do + expect(strategy.send(:valid_output_format?, "INVALID")).to be false + expect(strategy.send(:valid_output_format?, nil)).to be false + end + end + + describe "#score_threshold" do + before { allow(Decidim::Ai::SpamDetection).to receive(:user_score_threshold).and_return(0.5) } + + context "when name is :third_party_user" do + before { allow(strategy).to receive(:name).and_return(:third_party_user) } + + it "returns user score threshold" do + expect(strategy.send(:score_threshold)).to eq(0.5) + end + end + + context "when name is not :third_party_user" do + before { allow(strategy).to receive(:name).and_return(:other_name) } + + it "returns resource score threshold" do + expect(strategy.send(:score_threshold)).to eq(Decidim::Ai::SpamDetection.resource_score_threshold) + end + end + end + + describe "#system_log" do + let(:logger_double) { double("Rails.logger") } + + before { allow(Rails.logger).to receive(:send).and_return(logger_double) } + + it "logs a message with info level" do + expect(Rails.logger).to receive(:send).with(:info, "[decidim-ai] Decidim::Ai::SpamDetection::Strategy::ThirdParty - Test message") + strategy.send(:system_log, "Test message") + end + + it "logs a message with error level" do + expect(Rails.logger).to receive(:send).with(:error, "[decidim-ai] Decidim::Ai::SpamDetection::Strategy::ThirdParty - Error message") + strategy.send(:system_log, "Error message", level: :error) + end + end +end diff --git a/spec/lib/decidim/ai/spam_detection/third_party_service_spec.rb b/spec/lib/decidim/ai/spam_detection/third_party_service_spec.rb new file mode 100644 index 0000000..e380ed3 --- /dev/null +++ b/spec/lib/decidim/ai/spam_detection/third_party_service_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe Decidim::Ai::SpamDetection::ThirdPartyService do + subject { described_class.new(registry:) } + + let(:registry) { Decidim::Ai::SpamDetection.resource_registry } + let(:base_strategy) { { name: :base, strategy: Decidim::Ai::SpamDetection::Strategy::Base } } + let(:dummy_strategy) { { name: :dummy, strategy: Decidim::Ai::SpamDetection::Strategy::Base } } + + before do + registry.clear + registry.register_analyzer(**base_strategy) + registry.register_analyzer(**dummy_strategy) + end + + describe "train" do + it "trains all the strategies" do + expect(registry.for(:base)).to receive(:train).with(:spam, "text") + expect(registry.for(:dummy)).to receive(:train).with(:spam, "text") + + subject.train(:spam, "text") + end + end + + describe "untrain" do + it "untrains all the strategies" do + expect(registry.for(:base)).to receive(:untrain).with(:spam, "text") + expect(registry.for(:dummy)).to receive(:untrain).with(:spam, "text") + + subject.untrain(:spam, "text") + end + end + + describe "classify" do + it "classifies using all strategies" do + expect(registry.for(:base)).to receive(:classify).with("text", "decidim.example.org", "Decidim::Proposals::Proposal") + expect(registry.for(:dummy)).to receive(:classify).with("text", "decidim.example.org", "Decidim::Proposals::Proposal") + + subject.classify("text", "decidim.example.org", "Decidim::Proposals::Proposal") + end + end + + describe "classification_log" do + it "returns the log of all strategies" do + allow(registry.for(:base)).to receive(:log).and_return("base log") + allow(registry.for(:dummy)).to receive(:log).and_return("dummy log") + + expect(subject.classification_log).to eq("base log\ndummy log") + end + end + + describe "score" do + it "returns the average score of all strategies" do + allow(registry.for(:base)).to receive(:score).and_return(1) + allow(registry.for(:dummy)).to receive(:score).and_return(0) + + expect(subject.score).to eq(0.5) + end + end +end