tor-browser

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

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:
Mbrowser/app/profile/firefox.js | 2++
Mbrowser/base/content/test/static/browser_all_files_referenced.js | 4++--
Mbrowser/components/aiwindow/models/InsightsConstants.sys.mjs | 10++++++++++
Mbrowser/components/aiwindow/models/InsightsDriftDetector.sys.mjs | 12+++++++-----
Abrowser/components/aiwindow/models/InsightsHistoryScheduler.sys.mjs | 264+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/aiwindow/models/moz.build | 1+
Abrowser/components/aiwindow/models/tests/xpcshell/test_InsightsHistoryScheduler.js | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/aiwindow/models/tests/xpcshell/xpcshell.toml | 2++
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"]