347 lines
20 KiB
Ruby
347 lines
20 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 = 18
|
|
|
|
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
|
|
|
|
# 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(: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")
|
|
end
|
|
private_constant :LEVER_CATALOG
|
|
# rubocop:enable Metrics/BlockLength
|
|
end
|
|
end
|