diff options
Diffstat (limited to '')
-rw-r--r-- | browser/components/newtab/lib/RecommendationProvider.sys.mjs | 291 |
1 files changed, 291 insertions, 0 deletions
diff --git a/browser/components/newtab/lib/RecommendationProvider.sys.mjs b/browser/components/newtab/lib/RecommendationProvider.sys.mjs new file mode 100644 index 0000000000..03e976544f --- /dev/null +++ b/browser/components/newtab/lib/RecommendationProvider.sys.mjs @@ -0,0 +1,291 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + PersistentCache: "resource://activity-stream/lib/PersistentCache.sys.mjs", + PersonalityProvider: + "resource://activity-stream/lib/PersonalityProvider/PersonalityProvider.sys.mjs", +}); + +import { + actionTypes as at, + actionCreators as ac, +} from "resource://activity-stream/common/Actions.sys.mjs"; + +const CACHE_KEY = "personalization"; +const PREF_PERSONALIZATION_MODEL_KEYS = + "discoverystream.personalization.modelKeys"; +const PREF_USER_TOPSTORIES = "feeds.section.topstories"; +const PREF_SYSTEM_TOPSTORIES = "feeds.system.topstories"; +const PREF_PERSONALIZATION = "discoverystream.personalization.enabled"; +const MIN_PERSONALIZATION_UPDATE_TIME = 12 * 60 * 60 * 1000; // 12 hours +const PREF_PERSONALIZATION_OVERRIDE = + "discoverystream.personalization.override"; + +// The main purpose of this class is to handle interactions with the recommendation provider. +// A recommendation provider scores a list of stories, currently this is a personality provider. +// So all calls to the provider, anything involved with the setup of the provider, +// accessing prefs for the provider, or updaing devtools with provider state, is contained in here. +export class RecommendationProvider { + constructor() { + // Persistent cache for remote endpoint data. + this.cache = new lazy.PersistentCache(CACHE_KEY, true); + } + + async setProvider(isStartup = false, scores) { + // A provider is already set. This can happen when new stories come in + // and we need to update their scores. + // We can use the existing one, a fresh one is created after startup. + // Using the existing one might be a bit out of date, + // but it's fine for now. We can rely on restarts for updates. + // See bug 1629931 for improvements to this. + if (!this.provider) { + this.provider = new lazy.PersonalityProvider(this.modelKeys); + this.provider.setScores(scores); + } + + if (this.provider && this.provider.init) { + await this.provider.init(); + this.store.dispatch( + ac.BroadcastToContent({ + type: at.DISCOVERY_STREAM_PERSONALIZATION_INIT, + meta: { + isStartup, + }, + }) + ); + } + } + + async enable(isStartup) { + await this.loadPersonalizationScoresCache(isStartup); + Services.obs.addObserver(this, "idle-daily"); + this.loaded = true; + this.store.dispatch( + ac.BroadcastToContent({ + type: at.DISCOVERY_STREAM_PERSONALIZATION_UPDATED, + meta: { + isStartup, + }, + }) + ); + } + + get showStories() { + // Combine user-set stories opt-out with Mozilla-set config + return ( + this.store.getState().Prefs.values[PREF_SYSTEM_TOPSTORIES] && + this.store.getState().Prefs.values[PREF_USER_TOPSTORIES] + ); + } + + get personalized() { + // If stories are not displayed, no point in trying to personalize them. + if (!this.showStories) { + return false; + } + const spocsPersonalized = + this.store.getState().Prefs.values?.pocketConfig?.spocsPersonalized; + const recsPersonalized = + this.store.getState().Prefs.values?.pocketConfig?.recsPersonalized; + const personalization = + this.store.getState().Prefs.values[PREF_PERSONALIZATION]; + + // There is a server sent flag to keep personalization on. + // If the server stops sending this, we turn personalization off, + // until the server starts returning the signal. + const overrideState = + this.store.getState().Prefs.values[PREF_PERSONALIZATION_OVERRIDE]; + + return ( + personalization && + !overrideState && + (spocsPersonalized || recsPersonalized) + ); + } + + get modelKeys() { + if (!this._modelKeys) { + this._modelKeys = + this.store.getState().Prefs.values[PREF_PERSONALIZATION_MODEL_KEYS]; + } + + return this._modelKeys; + } + + /* + * This creates a new recommendationProvider using fresh data, + * It's run on a last updated timer. This is the opposite of loadPersonalizationScoresCache. + * This is also much slower so we only trigger this in the background on idle-daily. + * It causes new profiles to pick up personalization slowly because the first time + * a new profile is run you don't have any old cache to use, so it needs to wait for the first + * idle-daily. Older profiles can rely on cache during the idle-daily gap. Idle-daily is + * usually run once every 24 hours. + */ + async updatePersonalizationScores() { + if ( + !this.personalized || + Date.now() - this.personalizationLastUpdated < + MIN_PERSONALIZATION_UPDATE_TIME + ) { + return; + } + + await this.setProvider(); + + const personalization = { scores: this.provider.getScores() }; + this.personalizationLastUpdated = Date.now(); + + this.store.dispatch( + ac.BroadcastToContent({ + type: at.DISCOVERY_STREAM_PERSONALIZATION_LAST_UPDATED, + data: { + lastUpdated: this.personalizationLastUpdated, + }, + }) + ); + personalization._timestamp = this.personalizationLastUpdated; + this.cache.set("personalization", personalization); + } + + /* + * This just re hydrates the provider from cache. + * We can call this on startup because it's generally fast. + * It reports to devtools the last time the data in the cache was updated. + */ + async loadPersonalizationScoresCache(isStartup = false) { + const cachedData = (await this.cache.get()) || {}; + const { personalization } = cachedData; + + if (this.personalized && personalization?.scores) { + await this.setProvider(isStartup, personalization.scores); + + this.personalizationLastUpdated = personalization._timestamp; + + this.store.dispatch( + ac.BroadcastToContent({ + type: at.DISCOVERY_STREAM_PERSONALIZATION_LAST_UPDATED, + data: { + lastUpdated: this.personalizationLastUpdated, + }, + meta: { + isStartup, + }, + }) + ); + } + } + + // This turns personalization on/off if the server sends the override command. + // The server sends a true signal to keep personalization on. So a malfunctioning + // server would more likely mistakenly turn off personalization, and not turn it on. + // This is safer, because the override is for cases where personalization is causing issues. + // So having it mistakenly go off is safe, but it mistakenly going on could be bad. + personalizationOverride(overrideCommand) { + // Are we currently in an override state. + // This is useful to know if we want to do a cleanup. + const overrideState = + this.store.getState().Prefs.values[PREF_PERSONALIZATION_OVERRIDE]; + + // Is this profile currently set to be personalized. + const personalization = + this.store.getState().Prefs.values[PREF_PERSONALIZATION]; + + // If we have an override command, profile is currently personalized, + // and is not currently being overridden, we can set the override pref. + if (overrideCommand && personalization && !overrideState) { + this.store.dispatch(ac.SetPref(PREF_PERSONALIZATION_OVERRIDE, true)); + } + + // This is if we need to revert an override and do cleanup. + // We do this if we are in an override state, + // but not currently receiving the override signal. + if (!overrideCommand && overrideState) { + this.store.dispatch({ + type: at.CLEAR_PREF, + data: { name: PREF_PERSONALIZATION_OVERRIDE }, + }); + } + } + + async calculateItemRelevanceScore(item) { + if (this.provider) { + const scoreResult = await this.provider.calculateItemRelevanceScore(item); + if (scoreResult === 0 || scoreResult) { + item.score = scoreResult; + } + } + } + + teardown() { + if (this.provider && this.provider.teardown) { + // This removes any in memory listeners if available. + this.provider.teardown(); + } + if (this.loaded) { + Services.obs.removeObserver(this, "idle-daily"); + } + this.loaded = false; + } + + async resetState() { + this._modelKeys = null; + this.personalizationLastUpdated = null; + this.provider = null; + await this.cache.set("personalization", {}); + this.store.dispatch( + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_PERSONALIZATION_RESET, + }) + ); + } + + async observe(subject, topic, data) { + switch (topic) { + case "idle-daily": + await this.updatePersonalizationScores(); + this.store.dispatch( + ac.BroadcastToContent({ + type: at.DISCOVERY_STREAM_PERSONALIZATION_UPDATED, + }) + ); + break; + } + } + + async onAction(action) { + switch (action.type) { + case at.INIT: + await this.enable(true /* isStartup */); + break; + case at.DISCOVERY_STREAM_CONFIG_CHANGE: + this.teardown(); + await this.resetState(); + await this.enable(); + break; + case at.DISCOVERY_STREAM_DEV_IDLE_DAILY: + Services.obs.notifyObservers(null, "idle-daily"); + break; + case at.PREF_CHANGED: + switch (action.data.name) { + case PREF_PERSONALIZATION_MODEL_KEYS: + this.store.dispatch( + ac.BroadcastToContent({ + type: at.DISCOVERY_STREAM_CONFIG_RESET, + }) + ); + break; + } + break; + case at.DISCOVERY_STREAM_PERSONALIZATION_TOGGLE: + let enabled = this.store.getState().Prefs.values[PREF_PERSONALIZATION]; + this.store.dispatch(ac.SetPref(PREF_PERSONALIZATION, !enabled)); + break; + case at.DISCOVERY_STREAM_PERSONALIZATION_OVERRIDE: + this.personalizationOverride(action.data.override); + break; + } + } +} |