commit 7439d6aacf5dde98a8750272a790d6919486847d
parent 6d5ada44aabf2066d1f4144e529b45da0fd13ad7
Author: Chidam Gopal <cgopal@mozilla.com>
Date: Mon, 15 Dec 2025 22:55:41 +0000
Bug 2005768 - Insights scheduler for generation from history r=gregtatum,cdipersio,ai-models-reviewers
Differential Revision: https://phabricator.services.mozilla.com/D276232
Diffstat:
8 files changed, 438 insertions(+), 7 deletions(-)
diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js
@@ -2250,6 +2250,8 @@ pref("browser.ml.smartAssist.overrideNewTab", false);
// AI Window Feature
pref("browser.aiwindow.enabled", false);
pref("browser.aiwindow.chatStore.loglevel", "Error");
+pref("browser.aiwindow.insights", false);
+pref("browser.aiwindow.insightsLogLevel", "Warn");
// Block insecure active content on https pages
pref("security.mixed_content.block_active_content", true);
diff --git a/browser/base/content/test/static/browser_all_files_referenced.js b/browser/base/content/test/static/browser_all_files_referenced.js
@@ -368,9 +368,9 @@ var allowlist = [
{
file: "chrome://browser/content/aiwindow/firstrun.html",
},
- // Bug 2005524 - Insights drift detector for generation from history
+ // Bug 2005768 - Insights scheduler for generation from history
{
- file: "moz-src:///browser/components/aiwindow/models/InsightsDriftDetector.sys.mjs",
+ file: "moz-src:///browser/components/aiwindow/models/InsightsHistoryScheduler.sys.mjs",
},
// Bug 2000987 - get user messages from chat source
{
diff --git a/browser/components/aiwindow/models/InsightsConstants.sys.mjs b/browser/components/aiwindow/models/InsightsConstants.sys.mjs
@@ -50,3 +50,13 @@ export const INTENTS_LIST = [
"Entertain / Relax",
"Resume / Revisit",
];
+
+// if generate insights is enabled. This is used by
+// - InsightsScheduler
+export const PREF_GENERATE_INSIGHTS = "browser.aiwindow.insights";
+
+// Number of latest sessions to check drift
+export const DRIFT_EVAL_DELTA_COUNT = 3;
+
+// Quantile of baseline scores used as a threshold (e.g. 0.9 => 90th percentile).
+export const DRIFT_TRIGGER_QUANTILE = 0.9;
diff --git a/browser/components/aiwindow/models/InsightsDriftDetector.sys.mjs b/browser/components/aiwindow/models/InsightsDriftDetector.sys.mjs
@@ -7,6 +7,13 @@ import { PlacesUtils } from "resource://gre/modules/PlacesUtils.sys.mjs";
import { InsightsManager } from "moz-src:///browser/components/aiwindow/models/InsightsManager.sys.mjs";
import { sessionizeVisits } from "moz-src:///browser/components/aiwindow/models/InsightsHistorySource.sys.mjs";
+import {
+ // How many of the most recent delta sessions to evaluate against thresholds.
+ DRIFT_EVAL_DELTA_COUNT as DEFAULT_EVAL_DELTA_COUNT,
+ // Quantile of baseline scores used as a threshold (e.g. 0.9 => 90th percentile).
+ DRIFT_TRIGGER_QUANTILE as DEFAULT_TRIGGER_QUANTILE,
+} from "moz-src:///browser/components/aiwindow/models/InsightsConstants.sys.mjs";
+
/**
* @typedef {object} SessionMetric
* @property {string|number} sessionId Unique identifier for the session
@@ -34,16 +41,11 @@ import { sessionizeVisits } from "moz-src:///browser/components/aiwindow/models/
* and compare recent delta sessions to those thresholds to decide a trigger.
*/
-// Quantile of baseline scores used as a threshold (e.g. 0.9 => 90th percentile).
-const DEFAULT_TRIGGER_QUANTILE = 0.9;
// Lookback period before lastHistoryInsightTS to define the baseline window.
const DRIFT_LOOKBACK_DAYS = 14;
// Cap on how many visits to fetch from Places.
const DRIFT_HISTORY_LIMIT = 5000;
-// How many of the most recent delta sessions to evaluate against thresholds.
-const DEFAULT_EVAL_DELTA_COUNT = 3;
-
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const MICROS_PER_MS = 1000;
const EPS = 1e-12;
diff --git a/browser/components/aiwindow/models/InsightsHistoryScheduler.sys.mjs b/browser/components/aiwindow/models/InsightsHistoryScheduler.sys.mjs
@@ -0,0 +1,264 @@
+/* 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, {
+ setInterval: "resource://gre/modules/Timer.sys.mjs",
+ clearInterval: "resource://gre/modules/Timer.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ InsightsManager:
+ "moz-src:///browser/components/aiwindow/models/InsightsManager.sys.mjs",
+ InsightsDriftDetector:
+ "moz-src:///browser/components/aiwindow/models/InsightsDriftDetector.sys.mjs",
+ PREF_GENERATE_INSIGHTS:
+ "moz-src:///browser/components/aiwindow/models/InsightsConstants.sys.mjs",
+ DRIFT_EVAL_DELTA_COUNT:
+ "moz-src:///browser/components/aiwindow/models/InsightsConstants.sys.mjs",
+ DRIFT_TRIGGER_QUANTILE:
+ "moz-src:///browser/components/aiwindow/models/InsightsConstants.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "console", function () {
+ return console.createInstance({
+ prefix: "InsightsHistoryScheduler",
+ maxLogLevelPref: "browser.aiwindow.insightsLogLevel",
+ });
+});
+
+// Special case - Minimum number of pages before the first time insights run.
+const INITIAL_INSIGHTS_PAGES_THRESHOLD = 10;
+
+// Only run if at least this many pages have been visited.
+const INSIGHTS_SCHEDULER_PAGES_THRESHOLD = 25;
+
+// Insights history schedule every 6 hours
+const INSIGHTS_SCHEDULER_INTERVAL_MS = 6 * 60 * 60 * 1000;
+
+/**
+ * Schedules periodic generation of browsing history based insights.
+ *
+ * This decides based on the #pagesVisited and periodically evaluates history drift metrics.
+ * Triggers insights generation when drift exceeds a configured threshold.
+ *
+ * E.g. Usage: InsightsHistoryScheduler.maybeInit()
+ */
+export class InsightsHistoryScheduler {
+ #pagesVisited = 0;
+ #intervalHandle = 0;
+ #destroyed = false;
+ #running = false;
+
+ /** @type {InsightsHistoryScheduler | null} */
+ static #instance = null;
+
+ /**
+ * Initializes the scheduler if the relevant pref is enabled.
+ *
+ * This should be called from startup/feature initialization code.
+ *
+ * @returns {InsightsHistoryScheduler|null}
+ * The scheduler instance if initialized, otherwise null.
+ */
+ static maybeInit() {
+ if (!Services.prefs.getBoolPref(lazy.PREF_GENERATE_INSIGHTS, false)) {
+ return null;
+ }
+ if (!this.#instance) {
+ this.#instance = new InsightsHistoryScheduler();
+ }
+
+ return this.#instance;
+ }
+
+ /**
+ * Creates a new scheduler instance.
+ *
+ * The constructor:
+ * - Starts the periodic interval timer.
+ * - Subscribes to Places "page-visited" notifications.
+ */
+ constructor() {
+ this.#startInterval();
+ lazy.PlacesUtils.observers.addListener(
+ ["page-visited"],
+ this.#onPageVisited
+ );
+ lazy.console.debug("[InsightsHistoryScheduler] Initialized");
+ }
+
+ /**
+ * Starts the interval that periodically evaluates history drift and
+ * potentially triggers insight generation.
+ *
+ * @throws {Error} If an interval is already running.
+ */
+ #startInterval() {
+ if (this.#intervalHandle) {
+ throw new Error(
+ "Attempting to start an interval when one already existed"
+ );
+ }
+ this.#intervalHandle = lazy.setInterval(
+ this.#onInterval,
+ INSIGHTS_SCHEDULER_INTERVAL_MS
+ );
+ }
+
+ /**
+ * Stops the currently running interval, if any.
+ */
+ #stopInterval() {
+ if (this.#intervalHandle) {
+ lazy.clearInterval(this.#intervalHandle);
+ this.#intervalHandle = 0;
+ }
+ }
+
+ /**
+ * Places "page-visited" observer callback.
+ *
+ * Increments the internal counter of pages visited since the last
+ * successful insight generation run.
+ */
+ #onPageVisited = () => {
+ this.#pagesVisited++;
+ };
+
+ /**
+ * Periodic interval handler.
+ *
+ * - Skips if the scheduler is destroyed or already running.
+ * - Skips if the minimum pages-visited threshold is not met.
+ * - Computes history drift metrics and decides whether to run insights.
+ * - Invokes {@link lazy.InsightsManager.generateInsightsFromBrowsingHistory}
+ * when appropriate.
+ *
+ * @private
+ * @returns {Promise<void>} Resolves once the interval run completes.
+ */
+ #onInterval = async () => {
+ if (this.#destroyed) {
+ lazy.console.warn(
+ "[InsightsHistoryScheduler] Interval fired after destroy; ignoring."
+ );
+ return;
+ }
+
+ if (this.#running) {
+ lazy.console.debug(
+ "[InsightsHistoryScheduler] Skipping run because a previous run is still in progress."
+ );
+ return;
+ }
+
+ this.#running = true;
+ this.#stopInterval();
+
+ try {
+ // Detect whether generated history insights were before.
+ const lastInsightTs =
+ (await lazy.InsightsManager.getLastHistoryInsightTimestamp()) ?? 0;
+ const isFirstRun = lastInsightTs === 0;
+ const minPagesThreshold = isFirstRun
+ ? INITIAL_INSIGHTS_PAGES_THRESHOLD
+ : INSIGHTS_SCHEDULER_PAGES_THRESHOLD;
+
+ if (this.#pagesVisited < minPagesThreshold) {
+ lazy.console.debug(
+ `[InsightsHistoryScheduler] Not enough pages visited (${this.#pagesVisited}/${minPagesThreshold}); ` +
+ `skipping analysis. isFirstRun=${isFirstRun}`
+ );
+ return;
+ }
+
+ if (!isFirstRun) {
+ lazy.console.debug(
+ "[InsightsHistoryScheduler] Computing history drift metrics before running insights..."
+ );
+
+ const { baselineMetrics, deltaMetrics, trigger } =
+ await lazy.InsightsDriftDetector.computeHistoryDriftAndTrigger({
+ triggerQuantile: lazy.DRIFT_TRIGGER_QUANTILE,
+ evalDeltaCount: lazy.DRIFT_EVAL_DELTA_COUNT,
+ });
+
+ if (!baselineMetrics.length || !deltaMetrics.length) {
+ lazy.console.debug(
+ "[InsightsHistoryScheduler] Drift metrics incomplete (no baseline or delta); falling back to non-drift scheduling."
+ );
+ } else if (!trigger.triggered) {
+ lazy.console.debug(
+ "[InsightsHistoryScheduler] History drift below threshold; skipping insights run for this interval."
+ );
+ // Reset pages so we don’t repeatedly attempt with the same data.
+ this.#pagesVisited = 0;
+ return;
+ } else {
+ lazy.console.debug(
+ `[InsightsHistoryScheduler] Drift triggered (jsThreshold=${trigger.jsThreshold.toFixed(4)}, ` +
+ `surpriseThreshold=${trigger.surpriseThreshold.toFixed(4)}); sessions=${trigger.triggeredSessionIds.join(
+ ","
+ )}`
+ );
+ }
+ }
+
+ lazy.console.debug(
+ `[InsightsHistoryScheduler] Generating insights from history with ${this.#pagesVisited} new pages`
+ );
+ await lazy.InsightsManager.generateInsightsFromBrowsingHistory();
+ this.#pagesVisited = 0;
+
+ lazy.console.debug(
+ "[InsightsHistoryScheduler] History insights generation complete."
+ );
+ } catch (error) {
+ lazy.console.error(
+ "[InsightsHistoryScheduler] Failed to generate history insights",
+ error
+ );
+ } finally {
+ if (!this.#destroyed) {
+ this.#startInterval();
+ }
+ this.#running = false;
+ }
+ };
+
+ /**
+ * Cleans up scheduler resources.
+ *
+ * Stops the interval, unsubscribes from Places notifications,
+ * and marks the scheduler as destroyed so future interval ticks
+ * are ignored.
+ */
+ destroy() {
+ this.#stopInterval();
+ lazy.PlacesUtils.observers.removeListener(
+ ["page-visited"],
+ this.#onPageVisited
+ );
+ this.#destroyed = true;
+ lazy.console.debug("[InsightsHistoryScheduler] Destroyed");
+ }
+
+ /**
+ * Testing helper: set pagesVisited count.
+ * Not used in production code.
+ *
+ * @param {number} count
+ */
+ setPagesVisitedForTesting(count) {
+ this.#pagesVisited = count;
+ }
+
+ /**
+ * Testing helper: runs the interval handler once immediately.
+ * Not used in production code.
+ */
+ async runNowForTesting() {
+ await this.#onInterval();
+ }
+}
diff --git a/browser/components/aiwindow/models/moz.build b/browser/components/aiwindow/models/moz.build
@@ -16,6 +16,7 @@ MOZ_SRC_FILES += [
"InsightsChatSource.sys.mjs",
"InsightsConstants.sys.mjs",
"InsightsDriftDetector.sys.mjs",
+ "InsightsHistoryScheduler.sys.mjs",
"InsightsHistorySource.sys.mjs",
"InsightsManager.sys.mjs",
"InsightsSchemas.sys.mjs",
diff --git a/browser/components/aiwindow/models/tests/xpcshell/test_InsightsHistoryScheduler.js b/browser/components/aiwindow/models/tests/xpcshell/test_InsightsHistoryScheduler.js
@@ -0,0 +1,150 @@
+"use strict";
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+const { InsightsHistoryScheduler } = ChromeUtils.importESModule(
+ "moz-src:///browser/components/aiwindow/models/InsightsHistoryScheduler.sys.mjs"
+);
+const { InsightsDriftDetector } = ChromeUtils.importESModule(
+ "moz-src:///browser/components/aiwindow/models/InsightsDriftDetector.sys.mjs"
+);
+const { InsightsManager } = ChromeUtils.importESModule(
+ "moz-src:///browser/components/aiwindow/models/InsightsManager.sys.mjs"
+);
+
+const { PREF_GENERATE_INSIGHTS } = ChromeUtils.importESModule(
+ "moz-src:///browser/components/aiwindow/models/InsightsConstants.sys.mjs"
+);
+
+// insert N visits so the scheduler crosses its page threshold.
+async function addTestVisits(count) {
+ let seeded = [];
+ let base = Date.now();
+ for (let i = 0; i < count; i++) {
+ seeded.push({
+ url: `https://example${i}.com/`,
+ title: `Example ${i}`,
+ visits: [{ date: new Date(base - i * 1000) }],
+ });
+ }
+ await PlacesUtils.history.insertMany(seeded);
+}
+
+registerCleanupFunction(async () => {
+ Services.prefs.clearUserPref(PREF_GENERATE_INSIGHTS);
+ await PlacesUtils.history.clear();
+});
+
+// Drift triggers => insights run
+add_task(async function test_scheduler_runs_when_drift_triggers() {
+ Services.prefs.setBoolPref(PREF_GENERATE_INSIGHTS, true);
+
+ const generateStub = sinon
+ .stub(InsightsManager, "generateInsightsFromBrowsingHistory")
+ .resolves();
+
+ const driftStub = sinon
+ .stub(InsightsDriftDetector, "computeHistoryDriftAndTrigger")
+ .resolves({
+ baselineMetrics: [{ sessionId: 1, jsScore: 0.1, avgSurprisal: 1.0 }],
+ deltaMetrics: [{ sessionId: 2, jsScore: 0.9, avgSurprisal: 3.0 }],
+ trigger: {
+ jsThreshold: 0.5,
+ surpriseThreshold: 2.0,
+ triggered: true,
+ triggeredSessionIds: [2],
+ },
+ });
+
+ try {
+ let scheduler = InsightsHistoryScheduler.maybeInit();
+
+ // Force pagesVisited above threshold for the test.
+ scheduler.setPagesVisitedForTesting(100);
+
+ // Run the interval logic once.
+ await scheduler.runNowForTesting();
+
+ sinon.assert.calledOnce(generateStub);
+ } finally {
+ generateStub.restore();
+ driftStub.restore();
+ }
+});
+
+// Drift does NOT trigger => insights skipped
+add_task(async function test_scheduler_skips_when_drift_not_triggered() {
+ Services.prefs.setBoolPref(PREF_GENERATE_INSIGHTS, true);
+
+ const generateStub = sinon
+ .stub(InsightsManager, "generateInsightsFromBrowsingHistory")
+ .resolves();
+
+ const driftStub = sinon
+ .stub(InsightsDriftDetector, "computeHistoryDriftAndTrigger")
+ .resolves({
+ baselineMetrics: [{ sessionId: 1, jsScore: 0.1, avgSurprisal: 1.0 }],
+ deltaMetrics: [{ sessionId: 2, jsScore: 0.2, avgSurprisal: 1.2 }],
+ trigger: {
+ jsThreshold: 0.5,
+ surpriseThreshold: 2.0,
+ triggered: false,
+ triggeredSessionIds: [],
+ },
+ });
+
+ try {
+ let scheduler = InsightsHistoryScheduler.maybeInit();
+ await addTestVisits(60);
+ await scheduler.runNowForTesting();
+ sinon.assert.notCalled(generateStub);
+ } finally {
+ generateStub.restore();
+ driftStub.restore();
+ }
+});
+
+// First run (no previous insights) => insights run even with small history.
+add_task(async function test_scheduler_runs_on_first_run_with_small_history() {
+ Services.prefs.setBoolPref(PREF_GENERATE_INSIGHTS, true);
+
+ const generateStub = sinon
+ .stub(InsightsManager, "generateInsightsFromBrowsingHistory")
+ .resolves();
+
+ const driftStub = sinon
+ .stub(InsightsDriftDetector, "computeHistoryDriftAndTrigger")
+ .resolves({
+ baselineMetrics: [],
+ deltaMetrics: [],
+ trigger: {
+ jsThreshold: 0,
+ surpriseThreshold: 0,
+ triggered: false,
+ triggeredSessionIds: [],
+ },
+ });
+
+ const lastTsStub = sinon
+ .stub(InsightsManager, "getLastHistoryInsightTimestamp")
+ .resolves(0);
+
+ try {
+ let scheduler = InsightsHistoryScheduler.maybeInit();
+ Assert.ok(scheduler, "Scheduler should be initialized when pref is true");
+
+ // Set a number of pages that is:
+ // - below the normal threshold (25), but
+ // - high enough for the special first-run threshold (e.g. 15).
+ scheduler.setPagesVisitedForTesting(20);
+
+ // Run the interval logic once.
+ await scheduler.runNowForTesting();
+ sinon.assert.calledOnce(generateStub);
+ } finally {
+ generateStub.restore();
+ driftStub.restore();
+ lastTsStub.restore();
+ }
+});
diff --git a/browser/components/aiwindow/models/tests/xpcshell/xpcshell.toml b/browser/components/aiwindow/models/tests/xpcshell/xpcshell.toml
@@ -16,6 +16,8 @@ support-files = []
["test_InsightsDriftDetector.js"]
+["test_InsightsHistoryScheduler.js"]
+
["test_InsightsHistorySource.js"]
["test_InsightsManager.js"]