[Brave News]: Improve peeking card algorithm (#25355)
* [Brave News]: Update peeking card algorithm * Algorithm tweaks * Algorithm tweaks * [Peeking Card]: Boost top stories * Fix test compile * Begin work on tests * Changes for PR * Add score cutoff * Limit by publishers * Reduce cutoff * Changes for PR (1/2) * comment max_candidates
This commit is contained in:
@@ -46,6 +46,8 @@ static_library("browser") {
|
||||
"initialization_promise.h",
|
||||
"network.cc",
|
||||
"network.h",
|
||||
"peeking_card.cc",
|
||||
"peeking_card.h",
|
||||
"publishers_controller.cc",
|
||||
"publishers_controller.h",
|
||||
"publishers_parsing.cc",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
#include "brave/components/brave_news/browser/signal_calculator.h"
|
||||
#include "brave/components/brave_news/common/brave_news.mojom.h"
|
||||
#include "brave/components/brave_news/common/features.h"
|
||||
#include "brave/components/brave_news/common/subscriptions_snapshot.h"
|
||||
|
||||
namespace brave_news {
|
||||
|
||||
@@ -172,6 +173,7 @@ ArticleInfos GetArticleInfos(const std::string& locale,
|
||||
} // namespace
|
||||
|
||||
FeedGenerationInfo::FeedGenerationInfo(
|
||||
const SubscriptionsSnapshot& subscriptions,
|
||||
const std::string& locale,
|
||||
const FeedItems& feed_items,
|
||||
const Publishers& publishers,
|
||||
@@ -179,7 +181,8 @@ FeedGenerationInfo::FeedGenerationInfo(
|
||||
const Signals& signals,
|
||||
const std::vector<std::string>& suggested_publisher_ids,
|
||||
const TopicsResult& topics)
|
||||
: locale_(locale),
|
||||
: subscriptions_(subscriptions),
|
||||
locale_(locale),
|
||||
channels_(std::move(channels)),
|
||||
suggested_publisher_ids_(suggested_publisher_ids),
|
||||
suggested_publisher_ids_span_(base::make_span(suggested_publisher_ids_)) {
|
||||
|
||||
@@ -18,12 +18,14 @@
|
||||
#include "brave/components/brave_news/browser/signal_calculator.h"
|
||||
#include "brave/components/brave_news/browser/topics_fetcher.h"
|
||||
#include "brave/components/brave_news/common/brave_news.mojom-forward.h"
|
||||
#include "brave/components/brave_news/common/subscriptions_snapshot.h"
|
||||
|
||||
namespace brave_news {
|
||||
|
||||
class FeedGenerationInfo {
|
||||
public:
|
||||
FeedGenerationInfo(const std::string& locale,
|
||||
FeedGenerationInfo(const SubscriptionsSnapshot& subscriptions,
|
||||
const std::string& locale,
|
||||
const FeedItems& feed_items,
|
||||
const Publishers& publishers,
|
||||
std::vector<std::string> channels,
|
||||
@@ -51,6 +53,7 @@ class FeedGenerationInfo {
|
||||
// maintain the list of content groups.
|
||||
mojom::FeedItemMetadataPtr PickAndConsume(PickArticles picker);
|
||||
|
||||
const SubscriptionsSnapshot& subscriptions() { return subscriptions_; }
|
||||
const std::string locale() { return locale_; }
|
||||
const Publishers& publishers() { return publishers_; }
|
||||
|
||||
@@ -72,6 +75,8 @@ class FeedGenerationInfo {
|
||||
void ReduceCounts(const mojom::FeedItemMetadataPtr& article,
|
||||
const ArticleMetadata& meta);
|
||||
|
||||
SubscriptionsSnapshot subscriptions_;
|
||||
|
||||
std::string locale_;
|
||||
std::vector<std::string> channels_;
|
||||
Publishers publishers_;
|
||||
|
||||
@@ -115,8 +115,9 @@ TEST_F(BraveNewsFeedGenerationInfoTest, CanCreateFeedGenerationInfo) {
|
||||
feed_items.push_back(MakeArticleItem(kPublisher1));
|
||||
feed_items.push_back(MakeArticleItem(kPublisher2));
|
||||
feed_items.push_back(MakeArticleItem(kPublisherDisabled));
|
||||
FeedGenerationInfo info("en_NZ", feed_items, publishers,
|
||||
{kTopNewsChannel, kFooChannel}, signals, {}, topics);
|
||||
FeedGenerationInfo info(SubscriptionsSnapshot(), "en_NZ", feed_items,
|
||||
publishers, {kTopNewsChannel, kFooChannel}, signals,
|
||||
{}, topics);
|
||||
|
||||
// ContentGroups and ArticleInfos should be lazily created.
|
||||
EXPECT_FALSE(HasCreatedArticleInfos(info));
|
||||
@@ -148,8 +149,9 @@ TEST_F(BraveNewsFeedGenerationInfoTest,
|
||||
feed_items.push_back(MakeArticleItem(kPublisher1));
|
||||
feed_items.push_back(MakeArticleItem(kPublisher2));
|
||||
feed_items.push_back(MakeArticleItem(kPublisherDisabled));
|
||||
FeedGenerationInfo info("en_NZ", feed_items, publishers,
|
||||
{kTopNewsChannel, kFooChannel}, signals, {}, topics);
|
||||
FeedGenerationInfo info(SubscriptionsSnapshot(), "en_NZ", feed_items,
|
||||
publishers, {kTopNewsChannel, kFooChannel}, signals,
|
||||
{}, topics);
|
||||
|
||||
EXPECT_EQ(3u, info.GetEligibleContentGroups().size());
|
||||
|
||||
@@ -174,8 +176,9 @@ TEST_F(BraveNewsFeedGenerationInfoTest,
|
||||
feed_items.push_back(MakeArticleItem(kPublisher1));
|
||||
feed_items.push_back(MakeArticleItem(kPublisher2));
|
||||
feed_items.push_back(MakeArticleItem(kPublisherDisabled));
|
||||
FeedGenerationInfo info("en_NZ", feed_items, publishers,
|
||||
{kTopNewsChannel, kFooChannel}, signals, {}, topics);
|
||||
FeedGenerationInfo info(SubscriptionsSnapshot(), "en_NZ", feed_items,
|
||||
publishers, {kTopNewsChannel, kFooChannel}, signals,
|
||||
{}, topics);
|
||||
|
||||
EXPECT_EQ(3u, info.GetEligibleContentGroups().size());
|
||||
EXPECT_EQ(2u, info.EligibleChannels().size());
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#include "base/containers/span.h"
|
||||
#include "base/functional/bind.h"
|
||||
#include "base/functional/callback_forward.h"
|
||||
#include "base/functional/callback_helpers.h"
|
||||
#include "base/logging.h"
|
||||
#include "base/memory/scoped_refptr.h"
|
||||
#include "base/memory/weak_ptr.h"
|
||||
@@ -31,6 +32,7 @@
|
||||
#include "brave/components/brave_news/browser/feed_fetcher.h"
|
||||
#include "brave/components/brave_news/browser/feed_generation_info.h"
|
||||
#include "brave/components/brave_news/browser/feed_sampling.h"
|
||||
#include "brave/components/brave_news/browser/peeking_card.h"
|
||||
#include "brave/components/brave_news/browser/publishers_controller.h"
|
||||
#include "brave/components/brave_news/browser/signal_calculator.h"
|
||||
#include "brave/components/brave_news/browser/topics_fetcher.h"
|
||||
@@ -158,13 +160,15 @@ std::vector<mojom::FeedItemV2Ptr> GenerateBlock(FeedGenerationInfo& info,
|
||||
// 2. 1 - 5 Inline Articles (a percentage of which might be discover cards).
|
||||
std::vector<mojom::FeedItemV2Ptr> GenerateBlockFromContentGroups(
|
||||
FeedGenerationInfo& info,
|
||||
// Ratio of inline articles to discovery articles.
|
||||
// discover ratio % of the time, we should do a discover card here instead
|
||||
// of a roulette card.
|
||||
// https://docs.google.com/document/d/1bSVHunwmcHwyQTpa3ab4KRbGbgNQ3ym_GHvONnrBypg/edit#heading=h.4rkb0vecgekl
|
||||
double inline_discovery_ratio =
|
||||
features::kBraveNewsInlineDiscoveryRatio.Get()) {
|
||||
PickArticles pick_hero = base::NullCallback()) {
|
||||
DVLOG(1) << __FUNCTION__;
|
||||
// Ratio of inline articles to discovery articles.
|
||||
// discover ratio % of the time, we should do a discover card here instead
|
||||
// of a roulette card.
|
||||
// https://docs.google.com/document/d/1bSVHunwmcHwyQTpa3ab4KRbGbgNQ3ym_GHvONnrBypg/edit#heading=h.4rkb0vecgekl
|
||||
const double inline_discovery_ratio =
|
||||
features::kBraveNewsInlineDiscoveryRatio.Get();
|
||||
|
||||
auto eligible_content_groups = info.GetEligibleContentGroups();
|
||||
std::vector<mojom::FeedItemV2Ptr> result;
|
||||
if (info.GetArticleInfos().empty() || eligible_content_groups.empty()) {
|
||||
@@ -225,11 +229,13 @@ std::vector<mojom::FeedItemV2Ptr> GenerateBlockFromContentGroups(
|
||||
info.locale(), std::move(eligible_content_groups),
|
||||
std::move(publisher_id_to_channels));
|
||||
|
||||
PickArticles pick_hero = base::BindRepeating(
|
||||
[](GetWeighting weighting, const ArticleInfos& articles) {
|
||||
return PickRouletteWithWeighting(articles, std::move(weighting));
|
||||
},
|
||||
get_weighting.Run(true));
|
||||
if (pick_hero.is_null()) {
|
||||
pick_hero = base::BindRepeating(
|
||||
[](GetWeighting weighting, const ArticleInfos& articles) {
|
||||
return PickRouletteWithWeighting(articles, std::move(weighting));
|
||||
},
|
||||
get_weighting.Run(true));
|
||||
}
|
||||
|
||||
PickArticles pick_article = base::BindRepeating(
|
||||
[](base::RepeatingCallback<GetWeighting(bool)> gen_weighting,
|
||||
@@ -465,15 +471,17 @@ void FeedV2Builder::UpdateRequest::AlsoUpdate(
|
||||
// static
|
||||
mojom::FeedV2Ptr FeedV2Builder::GenerateBasicFeed(FeedGenerationInfo info,
|
||||
PickArticles pick_hero,
|
||||
PickArticles pick_article) {
|
||||
PickArticles pick_article,
|
||||
PickArticles pick_peeking) {
|
||||
DVLOG(1) << __FUNCTION__;
|
||||
auto feed = mojom::FeedV2::New();
|
||||
|
||||
constexpr size_t kIterationsPerAd = 2;
|
||||
size_t blocks = 0;
|
||||
while (!info.GetArticleInfos().empty()) {
|
||||
auto items = GenerateBlock(info, pick_hero, pick_article,
|
||||
/*inline_discovery_ratio=*/0);
|
||||
auto items = GenerateBlock(
|
||||
info, feed->items.empty() ? pick_peeking : pick_hero, pick_article,
|
||||
/*inline_discovery_ratio=*/0);
|
||||
if (items.empty()) {
|
||||
break;
|
||||
}
|
||||
@@ -514,10 +522,14 @@ mojom::FeedV2Ptr FeedV2Builder::GenerateAllFeed(FeedGenerationInfo info) {
|
||||
return feed;
|
||||
}
|
||||
|
||||
// Step 1: Generate a block
|
||||
// Step 1: Generate the initial block. We have a special algorithm for the
|
||||
// first hero card
|
||||
// (https://docs.google.com/document/d/1HH7pohTPp-8uqdccwK4phOrwqK0-NRo-kkBxTDuWUsM)
|
||||
// https://docs.google.com/document/d/1bSVHunwmcHwyQTpa3ab4KRbGbgNQ3ym_GHvONnrBypg/edit#heading=h.rkq699fwps0
|
||||
std::vector<mojom::FeedItemV2Ptr> initial_block =
|
||||
GenerateBlockFromContentGroups(info);
|
||||
GenerateBlockFromContentGroups(
|
||||
info, base::BindRepeating(&PickPeekingCard, info.subscriptions(),
|
||||
GetTopStoryUrls(info.topics())));
|
||||
DVLOG(1) << "Step 1: Standard Block (" << initial_block.size()
|
||||
<< " articles)";
|
||||
add_items(initial_block);
|
||||
@@ -629,9 +641,13 @@ void FeedV2Builder::BuildFollowingFeed(
|
||||
subscriptions, {.signals = true},
|
||||
mojom::FeedV2Type::NewFollowing(mojom::FeedV2FollowingType::New()),
|
||||
base::BindOnce([](FeedGenerationInfo info) {
|
||||
return GenerateBasicFeed(std::move(info),
|
||||
base::BindRepeating(&PickRoulette),
|
||||
base::BindRepeating(&PickRoulette));
|
||||
auto subscriptions = info.subscriptions();
|
||||
auto top_stories = GetTopStoryUrls(info.topics());
|
||||
return GenerateBasicFeed(
|
||||
std::move(info), base::BindRepeating(&PickRoulette),
|
||||
base::BindRepeating(&PickRoulette),
|
||||
base::BindRepeating(&PickPeekingCard, std::move(subscriptions),
|
||||
std::move(top_stories)));
|
||||
}),
|
||||
std::move(callback));
|
||||
}
|
||||
@@ -678,10 +694,13 @@ void FeedV2Builder::BuildChannelFeed(const SubscriptionsSnapshot& subscriptions,
|
||||
}
|
||||
|
||||
info.raw_feed_items() = std::move(feed_items);
|
||||
|
||||
return GenerateBasicFeed(std::move(info),
|
||||
base::BindRepeating(&PickRoulette),
|
||||
base::BindRepeating(&PickRoulette));
|
||||
auto subscriptions = info.subscriptions();
|
||||
auto top_stories = GetTopStoryUrls(info.topics());
|
||||
return GenerateBasicFeed(
|
||||
std::move(info), base::BindRepeating(&PickRoulette),
|
||||
base::BindRepeating(&PickRoulette),
|
||||
base::BindRepeating(&PickPeekingCard, std::move(subscriptions),
|
||||
std::move(top_stories)));
|
||||
},
|
||||
channel),
|
||||
std::move(callback));
|
||||
@@ -720,6 +739,7 @@ void FeedV2Builder::BuildPublisherFeed(
|
||||
info.raw_feed_items() = std::move(items);
|
||||
|
||||
return GenerateBasicFeed(std::move(info),
|
||||
base::BindRepeating(&PickFirstIndex),
|
||||
base::BindRepeating(&PickFirstIndex),
|
||||
base::BindRepeating(&PickFirstIndex));
|
||||
},
|
||||
@@ -970,6 +990,7 @@ void FeedV2Builder::GenerateFeed(const SubscriptionsSnapshot& subscriptions,
|
||||
subscriptions, std::move(settings),
|
||||
base::BindOnce(
|
||||
[](const base::WeakPtr<FeedV2Builder> builder,
|
||||
const SubscriptionsSnapshot& subscriptions,
|
||||
mojom::FeedV2TypePtr type, FeedGenerator generate,
|
||||
BuildFeedCallback callback) {
|
||||
if (!builder) {
|
||||
@@ -991,7 +1012,7 @@ void FeedV2Builder::GenerateFeed(const SubscriptionsSnapshot& subscriptions,
|
||||
}
|
||||
|
||||
FeedGenerationInfo info(
|
||||
locale, builder->raw_feed_items_, publishers,
|
||||
subscriptions, locale, builder->raw_feed_items_, publishers,
|
||||
std::move(channels), builder->signals_,
|
||||
builder->suggested_publisher_ids_, builder->topics_);
|
||||
|
||||
@@ -1020,8 +1041,8 @@ void FeedV2Builder::GenerateFeed(const SubscriptionsSnapshot& subscriptions,
|
||||
|
||||
std::move(callback).Run(std::move(feed));
|
||||
},
|
||||
weak_ptr_factory_.GetWeakPtr(), std::move(type), std::move(generator),
|
||||
std::move(callback)));
|
||||
weak_ptr_factory_.GetWeakPtr(), subscriptions, std::move(type),
|
||||
std::move(generator), std::move(callback)));
|
||||
}
|
||||
|
||||
} // namespace brave_news
|
||||
|
||||
@@ -125,7 +125,8 @@ class FeedV2Builder {
|
||||
|
||||
static mojom::FeedV2Ptr GenerateBasicFeed(FeedGenerationInfo info,
|
||||
PickArticles pick_hero,
|
||||
PickArticles pick_article);
|
||||
PickArticles pick_article,
|
||||
PickArticles pick_peeking);
|
||||
static mojom::FeedV2Ptr GenerateAllFeed(FeedGenerationInfo info);
|
||||
|
||||
void UpdateData(const SubscriptionsSnapshot& subscriptions,
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
// Copyright (c) 2024 The Brave Authors. All rights reserved.
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
#include "brave/components/brave_news/browser/peeking_card.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <iterator>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <tuple>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "base/containers/contains.h"
|
||||
#include "base/containers/flat_set.h"
|
||||
#include "base/time/time.h"
|
||||
#include "brave/components/brave_news/browser/channels_controller.h"
|
||||
#include "brave/components/brave_news/browser/feed_sampling.h"
|
||||
#include "brave/components/brave_news/browser/topics_fetcher.h"
|
||||
#include "brave/components/brave_news/common/brave_news.mojom.h"
|
||||
#include "url/gurl.h"
|
||||
|
||||
namespace brave_news {
|
||||
|
||||
namespace {
|
||||
using ItemScore = std::tuple</*index*/ size_t, /*score*/ double>;
|
||||
|
||||
constexpr size_t kMaxPeekingCardCandidates = 10;
|
||||
// The percentage of the final candidates that are allowed to come from the same
|
||||
// publisher.
|
||||
constexpr double kMaxPublisherPercentOfCandidates = 0.2;
|
||||
|
||||
constexpr double kDirectBoost = 15;
|
||||
constexpr double kPublisherBoost = 10;
|
||||
constexpr double kChannelBoost = 5;
|
||||
|
||||
constexpr double kTopStoryMultiplier = 1.2;
|
||||
|
||||
constexpr double kOneHourMultiplier = 1.5;
|
||||
constexpr double kThreeHoursMultiplier = 1.3;
|
||||
constexpr double kSixHoursMultiplier = 1.2;
|
||||
constexpr double kOneDayMultiplier = 1.1;
|
||||
|
||||
constexpr double kMorningNewsBoost = 3;
|
||||
constexpr double kEveningEntertainmentBoost = 3;
|
||||
|
||||
constexpr double kMaxCandidatesScorePercentCutoff = 0.7;
|
||||
|
||||
constexpr char kEntertainmentChannel[] = "Entertainment";
|
||||
} // namespace
|
||||
|
||||
base::flat_set<std::string> GetTopStoryUrls(
|
||||
const base::span<TopicAndArticles>& topics) {
|
||||
std::vector<std::string> urls;
|
||||
for (auto& [topic, articles] : topics) {
|
||||
base::ranges::transform(articles, std::back_inserter(urls),
|
||||
[](const auto& article) { return article.url; });
|
||||
}
|
||||
return base::flat_set<std::string>(urls);
|
||||
}
|
||||
|
||||
std::optional<size_t> PickPeekingCardWithMax(
|
||||
SubscriptionsSnapshot subscriptions,
|
||||
base::flat_set<std::string> top_story_urls,
|
||||
const ArticleInfos& articles,
|
||||
size_t max_candidates) {
|
||||
// Store now, so it's consistent for everything.
|
||||
auto now = base::Time::Now();
|
||||
|
||||
auto get_article =
|
||||
[&articles](size_t index) -> const mojom::FeedItemMetadataPtr& {
|
||||
return std::get<0>(articles[index]);
|
||||
};
|
||||
|
||||
// Create sets for looking up whether articles are subscribed.
|
||||
base::flat_set<std::string> subscribed_channels(
|
||||
subscriptions.GetChannelsFromAllLocales());
|
||||
std::set<std::string> direct_feed_publishers;
|
||||
for (const auto& direct : subscriptions.direct_feeds()) {
|
||||
direct_feed_publishers.insert(direct.id);
|
||||
}
|
||||
|
||||
size_t following_count = subscriptions.enabled_publishers().size() +
|
||||
subscribed_channels.size() +
|
||||
subscriptions.direct_feeds().size();
|
||||
if (following_count == 0) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::vector<ItemScore> candidates;
|
||||
for (size_t i = 0; i < articles.size(); ++i) {
|
||||
auto& [article, metadata] = articles[i];
|
||||
|
||||
// Ignore disabled publishers completely - they should never be picked for
|
||||
// the peeking card.
|
||||
if (subscriptions.disabled_publishers().contains(article->publisher_id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
double score = 0;
|
||||
|
||||
// Boost direct feeds.
|
||||
if (direct_feed_publishers.contains(article->publisher_id)) {
|
||||
score += kDirectBoost;
|
||||
|
||||
// Boost enabled publishers
|
||||
} else if (subscriptions.enabled_publishers().contains(
|
||||
article->publisher_id)) {
|
||||
score += kPublisherBoost;
|
||||
|
||||
// Boost enabled channels.
|
||||
} else if (std::ranges::any_of(article->channels, [&subscribed_channels](
|
||||
const auto& channel) {
|
||||
return base::Contains(subscribed_channels, channel);
|
||||
})) {
|
||||
score += kChannelBoost;
|
||||
}
|
||||
|
||||
// Article has no score, so we can't do anything with it.
|
||||
if (score == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (base::Contains(top_story_urls, article->url.spec())) {
|
||||
score *= kTopStoryMultiplier;
|
||||
}
|
||||
|
||||
// Apply a boost to recent articles.
|
||||
auto elapsed = now - article->publish_time;
|
||||
if (elapsed <= base::Hours(1)) {
|
||||
score *= kOneHourMultiplier;
|
||||
} else if (elapsed <= base::Hours(3)) {
|
||||
score *= kThreeHoursMultiplier;
|
||||
} else if (elapsed <= base::Hours(6)) {
|
||||
score *= kSixHoursMultiplier;
|
||||
} else if (elapsed <= base::Days(1)) {
|
||||
score *= kOneDayMultiplier;
|
||||
} else {
|
||||
// Decay for a week - after a week there probably isn't much difference.
|
||||
// (a half life of 1.1 days does nicely here)
|
||||
score *= std::max(0.1, pow(0.5, elapsed.InDays() / 1.1));
|
||||
}
|
||||
|
||||
candidates.emplace_back(i, score);
|
||||
}
|
||||
|
||||
base::Time::Exploded exploded;
|
||||
now.LocalExplode(&exploded);
|
||||
for (auto& [index, score] : candidates) {
|
||||
const auto& article = get_article(index);
|
||||
// In the morning, weight news higher
|
||||
if (6 <= exploded.hour && exploded.hour < 10 &&
|
||||
base::Contains(article->channels, kTopNewsChannel)) {
|
||||
score += kMorningNewsBoost;
|
||||
|
||||
// In the evening, weight entertainment higher
|
||||
} else if (17 <= exploded.hour && exploded.hour <= 22 &&
|
||||
base::Contains(article->channels, kEntertainmentChannel)) {
|
||||
score += kEveningEntertainmentBoost;
|
||||
}
|
||||
}
|
||||
|
||||
base::ranges::sort(candidates, [get_article](const auto& a, const auto& b) {
|
||||
const auto& [a_index, a_score] = a;
|
||||
const auto& [b_index, b_score] = b;
|
||||
if (a_score != b_score) {
|
||||
return a_score > b_score;
|
||||
}
|
||||
|
||||
const auto& a_article = get_article(a_index);
|
||||
const auto& b_article = get_article(b_index);
|
||||
|
||||
return a_article->publish_time > b_article->publish_time;
|
||||
});
|
||||
|
||||
if (candidates.empty()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::vector<ItemScore> final_candidates;
|
||||
std::map<std::string, size_t> seen_publishers;
|
||||
|
||||
// Limit each publisher to a percentage of the final candidates (i.e. no more
|
||||
// than 20% of the candidates should come from one source).
|
||||
const auto publisher_limit = static_cast<int>(
|
||||
kMaxPeekingCardCandidates * kMaxPublisherPercentOfCandidates);
|
||||
// This is the minimum score that we'll consider candidates at;
|
||||
const auto min_score =
|
||||
kMaxCandidatesScorePercentCutoff * std::get<1>(candidates.front());
|
||||
|
||||
for (auto& [index, score] : candidates) {
|
||||
if (final_candidates.size() >= max_candidates || score < min_score) {
|
||||
break;
|
||||
}
|
||||
|
||||
const auto& article = get_article(index);
|
||||
|
||||
if (seen_publishers[article->publisher_id] >= publisher_limit) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen_publishers[article->publisher_id]++;
|
||||
|
||||
final_candidates.emplace_back(index, score);
|
||||
}
|
||||
|
||||
if (final_candidates.empty()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
auto [index, score] = PickRandom(final_candidates);
|
||||
return index;
|
||||
}
|
||||
|
||||
std::optional<size_t> PickPeekingCard(
|
||||
SubscriptionsSnapshot subscriptions,
|
||||
base::flat_set<std::string> top_story_urls,
|
||||
const ArticleInfos& articles) {
|
||||
return PickPeekingCardWithMax(std::move(subscriptions),
|
||||
std::move(top_story_urls), articles,
|
||||
kMaxPeekingCardCandidates);
|
||||
}
|
||||
|
||||
} // namespace brave_news
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2024 The Brave Authors. All rights reserved.
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
#ifndef BRAVE_COMPONENTS_BRAVE_NEWS_BROWSER_PEEKING_CARD_H_
|
||||
#define BRAVE_COMPONENTS_BRAVE_NEWS_BROWSER_PEEKING_CARD_H_
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "base/containers/flat_set.h"
|
||||
#include "brave/components/brave_news/browser/feed_fetcher.h"
|
||||
#include "brave/components/brave_news/browser/feed_sampling.h"
|
||||
#include "brave/components/brave_news/browser/topics_fetcher.h"
|
||||
#include "brave/components/brave_news/common/subscriptions_snapshot.h"
|
||||
|
||||
namespace brave_news {
|
||||
|
||||
base::flat_set<std::string> GetTopStoryUrls(
|
||||
const base::span<TopicAndArticles>& topics);
|
||||
|
||||
std::optional<size_t> PickPeekingCardWithMax(
|
||||
SubscriptionsSnapshot subscriptions,
|
||||
base::flat_set<std::string> top_story_urls,
|
||||
const ArticleInfos& articles,
|
||||
size_t max_candidates);
|
||||
|
||||
std::optional<size_t> PickPeekingCard(
|
||||
SubscriptionsSnapshot subscriptions,
|
||||
base::flat_set<std::string> top_story_urls,
|
||||
const ArticleInfos& articles);
|
||||
|
||||
} // namespace brave_news
|
||||
|
||||
#endif // BRAVE_COMPONENTS_BRAVE_NEWS_BROWSER_PEEKING_CARD_H_
|
||||
@@ -0,0 +1,149 @@
|
||||
// Copyright (c) 2024 The Brave Authors. All rights reserved.
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
#include "brave/components/brave_news/browser/peeking_card.h"
|
||||
|
||||
#include <tuple>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "base/containers/flat_map.h"
|
||||
#include "brave/components/brave_news/browser/feed_sampling.h"
|
||||
#include "brave/components/brave_news/common/brave_news.mojom.h"
|
||||
#include "brave/components/brave_news/common/subscriptions_snapshot.h"
|
||||
#include "testing/gtest/include/gtest/gtest.h"
|
||||
|
||||
namespace brave_news {
|
||||
|
||||
namespace {
|
||||
|
||||
ArticleInfo FakeInfo(std::string id = "",
|
||||
std::vector<std::string> channels = {},
|
||||
GURL url = GURL()) {
|
||||
static int next_id = 0;
|
||||
|
||||
if (id.empty()) {
|
||||
id = base::NumberToString(++next_id);
|
||||
}
|
||||
|
||||
auto item = mojom::FeedItemMetadata::New();
|
||||
item->publisher_id = id;
|
||||
item->channels = channels;
|
||||
item->url = url;
|
||||
|
||||
return std::make_tuple(std::move(item), ArticleMetadata());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(BraveNewsPeekingCard, NoArticles) {
|
||||
EXPECT_EQ(std::nullopt, PickPeekingCard(SubscriptionsSnapshot(), {}, {}));
|
||||
}
|
||||
|
||||
TEST(BraveNewsPeekingCard, NoSubscribedArticles) {
|
||||
ArticleInfos articles;
|
||||
articles.push_back(FakeInfo());
|
||||
articles.push_back(FakeInfo());
|
||||
articles.push_back(FakeInfo());
|
||||
EXPECT_EQ(std::nullopt,
|
||||
PickPeekingCard(SubscriptionsSnapshot(), {}, articles));
|
||||
}
|
||||
|
||||
TEST(BraveNewsPeekingCard, PublishersAreIncluded) {
|
||||
ArticleInfos articles;
|
||||
articles.push_back(FakeInfo());
|
||||
articles.push_back(FakeInfo());
|
||||
articles.push_back(FakeInfo());
|
||||
|
||||
size_t enabled_index = 1;
|
||||
SubscriptionsSnapshot subscriptions(
|
||||
{std::get<0>(articles[enabled_index])->publisher_id}, {}, {}, {});
|
||||
|
||||
EXPECT_EQ(enabled_index, PickPeekingCardWithMax(subscriptions, {}, articles,
|
||||
/*max_candidates=*/1));
|
||||
}
|
||||
|
||||
TEST(BraveNewsPeekingCard, ChannelsAreIncluded) {
|
||||
ArticleInfos articles;
|
||||
articles.push_back(FakeInfo());
|
||||
articles.push_back(FakeInfo("foo", {"one"}));
|
||||
articles.push_back(FakeInfo());
|
||||
|
||||
base::flat_map<std::string, std::vector<std::string>> channels;
|
||||
channels["en_NZ"] = {"one"};
|
||||
SubscriptionsSnapshot subscriptions({}, {}, {}, channels);
|
||||
|
||||
EXPECT_EQ(1, PickPeekingCardWithMax(subscriptions, {}, articles,
|
||||
/*max_candidates=*/1));
|
||||
}
|
||||
|
||||
TEST(BraveNewsPeekingCard, DirectFeedsAreIncluded) {
|
||||
ArticleInfos articles;
|
||||
articles.push_back(FakeInfo());
|
||||
articles.push_back(FakeInfo("foo"));
|
||||
articles.push_back(FakeInfo());
|
||||
|
||||
std::vector<DirectFeed> feeds;
|
||||
feeds.push_back({"foo", GURL(), ""});
|
||||
SubscriptionsSnapshot subscriptions({}, {}, feeds, {});
|
||||
|
||||
EXPECT_EQ(1, PickPeekingCardWithMax(subscriptions, {}, articles,
|
||||
/*max_candidates=*/1));
|
||||
}
|
||||
|
||||
TEST(BraveNewsPeekingCard, DisabledPublishersExcluded) {
|
||||
ArticleInfos articles;
|
||||
articles.push_back(FakeInfo("bar"));
|
||||
articles.push_back(FakeInfo("foo"));
|
||||
articles.push_back(FakeInfo("frob"));
|
||||
|
||||
SubscriptionsSnapshot subscriptions({"foo"}, {"foo", "bar", "frob"}, {}, {});
|
||||
|
||||
EXPECT_EQ(std::nullopt, PickPeekingCardWithMax(subscriptions, {}, articles,
|
||||
/*max_candidates=*/1));
|
||||
}
|
||||
|
||||
TEST(BraveNewsPeekingCard, DirectFeedsAreHigherThanPublishers) {
|
||||
ArticleInfos articles;
|
||||
articles.push_back(FakeInfo("combined"));
|
||||
articles.push_back(FakeInfo("direct"));
|
||||
articles.push_back(FakeInfo("other"));
|
||||
|
||||
std::vector<DirectFeed> feeds;
|
||||
feeds.push_back({"direct", GURL(), ""});
|
||||
SubscriptionsSnapshot subscriptions({"other", "combined"}, {}, feeds, {});
|
||||
|
||||
EXPECT_EQ(1, PickPeekingCardWithMax(subscriptions, {}, articles,
|
||||
/*max_candidates=*/1));
|
||||
}
|
||||
|
||||
TEST(BraveNewsPeekingCard, PublishersAreHigherThanChannels) {
|
||||
ArticleInfos articles;
|
||||
articles.push_back(FakeInfo("", {"one", "two"}));
|
||||
articles.push_back(FakeInfo("combined"));
|
||||
articles.push_back(FakeInfo("", {"two"}));
|
||||
|
||||
base::flat_map<std::string, std::vector<std::string>> channels;
|
||||
channels["en_NZ"] = {"one", "two"};
|
||||
|
||||
SubscriptionsSnapshot subscriptions({"other", "combined"}, {}, {}, channels);
|
||||
|
||||
EXPECT_EQ(1, PickPeekingCardWithMax(subscriptions, {}, articles,
|
||||
/*max_candidates=*/1));
|
||||
}
|
||||
|
||||
TEST(BraveNewsPeekingCard, TopNewsBoost) {
|
||||
ArticleInfos articles;
|
||||
articles.push_back(FakeInfo("one", {}, GURL("https://one.com/1")));
|
||||
articles.push_back(FakeInfo("one", {}, GURL("https://one.com/2")));
|
||||
articles.push_back(FakeInfo("one", {}, GURL("https://one.com/3")));
|
||||
|
||||
SubscriptionsSnapshot subscriptions({"one"}, {}, {}, {});
|
||||
|
||||
EXPECT_EQ(1, PickPeekingCardWithMax(subscriptions, {"https://one.com/2"},
|
||||
articles, /*max_candidates=*/1));
|
||||
}
|
||||
|
||||
} // namespace brave_news
|
||||
@@ -21,6 +21,7 @@ source_set("brave_news_unit_tests") {
|
||||
"//brave/components/brave_news/browser/feed_sampling_unittest.cc",
|
||||
"//brave/components/brave_news/browser/html_parsing_unittest.cc",
|
||||
"//brave/components/brave_news/browser/initialization_promise_unittest.cc",
|
||||
"//brave/components/brave_news/browser/peeking_card_unittest.cc",
|
||||
"//brave/components/brave_news/browser/publishers_controller_unittest.cc",
|
||||
"//brave/components/brave_news/browser/publishers_parsing_unittest.cc",
|
||||
"//brave/components/brave_news/browser/suggestions_controller_unittest.cc",
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
|
||||
#include "brave/components/brave_news/common/subscriptions_snapshot.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <iterator>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "base/containers/contains.h"
|
||||
|
||||
@@ -77,6 +81,15 @@ bool SubscriptionsSnapshot::GetChannelSubscribed(
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<std::string> SubscriptionsSnapshot::GetChannelsFromAllLocales()
|
||||
const {
|
||||
std::vector<std::string> result;
|
||||
for (auto& [locale, channel] : channels_) {
|
||||
base::ranges::copy(channel, std::back_inserter(result));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
SubscriptionsDiff SubscriptionsSnapshot::DiffPublishers(
|
||||
const SubscriptionsSnapshot& old) const {
|
||||
SubscriptionsDiff result;
|
||||
|
||||
@@ -67,6 +67,8 @@ class SubscriptionsSnapshot {
|
||||
bool GetChannelSubscribed(const std::string& locale,
|
||||
const std::string& channel) const;
|
||||
|
||||
std::vector<std::string> GetChannelsFromAllLocales() const;
|
||||
|
||||
// Get the changes to the publisher subscriptions between two snapshots.
|
||||
// Useful for notifying the front end of publisher changes.
|
||||
SubscriptionsDiff DiffPublishers(const SubscriptionsSnapshot& old) const;
|
||||
|
||||
Reference in New Issue
Block a user