RecommendationProvider.sys.mjs (9881B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 const lazy = {}; 6 ChromeUtils.defineESModuleGetters(lazy, { 7 PersistentCache: "resource://newtab/lib/PersistentCache.sys.mjs", 8 PersonalityProvider: 9 "resource://newtab/lib/PersonalityProvider/PersonalityProvider.sys.mjs", 10 }); 11 12 import { 13 actionTypes as at, 14 actionCreators as ac, 15 } from "resource://newtab/common/Actions.mjs"; 16 17 const CACHE_KEY = "personalization"; 18 const PREF_PERSONALIZATION_MODEL_KEYS = 19 "discoverystream.personalization.modelKeys"; 20 const PREF_USER_TOPSTORIES = "feeds.section.topstories"; 21 const PREF_SYSTEM_TOPSTORIES = "feeds.system.topstories"; 22 const PREF_PERSONALIZATION = "discoverystream.personalization.enabled"; 23 const MIN_PERSONALIZATION_UPDATE_TIME = 12 * 60 * 60 * 1000; // 12 hours 24 const PREF_PERSONALIZATION_OVERRIDE = 25 "discoverystream.personalization.override"; 26 27 // The main purpose of this class is to handle interactions with the recommendation provider. 28 // A recommendation provider scores a list of stories, currently this is a personality provider. 29 // So all calls to the provider, anything involved with the setup of the provider, 30 // accessing prefs for the provider, or updaing devtools with provider state, is contained in here. 31 export class RecommendationProvider { 32 constructor() { 33 // Persistent cache for remote endpoint data. 34 this.cache = new lazy.PersistentCache(CACHE_KEY, true); 35 } 36 37 async setProvider(isStartup = false, scores) { 38 // A provider is already set. This can happen when new stories come in 39 // and we need to update their scores. 40 // We can use the existing one, a fresh one is created after startup. 41 // Using the existing one might be a bit out of date, 42 // but it's fine for now. We can rely on restarts for updates. 43 // See bug 1629931 for improvements to this. 44 if (!this.provider) { 45 this.provider = new lazy.PersonalityProvider(this.modelKeys); 46 this.provider.setScores(scores); 47 } 48 49 if (this.provider && this.provider.init) { 50 await this.provider.init(); 51 this.store.dispatch( 52 ac.BroadcastToContent({ 53 type: at.DISCOVERY_STREAM_PERSONALIZATION_INIT, 54 meta: { 55 isStartup, 56 }, 57 }) 58 ); 59 } 60 } 61 62 async enable(isStartup) { 63 await this.loadPersonalizationScoresCache(isStartup); 64 if (!this.loaded) { 65 this.loaded = true; 66 Services.obs.addObserver(this, "idle-daily"); 67 } 68 this.store.dispatch( 69 ac.BroadcastToContent({ 70 type: at.DISCOVERY_STREAM_PERSONALIZATION_UPDATED, 71 meta: { 72 isStartup, 73 }, 74 }) 75 ); 76 } 77 78 get showStories() { 79 // Combine user-set stories opt-out with Mozilla-set config 80 return ( 81 this.store.getState().Prefs.values[PREF_SYSTEM_TOPSTORIES] && 82 this.store.getState().Prefs.values[PREF_USER_TOPSTORIES] 83 ); 84 } 85 86 get personalized() { 87 // If stories are not displayed, no point in trying to personalize them. 88 if (!this.showStories) { 89 return false; 90 } 91 const spocsPersonalized = 92 this.store.getState().Prefs.values?.pocketConfig?.spocsPersonalized; 93 const recsPersonalized = 94 this.store.getState().Prefs.values?.pocketConfig?.recsPersonalized; 95 const personalization = 96 this.store.getState().Prefs.values[PREF_PERSONALIZATION]; 97 98 // There is a server sent flag to keep personalization on. 99 // If the server stops sending this, we turn personalization off, 100 // until the server starts returning the signal. 101 const overrideState = 102 this.store.getState().Prefs.values[PREF_PERSONALIZATION_OVERRIDE]; 103 104 return ( 105 personalization && 106 !overrideState && 107 (spocsPersonalized || recsPersonalized) 108 ); 109 } 110 111 get modelKeys() { 112 if (!this._modelKeys) { 113 this._modelKeys = 114 this.store.getState().Prefs.values[PREF_PERSONALIZATION_MODEL_KEYS]; 115 } 116 117 return this._modelKeys; 118 } 119 120 /* 121 * This creates a new recommendationProvider using fresh data, 122 * It's run on a last updated timer. This is the opposite of loadPersonalizationScoresCache. 123 * This is also much slower so we only trigger this in the background on idle-daily. 124 * It causes new profiles to pick up personalization slowly because the first time 125 * a new profile is run you don't have any old cache to use, so it needs to wait for the first 126 * idle-daily. Older profiles can rely on cache during the idle-daily gap. Idle-daily is 127 * usually run once every 24 hours. 128 */ 129 async updatePersonalizationScores() { 130 if ( 131 !this.personalized || 132 Date.now() - this.personalizationLastUpdated < 133 MIN_PERSONALIZATION_UPDATE_TIME 134 ) { 135 return; 136 } 137 138 await this.setProvider(); 139 140 const personalization = { scores: this.provider.getScores() }; 141 this.personalizationLastUpdated = Date.now(); 142 143 this.store.dispatch( 144 ac.BroadcastToContent({ 145 type: at.DISCOVERY_STREAM_PERSONALIZATION_LAST_UPDATED, 146 data: { 147 lastUpdated: this.personalizationLastUpdated, 148 }, 149 }) 150 ); 151 personalization._timestamp = this.personalizationLastUpdated; 152 this.cache.set("personalization", personalization); 153 } 154 155 /* 156 * This just re hydrates the provider from cache. 157 * We can call this on startup because it's generally fast. 158 * It reports to devtools the last time the data in the cache was updated. 159 */ 160 async loadPersonalizationScoresCache(isStartup = false) { 161 const cachedData = (await this.cache.get()) || {}; 162 const { personalization } = cachedData; 163 164 if (this.personalized && personalization?.scores) { 165 await this.setProvider(isStartup, personalization.scores); 166 167 this.personalizationLastUpdated = personalization._timestamp; 168 169 this.store.dispatch( 170 ac.BroadcastToContent({ 171 type: at.DISCOVERY_STREAM_PERSONALIZATION_LAST_UPDATED, 172 data: { 173 lastUpdated: this.personalizationLastUpdated, 174 }, 175 meta: { 176 isStartup, 177 }, 178 }) 179 ); 180 } 181 } 182 183 // This turns personalization on/off if the server sends the override command. 184 // The server sends a true signal to keep personalization on. So a malfunctioning 185 // server would more likely mistakenly turn off personalization, and not turn it on. 186 // This is safer, because the override is for cases where personalization is causing issues. 187 // So having it mistakenly go off is safe, but it mistakenly going on could be bad. 188 personalizationOverride(overrideCommand) { 189 // Are we currently in an override state. 190 // This is useful to know if we want to do a cleanup. 191 const overrideState = 192 this.store.getState().Prefs.values[PREF_PERSONALIZATION_OVERRIDE]; 193 194 // Is this profile currently set to be personalized. 195 const personalization = 196 this.store.getState().Prefs.values[PREF_PERSONALIZATION]; 197 198 // If we have an override command, profile is currently personalized, 199 // and is not currently being overridden, we can set the override pref. 200 if (overrideCommand && personalization && !overrideState) { 201 this.store.dispatch(ac.SetPref(PREF_PERSONALIZATION_OVERRIDE, true)); 202 } 203 204 // This is if we need to revert an override and do cleanup. 205 // We do this if we are in an override state, 206 // but not currently receiving the override signal. 207 if (!overrideCommand && overrideState) { 208 this.store.dispatch({ 209 type: at.CLEAR_PREF, 210 data: { name: PREF_PERSONALIZATION_OVERRIDE }, 211 }); 212 } 213 } 214 215 async calculateItemRelevanceScore(item) { 216 if (this.provider) { 217 const scoreResult = await this.provider.calculateItemRelevanceScore(item); 218 if (scoreResult === 0 || scoreResult) { 219 item.score = scoreResult; 220 } 221 } 222 } 223 224 teardown() { 225 if (this.provider && this.provider.teardown) { 226 // This removes any in memory listeners if available. 227 this.provider.teardown(); 228 } 229 if (this.loaded) { 230 this.loaded = false; 231 Services.obs.removeObserver(this, "idle-daily"); 232 } 233 } 234 235 async resetState() { 236 this._modelKeys = null; 237 this.personalizationLastUpdated = null; 238 this.provider = null; 239 await this.cache.set("personalization", {}); 240 this.store.dispatch( 241 ac.OnlyToMain({ 242 type: at.DISCOVERY_STREAM_PERSONALIZATION_RESET, 243 }) 244 ); 245 } 246 247 async observe(subject, topic) { 248 switch (topic) { 249 case "idle-daily": 250 await this.updatePersonalizationScores(); 251 this.store.dispatch( 252 ac.BroadcastToContent({ 253 type: at.DISCOVERY_STREAM_PERSONALIZATION_UPDATED, 254 }) 255 ); 256 break; 257 } 258 } 259 260 async onAction(action) { 261 switch (action.type) { 262 case at.INIT: 263 await this.enable(true /* isStartup */); 264 break; 265 case at.DISCOVERY_STREAM_CONFIG_CHANGE: 266 this.teardown(); 267 await this.resetState(); 268 await this.enable(); 269 break; 270 case at.DISCOVERY_STREAM_DEV_IDLE_DAILY: 271 Services.obs.notifyObservers(null, "idle-daily"); 272 break; 273 case at.PREF_CHANGED: 274 switch (action.data.name) { 275 case PREF_PERSONALIZATION_MODEL_KEYS: 276 this.store.dispatch( 277 ac.BroadcastToContent({ 278 type: at.DISCOVERY_STREAM_CONFIG_RESET, 279 }) 280 ); 281 break; 282 } 283 break; 284 case at.DISCOVERY_STREAM_PERSONALIZATION_TOGGLE: { 285 let enabled = this.store.getState().Prefs.values[PREF_PERSONALIZATION]; 286 this.store.dispatch(ac.SetPref(PREF_PERSONALIZATION, !enabled)); 287 break; 288 } 289 case at.DISCOVERY_STREAM_PERSONALIZATION_OVERRIDE: 290 this.personalizationOverride(action.data.override); 291 break; 292 } 293 } 294 }