decidim-ai 0.30.0.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +47 -0
  3. data/Rakefile +3 -0
  4. data/app/jobs/decidim/ai/application_job.rb +8 -0
  5. data/app/jobs/decidim/ai/spam_detection/application_job.rb +17 -0
  6. data/app/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job.rb +37 -0
  7. data/app/jobs/decidim/ai/spam_detection/user_spam_analyzer_job.rb +25 -0
  8. data/lib/decidim/ai/engine.rb +104 -0
  9. data/lib/decidim/ai/language/formatter.rb +17 -0
  10. data/lib/decidim/ai/language/language.rb +23 -0
  11. data/lib/decidim/ai/spam_detection/importer/database.rb +17 -0
  12. data/lib/decidim/ai/spam_detection/importer/file.rb +23 -0
  13. data/lib/decidim/ai/spam_detection/resource/base.rb +53 -0
  14. data/lib/decidim/ai/spam_detection/resource/collaborative_draft.rb +17 -0
  15. data/lib/decidim/ai/spam_detection/resource/comment.rb +17 -0
  16. data/lib/decidim/ai/spam_detection/resource/debate.rb +17 -0
  17. data/lib/decidim/ai/spam_detection/resource/initiative.rb +17 -0
  18. data/lib/decidim/ai/spam_detection/resource/meeting.rb +17 -0
  19. data/lib/decidim/ai/spam_detection/resource/proposal.rb +17 -0
  20. data/lib/decidim/ai/spam_detection/resource/user_base_entity.rb +35 -0
  21. data/lib/decidim/ai/spam_detection/service.rb +66 -0
  22. data/lib/decidim/ai/spam_detection/spam_detection.rb +203 -0
  23. data/lib/decidim/ai/spam_detection/strategy/base.rb +28 -0
  24. data/lib/decidim/ai/spam_detection/strategy/bayes.rb +74 -0
  25. data/lib/decidim/ai/strategy_registry.rb +32 -0
  26. data/lib/decidim/ai/test/factories.rb +1 -0
  27. data/lib/decidim/ai/version.rb +9 -0
  28. data/lib/decidim/ai.rb +13 -0
  29. data/lib/tasks/decidim_ai.rake +35 -0
  30. metadata +160 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3dfbdf71329ed49a7ff135c96047b20b05dad7746bb61885c823f7b759cec3db
4
+ data.tar.gz: 28742be322499ab899e685dc990d5ef0aa8fdc9e8e6c4fc878f22b976c1e1245
5
+ SHA512:
6
+ metadata.gz: f112e89075cb0bb1ee98f4df949ea4138a11dce85a12d8c7cb804ef116eb9f17117693fe467d05cc78990223826341297d9c0ca895c1b5fb9b66069521ee8963
7
+ data.tar.gz: 852350e6ebec0220b63829da254d9187c5ced7639d57fffa14df30cf2776ad11ba0a76b668fc4a7656f27497ec2ae57aa3d7fc463725fdb5cc705fbcf159b8dc
data/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # Decidim::Ai
2
+
3
+ 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.
4
+ All AI related functionality provided by Decidim should be included in this same module.
5
+
6
+ For more documentation on the AI tools API, please refer to [documentation](https://docs.decidim.org/en/develop/develop/ai_tools.html)
7
+
8
+ ## Installation
9
+
10
+ In order to install use this module, you need at least Decidim 0.30 to be installed.
11
+
12
+ To install this module, run in your console:
13
+
14
+ ```bash
15
+ bundle add decidim-ai
16
+ ```
17
+
18
+ After that, add an initializer file as presented in the [documentation](https://docs.decidim.org/en/develop/services/aitools.html#_configuration)
19
+
20
+ Then, you need to run the below command, so that the reporting user is created.
21
+
22
+ ```ruby
23
+ bin/rails decidim:ai:spam:create_reporting_user
24
+ ```
25
+
26
+ Then you can use the below command to train the engine with the module dataset:
27
+
28
+ ```ruby
29
+ bin/rails decidim:ai:spam:load_module_dataset
30
+ ```
31
+
32
+ Add the queue name to `config/sidekiq.yml` file:
33
+
34
+ ```yaml
35
+ :queues:
36
+ - ["default", 1]
37
+ - ["spam_analysis", 1]
38
+ # The other yaml entries
39
+ ```
40
+
41
+ ## Contributing
42
+
43
+ See [Decidim](https://github.com/decidim/decidim).
44
+
45
+ ## License
46
+
47
+ See [Decidim](https://github.com/decidim/decidim).
data/Rakefile ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "decidim/dev/common_rake"
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Ai
5
+ class ApplicationJob < Decidim::ApplicationJob
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Ai
5
+ module SpamDetection
6
+ class ApplicationJob < Decidim::Ai::ApplicationJob
7
+ queue_as :spam_analysis
8
+
9
+ protected
10
+
11
+ def classifier
12
+ @classifier ||= Decidim::Ai::SpamDetection.resource_classifier
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Ai
5
+ module SpamDetection
6
+ class GenericSpamAnalyzerJob < ApplicationJob
7
+ include Decidim::TranslatableAttributes
8
+
9
+ def perform(reportable, author, locale, fields)
10
+ @author = author
11
+ overall_score = I18n.with_locale(locale) do
12
+ fields.map do |field|
13
+ classifier.classify(translated_attribute(reportable.send(field)))
14
+ classifier.score
15
+ end
16
+ end
17
+
18
+ overall_score = overall_score.inject(0.0, :+) / overall_score.size
19
+
20
+ return unless overall_score >= Decidim::Ai::SpamDetection.resource_score_threshold
21
+
22
+ Decidim::CreateReport.call(form, reportable)
23
+ end
24
+
25
+ private
26
+
27
+ def form
28
+ @form ||= Decidim::ReportForm.new(reason: "spam", details: classifier.classification_log).with_context(current_user: reporting_user)
29
+ end
30
+
31
+ def reporting_user
32
+ @reporting_user ||= Decidim::User.find_by!(email: Decidim::Ai::SpamDetection.reporting_user_email)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Ai
5
+ module SpamDetection
6
+ class UserSpamAnalyzerJob < GenericSpamAnalyzerJob
7
+ def perform(reportable)
8
+ @author = reportable
9
+
10
+ classifier.classify(reportable.about)
11
+
12
+ return unless classifier.score >= Decidim::Ai::SpamDetection.user_score_threshold
13
+
14
+ Decidim::CreateUserReport.call(form, reportable)
15
+ end
16
+
17
+ protected
18
+
19
+ def classifier
20
+ @classifier ||= Decidim::Ai::SpamDetection.user_classifier
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Ai
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace Decidim::Ai
7
+
8
+ paths["db/migrate"] = nil
9
+
10
+ initializer "decidim_ai.resource_classifiers" do |_app|
11
+ Decidim::Ai::SpamDetection.resource_analyzers.each do |analyzer|
12
+ Decidim::Ai::SpamDetection.resource_registry.register_analyzer(**analyzer)
13
+ end
14
+ end
15
+
16
+ initializer "decidim_ai.user_classifiers" do |_app|
17
+ Decidim::Ai::SpamDetection.user_analyzers.each do |analyzer|
18
+ Decidim::Ai::SpamDetection.user_registry.register_analyzer(**analyzer)
19
+ end
20
+ end
21
+
22
+ initializer "decidim_ai.events.subscribe_profile" do
23
+ config.to_prepare do
24
+ Decidim::EventsManager.subscribe("decidim.update_account:after") do |_event_name, data|
25
+ Decidim::Ai::SpamDetection::UserSpamAnalyzerJob.perform_later(data[:resource])
26
+ end
27
+ Decidim::EventsManager.subscribe("decidim.update_user_group:after") do |_event_name, data|
28
+ Decidim::Ai::SpamDetection::UserSpamAnalyzerJob.perform_later(data[:resource])
29
+ end
30
+ Decidim::EventsManager.subscribe("decidim.create_user_group:after") do |_event_name, data|
31
+ Decidim::Ai::SpamDetection::UserSpamAnalyzerJob.perform_later(data[:resource])
32
+ end
33
+ end
34
+ end
35
+
36
+ initializer "decidim_ai.events.subscribe_comments" do
37
+ config.to_prepare do
38
+ ActiveSupport::Notifications.subscribe("decidim.comments.create_comment:after") do |_event_name, data|
39
+ Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body])
40
+ end
41
+ ActiveSupport::Notifications.subscribe("decidim.comments.update_comment:after") do |_event_name, data|
42
+ Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body])
43
+ end
44
+ end
45
+ end
46
+
47
+ initializer "decidim_ai.events.subscribe_meeting" do
48
+ config.to_prepare do
49
+ ActiveSupport::Notifications.subscribe("decidim.meetings.create_meeting:after") do |_event_name, data|
50
+ Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale),
51
+ [:description, :title, :location_hints, :registration_terms])
52
+ end
53
+ ActiveSupport::Notifications.subscribe("decidim.meetings.update_meeting:after") do |_event_name, data|
54
+ Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale),
55
+ [:description, :title, :location_hints, :registration_terms])
56
+ end
57
+ end
58
+ end
59
+
60
+ initializer "decidim_ai.events.subscribe_debate" do
61
+ config.to_prepare do
62
+ ActiveSupport::Notifications.subscribe("decidim.debates.create_debate:after") do |_event_name, data|
63
+ Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:description, :title])
64
+ end
65
+ ActiveSupport::Notifications.subscribe("decidim.debates.update_debate:after") do |_event_name, data|
66
+ Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:description, :title])
67
+ end
68
+ end
69
+ end
70
+
71
+ initializer "decidim_ai.events.subscribe_initiatives" do
72
+ config.to_prepare do
73
+ ActiveSupport::Notifications.subscribe("decidim.initiatives.create_initiative:after") do |_event_name, data|
74
+ Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:description, :title])
75
+ end
76
+ ActiveSupport::Notifications.subscribe("decidim.initiatives.update_initiative:after") do |_event_name, data|
77
+ Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:description, :title])
78
+ end
79
+ end
80
+ end
81
+
82
+ initializer "decidim_ai.events.subscribe_proposals" do
83
+ config.to_prepare do
84
+ ActiveSupport::Notifications.subscribe("decidim.proposals.create_proposal:after") do |_event_name, data|
85
+ Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body, :title])
86
+ end
87
+ ActiveSupport::Notifications.subscribe("decidim.proposals.update_proposal:after") do |_event_name, data|
88
+ Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body, :title])
89
+ end
90
+ ActiveSupport::Notifications.subscribe("decidim.proposals.create_collaborative_draft:after") do |_event_name, data|
91
+ Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body, :title])
92
+ end
93
+ ActiveSupport::Notifications.subscribe("decidim.proposals.update_collaborative_draft:after") do |_event_name, data|
94
+ Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body, :title])
95
+ end
96
+ end
97
+ end
98
+
99
+ def load_seed
100
+ nil
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Ai
5
+ module Language
6
+ class Formatter
7
+ include ActionView::Helpers::SanitizeHelper
8
+
9
+ # for the moment, we just use strip_tags to clean-up the text. At a later stage, we may need to introduce
10
+ # stemmers, ngrams or other kind of text normalization, as well any language specific criteria
11
+ def cleanup(text)
12
+ strip_tags(text)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Ai
5
+ module Language
6
+ autoload :Formatter, "decidim/ai/language/formatter"
7
+ include ActiveSupport::Configurable
8
+
9
+ # Text cleanup service
10
+ #
11
+ # If you want to implement your own text formatter, you can use a class having the following contract
12
+ #
13
+ # class Formatter
14
+ # def cleanup(text)
15
+ # # your code
16
+ # end
17
+ # end
18
+ config_accessor :formatter do
19
+ "Decidim::Ai::Language::Formatter"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Ai
5
+ module SpamDetection
6
+ module Importer
7
+ class Database
8
+ def self.call
9
+ Decidim::Ai::SpamDetection.resource_models.values.each do |model|
10
+ model.constantize.new.batch_train
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Ai
5
+ module SpamDetection
6
+ module Importer
7
+ class File
8
+ def self.call(file, service)
9
+ ext = ::File.extname(file)[1..-1]
10
+ reader_class = Decidim::Admin::Import::Readers.search_by_file_extension(ext)
11
+
12
+ reader_class.new(file).read_rows do |row|
13
+ next unless [:spam, :ham].include?(row[0].to_sym)
14
+ next if row[1].blank?
15
+
16
+ service.train(row[0].to_sym, row[1])
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Ai
5
+ module SpamDetection
6
+ module Resource
7
+ class Base
8
+ include Decidim::TranslatableAttributes
9
+
10
+ def fields; end
11
+
12
+ def batch_train
13
+ query.find_each(batch_size: 100) do |resource|
14
+ classification = resource_hidden?(resource) ? :spam : :ham
15
+
16
+ fields.each do |field_name|
17
+ raise "#{resource.class.name} does not implement #{field_name} as defined in `#{self.class.name}`" unless resource.respond_to?(field_name.to_sym)
18
+
19
+ train classification, translated_attribute(resource.send(field_name.to_sym))
20
+ end
21
+ end
22
+ end
23
+
24
+ def train(category, text)
25
+ raise error_message("Decidim::Ai::SpamDetection.resource_detection_service", __method__) unless classifier.respond_to?(:train)
26
+
27
+ classifier.train(category, text)
28
+ end
29
+
30
+ def untrain(category, text)
31
+ raise error_message("Decidim::Ai::SpamDetection.resource_detection_service", __method__) unless classifier.respond_to?(:untrain)
32
+
33
+ classifier.untrain(category, text)
34
+ end
35
+
36
+ protected
37
+
38
+ def error_message(klass, method_name)
39
+ "Invalid Classifier class! The class defined under `#{klass}` does not follow the contract regarding ##{method_name} method"
40
+ end
41
+
42
+ def resource_hidden?(resource)
43
+ resource.class.included_modules.include?(Decidim::Reportable) && resource.hidden?
44
+ end
45
+
46
+ def classifier
47
+ @classifier ||= Decidim::Ai::SpamDetection.resource_classifier
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Ai
5
+ module SpamDetection
6
+ module Resource
7
+ class CollaborativeDraft < Base
8
+ def fields = [:body, :title]
9
+
10
+ protected
11
+
12
+ def query = Decidim::Proposals::CollaborativeDraft.includes(:moderation)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Ai
5
+ module SpamDetection
6
+ module Resource
7
+ class Comment < Base
8
+ def fields = [:body]
9
+
10
+ protected
11
+
12
+ def query = Decidim::Comments::Comment.includes(:moderation)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Ai
5
+ module SpamDetection
6
+ module Resource
7
+ class Debate < Base
8
+ def fields = [:description, :title]
9
+
10
+ protected
11
+
12
+ def query = Decidim::Debates::Debate.includes(:moderation)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Ai
5
+ module SpamDetection
6
+ module Resource
7
+ class Initiative < Base
8
+ def fields = [:description, :title]
9
+
10
+ protected
11
+
12
+ def query = Decidim::Initiative
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Ai
5
+ module SpamDetection
6
+ module Resource
7
+ class Meeting < Base
8
+ def fields = [:description, :title, :location_hints, :registration_terms, :closing_report]
9
+
10
+ protected
11
+
12
+ def query = Decidim::Meetings::Meeting.includes(:moderation)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Ai
5
+ module SpamDetection
6
+ module Resource
7
+ class Proposal < Base
8
+ def fields = [:body, :title]
9
+
10
+ protected
11
+
12
+ def query = Decidim::Proposals::Proposal.includes(:moderation)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Ai
5
+ module SpamDetection
6
+ module Resource
7
+ class UserBaseEntity < Base
8
+ def fields = [:about]
9
+
10
+ def train(category, text)
11
+ raise error_message("Decidim::Ai::SpamDetection.user_detection_service", __method__) unless classifier.respond_to?(:train)
12
+
13
+ classifier.train(category, text)
14
+ end
15
+
16
+ def untrain(category, text)
17
+ raise error_message("Decidim::Ai::SpamDetection.user_detection_service", __method__) unless classifier.respond_to?(:untrain)
18
+
19
+ classifier.untrain(category, text)
20
+ end
21
+
22
+ protected
23
+
24
+ def query = Decidim::UserBaseEntity
25
+
26
+ def resource_hidden?(resource) = resource.class.included_modules.include?(Decidim::UserReportable) && resource.blocked?
27
+
28
+ def classifier
29
+ @classifier ||= Decidim::Ai::SpamDetection.user_classifier
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Ai
5
+ module SpamDetection
6
+ class Service
7
+ def initialize(registry:)
8
+ @registry = registry
9
+ end
10
+
11
+ def reset
12
+ @registry.each do |strategy|
13
+ next unless strategy.respond_to?(:reset)
14
+
15
+ strategy.reset
16
+ end
17
+ end
18
+
19
+ def train(category, text)
20
+ text = formatter.cleanup(text)
21
+ return if text.blank?
22
+
23
+ @registry.each do |strategy|
24
+ strategy.train(category, text)
25
+ end
26
+ end
27
+
28
+ def classify(text)
29
+ text = formatter.cleanup(text)
30
+ return if text.blank?
31
+
32
+ @registry.each do |strategy|
33
+ strategy.classify(text)
34
+ end
35
+ end
36
+
37
+ def untrain(category, text)
38
+ text = formatter.cleanup(text)
39
+ return if text.blank?
40
+
41
+ @registry.each do |strategy|
42
+ strategy.untrain(category, text)
43
+ end
44
+ end
45
+
46
+ def score
47
+ @registry.collect(&:score).inject(0.0, :+) / @registry.size
48
+ end
49
+
50
+ def classification_log
51
+ @classification_log = []
52
+ @registry.each do |strategy|
53
+ @classification_log << strategy.log
54
+ end
55
+ @classification_log.join("\n")
56
+ end
57
+
58
+ protected
59
+
60
+ def formatter
61
+ @formatter ||= Decidim::Ai::Language.formatter.safe_constantize&.new
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Ai
5
+ module SpamDetection
6
+ include ActiveSupport::Configurable
7
+
8
+ autoload :Service, "decidim/ai/spam_detection/service"
9
+
10
+ module Resource
11
+ autoload :Base, "decidim/ai/spam_detection/resource/base"
12
+ autoload :Comment, "decidim/ai/spam_detection/resource/comment"
13
+ autoload :Debate, "decidim/ai/spam_detection/resource/debate"
14
+ autoload :Initiative, "decidim/ai/spam_detection/resource/initiative"
15
+ autoload :Proposal, "decidim/ai/spam_detection/resource/proposal"
16
+ autoload :CollaborativeDraft, "decidim/ai/spam_detection/resource/collaborative_draft"
17
+ autoload :Meeting, "decidim/ai/spam_detection/resource/meeting"
18
+ autoload :UserBaseEntity, "decidim/ai/spam_detection/resource/user_base_entity"
19
+ end
20
+
21
+ module Importer
22
+ autoload :File, "decidim/ai/spam_detection/importer/file"
23
+ autoload :Database, "decidim/ai/spam_detection/importer/database"
24
+ end
25
+
26
+ module Strategy
27
+ autoload :Base, "decidim/ai/spam_detection/strategy/base"
28
+ autoload :Bayes, "decidim/ai/spam_detection/strategy/bayes"
29
+ end
30
+
31
+ # This is the email address used by the spam engine to
32
+ # properly identify the user that will report users and content
33
+ config_accessor :reporting_user_email do
34
+ "decidim-reporting-user@example.org"
35
+ end
36
+
37
+ # You can configure the spam threshold for the spam detection service.
38
+ # The threshold is a float value between 0 and 1.
39
+ # The default value is 0.75
40
+ # Any value below the threshold will be considered spam.
41
+ config_accessor :resource_score_threshold do
42
+ 0.75
43
+ end
44
+
45
+ # Registered analyzers.
46
+ # You can register your own analyzer by adding a new entry to this array.
47
+ # The entry must be a hash with the following keys:
48
+ # - name: the name of the analyzer
49
+ # - strategy: the class of the strategy to use
50
+ # - options: a hash with the options to pass to the strategy
51
+ # Example:
52
+ # config.resource_analyzers = {
53
+ # name: :bayes,
54
+ # strategy: Decidim::Ai::SpamContent::BayesStrategy,
55
+ # options: {
56
+ # adapter: :redis,
57
+ # params: {
58
+ # url: ENV["DECIDIM_SPAM_DETECTION_BACKEND_RESOURCE_URL"]
59
+ # }
60
+ # }
61
+ # }
62
+ config_accessor :resource_analyzers do
63
+ [
64
+ {
65
+ name: :bayes,
66
+ strategy: Decidim::Ai::SpamDetection::Strategy::Bayes,
67
+ options: {
68
+ adapter: ENV.fetch("DECIDIM_SPAM_DETECTION_BACKEND_RESOURCE", "redis"),
69
+ params: { url: ENV.fetch("DECIDIM_SPAM_DETECTION_BACKEND_RESOURCE_URL", "redis://localhost:6379/2") }
70
+ }
71
+ }
72
+ ]
73
+ end
74
+
75
+ # This config_accessor allows the implementers to change the class being used by the classifier,
76
+ # in order to change the finder method. or even define own resource visibility criteria.
77
+ # This is the place where new resources can be registered following the pattern
78
+ # Resource => Handler
79
+ config_accessor :resource_models do
80
+ @models ||= begin
81
+ models = {}
82
+ models["Decidim::Comments::Comment"] = "Decidim::Ai::SpamDetection::Resource::Comment" if Decidim.module_installed?("comments")
83
+ models["Decidim::Debates::Debate"] = "Decidim::Ai::SpamDetection::Resource::Debate" if Decidim.module_installed?("debates")
84
+ models["Decidim::Initiative"] = "Decidim::Ai::SpamDetection::Resource::Initiative" if Decidim.module_installed?("initiatives")
85
+ models["Decidim::Meetings::Meeting"] = "Decidim::Ai::SpamDetection::Resource::Meeting" if Decidim.module_installed?("meetings")
86
+ models["Decidim::Proposals::Proposal"] = "Decidim::Ai::SpamDetection::Resource::Proposal" if Decidim.module_installed?("proposals")
87
+ models["Decidim::Proposals::CollaborativeDraft"] = "Decidim::Ai::SpamDetection::Resource::CollaborativeDraft" if Decidim.module_installed?("proposals")
88
+ models
89
+ end
90
+ end
91
+
92
+ # Spam detection service class.
93
+ # If you want to use a different spam detection service, you can use a class service having the following contract
94
+ config_accessor :resource_detection_service do
95
+ "Decidim::Ai::SpamDetection::Service"
96
+ end
97
+
98
+ # You can configure the spam threshold for the spam detection service.
99
+ # The threshold is a float value between 0 and 1.
100
+ # The default value is 0.75
101
+ # Any value below the threshold will be considered spam.
102
+ config_accessor :user_score_threshold do
103
+ 0.75
104
+ end
105
+
106
+ # Registered analyzers.
107
+ # You can register your own analyzer by adding a new entry to this array.
108
+ # The entry must be a hash with the following keys:
109
+ # - name: the name of the analyzer
110
+ # - strategy: the class of the strategy to use
111
+ # - options: a hash with the options to pass to the strategy
112
+ # Example:
113
+ # config.user_analyzers = {
114
+ # name: :bayes,
115
+ # strategy: Decidim::Ai::SpamContent::BayesStrategy,
116
+ # options: {
117
+ # adapter: :redis,
118
+ # params: {
119
+ # url: ENV["DECIDIM_SPAM_DETECTION_BACKEND_USER_REDIS_URL"]
120
+ # }
121
+ # }
122
+ # }
123
+ config_accessor :user_analyzers do
124
+ [
125
+ {
126
+ name: :bayes,
127
+ strategy: Decidim::Ai::SpamDetection::Strategy::Bayes,
128
+ options: {
129
+ adapter: ENV.fetch("DECIDIM_SPAM_DETECTION_BACKEND_USER", "redis"),
130
+ params: { url: ENV.fetch("DECIDIM_SPAM_DETECTION_BACKEND_USER_REDIS_URL", "redis://localhost:6379/3") }
131
+ }
132
+ }
133
+ ]
134
+ end
135
+
136
+ # This config_accessor allows the implementers to change the class being used by the classifier,
137
+ # in order to change the finder method or what a hidden user really is.
138
+ # The same applies for UserGroups.
139
+ config_accessor :user_models do
140
+ @user_models ||= begin
141
+ user_models = {}
142
+
143
+ user_models["Decidim::UserGroup"] = "Decidim::Ai::SpamDetection::Resource::UserBaseEntity"
144
+ user_models["Decidim::User"] = "Decidim::Ai::SpamDetection::Resource::UserBaseEntity"
145
+ user_models
146
+ end
147
+ end
148
+
149
+ # Spam detection service class.
150
+ # If you want to use a different spam detection service, you can use a class service having the following contract
151
+ config_accessor :user_detection_service do
152
+ "Decidim::Ai::SpamDetection::Service"
153
+ end
154
+
155
+ # this is the generic resource classifier class. If you need to change your own class, please change the
156
+ # configuration of `Decidim::Ai::SpamDetection.detection_service` variable.
157
+ def self.resource_classifier
158
+ @resource_classifier = Decidim::Ai::SpamDetection.resource_detection_service.safe_constantize&.new(
159
+ registry: Decidim::Ai::SpamDetection.resource_registry
160
+ )
161
+ end
162
+
163
+ # The registry instance that stores the list of strategies needed to process the resources
164
+ # In essence is an enumerator class that responds to `register_analyzer(**params)` and `for(name)` methods
165
+ def self.resource_registry
166
+ @resource_registry ||= Decidim::Ai::StrategyRegistry.new
167
+ end
168
+
169
+ # this is the generic user classifier class. If you need to change your own class, please change the
170
+ # configuration of `Decidim::Ai::SpamDetection.detection_service` variable
171
+ def self.user_classifier
172
+ @user_classifier = Decidim::Ai::SpamDetection.user_detection_service.safe_constantize&.new(
173
+ registry: Decidim::Ai::SpamDetection.user_registry
174
+ )
175
+ end
176
+
177
+ # The registry instance that stores the list of strategies needed to process the user objects
178
+ # In essence is an enumerator class that responds to `register_analyzer(**params)` and `for(name)` methods
179
+ def self.user_registry
180
+ @user_registry ||= Decidim::Ai::StrategyRegistry.new
181
+ end
182
+
183
+ # This method is being called to ensure that user with email configured in
184
+ # `Decidim::Ai::SpamDetection.reporting_user_email` variable exists in the database.
185
+ def self.create_reporting_user!
186
+ Decidim::Organization.find_each do |organization|
187
+ user = organization.users.find_or_initialize_by(email: Decidim::Ai::SpamDetection.reporting_user_email)
188
+ next if user.persisted?
189
+
190
+ password = SecureRandom.hex(10)
191
+ user.password = password
192
+ user.password_confirmation = password
193
+
194
+ user.deleted_at = Time.current
195
+ user.tos_agreement = true
196
+ user.name = ""
197
+ user.skip_confirmation!
198
+ user.save!
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Ai
5
+ module SpamDetection
6
+ module Strategy
7
+ class Base
8
+ attr_reader :name
9
+
10
+ def initialize(options = {})
11
+ @name = options.delete(:name)
12
+ @options = options
13
+ end
14
+
15
+ def classify(_content); end
16
+
17
+ def train(_classification, _content); end
18
+
19
+ def untrain(_classification, _content); end
20
+
21
+ def log; end
22
+
23
+ def score = 0.0
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "classifier-reborn"
4
+
5
+ module Decidim
6
+ module Ai
7
+ module SpamDetection
8
+ module Strategy
9
+ class Bayes < Base
10
+ def initialize(options = {})
11
+ super
12
+ @options = { adapter: :memory, categories: [:ham, :spam], params: {} }.deep_merge(options)
13
+
14
+ @available_categories = options[:categories]
15
+ @backend = ClassifierReborn::Bayes.new(*available_categories, backend: configured_backend)
16
+ end
17
+
18
+ def log
19
+ return unless category
20
+
21
+ "The Classification engine marked this as #{category}"
22
+ end
23
+
24
+ # Calling this method without any trained categories will throw an error
25
+ def untrain(category, content)
26
+ return unless backend.categories.collect(&:downcase).collect(&:to_sym).include?(category)
27
+
28
+ backend.untrain(category, content)
29
+ end
30
+
31
+ delegate :train, :reset, to: :backend
32
+
33
+ def classify(content)
34
+ @category, @internal_score = backend.classify_with_score(content)
35
+ category
36
+ end
37
+
38
+ # The Bayes strategy returns a score between that can be lower than -1
39
+ # As per ClassifierReborn documentation, closest to 0 is being picked as the dominant category
40
+ #
41
+ # From original documentation:
42
+ # Returns the scores in each category the provided +text+. E.g.,
43
+ # b.classifications "I hate bad words and you"
44
+ # => {"Uninteresting"=>-12.6997928013932, "Interesting"=>-18.4206807439524}
45
+ # The largest of these scores (the one closest to 0) is the one picked out by #classify
46
+ def score
47
+ category.presence == "spam" ? 1 : 0
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :backend, :options, :available_categories, :category, :internal_score
53
+
54
+ def configured_backend
55
+ if options[:adapter].to_s == "memory"
56
+ system_log "[decidim-ai] #{self.class.name} - Running the Memory backend as it was requested. This is not recommended for production environment."
57
+ ClassifierReborn::BayesMemoryBackend.new
58
+ elsif options.dig(:params, :url) && options.dig(:params, :url).empty?
59
+ system_log "[decidim-ai] #{self.class.name} - Running the Memory backend as there are no redis credentials. This is not recommended for production environment."
60
+ ClassifierReborn::BayesMemoryBackend.new
61
+ else
62
+ system_log "[decidim-ai] #{self.class.name} - Running the Redis backend"
63
+ ClassifierReborn::BayesRedisBackend.new options[:params]
64
+ end
65
+ end
66
+
67
+ def system_log(message)
68
+ Rails.logger.info message
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Ai
5
+ class StrategyRegistry
6
+ class StrategyAlreadyRegistered < StandardError; end
7
+
8
+ delegate :clear, :collect, :each, :size, to: :strategies
9
+ attr_reader :strategies
10
+
11
+ def initialize
12
+ @strategies = []
13
+ end
14
+
15
+ def register_analyzer(name:, strategy:, options: {})
16
+ if self.for(name).present?
17
+ raise(
18
+ StrategyAlreadyRegistered,
19
+ "There is a strategy already registered with the name `:#{name}`"
20
+ )
21
+ end
22
+
23
+ options = { name: }.merge(options)
24
+ strategies << strategy.new(options)
25
+ end
26
+
27
+ def for(name)
28
+ strategies.select { |k, _v| k.name == name }.first
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1 @@
1
+ # frozen_string_literal: true
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Ai
5
+ def self.version
6
+ "0.30.0.rc1"
7
+ end
8
+ end
9
+ end
data/lib/decidim/ai.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "decidim/ai/engine"
4
+
5
+ module Decidim
6
+ module Ai
7
+ autoload :StrategyRegistry, "decidim/ai/strategy_registry"
8
+ autoload :SpamDetection, "decidim/ai/spam_detection/spam_detection"
9
+ autoload :Language, "decidim/ai/language/language"
10
+
11
+ include ActiveSupport::Configurable
12
+ end
13
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :decidim do
4
+ namespace :ai do
5
+ namespace :spam do
6
+ desc "Create reporting user"
7
+ task create_reporting_user: :environment do
8
+ Decidim::Ai::SpamDetection.create_reporting_user!
9
+ end
10
+
11
+ desc "Load application dataset file"
12
+ task :load_application_dataset, [:file] => :environment do |_, args|
13
+ Decidim::Ai::SpamDetection::Importer::File.call(args[:file], Decidim::Ai::SpamDetection.user_classifier)
14
+ Decidim::Ai::SpamDetection::Importer::File.call(args[:file], Decidim::Ai::SpamDetection.resource_classifier)
15
+ end
16
+
17
+ desc "Train model using application database"
18
+ task train_application_database: :environment do
19
+ Decidim::Ai::SpamDetection::Importer::Database.call
20
+ end
21
+
22
+ desc "Reset all training model"
23
+ task reset: :environment do
24
+ Decidim::Ai::SpamDetection.user_classifier.reset
25
+ Decidim::Ai::SpamDetection.resource_classifier.reset
26
+ end
27
+
28
+ private
29
+
30
+ def plugin_path
31
+ Gem.loaded_specs["decidim-ai"].full_gem_path
32
+ end
33
+ end
34
+ end
35
+ end
metadata ADDED
@@ -0,0 +1,160 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: decidim-ai
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.30.0.rc1
5
+ platform: ruby
6
+ authors:
7
+ - Alexandru-Emil Lupu
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-02-18 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: classifier-reborn
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 2.3.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 2.3.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: decidim-core
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - '='
32
+ - !ruby/object:Gem::Version
33
+ version: 0.30.0.rc1
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - '='
39
+ - !ruby/object:Gem::Version
40
+ version: 0.30.0.rc1
41
+ - !ruby/object:Gem::Dependency
42
+ name: decidim-debates
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - '='
46
+ - !ruby/object:Gem::Version
47
+ version: 0.30.0.rc1
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - '='
53
+ - !ruby/object:Gem::Version
54
+ version: 0.30.0.rc1
55
+ - !ruby/object:Gem::Dependency
56
+ name: decidim-initiatives
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - '='
60
+ - !ruby/object:Gem::Version
61
+ version: 0.30.0.rc1
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - '='
67
+ - !ruby/object:Gem::Version
68
+ version: 0.30.0.rc1
69
+ - !ruby/object:Gem::Dependency
70
+ name: decidim-meetings
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - '='
74
+ - !ruby/object:Gem::Version
75
+ version: 0.30.0.rc1
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - '='
81
+ - !ruby/object:Gem::Version
82
+ version: 0.30.0.rc1
83
+ - !ruby/object:Gem::Dependency
84
+ name: decidim-proposals
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - '='
88
+ - !ruby/object:Gem::Version
89
+ version: 0.30.0.rc1
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - '='
95
+ - !ruby/object:Gem::Version
96
+ version: 0.30.0.rc1
97
+ description: A module that aims to provide Artificial Intelligence tools for Decidim.
98
+ email:
99
+ - contact@alecslupu.ro
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - README.md
105
+ - Rakefile
106
+ - app/jobs/decidim/ai/application_job.rb
107
+ - app/jobs/decidim/ai/spam_detection/application_job.rb
108
+ - app/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job.rb
109
+ - app/jobs/decidim/ai/spam_detection/user_spam_analyzer_job.rb
110
+ - lib/decidim/ai.rb
111
+ - lib/decidim/ai/engine.rb
112
+ - lib/decidim/ai/language/formatter.rb
113
+ - lib/decidim/ai/language/language.rb
114
+ - lib/decidim/ai/spam_detection/importer/database.rb
115
+ - lib/decidim/ai/spam_detection/importer/file.rb
116
+ - lib/decidim/ai/spam_detection/resource/base.rb
117
+ - lib/decidim/ai/spam_detection/resource/collaborative_draft.rb
118
+ - lib/decidim/ai/spam_detection/resource/comment.rb
119
+ - lib/decidim/ai/spam_detection/resource/debate.rb
120
+ - lib/decidim/ai/spam_detection/resource/initiative.rb
121
+ - lib/decidim/ai/spam_detection/resource/meeting.rb
122
+ - lib/decidim/ai/spam_detection/resource/proposal.rb
123
+ - lib/decidim/ai/spam_detection/resource/user_base_entity.rb
124
+ - lib/decidim/ai/spam_detection/service.rb
125
+ - lib/decidim/ai/spam_detection/spam_detection.rb
126
+ - lib/decidim/ai/spam_detection/strategy/base.rb
127
+ - lib/decidim/ai/spam_detection/strategy/bayes.rb
128
+ - lib/decidim/ai/strategy_registry.rb
129
+ - lib/decidim/ai/test/factories.rb
130
+ - lib/decidim/ai/version.rb
131
+ - lib/tasks/decidim_ai.rake
132
+ homepage: https://decidim.org
133
+ licenses:
134
+ - AGPL-3.0-or-later
135
+ metadata:
136
+ bug_tracker_uri: https://github.com/decidim/decidim/issues
137
+ documentation_uri: https://docs.decidim.org/
138
+ funding_uri: https://opencollective.com/decidim
139
+ homepage_uri: https://decidim.org
140
+ source_code_uri: https://github.com/decidim/decidim
141
+ post_install_message:
142
+ rdoc_options: []
143
+ require_paths:
144
+ - lib
145
+ required_ruby_version: !ruby/object:Gem::Requirement
146
+ requirements:
147
+ - - "~>"
148
+ - !ruby/object:Gem::Version
149
+ version: 3.3.0
150
+ required_rubygems_version: !ruby/object:Gem::Requirement
151
+ requirements:
152
+ - - ">="
153
+ - !ruby/object:Gem::Version
154
+ version: '0'
155
+ requirements: []
156
+ rubygems_version: 3.5.11
157
+ signing_key:
158
+ specification_version: 4
159
+ summary: A Decidim module with AI tools
160
+ test_files: []