tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 }