* Allowing VariantQuery for Feed Generation Apologies for the breadth of this pull request, I had considered many small commits, but felt that would've been more effort for the value provided. This commit includes the following: - Documentation updates to the feed variant (though not the final pass) - Renaming and adding RelevancyLevers that help differentiate - Adding RelevancyLever#range to provide documentation - Reducing redundent controller logic by making a `Articles::Feeds.feed_for` method. - Adding some configuration validation for RelevancyLevers - Adding constants for better clarification - Testing unhappy paths for feed configuration - Adjusting the module namespace of some objects - Exposing top-level configurations for variants (along with their defaults) - Creating the VariantyQuery that at present inherits from the `Articles::Feeds::WeightedQueryStrategy` As implemented, we can deploy this code to production without using the new VariantQuery. Once we toggle on the `:feed_uses_variant_query_feature` FeatureFlag, it will switch to using the VariantQuery. The VariantQuery's two variants and the internal configuration of `Articles::Feeds::WeightedQueryStrategy` produce the same query. The goal of this factor is to allow for a quick on and off toggle of the feed query; to ensure that what we introduce remains performant. - Closes forem/forem#17272 - Closes forem/forem#17276 - Closes forem/forem#17216 In addition, I will be recording a code-walkthrough and linking that recording to the pull request. * Apply suggestions from code review Co-authored-by: Mac Siri <krairit.siri@gmail.com> Co-authored-by: Mac Siri <krairit.siri@gmail.com>
284 lines
16 KiB
Ruby
284 lines
16 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] if the :feed_uses_variant_query_feature feature is
|
|
# enabled.
|
|
# @return [Articles::Feeds::WeightedQueryStrategy] if the :feed_uses_variant_query_feature
|
|
# feature is not enabled.
|
|
def self.feed_for(controller:, user:, number_of_articles:, page:, tag:)
|
|
variant = AbExperiment.get_feed_variant_for(controller: controller, user: user)
|
|
|
|
if FeatureFlag.enabled?(:feed_uses_variant_query_feature)
|
|
VariantQuery.build_for(
|
|
variant: variant,
|
|
user: user,
|
|
number_of_articles: number_of_articles,
|
|
page: page,
|
|
tag: tag,
|
|
)
|
|
else
|
|
WeightedQueryStrategy.new(
|
|
variant: variant,
|
|
user: user,
|
|
number_of_articles: number_of_articles,
|
|
page: page,
|
|
tag: tag,
|
|
)
|
|
end
|
|
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: "RANDOM() ^ (1.0 / greatest(articles.score, 0.1)) 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(: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")
|
|
|
|
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_tag_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")
|
|
|
|
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")
|
|
end
|
|
private_constant :LEVER_CATALOG
|
|
# rubocop:enable Metrics/BlockLength
|
|
end
|
|
end
|