[Security Assistant] Adds new Knowledge Base Management Settings UI (#192665)

## Summary

This PR updates the Knowledge Base Management Settings page to use the
new `entries` API introduced in
https://github.com/elastic/kibana/pull/186566. Many thanks to @angorayc
for her work on the Assistant Management Settings overhaul, and initial
implementation of this new KB Management UI over in
https://github.com/elastic/kibana/pull/186847.

<p align="center">
<img width="600"
src="https://github.com/user-attachments/assets/0a82587e-f33c-45f1-9165-1a676d6db5fa"
/>
</p> 



### Feature Flag & Setup
The changes in this PR, as with the other [recent V2 KB
enhancements](https://github.com/elastic/kibana/pull/186566), are behind
the following feature flag:
```
xpack.securitySolution.enableExperimental:
  - 'assistantKnowledgeBaseByDefault'
```

~They also require a code change in the `AIAssistantService` to enable
the new mapping (since setup happens on plugin start before FF
registration), so be sure to update `fieldMap` to
`knowledgeBaseFieldMapV2` below before testing:~

This is no longer the case as of
[cdec104](cdec10402f).
Just changing the above feature flag is now sufficient, just note that
if upgrading and the KB was previously setup, you'll need to manually
delete the data stream (`DELETE
/_data_stream/.kibana-elastic-ai-assistant-knowledge-base-default`) or
the management table will be littered with the old ESQL docs instead of
being a single aggregate entry.

Once configured, the new Knowledge Base Management Settings will become
available in Stack Management. The old settings UI is currently still
available via the Settings Modal, but will soon be removed and replaced
with links to the new interface via the Assistant Settings Context Menu
(replacing the existing `cog`). Please see the designs ([Security
GenAI](https://www.figma.com/design/BMvpY9EhcPIaoOS7LSrkL0/%5B8.15%2C-%5D-GenAI-Security-Settings?node-id=51-25207&node-type=canvas&t=t3vZSPhMxQhScJVt-0)
/ [Unified AI
Assistant](https://www.figma.com/design/xN20zMRNtMlirWB6n9n1xJ/Unified-AI-Assistant-Settings?node-id=0-1&node-type=canvas&t=3RDYE7h2DjLlFlcN-0))
for all changes.

> [!IMPORTANT]
> There are no migrations in place between the legacy and v2 KB
mappings, so be sure to start with a clean ES data directory.

### Testing

To aid with developing the UI, I took the opportunity to start fleshing
out the KB Entries API integration tests. These live in
[x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries](7ae6be136a/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries),
and are currently configured to only run on `@ess`, as running
`tiny_elser` in serverless and MKI environments can be tricky (more on
that later).

To start the server and run the tests, from the
`x-pack/test/security_solution_api_integration/` directory run `yarn
genai_kb_entries:server:ess`, and once started, `yarn
genai_kb_entries🏃ess`.

##### Changes in support of testing

In order to setup the API integration tests for use with the Knowledge
Base, some functional changes needed to be made to the assistant/config:

1. Since ELSER is a heavy model to run in CI, the ML folks have created
`pt_tiny_elser` for use in testing. Unfortunately, the `getELSER()`
helper off the `ml` client that we use to get the `modelld` for
installing ELSER, ingest pipelines, etc, cannot be overridden
([#193633](https://github.com/elastic/kibana/issues/193633)), so we must
have some other means of doing that. So to get things working in the
test env, I've plumbed through an optional `modelId` override to the
POST knowledge base route (`/ internal/ elastic_assistant/
knowledge_base/{resource?}?modelId=pt_tiny_elser`). This then overrides
the aiAssistantService `getELSER()` function [when
fetching](645b3b863b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts (L334-L354))
a `kbDataClient` using the request, which appears to be the only way to
also trigger a reinitialization of the ingest pipeline (which required
the `modelId`), since that usually only occurs on plugin start. If there
is a cleaner way to perform this reinitialization, please let me know!

2. Turns out
[`getService('ml').importTrainedModel()`](f18224c686/x-pack/test/functional/services/ml/api.ts (L1575-L1587))
can't be run in test env's with `ssl:true`, which is the default
security config. You can read more about that issue in
[#193477](https://github.com/elastic/kibana/issues/193477), but the
current workaround is to turn off `ssl` for this specific test
configuration, so that's why
[`ess.config.ts`](cf73d4c7fc/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/trial_license_complete_tier/configs/ess.config.ts (L22))
looks a little different. If there's a better way to manage this config,
also please let me know!

##### Additional notes

We don't currently have a `securityAssistant` API client/service to use
in integration tests, so I've just been creating one-off functions using
`supertest` for now. I don't have the bandwidth to work this now, but
perhaps @MadameSheema / @muskangulati-qasource could lend a hand here? I
did need to test multi-user and multi-space scenarios, so I ported over
the same [auth
helpers](dc26f1012f/x-pack/test/security_solution_api_integration/test_suites/genai/knowledge_base/entries/utils/auth)
I saw used in other suites. Would be nice if these were bundled into the
client as well ala how the o11y folks have done it
[here](e9f23aa98e/x-pack/test/observability_ai_assistant_api_integration/tests/knowledge_base/knowledge_base.spec.ts (L27-L34)).
Perhaps this is also on the list of things for @maximpn to generate from
OAS's.... 🙃

### RBAC
In plumbing the UI, I've tried to place `// TODO: KB-RBAC` tags in all
the places I came across that will require an RBAC check/change. This
includes some of the API integration tests, which I currently have
skipped as they would fail without RBAC.

### Other notable changes

* There are now dedicated `legacy` and `v2` helper functions when
managing persistence/retrieval of knowledge base entries. This should
help with tearing out the old KB later, and better readability now.
* I've tried to remove dependency on the `ElasticsearchStore` as much as
possible. The store's only use should now be within tools as a retriever
[here](de89153368/x-pack/plugins/elastic_assistant/server/routes/helpers.ts (L397-L405)),
and in post_evaluate
[here](de89153368/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts (L170-L179)).
If we adopt the new
[`naturalLanguageToESQL`](https://github.com/elastic/kibana/pull/192042)
tool in `8.16` (or update our existing ESQL tool to use the
`kbDataClient` for retrieval), we should be able to get rid of this
entirely.
* Added a
[`spaces_roles_users_data.http`](7447394fe3/x-pack/packages/kbn-elastic-assistant-common/impl/utils/spaces_roles_users_data.http (L1))
file for adding spaces, roles, users, and a sample `slackbot` index for
use with [sample `IndexEntries`
here](7447394fe3/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/knowledge_base/entries/crud_knowledge_base_entries_route.http (L18-L56)).

### // TODO
In effort to make incremental progress and facilitate early knowledge
share with @patrykkopycinski, I'm capping this PR where it's at, and so
here are the remaining items to complete full integration of the new
Knowledge Base Management Settings interface:

- [ ] Support `Update` action
- [ ] Move from `EuiInMemoryTable` 
- [ ] Finalize `Setup` UI
- [ ] Cleanup `Save` loaders
- [ ] Plumb through `{{knowledge_history}}` prompt template and include
use's `required` entries

All this work is behind the aforementioned feature flag and required
code change, and this changeset has also been manually upgrade tested to
ensure there are no issues that would impact the regularly scheduled
serverless releases. This is more of a note to reviewers when testing
that full functionality is not present.




### Checklist

- [X] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
* Feature currently behind feature flag. Documentation to be added
before flag is removed. Tracked in
https://github.com/elastic/security-docs/issues/5337
- [X] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Garrett Spong 2024-09-25 14:38:18 -06:00 committed by GitHub
parent 9f499ec67d
commit 63730ea0c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
90 changed files with 3529 additions and 769 deletions

View file

@ -4,11 +4,22 @@
"port": "5601",
"basePath": "",
"elasticApiVersion": "1",
"elasticsearch": {
"host": "localhost",
"port": "9200"
},
"auth": {
"username": "elastic",
"password": "changeme"
"admin": {
"username": "elastic",
"password": "changeme"
},
"assistant_all": {
"username": "assistant_all",
"password": "changeme"
}
},
"appContext": {
"management": "%7B%22type%22%3A%22application%22%2C%22name%22%3A%22management%22%2C%22url%22%3A%22%2Fkbn%2Fapp%2Fmanagement%22%2C%22page%22%3A%22%22%7D",
"security": "%7B%22type%22%3A%22application%22%2C%22name%22%3A%22securitySolutionUI%22%2C%22url%22%3A%22%2Fkbn%2Fapp%2Fsecurity%22%7D"
}
}

View file

@ -27,6 +27,15 @@ export const KnowledgeBaseResponse = z.object({
success: z.boolean().optional(),
});
export type CreateKnowledgeBaseRequestQuery = z.infer<typeof CreateKnowledgeBaseRequestQuery>;
export const CreateKnowledgeBaseRequestQuery = z.object({
/**
* Optional ELSER modelId to use when setting up the Knowledge Base
*/
modelId: z.string().optional(),
});
export type CreateKnowledgeBaseRequestQueryInput = z.input<typeof CreateKnowledgeBaseRequestQuery>;
export type CreateKnowledgeBaseRequestParams = z.infer<typeof CreateKnowledgeBaseRequestParams>;
export const CreateKnowledgeBaseRequestParams = z.object({
/**

View file

@ -18,6 +18,12 @@ paths:
description: The KnowledgeBase `resource` value.
schema:
type: string
- name: modelId
in: query
description: Optional ELSER modelId to use when setting up the Knowledge Base
required: false
schema:
type: string
responses:
200:
description: Indicates a successful call.

View file

@ -3,7 +3,7 @@ POST http://{{host}}:{{port}}{{basePath}}/internal/elastic_assistant/knowledge_b
kbn-xsrf: "true"
Content-Type: application/json
Elastic-Api-Version: {{elasticApiVersion}}
Authorization: Basic {{auth.username}} {{auth.password}}
Authorization: Basic {{auth.admin.username}} {{auth.admin.password}}
X-Kbn-Context: {{appContext.security}}
{

View file

@ -1,45 +1,151 @@
### Create Document Entry
### Create Document Entry [Admin] [Private]
POST http://{{host}}:{{port}}{{basePath}}/internal/elastic_assistant/knowledge_base/entries
kbn-xsrf: "true"
Content-Type: application/json
Elastic-Api-Version: {{elasticApiVersion}}
Authorization: Basic {{auth.username}} {{auth.password}}
Authorization: Basic {{auth.admin.username}} {{auth.admin.password}}
X-Kbn-Context: {{appContext.security}}
{
"type": "document",
"name": "Favorites",
"name": "Document Entry [Admin] [Private]",
"kbResource": "user",
"source": "api",
"required": true,
"text": "My favorite food is Dan Bing"
}
### Create Index Entry
### Create Document Entry [Admin] [Global]
POST http://{{host}}:{{port}}{{basePath}}/internal/elastic_assistant/knowledge_base/entries
kbn-xsrf: "true"
Content-Type: application/json
Elastic-Api-Version: {{elasticApiVersion}}
Authorization: Basic {{auth.username}} {{auth.password}}
Authorization: Basic {{auth.admin.username}} {{auth.admin.password}}
X-Kbn-Context: {{appContext.security}}
{
"type": "document",
"name": "Document Entry [Admin] [Global]",
"kbResource": "user",
"source": "api",
"required": true,
"text": "My favorite food is pizza",
"users": []
}
### Create Document Entry [Assistant All] [Private]
POST http://{{host}}:{{port}}{{basePath}}/internal/elastic_assistant/knowledge_base/entries
kbn-xsrf: "true"
Content-Type: application/json
Elastic-Api-Version: {{elasticApiVersion}}
Authorization: Basic {{auth.assistant_all.username}} {{auth.assistant_all.password}}
X-Kbn-Context: {{appContext.security}}
{
"type": "document",
"name": "Document Entry [Assistant All] [Private]",
"kbResource": "user",
"source": "api",
"required": true,
"text": "My favorite food is popcorn"
}
### Create Document Entry [Assistant All] [Global]
POST http://{{host}}:{{port}}{{basePath}}/internal/elastic_assistant/knowledge_base/entries
kbn-xsrf: "true"
Content-Type: application/json
Elastic-Api-Version: {{elasticApiVersion}}
Authorization: Basic {{auth.assistant_all.username}} {{auth.assistant_all.password}}
X-Kbn-Context: {{appContext.security}}
{
"type": "document",
"name": "Document Entry [Assistant All] [Global]",
"kbResource": "user",
"source": "api",
"required": true,
"text": "My favorite food is peaches",
"users": []
}
### Create Index Entry [Admin] [Private]
POST http://{{host}}:{{port}}{{basePath}}/internal/elastic_assistant/knowledge_base/entries
kbn-xsrf: "true"
Content-Type: application/json
Elastic-Api-Version: {{elasticApiVersion}}
Authorization: Basic {{auth.admin.username}} {{auth.admin.password}}
X-Kbn-Context: {{appContext.security}}
{
"type": "index",
"name": "SpongBotSlackConnector",
"name": "Slackbot-test Index Entry [Admin] [Private]",
"namespace": "default",
"index": "spongbot-slack",
"index": "slackbot-test",
"field": "semantic_text",
"description": "Use this index to search for the user's Slack messages.",
"queryDescription":
"The free text search that the user wants to perform over this dataset. So if asking \"what are my slack messages from last week about failed tests\", the query would be \"A test has failed! failing test failed test\"",
"outputFields": ["author", "text", "timestamp"]
}
### Create Index Entry [Admin] [Global]
POST http://{{host}}:{{port}}{{basePath}}/internal/elastic_assistant/knowledge_base/entries
kbn-xsrf: "true"
Content-Type: application/json
Elastic-Api-Version: {{elasticApiVersion}}
Authorization: Basic {{auth.admin.username}} {{auth.admin.password}}
X-Kbn-Context: {{appContext.security}}
{
"type": "index",
"name": "Slackbot-test Index Entry [Admin] [Global]",
"namespace": "default",
"index": "slackbot-test",
"field": "semantic_text",
"description": "Use this index to search for the user's Slack messages.",
"queryDescription":
"The free text search that the user wants to perform over this dataset. So if asking \"what are my slack messages from last week about failed tests\", the query would be \"A test has failed! failing test failed test\"",
"inputSchema": [
{
"fieldName": "author",
"fieldType": "string",
"description": "The author of the message. So if asking for recent messages from Stan, you would provide 'Stan' as the author."
}
],
"outputFields": ["author", "text", "timestamp"]
"outputFields": ["author", "text", "timestamp"],
"users": []
}
### Create Index Entry [Assistant All] [Private]
POST http://{{host}}:{{port}}{{basePath}}/internal/elastic_assistant/knowledge_base/entries
kbn-xsrf: "true"
Content-Type: application/json
Elastic-Api-Version: {{elasticApiVersion}}
Authorization: Basic {{auth.assistant_all.username}} {{auth.assistant_all.password}}
X-Kbn-Context: {{appContext.security}}
{
"type": "index",
"name": "Slackbot-test Index Entry [Assistant All] [Private]",
"namespace": "default",
"index": "slackbot-test",
"field": "semantic_text",
"description": "Use this index to search for the user's Slack messages.",
"queryDescription": "The free text search that the user wants to perform over this dataset. So if asking \"what are my slack messages from last week about failed tests\", the query would be \"A test has failed! failing test failed test\"",
"outputFields": ["author", "text", "timestamp" ]
}
### Create Index Entry [Assistant All] [Global]
POST http://{{host}}:{{port}}{{basePath}}/internal/elastic_assistant/knowledge_base/entries
kbn-xsrf: "true"
Content-Type: application/json
Elastic-Api-Version: {{elasticApiVersion}}
Authorization: Basic {{auth.assistant_all.username}} {{auth.assistant_all.password}}
X-Kbn-Context: {{appContext.security}}
{
"type": "index",
"name": "Slackbot-test Index Entry [Assistant All] [Global]",
"namespace": "default",
"index": "slackbot-test",
"field": "semantic_text",
"description": "Use this index to search for the user's Slack messages.",
"queryDescription": "The free text search that the user wants to perform over this dataset. So if asking \"what are my slack messages from last week about failed tests\", the query would be \"A test has failed! failing test failed test\"",
"outputFields": ["author", "text", "timestamp" ],
"users": []
}

View file

@ -1,6 +1,6 @@
### Find all knowledge base entries
GET http://{{host}}:{{port}}{{basePath}}/internal/elastic_assistant/knowledge_base/entries/_find
Elastic-Api-Version: {{elasticApiVersion}}
Authorization: Basic {{auth.username}} {{auth.password}}
Authorization: Basic {{auth.admin.username}} {{auth.admin.password}}
X-Kbn-Context: {{appContext.security}}

View file

@ -16,12 +16,4 @@ export const indexEntryMock: IndexEntryCreateFields = {
description: "Use this index to search for the user's Slack messages.",
queryDescription:
'The free text search that the user wants to perform over this dataset. So if asking "what are my slack messages from last week about failed tests", the query would be "A test has failed! failing test failed test".',
inputSchema: [
{
fieldName: 'author',
fieldType: 'string',
description:
"The author of the message. So if asking for recent messages from Stan, you would provide 'Stan' as the author.",
},
],
};

View file

@ -0,0 +1,182 @@
### Create Space-X
POST http://{{host}}:{{port}}{{basePath}}/api/spaces/space
kbn-xsrf: "true"
Content-Type: application/json
Elastic-Api-Version: {{elasticApiVersion}}
Authorization: Basic {{auth.admin.username}} {{auth.admin.password}}
{
"name": "Space-X",
"id": "space-x",
"initials": "🚀",
"color": "#9170B8",
"disabledFeatures": [],
"imageUrl": ""
}
### Create Space-Y
POST http://{{host}}:{{port}}{{basePath}}/api/spaces/space
kbn-xsrf: "true"
Content-Type: application/json
Elastic-Api-Version: {{elasticApiVersion}}
Authorization: Basic {{auth.admin.username}} {{auth.admin.password}}
{
"name": "Space-Y",
"id": "space-y",
"initials": "🛰",
"color": "#DA8B45",
"disabledFeatures": [],
"imageUrl": ""
}
### Create Assistant All Role - All Spaces, All Features
PUT http://{{host}}:{{port}}{{basePath}}/api/security/role/assistant_all
kbn-xsrf: "true"
Content-Type: application/json
Elastic-Api-Version: {{elasticApiVersion}}
Authorization: Basic {{auth.admin.username}} {{auth.admin.password}}
{
"description": "Grants access to all Security Assistant features in all spaces",
"elasticsearch": {
"cluster": [
"all"
],
"indices": [
{
"names": [
"*"
],
"privileges": [
"all"
],
"field_security": {
"grant": [
"*"
],
"except": []
}
}
],
"run_as": []
},
"kibana": [
{
"spaces": [
"*"
],
"base": [],
"feature": {
"siem": [
"all"
],
"securitySolutionCases": [
"all"
],
"securitySolutionAssistant": [
"all"
],
"securitySolutionAttackDiscovery": [
"all"
],
"aiAssistantManagementSelection": [
"all"
],
"searchInferenceEndpoints": [
"all"
],
"dev_tools": [
"all"
],
"actions": [
"all"
],
"indexPatterns": [
"all"
]
}
}
]
}
### Create Assistant All User - All Spaces, All Features
POST http://{{host}}:{{port}}{{basePath}}/internal/security/users/assistant_all
kbn-xsrf: "true"
Content-Type: application/json
Elastic-Api-Version: {{elasticApiVersion}}
Authorization: Basic {{auth.admin.username}} {{auth.admin.password}}
{
"password": "{{auth.assistant_all.password}}",
"username": "{{auth.assistant_all.username}}",
"full_name": "Assistant All",
"email": "",
"roles": [
"assistant_all"
]
}
### Create Inference Endpoint
PUT http://{{elasticsearch.host}}:{{elasticsearch.port}}/_inference/sparse_embedding/elser_model_2
Content-Type: application/json
Authorization: Basic {{auth.admin.username}} {{auth.admin.password}}
{
"service": "elser",
"service_settings": {
"num_allocations": 1,
"num_threads": 1
}
}
### Create Slackbot Mappings
PUT http://{{elasticsearch.host}}:{{elasticsearch.port}}/slackbot-test
Content-Type: application/json
Authorization: Basic {{auth.admin.username}} {{auth.admin.password}}
{
"settings": {
"number_of_shards": 1
},
"mappings": {
"dynamic": "true",
"properties": {
"semantic_text": {
"type": "semantic_text",
"inference_id": "elser_model_2",
"model_settings": {
"task_type": "sparse_embedding"
}
},
"text": {
"type": "text",
"copy_to": [
"semantic_text"
]
}
}
}
}
### Create Slackbot Document
POST http://{{elasticsearch.host}}:{{elasticsearch.port}}/slackbot-test/_doc
Content-Type: application/json
Authorization: Basic {{auth.admin.username}} {{auth.admin.password}}
{
"subtype": null,
"author": "spong",
"edited_ts": null,
"thread_ts": "1727113718.664029",
"channel": "dev-details",
"text": "The Dude: That rug really tied the room together.",
"id": "C0A6H3AA1BL-1727115800.120029",
"type": "message",
"reply_count": null,
"ts": "1727115800.120029",
"latest_reply": null
}

View file

@ -5,19 +5,12 @@
* 2.0.
*/
import {
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiRange,
EuiSpacer,
EuiText,
useGeneratedHtmlId,
} from '@elastic/eui';
import { EuiFlexGroup, EuiFormRow, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui';
import { css } from '@emotion/react';
import React from 'react';
import { KnowledgeBaseConfig } from '../../assistant/types';
import { AlertsRange } from '../../knowledge_base/alerts_range';
import * as i18n from '../../knowledge_base/translations';
export const MIN_LATEST_ALERTS = 10;
@ -32,8 +25,6 @@ interface Props {
}
const AlertsSettingsComponent = ({ knowledgeBase, setUpdatedKnowledgeBaseSettings }: Props) => {
const inputRangeSliderId = useGeneratedHtmlId({ prefix: 'inputRangeSlider' });
return (
<>
<EuiFormRow
@ -58,22 +49,9 @@ const AlertsSettingsComponent = ({ knowledgeBase, setUpdatedKnowledgeBaseSetting
grow={false}
>
<EuiSpacer size="xs" />
<EuiRange
aria-label={i18n.ALERTS_RANGE}
compressed
data-test-subj="alertsRange"
id={inputRangeSliderId}
max={MAX_LATEST_ALERTS}
min={MIN_LATEST_ALERTS}
onChange={(e) =>
setUpdatedKnowledgeBaseSettings({
...knowledgeBase,
latestAlerts: Number(e.currentTarget.value),
})
}
showTicks
step={TICK_INTERVAL}
value={knowledgeBase.latestAlerts}
<AlertsRange
knowledgeBase={knowledgeBase}
setUpdatedKnowledgeBaseSettings={setUpdatedKnowledgeBaseSettings}
/>
<EuiSpacer size="s" />
</EuiFlexItem>

View file

@ -0,0 +1,44 @@
/*
* 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 { EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui';
import React from 'react';
import { KnowledgeBaseConfig } from '../../assistant/types';
import { AlertsRange } from '../../knowledge_base/alerts_range';
import * as i18n from '../../knowledge_base/translations';
interface Props {
knowledgeBase: KnowledgeBaseConfig;
setUpdatedKnowledgeBaseSettings: React.Dispatch<React.SetStateAction<KnowledgeBaseConfig>>;
}
export const AlertsSettingsManagement: React.FC<Props> = React.memo(
({ knowledgeBase, setUpdatedKnowledgeBaseSettings }) => {
return (
<EuiPanel hasShadow={false} hasBorder paddingSize="l" title={i18n.ALERTS_LABEL}>
<EuiTitle size="m">
<h3>{i18n.ALERTS_LABEL}</h3>
</EuiTitle>
<EuiSpacer size="m" />
<EuiText size="m">
<span>
{i18n.LATEST_AND_RISKIEST_OPEN_ALERTS(knowledgeBase.latestAlerts)}
{i18n.YOUR_ANONYMIZATION_SETTINGS}
</span>
</EuiText>
<EuiSpacer size="l" />
<AlertsRange
knowledgeBase={knowledgeBase}
setUpdatedKnowledgeBaseSettings={setUpdatedKnowledgeBaseSettings}
compressed={false}
/>
</EuiPanel>
);
}
);
AlertsSettingsManagement.displayName = 'AlertsSettingsManagement';

View file

@ -78,6 +78,13 @@ export const useCreateKnowledgeBaseEntry = ({
onSettled: () => {
invalidateKnowledgeBaseEntries();
},
onSuccess: () => {
toasts?.addSuccess({
title: i18n.translate('xpack.elasticAssistant.knowledgeBase.entries.createSuccessTitle', {
defaultMessage: 'Knowledge Base Entry created',
}),
});
},
}
);
};

View file

@ -5,8 +5,9 @@
* 2.0.
*/
import { HttpSetup } from '@kbn/core/public';
import { HttpSetup, type IHttpFetchError, type ResponseErrorBody } from '@kbn/core/public';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { IToasts } from '@kbn/core-notifications-browser';
import {
API_VERSIONS,
ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_FIND,
@ -15,11 +16,14 @@ import {
} from '@kbn/elastic-assistant-common';
import { useCallback } from 'react';
import { i18n } from '@kbn/i18n';
export interface UseKnowledgeBaseEntriesParams {
http: HttpSetup;
query: FindKnowledgeBaseEntriesRequestQuery;
query?: FindKnowledgeBaseEntriesRequestQuery;
signal?: AbortSignal | undefined;
toasts?: IToasts;
enabled?: boolean; // For disabling if FF is off
}
const defaultQuery: FindKnowledgeBaseEntriesRequestQuery = {
@ -50,6 +54,8 @@ export const useKnowledgeBaseEntries = ({
http,
query = defaultQuery,
signal,
toasts,
enabled = false,
}: UseKnowledgeBaseEntriesParams) =>
useQuery(
KNOWLEDGE_BASE_ENTRY_QUERY_KEY,
@ -64,8 +70,18 @@ export const useKnowledgeBaseEntries = ({
}
),
{
enabled,
keepPreviousData: true,
initialData: { page: 1, perPage: 100, total: 0, data: [] },
onError: (error: IHttpFetchError<ResponseErrorBody>) => {
if (error.name !== 'AbortError') {
toasts?.addError(error, {
title: i18n.translate('xpack.elasticAssistant.knowledgeBase.fetchError', {
defaultMessage: 'Error fetching Knowledge Base entries',
}),
});
}
},
}
);

View file

@ -0,0 +1,94 @@
/*
* 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 { useMutation } from '@tanstack/react-query';
import type { HttpSetup, IHttpFetchError, ResponseErrorBody } from '@kbn/core-http-browser';
import type { IToasts } from '@kbn/core-notifications-browser';
import { i18n } from '@kbn/i18n';
import {
API_VERSIONS,
ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_BULK_ACTION,
KnowledgeBaseEntryBulkCrudActionResponse,
PerformKnowledgeBaseEntryBulkActionRequestBody,
} from '@kbn/elastic-assistant-common';
import { useInvalidateKnowledgeBaseEntries } from './use_knowledge_base_entries';
const BULK_UPDATE_KNOWLEDGE_BASE_ENTRY_MUTATION_KEY = [
ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_BULK_ACTION,
API_VERSIONS.internal.v1,
'UPDATE',
];
export interface UseUpdateKnowledgeBaseEntriesParams {
http: HttpSetup;
signal?: AbortSignal;
toasts?: IToasts;
}
/**
* Hook for updating Knowledge Base Entries by id or query.
*
* @param {Object} options - The options object
* @param {HttpSetup} options.http - HttpSetup
* @param {AbortSignal} [options.signal] - AbortSignal
* @param {IToasts} [options.toasts] - IToasts
*
* @returns mutation hook for updating Knowledge Base Entries
*
*/
export const useUpdateKnowledgeBaseEntries = ({
http,
signal,
toasts,
}: UseUpdateKnowledgeBaseEntriesParams) => {
const invalidateKnowledgeBaseEntries = useInvalidateKnowledgeBaseEntries();
return useMutation(
BULK_UPDATE_KNOWLEDGE_BASE_ENTRY_MUTATION_KEY,
(updatedEntries: PerformKnowledgeBaseEntryBulkActionRequestBody['update']) => {
const body: PerformKnowledgeBaseEntryBulkActionRequestBody = {
update: updatedEntries,
};
return http.post<KnowledgeBaseEntryBulkCrudActionResponse>(
ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_BULK_ACTION,
{
body: JSON.stringify(body),
version: API_VERSIONS.internal.v1,
signal,
}
);
},
{
onError: (error: IHttpFetchError<ResponseErrorBody>) => {
if (error.name !== 'AbortError') {
toasts?.addError(
error.body && error.body.message ? new Error(error.body.message) : error,
{
title: i18n.translate(
'xpack.elasticAssistant.knowledgeBase.entries.updateErrorTitle',
{
defaultMessage: 'Error updating Knowledge Base Entries',
}
),
}
);
}
},
onSettled: () => {
invalidateKnowledgeBaseEntries();
},
onSuccess: () => {
toasts?.addSuccess({
title: i18n.translate('xpack.elasticAssistant.knowledgeBase.entries.updateSuccessTitle', {
defaultMessage: 'Knowledge Base Entries updated successfully',
}),
});
},
}
);
};

View file

@ -78,3 +78,20 @@ export const useInvalidateKnowledgeBaseStatus = () => {
});
}, [queryClient]);
};
/**
* Helper for determining if Knowledge Base setup is complete.
*
* Note: Consider moving to API
*
* @param kbStatus ReadKnowledgeBaseResponse
*/
export const isKnowledgeBaseSetup = (kbStatus: ReadKnowledgeBaseResponse | undefined): boolean => {
return (
(kbStatus?.elser_exists &&
kbStatus?.esql_exists &&
kbStatus?.index_exists &&
kbStatus?.pipeline_exists) ??
false
);
};

View file

@ -20,6 +20,7 @@ jest.mock('./api', () => {
};
});
jest.mock('./use_knowledge_base_status');
jest.mock('./entries/use_knowledge_base_entries');
jest.mock('@tanstack/react-query', () => ({
useMutation: jest.fn().mockImplementation(async (queryKey, fn, opts) => {

View file

@ -11,6 +11,7 @@ import type { IToasts } from '@kbn/core-notifications-browser';
import { i18n } from '@kbn/i18n';
import { postKnowledgeBase } from './api';
import { useInvalidateKnowledgeBaseStatus } from './use_knowledge_base_status';
import { useInvalidateKnowledgeBaseEntries } from './entries/use_knowledge_base_entries';
const SETUP_KNOWLEDGE_BASE_MUTATION_KEY = ['elastic-assistant', 'post-knowledge-base'];
@ -31,6 +32,7 @@ export interface UseSetupKnowledgeBaseParams {
*/
export const useSetupKnowledgeBase = ({ http, toasts }: UseSetupKnowledgeBaseParams) => {
const invalidateKnowledgeBaseStatus = useInvalidateKnowledgeBaseStatus();
const invalidateKnowledgeBaseEntries = useInvalidateKnowledgeBaseEntries();
return useMutation(
SETUP_KNOWLEDGE_BASE_MUTATION_KEY,
@ -53,6 +55,7 @@ export const useSetupKnowledgeBase = ({ http, toasts }: UseSetupKnowledgeBasePar
},
onSettled: () => {
invalidateKnowledgeBaseStatus();
invalidateKnowledgeBaseEntries();
},
}
);

View file

@ -8,16 +8,19 @@
import { EuiBadge } from '@elastic/eui';
import React from 'react';
export const BadgesColumn: React.FC<{ items: string[] | null | undefined; prefix: string }> =
React.memo(({ items, prefix }) =>
items && items.length > 0 ? (
<div>
{items.map((c, idx) => (
<EuiBadge key={`${prefix}-${idx}`} color="hollow">
{c}
</EuiBadge>
))}
</div>
) : null
);
export const BadgesColumn: React.FC<{
items: string[] | null | undefined;
prefix: string;
color?: string;
}> = React.memo(({ items, prefix, color = 'hollow' }) =>
items && items.length > 0 ? (
<div>
{items.map((c, idx) => (
<EuiBadge key={`${prefix}-${idx}`} color={color}>
{c}
</EuiBadge>
))}
</div>
) : null
);
BadgesColumn.displayName = 'BadgesColumn';

View file

@ -22,7 +22,7 @@ import * as i18n from './translations';
interface Props {
children: React.ReactNode;
title: string;
title?: string;
flyoutVisible: boolean;
onClose: () => void;
onSaveCancelled: () => void;

View file

@ -10,51 +10,60 @@ import { useCallback } from 'react';
import * as i18n from './translations';
interface Props<T> {
disabled?: boolean;
isEditEnabled?: (rowItem: T) => boolean;
isDeleteEnabled?: (rowItem: T) => boolean;
onDelete?: (rowItem: T) => void;
onEdit?: (rowItem: T) => void;
}
export const useInlineActions = <T extends { isDefault?: boolean | undefined }>() => {
const getInlineActions = useCallback(({ disabled = false, onDelete, onEdit }: Props<T>) => {
const handleEdit = (rowItem: T) => {
onEdit?.(rowItem);
};
const getInlineActions = useCallback(
({
isEditEnabled = () => false,
isDeleteEnabled = () => false,
onDelete,
onEdit,
}: Props<T>) => {
const handleEdit = (rowItem: T) => {
onEdit?.(rowItem);
};
const handleDelete = (rowItem: T) => {
onDelete?.(rowItem);
};
const handleDelete = (rowItem: T) => {
onDelete?.(rowItem);
};
const actions: EuiTableActionsColumnType<T> = {
name: i18n.ACTIONS_BUTTON,
actions: [
{
name: i18n.EDIT_BUTTON,
description: i18n.EDIT_BUTTON,
icon: 'pencil',
type: 'icon',
onClick: (rowItem: T) => {
handleEdit(rowItem);
const actions: EuiTableActionsColumnType<T> = {
name: i18n.ACTIONS_BUTTON,
actions: [
{
name: i18n.EDIT_BUTTON,
description: i18n.EDIT_BUTTON,
icon: 'pencil',
type: 'icon',
onClick: (rowItem: T) => {
handleEdit(rowItem);
},
enabled: isEditEnabled,
available: () => onEdit != null,
},
enabled: () => !disabled,
available: () => onEdit != null,
},
{
name: i18n.DELETE_BUTTON,
description: i18n.DELETE_BUTTON,
icon: 'trash',
type: 'icon',
onClick: (rowItem: T) => {
handleDelete(rowItem);
{
name: i18n.DELETE_BUTTON,
description: i18n.DELETE_BUTTON,
icon: 'trash',
type: 'icon',
onClick: (rowItem: T) => {
handleDelete(rowItem);
},
enabled: isDeleteEnabled,
available: () => onDelete != null,
color: 'danger',
},
enabled: ({ isDefault }: { isDefault?: boolean }) => !isDefault && !disabled,
available: () => onDelete != null,
color: 'danger',
},
],
};
return actions;
}, []);
],
};
return actions;
},
[]
);
return getInlineActions;
};

View file

@ -260,6 +260,8 @@ const ConversationSettingsManagementComponent: React.FC<Props> = ({
const columns = useMemo(
() =>
getColumns({
isDeleteEnabled: (rowItem: ConversationTableItem) => rowItem.isDefault !== true,
isEditEnabled: () => true,
onDeleteActionClicked,
onEditActionClicked,
}),

View file

@ -34,6 +34,8 @@ describe('useConversationsTable', () => {
it('should return columns', () => {
const { result } = renderHook(() => useConversationsTable());
const columns = result.current.getColumns({
isDeleteEnabled: jest.fn(),
isEditEnabled: jest.fn(),
onDeleteActionClicked: jest.fn(),
onEditActionClicked: jest.fn(),
});

View file

@ -38,9 +38,13 @@ export const useConversationsTable = () => {
const getActions = useInlineActions<ConversationTableItem>();
const getColumns = useCallback(
({
isDeleteEnabled,
isEditEnabled,
onDeleteActionClicked,
onEditActionClicked,
}: {
isDeleteEnabled: (conversation: ConversationTableItem) => boolean;
isEditEnabled: (conversation: ConversationTableItem) => boolean;
onDeleteActionClicked: (conversation: ConversationTableItem) => void;
onEditActionClicked: (conversation: ConversationTableItem) => void;
}): Array<EuiBasicTableColumn<ConversationTableItem>> => {
@ -91,6 +95,8 @@ export const useConversationsTable = () => {
width: '120px',
align: 'center',
...getActions({
isDeleteEnabled,
isEditEnabled,
onDelete: onDeleteActionClicked,
onEdit: onEditActionClicked,
}),

View file

@ -90,9 +90,12 @@ const AssistantComponent: React.FC<Props> = ({
getLastConversationId,
http,
promptContexts,
setCurrentUserAvatar,
setLastConversationId,
} = useAssistantContext();
setCurrentUserAvatar(currentUserAvatar);
const [selectedPromptContexts, setSelectedPromptContexts] = useState<
Record<string, SelectedPromptContext>
>({});

View file

@ -204,7 +204,13 @@ const SystemPromptSettingsManagementComponent = ({ connectors, defaultConnector
const columns = useMemo(
() =>
getColumns({ isActionsDisabled: isTableLoading, onEditActionClicked, onDeleteActionClicked }),
getColumns({
isActionsDisabled: isTableLoading,
onEditActionClicked,
onDeleteActionClicked,
isDeleteEnabled: (prompt: PromptResponse) => prompt.isDefault !== true,
isEditEnabled: () => true,
}),
[getColumns, isTableLoading, onEditActionClicked, onDeleteActionClicked]
);
const systemPromptListItems = useMemo(

View file

@ -66,6 +66,8 @@ describe('useSystemPromptTable', () => {
const onDeleteActionClicked = jest.fn();
const columns = result.current.getColumns({
isActionsDisabled: false,
isDeleteEnabled: jest.fn(),
isEditEnabled: jest.fn(),
onEditActionClicked,
onDeleteActionClicked,
});

View file

@ -25,10 +25,14 @@ export const useSystemPromptTable = () => {
const getColumns = useCallback(
({
isActionsDisabled,
isDeleteEnabled,
isEditEnabled,
onEditActionClicked,
onDeleteActionClicked,
}: {
isActionsDisabled: boolean;
isDeleteEnabled: (conversation: SystemPromptTableItem) => boolean;
isEditEnabled: (conversation: SystemPromptTableItem) => boolean;
onEditActionClicked: (prompt: SystemPromptTableItem) => void;
onDeleteActionClicked: (prompt: SystemPromptTableItem) => void;
}): Array<EuiBasicTableColumn<SystemPromptTableItem>> => [
@ -79,6 +83,8 @@ export const useSystemPromptTable = () => {
align: 'center',
width: '120px',
...getActions({
isDeleteEnabled,
isEditEnabled,
onDelete: onDeleteActionClicked,
onEdit: onEditActionClicked,
}),

View file

@ -148,6 +148,8 @@ const QuickPromptSettingsManagementComponent = () => {
basePromptContexts,
onEditActionClicked,
onDeleteActionClicked,
isDeleteEnabled: (prompt: PromptResponse) => prompt.isDefault !== true,
isEditEnabled: () => true,
});
const { onTableChange, pagination, sorting } = useSessionPagination({

View file

@ -12,6 +12,7 @@ import { MOCK_QUICK_PROMPTS } from '../../../mock/quick_prompt';
import { mockPromptContexts } from '../../../mock/prompt_context';
import { PromptResponse } from '@kbn/elastic-assistant-common';
const mockIsEditEnabled = jest.fn();
const mockOnEditActionClicked = jest.fn();
const mockOnDeleteActionClicked = jest.fn();
@ -20,6 +21,8 @@ describe('useQuickPromptTable', () => {
const props = {
isActionsDisabled: false,
basePromptContexts: mockPromptContexts,
isDeleteEnabled: (prompt: PromptResponse) => prompt.isDefault !== true,
isEditEnabled: mockIsEditEnabled,
onEditActionClicked: mockOnEditActionClicked,
onDeleteActionClicked: mockOnDeleteActionClicked,
};

View file

@ -19,11 +19,15 @@ export const useQuickPromptTable = () => {
const getColumns = useCallback(
({
isActionsDisabled,
isDeleteEnabled,
isEditEnabled,
basePromptContexts,
onEditActionClicked,
onDeleteActionClicked,
}: {
isActionsDisabled: boolean;
isDeleteEnabled: (prompt: PromptResponse) => boolean;
isEditEnabled: (prompt: PromptResponse) => boolean;
basePromptContexts: PromptContextTemplate[];
onEditActionClicked: (prompt: PromptResponse, color?: string) => void;
onDeleteActionClicked: (prompt: PromptResponse) => void;
@ -74,6 +78,8 @@ export const useQuickPromptTable = () => {
align: 'center',
width: '120px',
...getActions({
isDeleteEnabled,
isEditEnabled,
onDelete: onDeleteActionClicked,
onEdit: onEditActionClicked,
}),

View file

@ -96,6 +96,7 @@ export interface UseAssistantContext {
docLinks: Omit<DocLinksStart, 'links'>;
basePath: string;
baseConversations: Record<string, Conversation>;
currentUserAvatar?: UserAvatar;
getComments: GetAssistantMessages;
http: HttpSetup;
knowledgeBase: KnowledgeBaseConfig;
@ -106,6 +107,7 @@ export interface UseAssistantContext {
registerPromptContext: RegisterPromptContext;
selectedSettingsTab: SettingsTabs | null;
setAssistantStreamingEnabled: React.Dispatch<React.SetStateAction<boolean | undefined>>;
setCurrentUserAvatar: React.Dispatch<React.SetStateAction<UserAvatar | undefined>>;
setKnowledgeBase: React.Dispatch<React.SetStateAction<KnowledgeBaseConfig | undefined>>;
setLastConversationId: React.Dispatch<React.SetStateAction<string | undefined>>;
setSelectedSettingsTab: React.Dispatch<React.SetStateAction<SettingsTabs | null>>;
@ -218,6 +220,11 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
*/
const [showAssistantOverlay, setShowAssistantOverlay] = useState<ShowAssistantOverlay>(() => {});
/**
* Current User Avatar
*/
const [currentUserAvatar, setCurrentUserAvatar] = useState<UserAvatar>();
/**
* Settings State
*/
@ -250,6 +257,7 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
augmentMessageCodeBlocks,
basePath,
basePromptContexts,
currentUserAvatar,
docLinks,
getComments,
http,
@ -263,6 +271,7 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
assistantStreamingEnabled: localStorageStreaming ?? true,
setAssistantStreamingEnabled: setLocalStorageStreaming,
setKnowledgeBase: setLocalStorageKnowledgeBase,
setCurrentUserAvatar,
setSelectedSettingsTab,
setShowAssistantOverlay,
setTraceOptions: setSessionStorageTraceOptions,
@ -286,6 +295,7 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
augmentMessageCodeBlocks,
basePath,
basePromptContexts,
currentUserAvatar,
docLinks,
getComments,
http,

View file

@ -0,0 +1,60 @@
/*
* 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 { EuiRange, useGeneratedHtmlId } from '@elastic/eui';
import { css } from '@emotion/react';
import React from 'react';
import {
MAX_LATEST_ALERTS,
MIN_LATEST_ALERTS,
TICK_INTERVAL,
} from '../alerts/settings/alerts_settings';
import { KnowledgeBaseConfig } from '../assistant/types';
import { ALERTS_RANGE } from './translations';
interface Props {
knowledgeBase: KnowledgeBaseConfig;
setUpdatedKnowledgeBaseSettings: React.Dispatch<React.SetStateAction<KnowledgeBaseConfig>>;
compressed?: boolean;
}
const MAX_ALERTS_RANGE_WIDTH = 649; // px
export const AlertsRange: React.FC<Props> = React.memo(
({ knowledgeBase, setUpdatedKnowledgeBaseSettings, compressed = true }) => {
const inputRangeSliderId = useGeneratedHtmlId({ prefix: 'inputRangeSlider' });
return (
<EuiRange
aria-label={ALERTS_RANGE}
compressed={compressed}
data-test-subj="alertsRange"
id={inputRangeSliderId}
max={MAX_LATEST_ALERTS}
min={MIN_LATEST_ALERTS}
onChange={(e) =>
setUpdatedKnowledgeBaseSettings({
...knowledgeBase,
latestAlerts: Number(e.currentTarget.value),
})
}
showTicks
step={TICK_INTERVAL}
value={knowledgeBase.latestAlerts}
css={css`
max-inline-size: ${MAX_ALERTS_RANGE_WIDTH}px;
& .euiRangeTrack {
margin-inline-start: 0;
margin-inline-end: 0;
}
`}
/>
);
}
);
AlertsRange.displayName = 'AlertsRange';

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export const ESQL_RESOURCE = 'esql';
export const KNOWLEDGE_BASE_INDEX_PATTERN_OLD = '.kibana-elastic-ai-assistant-kb';
export const KNOWLEDGE_BASE_INDEX_PATTERN = '.kibana-elastic-ai-assistant-knowledge-base-(SPACE)';

View file

@ -1,272 +0,0 @@
/*
* 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, { useCallback, useMemo, useState } from 'react';
import {
EuiFormRow,
EuiText,
EuiHorizontalRule,
EuiSpacer,
EuiLink,
EuiFlexGroup,
EuiFlexItem,
EuiHealth,
EuiButtonEmpty,
EuiPanel,
EuiToolTip,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { css } from '@emotion/react';
import { AlertsSettings } from '../alerts/settings/alerts_settings';
import { useAssistantContext } from '../assistant_context';
import * as i18n from './translations';
import { useKnowledgeBaseStatus } from '../assistant/api/knowledge_base/use_knowledge_base_status';
import { useSetupKnowledgeBase } from '../assistant/api/knowledge_base/use_setup_knowledge_base';
import {
useSettingsUpdater,
DEFAULT_CONVERSATIONS,
DEFAULT_PROMPTS,
} from '../assistant/settings/use_settings_updater/use_settings_updater';
import { AssistantSettingsBottomBar } from '../assistant/settings/assistant_settings_bottom_bar';
import { SETTINGS_UPDATED_TOAST_TITLE } from '../assistant/settings/translations';
import { SETUP_KNOWLEDGE_BASE_BUTTON_TOOLTIP } from './translations';
const ESQL_RESOURCE = 'esql';
const KNOWLEDGE_BASE_INDEX_PATTERN = '.kibana-elastic-ai-assistant-knowledge-base-(SPACE)';
/**
* Knowledge Base Settings -- set up the Knowledge Base and configure RAG on alerts
*/
export const KnowledgeBaseSettingsManagement: React.FC = React.memo(() => {
const { http, toasts } = useAssistantContext();
const [hasPendingChanges, setHasPendingChanges] = useState(false);
const { knowledgeBase, setUpdatedKnowledgeBaseSettings, resetSettings, saveSettings } =
useSettingsUpdater(
DEFAULT_CONVERSATIONS, // Knowledge Base settings do not require conversations
DEFAULT_PROMPTS, // Knowledge Base settings do not require prompts
false, // Knowledge Base settings do not require prompts
false // Knowledge Base settings do not require conversations
);
const handleSave = useCallback(
async (param?: { callback?: () => void }) => {
await saveSettings();
toasts?.addSuccess({
iconType: 'check',
title: SETTINGS_UPDATED_TOAST_TITLE,
});
setHasPendingChanges(false);
param?.callback?.();
},
[saveSettings, toasts]
);
const handleUpdateKnowledgeBaseSettings = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(updatedKnowledgebase: any) => {
setHasPendingChanges(true);
setUpdatedKnowledgeBaseSettings(updatedKnowledgebase);
},
[setUpdatedKnowledgeBaseSettings]
);
const onCancelClick = useCallback(() => {
resetSettings();
setHasPendingChanges(false);
}, [resetSettings]);
const onSaveButtonClicked = useCallback(() => {
handleSave();
}, [handleSave]);
const {
data: kbStatus,
isLoading,
isFetching,
} = useKnowledgeBaseStatus({ http, resource: ESQL_RESOURCE });
const { mutate: setupKB, isLoading: isSettingUpKB } = useSetupKnowledgeBase({ http, toasts });
// Resource enabled state
const isElserEnabled = kbStatus?.elser_exists ?? false;
const isESQLEnabled = kbStatus?.esql_exists ?? false;
const isKnowledgeBaseSetup =
(isElserEnabled && isESQLEnabled && kbStatus?.index_exists && kbStatus?.pipeline_exists) ??
false;
const isSetupInProgress = kbStatus?.is_setup_in_progress ?? false;
const isSetupAvailable = kbStatus?.is_setup_available ?? false;
// Resource availability state
const isLoadingKb = isLoading || isFetching || isSettingUpKB || isSetupInProgress;
// Calculated health state for EuiHealth component
const elserHealth = isElserEnabled ? 'success' : 'subdued';
const knowledgeBaseHealth = isKnowledgeBaseSetup ? 'success' : 'subdued';
const esqlHealth = isESQLEnabled ? 'success' : 'subdued';
//////////////////////////////////////////////////////////////////////////////////////////
// Main `Knowledge Base` setup button
const onSetupKnowledgeBaseButtonClick = useCallback(() => {
setupKB(ESQL_RESOURCE);
}, [setupKB]);
const toolTipContent = !isSetupAvailable ? SETUP_KNOWLEDGE_BASE_BUTTON_TOOLTIP : undefined;
const setupKnowledgeBaseButton = useMemo(() => {
return isKnowledgeBaseSetup ? (
<></>
) : (
<EuiToolTip position={'bottom'} content={toolTipContent}>
<EuiButtonEmpty
color={'primary'}
data-test-subj={'setupKnowledgeBaseButton'}
disabled={!isSetupAvailable}
onClick={onSetupKnowledgeBaseButtonClick}
size="xs"
isLoading={isLoadingKb}
>
{i18n.SETUP_KNOWLEDGE_BASE_BUTTON}
</EuiButtonEmpty>
</EuiToolTip>
);
}, [
isKnowledgeBaseSetup,
isLoadingKb,
isSetupAvailable,
onSetupKnowledgeBaseButtonClick,
toolTipContent,
]);
//////////////////////////////////////////////////////////////////////////////////////////
// Knowledge Base Resource
const knowledgeBaseDescription = useMemo(() => {
return isKnowledgeBaseSetup ? (
<span data-test-subj="kb-installed">
{i18n.KNOWLEDGE_BASE_DESCRIPTION_INSTALLED(KNOWLEDGE_BASE_INDEX_PATTERN)}
</span>
) : (
<span data-test-subj="install-kb">{i18n.KNOWLEDGE_BASE_DESCRIPTION}</span>
);
}, [isKnowledgeBaseSetup]);
//////////////////////////////////////////////////////////////////////////////////////////
// ESQL Resource
const esqlDescription = useMemo(() => {
return isESQLEnabled ? (
<span data-test-subj="esql-installed">{i18n.ESQL_DESCRIPTION_INSTALLED}</span>
) : (
<span data-test-subj="install-esql">{i18n.ESQL_DESCRIPTION}</span>
);
}, [isESQLEnabled]);
return (
<EuiPanel hasShadow={false} hasBorder paddingSize="l">
<EuiText size="m">
<FormattedMessage
id="xpack.elasticAssistant.assistant.settings.knowledgeBasedSettingManagements.knowledgeBaseDescription"
defaultMessage="Powered by ELSER, the knowledge base enables the AI Assistant to recall documents and other relevant context within your conversation. For more information about user access refer to our {documentation}."
values={{
documentation: (
<EuiLink
external
href="https://www.elastic.co/guide/en/security/current/security-assistant.html"
target="_blank"
>
{i18n.KNOWLEDGE_BASE_DOCUMENTATION}
</EuiLink>
),
}}
/>
</EuiText>
<EuiHorizontalRule margin={'s'} />
<EuiFormRow
display="columnCompressedSwitch"
label={i18n.KNOWLEDGE_BASE_LABEL}
css={css`
.euiFormRow__labelWrapper {
min-width: 95px !important;
}
`}
>
{setupKnowledgeBaseButton}
</EuiFormRow>
<EuiSpacer size="s" />
<EuiFlexGroup
direction={'column'}
gutterSize={'s'}
css={css`
padding-left: 5px;
`}
>
<EuiFlexItem grow={false}>
<div>
<EuiHealth color={elserHealth}>{i18n.KNOWLEDGE_BASE_ELSER_LABEL}</EuiHealth>
<EuiText
size={'xs'}
color={'subdued'}
css={css`
padding-left: 20px;
`}
>
<FormattedMessage
defaultMessage="Elastic Learned Sparse EncodeR - or ELSER - is a retrieval model trained by Elastic for performing semantic search."
id="xpack.elasticAssistant.assistant.settings.knowledgeBasedSettingsManagement.knowledgeBaseDescription"
/>
</EuiText>
</div>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<div>
<EuiHealth color={knowledgeBaseHealth}>{i18n.KNOWLEDGE_BASE_LABEL}</EuiHealth>
<EuiText
size={'xs'}
color={'subdued'}
css={css`
padding-left: 20px;
`}
>
{knowledgeBaseDescription}
</EuiText>
</div>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<span>
<EuiHealth color={esqlHealth}>{i18n.ESQL_LABEL}</EuiHealth>
<EuiText
size={'xs'}
color={'subdued'}
css={css`
padding-left: 20px;
`}
>
{esqlDescription}
</EuiText>
</span>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<AlertsSettings
knowledgeBase={knowledgeBase}
setUpdatedKnowledgeBaseSettings={handleUpdateKnowledgeBaseSettings}
/>
<AssistantSettingsBottomBar
hasPendingChanges={hasPendingChanges}
onCancelClick={onCancelClick}
onSaveButtonClicked={onSaveButtonClicked}
/>
</EuiPanel>
);
});
KnowledgeBaseSettingsManagement.displayName = 'KnowledgeBaseSettingsManagement';

View file

@ -0,0 +1,85 @@
/*
* 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 {
EuiButton,
EuiIcon,
EuiPopover,
EuiContextMenuItem,
EuiContextMenuPanel,
} from '@elastic/eui';
import React, { useCallback, useState } from 'react';
import * as i18n from './translations';
interface Props {
isDocumentAvailable?: boolean;
isIndexAvailable?: boolean;
onDocumentClicked?: () => void;
onIndexClicked?: () => void;
}
export const AddEntryButton: React.FC<Props> = React.memo(
({
isDocumentAvailable = true,
isIndexAvailable = true,
onDocumentClicked,
onIndexClicked,
}: Props) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const closePopover = useCallback(() => setIsPopoverOpen(false), []);
const handleIndexClicked = useCallback(() => {
closePopover();
onIndexClicked?.();
}, [closePopover, onIndexClicked]);
const handleDocumentClicked = useCallback(() => {
closePopover();
onDocumentClicked?.();
}, [closePopover, onDocumentClicked]);
const onButtonClick = useCallback(() => setIsPopoverOpen((prevState) => !prevState), []);
const items = [
<EuiContextMenuItem
aria-label={i18n.INDEX}
key={i18n.INDEX}
icon="index"
onClick={handleIndexClicked}
disabled={!isIndexAvailable}
>
{i18n.INDEX}
</EuiContextMenuItem>,
<EuiContextMenuItem
aria-label={i18n.DOCUMENT}
key={i18n.DOCUMENT}
icon="document"
onClick={handleDocumentClicked}
disabled={!isDocumentAvailable}
>
{i18n.DOCUMENT}
</EuiContextMenuItem>,
];
return onIndexClicked || onDocumentClicked ? (
<EuiPopover
button={
<EuiButton iconType="arrowDown" iconSide="right" onClick={onButtonClick}>
<EuiIcon type="plusInCircle" />
{i18n.NEW}
</EuiButton>
}
isOpen={isPopoverOpen}
closePopover={closePopover}
anchorPosition="downLeft"
>
<EuiContextMenuPanel size="s" items={items} />
</EuiPopover>
) : null;
}
);
AddEntryButton.displayName = 'AddEntryButton';

View file

@ -0,0 +1,136 @@
/*
* 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 {
EuiCheckbox,
EuiFieldText,
EuiForm,
EuiFormRow,
EuiMarkdownEditor,
EuiSuperSelect,
EuiIcon,
EuiText,
} from '@elastic/eui';
import React, { useCallback } from 'react';
import { DocumentEntry } from '@kbn/elastic-assistant-common';
import * as i18n from './translations';
interface Props {
entry?: DocumentEntry;
setEntry: React.Dispatch<React.SetStateAction<Partial<DocumentEntry>>>;
}
export const DocumentEntryEditor: React.FC<Props> = React.memo(({ entry, setEntry }) => {
// Name
const setName = useCallback(
(e) => setEntry((prevEntry) => ({ ...prevEntry, name: e.target.value })),
[setEntry]
);
// Sharing
const setSharingOptions = useCallback(
(value) =>
setEntry((prevEntry) => ({
...prevEntry,
users: value === i18n.SHARING_GLOBAL_OPTION_LABEL ? [] : undefined,
})),
[setEntry]
);
// TODO: KB-RBAC Disable global option if no RBAC
const sharingOptions = [
{
value: i18n.SHARING_PRIVATE_OPTION_LABEL,
inputDisplay: (
<EuiText size={'s'}>
<EuiIcon
color="subdued"
style={{ lineHeight: 'inherit', marginRight: '4px' }}
type="lock"
/>
{i18n.SHARING_PRIVATE_OPTION_LABEL}
</EuiText>
),
},
{
value: i18n.SHARING_GLOBAL_OPTION_LABEL,
inputDisplay: (
<EuiText size={'s'}>
<EuiIcon
color="subdued"
style={{ lineHeight: 'inherit', marginRight: '4px' }}
type="globe"
/>
{i18n.SHARING_GLOBAL_OPTION_LABEL}
</EuiText>
),
},
];
const selectedSharingOption =
entry?.users?.length === 0 ? sharingOptions[1].value : sharingOptions[0].value;
// Text / markdown
const setMarkdownValue = useCallback(
(value: string) => {
setEntry((prevEntry) => ({ ...prevEntry, text: value }));
},
[setEntry]
);
// Required checkbox
const onRequiredKnowledgeChanged = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setEntry((prevEntry) => ({ ...prevEntry, required: e.target.checked }));
},
[setEntry]
);
return (
<EuiForm>
<EuiFormRow label={i18n.ENTRY_NAME_INPUT_LABEL} fullWidth>
<EuiFieldText
name="name"
placeholder={i18n.ENTRY_NAME_INPUT_PLACEHOLDER}
fullWidth
value={entry?.name}
onChange={setName}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.ENTRY_SHARING_INPUT_LABEL}
helpText={i18n.SHARING_HELP_TEXT}
fullWidth
>
<EuiSuperSelect
options={sharingOptions}
valueOfSelected={selectedSharingOption}
onChange={setSharingOptions}
fullWidth
/>
</EuiFormRow>
<EuiFormRow label={i18n.ENTRY_MARKDOWN_INPUT_TEXT} fullWidth>
<EuiMarkdownEditor
aria-label={i18n.ENTRY_MARKDOWN_INPUT_TEXT}
placeholder="# Title"
value={entry?.text ?? ''}
onChange={setMarkdownValue}
height={400}
initialViewMode={'editing'}
/>
</EuiFormRow>
<EuiFormRow fullWidth helpText={i18n.ENTRY_REQUIRED_KNOWLEDGE_HELP_TEXT}>
<EuiCheckbox
label={i18n.ENTRY_REQUIRED_KNOWLEDGE_CHECKBOX_LABEL}
id="requiredKnowledge"
onChange={onRequiredKnowledgeChanged}
checked={entry?.required ?? false}
disabled={true}
/>
</EuiFormRow>
</EuiForm>
);
});
DocumentEntryEditor.displayName = 'DocumentEntryEditor';

View file

@ -0,0 +1,36 @@
/*
* 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 {
DocumentEntryType,
KnowledgeBaseEntryCreateProps,
KnowledgeBaseEntryResponse,
} from '@kbn/elastic-assistant-common';
import { z } from '@kbn/zod';
export const isEsqlSystemEntry = (
entry: KnowledgeBaseEntryResponse
): entry is KnowledgeBaseEntryResponse & {
type: DocumentEntryType;
kbResource: 'esql';
} => {
return entry.type === DocumentEntryType.value && entry.kbResource === 'esql';
};
export const isKnowledgeBaseEntryCreateProps = (
entry: unknown
): entry is z.infer<typeof KnowledgeBaseEntryCreateProps> => {
const result = KnowledgeBaseEntryCreateProps.safeParse(entry);
return result.success;
};
export const isKnowledgeBaseEntryResponse = (
entry: unknown
): entry is z.infer<typeof KnowledgeBaseEntryResponse> => {
const result = KnowledgeBaseEntryResponse.safeParse(entry);
return result.success;
};

View file

@ -0,0 +1,300 @@
/*
* 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 {
EuiInMemoryTable,
EuiLink,
EuiPanel,
EuiSearchBarProps,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import {
DocumentEntry,
DocumentEntryType,
IndexEntry,
IndexEntryType,
KnowledgeBaseEntryCreateProps,
KnowledgeBaseEntryResponse,
} from '@kbn/elastic-assistant-common';
import { AlertsSettingsManagement } from '../../alerts/settings/alerts_settings_management';
import { useKnowledgeBaseEntries } from '../../assistant/api/knowledge_base/entries/use_knowledge_base_entries';
import { useAssistantContext } from '../../assistant_context';
import { useKnowledgeBaseTable } from './use_knowledge_base_table';
import { AssistantSettingsBottomBar } from '../../assistant/settings/assistant_settings_bottom_bar';
import {
useSettingsUpdater,
DEFAULT_CONVERSATIONS,
DEFAULT_PROMPTS,
} from '../../assistant/settings/use_settings_updater/use_settings_updater';
import { AddEntryButton } from './add_entry_button';
import * as i18n from './translations';
import { Flyout } from '../../assistant/common/components/assistant_settings_management/flyout';
import { useFlyoutModalVisibility } from '../../assistant/common/components/assistant_settings_management/flyout/use_flyout_modal_visibility';
import { IndexEntryEditor } from './index_entry_editor';
import { DocumentEntryEditor } from './document_entry_editor';
import { KnowledgeBaseSettings } from '../knowledge_base_settings';
import { SetupKnowledgeBaseButton } from '../setup_knowledge_base_button';
import { useDeleteKnowledgeBaseEntries } from '../../assistant/api/knowledge_base/entries/use_delete_knowledge_base_entries';
import {
isEsqlSystemEntry,
isKnowledgeBaseEntryCreateProps,
isKnowledgeBaseEntryResponse,
} from './helpers';
import { useCreateKnowledgeBaseEntry } from '../../assistant/api/knowledge_base/entries/use_create_knowledge_base_entry';
import { useUpdateKnowledgeBaseEntries } from '../../assistant/api/knowledge_base/entries/use_update_knowledge_base_entries';
import { SETTINGS_UPDATED_TOAST_TITLE } from '../../assistant/settings/translations';
export const KnowledgeBaseSettingsManagement: React.FC = React.memo(() => {
const {
assistantFeatures: { assistantKnowledgeBaseByDefault: enableKnowledgeBaseByDefault },
http,
toasts,
} = useAssistantContext();
const [hasPendingChanges, setHasPendingChanges] = useState(false);
// Only needed for legacy settings management
const { knowledgeBase, setUpdatedKnowledgeBaseSettings, resetSettings, saveSettings } =
useSettingsUpdater(
DEFAULT_CONVERSATIONS, // Knowledge Base settings do not require conversations
DEFAULT_PROMPTS, // Knowledge Base settings do not require prompts
false, // Knowledge Base settings do not require conversations
false // Knowledge Base settings do not require prompts
);
const handleUpdateKnowledgeBaseSettings = useCallback(
(updatedKnowledgeBase) => {
setHasPendingChanges(true);
setUpdatedKnowledgeBaseSettings(updatedKnowledgeBase);
},
[setUpdatedKnowledgeBaseSettings]
);
const handleSave = useCallback(
async (param?: { callback?: () => void }) => {
await saveSettings();
toasts?.addSuccess({
iconType: 'check',
title: SETTINGS_UPDATED_TOAST_TITLE,
});
setHasPendingChanges(false);
param?.callback?.();
},
[saveSettings, toasts]
);
const onCancelClick = useCallback(() => {
resetSettings();
setHasPendingChanges(false);
}, [resetSettings]);
const onSaveButtonClicked = useCallback(() => {
handleSave();
}, [handleSave]);
const { isFlyoutOpen: isFlyoutVisible, openFlyout, closeFlyout } = useFlyoutModalVisibility();
const [selectedEntry, setSelectedEntry] =
useState<Partial<DocumentEntry | IndexEntry | KnowledgeBaseEntryCreateProps>>();
// CRUD API accessors
const { mutate: createEntry, isLoading: isCreatingEntry } = useCreateKnowledgeBaseEntry({
http,
toasts,
});
const { mutate: updateEntries, isLoading: isUpdatingEntries } = useUpdateKnowledgeBaseEntries({
http,
toasts,
});
const { mutate: deleteEntry, isLoading: isDeletingEntries } = useDeleteKnowledgeBaseEntries({
http,
toasts,
});
const isModifyingEntry = isCreatingEntry || isUpdatingEntries || isDeletingEntries;
// Flyout Save/Cancel Actions
const onSaveConfirmed = useCallback(() => {
if (isKnowledgeBaseEntryCreateProps(selectedEntry)) {
createEntry(selectedEntry);
closeFlyout();
} else if (isKnowledgeBaseEntryResponse(selectedEntry)) {
updateEntries([selectedEntry]);
closeFlyout();
}
}, [closeFlyout, selectedEntry, createEntry, updateEntries]);
const onSaveCancelled = useCallback(() => {
setSelectedEntry(undefined);
closeFlyout();
}, [closeFlyout]);
const { data: entries } = useKnowledgeBaseEntries({
http,
toasts,
enabled: enableKnowledgeBaseByDefault,
});
const { getColumns } = useKnowledgeBaseTable();
const columns = useMemo(
() =>
getColumns({
onEntryNameClicked: ({ id }: KnowledgeBaseEntryResponse) => {
const entry = entries.data.find((e) => e.id === id);
setSelectedEntry(entry);
openFlyout();
},
onSpaceNameClicked: ({ namespace }: KnowledgeBaseEntryResponse) => {
openFlyout();
},
isDeleteEnabled: (entry: KnowledgeBaseEntryResponse) => {
return !isEsqlSystemEntry(entry);
},
onDeleteActionClicked: ({ id }: KnowledgeBaseEntryResponse) => {
deleteEntry({ ids: [id] });
},
isEditEnabled: (entry: KnowledgeBaseEntryResponse) => {
return !isEsqlSystemEntry(entry);
},
onEditActionClicked: ({ id }: KnowledgeBaseEntryResponse) => {
const entry = entries.data.find((e) => e.id === id);
setSelectedEntry(entry);
openFlyout();
},
}),
[deleteEntry, entries.data, getColumns, openFlyout]
);
const onDocumentClicked = useCallback(() => {
setSelectedEntry({ type: DocumentEntryType.value, kbResource: 'user', source: 'user' });
openFlyout();
}, [openFlyout]);
const onIndexClicked = useCallback(() => {
setSelectedEntry({ type: IndexEntryType.value });
openFlyout();
}, [openFlyout]);
const search: EuiSearchBarProps = useMemo(
() => ({
toolsRight: (
<AddEntryButton onDocumentClicked={onDocumentClicked} onIndexClicked={onIndexClicked} />
),
box: {
incremental: true,
placeholder: i18n.SEARCH_PLACEHOLDER,
},
filters: [],
}),
[onDocumentClicked, onIndexClicked]
);
const flyoutTitle = useMemo(() => {
// @ts-expect-error TS doesn't understand that selectedEntry is a partial
if (selectedEntry?.id != null) {
return selectedEntry.type === DocumentEntryType.value
? i18n.EDIT_DOCUMENT_FLYOUT_TITLE
: i18n.EDIT_INDEX_FLYOUT_TITLE;
}
return selectedEntry?.type === DocumentEntryType.value
? i18n.NEW_DOCUMENT_FLYOUT_TITLE
: i18n.NEW_INDEX_FLYOUT_TITLE;
}, [selectedEntry]);
if (!enableKnowledgeBaseByDefault) {
return (
<>
<KnowledgeBaseSettings
knowledgeBase={knowledgeBase}
setUpdatedKnowledgeBaseSettings={handleUpdateKnowledgeBaseSettings}
/>
<AssistantSettingsBottomBar
hasPendingChanges={hasPendingChanges}
onCancelClick={onCancelClick}
onSaveButtonClicked={onSaveButtonClicked}
/>
</>
);
}
const sorting = {
sort: {
field: 'name',
direction: 'desc' as const,
},
};
return (
<>
<EuiPanel hasShadow={false} hasBorder paddingSize="l">
<EuiText size={'m'}>
<FormattedMessage
id="xpack.elasticAssistant.assistant.settings.knowledgeBasedSettingManagements.knowledgeBaseDescription"
defaultMessage="The AI Assistant uses Elastic's ELSER model to semantically search your data sources and feed that context to an LLM. Import knowledge bases like Runbooks, GitHub issues, and others for more accurate, personalized assistance. {learnMore}."
values={{
learnMore: (
<EuiLink
external
href="https://www.elastic.co/guide/en/security/current/security-assistant.html"
target="_blank"
>
{i18n.KNOWLEDGE_BASE_DOCUMENTATION}
</EuiLink>
),
}}
/>
<SetupKnowledgeBaseButton display={'mini'} />
</EuiText>
<EuiSpacer size="l" />
<EuiInMemoryTable
columns={columns}
items={entries.data ?? []}
search={search}
sorting={sorting}
/>
</EuiPanel>
<EuiSpacer size="m" />
<AlertsSettingsManagement
knowledgeBase={knowledgeBase}
setUpdatedKnowledgeBaseSettings={handleUpdateKnowledgeBaseSettings}
/>
<AssistantSettingsBottomBar
hasPendingChanges={hasPendingChanges}
onCancelClick={onCancelClick}
onSaveButtonClicked={onSaveButtonClicked}
/>
<Flyout
flyoutVisible={isFlyoutVisible}
title={flyoutTitle}
onClose={onSaveCancelled}
onSaveCancelled={onSaveCancelled}
onSaveConfirmed={onSaveConfirmed}
saveButtonDisabled={!isKnowledgeBaseEntryCreateProps(selectedEntry) || isModifyingEntry} // TODO: KB-RBAC disable for global entries if user doesn't have global RBAC
>
<>
{selectedEntry?.type === DocumentEntryType.value ? (
<DocumentEntryEditor
entry={selectedEntry as DocumentEntry}
setEntry={
setSelectedEntry as React.Dispatch<React.SetStateAction<Partial<DocumentEntry>>>
}
/>
) : (
<IndexEntryEditor
entry={selectedEntry as IndexEntry}
setEntry={
setSelectedEntry as React.Dispatch<React.SetStateAction<Partial<IndexEntry>>>
}
/>
)}
</>
</Flyout>
</>
);
});
KnowledgeBaseSettingsManagement.displayName = 'KnowledgeBaseSettingsManagement';

View file

@ -0,0 +1,188 @@
/*
* 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 {
EuiComboBox,
EuiFieldText,
EuiForm,
EuiFormRow,
EuiComboBoxOptionOption,
EuiText,
EuiIcon,
EuiSuperSelect,
} from '@elastic/eui';
import React, { useCallback } from 'react';
import { IndexEntry } from '@kbn/elastic-assistant-common';
import * as i18n from './translations';
interface Props {
entry?: IndexEntry;
setEntry: React.Dispatch<React.SetStateAction<Partial<IndexEntry>>>;
}
export const IndexEntryEditor: React.FC<Props> = React.memo(({ entry, setEntry }) => {
// Name
const setName = useCallback(
(e) => setEntry((prevEntry) => ({ ...prevEntry, name: e.target.value })),
[setEntry]
);
// Sharing
const setSharingOptions = useCallback(
(value) =>
setEntry((prevEntry) => ({
...prevEntry,
users: value === i18n.SHARING_GLOBAL_OPTION_LABEL ? [] : undefined,
})),
[setEntry]
);
// TODO: KB-RBAC Disable global option if no RBAC
const sharingOptions = [
{
value: i18n.SHARING_PRIVATE_OPTION_LABEL,
inputDisplay: (
<EuiText size={'s'}>
<EuiIcon
color="subdued"
style={{ lineHeight: 'inherit', marginRight: '4px' }}
type="lock"
/>
{i18n.SHARING_PRIVATE_OPTION_LABEL}
</EuiText>
),
},
{
value: i18n.SHARING_GLOBAL_OPTION_LABEL,
inputDisplay: (
<EuiText size={'s'}>
<EuiIcon
color="subdued"
style={{ lineHeight: 'inherit', marginRight: '4px' }}
type="globe"
/>
{i18n.SHARING_GLOBAL_OPTION_LABEL}
</EuiText>
),
},
];
const selectedSharingOption =
entry?.users?.length === 0 ? sharingOptions[1].value : sharingOptions[0].value;
// Index
const setIndex = useCallback(
(e: Array<EuiComboBoxOptionOption<string>>) =>
setEntry((prevEntry) => ({ ...prevEntry, index: e[0].value })),
[setEntry]
);
const onCreateOption = (searchValue: string) => {
const normalizedSearchValue = searchValue.trim().toLowerCase();
if (!normalizedSearchValue) {
return;
}
const newOption: EuiComboBoxOptionOption<string> = {
label: searchValue,
value: searchValue,
};
setIndex([newOption]);
};
// Field
const setField = useCallback(
(e) => setEntry((prevEntry) => ({ ...prevEntry, field: e.target.value })),
[setEntry]
);
// Description
const setDescription = useCallback(
(e) => setEntry((prevEntry) => ({ ...prevEntry, description: e.target.value })),
[setEntry]
);
// Query Description
const setQueryDescription = useCallback(
(e) => setEntry((prevEntry) => ({ ...prevEntry, queryDescription: e.target.value })),
[setEntry]
);
return (
<EuiForm>
<EuiFormRow label={i18n.ENTRY_NAME_INPUT_LABEL} fullWidth>
<EuiFieldText
name="name"
placeholder={i18n.ENTRY_NAME_INPUT_PLACEHOLDER}
fullWidth
value={entry?.name}
onChange={setName}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.ENTRY_SHARING_INPUT_LABEL}
helpText={i18n.SHARING_HELP_TEXT}
fullWidth
>
<EuiSuperSelect
options={sharingOptions}
valueOfSelected={selectedSharingOption}
onChange={setSharingOptions}
fullWidth
/>
</EuiFormRow>
<EuiFormRow label={i18n.ENTRY_INDEX_NAME_INPUT_LABEL} fullWidth>
<EuiComboBox
aria-label={i18n.ENTRY_INDEX_NAME_INPUT_LABEL}
isClearable={true}
singleSelection={{ asPlainText: true }}
onCreateOption={onCreateOption}
fullWidth
selectedOptions={
entry?.index
? [
{
label: entry?.index,
value: entry?.index,
},
]
: []
}
onChange={setIndex}
/>
</EuiFormRow>
<EuiFormRow label={i18n.ENTRY_FIELD_INPUT_LABEL} fullWidth>
<EuiFieldText
name="field"
placeholder={i18n.ENTRY_INPUT_PLACEHOLDER}
fullWidth
value={entry?.field}
onChange={setField}
/>
</EuiFormRow>
<EuiFormRow label={i18n.ENTRY_DESCRIPTION_INPUT_LABEL} fullWidth>
<EuiFieldText
name="description"
placeholder={i18n.ENTRY_INPUT_PLACEHOLDER}
fullWidth
value={entry?.description}
onChange={setDescription}
/>
</EuiFormRow>
<EuiFormRow label={i18n.ENTRY_QUERY_DESCRIPTION_INPUT_LABEL} fullWidth>
<EuiFieldText
name="description"
placeholder={i18n.ENTRY_INPUT_PLACEHOLDER}
fullWidth
value={entry?.queryDescription}
onChange={setQueryDescription}
/>
</EuiFormRow>
</EuiForm>
);
});
IndexEntryEditor.displayName = 'IndexEntryEditor';

View file

@ -0,0 +1,291 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const NEW = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.newLabel',
{
defaultMessage: 'New',
}
);
export const INDEX = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.indexLabel',
{
defaultMessage: 'Index',
}
);
export const DOCUMENT = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.documentLabel',
{
defaultMessage: 'Document',
}
);
export const COLUMN_NAME = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.columnNameLabel',
{
defaultMessage: 'Name',
}
);
export const COLUMN_SHARING = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.columnSharingLabel',
{
defaultMessage: 'Sharing',
}
);
export const COLUMN_AUTHOR = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.columnAuthorLabel',
{
defaultMessage: 'Author',
}
);
export const COLUMN_ENTRIES = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.columnEntriesLabel',
{
defaultMessage: 'Entries',
}
);
export const COLUMN_SPACE = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.columnSpaceLabel',
{
defaultMessage: 'Space',
}
);
export const COLUMN_CREATED = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.columnCreatedLabel',
{
defaultMessage: 'Created',
}
);
export const COLUMN_ACTIONS = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.columnActionsLabel',
{
defaultMessage: 'Actions',
}
);
export const SEARCH_PLACEHOLDER = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.searchPlaceholder',
{
defaultMessage: 'Search for an entry',
}
);
export const DEFAULT_FLYOUT_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.defaultFlyoutTitle',
{
defaultMessage: 'Knowledge Base',
}
);
export const NEW_INDEX_FLYOUT_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.newIndexEntryFlyoutTitle',
{
defaultMessage: 'New index entry',
}
);
export const EDIT_INDEX_FLYOUT_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.editIndexEntryFlyoutTitle',
{
defaultMessage: 'Edit index entry',
}
);
export const NEW_DOCUMENT_FLYOUT_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.newDocumentEntryFlyoutTitle',
{
defaultMessage: 'New document entry',
}
);
export const EDIT_DOCUMENT_FLYOUT_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.editDocumentEntryFlyoutTitle',
{
defaultMessage: 'Edit document entry',
}
);
export const MANUAL = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.manualButtonLabel',
{
defaultMessage: 'Manual',
}
);
export const CREATE_INDEX_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.createIndexTitle',
{
defaultMessage: 'New Index entry',
}
);
export const NEW_ENTRY_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.newEntryTitle',
{
defaultMessage: 'New entry',
}
);
export const DELETE_ENTRY_DEFAULT_TITLE = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.deleteEntryDefaultTitle',
{
defaultMessage: 'Delete item',
}
);
export const ENTRY_NAME_INPUT_LABEL = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryNameInputLabel',
{
defaultMessage: 'Name',
}
);
export const ENTRY_SHARING_INPUT_LABEL = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entrySharingInputLabel',
{
defaultMessage: 'Sharing',
}
);
export const ENTRY_NAME_INPUT_PLACEHOLDER = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryNameInputPlaceholder',
{
defaultMessage: 'Name your Knowledge Base entry',
}
);
export const ENTRY_SPACE_INPUT_LABEL = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entrySpaceInputLabel',
{
defaultMessage: 'Space',
}
);
export const ENTRY_SPACE_INPUT_PLACEHOLDER = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entrySpaceInputPlaceholder',
{
defaultMessage: 'Select',
}
);
export const SHARING_PRIVATE_OPTION_LABEL = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.sharingPrivateOptionLabel',
{
defaultMessage: 'Private to you',
}
);
export const SHARING_GLOBAL_OPTION_LABEL = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.sharingGlobalOptionLabel',
{
defaultMessage: 'Global to everyone in the Space',
}
);
export const SHARING_HELP_TEXT = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.sharingHelpText',
{
defaultMessage: 'Set to global if youd like other users in your Org to have access.',
}
);
export const DELETE_ENTRY_CONFIRMATION_TITLE = (title: string) =>
i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.deleteEntryTitle',
{
values: { title },
defaultMessage: 'Delete "{title}"?',
}
);
export const ENTRY_MARKDOWN_INPUT_TEXT = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryMarkdownInputText',
{
defaultMessage: 'Markdown text',
}
);
export const ENTRY_REQUIRED_KNOWLEDGE_HELP_TEXT = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryRequiredKnowledgeHelpText',
{
defaultMessage:
'Check to indicate a Knowledge Base entry thats included in every conversation',
}
);
export const ENTRY_REQUIRED_KNOWLEDGE_CHECKBOX_LABEL = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryRequiredKnowledgeCheckboxLabel',
{
defaultMessage: 'Required knowledge',
}
);
export const ENTRY_INDEX_NAME_INPUT_LABEL = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryIndexNameInputLabel',
{
defaultMessage: 'Index',
}
);
export const ENTRY_FIELD_INPUT_LABEL = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryFieldInputLabel',
{
defaultMessage: 'Field',
}
);
export const ENTRY_DESCRIPTION_INPUT_LABEL = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryDescriptionInputLabel',
{
defaultMessage: 'Description',
}
);
export const ENTRY_QUERY_DESCRIPTION_INPUT_LABEL = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryQueryDescriptionInputLabel',
{
defaultMessage: 'Query Description',
}
);
export const ENTRY_INPUT_PLACEHOLDER = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.entryInputPlaceholder',
{
defaultMessage: 'Input',
}
);
export const KNOWLEDGE_BASE_DOCUMENTATION = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.knowledgeBaseDocumentation',
{
defaultMessage: 'Learn more',
}
);
export const GLOBAL = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.knowledgeBaseGlobal',
{
defaultMessage: 'Global',
}
);
export const PRIVATE = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettingsManagement.knowledgeBasePrivate',
{
defaultMessage: 'Private',
}
);

View file

@ -0,0 +1,159 @@
/*
* 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 { EuiAvatar, EuiBadge, EuiBasicTableColumn, EuiIcon, EuiLink, EuiText } from '@elastic/eui';
import { css } from '@emotion/react';
import React, { useCallback } from 'react';
import { FormattedDate } from '@kbn/i18n-react';
import {
DocumentEntryType,
IndexEntryType,
KnowledgeBaseEntryResponse,
} from '@kbn/elastic-assistant-common';
import { useAssistantContext } from '../../..';
import * as i18n from './translations';
import { BadgesColumn } from '../../assistant/common/components/assistant_settings_management/badges';
import { useInlineActions } from '../../assistant/common/components/assistant_settings_management/inline_actions';
import { isEsqlSystemEntry } from './helpers';
export const useKnowledgeBaseTable = () => {
const { currentUserAvatar } = useAssistantContext();
const getActions = useInlineActions<KnowledgeBaseEntryResponse & { isDefault?: undefined }>();
const getIconForEntry = (entry: KnowledgeBaseEntryResponse): string => {
if (entry.type === DocumentEntryType.value) {
if (entry.kbResource === 'user') {
return 'userAvatar';
}
if (entry.kbResource === 'esql') {
return 'logoElastic';
}
return 'visText';
} else if (entry.type === IndexEntryType.value) {
return 'index';
}
return 'questionInCircle';
};
const getColumns = useCallback(
({
isDeleteEnabled,
isEditEnabled,
onEntryNameClicked,
onDeleteActionClicked,
onEditActionClicked,
}): Array<EuiBasicTableColumn<KnowledgeBaseEntryResponse>> => {
return [
{
name: '',
render: (entry: KnowledgeBaseEntryResponse) => <EuiIcon type={getIconForEntry(entry)} />,
width: '24px',
},
{
name: i18n.COLUMN_NAME,
render: ({ id, name }: KnowledgeBaseEntryResponse) => (
<EuiLink onClick={() => onEntryNameClicked({ id })}>{name}</EuiLink>
),
sortable: ({ name }: KnowledgeBaseEntryResponse) => name,
width: '30%',
},
{
name: i18n.COLUMN_SHARING,
sortable: ({ users }: KnowledgeBaseEntryResponse) => users.length,
render: ({ id, users }: KnowledgeBaseEntryResponse) => {
const sharingItem = users.length > 0 ? i18n.PRIVATE : i18n.GLOBAL;
const color = users.length > 0 ? 'hollow' : 'primary';
return <BadgesColumn items={[sharingItem]} prefix={id} color={color} />;
},
width: '100px',
},
{
name: i18n.COLUMN_AUTHOR,
sortable: ({ users }: KnowledgeBaseEntryResponse) => users[0]?.name,
render: (entry: KnowledgeBaseEntryResponse) => {
// TODO: Look up user from `createdBy` id if privileges allow
const userName = entry.users?.[0]?.name ?? 'Unknown';
const badgeItem = isEsqlSystemEntry(entry) ? 'Elastic' : userName;
const userImage = isEsqlSystemEntry(entry) ? (
<EuiIcon
type={'logoElastic'}
css={css`
margin-left: 4px;
margin-right: 14px;
`}
/>
) : currentUserAvatar?.imageUrl != null ? (
<EuiAvatar
name={userName}
imageUrl={currentUserAvatar.imageUrl}
size={'s'}
color={currentUserAvatar?.color ?? 'subdued'}
css={css`
margin-right: 10px;
`}
/>
) : (
<EuiAvatar
name={userName}
initials={currentUserAvatar?.initials}
size={'s'}
color={currentUserAvatar?.color ?? 'subdued'}
css={css`
margin-right: 10px;
`}
/>
);
return (
<>
{userImage}
<EuiText size={'s'}>{badgeItem}</EuiText>
</>
);
},
},
{
name: i18n.COLUMN_ENTRIES,
render: (entry: KnowledgeBaseEntryResponse) => {
return isEsqlSystemEntry(entry)
? entry.text
: entry.type === DocumentEntryType.value
? '1'
: '-';
},
},
{
name: i18n.COLUMN_CREATED,
render: ({ createdAt }: { createdAt: string }) => (
<>
{createdAt ? (
<EuiBadge color="hollow">
<FormattedDate
value={new Date(createdAt)}
year="numeric"
month="2-digit"
day="numeric"
/>
</EuiBadge>
) : null}
</>
),
sortable: ({ createdAt }: KnowledgeBaseEntryResponse) => createdAt,
},
{
...getActions({
isDeleteEnabled,
isEditEnabled,
onDelete: onDeleteActionClicked,
onEdit: onEditActionClicked,
}),
},
];
},
[currentUserAvatar, getActions]
);
return { getColumns };
};

View file

@ -6,7 +6,7 @@
*/
import React, { useCallback } from 'react';
import { EuiButton, EuiToolTip } from '@elastic/eui';
import { EuiButton, EuiButtonEmpty, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useAssistantContext } from '../..';
@ -15,11 +15,15 @@ import { useKnowledgeBaseStatus } from '../assistant/api/knowledge_base/use_know
export const ESQL_RESOURCE = 'esql';
interface Props {
display?: 'mini';
}
/**
* Self-contained component that renders a button to set up the knowledge base.
*
*/
export const SetupKnowledgeBaseButton: React.FC = React.memo(() => {
export const SetupKnowledgeBaseButton: React.FC<Props> = React.memo(({ display }: Props) => {
const { http, toasts } = useAssistantContext();
const { data: kbStatus } = useKnowledgeBaseStatus({ http, resource: ESQL_RESOURCE });
@ -48,19 +52,35 @@ export const SetupKnowledgeBaseButton: React.FC = React.memo(() => {
return (
<EuiToolTip position={'bottom'} content={toolTipContent}>
<EuiButton
color="primary"
data-test-subj="setup-knowledge-base-button"
fill
disabled={!kbStatus?.is_setup_available}
isLoading={isSetupInProgress}
iconType="importAction"
onClick={onInstallKnowledgeBase}
>
{i18n.translate('xpack.elasticAssistant.knowledgeBase.installKnowledgeBaseButton', {
defaultMessage: 'Setup Knowledge Base',
})}
</EuiButton>
{display === 'mini' ? (
<EuiButtonEmpty
color="primary"
data-test-subj="setup-knowledge-base-button"
disabled={!kbStatus?.is_setup_available}
isLoading={isSetupInProgress}
iconType="importAction"
onClick={onInstallKnowledgeBase}
size={'xs'}
>
{i18n.translate('xpack.elasticAssistant.knowledgeBase.installKnowledgeBaseButton', {
defaultMessage: 'Setup Knowledge Base',
})}
</EuiButtonEmpty>
) : (
<EuiButton
color="primary"
data-test-subj="setup-knowledge-base-button"
fill
disabled={!kbStatus?.is_setup_available}
isLoading={isSetupInProgress}
iconType="importAction"
onClick={onInstallKnowledgeBase}
>
{i18n.translate('xpack.elasticAssistant.knowledgeBase.installKnowledgeBaseButton', {
defaultMessage: 'Setup Knowledge Base',
})}
</EuiButton>
)}
</EuiToolTip>
);
});

View file

@ -13,6 +13,14 @@ export const ALERTS_LABEL = i18n.translate(
defaultMessage: 'Alerts',
}
);
export const SEND_ALERTS_LABEL = i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettings.sendAlertsLabel',
{
defaultMessage: 'Send Alerts',
}
);
export const LATEST_AND_RISKIEST_OPEN_ALERTS = (alertsCount: number) =>
i18n.translate(
'xpack.elasticAssistant.assistant.settings.knowledgeBaseSettings.latestAndRiskiestOpenAlertsLabel',

View file

@ -29,5 +29,6 @@
"@kbn/ui-theme",
"@kbn/core-doc-links-browser",
"@kbn/core",
"@kbn/zod",
]
}

View file

@ -21,7 +21,10 @@ import {
} from './data_clients.mock';
import { AIAssistantConversationsDataClient } from '../ai_assistant_data_clients/conversations';
import { AIAssistantDataClient } from '../ai_assistant_data_clients';
import { AIAssistantKnowledgeBaseDataClient } from '../ai_assistant_data_clients/knowledge_base';
import {
AIAssistantKnowledgeBaseDataClient,
GetAIAssistantKnowledgeBaseDataClientParams,
} from '../ai_assistant_data_clients/knowledge_base';
import { defaultAssistantFeatures } from '@kbn/elastic-assistant-common';
import { AttackDiscoveryDataClient } from '../ai_assistant_data_clients/attack_discovery';
@ -124,10 +127,12 @@ const createElasticAssistantRequestContextMock = (
() => clients.elasticAssistant.getAIAssistantKnowledgeBaseDataClient
) as unknown as jest.MockInstance<
Promise<AIAssistantKnowledgeBaseDataClient | null>,
[boolean | undefined],
[params: GetAIAssistantKnowledgeBaseDataClientParams],
unknown
> &
((v2KnowledgeBaseEnabled?: boolean) => Promise<AIAssistantKnowledgeBaseDataClient | null>),
((
params: GetAIAssistantKnowledgeBaseDataClientParams
) => Promise<AIAssistantKnowledgeBaseDataClient | null>),
getCurrentUser: jest.fn(),
getServerBasePath: jest.fn(),
getSpaceId: jest.fn(),

View file

@ -9,8 +9,10 @@ import { v4 as uuidv4 } from 'uuid';
import { AuthenticatedUser, ElasticsearchClient, Logger } from '@kbn/core/server';
import {
DocumentEntryCreateFields,
KnowledgeBaseEntryCreateProps,
KnowledgeBaseEntryResponse,
Metadata,
} from '@kbn/elastic-assistant-common';
import { getKnowledgeBaseEntry } from './get_knowledge_base_entry';
import { CreateKnowledgeBaseEntrySchema } from './types';
@ -21,7 +23,9 @@ export interface CreateKnowledgeBaseEntryParams {
logger: Logger;
spaceId: string;
user: AuthenticatedUser;
knowledgeBaseEntry: KnowledgeBaseEntryCreateProps;
knowledgeBaseEntry: KnowledgeBaseEntryCreateProps | LegacyKnowledgeBaseEntryCreateProps;
global?: boolean;
isV2?: boolean;
}
export const createKnowledgeBaseEntry = async ({
@ -31,9 +35,25 @@ export const createKnowledgeBaseEntry = async ({
user,
knowledgeBaseEntry,
logger,
global = false,
isV2 = false,
}: CreateKnowledgeBaseEntryParams): Promise<KnowledgeBaseEntryResponse | null> => {
const createdAt = new Date().toISOString();
const body = transformToCreateSchema(createdAt, spaceId, user, knowledgeBaseEntry);
const body = isV2
? transformToCreateSchema({
createdAt,
spaceId,
user,
entry: knowledgeBaseEntry as unknown as KnowledgeBaseEntryCreateProps,
global,
})
: transformToLegacyCreateSchema({
createdAt,
spaceId,
user,
entry: knowledgeBaseEntry as unknown as TransformToLegacyCreateSchemaProps['entry'],
global,
});
try {
const response = await esClient.create({
body,
@ -57,25 +77,38 @@ export const createKnowledgeBaseEntry = async ({
}
};
export const transformToCreateSchema = (
createdAt: string,
spaceId: string,
user: AuthenticatedUser,
entry: KnowledgeBaseEntryCreateProps
): CreateKnowledgeBaseEntrySchema => {
interface TransformToCreateSchemaProps {
createdAt: string;
spaceId: string;
user: AuthenticatedUser;
entry: KnowledgeBaseEntryCreateProps;
global?: boolean;
}
export const transformToCreateSchema = ({
createdAt,
spaceId,
user,
entry,
global = false,
}: TransformToCreateSchemaProps): CreateKnowledgeBaseEntrySchema => {
const base = {
'@timestamp': createdAt,
created_at: createdAt,
created_by: user.profile_uid ?? 'unknown',
updated_at: createdAt,
updated_by: user.profile_uid ?? 'unknown',
name: entry.name,
namespace: spaceId,
users: [
{
id: user.profile_uid,
name: user.username,
},
],
type: entry.type,
users: global
? []
: [
{
id: user.profile_uid,
name: user.username,
},
],
};
if (entry.type === 'index') {
@ -93,5 +126,54 @@ export const transformToCreateSchema = (
output_fields: outputFields ?? undefined,
};
}
return { ...base, ...entry, vector: undefined };
return {
...base,
kb_resource: entry.kbResource,
required: entry.required ?? false,
source: entry.source,
text: entry.text,
vector: undefined,
};
};
export type LegacyKnowledgeBaseEntryCreateProps = Omit<
DocumentEntryCreateFields,
'kbResource' | 'source'
> & {
metadata: Metadata;
};
interface TransformToLegacyCreateSchemaProps {
createdAt: string;
spaceId: string;
user: AuthenticatedUser;
entry: LegacyKnowledgeBaseEntryCreateProps;
global?: boolean;
}
export const transformToLegacyCreateSchema = ({
createdAt,
spaceId,
user,
entry,
global = false,
}: TransformToLegacyCreateSchemaProps): CreateKnowledgeBaseEntrySchema => {
return {
'@timestamp': createdAt,
created_at: createdAt,
created_by: user.profile_uid ?? 'unknown',
updated_at: createdAt,
updated_by: user.profile_uid ?? 'unknown',
namespace: spaceId,
users: global
? []
: [
{
id: user.profile_uid,
name: user.username,
},
],
...entry,
vector: undefined,
};
};

View file

@ -25,24 +25,47 @@ export const getKnowledgeBaseEntry = async ({
id,
user,
}: GetKnowledgeBaseEntryParams): Promise<KnowledgeBaseEntryResponse | null> => {
const filterByUser = [
{
nested: {
path: 'users',
query: {
bool: {
must: [
{
match: user.profile_uid
? { 'users.id': user.profile_uid }
: { 'users.name': user.username },
},
],
const userFilter = {
should: [
{
nested: {
path: 'users',
query: {
bool: {
minimum_should_match: 1,
should: [
{
match: user.profile_uid
? { 'users.id': user.profile_uid }
: { 'users.name': user.username },
},
],
},
},
},
},
},
];
{
bool: {
must_not: [
{
nested: {
path: 'users',
query: {
bool: {
filter: {
exists: {
field: 'users',
},
},
},
},
},
},
],
},
},
],
};
try {
const response = await esClient.search<EsKnowledgeBaseEntrySchema>({
query: {
@ -59,8 +82,9 @@ export const getKnowledgeBaseEntry = async ({
],
},
},
...filterByUser,
],
...userFilter,
minimum_should_match: 1,
},
},
_source: true,

View file

@ -71,24 +71,47 @@ export const getKBVectorSearchQuery = ({
]
: [];
const userFilter = [
{
nested: {
path: 'users',
query: {
bool: {
must: [
{
match: user.profile_uid
? { 'users.id': user.profile_uid }
: { 'users.name': user.username },
},
],
const userFilter = {
should: [
{
nested: {
path: 'users',
query: {
bool: {
minimum_should_match: 1,
should: [
{
match: user.profile_uid
? { 'users.id': user.profile_uid }
: { 'users.name': user.username },
},
],
},
},
},
},
},
];
{
bool: {
must_not: [
{
nested: {
path: 'users',
query: {
bool: {
filter: {
exists: {
field: 'users',
},
},
},
},
},
},
],
},
},
],
};
return {
bool: {
@ -103,9 +126,10 @@ export const getKBVectorSearchQuery = ({
},
...requiredFilter,
...resourceFilter,
...userFilter,
],
...userFilter,
filter,
minimum_should_match: 1,
},
};
};
@ -137,7 +161,7 @@ export const getStructuredToolForIndexEntry = ({
}, {});
return new DynamicStructuredTool({
name: indexEntry.name.replaceAll(' ', ''), // Tool names cannot contain spaces, further sanitization possibly needed
name: indexEntry.name.replace(/[^a-zA-Z0-9-]/g, ''), // // Tool names expects a string that matches the pattern '^[a-zA-Z0-9-]+$'
description: indexEntry.description,
schema: z.object({
query: z.string().describe(indexEntry.queryDescription),

View file

@ -25,10 +25,14 @@ import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWith
import { StructuredTool } from '@langchain/core/tools';
import { ElasticsearchClient } from '@kbn/core/server';
import { AIAssistantDataClient, AIAssistantDataClientParams } from '..';
import { ElasticsearchStore } from '../../lib/langchain/elasticsearch_store/elasticsearch_store';
import { loadESQL } from '../../lib/langchain/content_loaders/esql_loader';
import { AssistantToolParams, GetElser } from '../../types';
import { createKnowledgeBaseEntry, transformToCreateSchema } from './create_knowledge_base_entry';
import {
createKnowledgeBaseEntry,
LegacyKnowledgeBaseEntryCreateProps,
transformToCreateSchema,
transformToLegacyCreateSchema,
} from './create_knowledge_base_entry';
import { EsDocumentEntry, EsIndexEntry, EsKnowledgeBaseEntrySchema } from './types';
import { transformESSearchToKnowledgeBaseEntry } from './transforms';
import { ESQL_DOCS_LOADED_QUERY } from '../../routes/knowledge_base/constants';
@ -39,6 +43,15 @@ import {
} from './helpers';
import { getKBUserFilter } from '../../routes/knowledge_base/entries/utils';
/**
* Params for when creating KbDataClient in Request Context Factory. Useful if needing to modify
* configuration after initial plugin start
*/
export interface GetAIAssistantKnowledgeBaseDataClientParams {
modelIdOverride?: string;
v2KnowledgeBaseEnabled?: boolean;
}
interface KnowledgeBaseDataClientParams extends AIAssistantDataClientParams {
ml: MlPluginSetup;
getElserId: GetElser;
@ -182,17 +195,17 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient {
* See ml-team issue for providing 'dry run' flag to perform these checks: https://github.com/elastic/ml-team/issues/1208
*
* @param options
* @param options.esStore ElasticsearchStore for loading ES|QL docs via LangChain loaders
* @param options.soClient SavedObjectsClientContract for installing ELSER so that ML SO's are in sync
* @param options.installEsqlDocs Whether to install ESQL documents as part of setup (e.g. not needed in test env)
*
* @returns Promise<void>
*/
public setupKnowledgeBase = async ({
esStore,
soClient,
installEsqlDocs = true,
}: {
esStore: ElasticsearchStore;
soClient: SavedObjectsClientContract;
installEsqlDocs?: boolean;
}): Promise<void> => {
if (this.options.getIsKBSetupInProgress()) {
this.options.logger.debug('Knowledge Base setup already in progress');
@ -235,12 +248,14 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient {
}
this.options.logger.debug(`Checking if Knowledge Base docs have been loaded...`);
const kbDocsLoaded = (await esStore.similaritySearch(ESQL_DOCS_LOADED_QUERY)).length > 0;
if (!kbDocsLoaded) {
this.options.logger.debug(`Loading KB docs...`);
await loadESQL(esStore, this.options.logger);
} else {
this.options.logger.debug(`Knowledge Base docs already loaded!`);
if (installEsqlDocs) {
const kbDocsLoaded = await this.isESQLDocsLoaded();
if (!kbDocsLoaded) {
this.options.logger.debug(`Loading KB docs...`);
await loadESQL(this, this.options.logger);
} else {
this.options.logger.debug(`Knowledge Base docs already loaded!`);
}
}
} catch (e) {
this.options.setIsKBSetupInProgress(false);
@ -254,15 +269,19 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient {
* Adds LangChain Documents to the knowledge base
*
* @param {Array<Document<Metadata>>} documents - LangChain Documents to add to the knowledge base
* @param global whether these entries should be added globally, i.e. empty users[]
*/
public addKnowledgeBaseDocuments = async ({
documents,
global = false,
}: {
documents: Array<Document<Metadata>>;
global?: boolean;
}): Promise<KnowledgeBaseEntryResponse[]> => {
const writer = await this.getWriter();
const changedAt = new Date().toISOString();
const authenticatedUser = this.options.currentUser;
// TODO: KB-RBAC check for when `global:true`
if (authenticatedUser == null) {
throw new Error(
'Authenticated user not found! Ensure kbDataClient was initialized from a request.'
@ -270,27 +289,40 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient {
}
const { errors, docs_created: docsCreated } = await writer.bulk({
documentsToCreate: documents.map((doc) => {
// v1 schema has metadata nested in a `metadata` object, and kbResource vs kb_resource
const body = this.options.v2KnowledgeBaseEnabled
? {
kb_resource: doc.metadata.kbResource ?? 'unknown',
// v1 schema has metadata nested in a `metadata` object
if (this.options.v2KnowledgeBaseEnabled) {
return transformToCreateSchema({
createdAt: changedAt,
spaceId: this.spaceId,
user: authenticatedUser,
entry: {
type: DocumentEntryType.value,
name: 'unknown',
text: doc.pageContent,
kbResource: doc.metadata.kbResource ?? 'unknown',
required: doc.metadata.required ?? false,
source: doc.metadata.source ?? 'unknown',
}
: {
},
global,
});
} else {
return transformToLegacyCreateSchema({
createdAt: changedAt,
spaceId: this.spaceId,
user: authenticatedUser,
entry: {
type: DocumentEntryType.value,
name: 'unknown',
text: doc.pageContent,
metadata: {
kbResource: doc.metadata.kbResource ?? 'unknown',
required: doc.metadata.required ?? false,
source: doc.metadata.source ?? 'unknown',
},
};
// @ts-ignore Transform only explicitly supports v2 schema, but technically still supports v1
return transformToCreateSchema(changedAt, this.spaceId, authenticatedUser, {
type: DocumentEntryType.value,
name: 'unknown',
text: doc.pageContent,
...body,
});
},
global,
});
}
}),
authenticatedUser,
});
@ -308,6 +340,18 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient {
return created?.data ? transformESSearchToKnowledgeBaseEntry(created?.data) : [];
};
/**
* Returns if ES|QL KB docs have been loaded
*/
public isESQLDocsLoaded = async (): Promise<boolean> => {
const esqlDocs = await this.getKnowledgeBaseDocumentEntries({
query: ESQL_DOCS_LOADED_QUERY,
// kbResource, // Note: `8.15` installs have kbResource as `unknown`, so don't filter yet
required: true,
});
return esqlDocs.length > 0;
};
/**
* Performs similarity search to retrieve LangChain Documents from the knowledge base
*/
@ -386,13 +430,17 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient {
* Creates a new Knowledge Base Entry.
*
* @param knowledgeBaseEntry
* @param global
*/
public createKnowledgeBaseEntry = async ({
knowledgeBaseEntry,
global = false,
}: {
knowledgeBaseEntry: KnowledgeBaseEntryCreateProps;
knowledgeBaseEntry: KnowledgeBaseEntryCreateProps | LegacyKnowledgeBaseEntryCreateProps;
global?: boolean;
}): Promise<KnowledgeBaseEntryResponse | null> => {
const authenticatedUser = this.options.currentUser;
// TODO: KB-RBAC check for when `global:true`
if (authenticatedUser == null) {
throw new Error(
'Authenticated user not found! Ensure kbDataClient was initialized from a request.'
@ -410,6 +458,8 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient {
spaceId: this.spaceId,
user: authenticatedUser,
knowledgeBaseEntry,
global,
isV2: this.options.v2KnowledgeBaseEnabled,
});
};

View file

@ -26,8 +26,14 @@ import { conversationsFieldMap } from '../ai_assistant_data_clients/conversation
import { assistantPromptsFieldMap } from '../ai_assistant_data_clients/prompts/field_maps_configuration';
import { assistantAnonymizationFieldsFieldMap } from '../ai_assistant_data_clients/anonymization_fields/field_maps_configuration';
import { AIAssistantDataClient } from '../ai_assistant_data_clients';
import { knowledgeBaseFieldMap } from '../ai_assistant_data_clients/knowledge_base/field_maps_configuration';
import { AIAssistantKnowledgeBaseDataClient } from '../ai_assistant_data_clients/knowledge_base';
import {
knowledgeBaseFieldMap,
knowledgeBaseFieldMapV2,
} from '../ai_assistant_data_clients/knowledge_base/field_maps_configuration';
import {
AIAssistantKnowledgeBaseDataClient,
GetAIAssistantKnowledgeBaseDataClientParams,
} from '../ai_assistant_data_clients/knowledge_base';
import { AttackDiscoveryDataClient } from '../ai_assistant_data_clients/attack_discovery';
import { createGetElserId, createPipeline, pipelineExists } from './helpers';
@ -90,7 +96,7 @@ export class AIAssistantService {
this.knowledgeBaseDataStream = this.createDataStream({
resource: 'knowledgeBase',
kibanaVersion: options.kibanaVersion,
fieldMap: knowledgeBaseFieldMap, // TODO: use v2 if FF is enabled
fieldMap: knowledgeBaseFieldMap, // TODO: use V2 if FF is enabled
});
this.promptsDataStream = this.createDataStream({
resource: 'prompts',
@ -173,17 +179,28 @@ export class AIAssistantService {
pluginStop$: this.options.pluginStop$,
});
// If v2 is enabled, re-install data stream resources for new mappings
if (this.v2KnowledgeBaseEnabled) {
this.options.logger.debug(`Using V2 Knowledge Base Mappings`);
this.knowledgeBaseDataStream = this.createDataStream({
resource: 'knowledgeBase',
kibanaVersion: this.options.kibanaVersion,
fieldMap: knowledgeBaseFieldMapV2,
});
}
await this.knowledgeBaseDataStream.install({
esClient,
logger: this.options.logger,
pluginStop$: this.options.pluginStop$,
});
// TODO: Pipeline creation is temporary as we'll be moving to semantic_text field once available in ES
// Note: Pipeline creation can be removed in favor of semantic_text
const pipelineCreated = await pipelineExists({
esClient,
id: this.resourceNames.pipelines.knowledgeBase,
});
// TODO: When FF is removed, ensure pipeline is re-created for those upgrading
if (!pipelineCreated || this.v2KnowledgeBaseEnabled) {
this.options.logger.debug(
`Installing ingest pipeline - ${this.resourceNames.pipelines.knowledgeBase}`
@ -329,12 +346,24 @@ export class AIAssistantService {
}
public async createAIAssistantKnowledgeBaseDataClient(
opts: CreateAIAssistantClientParams & { v2KnowledgeBaseEnabled: boolean }
opts: CreateAIAssistantClientParams & GetAIAssistantKnowledgeBaseDataClientParams
): Promise<AIAssistantKnowledgeBaseDataClient | null> {
// If modelIdOverride is set, swap getElserId(), and ensure the pipeline is re-created with the correct model
if (opts.modelIdOverride != null) {
const modelIdOverride = opts.modelIdOverride;
this.getElserId = async () => modelIdOverride;
}
// Note: Due to plugin lifecycle and feature flag registration timing, we need to pass in the feature flag here
// Remove this param and initialization when the `assistantKnowledgeBaseByDefault` feature flag is removed
if (opts.v2KnowledgeBaseEnabled) {
this.v2KnowledgeBaseEnabled = true;
}
// If either v2 KB or a modelIdOverride is provided, we need to reinitialize all persistence resources to make sure
// they're using the correct model/mappings. Technically all existing KB data is stale since it was created
// with a different model/mappings, but modelIdOverride is only intended for testing purposes at this time
if (opts.v2KnowledgeBaseEnabled || opts.modelIdOverride != null) {
await this.initializeResources();
}
@ -356,7 +385,7 @@ export class AIAssistantService {
ml: this.options.ml,
setIsKBSetupInProgress: this.setIsKBSetupInProgress.bind(this),
spaceId: opts.spaceId,
v2KnowledgeBaseEnabled: opts.v2KnowledgeBaseEnabled,
v2KnowledgeBaseEnabled: opts.v2KnowledgeBaseEnabled ?? false,
});
}

View file

@ -8,7 +8,6 @@
import { Logger } from '@kbn/core/server';
import { addRequiredKbResourceMetadata } from './add_required_kb_resource_metadata';
import { ElasticsearchStore } from '../elasticsearch_store/elasticsearch_store';
import { loadESQL } from './esql_loader';
import {
mockEsqlDocsFromDirectoryLoader,
@ -16,6 +15,7 @@ import {
mockExampleQueryDocsFromDirectoryLoader,
} from '../../../__mocks__/docs_from_directory_loader';
import { ESQL_RESOURCE } from '../../../routes/knowledge_base/constants';
import { AIAssistantKnowledgeBaseDataClient } from '../../../ai_assistant_data_clients/knowledge_base';
let mockLoad = jest.fn();
@ -29,9 +29,9 @@ jest.mock('langchain/document_loaders/fs/text', () => ({
TextLoader: jest.fn().mockImplementation(() => ({})),
}));
const esStore = {
addDocuments: jest.fn().mockResolvedValue(['1', '2', '3', '4', '5']),
} as unknown as ElasticsearchStore;
const kbDataClient = {
addKnowledgeBaseDocuments: jest.fn().mockResolvedValue(['1', '2', '3', '4', '5']),
} as unknown as AIAssistantKnowledgeBaseDataClient;
const logger = {
info: jest.fn(),
@ -51,26 +51,29 @@ describe('loadESQL', () => {
describe('loadESQL', () => {
beforeEach(async () => {
await loadESQL(esStore, logger);
await loadESQL(kbDataClient, logger);
});
it('loads ES|QL docs, language files, and example queries into the Knowledge Base', async () => {
expect(esStore.addDocuments).toHaveBeenCalledWith([
...addRequiredKbResourceMetadata({
docs: mockEsqlDocsFromDirectoryLoader,
kbResource: ESQL_RESOURCE,
required: false,
}),
...addRequiredKbResourceMetadata({
docs: mockEsqlLanguageDocsFromDirectoryLoader,
kbResource: ESQL_RESOURCE,
required: false,
}),
...addRequiredKbResourceMetadata({
docs: mockExampleQueryDocsFromDirectoryLoader,
kbResource: ESQL_RESOURCE,
}),
]);
expect(kbDataClient.addKnowledgeBaseDocuments).toHaveBeenCalledWith({
documents: [
...addRequiredKbResourceMetadata({
docs: mockEsqlDocsFromDirectoryLoader,
kbResource: ESQL_RESOURCE,
required: false,
}),
...addRequiredKbResourceMetadata({
docs: mockEsqlLanguageDocsFromDirectoryLoader,
kbResource: ESQL_RESOURCE,
required: false,
}),
...addRequiredKbResourceMetadata({
docs: mockExampleQueryDocsFromDirectoryLoader,
kbResource: ESQL_RESOURCE,
}),
],
global: true,
});
});
it('logs the expected (distinct) counts for each category of documents', async () => {
@ -91,26 +94,28 @@ describe('loadESQL', () => {
});
it('returns true if documents were loaded', async () => {
(esStore.addDocuments as jest.Mock).mockResolvedValueOnce(['this is a response']);
(kbDataClient.addKnowledgeBaseDocuments as jest.Mock).mockResolvedValueOnce([
'this is a response',
]);
const result = await loadESQL(esStore, logger);
const result = await loadESQL(kbDataClient, logger);
expect(result).toBe(true);
});
it('returns false if documents were NOT loaded', async () => {
(esStore.addDocuments as jest.Mock).mockResolvedValueOnce([]);
(kbDataClient.addKnowledgeBaseDocuments as jest.Mock).mockResolvedValueOnce([]);
const result = await loadESQL(esStore, logger);
const result = await loadESQL(kbDataClient, logger);
expect(result).toBe(false);
});
it('logs the expected error if loading fails', async () => {
const error = new Error('Failed to load documents');
(esStore.addDocuments as jest.Mock).mockRejectedValueOnce(error);
(kbDataClient.addKnowledgeBaseDocuments as jest.Mock).mockRejectedValueOnce(error);
await loadESQL(esStore, logger);
await loadESQL(kbDataClient, logger);
expect(logger.error).toHaveBeenCalledWith(
'Failed to load ES|QL docs, language docs, and example queries into the Knowledge Base\nError: Failed to load documents'

View file

@ -12,17 +12,17 @@ import { resolve } from 'path';
import { Document } from 'langchain/document';
import { Metadata } from '@kbn/elastic-assistant-common';
import { ElasticsearchStore } from '../elasticsearch_store/elasticsearch_store';
import { addRequiredKbResourceMetadata } from './add_required_kb_resource_metadata';
import { ESQL_RESOURCE } from '../../../routes/knowledge_base/constants';
import { AIAssistantKnowledgeBaseDataClient } from '../../../ai_assistant_data_clients/knowledge_base';
/**
* Loads the ESQL docs and language files into the Knowledge Base.
*
* *Item of Interest*
* Knob #1: Types of documents loaded, metadata included, and document chunking strategies + text-splitting
*/
export const loadESQL = async (esStore: ElasticsearchStore, logger: Logger): Promise<boolean> => {
export const loadESQL = async (
kbDataClient: AIAssistantKnowledgeBaseDataClient,
logger: Logger
): Promise<boolean> => {
try {
const docsLoader = new DirectoryLoader(
resolve(__dirname, '../../../knowledge_base/esql/documentation'),
@ -76,11 +76,10 @@ export const loadESQL = async (esStore: ElasticsearchStore, logger: Logger): Pro
`Loading ${docsWithMetadata.length} ES|QL docs, ${languageDocsWithMetadata.length} language docs, and ${requiredExampleQueries.length} example queries into the Knowledge Base`
);
const response = await esStore.addDocuments([
...docsWithMetadata,
...languageDocsWithMetadata,
...requiredExampleQueries,
]);
const response = await kbDataClient.addKnowledgeBaseDocuments({
documents: [...docsWithMetadata, ...languageDocsWithMetadata, ...requiredExampleQueries],
global: true,
});
logger.info(
`Loaded ${

View file

@ -157,6 +157,7 @@ export class ElasticsearchStore extends VectorStore {
try {
const response = await this.kbDataClient.addKnowledgeBaseDocuments({
documents,
global: true,
});
return response.map((doc) => doc.id);
} catch (e) {

View file

@ -95,8 +95,6 @@ export const callAssistantGraph: AgentExecutor<true | false> = async ({
const latestMessage = langChainMessages.slice(-1); // the last message
const modelExists = await esStore.isModelInstalled();
// Create a chain that uses the ELSER backed ElasticsearchStore, override k=10 for esql query generation for now
const chain = RetrievalQAChain.fromLLM(createLlmInstance(), esStore.asRetriever(10));
@ -114,7 +112,7 @@ export const callAssistantGraph: AgentExecutor<true | false> = async ({
isEnabledKnowledgeBase,
kbDataClient: dataClients?.kbDataClient,
logger,
modelExists,
modelExists: isEnabledKnowledgeBase,
onNewReplacements,
replacements,
request,

View file

@ -158,9 +158,9 @@ export const postEvaluateRoute = (
const conversationsDataClient =
(await assistantContext.getAIAssistantConversationsDataClient()) ?? undefined;
const kbDataClient =
(await assistantContext.getAIAssistantKnowledgeBaseDataClient(
v2KnowledgeBaseEnabled
)) ?? undefined;
(await assistantContext.getAIAssistantKnowledgeBaseDataClient({
v2KnowledgeBaseEnabled,
})) ?? undefined;
const dataClients: AssistantDataClients = {
anonymizationFieldsDataClient,
conversationsDataClient,
@ -221,8 +221,6 @@ export const postEvaluateRoute = (
? transformESSearchToAnonymizationFields(anonymizationFieldsRes.data)
: undefined;
const modelExists = await esStore.isModelInstalled();
// Create a chain that uses the ELSER backed ElasticsearchStore, override k=10 for esql query generation for now
const chain = RetrievalQAChain.fromLLM(llm, esStore.asRetriever(10));
@ -257,7 +255,7 @@ export const postEvaluateRoute = (
kbDataClient: dataClients?.kbDataClient,
llm,
logger,
modelExists,
modelExists: isEnabledKnowledgeBase,
request: skeletonRequest,
alertsIndexPattern,
// onNewReplacements,

View file

@ -389,8 +389,9 @@ export const langChainExecute = async ({
// Create an ElasticsearchStore for KB interactions
const kbDataClient =
(await assistantContext.getAIAssistantKnowledgeBaseDataClient(v2KnowledgeBaseEnabled)) ??
undefined;
(await assistantContext.getAIAssistantKnowledgeBaseDataClient({
v2KnowledgeBaseEnabled,
})) ?? undefined;
const bedrockChatEnabled =
assistantContext.getRegisteredFeatures(pluginName).assistantBedrockChat;
const esStore = new ElasticsearchStore(

View file

@ -19,9 +19,6 @@ import {
import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common';
import { buildResponse } from '../../lib/build_response';
import { ElasticAssistantRequestHandlerContext } from '../../types';
import { ElasticsearchStore } from '../../lib/langchain/elasticsearch_store/elasticsearch_store';
import { ESQL_RESOURCE } from './constants';
import { getKbResource } from './get_kb_resource';
import { isV2KnowledgeBaseEnabled } from '../helpers';
/**
@ -53,44 +50,22 @@ export const deleteKnowledgeBaseRoute = (
const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']);
const assistantContext = ctx.elasticAssistant;
const logger = ctx.elasticAssistant.logger;
const telemetry = assistantContext.telemetry;
// FF Check for V2 KB
const v2KnowledgeBaseEnabled = isV2KnowledgeBaseEnabled({ context: ctx, request });
try {
const kbResource = getKbResource(request);
const esClient = (await context.core).elasticsearch.client.asInternalUser;
const knowledgeBaseDataClient =
await assistantContext.getAIAssistantKnowledgeBaseDataClient(v2KnowledgeBaseEnabled);
await assistantContext.getAIAssistantKnowledgeBaseDataClient({
v2KnowledgeBaseEnabled,
});
if (!knowledgeBaseDataClient) {
return response.custom({ body: { success: false }, statusCode: 500 });
}
const esStore = new ElasticsearchStore(
esClient,
knowledgeBaseDataClient.indexTemplateAndPattern.alias,
logger,
telemetry,
'elserId', // Not needed for delete ops
kbResource,
knowledgeBaseDataClient
);
if (kbResource === ESQL_RESOURCE) {
// For now, tearing down the Knowledge Base is fine, but will want to support removing specific assets based
// on resource name or document query
// Implement deleteDocuments(query: string) in ElasticsearchStore
// const success = await esStore.deleteDocuments();
// return const body: DeleteKnowledgeBaseResponse = { success };
}
// Delete index and pipeline
const indexDeleted = await esStore.deleteIndex();
const pipelineDeleted = await esStore.deletePipeline();
// TODO: This delete API is likely not needed and can be replaced by the new `entries` API
const body: DeleteKnowledgeBaseResponse = {
success: indexDeleted && pipelineDeleted,
success: false,
};
return response.ok({ body });

View file

@ -166,9 +166,9 @@ export const bulkActionKnowledgeBaseEntriesRoute = (router: ElasticAssistantPlug
// subscribing to completed$, because it handles both cases when request was completed and aborted.
// when route is finished by timeout, aborted$ is not getting fired
request.events.completed$.subscribe(() => abortController.abort());
const kbDataClient = await ctx.elasticAssistant.getAIAssistantKnowledgeBaseDataClient(
true
);
const kbDataClient = await ctx.elasticAssistant.getAIAssistantKnowledgeBaseDataClient({
v2KnowledgeBaseEnabled: true,
});
const spaceId = ctx.elasticAssistant.getSpaceId();
// Authenticated user null check completed in `performChecks()` above
const authenticatedUser = ctx.elasticAssistant.getCurrentUser() as AuthenticatedUser;
@ -201,8 +201,13 @@ export const bulkActionKnowledgeBaseEntriesRoute = (router: ElasticAssistantPlug
docs_deleted: docsDeleted,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
} = await writer!.bulk({
documentsToCreate: body.create?.map((c) =>
transformToCreateSchema(changedAt, spaceId, authenticatedUser, c)
documentsToCreate: body.create?.map((entry) =>
transformToCreateSchema({
createdAt: changedAt,
spaceId,
user: authenticatedUser,
entry,
})
),
documentsToDelete: body.delete?.ids,
documentsToUpdate: [], // TODO: Support bulk update

View file

@ -59,13 +59,15 @@ export const createKnowledgeBaseEntryRoute = (router: ElasticAssistantPluginRout
}
// Check mappings and upgrade if necessary -- this route only supports v2 KB, so always `true`
const kbDataClient = await ctx.elasticAssistant.getAIAssistantKnowledgeBaseDataClient(
true
);
const kbDataClient = await ctx.elasticAssistant.getAIAssistantKnowledgeBaseDataClient({
v2KnowledgeBaseEnabled: true,
});
logger.debug(() => `Creating KB Entry:\n${JSON.stringify(request.body)}`);
const createResponse = await kbDataClient?.createKnowledgeBaseEntry({
knowledgeBaseEntry: request.body,
// TODO: KB-RBAC check, required when users != null as entry will either be created globally if empty, or for specific users (only admin API feature)
global: request.body.users != null && request.body.users.length === 0,
});
if (createResponse == null) {

View file

@ -10,6 +10,8 @@ import { transformError } from '@kbn/securitysolution-es-utils';
import {
API_VERSIONS,
DocumentEntry,
DocumentEntryType,
ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_FIND,
FindKnowledgeBaseEntriesRequestQuery,
FindKnowledgeBaseEntriesResponse,
@ -22,6 +24,7 @@ import { performChecks } from '../../helpers';
import { transformESSearchToKnowledgeBaseEntry } from '../../../ai_assistant_data_clients/knowledge_base/transforms';
import { EsKnowledgeBaseEntrySchema } from '../../../ai_assistant_data_clients/knowledge_base/types';
import { getKBUserFilter } from './utils';
import { ESQL_RESOURCE } from '../constants';
export const findKnowledgeBaseEntriesRoute = (router: ElasticAssistantPluginRouter) => {
router.versioned
@ -64,29 +67,62 @@ export const findKnowledgeBaseEntriesRoute = (router: ElasticAssistantPluginRout
return checkResponse;
}
const kbDataClient = await ctx.elasticAssistant.getAIAssistantKnowledgeBaseDataClient(
true
);
const kbDataClient = await ctx.elasticAssistant.getAIAssistantKnowledgeBaseDataClient({
v2KnowledgeBaseEnabled: true,
});
const currentUser = ctx.elasticAssistant.getCurrentUser();
const userFilter = getKBUserFilter(currentUser);
const systemFilter = ` AND NOT kb_resource:"${ESQL_RESOURCE}"`;
const additionalFilter = query.filter ? ` AND ${query.filter}` : '';
// TODO: Either plumb through new `findDocuments` that takes query DSL so you can do agg + pagination to collapse
// TODO: system entries, use scoped esClient from request, or query them separate and mess with pagination...latter for now.
const result = await kbDataClient?.findDocuments<EsKnowledgeBaseEntrySchema>({
perPage: query.per_page,
page: query.page,
sortField: query.sort_field,
sortOrder: query.sort_order,
filter: `${userFilter}${additionalFilter}`,
filter: `${userFilter}${systemFilter}${additionalFilter}`,
fields: query.fields,
});
const systemResult = await kbDataClient?.findDocuments<EsKnowledgeBaseEntrySchema>({
perPage: 1000,
page: 1,
filter: `kb_resource:"${ESQL_RESOURCE}"`,
});
// Group system entries
const systemEntry = systemResult?.data.hits.hits?.[0]?._source;
const systemEntryCount = systemResult?.data.hits.hits?.length ?? 1;
const systemEntries: DocumentEntry[] =
systemEntry == null
? []
: [
{
id: 'someID',
createdAt: systemEntry.created_at,
createdBy: systemEntry.created_by,
updatedAt: systemEntry.updated_at,
updatedBy: systemEntry.updated_by,
users: [],
name: 'ES|QL documents',
namespace: systemEntry.namespace,
type: DocumentEntryType.value,
kbResource: ESQL_RESOURCE,
source: '',
required: true,
text: `${systemEntryCount}`,
},
];
if (result) {
return response.ok({
body: {
perPage: result.perPage,
page: result.page,
total: result.total,
data: transformESSearchToKnowledgeBaseEntry(result.data),
data: [...transformESSearchToKnowledgeBaseEntry(result.data), ...systemEntries],
},
});
}

View file

@ -8,5 +8,19 @@
import { AuthenticatedUser } from '@kbn/core-security-common';
export const getKBUserFilter = (user: AuthenticatedUser | null) => {
return user?.profile_uid ? `users.id: "${user?.profile_uid}" or NOT users: *` : 'NOT users: *';
// Only return the current users entries and all other global entries (where user[] is empty)
const globalFilter = 'NOT users: {name:* OR id:* }';
const nameFilter = user?.username ? `users: {name: ${user?.username}}` : '';
const idFilter = user?.profile_uid ? `users: {id: ${user?.profile_uid}}` : '';
const userFilter =
user?.username && user?.profile_uid
? ` OR (${nameFilter} OR ${idFilter})`
: user?.username
? ` OR ${nameFilter}`
: user?.profile_uid
? ` OR ${idFilter}`
: '';
return `(${globalFilter}${userFilter})`;
};

View file

@ -9,17 +9,13 @@ import { getKnowledgeBaseStatusRoute } from './get_knowledge_base_status';
import { serverMock } from '../../__mocks__/server';
import { requestContextMock } from '../../__mocks__/request_context';
import { getGetKnowledgeBaseStatusRequest } from '../../__mocks__/request';
import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
import { AuthenticatedUser } from '@kbn/core-security-common';
describe('Get Knowledge Base Status Route', () => {
let server: ReturnType<typeof serverMock.create>;
// eslint-disable-next-line prefer-const
let { clients, context } = requestContextMock.createTools();
clients.core.elasticsearch.client = elasticsearchServiceMock.createScopedClusterClient();
let { context } = requestContextMock.createTools();
const mockGetElser = jest.fn().mockResolvedValue('.elser_model_2');
const mockUser = {
username: 'my_username',
authentication_realm: {
@ -39,9 +35,10 @@ describe('Get Knowledge Base Status Route', () => {
},
isModelInstalled: jest.fn().mockResolvedValue(true),
isSetupAvailable: jest.fn().mockResolvedValue(true),
isModelDeployed: jest.fn().mockResolvedValue(true),
});
getKnowledgeBaseStatusRoute(server.router, mockGetElser);
getKnowledgeBaseStatusRoute(server.router);
});
describe('Status codes', () => {
@ -52,16 +49,5 @@ describe('Get Knowledge Base Status Route', () => {
);
expect(response.status).toEqual(200);
});
test('returns 500 if error is thrown in checking kb status', async () => {
context.core.elasticsearch.client.asInternalUser.indices.exists.mockRejectedValue(
new Error('Test error')
);
const response = await server.inject(
getGetKnowledgeBaseStatusRequest('esql'),
requestContextMock.convertContext(context)
);
expect(response.status).toEqual(500);
});
});
});

View file

@ -17,21 +17,16 @@ import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/
import { KibanaRequest } from '@kbn/core/server';
import { getKbResource } from './get_kb_resource';
import { buildResponse } from '../../lib/build_response';
import { ElasticAssistantPluginRouter, GetElser } from '../../types';
import { ElasticsearchStore } from '../../lib/langchain/elasticsearch_store/elasticsearch_store';
import { ESQL_DOCS_LOADED_QUERY, ESQL_RESOURCE } from './constants';
import { ElasticAssistantPluginRouter } from '../../types';
import { ESQL_RESOURCE } from './constants';
import { isV2KnowledgeBaseEnabled } from '../helpers';
/**
* Get the status of the Knowledge Base index, pipeline, and resources (collection of documents)
*
* @param router IRouter for registering routes
* @param getElser Function to get the default Elser ID
*/
export const getKnowledgeBaseStatusRoute = (
router: ElasticAssistantPluginRouter,
getElser: GetElser
) => {
export const getKnowledgeBaseStatusRoute = (router: ElasticAssistantPluginRouter) => {
router.versioned
.get({
access: 'internal',
@ -54,39 +49,26 @@ export const getKnowledgeBaseStatusRoute = (
const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']);
const assistantContext = ctx.elasticAssistant;
const logger = ctx.elasticAssistant.logger;
const telemetry = assistantContext.telemetry;
try {
// Use asInternalUser
const esClient = (await context.core).elasticsearch.client.asInternalUser;
const elserId = await getElser();
const kbResource = getKbResource(request);
// FF Check for V2 KB
const v2KnowledgeBaseEnabled = isV2KnowledgeBaseEnabled({ context: ctx, request });
const kbDataClient = await assistantContext.getAIAssistantKnowledgeBaseDataClient(
v2KnowledgeBaseEnabled
);
const kbDataClient = await assistantContext.getAIAssistantKnowledgeBaseDataClient({
v2KnowledgeBaseEnabled,
});
if (!kbDataClient) {
return response.custom({ body: { success: false }, statusCode: 500 });
}
// Use old status checks by overriding esStore to use kbDataClient
const esStore = new ElasticsearchStore(
esClient,
kbDataClient.indexTemplateAndPattern.alias,
logger,
telemetry,
elserId,
kbResource,
kbDataClient
);
const indexExists = await esStore.indexExists();
const pipelineExists = await esStore.pipelineExists();
const modelExists = await esStore.isModelInstalled(elserId);
const indexExists = true; // Installed at startup, always true
const pipelineExists = true; // Installed at startup, always true
const modelExists = await kbDataClient.isModelInstalled();
const setupAvailable = await kbDataClient.isSetupAvailable();
const isModelDeployed = await kbDataClient.isModelDeployed();
const body: ReadKnowledgeBaseResponse = {
elser_exists: modelExists,
@ -96,14 +78,8 @@ export const getKnowledgeBaseStatusRoute = (
pipeline_exists: pipelineExists,
};
if (indexExists && kbResource === ESQL_RESOURCE) {
const esqlExists =
(
await kbDataClient.getKnowledgeBaseDocumentEntries({
query: ESQL_DOCS_LOADED_QUERY,
required: true,
})
).length > 0;
if (indexExists && isModelDeployed && kbResource === ESQL_RESOURCE) {
const esqlExists = await kbDataClient.isESQLDocsLoaded();
return response.ok({ body: { ...body, esql_exists: esqlExists } });
}

View file

@ -19,7 +19,6 @@ describe('Post Knowledge Base Route', () => {
clients.core.elasticsearch.client = elasticsearchServiceMock.createScopedClusterClient();
const mockGetElser = jest.fn().mockResolvedValue('.elser_model_2');
const mockUser = {
username: 'my_username',
authentication_realm: {
@ -40,7 +39,7 @@ describe('Post Knowledge Base Route', () => {
isModelInstalled: jest.fn().mockResolvedValue(true),
});
postKnowledgeBaseRoute(server.router, mockGetElser);
postKnowledgeBaseRoute(server.router);
});
describe('Status codes', () => {

View file

@ -10,14 +10,14 @@ import {
CreateKnowledgeBaseRequestParams,
CreateKnowledgeBaseResponse,
ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_URL,
CreateKnowledgeBaseRequestQuery,
} from '@kbn/elastic-assistant-common';
import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common';
import { IKibanaResponse, KibanaRequest } from '@kbn/core/server';
import { IKibanaResponse } from '@kbn/core/server';
import { buildResponse } from '../../lib/build_response';
import { ElasticAssistantPluginRouter, GetElser } from '../../types';
import { ElasticsearchStore } from '../../lib/langchain/elasticsearch_store/elasticsearch_store';
import { getKbResource } from './get_kb_resource';
import { ElasticAssistantPluginRouter } from '../../types';
import { isV2KnowledgeBaseEnabled } from '../helpers';
import { ESQL_RESOURCE } from './constants';
// Since we're awaiting on ELSER setup, this could take a bit (especially if ML needs to autoscale)
// Consider just returning if attempt was successful, and switch to client polling
@ -26,12 +26,8 @@ const ROUTE_HANDLER_TIMEOUT = 10 * 60 * 1000; // 10 * 60 seconds = 10 minutes
/**
* Load Knowledge Base index, pipeline, and resources (collection of documents)
* @param router
* @param getElser
*/
export const postKnowledgeBaseRoute = (
router: ElasticAssistantPluginRouter,
getElser: GetElser
) => {
export const postKnowledgeBaseRoute = (router: ElasticAssistantPluginRouter) => {
router.versioned
.post({
access: 'internal',
@ -49,46 +45,36 @@ export const postKnowledgeBaseRoute = (
validate: {
request: {
params: buildRouteValidationWithZod(CreateKnowledgeBaseRequestParams),
query: buildRouteValidationWithZod(CreateKnowledgeBaseRequestQuery),
},
},
},
async (
context,
request: KibanaRequest<CreateKnowledgeBaseRequestParams>,
response
): Promise<IKibanaResponse<CreateKnowledgeBaseResponse>> => {
async (context, request, response): Promise<IKibanaResponse<CreateKnowledgeBaseResponse>> => {
const resp = buildResponse(response);
const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']);
const assistantContext = ctx.elasticAssistant;
const logger = ctx.elasticAssistant.logger;
const telemetry = assistantContext.telemetry;
const elserId = await getElser();
const core = ctx.core;
const esClient = core.elasticsearch.client.asInternalUser;
const soClient = core.savedObjects.getClient();
const kbResource = request.params.resource;
// FF Check for V2 KB
const v2KnowledgeBaseEnabled = isV2KnowledgeBaseEnabled({ context: ctx, request });
// Only allow modelId override if FF is enabled as this will re-write the ingest pipeline and break any previous KB entries
// This is only really needed for API integration tests
const modelIdOverride = v2KnowledgeBaseEnabled ? request.query.modelId : undefined;
try {
const knowledgeBaseDataClient =
await assistantContext.getAIAssistantKnowledgeBaseDataClient(v2KnowledgeBaseEnabled);
await assistantContext.getAIAssistantKnowledgeBaseDataClient({
modelIdOverride,
v2KnowledgeBaseEnabled,
});
if (!knowledgeBaseDataClient) {
return response.custom({ body: { success: false }, statusCode: 500 });
}
// Continue to use esStore for loading esql docs until `semantic_text` is available and we can test the new chunking strategy
const esStore = new ElasticsearchStore(
esClient,
knowledgeBaseDataClient.indexTemplateAndPattern.alias,
logger,
telemetry,
elserId,
getKbResource(request),
knowledgeBaseDataClient
);
await knowledgeBaseDataClient.setupKnowledgeBase({ esStore, soClient });
const installEsqlDocs = kbResource === ESQL_RESOURCE;
await knowledgeBaseDataClient.setupKnowledgeBase({ soClient, installEsqlDocs });
return response.ok({ body: { success: true } });
} catch (error) {

View file

@ -157,9 +157,9 @@ export const postActionsConnectorExecuteRoute = (
const v2KnowledgeBaseEnabled =
assistantContext.getRegisteredFeatures(pluginName).assistantKnowledgeBaseByDefault;
const kbDataClient =
(await assistantContext.getAIAssistantKnowledgeBaseDataClient(
v2KnowledgeBaseEnabled
)) ?? undefined;
(await assistantContext.getAIAssistantKnowledgeBaseDataClient({
v2KnowledgeBaseEnabled,
})) ?? undefined;
const isEnabledKnowledgeBase = await getIsKnowledgeBaseEnabled(kbDataClient);
telemetry.reportEvent(INVOKE_ASSISTANT_ERROR_EVENT.eventType, {

View file

@ -18,7 +18,6 @@ import { updateConversationRoute } from './user_conversations/update_route';
import { findUserConversationsRoute } from './user_conversations/find_route';
import { bulkActionConversationsRoute } from './user_conversations/bulk_actions_route';
import { appendConversationMessageRoute } from './user_conversations/append_conversation_messages_route';
import { deleteKnowledgeBaseRoute } from './knowledge_base/delete_knowledge_base';
import { getKnowledgeBaseStatusRoute } from './knowledge_base/get_knowledge_base_status';
import { postKnowledgeBaseRoute } from './knowledge_base/post_knowledge_base';
import { getEvaluateRoute } from './evaluate/get_evaluate';
@ -61,9 +60,8 @@ export const registerRoutes = (
findUserConversationsRoute(router);
// Knowledge Base Setup
deleteKnowledgeBaseRoute(router);
getKnowledgeBaseStatusRoute(router, getElserId);
postKnowledgeBaseRoute(router, getElserId);
getKnowledgeBaseStatusRoute(router);
postKnowledgeBaseRoute(router);
// Knowledge Base Entries
findKnowledgeBaseEntriesRoute(router);

View file

@ -84,16 +84,21 @@ export class RequestContextFactory implements IRequestContextFactory {
telemetry: core.analytics,
// Note: Due to plugin lifecycle and feature flag registration timing, we need to pass in the feature flag here
// Remove `initializeKnowledgeBase` once 'assistantKnowledgeBaseByDefault' feature flag is removed
getAIAssistantKnowledgeBaseDataClient: memoize((v2KnowledgeBaseEnabled = false) => {
const currentUser = getCurrentUser();
return this.assistantService.createAIAssistantKnowledgeBaseDataClient({
spaceId: getSpaceId(),
logger: this.logger,
currentUser,
v2KnowledgeBaseEnabled,
});
}),
// Remove `v2KnowledgeBaseEnabled` once 'assistantKnowledgeBaseByDefault' feature flag is removed
// Additionally, modelIdOverride is used here to enable setting up the KB using a different ELSER model, which
// is necessary for testing purposes (`pt_tiny_elser`).
getAIAssistantKnowledgeBaseDataClient: memoize(
({ modelIdOverride, v2KnowledgeBaseEnabled = false }) => {
const currentUser = getCurrentUser();
return this.assistantService.createAIAssistantKnowledgeBaseDataClient({
spaceId: getSpaceId(),
logger: this.logger,
currentUser,
modelIdOverride,
v2KnowledgeBaseEnabled,
});
}
),
getAttackDiscoveryDataClient: memoize(() => {
const currentUser = getCurrentUser();

View file

@ -46,6 +46,7 @@ import {
} from '@kbn/langchain/server';
import type { InferenceServerStart } from '@kbn/inference-plugin/server';
import type { GetAIAssistantKnowledgeBaseDataClientParams } from './ai_assistant_data_clients/knowledge_base';
import { AttackDiscoveryDataClient } from './ai_assistant_data_clients/attack_discovery';
import { AIAssistantConversationsDataClient } from './ai_assistant_data_clients/conversations';
import type { GetRegisteredFeatures, GetRegisteredTools } from './services/app_context';
@ -126,7 +127,7 @@ export interface ElasticAssistantApiRequestHandlerContext {
getCurrentUser: () => AuthenticatedUser | null;
getAIAssistantConversationsDataClient: () => Promise<AIAssistantConversationsDataClient | null>;
getAIAssistantKnowledgeBaseDataClient: (
v2KnowledgeBaseEnabled?: boolean
params: GetAIAssistantKnowledgeBaseDataClientParams
) => Promise<AIAssistantKnowledgeBaseDataClient | null>;
getAttackDiscoveryDataClient: () => Promise<AttackDiscoveryDataClient | null>;
getAIAssistantPromptsDataClient: () => Promise<AIAssistantDataClient | null>;

View file

@ -16,6 +16,7 @@ import {
} from '@kbn/elastic-assistant';
import { useKibana } from '../../common/lib/kibana';
import { useConversation } from '@kbn/elastic-assistant/impl/assistant/use_conversation';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// Mock the necessary hooks and components
jest.mock('@kbn/elastic-assistant', () => ({
@ -40,9 +41,11 @@ const useKibanaMock = useKibana as jest.Mock;
const useConversationMock = useConversation as jest.Mock;
describe('ManagementSettings', () => {
const queryClient = new QueryClient();
const baseConversations = { base: 'conversation' };
const http = {};
const getDefaultConversation = jest.fn();
const setCurrentUserAvatar = jest.fn();
const navigateToApp = jest.fn();
const mockConversations = {
[WELCOME_CONVERSATION_TITLE]: { title: WELCOME_CONVERSATION_TITLE },
@ -59,6 +62,7 @@ describe('ManagementSettings', () => {
baseConversations,
http,
assistantAvailability: { isAssistantEnabled },
setCurrentUserAvatar,
});
useFetchCurrentUserConversationsMock.mockReturnValue({
@ -73,6 +77,11 @@ describe('ManagementSettings', () => {
securitySolutionAssistant: { 'ai-assistant': false },
},
},
security: {
userProfiles: {
getCurrent: jest.fn().mockResolvedValue({ data: { color: 'blue', initials: 'P' } }),
},
},
},
});
@ -80,7 +89,11 @@ describe('ManagementSettings', () => {
getDefaultConversation,
});
return render(<ManagementSettings />);
return render(
<QueryClientProvider client={queryClient}>
<ManagementSettings />
</QueryClientProvider>
);
};
beforeEach(() => {

View file

@ -16,6 +16,8 @@ import {
} from '@kbn/elastic-assistant';
import { useConversation } from '@kbn/elastic-assistant/impl/assistant/use_conversation';
import type { FetchConversationsResponse } from '@kbn/elastic-assistant/impl/assistant/api';
import { useQuery } from '@tanstack/react-query';
import type { UserAvatar } from '@kbn/elastic-assistant/impl/assistant_context';
import { useKibana } from '../../common/lib/kibana';
const defaultSelectedConversationId = WELCOME_CONVERSATION_TITLE;
@ -25,6 +27,7 @@ export const ManagementSettings = React.memo(() => {
baseConversations,
http,
assistantAvailability: { isAssistantEnabled },
setCurrentUserAvatar,
} = useAssistantContext();
const {
@ -34,8 +37,23 @@ export const ManagementSettings = React.memo(() => {
securitySolutionAssistant: { 'ai-assistant': securityAIAssistantEnabled },
},
},
security,
} = useKibana().services;
const { data: currentUserAvatar } = useQuery({
queryKey: ['currentUserAvatar'],
queryFn: () =>
security?.userProfiles.getCurrent<{ avatar: UserAvatar }>({
dataPath: 'avatar',
}),
select: (data) => {
return data.data.avatar;
},
keepPreviousData: true,
refetchOnWindowFocus: false,
});
setCurrentUserAvatar(currentUserAvatar);
const onFetchedConversations = useCallback(
(conversationsData: FetchConversationsResponse): Record<string, Conversation> =>
mergeBaseWithPersistedConversations(baseConversations, conversationsData),

View file

@ -11,6 +11,7 @@ import type { AssistantTool, AssistantToolParams } from '@kbn/elastic-assistant-
import type { AIAssistantKnowledgeBaseDataClient } from '@kbn/elastic-assistant-plugin/server/ai_assistant_data_clients/knowledge_base';
import { DocumentEntryType } from '@kbn/elastic-assistant-common';
import type { KnowledgeBaseEntryCreateProps } from '@kbn/elastic-assistant-common';
import type { LegacyKnowledgeBaseEntryCreateProps } from '@kbn/elastic-assistant-plugin/server/ai_assistant_data_clients/knowledge_base/create_knowledge_base_entry';
import { APP_UI_ID } from '../../../../common';
export interface KnowledgeBaseWriteToolParams extends AssistantToolParams {
@ -56,21 +57,24 @@ export const KNOWLEDGE_BASE_WRITE_TOOL: AssistantTool = {
() => `KnowledgeBaseWriteToolParams:input\n ${JSON.stringify(input, null, 2)}`
);
// Backwards compatibility with v1 schema -- createKnowledgeBaseEntry() technically supports both for now
const knowledgeBaseEntry: KnowledgeBaseEntryCreateProps =
kbDataClient.isV2KnowledgeBaseEnabled
? {
name: input.name,
kbResource: 'user',
source: 'conversation',
required: input.required,
text: input.query,
type: DocumentEntryType.value,
}
: ({
metadata: { kbResource: 'user', source: 'conversation', required: input.required },
text: input.query,
} as unknown as KnowledgeBaseEntryCreateProps);
// Backwards compatibility with v1 schema since this feature is technically supported in `8.15`
const knowledgeBaseEntry:
| KnowledgeBaseEntryCreateProps
| LegacyKnowledgeBaseEntryCreateProps = kbDataClient.isV2KnowledgeBaseEnabled
? {
name: input.name,
kbResource: 'user',
source: 'conversation',
required: input.required,
text: input.query,
type: DocumentEntryType.value,
}
: {
type: DocumentEntryType.value,
name: 'unknown',
metadata: { kbResource: 'user', source: 'conversation', required: input.required },
text: input.query,
};
logger.debug(() => `knowledgeBaseEntry\n ${JSON.stringify(knowledgeBaseEntry, null, 2)}`);
const resp = await kbDataClient.createKnowledgeBaseEntry({ knowledgeBaseEntry });

View file

@ -554,14 +554,13 @@ export class Plugin implements ISecuritySolutionPlugin {
APP_UI_ID,
getAssistantTools(config.experimentalFeatures.assistantNaturalLanguageESQLTool)
);
plugins.elasticAssistant.registerFeatures(APP_UI_ID, {
const features = {
assistantBedrockChat: config.experimentalFeatures.assistantBedrockChat,
assistantKnowledgeBaseByDefault: config.experimentalFeatures.assistantKnowledgeBaseByDefault,
assistantModelEvaluation: config.experimentalFeatures.assistantModelEvaluation,
});
plugins.elasticAssistant.registerFeatures('management', {
assistantModelEvaluation: config.experimentalFeatures.assistantModelEvaluation,
});
};
plugins.elasticAssistant.registerFeatures(APP_UI_ID, features);
plugins.elasticAssistant.registerFeatures('management', features);
if (this.lists && plugins.taskManager && plugins.fleet) {
// Exceptions, Artifacts and Manifests start

View file

@ -15043,7 +15043,6 @@
"xpack.elasticAssistant.assistant.settings.flyout.cancelButtonTitle": "Annuler",
"xpack.elasticAssistant.assistant.settings.flyout.saveButtonTitle": "Enregistrer",
"xpack.elasticAssistant.assistant.settings.knowledgeBasedSetting.knowledgeBaseDescription": "Propulsée par ELSER, la base de connaissances permet à l'Assistant d'IA de rappeler des documents et d'autres contextes pertinents dans votre conversation. Pour plus d'informations sur l'accès utilisateur, consultez notre {documentation}.",
"xpack.elasticAssistant.assistant.settings.knowledgeBasedSettingManagements.knowledgeBaseDescription": "Propulsée par ELSER, la base de connaissances permet à l'Assistant d'IA de rappeler des documents et d'autres contextes pertinents dans votre conversation. Pour plus d'informations sur l'accès utilisateur, consultez notre {documentation}.",
"xpack.elasticAssistant.assistant.settings.knowledgeBasedSettings.knowledgeBaseDescription": "Pour commencer, configurez ELSER dans {machineLearning}. {seeDocs}",
"xpack.elasticAssistant.assistant.settings.knowledgeBaseSettings.alertsLabel": "Alertes",
"xpack.elasticAssistant.assistant.settings.knowledgeBaseSettings.alertsRangeSliderLabel": "Plage d'alertes",

View file

@ -14789,7 +14789,6 @@
"xpack.elasticAssistant.assistant.settings.flyout.cancelButtonTitle": "キャンセル",
"xpack.elasticAssistant.assistant.settings.flyout.saveButtonTitle": "保存",
"xpack.elasticAssistant.assistant.settings.knowledgeBasedSetting.knowledgeBaseDescription": "ELSERを活用したナレッジベースは、AI Assistantによって、会話の中でドキュメントやその他の関連するコンテキストを呼び出すことができます。ユーザーアクセスの詳細については、{documentation}を参照してください。",
"xpack.elasticAssistant.assistant.settings.knowledgeBasedSettingManagements.knowledgeBaseDescription": "ELSERを活用したナレッジベースは、AI Assistantによって、会話の中でドキュメントやその他の関連するコンテキストを呼び出すことができます。ユーザーアクセスの詳細については、{documentation}を参照してください。",
"xpack.elasticAssistant.assistant.settings.knowledgeBasedSettings.knowledgeBaseDescription": "{machineLearning}内でELSERを構成して開始します。{seeDocs}",
"xpack.elasticAssistant.assistant.settings.knowledgeBaseSettings.alertsLabel": "アラート",
"xpack.elasticAssistant.assistant.settings.knowledgeBaseSettings.alertsRangeSliderLabel": "アラート範囲",

View file

@ -14817,7 +14817,6 @@
"xpack.elasticAssistant.assistant.settings.flyout.cancelButtonTitle": "取消",
"xpack.elasticAssistant.assistant.settings.flyout.saveButtonTitle": "保存",
"xpack.elasticAssistant.assistant.settings.knowledgeBasedSetting.knowledgeBaseDescription": "使用由 ELSER 提供支持的知识库AI 助手可以在对话中重复调用文档和其他相关上下文。有关用户访问权限的更多信息,请参阅我们的 {documentation}。",
"xpack.elasticAssistant.assistant.settings.knowledgeBasedSettingManagements.knowledgeBaseDescription": "使用由 ELSER 提供支持的知识库AI 助手可以在对话中重复调用文档和其他相关上下文。有关用户访问权限的更多信息,请参阅我们的 {documentation}。",
"xpack.elasticAssistant.assistant.settings.knowledgeBasedSettings.knowledgeBaseDescription": "在 {machineLearning} 中配置 ELSER 以开始。{seeDocs}",
"xpack.elasticAssistant.assistant.settings.knowledgeBaseSettings.alertsLabel": "告警",
"xpack.elasticAssistant.assistant.settings.knowledgeBaseSettings.alertsRangeSliderLabel": "告警范围",

View file

@ -1,60 +0,0 @@
/*
* 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 expect from 'expect';
import {
DocumentEntryCreateFields,
DocumentEntryType,
IndexEntryCreateFields,
IndexEntryType,
} from '@kbn/elastic-assistant-common';
import { FtrProviderContext } from '../../../../../ftr_provider_context';
import { createEntry } from '../utils/create_entry';
const documentEntry: DocumentEntryCreateFields = {
name: 'Sample Document Entry',
type: DocumentEntryType.value,
required: false,
source: 'api',
kbResource: 'user',
namespace: 'default',
text: 'This is a sample document entry',
users: [],
};
const indexEntry: IndexEntryCreateFields = {
name: 'Sample Index Entry',
type: IndexEntryType.value,
namespace: 'default',
index: 'sample-index',
field: 'sample-field',
description: 'This is a sample index entry',
users: [],
queryDescription: 'Use sample-field to search in sample-index',
};
export default ({ getService }: FtrProviderContext) => {
const supertest = getService('supertest');
const log = getService('log');
// TODO: Fill out tests
describe.skip('@ess @serverless Basic Security AI Assistant Knowledge Base Entries', () => {
describe('Create Entries', () => {
it('should create a new document entry', async () => {
const entry = await createEntry(supertest, log, documentEntry);
expect(entry).toEqual(documentEntry);
});
it('should create a new index entry', async () => {
const entry = await createEntry(supertest, log, indexEntry);
expect(entry).toEqual(indexEntry);
});
});
});
};

View file

@ -16,11 +16,38 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
...functionalConfig.getAll(),
kbnTestServer: {
...functionalConfig.get('kbnTestServer'),
serverArgs: [...functionalConfig.get('kbnTestServer.serverArgs')],
serverArgs: [
...functionalConfig
.get('kbnTestServer.serverArgs')
// ssl: false as ML vocab API is broken with SSL enabled
.filter(
(a: string) =>
!(
a.startsWith('--elasticsearch.hosts=') ||
a.startsWith('--elasticsearch.ssl.certificateAuthorities=')
)
),
'--elasticsearch.hosts=http://localhost:9220',
`--xpack.securitySolution.enableExperimental=${JSON.stringify([
'assistantKnowledgeBaseByDefault',
])}`,
],
},
testFiles: [require.resolve('..')],
junit: {
reportName: 'GenAI - Knowledge Base Entries Tests - ESS Env - Trial License',
},
// ssl: false as ML vocab API is broken with SSL enabled
servers: {
...functionalConfig.get('servers'),
elasticsearch: {
...functionalConfig.get('servers.elasticsearch'),
protocol: 'http',
},
},
esTestCluster: {
...functionalConfig.get('esTestCluster'),
ssl: false,
},
};
}

View file

@ -8,7 +8,16 @@
import { createTestConfig } from '../../../../../../config/serverless/config.base';
export default createTestConfig({
kbnTestServerArgs: [],
kbnTestServerArgs: [
`--xpack.securitySolution.enableExperimental=${JSON.stringify([
'assistantKnowledgeBaseByDefault',
])}`,
`--xpack.securitySolutionServerless.productTypes=${JSON.stringify([
{ product_line: 'security', product_tier: 'complete' },
{ product_line: 'endpoint', product_tier: 'complete' },
{ product_line: 'cloud', product_tier: 'complete' },
])}`,
],
testFiles: [require.resolve('..')],
junit: {
reportName: 'GenAI - Knowledge Base Entries Tests - Serverless Env - Complete Tier',

View file

@ -0,0 +1,192 @@
/*
* 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 expect from 'expect';
import { FtrProviderContext } from '../../../../../ftr_provider_context';
import { createEntry, createEntryForUser } from '../utils/create_entry';
import { findEntries } from '../utils/find_entry';
import {
clearKnowledgeBase,
deleteTinyElser,
installTinyElser,
setupKnowledgeBase,
} from '../utils/helpers';
import { removeServerGeneratedProperties } from '../utils/remove_server_generated_properties';
import { MachineLearningProvider } from '../../../../../../functional/services/ml';
import { documentEntry, indexEntry, globalDocumentEntry } from './mocks/entries';
import { secOnlySpacesAll } from '../utils/auth/users';
export default ({ getService }: FtrProviderContext) => {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const log = getService('log');
const es = getService('es');
const ml = getService('ml') as ReturnType<typeof MachineLearningProvider>;
describe('@ess Basic Security AI Assistant Knowledge Base Entries', () => {
before(async () => {
await installTinyElser(ml);
await setupKnowledgeBase(supertest, log);
});
after(async () => {
await deleteTinyElser(ml);
});
afterEach(async () => {
await clearKnowledgeBase(es);
});
describe('Create Entries', () => {
// TODO: KB-RBAC: Added stubbed admin tests for when RBAC is enabled. Hopefully this helps :]
// NOTE: Will need to update each section with the expected user, can use `createEntryForUser()` helper
describe('Admin User', () => {
it('should create a new document entry for the current user', async () => {
const entry = await createEntry({ supertest, log, entry: documentEntry });
const expectedDocumentEntry = {
...documentEntry,
users: [{ name: 'elastic' }],
};
expect(removeServerGeneratedProperties(entry)).toEqual(expectedDocumentEntry);
});
it('should create a new index entry for the current user', async () => {
const entry = await createEntry({ supertest, log, entry: indexEntry });
const expectedIndexEntry = {
...indexEntry,
inputSchema: [],
outputFields: [],
users: [{ name: 'elastic' }],
};
expect(removeServerGeneratedProperties(entry)).toEqual(expectedIndexEntry);
});
it('should create a new global entry for all users', async () => {
const entry = await createEntry({ supertest, log, entry: globalDocumentEntry });
expect(removeServerGeneratedProperties(entry)).toEqual(globalDocumentEntry);
});
it('should create a new global entry for all users in another space', async () => {
const entry = await createEntry({
supertest,
log,
entry: globalDocumentEntry,
space: 'space-x',
});
const expectedDocumentEntry = {
...globalDocumentEntry,
namespace: 'space-x',
};
expect(removeServerGeneratedProperties(entry)).toEqual(expectedDocumentEntry);
});
});
describe('Non-Admin User', () => {
it('should create a new document entry', async () => {
const entry = await createEntry({ supertest, log, entry: documentEntry });
const expectedDocumentEntry = {
...documentEntry,
users: [{ name: 'elastic' }],
};
expect(removeServerGeneratedProperties(entry)).toEqual(expectedDocumentEntry);
});
it('should create a new index entry', async () => {
const entry = await createEntry({ supertest, log, entry: indexEntry });
const expectedIndexEntry = {
...indexEntry,
inputSchema: [],
outputFields: [],
users: [{ name: 'elastic' }],
};
expect(removeServerGeneratedProperties(entry)).toEqual(expectedIndexEntry);
});
it('should not be able to create an entry for another user', async () => {
const entry = await createEntry({
supertest,
log,
entry: {
...documentEntry,
users: [{ name: 'george' }],
},
});
const expectedDocumentEntry = {
...documentEntry,
users: [{ name: 'elastic' }],
};
expect(removeServerGeneratedProperties(entry)).toEqual(expectedDocumentEntry);
});
// TODO: KB-RBAC: Action not currently limited without RBAC
it.skip('should not be able to create a global entry', async () => {
const entry = await createEntry({ supertest, log, entry: globalDocumentEntry });
const expectedDocumentEntry = {
...globalDocumentEntry,
users: [{ name: 'elastic' }],
};
expect(removeServerGeneratedProperties(entry)).toEqual(expectedDocumentEntry);
});
});
});
describe('Find Entries', () => {
it('should see other users global entries', async () => {
const users = [secOnlySpacesAll];
await Promise.all(
users.map((user) =>
createEntryForUser({
supertestWithoutAuth,
log,
entry: globalDocumentEntry,
user,
})
)
);
const entries = await findEntries({ supertest, log });
expect(entries.total).toEqual(1);
});
it('should not see other users private entries', async () => {
const users = [secOnlySpacesAll];
await Promise.all(
users.map((user) =>
createEntryForUser({
supertestWithoutAuth,
log,
entry: documentEntry,
user,
})
)
);
const entries = await findEntries({ supertest, log });
expect(entries.total).toEqual(0);
});
});
});
};

View file

@ -6,9 +6,18 @@
*/
import { FtrProviderContext } from '../../../../../ftr_provider_context';
import { createSpacesAndUsers, deleteSpacesAndUsers } from '../utils/auth';
export default function ({ loadTestFile }: FtrProviderContext) {
export default function ({ loadTestFile, getService }: FtrProviderContext) {
describe('GenAI - Knowledge Base Entries APIs', function () {
loadTestFile(require.resolve('./basic'));
before(async () => {
await createSpacesAndUsers(getService);
});
after(async () => {
await deleteSpacesAndUsers(getService);
});
loadTestFile(require.resolve('./entries'));
});
}

View file

@ -0,0 +1,47 @@
/*
* 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 {
DocumentEntryCreateFields,
DocumentEntryType,
IndexEntryCreateFields,
IndexEntryType,
} from '@kbn/elastic-assistant-common';
export const documentEntry: DocumentEntryCreateFields = {
name: 'Sample Document Entry',
type: DocumentEntryType.value,
required: false,
source: 'api',
kbResource: 'user',
namespace: 'default',
text: 'This is a sample document entry',
users: undefined,
};
export const globalDocumentEntry: DocumentEntryCreateFields = {
...documentEntry,
name: 'Sample Global Document Entry',
users: [],
};
export const indexEntry: IndexEntryCreateFields = {
name: 'Sample Index Entry',
type: IndexEntryType.value,
namespace: 'default',
index: 'sample-index',
field: 'sample-field',
description: 'This is a sample index entry',
queryDescription: 'Use sample-field to search in sample-index',
users: undefined,
};
export const globalIndexEntry: IndexEntryCreateFields = {
...indexEntry,
name: 'Sample Global Index Entry',
users: [],
};

View file

@ -0,0 +1,105 @@
/*
* 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 type { FtrProviderContext } from '../../../../../../ftr_provider_context';
import { Role, User, UserInfo } from './types';
import { allUsers } from './users';
import { allRoles } from './roles';
import { spaces } from './spaces';
export const getUserInfo = (user: User): UserInfo => ({
username: user.username,
full_name: user.username.replace('_', ' '),
email: `${user.username}@elastic.co`,
});
export const createSpaces = async (getService: FtrProviderContext['getService']) => {
const spacesService = getService('spaces');
for (const space of spaces) {
await spacesService.create(space);
}
};
/**
* Creates the users and roles for use in the tests. Defaults to specific users and roles used by the security_and_spaces
* scenarios but can be passed specific ones as well.
*/
export const createUsersAndRoles = async (
getService: FtrProviderContext['getService'],
usersToCreate: User[] = allUsers,
rolesToCreate: Role[] = allRoles
) => {
const security = getService('security');
const createRole = async ({ name, privileges }: Role) => {
return security.role.create(name, privileges);
};
const createUser = async (user: User) => {
const userInfo = getUserInfo(user);
return security.user.create(user.username, {
password: user.password,
roles: user.roles,
full_name: userInfo.full_name,
email: userInfo.email,
});
};
for (const role of rolesToCreate) {
await createRole(role);
}
for (const user of usersToCreate) {
await createUser(user);
}
};
export const deleteSpaces = async (getService: FtrProviderContext['getService']) => {
const spacesService = getService('spaces');
for (const space of spaces) {
try {
await spacesService.delete(space.id);
} catch (error) {
// ignore errors because if a migration is run it will delete the .kibana index which remove the spaces and users
}
}
};
export const deleteUsersAndRoles = async (
getService: FtrProviderContext['getService'],
usersToDelete: User[] = allUsers,
rolesToDelete: Role[] = allRoles
) => {
const security = getService('security');
for (const user of usersToDelete) {
try {
await security.user.delete(user.username);
} catch (error) {
// ignore errors because if a migration is run it will delete the .kibana index which remove the spaces and users
}
}
for (const role of rolesToDelete) {
try {
await security.role.delete(role.name);
} catch (error) {
// ignore errors because if a migration is run it will delete the .kibana index which remove the spaces and users
}
}
};
export const createSpacesAndUsers = async (getService: FtrProviderContext['getService']) => {
await createSpaces(getService);
await createUsersAndRoles(getService);
};
export const deleteSpacesAndUsers = async (getService: FtrProviderContext['getService']) => {
await deleteSpaces(getService);
await deleteUsersAndRoles(getService);
};

View file

@ -0,0 +1,199 @@
/*
* 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 { Role } from './types';
export const noKibanaPrivileges: Role = {
name: 'no_kibana_privileges',
privileges: {
elasticsearch: {
indices: [],
},
},
};
export const globalRead: Role = {
name: 'global_read',
privileges: {
elasticsearch: {
indices: [],
},
kibana: [
{
base: ['read'],
spaces: ['*'],
},
],
},
};
export const securitySolutionOnlyAll: Role = {
name: 'sec_only_all_spaces_space1',
privileges: {
elasticsearch: {
indices: [],
},
kibana: [
{
feature: {
siem: ['all'],
securitySolutionAssistant: ['all'],
securitySolutionAttackDiscovery: ['all'],
aiAssistantManagementSelection: ['all'],
},
spaces: ['space1'],
},
],
},
};
export const securitySolutionOnlyAllSpace2: Role = {
name: 'sec_only_all_spaces_space2',
privileges: {
elasticsearch: {
indices: [],
},
kibana: [
{
feature: {
siem: ['all'],
securitySolutionAssistant: ['all'],
securitySolutionAttackDiscovery: ['all'],
aiAssistantManagementSelection: ['all'],
},
spaces: ['space2'],
},
],
},
};
export const securitySolutionOnlyRead: Role = {
name: 'sec_only_read_spaces_space1',
privileges: {
elasticsearch: {
indices: [],
},
kibana: [
{
feature: {
siem: ['read'],
securitySolutionAssistant: ['all'],
securitySolutionAttackDiscovery: ['all'],
aiAssistantManagementSelection: ['all'],
},
spaces: ['space1'],
},
],
},
};
export const securitySolutionOnlyReadSpace2: Role = {
name: 'sec_only_read_spaces_space2',
privileges: {
elasticsearch: {
indices: [],
},
kibana: [
{
feature: {
siem: ['read'],
securitySolutionAssistant: ['all'],
securitySolutionAttackDiscovery: ['all'],
aiAssistantManagementSelection: ['all'],
},
spaces: ['space2'],
},
],
},
};
/**
* These roles have access to all spaces.
*/
export const securitySolutionOnlyAllSpacesAll: Role = {
name: 'sec_only_all_spaces_all',
privileges: {
elasticsearch: {
indices: [],
},
kibana: [
{
feature: {
siem: ['all'],
securitySolutionAssistant: ['all'],
securitySolutionAttackDiscovery: ['all'],
aiAssistantManagementSelection: ['all'],
},
spaces: ['*'],
},
],
},
};
export const securitySolutionOnlyAllSpacesAllWithReadESIndices: Role = {
name: 'sec_only_all_spaces_all_with_read_es_indices',
privileges: {
elasticsearch: {
indices: [
{
names: ['*'],
privileges: ['all'],
},
],
},
kibana: [
{
feature: {
siem: ['all'],
securitySolutionAssistant: ['all'],
securitySolutionAttackDiscovery: ['all'],
aiAssistantManagementSelection: ['all'],
},
spaces: ['*'],
},
],
},
};
export const securitySolutionOnlyReadSpacesAll: Role = {
name: 'sec_only_read_spaces_all',
privileges: {
elasticsearch: {
indices: [],
},
kibana: [
{
feature: {
siem: ['read'],
securitySolutionAssistant: ['all'],
securitySolutionAttackDiscovery: ['all'],
aiAssistantManagementSelection: ['all'],
},
spaces: ['*'],
},
],
},
};
export const roles = [
noKibanaPrivileges,
globalRead,
securitySolutionOnlyAll,
securitySolutionOnlyRead,
];
export const allRoles = [
noKibanaPrivileges,
globalRead,
securitySolutionOnlyAll,
securitySolutionOnlyRead,
securitySolutionOnlyAllSpacesAll,
securitySolutionOnlyAllSpacesAllWithReadESIndices,
securitySolutionOnlyReadSpacesAll,
securitySolutionOnlyAllSpace2,
securitySolutionOnlyReadSpace2,
];

View file

@ -0,0 +1,32 @@
/*
* 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 { Space } from './types';
const space1: Space = {
id: 'space1',
name: 'Space 1',
disabledFeatures: [],
};
const space2: Space = {
id: 'space2',
name: 'Space 2',
disabledFeatures: [],
};
const other: Space = {
id: 'other',
name: 'Other Space',
disabledFeatures: [],
};
export const spaces: Space[] = [space1, space2, other];
export const getSpaceUrlPrefix = (spaceId?: string) => {
return spaceId && spaceId !== 'default' ? `/s/${spaceId}` : ``;
};

View file

@ -0,0 +1,54 @@
/*
* 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.
*/
export interface Space {
id: string;
namespace?: string;
name: string;
disabledFeatures: string[];
}
export interface User {
username: string;
password: string;
description?: string;
roles: string[];
}
export interface UserInfo {
username: string;
full_name: string;
email: string;
}
interface FeaturesPrivileges {
[featureId: string]: string[];
}
interface ElasticsearchIndices {
names: string[];
privileges: string[];
}
export interface ElasticSearchPrivilege {
cluster?: string[];
indices?: ElasticsearchIndices[];
}
export interface KibanaPrivilege {
spaces: string[];
base?: string[];
feature?: FeaturesPrivileges;
}
export interface Role {
name: string;
privileges: {
elasticsearch?: ElasticSearchPrivilege;
kibana?: KibanaPrivilege[];
};
}

View file

@ -0,0 +1,100 @@
/*
* 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 {
securitySolutionOnlyAll,
securitySolutionOnlyRead,
globalRead as globalReadRole,
noKibanaPrivileges as noKibanaPrivilegesRole,
securitySolutionOnlyAllSpacesAll,
securitySolutionOnlyReadSpacesAll,
// trial license roles
securitySolutionOnlyAllSpace2,
securitySolutionOnlyReadSpace2,
securitySolutionOnlyAllSpacesAllWithReadESIndices,
} from './roles';
import { User } from './types';
export const superUser: User = {
username: 'superuser',
password: 'superuser',
roles: ['superuser'],
};
export const secOnly: User = {
username: 'sec_only_all_spaces_space1',
password: 'sec_only_all_spaces_space1',
roles: [securitySolutionOnlyAll.name],
};
export const secOnlySpace2: User = {
username: 'sec_only_all_spaces_space2',
password: 'sec_only_all_spaces_space2',
roles: [securitySolutionOnlyAllSpace2.name],
};
export const secOnlyRead: User = {
username: 'sec_only_read_spaces_space1',
password: 'sec_only_read_spaces_space1',
roles: [securitySolutionOnlyRead.name],
};
export const secOnlyReadSpace2: User = {
username: 'sec_only_read_spaces_space2',
password: 'sec_only_read_spaces_space2',
roles: [securitySolutionOnlyReadSpace2.name],
};
export const globalRead: User = {
username: 'global_read',
password: 'global_read',
roles: [globalReadRole.name],
};
export const noKibanaPrivileges: User = {
username: 'no_kibana_privileges',
password: 'no_kibana_privileges',
roles: [noKibanaPrivilegesRole.name],
};
export const users = [superUser, secOnly, secOnlyRead, globalRead, noKibanaPrivileges];
/**
* These users will have access to all spaces.
*/
export const secOnlySpacesAll: User = {
username: 'sec_only_all_spaces_all',
password: 'sec_only_all_spaces_all',
roles: [securitySolutionOnlyAllSpacesAll.name],
};
export const secOnlyReadSpacesAll: User = {
username: 'sec_only_read_spaces_all',
password: 'sec_only_read_spaces_all',
roles: [securitySolutionOnlyReadSpacesAll.name],
};
export const secOnlySpacesAllEsReadAll: User = {
username: 'sec_only_all_spaces_all_with_read_es_indices',
password: 'sec_only_all_spaces_all_with_read_es_indices',
roles: [securitySolutionOnlyAllSpacesAllWithReadESIndices.name],
};
export const allUsers = [
superUser,
secOnly,
secOnlyRead,
globalRead,
noKibanaPrivileges,
secOnlySpacesAll,
secOnlySpacesAllEsReadAll,
secOnlyReadSpacesAll,
secOnlySpace2,
secOnlyReadSpace2,
];

View file

@ -8,12 +8,13 @@
import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common';
import type { ToolingLog } from '@kbn/tooling-log';
import type SuperTest from 'supertest';
import {
ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL,
KnowledgeBaseEntryCreateProps,
KnowledgeBaseEntryResponse,
} from '@kbn/elastic-assistant-common';
import type { User } from './auth/types';
import { routeWithNamespace } from '../../../../../../common/utils/security_solution';
/**
@ -21,15 +22,20 @@ import { routeWithNamespace } from '../../../../../../common/utils/security_solu
* @param supertest The supertest deps
* @param log The tooling logger
* @param entry The entry to create
* @param namespace The Kibana Space to create the entry in (optional)
* @param space The Kibana Space to create the entry in (optional)
*/
export const createEntry = async (
supertest: SuperTest.Agent,
log: ToolingLog,
entry: KnowledgeBaseEntryCreateProps,
namespace?: string
): Promise<KnowledgeBaseEntryResponse> => {
const route = routeWithNamespace(ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL, namespace);
export const createEntry = async ({
supertest,
log,
entry,
space,
}: {
supertest: SuperTest.Agent;
log: ToolingLog;
entry: KnowledgeBaseEntryCreateProps;
space?: string;
}): Promise<KnowledgeBaseEntryResponse> => {
const route = routeWithNamespace(ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL, space);
const response = await supertest
.post(route)
.set('kbn-xsrf', 'true')
@ -45,3 +51,42 @@ export const createEntry = async (
return response.body;
}
};
/**
* Creates a Knowledge Base Entry for a given User
* @param supertest The supertest deps
* @param log The tooling logger
* @param entry The entry to create
* @param user The user to create the entry on behalf of
* @param space The Kibana Space to create the entry in (optional)
*/
export const createEntryForUser = async ({
supertestWithoutAuth,
log,
entry,
user,
space,
}: {
supertestWithoutAuth: SuperTest.Agent;
log: ToolingLog;
entry: KnowledgeBaseEntryCreateProps;
user: User;
space?: string;
}): Promise<KnowledgeBaseEntryResponse> => {
const route = routeWithNamespace(ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL, space);
const response = await supertestWithoutAuth
.post(route)
.auth(user.username, user.password)
.set('kbn-xsrf', 'true')
.set(ELASTIC_HTTP_VERSION_HEADER, '1')
.send(entry);
if (response.status !== 200) {
throw new Error(
`Unexpected non 200 ok when attempting to create entry: ${JSON.stringify(
response.status
)},${JSON.stringify(response, null, 4)}`
);
} else {
return response.body;
}
};

View file

@ -0,0 +1,92 @@
/*
* 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 { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common';
import type { ToolingLog } from '@kbn/tooling-log';
import type SuperTest from 'supertest';
import {
FindKnowledgeBaseEntriesResponse,
ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_FIND,
FindKnowledgeBaseEntriesRequestQuery,
} from '@kbn/elastic-assistant-common';
import type { User } from './auth/types';
import { routeWithNamespace } from '../../../../../../common/utils/security_solution';
/**
* Finds Knowledge Base Entries
* @param supertest The supertest deps
* @param log The tooling logger
* @param params Params for find API (optional)
* @param space The Kibana Space to find entries in (optional)
*/
export const findEntries = async ({
supertest,
log,
params,
space,
}: {
supertest: SuperTest.Agent;
log: ToolingLog;
params?: FindKnowledgeBaseEntriesRequestQuery;
space?: string;
}): Promise<FindKnowledgeBaseEntriesResponse> => {
const route = routeWithNamespace(ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_FIND, space);
const response = await supertest
.get(route)
.set('kbn-xsrf', 'true')
.set(ELASTIC_HTTP_VERSION_HEADER, '1')
.send();
if (response.status !== 200) {
throw new Error(
`Unexpected non 200 ok when attempting to find entries: ${JSON.stringify(
response.status
)},${JSON.stringify(response, null, 4)}`
);
} else {
return response.body;
}
};
/**
* Finds Knowledge Base Entries on behalf of a given User
* @param supertest The supertest deps
* @param log The tooling logger
* @param user The user to perform search on behalf of
* @param params Params for find API (optional)
* @param space The Kibana Space to find entries in (optional)
*/
export const findEntriesForUser = async ({
supertestWithoutAuth,
log,
user,
params,
space,
}: {
supertestWithoutAuth: SuperTest.Agent;
log: ToolingLog;
user: User;
params?: FindKnowledgeBaseEntriesRequestQuery;
space?: string;
}): Promise<FindKnowledgeBaseEntriesResponse> => {
const route = routeWithNamespace(ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_FIND, space);
const response = await supertestWithoutAuth
.get(route)
.auth(user.username, user.password)
.set('kbn-xsrf', 'true')
.set(ELASTIC_HTTP_VERSION_HEADER, '1')
.send();
if (response.status !== 200) {
throw new Error(
`Unexpected non 200 ok when attempting to find entries: ${JSON.stringify(
response.status
)},${JSON.stringify(response, null, 4)}`
);
} else {
return response.body;
}
};

View file

@ -0,0 +1,96 @@
/*
* 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 { Client } from '@elastic/elasticsearch';
import {
CreateKnowledgeBaseResponse,
ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_URL,
} from '@kbn/elastic-assistant-common';
import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common';
import type { ToolingLog } from '@kbn/tooling-log';
import type SuperTest from 'supertest';
import { MachineLearningProvider } from '../../../../../../functional/services/ml';
import { SUPPORTED_TRAINED_MODELS } from '../../../../../../functional/services/ml/api';
import { routeWithNamespace } from '../../../../../../common/utils/security_solution';
export const TINY_ELSER = {
...SUPPORTED_TRAINED_MODELS.TINY_ELSER,
id: SUPPORTED_TRAINED_MODELS.TINY_ELSER.name,
};
/**
* Installs `pt_tiny_elser` model for testing Kb features
* @param ml
*/
export const installTinyElser = async (ml: ReturnType<typeof MachineLearningProvider>) => {
const config = {
...ml.api.getTrainedModelConfig(TINY_ELSER.name),
input: {
field_names: ['text_field'],
},
};
await ml.api.assureMlStatsIndexExists();
await ml.api.importTrainedModel(TINY_ELSER.name, TINY_ELSER.id, config);
};
/**
* Deletes `pt_tiny_elser` model for testing Kb features
* @param ml
*/
export const deleteTinyElser = async (ml: ReturnType<typeof MachineLearningProvider>) => {
await ml.api.stopTrainedModelDeploymentES(TINY_ELSER.id, true);
await ml.api.deleteTrainedModelES(TINY_ELSER.id);
await ml.api.cleanMlIndices();
await ml.testResources.cleanMLSavedObjects();
};
/**
* Setup Knowledge Base
* @param supertest The supertest deps
* @param log The tooling logger
* @param resource
* @param namespace The Kibana Space where the KB should be set up
*/
export const setupKnowledgeBase = async (
supertest: SuperTest.Agent,
log: ToolingLog,
resource?: string,
namespace?: string
): Promise<CreateKnowledgeBaseResponse> => {
const path = ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_URL.replace('{resource?}', resource || '');
const route = routeWithNamespace(`${path}?modelId=pt_tiny_elser`, namespace);
const response = await supertest
.post(route)
.set('kbn-xsrf', 'true')
.set(ELASTIC_HTTP_VERSION_HEADER, '1')
.send();
if (response.status !== 200) {
throw new Error(
`Unexpected non 200 ok when attempting to setup Knowledge Base: ${JSON.stringify(
response.status
)},${JSON.stringify(response, null, 4)}`
);
} else {
return response.body;
}
};
/**
* Clear Knowledge Base
* @param es
* @param space
*/
export const clearKnowledgeBase = async (es: Client, space = 'default') => {
return es.deleteByQuery({
index: `.kibana-elastic-ai-assistant-knowledge-base-${space}`,
conflicts: 'proceed',
query: { match_all: {} },
refresh: true,
});
};

View file

@ -0,0 +1,36 @@
/*
* 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 { omit, pickBy } from 'lodash';
import { KnowledgeBaseEntryCreateProps } from '@kbn/elastic-assistant-common';
const serverGeneratedProperties = [
'id',
'createdAt',
'createdBy',
'updatedAt',
'updatedBy',
'vector',
] as const;
type ServerGeneratedProperties = (typeof serverGeneratedProperties)[number];
export type EntryWithoutServerGeneratedProperties = Omit<
KnowledgeBaseEntryCreateProps,
ServerGeneratedProperties
>;
/**
* This will remove server generated properties such as date times, etc...
* @param entry KnowledgeBaseEntryCreateProps to pass in to remove typical server generated properties
*/
export const removeServerGeneratedProperties = (
entry: KnowledgeBaseEntryCreateProps
): EntryWithoutServerGeneratedProperties => {
const removedProperties = omit(entry, serverGeneratedProperties);
// We're only removing undefined values, so this cast correctly narrows the type
return pickBy(removedProperties, (value) => value !== undefined) as KnowledgeBaseEntryCreateProps;
};