docbrown/app/models/articles/feeds.rb
2024-02-14 15:57:41 -05:00

418 lines
25 KiB
Ruby

module Articles
module Feeds
# The default number of days old that an article can be for us
# to consider it in the relevance feed.
#
# @note I believe that it is likely we would extract this constant
# into an administrative setting. Hence, I want to keep it
# a scalar.
DEFAULT_DAYS_SINCE_PUBLISHED = 7
# @note I believe that it is likely we would extract this constant
# into an administrative setting. Hence, I want to keep it
# a scalar to ease the implementation details of the admin
# setting.
NUMBER_OF_HOURS_TO_OFFSET_USERS_LATEST_ARTICLE_VIEWS =
(ApplicationConfig["NUMBER_OF_HOURS_TO_OFFSET_USERS_LATEST_ARTICLE_VIEWS"] || 18).to_i
DEFAULT_USER_EXPERIENCE_LEVEL = 5
DEFAULT_NEGATIVE_REACTION_THRESHOLD = -10
DEFAULT_POSITIVE_REACTION_THRESHOLD = 10
# @api private
#
# This method helps answer the question: What are the articles
# that I should consider as new for the given user? This method
# provides a date by which to filter out "stale to the user"
# articles.
#
# @note Do we need to continue using this method? It's part of
# the hot story grab experiment that we ran with the
# Article::Feeds::LargeForemExperimental, but may not be
# relevant.
#
# @param user [User]
# @param days_since_published [Integer] if someone
# hasn't viewed any articles, give them things from the
# database seeds.
#
# @return [ActiveSupport::TimeWithZone]
#
# @note the days_since_published is something carried
# over from the LargeForemExperimental and may not be
# relevant given that we have the :daily_decay.
# However, this further limitation based on a user's
# second most recent page view helps further winnow down
# the result set.
def self.oldest_published_at_to_consider_for(user:, days_since_published: DEFAULT_DAYS_SINCE_PUBLISHED)
time_of_second_latest_page_view = user&.page_views&.second_to_last&.created_at
return days_since_published.days.ago unless time_of_second_latest_page_view
time_of_second_latest_page_view - number_of_hours_to_offset_users_latest_article_views.hours
end
# Get the properly configured feed for the given user (and other parameters).
#
# @param controller [ApplicationController] used to retrieve the field_test variant
# @param user [User, NilClass] used to retrieve the variant and how we query the articles
# @param number_of_articles [Integer] the pagination page size
# @param page [Integer] the page on which to start pagination
# @param tag [NilClass, String] not used but carried forward for interface conformance
#
# @return [Articles::Feeds::VariantQuery]
def self.feed_for(controller:, user:, number_of_articles:, page:, tag:)
variant = AbExperiment.get_feed_variant_for(controller: controller, user: user)
VariantQuery.build_for(
variant: variant,
user: user,
number_of_articles: number_of_articles,
page: page,
tag: tag,
)
end
# The available feed levers for this Forem instance.
#
# @return [Articles::Feeds::LeverCatalogBuilder]
def self.lever_catalog
LEVER_CATALOG
end
def self.number_of_hours_to_offset_users_latest_article_views
(ApplicationConfig["NUMBER_OF_HOURS_TO_OFFSET_USERS_LATEST_ARTICLE_VIEWS"] || 18).to_i
end
# rubocop:disable Metrics/BlockLength
# The available levers for this forem instance.
LEVER_CATALOG = LeverCatalogBuilder.new do
order_by_lever(:relevancy_score_and_publication_date,
label: "Order by highest calculated relevancy score then latest published at time.",
order_by_fragment: "article_relevancies.relevancy_score DESC, articles.published_at DESC")
order_by_lever(:final_order_by_random_weighted_to_score,
label: "Order by conflating a random number and the score (see forem/forem#16128)",
order_by_fragment: "article_relevancies.randomized_value " \
"^ (1.0 / greatest(articles.score, 0.1)) DESC")
order_by_lever(:final_order_by_feed_success_score,
label: "Order by feed success score",
order_by_fragment: "articles.feed_success_score DESC")
order_by_lever(:final_order_by_feed_success_score_minus_clickbait_score,
label: "Order by feed success score minus clickbait score",
order_by_fragment: "articles.feed_success_score - articles.clickbait_score DESC")
order_by_lever(:final_order_by_feed_success_score_minus_half_of_clickbait_score,
label: "Order by feed success score minus half of clickbait score",
order_by_fragment: "articles.feed_success_score - (articles.clickbait_score / 2) DESC")
order_by_lever(:final_order_by_feed_success_score_minus_one_tenth_of_clickbait_score,
label: "Order by feed success score minus one tenth of clickbait score",
order_by_fragment: "articles.feed_success_score - (articles.clickbait_score / 10) DESC")
order_by_lever(:final_order_by_feed_success_score_minus_clickbait_score_with_randomness,
label: "Order by feed success score minus clickbait score with a randomization factor",
order_by_fragment:
"(articles.feed_success_score - articles.clickbait_score) *
article_relevancies.randomized_value DESC")
order_by_lever(:final_order_by_feed_success_score_minus_half_of_clickbait_score_with_small_randomness,
label: "Order by feed success score minus half of clickbait score with a randomization factor",
order_by_fragment:
"(articles.feed_success_score - (articles.clickbait_score / 2)) +
(article_relevancies.randomized_value / 3) DESC")
order_by_lever(:final_order_by_feed_success_score_and_primary_score,
label: "Order by feed success score and primary score",
order_by_fragment: "((articles.feed_success_score + 0.01) * (articles.score / 10)) DESC")
order_by_lever(:final_order_by_feed_success_score_and_log_of_primary_score,
label: "Order by feed success score and log of primary score",
order_by_fragment: "((feed_success_score + 0.01) * LOG(GREATEST(score, 1))) DESC")
order_by_lever(:final_order_by_feed_success_score_and_log_of_comment_score,
label: "Order by feed success score and log of comment_score",
order_by_fragment: "((feed_success_score + 0.01) * LOG(GREATEST(comment_score, 1))) DESC")
order_by_lever(:published_at_with_randomization_favoring_public_reactions,
label: "Favor recent articles with more reactions, " \
"but apply randomness to mitigate stagnation.",
order_by_fragment: "(cast(extract(epoch FROM published_at) as integer)) * " \
"(article_relevancies.randomized_value ^ (1.0 / " \
"greatest(0.1, ln(1 + greatest(0, public_reactions_count))))) DESC")
order_by_lever(:last_comment_at_with_randomization_favoring_public_reactions,
label: "Favor articles with recent comments and more reactions, " \
"but apply randomness to mitigate stagnation.",
order_by_fragment: "(cast(extract(epoch FROM last_comment_at) as integer)) * " \
"(article_relevancies.randomized_value ^ (1.0 / " \
"greatest(0.1, ln(1 + greatest(0, public_reactions_count))))) DESC")
order_by_lever(:random_pick_of_which_date_to_use_with_randomization_favoring_public_reactions,
label: "Favor articles with recent comments or published at and more reactions, " \
"but apply randomness to mitigate stagnation.",
order_by_fragment: "(cast(extract(epoch FROM " \
"(CASE WHEN RANDOM() > 0.5 THEN published_at ELSE last_comment_at END)) " \
"as integer)) * " \
"(article_relevancies.randomized_value ^ (1.0 / " \
"greatest(0.1, ln(1 + greatest(0, public_reactions_count))))) DESC")
relevancy_lever(:comments_count_by_those_followed,
label: "Weight to give for the number of comments on the article from other users" \
"that the given user follows.",
range: "[0..∞)",
user_required: true,
select_fragment: "COUNT(comments_by_followed.id)",
joins_fragments: ["LEFT OUTER JOIN follows AS followed_user
ON articles.user_id = followed_user.followable_id
AND followed_user.followable_type = 'User'
AND followed_user.follower_id = :user_id
AND followed_user.follower_type = 'User'",
"LEFT OUTER JOIN comments AS comments_by_followed
ON comments_by_followed.commentable_id = articles.id
AND comments_by_followed.commentable_type = 'Article'
AND followed_user.followable_id = comments_by_followed.user_id
AND followed_user.followable_type = 'User'
AND comments_by_followed.deleted = false
AND comments_by_followed.created_at > :oldest_published_at"])
relevancy_lever(:comments_count,
label: "Weight to give to the number of comments on the article.",
range: "[0..∞)",
user_required: false,
select_fragment: "articles.comments_count",
group_by_fragment: "articles.comments_count")
relevancy_lever(:comments_score,
label: "Weight given based on sum of comment scores of an article.",
range: "[0..∞)",
user_required: false,
select_fragment: "SUM(
CASE
WHEN comments.score is null then 0
ELSE comments.score
END)",
joins_fragments: ["LEFT OUTER JOIN comments
ON comments.commentable_id = articles.id
AND comments.commentable_type = 'Article'
AND comments.deleted = false"])
relevancy_lever(:daily_decay,
label: "Weight given based on the relative age of the article",
range: "[0..∞)",
user_required: true,
select_fragment: "(current_date - articles.published_at::date)",
group_by_fragment: "articles.published_at")
relevancy_lever(:experience,
label: "Weight to give based on the difference between experience level of the " \
"article and given user.",
range: "[0..∞)",
user_required: true,
select_fragment: "ROUND(ABS(articles.experience_level_rating - (SELECT
(CASE
WHEN experience_level IS NULL THEN :default_user_experience_level
ELSE experience_level END ) AS user_experience_level
FROM users_settings WHERE users_settings.user_id = :user_id)))",
group_by_fragment: "articles.experience_level_rating",
query_parameter_names: [:default_user_experience_level])
relevancy_lever(:featured_article,
label: "Weight to give for feature or unfeatured articles. 1 is featured.",
user_required: false,
range: "[0..1]",
select_fragment: "(CASE articles.featured WHEN true THEN 1 ELSE 0 END)",
group_by_fragment: "articles.featured")
relevancy_lever(:following_author,
label: "Weight to give when the given user follows the article's author." \
"1 is followed, 0 is not followed.",
range: "[0..1]",
user_required: true,
select_fragment: "COUNT(followed_user.follower_id)",
joins_fragments: ["LEFT OUTER JOIN follows AS followed_user
ON articles.user_id = followed_user.followable_id
AND followed_user.followable_type = 'User'
AND followed_user.follower_id = :user_id
AND followed_user.follower_type = 'User'"])
relevancy_lever(:following_org,
label: "Weight to give to the when the given user follows the article's organization." \
"1 is followed, 0 is not followed.",
range: "[0..1]",
user_required: true,
select_fragment: "COUNT(followed_org.follower_id)",
joins_fragments: ["LEFT OUTER JOIN follows AS followed_org
ON articles.organization_id = followed_org.followable_id
AND followed_org.followable_type = 'Organization'
AND followed_org.follower_id = :user_id
AND followed_org.follower_type = 'User'"])
relevancy_lever(:latest_comment,
label: "Weight to give an article based on it's most recent comment.",
range: "[0..∞)",
user_required: false,
select_fragment: "(current_date - MAX(comments.created_at)::date)",
joins_fragments: ["LEFT OUTER JOIN comments
ON comments.commentable_id = articles.id
AND comments.commentable_type = 'Article'
AND comments.deleted = false
AND comments.created_at > :oldest_published_at"])
relevancy_lever(:matching_negative_tags_intersection_count,
label: "Weight to give the number of intersecting tags of the article and " \
"user negative follows",
range: "[0..4]",
user_required: true,
select_fragment: "COUNT(negative_followed_tags.id)",
joins_fragments: ["LEFT OUTER JOIN taggings
ON taggings.taggable_id = articles.id
AND taggable_type = 'Article'",
"INNER JOIN tags
ON taggings.tag_id = tags.id",
"LEFT OUTER JOIN follows AS negative_followed_tags
ON tags.id = negative_followed_tags.followable_id
AND negative_followed_tags.followable_type = 'ActsAsTaggableOn::Tag'
AND negative_followed_tags.follower_type = 'User'
AND negative_followed_tags.follower_id = :user_id
AND negative_followed_tags.explicit_points < 0"])
relevancy_lever(:matching_negative_tags_intersection_points,
label: "Weight to give for the sum points of the intersecting tags of the article and " \
"user positive follows.",
user_required: true,
range: "[-10..0]",
select_fragment: "LEAST(-10.0, SUM(followed_tags.points))::integer",
joins_fragments: ["LEFT OUTER JOIN taggings
ON taggings.taggable_id = articles.id
AND taggable_type = 'Article'",
"INNER JOIN tags
ON taggings.tag_id = tags.id",
"LEFT OUTER JOIN follows AS followed_tags
ON tags.id = followed_tags.followable_id
AND followed_tags.followable_type = 'ActsAsTaggableOn::Tag'
AND followed_tags.follower_type = 'User'
AND followed_tags.follower_id = :user_id
AND followed_tags.explicit_points < 0"])
relevancy_lever(:matching_positive_tags_intersection_count,
label: "Weight to give for number of the intersecting tags of the article and " \
"user positive follows.",
range: "[0..4]",
user_required: true,
select_fragment: "COUNT(followed_tags.id)",
joins_fragments: ["LEFT OUTER JOIN taggings
ON taggings.taggable_id = articles.id
AND taggable_type = 'Article'",
"INNER JOIN tags
ON taggings.tag_id = tags.id",
"LEFT OUTER JOIN follows AS followed_tags
ON tags.id = followed_tags.followable_id
AND followed_tags.followable_type = 'ActsAsTaggableOn::Tag'
AND followed_tags.follower_type = 'User'
AND followed_tags.follower_id = :user_id
AND followed_tags.explicit_points >= 0"])
relevancy_lever(:matching_positive_tags_intersection_points,
label: "Weight to give for the sum points of the intersecting tags of the article and " \
"user positive follows.",
user_required: true,
range: "[0..10]",
select_fragment: "LEAST(10.0, SUM(followed_tags.points))::integer",
joins_fragments: ["LEFT OUTER JOIN taggings
ON taggings.taggable_id = articles.id
AND taggable_type = 'Article'",
"INNER JOIN tags
ON taggings.tag_id = tags.id",
"LEFT OUTER JOIN follows AS followed_tags
ON tags.id = followed_tags.followable_id
AND followed_tags.followable_type = 'ActsAsTaggableOn::Tag'
AND followed_tags.follower_type = 'User'
AND followed_tags.follower_id = :user_id
AND followed_tags.explicit_points >= 0"])
relevancy_lever(:privileged_user_reaction,
label: "-1 when privileged user reactions down-vote, 0 when netural, and 1 when positive.",
user_required: false,
range: "[-1..1]",
select_fragment: "(CASE
WHEN articles.privileged_users_reaction_points_sum < :negative_reaction_threshold THEN -1
WHEN articles.privileged_users_reaction_points_sum > :positive_reaction_threshold THEN 1
ELSE 0 END)",
group_by_fragment: "articles.privileged_users_reaction_points_sum",
query_parameter_names: %i[negative_reaction_threshold positive_reaction_threshold])
# Note the symmetry of the < and >=; the smaller value is always "exclusive" and the larger value is "inclusive"
relevancy_lever(:privileged_user_reaction_granular,
label: "A more granular configuration for privileged user reactions (see select_fragment)",
user_required: false,
range: "[-2..2]",
select_fragment: "(CASE
--- Very negative
WHEN articles.privileged_users_reaction_points_sum < :very_negative_reaction_threshold THEN -2
--- Negative
WHEN articles.privileged_users_reaction_points_sum >= :very_negative_reaction_threshold
AND articles.privileged_users_reaction_points_sum < :negative_reaction_threshold THEN -1
--- Neutral
WHEN articles.privileged_users_reaction_points_sum >= :negative_reaction_threshold
AND articles.privileged_users_reaction_points_sum < :positive_reaction_threshold THEN 0
--- Positive
WHEN articles.privileged_users_reaction_points_sum >= :positive_reaction_threshold
AND articles.privileged_users_reaction_points_sum < :very_positive_reaction_threshold THEN 1
--- Very Positive
WHEN articles.privileged_users_reaction_points_sum >= :very_positive_reaction_threshold THEN 2
ELSE 0 END)",
group_by_fragment: "articles.privileged_users_reaction_points_sum",
query_parameter_names: %i[
very_negative_reaction_threshold
negative_reaction_threshold
very_positive_reaction_threshold
positive_reaction_threshold
])
relevancy_lever(:public_reactions,
label: "Weight to give for the number of unicorn, heart, reading list reactions for article.",
range: "[0..∞)",
user_required: false,
select_fragment: "articles.public_reactions_count",
group_by_fragment: "articles.public_reactions_count")
relevancy_lever(:public_reactions_score,
label: "Weight to give based on article.score (see article.update_score for this calculation -
it's a sum of the scores of reactions on an article).",
range: "[0..∞)",
user_required: false,
select_fragment: "articles.score",
group_by_fragment: "articles.score")
relevancy_lever(:language_match,
label: "Weight to give based on whether the language matches any of the user's languages",
range: "[0..1]", # 0 for no match, 1 for match
user_required: true,
select_fragment: "CASE
WHEN COUNT(user_languages.language) = 0 THEN 0
WHEN articles.language = ANY(array_agg(user_languages.language)) THEN 1
ELSE 0
END",
joins_fragments: ["LEFT OUTER JOIN user_languages
ON user_languages.user_id = :user_id"],
group_by_fragment: "articles.language")
relevancy_lever(:recommended_articles_match,
label: "Weight to give based on whether the article is in the first non-expired recommendations",
range: "[0..1]", # 0 for no match, 1 for match
user_required: true,
select_fragment: "CASE
WHEN COUNT(first_matching_list.id) = 0 THEN 0
WHEN articles.id = ANY(array_agg(first_matching_list.article_ids)
FILTER (WHERE first_matching_list.article_ids IS NOT NULL)) THEN 1
ELSE 0
END",
joins_fragments: ["LEFT OUTER JOIN
(SELECT * FROM recommended_articles_lists
WHERE expires_at > CURRENT_TIMESTAMP
AND placement_area = 0
ORDER BY created_at ASC
LIMIT 1) AS first_matching_list
ON first_matching_list.user_id = :user_id"],
group_by_fragment: "articles.id")
end
private_constant :LEVER_CATALOG
# rubocop:enable Metrics/BlockLength
end
end