mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[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:
parent
669761be5e
commit
7c92a10b32
51 changed files with 696 additions and 1447 deletions
|
@ -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';
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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([
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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).`),
|
||||
},
|
||||
]);
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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: [],
|
||||
})
|
||||
|
|
|
@ -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] : []),
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
})),
|
||||
];
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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'));
|
||||
}
|
|
@ -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 })),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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'>;
|
|
@ -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 }>);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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 },
|
||||
}
|
||||
),
|
||||
});
|
||||
|
|
|
@ -30,7 +30,7 @@ export function useImportKnowledgeBaseEntries() {
|
|||
Omit<
|
||||
KnowledgeBaseEntry,
|
||||
'@timestamp' | 'confidence' | 'is_correction' | 'public' | 'labels'
|
||||
>
|
||||
> & { title: string }
|
||||
>;
|
||||
}
|
||||
>(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
)!;
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -27,6 +27,11 @@ export interface ObservabilityAIAssistantUIService {
|
|||
}
|
||||
|
||||
const pages = {
|
||||
kbManagementTab: {
|
||||
table: 'knowledgeBaseTable',
|
||||
tableTitleCell: 'knowledgeBaseTableTitleCell',
|
||||
tableAuthorCell: 'knowledgeBaseTableAuthorCell',
|
||||
},
|
||||
conversations: {
|
||||
setupGenAiConnectorsButtonSelector: `observabilityAiAssistantInitialSetupPanelSetUpGenerativeAiConnectorButton`,
|
||||
chatInput: 'observabilityAiAssistantChatPromptEditorTextArea',
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
||||
|
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue