diff --git a/.github/actions/spelling/excludes.txt b/.github/actions/spelling/excludes.txt index 294ead7f4864..d53911248e6f 100644 --- a/.github/actions/spelling/excludes.txt +++ b/.github/actions/spelling/excludes.txt @@ -108,4 +108,8 @@ ^\Qdecidim-meetings/app/views/decidim/meetings/admin/agenda/show.html.erb\E$ ^\Qdecidim-participatory_processes/app/views/decidim/participatory_processes/participatory_processes/_statistics.html.erb\E$ ^\Qdecidim-proposals/app/views/decidim/proposals/proposals/_endorsements_card_row.html.erb\E$ +^\Qdecidim-ai/data/blocked_accounts.csv\E$ +^\Qdecidim-ai/data/sms-spam.csv\E$ +^\Qdecidim-ai/data/spam_comments.csv\E$ +^\Qdecidim-ai/spec/support/test.csv\E$ ignore$ diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index d50bf4172536..bd1cc19cb418 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -5,6 +5,7 @@ abcd abcert abcloktpes accesslist +Aceasta acentos acro activejob @@ -21,9 +22,12 @@ admins adoc AFFERO AGPL +aitools Ajuntament Ajuntamentde alabs +alecslupu +Alexandru amd amendables AMR @@ -66,6 +70,7 @@ backticks barcat Basecontroller Batchloader +bayes bbb Bbbb BCN @@ -124,6 +129,7 @@ childs chromedriver cht cityhall +cld clickable clickbait cmax @@ -283,6 +289,7 @@ ERVz Esa espa estat +este Etags etherpad evanfuture @@ -503,6 +510,7 @@ lsclusters lte lteq LTREE +Lupu lvh lvml magick @@ -582,6 +590,7 @@ NEWCODE newsletterable newusermanager newwindow +ngrams nicknamizable nicknamize nie @@ -719,6 +728,7 @@ promotal promotors proposta Propostes +propozitie Propuesta prosemirror protonmail @@ -955,6 +965,7 @@ unresolveable unsigning unsigns unsubscribes +untrains unvotes upcomingfoobar UPDREF diff --git a/.github/autolabeler.yml b/.github/autolabeler.yml index c6c80150acbc..c9d1eee2b568 100644 --- a/.github/autolabeler.yml +++ b/.github/autolabeler.yml @@ -13,6 +13,7 @@ dependencies: javascript: "*.js" "module: accountability": decidim-accountability/**/* "module: admin": decidim-admin/**/* +"module: ai": decidim-ai/**/* "module: api": decidim-api/**/* "module: assemblies": decidim-assemblies/**/* "module: blogs": decidim-blogs/**/* diff --git a/.github/workflows/build_app.yml b/.github/workflows/build_app.yml index a718c9001bec..cf376dac475c 100644 --- a/.github/workflows/build_app.yml +++ b/.github/workflows/build_app.yml @@ -21,6 +21,8 @@ jobs: DATABASE_USERNAME: postgres DATABASE_PASSWORD: postgres DATABASE_HOST: localhost + DECIDIM_SPAM_DETECTION_BACKEND_RESOURCE: "memory" + DECIDIM_SPAM_DETECTION_BACKEND_USER: "memory" services: postgres: image: postgres:14 diff --git a/.github/workflows/ci_ai.yml b/.github/workflows/ci_ai.yml new file mode 100644 index 000000000000..64fa25a9f5ea --- /dev/null +++ b/.github/workflows/ci_ai.yml @@ -0,0 +1,34 @@ +name: "[CI] Ai" +on: + push: + branches: + - develop + - release/* + - "*-stable" + pull_request: + branches-ignore: + - "chore/l10n*" + paths: + - "*" + - ".github/**" + - "decidim-ai/**" + - "decidim-core/**" + - "decidim-dev/**" + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + build_app: + uses: ./.github/workflows/build_app.yml + secrets: inherit + name: Build test application + main: + needs: build_app + name: Tests + uses: ./.github/workflows/test_app.yml + secrets: inherit + with: + working-directory: "decidim-ai" + test_command: bundle exec parallel_test --type rspec --pattern spec/ diff --git a/.github/workflows/ci_generators.yml b/.github/workflows/ci_generators.yml index fe7da0446d47..ae1a408e489e 100644 --- a/.github/workflows/ci_generators.yml +++ b/.github/workflows/ci_generators.yml @@ -70,6 +70,8 @@ jobs: DATABASE_USERNAME: postgres DATABASE_PASSWORD: postgres DATABASE_HOST: localhost + DECIDIM_SPAM_DETECTION_BACKEND_RESOURCE: "memory" + DECIDIM_SPAM_DETECTION_BACKEND_USER: "memory" steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/ci_performance_metrics_monitoring.yml b/.github/workflows/ci_performance_metrics_monitoring.yml index eb17df4692e4..e9e4f02a63e9 100644 --- a/.github/workflows/ci_performance_metrics_monitoring.yml +++ b/.github/workflows/ci_performance_metrics_monitoring.yml @@ -24,6 +24,8 @@ env: RAILS_ENV: development RAILS_BOOST_PERFORMANCE: "true" SHAKAPACKER_RUNTIME_COMPILE: "false" + DECIDIM_SPAM_DETECTION_BACKEND_RESOURCE: "memory" + DECIDIM_SPAM_DETECTION_BACKEND_USER: "memory" concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} @@ -50,6 +52,8 @@ jobs: DATABASE_USERNAME: postgres DATABASE_PASSWORD: postgres DATABASE_HOST: localhost + DECIDIM_SPAM_DETECTION_BACKEND_RESOURCE: "memory" + DECIDIM_SPAM_DETECTION_BACKEND_USER: "memory" steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/ci_production_check.yml b/.github/workflows/ci_production_check.yml index 54534eccb0f6..66650dda1bb6 100644 --- a/.github/workflows/ci_production_check.yml +++ b/.github/workflows/ci_production_check.yml @@ -44,6 +44,8 @@ jobs: DATABASE_USERNAME: postgres DATABASE_PASSWORD: postgres DATABASE_HOST: localhost + DECIDIM_SPAM_DETECTION_BACKEND_RESOURCE: "memory" + DECIDIM_SPAM_DETECTION_BACKEND_USER: "memory" steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/test_app.yml b/.github/workflows/test_app.yml index f7ce62709e6e..0e4b0adbc294 100644 --- a/.github/workflows/test_app.yml +++ b/.github/workflows/test_app.yml @@ -54,6 +54,8 @@ jobs: DATABASE_USERNAME: postgres DATABASE_PASSWORD: postgres DATABASE_HOST: localhost + DECIDIM_SPAM_DETECTION_BACKEND_RESOURCE: "memory" + DECIDIM_SPAM_DETECTION_BACKEND_USER: "memory" services: validator: image: ghcr.io/validator/validator:latest diff --git a/.spelling.yml b/.spelling.yml index 91daa0b47e59..aecb261ec058 100644 --- a/.spelling.yml +++ b/.spelling.yml @@ -5,6 +5,8 @@ exclude_paths: - decidim-core/lib/decidim/db/common-passwords.txt - decidim-initiatives/spec/types/initiative_type_spec.rb - decidim-proposals/app/packs/documents/decidim/proposals/participatory_texts/participatory_text.md + - decidim-ai/data/(.*).csv + - decidim-ai/spec/support/test.csv forbidden: arent: are not diff --git a/Gemfile b/Gemfile index 722594c1ac40..a06dfb7238f8 100644 --- a/Gemfile +++ b/Gemfile @@ -5,6 +5,7 @@ source "https://rubygems.org" ruby RUBY_VERSION gem "decidim", path: "." +gem "decidim-ai", path: "." gem "decidim-conferences", path: "." gem "decidim-design", path: "." gem "decidim-initiatives", path: "." diff --git a/Gemfile.lock b/Gemfile.lock index c495a6c79027..c5a39b91c371 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -30,6 +30,9 @@ PATH devise (~> 4.7) devise-i18n (~> 1.2) devise_invitable (~> 2.0, >= 2.0.9) + decidim-ai (0.30.0.dev) + classifier-reborn (~> 2.3.0) + decidim-core (= 0.30.0.dev) decidim-api (0.30.0.dev) decidim-core (= 0.30.0.dev) graphql (~> 2.2.6) @@ -266,13 +269,13 @@ GEM tzinfo (~> 2.0) acts_as_list (1.1.0) activerecord (>= 4.2) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.8.6) + public_suffix (>= 2.0.2, < 6.0) ast (2.4.2) base64 (0.2.0) batch-loader (1.5.0) bcrypt (3.1.20) - better_html (2.1.1) + better_html (2.0.2) actionview (>= 6.0) activesupport (>= 6.0) ast (~> 2.0) @@ -281,7 +284,7 @@ GEM smart_properties bigdecimal (3.1.8) bindex (0.8.1) - bootsnap (1.10.3) + bootsnap (1.18.3) msgpack (~> 1.2) brakeman (6.1.2) racc @@ -311,11 +314,14 @@ GEM cells-rails (0.1.5) actionpack (>= 5.0) cells (>= 4.1.6, < 5.0.0) - charlock_holmes (0.7.9) + charlock_holmes (0.7.7) + classifier-reborn (2.3.0) + fast-stemmer (~> 1.0) + matrix (~> 0.4) cmdparse (3.0.7) commonmarker (0.23.10) concurrent-ruby (1.3.4) - crack (0.4.6) + crack (1.0.0) bigdecimal rexml crass (1.0.6) @@ -347,7 +353,7 @@ GEM nokogiri (>= 1.13.2, < 1.17.0) rubyzip (~> 2.3.0) docile (1.4.0) - doorkeeper (5.6.8) + doorkeeper (5.6.9) railties (>= 5) doorkeeper-i18n (4.0.1) erb_lint (0.6.0) @@ -360,7 +366,7 @@ GEM erbse (0.1.4) temple erubi (1.13.0) - escape_utils (1.2.2) + escape_utils (1.3.0) excon (0.109.0) extended-markdown-filter (0.7.0) html-pipeline (~> 2.9) @@ -371,12 +377,14 @@ GEM railties (>= 5.0.0) faker (3.2.3) i18n (>= 1.8.11, < 2) - faraday (2.10.0) - faraday-net_http (>= 2.0, < 3.2) + faraday (2.12.0) + faraday-net_http (>= 2.0, < 3.4) + json logger - faraday-net_http (3.1.0) + faraday-net_http (3.3.0) net-http - ffi (1.17.0) + fast-stemmer (1.0.2) + ffi (1.16.3) file_validators (3.0.0) activemodel (>= 3.2) mime-types (>= 1.0) @@ -392,10 +400,10 @@ GEM geocoder (1.8.3) base64 (>= 0.1.0) csv (>= 3.0.0) - geom2d (0.3.1) + geom2d (0.4.1) globalid (1.2.1) activesupport (>= 6.1) - graphql (2.2.7) + graphql (2.2.9) graphql-docs (4.0.0) commonmarker (~> 0.23, >= 0.23.6) dartsass (~> 1.49) @@ -410,17 +418,18 @@ GEM cmdparse (~> 3.0, >= 3.0.3) geom2d (~> 0.3) openssl (>= 2.2.1) - highline (3.1.0) + highline (3.1.1) reline html-pipeline (2.14.3) activesupport (>= 2) nokogiri (>= 1.4) htmlentities (4.3.4) - i18n (1.14.5) + i18n (1.14.6) concurrent-ruby (~> 1.0) - i18n-tasks (1.0.14) + i18n-tasks (1.0.13) activesupport (>= 4.0.2) ast (>= 2.1.0) + better_html (>= 1.0, < 3.0) erubi highline (>= 2.0.0) i18n @@ -428,8 +437,9 @@ GEM rails-i18n rainbow (>= 2.2.2, < 4.0) terminal-table (>= 1.5.1) - icalendar (2.10.2) + icalendar (2.10.3) ice_cube (~> 0.16) + ostruct ice_cube (0.17.0) image_processing (1.12.2) mini_magick (>= 4.9.5, < 5) @@ -438,7 +448,7 @@ GEM rails (>= 3.2.0) io-console (0.7.2) json (2.7.2) - jwt (2.8.2) + jwt (2.9.3) base64 kaminari (1.2.2) activesupport (>= 4.1.0) @@ -455,17 +465,17 @@ GEM language_server-protocol (3.17.0.3) launchy (2.5.2) addressable (~> 2.8) - letter_opener (1.8.1) + letter_opener (1.9.0) launchy (>= 2.2, < 3) letter_opener_web (2.0.0) actionmailer (>= 5.2) letter_opener (~> 1.7) railties (>= 5.2) rexml - listen (3.7.1) + listen (3.8.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - logger (1.6.0) + logger (1.6.1) loofah (2.22.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) @@ -479,17 +489,16 @@ GEM method_source (1.1.0) mime-types (3.5.2) mime-types-data (~> 3.2015) - mime-types-data (3.2023.1205) + mime-types-data (3.2024.0206) mini_magick (4.12.0) mini_mime (1.1.5) - mini_portile2 (2.8.7) minitest (5.25.1) - msgpack (1.4.5) + msgpack (1.7.2) multi_xml (0.7.1) bigdecimal (~> 3.1) net-http (0.4.1) uri - net-imap (0.4.14) + net-imap (0.4.16) date net-protocol net-pop (0.1.2) @@ -499,8 +508,7 @@ GEM net-smtp (0.3.4) net-protocol nio4r (2.7.3) - nokogiri (1.16.7) - mini_portile2 (~> 2.8.2) + nokogiri (1.16.7-x86_64-linux) racc (~> 1.4) oauth (1.1.0) oauth-tty (~> 1.0, >= 1.0.1) @@ -521,8 +529,8 @@ GEM rack-protection omniauth-facebook (5.0.0) omniauth-oauth2 (~> 1.2) - omniauth-google-oauth2 (1.1.2) - jwt (>= 2.0) + omniauth-google-oauth2 (1.2.0) + jwt (>= 2.9) oauth2 (~> 2.0) omniauth (~> 2.0) omniauth-oauth2 (~> 1.8) @@ -540,11 +548,12 @@ GEM rack openssl (3.2.0) orm_adapter (0.5.0) + ostruct (0.6.0) paper_trail (15.1.0) activerecord (>= 6.1) request_store (~> 1.4) parallel (1.26.3) - parallel_tests (4.4.0) + parallel_tests (4.5.1) parallel parser (3.3.4.2) ast (~> 2.4.1) @@ -561,7 +570,7 @@ GEM actionmailer (>= 3) net-smtp premailer (~> 1.7, >= 1.7.9) - public_suffix (6.0.1) + public_suffix (5.1.1) puma (6.4.2) nio4r (~> 2.0) racc (1.8.1) @@ -614,7 +623,7 @@ GEM zeitwerk (~> 2.5) rainbow (3.1.1) rake (13.2.1) - ransack (4.2.0) + ransack (4.2.1) activerecord (>= 6.1.5) activesupport (>= 6.1.5) i18n @@ -624,15 +633,14 @@ GEM redcarpet (3.6.0) redis (4.8.1) regexp_parser (2.9.2) - reline (0.5.9) + reline (0.5.10) io-console (~> 0.5) request_store (1.5.1) rack (>= 1.4) responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.3.6) - strscan + rexml (3.3.8) rspec (3.13.0) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) @@ -642,29 +650,29 @@ GEM rspec-rails (>= 3.0.0, < 6.2.0) rspec-core (3.13.1) rspec-support (~> 3.13.0) - rspec-expectations (3.13.3) + rspec-expectations (3.13.0) 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.1) + rspec-mocks (3.13.0) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (6.1.3) + rspec-rails (6.1.1) actionpack (>= 6.1) activesupport (>= 6.1) railties (>= 6.1) - rspec-core (~> 3.13) - rspec-expectations (~> 3.13) - rspec-mocks (~> 3.13) - rspec-support (~> 3.13) + rspec-core (~> 3.12) + rspec-expectations (~> 3.12) + rspec-mocks (~> 3.12) + rspec-support (~> 3.12) rspec-retry (0.6.2) rspec-core (> 3.3) rspec-support (3.13.1) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) - rubocop (1.65.1) + rubocop (1.65.0) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) @@ -675,7 +683,7 @@ GEM rubocop-ast (>= 1.31.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.32.1) + rubocop-ast (1.32.0) parser (>= 3.3.1.0) rubocop-capybara (2.21.0) rubocop (~> 1.41) @@ -700,10 +708,9 @@ GEM rubocop-rubycw (0.1.6) rubocop (~> 1.0) ruby-progressbar (1.13.0) - ruby-vips (2.2.2) + ruby-vips (2.2.0) ffi (~> 1.12) - logger - rubyXL (3.4.27) + rubyXL (3.4.25) nokogiri (>= 1.10.8) rubyzip (>= 1.3.0) rubyzip (2.3.2) @@ -735,11 +742,10 @@ GEM spring-watcher-listen (2.1.0) listen (>= 2.7, < 4.0) spring (>= 4) - strscan (3.1.0) temple (0.10.3) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) - thor (1.3.1) + thor (1.3.2) tilt (2.3.0) timeout (0.4.1) tzinfo (2.0.6) @@ -747,7 +753,7 @@ GEM uber (0.1.0) unicode-display_width (2.5.0) uniform_notifier (1.16.0) - uri (0.13.0) + uri (0.13.1) valid_email2 (4.0.6) activemodel (>= 3.2) mail (~> 2.5) @@ -770,11 +776,11 @@ GEM web-push (3.0.1) jwt (~> 2.0) openssl (~> 3.0) - webmock (3.19.1) + webmock (3.20.0) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - websocket (1.2.11) + websocket (1.2.10) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -785,16 +791,17 @@ GEM wkhtmltopdf-binary (0.12.6.6) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.6.16) + zeitwerk (2.6.18) PLATFORMS - ruby + x86_64-linux DEPENDENCIES bootsnap (~> 1.4) brakeman (~> 6.1) byebug (~> 11.0) decidim! + decidim-ai! decidim-conferences! decidim-design! decidim-dev! @@ -810,4 +817,4 @@ RUBY VERSION ruby 3.3.4p94 BUNDLED WITH - 2.4.6 + 2.4.20 diff --git a/decidim-ai/README.md b/decidim-ai/README.md new file mode 100644 index 000000000000..22d6dc21d437 --- /dev/null +++ b/decidim-ai/README.md @@ -0,0 +1,47 @@ +# Decidim::Ai + +The Decidim::Ai is a library that aims to provide Artificial Intelligence tools for Decidim. This plugin has been initially developed aiming to analyze the content and provide spam classification using Naive Bayes algorithm. +All AI related functionality provided by Decidim should be included in this same module. + +For more documentation on the AI tools API, please refer to [documentation](https://docs.decidim.org/en/develop/develop/ai_tools.html) + +## Installation + +In order to install use this module, you need at least Decidim 0.30 to be installed. + +To install this module, run in your console: + +```bash +bundle add decidim-ai +``` + +After that, add an initializer file as presented in the [documentation](https://docs.decidim.org/en/develop/services/aitools.html#_configuration) + +Then, you need to run the below command, so that the reporting user is created. + +```ruby +bin/rails decidim:ai:spam:create_reporting_user +``` + +Then you can use the below command to train the engine with the module dataset: + +```ruby +bin/rails decidim:ai:spam:load_module_dataset +``` + +Add the queue name to `config/sidekiq.yml` file: + +```yaml +:queues: +- ["default", 1] +- ["spam_analysis", 1] +# The other yaml entries +``` + +## Contributing + +See [Decidim](https://github.com/decidim/decidim). + +## License + +See [Decidim](https://github.com/decidim/decidim). diff --git a/decidim-ai/Rakefile b/decidim-ai/Rakefile new file mode 100644 index 000000000000..447b5c1bf263 --- /dev/null +++ b/decidim-ai/Rakefile @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require "decidim/dev/common_rake" diff --git a/decidim-ai/app/jobs/decidim/ai/application_job.rb b/decidim-ai/app/jobs/decidim/ai/application_job.rb new file mode 100644 index 000000000000..c20cb0d534c1 --- /dev/null +++ b/decidim-ai/app/jobs/decidim/ai/application_job.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Decidim + module Ai + class ApplicationJob < Decidim::ApplicationJob + end + end +end diff --git a/decidim-ai/app/jobs/decidim/ai/spam_detection/application_job.rb b/decidim-ai/app/jobs/decidim/ai/spam_detection/application_job.rb new file mode 100644 index 000000000000..e03d155107e6 --- /dev/null +++ b/decidim-ai/app/jobs/decidim/ai/spam_detection/application_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + class ApplicationJob < Decidim::Ai::ApplicationJob + queue_as :spam_analysis + + protected + + def classifier + @classifier ||= Decidim::Ai::SpamDetection.resource_classifier + end + end + end + end +end diff --git a/decidim-ai/app/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job.rb b/decidim-ai/app/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job.rb new file mode 100644 index 000000000000..219af2fb3f0b --- /dev/null +++ b/decidim-ai/app/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + class GenericSpamAnalyzerJob < ApplicationJob + include Decidim::TranslatableAttributes + + def perform(reportable, author, locale, fields) + @author = author + overall_score = I18n.with_locale(locale) do + fields.map do |field| + classifier.classify(translated_attribute(reportable.send(field))) + classifier.score + end + end + + overall_score = overall_score.inject(0.0, :+) / overall_score.size + + return unless overall_score >= Decidim::Ai::SpamDetection.resource_score_threshold + + Decidim::CreateReport.call(form, reportable) + end + + private + + def form + @form ||= Decidim::ReportForm.new(reason: "spam", details: classifier.classification_log).with_context(current_user: reporting_user) + end + + def reporting_user + @reporting_user ||= Decidim::User.find_by!(email: Decidim::Ai::SpamDetection.reporting_user_email) + end + end + end + end +end diff --git a/decidim-ai/app/jobs/decidim/ai/spam_detection/train_hidden_resource_data_job.rb b/decidim-ai/app/jobs/decidim/ai/spam_detection/train_hidden_resource_data_job.rb new file mode 100644 index 000000000000..7f045d8079e7 --- /dev/null +++ b/decidim-ai/app/jobs/decidim/ai/spam_detection/train_hidden_resource_data_job.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + class TrainHiddenResourceDataJob < ApplicationJob + include Decidim::TranslatableAttributes + + def perform(resource) + return unless resource.respond_to?(:hidden?) + + resource.reload + + wrapped = Decidim::Ai::SpamDetection.resource_models[resource.class.name].constantize.new + + if resource.hidden? + wrapped.fields.each do |field| + wrapped.untrain :ham, translated_attribute(resource.send(field)) + wrapped.train :spam, translated_attribute(resource.send(field)) + end + end + end + end + end + end +end diff --git a/decidim-ai/app/jobs/decidim/ai/spam_detection/train_user_data_job.rb b/decidim-ai/app/jobs/decidim/ai/spam_detection/train_user_data_job.rb new file mode 100644 index 000000000000..cb20135c23ec --- /dev/null +++ b/decidim-ai/app/jobs/decidim/ai/spam_detection/train_user_data_job.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + class TrainUserDataJob < ApplicationJob + def perform(user) + user.reload + + if user.blocked? + classifier.untrain :ham, user.about + classifier.train :spam, user.about + else + classifier.untrain :spam, user.about + classifier.train :ham, user.about + end + end + + protected + + def classifier + @classifier ||= Decidim::Ai::SpamDetection.user_classifier + end + end + end + end +end diff --git a/decidim-ai/app/jobs/decidim/ai/spam_detection/user_spam_analyzer_job.rb b/decidim-ai/app/jobs/decidim/ai/spam_detection/user_spam_analyzer_job.rb new file mode 100644 index 000000000000..f8ef6468decc --- /dev/null +++ b/decidim-ai/app/jobs/decidim/ai/spam_detection/user_spam_analyzer_job.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + class UserSpamAnalyzerJob < GenericSpamAnalyzerJob + def perform(reportable) + @author = reportable + + classifier.classify(reportable.about) + + return unless classifier.score >= Decidim::Ai::SpamDetection.user_score_threshold + + Decidim::CreateUserReport.call(form, reportable) + end + + protected + + def classifier + @classifier ||= Decidim::Ai::SpamDetection.user_classifier + end + end + end + end +end diff --git a/decidim-ai/decidim-ai.gemspec b/decidim-ai/decidim-ai.gemspec new file mode 100644 index 000000000000..1a1026e7aa25 --- /dev/null +++ b/decidim-ai/decidim-ai.gemspec @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +$LOAD_PATH.push File.expand_path("lib", __dir__) + +require "decidim/ai/version" + +Gem::Specification.new do |s| + s.version = Decidim::Ai.version + s.authors = ["Alexandru-Emil Lupu"] + s.email = ["contact@alecslupu.ro"] + s.license = "AGPL-3.0" + s.homepage = "https://decidim.org" + s.metadata = { + "bug_tracker_uri" => "https://github.com/decidim/decidim/issues", + "documentation_uri" => "https://docs.decidim.org/", + "funding_uri" => "https://opencollective.com/decidim", + "homepage_uri" => "https://decidim.org", + "source_code_uri" => "https://github.com/decidim/decidim" + } + s.required_ruby_version = "~> 3.3.0" + + s.name = "decidim-ai" + s.summary = "A Decidim module with AI tools" + s.description = "A module that aims to provide Artificial Intelligence tools for Decidim." + + s.files = Dir["{app,config,db,lib,vendor}/**/*", "Rakefile", "README.md"] + + s.add_dependency "classifier-reborn", "~> 2.3.0" + s.add_dependency "decidim-core", Decidim::Ai.version + s.add_development_dependency "decidim-debates", Decidim::Ai.version + s.add_development_dependency "decidim-initiatives", Decidim::Ai.version + s.add_development_dependency "decidim-meetings", Decidim::Ai.version + s.add_development_dependency "decidim-proposals", Decidim::Ai.version +end diff --git a/decidim-ai/lib/decidim/ai.rb b/decidim-ai/lib/decidim/ai.rb new file mode 100644 index 000000000000..87aaf1f9d865 --- /dev/null +++ b/decidim-ai/lib/decidim/ai.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "decidim/ai/engine" + +module Decidim + module Ai + autoload :StrategyRegistry, "decidim/ai/strategy_registry" + autoload :SpamDetection, "decidim/ai/spam_detection/spam_detection" + autoload :Language, "decidim/ai/language/language" + + include ActiveSupport::Configurable + end +end diff --git a/decidim-ai/lib/decidim/ai/engine.rb b/decidim-ai/lib/decidim/ai/engine.rb new file mode 100644 index 000000000000..01d77c425f48 --- /dev/null +++ b/decidim-ai/lib/decidim/ai/engine.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +module Decidim + module Ai + class Engine < ::Rails::Engine + isolate_namespace Decidim::Ai + + paths["db/migrate"] = nil + + initializer "decidim_ai.resource_classifiers" do |_app| + Decidim::Ai::SpamDetection.resource_analyzers.each do |analyzer| + Decidim::Ai::SpamDetection.resource_registry.register_analyzer(**analyzer) + end + end + + initializer "decidim_ai.user_classifiers" do |_app| + Decidim::Ai::SpamDetection.user_analyzers.each do |analyzer| + Decidim::Ai::SpamDetection.user_registry.register_analyzer(**analyzer) + end + end + + initializer "decidim_ai.events.hide_resource" do + config.to_prepare do + Decidim::EventsManager.subscribe("decidim.admin.hide_resource:after") do |_event_name, data| + Decidim::Ai::SpamDetection::TrainHiddenResourceDataJob.perform_later(data[:resource]) + end + 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::UserSpamAnalyzerJob.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]) + end + Decidim::EventsManager.subscribe("decidim.create_user_group:after") do |_event_name, data| + Decidim::Ai::SpamDetection::UserSpamAnalyzerJob.perform_later(data[:resource]) + end + Decidim::EventsManager.subscribe("decidim.admin.block_user:after") do |_event_name, data| + Decidim::Ai::SpamDetection::TrainUserDataJob.perform_later(data[:resource]) + end + end + end + + 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]) + 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]) + end + end + end + + initializer "decidim_ai.events.subscribe_meeting" do + config.to_prepare do + ActiveSupport::Notifications.subscribe("decidim.meetings.create_meeting:after") do |_event_name, data| + Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), + [:description, :title, :location_hints, :registration_terms]) + end + ActiveSupport::Notifications.subscribe("decidim.meetings.update_meeting:after") do |_event_name, data| + Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), + [:description, :title, :location_hints, :registration_terms]) + end + end + end + + 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]) + 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]) + end + end + end + + 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]) + 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]) + end + end + end + + 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]) + 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]) + 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]) + 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]) + end + end + end + + def load_seed + nil + end + end + end +end diff --git a/decidim-ai/lib/decidim/ai/language/formatter.rb b/decidim-ai/lib/decidim/ai/language/formatter.rb new file mode 100644 index 000000000000..2f7b0b97465a --- /dev/null +++ b/decidim-ai/lib/decidim/ai/language/formatter.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module Language + class Formatter + include ActionView::Helpers::SanitizeHelper + + # for the moment, we just use strip_tags to clean-up the text. At a later stage, we may need to introduce + # stemmers, ngrams or other kind of text normalization, as well any language specific criteria + def cleanup(text) + strip_tags(text) + end + end + end + end +end diff --git a/decidim-ai/lib/decidim/ai/language/language.rb b/decidim-ai/lib/decidim/ai/language/language.rb new file mode 100644 index 000000000000..250e868194d5 --- /dev/null +++ b/decidim-ai/lib/decidim/ai/language/language.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module Language + autoload :Formatter, "decidim/ai/language/formatter" + include ActiveSupport::Configurable + + # Text cleanup service + # + # If you want to implement your own text formatter, you can use a class having the following contract + # + # class Formatter + # def cleanup(text) + # # your code + # end + # end + config_accessor :formatter do + "Decidim::Ai::Language::Formatter" + end + end + end +end diff --git a/decidim-ai/lib/decidim/ai/spam_detection/importer/database.rb b/decidim-ai/lib/decidim/ai/spam_detection/importer/database.rb new file mode 100644 index 000000000000..a81c97a00864 --- /dev/null +++ b/decidim-ai/lib/decidim/ai/spam_detection/importer/database.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + module Importer + class Database + def self.call + Decidim::Ai::SpamDetection.resource_models.values.each do |model| + model.constantize.new.batch_train + end + end + end + end + end + end +end diff --git a/decidim-ai/lib/decidim/ai/spam_detection/importer/file.rb b/decidim-ai/lib/decidim/ai/spam_detection/importer/file.rb new file mode 100644 index 000000000000..aed97888c3fb --- /dev/null +++ b/decidim-ai/lib/decidim/ai/spam_detection/importer/file.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + module Importer + class File + def self.call(file, service) + ext = ::File.extname(file)[1..-1] + reader_class = Decidim::Admin::Import::Readers.search_by_file_extension(ext) + + reader_class.new(file).read_rows do |row| + next unless [:spam, :ham].include?(row[0].to_sym) + next if row[1].blank? + + service.train(row[0].to_sym, row[1]) + end + end + end + end + end + end +end diff --git a/decidim-ai/lib/decidim/ai/spam_detection/resource/base.rb b/decidim-ai/lib/decidim/ai/spam_detection/resource/base.rb new file mode 100644 index 000000000000..d9db2da23235 --- /dev/null +++ b/decidim-ai/lib/decidim/ai/spam_detection/resource/base.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + module Resource + class Base + include Decidim::TranslatableAttributes + + def fields; end + + def batch_train + query.find_each(batch_size: 100) do |resource| + classification = resource_hidden?(resource) ? :spam : :ham + + fields.each do |field_name| + raise "#{resource.class.name} does not implement #{field_name} as defined in `#{self.class.name}`" unless resource.respond_to?(field_name.to_sym) + + train classification, translated_attribute(resource.send(field_name.to_sym)) + end + end + end + + def train(category, text) + raise error_message("Decidim::Ai::SpamDetection.resource_detection_service", __method__) unless classifier.respond_to?(:train) + + classifier.train(category, text) + end + + def untrain(category, text) + raise error_message("Decidim::Ai::SpamDetection.resource_detection_service", __method__) unless classifier.respond_to?(:untrain) + + classifier.untrain(category, text) + end + + protected + + def error_message(klass, method_name) + "Invalid Classifier class! The class defined under `#{klass}` does not follow the contract regarding ##{method_name} method" + end + + def resource_hidden?(resource) + resource.class.included_modules.include?(Decidim::Reportable) && resource.hidden? + end + + def classifier + @classifier ||= Decidim::Ai::SpamDetection.resource_classifier + end + end + end + end + end +end diff --git a/decidim-ai/lib/decidim/ai/spam_detection/resource/collaborative_draft.rb b/decidim-ai/lib/decidim/ai/spam_detection/resource/collaborative_draft.rb new file mode 100644 index 000000000000..7a9f044de545 --- /dev/null +++ b/decidim-ai/lib/decidim/ai/spam_detection/resource/collaborative_draft.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + module Resource + class CollaborativeDraft < Base + def fields = [:body, :title] + + protected + + def query = Decidim::Proposals::CollaborativeDraft.includes(:moderation) + end + end + end + end +end diff --git a/decidim-ai/lib/decidim/ai/spam_detection/resource/comment.rb b/decidim-ai/lib/decidim/ai/spam_detection/resource/comment.rb new file mode 100644 index 000000000000..1cfcead347b6 --- /dev/null +++ b/decidim-ai/lib/decidim/ai/spam_detection/resource/comment.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + module Resource + class Comment < Base + def fields = [:body] + + protected + + def query = Decidim::Comments::Comment.includes(:moderation) + end + end + end + end +end diff --git a/decidim-ai/lib/decidim/ai/spam_detection/resource/debate.rb b/decidim-ai/lib/decidim/ai/spam_detection/resource/debate.rb new file mode 100644 index 000000000000..f1906de00bb2 --- /dev/null +++ b/decidim-ai/lib/decidim/ai/spam_detection/resource/debate.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + module Resource + class Debate < Base + def fields = [:description, :title] + + protected + + def query = Decidim::Debates::Debate.includes(:moderation) + end + end + end + end +end diff --git a/decidim-ai/lib/decidim/ai/spam_detection/resource/initiative.rb b/decidim-ai/lib/decidim/ai/spam_detection/resource/initiative.rb new file mode 100644 index 000000000000..b19ce3f06d3e --- /dev/null +++ b/decidim-ai/lib/decidim/ai/spam_detection/resource/initiative.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + module Resource + class Initiative < Base + def fields = [:description, :title] + + protected + + def query = Decidim::Initiative + end + end + end + end +end diff --git a/decidim-ai/lib/decidim/ai/spam_detection/resource/meeting.rb b/decidim-ai/lib/decidim/ai/spam_detection/resource/meeting.rb new file mode 100644 index 000000000000..db4f5ce5f5b2 --- /dev/null +++ b/decidim-ai/lib/decidim/ai/spam_detection/resource/meeting.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + module Resource + class Meeting < Base + def fields = [:description, :title, :location_hints, :registration_terms, :closing_report] + + protected + + def query = Decidim::Meetings::Meeting.includes(:moderation) + end + end + end + end +end diff --git a/decidim-ai/lib/decidim/ai/spam_detection/resource/proposal.rb b/decidim-ai/lib/decidim/ai/spam_detection/resource/proposal.rb new file mode 100644 index 000000000000..9293e84d4666 --- /dev/null +++ b/decidim-ai/lib/decidim/ai/spam_detection/resource/proposal.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + module Resource + class Proposal < Base + def fields = [:body, :title] + + protected + + def query = Decidim::Proposals::Proposal.includes(:moderation) + end + end + end + end +end diff --git a/decidim-ai/lib/decidim/ai/spam_detection/resource/user_base_entity.rb b/decidim-ai/lib/decidim/ai/spam_detection/resource/user_base_entity.rb new file mode 100644 index 000000000000..b635b4dd845f --- /dev/null +++ b/decidim-ai/lib/decidim/ai/spam_detection/resource/user_base_entity.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + module Resource + class UserBaseEntity < Base + def fields = [:about] + + def train(category, text) + raise error_message("Decidim::Ai::SpamDetection.user_detection_service", __method__) unless classifier.respond_to?(:train) + + classifier.train(category, text) + end + + def untrain(category, text) + raise error_message("Decidim::Ai::SpamDetection.user_detection_service", __method__) unless classifier.respond_to?(:untrain) + + classifier.untrain(category, text) + end + + protected + + def query = Decidim::UserBaseEntity + + def resource_hidden?(resource) = resource.class.included_modules.include?(Decidim::UserReportable) && resource.blocked? + + def classifier + @classifier ||= Decidim::Ai::SpamDetection.user_classifier + end + end + end + end + end +end diff --git a/decidim-ai/lib/decidim/ai/spam_detection/service.rb b/decidim-ai/lib/decidim/ai/spam_detection/service.rb new file mode 100644 index 000000000000..7c7b825ff48f --- /dev/null +++ b/decidim-ai/lib/decidim/ai/spam_detection/service.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + class Service + def initialize(registry:) + @registry = registry + end + + def reset + @registry.each do |strategy| + next unless strategy.respond_to?(:reset) + + strategy.reset + end + end + + def train(category, text) + text = formatter.cleanup(text) + return if text.blank? + + @registry.each do |strategy| + strategy.train(category, text) + end + end + + def classify(text) + text = formatter.cleanup(text) + return if text.blank? + + @registry.each do |strategy| + strategy.classify(text) + end + end + + def untrain(category, text) + text = formatter.cleanup(text) + return if text.blank? + + @registry.each do |strategy| + strategy.untrain(category, text) + end + end + + def score + @registry.collect(&:score).inject(0.0, :+) / @registry.size + end + + def classification_log + @classification_log = [] + @registry.each do |strategy| + @classification_log << strategy.log + end + @classification_log.join("\n") + end + + protected + + def formatter + @formatter ||= Decidim::Ai::Language.formatter.safe_constantize&.new + end + end + end + end +end diff --git a/decidim-ai/lib/decidim/ai/spam_detection/spam_detection.rb b/decidim-ai/lib/decidim/ai/spam_detection/spam_detection.rb new file mode 100644 index 000000000000..b0fb8d8afc2b --- /dev/null +++ b/decidim-ai/lib/decidim/ai/spam_detection/spam_detection.rb @@ -0,0 +1,203 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + include ActiveSupport::Configurable + + autoload :Service, "decidim/ai/spam_detection/service" + + module Resource + autoload :Base, "decidim/ai/spam_detection/resource/base" + autoload :Comment, "decidim/ai/spam_detection/resource/comment" + autoload :Debate, "decidim/ai/spam_detection/resource/debate" + autoload :Initiative, "decidim/ai/spam_detection/resource/initiative" + autoload :Proposal, "decidim/ai/spam_detection/resource/proposal" + autoload :CollaborativeDraft, "decidim/ai/spam_detection/resource/collaborative_draft" + autoload :Meeting, "decidim/ai/spam_detection/resource/meeting" + autoload :UserBaseEntity, "decidim/ai/spam_detection/resource/user_base_entity" + end + + module Importer + autoload :File, "decidim/ai/spam_detection/importer/file" + autoload :Database, "decidim/ai/spam_detection/importer/database" + end + + module Strategy + autoload :Base, "decidim/ai/spam_detection/strategy/base" + autoload :Bayes, "decidim/ai/spam_detection/strategy/bayes" + 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" + end + + # You can configure the spam threshold for the spam detection service. + # The threshold is a float value between 0 and 1. + # The default value is 0.75 + # Any value below the threshold will be considered spam. + config_accessor :resource_score_threshold do + 0.75 + end + + # Registered analyzers. + # You can register your own analyzer by adding a new entry to this array. + # The entry must be a hash with the following keys: + # - name: the name of the analyzer + # - strategy: the class of the strategy to use + # - options: a hash with the options to pass to the strategy + # Example: + # config.resource_analyzers = { + # name: :bayes, + # strategy: Decidim::Ai::SpamContent::BayesStrategy, + # options: { + # adapter: :redis, + # params: { + # url: ENV["DECIDIM_SPAM_DETECTION_BACKEND_RESOURCE_URL"] + # } + # } + # } + config_accessor :resource_analyzers do + [ + { + name: :bayes, + strategy: Decidim::Ai::SpamDetection::Strategy::Bayes, + options: { + adapter: ENV.fetch("DECIDIM_SPAM_DETECTION_BACKEND_RESOURCE", "redis"), + params: { url: ENV.fetch("DECIDIM_SPAM_DETECTION_BACKEND_RESOURCE_URL", "redis://localhost:6379/2") } + } + } + ] + end + + # This config_accessor allows the implementers to change the class being used by the classifier, + # in order to change the finder method. or even define own resource visibility criteria. + # This is the place where new resources can be registered following the pattern + # Resource => Handler + config_accessor :resource_models do + @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") + models["Decidim::Proposals::CollaborativeDraft"] = "Decidim::Ai::SpamDetection::Resource::CollaborativeDraft" if Decidim.module_installed?("proposals") + models + end + 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 :resource_detection_service do + "Decidim::Ai::SpamDetection::Service" + end + + # You can configure the spam threshold for the spam detection service. + # The threshold is a float value between 0 and 1. + # The default value is 0.75 + # Any value below the threshold will be considered spam. + config_accessor :user_score_threshold do + 0.75 + end + + # Registered analyzers. + # You can register your own analyzer by adding a new entry to this array. + # The entry must be a hash with the following keys: + # - name: the name of the analyzer + # - strategy: the class of the strategy to use + # - options: a hash with the options to pass to the strategy + # Example: + # config.user_analyzers = { + # name: :bayes, + # strategy: Decidim::Ai::SpamContent::BayesStrategy, + # options: { + # adapter: :redis, + # params: { + # url: ENV["DECIDIM_SPAM_DETECTION_BACKEND_USER_REDIS_URL"] + # } + # } + # } + config_accessor :user_analyzers do + [ + { + name: :bayes, + strategy: Decidim::Ai::SpamDetection::Strategy::Bayes, + options: { + adapter: ENV.fetch("DECIDIM_SPAM_DETECTION_BACKEND_USER", "redis"), + params: { url: ENV.fetch("DECIDIM_SPAM_DETECTION_BACKEND_USER_REDIS_URL", "redis://localhost:6379/3") } + } + } + ] + end + + # 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 + 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" + 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 + @resource_classifier = Decidim::Ai::SpamDetection.resource_detection_service.safe_constantize&.new( + registry: Decidim::Ai::SpamDetection.resource_registry + ) + end + + # The registry instance that stores the list of strategies needed to process the resources + # In essence is an enumerator class that responds to `register_analyzer(**params)` and `for(name)` methods + def self.resource_registry + @resource_registry ||= Decidim::Ai::StrategyRegistry.new + end + + # this is the generic user classifier class. If you need to change your own class, please change the + # configuration of `Decidim::Ai::SpamDetection.detection_service` variable + def self.user_classifier + @user_classifier = Decidim::Ai::SpamDetection.user_detection_service.safe_constantize&.new( + registry: Decidim::Ai::SpamDetection.user_registry + ) + end + + # The registry instance that stores the list of strategies needed to process the user objects + # In essence is an enumerator class that responds to `register_analyzer(**params)` and `for(name)` methods + def self.user_registry + @user_registry ||= Decidim::Ai::StrategyRegistry.new + end + + # This method is being called to ensure that user with email configured in + # `Decidim::Ai::SpamDetection.reporting_user_email` variable exists in the database. + def self.create_reporting_user! + Decidim::Organization.find_each do |organization| + user = organization.users.find_or_initialize_by(email: Decidim::Ai::SpamDetection.reporting_user_email) + next if user.persisted? + + password = SecureRandom.hex(10) + user.password = password + user.password_confirmation = password + + user.deleted_at = Time.current + user.tos_agreement = true + user.name = "" + user.skip_confirmation! + user.save! + end + end + end + end +end diff --git a/decidim-ai/lib/decidim/ai/spam_detection/strategy/base.rb b/decidim-ai/lib/decidim/ai/spam_detection/strategy/base.rb new file mode 100644 index 000000000000..6cd7153fffd5 --- /dev/null +++ b/decidim-ai/lib/decidim/ai/spam_detection/strategy/base.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + module Strategy + class Base + attr_reader :name + + def initialize(options = {}) + @name = options.delete(:name) + @options = options + end + + def classify(_content); end + + def train(_classification, _content); end + + def untrain(_classification, _content); end + + def log; end + + def score = 0.0 + end + end + end + end +end diff --git a/decidim-ai/lib/decidim/ai/spam_detection/strategy/bayes.rb b/decidim-ai/lib/decidim/ai/spam_detection/strategy/bayes.rb new file mode 100644 index 000000000000..3f0079b94db7 --- /dev/null +++ b/decidim-ai/lib/decidim/ai/spam_detection/strategy/bayes.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "classifier-reborn" + +module Decidim + module Ai + module SpamDetection + module Strategy + class Bayes < Base + def initialize(options = {}) + super + @options = { adapter: :memory, categories: [:ham, :spam], params: {} }.deep_merge(options) + + @available_categories = options[:categories] + @backend = ClassifierReborn::Bayes.new(*available_categories, backend: configured_backend) + end + + def log + return unless category + + "The Classification engine marked this as #{category}" + end + + # Calling this method without any trained categories will throw an error + def untrain(category, content) + return unless backend.categories.collect(&:downcase).collect(&:to_sym).include?(category) + + backend.untrain(category, content) + end + + delegate :train, :reset, to: :backend + + def classify(content) + @category, @internal_score = backend.classify_with_score(content) + category + end + + # The Bayes strategy returns a score between that can be lower than -1 + # As per ClassifierReborn documentation, closest to 0 is being picked as the dominant category + # + # From original documentation: + # Returns the scores in each category the provided +text+. E.g., + # b.classifications "I hate bad words and you" + # => {"Uninteresting"=>-12.6997928013932, "Interesting"=>-18.4206807439524} + # The largest of these scores (the one closest to 0) is the one picked out by #classify + def score + category.presence == "spam" ? 1 : 0 + end + + private + + attr_reader :backend, :options, :available_categories, :category, :internal_score + + def configured_backend + if options[:adapter].to_s == "memory" + system_log "[decidim-ai] #{self.class.name} - Running the Memory backend as it was requested. This is not recommended for production environment." + ClassifierReborn::BayesMemoryBackend.new + elsif options.dig(:params, :url) && options.dig(:params, :url).empty? + system_log "[decidim-ai] #{self.class.name} - Running the Memory backend as there are no redis credentials. This is not recommended for production environment." + ClassifierReborn::BayesMemoryBackend.new + else + system_log "[decidim-ai] #{self.class.name} - Running the Redis backend" + ClassifierReborn::BayesRedisBackend.new options[:params] + end + end + + def system_log(message) + Rails.logger.info message + end + end + end + end + end +end diff --git a/decidim-ai/lib/decidim/ai/strategy_registry.rb b/decidim-ai/lib/decidim/ai/strategy_registry.rb new file mode 100644 index 000000000000..f933748d92c1 --- /dev/null +++ b/decidim-ai/lib/decidim/ai/strategy_registry.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Decidim + module Ai + class StrategyRegistry + class StrategyAlreadyRegistered < StandardError; end + + delegate :clear, :collect, :each, :size, to: :strategies + attr_reader :strategies + + def initialize + @strategies = [] + end + + def register_analyzer(name:, strategy:, options: {}) + if self.for(name).present? + raise( + StrategyAlreadyRegistered, + "There is a strategy already registered with the name `:#{name}`" + ) + end + + options = { name: }.merge(options) + strategies << strategy.new(options) + end + + def for(name) + strategies.select { |k, _v| k.name == name }.first + end + end + end +end diff --git a/decidim-ai/lib/decidim/ai/test/factories.rb b/decidim-ai/lib/decidim/ai/test/factories.rb new file mode 100644 index 000000000000..8e9b8f90fa40 --- /dev/null +++ b/decidim-ai/lib/decidim/ai/test/factories.rb @@ -0,0 +1 @@ +# frozen_string_literal: true diff --git a/decidim-ai/lib/decidim/ai/version.rb b/decidim-ai/lib/decidim/ai/version.rb new file mode 100644 index 000000000000..682e64677bdf --- /dev/null +++ b/decidim-ai/lib/decidim/ai/version.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Decidim + module Ai + def self.version + "0.30.0.dev" + end + end +end diff --git a/decidim-ai/lib/tasks/decidim_ai.rake b/decidim-ai/lib/tasks/decidim_ai.rake new file mode 100644 index 000000000000..5ad319562689 --- /dev/null +++ b/decidim-ai/lib/tasks/decidim_ai.rake @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +namespace :decidim do + namespace :ai do + namespace :spam do + desc "Create reporting user" + task create_reporting_user: :environment do + Decidim::Ai::SpamDetection.create_reporting_user! + end + + desc "Load application dataset file" + task :load_application_dataset, [:file] => :environment do |_, args| + Decidim::Ai::SpamDetection::Importer::File.call(args[:file], Decidim::Ai::SpamDetection.user_classifier) + Decidim::Ai::SpamDetection::Importer::File.call(args[:file], Decidim::Ai::SpamDetection.resource_classifier) + end + + desc "Train model using application database" + task train_application_database: :environment do + Decidim::Ai::SpamDetection::Importer::Database.call + end + + desc "Reset all training model" + task reset: :environment do + Decidim::Ai::SpamDetection.user_classifier.reset + Decidim::Ai::SpamDetection.resource_classifier.reset + end + + private + + def plugin_path + Gem.loaded_specs["decidim-ai"].full_gem_path + end + end + end +end diff --git a/decidim-ai/spec/event_handlers/comments/user_creates_comment_spec.rb b/decidim-ai/spec/event_handlers/comments/user_creates_comment_spec.rb new file mode 100644 index 000000000000..51b69665c860 --- /dev/null +++ b/decidim-ai/spec/event_handlers/comments/user_creates_comment_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "User creates comment", type: :system do + let(:form_params) do + { + "comment" => { + "body" => body, + "alignment" => 1, + "user_group_id" => nil, + "commentable" => commentable + } + } + end + let(:form) do + Decidim::Comments::CommentForm.from_params( + form_params + ).with_context( + current_organization: organization, + current_user: author + ) + end + let(:command) { Decidim::Comments::CreateComment.new(form) } + + include_examples "comments spam analysis" +end diff --git a/decidim-ai/spec/event_handlers/comments/user_updates_comment_spec.rb b/decidim-ai/spec/event_handlers/comments/user_updates_comment_spec.rb new file mode 100644 index 000000000000..1843ffce07d2 --- /dev/null +++ b/decidim-ai/spec/event_handlers/comments/user_updates_comment_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "User updates comment", type: :system do + let(:form_params) do + { + "comment" => { + "body" => body, + "commentable" => commentable + } + } + end + let(:form) do + Decidim::Comments::CommentForm.from_params( + form_params + ).with_context( + current_organization: organization, + current_user: author + ) + end + let(:command) { Decidim::Comments::UpdateComment.new(comment, form) } + + include_examples "comments spam analysis" do + let(:comment) { create(:comment, author:, commentable:) } + end +end diff --git a/decidim-ai/spec/event_handlers/debates/user_creates_debate_spec.rb b/decidim-ai/spec/event_handlers/debates/user_creates_debate_spec.rb new file mode 100644 index 000000000000..8b9f55e5b222 --- /dev/null +++ b/decidim-ai/spec/event_handlers/debates/user_creates_debate_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "User creates debate", type: :system do + let(:form) do + double( + invalid?: false, + title:, + description:, + user_group_id: nil, + scope:, + category:, + current_user: author, + current_component: component, + current_organization: organization + ) + end + let(:command) { Decidim::Debates::CreateDebate.new(form) } + + include_examples "debates spam analysis" +end diff --git a/decidim-ai/spec/event_handlers/debates/user_updates_debate_spec.rb b/decidim-ai/spec/event_handlers/debates/user_updates_debate_spec.rb new file mode 100644 index 000000000000..1f193ec68d73 --- /dev/null +++ b/decidim-ai/spec/event_handlers/debates/user_updates_debate_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "User updates meeting", type: :system do + let(:form) do + Decidim::Debates::DebateForm.from_params( + title:, + description:, + scope_id: scope.id, + category_id: category.id, + id: debate.id + ).with_context( + current_organization: organization, + current_participatory_space: participatory_space, + current_component: component, + current_user: author + ) + end + + let(:command) { Decidim::Debates::UpdateDebate.new(form, debate) } + + include_examples "debates spam analysis" do + let!(:debate) do + create(:debate, author:, component:, + title: { en: "Some proposal that is not blocked" }, + description: { en: "The body for the meeting." }) + end + end +end diff --git a/decidim-ai/spec/event_handlers/initiatives/user_creates_initiative_spec.rb b/decidim-ai/spec/event_handlers/initiatives/user_creates_initiative_spec.rb new file mode 100644 index 000000000000..4f143141ef3c --- /dev/null +++ b/decidim-ai/spec/event_handlers/initiatives/user_creates_initiative_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "User creates debate", type: :system do + let(:initiatives_type) { create(:initiatives_type, organization:) } + let(:scope) { create(:initiatives_type_scope, type: initiatives_type) } + + let(:form) do + Decidim::Initiatives::InitiativeForm.from_params( + title:, + description:, + type_id: initiatives_type.id, + scope_id: scope&.scope&.id, + signature_type: "offline", + attachment: nil + ).with_context( + current_organization: organization, + current_component: nil, + current_user: author, + initiative_type: initiatives_type + ) + end + let(:command) { Decidim::Initiatives::CreateInitiative.new(form) } + + include_examples "initiatives spam analysis" +end diff --git a/decidim-ai/spec/event_handlers/initiatives/user_updates_initiative_spec.rb b/decidim-ai/spec/event_handlers/initiatives/user_updates_initiative_spec.rb new file mode 100644 index 000000000000..ad77245df4da --- /dev/null +++ b/decidim-ai/spec/event_handlers/initiatives/user_updates_initiative_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "User updates meeting", type: :system do + let(:form) do + Decidim::Initiatives::InitiativeForm.from_params( + title:, + description:, + type_id: initiative&.type&.id, + scope_id: initiative&.scope&.id, + signature_type: initiative.signature_type, + attachment: nil + ).with_context( + current_organization: organization, + initiative_type: initiative&.type, + current_user: author + ) + end + let(:command) { Decidim::Initiatives::UpdateInitiative.new(initiative, form) } + + context "when initiative is published" do + include_examples "initiatives spam analysis" do + let!(:initiative) do + create(:initiative, + :open, + organization:, + author:, + title: { "en" => "Some proposal that is not blocked" }, + description: { "en" => "The body for the proposal." }) + end + end + end + + context "when initiative is draft" do + include_examples "initiatives spam analysis" do + let!(:initiative) do + create(:initiative, + :created, + organization:, + author:, + title: { "en" => "Some proposal that is not blocked" }, + description: { "en" => "The body for the proposal." }) + end + end + end +end diff --git a/decidim-ai/spec/event_handlers/meetings/user_creates_meeting_spec.rb b/decidim-ai/spec/event_handlers/meetings/user_creates_meeting_spec.rb new file mode 100644 index 000000000000..a6b90f0c5e2c --- /dev/null +++ b/decidim-ai/spec/event_handlers/meetings/user_creates_meeting_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "User creates meeting", type: :system do + let(:form) do + double( + invalid?: false, + title:, + description:, + location: "The location of the meeting", + location_hints: "The location hints of the meeting", + start_time: 1.day.from_now, + end_time: 1.day.from_now + 2.hours, + address: "address", + latitude: 40.1234, + longitude: 2.1234, + scope:, + category:, + user_group_id: nil, + current_user: author, + current_component: component, + component:, + current_organization: organization, + registration_type: "on_this_platform", + available_slots: 0, + registration_url: "http://decidim.org", + registration_terms: "This meeting is not blocked", + registrations_enabled: true, + clean_type_of_meeting: "online", + online_meeting_url: "http://decidim.org", + iframe_embed_type: "embed_in_meeting_page", + iframe_access_level: "all" + ) + end + let(:command) { Decidim::Meetings::CreateMeeting.new(form) } + + include_examples "meetings spam analysis" +end diff --git a/decidim-ai/spec/event_handlers/meetings/user_updates_meeting_spec.rb b/decidim-ai/spec/event_handlers/meetings/user_updates_meeting_spec.rb new file mode 100644 index 000000000000..430762ddb06d --- /dev/null +++ b/decidim-ai/spec/event_handlers/meetings/user_updates_meeting_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "User updates meeting", type: :system do + let(:form) do + double( + invalid?: false, + title:, + description:, + location: "The location of the meeting", + location_hints: "The location hints of the meeting", + start_time: 1.day.from_now, + end_time: 1.day.from_now + 2.hours, + address: "address", + latitude: 40.1234, + longitude: 2.1234, + scope:, + category:, + user_group_id: nil, + current_user: author, + current_component: component, + current_organization: organization, + registration_type: "on_this_platform", + available_slots: 0, + registration_url: "http://decidim.org", + registration_terms: "This meeting is not blocked", + registrations_enabled: true, + clean_type_of_meeting: "online", + online_meeting_url: "http://decidim.org", + iframe_embed_type: "embed_in_meeting_page", + iframe_access_level: "all" + ) + end + let(:command) { Decidim::Meetings::UpdateMeeting.new(form, meeting) } + + include_examples "meetings spam analysis" do + let!(:meeting) do + create(:meeting, + component:, + title: { en: "Some proposal that is not blocked" }, + description: { en: "The body for the meeting." }) + end + end +end diff --git a/decidim-ai/spec/event_handlers/proposals/user_creates_collaborative_draft_spec.rb b/decidim-ai/spec/event_handlers/proposals/user_creates_collaborative_draft_spec.rb new file mode 100644 index 000000000000..fc6c1729cb15 --- /dev/null +++ b/decidim-ai/spec/event_handlers/proposals/user_creates_collaborative_draft_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "User creates collaborative draft", type: :system do + let(:form) do + Decidim::Proposals::CollaborativeDraftForm.from_params( + title:, + body:, + address: nil, + has_address: false, + latitude: 40.1234, + longitude: 2.1234, + add_documents: nil, + user_group_id: user_group.try(:id), + suggested_hashtags: [] + ).with_context( + current_user: author, + current_organization: organization, + current_participatory_space: participatory_space, + current_component: component + ) + end + + let(:command) { Decidim::Proposals::CreateCollaborativeDraft.new(form, author) } + + include_examples "Collaborative draft spam analysis" +end diff --git a/decidim-ai/spec/event_handlers/proposals/user_creates_proposal_spec.rb b/decidim-ai/spec/event_handlers/proposals/user_creates_proposal_spec.rb new file mode 100644 index 000000000000..1231fbea787d --- /dev/null +++ b/decidim-ai/spec/event_handlers/proposals/user_creates_proposal_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "User creates proposal", type: :system do + let(:form) do + Decidim::Proposals::ProposalForm.from_params( + title:, + body:, + user_group_id: user_group.try(:id) + ).with_context( + current_user: author, + current_organization: organization, + current_participatory_space: participatory_space, + current_component: component + ) + end + let(:command) { Decidim::Proposals::CreateProposal.new(form, author) } + + include_examples "proposal spam analysis" +end diff --git a/decidim-ai/spec/event_handlers/proposals/user_updates_collaborative_draft_spec.rb b/decidim-ai/spec/event_handlers/proposals/user_updates_collaborative_draft_spec.rb new file mode 100644 index 000000000000..b9bf1dea7a7d --- /dev/null +++ b/decidim-ai/spec/event_handlers/proposals/user_updates_collaborative_draft_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "User updates collaborative draft", type: :system do + let(:form) do + Decidim::Proposals::CollaborativeDraftForm.from_params( + title:, + body:, + address: nil, + has_address: false, + latitude: 40.1234, + longitude: 2.1234, + add_documents: nil, + user_group_id: user_group.try(:id), + suggested_hashtags: [] + ).with_context( + current_user: author, + current_organization: organization, + current_participatory_space: participatory_space, + current_component: component + ) + end + + let(:command) do + Decidim::Proposals::UpdateCollaborativeDraft.new(form, author, collaborative_draft) + end + + include_examples "Collaborative draft spam analysis" do + let!(:collaborative_draft) do + create(:collaborative_draft, + component:, + users: [author], + title: "Some draft that is not blocked", + body: "The body for the proposal.") + end + end +end diff --git a/decidim-ai/spec/event_handlers/proposals/user_updates_proposal_spec.rb b/decidim-ai/spec/event_handlers/proposals/user_updates_proposal_spec.rb new file mode 100644 index 000000000000..fc14b47d9449 --- /dev/null +++ b/decidim-ai/spec/event_handlers/proposals/user_updates_proposal_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "User updates proposal", type: :system do + let(:form) do + Decidim::Proposals::ProposalForm.from_params( + title:, + body:, + address: nil, + has_address: false, + user_group_id: user_group.try(:id), + suggested_hashtags: [], + attachment: nil, + photos: [], + add_photos: [], + documents: [], + add_documents: [], + errors: double.as_null_object + ).with_context( + current_organization: organization, + current_participatory_space: participatory_space, + current_component: component + ) + end + let(:command) { Decidim::Proposals::UpdateProposal.new(form, author, proposal) } + + context "when proposal is published" do + include_examples "proposal spam analysis" do + let!(:proposal) do + create(:proposal, + :published, + component:, + users: [author], + title: "Some proposal that is not blocked", + body: "The body for the proposal.") + end + end + end + + context "when proposal is draft" do + include_examples "proposal spam analysis" do + let!(:proposal) do + create(:proposal, + :draft, + component:, + users: [author], + title: "Some draft that is not blocked", + body: "The body for the proposal.") + end + end + end +end diff --git a/decidim-ai/spec/event_handlers/resource_is_being_hidden_spec.rb b/decidim-ai/spec/event_handlers/resource_is_being_hidden_spec.rb new file mode 100644 index 000000000000..65c54c6f5222 --- /dev/null +++ b/decidim-ai/spec/event_handlers/resource_is_being_hidden_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "User is being blocked by admin", type: :system do + subject { Decidim::Admin::HideResource.new(reportable, current_user) } + + let(:reportable) { create(:dummy_resource) } + let(:current_user) { create(:user, organization: reportable.participatory_space.organization) } + let(:moderation) { create(:moderation, reportable:, report_count: 1) } + let!(:report) { create(:report, moderation:) } + + it "broadcasts ok" do + expect { subject.call }.to broadcast(:ok) + end + + it "enqueues a training job" do + expect { subject.call }.to have_enqueued_job(Decidim::Ai::SpamDetection::TrainHiddenResourceDataJob).on_queue("spam_analysis").with(reportable) + end +end diff --git a/decidim-ai/spec/event_handlers/user/user_changes_profile_data_spec.rb b/decidim-ai/spec/event_handlers/user/user_changes_profile_data_spec.rb new file mode 100644 index 000000000000..c05df55dd602 --- /dev/null +++ b/decidim-ai/spec/event_handlers/user/user_changes_profile_data_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "User changes own data", 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 + { + name: user.name, + nickname: user.nickname, + email: user.email, + password: nil, + password_confirmation: nil, + avatar: nil, + remove_avatar: nil, + personal_url: "https://example.org", + about:, + locale: "es" + } + end + let(:organization) { create(:organization) } + let!(:system_user) { create(:user, :confirmed, email: Decidim::Ai::SpamDetection.reporting_user_email, organization:) } + + let(:user) { create(:user, :confirmed, about: "Some description about me, that is not going to be very easily blocked.", organization:) } + let(:command) { Decidim::UpdateAccount.new(form) } + + let(:form) do + Decidim::AccountForm.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 + + 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 diff --git a/decidim-ai/spec/event_handlers/user/user_is_being_blocked_spec.rb b/decidim-ai/spec/event_handlers/user/user_is_being_blocked_spec.rb new file mode 100644 index 000000000000..dee96b7581ff --- /dev/null +++ b/decidim-ai/spec/event_handlers/user/user_is_being_blocked_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "User is being blocked by admin", type: :system do + subject { Decidim::Admin::BlockUser.new(form) } + + let(:organization) { create(:organization) } + let(:user_to_block) { create(:user, :confirmed, organization:) } + let(:current_user) { create(:user, :admin, organization:) } + + let(:form) do + double( + user: user_to_block, + current_user:, + justification: "justification for blocking the user", + valid?: true, + hide?: true + ) + end + + it "broadcasts ok" do + expect { subject.call }.to broadcast(:ok, user_to_block) + end + + it "enqueues a training job" do + expect { subject.call }.to have_enqueued_job(Decidim::Ai::SpamDetection::TrainUserDataJob).on_queue("spam_analysis").with(user_to_block) + end +end diff --git a/decidim-ai/spec/event_handlers/user/user_manages_user_group_spec.rb b/decidim-ai/spec/event_handlers/user/user_manages_user_group_spec.rb new file mode 100644 index 000000000000..0fc5c7a3b1b2 --- /dev/null +++ b/decidim-ai/spec/event_handlers/user/user_manages_user_group_spec.rb @@ -0,0 +1,94 @@ +# 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/decidim-ai/spec/factories.rb b/decidim-ai/spec/factories.rb new file mode 100644 index 000000000000..b0e2cc4d7d62 --- /dev/null +++ b/decidim-ai/spec/factories.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require "decidim/core/test/factories" +require "decidim/ai/test/factories" +require "decidim/initiatives/test/factories" diff --git a/decidim-ai/spec/jobs/decidim/ai/spam_detection/train_hidden_resource_data_job_spec.rb b/decidim-ai/spec/jobs/decidim/ai/spam_detection/train_hidden_resource_data_job_spec.rb new file mode 100644 index 000000000000..ebe080ba5b95 --- /dev/null +++ b/decidim-ai/spec/jobs/decidim/ai/spam_detection/train_hidden_resource_data_job_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Ai + module SpamDetection + describe TrainHiddenResourceDataJob do + subject { described_class } + + shared_examples "a train hidden resource data job" do + let(:backend) { ClassifierReborn::BayesMemoryBackend.new } + + let(:bayes_classifier) { ClassifierReborn::Bayes.new :spam, :ham, backend: } + let(:algorithm) { Decidim::Ai::SpamDetection::Strategy::Bayes.new({}) } + + before do + Decidim::Ai::SpamDetection.resource_registry.clear + allow(algorithm).to receive(:backend).and_return(bayes_classifier) + allow(Decidim::Ai::SpamDetection.resource_registry).to receive(:strategies).and_return([algorithm]) + Decidim::Ai::SpamDetection.resource_classifier.train(:ham, text) + end + + it "adds data to spam" do + expect(backend.category_word_count(:ham)).to eq(4) + expect(backend.category_word_count(:spam)).to eq(0) + + moderation = Decidim::Moderation.find_or_create_by!(reportable:, participatory_space: participatory_process) + moderation.update!( + reported_content: text, + report_count: Decidim.max_reports_before_hiding, + hidden_at: Time.current + ) + Decidim::Report.create!( + moderation:, + user: author, + reason: "spam", + details: "testing purposes", + locale: I18n.locale + ) + + subject.perform_now(reportable) + expect(backend.category_word_count(:ham)).to eq(0) + expect(backend.category_word_count(:spam)).to eq(4) + end + end + + let(:organization) { create(:organization) } + let(:participatory_process) { create(:participatory_process, organization:) } + let(:author) { create(:user, organization:) } + let(:text) { "This is a very good idea!" } + + context "when the reportable is a comment" do + it_behaves_like "a train hidden resource data job" do + let(:component) { create(:component, participatory_space: participatory_process) } + let(:dummy_resource) { create(:dummy_resource, component:) } + let(:commentable) { dummy_resource } + let!(:comment) { create(:comment, author:, commentable:, body: { en: text }) } + + let(:reportable) { comment } + end + end + + context "when the reportable is a proposal" do + it_behaves_like "a train hidden resource data job" do + let(:component) { create(:component, manifest_name: "proposals", participatory_space: participatory_process) } + let(:reportable) { create(:proposal, component:, users: [author], title: { en: text }, body: { en: "" }) } + end + end + + context "when the reportable is a collaborative draft" do + it_behaves_like "a train hidden resource data job" do + let(:component) { create(:proposal_component, :with_collaborative_drafts_enabled, participatory_space: participatory_process) } + let(:reportable) { create(:collaborative_draft, component:, users: [author], title: text, body: "") } + end + end + + context "when the reportable is a meeting" do + it_behaves_like "a train hidden resource data job" do + let(:component) { create(:meeting_component, participatory_space: participatory_process) } + let(:reportable) do + create(:meeting, component:, author:, title: { en: text }, description: { en: "" }, + location_hints: { en: "" }, registration_terms: { en: "" }, closing_report: { en: "" }) + end + end + end + + context "when the reportable is a debate" do + it_behaves_like "a train hidden resource data job" do + let(:component) { create(:component, manifest_name: "debates", participatory_space: participatory_process) } + let(:reportable) { create(:debate, component:, author:, title: { en: text }, description: { en: "" }) } + end + end + end + end + end +end diff --git a/decidim-ai/spec/jobs/decidim/ai/spam_detection/train_user_data_job_spec.rb b/decidim-ai/spec/jobs/decidim/ai/spam_detection/train_user_data_job_spec.rb new file mode 100644 index 000000000000..1ff33dcf8870 --- /dev/null +++ b/decidim-ai/spec/jobs/decidim/ai/spam_detection/train_user_data_job_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Ai + module SpamDetection + describe TrainUserDataJob do + subject { described_class } + let(:organization) { create(:organization) } + let(:about) { "This is a short info about me" } + let!(:user) { create(:user, :confirmed, organization:, about:) } + + let(:backend) { ClassifierReborn::BayesMemoryBackend.new } + + let(:bayes_classifier) { ClassifierReborn::Bayes.new :spam, :ham, backend: } + let(:algorithm) { Decidim::Ai::SpamDetection::Strategy::Bayes.new({}) } + + before do + Decidim::Ai::SpamDetection.user_registry.clear + allow(algorithm).to receive(:backend).and_return(bayes_classifier) + allow(Decidim::Ai::SpamDetection.user_registry).to receive(:strategies).and_return([algorithm]) + Decidim::Ai::SpamDetection.user_classifier.train(:ham, about) + end + + it "adds data to spam" do + expect(backend.category_word_count(:ham)).to eq(3) + expect(backend.category_word_count(:spam)).to eq(0) + user.blocked = true + user.save! + subject.perform_now(user) + expect(backend.category_word_count(:ham)).to eq(0) + expect(backend.category_word_count(:spam)).to eq(3) + end + end + end + end +end diff --git a/decidim-ai/spec/lib/decidim/ai/spam_detection/importer/database_spec.rb b/decidim-ai/spec/lib/decidim/ai/spam_detection/importer/database_spec.rb new file mode 100644 index 000000000000..1700ec550ac2 --- /dev/null +++ b/decidim-ai/spec/lib/decidim/ai/spam_detection/importer/database_spec.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe Decidim::Ai::SpamDetection::Importer::Database do + around do |example| + resources = Decidim::Ai::SpamDetection.resource_models + + example.run + + Decidim::Ai::SpamDetection.resource_models = resources + end + + shared_examples "resource is being indexed" do + let(:organization) { create(:organization) } + let!(:author) { create(:user, organization:) } + let(:component) { create(:component, participatory_space:, manifest_name:) } + let(:participatory_space) { create(:participatory_process, organization:) } + let(:instance) { Decidim::Ai::SpamDetection::Service.new(registry: Decidim::Ai::SpamDetection.resource_registry) } + + before do + Decidim::Ai::SpamDetection.resource_models = resource_models + allow(Decidim::Ai::SpamDetection).to receive(:resource_classifier).and_return(instance) + end + + it "successfully loads the dataset" do + expect(instance).to receive(:train).exactly(training).times + + described_class.call + end + end + + context "when trained model is Decidim::Initiative" do + let(:organization) { create(:organization) } + let!(:author) { create(:user, organization:) } + let(:training) { 8 } + let!(:resource_models) { { "Decidim::Initiative" => "Decidim::Ai::SpamDetection::Resource::Initiative" } } + + include_examples "resource is being indexed" do + let!(:participatory_space) { create_list(:initiative, 4, author:, organization:) } + end + end + + context "when trained model is Decidim::Comment::Comment" do + let(:manifest_name) { "dummy" } + let(:dummy_resource) { create(:dummy_resource, component:) } + let(:commentable) { dummy_resource } + let!(:comments) { create_list(:comment, 4, author:, commentable:) } + let(:training) { 4 } + let(:resource_models) { { "Decidim::Comments::Comment" => "Decidim::Ai::SpamDetection::Resource::Comment" } } + + include_examples "resource is being indexed" + end + + context "when trained model is Decidim::Meetings::Meeting" do + 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(:resource_models) { { "Decidim::Meetings::Meeting" => "Decidim::Ai::SpamDetection::Resource::Meeting" } } + + include_examples "resource is being indexed" + 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(:resource_models) { { "Decidim::Proposals::Proposal" => "Decidim::Ai::SpamDetection::Resource::Proposal" } } + + include_examples "resource is being indexed" + 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(:resource_models) { { "Decidim::Proposals::CollaborativeDraft" => "Decidim::Ai::SpamDetection::Resource::CollaborativeDraft" } } + + include_examples "resource is being indexed" + end + + context "when trained model is Decidim::Debates::Debate" do + let(:manifest_name) { "debates" } + let(:training) { 8 } + + let!(:debates) do + create_list(:debate, 4, + author:, component:, + title: { en: "Some proposal that is not blocked" }, + description: { en: "The body for the meeting." }) + end + let(:resource_models) { { "Decidim::Debates::Debate" => "Decidim::Ai::SpamDetection::Resource::Debate" } } + + include_examples "resource is being indexed" + end + + context "when trained model is Decidim::User" do + let(:tested) { 3 } + let(:training) { tested + 1 } # 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" } } + + 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 + + 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/decidim-ai/spec/lib/decidim/ai/spam_detection/importer/file_spec.rb b/decidim-ai/spec/lib/decidim/ai/spam_detection/importer/file_spec.rb new file mode 100644 index 000000000000..4b7ed7e24a11 --- /dev/null +++ b/decidim-ai/spec/lib/decidim/ai/spam_detection/importer/file_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe Decidim::Ai::SpamDetection::Importer::File do + it "successfully loads the dataset" do + instance = Decidim::Ai::SpamDetection::Service.new(registry: Decidim::Ai::SpamDetection.resource_registry) + allow(Decidim::Ai::SpamDetection).to receive(:resource_classifier).and_return(instance) + expect(instance).to receive(:train).exactly(4).times + + described_class.call(Decidim::Ai::Engine.root.join("spec/support/test.csv"), Decidim::Ai::SpamDetection.resource_classifier) + end +end diff --git a/decidim-ai/spec/lib/decidim/ai/spam_detection/service_spec.rb b/decidim-ai/spec/lib/decidim/ai/spam_detection/service_spec.rb new file mode 100644 index 000000000000..668d119db25b --- /dev/null +++ b/decidim-ai/spec/lib/decidim/ai/spam_detection/service_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe Decidim::Ai::SpamDetection::Service 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") + expect(registry.for(:dummy)).to receive(:classify).with("text") + + subject.classify("text") + 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 diff --git a/decidim-ai/spec/lib/decidim/ai/spam_detection/strategy/base_spec.rb b/decidim-ai/spec/lib/decidim/ai/spam_detection/strategy/base_spec.rb new file mode 100644 index 000000000000..81e333d0bc18 --- /dev/null +++ b/decidim-ai/spec/lib/decidim/ai/spam_detection/strategy/base_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe Decidim::Ai::SpamDetection::Strategy::Base do + subject { described_class.new({}) } + + it "trains" do + expect { subject.train(:spam, "text") }.not_to raise_error + end + + it "untrains" do + expect { subject.untrain(:spam, "text") }.not_to raise_error + end + + it "classifies" do + expect { subject.classify("text") }.not_to raise_error + end +end diff --git a/decidim-ai/spec/lib/decidim/ai/spam_detection/strategy/bayes_spec.rb b/decidim-ai/spec/lib/decidim/ai/spam_detection/strategy/bayes_spec.rb new file mode 100644 index 000000000000..4371f8046648 --- /dev/null +++ b/decidim-ai/spec/lib/decidim/ai/spam_detection/strategy/bayes_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe Decidim::Ai::SpamDetection::Strategy::Bayes do + subject { described_class.new({}) } + + let(:backend) { ClassifierReborn::Bayes.new :spam, :ham } + + describe "train" do + it "calls backend.train" do + expect(subject.send(:backend)).to receive(:train).with(:spam, "text") + + subject.train(:spam, "text") + end + end + + describe "untrain" do + it "calls backend.untrain" do + subject.train(:spam, "text") + subject.train(:ham, "foo bar") + + expect(subject.send(:backend)).to receive(:untrain).with(:spam, "text") + + subject.untrain(:spam, "text") + end + end + + describe "classify" do + it "calls backend.classify" do + expect(subject.send(:backend)).to receive(:classify_with_score).with("text") + + subject.classify("text") + end + end + + describe "log" do + it "returns a log" do + expect(subject.log).to be_nil + end + + context "when category is spam" do + it "returns a log" do + allow(subject.send(:backend)).to receive(:classify_with_score).with("text").and_return(["spam", -12.6997]) + subject.classify("text") + expect(subject.log).to eq("The Classification engine marked this as spam") + end + end + end + + describe "score" do + it "returns a score" do + expect(subject.score).to eq(0) + end + + it "returns 0 when is ham" do + allow(subject.send(:backend)).to receive(:classify_with_score).with("text").and_return(["ham", -12.6997]) + subject.classify("text") + expect(subject.score).to eq(0) + end + + it "returns 1 when is spam" do + subject.train(:spam, "text") + subject.train(:ham, "foo bar") + + allow(subject.send(:backend)).to receive(:classify_with_score).with("text").and_return(["spam", -12.6997]) + subject.classify("text") + expect(subject.score).to eq(1) + end + end +end diff --git a/decidim-ai/spec/lib/decidim/ai/strategy_registry_spec.rb b/decidim-ai/spec/lib/decidim/ai/strategy_registry_spec.rb new file mode 100644 index 000000000000..b3cee443bdd4 --- /dev/null +++ b/decidim-ai/spec/lib/decidim/ai/strategy_registry_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Ai + describe StrategyRegistry do + subject { described_class.new } + + let(:analyzer) { { name: :dummy, strategy: Decidim::Ai::SpamDetection::Strategy::Base } } + + describe "register_analyzer" do + it "registers a content block" do + subject.register_analyzer(**analyzer) + + expect(subject.for(:dummy)).to be_a(Decidim::Ai::SpamDetection::Strategy::Base) + end + + it "raises an error if the content block is already registered" do + subject.register_analyzer(**analyzer) + + expect { subject.register_analyzer(**analyzer) } + .to raise_error(described_class::StrategyAlreadyRegistered) + end + end + + describe "for(:scope)" do + it "returns all content blocks for that scope" do + subject.register_analyzer(**analyzer) + + expect(subject.for(:dummy)).to be_a(Decidim::Ai::SpamDetection::Strategy::Base) + end + end + + describe "all" do + it "returns all content blocks" do + subject.register_analyzer(**analyzer) + + expect(subject.strategies).to be_a(Array) + expect(subject.size).to be(1) + end + end + end + end +end diff --git a/decidim-ai/spec/lib/engine_spec.rb b/decidim-ai/spec/lib/engine_spec.rb new file mode 100644 index 000000000000..52a3060626c4 --- /dev/null +++ b/decidim-ai/spec/lib/engine_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Ai + describe ".resource_classifier" do + subject { Decidim::Ai::SpamDetection.resource_classifier } + + it "returns a spam detection service" do + expect(subject).to be_a(Decidim::Ai::SpamDetection::Service) + end + end + + describe ".user_classifier" do + subject { Decidim::Ai::SpamDetection.user_classifier } + + it "returns a spam detection service" do + expect(subject).to be_a(Decidim::Ai::SpamDetection::Service) + end + end + + describe ".resource_registry" do + it "return strategy class" do + expect(Decidim::Ai::SpamDetection.resource_registry).to be_a(Decidim::Ai::StrategyRegistry) + end + end + + describe ".user_registry" do + it "return strategy class" do + expect(Decidim::Ai::SpamDetection.user_registry).to be_a(Decidim::Ai::StrategyRegistry) + end + end + + describe ".create_reporting_users" do + let!(:organization) { create(:organization) } + + it "successfully creates user" do + expect { Decidim::Ai::SpamDetection.create_reporting_user! }.to change(Decidim::User, :count).by(1) + expect(Decidim::User.where(email: Decidim::Ai::SpamDetection.reporting_user_email).count).to eq(1) + end + + it "ignores existing user" do + Decidim::Ai::SpamDetection.create_reporting_user! + expect(Decidim::User.where(email: Decidim::Ai::SpamDetection.reporting_user_email).count).to eq(1) + expect { Decidim::Ai::SpamDetection.create_reporting_user! }.not_to change(Decidim::User, :count) + expect(Decidim::User.where(email: Decidim::Ai::SpamDetection.reporting_user_email).count).to eq(1) + end + + it "creates users for all organizations" do + create_list(:organization, 2) + expect { Decidim::Ai::SpamDetection.create_reporting_user! }.to change(Decidim::User, :count).by(3) + end + end + end +end diff --git a/decidim-ai/spec/shared/events_examples.rb b/decidim-ai/spec/shared/events_examples.rb new file mode 100644 index 000000000000..55eaf661365d --- /dev/null +++ b/decidim-ai/spec/shared/events_examples.rb @@ -0,0 +1,219 @@ +# frozen_string_literal: true + +shared_examples "content submitted to spam analysis" do + let(:organization) { create(:organization) } + let(:participatory_space) { create(:participatory_process, organization:) } + let!(:system_user) { create(:user, :confirmed, email: Decidim::Ai::SpamDetection.reporting_user_email, organization:) } + let(:component) { create(:component, participatory_space:, manifest_name:) } + let!(:author) { create(:user, :confirmed, organization:) } + let(:queue_size) { 1 } + + before do + Decidim::Ai::SpamDetection.resource_registry.clear + Decidim::Ai::SpamDetection.resource_registry.register_analyzer(name: :bayes, + strategy: Decidim::Ai::SpamDetection::Strategy::Bayes, + options: { adapter: :memory, params: {} }) + + Decidim::Ai::SpamDetection.resource_classifier.train :ham, "I am a passionate Decidim Maintainer. It is nice to be here." + Decidim::Ai::SpamDetection.resource_classifier.train :ham, "Yet I do not have an idea about what I am doing here." + Decidim::Ai::SpamDetection.resource_classifier.train :ham, "Maybe You would understand better, and you would not get blocked as i did." + Decidim::Ai::SpamDetection.resource_classifier.train :ham, "Just kidding, I needed some Ham to make an omelette." + + Decidim::Ai::SpamDetection.resource_classifier.train :spam, "You are the lucky winner! Claim your holiday prize." + end + + 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::Report, :count).by(spam_count) + expect(Decidim::Report.count).to eq(spam_count) + end + end +end + +shared_examples "initiatives spam analysis" do + context "when spam content is added" do + let(:description) { "Claim your prize today so you can win." } + let(:title) { "You are the Lucky winner" } + + include_examples "content submitted to spam analysis" do + let(:spam_count) { 1 } + let(:compared_field) { :description } + let(:compared_against) { description } + let(:resource) { Decidim::Initiative } + let(:component) { nil } + let(:participatory_space) { initiative } + end + end + + context "when regular content is added" do + let(:description) { "Very nice idea that is not going to be blocked by engine" } + let(:title) { "This is the debate title" } + + include_examples "content submitted to spam analysis" do + let(:spam_count) { 0 } + let(:compared_field) { :description } + let(:compared_against) { description } + let(:resource) { Decidim::Initiative } + let(:component) { nil } + let(:participatory_space) { initiative } + end + end +end + +shared_examples "debates spam analysis" do + let(:manifest_name) { "debates" } + let(:scope) { create(:scope, organization:) } + let(:category) { create(:category, participatory_space:) } + + context "when spam content is added" do + let(:description) { "Claim your prize today so you can win." } + let(:title) { "You are the Lucky winner" } + + include_examples "content submitted to spam analysis" do + let(:spam_count) { 1 } + let(:compared_field) { :description } + let(:compared_against) { description } + let(:resource) { Decidim::Debates::Debate } + end + end + + context "when regular content is added" do + let(:description) { "Very nice idea that is not going to be blocked by engine" } + let(:title) { "This is the debate title" } + + include_examples "content submitted to spam analysis" do + let(:spam_count) { 0 } + let(:compared_field) { :description } + let(:compared_against) { description } + let(:resource) { Decidim::Debates::Debate } + end + end +end + +shared_examples "comments spam analysis" do + let(:manifest_name) { "dummy" } + let(:dummy_resource) { create(:dummy_resource, component:) } + let(:commentable) { dummy_resource } + + context "when spam content is added" do + let(:body) { "Claim your prize today so you can win." } + + include_examples "content submitted to spam analysis" do + let(:spam_count) { 1 } + let(:compared_field) { :body } + let(:compared_against) { body } + let(:resource) { Decidim::Comments::Comment } + end + end + + context "when regular content is added" do + let(:body) { "Very nice idea that is not going to be blocked by engine" } + + include_examples "content submitted to spam analysis" do + let(:spam_count) { 0 } + let(:compared_field) { :body } + let(:compared_against) { body } + let(:resource) { Decidim::Comments::Comment } + end + end +end + +shared_examples "meetings spam analysis" do + let(:manifest_name) { "meetings" } + let(:scope) { create(:scope, organization:) } + let(:category) { create(:category, participatory_space:) } + + context "when spam content is added" do + let(:description) { "Claim your prize today so you can win." } + let(:title) { "You are the Lucky winner" } + + include_examples "content submitted to spam analysis" do + let(:spam_count) { 1 } + let(:compared_field) { :description } + let(:compared_against) { description } + let(:resource) { Decidim::Meetings::Meeting } + end + end + + context "when regular content is added" do + let(:description) { "Very nice idea that is not going to be blocked by engine" } + let(:title) { "This is the collaborative draft title" } + + include_examples "content submitted to spam analysis" do + let(:spam_count) { 0 } + let(:compared_field) { :description } + let(:compared_against) { description } + let(:resource) { Decidim::Meetings::Meeting } + end + end +end + +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." } + let(:title) { "You are the Lucky winner" } + + include_examples "content submitted to spam analysis" do + let(:spam_count) { 1 } + let(:compared_field) { :body } + let(:compared_against) { body } + let(:resource) { Decidim::Proposals::Proposal } + end + end + + context "when regular content is added" do + let(:body) { "Very nice idea that is not going to be blocked by engine" } + let(:title) { "This is the collaborative draft title" } + + include_examples "content submitted to spam analysis" do + let(:spam_count) { 0 } + let(:compared_field) { :body } + let(:compared_against) { body } + let(:resource) { Decidim::Proposals::Proposal } + end + end +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" } + + include_examples "content submitted to spam analysis" do + let(:component) { create(:proposal_component, :with_collaborative_drafts_enabled, participatory_space:) } + let(:spam_count) { 1 } + let(:compared_field) { :body } + let(:compared_against) { body } + let(:resource) { Decidim::Proposals::CollaborativeDraft } + end + end + + context "when regular content is added" do + let(:body) { "Very nice idea that is not going to be blocked by engine" } + let(:title) { "This is the collaborative draft title" } + + include_examples "content submitted to spam analysis" do + let(:component) { create(:proposal_component, :with_collaborative_drafts_enabled, participatory_space:) } + let(:spam_count) { 0 } + let(:compared_field) { :body } + let(:compared_against) { body } + let(:resource) { Decidim::Proposals::CollaborativeDraft } + end + end +end diff --git a/decidim-ai/spec/spec_helper.rb b/decidim-ai/spec/spec_helper.rb new file mode 100644 index 000000000000..f435e27a861e --- /dev/null +++ b/decidim-ai/spec/spec_helper.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "decidim/dev" + +ENV["ENGINE_ROOT"] = File.dirname(__dir__) +ENV["DECIDIM_SPAM_DETECTION_BACKEND_USER"] ||= "memory" +ENV["DECIDIM_SPAM_DETECTION_BACKEND_RESOURCE"] ||= "memory" + +Decidim::Dev.dummy_app_path = File.expand_path(File.join("..", "spec", "decidim_dummy_app")) + +require "decidim/dev/test/base_spec_helper" +require_relative "shared/events_examples" + +require "decidim/debates/test/factories" +require "decidim/meetings/test/factories" +require "decidim/proposals/test/factories" diff --git a/decidim-ai/spec/support/test.csv b/decidim-ai/spec/support/test.csv new file mode 100644 index 000000000000..44bf9dcfd142 --- /dev/null +++ b/decidim-ai/spec/support/test.csv @@ -0,0 +1,4 @@ +spam;Nous sommes heureux de vous proposer un produit avec le meilleur rapport qualité-prix. N'hésitez pas à nous contacter pour de plus amples informations, nous vous ferons une offre sur mesure. Grâce à nous, vous ne trouverez rien de mieux. +spam;Abudhabicarpet. ae is Abu Dhabi’s best Carpet Abu Dhabi industry brand as we have all varieties of carpets. We offer the best carpets fixing and fast installation services in Abu Dhabi. We also offer different specifications and sizes of carpet as per the demands of our customers. We are the best choice for our customers' demand. +spam;My name is Pooja Sharma and I am one of the Best Delhi Escorts Service providers for your requirements. Just like we need food we also need to enjoy life to live a happy and healthy life. I know, you may have not had a good experience while engaging with different partners but believe me. +spam;Mister Mechanic is offering a brand new forklift in a variety of models and sizes at market rates. Call our forklift selling company to find the right forklifts for your business. You can also buy used material handling equipment from us. We have both new and used material handling equipment from the world's leading manufacturer. diff --git a/decidim-ai/spec/tasks/decidim_ai_spec.rb b/decidim-ai/spec/tasks/decidim_ai_spec.rb new file mode 100644 index 000000000000..1f0ac7c0b393 --- /dev/null +++ b/decidim-ai/spec/tasks/decidim_ai_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Executing Decidim Ai tasks" do + describe "rake decidim:ai:spam:create_reporting_user", type: :task do + context "when executing task" do + let!(:organization) { create(:organization) } + + it "successfully invokes the user creation" do + expect { Rake::Task[:"decidim:ai:spam:create_reporting_user"].invoke }.to change(Decidim::User, :count).by(1) + end + end + end + + describe "rake decidim:ai:spam:load_application_dataset", type: :task do + context "when executing task" do + it "successfully loads the dataset" do + instance = Decidim::Ai::SpamDetection::Service.new(registry: Decidim::Ai::SpamDetection.resource_registry) + allow(Decidim::Ai::SpamDetection).to receive(:resource_classifier).and_return(instance) + expect(instance).to receive(:train).exactly(4).times + + Rake::Task[:"decidim:ai:spam:load_application_dataset"].invoke("spec/support/test.csv") + end + end + end + + describe "rake decidim:ai:spam:reset_training_model", type: :task do + context "when executing task" do + it "calls reset on the spam detection instance" do + instance = Decidim::Ai::SpamDetection::Service.new(registry: Decidim::Ai::SpamDetection.resource_registry) + allow(Decidim::Ai::SpamDetection).to receive(:resource_classifier).and_return(instance) + expect(instance).to receive(:reset).exactly(1).time + + Rake::Task[:"decidim:ai:spam:reset"].invoke + end + end + end +end diff --git a/decidim-core/app/commands/decidim/create_report.rb b/decidim-core/app/commands/decidim/create_report.rb index 2e7e35f59d30..629d3721a56c 100644 --- a/decidim-core/app/commands/decidim/create_report.rb +++ b/decidim-core/app/commands/decidim/create_report.rb @@ -53,7 +53,7 @@ def create_report! end def participatory_space_moderators - @participatory_space_moderators ||= participatory_space.moderators + @participatory_space_moderators ||= participatory_space.respond_to?(:moderators) ? participatory_space.moderators : [] end def send_report_notification_to_moderators @@ -84,9 +84,5 @@ def send_hide_notification_to_moderators ReportedMailer.hide(moderator, @report).deliver_later end end - - def participatory_space - @participatory_space ||= @reportable.component&.participatory_space || @reportable.try(:participatory_space) - end end end diff --git a/decidim-core/app/commands/decidim/create_user_group.rb b/decidim-core/app/commands/decidim/create_user_group.rb index 6ff82ceda680..73f8401d3cee 100644 --- a/decidim-core/app/commands/decidim/create_user_group.rb +++ b/decidim-core/app/commands/decidim/create_user_group.rb @@ -19,7 +19,7 @@ def initialize(form) def call return broadcast(:invalid) if form.invalid? - transaction do + with_events(with_transaction: true) do create_user_group create_membership end @@ -30,7 +30,11 @@ def call private - attr_reader :form + attr_reader :form, :user_group + + def event_arguments + { resource: user_group } + end def create_user_group @user_group = UserGroup.create!( diff --git a/decidim-core/app/commands/decidim/update_account.rb b/decidim-core/app/commands/decidim/update_account.rb index 387bd6d2a22a..82281b55658d 100644 --- a/decidim-core/app/commands/decidim/update_account.rb +++ b/decidim-core/app/commands/decidim/update_account.rb @@ -20,10 +20,12 @@ def call update_password if current_user.valid? - changes = current_user.changed - current_user.save! + with_events do + changes = current_user.changed + current_user.save! + send_update_summary!(changes) + end notify_followers - send_update_summary!(changes) broadcast(:ok, current_user.unconfirmed_email.present?) else [:avatar, :password].each do |key| @@ -33,6 +35,12 @@ def call end end + protected + + def event_arguments + { resource: current_user } + end + private attr_reader :form diff --git a/decidim-core/app/commands/decidim/update_user_group.rb b/decidim-core/app/commands/decidim/update_user_group.rb index d8c718e74831..31d1fe8fb6ee 100644 --- a/decidim-core/app/commands/decidim/update_user_group.rb +++ b/decidim-core/app/commands/decidim/update_user_group.rb @@ -22,7 +22,9 @@ def call return broadcast(:invalid) if form.invalid? was_verified = user_group.verified? - update_user_group + with_events do + update_user_group + end notify_admins if was_verified broadcast(:ok, user_group) @@ -32,6 +34,10 @@ def call attr_reader :form, :user_group + def event_arguments + { resource: user_group } + end + def update_user_group user_group_attributes = attributes user_group_attributes.delete(:avatar) if form.avatar.blank? diff --git a/decidim-core/lib/decidim/moderation_tools.rb b/decidim-core/lib/decidim/moderation_tools.rb index 8c56860cd7bb..adc31cadfd95 100644 --- a/decidim-core/lib/decidim/moderation_tools.rb +++ b/decidim-core/lib/decidim/moderation_tools.rb @@ -28,12 +28,16 @@ def update_report_count! # Public: fetches the participatory space of the resource's component or from the resource itself def participatory_space - @participatory_space ||= @reportable.component&.participatory_space || @reportable.try(:participatory_space) + @participatory_space ||= if reportable.class.respond_to?(:participatory_space?) + reportable + else + reportable.component&.participatory_space || reportable.try(:participatory_space) + end end # Public: updates the reported content for the moderation object associated with resource def update_reported_content! - moderation.update!(reported_content: @reportable.reported_searchable_content_text) + moderation.update!(reported_content: reportable.reported_searchable_content_text) end # Public: creates a new report for the given resource, having a basic set of options diff --git a/decidim-core/spec/commands/decidim/create_user_group_spec.rb b/decidim-core/spec/commands/decidim/create_user_group_spec.rb index 56b50577a997..76df9baecb11 100644 --- a/decidim-core/spec/commands/decidim/create_user_group_spec.rb +++ b/decidim-core/spec/commands/decidim/create_user_group_spec.rb @@ -40,6 +40,8 @@ module Comments end let(:command) { described_class.new(form) } + it_behaves_like "fires an ActiveSupport::Notification event", "decidim.create_user_group:after" + describe "when the form is not valid" do before do allow(form).to receive(:invalid?).and_return(true) diff --git a/decidim-core/spec/commands/decidim/update_account_spec.rb b/decidim-core/spec/commands/decidim/update_account_spec.rb index ebc1caad58fe..23bb7d36852c 100644 --- a/decidim-core/spec/commands/decidim/update_account_spec.rb +++ b/decidim-core/spec/commands/decidim/update_account_spec.rb @@ -51,6 +51,9 @@ module Decidim end context "when valid" do + it_behaves_like "fires an ActiveSupport::Notification event", "decidim.update_account:before" + it_behaves_like "fires an ActiveSupport::Notification event", "decidim.update_account:after" + it "updates the users's name" do form.name = "Pepito de los palotes" expect { command.call }.to broadcast(:ok) diff --git a/decidim-core/spec/commands/decidim/update_user_group_spec.rb b/decidim-core/spec/commands/decidim/update_user_group_spec.rb index 8b0b39734d79..1d01ef8536b7 100644 --- a/decidim-core/spec/commands/decidim/update_user_group_spec.rb +++ b/decidim-core/spec/commands/decidim/update_user_group_spec.rb @@ -41,6 +41,9 @@ module Comments end let(:command) { described_class.new(form, user_group) } + it_behaves_like "fires an ActiveSupport::Notification event", "decidim.update_user_group:before" + it_behaves_like "fires an ActiveSupport::Notification event", "decidim.update_user_group:after" + context "when the form is not valid" do before do allow(form).to receive(:invalid?).and_return(true) diff --git a/decidim-generators/Gemfile b/decidim-generators/Gemfile index e6310f98ec10..0687d5aeacdc 100644 --- a/decidim-generators/Gemfile +++ b/decidim-generators/Gemfile @@ -5,6 +5,7 @@ source "https://rubygems.org" ruby RUBY_VERSION gem "decidim", path: ".." +gem "decidim-ai", path: ".." gem "decidim-conferences", path: ".." gem "decidim-design", path: ".." gem "decidim-initiatives", path: ".." diff --git a/decidim-generators/Gemfile.lock b/decidim-generators/Gemfile.lock index bcbacf163e92..338e4af7bc1e 100644 --- a/decidim-generators/Gemfile.lock +++ b/decidim-generators/Gemfile.lock @@ -30,6 +30,9 @@ PATH devise (~> 4.7) devise-i18n (~> 1.2) devise_invitable (~> 2.0, >= 2.0.9) + decidim-ai (0.30.0.dev) + classifier-reborn (~> 2.3.0) + decidim-core (= 0.30.0.dev) decidim-api (0.30.0.dev) decidim-core (= 0.30.0.dev) graphql (~> 2.2.6) @@ -266,22 +269,22 @@ GEM tzinfo (~> 2.0) acts_as_list (1.1.0) activerecord (>= 4.2) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.8.6) + public_suffix (>= 2.0.2, < 6.0) ast (2.4.2) base64 (0.2.0) batch-loader (1.5.0) bcrypt (3.1.20) - better_html (2.1.1) + better_html (2.0.2) actionview (>= 6.0) activesupport (>= 6.0) ast (~> 2.0) erubi (~> 1.4) parser (>= 2.4) smart_properties - bigdecimal (3.1.8) + bigdecimal (3.1.6) bindex (0.8.1) - bootsnap (1.10.3) + bootsnap (1.18.3) msgpack (~> 1.2) brakeman (6.1.2) racc @@ -311,11 +314,14 @@ GEM cells-rails (0.1.5) actionpack (>= 5.0) cells (>= 4.1.6, < 5.0.0) - charlock_holmes (0.7.9) + charlock_holmes (0.7.7) + classifier-reborn (2.3.0) + fast-stemmer (~> 1.0) + matrix (~> 0.4) cmdparse (3.0.7) commonmarker (0.23.10) concurrent-ruby (1.3.4) - crack (0.4.6) + crack (1.0.0) bigdecimal rexml crass (1.0.6) @@ -347,7 +353,7 @@ GEM nokogiri (>= 1.13.2, < 1.17.0) rubyzip (~> 2.3.0) docile (1.4.0) - doorkeeper (5.6.8) + doorkeeper (5.6.9) railties (>= 5) doorkeeper-i18n (4.0.1) erb_lint (0.6.0) @@ -360,7 +366,7 @@ GEM erbse (0.1.4) temple erubi (1.13.0) - escape_utils (1.2.2) + escape_utils (1.3.0) excon (0.109.0) extended-markdown-filter (0.7.0) html-pipeline (~> 2.9) @@ -371,12 +377,14 @@ GEM railties (>= 5.0.0) faker (3.2.3) i18n (>= 1.8.11, < 2) - faraday (2.10.0) - faraday-net_http (>= 2.0, < 3.2) + faraday (2.12.0) + faraday-net_http (>= 2.0, < 3.4) + json logger - faraday-net_http (3.1.0) + faraday-net_http (3.3.0) net-http - ffi (1.17.0) + fast-stemmer (1.0.2) + ffi (1.16.3) file_validators (3.0.0) activemodel (>= 3.2) mime-types (>= 1.0) @@ -392,10 +400,10 @@ GEM geocoder (1.8.3) base64 (>= 0.1.0) csv (>= 3.0.0) - geom2d (0.3.1) + geom2d (0.4.1) globalid (1.2.1) activesupport (>= 6.1) - graphql (2.2.7) + graphql (2.2.9) graphql-docs (4.0.0) commonmarker (~> 0.23, >= 0.23.6) dartsass (~> 1.49) @@ -410,17 +418,18 @@ GEM cmdparse (~> 3.0, >= 3.0.3) geom2d (~> 0.3) openssl (>= 2.2.1) - highline (3.1.0) + highline (3.1.1) reline html-pipeline (2.14.3) activesupport (>= 2) nokogiri (>= 1.4) htmlentities (4.3.4) - i18n (1.14.5) + i18n (1.14.6) concurrent-ruby (~> 1.0) - i18n-tasks (1.0.14) + i18n-tasks (1.0.13) activesupport (>= 4.0.2) ast (>= 2.1.0) + better_html (>= 1.0, < 3.0) erubi highline (>= 2.0.0) i18n @@ -428,8 +437,9 @@ GEM rails-i18n rainbow (>= 2.2.2, < 4.0) terminal-table (>= 1.5.1) - icalendar (2.10.2) + icalendar (2.10.3) ice_cube (~> 0.16) + ostruct ice_cube (0.17.0) image_processing (1.12.2) mini_magick (>= 4.9.5, < 5) @@ -438,7 +448,7 @@ GEM rails (>= 3.2.0) io-console (0.7.2) json (2.7.2) - jwt (2.8.2) + jwt (2.9.3) base64 kaminari (1.2.2) activesupport (>= 4.1.0) @@ -455,17 +465,17 @@ GEM language_server-protocol (3.17.0.3) launchy (2.5.2) addressable (~> 2.8) - letter_opener (1.8.1) + letter_opener (1.9.0) launchy (>= 2.2, < 3) letter_opener_web (2.0.0) actionmailer (>= 5.2) letter_opener (~> 1.7) railties (>= 5.2) rexml - listen (3.7.1) + listen (3.8.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - logger (1.6.0) + logger (1.6.1) loofah (2.22.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) @@ -479,12 +489,11 @@ GEM method_source (1.1.0) mime-types (3.5.2) mime-types-data (~> 3.2015) - mime-types-data (3.2023.1205) + mime-types-data (3.2024.0206) mini_magick (4.12.0) mini_mime (1.1.5) - mini_portile2 (2.8.7) minitest (5.25.1) - msgpack (1.4.5) + msgpack (1.7.2) multi_xml (0.7.1) bigdecimal (~> 3.1) net-http (0.4.1) @@ -500,8 +509,7 @@ GEM net-smtp (0.3.4) net-protocol nio4r (2.7.3) - nokogiri (1.16.7) - mini_portile2 (~> 2.8.2) + nokogiri (1.16.7-x86_64-linux) racc (~> 1.4) oauth (1.1.0) oauth-tty (~> 1.0, >= 1.0.1) @@ -522,8 +530,8 @@ GEM rack-protection omniauth-facebook (5.0.0) omniauth-oauth2 (~> 1.2) - omniauth-google-oauth2 (1.1.2) - jwt (>= 2.0) + omniauth-google-oauth2 (1.2.0) + jwt (>= 2.9) oauth2 (~> 2.0) omniauth (~> 2.0) omniauth-oauth2 (~> 1.8) @@ -541,11 +549,12 @@ GEM rack openssl (3.2.0) orm_adapter (0.5.0) + ostruct (0.6.0) paper_trail (15.1.0) activerecord (>= 6.1) request_store (~> 1.4) parallel (1.26.3) - parallel_tests (4.4.0) + parallel_tests (4.5.1) parallel parser (3.3.4.2) ast (~> 2.4.1) @@ -562,7 +571,7 @@ GEM actionmailer (>= 3) net-smtp premailer (~> 1.7, >= 1.7.9) - public_suffix (6.0.1) + public_suffix (5.1.1) puma (6.4.2) nio4r (~> 2.0) racc (1.8.1) @@ -615,7 +624,7 @@ GEM zeitwerk (~> 2.5) rainbow (3.1.1) rake (13.2.1) - ransack (4.2.0) + ransack (4.2.1) activerecord (>= 6.1.5) activesupport (>= 6.1.5) i18n @@ -625,15 +634,14 @@ GEM redcarpet (3.6.0) redis (4.8.1) regexp_parser (2.9.2) - reline (0.5.9) + reline (0.5.10) io-console (~> 0.5) request_store (1.5.1) rack (>= 1.4) responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.3.6) - strscan + rexml (3.3.8) rspec (3.13.0) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) @@ -643,29 +651,29 @@ GEM rspec-rails (>= 3.0.0, < 6.2.0) rspec-core (3.13.1) rspec-support (~> 3.13.0) - rspec-expectations (3.13.3) + rspec-expectations (3.13.0) 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.1) + rspec-mocks (3.13.0) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (6.1.3) + rspec-rails (6.1.1) actionpack (>= 6.1) activesupport (>= 6.1) railties (>= 6.1) - rspec-core (~> 3.13) - rspec-expectations (~> 3.13) - rspec-mocks (~> 3.13) - rspec-support (~> 3.13) + rspec-core (~> 3.12) + rspec-expectations (~> 3.12) + rspec-mocks (~> 3.12) + rspec-support (~> 3.12) rspec-retry (0.6.2) rspec-core (> 3.3) rspec-support (3.13.1) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) - rubocop (1.65.1) + rubocop (1.65.0) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) @@ -676,7 +684,7 @@ GEM rubocop-ast (>= 1.31.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.32.1) + rubocop-ast (1.32.0) parser (>= 3.3.1.0) rubocop-capybara (2.21.0) rubocop (~> 1.41) @@ -701,10 +709,9 @@ GEM rubocop-rubycw (0.1.6) rubocop (~> 1.0) ruby-progressbar (1.13.0) - ruby-vips (2.2.2) + ruby-vips (2.2.0) ffi (~> 1.12) - logger - rubyXL (3.4.27) + rubyXL (3.4.25) nokogiri (>= 1.10.8) rubyzip (>= 1.3.0) rubyzip (2.3.2) @@ -740,7 +747,7 @@ GEM temple (0.10.3) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) - thor (1.3.1) + thor (1.3.2) tilt (2.3.0) timeout (0.4.1) tzinfo (2.0.6) @@ -748,7 +755,7 @@ GEM uber (0.1.0) unicode-display_width (2.5.0) uniform_notifier (1.16.0) - uri (0.13.0) + uri (0.13.1) valid_email2 (4.0.6) activemodel (>= 3.2) mail (~> 2.5) @@ -771,11 +778,11 @@ GEM web-push (3.0.1) jwt (~> 2.0) openssl (~> 3.0) - webmock (3.19.1) + webmock (3.20.0) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - websocket (1.2.11) + websocket (1.2.10) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -786,16 +793,17 @@ GEM wkhtmltopdf-binary (0.12.6.6) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.6.16) + zeitwerk (2.6.18) PLATFORMS - ruby + x86_64-linux DEPENDENCIES bootsnap (~> 1.3) brakeman (~> 6.1) byebug (~> 11.0) decidim! + decidim-ai! decidim-conferences! decidim-design! decidim-dev! @@ -814,4 +822,4 @@ RUBY VERSION ruby 3.3.4p94 BUNDLED WITH - 2.4.6 + 2.4.20 diff --git a/decidim-generators/lib/decidim/generators/app_generator.rb b/decidim-generators/lib/decidim/generators/app_generator.rb index 44ab1f1d45f5..f4c18d810495 100644 --- a/decidim-generators/lib/decidim/generators/app_generator.rb +++ b/decidim-generators/lib/decidim/generators/app_generator.rb @@ -181,7 +181,7 @@ def gemfile gsub_file "Gemfile", /gem "decidim-dev".*/, "gem \"decidim-dev\", #{gem_modifier}" - %w(conferences design initiatives templates).each do |component| + %w(ai conferences design initiatives templates).each do |component| if options[:demo] gsub_file "Gemfile", /gem "decidim-#{component}".*/, "gem \"decidim-#{component}\", #{gem_modifier}" else @@ -384,6 +384,12 @@ def budgets_workflows copy_file "budgets_initializer.rb", "config/initializers/decidim_budgets.rb" end + def ai_toolkit + return unless options[:demo] + + copy_file "ai_initializer.rb", "config/initializers/decidim_ai.rb" + end + def timestamp_service return unless options[:demo] diff --git a/decidim-generators/lib/decidim/generators/app_templates/ai_initializer.rb b/decidim-generators/lib/decidim/generators/app_templates/ai_initializer.rb new file mode 100644 index 000000000000..c4be25f00832 --- /dev/null +++ b/decidim-generators/lib/decidim/generators/app_templates/ai_initializer.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +if Decidim.module_installed?(:ai) + Decidim::Ai::Language.formatter = "Decidim::Ai::Language::Formatter" + + Decidim::Ai::SpamDetection.reporting_user_email = "your-admin@example.org" + + Decidim::Ai::SpamDetection.resource_score_threshold = 0.75 # default + + # The entry must be a hash with the following keys: + # - name: the name of the analyzer + # - strategy: the class of the strategy to use + # - options: a hash with the options to pass to the strategy + # Example: + # Decidim::Ai::SpamDetection.resource_analyzers = [ + # { + # name: :bayes, + # strategy: Decidim::Ai::SpamContent::BayesStrategy, + # options: { + # adapter: :redis, + # params: { + # url: lambda { ENV["REDIS_URL"] } + # scheme: "redis" + # host: "127.0.0.1" + # port: 6379 + # path: nil + # timeout: 5.0 + # password: nil + # db: 0 + # driver: nil + # id: nil + # tcp_keepalive: 0 + # reconnect_attempts: 1 + # inherit_socket: false + # } + # } + # } + # ] + Decidim::Ai::SpamDetection.resource_analyzers = [ + { + name: :bayes, + strategy: Decidim::Ai::SpamDetection::Strategy::Bayes, + options: { + adapter: ENV.fetch("DECIDIM_SPAM_DETECTION_BACKEND_RESOURCE", "redis"), + params: { url: ENV.fetch("DECIDIM_SPAM_DETECTION_BACKEND_RESOURCE_REDIS_URL", "redis://localhost:6379/2") } + } + } + ] + + # If you want to use a different spam detection service, you can define your own service. + # Refer to documentation for more details. + # + Decidim::Ai::SpamDetection.resource_detection_service = "Decidim::Ai::SpamDetection::Service" + + # Customize here what are the analyzed models. You may want to use this to + # override what we register by default, or to register your own resources. + # Follow the documentation on how to trail more resources + 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") + models["Decidim::Proposals::CollaborativeDraft"] = "Decidim::Ai::SpamDetection::Resource::CollaborativeDraft" if Decidim.module_installed?("proposals") + models + end + + Decidim::Ai::SpamDetection.user_score_threshold = 0.75 # default + + # The entry must be a hash with the following keys: + # - name: the name of the analyzer + # - strategy: the class of the strategy to use + # - options: a hash with the options to pass to the strategy + # Example: + # Decidim::Ai::SpamDetection.user_analyzers = [ + # { + # name: :bayes, + # strategy: Decidim::Ai::SpamContent::BayesStrategy, + # options: { + # adapter: :redis, + # params: { + # url: lambda { ENV["REDIS_URL"] } + # } + # } + # } + # ] + Decidim::Ai::SpamDetection.user_analyzers = [ + { + name: :bayes, + strategy: Decidim::Ai::SpamDetection::Strategy::Bayes, + options: { + adapter: ENV.fetch("DECIDIM_SPAM_DETECTION_BACKEND_USER", "redis"), + params: { url: ENV.fetch("DECIDIM_SPAM_DETECTION_BACKEND_USER_REDIS_URL", "redis://localhost:6379/3") } + } + } + ] + + # Customize here what are the analyzed models. You may want to use this to + # override what we register by default, or to register your own resources. + # Follow the documentation on how to trail more resources + Decidim::Ai::SpamDetection.user_models = { + "Decidim::UserGroup" => "Decidim::Ai::SpamDetection::Resource::UserBaseEntity", + "Decidim::User" => "Decidim::Ai::SpamDetection::Resource::UserBaseEntity" + } + + # If you want to use a different spam detection service, you can define your own service. + # Refer to documentation for more details. + # + Decidim::Ai::SpamDetection.user_detection_service = "Decidim::Ai::SpamDetection::Service" +end diff --git a/decidim-generators/lib/decidim/generators/app_templates/sidekiq.yml.erb b/decidim-generators/lib/decidim/generators/app_templates/sidekiq.yml.erb index 77767f1330ba..c9dc42bd81bf 100644 --- a/decidim-generators/lib/decidim/generators/app_templates/sidekiq.yml.erb +++ b/decidim-generators/lib/decidim/generators/app_templates/sidekiq.yml.erb @@ -14,3 +14,4 @@ - [metrics, 1] - [exports, 1] - [close_meeting_reminder, 1] + - [spam_analysis, 1] diff --git a/decidim-initiatives/app/commands/decidim/initiatives/create_initiative.rb b/decidim-initiatives/app/commands/decidim/initiatives/create_initiative.rb index 306624d25005..85139b5186b8 100644 --- a/decidim-initiatives/app/commands/decidim/initiatives/create_initiative.rb +++ b/decidim-initiatives/app/commands/decidim/initiatives/create_initiative.rb @@ -46,16 +46,28 @@ def call end end + protected + + def event_arguments + { + resource: initiative, + extra: { + event_author: form.current_user, + locale: + } + } + end + private - attr_reader :form, :attachment + attr_reader :form, :attachment, :initiative # Creates the initiative and all default components def create_initiative - initiative = build_initiative + build_initiative return initiative unless initiative.valid? - initiative.transaction do + with_events(with_transaction: true) do initiative.save! @attached_to = initiative @@ -72,7 +84,7 @@ def create_initiative end def build_initiative - Initiative.new( + @initiative = Initiative.new( organization: form.current_organization, title: { current_locale => form.title }, description: { current_locale => form.description }, diff --git a/decidim-initiatives/app/commands/decidim/initiatives/update_initiative.rb b/decidim-initiatives/app/commands/decidim/initiatives/update_initiative.rb index d1ce888a06ae..801ed5f08e7e 100644 --- a/decidim-initiatives/app/commands/decidim/initiatives/update_initiative.rb +++ b/decidim-initiatives/app/commands/decidim/initiatives/update_initiative.rb @@ -39,22 +39,36 @@ def call return broadcast(:invalid) if gallery_invalid? end - @initiative = Decidim.traceability.update!( - initiative, - current_user, - attributes - ) + with_events(with_transaction: true) do + @initiative = Decidim.traceability.update!( + initiative, + current_user, + attributes + ) - photo_cleanup! - document_cleanup! - create_attachments if process_attachments? - create_gallery if process_gallery? + photo_cleanup! + document_cleanup! + create_attachments if process_attachments? + create_gallery if process_gallery? + end broadcast(:ok, initiative) rescue ActiveRecord::RecordInvalid broadcast(:invalid, initiative) end + protected + + def event_arguments + { + resource: initiative, + extra: { + event_author: form.current_user, + locale: + } + } + end + private attr_reader :form, :initiative diff --git a/decidim-initiatives/app/models/decidim/initiative.rb b/decidim-initiatives/app/models/decidim/initiative.rb index da3b246e607c..579a3adf73d9 100644 --- a/decidim-initiatives/app/models/decidim/initiative.rb +++ b/decidim-initiatives/app/models/decidim/initiative.rb @@ -24,6 +24,7 @@ class Initiative < ApplicationRecord include Decidim::HasResourcePermission include Decidim::HasArea include Decidim::FilterableResource + include Decidim::Reportable include Decidim::ShareableWithToken translatable_fields :title, :description, :answer @@ -205,6 +206,16 @@ def author_name user_group&.name || author.name end + # Public: Overrides the `reported_content_url` Reportable concern method. + def reported_content_url + ResourceLocatorPresenter.new(self).url + end + + # Public: Overrides the `reported_attributes` Reportable concern method. + def reported_attributes + [:title, :description] + end + def votes_enabled? published? && signature_start_date <= Date.current && diff --git a/decidim-initiatives/spec/commands/decidim/initiatives/create_initiative_spec.rb b/decidim-initiatives/spec/commands/decidim/initiatives/create_initiative_spec.rb index 2c4a27438fcd..3725c0ef401a 100644 --- a/decidim-initiatives/spec/commands/decidim/initiatives/create_initiative_spec.rb +++ b/decidim-initiatives/spec/commands/decidim/initiatives/create_initiative_spec.rb @@ -37,6 +37,13 @@ module Initiatives described_class.new(form) end + it_behaves_like "fires an ActiveSupport::Notification event", "decidim.initiatives.create_initiative:before" do + let(:command) { subject } + end + it_behaves_like "fires an ActiveSupport::Notification event", "decidim.initiatives.create_initiative:after" do + let(:command) { subject } + end + let(:area) { create(:area, organization:) } let(:scoped_type) { create(:initiatives_type_scope) } let(:organization) { scoped_type.type.organization } diff --git a/decidim-initiatives/spec/commands/decidim/initiatives/update_initiative_spec.rb b/decidim-initiatives/spec/commands/decidim/initiatives/update_initiative_spec.rb index 0f622b3121a8..8ac0652ee5e3 100644 --- a/decidim-initiatives/spec/commands/decidim/initiatives/update_initiative_spec.rb +++ b/decidim-initiatives/spec/commands/decidim/initiatives/update_initiative_spec.rb @@ -59,6 +59,9 @@ module Initiatives end describe "when the form is valid" do + it_behaves_like "fires an ActiveSupport::Notification event", "decidim.initiatives.update_initiative:before" + it_behaves_like "fires an ActiveSupport::Notification event", "decidim.initiatives.update_initiative:after" + it "broadcasts ok" do expect { command.call }.to broadcast(:ok) end diff --git a/decidim-meetings/spec/commands/create_meeting_spec.rb b/decidim-meetings/spec/commands/create_meeting_spec.rb index dcd375e931d1..abf3cda80cb1 100644 --- a/decidim-meetings/spec/commands/create_meeting_spec.rb +++ b/decidim-meetings/spec/commands/create_meeting_spec.rb @@ -164,21 +164,21 @@ module Decidim::Meetings end it "schedules a upcoming meeting notification job 48h before start time" do - meeting = instance_double(Meeting, id: 1, start_time:, participatory_space: participatory_process, author: current_user, persisted?: true) + meeting = create(:meeting, start_time:, component: current_component, author: current_user) allow(Decidim.traceability) .to receive(:create!) .and_return(meeting) expect(meeting).to receive(:valid?) expect(meeting).to receive(:publish!) - allow(meeting).to receive(:to_signed_global_id).and_return "gid://Decidim::Meetings::Meeting/1" + allow(meeting).to receive(:to_signed_global_id).and_return "gid://Decidim::Meetings::Meeting/#{meeting.id}" allow(UpcomingMeetingNotificationJob) .to receive(:generate_checksum).and_return "1234" expect(UpcomingMeetingNotificationJob) .to receive_message_chain(:set, :perform_later) # rubocop:disable RSpec/MessageChain - .with(set: start_time - Decidim::Meetings.upcoming_meeting_notification).with(1, "1234") + .with(set: start_time - Decidim::Meetings.upcoming_meeting_notification).with(meeting.id, "1234") allow(Decidim::EventsManager).to receive(:publish).and_return(true) @@ -186,14 +186,14 @@ module Decidim::Meetings end it "does not schedule an upcoming meeting notification if start time is in the past" do - meeting = instance_double(Meeting, id: 1, start_time: 2.days.ago, participatory_space: participatory_process, author: current_user, persisted?: true) + meeting = create(:meeting, start_time: 2.days.ago, component: current_component, author: current_user) allow(Decidim.traceability) .to receive(:create!) .and_return(meeting) expect(meeting).to receive(:valid?) expect(meeting).to receive(:publish!) - allow(meeting).to receive(:to_signed_global_id).and_return "gid://Decidim::Meetings::Meeting/1" + allow(meeting).to receive(:to_signed_global_id).and_return "gid://Decidim::Meetings::Meeting/#{meeting.id}" expect(UpcomingMeetingNotificationJob).not_to receive(:generate_checksum) expect(UpcomingMeetingNotificationJob).not_to receive(:set) diff --git a/docs/modules/configure/pages/environment_variables.adoc b/docs/modules/configure/pages/environment_variables.adoc index 0ba0a59c7773..73f5751db456 100644 --- a/docs/modules/configure/pages/environment_variables.adoc +++ b/docs/modules/configure/pages/environment_variables.adoc @@ -628,6 +628,25 @@ Additional context: This has been revealed as an issue during a security audit o |false |No +|*DECIDIM_SPAM_DETECTION_BACKEND_USER* +| The adapter type needed for user classifier. (for CI purposes, you can set "memory") +| redis +| No + +|*DECIDIM_SPAM_DETECTION_BACKEND_USER_REDIS_URL* +| The redis connection url used by the user classifier +| redis://localhost:6379/3 +| No + +|*DECIDIM_SPAM_DETECTION_BACKEND_RESOURCE* +| The adapter type needed for resource classifier. (for CI purposes, you can set "memory") +| redis +| No + +|*DECIDIM_SPAM_DETECTION_BACKEND_RESOURCE_REDIS_URL* +| The redis connection url used by the resource classifier +| redis://localhost:6379/2 +| No |=== diff --git a/docs/modules/develop/pages/ai_tools.adoc b/docs/modules/develop/pages/ai_tools.adoc new file mode 100644 index 000000000000..a655feecbaa9 --- /dev/null +++ b/docs/modules/develop/pages/ai_tools.adoc @@ -0,0 +1,11 @@ += AI tools for decidim + +== Configuration reference +* xref:services:aitools.adoc[AI Tools configuration] + +== Class Specific reference + +* xref:develop:ai_tools/lang_detection_formatter.adoc[Language Detection Formatter] +* xref:develop:ai_tools/spam_detection_strategy.adoc[Spam detection Strategy] +* xref:develop:ai_tools/spam_detection_service.adoc[Spam detection Service] +* xref:develop:ai_tools/spam_detection_analyzer.adoc[Spam detection Analyzer] diff --git a/docs/modules/develop/pages/ai_tools/lang_detection_formatter.adoc b/docs/modules/develop/pages/ai_tools/lang_detection_formatter.adoc new file mode 100644 index 000000000000..14384dbb30f2 --- /dev/null +++ b/docs/modules/develop/pages/ai_tools/lang_detection_formatter.adoc @@ -0,0 +1,9 @@ += Language Detection Formatter + +```ruby +class Formatter + def cleanup(text) + strip_tags(text) + end +end +``` diff --git a/docs/modules/develop/pages/ai_tools/spam_detection_analyzer.adoc b/docs/modules/develop/pages/ai_tools/spam_detection_analyzer.adoc new file mode 100644 index 000000000000..c922b76da8ce --- /dev/null +++ b/docs/modules/develop/pages/ai_tools/spam_detection_analyzer.adoc @@ -0,0 +1,20 @@ += Spam detection Analyzer + +```ruby +class ResourceAnalyzer < Decidim::Ai::SpamDetection::Resource::Base + def fields + [:title, :body, :etc] + end + + protected + + def query + Your::Model.including() + end + + def resource_hidden?(resource); end + def batch_train; end + def train; end # delegated to classifier + def untrain; end # delegated to classifier +end +``` diff --git a/docs/modules/develop/pages/ai_tools/spam_detection_service.adoc b/docs/modules/develop/pages/ai_tools/spam_detection_service.adoc new file mode 100644 index 000000000000..f79f159851bc --- /dev/null +++ b/docs/modules/develop/pages/ai_tools/spam_detection_service.adoc @@ -0,0 +1,25 @@ += Spam detection Service + +```ruby +class SpamDetection::Service + def initialize + @registry = Decidim::Ai.spam_detection_registry + end + + def train(category, text) + # train the strategy + end + + def classify(text) + # classify the text + end + + def untrain(category, text) + # untrain the strategy + end + + def classification_log + # return the classification log + end +end +``` diff --git a/docs/modules/develop/pages/ai_tools/spam_detection_strategy.adoc b/docs/modules/develop/pages/ai_tools/spam_detection_strategy.adoc new file mode 100644 index 000000000000..dedad69c6255 --- /dev/null +++ b/docs/modules/develop/pages/ai_tools/spam_detection_strategy.adoc @@ -0,0 +1,43 @@ += Spam detection Strategy + +```ruby +module SpamDetection::Strategy + class Bayes < Base + def initialize(options = {}) + # Add here your configuration, assign your variables + end + + def log + return unless category + + "The Classification engine marked this as #{category}" + end + + def train(category, text) + # some call to the original backend + end + + # Calling this method without any trained categories will throw an error + def untrain(category, content) + # some call to the original backend + end + + def reset + # some call that actually resets the backend data + end + + def classify(content) + @category, @internal_score = backend.classify_with_score(content) + category + end + + def score + category.presence == "spam" ? 1 : 0 + end + + private + + # your implementation + end +end +``` diff --git a/docs/modules/services/pages/aitools.adoc b/docs/modules/services/pages/aitools.adoc new file mode 100644 index 000000000000..a6968aa4c4f3 --- /dev/null +++ b/docs/modules/services/pages/aitools.adoc @@ -0,0 +1,164 @@ += AI tools + +In order to help the moderator communities to manage better their Decidim installations, we have shipped the first version of the AI tools. This is a set of tools that will help the moderators to detect and manage the content that is being published. + +== Functionalities + +=== Spam detection + +This service allows you to install and configure a spam detection service so that any suspicious content get reported at the very early possible step. + +This service can be trained either using the datasets that we provide, or using your own content. This way the engine learns your own business domain and it is able to detect spam or things that are not relevant for your activity. + +== Installation + +In order to install the module, you need to run the following command: + +```bash +bundle add decidim-ai +``` + +== Configuration + +The AI tool allows you to configure most of the parameters using either defaults, or the initializer. +In order to get control of your AI installation, you may need to create an initializer in your application. + +You can do it by creating a file in `config/initializers/decidim_ai.rb` with the following content: + +```ruby +Decidim::Ai::SpamDetection.resource_score_threshold = 0.75 # default +# The entry must be a hash with the following keys: +# - name: the name of the analyzer +# - strategy: the class of the strategy to use +# - options: a hash with the options to pass to the strategy +# Example: +# Decidim::Ai.registered_analyzers = [ +# { +# name: :bayes, +# strategy: Decidim::Ai::SpamContent::BayesStrategy, +# options: { +# adapter: :redis, +# params: { +# url: lambda { ENV["REDIS_URL"] } +# } +# } +# } +# ] +Decidim::Ai::SpamDetection.resource_analyzers = [ + { + name: :bayes, + strategy: Decidim::Ai::SpamDetection::Strategy::Bayes, + options: { + adapter: ENV.fetch("DECIDIM_SPAM_DETECTION_BACKEND_RESOURCE", "redis"), + params: { url: ENV.fetch("DECIDIM_SPAM_DETECTION_BACKEND_RESOURCE_REDIS_URL", "redis://localhost:6379/2") } + } + } +] + +Decidim::Ai::SpamDetection.reporting_user_email = "your-admin@example.org" + +# If you want to use a different spam detection service, +# you can use a class service having the following contract +# +Decidim::Ai::SpamDetection.resource_detection_service = "Decidim::Ai::SpamDetection::Service" + +# Customize here what are the analyzed models. You may want to use this to +# override what we register by default, or to register your own resources. + +Decidim::Ai::SpamDetection.resource_models = { + "Decidim::Comments::Comment" => "Decidim::Ai::SpamDetection::Resource::Comment", + "Decidim::Initiative" => "Decidim::Ai::SpamDetection::Resource::Initiative", + "Decidim::Debates::Debate" => "Decidim::Ai::SpamDetection::Resource::Debate", + "Decidim::Meetings::Meeting" => "Decidim::Ai::SpamDetection::Resource::Meeting", + "Decidim::Proposals::Proposal" => "Decidim::Ai::SpamDetection::Resource::Proposal", + "Decidim::Proposals::CollaborativeDraft" => "Decidim::Ai::SpamDetection::Resource::CollaborativeDraft", + "Decidim::UserGroup" => "Decidim::Ai::SpamDetection::Resource::UserBaseEntity", + "Decidim::User" => "Decidim::Ai::SpamDetection::Resource::UserBaseEntity" +} + +Decidim::Ai::SpamDetection.user_score_threshold = 0.75 # default + +# The entry must be a hash with the following keys: +# - name: the name of the analyzer +# - strategy: the class of the strategy to use +# - options: a hash with the options to pass to the strategy +# Example: +# Decidim::Ai::SpamDetection.user_analyzers = [ +# { +# name: :bayes, +# strategy: Decidim::Ai::SpamContent::BayesStrategy, +# options: { +# adapter: :redis, +# params: { +# url: lambda { ENV["REDIS_URL"] } +# } +# } +# } +# ] +Decidim::Ai::SpamDetection.user_analyzers = [ + { + name: :bayes, + strategy: Decidim::Ai::SpamDetection::Strategy::Bayes, + options: { + adapter: ENV.fetch("DECIDIM_SPAM_DETECTION_BACKEND_USER", "redis"), + params: { url: ENV.fetch("DECIDIM_SPAM_DETECTION_BACKEND_USER_REDIS_URL", "redis://localhost:6379/3") } + } + } +] + +# Customize here what are the analyzed models. You may want to use this to +# override what we register by default, or to register your own resources. +# Follow the documentation on how to trail more resources +Decidim::Ai::SpamDetection.user_models = { + "Decidim::UserGroup" => "Decidim::Ai::SpamDetection::Resource::UserBaseEntity", + "Decidim::User" => "Decidim::Ai::SpamDetection::Resource::UserBaseEntity" +} + +# If you want to use a different spam detection service, you can define your own service. +# Refer to documentation for more details. +# +Decidim::Ai::SpamDetection.user_detection_service = "Decidim::Ai::SpamDetection::Service" + +``` + +== Commands + +Decidim Ai provides a set of commands that you can use to manage the engine. + +=== Create reporting user + +In order to preserve the database integrity, you need to configure a system user that could be used to report content in the application. Use the following command to create an user for each one of the organizations you may have. The email address defined by `Decidim::Ai::SpamDetection.reporting_user_email` will be used to find or create the user. + +```bash +bin/rails decidim:ai:spam:create_reporting_user +``` + +=== Load custom model + +In some cases, when you manage multiple installations, you may want to share the same model between them. You can use the following command to load a simple CSV. + +```bash +bin/rails decidim:ai:spam:load_application_dataset[/path/to/file.csv] +``` + +=== Load the data from your server + +In some cases, like an upgrade, you may want to train your model using your existing data, so you can use: + +```bash +bin/rails decidim:ai:spam:train_using_database +``` + +=== Reset the model + +If the trained model becomes corrupt, you could use the below command to reinitialize the model. Once you do this, you would need to train the model again. using any of the above commands. + +```bash +bin/rails decidim:ai:spam:reset_model +``` + +== Sidekiq + +Decidim Ai comes with a new queue that is aimed to be ran to analyze the content of the platform. We have decided to have it in a separate queue to avoid blocking other events that your sidekiq may use. + +We start to provide the `spam_analysis` queue name. diff --git a/docs/modules/services/pages/index.adoc b/docs/modules/services/pages/index.adoc index 60993c0724e8..f52dc2264ac3 100644 --- a/docs/modules/services/pages/index.adoc +++ b/docs/modules/services/pages/index.adoc @@ -4,6 +4,7 @@ There are multiple services that can be enabled in a Decidim installation. It is * xref:services:activejob.adoc[Active Job] * xref:services:activestorage.adoc[Active Storage] +* xref:services:aitools.adoc[AI tools] * xref:services:etherpad.adoc[Etherpad] * xref:services:maps.adoc[Maps] * xref:services:sms.adoc[SMS]