diff --git a/app/controllers/admin/settings/ai_functions_controller.rb b/app/controllers/admin/settings/ai_functions_controller.rb
index 81c7eb0ffa37d..1ee3742d32c15 100644
--- a/app/controllers/admin/settings/ai_functions_controller.rb
+++ b/app/controllers/admin/settings/ai_functions_controller.rb
@@ -17,15 +17,23 @@ def authorization_resource
# Unknown functions and options a function does not support are dropped rather than
# stored, so a stale form can never select, say, Jev for a text-generation function.
def upsert_config(settings)
- selections = Ai::FunctionConfig.sanitize(settings[:function_models].to_h)
- ::Settings::AiFunctions.set_global_function_models(selections)
+ ::Settings::AiFunctions.transaction do
+ if settings.key?(:function_models)
+ selections = Ai::FunctionConfig.sanitize(settings[:function_models].to_h)
+ ::Settings::AiFunctions.set_global_function_models(selections)
+ end
+ if settings[:spam_escalation_threshold].present?
+ ::Settings::AiFunctions.set_global_spam_escalation_threshold(settings[:spam_escalation_threshold])
+ end
+ end
Result.new([])
rescue ActiveRecord::RecordInvalid => e
Result.new([e.message])
end
def settings_params
- params.require(:settings_ai_functions).permit(function_models: Ai::FunctionRegistry.configurable.map(&:key))
+ params.require(:settings_ai_functions)
+ .permit(:spam_escalation_threshold, function_models: Ai::FunctionRegistry.configurable.map(&:key))
end
end
end
diff --git a/app/helpers/admin/settings_helper.rb b/app/helpers/admin/settings_helper.rb
index ada004a992c4f..e13f849240881 100644
--- a/app/helpers/admin/settings_helper.rb
+++ b/app/helpers/admin/settings_helper.rb
@@ -13,13 +13,15 @@ def billboard_all_countries_for_editing
def ai_function_model_options(function, current)
options = Ai::FunctionConfig.options_for(function)
labeled = options.map do |option|
- label = Ai::FunctionConfig.option_label(option)
- unless Ai::FunctionConfig.option_available?(option)
+ label = Ai::FunctionConfig.option_label(option, function)
+ unless Ai::FunctionConfig.option_available?(option, function)
label = "#{label} (requires #{Ai::FunctionConfig.missing_key_for(option)})"
end
[label, option]
end
- disabled = options.reject { |option| option == current || Ai::FunctionConfig.option_available?(option) }
+ disabled = options.reject do |option|
+ option == current || Ai::FunctionConfig.option_available?(option, function)
+ end
options_for_select(labeled, selected: current, disabled: disabled)
end
diff --git a/app/models/article.rb b/app/models/article.rb
index 3b5f49b41e902..81b36b9e3207e 100644
--- a/app/models/article.rb
+++ b/app/models/article.rb
@@ -1800,7 +1800,8 @@ def create_conditional_autovomits
saved_change_to_published? ||
published_at > 1.minute.ago
- Articles::HandleSpamWorker.perform_async(id)
+ # Enqueue after commit so the worker sees the saved article (and its published state).
+ ActiveRecord.after_all_transactions_commit { Articles::HandleSpamWorker.perform_async(id) }
end
def async_bust
diff --git a/app/models/settings/ai_functions.rb b/app/models/settings/ai_functions.rb
index b58bca361beb2..b30b16564b21f 100644
--- a/app/models/settings/ai_functions.rb
+++ b/app/models/settings/ai_functions.rb
@@ -12,6 +12,10 @@ class AiFunctions < Base
self.table_name = :settings_ai_functions
setting :function_models, type: :hash, default: {}
+ # Minimum Jev probability at which content without links is escalated to the spam checks
+ # (see Ai::SpamEscalationCheck). Lower catches more spam at the cost of more spam checks.
+ setting :spam_escalation_threshold, type: :float, default: 0.3,
+ validates: { numericality: { greater_than: 0, less_than_or_equal_to: 1 } }
class << self
# @return [HashWithIndifferentAccess] function key => option key, global rows only.
@@ -25,6 +29,21 @@ def function_model(function_key)
global_function_models[function_key.to_s].presence
end
+ # @return [Float] The global escalation threshold, or its default.
+ def global_spam_escalation_threshold
+ raw = all_settings(nil)["spam_escalation_threshold"]
+ raw.present? ? convert_string_to_value_type(:float, raw) : get_default(:spam_escalation_threshold)
+ end
+
+ def set_global_spam_escalation_threshold(value)
+ record = find_by(var: "spam_escalation_threshold", subforem_id: nil) ||
+ new(var: "spam_escalation_threshold", subforem_id: nil)
+ record.value = value
+ record.save!
+ clear_cache
+ value
+ end
+
# Replaces the stored selections. Keys and values are validated by the caller
# (Ai::FunctionConfig.sanitize).
def set_global_function_models(selections)
diff --git a/app/models/user.rb b/app/models/user.rb
index 3722018f3e450..77ce7b11e5e80 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1228,7 +1228,9 @@ def update_user_roles_cache(role)
# Check for spam patterns when user gets spam or suspended role
if role.name.in?(%w[spam suspended])
- Spam::DomainDetector.new(self).check_and_block_domain!
+ # The domain scan can hit the statement timeout, so keep it out of this
+ # callback: an error here rolls back the role grant (and aborts a banish).
+ Spam::DomainDetectorWorker.perform_async(id)
calculate_score
end
diff --git a/app/services/ai/article_check.rb b/app/services/ai/article_check.rb
index eb11188c07123..2ae5da5ef9bfe 100644
--- a/app/services/ai/article_check.rb
+++ b/app/services/ai/article_check.rb
@@ -58,7 +58,7 @@ def spam_via_jev?
def jev_client
Ai::TypeSafe::Client.new(model: @selection.model, wrapper: self, affected_user: @article.user,
- affected_content: @article)
+ affected_content: @article, **Ai::TypeSafe::Client::FAIL_FAST)
end
def jev_state
diff --git a/app/services/ai/article_enhancer.rb b/app/services/ai/article_enhancer.rb
index ffcf132ae69b3..fe88900567622 100644
--- a/app/services/ai/article_enhancer.rb
+++ b/app/services/ai/article_enhancer.rb
@@ -126,7 +126,7 @@ def gemini_client(function_key)
def jev_client(function_key)
Ai::TypeSafe::Client.new(model: selection(function_key).model, wrapper: self, affected_content: @article,
- affected_user: @article.user)
+ affected_user: @article.user, **Ai::TypeSafe::Client::FAIL_FAST)
end
# --- Jev (TypeSafe System One) ---
diff --git a/app/services/ai/comment_check.rb b/app/services/ai/comment_check.rb
index eb27b87583944..3a11ef773c6a3 100644
--- a/app/services/ai/comment_check.rb
+++ b/app/services/ai/comment_check.rb
@@ -50,7 +50,7 @@ def spam?
def spam_via_jev?
client = Ai::TypeSafe::Client.new(model: @selection.model, wrapper: self, affected_content: @comment,
- affected_user: @comment.user)
+ affected_user: @comment.user, **Ai::TypeSafe::Client::FAIL_FAST)
spam_from_jev?(client.evaluate(state: jev_state, questions: jev_questions))
end
diff --git a/app/services/ai/content_moderation_labeler.rb b/app/services/ai/content_moderation_labeler.rb
index 9443c1dc95e7c..abca0e15b6344 100644
--- a/app/services/ai/content_moderation_labeler.rb
+++ b/app/services/ai/content_moderation_labeler.rb
@@ -99,7 +99,7 @@ def evaluate
# The client already retries transient failures (429/529/5xx) with backoff.
def evaluate_via_jev
client = Ai::TypeSafe::Client.new(model: @selection.model, wrapper: self, affected_content: @article,
- affected_user: @article.user)
+ affected_user: @article.user, **Ai::TypeSafe::Client::FAIL_FAST)
result = client.evaluate(state: jev_state, questions: jev_questions)
{ label: label_from_jev(result), compellingness_score: compellingness_from_jev(result) }
rescue StandardError => e
diff --git a/app/services/ai/function_config.rb b/app/services/ai/function_config.rb
index 1aabb4773ca52..ed8f4d2129588 100644
--- a/app/services/ai/function_config.rb
+++ b/app/services/ai/function_config.rb
@@ -4,7 +4,8 @@ module Ai
#
# Admins choose a model option per function in Settings::AiFunctions. Resolution:
# 1. Use the configured option when the function allows it and its API key is present.
- # 2. Otherwise use "default": the function's built-in Gemini behavior, unchanged.
+ # 2. Otherwise use "default": the function's built-in Gemini behavior, unchanged. For
+ # Jev-only functions (see Ai::FunctionRegistry) "default" means the function is off.
#
# With nothing configured every function resolves to "default", so behavior is identical
# to before this config existed.
@@ -73,11 +74,15 @@ def gemini_model_for(function_key, builtin_model = Ai::Base::DEFAULT_MODEL)
def options_for(function)
function = Ai::FunctionRegistry.fetch(function) unless function.is_a?(Ai::FunctionRegistry::Function)
return [] unless function.configurable?
+ return %w[default jev] if function.jev_only?
OPTIONS.values.filter_map { |option| option.key if function.jev? || !option.jev_only }
end
- def option_available?(option_key)
+ # "Off" (the default of a Jev-only function) needs no key.
+ def option_available?(option_key, function = nil)
+ return true if option_key.to_s == "default" && function&.jev_only?
+
case OPTIONS[option_key.to_s]&.provider
when :gemini then Ai::Base::DEFAULT_KEY.present?
when :typesafe then Ai::TypeSafe::Client::DEFAULT_KEY.present?
@@ -85,9 +90,9 @@ def option_available?(option_key)
end
end
- def option_label(option_key)
+ def option_label(option_key, function = nil)
case option_key.to_s
- when "default" then "Default (built-in Gemini behavior)"
+ when "default" then function&.jev_only? ? "Off" : "Default (built-in Gemini behavior)"
when "gemini_pro" then "Gemini: #{Ai::Base::DEFAULT_MODEL}"
when "gemini_lite" then "Gemini: #{Ai::Base::DEFAULT_LITE_MODEL}"
when "jev" then "TypeSafe Jev: #{Ai::TypeSafe::Client::DEFAULT_MODEL}"
diff --git a/app/services/ai/function_registry.rb b/app/services/ai/function_registry.rb
index 044a8607518ac..83f87ef7af83d 100644
--- a/app/services/ai/function_registry.rb
+++ b/app/services/ai/function_registry.rb
@@ -10,14 +10,21 @@ module Ai
# Functions that write text (summaries, copy, chat replies, trend names) stay on
# generative models: System One models return typed judgments, not prose.
#
+ # `jev_only: true` marks functions that exist only as a Jev pass and have no Gemini
+ # behavior. Their "default" option means the function is off.
+ #
# `configurable: false` marks functions whose model cannot be swapped safely at runtime
# (e.g. embeddings, where a different model would invalidate every stored vector).
class FunctionRegistry
- Function = Struct.new(:key, :name, :description, :group, :jev, :configurable, keyword_init: true) do
+ Function = Struct.new(:key, :name, :description, :group, :jev, :jev_only, :configurable, keyword_init: true) do
def jev?
jev
end
+ def jev_only?
+ jev_only
+ end
+
def configurable?
configurable
end
@@ -34,10 +41,17 @@ def configurable?
# Moderation & spam
{ key: :article_spam_check, group: :moderation, jev: true,
name: "Article spam check",
- description: "Decides whether a linked article from a newer account is clearly spam (Ai::ArticleCheck)." },
+ description: "Decides whether an article from a newer account is clearly spam, when it has a link or " \
+ "the spam escalation check flags it (Ai::ArticleCheck)." },
{ key: :comment_spam_check, group: :moderation, jev: true,
name: "Comment spam check",
- description: "Decides whether a comment containing a link is clearly spam (Ai::CommentCheck)." },
+ description: "Decides whether a comment is clearly spam, when it has a link or the spam escalation " \
+ "check flags it (Ai::CommentCheck)." },
+ { key: :spam_escalation, group: :moderation, jev: true, jev_only: true,
+ name: "Spam escalation for content without links",
+ description: "Sends articles and comments without links to the spam checks above when they look like " \
+ "spam, such as Telegram or WhatsApp contacts written as text (Ai::SpamEscalationCheck). " \
+ "Off keeps link-only escalation." },
{ key: :profile_moderation, group: :moderation, jev: true,
name: "Profile moderation label",
description: "Flags clear and obvious spam or abuse in new profiles (Ai::ProfileModerationLabeler)." },
@@ -115,7 +129,7 @@ def configurable?
{ key: :image_generation, group: :fixed, jev: false, configurable: false,
name: "Image generation",
description: "Generates cover and profile images (Ai::ImageGenerator) with a dedicated image model." },
- ].map { |attrs| Function.new(configurable: true, **attrs).freeze }.index_by(&:key).freeze
+ ].map { |attrs| Function.new(configurable: true, jev_only: false, **attrs).freeze }.index_by(&:key).freeze
class UnknownFunctionError < ArgumentError; end
diff --git a/app/services/ai/spam_escalation_check.rb b/app/services/ai/spam_escalation_check.rb
new file mode 100644
index 0000000000000..e74be7e7007ca
--- /dev/null
+++ b/app/services/ai/spam_escalation_check.rb
@@ -0,0 +1,61 @@
+module Ai
+ ##
+ # A cheap Jev pass that decides whether an article or comment without links should still go
+ # to the full spam check (Ai::ArticleCheck / Ai::CommentCheck), which makes the final call on
+ # whichever model it is configured for. It only escalates: a "yes" here never flags anything
+ # on its own.
+ #
+ # Runs only when the :spam_escalation function is set to Jev (see Ai::FunctionConfig);
+ # otherwise content without links is never escalated, as before.
+ class SpamEscalationCheck
+ VERSION = "1.1".freeze
+ FUNCTION_KEY = :spam_escalation
+
+ MAX_TEXT_LENGTH = 3_000
+
+ QUESTIONS = {
+ spam: Ai::TypeSafe::Questions.noul(
+ "Is `content`, published on a developer community, spam?",
+ yes: "Advertising, SEO or affiliate content, selling goods, services or accounts, scams, or gibberish, " \
+ "rather than a good-faith contribution to the community.",
+ no: "A good-faith post for developers, even if it mentions the author's own project.",
+ ),
+ offplatform_contact: Ai::TypeSafe::Questions.noul(
+ "Does `content` ask readers to contact someone off this platform to buy, order, or get a service?",
+ yes: "It gives a Telegram, WhatsApp, Discord, phone number, email, or similar contact for readers " \
+ "to buy, order, or get a service.",
+ no: "It makes no such request.",
+ )
+ }.freeze
+
+ # @param text [String] the content to check
+ # @param content [Article, Comment] the record the text came from (for the audit log)
+ def initialize(text:, content:)
+ @text = text.to_s.first(MAX_TEXT_LENGTH)
+ @content = content
+ end
+
+ # @return [Boolean] true if the content should go to the full spam check
+ def escalate?
+ selection = Ai::FunctionConfig.selection_for(FUNCTION_KEY)
+ return false unless selection.jev?
+
+ client = Ai::TypeSafe::Client.new(model: selection.model, wrapper: self, affected_content: @content,
+ affected_user: @content.user, **Ai::TypeSafe::Client::FAIL_FAST)
+ result = client.evaluate(state: { content: @text }, questions: QUESTIONS)
+ QUESTIONS.keys.any? { |id| result.noul(id) >= threshold }
+ rescue StandardError => e
+ Rails.logger.error("Spam escalation check failed: #{e}")
+ false
+ end
+
+ private
+
+ # Admin-tunable (Settings::AiFunctions). The default favors recall, since a false escalation
+ # only costs one spam check. Tune it from the probabilities logged in AiAudit against which
+ # escalations the spam check confirms.
+ def threshold
+ Settings::AiFunctions.global_spam_escalation_threshold
+ end
+ end
+end
diff --git a/app/services/ai/subforem_finder.rb b/app/services/ai/subforem_finder.rb
index 70a1e170f970a..565f89d55de33 100644
--- a/app/services/ai/subforem_finder.rb
+++ b/app/services/ai/subforem_finder.rb
@@ -90,7 +90,7 @@ def find_best_match_via_jev(available_subforems)
candidates = available_subforems.first(Ai::TypeSafe::Questions::MAX_CHOICE_OPTIONS - 1)
.index_by { |subforem| "subforem_#{subforem.id}" }
client = Ai::TypeSafe::Client.new(model: @selection.model, wrapper: self, affected_content: article,
- affected_user: article.user)
+ affected_user: article.user, **Ai::TypeSafe::Client::FAIL_FAST)
result = client.evaluate(state: jev_state, questions: jev_questions(candidates))
pick = result.choice(:best_home)
diff --git a/app/services/ai/type_safe/client.rb b/app/services/ai/type_safe/client.rb
index 6417e5ff1ac8c..3f0541b617a66 100644
--- a/app/services/ai/type_safe/client.rb
+++ b/app/services/ai/type_safe/client.rb
@@ -9,6 +9,12 @@ module TypeSafe
# callers should batch every question they might need into a single #evaluate call.
#
# Every call is recorded as an AiAudit row, mirroring Ai::Base.
+ #
+ # Outages: callers on latency-sensitive queues (the high-priority spam and moderation jobs)
+ # pass FAIL_FAST so one call can hold a worker for at most one short attempt. A circuit
+ # breaker shared across processes (via Rails.cache) also stops calling TypeSafe for
+ # CIRCUIT_COOLDOWN after CIRCUIT_FAILURE_THRESHOLD outage failures within CIRCUIT_WINDOW,
+ # raising CircuitOpenError immediately instead. Every caller fails open on errors.
class Client
include HTTParty
base_uri "https://api.typesafe.ai/v1"
@@ -18,10 +24,22 @@ class Client
# (e.g. "jev-1.13.0") via TYPESAFE_API_MODEL once thresholds are tuned against it.
# `presence` so a blank value from a copied .env file does not become the model name.
DEFAULT_MODEL = (ENV["TYPESAFE_API_MODEL"].presence || "jev-latest").freeze
- TIMEOUT_SECONDS = 20
+ # Matches the TypeSafe SDKs' default per-request timeout.
+ TIMEOUT_SECONDS = 10
MAX_RETRIES = 3
+ # For calls inside high-priority jobs: one short attempt and no retries, the same as the
+ # Gemini calls on those paths. A missed answer only means the caller falls back.
+ FAIL_FAST = { timeout: 5, max_retries: 0 }.freeze
# 429: rate limited, 529: overloaded. Both are documented as retryable with backoff.
RETRYABLE_STATUS_CODES = [429, 500, 502, 503, 504, 529].freeze
+ NETWORK_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Errno::ECONNRESET,
+ Errno::ECONNREFUSED].freeze
+
+ CIRCUIT_FAILURE_THRESHOLD = 5
+ CIRCUIT_WINDOW = 1.minute
+ CIRCUIT_COOLDOWN = 2.minutes
+ CIRCUIT_FAILURES_KEY = "ai:type_safe:circuit:failures".freeze
+ CIRCUIT_OPEN_KEY = "ai:type_safe:circuit:open".freeze
class Error < StandardError
attr_reader :status_code
@@ -32,10 +50,33 @@ def initialize(message, status_code: nil)
end
end
+ # Raised without calling TypeSafe while the circuit breaker is open.
+ class CircuitOpenError < Error; end
+
+ class << self
+ def circuit_open?
+ Rails.cache.exist?(CIRCUIT_OPEN_KEY)
+ end
+
+ # Counts an outage-type failure (timeout, connection error, 429 or 5xx) and opens the
+ # circuit once there are enough of them in the window.
+ def record_outage_failure
+ failures = Rails.cache.increment(CIRCUIT_FAILURES_KEY, 1, expires_in: CIRCUIT_WINDOW)
+ return unless failures.to_i >= CIRCUIT_FAILURE_THRESHOLD
+
+ Rails.cache.write(CIRCUIT_OPEN_KEY, true, expires_in: CIRCUIT_COOLDOWN)
+ Rails.cache.delete(CIRCUIT_FAILURES_KEY)
+ Rails.logger.warn("TypeSafe circuit breaker opened for #{CIRCUIT_COOLDOWN.inspect} after " \
+ "#{failures} failures")
+ rescue StandardError => e
+ Rails.logger.error("TypeSafe circuit breaker failed to record a failure: #{e}")
+ end
+ end
+
attr_reader :model, :last_response
def initialize(api_key: DEFAULT_KEY, model: DEFAULT_MODEL, wrapper: nil, affected_user: nil,
- affected_content: nil)
+ affected_content: nil, timeout: TIMEOUT_SECONDS, max_retries: MAX_RETRIES)
raise ArgumentError, "TypeSafe API key cannot be nil" if api_key.blank? && !Rails.env.test?
@api_key = api_key
@@ -43,6 +84,8 @@ def initialize(api_key: DEFAULT_KEY, model: DEFAULT_MODEL, wrapper: nil, affecte
@wrapper = wrapper
@affected_user = affected_user
@affected_content = affected_content
+ @timeout = timeout
+ @max_retries = max_retries
end
##
@@ -55,6 +98,7 @@ def initialize(api_key: DEFAULT_KEY, model: DEFAULT_MODEL, wrapper: nil, affecte
# @return [Ai::TypeSafe::Result]
def evaluate(state:, questions:)
raise ArgumentError, "At least one question is required" if questions.blank?
+ raise CircuitOpenError, "TypeSafe circuit breaker is open; skipping the call" if self.class.circuit_open?
body = { model: @model, state: state, questions: questions.transform_keys(&:to_s) }.to_json
attempt = 0
@@ -62,20 +106,21 @@ def evaluate(state:, questions:)
begin
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@last_response = nil
- @last_response = self.class.post("/systemone", body: body, headers: headers, timeout: TIMEOUT_SECONDS)
+ @last_response = self.class.post("/systemone", body: body, headers: headers, timeout: @timeout)
result = handle_response(@last_response)
log_audit(body, retry_count: attempt, latency_ms: elapsed_ms(start_time))
result
- rescue Error, Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET => e
+ rescue Error, *NETWORK_ERRORS => e
log_audit(body, retry_count: attempt, latency_ms: elapsed_ms(start_time), error_message: e.message)
- if retryable?(e) && attempt < MAX_RETRIES
- attempt += 1
- sleep(backoff_seconds(attempt)) unless Rails.env.test?
- retry
- end
+ raise unless retryable?(e)
+
+ self.class.record_outage_failure
+ raise if attempt >= @max_retries || self.class.circuit_open?
- raise
+ attempt += 1
+ sleep(backoff_seconds(attempt)) unless Rails.env.test?
+ retry
end
end
diff --git a/app/services/articles/detect_code_block_languages.rb b/app/services/articles/detect_code_block_languages.rb
index f4c33573a74cd..6c0e1fd92e29b 100644
--- a/app/services/articles/detect_code_block_languages.rb
+++ b/app/services/articles/detect_code_block_languages.rb
@@ -103,11 +103,14 @@ def ai_client_unconfigured?
def detect_languages_via_jev(unlabeled_blocks)
client = Ai::TypeSafe::Client.new(model: selection.model, wrapper: self, affected_content: article,
- affected_user: article.user)
+ affected_user: article.user, **Ai::TypeSafe::Client::FAIL_FAST)
unlabeled_blocks.map do |code|
answer = client.evaluate(state: { code: code.to_s.strip.first(MAX_BLOCK_CHARS) },
questions: { language: language_question }).choice(:language)
answer.confidence >= MIN_JEV_CONFIDENCE ? normalize_language(answer.choice) : "plaintext"
+ rescue Ai::TypeSafe::Client::CircuitOpenError
+ # TypeSafe is down: abort rather than write "plaintext" for every remaining block.
+ raise
rescue StandardError => e
Rails.logger.error("Jev code block language detection failed for article #{article.id}: #{e}")
"plaintext"
diff --git a/app/services/spam/domain_detector.rb b/app/services/spam/domain_detector.rb
index 103ae9e0c7ec6..f5041515f98e1 100644
--- a/app/services/spam/domain_detector.rb
+++ b/app/services/spam/domain_detector.rb
@@ -80,8 +80,10 @@ def check_and_block_domain!
private
+ # Already-blocked domains are skipped so the bulk suspension in
+ # BlockDomainAndSuspendUsersWorker doesn't re-scan the domain per user.
def should_skip_domain?
- POPULAR_SHARED_DOMAINS.include?(@email_domain)
+ POPULAR_SHARED_DOMAINS.include?(@email_domain) || BlockedEmailDomain.exists?(domain: @email_domain)
end
def spam_pattern_detected?
diff --git a/app/services/spam/handler.rb b/app/services/spam/handler.rb
index 6f1c82f6126ff..88b692dda00c8 100644
--- a/app/services/spam/handler.rb
+++ b/app/services/spam/handler.rb
@@ -26,6 +26,7 @@ module Handler
"forex signals",
"loan shark",
].freeze
+ CLEAR_VIOLATION_LABELS = %w[clear_and_obvious_spam clear_and_obvious_harmful clear_and_obvious_inciting].freeze
# @return [TrueClass] if we are going to try to use more rigorous spam handling
# @return [FalseClass] if we are using less rigorous spam handling
def self.more_rigorous_user_profile_spam_checking?
@@ -54,15 +55,9 @@ def self.handle_article!(article:, attributes: %i[title body_markdown])
end
# Handle clear and obvious violations immediately
- if %w[clear_and_obvious_spam clear_and_obvious_harmful clear_and_obvious_inciting].include?(article.automod_label)
+ if CLEAR_VIOLATION_LABELS.include?(article.automod_label)
issue_spam_reaction_for!(reactable: article)
-
- if Reaction.user_has_been_given_too_many_spammy_article_reactions?(
- user: article.user,
- include_user_profile: more_rigorous_user_profile_spam_checking?,
- )
- suspend!(user: article.user)
- end
+ escalate_clear_violation_author!(user: article.user)
return :spam
end
@@ -81,8 +76,9 @@ def self.handle_article!(article:, attributes: %i[title body_markdown])
# Check if we should trigger spam detection
should_check = Settings::RateLimit.trigger_spam_for?(text: text) ||
- (article.processed_html.include?("= 4
+
+ recent_auto_flagged_article_count(user: user) > threshold
+ end
+
+ def self.recent_auto_flagged_article_count(user:)
+ flagged_articles = user.articles.published
+ .where("published_at > ?", 1.month.ago)
+ .where(automod_label: CLEAR_VIOLATION_LABELS)
+ Reaction.article_vomits.valid_or_confirmed
+ .where(user_id: Settings::General.mascot_user_id, reactable_id: flagged_articles.select(:id))
+ .count
+ end
+
+ # Leave a note so moderators can see why the account was marked as spam. Skipped when the
+ # author is already spam so queued jobs for the same author don't pile up duplicate notes.
+ def self.mark_repeat_auto_flagged_author_as_spam!(user:)
+ return if user.spam?
+
+ user.add_role(:spam)
+
+ Note.create(
+ author_id: Settings::General.mascot_user_id,
+ noteable: user,
+ reason: "automatic_spam",
+ content: I18n.t("services.spam.article_handler.marked_spam_repeat_auto_flags",
+ count: recent_auto_flagged_article_count(user: user)),
+ )
end
# NEW/private: Helper method to check for extensive domain-based spam.
@@ -234,6 +279,14 @@ def self.extract_first_domain_from(html)
end
end
+ # Content with a link always goes to the spam check. Without a link, it goes only when the
+ # cheaper Jev escalation check flags it (e.g. Telegram/WhatsApp contacts written as text).
+ # That check is off unless :spam_escalation is set to Jev in Ai::FunctionConfig.
+ def self.link_or_escalated?(html:, text:, content:)
+ html.include?("
<% end %>
+