407 lines
12 KiB
JavaScript
407 lines
12 KiB
JavaScript
/* 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, {
|
|
NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs",
|
|
PersistentCache: "resource://newtab/lib/PersistentCache.sys.mjs",
|
|
PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
|
|
});
|
|
|
|
import { FeatureModel } from "resource://newtab/lib/InferredModel/FeatureModel.sys.mjs";
|
|
|
|
import {
|
|
FORMAT,
|
|
AggregateResultKeys,
|
|
DEFAULT_INFERRED_MODEL_DATA,
|
|
} from "resource://newtab/lib/InferredModel/InferredConstants.sys.mjs";
|
|
|
|
import {
|
|
actionTypes as at,
|
|
actionCreators as ac,
|
|
} from "resource://newtab/common/Actions.mjs";
|
|
|
|
import { MODEL_TYPE } from "./InferredModel/InferredConstants.sys.mjs";
|
|
|
|
const CACHE_KEY = "inferred_personalization_feed";
|
|
const DISCOVERY_STREAM_CACHE_KEY = "discovery_stream";
|
|
const INTEREST_VECTOR_UPDATE_TIME = 4 * 60 * 60 * 1000; // 4 hours
|
|
const PREF_USER_INFERRED_PERSONALIZATION =
|
|
"discoverystream.sections.personalization.inferred.user.enabled";
|
|
const PREF_SYSTEM_INFERRED_PERSONALIZATION =
|
|
"discoverystream.sections.personalization.inferred.enabled";
|
|
const PREF_SYSTEM_INFERRED_MODEL_OVERRIDE =
|
|
"discoverystream.sections.personalization.inferred.model.override";
|
|
|
|
function timeMSToSeconds(timeMS) {
|
|
return Math.round(timeMS / 1000);
|
|
}
|
|
|
|
const CLICK_TABLE = "moz_newtab_story_click";
|
|
const IMPRESSION_TABLE = "moz_newtab_story_impression";
|
|
const TEST_MODEL_ID = "TEST";
|
|
|
|
/**
|
|
* A feature that periodically generates a interest vector for inferred personalization.
|
|
*/
|
|
export class InferredPersonalizationFeed {
|
|
constructor() {
|
|
this.loaded = false;
|
|
this.cache = this.PersistentCache(CACHE_KEY, true);
|
|
}
|
|
|
|
async reset() {
|
|
if (this.cache) {
|
|
await this.cache.set("interest_vector", {});
|
|
}
|
|
this.loaded = false;
|
|
this.store.dispatch(
|
|
ac.OnlyToMain({
|
|
type: at.INFERRED_PERSONALIZATION_RESET,
|
|
})
|
|
);
|
|
}
|
|
|
|
isEnabled() {
|
|
return (
|
|
this.store.getState().Prefs.values[PREF_USER_INFERRED_PERSONALIZATION] &&
|
|
this.store.getState().Prefs.values[PREF_SYSTEM_INFERRED_PERSONALIZATION]
|
|
);
|
|
}
|
|
|
|
async init() {
|
|
await this.loadInterestVector(true /* isStartup */);
|
|
}
|
|
|
|
async queryDatabaseForTimeIntervals(intervals, table) {
|
|
let results = [];
|
|
for (const interval of intervals) {
|
|
const agg = await this.fetchInferredPersonalizationSummary(
|
|
interval.start,
|
|
interval.end,
|
|
table
|
|
);
|
|
results.push(agg);
|
|
}
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Get Inferrred model raw data
|
|
* @returns JSON of inferred model
|
|
*/
|
|
async getInferredModelData() {
|
|
const modelOverrideRaw =
|
|
this.store.getState().Prefs.values[PREF_SYSTEM_INFERRED_MODEL_OVERRIDE];
|
|
if (modelOverrideRaw) {
|
|
if (modelOverrideRaw === TEST_MODEL_ID) {
|
|
return {
|
|
model_id: TEST_MODEL_ID,
|
|
model_data: DEFAULT_INFERRED_MODEL_DATA,
|
|
};
|
|
}
|
|
try {
|
|
return JSON.parse(modelOverrideRaw);
|
|
} catch (_error) {}
|
|
}
|
|
const dsCache = this.PersistentCache(DISCOVERY_STREAM_CACHE_KEY, true);
|
|
const cachedData = (await dsCache.get()) || {};
|
|
let { inferredModel } = cachedData;
|
|
return inferredModel;
|
|
}
|
|
|
|
async generateInterestVector() {
|
|
const inferredModel = await this.getInferredModelData();
|
|
if (!inferredModel || !inferredModel.model_data) {
|
|
return {};
|
|
}
|
|
const model = FeatureModel.fromJSON(inferredModel.model_data);
|
|
|
|
const intervals = model.getDateIntervals(this.Date().now());
|
|
const schema = {
|
|
[AggregateResultKeys.FEATURE]: 0,
|
|
[AggregateResultKeys.FORMAT_ENUM]: 1,
|
|
[AggregateResultKeys.VALUE]: 2,
|
|
};
|
|
|
|
const aggClickPerInterval = await this.queryDatabaseForTimeIntervals(
|
|
intervals,
|
|
CLICK_TABLE
|
|
);
|
|
|
|
const interests = model.computeInterestVectors({
|
|
dataForIntervals: aggClickPerInterval,
|
|
indexSchema: schema,
|
|
model_id: inferredModel.model_id,
|
|
});
|
|
|
|
if (model.modelType === MODEL_TYPE.CLICKS) {
|
|
return interests;
|
|
}
|
|
|
|
if (
|
|
model.modelType === MODEL_TYPE.CLICK_IMP_PAIR ||
|
|
model.modelType === MODEL_TYPE.CTR
|
|
) {
|
|
// This model type does not support differential privacy or thresholding
|
|
const aggImpressionsPerInterval =
|
|
await this.queryDatabaseForTimeIntervals(intervals, IMPRESSION_TABLE);
|
|
const ivImpressions = model.computeInterestVector({
|
|
dataForIntervals: aggImpressionsPerInterval,
|
|
indexSchema: schema,
|
|
});
|
|
|
|
if (model.modelType === MODEL_TYPE.CTR) {
|
|
const inferredInterests = model.computeCTRInterestVectors(
|
|
interests.inferredInterests,
|
|
ivImpressions,
|
|
inferredModel.model_id
|
|
);
|
|
return { inferredInterests };
|
|
}
|
|
const res = {
|
|
c: interests.inferredInterests,
|
|
i: ivImpressions,
|
|
model_id: inferredModel.model_id,
|
|
};
|
|
return { inferredInterests: res };
|
|
}
|
|
|
|
// unsupported modelType
|
|
return {};
|
|
}
|
|
|
|
async loadInterestVector(isStartup = false) {
|
|
const cachedData = (await this.cache.get()) || {};
|
|
let { interest_vector } = cachedData;
|
|
|
|
// If we have nothing in cache, or cache has expired, we can make a fresh fetch.
|
|
if (
|
|
!interest_vector?.lastUpdated ||
|
|
!(
|
|
this.Date().now() - interest_vector.lastUpdated <
|
|
INTEREST_VECTOR_UPDATE_TIME
|
|
)
|
|
) {
|
|
interest_vector = {
|
|
data: await this.generateInterestVector(),
|
|
lastUpdated: this.Date().now(),
|
|
};
|
|
}
|
|
await this.cache.set("interest_vector", interest_vector);
|
|
this.loaded = true;
|
|
|
|
this.store.dispatch(
|
|
ac.OnlyToMain({
|
|
type: at.INFERRED_PERSONALIZATION_UPDATE,
|
|
data: {
|
|
lastUpdated: interest_vector.lastUpdated,
|
|
inferredInterests: interest_vector.data.inferredInterests,
|
|
coarseInferredInterests: interest_vector.data.coarseInferredInterests,
|
|
coarsePrivateInferredInterests:
|
|
interest_vector.data.coarsePrivateInferredInterests,
|
|
},
|
|
meta: {
|
|
isStartup,
|
|
},
|
|
})
|
|
);
|
|
}
|
|
|
|
async handleDiscoveryStreamImpressionStats(action) {
|
|
const { tiles } = action.data;
|
|
|
|
for (const tile of tiles) {
|
|
const { type, format, pos, topic, section_position, features } = tile;
|
|
if (["organic"].includes(type)) {
|
|
await this.recordInferredPersonalizationImpression({
|
|
format,
|
|
pos,
|
|
topic,
|
|
section_position,
|
|
features,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
async handleDiscoveryStreamUserEvent(action) {
|
|
switch (action.data?.event) {
|
|
case "OPEN_NEW_WINDOW":
|
|
case "CLICK": {
|
|
const { card_type, format, topic, section_position, features } =
|
|
action.data.value ?? {};
|
|
const pos = action.data.action_position;
|
|
if (["organic"].includes(card_type)) {
|
|
await this.recordInferredPersonalizationClick({
|
|
format,
|
|
pos,
|
|
topic,
|
|
section_position,
|
|
features,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
async recordInferredPersonalizationImpression(tile) {
|
|
await this.recordInferredPersonalizationInteraction(IMPRESSION_TABLE, tile);
|
|
}
|
|
async recordInferredPersonalizationClick(tile) {
|
|
await this.recordInferredPersonalizationInteraction(
|
|
CLICK_TABLE,
|
|
tile,
|
|
true
|
|
);
|
|
}
|
|
|
|
async fetchInferredPersonalizationImpression() {
|
|
return await this.fetchInferredPersonalizationInteraction(
|
|
"moz_newtab_story_impression"
|
|
);
|
|
}
|
|
|
|
async fetchInferredPersonalizationSummary(startTime, endTime, table) {
|
|
let sql = `SELECT feature, card_format_enum, SUM(feature_value) FROM ${table}
|
|
WHERE timestamp_s > ${timeMSToSeconds(startTime)}
|
|
AND timestamp_s < ${timeMSToSeconds(endTime)}
|
|
GROUP BY feature, card_format_enum`;
|
|
const { activityStreamProvider } = lazy.NewTabUtils;
|
|
const interactions = await activityStreamProvider.executePlacesQuery(sql);
|
|
return interactions;
|
|
}
|
|
|
|
async recordInferredPersonalizationInteraction(
|
|
table,
|
|
tile,
|
|
extraClickEvent = false
|
|
) {
|
|
const timestamp_s = timeMSToSeconds(this.Date().now());
|
|
const card_format_enum = FORMAT[tile.format];
|
|
const position = tile.pos;
|
|
const section_position = tile.section_position || 0;
|
|
let featureValuePairs = [];
|
|
if (extraClickEvent) {
|
|
featureValuePairs.push(["click", 1]);
|
|
}
|
|
if (tile.features) {
|
|
featureValuePairs = featureValuePairs.concat(
|
|
Object.entries(tile.features)
|
|
);
|
|
}
|
|
if (table !== CLICK_TABLE && table !== IMPRESSION_TABLE) {
|
|
return;
|
|
}
|
|
const primaryValues = {
|
|
timestamp_s,
|
|
card_format_enum,
|
|
position,
|
|
section_position,
|
|
};
|
|
|
|
const insertValues = featureValuePairs.map(pair =>
|
|
Object.assign({}, primaryValues, {
|
|
feature: pair[0],
|
|
feature_value: pair[1],
|
|
})
|
|
);
|
|
|
|
let sql = `
|
|
INSERT INTO ${table}(feature, timestamp_s, card_format_enum, position, section_position, feature_value)
|
|
VALUES (:feature, :timestamp_s, :card_format_enum, :position, :section_position, :feature_value)
|
|
`;
|
|
await lazy.PlacesUtils.withConnectionWrapper(
|
|
"newtab/lib/InferredPersonalizationFeed.sys.mjs: recordInferredPersonalizationImpression",
|
|
async db => {
|
|
await db.execute(sql, insertValues);
|
|
}
|
|
);
|
|
}
|
|
|
|
async fetchInferredPersonalizationInteraction(table) {
|
|
if (
|
|
table !== "moz_newtab_story_impression" &&
|
|
table !== "moz_newtab_story_click"
|
|
) {
|
|
return [];
|
|
}
|
|
|
|
let sql = `SELECT feature, timestamp_s, card_format_enum, position, section_position, feature_value
|
|
FROM ${table}`;
|
|
//sql += `WHERE timestamp_s >= ${beginTimeSecs * 1000000}`;
|
|
//sql += `AND timestamp_s < ${endTimeSecs * 1000000}`;
|
|
|
|
const { activityStreamProvider } = lazy.NewTabUtils;
|
|
const interactions = await activityStreamProvider.executePlacesQuery(sql);
|
|
|
|
return interactions;
|
|
}
|
|
|
|
async onPrefChangedAction(action) {
|
|
switch (action.data.name) {
|
|
case PREF_USER_INFERRED_PERSONALIZATION:
|
|
case PREF_SYSTEM_INFERRED_PERSONALIZATION:
|
|
if (this.isEnabled() && action.data.value) {
|
|
await this.loadInterestVector();
|
|
} else {
|
|
await this.reset();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
async onAction(action) {
|
|
switch (action.type) {
|
|
case at.INIT:
|
|
if (this.isEnabled()) {
|
|
await this.init();
|
|
}
|
|
break;
|
|
case at.UNINIT:
|
|
await this.reset();
|
|
break;
|
|
case at.DISCOVERY_STREAM_DEV_SYSTEM_TICK:
|
|
case at.SYSTEM_TICK:
|
|
if (this.loaded && this.isEnabled()) {
|
|
await this.loadInterestVector();
|
|
}
|
|
break;
|
|
case at.INFERRED_PERSONALIZATION_REFRESH:
|
|
if (this.loaded && this.isEnabled()) {
|
|
await this.reset();
|
|
await this.loadInterestVector();
|
|
}
|
|
break;
|
|
case at.PLACES_HISTORY_CLEARED:
|
|
// TODO Handle places history clear
|
|
break;
|
|
case at.DISCOVERY_STREAM_IMPRESSION_STATS:
|
|
if (this.loaded && this.isEnabled()) {
|
|
await this.handleDiscoveryStreamImpressionStats(action);
|
|
}
|
|
break;
|
|
case at.DISCOVERY_STREAM_USER_EVENT:
|
|
if (this.loaded && this.isEnabled()) {
|
|
await this.handleDiscoveryStreamUserEvent(action);
|
|
}
|
|
break;
|
|
case at.PREF_CHANGED:
|
|
await this.onPrefChangedAction(action);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creating a thin wrapper around PersistentCache, and Date.
|
|
* This makes it easier for us to write automated tests that simulate responses.
|
|
*/
|
|
InferredPersonalizationFeed.prototype.PersistentCache = (...args) => {
|
|
return new lazy.PersistentCache(...args);
|
|
};
|
|
InferredPersonalizationFeed.prototype.Date = () => {
|
|
return Date;
|
|
};
|