Fixing weighted query hotness grab logic (#15528)

* Fixing weighted query hotness grab logic

Prior to this commit, I carried over (albeit imprecisely) logic from the
LargeForemExperimental.  That logic was to help limit articles to those
that were published since the user's latest page view.

However, I introduced a bug in this transcription.  Now both
LargeForemExperimental and WeightedQueryStrategy use the same logic to
determine the oldest publication date to search for in the feed.

This resolves a bug reported where users were not seeing a large number
of items in their feed.

Incidentally, if a forem has little activity in the 18 hours, there
might be very few items in the feed.

I believe, going forward, we may need to better parameterize how many
hours is considered "stale since last page view".

Related to #15240

* Fixing implementation detail

* Extracting helper method

Prior to this commit, I had introduced a method an put it in a less
ideal module space.  This commit extracts that method to a more readily
shareable module space.

I've added a few more specs to help clarify and verify behavior.

* Updating documentation

* Renaming and documenting variables/constants

* Fixing that which I broke
This commit is contained in:
Jeremy Friesen 2021-11-30 12:55:48 -05:00 committed by GitHub
parent 29f6853ee9
commit 60dc2cc12e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 127 additions and 28 deletions

View file

@ -3,6 +3,7 @@ module Stories
respond_to :json
def show
@page = (params[:page] || 1).to_i
@stories = assign_feed_stories
add_pinned_article

View file

@ -0,0 +1,49 @@
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 adminsitrative 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
# @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_factor_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
end
end

View file

@ -101,7 +101,7 @@ module Articles
end
def experimental_hot_story_grab
start_time = [(@user.page_views.second_to_last&.created_at || 7.days.ago) - 18.hours, 7.days.ago].max
start_time = Articles::Feeds.oldest_published_at_to_consider_for(user: @user)
Article.published.limited_column_select.includes(top_comments: :user)
.where("published_at > ?", start_time)
.page(@page).per(@number_of_articles)

View file

@ -46,31 +46,34 @@ module Articles
#
# Each scoring method has the following keys:
#
# - clause: The SQL clause statement; note: there exists a
# coupling between the clause and the SQL fragments
# that join the various tables. Also, under no
# circumstances should you allow any user value for
# - clause: [Required] The SQL clause statement; note: there
# exists a coupling between the clause and the SQL
# fragments that join the various tables. Also, under
# no circumstances should you allow any user value for
# this, as it is not something we can sanitize.
#
# - cases: An Array of Arrays, the first value is what matches
# the clause, the second value is the multiplicative
# factor.
# - cases: [Required] An Array of Arrays, the first value is
# what matches the clause, the second value is the
# multiplicative factor.
#
# - fallback: When no case is matched use this factor.
# - fallback: [Required] When no case is matched use this
# factor.
#
# - requires_user: Does this scoring method require a given
# user. If not, don't use it if we don't have
# a nil user.
# - requires_user: [Required] Does this scoring method require a
# given user. If not, don't use it if we don't
# have a nil user.
#
# - group_by: An SQL fragment that ensures a valid postgres
# statement in older versions of postgres. See
# - group_by: [Optional] An SQL fragment that ensures a valid
# postgres statement in older versions of postgres.
# See
# https://github.com/forem/forem/pull/15240#discussion_r750392321
# for further sleuthing details. When you reference
# a field in the clause, you likely need to include
# a corresponding :group_by attribute.
#
# - joins: An SQL fragment that defines the join necessary to
# fulfill the clause of the scoring method.
# - joins: [Optional] An SQL fragment that defines the join
# necessary to fulfill the clause of the scoring
# method.
#
# @note The group by clause appears necessary for postgres
# versions and Heroku configurations of current (as of
@ -243,8 +246,6 @@ module Articles
# @option config [Array<Symbol>] :scoring_configs
# allows for you to configure which methods you want to use.
# This is most relevant when running A/B testing.
# @option config [Integer] :most_number_of_days_to_consider
# defines the oldest published date that we'll consider.
# @option config [Integer] :negative_reaction_threshold, when
# the `articles.privileged_users_reaction_points_sum` is
# less than this amount, treat this is a negative
@ -259,18 +260,19 @@ module Articles
def initialize(user: nil, number_of_articles: 50, page: 1, tag: nil, **config)
@user = user
@number_of_articles = number_of_articles.to_i
@page = page.to_i
@page = (page || 1).to_i
# TODO: The tag parameter is vestigial, there's no logic around this value.
@tag = tag
@default_user_experience_level = config.fetch(:default_user_experience_level) { DEFAULT_USER_EXPERIENCE_LEVEL }
@negative_reaction_threshold = config.fetch(:negative_reaction_threshold, DEFAULT_NEGATIVE_REACTION_THRESHOLD)
@positive_reaction_threshold = config.fetch(:positive_reaction_threshold, DEFAULT_POSITIVE_REACTION_THRESHOLD)
@oldest_published_at = determine_oldest_published_at(
user: @user,
most_number_of_days_to_consider: config.fetch(:most_number_of_days_to_consider, 31),
)
@scoring_configs = config.fetch(:scoring_configs) { default_scoring_configs }
configure!(scoring_configs: @scoring_configs)
@oldest_published_at = Articles::Feeds.oldest_published_at_to_consider_for(
user: @user,
days_since_published: @days_since_published,
)
end
# The goal of this query is to generate a list of articles that
@ -499,10 +501,6 @@ module Articles
@joins.join("\n")
end
def determine_oldest_published_at(user:, most_number_of_days_to_consider: 31)
user&.page_views&.second_to_last&.created_at || most_number_of_days_to_consider.days.ago
end
# We multiply the relevance score components together.
def relevance_score_components_as_sql
@relevance_score_components.join(" * \n")
@ -531,6 +529,7 @@ module Articles
# @see SCORING_METHOD_CONFIGURATIONS
# @note Be mindful to guard against SQL injection!
def configure!(scoring_configs:)
@days_since_published = Articles::Feeds::DEFAULT_DAYS_SINCE_PUBLISHED
@relevance_score_components = []
# By default we always need to group by the articles.id
@ -580,6 +579,12 @@ module Articles
cases: scoring_config.fetch(:cases),
fallback: scoring_config.fetch(:fallback),
)
# Make sure that we consider all of the days for which we're
# establishing cases and for which there is a fallback.
if valid_method_name == :daily_decay_factor
@days_since_published = scoring_config.fetch(:cases).count + 1
end
end
end

View file

@ -135,7 +135,7 @@ RSpec.describe "Stories::Feeds", type: :request do
get timeframe_stories_feed_path(:week)
expect(Articles::Feeds::Timeframe).to have_received(:call).with("week", page: nil, tag: nil)
expect(Articles::Feeds::Timeframe).to have_received(:call).with("week", page: 1, tag: nil)
end
it "calls the feed service for latest" do

View file

@ -0,0 +1,44 @@
require "rails_helper"
RSpec.describe Articles::Feeds do
describe ".oldest_published_at_to_consider_for" do
subject(:function_call) { described_class.oldest_published_at_to_consider_for(user: user) }
context "when the given user is nil" do
let(:user) { nil }
it { is_expected.to be_a ActiveSupport::TimeWithZone }
end
context "when the given user has no page views" do
let(:user) { instance_double(User) }
before do
allow(user).to receive(:page_views).and_return(nil)
end
it { is_expected.to be_a ActiveSupport::TimeWithZone }
end
context "when the user has page views" do
let(:user) { instance_double(User) }
let(:page_viewed_at) { Time.current }
let(:expected_result) do
page_viewed_at - described_class::NUMBER_OF_HOURS_TO_OFFSET_USERS_LATEST_ARTICLE_VIEWS.hours
end
before do
# This is a distinct code smell. The other option is adding a
# method to user and perhaps to page views and then testing
# delegation. This is, instead, an ActiveRecord call chain,
# so I'm going to ask that we accept this smell to ease
# testing.
# rubocop:disable RSpec/MessageChain
allow(user).to receive_message_chain(:page_views, :second_to_last, :created_at).and_return(page_viewed_at)
# rubocop:enable RSpec/MessageChain
end
it { is_expected.to eq(expected_result) }
end
end
end