commit 0c757dda4ee784e34485f6e415d31f5e633bfbf1 parent d417e1ff9670d771aca9a729a00ef11bd639b28f Author: Christopher DiPersio <cdipersio@mozilla.com> Date: Wed, 17 Dec 2025 21:25:26 +0000 Bug 2006090 - Insight updation - Day 0 and incremental updates from Chat history r=tzhang,ai-models-reviewers Differential Revision: https://phabricator.services.mozilla.com/D276690 Diffstat:
10 files changed, 604 insertions(+), 22 deletions(-)
diff --git a/browser/base/content/test/static/browser_all_files_referenced.js b/browser/base/content/test/static/browser_all_files_referenced.js @@ -371,11 +371,15 @@ var allowlist = [ // Bug 2000987 - get user messages from chat source { file: "moz-src:///browser/components/aiwindow/models/InsightsChatSource.sys.mjs", - // Bug 2003303 - Implement Title Generation (backed out due to unused file) }, + // Bug 2003303 - Implement Title Generation (backed out due to unused file) { file: "moz-src:///browser/components/aiwindow/models/TitleGeneration.sys.mjs", }, + // Bug 2006090 - Insight updation - Day 0 and incremental updates from Chat history + { + file: "moz-src:///browser/components/aiwindow/models/InsightsConversationScheduler.sys.mjs", + }, ]; if (AppConstants.NIGHTLY_BUILD) { diff --git a/browser/components/aiwindow/models/InsightsConversationScheduler.sys.mjs b/browser/components/aiwindow/models/InsightsConversationScheduler.sys.mjs @@ -0,0 +1,140 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + setInterval: "resource://gre/modules/Timer.sys.mjs", + clearInterval: "resource://gre/modules/Timer.sys.mjs", + InsightsManager: + "moz-src:///browser/components/aiwindow/models/InsightsManager.sys.mjs", + getRecentChats: + "moz-src:///browser/components/aiwindow/models/InsightsChatSource.sys.mjs", + PREF_GENERATE_INSIGHTS: + "moz-src:///browser/components/aiwindow/models/InsightsConstants.sys.mjs", +}); +ChromeUtils.defineLazyGetter(lazy, "console", function () { + return console.createInstance({ + prefix: "InsightsConversationScheduler", + maxLogLevelPref: "browser.aiwindow.insightsLogLevel", + }); +}); + +// Generate insights if there have been at least 10 user messages since the last run +const INSIGHTS_SCHEDULER_MESSAGES_THRESHOLD = 10; + +// Insights conversation schedule every 4 hours +const INSIGHTS_SCHEDULER_INTERVAL_MS = 4 * 60 * 60 * 1000; + +/** + * Schedules periodic generation of conversation-based insights. + * Triggers insights generation when number of user messages exceeds the configured threshold ({@link INSIGHTS_SCHEDULER_MESSAGES_THRESHOLD}) + * + * E.g. Usage: InsightsConversationScheduler.maybeInit() + */ +export class InsightsConversationScheduler { + #intervalHandle = 0; + #destroyed = false; + #running = false; + + /** @type {InsightsConversationScheduler | null} */ + static #instance = null; + + static maybeInit() { + if (!Services.prefs.getBoolPref(lazy.PREF_GENERATE_INSIGHTS, false)) { + return null; + } + if (!this.#instance) { + this.#instance = new InsightsConversationScheduler(); + } + return this.#instance; + } + + constructor() { + this.#startInterval(); + lazy.console.debug("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; + } + } + + #onInterval = async () => { + if (this.#destroyed) { + lazy.console.warn("Interval fired after destroy; ignoring."); + return; + } + + if (this.#running) { + lazy.console.debug( + "Skipping run because a previous run is still in progress." + ); + return; + } + + this.#running = true; + this.#stopInterval(); + + try { + // Detect whether conversation insights were generated before. + const lastInsightTs = + (await lazy.InsightsManager.getLastConversationInsightTimestamp()) ?? 0; + + // Get user chat messages + const chatMessagesSinceLastInsight = + await lazy.getRecentChats(lastInsightTs); + + // Not enough new messages + if ( + chatMessagesSinceLastInsight.length < + INSIGHTS_SCHEDULER_MESSAGES_THRESHOLD + ) { + return; + } + + // Generate insights + await lazy.InsightsManager.generateInsightsFromConversationHistory(); + } catch (error) { + lazy.console.error("Failed to generate conversation insights", error); + } finally { + if (!this.#destroyed) { + this.#startInterval(); + } + this.#running = false; + } + }; + + destroy() { + this.#stopInterval(); + this.#destroyed = true; + lazy.console.debug("Destroyed"); + } + + async runNowForTesting() { + await this.#onInterval(); + } +} diff --git a/browser/components/aiwindow/models/InsightsManager.sys.mjs b/browser/components/aiwindow/models/InsightsManager.sys.mjs @@ -9,6 +9,7 @@ import { aggregateSessions, topkAggregates, } from "moz-src:///browser/components/aiwindow/models/InsightsHistorySource.sys.mjs"; +import { getRecentChats } from "./InsightsChatSource.sys.mjs"; import { openAIEngine, renderPrompt, @@ -41,6 +42,8 @@ const K_SEARCHES_DELTA = 10; const DEFAULT_HISTORY_FULL_LOOKUP_DAYS = 60; const DEFAULT_HISTORY_FULL_MAX_RESULTS = 3000; const DEFAULT_HISTORY_DELTA_MAX_RESULTS = 500; +const DEFAULT_CHAT_FULL_MAX_RESULTS = 50; +const DEFAULT_CHAT_HALF_LIFE_DAYS_FULL_RESULTS = 7; const LAST_HISTORY_INSIGHT_TS_ATTRIBUTE = "last_history_insight_ts"; const LAST_CONVERSATION_INSIGHT_TS_ATTRIBUTE = "last_chat_insight_ts"; @@ -50,6 +53,9 @@ const LAST_CONVERSATION_INSIGHT_TS_ATTRIBUTE = "last_chat_insight_ts"; export class InsightsManager { static #openAIEnginePromise = null; + // Exposed to be stubbed for testing + static _getRecentChats = getRecentChats; + /** * Creates and returns an class-level openAIEngine instance if one has not already been created. * This current pulls from the general browser.aiwindow.* prefs, but will likely pull from insights-specific ones in the future @@ -64,6 +70,36 @@ export class InsightsManager { } /** + * Generates, saves, and returns insights from pre-computed sources + * + * @param {object} sources User data source type to aggregrated records (i.e., {history: [domainItems, titleItems, searchItems]}) + * @param {string} sourceName Specific source type from which insights are generated ("history" or "conversation") + * @returns {Promise<Insight[]>} + * A promise that resolves to the list of persisted insights + * (newly created or updated), sorted and shaped as returned by + * {@link InsightStore.addInsight}. + */ + static async generateAndSaveInsightsFromSources(sources, sourceName) { + const now = Date.now(); + const existingInsights = await this.getAllInsights(); + const existingInsightsSummaries = existingInsights.map( + i => i.insight_summary + ); + const engine = await this.ensureOpenAIEngine(); + const insights = await generateInsights( + engine, + sources, + existingInsightsSummaries + ); + const { persistedInsights } = await this.saveInsights( + insights, + sourceName, + now + ); + return persistedInsights; + } + + /** * Generates and persists insights derived from the user's recent browsing history. * * This method: @@ -79,11 +115,7 @@ export class InsightsManager { * * Uses delta top-k settings (K_DOMAINS_DELTA, K_TITLES_DELTA, K_SEARCHES_DELTA). * 3. Calls {@link getAggregatedBrowserHistory} with the computed options to obtain * domain, title, and search aggregates. - * 4. Fetches existing insights via {@link getAllInsights}. - * 5. Ensures a shared OpenAI engine via {@link ensureOpenAIEngine} and calls - * {@link generateInsights} to produce new/updated insights. - * 6. Persists those insights via {@link saveInsights}, which also updates - * `last_history_insight_ts` in {@link InsightStore.updateMeta}. + * 4. Calls {@link generateAndSaveInsightsFromSources} with retrieved history to generate and save new insights. * * @returns {Promise<Insight[]>} * A promise that resolves to the list of persisted history insights @@ -128,22 +160,52 @@ export class InsightsManager { topkAggregatesOpts ); const sources = { history: [domainItems, titleItems, searchItems] }; - const existingInsights = await this.getAllInsights(); - const existingInsightsSummaries = existingInsights.map( - i => i.insight_summary - ); - const engine = await this.ensureOpenAIEngine(); - const insights = await generateInsights( - engine, + return await this.generateAndSaveInsightsFromSources( sources, - existingInsightsSummaries + SOURCE_HISTORY ); - const { persistedInsights } = await this.saveInsights( - insights, - SOURCE_HISTORY, - now + } + + /** + * Generates and persists insights derived from the user's recent chat history. + * + * This method: + * 1. Reads {@link last_chat_insight_ts} via {@link getLastConversationInsightTimestamp}. + * 2. Decides between: + * - Full processing (first run, no prior timestamp): + * * Pulls all messages from the beginning of time. + * - Delta processing (subsequent runs, prior timestamp present): + * * Pulls all messages since the last timestamp. + * 3. Calls {@link getRecentChats} with the computed options to obtain messages. + * 4. Calls {@link generateAndSaveInsightsFromSources} with messages to generate and save new insights. + * + * @returns {Promise<Insight[]>} + * A promise that resolves to the list of persisted conversation insights + * (newly created or updated), sorted and shaped as returned by + * {@link InsightStore.addInsight}. + */ + static async generateInsightsFromConversationHistory() { + // get last chat insight timestamp in ms + const lastTsMs = await this.getLastConversationInsightTimestamp(); + const isDelta = typeof lastTsMs === "number" && lastTsMs > 0; + + let startTime = 0; + + // If this is a subsequent run, set startTime to lastTsMs, the last time we generated chat-based insights + if (isDelta) { + startTime = lastTsMs; + } + + const chatMessages = await this._getRecentChats( + startTime, + DEFAULT_CHAT_FULL_MAX_RESULTS, + DEFAULT_CHAT_HALF_LIFE_DAYS_FULL_RESULTS + ); + const sources = { conversation: chatMessages }; + return await this.generateAndSaveInsightsFromSources( + sources, + SOURCE_CONVERSATION ); - return persistedInsights; } /** @@ -233,6 +295,19 @@ export class InsightsManager { } /** + * Returns the last timestamp (in ms since Unix epoch) when a chat-based + * insight was generated, as persisted in InsightStore.meta. + * + * If the store has never been updated, this returns 0. + * + * @returns {Promise<number>} Milliseconds since Unix epoch + */ + static async getLastConversationInsightTimestamp() { + const meta = await InsightStore.getMeta(); + return meta.last_chat_insight_ts || 0; + } + + /** * Persist a list of generated insights and update the appropriate meta timestamp. * * @param {Array<object>|null|undefined} generatedInsights diff --git a/browser/components/aiwindow/models/moz.build b/browser/components/aiwindow/models/moz.build @@ -15,6 +15,7 @@ MOZ_SRC_FILES += [ "Insights.sys.mjs", "InsightsChatSource.sys.mjs", "InsightsConstants.sys.mjs", + "InsightsConversationScheduler.sys.mjs", "InsightsDriftDetector.sys.mjs", "InsightsHistoryScheduler.sys.mjs", "InsightsHistorySource.sys.mjs", diff --git a/browser/components/aiwindow/models/tests/xpcshell/test_ChatUtils.js b/browser/components/aiwindow/models/tests/xpcshell/test_ChatUtils.js @@ -4,6 +4,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +do_get_profile(); + const { constructRealTimeInfoInjectionMessage, getLocalIsoTime, diff --git a/browser/components/aiwindow/models/tests/xpcshell/test_InsightsConversationScheduler.js b/browser/components/aiwindow/models/tests/xpcshell/test_InsightsConversationScheduler.js @@ -0,0 +1,161 @@ +/* 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/. */ + +do_get_profile(); +("use strict"); + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const { InsightsConversationScheduler } = ChromeUtils.importESModule( + "moz-src:///browser/components/aiwindow/models/InsightsConversationScheduler.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" +); +const { ChatStore, ChatMessage, MESSAGE_ROLE } = ChromeUtils.importESModule( + "moz-src:///browser/components/aiwindow/ui/modules/ChatStore.sys.mjs" +); + +// Clear insights pref after testing +add_setup(async function () { + registerCleanupFunction(() => { + Services.prefs.clearUserPref(PREF_GENERATE_INSIGHTS); + }); +}); + +/** + * Builds fake chat history data for testing + * + * @param {number} numMessagesToCreate Number of user messages to create (default: 10) + * @returns {Promise<ChatMessage[]>} Array of ChatMessage instances + */ +async function buildFakeChatHistory(numMessagesToCreate = 10) { + const fixedNow = 1_700_000_000_000; + + let messages = []; + for (let i = 0; i < numMessagesToCreate; i++) { + messages.push( + new ChatMessage({ + createdDate: fixedNow - i * 10_000, + ordinal: i + 1, + role: MESSAGE_ROLE.USER, + content: { type: "text", body: `Test message ${i + 1}` }, + pageUrl: `https://example.com/${i + 1}`, + turnIndex: 0, + }) + ); + } + + return messages; +} + +/** + * Tests the scheduler does not initialize when the insights preference is false + */ +add_task(async function test_schedule_not_init_when_pref_false() { + Services.prefs.setBoolPref(PREF_GENERATE_INSIGHTS, false); + + let scheduler = InsightsConversationScheduler.maybeInit(); + Assert.equal( + scheduler, + null, + "Scheduler should not be initialized when pref is false" + ); +}); + +/** + * Tests the scheduler initializes but does not run when there aren't enough messages + */ +add_task(async function test_scheduler_doesnt_run_with_insufficient_messages() { + Services.prefs.setBoolPref(PREF_GENERATE_INSIGHTS, true); + + // Need at least 10 messages for insights generation to trigger + // 5 will cause the expected failure + const messages = await buildFakeChatHistory(5); + const sb = sinon.createSandbox(); + + try { + const findMessagesStub = sb + .stub(ChatStore.prototype, "findMessagesByDate") + .callsFake(async () => { + return messages; + }); + + const lastTsStub = sb + .stub(InsightsManager, "getLastConversationInsightTimestamp") + .resolves(0); + + const generateStub = sb + .stub(InsightsManager, "generateInsightsFromConversationHistory") + .resolves(); + + let scheduler = InsightsConversationScheduler.maybeInit(); + Assert.ok(scheduler, "Scheduler should be initialized when pref is true"); + + await scheduler.runNowForTesting(); + Assert.ok( + findMessagesStub.calledOnce, + "Should check for recent messages once" + ); + Assert.ok( + lastTsStub.calledOnce, + "Should check last insight timestamp once" + ); + Assert.ok( + !generateStub.calledOnce, + "Insights generation should not be triggered with only 5 messages" + ); + } finally { + sb.restore(); + } +}); + +/** + * Tests the scheduler initializes and runs when there are enough messages + */ +add_task(async function test_scheduler_runs_with_small_history() { + Services.prefs.setBoolPref(PREF_GENERATE_INSIGHTS, true); + + const messages = await buildFakeChatHistory(); + const sb = sinon.createSandbox(); + + try { + const findMessagesStub = sb + .stub(ChatStore.prototype, "findMessagesByDate") + .callsFake(async () => { + return messages; + }); + + const lastTsStub = sb + .stub(InsightsManager, "getLastConversationInsightTimestamp") + .resolves(0); + + const generateStub = sb + .stub(InsightsManager, "generateInsightsFromConversationHistory") + .resolves(); + + let scheduler = InsightsConversationScheduler.maybeInit(); + Assert.ok(scheduler, "Scheduler should be initialized when pref is true"); + + await scheduler.runNowForTesting(); + Assert.ok( + findMessagesStub.calledOnce, + "Should check for recent messages once" + ); + Assert.ok( + lastTsStub.calledOnce, + "Should check last insight timestamp once" + ); + Assert.ok( + generateStub.calledOnce, + "Insights generation should be triggered once" + ); + } finally { + sb.restore(); + } +}); diff --git a/browser/components/aiwindow/models/tests/xpcshell/test_InsightsDriftDetector.js b/browser/components/aiwindow/models/tests/xpcshell/test_InsightsDriftDetector.js @@ -3,7 +3,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -"use strict"; +do_get_profile(); +("use strict"); const { InsightsDriftDetector } = ChromeUtils.importESModule( "moz-src:///browser/components/aiwindow/models/InsightsDriftDetector.sys.mjs" diff --git a/browser/components/aiwindow/models/tests/xpcshell/test_InsightsHistoryScheduler.js b/browser/components/aiwindow/models/tests/xpcshell/test_InsightsHistoryScheduler.js @@ -1,4 +1,9 @@ -"use strict"; +/* 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/. */ + +do_get_profile(); +("use strict"); const { sinon } = ChromeUtils.importESModule( "resource://testing-common/Sinon.sys.mjs" diff --git a/browser/components/aiwindow/models/tests/xpcshell/test_InsightsManager.js b/browser/components/aiwindow/models/tests/xpcshell/test_InsightsManager.js @@ -4,7 +4,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -"use strict"; +do_get_profile(); +("use strict"); const { sinon } = ChromeUtils.importESModule( "resource://testing-common/Sinon.sys.mjs" @@ -847,3 +848,193 @@ add_task(async function test_getLastHistoryInsightTimestamp_reads_meta() { "getLastHistoryInsightTimestamp should return last_history_insight_ts from InsightStore meta" ); }); + +/** + * Tests that getLastConversationInsightTimestamp reads the same value written via InsightStore.updateMeta. + */ +add_task(async function test_getLastConversationInsightTimestamp_reads_meta() { + const ts = Date.now() - 54321; + + // Write meta directly + await InsightStore.updateMeta({ + last_chat_insight_ts: ts, + }); + + // Read via InsightsManager helper + const readTs = await InsightsManager.getLastConversationInsightTimestamp(); + + Assert.equal( + readTs, + ts, + "getLastConversationInsightTimestamp should return last_chat_insight_ts from InsightStore meta" + ); +}); + +/** + * Tests that history insight generation updates last_history_insight_ts and not last_conversation_insight_ts. + */ +add_task( + async function test_historyTimestampUpdatedAfterHistoryInsightsGenerationPass() { + const sb = sinon.createSandbox(); + + const lastHistoryInsightsUpdateTs = + await InsightsManager.getLastHistoryInsightTimestamp(); + const lastConversationInsightsUpdateTs = + await InsightsManager.getLastConversationInsightTimestamp(); + + try { + const aggregateBrowserHistoryStub = sb + .stub(InsightsManager, "getAggregatedBrowserHistory") + .resolves([[], [], []]); + const fakeEngine = sb + .stub(InsightsManager, "ensureOpenAIEngine") + .resolves({ + run() { + return { + finalOutput: `[ + { + "why": "User has recently searched for Firefox history and visited mozilla.org.", + "category": "Internet & Telecom", + "intent": "Research / Learn", + "insight_summary": "Searches for Firefox information", + "score": 7, + "evidence": [ + { + "type": "search", + "value": "Google Search: firefox history" + }, + { + "type": "domain", + "value": "mozilla.org" + } + ] + }, + { + "why": "User buys dog food online regularly from multiple sources.", + "category": "Pets & Animals", + "intent": "Buy / Acquire", + "insight_summary": "Purchases dog food online", + "score": -1, + "evidence": [ + { + "type": "domain", + "value": "example.com" + } + ] + } +]`, + }; + }, + }); + + await InsightsManager.generateInsightsFromBrowsingHistory(); + + Assert.ok( + aggregateBrowserHistoryStub.calledOnce, + "getAggregatedBrowserHistory should be called once during insight generation" + ); + Assert.ok( + fakeEngine.calledOnce, + "ensureOpenAIEngine should be called once during insight generation" + ); + + Assert.greater( + await InsightsManager.getLastHistoryInsightTimestamp(), + lastHistoryInsightsUpdateTs, + "Last history insight timestamp should be updated after history generation pass" + ); + Assert.equal( + await InsightsManager.getLastConversationInsightTimestamp(), + lastConversationInsightsUpdateTs, + "Last conversation insight timestamp should remain unchanged after history generation pass" + ); + } finally { + sb.restore(); + } + } +); + +/** + * Tests that conversation insight generation updates last_conversation_insight_ts and not last_history_insight_ts. + */ +add_task( + async function test_conversationTimestampUpdatedAfterConversationInsightsGenerationPass() { + const sb = sinon.createSandbox(); + + const lastConversationInsightsUpdateTs = + await InsightsManager.getLastConversationInsightTimestamp(); + const lastHistoryInsightsUpdateTs = + await InsightsManager.getLastHistoryInsightTimestamp(); + + try { + const getRecentChatsStub = sb + .stub(InsightsManager, "_getRecentChats") + .resolves([]); + + const fakeEngine = sb + .stub(InsightsManager, "ensureOpenAIEngine") + .resolves({ + run() { + return { + finalOutput: `[ + { + "why": "User has recently searched for Firefox history and visited mozilla.org.", + "category": "Internet & Telecom", + "intent": "Research / Learn", + "insight_summary": "Searches for Firefox information", + "score": 7, + "evidence": [ + { + "type": "search", + "value": "Google Search: firefox history" + }, + { + "type": "domain", + "value": "mozilla.org" + } + ] + }, + { + "why": "User buys dog food online regularly from multiple sources.", + "category": "Pets & Animals", + "intent": "Buy / Acquire", + "insight_summary": "Purchases dog food online", + "score": -1, + "evidence": [ + { + "type": "domain", + "value": "example.com" + } + ] + } +]`, + }; + }, + }); + + await InsightsManager.generateInsightsFromConversationHistory(); + + Assert.ok( + getRecentChatsStub.calledOnce, + "getRecentChats should be called once during insight generation" + ); + Assert.ok( + fakeEngine.calledOnce, + "ensureOpenAIEngine should be called once during insight generation" + ); + + Assert.greater( + await InsightsManager.getLastConversationInsightTimestamp(), + lastConversationInsightsUpdateTs, + "Last conversation insight timestamp should be updated after conversation generation pass" + ); + Assert.equal( + await InsightsManager.getLastHistoryInsightTimestamp(), + lastHistoryInsightsUpdateTs, + "Last history insight timestamp should remain unchanged after conversation generation pass" + ); + } finally { + sb.restore(); + } + } +); diff --git a/browser/components/aiwindow/models/tests/xpcshell/xpcshell.toml b/browser/components/aiwindow/models/tests/xpcshell/xpcshell.toml @@ -14,6 +14,8 @@ support-files = [] ["test_InsightsChatSource.js"] +["test_InsightsConversationScheduler.js"] + ["test_InsightsDriftDetector.js"] ["test_InsightsHistoryScheduler.js"]