mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-06-29 18:19:40 +01:00
Add the vector policy source embedding controls
This commit is contained in:
@@ -66,6 +66,8 @@ export interface DatabaseAccountExtendedProperties {
|
||||
enablePriorityBasedExecution?: boolean;
|
||||
vcoreMongoEndpoint?: string;
|
||||
enableAllVersionsAndDeletesChangeFeed?: boolean;
|
||||
enableFullFidelityChangeFeed?: boolean;
|
||||
enableEmbeddingGenerator?: boolean;
|
||||
}
|
||||
|
||||
export interface DatabaseAccountResponseLocation {
|
||||
@@ -414,6 +416,7 @@ export interface AccountOverride {
|
||||
capacityMode?: CapacityMode;
|
||||
enableFreeTier?: boolean;
|
||||
enableAnalyticalStorage?: boolean;
|
||||
enableEmbeddingGenerator?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateDatabaseParams {
|
||||
@@ -458,6 +461,15 @@ export interface VectorEmbedding {
|
||||
dimensions: number;
|
||||
distanceFunction: "euclidean" | "cosine" | "dotproduct";
|
||||
path: string;
|
||||
embeddingSource?: VectorEmbeddingSource;
|
||||
}
|
||||
|
||||
export interface VectorEmbeddingSource {
|
||||
sourcePaths: string[];
|
||||
deploymentName: string;
|
||||
modelName: string;
|
||||
endpoint: string;
|
||||
authType: "Entra";
|
||||
}
|
||||
|
||||
export interface FullTextPolicy {
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface CollapsibleSectionProps {
|
||||
onDelete?: () => void;
|
||||
disabled?: boolean;
|
||||
disableDelete?: boolean;
|
||||
dataTest?: string;
|
||||
}
|
||||
|
||||
export interface CollapsibleSectionState {
|
||||
@@ -57,6 +58,7 @@ export class CollapsibleSectionComponent extends React.Component<CollapsibleSect
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-expanded={this.state.isExpanded}
|
||||
data-test={this.props.dataTest}
|
||||
>
|
||||
<Icon iconName={this.state.isExpanded ? "ChevronDown" : "ChevronRight"} />
|
||||
<Label styles={{ root: { color: "var(--colorNeutralForeground1)" } }}>{this.props.title}</Label>
|
||||
|
||||
@@ -2,8 +2,14 @@ import "@testing-library/jest-dom";
|
||||
import { RenderResult, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { VectorEmbedding, VectorIndex } from "Contracts/DataModels";
|
||||
import React from "react";
|
||||
import * as CapabilityUtils from "Utils/CapabilityUtils";
|
||||
import { VectorEmbeddingPoliciesComponent } from "./VectorEmbeddingPoliciesComponent";
|
||||
|
||||
// The embedding-source UI is gated behind the integrated-embedding capability.
|
||||
// Default the mock to `true` for the existing suites so they exercise the full UI;
|
||||
// the dedicated suite at the bottom of this file flips it to `false` to verify gating.
|
||||
jest.spyOn(CapabilityUtils, "isIntegratedEmbeddingEnabled").mockReturnValue(true);
|
||||
|
||||
const mockVectorEmbedding: VectorEmbedding[] = [
|
||||
{ path: "/vector1", dataType: "float32", distanceFunction: "euclidean", dimensions: 0 },
|
||||
];
|
||||
@@ -81,3 +87,197 @@ describe("AddVectorEmbeddingPolicyForm", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("VectorEmbeddingPoliciesComponent - embedding source", () => {
|
||||
const newEmbedding: VectorEmbedding[] = [
|
||||
{ path: "/vector2", dataType: "float32", distanceFunction: "cosine", dimensions: 1536 },
|
||||
];
|
||||
let onChange: jest.Mock;
|
||||
let view: RenderResult;
|
||||
|
||||
beforeEach(() => {
|
||||
onChange = jest.fn();
|
||||
view = render(
|
||||
<VectorEmbeddingPoliciesComponent
|
||||
vectorEmbeddingsBaseline={[]}
|
||||
vectorEmbeddings={newEmbedding}
|
||||
vectorIndexes={[]}
|
||||
onVectorEmbeddingChange={onChange}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const expandSection = () => {
|
||||
const header = screen.getByRole("button", { name: /Embedding source/ });
|
||||
fireEvent.click(header);
|
||||
};
|
||||
|
||||
test("renders the embedding source accordion collapsed by default with no errors", () => {
|
||||
expect(screen.getByText("Embedding source (Preview)")).toBeInTheDocument();
|
||||
expect(view.container.querySelector("#vector-policy-embeddingSource-sourcePaths-1")).toBeNull();
|
||||
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1];
|
||||
expect(lastCall[2]).toBe(true);
|
||||
expect(lastCall[0][0].embeddingSource).toBeUndefined();
|
||||
});
|
||||
|
||||
test("expanding the accordion reveals all source fields without errors when empty", () => {
|
||||
expandSection();
|
||||
expect(view.container.querySelector("#vector-policy-embeddingSource-sourcePaths-1")).not.toBeNull();
|
||||
expect(view.container.querySelector("#vector-policy-embeddingSource-deploymentName-1")).not.toBeNull();
|
||||
expect(view.container.querySelector("#vector-policy-embeddingSource-modelName-1")).not.toBeNull();
|
||||
expect(view.container.querySelector("#vector-policy-embeddingSource-endpoint-1")).not.toBeNull();
|
||||
expect(view.container.querySelector("#vector-policy-embeddingSource-authType-1")).not.toBeNull();
|
||||
expect(screen.queryByText("At least one source path is required")).toBeNull();
|
||||
expect(screen.queryByText("Deployment name is required")).toBeNull();
|
||||
});
|
||||
|
||||
test("typing in one field surfaces required errors for the others", async () => {
|
||||
expandSection();
|
||||
fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-deploymentName-1"), {
|
||||
target: { value: "my-deployment" },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("At least one source path is required")).toBeInTheDocument();
|
||||
expect(screen.getByText("Model name is required")).toBeInTheDocument();
|
||||
expect(screen.getByText("Endpoint is required")).toBeInTheDocument();
|
||||
});
|
||||
const last = onChange.mock.calls[onChange.mock.calls.length - 1];
|
||||
expect(last[2]).toBe(false);
|
||||
});
|
||||
|
||||
test("invalid endpoint shows the https:// error", async () => {
|
||||
expandSection();
|
||||
fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-endpoint-1"), {
|
||||
target: { value: "not-a-url" },
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText("Endpoint must be a valid https:// URL")).toBeInTheDocument());
|
||||
|
||||
fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-endpoint-1"), {
|
||||
target: { value: "http://insecure.example.com" },
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText("Endpoint must be a valid https:// URL")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
test("valid input propagates an embeddingSource with parsed sourcePaths", async () => {
|
||||
expandSection();
|
||||
fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-sourcePaths-1"), {
|
||||
target: { value: "/description, title" },
|
||||
});
|
||||
fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-deploymentName-1"), {
|
||||
target: { value: "my-deployment" },
|
||||
});
|
||||
fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-modelName-1"), {
|
||||
target: { value: "text-embedding-3-small" },
|
||||
});
|
||||
fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-endpoint-1"), {
|
||||
target: { value: "https://my-foundry.openai.azure.com" },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1];
|
||||
const [embeddings, , valid] = lastCall;
|
||||
expect(valid).toBe(true);
|
||||
expect(embeddings[0].embeddingSource).toEqual({
|
||||
sourcePaths: ["/description", "/title"],
|
||||
deploymentName: "my-deployment",
|
||||
modelName: "text-embedding-3-small",
|
||||
endpoint: "https://my-foundry.openai.azure.com",
|
||||
authType: "Entra",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("clearing all fields drops embeddingSource from the emitted embedding", async () => {
|
||||
expandSection();
|
||||
const sourcePaths = view.container.querySelector(
|
||||
"#vector-policy-embeddingSource-sourcePaths-1",
|
||||
) as HTMLInputElement;
|
||||
const deploymentName = view.container.querySelector(
|
||||
"#vector-policy-embeddingSource-deploymentName-1",
|
||||
) as HTMLInputElement;
|
||||
const modelName = view.container.querySelector("#vector-policy-embeddingSource-modelName-1") as HTMLInputElement;
|
||||
const endpoint = view.container.querySelector("#vector-policy-embeddingSource-endpoint-1") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(sourcePaths, { target: { value: "/description" } });
|
||||
fireEvent.change(deploymentName, { target: { value: "d" } });
|
||||
fireEvent.change(modelName, { target: { value: "m" } });
|
||||
fireEvent.change(endpoint, { target: { value: "https://x.example.com" } });
|
||||
|
||||
await waitFor(() => {
|
||||
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1];
|
||||
expect(lastCall[2]).toBe(true);
|
||||
expect(lastCall[0][0].embeddingSource).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.change(sourcePaths, { target: { value: "" } });
|
||||
fireEvent.change(deploymentName, { target: { value: "" } });
|
||||
fireEvent.change(modelName, { target: { value: "" } });
|
||||
fireEvent.change(endpoint, { target: { value: "" } });
|
||||
|
||||
await waitFor(() => {
|
||||
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1];
|
||||
const [embeddings, , valid] = lastCall;
|
||||
expect(valid).toBe(true);
|
||||
expect(embeddings[0].embeddingSource).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("does not loop onVectorEmbeddingChange once all fields become valid (regression)", async () => {
|
||||
// Regression for an infinite render loop where the child rebuilt a fresh
|
||||
// VectorEmbeddingSource literal on every render, defeating the parent's
|
||||
// reference-equality dedupe and re-triggering child useEffect via the
|
||||
// unstable onChange prop. Before the fix, this scenario hung the test
|
||||
// runner (Jest would time out after several minutes).
|
||||
expandSection();
|
||||
fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-sourcePaths-1"), {
|
||||
target: { value: "/title, /description" },
|
||||
});
|
||||
fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-deploymentName-1"), {
|
||||
target: { value: "text-embedding-ada-002" },
|
||||
});
|
||||
fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-modelName-1"), {
|
||||
target: { value: "text-embedding-ada-002" },
|
||||
});
|
||||
fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-endpoint-1"), {
|
||||
target: { value: "https://my-foundry.openai.azure.com" },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1];
|
||||
expect(lastCall[2]).toBe(true);
|
||||
expect(lastCall[0][0].embeddingSource).toBeDefined();
|
||||
});
|
||||
|
||||
const stable = onChange.mock.calls.length;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(onChange.mock.calls.length).toBe(stable);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VectorEmbeddingPoliciesComponent - embedding source gating", () => {
|
||||
const newEmbedding: VectorEmbedding[] = [
|
||||
{ path: "/vector3", dataType: "float32", distanceFunction: "cosine", dimensions: 1536 },
|
||||
];
|
||||
|
||||
test("hides the embedding source accordion when the integrated-embedding capability is missing", () => {
|
||||
const isEnabledSpy = jest.spyOn(CapabilityUtils, "isIntegratedEmbeddingEnabled").mockReturnValue(false);
|
||||
try {
|
||||
const view = render(
|
||||
<VectorEmbeddingPoliciesComponent
|
||||
vectorEmbeddingsBaseline={[]}
|
||||
vectorEmbeddings={newEmbedding}
|
||||
vectorIndexes={[]}
|
||||
onVectorEmbeddingChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("Embedding source (Preview)")).toBeNull();
|
||||
expect(view.container.querySelector("#vector-policy-embeddingSource-sourcePaths-1")).toBeNull();
|
||||
expect(view.container.querySelector("#vector-policy-embeddingSource-deploymentName-1")).toBeNull();
|
||||
expect(view.container.querySelector("#vector-policy-embeddingSource-modelName-1")).toBeNull();
|
||||
expect(view.container.querySelector("#vector-policy-embeddingSource-endpoint-1")).toBeNull();
|
||||
expect(view.container.querySelector("#vector-policy-embeddingSource-authType-1")).toBeNull();
|
||||
} finally {
|
||||
isEnabledSpy.mockReturnValue(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import {
|
||||
DefaultButton,
|
||||
Dropdown,
|
||||
IDropdownOption,
|
||||
IStyleFunctionOrObject,
|
||||
ITextFieldStyleProps,
|
||||
ITextFieldStyles,
|
||||
Label,
|
||||
Stack,
|
||||
TextField,
|
||||
} from "@fluentui/react";
|
||||
import { DefaultButton, Dropdown, IDropdownOption, Label, Stack, TextField } from "@fluentui/react";
|
||||
import { InfoTooltip } from "Common/Tooltip/InfoTooltip";
|
||||
import { VectorEmbedding, VectorIndex } from "Contracts/DataModels";
|
||||
import { VectorEmbedding, VectorEmbeddingSource, VectorIndex } from "Contracts/DataModels";
|
||||
import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent";
|
||||
import { VectorEmbeddingSourceComponent } from "Explorer/Controls/VectorSearch/VectorEmbeddingSourceComponent";
|
||||
import {
|
||||
getDataTypeOptions,
|
||||
getDistanceFunctionOptions,
|
||||
@@ -19,8 +10,15 @@ import {
|
||||
getQuantizerTypeOptions,
|
||||
supportsQuantization,
|
||||
} from "Explorer/Controls/VectorSearch/VectorSearchUtils";
|
||||
import { dropdownStyles, labelStyles, textFieldStyles } from "Explorer/Controls/VectorSearch/vectorSearchStyles";
|
||||
import { Keys, t } from "Localization";
|
||||
import React, { FunctionComponent, useState } from "react";
|
||||
import React, { FunctionComponent, useCallback, useState } from "react";
|
||||
import { isIntegratedEmbeddingEnabled } from "Utils/CapabilityUtils";
|
||||
|
||||
const generatePolicyId = (): string =>
|
||||
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `vep-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
export interface IVectorEmbeddingPoliciesComponentProps {
|
||||
vectorEmbeddingsBaseline: VectorEmbedding[];
|
||||
@@ -37,6 +35,7 @@ export interface IVectorEmbeddingPoliciesComponentProps {
|
||||
}
|
||||
|
||||
export interface VectorEmbeddingPolicyData {
|
||||
id: string;
|
||||
path: string;
|
||||
dataType: VectorEmbedding["dataType"];
|
||||
distanceFunction: VectorEmbedding["distanceFunction"];
|
||||
@@ -50,44 +49,12 @@ export interface VectorEmbeddingPolicyData {
|
||||
quantizationByteSize?: number;
|
||||
quantizationByteSizeError?: string;
|
||||
quantizerType?: VectorIndex["quantizerType"];
|
||||
embeddingSource?: VectorEmbeddingSource;
|
||||
embeddingSourceValid: boolean;
|
||||
}
|
||||
|
||||
type VectorEmbeddingPolicyProperty = "dataType" | "distanceFunction" | "indexType";
|
||||
|
||||
const labelStyles = {
|
||||
root: {
|
||||
fontSize: 12,
|
||||
color: "var(--colorNeutralForeground1)",
|
||||
},
|
||||
};
|
||||
|
||||
const textFieldStyles: IStyleFunctionOrObject<ITextFieldStyleProps, ITextFieldStyles> = {
|
||||
fieldGroup: {
|
||||
height: 27,
|
||||
},
|
||||
field: {
|
||||
fontSize: 12,
|
||||
padding: "0 8px",
|
||||
backgroundColor: "var(--colorNeutralBackground1)",
|
||||
color: "var(--colorNeutralForeground1)",
|
||||
},
|
||||
};
|
||||
|
||||
const dropdownStyles = {
|
||||
title: {
|
||||
height: 27,
|
||||
lineHeight: "24px",
|
||||
fontSize: 12,
|
||||
},
|
||||
dropdown: {
|
||||
height: 27,
|
||||
lineHeight: "24px",
|
||||
},
|
||||
dropdownItem: {
|
||||
fontSize: 12,
|
||||
},
|
||||
};
|
||||
|
||||
export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddingPoliciesComponentProps> = ({
|
||||
vectorEmbeddingsBaseline,
|
||||
vectorEmbeddings,
|
||||
@@ -163,6 +130,7 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
|
||||
const supportsQuantizer = supportsQuantization(matchingType);
|
||||
mergedData.push({
|
||||
...embedding,
|
||||
id: generatePolicyId(),
|
||||
indexType: matchingType || "none",
|
||||
indexingSearchListSize: matchingIndex?.indexingSearchListSize || undefined,
|
||||
quantizationByteSize: matchingIndex?.quantizationByteSize || undefined,
|
||||
@@ -170,6 +138,8 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
|
||||
vectorIndexShardKey: matchingIndex?.vectorIndexShardKey || undefined,
|
||||
pathError: onVectorEmbeddingPathError(embedding.path),
|
||||
dimensionsError: onVectorEmbeddingDimensionError(embedding.dimensions, matchingIndex?.type || "none"),
|
||||
embeddingSource: embedding.embeddingSource,
|
||||
embeddingSourceValid: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -193,12 +163,18 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
|
||||
}, [discardChanges]);
|
||||
|
||||
const propagateData = () => {
|
||||
const vectorEmbeddings: VectorEmbedding[] = vectorEmbeddingPolicyData.map((policy: VectorEmbeddingPolicyData) => ({
|
||||
path: policy.path,
|
||||
dataType: policy.dataType,
|
||||
dimensions: policy.dimensions,
|
||||
distanceFunction: policy.distanceFunction,
|
||||
}));
|
||||
const vectorEmbeddings: VectorEmbedding[] = vectorEmbeddingPolicyData.map((policy: VectorEmbeddingPolicyData) => {
|
||||
const base: VectorEmbedding = {
|
||||
path: policy.path,
|
||||
dataType: policy.dataType,
|
||||
dimensions: policy.dimensions,
|
||||
distanceFunction: policy.distanceFunction,
|
||||
};
|
||||
if (policy.embeddingSource) {
|
||||
base.embeddingSource = policy.embeddingSource;
|
||||
}
|
||||
return base;
|
||||
});
|
||||
const vectorIndexes: VectorIndex[] = vectorEmbeddingPolicyData
|
||||
.filter((policy: VectorEmbeddingPolicyData) => policy.indexType !== "none")
|
||||
.map(
|
||||
@@ -215,7 +191,8 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
|
||||
}) as VectorIndex,
|
||||
);
|
||||
const validationPassed = vectorEmbeddingPolicyData.every(
|
||||
(policy: VectorEmbeddingPolicyData) => policy.pathError === "" && policy.dimensionsError === "",
|
||||
(policy: VectorEmbeddingPolicyData) =>
|
||||
policy.pathError === "" && policy.dimensionsError === "" && policy.embeddingSourceValid,
|
||||
);
|
||||
|
||||
onVectorEmbeddingChange(vectorEmbeddings, vectorIndexes, validationPassed);
|
||||
@@ -306,10 +283,29 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
|
||||
setVectorEmbeddingPolicyData(vectorEmbeddings);
|
||||
};
|
||||
|
||||
const onEmbeddingSourceChange = useCallback(
|
||||
(index: number, embeddingSource: VectorEmbeddingSource | undefined, isValid: boolean): void => {
|
||||
setVectorEmbeddingPolicyData((prev) => {
|
||||
const current = prev[index];
|
||||
if (!current) {
|
||||
return prev;
|
||||
}
|
||||
if (current.embeddingSource === embeddingSource && current.embeddingSourceValid === isValid) {
|
||||
return prev;
|
||||
}
|
||||
const next = [...prev];
|
||||
next[index] = { ...current, embeddingSource, embeddingSourceValid: isValid };
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onAdd = () => {
|
||||
setVectorEmbeddingPolicyData([
|
||||
...vectorEmbeddingPolicyData,
|
||||
{
|
||||
id: generatePolicyId(),
|
||||
path: "",
|
||||
dataType: "float32",
|
||||
distanceFunction: "euclidean",
|
||||
@@ -317,6 +313,8 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
|
||||
indexType: "none",
|
||||
pathError: onVectorEmbeddingPathError(""),
|
||||
dimensionsError: onVectorEmbeddingDimensionError(0, "none"),
|
||||
embeddingSource: undefined,
|
||||
embeddingSourceValid: true,
|
||||
},
|
||||
]);
|
||||
};
|
||||
@@ -340,12 +338,13 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
|
||||
vectorEmbeddingPolicyData.map((vectorEmbeddingPolicy: VectorEmbeddingPolicyData, index: number) => (
|
||||
<CollapsibleSectionComponent
|
||||
disabled={isExistingPolicy(vectorEmbeddingPolicy)}
|
||||
key={index}
|
||||
key={vectorEmbeddingPolicy.id}
|
||||
isExpandedByDefault={true}
|
||||
title={t(Keys.controls.vectorEmbeddingPolicies.vectorEmbeddingTitle, { index: index + 1 })}
|
||||
showDelete={true}
|
||||
onDelete={() => onDelete(index)}
|
||||
disableDelete={false}
|
||||
dataTest={`VectorEmbedding/Section/${index + 1}`}
|
||||
>
|
||||
<Stack horizontal tokens={{ childrenGap: 4 }}>
|
||||
<Stack
|
||||
@@ -365,6 +364,7 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
|
||||
<TextField
|
||||
disabled={isExistingPolicy(vectorEmbeddingPolicy)}
|
||||
id={`vector-policy-path-${index + 1}`}
|
||||
data-test={`VectorEmbedding/Path/${index + 1}`}
|
||||
required={true}
|
||||
placeholder="/vector1"
|
||||
styles={textFieldStyles}
|
||||
@@ -410,6 +410,7 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
|
||||
<TextField
|
||||
disabled={isExistingPolicy(vectorEmbeddingPolicy)}
|
||||
id={`vector-policy-dimension-${index + 1}`}
|
||||
data-test={`VectorEmbedding/Dimensions/${index + 1}`}
|
||||
required={true}
|
||||
styles={textFieldStyles}
|
||||
value={String(vectorEmbeddingPolicy.dimensions || 0)}
|
||||
@@ -525,11 +526,25 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
{isIntegratedEmbeddingEnabled() && (
|
||||
<VectorEmbeddingSourceComponent
|
||||
index={index}
|
||||
disabled={isExistingPolicy(vectorEmbeddingPolicy)}
|
||||
initialEmbeddingSource={vectorEmbeddingPolicy.embeddingSource}
|
||||
discardChanges={discardChanges}
|
||||
onChange={(source, isValid) => onEmbeddingSourceChange(index, source, isValid)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CollapsibleSectionComponent>
|
||||
))}
|
||||
<DefaultButton id={`add-vector-policy`} styles={{ root: { maxWidth: 170, fontSize: 12 } }} onClick={onAdd}>
|
||||
<DefaultButton
|
||||
id={`add-vector-policy`}
|
||||
data-test="VectorEmbedding/AddButton"
|
||||
styles={{ root: { maxWidth: 170, fontSize: 12 } }}
|
||||
onClick={onAdd}
|
||||
>
|
||||
{t(Keys.controls.vectorEmbeddingPolicies.addVectorEmbedding)}
|
||||
</DefaultButton>
|
||||
</Stack>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { IDropdownOption } from "@fluentui/react";
|
||||
import { VectorIndex } from "Contracts/DataModels";
|
||||
import { VectorEmbeddingSource, VectorIndex } from "Contracts/DataModels";
|
||||
import { Keys, t } from "Localization";
|
||||
|
||||
const dataTypes = ["float32", "uint8", "int8", "float16"];
|
||||
const distanceFunctions = ["euclidean", "cosine", "dotproduct"];
|
||||
const indexTypes = ["none", "flat", "diskANN", "quantizedFlat"];
|
||||
const authTypes: VectorEmbeddingSource["authType"][] = ["Entra"];
|
||||
|
||||
export const getDataTypeOptions = (): IDropdownOption[] => createDropdownOptionsFromLiterals(dataTypes);
|
||||
export const getDistanceFunctionOptions = (): IDropdownOption[] => createDropdownOptionsFromLiterals(distanceFunctions);
|
||||
export const getIndexTypeOptions = (): IDropdownOption[] => createDropdownOptionsFromLiterals(indexTypes);
|
||||
export const getAuthTypeOptions = (): IDropdownOption[] => createDropdownOptionsFromLiterals(authTypes);
|
||||
export const getQuantizerTypeOptions = (): IDropdownOption[] => [
|
||||
{ key: "product", text: "Product" },
|
||||
{ key: "spherical", text: `Spherical (${t(Keys.common.preview)})` },
|
||||
@@ -17,6 +19,28 @@ export const getQuantizerTypeOptions = (): IDropdownOption[] => [
|
||||
export const supportsQuantization = (indexType: VectorIndex["type"] | "none" | undefined): boolean =>
|
||||
indexType === "quantizedFlat" || indexType === "diskANN";
|
||||
|
||||
// Parses a comma-separated path list, trims whitespace, drops blanks, and
|
||||
// prefixes a leading "/" on each entry if it isn't there already.
|
||||
export const parseSourcePaths = (raw: string): string[] => {
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
return raw
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0)
|
||||
.map((p) => (p.startsWith("/") ? p : `/${p}`));
|
||||
};
|
||||
|
||||
export const isValidHttpsUrl = (value: string): boolean => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
function createDropdownOptionsFromLiterals<T extends string>(literals: T[]): IDropdownOption[] {
|
||||
return literals.map((value) => ({
|
||||
key: value,
|
||||
|
||||
@@ -838,6 +838,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
scrollToSection("collapsibleVectorPolicySectionContent");
|
||||
}}
|
||||
tooltipContent={ContainerVectorPolicyTooltipContent()}
|
||||
dataTest="ContainerVectorPolicy/Section"
|
||||
>
|
||||
<Stack id="collapsibleVectorPolicySectionContent" styles={{ root: { position: "relative" } }}>
|
||||
<Stack styles={{ root: { paddingLeft: 40 } }}>
|
||||
|
||||
@@ -994,12 +994,27 @@
|
||||
"indexingSearchListSize": "Indexing search list size",
|
||||
"vectorIndexShardKey": "Vector index shard key",
|
||||
"addVectorEmbedding": "Add vector embedding",
|
||||
"embeddingSourceSection": "Embedding source",
|
||||
"embeddingSourceTooltip": "Configure Cosmos DB to auto-generate the vector from one or more source paths using a Microsoft Foundry deployment. Leave all fields empty to skip auto-generation.",
|
||||
"sourcePaths": "Source paths",
|
||||
"sourcePathsPlaceholder": "/description, /title",
|
||||
"deploymentName": "Deployment name",
|
||||
"modelName": "Model name",
|
||||
"endpoint": "Endpoint",
|
||||
"endpointPlaceholder": "https://<your-foundry-endpoint>",
|
||||
"authType": "Authentication type",
|
||||
"pathEmptyError": "Path should not be empty",
|
||||
"pathDuplicateError": "Path is already defined",
|
||||
"dimensionRangeError": "Dimension must be greater than 0 and less than or equal 4096",
|
||||
"dimensionFlatIndexError": "Maximum allowed dimension for flat index is 505",
|
||||
"quantizationByteSizeRangeError": "Quantization byte size must be greater than 0 and less than or equal to 512",
|
||||
"indexingSearchListSizeRangeError": "Indexing search list size must be greater than or equal to 25 and less than or equal to 500"
|
||||
"indexingSearchListSizeRangeError": "Indexing search list size must be greater than or equal to 25 and less than or equal to 500",
|
||||
"sourcePathsRequiredError": "At least one source path is required",
|
||||
"sourcePathDuplicateError": "Source paths must be unique",
|
||||
"deploymentNameRequiredError": "Deployment name is required",
|
||||
"modelNameRequiredError": "Model name is required",
|
||||
"endpointRequiredError": "Endpoint is required",
|
||||
"endpointInvalidError": "Endpoint must be a valid https:// URL"
|
||||
}
|
||||
},
|
||||
"containerCopy": {
|
||||
@@ -1173,4 +1188,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,3 +34,14 @@ export const isFullTextSearchPreviewFeaturesEnabled = (targetAccountOverride?: A
|
||||
isCapabilityEnabled(Constants.CapabilityNames.EnableNoSQLFullTextSearchPreviewFeatures, targetAccountOverride)
|
||||
);
|
||||
};
|
||||
|
||||
// `enableEmbeddingGenerator` is a top-level boolean on the database account
|
||||
// (e.g. properties.enableEmbeddingGenerator), not an entry in
|
||||
// properties.capabilities[]. It's the Foundry integrated embedding generator
|
||||
// flag that gates the `embeddingSource` block inside a Container Vector Policy.
|
||||
export const isIntegratedEmbeddingEnabled = (targetAccountOverride?: AccountOverride): boolean => {
|
||||
const { databaseAccount } = userContext;
|
||||
const enableEmbeddingGenerator =
|
||||
targetAccountOverride?.enableEmbeddingGenerator ?? databaseAccount?.properties?.enableEmbeddingGenerator;
|
||||
return isVectorSearchEnabled(targetAccountOverride) && enableEmbeddingGenerator === true;
|
||||
};
|
||||
|
||||
@@ -1412,6 +1412,27 @@ export interface VectorEmbedding {
|
||||
|
||||
/* The number of dimensions in the vector. */
|
||||
dimensions: number;
|
||||
|
||||
/* Optional configuration for Cosmos DB integrated embeddings (server-side
|
||||
embedding generation via Microsoft Foundry). When set, Cosmos DB
|
||||
auto-populates the vector field from the listed source paths.
|
||||
TODO: This field was added manually pending an upstream Swagger update;
|
||||
re-applying `npm run generateARMClients` will overwrite it. */
|
||||
embeddingSource?: VectorEmbeddingSource;
|
||||
}
|
||||
|
||||
/* Configuration for Cosmos DB integrated embeddings. */
|
||||
export interface VectorEmbeddingSource {
|
||||
/* Source field paths used as input for embedding generation. */
|
||||
sourcePaths: string[];
|
||||
/* The Foundry deployment name. */
|
||||
deploymentName: string;
|
||||
/* The Foundry model name. */
|
||||
modelName: string;
|
||||
/* The Foundry endpoint URL. */
|
||||
endpoint: string;
|
||||
/* Authentication type used by Cosmos DB to call Foundry. */
|
||||
authType: "Entra";
|
||||
}
|
||||
|
||||
/* Represents the full text index path. */
|
||||
|
||||
@@ -50,3 +50,81 @@ test("SQL database and container CRUD", async ({ page }) => {
|
||||
|
||||
await expect(databaseNode.element).not.toBeAttached();
|
||||
});
|
||||
|
||||
test("SQL container with vector embedding policy and embedding source", async ({ page }) => {
|
||||
const databaseId = generateUniqueName("vdb");
|
||||
const containerId = "testvectorcontainer";
|
||||
|
||||
const explorer = await DataExplorer.open(page, TestAccount.SQL);
|
||||
|
||||
// Open the New Container panel and check if the embedding source capability is available
|
||||
// before proceeding. We must skip before whilePanelOpen to avoid a timeout on panel close.
|
||||
const newContainerButton = await explorer.globalCommandButton("New Container");
|
||||
await newContainerButton.click();
|
||||
|
||||
const panel = explorer.panel("New Container");
|
||||
await panel.waitFor();
|
||||
|
||||
// Expand vector policy section and add a vector embedding to check for the embedding source accordion
|
||||
await panel.getByTestId("ContainerVectorPolicy/Section").click();
|
||||
await panel.getByTestId("VectorEmbedding/AddButton").click();
|
||||
|
||||
const embeddingSourceSection = panel.getByTestId("VectorEmbeddingSource/Section/1");
|
||||
if ((await embeddingSourceSection.count()) === 0) {
|
||||
// Close the panel before skipping
|
||||
await panel.getByRole("button", { name: "Close New Container" }).click();
|
||||
await panel.waitFor({ state: "detached" });
|
||||
test.skip(true, "Test account does not have the integrated embedding capability.");
|
||||
}
|
||||
|
||||
await panel.getByPlaceholder("Type a new database id").fill(databaseId);
|
||||
await panel.getByRole("textbox", { name: "Container id, Example Container1" }).fill(containerId);
|
||||
await panel.getByRole("textbox", { name: "Partition key" }).fill("/pk");
|
||||
await panel.getByTestId("autoscaleRUInput").fill(TEST_AUTOSCALE_THROUGHPUT_RU.toString());
|
||||
|
||||
await panel.getByTestId("VectorEmbedding/Path/1").fill("/embedding");
|
||||
await panel.getByTestId("VectorEmbedding/Dimensions/1").fill("1536");
|
||||
|
||||
// Expand embedding source section and fill fields
|
||||
await embeddingSourceSection.click();
|
||||
|
||||
await panel.getByTestId("VectorEmbeddingSource/SourcePaths/1").fill("/description");
|
||||
await panel.getByTestId("VectorEmbeddingSource/DeploymentName/1").fill("text-embedding-3-small");
|
||||
await panel.getByTestId("VectorEmbeddingSource/ModelName/1").fill("text-embedding-3-small");
|
||||
await panel
|
||||
.getByTestId("VectorEmbeddingSource/Endpoint/1")
|
||||
.fill("https://e2e-embedding.cognitiveservices.azure.com/");
|
||||
|
||||
const okButton = panel.getByTestId("Panel/OkButton");
|
||||
await okButton.click();
|
||||
await panel.waitFor({ state: "detached", timeout: 5 * 60 * 1000 });
|
||||
|
||||
const databaseNode = await explorer.waitForNode(databaseId);
|
||||
const containerNode = await explorer.waitForContainerNode(databaseId, containerId);
|
||||
|
||||
// Cleanup
|
||||
await containerNode.openContextMenu();
|
||||
await containerNode.contextMenuItem("Delete Container").click();
|
||||
await explorer.whilePanelOpen(
|
||||
"Delete Container",
|
||||
async (panel, okButton) => {
|
||||
await panel.getByRole("textbox", { name: "Confirm by typing the container id" }).fill(containerId);
|
||||
await okButton.click();
|
||||
},
|
||||
{ closeTimeout: 5 * 60 * 1000 },
|
||||
);
|
||||
await expect(containerNode.element).not.toBeAttached();
|
||||
|
||||
await databaseNode.openContextMenu();
|
||||
await databaseNode.contextMenuItem("Delete Database").click();
|
||||
await explorer.whilePanelOpen(
|
||||
"Delete Database",
|
||||
async (panel, okButton) => {
|
||||
await panel.getByRole("textbox", { name: "Confirm by typing the database id" }).fill(databaseId);
|
||||
await okButton.click();
|
||||
},
|
||||
{ closeTimeout: 5 * 60 * 1000 },
|
||||
);
|
||||
|
||||
await expect(databaseNode.element).not.toBeAttached();
|
||||
});
|
||||
|
||||
@@ -190,4 +190,59 @@ test.describe("Vector Policy under Scale & Settings", () => {
|
||||
const saveButton = explorer.commandBarButton(CommandBarButton.Save);
|
||||
await expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("Add embedding source to vector embedding policy", async () => {
|
||||
const addButton = explorer.frame.getByTestId("VectorEmbedding/AddButton");
|
||||
await addButton.click();
|
||||
|
||||
const embeddingSourceSection = explorer.frame.getByTestId("VectorEmbeddingSource/Section/1");
|
||||
if ((await embeddingSourceSection.count()) === 0) {
|
||||
test.skip(true, "Test account does not have the integrated embedding capability.");
|
||||
}
|
||||
|
||||
await explorer.frame.getByTestId("VectorEmbedding/Path/1").fill("/embedding");
|
||||
await explorer.frame.getByTestId("VectorEmbedding/Dimensions/1").fill("1536");
|
||||
|
||||
// Expand embedding source section and fill fields
|
||||
await embeddingSourceSection.click();
|
||||
|
||||
await explorer.frame.getByTestId("VectorEmbeddingSource/SourcePaths/1").fill("/description");
|
||||
await explorer.frame.getByTestId("VectorEmbeddingSource/DeploymentName/1").fill("text-embedding-3-small");
|
||||
await explorer.frame.getByTestId("VectorEmbeddingSource/ModelName/1").fill("text-embedding-3-small");
|
||||
await explorer.frame
|
||||
.getByTestId("VectorEmbeddingSource/Endpoint/1")
|
||||
.fill("https://e2e-embedding.cognitiveservices.azure.com/");
|
||||
|
||||
// Save changes
|
||||
const saveButton = explorer.commandBarButton(CommandBarButton.Save);
|
||||
await expect(saveButton).toBeEnabled();
|
||||
await saveButton.click();
|
||||
await expect(explorer.getConsoleHeaderStatus()).toContainText(
|
||||
`Successfully updated container ${context.container.id}`,
|
||||
{ timeout: 2 * ONE_MINUTE_MS },
|
||||
);
|
||||
});
|
||||
|
||||
test("Existing embedding source fields are disabled after save", async () => {
|
||||
// Ensure a policy with embedding source exists (from previous test or create one)
|
||||
const sourcePathsInput = explorer.frame.getByTestId("VectorEmbeddingSource/SourcePaths/1");
|
||||
if ((await sourcePathsInput.count()) === 0) {
|
||||
test.skip(true, "No embedding source present; previous test may have been skipped.");
|
||||
}
|
||||
|
||||
await expect(sourcePathsInput).toBeDisabled();
|
||||
await expect(sourcePathsInput).toHaveValue("/description");
|
||||
|
||||
const deploymentInput = explorer.frame.getByTestId("VectorEmbeddingSource/DeploymentName/1");
|
||||
await expect(deploymentInput).toBeDisabled();
|
||||
await expect(deploymentInput).toHaveValue("text-embedding-3-small");
|
||||
|
||||
const modelInput = explorer.frame.getByTestId("VectorEmbeddingSource/ModelName/1");
|
||||
await expect(modelInput).toBeDisabled();
|
||||
await expect(modelInput).toHaveValue("text-embedding-3-small");
|
||||
|
||||
const endpointInput = explorer.frame.getByTestId("VectorEmbeddingSource/Endpoint/1");
|
||||
await expect(endpointInput).toBeDisabled();
|
||||
await expect(endpointInput).toHaveValue("https://e2e-embedding.cognitiveservices.azure.com/");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user