mirror of
https://github.com/elastic/kibana.git
synced 2025-04-16 22:21:06 -04:00
[workchat] reintegrate into main
(#215627)
## Summary ~**DO NOT MERGE:** depends on https://github.com/elastic/kibana/issues/213468~ This PR reintegrates the work from the `workchat_m1` branch into `main`: - introduces a 4th solution type, `chat`, that will be used for the *WorkChat* project type. - edit things in various platform code to introduce/handle that new project type - add plugins and packages for the workchat app. ### To AppEx reviewers: File change count is scary, but you can safely ignore anything from `xpack/solutions/chat` (given it's solution code), and focus on your owned changes, which are way more reasonable --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Joe McElroy <joseph.mcelroy@elastic.co> Co-authored-by: Rodney Norris <rodney.norris@elastic.co> Co-authored-by: Jedr Blaszyk <jedrazb@gmail.com> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> Co-authored-by: Meghan Murphy <meghan.murphy@elastic.co>
This commit is contained in:
parent
1a555fdc86
commit
c05dda37e2
333 changed files with 14922 additions and 93 deletions
11
.github/CODEOWNERS
vendored
11
.github/CODEOWNERS
vendored
|
@ -390,6 +390,7 @@ src/platform/packages/shared/content-management/table_list_view_common @elastic/
|
|||
src/platform/packages/shared/content-management/table_list_view_table @elastic/appex-sharedux
|
||||
src/platform/packages/shared/content-management/user_profiles @elastic/appex-sharedux
|
||||
src/platform/packages/shared/deeplinks/analytics @elastic/kibana-data-discovery @elastic/kibana-presentation @elastic/kibana-visualizations
|
||||
src/platform/packages/shared/deeplinks/chat @elastic/search-kibana
|
||||
src/platform/packages/shared/deeplinks/devtools @elastic/kibana-management
|
||||
src/platform/packages/shared/deeplinks/fleet @elastic/fleet
|
||||
src/platform/packages/shared/deeplinks/management @elastic/kibana-management
|
||||
|
@ -937,7 +938,17 @@ x-pack/platform/plugins/shared/streams_app @elastic/streams-program-team
|
|||
x-pack/platform/plugins/shared/task_manager @elastic/response-ops
|
||||
x-pack/platform/plugins/shared/timelines @elastic/security-threat-hunting-investigations
|
||||
x-pack/platform/plugins/shared/triggers_actions_ui @elastic/response-ops
|
||||
x-pack/solutions/chat/packages/wc-genai-utils @elastic/search-kibana
|
||||
x-pack/solutions/chat/packages/wc-index-schema-builder @elastic/search-kibana
|
||||
x-pack/solutions/chat/packages/wc-integration-utils @elastic/search-kibana
|
||||
x-pack/solutions/chat/packages/wci-browser @elastic/search-kibana
|
||||
x-pack/solutions/chat/packages/wci-common @elastic/search-kibana
|
||||
x-pack/solutions/chat/packages/wci-server @elastic/search-kibana
|
||||
x-pack/solutions/chat/plugins/serverless_chat @elastic/search-kibana
|
||||
x-pack/solutions/chat/plugins/wci-external-server @elastic/search-kibana
|
||||
x-pack/solutions/chat/plugins/wci-index-source @elastic/search-kibana
|
||||
x-pack/solutions/chat/plugins/wci-salesforce @elastic/search-kibana
|
||||
x-pack/solutions/chat/plugins/workchat-app @elastic/search-kibana
|
||||
x-pack/solutions/observability/packages/alert-details @elastic/obs-ux-management-team
|
||||
x-pack/solutions/observability/packages/alerting-test-data @elastic/obs-ux-management-team
|
||||
x-pack/solutions/observability/packages/get-padded-alert-time-range-util @elastic/obs-ux-management-team
|
||||
|
|
|
@ -7,7 +7,17 @@ xpack.serverless.chat.enabled: true
|
|||
xpack.cloud.serverless.project_type: search
|
||||
|
||||
## Set the home route
|
||||
uiSettings.overrides.defaultRoute: /app/home
|
||||
uiSettings.overrides.defaultRoute: /app/workchat
|
||||
|
||||
## Enable workchat plugins
|
||||
xpack.workchatApp.enabled: true
|
||||
xpack.wciSalesforce.enabled: true
|
||||
xpack.wciIndexSource.enabled: true
|
||||
xpack.wciExternalServer.enabled: true
|
||||
|
||||
# Disable spaces
|
||||
xpack.spaces.maxSpaces: 1
|
||||
|
||||
|
||||
## Disable plugins that belong to other solutions
|
||||
xpack.apm.enabled: false
|
||||
|
|
|
@ -240,3 +240,7 @@ mapped_pages:
|
|||
| [urlDrilldown](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/private/drilldowns/url_drilldown/README.md) | NOTE: This plugin contains implementation of URL drilldown. For drilldowns infrastructure code refer to ui_actions_enhanced plugin. |
|
||||
| [ux](https://github.com/elastic/kibana/blob/main/x-pack/solutions/observability/plugins/ux/readme.md) | https://docs.elastic.dev/kibana-dev-docs/welcome |
|
||||
| [watcher](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/private/watcher/README.md) | This plugins adopts some conventions in addition to or in place of conventions in Kibana (at the time of the plugin's creation): |
|
||||
| [wciExternalServer](https://github.com/elastic/kibana/blob/main/x-pack/solutions/chat/plugins/wci-external-server/README.md) | WorkChat External Server integration plugin that provides functionality to connect to external servers for the WorkChat application. |
|
||||
| [wciIndexSource](https://github.com/elastic/kibana/blob/main/x-pack/solutions/chat/plugins/wci-index-source/README.md) | WorkChat Index Source integration plugin that provides functionality to search Elasticsearch indices for the WorkChat application. |
|
||||
| [wciSalesforce](https://github.com/elastic/kibana/blob/main/x-pack/solutions/chat/plugins/wci-salesforce/README.md) | WorkChat Salesforce integration plugin that provides Salesforce-specific functionality for the WorkChat application. |
|
||||
| [workchatApp](https://github.com/elastic/kibana/blob/main/x-pack/solutions/chat/plugins/workchat-app/README.md) | WorkChat application plugin |
|
||||
|
|
17
package.json
17
package.json
|
@ -102,7 +102,8 @@
|
|||
"@types/react": "~18.2.0",
|
||||
"@types/react-dom": "~18.2.0",
|
||||
"@xstate5/react/**/xstate": "^5.19.2",
|
||||
"globby/fast-glob": "^3.3.2"
|
||||
"globby/fast-glob": "^3.3.2",
|
||||
"pkce-challenge": "3.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apidevtools/json-schema-ref-parser": "^11.9.1",
|
||||
|
@ -447,6 +448,7 @@
|
|||
"@kbn/dataset-quality-plugin": "link:x-pack/platform/plugins/shared/dataset_quality",
|
||||
"@kbn/datemath": "link:src/platform/packages/shared/kbn-datemath",
|
||||
"@kbn/deeplinks-analytics": "link:src/platform/packages/shared/deeplinks/analytics",
|
||||
"@kbn/deeplinks-chat": "link:src/platform/packages/shared/deeplinks/chat",
|
||||
"@kbn/deeplinks-devtools": "link:src/platform/packages/shared/deeplinks/devtools",
|
||||
"@kbn/deeplinks-fleet": "link:src/platform/packages/shared/deeplinks/fleet",
|
||||
"@kbn/deeplinks-management": "link:src/platform/packages/shared/deeplinks/management",
|
||||
|
@ -1037,6 +1039,16 @@
|
|||
"@kbn/visualization-utils": "link:src/platform/packages/shared/kbn-visualization-utils",
|
||||
"@kbn/visualizations-plugin": "link:src/platform/plugins/shared/visualizations",
|
||||
"@kbn/watcher-plugin": "link:x-pack/platform/plugins/private/watcher",
|
||||
"@kbn/wc-genai-utils": "link:x-pack/solutions/chat/packages/wc-genai-utils",
|
||||
"@kbn/wc-index-schema-builder": "link:x-pack/solutions/chat/packages/wc-index-schema-builder",
|
||||
"@kbn/wc-integration-utils": "link:x-pack/solutions/chat/packages/wc-integration-utils",
|
||||
"@kbn/wci-browser": "link:x-pack/solutions/chat/packages/wci-browser",
|
||||
"@kbn/wci-common": "link:x-pack/solutions/chat/packages/wci-common",
|
||||
"@kbn/wci-external-server": "link:x-pack/solutions/chat/plugins/wci-external-server",
|
||||
"@kbn/wci-index-source": "link:x-pack/solutions/chat/plugins/wci-index-source",
|
||||
"@kbn/wci-salesforce": "link:x-pack/solutions/chat/plugins/wci-salesforce",
|
||||
"@kbn/wci-server": "link:x-pack/solutions/chat/packages/wci-server",
|
||||
"@kbn/workchat-app": "link:x-pack/solutions/chat/plugins/workchat-app",
|
||||
"@kbn/xstate-utils": "link:src/platform/packages/shared/kbn-xstate-utils",
|
||||
"@kbn/zod": "link:src/platform/packages/shared/kbn-zod",
|
||||
"@kbn/zod-helpers": "link:src/platform/packages/shared/kbn-zod-helpers",
|
||||
|
@ -1059,6 +1071,8 @@
|
|||
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
|
||||
"@mapbox/mapbox-gl-supported": "2.0.1",
|
||||
"@mapbox/vector-tile": "1.3.1",
|
||||
"@modelcontextprotocol/sdk": "^1.6.0",
|
||||
"@n8n/json-schema-to-zod": "^1.1.0",
|
||||
"@openfeature/core": "^1.7.2",
|
||||
"@openfeature/launchdarkly-client-provider": "^0.3.2",
|
||||
"@openfeature/server-sdk": "^1.17.1",
|
||||
|
@ -1149,6 +1163,7 @@
|
|||
"execa": "^5.1.1",
|
||||
"expiry-js": "0.1.7",
|
||||
"exponential-backoff": "^3.1.1",
|
||||
"expr-eval": "^2.0.2",
|
||||
"extract-zip": "^2.0.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-glob": "^3.3.2",
|
||||
|
|
|
@ -1169,5 +1169,37 @@
|
|||
"title",
|
||||
"version"
|
||||
],
|
||||
"workchat_agent": [
|
||||
"access_control",
|
||||
"access_control.public",
|
||||
"agent_id",
|
||||
"agent_name",
|
||||
"configuration",
|
||||
"description",
|
||||
"last_updated",
|
||||
"user_id",
|
||||
"user_name"
|
||||
],
|
||||
"workchat_conversation": [
|
||||
"access_control",
|
||||
"access_control.public",
|
||||
"agent_id",
|
||||
"conversation_id",
|
||||
"events",
|
||||
"last_updated",
|
||||
"title",
|
||||
"user_id",
|
||||
"user_name"
|
||||
],
|
||||
"workchat_integration": [
|
||||
"configuration",
|
||||
"created_at",
|
||||
"created_by",
|
||||
"description",
|
||||
"integration_id",
|
||||
"name",
|
||||
"type",
|
||||
"updated_at"
|
||||
],
|
||||
"workplace_search_telemetry": []
|
||||
}
|
||||
|
|
|
@ -3863,6 +3863,107 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"workchat_agent": {
|
||||
"dynamic": "strict",
|
||||
"properties": {
|
||||
"access_control": {
|
||||
"properties": {
|
||||
"public": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"agent_id": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"agent_name": {
|
||||
"type": "text"
|
||||
},
|
||||
"configuration": {
|
||||
"dynamic": false,
|
||||
"properties": {},
|
||||
"type": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "text"
|
||||
},
|
||||
"last_updated": {
|
||||
"type": "date"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"user_name": {
|
||||
"type": "keyword"
|
||||
}
|
||||
}
|
||||
},
|
||||
"workchat_conversation": {
|
||||
"dynamic": "strict",
|
||||
"properties": {
|
||||
"access_control": {
|
||||
"properties": {
|
||||
"public": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"agent_id": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"conversation_id": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"events": {
|
||||
"dynamic": false,
|
||||
"properties": {},
|
||||
"type": "object"
|
||||
},
|
||||
"last_updated": {
|
||||
"type": "date"
|
||||
},
|
||||
"title": {
|
||||
"type": "text"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"user_name": {
|
||||
"type": "keyword"
|
||||
}
|
||||
}
|
||||
},
|
||||
"workchat_integration": {
|
||||
"dynamic": "strict",
|
||||
"properties": {
|
||||
"configuration": {
|
||||
"dynamic": false,
|
||||
"properties": {},
|
||||
"type": "object"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "date"
|
||||
},
|
||||
"created_by": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"description": {
|
||||
"type": "text"
|
||||
},
|
||||
"integration_id": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"name": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"type": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"workplace_search_telemetry": {
|
||||
"dynamic": false,
|
||||
"properties": {}
|
||||
|
|
|
@ -199,3 +199,7 @@ pageLoadAssetSize:
|
|||
visTypeXy: 46868
|
||||
visualizations: 41000
|
||||
watcher: 43598
|
||||
wciExternalServer: 35000
|
||||
wciIndexSource: 40000
|
||||
wciSalesforce: 25000
|
||||
workchatApp: 25000
|
||||
|
|
|
@ -2140,6 +2140,28 @@
|
|||
"minimumReleaseAge": "7 days",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"groupName": "workchat gen-ai dependencies",
|
||||
"matchDepNames": [
|
||||
"@modelcontextprotocol/sdk",
|
||||
"@n8n/json-schema-to-zod",
|
||||
"expr-eval"
|
||||
],
|
||||
"reviewers": [
|
||||
"team:search-kibana"
|
||||
],
|
||||
"matchBaseBranches": [
|
||||
"main"
|
||||
],
|
||||
"labels": [
|
||||
"release_note:skip",
|
||||
"Team:Search",
|
||||
"Team:Enterprise Search",
|
||||
"backport:all-open"
|
||||
],
|
||||
"minimumReleaseAge": "7 days",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"groupName": "vinyl",
|
||||
"matchDepNames": [
|
||||
|
|
|
@ -248,7 +248,7 @@ export default function (program) {
|
|||
'Adds plugin paths for all the Kibana example plugins and runs with no base path'
|
||||
)
|
||||
.option(
|
||||
'--serverless [oblt|security|es]',
|
||||
'--serverless [oblt|security|es|chat]',
|
||||
'Start Kibana in a specific serverless project mode. ' +
|
||||
'If no mode is provided, it starts Kibana in the most recent serverless project mode (default is es)'
|
||||
);
|
||||
|
|
|
@ -20,7 +20,6 @@ export const DEFAULT_APP_CATEGORIES: Record<string, AppCategory> = Object.freeze
|
|||
euiIconType: 'logoKibana',
|
||||
order: 1000,
|
||||
},
|
||||
// BOOKMARK - List of Kibana solutions - TODO handle the new 'chat' project type - https://elastic.slack.com/archives/C061KHPJS2C/p1741691346619339
|
||||
enterpriseSearch: {
|
||||
id: 'enterpriseSearch',
|
||||
label: i18n.translate('core.ui.searchNavList.label', {
|
||||
|
@ -45,6 +44,14 @@ export const DEFAULT_APP_CATEGORIES: Record<string, AppCategory> = Object.freeze
|
|||
order: 4000,
|
||||
euiIconType: 'logoSecurity',
|
||||
},
|
||||
chat: {
|
||||
id: 'chat',
|
||||
label: i18n.translate('core.ui.chatNavList.label', {
|
||||
defaultMessage: 'Workchat',
|
||||
}),
|
||||
order: 4500,
|
||||
euiIconType: 'logoElasticsearch',
|
||||
},
|
||||
management: {
|
||||
id: 'management',
|
||||
label: i18n.translate('core.ui.managementNavList.label', {
|
||||
|
|
|
@ -36,11 +36,12 @@ import type {
|
|||
import type { AppId as SecurityApp, DeepLinkId as SecurityLink } from '@kbn/deeplinks-security';
|
||||
import type { AppId as FleetApp, DeepLinkId as FleetLink } from '@kbn/deeplinks-fleet';
|
||||
import type { AppId as SharedApp, DeepLinkId as SharedLink } from '@kbn/deeplinks-shared';
|
||||
import type { WorkchatApp, DeepLinkId as ChatLink } from '@kbn/deeplinks-chat';
|
||||
|
||||
import type { ChromeNavLink } from './nav_links';
|
||||
import type { ChromeRecentlyAccessedHistoryItem } from './recently_accessed';
|
||||
|
||||
export type SolutionId = 'es' | 'oblt' | 'security';
|
||||
export type SolutionId = 'es' | 'oblt' | 'security' | 'chat';
|
||||
|
||||
/** @public */
|
||||
export type AppId =
|
||||
|
@ -56,7 +57,8 @@ export type AppId =
|
|||
| ObservabilityApp
|
||||
| SecurityApp
|
||||
| FleetApp
|
||||
| SharedApp;
|
||||
| SharedApp
|
||||
| WorkchatApp;
|
||||
|
||||
/** @public */
|
||||
export type AppDeepLinkId =
|
||||
|
@ -68,7 +70,8 @@ export type AppDeepLinkId =
|
|||
| ObservabilityLink
|
||||
| SecurityLink
|
||||
| FleetLink
|
||||
| SharedLink;
|
||||
| SharedLink
|
||||
| ChatLink;
|
||||
|
||||
/** @public */
|
||||
export type CloudLinkId =
|
||||
|
|
|
@ -23,7 +23,8 @@
|
|||
"@kbn/core-application-browser",
|
||||
"@kbn/deeplinks-security",
|
||||
"@kbn/deeplinks-fleet",
|
||||
"@kbn/deeplinks-shared"
|
||||
"@kbn/deeplinks-shared",
|
||||
"@kbn/deeplinks-chat"
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -142,6 +142,11 @@ export class ImportResolver {
|
|||
return this.adaptReq('src/core/server', dirname);
|
||||
}
|
||||
|
||||
if (req.startsWith('@modelcontextprotocol/sdk')) {
|
||||
const relPath = req.split('@modelcontextprotocol/sdk')[1];
|
||||
return Path.resolve(REPO_ROOT, `node_modules/@modelcontextprotocol/sdk/dist/esm/${relPath}`);
|
||||
}
|
||||
|
||||
// turn root-relative paths into relative paths
|
||||
if (
|
||||
req.startsWith('src/') ||
|
||||
|
|
3
src/platform/packages/shared/deeplinks/chat/README.md
Normal file
3
src/platform/packages/shared/deeplinks/chat/README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# @kbn/deeplinks-chat
|
||||
|
||||
Deeplinks for apps from the chat project type.
|
10
src/platform/packages/shared/deeplinks/chat/constants.ts
Normal file
10
src/platform/packages/shared/deeplinks/chat/constants.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export const WORKCHAT_APP_ID = 'workchat';
|
16
src/platform/packages/shared/deeplinks/chat/deep_links.ts
Normal file
16
src/platform/packages/shared/deeplinks/chat/deep_links.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { WORKCHAT_APP_ID } from './constants';
|
||||
|
||||
export type WorkchatApp = typeof WORKCHAT_APP_ID;
|
||||
|
||||
export type WorkchatLinkId = 'agents' | 'integrations';
|
||||
|
||||
export type DeepLinkId = WorkchatApp | `${WorkchatApp}:${WorkchatLinkId}`;
|
11
src/platform/packages/shared/deeplinks/chat/index.ts
Normal file
11
src/platform/packages/shared/deeplinks/chat/index.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export { WORKCHAT_APP_ID } from './constants';
|
||||
export type { WorkchatApp, WorkchatLinkId, DeepLinkId } from './deep_links';
|
14
src/platform/packages/shared/deeplinks/chat/jest.config.js
Normal file
14
src/platform/packages/shared/deeplinks/chat/jest.config.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
preset: '@kbn/test/jest_node',
|
||||
rootDir: '../../../../../..',
|
||||
roots: ['<rootDir>/src/platform/packages/shared/deeplinks/chat'],
|
||||
};
|
7
src/platform/packages/shared/deeplinks/chat/kibana.jsonc
Normal file
7
src/platform/packages/shared/deeplinks/chat/kibana.jsonc
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/deeplinks-chat",
|
||||
"owner": "@elastic/search-kibana",
|
||||
"group": "platform",
|
||||
"visibility": "shared"
|
||||
}
|
6
src/platform/packages/shared/deeplinks/chat/package.json
Normal file
6
src/platform/packages/shared/deeplinks/chat/package.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@kbn/deeplinks-chat",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
|
||||
}
|
17
src/platform/packages/shared/deeplinks/chat/tsconfig.json
Normal file
17
src/platform/packages/shared/deeplinks/chat/tsconfig.json
Normal file
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"extends": "../../../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": [
|
||||
"jest",
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*"
|
||||
],
|
||||
"kbn_references": []
|
||||
}
|
|
@ -14,6 +14,7 @@ import type { SolutionId } from '@kbn/core-chrome-browser';
|
|||
|
||||
const feedbackUrls: { [id in SolutionId]: string } = {
|
||||
es: 'https://ela.st/search-nav-feedback',
|
||||
chat: 'https://ela.st/search-nav-feedback',
|
||||
oblt: 'https://ela.st/o11y-nav-feedback',
|
||||
security: 'https://ela.st/security-nav-feedback',
|
||||
};
|
||||
|
|
|
@ -14,4 +14,5 @@ export const DEFAULT_ROUTES = {
|
|||
es: '/app/elasticsearch/overview',
|
||||
oblt: '/app/observabilityOnboarding',
|
||||
security: '/app/security/get_started',
|
||||
chat: '/app/workchat',
|
||||
};
|
||||
|
|
|
@ -740,6 +740,8 @@
|
|||
"@kbn/datemath/*": ["src/platform/packages/shared/kbn-datemath/*"],
|
||||
"@kbn/deeplinks-analytics": ["src/platform/packages/shared/deeplinks/analytics"],
|
||||
"@kbn/deeplinks-analytics/*": ["src/platform/packages/shared/deeplinks/analytics/*"],
|
||||
"@kbn/deeplinks-chat": ["src/platform/packages/shared/deeplinks/chat"],
|
||||
"@kbn/deeplinks-chat/*": ["src/platform/packages/shared/deeplinks/chat/*"],
|
||||
"@kbn/deeplinks-devtools": ["src/platform/packages/shared/deeplinks/devtools"],
|
||||
"@kbn/deeplinks-devtools/*": ["src/platform/packages/shared/deeplinks/devtools/*"],
|
||||
"@kbn/deeplinks-fleet": ["src/platform/packages/shared/deeplinks/fleet"],
|
||||
|
@ -2134,10 +2136,30 @@
|
|||
"@kbn/visualizations-plugin/*": ["src/platform/plugins/shared/visualizations/*"],
|
||||
"@kbn/watcher-plugin": ["x-pack/platform/plugins/private/watcher"],
|
||||
"@kbn/watcher-plugin/*": ["x-pack/platform/plugins/private/watcher/*"],
|
||||
"@kbn/wc-genai-utils": ["x-pack/solutions/chat/packages/wc-genai-utils"],
|
||||
"@kbn/wc-genai-utils/*": ["x-pack/solutions/chat/packages/wc-genai-utils/*"],
|
||||
"@kbn/wc-index-schema-builder": ["x-pack/solutions/chat/packages/wc-index-schema-builder"],
|
||||
"@kbn/wc-index-schema-builder/*": ["x-pack/solutions/chat/packages/wc-index-schema-builder/*"],
|
||||
"@kbn/wc-integration-utils": ["x-pack/solutions/chat/packages/wc-integration-utils"],
|
||||
"@kbn/wc-integration-utils/*": ["x-pack/solutions/chat/packages/wc-integration-utils/*"],
|
||||
"@kbn/wci-browser": ["x-pack/solutions/chat/packages/wci-browser"],
|
||||
"@kbn/wci-browser/*": ["x-pack/solutions/chat/packages/wci-browser/*"],
|
||||
"@kbn/wci-common": ["x-pack/solutions/chat/packages/wci-common"],
|
||||
"@kbn/wci-common/*": ["x-pack/solutions/chat/packages/wci-common/*"],
|
||||
"@kbn/wci-external-server": ["x-pack/solutions/chat/plugins/wci-external-server"],
|
||||
"@kbn/wci-external-server/*": ["x-pack/solutions/chat/plugins/wci-external-server/*"],
|
||||
"@kbn/wci-index-source": ["x-pack/solutions/chat/plugins/wci-index-source"],
|
||||
"@kbn/wci-index-source/*": ["x-pack/solutions/chat/plugins/wci-index-source/*"],
|
||||
"@kbn/wci-salesforce": ["x-pack/solutions/chat/plugins/wci-salesforce"],
|
||||
"@kbn/wci-salesforce/*": ["x-pack/solutions/chat/plugins/wci-salesforce/*"],
|
||||
"@kbn/wci-server": ["x-pack/solutions/chat/packages/wci-server"],
|
||||
"@kbn/wci-server/*": ["x-pack/solutions/chat/packages/wci-server/*"],
|
||||
"@kbn/web-worker-stub": ["packages/kbn-web-worker-stub"],
|
||||
"@kbn/web-worker-stub/*": ["packages/kbn-web-worker-stub/*"],
|
||||
"@kbn/whereis-pkg-cli": ["packages/kbn-whereis-pkg-cli"],
|
||||
"@kbn/whereis-pkg-cli/*": ["packages/kbn-whereis-pkg-cli/*"],
|
||||
"@kbn/workchat-app": ["x-pack/solutions/chat/plugins/workchat-app"],
|
||||
"@kbn/workchat-app/*": ["x-pack/solutions/chat/plugins/workchat-app/*"],
|
||||
"@kbn/xstate-utils": ["src/platform/packages/shared/kbn-xstate-utils"],
|
||||
"@kbn/xstate-utils/*": ["src/platform/packages/shared/kbn-xstate-utils/*"],
|
||||
"@kbn/yarn-lock-validator": ["packages/kbn-yarn-lock-validator"],
|
||||
|
|
|
@ -128,7 +128,7 @@ describe('config schema', () => {
|
|||
{ serverless: true }
|
||||
)
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[overrides.featureA.category]: Unknown category \\"unknown\\". Should be one of kibana, enterpriseSearch, observability, security, management"`
|
||||
`"[overrides.featureA.category]: Unknown category \\"unknown\\". Should be one of kibana, enterpriseSearch, observability, security, chat, management"`
|
||||
);
|
||||
});
|
||||
it('properly validates sub-feature privilege inclusion override', () => {
|
||||
|
|
|
@ -36,6 +36,9 @@ const solutionMap: Record<SolutionId, string> = {
|
|||
oblt: i18n.translate('xpack.spaces.navControl.tour.obltSolution', {
|
||||
defaultMessage: 'Observability',
|
||||
}),
|
||||
chat: i18n.translate('xpack.spaces.navControl.tour.chatSolution', {
|
||||
defaultMessage: 'Workchat',
|
||||
}),
|
||||
};
|
||||
|
||||
interface Props extends PropsWithChildren<{}> {
|
||||
|
|
|
@ -25,6 +25,10 @@ const SolutionOptions: Record<
|
|||
/>
|
||||
),
|
||||
},
|
||||
chat: {
|
||||
iconType: 'logoElasticsearch',
|
||||
label: <FormattedMessage id="xpack.spaces.spaceSolutionBadge.chat" defaultMessage="Workchat" />,
|
||||
},
|
||||
security: {
|
||||
iconType: 'logoSecurity',
|
||||
label: (
|
||||
|
|
|
@ -12,6 +12,7 @@ const features = [
|
|||
{ id: 'feature1', category: { id: 'observability' } },
|
||||
{ id: 'feature2', category: { id: 'enterpriseSearch' } },
|
||||
{ id: 'feature3', category: { id: 'securitySolution' } },
|
||||
{ id: 'feature5', category: { id: 'chat' } },
|
||||
{ id: 'feature4', category: { id: 'should_not_be_returned' } }, // not a solution, it should never appeared in the disabled features
|
||||
] as KibanaFeature[];
|
||||
|
||||
|
@ -42,7 +43,7 @@ describe('#withSpaceSolutionDisabledFeatures', () => {
|
|||
});
|
||||
|
||||
describe('when the space solution is "es"', () => {
|
||||
test('it removes the "oblt" and "security" features', () => {
|
||||
test('it removes the "oblt", "security" and "chat" features', () => {
|
||||
const spaceDisabledFeatures: string[] = ['foo'];
|
||||
const spaceSolution = 'es';
|
||||
|
||||
|
@ -53,12 +54,12 @@ describe('#withSpaceSolutionDisabledFeatures', () => {
|
|||
);
|
||||
|
||||
// merges the spaceDisabledFeatures with the disabledFeatureKeysFromSolution
|
||||
expect(result).toEqual(['feature1', 'feature3']); // "foo" from the spaceDisabledFeatures should not be removed
|
||||
expect(result).toEqual(['feature1', 'feature3', 'feature5']); // "foo" from the spaceDisabledFeatures should not be removed
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the space solution is "oblt"', () => {
|
||||
test('it removes the "security" features', () => {
|
||||
test('it removes the "security" and "chat" features', () => {
|
||||
const spaceDisabledFeatures: string[] = [];
|
||||
const spaceSolution = 'oblt';
|
||||
|
||||
|
@ -68,12 +69,12 @@ describe('#withSpaceSolutionDisabledFeatures', () => {
|
|||
spaceSolution
|
||||
);
|
||||
|
||||
expect(result).toEqual(['feature3']);
|
||||
expect(result).toEqual(['feature3', 'feature5']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the space solution is "security"', () => {
|
||||
test('it removes the "observability" and "enterpriseSearch" features', () => {
|
||||
test('it removes the "observability", "enterpriseSearch" and "chat" features', () => {
|
||||
const spaceDisabledFeatures: string[] = ['baz'];
|
||||
const spaceSolution = 'security';
|
||||
|
||||
|
@ -83,7 +84,23 @@ describe('#withSpaceSolutionDisabledFeatures', () => {
|
|||
spaceSolution
|
||||
);
|
||||
|
||||
expect(result).toEqual(['feature1', 'feature2']); // "baz" from the spaceDisabledFeatures should not be removed
|
||||
expect(result).toEqual(['feature1', 'feature2', 'feature5']); // "baz" from the spaceDisabledFeatures should not be removed
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the space solution is "chat"', () => {
|
||||
test('it removes the "oblt", "es" and "security" features', () => {
|
||||
const spaceDisabledFeatures: string[] = ['foo'];
|
||||
const spaceSolution = 'chat';
|
||||
|
||||
const result = withSpaceSolutionDisabledFeatures(
|
||||
features,
|
||||
spaceDisabledFeatures,
|
||||
spaceSolution
|
||||
);
|
||||
|
||||
// merges the spaceDisabledFeatures with the disabledFeatureKeysFromSolution
|
||||
expect(result).toEqual(['feature1', 'feature2', 'feature3']); // "foo" from the spaceDisabledFeatures should not be removed
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -11,13 +11,17 @@ import type { SolutionView } from '../../../common';
|
|||
|
||||
const getFeatureIdsForCategories = (
|
||||
features: KibanaFeature[],
|
||||
categories: Array<'observability' | 'enterpriseSearch' | 'securitySolution'>
|
||||
categories: Array<'observability' | 'enterpriseSearch' | 'securitySolution' | 'chat'>
|
||||
) => {
|
||||
return features
|
||||
.filter((feature) =>
|
||||
feature.category
|
||||
? categories.includes(
|
||||
feature.category.id as 'observability' | 'enterpriseSearch' | 'securitySolution'
|
||||
feature.category.id as
|
||||
| 'observability'
|
||||
| 'enterpriseSearch'
|
||||
| 'securitySolution'
|
||||
| 'chat'
|
||||
)
|
||||
: false
|
||||
)
|
||||
|
@ -32,6 +36,7 @@ const enabledFeaturesPerSolution: Record<SolutionId, string[]> = {
|
|||
es: ['observabilityAIAssistant'],
|
||||
oblt: [],
|
||||
security: [],
|
||||
chat: [],
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -59,16 +64,25 @@ export function withSpaceSolutionDisabledFeatures(
|
|||
disabledFeatureKeysFromSolution = getFeatureIdsForCategories(features, [
|
||||
'observability',
|
||||
'securitySolution',
|
||||
'chat',
|
||||
]).filter((featureId) => !enabledFeaturesPerSolution.es.includes(featureId));
|
||||
} else if (spaceSolution === 'oblt') {
|
||||
disabledFeatureKeysFromSolution = getFeatureIdsForCategories(features, [
|
||||
'securitySolution',
|
||||
'chat',
|
||||
]).filter((featureId) => !enabledFeaturesPerSolution.oblt.includes(featureId));
|
||||
} else if (spaceSolution === 'security') {
|
||||
disabledFeatureKeysFromSolution = getFeatureIdsForCategories(features, [
|
||||
'observability',
|
||||
'enterpriseSearch',
|
||||
'chat',
|
||||
]).filter((featureId) => !enabledFeaturesPerSolution.security.includes(featureId));
|
||||
} else if (spaceSolution === 'chat') {
|
||||
disabledFeatureKeysFromSolution = getFeatureIdsForCategories(features, [
|
||||
'observability',
|
||||
'securitySolution',
|
||||
'enterpriseSearch',
|
||||
]).filter((featureId) => !enabledFeaturesPerSolution.chat.includes(featureId));
|
||||
}
|
||||
|
||||
return Array.from(new Set([...disabledFeatureKeysFromSolution]));
|
||||
|
|
3
x-pack/solutions/chat/packages/wc-genai-utils/README.md
Normal file
3
x-pack/solutions/chat/packages/wc-genai-utils/README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# @kbn/wc-genai-utils
|
||||
|
||||
Empty package generated by @kbn/generate
|
8
x-pack/solutions/chat/packages/wc-genai-utils/index.ts
Normal file
8
x-pack/solutions/chat/packages/wc-genai-utils/index.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* 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 { getConnectorList, getDefaultConnector } from './src/connectors';
|
12
x-pack/solutions/chat/packages/wc-genai-utils/jest.config.js
Normal file
12
x-pack/solutions/chat/packages/wc-genai-utils/jest.config.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
preset: '@kbn/test/jest_node',
|
||||
rootDir: '../../../../..',
|
||||
roots: ['<rootDir>/x-pack/solutions/chat/packages/wc-genai-utils'],
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/wc-genai-utils",
|
||||
"owner": "@elastic/search-kibana",
|
||||
"group": "chat",
|
||||
"visibility": "private"
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@kbn/wc-genai-utils",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "Elastic License 2.0"
|
||||
}
|
|
@ -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 type { KibanaRequest } from '@kbn/core/server';
|
||||
import type { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server';
|
||||
import {
|
||||
isSupportedConnector,
|
||||
connectorToInference,
|
||||
InferenceConnector,
|
||||
} from '@kbn/inference-common';
|
||||
|
||||
export const getConnectorList = async ({
|
||||
actions,
|
||||
request,
|
||||
}: {
|
||||
actions: ActionsPluginStart;
|
||||
request: KibanaRequest;
|
||||
}): Promise<InferenceConnector[]> => {
|
||||
const actionClient = await actions.getActionsClientWithRequest(request);
|
||||
|
||||
const allConnectors = await actionClient.getAll({
|
||||
includeSystemActions: false,
|
||||
});
|
||||
|
||||
return allConnectors
|
||||
.filter((connector) => isSupportedConnector(connector))
|
||||
.map(connectorToInference);
|
||||
};
|
|
@ -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 { InferenceConnector, InferenceConnectorType } from '@kbn/inference-common';
|
||||
|
||||
/**
|
||||
* Naive utility function to consistently return the "best" connector for workchat features.
|
||||
*
|
||||
* In practice, mostly useful for development, as for production there should always be a single connector
|
||||
*/
|
||||
export const getDefaultConnector = ({ connectors }: { connectors: InferenceConnector[] }) => {
|
||||
//
|
||||
const inferenceConnector = connectors.find(
|
||||
(connector) => connector.type === InferenceConnectorType.Inference
|
||||
);
|
||||
if (inferenceConnector) {
|
||||
return inferenceConnector;
|
||||
}
|
||||
|
||||
const openAIConnector = connectors.find(
|
||||
(connector) => connector.type === InferenceConnectorType.OpenAI
|
||||
);
|
||||
if (openAIConnector) {
|
||||
return openAIConnector;
|
||||
}
|
||||
|
||||
return connectors[0];
|
||||
};
|
|
@ -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 { getConnectorList } from './get_connector_list';
|
||||
export { getDefaultConnector } from './get_default_connector';
|
21
x-pack/solutions/chat/packages/wc-genai-utils/tsconfig.json
Normal file
21
x-pack/solutions/chat/packages/wc-genai-utils/tsconfig.json
Normal file
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"extends": "../../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": [
|
||||
"jest",
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*"
|
||||
],
|
||||
"kbn_references": [
|
||||
"@kbn/core",
|
||||
"@kbn/actions-plugin",
|
||||
"@kbn/inference-common",
|
||||
]
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
# @kbn/wc-index-schema-builder
|
||||
|
||||
Empty package generated by @kbn/generate
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* 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 { buildSchema } from './src/build_schema';
|
|
@ -5,9 +5,8 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
/** @type {import('@jest/types').Config.InitialOptions} */
|
||||
module.exports = {
|
||||
preset: '@kbn/test',
|
||||
rootDir: '../../../../../..',
|
||||
roots: ['<rootDir>/x-pack/solutions/chat/plugins/serverless_chat/server/'],
|
||||
preset: '@kbn/test/jest_node',
|
||||
rootDir: '../../../../..',
|
||||
roots: ['<rootDir>/x-pack/solutions/chat/packages/wc-index-schema-builder'],
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/wc-index-schema-builder",
|
||||
"owner": "@elastic/search-kibana",
|
||||
"group": "chat",
|
||||
"visibility": "private"
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@kbn/wc-index-schema-builder",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "Elastic License 2.0"
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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 { Logger, ElasticsearchClient } from '@kbn/core/server';
|
||||
import type { InferenceChatModel } from '@kbn/inference-langchain';
|
||||
import type { IndexSourceDefinition } from '@kbn/wci-common';
|
||||
import { createSchemaGraph } from './workflows/build_index_schema';
|
||||
|
||||
export const buildSchema = async ({
|
||||
indexName,
|
||||
esClient,
|
||||
logger,
|
||||
chatModel,
|
||||
}: {
|
||||
indexName: string;
|
||||
logger: Logger;
|
||||
chatModel: InferenceChatModel;
|
||||
esClient: ElasticsearchClient;
|
||||
}): Promise<IndexSourceDefinition> => {
|
||||
const graph = await createSchemaGraph({ chatModel, esClient, logger });
|
||||
|
||||
const output = await graph.invoke({ indexName });
|
||||
|
||||
return output.generatedDefinition as IndexSourceDefinition;
|
||||
};
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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 { MappingTypeMapping } from '@elastic/elasticsearch/lib/api/types';
|
||||
import type { ElasticsearchClient } from '@kbn/core/server';
|
||||
|
||||
export interface IndexInformation {
|
||||
mappings: MappingTypeMapping;
|
||||
}
|
||||
|
||||
export const getIndexInformation = async ({
|
||||
indexName,
|
||||
esClient,
|
||||
}: {
|
||||
indexName: string;
|
||||
esClient: ElasticsearchClient;
|
||||
}): Promise<IndexInformation> => {
|
||||
const response = await esClient.indices.getMapping({ index: indexName });
|
||||
|
||||
const mappings = response[indexName]!.mappings;
|
||||
|
||||
return {
|
||||
mappings,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
* 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 { MappingTypeMapping } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { getLeafFields, MappingField } from './get_leaf_fields';
|
||||
|
||||
describe('getLeafFields', () => {
|
||||
test('should return empty array when mappings has no properties', () => {
|
||||
const mappings: MappingTypeMapping = {
|
||||
properties: {},
|
||||
};
|
||||
|
||||
const result = getLeafFields({ mappings });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test('should extract leaf fields at root level', () => {
|
||||
const mappings: MappingTypeMapping = {
|
||||
properties: {
|
||||
title: { type: 'text' },
|
||||
age: { type: 'integer' },
|
||||
enabled: { type: 'boolean' },
|
||||
},
|
||||
};
|
||||
|
||||
const expected: MappingField[] = [
|
||||
{ path: 'title', type: 'text' },
|
||||
{ path: 'age', type: 'integer' },
|
||||
{ path: 'enabled', type: 'boolean' },
|
||||
];
|
||||
|
||||
const result = getLeafFields({ mappings });
|
||||
expect(result).toEqual(expect.arrayContaining(expected));
|
||||
expect(result.length).toBe(expected.length);
|
||||
});
|
||||
|
||||
test('should extract nested fields with correct paths', () => {
|
||||
const mappings: MappingTypeMapping = {
|
||||
properties: {
|
||||
user: {
|
||||
properties: {
|
||||
firstName: { type: 'text' },
|
||||
lastName: { type: 'text' },
|
||||
address: {
|
||||
properties: {
|
||||
city: { type: 'keyword' },
|
||||
zipCode: { type: 'keyword' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const expected: MappingField[] = [
|
||||
{ path: 'user.firstName', type: 'text' },
|
||||
{ path: 'user.lastName', type: 'text' },
|
||||
{ path: 'user.address.city', type: 'keyword' },
|
||||
{ path: 'user.address.zipCode', type: 'keyword' },
|
||||
];
|
||||
|
||||
const result = getLeafFields({ mappings });
|
||||
expect(result).toEqual(expect.arrayContaining(expected));
|
||||
expect(result.length).toBe(expected.length);
|
||||
});
|
||||
|
||||
test('should handle a mix of leaf fields and nested objects', () => {
|
||||
const mappings: MappingTypeMapping = {
|
||||
properties: {
|
||||
id: { type: 'keyword' },
|
||||
content: { type: 'text' },
|
||||
metadata: {
|
||||
properties: {
|
||||
created: { type: 'date' },
|
||||
author: {
|
||||
properties: {
|
||||
id: { type: 'keyword' },
|
||||
name: { type: 'text' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: { type: 'keyword' },
|
||||
},
|
||||
};
|
||||
|
||||
const expected: MappingField[] = [
|
||||
{ path: 'id', type: 'keyword' },
|
||||
{ path: 'content', type: 'text' },
|
||||
{ path: 'metadata.created', type: 'date' },
|
||||
{ path: 'metadata.author.id', type: 'keyword' },
|
||||
{ path: 'metadata.author.name', type: 'text' },
|
||||
{ path: 'tags', type: 'keyword' },
|
||||
];
|
||||
|
||||
const result = getLeafFields({ mappings });
|
||||
expect(result).toEqual(expect.arrayContaining(expected));
|
||||
expect(result.length).toBe(expected.length);
|
||||
});
|
||||
|
||||
test('should handle mappings with undefined properties', () => {
|
||||
const mappings: MappingTypeMapping = {};
|
||||
|
||||
const result = getLeafFields({ mappings });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* 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 { MappingTypeMapping, MappingProperty } from '@elastic/elasticsearch/lib/api/types';
|
||||
|
||||
export type FieldType = Extract<MappingProperty, { type: string }>['type'];
|
||||
|
||||
export interface MappingField {
|
||||
path: string;
|
||||
type: FieldType;
|
||||
}
|
||||
|
||||
export interface MappingProperties {
|
||||
[key: string]: {
|
||||
type?: string; // Leaf field (e.g., "text", "keyword", etc.)
|
||||
properties?: MappingProperties; // Nested object fields
|
||||
};
|
||||
}
|
||||
|
||||
export const getLeafFields = ({ mappings }: { mappings: MappingTypeMapping }): MappingField[] => {
|
||||
const properties: MappingProperties = mappings.properties ?? {};
|
||||
|
||||
function extractFields(obj: MappingProperties, prefix = ''): MappingField[] {
|
||||
let fields: MappingField[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const fieldPath = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (value.type) {
|
||||
// If it's a leaf field, add it
|
||||
fields.push({
|
||||
type: value.type as FieldType,
|
||||
path: fieldPath,
|
||||
});
|
||||
} else if (value.properties) {
|
||||
// If it's an object, go deeper
|
||||
fields = fields.concat(extractFields(value.properties, fieldPath));
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
return extractFields(properties);
|
||||
};
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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 { ElasticsearchClient } from '@kbn/core/server';
|
||||
|
||||
export type SampleDocument = Record<string, unknown>;
|
||||
|
||||
export const getSampleDocuments = async ({
|
||||
indexName,
|
||||
esClient,
|
||||
maxSamples = 5,
|
||||
}: {
|
||||
indexName: string;
|
||||
esClient: ElasticsearchClient;
|
||||
maxSamples?: number;
|
||||
}): Promise<{ samples: SampleDocument[] }> => {
|
||||
const response = await esClient.search({
|
||||
index: indexName,
|
||||
size: maxSamples,
|
||||
});
|
||||
|
||||
const documents = response.hits.hits.map((hit) => hit._source! as Record<string, unknown>);
|
||||
|
||||
return { samples: documents };
|
||||
};
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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 { getIndexInformation, type IndexInformation } from './get_index_information';
|
||||
export { getLeafFields } from './get_leaf_fields';
|
||||
export { getSampleDocuments, type SampleDocument } from './get_sample_documents';
|
|
@ -0,0 +1,286 @@
|
|||
/*
|
||||
* 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 { z } from '@kbn/zod';
|
||||
import { StateGraph, Annotation, Send } from '@langchain/langgraph';
|
||||
import type { ElasticsearchClient, Logger } from '@kbn/core/server';
|
||||
import { InferenceChatModel } from '@kbn/inference-langchain';
|
||||
import { getFieldTypeByPath, getFieldsTopValues } from '@kbn/wc-integration-utils';
|
||||
import type {
|
||||
IndexSourceDefinition,
|
||||
IndexSourceFilter,
|
||||
IndexSourceQueryFields,
|
||||
} from '@kbn/wci-common';
|
||||
import {
|
||||
getIndexInformation,
|
||||
getSampleDocuments,
|
||||
getLeafFields,
|
||||
type IndexInformation,
|
||||
type SampleDocument,
|
||||
} from '../utils';
|
||||
import {
|
||||
generateDescriptionPrompt,
|
||||
pickFilterFieldsPrompt,
|
||||
pickQueryFieldsPrompt,
|
||||
pickContentFieldsPrompt,
|
||||
generateFilterPrompt,
|
||||
} from './prompts';
|
||||
|
||||
export const createSchemaGraph = async ({
|
||||
chatModel,
|
||||
esClient,
|
||||
}: {
|
||||
chatModel: InferenceChatModel;
|
||||
esClient: ElasticsearchClient;
|
||||
logger: Logger;
|
||||
}) => {
|
||||
const StateAnnotation = Annotation.Root({
|
||||
indexName: Annotation<string>(),
|
||||
indexInfo: Annotation<IndexInformation>,
|
||||
sampleDocuments: Annotation<SampleDocument[]>,
|
||||
fieldTopValues: Annotation<Record<string, string[]>>,
|
||||
// temporary
|
||||
filterFields: Annotation<string[]>,
|
||||
queryFields: Annotation<string[]>,
|
||||
contentFields: Annotation<string[]>,
|
||||
description: Annotation<string>(),
|
||||
// output
|
||||
generatedDefinition: Annotation<Partial<IndexSourceDefinition>>({
|
||||
reducer: (a, b) => ({
|
||||
...a,
|
||||
...b,
|
||||
filterFields: [...(a.filterFields ?? []), ...(b.filterFields ?? [])],
|
||||
}),
|
||||
default: () => ({}),
|
||||
}),
|
||||
});
|
||||
|
||||
type StateType = typeof StateAnnotation.State;
|
||||
type BuildFilterStateType = StateType & {
|
||||
fieldName: string;
|
||||
};
|
||||
|
||||
const gatherIndexInfo = async (state: StateType) => {
|
||||
const indexInfo = await getIndexInformation({
|
||||
indexName: state.indexName,
|
||||
esClient,
|
||||
});
|
||||
const sampleDocuments = await getSampleDocuments({
|
||||
indexName: state.indexName,
|
||||
esClient,
|
||||
maxSamples: 3,
|
||||
});
|
||||
|
||||
const leafFields = getLeafFields({ mappings: indexInfo.mappings });
|
||||
|
||||
const fieldTopValues = await getFieldsTopValues({
|
||||
indexName: state.indexName,
|
||||
esClient,
|
||||
maxSize: 20,
|
||||
fieldNames: leafFields.filter((field) => field.type === 'keyword').map((field) => field.path),
|
||||
});
|
||||
|
||||
return {
|
||||
indexInfo,
|
||||
sampleDocuments,
|
||||
fieldTopValues,
|
||||
generatedDefinition: { index: state.indexName },
|
||||
};
|
||||
};
|
||||
|
||||
const pickFilterFields = async (state: StateType) => {
|
||||
const structuredModel = chatModel.withStructuredOutput(
|
||||
z.object({
|
||||
fields: z.array(z.string()).describe('The list of fields to use as filter fields'),
|
||||
})
|
||||
);
|
||||
|
||||
const response = await structuredModel.invoke(
|
||||
pickFilterFieldsPrompt({
|
||||
indexName: state.indexName,
|
||||
indexInfo: state.indexInfo,
|
||||
fieldTopValues: state.fieldTopValues,
|
||||
sampleDocuments: state.sampleDocuments,
|
||||
})
|
||||
);
|
||||
|
||||
return { filterFields: response.fields };
|
||||
};
|
||||
|
||||
const dispatchFilterFields = async (state: StateType) => {
|
||||
return state.filterFields.map((filterField) => {
|
||||
return new Send('build_filter_field', { ...state, fieldName: filterField });
|
||||
});
|
||||
};
|
||||
|
||||
const buildFilterField = async (state: BuildFilterStateType) => {
|
||||
const structuredModel = chatModel.withStructuredOutput(
|
||||
z.object({
|
||||
description: z
|
||||
.string()
|
||||
.describe('the description for the filter. Please refer to the instruction'),
|
||||
asEnum: z
|
||||
.boolean()
|
||||
.describe('The asEnum value for the filter. Please refer to the instructions'),
|
||||
})
|
||||
);
|
||||
|
||||
const fieldName = state.fieldName;
|
||||
const fieldType = getFieldTypeByPath({
|
||||
fieldPath: state.fieldName,
|
||||
mappings: state.indexInfo.mappings,
|
||||
});
|
||||
const fieldTopValues = state.fieldTopValues[fieldName];
|
||||
|
||||
const response = await structuredModel.invoke(
|
||||
generateFilterPrompt({
|
||||
indexName: state.indexName,
|
||||
fieldName,
|
||||
fieldType,
|
||||
fieldTopValues,
|
||||
sampleDocuments: state.sampleDocuments,
|
||||
})
|
||||
);
|
||||
|
||||
const filterField: IndexSourceFilter = {
|
||||
field: fieldName,
|
||||
type: fieldType,
|
||||
description: response.description ?? '',
|
||||
asEnum: response.asEnum ?? false,
|
||||
};
|
||||
|
||||
return {
|
||||
generatedDefinition: {
|
||||
filterFields: [filterField],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const pickQueryFields = async (state: StateType) => {
|
||||
const structuredModel = chatModel.withStructuredOutput(
|
||||
z.object({
|
||||
fields: z.array(z.string()).describe('The list of fields to use as fulltext fields'),
|
||||
})
|
||||
);
|
||||
|
||||
const response = await structuredModel.invoke(
|
||||
pickQueryFieldsPrompt({
|
||||
indexName: state.indexName,
|
||||
indexInfo: state.indexInfo,
|
||||
sampleDocuments: state.sampleDocuments,
|
||||
})
|
||||
);
|
||||
|
||||
return { queryFields: response.fields };
|
||||
};
|
||||
|
||||
const buildQueryFields = async (state: StateType) => {
|
||||
const {
|
||||
indexInfo: { mappings },
|
||||
} = state;
|
||||
|
||||
const queryFields: IndexSourceQueryFields[] = state.queryFields.map((field) => {
|
||||
return {
|
||||
field,
|
||||
type: getFieldTypeByPath({ fieldPath: field, mappings })!,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
generatedDefinition: {
|
||||
queryFields,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
///////
|
||||
const pickContentFields = async (state: StateType) => {
|
||||
const structuredModel = chatModel.withStructuredOutput(
|
||||
z.object({
|
||||
fields: z.array(z.string()).describe('The list of fields to use as content fields'),
|
||||
})
|
||||
);
|
||||
|
||||
const response = await structuredModel.invoke(
|
||||
pickContentFieldsPrompt({
|
||||
indexName: state.indexName,
|
||||
indexInfo: state.indexInfo,
|
||||
sampleDocuments: state.sampleDocuments,
|
||||
})
|
||||
);
|
||||
|
||||
return { contentFields: response.fields };
|
||||
};
|
||||
|
||||
const buildContentFields = async (state: StateType) => {
|
||||
const {
|
||||
indexInfo: { mappings },
|
||||
} = state;
|
||||
|
||||
const contentFields: IndexSourceQueryFields[] = state.contentFields.map((field) => {
|
||||
return {
|
||||
field,
|
||||
type: getFieldTypeByPath({ fieldPath: field, mappings })!,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
generatedDefinition: {
|
||||
contentFields,
|
||||
},
|
||||
};
|
||||
};
|
||||
///////
|
||||
|
||||
const generateDescription = async (state: StateType) => {
|
||||
const structuredModel = chatModel.withStructuredOutput(
|
||||
z.object({
|
||||
description: z.string().describe('The description for the tool'),
|
||||
})
|
||||
);
|
||||
|
||||
const response = await structuredModel.invoke(
|
||||
generateDescriptionPrompt({
|
||||
sourceDefinition: state.generatedDefinition,
|
||||
indexName: state.indexName,
|
||||
indexInfo: state.indexInfo,
|
||||
sampleDocuments: state.sampleDocuments,
|
||||
})
|
||||
);
|
||||
return { generatedDefinition: { description: response.description } };
|
||||
};
|
||||
|
||||
const graph = new StateGraph(StateAnnotation)
|
||||
// nodes
|
||||
.addNode('gather_index_info', gatherIndexInfo)
|
||||
.addNode('pick_filter_fields', pickFilterFields)
|
||||
.addNode('pick_query_fields', pickQueryFields)
|
||||
.addNode('build_query_fields', buildQueryFields)
|
||||
.addNode('build_filter_field', buildFilterField)
|
||||
.addNode('generate_description', generateDescription)
|
||||
.addNode('pick_content_fields', pickContentFields)
|
||||
.addNode('build_content_fields', buildContentFields)
|
||||
|
||||
// transitions
|
||||
.addEdge('__start__', 'gather_index_info')
|
||||
.addEdge('gather_index_info', 'pick_filter_fields')
|
||||
.addEdge('gather_index_info', 'pick_query_fields')
|
||||
.addEdge('gather_index_info', 'pick_content_fields')
|
||||
.addEdge('pick_query_fields', 'build_query_fields')
|
||||
.addEdge('build_query_fields', 'generate_description')
|
||||
.addEdge('pick_content_fields', 'build_content_fields')
|
||||
.addEdge('build_content_fields', 'generate_description')
|
||||
.addEdge('generate_description', '__end__')
|
||||
.addConditionalEdges('pick_filter_fields', dispatchFilterFields, {
|
||||
build_filter_field: 'build_filter_field',
|
||||
})
|
||||
.addEdge('build_filter_field', 'generate_description')
|
||||
// done
|
||||
.compile();
|
||||
|
||||
return graph;
|
||||
};
|
|
@ -0,0 +1,319 @@
|
|||
/*
|
||||
* 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 { BaseMessageLike } from '@langchain/core/messages';
|
||||
import type { IndexSourceDefinition } from '@kbn/wci-common';
|
||||
import { type IndexInformation, type SampleDocument } from '../utils';
|
||||
|
||||
export const generateDescriptionPrompt = ({
|
||||
indexName,
|
||||
indexInfo,
|
||||
sampleDocuments,
|
||||
sourceDefinition,
|
||||
}: {
|
||||
indexName: string;
|
||||
indexInfo: IndexInformation;
|
||||
sampleDocuments: SampleDocument[];
|
||||
sourceDefinition: Partial<IndexSourceDefinition>;
|
||||
}): BaseMessageLike[] => {
|
||||
return [
|
||||
[
|
||||
'system',
|
||||
`You are an helpful AI assistant, with expert knowledge of Elasticsearch and the Elastic stack.
|
||||
Your current job is to generate schemas to describe Elasticsearch indices, following a predefined format.
|
||||
`,
|
||||
],
|
||||
[
|
||||
'human',
|
||||
`
|
||||
## Task description
|
||||
|
||||
You are building a schema representing a tool that can be used by an LLM to query an Elasticsearch index.
|
||||
You previously generated the information about which field should be used for full text search (query fields),
|
||||
and which ones should be used for filtering (filter fields).
|
||||
|
||||
Your current task is to generate a description for the tool. The description will be used for the LLM
|
||||
to know what the tool can be used for.
|
||||
|
||||
- Please keep the description relatively short - ideally not more than a few lines
|
||||
- Describe *what the tool can be used to query*, not the index
|
||||
|
||||
E.g
|
||||
|
||||
"This tool can be used to query the [logs] index, which contains log entries from a web application.
|
||||
most of the logs are access logs from NGInx."
|
||||
|
||||
### Base information:
|
||||
|
||||
- index name: ${indexName}
|
||||
|
||||
### Tool definition:
|
||||
|
||||
${JSON.stringify(sourceDefinition)}
|
||||
|
||||
### Mappings:
|
||||
|
||||
${JSON.stringify(indexInfo.mappings)}
|
||||
|
||||
### Sample documents:
|
||||
|
||||
${JSON.stringify(sampleDocuments)}
|
||||
|
||||
`,
|
||||
],
|
||||
];
|
||||
};
|
||||
|
||||
export const pickFilterFieldsPrompt = ({
|
||||
indexName,
|
||||
indexInfo,
|
||||
sampleDocuments,
|
||||
fieldTopValues,
|
||||
maxFilters = 5,
|
||||
}: {
|
||||
indexName: string;
|
||||
indexInfo: IndexInformation;
|
||||
sampleDocuments: SampleDocument[];
|
||||
fieldTopValues: Record<string, string[]>;
|
||||
maxFilters?: number;
|
||||
}): BaseMessageLike[] => {
|
||||
return [
|
||||
[
|
||||
'system',
|
||||
`You are an helpful AI assistant, with expert knowledge of Elasticsearch and the Elastic stack.
|
||||
Your current job is to generate schemas to describe Elasticsearch indices, following a predefined format.
|
||||
`,
|
||||
],
|
||||
[
|
||||
'human',
|
||||
`
|
||||
|
||||
## Task description
|
||||
|
||||
We want to generate a search schema for the index ${indexName}. For that purpose,
|
||||
we want to select the fields will be defined as "filters" in the schema.
|
||||
|
||||
"Filter" fields are basically fields that the user will be able to use in the UI to create search filter.
|
||||
E.g. if the "category" field is a filter, then the user will be able to search for "category: CAT".
|
||||
|
||||
## Additional directives
|
||||
|
||||
- "meta" fields that don't represent anything concrete are irrelevant, and shouldn't be picked
|
||||
- e.g 'inference_id' or '_run_inference'
|
||||
|
||||
- please pick no more than *${maxFilters}* fields.
|
||||
|
||||
## Index information
|
||||
|
||||
Here are some information to help you in your decision:
|
||||
|
||||
### Base information:
|
||||
|
||||
- index name: ${indexName}
|
||||
|
||||
### Mappings:
|
||||
|
||||
${JSON.stringify(indexInfo.mappings)}
|
||||
|
||||
### Sample documents:
|
||||
|
||||
${JSON.stringify(sampleDocuments)}
|
||||
|
||||
### Fields top values:
|
||||
|
||||
${Object.entries(fieldTopValues)
|
||||
.map(([key, values]) => {
|
||||
return `- field ${key}: ${values.join(', ')}`;
|
||||
})
|
||||
.join('\n')}
|
||||
|
||||
Given the previous information, please list the fields that you think would make the most sense to be used as filter.
|
||||
`,
|
||||
],
|
||||
];
|
||||
};
|
||||
|
||||
export const generateFilterPrompt = ({
|
||||
indexName,
|
||||
fieldName,
|
||||
fieldType,
|
||||
sampleDocuments,
|
||||
fieldTopValues,
|
||||
}: {
|
||||
indexName: string;
|
||||
fieldName: string;
|
||||
fieldType: string;
|
||||
|
||||
sampleDocuments: SampleDocument[];
|
||||
fieldTopValues?: string[];
|
||||
}): BaseMessageLike[] => {
|
||||
return [
|
||||
[
|
||||
'system',
|
||||
`You are an helpful AI assistant, with expert knowledge of Elasticsearch and the Elastic stack.
|
||||
Your current job is to generate schemas to describe Elasticsearch indices, following a predefined format.
|
||||
`,
|
||||
],
|
||||
[
|
||||
'human',
|
||||
`
|
||||
|
||||
## Task description
|
||||
|
||||
We previously selected a list of fields from the index's mappings that will be used as filter in the schema.
|
||||
|
||||
"Filter" fields are basically fields that the user will be able to use in the UI to create search filter.
|
||||
E.g. if the "category" field is a filter, then the user will be able to search for "category: CAT".
|
||||
|
||||
We now want to generate the full filter definition for the "${fieldName}" field, which is of type "${fieldType}".
|
||||
|
||||
The filter definition is composed of:
|
||||
- description: (string) a short description for what the field/filter can be used for
|
||||
- asEnum: (boolean) if true, the filter will behave as an enum: the field's top values will be fetched at query time and
|
||||
listed in the description, and using the filter will be limited to doing it against those values.
|
||||
|
||||
## Additional directives
|
||||
|
||||
- 'asEnum' can only be true if the field type is 'keyword' or 'boolean'
|
||||
|
||||
## Context information
|
||||
|
||||
Here are some information to help you in your decision:
|
||||
|
||||
### Base information:
|
||||
|
||||
- index name: ${indexName}
|
||||
- filter field name: ${fieldName}
|
||||
- filter field type: ${fieldType}
|
||||
|
||||
### Sample documents:
|
||||
|
||||
${JSON.stringify(sampleDocuments)}
|
||||
|
||||
### Fields top values:
|
||||
|
||||
${fieldTopValues?.map((value) => `- ${value}`).join('\n') ?? 'No top values for that field type'}
|
||||
|
||||
Given the previous information, generate the filter definition.
|
||||
`,
|
||||
],
|
||||
];
|
||||
};
|
||||
|
||||
export const pickQueryFieldsPrompt = ({
|
||||
indexName,
|
||||
indexInfo,
|
||||
sampleDocuments,
|
||||
maxFields = 2,
|
||||
}: {
|
||||
indexName: string;
|
||||
indexInfo: IndexInformation;
|
||||
sampleDocuments: SampleDocument[];
|
||||
maxFields?: number;
|
||||
}): BaseMessageLike[] => {
|
||||
return [
|
||||
[
|
||||
'system',
|
||||
`You are an helpful AI assistant, with expert knowledge of Elasticsearch and the Elastic stack.
|
||||
Your current job is to generate schemas to describe Elasticsearch indices, following a predefined format.
|
||||
`,
|
||||
],
|
||||
[
|
||||
'human',
|
||||
`
|
||||
|
||||
## Task description
|
||||
|
||||
We want to generate a search schema for the index ${indexName}. For that purpose,
|
||||
we want to select the fields will be defined as "fulltext search" fields in the schema.
|
||||
|
||||
"fulltext search" fields are fields that text queries will be performed against.
|
||||
E.g. when the user search for "red balloon CATEGORY:RED", we will perform a text search for "red balloon" against the fields
|
||||
defined as "fulltext search" field in the schema.
|
||||
|
||||
## Additional directives
|
||||
|
||||
- fulltext search fields can only be of type 'text' or 'semantic_text'
|
||||
- only includes the fields that are the most likely to contains "content" text
|
||||
- please pick no more than *${maxFields}* fields.
|
||||
|
||||
## Index information
|
||||
|
||||
Here are some information to help you in your decision:
|
||||
|
||||
### Base information:
|
||||
|
||||
- index name: ${indexName}
|
||||
|
||||
### Mappings:
|
||||
|
||||
${JSON.stringify(indexInfo.mappings)}
|
||||
|
||||
### Sample documents:
|
||||
|
||||
${JSON.stringify(sampleDocuments)}
|
||||
|
||||
Given the previous information, please list the fields that you think would make the most sense to be used as full text fields.
|
||||
`,
|
||||
],
|
||||
];
|
||||
};
|
||||
|
||||
export const pickContentFieldsPrompt = ({
|
||||
indexName,
|
||||
indexInfo,
|
||||
sampleDocuments,
|
||||
maxFields = 10,
|
||||
}: {
|
||||
indexName: string;
|
||||
indexInfo: IndexInformation;
|
||||
sampleDocuments: SampleDocument[];
|
||||
maxFields?: number;
|
||||
}): BaseMessageLike[] => {
|
||||
return [
|
||||
[
|
||||
'system',
|
||||
`You are an helpful AI assistant, with expert knowledge of Elasticsearch and the Elastic stack.
|
||||
Your current job is to generate schemas to describe Elasticsearch indices, following a predefined format.
|
||||
`,
|
||||
],
|
||||
[
|
||||
'human',
|
||||
`
|
||||
|
||||
## Task description
|
||||
|
||||
We want to generate a schema for a tool that will then be used by a LLM to query the index ${indexName}. For that purpose,
|
||||
you current task is to define the "content" fields, fields that will be returned by the tool as content for the LLM to use.
|
||||
|
||||
## Additional directives
|
||||
|
||||
- do not include "meta" fields such as _inference_id or similar without real value
|
||||
- please pick the fields that you think would be the most useful for the
|
||||
- please pick no more than *${maxFields}* fields.
|
||||
|
||||
## Index information
|
||||
|
||||
Here are some information to help you in your decision:
|
||||
|
||||
### Base information:
|
||||
|
||||
- index name: ${indexName}
|
||||
|
||||
### Mappings:
|
||||
|
||||
${JSON.stringify(indexInfo.mappings)}
|
||||
|
||||
### Sample documents:
|
||||
|
||||
${JSON.stringify(sampleDocuments)}
|
||||
|
||||
Given the previous information, please list the fields that you think would make the most sense to be used as content fields.
|
||||
`,
|
||||
],
|
||||
];
|
||||
};
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"extends": "../../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": [
|
||||
"jest",
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*"
|
||||
],
|
||||
"kbn_references": [
|
||||
"@kbn/core",
|
||||
"@kbn/inference-langchain",
|
||||
"@kbn/wci-common",
|
||||
"@kbn/zod",
|
||||
"@kbn/wc-integration-utils",
|
||||
]
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
# @kbn/wc-integration-utils
|
||||
|
||||
Empty package generated by @kbn/generate
|
14
x-pack/solutions/chat/packages/wc-integration-utils/index.ts
Normal file
14
x-pack/solutions/chat/packages/wc-integration-utils/index.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* 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 { getFieldsTopValues, getFieldTypeByPath } from './src/elasticsearch';
|
||||
export {
|
||||
generateSearchSchema,
|
||||
type SearchFilter,
|
||||
createFilterClauses,
|
||||
hitToContent,
|
||||
} from './src/tools';
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
preset: '@kbn/test/jest_node',
|
||||
rootDir: '../../../../..',
|
||||
roots: ['<rootDir>/x-pack/solutions/chat/packages/wc-integration-utils'],
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/wc-integration-utils",
|
||||
"owner": "@elastic/search-kibana",
|
||||
"group": "chat",
|
||||
"visibility": "private"
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@kbn/wc-integration-utils",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "Elastic License 2.0"
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* 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 { MappingTypeMapping } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { getFieldTypeByPath } from './get_field_type_by_path';
|
||||
|
||||
describe('getFieldTypeByPath', () => {
|
||||
it('returns the type for a top level field', () => {
|
||||
const mappings: MappingTypeMapping = {
|
||||
properties: {
|
||||
content: {
|
||||
type: 'text',
|
||||
},
|
||||
category: {
|
||||
type: 'keyword',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const type = getFieldTypeByPath({ fieldPath: 'content', mappings });
|
||||
|
||||
expect(type).toEqual('text');
|
||||
});
|
||||
|
||||
it('returns the type for a nested field', () => {
|
||||
const mappings: MappingTypeMapping = {
|
||||
properties: {
|
||||
nested: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
category: { type: 'keyword' },
|
||||
},
|
||||
},
|
||||
category: {
|
||||
type: 'keyword',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const type = getFieldTypeByPath({ fieldPath: 'nested.category', mappings });
|
||||
|
||||
expect(type).toEqual('keyword');
|
||||
});
|
||||
|
||||
it('throw an error for fields not present in the mappings', () => {
|
||||
const mappings: MappingTypeMapping = {
|
||||
properties: {
|
||||
content: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
getFieldTypeByPath({ fieldPath: 'missing', mappings })
|
||||
).toThrowErrorMatchingInlineSnapshot(`"Field 'missing' not found in mappings"`);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* 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 { MappingTypeMapping } from '@elastic/elasticsearch/lib/api/types';
|
||||
|
||||
interface MappingProperties {
|
||||
[key: string]: {
|
||||
type?: string;
|
||||
properties?: MappingProperties;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the type of a given field from its path in the provided mappings.
|
||||
*/
|
||||
export const getFieldTypeByPath = ({
|
||||
fieldPath,
|
||||
mappings,
|
||||
}: {
|
||||
fieldPath: string;
|
||||
mappings: MappingTypeMapping;
|
||||
}): string => {
|
||||
let properties: MappingProperties = mappings.properties ?? {};
|
||||
|
||||
const paths = fieldPath.split('.');
|
||||
for (let i = 0; i < paths.length; i++) {
|
||||
const path = paths[i];
|
||||
const isLast = i === paths.length - 1;
|
||||
if (isLast) {
|
||||
if (properties[path]?.type) {
|
||||
return properties[path]?.type!;
|
||||
} else {
|
||||
throw Error(`Field '${fieldPath}' not found in mappings`);
|
||||
}
|
||||
} else {
|
||||
if (properties[path]?.properties) {
|
||||
properties = properties[path]!.properties!;
|
||||
} else {
|
||||
throw Error(`Field '${fieldPath}' not found in mappings`);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw Error(`Exited loop without return (should never happen)`);
|
||||
};
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* 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 { AggregationsStringTermsAggregate } from '@elastic/elasticsearch/lib/api/types';
|
||||
import type { ElasticsearchClient } from '@kbn/core/server';
|
||||
|
||||
export const getFieldsTopValues = async ({
|
||||
indexName,
|
||||
fieldNames,
|
||||
esClient,
|
||||
maxSize = 20,
|
||||
}: {
|
||||
indexName: string;
|
||||
fieldNames: string[];
|
||||
esClient: ElasticsearchClient;
|
||||
maxSize?: number;
|
||||
}): Promise<Record<string, string[]>> => {
|
||||
const aggResult = await esClient.search({
|
||||
index: indexName,
|
||||
size: 0,
|
||||
aggs: Object.fromEntries(
|
||||
fieldNames.map((field) => [
|
||||
field,
|
||||
{
|
||||
terms: {
|
||||
field,
|
||||
size: maxSize,
|
||||
},
|
||||
},
|
||||
])
|
||||
),
|
||||
});
|
||||
|
||||
const aggregations = aggResult.aggregations!;
|
||||
|
||||
const topValues = fieldNames.reduce((map, fieldName) => {
|
||||
const aggr = aggregations[fieldName] as AggregationsStringTermsAggregate;
|
||||
|
||||
if (aggr.buckets && Array.isArray(aggr.buckets)) {
|
||||
// key | doc_count
|
||||
const values = aggr.buckets.map((bucket) => bucket.key as string);
|
||||
map[fieldName] = values;
|
||||
}
|
||||
|
||||
// aggr.buckets[0]
|
||||
|
||||
return map;
|
||||
}, {} as Record<string, string[]>);
|
||||
|
||||
return topValues;
|
||||
};
|
|
@ -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 { getFieldsTopValues } from './get_fields_top_values';
|
||||
export { getFieldTypeByPath } from './get_field_type_by_path';
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* 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 { createFilterClauses } from './create_filter_clauses';
|
||||
import type { SearchFilter } from './generate_search_schema';
|
||||
|
||||
describe('createFilterClauses', () => {
|
||||
it('generate the correct clauses for a keyword filter', () => {
|
||||
const filter: SearchFilter = {
|
||||
type: 'keyword',
|
||||
field: 'foo',
|
||||
description: 'foo filter',
|
||||
};
|
||||
|
||||
const values = {
|
||||
foo: 'bar',
|
||||
hello: 'dolly',
|
||||
};
|
||||
|
||||
const output = createFilterClauses({ filters: [filter], values });
|
||||
|
||||
expect(output).toEqual([{ term: { foo: 'bar' } }]);
|
||||
});
|
||||
|
||||
it('generate the correct clauses for a boolean filter', () => {
|
||||
const filter: SearchFilter = {
|
||||
type: 'boolean',
|
||||
field: 'isActive',
|
||||
description: 'active status filter',
|
||||
};
|
||||
|
||||
const values = {
|
||||
isActive: true,
|
||||
otherField: 'ignored',
|
||||
};
|
||||
|
||||
const output = createFilterClauses({ filters: [filter], values });
|
||||
|
||||
expect(output).toEqual([{ term: { isActive: true } }]);
|
||||
});
|
||||
|
||||
it('handles multiple filters correctly', () => {
|
||||
const filters: SearchFilter[] = [
|
||||
{
|
||||
type: 'keyword',
|
||||
field: 'foo',
|
||||
description: 'foo filter',
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
field: 'isActive',
|
||||
description: 'active status filter',
|
||||
},
|
||||
];
|
||||
|
||||
const values = {
|
||||
foo: 'bar',
|
||||
isActive: true,
|
||||
ignored: 'value',
|
||||
};
|
||||
|
||||
const output = createFilterClauses({ filters, values });
|
||||
|
||||
expect(output).toEqual([{ term: { foo: 'bar' } }, { term: { isActive: true } }]);
|
||||
});
|
||||
|
||||
it('returns empty array when no values are provided', () => {
|
||||
const filters: SearchFilter[] = [
|
||||
{
|
||||
type: 'keyword',
|
||||
field: 'foo',
|
||||
description: 'foo filter',
|
||||
},
|
||||
];
|
||||
|
||||
const values = {};
|
||||
|
||||
const output = createFilterClauses({ filters, values });
|
||||
|
||||
expect(output).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores values that do not have matching filters', () => {
|
||||
const filters: SearchFilter[] = [
|
||||
{
|
||||
type: 'keyword',
|
||||
field: 'foo',
|
||||
description: 'foo filter',
|
||||
},
|
||||
];
|
||||
|
||||
const values = {
|
||||
foo: 'bar',
|
||||
unmatched: 'value',
|
||||
anotherUnmatched: 123,
|
||||
};
|
||||
|
||||
const output = createFilterClauses({ filters, values });
|
||||
|
||||
expect(output).toEqual([{ term: { foo: 'bar' } }]);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* 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 { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
|
||||
import type { SearchFilter } from './generate_search_schema';
|
||||
|
||||
export const createFilterClauses = ({
|
||||
filters,
|
||||
values,
|
||||
}: {
|
||||
filters: SearchFilter[];
|
||||
values: Record<string, unknown>;
|
||||
}): QueryDslQueryContainer[] => {
|
||||
const clauses: QueryDslQueryContainer[] = [];
|
||||
|
||||
Object.entries(values).forEach(([field, value]) => {
|
||||
const filter = filters.find((f) => f.field === field);
|
||||
if (filter) {
|
||||
if (filter.type === 'keyword' || filter.type === 'boolean') {
|
||||
clauses.push({
|
||||
term: { [field]: value },
|
||||
});
|
||||
}
|
||||
// TODO: handle other field types, date mostly
|
||||
}
|
||||
});
|
||||
|
||||
return clauses;
|
||||
};
|
|
@ -0,0 +1,145 @@
|
|||
/*
|
||||
* 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 { z } from '@kbn/zod';
|
||||
import { generateSearchSchema, type SearchFilter } from './generate_search_schema';
|
||||
|
||||
describe('generateSearchSchema', () => {
|
||||
function getTypeName(schema: z.Schema): string | undefined {
|
||||
schema = unwrap(schema);
|
||||
|
||||
const typeName =
|
||||
'typeName' in schema._def && typeof schema._def.typeName === 'string'
|
||||
? schema._def.typeName
|
||||
: undefined;
|
||||
|
||||
return typeName;
|
||||
}
|
||||
|
||||
function unwrap(schema: z.Schema) {
|
||||
if (schema.isOptional()) {
|
||||
return (schema as z.ZodOptional<any>).unwrap();
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
it('should generate a schema with query field by default', () => {
|
||||
const schema = generateSearchSchema({ filters: [] });
|
||||
expect(schema.query).toBeDefined();
|
||||
expect(getTypeName(schema.query)).toBe('ZodString');
|
||||
expect(schema.query.isOptional()).toBe(true);
|
||||
});
|
||||
|
||||
it('should generate schema for keyword filter without predefined values', () => {
|
||||
const filters: SearchFilter[] = [
|
||||
{
|
||||
field: 'status',
|
||||
type: 'keyword',
|
||||
description: 'Filter by status',
|
||||
},
|
||||
];
|
||||
const schema = generateSearchSchema({ filters });
|
||||
|
||||
expect(schema.status).toBeDefined();
|
||||
expect(getTypeName(schema.status)).toBe('ZodString');
|
||||
expect(schema.status.isOptional()).toBe(true);
|
||||
});
|
||||
|
||||
it('should generate schema for keyword filter with predefined values', () => {
|
||||
const filters: SearchFilter[] = [
|
||||
{
|
||||
field: 'status',
|
||||
type: 'keyword',
|
||||
description: 'Filter by status',
|
||||
values: ['active', 'inactive', 'pending'],
|
||||
},
|
||||
];
|
||||
const schema = generateSearchSchema({ filters });
|
||||
|
||||
expect(schema.status).toBeDefined();
|
||||
expect(getTypeName(schema.status)).toBe('ZodEnum');
|
||||
expect((unwrap(schema.status) as z.ZodEnum<any>).options).toEqual([
|
||||
'active',
|
||||
'inactive',
|
||||
'pending',
|
||||
]);
|
||||
expect(schema.status.isOptional()).toBe(true);
|
||||
});
|
||||
|
||||
it('should generate schema for date filter', () => {
|
||||
const filters: SearchFilter[] = [
|
||||
{
|
||||
field: 'created_at',
|
||||
type: 'date',
|
||||
description: 'Filter by creation date',
|
||||
},
|
||||
];
|
||||
const schema = generateSearchSchema({ filters });
|
||||
|
||||
expect(schema.created_at).toBeDefined();
|
||||
expect(getTypeName(schema.created_at)).toBe('ZodString');
|
||||
expect(schema.created_at.isOptional()).toBe(true);
|
||||
});
|
||||
|
||||
it('should generate schema for boolean filter', () => {
|
||||
const filters: SearchFilter[] = [
|
||||
{
|
||||
field: 'is_active',
|
||||
type: 'boolean',
|
||||
description: 'Filter by active status',
|
||||
},
|
||||
];
|
||||
const schema = generateSearchSchema({ filters });
|
||||
|
||||
expect(schema.is_active).toBeDefined();
|
||||
expect(getTypeName(schema.is_active)).toBe('ZodBoolean');
|
||||
expect(schema.is_active.isOptional()).toBe(true);
|
||||
});
|
||||
|
||||
it('should combine multiple filters in the schema', () => {
|
||||
const filters: SearchFilter[] = [
|
||||
{
|
||||
field: 'status',
|
||||
type: 'keyword',
|
||||
description: 'Filter by status',
|
||||
values: ['active', 'inactive'],
|
||||
},
|
||||
{
|
||||
field: 'created_at',
|
||||
type: 'date',
|
||||
description: 'Filter by creation date',
|
||||
},
|
||||
{
|
||||
field: 'is_active',
|
||||
type: 'boolean',
|
||||
description: 'Filter by active status',
|
||||
},
|
||||
];
|
||||
const schema = generateSearchSchema({ filters });
|
||||
|
||||
expect(schema.query).toBeDefined();
|
||||
expect(schema.status).toBeDefined();
|
||||
expect(schema.created_at).toBeDefined();
|
||||
expect(schema.is_active).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle empty values array for keyword filter', () => {
|
||||
const filters: SearchFilter[] = [
|
||||
{
|
||||
field: 'status',
|
||||
type: 'keyword',
|
||||
description: 'Filter by status',
|
||||
values: [],
|
||||
},
|
||||
];
|
||||
const schema = generateSearchSchema({ filters });
|
||||
|
||||
expect(schema.status).toBeDefined();
|
||||
expect(getTypeName(schema.status)).toBe('ZodString');
|
||||
expect(schema.status.isOptional()).toBe(true);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* 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 { z } from '@kbn/zod';
|
||||
|
||||
export interface SearchFilter {
|
||||
field: string;
|
||||
type: 'keyword' | 'date' | 'boolean';
|
||||
description: string;
|
||||
values?: string[];
|
||||
}
|
||||
|
||||
export const generateSearchSchema = ({ filters }: { filters: SearchFilter[] }) => {
|
||||
return filters.reduce<Record<string, z.ZodType>>(
|
||||
(schema, filter) => {
|
||||
return {
|
||||
...schema,
|
||||
...generateFilterSchema({ filter }),
|
||||
};
|
||||
},
|
||||
{
|
||||
query: z.string().describe('A query to use for fulltext search').optional(),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const generateFilterSchema = ({
|
||||
filter,
|
||||
}: {
|
||||
filter: SearchFilter;
|
||||
}): Record<string, z.ZodType> => {
|
||||
switch (filter.type) {
|
||||
case 'keyword':
|
||||
if (filter.values && filter.values.length > 0) {
|
||||
return {
|
||||
[filter.field]: z
|
||||
.enum(filter.values as [string, ...string[]])
|
||||
.describe(filter.description)
|
||||
.optional(),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
[filter.field]: z.string().describe(filter.description).optional(),
|
||||
};
|
||||
}
|
||||
case 'date':
|
||||
return {
|
||||
[filter.field]: z
|
||||
.string()
|
||||
.datetime({ offset: true })
|
||||
.describe(`${filter.description} - use ISO 8601 format`)
|
||||
.optional(),
|
||||
};
|
||||
case 'boolean':
|
||||
return { [filter.field]: z.boolean().describe(filter.description).optional() };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
* 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 { SearchHit } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { hitToContent } from './hit_to_content';
|
||||
|
||||
const createSearchHit = (overrides: Partial<SearchHit> = {}): SearchHit => ({
|
||||
_index: 'test-index',
|
||||
_id: 'test-id',
|
||||
_score: 1,
|
||||
_source: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('hitToContent', () => {
|
||||
it('should return highlighted fields when highlights are present', () => {
|
||||
const hit = createSearchHit({
|
||||
_source: {
|
||||
title: 'Original title',
|
||||
content: 'Original content',
|
||||
},
|
||||
highlight: {
|
||||
title: ['Highlighted title'],
|
||||
content: ['Highlighted content'],
|
||||
},
|
||||
});
|
||||
|
||||
const result = hitToContent({
|
||||
hit,
|
||||
fields: ['title', 'content'],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
title: ['Highlighted title'],
|
||||
content: ['Highlighted content'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should fall back to _source when highlights are not present', () => {
|
||||
const hit = createSearchHit({
|
||||
_source: {
|
||||
title: 'Original title',
|
||||
content: 'Original content',
|
||||
},
|
||||
});
|
||||
|
||||
const result = hitToContent({
|
||||
hit,
|
||||
fields: ['title', 'content'],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
title: 'Original title',
|
||||
content: 'Original content',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle undefined _source', () => {
|
||||
const hit = createSearchHit({
|
||||
_source: undefined,
|
||||
highlight: {
|
||||
title: ['Highlighted title'],
|
||||
},
|
||||
});
|
||||
|
||||
const result = hitToContent({
|
||||
hit,
|
||||
fields: ['title', 'content'],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
title: ['Highlighted title'],
|
||||
content: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle non-existent fields', () => {
|
||||
const hit = createSearchHit({
|
||||
_source: {
|
||||
title: 'Original title',
|
||||
},
|
||||
});
|
||||
|
||||
const result = hitToContent({
|
||||
hit,
|
||||
fields: ['title', 'nonExistentField'],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
title: 'Original title',
|
||||
nonExistentField: undefined,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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 { get } from 'lodash';
|
||||
import type { SearchHit } from '@elastic/elasticsearch/lib/api/types';
|
||||
|
||||
export const hitToContent = ({
|
||||
hit,
|
||||
fields,
|
||||
}: {
|
||||
hit: SearchHit;
|
||||
fields: string[];
|
||||
}): Record<string, unknown> => {
|
||||
const content: Record<string, unknown> = {};
|
||||
|
||||
fields.forEach((field) => {
|
||||
if (hit.highlight?.[field]) {
|
||||
content[field] = hit.highlight?.[field];
|
||||
} else {
|
||||
content[field] = get(hit._source ?? {}, field);
|
||||
}
|
||||
});
|
||||
|
||||
return content;
|
||||
};
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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 { generateSearchSchema, type SearchFilter } from './generate_search_schema';
|
||||
export { createFilterClauses } from './create_filter_clauses';
|
||||
export { hitToContent } from './hit_to_content';
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"extends": "../../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": [
|
||||
"jest",
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*"
|
||||
],
|
||||
"kbn_references": [
|
||||
"@kbn/core",
|
||||
"@kbn/zod",
|
||||
]
|
||||
}
|
3
x-pack/solutions/chat/packages/wci-browser/README.md
Normal file
3
x-pack/solutions/chat/packages/wci-browser/README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# @kbn/wci-browser
|
||||
|
||||
Empty package generated by @kbn/generate
|
12
x-pack/solutions/chat/packages/wci-browser/index.ts
Normal file
12
x-pack/solutions/chat/packages/wci-browser/index.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* 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 type {
|
||||
IntegrationComponentDescriptor,
|
||||
IntegrationToolComponentProps,
|
||||
IntegrationConfigurationFormProps,
|
||||
} from './src/integration_ui_descriptor';
|
12
x-pack/solutions/chat/packages/wci-browser/jest.config.js
Normal file
12
x-pack/solutions/chat/packages/wci-browser/jest.config.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
preset: '@kbn/test',
|
||||
rootDir: '../../../../..',
|
||||
roots: ['<rootDir>/x-pack/solutions/chat/packages/wci-browser'],
|
||||
};
|
7
x-pack/solutions/chat/packages/wci-browser/kibana.jsonc
Normal file
7
x-pack/solutions/chat/packages/wci-browser/kibana.jsonc
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"type": "shared-browser",
|
||||
"id": "@kbn/wci-browser",
|
||||
"owner": "@elastic/search-kibana",
|
||||
"group": "chat",
|
||||
"visibility": "private"
|
||||
}
|
7
x-pack/solutions/chat/packages/wci-browser/package.json
Normal file
7
x-pack/solutions/chat/packages/wci-browser/package.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "@kbn/wci-browser",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "Elastic License 2.0",
|
||||
"sideEffects": false
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
import { IntegrationType, Integration, ToolCall } from '@kbn/wci-common';
|
||||
|
||||
export interface IntegrationComponentDescriptor {
|
||||
getType: () => IntegrationType;
|
||||
getConfigurationForm: () => React.ComponentType<IntegrationConfigurationFormProps>;
|
||||
getToolCallComponent: (toolName: string) => React.ComponentType<IntegrationToolComponentProps>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props that will be passed to the tool call component
|
||||
*/
|
||||
export interface IntegrationToolComponentProps {
|
||||
/**
|
||||
* The integration the call was made with
|
||||
*/
|
||||
integration: Integration;
|
||||
/**
|
||||
* The tool call to render
|
||||
*/
|
||||
toolCall: ToolCall;
|
||||
/**
|
||||
* If tool call is complete, will contain the string result of the call
|
||||
*/
|
||||
toolResult?: string;
|
||||
}
|
||||
|
||||
export interface IntegrationConfigurationFormProps {
|
||||
// TODO: fix this
|
||||
// shouldn't need this and use the useFormContext vs passing down as prop
|
||||
form: UseFormReturn<any>;
|
||||
}
|
21
x-pack/solutions/chat/packages/wci-browser/tsconfig.json
Normal file
21
x-pack/solutions/chat/packages/wci-browser/tsconfig.json
Normal file
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"extends": "../../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": [
|
||||
"jest",
|
||||
"node",
|
||||
"react"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*"
|
||||
],
|
||||
"kbn_references": [
|
||||
"@kbn/wci-common",
|
||||
]
|
||||
}
|
3
x-pack/solutions/chat/packages/wci-common/README.md
Normal file
3
x-pack/solutions/chat/packages/wci-common/README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# @kbn/wci-common
|
||||
|
||||
Contains shared types and constants for WorkChat integrations. For server-side implementation, see the `@kbn/wci-server` package.
|
20
x-pack/solutions/chat/packages/wci-common/index.ts
Normal file
20
x-pack/solutions/chat/packages/wci-common/index.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* 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 { IntegrationType } from './src/constants';
|
||||
export type { Integration, IntegrationConfiguration } from './src/integrations';
|
||||
export type { ToolCall } from './src/tool_calls';
|
||||
export {
|
||||
buildToolName,
|
||||
parseToolName,
|
||||
type ToolNameAndIntegrationId,
|
||||
} from './src/integration_tools';
|
||||
export type {
|
||||
IndexSourceDefinition,
|
||||
IndexSourceFilter,
|
||||
IndexSourceQueryFields,
|
||||
} from './src/index_source';
|
12
x-pack/solutions/chat/packages/wci-common/jest.config.js
Normal file
12
x-pack/solutions/chat/packages/wci-common/jest.config.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
preset: '@kbn/test',
|
||||
rootDir: '../../../../..',
|
||||
roots: ['<rootDir>/x-pack/solutions/chat/packages/wci-common'],
|
||||
};
|
9
x-pack/solutions/chat/packages/wci-common/kibana.jsonc
Normal file
9
x-pack/solutions/chat/packages/wci-common/kibana.jsonc
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/wci-common",
|
||||
"owner": [
|
||||
"@elastic/search-kibana"
|
||||
],
|
||||
"group": "chat",
|
||||
"visibility": "private"
|
||||
}
|
7
x-pack/solutions/chat/packages/wci-common/package.json
Normal file
7
x-pack/solutions/chat/packages/wci-common/package.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "@kbn/wci-common",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "Elastic License 2.0",
|
||||
"sideEffects": false
|
||||
}
|
12
x-pack/solutions/chat/packages/wci-common/src/constants.ts
Normal file
12
x-pack/solutions/chat/packages/wci-common/src/constants.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* 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 enum IntegrationType {
|
||||
salesforce = 'salesforce',
|
||||
index_source = 'index_source',
|
||||
external_server = 'external_server',
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents a definition for an index source.
|
||||
*
|
||||
* The definition contains all what's necessary for the system to build
|
||||
* the MCP tool that will then be used by the LLM to query the data.
|
||||
*/
|
||||
export interface IndexSourceDefinition {
|
||||
/**
|
||||
* ID of the index that is going to be used for this index source
|
||||
*/
|
||||
index: string;
|
||||
/**
|
||||
* A short description of what the index contains
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* List of fields that will be used for fulltext search.
|
||||
*/
|
||||
queryFields: IndexSourceQueryFields[];
|
||||
/**
|
||||
* List of possible filters when querying for the data
|
||||
*/
|
||||
filterFields: IndexSourceFilter[];
|
||||
/**
|
||||
* List of fields that will be returned as content by the tool
|
||||
*/
|
||||
contentFields: IndexSourceContentFields[];
|
||||
}
|
||||
|
||||
export interface IndexSourceFilter {
|
||||
/**
|
||||
* The name / path to the field
|
||||
* E.g. `content` or `reference.id`
|
||||
*/
|
||||
field: string;
|
||||
/**
|
||||
* The type of field. Should be the same type as defined in the mappings
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* A human-readable description for this filter.
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* If true, the field's top values will be fetched at query time,
|
||||
* and added to the description. The parameter will also be restricted
|
||||
* to only allow those values
|
||||
*/
|
||||
asEnum: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a field that will be used for full-text search.
|
||||
*/
|
||||
export interface IndexSourceQueryFields {
|
||||
/**
|
||||
* The name / path to the field
|
||||
* E.g. `content` or `reference.id`
|
||||
*/
|
||||
field: string;
|
||||
/**
|
||||
* The type of field. Should be the same type as defined in the mappings
|
||||
*/
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a field that will be used for full-text search.
|
||||
*/
|
||||
export interface IndexSourceContentFields {
|
||||
/**
|
||||
* The name / path to the field
|
||||
* E.g. `content` or `reference.id`
|
||||
*/
|
||||
field: string;
|
||||
/**
|
||||
* The type of field. Should be the same type as defined in the mappings
|
||||
*/
|
||||
type: string;
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* 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 { buildToolName, parseToolName, ToolNameAndIntegrationId } from './integration_tools';
|
||||
|
||||
describe('integration_tools', () => {
|
||||
describe('buildToolName', () => {
|
||||
it('should correctly concatenate toolName and integrationId', () => {
|
||||
const input: ToolNameAndIntegrationId = {
|
||||
toolName: 'searchTool',
|
||||
integrationId: 'elastic-search-123',
|
||||
};
|
||||
expect(buildToolName(input)).toBe('searchTool___elastic-search-123');
|
||||
});
|
||||
|
||||
it('should handle special characters in input', () => {
|
||||
const input: ToolNameAndIntegrationId = {
|
||||
toolName: 'special@#$!%',
|
||||
integrationId: 'integration&^*()',
|
||||
};
|
||||
expect(buildToolName(input)).toBe('special@#$!%___integration&^*()');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseToolName', () => {
|
||||
it('should correctly parse a valid tool name', () => {
|
||||
const fullToolName = 'searchTool___elastic-search-123';
|
||||
expect(parseToolName(fullToolName)).toEqual({
|
||||
toolName: 'searchTool',
|
||||
integrationId: 'elastic-search-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error for invalid tool name format', () => {
|
||||
expect(() => parseToolName('invalidToolName')).toThrow(
|
||||
'Invalid tool name format : "invalidToolName"'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error when there are too many separators', () => {
|
||||
expect(() => parseToolName('tool___integration___extra')).toThrow(
|
||||
'Invalid tool name format : "tool___integration___extra"'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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.
|
||||
*/
|
||||
|
||||
const TOOL_NAME_SEPARATOR = '___';
|
||||
|
||||
export interface ToolNameAndIntegrationId {
|
||||
integrationId: string;
|
||||
toolName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique tool name based on the integrationId and base tool name.
|
||||
* This is used by the orchestration layer to generate "uuids" for each integration/tool tuples.
|
||||
*/
|
||||
export const buildToolName = ({ integrationId, toolName }: ToolNameAndIntegrationId) => {
|
||||
return `${toolName}${TOOL_NAME_SEPARATOR}${integrationId}`;
|
||||
};
|
||||
|
||||
export const parseToolName = (fullToolName: string): ToolNameAndIntegrationId => {
|
||||
const splits = fullToolName.split(TOOL_NAME_SEPARATOR);
|
||||
if (splits.length !== 2) {
|
||||
throw new Error(`Invalid tool name format : "${fullToolName}"`);
|
||||
}
|
||||
return {
|
||||
toolName: splits[0],
|
||||
integrationId: splits[1],
|
||||
};
|
||||
};
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* 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 { IntegrationType } from './constants';
|
||||
|
||||
export interface Integration {
|
||||
id: string;
|
||||
name: string;
|
||||
type: IntegrationType;
|
||||
description: string;
|
||||
configuration: IntegrationConfiguration;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export type IntegrationConfiguration = Record<string, any>;
|
24
x-pack/solutions/chat/packages/wci-common/src/tool_calls.ts
Normal file
24
x-pack/solutions/chat/packages/wci-common/src/tool_calls.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents a tool call that was requested by the assistant
|
||||
*/
|
||||
export interface ToolCall {
|
||||
/**
|
||||
* The id the of tool call
|
||||
*/
|
||||
toolCallId: string;
|
||||
/**
|
||||
* The complete tool name (containing the integrationId)
|
||||
*/
|
||||
toolName: string;
|
||||
/**
|
||||
* Arguments that were used to call the tool
|
||||
*/
|
||||
args: Record<string, any>;
|
||||
}
|
20
x-pack/solutions/chat/packages/wci-common/tsconfig.json
Normal file
20
x-pack/solutions/chat/packages/wci-common/tsconfig.json
Normal file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"extends": "../../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": [
|
||||
"jest",
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"index.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*"
|
||||
],
|
||||
"kbn_references": [
|
||||
]
|
||||
}
|
3
x-pack/solutions/chat/packages/wci-server/README.md
Normal file
3
x-pack/solutions/chat/packages/wci-server/README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# @kbn/wci-server
|
||||
|
||||
Contains server-side implementation for WorkChat integrations. Uses types from `@kbn/wci-common`.
|
24
x-pack/solutions/chat/packages/wci-server/index.ts
Normal file
24
x-pack/solutions/chat/packages/wci-server/index.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* 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 {
|
||||
type McpTool,
|
||||
type McpClient,
|
||||
type McpProvider,
|
||||
type McpClientFactoryFn,
|
||||
toolResult,
|
||||
} from './src/mcp';
|
||||
export {
|
||||
getConnectToInternalServer,
|
||||
getConnectToExternalServer,
|
||||
createMcpServer,
|
||||
} from './src/utils';
|
||||
export type {
|
||||
IntegrationContext,
|
||||
WorkChatIntegration,
|
||||
WorkchatIntegrationDefinition,
|
||||
} from './src/integration';
|
12
x-pack/solutions/chat/packages/wci-server/jest.config.js
Normal file
12
x-pack/solutions/chat/packages/wci-server/jest.config.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
preset: '@kbn/test',
|
||||
rootDir: '../../../../..',
|
||||
roots: ['<rootDir>/x-pack/solutions/chat/packages/wci-server'],
|
||||
};
|
9
x-pack/solutions/chat/packages/wci-server/kibana.jsonc
Normal file
9
x-pack/solutions/chat/packages/wci-server/kibana.jsonc
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"type": "shared-server",
|
||||
"id": "@kbn/wci-server",
|
||||
"owner": [
|
||||
"@elastic/search-kibana"
|
||||
],
|
||||
"group": "chat",
|
||||
"visibility": "private"
|
||||
}
|
7
x-pack/solutions/chat/packages/wci-server/package.json
Normal file
7
x-pack/solutions/chat/packages/wci-server/package.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "@kbn/wci-server",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "Elastic License 2.0",
|
||||
"sideEffects": false
|
||||
}
|
46
x-pack/solutions/chat/packages/wci-server/src/integration.ts
Normal file
46
x-pack/solutions/chat/packages/wci-server/src/integration.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* 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 { MaybePromise } from '@kbn/utility-types';
|
||||
import type { KibanaRequest } from '@kbn/core/server';
|
||||
import type { IntegrationType, IntegrationConfiguration } from '@kbn/wci-common';
|
||||
import type { McpProvider } from './mcp';
|
||||
/**
|
||||
* Represents the definition of a type of integration for WorkChat.
|
||||
*
|
||||
* This is the top level entity for integration, which is the source
|
||||
* of all things related to this integration type, such as being
|
||||
* able to create an actual integration instance.
|
||||
*/
|
||||
export interface WorkchatIntegrationDefinition<
|
||||
T extends IntegrationConfiguration = IntegrationConfiguration
|
||||
> {
|
||||
/**
|
||||
* Returns the type of integration.
|
||||
*/
|
||||
getType(): IntegrationType;
|
||||
|
||||
/**
|
||||
* Creates an integration instance based on the provided context
|
||||
*/
|
||||
createIntegration(context: IntegrationContext<T>): MaybePromise<WorkChatIntegration>;
|
||||
}
|
||||
|
||||
export interface IntegrationContext<T extends IntegrationConfiguration> {
|
||||
request: KibanaRequest;
|
||||
description: string;
|
||||
integrationId: string;
|
||||
configuration: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an instance of an integration type, bound to a specific context
|
||||
*/
|
||||
export interface WorkChatIntegration {
|
||||
/** connect to the MCP client */
|
||||
connect: McpProvider['connect'];
|
||||
}
|
65
x-pack/solutions/chat/packages/wci-server/src/mcp.ts
Normal file
65
x-pack/solutions/chat/packages/wci-server/src/mcp.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* 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 { z, ZodRawShape, ZodTypeAny } from '@kbn/zod';
|
||||
import type { Client as McpBaseClient } from '@modelcontextprotocol/sdk/client/index';
|
||||
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { MaybePromise } from '@kbn/utility-types';
|
||||
|
||||
export interface McpTool<RunInput extends ZodRawShape = ZodRawShape> {
|
||||
name: string;
|
||||
description: string;
|
||||
schema: RunInput;
|
||||
execute: (args: z.objectOutputType<RunInput, ZodTypeAny>) => MaybePromise<CallToolResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper on top of the MCP client implementation to avoid leaking internals
|
||||
* and to control which APIs are supported.
|
||||
*/
|
||||
export type McpClient = Pick<McpBaseClient, 'listTools' | 'callTool'> & {
|
||||
/**
|
||||
* Disconnect the client. Note that once disconnected, it can't
|
||||
* be connected again.
|
||||
*/
|
||||
disconnect: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type McpClientFactoryFn = () => MaybePromise<McpClient>;
|
||||
|
||||
export interface McpProvider {
|
||||
id: string;
|
||||
connect: McpClientFactoryFn;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility factory to generate MCP call tool results
|
||||
*/
|
||||
export const toolResult = {
|
||||
text: (text: string): CallToolResult => {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
error: (message: string): CallToolResult => {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error during tool execution: ${message}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
},
|
||||
};
|
|
@ -0,0 +1,112 @@
|
|||
/*
|
||||
* 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 '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { getConnectToExternalServer } from './create_external_client';
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/client/index.js');
|
||||
jest.mock('@modelcontextprotocol/sdk/client/sse.js');
|
||||
|
||||
describe('getConnectToExternalServer', () => {
|
||||
const mockUrl = 'http://test-server.com/mcp';
|
||||
|
||||
const setupMocks = () => {
|
||||
const mockTransport = {
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
const mockClient = {
|
||||
connect: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
(SSEClientTransport as jest.Mock).mockImplementation(() => mockTransport);
|
||||
(Client as jest.Mock).mockImplementation(() => mockClient);
|
||||
|
||||
return { mockTransport, mockClient };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should create a client and connect successfully', async () => {
|
||||
const { mockTransport, mockClient } = setupMocks();
|
||||
|
||||
const connectFn = getConnectToExternalServer({
|
||||
serverUrl: mockUrl,
|
||||
clientName: 'test-client',
|
||||
});
|
||||
|
||||
await connectFn();
|
||||
|
||||
expect(SSEClientTransport).toHaveBeenCalledWith(expect.any(URL));
|
||||
expect(Client).toHaveBeenCalledWith(
|
||||
{
|
||||
name: 'test-client',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
resources: {},
|
||||
tools: {},
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(mockClient.connect).toHaveBeenCalledWith(mockTransport);
|
||||
});
|
||||
|
||||
it('should use correct URL when creating transport', async () => {
|
||||
setupMocks();
|
||||
|
||||
const connectFn = getConnectToExternalServer({
|
||||
serverUrl: mockUrl,
|
||||
});
|
||||
|
||||
await connectFn();
|
||||
|
||||
expect(SSEClientTransport).toHaveBeenCalledWith(new URL(mockUrl));
|
||||
});
|
||||
|
||||
it('should disconnect properly', async () => {
|
||||
const { mockClient } = setupMocks();
|
||||
|
||||
const connectFn = getConnectToExternalServer({
|
||||
serverUrl: mockUrl,
|
||||
});
|
||||
|
||||
const client = await connectFn();
|
||||
await client.disconnect();
|
||||
|
||||
expect(mockClient.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error when connecting an already connected client', async () => {
|
||||
setupMocks();
|
||||
|
||||
const connectFn = getConnectToExternalServer({
|
||||
serverUrl: mockUrl,
|
||||
});
|
||||
|
||||
await connectFn();
|
||||
await expect(connectFn()).rejects.toThrow('Client already connected');
|
||||
});
|
||||
|
||||
it('should handle disconnection errors gracefully', async () => {
|
||||
const { mockClient } = setupMocks();
|
||||
mockClient.close.mockRejectedValueOnce(new Error('Disconnect failed'));
|
||||
|
||||
const connectFn = getConnectToExternalServer({
|
||||
serverUrl: mockUrl,
|
||||
});
|
||||
|
||||
const client = await connectFn();
|
||||
await expect(client.disconnect()).rejects.toThrow('Disconnect failed');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* 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 '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import type { McpClient } from '../mcp';
|
||||
|
||||
export const getConnectToExternalServer = ({
|
||||
serverUrl,
|
||||
clientName = 'unknown',
|
||||
}: {
|
||||
serverUrl: string;
|
||||
clientName?: string;
|
||||
}): (() => Promise<McpClient>) => {
|
||||
let connected = false;
|
||||
|
||||
return async function connect() {
|
||||
if (connected) {
|
||||
throw new Error('Client already connected');
|
||||
}
|
||||
connected = true;
|
||||
|
||||
const transport = new SSEClientTransport(new URL(serverUrl));
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: clientName,
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
resources: {},
|
||||
tools: {},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await client.connect(transport);
|
||||
|
||||
const disconnect = async () => {
|
||||
await client.close();
|
||||
};
|
||||
|
||||
return Object.assign(client, {
|
||||
disconnect,
|
||||
});
|
||||
};
|
||||
};
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
* 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 { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { getConnectToInternalServer } from './create_internal_client';
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/inMemory.js');
|
||||
jest.mock('@modelcontextprotocol/sdk/client/index.js');
|
||||
|
||||
describe('getConnectToInternalServer', () => {
|
||||
const createStubServer = (): jest.Mocked<McpServer> => {
|
||||
return {
|
||||
connect: jest.fn(),
|
||||
close: jest.fn(),
|
||||
} as unknown as jest.Mocked<McpServer>;
|
||||
};
|
||||
|
||||
const createStubTransport = () => ({
|
||||
close: jest.fn(),
|
||||
});
|
||||
|
||||
const setupMocks = () => {
|
||||
const clientTransport = createStubTransport();
|
||||
const serverTransport = createStubTransport();
|
||||
const mockClient = {
|
||||
connect: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
(InMemoryTransport.createLinkedPair as jest.Mock).mockReturnValue([
|
||||
clientTransport,
|
||||
serverTransport,
|
||||
]);
|
||||
(Client as jest.Mock).mockImplementation(() => mockClient);
|
||||
|
||||
return { clientTransport, serverTransport, mockClient };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should create a client and connect successfully', async () => {
|
||||
const server = createStubServer();
|
||||
const { clientTransport, serverTransport, mockClient } = setupMocks();
|
||||
|
||||
const connectFn = getConnectToInternalServer({
|
||||
server,
|
||||
clientName: 'test-client',
|
||||
});
|
||||
|
||||
await connectFn();
|
||||
|
||||
expect(Client).toHaveBeenCalledWith({
|
||||
name: 'test-client',
|
||||
version: '1.0.0',
|
||||
});
|
||||
expect(server.connect).toHaveBeenCalledWith(clientTransport);
|
||||
expect(mockClient.connect).toHaveBeenCalledWith(serverTransport);
|
||||
});
|
||||
|
||||
it('should disconnect properly', async () => {
|
||||
const server = createStubServer();
|
||||
const { mockClient } = setupMocks();
|
||||
|
||||
const connectFn = getConnectToInternalServer({
|
||||
server,
|
||||
});
|
||||
|
||||
const client = await connectFn();
|
||||
await client.disconnect();
|
||||
|
||||
expect(mockClient.close).toHaveBeenCalled();
|
||||
expect(server.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error when connecting an already connected client', async () => {
|
||||
const server = createStubServer();
|
||||
setupMocks();
|
||||
|
||||
const connectFn = getConnectToInternalServer({
|
||||
server,
|
||||
});
|
||||
|
||||
await connectFn();
|
||||
await expect(connectFn()).rejects.toThrow('Client already connected');
|
||||
});
|
||||
|
||||
it('should handle disconnection errors gracefully', async () => {
|
||||
const server = createStubServer();
|
||||
const { mockClient } = setupMocks();
|
||||
mockClient.close.mockRejectedValueOnce(new Error('Disconnect failed'));
|
||||
|
||||
const connectFn = getConnectToInternalServer({
|
||||
server,
|
||||
});
|
||||
|
||||
const client = await connectFn();
|
||||
await expect(client.disconnect()).rejects.toThrow('Disconnect failed');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* 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 { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import type { McpClient } from '../mcp';
|
||||
|
||||
/**
|
||||
* Returns a {@link McpProviderFn} that will run the provided server in memory
|
||||
* and connect to it.
|
||||
*/
|
||||
export const getConnectToInternalServer = ({
|
||||
server,
|
||||
clientName = 'unknown',
|
||||
}: {
|
||||
server: McpServer;
|
||||
clientName?: string;
|
||||
}): (() => Promise<McpClient>) => {
|
||||
let connected = false;
|
||||
|
||||
return async function connect() {
|
||||
if (connected) {
|
||||
throw new Error('Client already connected');
|
||||
}
|
||||
connected = true;
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const client = new Client({
|
||||
name: clientName,
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
await server.connect(clientTransport);
|
||||
await client.connect(serverTransport);
|
||||
|
||||
const disconnect = async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
};
|
||||
|
||||
return Object.assign(client, {
|
||||
disconnect,
|
||||
});
|
||||
};
|
||||
};
|
|
@ -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 { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import type { McpTool } from '../mcp';
|
||||
|
||||
export const createMcpServer = ({
|
||||
name,
|
||||
version = '1.0.0',
|
||||
tools,
|
||||
}: {
|
||||
name: string;
|
||||
version?: string;
|
||||
tools: McpTool[];
|
||||
}): McpServer => {
|
||||
const server = new McpServer({
|
||||
name,
|
||||
version,
|
||||
});
|
||||
|
||||
tools.forEach((tool) => {
|
||||
server.tool(tool.name, tool.description, tool.schema, async (params) => {
|
||||
return tool.execute(params);
|
||||
});
|
||||
});
|
||||
|
||||
return server;
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue