decidim-ai 0.30.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
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: []