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
59 changes: 45 additions & 14 deletions app/controllers/admin/badge_automations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,17 @@ class BadgeAutomationsController < Admin::ApplicationController

def index
@automations = ScheduledAutomation
.where(action: "award_first_org_post_badge")
.where(action: ["award_first_org_post_badge", "award_article_content_badge"])
.where("action_config->>'badge_slug' = ?", @badge.slug)
.includes(:user)
.order(created_at: :desc)
end

def new
@automation_type = params[:automation_type] || "first_org_post"
@automation = ScheduledAutomation.new(
action: "award_first_org_post_badge",
service_name: "first_org_post_badge",
action: @automation_type == "article_content" ? "award_article_content_badge" : "award_first_org_post_badge",
service_name: @automation_type == "article_content" ? "article_content_badge" : "first_org_post_badge",
action_config: {
"badge_slug" => @badge.slug
},
Expand All @@ -29,19 +30,34 @@ def new
end

def create
@automation_type = params[:automation_type] || automation_params[:action_config]&.dig("automation_type") || "first_org_post"
@automation = ScheduledAutomation.new(automation_params)
@automation.action = "award_first_org_post_badge"
@automation.service_name = "first_org_post_badge"
@automation.user = current_user # Automatically assign to current admin user
@automation.action_config ||= {}
@automation.action_config["badge_slug"] = @badge.slug

# Ensure organization_id is set from params
if params[:organization_id].present?
@automation.action_config["organization_id"] = params[:organization_id]
if @automation_type == "article_content"
@automation.action = "award_article_content_badge"
@automation.service_name = "article_content_badge"
@automation.action_config ||= {}
@automation.action_config["badge_slug"] = @badge.slug

# Validate required fields for article content badge
unless @automation.action_config["criteria"].present?
@automation.errors.add(:base, "Quality criteria is required")
end
else
@automation.errors.add(:base, "Organization is required")
@automation.action = "award_first_org_post_badge"
@automation.service_name = "first_org_post_badge"
@automation.action_config ||= {}
@automation.action_config["badge_slug"] = @badge.slug

# Ensure organization_id is set from params
if params[:organization_id].present?
@automation.action_config["organization_id"] = params[:organization_id]
else
@automation.errors.add(:base, "Organization is required")
end
end

@automation.user = current_user # Automatically assign to current admin user

if @automation.errors.empty? && @automation.valid?
@automation.set_next_run_time!
Expand Down Expand Up @@ -70,8 +86,8 @@ def update
@automation.action_config ||= {}
@automation.action_config["badge_slug"] = @badge.slug

# Update organization_id if provided
if params[:organization_id].present?
# Update organization_id if provided (for first_org_post_badge)
if @automation.action == "award_first_org_post_badge" && params[:organization_id].present?
@automation.action_config["organization_id"] = params[:organization_id]
end

Expand Down Expand Up @@ -121,6 +137,9 @@ def set_automation
unless @automation.action_config&.dig("badge_slug") == @badge.slug
raise ActiveRecord::RecordNotFound
end

# Set automation type for edit view
@automation_type = @automation.action == "award_article_content_badge" ? "article_content" : "first_org_post"
end

def set_organizations
Expand Down Expand Up @@ -151,6 +170,18 @@ def automation_params
result[:action_config]["organization_id"] = params[:organization_id]
end

# Handle keywords - convert comma-separated string to array
if result[:action_config] && result[:action_config]["keywords"].present?
keywords_str = result[:action_config]["keywords"]
if keywords_str.is_a?(String)
keywords_array = keywords_str.split(",").map(&:strip).reject(&:blank?)
result[:action_config]["keywords"] = keywords_array.any? ? keywords_array : []
end
elsif result[:action_config] && result[:action_config].key?("keywords")
# If keywords field exists but is empty, set to empty array
result[:action_config]["keywords"] = []
end

result
end

Expand Down
2 changes: 1 addition & 1 deletion app/models/scheduled_automation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ class ScheduledAutomation < ApplicationRecord

# Validations
validates :frequency, presence: true, inclusion: { in: %w[daily weekly hourly custom_interval] }
validates :action, presence: true, inclusion: { in: %w[create_draft publish_article award_first_org_post_badge award_warm_welcome_badge] }
validates :action, presence: true, inclusion: { in: %w[create_draft publish_article award_first_org_post_badge award_warm_welcome_badge award_article_content_badge] }
validates :service_name, presence: true
validates :state, presence: true, inclusion: { in: %w[active running completed failed] }
validate :validate_frequency_config
Expand Down
75 changes: 75 additions & 0 deletions app/services/ai/badge_criteria_assessor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
module Ai
##
# Analyzes an article to determine if it meets quality criteria for badge awards.
# This class uses AI to assess whether an article meets specific quality standards
# based on custom criteria provided.
class BadgeCriteriaAssessor
# @param article [Article] The article object to be assessed.
# @param criteria [String] The quality criteria to check against.
def initialize(article, criteria:)
@ai_client = Ai::Base.new
@article = article
@criteria = criteria
end

##
# Asks the AI if the article meets the quality criteria.
#
# @return [Boolean] true if the article qualifies, false otherwise.
def qualifies?
prompt = build_prompt
response = @ai_client.call(prompt)
parse_response(response)
rescue StandardError => e
Rails.logger.error("Badge Criteria Assessment failed: #{e}")
# Fallback to false if AI assessment fails
false
end

private

##
# Gathers all necessary context and constructs a detailed prompt for the AI.
# @return [String] The prompt to be sent to the AI API.
def build_prompt
<<~PROMPT
Analyze the following article to determine if it meets the specified quality criteria for a badge award.

**Article Information:**
---
Title: #{@article.title}
Published: #{@article.published_at}
Tags: #{@article.cached_tag_list}
Reading Time: #{@article.reading_time} minutes
---

**Article Content:**
---
#{@article.body_markdown.first(5000)}
---

**Quality Criteria:**
#{@criteria}

**Assessment Instructions:**
- Evaluate whether the article meets the specified quality criteria
- Consider the article's content, depth, relevance, and overall quality
- The article should be substantive and meaningful
- Exclude articles that are spam, low-effort, or do not meet the criteria

Based on the quality criteria provided, does this article qualify for a badge award?

Answer only with YES or NO.
PROMPT
end

##
# Parses the AI's direct YES/NO response.
# @param response [String] The text response from the AI.
# @return [Boolean]
def parse_response(response)
# Check if the response contains "YES", ignoring case and leading/trailing whitespace.
!response.nil? && response.strip.upcase.include?("YES")
end
end
end
189 changes: 189 additions & 0 deletions app/services/scheduled_automations/article_content_badge_awarder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
module ScheduledAutomations
##
# Service that awards badges to users who have posted quality articles
# based on specific criteria and keywords.
#
# This automation:
# - Searches for articles matching keywords (case-insensitive)
# - Filters by minimum indexable threshold
# - Uses AI to assess article quality based on custom criteria
# - Awards badges to qualifying users
# - Respects weekly limits for badges that allow multiple awards
#
# @example Award badges for quality articles
# automation = ScheduledAutomation.find(1)
# result = ScheduledAutomations::ArticleContentBadgeAwarder.call(automation)
# puts "Awarded #{result.users_awarded} badges"
class ArticleContentBadgeAwarder
Result = Struct.new(:success?, :users_awarded, :error_message, keyword_init: true)

LOOKBACK_HOURS = 2
LOOKBACK_BUFFER = 15.minutes
MIN_DAYS_BETWEEN_AWARDS = 7

class << self
def call(automation)
new(automation).call
end
end

def initialize(automation)
@automation = automation
@badge_slug = automation.action_config["badge_slug"]
@keywords = automation.action_config["keywords"] || []
@criteria = automation.action_config["criteria"]
@lookback_hours = (automation.action_config["lookback_hours"] || LOOKBACK_HOURS).to_i

# Look back 2 hours + 15 minutes (or configured hours + buffer) from last run
# If no last run, use the configured hours + buffer from now
last_run = automation.last_run_at
@since_time = if last_run
last_run - (@lookback_hours.hours + LOOKBACK_BUFFER)
else
(@lookback_hours.hours + LOOKBACK_BUFFER).ago
end
end

def call
validate_config!

badge = Badge.find_by(slug: @badge_slug)
unless badge
return Result.new(
success?: false,
users_awarded: 0,
error_message: "Badge with slug '#{@badge_slug}' not found",
)
end

badge_id = badge.id

users_awarded = award_badges_to_qualifying_users(badge_id, badge)

Result.new(
success?: true,
users_awarded: users_awarded,
error_message: nil,
)
rescue StandardError => e
Result.new(
success?: false,
users_awarded: 0,
error_message: "#{e.class}: #{e.message}",
)
end

private

def validate_config!
raise ArgumentError, "badge_slug is required in action_config" if @badge_slug.blank?
raise ArgumentError, "criteria is required in action_config" if @criteria.blank?
end

def award_badges_to_qualifying_users(badge_id, badge)
# Find articles matching keywords and time window
candidate_articles = find_candidate_articles

return 0 if candidate_articles.empty?

users_awarded = 0
assessed_user_ids = []

candidate_articles.each do |article|
next if article.user.nil? || article.user.banished?
next if assessed_user_ids.include?(article.user_id)

# Check if user already received this badge recently (within the last week)
# Only check if badge allows multiple awards
if badge.allow_multiple_awards && recently_awarded?(article.user_id, badge_id)
assessed_user_ids << article.user_id
next
end

# Skip if badge doesn't allow multiple awards and user already has it
if !badge.allow_multiple_awards && BadgeAchievement.exists?(user_id: article.user_id, badge_id: badge_id)
assessed_user_ids << article.user_id
next
end

# Assess if the article qualifies using AI
next unless qualifies_for_badge?(article)

achievement = BadgeAchievement.create(
user_id: article.user_id,
badge_id: badge_id,
rewarding_context_message_markdown: generate_message(article),
)

next unless achievement.persisted?

article.user.touch
users_awarded += 1
assessed_user_ids << article.user_id
end

users_awarded
end

def find_candidate_articles
# Start with published articles in the time window
# We use Time.zone.now instead of Time.current to ensure consistency with Timecop in tests
articles = Article.published
.where("articles.published_at > ?", @since_time)
.where("articles.published_at <= ?", Time.zone.now)
.includes(:user)

# If keywords are provided, search for articles matching them
if @keywords.present?
# Use search_articles for efficient keyword matching (case-insensitive)
search_term = @keywords.join(" ")

# In some test environments, pg_search might not be fully functional due to trigger issues
# We provide a simple fallback if no results are found with search_articles
search_results = articles.search_articles(search_term)

if search_results.exists?
articles = search_results
else
# Fallback to simple ILIKE search for title and body
keyword_conditions = @keywords.map do
"(articles.title ILIKE ? OR articles.body_markdown ILIKE ?)"
end.join(" OR ")
bind_values = @keywords.flat_map { |keyword| ["%#{keyword}%", "%#{keyword}%"] }
articles = articles.where(keyword_conditions, *bind_values)
end
end

# Filter by minimum indexable threshold using SQL conditions
min_score = Settings::UserExperience.index_minimum_score
min_date = Time.at(Settings::UserExperience.index_minimum_date)

articles
.where("articles.score >= ?", -1)
.where("articles.published_at >= ?", min_date)
.where("(articles.score >= ? OR articles.featured = ?)", min_score, true)
end

def recently_awarded?(user_id, badge_id)
# Check if user received this badge within the last week
cutoff = MIN_DAYS_BETWEEN_AWARDS.days.ago
BadgeAchievement
.where(user_id: user_id, badge_id: badge_id)
.where("created_at > ?", cutoff)
.exists?
end

def qualifies_for_badge?(article)
# Use AI to assess if article meets quality criteria
assessor = Ai::BadgeCriteriaAssessor.new(article, criteria: @criteria)
assessor.qualifies?
end

def generate_message(article)
article_url = URL.article(article)

"Congratulations on posting a quality article! " \
"Your post [#{article.title}](#{article_url}) met our quality criteria and earned you this badge."
end
end
end
Loading
Loading