From f711a912af7110600991793e8ae3b310b01a31d4 Mon Sep 17 00:00:00 2001 From: Yan Savitski Date: Tue, 12 May 2026 16:30:00 +0200 Subject: [PATCH 01/20] Move ai.prompt, ai.summarize, ai.classify workflow steps to new inferenceWorkflows plugin Create a new `inferenceWorkflows` plugin at x-pack/platform/plugins/shared/inference_workflows/ that registers the three AI workflow steps via the external workflowsExtensions registration API. This moves inference-specific workflow step logic out of the generic workflows_extensions plugin and into a search-team owned plugin, following the same pattern as agent_builder. --- .../common/steps/ai/ai_classify_step.test.ts | 132 ++++ .../common/steps/ai/ai_classify_step.ts | 158 ++++ .../common/steps/ai/ai_prompt_step.ts | 144 ++++ .../common/steps/ai/ai_summarize_step.ts | 109 +++ .../common/steps/ai/index.ts | 35 + .../shared/inference_workflows/kibana.jsonc | 15 + .../inference_workflows/public/index.ts | 13 + .../inference_workflows/public/plugin.ts | 34 + .../public/steps/ai/ai_classify_step.ts | 35 + .../public/steps/ai/ai_prompt_step.ts | 49 ++ .../public/steps/ai/ai_summarize_step.ts | 29 + .../inference_workflows/server/index.ts | 13 + .../inference_workflows/server/plugin.ts | 27 + .../ai/ai_classify_step/build_prompts.test.ts | 156 ++++ .../ai/ai_classify_step/build_prompts.ts | 108 +++ .../steps/ai/ai_classify_step/step.test.ts | 672 ++++++++++++++++++ .../server/steps/ai/ai_classify_step/step.ts | 91 +++ .../validate_model_response.test.ts | 414 +++++++++++ .../validate_model_response.ts | 56 ++ .../ai/ai_prompt_step/ai_prompt_step.test.ts | 520 ++++++++++++++ .../server/steps/ai/ai_prompt_step/step.ts | 81 +++ .../ai_summarize_step.test.ts | 480 +++++++++++++ .../ai_summarize_step/build_prompts.test.ts | 336 +++++++++ .../ai/ai_summarize_step/build_prompts.ts | 89 +++ .../server/steps/ai/ai_summarize_step/step.ts | 66 ++ .../ai/utils/resolve_connector_id.test.ts | 216 ++++++ .../steps/ai/utils/resolve_connector_id.ts | 49 ++ .../inference_workflows/server/types.ts | 17 + .../shared/inference_workflows/tsconfig.json | 17 + 29 files changed, 4161 insertions(+) create mode 100644 x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_classify_step.test.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_classify_step.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_prompt_step.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_summarize_step.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/index.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/kibana.jsonc create mode 100644 x-pack/platform/plugins/shared/inference_workflows/public/index.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/public/plugin.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_classify_step.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_prompt_step.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_summarize_step.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/server/index.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/server/plugin.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/build_prompts.test.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/build_prompts.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/step.test.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/step.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/validate_model_response.test.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/validate_model_response.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_prompt_step/ai_prompt_step.test.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_prompt_step/step.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_summarize_step/ai_summarize_step.test.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_summarize_step/build_prompts.test.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_summarize_step/build_prompts.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_summarize_step/step.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/utils/resolve_connector_id.test.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/utils/resolve_connector_id.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/server/types.ts create mode 100644 x-pack/platform/plugins/shared/inference_workflows/tsconfig.json diff --git a/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_classify_step.test.ts b/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_classify_step.test.ts new file mode 100644 index 0000000000000..07ad1f982de9a --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_classify_step.test.ts @@ -0,0 +1,132 @@ +/* + * 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 { buildStructuredOutputSchema, ConfigSchema, InputSchema } from './ai_classify_step'; + +describe('ai_classify_step common', () => { + describe('schema definitions', () => { + it('ConfigSchema accepts optional connector-id', () => { + expect(ConfigSchema.safeParse({}).success).toBe(true); + expect(ConfigSchema.safeParse({ 'connector-id': 'abc' }).success).toBe(true); + }); + + it('InputSchema requires input and categories', () => { + expect(InputSchema.safeParse({}).success).toBe(false); + expect(InputSchema.safeParse({ input: 'text', categories: ['A', 'B'] }).success).toBe(true); + }); + + it('InputSchema rejects empty categories array', () => { + expect(InputSchema.safeParse({ input: 'text', categories: [] }).success).toBe(false); + }); + + it('InputSchema accepts all optional fields', () => { + const result = InputSchema.safeParse({ + input: 'text', + categories: ['A'], + instructions: 'focus on severity', + allowMultipleCategories: true, + fallbackCategory: 'Other', + includeRationale: true, + temperature: 0.5, + }); + expect(result.success).toBe(true); + }); + + it('InputSchema rejects temperature out of range', () => { + expect(InputSchema.safeParse({ input: 'x', categories: ['A'], temperature: 2 }).success).toBe( + false + ); + expect( + InputSchema.safeParse({ input: 'x', categories: ['A'], temperature: -0.1 }).success + ).toBe(false); + }); + + it('InputSchema accepts object and array inputs', () => { + expect(InputSchema.safeParse({ input: { key: 'val' }, categories: ['A'] }).success).toBe( + true + ); + expect(InputSchema.safeParse({ input: [1, 2, 3], categories: ['A'] }).success).toBe(true); + }); + }); + + describe('buildStructuredOutputSchema', () => { + it('returns schema with category field for single classification', () => { + const schema = buildStructuredOutputSchema({ + input: 'text', + categories: ['A', 'B'], + }); + + expect(schema.shape).toHaveProperty('category'); + expect(schema.shape).not.toHaveProperty('categories'); + expect(schema.shape).toHaveProperty('metadata'); + expect(schema.shape).not.toHaveProperty('rationale'); + }); + + it('returns schema with categories array for multi-label classification', () => { + const schema = buildStructuredOutputSchema({ + input: 'text', + categories: ['A', 'B'], + allowMultipleCategories: true, + }); + + expect(schema.shape).toHaveProperty('categories'); + expect(schema.shape).not.toHaveProperty('category'); + }); + + it('includes rationale field when includeRationale is true', () => { + const schema = buildStructuredOutputSchema({ + input: 'text', + categories: ['A'], + includeRationale: true, + }); + + expect(schema.shape).toHaveProperty('rationale'); + }); + + it('does not include rationale field when includeRationale is false or undefined', () => { + const withFalse = buildStructuredOutputSchema({ + input: 'text', + categories: ['A'], + includeRationale: false, + }); + expect(withFalse.shape).not.toHaveProperty('rationale'); + + const withUndefined = buildStructuredOutputSchema({ + input: 'text', + categories: ['A'], + }); + expect(withUndefined.shape).not.toHaveProperty('rationale'); + }); + + it('returns a valid Zod object schema that can parse data', () => { + const schema = buildStructuredOutputSchema({ + input: 'text', + categories: ['A', 'B'], + allowMultipleCategories: true, + includeRationale: true, + }); + + const result = schema.safeParse({ + categories: ['A'], + rationale: 'because', + metadata: { model: 'test' }, + }); + expect(result.success).toBe(true); + }); + + it('returned schema rejects data missing required fields', () => { + const schema = buildStructuredOutputSchema({ + input: 'text', + categories: ['A'], + }); + + // Missing category and metadata + const result = schema.safeParse({}); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_classify_step.ts b/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_classify_step.ts new file mode 100644 index 0000000000000..9e388a7075655 --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_classify_step.ts @@ -0,0 +1,158 @@ +/* + * 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'; +import { StepCategory } from '@kbn/workflows'; +import { z } from '@kbn/zod/v4'; +import type { CommonStepDefinition } from '@kbn/workflows-extensions/common'; + +export const AiClassifyStepTypeId = 'ai.classify'; + +export const ConfigSchema = z.object({ + 'connector-id': z.string().optional(), +}); + +export const InputSchema = z.object({ + input: z.union([z.string(), z.array(z.unknown()), z.record(z.string(), z.unknown())]), + categories: z.array(z.string()).min(1), + instructions: z.string().optional(), + allowMultipleCategories: z.boolean().optional(), + fallbackCategory: z.string().optional(), + includeRationale: z.boolean().optional(), + temperature: z.number().min(0).max(1).optional(), +}); + +export const OutputSchema = z.object({ + category: z.string().optional(), + categories: z.array(z.string()).optional(), + rationale: z.string().optional(), + metadata: z.record(z.string(), z.any()), +}); + +export type AiClassifyStepConfigSchema = typeof ConfigSchema; +export type AiClassifyStepInputSchema = typeof InputSchema; +export type AiClassifyStepOutputSchema = typeof OutputSchema; + +export const AiClassifyStepCommonDefinition: CommonStepDefinition< + AiClassifyStepInputSchema, + AiClassifyStepOutputSchema, + AiClassifyStepConfigSchema +> = { + id: AiClassifyStepTypeId, + category: StepCategory.Ai, + label: i18n.translate('xpack.inferenceWorkflows.AiClassifyStep.label', { + defaultMessage: 'AI Classify', + }), + description: i18n.translate('xpack.inferenceWorkflows.AiClassifyStep.description', { + defaultMessage: 'Categorizes data into predefined categories using AI', + }), + documentation: { + details: i18n.translate('xpack.inferenceWorkflows.AiClassifyStep.documentation.details', { + defaultMessage: `The ${AiClassifyStepTypeId} step categorizes input data into predefined categories using an AI connector. The classification result can be referenced in later steps using template syntax.`, + values: { templateSyntax: '`{{ steps.stepName.output }}`' }, + }), + examples: [ + `## Basic Classification +\`\`\`yaml +- name: classify_alert + type: ${AiClassifyStepTypeId} + with: + input: "{{ steps.fetch_alert.output }}" + categories: ["Critical", "Warning", "Info"] +\`\`\` +The default AI connector configured for the workflow will be used.`, + + `## Custom Instructions +\`\`\`yaml +- name: classify_incident + type: ${AiClassifyStepTypeId} + with: + input: "{{ steps.get_incident.output }}" + categories: ["Security", "Performance", "Network", "Application"] + instructions: "Focus on root cause type. Ignore transient issues." +\`\`\``, + + `## Fallback Category +\`\`\`yaml +- name: classify_log + type: ${AiClassifyStepTypeId} + with: + input: "{{ steps.get_log.output }}" + categories: ["Authentication", "Authorization", "Data Access"] + fallbackCategory: "Unknown" +\`\`\` +When the model cannot confidently match input to defined categories, the fallback category is used.`, + + `## Multi-label Classification with Rationale +\`\`\`yaml +- name: tag_alert + type: ${AiClassifyStepTypeId} + with: + input: "{{ steps.alert_details.output }}" + categories: ["High Priority", "Security", "Performance", "User Impacting"] + allowMultipleCategories: true + includeRationale: true + instructions: "Select all applicable tags" +\`\`\` +When \`allowMultipleCategories\` is true, the output includes a \`categories\` array. When \`includeRationale\` is true, the output includes a \`rationale\` field.`, + + `## Custom Connector with Temperature +\`\`\`yaml +- name: classify_ticket + type: ${AiClassifyStepTypeId} + connector-id: "custom-classifier-model" + with: + input: "{{ steps.ticket_description.output }}" + categories: ["Bug", "Feature Request", "Support"] + temperature: 0.1 + instructions: "Prefer 'Bug' if any technical issue mentioned" +\`\`\``, + + `## Use classification in subsequent steps +\`\`\`yaml +- name: classify_severity + type: ${AiClassifyStepTypeId} + with: + input: "{{ steps.get_incident_details.output }}" + categories: ["Critical", "High", "Medium", "Low"] + includeRationale: true +- name: notify_team + type: http + with: + url: "https://api.example.com/notify" + body: + severity: "{{ steps.classify_severity.output.category }}" + reason: "{{ steps.classify_severity.output.rationale }}" +\`\`\``, + ], + }, + inputSchema: InputSchema, + outputSchema: OutputSchema, + configSchema: ConfigSchema, +}; + +export function buildStructuredOutputSchema( + params: z.infer +): typeof OutputSchema { + const { allowMultipleCategories, includeRationale } = params; + + const shape: Record = { + metadata: z.record(z.string(), z.any()), + }; + + if (allowMultipleCategories) { + shape.categories = z.array(z.string()); + } else { + shape.category = z.string(); + } + + if (includeRationale) { + shape.rationale = z.string(); + } + + return z.object(shape) as typeof OutputSchema; +} diff --git a/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_prompt_step.ts b/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_prompt_step.ts new file mode 100644 index 0000000000000..44b847ad71f1f --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_prompt_step.ts @@ -0,0 +1,144 @@ +/* + * 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'; +import { StepCategory } from '@kbn/workflows'; +import { JsonModelShapeSchema } from '@kbn/workflows/spec/schema/common/json_model_shape_schema'; +import { z } from '@kbn/zod/v4'; +import type { CommonStepDefinition } from '@kbn/workflows-extensions/common'; + +export const AiPromptStepTypeId = 'ai.prompt'; + +export const ConfigSchema = z.object({ + 'connector-id': z.string().optional(), +}); + +export const MetadataSchema = z.record(z.string(), z.any()); + +export const InputSchema = z.object({ + prompt: z.string(), + systemPrompt: z.string().optional(), + schema: JsonModelShapeSchema.optional().describe('The schema for the output of the step.'), + temperature: z.number().min(0).max(1).optional(), +}); + +export function getStructuredOutputSchema(contentSchema: z.ZodType) { + return z.object({ + content: contentSchema, + metadata: MetadataSchema, + }); +} + +const StringOutputSchema = z.object({ + content: z.string(), + metadata: MetadataSchema, +}); + +export const OutputSchema = z.union([StringOutputSchema, getStructuredOutputSchema(z.unknown())]); + +export type AiPromptStepConfigSchema = typeof ConfigSchema; +export type AiPromptStepInputSchema = typeof InputSchema; +export type AiPromptStepOutputSchema = typeof OutputSchema; + +export const AiPromptStepCommonDefinition: CommonStepDefinition< + AiPromptStepInputSchema, + AiPromptStepOutputSchema, + AiPromptStepConfigSchema +> = { + id: AiPromptStepTypeId, + category: StepCategory.Ai, + label: i18n.translate('xpack.inferenceWorkflows.AiPromptStep.label', { + defaultMessage: 'AI Prompt', + }), + description: i18n.translate('xpack.inferenceWorkflows.AiPromptStep.description', { + defaultMessage: 'Sends a prompt to an AI connector and returns the response', + }), + documentation: { + details: i18n.translate('xpack.inferenceWorkflows.AiPromptStep.documentation.details', { + defaultMessage: `The ${AiPromptStepTypeId} step sends a prompt to an AI connector and returns the response. The response can be referenced in later steps using template syntax like {templateSyntax}.`, + values: { templateSyntax: '`{{ steps.stepName.output }}`' }, + }), + examples: [ + `## Basic AI prompt +\`\`\`yaml +- name: ask_ai + type: ${AiPromptStepTypeId} + with: + prompt: "What is the weather like today?" +\`\`\` +The default AI connector configured for the workflow will be used.`, + `## AI prompt with dynamic input +\`\`\`yaml +- name: analyze_data + type: ${AiPromptStepTypeId} + connector-id: ai_connector + with: + prompt: "Analyze this data: {{ steps.previous_step.output }}" +\`\`\``, + + `## AI prompt with structured output schema. +Output schema must be a valid JSON Schema object. +See this [JSON Schema reference](https://json-schema.org/learn/getting-started-step-by-step) for details. +\`\`\`yaml +- name: extract_info + type: ${AiPromptStepTypeId} + connector-id: my-ai-connector + with: + prompt: "Extract key information from this text: {{ workflow.input }}" + schema: + type: "object" + properties: + summary: + type: "string" + key_points: + type: "array" + items: + type: "string" +\`\`\``, + + `## AI prompt with structured output schema (JSON object syntax) +See this [JSON Schema reference](https://json-schema.org/learn/getting-started-step-by-step) for details. +\`\`\`yaml +- name: extract_info + type: ${AiPromptStepTypeId} + connector-id: my-ai-connector + with: + prompt: "Extract key information from this text: {{ workflow.input }}" + schema: { + "type":"object", + "properties":{ + "summary":{ + "type":"string" + }, + "key_points":{ + "type":"array", + "items":{ + "type":"string" + } + } + } +\`\`\``, + + `## Use AI response in subsequent steps +\`\`\`yaml +- name: get_recommendation + type: ${AiPromptStepTypeId} + connector-id: "my-ai-connector" + with: + prompt: "Provide a recommendation based on this data" +- name: process_recommendation + type: http + with: + url: "https://api.example.com/process" + body: "{{ steps.get_recommendation.output }}" +\`\`\``, + ], + }, + inputSchema: InputSchema, + outputSchema: OutputSchema, + configSchema: ConfigSchema, +}; diff --git a/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_summarize_step.ts b/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_summarize_step.ts new file mode 100644 index 0000000000000..1069f9d146d2e --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_summarize_step.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 { i18n } from '@kbn/i18n'; +import { StepCategory } from '@kbn/workflows'; +import { z } from '@kbn/zod/v4'; +import type { CommonStepDefinition } from '@kbn/workflows-extensions/common'; + +export const AiSummarizeStepTypeId = 'ai.summarize'; + +export const ConfigSchema = z.object({ + 'connector-id': z.string().optional(), +}); + +export const InputSchema = z.object({ + input: z.union([z.string(), z.array(z.unknown()), z.record(z.string(), z.unknown())]), + instructions: z.string().optional(), + maxLength: z.number().int().positive().optional(), + temperature: z.number().min(0).max(1).optional(), +}); + +export const OutputSchema = z.object({ + content: z.string(), + metadata: z.record(z.string(), z.any()).optional(), +}); + +export type AiSummarizeStepConfigSchema = typeof ConfigSchema; +export type AiSummarizeStepInputSchema = typeof InputSchema; +export type AiSummarizeStepOutputSchema = typeof OutputSchema; + +export const AiSummarizeStepCommonDefinition: CommonStepDefinition< + AiSummarizeStepInputSchema, + AiSummarizeStepOutputSchema, + AiSummarizeStepConfigSchema +> = { + id: AiSummarizeStepTypeId, + category: StepCategory.Ai, + label: i18n.translate('xpack.inferenceWorkflows.AiSummarizeStep.label', { + defaultMessage: 'AI Summarize', + }), + description: i18n.translate('xpack.inferenceWorkflows.AiSummarizeStep.description', { + defaultMessage: 'Generates a summary of the provided content using AI', + }), + documentation: { + details: i18n.translate('xpack.inferenceWorkflows.AiSummarizeStep.documentation.details', { + defaultMessage: `The ${AiSummarizeStepTypeId} step generates a concise summary of the provided content using an AI connector. The summary can be referenced in later steps using template syntax.`, + values: { templateSyntax: '`{{ steps.stepName.output }}`' }, + }), + examples: [ + `## Basic Summarization +\`\`\`yaml +- name: summarize_logs + type: ${AiSummarizeStepTypeId} + with: + input: "{{ steps.fetch_logs.output }}" +\`\`\` +The default AI connector configured for the workflow will be used.`, + + `## Data Summarization +\`\`\`yaml +- name: summarize_alerts + type: ${AiSummarizeStepTypeId} + with: + input: "{{ steps.fetch_alerts.output }}" +\`\`\` +Supports objects and arrays as input.`, + + `## Custom Instructions +\`\`\`yaml +- name: summarize_alerts + type: ${AiSummarizeStepTypeId} + with: + input: "{{ steps.get_alerts.output }}" + instructions: "Use bullet points. Focus on root cause. Limit to 3 key points." +\`\`\``, + + `## Length Control +\`\`\`yaml +- name: summarize_for_pagerduty + type: ${AiSummarizeStepTypeId} + with: + input: "{{ steps.error_details.output }}" + maxLength: 100 + instructions: "One sentence summary suitable for alert title" +\`\`\``, + + `## Use AI summary in subsequent steps +\`\`\`yaml +- name: summarize_incident + type: ${AiSummarizeStepTypeId} + with: + input: "{{ steps.get_incident_details.output }}" + instructions: "Concise summary for notification" +- name: send_notification + type: http + with: + url: "https://api.example.com/notify" + body: "{{ steps.summarize_incident.output.content }}" +\`\`\``, + ], + }, + inputSchema: InputSchema, + outputSchema: OutputSchema, + configSchema: ConfigSchema, +}; diff --git a/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/index.ts b/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/index.ts new file mode 100644 index 0000000000000..d5f38c2dace7f --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/index.ts @@ -0,0 +1,35 @@ +/* + * 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 { + AiPromptStepCommonDefinition, + AiPromptStepTypeId, + InputSchema as AiPromptInputSchema, + OutputSchema as AiPromptOutputSchema, + getStructuredOutputSchema, + type AiPromptStepConfigSchema, + type AiPromptStepInputSchema, + type AiPromptStepOutputSchema, +} from './ai_prompt_step'; + +export { + AiSummarizeStepCommonDefinition, + AiSummarizeStepTypeId, + type AiSummarizeStepConfigSchema, + type AiSummarizeStepInputSchema, + type AiSummarizeStepOutputSchema, +} from './ai_summarize_step'; + +export * from './ai_prompt_step'; +export { + AiClassifyStepCommonDefinition, + AiClassifyStepTypeId, + type AiClassifyStepConfigSchema, + type AiClassifyStepInputSchema, + type AiClassifyStepOutputSchema, + buildStructuredOutputSchema, +} from './ai_classify_step'; diff --git a/x-pack/platform/plugins/shared/inference_workflows/kibana.jsonc b/x-pack/platform/plugins/shared/inference_workflows/kibana.jsonc new file mode 100644 index 0000000000000..4262c058f722d --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/kibana.jsonc @@ -0,0 +1,15 @@ +{ + "type": "plugin", + "id": "@kbn/inference-workflows-plugin", + "owner": ["@elastic/search-kibana"], + "group": "platform", + "visibility": "shared", + "description": "Registers AI workflow steps (ai.prompt, ai.summarize, ai.classify) that bridge the inference and workflows_extensions plugins.", + "plugin": { + "id": "inferenceWorkflows", + "server": true, + "browser": true, + "configPath": ["xpack", "inferenceWorkflows"], + "requiredPlugins": ["inference", "workflowsExtensions"] + } +} diff --git a/x-pack/platform/plugins/shared/inference_workflows/public/index.ts b/x-pack/platform/plugins/shared/inference_workflows/public/index.ts new file mode 100644 index 0000000000000..ea60ca09aed3e --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/public/index.ts @@ -0,0 +1,13 @@ +/* + * 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 { PluginInitializerContext } from '@kbn/core/public'; +import { InferenceWorkflowsPublicPlugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new InferenceWorkflowsPublicPlugin(); +} diff --git a/x-pack/platform/plugins/shared/inference_workflows/public/plugin.ts b/x-pack/platform/plugins/shared/inference_workflows/public/plugin.ts new file mode 100644 index 0000000000000..284fb0b04c850 --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/public/plugin.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 type { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; +import type { WorkflowsExtensionsPublicPluginSetup } from '@kbn/workflows-extensions/public'; + +interface InferenceWorkflowsPublicSetupDeps { + workflowsExtensions: WorkflowsExtensionsPublicPluginSetup; +} + +export class InferenceWorkflowsPublicPlugin + implements Plugin<{}, {}, InferenceWorkflowsPublicSetupDeps> +{ + setup(_core: CoreSetup, deps: InferenceWorkflowsPublicSetupDeps) { + deps.workflowsExtensions.registerStepDefinition(() => + import('./steps/ai/ai_prompt_step').then((m) => m.AiPromptStepDefinition) + ); + deps.workflowsExtensions.registerStepDefinition(() => + import('./steps/ai/ai_summarize_step').then((m) => m.AiSummarizeStepDefinition) + ); + deps.workflowsExtensions.registerStepDefinition(() => + import('./steps/ai/ai_classify_step').then((m) => m.AiClassifyStepDefinition) + ); + return {}; + } + + start(_core: CoreStart) { + return {}; + } +} diff --git a/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_classify_step.ts b/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_classify_step.ts new file mode 100644 index 0000000000000..3e6f761fcaf3b --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_classify_step.ts @@ -0,0 +1,35 @@ +/* + * 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 { createPublicStepDefinition } from '@kbn/workflows-extensions/public'; +import { + AiClassifyStepCommonDefinition, + buildStructuredOutputSchema, +} from '../../../common/steps/ai'; + +export const AiClassifyStepDefinition = createPublicStepDefinition({ + ...AiClassifyStepCommonDefinition, + icon: React.lazy(() => + import('@elastic/eui/es/components/icon/assets/product_agent').then(({ icon }) => ({ + default: icon, + })) + ), + editorHandlers: { + config: { + 'connector-id': { + connectorIdSelection: { + connectorTypes: ['inference.unified_completion', 'bedrock', 'gen-ai', 'gemini'], + enableCreation: false, + }, + }, + }, + dynamicSchema: { + getOutputSchema: ({ input }) => buildStructuredOutputSchema(input), + }, + }, +}); diff --git a/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_prompt_step.ts b/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_prompt_step.ts new file mode 100644 index 0000000000000..6155140c85d6d --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_prompt_step.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { fromJSONSchema } from '@kbn/zod/v4/from_json_schema'; +import { createPublicStepDefinition } from '@kbn/workflows-extensions/public'; +import { + AiPromptOutputSchema, + AiPromptStepCommonDefinition, + getStructuredOutputSchema, +} from '../../../common/steps/ai'; + +export const AiPromptStepDefinition = createPublicStepDefinition({ + ...AiPromptStepCommonDefinition, + icon: React.lazy(() => + import('@elastic/eui/es/components/icon/assets/product_agent').then(({ icon }) => ({ + default: icon, + })) + ), + editorHandlers: { + config: { + 'connector-id': { + connectorIdSelection: { + connectorTypes: ['inference.unified_completion', 'bedrock', 'gen-ai', 'gemini'], + enableCreation: false, + }, + }, + }, + dynamicSchema: { + getOutputSchema: ({ input }) => { + if (!input.schema) { + return AiPromptOutputSchema; + } + + const zodSchema = fromJSONSchema(input.schema as Record); + + if (!zodSchema) { + return AiPromptOutputSchema; + } + + return getStructuredOutputSchema(zodSchema); + }, + }, + }, +}); diff --git a/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_summarize_step.ts b/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_summarize_step.ts new file mode 100644 index 0000000000000..0ceb46977b297 --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_summarize_step.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 React from 'react'; +import { createPublicStepDefinition } from '@kbn/workflows-extensions/public'; +import { AiSummarizeStepCommonDefinition } from '../../../common/steps/ai'; + +export const AiSummarizeStepDefinition = createPublicStepDefinition({ + ...AiSummarizeStepCommonDefinition, + icon: React.lazy(() => + import('@elastic/eui/es/components/icon/assets/product_agent').then(({ icon }) => ({ + default: icon, + })) + ), + editorHandlers: { + config: { + 'connector-id': { + connectorIdSelection: { + connectorTypes: ['inference.unified_completion', 'bedrock', 'gen-ai', 'gemini'], + enableCreation: false, + }, + }, + }, + }, +}); diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/index.ts b/x-pack/platform/plugins/shared/inference_workflows/server/index.ts new file mode 100644 index 0000000000000..0d6235e175f0f --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/server/index.ts @@ -0,0 +1,13 @@ +/* + * 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 { PluginInitializerContext } from '@kbn/core/server'; + +export async function plugin(initializerContext: PluginInitializerContext) { + const { InferenceWorkflowsPlugin } = await import('./plugin'); + return new InferenceWorkflowsPlugin(); +} diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/plugin.ts b/x-pack/platform/plugins/shared/inference_workflows/server/plugin.ts new file mode 100644 index 0000000000000..9595bbdf5f88a --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/server/plugin.ts @@ -0,0 +1,27 @@ +/* + * 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 { CoreSetup, CoreStart, Plugin } from '@kbn/core/server'; +import type { InferenceWorkflowsSetupDeps, InferenceWorkflowsStartDeps } from './types'; +import { aiPromptStepDefinition } from './steps/ai/ai_prompt_step/step'; +import { aiSummarizeStepDefinition } from './steps/ai/ai_summarize_step/step'; +import { aiClassifyStepDefinition } from './steps/ai/ai_classify_step/step'; + +export class InferenceWorkflowsPlugin + implements Plugin<{}, {}, InferenceWorkflowsSetupDeps, InferenceWorkflowsStartDeps> +{ + setup(core: CoreSetup, deps: InferenceWorkflowsSetupDeps) { + deps.workflowsExtensions.registerStepDefinition(aiPromptStepDefinition(core)); + deps.workflowsExtensions.registerStepDefinition(aiSummarizeStepDefinition(core)); + deps.workflowsExtensions.registerStepDefinition(aiClassifyStepDefinition(core)); + return {}; + } + + start(_core: CoreStart) { + return {}; + } +} diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/build_prompts.test.ts b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/build_prompts.test.ts new file mode 100644 index 0000000000000..b76c786f87b56 --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/build_prompts.test.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 { + buildClassificationRequestPart, + buildDataPart, + buildInstructionsPart, + buildSystemPart, +} from './build_prompts'; + +describe('build_prompts', () => { + describe('buildSystemPart', () => { + it('returns a system-role message with classification rules', () => { + const parts = buildSystemPart(); + expect(parts).toHaveLength(1); + expect(parts[0].role).toBe('system'); + expect(parts[0].content).toContain('Output ONLY valid JSON'); + expect(parts[0].content).toContain('Categories are case-sensitive'); + }); + }); + + describe('buildDataPart', () => { + it('wraps object input as json', () => { + const parts = buildDataPart({ foo: 'bar' }); + expect(parts).toHaveLength(1); + expect(parts[0].role).toBe('user'); + expect(parts[0].content).toContain('```json'); + expect(parts[0].content).toContain(JSON.stringify({ foo: 'bar' })); + }); + + it('wraps string input as text', () => { + const parts = buildDataPart('hello world'); + expect(parts).toHaveLength(1); + expect(parts[0].content).toContain('```text'); + expect(parts[0].content).toContain('hello world'); + }); + + it('handles null input (typeof null === "object") by JSON stringifying it', () => { + const parts = buildDataPart(null); + expect(parts[0].content).toContain('```json'); + expect(parts[0].content).toContain('null'); + }); + + it('handles array input via the json path', () => { + const parts = buildDataPart([1, 2, 3]); + expect(parts[0].content).toContain('```json'); + expect(parts[0].content).toContain('[1,2,3]'); + }); + + it('handles number input via the text path', () => { + const parts = buildDataPart(42); + expect(parts[0].content).toContain('```text'); + expect(parts[0].content).toContain('42'); + }); + + it('handles boolean input via the text path', () => { + const parts = buildDataPart(true); + expect(parts[0].content).toContain('```text'); + expect(parts[0].content).toContain('true'); + }); + }); + + describe('buildInstructionsPart', () => { + it('returns empty array for undefined instructions', () => { + expect(buildInstructionsPart(undefined)).toEqual([]); + }); + + it('returns empty array for empty string instructions', () => { + expect(buildInstructionsPart('')).toEqual([]); + }); + + it('returns a user-role message containing the instructions', () => { + const parts = buildInstructionsPart('focus on severity'); + expect(parts).toHaveLength(1); + expect(parts[0].role).toBe('user'); + expect(parts[0].content).toContain('focus on severity'); + }); + }); + + describe('buildClassificationRequestPart', () => { + it('renders all categories as list items', () => { + const parts = buildClassificationRequestPart({ + categories: ['Urgent', 'Normal', 'Low'], + allowMultipleCategories: false, + includeRationale: false, + }); + expect(parts[0].content).toContain('- Urgent'); + expect(parts[0].content).toContain('- Normal'); + expect(parts[0].content).toContain('- Low'); + }); + + it('includes fallback category rule when provided', () => { + const parts = buildClassificationRequestPart({ + categories: ['A', 'B'], + allowMultipleCategories: false, + fallbackCategory: 'Other', + includeRationale: false, + }); + expect(parts[0].content).toContain('fallback category: "Other"'); + }); + + it('includes single-category rules when allowMultipleCategories is false', () => { + const parts = buildClassificationRequestPart({ + categories: ['A'], + allowMultipleCategories: false, + includeRationale: false, + }); + expect(parts[0].content).toContain('exactly ONE category'); + expect(parts[0].content).not.toContain('multiple categories'); + }); + + it('includes multi-category rules when allowMultipleCategories is true', () => { + const parts = buildClassificationRequestPart({ + categories: ['A', 'B'], + allowMultipleCategories: true, + includeRationale: false, + }); + expect(parts[0].content).toContain('multiple categories'); + expect(parts[0].content).not.toContain('exactly ONE'); + }); + + it('includes rationale rule when includeRationale is true', () => { + const parts = buildClassificationRequestPart({ + categories: ['A'], + allowMultipleCategories: false, + includeRationale: true, + }); + expect(parts[0].content).toContain('"rationale" field'); + }); + + it('does not include rationale rule when includeRationale is false', () => { + const parts = buildClassificationRequestPart({ + categories: ['A'], + allowMultipleCategories: false, + includeRationale: false, + }); + expect(parts[0].content).not.toContain('rationale'); + }); + + it('handles category names containing special characters', () => { + const categories = ['Category "A"', 'Category\nB', 'Category\\C']; + const parts = buildClassificationRequestPart({ + categories, + allowMultipleCategories: false, + includeRationale: false, + }); + categories.forEach((cat) => { + expect(parts[0].content).toContain(`- ${cat}`); + }); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/build_prompts.ts b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/build_prompts.ts new file mode 100644 index 0000000000000..e80ad216eccd6 --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/build_prompts.ts @@ -0,0 +1,108 @@ +/* + * 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 { MessageFieldWithRole } from '@langchain/core/messages'; + +export function buildSystemPart(): MessageFieldWithRole[] { + return [ + { + role: 'system', + content: ` +You are a specialized classification engine that categorizes data into predefined categories. + +# CRITICAL RULES: +- Output ONLY valid JSON matching the exact format specified +- Your response must be PLAIN JSON with NO formatting, NO code blocks, NO markdown +- DO NOT wrap your response in \`\`\`json or \`\`\` blocks +- DO NOT include any text before or after the JSON +- The output must be a raw JSON string that can be parsed directly +- Categories are case-sensitive and must match exactly as provided +- Only use categories from the available categories list +`.trim(), + }, + ]; +} + +export function buildDataPart(input: unknown): MessageFieldWithRole[] { + const inputType = typeof input === 'object' ? 'json' : 'text'; + let resolvedInput = input; + + if (inputType === 'json') { + resolvedInput = JSON.stringify(input); + } + + return [ + { + role: 'user', + content: `# DATA TO CLASSIFY: +\`\`\`${inputType} +${resolvedInput} +\`\`\` +`.trim(), + }, + ]; +} + +export function buildInstructionsPart(instructions: string | undefined): MessageFieldWithRole[] { + if (!instructions) { + return []; + } + + return [ + { + role: 'user', + content: `# ADDITIONAL CLASSIFICATION INSTRUCTIONS: +${instructions} +`, + }, + ]; +} + +export function buildClassificationRequestPart(params: { + categories: string[]; + allowMultipleCategories: boolean; + fallbackCategory?: string; + includeRationale: boolean; +}): MessageFieldWithRole[] { + const { categories, allowMultipleCategories, fallbackCategory, includeRationale } = params; + + const classificationRules: string[] = []; + + if (fallbackCategory) { + classificationRules.push( + `If the input does not clearly match any defined category, use the fallback category: "${fallbackCategory}"` + ); + } + + if (allowMultipleCategories) { + classificationRules.push( + 'You may select multiple categories if the input matches more than one category' + ); + classificationRules.push('Return all matching categories in the "categories" array'); + } else { + classificationRules.push('You must select exactly ONE category from the provided list'); + classificationRules.push('Return the selected category in the "category" field'); + } + + if (includeRationale) { + classificationRules.push( + 'You MUST provide a clear, concise explanation in the "rationale" field explaining why you chose this category/categories' + ); + } + return [ + { + role: 'user', + content: ` +AVAILABLE CATEGORIES: +${categories.map((cat) => `- ${cat}`).join('\n')} + +CLASSIFICATION RULES: +${classificationRules.map((rule) => rule.trim()).join('\n- ')} +`.trim(), + }, + ]; +} diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/step.test.ts b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/step.test.ts new file mode 100644 index 0000000000000..7f915d970ef0f --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/step.test.ts @@ -0,0 +1,672 @@ +/* + * 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 { CoreSetup, KibanaRequest } from '@kbn/core/server'; +import type { InferenceServerStart } from '@kbn/inference-plugin/server'; + +jest.mock('./build_prompts', () => ({ + buildSystemPart: jest.fn(), + buildDataPart: jest.fn(), + buildInstructionsPart: jest.fn(), + buildClassificationRequestPart: jest.fn(), +})); + +jest.mock('./validate_model_response', () => ({ + validateModelResponse: jest.fn(), +})); + +jest.mock('../../../../common/steps/ai', () => ({ + AiClassifyStepCommonDefinition: { + id: 'ai.classify', + inputSchema: {}, + outputSchema: {}, + configSchema: {}, + }, + buildStructuredOutputSchema: jest.fn(), +})); + +jest.mock('@kbn/workflows-extensions/server', () => ({ + createServerStepDefinition: jest.fn((definition) => definition), +})); + +jest.mock('../utils/resolve_connector_id', () => ({ + resolveConnectorId: jest.fn(), +})); + +import { + buildClassificationRequestPart, + buildDataPart, + buildInstructionsPart, + buildSystemPart, +} from './build_prompts'; +import { aiClassifyStepDefinition } from './step'; +import { validateModelResponse } from './validate_model_response'; +import { buildStructuredOutputSchema } from '../../../../common/steps/ai'; +import type { ContextManager, StepHandlerContext } from '@kbn/workflows-extensions/server'; +import { createServerStepDefinition } from '@kbn/workflows-extensions/server'; +import type { InferenceWorkflowsStartDeps } from '../../../types'; +import { resolveConnectorId } from '../utils/resolve_connector_id'; + +const mockBuildSystemPart = buildSystemPart as jest.MockedFunction; +const mockBuildDataPart = buildDataPart as jest.MockedFunction; +const mockBuildInstructionsPart = buildInstructionsPart as jest.MockedFunction< + typeof buildInstructionsPart +>; +const mockBuildClassificationRequestPart = buildClassificationRequestPart as jest.MockedFunction< + typeof buildClassificationRequestPart +>; +const mockValidateModelResponse = validateModelResponse as jest.MockedFunction< + typeof validateModelResponse +>; +const mockBuildStructuredOutputSchema = buildStructuredOutputSchema as jest.MockedFunction< + typeof buildStructuredOutputSchema +>; +const mockCreateServerStepDefinition = createServerStepDefinition as jest.MockedFunction< + typeof createServerStepDefinition +>; +const mockResolveConnectorId = resolveConnectorId as jest.MockedFunction; + +describe('aiClassifyStepDefinition', () => { + let mockCoreSetup: jest.Mocked>; + let mockInference: jest.Mocked; + let mockContextManager: jest.Mocked; + let mockContext: StepHandlerContext; + let mockChatModel: any; + let mockRunnable: any; + let mockAbortController: AbortController; + let mockSchema: any; + + beforeEach(() => { + jest.clearAllMocks(); + + mockAbortController = new AbortController(); + + mockSchema = { + parse: jest.fn(), + safeParse: jest.fn(), + }; + + mockRunnable = { + invoke: jest.fn().mockResolvedValue({ + parsed: { + category: 'test-category', + metadata: {}, + }, + raw: { + response_metadata: { + model: 'test-model', + finish_reason: 'stop', + }, + }, + }), + }; + + mockChatModel = { + invoke: jest.fn(), + withStructuredOutput: jest.fn().mockReturnValue(mockRunnable), + }; + + mockInference = { + getChatModel: jest.fn().mockResolvedValue(mockChatModel), + } as any; + + mockCoreSetup = { + getStartServices: jest.fn().mockResolvedValue([{}, { inference: mockInference }, {}]), + } as any; + + mockContextManager = { + getFakeRequest: jest.fn().mockReturnValue({} as KibanaRequest), + getContext: jest.fn(), + getScopedEsClient: jest.fn(), + renderInputTemplate: jest.fn(), + }; + + mockContext = { + config: { + 'connector-id': 'test-connector-id', + }, + input: { + input: 'Test input data', + categories: ['category1', 'category2'], + instructions: 'Test instructions', + allowMultipleCategories: false, + fallbackCategory: undefined, + includeRationale: false, + temperature: 0.7, + }, + rawInput: { + input: 'Test input data', + categories: ['category1', 'category2'], + instructions: 'Test instructions', + allowMultipleCategories: false, + fallbackCategory: undefined, + includeRationale: false, + temperature: 0.7, + }, + contextManager: mockContextManager, + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + abortSignal: mockAbortController.signal, + stepId: 'test-step-id', + stepType: 'ai.classify', + } as any; + + mockResolveConnectorId.mockResolvedValue('resolved-connector-id'); + mockBuildStructuredOutputSchema.mockReturnValue(mockSchema as any); + mockBuildSystemPart.mockReturnValue([{ role: 'system', content: 'System prompt' }]); + mockBuildDataPart.mockReturnValue([{ role: 'user', content: 'Data part' }]); + mockBuildInstructionsPart.mockReturnValue([{ role: 'user', content: 'Instructions part' }]); + mockBuildClassificationRequestPart.mockReturnValue([ + { role: 'user', content: 'Classification request' }, + ]); + mockValidateModelResponse.mockImplementation(() => {}); + }); + + describe('step definition creation', () => { + it('should create step definition with correct structure', () => { + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + + expect(mockCreateServerStepDefinition).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'ai.classify', + handler: expect.any(Function), + }) + ); + expect(stepDefinition).toBeDefined(); + expect(stepDefinition.handler).toBeDefined(); + }); + }); + + describe('handler execution', () => { + it('should successfully classify data with single category', async () => { + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + const result = await stepDefinition.handler(mockContext); + + expect(mockCoreSetup.getStartServices).toHaveBeenCalled(); + expect(mockResolveConnectorId).toHaveBeenCalledWith( + 'test-connector-id', + mockInference, + expect.any(Object) + ); + expect(mockInference.getChatModel).toHaveBeenCalledWith({ + connectorId: 'resolved-connector-id', + request: expect.any(Object), + chatModelOptions: { + temperature: 0.7, + maxRetries: 0, + }, + }); + expect(result).toEqual({ + output: { + category: 'test-category', + metadata: { + model: 'test-model', + finish_reason: 'stop', + }, + }, + }); + }); + + it('should successfully classify data with multiple categories', async () => { + mockContext.input.allowMultipleCategories = true; + mockRunnable.invoke.mockResolvedValueOnce({ + parsed: { + categories: ['category1', 'category2'], + metadata: {}, + }, + raw: { + response_metadata: { + model: 'test-model', + }, + }, + }); + + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + const result = await stepDefinition.handler(mockContext); + + expect(result.output).toEqual({ + categories: ['category1', 'category2'], + metadata: { + model: 'test-model', + }, + }); + }); + + it('should include rationale when requested', async () => { + mockContext.input.includeRationale = true; + mockRunnable.invoke.mockResolvedValueOnce({ + parsed: { + category: 'category1', + rationale: 'This is the rationale', + metadata: {}, + }, + raw: { + response_metadata: { + model: 'test-model', + }, + }, + }); + + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + const result = await stepDefinition.handler(mockContext); + + expect(result.output).toEqual({ + category: 'category1', + rationale: 'This is the rationale', + metadata: { + model: 'test-model', + }, + }); + }); + + it('should use fallback category when provided', async () => { + mockContext.input.fallbackCategory = 'fallback'; + mockRunnable.invoke.mockResolvedValueOnce({ + parsed: { + category: 'fallback', + metadata: {}, + }, + raw: { + response_metadata: { + model: 'test-model', + }, + }, + }); + + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + const result = await stepDefinition.handler(mockContext); + + expect(result).toBeDefined(); + expect(result.output).toBeDefined(); + expect(result.output!.category).toBe('fallback'); + expect(mockBuildClassificationRequestPart).toHaveBeenCalledWith( + expect.objectContaining({ + fallbackCategory: 'fallback', + }) + ); + }); + + it('should handle custom temperature setting', async () => { + mockContext.input.temperature = 0.3; + + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + await stepDefinition.handler(mockContext); + + expect(mockInference.getChatModel).toHaveBeenCalledWith( + expect.objectContaining({ + chatModelOptions: expect.objectContaining({ + temperature: 0.3, + }), + }) + ); + }); + + it('should handle undefined temperature', async () => { + mockContext.input.temperature = undefined; + + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + await stepDefinition.handler(mockContext); + + expect(mockInference.getChatModel).toHaveBeenCalledWith( + expect.objectContaining({ + chatModelOptions: expect.objectContaining({ + temperature: undefined, + }), + }) + ); + }); + }); + + describe('prompt building', () => { + it('should build correct prompt structure', async () => { + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + await stepDefinition.handler(mockContext); + + expect(mockBuildSystemPart).toHaveBeenCalled(); + expect(mockBuildDataPart).toHaveBeenCalledWith('Test input data'); + expect(mockBuildClassificationRequestPart).toHaveBeenCalledWith({ + categories: ['category1', 'category2'], + allowMultipleCategories: false, + fallbackCategory: undefined, + includeRationale: false, + }); + expect(mockBuildInstructionsPart).toHaveBeenCalledWith('Test instructions'); + }); + + it('should handle different input types', async () => { + mockContext.input.input = { key: 'value' }; + + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + await stepDefinition.handler(mockContext); + + expect(mockBuildDataPart).toHaveBeenCalledWith({ key: 'value' }); + }); + + it('should handle array input', async () => { + mockContext.input.input = ['item1', 'item2']; + + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + await stepDefinition.handler(mockContext); + + expect(mockBuildDataPart).toHaveBeenCalledWith(['item1', 'item2']); + }); + + it('should handle undefined instructions', async () => { + mockContext.input.instructions = undefined; + + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + await stepDefinition.handler(mockContext); + + expect(mockBuildInstructionsPart).toHaveBeenCalledWith(undefined); + }); + }); + + describe('schema and validation', () => { + it('should build structured output schema from input', async () => { + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + await stepDefinition.handler(mockContext); + + expect(mockBuildStructuredOutputSchema).toHaveBeenCalledWith(mockContext.input); + }); + + it('should pass Zod schema directly to withStructuredOutput', async () => { + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + await stepDefinition.handler(mockContext); + + expect(mockChatModel.withStructuredOutput).toHaveBeenCalledWith(mockSchema, { + name: 'classify', + includeRaw: true, + method: 'json', + }); + }); + + it('should validate model response', async () => { + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + await stepDefinition.handler(mockContext); + + expect(mockValidateModelResponse).toHaveBeenCalledWith({ + modelResponse: { + category: 'test-category', + metadata: {}, + }, + expectedCategories: ['category1', 'category2'], + fallbackCategory: undefined, + responseMetadata: { + model: 'test-model', + finish_reason: 'stop', + }, + }); + }); + + it('should propagate validation errors', async () => { + mockValidateModelResponse.mockImplementationOnce(() => { + throw new Error('Validation failed'); + }); + + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + + await expect(stepDefinition.handler(mockContext)).rejects.toThrow('Validation failed'); + }); + }); + + describe('model invocation', () => { + it('should invoke model with correct parameters', async () => { + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + await stepDefinition.handler(mockContext); + + expect(mockRunnable.invoke).toHaveBeenCalledWith( + [ + { role: 'system', content: 'System prompt' }, + { role: 'user', content: 'Data part' }, + { role: 'user', content: 'Classification request' }, + { role: 'user', content: 'Instructions part' }, + ], + { signal: mockAbortController.signal } + ); + }); + + it('should respect abort signal', async () => { + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + await stepDefinition.handler(mockContext); + + expect(mockRunnable.invoke).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + signal: mockAbortController.signal, + }) + ); + }); + + it('should handle model invocation errors', async () => { + mockRunnable.invoke.mockRejectedValueOnce(new Error('Model invocation failed')); + + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + + await expect(stepDefinition.handler(mockContext)).rejects.toThrow('Model invocation failed'); + }); + + it('should set maxRetries to 0', async () => { + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + await stepDefinition.handler(mockContext); + + expect(mockInference.getChatModel).toHaveBeenCalledWith( + expect.objectContaining({ + chatModelOptions: expect.objectContaining({ + maxRetries: 0, + }), + }) + ); + }); + }); + + describe('connector resolution', () => { + it('should resolve connector id from config', async () => { + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + await stepDefinition.handler(mockContext); + + expect(mockResolveConnectorId).toHaveBeenCalledWith( + 'test-connector-id', + mockInference, + expect.any(Object) + ); + }); + + it('should handle undefined connector id in config', async () => { + mockContext.config['connector-id'] = undefined; + + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + await stepDefinition.handler(mockContext); + + expect(mockResolveConnectorId).toHaveBeenCalledWith( + undefined, + mockInference, + expect.any(Object) + ); + }); + + it('should use resolved connector id for chat model', async () => { + mockResolveConnectorId.mockResolvedValueOnce('custom-resolved-id'); + + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + await stepDefinition.handler(mockContext); + + expect(mockInference.getChatModel).toHaveBeenCalledWith( + expect.objectContaining({ + connectorId: 'custom-resolved-id', + }) + ); + }); + + it('should handle connector resolution errors', async () => { + mockResolveConnectorId.mockRejectedValueOnce(new Error('Connector not found')); + + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + + await expect(stepDefinition.handler(mockContext)).rejects.toThrow('Connector not found'); + }); + }); + + describe('output formatting', () => { + it('should merge parsed response with metadata', async () => { + mockRunnable.invoke.mockResolvedValueOnce({ + parsed: { + category: 'category1', + rationale: 'Test rationale', + }, + raw: { + response_metadata: { + model: 'gpt-4', + tokens: 100, + custom: 'value', + }, + }, + }); + + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + const result = await stepDefinition.handler(mockContext); + + expect(result.output).toEqual({ + category: 'category1', + rationale: 'Test rationale', + metadata: { + model: 'gpt-4', + tokens: 100, + custom: 'value', + }, + }); + }); + + it('should handle empty response metadata', async () => { + mockRunnable.invoke.mockResolvedValueOnce({ + parsed: { + category: 'category1', + }, + raw: { + response_metadata: {}, + }, + }); + + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + const result = await stepDefinition.handler(mockContext); + + expect(result.output).toEqual({ + category: 'category1', + metadata: {}, + }); + }); + + it('should preserve all parsed fields in output', async () => { + mockRunnable.invoke.mockResolvedValueOnce({ + parsed: { + category: 'category1', + rationale: 'Test rationale', + confidence: 0.95, + }, + raw: { + response_metadata: { + model: 'test-model', + }, + }, + }); + + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + const result = await stepDefinition.handler(mockContext); + + expect(result.output).toEqual({ + category: 'category1', + rationale: 'Test rationale', + confidence: 0.95, + metadata: { + model: 'test-model', + }, + }); + }); + }); + + describe('context manager integration', () => { + it('should use fake request from context manager', async () => { + const fakeRequest = { id: 'fake-request' } as KibanaRequest; + mockContextManager.getFakeRequest.mockReturnValue(fakeRequest); + + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + await stepDefinition.handler(mockContext); + + expect(mockContextManager.getFakeRequest).toHaveBeenCalledTimes(2); + expect(mockResolveConnectorId).toHaveBeenCalledWith( + 'test-connector-id', + mockInference, + fakeRequest + ); + expect(mockInference.getChatModel).toHaveBeenCalledWith( + expect.objectContaining({ + request: fakeRequest, + }) + ); + }); + }); + + describe('edge cases', () => { + it('should handle empty categories array', async () => { + mockContext.input.categories = []; + + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + await stepDefinition.handler(mockContext); + + expect(mockBuildClassificationRequestPart).toHaveBeenCalledWith( + expect.objectContaining({ + categories: [], + }) + ); + }); + + it('should handle empty string input', async () => { + mockContext.input.input = ''; + + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + await stepDefinition.handler(mockContext); + + expect(mockBuildDataPart).toHaveBeenCalledWith(''); + }); + + it('should handle complex nested object input', async () => { + const complexInput = { + nested: { + deep: { + value: 'test', + array: [1, 2, 3], + }, + }, + }; + mockContext.input.input = complexInput; + + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + await stepDefinition.handler(mockContext); + + expect(mockBuildDataPart).toHaveBeenCalledWith(complexInput); + }); + + it('should handle getChatModel failure', async () => { + mockInference.getChatModel.mockRejectedValueOnce(new Error('Chat model unavailable')); + + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + + await expect(stepDefinition.handler(mockContext)).rejects.toThrow('Chat model unavailable'); + }); + + it('should handle getStartServices failure', async () => { + mockCoreSetup.getStartServices.mockRejectedValueOnce(new Error('Services unavailable')); + + const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); + + await expect(stepDefinition.handler(mockContext)).rejects.toThrow('Services unavailable'); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/step.ts b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/step.ts new file mode 100644 index 0000000000000..9c07915d039ba --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/step.ts @@ -0,0 +1,91 @@ +/* + * 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 { MessageFieldWithRole } from '@langchain/core/messages'; +import type { CoreSetup } from '@kbn/core/server'; +import { createServerStepDefinition } from '@kbn/workflows-extensions/server'; +import { + buildClassificationRequestPart, + buildDataPart, + buildInstructionsPart, + buildSystemPart, +} from './build_prompts'; +import { validateModelResponse } from './validate_model_response'; +import { + AiClassifyStepCommonDefinition, + buildStructuredOutputSchema, +} from '../../../../common/steps/ai'; +import type { InferenceWorkflowsStartDeps } from '../../../types'; +import { resolveConnectorId } from '../utils/resolve_connector_id'; + +export const aiClassifyStepDefinition = (coreSetup: CoreSetup) => + createServerStepDefinition({ + ...AiClassifyStepCommonDefinition, + handler: async (context) => { + const [, { inference }] = await coreSetup.getStartServices(); + + const resolvedConnectorId = await resolveConnectorId( + context.config['connector-id'], + inference, + context.contextManager.getFakeRequest() + ); + + const chatModel = await inference.getChatModel({ + connectorId: resolvedConnectorId, + request: context.contextManager.getFakeRequest(), + chatModelOptions: { + temperature: context.input.temperature, + maxRetries: 0, + }, + }); + + const { + input, + categories, + instructions, + allowMultipleCategories = false, + fallbackCategory, + includeRationale = false, + } = context.input; + const responseZodSchema = buildStructuredOutputSchema(context.input); + const modelInput: MessageFieldWithRole[] = [ + ...buildSystemPart(), + ...buildDataPart(input), + ...buildClassificationRequestPart({ + categories, + allowMultipleCategories, + fallbackCategory, + includeRationale, + }), + ...buildInstructionsPart(instructions), + ]; + + const invocationResult = await chatModel + .withStructuredOutput(responseZodSchema, { + name: 'classify', + includeRaw: true, + method: 'json', + }) + .invoke(modelInput, { + signal: context.abortSignal, + }); + + validateModelResponse({ + modelResponse: invocationResult.parsed, + expectedCategories: context.input.categories, + fallbackCategory: context.input.fallbackCategory, + responseMetadata: invocationResult.raw.response_metadata, + }); + + return { + output: { + ...invocationResult.parsed, + metadata: invocationResult.raw.response_metadata, + }, + }; + }, + }); diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/validate_model_response.test.ts b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/validate_model_response.test.ts new file mode 100644 index 0000000000000..a1075c6872457 --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/validate_model_response.test.ts @@ -0,0 +1,414 @@ +/* + * 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 { ExecutionError } from '@kbn/workflows/server'; +import { validateModelResponse } from './validate_model_response'; + +describe('validateModelResponse', () => { + const responseMetadata = { + modelId: 'test-model', + timestamp: Date.now(), + }; + + describe('category validation', () => { + it('should accept a category that matches expected categories', () => { + const modelResponse = { + category: 'category1', + metadata: {}, + }; + + expect(() => { + validateModelResponse({ + modelResponse, + expectedCategories: ['category1', 'category2'], + fallbackCategory: undefined, + responseMetadata, + }); + }).not.toThrow(); + }); + + it('should accept multiple categories that all match expected categories', () => { + const modelResponse = { + categories: ['category1', 'category2'], + metadata: {}, + }; + + expect(() => { + validateModelResponse({ + modelResponse, + + expectedCategories: ['category1', 'category2', 'category3'], + fallbackCategory: undefined, + responseMetadata, + }); + }).not.toThrow(); + }); + + it('should accept a category that matches fallback category', () => { + const modelResponse = { + category: 'fallback', + metadata: {}, + }; + + expect(() => { + validateModelResponse({ + modelResponse, + expectedCategories: ['category1', 'category2'], + fallbackCategory: 'fallback', + responseMetadata, + }); + }).not.toThrow(); + }); + + it('should accept categories when one matches expected and one matches fallback', () => { + const modelResponse = { + categories: ['category1', 'fallback'], + metadata: {}, + }; + + expect(() => { + validateModelResponse({ + modelResponse, + expectedCategories: ['category1', 'category2'], + fallbackCategory: 'fallback', + responseMetadata, + }); + }).not.toThrow(); + }); + + it('should throw UnexpectedCategories error when category does not match expected categories', () => { + const modelResponse = { + category: 'unexpected', + metadata: {}, + }; + + expect(() => { + validateModelResponse({ + modelResponse, + expectedCategories: ['category1', 'category2'], + fallbackCategory: undefined, + responseMetadata, + }); + }).toThrow(ExecutionError); + + try { + validateModelResponse({ + modelResponse, + + expectedCategories: ['category1', 'category2'], + fallbackCategory: undefined, + responseMetadata, + }); + } catch (error) { + expect(error).toBeInstanceOf(ExecutionError); + expect((error as ExecutionError).type).toBe('UnexpectedCategories'); + expect((error as ExecutionError).message).toBe('Model returned unexpected categories.'); + expect((error as ExecutionError).details).toMatchObject({ + modelResponse, + metadata: responseMetadata, + }); + } + }); + + it('should throw UnexpectedCategories error when one of multiple categories is unexpected', () => { + const modelResponse = { + categories: ['category1', 'unexpected'], + metadata: {}, + }; + + expect(() => { + validateModelResponse({ + modelResponse, + expectedCategories: ['category1', 'category2'], + fallbackCategory: undefined, + responseMetadata, + }); + }).toThrow(ExecutionError); + + try { + validateModelResponse({ + modelResponse, + + expectedCategories: ['category1', 'category2'], + fallbackCategory: undefined, + responseMetadata, + }); + } catch (error) { + expect(error).toBeInstanceOf(ExecutionError); + expect((error as ExecutionError).type).toBe('UnexpectedCategories'); + } + }); + + it('should throw UnexpectedCategories error when all categories are unexpected', () => { + const modelResponse = { + categories: ['unexpected1', 'unexpected2'], + metadata: {}, + }; + + expect(() => { + validateModelResponse({ + modelResponse, + expectedCategories: ['category1', 'category2'], + fallbackCategory: undefined, + responseMetadata, + }); + }).toThrow(ExecutionError); + }); + + it('should throw UnexpectedCategories error when category does not match expected or fallback', () => { + const modelResponse = { + category: 'unexpected', + metadata: {}, + }; + + expect(() => { + validateModelResponse({ + modelResponse, + expectedCategories: ['category1', 'category2'], + fallbackCategory: 'fallback', + responseMetadata, + }); + }).toThrow(ExecutionError); + }); + }); + + describe('edge cases', () => { + it('should handle empty expectedCategories array when fallbackCategory is provided', () => { + const modelResponse = { + category: 'fallback', + metadata: {}, + }; + + expect(() => { + validateModelResponse({ + modelResponse, + expectedCategories: [], + fallbackCategory: 'fallback', + responseMetadata, + }); + }).not.toThrow(); + }); + + it('should handle response with both category and categories fields (prioritize categories)', () => { + const modelResponse = { + category: 'category1', + categories: ['category2', 'category3'], + metadata: {}, + }; + + expect(() => { + validateModelResponse({ + modelResponse, + expectedCategories: ['category1', 'category2', 'category3'], + fallbackCategory: undefined, + responseMetadata, + }); + }).not.toThrow(); + }); + + it('should handle response with empty categories array by falling back to category field', () => { + const modelResponse = { + category: 'category1', + categories: [], + metadata: {}, + }; + + expect(() => { + validateModelResponse({ + modelResponse, + expectedCategories: ['category1', 'category2'], + fallbackCategory: undefined, + responseMetadata, + }); + }).not.toThrow(); + }); + + it('should handle response with single category in categories array', () => { + const modelResponse = { + categories: ['category1'], + metadata: {}, + }; + + expect(() => { + validateModelResponse({ + modelResponse, + expectedCategories: ['category1', 'category2'], + fallbackCategory: undefined, + responseMetadata, + }); + }).not.toThrow(); + }); + + it('should handle response with additional metadata fields', () => { + const modelResponse = { + category: 'category1', + metadata: { + confidence: 0.95, + processingTime: 123, + additionalInfo: 'test', + }, + }; + + expect(() => { + validateModelResponse({ + modelResponse, + expectedCategories: ['category1', 'category2'], + fallbackCategory: undefined, + responseMetadata, + }); + }).not.toThrow(); + }); + + it('should handle undefined fallbackCategory', () => { + const modelResponse = { + category: 'category1', + metadata: {}, + }; + + expect(() => { + validateModelResponse({ + modelResponse, + expectedCategories: ['category1', 'category2'], + fallbackCategory: undefined, + responseMetadata, + }); + }).not.toThrow(); + }); + + it('should handle empty responseMetadata', () => { + const modelResponse = { + category: 'category1', + metadata: {}, + }; + + expect(() => { + validateModelResponse({ + modelResponse, + expectedCategories: ['category1', 'category2'], + fallbackCategory: undefined, + responseMetadata: {}, + }); + }).not.toThrow(); + }); + + it('should preserve responseMetadata in error details', () => { + const customMetadata = { + customField: 'customValue', + timestamp: Date.now(), + }; + + const modelResponse = { + category: 'unexpected', + metadata: {}, + }; + + try { + validateModelResponse({ + modelResponse, + + expectedCategories: ['category1', 'category2'], + fallbackCategory: undefined, + responseMetadata: customMetadata, + }); + } catch (error) { + expect((error as ExecutionError).details?.metadata).toEqual(customMetadata); + } + }); + }); + + describe('null/undefined model response', () => { + it('throws InvalidModelResponse when modelResponse is null', () => { + expect(() => { + validateModelResponse({ + modelResponse: null, + expectedCategories: ['a'], + fallbackCategory: undefined, + responseMetadata: {}, + }); + }).toThrow(ExecutionError); + + try { + validateModelResponse({ + modelResponse: null, + expectedCategories: ['a'], + fallbackCategory: undefined, + responseMetadata: {}, + }); + } catch (error) { + expect((error as ExecutionError).type).toBe('InvalidModelResponse'); + expect((error as ExecutionError).message).toBe('Model response is null or undefined.'); + } + }); + + it('throws InvalidModelResponse when modelResponse is undefined', () => { + expect(() => { + validateModelResponse({ + modelResponse: undefined, + expectedCategories: ['a'], + fallbackCategory: undefined, + responseMetadata: {}, + }); + }).toThrow(ExecutionError); + + try { + validateModelResponse({ + modelResponse: undefined, + expectedCategories: ['a'], + fallbackCategory: undefined, + responseMetadata: {}, + }); + } catch (error) { + expect((error as ExecutionError).type).toBe('InvalidModelResponse'); + } + }); + }); + + describe('case sensitivity', () => { + it('should treat category names as case-sensitive', () => { + const modelResponse = { + category: 'Category1', + metadata: {}, + }; + + expect(() => { + validateModelResponse({ + modelResponse, + expectedCategories: ['category1', 'category2'], + fallbackCategory: undefined, + responseMetadata, + }); + }).toThrow(ExecutionError); + + try { + validateModelResponse({ + modelResponse, + expectedCategories: ['category1', 'category2'], + fallbackCategory: undefined, + responseMetadata, + }); + } catch (error) { + expect((error as ExecutionError).type).toBe('UnexpectedCategories'); + } + }); + + it('should match exact category names including whitespace', () => { + const modelResponse = { + category: 'category 1', + metadata: {}, + }; + + expect(() => { + validateModelResponse({ + modelResponse, + expectedCategories: ['category1', 'category2'], + fallbackCategory: undefined, + responseMetadata, + }); + }).toThrow(ExecutionError); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/validate_model_response.ts b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/validate_model_response.ts new file mode 100644 index 0000000000000..214f0d77780d8 --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/validate_model_response.ts @@ -0,0 +1,56 @@ +/* + * 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 { ExecutionError } from '@kbn/workflows/server'; +import type { z } from '@kbn/zod/v4'; +import type { AiClassifyStepOutputSchema } from '../../../../common/steps/ai'; + +export function validateModelResponse({ + modelResponse, + expectedCategories, + fallbackCategory, + responseMetadata, +}: { + modelResponse: z.infer | null | undefined; + expectedCategories: string[]; + fallbackCategory: string | undefined; + responseMetadata: Record; +}): void { + if (!modelResponse) { + throw new ExecutionError({ + type: 'InvalidModelResponse', + message: 'Model response is null or undefined.', + details: { + modelResponse, + metadata: responseMetadata, + }, + }); + } + + const returnedCategories = modelResponse.categories?.length + ? modelResponse.categories + : [modelResponse.category as string]; + const categoriesSet = new Set([ + ...expectedCategories, + ...(fallbackCategory ? [fallbackCategory] : []), + ]); + + const unexpectedCategories = returnedCategories.filter( + (returnedCategory: string) => !categoriesSet.has(returnedCategory) + ); + + if (unexpectedCategories.length) { + throw new ExecutionError({ + type: 'UnexpectedCategories', + message: 'Model returned unexpected categories.', + details: { + modelResponse, + metadata: responseMetadata, + }, + }); + } +} diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_prompt_step/ai_prompt_step.test.ts b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_prompt_step/ai_prompt_step.test.ts new file mode 100644 index 0000000000000..751ed85278762 --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_prompt_step/ai_prompt_step.test.ts @@ -0,0 +1,520 @@ +/* + * 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 { CoreSetup, KibanaRequest } from '@kbn/core/server'; +import type { InferenceServerStart } from '@kbn/inference-plugin/server'; + +jest.mock('../utils/resolve_connector_id', () => ({ + resolveConnectorId: jest.fn(), +})); + +jest.mock('../../../../common/steps/ai', () => ({ + AiPromptStepCommonDefinition: { + id: 'ai.prompt', + inputSchema: {}, + outputSchema: {}, + }, +})); + +jest.mock('@kbn/workflows-extensions/server', () => ({ + createServerStepDefinition: jest.fn((definition) => definition), +})); + +import { aiPromptStepDefinition } from './step'; +import type { ContextManager, StepHandlerContext } from '@kbn/workflows-extensions/server'; +import { createServerStepDefinition } from '@kbn/workflows-extensions/server'; +import type { InferenceWorkflowsStartDeps } from '../../../types'; +import { resolveConnectorId } from '../utils/resolve_connector_id'; + +const mockResolveConnectorId = resolveConnectorId as jest.MockedFunction; +const mockCreateServerStepDefinition = createServerStepDefinition as jest.MockedFunction< + typeof createServerStepDefinition +>; + +describe('aiPromptStepDefinition', () => { + let mockCoreSetup: jest.Mocked>; + let mockInference: jest.Mocked; + let mockContextManager: jest.Mocked; + let mockContext: StepHandlerContext; + let mockChatModel: any; + let mockRunnable: any; + let mockAbortController: AbortController; + + beforeEach(() => { + jest.clearAllMocks(); + + mockAbortController = new AbortController(); + + mockRunnable = { + invoke: jest.fn(), + }; + + mockChatModel = { + invoke: jest.fn(), + withStructuredOutput: jest.fn().mockReturnValue(mockRunnable), + }; + + mockInference = { + getChatModel: jest.fn().mockResolvedValue(mockChatModel), + } as any; + + mockContextManager = { + getFakeRequest: jest.fn().mockReturnValue({} as KibanaRequest), + getContext: jest.fn(), + getScopedEsClient: jest.fn(), + renderInputTemplate: jest.fn(), + }; + + mockContext = { + config: { + 'connector-id': 'test-connector-id', + }, + input: { + prompt: 'Test prompt', + temperature: 0.7, + }, + rawInput: { + prompt: 'Test prompt', + temperature: 0.7, + }, + contextManager: mockContextManager, + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + abortSignal: mockAbortController.signal, + stepId: 'test-step-id', + stepType: 'ai.prompt', + }; + + mockCoreSetup = { + getStartServices: jest.fn().mockResolvedValue([{}, { inference: mockInference }]), + } as any; + + mockResolveConnectorId.mockResolvedValue('resolved-connector-id'); + mockCreateServerStepDefinition.mockImplementation((def) => def); + }); + + describe('step definition creation', () => { + it('should create a step definition with correct structure', () => { + const stepDefinition = aiPromptStepDefinition(mockCoreSetup); + + expect(mockCreateServerStepDefinition).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'ai.prompt', + inputSchema: {}, + outputSchema: {}, + handler: expect.any(Function), + }) + ); + + expect(stepDefinition).toBeDefined(); + expect(typeof stepDefinition.handler).toBe('function'); + }); + }); + + describe('handler execution', () => { + let stepDefinition: any; + let handler: Function; + + beforeEach(() => { + stepDefinition = aiPromptStepDefinition(mockCoreSetup); + handler = stepDefinition.handler; + }); + + describe('with basic input (no output schema)', () => { + it('should successfully execute AI prompt and return response', async () => { + const mockResponse = { + content: 'AI generated response', + response_metadata: { model: 'gpt-3.5-turbo', usage: { tokens: 100 } }, + }; + + mockChatModel.invoke.mockResolvedValue(mockResponse); + mockContext.input.systemPrompt = 'You are a helpful assistant.'; + const result = await handler(mockContext); + + expect(mockCoreSetup.getStartServices).toHaveBeenCalledTimes(1); + expect(mockResolveConnectorId).toHaveBeenCalledWith( + 'test-connector-id', + mockInference, + expect.any(Object) + ); + expect(mockInference.getChatModel).toHaveBeenCalledWith({ + connectorId: 'resolved-connector-id', + request: expect.any(Object), + chatModelOptions: { + temperature: 0.7, + maxRetries: 0, + }, + }); + expect(mockChatModel.invoke).toHaveBeenCalledWith( + [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'Test prompt' }, + ], + { signal: mockAbortController.signal } + ); + + expect(result).toEqual({ + output: { + content: 'AI generated response', + metadata: { model: 'gpt-3.5-turbo', usage: { tokens: 100 } }, + }, + }); + }); + + it('should handle missing temperature in input', async () => { + const contextWithoutTemperature = { + ...mockContext, + input: { + prompt: 'Test prompt', + connectorId: 'test-connector-id', + }, + }; + + const mockResponse = { + content: 'AI response', + response_metadata: {}, + }; + + mockChatModel.invoke.mockResolvedValue(mockResponse); + + await handler(contextWithoutTemperature); + + expect(mockInference.getChatModel).toHaveBeenCalledWith({ + connectorId: 'resolved-connector-id', + request: expect.any(Object), + chatModelOptions: { + temperature: undefined, + maxRetries: 0, + }, + }); + }); + + it('should handle missing connectorId in input', async () => { + const contextWithoutConnectorId = { + ...mockContext, + config: { + ...mockContext.config, + 'connector-id': undefined, + }, + input: { + prompt: 'Test prompt', + temperature: 0.5, + }, + }; + + const mockResponse = { + content: 'AI response', + response_metadata: {}, + }; + + mockChatModel.invoke.mockResolvedValue(mockResponse); + + await handler(contextWithoutConnectorId); + + expect(mockResolveConnectorId).toHaveBeenCalledWith( + undefined, + mockInference, + expect.any(Object) + ); + }); + }); + + describe('with structured output schema', () => { + it('should use structured output when outputSchema is provided', async () => { + const contextWithSchema = { + ...mockContext, + input: { + ...mockContext.input, + schema: { + type: 'object', + properties: { + summary: { type: 'string' }, + sentiment: { type: 'string' }, + }, + }, + }, + }; + + const mockStructuredResponse = { + response: { + summary: 'This is a summary', + sentiment: 'positive', + }, + }; + + mockRunnable.invoke.mockResolvedValue({ + parsed: mockStructuredResponse, + raw: { + response_metadata: { tokens_used: 150 }, + }, + }); + + const result = await handler(contextWithSchema); + + expect(mockChatModel.withStructuredOutput).toHaveBeenCalledWith( + { + type: 'object', + properties: { + response: { + type: 'object', + properties: { + summary: { type: 'string' }, + sentiment: { type: 'string' }, + }, + }, + }, + }, + { + name: 'extract_structured_response', + includeRaw: true, + method: 'jsonMode', + } + ); + + expect(mockRunnable.invoke).toHaveBeenCalledWith( + [{ role: 'user', content: 'Test prompt' }], + { signal: mockAbortController.signal } + ); + + expect(result).toEqual({ + output: { + content: { + summary: 'This is a summary', + sentiment: 'positive', + }, + metadata: { tokens_used: 150 }, + }, + }); + + expect(mockChatModel.invoke).not.toHaveBeenCalled(); + }); + + it('should handle array output schema by wrapping in response object', async () => { + const contextWithArraySchema = { + ...mockContext, + input: { + ...mockContext.input, + schema: { + type: 'array', + items: { type: 'string' }, + }, + }, + }; + + const mockStructuredResponse = { + response: ['item1', 'item2', 'item3'], + }; + + mockRunnable.invoke.mockResolvedValue({ + parsed: mockStructuredResponse, + raw: { + response_metadata: { tokens_used: 150 }, + }, + }); + + const result = await handler(contextWithArraySchema); + + expect(mockChatModel.withStructuredOutput).toHaveBeenCalledWith( + { + type: 'object', + properties: { + response: { + type: 'array', + items: { type: 'string' }, + }, + }, + }, + { + name: 'extract_structured_response', + includeRaw: true, + method: 'jsonMode', + } + ); + + expect(result).toEqual({ + output: expect.objectContaining({ + content: ['item1', 'item2', 'item3'], + }), + }); + }); + }); + + describe('error handling', () => { + it('should propagate errors from resolveConnectorId', async () => { + const error = new Error('Connector resolution failed'); + mockResolveConnectorId.mockRejectedValue(error); + + await expect(handler(mockContext)).rejects.toThrow('Connector resolution failed'); + }); + + it('should propagate errors from getChatModel', async () => { + const error = new Error('Chat model initialization failed'); + mockInference.getChatModel.mockRejectedValue(error); + + await expect(handler(mockContext)).rejects.toThrow('Chat model initialization failed'); + }); + + it('should propagate errors from chat model invoke', async () => { + const error = new Error('AI model invocation failed'); + mockChatModel.invoke.mockRejectedValue(error); + + await expect(handler(mockContext)).rejects.toThrow('AI model invocation failed'); + }); + + it('should propagate errors from structured output invoke', async () => { + const contextWithSchema = { + ...mockContext, + input: { + ...mockContext.input, + schema: { type: 'object', properties: {} }, + }, + }; + + const error = new Error('Structured output invocation failed'); + mockRunnable.invoke.mockRejectedValue(error); + + await expect(handler(contextWithSchema)).rejects.toThrow( + 'Structured output invocation failed' + ); + }); + + it('should handle abortion via abortSignal', async () => { + mockAbortController.abort(); + + const error = new Error('Aborted'); + error.name = 'AbortError'; + mockChatModel.invoke.mockRejectedValue(error); + + await expect(handler(mockContext)).rejects.toThrow('Aborted'); + }); + }); + + describe('service integration', () => { + it('should pass correct parameters to all services', async () => { + const mockResponse = { + content: 'Test response', + response_metadata: {}, + }; + + mockChatModel.invoke.mockResolvedValue(mockResponse); + + await handler(mockContext); + + expect(mockCoreSetup.getStartServices).toHaveBeenCalledTimes(1); + expect(mockContextManager.getFakeRequest).toHaveBeenCalledTimes(2); + expect(mockResolveConnectorId).toHaveBeenCalledWith( + 'test-connector-id', + mockInference, + expect.any(Object) + ); + expect(mockInference.getChatModel).toHaveBeenCalledWith({ + connectorId: 'resolved-connector-id', + request: expect.any(Object), + chatModelOptions: { + temperature: 0.7, + maxRetries: 0, + }, + }); + expect(mockChatModel.invoke).toHaveBeenCalledWith( + [{ role: 'user', content: 'Test prompt' }], + { signal: mockAbortController.signal } + ); + }); + + it('should use the same fake request for connector resolution and chat model', async () => { + const mockFakeRequest = { headers: {}, auth: {} } as KibanaRequest; + mockContextManager.getFakeRequest.mockReturnValue(mockFakeRequest); + + const mockResponse = { + content: 'Test response', + response_metadata: {}, + }; + + mockChatModel.invoke.mockResolvedValue(mockResponse); + + await handler(mockContext); + + expect(mockResolveConnectorId).toHaveBeenCalledWith( + 'test-connector-id', + mockInference, + mockFakeRequest + ); + + expect(mockInference.getChatModel).toHaveBeenCalledWith({ + connectorId: 'resolved-connector-id', + request: mockFakeRequest, + chatModelOptions: { + temperature: 0.7, + maxRetries: 0, + }, + }); + }); + }); + + describe('input validation and processing', () => { + it('should handle various temperature values', async () => { + const temperatures = [0, 0.1, 0.5, 0.9, 1.0]; + const mockResponse = { content: 'response', response_metadata: {} }; + mockChatModel.invoke.mockResolvedValue(mockResponse); + + for (const temperature of temperatures) { + const contextWithTemperature = { + ...mockContext, + input: { + ...mockContext.input, + temperature, + }, + }; + + await handler(contextWithTemperature); + + expect(mockInference.getChatModel).toHaveBeenCalledWith( + expect.objectContaining({ + chatModelOptions: expect.objectContaining({ + temperature, + maxRetries: 0, + }), + }) + ); + } + }); + + it('should handle different prompt types', async () => { + const prompts = [ + 'Simple text prompt', + 'Multi\nline\nprompt', + 'Prompt with special characters: @#$%^&*()', + '', + 'Very long prompt that exceeds normal length expectations and continues for a while to test edge cases', + ]; + + const mockResponse = { content: 'response', response_metadata: {} }; + mockChatModel.invoke.mockResolvedValue(mockResponse); + + for (const prompt of prompts) { + const contextWithPrompt = { + ...mockContext, + input: { + ...mockContext.input, + prompt, + }, + }; + + await handler(contextWithPrompt); + + expect(mockChatModel.invoke).toHaveBeenCalledWith([{ role: 'user', content: prompt }], { + signal: mockAbortController.signal, + }); + } + }); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_prompt_step/step.ts b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_prompt_step/step.ts new file mode 100644 index 0000000000000..29e3423ed3d24 --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_prompt_step/step.ts @@ -0,0 +1,81 @@ +/* + * 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 { CoreSetup } from '@kbn/core/server'; +import { createServerStepDefinition } from '@kbn/workflows-extensions/server'; +import { AiPromptStepCommonDefinition } from '../../../../common/steps/ai'; +import type { InferenceWorkflowsStartDeps } from '../../../types'; +import { resolveConnectorId } from '../utils/resolve_connector_id'; + +export const aiPromptStepDefinition = (coreSetup: CoreSetup) => + createServerStepDefinition({ + ...AiPromptStepCommonDefinition, + handler: async (context) => { + const [, { inference }] = await coreSetup.getStartServices(); + + const resolvedConnectorId = await resolveConnectorId( + context.config['connector-id'], + inference, + context.contextManager.getFakeRequest() + ); + + const chatModel = await inference.getChatModel({ + connectorId: resolvedConnectorId, + request: context.contextManager.getFakeRequest(), + chatModelOptions: { + temperature: context.input.temperature, + maxRetries: 0, + }, + }); + const modelInput = [ + ...(context.input.systemPrompt + ? [{ role: 'system', content: context.input.systemPrompt }] + : []), + { + role: 'user', + content: context.input.prompt, + }, + ]; + + if (context.input.schema) { + const runnable = chatModel.withStructuredOutput( + { + type: 'object', + properties: { + response: context.input.schema, + }, + }, + { + name: 'extract_structured_response', + includeRaw: true, + method: 'jsonMode', + } + ); + + const invocationResult = await runnable.invoke(modelInput, { + signal: context.abortSignal, + }); + return { + output: { + content: invocationResult.parsed.response, + metadata: invocationResult.raw.response_metadata, + }, + }; + } + + const invocationResult = await chatModel.invoke(modelInput, { + signal: context.abortSignal, + }); + + return { + output: { + content: invocationResult.content, + metadata: invocationResult.response_metadata, + }, + }; + }, + }); diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_summarize_step/ai_summarize_step.test.ts b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_summarize_step/ai_summarize_step.test.ts new file mode 100644 index 0000000000000..1a04f74879804 --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_summarize_step/ai_summarize_step.test.ts @@ -0,0 +1,480 @@ +/* + * 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 { CoreSetup, KibanaRequest } from '@kbn/core/server'; +import type { InferenceServerStart } from '@kbn/inference-plugin/server'; + +jest.mock('../utils/resolve_connector_id', () => ({ + resolveConnectorId: jest.fn(), +})); + +jest.mock('./build_prompts', () => ({ + buildSystemPart: jest.fn(), + buildDataPart: jest.fn(), + buildRequirementsPart: jest.fn(), + buildInstructionsPart: jest.fn(), +})); + +jest.mock('../../../../common/steps/ai', () => ({ + AiSummarizeStepCommonDefinition: { + id: 'ai.summarize', + inputSchema: {}, + outputSchema: {}, + configSchema: {}, + }, +})); + +jest.mock('@kbn/workflows-extensions/server', () => ({ + createServerStepDefinition: jest.fn((definition) => definition), +})); + +import { + buildDataPart, + buildInstructionsPart, + buildRequirementsPart, + buildSystemPart, +} from './build_prompts'; +import { aiSummarizeStepDefinition } from './step'; +import { createServerStepDefinition } from '@kbn/workflows-extensions/server'; +import type { ContextManager, StepHandlerContext } from '@kbn/workflows-extensions/server'; +import type { InferenceWorkflowsStartDeps } from '../../../types'; +import { resolveConnectorId } from '../utils/resolve_connector_id'; + +const mockResolveConnectorId = resolveConnectorId as jest.MockedFunction; +const mockBuildSystemPart = buildSystemPart as jest.MockedFunction; +const mockBuildDataPart = buildDataPart as jest.MockedFunction; +const mockBuildRequirementsPart = buildRequirementsPart as jest.MockedFunction< + typeof buildRequirementsPart +>; +const mockBuildInstructionsPart = buildInstructionsPart as jest.MockedFunction< + typeof buildInstructionsPart +>; +const mockCreateServerStepDefinition = createServerStepDefinition as jest.MockedFunction< + typeof createServerStepDefinition +>; + +describe('aiSummarizeStepDefinition', () => { + let mockCoreSetup: jest.Mocked>; + let mockInference: jest.Mocked; + let mockContextManager: jest.Mocked; + let mockContext: StepHandlerContext; + let mockChatModel: any; + let mockAbortController: AbortController; + + beforeEach(() => { + jest.clearAllMocks(); + + mockAbortController = new AbortController(); + + mockChatModel = { + invoke: jest.fn(), + }; + + mockInference = { + getChatModel: jest.fn().mockResolvedValue(mockChatModel), + } as any; + + mockContextManager = { + getFakeRequest: jest.fn().mockReturnValue({} as KibanaRequest), + getContext: jest.fn(), + getScopedEsClient: jest.fn(), + renderInputTemplate: jest.fn(), + }; + + mockContext = { + config: { + 'connector-id': 'test-connector-id', + }, + input: { + input: 'Text to summarize', + temperature: 0.7, + }, + rawInput: { + input: 'Text to summarize', + temperature: 0.7, + }, + contextManager: mockContextManager, + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + abortSignal: mockAbortController.signal, + stepId: 'test-step-id', + stepType: 'ai.summarize', + }; + + mockCoreSetup = { + getStartServices: jest.fn().mockResolvedValue([{}, { inference: mockInference }]), + } as any; + + mockBuildSystemPart.mockReturnValue([{ role: 'system', content: 'System prompt' }]); + mockBuildDataPart.mockReturnValue([{ role: 'user', content: 'Data to summarize' }]); + mockBuildRequirementsPart.mockReturnValue([ + { role: 'user', content: 'Requirements for summary' }, + ]); + mockBuildInstructionsPart.mockReturnValue([ + { role: 'user', content: 'Additional instructions' }, + ]); + + mockResolveConnectorId.mockResolvedValue('resolved-connector-id'); + mockCreateServerStepDefinition.mockImplementation((def) => def); + }); + + describe('handler execution', () => { + let stepDefinition: any; + let handler: Function; + + beforeEach(() => { + stepDefinition = aiSummarizeStepDefinition(mockCoreSetup); + handler = stepDefinition.handler; + }); + + describe('with basic input', () => { + it('should successfully execute AI summarize and return response with string content', async () => { + const mockResponse = { + content: 'This is a summary of the text', + response_metadata: { model: 'gpt-3.5-turbo', usage: { tokens: 100 } }, + }; + + mockChatModel.invoke.mockResolvedValue(mockResponse); + + const result = await handler(mockContext); + + expect(mockCoreSetup.getStartServices).toHaveBeenCalledTimes(1); + expect(mockResolveConnectorId).toHaveBeenCalledWith( + 'test-connector-id', + mockInference, + expect.any(Object) + ); + expect(mockInference.getChatModel).toHaveBeenCalledWith({ + connectorId: 'resolved-connector-id', + request: expect.any(Object), + chatModelOptions: { + temperature: 0.7, + maxRetries: 0, + }, + }); + + expect(mockBuildSystemPart).toHaveBeenCalledTimes(1); + expect(mockBuildDataPart).toHaveBeenCalledWith('Text to summarize'); + expect(mockBuildRequirementsPart).toHaveBeenCalledWith({ maxLength: undefined }); + expect(mockBuildInstructionsPart).toHaveBeenCalledWith(undefined); + + expect(mockChatModel.invoke).toHaveBeenCalledWith( + [ + { role: 'system', content: 'System prompt' }, + { role: 'user', content: 'Data to summarize' }, + { role: 'user', content: 'Requirements for summary' }, + { role: 'user', content: 'Additional instructions' }, + ], + { signal: mockAbortController.signal } + ); + + expect(result).toEqual({ + output: { + content: 'This is a summary of the text', + metadata: { model: 'gpt-3.5-turbo', usage: { tokens: 100 } }, + }, + }); + }); + + it('should handle array content in response', async () => { + const mockResponse = { + content: [ + { type: 'text', text: 'First part of summary' }, + { type: 'text', text: 'Second part of summary' }, + ], + response_metadata: { model: 'gpt-4' }, + }; + + mockChatModel.invoke.mockResolvedValue(mockResponse); + + const result = await handler(mockContext); + + expect(result).toEqual({ + output: { + content: 'First part of summarySecond part of summary', + metadata: { model: 'gpt-4' }, + }, + }); + }); + + it('should handle array content with non-text parts', async () => { + const mockResponse = { + content: [ + { type: 'text', text: 'Text part' }, + { type: 'image', url: 'http://example.com/image.jpg' }, + { type: 'text', text: 'Another text part' }, + ], + response_metadata: {}, + }; + + mockChatModel.invoke.mockResolvedValue(mockResponse); + + const result = await handler(mockContext); + + expect(result).toEqual({ + output: { + content: 'Text partAnother text part', + metadata: {}, + }, + }); + }); + + it('should handle missing temperature in input', async () => { + const contextWithoutTemperature = { + ...mockContext, + input: { + input: 'Text to summarize', + }, + }; + + const mockResponse = { + content: 'Summary', + response_metadata: {}, + }; + + mockChatModel.invoke.mockResolvedValue(mockResponse); + + await handler(contextWithoutTemperature); + + expect(mockInference.getChatModel).toHaveBeenCalledWith({ + connectorId: 'resolved-connector-id', + request: expect.any(Object), + chatModelOptions: { + temperature: undefined, + maxRetries: 0, + }, + }); + }); + + it('should handle missing connector-id in config', async () => { + const contextWithoutConnectorId = { + ...mockContext, + config: {}, + }; + + const mockResponse = { + content: 'Summary', + response_metadata: {}, + }; + + mockChatModel.invoke.mockResolvedValue(mockResponse); + + await handler(contextWithoutConnectorId); + + expect(mockResolveConnectorId).toHaveBeenCalledWith( + undefined, + mockInference, + expect.any(Object) + ); + }); + }); + + describe('with optional parameters', () => { + it('should handle maxLength parameter', async () => { + const contextWithMaxLength = { + ...mockContext, + input: { + input: 'Text to summarize', + maxLength: 100, + }, + }; + + const mockResponse = { + content: 'Short summary', + response_metadata: {}, + }; + + mockChatModel.invoke.mockResolvedValue(mockResponse); + + await handler(contextWithMaxLength); + + expect(mockBuildRequirementsPart).toHaveBeenCalledWith({ maxLength: 100 }); + }); + + it('should handle instructions parameter', async () => { + const contextWithInstructions = { + ...mockContext, + input: { + input: 'Text to summarize', + instructions: 'Focus on key points only', + }, + }; + + const mockResponse = { + content: 'Focused summary', + response_metadata: {}, + }; + + mockChatModel.invoke.mockResolvedValue(mockResponse); + + await handler(contextWithInstructions); + + expect(mockBuildInstructionsPart).toHaveBeenCalledWith('Focus on key points only'); + }); + + it('should handle both maxLength and instructions', async () => { + const contextWithBoth = { + ...mockContext, + input: { + input: 'Text to summarize', + maxLength: 150, + instructions: 'Be concise and factual', + temperature: 0.5, + }, + }; + + const mockResponse = { + content: 'Concise summary', + response_metadata: {}, + }; + + mockChatModel.invoke.mockResolvedValue(mockResponse); + + await handler(contextWithBoth); + + expect(mockBuildRequirementsPart).toHaveBeenCalledWith({ maxLength: 150 }); + expect(mockBuildInstructionsPart).toHaveBeenCalledWith('Be concise and factual'); + }); + }); + + describe('with different input types', () => { + it('should handle string input', async () => { + const contextWithString = { + ...mockContext, + input: { + input: 'Simple text string', + }, + }; + + const mockResponse = { + content: 'Summary', + response_metadata: {}, + }; + + mockChatModel.invoke.mockResolvedValue(mockResponse); + + await handler(contextWithString); + + expect(mockBuildDataPart).toHaveBeenCalledWith('Simple text string'); + }); + + it('should handle object input', async () => { + const inputObject = { name: 'John', age: 30, city: 'New York' }; + const contextWithObject = { + ...mockContext, + input: { + input: inputObject, + }, + }; + + const mockResponse = { + content: 'Summary', + response_metadata: {}, + }; + + mockChatModel.invoke.mockResolvedValue(mockResponse); + + await handler(contextWithObject); + + expect(mockBuildDataPart).toHaveBeenCalledWith(inputObject); + }); + + it('should handle array input', async () => { + const inputArray = ['item1', 'item2', 'item3']; + const contextWithArray = { + ...mockContext, + input: { + input: inputArray, + }, + }; + + const mockResponse = { + content: 'Summary', + response_metadata: {}, + }; + + mockChatModel.invoke.mockResolvedValue(mockResponse); + + await handler(contextWithArray); + + expect(mockBuildDataPart).toHaveBeenCalledWith(inputArray); + }); + }); + + describe('error handling', () => { + it('should handle abortion via abortSignal', async () => { + mockAbortController.abort(); + + const error = new Error('Aborted'); + error.name = 'AbortError'; + mockChatModel.invoke.mockRejectedValue(error); + + await expect(handler(mockContext)).rejects.toThrow('Aborted'); + }); + }); + + describe('prompt composition', () => { + it('should compose prompts in correct order', async () => { + const mockResponse = { + content: 'Summary', + response_metadata: {}, + }; + + mockChatModel.invoke.mockResolvedValue(mockResponse); + + await handler(mockContext); + + const callOrder = [ + mockBuildSystemPart.mock.invocationCallOrder[0], + mockBuildDataPart.mock.invocationCallOrder[0], + mockBuildRequirementsPart.mock.invocationCallOrder[0], + mockBuildInstructionsPart.mock.invocationCallOrder[0], + ]; + + expect(callOrder[0]).toBeLessThan(callOrder[1]); + expect(callOrder[1]).toBeLessThan(callOrder[2]); + expect(callOrder[2]).toBeLessThan(callOrder[3]); + }); + + it('should flatten all prompt parts into single array', async () => { + mockBuildSystemPart.mockReturnValue([ + { role: 'system', content: 'System 1' }, + { role: 'system', content: 'System 2' }, + ]); + mockBuildDataPart.mockReturnValue([ + { role: 'user', content: 'Data 1' }, + { role: 'user', content: 'Data 2' }, + ]); + + const mockResponse = { + content: 'Summary', + response_metadata: {}, + }; + + mockChatModel.invoke.mockResolvedValue(mockResponse); + + await handler(mockContext); + + expect(mockChatModel.invoke).toHaveBeenCalledWith( + [ + { role: 'system', content: 'System 1' }, + { role: 'system', content: 'System 2' }, + { role: 'user', content: 'Data 1' }, + { role: 'user', content: 'Data 2' }, + { role: 'user', content: 'Requirements for summary' }, + { role: 'user', content: 'Additional instructions' }, + ], + { signal: mockAbortController.signal } + ); + }); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_summarize_step/build_prompts.test.ts b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_summarize_step/build_prompts.test.ts new file mode 100644 index 0000000000000..7c90410af1e1b --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_summarize_step/build_prompts.test.ts @@ -0,0 +1,336 @@ +/* + * 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 { + buildDataPart, + buildInstructionsPart, + buildRequirementsPart, + buildSystemPart, +} from './build_prompts'; + +describe('buildSystemPart', () => { + it('should return an array with a system message', () => { + const result = buildSystemPart(); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + expect(result[0]).toHaveProperty('role', 'system'); + expect(result[0]).toHaveProperty('content'); + expect(typeof result[0].content).toBe('string'); + }); + + it('should return consistent results across multiple calls', () => { + const result1 = buildSystemPart(); + const result2 = buildSystemPart(); + + expect(result1).toEqual(result2); + }); + + it('should have non-empty content', () => { + const result = buildSystemPart(); + + expect(result[0].content.length).toBeGreaterThan(0); + if (typeof result[0].content === 'string') { + expect(result[0].content.trim()).not.toBe(''); + } + }); +}); + +describe('buildDataPart', () => { + describe('with string input', () => { + it('should return an array with a user message', () => { + const input = 'Test string data'; + const result = buildDataPart(input); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + expect(result[0]).toHaveProperty('role', 'user'); + expect(result[0]).toHaveProperty('content'); + expect(typeof result[0].content).toBe('string'); + }); + + it('should use json marker for markdown', () => { + const input = { name: 'John', age: 30 }; + const result = buildDataPart(input); + + expect(result[0].content).toContain('```json'); + }); + + it('should use text marker for markdown', () => { + const input = 'foo bar'; + const result = buildDataPart(input); + + expect(result[0].content).toContain('```text'); + }); + + it('should include the input string in content', () => { + const input = 'Test string data'; + const result = buildDataPart(input); + + expect(result[0].content).toContain(input); + }); + + it('should handle empty string', () => { + const input = ''; + const result = buildDataPart(input); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + }); + + it('should handle multi-line strings', () => { + const input = 'Line 1\nLine 2\nLine 3'; + const result = buildDataPart(input); + + expect(result[0].content).toContain(input); + }); + }); + + describe('with object input', () => { + it('should return an array with a user message', () => { + const input = { key: 'value', number: 42 }; + const result = buildDataPart(input); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + expect(result[0]).toHaveProperty('role', 'user'); + expect(result[0]).toHaveProperty('content'); + expect(typeof result[0].content).toBe('string'); + }); + + it('should stringify object input', () => { + const input = { name: 'John', age: 30 }; + const result = buildDataPart(input); + + expect(result[0].content).toContain('John'); + expect(result[0].content).toContain('30'); + }); + + it('should use json marker for markdown', () => { + const input = { name: 'John', age: 30 }; + const result = buildDataPart(input); + + expect(result[0].content).toContain('```json'); + }); + + it('should handle nested objects', () => { + const input = { + user: { + name: 'John', + address: { + city: 'New York', + }, + }, + }; + const result = buildDataPart(input); + + expect(result[0].content).toContain('New York'); + }); + + it('should handle empty object', () => { + const input = {}; + const result = buildDataPart(input); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + }); + }); + + describe('with array input', () => { + it('should return an array with a user message', () => { + const input = ['item1', 'item2', 'item3']; + const result = buildDataPart(input); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + expect(result[0]).toHaveProperty('role', 'user'); + expect(result[0]).toHaveProperty('content'); + expect(typeof result[0].content).toBe('string'); + }); + + it('should stringify array input', () => { + const input = ['item1', 'item2']; + const result = buildDataPart(input); + + expect(result[0].content).toContain('item1'); + expect(result[0].content).toContain('item2'); + }); + + it('should handle empty array', () => { + const input: unknown[] = []; + const result = buildDataPart(input); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + }); + + it('should handle array of objects', () => { + const input = [ + { id: 1, name: 'First' }, + { id: 2, name: 'Second' }, + ]; + const result = buildDataPart(input); + + expect(result[0].content).toContain('First'); + expect(result[0].content).toContain('Second'); + }); + }); + + describe('with primitive types', () => { + it('should handle number input', () => { + const input = 42; + const result = buildDataPart(input); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + expect(result[0].content).toContain('42'); + }); + + it('should handle boolean input', () => { + const input = true; + const result = buildDataPart(input); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + }); + + it('should handle null input', () => { + const input = null; + const result = buildDataPart(input); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + }); + }); +}); + +describe('buildRequirementsPart', () => { + describe('with maxLength parameter', () => { + it('should return an array with a user message when maxLength is provided', () => { + const result = buildRequirementsPart({ maxLength: 100 }); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + expect(result[0]).toHaveProperty('role', 'user'); + expect(result[0]).toHaveProperty('content'); + expect(typeof result[0].content).toBe('string'); + }); + + it('should include maxLength value in content', () => { + const result = buildRequirementsPart({ maxLength: 150 }); + + expect(result[0].content).toContain('150'); + }); + + it('should handle different maxLength values', () => { + const result1 = buildRequirementsPart({ maxLength: 50 }); + const result2 = buildRequirementsPart({ maxLength: 200 }); + + expect(result1[0].content).toContain('50'); + expect(result2[0].content).toContain('200'); + expect(result1[0].content).not.toEqual(result2[0].content); + }); + + it('should handle large maxLength values', () => { + const result = buildRequirementsPart({ maxLength: 10000 }); + + expect(result[0].content).toContain('10000'); + }); + + it('should handle small maxLength values', () => { + const result = buildRequirementsPart({ maxLength: 1 }); + + expect(result[0].content).toContain('1'); + }); + }); + + describe('without maxLength parameter', () => { + it('should return an empty array when maxLength is undefined', () => { + const result = buildRequirementsPart({ maxLength: undefined }); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(0); + }); + + it('should return an empty array when maxLength is not provided', () => { + const result = buildRequirementsPart({}); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(0); + }); + }); +}); + +describe('buildInstructionsPart', () => { + describe('with instructions parameter', () => { + it('should return an array with a user message when instructions are provided', () => { + const instructions = 'Focus on key points'; + const result = buildInstructionsPart(instructions); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + expect(result[0]).toHaveProperty('role', 'user'); + expect(result[0]).toHaveProperty('content'); + expect(typeof result[0].content).toBe('string'); + }); + + it('should include instructions text in content', () => { + const instructions = 'Be concise and factual'; + const result = buildInstructionsPart(instructions); + + expect(result[0].content).toContain(instructions); + }); + + it('should handle different instruction texts', () => { + const instructions1 = 'Short summary'; + const instructions2 = 'Detailed analysis with examples'; + + const result1 = buildInstructionsPart(instructions1); + const result2 = buildInstructionsPart(instructions2); + + expect(result1[0].content).toContain(instructions1); + expect(result2[0].content).toContain(instructions2); + }); + + it('should handle multi-line instructions', () => { + const instructions = 'Line 1\nLine 2\nLine 3'; + const result = buildInstructionsPart(instructions); + + expect(result[0].content).toContain(instructions); + }); + + it('should handle long instruction text', () => { + const instructions = 'A'.repeat(1000); + const result = buildInstructionsPart(instructions); + + expect(result[0].content).toContain(instructions); + }); + + it('should handle empty string', () => { + const instructions = ''; + const result = buildInstructionsPart(instructions); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(0); + }); + + it('should handle whitespace-only string', () => { + const instructions = ' '; + const result = buildInstructionsPart(instructions); + + expect(result).toEqual([]); + }); + }); + + describe('without instructions parameter', () => { + it('should return an empty array when instructions are undefined', () => { + const result = buildInstructionsPart(undefined); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_summarize_step/build_prompts.ts b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_summarize_step/build_prompts.ts new file mode 100644 index 0000000000000..31d15b1f59f53 --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_summarize_step/build_prompts.ts @@ -0,0 +1,89 @@ +/* + * 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 { MessageFieldWithRole } from '@langchain/core/messages'; + +export function buildSystemPart(): MessageFieldWithRole[] { + return [ + { + role: 'system', + content: ` +You are a specialized summarization engine that produces concise, factual summaries. + +CRITICAL RULES: +- Output ONLY the summary text itself +- Do NOT include any preambles, introductions, or phrases like "Here is the summary:", "Based on the data:", "The summary is:", etc. +- Do NOT engage in conversation or ask questions +- Do NOT add explanations, commentary, or meta-statements about the summary +- Do NOT use markdown formatting unless explicitly instructed +- Do NOT start responses with conversational phrases +- If you cannot summarize, output only: "Unable to generate summary" + +Your response must be the raw summary text with no additional content.`, + }, + ]; +} + +export function buildRequirementsPart(params: { maxLength?: number }): MessageFieldWithRole[] { + const { maxLength } = params; + const summaryRequirements: string[] = []; + + if (typeof maxLength === 'number') { + summaryRequirements.push(`Max length of summary must be ${maxLength} characters.`); + } + + if (summaryRequirements.length) { + return [ + { + role: 'user', + content: ` +# Requirements: +${summaryRequirements.map((req) => `- ${req}`).join('\n')} +`, + }, + ]; + } + + return []; +} + +export function buildDataPart(input: unknown): MessageFieldWithRole[] { + const inputType = typeof input === 'object' ? 'json' : 'text'; + let resolvedInput = input; + + if (inputType === 'json') { + resolvedInput = JSON.stringify(input); + } + + return [ + { + role: 'user', + content: ` +# Data to summarize: +\`\`\`${inputType} +${resolvedInput} +\`\`\` +`, + }, + ]; +} + +export function buildInstructionsPart(instructions: string | undefined): MessageFieldWithRole[] { + if (!instructions?.trim()) { + return []; + } + + return [ + { + role: 'user', + content: ` +# Additional instructions: +${instructions} +`, + }, + ]; +} diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_summarize_step/step.ts b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_summarize_step/step.ts new file mode 100644 index 0000000000000..a9944d6298c5c --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_summarize_step/step.ts @@ -0,0 +1,66 @@ +/* + * 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 { MessageFieldWithRole } from '@langchain/core/messages'; +import type { CoreSetup } from '@kbn/core/server'; +import { createServerStepDefinition } from '@kbn/workflows-extensions/server'; +import { + buildDataPart, + buildInstructionsPart, + buildRequirementsPart, + buildSystemPart, +} from './build_prompts'; +import { AiSummarizeStepCommonDefinition } from '../../../../common/steps/ai'; +import type { InferenceWorkflowsStartDeps } from '../../../types'; +import { resolveConnectorId } from '../utils/resolve_connector_id'; + +export const aiSummarizeStepDefinition = (coreSetup: CoreSetup) => + createServerStepDefinition({ + ...AiSummarizeStepCommonDefinition, + handler: async (context) => { + const [, { inference }] = await coreSetup.getStartServices(); + + const resolvedConnectorId = await resolveConnectorId( + context.config['connector-id'], + inference, + context.contextManager.getFakeRequest() + ); + + const chatModel = await inference.getChatModel({ + connectorId: resolvedConnectorId, + request: context.contextManager.getFakeRequest(), + chatModelOptions: { + temperature: context.input.temperature, + maxRetries: 0, + }, + }); + + const modelInput: MessageFieldWithRole[] = [ + ...buildSystemPart(), + ...buildDataPart(context.input.input), + ...buildRequirementsPart({ maxLength: context.input.maxLength }), + ...buildInstructionsPart(context.input.instructions), + ]; + + const modelResponse = await chatModel.invoke(modelInput, { + signal: context.abortSignal, + }); + + // Convert content to string if it's an array + const content = + typeof modelResponse.content === 'string' + ? modelResponse.content + : modelResponse.content.map((part) => ('text' in part ? part.text : '')).join(''); + + return { + output: { + content, + metadata: modelResponse.response_metadata, + }, + }; + }, + }); diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/utils/resolve_connector_id.test.ts b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/utils/resolve_connector_id.test.ts new file mode 100644 index 0000000000000..4de18bf3d334e --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/utils/resolve_connector_id.test.ts @@ -0,0 +1,216 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { KibanaRequest } from '@kbn/core/server'; +import type { InferenceConnector } from '@kbn/inference-common'; +import { InferenceConnectorType } from '@kbn/inference-common'; +import type { InferenceServerStart } from '@kbn/inference-plugin/server'; + +import { resolveConnectorId } from './resolve_connector_id'; + +describe('resolveConnectorId', () => { + let mockInferencePlugin: jest.Mocked; + let mockKibanaRequest: jest.Mocked; + + const createMockConnector = (partial: Partial): InferenceConnector => ({ + type: InferenceConnectorType.OpenAI, + name: 'Mock Connector', + connectorId: 'mock-connector-id', + config: {}, + capabilities: {}, + isInferenceEndpoint: false, + isPreconfigured: false, + ...partial, + }); + + beforeEach(() => { + jest.clearAllMocks(); + + mockKibanaRequest = {} as jest.Mocked; + + mockInferencePlugin = { + getDefaultConnector: jest.fn(), + getConnectorList: jest.fn(), + getConnectorById: jest.fn(), + } as any; + }); + + describe('when nameOrId is undefined', () => { + it('should return the default connector ID when a default connector exists', async () => { + const defaultConnectorId = 'default-connector-123'; + const mockConnector = createMockConnector({ + connectorId: defaultConnectorId, + }); + mockInferencePlugin.getDefaultConnector.mockResolvedValue(mockConnector); + + const result = await resolveConnectorId(undefined, mockInferencePlugin, mockKibanaRequest); + + expect(mockInferencePlugin.getDefaultConnector).toHaveBeenCalledWith(mockKibanaRequest); + expect(result).toBe(defaultConnectorId); + }); + + it('should throw an error when no default connector is configured', async () => { + mockInferencePlugin.getDefaultConnector.mockResolvedValue(null as any); + + await expect( + resolveConnectorId(undefined, mockInferencePlugin, mockKibanaRequest) + ).rejects.toThrow('No default AI connector configured'); + + expect(mockInferencePlugin.getDefaultConnector).toHaveBeenCalledWith(mockKibanaRequest); + }); + + it('should throw an error when default connector is undefined', async () => { + mockInferencePlugin.getDefaultConnector.mockResolvedValue(undefined as any); + + await expect( + resolveConnectorId(undefined, mockInferencePlugin, mockKibanaRequest) + ).rejects.toThrow('No default AI connector configured'); + + expect(mockInferencePlugin.getDefaultConnector).toHaveBeenCalledWith(mockKibanaRequest); + }); + }); + + describe('when nameOrId is empty string', () => { + it('should return the default connector ID when nameOrId is empty string', async () => { + const defaultConnectorId = 'default-connector-123'; + const mockConnector = createMockConnector({ + connectorId: defaultConnectorId, + }); + mockInferencePlugin.getDefaultConnector.mockResolvedValue(mockConnector); + + const result = await resolveConnectorId('', mockInferencePlugin, mockKibanaRequest); + + expect(mockInferencePlugin.getDefaultConnector).toHaveBeenCalledWith(mockKibanaRequest); + expect(result).toBe(defaultConnectorId); + }); + }); + + describe('when nameOrId is a connector ID', () => { + it('should return the connector ID when it exists in the connector list', async () => { + const connectorId = 'openai-gpt4-connector-id'; + const mockConnectors = [ + createMockConnector({ + name: 'OpenAI GPT-4', + connectorId, + }), + ]; + mockInferencePlugin.getConnectorList.mockResolvedValue(mockConnectors); + mockInferencePlugin.getConnectorById.mockResolvedValue(mockConnectors[0]); + + const result = await resolveConnectorId(connectorId, mockInferencePlugin, mockKibanaRequest); + + expect(mockInferencePlugin.getConnectorById).toHaveBeenCalledWith( + connectorId, + mockKibanaRequest + ); + expect(mockInferencePlugin.getConnectorList).not.toHaveBeenCalled(); + expect(result).toBe(connectorId); + }); + + it('should throw an error when connector ID is not found', async () => { + const nonExistentId = 'non-existent-connector-id'; + const mockConnectors = [ + createMockConnector({ + name: 'OpenAI GPT-4', + connectorId: 'openai-gpt4-connector-id', + }), + ]; + mockInferencePlugin.getConnectorList.mockResolvedValue(mockConnectors); + + await expect( + resolveConnectorId(nonExistentId, mockInferencePlugin, mockKibanaRequest) + ).rejects.toThrow( + `AI Connector '${nonExistentId}' not found. Available AI connectors: OpenAI GPT-4 (ID: openai-gpt4-connector-id)` + ); + }); + }); + + describe('when nameOrId is a connector name', () => { + const mockConnectors = [ + createMockConnector({ + name: 'OpenAI GPT-4', + connectorId: 'openai-gpt4-connector-id', + }), + createMockConnector({ + name: 'Azure OpenAI', + connectorId: 'azure-openai-connector-id', + }), + createMockConnector({ + name: 'Anthropic Claude', + connectorId: 'anthropic-claude-connector-id', + }), + ]; + + beforeEach(() => { + mockInferencePlugin.getConnectorList.mockResolvedValue(mockConnectors); + }); + + it('should return the connector ID when connector name exists', async () => { + const connectorName = 'OpenAI GPT-4'; + const expectedConnectorId = 'openai-gpt4-connector-id'; + + const result = await resolveConnectorId( + connectorName, + mockInferencePlugin, + mockKibanaRequest + ); + + expect(mockInferencePlugin.getConnectorList).toHaveBeenCalledWith(mockKibanaRequest); + expect(result).toBe(expectedConnectorId); + }); + + it('should perform case-sensitive name matching', async () => { + const connectorName = 'openai gpt-4'; + + await expect( + resolveConnectorId(connectorName, mockInferencePlugin, mockKibanaRequest) + ).rejects.toThrow( + `AI Connector 'openai gpt-4' not found. Available AI connectors: OpenAI GPT-4 (ID: openai-gpt4-connector-id), Azure OpenAI (ID: azure-openai-connector-id), Anthropic Claude (ID: anthropic-claude-connector-id)` + ); + }); + + it('should throw an error when connector name is not found', async () => { + const nonExistentName = 'Non-existent Connector'; + + await expect( + resolveConnectorId(nonExistentName, mockInferencePlugin, mockKibanaRequest) + ).rejects.toThrow( + `AI Connector 'Non-existent Connector' not found. Available AI connectors: OpenAI GPT-4 (ID: openai-gpt4-connector-id), Azure OpenAI (ID: azure-openai-connector-id), Anthropic Claude (ID: anthropic-claude-connector-id)` + ); + + expect(mockInferencePlugin.getConnectorList).toHaveBeenCalledWith(mockKibanaRequest); + }); + + it('should handle empty connector list gracefully', async () => { + mockInferencePlugin.getConnectorList.mockResolvedValue([]); + const connectorName = 'Any Connector'; + + await expect( + resolveConnectorId(connectorName, mockInferencePlugin, mockKibanaRequest) + ).rejects.toThrow('No AI connectors found.'); + }); + }); + + describe('edge cases', () => { + it('should handle connector list with duplicate names (first match wins)', async () => { + const duplicateConnectors = [ + createMockConnector({ name: 'Duplicate Connector', connectorId: 'first-connector-id' }), + createMockConnector({ name: 'Duplicate Connector', connectorId: 'second-connector-id' }), + ]; + + mockInferencePlugin.getConnectorList.mockResolvedValue(duplicateConnectors); + + const result = await resolveConnectorId( + 'Duplicate Connector', + mockInferencePlugin, + mockKibanaRequest + ); + + expect(result).toBe('first-connector-id'); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/utils/resolve_connector_id.ts b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/utils/resolve_connector_id.ts new file mode 100644 index 0000000000000..29568f35e73e2 --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/utils/resolve_connector_id.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { KibanaRequest } from '@kbn/core/server'; +import type { InferenceServerStart } from '@kbn/inference-plugin/server'; + +export async function resolveConnectorId( + nameOrId: string | undefined, + inferencePlugin: InferenceServerStart, + kibanaRequest: KibanaRequest +): Promise { + if (!nameOrId) { + const defaultConnector = await inferencePlugin.getDefaultConnector(kibanaRequest); + + if (!defaultConnector) { + throw new Error('No default AI connector configured'); + } + + return defaultConnector.connectorId; + } + + const connectorById = await inferencePlugin.getConnectorById(nameOrId, kibanaRequest); + + if (connectorById) { + return connectorById.connectorId; + } + + const allConnectors = await inferencePlugin.getConnectorList(kibanaRequest); + + if (!allConnectors.length) { + throw new Error(`No AI connectors found.`); + } + + const connector = allConnectors.find((c) => c.name === nameOrId); + + if (!connector) { + throw new Error( + `AI Connector '${nameOrId}' not found. Available AI connectors: ${allConnectors + .map((c) => `${c.name} (ID: ${c.connectorId})`) + .join(', ')}` + ); + } + + return connector.connectorId; +} diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/types.ts b/x-pack/platform/plugins/shared/inference_workflows/server/types.ts new file mode 100644 index 0000000000000..c3f71672d1d11 --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/server/types.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 { InferenceServerStart } from '@kbn/inference-plugin/server'; +import type { WorkflowsExtensionsServerPluginSetup } from '@kbn/workflows-extensions/server'; + +export interface InferenceWorkflowsSetupDeps { + workflowsExtensions: WorkflowsExtensionsServerPluginSetup; +} + +export interface InferenceWorkflowsStartDeps { + inference: InferenceServerStart; +} diff --git a/x-pack/platform/plugins/shared/inference_workflows/tsconfig.json b/x-pack/platform/plugins/shared/inference_workflows/tsconfig.json new file mode 100644 index 0000000000000..7fc3dcf5bac86 --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "@kbn/tsconfig-base/tsconfig.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": ["../../../../../typings/**/*", "common/**/*", "public/**/*", "server/**/*"], + "exclude": ["target/**/*"], + "kbn_references": [ + "@kbn/core", + "@kbn/i18n", + "@kbn/zod", + "@kbn/workflows", + "@kbn/workflows-extensions", + "@kbn/inference-plugin", + "@kbn/inference-common" + ] +} From a563d95d54e0632aacf041a7edc5cb9255bbab4e Mon Sep 17 00:00:00 2001 From: Yan Savitski Date: Wed, 13 May 2026 09:15:00 +0200 Subject: [PATCH 02/20] Remove ai.* workflow steps from workflows_extensions plugin Remove ai.prompt, ai.summarize, ai.classify step definitions from the workflows_extensions plugin now that they are registered externally by the new inferenceWorkflows plugin. Update CODEOWNERS and i18n config. --- .github/CODEOWNERS | 1 + .../common/steps/ai/ai_classify_step.test.ts | 134 ---- .../common/steps/ai/ai_classify_step.ts | 178 ----- .../common/steps/ai/ai_prompt_step.ts | 165 ----- .../common/steps/ai/ai_summarize_step.ts | 125 ---- .../common/steps/ai/index.ts | 37 - .../common/steps/index.ts | 1 - .../public/steps/ai/ai_classify_step.ts | 37 - .../public/steps/ai/ai_prompt_step.ts | 51 -- .../public/steps/ai/ai_summarize_step.ts | 31 - .../public/steps/index.ts | 7 - .../workflows_extensions/server/plugin.ts | 2 +- .../ai/ai_classify_step/build_prompts.test.ts | 158 ---- .../ai/ai_classify_step/build_prompts.ts | 110 --- .../steps/ai/ai_classify_step/step.test.ts | 687 ------------------ .../server/steps/ai/ai_classify_step/step.ts | 95 --- .../validate_model_response.test.ts | 416 ----------- .../validate_model_response.ts | 58 -- .../ai/ai_prompt_step/ai_prompt_step.test.ts | 539 -------------- .../server/steps/ai/ai_prompt_step/step.ts | 92 --- .../ai_summarize_step.test.ts | 491 ------------- .../ai_summarize_step/build_prompts.test.ts | 340 --------- .../ai/ai_summarize_step/build_prompts.ts | 91 --- .../server/steps/ai/ai_summarize_step/step.ts | 70 -- .../server/steps/ai/index.ts | 12 - .../ai/utils/resolve_connector_id.test.ts | 224 ------ .../steps/ai/utils/resolve_connector_id.ts | 51 -- .../server/steps/index.ts | 13 +- x-pack/.i18nrc.json | 73 +- 29 files changed, 24 insertions(+), 4265 deletions(-) delete mode 100644 src/platform/plugins/shared/workflows_extensions/common/steps/ai/ai_classify_step.test.ts delete mode 100644 src/platform/plugins/shared/workflows_extensions/common/steps/ai/ai_classify_step.ts delete mode 100644 src/platform/plugins/shared/workflows_extensions/common/steps/ai/ai_prompt_step.ts delete mode 100644 src/platform/plugins/shared/workflows_extensions/common/steps/ai/ai_summarize_step.ts delete mode 100644 src/platform/plugins/shared/workflows_extensions/common/steps/ai/index.ts delete mode 100644 src/platform/plugins/shared/workflows_extensions/public/steps/ai/ai_classify_step.ts delete mode 100644 src/platform/plugins/shared/workflows_extensions/public/steps/ai/ai_prompt_step.ts delete mode 100644 src/platform/plugins/shared/workflows_extensions/public/steps/ai/ai_summarize_step.ts delete mode 100644 src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_classify_step/build_prompts.test.ts delete mode 100644 src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_classify_step/build_prompts.ts delete mode 100644 src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_classify_step/step.test.ts delete mode 100644 src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_classify_step/step.ts delete mode 100644 src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_classify_step/validate_model_response.test.ts delete mode 100644 src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_classify_step/validate_model_response.ts delete mode 100644 src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_prompt_step/ai_prompt_step.test.ts delete mode 100644 src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_prompt_step/step.ts delete mode 100644 src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_summarize_step/ai_summarize_step.test.ts delete mode 100644 src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_summarize_step/build_prompts.test.ts delete mode 100644 src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_summarize_step/build_prompts.ts delete mode 100644 src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_summarize_step/step.ts delete mode 100644 src/platform/plugins/shared/workflows_extensions/server/steps/ai/index.ts delete mode 100644 src/platform/plugins/shared/workflows_extensions/server/steps/ai/utils/resolve_connector_id.test.ts delete mode 100644 src/platform/plugins/shared/workflows_extensions/server/steps/ai/utils/resolve_connector_id.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2034718d2deea..040c759fb15bf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1172,6 +1172,7 @@ x-pack/platform/plugins/shared/inbox @elastic/security-generative-ai x-pack/platform/plugins/shared/index_management @elastic/kibana-management x-pack/platform/plugins/shared/inference @elastic/search-kibana x-pack/platform/plugins/shared/inference_endpoint @elastic/search-kibana +x-pack/platform/plugins/shared/inference_workflows @elastic/search-kibana x-pack/platform/plugins/shared/ingest_hub @elastic/obs-onboarding-team x-pack/platform/plugins/shared/ingest_pipelines @elastic/kibana-management x-pack/platform/plugins/shared/lens @elastic/kibana-visualizations diff --git a/src/platform/plugins/shared/workflows_extensions/common/steps/ai/ai_classify_step.test.ts b/src/platform/plugins/shared/workflows_extensions/common/steps/ai/ai_classify_step.test.ts deleted file mode 100644 index 19b316049bff6..0000000000000 --- a/src/platform/plugins/shared/workflows_extensions/common/steps/ai/ai_classify_step.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { buildStructuredOutputSchema, ConfigSchema, InputSchema } from './ai_classify_step'; - -describe('ai_classify_step common', () => { - describe('schema definitions', () => { - it('ConfigSchema accepts optional connector-id', () => { - expect(ConfigSchema.safeParse({}).success).toBe(true); - expect(ConfigSchema.safeParse({ 'connector-id': 'abc' }).success).toBe(true); - }); - - it('InputSchema requires input and categories', () => { - expect(InputSchema.safeParse({}).success).toBe(false); - expect(InputSchema.safeParse({ input: 'text', categories: ['A', 'B'] }).success).toBe(true); - }); - - it('InputSchema rejects empty categories array', () => { - expect(InputSchema.safeParse({ input: 'text', categories: [] }).success).toBe(false); - }); - - it('InputSchema accepts all optional fields', () => { - const result = InputSchema.safeParse({ - input: 'text', - categories: ['A'], - instructions: 'focus on severity', - allowMultipleCategories: true, - fallbackCategory: 'Other', - includeRationale: true, - temperature: 0.5, - }); - expect(result.success).toBe(true); - }); - - it('InputSchema rejects temperature out of range', () => { - expect(InputSchema.safeParse({ input: 'x', categories: ['A'], temperature: 2 }).success).toBe( - false - ); - expect( - InputSchema.safeParse({ input: 'x', categories: ['A'], temperature: -0.1 }).success - ).toBe(false); - }); - - it('InputSchema accepts object and array inputs', () => { - expect(InputSchema.safeParse({ input: { key: 'val' }, categories: ['A'] }).success).toBe( - true - ); - expect(InputSchema.safeParse({ input: [1, 2, 3], categories: ['A'] }).success).toBe(true); - }); - }); - - describe('buildStructuredOutputSchema', () => { - it('returns schema with category field for single classification', () => { - const schema = buildStructuredOutputSchema({ - input: 'text', - categories: ['A', 'B'], - }); - - expect(schema.shape).toHaveProperty('category'); - expect(schema.shape).not.toHaveProperty('categories'); - expect(schema.shape).toHaveProperty('metadata'); - expect(schema.shape).not.toHaveProperty('rationale'); - }); - - it('returns schema with categories array for multi-label classification', () => { - const schema = buildStructuredOutputSchema({ - input: 'text', - categories: ['A', 'B'], - allowMultipleCategories: true, - }); - - expect(schema.shape).toHaveProperty('categories'); - expect(schema.shape).not.toHaveProperty('category'); - }); - - it('includes rationale field when includeRationale is true', () => { - const schema = buildStructuredOutputSchema({ - input: 'text', - categories: ['A'], - includeRationale: true, - }); - - expect(schema.shape).toHaveProperty('rationale'); - }); - - it('does not include rationale field when includeRationale is false or undefined', () => { - const withFalse = buildStructuredOutputSchema({ - input: 'text', - categories: ['A'], - includeRationale: false, - }); - expect(withFalse.shape).not.toHaveProperty('rationale'); - - const withUndefined = buildStructuredOutputSchema({ - input: 'text', - categories: ['A'], - }); - expect(withUndefined.shape).not.toHaveProperty('rationale'); - }); - - it('returns a valid Zod object schema that can parse data', () => { - const schema = buildStructuredOutputSchema({ - input: 'text', - categories: ['A', 'B'], - allowMultipleCategories: true, - includeRationale: true, - }); - - const result = schema.safeParse({ - categories: ['A'], - rationale: 'because', - metadata: { model: 'test' }, - }); - expect(result.success).toBe(true); - }); - - it('returned schema rejects data missing required fields', () => { - const schema = buildStructuredOutputSchema({ - input: 'text', - categories: ['A'], - }); - - // Missing category and metadata - const result = schema.safeParse({}); - expect(result.success).toBe(false); - }); - }); -}); diff --git a/src/platform/plugins/shared/workflows_extensions/common/steps/ai/ai_classify_step.ts b/src/platform/plugins/shared/workflows_extensions/common/steps/ai/ai_classify_step.ts deleted file mode 100644 index 70ddcd37e7fad..0000000000000 --- a/src/platform/plugins/shared/workflows_extensions/common/steps/ai/ai_classify_step.ts +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { i18n } from '@kbn/i18n'; -import { StepCategory } from '@kbn/workflows'; -import { z } from '@kbn/zod/v4'; -import type { CommonStepDefinition } from '../../step_registry/types'; - -/** - * Step type ID for the AI classify step. - */ -export const AiClassifyStepTypeId = 'ai.classify'; - -export const ConfigSchema = z.object({ - 'connector-id': z.string().optional(), -}); - -/** - * Input schema for the AI classify step. - */ -export const InputSchema = z.object({ - input: z.union([z.string(), z.array(z.unknown()), z.record(z.string(), z.unknown())]), - categories: z.array(z.string()).min(1), - instructions: z.string().optional(), - allowMultipleCategories: z.boolean().optional(), - fallbackCategory: z.string().optional(), - includeRationale: z.boolean().optional(), - temperature: z.number().min(0).max(1).optional(), -}); - -/** - * Output schema for the AI classify step. - * This is the base schema - the dynamic schema will be created based on input parameters. - */ -export const OutputSchema = z.object({ - category: z.string().optional(), - categories: z.array(z.string()).optional(), - rationale: z.string().optional(), - metadata: z.record(z.string(), z.any()), -}); - -export type AiClassifyStepConfigSchema = typeof ConfigSchema; -export type AiClassifyStepInputSchema = typeof InputSchema; -export type AiClassifyStepOutputSchema = typeof OutputSchema; - -/** - * Common step definition for AI classify step. - * This is shared between server and public implementations. - * Input and output types are automatically inferred from the schemas. - */ -export const AiClassifyStepCommonDefinition: CommonStepDefinition< - AiClassifyStepInputSchema, - AiClassifyStepOutputSchema, - AiClassifyStepConfigSchema -> = { - id: AiClassifyStepTypeId, - category: StepCategory.Ai, - label: i18n.translate('workflowsExtensionsExample.AiClassifyStep.label', { - defaultMessage: 'AI Classify', - }), - description: i18n.translate('workflowsExtensionsExample.AiClassifyStep.description', { - defaultMessage: 'Categorizes data into predefined categories using AI', - }), - documentation: { - details: i18n.translate('workflowsExtensionsExample.AiClassifyStep.documentation.details', { - defaultMessage: `The ${AiClassifyStepTypeId} step categorizes input data into predefined categories using an AI connector. The classification result can be referenced in later steps using template syntax.`, - values: { templateSyntax: '`{{ steps.stepName.output }}`' }, - }), - examples: [ - `## Basic Classification -\`\`\`yaml -- name: classify_alert - type: ${AiClassifyStepTypeId} - with: - input: "{{ steps.fetch_alert.output }}" - categories: ["Critical", "Warning", "Info"] -\`\`\` -The default AI connector configured for the workflow will be used.`, - - `## Custom Instructions -\`\`\`yaml -- name: classify_incident - type: ${AiClassifyStepTypeId} - with: - input: "{{ steps.get_incident.output }}" - categories: ["Security", "Performance", "Network", "Application"] - instructions: "Focus on root cause type. Ignore transient issues." -\`\`\``, - - `## Fallback Category -\`\`\`yaml -- name: classify_log - type: ${AiClassifyStepTypeId} - with: - input: "{{ steps.get_log.output }}" - categories: ["Authentication", "Authorization", "Data Access"] - fallbackCategory: "Unknown" -\`\`\` -When the model cannot confidently match input to defined categories, the fallback category is used.`, - - `## Multi-label Classification with Rationale -\`\`\`yaml -- name: tag_alert - type: ${AiClassifyStepTypeId} - with: - input: "{{ steps.alert_details.output }}" - categories: ["High Priority", "Security", "Performance", "User Impacting"] - allowMultipleCategories: true - includeRationale: true - instructions: "Select all applicable tags" -\`\`\` -When \`allowMultipleCategories\` is true, the output includes a \`categories\` array. When \`includeRationale\` is true, the output includes a \`rationale\` field.`, - - `## Custom Connector with Temperature -\`\`\`yaml -- name: classify_ticket - type: ${AiClassifyStepTypeId} - connector-id: "custom-classifier-model" - with: - input: "{{ steps.ticket_description.output }}" - categories: ["Bug", "Feature Request", "Support"] - temperature: 0.1 - instructions: "Prefer 'Bug' if any technical issue mentioned" -\`\`\``, - - `## Use classification in subsequent steps -\`\`\`yaml -- name: classify_severity - type: ${AiClassifyStepTypeId} - with: - input: "{{ steps.get_incident_details.output }}" - categories: ["Critical", "High", "Medium", "Low"] - includeRationale: true -- name: notify_team - type: http - with: - url: "https://api.example.com/notify" - body: - severity: "{{ steps.classify_severity.output.category }}" - reason: "{{ steps.classify_severity.output.rationale }}" -\`\`\``, - ], - }, - inputSchema: InputSchema, - outputSchema: OutputSchema, - configSchema: ConfigSchema, -}; - -/** - * Builds a dynamic Zod schema for structured output based on AI classification step inputs. - */ -export function buildStructuredOutputSchema( - params: z.infer -): typeof OutputSchema { - const { allowMultipleCategories, includeRationale } = params; - - const shape: Record = { - metadata: z.record(z.string(), z.any()), - }; - - if (allowMultipleCategories) { - shape.categories = z.array(z.string()); - } else { - shape.category = z.string(); - } - - if (includeRationale) { - shape.rationale = z.string(); - } - - return z.object(shape) as typeof OutputSchema; -} diff --git a/src/platform/plugins/shared/workflows_extensions/common/steps/ai/ai_prompt_step.ts b/src/platform/plugins/shared/workflows_extensions/common/steps/ai/ai_prompt_step.ts deleted file mode 100644 index ce27d11bace39..0000000000000 --- a/src/platform/plugins/shared/workflows_extensions/common/steps/ai/ai_prompt_step.ts +++ /dev/null @@ -1,165 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { i18n } from '@kbn/i18n'; -import { StepCategory } from '@kbn/workflows'; -import { JsonModelShapeSchema } from '@kbn/workflows/spec/schema/common/json_model_shape_schema'; -import { z } from '@kbn/zod/v4'; -import type { CommonStepDefinition } from '../../step_registry/types'; - -/** - * Step type ID for the AI prompt step. - */ -export const AiPromptStepTypeId = 'ai.prompt'; - -export const ConfigSchema = z.object({ - 'connector-id': z.string().optional(), -}); - -// Maybe we can define specific schema for metadata in the future -// For now it's a record with string keys and any values -// Because langchain returns it this format -export const MetadataSchema = z.record(z.string(), z.any()); - -/** - * Input schema for the AI prompt step. - * Uses variables structure with key->value pairs. - */ -export const InputSchema = z.object({ - prompt: z.string(), - systemPrompt: z.string().optional(), - schema: JsonModelShapeSchema.optional().describe('The schema for the output of the step.'), - temperature: z.number().min(0).max(1).optional(), -}); - -export function getStructuredOutputSchema(contentSchema: z.ZodType) { - return z.object({ - content: contentSchema, - metadata: MetadataSchema, - }); -} - -const StringOutputSchema = z.object({ - content: z.string(), - metadata: MetadataSchema, -}); - -/** - * Output schema for the AI prompt step. - * Uses variables structure with key->value pairs. - */ -export const OutputSchema = z.union([StringOutputSchema, getStructuredOutputSchema(z.unknown())]); - -export type AiPromptStepConfigSchema = typeof ConfigSchema; -export type AiPromptStepInputSchema = typeof InputSchema; -export type AiPromptStepOutputSchema = typeof OutputSchema; - -/** - * Common step definition for AI prompt step. - * This is shared between server and public implementations. - * Input and output types are automatically inferred from the schemas. - */ -export const AiPromptStepCommonDefinition: CommonStepDefinition< - AiPromptStepInputSchema, - AiPromptStepOutputSchema, - AiPromptStepConfigSchema -> = { - id: AiPromptStepTypeId, - category: StepCategory.Ai, - label: i18n.translate('workflowsExtensionsExample.AiPromptStep.label', { - defaultMessage: 'AI Prompt', - }), - description: i18n.translate('workflowsExtensionsExample.AiPromptStep.description', { - defaultMessage: 'Sends a prompt to an AI connector and returns the response', - }), - documentation: { - details: i18n.translate('workflowsExtensionsExample.AiPromptStep.documentation.details', { - defaultMessage: `The ${AiPromptStepTypeId} step sends a prompt to an AI connector and returns the response. The response can be referenced in later steps using template syntax like {templateSyntax}.`, - values: { templateSyntax: '`{{ steps.stepName.output }}`' }, // Needs to be extracted so it is not interpreted as a variable by the i18n plugin - }), - examples: [ - `## Basic AI prompt -\`\`\`yaml -- name: ask_ai - type: ${AiPromptStepTypeId} - with: - prompt: "What is the weather like today?" -\`\`\` -The default AI connector configured for the workflow will be used.`, - `## AI prompt with dynamic input -\`\`\`yaml -- name: analyze_data - type: ${AiPromptStepTypeId} - connector-id: ai_connector - with: - prompt: "Analyze this data: {{ steps.previous_step.output }}" -\`\`\``, - - `## AI prompt with structured output schema. -Output schema must be a valid JSON Schema object. -See this [JSON Schema reference](https://json-schema.org/learn/getting-started-step-by-step) for details. -\`\`\`yaml -- name: extract_info - type: ${AiPromptStepTypeId} - connector-id: my-ai-connector - with: - prompt: "Extract key information from this text: {{ workflow.input }}" - schema: - type: "object" - properties: - summary: - type: "string" - key_points: - type: "array" - items: - type: "string" -\`\`\``, - - `## AI prompt with structured output schema (JSON object syntax) -See this [JSON Schema reference](https://json-schema.org/learn/getting-started-step-by-step) for details. -\`\`\`yaml -- name: extract_info - type: ${AiPromptStepTypeId} - connector-id: my-ai-connector - with: - prompt: "Extract key information from this text: {{ workflow.input }}" - schema: { - "type":"object", - "properties":{ - "summary":{ - "type":"string" - }, - "key_points":{ - "type":"array", - "items":{ - "type":"string" - } - } - } -\`\`\``, - - `## Use AI response in subsequent steps -\`\`\`yaml -- name: get_recommendation - type: ${AiPromptStepTypeId} - connector-id: "my-ai-connector" - with: - prompt: "Provide a recommendation based on this data" -- name: process_recommendation - type: http - with: - url: "https://api.example.com/process" - body: "{{ steps.get_recommendation.output }}" -\`\`\``, - ], - }, - inputSchema: InputSchema, - outputSchema: OutputSchema, - configSchema: ConfigSchema, -}; diff --git a/src/platform/plugins/shared/workflows_extensions/common/steps/ai/ai_summarize_step.ts b/src/platform/plugins/shared/workflows_extensions/common/steps/ai/ai_summarize_step.ts deleted file mode 100644 index ce31362544d65..0000000000000 --- a/src/platform/plugins/shared/workflows_extensions/common/steps/ai/ai_summarize_step.ts +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { i18n } from '@kbn/i18n'; -import { StepCategory } from '@kbn/workflows'; -import { z } from '@kbn/zod/v4'; -import type { CommonStepDefinition } from '../../step_registry/types'; - -/** - * Step type ID for the AI summarize step. - */ -export const AiSummarizeStepTypeId = 'ai.summarize'; - -export const ConfigSchema = z.object({ - 'connector-id': z.string().optional(), -}); - -/** - * Input schema for the AI summarize step. - */ -export const InputSchema = z.object({ - input: z.union([z.string(), z.array(z.unknown()), z.record(z.string(), z.unknown())]), - instructions: z.string().optional(), - maxLength: z.number().int().positive().optional(), - temperature: z.number().min(0).max(1).optional(), -}); - -/** - * Output schema for the AI summarize step. - */ -export const OutputSchema = z.object({ - content: z.string(), - metadata: z.record(z.string(), z.any()).optional(), -}); - -export type AiSummarizeStepConfigSchema = typeof ConfigSchema; -export type AiSummarizeStepInputSchema = typeof InputSchema; -export type AiSummarizeStepOutputSchema = typeof OutputSchema; - -/** - * Common step definition for AI summarize step. - * This is shared between server and public implementations. - * Input and output types are automatically inferred from the schemas. - */ -export const AiSummarizeStepCommonDefinition: CommonStepDefinition< - AiSummarizeStepInputSchema, - AiSummarizeStepOutputSchema, - AiSummarizeStepConfigSchema -> = { - id: AiSummarizeStepTypeId, - category: StepCategory.Ai, - label: i18n.translate('workflowsExtensionsExample.AiSummarizeStep.label', { - defaultMessage: 'AI Summarize', - }), - description: i18n.translate('workflowsExtensionsExample.AiSummarizeStep.description', { - defaultMessage: 'Generates a summary of the provided content using AI', - }), - documentation: { - details: i18n.translate('workflowsExtensionsExample.AiSummarizeStep.documentation.details', { - defaultMessage: `The ${AiSummarizeStepTypeId} step generates a concise summary of the provided content using an AI connector. The summary can be referenced in later steps using template syntax.`, - values: { templateSyntax: '`{{ steps.stepName.output }}`' }, - }), - examples: [ - `## Basic Summarization -\`\`\`yaml -- name: summarize_logs - type: ${AiSummarizeStepTypeId} - with: - input: "{{ steps.fetch_logs.output }}" -\`\`\` -The default AI connector configured for the workflow will be used.`, - - `## Data Summarization -\`\`\`yaml -- name: summarize_alerts - type: ${AiSummarizeStepTypeId} - with: - input: "{{ steps.fetch_alerts.output }}" -\`\`\` -Supports objects and arrays as input.`, - - `## Custom Instructions -\`\`\`yaml -- name: summarize_alerts - type: ${AiSummarizeStepTypeId} - with: - input: "{{ steps.get_alerts.output }}" - instructions: "Use bullet points. Focus on root cause. Limit to 3 key points." -\`\`\``, - - `## Length Control -\`\`\`yaml -- name: summarize_for_pagerduty - type: ${AiSummarizeStepTypeId} - with: - input: "{{ steps.error_details.output }}" - maxLength: 100 - instructions: "One sentence summary suitable for alert title" -\`\`\``, - - `## Use AI summary in subsequent steps -\`\`\`yaml -- name: summarize_incident - type: ${AiSummarizeStepTypeId} - with: - input: "{{ steps.get_incident_details.output }}" - instructions: "Concise summary for notification" -- name: send_notification - type: http - with: - url: "https://api.example.com/notify" - body: "{{ steps.summarize_incident.output.content }}" -\`\`\``, - ], - }, - inputSchema: InputSchema, - outputSchema: OutputSchema, - configSchema: ConfigSchema, -}; diff --git a/src/platform/plugins/shared/workflows_extensions/common/steps/ai/index.ts b/src/platform/plugins/shared/workflows_extensions/common/steps/ai/index.ts deleted file mode 100644 index e96f22b9d1ac7..0000000000000 --- a/src/platform/plugins/shared/workflows_extensions/common/steps/ai/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -export { - AiPromptStepCommonDefinition, - AiPromptStepTypeId, - InputSchema as AiPromptInputSchema, - OutputSchema as AiPromptOutputSchema, - getStructuredOutputSchema, - type AiPromptStepConfigSchema, - type AiPromptStepInputSchema, - type AiPromptStepOutputSchema, -} from './ai_prompt_step'; - -export { - AiSummarizeStepCommonDefinition, - AiSummarizeStepTypeId, - type AiSummarizeStepConfigSchema, - type AiSummarizeStepInputSchema, - type AiSummarizeStepOutputSchema, -} from './ai_summarize_step'; - -export * from './ai_prompt_step'; -export { - AiClassifyStepCommonDefinition, - AiClassifyStepTypeId, - type AiClassifyStepConfigSchema, - type AiClassifyStepInputSchema, - type AiClassifyStepOutputSchema, - buildStructuredOutputSchema, -} from './ai_classify_step'; diff --git a/src/platform/plugins/shared/workflows_extensions/common/steps/index.ts b/src/platform/plugins/shared/workflows_extensions/common/steps/index.ts index 442170ddadaa3..8bc3ec9dd8c89 100644 --- a/src/platform/plugins/shared/workflows_extensions/common/steps/index.ts +++ b/src/platform/plugins/shared/workflows_extensions/common/steps/index.ts @@ -7,5 +7,4 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export * from './ai'; export * from './data'; diff --git a/src/platform/plugins/shared/workflows_extensions/public/steps/ai/ai_classify_step.ts b/src/platform/plugins/shared/workflows_extensions/public/steps/ai/ai_classify_step.ts deleted file mode 100644 index 9b1a8bae07c20..0000000000000 --- a/src/platform/plugins/shared/workflows_extensions/public/steps/ai/ai_classify_step.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import React from 'react'; -import { - AiClassifyStepCommonDefinition, - buildStructuredOutputSchema, -} from '../../../common/steps/ai'; -import { createPublicStepDefinition } from '../../step_registry/types'; - -export const AiClassifyStepDefinition = createPublicStepDefinition({ - ...AiClassifyStepCommonDefinition, - icon: React.lazy(() => - import('@elastic/eui/es/components/icon/assets/product_agent').then(({ icon }) => ({ - default: icon, - })) - ), - editorHandlers: { - config: { - 'connector-id': { - connectorIdSelection: { - connectorTypes: ['inference.unified_completion', 'bedrock', 'gen-ai', 'gemini'], - enableCreation: false, - }, - }, - }, - dynamicSchema: { - getOutputSchema: ({ input }) => buildStructuredOutputSchema(input), - }, - }, -}); diff --git a/src/platform/plugins/shared/workflows_extensions/public/steps/ai/ai_prompt_step.ts b/src/platform/plugins/shared/workflows_extensions/public/steps/ai/ai_prompt_step.ts deleted file mode 100644 index 2afe4f0f8f2ce..0000000000000 --- a/src/platform/plugins/shared/workflows_extensions/public/steps/ai/ai_prompt_step.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import React from 'react'; -import { fromJSONSchema } from '@kbn/zod/v4/from_json_schema'; -import { - AiPromptOutputSchema, - AiPromptStepCommonDefinition, - getStructuredOutputSchema, -} from '../../../common/steps/ai'; -import { createPublicStepDefinition } from '../../step_registry/types'; - -export const AiPromptStepDefinition = createPublicStepDefinition({ - ...AiPromptStepCommonDefinition, - icon: React.lazy(() => - import('@elastic/eui/es/components/icon/assets/product_agent').then(({ icon }) => ({ - default: icon, - })) - ), - editorHandlers: { - config: { - 'connector-id': { - connectorIdSelection: { - connectorTypes: ['inference.unified_completion', 'bedrock', 'gen-ai', 'gemini'], - enableCreation: false, - }, - }, - }, - dynamicSchema: { - getOutputSchema: ({ input }) => { - if (!input.schema) { - return AiPromptOutputSchema; - } - - const zodSchema = fromJSONSchema(input.schema as Record); - - if (!zodSchema) { - return AiPromptOutputSchema; - } - - return getStructuredOutputSchema(zodSchema); - }, - }, - }, -}); diff --git a/src/platform/plugins/shared/workflows_extensions/public/steps/ai/ai_summarize_step.ts b/src/platform/plugins/shared/workflows_extensions/public/steps/ai/ai_summarize_step.ts deleted file mode 100644 index 15e386be2dbe5..0000000000000 --- a/src/platform/plugins/shared/workflows_extensions/public/steps/ai/ai_summarize_step.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import React from 'react'; -import { AiSummarizeStepCommonDefinition } from '../../../common/steps/ai'; -import { createPublicStepDefinition } from '../../step_registry/types'; - -export const AiSummarizeStepDefinition = createPublicStepDefinition({ - ...AiSummarizeStepCommonDefinition, - icon: React.lazy(() => - import('@elastic/eui/es/components/icon/assets/product_agent').then(({ icon }) => ({ - default: icon, - })) - ), - editorHandlers: { - config: { - 'connector-id': { - connectorIdSelection: { - connectorTypes: ['inference.unified_completion', 'bedrock', 'gen-ai', 'gemini'], - enableCreation: false, - }, - }, - }, - }, -}); diff --git a/src/platform/plugins/shared/workflows_extensions/public/steps/index.ts b/src/platform/plugins/shared/workflows_extensions/public/steps/index.ts index 552e4ae2e768d..4348a50b15aaa 100644 --- a/src/platform/plugins/shared/workflows_extensions/public/steps/index.ts +++ b/src/platform/plugins/shared/workflows_extensions/public/steps/index.ts @@ -29,13 +29,6 @@ export const registerInternalStepDefinitions = (stepRegistry: PublicStepRegistry stepRegistry.register(() => import('./data/data_aggregate_step').then((m) => m.dataAggregateStepDefinition) ); - stepRegistry.register(() => import('./ai/ai_prompt_step').then((m) => m.AiPromptStepDefinition)); - stepRegistry.register(() => - import('./ai/ai_summarize_step').then((m) => m.AiSummarizeStepDefinition) - ); - stepRegistry.register(() => - import('./ai/ai_classify_step').then((m) => m.AiClassifyStepDefinition) - ); stepRegistry.register(() => import('./data/data_stringify_json_step').then((m) => m.dataStringifyJsonStepDefinition) ); diff --git a/src/platform/plugins/shared/workflows_extensions/server/plugin.ts b/src/platform/plugins/shared/workflows_extensions/server/plugin.ts index 3d620a64e56a7..c4583ece30567 100644 --- a/src/platform/plugins/shared/workflows_extensions/server/plugin.ts +++ b/src/platform/plugins/shared/workflows_extensions/server/plugin.ts @@ -69,7 +69,7 @@ export class WorkflowsExtensionsServerPlugin registerGetStepDefinitionsRoute(router, this.stepRegistry); registerGetTriggerDefinitionsRoute(router, this.triggerRegistry); - registerInternalStepDefinitions(core, this.stepRegistry); + registerInternalStepDefinitions(this.stepRegistry); registerInternalTriggerDefinitions(this.triggerRegistry); return { diff --git a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_classify_step/build_prompts.test.ts b/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_classify_step/build_prompts.test.ts deleted file mode 100644 index d8db43520b276..0000000000000 --- a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_classify_step/build_prompts.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { - buildClassificationRequestPart, - buildDataPart, - buildInstructionsPart, - buildSystemPart, -} from './build_prompts'; - -describe('build_prompts', () => { - describe('buildSystemPart', () => { - it('returns a system-role message with classification rules', () => { - const parts = buildSystemPart(); - expect(parts).toHaveLength(1); - expect(parts[0].role).toBe('system'); - expect(parts[0].content).toContain('Output ONLY valid JSON'); - expect(parts[0].content).toContain('Categories are case-sensitive'); - }); - }); - - describe('buildDataPart', () => { - it('wraps object input as json', () => { - const parts = buildDataPart({ foo: 'bar' }); - expect(parts).toHaveLength(1); - expect(parts[0].role).toBe('user'); - expect(parts[0].content).toContain('```json'); - expect(parts[0].content).toContain(JSON.stringify({ foo: 'bar' })); - }); - - it('wraps string input as text', () => { - const parts = buildDataPart('hello world'); - expect(parts).toHaveLength(1); - expect(parts[0].content).toContain('```text'); - expect(parts[0].content).toContain('hello world'); - }); - - it('handles null input (typeof null === "object") by JSON stringifying it', () => { - const parts = buildDataPart(null); - expect(parts[0].content).toContain('```json'); - expect(parts[0].content).toContain('null'); - }); - - it('handles array input via the json path', () => { - const parts = buildDataPart([1, 2, 3]); - expect(parts[0].content).toContain('```json'); - expect(parts[0].content).toContain('[1,2,3]'); - }); - - it('handles number input via the text path', () => { - const parts = buildDataPart(42); - expect(parts[0].content).toContain('```text'); - expect(parts[0].content).toContain('42'); - }); - - it('handles boolean input via the text path', () => { - const parts = buildDataPart(true); - expect(parts[0].content).toContain('```text'); - expect(parts[0].content).toContain('true'); - }); - }); - - describe('buildInstructionsPart', () => { - it('returns empty array for undefined instructions', () => { - expect(buildInstructionsPart(undefined)).toEqual([]); - }); - - it('returns empty array for empty string instructions', () => { - expect(buildInstructionsPart('')).toEqual([]); - }); - - it('returns a user-role message containing the instructions', () => { - const parts = buildInstructionsPart('focus on severity'); - expect(parts).toHaveLength(1); - expect(parts[0].role).toBe('user'); - expect(parts[0].content).toContain('focus on severity'); - }); - }); - - describe('buildClassificationRequestPart', () => { - it('renders all categories as list items', () => { - const parts = buildClassificationRequestPart({ - categories: ['Urgent', 'Normal', 'Low'], - allowMultipleCategories: false, - includeRationale: false, - }); - expect(parts[0].content).toContain('- Urgent'); - expect(parts[0].content).toContain('- Normal'); - expect(parts[0].content).toContain('- Low'); - }); - - it('includes fallback category rule when provided', () => { - const parts = buildClassificationRequestPart({ - categories: ['A', 'B'], - allowMultipleCategories: false, - fallbackCategory: 'Other', - includeRationale: false, - }); - expect(parts[0].content).toContain('fallback category: "Other"'); - }); - - it('includes single-category rules when allowMultipleCategories is false', () => { - const parts = buildClassificationRequestPart({ - categories: ['A'], - allowMultipleCategories: false, - includeRationale: false, - }); - expect(parts[0].content).toContain('exactly ONE category'); - expect(parts[0].content).not.toContain('multiple categories'); - }); - - it('includes multi-category rules when allowMultipleCategories is true', () => { - const parts = buildClassificationRequestPart({ - categories: ['A', 'B'], - allowMultipleCategories: true, - includeRationale: false, - }); - expect(parts[0].content).toContain('multiple categories'); - expect(parts[0].content).not.toContain('exactly ONE'); - }); - - it('includes rationale rule when includeRationale is true', () => { - const parts = buildClassificationRequestPart({ - categories: ['A'], - allowMultipleCategories: false, - includeRationale: true, - }); - expect(parts[0].content).toContain('"rationale" field'); - }); - - it('does not include rationale rule when includeRationale is false', () => { - const parts = buildClassificationRequestPart({ - categories: ['A'], - allowMultipleCategories: false, - includeRationale: false, - }); - expect(parts[0].content).not.toContain('rationale'); - }); - - it('handles category names containing special characters', () => { - const categories = ['Category "A"', 'Category\nB', 'Category\\C']; - const parts = buildClassificationRequestPart({ - categories, - allowMultipleCategories: false, - includeRationale: false, - }); - categories.forEach((cat) => { - expect(parts[0].content).toContain(`- ${cat}`); - }); - }); - }); -}); diff --git a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_classify_step/build_prompts.ts b/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_classify_step/build_prompts.ts deleted file mode 100644 index 0a74ef43052ce..0000000000000 --- a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_classify_step/build_prompts.ts +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { MessageFieldWithRole } from '@langchain/core/messages'; - -export function buildSystemPart(): MessageFieldWithRole[] { - return [ - { - role: 'system', - content: ` -You are a specialized classification engine that categorizes data into predefined categories. - -# CRITICAL RULES: -- Output ONLY valid JSON matching the exact format specified -- Your response must be PLAIN JSON with NO formatting, NO code blocks, NO markdown -- DO NOT wrap your response in \`\`\`json or \`\`\` blocks -- DO NOT include any text before or after the JSON -- The output must be a raw JSON string that can be parsed directly -- Categories are case-sensitive and must match exactly as provided -- Only use categories from the available categories list -`.trim(), - }, - ]; -} - -export function buildDataPart(input: unknown): MessageFieldWithRole[] { - const inputType = typeof input === 'object' ? 'json' : 'text'; - let resolvedInput = input; - - if (inputType === 'json') { - resolvedInput = JSON.stringify(input); - } - - return [ - { - role: 'user', - content: `# DATA TO CLASSIFY: -\`\`\`${inputType} -${resolvedInput} -\`\`\` -`.trim(), - }, - ]; -} - -export function buildInstructionsPart(instructions: string | undefined): MessageFieldWithRole[] { - if (!instructions) { - return []; - } - - return [ - { - role: 'user', - content: `# ADDITIONAL CLASSIFICATION INSTRUCTIONS: -${instructions} -`, - }, - ]; -} - -export function buildClassificationRequestPart(params: { - categories: string[]; - allowMultipleCategories: boolean; - fallbackCategory?: string; - includeRationale: boolean; -}): MessageFieldWithRole[] { - const { categories, allowMultipleCategories, fallbackCategory, includeRationale } = params; - - const classificationRules: string[] = []; - - if (fallbackCategory) { - classificationRules.push( - `If the input does not clearly match any defined category, use the fallback category: "${fallbackCategory}"` - ); - } - - if (allowMultipleCategories) { - classificationRules.push( - 'You may select multiple categories if the input matches more than one category' - ); - classificationRules.push('Return all matching categories in the "categories" array'); - } else { - classificationRules.push('You must select exactly ONE category from the provided list'); - classificationRules.push('Return the selected category in the "category" field'); - } - - if (includeRationale) { - classificationRules.push( - 'You MUST provide a clear, concise explanation in the "rationale" field explaining why you chose this category/categories' - ); - } - return [ - { - role: 'user', - content: ` -AVAILABLE CATEGORIES: -${categories.map((cat) => `- ${cat}`).join('\n')} - -CLASSIFICATION RULES: -${classificationRules.map((rule) => rule.trim()).join('\n- ')} -`.trim(), - }, - ]; -} diff --git a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_classify_step/step.test.ts b/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_classify_step/step.test.ts deleted file mode 100644 index 871cd519b1319..0000000000000 --- a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_classify_step/step.test.ts +++ /dev/null @@ -1,687 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { CoreSetup, KibanaRequest } from '@kbn/core/server'; -import type { InferenceServerStart } from '@kbn/inference-plugin/server'; - -// Mock all external dependencies -jest.mock('./build_prompts', () => ({ - buildSystemPart: jest.fn(), - buildDataPart: jest.fn(), - buildInstructionsPart: jest.fn(), - buildClassificationRequestPart: jest.fn(), -})); - -jest.mock('./validate_model_response', () => ({ - validateModelResponse: jest.fn(), -})); - -jest.mock('../../../../common/steps/ai', () => ({ - AiClassifyStepCommonDefinition: { - id: 'ai.classify', - inputSchema: {}, - outputSchema: {}, - configSchema: {}, - }, - buildStructuredOutputSchema: jest.fn(), -})); - -jest.mock('../../../step_registry/types', () => ({ - createServerStepDefinition: jest.fn((definition) => definition), -})); - -jest.mock('../utils/resolve_connector_id', () => ({ - resolveConnectorId: jest.fn(), -})); - -import { - buildClassificationRequestPart, - buildDataPart, - buildInstructionsPart, - buildSystemPart, -} from './build_prompts'; -import { aiClassifyStepDefinition } from './step'; -import { validateModelResponse } from './validate_model_response'; -import { buildStructuredOutputSchema } from '../../../../common/steps/ai'; -import type { ContextManager, StepHandlerContext } from '../../../step_registry/types'; -import { createServerStepDefinition } from '../../../step_registry/types'; -import type { WorkflowsExtensionsServerPluginStartDeps } from '../../../types'; -import { resolveConnectorId } from '../utils/resolve_connector_id'; - -const mockBuildSystemPart = buildSystemPart as jest.MockedFunction; -const mockBuildDataPart = buildDataPart as jest.MockedFunction; -const mockBuildInstructionsPart = buildInstructionsPart as jest.MockedFunction< - typeof buildInstructionsPart ->; -const mockBuildClassificationRequestPart = buildClassificationRequestPart as jest.MockedFunction< - typeof buildClassificationRequestPart ->; -const mockValidateModelResponse = validateModelResponse as jest.MockedFunction< - typeof validateModelResponse ->; -const mockBuildStructuredOutputSchema = buildStructuredOutputSchema as jest.MockedFunction< - typeof buildStructuredOutputSchema ->; -const mockCreateServerStepDefinition = createServerStepDefinition as jest.MockedFunction< - typeof createServerStepDefinition ->; -const mockResolveConnectorId = resolveConnectorId as jest.MockedFunction; - -describe('aiClassifyStepDefinition', () => { - let mockCoreSetup: jest.Mocked>; - let mockInference: jest.Mocked; - let mockContextManager: jest.Mocked; - let mockContext: StepHandlerContext; - let mockChatModel: any; - let mockRunnable: any; - let mockAbortController: AbortController; - let mockSchema: any; - - beforeEach(() => { - jest.clearAllMocks(); - - mockAbortController = new AbortController(); - - // Mock schema - mockSchema = { - parse: jest.fn(), - safeParse: jest.fn(), - }; - - // Mock chat model runnable - mockRunnable = { - invoke: jest.fn().mockResolvedValue({ - parsed: { - category: 'test-category', - metadata: {}, - }, - raw: { - response_metadata: { - model: 'test-model', - finish_reason: 'stop', - }, - }, - }), - }; - - // Mock chat model - mockChatModel = { - invoke: jest.fn(), - withStructuredOutput: jest.fn().mockReturnValue(mockRunnable), - }; - - // Mock inference service - mockInference = { - getChatModel: jest.fn().mockResolvedValue(mockChatModel), - } as any; - - // Mock core setup - mockCoreSetup = { - getStartServices: jest.fn().mockResolvedValue([ - {}, // core - { inference: mockInference }, // plugins - {}, // own plugin - ]), - } as any; - - // Mock context manager - mockContextManager = { - getFakeRequest: jest.fn().mockReturnValue({} as KibanaRequest), - getContext: jest.fn(), - getScopedEsClient: jest.fn(), - renderInputTemplate: jest.fn(), - }; - - // Mock step handler context - mockContext = { - config: { - 'connector-id': 'test-connector-id', - }, - input: { - input: 'Test input data', - categories: ['category1', 'category2'], - instructions: 'Test instructions', - allowMultipleCategories: false, - fallbackCategory: undefined, - includeRationale: false, - temperature: 0.7, - }, - rawInput: { - input: 'Test input data', - categories: ['category1', 'category2'], - instructions: 'Test instructions', - allowMultipleCategories: false, - fallbackCategory: undefined, - includeRationale: false, - temperature: 0.7, - }, - contextManager: mockContextManager, - logger: { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }, - abortSignal: mockAbortController.signal, - stepId: 'test-step-id', - stepType: 'ai.classify', - } as any; - - // Setup default mock implementations - mockResolveConnectorId.mockResolvedValue('resolved-connector-id'); - mockBuildStructuredOutputSchema.mockReturnValue(mockSchema as any); - mockBuildSystemPart.mockReturnValue([{ role: 'system', content: 'System prompt' }]); - mockBuildDataPart.mockReturnValue([{ role: 'user', content: 'Data part' }]); - mockBuildInstructionsPart.mockReturnValue([{ role: 'user', content: 'Instructions part' }]); - mockBuildClassificationRequestPart.mockReturnValue([ - { role: 'user', content: 'Classification request' }, - ]); - mockValidateModelResponse.mockImplementation(() => {}); - }); - - describe('step definition creation', () => { - it('should create step definition with correct structure', () => { - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - - expect(mockCreateServerStepDefinition).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'ai.classify', - handler: expect.any(Function), - }) - ); - expect(stepDefinition).toBeDefined(); - expect(stepDefinition.handler).toBeDefined(); - }); - }); - - describe('handler execution', () => { - it('should successfully classify data with single category', async () => { - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - const result = await stepDefinition.handler(mockContext); - - expect(mockCoreSetup.getStartServices).toHaveBeenCalled(); - expect(mockResolveConnectorId).toHaveBeenCalledWith( - 'test-connector-id', - mockInference, - expect.any(Object) - ); - expect(mockInference.getChatModel).toHaveBeenCalledWith({ - connectorId: 'resolved-connector-id', - request: expect.any(Object), - chatModelOptions: { - temperature: 0.7, - maxRetries: 0, - }, - }); - expect(result).toEqual({ - output: { - category: 'test-category', - metadata: { - model: 'test-model', - finish_reason: 'stop', - }, - }, - }); - }); - - it('should successfully classify data with multiple categories', async () => { - mockContext.input.allowMultipleCategories = true; - mockRunnable.invoke.mockResolvedValueOnce({ - parsed: { - categories: ['category1', 'category2'], - metadata: {}, - }, - raw: { - response_metadata: { - model: 'test-model', - }, - }, - }); - - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - const result = await stepDefinition.handler(mockContext); - - expect(result.output).toEqual({ - categories: ['category1', 'category2'], - metadata: { - model: 'test-model', - }, - }); - }); - - it('should include rationale when requested', async () => { - mockContext.input.includeRationale = true; - mockRunnable.invoke.mockResolvedValueOnce({ - parsed: { - category: 'category1', - rationale: 'This is the rationale', - metadata: {}, - }, - raw: { - response_metadata: { - model: 'test-model', - }, - }, - }); - - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - const result = await stepDefinition.handler(mockContext); - - expect(result.output).toEqual({ - category: 'category1', - rationale: 'This is the rationale', - metadata: { - model: 'test-model', - }, - }); - }); - - it('should use fallback category when provided', async () => { - mockContext.input.fallbackCategory = 'fallback'; - mockRunnable.invoke.mockResolvedValueOnce({ - parsed: { - category: 'fallback', - metadata: {}, - }, - raw: { - response_metadata: { - model: 'test-model', - }, - }, - }); - - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - const result = await stepDefinition.handler(mockContext); - - expect(result).toBeDefined(); - expect(result.output).toBeDefined(); - expect(result.output!.category).toBe('fallback'); - expect(mockBuildClassificationRequestPart).toHaveBeenCalledWith( - expect.objectContaining({ - fallbackCategory: 'fallback', - }) - ); - }); - - it('should handle custom temperature setting', async () => { - mockContext.input.temperature = 0.3; - - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - await stepDefinition.handler(mockContext); - - expect(mockInference.getChatModel).toHaveBeenCalledWith( - expect.objectContaining({ - chatModelOptions: expect.objectContaining({ - temperature: 0.3, - }), - }) - ); - }); - - it('should handle undefined temperature', async () => { - mockContext.input.temperature = undefined; - - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - await stepDefinition.handler(mockContext); - - expect(mockInference.getChatModel).toHaveBeenCalledWith( - expect.objectContaining({ - chatModelOptions: expect.objectContaining({ - temperature: undefined, - }), - }) - ); - }); - }); - - describe('prompt building', () => { - it('should build correct prompt structure', async () => { - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - await stepDefinition.handler(mockContext); - - expect(mockBuildSystemPart).toHaveBeenCalled(); - expect(mockBuildDataPart).toHaveBeenCalledWith('Test input data'); - expect(mockBuildClassificationRequestPart).toHaveBeenCalledWith({ - categories: ['category1', 'category2'], - allowMultipleCategories: false, - fallbackCategory: undefined, - includeRationale: false, - }); - expect(mockBuildInstructionsPart).toHaveBeenCalledWith('Test instructions'); - }); - - it('should handle different input types', async () => { - mockContext.input.input = { key: 'value' }; - - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - await stepDefinition.handler(mockContext); - - expect(mockBuildDataPart).toHaveBeenCalledWith({ key: 'value' }); - }); - - it('should handle array input', async () => { - mockContext.input.input = ['item1', 'item2']; - - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - await stepDefinition.handler(mockContext); - - expect(mockBuildDataPart).toHaveBeenCalledWith(['item1', 'item2']); - }); - - it('should handle undefined instructions', async () => { - mockContext.input.instructions = undefined; - - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - await stepDefinition.handler(mockContext); - - expect(mockBuildInstructionsPart).toHaveBeenCalledWith(undefined); - }); - }); - - describe('schema and validation', () => { - it('should build structured output schema from input', async () => { - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - await stepDefinition.handler(mockContext); - - expect(mockBuildStructuredOutputSchema).toHaveBeenCalledWith(mockContext.input); - }); - - it('should pass Zod schema directly to withStructuredOutput', async () => { - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - await stepDefinition.handler(mockContext); - - expect(mockChatModel.withStructuredOutput).toHaveBeenCalledWith(mockSchema, { - name: 'classify', - includeRaw: true, - method: 'json', - }); - }); - - it('should validate model response', async () => { - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - await stepDefinition.handler(mockContext); - - expect(mockValidateModelResponse).toHaveBeenCalledWith({ - modelResponse: { - category: 'test-category', - metadata: {}, - }, - expectedCategories: ['category1', 'category2'], - fallbackCategory: undefined, - responseMetadata: { - model: 'test-model', - finish_reason: 'stop', - }, - }); - }); - - it('should propagate validation errors', async () => { - mockValidateModelResponse.mockImplementationOnce(() => { - throw new Error('Validation failed'); - }); - - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - - await expect(stepDefinition.handler(mockContext)).rejects.toThrow('Validation failed'); - }); - }); - - describe('model invocation', () => { - it('should invoke model with correct parameters', async () => { - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - await stepDefinition.handler(mockContext); - - expect(mockRunnable.invoke).toHaveBeenCalledWith( - [ - { role: 'system', content: 'System prompt' }, - { role: 'user', content: 'Data part' }, - { role: 'user', content: 'Classification request' }, - { role: 'user', content: 'Instructions part' }, - ], - { signal: mockAbortController.signal } - ); - }); - - it('should respect abort signal', async () => { - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - await stepDefinition.handler(mockContext); - - expect(mockRunnable.invoke).toHaveBeenCalledWith( - expect.any(Array), - expect.objectContaining({ - signal: mockAbortController.signal, - }) - ); - }); - - it('should handle model invocation errors', async () => { - mockRunnable.invoke.mockRejectedValueOnce(new Error('Model invocation failed')); - - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - - await expect(stepDefinition.handler(mockContext)).rejects.toThrow('Model invocation failed'); - }); - - it('should set maxRetries to 0', async () => { - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - await stepDefinition.handler(mockContext); - - expect(mockInference.getChatModel).toHaveBeenCalledWith( - expect.objectContaining({ - chatModelOptions: expect.objectContaining({ - maxRetries: 0, - }), - }) - ); - }); - }); - - describe('connector resolution', () => { - it('should resolve connector id from config', async () => { - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - await stepDefinition.handler(mockContext); - - expect(mockResolveConnectorId).toHaveBeenCalledWith( - 'test-connector-id', - mockInference, - expect.any(Object) - ); - }); - - it('should handle undefined connector id in config', async () => { - mockContext.config['connector-id'] = undefined; - - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - await stepDefinition.handler(mockContext); - - expect(mockResolveConnectorId).toHaveBeenCalledWith( - undefined, - mockInference, - expect.any(Object) - ); - }); - - it('should use resolved connector id for chat model', async () => { - mockResolveConnectorId.mockResolvedValueOnce('custom-resolved-id'); - - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - await stepDefinition.handler(mockContext); - - expect(mockInference.getChatModel).toHaveBeenCalledWith( - expect.objectContaining({ - connectorId: 'custom-resolved-id', - }) - ); - }); - - it('should handle connector resolution errors', async () => { - mockResolveConnectorId.mockRejectedValueOnce(new Error('Connector not found')); - - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - - await expect(stepDefinition.handler(mockContext)).rejects.toThrow('Connector not found'); - }); - }); - - describe('output formatting', () => { - it('should merge parsed response with metadata', async () => { - mockRunnable.invoke.mockResolvedValueOnce({ - parsed: { - category: 'category1', - rationale: 'Test rationale', - }, - raw: { - response_metadata: { - model: 'gpt-4', - tokens: 100, - custom: 'value', - }, - }, - }); - - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - const result = await stepDefinition.handler(mockContext); - - expect(result.output).toEqual({ - category: 'category1', - rationale: 'Test rationale', - metadata: { - model: 'gpt-4', - tokens: 100, - custom: 'value', - }, - }); - }); - - it('should handle empty response metadata', async () => { - mockRunnable.invoke.mockResolvedValueOnce({ - parsed: { - category: 'category1', - }, - raw: { - response_metadata: {}, - }, - }); - - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - const result = await stepDefinition.handler(mockContext); - - expect(result.output).toEqual({ - category: 'category1', - metadata: {}, - }); - }); - - it('should preserve all parsed fields in output', async () => { - mockRunnable.invoke.mockResolvedValueOnce({ - parsed: { - category: 'category1', - rationale: 'Test rationale', - confidence: 0.95, - }, - raw: { - response_metadata: { - model: 'test-model', - }, - }, - }); - - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - const result = await stepDefinition.handler(mockContext); - - expect(result.output).toEqual({ - category: 'category1', - rationale: 'Test rationale', - confidence: 0.95, - metadata: { - model: 'test-model', - }, - }); - }); - }); - - describe('context manager integration', () => { - it('should use fake request from context manager', async () => { - const fakeRequest = { id: 'fake-request' } as KibanaRequest; - mockContextManager.getFakeRequest.mockReturnValue(fakeRequest); - - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - await stepDefinition.handler(mockContext); - - expect(mockContextManager.getFakeRequest).toHaveBeenCalledTimes(2); - expect(mockResolveConnectorId).toHaveBeenCalledWith( - 'test-connector-id', - mockInference, - fakeRequest - ); - expect(mockInference.getChatModel).toHaveBeenCalledWith( - expect.objectContaining({ - request: fakeRequest, - }) - ); - }); - }); - - describe('edge cases', () => { - it('should handle empty categories array', async () => { - mockContext.input.categories = []; - - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - await stepDefinition.handler(mockContext); - - expect(mockBuildClassificationRequestPart).toHaveBeenCalledWith( - expect.objectContaining({ - categories: [], - }) - ); - }); - - it('should handle empty string input', async () => { - mockContext.input.input = ''; - - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - await stepDefinition.handler(mockContext); - - expect(mockBuildDataPart).toHaveBeenCalledWith(''); - }); - - it('should handle complex nested object input', async () => { - const complexInput = { - nested: { - deep: { - value: 'test', - array: [1, 2, 3], - }, - }, - }; - mockContext.input.input = complexInput; - - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - await stepDefinition.handler(mockContext); - - expect(mockBuildDataPart).toHaveBeenCalledWith(complexInput); - }); - - it('should handle getChatModel failure', async () => { - mockInference.getChatModel.mockRejectedValueOnce(new Error('Chat model unavailable')); - - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - - await expect(stepDefinition.handler(mockContext)).rejects.toThrow('Chat model unavailable'); - }); - - it('should handle getStartServices failure', async () => { - mockCoreSetup.getStartServices.mockRejectedValueOnce(new Error('Services unavailable')); - - const stepDefinition = aiClassifyStepDefinition(mockCoreSetup); - - await expect(stepDefinition.handler(mockContext)).rejects.toThrow('Services unavailable'); - }); - }); -}); diff --git a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_classify_step/step.ts b/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_classify_step/step.ts deleted file mode 100644 index d7c82ac1aa7b2..0000000000000 --- a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_classify_step/step.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { MessageFieldWithRole } from '@langchain/core/messages'; -import type { CoreSetup } from '@kbn/core/server'; -import { - buildClassificationRequestPart, - buildDataPart, - buildInstructionsPart, - buildSystemPart, -} from './build_prompts'; -import { validateModelResponse } from './validate_model_response'; -import { - AiClassifyStepCommonDefinition, - buildStructuredOutputSchema, -} from '../../../../common/steps/ai'; -import { createServerStepDefinition } from '../../../step_registry/types'; -import type { WorkflowsExtensionsServerPluginStartDeps } from '../../../types'; -import { resolveConnectorId } from '../utils/resolve_connector_id'; - -export const aiClassifyStepDefinition = ( - coreSetup: CoreSetup -) => - createServerStepDefinition({ - ...AiClassifyStepCommonDefinition, - handler: async (context) => { - const [, { inference }] = await coreSetup.getStartServices(); - - const resolvedConnectorId = await resolveConnectorId( - context.config['connector-id'], - inference, - context.contextManager.getFakeRequest() - ); - - const chatModel = await inference.getChatModel({ - connectorId: resolvedConnectorId, - request: context.contextManager.getFakeRequest(), - chatModelOptions: { - temperature: context.input.temperature, - maxRetries: 0, // Disable automatic retries; validation is handled via validateModelResponse - }, - }); - - const { - input, - categories, - instructions, - allowMultipleCategories = false, - fallbackCategory, - includeRationale = false, - } = context.input; - const responseZodSchema = buildStructuredOutputSchema(context.input); - const modelInput: MessageFieldWithRole[] = [ - ...buildSystemPart(), - ...buildDataPart(input), - ...buildClassificationRequestPart({ - categories, - allowMultipleCategories, - fallbackCategory, - includeRationale, - }), - ...buildInstructionsPart(instructions), - ]; - - const invocationResult = await chatModel - .withStructuredOutput(responseZodSchema, { - name: 'classify', - includeRaw: true, - method: 'json', - }) - .invoke(modelInput, { - signal: context.abortSignal, - }); - - validateModelResponse({ - modelResponse: invocationResult.parsed, - expectedCategories: context.input.categories, - fallbackCategory: context.input.fallbackCategory, - responseMetadata: invocationResult.raw.response_metadata, - }); - - return { - output: { - ...invocationResult.parsed, - metadata: invocationResult.raw.response_metadata, - }, - }; - }, - }); diff --git a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_classify_step/validate_model_response.test.ts b/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_classify_step/validate_model_response.test.ts deleted file mode 100644 index ff89bef7dc9c1..0000000000000 --- a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_classify_step/validate_model_response.test.ts +++ /dev/null @@ -1,416 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { ExecutionError } from '@kbn/workflows/server'; -import { validateModelResponse } from './validate_model_response'; - -describe('validateModelResponse', () => { - const responseMetadata = { - modelId: 'test-model', - timestamp: Date.now(), - }; - - describe('category validation', () => { - it('should accept a category that matches expected categories', () => { - const modelResponse = { - category: 'category1', - metadata: {}, - }; - - expect(() => { - validateModelResponse({ - modelResponse, - expectedCategories: ['category1', 'category2'], - fallbackCategory: undefined, - responseMetadata, - }); - }).not.toThrow(); - }); - - it('should accept multiple categories that all match expected categories', () => { - const modelResponse = { - categories: ['category1', 'category2'], - metadata: {}, - }; - - expect(() => { - validateModelResponse({ - modelResponse, - - expectedCategories: ['category1', 'category2', 'category3'], - fallbackCategory: undefined, - responseMetadata, - }); - }).not.toThrow(); - }); - - it('should accept a category that matches fallback category', () => { - const modelResponse = { - category: 'fallback', - metadata: {}, - }; - - expect(() => { - validateModelResponse({ - modelResponse, - expectedCategories: ['category1', 'category2'], - fallbackCategory: 'fallback', - responseMetadata, - }); - }).not.toThrow(); - }); - - it('should accept categories when one matches expected and one matches fallback', () => { - const modelResponse = { - categories: ['category1', 'fallback'], - metadata: {}, - }; - - expect(() => { - validateModelResponse({ - modelResponse, - expectedCategories: ['category1', 'category2'], - fallbackCategory: 'fallback', - responseMetadata, - }); - }).not.toThrow(); - }); - - it('should throw UnexpectedCategories error when category does not match expected categories', () => { - const modelResponse = { - category: 'unexpected', - metadata: {}, - }; - - expect(() => { - validateModelResponse({ - modelResponse, - expectedCategories: ['category1', 'category2'], - fallbackCategory: undefined, - responseMetadata, - }); - }).toThrow(ExecutionError); - - try { - validateModelResponse({ - modelResponse, - - expectedCategories: ['category1', 'category2'], - fallbackCategory: undefined, - responseMetadata, - }); - } catch (error) { - expect(error).toBeInstanceOf(ExecutionError); - expect((error as ExecutionError).type).toBe('UnexpectedCategories'); - expect((error as ExecutionError).message).toBe('Model returned unexpected categories.'); - expect((error as ExecutionError).details).toMatchObject({ - modelResponse, - metadata: responseMetadata, - }); - } - }); - - it('should throw UnexpectedCategories error when one of multiple categories is unexpected', () => { - const modelResponse = { - categories: ['category1', 'unexpected'], - metadata: {}, - }; - - expect(() => { - validateModelResponse({ - modelResponse, - expectedCategories: ['category1', 'category2'], - fallbackCategory: undefined, - responseMetadata, - }); - }).toThrow(ExecutionError); - - try { - validateModelResponse({ - modelResponse, - - expectedCategories: ['category1', 'category2'], - fallbackCategory: undefined, - responseMetadata, - }); - } catch (error) { - expect(error).toBeInstanceOf(ExecutionError); - expect((error as ExecutionError).type).toBe('UnexpectedCategories'); - } - }); - - it('should throw UnexpectedCategories error when all categories are unexpected', () => { - const modelResponse = { - categories: ['unexpected1', 'unexpected2'], - metadata: {}, - }; - - expect(() => { - validateModelResponse({ - modelResponse, - expectedCategories: ['category1', 'category2'], - fallbackCategory: undefined, - responseMetadata, - }); - }).toThrow(ExecutionError); - }); - - it('should throw UnexpectedCategories error when category does not match expected or fallback', () => { - const modelResponse = { - category: 'unexpected', - metadata: {}, - }; - - expect(() => { - validateModelResponse({ - modelResponse, - expectedCategories: ['category1', 'category2'], - fallbackCategory: 'fallback', - responseMetadata, - }); - }).toThrow(ExecutionError); - }); - }); - - describe('edge cases', () => { - it('should handle empty expectedCategories array when fallbackCategory is provided', () => { - const modelResponse = { - category: 'fallback', - metadata: {}, - }; - - expect(() => { - validateModelResponse({ - modelResponse, - expectedCategories: [], - fallbackCategory: 'fallback', - responseMetadata, - }); - }).not.toThrow(); - }); - - it('should handle response with both category and categories fields (prioritize categories)', () => { - const modelResponse = { - category: 'category1', - categories: ['category2', 'category3'], - metadata: {}, - }; - - expect(() => { - validateModelResponse({ - modelResponse, - expectedCategories: ['category1', 'category2', 'category3'], - fallbackCategory: undefined, - responseMetadata, - }); - }).not.toThrow(); - }); - - it('should handle response with empty categories array by falling back to category field', () => { - const modelResponse = { - category: 'category1', - categories: [], - metadata: {}, - }; - - expect(() => { - validateModelResponse({ - modelResponse, - expectedCategories: ['category1', 'category2'], - fallbackCategory: undefined, - responseMetadata, - }); - }).not.toThrow(); - }); - - it('should handle response with single category in categories array', () => { - const modelResponse = { - categories: ['category1'], - metadata: {}, - }; - - expect(() => { - validateModelResponse({ - modelResponse, - expectedCategories: ['category1', 'category2'], - fallbackCategory: undefined, - responseMetadata, - }); - }).not.toThrow(); - }); - - it('should handle response with additional metadata fields', () => { - const modelResponse = { - category: 'category1', - metadata: { - confidence: 0.95, - processingTime: 123, - additionalInfo: 'test', - }, - }; - - expect(() => { - validateModelResponse({ - modelResponse, - expectedCategories: ['category1', 'category2'], - fallbackCategory: undefined, - responseMetadata, - }); - }).not.toThrow(); - }); - - it('should handle undefined fallbackCategory', () => { - const modelResponse = { - category: 'category1', - metadata: {}, - }; - - expect(() => { - validateModelResponse({ - modelResponse, - expectedCategories: ['category1', 'category2'], - fallbackCategory: undefined, - responseMetadata, - }); - }).not.toThrow(); - }); - - it('should handle empty responseMetadata', () => { - const modelResponse = { - category: 'category1', - metadata: {}, - }; - - expect(() => { - validateModelResponse({ - modelResponse, - expectedCategories: ['category1', 'category2'], - fallbackCategory: undefined, - responseMetadata: {}, - }); - }).not.toThrow(); - }); - - it('should preserve responseMetadata in error details', () => { - const customMetadata = { - customField: 'customValue', - timestamp: Date.now(), - }; - - const modelResponse = { - category: 'unexpected', - metadata: {}, - }; - - try { - validateModelResponse({ - modelResponse, - - expectedCategories: ['category1', 'category2'], - fallbackCategory: undefined, - responseMetadata: customMetadata, - }); - } catch (error) { - expect((error as ExecutionError).details?.metadata).toEqual(customMetadata); - } - }); - }); - - describe('null/undefined model response', () => { - it('throws InvalidModelResponse when modelResponse is null', () => { - expect(() => { - validateModelResponse({ - modelResponse: null, - expectedCategories: ['a'], - fallbackCategory: undefined, - responseMetadata: {}, - }); - }).toThrow(ExecutionError); - - try { - validateModelResponse({ - modelResponse: null, - expectedCategories: ['a'], - fallbackCategory: undefined, - responseMetadata: {}, - }); - } catch (error) { - expect((error as ExecutionError).type).toBe('InvalidModelResponse'); - expect((error as ExecutionError).message).toBe('Model response is null or undefined.'); - } - }); - - it('throws InvalidModelResponse when modelResponse is undefined', () => { - expect(() => { - validateModelResponse({ - modelResponse: undefined, - expectedCategories: ['a'], - fallbackCategory: undefined, - responseMetadata: {}, - }); - }).toThrow(ExecutionError); - - try { - validateModelResponse({ - modelResponse: undefined, - expectedCategories: ['a'], - fallbackCategory: undefined, - responseMetadata: {}, - }); - } catch (error) { - expect((error as ExecutionError).type).toBe('InvalidModelResponse'); - } - }); - }); - - describe('case sensitivity', () => { - it('should treat category names as case-sensitive', () => { - const modelResponse = { - category: 'Category1', - metadata: {}, - }; - - expect(() => { - validateModelResponse({ - modelResponse, - expectedCategories: ['category1', 'category2'], - fallbackCategory: undefined, - responseMetadata, - }); - }).toThrow(ExecutionError); - - try { - validateModelResponse({ - modelResponse, - expectedCategories: ['category1', 'category2'], - fallbackCategory: undefined, - responseMetadata, - }); - } catch (error) { - expect((error as ExecutionError).type).toBe('UnexpectedCategories'); - } - }); - - it('should match exact category names including whitespace', () => { - const modelResponse = { - category: 'category 1', - metadata: {}, - }; - - expect(() => { - validateModelResponse({ - modelResponse, - expectedCategories: ['category1', 'category2'], - fallbackCategory: undefined, - responseMetadata, - }); - }).toThrow(ExecutionError); - }); - }); -}); diff --git a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_classify_step/validate_model_response.ts b/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_classify_step/validate_model_response.ts deleted file mode 100644 index ee76b10ad1451..0000000000000 --- a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_classify_step/validate_model_response.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { ExecutionError } from '@kbn/workflows/server'; -import type { z } from '@kbn/zod/v4'; -import type { AiClassifyStepOutputSchema } from '../../../../common/steps/ai'; - -export function validateModelResponse({ - modelResponse, - expectedCategories, - fallbackCategory, - responseMetadata, -}: { - modelResponse: z.infer | null | undefined; - expectedCategories: string[]; - fallbackCategory: string | undefined; - responseMetadata: Record; -}): void { - if (!modelResponse) { - throw new ExecutionError({ - type: 'InvalidModelResponse', - message: 'Model response is null or undefined.', - details: { - modelResponse, - metadata: responseMetadata, - }, - }); - } - - const returnedCategories = modelResponse.categories?.length - ? modelResponse.categories - : [modelResponse.category as string]; - const categoriesSet = new Set([ - ...expectedCategories, - ...(fallbackCategory ? [fallbackCategory] : []), - ]); - - const unexpectedCategories = returnedCategories.filter( - (returnedCategory: string) => !categoriesSet.has(returnedCategory) - ); - - if (unexpectedCategories.length) { - throw new ExecutionError({ - type: 'UnexpectedCategories', - message: 'Model returned unexpected categories.', - details: { - modelResponse, - metadata: responseMetadata, - }, - }); - } -} diff --git a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_prompt_step/ai_prompt_step.test.ts b/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_prompt_step/ai_prompt_step.test.ts deleted file mode 100644 index 0a1ff54298b81..0000000000000 --- a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_prompt_step/ai_prompt_step.test.ts +++ /dev/null @@ -1,539 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { CoreSetup, KibanaRequest } from '@kbn/core/server'; -import type { InferenceServerStart } from '@kbn/inference-plugin/server'; - -// Mock external dependencies -jest.mock('../utils/resolve_connector_id', () => ({ - resolveConnectorId: jest.fn(), -})); - -jest.mock('../../../../common/steps/ai', () => ({ - AiPromptStepCommonDefinition: { - id: 'ai.prompt', - inputSchema: {}, - outputSchema: {}, - }, -})); - -jest.mock('../../../step_registry/types', () => ({ - createServerStepDefinition: jest.fn((definition) => definition), -})); - -import { aiPromptStepDefinition } from './step'; -import type { ContextManager, StepHandlerContext } from '../../../step_registry/types'; -import { createServerStepDefinition } from '../../../step_registry/types'; -import type { WorkflowsExtensionsServerPluginStartDeps } from '../../../types'; -import { resolveConnectorId } from '../utils/resolve_connector_id'; - -const mockResolveConnectorId = resolveConnectorId as jest.MockedFunction; -const mockCreateServerStepDefinition = createServerStepDefinition as jest.MockedFunction< - typeof createServerStepDefinition ->; - -describe('aiPromptStepDefinition', () => { - let mockCoreSetup: jest.Mocked>; - let mockInference: jest.Mocked; - let mockContextManager: jest.Mocked; - let mockContext: StepHandlerContext; - let mockChatModel: any; - let mockRunnable: any; - let mockAbortController: AbortController; - - beforeEach(() => { - jest.clearAllMocks(); - - mockAbortController = new AbortController(); - - // Mock chat model - mockRunnable = { - invoke: jest.fn(), - }; - - mockChatModel = { - invoke: jest.fn(), - withStructuredOutput: jest.fn().mockReturnValue(mockRunnable), - }; - - // Mock inference service - mockInference = { - getChatModel: jest.fn().mockResolvedValue(mockChatModel), - } as any; - - // Mock context manager - mockContextManager = { - getFakeRequest: jest.fn().mockReturnValue({} as KibanaRequest), - getContext: jest.fn(), - getScopedEsClient: jest.fn(), - renderInputTemplate: jest.fn(), - }; - - // Mock step handler context - mockContext = { - config: { - 'connector-id': 'test-connector-id', - }, - input: { - prompt: 'Test prompt', - temperature: 0.7, - }, - rawInput: { - prompt: 'Test prompt', - temperature: 0.7, - }, - contextManager: mockContextManager, - logger: { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }, - abortSignal: mockAbortController.signal, - stepId: 'test-step-id', - stepType: 'ai.prompt', - }; - - // Mock CoreSetup - mockCoreSetup = { - getStartServices: jest.fn().mockResolvedValue([{}, { inference: mockInference }]), - } as any; - - mockResolveConnectorId.mockResolvedValue('resolved-connector-id'); - mockCreateServerStepDefinition.mockImplementation((def) => def); - }); - - describe('step definition creation', () => { - it('should create a step definition with correct structure', () => { - const stepDefinition = aiPromptStepDefinition(mockCoreSetup); - - expect(mockCreateServerStepDefinition).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'ai.prompt', - inputSchema: {}, - outputSchema: {}, - handler: expect.any(Function), - }) - ); - - expect(stepDefinition).toBeDefined(); - expect(typeof stepDefinition.handler).toBe('function'); - }); - }); - - describe('handler execution', () => { - let stepDefinition: any; - let handler: Function; - - beforeEach(() => { - stepDefinition = aiPromptStepDefinition(mockCoreSetup); - handler = stepDefinition.handler; - }); - - describe('with basic input (no output schema)', () => { - it('should successfully execute AI prompt and return response', async () => { - const mockResponse = { - content: 'AI generated response', - response_metadata: { model: 'gpt-3.5-turbo', usage: { tokens: 100 } }, - }; - - mockChatModel.invoke.mockResolvedValue(mockResponse); - mockContext.input.systemPrompt = 'You are a helpful assistant.'; - const result = await handler(mockContext); - - expect(mockCoreSetup.getStartServices).toHaveBeenCalledTimes(1); - expect(mockResolveConnectorId).toHaveBeenCalledWith( - 'test-connector-id', - mockInference, - expect.any(Object) - ); - expect(mockInference.getChatModel).toHaveBeenCalledWith({ - connectorId: 'resolved-connector-id', - request: expect.any(Object), - chatModelOptions: { - temperature: 0.7, - maxRetries: 0, - }, - }); - expect(mockChatModel.invoke).toHaveBeenCalledWith( - [ - { role: 'system', content: 'You are a helpful assistant.' }, - { role: 'user', content: 'Test prompt' }, - ], - { signal: mockAbortController.signal } - ); - - expect(result).toEqual({ - output: { - content: 'AI generated response', - metadata: { model: 'gpt-3.5-turbo', usage: { tokens: 100 } }, - }, - }); - }); - - it('should handle missing temperature in input', async () => { - const contextWithoutTemperature = { - ...mockContext, - input: { - prompt: 'Test prompt', - connectorId: 'test-connector-id', - }, - }; - - const mockResponse = { - content: 'AI response', - response_metadata: {}, - }; - - mockChatModel.invoke.mockResolvedValue(mockResponse); - - await handler(contextWithoutTemperature); - - expect(mockInference.getChatModel).toHaveBeenCalledWith({ - connectorId: 'resolved-connector-id', - request: expect.any(Object), - chatModelOptions: { - temperature: undefined, - maxRetries: 0, - }, - }); - }); - - it('should handle missing connectorId in input', async () => { - const contextWithoutConnectorId = { - ...mockContext, - config: { - ...mockContext.config, - 'connector-id': undefined, - }, - input: { - prompt: 'Test prompt', - temperature: 0.5, - }, - }; - - const mockResponse = { - content: 'AI response', - response_metadata: {}, - }; - - mockChatModel.invoke.mockResolvedValue(mockResponse); - - await handler(contextWithoutConnectorId); - - expect(mockResolveConnectorId).toHaveBeenCalledWith( - undefined, - mockInference, - expect.any(Object) - ); - }); - }); - - describe('with structured output schema', () => { - it('should use structured output when outputSchema is provided', async () => { - const contextWithSchema = { - ...mockContext, - input: { - ...mockContext.input, - schema: { - type: 'object', - properties: { - summary: { type: 'string' }, - sentiment: { type: 'string' }, - }, - }, - }, - }; - - const mockStructuredResponse = { - response: { - summary: 'This is a summary', - sentiment: 'positive', - }, - }; - - mockRunnable.invoke.mockResolvedValue({ - parsed: mockStructuredResponse, - raw: { - response_metadata: { tokens_used: 150 }, - }, - }); - - const result = await handler(contextWithSchema); - - expect(mockChatModel.withStructuredOutput).toHaveBeenCalledWith( - { - type: 'object', - properties: { - response: { - type: 'object', - properties: { - summary: { type: 'string' }, - sentiment: { type: 'string' }, - }, - }, - }, - }, - { - name: 'extract_structured_response', - includeRaw: true, - method: 'jsonMode', - } - ); - - expect(mockRunnable.invoke).toHaveBeenCalledWith( - [{ role: 'user', content: 'Test prompt' }], - { signal: mockAbortController.signal } - ); - - expect(result).toEqual({ - output: { - content: { - summary: 'This is a summary', - sentiment: 'positive', - }, - metadata: { tokens_used: 150 }, - }, - }); - - // Ensure regular invoke is not called when using structured output - expect(mockChatModel.invoke).not.toHaveBeenCalled(); - }); - - it('should handle array output schema by wrapping in response object', async () => { - const contextWithArraySchema = { - ...mockContext, - input: { - ...mockContext.input, - schema: { - type: 'array', - items: { type: 'string' }, - }, - }, - }; - - const mockStructuredResponse = { - response: ['item1', 'item2', 'item3'], - }; - - mockRunnable.invoke.mockResolvedValue({ - parsed: mockStructuredResponse, - raw: { - response_metadata: { tokens_used: 150 }, - }, - }); - - const result = await handler(contextWithArraySchema); - - expect(mockChatModel.withStructuredOutput).toHaveBeenCalledWith( - { - type: 'object', - properties: { - response: { - type: 'array', - items: { type: 'string' }, - }, - }, - }, - { - name: 'extract_structured_response', - includeRaw: true, - method: 'jsonMode', - } - ); - - expect(result).toEqual({ - output: expect.objectContaining({ - content: ['item1', 'item2', 'item3'], - }), - }); - }); - }); - - describe('error handling', () => { - it('should propagate errors from resolveConnectorId', async () => { - const error = new Error('Connector resolution failed'); - mockResolveConnectorId.mockRejectedValue(error); - - await expect(handler(mockContext)).rejects.toThrow('Connector resolution failed'); - }); - - it('should propagate errors from getChatModel', async () => { - const error = new Error('Chat model initialization failed'); - mockInference.getChatModel.mockRejectedValue(error); - - await expect(handler(mockContext)).rejects.toThrow('Chat model initialization failed'); - }); - - it('should propagate errors from chat model invoke', async () => { - const error = new Error('AI model invocation failed'); - mockChatModel.invoke.mockRejectedValue(error); - - await expect(handler(mockContext)).rejects.toThrow('AI model invocation failed'); - }); - - it('should propagate errors from structured output invoke', async () => { - const contextWithSchema = { - ...mockContext, - input: { - ...mockContext.input, - schema: { type: 'object', properties: {} }, - }, - }; - - const error = new Error('Structured output invocation failed'); - mockRunnable.invoke.mockRejectedValue(error); - - await expect(handler(contextWithSchema)).rejects.toThrow( - 'Structured output invocation failed' - ); - }); - - it('should handle abortion via abortSignal', async () => { - mockAbortController.abort(); - - const error = new Error('Aborted'); - error.name = 'AbortError'; - mockChatModel.invoke.mockRejectedValue(error); - - await expect(handler(mockContext)).rejects.toThrow('Aborted'); - }); - }); - - describe('service integration', () => { - it('should pass correct parameters to all services', async () => { - const mockResponse = { - content: 'Test response', - response_metadata: {}, - }; - - mockChatModel.invoke.mockResolvedValue(mockResponse); - - await handler(mockContext); - - // Verify CoreSetup.getStartServices is called - expect(mockCoreSetup.getStartServices).toHaveBeenCalledTimes(1); - - // Verify contextManager.getFakeRequest is called twice (for resolveConnectorId and getChatModel) - expect(mockContextManager.getFakeRequest).toHaveBeenCalledTimes(2); - - // Verify resolveConnectorId is called with correct parameters - expect(mockResolveConnectorId).toHaveBeenCalledWith( - 'test-connector-id', - mockInference, - expect.any(Object) - ); - - // Verify getChatModel is called with correct parameters - expect(mockInference.getChatModel).toHaveBeenCalledWith({ - connectorId: 'resolved-connector-id', - request: expect.any(Object), - chatModelOptions: { - temperature: 0.7, - maxRetries: 0, - }, - }); - - // Verify chat model invoke is called with correct parameters - expect(mockChatModel.invoke).toHaveBeenCalledWith( - [{ role: 'user', content: 'Test prompt' }], - { signal: mockAbortController.signal } - ); - }); - - it('should use the same fake request for connector resolution and chat model', async () => { - const mockFakeRequest = { headers: {}, auth: {} } as KibanaRequest; - mockContextManager.getFakeRequest.mockReturnValue(mockFakeRequest); - - const mockResponse = { - content: 'Test response', - response_metadata: {}, - }; - - mockChatModel.invoke.mockResolvedValue(mockResponse); - - await handler(mockContext); - - // Verify the same fake request is used in both calls - expect(mockResolveConnectorId).toHaveBeenCalledWith( - 'test-connector-id', - mockInference, - mockFakeRequest - ); - - expect(mockInference.getChatModel).toHaveBeenCalledWith({ - connectorId: 'resolved-connector-id', - request: mockFakeRequest, - chatModelOptions: { - temperature: 0.7, - maxRetries: 0, - }, - }); - }); - }); - - describe('input validation and processing', () => { - it('should handle various temperature values', async () => { - const temperatures = [0, 0.1, 0.5, 0.9, 1.0]; - const mockResponse = { content: 'response', response_metadata: {} }; - mockChatModel.invoke.mockResolvedValue(mockResponse); - - for (const temperature of temperatures) { - const contextWithTemperature = { - ...mockContext, - input: { - ...mockContext.input, - temperature, - }, - }; - - await handler(contextWithTemperature); - - expect(mockInference.getChatModel).toHaveBeenCalledWith( - expect.objectContaining({ - chatModelOptions: expect.objectContaining({ - temperature, - maxRetries: 0, - }), - }) - ); - } - }); - - it('should handle different prompt types', async () => { - const prompts = [ - 'Simple text prompt', - 'Multi\nline\nprompt', - 'Prompt with special characters: @#$%^&*()', - '', - 'Very long prompt that exceeds normal length expectations and continues for a while to test edge cases', - ]; - - const mockResponse = { content: 'response', response_metadata: {} }; - mockChatModel.invoke.mockResolvedValue(mockResponse); - - for (const prompt of prompts) { - const contextWithPrompt = { - ...mockContext, - input: { - ...mockContext.input, - prompt, - }, - }; - - await handler(contextWithPrompt); - - expect(mockChatModel.invoke).toHaveBeenCalledWith([{ role: 'user', content: prompt }], { - signal: mockAbortController.signal, - }); - } - }); - }); - }); -}); diff --git a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_prompt_step/step.ts b/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_prompt_step/step.ts deleted file mode 100644 index 3c8bb0352bf76..0000000000000 --- a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_prompt_step/step.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { CoreSetup } from '@kbn/core/server'; -import { AiPromptStepCommonDefinition } from '../../../../common/steps/ai'; -import { createServerStepDefinition } from '../../../step_registry/types'; -import type { WorkflowsExtensionsServerPluginStartDeps } from '../../../types'; -import { resolveConnectorId } from '../utils/resolve_connector_id'; - -export const aiPromptStepDefinition = ( - coreSetup: CoreSetup -) => - createServerStepDefinition({ - ...AiPromptStepCommonDefinition, - handler: async (context) => { - const [, { inference }] = await coreSetup.getStartServices(); - - const resolvedConnectorId = await resolveConnectorId( - context.config['connector-id'], - inference, - context.contextManager.getFakeRequest() - ); - - const chatModel = await inference.getChatModel({ - connectorId: resolvedConnectorId, - request: context.contextManager.getFakeRequest(), - chatModelOptions: { - temperature: context.input.temperature, - maxRetries: 0, - }, - }); - const modelInput = [ - ...(context.input.systemPrompt - ? [{ role: 'system', content: context.input.systemPrompt }] - : []), - { - role: 'user', - content: context.input.prompt, - }, - ]; - - if (context.input.schema) { - const runnable = chatModel.withStructuredOutput( - { - type: 'object', - properties: { - // withStructuredOutput fails if outputSchema is not an object. - // for example, if the user expects an array, we wrap it into an object here - // and then unwrap it below - response: context.input.schema, - }, - }, - { - name: 'extract_structured_response', - includeRaw: true, - method: 'jsonMode', - } - ); - - const invocationResult = await runnable.invoke(modelInput, { - signal: context.abortSignal, - }); - return { - // We modify the output to match the expected schema - // For now, structured output flow does not output response_metadata, - // so we only return the content here, but looking ahead we might have response_metadata returned, - // so we keep the same output structure with potential response_metadata addition in the future. - output: { - content: invocationResult.parsed.response, - metadata: invocationResult.raw.response_metadata, - }, - }; - } - - const invocationResult = await chatModel.invoke(modelInput, { - signal: context.abortSignal, - }); - - return { - output: { - content: invocationResult.content, - metadata: invocationResult.response_metadata, - }, - }; - }, - }); diff --git a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_summarize_step/ai_summarize_step.test.ts b/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_summarize_step/ai_summarize_step.test.ts deleted file mode 100644 index 26a1123648047..0000000000000 --- a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_summarize_step/ai_summarize_step.test.ts +++ /dev/null @@ -1,491 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { CoreSetup, KibanaRequest } from '@kbn/core/server'; -import type { InferenceServerStart } from '@kbn/inference-plugin/server'; - -// Mock external dependencies -jest.mock('../utils/resolve_connector_id', () => ({ - resolveConnectorId: jest.fn(), -})); - -jest.mock('./build_prompts', () => ({ - buildSystemPart: jest.fn(), - buildDataPart: jest.fn(), - buildRequirementsPart: jest.fn(), - buildInstructionsPart: jest.fn(), -})); - -jest.mock('../../../../common/steps/ai', () => ({ - AiSummarizeStepCommonDefinition: { - id: 'ai.summarize', - inputSchema: {}, - outputSchema: {}, - configSchema: {}, - }, -})); - -jest.mock('../../../step_registry/types', () => ({ - createServerStepDefinition: jest.fn((definition) => definition), -})); - -import { - buildDataPart, - buildInstructionsPart, - buildRequirementsPart, - buildSystemPart, -} from './build_prompts'; -import { aiSummarizeStepDefinition } from './step'; -import { createServerStepDefinition } from '../../../step_registry/types'; -import type { ContextManager, StepHandlerContext } from '../../../step_registry/types'; -import type { WorkflowsExtensionsServerPluginStartDeps } from '../../../types'; -import { resolveConnectorId } from '../utils/resolve_connector_id'; - -const mockResolveConnectorId = resolveConnectorId as jest.MockedFunction; -const mockBuildSystemPart = buildSystemPart as jest.MockedFunction; -const mockBuildDataPart = buildDataPart as jest.MockedFunction; -const mockBuildRequirementsPart = buildRequirementsPart as jest.MockedFunction< - typeof buildRequirementsPart ->; -const mockBuildInstructionsPart = buildInstructionsPart as jest.MockedFunction< - typeof buildInstructionsPart ->; -const mockCreateServerStepDefinition = createServerStepDefinition as jest.MockedFunction< - typeof createServerStepDefinition ->; - -describe('aiSummarizeStepDefinition', () => { - let mockCoreSetup: jest.Mocked>; - let mockInference: jest.Mocked; - let mockContextManager: jest.Mocked; - let mockContext: StepHandlerContext; - let mockChatModel: any; - let mockAbortController: AbortController; - - beforeEach(() => { - jest.clearAllMocks(); - - mockAbortController = new AbortController(); - - // Mock chat model - mockChatModel = { - invoke: jest.fn(), - }; - - // Mock inference service - mockInference = { - getChatModel: jest.fn().mockResolvedValue(mockChatModel), - } as any; - - // Mock context manager - mockContextManager = { - getFakeRequest: jest.fn().mockReturnValue({} as KibanaRequest), - getContext: jest.fn(), - getScopedEsClient: jest.fn(), - renderInputTemplate: jest.fn(), - }; - - // Mock step handler context - mockContext = { - config: { - 'connector-id': 'test-connector-id', - }, - input: { - input: 'Text to summarize', - temperature: 0.7, - }, - rawInput: { - input: 'Text to summarize', - temperature: 0.7, - }, - contextManager: mockContextManager, - logger: { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }, - abortSignal: mockAbortController.signal, - stepId: 'test-step-id', - stepType: 'ai.summarize', - }; - - // Mock CoreSetup - mockCoreSetup = { - getStartServices: jest.fn().mockResolvedValue([{}, { inference: mockInference }]), - } as any; - - // Mock build functions to return sample message parts - mockBuildSystemPart.mockReturnValue([{ role: 'system', content: 'System prompt' }]); - mockBuildDataPart.mockReturnValue([{ role: 'user', content: 'Data to summarize' }]); - mockBuildRequirementsPart.mockReturnValue([ - { role: 'user', content: 'Requirements for summary' }, - ]); - mockBuildInstructionsPart.mockReturnValue([ - { role: 'user', content: 'Additional instructions' }, - ]); - - mockResolveConnectorId.mockResolvedValue('resolved-connector-id'); - mockCreateServerStepDefinition.mockImplementation((def) => def); - }); - - describe('handler execution', () => { - let stepDefinition: any; - let handler: Function; - - beforeEach(() => { - stepDefinition = aiSummarizeStepDefinition(mockCoreSetup); - handler = stepDefinition.handler; - }); - - describe('with basic input', () => { - it('should successfully execute AI summarize and return response with string content', async () => { - const mockResponse = { - content: 'This is a summary of the text', - response_metadata: { model: 'gpt-3.5-turbo', usage: { tokens: 100 } }, - }; - - mockChatModel.invoke.mockResolvedValue(mockResponse); - - const result = await handler(mockContext); - - expect(mockCoreSetup.getStartServices).toHaveBeenCalledTimes(1); - expect(mockResolveConnectorId).toHaveBeenCalledWith( - 'test-connector-id', - mockInference, - expect.any(Object) - ); - expect(mockInference.getChatModel).toHaveBeenCalledWith({ - connectorId: 'resolved-connector-id', - request: expect.any(Object), - chatModelOptions: { - temperature: 0.7, - maxRetries: 0, - }, - }); - - // Verify build functions were called with correct parameters - expect(mockBuildSystemPart).toHaveBeenCalledTimes(1); - expect(mockBuildDataPart).toHaveBeenCalledWith('Text to summarize'); - expect(mockBuildRequirementsPart).toHaveBeenCalledWith({ maxLength: undefined }); - expect(mockBuildInstructionsPart).toHaveBeenCalledWith(undefined); - - expect(mockChatModel.invoke).toHaveBeenCalledWith( - [ - { role: 'system', content: 'System prompt' }, - { role: 'user', content: 'Data to summarize' }, - { role: 'user', content: 'Requirements for summary' }, - { role: 'user', content: 'Additional instructions' }, - ], - { signal: mockAbortController.signal } - ); - - expect(result).toEqual({ - output: { - content: 'This is a summary of the text', - metadata: { model: 'gpt-3.5-turbo', usage: { tokens: 100 } }, - }, - }); - }); - - it('should handle array content in response', async () => { - const mockResponse = { - content: [ - { type: 'text', text: 'First part of summary' }, - { type: 'text', text: 'Second part of summary' }, - ], - response_metadata: { model: 'gpt-4' }, - }; - - mockChatModel.invoke.mockResolvedValue(mockResponse); - - const result = await handler(mockContext); - - expect(result).toEqual({ - output: { - content: 'First part of summarySecond part of summary', - metadata: { model: 'gpt-4' }, - }, - }); - }); - - it('should handle array content with non-text parts', async () => { - const mockResponse = { - content: [ - { type: 'text', text: 'Text part' }, - { type: 'image', url: 'http://example.com/image.jpg' }, - { type: 'text', text: 'Another text part' }, - ], - response_metadata: {}, - }; - - mockChatModel.invoke.mockResolvedValue(mockResponse); - - const result = await handler(mockContext); - - expect(result).toEqual({ - output: { - content: 'Text partAnother text part', - metadata: {}, - }, - }); - }); - - it('should handle missing temperature in input', async () => { - const contextWithoutTemperature = { - ...mockContext, - input: { - input: 'Text to summarize', - }, - }; - - const mockResponse = { - content: 'Summary', - response_metadata: {}, - }; - - mockChatModel.invoke.mockResolvedValue(mockResponse); - - await handler(contextWithoutTemperature); - - expect(mockInference.getChatModel).toHaveBeenCalledWith({ - connectorId: 'resolved-connector-id', - request: expect.any(Object), - chatModelOptions: { - temperature: undefined, - maxRetries: 0, - }, - }); - }); - - it('should handle missing connector-id in config', async () => { - const contextWithoutConnectorId = { - ...mockContext, - config: {}, - }; - - const mockResponse = { - content: 'Summary', - response_metadata: {}, - }; - - mockChatModel.invoke.mockResolvedValue(mockResponse); - - await handler(contextWithoutConnectorId); - - expect(mockResolveConnectorId).toHaveBeenCalledWith( - undefined, - mockInference, - expect.any(Object) - ); - }); - }); - - describe('with optional parameters', () => { - it('should handle maxLength parameter', async () => { - const contextWithMaxLength = { - ...mockContext, - input: { - input: 'Text to summarize', - maxLength: 100, - }, - }; - - const mockResponse = { - content: 'Short summary', - response_metadata: {}, - }; - - mockChatModel.invoke.mockResolvedValue(mockResponse); - - await handler(contextWithMaxLength); - - expect(mockBuildRequirementsPart).toHaveBeenCalledWith({ maxLength: 100 }); - }); - - it('should handle instructions parameter', async () => { - const contextWithInstructions = { - ...mockContext, - input: { - input: 'Text to summarize', - instructions: 'Focus on key points only', - }, - }; - - const mockResponse = { - content: 'Focused summary', - response_metadata: {}, - }; - - mockChatModel.invoke.mockResolvedValue(mockResponse); - - await handler(contextWithInstructions); - - expect(mockBuildInstructionsPart).toHaveBeenCalledWith('Focus on key points only'); - }); - - it('should handle both maxLength and instructions', async () => { - const contextWithBoth = { - ...mockContext, - input: { - input: 'Text to summarize', - maxLength: 150, - instructions: 'Be concise and factual', - temperature: 0.5, - }, - }; - - const mockResponse = { - content: 'Concise summary', - response_metadata: {}, - }; - - mockChatModel.invoke.mockResolvedValue(mockResponse); - - await handler(contextWithBoth); - - expect(mockBuildRequirementsPart).toHaveBeenCalledWith({ maxLength: 150 }); - expect(mockBuildInstructionsPart).toHaveBeenCalledWith('Be concise and factual'); - }); - }); - - describe('with different input types', () => { - it('should handle string input', async () => { - const contextWithString = { - ...mockContext, - input: { - input: 'Simple text string', - }, - }; - - const mockResponse = { - content: 'Summary', - response_metadata: {}, - }; - - mockChatModel.invoke.mockResolvedValue(mockResponse); - - await handler(contextWithString); - - expect(mockBuildDataPart).toHaveBeenCalledWith('Simple text string'); - }); - - it('should handle object input', async () => { - const inputObject = { name: 'John', age: 30, city: 'New York' }; - const contextWithObject = { - ...mockContext, - input: { - input: inputObject, - }, - }; - - const mockResponse = { - content: 'Summary', - response_metadata: {}, - }; - - mockChatModel.invoke.mockResolvedValue(mockResponse); - - await handler(contextWithObject); - - expect(mockBuildDataPart).toHaveBeenCalledWith(inputObject); - }); - - it('should handle array input', async () => { - const inputArray = ['item1', 'item2', 'item3']; - const contextWithArray = { - ...mockContext, - input: { - input: inputArray, - }, - }; - - const mockResponse = { - content: 'Summary', - response_metadata: {}, - }; - - mockChatModel.invoke.mockResolvedValue(mockResponse); - - await handler(contextWithArray); - - expect(mockBuildDataPart).toHaveBeenCalledWith(inputArray); - }); - }); - - describe('error handling', () => { - it('should handle abortion via abortSignal', async () => { - mockAbortController.abort(); - - const error = new Error('Aborted'); - error.name = 'AbortError'; - mockChatModel.invoke.mockRejectedValue(error); - - await expect(handler(mockContext)).rejects.toThrow('Aborted'); - }); - }); - - describe('prompt composition', () => { - it('should compose prompts in correct order', async () => { - const mockResponse = { - content: 'Summary', - response_metadata: {}, - }; - - mockChatModel.invoke.mockResolvedValue(mockResponse); - - await handler(mockContext); - - // Verify build functions were called in order - const callOrder = [ - mockBuildSystemPart.mock.invocationCallOrder[0], - mockBuildDataPart.mock.invocationCallOrder[0], - mockBuildRequirementsPart.mock.invocationCallOrder[0], - mockBuildInstructionsPart.mock.invocationCallOrder[0], - ]; - - expect(callOrder[0]).toBeLessThan(callOrder[1]); - expect(callOrder[1]).toBeLessThan(callOrder[2]); - expect(callOrder[2]).toBeLessThan(callOrder[3]); - }); - - it('should flatten all prompt parts into single array', async () => { - mockBuildSystemPart.mockReturnValue([ - { role: 'system', content: 'System 1' }, - { role: 'system', content: 'System 2' }, - ]); - mockBuildDataPart.mockReturnValue([ - { role: 'user', content: 'Data 1' }, - { role: 'user', content: 'Data 2' }, - ]); - - const mockResponse = { - content: 'Summary', - response_metadata: {}, - }; - - mockChatModel.invoke.mockResolvedValue(mockResponse); - - await handler(mockContext); - - expect(mockChatModel.invoke).toHaveBeenCalledWith( - [ - { role: 'system', content: 'System 1' }, - { role: 'system', content: 'System 2' }, - { role: 'user', content: 'Data 1' }, - { role: 'user', content: 'Data 2' }, - { role: 'user', content: 'Requirements for summary' }, - { role: 'user', content: 'Additional instructions' }, - ], - { signal: mockAbortController.signal } - ); - }); - }); - }); -}); diff --git a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_summarize_step/build_prompts.test.ts b/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_summarize_step/build_prompts.test.ts deleted file mode 100644 index 435ba175da4bf..0000000000000 --- a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_summarize_step/build_prompts.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { - buildDataPart, - buildInstructionsPart, - buildRequirementsPart, - buildSystemPart, -} from './build_prompts'; - -describe('buildSystemPart', () => { - it('should return an array with a system message', () => { - const result = buildSystemPart(); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty('role', 'system'); - expect(result[0]).toHaveProperty('content'); - expect(typeof result[0].content).toBe('string'); - }); - - it('should return consistent results across multiple calls', () => { - const result1 = buildSystemPart(); - const result2 = buildSystemPart(); - - expect(result1).toEqual(result2); - }); - - it('should have non-empty content', () => { - const result = buildSystemPart(); - - expect(result[0].content.length).toBeGreaterThan(0); - if (typeof result[0].content === 'string') { - expect(result[0].content.trim()).not.toBe(''); - } - }); -}); - -describe('buildDataPart', () => { - describe('with string input', () => { - it('should return an array with a user message', () => { - const input = 'Test string data'; - const result = buildDataPart(input); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty('role', 'user'); - expect(result[0]).toHaveProperty('content'); - expect(typeof result[0].content).toBe('string'); - }); - - it('should use json marker for markdown', () => { - const input = { name: 'John', age: 30 }; - const result = buildDataPart(input); - - expect(result[0].content).toContain('```json'); - }); - - it('should use text marker for markdown', () => { - const input = 'foo bar'; - const result = buildDataPart(input); - - expect(result[0].content).toContain('```text'); - }); - - it('should include the input string in content', () => { - const input = 'Test string data'; - const result = buildDataPart(input); - - expect(result[0].content).toContain(input); - }); - - it('should handle empty string', () => { - const input = ''; - const result = buildDataPart(input); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThan(0); - }); - - it('should handle multi-line strings', () => { - const input = 'Line 1\nLine 2\nLine 3'; - const result = buildDataPart(input); - - expect(result[0].content).toContain(input); - }); - }); - - describe('with object input', () => { - it('should return an array with a user message', () => { - const input = { key: 'value', number: 42 }; - const result = buildDataPart(input); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty('role', 'user'); - expect(result[0]).toHaveProperty('content'); - expect(typeof result[0].content).toBe('string'); - }); - - it('should stringify object input', () => { - const input = { name: 'John', age: 30 }; - const result = buildDataPart(input); - - expect(result[0].content).toContain('John'); - expect(result[0].content).toContain('30'); - }); - - it('should use json marker for markdown', () => { - const input = { name: 'John', age: 30 }; - const result = buildDataPart(input); - - expect(result[0].content).toContain('```json'); - }); - - it('should handle nested objects', () => { - const input = { - user: { - name: 'John', - address: { - city: 'New York', - }, - }, - }; - const result = buildDataPart(input); - - expect(result[0].content).toContain('New York'); - }); - - it('should handle empty object', () => { - const input = {}; - const result = buildDataPart(input); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThan(0); - }); - }); - - describe('with array input', () => { - it('should return an array with a user message', () => { - const input = ['item1', 'item2', 'item3']; - const result = buildDataPart(input); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty('role', 'user'); - expect(result[0]).toHaveProperty('content'); - expect(typeof result[0].content).toBe('string'); - }); - - it('should stringify array input', () => { - const input = ['item1', 'item2']; - const result = buildDataPart(input); - - expect(result[0].content).toContain('item1'); - expect(result[0].content).toContain('item2'); - }); - - it('should handle empty array', () => { - const input: unknown[] = []; - const result = buildDataPart(input); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThan(0); - }); - - it('should handle array of objects', () => { - const input = [ - { id: 1, name: 'First' }, - { id: 2, name: 'Second' }, - ]; - const result = buildDataPart(input); - - expect(result[0].content).toContain('First'); - expect(result[0].content).toContain('Second'); - }); - }); - - describe('with primitive types', () => { - it('should handle number input', () => { - const input = 42; - const result = buildDataPart(input); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThan(0); - expect(result[0].content).toContain('42'); - }); - - it('should handle boolean input', () => { - const input = true; - const result = buildDataPart(input); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThan(0); - }); - - it('should handle null input', () => { - const input = null; - const result = buildDataPart(input); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThan(0); - }); - }); -}); - -describe('buildRequirementsPart', () => { - describe('with maxLength parameter', () => { - it('should return an array with a user message when maxLength is provided', () => { - const result = buildRequirementsPart({ maxLength: 100 }); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty('role', 'user'); - expect(result[0]).toHaveProperty('content'); - expect(typeof result[0].content).toBe('string'); - }); - - it('should include maxLength value in content', () => { - const result = buildRequirementsPart({ maxLength: 150 }); - - expect(result[0].content).toContain('150'); - }); - - it('should handle different maxLength values', () => { - const result1 = buildRequirementsPart({ maxLength: 50 }); - const result2 = buildRequirementsPart({ maxLength: 200 }); - - expect(result1[0].content).toContain('50'); - expect(result2[0].content).toContain('200'); - expect(result1[0].content).not.toEqual(result2[0].content); - }); - - it('should handle large maxLength values', () => { - const result = buildRequirementsPart({ maxLength: 10000 }); - - expect(result[0].content).toContain('10000'); - }); - - it('should handle small maxLength values', () => { - const result = buildRequirementsPart({ maxLength: 1 }); - - expect(result[0].content).toContain('1'); - }); - }); - - describe('without maxLength parameter', () => { - it('should return an empty array when maxLength is undefined', () => { - const result = buildRequirementsPart({ maxLength: undefined }); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBe(0); - }); - - it('should return an empty array when maxLength is not provided', () => { - const result = buildRequirementsPart({}); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBe(0); - }); - }); -}); - -describe('buildInstructionsPart', () => { - describe('with instructions parameter', () => { - it('should return an array with a user message when instructions are provided', () => { - const instructions = 'Focus on key points'; - const result = buildInstructionsPart(instructions); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty('role', 'user'); - expect(result[0]).toHaveProperty('content'); - expect(typeof result[0].content).toBe('string'); - }); - - it('should include instructions text in content', () => { - const instructions = 'Be concise and factual'; - const result = buildInstructionsPart(instructions); - - expect(result[0].content).toContain(instructions); - }); - - it('should handle different instruction texts', () => { - const instructions1 = 'Short summary'; - const instructions2 = 'Detailed analysis with examples'; - - const result1 = buildInstructionsPart(instructions1); - const result2 = buildInstructionsPart(instructions2); - - expect(result1[0].content).toContain(instructions1); - expect(result2[0].content).toContain(instructions2); - }); - - it('should handle multi-line instructions', () => { - const instructions = 'Line 1\nLine 2\nLine 3'; - const result = buildInstructionsPart(instructions); - - expect(result[0].content).toContain(instructions); - }); - - it('should handle long instruction text', () => { - const instructions = 'A'.repeat(1000); - const result = buildInstructionsPart(instructions); - - expect(result[0].content).toContain(instructions); - }); - - it('should handle empty string', () => { - const instructions = ''; - const result = buildInstructionsPart(instructions); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBe(0); - }); - - it('should handle whitespace-only string', () => { - const instructions = ' '; - const result = buildInstructionsPart(instructions); - - // Should still return the instructions even if just whitespace - // (behavior depends on implementation - adjust if needed) - expect(result).toEqual([]); - }); - }); - - describe('without instructions parameter', () => { - it('should return an empty array when instructions are undefined', () => { - const result = buildInstructionsPart(undefined); - - expect(result).toEqual([]); - }); - }); -}); diff --git a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_summarize_step/build_prompts.ts b/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_summarize_step/build_prompts.ts deleted file mode 100644 index eb9b5e68c74d2..0000000000000 --- a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_summarize_step/build_prompts.ts +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { MessageFieldWithRole } from '@langchain/core/messages'; - -export function buildSystemPart(): MessageFieldWithRole[] { - return [ - { - role: 'system', - content: ` -You are a specialized summarization engine that produces concise, factual summaries. - -CRITICAL RULES: -- Output ONLY the summary text itself -- Do NOT include any preambles, introductions, or phrases like "Here is the summary:", "Based on the data:", "The summary is:", etc. -- Do NOT engage in conversation or ask questions -- Do NOT add explanations, commentary, or meta-statements about the summary -- Do NOT use markdown formatting unless explicitly instructed -- Do NOT start responses with conversational phrases -- If you cannot summarize, output only: "Unable to generate summary" - -Your response must be the raw summary text with no additional content.`, - }, - ]; -} - -export function buildRequirementsPart(params: { maxLength?: number }): MessageFieldWithRole[] { - const { maxLength } = params; - const summaryRequirements: string[] = []; - - if (typeof maxLength === 'number') { - summaryRequirements.push(`Max length of summary must be ${maxLength} characters.`); - } - - if (summaryRequirements.length) { - return [ - { - role: 'user', - content: ` -# Requirements: -${summaryRequirements.map((req) => `- ${req}`).join('\n')} -`, - }, - ]; - } - - return []; -} - -export function buildDataPart(input: unknown): MessageFieldWithRole[] { - const inputType = typeof input === 'object' ? 'json' : 'text'; - let resolvedInput = input; - - if (inputType === 'json') { - resolvedInput = JSON.stringify(input); - } - - return [ - { - role: 'user', - content: ` -# Data to summarize: -\`\`\`${inputType} -${resolvedInput} -\`\`\` -`, - }, - ]; -} - -export function buildInstructionsPart(instructions: string | undefined): MessageFieldWithRole[] { - if (!instructions?.trim()) { - return []; - } - - return [ - { - role: 'user', - content: ` -# Additional instructions: -${instructions} -`, - }, - ]; -} diff --git a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_summarize_step/step.ts b/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_summarize_step/step.ts deleted file mode 100644 index 26c81d304c52c..0000000000000 --- a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/ai_summarize_step/step.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { MessageFieldWithRole } from '@langchain/core/messages'; -import type { CoreSetup } from '@kbn/core/server'; -import { - buildDataPart, - buildInstructionsPart, - buildRequirementsPart, - buildSystemPart, -} from './build_prompts'; -import { AiSummarizeStepCommonDefinition } from '../../../../common/steps/ai'; -import { createServerStepDefinition } from '../../../step_registry/types'; -import type { WorkflowsExtensionsServerPluginStartDeps } from '../../../types'; -import { resolveConnectorId } from '../utils/resolve_connector_id'; - -export const aiSummarizeStepDefinition = ( - coreSetup: CoreSetup -) => - createServerStepDefinition({ - ...AiSummarizeStepCommonDefinition, - handler: async (context) => { - const [, { inference }] = await coreSetup.getStartServices(); - - const resolvedConnectorId = await resolveConnectorId( - context.config['connector-id'], - inference, - context.contextManager.getFakeRequest() - ); - - const chatModel = await inference.getChatModel({ - connectorId: resolvedConnectorId, - request: context.contextManager.getFakeRequest(), - chatModelOptions: { - temperature: context.input.temperature, - maxRetries: 0, - }, - }); - - const modelInput: MessageFieldWithRole[] = [ - ...buildSystemPart(), - ...buildDataPart(context.input.input), - ...buildRequirementsPart({ maxLength: context.input.maxLength }), - ...buildInstructionsPart(context.input.instructions), - ]; - - const modelResponse = await chatModel.invoke(modelInput, { - signal: context.abortSignal, - }); - - // Convert content to string if it's an array - const content = - typeof modelResponse.content === 'string' - ? modelResponse.content - : modelResponse.content.map((part) => ('text' in part ? part.text : '')).join(''); - - return { - output: { - content, - metadata: modelResponse.response_metadata, - }, - }; - }, - }); diff --git a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/index.ts b/src/platform/plugins/shared/workflows_extensions/server/steps/ai/index.ts deleted file mode 100644 index 1cde2dc8b7b9a..0000000000000 --- a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -export * from './ai_prompt_step/step'; -export * from './ai_summarize_step/step'; -export * from './ai_classify_step/step'; diff --git a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/utils/resolve_connector_id.test.ts b/src/platform/plugins/shared/workflows_extensions/server/steps/ai/utils/resolve_connector_id.test.ts deleted file mode 100644 index f7018cd28e8fb..0000000000000 --- a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/utils/resolve_connector_id.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { KibanaRequest } from '@kbn/core/server'; -import type { InferenceConnector } from '@kbn/inference-common'; -import { InferenceConnectorType } from '@kbn/inference-common'; -import type { InferenceServerStart } from '@kbn/inference-plugin/server'; - -import { resolveConnectorId } from './resolve_connector_id'; - -describe('resolveConnectorId', () => { - let mockInferencePlugin: jest.Mocked; - let mockKibanaRequest: jest.Mocked; - - // Helper function to create mock connectors - const createMockConnector = (partial: Partial): InferenceConnector => ({ - type: InferenceConnectorType.OpenAI, - name: 'Mock Connector', - connectorId: 'mock-connector-id', - config: {}, - capabilities: {}, - isInferenceEndpoint: false, - isPreconfigured: false, - ...partial, - }); - - beforeEach(() => { - jest.clearAllMocks(); - - // Mock KibanaRequest - mockKibanaRequest = {} as jest.Mocked; - - // Mock InferenceServerStart - need to use 'any' to allow null/undefined returns - // that don't match the strict type signature but are handled by the implementation - mockInferencePlugin = { - getDefaultConnector: jest.fn(), - getConnectorList: jest.fn(), - getConnectorById: jest.fn(), - } as any; - }); - - describe('when nameOrId is undefined', () => { - it('should return the default connector ID when a default connector exists', async () => { - const defaultConnectorId = 'default-connector-123'; - const mockConnector = createMockConnector({ - connectorId: defaultConnectorId, - }); - mockInferencePlugin.getDefaultConnector.mockResolvedValue(mockConnector); - - const result = await resolveConnectorId(undefined, mockInferencePlugin, mockKibanaRequest); - - expect(mockInferencePlugin.getDefaultConnector).toHaveBeenCalledWith(mockKibanaRequest); - expect(result).toBe(defaultConnectorId); - }); - - it('should throw an error when no default connector is configured', async () => { - // Using type assertion since the implementation handles null but types don't allow it - mockInferencePlugin.getDefaultConnector.mockResolvedValue(null as any); - - await expect( - resolveConnectorId(undefined, mockInferencePlugin, mockKibanaRequest) - ).rejects.toThrow('No default AI connector configured'); - - expect(mockInferencePlugin.getDefaultConnector).toHaveBeenCalledWith(mockKibanaRequest); - }); - - it('should throw an error when default connector is undefined', async () => { - // Using type assertion since the implementation handles undefined but types don't allow it - mockInferencePlugin.getDefaultConnector.mockResolvedValue(undefined as any); - - await expect( - resolveConnectorId(undefined, mockInferencePlugin, mockKibanaRequest) - ).rejects.toThrow('No default AI connector configured'); - - expect(mockInferencePlugin.getDefaultConnector).toHaveBeenCalledWith(mockKibanaRequest); - }); - }); - - describe('when nameOrId is empty string', () => { - it('should return the default connector ID when nameOrId is empty string', async () => { - const defaultConnectorId = 'default-connector-123'; - const mockConnector = createMockConnector({ - connectorId: defaultConnectorId, - }); - mockInferencePlugin.getDefaultConnector.mockResolvedValue(mockConnector); - - const result = await resolveConnectorId('', mockInferencePlugin, mockKibanaRequest); - - expect(mockInferencePlugin.getDefaultConnector).toHaveBeenCalledWith(mockKibanaRequest); - expect(result).toBe(defaultConnectorId); - }); - }); - - describe('when nameOrId is a connector ID', () => { - it('should return the connector ID when it exists in the connector list', async () => { - const connectorId = 'openai-gpt4-connector-id'; - const mockConnectors = [ - createMockConnector({ - name: 'OpenAI GPT-4', - connectorId, - }), - ]; - mockInferencePlugin.getConnectorList.mockResolvedValue(mockConnectors); - mockInferencePlugin.getConnectorById.mockResolvedValue(mockConnectors[0]); - - const result = await resolveConnectorId(connectorId, mockInferencePlugin, mockKibanaRequest); - - expect(mockInferencePlugin.getConnectorById).toHaveBeenCalledWith( - connectorId, - mockKibanaRequest - ); - expect(mockInferencePlugin.getConnectorList).not.toHaveBeenCalled(); - expect(result).toBe(connectorId); - }); - - it('should throw an error when connector ID is not found', async () => { - const nonExistentId = 'non-existent-connector-id'; - const mockConnectors = [ - createMockConnector({ - name: 'OpenAI GPT-4', - connectorId: 'openai-gpt4-connector-id', - }), - ]; - mockInferencePlugin.getConnectorList.mockResolvedValue(mockConnectors); - - await expect( - resolveConnectorId(nonExistentId, mockInferencePlugin, mockKibanaRequest) - ).rejects.toThrow( - `AI Connector '${nonExistentId}' not found. Available AI connectors: OpenAI GPT-4 (ID: openai-gpt4-connector-id)` - ); - }); - }); - - describe('when nameOrId is a connector name', () => { - const mockConnectors = [ - createMockConnector({ - name: 'OpenAI GPT-4', - connectorId: 'openai-gpt4-connector-id', - }), - createMockConnector({ - name: 'Azure OpenAI', - connectorId: 'azure-openai-connector-id', - }), - createMockConnector({ - name: 'Anthropic Claude', - connectorId: 'anthropic-claude-connector-id', - }), - ]; - - beforeEach(() => { - mockInferencePlugin.getConnectorList.mockResolvedValue(mockConnectors); - }); - - it('should return the connector ID when connector name exists', async () => { - const connectorName = 'OpenAI GPT-4'; - const expectedConnectorId = 'openai-gpt4-connector-id'; - - const result = await resolveConnectorId( - connectorName, - mockInferencePlugin, - mockKibanaRequest - ); - - expect(mockInferencePlugin.getConnectorList).toHaveBeenCalledWith(mockKibanaRequest); - expect(result).toBe(expectedConnectorId); - }); - - it('should perform case-sensitive name matching', async () => { - const connectorName = 'openai gpt-4'; // Different case - - await expect( - resolveConnectorId(connectorName, mockInferencePlugin, mockKibanaRequest) - ).rejects.toThrow( - `AI Connector 'openai gpt-4' not found. Available AI connectors: OpenAI GPT-4 (ID: openai-gpt4-connector-id), Azure OpenAI (ID: azure-openai-connector-id), Anthropic Claude (ID: anthropic-claude-connector-id)` - ); - }); - - it('should throw an error when connector name is not found', async () => { - const nonExistentName = 'Non-existent Connector'; - - await expect( - resolveConnectorId(nonExistentName, mockInferencePlugin, mockKibanaRequest) - ).rejects.toThrow( - `AI Connector 'Non-existent Connector' not found. Available AI connectors: OpenAI GPT-4 (ID: openai-gpt4-connector-id), Azure OpenAI (ID: azure-openai-connector-id), Anthropic Claude (ID: anthropic-claude-connector-id)` - ); - - expect(mockInferencePlugin.getConnectorList).toHaveBeenCalledWith(mockKibanaRequest); - }); - - it('should handle empty connector list gracefully', async () => { - mockInferencePlugin.getConnectorList.mockResolvedValue([]); - const connectorName = 'Any Connector'; - - await expect( - resolveConnectorId(connectorName, mockInferencePlugin, mockKibanaRequest) - ).rejects.toThrow('No AI connectors found.'); - }); - }); - - describe('edge cases', () => { - it('should handle connector list with duplicate names (first match wins)', async () => { - const duplicateConnectors = [ - createMockConnector({ name: 'Duplicate Connector', connectorId: 'first-connector-id' }), - createMockConnector({ name: 'Duplicate Connector', connectorId: 'second-connector-id' }), - ]; - - mockInferencePlugin.getConnectorList.mockResolvedValue(duplicateConnectors); - - const result = await resolveConnectorId( - 'Duplicate Connector', - mockInferencePlugin, - mockKibanaRequest - ); - - expect(result).toBe('first-connector-id'); - }); - }); -}); diff --git a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/utils/resolve_connector_id.ts b/src/platform/plugins/shared/workflows_extensions/server/steps/ai/utils/resolve_connector_id.ts deleted file mode 100644 index 644180e2890be..0000000000000 --- a/src/platform/plugins/shared/workflows_extensions/server/steps/ai/utils/resolve_connector_id.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import type { KibanaRequest } from '@kbn/core/server'; -import type { InferenceServerStart } from '@kbn/inference-plugin/server'; - -export async function resolveConnectorId( - nameOrId: string | undefined, - inferencePlugin: InferenceServerStart, - kibanaRequest: KibanaRequest -): Promise { - if (!nameOrId) { - const defaultConnector = await inferencePlugin.getDefaultConnector(kibanaRequest); - - if (!defaultConnector) { - throw new Error('No default AI connector configured'); - } - - return defaultConnector.connectorId; - } - - const connectorById = await inferencePlugin.getConnectorById(nameOrId, kibanaRequest); - - if (connectorById) { - return connectorById.connectorId; - } - - const allConnectors = await inferencePlugin.getConnectorList(kibanaRequest); - - if (!allConnectors.length) { - throw new Error(`No AI connectors found.`); - } - - const connector = allConnectors.find((c) => c.name === nameOrId); - - if (!connector) { - throw new Error( - `AI Connector '${nameOrId}' not found. Available AI connectors: ${allConnectors - .map((c) => `${c.name} (ID: ${c.connectorId})`) - .join(', ')}` - ); - } - - return connector.connectorId; -} diff --git a/src/platform/plugins/shared/workflows_extensions/server/steps/index.ts b/src/platform/plugins/shared/workflows_extensions/server/steps/index.ts index 640d30af894bb..172a53bdc2517 100644 --- a/src/platform/plugins/shared/workflows_extensions/server/steps/index.ts +++ b/src/platform/plugins/shared/workflows_extensions/server/steps/index.ts @@ -7,10 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { CoreSetup } from '@kbn/core/server'; -import { aiClassifyStepDefinition } from './ai/ai_classify_step/step'; -import { aiPromptStepDefinition } from './ai/ai_prompt_step/step'; -import { aiSummarizeStepDefinition } from './ai/ai_summarize_step/step'; import { dataAggregateStepDefinition, dataConcatStepDefinition, @@ -24,12 +20,8 @@ import { dataStringifyJsonStepDefinition, } from './data'; import type { ServerStepRegistry } from '../step_registry/step_registry'; -import type { WorkflowsExtensionsServerPluginStartDeps } from '../types'; -export const registerInternalStepDefinitions = ( - core: CoreSetup, - serverStepRegistry: ServerStepRegistry -) => { +export const registerInternalStepDefinitions = (serverStepRegistry: ServerStepRegistry) => { serverStepRegistry.register(dataMapStepDefinition); serverStepRegistry.register(dataDedupeStepDefinition); serverStepRegistry.register(dataFilterStepDefinition); @@ -40,7 +32,4 @@ export const registerInternalStepDefinitions = ( serverStepRegistry.register(dataConcatStepDefinition); serverStepRegistry.register(dataParseJsonStepDefinition); serverStepRegistry.register(dataStringifyJsonStepDefinition); - serverStepRegistry.register(aiClassifyStepDefinition(core)); - serverStepRegistry.register(aiPromptStepDefinition(core)); - serverStepRegistry.register(aiSummarizeStepDefinition(core)); }; diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 23e0e144c8fe5..d9f7e88a92a05 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -46,15 +46,9 @@ "xpack.dataVisualizer": "platform/plugins/private/data_visualizer", "xpack.exploratoryView": "solutions/observability/plugins/exploratory_view", "xpack.fileUpload": "platform/plugins/private/file_upload", - "xpack.globalSearch": [ - "platform/plugins/shared/global_search" - ], - "xpack.globalSearchBar": [ - "platform/plugins/private/global_search_bar" - ], - "xpack.graph": [ - "platform/plugins/private/graph" - ], + "xpack.globalSearch": ["platform/plugins/shared/global_search"], + "xpack.globalSearchBar": ["platform/plugins/private/global_search_bar"], + "xpack.graph": ["platform/plugins/private/graph"], "xpack.grokDebugger": "platform/plugins/private/grokdebugger", "xpack.idxMgmt": "platform/plugins/shared/index_management", "xpack.idxMgmtPackage": "packages/index-management", @@ -66,6 +60,7 @@ "xpack.fleet": "platform/plugins/shared/fleet", "xpack.ingestPipelines": "platform/plugins/shared/ingest_pipelines", "xpack.inference": "platform/plugins/shared/inference", + "xpack.inferenceWorkflows": "platform/plugins/shared/inference_workflows", "xpack.inventory": "solutions/observability/plugins/inventory", "xpack.kubernetesSecurity": "solutions/security/plugins/kubernetes_security", "xpack.lens": "platform/plugins/shared/lens", @@ -73,13 +68,9 @@ "xpack.licenseMgmt": "platform/plugins/shared/license_management", "xpack.licensing": "platform/plugins/shared/licensing", "xpack.lists": "solutions/security/plugins/lists", - "xpack.logstash": [ - "platform/plugins/private/logstash" - ], + "xpack.logstash": ["platform/plugins/private/logstash"], "xpack.main": "legacy/plugins/xpack_main", - "xpack.maps": [ - "platform/plugins/shared/maps" - ], + "xpack.maps": ["platform/plugins/shared/maps"], "xpack.metricsData": "solutions/observability/plugins/metrics_data_access", "xpack.ml": [ "platform/packages/shared/ml/anomaly_utils", @@ -93,9 +84,7 @@ "platform/packages/private/ml/ui_actions", "platform/plugins/shared/ml" ], - "xpack.monitoring": [ - "platform/plugins/private/monitoring" - ], + "xpack.monitoring": ["platform/plugins/private/monitoring"], "xpack.observability": "solutions/observability/plugins/observability", "xpack.observabilityAgentBuilder": "solutions/observability/plugins/observability_agent_builder", "xpack.observabilityAiAssistant": [ @@ -106,26 +95,18 @@ "xpack.observabilityLogsExplorer": "solutions/observability/plugins/observability_logs_explorer", "xpack.observability_onboarding": "solutions/observability/plugins/observability_onboarding", "xpack.observabilityShared": "solutions/observability/plugins/observability_shared", - "xpack.observabilityLogsOverview": [ - "platform/packages/shared/logs-overview/src/components" - ], - "xpack.agentBuilder": ["platform/plugins/shared/agent_builder", "platform/packages/shared/agent-builder"], - "xpack.osquery": [ - "platform/plugins/shared/osquery" + "xpack.observabilityLogsOverview": ["platform/packages/shared/logs-overview/src/components"], + "xpack.agentBuilder": [ + "platform/plugins/shared/agent_builder", + "platform/packages/shared/agent-builder" ], + "xpack.osquery": ["platform/plugins/shared/osquery"], "xpack.painlessLab": "platform/plugins/private/painless_lab", - "xpack.profiling": [ - "solutions/observability/plugins/profiling" - ], + "xpack.profiling": ["solutions/observability/plugins/profiling"], "xpack.reindexService": "platform/plugins/private/reindex_service", "xpack.remoteClusters": "platform/plugins/private/remote_clusters", - "xpack.reporting": [ - "platform/plugins/private/reporting" - ], - "xpack.rollupJobs": [ - "platform/packages/private/rollup", - "platform/plugins/private/rollup" - ], + "xpack.reporting": ["platform/plugins/private/reporting"], + "xpack.rollupJobs": ["platform/packages/private/rollup", "platform/plugins/private/rollup"], "xpack.runtimeFields": "platform/plugins/private/runtime_fields", "xpack.screenshotting": "platform/plugins/shared/screenshotting", "xpack.searchSharedUI": "solutions/search/packages/shared-ui", @@ -149,15 +130,11 @@ "xpack.securitySolutionEss": "solutions/security/plugins/security_solution_ess", "xpack.securitySolutionServerless": "solutions/security/plugins/security_solution_serverless", "xpack.sessionView": "solutions/security/plugins/session_view", - "xpack.streams": [ - "platform/plugins/shared/streams_app" - ], + "xpack.streams": ["platform/plugins/shared/streams_app"], "xpack.slo": "solutions/observability/plugins/slo", "xpack.snapshotRestore": "platform/plugins/private/snapshot_restore", "xpack.spaces": "platform/plugins/shared/spaces", - "xpack.savedObjectsTagging": [ - "platform/plugins/shared/saved_objects_tagging" - ], + "xpack.savedObjectsTagging": ["platform/plugins/shared/saved_objects_tagging"], "xpack.taskManager": "legacy/platform/plugins/shared/task_manager", "xpack.threatIntelligence": "solutions/security/plugins/threat_intelligence", "xpack.timelines": "solutions/security/plugins/timelines", @@ -169,22 +146,14 @@ "platform/packages/private/upgrade-assistant/public", "platform/packages/private/upgrade-assistant/server" ], - "xpack.uptime": [ - "solutions/observability/plugins/uptime" - ], - "xpack.synthetics": [ - "solutions/observability/plugins/synthetics" - ], - "xpack.ux": [ - "solutions/observability/plugins/ux" - ], + "xpack.uptime": ["solutions/observability/plugins/uptime"], + "xpack.synthetics": ["solutions/observability/plugins/synthetics"], + "xpack.ux": ["solutions/observability/plugins/ux"], "xpack.urlDrilldown": "platform/plugins/private/drilldowns/url_drilldown", "xpack.watcher": "platform/plugins/private/watcher", "xpack.eventStacktrace": "platform/packages/shared/kbn-event-stacktrace" }, - "exclude": [ - "examples" - ], + "exclude": ["examples"], "translations": [ "@kbn/translations-plugin/translations/zh-CN.json", "@kbn/translations-plugin/translations/ja-JP.json", From 95fde9e9343328816480c35bdfaf97dfe7ca477c Mon Sep 17 00:00:00 2001 From: Yan Savitski Date: Wed, 13 May 2026 09:20:00 +0200 Subject: [PATCH 03/20] Add jest.config.js for inferenceWorkflows plugin --- .../shared/inference_workflows/jest.config.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 x-pack/platform/plugins/shared/inference_workflows/jest.config.js diff --git a/x-pack/platform/plugins/shared/inference_workflows/jest.config.js b/x-pack/platform/plugins/shared/inference_workflows/jest.config.js new file mode 100644 index 0000000000000..d140968a32c75 --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/jest.config.js @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../..', + roots: [ + '/x-pack/platform/plugins/shared/inference_workflows/public', + '/x-pack/platform/plugins/shared/inference_workflows/server', + '/x-pack/platform/plugins/shared/inference_workflows/common', + ], +}; From 7d97d4b89741af7bcfc4de02cb23c4d4118ced1a Mon Sep 17 00:00:00 2001 From: Yan Savitski Date: Wed, 13 May 2026 09:25:00 +0200 Subject: [PATCH 04/20] Add optimizer limits for inferenceWorkflows plugin --- packages/kbn-optimizer/limits.yml | 1 + packages/kbn-rspack-optimizer/limits.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 93b83804fc473..1a863c6f19873 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -91,6 +91,7 @@ pageLoadAssetSize: indexLifecycleManagement: 35090 indexManagement: 39694 inference: 10368 + inferenceWorkflows: 25000 infra: 56302 ingestHub: 6112 ingestPipelines: 17866 diff --git a/packages/kbn-rspack-optimizer/limits.yml b/packages/kbn-rspack-optimizer/limits.yml index 1114219ee4bc4..4877d6c40dbc5 100644 --- a/packages/kbn-rspack-optimizer/limits.yml +++ b/packages/kbn-rspack-optimizer/limits.yml @@ -91,6 +91,7 @@ pageLoadAssetSize: indexLifecycleManagement: 29229 indexManagement: 316 inference: 5751 + inferenceWorkflows: 25000 infra: 45673 ingestHub: 1923 ingestPipelines: 270 From d00d6c342e2fabebe0fbbd4fe30ec1757e7209be Mon Sep 17 00:00:00 2001 From: Yan Savitski Date: Wed, 13 May 2026 09:30:00 +0200 Subject: [PATCH 05/20] Restore original JSDoc comments in moved files --- .../common/steps/ai/ai_classify_step.ts | 18 ++++++++++++++++++ .../common/steps/ai/ai_prompt_step.ts | 19 +++++++++++++++++++ .../common/steps/ai/ai_summarize_step.ts | 14 ++++++++++++++ .../server/steps/ai/ai_classify_step/step.ts | 2 +- .../server/steps/ai/ai_prompt_step/step.ts | 7 +++++++ 5 files changed, 59 insertions(+), 1 deletion(-) diff --git a/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_classify_step.ts b/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_classify_step.ts index 9e388a7075655..4620b4984081c 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_classify_step.ts +++ b/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_classify_step.ts @@ -10,12 +10,18 @@ import { StepCategory } from '@kbn/workflows'; import { z } from '@kbn/zod/v4'; import type { CommonStepDefinition } from '@kbn/workflows-extensions/common'; +/** + * Step type ID for the AI classify step. + */ export const AiClassifyStepTypeId = 'ai.classify'; export const ConfigSchema = z.object({ 'connector-id': z.string().optional(), }); +/** + * Input schema for the AI classify step. + */ export const InputSchema = z.object({ input: z.union([z.string(), z.array(z.unknown()), z.record(z.string(), z.unknown())]), categories: z.array(z.string()).min(1), @@ -26,6 +32,10 @@ export const InputSchema = z.object({ temperature: z.number().min(0).max(1).optional(), }); +/** + * Output schema for the AI classify step. + * This is the base schema - the dynamic schema will be created based on input parameters. + */ export const OutputSchema = z.object({ category: z.string().optional(), categories: z.array(z.string()).optional(), @@ -37,6 +47,11 @@ export type AiClassifyStepConfigSchema = typeof ConfigSchema; export type AiClassifyStepInputSchema = typeof InputSchema; export type AiClassifyStepOutputSchema = typeof OutputSchema; +/** + * Common step definition for AI classify step. + * This is shared between server and public implementations. + * Input and output types are automatically inferred from the schemas. + */ export const AiClassifyStepCommonDefinition: CommonStepDefinition< AiClassifyStepInputSchema, AiClassifyStepOutputSchema, @@ -135,6 +150,9 @@ When \`allowMultipleCategories\` is true, the output includes a \`categories\` a configSchema: ConfigSchema, }; +/** + * Builds a dynamic Zod schema for structured output based on AI classification step inputs. + */ export function buildStructuredOutputSchema( params: z.infer ): typeof OutputSchema { diff --git a/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_prompt_step.ts b/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_prompt_step.ts index 44b847ad71f1f..14d501ea04614 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_prompt_step.ts +++ b/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_prompt_step.ts @@ -11,14 +11,24 @@ import { JsonModelShapeSchema } from '@kbn/workflows/spec/schema/common/json_mod import { z } from '@kbn/zod/v4'; import type { CommonStepDefinition } from '@kbn/workflows-extensions/common'; +/** + * Step type ID for the AI prompt step. + */ export const AiPromptStepTypeId = 'ai.prompt'; export const ConfigSchema = z.object({ 'connector-id': z.string().optional(), }); +// Maybe we can define specific schema for metadata in the future +// For now it's a record with string keys and any values +// Because langchain returns it this format export const MetadataSchema = z.record(z.string(), z.any()); +/** + * Input schema for the AI prompt step. + * Uses variables structure with key->value pairs. + */ export const InputSchema = z.object({ prompt: z.string(), systemPrompt: z.string().optional(), @@ -38,12 +48,21 @@ const StringOutputSchema = z.object({ metadata: MetadataSchema, }); +/** + * Output schema for the AI prompt step. + * Uses variables structure with key->value pairs. + */ export const OutputSchema = z.union([StringOutputSchema, getStructuredOutputSchema(z.unknown())]); export type AiPromptStepConfigSchema = typeof ConfigSchema; export type AiPromptStepInputSchema = typeof InputSchema; export type AiPromptStepOutputSchema = typeof OutputSchema; +/** + * Common step definition for AI prompt step. + * This is shared between server and public implementations. + * Input and output types are automatically inferred from the schemas. + */ export const AiPromptStepCommonDefinition: CommonStepDefinition< AiPromptStepInputSchema, AiPromptStepOutputSchema, diff --git a/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_summarize_step.ts b/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_summarize_step.ts index 1069f9d146d2e..956dfd66fd01c 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_summarize_step.ts +++ b/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_summarize_step.ts @@ -10,12 +10,18 @@ import { StepCategory } from '@kbn/workflows'; import { z } from '@kbn/zod/v4'; import type { CommonStepDefinition } from '@kbn/workflows-extensions/common'; +/** + * Step type ID for the AI summarize step. + */ export const AiSummarizeStepTypeId = 'ai.summarize'; export const ConfigSchema = z.object({ 'connector-id': z.string().optional(), }); +/** + * Input schema for the AI summarize step. + */ export const InputSchema = z.object({ input: z.union([z.string(), z.array(z.unknown()), z.record(z.string(), z.unknown())]), instructions: z.string().optional(), @@ -23,6 +29,9 @@ export const InputSchema = z.object({ temperature: z.number().min(0).max(1).optional(), }); +/** + * Output schema for the AI summarize step. + */ export const OutputSchema = z.object({ content: z.string(), metadata: z.record(z.string(), z.any()).optional(), @@ -32,6 +41,11 @@ export type AiSummarizeStepConfigSchema = typeof ConfigSchema; export type AiSummarizeStepInputSchema = typeof InputSchema; export type AiSummarizeStepOutputSchema = typeof OutputSchema; +/** + * Common step definition for AI summarize step. + * This is shared between server and public implementations. + * Input and output types are automatically inferred from the schemas. + */ export const AiSummarizeStepCommonDefinition: CommonStepDefinition< AiSummarizeStepInputSchema, AiSummarizeStepOutputSchema, diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/step.ts b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/step.ts index 9c07915d039ba..7334fa8a07e27 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/step.ts +++ b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/step.ts @@ -39,7 +39,7 @@ export const aiClassifyStepDefinition = (coreSetup: CoreSetup Date: Thu, 14 May 2026 22:20:46 +0000 Subject: [PATCH 06/20] Changes from node scripts/lint.js --fix --- package.json | 1 + tsconfig.base.json | 2 ++ yarn.lock | 4 ++++ 3 files changed, 7 insertions(+) diff --git a/package.json b/package.json index a5d49189acc73..9af9efd5016d6 100644 --- a/package.json +++ b/package.json @@ -754,6 +754,7 @@ "@kbn/inference-prompt-utils": "link:x-pack/platform/packages/shared/kbn-inference-prompt-utils", "@kbn/inference-tracing": "link:x-pack/platform/packages/shared/kbn-inference-tracing", "@kbn/inference-tracing-config": "link:x-pack/platform/packages/shared/kbn-inference-tracing-config", + "@kbn/inference-workflows-plugin": "link:x-pack/platform/plugins/shared/inference_workflows", "@kbn/infra-forge": "link:x-pack/platform/packages/private/kbn-infra-forge", "@kbn/infra-plugin": "link:x-pack/solutions/observability/plugins/infra", "@kbn/ingest-hub-plugin": "link:x-pack/platform/plugins/shared/ingest_hub", diff --git a/tsconfig.base.json b/tsconfig.base.json index 7500a0ac65790..acb4e008742ac 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1446,6 +1446,8 @@ "@kbn/inference-tracing/*": ["x-pack/platform/packages/shared/kbn-inference-tracing/*"], "@kbn/inference-tracing-config": ["x-pack/platform/packages/shared/kbn-inference-tracing-config"], "@kbn/inference-tracing-config/*": ["x-pack/platform/packages/shared/kbn-inference-tracing-config/*"], + "@kbn/inference-workflows-plugin": ["x-pack/platform/plugins/shared/inference_workflows"], + "@kbn/inference-workflows-plugin/*": ["x-pack/platform/plugins/shared/inference_workflows/*"], "@kbn/infra-forge": ["x-pack/platform/packages/private/kbn-infra-forge"], "@kbn/infra-forge/*": ["x-pack/platform/packages/private/kbn-infra-forge/*"], "@kbn/infra-plugin": ["x-pack/solutions/observability/plugins/infra"], diff --git a/yarn.lock b/yarn.lock index 05b0fb5ffff95..63a64a0e8c6ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7416,6 +7416,10 @@ version "0.0.0" uid "" +"@kbn/inference-workflows-plugin@link:x-pack/platform/plugins/shared/inference_workflows": + version "0.0.0" + uid "" + "@kbn/infra-forge@link:x-pack/platform/packages/private/kbn-infra-forge": version "0.0.0" uid "" From 93f7a43f7f98830d01894536ec2e4ed10081916f Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 14 May 2026 22:27:21 +0000 Subject: [PATCH 07/20] Changes from node scripts/lint_ts_projects --fix --- src/platform/plugins/shared/workflows_extensions/tsconfig.json | 1 - 1 file changed, 1 deletion(-) diff --git a/src/platform/plugins/shared/workflows_extensions/tsconfig.json b/src/platform/plugins/shared/workflows_extensions/tsconfig.json index bd35df14b777d..f770ee233b640 100644 --- a/src/platform/plugins/shared/workflows_extensions/tsconfig.json +++ b/src/platform/plugins/shared/workflows_extensions/tsconfig.json @@ -20,7 +20,6 @@ "@kbn/scout", "@kbn/i18n", "@kbn/inference-plugin", - "@kbn/inference-common", "@kbn/actions-plugin", "@kbn/utility-types", "@kbn/spaces-plugin", From 00b3ba416f74912ca5bdb1e6484899ffdcde34a5 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 14 May 2026 22:27:53 +0000 Subject: [PATCH 08/20] Changes from node scripts/build_plugin_list_docs --- docs/extend/plugin-list.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/extend/plugin-list.md b/docs/extend/plugin-list.md index eaba2f2965193..236452bc874ee 100644 --- a/docs/extend/plugin-list.md +++ b/docs/extend/plugin-list.md @@ -172,6 +172,7 @@ mapped_pages: | [indicesMetadata](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/private/indices_metadata/README.md) | Plugin for managing and retrieving metadata about indices in Kibana. This plugin collects and processes metadata from Elasticsearch indices, data streams, ILM policies, and index templates. | | [inference](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/inference/README.md) | The inference plugin is a central place to handle all interactions with the Elasticsearch Inference API and external LLM APIs. Its goals are: | | [inferenceEndpoint](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/inference_endpoint/README.md) | A Kibana plugin | +| [inferenceWorkflows](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/inference_workflows) | WARNING: Missing or empty README. | | [infra](https://github.com/elastic/kibana/blob/main/x-pack/solutions/observability/plugins/infra/README.md) | This is the home of the infra plugin, which aims to provide a solution for the infrastructure monitoring use-case within Kibana. | | [ingestHub](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/ingest_hub/README.md) | Cross-solution onboarding page for adding data sources and integrations. Gated behind the ingestHub.enabled feature flag. | | [ingestPipelines](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/ingest_pipelines/README.md) | The ingest_pipelines plugin provides Kibana support for Elasticsearch's ingest pipelines. | From 0b86a410eff39100cbbde40c552891bac97ae27a Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 14 May 2026 22:32:14 +0000 Subject: [PATCH 09/20] Changes from node scripts/regenerate_moon_projects.js --update --- .../shared/workflows_extensions/moon.yml | 1 - .../shared/inference_workflows/moon.yml | 41 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 x-pack/platform/plugins/shared/inference_workflows/moon.yml diff --git a/src/platform/plugins/shared/workflows_extensions/moon.yml b/src/platform/plugins/shared/workflows_extensions/moon.yml index 9f51100af9e60..25f40f3b5cda2 100644 --- a/src/platform/plugins/shared/workflows_extensions/moon.yml +++ b/src/platform/plugins/shared/workflows_extensions/moon.yml @@ -25,7 +25,6 @@ dependsOn: - '@kbn/scout' - '@kbn/i18n' - '@kbn/inference-plugin' - - '@kbn/inference-common' - '@kbn/actions-plugin' - '@kbn/utility-types' - '@kbn/spaces-plugin' diff --git a/x-pack/platform/plugins/shared/inference_workflows/moon.yml b/x-pack/platform/plugins/shared/inference_workflows/moon.yml new file mode 100644 index 0000000000000..22439a6bfdd5f --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/moon.yml @@ -0,0 +1,41 @@ +# This file is generated by the @kbn/moon package. Any manual edits will be erased! +# To extend this, write your extensions/overrides to 'moon.extend.yml' +# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/inference-workflows-plugin' + +$schema: https://moonrepo.dev/schemas/project.json +id: '@kbn/inference-workflows-plugin' +layer: unknown +owners: + defaultOwner: '@elastic/search-kibana' +toolchains: + default: node +language: typescript +project: + title: '@kbn/inference-workflows-plugin' + description: Moon project for @kbn/inference-workflows-plugin + channel: '' + owner: '@elastic/search-kibana' + sourceRoot: x-pack/platform/plugins/shared/inference_workflows +dependsOn: + - '@kbn/core' + - '@kbn/i18n' + - '@kbn/zod' + - '@kbn/workflows' + - '@kbn/workflows-extensions' + - '@kbn/inference-plugin' + - '@kbn/inference-common' +tags: + - plugin + - prod + - group-platform + - shared + - jest-unit-tests +fileGroups: + src: + - common/**/* + - public/**/* + - server/**/* + - '!target/**/*' + jest-config: + - jest.config.js +tasks: {} From d19441c89af25173a4014f4a0446a3958fc01d4d Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 May 2026 14:22:45 +0000 Subject: [PATCH 10/20] Changes from node scripts/lint_ts_projects --fix --- .../platform/plugins/shared/inference_workflows/tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/platform/plugins/shared/inference_workflows/tsconfig.json b/x-pack/platform/plugins/shared/inference_workflows/tsconfig.json index 7fc3dcf5bac86..036b4a1dd242d 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/tsconfig.json +++ b/x-pack/platform/plugins/shared/inference_workflows/tsconfig.json @@ -12,6 +12,7 @@ "@kbn/workflows", "@kbn/workflows-extensions", "@kbn/inference-plugin", - "@kbn/inference-common" + "@kbn/inference-common", + "@kbn/search-inference-endpoints" ] } From a359467c00e07bcf8281e90ebf9819fb04a3dba1 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 May 2026 14:30:00 +0000 Subject: [PATCH 11/20] Changes from node scripts/regenerate_moon_projects.js --update --- x-pack/platform/plugins/shared/inference_workflows/moon.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/platform/plugins/shared/inference_workflows/moon.yml b/x-pack/platform/plugins/shared/inference_workflows/moon.yml index 22439a6bfdd5f..f6aa4cfb112a6 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/moon.yml +++ b/x-pack/platform/plugins/shared/inference_workflows/moon.yml @@ -24,6 +24,7 @@ dependsOn: - '@kbn/workflows-extensions' - '@kbn/inference-plugin' - '@kbn/inference-common' + - '@kbn/search-inference-endpoints' tags: - plugin - prod From b12e639cc8e7eda2c32ff505ec4d3d132458c663 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 May 2026 14:49:58 +0000 Subject: [PATCH 12/20] Changes from node scripts/eslint_all_files --no-cache --fix --- .../inference_workflows/server/steps/ai/ai_feature_ids.ts | 8 +++----- .../server/steps/ai/register_inference_features.ts | 8 +++----- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_feature_ids.ts b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_feature_ids.ts index 25836484bc558..c897139a69014 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_feature_ids.ts +++ b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_feature_ids.ts @@ -1,10 +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", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". + * 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 WORKFLOWS_AI_PARENT_FEATURE_ID = 'workflows_ai'; diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/register_inference_features.ts b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/register_inference_features.ts index decf3831c89c0..243d0e53cab4b 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/register_inference_features.ts +++ b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/register_inference_features.ts @@ -1,10 +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", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". + * 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 { SearchInferenceEndpointsPluginSetup } from '@kbn/search-inference-endpoints/server'; From 0aca65b61a1704323b3a41457556f8982c3053ec Mon Sep 17 00:00:00 2001 From: Yan Savitski Date: Wed, 20 May 2026 13:12:39 +0200 Subject: [PATCH 13/20] Wire searchInferenceEndpoints into inference_workflows, clean up stale deps from workflows_extensions - Move searchInferenceEndpoints optional dep from workflows_extensions to inference_workflows - Call registerInferenceFeatures in inference_workflows plugin setup - Fix SSPL license headers on ai_feature_ids.ts and register_inference_features.ts (x-pack requires EL2.0) - Add InferenceWorkflowsSetupDeps/StartDeps types with searchInferenceEndpoints --- .../plugins/shared/workflows_extensions/kibana.jsonc | 2 -- .../plugins/shared/workflows_extensions/server/plugin.ts | 5 ----- .../plugins/shared/workflows_extensions/server/types.ts | 9 +-------- .../plugins/shared/workflows_extensions/tsconfig.json | 3 +-- .../plugins/shared/inference_workflows/kibana.jsonc | 3 ++- .../plugins/shared/inference_workflows/server/plugin.ts | 6 ++++++ .../server/steps/ai/ai_feature_ids.ts | 8 +++----- .../server/steps/ai/register_inference_features.ts | 8 +++----- .../plugins/shared/inference_workflows/server/types.ts | 6 ++++++ .../plugins/shared/inference_workflows/tsconfig.json | 3 ++- 10 files changed, 24 insertions(+), 29 deletions(-) diff --git a/src/platform/plugins/shared/workflows_extensions/kibana.jsonc b/src/platform/plugins/shared/workflows_extensions/kibana.jsonc index 4d75a8445d9a8..a3c3ec19fe296 100644 --- a/src/platform/plugins/shared/workflows_extensions/kibana.jsonc +++ b/src/platform/plugins/shared/workflows_extensions/kibana.jsonc @@ -11,8 +11,6 @@ "browser": true, "server": true, "requiredPlugins": ["actions", "inference", "spaces"], - "optionalPlugins": ["searchInferenceEndpoints"], "extraPublicDirs": ["common"] } } - diff --git a/src/platform/plugins/shared/workflows_extensions/server/plugin.ts b/src/platform/plugins/shared/workflows_extensions/server/plugin.ts index 4d91158f34f17..82de14172907f 100644 --- a/src/platform/plugins/shared/workflows_extensions/server/plugin.ts +++ b/src/platform/plugins/shared/workflows_extensions/server/plugin.ts @@ -19,7 +19,6 @@ import { registerGetStepDefinitionsRoute } from './routes/get_step_definitions'; import { registerGetTriggerDefinitionsRoute } from './routes/get_trigger_definitions'; import { ServerStepRegistry } from './step_registry'; import { registerInternalStepDefinitions } from './steps'; -import { registerInferenceFeatures } from './steps/ai/register_inference_features'; import { TriggerRegistry } from './trigger_registry'; import { registerInternalTriggerDefinitions } from './triggers'; import type { @@ -73,10 +72,6 @@ export class WorkflowsExtensionsServerPlugin registerInternalStepDefinitions(this.stepRegistry); registerInternalTriggerDefinitions(this.triggerRegistry); - if (plugins.searchInferenceEndpoints) { - registerInferenceFeatures(plugins.searchInferenceEndpoints); - } - return { registerStepDefinition: (definition) => { this.stepRegistry.register(definition); diff --git a/src/platform/plugins/shared/workflows_extensions/server/types.ts b/src/platform/plugins/shared/workflows_extensions/server/types.ts index 8d6c81a3e137e..5df7954d2a773 100644 --- a/src/platform/plugins/shared/workflows_extensions/server/types.ts +++ b/src/platform/plugins/shared/workflows_extensions/server/types.ts @@ -10,10 +10,6 @@ import type { PluginStartContract as ActionsPluginStartContract } from '@kbn/actions-plugin/server'; import type { CustomRequestHandlerContext, KibanaRequest } from '@kbn/core/server'; import type { InferenceServerStart } from '@kbn/inference-plugin/server'; -import type { - SearchInferenceEndpointsPluginSetup, - SearchInferenceEndpointsPluginStart, -} from '@kbn/search-inference-endpoints/server'; import type { SpacesPluginStart } from '@kbn/spaces-plugin/server'; import type { WorkflowsApiRequestHandlerContext, @@ -89,9 +85,7 @@ export type WorkflowsExtensionsServerPluginStart = /** * Dependencies for the server plugin setup phase. */ -export interface WorkflowsExtensionsServerPluginSetupDeps { - searchInferenceEndpoints?: SearchInferenceEndpointsPluginSetup; -} +export type WorkflowsExtensionsServerPluginSetupDeps = Record; export type ServerStepDefinitionOrLoader< Input extends z.ZodType = z.ZodType, @@ -107,7 +101,6 @@ export type ServerStepDefinitionOrLoader< export interface WorkflowsExtensionsServerPluginStartDeps { actions: ActionsPluginStartContract; inference: InferenceServerStart; - searchInferenceEndpoints?: SearchInferenceEndpointsPluginStart; spaces?: SpacesPluginStart; } diff --git a/src/platform/plugins/shared/workflows_extensions/tsconfig.json b/src/platform/plugins/shared/workflows_extensions/tsconfig.json index 4ecf5e3167cad..f770ee233b640 100644 --- a/src/platform/plugins/shared/workflows_extensions/tsconfig.json +++ b/src/platform/plugins/shared/workflows_extensions/tsconfig.json @@ -28,8 +28,7 @@ "@kbn/std", "@kbn/logging-mocks", "@kbn/logging", - "@kbn/core-http-common", - "@kbn/search-inference-endpoints" + "@kbn/core-http-common" ], "exclude": ["target/**/*"] } diff --git a/x-pack/platform/plugins/shared/inference_workflows/kibana.jsonc b/x-pack/platform/plugins/shared/inference_workflows/kibana.jsonc index 4262c058f722d..b756e7d5a2743 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/kibana.jsonc +++ b/x-pack/platform/plugins/shared/inference_workflows/kibana.jsonc @@ -10,6 +10,7 @@ "server": true, "browser": true, "configPath": ["xpack", "inferenceWorkflows"], - "requiredPlugins": ["inference", "workflowsExtensions"] + "requiredPlugins": ["inference", "workflowsExtensions"], + "optionalPlugins": ["searchInferenceEndpoints"] } } diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/plugin.ts b/x-pack/platform/plugins/shared/inference_workflows/server/plugin.ts index 9595bbdf5f88a..2dc8da536dac4 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/server/plugin.ts +++ b/x-pack/platform/plugins/shared/inference_workflows/server/plugin.ts @@ -10,6 +10,7 @@ import type { InferenceWorkflowsSetupDeps, InferenceWorkflowsStartDeps } from '. import { aiPromptStepDefinition } from './steps/ai/ai_prompt_step/step'; import { aiSummarizeStepDefinition } from './steps/ai/ai_summarize_step/step'; import { aiClassifyStepDefinition } from './steps/ai/ai_classify_step/step'; +import { registerInferenceFeatures } from './steps/ai/register_inference_features'; export class InferenceWorkflowsPlugin implements Plugin<{}, {}, InferenceWorkflowsSetupDeps, InferenceWorkflowsStartDeps> @@ -18,6 +19,11 @@ export class InferenceWorkflowsPlugin deps.workflowsExtensions.registerStepDefinition(aiPromptStepDefinition(core)); deps.workflowsExtensions.registerStepDefinition(aiSummarizeStepDefinition(core)); deps.workflowsExtensions.registerStepDefinition(aiClassifyStepDefinition(core)); + + if (deps.searchInferenceEndpoints) { + registerInferenceFeatures(deps.searchInferenceEndpoints); + } + return {}; } diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_feature_ids.ts b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_feature_ids.ts index 25836484bc558..c897139a69014 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_feature_ids.ts +++ b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_feature_ids.ts @@ -1,10 +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", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". + * 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 WORKFLOWS_AI_PARENT_FEATURE_ID = 'workflows_ai'; diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/register_inference_features.ts b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/register_inference_features.ts index decf3831c89c0..243d0e53cab4b 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/register_inference_features.ts +++ b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/register_inference_features.ts @@ -1,10 +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", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". + * 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 { SearchInferenceEndpointsPluginSetup } from '@kbn/search-inference-endpoints/server'; diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/types.ts b/x-pack/platform/plugins/shared/inference_workflows/server/types.ts index c3f71672d1d11..d0f07f925daea 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/server/types.ts +++ b/x-pack/platform/plugins/shared/inference_workflows/server/types.ts @@ -7,11 +7,17 @@ import type { InferenceServerStart } from '@kbn/inference-plugin/server'; import type { WorkflowsExtensionsServerPluginSetup } from '@kbn/workflows-extensions/server'; +import type { + SearchInferenceEndpointsPluginSetup, + SearchInferenceEndpointsPluginStart, +} from '@kbn/search-inference-endpoints/server'; export interface InferenceWorkflowsSetupDeps { workflowsExtensions: WorkflowsExtensionsServerPluginSetup; + searchInferenceEndpoints?: SearchInferenceEndpointsPluginSetup; } export interface InferenceWorkflowsStartDeps { inference: InferenceServerStart; + searchInferenceEndpoints?: SearchInferenceEndpointsPluginStart; } diff --git a/x-pack/platform/plugins/shared/inference_workflows/tsconfig.json b/x-pack/platform/plugins/shared/inference_workflows/tsconfig.json index 7fc3dcf5bac86..036b4a1dd242d 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/tsconfig.json +++ b/x-pack/platform/plugins/shared/inference_workflows/tsconfig.json @@ -12,6 +12,7 @@ "@kbn/workflows", "@kbn/workflows-extensions", "@kbn/inference-plugin", - "@kbn/inference-common" + "@kbn/inference-common", + "@kbn/search-inference-endpoints" ] } From 74289b8ecfa68ea8b34459c20b6feb0c7c64a840 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 20 May 2026 11:29:46 +0000 Subject: [PATCH 14/20] Changes from node scripts/regenerate_moon_projects.js --update --- src/platform/plugins/shared/workflows_extensions/moon.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/src/platform/plugins/shared/workflows_extensions/moon.yml b/src/platform/plugins/shared/workflows_extensions/moon.yml index fa4741430925e..25f40f3b5cda2 100644 --- a/src/platform/plugins/shared/workflows_extensions/moon.yml +++ b/src/platform/plugins/shared/workflows_extensions/moon.yml @@ -34,7 +34,6 @@ dependsOn: - '@kbn/logging-mocks' - '@kbn/logging' - '@kbn/core-http-common' - - '@kbn/search-inference-endpoints' tags: - plugin - prod From 89d421da316b8f9b881f5cadd4ce202e2e24256c Mon Sep 17 00:00:00 2001 From: Yan Savitski Date: Wed, 20 May 2026 13:53:01 +0200 Subject: [PATCH 15/20] Add README for inference_workflows plugin --- docs/extend/plugin-list.md | 2 +- x-pack/platform/plugins/shared/inference_workflows/README.md | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 x-pack/platform/plugins/shared/inference_workflows/README.md diff --git a/docs/extend/plugin-list.md b/docs/extend/plugin-list.md index 236452bc874ee..f82f3e283bf0b 100644 --- a/docs/extend/plugin-list.md +++ b/docs/extend/plugin-list.md @@ -172,7 +172,7 @@ mapped_pages: | [indicesMetadata](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/private/indices_metadata/README.md) | Plugin for managing and retrieving metadata about indices in Kibana. This plugin collects and processes metadata from Elasticsearch indices, data streams, ILM policies, and index templates. | | [inference](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/inference/README.md) | The inference plugin is a central place to handle all interactions with the Elasticsearch Inference API and external LLM APIs. Its goals are: | | [inferenceEndpoint](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/inference_endpoint/README.md) | A Kibana plugin | -| [inferenceWorkflows](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/inference_workflows) | WARNING: Missing or empty README. | +| [inferenceWorkflows](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/inference_workflows/README.md) | Registers AI workflow steps (ai.prompt, ai.summarize, ai.classify) that use the inference plugin to power LLM-based automation in Kibana Workflows. | | [infra](https://github.com/elastic/kibana/blob/main/x-pack/solutions/observability/plugins/infra/README.md) | This is the home of the infra plugin, which aims to provide a solution for the infrastructure monitoring use-case within Kibana. | | [ingestHub](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/ingest_hub/README.md) | Cross-solution onboarding page for adding data sources and integrations. Gated behind the ingestHub.enabled feature flag. | | [ingestPipelines](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/ingest_pipelines/README.md) | The ingest_pipelines plugin provides Kibana support for Elasticsearch's ingest pipelines. | diff --git a/x-pack/platform/plugins/shared/inference_workflows/README.md b/x-pack/platform/plugins/shared/inference_workflows/README.md new file mode 100644 index 0000000000000..876f906d464bb --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/README.md @@ -0,0 +1,3 @@ +# Inference Workflows + +Registers AI workflow steps (`ai.prompt`, `ai.summarize`, `ai.classify`) that use the inference plugin to power LLM-based automation in Kibana Workflows. From a7420e94f04e7318e703339088c8865d3d7155b7 Mon Sep 17 00:00:00 2001 From: Yan Savitski Date: Wed, 20 May 2026 15:15:04 +0200 Subject: [PATCH 16/20] Fix TypeScript errors: ContextManager type and product_agent declaration --- typings/@elastic/eui/index.d.ts | 5 +++++ .../server/steps/ai/ai_classify_step/step.test.ts | 4 +++- .../server/steps/ai/ai_prompt_step/ai_prompt_step.test.ts | 4 +++- .../steps/ai/ai_summarize_step/ai_summarize_step.test.ts | 4 +++- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/typings/@elastic/eui/index.d.ts b/typings/@elastic/eui/index.d.ts index 7e7d739ecf805..80608da18e1eb 100644 --- a/typings/@elastic/eui/index.d.ts +++ b/typings/@elastic/eui/index.d.ts @@ -12,3 +12,8 @@ declare module '@elastic/eui/lib/services' { export const RIGHT_ALIGNMENT: any; } + +declare module '@elastic/eui/es/components/icon/assets/product_agent' { + import type { ComponentType, SVGProps } from 'react'; + export const icon: ComponentType>; +} diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/step.test.ts b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/step.test.ts index dd148053a3b6c..5cd9546e282d7 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/step.test.ts +++ b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/step.test.ts @@ -46,7 +46,9 @@ import { import { aiClassifyStepDefinition } from './step'; import { validateModelResponse } from './validate_model_response'; import { buildStructuredOutputSchema } from '../../../../common/steps/ai'; -import type { ContextManager, StepHandlerContext } from '@kbn/workflows-extensions/server'; +import type { StepHandlerContext } from '@kbn/workflows-extensions/server'; + +type ContextManager = StepHandlerContext['contextManager']; import { createServerStepDefinition } from '@kbn/workflows-extensions/server'; import type { InferenceWorkflowsStartDeps } from '../../../types'; import { resolveConnectorId } from '../utils/resolve_connector_id'; diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_prompt_step/ai_prompt_step.test.ts b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_prompt_step/ai_prompt_step.test.ts index f78375e2dc4c6..597f7e74a8428 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_prompt_step/ai_prompt_step.test.ts +++ b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_prompt_step/ai_prompt_step.test.ts @@ -25,7 +25,9 @@ jest.mock('@kbn/workflows-extensions/server', () => ({ })); import { aiPromptStepDefinition } from './step'; -import type { ContextManager, StepHandlerContext } from '@kbn/workflows-extensions/server'; +import type { StepHandlerContext } from '@kbn/workflows-extensions/server'; + +type ContextManager = StepHandlerContext['contextManager']; import { createServerStepDefinition } from '@kbn/workflows-extensions/server'; import type { InferenceWorkflowsStartDeps } from '../../../types'; import { resolveConnectorId } from '../utils/resolve_connector_id'; diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_summarize_step/ai_summarize_step.test.ts b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_summarize_step/ai_summarize_step.test.ts index 8c3ed11ff161b..cb30e23219a48 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_summarize_step/ai_summarize_step.test.ts +++ b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_summarize_step/ai_summarize_step.test.ts @@ -40,7 +40,9 @@ import { } from './build_prompts'; import { aiSummarizeStepDefinition } from './step'; import { createServerStepDefinition } from '@kbn/workflows-extensions/server'; -import type { ContextManager, StepHandlerContext } from '@kbn/workflows-extensions/server'; +import type { StepHandlerContext } from '@kbn/workflows-extensions/server'; + +type ContextManager = StepHandlerContext['contextManager']; import type { InferenceWorkflowsStartDeps } from '../../../types'; import { resolveConnectorId } from '../utils/resolve_connector_id'; From abaa52277f1552af83ee99f5322ad1762b0675e4 Mon Sep 17 00:00:00 2001 From: Yan Savitski Date: Wed, 20 May 2026 20:39:30 +0200 Subject: [PATCH 17/20] Fix product_agent TS error: use src/ path instead of es/ to match EUI type declarations --- typings/@elastic/eui/index.d.ts | 5 ----- .../inference_workflows/public/steps/ai/ai_classify_step.ts | 2 +- .../inference_workflows/public/steps/ai/ai_prompt_step.ts | 2 +- .../inference_workflows/public/steps/ai/ai_summarize_step.ts | 2 +- 4 files changed, 3 insertions(+), 8 deletions(-) diff --git a/typings/@elastic/eui/index.d.ts b/typings/@elastic/eui/index.d.ts index 80608da18e1eb..7e7d739ecf805 100644 --- a/typings/@elastic/eui/index.d.ts +++ b/typings/@elastic/eui/index.d.ts @@ -12,8 +12,3 @@ declare module '@elastic/eui/lib/services' { export const RIGHT_ALIGNMENT: any; } - -declare module '@elastic/eui/es/components/icon/assets/product_agent' { - import type { ComponentType, SVGProps } from 'react'; - export const icon: ComponentType>; -} diff --git a/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_classify_step.ts b/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_classify_step.ts index 3e6f761fcaf3b..2c9cc2ff4bf6a 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_classify_step.ts +++ b/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_classify_step.ts @@ -15,7 +15,7 @@ import { export const AiClassifyStepDefinition = createPublicStepDefinition({ ...AiClassifyStepCommonDefinition, icon: React.lazy(() => - import('@elastic/eui/es/components/icon/assets/product_agent').then(({ icon }) => ({ + import('@elastic/eui/src/components/icon/assets/product_agent').then(({ icon }) => ({ default: icon, })) ), diff --git a/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_prompt_step.ts b/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_prompt_step.ts index 6155140c85d6d..ed454f7884cd1 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_prompt_step.ts +++ b/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_prompt_step.ts @@ -17,7 +17,7 @@ import { export const AiPromptStepDefinition = createPublicStepDefinition({ ...AiPromptStepCommonDefinition, icon: React.lazy(() => - import('@elastic/eui/es/components/icon/assets/product_agent').then(({ icon }) => ({ + import('@elastic/eui/src/components/icon/assets/product_agent').then(({ icon }) => ({ default: icon, })) ), diff --git a/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_summarize_step.ts b/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_summarize_step.ts index 0ceb46977b297..3f14ba2509f12 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_summarize_step.ts +++ b/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_summarize_step.ts @@ -12,7 +12,7 @@ import { AiSummarizeStepCommonDefinition } from '../../../common/steps/ai'; export const AiSummarizeStepDefinition = createPublicStepDefinition({ ...AiSummarizeStepCommonDefinition, icon: React.lazy(() => - import('@elastic/eui/es/components/icon/assets/product_agent').then(({ icon }) => ({ + import('@elastic/eui/src/components/icon/assets/product_agent').then(({ icon }) => ({ default: icon, })) ), From 2ee97bebce86dff9f561b6f471077505693a3d18 Mon Sep 17 00:00:00 2001 From: Yan Savitski Date: Thu, 21 May 2026 11:02:31 +0200 Subject: [PATCH 18/20] Use @elastic/eui/es icon path with local eui_icons.d.ts declaration Add public/eui_icons.d.ts (matching agent_builder pattern) and revert icon imports from src/ back to es/ path. --- .../inference_workflows/public/eui_icons.d.ts | 21 +++++++++++++++++++ .../public/steps/ai/ai_classify_step.ts | 2 +- .../public/steps/ai/ai_prompt_step.ts | 2 +- .../public/steps/ai/ai_summarize_step.ts | 2 +- 4 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 x-pack/platform/plugins/shared/inference_workflows/public/eui_icons.d.ts diff --git a/x-pack/platform/plugins/shared/inference_workflows/public/eui_icons.d.ts b/x-pack/platform/plugins/shared/inference_workflows/public/eui_icons.d.ts new file mode 100644 index 0000000000000..dbacfe2681740 --- /dev/null +++ b/x-pack/platform/plugins/shared/inference_workflows/public/eui_icons.d.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +declare module '@elastic/eui/es/components/icon/assets/*' { + import type * as React from 'react'; + + interface SVGRProps { + title?: string; + titleId?: string; + } + export const icon: ({ + title, + titleId, + ...props + }: React.SVGProps & SVGRProps) => React.JSX.Element; + export {}; +} diff --git a/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_classify_step.ts b/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_classify_step.ts index 2c9cc2ff4bf6a..3e6f761fcaf3b 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_classify_step.ts +++ b/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_classify_step.ts @@ -15,7 +15,7 @@ import { export const AiClassifyStepDefinition = createPublicStepDefinition({ ...AiClassifyStepCommonDefinition, icon: React.lazy(() => - import('@elastic/eui/src/components/icon/assets/product_agent').then(({ icon }) => ({ + import('@elastic/eui/es/components/icon/assets/product_agent').then(({ icon }) => ({ default: icon, })) ), diff --git a/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_prompt_step.ts b/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_prompt_step.ts index ed454f7884cd1..6155140c85d6d 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_prompt_step.ts +++ b/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_prompt_step.ts @@ -17,7 +17,7 @@ import { export const AiPromptStepDefinition = createPublicStepDefinition({ ...AiPromptStepCommonDefinition, icon: React.lazy(() => - import('@elastic/eui/src/components/icon/assets/product_agent').then(({ icon }) => ({ + import('@elastic/eui/es/components/icon/assets/product_agent').then(({ icon }) => ({ default: icon, })) ), diff --git a/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_summarize_step.ts b/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_summarize_step.ts index 3f14ba2509f12..0ceb46977b297 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_summarize_step.ts +++ b/x-pack/platform/plugins/shared/inference_workflows/public/steps/ai/ai_summarize_step.ts @@ -12,7 +12,7 @@ import { AiSummarizeStepCommonDefinition } from '../../../common/steps/ai'; export const AiSummarizeStepDefinition = createPublicStepDefinition({ ...AiSummarizeStepCommonDefinition, icon: React.lazy(() => - import('@elastic/eui/src/components/icon/assets/product_agent').then(({ icon }) => ({ + import('@elastic/eui/es/components/icon/assets/product_agent').then(({ icon }) => ({ default: icon, })) ), From 3ece0c3d1361d44718ec7a904b3c2a037aedeac3 Mon Sep 17 00:00:00 2001 From: Yan Savitski Date: Thu, 21 May 2026 12:05:22 +0200 Subject: [PATCH 19/20] Fix i18n template literal substitution in AI step documentation strings Replace template literals with variable substitution in defaultMessage with i18n values object pattern as required by the i18n linter. --- .../inference_workflows/common/steps/ai/ai_classify_step.ts | 5 +++-- .../inference_workflows/common/steps/ai/ai_prompt_step.ts | 5 +++-- .../inference_workflows/common/steps/ai/ai_summarize_step.ts | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_classify_step.ts b/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_classify_step.ts index 4620b4984081c..5f91fe6b76712 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_classify_step.ts +++ b/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_classify_step.ts @@ -67,8 +67,9 @@ export const AiClassifyStepCommonDefinition: CommonStepDefinition< }), documentation: { details: i18n.translate('xpack.inferenceWorkflows.AiClassifyStep.documentation.details', { - defaultMessage: `The ${AiClassifyStepTypeId} step categorizes input data into predefined categories using an AI connector. The classification result can be referenced in later steps using template syntax.`, - values: { templateSyntax: '`{{ steps.stepName.output }}`' }, + defaultMessage: + 'The {stepTypeId} step categorizes input data into predefined categories using an AI connector. The classification result can be referenced in later steps using template syntax.', + values: { stepTypeId: AiClassifyStepTypeId }, }), examples: [ `## Basic Classification diff --git a/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_prompt_step.ts b/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_prompt_step.ts index 14d501ea04614..1cf77a6986859 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_prompt_step.ts +++ b/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_prompt_step.ts @@ -78,8 +78,9 @@ export const AiPromptStepCommonDefinition: CommonStepDefinition< }), documentation: { details: i18n.translate('xpack.inferenceWorkflows.AiPromptStep.documentation.details', { - defaultMessage: `The ${AiPromptStepTypeId} step sends a prompt to an AI connector and returns the response. The response can be referenced in later steps using template syntax like {templateSyntax}.`, - values: { templateSyntax: '`{{ steps.stepName.output }}`' }, + defaultMessage: + 'The {stepTypeId} step sends a prompt to an AI connector and returns the response. The response can be referenced in later steps using template syntax.', + values: { stepTypeId: AiPromptStepTypeId }, }), examples: [ `## Basic AI prompt diff --git a/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_summarize_step.ts b/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_summarize_step.ts index 956dfd66fd01c..ed1f4cac3ef26 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_summarize_step.ts +++ b/x-pack/platform/plugins/shared/inference_workflows/common/steps/ai/ai_summarize_step.ts @@ -61,8 +61,9 @@ export const AiSummarizeStepCommonDefinition: CommonStepDefinition< }), documentation: { details: i18n.translate('xpack.inferenceWorkflows.AiSummarizeStep.documentation.details', { - defaultMessage: `The ${AiSummarizeStepTypeId} step generates a concise summary of the provided content using an AI connector. The summary can be referenced in later steps using template syntax.`, - values: { templateSyntax: '`{{ steps.stepName.output }}`' }, + defaultMessage: + 'The {stepTypeId} step generates a concise summary of the provided content using an AI connector. The summary can be referenced in later steps using template syntax.', + values: { stepTypeId: AiSummarizeStepTypeId }, }), examples: [ `## Basic Summarization From adccec40abac7e5b778215a75f96789586c90e56 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Fri, 29 May 2026 13:55:39 +0000 Subject: [PATCH 20/20] Changes from node scripts/eslint_all_files --no-cache --fix --- .../server/steps/ai/ai_classify_step/schemas.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/schemas.ts b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/schemas.ts index 75a2a7bf3e076..6f11d6ad4aa8a 100644 --- a/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/schemas.ts +++ b/x-pack/platform/plugins/shared/inference_workflows/server/steps/ai/ai_classify_step/schemas.ts @@ -1,10 +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", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". + * 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 { OutputSchema } from '../../../../common/steps/ai/ai_classify_step';