diff --git a/x-pack/plugins/integration_assistant/__jest__/fixtures/cel.ts b/x-pack/plugins/integration_assistant/__jest__/fixtures/cel.ts new file mode 100644 index 0000000000000..e13c4216fbcdd --- /dev/null +++ b/x-pack/plugins/integration_assistant/__jest__/fixtures/cel.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const celTestState = { + dataStreamName: 'testDataStream', + apiDefinition: 'apiDefinition', + lastExecutedChain: 'testchain', + finalized: false, + apiQuerySummary: 'testQuerySummary', + exampleCelPrograms: [], + currentProgram: 'testProgram', + stateVarNames: ['testVar'], + stateSettings: { test: 'testDetails' }, + redactVars: ['testRedact'], + results: { test: 'testResults' }, +}; + +export const celQuerySummaryMockedResponse = `To cover all events in a chronological manner for the device_tasks endpoint, you should use the /v1/device_tasks GET route with pagination parameters. Specifically, use the pageSize and pageToken query parameters. Start with a large pageSize and use the nextPageToken from each response to fetch subsequent pages until all events are retrieved. +Sample URL path: +/v1/device_tasks?pageSize=1000&pageToken={nextPageToken} +Replace {nextPageToken} with the actual token received from the previous response. Repeat this process, updating the pageToken each time, until you've retrieved all events.`; + +export const celProgramMockedResponse = `Based on the provided context and requirements, here's the CEL program section for the device_tasks datastream: + +\`\`\` +request("GET", state.url + "/v1/device_tasks" + "?" + { + "pageSize": [string(state.page_size)], + "pageToken": [state.page_token] +}.format_query()).with({ + "Header": { + "Content-Type": ["application/json"] + } +}).do_request().as(resp, + resp.StatusCode == 200 ? + bytes(resp.Body).decode_json().as(body, { + "events": body.tasks.map(e, {"message": e.encode_json()}), + "page_token": body.nextPageToken, + "want_more": body.nextPageToken != null + }) : { + "events": { + "error": { + "code": string(resp.StatusCode), + "message": string(resp.Body) + } + }, + "want_more": false + } +) +\`\`\``; + +export const celProgramMock = `request("GET", state.url + "/v1/device_tasks" + "?" + { + "pageSize": [string(state.page_size)], + "pageToken": [state.page_token] +}.format_query()).with({ + "Header": { + "Content-Type": ["application/json"] + } +}).do_request().as(resp, + resp.StatusCode == 200 ? + bytes(resp.Body).decode_json().as(body, { + "events": body.tasks.map(e, {"message": e.encode_json()}), + "page_token": body.nextPageToken, + "want_more": body.nextPageToken != null + }) : { + "events": { + "error": { + "code": string(resp.StatusCode), + "message": string(resp.Body) + } + }, + "want_more": false + } +)`; + +export const celStateVarsMockedResponse = ['config1', 'config2', 'config3']; + +export const celStateDetailsMockedResponse = [ + { + name: 'config1', + default: 50, + redact: false, + }, + { + name: 'config2', + default: '', + redact: true, + }, + { + name: 'config3', + default: 'event', + redact: false, + }, +]; + +export const celStateSettings = { + config1: 50, + config2: '', + config3: 'event', +}; + +export const celRedact = ['config2']; + +export const celExpectedResults = { + program: celProgramMock, + stateSettings: { + config1: 50, + config2: '', + config3: 'event', + }, + redactVars: ['config2'], +}; diff --git a/x-pack/plugins/integration_assistant/__jest__/fixtures/index.ts b/x-pack/plugins/integration_assistant/__jest__/fixtures/index.ts index 7e3e155e67b8a..a25e3d6cf43a7 100644 --- a/x-pack/plugins/integration_assistant/__jest__/fixtures/index.ts +++ b/x-pack/plugins/integration_assistant/__jest__/fixtures/index.ts @@ -52,3 +52,8 @@ export const mockedRequestWithPipeline = { dataStreamName: 'audit', currentPipeline: currentPipelineMock, }; + +export const mockedRequestWithApiDefinition = { + apiDefinition: '{ "openapi": "3.0.0" }', + dataStreamName: 'audit', +}; diff --git a/x-pack/plugins/integration_assistant/common/api/cel/cel_input_route.gen.ts b/x-pack/plugins/integration_assistant/common/api/cel/cel_input_route.gen.ts new file mode 100644 index 0000000000000..276fd27072c98 --- /dev/null +++ b/x-pack/plugins/integration_assistant/common/api/cel/cel_input_route.gen.ts @@ -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. + */ + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: Automatic Import CEL Input API endpoint + * version: 1 + */ + +import { z } from '@kbn/zod'; + +import { DataStreamName, Connector, LangSmithOptions } from '../model/common_attributes.gen'; +import { ApiDefinition } from '../model/cel_input_attributes.gen'; +import { CelInputAPIResponse } from '../model/response_schemas.gen'; + +export type CelInputRequestBody = z.infer; +export const CelInputRequestBody = z.object({ + dataStreamName: DataStreamName, + apiDefinition: ApiDefinition, + connectorId: Connector, + langSmithOptions: LangSmithOptions.optional(), +}); +export type CelInputRequestBodyInput = z.input; + +export type CelInputResponse = z.infer; +export const CelInputResponse = CelInputAPIResponse; diff --git a/x-pack/plugins/integration_assistant/common/api/cel/cel_input_route.schema.yaml b/x-pack/plugins/integration_assistant/common/api/cel/cel_input_route.schema.yaml new file mode 100644 index 0000000000000..18187959fe461 --- /dev/null +++ b/x-pack/plugins/integration_assistant/common/api/cel/cel_input_route.schema.yaml @@ -0,0 +1,39 @@ +openapi: 3.0.3 +info: + title: Automatic Import CEL Input API endpoint + version: "1" +paths: + /api/integration_assistant/cel: + post: + summary: Builds CEL input configuration + operationId: CelInput + x-codegen-enabled: true + description: Generate CEL input configuration + tags: + - CEL API + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - apiDefinition + - dataStreamName + - connectorId + properties: + dataStreamName: + $ref: "../model/common_attributes.schema.yaml#/components/schemas/DataStreamName" + apiDefinition: + $ref: "../model/cel_input_attributes.schema.yaml#/components/schemas/ApiDefinition" + connectorId: + $ref: "../model/common_attributes.schema.yaml#/components/schemas/Connector" + langSmithOptions: + $ref: "../model/common_attributes.schema.yaml#/components/schemas/LangSmithOptions" + responses: + 200: + description: Indicates a successful call. + content: + application/json: + schema: + $ref: "../model/response_schemas.schema.yaml#/components/schemas/CelInputAPIResponse" diff --git a/x-pack/plugins/integration_assistant/common/api/cel/cel_input_route.test.ts b/x-pack/plugins/integration_assistant/common/api/cel/cel_input_route.test.ts new file mode 100644 index 0000000000000..8518decc102e2 --- /dev/null +++ b/x-pack/plugins/integration_assistant/common/api/cel/cel_input_route.test.ts @@ -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. + */ + +import { expectParseSuccess } from '@kbn/zod-helpers'; +import { getCelRequestMock } from '../model/api_test.mock'; +import { CelInputRequestBody } from './cel_input_route.gen'; + +describe('Cel request schema', () => { + test('full request validate', () => { + const payload: CelInputRequestBody = getCelRequestMock(); + + const result = CelInputRequestBody.safeParse(payload); + expectParseSuccess(result); + expect(result.data).toEqual(payload); + }); +}); diff --git a/x-pack/plugins/integration_assistant/common/api/model/api_test.mock.ts b/x-pack/plugins/integration_assistant/common/api/model/api_test.mock.ts index de29624cbb21a..ea2aa61417526 100644 --- a/x-pack/plugins/integration_assistant/common/api/model/api_test.mock.ts +++ b/x-pack/plugins/integration_assistant/common/api/model/api_test.mock.ts @@ -8,6 +8,7 @@ import type { AnalyzeLogsRequestBody } from '../analyze_logs/analyze_logs_route.gen'; import type { BuildIntegrationRequestBody } from '../build_integration/build_integration.gen'; import type { CategorizationRequestBody } from '../categorization/categorization_route.gen'; +import type { CelInputRequestBody } from '../cel/cel_input_route.gen'; import type { EcsMappingRequestBody } from '../ecs/ecs_route.gen'; import type { RelatedRequestBody } from '../related/related_route.gen'; import type { DataStream, Integration, Pipeline } from './common_attributes.gen'; @@ -65,6 +66,12 @@ export const getCategorizationRequestMock = (): CategorizationRequestBody => ({ samplesFormat: { name: 'ndjson' }, }); +export const getCelRequestMock = (): CelInputRequestBody => ({ + dataStreamName: 'test-data-stream-name', + apiDefinition: 'test-api-definition', + connectorId: 'test-connector-id', +}); + export const getBuildIntegrationRequestMock = (): BuildIntegrationRequestBody => ({ integration: getIntegrationMock(), }); diff --git a/x-pack/plugins/integration_assistant/common/api/model/cel_input_attributes.gen.ts b/x-pack/plugins/integration_assistant/common/api/model/cel_input_attributes.gen.ts new file mode 100644 index 0000000000000..9ee1ee2ed290f --- /dev/null +++ b/x-pack/plugins/integration_assistant/common/api/model/cel_input_attributes.gen.ts @@ -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. + */ + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: Cel Input Attributes + * version: not applicable + */ + +import { z } from '@kbn/zod'; + +/** + * String form of the Open API schema. + */ +export type ApiDefinition = z.infer; +export const ApiDefinition = z.string(); + +/** + * Optional CEL input details. + */ +export type CelInput = z.infer; +export const CelInput = z.object({ + program: z.string(), + stateSettings: z.object({}).catchall(z.unknown()), + redactVars: z.array(z.string()), +}); diff --git a/x-pack/plugins/integration_assistant/common/api/model/cel_input_attributes.schema.yaml b/x-pack/plugins/integration_assistant/common/api/model/cel_input_attributes.schema.yaml new file mode 100644 index 0000000000000..cd05202ddfda0 --- /dev/null +++ b/x-pack/plugins/integration_assistant/common/api/model/cel_input_attributes.schema.yaml @@ -0,0 +1,30 @@ +openapi: 3.0.3 +info: + title: Cel Input Attributes + version: "not applicable" +paths: {} +components: + x-codegen-enabled: true + schemas: + ApiDefinition: + type: string + description: String form of the Open API schema. + + CelInput: + type: object + description: Optional CEL input details. + required: + - program + - stateSettings + - redactVars + properties: + program: + type: string + stateSettings: + type: object + additionalProperties: true + redactVars: + type: array + items: + type: string + \ No newline at end of file diff --git a/x-pack/plugins/integration_assistant/common/api/model/common_attributes.gen.ts b/x-pack/plugins/integration_assistant/common/api/model/common_attributes.gen.ts index 49e6f12691429..7b64b4f8a88d8 100644 --- a/x-pack/plugins/integration_assistant/common/api/model/common_attributes.gen.ts +++ b/x-pack/plugins/integration_assistant/common/api/model/common_attributes.gen.ts @@ -17,6 +17,7 @@ import { z } from '@kbn/zod'; import { ESProcessorItem } from './processor_attributes.gen'; +import { CelInput } from './cel_input_attributes.gen'; /** * Package name for the integration to be built. @@ -178,6 +179,10 @@ export const DataStream = z.object({ * The format of log samples in this dataStream. */ samplesFormat: SamplesFormat, + /** + * The optional CEL input configuration for the dataStream. + */ + celInput: CelInput.optional(), }); /** diff --git a/x-pack/plugins/integration_assistant/common/api/model/common_attributes.schema.yaml b/x-pack/plugins/integration_assistant/common/api/model/common_attributes.schema.yaml index 073b485b1cb3d..aba43d0174bb8 100644 --- a/x-pack/plugins/integration_assistant/common/api/model/common_attributes.schema.yaml +++ b/x-pack/plugins/integration_assistant/common/api/model/common_attributes.schema.yaml @@ -156,6 +156,9 @@ components: samplesFormat: $ref: "#/components/schemas/SamplesFormat" description: The format of log samples in this dataStream. + celInput: + $ref: "./cel_input_attributes.schema.yaml#/components/schemas/CelInput" + description: The optional CEL input configuration for the dataStream. Integration: type: object diff --git a/x-pack/plugins/integration_assistant/common/api/model/response_schemas.gen.ts b/x-pack/plugins/integration_assistant/common/api/model/response_schemas.gen.ts index acb4954c21b90..f13b6fc537235 100644 --- a/x-pack/plugins/integration_assistant/common/api/model/response_schemas.gen.ts +++ b/x-pack/plugins/integration_assistant/common/api/model/response_schemas.gen.ts @@ -18,6 +18,7 @@ import { z } from '@kbn/zod'; import { Mapping, Pipeline, Docs, SamplesFormat } from './common_attributes.gen'; import { ESProcessorItem } from './processor_attributes.gen'; +import { CelInput } from './cel_input_attributes.gen'; export type EcsMappingAPIResponse = z.infer; export const EcsMappingAPIResponse = z.object({ @@ -58,3 +59,8 @@ export const AnalyzeLogsAPIResponse = z.object({ parsedSamples: z.array(z.string()), }), }); + +export type CelInputAPIResponse = z.infer; +export const CelInputAPIResponse = z.object({ + results: CelInput, +}); diff --git a/x-pack/plugins/integration_assistant/common/api/model/response_schemas.schema.yaml b/x-pack/plugins/integration_assistant/common/api/model/response_schemas.schema.yaml index c504ad8b17d16..62776b9dc5c13 100644 --- a/x-pack/plugins/integration_assistant/common/api/model/response_schemas.schema.yaml +++ b/x-pack/plugins/integration_assistant/common/api/model/response_schemas.schema.yaml @@ -88,3 +88,11 @@ components: type: array items: type: string + + CelInputAPIResponse: + type: object + required: + - results + properties: + results: + $ref: "./cel_input_attributes.schema.yaml#/components/schemas/CelInput" \ No newline at end of file diff --git a/x-pack/plugins/integration_assistant/common/constants.ts b/x-pack/plugins/integration_assistant/common/constants.ts index 3891c1e5e4343..1472a260fadf0 100644 --- a/x-pack/plugins/integration_assistant/common/constants.ts +++ b/x-pack/plugins/integration_assistant/common/constants.ts @@ -20,6 +20,7 @@ export const ECS_GRAPH_PATH = `${INTEGRATION_ASSISTANT_BASE_PATH}/ecs`; export const CATEGORIZATION_GRAPH_PATH = `${INTEGRATION_ASSISTANT_BASE_PATH}/categorization`; export const ANALYZE_LOGS_PATH = `${INTEGRATION_ASSISTANT_BASE_PATH}/analyzelogs`; export const RELATED_GRAPH_PATH = `${INTEGRATION_ASSISTANT_BASE_PATH}/related`; +export const CEL_INPUT_GRAPH_PATH = `${INTEGRATION_ASSISTANT_BASE_PATH}/cel`; export const CHECK_PIPELINE_PATH = `${INTEGRATION_ASSISTANT_BASE_PATH}/pipeline`; export const INTEGRATION_BUILDER_PATH = `${INTEGRATION_ASSISTANT_BASE_PATH}/build`; export const FLEET_PACKAGES_PATH = `/api/fleet/epm/packages`; diff --git a/x-pack/plugins/integration_assistant/common/experimental_features.ts b/x-pack/plugins/integration_assistant/common/experimental_features.ts index 7de449b1b31da..76b4ed6f28fee 100644 --- a/x-pack/plugins/integration_assistant/common/experimental_features.ts +++ b/x-pack/plugins/integration_assistant/common/experimental_features.ts @@ -8,8 +8,10 @@ export type ExperimentalFeatures = typeof allowedExperimentalValues; const _allowedExperimentalValues = { - // Leaving this in here until we have a 'real' experimental feature - testFeature: false, + /** + * Enables whether the user is able to utilize the LLM to generate the CEL input configuration. + */ + generateCel: false, }; /** diff --git a/x-pack/plugins/integration_assistant/common/index.ts b/x-pack/plugins/integration_assistant/common/index.ts index 21ee814655e10..5e90e6e6a6217 100644 --- a/x-pack/plugins/integration_assistant/common/index.ts +++ b/x-pack/plugins/integration_assistant/common/index.ts @@ -19,6 +19,7 @@ export { AnalyzeLogsRequestBody, AnalyzeLogsResponse, } from './api/analyze_logs/analyze_logs_route.gen'; +export { CelInputRequestBody, CelInputResponse } from './api/cel/cel_input_route.gen'; export type { DataStream, @@ -31,9 +32,11 @@ export type { } from './api/model/common_attributes.gen'; export { SamplesFormatName } from './api/model/common_attributes.gen'; export type { ESProcessorItem } from './api/model/processor_attributes.gen'; +export type { CelInput } from './api/model/cel_input_attributes.gen'; export { CATEGORIZATION_GRAPH_PATH, + CEL_INPUT_GRAPH_PATH, ECS_GRAPH_PATH, INTEGRATION_ASSISTANT_APP_ROUTE, INTEGRATION_ASSISTANT_BASE_PATH, diff --git a/x-pack/plugins/integration_assistant/public/common/lib/api.ts b/x-pack/plugins/integration_assistant/public/common/lib/api.ts index 46c9487df4b7f..0a6d91375219e 100644 --- a/x-pack/plugins/integration_assistant/public/common/lib/api.ts +++ b/x-pack/plugins/integration_assistant/public/common/lib/api.ts @@ -18,12 +18,15 @@ import type { BuildIntegrationRequestBody, AnalyzeLogsRequestBody, AnalyzeLogsResponse, + CelInputRequestBody, + CelInputResponse, } from '../../../common'; import { INTEGRATION_BUILDER_PATH, ECS_GRAPH_PATH, CATEGORIZATION_GRAPH_PATH, RELATED_GRAPH_PATH, + CEL_INPUT_GRAPH_PATH, CHECK_PIPELINE_PATH, } from '../../../common'; import { ANALYZE_LOGS_PATH, FLEET_PACKAGES_PATH } from '../../../common/constants'; @@ -84,6 +87,16 @@ export const runRelatedGraph = async ( signal: abortSignal, }); +export const runCelGraph = async ( + body: CelInputRequestBody, + { http, abortSignal }: RequestDeps +): Promise => + http.post(CEL_INPUT_GRAPH_PATH, { + headers: defaultHeaders, + body: JSON.stringify(body), + signal: abortSignal, + }); + export const runCheckPipelineResults = async ( body: CheckPipelineRequestBody, { http, abortSignal }: RequestDeps diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/create_integration_assistant.test.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/create_integration_assistant.test.tsx index 496f34a943bed..b6fe577865822 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/create_integration_assistant.test.tsx +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/create_integration_assistant.test.tsx @@ -10,6 +10,7 @@ import { render } from '@testing-library/react'; import { TestProvider } from '../../../mocks/test_provider'; import { CreateIntegrationAssistant } from './create_integration_assistant'; import type { State } from './state'; +import { ExperimentalFeaturesService } from '../../../services'; export const defaultInitialState: State = { step: 1, @@ -26,16 +27,23 @@ jest.mock('./state', () => ({ }, })); +jest.mock('../../../services'); +const mockedExperimentalFeaturesService = jest.mocked(ExperimentalFeaturesService); + const mockConnectorStep = jest.fn(() =>
); const mockIntegrationStep = jest.fn(() =>
); const mockDataStreamStep = jest.fn(() =>
); const mockReviewStep = jest.fn(() =>
); +const mockCelInputStep = jest.fn(() =>
); +const mockReviewCelStep = jest.fn(() =>
); const mockDeployStep = jest.fn(() =>
); const mockIsConnectorStepReady = jest.fn(); const mockIsIntegrationStepReady = jest.fn(); const mockIsDataStreamStepReady = jest.fn(); const mockIsReviewStepReady = jest.fn(); +const mockIsCelInputStepReady = jest.fn(); +const mockIsCelReviewStepReady = jest.fn(); jest.mock('./steps/connector_step', () => ({ ConnectorStep: () => mockConnectorStep(), @@ -53,6 +61,14 @@ jest.mock('./steps/review_step', () => ({ ReviewStep: () => mockReviewStep(), isReviewStepReady: () => mockIsReviewStepReady(), })); +jest.mock('./steps/cel_input_step', () => ({ + CelInputStep: () => mockCelInputStep(), + isCelInputStepReady: () => mockIsCelInputStepReady(), +})); +jest.mock('./steps/review_cel_step', () => ({ + ReviewCelStep: () => mockReviewCelStep(), + isCelReviewStepReady: () => mockIsCelReviewStepReady(), +})); jest.mock('./steps/deploy_step', () => ({ DeployStep: () => mockDeployStep() })); const renderIntegrationAssistant = () => @@ -61,6 +77,10 @@ const renderIntegrationAssistant = () => describe('CreateIntegration', () => { beforeEach(() => { jest.clearAllMocks(); + + mockedExperimentalFeaturesService.get.mockReturnValue({ + generateCel: false, + } as never); }); describe('when step is 1', () => { @@ -138,3 +158,56 @@ describe('CreateIntegration', () => { }); }); }); + +describe('CreateIntegration with generateCel enabled', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockedExperimentalFeaturesService.get.mockReturnValue({ + generateCel: true, + } as never); + }); + + describe('when step is 5', () => { + beforeEach(() => { + mockInitialState.mockReturnValueOnce({ ...defaultInitialState, step: 5 }); + }); + + it('should render cel input', () => { + const result = renderIntegrationAssistant(); + expect(result.queryByTestId('celInputStepMock')).toBeInTheDocument(); + }); + + it('should call isCelInputStepReady', () => { + renderIntegrationAssistant(); + expect(mockIsCelInputStepReady).toHaveBeenCalled(); + }); + }); + + describe('when step is 6', () => { + beforeEach(() => { + mockInitialState.mockReturnValueOnce({ ...defaultInitialState, step: 6 }); + }); + + it('should render review', () => { + const result = renderIntegrationAssistant(); + expect(result.queryByTestId('reviewCelStepMock')).toBeInTheDocument(); + }); + + it('should call isReviewCelStepReady', () => { + renderIntegrationAssistant(); + expect(mockIsCelReviewStepReady).toHaveBeenCalled(); + }); + }); + + describe('when step is 7', () => { + beforeEach(() => { + mockInitialState.mockReturnValueOnce({ ...defaultInitialState, step: 7 }); + }); + + it('should render deploy', () => { + const result = renderIntegrationAssistant(); + expect(result.queryByTestId('deployStepMock')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/create_integration_assistant.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/create_integration_assistant.tsx index adc8a05654551..1297e7c975e3b 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/create_integration_assistant.tsx +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/create_integration_assistant.tsx @@ -13,13 +13,18 @@ import { ConnectorStep, isConnectorStepReady } from './steps/connector_step'; import { IntegrationStep, isIntegrationStepReady } from './steps/integration_step'; import { DataStreamStep, isDataStreamStepReady } from './steps/data_stream_step'; import { ReviewStep, isReviewStepReady } from './steps/review_step'; +import { CelInputStep, isCelInputStepReady } from './steps/cel_input_step'; +import { ReviewCelStep, isCelReviewStepReady } from './steps/review_cel_step'; import { DeployStep } from './steps/deploy_step'; import { reducer, initialState, ActionsProvider, type Actions } from './state'; import { useTelemetry } from '../telemetry'; +import { ExperimentalFeaturesService } from '../../../services'; export const CreateIntegrationAssistant = React.memo(() => { const [state, dispatch] = useReducer(reducer, initialState); + const { generateCel: isGenerateCelEnabled } = ExperimentalFeaturesService.get(); + const telemetry = useTelemetry(); useEffect(() => { telemetry.reportAssistantOpen(); @@ -42,6 +47,9 @@ export const CreateIntegrationAssistant = React.memo(() => { setResult: (payload) => { dispatch({ type: 'SET_GENERATED_RESULT', payload }); }, + setCelInputResult: (payload) => { + dispatch({ type: 'SET_CEL_INPUT_RESULT', payload }); + }, }), [] ); @@ -55,9 +63,13 @@ export const CreateIntegrationAssistant = React.memo(() => { return isDataStreamStepReady(state); } else if (state.step === 4) { return isReviewStepReady(state); + } else if (isGenerateCelEnabled && state.step === 5) { + return isCelInputStepReady(state); + } else if (isGenerateCelEnabled && state.step === 6) { + return isCelReviewStepReady(state); } return false; - }, [state]); + }, [state, isGenerateCelEnabled]); return ( @@ -80,10 +92,32 @@ export const CreateIntegrationAssistant = React.memo(() => { result={state.result} /> )} - {state.step === 5 && ( + {state.step === 5 && + (isGenerateCelEnabled ? ( + + ) : ( + + ))} + + {isGenerateCelEnabled && state.step === 6 && ( + + )} + {isGenerateCelEnabled && state.step === 7 && ( )} diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/footer/footer.test.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/footer/footer.test.tsx index d06762b4656fa..900a72ab272a0 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/footer/footer.test.tsx +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/footer/footer.test.tsx @@ -13,6 +13,7 @@ import { ActionsProvider } from '../state'; import { mockActions } from '../mocks/state'; import { mockReportEvent } from '../../../../services/telemetry/mocks/service'; import { TelemetryEventType } from '../../../../services/telemetry/types'; +import { ExperimentalFeaturesService } from '../../../../services'; const mockNavigate = jest.fn(); jest.mock('../../../../common/hooks/use_navigate', () => ({ @@ -20,6 +21,9 @@ jest.mock('../../../../common/hooks/use_navigate', () => ({ useNavigate: () => mockNavigate, })); +jest.mock('../../../../services'); +const mockedExperimentalFeaturesService = jest.mocked(ExperimentalFeaturesService); + const wrapper: React.FC> = ({ children }) => ( {children} @@ -29,6 +33,10 @@ const wrapper: React.FC> = ({ children }) => ( describe('Footer', () => { beforeEach(() => { jest.clearAllMocks(); + + mockedExperimentalFeaturesService.get.mockReturnValue({ + generateCel: false, + } as never); }); describe('when rendered', () => { diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/footer/footer.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/footer/footer.tsx index eefbbb6b385c3..9a2f862264e27 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/footer/footer.tsx +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/footer/footer.tsx @@ -12,6 +12,7 @@ import { useNavigate, Page } from '../../../../common/hooks/use_navigate'; import { useTelemetry } from '../../telemetry'; import { useActions, type State } from '../state'; import * as i18n from './translations'; +import { ExperimentalFeaturesService } from '../../../../services'; // Generation button for Step 3 const AnalyzeButtonText = React.memo<{ isGenerating: boolean }>(({ isGenerating }) => { @@ -27,6 +28,20 @@ const AnalyzeButtonText = React.memo<{ isGenerating: boolean }>(({ isGenerating }); AnalyzeButtonText.displayName = 'AnalyzeButtonText'; +// Generation button for Step 5 +const AnalyzeCelButtonText = React.memo<{ isGenerating: boolean }>(({ isGenerating }) => { + if (!isGenerating) { + return <>{i18n.ANALYZE_CEL}; + } + return ( + <> + + {i18n.LOADING} + + ); +}); +AnalyzeCelButtonText.displayName = 'AnalyzeCelButtonText'; + interface FooterProps { currentStep: State['step']; isGenerating: State['isGenerating']; @@ -39,6 +54,8 @@ export const Footer = React.memo( const { setStep, setIsGenerating } = useActions(); const navigate = useNavigate(); + const { generateCel: isGenerateCelEnabled } = ExperimentalFeaturesService.get(); + const onBack = useCallback(() => { if (currentStep === 1) { navigate(Page.landing); @@ -49,7 +66,7 @@ export const Footer = React.memo( const onNext = useCallback(() => { telemetry.reportAssistantStepComplete({ step: currentStep }); - if (currentStep === 3) { + if (currentStep === 3 || currentStep === 5) { setIsGenerating(true); } else { setStep(currentStep + 1); @@ -60,12 +77,18 @@ export const Footer = React.memo( if (currentStep === 3) { return ; } - if (currentStep === 4) { + if (currentStep === 4 && !isGenerateCelEnabled) { + return i18n.ADD_TO_ELASTIC; + } + if (currentStep === 5 && isGenerateCelEnabled) { + return ; + } + if (currentStep === 6 && isGenerateCelEnabled) { return i18n.ADD_TO_ELASTIC; } - }, [currentStep, isGenerating]); + }, [currentStep, isGenerating, isGenerateCelEnabled]); - if (currentStep === 5) { + if (currentStep === 7 || (currentStep === 5 && !isGenerateCelEnabled)) { return ; } return ( diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/footer/translations.ts b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/footer/translations.ts index aedfd63ba2213..3f568486617f2 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/footer/translations.ts +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/footer/translations.ts @@ -11,6 +11,10 @@ export const ANALYZE_LOGS = i18n.translate('xpack.integrationAssistant.bottomBar defaultMessage: 'Analyze logs', }); +export const ANALYZE_CEL = i18n.translate('xpack.integrationAssistant.bottomBar.analyzeCel', { + defaultMessage: 'Generate CEL input configuration', +}); + export const LOADING = i18n.translate('xpack.integrationAssistant.bottomBar.loading', { defaultMessage: 'Loading', }); diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/mocks/state.ts b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/mocks/state.ts index c842d097fa7c3..452d5e65a972c 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/mocks/state.ts +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/mocks/state.ts @@ -432,4 +432,5 @@ export const mockActions: Actions = { setIntegrationSettings: jest.fn(), setIsGenerating: jest.fn(), setResult: jest.fn(), + setCelInputResult: jest.fn(), }; diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/state.ts b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/state.ts index 99f95a3eee039..0492012ab8686 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/state.ts +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/state.ts @@ -5,7 +5,7 @@ * 2.0. */ import { createContext, useContext } from 'react'; -import type { Pipeline, Docs, SamplesFormat } from '../../../../common'; +import type { Pipeline, Docs, SamplesFormat, CelInput } from '../../../../common'; import type { AIConnector, IntegrationSettings } from './types'; export interface State { @@ -18,6 +18,7 @@ export interface State { docs: Docs; samplesFormat?: SamplesFormat; }; + celInputResult?: CelInput; } export const initialState: State = { @@ -33,7 +34,8 @@ type Action = | { type: 'SET_CONNECTOR'; payload: State['connector'] } | { type: 'SET_INTEGRATION_SETTINGS'; payload: State['integrationSettings'] } | { type: 'SET_IS_GENERATING'; payload: State['isGenerating'] } - | { type: 'SET_GENERATED_RESULT'; payload: State['result'] }; + | { type: 'SET_GENERATED_RESULT'; payload: State['result'] } + | { type: 'SET_CEL_INPUT_RESULT'; payload: State['celInputResult'] }; export const reducer = (state: State, action: Action): State => { switch (action.type) { @@ -56,6 +58,8 @@ export const reducer = (state: State, action: Action): State => { // keep original result as the samplesFormat is not always included in the payload result: state.result ? { ...state.result, ...action.payload } : action.payload, }; + case 'SET_CEL_INPUT_RESULT': + return { ...state, celInputResult: action.payload }; default: return state; } @@ -67,6 +71,7 @@ export interface Actions { setIntegrationSettings: (payload: State['integrationSettings']) => void; setIsGenerating: (payload: State['isGenerating']) => void; setResult: (payload: State['result']) => void; + setCelInputResult: (payload: State['celInputResult']) => void; } const ActionsContext = createContext(undefined); diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/cel_input_step/api_definition_input.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/cel_input_step/api_definition_input.tsx new file mode 100644 index 0000000000000..9a9f36efcddb8 --- /dev/null +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/cel_input_step/api_definition_input.tsx @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useState } from 'react'; +import { EuiFilePicker, EuiFormRow, EuiText } from '@elastic/eui'; +import type { IntegrationSettings } from '../../types'; +import * as i18n from './translations'; +import { useActions } from '../../state'; + +interface ApiDefinitionInputProps { + integrationSettings: IntegrationSettings | undefined; +} + +export const ApiDefinitionInput = React.memo(({ integrationSettings }) => { + const { setIntegrationSettings } = useActions(); + const [isParsing, setIsParsing] = useState(false); + const [apiFileError, setApiFileError] = useState(); + + const onChangeApiDefinition = useCallback( + (files: FileList | null) => { + if (!files) { + return; + } + + setApiFileError(undefined); + setIntegrationSettings({ + ...integrationSettings, + apiDefinition: undefined, + }); + + const apiDefinitionFile = files[0]; + const reader = new FileReader(); + + reader.onloadstart = function () { + setIsParsing(true); + }; + + reader.onloadend = function () { + setIsParsing(false); + }; + + reader.onload = function (e) { + const fileContent = e.target?.result as string | undefined; // We can safely cast to string since we call `readAsText` to load the file. + + if (fileContent == null) { + setApiFileError(i18n.API_DEFINITION_ERROR.CAN_NOT_READ); + return; + } + + if (fileContent === '' && e.loaded > 100000) { + // V8-based browsers can't handle large files and return an empty string + // instead of an error; see https://stackoverflow.com/a/61316641 + setApiFileError(i18n.API_DEFINITION_ERROR.TOO_LARGE_TO_PARSE); + return; + } + + setIntegrationSettings({ + ...integrationSettings, + apiDefinition: fileContent, + }); + }; + + const handleReaderError = function () { + const message = reader.error?.message; + if (message) { + setApiFileError(i18n.API_DEFINITION_ERROR.CAN_NOT_READ_WITH_REASON(message)); + } else { + setApiFileError(i18n.API_DEFINITION_ERROR.CAN_NOT_READ); + } + }; + + reader.onerror = handleReaderError; + reader.onabort = handleReaderError; + + reader.readAsText(apiDefinitionFile); + }, + [integrationSettings, setIntegrationSettings, setIsParsing] + ); + + return ( + + {apiFileError} + + } + isInvalid={apiFileError != null} + > + <> + + + {i18n.API_DEFINITION_DESCRIPTION} + + + {i18n.API_DEFINITION_DESCRIPTION_2} + + + } + onChange={onChangeApiDefinition} + display="large" + aria-label="Upload API definition file" + isLoading={isParsing} + data-test-subj="apiDefinitionFilePicker" + data-loading={isParsing} + /> + + + ); +}); +ApiDefinitionInput.displayName = 'ApiDefinitionInput'; diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/cel_input_step/cel_input_step.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/cel_input_step/cel_input_step.tsx new file mode 100644 index 0000000000000..b0a0b3194ec33 --- /dev/null +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/cel_input_step/cel_input_step.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiForm, EuiPanel } from '@elastic/eui'; +import { StepContentWrapper } from '../step_content_wrapper'; +import { useActions, type State } from '../../state'; +import type { OnComplete } from './generation_modal'; +import { GenerationModal } from './generation_modal'; +import { ApiDefinitionInput } from './api_definition_input'; +import * as i18n from './translations'; + +interface CelInputStepProps { + integrationSettings: State['integrationSettings']; + connector: State['connector']; + isGenerating: State['isGenerating']; +} + +export const CelInputStep = React.memo( + ({ integrationSettings, connector, isGenerating }) => { + const { setIsGenerating, setStep, setCelInputResult } = useActions(); + + const onGenerationCompleted = useCallback( + (result: State['celInputResult']) => { + if (result) { + setCelInputResult(result); + setIsGenerating(false); + setStep(6); + } + }, + [setCelInputResult, setIsGenerating, setStep] + ); + const onGenerationClosed = useCallback(() => { + setIsGenerating(false); // aborts generation + }, [setIsGenerating]); + + return ( + + + + + + + + + + {isGenerating && ( + + )} + + + ); + } +); +CelInputStep.displayName = 'CelInputStep'; diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/cel_input_step/generation_modal.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/cel_input_step/generation_modal.tsx new file mode 100644 index 0000000000000..db4a89754bbc8 --- /dev/null +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/cel_input_step/generation_modal.tsx @@ -0,0 +1,222 @@ +/* + * 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 { + EuiButtonEmpty, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSpacer, + EuiText, + useEuiTheme, +} from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; +import React, { useCallback, useEffect, useState } from 'react'; +import { css } from '@emotion/react'; +import { getLangSmithOptions } from '../../../../../common/lib/lang_smith'; +import { type CelInputRequestBody } from '../../../../../../common'; +import { runCelGraph } from '../../../../../common/lib/api'; +import { useKibana } from '../../../../../common/hooks/use_kibana'; +import type { State } from '../../state'; +import * as i18n from './translations'; +import { useTelemetry } from '../../../telemetry'; + +export type OnComplete = (result: State['celInputResult']) => void; + +interface UseGenerationProps { + integrationSettings: State['integrationSettings']; + connector: State['connector']; + onComplete: OnComplete; +} +export const useGeneration = ({ + integrationSettings, + connector, + onComplete, +}: UseGenerationProps) => { + const { reportCelGenerationComplete } = useTelemetry(); + const { http, notifications } = useKibana().services; + const [error, setError] = useState(null); + const [isRequesting, setIsRequesting] = useState(true); + + useEffect(() => { + if ( + !isRequesting || + http == null || + connector == null || + integrationSettings == null || + notifications?.toasts == null + ) { + return; + } + const generationStartedAt = Date.now(); + const abortController = new AbortController(); + const deps = { http, abortSignal: abortController.signal }; + + (async () => { + try { + const apiDefinition = integrationSettings.apiDefinition; + const celRequest: CelInputRequestBody = { + dataStreamName: integrationSettings.dataStreamName ?? '', + apiDefinition: apiDefinition ?? '', + connectorId: connector.id, + langSmithOptions: getLangSmithOptions(), + }; + const celGraphResult = await runCelGraph(celRequest, deps); + + if (abortController.signal.aborted) return; + + if (isEmpty(celGraphResult?.results)) { + throw new Error('Results not found in response'); + } + + reportCelGenerationComplete({ + connector, + integrationSettings, + durationMs: Date.now() - generationStartedAt, + }); + + const result = { + program: celGraphResult.results.program, + stateSettings: celGraphResult.results.stateSettings, + redactVars: celGraphResult.results.redactVars, + }; + + onComplete(result); + } catch (e) { + if (abortController.signal.aborted) return; + const errorMessage = `${e.message}${ + e.body ? ` (${e.body.statusCode}): ${e.body.message}` : '' + }`; + + reportCelGenerationComplete({ + connector, + integrationSettings, + durationMs: Date.now() - generationStartedAt, + error: errorMessage, + }); + + setError(errorMessage); + } finally { + setIsRequesting(false); + } + })(); + return () => { + abortController.abort(); + }; + }, [ + isRequesting, + onComplete, + connector, + http, + integrationSettings, + reportCelGenerationComplete, + notifications?.toasts, + ]); + + const retry = useCallback(() => { + setError(null); + setIsRequesting(true); + }, []); + + return { error, retry }; +}; + +const useModalCss = () => { + const { euiTheme } = useEuiTheme(); + return { + headerCss: css` + justify-content: center; + margin-top: ${euiTheme.size.m}; + `, + bodyCss: css` + padding: ${euiTheme.size.xxxxl}; + min-width: 600px; + `, + }; +}; + +interface GenerationModalProps { + integrationSettings: State['integrationSettings']; + connector: State['connector']; + onComplete: OnComplete; + onClose: () => void; +} +export const GenerationModal = React.memo( + ({ integrationSettings, connector, onComplete, onClose }) => { + const { headerCss, bodyCss } = useModalCss(); + const { error, retry } = useGeneration({ + integrationSettings, + connector, + onComplete, + }); + + return ( + + + {i18n.ANALYZING} + + + + {error ? ( + + + {error} + + + ) : ( + <> + + + + + + + + {i18n.PROGRESS_CEL_INPUT_GRAPH} + + + + + + + )} + + + + {error ? ( + + + + {i18n.RETRY} + + + + ) : ( + + )} + + + ); + } +); +GenerationModal.displayName = 'GenerationModal'; diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/cel_input_step/index.ts b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/cel_input_step/index.ts new file mode 100644 index 0000000000000..a04847f478de0 --- /dev/null +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/cel_input_step/index.ts @@ -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 { CelInputStep } from './cel_input_step'; +export * from './is_step_ready'; diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/cel_input_step/is_step_ready.ts b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/cel_input_step/is_step_ready.ts new file mode 100644 index 0000000000000..594f4230164ce --- /dev/null +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/cel_input_step/is_step_ready.ts @@ -0,0 +1,17 @@ +/* + * 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 { State } from '../../state'; + +export const isCelInputStepReady = ({ integrationSettings }: State) => + Boolean( + integrationSettings?.name && + integrationSettings?.dataStreamTitle && + integrationSettings?.dataStreamDescription && + integrationSettings?.dataStreamName && + integrationSettings?.apiDefinition + ); +// TODO add support for not uploading a spec file diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/cel_input_step/translations.ts b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/cel_input_step/translations.ts new file mode 100644 index 0000000000000..60853f9665d74 --- /dev/null +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/cel_input_step/translations.ts @@ -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. + */ + +import { i18n } from '@kbn/i18n'; + +export const ANALYZING = i18n.translate('xpack.integrationAssistant.step.dataStream.analyzing', { + defaultMessage: 'Analyzing', +}); + +export const CEL_INPUT_TITLE = i18n.translate( + 'xpack.integrationAssistant.step.celInput.celInputTitle', + { + defaultMessage: 'Generate CEL input configuration', + } +); +export const CEL_INPUT_DESCRIPTION = i18n.translate( + 'xpack.integrationAssistant.step.celInput.celInputDescription', + { + defaultMessage: 'Upload an OpenAPI spec file to generate a configuration for the CEL input', + } +); + +export const API_DEFINITION_LABEL = i18n.translate( + 'xpack.integrationAssistant.step.celInput.apiDefinition.label', + { + defaultMessage: 'OpenAPI spec', + } +); + +export const API_DEFINITION_DESCRIPTION = i18n.translate( + 'xpack.integrationAssistant.step.celInput.apiDefinition.description', + { + defaultMessage: 'Drag and drop a file or browse files.', + } +); + +export const API_DEFINITION_DESCRIPTION_2 = i18n.translate( + 'xpack.integrationAssistant.step.celInput.apiDefinition.description2', + { + defaultMessage: 'OpenAPI specification', + } +); + +export const API_DEFINITION_ERROR = { + CAN_NOT_READ: i18n.translate( + 'xpack.integrationAssistant.step.celInput.openapiSpec.errorCanNotRead', + { + defaultMessage: 'Failed to read the logs sample file', + } + ), + CAN_NOT_READ_WITH_REASON: (reason: string) => + i18n.translate( + 'xpack.integrationAssistant.step.celInput.openapiSpec.errorCanNotReadWithReason', + { + values: { reason }, + defaultMessage: 'An error occurred when reading spec file: {reason}', + } + ), + TOO_LARGE_TO_PARSE: i18n.translate( + 'xpack.integrationAssistant.step.celInput.openapiSpec.errorTooLargeToParse', + { + defaultMessage: 'This spec file is too large to parse', + } + ), +}; + +export const PROGRESS_CEL_INPUT_GRAPH = i18n.translate( + 'xpack.integrationAssistant.step.celInput.progress.relatedGraph', + { + defaultMessage: 'Generating CEL input configuration', + } +); + +export const GENERATION_ERROR = i18n.translate( + 'xpack.integrationAssistant.step.celInput.generationError', + { + defaultMessage: 'An error occurred during: CEL input generation', + } +); + +export const RETRY = i18n.translate('xpack.integrationAssistant.step.celInput.retryButtonLabel', { + defaultMessage: 'Retry', +}); diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/deploy_step/deploy_step.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/deploy_step/deploy_step.tsx index 81f211de80b1e..0f50fa8104eb5 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/deploy_step/deploy_step.tsx +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/deploy_step/deploy_step.tsx @@ -26,14 +26,16 @@ import * as i18n from './translations'; interface DeployStepProps { integrationSettings: State['integrationSettings']; result: State['result']; + celInputResult?: State['celInputResult']; connector: State['connector']; } export const DeployStep = React.memo( - ({ integrationSettings, result, connector }) => { + ({ integrationSettings, result, celInputResult, connector }) => { const { isLoading, error, integrationFile, integrationName } = useDeployIntegration({ integrationSettings, result, + celInputResult, connector, }); diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/deploy_step/use_deploy_integration.ts b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/deploy_step/use_deploy_integration.ts index 5ec27b4e6de65..e1947e090f2fd 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/deploy_step/use_deploy_integration.ts +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/deploy_step/use_deploy_integration.ts @@ -16,12 +16,14 @@ import { useTelemetry } from '../../../telemetry'; interface PipelineGenerationProps { integrationSettings: State['integrationSettings']; result: State['result']; + celInputResult: State['celInputResult']; connector: State['connector']; } export const useDeployIntegration = ({ integrationSettings, result, + celInputResult, connector, }: PipelineGenerationProps) => { const telemetry = useTelemetry(); @@ -63,6 +65,7 @@ export const useDeployIntegration = ({ docs: result.docs ?? [], samplesFormat: result.samplesFormat ?? { name: 'json' }, pipeline: result.pipeline, + celInput: celInputResult, }, ], }, @@ -119,6 +122,7 @@ export const useDeployIntegration = ({ result?.docs, result?.pipeline, result?.samplesFormat, + celInputResult, telemetry, ]); diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/review_cel_step/cel_config_results.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/review_cel_step/cel_config_results.tsx new file mode 100644 index 0000000000000..91577ed69491a --- /dev/null +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/review_cel_step/cel_config_results.tsx @@ -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 React from 'react'; +import { + EuiFlexItem, + EuiFlexGroup, + EuiCodeBlock, + EuiText, + EuiPanel, + EuiSpacer, +} from '@elastic/eui'; +import { type State } from '../../state'; +import * as i18n from './translations'; + +interface CelConfigResultsProps { + celInputResult: State['celInputResult']; +} + +export const CelConfigResults = React.memo(({ celInputResult }) => { + return ( + + + + +

{i18n.PROGRAM}

+
+ + + {celInputResult?.program} + +
+
+ + + + +

{i18n.STATE}

+
+ + + {JSON.stringify(celInputResult?.stateSettings, null, 2)} + +
+
+ + + + +

{i18n.REDACT_VARS}

+
+ + + {JSON.stringify(celInputResult?.redactVars)} + +
+
+
+ ); +}); +CelConfigResults.displayName = 'CelConfigForm'; diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/review_cel_step/index.ts b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/review_cel_step/index.ts new file mode 100644 index 0000000000000..d396bd17d7a9f --- /dev/null +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/review_cel_step/index.ts @@ -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 { ReviewCelStep } from './review_cel_step'; +export * from './is_step_ready'; diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/review_cel_step/is_step_ready.ts b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/review_cel_step/is_step_ready.ts new file mode 100644 index 0000000000000..166c40e8e2614 --- /dev/null +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/review_cel_step/is_step_ready.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { State } from '../../state'; + +export const isCelReviewStepReady = ({ isGenerating, celInputResult }: State) => + isGenerating === false && celInputResult != null; diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/review_cel_step/review_cel_step.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/review_cel_step/review_cel_step.tsx new file mode 100644 index 0000000000000..a40fec082894e --- /dev/null +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/review_cel_step/review_cel_step.tsx @@ -0,0 +1,34 @@ +/* + * 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 { EuiLoadingSpinner, EuiPanel } from '@elastic/eui'; +import React from 'react'; +import type { State } from '../../state'; +import { StepContentWrapper } from '../step_content_wrapper'; +import * as i18n from './translations'; +import { CelConfigResults } from './cel_config_results'; + +interface ReviewCelStepProps { + celInputResult: State['celInputResult']; + isGenerating: State['isGenerating']; +} + +export const ReviewCelStep = React.memo(({ isGenerating, celInputResult }) => { + return ( + + + {isGenerating ? ( + + ) : ( + <> + + + )} + + + ); +}); +ReviewCelStep.displayName = 'ReviewCelStep'; diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/review_cel_step/translations.ts b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/review_cel_step/translations.ts new file mode 100644 index 0000000000000..25cd90565a3ec --- /dev/null +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/steps/review_cel_step/translations.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const TITLE = i18n.translate('xpack.integrationAssistant.step.reviewCel.title', { + defaultMessage: 'Review results', +}); +export const DESCRIPTION = i18n.translate('xpack.integrationAssistant.step.reviewCel.description', { + defaultMessage: + 'Review the generated CEL input configuration settings for your integration. These settings will be auto-populated into the integration configuration where editing will be possible.', +}); + +export const PROGRAM = i18n.translate('xpack.integrationAssistant.step.reviewCel.program', { + defaultMessage: 'The CEL program to be run for each polling', +}); +export const STATE = i18n.translate('xpack.integrationAssistant.step.reviewCel.state', { + defaultMessage: 'Initial CEL evaluation state', +}); +export const REDACT_VARS = i18n.translate('xpack.integrationAssistant.step.reviewCel.redact', { + defaultMessage: 'Redacted fields', +}); + +export const SAVE_BUTTON = i18n.translate('xpack.integrationAssistant.step.reviewCel.save', { + defaultMessage: 'Save', +}); diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/types.ts b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/types.ts index 6ba7b2945b7a8..27d053a626775 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/types.ts +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/types.ts @@ -35,4 +35,5 @@ export interface IntegrationSettings { inputTypes?: InputType[]; logSamples?: string[]; samplesFormat?: SamplesFormat; + apiDefinition?: string; } diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/telemetry.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/telemetry.tsx index f4dd6d7d436be..39076726b7f9d 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/telemetry.tsx +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/telemetry.tsx @@ -35,6 +35,12 @@ type ReportGenerationComplete = (params: { durationMs: number; error?: string; }) => void; +type ReportCelGenerationComplete = (params: { + connector: AIConnector; + integrationSettings: IntegrationSettings; + durationMs: number; + error?: string; +}) => void; type ReportAssistantComplete = (params: { integrationName: string; integrationSettings: IntegrationSettings; @@ -47,6 +53,7 @@ interface TelemetryContextProps { reportAssistantOpen: ReportAssistantOpen; reportAssistantStepComplete: ReportAssistantStepComplete; reportGenerationComplete: ReportGenerationComplete; + reportCelGenerationComplete: ReportCelGenerationComplete; reportAssistantComplete: ReportAssistantComplete; } @@ -113,6 +120,20 @@ export const TelemetryContextProvider = React.memo>(({ chi [telemetry] ); + const reportCelGenerationComplete = useCallback( + ({ connector, integrationSettings, durationMs, error }) => { + telemetry.reportEvent(TelemetryEventType.IntegrationAssistantCelGenerationComplete, { + sessionId: sessionData.current.sessionId, + actionTypeId: connector.actionTypeId, + model: getConnectorModel(connector), + provider: connector.apiProvider ?? 'unknown', + durationMs, + errorMessage: error, + }); + }, + [telemetry] + ); + const reportAssistantComplete = useCallback( ({ integrationName, integrationSettings, connector, error }) => { telemetry.reportEvent(TelemetryEventType.IntegrationAssistantComplete, { @@ -137,6 +158,7 @@ export const TelemetryContextProvider = React.memo>(({ chi reportAssistantOpen, reportAssistantStepComplete, reportGenerationComplete, + reportCelGenerationComplete, reportAssistantComplete, }), [ @@ -144,6 +166,7 @@ export const TelemetryContextProvider = React.memo>(({ chi reportAssistantOpen, reportAssistantStepComplete, reportGenerationComplete, + reportCelGenerationComplete, reportAssistantComplete, ] ); diff --git a/x-pack/plugins/integration_assistant/public/services/telemetry/events.ts b/x-pack/plugins/integration_assistant/public/services/telemetry/events.ts index 4d2d24ff7a657..0da7cdb9b288c 100644 --- a/x-pack/plugins/integration_assistant/public/services/telemetry/events.ts +++ b/x-pack/plugins/integration_assistant/public/services/telemetry/events.ts @@ -133,6 +133,51 @@ export const telemetryEventsSchemas: TelemetryEventsSchemas = { }, }, + [TelemetryEventType.IntegrationAssistantCelGenerationComplete]: { + sessionId: { + type: 'keyword', + _meta: { + description: 'The ID to identify all the events the same session', + optional: false, + }, + }, + durationMs: { + type: 'long', + _meta: { + description: 'Time spent in the generation process', + optional: false, + }, + }, + actionTypeId: { + type: 'keyword', + _meta: { + description: 'The connector action type ID', + optional: false, + }, + }, + model: { + type: 'keyword', + _meta: { + description: 'The model used to generate the integration', + optional: false, + }, + }, + provider: { + type: 'keyword', + _meta: { + description: 'The provider of the LLM', + optional: false, + }, + }, + errorMessage: { + type: 'text', + _meta: { + description: 'The error message if the generation failed', + optional: true, + }, + }, + }, + [TelemetryEventType.IntegrationAssistantComplete]: { sessionId: { type: 'keyword', diff --git a/x-pack/plugins/integration_assistant/public/services/telemetry/types.ts b/x-pack/plugins/integration_assistant/public/services/telemetry/types.ts index 2f674b5880514..6b5f8e8b558fe 100644 --- a/x-pack/plugins/integration_assistant/public/services/telemetry/types.ts +++ b/x-pack/plugins/integration_assistant/public/services/telemetry/types.ts @@ -11,6 +11,7 @@ export enum TelemetryEventType { IntegrationAssistantOpen = 'integration_assistant_open', IntegrationAssistantStepComplete = 'integration_assistant_step_complete', IntegrationAssistantGenerationComplete = 'integration_assistant_generation_complete', + IntegrationAssistantCelGenerationComplete = 'integration_assistant_cel_generation_complete', IntegrationAssistantComplete = 'integration_assistant_complete', } @@ -43,6 +44,15 @@ interface IntegrationAssistantGenerationCompleteData { errorMessage?: string; } +interface IntegrationAssistantCelGenerationCompleteData { + sessionId: string; + durationMs: number; + actionTypeId: string; + model: string; + provider: string; + errorMessage?: string; +} + interface IntegrationAssistantCompleteData { sessionId: string; durationMs: number; @@ -69,6 +79,8 @@ export type TelemetryEventTypeData = ? IntegrationAssistantStepCompleteData : T extends TelemetryEventType.IntegrationAssistantGenerationComplete ? IntegrationAssistantGenerationCompleteData + : T extends TelemetryEventType.IntegrationAssistantCelGenerationComplete + ? IntegrationAssistantCelGenerationCompleteData : T extends TelemetryEventType.IntegrationAssistantComplete ? IntegrationAssistantCompleteData : never; diff --git a/x-pack/plugins/integration_assistant/server/graphs/cel/build_program.test.ts b/x-pack/plugins/integration_assistant/server/graphs/cel/build_program.test.ts new file mode 100644 index 0000000000000..39a3902399848 --- /dev/null +++ b/x-pack/plugins/integration_assistant/server/graphs/cel/build_program.test.ts @@ -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 { + ActionsClientChatOpenAI, + ActionsClientSimpleChatModel, +} from '@kbn/langchain/server/language_models'; +import { FakeLLM } from '@langchain/core/utils/testing'; +import { celTestState } from '../../../__jest__/fixtures/cel'; +import type { CelInputState } from '../../types'; +import { handleBuildProgram } from './build_program'; + +const model = new FakeLLM({ + response: 'my_cel_program', +}) as unknown as ActionsClientChatOpenAI | ActionsClientSimpleChatModel; + +const state: CelInputState = celTestState; + +describe('Testing cel handler', () => { + it('handleBuildProgram()', async () => { + const response = await handleBuildProgram({ state, model }); + expect(response.currentProgram).toStrictEqual('my_cel_program'); + expect(response.lastExecutedChain).toBe('buildCelProgram'); + }); +}); diff --git a/x-pack/plugins/integration_assistant/server/graphs/cel/build_program.ts b/x-pack/plugins/integration_assistant/server/graphs/cel/build_program.ts new file mode 100644 index 0000000000000..8ebd24cd93326 --- /dev/null +++ b/x-pack/plugins/integration_assistant/server/graphs/cel/build_program.ts @@ -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 { StringOutputParser } from '@langchain/core/output_parsers'; +import { CelInputState } from '../../types'; +import { EX_ANSWER_PROGRAM, SAMPLE_CEL_PROGRAMS } from './constants'; +import { CEL_BASE_PROGRAM_PROMPT } from './prompts'; +import { CelInputNodeParams } from './types'; + +export async function handleBuildProgram({ + state, + model, +}: CelInputNodeParams): Promise> { + const outputParser = new StringOutputParser(); + const celProgramGraph = CEL_BASE_PROGRAM_PROMPT.pipe(model).pipe(outputParser); + + const program = await celProgramGraph.invoke({ + data_stream_name: state.dataStreamName, + example_cel_programs: SAMPLE_CEL_PROGRAMS, + open_api_spec: state.apiDefinition, + api_query_summary: state.apiQuerySummary, + ex_answer: EX_ANSWER_PROGRAM, + }); + + return { + currentProgram: program.trim(), + lastExecutedChain: 'buildCelProgram', + }; +} diff --git a/x-pack/plugins/integration_assistant/server/graphs/cel/constants.ts b/x-pack/plugins/integration_assistant/server/graphs/cel/constants.ts new file mode 100644 index 0000000000000..3119346091e4e --- /dev/null +++ b/x-pack/plugins/integration_assistant/server/graphs/cel/constants.ts @@ -0,0 +1,298 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export const EX_ANSWER_PROGRAM = `( + !has(state.cursor) || !has(state.cursor.scroll_id) ? + post(state.url+"?scroll=5m", "", "") + : + post( + state.url+"/scroll?"+{"scroll_id": [state.cursor.scroll_id]}.format_query(), + "application/json", + {"scroll": state.scroll}.encode_json() + ) + ).as(resp, bytes(resp.Body).decode_json().as(body, { + "events": body.hits.hits, + "cursor": {"scroll_id": body._scroll_id}, +}))`; + +export const EX_ANSWER_STATE = `["config1", "config2"]`; + +export const EX_ANSWER_CONFIG = [ + { + name: 'config1', + default: 50, + redact: false, + }, + { + name: 'config2', + default: '', + redact: false, + }, + { + name: 'config3', + default: 'event', + redact: true, + }, +]; + +export const SAMPLE_CEL_PROGRAMS = [ + `request("GET", (state.url+"&startingAfter="+state.cursor.checkpoint)).with({ + "Header":{ + "Content-Type": ["application/json"], + "Authorization": [state.token], + }, + }).do_request().as(resp, resp.StatusCode == 200 ? + bytes(resp.Body).decode_json().as(body,{ + "events": body.map(e, { "message": e.encode_json(), }), + "cursor": { + "initial_checkpoint": state.cursor.initial_checkpoint, + "checkpoint": body.size() > 0 ? body[body.size()-1].?serial : state.cursor.initial_checkpoint, + }, + "want_more": body.size() != 0, + "token": state.token, + }) + : + { + "events": { + "error": { + "code": string(resp.StatusCode), + "id": string(resp.Status), + "message": string(resp.Body) + }, + }, + "want_more": false, + } + )`, + `request("GET", state.url).with({ + "Header":{ + "Content-Type": ["application/json"], + } + }).as(req, req.do_request().as(resp, + bytes(resp.Body).decode_json().as(body, { + "events": body.payloads.map(payload, { + "message": payload.encode_json() + }), + "url": state.url + }) + ))`, + `( + !state.want_more ? + request("GET", state.url + "/iocs/combined/indicator/v1?sort=modified_on&offset=0&limit=" + string(state.batch_size) + '&filter=modified_on:>"' + ( + has(state.cursor) && has(state.cursor.last_timestamp) && state.cursor.last_timestamp != null ? + state.cursor.last_timestamp + '"' + : + (now - duration(state.initial_interval)).format(time_layout.RFC3339) + '"' + )) + : + request("GET", state.url + "/iocs/combined/indicator/v1?sort=modified_on&limit=" + string(state.batch_size) + "&offset=" + string(state.offset) + '&filter=modified_on:>"' + ( + has(state.cursor) && has(state.cursor.first_timestamp) && state.cursor.first_timestamp != null ? + state.cursor.first_timestamp + '"' + : + '"' + )) + ).do_request().as(resp, + resp.StatusCode == 200 ? + bytes(resp.Body).decode_json().as(body, { + "events": body.resources.map(e, { + "message": e.encode_json(), + }), + "want_more": has(body.meta.pagination) && (int(state.offset) + body.resources.size()) < body.meta.pagination.total, + "offset": has(body.meta.pagination) && ((int(state.offset) + body.resources.size()) < body.meta.pagination.total) ? + int(state.offset) + int(body.resources.size()) + : + 0, + "url": state.url, + "batch_size": state.batch_size, + "initial_interval": state.initial_interval, + "cursor": { + "last_timestamp": ( + has(body.resources) && body.resources.size() > 0 ? + ( + has(state.cursor) && has(state.cursor.last_timestamp) && body.resources.map(e, e.modified_on).max() < state.cursor.last_timestamp ? + state.cursor.last_timestamp + : + body.resources.map(e, e.modified_on).max() + ) + : + ( + has(state.cursor) && has(state.cursor.last_timestamp) ? + state.cursor.last_timestamp + : + null + ) + ), + "first_timestamp": ( + has(state.cursor) && has(state.cursor.first_timestamp) && state.cursor.first_timestamp != null ? + ( + state.want_more ? + state.cursor.first_timestamp + : + state.cursor.last_timestamp + ) + : + (now - duration(state.initial_interval)).format(time_layout.RFC3339) + ), + }, + }) + : + { + "events": { + "error": { + "code": string(resp.StatusCode), + "id": string(resp.Status), + "message": string(resp.Body) + }, + }, + "want_more": false, + "offset": 0, + "url": state.url, + "batch_size": state.batch_size, + "initial_interval": state.initial_interval, + } + ) + `, + ` + state.with({ + "Header": { + "Accept": ["application/vnd.api+json"], + "Authorization": ["Token " + state.api_token], + } +}.as(auth_header, + ( + has(state.work_list) ? + state.work_list + : (state.audit_id == "*" && state.end_point_type == "/rest/orgs/") ? + get_request( + state.url.trim_right("/") + "/rest/orgs?" + { + "version": [state.version], + }.format_query() + ).with(auth_header).do_request().as(resp, resp.StatusCode != 200 ? [] : + resp.Body.decode_json().data.map(org, { + "id": org.id, + ?"last_created": state.?cursor[org.id].last_created + }) + ) + : has(state.?cursor.last_created) ? + [{ + "id": state.audit_id, + "last_created": state.cursor.last_created, + }] + : has(state.cursor) && state.end_point_type == "/rest/orgs/" ? + state.cursor.map(audit_id, state.cursor[audit_id].with({"id": audit_id})) + : + [{"id": state.audit_id}] + ).map(item, + get_request( + debug("GET", state.url.trim_right("/") + item.?next.orValue( + state.end_point_type + item.id + "/audit_logs/search?" + debug("QUERY",{ + "version": [state.version], + "sort_order": ['ASC'], + ?"from": has(item.last_created) ? + optional.of([string(timestamp(item.last_created)+duration("1us"))]) + : has(state.lookback) ? + optional.of([string(now-duration(state.lookback))]) + : + optional.none(), + ?"size": has(state.size) ? + optional.of([string(int(state.size))]) + : + optional.none(), + ?"user_id": has(state.user_id) ? + optional.of([state.user_id]) + : + optional.none(), + ?"project_id": has(state.project_id) ? + optional.of([state.project_id]) + : + optional.none(), + ?"events": state.?events_filter, + }).format_query() + )) + ).with(auth_header).do_request().as(resp, resp.StatusCode != 200 ? + { + "id": item.id, + "events": [{ + "error": { + "code": string(resp.StatusCode), + "id": string(resp.Status), + "message": size(resp.Body) != 0 ? + string(resp.Body) + : + string(resp.Status) + ' (' + string(resp.StatusCode) + ')', + } + }], + "want_more": false, + } + : + bytes(resp.Body).decode_json().as(body, !has(body.?data.items) ? + { + "id": item.id, + "events":[], + "want_more": false, + } + : + { + "id": item.id, + "events": body.data.items.map(item, { + "message": item.encode_json() + }), + "cursor": { + "id": item.id, + ?"next": body.?links.next, + "last_created": body.data.items.map(item, + has(item.created), timestamp(item.created) + ).as(times, size(times) == 0 ? item.?last_created.orValue(now) : times.max()), + }, + "want_more": has(body.?links.next), + } + ) + ) + ).as(result, { + "cursor": state.?cursor.orValue({}).drop("last_created").with(zip( + result.map(r, has(r.?cursor.id), r.cursor.id), + result.map(r, has(r.?cursor.id), r.cursor.drop(["id","next"])) + )), + "work_list": result.map(r, has(r.?cursor.next), { + "id": r.cursor.id, + "next": r.cursor.next, + }), + "events": result.map(r, r.events).flatten(), + "want_more": result.exists(r, r.want_more), + }) +))`, + `( + has(state.hostlist) && size(state.hostlist) > 0 ? + state + : + state.with(request("GET", state.url + "/api/atlas/v2/groups/" + state.group_id + "/processes?pageNum=" + string(state.page_num) + "&itemsPerPage=state.page_size").with({ + "Header": { + "Accept": ["application/vnd.atlas." + string(now.getFullYear()) + "-01-01+json"] + } + }).do_request().as(resp, bytes(resp.Body).decode_json().as(body, { + "hostlist": body.results.map(e, state.url + "/api/atlas/v2/groups/" + state.group_id + "/processes/" + e.id + state.query), + "next": 0, + "page_num": body.links.exists_one(res, res.rel == "next") ? int(state.page_num)+1 : 1 + }))) + ).as(state, state.next >= size(state.hostlist) ? {} : + request("GET", string(state.hostlist[state.next])).with({ + "Header": { + "Accept": ["application/vnd.atlas." + string(now.getFullYear()) + "-01-01+json"] + } + }).do_request().as(res, { + "events": bytes(res.Body).decode_json().as(f, f.with({"response": zip( + //Combining measurement names and actual values of measurement to generate \`key : value\` pairs. + f.measurements.map(m, m.name), + f.measurements.map(m, m.dataPoints.map(d, d.value).as(v, size(v) == 0 ? null : v[0])) + )}).drop(["measurements", "links"])), + "hostlist": (int(state.next)+1) < size(state.hostlist) ? state.hostlist : [], + "next": (int(state.next)+1) < size(state.hostlist) ? (int(state.next)+1) : 0, + "want_more": (int(state.next)+1) < size(state.hostlist) || state.page_num != 1, + "page_num": state.page_num, + "group_id": state.group_id, + "query": state.query, + }) + )`, +]; diff --git a/x-pack/plugins/integration_assistant/server/graphs/cel/graph.test.ts b/x-pack/plugins/integration_assistant/server/graphs/cel/graph.test.ts new file mode 100644 index 0000000000000..7af5e9c83c3fc --- /dev/null +++ b/x-pack/plugins/integration_assistant/server/graphs/cel/graph.test.ts @@ -0,0 +1,99 @@ +/* + * 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 { FakeLLM } from '@langchain/core/utils/testing'; +import { getCelGraph } from './graph'; +import { + celProgramMock, + celQuerySummaryMockedResponse, + celStateVarsMockedResponse, + celExpectedResults, + celStateSettings, + celRedact, +} from '../../../__jest__/fixtures/cel'; +import { mockedRequestWithApiDefinition } from '../../../__jest__/fixtures'; +import { handleSummarizeQuery } from './summarize_query'; +import { handleBuildProgram } from './build_program'; +import { handleGetStateVariables } from './retrieve_state_vars'; +import { handleGetStateDetails } from './retrieve_state_details'; + +import { + ActionsClientChatOpenAI, + ActionsClientSimpleChatModel, +} from '@kbn/langchain/server/language_models'; + +const model = new FakeLLM({ + response: "I'll callback later.", +}) as unknown as ActionsClientChatOpenAI | ActionsClientSimpleChatModel; + +jest.mock('./summarize_query'); +jest.mock('./build_program'); +jest.mock('./retrieve_state_vars'); +jest.mock('./retrieve_state_details'); + +describe('CelGraph', () => { + beforeEach(() => { + // Mocked responses for each node that requires an LLM API call/response. + const mockInvokeCelSummarizeQuery = jest.fn().mockResolvedValue(celQuerySummaryMockedResponse); + const mockInvokeCelProgram = jest.fn().mockResolvedValue(celProgramMock); + const mockInvokeCelStateVars = jest.fn().mockResolvedValue(celStateVarsMockedResponse); + const mockInvokeCelStateSettings = jest.fn().mockResolvedValue(celStateSettings); + const mockInvokeCelRedactVars = jest.fn().mockResolvedValue(celRedact); + + // Returns the initial query summary for the api, to trigger the next step. + (handleSummarizeQuery as jest.Mock).mockImplementation(async () => ({ + apiQuerySummary: await mockInvokeCelSummarizeQuery(), + lastExecutedChain: 'summarizeQuery', + })); + + // Returns the CEL program, to trigger the next step. + (handleBuildProgram as jest.Mock).mockImplementation(async () => ({ + currentProgram: await mockInvokeCelProgram(), + lastExecutedChain: 'buildProgram', + })); + + // Returns the state variable names for the CEL program, to trigger the next step. + (handleGetStateVariables as jest.Mock).mockImplementation(async () => ({ + stateVarNames: await mockInvokeCelStateVars(), + lastExecutedChain: 'getStateVars', + })); + + // Returns the state details for the CEL program. + (handleGetStateDetails as jest.Mock).mockImplementation(async () => ({ + stateSettings: await mockInvokeCelStateSettings(), + redactVars: await mockInvokeCelRedactVars(), + lastExecutedChain: 'getStateDetails', + })); + }); + + it('Ensures that the graph compiles', async () => { + // When getCelGraph runs, langgraph compiles the graph it will error if the graph has any issues. + // Common issues for example detecting a node has no next step, or there is a infinite loop between them. + try { + await getCelGraph({ model }); + } catch (error) { + throw Error(`getCelGraph threw an error: ${error}`); + } + }); + + it('Runs the whole graph, with mocked outputs from the LLM.', async () => { + const celGraph = await getCelGraph({ model }); + let response; + try { + response = await celGraph.invoke(mockedRequestWithApiDefinition); + } catch (error) { + throw Error(`getCelGraph threw an error: ${error}`); + } + + expect(handleSummarizeQuery).toHaveBeenCalled(); + expect(handleBuildProgram).toHaveBeenCalled(); + expect(handleGetStateVariables).toHaveBeenCalled(); + expect(handleGetStateDetails).toHaveBeenCalled(); + + expect(response.results).toStrictEqual(celExpectedResults); + }); +}); diff --git a/x-pack/plugins/integration_assistant/server/graphs/cel/graph.ts b/x-pack/plugins/integration_assistant/server/graphs/cel/graph.ts new file mode 100644 index 0000000000000..a8f2e0521c788 --- /dev/null +++ b/x-pack/plugins/integration_assistant/server/graphs/cel/graph.ts @@ -0,0 +1,109 @@ +/* + * 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 { StateGraphArgs } from '@langchain/langgraph'; +import { END, START, StateGraph } from '@langchain/langgraph'; +import type { CelInputState } from '../../types'; +import { handleBuildProgram } from './build_program'; +import { handleGetStateDetails } from './retrieve_state_details'; +import { handleGetStateVariables } from './retrieve_state_vars'; +import { handleSummarizeQuery } from './summarize_query'; +import { CelInputBaseNodeParams, CelInputGraphParams } from './types'; + +const graphState: StateGraphArgs['channels'] = { + lastExecutedChain: { + value: (x: string, y?: string) => y ?? x, + default: () => '', + }, + dataStreamName: { + value: (x: string, y?: string) => y ?? x, + default: () => '', + }, + finalized: { + value: (x: boolean, y?: boolean) => y ?? x, + default: () => false, + }, + results: { + value: (x: object, y?: object) => y ?? x, + default: () => ({}), + }, + apiDefinition: { + value: (x: string, y?: string) => y ?? x, + default: () => '', + }, + apiQuerySummary: { + value: (x: string, y?: string) => y ?? x, + default: () => '', + }, + exampleCelPrograms: { + value: (x: string[], y?: string[]) => y ?? x, + default: () => [], + }, + currentProgram: { + value: (x: string, y?: string) => y ?? x, + default: () => '', + }, + stateVarNames: { + value: (x: string[], y?: string[]) => y ?? x, + default: () => [], + }, + stateSettings: { + value: (x: object, y?: object) => y ?? x, + default: () => ({}), + }, + redactVars: { + value: (x: string[], y?: string[]) => y ?? x, + default: () => [], + }, +}; + +function modelInput({ state }: CelInputBaseNodeParams): Partial { + return { + finalized: false, + lastExecutedChain: 'modelInput', + apiDefinition: state.apiDefinition, + dataStreamName: state.dataStreamName, + }; +} + +function modelOutput({ state }: CelInputBaseNodeParams): Partial { + return { + finalized: true, + lastExecutedChain: 'modelOutput', + results: { + program: state.currentProgram, + stateSettings: state.stateSettings, + redactVars: state.redactVars, + }, + }; +} + +export async function getCelGraph({ model }: CelInputGraphParams) { + const workflow = new StateGraph({ channels: graphState }) + .addNode('modelInput', (state: CelInputState) => modelInput({ state })) + .addNode('handleSummarizeQuery', (state: CelInputState) => + handleSummarizeQuery({ state, model }) + ) + .addNode('handleBuildProgram', (state: CelInputState) => handleBuildProgram({ state, model })) + .addNode('handleGetStateVariables', (state: CelInputState) => + handleGetStateVariables({ state, model }) + ) + .addNode('handleGetStateDetails', (state: CelInputState) => + handleGetStateDetails({ state, model }) + ) + .addNode('modelOutput', (state: CelInputState) => modelOutput({ state })) + .addEdge(START, 'modelInput') + .addEdge('modelOutput', END) + .addEdge('modelInput', 'handleSummarizeQuery') + .addEdge('handleSummarizeQuery', 'handleBuildProgram') + .addEdge('handleBuildProgram', 'handleGetStateVariables') + .addEdge('handleGetStateVariables', 'handleGetStateDetails') + .addEdge('handleGetStateDetails', 'modelOutput'); + + const compiledCelGraph = workflow.compile().withConfig({ runName: 'CEL' }); + return compiledCelGraph; +} diff --git a/x-pack/plugins/integration_assistant/server/graphs/cel/index.ts b/x-pack/plugins/integration_assistant/server/graphs/cel/index.ts new file mode 100644 index 0000000000000..c30f5e32e0fe5 --- /dev/null +++ b/x-pack/plugins/integration_assistant/server/graphs/cel/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { getCelGraph } from './graph'; diff --git a/x-pack/plugins/integration_assistant/server/graphs/cel/prompts.ts b/x-pack/plugins/integration_assistant/server/graphs/cel/prompts.ts new file mode 100644 index 0000000000000..71cab16ae2c88 --- /dev/null +++ b/x-pack/plugins/integration_assistant/server/graphs/cel/prompts.ts @@ -0,0 +1,156 @@ +/* + * 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 { ChatPromptTemplate } from '@langchain/core/prompts'; + +export const CEL_QUERY_SUMMARY_PROMPT = ChatPromptTemplate.fromMessages([ + [ + 'system', + `You are a helpful, expert assistant in REST APIs and OpenAPI specifications. +Here is some context for you to reference for your task, read it carefully as you will get questions about it later: + + +{open_api_spec} + +`, + ], + [ + 'human', + `For the {data_stream_name} endpoint and provided OpenAPI specification, please describe which query parameters you would use so that all events are covered in a chronological manner. + +You ALWAYS follow these guidelines when writing your response: + + - Prioritize bulk api routes over more specialized routes. + + +Please respond with a concise text answer, and a sample URL path.`, + ], + ['ai', `Please find the query summary text below:`], +]); + +export const CEL_BASE_PROGRAM_PROMPT = ChatPromptTemplate.fromMessages([ + [ + 'system', + `You are a helpful, expert assistant in building Elastic filebeat input configurations utilizing the Common Expression Language (CEL) input type. +Here is some context for you to reference your task, review it carefully as you will get questions about it later: + + +{open_api_spec} + + +{example_cel_programs} + +`, + ], + [ + 'human', + `Please build only the program section of the CEL filebeat input configuration for the the datastream {data_stream_name} such that the program is able to iterate through and ingest pages of events into Elasticsearch. +Utilize the following paging summary details and sample URL for implementing paging when building your output. + + + +{api_query_summary} + + + +Each of the following criteria must be addressed in final configuration output: +- The REST verb must be specified. +- The request URL must include the 'state.url'. +- The request URL must include the API path. +- The request URL must include all query parameters from the paging summary using a 'format_query' function. Remember to utilize the state variables inside brackets when building the function and be sure to cast any numeric variables to string using 'string(variable)'. +- All request URL parameters must utilize state variables. +- The request URL must include a '?' at the end of API path string. +- Always use the casing specified by the API spec when building the API path and query parameters. +- There must not be configuration for authentication or authorization. +- There must be configuration of any required headers. +- There must be configuration for parsing the events returned from the API mapped to the 'message' field and encoded in JSON. +- There must be configuration in the API response handling for 'want_more' based on the paging token. +- There must be configuration for error handling. This includes setting the 'want_more' flag to false. +- All state variables must use snake casing. +- The page tokens must be updated the corresponding state variable(s). + +You ALWAYS follow these guidelines when writing your response: + +- You must never include any code for writing data to the API. +- You must respond only with the code block containing the program formatted like human-readable C code. See example response below. +- You must use 2 spaces for tab size. +- Do not enclose the final output in backticks, only return the codeblock and nothing else. + + +Example response: +A: Please find the CEL program below: +{ex_answer}`, + ], + ['ai', `Please find the CEL program below:`], +]); + +export const CEL_STATE_PROMPT = ChatPromptTemplate.fromMessages([ + [ + 'system', + `You are a helpful, expert assistant in building Elastic filebeat input configurations utilizing the Common Expression Language (CEL) input type. +Here is some context for you to reference for your task, read it carefully as you will get questions about it later: + + + +{cel_program} + +`, + ], + [ + 'human', + `Looking at the CEL program provided in the context, please return a string array of the state variables + +You ALWAYS follow these guidelines when writing your response: + + + - Respond with the array only. + + + + A: Please find the JSON list below: + \`\`\` +{ex_answer} + \`\`\` + `, + ], + ['ai', `Please find the JSON list below:`], +]); + +export const CEL_CONFIG_DETAILS_PROMPT = ChatPromptTemplate.fromMessages([ + [ + 'system', + `You are a helpful, expert assistant on OpenAPI specifications and building Elastic integrations. +Here is some context for you to reference for your task, read it carefully as you will get questions about it later: + + + +{open_api_spec} + +`, + ], + [ + 'human', + `For the identified state variables {state_variables}, iterate through each variable (name) and identify a default value (default) and a boolean representing if it should be redacted(redact). Return all of this information in a JSON object like the sample below. + +You ALWAYS follow these guidelines when writing your response: + + + - Page sizing default should always be non-zero. + - Redact anything that could possibly contain PII, tokens or keys, or expose any sensitive information in the logs. + - You must use the variable names in parentheses when building the return object. Each item in the response must contain the fields: name, redact and default. + - Do not respond with anything except the JSON object enclosed with 3 backticks (\`), see example response below. + +Example response format: + + A: Please find the JSON object below: + \`\`\`json +{ex_answer} + \`\`\` + +`, + ], + ['ai', `Please find the JSON object below:`], +]); diff --git a/x-pack/plugins/integration_assistant/server/graphs/cel/retrieve_state_details.test.ts b/x-pack/plugins/integration_assistant/server/graphs/cel/retrieve_state_details.test.ts new file mode 100644 index 0000000000000..9523ac708fbd5 --- /dev/null +++ b/x-pack/plugins/integration_assistant/server/graphs/cel/retrieve_state_details.test.ts @@ -0,0 +1,34 @@ +/* + * 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 { + ActionsClientChatOpenAI, + ActionsClientSimpleChatModel, +} from '@kbn/langchain/server/language_models'; +import { FakeLLM } from '@langchain/core/utils/testing'; +import { + celExpectedResults, + celStateDetailsMockedResponse, + celTestState, +} from '../../../__jest__/fixtures/cel'; +import type { CelInputState } from '../../types'; +import { handleGetStateDetails } from './retrieve_state_details'; + +const model = new FakeLLM({ + response: JSON.stringify(celStateDetailsMockedResponse, null, 2), +}) as unknown as ActionsClientChatOpenAI | ActionsClientSimpleChatModel; + +const state: CelInputState = celTestState; + +describe('Testing cel handler', () => { + it('handleGetStateDetails()', async () => { + const response = await handleGetStateDetails({ state, model }); + expect(response.stateSettings).toStrictEqual(celExpectedResults.stateSettings); + expect(response.redactVars).toStrictEqual(celExpectedResults.redactVars); + expect(response.lastExecutedChain).toBe('getStateDetails'); + }); +}); diff --git a/x-pack/plugins/integration_assistant/server/graphs/cel/retrieve_state_details.ts b/x-pack/plugins/integration_assistant/server/graphs/cel/retrieve_state_details.ts new file mode 100644 index 0000000000000..884767ee56fcb --- /dev/null +++ b/x-pack/plugins/integration_assistant/server/graphs/cel/retrieve_state_details.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { JsonOutputParser } from '@langchain/core/output_parsers'; +import { CelInputState } from '../../types'; +import { EX_ANSWER_CONFIG } from './constants'; +import { CEL_CONFIG_DETAILS_PROMPT } from './prompts'; +import { CelInputNodeParams, CelInputStateDetails } from './types'; +import { getRedactVariables, getStateVarsAndDefaultValues } from './util'; + +export async function handleGetStateDetails({ + state, + model, +}: CelInputNodeParams): Promise> { + const outputParser = new JsonOutputParser(); + const celConfigGraph = CEL_CONFIG_DETAILS_PROMPT.pipe(model).pipe(outputParser); + + const stateDetails = (await celConfigGraph.invoke({ + state_variables: state.stateVarNames, + open_api_spec: state.apiDefinition, + ex_answer: EX_ANSWER_CONFIG, + })) as CelInputStateDetails[]; + + const stateSettings = getStateVarsAndDefaultValues(stateDetails); + const redactVars = getRedactVariables(stateDetails); + + return { + stateSettings, + redactVars, + lastExecutedChain: 'getStateDetails', + }; +} diff --git a/x-pack/plugins/integration_assistant/server/graphs/cel/retrieve_state_vars.test.ts b/x-pack/plugins/integration_assistant/server/graphs/cel/retrieve_state_vars.test.ts new file mode 100644 index 0000000000000..03165ae8c8692 --- /dev/null +++ b/x-pack/plugins/integration_assistant/server/graphs/cel/retrieve_state_vars.test.ts @@ -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 { + ActionsClientChatOpenAI, + ActionsClientSimpleChatModel, +} from '@kbn/langchain/server/language_models'; +import { FakeLLM } from '@langchain/core/utils/testing'; +import { celTestState } from '../../../__jest__/fixtures/cel'; +import type { CelInputState } from '../../types'; +import { handleGetStateVariables } from './retrieve_state_vars'; + +const model = new FakeLLM({ + response: '[ "my_state_var", "url" ]', +}) as unknown as ActionsClientChatOpenAI | ActionsClientSimpleChatModel; + +const state: CelInputState = celTestState; + +describe('Testing cel handler', () => { + it('handleGetStateVariables()', async () => { + const response = await handleGetStateVariables({ state, model }); + expect(response.stateVarNames).toStrictEqual(['my_state_var']); + expect(response.lastExecutedChain).toBe('getStateVars'); + }); +}); diff --git a/x-pack/plugins/integration_assistant/server/graphs/cel/retrieve_state_vars.ts b/x-pack/plugins/integration_assistant/server/graphs/cel/retrieve_state_vars.ts new file mode 100644 index 0000000000000..a5c252778ace5 --- /dev/null +++ b/x-pack/plugins/integration_assistant/server/graphs/cel/retrieve_state_vars.ts @@ -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 { JsonOutputParser } from '@langchain/core/output_parsers'; +import { CelInputState } from '../../types'; +import { EX_ANSWER_STATE } from './constants'; +import { CEL_STATE_PROMPT } from './prompts'; +import { CelInputNodeParams } from './types'; + +export async function handleGetStateVariables({ + state, + model, +}: CelInputNodeParams): Promise> { + const outputParser = new JsonOutputParser(); + const celStateGraph = CEL_STATE_PROMPT.pipe(model).pipe(outputParser); + + const celState = await celStateGraph.invoke({ + cel_program: state.currentProgram, + ex_answer: EX_ANSWER_STATE, + }); + + // Return all state vars besides the URL as it gets included automatically + const filteredState = celState.filter((stateVar: string) => stateVar !== 'url'); + + return { + stateVarNames: filteredState, + lastExecutedChain: 'getStateVars', + }; +} diff --git a/x-pack/plugins/integration_assistant/server/graphs/cel/summarize_query.test.ts b/x-pack/plugins/integration_assistant/server/graphs/cel/summarize_query.test.ts new file mode 100644 index 0000000000000..6dcb28813df70 --- /dev/null +++ b/x-pack/plugins/integration_assistant/server/graphs/cel/summarize_query.test.ts @@ -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 { + ActionsClientChatOpenAI, + ActionsClientSimpleChatModel, +} from '@kbn/langchain/server/language_models'; +import { FakeLLM } from '@langchain/core/utils/testing'; +import { celTestState } from '../../../__jest__/fixtures/cel'; +import type { CelInputState } from '../../types'; +import { handleSummarizeQuery } from './summarize_query'; + +const model = new FakeLLM({ + response: 'my_api_query_summary', +}) as unknown as ActionsClientChatOpenAI | ActionsClientSimpleChatModel; + +const state: CelInputState = celTestState; + +describe('Testing cel handler', () => { + it('handleSummarizeQuery()', async () => { + const response = await handleSummarizeQuery({ state, model }); + expect(response.apiQuerySummary).toStrictEqual('my_api_query_summary'); + expect(response.lastExecutedChain).toBe('summarizeQuery'); + }); +}); diff --git a/x-pack/plugins/integration_assistant/server/graphs/cel/summarize_query.ts b/x-pack/plugins/integration_assistant/server/graphs/cel/summarize_query.ts new file mode 100644 index 0000000000000..dc5e7e32e8697 --- /dev/null +++ b/x-pack/plugins/integration_assistant/server/graphs/cel/summarize_query.ts @@ -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 { StringOutputParser } from '@langchain/core/output_parsers'; +import { CelInputState } from '../../types'; +import { CEL_QUERY_SUMMARY_PROMPT } from './prompts'; +import { CelInputNodeParams } from './types'; + +export async function handleSummarizeQuery({ + state, + model, +}: CelInputNodeParams): Promise> { + const outputParser = new StringOutputParser(); + const celSummarizeGraph = CEL_QUERY_SUMMARY_PROMPT.pipe(model).pipe(outputParser); + + const apiQuerySummary = await celSummarizeGraph.invoke({ + data_stream_name: state.dataStreamName, + open_api_spec: state.apiDefinition, + }); + + return { + apiQuerySummary, + lastExecutedChain: 'summarizeQuery', + }; +} diff --git a/x-pack/plugins/integration_assistant/server/graphs/cel/types.ts b/x-pack/plugins/integration_assistant/server/graphs/cel/types.ts new file mode 100644 index 0000000000000..c0a855754a8b6 --- /dev/null +++ b/x-pack/plugins/integration_assistant/server/graphs/cel/types.ts @@ -0,0 +1,26 @@ +/* + * 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 { CelInputState, ChatModels } from '../../types'; + +export interface CelInputBaseNodeParams { + state: CelInputState; +} + +export interface CelInputNodeParams extends CelInputBaseNodeParams { + model: ChatModels; +} + +export interface CelInputGraphParams { + model: ChatModels; +} + +export interface CelInputStateDetails { + name: string; + default: string | number | boolean; + redact: boolean; +} diff --git a/x-pack/plugins/integration_assistant/server/graphs/cel/util.test.ts b/x-pack/plugins/integration_assistant/server/graphs/cel/util.test.ts new file mode 100644 index 0000000000000..6dfeea7952909 --- /dev/null +++ b/x-pack/plugins/integration_assistant/server/graphs/cel/util.test.ts @@ -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 { getRedactVariables, getStateVarsAndDefaultValues } from './util'; +import { + celStateDetailsMockedResponse, + celStateSettings, + celRedact, +} from '../../../__jest__/fixtures/cel'; + +describe('getCelInputDetails', () => { + beforeEach(async () => { + jest.clearAllMocks(); + }); + + it('getRedactVariables', () => { + const result = getRedactVariables(celStateDetailsMockedResponse); + expect(result).toStrictEqual(celRedact); + }); + + it('getStateVarsAndDefaultValues', () => { + const result = getStateVarsAndDefaultValues(celStateDetailsMockedResponse); + expect(result).toStrictEqual(celStateSettings); + }); +}); diff --git a/x-pack/plugins/integration_assistant/server/graphs/cel/util.ts b/x-pack/plugins/integration_assistant/server/graphs/cel/util.ts new file mode 100644 index 0000000000000..e149e55b47907 --- /dev/null +++ b/x-pack/plugins/integration_assistant/server/graphs/cel/util.ts @@ -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 { CelInputStateDetails } from './types'; + +/** + * Gets a list of variables that require redaction from agent logs for the CEL input. + */ +export function getRedactVariables(stateDetails: CelInputStateDetails[]): string[] { + const redact = [] as string[]; + for (const cVar of stateDetails) { + if (cVar.redact) { + redact.push(cVar.name); + } + } + return redact; +} + +/** + * Gets an object containing state variables and their corresponding default values. + */ +export function getStateVarsAndDefaultValues(stateDetails: CelInputStateDetails[]): object { + const defaultStateVarSettings: Record = {}; + for (const stateVar of stateDetails) { + defaultStateVarSettings[stateVar.name] = stateVar.default; + } + return defaultStateVarSettings; +} diff --git a/x-pack/plugins/integration_assistant/server/integration_builder/constants.ts b/x-pack/plugins/integration_assistant/server/integration_builder/constants.ts new file mode 100644 index 0000000000000..7c70cfae756d9 --- /dev/null +++ b/x-pack/plugins/integration_assistant/server/integration_builder/constants.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const DEFAULT_CEL_PROGRAM = `# // Fetch the agent's public IP every minute and note when the last request was made. +# // It does not use the Resource URL configuration value. +# bytes(get("https://api.ipify.org/?format=json").Body).as(body, { +# "events": [body.decode_json().with({ +# "last_requested_at": has(state.cursor) && has(state.cursor.last_requested_at) ? +# state.cursor.last_requested_at +# : +# now +# })], +# "cursor": {"last_requested_at": now} +# })`; diff --git a/x-pack/plugins/integration_assistant/server/integration_builder/data_stream.test.ts b/x-pack/plugins/integration_assistant/server/integration_builder/data_stream.test.ts index 0a269fa07a1c8..ba6959da00b2a 100644 --- a/x-pack/plugins/integration_assistant/server/integration_builder/data_stream.test.ts +++ b/x-pack/plugins/integration_assistant/server/integration_builder/data_stream.test.ts @@ -22,6 +22,10 @@ jest.mock('../util', () => ({ generateFields: jest.fn(), })); +beforeEach(async () => { + jest.clearAllMocks(); +}); + describe('createDataStream', () => { const packageName = 'package'; const dataStreamPath = 'path'; @@ -55,6 +59,22 @@ describe('createDataStream', () => { samplesFormat: { name: 'ndjson', multiline: false }, }; + const celDataStream: DataStream = { + name: firstDatastreamName, + title: 'Datastream_1', + description: 'Datastream_1 description', + inputTypes: ['cel'] as InputType[], + docs: firstDataStreamDocs, + rawSamples: [samples], + pipeline: firstDataStreamPipeline, + samplesFormat: { name: 'ndjson', multiline: false }, + celInput: { + program: 'line1\nline2', + stateSettings: { setting1: 100, setting2: '' }, + redactVars: ['setting2'], + }, + }; + it('Should create expected directories and files', async () => { createDataStream(packageName, dataStreamPath, firstDataStream); @@ -93,4 +113,23 @@ describe('createDataStream', () => { }); }); }); + + it('Should populate expected CEL fields', async () => { + createDataStream(packageName, dataStreamPath, celDataStream); + + const expectedMappedValues = { + data_stream_title: celDataStream.title, + data_stream_description: celDataStream.description, + package_name: packageName, + data_stream_name: firstDatastreamName, + multiline_ndjson: celDataStream.samplesFormat.multiline, + program: celDataStream.celInput?.program.split('\n'), + state: celDataStream.celInput?.stateSettings, + redact: celDataStream.celInput?.redactVars, + }; + + // // Manifest files + expect(createSync).toHaveBeenCalledWith(`${dataStreamPath}/manifest.yml`, undefined); + expect(render).toHaveBeenCalledWith(`cel_manifest.yml.njk`, expectedMappedValues); + }); }); diff --git a/x-pack/plugins/integration_assistant/server/integration_builder/data_stream.ts b/x-pack/plugins/integration_assistant/server/integration_builder/data_stream.ts index 6338474fae0ed..8dac7f03c8086 100644 --- a/x-pack/plugins/integration_assistant/server/integration_builder/data_stream.ts +++ b/x-pack/plugins/integration_assistant/server/integration_builder/data_stream.ts @@ -9,6 +9,7 @@ import nunjucks from 'nunjucks'; import { join as joinPath } from 'path'; import { safeLoad } from 'js-yaml'; import type { DataStream } from '../../common'; +import { DEFAULT_CEL_PROGRAM } from './constants'; import { copySync, createSync, ensureDirSync, listDirSync, readSync } from '../util'; import { Field } from '../util/samples'; @@ -30,13 +31,33 @@ export function createDataStream( const dataStreams: string[] = []; for (const inputType of dataStream.inputTypes) { - const mappedValues = { + let mappedValues = { data_stream_title: title, data_stream_description: description, package_name: packageName, data_stream_name: dataStreamName, multiline_ndjson: useMultilineNDJSON, - }; + } as object; + + if (inputType === 'cel') { + if (dataStream.celInput != null) { + // Map the generated CEL config items into the template + const cel = dataStream.celInput; + mappedValues = { + ...mappedValues, + // Ready the program for printing with correct indentation + program: cel.program.split('\n'), + state: cel.stateSettings, + redact: cel.redactVars, + }; + } else { + mappedValues = { + ...mappedValues, + program: DEFAULT_CEL_PROGRAM.split('\n'), + }; + } + } + const dataStreamManifest = nunjucks.render( `${inputType.replaceAll('-', '_')}_manifest.yml.njk`, mappedValues diff --git a/x-pack/plugins/integration_assistant/server/plugin.ts b/x-pack/plugins/integration_assistant/server/plugin.ts index 64989d23e7dd8..bf972f04a61a5 100644 --- a/x-pack/plugins/integration_assistant/server/plugin.ts +++ b/x-pack/plugins/integration_assistant/server/plugin.ts @@ -21,6 +21,8 @@ import type { IntegrationAssistantPluginStart, IntegrationAssistantPluginStartDependencies, } from './types'; +import { parseExperimentalConfigValue } from '../common/experimental_features'; +import { IntegrationAssistantConfigType } from './config'; export type IntegrationAssistantRouteHandlerContext = CustomRequestHandlerContext<{ integrationAssistant: { @@ -36,11 +38,13 @@ export class IntegrationAssistantPlugin implements Plugin { private readonly logger: Logger; + private readonly config: IntegrationAssistantConfigType; private isAvailable: boolean; private hasLicense: boolean; constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); + this.config = initializerContext.config.get(); this.isAvailable = true; this.hasLicense = false; } @@ -59,9 +63,11 @@ export class IntegrationAssistantPlugin logger: this.logger, })); const router = core.http.createRouter(); + const experimentalFeatures = parseExperimentalConfigValue(this.config.enableExperimental ?? []); + this.logger.debug('integrationAssistant api: Setup'); - registerRoutes(router); + registerRoutes(router, experimentalFeatures); return { setIsAvailable: (isAvailable: boolean) => { diff --git a/x-pack/plugins/integration_assistant/server/routes/cel_route.test.ts b/x-pack/plugins/integration_assistant/server/routes/cel_route.test.ts new file mode 100644 index 0000000000000..be435aa9866bb --- /dev/null +++ b/x-pack/plugins/integration_assistant/server/routes/cel_route.test.ts @@ -0,0 +1,75 @@ +/* + * 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 { serverMock } from '../__mocks__/mock_server'; +import { requestMock } from '../__mocks__/request'; +import { requestContextMock } from '../__mocks__/request_context'; +import { CEL_INPUT_GRAPH_PATH } from '../../common'; +import { registerCelInputRoutes } from './cel_routes'; + +const mockResult = jest.fn().mockResolvedValue({ + results: { + program: '', + stateSettings: {}, + redactVars: [], + }, +}); + +jest.mock('../graphs/cel', () => { + return { + getCelGraph: jest.fn().mockResolvedValue({ + invoke: () => mockResult(), + }), + }; +}); + +describe('registerCelInputRoute', () => { + let server: ReturnType; + let { context } = requestContextMock.createTools(); + + const req = requestMock.create({ + method: 'post', + path: CEL_INPUT_GRAPH_PATH, + body: { + connectorId: 'testConnector', + dataStreamName: 'testStream', + apiDefinition: 'testApiDefinitionFileContents', + }, + }); + + beforeEach(() => { + jest.clearAllMocks(); + server = serverMock.create(); + ({ context } = requestContextMock.createTools()); + registerCelInputRoutes(server.router); + }); + + it('Runs route and gets CelInputResponse', async () => { + const response = await server.inject(req, requestContextMock.convertContext(context)); + expect(response.body).toEqual({ + results: { program: '', stateSettings: {}, redactVars: [] }, + }); + expect(response.status).toEqual(200); + }); + + it('Runs route with badRequest', async () => { + mockResult.mockResolvedValueOnce({}); + const response = await server.inject(req, requestContextMock.convertContext(context)); + expect(response.status).toEqual(400); + }); + + describe('when the integration assistant is not available', () => { + beforeEach(() => { + context.integrationAssistant.isAvailable.mockReturnValue(false); + }); + + it('returns a 404', async () => { + const response = await server.inject(req, requestContextMock.convertContext(context)); + expect(response.status).toEqual(404); + }); + }); +}); diff --git a/x-pack/plugins/integration_assistant/server/routes/cel_routes.ts b/x-pack/plugins/integration_assistant/server/routes/cel_routes.ts new file mode 100644 index 0000000000000..ecf012a88cfe5 --- /dev/null +++ b/x-pack/plugins/integration_assistant/server/routes/cel_routes.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IKibanaResponse, IRouter } from '@kbn/core/server'; +import { getRequestAbortedSignal } from '@kbn/data-plugin/server'; +import { APMTracer } from '@kbn/langchain/server/tracers/apm'; +import { getLangSmithTracer } from '@kbn/langchain/server/tracers/langsmith'; +import { CEL_INPUT_GRAPH_PATH, CelInputRequestBody, CelInputResponse } from '../../common'; +import { ROUTE_HANDLER_TIMEOUT } from '../constants'; +import { getCelGraph } from '../graphs/cel'; +import type { IntegrationAssistantRouteHandlerContext } from '../plugin'; +import { getLLMClass, getLLMType } from '../util/llm'; +import { buildRouteValidationWithZod } from '../util/route_validation'; +import { withAvailability } from './with_availability'; +import { isErrorThatHandlesItsOwnResponse } from '../lib/errors'; + +export function registerCelInputRoutes(router: IRouter) { + router.versioned + .post({ + path: CEL_INPUT_GRAPH_PATH, + access: 'internal', + options: { + timeout: { + idleSocket: ROUTE_HANDLER_TIMEOUT, + }, + }, + }) + .addVersion( + { + version: '1', + validate: { + request: { + body: buildRouteValidationWithZod(CelInputRequestBody), + }, + }, + }, + withAvailability(async (context, req, res): Promise> => { + const { dataStreamName, apiDefinition, langSmithOptions } = req.body; + const { getStartServices, logger } = await context.integrationAssistant; + const [, { actions: actionsPlugin }] = await getStartServices(); + + try { + const actionsClient = await actionsPlugin.getActionsClientWithRequest(req); + const connector = await actionsClient.get({ id: req.body.connectorId }); + + const abortSignal = getRequestAbortedSignal(req.events.aborted$); + + const actionTypeId = connector.actionTypeId; + const llmType = getLLMType(actionTypeId); + const llmClass = getLLMClass(llmType); + + const model = new llmClass({ + actionsClient, + connectorId: connector.id, + logger, + llmType, + model: connector.config?.defaultModel, + temperature: 0.05, + maxTokens: 4096, + signal: abortSignal, + streaming: false, + }); + + const parameters = { + dataStreamName, + apiDefinition, + }; + + const options = { + callbacks: [ + new APMTracer({ projectName: langSmithOptions?.projectName ?? 'default' }, logger), + ...getLangSmithTracer({ ...langSmithOptions, logger }), + ], + }; + + const graph = await getCelGraph({ model }); + const results = await graph.invoke(parameters, options); + + return res.ok({ body: CelInputResponse.parse(results) }); + } catch (e) { + if (isErrorThatHandlesItsOwnResponse(e)) { + return e.sendResponse(res); + } + return res.badRequest({ body: e }); + } + }) + ); +} diff --git a/x-pack/plugins/integration_assistant/server/routes/register_routes.ts b/x-pack/plugins/integration_assistant/server/routes/register_routes.ts index 781010972ddcb..3f8795421a5c0 100644 --- a/x-pack/plugins/integration_assistant/server/routes/register_routes.ts +++ b/x-pack/plugins/integration_assistant/server/routes/register_routes.ts @@ -13,12 +13,21 @@ import { registerRelatedRoutes } from './related_routes'; import { registerPipelineRoutes } from './pipeline_routes'; import type { IntegrationAssistantRouteHandlerContext } from '../plugin'; import { registerAnalyzeLogsRoutes } from './analyze_logs_routes'; +import { registerCelInputRoutes } from './cel_routes'; +import { ExperimentalFeatures } from '../../common/experimental_features'; -export function registerRoutes(router: IRouter) { +export function registerRoutes( + router: IRouter, + experimentalFeatures: ExperimentalFeatures +) { registerAnalyzeLogsRoutes(router); registerEcsRoutes(router); registerIntegrationBuilderRoutes(router); registerCategorizationRoutes(router); registerRelatedRoutes(router); registerPipelineRoutes(router); + + if (experimentalFeatures.generateCel) { + registerCelInputRoutes(router); + } } diff --git a/x-pack/plugins/integration_assistant/server/templates/manifest/cel_manifest.yml.njk b/x-pack/plugins/integration_assistant/server/templates/manifest/cel_manifest.yml.njk index 04db3351691d4..025732305a59d 100644 --- a/x-pack/plugins/integration_assistant/server/templates/manifest/cel_manifest.yml.njk +++ b/x-pack/plugins/integration_assistant/server/templates/manifest/cel_manifest.yml.njk @@ -42,18 +42,8 @@ show_user: true multi: false required: true - default: | - # // Fetch the agent's public IP every minute and note when the last request was made. - # // It does not use the Resource URL configuration value. - # bytes(get("https://api.ipify.org/?format=json").Body).as(body, { - # "events": [body.decode_json().with({ - # "last_requested_at": has(state.cursor) && has(state.cursor.last_requested_at) ? - # state.cursor.last_requested_at - # : - # now - # })], - # "cursor": {"last_requested_at": now} - # }) + default: | {% for programLine in program %} + {{ programLine }}{% endfor %} - name: state type: yaml title: Initial CEL evaluation state @@ -62,7 +52,9 @@ More information can be found in the [documentation](https://www.elastic.co/guide/en/beats/filebeat/current/filebeat-input-cel.html#input-state-cel). show_user: true multi: false - required: false + required: false{% if state %} + default: | {% for key, value in state %} + {{ key }} : {% if value %}{{ value }}{% else %}""{% endif %}{% endfor %}{% endif %} - name: regexp type: yaml title: Defined Regular Expressions @@ -135,7 +127,9 @@ in logs. This may leak secrets, so list sensitive state fields in this configuration. show_user: true multi: true - required: false + required: false{% if redact.length > 0 %} + default: {% for redactVar in redact %} + - {{ redactVar }}{% endfor %}{% endif %} - name: delete_redacted_fields type: bool title: Delete redacted fields diff --git a/x-pack/plugins/integration_assistant/server/types.ts b/x-pack/plugins/integration_assistant/server/types.ts index 454370a02c366..3c17761c495b0 100644 --- a/x-pack/plugins/integration_assistant/server/types.ts +++ b/x-pack/plugins/integration_assistant/server/types.ts @@ -64,6 +64,20 @@ export interface CategorizationState { samplesFormat: SamplesFormat; } +export interface CelInputState { + dataStreamName: string; + apiDefinition: string; + lastExecutedChain: string; + finalized: boolean; + apiQuerySummary: string; + exampleCelPrograms: string[]; + currentProgram: string; + stateVarNames: string[]; + stateSettings: object; + redactVars: string[]; + results: object; +} + export interface EcsMappingState { ecs: string; chunkSize: number;