[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:
Pierre Gayvallet 2025-04-02 12:00:32 +02:00 committed by GitHub
parent 1a555fdc86
commit c05dda37e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
333 changed files with 14922 additions and 93 deletions

11
.github/CODEOWNERS vendored
View file

@ -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

View file

@ -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

View file

@ -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 |

View file

@ -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",

View file

@ -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": []
}

View file

@ -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": {}

View file

@ -199,3 +199,7 @@ pageLoadAssetSize:
visTypeXy: 46868
visualizations: 41000
watcher: 43598
wciExternalServer: 35000
wciIndexSource: 40000
wciSalesforce: 25000
workchatApp: 25000

View file

@ -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": [

View file

@ -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)'
);

View file

@ -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', {

View file

@ -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 =

View file

@ -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/**/*",

View file

@ -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/') ||

View file

@ -0,0 +1,3 @@
# @kbn/deeplinks-chat
Deeplinks for apps from the chat project type.

View 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';

View 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}`;

View 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';

View 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'],
};

View file

@ -0,0 +1,7 @@
{
"type": "shared-common",
"id": "@kbn/deeplinks-chat",
"owner": "@elastic/search-kibana",
"group": "platform",
"visibility": "shared"
}

View 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"
}

View file

@ -0,0 +1,17 @@
{
"extends": "../../../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts",
],
"exclude": [
"target/**/*"
],
"kbn_references": []
}

View file

@ -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',
};

View file

@ -14,4 +14,5 @@ export const DEFAULT_ROUTES = {
es: '/app/elasticsearch/overview',
oblt: '/app/observabilityOnboarding',
security: '/app/security/get_started',
chat: '/app/workchat',
};

View file

@ -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"],

View file

@ -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', () => {

View file

@ -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<{}> {

View file

@ -25,6 +25,10 @@ const SolutionOptions: Record<
/>
),
},
chat: {
iconType: 'logoElasticsearch',
label: <FormattedMessage id="xpack.spaces.spaceSolutionBadge.chat" defaultMessage="Workchat" />,
},
security: {
iconType: 'logoSecurity',
label: (

View file

@ -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
});
});
});

View file

@ -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]));

View file

@ -0,0 +1,3 @@
# @kbn/wc-genai-utils
Empty package generated by @kbn/generate

View 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';

View 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'],
};

View file

@ -0,0 +1,7 @@
{
"type": "shared-common",
"id": "@kbn/wc-genai-utils",
"owner": "@elastic/search-kibana",
"group": "chat",
"visibility": "private"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/wc-genai-utils",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0"
}

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import 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);
};

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { 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];
};

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { getConnectorList } from './get_connector_list';
export { getDefaultConnector } from './get_default_connector';

View 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",
]
}

View file

@ -0,0 +1,3 @@
# @kbn/wc-index-schema-builder
Empty package generated by @kbn/generate

View 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 { buildSchema } from './src/build_schema';

View file

@ -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'],
};

View file

@ -0,0 +1,7 @@
{
"type": "shared-common",
"id": "@kbn/wc-index-schema-builder",
"owner": "@elastic/search-kibana",
"group": "chat",
"visibility": "private"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/wc-index-schema-builder",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0"
}

View file

@ -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;
};

View file

@ -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,
};
};

View file

@ -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([]);
});
});

View file

@ -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);
};

View file

@ -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 };
};

View 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; 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';

View file

@ -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;
};

View file

@ -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.
`,
],
];
};

View file

@ -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",
]
}

View file

@ -0,0 +1,3 @@
# @kbn/wc-integration-utils
Empty package generated by @kbn/generate

View 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';

View 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-integration-utils'],
};

View file

@ -0,0 +1,7 @@
{
"type": "shared-common",
"id": "@kbn/wc-integration-utils",
"owner": "@elastic/search-kibana",
"group": "chat",
"visibility": "private"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/wc-integration-utils",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0"
}

View file

@ -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"`);
});
});

View file

@ -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)`);
};

View file

@ -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;
};

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { getFieldsTopValues } from './get_fields_top_values';
export { getFieldTypeByPath } from './get_field_type_by_path';

View file

@ -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' } }]);
});
});

View file

@ -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;
};

View file

@ -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);
});
});

View file

@ -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 {};
}
};

View file

@ -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,
});
});
});

View file

@ -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;
};

View 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; 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';

View file

@ -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",
]
}

View file

@ -0,0 +1,3 @@
# @kbn/wci-browser
Empty package generated by @kbn/generate

View 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';

View 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'],
};

View file

@ -0,0 +1,7 @@
{
"type": "shared-browser",
"id": "@kbn/wci-browser",
"owner": "@elastic/search-kibana",
"group": "chat",
"visibility": "private"
}

View file

@ -0,0 +1,7 @@
{
"name": "@kbn/wci-browser",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0",
"sideEffects": false
}

View file

@ -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>;
}

View 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",
]
}

View 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.

View 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';

View 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'],
};

View file

@ -0,0 +1,9 @@
{
"type": "shared-common",
"id": "@kbn/wci-common",
"owner": [
"@elastic/search-kibana"
],
"group": "chat",
"visibility": "private"
}

View file

@ -0,0 +1,7 @@
{
"name": "@kbn/wci-common",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0",
"sideEffects": false
}

View 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',
}

View file

@ -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;
}

View file

@ -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"'
);
});
});
});

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
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],
};
};

View file

@ -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>;

View 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>;
}

View 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": [
]
}

View file

@ -0,0 +1,3 @@
# @kbn/wci-server
Contains server-side implementation for WorkChat integrations. Uses types from `@kbn/wci-common`.

View 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';

View 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'],
};

View file

@ -0,0 +1,9 @@
{
"type": "shared-server",
"id": "@kbn/wci-server",
"owner": [
"@elastic/search-kibana"
],
"group": "chat",
"visibility": "private"
}

View file

@ -0,0 +1,7 @@
{
"name": "@kbn/wci-server",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0",
"sideEffects": false
}

View 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'];
}

View 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,
};
},
};

View file

@ -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');
});
});

View file

@ -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,
});
};
};

View file

@ -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');
});
});

View file

@ -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,
});
};
};

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { 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