[Security Assistant] Fix system prompts (#189130)

This commit is contained in:
Steph Milovic 2024-07-30 13:02:03 -05:00 committed by GitHub
parent 64c61e0442
commit dc11f75bd4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 217 additions and 31 deletions

View file

@ -13,6 +13,7 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/public/common';
import { noop } from 'lodash/fp';
import { PromptResponse } from '@kbn/elastic-assistant-common';
import { QueryObserverResult } from '@tanstack/react-query';
import { Conversation } from '../../../..';
import * as i18n from './translations';
import * as i18nModel from '../../../connectorland/models/model_selector/translations';
@ -37,6 +38,7 @@ export interface ConversationSettingsEditorProps {
React.SetStateAction<ConversationsBulkActions>
>;
onSelectedConversationChange: (conversation?: Conversation) => void;
refetchConversations?: () => Promise<QueryObserverResult<Record<string, Conversation>, unknown>>;
}
/**
@ -53,6 +55,7 @@ export const ConversationSettingsEditor: React.FC<ConversationSettingsEditorProp
conversationsSettingsBulkActions,
setConversationsSettingsBulkActions,
onSelectedConversationChange,
refetchConversations,
}) => {
const { data: connectors, isSuccess: areConnectorsFetched } = useLoadConnectors({
http,
@ -276,6 +279,7 @@ export const ConversationSettingsEditor: React.FC<ConversationSettingsEditorProp
conversation={selectedConversation}
isDisabled={isDisabled}
onSystemPromptSelectionChange={handleOnSystemPromptSelectionChange}
refetchConversations={refetchConversations}
selectedPrompt={selectedSystemPrompt}
isSettingsModalVisible={true}
setIsSettingsModalVisible={noop} // noop, already in settings

View file

@ -321,6 +321,7 @@ const ConversationSettingsManagementComponent: React.FC<Props> = ({
conversationsSettingsBulkActions={conversationsSettingsBulkActions}
http={http}
isDisabled={isDisabled}
refetchConversations={refetchConversations}
selectedConversation={selectedConversation}
setConversationSettings={setConversationSettings}
setConversationsSettingsBulkActions={setConversationsSettingsBulkActions}

View file

@ -256,6 +256,13 @@ const AssistantComponent: React.FC<Props> = ({
conversations[WELCOME_CONVERSATION_TITLE] ??
getDefaultConversation({ cTitle: WELCOME_CONVERSATION_TITLE });
// updated selected system prompt
setEditingSystemPromptId(
getDefaultSystemPrompt({
allSystemPrompts,
conversation: conversationToReturn,
})?.id
);
if (
prev &&
prev.id === conversationToReturn.id &&
@ -273,6 +280,7 @@ const AssistantComponent: React.FC<Props> = ({
});
}
}, [
allSystemPrompts,
areConnectorsFetched,
conversationTitle,
conversations,
@ -647,6 +655,7 @@ const AssistantComponent: React.FC<Props> = ({
actionTypeId: (defaultConnector?.actionTypeId as string) ?? '.gen-ai',
provider: apiConfig?.apiProvider,
model: apiConfig?.defaultModel,
defaultSystemPromptId: allSystemPrompts.find((sp) => sp.isNewConversationDefault)?.id,
},
});
},
@ -665,14 +674,14 @@ const AssistantComponent: React.FC<Props> = ({
useEffect(() => {
(async () => {
if (areConnectorsFetched && currentConversation?.id === '') {
if (areConnectorsFetched && currentConversation?.id === '' && !isLoadingPrompts) {
const conversation = await mutateAsync(currentConversation);
if (currentConversation.id === '' && conversation) {
setCurrentConversationId(conversation.id);
}
}
})();
}, [areConnectorsFetched, currentConversation, mutateAsync]);
}, [areConnectorsFetched, currentConversation, isLoadingPrompts, mutateAsync]);
const handleCreateConversation = useCallback(async () => {
const newChatExists = find(conversations, ['title', NEW_CHAT]);
@ -791,6 +800,7 @@ const AssistantComponent: React.FC<Props> = ({
isSettingsModalVisible={isSettingsModalVisible}
setIsSettingsModalVisible={setIsSettingsModalVisible}
allSystemPrompts={allSystemPrompts}
refetchConversations={refetchResults}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
@ -823,6 +833,7 @@ const AssistantComponent: React.FC<Props> = ({
handleOnSystemPromptSelectionChange,
isSettingsModalVisible,
isWelcomeSetup,
refetchResults,
]);
return (

View file

@ -16,13 +16,13 @@ import { getOptions, getOptionFromPrompt } from './helpers';
describe('helpers', () => {
describe('getOptionFromPrompt', () => {
it('returns an EuiSuperSelectOption with the correct value', () => {
const option = getOptionFromPrompt({ ...mockSystemPrompt });
const option = getOptionFromPrompt({ ...mockSystemPrompt, isCleared: false });
expect(option.value).toBe(mockSystemPrompt.id);
});
it('returns an EuiSuperSelectOption with the correct inputDisplay', () => {
const option = getOptionFromPrompt({ ...mockSystemPrompt });
const option = getOptionFromPrompt({ ...mockSystemPrompt, isCleared: false });
render(<>{option.inputDisplay}</>);
@ -30,7 +30,7 @@ describe('helpers', () => {
});
it('shows the expected name in the dropdownDisplay', () => {
const option = getOptionFromPrompt({ ...mockSystemPrompt });
const option = getOptionFromPrompt({ ...mockSystemPrompt, isCleared: false });
render(<TestProviders>{option.dropdownDisplay}</TestProviders>);
@ -38,7 +38,7 @@ describe('helpers', () => {
});
it('shows the expected prompt content in the dropdownDisplay', () => {
const option = getOptionFromPrompt({ ...mockSystemPrompt });
const option = getOptionFromPrompt({ ...mockSystemPrompt, isCleared: false });
render(<TestProviders>{option.dropdownDisplay}</TestProviders>);
@ -51,7 +51,7 @@ describe('helpers', () => {
const prompts = [mockSystemPrompt, mockSuperheroSystemPrompt];
const promptIds = prompts.map(({ id }) => id);
const options = getOptions({ prompts });
const options = getOptions({ prompts, isCleared: false });
const optionValues = options.map(({ value }) => value);
expect(optionValues).toEqual(promptIds);

View file

@ -12,19 +12,38 @@ import styled from '@emotion/styled';
import { isEmpty } from 'lodash/fp';
import { euiThemeVars } from '@kbn/ui-theme';
import { PromptResponse } from '@kbn/elastic-assistant-common';
import { css } from '@emotion/react';
import { EMPTY_PROMPT } from './translations';
const Strong = styled.strong`
margin-right: ${euiThemeVars.euiSizeS};
`;
interface GetOptionFromPromptProps extends PromptResponse {
content: string;
id: string;
name: string;
isCleared: boolean;
}
export const getOptionFromPrompt = ({
content,
id,
isCleared,
name,
}: PromptResponse): EuiSuperSelectOption<string> => ({
}: GetOptionFromPromptProps): EuiSuperSelectOption<string> => ({
value: id,
inputDisplay: <span data-test-subj="systemPromptText">{name}</span>,
inputDisplay: (
<span
data-test-subj="systemPromptText"
// @ts-ignore
css={css`
color: ${isCleared ? euiThemeVars.euiColorLightShade : euiThemeVars.euiColorDarkestShade};
`}
>
{name}
</span>
),
dropdownDisplay: (
<>
<Strong data-test-subj="name">{name}</Strong>
@ -41,6 +60,10 @@ export const getOptionFromPrompt = ({
interface GetOptionsProps {
prompts: PromptResponse[] | undefined;
isCleared: boolean;
}
export const getOptions = ({ prompts }: GetOptionsProps): Array<EuiSuperSelectOption<string>> =>
prompts?.map(getOptionFromPrompt) ?? [];
export const getOptions = ({
prompts,
isCleared,
}: GetOptionsProps): Array<EuiSuperSelectOption<string>> =>
prompts?.map((p) => getOptionFromPrompt({ ...p, isCleared })) ?? [];

View file

@ -5,8 +5,9 @@
* 2.0.
*/
import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { PromptResponse } from '@kbn/elastic-assistant-common';
import { QueryObserverResult } from '@tanstack/react-query';
import { Conversation } from '../../../..';
import { SelectSystemPrompt } from './select_system_prompt';
@ -17,6 +18,7 @@ interface Props {
onSystemPromptSelectionChange: (systemPromptId: string | undefined) => void;
setIsSettingsModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
allSystemPrompts: PromptResponse[];
refetchConversations?: () => Promise<QueryObserverResult<Record<string, Conversation>, unknown>>;
}
const SystemPromptComponent: React.FC<Props> = ({
@ -26,9 +28,12 @@ const SystemPromptComponent: React.FC<Props> = ({
onSystemPromptSelectionChange,
setIsSettingsModalVisible,
allSystemPrompts,
refetchConversations,
}) => {
const [isCleared, setIsCleared] = useState(false);
const selectedPrompt = useMemo(() => {
if (editingSystemPromptId !== undefined) {
setIsCleared(false);
return allSystemPrompts.find((p) => p.id === editingSystemPromptId);
} else {
return allSystemPrompts.find((p) => p.id === conversation?.apiConfig?.defaultSystemPromptId);
@ -36,10 +41,21 @@ const SystemPromptComponent: React.FC<Props> = ({
}, [allSystemPrompts, conversation?.apiConfig?.defaultSystemPromptId, editingSystemPromptId]);
const handleClearSystemPrompt = useCallback(() => {
if (conversation) {
if (editingSystemPromptId === undefined) {
setIsCleared(false);
onSystemPromptSelectionChange(
allSystemPrompts.find((p) => p.id === conversation?.apiConfig?.defaultSystemPromptId)?.id
);
} else {
setIsCleared(true);
onSystemPromptSelectionChange(undefined);
}
}, [conversation, onSystemPromptSelectionChange]);
}, [
allSystemPrompts,
conversation?.apiConfig?.defaultSystemPromptId,
editingSystemPromptId,
onSystemPromptSelectionChange,
]);
return (
<SelectSystemPrompt
@ -48,6 +64,8 @@ const SystemPromptComponent: React.FC<Props> = ({
conversation={conversation}
data-test-subj="systemPrompt"
isClearable={true}
isCleared={isCleared}
refetchConversations={refetchConversations}
isSettingsModalVisible={isSettingsModalVisible}
onSystemPromptSelectionChange={onSystemPromptSelectionChange}
selectedPrompt={selectedPrompt}

View file

@ -22,6 +22,7 @@ import {
PromptResponse,
PromptTypeEnum,
} from '@kbn/elastic-assistant-common/impl/schemas/prompts/bulk_crud_prompts_route.gen';
import { QueryObserverResult } from '@tanstack/react-query';
import { Conversation } from '../../../../..';
import { getOptions } from '../helpers';
import * as i18n from '../translations';
@ -38,6 +39,7 @@ export interface Props {
selectedPrompt: PromptResponse | undefined;
clearSelectedSystemPrompt?: () => void;
isClearable?: boolean;
isCleared?: boolean;
isDisabled?: boolean;
isOpen?: boolean;
isSettingsModalVisible: boolean;
@ -46,6 +48,7 @@ export interface Props {
onSelectedConversationChange?: (result: Conversation) => void;
setConversationSettings?: React.Dispatch<React.SetStateAction<Record<string, Conversation>>>;
setConversationsSettingsBulkActions?: React.Dispatch<Record<string, Conversation>>;
refetchConversations?: () => Promise<QueryObserverResult<Record<string, Conversation>, unknown>>;
}
const ADD_NEW_SYSTEM_PROMPT = 'ADD_NEW_SYSTEM_PROMPT';
@ -57,8 +60,10 @@ const SelectSystemPromptComponent: React.FC<Props> = ({
selectedPrompt,
clearSelectedSystemPrompt,
isClearable = false,
isCleared = false,
isDisabled = false,
isOpen = false,
refetchConversations,
isSettingsModalVisible,
onSystemPromptSelectionChange,
setIsSettingsModalVisible,
@ -89,10 +94,11 @@ const SelectSystemPromptComponent: React.FC<Props> = ({
defaultSystemPromptId: promptId,
},
});
await refetchConversations?.();
return result;
}
},
[conversation, setApiConfig]
[conversation, refetchConversations, setApiConfig]
);
const addNewSystemPrompt = useMemo(() => {
@ -116,7 +122,10 @@ const SelectSystemPromptComponent: React.FC<Props> = ({
}, []);
// SuperSelect State/Actions
const options = useMemo(() => getOptions({ prompts: allSystemPrompts }), [allSystemPrompts]);
const options = useMemo(
() => getOptions({ prompts: allSystemPrompts, isCleared }),
[allSystemPrompts, isCleared]
);
const onChange = useCallback(
async (selectedSystemPromptId) => {
@ -160,9 +169,8 @@ const SelectSystemPromptComponent: React.FC<Props> = ({
);
const clearSystemPrompt = useCallback(() => {
setSelectedSystemPrompt(undefined);
clearSelectedSystemPrompt?.();
}, [clearSelectedSystemPrompt, setSelectedSystemPrompt]);
}, [clearSelectedSystemPrompt]);
return (
<EuiFlexGroup
@ -226,10 +234,14 @@ const SelectSystemPromptComponent: React.FC<Props> = ({
inline-size: 16px;
block-size: 16px;
border-radius: 16px;
background: ${euiThemeVars.euiColorMediumShade};
background: ${isCleared
? euiThemeVars.euiColorLightShade
: euiThemeVars.euiColorMediumShade};
:hover:not(:disabled) {
background: ${euiThemeVars.euiColorMediumShade};
background: ${isCleared
? euiThemeVars.euiColorLightShade
: euiThemeVars.euiColorMediumShade};
transform: none;
}

View file

@ -5,13 +5,38 @@
* 2.0.
*/
import React from 'react';
import { act, renderHook } from '@testing-library/react-hooks';
import { httpServiceMock, type HttpSetupMock } from '@kbn/core-http-browser-mocks';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
import { createConversations } from './provider';
import { AssistantProvider, createConversations } from './provider';
import { coreMock } from '@kbn/core/public/mocks';
import { useKibana as mockUseKibana } from '../common/lib/kibana/__mocks__';
import { loadAllActions as loadConnectors } from '@kbn/triggers-actions-ui-plugin/public/common/constants';
import { useKibana } from '../common/lib/kibana';
import { render, waitFor } from '@testing-library/react';
import { TestProviders } from '../common/mock';
import { useAssistantAvailability } from './use_assistant_availability';
import {
bulkUpdatePrompts,
getPrompts,
getUserConversations,
} from '@kbn/elastic-assistant/impl/assistant/api';
import { BASE_SECURITY_SYSTEM_PROMPTS } from './content/prompts/system';
const mockedUseKibana = mockUseKibana();
jest.mock('./use_assistant_availability');
jest.mock('../common/lib/kibana');
jest.mock('@kbn/elastic-assistant/impl/assistant/api');
jest.mock('../common/hooks/use_license', () => ({
useLicense: () => ({
isEnterprise: () => true,
}),
licenseService: {
isEnterprise: () => true,
},
}));
jest.mock('@kbn/triggers-actions-ui-plugin/public/common/constants');
let http: HttpSetupMock = coreMock.createSetup().http;
export const mockConnectors = [
@ -199,3 +224,85 @@ describe('createConversations', () => {
});
});
});
describe('AssistantProvider', () => {
beforeEach(() => {
jest.clearAllMocks();
(useKibana as jest.Mock).mockReturnValue({
...mockedUseKibana,
services: {
...mockedUseKibana.services,
},
});
jest.mocked(useAssistantAvailability).mockReturnValue({
hasAssistantPrivilege: true,
hasConnectorsAllPrivilege: true,
hasConnectorsReadPrivilege: true,
hasUpdateAIAssistantAnonymization: true,
isAssistantEnabled: true,
});
(getUserConversations as jest.Mock).mockResolvedValue({
page: 1,
perPage: 5,
total: 5,
data: [],
});
(getPrompts as jest.Mock).mockResolvedValue({
page: 1,
perPage: 5,
total: 0,
data: [],
});
});
it('should not render the assistant when no prompts have been returned', async () => {
const { queryByTestId } = render(
<AssistantProvider>
<span data-test-subj="ourAssistant" />
</AssistantProvider>,
{
wrapper: TestProviders,
}
);
expect(queryByTestId('ourAssistant')).toBeNull();
});
it('should render the assistant when prompts are returned', async () => {
(getPrompts as jest.Mock).mockResolvedValue({
page: 1,
perPage: 5,
total: 2,
data: BASE_SECURITY_SYSTEM_PROMPTS,
});
const { getByTestId } = render(
<AssistantProvider>
<span data-test-subj="ourAssistant" />
</AssistantProvider>,
{
wrapper: TestProviders,
}
);
await waitFor(() => {
expect(getByTestId('ourAssistant')).not.toBeNull();
});
});
it('should render the assistant once prompts have been created', async () => {
(bulkUpdatePrompts as jest.Mock).mockResolvedValue({
success: true,
attributes: {
results: {
created: BASE_SECURITY_SYSTEM_PROMPTS,
},
},
});
const { getByTestId } = render(
<AssistantProvider>
<span data-test-subj="ourAssistant" />
</AssistantProvider>,
{
wrapper: TestProviders,
}
);
await waitFor(() => {
expect(getByTestId('ourAssistant')).not.toBeNull();
});
});
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { FC, PropsWithChildren } from 'react';
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { parse } from '@kbn/datemath';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
import { i18n } from '@kbn/i18n';
@ -128,7 +128,7 @@ export const createBasePrompts = async (notifications: NotificationsStart, http:
notifications.toasts
);
if (bulkResult && bulkResult.success) {
return true;
return bulkResult.attributes.results.created;
}
};
@ -176,6 +176,8 @@ export const AssistantProvider: FC<PropsWithChildren<unknown>> = ({ children })
storage,
]);
const [basePromptsLoaded, setBasePromptsLoaded] = useState(false);
useEffect(() => {
const createSecurityPrompts = once(async () => {
if (
@ -183,15 +185,20 @@ export const AssistantProvider: FC<PropsWithChildren<unknown>> = ({ children })
assistantAvailability.isAssistantEnabled &&
assistantAvailability.hasAssistantPrivilege
) {
const res = await getPrompts({
http,
toasts: notifications.toasts,
});
try {
const res = await getPrompts({
http,
toasts: notifications.toasts,
});
if (res.total === 0) {
await createBasePrompts(notifications, http);
}
if (res.total === 0) {
await createBasePrompts(notifications, http);
}
// eslint-disable-next-line no-empty
} catch (e) {}
}
setBasePromptsLoaded(true);
});
createSecurityPrompts();
}, [
@ -205,6 +212,9 @@ export const AssistantProvider: FC<PropsWithChildren<unknown>> = ({ children })
const { signalIndexName } = useSignalIndex();
const alertsIndexPattern = signalIndexName ?? undefined;
const toasts = useAppToasts() as unknown as IToasts; // useAppToasts is the current, non-deprecated method of getting the toasts service in the Security Solution, but it doesn't return the IToasts interface (defined by core)
// Because our conversations need an assigned system prompt at create time,
// we want to make sure the prompts are there before creating the first conversation
// however if there is an error fetching the prompts, we don't want to block the app
return (
<ElasticAssistantProvider
@ -224,7 +234,7 @@ export const AssistantProvider: FC<PropsWithChildren<unknown>> = ({ children })
toasts={toasts}
currentAppId={currentAppId ?? 'securitySolutionUI'}
>
{children}
{basePromptsLoaded ? children : null}
</ElasticAssistantProvider>
);
};