Skip to content
Merged
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
14 changes: 11 additions & 3 deletions app/controllers/admin/settings/ai_functions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions app/helpers/admin/settings_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion app/models/article.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions app/models/settings/ai_functions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion app/services/ai/article_check.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/services/ai/article_enhancer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) ---
Expand Down
2 changes: 1 addition & 1 deletion app/services/ai/comment_check.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion app/services/ai/content_moderation_labeler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions app/services/ai/function_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -73,21 +74,25 @@ 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?
else false
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}"
Expand Down
22 changes: 18 additions & 4 deletions app/services/ai/function_registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)." },
Expand Down Expand Up @@ -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

Expand Down
61 changes: 61 additions & 0 deletions app/services/ai/spam_escalation_check.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion app/services/ai/subforem_finder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading