From 5d967f097a24416685ffdc9006571f1dfa048064 Mon Sep 17 00:00:00 2001 From: Quentin Champenois Date: Fri, 14 Mar 2025 11:24:14 +0100 Subject: [PATCH 01/17] feat(ai): Add Scaleway third-party AI service --- Gemfile.lock | 4 + .../ai/spam_detection/spam_detection.rb | 2 + .../ai/spam_detection/strategy/base.rb | 6 + .../ai/spam_detection/strategy/scaleway.rb | 19 ++ .../ai/spam_detection/strategy/third_party.rb | 112 ++++++++++ .../spam_detection/strategy/scaleway_spec.rb | 103 +++++++++ .../strategy/third_party_spec.rb | 205 ++++++++++++++++++ 7 files changed, 451 insertions(+) create mode 100644 lib/decidim/ai/spam_detection/strategy/scaleway.rb create mode 100644 lib/decidim/ai/spam_detection/strategy/third_party.rb create mode 100644 spec/lib/decidim/ai/spam_detection/strategy/scaleway_spec.rb create mode 100644 spec/lib/decidim/ai/spam_detection/strategy/third_party_spec.rb diff --git a/Gemfile.lock b/Gemfile.lock index bc95c31..86b55a7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -371,6 +371,7 @@ GEM faraday-net_http (3.4.0) net-http (>= 0.5.0) fast-stemmer (1.0.2) + ffi (1.17.1-arm64-darwin) ffi (1.17.1-x86_64-linux-gnu) file_validators (3.0.0) activemodel (>= 3.2) @@ -503,6 +504,8 @@ GEM net-smtp (0.3.4) net-protocol nio4r (2.7.4) + nokogiri (1.16.8-arm64-darwin) + racc (~> 1.4) nokogiri (1.16.8-x86_64-linux) racc (~> 1.4) oauth (1.1.0) @@ -797,6 +800,7 @@ GEM zeitwerk (2.7.2) PLATFORMS + arm64-darwin-22 x86_64-linux-gnu DEPENDENCIES diff --git a/lib/decidim/ai/spam_detection/spam_detection.rb b/lib/decidim/ai/spam_detection/spam_detection.rb index b0fb8d8..c38a6ed 100644 --- a/lib/decidim/ai/spam_detection/spam_detection.rb +++ b/lib/decidim/ai/spam_detection/spam_detection.rb @@ -26,6 +26,8 @@ module Importer module Strategy autoload :Base, "decidim/ai/spam_detection/strategy/base" autoload :Bayes, "decidim/ai/spam_detection/strategy/bayes" + autoload :ThirdParty, "decidim/ai/spam_detection/strategy/third_party" + autoload :Scaleway, "decidim/ai/spam_detection/strategy/scaleway" end # This is the email address used by the spam engine to diff --git a/lib/decidim/ai/spam_detection/strategy/base.rb b/lib/decidim/ai/spam_detection/strategy/base.rb index 6cd7153..a760237 100644 --- a/lib/decidim/ai/spam_detection/strategy/base.rb +++ b/lib/decidim/ai/spam_detection/strategy/base.rb @@ -21,6 +21,12 @@ def untrain(_classification, _content); end def log; end def score = 0.0 + + private + + def system_log(message, level: :info) + Rails.logger.send(level, message) + end end end end diff --git a/lib/decidim/ai/spam_detection/strategy/scaleway.rb b/lib/decidim/ai/spam_detection/strategy/scaleway.rb new file mode 100644 index 0000000..19faeab --- /dev/null +++ b/lib/decidim/ai/spam_detection/strategy/scaleway.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + module Strategy + # Example third-party strategy + class Scaleway < ThirdParty + def third_party_content(body) + return [] if body.blank? + + choices = JSON.parse(body)&.fetch("choices", []) + choices.first&.dig("message", "content") + end + end + end + end + end +end diff --git a/lib/decidim/ai/spam_detection/strategy/third_party.rb b/lib/decidim/ai/spam_detection/strategy/third_party.rb new file mode 100644 index 0000000..590b4fa --- /dev/null +++ b/lib/decidim/ai/spam_detection/strategy/third_party.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + module Strategy + class ThirdParty < Base + class InvalidOutputFormat < StandardError; end + + class InvalidEntity < StandardError; end + + OUTPUT = %w(SPAM NOT_SPAM).freeze + + def initialize(options = {}) + super + @endpoint = options[:endpoint] + @secret = options[:secret] + @options = options + end + + def log + return "AI system didn't marked this content as spam" if score <= score_threshold + + "AI system marked this as spam" + end + + def classify(content) + system_log("Starting classification...") + res = request(content) + body = res.body + + system_log("Received response from third party service: #{body}") + raise InvalidEntity, res.error unless res.is_a?(Net::HTTPSuccess) + + content = third_party_content(body) + raise InvalidOutputFormat, "Third party service response isn't valid JSON" unless valid_output_format?(content) + + @category = content.downcase + system_log("Spam : #{score}.") + score + rescue InvalidEntity, InvalidOutputFormat => e + system_log(e.message, level: :error) + score + end + + def request(content) + uri = URI(@endpoint) + payload = payload(content).to_json + system_log("Sending request to third party service: #{payload}") + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.post(uri.path, payload, headers) + end + + def headers + @headers ||= { + "Authorization" => "Bearer #{@secret}", + "Content-Type" => "application/json", + "Accept" => "application/json" + } + end + + def payload(content) + { + model: @options[:model], + messages: [ + { + role: "system", + content: @options[:system_message] + }, + { + role: "user", + content: + } + ], + max_tokens: @options[:max_tokens], + temperature: @options[:temperature], + top_p: @options[:top_p], + presence_penalty: @options[:presence_penalty], + stream: @options[:stream] + } + end + + # This method should be implemented by the subclass depending on the third-party service + def third_party_content(body) end + + def score + @score ||= @category.presence == "spam" ? 1 : 0 + end + + private + + attr_reader :options + + def valid_output_format?(output) + output.present? && output.is_a?(String) && output.in?(OUTPUT) + end + + def score_threshold + return Decidim::Ai::SpamDetection.user_score_threshold if name == :third_party_user + + Decidim::Ai::SpamDetection.resource_score_threshold + end + + def system_log(message, level: :info) + Rails.logger.send(level, "[decidim-ai] #{self.class.name} - #{message}") + end + end + end + end + end +end diff --git a/spec/lib/decidim/ai/spam_detection/strategy/scaleway_spec.rb b/spec/lib/decidim/ai/spam_detection/strategy/scaleway_spec.rb new file mode 100644 index 0000000..5ec1f0f --- /dev/null +++ b/spec/lib/decidim/ai/spam_detection/strategy/scaleway_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Decidim::Ai::SpamDetection::Strategy::Scaleway do + let(:strategy) { described_class.new(options) } + let(:endpoint) { "https://example.com/api" } + let(:secret) { "secret_key" } + let(:options) do + { + endpoint:, + secret:, + model: "model_name", + system_message: "System message", + max_tokens: 100, + temperature: 0.7, + top_p: 0.9, + presence_penalty: 0, + stream: false + } + end + + describe "#third_party_content" do + context "when body contains valid JSON with choices" do + let(:body) do + { + "choices" => [ + { + "message" => { + "content" => "SPAM" + } + } + ] + }.to_json + end + + it "returns the content of the first choice" do + expect(strategy.third_party_content(body)).to eq("SPAM") + end + end + + context "when body contains valid JSON without choices" do + let(:body) do + { + "choices" => [] + }.to_json + end + + it "returns nil" do + expect(strategy.third_party_content(body)).to be_nil + end + end + + context "when body contains invalid JSON" do + let(:body) { "invalid json" } + + it "raises a JSON::ParserError" do + expect { strategy.third_party_content(body) }.to raise_error(JSON::ParserError) + end + end + + context "when body is empty" do + let(:body) { "" } + + it "returns nil" do + expect(strategy.third_party_content(body)).to be_empty + end + end + end + + describe "#classify" do + let(:response_double) { double("Net::HTTPResponse", body: '{"choices": [{"message": {"content": "NOT_SPAM"}}]}', is_a?: true) } + + before do + allow(strategy).to receive(:request).and_return(response_double) + end + + it "classifies content as not spam" do + expect(strategy.classify("Test content")).to eq(0) + end + + context "when response is invalid" do + before do + allow(response_double).to receive(:is_a?).with(Net::HTTPSuccess).and_return(false) + allow(response_double).to receive(:error).and_return("Error message") + end + + it "raises InvalidEntity error" do + expect { strategy.classify("Test content") }.not_to raise_error(Decidim::Ai::SpamDetection::Strategy::ThirdParty::InvalidEntity) + expect(strategy.instance_variable_get(:@score)).to eq(0) + end + end + + context "when response format is invalid" do + before { allow(strategy).to receive(:valid_output_format?).and_return(false) } + + it "raises InvalidOutputFormat error" do + expect { strategy.classify("Test content") }.not_to raise_error(Decidim::Ai::SpamDetection::Strategy::ThirdParty::InvalidOutputFormat) + expect(strategy.instance_variable_get(:@score)).to eq(0) + end + end + end +end diff --git a/spec/lib/decidim/ai/spam_detection/strategy/third_party_spec.rb b/spec/lib/decidim/ai/spam_detection/strategy/third_party_spec.rb new file mode 100644 index 0000000..93bc9bf --- /dev/null +++ b/spec/lib/decidim/ai/spam_detection/strategy/third_party_spec.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe Decidim::Ai::SpamDetection::Strategy::ThirdParty do + let(:strategy) { described_class.new(options) } + let(:endpoint) { "https://example.com/api" } + let(:secret) { "secret_key" } + let(:options) do + { + endpoint:, + secret:, + model: "model_name", + system_message: "System message", + max_tokens: 100, + temperature: 0.7, + top_p: 0.9, + presence_penalty: 0, + stream: false + } + end + let(:content) { "Test contribution input." } + + describe "#initialize" do + it "initializes with options" do + expect(strategy.instance_variable_get(:@endpoint)).to eq(endpoint) + expect(strategy.instance_variable_get(:@secret)).to eq(secret) + expect(strategy.instance_variable_get(:@options)).to eq(options) + end + end + + describe "train" do + it "returns nothing" do + expect(strategy.train(:spam, "text")).to be_nil + end + end + + describe "untrain" do + it "returns nothing" do + expect(strategy.untrain(:spam, "text")).to be_nil + end + end + + describe "#log" do + context "when score is below threshold" do + before { allow(strategy).to receive(:score).and_return(0) } + + it "returns a non-spam message" do + expect(strategy.log).to eq("AI system didn't marked this content as spam") + end + end + + context "when score is above threshold" do + before { allow(strategy).to receive(:score).and_return(1) } + + it "returns a spam message" do + expect(strategy.log).to eq("AI system marked this as spam") + end + end + end + + describe "#classify" do + let(:response_double) { double("Net::HTTPResponse", body: '{"category": "NOT_SPAM"}', is_a?: true) } + + before do + allow(strategy).to receive(:request).and_return(response_double) + allow(strategy).to receive(:third_party_content).and_return("NOT_SPAM") + end + + it "classifies content as not spam" do + expect(strategy.classify(content)).to eq(0) + end + + context "when response is invalid" do + before do + allow(response_double).to receive(:is_a?).with(Net::HTTPSuccess).and_return(false) + allow(response_double).to receive(:error).and_return("Error message") + end + + it "raises InvalidEntity error" do + expect { strategy.classify(content) }.not_to raise_error(Decidim::Ai::SpamDetection::Strategy::ThirdParty::InvalidEntity) + expect(strategy.instance_variable_get(:@score)).to eq(0) + end + end + + context "when response format is invalid" do + before { allow(strategy).to receive(:valid_output_format?).and_return(false) } + + it "raises InvalidOutputFormat error" do + expect { strategy.classify(content) }.not_to raise_error(Decidim::Ai::SpamDetection::Strategy::ThirdParty::InvalidOutputFormat) + expect(strategy.instance_variable_get(:@score)).to eq(0) + end + end + end + + describe "#request" do + let(:uri_double) { double("URI", host: "example.com", port: 443, path: "/api") } + let(:http_double) { double("Net::HTTP", :use_ssl= => true) } + + before do + allow(URI).to receive(:parse).and_return(uri_double) + allow(Net::HTTP).to receive(:new).and_return(http_double) + # allow(http_double).to receive(:use_ssl=) + allow(http_double).to receive(:post).and_return(double("Net::HTTPResponse", body: '{"category": "NOT_SPAM"}')) + end + + it "sends a request to the third-party service" do + expect(http_double).to receive(:post).with("/api", anything, anything) + strategy.request(content) + end + end + + describe "#headers" do + it "returns the correct headers" do + expect(strategy.headers).to eq( + "Authorization" => "Bearer secret_key", + "Content-Type" => "application/json", + "Accept" => "application/json" + ) + end + end + + describe "#payload" do + it "returns the correct payload" do + expect(strategy.payload(content)).to eq( + model: "model_name", + messages: [ + { role: "system", content: "System message" }, + { role: "user", content: } + ], + max_tokens: 100, + temperature: 0.7, + top_p: 0.9, + presence_penalty: 0, + stream: false + ) + end + end + + describe "#score" do + context "when category is 'spam'" do + before { strategy.instance_variable_set(:@category, "spam") } + + it "returns 1" do + expect(strategy.score).to eq(1) + end + end + + context "when category is not 'spam'" do + before { allow(strategy).to receive(:@category).and_return("not_spam") } + + it "returns 0" do + expect(strategy.score).to eq(0) + end + end + end + + describe "#valid_output_format?" do + it "returns true for valid output" do + expect(strategy.send(:valid_output_format?, "SPAM")).to be true + expect(strategy.send(:valid_output_format?, "NOT_SPAM")).to be true + end + + it "returns false for invalid output" do + expect(strategy.send(:valid_output_format?, "INVALID")).to be false + expect(strategy.send(:valid_output_format?, nil)).to be false + end + end + + describe "#score_threshold" do + before { allow(Decidim::Ai::SpamDetection).to receive(:user_score_threshold).and_return(0.5) } + + context "when name is :third_party_user" do + before { allow(strategy).to receive(:name).and_return(:third_party_user) } + + it "returns user score threshold" do + expect(strategy.send(:score_threshold)).to eq(0.5) + end + end + + context "when name is not :third_party_user" do + before { allow(strategy).to receive(:name).and_return(:other_name) } + + it "returns resource score threshold" do + expect(strategy.send(:score_threshold)).to eq(Decidim::Ai::SpamDetection.resource_score_threshold) + end + end + end + + describe "#system_log" do + let(:logger_double) { double("Rails.logger") } + + before { allow(Rails.logger).to receive(:send).and_return(logger_double) } + + it "logs a message with info level" do + expect(Rails.logger).to receive(:send).with(:info, "[decidim-ai] Decidim::Ai::SpamDetection::Strategy::ThirdParty - Test message") + strategy.send(:system_log, "Test message") + end + + it "logs a message with error level" do + expect(Rails.logger).to receive(:send).with(:error, "[decidim-ai] Decidim::Ai::SpamDetection::Strategy::ThirdParty - Error message") + strategy.send(:system_log, "Error message", level: :error) + end + end +end From b08c1039515bd68426710d375544f8a94117a81a Mon Sep 17 00:00:00 2001 From: Quentin Champenois Date: Fri, 14 Mar 2025 14:40:38 +0100 Subject: [PATCH 02/17] chore: Add README documentation --- README.md | 47 +++++++++++++++++++ .../ai/spam_detection/strategy/scaleway.rb | 3 +- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 22d6dc2..2985d42 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,53 @@ Add the queue name to `config/sidekiq.yml` file: # The other yaml entries ``` +## Configure third-party service + +To use the third-party service, you need to add the following configuration to your `config/initilizers/decidim_ai.rb` file: + +```ruby + +# frozen_string_literal: true + +if Decidim.module_installed?(:ai) + Decidim::Ai::SpamDetection.user_analyzers = [ + { + name: :bayes, + strategy: Decidim::Ai::SpamDetection::Strategy::Scaleway, + options: { + model: Rails.application.secrets.ai.model, + endpoint: Rails.application.secrets.ai.endpoint, + secret: Rails.application.secrets.ai.secret, + max_tokens: Rails.application.secrets.ai.max_tokens, + temperature: Rails.application.secrets.ai.temperature, + top_p: Rails.application.secrets.ai.top_p, + presence_penalty: Rails.application.secrets.ai.presence_penalty, + stream: Rails.application.secrets.ai.stream, + system_message: Rails.application.secrets.ai.system_message + } + } + ] +end +``` + +Add secrets to your `config/secrets.yml` file: + +```yaml + +default: &default + decidim: + ai: + model: ENV.fetch("SCW_AI_MODEL", "deepseek-r1-distill-llama-70b") + endpoint: ENV.fetch("SCW_AI_ENDPOINT") + secret: ENV.fetch("SCW_SECRET") + max_tokens: ENV.fetch("SCW_MAX_TOKENS", "100")&.to_i + temperature: ENV.fetch("SCW_MAX_TEMPERATURE", "0.7")&.to_f + top_p: ENV.fetch("SCW_TOP_P", "1")&.to_i + presence_penalty: ENV.fetch("SCW_PRESENCE_PENALTY", "0")&.to_i + stream: ENV.fetch("SCW_STREAM", "false") == "true" + system_message: ENV.fetch("SCW_SYSTEM_MESSAGE", "You are an expert content moderator for participatory democracy platforms like Decidim. Your task is to classify user-submitted content in any language as either legitimate civic participation or spam.") +``` + ## Contributing See [Decidim](https://github.com/decidim/decidim). diff --git a/lib/decidim/ai/spam_detection/strategy/scaleway.rb b/lib/decidim/ai/spam_detection/strategy/scaleway.rb index 19faeab..e2311bb 100644 --- a/lib/decidim/ai/spam_detection/strategy/scaleway.rb +++ b/lib/decidim/ai/spam_detection/strategy/scaleway.rb @@ -4,7 +4,8 @@ module Decidim module Ai module SpamDetection module Strategy - # Example third-party strategy + # Scaleway third-party strategy + # doc: https://www.scaleway.com/en/docs/managed-inference/quickstart/ class Scaleway < ThirdParty def third_party_content(body) return [] if body.blank? From 9ee8df374d6a0160f96ba9c9a505da837ba28a5c Mon Sep 17 00:00:00 2001 From: Quentin Champenois <26109239+Quentinchampenois@users.noreply.github.com> Date: Mon, 17 Mar 2025 15:45:29 +0100 Subject: [PATCH 03/17] fix: error on InvalidResponse --- lib/decidim/ai/spam_detection/strategy/third_party.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/decidim/ai/spam_detection/strategy/third_party.rb b/lib/decidim/ai/spam_detection/strategy/third_party.rb index 590b4fa..12f121d 100644 --- a/lib/decidim/ai/spam_detection/strategy/third_party.rb +++ b/lib/decidim/ai/spam_detection/strategy/third_party.rb @@ -30,7 +30,7 @@ def classify(content) body = res.body system_log("Received response from third party service: #{body}") - raise InvalidEntity, res.error unless res.is_a?(Net::HTTPSuccess) + raise InvalidEntity, res unless res.is_a?(Net::HTTPSuccess) content = third_party_content(body) raise InvalidOutputFormat, "Third party service response isn't valid JSON" unless valid_output_format?(content) @@ -39,7 +39,7 @@ def classify(content) system_log("Spam : #{score}.") score rescue InvalidEntity, InvalidOutputFormat => e - system_log(e.message, level: :error) + system_log(e, level: :error) score end From 8ed61459f600307077fb98ab8b9527ce7a07003e Mon Sep 17 00:00:00 2001 From: Quentin Champenois Date: Thu, 10 Apr 2025 20:45:31 +0200 Subject: [PATCH 04/17] feat(ai): Add Scaleway Third Party strategy --- Gemfile | 2 +- Gemfile.lock | 3 +- .../ai/spam_detection/strategy/scaleway.rb | 39 +++++++++++++++++++ .../ai/spam_detection/strategy/third_party.rb | 4 +- .../strategy/third_party_spec.rb | 1 - 5 files changed, 44 insertions(+), 5 deletions(-) diff --git a/Gemfile b/Gemfile index 41f1bd3..2e4b54e 100644 --- a/Gemfile +++ b/Gemfile @@ -15,7 +15,7 @@ gem "decidim-proposals", "~> 0.29" gem "bootsnap", "~> 1.4" gem "puma", ">= 6.3" - +gem "uri", "1.0.3" group :development, :test do gem "byebug", "~> 11.0", platform: :mri diff --git a/Gemfile.lock b/Gemfile.lock index 86b55a7..ecb0394 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -758,7 +758,7 @@ GEM uber (0.1.0) unicode-display_width (2.6.0) uniform_notifier (1.16.0) - uri (1.0.2) + uri (1.0.3) valid_email2 (4.0.6) activemodel (>= 3.2) mail (~> 2.5) @@ -821,6 +821,7 @@ DEPENDENCIES rubocop-faker rubocop-performance simplecov + uri (= 1.0.3) web-console (~> 4.2) RUBY VERSION diff --git a/lib/decidim/ai/spam_detection/strategy/scaleway.rb b/lib/decidim/ai/spam_detection/strategy/scaleway.rb index e2311bb..64e1d8f 100644 --- a/lib/decidim/ai/spam_detection/strategy/scaleway.rb +++ b/lib/decidim/ai/spam_detection/strategy/scaleway.rb @@ -7,12 +7,51 @@ module Strategy # Scaleway third-party strategy # doc: https://www.scaleway.com/en/docs/managed-inference/quickstart/ class Scaleway < ThirdParty + # classify calls the third party AI system to classify content + # @param content [String] Content to classify + # @param klass [String] Stringified klass of reportable + # @return Integer + def classify(content, klass) + system_log("Starting classification...") + res = third_party_request(content, klass) + body = res.body + + system_log("Received response from third party service: #{body}") + raise InvalidEntity, res.error unless res.is_a?(Net::HTTPSuccess) + + content = third_party_content(body) + raise InvalidOutputFormat, "Third party service response isn't valid JSON" unless valid_output_format?(content) + + @category = content.downcase + system_log(score == 1 ? "SPAM" : "NOT_SPAM") + score + rescue InvalidEntity, InvalidOutputFormat => e + system_log(e.message, level: :error) + score + end + + def third_party_request(content, klass) + uri = URI(@endpoint) + payload = payload(content, klass).to_json + system_log("Sending request to third party service: #{payload}") + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.post(uri.path, payload, headers) + end + def third_party_content(body) return [] if body.blank? choices = JSON.parse(body)&.fetch("choices", []) choices.first&.dig("message", "content") end + + def payload(content, klass) + { + content:, + type: klass + } + end end end end diff --git a/lib/decidim/ai/spam_detection/strategy/third_party.rb b/lib/decidim/ai/spam_detection/strategy/third_party.rb index 12f121d..932d141 100644 --- a/lib/decidim/ai/spam_detection/strategy/third_party.rb +++ b/lib/decidim/ai/spam_detection/strategy/third_party.rb @@ -26,7 +26,7 @@ def log def classify(content) system_log("Starting classification...") - res = request(content) + res = third_party_request(content) body = res.body system_log("Received response from third party service: #{body}") @@ -43,7 +43,7 @@ def classify(content) score end - def request(content) + def third_party_request(content) uri = URI(@endpoint) payload = payload(content).to_json system_log("Sending request to third party service: #{payload}") diff --git a/spec/lib/decidim/ai/spam_detection/strategy/third_party_spec.rb b/spec/lib/decidim/ai/spam_detection/strategy/third_party_spec.rb index 93bc9bf..fb50dc6 100644 --- a/spec/lib/decidim/ai/spam_detection/strategy/third_party_spec.rb +++ b/spec/lib/decidim/ai/spam_detection/strategy/third_party_spec.rb @@ -100,7 +100,6 @@ before do allow(URI).to receive(:parse).and_return(uri_double) allow(Net::HTTP).to receive(:new).and_return(http_double) - # allow(http_double).to receive(:use_ssl=) allow(http_double).to receive(:post).and_return(double("Net::HTTPResponse", body: '{"category": "NOT_SPAM"}')) end From 1bfebe6a3a6420f9b29b8072c3bdac718f4ed961 Mon Sep 17 00:00:00 2001 From: Quentin Champenois Date: Fri, 11 Apr 2025 14:08:09 +0200 Subject: [PATCH 05/17] fix(scaleway): Allow to refers reportable class --- .../ai/spam_detection/spam_detection.rb | 1 + .../ai/spam_detection/strategy/scaleway.rb | 12 +++- .../ai/spam_detection/third_party_service.rb | 22 +++++++ .../spam_detection/strategy/scaleway_spec.rb | 42 +++++++++---- .../strategy/third_party_spec.rb | 7 ++- .../third_party_service_spec.rb | 62 +++++++++++++++++++ 6 files changed, 131 insertions(+), 15 deletions(-) create mode 100644 lib/decidim/ai/spam_detection/third_party_service.rb create mode 100644 spec/lib/decidim/ai/spam_detection/third_party_service_spec.rb diff --git a/lib/decidim/ai/spam_detection/spam_detection.rb b/lib/decidim/ai/spam_detection/spam_detection.rb index c38a6ed..e762429 100644 --- a/lib/decidim/ai/spam_detection/spam_detection.rb +++ b/lib/decidim/ai/spam_detection/spam_detection.rb @@ -6,6 +6,7 @@ module SpamDetection include ActiveSupport::Configurable autoload :Service, "decidim/ai/spam_detection/service" + autoload :ThirdPartyService, "decidim/ai/spam_detection/third_party_service" module Resource autoload :Base, "decidim/ai/spam_detection/resource/base" diff --git a/lib/decidim/ai/spam_detection/strategy/scaleway.rb b/lib/decidim/ai/spam_detection/strategy/scaleway.rb index 64e1d8f..34ca23a 100644 --- a/lib/decidim/ai/spam_detection/strategy/scaleway.rb +++ b/lib/decidim/ai/spam_detection/strategy/scaleway.rb @@ -17,7 +17,7 @@ def classify(content, klass) body = res.body system_log("Received response from third party service: #{body}") - raise InvalidEntity, res.error unless res.is_a?(Net::HTTPSuccess) + raise InvalidEntity, res.error unless res.code == Net::HTTPSuccess content = third_party_content(body) raise InvalidOutputFormat, "Third party service response isn't valid JSON" unless valid_output_format?(content) @@ -48,10 +48,18 @@ def third_party_content(body) def payload(content, klass) { - content:, + text: content, type: klass } end + + def headers + @headers ||= { + "X-Auth-Token" => @secret, + "Content-Type" => "application/json", + "Accept" => "application/json" + } + end end end end diff --git a/lib/decidim/ai/spam_detection/third_party_service.rb b/lib/decidim/ai/spam_detection/third_party_service.rb new file mode 100644 index 0000000..5b7c3ec --- /dev/null +++ b/lib/decidim/ai/spam_detection/third_party_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + class ThirdPartyService < Decidim::Ai::SpamDetection::Service + # classify calls the third party AI system to classify content + # @param text [String] Content to classify + # @param klass [String] Stringified klass of reportable + # @return nil + def classify(text, klass = "") + text = formatter.cleanup(text) + return if text.blank? + + @registry.each do |strategy| + strategy.classify(text, klass) + end + end + end + end + end +end diff --git a/spec/lib/decidim/ai/spam_detection/strategy/scaleway_spec.rb b/spec/lib/decidim/ai/spam_detection/strategy/scaleway_spec.rb index 5ec1f0f..97b1b84 100644 --- a/spec/lib/decidim/ai/spam_detection/strategy/scaleway_spec.rb +++ b/spec/lib/decidim/ai/spam_detection/strategy/scaleway_spec.rb @@ -6,17 +6,11 @@ let(:strategy) { described_class.new(options) } let(:endpoint) { "https://example.com/api" } let(:secret) { "secret_key" } + let(:klass) { "Decidim::Proposals::Proposal" } let(:options) do { endpoint:, - secret:, - model: "model_name", - system_message: "System message", - max_tokens: 100, - temperature: 0.7, - top_p: 0.9, - presence_penalty: 0, - stream: false + secret: } end @@ -69,14 +63,19 @@ end describe "#classify" do - let(:response_double) { double("Net::HTTPResponse", body: '{"choices": [{"message": {"content": "NOT_SPAM"}}]}', is_a?: true) } + let(:response_double) { double(Net::HTTPResponse, body: '{"choices": [{"message": {"content": "NOT_SPAM"}}]}', is_a?: true, error: "Error message") } + let(:uri_double) { double(URI, host: "example.com", port: 443, path: "/api", method: :POST) } + let(:http_double) { double(Net::HTTP, :use_ssl= => true) } before do + allow(URI).to receive(:parse).and_return(uri_double) + allow(Net::HTTP).to receive(:new).and_return(http_double) + allow(http_double).to receive(:post).and_return(double(Net::HTTPResponse, code: Net::HTTPSuccess, body: '{"category": "NOT_SPAM"}')) allow(strategy).to receive(:request).and_return(response_double) end it "classifies content as not spam" do - expect(strategy.classify("Test content")).to eq(0) + expect(strategy.classify("Test content", klass)).to eq(0) end context "when response is invalid" do @@ -86,7 +85,7 @@ end it "raises InvalidEntity error" do - expect { strategy.classify("Test content") }.not_to raise_error(Decidim::Ai::SpamDetection::Strategy::ThirdParty::InvalidEntity) + expect { strategy.classify("Test content", klass) }.not_to raise_error(Decidim::Ai::SpamDetection::Strategy::ThirdParty::InvalidEntity) expect(strategy.instance_variable_get(:@score)).to eq(0) end end @@ -95,9 +94,28 @@ before { allow(strategy).to receive(:valid_output_format?).and_return(false) } it "raises InvalidOutputFormat error" do - expect { strategy.classify("Test content") }.not_to raise_error(Decidim::Ai::SpamDetection::Strategy::ThirdParty::InvalidOutputFormat) + expect { strategy.classify("Test content", klass) }.not_to raise_error(Decidim::Ai::SpamDetection::Strategy::ThirdParty::InvalidOutputFormat) expect(strategy.instance_variable_get(:@score)).to eq(0) end end end + + describe "#headers" do + it "returns the correct headers" do + expect(strategy.headers).to eq( + "X-Auth-Token" => "secret_key", + "Content-Type" => "application/json", + "Accept" => "application/json" + ) + end + end + + describe "#payload" do + it "returns the correct payload" do + expect(strategy.payload("Test content", "Decidim::Proposals::Proposal")).to eq({ + text: "Test content", + type: "Decidim::Proposals::Proposal" + }) + end + end end diff --git a/spec/lib/decidim/ai/spam_detection/strategy/third_party_spec.rb b/spec/lib/decidim/ai/spam_detection/strategy/third_party_spec.rb index fb50dc6..9559bb8 100644 --- a/spec/lib/decidim/ai/spam_detection/strategy/third_party_spec.rb +++ b/spec/lib/decidim/ai/spam_detection/strategy/third_party_spec.rb @@ -21,6 +21,11 @@ end let(:content) { "Test contribution input." } + before do + stub_request(:post, "https://example.com/api") + .to_return(status: 200, body: "", headers: {}) + end + describe "#initialize" do it "initializes with options" do expect(strategy.instance_variable_get(:@endpoint)).to eq(endpoint) @@ -105,7 +110,7 @@ it "sends a request to the third-party service" do expect(http_double).to receive(:post).with("/api", anything, anything) - strategy.request(content) + strategy.third_party_request(content) end end diff --git a/spec/lib/decidim/ai/spam_detection/third_party_service_spec.rb b/spec/lib/decidim/ai/spam_detection/third_party_service_spec.rb new file mode 100644 index 0000000..a80f3a6 --- /dev/null +++ b/spec/lib/decidim/ai/spam_detection/third_party_service_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe Decidim::Ai::SpamDetection::ThirdPartyService do + subject { described_class.new(registry:) } + + let(:registry) { Decidim::Ai::SpamDetection.resource_registry } + let(:base_strategy) { { name: :base, strategy: Decidim::Ai::SpamDetection::Strategy::Base } } + let(:dummy_strategy) { { name: :dummy, strategy: Decidim::Ai::SpamDetection::Strategy::Base } } + + before do + registry.clear + registry.register_analyzer(**base_strategy) + registry.register_analyzer(**dummy_strategy) + end + + describe "train" do + it "trains all the strategies" do + expect(registry.for(:base)).to receive(:train).with(:spam, "text") + expect(registry.for(:dummy)).to receive(:train).with(:spam, "text") + + subject.train(:spam, "text") + end + end + + describe "untrain" do + it "untrains all the strategies" do + expect(registry.for(:base)).to receive(:untrain).with(:spam, "text") + expect(registry.for(:dummy)).to receive(:untrain).with(:spam, "text") + + subject.untrain(:spam, "text") + end + end + + describe "classify" do + it "classifies using all strategies" do + expect(registry.for(:base)).to receive(:classify).with("text", "Decidim::Proposals::Proposal") + expect(registry.for(:dummy)).to receive(:classify).with("text", "Decidim::Proposals::Proposal") + + subject.classify("text", "Decidim::Proposals::Proposal") + end + end + + describe "classification_log" do + it "returns the log of all strategies" do + allow(registry.for(:base)).to receive(:log).and_return("base log") + allow(registry.for(:dummy)).to receive(:log).and_return("dummy log") + + expect(subject.classification_log).to eq("base log\ndummy log") + end + end + + describe "score" do + it "returns the average score of all strategies" do + allow(registry.for(:base)).to receive(:score).and_return(1) + allow(registry.for(:dummy)).to receive(:score).and_return(0) + + expect(subject.score).to eq(0.5) + end + end +end From 06ce952d0612404061f39771280e2502352e1a5d Mon Sep 17 00:00:00 2001 From: Quentin Champenois Date: Fri, 11 Apr 2025 14:21:51 +0200 Subject: [PATCH 06/17] fix(scw): Third party service --- .../ai/spam_detection/strategy/scaleway.rb | 16 ++++++++++------ .../ai/spam_detection/third_party_service.rb | 4 ++-- .../ai/spam_detection/strategy/scaleway_spec.rb | 15 +++++++++------ .../spam_detection/third_party_service_spec.rb | 6 +++--- 4 files changed, 24 insertions(+), 17 deletions(-) diff --git a/lib/decidim/ai/spam_detection/strategy/scaleway.rb b/lib/decidim/ai/spam_detection/strategy/scaleway.rb index 34ca23a..a18640c 100644 --- a/lib/decidim/ai/spam_detection/strategy/scaleway.rb +++ b/lib/decidim/ai/spam_detection/strategy/scaleway.rb @@ -9,11 +9,12 @@ module Strategy class Scaleway < ThirdParty # classify calls the third party AI system to classify content # @param content [String] Content to classify + # @param organization_host [String] Decidim host # @param klass [String] Stringified klass of reportable # @return Integer - def classify(content, klass) + def classify(content, organization_host, klass) system_log("Starting classification...") - res = third_party_request(content, klass) + res = third_party_request(content, organization_host, klass) body = res.body system_log("Received response from third party service: #{body}") @@ -30,13 +31,14 @@ def classify(content, klass) score end - def third_party_request(content, klass) + def third_party_request(content, organization_host, klass) uri = URI(@endpoint) payload = payload(content, klass).to_json system_log("Sending request to third party service: #{payload}") http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true - http.post(uri.path, payload, headers) + http.headers = headers(organization_host) + http.post(uri.path, payload) end def third_party_content(body) @@ -53,11 +55,13 @@ def payload(content, klass) } end - def headers + def headers(organization_host) @headers ||= { "X-Auth-Token" => @secret, "Content-Type" => "application/json", - "Accept" => "application/json" + "Accept" => "application/json", + "Host" => organization_host, + "Decidim" => organization_host } end end diff --git a/lib/decidim/ai/spam_detection/third_party_service.rb b/lib/decidim/ai/spam_detection/third_party_service.rb index 5b7c3ec..e0aa8bc 100644 --- a/lib/decidim/ai/spam_detection/third_party_service.rb +++ b/lib/decidim/ai/spam_detection/third_party_service.rb @@ -8,12 +8,12 @@ class ThirdPartyService < Decidim::Ai::SpamDetection::Service # @param text [String] Content to classify # @param klass [String] Stringified klass of reportable # @return nil - def classify(text, klass = "") + def classify(text, organization_host = "", klass = "") text = formatter.cleanup(text) return if text.blank? @registry.each do |strategy| - strategy.classify(text, klass) + strategy.classify(text, organization_host, klass) end end end diff --git a/spec/lib/decidim/ai/spam_detection/strategy/scaleway_spec.rb b/spec/lib/decidim/ai/spam_detection/strategy/scaleway_spec.rb index 97b1b84..8bf3ce8 100644 --- a/spec/lib/decidim/ai/spam_detection/strategy/scaleway_spec.rb +++ b/spec/lib/decidim/ai/spam_detection/strategy/scaleway_spec.rb @@ -70,12 +70,13 @@ before do allow(URI).to receive(:parse).and_return(uri_double) allow(Net::HTTP).to receive(:new).and_return(http_double) - allow(http_double).to receive(:post).and_return(double(Net::HTTPResponse, code: Net::HTTPSuccess, body: '{"category": "NOT_SPAM"}')) + allow(http_double).to receive(:post).and_return(double(Net::HTTPResponse, code: Net::HTTPSuccess, body: '{"choices": [{"message": {"content": "NOT_SPAM"}}]}')) + allow(http_double).to receive(:headers=).and_return({ "Accept" => "application/json", "Content-Type" => "application/json", "Decidim" => "decidim.example.org", "Host" => "decidim.example.org", "X-Auth-Token" => "secret_key" }) allow(strategy).to receive(:request).and_return(response_double) end it "classifies content as not spam" do - expect(strategy.classify("Test content", klass)).to eq(0) + expect(strategy.classify("Test content", "decidim.example.org", klass)).to eq(0) end context "when response is invalid" do @@ -85,7 +86,7 @@ end it "raises InvalidEntity error" do - expect { strategy.classify("Test content", klass) }.not_to raise_error(Decidim::Ai::SpamDetection::Strategy::ThirdParty::InvalidEntity) + expect { strategy.classify("Test content", "decidim.example.org", klass) }.not_to raise_error(Decidim::Ai::SpamDetection::Strategy::ThirdParty::InvalidEntity) expect(strategy.instance_variable_get(:@score)).to eq(0) end end @@ -94,7 +95,7 @@ before { allow(strategy).to receive(:valid_output_format?).and_return(false) } it "raises InvalidOutputFormat error" do - expect { strategy.classify("Test content", klass) }.not_to raise_error(Decidim::Ai::SpamDetection::Strategy::ThirdParty::InvalidOutputFormat) + expect { strategy.classify("Test content", "decidim.example.org", klass) }.not_to raise_error(Decidim::Ai::SpamDetection::Strategy::ThirdParty::InvalidOutputFormat) expect(strategy.instance_variable_get(:@score)).to eq(0) end end @@ -102,10 +103,12 @@ describe "#headers" do it "returns the correct headers" do - expect(strategy.headers).to eq( + expect(strategy.headers("decidim.example.org")).to eq( "X-Auth-Token" => "secret_key", "Content-Type" => "application/json", - "Accept" => "application/json" + "Accept" => "application/json", + "Host" => "decidim.example.org", + "Decidim" => "decidim.example.org" ) end end diff --git a/spec/lib/decidim/ai/spam_detection/third_party_service_spec.rb b/spec/lib/decidim/ai/spam_detection/third_party_service_spec.rb index a80f3a6..e380ed3 100644 --- a/spec/lib/decidim/ai/spam_detection/third_party_service_spec.rb +++ b/spec/lib/decidim/ai/spam_detection/third_party_service_spec.rb @@ -35,10 +35,10 @@ describe "classify" do it "classifies using all strategies" do - expect(registry.for(:base)).to receive(:classify).with("text", "Decidim::Proposals::Proposal") - expect(registry.for(:dummy)).to receive(:classify).with("text", "Decidim::Proposals::Proposal") + expect(registry.for(:base)).to receive(:classify).with("text", "decidim.example.org", "Decidim::Proposals::Proposal") + expect(registry.for(:dummy)).to receive(:classify).with("text", "decidim.example.org", "Decidim::Proposals::Proposal") - subject.classify("text", "Decidim::Proposals::Proposal") + subject.classify("text", "decidim.example.org", "Decidim::Proposals::Proposal") end end From 281e091ba468e130488e92eda572c231675cb968 Mon Sep 17 00:00:00 2001 From: Quentin Champenois Date: Fri, 11 Apr 2025 14:42:09 +0200 Subject: [PATCH 07/17] fix(jobs): Add third party obs --- .../third_party/generic_spam_analyzer_job.rb | 29 +++++++++++++ .../third_party/user_spam_analyzer_job.rb | 22 ++++++++++ lib/decidim/ai/engine.rb | 42 +++++++++++-------- .../ai/spam_detection/spam_detection.rb | 10 +++++ 4 files changed, 86 insertions(+), 17 deletions(-) create mode 100644 app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb create mode 100644 app/jobs/decidim/ai/spam_detection/third_party/user_spam_analyzer_job.rb diff --git a/app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb b/app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb new file mode 100644 index 0000000..3c83e56 --- /dev/null +++ b/app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + module ThirdParty + class GenericSpamAnalyzerJob < Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob + def perform(reportable, author, locale, fields) + @author = author + organization_host = reportable.organization.host + klass = reportable.class.to_s + overall_score = I18n.with_locale(locale) do + fields.map do |field| + classifier.classify(translated_attribute(reportable.send(field)), organization_host, klass) + 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 + end + end + end + end +end diff --git a/app/jobs/decidim/ai/spam_detection/third_party/user_spam_analyzer_job.rb b/app/jobs/decidim/ai/spam_detection/third_party/user_spam_analyzer_job.rb new file mode 100644 index 0000000..bd96eac --- /dev/null +++ b/app/jobs/decidim/ai/spam_detection/third_party/user_spam_analyzer_job.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Decidim + module Ai + module SpamDetection + module ThirdParty + class UserSpamAnalyzerJob < Decidim::Ai::SpamDetection::UserSpamAnalyzerJob + def perform(reportable) + @author = reportable + organization_host = reportable.organization.host + klass = reportable.class.to_s + classifier.classify(reportable.about, organization_host, klass) + + return unless classifier.score >= Decidim::Ai::SpamDetection.user_score_threshold + + Decidim::CreateUserReport.call(form, reportable) + end + end + end + end + end +end diff --git a/lib/decidim/ai/engine.rb b/lib/decidim/ai/engine.rb index 0c6b593..a08a862 100644 --- a/lib/decidim/ai/engine.rb +++ b/lib/decidim/ai/engine.rb @@ -30,13 +30,13 @@ class Engine < ::Rails::Engine initializer "decidim_ai.events.subscribe_profile" do config.to_prepare do Decidim::EventsManager.subscribe("decidim.update_account:after") do |_event_name, data| - Decidim::Ai::SpamDetection::UserSpamAnalyzerJob.perform_later(data[:resource]) + Decidim::Ai::SpamDetection.user_spam_analyzer_job.constantize.perform_later(data[:resource]) end Decidim::EventsManager.subscribe("decidim.update_user_group:after") do |_event_name, data| - Decidim::Ai::SpamDetection::UserSpamAnalyzerJob.perform_later(data[:resource]) + Decidim::Ai::SpamDetection.user_spam_analyzer_job.constantize.perform_later(data[:resource]) end Decidim::EventsManager.subscribe("decidim.create_user_group:after") do |_event_name, data| - Decidim::Ai::SpamDetection::UserSpamAnalyzerJob.perform_later(data[:resource]) + Decidim::Ai::SpamDetection.user_spam_analyzer_job.constantize.perform_later(data[:resource]) end end end @@ -44,10 +44,10 @@ class Engine < ::Rails::Engine initializer "decidim_ai.events.subscribe_comments" do config.to_prepare do ActiveSupport::Notifications.subscribe("decidim.comments.create_comment:after") do |_event_name, data| - Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body]) + Decidim::Ai::SpamDetection.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body]) end ActiveSupport::Notifications.subscribe("decidim.comments.update_comment:after") do |_event_name, data| - Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body]) + Decidim::Ai::SpamDetection.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body]) end end end @@ -55,12 +55,12 @@ class Engine < ::Rails::Engine initializer "decidim_ai.events.subscribe_meeting" do config.to_prepare do ActiveSupport::Notifications.subscribe("decidim.meetings.create_meeting:after") do |_event_name, data| - Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), - [:description, :title, :location_hints, :registration_terms]) + Decidim::Ai::SpamDetection.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), + [:description, :title, :location_hints, :registration_terms]) end ActiveSupport::Notifications.subscribe("decidim.meetings.update_meeting:after") do |_event_name, data| - Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), - [:description, :title, :location_hints, :registration_terms]) + Decidim::Ai::SpamDetection.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), + [:description, :title, :location_hints, :registration_terms]) end end end @@ -68,10 +68,12 @@ class Engine < ::Rails::Engine initializer "decidim_ai.events.subscribe_debate" do config.to_prepare do ActiveSupport::Notifications.subscribe("decidim.debates.create_debate:after") do |_event_name, data| - Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:description, :title]) + Decidim::Ai::SpamDetection.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), + [:description, :title]) end ActiveSupport::Notifications.subscribe("decidim.debates.update_debate:after") do |_event_name, data| - Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:description, :title]) + Decidim::Ai::SpamDetection.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), + [:description, :title]) end end end @@ -79,10 +81,12 @@ class Engine < ::Rails::Engine initializer "decidim_ai.events.subscribe_initiatives" do config.to_prepare do ActiveSupport::Notifications.subscribe("decidim.initiatives.create_initiative:after") do |_event_name, data| - Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:description, :title]) + Decidim::Ai::SpamDetection.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), + [:description, :title]) end ActiveSupport::Notifications.subscribe("decidim.initiatives.update_initiative:after") do |_event_name, data| - Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:description, :title]) + Decidim::Ai::SpamDetection.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), + [:description, :title]) end end end @@ -90,16 +94,20 @@ class Engine < ::Rails::Engine initializer "decidim_ai.events.subscribe_proposals" do config.to_prepare do ActiveSupport::Notifications.subscribe("decidim.proposals.create_proposal:after") do |_event_name, data| - Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body, :title]) + Decidim::Ai::SpamDetection.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), + [:body, :title]) end ActiveSupport::Notifications.subscribe("decidim.proposals.update_proposal:after") do |_event_name, data| - Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body, :title]) + Decidim::Ai::SpamDetection.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), + [:body, :title]) end ActiveSupport::Notifications.subscribe("decidim.proposals.create_collaborative_draft:after") do |_event_name, data| - Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body, :title]) + Decidim::Ai::SpamDetection.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), + [:body, :title]) end ActiveSupport::Notifications.subscribe("decidim.proposals.update_collaborative_draft:after") do |_event_name, data| - Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), [:body, :title]) + Decidim::Ai::SpamDetection.generic_spam_analyzer_job.constantize.perform_later(data[:resource], data.dig(:extra, :event_author), data.dig(:extra, :locale), + [:body, :title]) end end end diff --git a/lib/decidim/ai/spam_detection/spam_detection.rb b/lib/decidim/ai/spam_detection/spam_detection.rb index e762429..9461efc 100644 --- a/lib/decidim/ai/spam_detection/spam_detection.rb +++ b/lib/decidim/ai/spam_detection/spam_detection.rb @@ -155,6 +155,16 @@ module Strategy "Decidim::Ai::SpamDetection::Service" end + # User spam analyzer job class. + config_accessor :user_spam_analyzer_job do + "Decidim::Ai::SpamDetection::UserSpamAnalyzerJob" + end + + # User spam analyzer job class. + config_accessor :generic_spam_analyzer_job do + "Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob" + end + # this is the generic resource classifier class. If you need to change your own class, please change the # configuration of `Decidim::Ai::SpamDetection.detection_service` variable. def self.resource_classifier From 691306430e9012e525c0e0a6289fb527afbddd35 Mon Sep 17 00:00:00 2001 From: Quentin Champenois Date: Fri, 11 Apr 2025 14:54:23 +0200 Subject: [PATCH 08/17] chore: Update documentation --- README.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 2985d42..22edc6e 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,8 @@ To use the third-party service, you need to add the following configuration to y if Decidim.module_installed?(:ai) Decidim::Ai::SpamDetection.user_analyzers = [ { - name: :bayes, - strategy: Decidim::Ai::SpamDetection::Strategy::Scaleway, + name: :third_party, + strategy: Decidim::Ai::SpamDetection::Strategy::ThirdParty, options: { model: Rails.application.secrets.ai.model, endpoint: Rails.application.secrets.ai.endpoint, @@ -74,15 +74,15 @@ Add secrets to your `config/secrets.yml` file: default: &default decidim: ai: - model: ENV.fetch("SCW_AI_MODEL", "deepseek-r1-distill-llama-70b") - endpoint: ENV.fetch("SCW_AI_ENDPOINT") - secret: ENV.fetch("SCW_SECRET") - max_tokens: ENV.fetch("SCW_MAX_TOKENS", "100")&.to_i - temperature: ENV.fetch("SCW_MAX_TEMPERATURE", "0.7")&.to_f - top_p: ENV.fetch("SCW_TOP_P", "1")&.to_i - presence_penalty: ENV.fetch("SCW_PRESENCE_PENALTY", "0")&.to_i - stream: ENV.fetch("SCW_STREAM", "false") == "true" - system_message: ENV.fetch("SCW_SYSTEM_MESSAGE", "You are an expert content moderator for participatory democracy platforms like Decidim. Your task is to classify user-submitted content in any language as either legitimate civic participation or spam.") + model: ENV.fetch("DECIDIM_AI_MODEL", "deepseek-r1-distill-llama-70b") + endpoint: ENV.fetch("DECIDIM_AI_ENDPOINT") + secret: ENV.fetch("DECIDIM_AI_SECRET") + max_tokens: ENV.fetch("DECIDIM_AI_MAX_TOKENS", "100")&.to_i + temperature: ENV.fetch("DECIDIM_AI_MAX_TEMPERATURE", "0.7")&.to_f + top_p: ENV.fetch("DECIDIM_AI_TOP_P", "1")&.to_i + presence_penalty: ENV.fetch("DECIDIM_AI_PRESENCE_PENALTY", "0")&.to_i + stream: ENV.fetch("DECIDIM_AI_STREAM", "false") == "true" + system_message: ENV.fetch("DECIDIM_AI_SYSTEM_MESSAGE", "You are an expert content moderator for participatory democracy platforms like Decidim. Your task is to classify user-submitted content in any language as either legitimate civic participation or spam.") ``` ## Contributing From 4aa5328df623bbf9fa91f204ae28a476b79da887 Mon Sep 17 00:00:00 2001 From: Quentin Champenois Date: Tue, 10 Jun 2025 15:02:31 +0200 Subject: [PATCH 09/17] fix: Scaleway third party service --- .../third_party/generic_spam_analyzer_job.rb | 2 + examples/decidim_ai_scaleway.rb | 54 +++++++++++++++++++ .../ai/spam_detection/strategy/scaleway.rb | 39 +++++++------- .../ai/spam_detection/strategy/third_party.rb | 13 +++-- .../spam_detection/strategy/scaleway_spec.rb | 4 +- 5 files changed, 87 insertions(+), 25 deletions(-) create mode 100644 examples/decidim_ai_scaleway.rb diff --git a/app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb b/app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb index 3c83e56..7648f07 100644 --- a/app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb +++ b/app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb @@ -21,6 +21,8 @@ def perform(reportable, author, locale, fields) return unless overall_score >= Decidim::Ai::SpamDetection.resource_score_threshold Decidim::CreateReport.call(form, reportable) + rescue StandardError => e + Rails.logger.error "Error in GenericSpamAnalyzerJob: #{e.message}" end end end diff --git a/examples/decidim_ai_scaleway.rb b/examples/decidim_ai_scaleway.rb new file mode 100644 index 0000000..b42c070 --- /dev/null +++ b/examples/decidim_ai_scaleway.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +if Decidim.module_installed?(:ai) + if Rails.application.secrets.dig(:decidim, :ai, :endpoint).blank? || Rails.application.secrets.dig(:decidim, :ai, :secret).blank? + Rails.logger.warn "(decidim-ai/initializer)> AI endpoint or secret not configured. AI features will be disabled." + return + end + + opts = { + endpoint: Rails.application.secrets.dig(:decidim, :ai, :endpoint), + secret: Rails.application.secrets.dig(:decidim, :ai, :secret) + } + Decidim::Ai::Language.formatter = "Decidim::Ai::Language::Formatter" + + Decidim::Ai::SpamDetection.reporting_user_email = Rails.application.secrets.dig(:decidim, :ai, :reporting_user_email) + Decidim::Ai::SpamDetection.resource_score_threshold = 0.75 + Decidim::Ai::SpamDetection.resource_analyzers = [ + { + name: :scaleway, + strategy: Decidim::Ai::SpamDetection::Strategy::Scaleway, + options: opts + } + ] + Decidim::Ai::SpamDetection.resource_models = begin + models = {} + models["Decidim::Comments::Comment"] = "Decidim::Ai::SpamDetection::Resource::Comment" if Decidim.module_installed?("comments") + models["Decidim::Debates::Debate"] = "Decidim::Ai::SpamDetection::Resource::Debate" if Decidim.module_installed?("debates") + models["Decidim::Initiative"] = "Decidim::Ai::SpamDetection::Resource::Initiative" if Decidim.module_installed?("initiatives") + models["Decidim::Meetings::Meeting"] = "Decidim::Ai::SpamDetection::Resource::Meeting" if Decidim.module_installed?("meetings") + models["Decidim::Proposals::Proposal"] = "Decidim::Ai::SpamDetection::Resource::Proposal" if Decidim.module_installed?("proposals") + if Decidim.module_installed?("proposals") + models["Decidim::Proposals::CollaborativeDraft"] = + "Decidim::Ai::SpamDetection::Resource::CollaborativeDraft" + end + models + end + + Decidim::Ai::SpamDetection.user_score_threshold = 0.75 # default + Decidim::Ai::SpamDetection.user_analyzers = [ + { + name: :scaleway, + strategy: Decidim::Ai::SpamDetection::Strategy::Scaleway, + options: opts + } + ] + Decidim::Ai::SpamDetection.user_models = { + "Decidim::User" => "Decidim::Ai::SpamDetection::Resource::UserBaseEntity" + } + + Decidim::Ai::SpamDetection.user_detection_service = "Decidim::Ai::SpamDetection::ThirdPartyService" + Decidim::Ai::SpamDetection.resource_detection_service = "Decidim::Ai::SpamDetection::ThirdPartyService" + Decidim::Ai::SpamDetection.user_spam_analyzer_job = "Decidim::Ai::SpamDetection::ThirdParty::UserSpamAnalyzerJob" + Decidim::Ai::SpamDetection.generic_spam_analyzer_job = "Decidim::Ai::SpamDetection::ThirdParty::GenericSpamAnalyzerJob" +end diff --git a/lib/decidim/ai/spam_detection/strategy/scaleway.rb b/lib/decidim/ai/spam_detection/strategy/scaleway.rb index a18640c..e22b5e4 100644 --- a/lib/decidim/ai/spam_detection/strategy/scaleway.rb +++ b/lib/decidim/ai/spam_detection/strategy/scaleway.rb @@ -13,18 +13,17 @@ class Scaleway < ThirdParty # @param klass [String] Stringified klass of reportable # @return Integer def classify(content, organization_host, klass) - system_log("Starting classification...") + system_log("Starting classification with Scaleway's strategy...") res = third_party_request(content, organization_host, klass) - body = res.body + body = JSON.parse(res.body) system_log("Received response from third party service: #{body}") - raise InvalidEntity, res.error unless res.code == Net::HTTPSuccess + raise InvalidEntity, body unless res.is_a? Net::HTTPSuccess content = third_party_content(body) raise InvalidOutputFormat, "Third party service response isn't valid JSON" unless valid_output_format?(content) @category = content.downcase - system_log(score == 1 ? "SPAM" : "NOT_SPAM") score rescue InvalidEntity, InvalidOutputFormat => e system_log(e.message, level: :error) @@ -32,20 +31,32 @@ def classify(content, organization_host, klass) end def third_party_request(content, organization_host, klass) + # TODO: Prevent undefined endpoint uri = URI(@endpoint) + payload = payload(content, klass).to_json system_log("Sending request to third party service: #{payload}") http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true - http.headers = headers(organization_host) - http.post(uri.path, payload) + request = Net::HTTP::Post.new(uri.to_s, "Content-Type" => "application/json", "Accept" => "application/json") + request["X-Auth-Token"] = @secret + request["X-Host"] = organization_host + request["X-Decidim-Host"] = organization_host + request["X-Decidim"] = organization_host + request["Host"] = organization_host + + request.body = payload + + http.request(request) + rescue StandardError => e + system_log("Error during request to Scaleway service: #{e.message}", level: :error) + { "error" => "Error during request to third party service" } end def third_party_content(body) - return [] if body.blank? + return "" if body.blank? - choices = JSON.parse(body)&.fetch("choices", []) - choices.first&.dig("message", "content") + body.fetch("spam", "") end def payload(content, klass) @@ -54,16 +65,6 @@ def payload(content, klass) type: klass } end - - def headers(organization_host) - @headers ||= { - "X-Auth-Token" => @secret, - "Content-Type" => "application/json", - "Accept" => "application/json", - "Host" => organization_host, - "Decidim" => organization_host - } - end end end end diff --git a/lib/decidim/ai/spam_detection/strategy/third_party.rb b/lib/decidim/ai/spam_detection/strategy/third_party.rb index 932d141..0b18c16 100644 --- a/lib/decidim/ai/spam_detection/strategy/third_party.rb +++ b/lib/decidim/ai/spam_detection/strategy/third_party.rb @@ -13,8 +13,8 @@ class InvalidEntity < StandardError; end def initialize(options = {}) super - @endpoint = options[:endpoint] - @secret = options[:secret] + @endpoint = Rails.application.secrets.dig(:decidim, :ai, :endpoint) + @secret = Rails.application.secrets.dig(:decidim, :ai, :secret) @options = options end @@ -82,10 +82,15 @@ def payload(content) end # This method should be implemented by the subclass depending on the third-party service - def third_party_content(body) end + def third_party_content(body) + return [] if body.blank? + + choices = JSON.parse(body)&.fetch("choices", []) + choices.first&.dig("message", "content") + end def score - @score ||= @category.presence == "spam" ? 1 : 0 + @category.presence == "spam" ? 1 : 0 end private diff --git a/spec/lib/decidim/ai/spam_detection/strategy/scaleway_spec.rb b/spec/lib/decidim/ai/spam_detection/strategy/scaleway_spec.rb index 8bf3ce8..da8503e 100644 --- a/spec/lib/decidim/ai/spam_detection/strategy/scaleway_spec.rb +++ b/spec/lib/decidim/ai/spam_detection/strategy/scaleway_spec.rb @@ -63,14 +63,14 @@ end describe "#classify" do - let(:response_double) { double(Net::HTTPResponse, body: '{"choices": [{"message": {"content": "NOT_SPAM"}}]}', is_a?: true, error: "Error message") } + let(:response_double) { double(Net::HTTPResponse, body: '{"choices": [{"message": {"spam": "NOT_SPAM"}}]}', is_a?: true, error: "Error message") } let(:uri_double) { double(URI, host: "example.com", port: 443, path: "/api", method: :POST) } let(:http_double) { double(Net::HTTP, :use_ssl= => true) } before do allow(URI).to receive(:parse).and_return(uri_double) allow(Net::HTTP).to receive(:new).and_return(http_double) - allow(http_double).to receive(:post).and_return(double(Net::HTTPResponse, code: Net::HTTPSuccess, body: '{"choices": [{"message": {"content": "NOT_SPAM"}}]}')) + allow(http_double).to receive(:post).and_return(double(Net::HTTPResponse, code: Net::HTTPSuccess, body: '{"choices": [{"message": {"spam": "NOT_SPAM"}}]}')) allow(http_double).to receive(:headers=).and_return({ "Accept" => "application/json", "Content-Type" => "application/json", "Decidim" => "decidim.example.org", "Host" => "decidim.example.org", "X-Auth-Token" => "secret_key" }) allow(strategy).to receive(:request).and_return(response_double) end From 157161ee45b7342130a3a4c3a0403664192d5486 Mon Sep 17 00:00:00 2001 From: Quentin Champenois Date: Tue, 10 Jun 2025 15:03:02 +0200 Subject: [PATCH 10/17] chore(doc): Add Third party and scaleway documentation --- docs/scaleway.md | 76 ++++++++++++++++++++++++++++++ docs/third_party.md | 58 +++++++++++++++++++++++ examples/decidim_ai_scaleway.rb | 39 +++++++-------- examples/decidim_ai_third_party.rb | 59 +++++++++++++++++++++++ 4 files changed, 211 insertions(+), 21 deletions(-) create mode 100644 docs/scaleway.md create mode 100644 docs/third_party.md create mode 100644 examples/decidim_ai_third_party.rb diff --git a/docs/scaleway.md b/docs/scaleway.md new file mode 100644 index 0000000..6883955 --- /dev/null +++ b/docs/scaleway.md @@ -0,0 +1,76 @@ +# Configure a Scaleway Third Party AI system to detect spam + +## Introduction to Scaleway Strategy + +We've added a Scaleway strategy which inherits from the Third Party Strategy to abstract the AI system configuration to a third party service. It reduces considerably the configuration on the Decidim instance by defining only a endpoint and a secret parameters to connect to the Scaleway AI service. + +### How it works + +The Scaleway strategy uses the Scaleway AI service to analyze content for spam detection. It sends the content to the Scaleway endpoint, which processes it and returns a response indicating whether the content is considered spam or not. + +Outputs expected are JSON objects with the following structure: + +```json +{ + "SPAM": "SPAM" +} +``` +or +```json +{ + "SPAM": "NOT_SPAM" +} +``` + +Every time a contribution is made on Decidim, a POST request is sent to a serverless function endpoint. This endpoint retrieve the corresponding prompt based on the resource being analyzed (e.g., proposal, comment, etc.) and the type of analysis (resource or user). And it performs a POST request to the [Scaleway AI service](https://www.scaleway.com/en/docs/generative-apis/concepts/) with the content to be analyzed, the prompt, and the necessary parameters (temperature, top_p, etc…). + +The whole AI specifications prompts, parameters, etc… are defined in a [Langfuse](https://github.com/langfuse/langfuse) self-hosted instance which allows to get metrics on the AI usage and to improve the prompts over time. + +Every Decidim application connected to this system has the same prompts and parameters, which are defined in the Langfuse instance. This allows for a consistent spam detection experience across all applications using this strategy. + +## Infrastructure + +⚠️ We plan to share the Terraform (OpenTofu) project to deploy the serverless endpoint located at : https://github.com/OpenSourcePolitics/serverless/tree/main/faas_ai/infra + + +## Getting started + +To use a third-party AI system to detect spam, you need to configure the `decidim_ai` gem in your Decidim application. This guide will help you set up the necessary configurations. + +## Configure the AI module + +Define a decidim-ai initializer in your application configuration : `config/initializers/decidim_ai.rb`: + +```ruby +# frozen_string_literal: true + +if Decidim.module_installed?(:ai) + analyzers = [ + { + name: :scaleway, + strategy: Decidim::Ai::SpamDetection::Strategy::Scaleway, + options: { + endpoint: Rails.application.secrets.dig(:decidim, :ai, :endpoint), + secret: Rails.application.secrets.dig(:decidim, :ai, :secret), + } + } + ] + + Decidim::Ai::SpamDetection.resource_analyzers = analyzers + Decidim::Ai::SpamDetection.user_analyzers = analyzers +end +``` + +**A full example of configuration is available at examples/scaleway.rb** + + +Add the secrets to your `config/secrets.yml` file: + +```yaml +decidim: + ai: + endpoint: <%= Decidim::Env.new("DECIDIM_AI_ENDPOINT").to_s %> + secret: <%= Decidim::Env.new("DECIDIM_AI_SECRET").to_s %> +``` + +You can now run your server and start using the Scaleway AI service for spam detection ! \ No newline at end of file diff --git a/docs/third_party.md b/docs/third_party.md new file mode 100644 index 0000000..a49a630 --- /dev/null +++ b/docs/third_party.md @@ -0,0 +1,58 @@ +# Configure a Third Party AI system to detect spam + +## Getting started + +To use a third-party AI system to detect spam, you need to configure the `decidim_ai` gem in your Decidim application. This guide will help you set up the necessary configurations. + +## Configure the AI module + +Define a decidim-ai initializer in your application configuration : `config/initializers/decidim_ai.rb`: + +```ruby +# frozen_string_literal: true + +if Decidim.module_installed?(:ai) + analyzers = [ + { + name: :third_party, + strategy: Decidim::Ai::SpamDetection::Strategy::ThirdParty, + options: { + model: Rails.application.secrets.dig(:decidim, :ai, :model), + endpoint: Rails.application.secrets.dig(:decidim, :ai, :endpoint), + secret: Rails.application.secrets.dig(:decidim, :ai, :secret), + max_tokens: Rails.application.secrets.dig(:decidim, :ai, :max_tokens), + temperature: Rails.application.secrets.dig(:decidim, :ai, :temperature), + top_p: Rails.application.secrets.dig(:decidim, :ai, :top_p), + presence_penalty: Rails.application.secrets.dig(:decidim, :ai, :presence_penalty), + stream: Rails.application.secrets.dig(:decidim, :ai, :stream), + system_message: Rails.application.secrets.dig(:decidim, :ai, :system_message), + reporting_user_email: Rails.application.secrets.dig(:decidim, :ai, :reporting_user_email) + } + } + ] + + Decidim::Ai::SpamDetection.resource_analyzers = analyzers + Decidim::Ai::SpamDetection.user_analyzers = analyzers +end +``` + +**A full example of configuration is available at examples/decidim_ai_third_party.rb** + +Add the secrets to your `config/secrets.yml` file: + +```yaml +decidim: + ai: + model: <%= Decidim::Env.new("DECIDIM_AI_MODEL").to_s %> + endpoint: <%= Decidim::Env.new("DECIDIM_AI_ENDPOINT").to_s %> + secret: <%= Decidim::Env.new("DECIDIM_AI_SECRET").to_s %> + max_tokens: <%= Decidim::Env.new("DECIDIM_AI_MAX_TOKENS").to_i %> + temperature: <%= Decidim::Env.new("DECIDIM_AI_TEMPERATURE").to_f %> + top_p: <%= Decidim::Env.new("DECIDIM_AI_TOP_P").to_i %> + presence_penalty: <%= Decidim::Env.new("DECIDIM_AI_PRESENCE_PENALTY").to_i %> + stream: <%= Decidim::Env.new("DECIDIM_AI_STREAM") == "true" %> + system_message: <%= Decidim::Env.new("DECIDIM_AI_SYSTEM_MESSAGE").to_s %> + reporting_user_email: <%= Decidim::Env.new("DECIDIM_AI_REPORTING_USER_EMAIL").to_s %> +``` + +You can now run your server and start using the third-party AI service for spam detection ! \ No newline at end of file diff --git a/examples/decidim_ai_scaleway.rb b/examples/decidim_ai_scaleway.rb index b42c070..8e79162 100644 --- a/examples/decidim_ai_scaleway.rb +++ b/examples/decidim_ai_scaleway.rb @@ -2,25 +2,16 @@ if Decidim.module_installed?(:ai) if Rails.application.secrets.dig(:decidim, :ai, :endpoint).blank? || Rails.application.secrets.dig(:decidim, :ai, :secret).blank? - Rails.logger.warn "(decidim-ai/initializer)> AI endpoint or secret not configured. AI features will be disabled." + Rails.logger.warn "[decidim-ai] Initializer - AI endpoint or secret not configured. AI features will be disabled." return end - opts = { - endpoint: Rails.application.secrets.dig(:decidim, :ai, :endpoint), - secret: Rails.application.secrets.dig(:decidim, :ai, :secret) - } - Decidim::Ai::Language.formatter = "Decidim::Ai::Language::Formatter" - + # Module configuration Decidim::Ai::SpamDetection.reporting_user_email = Rails.application.secrets.dig(:decidim, :ai, :reporting_user_email) - Decidim::Ai::SpamDetection.resource_score_threshold = 0.75 - Decidim::Ai::SpamDetection.resource_analyzers = [ - { - name: :scaleway, - strategy: Decidim::Ai::SpamDetection::Strategy::Scaleway, - options: opts - } - ] + Decidim::Ai::Language.formatter = "Decidim::Ai::Language::Formatter" + Decidim::Ai::SpamDetection.user_models = { + "Decidim::User" => "Decidim::Ai::SpamDetection::Resource::UserBaseEntity" + } Decidim::Ai::SpamDetection.resource_models = begin models = {} models["Decidim::Comments::Comment"] = "Decidim::Ai::SpamDetection::Resource::Comment" if Decidim.module_installed?("comments") @@ -35,20 +26,26 @@ models end - Decidim::Ai::SpamDetection.user_score_threshold = 0.75 # default - Decidim::Ai::SpamDetection.user_analyzers = [ + # Configuring Scaleway strategy + analyzers = [ { name: :scaleway, strategy: Decidim::Ai::SpamDetection::Strategy::Scaleway, - options: opts + options: { + endpoint: Rails.application.secrets.dig(:decidim, :ai, :endpoint), + secret: Rails.application.secrets.dig(:decidim, :ai, :secret) + } } ] - Decidim::Ai::SpamDetection.user_models = { - "Decidim::User" => "Decidim::Ai::SpamDetection::Resource::UserBaseEntity" - } + Decidim::Ai::SpamDetection.resource_analyzers = analyzers + Decidim::Ai::SpamDetection.user_analyzers = analyzers + + # Configuring Third Party services Decidim::Ai::SpamDetection.user_detection_service = "Decidim::Ai::SpamDetection::ThirdPartyService" Decidim::Ai::SpamDetection.resource_detection_service = "Decidim::Ai::SpamDetection::ThirdPartyService" + + # Configuring Third Party jobs Decidim::Ai::SpamDetection.user_spam_analyzer_job = "Decidim::Ai::SpamDetection::ThirdParty::UserSpamAnalyzerJob" Decidim::Ai::SpamDetection.generic_spam_analyzer_job = "Decidim::Ai::SpamDetection::ThirdParty::GenericSpamAnalyzerJob" end diff --git a/examples/decidim_ai_third_party.rb b/examples/decidim_ai_third_party.rb new file mode 100644 index 0000000..4273cc9 --- /dev/null +++ b/examples/decidim_ai_third_party.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +if Decidim.module_installed?(:ai) + if Rails.application.secrets.dig(:decidim, :ai, :endpoint).blank? || Rails.application.secrets.dig(:decidim, :ai, :secret).blank? + Rails.logger.warn "[decidim-ai] Initializer - AI endpoint or secret not configured. AI features will be disabled." + return + end + + # Module configuration + Decidim::Ai::SpamDetection.reporting_user_email = Rails.application.secrets.dig(:decidim, :ai, :reporting_user_email) + Decidim::Ai::Language.formatter = "Decidim::Ai::Language::Formatter" + Decidim::Ai::SpamDetection.user_models = { + "Decidim::User" => "Decidim::Ai::SpamDetection::Resource::UserBaseEntity" + } + Decidim::Ai::SpamDetection.resource_models = begin + models = {} + models["Decidim::Comments::Comment"] = "Decidim::Ai::SpamDetection::Resource::Comment" if Decidim.module_installed?("comments") + models["Decidim::Debates::Debate"] = "Decidim::Ai::SpamDetection::Resource::Debate" if Decidim.module_installed?("debates") + models["Decidim::Initiative"] = "Decidim::Ai::SpamDetection::Resource::Initiative" if Decidim.module_installed?("initiatives") + models["Decidim::Meetings::Meeting"] = "Decidim::Ai::SpamDetection::Resource::Meeting" if Decidim.module_installed?("meetings") + models["Decidim::Proposals::Proposal"] = "Decidim::Ai::SpamDetection::Resource::Proposal" if Decidim.module_installed?("proposals") + if Decidim.module_installed?("proposals") + models["Decidim::Proposals::CollaborativeDraft"] = + "Decidim::Ai::SpamDetection::Resource::CollaborativeDraft" + end + models + end + + # Configuring Third Party strategy + analyzers = [ + { + name: :third_party, + strategy: Decidim::Ai::SpamDetection::Strategy::ThirdParty, + options: { + model: Rails.application.secrets.dig(:decidim, :ai, :model), + endpoint: Rails.application.secrets.dig(:decidim, :ai, :endpoint), + secret: Rails.application.secrets.dig(:decidim, :ai, :secret), + max_tokens: Rails.application.secrets.dig(:decidim, :ai, :max_tokens), + temperature: Rails.application.secrets.dig(:decidim, :ai, :temperature), + top_p: Rails.application.secrets.dig(:decidim, :ai, :top_p), + presence_penalty: Rails.application.secrets.dig(:decidim, :ai, :presence_penalty), + stream: Rails.application.secrets.dig(:decidim, :ai, :stream), + system_message: Rails.application.secrets.dig(:decidim, :ai, :system_message), + reporting_user_email: Rails.application.secrets.dig(:decidim, :ai, :reporting_user_email) + } + } + ] + + Decidim::Ai::SpamDetection.resource_analyzers = analyzers + Decidim::Ai::SpamDetection.user_analyzers = analyzers + + # Configuring Third Party services + Decidim::Ai::SpamDetection.user_detection_service = "Decidim::Ai::SpamDetection::ThirdPartyService" + Decidim::Ai::SpamDetection.resource_detection_service = "Decidim::Ai::SpamDetection::ThirdPartyService" + + # Configuring Third Party jobs + Decidim::Ai::SpamDetection.user_spam_analyzer_job = "Decidim::Ai::SpamDetection::ThirdParty::UserSpamAnalyzerJob" + Decidim::Ai::SpamDetection.generic_spam_analyzer_job = "Decidim::Ai::SpamDetection::ThirdParty::GenericSpamAnalyzerJob" +end From cdf5be99815e2406257b6715586b65c18d1ad875 Mon Sep 17 00:00:00 2001 From: Quentin Champenois Date: Tue, 10 Jun 2025 15:09:23 +0200 Subject: [PATCH 11/17] chore(doc): Edit README --- README.md | 47 +++-------------------------------------------- 1 file changed, 3 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 22edc6e..b1f8d20 100644 --- a/README.md +++ b/README.md @@ -40,50 +40,9 @@ Add the queue name to `config/sidekiq.yml` file: ## Configure third-party service -To use the third-party service, you need to add the following configuration to your `config/initilizers/decidim_ai.rb` file: - -```ruby - -# frozen_string_literal: true - -if Decidim.module_installed?(:ai) - Decidim::Ai::SpamDetection.user_analyzers = [ - { - name: :third_party, - strategy: Decidim::Ai::SpamDetection::Strategy::ThirdParty, - options: { - model: Rails.application.secrets.ai.model, - endpoint: Rails.application.secrets.ai.endpoint, - secret: Rails.application.secrets.ai.secret, - max_tokens: Rails.application.secrets.ai.max_tokens, - temperature: Rails.application.secrets.ai.temperature, - top_p: Rails.application.secrets.ai.top_p, - presence_penalty: Rails.application.secrets.ai.presence_penalty, - stream: Rails.application.secrets.ai.stream, - system_message: Rails.application.secrets.ai.system_message - } - } - ] -end -``` - -Add secrets to your `config/secrets.yml` file: - -```yaml - -default: &default - decidim: - ai: - model: ENV.fetch("DECIDIM_AI_MODEL", "deepseek-r1-distill-llama-70b") - endpoint: ENV.fetch("DECIDIM_AI_ENDPOINT") - secret: ENV.fetch("DECIDIM_AI_SECRET") - max_tokens: ENV.fetch("DECIDIM_AI_MAX_TOKENS", "100")&.to_i - temperature: ENV.fetch("DECIDIM_AI_MAX_TEMPERATURE", "0.7")&.to_f - top_p: ENV.fetch("DECIDIM_AI_TOP_P", "1")&.to_i - presence_penalty: ENV.fetch("DECIDIM_AI_PRESENCE_PENALTY", "0")&.to_i - stream: ENV.fetch("DECIDIM_AI_STREAM", "false") == "true" - system_message: ENV.fetch("DECIDIM_AI_SYSTEM_MESSAGE", "You are an expert content moderator for participatory democracy platforms like Decidim. Your task is to classify user-submitted content in any language as either legitimate civic participation or spam.") -``` +Documentations and examples are available at : +- [Examples](./examples/) +- [Docs](./docs/) ## Contributing From 93f74f0568a394916ec929a2acbc1f6ea5b9518f Mon Sep 17 00:00:00 2001 From: Quentin Champenois Date: Wed, 11 Jun 2025 15:30:12 +0200 Subject: [PATCH 12/17] fix: Multi-tenant reporting user --- .../generic_spam_analyzer_job.rb | 2 +- .../third_party/generic_spam_analyzer_job.rb | 1 + examples/decidim_ai_scaleway.rb | 24 +++++++++---------- examples/decidim_ai_third_party.rb | 24 +++++++++---------- .../ai/spam_detection/strategy/scaleway.rb | 2 ++ 5 files changed, 28 insertions(+), 25 deletions(-) diff --git a/app/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job.rb b/app/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job.rb index 219af2f..5e34fa0 100644 --- a/app/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job.rb +++ b/app/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job.rb @@ -29,7 +29,7 @@ def form end def reporting_user - @reporting_user ||= Decidim::User.find_by!(email: Decidim::Ai::SpamDetection.reporting_user_email) + @reporting_user ||= Decidim::User.find_by!(email: Decidim::Ai::SpamDetection.reporting_user_email, organization: @author.organization) end end end diff --git a/app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb b/app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb index 7648f07..06a5868 100644 --- a/app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb +++ b/app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb @@ -22,6 +22,7 @@ def perform(reportable, author, locale, fields) Decidim::CreateReport.call(form, reportable) rescue StandardError => e + Rails.logger.error e.backtrace.first(15).join("\n") Rails.logger.error "Error in GenericSpamAnalyzerJob: #{e.message}" end end diff --git a/examples/decidim_ai_scaleway.rb b/examples/decidim_ai_scaleway.rb index 8e79162..94a114c 100644 --- a/examples/decidim_ai_scaleway.rb +++ b/examples/decidim_ai_scaleway.rb @@ -13,18 +13,18 @@ "Decidim::User" => "Decidim::Ai::SpamDetection::Resource::UserBaseEntity" } Decidim::Ai::SpamDetection.resource_models = begin - models = {} - models["Decidim::Comments::Comment"] = "Decidim::Ai::SpamDetection::Resource::Comment" if Decidim.module_installed?("comments") - models["Decidim::Debates::Debate"] = "Decidim::Ai::SpamDetection::Resource::Debate" if Decidim.module_installed?("debates") - models["Decidim::Initiative"] = "Decidim::Ai::SpamDetection::Resource::Initiative" if Decidim.module_installed?("initiatives") - models["Decidim::Meetings::Meeting"] = "Decidim::Ai::SpamDetection::Resource::Meeting" if Decidim.module_installed?("meetings") - models["Decidim::Proposals::Proposal"] = "Decidim::Ai::SpamDetection::Resource::Proposal" if Decidim.module_installed?("proposals") - if Decidim.module_installed?("proposals") - models["Decidim::Proposals::CollaborativeDraft"] = - "Decidim::Ai::SpamDetection::Resource::CollaborativeDraft" - end - models - end + models = {} + models["Decidim::Comments::Comment"] = "Decidim::Ai::SpamDetection::Resource::Comment" if Decidim.module_installed?("comments") + models["Decidim::Debates::Debate"] = "Decidim::Ai::SpamDetection::Resource::Debate" if Decidim.module_installed?("debates") + models["Decidim::Initiative"] = "Decidim::Ai::SpamDetection::Resource::Initiative" if Decidim.module_installed?("initiatives") + models["Decidim::Meetings::Meeting"] = "Decidim::Ai::SpamDetection::Resource::Meeting" if Decidim.module_installed?("meetings") + models["Decidim::Proposals::Proposal"] = "Decidim::Ai::SpamDetection::Resource::Proposal" if Decidim.module_installed?("proposals") + if Decidim.module_installed?("proposals") + models["Decidim::Proposals::CollaborativeDraft"] = + "Decidim::Ai::SpamDetection::Resource::CollaborativeDraft" + end + models + end # Configuring Scaleway strategy analyzers = [ diff --git a/examples/decidim_ai_third_party.rb b/examples/decidim_ai_third_party.rb index 4273cc9..a8a6580 100644 --- a/examples/decidim_ai_third_party.rb +++ b/examples/decidim_ai_third_party.rb @@ -13,18 +13,18 @@ "Decidim::User" => "Decidim::Ai::SpamDetection::Resource::UserBaseEntity" } Decidim::Ai::SpamDetection.resource_models = begin - models = {} - models["Decidim::Comments::Comment"] = "Decidim::Ai::SpamDetection::Resource::Comment" if Decidim.module_installed?("comments") - models["Decidim::Debates::Debate"] = "Decidim::Ai::SpamDetection::Resource::Debate" if Decidim.module_installed?("debates") - models["Decidim::Initiative"] = "Decidim::Ai::SpamDetection::Resource::Initiative" if Decidim.module_installed?("initiatives") - models["Decidim::Meetings::Meeting"] = "Decidim::Ai::SpamDetection::Resource::Meeting" if Decidim.module_installed?("meetings") - models["Decidim::Proposals::Proposal"] = "Decidim::Ai::SpamDetection::Resource::Proposal" if Decidim.module_installed?("proposals") - if Decidim.module_installed?("proposals") - models["Decidim::Proposals::CollaborativeDraft"] = - "Decidim::Ai::SpamDetection::Resource::CollaborativeDraft" - end - models - end + models = {} + models["Decidim::Comments::Comment"] = "Decidim::Ai::SpamDetection::Resource::Comment" if Decidim.module_installed?("comments") + models["Decidim::Debates::Debate"] = "Decidim::Ai::SpamDetection::Resource::Debate" if Decidim.module_installed?("debates") + models["Decidim::Initiative"] = "Decidim::Ai::SpamDetection::Resource::Initiative" if Decidim.module_installed?("initiatives") + models["Decidim::Meetings::Meeting"] = "Decidim::Ai::SpamDetection::Resource::Meeting" if Decidim.module_installed?("meetings") + models["Decidim::Proposals::Proposal"] = "Decidim::Ai::SpamDetection::Resource::Proposal" if Decidim.module_installed?("proposals") + if Decidim.module_installed?("proposals") + models["Decidim::Proposals::CollaborativeDraft"] = + "Decidim::Ai::SpamDetection::Resource::CollaborativeDraft" + end + models + end # Configuring Third Party strategy analyzers = [ diff --git a/lib/decidim/ai/spam_detection/strategy/scaleway.rb b/lib/decidim/ai/spam_detection/strategy/scaleway.rb index e22b5e4..da67d7b 100644 --- a/lib/decidim/ai/spam_detection/strategy/scaleway.rb +++ b/lib/decidim/ai/spam_detection/strategy/scaleway.rb @@ -17,6 +17,8 @@ def classify(content, organization_host, klass) res = third_party_request(content, organization_host, klass) body = JSON.parse(res.body) + # TODO: Handle activator request timeout response + system_log("Received response from third party service: #{body}") raise InvalidEntity, body unless res.is_a? Net::HTTPSuccess From b94a6bd9b354a23a9e8bf1bc970649fc5e45c2fa Mon Sep 17 00:00:00 2001 From: Quentin Champenois Date: Tue, 17 Jun 2025 15:28:05 +0200 Subject: [PATCH 13/17] fix: Add error handling --- .../generic_spam_analyzer_job.rb | 3 +- .../spam_detection/user_spam_analyzer_job.rb | 1 + .../ai/spam_detection/strategy/scaleway.rb | 42 ++++++++++++------- .../ai/spam_detection/strategy/third_party.rb | 10 ++++- 4 files changed, 38 insertions(+), 18 deletions(-) diff --git a/app/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job.rb b/app/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job.rb index 5e34fa0..90e218b 100644 --- a/app/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job.rb +++ b/app/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job.rb @@ -8,6 +8,7 @@ class GenericSpamAnalyzerJob < ApplicationJob def perform(reportable, author, locale, fields) @author = author + @organization = reportable.organization overall_score = I18n.with_locale(locale) do fields.map do |field| classifier.classify(translated_attribute(reportable.send(field))) @@ -29,7 +30,7 @@ def form end def reporting_user - @reporting_user ||= Decidim::User.find_by!(email: Decidim::Ai::SpamDetection.reporting_user_email, organization: @author.organization) + @reporting_user ||= Decidim::User.find_by!(email: Decidim::Ai::SpamDetection.reporting_user_email, organization: @organization) end end end diff --git a/app/jobs/decidim/ai/spam_detection/user_spam_analyzer_job.rb b/app/jobs/decidim/ai/spam_detection/user_spam_analyzer_job.rb index f8ef646..0670205 100644 --- a/app/jobs/decidim/ai/spam_detection/user_spam_analyzer_job.rb +++ b/app/jobs/decidim/ai/spam_detection/user_spam_analyzer_job.rb @@ -6,6 +6,7 @@ module SpamDetection class UserSpamAnalyzerJob < GenericSpamAnalyzerJob def perform(reportable) @author = reportable + @organization = reportable.organization classifier.classify(reportable.about) diff --git a/lib/decidim/ai/spam_detection/strategy/scaleway.rb b/lib/decidim/ai/spam_detection/strategy/scaleway.rb index da67d7b..11358d6 100644 --- a/lib/decidim/ai/spam_detection/strategy/scaleway.rb +++ b/lib/decidim/ai/spam_detection/strategy/scaleway.rb @@ -13,31 +13,27 @@ class Scaleway < ThirdParty # @param klass [String] Stringified klass of reportable # @return Integer def classify(content, organization_host, klass) - system_log("Starting classification with Scaleway's strategy...") + system_log("classify - Classifying content with Scaleway's strategy...") res = third_party_request(content, organization_host, klass) - body = JSON.parse(res.body) - - # TODO: Handle activator request timeout response - - system_log("Received response from third party service: #{body}") - raise InvalidEntity, body unless res.is_a? Net::HTTPSuccess + body = parse_http_response(res) + system_log("classify - HTTP response body : #{body}") content = third_party_content(body) - raise InvalidOutputFormat, "Third party service response isn't valid JSON" unless valid_output_format?(content) + + raise InvalidOutputFormat, "Unexpected value received : '#{content}'. Expected to be in #{OUTPUT}" unless valid_output_format?(content) @category = content.downcase score - rescue InvalidEntity, InvalidOutputFormat => e - system_log(e.message, level: :error) - score + rescue ThirdPartyError => e + system_log("classify - Error: #{e.message}", level: :error) + raise e end def third_party_request(content, organization_host, klass) - # TODO: Prevent undefined endpoint uri = URI(@endpoint) payload = payload(content, klass).to_json - system_log("Sending request to third party service: #{payload}") + system_log("third_party_request - HTTP Request payload: #{payload}") http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true request = Net::HTTP::Post.new(uri.to_s, "Content-Type" => "application/json", "Accept" => "application/json") @@ -51,8 +47,24 @@ def third_party_request(content, organization_host, klass) http.request(request) rescue StandardError => e - system_log("Error during request to Scaleway service: #{e.message}", level: :error) - { "error" => "Error during request to third party service" } + system_log("third_party_request - Error: #{e.message}", level: :error) + system_log("third_party_request - HTTP : (url/#{@endpoint}) (Host/#{organization_host})", level: :error) + raise ThirdPartyError, "Error during request to third party service: #{e.message}" + end + + def parse_http_response(response) + case response + when Net::HTTPSuccess + JSON.parse(response.body) + when Net::HTTPForbidden + raise Forbidden, "Access forbidden to the third party service. Check your API key or permissions." + when Net::HTTPRequestTimeout + raise TimeoutError, response.body || "Request timed out" + when Net::HTTPServiceUnavailable + raise InvalidEntity, response.body || "Service unavailable" + else + raise InvalidEntity, "Received unexpected response from third party service: #{response.body}" + end end def third_party_content(body) diff --git a/lib/decidim/ai/spam_detection/strategy/third_party.rb b/lib/decidim/ai/spam_detection/strategy/third_party.rb index 0b18c16..a4e3750 100644 --- a/lib/decidim/ai/spam_detection/strategy/third_party.rb +++ b/lib/decidim/ai/spam_detection/strategy/third_party.rb @@ -5,9 +5,15 @@ module Ai module SpamDetection module Strategy class ThirdParty < Base - class InvalidOutputFormat < StandardError; end + class ThirdPartyError < StandardError; end - class InvalidEntity < StandardError; end + class InvalidOutputFormat < ThirdPartyError; end + + class InvalidEntity < ThirdPartyError; end + + class Forbidden < ThirdPartyError; end + + class TimeoutError < ThirdPartyError; end OUTPUT = %w(SPAM NOT_SPAM).freeze From b5ec2e70da45825f2265cfc463d87152e6525d27 Mon Sep 17 00:00:00 2001 From: Quentin Champenois Date: Thu, 19 Jun 2025 10:22:11 +0200 Subject: [PATCH 14/17] fix: Add organization host in third party analyzer --- .../third_party/generic_spam_analyzer_job.rb | 4 +-- .../third_party/user_spam_analyzer_job.rb | 4 +-- .../generic_spam_analyzer_job_spec.rb | 31 +++++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 spec/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job_spec.rb diff --git a/app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb b/app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb index 06a5868..b59ae55 100644 --- a/app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb +++ b/app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb @@ -7,11 +7,11 @@ module ThirdParty class GenericSpamAnalyzerJob < Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob def perform(reportable, author, locale, fields) @author = author - organization_host = reportable.organization.host + @organization = reportable.organization klass = reportable.class.to_s overall_score = I18n.with_locale(locale) do fields.map do |field| - classifier.classify(translated_attribute(reportable.send(field)), organization_host, klass) + classifier.classify(translated_attribute(reportable.send(field)), @organization.host, klass) classifier.score end end diff --git a/app/jobs/decidim/ai/spam_detection/third_party/user_spam_analyzer_job.rb b/app/jobs/decidim/ai/spam_detection/third_party/user_spam_analyzer_job.rb index bd96eac..9219dbe 100644 --- a/app/jobs/decidim/ai/spam_detection/third_party/user_spam_analyzer_job.rb +++ b/app/jobs/decidim/ai/spam_detection/third_party/user_spam_analyzer_job.rb @@ -7,9 +7,9 @@ module ThirdParty class UserSpamAnalyzerJob < Decidim::Ai::SpamDetection::UserSpamAnalyzerJob def perform(reportable) @author = reportable - organization_host = reportable.organization.host + @organization = reportable.organization klass = reportable.class.to_s - classifier.classify(reportable.about, organization_host, klass) + classifier.classify(reportable.about, @organization.host, klass) return unless classifier.score >= Decidim::Ai::SpamDetection.user_score_threshold diff --git a/spec/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job_spec.rb b/spec/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job_spec.rb new file mode 100644 index 0000000..a3b1f8e --- /dev/null +++ b/spec/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob do + subject { described_class } + + let!(:organization_one) { create(:organization) } + let!(:organization_two) { create(:organization) } + let!(:user_one) { create(:user, email: reporting_user_email, organization: organization_one) } + let!(:user_two) { create(:user, email: reporting_user_email, organization: organization_two) } + let(:reporting_user_email) { "reporting@example.org" } + + describe "queue" do + it "is queued to spam_analysis" do + expect(subject.queue_name).to eq "spam_analysis" + end + end + + describe "#reporting_user" do + before do + allow(Decidim::Ai::SpamDetection).to receive(:reporting_user_email).and_return(reporting_user_email) + end + + it "finds the user by email" do + obj = subject.new + obj.instance_variable_set(:@organization, organization_two) + expect(obj.send(:reporting_user)).to eq user_two + end + end +end \ No newline at end of file From 145c1c0dec6b9725cf6f1596d263cfa8c6e3c5ed Mon Sep 17 00:00:00 2001 From: Quentin Champenois Date: Mon, 23 Jun 2025 11:01:07 +0200 Subject: [PATCH 15/17] fix: Handle Activator request timeout --- lib/decidim/ai/spam_detection/strategy/scaleway.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/decidim/ai/spam_detection/strategy/scaleway.rb b/lib/decidim/ai/spam_detection/strategy/scaleway.rb index 11358d6..930dcc1 100644 --- a/lib/decidim/ai/spam_detection/strategy/scaleway.rb +++ b/lib/decidim/ai/spam_detection/strategy/scaleway.rb @@ -58,7 +58,7 @@ def parse_http_response(response) JSON.parse(response.body) when Net::HTTPForbidden raise Forbidden, "Access forbidden to the third party service. Check your API key or permissions." - when Net::HTTPRequestTimeout + when Net::HTTPRequestTimeout, Net::HTTPGatewayTimeout raise TimeoutError, response.body || "Request timed out" when Net::HTTPServiceUnavailable raise InvalidEntity, response.body || "Service unavailable" From 20158deff026ced12a4a1e3c527e2d28823f91db Mon Sep 17 00:00:00 2001 From: Quentin Champenois Date: Mon, 23 Jun 2025 11:02:34 +0200 Subject: [PATCH 16/17] fix: Unify resource content into a single string --- .../third_party/generic_spam_analyzer_job.rb | 17 +++++++++------ .../third_party/user_spam_analyzer_job.rb | 21 ++++++++++++++++++- .../generic_spam_analyzer_job_spec.rb | 2 +- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb b/app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb index b59ae55..4a3c8c3 100644 --- a/app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb +++ b/app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb @@ -10,20 +10,25 @@ def perform(reportable, author, locale, fields) @organization = reportable.organization klass = reportable.class.to_s overall_score = I18n.with_locale(locale) do - fields.map do |field| - classifier.classify(translated_attribute(reportable.send(field)), @organization.host, klass) - classifier.score + contents = fields.map do |field| + content = translated_attribute(reportable.send(field)) + if content.present? + "### #{field}:\n#{content}" + else + "" + end end - end - overall_score = overall_score.inject(0.0, :+) / overall_score.size + classifier.classify(contents.join("\n"), @organization.host, klass) + classifier.score + end return unless overall_score >= Decidim::Ai::SpamDetection.resource_score_threshold Decidim::CreateReport.call(form, reportable) rescue StandardError => e + Rails.logger.error "Decidim::Ai::SpamDetection::ThirdParty::GenericSpamAnalyzerJob> Error spam_analysis: #{e.message}" Rails.logger.error e.backtrace.first(15).join("\n") - Rails.logger.error "Error in GenericSpamAnalyzerJob: #{e.message}" end end end diff --git a/app/jobs/decidim/ai/spam_detection/third_party/user_spam_analyzer_job.rb b/app/jobs/decidim/ai/spam_detection/third_party/user_spam_analyzer_job.rb index 9219dbe..a84f3c9 100644 --- a/app/jobs/decidim/ai/spam_detection/third_party/user_spam_analyzer_job.rb +++ b/app/jobs/decidim/ai/spam_detection/third_party/user_spam_analyzer_job.rb @@ -9,10 +9,29 @@ def perform(reportable) @author = reportable @organization = reportable.organization klass = reportable.class.to_s - classifier.classify(reportable.about, @organization.host, klass) + contents = [ + "### nickname:", + reportable.nickname.to_s, + "### about:", + translated_attribute(reportable.about).to_s, + "### locale:", + reportable.locale.to_s + ] + + if reportable.personal_url.present? + contents << "### personal_url:" + contents << reportable.personal_url.to_s + end + + classifier.classify(contents.join("\n"), @organization.host, klass) return unless classifier.score >= Decidim::Ai::SpamDetection.user_score_threshold + if Decidim::UserModeration.find_by(user: reporting_user).present? + Rails.logger.warn("[decidim-ai] User already moderated: ##{reportable.id} #{reportable.nickname}") + return + end + Decidim::CreateUserReport.call(form, reportable) end end diff --git a/spec/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job_spec.rb b/spec/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job_spec.rb index a3b1f8e..f4203ba 100644 --- a/spec/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job_spec.rb +++ b/spec/jobs/decidim/ai/spam_detection/generic_spam_analyzer_job_spec.rb @@ -28,4 +28,4 @@ expect(obj.send(:reporting_user)).to eq user_two end end -end \ No newline at end of file +end From ec88085084499a972517471079212b9180d42275 Mon Sep 17 00:00:00 2001 From: Quentin Champenois Date: Mon, 23 Jun 2025 14:44:20 +0200 Subject: [PATCH 17/17] fix: Remove exception rescue to let sidekiq deal with --- .../ai/spam_detection/third_party/generic_spam_analyzer_job.rb | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb b/app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb index 4a3c8c3..6af117b 100644 --- a/app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb +++ b/app/jobs/decidim/ai/spam_detection/third_party/generic_spam_analyzer_job.rb @@ -26,9 +26,6 @@ def perform(reportable, author, locale, fields) return unless overall_score >= Decidim::Ai::SpamDetection.resource_score_threshold Decidim::CreateReport.call(form, reportable) - rescue StandardError => e - Rails.logger.error "Decidim::Ai::SpamDetection::ThirdParty::GenericSpamAnalyzerJob> Error spam_analysis: #{e.message}" - Rails.logger.error e.backtrace.first(15).join("\n") end end end