Bug 2063111 - updates to align with API contract r=mshillabeer,jlewis
Differential Revision: https://phabricator.services.mozilla.com/D322800
This commit is contained in:
committed by
ogonzalez@mozilla.com
parent
a1ede75a77
commit
9a17117b7b
@@ -13,6 +13,7 @@ import {
|
||||
buildConversation,
|
||||
loadPrompt,
|
||||
} from "moz-src:///browser/components/aiwindow/models/PromptLoader.sys.mjs";
|
||||
import { UrlTokenizer } from "moz-src:///browser/components/aiwindow/ui/modules/UrlTokenizer.sys.mjs";
|
||||
|
||||
/**
|
||||
* Reports the model and prompt version a request is about to be sent with.
|
||||
@@ -141,11 +142,17 @@ import {
|
||||
* @property {string} tabContent The tab content
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} MemoryDataForValueGen
|
||||
* @property {string} id Memory ID
|
||||
* @property {string} memory_summary Memory summary
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} Context
|
||||
* @property {string} [pageText] Text of the current page
|
||||
* @property {Array<TabCandidate>} [relevantTabs] Tabs for context
|
||||
* @property {Array<string>} [memories] List of memories
|
||||
* @property {Array<MemoryDataForValueGen>} [memories] List of memories
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -161,14 +168,9 @@ import {
|
||||
* @typedef {object} FieldValue
|
||||
* @property {string} id The stable field ID
|
||||
* @property {"fill_from_token" | "select_option" | "generate" | "skip"} action The action the LLM decided for the value
|
||||
* @property {string} [token] Candidate token, present only when action is
|
||||
* "fill_from_token"
|
||||
* @property {"high" | "medium" | "low"} confidence The LLM's value confidence
|
||||
* @property {string} [optionId] Select option stable ID, present only when
|
||||
* action is
|
||||
* "select_option"
|
||||
* @property {string} [value] Generated value, present only when action is
|
||||
* "generate"
|
||||
* @property {string} value Candidate token, option ID, generated value, or an
|
||||
* empty string when the action is "skip"
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -264,6 +266,8 @@ function queueValuesBatchRequest(request, options) {
|
||||
return promise;
|
||||
}
|
||||
|
||||
const TITLE_CHAR_LIMIT = 100;
|
||||
|
||||
const FIELD_CLASSIFICATION_RESPONSE_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -326,75 +330,21 @@ const FORM_VALUES_RESPONSE_SCHEMA = {
|
||||
fields: {
|
||||
type: "array",
|
||||
items: {
|
||||
oneOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
action: {
|
||||
type: "string",
|
||||
enum: ["fill_from_token"],
|
||||
},
|
||||
token: { type: "string" },
|
||||
confidence: {
|
||||
type: "string",
|
||||
enum: ["high", "medium", "low"],
|
||||
},
|
||||
},
|
||||
required: ["id", "action", "token", "confidence"],
|
||||
additionalProperties: false,
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
action: {
|
||||
type: "string",
|
||||
enum: ["fill_from_token", "select_option", "generate", "skip"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
action: {
|
||||
type: "string",
|
||||
enum: ["generate"],
|
||||
},
|
||||
value: { type: "string" },
|
||||
confidence: {
|
||||
type: "string",
|
||||
enum: ["high", "medium", "low"],
|
||||
},
|
||||
},
|
||||
required: ["id", "action", "value", "confidence"],
|
||||
additionalProperties: false,
|
||||
confidence: {
|
||||
type: "string",
|
||||
enum: ["high", "medium", "low"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
action: {
|
||||
type: "string",
|
||||
enum: ["select_option"],
|
||||
},
|
||||
optionId: { type: "string" },
|
||||
confidence: {
|
||||
type: "string",
|
||||
enum: ["high", "medium", "low"],
|
||||
},
|
||||
},
|
||||
required: ["id", "action", "optionId", "confidence"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
action: {
|
||||
type: "string",
|
||||
enum: ["skip"],
|
||||
},
|
||||
confidence: {
|
||||
type: "string",
|
||||
enum: ["high", "medium", "low"],
|
||||
},
|
||||
},
|
||||
required: ["id", "action", "confidence"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
value: { type: "string" },
|
||||
},
|
||||
required: ["id", "action", "confidence", "value"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -409,10 +359,14 @@ const FORM_VALUES_RESPONSE_SCHEMA = {
|
||||
* @param {object} [param1={}]
|
||||
* @param {AbortSignal} [param1.signal]
|
||||
* @param {ModelInfoCallback} [param1.onDispatch]
|
||||
* @param {UrlTokenizer} param1.urlTokenizer
|
||||
*
|
||||
* @returns {Promise<GenerateFormValuesBatchResponse>}
|
||||
*/
|
||||
async function generateFormValuesBatch(request, { signal, onDispatch } = {}) {
|
||||
async function generateFormValuesBatch(
|
||||
request,
|
||||
{ signal, onDispatch, urlTokenizer } = {}
|
||||
) {
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const conversation = await buildConversation(MODEL_FEATURES.SMART_FORM_FILL);
|
||||
@@ -432,12 +386,21 @@ async function generateFormValuesBatch(request, { signal, onDispatch } = {}) {
|
||||
]);
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const url = urlTokenizer.encodeToken(request.page.url);
|
||||
const relevantTabs = request.context.relevantTabs.map(tab => {
|
||||
return {
|
||||
...tab,
|
||||
title: tab.title.substring(0, TITLE_CHAR_LIMIT),
|
||||
url: urlTokenizer.encodeToken(tab.url),
|
||||
};
|
||||
});
|
||||
|
||||
const userPrompt = renderPrompt(userPromptTemplate, {
|
||||
title: request.page.title,
|
||||
url: request.page.url,
|
||||
url,
|
||||
title: request.page.title.substring(0, TITLE_CHAR_LIMIT),
|
||||
pageText: request.context.pageText ?? "",
|
||||
memories: JSON.stringify(request.context.memories ?? []),
|
||||
pageContext: JSON.stringify(request.context.relevantTabs ?? []),
|
||||
pageContext: JSON.stringify(relevantTabs ?? []),
|
||||
candidateTokens: JSON.stringify(request.candidates),
|
||||
fields: JSON.stringify(request.fields),
|
||||
});
|
||||
@@ -516,9 +479,11 @@ export const SmartFormFillModel = {
|
||||
]);
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const urlTokenizer = new UrlTokenizer();
|
||||
const url = urlTokenizer.encodeToken(request.page.url);
|
||||
const userPrompt = renderPrompt(userPromptTemplate, {
|
||||
title: request.page.title,
|
||||
url: request.page.url,
|
||||
url,
|
||||
title: request.page.title.substring(0, TITLE_CHAR_LIMIT),
|
||||
fields: JSON.stringify(request.fields),
|
||||
});
|
||||
|
||||
@@ -577,11 +542,21 @@ export const SmartFormFillModel = {
|
||||
]);
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const urlTokenizer = new UrlTokenizer();
|
||||
const url = urlTokenizer.encodeToken(request.page.url);
|
||||
const tabs = request.tabs.map(tab => {
|
||||
return {
|
||||
...tab,
|
||||
title: tab.title.substring(0, TITLE_CHAR_LIMIT),
|
||||
url: urlTokenizer.encodeToken(tab.url),
|
||||
};
|
||||
});
|
||||
|
||||
const userPrompt = renderPrompt(userPromptTemplate, {
|
||||
title: request.page.title,
|
||||
url: request.page.url,
|
||||
url,
|
||||
title: request.page.title.substring(0, TITLE_CHAR_LIMIT),
|
||||
fields: JSON.stringify(request.fields),
|
||||
tabs: JSON.stringify(request.tabs),
|
||||
tabs: JSON.stringify(tabs),
|
||||
max_selected_tabs: request.maxSelectedTabs,
|
||||
});
|
||||
|
||||
@@ -626,6 +601,7 @@ export const SmartFormFillModel = {
|
||||
async generateFormValues(request, { signal, onDispatch } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const urlTokenizer = new UrlTokenizer();
|
||||
const requests = [];
|
||||
for (
|
||||
let index = 0;
|
||||
@@ -641,7 +617,7 @@ export const SmartFormFillModel = {
|
||||
index + MAX_FIELDS_PER_GENERATION_REQUEST
|
||||
),
|
||||
},
|
||||
{ signal, onDispatch }
|
||||
{ signal, onDispatch, urlTokenizer }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
+181
-94
@@ -99,10 +99,47 @@ function makeRequest(id) {
|
||||
},
|
||||
],
|
||||
candidates: [],
|
||||
context: {},
|
||||
context: {
|
||||
relevantTabs: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeFields(count) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `field-${index}`,
|
||||
label: `Field ${index}`,
|
||||
inputType: "text",
|
||||
options: [],
|
||||
type: "unknown",
|
||||
classificationConfidence: "low",
|
||||
}));
|
||||
}
|
||||
|
||||
function generateValuesForFields(fields) {
|
||||
return SmartFormFillModel.generateFormValues({
|
||||
task: "generate",
|
||||
page: {
|
||||
title: "Batch test",
|
||||
url: "https://example.com/batch",
|
||||
},
|
||||
fields,
|
||||
candidates: [],
|
||||
context: {
|
||||
pageText: "",
|
||||
relevantTabs: [],
|
||||
memories: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getRequestedFields(request) {
|
||||
const marker = "Fields to fill:\n";
|
||||
const content = request.args.at(-1).content;
|
||||
|
||||
return JSON.parse(content.slice(content.lastIndexOf(marker) + marker.length));
|
||||
}
|
||||
|
||||
describe("SmartFormFillModel", () => {
|
||||
let mockEngineMan;
|
||||
|
||||
@@ -120,7 +157,8 @@ describe("SmartFormFillModel", () => {
|
||||
});
|
||||
|
||||
describe("classifyFields", () => {
|
||||
it("uses the field detection prompts and returns classifications", async () => {
|
||||
it("builds a field classification request", async () => {
|
||||
const title = "T".repeat(101);
|
||||
const fields = [
|
||||
{
|
||||
id: "field-1",
|
||||
@@ -134,7 +172,7 @@ describe("SmartFormFillModel", () => {
|
||||
task: "classify",
|
||||
enumVersion: "sff-fieldtypes-1",
|
||||
page: {
|
||||
title: "Example form",
|
||||
title,
|
||||
url: "https://example.com/form",
|
||||
},
|
||||
fields,
|
||||
@@ -157,8 +195,8 @@ describe("SmartFormFillModel", () => {
|
||||
Assert.deepEqual(request.args[1], {
|
||||
role: "user",
|
||||
content:
|
||||
"Page title:\nExample form\n\n" +
|
||||
"Page url:\nhttps://example.com/form\n\n" +
|
||||
`Page title:\n${title.substring(0, 100)}\n\n` +
|
||||
"Page url:\nEXAMPLE_COM_FORM_1\n\n" +
|
||||
`Fields to classify:\n${JSON.stringify(fields)}`,
|
||||
});
|
||||
Assert.deepEqual(request.tools, []);
|
||||
@@ -171,32 +209,15 @@ describe("SmartFormFillModel", () => {
|
||||
);
|
||||
Assert.deepEqual(responseFormat.json_schema.schema.required, ["fields"]);
|
||||
|
||||
respond(
|
||||
JSON.stringify({
|
||||
fields: [
|
||||
{
|
||||
id: "field-1",
|
||||
type: "email",
|
||||
confidence: "high",
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
Assert.deepEqual(await requestPromise, {
|
||||
fields: [
|
||||
{
|
||||
id: "field-1",
|
||||
type: "email",
|
||||
confidence: "high",
|
||||
},
|
||||
],
|
||||
});
|
||||
respond(JSON.stringify({ fields: [] }));
|
||||
await requestPromise;
|
||||
});
|
||||
});
|
||||
|
||||
describe("findRelevantTabs", () => {
|
||||
it("uses the tab selection prompts and returns relevant tabs", async () => {
|
||||
it("builds a relevant tabs request", async () => {
|
||||
const pageTitle = "P".repeat(101);
|
||||
const tabTitle = "T".repeat(101);
|
||||
const fields = [
|
||||
{
|
||||
id: "field-1",
|
||||
@@ -208,14 +229,21 @@ describe("SmartFormFillModel", () => {
|
||||
const tabs = [
|
||||
{
|
||||
id: "tab-1",
|
||||
title: "Professional profile",
|
||||
title: tabTitle,
|
||||
url: "https://example.com/profile",
|
||||
},
|
||||
];
|
||||
const shapedTabs = [
|
||||
{
|
||||
...tabs[0],
|
||||
title: tabTitle.substring(0, 100),
|
||||
url: "EXAMPLE_COM_PROFILE_1",
|
||||
},
|
||||
];
|
||||
const requestPromise = SmartFormFillModel.findRelevantTabs({
|
||||
task: "select_tabs",
|
||||
page: {
|
||||
title: "Job application",
|
||||
title: pageTitle,
|
||||
url: "https://example.com/jobs/apply",
|
||||
},
|
||||
maxSelectedTabs: 3,
|
||||
@@ -237,12 +265,12 @@ describe("SmartFormFillModel", () => {
|
||||
role: "user",
|
||||
content:
|
||||
"FORM THE USER IS FILLING\n" +
|
||||
"Title: Job application\n" +
|
||||
"URL: https://example.com/jobs/apply\n" +
|
||||
`Title: ${pageTitle.substring(0, 100)}\n` +
|
||||
"URL: EXAMPLE_COM_JOBS_APPLY_1\n" +
|
||||
"Fields it is asking for:\n" +
|
||||
`${JSON.stringify(fields)}\n\n` +
|
||||
"THE USER'S OTHER OPEN TABS (title and URL only)\n" +
|
||||
`${JSON.stringify(tabs)}\n\n` +
|
||||
`${JSON.stringify(shapedTabs)}\n\n` +
|
||||
"maxSelectedTabs: 3\n\n" +
|
||||
"Which of these tabs could help fill this form? " +
|
||||
"Include every tab that plausibly could.",
|
||||
@@ -259,27 +287,8 @@ describe("SmartFormFillModel", () => {
|
||||
"selectedTabs",
|
||||
]);
|
||||
|
||||
respond(
|
||||
JSON.stringify({
|
||||
selectedTabs: [
|
||||
{
|
||||
id: "tab-1",
|
||||
relevance: "high",
|
||||
reason: "Your professional profile",
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
Assert.deepEqual(await requestPromise, {
|
||||
selectedTabs: [
|
||||
{
|
||||
id: "tab-1",
|
||||
relevance: "high",
|
||||
reason: "Your professional profile",
|
||||
},
|
||||
],
|
||||
});
|
||||
respond(JSON.stringify({ selectedTabs: [] }));
|
||||
await requestPromise;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -292,8 +301,10 @@ describe("SmartFormFillModel", () => {
|
||||
_clearRemoteClientForTesting();
|
||||
});
|
||||
|
||||
it("uses the value generation prompts and returns fill actions", async () => {
|
||||
const candidates = [{ token: "$EMAIL_1", type: "email" }];
|
||||
it("builds a value generation request with the expected schema", async () => {
|
||||
const pageTitle = "P".repeat(101);
|
||||
const tabTitle = "T".repeat(101);
|
||||
const candidates = [{ token: "§EMAIL_1§", type: "email" }];
|
||||
const fields = [
|
||||
{
|
||||
id: "field-email",
|
||||
@@ -314,16 +325,23 @@ describe("SmartFormFillModel", () => {
|
||||
];
|
||||
const relevantTabs = [
|
||||
{
|
||||
title: "Example role",
|
||||
title: tabTitle,
|
||||
url: "https://example.com/jobs/role",
|
||||
tabContent: "The role focuses on browser engineering.",
|
||||
},
|
||||
];
|
||||
const shapedRelevantTabs = [
|
||||
{
|
||||
...relevantTabs[0],
|
||||
title: tabTitle.substring(0, 100),
|
||||
url: "EXAMPLE_COM_JOBS_ROLE_1",
|
||||
},
|
||||
];
|
||||
const requestPromise = SmartFormFillModel.generateFormValues({
|
||||
task: "generate",
|
||||
page: {
|
||||
title: "Job application",
|
||||
url: "https://example.com/jobs/apply",
|
||||
title: pageTitle,
|
||||
url: "https://example.com/jobs/generate",
|
||||
},
|
||||
fields,
|
||||
candidates,
|
||||
@@ -346,11 +364,11 @@ describe("SmartFormFillModel", () => {
|
||||
Assert.deepEqual(request.args[1], {
|
||||
role: "user",
|
||||
content:
|
||||
"Current page title:\nJob application\n\n" +
|
||||
"Current page url:\nhttps://example.com/jobs/apply\n\n" +
|
||||
`Current page title:\n${pageTitle.substring(0, 100)}\n\n` +
|
||||
"Current page url:\nEXAMPLE_COM_JOBS_GENERATE_1\n\n" +
|
||||
"Current page text:\nApply for the example role.\n\n" +
|
||||
"Relevant memories about the user:\n[]\n\n" +
|
||||
`Relevant open tabs:\n${JSON.stringify(relevantTabs)}\n\n` +
|
||||
`Relevant open tabs:\n${JSON.stringify(shapedRelevantTabs)}\n\n` +
|
||||
`Available candidate tokens:\n${JSON.stringify(candidates)}\n\n` +
|
||||
`Fields to fill:\n${JSON.stringify(fields)}`,
|
||||
});
|
||||
@@ -364,48 +382,117 @@ describe("SmartFormFillModel", () => {
|
||||
"tabs_used",
|
||||
"fields",
|
||||
]);
|
||||
const fieldSchema =
|
||||
responseFormat.json_schema.schema.properties.fields.items;
|
||||
Assert.deepEqual(fieldSchema.required, [
|
||||
"id",
|
||||
"action",
|
||||
"confidence",
|
||||
"value",
|
||||
]);
|
||||
Assert.deepEqual(fieldSchema.properties.action.enum, [
|
||||
"fill_from_token",
|
||||
"select_option",
|
||||
"generate",
|
||||
"skip",
|
||||
]);
|
||||
|
||||
respond(
|
||||
JSON.stringify({
|
||||
memories_used: [],
|
||||
fields: [
|
||||
{
|
||||
id: "field-email",
|
||||
action: "fill_from_token",
|
||||
token: "$EMAIL_1",
|
||||
confidence: "high",
|
||||
},
|
||||
{
|
||||
id: "field-reason",
|
||||
tabs_used: [],
|
||||
fields: [],
|
||||
})
|
||||
);
|
||||
await requestPromise;
|
||||
});
|
||||
|
||||
it("splits fields into batches and combines their results", async () => {
|
||||
const fields = makeFields(21);
|
||||
const requestPromise = generateValuesForFields(fields);
|
||||
const batchSizes = [];
|
||||
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const { request, respond } = await mockEngineMan.captureRequest({
|
||||
purpose: PURPOSE,
|
||||
});
|
||||
const requestedFields = getRequestedFields(request);
|
||||
batchSizes.push(requestedFields.length);
|
||||
|
||||
respond(
|
||||
JSON.stringify({
|
||||
memories_used: [],
|
||||
tabs_used: ["tab-1"],
|
||||
fields: requestedFields.map(({ id }) => ({
|
||||
id,
|
||||
action: "generate",
|
||||
value: "I am interested in browser engineering.",
|
||||
confidence: "high",
|
||||
},
|
||||
],
|
||||
value: `Value for ${id}`,
|
||||
})),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const result = await requestPromise;
|
||||
|
||||
Assert.deepEqual(
|
||||
batchSizes,
|
||||
[20, 1],
|
||||
"Fields should be split at the batch limit"
|
||||
);
|
||||
Assert.deepEqual(
|
||||
result.fields.map(({ id }) => id),
|
||||
fields.map(({ id }) => id),
|
||||
"Fields from every batch should be combined in request order"
|
||||
);
|
||||
Assert.deepEqual(
|
||||
result.tabs_used,
|
||||
["tab-1"],
|
||||
"Tab IDs reported by multiple batches should be deduplicated"
|
||||
);
|
||||
Assert.deepEqual(
|
||||
result.batches,
|
||||
{ total: 2, failed: 0 },
|
||||
"Batch metadata should report both successful requests"
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps successful results when another batch fails", async () => {
|
||||
const fields = makeFields(21);
|
||||
const requestPromise = generateValuesForFields(fields);
|
||||
const { request, respond } = await mockEngineMan.captureRequest({
|
||||
purpose: PURPOSE,
|
||||
});
|
||||
const successfulFields = getRequestedFields(request);
|
||||
|
||||
respond(
|
||||
JSON.stringify({
|
||||
memories_used: [],
|
||||
tabs_used: [],
|
||||
fields: successfulFields.map(({ id }) => ({
|
||||
id,
|
||||
action: "generate",
|
||||
confidence: "high",
|
||||
value: `Value for ${id}`,
|
||||
})),
|
||||
})
|
||||
);
|
||||
|
||||
Assert.deepEqual(await requestPromise, {
|
||||
memories_used: [],
|
||||
// Absent from the answer above, so the merged result reports it empty.
|
||||
tabs_used: [],
|
||||
fields: [
|
||||
{
|
||||
id: "field-email",
|
||||
action: "fill_from_token",
|
||||
token: "$EMAIL_1",
|
||||
confidence: "high",
|
||||
},
|
||||
{
|
||||
id: "field-reason",
|
||||
action: "generate",
|
||||
value: "I am interested in browser engineering.",
|
||||
confidence: "high",
|
||||
},
|
||||
],
|
||||
// One batch, because the form fits in a single request.
|
||||
batches: { total: 1, failed: 0 },
|
||||
});
|
||||
await mockEngineMan.captureRequest({ purpose: PURPOSE });
|
||||
mockEngineMan.rejectAllRequests();
|
||||
|
||||
const result = await requestPromise;
|
||||
|
||||
Assert.deepEqual(
|
||||
result.fields.map(({ id }) => id),
|
||||
successfulFields.map(({ id }) => id),
|
||||
"Successful batch results should be preserved"
|
||||
);
|
||||
Assert.deepEqual(
|
||||
result.batches,
|
||||
{ total: 2, failed: 1 },
|
||||
"Batch metadata should report the failed request"
|
||||
);
|
||||
});
|
||||
|
||||
it("limits concurrent value generation requests globally", async () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
|
||||
import { Conversation } from "moz-src:///browser/components/aiwindow/models/Conversation.sys.mjs";
|
||||
import { consumeStreamChunk } from "moz-src:///browser/components/aiwindow/models/TokenStreamParser.sys.mjs";
|
||||
import { UrlTokenizer } from "moz-src:///browser/components/aiwindow/ui/modules/UrlTokenizer.sys.mjs";
|
||||
|
||||
/** @typedef {import("moz-src:///browser/components/aiwindow/models/SearchBrowsingHistory.sys.mjs").HistoryRow} HistoryRow */
|
||||
|
||||
@@ -141,49 +142,14 @@ export class ChatConversation extends Conversation {
|
||||
promptEmbeddedMemories = [];
|
||||
|
||||
/**
|
||||
* A mapping of a URL to its unique URL token. URL tokens are used as shortened
|
||||
* versions of URLs to help the model deal with very long URLs. Very long URLs are
|
||||
* problematic since they are hard for a model to repeat back without making mistakes
|
||||
* or hallucinating details about the URL. There is also additional cost for every
|
||||
* additional token in the context. Long URLs can also contain prompt injections since
|
||||
* they can be of an arbitrary size. URL Tokens help solve all of these issues.
|
||||
* URL Tokenizer instance for sending URLs to LLM.
|
||||
* The tokens created by UrlTokenizer are cached to the conversation while
|
||||
* the conversation is loaded in memory. These tokens are in-memory only and not
|
||||
* serialized to storage.
|
||||
*
|
||||
* URL tokens are only generated while a message is "in flight" to and from the language
|
||||
* model. When tool calls are handled, messages rendered, and messages stored they are
|
||||
* all done with the URL tokens expanded into full URLs.
|
||||
*
|
||||
* There are no guarantees that a URL in this list isn't just hallucinated by the model.
|
||||
* Any URL the language model invents can be present in this list. The only guarantee
|
||||
* is that a token maps to some kind of arbitrary URL.
|
||||
*
|
||||
* Example mapping:
|
||||
* https://github.com/mozilla/ -> GITHUB_COM_MOZILLA_1
|
||||
*
|
||||
* @type {Map<string, string>}
|
||||
* @type {UrlTokenizer}
|
||||
*/
|
||||
urlToToken = new Map();
|
||||
|
||||
/**
|
||||
* The reverse mapping for a token back to its original URL.
|
||||
*
|
||||
* e.g. GITHUB_COM_MOZILLA_1 -> https://github.com/mozilla/
|
||||
*
|
||||
* @type {Map<string, string>}
|
||||
*/
|
||||
tokenToUrl = new Map();
|
||||
|
||||
/**
|
||||
* A mapping of the base URL token to how many counts there are for it. It's
|
||||
* used to generate the final number on URL tokens.
|
||||
*
|
||||
* e.g.
|
||||
*
|
||||
* https://github.com/mozilla/ -> GITHUB_COM_MOZILLA_1
|
||||
* https://github.com/mozilla#not-part-of-token -> GITHUB_COM_MOZILLA_2
|
||||
*
|
||||
* @type {Map<string, number>}
|
||||
*/
|
||||
#baseTokenCounts = new Map();
|
||||
urlTokenizer = null;
|
||||
|
||||
/**
|
||||
* Conversation-level pool of history results keyed by URL, accumulated across
|
||||
@@ -262,6 +228,7 @@ export class ChatConversation extends Conversation {
|
||||
securityProperties,
|
||||
});
|
||||
|
||||
this.urlTokenizer = new UrlTokenizer();
|
||||
this.title = title;
|
||||
this.description = description;
|
||||
this.pageUrl = pageUrl;
|
||||
@@ -352,63 +319,7 @@ export class ChatConversation extends Conversation {
|
||||
* @returns {string} The short token for the URL (e.g. "GITHUB_COM_1")
|
||||
*/
|
||||
convertUrlToToken(url) {
|
||||
const seenToken = this.urlToToken.get(url);
|
||||
if (seenToken) {
|
||||
return seenToken;
|
||||
}
|
||||
|
||||
let baseToken = "";
|
||||
|
||||
// Attempt to convert the URL into a base token.
|
||||
const parsedUrl = URL.parse(url);
|
||||
if (parsedUrl) {
|
||||
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
||||
// Go ahead and handle URL tokens for more complicated URLs that
|
||||
// aren't probably supported in the chat interface, but would be useful
|
||||
// to disambiguate from the HTTP(s) varieties.
|
||||
baseToken +=
|
||||
// e.g. "ftp:" -> "FTP"
|
||||
parsedUrl.protocol.toUpperCase().replace(":", "");
|
||||
}
|
||||
|
||||
// Convert the hostname into a token.
|
||||
const hostToken = parsedUrl.hostname
|
||||
.replace(/^www\./, "")
|
||||
.toUpperCase()
|
||||
.replace(/[.\-]/g, "_")
|
||||
.substring(0, 100);
|
||||
|
||||
if (hostToken) {
|
||||
baseToken = baseToken ? `${baseToken}_${hostToken}` : hostToken;
|
||||
}
|
||||
|
||||
// Add on the parts of the URL to the token.
|
||||
for (let part of parsedUrl.pathname.split("/")) {
|
||||
if (!part) {
|
||||
continue;
|
||||
}
|
||||
const partToken = part.toUpperCase().replace(/[^A-Z0-9]/g, "_");
|
||||
|
||||
const nextToken = `${baseToken}_${partToken}`;
|
||||
if (nextToken.length > 100) {
|
||||
break;
|
||||
}
|
||||
baseToken = nextToken;
|
||||
}
|
||||
} else {
|
||||
baseToken = "INVALID_URL";
|
||||
}
|
||||
|
||||
let count = this.#baseTokenCounts.get(baseToken) ?? 0;
|
||||
count += 1;
|
||||
this.#baseTokenCounts.set(baseToken, count);
|
||||
|
||||
const tokenFinal = `${baseToken}_${count}`;
|
||||
|
||||
this.urlToToken.set(url, tokenFinal);
|
||||
this.tokenToUrl.set(tokenFinal, url);
|
||||
|
||||
return tokenFinal;
|
||||
return this.urlTokenizer.encodeToken(url);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -420,7 +331,7 @@ export class ChatConversation extends Conversation {
|
||||
const { plainText, tokens } = consumeStreamChunk(
|
||||
chunk,
|
||||
parserState,
|
||||
this.tokenToUrl
|
||||
this.urlTokenizer.tokenToUrl
|
||||
);
|
||||
|
||||
if (plainText && currentMessage?.content) {
|
||||
@@ -477,7 +388,7 @@ export class ChatConversation extends Conversation {
|
||||
|
||||
if (result.currentMessage?.content?.body) {
|
||||
// Expand URL tokens and remove any hallucinated ones.
|
||||
if (this.urlToToken.size) {
|
||||
if (this.urlTokenizer.urlToToken.size) {
|
||||
result.currentMessage.content.body = stripUnresolvedUrlTokens(
|
||||
result.currentMessage.content.body
|
||||
);
|
||||
@@ -1135,6 +1046,14 @@ export class ChatConversation extends Conversation {
|
||||
return this.messages.filter(m => CHAT_ROLES.includes(m.role)).length;
|
||||
}
|
||||
|
||||
get tokenToUrl() {
|
||||
return this.urlTokenizer.tokenToUrl;
|
||||
}
|
||||
|
||||
get urlToToken() {
|
||||
return this.urlTokenizer.urlToToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the contextMentions count from the most recent user message in
|
||||
* the conversation, or 0 if none.
|
||||
|
||||
@@ -526,7 +526,7 @@ export class SmartFormFillController {
|
||||
* @param {PageInfo} pageInfo
|
||||
* @param {Array<FieldData>} fields
|
||||
*
|
||||
* @returns {Promise<Array<string>>}
|
||||
* @returns {Promise<Array<{id: string, memory_summary: string}>>}
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-private-class-members -- will be enabled in v0+
|
||||
async #getMemories(pageInfo, fields) {
|
||||
@@ -552,9 +552,11 @@ export class SmartFormFillController {
|
||||
const relevantMemories =
|
||||
await lazy.MemoriesManager.getRelevantMemories(contextMessage);
|
||||
|
||||
return relevantMemories.map(
|
||||
relevant_memory => relevant_memory.memory_summary
|
||||
);
|
||||
return relevantMemories.map(relevant_memory => {
|
||||
const { id, memory_summary } = relevant_memory;
|
||||
|
||||
return { id, memory_summary };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -584,7 +586,7 @@ export class SmartFormFillController {
|
||||
let value;
|
||||
switch (result.action) {
|
||||
case "fill_from_token":
|
||||
value = result.token ? valuesByToken.get(result.token) : undefined;
|
||||
value = result.value ? valuesByToken.get(result.value) : undefined;
|
||||
break;
|
||||
|
||||
case "generate":
|
||||
@@ -640,7 +642,7 @@ export class SmartFormFillController {
|
||||
const count = (typeCounts.get(type) ?? 0) + 1;
|
||||
typeCounts.set(type, count);
|
||||
|
||||
const token = `$${type.toUpperCase().replaceAll("-", "_")}_${count}`;
|
||||
const token = `§${type.toUpperCase().replaceAll("-", "_")}_${count}§`;
|
||||
|
||||
candidates.push({ token, type });
|
||||
valuesByToken.set(token, value);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Tokenizes URLs to send to an LLM
|
||||
*/
|
||||
export class UrlTokenizer {
|
||||
/**
|
||||
* A mapping of a URL to its unique URL token. URL tokens are used as shortened
|
||||
* versions of URLs to help the model deal with very long URLs. Very long URLs are
|
||||
* problematic since they are hard for a model to repeat back without making mistakes
|
||||
* or hallucinating details about the URL. There is also additional cost for every
|
||||
* additional token in the context. Long URLs can also contain prompt injections since
|
||||
* they can be of an arbitrary size. URL Tokens help solve all of these issues.
|
||||
*
|
||||
* URL tokens are only generated while a message is "in flight" to and from the language
|
||||
* model. When tool calls are handled, messages rendered, and messages stored they are
|
||||
* all done with the URL tokens expanded into full URLs.
|
||||
*
|
||||
* There are no guarantees that a URL in this list isn't just hallucinated by the model.
|
||||
* Any URL the language model invents can be present in this list. The only guarantee
|
||||
* is that a token maps to some kind of arbitrary URL.
|
||||
*
|
||||
* Example mapping:
|
||||
* https://github.com/mozilla/ -> GITHUB_COM_MOZILLA_1
|
||||
*
|
||||
* @type {Map<string, string>}
|
||||
*/
|
||||
urlToToken;
|
||||
|
||||
/**
|
||||
* The reverse mapping for a token back to its original URL.
|
||||
*
|
||||
* e.g. GITHUB_COM_MOZILLA_1 -> https://github.com/mozilla/
|
||||
*
|
||||
* @type {Map<string, string>}
|
||||
*/
|
||||
tokenToUrl;
|
||||
|
||||
/**
|
||||
* A mapping of the base URL token to how many counts there are for it. It's
|
||||
* used to generate the final number on URL tokens.
|
||||
*
|
||||
* e.g.
|
||||
*
|
||||
* https://github.com/mozilla/ -> GITHUB_COM_MOZILLA_1
|
||||
* https://github.com/mozilla#not-part-of-token -> GITHUB_COM_MOZILLA_2
|
||||
*
|
||||
* @type {Map<string, number>}
|
||||
*/
|
||||
#baseTokenCounts = new Map();
|
||||
|
||||
constructor() {
|
||||
this.urlToToken = new Map();
|
||||
this.tokenToUrl = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a URL into a token. It first computes a base token from
|
||||
* the hostname and path parts, then appends a monotonically increasing number on the
|
||||
* end to make it unique.
|
||||
*
|
||||
* URL token analysis:
|
||||
* https://docs.google.com/document/d/1kwf2PH1APyUR4wrvv6lJIhA12bkQoNVV5KFwAPtWubw/edit?tab=t.0#heading=h.yhx5pggnwgne
|
||||
*
|
||||
* @param {string} url - The full URL to register
|
||||
*
|
||||
* @returns {string} The short token for the URL (e.g. "GITHUB_COM_1")
|
||||
*/
|
||||
encodeToken(url) {
|
||||
const seenToken = this.urlToToken.get(url);
|
||||
if (seenToken) {
|
||||
return seenToken;
|
||||
}
|
||||
|
||||
let baseToken = "";
|
||||
|
||||
// Attempt to convert the URL into a base token.
|
||||
const parsedUrl = URL.parse(url);
|
||||
if (parsedUrl) {
|
||||
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
||||
// Go ahead and handle URL tokens for more complicated URLs that
|
||||
// aren't probably supported in the chat interface, but would be useful
|
||||
// to disambiguate from the HTTP(s) varieties.
|
||||
baseToken +=
|
||||
// e.g. "ftp:" -> "FTP"
|
||||
parsedUrl.protocol.toUpperCase().replace(":", "");
|
||||
}
|
||||
|
||||
// Convert the hostname into a token.
|
||||
const hostToken = parsedUrl.hostname
|
||||
.replace(/^www\./, "")
|
||||
.toUpperCase()
|
||||
.replace(/[.\-]/g, "_")
|
||||
.substring(0, 100);
|
||||
|
||||
if (hostToken) {
|
||||
baseToken = baseToken ? `${baseToken}_${hostToken}` : hostToken;
|
||||
}
|
||||
|
||||
// Add on the parts of the URL to the token.
|
||||
for (let part of parsedUrl.pathname.split("/")) {
|
||||
if (!part) {
|
||||
continue;
|
||||
}
|
||||
const partToken = part.toUpperCase().replace(/[^A-Z0-9]/g, "_");
|
||||
|
||||
const nextToken = `${baseToken}_${partToken}`;
|
||||
if (nextToken.length > 100) {
|
||||
break;
|
||||
}
|
||||
baseToken = nextToken;
|
||||
}
|
||||
} else {
|
||||
baseToken = "INVALID_URL";
|
||||
}
|
||||
|
||||
let count = this.#baseTokenCounts.get(baseToken) ?? 0;
|
||||
count += 1;
|
||||
this.#baseTokenCounts.set(baseToken, count);
|
||||
|
||||
const tokenFinal = `${baseToken}_${count}`;
|
||||
|
||||
this.urlToToken.set(url, tokenFinal);
|
||||
this.tokenToUrl.set(tokenFinal, url);
|
||||
|
||||
return tokenFinal;
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ MOZ_SRC_FILES += [
|
||||
"modules/ToolActionLog.sys.mjs",
|
||||
"modules/ToolUI.sys.mjs",
|
||||
"modules/ToolUITelemetry.sys.mjs",
|
||||
"modules/UrlTokenizer.sys.mjs",
|
||||
]
|
||||
|
||||
TESTING_JS_MODULES += [
|
||||
|
||||
@@ -55,8 +55,8 @@ async function saveStoredValues(address) {
|
||||
*/
|
||||
function respondWithTokenFills(mockEngineManager) {
|
||||
return respondWithGeneratedFields(mockEngineManager, [
|
||||
{ action: "fill_from_token", token: "$EMAIL_1", confidence: "high" },
|
||||
{ action: "fill_from_token", token: "$NAME_1", confidence: "high" },
|
||||
{ action: "fill_from_token", value: "§EMAIL_1§", confidence: "high" },
|
||||
{ action: "fill_from_token", value: "§NAME_1§", confidence: "high" },
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
const { MockEngineManager } = ChromeUtils.importESModule(
|
||||
"resource://testing-common/AIWindowTestUtils.sys.mjs"
|
||||
);
|
||||
const { UrlTokenizer } = ChromeUtils.importESModule(
|
||||
"moz-src:///browser/components/aiwindow/ui/modules/UrlTokenizer.sys.mjs"
|
||||
);
|
||||
|
||||
const FORM_URL =
|
||||
"https://example.com/browser/browser/components/aiwindow/ui/test/browser/test_smartformfill_autocomplete.html";
|
||||
@@ -183,9 +186,12 @@ function respondToMetadataRequest(
|
||||
return;
|
||||
|
||||
case RELEVANT_TABS_SCHEMA: {
|
||||
const source = selectedSourceUrl
|
||||
? requestData.tabs.find(tab => tab.url === selectedSourceUrl)
|
||||
const selectedSourceToken = selectedSourceUrl
|
||||
? new UrlTokenizer().encodeToken(selectedSourceUrl, false)
|
||||
: null;
|
||||
const source = requestData.tabs.find(
|
||||
tab => tab.url === selectedSourceToken
|
||||
);
|
||||
respond(
|
||||
JSON.stringify({
|
||||
selectedTabs: source
|
||||
|
||||
@@ -93,7 +93,8 @@ function respondToFormReviewMetadataRequest(schemaName, requestData, respond) {
|
||||
return;
|
||||
|
||||
case RELEVANT_TABS_SCHEMA: {
|
||||
const source = requestData.tabs.find(tab => tab.url === SOURCE_URL);
|
||||
const sourceToken = new UrlTokenizer().encodeToken(SOURCE_URL);
|
||||
const source = requestData.tabs.find(tab => tab.url === sourceToken);
|
||||
respond(
|
||||
JSON.stringify({
|
||||
selectedTabs: source
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
* http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
const { UrlTokenizer } = ChromeUtils.importESModule(
|
||||
"moz-src:///browser/components/aiwindow/ui/modules/UrlTokenizer.sys.mjs"
|
||||
);
|
||||
|
||||
add_task(async function test_UrlTokenizer_encodeToken() {
|
||||
const cases = [
|
||||
{
|
||||
message: "Works for a URL with a path.",
|
||||
url: "http://www.github.com/foo/bar/baz",
|
||||
expected: "GITHUB_COM_FOO_BAR_BAZ_1",
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Returns a new number for a URL that is different but creates the same token.",
|
||||
url: "http://www.github.com/foo/bar/baz?ignored",
|
||||
expected: "GITHUB_COM_FOO_BAR_BAZ_2",
|
||||
},
|
||||
{
|
||||
message: "Returns the exact same token given another URL",
|
||||
url: "http://www.github.com/foo/bar/baz",
|
||||
expected: "GITHUB_COM_FOO_BAR_BAZ_1",
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Returns a different token given the same URL with a different protocol",
|
||||
url: "https://www.github.com/foo/bar/baz",
|
||||
expected: "GITHUB_COM_FOO_BAR_BAZ_3",
|
||||
},
|
||||
{
|
||||
message: "Can handle about URLs.",
|
||||
url: "about:config",
|
||||
expected: "ABOUT_CONFIG_1",
|
||||
},
|
||||
{
|
||||
message: "Uses non-http protocols",
|
||||
url: "ftp://github.com/foo/bar/baz",
|
||||
expected: "FTP_GITHUB_COM_FOO_BAR_BAZ_1",
|
||||
},
|
||||
{
|
||||
message: "Uses invalid protocols",
|
||||
url: "asdf://github.com/foo/bar/baz",
|
||||
expected: "ASDF_GITHUB_COM_FOO_BAR_BAZ_1",
|
||||
},
|
||||
{
|
||||
message: "Ignores the port.",
|
||||
url: "http://github.com:1234/ignore/port",
|
||||
expected: "GITHUB_COM_IGNORE_PORT_1",
|
||||
},
|
||||
{
|
||||
message: "Ignores the params.",
|
||||
url: "http://www.github.com/ignore/params?token=xxx",
|
||||
expected: "GITHUB_COM_IGNORE_PARAMS_1",
|
||||
},
|
||||
{
|
||||
message: "Ignores the hash.",
|
||||
url: "http://www.github.com/ignore/hash/part?token=xxx#hash",
|
||||
expected: "GITHUB_COM_IGNORE_HASH_PART_1",
|
||||
},
|
||||
{
|
||||
message: "Truncates text in the host from 110 to 100.",
|
||||
url: `http://www.${"a".repeat(110)}.com/foo`,
|
||||
expected: "A".repeat(100) + "_1",
|
||||
},
|
||||
{
|
||||
message: "Skips text in the path that is too long",
|
||||
url: `http://github.com/skip/long/path/` + "A".repeat(100),
|
||||
expected: "GITHUB_COM_SKIP_LONG_PATH_1",
|
||||
},
|
||||
];
|
||||
|
||||
const urlTokenizer = new UrlTokenizer({});
|
||||
|
||||
for (const { message, url, expected } of cases) {
|
||||
const token = urlTokenizer.encodeToken(url);
|
||||
Assert.equal(token, expected, message);
|
||||
|
||||
const decodedUrl = urlTokenizer.tokenToUrl.get(token);
|
||||
Assert.equal(
|
||||
decodedUrl,
|
||||
url,
|
||||
`Expected the decoded token (${token}) to equal '${url}' but got: ${decodedUrl}`
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -41,6 +41,8 @@ run-if = [
|
||||
|
||||
["test_ToolUIUpdate.js"]
|
||||
|
||||
["test_UrlTokenizer.js"]
|
||||
|
||||
["test_chat-utils.js"]
|
||||
|
||||
["test_ui_modules_AIWindow_placesEvents.js"]
|
||||
|
||||
Reference in New Issue
Block a user