From 36d7e28702bece32ae4f8c72e143a10d7278a608 Mon Sep 17 00:00:00 2001 From: Quentin Champenois Date: Fri, 18 Jul 2025 17:22:50 +0200 Subject: [PATCH 1/6] fix: Set conf detection job --- Gemfile.lock | 4 +++ lib/decidim/ai/engine.rb | 30 +++++++++---------- .../ai/spam_detection/spam_detection.rb | 10 +++++++ 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index bc95c31..86b55a7 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) @@ -797,6 +800,7 @@ GEM zeitwerk (2.7.2) PLATFORMS + arm64-darwin-22 x86_64-linux-gnu DEPENDENCIES diff --git a/lib/decidim/ai/engine.rb b/lib/decidim/ai/engine.rb index 0c6b593..722d155 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,11 +55,11 @@ 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), + 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), + 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 @@ -68,10 +68,10 @@ 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 +79,10 @@ 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 +90,16 @@ 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..d2894fa 100644 --- a/lib/decidim/ai/spam_detection/spam_detection.rb +++ b/lib/decidim/ai/spam_detection/spam_detection.rb @@ -152,6 +152,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 From 432d4cefa7b7f389c6c5a6b6cb829dc8f50388bb Mon Sep 17 00:00:00 2001 From: Quentin Champenois Date: Tue, 22 Jul 2025 17:10:10 +0200 Subject: [PATCH 2/6] backport: Fix multi tenant reporting user --- .../generic_spam_analyzer_job.rb | 3 +- .../spam_detection/user_spam_analyzer_job.rb | 1 + .../generic_spam_analyzer_job_spec.rb | 31 +++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 spec/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job_spec.rb 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/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/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..a3b1f8e --- /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 \ No newline at end of file From 0a04f245e83d8a55d18ca9622cb3ca70b3be442a Mon Sep 17 00:00:00 2001 From: AyakorK Date: Thu, 5 Feb 2026 15:34:23 +0100 Subject: [PATCH 3/6] bump: 0.31 --- .github/workflows/ci_ai.yml | 8 +- .github/workflows/ci_lint.yml | 4 +- .gitlab-ci.yml | 2 +- .node-version | 2 +- .ruby-version | 2 +- Gemfile | 12 +- Gemfile.lock | 665 ++++++++++-------- .../generic_spam_analyzer_job.rb | 11 +- decidim-ai.gemspec | 4 +- lib/decidim/ai.rb | 2 - lib/decidim/ai/engine.rb | 86 ++- lib/decidim/ai/language/language.rb | 2 +- lib/decidim/ai/overrides/create_user_group.rb | 25 - lib/decidim/ai/overrides/update_account.rb | 36 - lib/decidim/ai/overrides/update_user_group.rb | 19 - .../ai/spam_detection/resource/base.rb | 6 +- .../ai/spam_detection/spam_detection.rb | 33 +- lib/decidim/ai/version.rb | 2 +- .../comments/user_creates_comment_spec.rb | 1 - .../debates/user_creates_debate_spec.rb | 4 +- .../debates/user_updates_debate_spec.rb | 2 +- .../meetings/user_creates_meeting_spec.rb | 4 +- .../meetings/user_updates_meeting_spec.rb | 4 +- .../user_creates_collaborative_draft_spec.rb | 4 +- .../proposals/user_creates_proposal_spec.rb | 3 +- .../user_updates_collaborative_draft_spec.rb | 4 +- .../proposals/user_updates_proposal_spec.rb | 2 - .../user/user_manages_user_group_spec.rb | 94 --- .../generic_spam_analyzer_job_spec.rb | 2 +- .../spam_detection/importer/database_spec.rb | 87 +-- spec/shared/events_examples.rb | 37 +- 31 files changed, 579 insertions(+), 590 deletions(-) delete mode 100644 lib/decidim/ai/overrides/create_user_group.rb delete mode 100644 lib/decidim/ai/overrides/update_account.rb delete mode 100644 lib/decidim/ai/overrides/update_user_group.rb delete mode 100644 spec/event_handlers/user/user_manages_user_group_spec.rb diff --git a/.github/workflows/ci_ai.yml b/.github/workflows/ci_ai.yml index 84ed4b7..b434f13 100644 --- a/.github/workflows/ci_ai.yml +++ b/.github/workflows/ci_ai.yml @@ -12,8 +12,8 @@ on: env: CI: "true" - RUBY_VERSION: 3.2.2 - NODE_VERSION: 18.17.1 + RUBY_VERSION: 3.3.4 + NODE_VERSION: 22.16.0 concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} @@ -69,6 +69,10 @@ jobs: npm- - run: bundle exec rake test_app name: Create test app + - run: | + rm -f ./spec/decidim_dummy_app/app/services/dummy_signature_handler.rb + rm -f ./spec/decidim_dummy_app/app/services/dummy_sms_mobile_phone_validator.rb + name: Remove Initiative-dependent dummy files - run: mkdir -p ./spec/decidim_dummy_app/tmp/screenshots name: Create the screenshots folder - uses: nanasess/setup-chromedriver@v2 diff --git a/.github/workflows/ci_lint.yml b/.github/workflows/ci_lint.yml index d1505a9..cec2ffd 100644 --- a/.github/workflows/ci_lint.yml +++ b/.github/workflows/ci_lint.yml @@ -7,8 +7,8 @@ on: pull_request: env: - RUBY_VERSION: 3.2.2 - NODE_VERSION: 18.17.1 + RUBY_VERSION: 3.3.4 + NODE_VERSION: 22.16.0 jobs: lint-report: diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0f99bad..26c7e0a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -76,4 +76,4 @@ rubycritic: test stable: extends: .test variables: - DECIDIM_VERSION: "~> 0.29.0" + DECIDIM_VERSION: "~> 0.31.0" diff --git a/.node-version b/.node-version index 4a1f488..adb5558 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -18.17.1 +22.14.0 \ No newline at end of file diff --git a/.ruby-version b/.ruby-version index be94e6f..2c6109e 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.2.2 +3.3.4 \ No newline at end of file diff --git a/Gemfile b/Gemfile index 41f1bd3..329d164 100644 --- a/Gemfile +++ b/Gemfile @@ -6,12 +6,12 @@ ruby RUBY_VERSION gem "concurrent-ruby", "= 1.3.4" -gem "decidim", "~> 0.29" +gem "decidim", "~> 0.31" gem "decidim-ai", path: "." -gem "decidim-debates", "~> 0.29" -gem "decidim-initiatives", "~> 0.29" -gem "decidim-meetings", "~> 0.29" -gem "decidim-proposals", "~> 0.29" +gem "decidim-debates", "~> 0.31" +gem "decidim-initiatives", "~> 0.31" +gem "decidim-meetings", "~> 0.31" +gem "decidim-proposals", "~> 0.31" gem "bootsnap", "~> 1.4" gem "puma", ">= 6.3" @@ -21,7 +21,7 @@ group :development, :test do gem "faker" - gem "decidim-dev", "~> 0.29" + gem "decidim-dev", "~> 0.31" gem "rubocop-performance" gem "simplecov", require: false diff --git a/Gemfile.lock b/Gemfile.lock index 86b55a7..d518573 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,81 +1,89 @@ PATH remote: . specs: - decidim-ai (0.29.2) + decidim-ai (0.31.0) classifier-reborn (~> 2.3.0) - decidim-core (~> 0.29.0) + decidim-core (~> 0.31.0) GEM remote: https://rubygems.org/ specs: - actioncable (7.0.8.7) - actionpack (= 7.0.8.7) - activesupport (= 7.0.8.7) + actioncable (7.2.3) + actionpack (= 7.2.3) + activesupport (= 7.2.3) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (7.0.8.7) - actionpack (= 7.0.8.7) - activejob (= 7.0.8.7) - activerecord (= 7.0.8.7) - activestorage (= 7.0.8.7) - activesupport (= 7.0.8.7) - mail (>= 2.7.1) - net-imap - net-pop - net-smtp - actionmailer (7.0.8.7) - actionpack (= 7.0.8.7) - actionview (= 7.0.8.7) - activejob (= 7.0.8.7) - activesupport (= 7.0.8.7) - mail (~> 2.5, >= 2.5.4) - net-imap - net-pop - net-smtp - rails-dom-testing (~> 2.0) - actionpack (7.0.8.7) - actionview (= 7.0.8.7) - activesupport (= 7.0.8.7) - rack (~> 2.0, >= 2.2.4) + zeitwerk (~> 2.6) + actionmailbox (7.2.3) + actionpack (= 7.2.3) + activejob (= 7.2.3) + activerecord (= 7.2.3) + activestorage (= 7.2.3) + activesupport (= 7.2.3) + mail (>= 2.8.0) + actionmailer (7.2.3) + actionpack (= 7.2.3) + actionview (= 7.2.3) + activejob (= 7.2.3) + activesupport (= 7.2.3) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (7.2.3) + actionview (= 7.2.3) + activesupport (= 7.2.3) + cgi + nokogiri (>= 1.8.5) + racc + rack (>= 2.2.4, < 3.3) + rack-session (>= 1.0.1) rack-test (>= 0.6.3) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (7.0.8.7) - actionpack (= 7.0.8.7) - activerecord (= 7.0.8.7) - activestorage (= 7.0.8.7) - activesupport (= 7.0.8.7) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (7.2.3) + actionpack (= 7.2.3) + activerecord (= 7.2.3) + activestorage (= 7.2.3) + activesupport (= 7.2.3) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.0.8.7) - activesupport (= 7.0.8.7) + actionview (7.2.3) + activesupport (= 7.2.3) builder (~> 3.1) - erubi (~> 1.4) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.1, >= 1.2.0) + cgi + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) active_link_to (1.0.5) actionpack addressable - activejob (7.0.8.7) - activesupport (= 7.0.8.7) + activejob (7.2.3) + activesupport (= 7.2.3) globalid (>= 0.3.6) - activemodel (7.0.8.7) - activesupport (= 7.0.8.7) - activerecord (7.0.8.7) - activemodel (= 7.0.8.7) - activesupport (= 7.0.8.7) - activestorage (7.0.8.7) - actionpack (= 7.0.8.7) - activejob (= 7.0.8.7) - activerecord (= 7.0.8.7) - activesupport (= 7.0.8.7) + activemodel (7.2.3) + activesupport (= 7.2.3) + activerecord (7.2.3) + activemodel (= 7.2.3) + activesupport (= 7.2.3) + timeout (>= 0.4.0) + activestorage (7.2.3) + actionpack (= 7.2.3) + activejob (= 7.2.3) + activerecord (= 7.2.3) + activesupport (= 7.2.3) marcel (~> 1.0) - mini_mime (>= 1.1.0) - activesupport (7.0.8.7) - concurrent-ruby (~> 1.0, >= 1.0.2) + activesupport (7.2.3) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) - tzinfo (~> 2.0) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) acts_as_list (1.2.4) activerecord (>= 6.1) activesupport (>= 6.1) @@ -83,11 +91,12 @@ GEM public_suffix (>= 2.0.2, < 7.0) ast (2.4.2) base64 (0.2.0) - batch-loader (1.5.0) + batch-loader (2.0.6) bcrypt (3.1.20) - better_html (2.1.1) - actionview (>= 6.0) - activesupport (>= 6.0) + benchmark (0.5.0) + better_html (2.2.0) + actionview (>= 7.0) + activesupport (>= 7.0) ast (~> 2.0) erubi (~> 1.4) parser (>= 2.4) @@ -96,9 +105,9 @@ GEM bindex (0.8.1) bootsnap (1.18.4) msgpack (~> 1.2) - browser (2.7.1) + browser (6.2.0) builder (3.3.0) - bullet (7.1.6) + bullet (8.0.8) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) byebug (11.1.3) @@ -122,139 +131,155 @@ GEM cells-rails (0.1.6) actionpack (>= 5.0) cells (>= 4.1.6, < 5.0.0) + cgi (0.5.1) charlock_holmes (0.7.9) + chartkick (5.1.5) childprocess (5.1.0) logger (~> 1.5) + chunky_png (1.4.0) classifier-reborn (2.3.0) fast-stemmer (~> 1.0) matrix (~> 0.4) cmdparse (3.0.7) - commonmarker (0.23.11) + commonmarker (0.23.12) concurrent-ruby (1.3.4) - crack (1.0.0) + connection_pool (2.5.5) + crack (1.0.1) bigdecimal rexml crass (1.0.6) css_parser (1.21.0) addressable csv (3.3.2) - dartsass (1.49.8) + data_migrate (11.3.1) + activerecord (>= 6.1) + railties (>= 6.1) date (3.4.1) date_validator (0.12.0) activemodel (>= 3) activesupport (>= 3) - decidim (0.29.2) - decidim-accountability (= 0.29.2) - decidim-admin (= 0.29.2) - decidim-api (= 0.29.2) - decidim-assemblies (= 0.29.2) - decidim-blogs (= 0.29.2) - decidim-budgets (= 0.29.2) - decidim-comments (= 0.29.2) - decidim-core (= 0.29.2) - decidim-debates (= 0.29.2) - decidim-forms (= 0.29.2) - decidim-generators (= 0.29.2) - decidim-meetings (= 0.29.2) - decidim-pages (= 0.29.2) - decidim-participatory_processes (= 0.29.2) - decidim-proposals (= 0.29.2) - decidim-sortitions (= 0.29.2) - decidim-surveys (= 0.29.2) - decidim-system (= 0.29.2) - decidim-verifications (= 0.29.2) - decidim-accountability (0.29.2) - decidim-comments (= 0.29.2) - decidim-core (= 0.29.2) - decidim-admin (0.29.2) + decidim (0.31.1) + decidim-accountability (= 0.31.1) + decidim-admin (= 0.31.1) + decidim-api (= 0.31.1) + decidim-assemblies (= 0.31.1) + decidim-blogs (= 0.31.1) + decidim-budgets (= 0.31.1) + decidim-comments (= 0.31.1) + decidim-core (= 0.31.1) + decidim-debates (= 0.31.1) + decidim-forms (= 0.31.1) + decidim-generators (= 0.31.1) + decidim-meetings (= 0.31.1) + decidim-pages (= 0.31.1) + decidim-participatory_processes (= 0.31.1) + decidim-proposals (= 0.31.1) + decidim-sortitions (= 0.31.1) + decidim-surveys (= 0.31.1) + decidim-system (= 0.31.1) + decidim-verifications (= 0.31.1) + decidim-accountability (0.31.1) + decidim-comments (= 0.31.1) + decidim-core (= 0.31.1) + decidim-admin (0.31.1) active_link_to (~> 1.0) - decidim-core (= 0.29.2) + decidim-core (= 0.31.1) devise (~> 4.7) devise-i18n (~> 1.2) devise_invitable (~> 2.0, >= 2.0.9) - decidim-api (0.29.2) - decidim-core (= 0.29.2) - graphql (~> 2.2.6) - graphql-docs (~> 4.0) + decidim-api (0.31.1) + decidim-core (= 0.31.1) + devise-jwt (~> 0.12.1) + graphql (~> 2.4.0, >= 2.4.17) + graphql-docs (~> 5.0) rack-cors (~> 1.0) - decidim-assemblies (0.29.2) - decidim-core (= 0.29.2) - decidim-blogs (0.29.2) - decidim-admin (= 0.29.2) - decidim-comments (= 0.29.2) - decidim-core (= 0.29.2) - decidim-budgets (0.29.2) - decidim-comments (= 0.29.2) - decidim-core (= 0.29.2) - decidim-comments (0.29.2) - decidim-core (= 0.29.2) + decidim-assemblies (0.31.1) + decidim-core (= 0.31.1) + decidim-blogs (0.31.1) + decidim-admin (= 0.31.1) + decidim-comments (= 0.31.1) + decidim-core (= 0.31.1) + decidim-budgets (0.31.1) + decidim-comments (= 0.31.1) + decidim-core (= 0.31.1) + decidim-comments (0.31.1) + decidim-core (= 0.31.1) redcarpet (~> 3.5, >= 3.5.1) - decidim-core (0.29.2) + decidim-core (0.31.1) active_link_to (~> 1.0) acts_as_list (~> 1.0) - batch-loader (~> 1.2) - browser (~> 2.7) + batch-loader (~> 2.0) + browser (~> 6.2.0) cells-erb (~> 0.1.0) cells-rails (~> 0.1.3) charlock_holmes (~> 0.7) - concurrent-ruby (= 1.3.4) + chartkick (~> 5.1.2) + concurrent-ruby (~> 1.3.0) + connection_pool (< 3) + data_migrate (~> 11.3) date_validator (~> 0.12.0) devise (~> 4.7) - devise-i18n (~> 1.2, < 1.11.1) + devise-i18n (~> 1.2) diffy (~> 3.3) doorkeeper (~> 5.6, >= 5.6.6) doorkeeper-i18n (~> 4.0) file_validators (~> 3.0) fog-local (~> 0.6) - foundation_rails_helper (~> 4.0) geocoder (~> 1.8) hashdiff (>= 0.4.0, < 2.0.0) + hexapdf (~> 1.1.0) image_processing (~> 1.2) invisible_captcha (~> 0.12) kaminari (~> 1.2, >= 1.2.1) loofah (~> 2.19, >= 2.19.1) mime-types (>= 1.16, < 4.0) mini_magick (~> 4.9) - net-smtp (~> 0.3.1) + net-smtp (~> 0.5.0) nokogiri (~> 1.16, >= 1.16.2) omniauth (~> 2.0) omniauth-facebook (~> 5.0) omniauth-google-oauth2 (~> 1.0) omniauth-rails_csrf_protection (~> 1.0) omniauth-twitter (~> 1.4) - paper_trail (~> 12.0) - pg (~> 1.4.0, < 2) + paper_trail (~> 16.0) + paranoia (~> 3.0.0) + pg (~> 1.5.0, < 2) pg_search (~> 2.2) premailer-rails (~> 1.10) - psych (~> 4.0) rack (~> 2.2, >= 2.2.8.1) rack-attack (~> 6.0) - rails (~> 7.0.8) + rails (~> 7.2.0, >= 7.2.2.2) rails-i18n (~> 7.0) - ransack (~> 3.2.1) + ransack (~> 4.2.0) redis (~> 4.1) - request_store (~> 1.5.0) + request_store (~> 1.7.0) + rqrcode (~> 2.2.0) rubyXL (~> 3.4) rubyzip (~> 2.0) - shakapacker (~> 7.1.0) - valid_email2 (~> 4.0) + shakapacker (~> 8.3.0) + valid_email2 (~> 7.0) web-push (~> 3.0) - wisper (~> 2.0) - decidim-debates (0.29.2) - decidim-comments (= 0.29.2) - decidim-core (= 0.29.2) - decidim-dev (0.29.2) - bullet (~> 7.1.6) + wisper (~> 3.0) + decidim-debates (0.31.1) + decidim-comments (= 0.31.1) + decidim-core (= 0.31.1) + decidim-dev (0.31.1) + bullet (~> 8.0.0) byebug (~> 11.0) capybara (~> 3.39) - decidim (= 0.29.2) - erb_lint (~> 0.4.0) + decidim-admin (= 0.31.1) + decidim-api (= 0.31.1) + decidim-comments (= 0.31.1) + decidim-core (= 0.31.1) + decidim-generators (= 0.31.1) + decidim-verifications (= 0.31.1) + erb_lint (~> 0.8.0) factory_bot_rails (~> 6.2) faker (~> 3.2) i18n-tasks (~> 1.0) nokogiri (~> 1.16, >= 1.16.2) parallel_tests (~> 4.2) - puma (~> 6.2, >= 6.3.1) + puma (~> 6.5) rails-controller-testing (~> 1.0) rspec (~> 3.12) rspec-cells (~> 0.3.7) @@ -262,15 +287,16 @@ GEM rspec-rails (~> 6.0) rspec-retry (~> 0.6.2) rspec_junit_formatter (~> 0.6.0) - rubocop (~> 1.65.0) - rubocop-capybara (~> 2.21) - rubocop-factory_bot (~> 2.26) - rubocop-faker (~> 1.1) - rubocop-performance (~> 1.21) - rubocop-rails (~> 2.25) - rubocop-rspec (~> 3.0) - rubocop-rspec_rails (~> 2.30) - rubocop-rubycw (~> 0.1) + rubocop (~> 1.78.0) + rubocop-capybara (~> 2.22.0, >= 2.22.1) + rubocop-factory_bot (~> 2.27.0) + rubocop-faker (~> 1.3, >= 1.3.0) + rubocop-graphql (~> 1.5, >= 1.5.6) + rubocop-performance (~> 1.25, >= 1.25.0) + rubocop-rails (~> 2.32.0, >= 2.32.0) + rubocop-rspec (~> 3.0, >= 3.6.0) + rubocop-rspec_rails (~> 2.31.0) + rubocop-rubycw (~> 0.2.0) selenium-webdriver (~> 4.9) simplecov (~> 0.22.0) simplecov-cobertura (~> 2.1.0) @@ -279,47 +305,44 @@ GEM w3c_rspec_validators (~> 0.3.0) webmock (~> 3.18) wisper-rspec (~> 1.0) - decidim-forms (0.29.2) - decidim-core (= 0.29.2) - wicked_pdf (~> 2.1) - decidim-generators (0.29.2) - decidim-core (= 0.29.2) - decidim-initiatives (0.29.2) - decidim-admin (= 0.29.2) - decidim-comments (= 0.29.2) - decidim-core (= 0.29.2) - decidim-verifications (= 0.29.2) - hexapdf (~> 0.32.0) - wicked_pdf (~> 2.1) - decidim-meetings (0.29.2) - decidim-core (= 0.29.2) - decidim-forms (= 0.29.2) + decidim-forms (0.31.1) + decidim-core (= 0.31.1) + decidim-generators (0.31.1) + decidim-core (= 0.31.1) + decidim-initiatives (0.31.1) + decidim-admin (= 0.31.1) + decidim-comments (= 0.31.1) + decidim-core (= 0.31.1) + decidim-verifications (= 0.31.1) + decidim-meetings (0.31.1) + decidim-core (= 0.31.1) + decidim-forms (= 0.31.1) icalendar (~> 2.5) - decidim-pages (0.29.2) - decidim-core (= 0.29.2) - decidim-participatory_processes (0.29.2) - decidim-core (= 0.29.2) - decidim-proposals (0.29.2) - decidim-comments (= 0.29.2) - decidim-core (= 0.29.2) - doc2text (~> 0.4.7) + decidim-pages (0.31.1) + decidim-core (= 0.31.1) + decidim-participatory_processes (0.31.1) + decidim-core (= 0.31.1) + decidim-proposals (0.31.1) + decidim-comments (= 0.31.1) + decidim-core (= 0.31.1) + doc2text (~> 0.4.0, >= 0.4.8) redcarpet (~> 3.5, >= 3.5.1) - decidim-sortitions (0.29.2) - decidim-admin (= 0.29.2) - decidim-comments (= 0.29.2) - decidim-core (= 0.29.2) - decidim-proposals (= 0.29.2) - decidim-surveys (0.29.2) - decidim-core (= 0.29.2) - decidim-forms (= 0.29.2) - decidim-system (0.29.2) + decidim-sortitions (0.31.1) + decidim-admin (= 0.31.1) + decidim-comments (= 0.31.1) + decidim-core (= 0.31.1) + decidim-proposals (= 0.31.1) + decidim-surveys (0.31.1) + decidim-core (= 0.31.1) + decidim-forms (= 0.31.1) + decidim-system (0.31.1) active_link_to (~> 1.0) - decidim-core (= 0.29.2) + decidim-core (= 0.31.1) devise (~> 4.7) devise-i18n (~> 1.2) devise_invitable (~> 2.0, >= 2.0.9) - decidim-verifications (0.29.2) - decidim-core (= 0.29.2) + decidim-verifications (0.31.1) + decidim-core (= 0.31.1) declarative-builder (0.2.0) trailblazer-option (~> 0.1.0) declarative-option (0.1.0) @@ -331,24 +354,39 @@ GEM warden (~> 1.2.3) devise-i18n (1.11.0) devise (>= 4.9.0) - devise_invitable (2.0.9) + devise-jwt (0.12.1) + devise (~> 4.0) + warden-jwt_auth (~> 0.10) + devise_invitable (2.0.11) actionmailer (>= 5.0) devise (>= 4.6) - diff-lcs (1.6.0) + diff-lcs (1.6.2) diffy (3.4.3) - doc2text (0.4.7) - nokogiri (>= 1.13.2, < 1.17.0) + doc2text (0.4.8) + nokogiri (>= 1.18.2) rubyzip (~> 2.3.0) docile (1.4.1) doorkeeper (5.8.1) railties (>= 5) doorkeeper-i18n (4.0.1) - erb_lint (0.4.0) + drb (2.2.3) + dry-auto_inject (1.1.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-configurable (1.3.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-core (1.2.0) + concurrent-ruby (~> 1.0) + logger + zeitwerk (~> 2.6) + erb (6.0.1) + erb_lint (0.8.0) activesupport better_html (>= 2.0.1) parser (>= 2.7.1.4) rainbow - rubocop + rubocop (>= 1) smart_properties erbse (0.1.4) temple @@ -357,11 +395,11 @@ GEM excon (1.2.3) extended-markdown-filter (0.7.0) html-pipeline (~> 2.9) - factory_bot (6.5.1) + factory_bot (6.5.6) activesupport (>= 6.1.0) - factory_bot_rails (6.4.4) + factory_bot_rails (6.5.1) factory_bot (~> 6.5) - railties (>= 5.0.0) + railties (>= 6.1.0) faker (3.5.1) i18n (>= 1.8.11, < 2) faraday (2.12.2) @@ -373,6 +411,7 @@ GEM fast-stemmer (1.0.2) ffi (1.17.1-arm64-darwin) ffi (1.17.1-x86_64-linux-gnu) + fiber-storage (1.0.1) file_validators (3.0.0) activemodel (>= 3.2) mime-types (>= 1.0) @@ -384,11 +423,6 @@ GEM fog-local (0.8.0) fog-core (>= 1.27, < 3.0) formatador (1.1.0) - foundation_rails_helper (4.0.1) - actionpack (>= 4.1, < 7.1) - activemodel (>= 4.1, < 7.1) - activesupport (>= 4.1, < 7.1) - railties (>= 4.1, < 7.1) gemoji (3.0.1) geocoder (1.8.5) base64 (>= 0.1.0) @@ -396,22 +430,33 @@ GEM geom2d (0.4.1) globalid (1.2.1) activesupport (>= 6.1) - graphql (2.2.16) + google-protobuf (4.33.4-arm64-darwin) + bigdecimal + rake (>= 13) + google-protobuf (4.33.4-x86_64-linux-gnu) + bigdecimal + rake (>= 13) + graphql (2.4.17) base64 - graphql-docs (4.0.0) + fiber-storage + logger + graphql-docs (5.2.0) commonmarker (~> 0.23, >= 0.23.6) - dartsass (~> 1.49) escape_utils (~> 1.2) extended-markdown-filter (~> 0.4) gemoji (~> 3.0) graphql (~> 2.0) html-pipeline (~> 2.14, >= 2.14.3) + logger (~> 1.6) + ostruct (~> 0.6) + sass-embedded (~> 1.58) hashdiff (1.1.2) hashie (5.0.0) - hexapdf (0.32.2) + hexapdf (1.1.1) cmdparse (~> 3.0, >= 3.0.3) - geom2d (~> 0.3) + geom2d (~> 0.4, >= 0.4.1) openssl (>= 2.2.1) + strscan (>= 3.1.2) highline (3.1.2) reline html-pipeline (2.14.3) @@ -420,18 +465,22 @@ GEM htmlentities (4.3.4) i18n (1.14.7) concurrent-ruby (~> 1.0) - i18n-tasks (1.0.14) + i18n-tasks (1.1.2) activesupport (>= 4.0.2) ast (>= 2.1.0) erubi - highline (>= 2.0.0) + highline (>= 3.0.0) i18n parser (>= 3.2.2.1) + prism rails-i18n rainbow (>= 2.2.2, < 4.0) + ruby-progressbar (~> 1.8, >= 1.8.1) terminal-table (>= 1.5.1) - icalendar (2.10.3) + icalendar (2.12.1) + base64 ice_cube (~> 0.16) + logger ostruct ice_cube (0.17.0) image_processing (1.14.0) @@ -439,7 +488,11 @@ GEM ruby-vips (>= 2.0.17, < 3) invisible_captcha (0.13.0) rails (>= 3.2.0) - io-console (0.8.0) + io-console (0.8.2) + irb (1.16.0) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) json (2.10.1) jwt (2.10.1) base64 @@ -467,6 +520,7 @@ GEM letter_opener (~> 1.7) railties (>= 5.2) rexml + lint_roller (1.1.0) listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) @@ -481,7 +535,6 @@ GEM net-smtp marcel (1.0.4) matrix (0.4.2) - method_source (1.1.0) mime-types (3.6.0) logger mime-types-data (~> 3.2015) @@ -501,12 +554,12 @@ GEM net-protocol net-protocol (0.2.2) timeout - net-smtp (0.3.4) + net-smtp (0.5.1) net-protocol nio4r (2.7.4) - nokogiri (1.16.8-arm64-darwin) + nokogiri (1.19.0-arm64-darwin) racc (~> 1.4) - nokogiri (1.16.8-x86_64-linux) + nokogiri (1.19.0-x86_64-linux-gnu) racc (~> 1.4) oauth (1.1.0) oauth-tty (~> 1.0, >= 1.0.1) @@ -547,20 +600,25 @@ GEM rack openssl (3.3.0) orm_adapter (0.5.0) - ostruct (0.6.1) - paper_trail (12.3.0) - activerecord (>= 5.2) - request_store (~> 1.1) + ostruct (0.6.3) + package_json (0.2.0) + paper_trail (16.0.0) + activerecord (>= 6.1) + request_store (~> 1.4) parallel (1.26.3) - parallel_tests (4.9.0) + parallel_tests (4.10.1) parallel - parser (3.3.7.1) + paranoia (3.0.1) + activerecord (>= 6, < 8.1) + parser (3.3.10.1) ast (~> 2.4.1) racc - pg (1.4.6) + pg (1.5.9) pg_search (2.3.7) activerecord (>= 6.1) activesupport (>= 6.1) + pp (0.6.3) + prettyprint premailer (1.27.0) addressable css_parser (>= 1.19.0) @@ -569,6 +627,8 @@ GEM actionmailer (>= 3) net-smtp premailer (~> 1.7, >= 1.7.9) + prettyprint (0.2.0) + prism (1.9.0) psych (4.0.6) stringio public_suffix (6.0.1) @@ -585,22 +645,27 @@ GEM rack (~> 2.2, >= 2.2.4) rack-proxy (0.7.7) rack + rack-session (1.0.2) + rack (< 3) rack-test (2.2.0) rack (>= 1.3) - rails (7.0.8.7) - actioncable (= 7.0.8.7) - actionmailbox (= 7.0.8.7) - actionmailer (= 7.0.8.7) - actionpack (= 7.0.8.7) - actiontext (= 7.0.8.7) - actionview (= 7.0.8.7) - activejob (= 7.0.8.7) - activemodel (= 7.0.8.7) - activerecord (= 7.0.8.7) - activestorage (= 7.0.8.7) - activesupport (= 7.0.8.7) + rackup (1.0.1) + rack (< 3) + webrick + rails (7.2.3) + actioncable (= 7.2.3) + actionmailbox (= 7.2.3) + actionmailer (= 7.2.3) + actionpack (= 7.2.3) + actiontext (= 7.2.3) + actionview (= 7.2.3) + activejob (= 7.2.3) + activemodel (= 7.2.3) + activerecord (= 7.2.3) + activestorage (= 7.2.3) + activesupport (= 7.2.3) bundler (>= 1.15.0) - railties (= 7.0.8.7) + railties (= 7.2.3) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -615,49 +680,60 @@ GEM rails-i18n (7.0.10) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) - railties (7.0.8.7) - actionpack (= 7.0.8.7) - activesupport (= 7.0.8.7) - method_source + railties (7.2.3) + actionpack (= 7.2.3) + activesupport (= 7.2.3) + cgi + irb (~> 1.13) + rackup (>= 1.0.0) rake (>= 12.2) - thor (~> 1.0) - zeitwerk (~> 2.5) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) - ransack (3.2.1) + ransack (4.2.1) activerecord (>= 6.1.5) activesupport (>= 6.1.5) i18n rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) - redcarpet (3.6.0) + rdoc (7.1.0) + erb + psych (>= 4.0.0) + tsort + redcarpet (3.6.1) redis (4.8.1) regexp_parser (2.10.0) - reline (0.6.0) + reline (0.6.3) io-console (~> 0.5) - request_store (1.5.1) + request_store (1.7.0) rack (>= 1.4) responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) rexml (3.4.1) - rspec (3.13.0) + rqrcode (2.2.0) + chunky_png (~> 1.0) + rqrcode_core (~> 1.0) + rqrcode_core (1.2.0) + rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) rspec-cells (0.3.10) cells (>= 4.0.0, < 6.0.0) rspec-rails (>= 3.0.0) - rspec-core (3.13.3) + rspec-core (3.13.6) rspec-support (~> 3.13.0) - rspec-expectations (3.13.3) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-html-matchers (0.10.0) nokogiri (~> 1) rspec (>= 3.0.0.a) - rspec-mocks (3.13.2) + rspec-mocks (3.13.7) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-rails (6.1.5) @@ -670,44 +746,56 @@ GEM rspec-support (~> 3.13) rspec-retry (0.6.2) rspec-core (> 3.3) - rspec-support (3.13.2) + rspec-support (3.13.7) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) - rubocop (1.65.1) + rubocop (1.78.0) json (~> 2.3) - language_server-protocol (>= 3.17.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 2.4, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.31.1, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.45.1, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.38.0) - parser (>= 3.3.1.0) - rubocop-capybara (2.21.0) - rubocop (~> 1.41) - rubocop-factory_bot (2.26.1) - rubocop (~> 1.61) - rubocop-faker (1.2.0) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-capybara (2.22.1) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-factory_bot (2.27.1) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-faker (1.3.0) faker (>= 2.12.0) - rubocop (>= 1.13.0) - rubocop-performance (1.23.1) - rubocop (>= 1.48.1, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails (2.29.0) + lint_roller (~> 1.1) + rubocop (>= 1.72.1) + rubocop-graphql (1.5.6) + lint_roller (~> 1.1) + rubocop (>= 1.72.1, < 2) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.32.0) activesupport (>= 4.2.0) + lint_roller (~> 1.1) rack (>= 1.1) - rubocop (>= 1.52.0, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rspec (3.4.0) - rubocop (~> 1.61) - rubocop-rspec_rails (2.30.0) - rubocop (~> 1.61) - rubocop-rspec (~> 3, >= 3.0.1) - rubocop-rubycw (0.1.6) - rubocop (~> 1.0) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rspec (3.7.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-rspec_rails (2.31.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-rspec (~> 3.5) + rubocop-rubycw (0.2.2) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) ruby-progressbar (1.13.0) ruby-vips (2.2.3) ffi (~> 1.12) @@ -716,15 +804,21 @@ GEM nokogiri (>= 1.10.8) rubyzip (>= 1.3.0) rubyzip (2.3.2) - selenium-webdriver (4.28.0) + sass-embedded (1.97.3-arm64-darwin) + google-protobuf (~> 4.31) + sass-embedded (1.97.3-x86_64-linux-gnu) + google-protobuf (~> 4.31) + securerandom (0.4.1) + selenium-webdriver (4.40.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) - rubyzip (>= 1.2.2, < 3.0) + rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) semantic_range (3.1.0) - shakapacker (7.1.0) + shakapacker (8.3.0) activesupport (>= 5.2) + package_json rack-proxy (>= 0.6.1) railties (>= 5.2) semantic_range (>= 2.3.0) @@ -741,11 +835,12 @@ GEM snaky_hash (2.0.1) hashie version_gem (~> 1.1, >= 1.1.1) - spring (4.2.1) + spring (4.4.2) spring-watcher-listen (2.1.0) listen (>= 2.7, < 4.0) spring (>= 4) stringio (3.1.3) + strscan (3.1.7) temple (0.10.3) terminal-table (4.0.0) unicode-display_width (>= 1.1.1, < 4) @@ -753,14 +848,16 @@ GEM tilt (2.6.0) timeout (0.4.3) trailblazer-option (0.1.2) + tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) uber (0.1.0) unicode-display_width (2.6.0) - uniform_notifier (1.16.0) + uniform_notifier (1.18.0) uri (1.0.2) - valid_email2 (4.0.6) - activemodel (>= 3.2) + useragent (0.16.11) + valid_email2 (7.0.13) + activemodel (>= 6.0) mail (~> 2.5) version_gem (1.1.4) w3c_rspec_validators (0.3.0) @@ -773,6 +870,11 @@ GEM rexml (~> 3.2) warden (1.2.9) rack (>= 2.0.9) + warden-jwt_auth (0.12.0) + dry-auto_inject (>= 0.8, < 2) + dry-configurable (>= 0.13, < 2) + jwt (>= 2.1, < 4) + warden (~> 1.2) web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) @@ -781,19 +883,17 @@ GEM web-push (3.0.1) jwt (~> 2.0) openssl (~> 3.0) - webmock (3.25.0) + webmock (3.26.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) + webrick (1.9.2) websocket (1.2.11) websocket-driver (0.7.7) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) - wicked_pdf (2.8.2) - activesupport - ostruct - wisper (2.0.1) + wisper (3.0.0) wisper-rspec (1.1.0) xpath (3.2.0) nokogiri (~> 1.8) @@ -801,19 +901,20 @@ GEM PLATFORMS arm64-darwin-22 + arm64-darwin-24 x86_64-linux-gnu DEPENDENCIES bootsnap (~> 1.4) byebug (~> 11.0) concurrent-ruby (= 1.3.4) - decidim (~> 0.29) + decidim (~> 0.31) decidim-ai! - decidim-debates (~> 0.29) - decidim-dev (~> 0.29) - decidim-initiatives (~> 0.29) - decidim-meetings (~> 0.29) - decidim-proposals (~> 0.29) + decidim-debates (~> 0.31) + decidim-dev (~> 0.31) + decidim-initiatives (~> 0.31) + decidim-meetings (~> 0.31) + decidim-proposals (~> 0.31) faker letter_opener_web (~> 2.0) listen (~> 3.1) @@ -824,7 +925,7 @@ DEPENDENCIES web-console (~> 4.2) RUBY VERSION - ruby 3.2.2p53 + ruby 3.3.4p94 BUNDLED WITH 2.5.15 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 90e218b..8ada3d0 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 @@ -9,6 +9,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))) @@ -26,7 +27,15 @@ def perform(reportable, author, locale, fields) private def form - @form ||= Decidim::ReportForm.new(reason: "spam", details: classifier.classification_log).with_context(current_user: reporting_user) + @form ||= Decidim::ReportForm.new( + reason: "spam", + details: classifier.classification_log, + hide: Decidim::Ai::SpamDetection.hide_reported_resources_automatically + ).with_context( + current_user: reporting_user, + can_hide: false, + marked_as_spam: true + ) end def reporting_user diff --git a/decidim-ai.gemspec b/decidim-ai.gemspec index b582149..3ab312e 100644 --- a/decidim-ai.gemspec +++ b/decidim-ai.gemspec @@ -17,7 +17,7 @@ Gem::Specification.new do |s| "homepage_uri" => "https://decidim.org", "source_code_uri" => "https://github.com/decidim/decidim" } - s.required_ruby_version = "~> 3.2.0" + s.required_ruby_version = "~> 3.3.4" s.name = "decidim-ai" s.summary = "A Decidim module with AI tools" @@ -26,5 +26,5 @@ Gem::Specification.new do |s| s.files = Dir["{app,config,db,lib,vendor}/**/*", "Rakefile", "README.md"] s.add_dependency "classifier-reborn", "~> 2.3.0" - s.add_dependency "decidim-core", "~> 0.29.0" + s.add_dependency "decidim-core", "~> 0.31.0" end diff --git a/lib/decidim/ai.rb b/lib/decidim/ai.rb index 2a6f280..dbec56e 100644 --- a/lib/decidim/ai.rb +++ b/lib/decidim/ai.rb @@ -10,8 +10,6 @@ module Ai module Overrides autoload :UpdateAccount, "decidim/ai/overrides/update_account" - autoload :UpdateUserGroup, "decidim/ai/overrides/update_user_group" - autoload :CreateUserGroup, "decidim/ai/overrides/create_user_group" end include ActiveSupport::Configurable diff --git a/lib/decidim/ai/engine.rb b/lib/decidim/ai/engine.rb index 722d155..81c0cff 100644 --- a/lib/decidim/ai/engine.rb +++ b/lib/decidim/ai/engine.rb @@ -7,6 +7,12 @@ class Engine < ::Rails::Engine paths["db/migrate"] = nil + initializer "decidim_ai.data_migrate", after: "decidim_core.data_migrate" do + DataMigrate.configure do |config| + config.data_migrations_path << root.join("db/data").to_s + end + end + initializer "decidim_ai.resource_classifiers" do |_app| Decidim::Ai::SpamDetection.resource_analyzers.each do |analyzer| Decidim::Ai::SpamDetection.resource_registry.register_analyzer(**analyzer) @@ -19,24 +25,12 @@ class Engine < ::Rails::Engine end end - initializer "decidim_ai.patch_resources" do - Rails.application.config.to_prepare do - Decidim::UpdateAccount.prepend Decidim::Ai::Overrides::UpdateAccount - Decidim::UpdateUserGroup.prepend Decidim::Ai::Overrides::UpdateUserGroup - Decidim::CreateUserGroup.prepend Decidim::Ai::Overrides::CreateUserGroup - end - end - 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.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.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.user_spam_analyzer_job.constantize.perform_later(data[:resource]) + Decidim::Ai::SpamDetection::UserSpamAnalyzerJob + .set(wait: Decidim::Ai::SpamDetection.spam_detection_delay) + .perform_later(data[:resource]) end end end @@ -44,10 +38,16 @@ 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.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body]) + Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob + .set(wait: Decidim::Ai::SpamDetection.spam_detection_delay) + .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.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body]) + Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob + .set(wait: Decidim::Ai::SpamDetection.spam_detection_delay) + .perform_later(data[:resource], + data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body]) end end end @@ -55,12 +55,16 @@ 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.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), - [:description, :title, :location_hints, :registration_terms]) + Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob + .set(wait: Decidim::Ai::SpamDetection.spam_detection_delay) + .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.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), - [:description, :title, :location_hints, :registration_terms]) + Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob + .set(wait: Decidim::Ai::SpamDetection.spam_detection_delay) + .perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), + [:description, :title, :location_hints, :registration_terms]) end end end @@ -68,10 +72,16 @@ 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.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:description, :title]) + Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob + .set(wait: Decidim::Ai::SpamDetection.spam_detection_delay) + .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.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:description, :title]) + Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob + .set(wait: Decidim::Ai::SpamDetection.spam_detection_delay) + .perform_later(data[:resource], + data.dig(:extra, :event_author), data.dig(:extra, :locale), [:description, :title]) end end end @@ -79,10 +89,16 @@ 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.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:description, :title]) + Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob + .set(wait: Decidim::Ai::SpamDetection.spam_detection_delay) + .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.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:description, :title]) + Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob + .set(wait: Decidim::Ai::SpamDetection.spam_detection_delay) + .perform_later(data[:resource], + data.dig(:extra, :event_author), data.dig(:extra, :locale), [:description, :title]) end end end @@ -90,16 +106,28 @@ 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.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body, :title]) + Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob + .set(wait: Decidim::Ai::SpamDetection.spam_detection_delay) + .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.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body, :title]) + Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob + .set(wait: Decidim::Ai::SpamDetection.spam_detection_delay) + .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.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body, :title]) + Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob + .set(wait: Decidim::Ai::SpamDetection.spam_detection_delay) + .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.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body, :title]) + Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob + .set(wait: Decidim::Ai::SpamDetection.spam_detection_delay) + .perform_later(data[:resource], + data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body, :title]) end end end diff --git a/lib/decidim/ai/language/language.rb b/lib/decidim/ai/language/language.rb index 250e868..6e74db2 100644 --- a/lib/decidim/ai/language/language.rb +++ b/lib/decidim/ai/language/language.rb @@ -16,7 +16,7 @@ module Language # end # end config_accessor :formatter do - "Decidim::Ai::Language::Formatter" + Decidim::Env.new("DECIDIM_AI_LANGUAGE_FORMATTER", "Decidim::Ai::Language::Formatter").value end end end diff --git a/lib/decidim/ai/overrides/create_user_group.rb b/lib/decidim/ai/overrides/create_user_group.rb deleted file mode 100644 index e22413e..0000000 --- a/lib/decidim/ai/overrides/create_user_group.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module Decidim - module Ai - module Overrides - module CreateUserGroup - def call - return broadcast(:invalid) if form.invalid? - - with_events(with_transaction: true) do - create_user_group - create_membership - end - notify_admins - - broadcast(:ok, @user_group) - end - - def event_arguments - { resource: @user_group } - end - end - end - end -end diff --git a/lib/decidim/ai/overrides/update_account.rb b/lib/decidim/ai/overrides/update_account.rb deleted file mode 100644 index c6ab7fc..0000000 --- a/lib/decidim/ai/overrides/update_account.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -module Decidim - module Ai - module Overrides - module UpdateAccount - def call - return broadcast(:invalid, @form.password) unless @form.valid? - - update_personal_data - update_avatar - update_password - - if current_user.valid? - with_events do - changes = current_user.changed - current_user.save! - send_update_summary!(changes) - end - notify_followers - broadcast(:ok, current_user.unconfirmed_email.present?) - else - [:avatar, :password].each do |key| - @form.errors.add key, current_user.errors[key] if current_user.errors.has_key? key - end - broadcast(:invalid, @form.password) - end - end - - def event_arguments - { resource: current_user } - end - end - end - end -end diff --git a/lib/decidim/ai/overrides/update_user_group.rb b/lib/decidim/ai/overrides/update_user_group.rb deleted file mode 100644 index 99d1888..0000000 --- a/lib/decidim/ai/overrides/update_user_group.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module Decidim - module Ai - module Overrides - module UpdateUserGroup - def update_user_group - with_events do - super - end - end - - def event_arguments - { resource: user_group } - end - end - end - end -end diff --git a/lib/decidim/ai/spam_detection/resource/base.rb b/lib/decidim/ai/spam_detection/resource/base.rb index d9db2da..3482f15 100644 --- a/lib/decidim/ai/spam_detection/resource/base.rb +++ b/lib/decidim/ai/spam_detection/resource/base.rb @@ -40,7 +40,11 @@ def error_message(klass, method_name) end def resource_hidden?(resource) - resource.class.included_modules.include?(Decidim::Reportable) && resource.hidden? + resource.class.included_modules.include?(Decidim::Reportable) && resource.hidden? && report_reasons.include?(resource.reports&.last&.reason) + end + + def report_reasons + Decidim::Report::REASONS.excluding("parent_hidden") end def classifier diff --git a/lib/decidim/ai/spam_detection/spam_detection.rb b/lib/decidim/ai/spam_detection/spam_detection.rb index d2894fa..773d586 100644 --- a/lib/decidim/ai/spam_detection/spam_detection.rb +++ b/lib/decidim/ai/spam_detection/spam_detection.rb @@ -28,10 +28,16 @@ module Strategy autoload :Bayes, "decidim/ai/spam_detection/strategy/bayes" end + # When the engine is consistently marking spam content without errors, + # you can skip human intervention by enabling this functionality + config_accessor :hide_reported_resources_automatically do + Decidim::Env.new("DECIDIM_SPAM_HIDE_REPORTED_RESOURCES_AUTOMATICALLY", false).present? + end + # This is the email address used by the spam engine to # properly identify the user that will report users and content config_accessor :reporting_user_email do - "decidim-reporting-user@example.org" + Decidim::Env.new("DECIDIM_SPAM_REPORTING_USER", "decidim-reporting-user@example.org").value end # You can configure the spam threshold for the spam detection service. @@ -39,7 +45,13 @@ module Strategy # The default value is 0.75 # Any value below the threshold will be considered spam. config_accessor :resource_score_threshold do - 0.75 + Decidim::Env.new("DECIDIM_SPAM_DETECTION_RESOURCE_SCORE_THRESHOLD", 0.75).to_f + end + + # You can configure the spam delay for the spam detection service. + # The default value is 30 seconds + config_accessor :spam_detection_delay do + Decidim::Env.new("DECIDIM_SPAM_DETECTION_DELAY_IN_SECONDS", 30).to_i.seconds end # Registered analyzers. @@ -92,7 +104,7 @@ module Strategy # Spam detection service class. # If you want to use a different spam detection service, you can use a class service having the following contract config_accessor :resource_detection_service do - "Decidim::Ai::SpamDetection::Service" + Decidim::Env.new("DECIDIM_SPAM_DETECTION_RESOURCE_SERVICE", "Decidim::Ai::SpamDetection::Service").value end # You can configure the spam threshold for the spam detection service. @@ -100,7 +112,7 @@ module Strategy # The default value is 0.75 # Any value below the threshold will be considered spam. config_accessor :user_score_threshold do - 0.75 + Decidim::Env.new("DECIDIM_SPAM_DETECTION_USER_SCORE_THRESHOLD", 0.75).to_f end # Registered analyzers. @@ -135,21 +147,16 @@ module Strategy # This config_accessor allows the implementers to change the class being used by the classifier, # in order to change the finder method or what a hidden user really is. - # The same applies for UserGroups. config_accessor :user_models do - @user_models ||= begin - user_models = {} - - user_models["Decidim::UserGroup"] = "Decidim::Ai::SpamDetection::Resource::UserBaseEntity" - user_models["Decidim::User"] = "Decidim::Ai::SpamDetection::Resource::UserBaseEntity" - user_models - end + { + "Decidim::User" => "Decidim::Ai::SpamDetection::Resource::UserBaseEntity" + } end # Spam detection service class. # If you want to use a different spam detection service, you can use a class service having the following contract config_accessor :user_detection_service do - "Decidim::Ai::SpamDetection::Service" + Decidim::Env.new("DECIDIM_SPAM_DETECTION_USER_SERVICE", "Decidim::Ai::SpamDetection::Service").value end # User spam analyzer job class. diff --git a/lib/decidim/ai/version.rb b/lib/decidim/ai/version.rb index ab4c5a6..931362d 100644 --- a/lib/decidim/ai/version.rb +++ b/lib/decidim/ai/version.rb @@ -3,7 +3,7 @@ module Decidim module Ai def self.version - "0.29.2" + "0.31.0" end end end diff --git a/spec/event_handlers/comments/user_creates_comment_spec.rb b/spec/event_handlers/comments/user_creates_comment_spec.rb index 51b6966..0bd1624 100644 --- a/spec/event_handlers/comments/user_creates_comment_spec.rb +++ b/spec/event_handlers/comments/user_creates_comment_spec.rb @@ -8,7 +8,6 @@ "comment" => { "body" => body, "alignment" => 1, - "user_group_id" => nil, "commentable" => commentable } } diff --git a/spec/event_handlers/debates/user_creates_debate_spec.rb b/spec/event_handlers/debates/user_creates_debate_spec.rb index 2ced8a1..e7e0283 100644 --- a/spec/event_handlers/debates/user_creates_debate_spec.rb +++ b/spec/event_handlers/debates/user_creates_debate_spec.rb @@ -9,9 +9,7 @@ invalid?: false, title:, description:, - user_group_id: nil, - scope:, - category:, + taxonomizations:, add_documents: attachments, documents: [], current_user: author, diff --git a/spec/event_handlers/debates/user_updates_debate_spec.rb b/spec/event_handlers/debates/user_updates_debate_spec.rb index 8f66662..17fda6c 100644 --- a/spec/event_handlers/debates/user_updates_debate_spec.rb +++ b/spec/event_handlers/debates/user_updates_debate_spec.rb @@ -7,7 +7,7 @@ Decidim::Debates::DebateForm.from_params( title:, description:, - # taxonomizations:, + taxonomizations:, id: debate.id ).with_context( current_organization: organization, diff --git a/spec/event_handlers/meetings/user_creates_meeting_spec.rb b/spec/event_handlers/meetings/user_creates_meeting_spec.rb index a6b90f0..fc5e7cc 100644 --- a/spec/event_handlers/meetings/user_creates_meeting_spec.rb +++ b/spec/event_handlers/meetings/user_creates_meeting_spec.rb @@ -15,9 +15,7 @@ address: "address", latitude: 40.1234, longitude: 2.1234, - scope:, - category:, - user_group_id: nil, + taxonomizations:, current_user: author, current_component: component, component:, diff --git a/spec/event_handlers/meetings/user_updates_meeting_spec.rb b/spec/event_handlers/meetings/user_updates_meeting_spec.rb index 430762d..9880467 100644 --- a/spec/event_handlers/meetings/user_updates_meeting_spec.rb +++ b/spec/event_handlers/meetings/user_updates_meeting_spec.rb @@ -15,9 +15,7 @@ address: "address", latitude: 40.1234, longitude: 2.1234, - scope:, - category:, - user_group_id: nil, + taxonomizations:, current_user: author, current_component: component, current_organization: organization, diff --git a/spec/event_handlers/proposals/user_creates_collaborative_draft_spec.rb b/spec/event_handlers/proposals/user_creates_collaborative_draft_spec.rb index fc6c172..247dd89 100644 --- a/spec/event_handlers/proposals/user_creates_collaborative_draft_spec.rb +++ b/spec/event_handlers/proposals/user_creates_collaborative_draft_spec.rb @@ -11,9 +11,7 @@ has_address: false, latitude: 40.1234, longitude: 2.1234, - add_documents: nil, - user_group_id: user_group.try(:id), - suggested_hashtags: [] + add_documents: nil ).with_context( current_user: author, current_organization: organization, diff --git a/spec/event_handlers/proposals/user_creates_proposal_spec.rb b/spec/event_handlers/proposals/user_creates_proposal_spec.rb index 1231fbe..2023e1a 100644 --- a/spec/event_handlers/proposals/user_creates_proposal_spec.rb +++ b/spec/event_handlers/proposals/user_creates_proposal_spec.rb @@ -6,8 +6,7 @@ let(:form) do Decidim::Proposals::ProposalForm.from_params( title:, - body:, - user_group_id: user_group.try(:id) + body: ).with_context( current_user: author, current_organization: organization, diff --git a/spec/event_handlers/proposals/user_updates_collaborative_draft_spec.rb b/spec/event_handlers/proposals/user_updates_collaborative_draft_spec.rb index b9bf1de..563b125 100644 --- a/spec/event_handlers/proposals/user_updates_collaborative_draft_spec.rb +++ b/spec/event_handlers/proposals/user_updates_collaborative_draft_spec.rb @@ -11,9 +11,7 @@ has_address: false, latitude: 40.1234, longitude: 2.1234, - add_documents: nil, - user_group_id: user_group.try(:id), - suggested_hashtags: [] + add_documents: nil ).with_context( current_user: author, current_organization: organization, diff --git a/spec/event_handlers/proposals/user_updates_proposal_spec.rb b/spec/event_handlers/proposals/user_updates_proposal_spec.rb index fc14b47..bebbcf5 100644 --- a/spec/event_handlers/proposals/user_updates_proposal_spec.rb +++ b/spec/event_handlers/proposals/user_updates_proposal_spec.rb @@ -9,8 +9,6 @@ body:, address: nil, has_address: false, - user_group_id: user_group.try(:id), - suggested_hashtags: [], attachment: nil, photos: [], add_photos: [], diff --git a/spec/event_handlers/user/user_manages_user_group_spec.rb b/spec/event_handlers/user/user_manages_user_group_spec.rb deleted file mode 100644 index 0fc5c7a..0000000 --- a/spec/event_handlers/user/user_manages_user_group_spec.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -describe "User manages user group", type: :system do - shared_examples "user content submitted to spam analysis" do - let(:queue_size) { 1 } - let(:compared_field) { :about } - let(:compared_against) { about } - let(:resource) { Decidim::UserBaseEntity } - it "updates the about text" do - expect { command.call }.to broadcast(:ok) - field = resource.last.reload.send(compared_field) - expect(field.is_a?(String) ? field : field[I18n.locale.to_s]).to eq(compared_against) - end - - it "fires the event" do - expect { command.call }.to have_enqueued_job.on_queue("spam_analysis") - .exactly(queue_size).times - end - - it "processes the event" do - perform_enqueued_jobs do - expect { command.call }.to change(Decidim::UserReport, :count).by_at_least(spam_count) - expect(Decidim::UserReport.count).to eq(spam_count) - end - end - end - - let(:data) do - { - "group" => { - name: "User group name", - nickname: "nickname", - email: "user@myrealdomain.org", - phone: "Y1fERVzL2F", - document_number: "123456780X", - about: - } - } - end - let(:organization) { create(:organization) } - let!(:system_user) { create(:user, :confirmed, email: Decidim::Ai::SpamDetection.reporting_user_email, organization:) } - let(:user) { create(:user, :confirmed, organization:) } - - let(:form) do - Decidim::UserGroupForm.from_params(**data).with_context(current_organization: organization, current_user: user) - end - - before do - Decidim::Ai::SpamDetection.user_registry.clear - Decidim::Ai::SpamDetection.user_registry.register_analyzer(name: :bayes, - strategy: Decidim::Ai::SpamDetection::Strategy::Bayes, - options: { adapter: :memory, params: {} }) - - Decidim::Ai::SpamDetection.user_classifier.train :ham, "I am a passionate Decidim Maintainer. It is nice to be here." - Decidim::Ai::SpamDetection.user_classifier.train :ham, "Yet I do not have an idea about what I am doing here." - Decidim::Ai::SpamDetection.user_classifier.train :ham, "Maybe You would understand better, and you would not get blocked as i did." - Decidim::Ai::SpamDetection.user_classifier.train :ham, "Just kidding, I needed some Ham to make an omelette." - - Decidim::Ai::SpamDetection.user_classifier.train :spam, "You are the lucky winner! Claim your holiday prize." - end - - shared_examples "test submitted data" do - context "when spam content is added" do - let(:about) { "Claim your prize today so you can win." } - - include_examples "user content submitted to spam analysis" do - let(:spam_count) { 1 } - end - end - - context "when regular content is added" do - let(:about) { "Very nice idea that is not going to be blocked" } - - include_examples "user content submitted to spam analysis" do - let(:spam_count) { 0 } - end - end - end - - context "when updating the account" do - let(:user_group) { create(:user_group, organization:) } - let(:command) { Decidim::UpdateUserGroup.new(form, user_group) } - - include_examples "test submitted data" - end - - context "when creates the account" do - let(:command) { Decidim::CreateUserGroup.new(form) } - - include_examples "test submitted data" - 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 index a3b1f8e..f4203ba 100644 --- 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 @@ -28,4 +28,4 @@ expect(obj.send(:reporting_user)).to eq user_two end end -end \ No newline at end of file +end diff --git a/spec/lib/decidim/ai/spam_detection/importer/database_spec.rb b/spec/lib/decidim/ai/spam_detection/importer/database_spec.rb index 1700ec5..0cb1dda 100644 --- a/spec/lib/decidim/ai/spam_detection/importer/database_spec.rb +++ b/spec/lib/decidim/ai/spam_detection/importer/database_spec.rb @@ -11,6 +11,30 @@ Decidim::Ai::SpamDetection.resource_models = resources end + shared_examples "some resources are being spam" do + before do + Decidim::Ai::SpamDetection.resource_models = resource_models + allow(Decidim::Ai::SpamDetection).to receive(:resource_classifier).and_return(instance) + end + + let(:reporting_user) { author } + let(:spam_count) { 2 } + let!(:parent) { create(:report, reason: "parent_hidden", user: reporting_user, moderation: create(:moderation, :hidden, reportable: resources.last)) } + Decidim::Report::REASONS.excluding("parent_hidden").each do |reason| + let!(:report) { create(:report, reason:, user: reporting_user, moderation: create(:moderation, :hidden, reportable:)) } + + it "successfully loads the dataset when there are resources marked as #{reason}" do + allow(instance).to receive(:train) + + described_class.call + + expect(instance).to have_received(:train).with(:ham, anything).at_least(training - spam_count) + expect(instance).to have_received(:train).with(:spam, anything).at_least(spam_count) + expect(instance).to have_received(:train).with(:spam, "Hidden resource").at_least(1) + end + end + end + shared_examples "resource is being indexed" do let(:organization) { create(:organization) } let!(:author) { create(:user, organization:) } @@ -56,67 +80,64 @@ let(:manifest_name) { "meetings" } let(:training) { 20 } - let!(:meetings) do - create_list(:meeting, 4, component:, author:, - title: { en: "Some proposal that is not blocked" }, - description: { en: "The body for the meeting." }) - end + let!(:reportable) { create(:meeting, component:, author:, title: { en: "Hidden resource" }) } + let!(:resources) { create_list(:meeting, 3, component:, author:) } + let(:resource_models) { { "Decidim::Meetings::Meeting" => "Decidim::Ai::SpamDetection::Resource::Meeting" } } include_examples "resource is being indexed" + include_examples "some resources are being spam" do + let(:spam_count) { 5 } + end end context "when trained model is Decidim::Proposals::Proposal" do let(:manifest_name) { "proposals" } let(:training) { 8 } - let!(:proposals) do - create_list(:proposal, 4, - :published, - component:, - users: [author], - title: "Some proposal that is not blocked", - body: "The body for the proposal.") - end + let!(:reportable) { create(:proposal, :published, component:, users: [author], title: { en: "Hidden resource" }) } + let!(:resources) { create_list(:proposal, 3, :published, component:, users: [author]) } let(:resource_models) { { "Decidim::Proposals::Proposal" => "Decidim::Ai::SpamDetection::Resource::Proposal" } } include_examples "resource is being indexed" + include_examples "some resources are being spam" end context "when trained model is Decidim::Proposals::CollaborativeDraft" do let(:manifest_name) { "proposals" } let(:training) { 8 } - let!(:collaborative_drafts) do - create_list(:collaborative_draft, 4, - component:, - users: [author], - title: "Some draft that is not blocked", - body: "The body for the proposal.") - end + let!(:reportable) { create(:collaborative_draft, component:, users: [author], title: "Hidden resource") } + let!(:resources) { create_list(:collaborative_draft, 3, component:, users: [author]) } let(:resource_models) { { "Decidim::Proposals::CollaborativeDraft" => "Decidim::Ai::SpamDetection::Resource::CollaborativeDraft" } } include_examples "resource is being indexed" + include_examples "some resources are being spam" end context "when trained model is Decidim::Debates::Debate" do let(:manifest_name) { "debates" } let(:training) { 8 } - let!(:debates) do - create_list(:debate, 4, + let!(:reportable) do + create(:debate, + author:, component:, + title: { en: "Hidden resource" }) + end + let!(:resources) do + create_list(:debate, 3, author:, component:, - title: { en: "Some proposal that is not blocked" }, - description: { en: "The body for the meeting." }) + title: { en: "Some proposal that is not blocked" }) end let(:resource_models) { { "Decidim::Debates::Debate" => "Decidim::Ai::SpamDetection::Resource::Debate" } } include_examples "resource is being indexed" + include_examples "some resources are being spam" end context "when trained model is Decidim::User" do let(:tested) { 3 } - let(:training) { tested + 1 } # tested + author in shared example + let(:training) { 4 } # tested + author in shared example let!(:user) { create_list(:user, tested, organization:, about: "Something about me") } let(:resource_models) { { "Decidim::User" => "Decidim::Ai::SpamDetection::Resource::UserBaseEntity" } } @@ -129,20 +150,4 @@ end end end - - context "when trained model is Decidim::UserGroup" do - let(:tested) { 3 } - let(:training) { tested + 1 } # tested + author in shared example - - let!(:user) { create_list(:user_group, tested, organization:) } - let(:resource_models) { { "Decidim::UserGroup" => "Decidim::Ai::SpamDetection::Resource::UserBaseEntity" } } - - include_examples "resource is being indexed" do - let(:instance) { Decidim::Ai::SpamDetection::Service.new(registry: Decidim::Ai::SpamDetection.user_registry) } - - before do - allow(Decidim::Ai::SpamDetection).to receive(:user_classifier).and_return(instance) - end - end - end end diff --git a/spec/shared/events_examples.rb b/spec/shared/events_examples.rb index 5dff470..6cdfc2d 100644 --- a/spec/shared/events_examples.rb +++ b/spec/shared/events_examples.rb @@ -39,6 +39,28 @@ expect(Decidim::Report.count).to eq(spam_count) end end + + it "hides automatically the resource" do + allow(Decidim::Ai::SpamDetection).to receive(:hide_reported_resources_automatically).and_return(true) + perform_enqueued_jobs do + expect { command.call }.to change(Decidim::Report, :count).by(spam_count) + expect(Decidim::Report.count).to eq(spam_count) + # We are reusing the spec for Valid and invalid content. We are just checking that the resource is hidden if the + # resource is spam + expect(resource.last.hidden?).to eq(spam_count == 1) + end + end + + it "keps the resource visible" do + allow(Decidim::Ai::SpamDetection).to receive(:hide_reported_resources_automatically).and_return(false) + perform_enqueued_jobs do + expect { command.call }.to change(Decidim::Report, :count).by(spam_count) + expect(Decidim::Report.count).to eq(spam_count) + # We are reusing the spec for Valid and invalid content. We are just checking that the resource is not hidden if the + # setting is set to not hide spam content + expect(resource.last.hidden?).to be(false) + end + end end shared_examples "initiatives spam analysis" do @@ -52,7 +74,7 @@ let(:compared_against) { description } let(:resource) { Decidim::Initiative } let(:component) { nil } - let!(:participatory_space) { initiative } + let(:participatory_space) { initiative } end end @@ -73,8 +95,9 @@ shared_examples "debates spam analysis" do let(:manifest_name) { "debates" } - let(:scope) { create(:scope, organization:) } - let(:category) { create(:category, participatory_space:) } + let(:taxonomizations) do + 2.times.map { build(:taxonomization, taxonomy: create(:taxonomy, :with_parent, organization:), taxonomizable: nil) } + end context "when spam content is added" do let(:description) { "Claim your prize today so you can win." } @@ -131,8 +154,9 @@ shared_examples "meetings spam analysis" do let(:manifest_name) { "meetings" } - let(:scope) { create(:scope, organization:) } - let(:category) { create(:category, participatory_space:) } + let(:taxonomizations) do + 2.times.map { build(:taxonomization, taxonomy: create(:taxonomy, :with_parent, organization:), taxonomizable: nil) } + end context "when spam content is added" do let(:description) { "Claim your prize today so you can win." } @@ -161,7 +185,6 @@ shared_examples "proposal spam analysis" do let(:manifest_name) { "proposals" } - let(:user_group) { nil } context "when spam content is added" do let(:body) { "Claim your prize today so you can win." } @@ -189,8 +212,6 @@ end shared_examples "Collaborative draft spam analysis" do - let(:user_group) { nil } - context "when spam content is added" do let(:body) { "Claim your prize today so you can win." } let(:title) { "You are the Lucky winner" } From 513788f7430847de86af4a0a19e3244db00845e5 Mon Sep 17 00:00:00 2001 From: AyakorK Date: Thu, 5 Feb 2026 15:40:18 +0100 Subject: [PATCH 4/6] chore: Manually trigger CI --- .github/workflows/ci_ai.yml | 1 + .github/workflows/ci_lint.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/ci_ai.yml b/.github/workflows/ci_ai.yml index b434f13..a003c06 100644 --- a/.github/workflows/ci_ai.yml +++ b/.github/workflows/ci_ai.yml @@ -6,6 +6,7 @@ on: - main - release/* - "*-stable" + - "*bump*" pull_request: branches-ignore: - "chore/l10n*" diff --git a/.github/workflows/ci_lint.yml b/.github/workflows/ci_lint.yml index cec2ffd..9791a2a 100644 --- a/.github/workflows/ci_lint.yml +++ b/.github/workflows/ci_lint.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - "*bump*" pull_request: env: From 07a45d8f56b3ba21d01f5c74f49c975f040a0170 Mon Sep 17 00:00:00 2001 From: AyakorK Date: Thu, 5 Feb 2026 15:44:31 +0100 Subject: [PATCH 5/6] Trigger CI From cd719098da4184ed9092e849059ebc1af6deafea Mon Sep 17 00:00:00 2001 From: AyakorK Date: Fri, 6 Feb 2026 12:10:07 +0100 Subject: [PATCH 6/6] bump: Third party service 0.31 --- Gemfile | 2 + Gemfile.lock | 3 +- .../third_party/generic_spam_analyzer_job.rb | 34 +++ .../third_party/user_spam_analyzer_job.rb | 41 ++++ docs/scaleway.md | 75 +++++++ docs/third_party.md | 37 +++ examples/decidim_ai_scaleway.rb | 51 +++++ examples/decidim_ai_third_party.rb | 59 +++++ .../ai/spam_detection/spam_detection.rb | 3 + .../ai/spam_detection/strategy/base.rb | 6 + .../ai/spam_detection/strategy/scaleway.rb | 98 ++++++++ .../ai/spam_detection/strategy/third_party.rb | 124 ++++++++++ .../ai/spam_detection/third_party_service.rb | 22 ++ .../spam_detection/strategy/scaleway_spec.rb | 116 ++++++++++ .../strategy/third_party_spec.rb | 211 ++++++++++++++++++ .../third_party_service_spec.rb | 62 +++++ 16 files changed, 943 insertions(+), 1 deletion(-) create mode 100644 app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb create mode 100644 app/jobs/decidim/ai/spam_detection/third_party/user_spam_analyzer_job.rb create mode 100644 docs/scaleway.md create mode 100644 docs/third_party.md create mode 100644 examples/decidim_ai_scaleway.rb create mode 100644 examples/decidim_ai_third_party.rb create mode 100644 lib/decidim/ai/spam_detection/strategy/scaleway.rb create mode 100644 lib/decidim/ai/spam_detection/strategy/third_party.rb create mode 100644 lib/decidim/ai/spam_detection/third_party_service.rb create mode 100644 spec/lib/decidim/ai/spam_detection/strategy/scaleway_spec.rb create mode 100644 spec/lib/decidim/ai/spam_detection/strategy/third_party_spec.rb create mode 100644 spec/lib/decidim/ai/spam_detection/third_party_service_spec.rb diff --git a/Gemfile b/Gemfile index 329d164..e348def 100644 --- a/Gemfile +++ b/Gemfile @@ -16,6 +16,8 @@ gem "decidim-proposals", "~> 0.31" 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 d518573..63237da 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -854,7 +854,7 @@ GEM uber (0.1.0) unicode-display_width (2.6.0) uniform_notifier (1.18.0) - uri (1.0.2) + uri (1.0.3) useragent (0.16.11) valid_email2 (7.0.13) activemodel (>= 6.0) @@ -922,6 +922,7 @@ DEPENDENCIES rubocop-faker rubocop-performance simplecov + uri (= 1.0.3) web-console (~> 4.2) RUBY VERSION 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/docs/scaleway.md b/docs/scaleway.md new file mode 100644 index 0000000..5857e0c --- /dev/null +++ b/docs/scaleway.md @@ -0,0 +1,75 @@ +# 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 ! diff --git a/docs/third_party.md b/docs/third_party.md new file mode 100644 index 0000000..7c2457c --- /dev/null +++ b/docs/third_party.md @@ -0,0 +1,37 @@ +# 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: 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 + } + } + ] + + Decidim::Ai::SpamDetection.resource_analyzers = analyzers + Decidim::Ai::SpamDetection.user_analyzers = analyzers +end +``` \ 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..d01b54a --- /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: 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 + } + } + ] + + 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/spam_detection/spam_detection.rb b/lib/decidim/ai/spam_detection/spam_detection.rb index 773d586..c10993f 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 # When the engine is consistently marking spam content without errors, 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..3c18de2 --- /dev/null +++ b/lib/decidim/ai/spam_detection/strategy/scaleway.rb @@ -0,0 +1,98 @@ +# 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) + @category = nil + 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? + + parsed = body.is_a?(String) ? JSON.parse(body) : body + parsed.fetch("spam", "") + end + + def headers(organization_host) + { + "X-Auth-Token" => @secret, + "Content-Type" => "application/json", + "Accept" => "application/json", + "Host" => organization_host, + "Decidim" => organization_host + } + 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..dcd98df --- /dev/null +++ b/lib/decidim/ai/spam_detection/strategy/third_party.rb @@ -0,0 +1,124 @@ +# 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 = options[:endpoint] || Decidim::Env.new("DECIDIM_AI_ENDPOINT").to_s + @secret = options[:secret] || Decidim::Env.new("DECIDIM_AI_SECRET").to_s + @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) + @category = nil + 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/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..b4a00e4 --- /dev/null +++ b/spec/lib/decidim/ai/spam_detection/strategy/scaleway_spec.rb @@ -0,0 +1,116 @@ +# 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 + { + "spam" => "SPAM" + } + 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 + {} + end + + it "returns empty string" do + expect(strategy.third_party_content(body)).to eq("") + 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 empty string" do + expect(strategy.third_party_content(body)).to be_empty + end + end + end + + describe "#classify" do + let(:http_response_body) { '{"spam": "NOT_SPAM"}' } + let(:http_response_double) do + response = Net::HTTPOK.new("1.1", "200", "OK") + allow(response).to receive(:body).and_return(http_response_body) + response + end + let(:http_double) { instance_double(Net::HTTP, :use_ssl= => true) } + + before do + allow(Net::HTTP).to receive(:new).and_return(http_double) + allow(http_double).to receive(:request).and_return(http_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 + let(:http_response_double) do + response = Net::HTTPBadRequest.new("1.1", "400", "Bad Request") + allow(response).to receive(:body).and_return("Error") + response + end + + it "raises InvalidEntity error" do + expect { strategy.classify("Test content", "decidim.example.org", klass) }.to raise_error(Decidim::Ai::SpamDetection::Strategy::ThirdParty::InvalidEntity) + end + end + + context "when response format is invalid" do + let(:http_response_body) { '{"spam": "INVALID_FORMAT"}' } + + it "raises InvalidOutputFormat error" do + expect { strategy.classify("Test content", "decidim.example.org", klass) }.to raise_error(Decidim::Ai::SpamDetection::Strategy::ThirdParty::InvalidOutputFormat) + 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..73af7a4 --- /dev/null +++ b/spec/lib/decidim/ai/spam_detection/strategy/third_party_spec.rb @@ -0,0 +1,211 @@ +# 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: '{"choices": [{"message": {"content": "NOT_SPAM"}}]}', is_a?: true) } + + before do + allow(strategy).to receive(:third_party_request).and_return(response_double) + 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 + expect(strategy.score).to eq(0) + end + end + + context "when response format is invalid" do + before do + allow(response_double).to receive(:is_a?).with(Net::HTTPSuccess).and_return(true) + allow(strategy).to receive(:third_party_content).and_return("INVALID_FORMAT") + end + + it "raises InvalidOutputFormat error" do + expect { strategy.classify(content) }.not_to raise_error + expect(strategy.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