Source code

Revision control

Copy as Markdown

Other Tools

/* 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:
});
import {
actionTypes as at,
actionCreators as ac,
} from "resource://activity-stream/common/Actions.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) {
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;
}
}
}