[8.15] [Search][Playground] Fix playground selected fields (#188278) (#188380)

# Backport

This will backport the following commits from `main` to `8.15`:
- [[Search][Playground] Fix playground selected fields
(#188278)](https://github.com/elastic/kibana/pull/188278)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Yan
Savitski","email":"yan.savitski@elastic.co"},"sourceCommit":{"committedDate":"2024-07-15T22:37:41Z","message":"[Search][Playground]
Fix playground selected fields (#188278)\n\nWhen user selected fields in
query mode, goes to chat mode and then back\r\nto query mode. Some
fields may return to default
value\r\n\r\n---------\r\n\r\nCo-authored-by: Joseph McElroy
<joseph.mcelroy@elastic.co>","sha":"37845b04e8df7e61a2606634df28b6800cf55ed7","branchLabelMapping":{"^v8.16.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:EnterpriseSearch","v8.15.0","v8.16.0"],"title":"[Search][Playground]
Fix playground selected
fields","number":188278,"url":"https://github.com/elastic/kibana/pull/188278","mergeCommit":{"message":"[Search][Playground]
Fix playground selected fields (#188278)\n\nWhen user selected fields in
query mode, goes to chat mode and then back\r\nto query mode. Some
fields may return to default
value\r\n\r\n---------\r\n\r\nCo-authored-by: Joseph McElroy
<joseph.mcelroy@elastic.co>","sha":"37845b04e8df7e61a2606634df28b6800cf55ed7"}},"sourceBranch":"main","suggestedTargetBranches":["8.15"],"targetPullRequestStates":[{"branch":"8.15","label":"v8.15.0","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v8.16.0","branchLabelMappingKey":"^v8.16.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/188278","number":188278,"mergeCommit":{"message":"[Search][Playground]
Fix playground selected fields (#188278)\n\nWhen user selected fields in
query mode, goes to chat mode and then back\r\nto query mode. Some
fields may return to default
value\r\n\r\n---------\r\n\r\nCo-authored-by: Joseph McElroy
<joseph.mcelroy@elastic.co>","sha":"37845b04e8df7e61a2606634df28b6800cf55ed7"}}]}]
BACKPORT-->

Co-authored-by: Yan Savitski <yan.savitski@elastic.co>
This commit is contained in:
Kibana Machine 2024-07-16 02:04:04 +02:00 committed by GitHub
parent cc7df3dce5
commit c2ae24f1bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 494 additions and 311 deletions

View file

@ -7,8 +7,6 @@
import React, { useMemo } from 'react';
import { EuiPageTemplate } from '@elastic/eui';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from './utils/query_client';
import { PlaygroundProvider } from './providers/playground_provider';
import { App } from './components/app';
@ -25,19 +23,17 @@ export const ChatPlaygroundOverview: React.FC = () => {
);
return (
<QueryClientProvider client={queryClient}>
<PlaygroundProvider>
<EuiPageTemplate
offset={0}
restrictWidth={false}
data-test-subj="svlPlaygroundPage"
grow={false}
panelled={false}
>
<App showDocs />
{embeddableConsole}
</EuiPageTemplate>
</PlaygroundProvider>
</QueryClientProvider>
<PlaygroundProvider>
<EuiPageTemplate
offset={0}
restrictWidth={false}
data-test-subj="svlPlaygroundPage"
grow={false}
panelled={false}
>
<App showDocs />
{embeddableConsole}
</EuiPageTemplate>
</PlaygroundProvider>
);
};

View file

@ -8,7 +8,7 @@
import React, { useEffect, useState } from 'react';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { useFormContext } from 'react-hook-form';
import { useWatch } from 'react-hook-form';
import { QueryMode } from './query_mode/query_mode';
import { SetupPage } from './setup_page/setup_page';
import { Header } from './header';
@ -28,14 +28,17 @@ export enum ViewMode {
export const App: React.FC<AppProps> = ({ showDocs = false }) => {
const [showSetupPage, setShowSetupPage] = useState(true);
const [selectedMode, setSelectedMode] = useState<ViewMode>(ViewMode.chat);
const { watch } = useFormContext<ChatForm>();
const { data: connectors } = useLoadConnectors();
const hasSelectedIndices = watch(ChatFormFields.indices).length;
const hasSelectedIndices = useWatch<ChatForm, ChatFormFields.indices>({
name: ChatFormFields.indices,
}).length;
const handleModeChange = (id: string) => setSelectedMode(id as ViewMode);
useEffect(() => {
if (showSetupPage && connectors?.length && hasSelectedIndices) {
setShowSetupPage(false);
} else if (!showSetupPage && (!connectors?.length || !hasSelectedIndices)) {
setShowSetupPage(true);
}
}, [connectors, hasSelectedIndices, showSetupPage]);

View file

@ -51,6 +51,16 @@ const MockFormProvider = ({ children }: { children: React.ReactElement }) => {
index1: ['field1'],
index2: ['field1'],
},
[ChatFormFields.elasticsearchQuery]: {
retriever: {
rrf: {
retrievers: [
{ standard: { query: { multi_match: { query: '{query}', fields: ['field1'] } } } },
{ standard: { query: { multi_match: { query: '{query}', fields: ['field1'] } } } },
],
},
},
},
},
});
return <FormProvider {...methods}>{children}</FormProvider>;

View file

@ -40,7 +40,7 @@ const isQueryFieldSelected = (
export const QueryMode: React.FC = () => {
const { euiTheme } = useEuiTheme();
const usageTracker = useUsageTracker();
const { fields, isFieldsLoading } = useSourceIndicesFields();
const { fields } = useSourceIndicesFields();
const sourceFields = useWatch<ChatForm, ChatFormFields.sourceFields>({
name: ChatFormFields.sourceFields,
});
@ -49,10 +49,9 @@ export const QueryMode: React.FC = () => {
} = useController<ChatForm, ChatFormFields.queryFields>({
name: ChatFormFields.queryFields,
});
const {
field: { onChange: elasticsearchQueryChange },
} = useController({
field: { onChange: elasticsearchQueryChange, value: elasticsearchQuery },
} = useController<ChatForm, ChatFormFields.elasticsearchQuery>({
name: ChatFormFields.elasticsearchQuery,
});
@ -70,12 +69,7 @@ export const QueryMode: React.FC = () => {
useEffect(() => {
usageTracker?.load(AnalyticsEvents.queryModeLoaded);
}, [usageTracker]);
const query = useMemo(
() =>
!isFieldsLoading && JSON.stringify(createQuery(queryFields, sourceFields, fields), null, 2),
[isFieldsLoading, queryFields, sourceFields, fields]
);
const query = useMemo(() => JSON.stringify(elasticsearchQuery, null, 2), [elasticsearchQuery]);
return (
<EuiFlexGroup>
@ -158,6 +152,7 @@ export const QueryMode: React.FC = () => {
checked={checked}
onChange={(e) => updateFields(index, field.name, e.target.checked)}
compressed
data-test-subj={`field-${field.name}-${checked}`}
/>
);
},

View file

@ -41,7 +41,6 @@ describe('SelectIndicesFlyout', () => {
indices: ['index1', 'index2'],
setIndices: jest.fn(),
fields: {},
loading: false,
addIndex: () => {},
removeIndex: () => {},
isFieldsLoading: false,
@ -96,7 +95,6 @@ describe('SelectIndicesFlyout', () => {
indices: [],
setIndices: jest.fn(),
fields: {},
loading: false,
addIndex: () => {},
removeIndex: () => {},
isFieldsLoading: false,

View file

@ -13,13 +13,11 @@ import {
EuiLoadingSpinner,
EuiTitle,
} from '@elastic/eui';
import React, { useEffect, useMemo } from 'react';
import React, { useEffect } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { CreateIndexButton } from './create_index_button';
import { useQueryIndices } from '../../hooks/use_query_indices';
import { docLinks } from '../../../common/doc_links';
import { useSourceIndicesFields } from '../../hooks/use_source_indices_field';
import { useUsageTracker } from '../../hooks/use_usage_tracker';
import { AnalyticsEvents } from '../../analytics/constants';
import { ConnectLLMButton } from './connect_llm_button';
@ -27,16 +25,7 @@ import { AddDataSources } from './add_data_sources';
export const SetupPage: React.FC = () => {
const usageTracker = useUsageTracker();
const [searchParams] = useSearchParams();
const { indices, isLoading: isIndicesLoading } = useQueryIndices();
const index = useMemo(() => searchParams.get('default-index'), [searchParams]);
const { addIndex } = useSourceIndicesFields();
useEffect(() => {
if (index) {
addIndex(index);
}
}, [index, addIndex]);
useEffect(() => {
usageTracker?.load(AnalyticsEvents.setupChatPageLoaded);

View file

@ -9,9 +9,7 @@ import React from 'react';
import { dynamic } from '@kbn/shared-ux-utility';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { CoreStart } from '@kbn/core-lifecycle-browser';
import { QueryClientProvider } from '@tanstack/react-query';
import { AppPluginStartDependencies } from './types';
import { queryClient } from './utils/query_client';
import { AppProps } from './components/app';
export const Playground = dynamic<React.FC<AppProps>>(async () => ({
@ -31,8 +29,6 @@ export const getPlaygroundProvider =
(props: React.ComponentProps<typeof PlaygroundProvider>) =>
(
<KibanaContextProvider services={{ ...core, ...services }}>
<QueryClientProvider client={queryClient}>
<PlaygroundProvider {...props} />
</QueryClientProvider>
<PlaygroundProvider {...props} />
</KibanaContextProvider>
);

View file

@ -0,0 +1,123 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { renderHook } from '@testing-library/react-hooks';
import { useLoadFieldsByIndices } from './use_load_fields_by_indices';
import { useUsageTracker } from './use_usage_tracker';
import { useIndicesFields } from './use_indices_fields';
import { createQuery, getDefaultQueryFields, getDefaultSourceFields } from '../utils/create_query';
import { AnalyticsEvents } from '../analytics/constants';
import { ChatFormFields } from '../types';
// Mock dependencies
jest.mock('./use_usage_tracker');
jest.mock('./use_indices_fields');
jest.mock('../utils/create_query');
describe('useLoadFieldsByIndices', () => {
const mockSetValue = jest.fn();
const mockGetValues = jest.fn();
const mockWatch = jest.fn();
const mockUsageTracker = { count: jest.fn() };
beforeEach(() => {
jest.clearAllMocks();
(useUsageTracker as jest.Mock).mockReturnValue(mockUsageTracker);
(useIndicesFields as jest.Mock).mockReturnValue({ fields: {} });
(getDefaultQueryFields as jest.Mock).mockReturnValue({ newIndex: ['title', 'body'] });
(getDefaultSourceFields as jest.Mock).mockReturnValue({ testIndex: ['content'] });
(createQuery as jest.Mock).mockReturnValue('mocked query');
});
const setup = () => {
return renderHook(() =>
useLoadFieldsByIndices({
watch: mockWatch,
setValue: mockSetValue,
getValues: mockGetValues,
})
);
};
it('sets values and tracks usage on fields change', () => {
(useIndicesFields as jest.Mock).mockReturnValue({ fields: { newIndex: {}, testIndex: {} } });
mockGetValues.mockReturnValueOnce([{}, {}]);
mockWatch.mockReturnValue(['index1']);
setup();
expect(mockSetValue).toHaveBeenCalledWith(ChatFormFields.elasticsearchQuery, 'mocked query');
expect(mockSetValue).toHaveBeenCalledWith(ChatFormFields.queryFields, {
newIndex: ['title', 'body'],
});
expect(mockSetValue).toHaveBeenCalledWith(ChatFormFields.sourceFields, {
testIndex: ['content'],
});
expect(mockUsageTracker.count).toHaveBeenCalledWith(AnalyticsEvents.sourceFieldsLoaded, 2);
});
describe('merge fields', () => {
it('save changed fields', () => {
(getDefaultQueryFields as jest.Mock).mockReturnValue({ index: ['title', 'body'] });
(getDefaultSourceFields as jest.Mock).mockReturnValue({ index: ['title'] });
mockGetValues.mockReturnValueOnce([{ index: [] }, { index: ['body'] }]);
setup();
expect(mockSetValue).toHaveBeenNthCalledWith(2, ChatFormFields.queryFields, {
index: [],
});
expect(mockSetValue).toHaveBeenNthCalledWith(3, ChatFormFields.sourceFields, {
index: ['body'],
});
});
it('remove old indices from fields', () => {
(getDefaultQueryFields as jest.Mock).mockReturnValue({ index: ['title', 'body'] });
(getDefaultSourceFields as jest.Mock).mockReturnValue({ index: ['title'] });
mockGetValues.mockReturnValueOnce([
{ index: [], oldIndex: ['title'] },
{ index: ['body'], oldIndex: ['title'] },
]);
setup();
expect(mockSetValue).toHaveBeenNthCalledWith(2, ChatFormFields.queryFields, {
index: [],
});
expect(mockSetValue).toHaveBeenNthCalledWith(3, ChatFormFields.sourceFields, {
index: ['body'],
});
});
it('add new indices to fields', () => {
(getDefaultQueryFields as jest.Mock).mockReturnValue({
index: ['title', 'body'],
newIndex: ['content'],
});
(getDefaultSourceFields as jest.Mock).mockReturnValue({
index: ['title'],
newIndex: ['content'],
});
mockGetValues.mockReturnValueOnce([
{ index: [], oldIndex: ['title'] },
{ index: ['body'], oldIndex: ['title'] },
]);
setup();
expect(mockSetValue).toHaveBeenNthCalledWith(2, ChatFormFields.queryFields, {
index: [],
newIndex: ['content'],
});
expect(mockSetValue).toHaveBeenNthCalledWith(3, ChatFormFields.sourceFields, {
index: ['body'],
newIndex: ['content'],
});
});
});
});

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect } from 'react';
import { UseFormReturn } from 'react-hook-form/dist/types';
import { useUsageTracker } from './use_usage_tracker';
import { ChatForm, ChatFormFields } from '../types';
import { useIndicesFields } from './use_indices_fields';
import {
createQuery,
getDefaultQueryFields,
getDefaultSourceFields,
IndexFields,
} from '../utils/create_query';
import { AnalyticsEvents } from '../analytics/constants';
const mergeDefaultAndCurrentValues = (
defaultFields: IndexFields,
currentFields: IndexFields
): IndexFields =>
Object.keys(defaultFields).reduce<IndexFields>((result, key) => {
result[key] = currentFields?.[key] ?? defaultFields[key];
return result;
}, {});
export const useLoadFieldsByIndices = ({
watch,
setValue,
getValues,
}: Pick<UseFormReturn<ChatForm>, 'watch' | 'getValues' | 'setValue'>) => {
const usageTracker = useUsageTracker();
const selectedIndices = watch(ChatFormFields.indices);
const { fields } = useIndicesFields(selectedIndices);
useEffect(() => {
const [queryFields, sourceFields] = getValues([
ChatFormFields.queryFields,
ChatFormFields.sourceFields,
]);
const defaultFields = getDefaultQueryFields(fields);
const defaultSourceFields = getDefaultSourceFields(fields);
const mergedQueryFields = mergeDefaultAndCurrentValues(defaultFields, queryFields);
const mergedSourceFields = mergeDefaultAndCurrentValues(defaultSourceFields, sourceFields);
setValue(
ChatFormFields.elasticsearchQuery,
createQuery(mergedQueryFields, mergedSourceFields, fields)
);
setValue(ChatFormFields.queryFields, mergedQueryFields);
setValue(ChatFormFields.sourceFields, mergedSourceFields);
usageTracker?.count(AnalyticsEvents.sourceFieldsLoaded, Object.values(fields)?.flat()?.length);
}, [fields, getValues, setValue, usageTracker]);
};

View file

@ -7,86 +7,24 @@
import { useController } from 'react-hook-form';
import { IndexName } from '@elastic/elasticsearch/lib/api/types';
import { useCallback, useEffect, useState } from 'react';
import { merge } from 'lodash';
import { useCallback } from 'react';
import { useIndicesFields } from './use_indices_fields';
import { ChatForm, ChatFormFields } from '../types';
import {
createQuery,
getDefaultQueryFields,
getDefaultSourceFields,
IndexFields,
} from '../utils/create_query';
import { ChatFormFields } from '../types';
import { useUsageTracker } from './use_usage_tracker';
import { AnalyticsEvents } from '../analytics/constants';
export const getIndicesWithNoSourceFields = (
defaultSourceFields: IndexFields
): string | undefined => {
const indices: string[] = [];
Object.keys(defaultSourceFields).forEach((index: string) => {
if (defaultSourceFields[index].length === 0) {
indices.push(index);
}
});
return indices.length === 0 ? undefined : indices.join();
};
export const useSourceIndicesFields = () => {
const usageTracker = useUsageTracker();
const [loading, setLoading] = useState<boolean>(false);
const {
field: { value: selectedIndices, onChange: onIndicesChange },
} = useController({
name: ChatFormFields.indices,
});
const {
field: { onChange: onElasticsearchQueryChange },
} = useController({
name: ChatFormFields.elasticsearchQuery,
defaultValue: {},
});
const {
field: { onChange: onQueryFieldsOnChange, value: queryFields },
} = useController<ChatForm, ChatFormFields.queryFields>({
name: ChatFormFields.queryFields,
});
const {
field: { onChange: onSourceFieldsChange, value: sourceFields },
} = useController({
name: ChatFormFields.sourceFields,
});
const { fields, isLoading: isFieldsLoading } = useIndicesFields(selectedIndices);
useEffect(() => {
if (fields) {
const defaultFields = getDefaultQueryFields(fields);
const defaultSourceFields = getDefaultSourceFields(fields);
const mergedQueryFields = merge(defaultFields, queryFields);
const mergedSourceFields = merge(defaultSourceFields, sourceFields);
onElasticsearchQueryChange(createQuery(mergedQueryFields, mergedSourceFields, fields));
onQueryFieldsOnChange(mergedQueryFields);
onSourceFieldsChange(mergedSourceFields);
usageTracker?.count(
AnalyticsEvents.sourceFieldsLoaded,
Object.values(fields)?.flat()?.length
);
}
setLoading(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fields]);
const addIndex = useCallback(
(newIndex: IndexName) => {
const newIndices = [...selectedIndices, newIndex];
setLoading(true);
onIndicesChange(newIndices);
usageTracker?.count(AnalyticsEvents.sourceIndexUpdated, newIndices.length);
},
@ -96,7 +34,6 @@ export const useSourceIndicesFields = () => {
const removeIndex = useCallback(
(index: IndexName) => {
const newIndices = selectedIndices.filter((indexName: string) => indexName !== index);
setLoading(true);
onIndicesChange(newIndices);
usageTracker?.count(AnalyticsEvents.sourceIndexUpdated, newIndices.length);
},
@ -105,7 +42,6 @@ export const useSourceIndicesFields = () => {
const setIndices = useCallback(
(indices: IndexName[]) => {
setLoading(true);
onIndicesChange(indices);
usageTracker?.count(AnalyticsEvents.sourceIndexUpdated, indices.length);
},
@ -115,7 +51,6 @@ export const useSourceIndicesFields = () => {
return {
indices: selectedIndices,
fields,
loading,
isFieldsLoading,
addIndex,
removeIndex,

View file

@ -14,14 +14,16 @@ import * as ReactHookForm from 'react-hook-form';
jest.mock('./use_kibana', () => ({
useKibana: jest.fn(),
}));
jest.mock('react-router-dom-v5-compat', () => ({
useSearchParams: jest.fn(() => [{ get: jest.fn() }]),
}));
let formHookSpy: jest.SpyInstance;
import { getIndicesWithNoSourceFields, useSourceIndicesFields } from './use_source_indices_field';
import { useSourceIndicesFields } from './use_source_indices_field';
import { IndicesQuerySourceFields } from '../types';
// FLAKY: https://github.com/elastic/kibana/issues/181102
describe.skip('useSourceIndicesFields Hook', () => {
describe('useSourceIndicesFields Hook', () => {
let postMock: jest.Mock;
const wrapper = ({ children }: { children: React.ReactNode }) => (
@ -53,6 +55,9 @@ describe.skip('useSourceIndicesFields Hook', () => {
services: {
http: {
post: postMock,
get: jest.fn(() => {
return [];
}),
},
},
}));
@ -62,25 +67,7 @@ describe.skip('useSourceIndicesFields Hook', () => {
jest.clearAllMocks();
});
describe('getIndicesWithNoSourceFields', () => {
it('should return undefined if all indices have source fields', () => {
const defaultSourceFields = {
index1: ['field1'],
index2: ['field2'],
};
expect(getIndicesWithNoSourceFields(defaultSourceFields)).toBeUndefined();
});
it('should return indices with no source fields', () => {
const defaultSourceFields = {
index1: ['field1'],
index2: [],
};
expect(getIndicesWithNoSourceFields(defaultSourceFields)).toBe('index2');
});
});
it('should handle addIndex correctly changing indices and updating loading state', async () => {
it('should handle addIndex correctly changing indices', async () => {
const { result, waitForNextUpdate } = renderHook(() => useSourceIndicesFields(), { wrapper });
const { getValues } = formHookSpy.mock.results[0].value;
@ -89,10 +76,20 @@ describe.skip('useSourceIndicesFields Hook', () => {
expect(getValues()).toMatchInlineSnapshot(`
Object {
"doc_size": 3,
"elasticsearch_query": Object {},
"elasticsearch_query": Object {
"retriever": Object {
"standard": Object {
"query": Object {
"match_all": Object {},
},
},
},
},
"indices": Array [],
"prompt": "You are an assistant for question-answering tasks.",
"query_fields": Object {},
"source_fields": Object {},
"summarization_model": undefined,
}
`);
result.current.addIndex('newIndex');
@ -101,13 +98,11 @@ describe.skip('useSourceIndicesFields Hook', () => {
await act(async () => {
await waitForNextUpdate();
expect(result.current.indices).toEqual(['newIndex']);
expect(result.current.loading).toBe(true);
});
expect(postMock).toHaveBeenCalled();
await act(async () => {
expect(result.current.loading).toBe(false);
expect(getValues()).toMatchInlineSnapshot(`
Object {
"doc_size": 3,
@ -128,115 +123,17 @@ describe.skip('useSourceIndicesFields Hook', () => {
"newIndex",
],
"prompt": "You are an assistant for question-answering tasks.",
"query_fields": Object {
"newIndex": Array [
"field1",
],
},
"source_fields": Object {
"newIndex": Array [
"field1",
],
},
}
`);
});
});
it('should provide warning message for adding an index without any fields', async () => {
const querySourceFields: IndicesQuerySourceFields = {
missing_fields_index: {
elser_query_fields: [],
dense_vector_query_fields: [],
bm25_query_fields: [],
source_fields: [],
skipped_fields: 0,
semantic_fields: [],
},
};
postMock.mockResolvedValue(querySourceFields);
const { result, waitForNextUpdate } = renderHook(() => useSourceIndicesFields(), { wrapper });
const { getValues } = formHookSpy.mock.results[0].value;
await act(async () => {
result.current.addIndex('missing_fields_index');
});
await act(async () => {
await waitForNextUpdate();
});
expect(postMock).toHaveBeenCalled();
await act(async () => {
expect(result.current.loading).toBe(false);
expect(getValues()).toMatchInlineSnapshot(`
Object {
"doc_size": 3,
"elasticsearch_query": Object {
"retriever": Object {
"standard": Object {
"query": Object {
"match_all": Object {},
},
},
},
},
"indices": Array [
"missing_fields_index",
],
"prompt": "You are an assistant for question-answering tasks.",
"source_fields": Object {
"missing_fields_index": Array [],
},
}
`);
});
});
it('should not provide any warning message for adding and then removing an index without any fields', async () => {
const querySourceFields: IndicesQuerySourceFields = {
missing_fields_index: {
elser_query_fields: [],
dense_vector_query_fields: [],
bm25_query_fields: [],
source_fields: [],
skipped_fields: 0,
semantic_fields: [],
},
};
postMock.mockResolvedValue(querySourceFields);
const { result } = renderHook(() => useSourceIndicesFields(), { wrapper });
const { getValues } = formHookSpy.mock.results[0].value;
await act(async () => {
result.current.addIndex('missing_fields_index');
});
await act(async () => {
result.current.removeIndex('missing_fields_index');
});
expect(postMock).toHaveBeenCalled();
await act(async () => {
expect(result.current.loading).toBe(false);
expect(getValues()).toMatchInlineSnapshot(`
Object {
"doc_size": 3,
"elasticsearch_query": Object {
"retriever": Object {
"standard": Object {
"query": Object {
"match_all": Object {},
},
},
},
},
"indices": Array [],
"prompt": "You are an assistant for question-answering tasks.",
"source_fields": Object {
"missing_fields_index": Array [],
},
"summarization_model": undefined,
}
`);
});

View file

@ -0,0 +1,101 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { FormProvider } from './form_provider';
import { useLoadFieldsByIndices } from '../hooks/use_load_fields_by_indices';
import { useLLMsModels } from '../hooks/use_llms_models';
import * as ReactHookForm from 'react-hook-form';
import { ChatFormFields } from '../types';
jest.mock('../hooks/use_load_fields_by_indices');
jest.mock('../hooks/use_llms_models');
jest.mock('react-router-dom-v5-compat', () => ({
useSearchParams: jest.fn(() => [{ get: jest.fn() }]),
}));
let formHookSpy: jest.SpyInstance;
const mockUseLoadFieldsByIndices = useLoadFieldsByIndices as jest.Mock;
const mockUseLLMsModels = useLLMsModels as jest.Mock;
describe('FormProvider', () => {
beforeEach(() => {
formHookSpy = jest.spyOn(ReactHookForm, 'useForm');
mockUseLLMsModels.mockReturnValue([]);
mockUseLoadFieldsByIndices.mockImplementation(() => {});
});
afterEach(() => {
jest.clearAllMocks();
});
it('renders the form provider with initial values, no default model', async () => {
render(
<FormProvider>
<div>Test Child Component</div>
</FormProvider>
);
const { getValues } = formHookSpy.mock.results[0].value;
await waitFor(() => {
expect(getValues()).toEqual({
doc_size: 3,
indices: [],
prompt: 'You are an assistant for question-answering tasks.',
source_fields: {},
summarization_model: undefined,
});
});
});
it('sets the default summarization model with models available', async () => {
const mockModels = [
{ id: 'model1', name: 'Model 1', disabled: false },
{ id: 'model2', name: 'Model 2', disabled: true },
];
mockUseLLMsModels.mockReturnValueOnce(mockModels);
render(
<FormProvider>
<div>Test Child Component</div>
</FormProvider>
);
await waitFor(() => {
expect(mockUseLoadFieldsByIndices).toHaveBeenCalled();
const defaultModel = mockModels.find((model) => !model.disabled);
const { getValues } = formHookSpy.mock.results[0].value;
expect(getValues(ChatFormFields.summarizationModel)).toEqual(defaultModel);
});
});
it('does not set a disabled model as the default summarization model', async () => {
const modelsWithAllDisabled = [
{ id: 'model1', name: 'Model 1', disabled: true },
{ id: 'model2', name: 'Model 2', disabled: true },
];
mockUseLLMsModels.mockReturnValueOnce(modelsWithAllDisabled);
render(
<FormProvider>
<div>Test Child Component</div>
</FormProvider>
);
await waitFor(() => {
expect(mockUseLoadFieldsByIndices).toHaveBeenCalled();
expect(modelsWithAllDisabled.find((model) => !model.disabled)).toBeUndefined();
});
});
});

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FormProvider as ReactHookFormProvider, useForm } from 'react-hook-form';
import React, { useEffect, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { useLoadFieldsByIndices } from '../hooks/use_load_fields_by_indices';
import { ChatForm, ChatFormFields } from '../types';
import { useLLMsModels } from '../hooks/use_llms_models';
export const FormProvider: React.FC = ({ children }) => {
const models = useLLMsModels();
const [searchParams] = useSearchParams();
const index = useMemo(() => searchParams.get('default-index'), [searchParams]);
const form = useForm<ChatForm>({
defaultValues: {
prompt: 'You are an assistant for question-answering tasks.',
doc_size: 3,
source_fields: {},
indices: index ? [index] : [],
summarization_model: undefined,
},
});
useLoadFieldsByIndices({ watch: form.watch, setValue: form.setValue, getValues: form.getValues });
useEffect(() => {
const defaultModel = models.find((model) => !model.disabled);
if (defaultModel && !form.getValues(ChatFormFields.summarizationModel)) {
form.setValue(ChatFormFields.summarizationModel, defaultModel);
}
}, [form, models]);
return <ReactHookFormProvider {...form}>{children}</ReactHookFormProvider>;
};

View file

@ -5,30 +5,15 @@
* 2.0.
*/
import React, { FC, useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useLLMsModels } from '../hooks/use_llms_models';
import { ChatForm, ChatFormFields } from '../types';
import React, { FC } from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from '../utils/query_client';
import { FormProvider } from './form_provider';
export const PlaygroundProvider: FC = ({ children }) => {
const models = useLLMsModels();
const form = useForm<ChatForm>({
defaultValues: {
prompt: 'You are an assistant for question-answering tasks.',
doc_size: 3,
source_fields: {},
indices: [],
summarization_model: {},
},
});
useEffect(() => {
const defaultModel = models.find((model) => !model.disabled);
if (defaultModel) {
form.setValue(ChatFormFields.summarizationModel, defaultModel);
}
}, [form, models]);
return <FormProvider {...form}>{children}</FormProvider>;
return (
<QueryClientProvider client={queryClient}>
<FormProvider>{children}</FormProvider>
</QueryClientProvider>
);
};

View file

@ -9,7 +9,6 @@ import {
HealthStatus,
IndexName,
IndicesStatsIndexMetadataState,
QueryDslQueryContainer,
Uuid,
} from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public';
@ -74,7 +73,7 @@ export interface ChatForm {
[ChatFormFields.citations]: boolean;
[ChatFormFields.indices]: string[];
[ChatFormFields.summarizationModel]: LLMModel;
[ChatFormFields.elasticsearchQuery]: { query: QueryDslQueryContainer };
[ChatFormFields.elasticsearchQuery]: { retriever: unknown }; // RetrieverContainer leads to "Type instantiation is excessively deep and possibly infinite" error
[ChatFormFields.sourceFields]: { [index: string]: string[] };
[ChatFormFields.docSize]: number;
[ChatFormFields.queryFields]: { [index: string]: string[] };

View file

@ -6,7 +6,12 @@
*/
import { IndicesQuerySourceFields } from '../types';
import { createQuery, getDefaultQueryFields, getDefaultSourceFields } from './create_query';
import {
createQuery,
getDefaultQueryFields,
getDefaultSourceFields,
getIndicesWithNoSourceFields,
} from './create_query';
describe('create_query', () => {
const sourceFields = { index1: [], index2: [] };
@ -965,4 +970,28 @@ describe('create_query', () => {
});
});
});
describe('getIndicesWithNoSourceFields', () => {
it('should return undefined if all indices have source fields', () => {
const fieldDescriptors: IndicesQuerySourceFields = {
empty_index: {
elser_query_fields: [],
dense_vector_query_fields: [],
bm25_query_fields: [],
source_fields: [],
skipped_fields: 0,
semantic_fields: [],
},
non_empty_index: {
elser_query_fields: [],
dense_vector_query_fields: [],
bm25_query_fields: ['field2'],
source_fields: ['field1'],
skipped_fields: 0,
semantic_fields: [],
},
};
expect(getIndicesWithNoSourceFields(fieldDescriptors)).toBe('empty_index');
});
});
});

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { RetrieverContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { IndicesQuerySourceFields, QuerySourceFields } from '../types';
export type IndexFields = Record<string, string[]>;
@ -51,7 +52,7 @@ export function createQuery(
rerankOptions: ReRankOptions = {
rrf: true,
}
) {
): { retriever: RetrieverContainer } {
const indices = Object.keys(fieldDescriptors);
const boolMatches = Object.keys(fields).reduce<Matches>(
(acc, index) => {

View file

@ -62,10 +62,10 @@ describe('fetch_query_source_fields', () => {
).toEqual({
workplace_index: {
bm25_query_fields: [
'metadata.summary',
'metadata.rolePermissions',
'text',
'metadata.name',
'metadata.rolePermissions',
'metadata.summary',
'text',
],
dense_vector_query_fields: [],
elser_query_fields: [
@ -77,16 +77,16 @@ describe('fetch_query_source_fields', () => {
},
],
skipped_fields: 8,
source_fields: ['metadata.summary', 'metadata.rolePermissions', 'text', 'metadata.name'],
source_fields: ['metadata.name', 'metadata.rolePermissions', 'metadata.summary', 'text'],
semantic_fields: [],
},
workplace_index2: {
semantic_fields: [],
bm25_query_fields: [
'metadata.summary',
'content',
'metadata.rolePermissions',
'metadata.name',
'metadata.rolePermissions',
'metadata.summary',
],
dense_vector_query_fields: [],
skipped_fields: 8,
@ -99,10 +99,10 @@ describe('fetch_query_source_fields', () => {
},
],
source_fields: [
'metadata.summary',
'content',
'metadata.rolePermissions',
'metadata.name',
'metadata.rolePermissions',
'metadata.summary',
],
},
});
@ -125,19 +125,19 @@ describe('fetch_query_source_fields', () => {
'search-example-main': {
semantic_fields: [],
bm25_query_fields: [
'page_content_key',
'title',
'main_button.button_title',
'page_notification',
'bread_crumbs',
'url',
'page_content_text',
'buttons.button_title',
'filter_list',
'buttons.button_link',
'buttons.button_new_tab',
'title_text',
'buttons.button_title',
'filter_list',
'main_button.button_link',
'main_button.button_title',
'page_content_key',
'page_content_text',
'page_notification',
'title',
'title_text',
'url',
],
dense_vector_query_fields: [
{
@ -149,19 +149,19 @@ describe('fetch_query_source_fields', () => {
elser_query_fields: [],
skipped_fields: 30,
source_fields: [
'page_content_key',
'title',
'main_button.button_title',
'page_notification',
'bread_crumbs',
'url',
'page_content_text',
'buttons.button_title',
'filter_list',
'buttons.button_link',
'buttons.button_new_tab',
'title_text',
'buttons.button_title',
'filter_list',
'main_button.button_link',
'main_button.button_title',
'page_content_key',
'page_content_text',
'page_notification',
'title',
'title_text',
'url',
],
},
});
@ -216,27 +216,27 @@ describe('fetch_query_source_fields', () => {
).toEqual({
workplace_index_nested: {
bm25_query_fields: [
'metadata.category',
'content',
'metadata.url',
'metadata.rolePermissions',
'metadata.name',
'passages.text',
'metadata.summary',
'metadata.category',
'metadata.content',
'metadata.name',
'metadata.rolePermissions',
'metadata.summary',
'metadata.url',
'passages.text',
],
dense_vector_query_fields: [],
elser_query_fields: [],
semantic_fields: [],
source_fields: [
'metadata.category',
'content',
'metadata.url',
'metadata.rolePermissions',
'metadata.name',
'passages.text',
'metadata.summary',
'metadata.category',
'metadata.content',
'metadata.name',
'metadata.rolePermissions',
'metadata.summary',
'metadata.url',
'passages.text',
],
skipped_fields: 18,
},

View file

@ -164,11 +164,20 @@ const isFieldInIndex = (
);
};
const sortFields = (fields: FieldCapsResponse['fields']): FieldCapsResponse['fields'] => {
const entries = Object.entries(fields);
entries.sort((a, b) => a[0].localeCompare(b[0]));
return Object.fromEntries(entries);
};
export const parseFieldsCapabilities = (
fieldCapsResponse: FieldCapsResponse,
aggMappingDocs: Array<{ index: string; doc: SearchResponse; mapping: IndicesGetMappingResponse }>
): IndicesQuerySourceFields => {
const { fields, indices: indexOrIndices } = fieldCapsResponse;
const { indices: indexOrIndices } = fieldCapsResponse;
const fields = sortFields(fieldCapsResponse.fields);
const indices = Array.isArray(indexOrIndices) ? indexOrIndices : [indexOrIndices];
const indexModelIdFields = aggMappingDocs.map<IndexFieldModel>((aggDoc) => {

View file

@ -170,6 +170,10 @@ export default function (ftrContext: FtrProviderContext) {
it('show edit context', async () => {
await pageObjects.searchPlayground.PlaygroundChatPage.expectEditContextOpens();
});
it('save selected fields between modes', async () => {
await pageObjects.searchPlayground.PlaygroundChatPage.expectSaveFieldsBetweenModes();
});
});
after(async () => {

View file

@ -146,6 +146,8 @@ export function SearchPlaygroundPageProvider({ getService }: FtrProviderContext)
expect(code.replace(/ /g, '')).to.be(
'{\n"retriever":{\n"standard":{\n"query":{\n"multi_match":{\n"query":"{query}",\n"fields":[\n"baz"\n]\n}\n}\n}\n}\n}'
);
await testSubjects.click('chatMode');
},
async expectEditContextOpens() {
@ -157,6 +159,16 @@ export function SearchPlaygroundPageProvider({ getService }: FtrProviderContext)
expect(fields.length).to.be(1);
},
async expectSaveFieldsBetweenModes() {
await testSubjects.click('queryMode');
await testSubjects.existOrFail('field-baz-true');
await testSubjects.click('field-baz-true');
await testSubjects.existOrFail('field-baz-false');
await testSubjects.click('chatMode');
await testSubjects.click('queryMode');
await testSubjects.existOrFail('field-baz-false');
},
},
};
}

View file

@ -176,6 +176,10 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
it('show edit context', async () => {
await pageObjects.searchPlayground.PlaygroundChatPage.expectEditContextOpens();
});
it('save selected fields between modes', async () => {
await pageObjects.searchPlayground.PlaygroundChatPage.expectSaveFieldsBetweenModes();
});
});
after(async () => {