[Obs AI Assistant] Add uuid to knowledge base entries to avoid overwriting accidentally (#191043)

Closes https://github.com/elastic/kibana/issues/184069

**The Problem**
The LLM decides the identifier (both `_id` and `doc_id`) for knowledge
base entries. The `_id` must be globally unique in Elasticsearch but the
LLM can easily pick the same id for different users thereby overwriting
one users learning with another users learning.

**Solution**
The LLM should not pick the `_id`. With this PR a UUID is generated for
new entries. This means the LLM will only be able to create new KB
entries - it will not be able to update existing ones.

`doc_id` has been removed, and replaced with a `title` property. Title
is simply a human readable string - it is not used to identify KB
entries.
To retain backwards compatability, we will display the `doc_id` if
`title` is not available

---------

Co-authored-by: Sandra G <neptunian@users.noreply.github.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Søren Louv-Jansen 2024-11-07 09:55:34 +01:00 committed by GitHub
parent 669761be5e
commit 7c92a10b32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 696 additions and 1447 deletions

View file

@ -82,8 +82,8 @@ export type ConversationUpdateRequest = ConversationRequestBase & {
export interface KnowledgeBaseEntry {
'@timestamp': string;
id: string;
title?: string;
text: string;
doc_id: string;
confidence: 'low' | 'medium' | 'high';
is_correction: boolean;
type?: 'user_instruction' | 'contextual';
@ -96,12 +96,12 @@ export interface KnowledgeBaseEntry {
}
export interface Instruction {
doc_id: string;
id: string;
text: string;
}
export interface AdHocInstruction {
doc_id?: string;
id?: string;
text: string;
instruction_type: 'user_instruction' | 'application_instruction';
}

View file

@ -7,6 +7,16 @@
import { ShortIdTable } from './short_id_table';
describe('shortIdTable', () => {
it('generates a short id from a uuid', () => {
const table = new ShortIdTable();
const uuid = 'd877f65c-4036-42c4-b105-19e2f1a1c045';
const shortId = table.take(uuid);
expect(shortId.length).toBe(4);
expect(table.lookup(shortId)).toBe(uuid);
});
it('generates at least 10k unique ids consistently', () => {
const ids = new Set();

View file

@ -52,9 +52,9 @@ const schema: RootSchema<RecallRanking> = {
},
};
export const RecallRankingEventType = 'observability_ai_assistant_recall_ranking';
export const recallRankingEventType = 'observability_ai_assistant_recall_ranking';
export const recallRankingEvent: EventTypeOpts<RecallRanking> = {
eventType: RecallRankingEventType,
eventType: recallRankingEventType,
schema,
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { KnowledgeBaseType } from '../../common/types';
import { v4 } from 'uuid';
import type { FunctionRegistrationParameters } from '.';
import { KnowledgeBaseEntryRole } from '../../common';
@ -14,6 +14,7 @@ export const SUMMARIZE_FUNCTION_NAME = 'summarize';
export function registerSummarizationFunction({
client,
functions,
resources,
}: FunctionRegistrationParameters) {
functions.registerFunction(
{
@ -28,10 +29,10 @@ export function registerSummarizationFunction({
parameters: {
type: 'object',
properties: {
id: {
title: {
type: 'string',
description:
'An id for the document. This should be a short human-readable keyword field with only alphabetic characters and underscores, that allow you to update it later.',
'A human readable title that can be used to identify the document later. This should be no longer than 255 characters',
},
text: {
type: 'string',
@ -54,7 +55,7 @@ export function registerSummarizationFunction({
},
},
required: [
'id' as const,
'title' as const,
'text' as const,
'is_correction' as const,
'confidence' as const,
@ -62,21 +63,23 @@ export function registerSummarizationFunction({
],
},
},
(
{ arguments: { id, text, is_correction: isCorrection, confidence, public: isPublic } },
async (
{ arguments: { title, text, is_correction: isCorrection, confidence, public: isPublic } },
signal
) => {
const id = v4();
resources.logger.debug(`Creating new knowledge base entry with id: ${id}`);
return client
.addKnowledgeBaseEntry({
entry: {
doc_id: id,
role: KnowledgeBaseEntryRole.AssistantSummarization,
id,
title,
text,
is_correction: isCorrection,
type: KnowledgeBaseType.Contextual,
confidence,
public: isPublic,
role: KnowledgeBaseEntryRole.AssistantSummarization,
confidence,
is_correction: isCorrection,
labels: {},
},
// signal,

View file

@ -42,7 +42,7 @@ const chatCompleteBaseRt = t.type({
]),
instructions: t.array(
t.intersection([
t.partial({ doc_id: t.string }),
t.partial({ id: t.string }),
t.type({
text: t.string,
instruction_type: t.union([

View file

@ -7,6 +7,7 @@
import { notImplemented } from '@hapi/boom';
import { nonEmptyStringRt, toBooleanRt } from '@kbn/io-ts-utils';
import * as t from 'io-ts';
import { v4 } from 'uuid';
import { FunctionDefinition } from '../../../common/functions/types';
import { KnowledgeBaseEntryRole } from '../../../common/types';
import type { RecalledEntry } from '../../service/knowledge_base_service';
@ -114,7 +115,8 @@ const functionRecallRoute = createObservabilityAIAssistantServerRoute({
throw notImplemented();
}
return client.recall({ queries, categories });
const entries = await client.recall({ queries, categories });
return { entries };
},
});
@ -122,11 +124,10 @@ const functionSummariseRoute = createObservabilityAIAssistantServerRoute({
endpoint: 'POST /internal/observability_ai_assistant/functions/summarize',
params: t.type({
body: t.type({
id: t.string,
title: t.string,
text: nonEmptyStringRt,
confidence: t.union([t.literal('low'), t.literal('medium'), t.literal('high')]),
is_correction: toBooleanRt,
type: t.union([t.literal('user_instruction'), t.literal('contextual')]),
public: toBooleanRt,
labels: t.record(t.string, t.string),
}),
@ -142,10 +143,9 @@ const functionSummariseRoute = createObservabilityAIAssistantServerRoute({
}
const {
title,
confidence,
id,
is_correction: isCorrection,
type,
text,
public: isPublic,
labels,
@ -153,11 +153,10 @@ const functionSummariseRoute = createObservabilityAIAssistantServerRoute({
return client.addKnowledgeBaseEntry({
entry: {
title,
confidence,
id,
doc_id: id,
id: v4(),
is_correction: isCorrection,
type,
text,
public: isPublic,
labels,

View file

@ -9,16 +9,12 @@ import type {
MlDeploymentAllocationState,
MlDeploymentState,
} from '@elastic/elasticsearch/lib/api/types';
import pLimit from 'p-limit';
import { notImplemented } from '@hapi/boom';
import { nonEmptyStringRt, toBooleanRt } from '@kbn/io-ts-utils';
import * as t from 'io-ts';
import { createObservabilityAIAssistantServerRoute } from '../create_observability_ai_assistant_server_route';
import {
Instruction,
KnowledgeBaseEntry,
KnowledgeBaseEntryRole,
KnowledgeBaseType,
} from '../../../common/types';
import { Instruction, KnowledgeBaseEntry, KnowledgeBaseEntryRole } from '../../../common/types';
const getKnowledgeBaseStatus = createObservabilityAIAssistantServerRoute({
endpoint: 'GET /internal/observability_ai_assistant/kb/status',
@ -108,18 +104,8 @@ const saveKnowledgeBaseUserInstruction = createObservabilityAIAssistantServerRou
}
const { id, text, public: isPublic } = resources.params.body;
return client.addKnowledgeBaseEntry({
entry: {
id,
doc_id: id,
text,
public: isPublic,
confidence: 'high',
is_correction: false,
type: KnowledgeBaseType.UserInstruction,
labels: {},
role: KnowledgeBaseEntryRole.UserEntry,
},
return client.addUserInstruction({
entry: { id, text, public: isPublic },
});
},
});
@ -153,26 +139,29 @@ const getKnowledgeBaseEntries = createObservabilityAIAssistantServerRoute({
},
});
const knowledgeBaseEntryRt = t.intersection([
t.type({
id: t.string,
title: t.string,
text: nonEmptyStringRt,
}),
t.partial({
confidence: t.union([t.literal('low'), t.literal('medium'), t.literal('high')]),
is_correction: toBooleanRt,
public: toBooleanRt,
labels: t.record(t.string, t.string),
role: t.union([
t.literal(KnowledgeBaseEntryRole.AssistantSummarization),
t.literal(KnowledgeBaseEntryRole.UserEntry),
t.literal(KnowledgeBaseEntryRole.Elastic),
]),
}),
]);
const saveKnowledgeBaseEntry = createObservabilityAIAssistantServerRoute({
endpoint: 'POST /internal/observability_ai_assistant/kb/entries/save',
params: t.type({
body: t.intersection([
t.type({
id: t.string,
text: nonEmptyStringRt,
}),
t.partial({
confidence: t.union([t.literal('low'), t.literal('medium'), t.literal('high')]),
is_correction: toBooleanRt,
public: toBooleanRt,
labels: t.record(t.string, t.string),
role: t.union([
t.literal('assistant_summarization'),
t.literal('user_entry'),
t.literal('elastic'),
]),
}),
]),
body: knowledgeBaseEntryRt,
}),
options: {
tags: ['access:ai_assistant'],
@ -184,27 +173,15 @@ const saveKnowledgeBaseEntry = createObservabilityAIAssistantServerRoute({
throw notImplemented();
}
const {
id,
text,
public: isPublic,
confidence,
is_correction: isCorrection,
labels,
role,
} = resources.params.body;
const entry = resources.params.body;
return client.addKnowledgeBaseEntry({
entry: {
id,
text,
doc_id: id,
confidence: confidence ?? 'high',
is_correction: isCorrection ?? false,
type: 'contextual',
public: isPublic ?? true,
labels: labels ?? {},
role: (role as KnowledgeBaseEntryRole) ?? KnowledgeBaseEntryRole.UserEntry,
confidence: 'high',
is_correction: false,
public: true,
labels: {},
role: KnowledgeBaseEntryRole.UserEntry,
...entry,
},
});
},
@ -235,12 +212,7 @@ const importKnowledgeBaseEntries = createObservabilityAIAssistantServerRoute({
endpoint: 'POST /internal/observability_ai_assistant/kb/entries/import',
params: t.type({
body: t.type({
entries: t.array(
t.type({
id: t.string,
text: nonEmptyStringRt,
})
),
entries: t.array(knowledgeBaseEntryRt),
}),
}),
options: {
@ -253,18 +225,29 @@ const importKnowledgeBaseEntries = createObservabilityAIAssistantServerRoute({
throw notImplemented();
}
const entries = resources.params.body.entries.map((entry) => ({
doc_id: entry.id,
confidence: 'high' as KnowledgeBaseEntry['confidence'],
is_correction: false,
type: 'contextual' as const,
public: true,
labels: {},
role: KnowledgeBaseEntryRole.UserEntry,
...entry,
}));
const status = await client.getKnowledgeBaseStatus();
if (!status.ready) {
throw new Error('Knowledge base is not ready');
}
return await client.importKnowledgeBaseEntries({ entries });
const limiter = pLimit(5);
const promises = resources.params.body.entries.map(async (entry) => {
return limiter(async () => {
return client.addKnowledgeBaseEntry({
entry: {
confidence: 'high',
is_correction: false,
public: true,
labels: {},
role: KnowledgeBaseEntryRole.UserEntry,
...entry,
},
});
});
});
await Promise.all(promises);
},
});
@ -273,8 +256,8 @@ export const knowledgeBaseRoutes = {
...getKnowledgeBaseStatus,
...getKnowledgeBaseEntries,
...saveKnowledgeBaseUserInstruction,
...getKnowledgeBaseUserInstructions,
...importKnowledgeBaseEntries,
...getKnowledgeBaseUserInstructions,
...saveKnowledgeBaseEntry,
...deleteKnowledgeBaseEntry,
};

View file

@ -47,21 +47,19 @@ import {
} from '../../../common/conversation_complete';
import { CompatibleJSONSchema } from '../../../common/functions/types';
import {
AdHocInstruction,
type AdHocInstruction,
type Conversation,
type ConversationCreateRequest,
type ConversationUpdateRequest,
type KnowledgeBaseEntry,
type Message,
KnowledgeBaseType,
KnowledgeBaseEntryRole,
} from '../../../common/types';
import { withoutTokenCountEvents } from '../../../common/utils/without_token_count_events';
import { CONTEXT_FUNCTION_NAME } from '../../functions/context';
import type { ChatFunctionClient } from '../chat_function_client';
import {
KnowledgeBaseEntryOperationType,
KnowledgeBaseService,
RecalledEntry,
} from '../knowledge_base_service';
import { KnowledgeBaseService, RecalledEntry } from '../knowledge_base_service';
import { getAccessQuery } from '../util/get_access_query';
import { getSystemMessageFromInstructions } from '../util/get_system_message_from_instructions';
import { replaceSystemMessage } from '../util/replace_system_message';
@ -709,7 +707,7 @@ export class ObservabilityAIAssistantClient {
}: {
queries: Array<{ text: string; boost?: number }>;
categories?: string[];
}): Promise<{ entries: RecalledEntry[] }> => {
}): Promise<RecalledEntry[]> => {
return (
this.dependencies.knowledgeBaseService?.recall({
namespace: this.dependencies.namespace,
@ -718,7 +716,7 @@ export class ObservabilityAIAssistantClient {
categories,
esClient: this.dependencies.esClient,
uiSettingsClient: this.dependencies.uiSettingsClient,
}) || { entries: [] }
}) || []
);
};
@ -730,31 +728,57 @@ export class ObservabilityAIAssistantClient {
return this.dependencies.knowledgeBaseService.setup();
};
addUserInstruction = async ({
entry,
}: {
entry: Omit<
KnowledgeBaseEntry,
'@timestamp' | 'confidence' | 'is_correction' | 'type' | 'role'
>;
}): Promise<void> => {
// for now we want to limit the number of user instructions to 1 per user
// if a user instruction already exists for the user, we get the id and update it
this.dependencies.logger.debug('Adding user instruction entry');
const existingId = await this.dependencies.knowledgeBaseService.getPersonalUserInstructionId({
isPublic: entry.public,
namespace: this.dependencies.namespace,
user: this.dependencies.user,
});
if (existingId) {
entry.id = existingId;
this.dependencies.logger.debug(`Updating user instruction with id "${existingId}"`);
}
return this.dependencies.knowledgeBaseService.addEntry({
namespace: this.dependencies.namespace,
user: this.dependencies.user,
entry: {
...entry,
confidence: 'high',
is_correction: false,
type: KnowledgeBaseType.UserInstruction,
labels: {},
role: KnowledgeBaseEntryRole.UserEntry,
},
});
};
addKnowledgeBaseEntry = async ({
entry,
}: {
entry: Omit<KnowledgeBaseEntry, '@timestamp'>;
entry: Omit<KnowledgeBaseEntry, '@timestamp' | 'type'>;
}): Promise<void> => {
return this.dependencies.knowledgeBaseService.addEntry({
namespace: this.dependencies.namespace,
user: this.dependencies.user,
entry,
entry: {
...entry,
type: KnowledgeBaseType.Contextual,
},
});
};
importKnowledgeBaseEntries = async ({
entries,
}: {
entries: Array<Omit<KnowledgeBaseEntry, '@timestamp'>>;
}): Promise<void> => {
const operations = entries.map((entry) => ({
type: KnowledgeBaseEntryOperationType.Index,
document: { ...entry, '@timestamp': new Date().toISOString() },
}));
await this.dependencies.knowledgeBaseService.addEntries({ operations });
};
getKnowledgeBaseEntries = async ({
query,
sortBy,

View file

@ -13,18 +13,14 @@ import { getSpaceIdFromPath } from '@kbn/spaces-plugin/common';
import type { TaskManagerSetupContract } from '@kbn/task-manager-plugin/server';
import { once } from 'lodash';
import type { AssistantScope } from '@kbn/ai-assistant-common';
import {
KnowledgeBaseEntryRole,
ObservabilityAIAssistantScreenContextRequest,
} from '../../common/types';
import { ObservabilityAIAssistantScreenContextRequest } from '../../common/types';
import type { ObservabilityAIAssistantPluginStartDependencies } from '../types';
import { ChatFunctionClient } from './chat_function_client';
import { ObservabilityAIAssistantClient } from './client';
import { conversationComponentTemplate } from './conversation_component_template';
import { kbComponentTemplate } from './kb_component_template';
import { KnowledgeBaseEntryOperationType, KnowledgeBaseService } from './knowledge_base_service';
import { KnowledgeBaseService } from './knowledge_base_service';
import type { RegistrationCallback, RespondFunctionResources } from './types';
import { splitKbText } from './util/split_kb_text';
function getResourceName(resource: string) {
return `.kibana-observability-ai-assistant-${resource}`;
@ -52,24 +48,11 @@ export const resourceNames = {
},
};
export const INDEX_QUEUED_DOCUMENTS_TASK_ID = 'observabilityAIAssistant:indexQueuedDocumentsTask';
export const INDEX_QUEUED_DOCUMENTS_TASK_TYPE = INDEX_QUEUED_DOCUMENTS_TASK_ID + 'Type';
type KnowledgeBaseEntryRequest = { id: string; labels?: Record<string, string> } & (
| {
text: string;
}
| {
texts: string[];
}
);
export class ObservabilityAIAssistantService {
private readonly core: CoreSetup<ObservabilityAIAssistantPluginStartDependencies>;
private readonly logger: Logger;
private readonly getModelId: () => Promise<string>;
private kbService?: KnowledgeBaseService;
public kbService?: KnowledgeBaseService;
private enableKnowledgeBase: boolean;
private readonly registrations: RegistrationCallback[] = [];
@ -93,26 +76,6 @@ export class ObservabilityAIAssistantService {
this.enableKnowledgeBase = enableKnowledgeBase;
this.allowInit();
if (enableKnowledgeBase) {
taskManager.registerTaskDefinitions({
[INDEX_QUEUED_DOCUMENTS_TASK_TYPE]: {
title: 'Index queued KB articles',
description:
'Indexes previously registered entries into the knowledge base when it is ready',
timeout: '30m',
maxAttempts: 2,
createTaskRunner: (context) => {
return {
run: async () => {
if (this.kbService) {
await this.kbService.processQueue();
}
},
};
},
},
});
}
}
getKnowledgeBaseStatus() {
@ -336,65 +299,6 @@ export class ObservabilityAIAssistantService {
return fnClient;
}
addToKnowledgeBaseQueue(entries: KnowledgeBaseEntryRequest[]): void {
if (this.enableKnowledgeBase) {
this.init()
.then(() => {
this.kbService!.queue(
entries.flatMap((entry) => {
const entryWithSystemProperties = {
...entry,
'@timestamp': new Date().toISOString(),
doc_id: entry.id,
public: true,
confidence: 'high' as const,
type: 'contextual' as const,
is_correction: false,
labels: {
...entry.labels,
},
role: KnowledgeBaseEntryRole.Elastic,
};
const operations =
'texts' in entryWithSystemProperties
? splitKbText(entryWithSystemProperties)
: [
{
type: KnowledgeBaseEntryOperationType.Index,
document: entryWithSystemProperties,
},
];
return operations;
})
);
})
.catch((error) => {
this.logger.error(
`Could not index ${entries.length} entries because of an initialisation error`
);
this.logger.error(error);
});
}
}
addCategoryToKnowledgeBase(categoryId: string, entries: KnowledgeBaseEntryRequest[]) {
if (this.enableKnowledgeBase) {
this.addToKnowledgeBaseQueue(
entries.map((entry) => {
return {
...entry,
labels: {
...entry.labels,
category: categoryId,
},
};
})
);
}
}
register(cb: RegistrationCallback) {
this.registrations.push(cb);
}

View file

@ -31,7 +31,16 @@ export const kbComponentTemplate: ClusterComponentTemplate['component_template']
properties: {
'@timestamp': date,
id: keyword,
doc_id: { type: 'text', fielddata: true },
doc_id: { type: 'text', fielddata: true }, // deprecated but kept for backwards compatibility
title: {
type: 'text',
fields: {
keyword: {
type: 'keyword',
ignore_above: 256,
},
},
},
user: {
properties: {
id: keyword,

View file

@ -9,16 +9,11 @@ import { serverUnavailable, gatewayTimeout, badRequest } from '@hapi/boom';
import type { ElasticsearchClient, IUiSettingsClient } from '@kbn/core/server';
import type { Logger } from '@kbn/logging';
import type { TaskManagerStartContract } from '@kbn/task-manager-plugin/server';
import pLimit from 'p-limit';
import pRetry from 'p-retry';
import { map, orderBy } from 'lodash';
import { orderBy } from 'lodash';
import { encode } from 'gpt-tokenizer';
import { MlTrainedModelDeploymentNodesStats } from '@elastic/elasticsearch/lib/api/types';
import {
INDEX_QUEUED_DOCUMENTS_TASK_ID,
INDEX_QUEUED_DOCUMENTS_TASK_TYPE,
resourceNames,
} from '..';
import { resourceNames } from '..';
import {
Instruction,
KnowledgeBaseEntry,
@ -63,36 +58,11 @@ function throwKnowledgeBaseNotReady(body: any) {
throw serverUnavailable(`Knowledge base is not ready yet`, body);
}
export enum KnowledgeBaseEntryOperationType {
Index = 'index',
Delete = 'delete',
}
interface KnowledgeBaseDeleteOperation {
type: KnowledgeBaseEntryOperationType.Delete;
doc_id?: string;
labels?: Record<string, string>;
}
interface KnowledgeBaseIndexOperation {
type: KnowledgeBaseEntryOperationType.Index;
document: KnowledgeBaseEntry;
}
export type KnowledgeBaseEntryOperation =
| KnowledgeBaseDeleteOperation
| KnowledgeBaseIndexOperation;
export class KnowledgeBaseService {
private hasSetup: boolean = false;
private _queue: KnowledgeBaseEntryOperation[] = [];
constructor(private readonly dependencies: Dependencies) {
this.ensureTaskScheduled();
}
constructor(private readonly dependencies: Dependencies) {}
setup = async () => {
this.dependencies.logger.debug('Setting up knowledge base');
if (!this.dependencies.enabled) {
return;
}
@ -192,7 +162,7 @@ export class KnowledgeBaseService {
);
if (isReady) {
return Promise.resolve();
return;
}
this.dependencies.logger.debug(`${elserModelId} model is not allocated yet`);
@ -202,116 +172,10 @@ export class KnowledgeBaseService {
}, retryOptions);
this.dependencies.logger.info(`${elserModelId} model is ready`);
this.ensureTaskScheduled();
};
private ensureTaskScheduled() {
if (!this.dependencies.enabled) {
return;
}
this.dependencies.taskManagerStart
.ensureScheduled({
taskType: INDEX_QUEUED_DOCUMENTS_TASK_TYPE,
id: INDEX_QUEUED_DOCUMENTS_TASK_ID,
state: {},
params: {},
schedule: {
interval: '1h',
},
})
.then(() => {
this.dependencies.logger.debug('Scheduled queue task');
return this.dependencies.taskManagerStart.runSoon(INDEX_QUEUED_DOCUMENTS_TASK_ID);
})
.then(() => {
this.dependencies.logger.debug('Queue task ran');
})
.catch((err) => {
this.dependencies.logger.error(`Failed to schedule queue task`);
this.dependencies.logger.error(err);
});
}
private async processOperation(operation: KnowledgeBaseEntryOperation) {
if (operation.type === KnowledgeBaseEntryOperationType.Delete) {
await this.dependencies.esClient.asInternalUser.deleteByQuery({
index: resourceNames.aliases.kb,
query: {
bool: {
filter: [
...(operation.doc_id ? [{ term: { _id: operation.doc_id } }] : []),
...(operation.labels
? map(operation.labels, (value, key) => {
return { term: { [key]: value } };
})
: []),
],
},
},
});
return;
}
await this.addEntry({
entry: operation.document,
});
}
async processQueue() {
if (!this._queue.length || !this.dependencies.enabled) {
return;
}
if (!(await this.status()).ready) {
this.dependencies.logger.debug(`Bailing on queue task: KB is not ready yet`);
return;
}
this.dependencies.logger.debug(`Processing queue`);
this.hasSetup = true;
this.dependencies.logger.info(`Processing ${this._queue.length} queue operations`);
const limiter = pLimit(5);
const operations = this._queue.concat();
await Promise.all(
operations.map((operation) =>
limiter(async () => {
this._queue.splice(operations.indexOf(operation), 1);
await this.processOperation(operation);
})
)
);
this.dependencies.logger.info('Processed all queued operations');
}
queue(operations: KnowledgeBaseEntryOperation[]): void {
if (!operations.length) {
return;
}
if (!this.hasSetup) {
this._queue.push(...operations);
return;
}
const limiter = pLimit(5);
const limitedFunctions = this._queue.map((operation) =>
limiter(() => this.processOperation(operation))
);
Promise.all(limitedFunctions).catch((err) => {
this.dependencies.logger.error(`Failed to process all queued operations`);
this.dependencies.logger.error(err);
});
}
status = async () => {
this.dependencies.logger.debug('Checking model status');
if (!this.dependencies.enabled) {
return { ready: false, enabled: false };
}
@ -324,15 +188,24 @@ export class KnowledgeBaseService {
const elserModelStats = modelStats.trained_model_stats[0];
const deploymentState = elserModelStats.deployment_stats?.state;
const allocationState = elserModelStats.deployment_stats?.allocation_status.state;
const ready = deploymentState === 'started' && allocationState === 'fully_allocated';
this.dependencies.logger.debug(
`Model deployment state: ${deploymentState}, allocation state: ${allocationState}, ready: ${ready}`
);
return {
ready: deploymentState === 'started' && allocationState === 'fully_allocated',
ready,
deployment_state: deploymentState,
allocation_state: allocationState,
model_name: elserModelId,
enabled: true,
};
} catch (error) {
this.dependencies.logger.debug(
`Failed to get status for model "${elserModelId}" due to ${error.message}`
);
return {
error: error instanceof errors.ResponseError ? error.body.error : String(error),
ready: false,
@ -380,18 +253,21 @@ export class KnowledgeBaseService {
};
const response = await this.dependencies.esClient.asInternalUser.search<
Pick<KnowledgeBaseEntry, 'text' | 'is_correction' | 'labels'>
Pick<KnowledgeBaseEntry, 'text' | 'is_correction' | 'labels' | 'title'> & { doc_id?: string }
>({
index: [resourceNames.aliases.kb],
query: esQuery,
size: 20,
_source: {
includes: ['text', 'is_correction', 'labels'],
includes: ['text', 'is_correction', 'labels', 'doc_id', 'title'],
},
});
return response.hits.hits.map((hit) => ({
...hit._source!,
text: hit._source?.text!,
is_correction: hit._source?.is_correction,
labels: hit._source?.labels,
title: hit._source?.title ?? hit._source?.doc_id, // use `doc_id` as fallback title for backwards compatibility
score: hit._score!,
id: hit._id!,
}));
@ -411,12 +287,11 @@ export class KnowledgeBaseService {
namespace: string;
esClient: { asCurrentUser: ElasticsearchClient; asInternalUser: ElasticsearchClient };
uiSettingsClient: IUiSettingsClient;
}): Promise<{
entries: RecalledEntry[];
}> => {
}): Promise<RecalledEntry[]> => {
if (!this.dependencies.enabled) {
return { entries: [] };
return [];
}
this.dependencies.logger.debug(
() => `Recalling entries from KB for queries: "${JSON.stringify(queries)}"`
);
@ -480,9 +355,7 @@ export class KnowledgeBaseService {
this.dependencies.logger.info(`Dropped ${droppedEntries} entries because of token limit`);
}
return {
entries: returnedEntries,
};
return returnedEntries;
};
getUserInstructions = async (
@ -508,11 +381,11 @@ export class KnowledgeBaseService {
},
},
size: 500,
_source: ['doc_id', 'text', 'public'],
_source: ['id', 'text', 'public'],
});
return response.hits.hits.map((hit) => ({
doc_id: hit._source?.doc_id ?? '',
id: hit._id!,
text: hit._source?.text ?? '',
public: hit._source?.public,
}));
@ -536,13 +409,17 @@ export class KnowledgeBaseService {
return { entries: [] };
}
try {
const response = await this.dependencies.esClient.asInternalUser.search<KnowledgeBaseEntry>({
const response = await this.dependencies.esClient.asInternalUser.search<
KnowledgeBaseEntry & { doc_id?: string }
>({
index: resourceNames.aliases.kb,
query: {
bool: {
filter: [
// filter title by query
...(query ? [{ wildcard: { doc_id: { value: `${query}*` } } }] : []),
// filter by search query
...(query
? [{ query_string: { query: `${query}*`, fields: ['doc_id', 'title'] } }]
: []),
{
// exclude user instructions
bool: { must_not: { term: { type: KnowledgeBaseType.UserInstruction } } },
@ -550,16 +427,17 @@ export class KnowledgeBaseService {
],
},
},
sort: [
{
[String(sortBy)]: {
order: sortDirection,
},
},
],
sort:
sortBy === 'title'
? [
{ ['title.keyword']: { order: sortDirection } },
{ doc_id: { order: sortDirection } }, // sort by doc_id for backwards compatibility
]
: [{ [String(sortBy)]: { order: sortDirection } }],
size: 500,
_source: {
includes: [
'title',
'doc_id',
'text',
'is_correction',
@ -577,6 +455,7 @@ export class KnowledgeBaseService {
return {
entries: response.hits.hits.map((hit) => ({
...hit._source!,
title: hit._source!.title ?? hit._source!.doc_id, // use `doc_id` as fallback title for backwards compatibility
role: hit._source!.role ?? KnowledgeBaseEntryRole.UserEntry,
score: hit._score,
id: hit._id!,
@ -590,7 +469,7 @@ export class KnowledgeBaseService {
}
};
getExistingUserInstructionId = async ({
getPersonalUserInstructionId = async ({
isPublic,
user,
namespace,
@ -602,9 +481,7 @@ export class KnowledgeBaseService {
if (!this.dependencies.enabled) {
return null;
}
const res = await this.dependencies.esClient.asInternalUser.search<
Pick<KnowledgeBaseEntry, 'doc_id'>
>({
const res = await this.dependencies.esClient.asInternalUser.search<KnowledgeBaseEntry>({
index: resourceNames.aliases.kb,
query: {
bool: {
@ -616,14 +493,47 @@ export class KnowledgeBaseService {
},
},
size: 1,
_source: ['doc_id'],
_source: false,
});
return res.hits.hits[0]?._source?.doc_id;
return res.hits.hits[0]?._id;
};
getUuidFromDocId = async ({
docId,
user,
namespace,
}: {
docId: string;
user?: { name: string; id?: string };
namespace?: string;
}) => {
const query = {
bool: {
filter: [
{ term: { doc_id: docId } },
// exclude user instructions
{ bool: { must_not: { term: { type: KnowledgeBaseType.UserInstruction } } } },
// restrict access to user's own entries
...getAccessQuery({ user, namespace }),
],
},
};
const response = await this.dependencies.esClient.asInternalUser.search<KnowledgeBaseEntry>({
size: 1,
index: resourceNames.aliases.kb,
query,
_source: false,
});
return response.hits.hits[0]?._id;
};
addEntry = async ({
entry: { id, ...document },
entry: { id, ...doc },
user,
namespace,
}: {
@ -634,19 +544,6 @@ export class KnowledgeBaseService {
if (!this.dependencies.enabled) {
return;
}
// for now we want to limit the number of user instructions to 1 per user
if (document.type === KnowledgeBaseType.UserInstruction) {
const existingId = await this.getExistingUserInstructionId({
isPublic: document.public,
user,
namespace,
});
if (existingId) {
id = existingId;
document.doc_id = existingId;
}
}
try {
await this.dependencies.esClient.asInternalUser.index({
@ -654,7 +551,7 @@ export class KnowledgeBaseService {
id,
document: {
'@timestamp': new Date().toISOString(),
...document,
...doc,
user,
namespace,
},
@ -669,29 +566,6 @@ export class KnowledgeBaseService {
}
};
addEntries = async ({
operations,
}: {
operations: KnowledgeBaseEntryOperation[];
}): Promise<void> => {
if (!this.dependencies.enabled) {
return;
}
this.dependencies.logger.info(`Starting import of ${operations.length} entries`);
const limiter = pLimit(5);
await Promise.all(
operations.map((operation) =>
limiter(async () => {
await this.processOperation(operation);
})
)
);
this.dependencies.logger.info(`Completed import of ${operations.length} entries`);
};
deleteEntry = async ({ id }: { id: string }): Promise<void> => {
try {
await this.dependencies.esClient.asInternalUser.delete({

View file

@ -1,596 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import dedent from 'dedent';
import type { Logger } from '@kbn/logging';
import type { ObservabilityAIAssistantService } from '../..';
export function addLensDocsToKb({
service,
}: {
service: ObservabilityAIAssistantService;
logger: Logger;
}) {
service.addCategoryToKnowledgeBase('lens', [
{
id: 'lens_formulas_how_it_works',
texts: [
`Lens formulas let you do math using a combination of Elasticsearch aggregations and
math functions. There are three main types of functions:
* Elasticsearch metrics, like \`sum(bytes)\`
* Time series functions use Elasticsearch metrics as input, like \`cumulative_sum()\`
* Math functions like \`round()\`
An example formula that uses all of these:
\`\`\`
round(100 * moving_average(
average(cpu.load.pct),
window=10,
kql='datacenter.name: east*'
))
\`\`\`
`,
`Elasticsearch functions take a field name, which can be in quotes. \`sum(bytes)\` is the same
as \`sum('bytes')\`.
Some functions take named arguments, like \`moving_average(count(), window=5)\`.
Elasticsearch metrics can be filtered using KQL or Lucene syntax. To add a filter, use the named
parameter \`kql='field: value'\` or \`lucene=''\`. Always use single quotes when writing KQL or Lucene
queries. If your search has a single quote in it, use a backslash to escape, like: \`kql='Women's'\'
Math functions can take positional arguments, like pow(count(), 3) is the same as count() * count() * count()
Use the symbols +, -, /, and * to perform basic math.`,
],
},
{
id: 'lens_common_formulas',
texts: [
`The most common formulas are dividing two values to produce a percent. To display accurately, set
"value format" to "percent"`,
`### Filter ratio:
Use \`kql=''\` to filter one set of documents and compare it to other documents within the same grouping.
For example, to see how the error rate changes over time:
\`\`\`
count(kql='response.status_code > 400') / count()
\`\`\``,
`### Week over week:
Use \`shift='1w'\` to get the value of each grouping from
the previous week. Time shift should not be used with the *Top values* function.
\`\`\`
percentile(system.network.in.bytes, percentile=99) /
percentile(system.network.in.bytes, percentile=99, shift='1w')
\`\`\``,
`### Percent of total
Formulas can calculate \`overall_sum\` for all the groupings,
which lets you convert each grouping into a percent of total:
\`\`\`
sum(products.base_price) / overall_sum(sum(products.base_price))
\`\`\``,
`### Recent change
Use \`reducedTimeRange='30m'\` to add an additional filter on the
time range of a metric aligned with the end of the global time range.
This can be used to calculate how much a value changed recently.
\`\`\`
max(system.network.in.bytes, reducedTimeRange="30m")
- min(system.network.in.bytes, reducedTimeRange="30m")
\`\`\`
`,
],
},
{
id: 'lens_formulas_elasticsearch_functions',
texts: [
`## Elasticsearch functions
These functions will be executed on the raw documents for each row of the
resulting table, aggregating all documents matching the break down
dimensions into a single value.`,
`#### average(field: string)
Returns the average of a field. This function only works for number fields.
Example: Get the average of price: \`average(price)\`
Example: Get the average of price for orders from the UK: \`average(price,
kql='location:UK')\``,
`#### count([field: string])
The total number of documents. When you provide a field, the total number of
field values is counted. When you use the Count function for fields that have
multiple values in a single document, all values are counted.
To calculate the total number of documents, use \`count().\`
To calculate the number of products in all orders, use \`count(products.id)\`.
To calculate the number of documents that match a specific filter, use
\`count(kql='price > 500')\`.`,
`#### last_value(field: string)
Returns the value of a field from the last document, ordered by the default
time field of the data view.
This function is usefull the retrieve the latest state of an entity.
Example: Get the current status of server A: \`last_value(server.status,
kql='server.name="A"')\``,
`#### max(field: string)
Returns the max of a field. This function only works for number fields.
Example: Get the max of price: \`max(price)\`
Example: Get the max of price for orders from the UK: \`max(price,
kql='location:UK')\``,
`#### median(field: string)
Returns the median of a field. This function only works for number fields.
Example: Get the median of price: \`median(price)\`
Example: Get the median of price for orders from the UK: \`median(price,
kql='location:UK')\``,
`#### min(field: string)
Returns the min of a field. This function only works for number fields.
Example: Get the min of price: \`min(price)\`
Example: Get the min of price for orders from the UK: \`min(price,
kql='location:UK')\``,
`#### percentile(field: string, [percentile]: number)
Returns the specified percentile of the values of a field. This is the value n
percent of the values occuring in documents are smaller.
Example: Get the number of bytes larger than 95 % of values:
\`percentile(bytes, percentile=95)\``,
`#### percentile_rank(field: string, [value]: number)
Returns the percentage of values which are below a certain value. For example,
if a value is greater than or equal to 95% of the observed values it is said to
be at the 95th percentile rank
Example: Get the percentage of values which are below of 100:
\`percentile_rank(bytes, value=100)\``,
`#### standard_deviation(field: string)
Returns the amount of variation or dispersion of the field. The function works
only for number fields.
Example: To get the standard deviation of price, use
\`standard_deviation(price).\`
Example: To get the variance of price for orders from the UK, use
\`square(standard_deviation(price, kql='location:UK'))\`.`,
`#### sum(field: string)
Returns the sum of a field. This function only works for number fields.
Example: Get the sum of price: sum(price)
Example: Get the sum of price for orders from the UK: \`sum(price,
kql='location:UK')\``,
`#### unique_count(field: string)
Calculates the number of unique values of a specified field. Works for number,
string, date and boolean values.
Example: Calculate the number of different products:
\`unique_count(product.name)\`
Example: Calculate the number of different products from the "clothes" group:
\`unique_count(product.name, kql='product.group=clothes')\`
`,
],
},
{
id: 'lens_formulas_column_functions',
texts: [
`## Column calculations
These functions are executed for each row, but are provided with the whole
column as context. This is also known as a window function.`,
`#### counter_rate(metric: number)
Calculates the rate of an ever increasing counter. This function will only
yield helpful results on counter metric fields which contain a measurement of
some kind monotonically growing over time. If the value does get smaller, it
will interpret this as a counter reset. To get most precise results,
counter_rate should be calculated on the max of a field.
This calculation will be done separately for separate series defined by filters
or top values dimensions. It uses the current interval when used in Formula.
Example: Visualize the rate of bytes received over time by a memcached server:
counter_rate(max(memcached.stats.read.bytes))`,
`cumulative_sum(metric: number)
Calculates the cumulative sum of a metric over time, adding all previous values
of a series to each value. To use this function, you need to configure a date
histogram dimension as well.
This calculation will be done separately for separate series defined by filters
or top values dimensions.
Example: Visualize the received bytes accumulated over time:
cumulative_sum(sum(bytes))`,
`differences(metric: number)
Calculates the difference to the last value of a metric over time. To use this
function, you need to configure a date histogram dimension as well. Differences
requires the data to be sequential. If your data is empty when using
differences, try increasing the date histogram interval.
This calculation will be done separately for separate series defined by filters
or top values dimensions.
Example: Visualize the change in bytes received over time:
differences(sum(bytes))`,
`moving_average(metric: number, [window]: number)
Calculates the moving average of a metric over time, averaging the last n-th
values to calculate the current value. To use this function, you need to
configure a date histogram dimension as well. The default window value is 5.
This calculation will be done separately for separate series defined by filters
or top values dimensions.
Takes a named parameter window which specifies how many last values to include
in the average calculation for the current value.
Example: Smooth a line of measurements: moving_average(sum(bytes), window=5)`,
`normalize_by_unit(metric: number, unit: s|m|h|d|w|M|y)
This advanced function is useful for normalizing counts and sums to a specific
time interval. It allows for integration with metrics that are stored already
normalized to a specific time interval.
This function can only be used if there's a date histogram function used in the
current chart.
Example: A ratio comparing an already normalized metric to another metric that
needs to be normalized.
normalize_by_unit(counter_rate(max(system.diskio.write.bytes)), unit='s') /
last_value(apache.status.bytes_per_second)`,
`overall_average(metric: number)
Calculates the average of a metric for all data points of a series in the
current chart. A series is defined by a dimension using a date histogram or
interval function. Other dimensions breaking down the data like top values or
filter are treated as separate series.
If no date histograms or interval functions are used in the current chart,
overall_average is calculating the average over all dimensions no matter the
used function
Example: Divergence from the mean: sum(bytes) - overall_average(sum(bytes))`,
`overall_max(metric: number)
Calculates the maximum of a metric for all data points of a series in the
current chart. A series is defined by a dimension using a date histogram or
interval function. Other dimensions breaking down the data like top values or
filter are treated as separate series.
If no date histograms or interval functions are used in the current chart,
overall_max is calculating the maximum over all dimensions no matter the used
function
Example: Percentage of range (sum(bytes) - overall_min(sum(bytes))) /
(overall_max(sum(bytes)) - overall_min(sum(bytes)))`,
`overall_min(metric: number)
Calculates the minimum of a metric for all data points of a series in the
current chart. A series is defined by a dimension using a date histogram or
interval function. Other dimensions breaking down the data like top values or
filter are treated as separate series.
If no date histograms or interval functions are used in the current chart,
overall_min is calculating the minimum over all dimensions no matter the used
function
Example: Percentage of range (sum(bytes) - overall_min(sum(bytes)) /
(overall_max(sum(bytes)) - overall_min(sum(bytes)))`,
`overall_sum(metric: number)
Calculates the sum of a metric of all data points of a series in the current
chart. A series is defined by a dimension using a date histogram or interval
function. Other dimensions breaking down the data like top values or filter are
treated as separate series.
If no date histograms or interval functions are used in the current chart,
overall_sum is calculating the sum over all dimensions no matter the used
function.
Example: Percentage of total sum(bytes) / overall_sum(sum(bytes))`,
],
},
{
id: 'lens_formulas_math_functions',
texts: [
`Math
These functions will be executed for reach row of the resulting table using single values from the same row calculated using other functions.`,
`abs([value]: number)
Calculates absolute value. A negative value is multiplied by -1, a positive value stays the same.
Example: Calculate average distance to sea level abs(average(altitude))`,
`add([left]: number, [right]: number)
Adds up two numbers.
Also works with + symbol.
Example: Calculate the sum of two fields
sum(price) + sum(tax)
Example: Offset count by a static value
add(count(), 5)`,
`cbrt([value]: number)
Cube root of value.
Example: Calculate side length from volume
cbrt(last_value(volume))
ceil([value]: number)
Ceiling of value, rounds up.
Example: Round up price to the next dollar
ceil(sum(price))`,
`clamp([value]: number, [min]: number, [max]: number)
Limits the value from a minimum to maximum.
Example: Make sure to catch outliers
clamp(
average(bytes),
percentile(bytes, percentile=5),
percentile(bytes, percentile=95)
)`,
`cube([value]: number)
Calculates the cube of a number.
Example: Calculate volume from side length
cube(last_value(length))`,
`defaults([value]: number, [default]: number)
Returns a default numeric value when value is null.
Example: Return -1 when a field has no data
defaults(average(bytes), -1)`,
`divide([left]: number, [right]: number)
Divides the first number by the second number.
Also works with / symbol
Example: Calculate profit margin
sum(profit) / sum(revenue)
Example: divide(sum(bytes), 2)`,
`exp([value]: number)
Raises e to the nth power.
Example: Calculate the natural exponential function
exp(last_value(duration))`,
`fix([value]: number)
For positive values, takes the floor. For negative values, takes the ceiling.
Example: Rounding towards zero
fix(sum(profit))`,
`floor([value]: number)
Round down to nearest integer value
Example: Round down a price
floor(sum(price))`,
`log([value]: number, [base]?: number)
Logarithm with optional base. The natural base e is used as default.
Example: Calculate number of bits required to store values
log(sum(bytes))
log(sum(bytes), 2)`,
`mod([value]: number, [base]: number)
Remainder after dividing the function by a number
Example: Calculate last three digits of a value
mod(sum(price), 1000)`,
`multiply([left]: number, [right]: number)
Multiplies two numbers.
Also works with * symbol.
Example: Calculate price after current tax rate
sum(bytes) * last_value(tax_rate)
Example: Calculate price after constant tax rate
multiply(sum(price), 1.2)`,
`pick_max([left]: number, [right]: number)
Finds the maximum value between two numbers.
Example: Find the maximum between two fields averages
pick_max(average(bytes), average(memory))`,
`pick_min([left]: number, [right]: number)
Finds the minimum value between two numbers.
Example: Find the minimum between two fields averages
pick_min(average(bytes), average(memory))`,
`pow([value]: number, [base]: number)
Raises the value to a certain power. The second argument is required
Example: Calculate volume based on side length
pow(last_value(length), 3)`,
`round([value]: number, [decimals]?: number)
Rounds to a specific number of decimal places, default of 0
Examples: Round to the cent
round(sum(bytes))
round(sum(bytes), 2)`,
`sqrt([value]: number)
Square root of a positive value only
Example: Calculate side length based on area
sqrt(last_value(area))`,
`square([value]: number)
Raise the value to the 2nd power
Example: Calculate area based on side length
square(last_value(length))`,
`subtract([left]: number, [right]: number)
Subtracts the first number from the second number.
Also works with - symbol.
Example: Calculate the range of a field
subtract(max(bytes), min(bytes))`,
],
},
{
id: 'lens_formulas_comparison_functions',
texts: [
`Comparison
These functions are used to perform value comparison.`,
`eq([left]: number, [right]: number)
Performs an equality comparison between two values.
To be used as condition for ifelse comparison function.
Also works with == symbol.
Example: Returns true if the average of bytes is exactly the same amount of average memory
average(bytes) == average(memory)
Example: eq(sum(bytes), 1000000)`,
`gt([left]: number, [right]: number)
Performs a greater than comparison between two values.
To be used as condition for ifelse comparison function.
Also works with > symbol.
Example: Returns true if the average of bytes is greater than the average amount of memory
average(bytes) > average(memory)
Example: gt(average(bytes), 1000)`,
`gte([left]: number, [right]: number)
Performs a greater than comparison between two values.
To be used as condition for ifelse comparison function.
Also works with >= symbol.
Example: Returns true if the average of bytes is greater than or equal to the average amount of memory
average(bytes) >= average(memory)
Example: gte(average(bytes), 1000)`,
`ifelse([condition]: boolean, [left]: number, [right]: number)
Returns a value depending on whether the element of condition is true or false.
Example: Average revenue per customer but in some cases customer id is not provided which counts as additional customer
sum(total)/(unique_count(customer_id) + ifelse( count() > count(kql='customer_id:*'), 1, 0))`,
`lt([left]: number, [right]: number)
Performs a lower than comparison between two values.
To be used as condition for ifelse comparison function.
Also works with < symbol.
Example: Returns true if the average of bytes is lower than the average amount of memory
average(bytes) <= average(memory)
Example: lt(average(bytes), 1000)`,
`lte([left]: number, [right]: number)
Performs a lower than or equal comparison between two values.
To be used as condition for ifelse comparison function.
Also works with <= symbol.
Example: Returns true if the average of bytes is lower than or equal to the average amount of memory
average(bytes) <= average(memory)
Example: lte(average(bytes), 1000)`,
],
},
{
id: 'lens_formulas_kibana_context',
text: dedent(`Kibana context
These functions are used to retrieve Kibana context variables, which are the
date histogram \`interval\`, the current \`now\` and the selected \`time_range\`
and help you to compute date math operations.
interval()
The specified minimum interval for the date histogram, in milliseconds (ms).
now()
The current now moment used in Kibana expressed in milliseconds (ms).
time_range()
The specified time range, in milliseconds (ms).`),
},
]);
}

View file

@ -17,7 +17,6 @@ import type {
import type {
Message,
ObservabilityAIAssistantScreenContextRequest,
InstructionOrPlainText,
AdHocInstruction,
} from '../../common/types';
import type { ObservabilityAIAssistantRouteHandlerResources } from '../routes/types';
@ -67,13 +66,13 @@ export interface FunctionHandler {
respond: RespondFunction<any, FunctionResponse>;
}
export type InstructionOrCallback = InstructionOrPlainText | RegisterInstructionCallback;
export type InstructionOrCallback = string | RegisterInstructionCallback;
export type RegisterInstructionCallback = ({
availableFunctionNames,
}: {
availableFunctionNames: string[];
}) => InstructionOrPlainText | InstructionOrPlainText[] | undefined;
}) => string | string[] | undefined;
export type RegisterInstruction = (...instruction: InstructionOrCallback[]) => void;

View file

@ -41,10 +41,10 @@ describe('getSystemMessageFromInstructions', () => {
expect(
getSystemMessageFromInstructions({
applicationInstructions: ['first'],
userInstructions: [{ doc_id: 'second', text: 'second from kb' }],
userInstructions: [{ id: 'second', text: 'second from kb' }],
adHocInstructions: [
{
doc_id: 'second',
id: 'second',
text: 'second from adhoc instruction',
instruction_type: 'user_instruction',
},
@ -58,7 +58,7 @@ describe('getSystemMessageFromInstructions', () => {
expect(
getSystemMessageFromInstructions({
applicationInstructions: ['first'],
userInstructions: [{ doc_id: 'second', text: 'second_kb' }],
userInstructions: [{ id: 'second', text: 'second_kb' }],
adHocInstructions: [],
availableFunctionNames: [],
})

View file

@ -45,7 +45,7 @@ export function getSystemMessageFromInstructions({
const adHocInstructionsWithId = adHocInstructions.map((adHocInstruction) => ({
...adHocInstruction,
doc_id: adHocInstruction?.doc_id ?? v4(),
id: adHocInstruction?.id ?? v4(),
}));
// split ad hoc instructions into user instructions and application instructions
@ -55,15 +55,16 @@ export function getSystemMessageFromInstructions({
);
// all adhoc instructions and KB instructions.
// adhoc instructions will be prioritized over Knowledge Base instructions if the doc_id is the same
// adhoc instructions will be prioritized over Knowledge Base instructions if the id is the same
const allUserInstructions = withTokenBudget(
uniqBy([...adHocUserInstructions, ...kbUserInstructions], (i) => i.doc_id),
uniqBy([...adHocUserInstructions, ...kbUserInstructions], (i) => i.id),
1000
);
return [
// application instructions
...allApplicationInstructions.concat(adHocApplicationInstructions),
...allApplicationInstructions,
...adHocApplicationInstructions,
// user instructions
...(allUserInstructions.length ? [USER_INSTRUCTIONS_HEADER, ...allUserInstructions] : []),

View file

@ -1,36 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { merge } from 'lodash';
import type { KnowledgeBaseEntry } from '../../../common/types';
import {
type KnowledgeBaseEntryOperation,
KnowledgeBaseEntryOperationType,
} from '../knowledge_base_service';
export function splitKbText({
id,
texts,
...rest
}: Omit<KnowledgeBaseEntry, 'text'> & { texts: string[] }): KnowledgeBaseEntryOperation[] {
return [
{
type: KnowledgeBaseEntryOperationType.Delete,
doc_id: id,
labels: {},
},
...texts.map((text, index) => ({
type: KnowledgeBaseEntryOperationType.Index,
document: merge({}, rest, {
id: [id, index].join('_'),
doc_id: id,
labels: {},
text,
}),
})),
];
}

View file

@ -7,13 +7,14 @@
import type { Logger } from '@kbn/logging';
import { AnalyticsServiceStart } from '@kbn/core/server';
import { scoreSuggestions } from './score_suggestions';
import type { Message } from '../../../common';
import type { ObservabilityAIAssistantClient } from '../../service/client';
import type { FunctionCallChatFunction } from '../../service/types';
import { retrieveSuggestions } from './retrieve_suggestions';
import { scoreSuggestions } from './score_suggestions';
import type { RetrievedSuggestion } from './types';
import { RecallRanking, RecallRankingEventType } from '../../analytics/recall_ranking';
import { RecallRanking, recallRankingEventType } from '../../analytics/recall_ranking';
import { RecalledEntry } from '../../service/knowledge_base_service';
export type RecalledSuggestion = Pick<RecalledEntry, 'id' | 'text' | 'score'>;
export async function recallAndScore({
recall,
@ -34,19 +35,18 @@ export async function recallAndScore({
logger: Logger;
signal: AbortSignal;
}): Promise<{
relevantDocuments?: RetrievedSuggestion[];
relevantDocuments?: RecalledSuggestion[];
scores?: Array<{ id: string; score: number }>;
suggestions: RetrievedSuggestion[];
suggestions: RecalledSuggestion[];
}> {
const queries = [
{ text: userPrompt, boost: 3 },
{ text: context, boost: 1 },
].filter((query) => query.text.trim());
const suggestions = await retrieveSuggestions({
recall,
queries,
});
const suggestions: RecalledSuggestion[] = (await recall({ queries })).map(
({ id, text, score }) => ({ id, text, score })
);
if (!suggestions.length) {
return {
@ -67,7 +67,7 @@ export async function recallAndScore({
chat,
});
analytics.reportEvent<RecallRanking>(RecallRankingEventType, {
analytics.reportEvent<RecallRanking>(recallRankingEventType, {
prompt: queries.map((query) => query.text).join('\n\n'),
scoredDocuments: suggestions.map((suggestion) => {
const llmScore = scores.find((score) => score.id === suggestion.id);

View file

@ -1,24 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { omit } from 'lodash';
import { ObservabilityAIAssistantClient } from '../../service/client';
import { RetrievedSuggestion } from './types';
export async function retrieveSuggestions({
queries,
recall,
}: {
queries: Array<{ text: string; boost?: number }>;
recall: ObservabilityAIAssistantClient['recall'];
}): Promise<RetrievedSuggestion[]> {
const recallResponse = await recall({
queries,
});
return recallResponse.entries.map((entry) => omit(entry, 'labels', 'is_correction'));
}

View file

@ -5,15 +5,15 @@
* 2.0.
*/
import * as t from 'io-ts';
import { omit } from 'lodash';
import { Logger } from '@kbn/logging';
import dedent from 'dedent';
import { lastValueFrom } from 'rxjs';
import { decodeOrThrow, jsonRt } from '@kbn/io-ts-utils';
import { omit } from 'lodash';
import { concatenateChatCompletionChunks, Message, MessageRole } from '../../../common';
import type { FunctionCallChatFunction } from '../../service/types';
import type { RetrievedSuggestion } from './types';
import { parseSuggestionScores } from './parse_suggestion_scores';
import { RecalledSuggestion } from './recall_and_score';
import { ShortIdTable } from '../../../common/utils/short_id_table';
const scoreFunctionRequestRt = t.type({
@ -38,7 +38,7 @@ export async function scoreSuggestions({
signal,
logger,
}: {
suggestions: RetrievedSuggestion[];
suggestions: RecalledSuggestion[];
messages: Message[];
userPrompt: string;
context: string;
@ -46,28 +46,21 @@ export async function scoreSuggestions({
signal: AbortSignal;
logger: Logger;
}): Promise<{
relevantDocuments: RetrievedSuggestion[];
relevantDocuments: RecalledSuggestion[];
scores: Array<{ id: string; score: number }>;
}> {
const shortIdTable = new ShortIdTable();
const suggestionsWithShortId = suggestions.map((suggestion) => ({
...omit(suggestion, 'score', 'id'), // To not bias the LLM
originalId: suggestion.id,
shortId: shortIdTable.take(suggestion.id),
}));
const newUserMessageContent =
dedent(`Given the following question, score the documents that are relevant to the question. on a scale from 0 to 7,
0 being completely irrelevant, and 7 being extremely relevant. Information is relevant to the question if it helps in
answering the question. Judge it according to the following criteria:
- The document is relevant to the question, and the rest of the conversation
- The document has information relevant to the question that is not mentioned,
or more detailed than what is available in the conversation
- The document has a high amount of information relevant to the question compared to other documents
- The document contains new information not mentioned before in the conversation
dedent(`Given the following prompt, score the documents that are relevant to the prompt on a scale from 0 to 7,
0 being completely irrelevant, and 7 being extremely relevant. Information is relevant to the prompt if it helps in
answering the prompt. Judge the document according to the following criteria:
- The document is relevant to the prompt, and the rest of the conversation
- The document has information relevant to the prompt that is not mentioned, or more detailed than what is available in the conversation
- The document has a high amount of information relevant to the prompt compared to other documents
- The document contains new information not mentioned before in the conversation or provides a correction to previously stated information.
User prompt:
${userPrompt}
@ -76,9 +69,9 @@ export async function scoreSuggestions({
Documents:
${JSON.stringify(
suggestionsWithShortId.map((suggestion) => ({
id: suggestion.shortId,
content: suggestion.text,
suggestions.map((suggestion) => ({
...omit(suggestion, 'score'), // Omit score to not bias the LLM
id: shortIdTable.take(suggestion.id), // Shorten id to save tokens
})),
null,
2
@ -127,15 +120,9 @@ export async function scoreSuggestions({
scoreFunctionRequest.message.function_call.arguments
);
const scores = parseSuggestionScores(scoresAsString).map(({ id, score }) => {
const originalSuggestion = suggestionsWithShortId.find(
(suggestion) => suggestion.shortId === id
);
return {
originalId: originalSuggestion?.originalId,
score,
};
});
const scores = parseSuggestionScores(scoresAsString)
// Restore original IDs
.map(({ id, score }) => ({ id: shortIdTable.lookup(id)!, score }));
if (scores.length === 0) {
// seemingly invalid or no scores, return all
@ -144,12 +131,13 @@ export async function scoreSuggestions({
const suggestionIds = suggestions.map((document) => document.id);
// get top 5 documents ids with scores > 4
const relevantDocumentIds = scores
.filter((document) => suggestionIds.includes(document.originalId ?? '')) // Remove hallucinated documents
.filter((document) => document.score > 4)
.filter(({ score }) => score > 4)
.sort((a, b) => b.score - a.score)
.slice(0, 5)
.map((document) => document.originalId);
.filter(({ id }) => suggestionIds.includes(id ?? '')) // Remove hallucinated documents
.map(({ id }) => id);
const relevantDocuments = suggestions.filter((suggestion) =>
relevantDocumentIds.includes(suggestion.id)
@ -159,6 +147,6 @@ export async function scoreSuggestions({
return {
relevantDocuments,
scores: scores.map((score) => ({ id: score.originalId!, score: score.score })),
scores: scores.map((score) => ({ id: score.id, score: score.score })),
};
}

View file

@ -1,10 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { RecalledEntry } from '../../service/knowledge_base_service';
export type RetrievedSuggestion = Omit<RecalledEntry, 'labels' | 'is_correction'>;

View file

@ -9,21 +9,30 @@ import type { KnowledgeBaseEntry } from '@kbn/observability-ai-assistant-plugin/
export interface KnowledgeBaseEntryCategory {
'@timestamp': string;
categoryName: string;
categoryKey: string;
title: string;
entries: KnowledgeBaseEntry[];
}
export function categorizeEntries({ entries }: { entries: KnowledgeBaseEntry[] }) {
export function categorizeEntries({
entries,
}: {
entries: KnowledgeBaseEntry[];
}): KnowledgeBaseEntryCategory[] {
return entries.reduce((acc, entry) => {
const categoryName = entry.labels?.category ?? entry.id;
const categoryKey = entry.labels?.category ?? entry.id;
const index = acc.findIndex((item) => item.categoryName === categoryName);
if (index > -1) {
acc[index].entries.push(entry);
const existingEntry = acc.find((item) => item.categoryKey === categoryKey);
if (existingEntry) {
existingEntry.entries.push(entry);
return acc;
} else {
return acc.concat({ categoryName, entries: [entry], '@timestamp': entry['@timestamp'] });
}
}, [] as Array<{ categoryName: string; entries: KnowledgeBaseEntry[]; '@timestamp': string }>);
return acc.concat({
categoryKey,
title: entry.labels?.category ?? entry.title ?? 'No title',
entries: [entry],
'@timestamp': entry['@timestamp'],
});
}, [] as Array<{ categoryKey: string; title: string; entries: KnowledgeBaseEntry[]; '@timestamp': string }>);
}

View file

@ -28,10 +28,9 @@ export function useCreateKnowledgeBaseEntry() {
void,
ServerError,
{
entry: Omit<
KnowledgeBaseEntry,
'@timestamp' | 'confidence' | 'is_correction' | 'role' | 'doc_id'
>;
entry: Omit<KnowledgeBaseEntry, '@timestamp'> & {
title: string;
};
}
>(
[REACT_QUERY_KEYS.CREATE_KB_ENTRIES],
@ -41,10 +40,7 @@ export function useCreateKnowledgeBaseEntry() {
{
signal: null,
params: {
body: {
...entry,
role: 'user_entry',
},
body: entry,
},
}
);

View file

@ -32,7 +32,7 @@ export function useCreateKnowledgeBaseUserInstruction() {
signal: null,
params: {
body: {
id: entry.doc_id,
id: entry.id,
text: entry.text,
public: entry.public,
},
@ -62,7 +62,7 @@ export function useCreateKnowledgeBaseUserInstruction() {
'xpack.observabilityAiAssistantManagement.kb.addUserInstruction.errorNotification',
{
defaultMessage: 'Something went wrong while creating {name}',
values: { name: entry.doc_id },
values: { name: entry.id },
}
),
});

View file

@ -30,7 +30,7 @@ export function useImportKnowledgeBaseEntries() {
Omit<
KnowledgeBaseEntry,
'@timestamp' | 'confidence' | 'is_correction' | 'public' | 'labels'
>
> & { title: string }
>;
}
>(

View file

@ -47,15 +47,13 @@ export function KnowledgeBaseBulkImportFlyout({ onClose }: { onClose: () => void
};
const handleSubmitNewEntryClick = async () => {
let entries: Array<Omit<KnowledgeBaseEntry, '@timestamp'>> = [];
let entries: Array<Omit<KnowledgeBaseEntry, '@timestamp' | 'title'> & { title: string }> = [];
const text = await files[0].text();
const elements = text.split('\n').filter(Boolean);
try {
entries = elements.map((el) => JSON.parse(el)) as Array<
Omit<KnowledgeBaseEntry, '@timestamp'>
>;
entries = elements.map((el) => JSON.parse(el));
} catch (_) {
toasts.addError(
new Error(

View file

@ -104,13 +104,13 @@ export function KnowledgeBaseCategoryFlyout({
];
const hasDescription =
CATEGORY_MAP[category.categoryName as unknown as keyof typeof CATEGORY_MAP]?.description;
CATEGORY_MAP[category.categoryKey as unknown as keyof typeof CATEGORY_MAP]?.description;
return (
<EuiFlyout onClose={onClose} data-test-subj="knowledgeBaseCategoryFlyout">
<EuiFlyoutHeader hasBorder>
<EuiTitle>
<h2>{capitalize(category.categoryName)}</h2>
<h2>{capitalize(category.categoryKey)}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>

View file

@ -26,7 +26,9 @@ import {
EuiTitle,
} from '@elastic/eui';
import moment from 'moment';
import type { KnowledgeBaseEntry } from '@kbn/observability-ai-assistant-plugin/common/types';
import { KnowledgeBaseEntryRole } from '@kbn/observability-ai-assistant-plugin/public';
import { type KnowledgeBaseEntry } from '@kbn/observability-ai-assistant-plugin/common';
import { v4 } from 'uuid';
import { useCreateKnowledgeBaseEntry } from '../../hooks/use_create_knowledge_base_entry';
import { useDeleteKnowledgeBaseEntry } from '../../hooks/use_delete_knowledge_base_entry';
import { useKibana } from '../../hooks/use_kibana';
@ -45,20 +47,24 @@ export function KnowledgeBaseEditManualEntryFlyout({
const { mutateAsync: deleteEntry, isLoading: isDeleting } = useDeleteKnowledgeBaseEntry();
const [isPublic, setIsPublic] = useState(entry?.public ?? false);
const [newEntryId, setNewEntryId] = useState(entry?.id ?? '');
const [newEntryTitle, setNewEntryTitle] = useState(entry?.title ?? '');
const [newEntryText, setNewEntryText] = useState(entry?.text ?? '');
const isEntryIdInvalid = newEntryId.trim() === '';
const isEntryTitleInvalid = newEntryTitle.trim() === '';
const isEntryTextInvalid = newEntryText.trim() === '';
const isFormInvalid = isEntryIdInvalid || isEntryTextInvalid;
const isFormInvalid = isEntryTitleInvalid || isEntryTextInvalid;
const handleSubmit = async () => {
await createEntry({
entry: {
id: newEntryId,
id: entry?.id ?? v4(),
title: newEntryTitle,
text: newEntryText,
public: isPublic,
role: KnowledgeBaseEntryRole.UserEntry,
confidence: 'high',
is_correction: false,
labels: {},
},
});
@ -85,8 +91,8 @@ export function KnowledgeBaseEditManualEntryFlyout({
: i18n.translate(
'xpack.observabilityAiAssistantManagement.knowledgeBaseNewEntryFlyout.h2.editEntryLabel',
{
defaultMessage: 'Edit {id}',
values: { id: entry.id },
defaultMessage: 'Edit {title}',
values: { title: entry?.title },
}
)}
</h2>
@ -94,23 +100,7 @@ export function KnowledgeBaseEditManualEntryFlyout({
</EuiFlyoutHeader>
<EuiFlyoutBody>
{!entry ? (
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.observabilityAiAssistantManagement.knowledgeBaseEditManualEntryFlyout.euiFormRow.idLabel',
{ defaultMessage: 'Name' }
)}
>
<EuiFieldText
data-test-subj="knowledgeBaseEditManualEntryFlyoutIdInput"
fullWidth
value={newEntryId}
onChange={(e) => setNewEntryId(e.target.value)}
isInvalid={isEntryIdInvalid}
/>
</EuiFormRow>
) : (
{entry ? (
<EuiFlexGroup>
<EuiFlexItem>
<EuiText color="subdued" size="s">
@ -136,7 +126,26 @@ export function KnowledgeBaseEditManualEntryFlyout({
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
)}
) : null}
<EuiSpacer size="m" />
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.observabilityAiAssistantManagement.knowledgeBaseEditManualEntryFlyout.euiFormRow.idLabel',
{ defaultMessage: 'Name' }
)}
>
<EuiFieldText
data-test-subj="knowledgeBaseEditManualEntryFlyoutIdInput"
fullWidth
value={newEntryTitle}
onChange={(e) => setNewEntryTitle(e.target.value)}
isInvalid={isEntryTitleInvalid}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFlexGroup alignItems="center">

View file

@ -30,19 +30,19 @@ export function KnowledgeBaseEditUserInstructionFlyout({ onClose }: { onClose: (
const { userInstructions, isLoading: isFetching } = useGetUserInstructions();
const { mutateAsync: createEntry, isLoading: isSaving } = useCreateKnowledgeBaseUserInstruction();
const [newEntryText, setNewEntryText] = useState('');
const [newEntryDocId, setNewEntryDocId] = useState<string>();
const [newEntryId, setNewEntryId] = useState<string>();
const isSubmitDisabled = newEntryText.trim() === '';
useEffect(() => {
const userInstruction = userInstructions?.find((entry) => !entry.public);
setNewEntryDocId(userInstruction?.doc_id);
setNewEntryText(userInstruction?.text ?? '');
setNewEntryId(userInstruction?.id);
}, [userInstructions]);
const handleSubmit = async () => {
await createEntry({
entry: {
doc_id: newEntryDocId ?? uuidv4(),
id: newEntryId ?? uuidv4(),
text: newEntryText,
public: false, // limit user instructions to private (for now)
},

View file

@ -81,7 +81,18 @@ describe('KnowledgeBaseTab', () => {
getByTestId('knowledgeBaseEditManualEntryFlyoutSaveButton').click();
expect(createMock).toHaveBeenCalledWith({ entry: { id: 'foo', public: false, text: 'bar' } });
expect(createMock).toHaveBeenCalledWith({
entry: {
id: expect.any(String),
title: 'foo',
public: false,
text: 'bar',
role: 'user_entry',
confidence: 'high',
is_correction: false,
labels: expect.any(Object),
},
});
});
it('should require an id', () => {
@ -126,7 +137,7 @@ describe('KnowledgeBaseTab', () => {
entries: [
{
id: 'test',
doc_id: 'test',
title: 'test',
text: 'test',
'@timestamp': 1638340456,
labels: {},
@ -134,7 +145,7 @@ describe('KnowledgeBaseTab', () => {
},
{
id: 'test2',
doc_id: 'test2',
title: 'test2',
text: 'test',
'@timestamp': 1638340456,
labels: {
@ -144,7 +155,7 @@ describe('KnowledgeBaseTab', () => {
},
{
id: 'test3',
doc_id: 'test3',
title: 'test3',
text: 'test',
'@timestamp': 1638340456,
labels: {

View file

@ -57,10 +57,10 @@ export function KnowledgeBaseTab() {
data-test-subj="pluginsColumnsButton"
onClick={() => setSelectedCategory(category)}
aria-label={
category.categoryName === selectedCategory?.categoryName ? 'Collapse' : 'Expand'
category.categoryKey === selectedCategory?.categoryKey ? 'Collapse' : 'Expand'
}
iconType={
category.categoryName === selectedCategory?.categoryName ? 'minimize' : 'expand'
category.categoryKey === selectedCategory?.categoryKey ? 'minimize' : 'expand'
}
/>
);
@ -85,7 +85,8 @@ export function KnowledgeBaseTab() {
width: '40px',
},
{
field: 'categoryName',
'data-test-subj': 'knowledgeBaseTableTitleCell',
field: 'title',
name: i18n.translate('xpack.observabilityAiAssistantManagement.kbTab.columns.name', {
defaultMessage: 'Name',
}),
@ -107,6 +108,7 @@ export function KnowledgeBaseTab() {
},
},
{
'data-test-subj': 'knowledgeBaseTableAuthorCell',
name: i18n.translate('xpack.observabilityAiAssistantManagement.kbTab.columns.author', {
defaultMessage: 'Author',
}),
@ -183,7 +185,7 @@ export function KnowledgeBaseTab() {
const [isNewEntryPopoverOpen, setIsNewEntryPopoverOpen] = useState(false);
const [isEditUserInstructionFlyoutOpen, setIsEditUserInstructionFlyoutOpen] = useState(false);
const [query, setQuery] = useState('');
const [sortBy, setSortBy] = useState<'doc_id' | '@timestamp'>('doc_id');
const [sortBy, setSortBy] = useState<keyof KnowledgeBaseEntryCategory>('title');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const {
@ -193,17 +195,10 @@ export function KnowledgeBaseTab() {
} = useGetKnowledgeBaseEntries({ query, sortBy, sortDirection });
const categorizedEntries = categorizeEntries({ entries });
const handleChangeSort = ({
sort,
}: Criteria<KnowledgeBaseEntryCategory & KnowledgeBaseEntry>) => {
const handleChangeSort = ({ sort }: Criteria<KnowledgeBaseEntryCategory>) => {
if (sort) {
const { field, direction } = sort;
if (field === '@timestamp') {
setSortBy(field);
}
if (field === 'categoryName') {
setSortBy('doc_id');
}
setSortBy(field);
setSortDirection(direction);
}
};
@ -329,7 +324,7 @@ export function KnowledgeBaseTab() {
loading={isLoading}
sorting={{
sort: {
field: sortBy === 'doc_id' ? 'categoryName' : sortBy,
field: sortBy,
direction: sortDirection,
},
}}

View file

@ -32566,7 +32566,6 @@
"xpack.observabilityAiAssistantManagement.knowledgeBaseEditManualEntryFlyout.euiFormRow.contentsLabel": "Contenu",
"xpack.observabilityAiAssistantManagement.knowledgeBaseEditManualEntryFlyout.euiFormRow.idLabel": "Nom",
"xpack.observabilityAiAssistantManagement.knowledgeBaseEditManualEntryFlyout.euiMarkdownEditor.enterContentsLabel": "Entrer du contenu",
"xpack.observabilityAiAssistantManagement.knowledgeBaseNewEntryFlyout.h2.editEntryLabel": "Modifier {id}",
"xpack.observabilityAiAssistantManagement.knowledgeBaseNewEntryFlyout.h2.newEntryLabel": "Nouvelle entrée",
"xpack.observabilityAiAssistantManagement.knowledgeBaseNewManualEntryFlyout.cancelButtonEmptyLabel": "Annuler",
"xpack.observabilityAiAssistantManagement.knowledgeBaseNewManualEntryFlyout.euiMarkdownEditor.observabilityAiAssistantKnowledgeBaseViewMarkdownEditorLabel": "observabilityAiAssistantKnowledgeBaseViewMarkdownEditor",

View file

@ -32313,7 +32313,6 @@
"xpack.observabilityAiAssistantManagement.knowledgeBaseEditManualEntryFlyout.euiFormRow.contentsLabel": "目次",
"xpack.observabilityAiAssistantManagement.knowledgeBaseEditManualEntryFlyout.euiFormRow.idLabel": "名前",
"xpack.observabilityAiAssistantManagement.knowledgeBaseEditManualEntryFlyout.euiMarkdownEditor.enterContentsLabel": "コンテンツを入力",
"xpack.observabilityAiAssistantManagement.knowledgeBaseNewEntryFlyout.h2.editEntryLabel": "{id}を編集",
"xpack.observabilityAiAssistantManagement.knowledgeBaseNewEntryFlyout.h2.newEntryLabel": "新しいエントリー",
"xpack.observabilityAiAssistantManagement.knowledgeBaseNewManualEntryFlyout.cancelButtonEmptyLabel": "キャンセル",
"xpack.observabilityAiAssistantManagement.knowledgeBaseNewManualEntryFlyout.euiMarkdownEditor.observabilityAiAssistantKnowledgeBaseViewMarkdownEditorLabel": "observabilityAiAssistantKnowledgeBaseViewMarkdownEditor",

View file

@ -32355,7 +32355,6 @@
"xpack.observabilityAiAssistantManagement.knowledgeBaseEditManualEntryFlyout.euiFormRow.contentsLabel": "内容",
"xpack.observabilityAiAssistantManagement.knowledgeBaseEditManualEntryFlyout.euiFormRow.idLabel": "名称",
"xpack.observabilityAiAssistantManagement.knowledgeBaseEditManualEntryFlyout.euiMarkdownEditor.enterContentsLabel": "输入内容",
"xpack.observabilityAiAssistantManagement.knowledgeBaseNewEntryFlyout.h2.editEntryLabel": "编辑 {id}",
"xpack.observabilityAiAssistantManagement.knowledgeBaseNewEntryFlyout.h2.newEntryLabel": "新条目",
"xpack.observabilityAiAssistantManagement.knowledgeBaseNewManualEntryFlyout.cancelButtonEmptyLabel": "取消",
"xpack.observabilityAiAssistantManagement.knowledgeBaseNewManualEntryFlyout.euiMarkdownEditor.observabilityAiAssistantKnowledgeBaseViewMarkdownEditorLabel": "observabilityAiAssistantKnowledgeBaseViewMarkdownEditor",

View file

@ -11,7 +11,7 @@ import { ObservabilityAIAssistantFtrConfigName } from '../configs';
import { getApmSynthtraceEsClient } from './create_synthtrace_client';
import { InheritedFtrProviderContext, InheritedServices } from './ftr_provider_context';
import { getScopedApiClient } from './observability_ai_assistant_api_client';
import { editorUser, viewerUser } from './users/users';
import { editor, secondaryEditor, viewer } from './users/users';
export interface ObservabilityAIAssistantFtrConfig {
name: ObservabilityAIAssistantFtrConfigName;
@ -23,6 +23,10 @@ export type CreateTestConfig = ReturnType<typeof createTestConfig>;
export type CreateTest = ReturnType<typeof createObservabilityAIAssistantAPIConfig>;
export type ObservabilityAIAssistantApiClients = Awaited<
ReturnType<CreateTest['services']['observabilityAIAssistantAPIClient']>
>;
export type ObservabilityAIAssistantAPIClient = Awaited<
ReturnType<CreateTest['services']['observabilityAIAssistantAPIClient']>
>;
@ -46,21 +50,23 @@ export function createObservabilityAIAssistantAPIConfig({
const apmSynthtraceKibanaClient = services.apmSynthtraceKibanaClient();
const allConfigs = config.getAll() as Record<string, any>;
const getScopedApiClientForUsername = (username: string) =>
getScopedApiClient(kibanaServer, username);
return {
...allConfigs,
servers,
services: {
...services,
getScopedApiClientForUsername: () => {
return (username: string) => getScopedApiClient(kibanaServer, username);
},
getScopedApiClientForUsername: () => getScopedApiClientForUsername,
apmSynthtraceEsClient: (context: InheritedFtrProviderContext) =>
getApmSynthtraceEsClient(context, apmSynthtraceKibanaClient),
observabilityAIAssistantAPIClient: async () => {
return {
adminUser: await getScopedApiClient(kibanaServer, 'elastic'),
viewerUser: await getScopedApiClient(kibanaServer, viewerUser.username),
editorUser: await getScopedApiClient(kibanaServer, editorUser.username),
admin: getScopedApiClientForUsername('elastic'),
viewer: getScopedApiClientForUsername(viewer.username),
editor: getScopedApiClientForUsername(editor.username),
secondaryEditor: getScopedApiClientForUsername(secondaryEditor.username),
};
},
},

View file

@ -9,21 +9,27 @@ import { kbnTestConfig } from '@kbn/test';
const password = kbnTestConfig.getUrlParts().password!;
export interface User {
username: 'elastic' | 'editor' | 'viewer';
username: 'elastic' | 'editor' | 'viewer' | 'secondary_editor';
password: string;
roles: string[];
}
export const editorUser: User = {
export const editor: User = {
username: 'editor',
password,
roles: ['editor'],
};
export const viewerUser: User = {
export const secondaryEditor: User = {
username: 'secondary_editor',
password,
roles: ['editor'],
};
export const viewer: User = {
username: 'viewer',
password,
roles: ['viewer'],
};
export const allUsers = [editorUser, viewerUser];
export const allUsers = [editor, secondaryEditor, viewer];

View file

@ -290,7 +290,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
)[0]?.conversation.id;
await observabilityAIAssistantAPIClient
.adminUser({
.admin({
endpoint: 'DELETE /internal/observability_ai_assistant/conversation/{conversationId}',
params: {
path: {
@ -366,7 +366,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
).to.eql(0);
const conversations = await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'POST /internal/observability_ai_assistant/conversations',
})
.expect(200);
@ -396,7 +396,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
.completeAfterIntercept();
const createResponse = await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'POST /internal/observability_ai_assistant/chat/complete',
params: {
body: {
@ -415,7 +415,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
conversationCreatedEvent = getConversationCreatedEvent(createResponse.body);
const conversationId = conversationCreatedEvent.conversation.id;
const fullConversation = await observabilityAIAssistantAPIClient.editorUser({
const fullConversation = await observabilityAIAssistantAPIClient.editor({
endpoint: 'GET /internal/observability_ai_assistant/conversation/{conversationId}',
params: {
path: {
@ -429,7 +429,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
.completeAfterIntercept();
const updatedResponse = await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'POST /internal/observability_ai_assistant/chat/complete',
params: {
body: {
@ -460,7 +460,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
after(async () => {
await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'DELETE /internal/observability_ai_assistant/conversation/{conversationId}',
params: {
path: {

View file

@ -43,7 +43,7 @@ export async function invokeChatCompleteWithFunctionRequest({
scopes?: AssistantScope[];
}) {
const { body } = await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'POST /internal/observability_ai_assistant/chat/complete',
params: {
body: {

View file

@ -32,10 +32,9 @@ export default function ApiTest({ getService }: FtrProviderContext) {
let connectorId: string;
before(async () => {
await clearKnowledgeBase(es);
await createKnowledgeBaseModel(ml);
await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'POST /internal/observability_ai_assistant/kb/setup',
})
.expect(200);
@ -55,7 +54,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
name: 'summarize',
trigger: MessageRole.User,
arguments: JSON.stringify({
id: 'my-id',
title: 'My Title',
text: 'Hello world',
is_correction: false,
confidence: 'high',
@ -72,28 +71,29 @@ export default function ApiTest({ getService }: FtrProviderContext) {
await deleteActionConnector({ supertest, connectorId, log });
await deleteKnowledgeBaseModel(ml);
await clearKnowledgeBase(es);
});
it('persists entry in knowledge base', async () => {
const res = await observabilityAIAssistantAPIClient.editorUser({
const res = await observabilityAIAssistantAPIClient.editor({
endpoint: 'GET /internal/observability_ai_assistant/kb/entries',
params: {
query: {
query: '',
sortBy: 'doc_id',
sortBy: 'title',
sortDirection: 'asc',
},
},
});
const { role, public: isPublic, text, type, user, id } = res.body.entries[0];
const { role, public: isPublic, text, type, user, title } = res.body.entries[0];
expect(role).to.eql('assistant_summarization');
expect(isPublic).to.eql(false);
expect(text).to.eql('Hello world');
expect(type).to.eql('contextual');
expect(user?.name).to.eql('editor');
expect(id).to.eql('my-id');
expect(title).to.eql('My Title');
expect(res.body.entries).to.have.length(1);
});
});

View file

@ -26,14 +26,14 @@ export default function ApiTest({ getService }: FtrProviderContext) {
it('Returns a 2xx for enterprise license', async () => {
await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'GET /internal/observability_ai_assistant/connectors',
})
.expect(200);
});
it('returns an empty list of connectors', async () => {
const res = await observabilityAIAssistantAPIClient.editorUser({
const res = await observabilityAIAssistantAPIClient.editor({
endpoint: 'GET /internal/observability_ai_assistant/connectors',
});
@ -43,7 +43,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
it("returns the gen ai connector if it's been created", async () => {
const connectorId = await createProxyActionConnector({ supertest, log, port: 1234 });
const res = await observabilityAIAssistantAPIClient.editorUser({
const res = await observabilityAIAssistantAPIClient.editor({
endpoint: 'GET /internal/observability_ai_assistant/connectors',
});

View file

@ -48,7 +48,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
describe('without conversations', () => {
it('returns no conversations when listing', async () => {
const response = await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'POST /internal/observability_ai_assistant/conversations',
})
.expect(200);
@ -58,7 +58,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
it('returns a 404 for updating conversations', async () => {
await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'PUT /internal/observability_ai_assistant/conversation/{conversationId}',
params: {
path: {
@ -74,7 +74,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
it('returns a 404 for retrieving a conversation', async () => {
await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'GET /internal/observability_ai_assistant/conversation/{conversationId}',
params: {
path: {
@ -92,7 +92,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
>;
before(async () => {
createResponse = await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'POST /internal/observability_ai_assistant/conversation',
params: {
body: {
@ -105,7 +105,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
after(async () => {
await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'DELETE /internal/observability_ai_assistant/conversation/{conversationId}',
params: {
path: {
@ -116,7 +116,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
.expect(200);
await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'GET /internal/observability_ai_assistant/conversation/{conversationId}',
params: {
path: {
@ -148,7 +148,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
it('returns a 404 for updating a non-existing conversation', async () => {
await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'PUT /internal/observability_ai_assistant/conversation/{conversationId}',
params: {
path: {
@ -164,7 +164,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
it('returns a 404 for retrieving a non-existing conversation', async () => {
await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'GET /internal/observability_ai_assistant/conversation/{conversationId}',
params: {
path: {
@ -177,7 +177,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
it('returns the conversation that was created', async () => {
const response = await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'GET /internal/observability_ai_assistant/conversation/{conversationId}',
params: {
path: {
@ -192,7 +192,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
it('returns the created conversation when listing', async () => {
const response = await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'POST /internal/observability_ai_assistant/conversations',
})
.expect(200);
@ -210,7 +210,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
before(async () => {
updateResponse = await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'PUT /internal/observability_ai_assistant/conversation/{conversationId}',
params: {
path: {
@ -234,7 +234,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
it('returns the updated conversation after get', async () => {
const updateAfterCreateResponse = await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'GET /internal/observability_ai_assistant/conversation/{conversationId}',
params: {
path: {

View file

@ -6,67 +6,66 @@
*/
import expect from '@kbn/expect';
import { type KnowledgeBaseEntry } from '@kbn/observability-ai-assistant-plugin/common';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import { clearKnowledgeBase, createKnowledgeBaseModel, deleteKnowledgeBaseModel } from './helpers';
export default function ApiTest({ getService }: FtrProviderContext) {
const ml = getService('ml');
const es = getService('es');
const observabilityAIAssistantAPIClient = getService('observabilityAIAssistantAPIClient');
describe('Knowledge base', () => {
before(async () => {
await createKnowledgeBaseModel(ml);
await observabilityAIAssistantAPIClient
.editor({ endpoint: 'POST /internal/observability_ai_assistant/kb/setup' })
.expect(200);
});
after(async () => {
await deleteKnowledgeBaseModel(ml);
await clearKnowledgeBase(es);
});
it('returns 200 on knowledge base setup', async () => {
const res = await observabilityAIAssistantAPIClient
.editorUser({
endpoint: 'POST /internal/observability_ai_assistant/kb/setup',
})
.expect(200);
expect(res.body).to.eql({});
});
describe('when managing a single entry', () => {
const knowledgeBaseEntry = {
id: 'my-doc-id-1',
title: 'My title',
text: 'My content',
};
it('returns 200 on create', async () => {
await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'POST /internal/observability_ai_assistant/kb/entries/save',
params: { body: knowledgeBaseEntry },
})
.expect(200);
const res = await observabilityAIAssistantAPIClient.editorUser({
const res = await observabilityAIAssistantAPIClient.editor({
endpoint: 'GET /internal/observability_ai_assistant/kb/entries',
params: {
query: {
query: '',
sortBy: 'doc_id',
sortBy: 'title',
sortDirection: 'asc',
},
},
});
const entry = res.body.entries[0];
expect(entry.id).to.equal(knowledgeBaseEntry.id);
expect(entry.title).to.equal(knowledgeBaseEntry.title);
expect(entry.text).to.equal(knowledgeBaseEntry.text);
});
it('returns 200 on get entries and entry exists', async () => {
const res = await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'GET /internal/observability_ai_assistant/kb/entries',
params: {
query: {
query: '',
sortBy: 'doc_id',
sortBy: 'title',
sortDirection: 'asc',
},
},
@ -74,13 +73,14 @@ export default function ApiTest({ getService }: FtrProviderContext) {
.expect(200);
const entry = res.body.entries[0];
expect(entry.id).to.equal(knowledgeBaseEntry.id);
expect(entry.title).to.equal(knowledgeBaseEntry.title);
expect(entry.text).to.equal(knowledgeBaseEntry.text);
});
it('returns 200 on delete', async () => {
const entryId = 'my-doc-id-1';
await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'DELETE /internal/observability_ai_assistant/kb/entries/{entryId}',
params: {
path: { entryId },
@ -89,12 +89,12 @@ export default function ApiTest({ getService }: FtrProviderContext) {
.expect(200);
const res = await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'GET /internal/observability_ai_assistant/kb/entries',
params: {
query: {
query: '',
sortBy: 'doc_id',
sortBy: 'title',
sortDirection: 'asc',
},
},
@ -108,7 +108,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
it('returns 500 on delete not found', async () => {
const entryId = 'my-doc-id-1';
await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'DELETE /internal/observability_ai_assistant/kb/entries/{entryId}',
params: {
path: { entryId },
@ -117,119 +117,88 @@ export default function ApiTest({ getService }: FtrProviderContext) {
.expect(500);
});
});
describe('when managing multiple entries', () => {
before(async () => {
async function getEntries({
query = '',
sortBy = 'title',
sortDirection = 'asc',
}: { query?: string; sortBy?: string; sortDirection?: 'asc' | 'desc' } = {}) {
const res = await observabilityAIAssistantAPIClient
.editor({
endpoint: 'GET /internal/observability_ai_assistant/kb/entries',
params: {
query: { query, sortBy, sortDirection },
},
})
.expect(200);
return omitCategories(res.body.entries);
}
beforeEach(async () => {
await clearKnowledgeBase(es);
await observabilityAIAssistantAPIClient
.editor({
endpoint: 'POST /internal/observability_ai_assistant/kb/entries/import',
params: {
body: {
entries: [
{
id: 'my_doc_a',
title: 'My title a',
text: 'My content a',
},
{
id: 'my_doc_b',
title: 'My title b',
text: 'My content b',
},
{
id: 'my_doc_c',
title: 'My title c',
text: 'My content c',
},
],
},
},
})
.expect(200);
});
afterEach(async () => {
await clearKnowledgeBase(es);
});
const knowledgeBaseEntries = [
{
id: 'my_doc_a',
text: 'My content a',
},
{
id: 'my_doc_b',
text: 'My content b',
},
{
id: 'my_doc_c',
text: 'My content c',
},
];
it('returns 200 on create', async () => {
await observabilityAIAssistantAPIClient
.editorUser({
endpoint: 'POST /internal/observability_ai_assistant/kb/entries/import',
params: { body: { entries: knowledgeBaseEntries } },
})
.expect(200);
const res = await observabilityAIAssistantAPIClient
.editorUser({
endpoint: 'GET /internal/observability_ai_assistant/kb/entries',
params: {
query: {
query: '',
sortBy: 'doc_id',
sortDirection: 'asc',
},
},
})
.expect(200);
expect(res.body.entries.filter((entry) => entry.id.startsWith('my_doc')).length).to.eql(3);
const entries = await getEntries();
expect(omitCategories(entries).length).to.eql(3);
});
it('allows sorting', async () => {
await observabilityAIAssistantAPIClient
.editorUser({
endpoint: 'POST /internal/observability_ai_assistant/kb/entries/import',
params: { body: { entries: knowledgeBaseEntries } },
})
.expect(200);
describe('when sorting ', () => {
const ascendingOrder = ['my_doc_a', 'my_doc_b', 'my_doc_c'];
const res = await observabilityAIAssistantAPIClient
.editorUser({
endpoint: 'GET /internal/observability_ai_assistant/kb/entries',
params: {
query: {
query: '',
sortBy: 'doc_id',
sortDirection: 'desc',
},
},
})
.expect(200);
it('allows sorting ascending', async () => {
const entries = await getEntries({ sortBy: 'title', sortDirection: 'asc' });
expect(entries.map(({ id }) => id)).to.eql(ascendingOrder);
});
const entries = res.body.entries.filter((entry) => entry.id.startsWith('my_doc'));
expect(entries[0].id).to.eql('my_doc_c');
expect(entries[1].id).to.eql('my_doc_b');
expect(entries[2].id).to.eql('my_doc_a');
// asc
const resAsc = await observabilityAIAssistantAPIClient
.editorUser({
endpoint: 'GET /internal/observability_ai_assistant/kb/entries',
params: {
query: {
query: '',
sortBy: 'doc_id',
sortDirection: 'asc',
},
},
})
.expect(200);
const entriesAsc = resAsc.body.entries.filter((entry) => entry.id.startsWith('my_doc'));
expect(entriesAsc[0].id).to.eql('my_doc_a');
expect(entriesAsc[1].id).to.eql('my_doc_b');
expect(entriesAsc[2].id).to.eql('my_doc_c');
it('allows sorting descending', async () => {
const entries = await getEntries({ sortBy: 'title', sortDirection: 'desc' });
expect(entries.map(({ id }) => id)).to.eql([...ascendingOrder].reverse());
});
});
it('allows searching', async () => {
await observabilityAIAssistantAPIClient
.editorUser({
endpoint: 'POST /internal/observability_ai_assistant/kb/entries/import',
params: { body: { entries: knowledgeBaseEntries } },
})
.expect(200);
const res = await observabilityAIAssistantAPIClient
.editorUser({
endpoint: 'GET /internal/observability_ai_assistant/kb/entries',
params: {
query: {
query: 'my_doc_a',
sortBy: 'doc_id',
sortDirection: 'asc',
},
},
})
.expect(200);
expect(res.body.entries.length).to.eql(1);
expect(res.body.entries[0].id).to.eql('my_doc_a');
it('allows searching by title', async () => {
const entries = await getEntries({ query: 'b' });
expect(entries.length).to.eql(1);
expect(entries[0].title).to.eql('My title b');
});
});
});
}
function omitCategories(entries: KnowledgeBaseEntry[]) {
return entries.filter((entry) => entry.labels?.category === undefined);
}

View file

@ -17,7 +17,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
it('returns empty object when successful', async () => {
await createKnowledgeBaseModel(ml);
const res = await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'POST /internal/observability_ai_assistant/kb/setup',
})
.expect(200);
@ -27,7 +27,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
it('returns bad request if model cannot be installed', async () => {
await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'POST /internal/observability_ai_assistant/kb/setup',
})
.expect(400);

View file

@ -17,7 +17,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
before(async () => {
await createKnowledgeBaseModel(ml);
await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'POST /internal/observability_ai_assistant/kb/setup',
})
.expect(200);
@ -29,7 +29,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
it('returns correct status after knowledge base is setup', async () => {
const res = await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'GET /internal/observability_ai_assistant/kb/status',
})
.expect(200);
@ -41,7 +41,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
await ml.api.stopTrainedModelDeploymentES(TINY_ELSER.id, true);
const res = await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'GET /internal/observability_ai_assistant/kb/status',
})
.expect(200);

View file

@ -6,7 +6,6 @@
*/
import expect from '@kbn/expect';
import { kbnTestConfig } from '@kbn/test';
import { sortBy } from 'lodash';
import { Message, MessageRole } from '@kbn/observability-ai-assistant-plugin/common';
import { CONTEXT_FUNCTION_NAME } from '@kbn/observability-ai-assistant-plugin/server/functions/context';
@ -21,33 +20,27 @@ import {
import { getConversationCreatedEvent } from '../conversations/helpers';
import { LlmProxy, createLlmProxy } from '../../common/create_llm_proxy';
import { createProxyActionConnector, deleteActionConnector } from '../../common/action_connectors';
import { User } from '../../common/users/users';
export default function ApiTest({ getService }: FtrProviderContext) {
const observabilityAIAssistantAPIClient = getService('observabilityAIAssistantAPIClient');
const getScopedApiClientForUsername = getService('getScopedApiClientForUsername');
const security = getService('security');
const supertest = getService('supertest');
const es = getService('es');
const ml = getService('ml');
const log = getService('log');
const getScopedApiClientForUsername = getService('getScopedApiClientForUsername');
describe('Knowledge base user instructions', () => {
const userJohn = 'john';
before(async () => {
// create user
const password = kbnTestConfig.getUrlParts().password!;
await security.user.create(userJohn, { password, roles: ['editor'] });
await createKnowledgeBaseModel(ml);
await observabilityAIAssistantAPIClient
.editorUser({ endpoint: 'POST /internal/observability_ai_assistant/kb/setup' })
.editor({ endpoint: 'POST /internal/observability_ai_assistant/kb/setup' })
.expect(200);
});
after(async () => {
await deleteKnowledgeBaseModel(ml);
await security.user.delete(userJohn);
await clearKnowledgeBase(es);
await clearConversations(es);
});
@ -58,19 +51,19 @@ export default function ApiTest({ getService }: FtrProviderContext) {
const promises = [
{
username: 'editor',
username: 'editor' as const,
isPublic: true,
},
{
username: 'editor',
username: 'editor' as const,
isPublic: false,
},
{
username: userJohn,
username: 'secondary_editor' as const,
isPublic: true,
},
{
username: userJohn,
username: 'secondary_editor' as const,
isPublic: false,
},
].map(async ({ username, isPublic }) => {
@ -92,61 +85,59 @@ export default function ApiTest({ getService }: FtrProviderContext) {
});
it('"editor" can retrieve their own private instructions and the public instruction', async () => {
const res = await observabilityAIAssistantAPIClient.editorUser({
const res = await observabilityAIAssistantAPIClient.editor({
endpoint: 'GET /internal/observability_ai_assistant/kb/user_instructions',
});
const instructions = res.body.userInstructions;
const sortByDocId = (data: Array<Instruction & { public?: boolean }>) =>
sortBy(data, 'doc_id');
const sortById = (data: Array<Instruction & { public?: boolean }>) => sortBy(data, 'id');
expect(sortByDocId(instructions)).to.eql(
sortByDocId([
expect(sortById(instructions)).to.eql(
sortById([
{
doc_id: 'private-doc-from-editor',
id: 'private-doc-from-editor',
public: false,
text: 'Private user instruction from "editor"',
},
{
doc_id: 'public-doc-from-editor',
id: 'public-doc-from-editor',
public: true,
text: 'Public user instruction from "editor"',
},
{
doc_id: 'public-doc-from-john',
id: 'public-doc-from-secondary_editor',
public: true,
text: 'Public user instruction from "john"',
text: 'Public user instruction from "secondary_editor"',
},
])
);
});
it('"john" can retrieve their own private instructions and the public instruction', async () => {
const res = await getScopedApiClientForUsername(userJohn)({
it('"secondaryEditor" can retrieve their own private instructions and the public instruction', async () => {
const res = await observabilityAIAssistantAPIClient.secondaryEditor({
endpoint: 'GET /internal/observability_ai_assistant/kb/user_instructions',
});
const instructions = res.body.userInstructions;
const sortByDocId = (data: Array<Instruction & { public?: boolean }>) =>
sortBy(data, 'doc_id');
const sortById = (data: Array<Instruction & { public?: boolean }>) => sortBy(data, 'id');
expect(sortByDocId(instructions)).to.eql(
sortByDocId([
expect(sortById(instructions)).to.eql(
sortById([
{
doc_id: 'public-doc-from-editor',
id: 'public-doc-from-editor',
public: true,
text: 'Public user instruction from "editor"',
},
{
doc_id: 'public-doc-from-john',
id: 'public-doc-from-secondary_editor',
public: true,
text: 'Public user instruction from "john"',
text: 'Public user instruction from "secondary_editor"',
},
{
doc_id: 'private-doc-from-john',
id: 'private-doc-from-secondary_editor',
public: false,
text: 'Private user instruction from "john"',
text: 'Private user instruction from "secondary_editor"',
},
])
);
@ -158,7 +149,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
await clearKnowledgeBase(es);
await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'PUT /internal/observability_ai_assistant/kb/user_instructions',
params: {
body: {
@ -171,7 +162,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
.expect(200);
await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'PUT /internal/observability_ai_assistant/kb/user_instructions',
params: {
body: {
@ -185,14 +176,14 @@ export default function ApiTest({ getService }: FtrProviderContext) {
});
it('updates the user instruction', async () => {
const res = await observabilityAIAssistantAPIClient.editorUser({
const res = await observabilityAIAssistantAPIClient.editor({
endpoint: 'GET /internal/observability_ai_assistant/kb/user_instructions',
});
const instructions = res.body.userInstructions;
expect(instructions).to.eql([
{
doc_id: 'doc-to-update',
id: 'doc-to-update',
text: 'Updated text',
public: false,
},
@ -207,12 +198,12 @@ export default function ApiTest({ getService }: FtrProviderContext) {
const userInstructionText =
'Be polite and use language that is easy to understand. Never disagree with the user.';
async function getConversationForUser(username: string) {
async function getConversationForUser(username: User['username']) {
const apiClient = getScopedApiClientForUsername(username);
// the user instruction is always created by "editor" user
await observabilityAIAssistantAPIClient
.editorUser({
.editor({
endpoint: 'PUT /internal/observability_ai_assistant/kb/user_instructions',
params: {
body: {
@ -314,7 +305,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
});
it('does not add the instruction conversation for other users', async () => {
const conversation = await getConversationForUser('john');
const conversation = await getConversationForUser('secondary_editor');
const systemMessage = conversation.messages.find(
(message) => message.message.role === MessageRole.System
)!;

View file

@ -70,7 +70,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
(body) => !isFunctionTitleRequest(body)
);
const responsePromise = observabilityAIAssistantAPIClient.adminUser({
const responsePromise = observabilityAIAssistantAPIClient.admin({
endpoint: 'POST /api/observability_ai_assistant/chat/complete 2023-10-31',
params: {
query: { format },

View file

@ -13,8 +13,9 @@ import {
KibanaEBTUIProvider,
} from '@kbn/test-suites-src/analytics/services/kibana_ebt';
import {
editorUser,
viewerUser,
secondaryEditor,
editor,
viewer,
} from '../../observability_ai_assistant_api_integration/common/users/users';
import {
ObservabilityAIAssistantFtrConfig,
@ -61,9 +62,10 @@ async function getTestConfig({
ObservabilityAIAssistantUIProvider(context),
observabilityAIAssistantAPIClient: async (context: InheritedFtrProviderContext) => {
return {
adminUser: await getScopedApiClient(kibanaServer, 'elastic'),
viewerUser: await getScopedApiClient(kibanaServer, viewerUser.username),
editorUser: await getScopedApiClient(kibanaServer, editorUser.username),
admin: getScopedApiClient(kibanaServer, 'elastic'),
viewer: getScopedApiClient(kibanaServer, viewer.username),
editor: getScopedApiClient(kibanaServer, editor.username),
secondaryEditor: getScopedApiClient(kibanaServer, secondaryEditor.username),
};
},
kibana_ebt_server: KibanaEBTServerProvider,

View file

@ -27,6 +27,11 @@ export interface ObservabilityAIAssistantUIService {
}
const pages = {
kbManagementTab: {
table: 'knowledgeBaseTable',
tableTitleCell: 'knowledgeBaseTableTitleCell',
tableAuthorCell: 'knowledgeBaseTableAuthorCell',
},
conversations: {
setupGenAiConnectorsButtonSelector: `observabilityAiAssistantInitialSetupPanelSetUpGenerativeAiConnectorButton`,
chatInput: 'observabilityAiAssistantChatPromptEditorTextArea',

View file

@ -36,12 +36,12 @@ export default function ApiTest({ getService, getPageObjects }: FtrProviderConte
const flyoutService = getService('flyout');
async function deleteConversations() {
const response = await observabilityAIAssistantAPIClient.editorUser({
const response = await observabilityAIAssistantAPIClient.editor({
endpoint: 'POST /internal/observability_ai_assistant/conversations',
});
for (const conversation of response.body.conversations) {
await observabilityAIAssistantAPIClient.editorUser({
await observabilityAIAssistantAPIClient.editor({
endpoint: `DELETE /internal/observability_ai_assistant/conversation/{conversationId}`,
params: {
path: {
@ -53,7 +53,7 @@ export default function ApiTest({ getService, getPageObjects }: FtrProviderConte
}
async function deleteConnectors() {
const response = await observabilityAIAssistantAPIClient.editorUser({
const response = await observabilityAIAssistantAPIClient.editor({
endpoint: 'GET /internal/observability_ai_assistant/connectors',
});
@ -66,7 +66,7 @@ export default function ApiTest({ getService, getPageObjects }: FtrProviderConte
}
async function createOldConversation() {
await observabilityAIAssistantAPIClient.editorUser({
await observabilityAIAssistantAPIClient.editor({
endpoint: 'POST /internal/observability_ai_assistant/conversation',
params: {
body: {
@ -204,7 +204,7 @@ export default function ApiTest({ getService, getPageObjects }: FtrProviderConte
});
it('creates a connector', async () => {
const response = await observabilityAIAssistantAPIClient.editorUser({
const response = await observabilityAIAssistantAPIClient.editor({
endpoint: 'GET /internal/observability_ai_assistant/connectors',
});
@ -259,7 +259,7 @@ export default function ApiTest({ getService, getPageObjects }: FtrProviderConte
});
it('creates a conversation and updates the URL', async () => {
const response = await observabilityAIAssistantAPIClient.editorUser({
const response = await observabilityAIAssistantAPIClient.editor({
endpoint: 'POST /internal/observability_ai_assistant/conversations',
});
@ -325,7 +325,7 @@ export default function ApiTest({ getService, getPageObjects }: FtrProviderConte
});
it('does not create another conversation', async () => {
const response = await observabilityAIAssistantAPIClient.editorUser({
const response = await observabilityAIAssistantAPIClient.editor({
endpoint: 'POST /internal/observability_ai_assistant/conversations',
});
@ -333,7 +333,7 @@ export default function ApiTest({ getService, getPageObjects }: FtrProviderConte
});
it('appends to the existing one', async () => {
const response = await observabilityAIAssistantAPIClient.editorUser({
const response = await observabilityAIAssistantAPIClient.editor({
endpoint: 'POST /internal/observability_ai_assistant/conversations',
});

View file

@ -0,0 +1,124 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { subj as testSubjSelector } from '@kbn/test-subj-selector';
import {
clearKnowledgeBase,
createKnowledgeBaseModel,
deleteKnowledgeBaseModel,
} from '../../../observability_ai_assistant_api_integration/tests/knowledge_base/helpers';
import { ObservabilityAIAssistantApiClient } from '../../../observability_ai_assistant_api_integration/common/observability_ai_assistant_api_client';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ApiTest({ getService, getPageObjects }: FtrProviderContext) {
const observabilityAIAssistantAPIClient = getService('observabilityAIAssistantAPIClient');
const ui = getService('observabilityAIAssistantUI');
const testSubjects = getService('testSubjects');
const log = getService('log');
const ml = getService('ml');
const es = getService('es');
const { common } = getPageObjects(['common']);
async function saveKbEntry({
apiClient,
text,
}: {
apiClient: ObservabilityAIAssistantApiClient;
text: string;
}) {
return apiClient({
endpoint: 'POST /internal/observability_ai_assistant/functions/summarize',
params: {
body: {
title: 'Favourite color',
text,
confidence: 'high',
is_correction: false,
public: false,
labels: {},
},
},
}).expect(200);
}
describe('Knowledge management tab', () => {
before(async () => {
await clearKnowledgeBase(es);
// create a knowledge base model
await createKnowledgeBaseModel(ml);
await Promise.all([
// setup the knowledge base
observabilityAIAssistantAPIClient
.editor({ endpoint: 'POST /internal/observability_ai_assistant/kb/setup' })
.expect(200),
// login as editor
ui.auth.login('editor'),
]);
});
after(async () => {
await Promise.all([deleteKnowledgeBaseModel(ml), clearKnowledgeBase(es), ui.auth.logout()]);
});
describe('when the LLM calls the "summarize" function for two different users', () => {
async function getKnowledgeBaseEntries() {
await common.navigateToUrlWithBrowserHistory(
'management',
'/kibana/observabilityAiAssistantManagement',
'tab=knowledge_base'
);
const entryTitleCells = await testSubjects.findAll(ui.pages.kbManagementTab.tableTitleCell);
const rows = await Promise.all(
entryTitleCells.map(async (cell) => {
const title = await cell.getVisibleText();
const parentRow = await cell.findByXpath('ancestor::tr');
const authorElm = await parentRow.findByCssSelector(
testSubjSelector(ui.pages.kbManagementTab.tableAuthorCell)
);
const author = await authorElm.getVisibleText();
const rowText = (await parentRow.getVisibleText()).split('\n');
return { rowText, author, title };
})
);
log.debug(`Found ${rows.length} rows in the KB management table: ${JSON.stringify(rows)}`);
return rows.filter(({ title }) => title === 'Favourite color');
}
before(async () => {
await saveKbEntry({
apiClient: observabilityAIAssistantAPIClient.editor,
text: 'My favourite color is red',
});
await saveKbEntry({
apiClient: observabilityAIAssistantAPIClient.secondaryEditor,
text: 'My favourite color is blue',
});
});
it('shows two entries', async () => {
const entries = await getKnowledgeBaseEntries();
expect(entries.length).to.eql(2);
});
it('shows two different authors', async () => {
const entries = await getKnowledgeBaseEntries();
expect(entries.map(({ author }) => author)).to.eql(['secondary_editor', 'editor']);
});
});
});
}

View file

@ -151,7 +151,6 @@ export default function ({ getService }: FtrProviderContext) {
'fleet:update_agent_tags:retry',
'fleet:upgrade_action:retry',
'logs-data-telemetry',
'observabilityAIAssistant:indexQueuedDocumentsTaskType',
'osquery:telemetry-configs',
'osquery:telemetry-packs',
'osquery:telemetry-saved-queries',

View file

@ -52,6 +52,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
describe('when managing a single entry', () => {
const knowledgeBaseEntry = {
id: 'my-doc-id-1',
title: 'My title',
text: 'My content',
};
it('returns 200 on create', async () => {
@ -156,14 +157,17 @@ export default function ApiTest({ getService }: FtrProviderContext) {
const knowledgeBaseEntries = [
{
id: 'my_doc_a',
title: 'My title a',
text: 'My content a',
},
{
id: 'my_doc_b',
title: 'My title b',
text: 'My content b',
},
{
id: 'my_doc_c',
title: 'My title c',
text: 'My content c',
},
];