Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -755,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)
Expand Down Expand Up @@ -797,6 +800,7 @@ GEM
zeitwerk (2.7.2)

PLATFORMS
arm64-darwin-22
x86_64-linux-gnu

DEPENDENCIES
Expand All @@ -817,6 +821,7 @@ DEPENDENCIES
rubocop-faker
rubocop-performance
simplecov
uri (= 1.0.3)
web-console (~> 4.2)

RUBY VERSION
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ Add the queue name to `config/sidekiq.yml` file:
# The other yaml entries
```

## Configure third-party service

Documentations and examples are available at :
- [Examples](./examples/)
- [Docs](./docs/)

## Contributing

See [Decidim](https://github.com/decidim/decidim).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand All @@ -29,7 +30,7 @@ def form
end

def reporting_user
@reporting_user ||= Decidim::User.find_by!(email: Decidim::Ai::SpamDetection.reporting_user_email)
@reporting_user ||= Decidim::User.find_by!(email: Decidim::Ai::SpamDetection.reporting_user_email, organization: @organization)
end
end
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# frozen_string_literal: true

module Decidim
module Ai
module SpamDetection
module ThirdParty
class GenericSpamAnalyzerJob < Decidim::Ai::SpamDetection::GenericSpamAnalyzerJob
def perform(reportable, author, locale, fields)
@author = author
@organization = reportable.organization
klass = reportable.class.to_s

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure that we would really need the spam class as input data. I think a spam message is spam regardless of the place is posted. But nevertheless, i would like to find out why you need it.

overall_score = I18n.with_locale(locale) do
contents = fields.map do |field|
content = translated_attribute(reportable.send(field))
if content.present?
"### #{field}:\n#{content}"
else
""
end
end

classifier.classify(contents.join("\n"), @organization.host, klass)
classifier.score
end

return unless overall_score >= Decidim::Ai::SpamDetection.resource_score_threshold

Decidim::CreateReport.call(form, reportable)
end
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# frozen_string_literal: true

module Decidim
module Ai
module SpamDetection
module ThirdParty
class UserSpamAnalyzerJob < Decidim::Ai::SpamDetection::UserSpamAnalyzerJob
def perform(reportable)
@author = reportable
@organization = reportable.organization
klass = reportable.class.to_s
contents = [
"### nickname:",
reportable.nickname.to_s,
"### about:",
translated_attribute(reportable.about).to_s,
"### locale:",
reportable.locale.to_s
]

if reportable.personal_url.present?
contents << "### personal_url:"
contents << reportable.personal_url.to_s
end

classifier.classify(contents.join("\n"), @organization.host, klass)

return unless classifier.score >= Decidim::Ai::SpamDetection.user_score_threshold

if Decidim::UserModeration.find_by(user: reporting_user).present?
Rails.logger.warn("[decidim-ai] User already moderated: ##{reportable.id} #{reportable.nickname}")
return
end

Decidim::CreateUserReport.call(form, reportable)
end
end
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ module SpamDetection
class UserSpamAnalyzerJob < GenericSpamAnalyzerJob
def perform(reportable)
@author = reportable
@organization = reportable.organization

classifier.classify(reportable.about)

Expand Down
76 changes: 76 additions & 0 deletions docs/scaleway.md
Original file line number Diff line number Diff line change
@@ -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 !
58 changes: 58 additions & 0 deletions docs/third_party.md
Original file line number Diff line number Diff line change
@@ -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 !
51 changes: 51 additions & 0 deletions examples/decidim_ai_scaleway.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# frozen_string_literal: true

if Decidim.module_installed?(:ai)
if Rails.application.secrets.dig(:decidim, :ai, :endpoint).blank? || Rails.application.secrets.dig(:decidim, :ai, :secret).blank?
Rails.logger.warn "[decidim-ai] Initializer - AI endpoint or secret not configured. AI features will be disabled."
return
end

# Module configuration
Decidim::Ai::SpamDetection.reporting_user_email = Rails.application.secrets.dig(:decidim, :ai, :reporting_user_email)
Decidim::Ai::Language.formatter = "Decidim::Ai::Language::Formatter"
Decidim::Ai::SpamDetection.user_models = {
"Decidim::User" => "Decidim::Ai::SpamDetection::Resource::UserBaseEntity"
}
Decidim::Ai::SpamDetection.resource_models = begin
models = {}
models["Decidim::Comments::Comment"] = "Decidim::Ai::SpamDetection::Resource::Comment" if Decidim.module_installed?("comments")
models["Decidim::Debates::Debate"] = "Decidim::Ai::SpamDetection::Resource::Debate" if Decidim.module_installed?("debates")
models["Decidim::Initiative"] = "Decidim::Ai::SpamDetection::Resource::Initiative" if Decidim.module_installed?("initiatives")
models["Decidim::Meetings::Meeting"] = "Decidim::Ai::SpamDetection::Resource::Meeting" if Decidim.module_installed?("meetings")
models["Decidim::Proposals::Proposal"] = "Decidim::Ai::SpamDetection::Resource::Proposal" if Decidim.module_installed?("proposals")
if Decidim.module_installed?("proposals")
models["Decidim::Proposals::CollaborativeDraft"] =
"Decidim::Ai::SpamDetection::Resource::CollaborativeDraft"
end
models
end

# Configuring Scaleway strategy
analyzers = [
{
name: :scaleway,
strategy: Decidim::Ai::SpamDetection::Strategy::Scaleway,
options: {
endpoint: Rails.application.secrets.dig(:decidim, :ai, :endpoint),
secret: Rails.application.secrets.dig(:decidim, :ai, :secret)
}
}
]

Decidim::Ai::SpamDetection.resource_analyzers = analyzers
Decidim::Ai::SpamDetection.user_analyzers = analyzers

# Configuring Third Party services
Decidim::Ai::SpamDetection.user_detection_service = "Decidim::Ai::SpamDetection::ThirdPartyService"
Decidim::Ai::SpamDetection.resource_detection_service = "Decidim::Ai::SpamDetection::ThirdPartyService"

# Configuring Third Party jobs
Decidim::Ai::SpamDetection.user_spam_analyzer_job = "Decidim::Ai::SpamDetection::ThirdParty::UserSpamAnalyzerJob"
Decidim::Ai::SpamDetection.generic_spam_analyzer_job = "Decidim::Ai::SpamDetection::ThirdParty::GenericSpamAnalyzerJob"
Comment on lines +48 to +50

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we do not need to register a custom analyzer after the changes are added to main repo.

end
Loading