Skip to content

Conversation

@BarbaraOliveira13
Copy link
Contributor

@BarbaraOliveira13 BarbaraOliveira13 commented Oct 10, 2025

✉️ Feature: Decidim-AI — Spam Summary Digest Event (Weekly / Daily)

🔧 This PR also fix a CI part by normalize nl local file.

MAIN SUBJECT :
This PR adds a Decidim digest system that aggregates spam reports detected by Decidim-AI and sends a daily or weekly summary email, to each organization’s administrators (selon leur choix de config 'frequency').
Capture d’écran 2025-10-13 à 17 28 03
It integrates with Decidim EventsManager and EmailEvent notification system.

🧠 Technical Notes
resource: organization ensures Decidim’s BaseEvent behaves correctly
(the spam digest concerns all types of content: proposals, projects, comments, etc.)
If need more details, cf conclusion in the issue

🧭 Steps to Reproduce

  1. Log in as an admin
  2. Ensure Decidim-AI variables are configured in .env/.env.local:
DECIDIM_AI_BASIC_AUTH="decidimai:xxx"
DECIDIM_AI_ENABLED="true"
DECIDIM_AI_ENDPOINT=https://decidim-ai.k8s.osp.cat/spam/detection
DECIDIM_AI_GENERIC_SPAM_ANALYZER_ENABLED="true"
[email protected]
DECIDIM_AI_USER_SPAM_ANALYZER_ENABLED="true"

(Replace xxx with the password stored in Passbolt)
5. Restart the server: spring stop && bundle exec rails s
10. Create the reporting_user (Spam Bot): script for console:

user = Decidim::Organization.first.users.find_or_initialize_by(email: "[email protected]")
unless user.persisted?
  password = SecureRandom.hex(10)
  user.password = password
  user.password_confirmation = password
  user.deleted_at = Time.current
  user.tos_agreement = true
  user.name = "Spam Bot"
  user.skip_confirmation!
  user.save!
end
  • Start Sidekiq in a separate terminal: bundle exec sidekiq
  • Creates SPAM
  • Lot of possibilities, (exemple of SPAM: 'winamax.fr')
    • Create a new user, clic on the link from the mail (http://localhost:3000/letter_opener) to log in
      • Go to his account, and add a SPAM in the 'about' part
    • Add a SPAM comment
    • Crate a proposal, process, assembly... with a SPAM name or description
  • Run the digest manually:
Capture d’écran 2025-10-24 à 10 07 38 `Decidim::Ai::SpamDetection::SpamDigestGeneratorJob.perform_now(:weekly)` - => Before this commande: Update notifications settings to receive the right frequency (weekly with this command) `Decidim::Ai::SpamDetection::SpamDigestGeneratorJob.perform_now(:daily)` - - => Before this commande: Update notifications settings to receive the right frequency (daily with this command) - Open the generated email using LetterOpener

Expected output:
Capture d’écran 2025-10-17 à 08 54 03

📌 Related Issues

https://github.com/orgs/OpenSourcePolitics/projects/26/views/1?filterQuery=assignee%3A%40me&pane=issue&itemId=129336496&issue=OpenSourcePolitics%7Cintern-tasks%7C114

Tasks

  • Add specs
  • In case of new dependencies or version bump, update related documentation

📷 Screenshots

Please add screenshots of the changes you're proposing if related to the UI

Extra information

For the DATA Team: ⚠️ The digest is not stored in any table — it’s generated dynamically when EventsManager.publish is called.

@BarbaraOliveira13 BarbaraOliveira13 changed the title Spam Digest Job to aggregate spam reports (daily/weekly) Feat/Spam Digest Job Oct 20, 2025
@BarbaraOliveira13 BarbaraOliveira13 changed the title Feat/Spam Digest Job Feat/spam digest job Oct 20, 2025
Copy link
Contributor

@AyakorK AyakorK left a comment

Choose a reason for hiding this comment

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

A lot of reviews, but we probably don't need to fix everything, some of them might be critical but other issues are reported just to keep a trace on it !

Comment on lines 58 to 75
Class.new do
def initialize(path, url)
@path = path
@url = url
end

def path(_params = nil)
@path
end

def url(_params = nil)
@url
end
end.new(
resource_path,
helpers.root_url(host:, protocol: "http")
)
end
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not a huge fan of anonymous classes, Struct might be a little more maintainable or even better an external class that would help making some specs on it (like a ResourceLocator)

admins = organization.admins.where(notifications_sending_frequency: frequency)
next if admins.empty?

spam_count = count_spam(organization, frequency)
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm slight ⚠️ about memory usage there, Every spam report will be loaded into RAM which might cause an issue in the future

Maybe we should try to do the filter using SQL and not Ruby, but maybe try to see with other devs to check their POV

Comment on lines 44 to 57
# Returns all spam reports created since the given time
def spam_reports_since(since)
Decidim::Report
.joins(:moderation)
.where(reason: "spam")
.where("decidim_reports.created_at >= ?", since)
end

def spam_user_reports_since(since)
Decidim::UserReport
.joins(:user)
.where(reason: "spam")
.where("decidim_user_reports.created_at >= ?", since)
end
Copy link
Contributor

Choose a reason for hiding this comment

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

Great ! But we probably could refactor this as the only difference is the .joins of each method

class SpamDigestGeneratorJob < ApplicationJob
queue_as :mailers

def perform(frequency)
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice to have: We could validate the frequency parameter to avoid silent failures if an invalid value is passed.

Example but it might not be exactly what you wanted to do

FREQUENCIES = {
daily: "daily",
weekly: "weekly"
}.freeze

Then check with: raise ArgumentError unless FREQUENCIES.key?(frequency.to_sym)

end

def send_notification_to_admins!
return if spam_report? && !frequency_notifications_is_realtime?(@report.moderation.user.organization.admins)
Copy link
Contributor

Choose a reason for hiding this comment

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

@report.moderation.user.organization.admins
1/2 This probably loads all admins into memory just to check a boolean. We might consider using .exists? for better performance, once again it might not be a big problem as there is rarely more than 10 admins

end

def frequency_notifications_is_realtime?(admins)
admins.any? { |a| a.notifications_sending_frequency == "realtime" }
Copy link
Contributor

Choose a reason for hiding this comment

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

2/2

Maybe a refactor using something like this could work ? I'm not sure so please don't mind telling me if I'm mistaking

def has_realtime_admins?(organization)
  organization.admins.exists?(notifications_sending_frequency: "realtime")
end

@Stef-Rousset
Copy link
Contributor

Stef-Rousset commented Dec 10, 2025

Hello @BarbaraOliveira13 , thx for your PR !!!

I try to run the job Decidim::Ai::SpamDetection::SpamDigestGeneratorJob.perform_now(:daily) in console on local env, and I got an error

/Users/stephanierousset/.rvm/gems/ruby-3.2.2/gems/activerecord-7.0.8.7/lib/active_record/connection_adapters/postgresql_adapter.rb:768:in `exec_params': ERROR:  column decidim_moderations.decidim_organization_id does not exist (PG::UndefinedColumn)
LINE 1: ...s.created_at >= '2025-12-09 13:51:44.487895') AND "decidim_m...

I think it may be related to this line https://github.com/OpenSourcePolitics/decidim-app/pull/905/files#diff-236c78d077b1c09f66a2df7f331aefd14b4c5c86155cd2a12f2f2fb42038ff26R53, because decidim_moderations table has no column decidim_organization_id

@BarbaraOliveira13
Copy link
Contributor Author

Hello @BarbaraOliveira13 , thx for your PR !!!

I try to run the job Decidim::Ai::SpamDetection::SpamDigestGeneratorJob.perform_now(:daily) in console on local env, and I got an error

/Users/stephanierousset/.rvm/gems/ruby-3.2.2/gems/activerecord-7.0.8.7/lib/active_record/connection_adapters/postgresql_adapter.rb:768:in `exec_params': ERROR:  column decidim_moderations.decidim_organization_id does not exist (PG::UndefinedColumn)
LINE 1: ...s.created_at >= '2025-12-09 13:51:44.487895') AND "decidim_m...

I think it may be related to this line https://github.com/OpenSourcePolitics/decidim-app/pull/905/files#diff-236c78d077b1c09f66a2df7f331aefd14b4c5c86155cd2a12f2f2fb42038ff26R53, because decidim_moderations table has no column decidim_organization_id

Hello @Stef-Rousset Oh, I had the right chain before I think... I should changed too much time :s
I will watch and fix It!
Thanks a lot for your review ! 🙏

@BarbaraOliveira13
Copy link
Contributor Author

BarbaraOliveira13 commented Dec 12, 2025

Fail CI is not relevant of my changes. It's a flaky CF Redesigned upload modal flaky test #10961
This PR is ready for review and more!

@Stef-Rousset you can re-run the job, I fix it ! 🙏

@BarbaraOliveira13
Copy link
Contributor Author

Next step:

  1. Review tech Alain or Guillaume
  2. Test directement après un déploiement sur une app de test

@Stef-Rousset
Copy link
Contributor

Decidim::Ai::SpamDetection::SpamDigestGeneratorJob.perform_now(:daily)

hello @BarbaraOliveira13 , great job, I run the code locally and everything works !!!
Just a last quick refacto on the spam_user_reports_since method: I think you don't need .includes(:user) and reports.select { |r| r.user.decidim_organization_id == organization.id }, as the .where(decidim_users: { decidim_organization_id: organization.id }) already filters the user reports belonging to the organization.

@BarbaraOliveira13
Copy link
Contributor Author

Decidim::Ai::SpamDetection::SpamDigestGeneratorJob.perform_now(:daily)

hello @BarbaraOliveira13 , great job, I run the code locally and everything works !!! Just a last quick refacto on the spam_user_reports_since method: I think you don't need .includes(:user) and reports.select { |r| r.user.decidim_organization_id == organization.id }, as the .where(decidim_users: { decidim_organization_id: organization.id }) already filters the user reports belonging to the organization.

Thanks ! done 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants