[8.x] [LLM tasks] Add product documentation retrieval task (#194379) (#200754)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[LLM tasks] Add product documentation retrieval task
(#194379)](https://github.com/elastic/kibana/pull/194379)

<!--- Backport version: 8.9.8 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Pierre
Gayvallet","email":"pierre.gayvallet@elastic.co"},"sourceCommit":{"committedDate":"2024-11-19T14:28:26Z","message":"[LLM
tasks] Add product documentation retrieval task (#194379)\n\n##
Summary\r\n\r\nClose
https://github.com/elastic/kibana/issues/193473\r\nClose
https://github.com/elastic/kibana/issues/193474\r\n\r\nThis PR utilize
the documentation packages that are build via the tool\r\nintroduced by
https://github.com/elastic/kibana/pull/193847, allowing to\r\ninstall
them in Kibana and expose documentation retrieval as an LLM task\r\nthat
AI assistants (or other consumers) can call.\r\n\r\nUsers can now decide
to install the Elastic documentation from the\r\nassistant's config
screen, which will expose a new tool for the\r\nassistant,
`retrieve_documentation` (only implemented for the o11y\r\nassistant in
the current PR, shall be done for security as a follow up).\r\n\r\nFor
more information, please refer to the self-review.\r\n\r\n## General
architecture\r\n\r\n<img width=\"1118\" alt=\"Screenshot 2024-10-17 at
09 22
32\"\r\nsrc=\"https://github.com/user-attachments/assets/3df8c30a-9ccc-49ab-92ce-c204b96d6fc4\">\r\n\r\n##
What this PR does\r\n\r\nAdds two plugin:\r\n- `productDocBase`:
contains all the logic related to product\r\ndocumentation installation,
status, and search. This is meant to be a\r\n\"low level\" components
only responsible for this specific part.\r\n- `llmTasks`: an higher
level plugin that will contain various LLM tasks\r\nto be used by
assistants and genAI consumers. The intent is not to have\r\na single
place to put all llm tasks, but more to have a default place\r\nwhere we
can introduce new tasks from. (fwiw, the `nlToEsql` task
will\r\nprobably be moved to that plugin).\r\n\r\n- Add a
`retrieve_documentation` tool registration for the
o11y\r\nassistant\r\n- Add a component on the o11y assistant
configuration page to install\r\nthe product doc\r\n\r\n(wiring the
feature to the o11y assistant was done for testing purposes\r\nmostly,
any addition / changes / enhancement should be done by the\r\nowning
team - either in this PR or as a follow-up)\r\n\r\n## What is NOT
included in this PR:\r\n\r\n- Wire product base feature to the security
assistant (should be done by\r\nthe owning team as a follow-up)\r\n -
installation\r\n - utilization as tool\r\n\r\n- FTR tests: this is
somewhat blocked by the same things we need to\r\nfigure out for
https://github.com/elastic/kibana-team/issues/1271\r\n\r\n## Screenshots
\r\n\r\n### Installation from o11y assistant configuration
page\r\n\r\n<img width=\"1476\" alt=\"Screenshot 2024-10-17 at 09 41
24\"\r\nsrc=\"https://github.com/user-attachments/assets/31daa585-9fb2-400a-a2d1-5917a262367a\">\r\n\r\n###
Example of output\r\n\r\n#### Without product documentation installed
\r\n\r\n<img width=\"739\" alt=\"Screenshot 2024-10-10 at 09 59
41\"\r\nsrc=\"https://github.com/user-attachments/assets/993fb216-6c9a-433f-bf44-f6e383d20d9d\">\r\n\r\n####
With product documentation installed\r\n\r\n<img width=\"718\"
alt=\"Screenshot 2024-10-10 at 09 55
38\"\r\nsrc=\"https://github.com/user-attachments/assets/805ea4ca-8bc9-4355-a434-0ba81f8228a9\">\r\n\r\n---------\r\n\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Alex Szabo <alex.szabo@elastic.co>\r\nCo-authored-by: Matthias Wilhelm
<matthias.wilhelm@elastic.co>\r\nCo-authored-by: Elastic Machine
<elasticmachine@users.noreply.github.com>","sha":"455c781c6d1e1161f66e275299cf06064a0ffde2","branchLabelMapping":{"^v9.0.0$":"main","^v8.17.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","v9.0.0","backport:prev-minor","ci:cloud-deploy","Team:Obs
AI Assistant","ci:project-deploy-observability","Team:AI
Infra","v8.17.0"],"number":194379,"url":"https://github.com/elastic/kibana/pull/194379","mergeCommit":{"message":"[LLM
tasks] Add product documentation retrieval task (#194379)\n\n##
Summary\r\n\r\nClose
https://github.com/elastic/kibana/issues/193473\r\nClose
https://github.com/elastic/kibana/issues/193474\r\n\r\nThis PR utilize
the documentation packages that are build via the tool\r\nintroduced by
https://github.com/elastic/kibana/pull/193847, allowing to\r\ninstall
them in Kibana and expose documentation retrieval as an LLM task\r\nthat
AI assistants (or other consumers) can call.\r\n\r\nUsers can now decide
to install the Elastic documentation from the\r\nassistant's config
screen, which will expose a new tool for the\r\nassistant,
`retrieve_documentation` (only implemented for the o11y\r\nassistant in
the current PR, shall be done for security as a follow up).\r\n\r\nFor
more information, please refer to the self-review.\r\n\r\n## General
architecture\r\n\r\n<img width=\"1118\" alt=\"Screenshot 2024-10-17 at
09 22
32\"\r\nsrc=\"https://github.com/user-attachments/assets/3df8c30a-9ccc-49ab-92ce-c204b96d6fc4\">\r\n\r\n##
What this PR does\r\n\r\nAdds two plugin:\r\n- `productDocBase`:
contains all the logic related to product\r\ndocumentation installation,
status, and search. This is meant to be a\r\n\"low level\" components
only responsible for this specific part.\r\n- `llmTasks`: an higher
level plugin that will contain various LLM tasks\r\nto be used by
assistants and genAI consumers. The intent is not to have\r\na single
place to put all llm tasks, but more to have a default place\r\nwhere we
can introduce new tasks from. (fwiw, the `nlToEsql` task
will\r\nprobably be moved to that plugin).\r\n\r\n- Add a
`retrieve_documentation` tool registration for the
o11y\r\nassistant\r\n- Add a component on the o11y assistant
configuration page to install\r\nthe product doc\r\n\r\n(wiring the
feature to the o11y assistant was done for testing purposes\r\nmostly,
any addition / changes / enhancement should be done by the\r\nowning
team - either in this PR or as a follow-up)\r\n\r\n## What is NOT
included in this PR:\r\n\r\n- Wire product base feature to the security
assistant (should be done by\r\nthe owning team as a follow-up)\r\n -
installation\r\n - utilization as tool\r\n\r\n- FTR tests: this is
somewhat blocked by the same things we need to\r\nfigure out for
https://github.com/elastic/kibana-team/issues/1271\r\n\r\n## Screenshots
\r\n\r\n### Installation from o11y assistant configuration
page\r\n\r\n<img width=\"1476\" alt=\"Screenshot 2024-10-17 at 09 41
24\"\r\nsrc=\"https://github.com/user-attachments/assets/31daa585-9fb2-400a-a2d1-5917a262367a\">\r\n\r\n###
Example of output\r\n\r\n#### Without product documentation installed
\r\n\r\n<img width=\"739\" alt=\"Screenshot 2024-10-10 at 09 59
41\"\r\nsrc=\"https://github.com/user-attachments/assets/993fb216-6c9a-433f-bf44-f6e383d20d9d\">\r\n\r\n####
With product documentation installed\r\n\r\n<img width=\"718\"
alt=\"Screenshot 2024-10-10 at 09 55
38\"\r\nsrc=\"https://github.com/user-attachments/assets/805ea4ca-8bc9-4355-a434-0ba81f8228a9\">\r\n\r\n---------\r\n\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Alex Szabo <alex.szabo@elastic.co>\r\nCo-authored-by: Matthias Wilhelm
<matthias.wilhelm@elastic.co>\r\nCo-authored-by: Elastic Machine
<elasticmachine@users.noreply.github.com>","sha":"455c781c6d1e1161f66e275299cf06064a0ffde2"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","labelRegex":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/194379","number":194379,"mergeCommit":{"message":"[LLM
tasks] Add product documentation retrieval task (#194379)\n\n##
Summary\r\n\r\nClose
https://github.com/elastic/kibana/issues/193473\r\nClose
https://github.com/elastic/kibana/issues/193474\r\n\r\nThis PR utilize
the documentation packages that are build via the tool\r\nintroduced by
https://github.com/elastic/kibana/pull/193847, allowing to\r\ninstall
them in Kibana and expose documentation retrieval as an LLM task\r\nthat
AI assistants (or other consumers) can call.\r\n\r\nUsers can now decide
to install the Elastic documentation from the\r\nassistant's config
screen, which will expose a new tool for the\r\nassistant,
`retrieve_documentation` (only implemented for the o11y\r\nassistant in
the current PR, shall be done for security as a follow up).\r\n\r\nFor
more information, please refer to the self-review.\r\n\r\n## General
architecture\r\n\r\n<img width=\"1118\" alt=\"Screenshot 2024-10-17 at
09 22
32\"\r\nsrc=\"https://github.com/user-attachments/assets/3df8c30a-9ccc-49ab-92ce-c204b96d6fc4\">\r\n\r\n##
What this PR does\r\n\r\nAdds two plugin:\r\n- `productDocBase`:
contains all the logic related to product\r\ndocumentation installation,
status, and search. This is meant to be a\r\n\"low level\" components
only responsible for this specific part.\r\n- `llmTasks`: an higher
level plugin that will contain various LLM tasks\r\nto be used by
assistants and genAI consumers. The intent is not to have\r\na single
place to put all llm tasks, but more to have a default place\r\nwhere we
can introduce new tasks from. (fwiw, the `nlToEsql` task
will\r\nprobably be moved to that plugin).\r\n\r\n- Add a
`retrieve_documentation` tool registration for the
o11y\r\nassistant\r\n- Add a component on the o11y assistant
configuration page to install\r\nthe product doc\r\n\r\n(wiring the
feature to the o11y assistant was done for testing purposes\r\nmostly,
any addition / changes / enhancement should be done by the\r\nowning
team - either in this PR or as a follow-up)\r\n\r\n## What is NOT
included in this PR:\r\n\r\n- Wire product base feature to the security
assistant (should be done by\r\nthe owning team as a follow-up)\r\n -
installation\r\n - utilization as tool\r\n\r\n- FTR tests: this is
somewhat blocked by the same things we need to\r\nfigure out for
https://github.com/elastic/kibana-team/issues/1271\r\n\r\n## Screenshots
\r\n\r\n### Installation from o11y assistant configuration
page\r\n\r\n<img width=\"1476\" alt=\"Screenshot 2024-10-17 at 09 41
24\"\r\nsrc=\"https://github.com/user-attachments/assets/31daa585-9fb2-400a-a2d1-5917a262367a\">\r\n\r\n###
Example of output\r\n\r\n#### Without product documentation installed
\r\n\r\n<img width=\"739\" alt=\"Screenshot 2024-10-10 at 09 59
41\"\r\nsrc=\"https://github.com/user-attachments/assets/993fb216-6c9a-433f-bf44-f6e383d20d9d\">\r\n\r\n####
With product documentation installed\r\n\r\n<img width=\"718\"
alt=\"Screenshot 2024-10-10 at 09 55
38\"\r\nsrc=\"https://github.com/user-attachments/assets/805ea4ca-8bc9-4355-a434-0ba81f8228a9\">\r\n\r\n---------\r\n\r\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Alex Szabo <alex.szabo@elastic.co>\r\nCo-authored-by: Matthias Wilhelm
<matthias.wilhelm@elastic.co>\r\nCo-authored-by: Elastic Machine
<elasticmachine@users.noreply.github.com>","sha":"455c781c6d1e1161f66e275299cf06064a0ffde2"}},{"branch":"8.x","label":"v8.17.0","labelRegex":"^v8.17.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->
This commit is contained in:
Pierre Gayvallet 2024-11-19 18:06:20 +01:00 committed by GitHub
parent d2f38b9db8
commit b6fe3628e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
149 changed files with 5660 additions and 64 deletions

View file

@ -690,6 +690,10 @@ the infrastructure monitoring use-case within Kibana.
using the CURL scripts in the scripts folder.
|{kib-repo}blob/{branch}/x-pack/plugins/ai_infra/llm_tasks/README.md[llmTasks]
|This plugin contains various LLM tasks.
|{kib-repo}blob/{branch}/x-pack/plugins/observability_solution/logs_data_access/README.md[logsDataAccess]
|Exposes services to access logs data.
@ -767,6 +771,10 @@ Elastic.
|This plugin helps users learn how to use the Painless scripting language.
|{kib-repo}blob/{branch}/x-pack/plugins/ai_infra/product_doc_base/README.md[productDocBase]
|This plugin contains the product documentation base service.
|{kib-repo}blob/{branch}/x-pack/plugins/observability_solution/profiling/README.md[profiling]
|Universal Profiling provides fleet-wide, whole-system, continuous profiling with zero instrumentation. Get a comprehensive understanding of what lines of code are consuming compute resources throughout your entire fleet by visualizing your data in Kibana using the flamegraph, stacktraces, and top functions views.

View file

@ -148,6 +148,9 @@ Refer to the corresponding {es} logs for potential write errors.
| `success` | Creating trained model.
| `failure` | Failed to create trained model.
.1+| `product_documentation_create`
| `unknown` | User requested to install the product documentation for use in AI Assistants.
3+a|
====== Type: change
@ -334,6 +337,9 @@ Refer to the corresponding {es} logs for potential write errors.
| `success` | Updating trained model deployment.
| `failure` | Failed to update trained model deployment.
.1+| `product_documentation_update`
| `unknown` | User requested to update the product documentation for use in AI Assistants.
3+a|
====== Type: deletion
@ -425,6 +431,9 @@ Refer to the corresponding {es} logs for potential write errors.
| `success` | Deleting trained model.
| `failure` | Failed to delete trained model.
.1+| `product_documentation_delete`
| `unknown` | User requested to delete the product documentation for use in AI Assistants.
3+a|
====== Type: access

View file

@ -616,6 +616,7 @@
"@kbn/licensing-plugin": "link:x-pack/plugins/licensing",
"@kbn/links-plugin": "link:src/plugins/links",
"@kbn/lists-plugin": "link:x-pack/plugins/lists",
"@kbn/llm-tasks-plugin": "link:x-pack/plugins/ai_infra/llm_tasks",
"@kbn/locator-examples-plugin": "link:examples/locator_examples",
"@kbn/locator-explorer-plugin": "link:examples/locator_explorer",
"@kbn/logging": "link:packages/kbn-logging",
@ -718,6 +719,8 @@
"@kbn/presentation-panel-plugin": "link:src/plugins/presentation_panel",
"@kbn/presentation-publishing": "link:packages/presentation/presentation_publishing",
"@kbn/presentation-util-plugin": "link:src/plugins/presentation_util",
"@kbn/product-doc-base-plugin": "link:x-pack/plugins/ai_infra/product_doc_base",
"@kbn/product-doc-common": "link:x-pack/packages/ai-infra/product-doc-common",
"@kbn/profiling-data-access-plugin": "link:x-pack/plugins/observability_solution/profiling_data_access",
"@kbn/profiling-plugin": "link:x-pack/plugins/observability_solution/profiling",
"@kbn/profiling-utils": "link:packages/kbn-profiling-utils",

View file

@ -855,6 +855,13 @@
"policy-settings-protection-updates-note": [
"note"
],
"product-doc-install-status": [
"index_name",
"installation_status",
"last_installation_date",
"product_name",
"product_version"
],
"query": [
"description",
"title",

View file

@ -2841,6 +2841,26 @@
}
}
},
"product-doc-install-status": {
"dynamic": false,
"properties": {
"index_name": {
"type": "keyword"
},
"installation_status": {
"type": "keyword"
},
"last_installation_date": {
"type": "date"
},
"product_name": {
"type": "keyword"
},
"product_version": {
"type": "keyword"
}
}
},
"query": {
"dynamic": false,
"properties": {

View file

@ -124,6 +124,7 @@ pageLoadAssetSize:
painlessLab: 179748
presentationPanel: 55463
presentationUtil: 58834
productDocBase: 22500
profiling: 36694
remoteClusters: 51327
reporting: 58600

View file

@ -145,6 +145,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"osquery-pack-asset": "cd140bc2e4b092e93692b587bf6e38051ef94c75",
"osquery-saved-query": "6095e288750aa3164dfe186c74bc5195c2bf2bd4",
"policy-settings-protection-updates-note": "33924bb246f9e5bcb876109cc83e3c7a28308352",
"product-doc-install-status": "ca6e96840228e4cc2f11bae24a0797f4f7238c8c",
"query": "501bece68f26fe561286a488eabb1a8ab12f1137",
"risk-engine-configuration": "aea0c371a462e6d07c3ceb3aff11891b47feb09d",
"rules-settings": "ba57ef1881b3dcbf48fbfb28902d8f74442190b2",

View file

@ -115,6 +115,7 @@ const previouslyRegisteredTypes = [
'osquery-usage-metric',
'osquery-manager-usage-metric',
'policy-settings-protection-updates-note',
'product-doc-install-status',
'query',
'rules-settings',
'sample-data-telemetry',

View file

@ -1146,6 +1146,8 @@
"@kbn/lint-ts-projects-cli/*": ["packages/kbn-lint-ts-projects-cli/*"],
"@kbn/lists-plugin": ["x-pack/plugins/lists"],
"@kbn/lists-plugin/*": ["x-pack/plugins/lists/*"],
"@kbn/llm-tasks-plugin": ["x-pack/plugins/ai_infra/llm_tasks"],
"@kbn/llm-tasks-plugin/*": ["x-pack/plugins/ai_infra/llm_tasks/*"],
"@kbn/locator-examples-plugin": ["examples/locator_examples"],
"@kbn/locator-examples-plugin/*": ["examples/locator_examples/*"],
"@kbn/locator-explorer-plugin": ["examples/locator_explorer"],
@ -1384,6 +1386,10 @@
"@kbn/presentation-util-plugin/*": ["src/plugins/presentation_util/*"],
"@kbn/product-doc-artifact-builder": ["x-pack/packages/ai-infra/product-doc-artifact-builder"],
"@kbn/product-doc-artifact-builder/*": ["x-pack/packages/ai-infra/product-doc-artifact-builder/*"],
"@kbn/product-doc-base-plugin": ["x-pack/plugins/ai_infra/product_doc_base"],
"@kbn/product-doc-base-plugin/*": ["x-pack/plugins/ai_infra/product_doc_base/*"],
"@kbn/product-doc-common": ["x-pack/packages/ai-infra/product-doc-common"],
"@kbn/product-doc-common/*": ["x-pack/packages/ai-infra/product-doc-common/*"],
"@kbn/profiling-data-access-plugin": ["x-pack/plugins/observability_solution/profiling_data_access"],
"@kbn/profiling-data-access-plugin/*": ["x-pack/plugins/observability_solution/profiling_data_access/*"],
"@kbn/profiling-plugin": ["x-pack/plugins/observability_solution/profiling"],

View file

@ -1,3 +1,49 @@
# @kbn/product-doc-artifact-builder
Script to build the knowledge base artifacts
Script to build the knowledge base artifacts.
## How to run
```
node scripts/build_product_doc_artifacts.js --stack-version {version} --product-name {product}
```
### parameters
#### `stack-version`:
the stack version to generate the artifacts for.
#### `product-name`:
(multi-value) the list of products to generate artifacts for.
possible values:
- "kibana"
- "elasticsearch"
- "observability"
- "security"
#### `target-folder`:
The folder to generate the artifacts in.
Defaults to `{REPO_ROOT}/build-kb-artifacts`.
#### `build-folder`:
The folder to use for temporary files.
Defaults to `{REPO_ROOT}/build/temp-kb-artifacts`
#### Cluster infos
- params for the source cluster:
`sourceClusterUrl` / env.KIBANA_SOURCE_CLUSTER_URL
`sourceClusterUsername` / env.KIBANA_SOURCE_CLUSTER_USERNAME
`sourceClusterPassword` / env.KIBANA_SOURCE_CLUSTER_PASSWORD
- params for the embedding cluster:
`embeddingClusterUrl` / env.KIBANA_EMBEDDING_CLUSTER_URL
`embeddingClusterUsername` / env.KIBANA_EMBEDDING_CLUSTER_USERNAME
`embeddingClusterPassword` / env.KIBANA_EMBEDDING_CLUSTER_PASSWORD

View file

@ -5,17 +5,13 @@
* 2.0.
*/
export interface ArtifactManifest {
formatVersion: string;
productName: string;
productVersion: string;
}
import type { ArtifactManifest, ProductName } from '@kbn/product-doc-common';
export const getArtifactManifest = ({
productName,
stackVersion,
}: {
productName: string;
productName: ProductName;
stackVersion: string;
}): ArtifactManifest => {
return {

View file

@ -21,10 +21,7 @@ export const getArtifactMappings = (inferenceEndpoint: string): MappingTypeMappi
slug: { type: 'keyword' },
url: { type: 'keyword' },
version: { type: 'version' },
ai_subtitle: {
type: 'semantic_text',
inference_id: inferenceEndpoint,
},
ai_subtitle: { type: 'text' },
ai_summary: {
type: 'semantic_text',
inference_id: inferenceEndpoint,

View file

@ -5,7 +5,34 @@
* 2.0.
*/
/**
* The allowed product names, as found in the source's cluster
*/
export const sourceProductNames = ['Kibana', 'Elasticsearch', 'Security', 'Observability'];
import type { ProductName } from '@kbn/product-doc-common';
const productNameToSourceNamesMap: Record<ProductName, string[]> = {
kibana: ['Kibana'],
elasticsearch: ['Elasticsearch'],
security: ['Security'],
observability: ['Observability'],
};
const sourceNameToProductName = Object.entries(productNameToSourceNamesMap).reduce<
Record<string, ProductName>
>((map, [productName, sourceNames]) => {
sourceNames.forEach((sourceName) => {
map[sourceName] = productName as ProductName;
});
return map;
}, {});
export const getSourceNamesFromProductName = (productName: ProductName): string[] => {
if (!productNameToSourceNamesMap[productName]) {
throw new Error(`Unknown product name: ${productName}`);
}
return productNameToSourceNamesMap[productName];
};
export const getProductNameFromSource = (source: string): ProductName => {
if (!sourceNameToProductName[source]) {
throw new Error(`Unknown source name: ${source}`);
}
return sourceNameToProductName[source];
};

View file

@ -8,6 +8,7 @@
import Path from 'path';
import { Client } from '@elastic/elasticsearch';
import { ToolingLog } from '@kbn/tooling-log';
import type { ProductName } from '@kbn/product-doc-common';
import {
// checkConnectivity,
createTargetIndex,
@ -18,6 +19,7 @@ import {
createArtifact,
cleanupFolders,
deleteIndex,
processDocuments,
} from './tasks';
import type { TaskConfig } from './types';
@ -93,7 +95,7 @@ const buildArtifact = async ({
sourceClient,
log,
}: {
productName: string;
productName: ProductName;
stackVersion: string;
buildFolder: string;
targetFolder: string;
@ -105,7 +107,7 @@ const buildArtifact = async ({
const targetIndex = getTargetIndexName({ productName, stackVersion });
const documents = await extractDocumentation({
let documents = await extractDocumentation({
client: sourceClient,
index: 'search-docs-1',
log,
@ -113,6 +115,8 @@ const buildArtifact = async ({
stackVersion,
});
documents = await processDocuments({ documents, log });
await createTargetIndex({
client: embeddingClient,
indexName: targetIndex,

View file

@ -6,19 +6,19 @@
*/
import Path from 'path';
import { REPO_ROOT } from '@kbn/repo-info';
import yargs from 'yargs';
import { REPO_ROOT } from '@kbn/repo-info';
import { DocumentationProduct } from '@kbn/product-doc-common';
import type { TaskConfig } from './types';
import { buildArtifacts } from './build_artifacts';
import { sourceProductNames } from './artifact/product_name';
function options(y: yargs.Argv) {
return y
.option('productName', {
describe: 'name of products to generate documentation for',
array: true,
choices: sourceProductNames,
default: ['Kibana'],
choices: Object.values(DocumentationProduct),
default: [DocumentationProduct.kibana],
})
.option('stackVersion', {
describe: 'The stack version to generate documentation for',

View file

@ -8,9 +8,9 @@
import Path from 'path';
import AdmZip from 'adm-zip';
import type { ToolingLog } from '@kbn/tooling-log';
import { getArtifactName, type ProductName } from '@kbn/product-doc-common';
import { getArtifactMappings } from '../artifact/mappings';
import { getArtifactManifest } from '../artifact/manifest';
import { getArtifactName } from '../artifact/artifact_name';
export const createArtifact = async ({
productName,
@ -21,7 +21,7 @@ export const createArtifact = async ({
}: {
buildFolder: string;
targetFolder: string;
productName: string;
productName: ProductName;
stackVersion: string;
log: ToolingLog;
}) => {

View file

@ -10,7 +10,7 @@ import Fs from 'fs/promises';
import type { Client } from '@elastic/elasticsearch';
import type { ToolingLog } from '@kbn/tooling-log';
const fileSizeLimit = 250_000;
const fileSizeLimit = 500_000;
export const createChunkFiles = async ({
index,

View file

@ -21,10 +21,7 @@ const mappings: MappingTypeMapping = {
slug: { type: 'keyword' },
url: { type: 'keyword' },
version: { type: 'version' },
ai_subtitle: {
type: 'semantic_text',
inference_id: 'kibana-elser2',
},
ai_subtitle: { type: 'text' },
ai_summary: {
type: 'semantic_text',
inference_id: 'kibana-elser2',

View file

@ -8,6 +8,8 @@
import type { Client } from '@elastic/elasticsearch';
import type { SearchHit } from '@elastic/elasticsearch/lib/api/types';
import type { ToolingLog } from '@kbn/tooling-log';
import type { ProductName } from '@kbn/product-doc-common';
import { getSourceNamesFromProductName, getProductNameFromSource } from '../artifact/product_name';
/** the list of fields to import from the source cluster */
const fields = [
@ -27,7 +29,7 @@ const fields = [
export interface ExtractedDocument {
content_title: string;
content_body: string;
product_name: string;
product_name: ProductName;
root_type: string;
slug: string;
url: string;
@ -43,7 +45,7 @@ const convertHit = (hit: SearchHit<any>): ExtractedDocument => {
return {
content_title: source.content_title,
content_body: source.content_body,
product_name: source.product_name,
product_name: getProductNameFromSource(source.product_name),
root_type: 'documentation',
slug: source.slug,
url: source.url,
@ -65,7 +67,7 @@ export const extractDocumentation = async ({
client: Client;
index: string;
stackVersion: string;
productName: string;
productName: ProductName;
log: ToolingLog;
}) => {
log.info(`Starting to extract documents from source cluster`);
@ -76,7 +78,7 @@ export const extractDocumentation = async ({
query: {
bool: {
must: [
{ term: { product_name: productName } },
{ terms: { product_name: getSourceNamesFromProductName(productName) } },
{ term: { version: stackVersion } },
{ exists: { field: 'ai_fields.ai_summary' } },
],

View file

@ -10,8 +10,8 @@ export { indexDocuments } from './index_documents';
export { createTargetIndex } from './create_index';
export { installElser } from './install_elser';
export { createChunkFiles } from './create_chunk_files';
export { performSemanticSearch } from './perform_semantic_search';
export { checkConnectivity } from './check_connectivity';
export { createArtifact } from './create_artifact';
export { cleanupFolders } from './cleanup_folders';
export { deleteIndex } from './delete_index';
export { processDocuments } from './process_documents';

View file

@ -0,0 +1,59 @@
/*
* 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 { uniqBy } from 'lodash';
import { encode } from 'gpt-tokenizer';
import type { ToolingLog } from '@kbn/tooling-log';
import type { ExtractedDocument } from './extract_documentation';
export const processDocuments = async ({
documents,
log,
}: {
documents: ExtractedDocument[];
log: ToolingLog;
}): Promise<ExtractedDocument[]> => {
log.info('Starting processing documents.');
const initialCount = documents.length;
documents = removeDuplicates(documents);
const noDupCount = documents.length;
log.info(`Removed ${initialCount - noDupCount} duplicates`);
documents.forEach(processDocument);
documents = filterEmptyDocs(documents);
log.info(`Removed ${noDupCount - documents.length} empty documents`);
log.info('Done processing documents.');
return documents;
};
const removeDuplicates = (documents: ExtractedDocument[]): ExtractedDocument[] => {
return uniqBy(documents, (doc) => doc.slug);
};
/**
* Filter "this content has moved" or "deleted pages" type of documents, just based on token count.
*/
const filterEmptyDocs = (documents: ExtractedDocument[]): ExtractedDocument[] => {
return documents.filter((doc) => {
const tokenCount = encode(doc.content_body).length;
if (tokenCount < 100) {
return false;
}
return true;
});
};
const processDocument = (document: ExtractedDocument) => {
document.content_body = document.content_body
// remove those "edit" button text that got embedded into titles.
.replaceAll(/([a-zA-Z])edit\n/g, (match) => {
return `${match[0]}\n`;
})
// limit to 2 consecutive carriage return
.replaceAll(/\n\n+/g, '\n\n');
return document;
};

View file

@ -5,8 +5,10 @@
* 2.0.
*/
import type { ProductName } from '@kbn/product-doc-common';
export interface TaskConfig {
productNames: string[];
productNames: ProductName[];
stackVersion: string;
buildFolder: string;
targetFolder: string;

View file

@ -16,5 +16,6 @@
"kbn_references": [
"@kbn/tooling-log",
"@kbn/repo-info",
"@kbn/product-doc-common",
]
}

View file

@ -0,0 +1,3 @@
# @kbn/product-doc-common
Common types and utilities for the product documentation feature.

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { getArtifactName, parseArtifactName } from './src/artifact';
export { type ArtifactManifest } from './src/manifest';
export { DocumentationProduct, type ProductName } from './src/product';
export { isArtifactContentFilePath } from './src/artifact_content';
export {
productDocIndexPrefix,
productDocIndexPattern,
getProductDocIndexName,
} from './src/indices';
export type { ProductDocumentationAttributes } from './src/documents';

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '../../../..',
roots: ['<rootDir>/x-pack/packages/ai-infra/product-doc-common'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/product-doc-common",
"owner": "@elastic/appex-ai-infra"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/product-doc-common",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0"
}

View file

@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { getArtifactName, parseArtifactName } from './artifact';
describe('getArtifactName', () => {
it('builds the name based on the provided product name and version', () => {
expect(
getArtifactName({
productName: 'kibana',
productVersion: '8.16',
})
).toEqual('kb-product-doc-kibana-8.16.zip');
});
it('excludes the extension when excludeExtension is true', () => {
expect(
getArtifactName({
productName: 'elasticsearch',
productVersion: '8.17',
excludeExtension: true,
})
).toEqual('kb-product-doc-elasticsearch-8.17');
});
it('generates a lowercase name', () => {
expect(
getArtifactName({
// @ts-expect-error testing
productName: 'ElasticSearch',
productVersion: '8.17',
excludeExtension: true,
})
).toEqual('kb-product-doc-elasticsearch-8.17');
});
});
describe('parseArtifactName', () => {
it('parses an artifact name with extension', () => {
expect(parseArtifactName('kb-product-doc-kibana-8.16.zip')).toEqual({
productName: 'kibana',
productVersion: '8.16',
});
});
it('parses an artifact name without extension', () => {
expect(parseArtifactName('kb-product-doc-security-8.17')).toEqual({
productName: 'security',
productVersion: '8.17',
});
});
it('returns undefined if the provided string does not match the artifact name pattern', () => {
expect(parseArtifactName('some-wrong-name')).toEqual(undefined);
});
it('returns undefined if the provided string is not strictly lowercase', () => {
expect(parseArtifactName('kb-product-doc-Security-8.17')).toEqual(undefined);
});
});

View file

@ -0,0 +1,39 @@
/*
* 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 ProductName, DocumentationProduct } from './product';
// kb-product-doc-elasticsearch-8.15.zip
const artifactNameRegexp = /^kb-product-doc-([a-z]+)-([0-9]+\.[0-9]+)(\.zip)?$/;
const allowedProductNames: ProductName[] = Object.values(DocumentationProduct);
export const getArtifactName = ({
productName,
productVersion,
excludeExtension = false,
}: {
productName: ProductName;
productVersion: string;
excludeExtension?: boolean;
}): string => {
const ext = excludeExtension ? '' : '.zip';
return `kb-product-doc-${productName}-${productVersion}${ext}`.toLowerCase();
};
export const parseArtifactName = (artifactName: string) => {
const match = artifactNameRegexp.exec(artifactName);
if (match) {
const productName = match[1].toLowerCase() as ProductName;
const productVersion = match[2].toLowerCase();
if (allowedProductNames.includes(productName)) {
return {
productName,
productVersion,
};
}
}
};

View file

@ -0,0 +1,23 @@
/*
* 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 { isArtifactContentFilePath } from './artifact_content';
describe('isArtifactContentFilePath', () => {
it('returns true for filenames matching the pattern', () => {
expect(isArtifactContentFilePath('content/content-0.ndjson')).toEqual(true);
expect(isArtifactContentFilePath('content/content-007.ndjson')).toEqual(true);
expect(isArtifactContentFilePath('content/content-9042.ndjson')).toEqual(true);
});
it('returns false for filenames not matching the pattern', () => {
expect(isArtifactContentFilePath('content-0.ndjson')).toEqual(false);
expect(isArtifactContentFilePath('content/content-0')).toEqual(false);
expect(isArtifactContentFilePath('content/content.ndjson')).toEqual(false);
expect(isArtifactContentFilePath('content/content-9042.json')).toEqual(false);
});
});

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
const contentFileRegexp = /^content\/content-[0-9]+\.ndjson$/;
export const isArtifactContentFilePath = (path: string): boolean => {
return contentFileRegexp.test(path);
};

View file

@ -0,0 +1,31 @@
/*
* 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 { ProductName } from './product';
// don't need to define the other props
interface SemanticTextField {
text: string;
}
interface SemanticTextArrayField {
text: string[];
}
export interface ProductDocumentationAttributes {
content_title: string;
content_body: SemanticTextField;
product_name: ProductName;
root_type: string;
slug: string;
url: string;
version: string;
ai_subtitle: string;
ai_summary: SemanticTextField;
ai_questions_answered: SemanticTextArrayField;
ai_tags: string[];
}

View file

@ -0,0 +1,15 @@
/*
* 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 { ProductName } from './product';
export const productDocIndexPrefix = '.kibana-ai-product-doc';
export const productDocIndexPattern = `${productDocIndexPrefix}-*`;
export const getProductDocIndexName = (productName: ProductName): string => {
return `${productDocIndexPrefix}-${productName.toLowerCase()}`;
};

View file

@ -5,12 +5,10 @@
* 2.0.
*/
export const getArtifactName = ({
productName,
productVersion,
}: {
productName: string;
import type { ProductName } from './product';
export interface ArtifactManifest {
formatVersion: string;
productName: ProductName;
productVersion: string;
}): string => {
return `kibana-kb-${productName}-${productVersion}.zip`.toLowerCase();
};
}

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export enum DocumentationProduct {
kibana = 'kibana',
elasticsearch = 'elasticsearch',
observability = 'observability',
security = 'security',
}
export type ProductName = keyof typeof DocumentationProduct;

View file

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

View file

@ -0,0 +1,45 @@
# LLM Tasks plugin
This plugin contains various LLM tasks.
## Retrieve documentation
This task allows to retrieve documents from our Elastic product documentation.
The task depends on the `product-doc-base` plugin, as this dependency is used
to install and manage the product documentation.
### Checking if the task is available
A `retrieveDocumentationAvailable` API is exposed from the start contract, that
should be used to assert that the `retrieve_doc` task can be used in the current
context.
That API receive the inbound request as parameter.
Example:
```ts
if (await llmTasksStart.retrieveDocumentationAvailable({ request })) {
// task is available
} else {
// task is not available
}
```
### Executing the task
The task is executed as an API of the plugin's start contract, and can be invoked
as any other lifecycle API would.
Example:
```ts
const result = await llmTasksStart.retrieveDocumentation({
searchTerm: "How to create a space in Kibana?",
request,
connectorId: 'my-connector-id',
});
const { success, documents } = result;
```
The exhaustive list of options for the task is available on the `RetrieveDocumentationParams` type's TS doc.

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../..',
roots: ['<rootDir>/x-pack/plugins/ai_infra/llm_tasks/server'],
setupFiles: [],
collectCoverage: true,
collectCoverageFrom: [
'<rootDir>/x-pack/plugins/ai_infra/llm_tasks/{public,server,common}/**/*.{js,ts,tsx}',
],
coverageReporters: ['html'],
};

View file

@ -0,0 +1,15 @@
{
"type": "plugin",
"id": "@kbn/llm-tasks-plugin",
"owner": "@elastic/appex-ai-infra",
"plugin": {
"id": "llmTasks",
"server": true,
"browser": false,
"configPath": ["xpack", "llmTasks"],
"requiredPlugins": ["inference", "productDocBase"],
"requiredBundles": [],
"optionalPlugins": [],
"extraPublicDirs": []
}
}

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { schema, type TypeOf } from '@kbn/config-schema';
import type { PluginConfigDescriptor } from '@kbn/core/server';
const configSchema = schema.object({});
export const config: PluginConfigDescriptor<LlmTasksConfig> = {
schema: configSchema,
exposeToBrowser: {},
};
export type LlmTasksConfig = TypeOf<typeof configSchema>;

View file

@ -0,0 +1,28 @@
/*
* 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 { PluginInitializer, PluginInitializerContext } from '@kbn/core/server';
import type { LlmTasksConfig } from './config';
import type {
LlmTasksPluginSetup,
LlmTasksPluginStart,
PluginSetupDependencies,
PluginStartDependencies,
} from './types';
import { LlmTasksPlugin } from './plugin';
export { config } from './config';
export type { LlmTasksPluginSetup, LlmTasksPluginStart };
export const plugin: PluginInitializer<
LlmTasksPluginSetup,
LlmTasksPluginStart,
PluginSetupDependencies,
PluginStartDependencies
> = async (pluginInitializerContext: PluginInitializerContext<LlmTasksConfig>) =>
new LlmTasksPlugin(pluginInitializerContext);

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Logger } from '@kbn/logging';
import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/server';
import type { LlmTasksConfig } from './config';
import type {
LlmTasksPluginSetup,
LlmTasksPluginStart,
PluginSetupDependencies,
PluginStartDependencies,
} from './types';
import { retrieveDocumentation } from './tasks';
export class LlmTasksPlugin
implements
Plugin<
LlmTasksPluginSetup,
LlmTasksPluginStart,
PluginSetupDependencies,
PluginStartDependencies
>
{
private logger: Logger;
constructor(context: PluginInitializerContext<LlmTasksConfig>) {
this.logger = context.logger.get();
}
setup(
coreSetup: CoreSetup<PluginStartDependencies, LlmTasksPluginStart>,
setupDependencies: PluginSetupDependencies
): LlmTasksPluginSetup {
return {};
}
start(core: CoreStart, startDependencies: PluginStartDependencies): LlmTasksPluginStart {
const { inference, productDocBase } = startDependencies;
return {
retrieveDocumentationAvailable: async () => {
const docBaseStatus = await startDependencies.productDocBase.management.getStatus();
return docBaseStatus.status === 'installed';
},
retrieveDocumentation: (options) => {
return retrieveDocumentation({
outputAPI: inference.getClient({ request: options.request }).output,
searchDocAPI: productDocBase.search,
logger: this.logger.get('tasks.retrieve-documentation'),
})(options);
},
};
}
}

View file

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

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { retrieveDocumentation } from './retrieve_documentation';
export type {
RetrieveDocumentationAPI,
RetrieveDocumentationResult,
RetrieveDocumentationParams,
} from './types';

View file

@ -0,0 +1,182 @@
/*
* 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 { httpServerMock } from '@kbn/core/server/mocks';
import { loggerMock, type MockedLogger } from '@kbn/logging-mocks';
import type { DocSearchResult } from '@kbn/product-doc-base-plugin/server/services/search';
import { retrieveDocumentation } from './retrieve_documentation';
import { truncate, count as countTokens } from '../../utils/tokens';
jest.mock('../../utils/tokens');
const truncateMock = truncate as jest.MockedFn<typeof truncate>;
const countTokensMock = countTokens as jest.MockedFn<typeof countTokens>;
import { summarizeDocument } from './summarize_document';
jest.mock('./summarize_document');
const summarizeDocumentMock = summarizeDocument as jest.MockedFn<typeof summarizeDocument>;
describe('retrieveDocumentation', () => {
let logger: MockedLogger;
let request: ReturnType<typeof httpServerMock.createKibanaRequest>;
let outputAPI: jest.Mock;
let searchDocAPI: jest.Mock;
let retrieve: ReturnType<typeof retrieveDocumentation>;
const createResult = (parts: Partial<DocSearchResult> = {}): DocSearchResult => {
return {
title: 'title',
content: 'content',
url: 'url',
productName: 'kibana',
...parts,
};
};
beforeEach(() => {
logger = loggerMock.create();
request = httpServerMock.createKibanaRequest();
outputAPI = jest.fn();
searchDocAPI = jest.fn();
retrieve = retrieveDocumentation({ logger, searchDocAPI, outputAPI });
});
afterEach(() => {
summarizeDocumentMock.mockReset();
truncateMock.mockReset();
countTokensMock.mockReset();
});
it('calls the search API with the right parameters', async () => {
searchDocAPI.mockResolvedValue({ results: [] });
const result = await retrieve({
searchTerm: 'What is Kibana?',
products: ['kibana'],
request,
max: 5,
connectorId: '.my-connector',
functionCalling: 'simulated',
});
expect(result).toEqual({
success: true,
documents: [],
});
expect(searchDocAPI).toHaveBeenCalledTimes(1);
expect(searchDocAPI).toHaveBeenCalledWith({
query: 'What is Kibana?',
products: ['kibana'],
max: 5,
});
});
it('reduces the document length using the truncate strategy', async () => {
searchDocAPI.mockResolvedValue({
results: [
createResult({ content: 'content-1' }),
createResult({ content: 'content-2' }),
createResult({ content: 'content-3' }),
],
});
countTokensMock.mockImplementation((text) => {
if (text === 'content-2') {
return 150;
} else {
return 50;
}
});
truncateMock.mockReturnValue('truncated');
const result = await retrieve({
searchTerm: 'What is Kibana?',
request,
connectorId: '.my-connector',
maxDocumentTokens: 100,
tokenReductionStrategy: 'truncate',
});
expect(result.documents.length).toEqual(3);
expect(result.documents[0].content).toEqual('content-1');
expect(result.documents[1].content).toEqual('truncated');
expect(result.documents[2].content).toEqual('content-3');
expect(truncateMock).toHaveBeenCalledTimes(1);
expect(truncateMock).toHaveBeenCalledWith('content-2', 100);
});
it('reduces the document length using the summarize strategy', async () => {
searchDocAPI.mockResolvedValue({
results: [
createResult({ content: 'content-1' }),
createResult({ content: 'content-2' }),
createResult({ content: 'content-3' }),
],
});
countTokensMock.mockImplementation((text) => {
if (text === 'content-2') {
return 50;
} else {
return 150;
}
});
truncateMock.mockImplementation((text) => text);
summarizeDocumentMock.mockImplementation(({ documentContent }) => {
return Promise.resolve({ summary: `${documentContent}-summarized` });
});
const result = await retrieve({
searchTerm: 'What is Kibana?',
request,
connectorId: '.my-connector',
maxDocumentTokens: 100,
tokenReductionStrategy: 'summarize',
});
expect(result.documents.length).toEqual(3);
expect(result.documents[0].content).toEqual('content-1-summarized');
expect(result.documents[1].content).toEqual('content-2');
expect(result.documents[2].content).toEqual('content-3-summarized');
expect(truncateMock).toHaveBeenCalledTimes(2);
expect(truncateMock).toHaveBeenCalledWith('content-1-summarized', 100);
expect(truncateMock).toHaveBeenCalledWith('content-3-summarized', 100);
});
it('logs an error and return an empty list of docs in case of error', async () => {
searchDocAPI.mockResolvedValue({
results: [createResult({ content: 'content-1' })],
});
countTokensMock.mockImplementation(() => {
return 150;
});
summarizeDocumentMock.mockImplementation(() => {
throw new Error('woups');
});
const result = await retrieve({
searchTerm: 'What is Kibana?',
request,
connectorId: '.my-connector',
maxDocumentTokens: 100,
tokenReductionStrategy: 'summarize',
});
expect(result).toEqual({
success: false,
documents: [],
});
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('Error retrieving documentation')
);
});
});

View file

@ -0,0 +1,88 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Logger } from '@kbn/logging';
import type { OutputAPI } from '@kbn/inference-common';
import type { ProductDocSearchAPI } from '@kbn/product-doc-base-plugin/server';
import { truncate, count as countTokens } from '../../utils/tokens';
import type { RetrieveDocumentationAPI } from './types';
import { summarizeDocument } from './summarize_document';
const MAX_DOCUMENTS_DEFAULT = 3;
const MAX_TOKENS_DEFAULT = 1000;
export const retrieveDocumentation =
({
outputAPI,
searchDocAPI,
logger: log,
}: {
outputAPI: OutputAPI;
searchDocAPI: ProductDocSearchAPI;
logger: Logger;
}): RetrieveDocumentationAPI =>
async ({
searchTerm,
connectorId,
products,
functionCalling,
max = MAX_DOCUMENTS_DEFAULT,
maxDocumentTokens = MAX_TOKENS_DEFAULT,
tokenReductionStrategy = 'summarize',
}) => {
try {
const { results } = await searchDocAPI({ query: searchTerm, products, max });
log.debug(`searching with term=[${searchTerm}] returned ${results.length} documents`);
const processedDocuments = await Promise.all(
results.map(async (document) => {
const tokenCount = countTokens(document.content);
const docHasTooManyTokens = tokenCount >= maxDocumentTokens;
log.debug(
`processing doc [${document.url}] - tokens : [${tokenCount}] - tooManyTokens: [${docHasTooManyTokens}]`
);
let content = document.content;
if (docHasTooManyTokens) {
if (tokenReductionStrategy === 'summarize') {
const extractResponse = await summarizeDocument({
searchTerm,
documentContent: document.content,
outputAPI,
connectorId,
functionCalling,
});
content = truncate(extractResponse.summary, maxDocumentTokens);
} else {
content = truncate(document.content, maxDocumentTokens);
}
}
log.debug(`done processing document [${document.url}]`);
return {
title: document.title,
url: document.url,
content,
};
})
);
log.debug(() => {
const docsAsJson = JSON.stringify(processedDocuments);
return `searching with term=[${searchTerm}] - results: ${docsAsJson}`;
});
return {
success: true,
documents: processedDocuments.filter((doc) => doc.content.length > 0),
};
} catch (e) {
log.error(`Error retrieving documentation: ${e.message}. Returning empty results.`);
return { success: false, documents: [] };
}
};

View file

@ -0,0 +1,67 @@
/*
* 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 { ToolSchema, FunctionCallingMode, OutputAPI } from '@kbn/inference-common';
const summarizeDocumentSchema = {
type: 'object',
properties: {
useful: {
type: 'boolean',
description: `Whether the provided document has any useful information related to the user's query.`,
},
summary: {
type: 'string',
description: `The condensed version of the document that can be used to answer the question. Can be empty.`,
},
},
required: ['useful'],
} as const satisfies ToolSchema;
interface SummarizeDocumentResponse {
summary: string;
}
export const summarizeDocument = async ({
searchTerm,
documentContent,
connectorId,
outputAPI,
functionCalling,
}: {
searchTerm: string;
documentContent: string;
outputAPI: OutputAPI;
connectorId: string;
functionCalling?: FunctionCallingMode;
}): Promise<SummarizeDocumentResponse> => {
const result = await outputAPI({
id: 'summarize_document',
connectorId,
functionCalling,
system: `You are an helpful Elastic assistant, and your current task is to help answer the user's question.
Given a question and a document, please provide a condensed version of the document that can be used to answer the question.
- Limit the length of the output to 500 words.
- Try to include all relevant information that could be used to answer the question. If this
can't be done within the 500 words limit, then only include the most relevant information related to the question.
- If you think the document isn't relevant at all to answer the question, just return an empty text`,
input: `
## User question
${searchTerm}
## Document
${documentContent}
`,
schema: summarizeDocumentSchema,
});
return {
summary: result.output.summary ?? '',
};
};

View file

@ -0,0 +1,72 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { KibanaRequest } from '@kbn/core/server';
import type { FunctionCallingMode } from '@kbn/inference-common';
import type { ProductName } from '@kbn/product-doc-common';
/**
* Parameters for {@link RetrieveDocumentationAPI}
*/
export interface RetrieveDocumentationParams {
/**
* The search term to perform semantic text with.
* E.g. "What is Kibana Lens?"
*/
searchTerm: string;
/**
* Maximum number of documents to return.
* Defaults to 3.
*/
max?: number;
/**
* Optional list of products to restrict the search to.
*/
products?: ProductName[];
/**
* The maximum number of tokens to return *per document*.
* Documents exceeding this limit will go through token reduction.
*
* Defaults to `1000`.
*/
maxDocumentTokens?: number;
/**
* The token reduction strategy to apply for documents exceeding max token count.
* - truncate: Will keep the N first tokens
* - summarize: Will call the LLM asking to generate a contextualized summary of the document
*
* Overall, `summarize` is way more efficient, but significantly slower, given that an additional
* LLM call will be performed.
*
* Defaults to `summarize`
*/
tokenReductionStrategy?: 'truncate' | 'summarize';
/**
* The request that initiated the task.
*/
request: KibanaRequest;
/**
* Id of the LLM connector to use for the task.
*/
connectorId: string;
functionCalling?: FunctionCallingMode;
}
export interface RetrievedDocument {
title: string;
url: string;
content: string;
}
export interface RetrieveDocumentationResult {
success: boolean;
documents: RetrievedDocument[];
}
export type RetrieveDocumentationAPI = (
options: RetrieveDocumentationParams
) => Promise<RetrieveDocumentationResult>;

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { InferenceServerStart } from '@kbn/inference-plugin/server';
import type { ProductDocBaseStartContract } from '@kbn/product-doc-base-plugin/server';
import type { RetrieveDocumentationAPI } from './tasks/retrieve_documentation';
/* eslint-disable @typescript-eslint/no-empty-interface*/
export interface PluginSetupDependencies {}
export interface PluginStartDependencies {
inference: InferenceServerStart;
productDocBase: ProductDocBaseStartContract;
}
/**
* Describes public llmTasks plugin contract returned at the `setup` stage.
*/
export interface LlmTasksPluginSetup {}
/**
* Describes public llmTasks plugin contract returned at the `start` stage.
*/
export interface LlmTasksPluginStart {
/**
* Checks if all prerequisites to use the `retrieveDocumentation` task
* are respected. Can be used to check if the task can be registered
* as LLM tool for example.
*/
retrieveDocumentationAvailable: () => Promise<boolean>;
/**
* Perform the `retrieveDocumentation` task.
*
* @see RetrieveDocumentationAPI
*/
retrieveDocumentation: RetrieveDocumentationAPI;
}

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { count, truncate } from './tokens';
describe('count', () => {
it('returns the token count of a given text', () => {
expect(count('some short sentence')).toBeGreaterThan(1);
});
});
describe('truncate', () => {
it('truncates text that exceed the specified maximum token count', () => {
const text = 'some sentence that is likely longer than 5 tokens.';
const output = truncate(text, 5);
expect(output.length).toBeLessThan(text.length);
});
it('keeps text with a smaller amount of tokens unchanged', () => {
const text = 'some sentence that is likely less than 100 tokens.';
const output = truncate(text, 100);
expect(output.length).toEqual(text.length);
});
});

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { encode, decode } from 'gpt-tokenizer';
export const count = (text: string): number => {
return encode(text).length;
};
export const truncate = (text: string, maxTokens: number): string => {
const encoded = encode(text);
if (encoded.length > maxTokens) {
const truncated = encoded.slice(0, maxTokens);
return decode(truncated);
}
return text;
};

View file

@ -0,0 +1,27 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types"
},
"include": [
"../../../../typings/**/*",
"common/**/*",
"public/**/*",
"typings/**/*",
"public/**/*.json",
"server/**/*",
"scripts/**/*",
".storybook/**/*"
],
"exclude": ["target/**/*", ".storybook/**/*.js"],
"kbn_references": [
"@kbn/core",
"@kbn/logging",
"@kbn/config-schema",
"@kbn/product-doc-common",
"@kbn/inference-plugin",
"@kbn/product-doc-base-plugin",
"@kbn/logging-mocks",
"@kbn/inference-common",
]
}

View file

@ -0,0 +1,3 @@
# Product documentation base plugin
This plugin contains the product documentation base service.

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const productDocInstallStatusSavedObjectTypeName = 'product-doc-install-status';
/**
* The id of the inference endpoint we're creating for our product doc indices.
* Could be replaced with the default elser 2 endpoint once the default endpoint feature is available.
*/
export const internalElserInferenceId = 'kibana-internal-elser2';

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ProductName } from '@kbn/product-doc-common';
import type { ProductInstallState, InstallationStatus } from '../install_status';
export const INSTALLATION_STATUS_API_PATH = '/internal/product_doc_base/status';
export const INSTALL_ALL_API_PATH = '/internal/product_doc_base/install';
export const UNINSTALL_ALL_API_PATH = '/internal/product_doc_base/uninstall';
export interface InstallationStatusResponse {
overall: InstallationStatus;
perProducts: Record<ProductName, ProductInstallState>;
}
export interface PerformInstallResponse {
installed: boolean;
}
export interface UninstallResponse {
success: boolean;
}

View file

@ -0,0 +1,28 @@
/*
* 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 { ProductName } from '@kbn/product-doc-common';
export type InstallationStatus = 'installed' | 'uninstalled' | 'installing' | 'error';
/**
* DTO representation of the product doc install status SO
*/
export interface ProductDocInstallStatus {
id: string;
productName: ProductName;
productVersion: string;
installationStatus: InstallationStatus;
lastInstallationDate: Date | undefined;
lastInstallationFailureReason: string | undefined;
indexName?: string;
}
export interface ProductInstallState {
status: InstallationStatus;
version?: string;
}

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../..',
roots: [
'<rootDir>/x-pack/plugins/ai_infra/product_doc_base/public',
'<rootDir>/x-pack/plugins/ai_infra/product_doc_base/server',
'<rootDir>/x-pack/plugins/ai_infra/product_doc_base/common',
],
setupFiles: [],
collectCoverage: true,
collectCoverageFrom: [
'<rootDir>/x-pack/plugins/ai_infra/product_doc_base/{public,server,common}/**/*.{js,ts,tsx}',
],
coverageReporters: ['html'],
};

View file

@ -0,0 +1,15 @@
{
"type": "plugin",
"id": "@kbn/product-doc-base-plugin",
"owner": "@elastic/appex-ai-infra",
"plugin": {
"id": "productDocBase",
"server": true,
"browser": true,
"configPath": ["xpack", "productDocBase"],
"requiredPlugins": ["licensing", "taskManager"],
"requiredBundles": [],
"optionalPlugins": [],
"extraPublicDirs": []
}
}

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { PluginInitializer, PluginInitializerContext } from '@kbn/core/public';
import { ProductDocBasePlugin } from './plugin';
import type {
ProductDocBasePluginSetup,
ProductDocBasePluginStart,
PluginSetupDependencies,
PluginStartDependencies,
PublicPluginConfig,
} from './types';
export type { ProductDocBasePluginSetup, ProductDocBasePluginStart };
export const plugin: PluginInitializer<
ProductDocBasePluginSetup,
ProductDocBasePluginStart,
PluginSetupDependencies,
PluginStartDependencies
> = (pluginInitializerContext: PluginInitializerContext<PublicPluginConfig>) =>
new ProductDocBasePlugin(pluginInitializerContext);

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public';
import type { Logger } from '@kbn/logging';
import type {
PublicPluginConfig,
ProductDocBasePluginSetup,
ProductDocBasePluginStart,
PluginSetupDependencies,
PluginStartDependencies,
} from './types';
import { InstallationService } from './services/installation';
export class ProductDocBasePlugin
implements
Plugin<
ProductDocBasePluginSetup,
ProductDocBasePluginStart,
PluginSetupDependencies,
PluginStartDependencies
>
{
logger: Logger;
constructor(context: PluginInitializerContext<PublicPluginConfig>) {
this.logger = context.logger.get();
}
setup(
coreSetup: CoreSetup<PluginStartDependencies, ProductDocBasePluginStart>,
pluginsSetup: PluginSetupDependencies
): ProductDocBasePluginSetup {
return {};
}
start(coreStart: CoreStart, pluginsStart: PluginStartDependencies): ProductDocBasePluginStart {
const installationService = new InstallationService({ http: coreStart.http });
return {
installation: {
getStatus: () => installationService.getInstallationStatus(),
install: () => installationService.install(),
uninstall: () => installationService.uninstall(),
},
};
}
}

View file

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

View file

@ -0,0 +1,79 @@
/*
* 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 { httpServiceMock } from '@kbn/core/public/mocks';
import { InstallationService } from './installation_service';
import {
INSTALLATION_STATUS_API_PATH,
INSTALL_ALL_API_PATH,
UNINSTALL_ALL_API_PATH,
} from '../../../common/http_api/installation';
describe('InstallationService', () => {
let http: ReturnType<typeof httpServiceMock.createSetupContract>;
let service: InstallationService;
beforeEach(() => {
http = httpServiceMock.createSetupContract();
service = new InstallationService({ http });
});
describe('#getInstallationStatus', () => {
it('calls the endpoint with the right parameters', async () => {
await service.getInstallationStatus();
expect(http.get).toHaveBeenCalledTimes(1);
expect(http.get).toHaveBeenCalledWith(INSTALLATION_STATUS_API_PATH);
});
it('returns the value from the server', async () => {
const expected = { stubbed: true };
http.get.mockResolvedValue(expected);
const response = await service.getInstallationStatus();
expect(response).toEqual(expected);
});
});
describe('#install', () => {
beforeEach(() => {
http.post.mockResolvedValue({ installed: true });
});
it('calls the endpoint with the right parameters', async () => {
await service.install();
expect(http.post).toHaveBeenCalledTimes(1);
expect(http.post).toHaveBeenCalledWith(INSTALL_ALL_API_PATH);
});
it('returns the value from the server', async () => {
const expected = { installed: true };
http.post.mockResolvedValue(expected);
const response = await service.install();
expect(response).toEqual(expected);
});
it('throws when the server returns installed: false', async () => {
const expected = { installed: false };
http.post.mockResolvedValue(expected);
await expect(service.install()).rejects.toThrowErrorMatchingInlineSnapshot(
`"Installation did not complete successfully"`
);
});
});
describe('#uninstall', () => {
it('calls the endpoint with the right parameters', async () => {
await service.uninstall();
expect(http.post).toHaveBeenCalledTimes(1);
expect(http.post).toHaveBeenCalledWith(UNINSTALL_ALL_API_PATH);
});
it('returns the value from the server', async () => {
const expected = { stubbed: true };
http.post.mockResolvedValue(expected);
const response = await service.uninstall();
expect(response).toEqual(expected);
});
});
});

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { HttpSetup } from '@kbn/core-http-browser';
import {
INSTALLATION_STATUS_API_PATH,
INSTALL_ALL_API_PATH,
UNINSTALL_ALL_API_PATH,
InstallationStatusResponse,
PerformInstallResponse,
UninstallResponse,
} from '../../../common/http_api/installation';
export class InstallationService {
private readonly http: HttpSetup;
constructor({ http }: { http: HttpSetup }) {
this.http = http;
}
async getInstallationStatus(): Promise<InstallationStatusResponse> {
return await this.http.get<InstallationStatusResponse>(INSTALLATION_STATUS_API_PATH);
}
async install(): Promise<PerformInstallResponse> {
const response = await this.http.post<PerformInstallResponse>(INSTALL_ALL_API_PATH);
if (!response.installed) {
throw new Error('Installation did not complete successfully');
}
return response;
}
async uninstall(): Promise<UninstallResponse> {
return await this.http.post<UninstallResponse>(UNINSTALL_ALL_API_PATH);
}
}

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type {
InstallationStatusResponse,
PerformInstallResponse,
UninstallResponse,
} from '../../../common/http_api/installation';
export interface InstallationAPI {
getStatus(): Promise<InstallationStatusResponse>;
install(): Promise<PerformInstallResponse>;
uninstall(): Promise<UninstallResponse>;
}

View file

@ -0,0 +1,22 @@
/*
* 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 { InstallationAPI } from './services/installation';
/* eslint-disable @typescript-eslint/no-empty-interface*/
export interface PublicPluginConfig {}
export interface PluginSetupDependencies {}
export interface PluginStartDependencies {}
export interface ProductDocBasePluginSetup {}
export interface ProductDocBasePluginStart {
installation: InstallationAPI;
}

View file

@ -0,0 +1,22 @@
/*
* 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 { schema, type TypeOf } from '@kbn/config-schema';
import type { PluginConfigDescriptor } from '@kbn/core/server';
const configSchema = schema.object({
artifactRepositoryUrl: schema.string({
defaultValue: 'https://kibana-knowledge-base-artifacts.elastic.co',
}),
});
export const config: PluginConfigDescriptor<ProductDocBaseConfig> = {
schema: configSchema,
exposeToBrowser: {},
};
export type ProductDocBaseConfig = TypeOf<typeof configSchema>;

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { PluginInitializer, PluginInitializerContext } from '@kbn/core/server';
import type { ProductDocBaseConfig } from './config';
import type {
ProductDocBaseSetupContract,
ProductDocBaseStartContract,
ProductDocBaseSetupDependencies,
ProductDocBaseStartDependencies,
} from './types';
import { ProductDocBasePlugin } from './plugin';
export { config } from './config';
export type { ProductDocBaseSetupContract, ProductDocBaseStartContract };
export type { SearchApi as ProductDocSearchAPI } from './services/search/types';
export const plugin: PluginInitializer<
ProductDocBaseSetupContract,
ProductDocBaseStartContract,
ProductDocBaseSetupDependencies,
ProductDocBaseStartDependencies
> = async (pluginInitializerContext: PluginInitializerContext<ProductDocBaseConfig>) =>
new ProductDocBasePlugin(pluginInitializerContext);

View file

@ -0,0 +1,96 @@
/*
* 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 { coreMock } from '@kbn/core/server/mocks';
import { licensingMock } from '@kbn/licensing-plugin/server/mocks';
import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks';
import { productDocInstallStatusSavedObjectTypeName } from '../common/consts';
import { ProductDocBasePlugin } from './plugin';
import { ProductDocBaseSetupDependencies, ProductDocBaseStartDependencies } from './types';
jest.mock('./services/package_installer');
jest.mock('./services/search');
jest.mock('./services/doc_install_status');
jest.mock('./routes');
jest.mock('./tasks');
import { registerRoutes } from './routes';
import { PackageInstaller } from './services/package_installer';
import { registerTaskDefinitions, scheduleEnsureUpToDateTask } from './tasks';
const PackageInstallMock = PackageInstaller as jest.Mock;
describe('ProductDocBasePlugin', () => {
let initContext: ReturnType<typeof coreMock.createPluginInitializerContext>;
let plugin: ProductDocBasePlugin;
let pluginSetupDeps: ProductDocBaseSetupDependencies;
let pluginStartDeps: ProductDocBaseStartDependencies;
beforeEach(() => {
initContext = coreMock.createPluginInitializerContext();
plugin = new ProductDocBasePlugin(initContext);
pluginSetupDeps = {
taskManager: taskManagerMock.createSetup(),
};
pluginStartDeps = {
licensing: licensingMock.createStart(),
taskManager: taskManagerMock.createStart(),
};
PackageInstallMock.mockReturnValue({ ensureUpToDate: jest.fn().mockResolvedValue({}) });
});
afterEach(() => {
(scheduleEnsureUpToDateTask as jest.Mock).mockReset();
});
describe('#setup', () => {
it('register the routes', () => {
plugin.setup(coreMock.createSetup(), pluginSetupDeps);
expect(registerRoutes).toHaveBeenCalledTimes(1);
});
it('register the product-doc SO type', () => {
const coreSetup = coreMock.createSetup();
plugin.setup(coreSetup, pluginSetupDeps);
expect(coreSetup.savedObjects.registerType).toHaveBeenCalledTimes(1);
expect(coreSetup.savedObjects.registerType).toHaveBeenCalledWith(
expect.objectContaining({
name: productDocInstallStatusSavedObjectTypeName,
})
);
});
it('register the task definitions', () => {
plugin.setup(coreMock.createSetup(), pluginSetupDeps);
expect(registerTaskDefinitions).toHaveBeenCalledTimes(3);
});
});
describe('#start', () => {
it('returns a contract with the expected shape', () => {
plugin.setup(coreMock.createSetup(), pluginSetupDeps);
const startContract = plugin.start(coreMock.createStart(), pluginStartDeps);
expect(startContract).toEqual({
management: {
getStatus: expect.any(Function),
install: expect.any(Function),
uninstall: expect.any(Function),
update: expect.any(Function),
},
search: expect.any(Function),
});
});
it('schedules the update task', () => {
plugin.setup(coreMock.createSetup(), pluginSetupDeps);
plugin.start(coreMock.createStart(), pluginStartDeps);
expect(scheduleEnsureUpToDateTask).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -0,0 +1,133 @@
/*
* 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 Path from 'path';
import type { Logger } from '@kbn/logging';
import { getDataPath } from '@kbn/utils';
import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/server';
import { SavedObjectsClient } from '@kbn/core/server';
import { productDocInstallStatusSavedObjectTypeName } from '../common/consts';
import type { ProductDocBaseConfig } from './config';
import {
ProductDocBaseSetupContract,
ProductDocBaseStartContract,
ProductDocBaseSetupDependencies,
ProductDocBaseStartDependencies,
InternalServices,
} from './types';
import { productDocInstallStatusSavedObjectType } from './saved_objects';
import { PackageInstaller } from './services/package_installer';
import { InferenceEndpointManager } from './services/inference_endpoint';
import { ProductDocInstallClient } from './services/doc_install_status';
import { DocumentationManager } from './services/doc_manager';
import { SearchService } from './services/search';
import { registerRoutes } from './routes';
import { registerTaskDefinitions } from './tasks';
export class ProductDocBasePlugin
implements
Plugin<
ProductDocBaseSetupContract,
ProductDocBaseStartContract,
ProductDocBaseSetupDependencies,
ProductDocBaseStartDependencies
>
{
private logger: Logger;
private internalServices?: InternalServices;
constructor(private readonly context: PluginInitializerContext<ProductDocBaseConfig>) {
this.logger = context.logger.get();
}
setup(
coreSetup: CoreSetup<ProductDocBaseStartDependencies, ProductDocBaseStartContract>,
{ taskManager }: ProductDocBaseSetupDependencies
): ProductDocBaseSetupContract {
const getServices = () => {
if (!this.internalServices) {
throw new Error('getServices called before #start');
}
return this.internalServices;
};
coreSetup.savedObjects.registerType(productDocInstallStatusSavedObjectType);
registerTaskDefinitions({
taskManager,
getServices,
});
const router = coreSetup.http.createRouter();
registerRoutes({
router,
getServices,
});
return {};
}
start(
core: CoreStart,
{ licensing, taskManager }: ProductDocBaseStartDependencies
): ProductDocBaseStartContract {
const soClient = new SavedObjectsClient(
core.savedObjects.createInternalRepository([productDocInstallStatusSavedObjectTypeName])
);
const productDocClient = new ProductDocInstallClient({ soClient });
const endpointManager = new InferenceEndpointManager({
esClient: core.elasticsearch.client.asInternalUser,
logger: this.logger.get('endpoint-manager'),
});
const packageInstaller = new PackageInstaller({
esClient: core.elasticsearch.client.asInternalUser,
productDocClient,
endpointManager,
kibanaVersion: this.context.env.packageInfo.version,
artifactsFolder: Path.join(getDataPath(), 'ai-kb-artifacts'),
artifactRepositoryUrl: this.context.config.get().artifactRepositoryUrl,
logger: this.logger.get('package-installer'),
});
const searchService = new SearchService({
esClient: core.elasticsearch.client.asInternalUser,
logger: this.logger.get('search-service'),
});
const documentationManager = new DocumentationManager({
logger: this.logger.get('doc-manager'),
docInstallClient: productDocClient,
licensing,
taskManager,
auditService: core.security.audit,
});
this.internalServices = {
logger: this.logger,
packageInstaller,
installClient: productDocClient,
documentationManager,
licensing,
taskManager,
};
documentationManager.update().catch((err) => {
this.logger.error(`Error scheduling product documentation update task: ${err.message}`);
});
return {
management: {
install: documentationManager.install.bind(documentationManager),
update: documentationManager.update.bind(documentationManager),
uninstall: documentationManager.uninstall.bind(documentationManager),
getStatus: documentationManager.getStatus.bind(documentationManager),
},
search: searchService.search.bind(searchService),
};
}
}

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { IRouter } from '@kbn/core/server';
import { registerInstallationRoutes } from './installation';
import type { InternalServices } from '../types';
export const registerRoutes = ({
router,
getServices,
}: {
router: IRouter;
getServices: () => InternalServices;
}) => {
registerInstallationRoutes({ getServices, router });
};

View file

@ -0,0 +1,115 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { IRouter } from '@kbn/core/server';
import {
INSTALLATION_STATUS_API_PATH,
INSTALL_ALL_API_PATH,
UNINSTALL_ALL_API_PATH,
InstallationStatusResponse,
PerformInstallResponse,
UninstallResponse,
} from '../../common/http_api/installation';
import type { InternalServices } from '../types';
export const registerInstallationRoutes = ({
router,
getServices,
}: {
router: IRouter;
getServices: () => InternalServices;
}) => {
router.get(
{
path: INSTALLATION_STATUS_API_PATH,
validate: false,
options: {
access: 'internal',
security: {
authz: {
requiredPrivileges: ['manage_llm_product_doc'],
},
},
},
},
async (ctx, req, res) => {
const { installClient, documentationManager } = getServices();
const installStatus = await installClient.getInstallationStatus();
const { status: overallStatus } = await documentationManager.getStatus();
return res.ok<InstallationStatusResponse>({
body: {
perProducts: installStatus,
overall: overallStatus,
},
});
}
);
router.post(
{
path: INSTALL_ALL_API_PATH,
validate: false,
options: {
access: 'internal',
security: {
authz: {
requiredPrivileges: ['manage_llm_product_doc'],
},
},
timeout: { idleSocket: 20 * 60 * 1000 }, // install can take time.
},
},
async (ctx, req, res) => {
const { documentationManager } = getServices();
await documentationManager.install({
request: req,
force: false,
wait: true,
});
// check status after installation in case of failure
const { status } = await documentationManager.getStatus();
return res.ok<PerformInstallResponse>({
body: {
installed: status === 'installed',
},
});
}
);
router.post(
{
path: UNINSTALL_ALL_API_PATH,
validate: false,
options: {
access: 'internal',
security: {
authz: {
requiredPrivileges: ['manage_llm_product_doc'],
},
},
},
},
async (ctx, req, res) => {
const { documentationManager } = getServices();
await documentationManager.uninstall({
request: req,
wait: true,
});
return res.ok<UninstallResponse>({
body: {
success: true,
},
});
}
);
};

View file

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

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { SavedObjectsType } from '@kbn/core/server';
import type { ProductName } from '@kbn/product-doc-common';
import { productDocInstallStatusSavedObjectTypeName } from '../../common/consts';
import type { InstallationStatus } from '../../common/install_status';
/**
* Interface describing the raw attributes of the product doc install SO type.
* Contains more fields than the mappings, which only list
* indexed fields.
*/
export interface ProductDocInstallStatusAttributes {
product_name: ProductName;
product_version: string;
installation_status: InstallationStatus;
last_installation_date?: number;
last_installation_failure_reason?: string;
index_name?: string;
}
export const productDocInstallStatusSavedObjectType: SavedObjectsType<ProductDocInstallStatusAttributes> =
{
name: productDocInstallStatusSavedObjectTypeName,
hidden: true,
namespaceType: 'agnostic',
mappings: {
dynamic: false,
properties: {
product_name: { type: 'keyword' },
product_version: { type: 'keyword' },
installation_status: { type: 'keyword' },
last_installation_date: { type: 'date' },
index_name: { type: 'keyword' },
},
},
management: {
importableAndExportable: false,
},
modelVersions: {},
};

View file

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

View file

@ -0,0 +1,44 @@
/*
* 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 { SavedObject } from '@kbn/core/server';
import type { ProductDocInstallStatusAttributes } from '../../saved_objects';
import { soToModel } from './model_conversion';
const createObj = (
attrs: ProductDocInstallStatusAttributes
): SavedObject<ProductDocInstallStatusAttributes> => {
return {
id: 'some-id',
type: 'product-doc-install-status',
attributes: attrs,
references: [],
};
};
describe('soToModel', () => {
it('converts the SO to the expected shape', () => {
const input = createObj({
product_name: 'kibana',
product_version: '8.16',
installation_status: 'installed',
last_installation_date: 9000,
index_name: '.kibana',
});
const output = soToModel(input);
expect(output).toEqual({
id: 'some-id',
productName: 'kibana',
productVersion: '8.16',
indexName: '.kibana',
installationStatus: 'installed',
lastInstallationDate: expect.any(Date),
});
});
});

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { SavedObject } from '@kbn/core/server';
import type { ProductDocInstallStatus } from '../../../common/install_status';
import type { ProductDocInstallStatusAttributes } from '../../saved_objects';
export const soToModel = (
so: SavedObject<ProductDocInstallStatusAttributes>
): ProductDocInstallStatus => {
return {
id: so.id,
productName: so.attributes.product_name,
productVersion: so.attributes.product_version,
installationStatus: so.attributes.installation_status,
indexName: so.attributes.index_name,
lastInstallationDate: so.attributes.last_installation_date
? new Date(so.attributes.last_installation_date)
: undefined,
lastInstallationFailureReason: so.attributes.last_installation_failure_reason,
};
};

View file

@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { SavedObjectsFindResult } from '@kbn/core/server';
import { DocumentationProduct } from '@kbn/product-doc-common';
import type { ProductDocInstallStatusAttributes as TypeAttributes } from '../../saved_objects';
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
import { ProductDocInstallClient } from './product_doc_install_service';
const createObj = (attrs: TypeAttributes): SavedObjectsFindResult<TypeAttributes> => {
return {
id: attrs.product_name,
type: 'type',
references: [],
attributes: attrs,
score: 42,
};
};
describe('ProductDocInstallClient', () => {
let soClient: ReturnType<typeof savedObjectsClientMock.create>;
let service: ProductDocInstallClient;
beforeEach(() => {
soClient = savedObjectsClientMock.create();
service = new ProductDocInstallClient({ soClient });
});
describe('getInstallationStatus', () => {
it('returns the installation status based on existing entries', async () => {
soClient.find.mockResolvedValue({
saved_objects: [
createObj({
product_name: 'kibana',
product_version: '8.15',
installation_status: 'installed',
}),
createObj({
product_name: 'elasticsearch',
product_version: '8.15',
installation_status: 'installing',
}),
],
total: 2,
per_page: 100,
page: 1,
});
const installStatus = await service.getInstallationStatus();
expect(Object.keys(installStatus).sort()).toEqual(Object.keys(DocumentationProduct).sort());
expect(installStatus.kibana).toEqual({
status: 'installed',
version: '8.15',
});
expect(installStatus.security).toEqual({
status: 'uninstalled',
});
});
});
});

View file

@ -0,0 +1,89 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { SavedObjectsClientContract } from '@kbn/core/server';
import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server';
import { ProductName, DocumentationProduct } from '@kbn/product-doc-common';
import type { ProductInstallState } from '../../../common/install_status';
import { productDocInstallStatusSavedObjectTypeName as typeName } from '../../../common/consts';
import type { ProductDocInstallStatusAttributes as TypeAttributes } from '../../saved_objects';
export class ProductDocInstallClient {
private soClient: SavedObjectsClientContract;
constructor({ soClient }: { soClient: SavedObjectsClientContract }) {
this.soClient = soClient;
}
async getInstallationStatus(): Promise<Record<ProductName, ProductInstallState>> {
const response = await this.soClient.find<TypeAttributes>({
type: typeName,
perPage: 100,
});
const installStatus = Object.values(DocumentationProduct).reduce((memo, product) => {
memo[product] = { status: 'uninstalled' };
return memo;
}, {} as Record<ProductName, ProductInstallState>);
response.saved_objects.forEach(({ attributes }) => {
installStatus[attributes.product_name as ProductName] = {
status: attributes.installation_status,
version: attributes.product_version,
};
});
return installStatus;
}
async setInstallationStarted(fields: { productName: ProductName; productVersion: string }) {
const { productName, productVersion } = fields;
const objectId = getObjectIdFromProductName(productName);
const attributes = {
product_name: productName,
product_version: productVersion,
installation_status: 'installing' as const,
last_installation_failure_reason: '',
};
await this.soClient.update<TypeAttributes>(typeName, objectId, attributes, {
upsert: attributes,
});
}
async setInstallationSuccessful(productName: ProductName, indexName: string) {
const objectId = getObjectIdFromProductName(productName);
await this.soClient.update<TypeAttributes>(typeName, objectId, {
installation_status: 'installed',
index_name: indexName,
});
}
async setInstallationFailed(productName: ProductName, failureReason: string) {
const objectId = getObjectIdFromProductName(productName);
await this.soClient.update<TypeAttributes>(typeName, objectId, {
installation_status: 'error',
last_installation_failure_reason: failureReason,
});
}
async setUninstalled(productName: ProductName) {
const objectId = getObjectIdFromProductName(productName);
try {
await this.soClient.update<TypeAttributes>(typeName, objectId, {
installation_status: 'uninstalled',
last_installation_failure_reason: '',
});
} catch (e) {
if (!SavedObjectsErrorHelpers.isNotFoundError(e)) {
throw e;
}
}
}
}
const getObjectIdFromProductName = (productName: ProductName) =>
`kb-product-doc-${productName}-status`.toLowerCase();

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ProductDocInstallClient } from './product_doc_install_service';
export type InstallClientMock = jest.Mocked<ProductDocInstallClient>;
const createInstallClientMock = (): InstallClientMock => {
return {
getInstallationStatus: jest.fn(),
setInstallationStarted: jest.fn(),
setInstallationSuccessful: jest.fn(),
setInstallationFailed: jest.fn(),
setUninstalled: jest.fn(),
} as unknown as InstallClientMock;
};
export const installClientMock = {
create: createInstallClientMock,
};

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ILicense } from '@kbn/licensing-plugin/server';
export const checkLicense = (license: ILicense): boolean => {
const result = license.check('elastic documentation', 'enterprise');
return result.state === 'valid';
};

View file

@ -0,0 +1,247 @@
/*
* 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 { loggerMock, MockedLogger } from '@kbn/logging-mocks';
import { securityServiceMock, httpServerMock } from '@kbn/core/server/mocks';
import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks';
import { licensingMock } from '@kbn/licensing-plugin/server/mocks';
import type { ProductDocInstallClient } from '../doc_install_status';
import { DocumentationManager } from './doc_manager';
jest.mock('../../tasks');
import {
scheduleInstallAllTask,
scheduleUninstallAllTask,
scheduleEnsureUpToDateTask,
getTaskStatus,
waitUntilTaskCompleted,
} from '../../tasks';
const scheduleInstallAllTaskMock = scheduleInstallAllTask as jest.MockedFn<
typeof scheduleInstallAllTask
>;
const scheduleUninstallAllTaskMock = scheduleUninstallAllTask as jest.MockedFn<
typeof scheduleUninstallAllTask
>;
const scheduleEnsureUpToDateTaskMock = scheduleEnsureUpToDateTask as jest.MockedFn<
typeof scheduleEnsureUpToDateTask
>;
const waitUntilTaskCompletedMock = waitUntilTaskCompleted as jest.MockedFn<
typeof waitUntilTaskCompleted
>;
const getTaskStatusMock = getTaskStatus as jest.MockedFn<typeof getTaskStatus>;
describe('DocumentationManager', () => {
let logger: MockedLogger;
let taskManager: ReturnType<typeof taskManagerMock.createStart>;
let licensing: ReturnType<typeof licensingMock.createStart>;
let auditService: ReturnType<typeof securityServiceMock.createStart>['audit'];
let docInstallClient: jest.Mocked<ProductDocInstallClient>;
let docManager: DocumentationManager;
beforeEach(() => {
logger = loggerMock.create();
taskManager = taskManagerMock.createStart();
licensing = licensingMock.createStart();
auditService = securityServiceMock.createStart().audit;
docInstallClient = {
getInstallationStatus: jest.fn(),
} as unknown as jest.Mocked<ProductDocInstallClient>;
docManager = new DocumentationManager({
logger,
taskManager,
licensing,
auditService,
docInstallClient,
});
});
afterEach(() => {
scheduleInstallAllTaskMock.mockReset();
scheduleUninstallAllTaskMock.mockReset();
scheduleEnsureUpToDateTaskMock.mockReset();
waitUntilTaskCompletedMock.mockReset();
getTaskStatusMock.mockReset();
});
describe('#install', () => {
beforeEach(() => {
licensing.getLicense.mockResolvedValue(
licensingMock.createLicense({ license: { type: 'enterprise' } })
);
getTaskStatusMock.mockResolvedValue('not_scheduled');
docInstallClient.getInstallationStatus.mockResolvedValue({
kibana: { status: 'uninstalled' },
} as Awaited<ReturnType<ProductDocInstallClient['getInstallationStatus']>>);
});
it('calls `scheduleInstallAllTask`', async () => {
await docManager.install({});
expect(scheduleInstallAllTaskMock).toHaveBeenCalledTimes(1);
expect(scheduleInstallAllTaskMock).toHaveBeenCalledWith({
taskManager,
logger,
});
expect(waitUntilTaskCompletedMock).not.toHaveBeenCalled();
});
it('calls waitUntilTaskCompleted if wait=true', async () => {
await docManager.install({ wait: true });
expect(scheduleInstallAllTaskMock).toHaveBeenCalledTimes(1);
expect(waitUntilTaskCompletedMock).toHaveBeenCalledTimes(1);
});
it('does not call scheduleInstallAllTask if already installed and not force', async () => {
docInstallClient.getInstallationStatus.mockResolvedValue({
kibana: { status: 'installed' },
} as Awaited<ReturnType<ProductDocInstallClient['getInstallationStatus']>>);
await docManager.install({ wait: true });
expect(scheduleInstallAllTaskMock).not.toHaveBeenCalled();
expect(waitUntilTaskCompletedMock).not.toHaveBeenCalled();
});
it('records an audit log when request is provided', async () => {
const request = httpServerMock.createKibanaRequest();
const auditLog = auditService.withoutRequest;
auditService.asScoped = jest.fn(() => auditLog);
await docManager.install({ force: false, wait: false, request });
expect(auditLog.log).toHaveBeenCalledTimes(1);
expect(auditLog.log).toHaveBeenCalledWith({
message: expect.any(String),
event: {
action: 'product_documentation_create',
category: ['database'],
type: ['creation'],
outcome: 'unknown',
},
});
});
it('throws an error if license level is not sufficient', async () => {
licensing.getLicense.mockResolvedValue(
licensingMock.createLicense({ license: { type: 'basic' } })
);
await expect(
docManager.install({ force: false, wait: false })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Elastic documentation requires an enterprise license"`
);
});
});
describe('#update', () => {
beforeEach(() => {
getTaskStatusMock.mockResolvedValue('not_scheduled');
docInstallClient.getInstallationStatus.mockResolvedValue({
kibana: { status: 'uninstalled' },
} as Awaited<ReturnType<ProductDocInstallClient['getInstallationStatus']>>);
});
it('calls `scheduleEnsureUpToDateTask`', async () => {
await docManager.update({});
expect(scheduleEnsureUpToDateTaskMock).toHaveBeenCalledTimes(1);
expect(scheduleEnsureUpToDateTaskMock).toHaveBeenCalledWith({
taskManager,
logger,
});
expect(waitUntilTaskCompletedMock).not.toHaveBeenCalled();
});
it('calls waitUntilTaskCompleted if wait=true', async () => {
await docManager.update({ wait: true });
expect(scheduleEnsureUpToDateTaskMock).toHaveBeenCalledTimes(1);
expect(waitUntilTaskCompletedMock).toHaveBeenCalledTimes(1);
});
it('records an audit log when request is provided', async () => {
const request = httpServerMock.createKibanaRequest();
const auditLog = auditService.withoutRequest;
auditService.asScoped = jest.fn(() => auditLog);
await docManager.update({ wait: false, request });
expect(auditLog.log).toHaveBeenCalledTimes(1);
expect(auditLog.log).toHaveBeenCalledWith({
message: expect.any(String),
event: {
action: 'product_documentation_update',
category: ['database'],
type: ['change'],
outcome: 'unknown',
},
});
});
});
describe('#uninstall', () => {
beforeEach(() => {
getTaskStatusMock.mockResolvedValue('not_scheduled');
docInstallClient.getInstallationStatus.mockResolvedValue({
kibana: { status: 'uninstalled' },
} as Awaited<ReturnType<ProductDocInstallClient['getInstallationStatus']>>);
});
it('calls `scheduleUninstallAllTask`', async () => {
await docManager.uninstall({});
expect(scheduleUninstallAllTaskMock).toHaveBeenCalledTimes(1);
expect(scheduleUninstallAllTaskMock).toHaveBeenCalledWith({
taskManager,
logger,
});
expect(waitUntilTaskCompletedMock).not.toHaveBeenCalled();
});
it('calls waitUntilTaskCompleted if wait=true', async () => {
await docManager.uninstall({ wait: true });
expect(scheduleUninstallAllTaskMock).toHaveBeenCalledTimes(1);
expect(waitUntilTaskCompletedMock).toHaveBeenCalledTimes(1);
});
it('records an audit log when request is provided', async () => {
const request = httpServerMock.createKibanaRequest();
const auditLog = auditService.withoutRequest;
auditService.asScoped = jest.fn(() => auditLog);
await docManager.uninstall({ wait: false, request });
expect(auditLog.log).toHaveBeenCalledTimes(1);
expect(auditLog.log).toHaveBeenCalledWith({
message: expect.any(String),
event: {
action: 'product_documentation_delete',
category: ['database'],
type: ['deletion'],
outcome: 'unknown',
},
});
});
});
});

View file

@ -0,0 +1,204 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Logger } from '@kbn/logging';
import type { CoreAuditService } from '@kbn/core/server';
import { type TaskManagerStartContract, TaskStatus } from '@kbn/task-manager-plugin/server';
import type { LicensingPluginStart } from '@kbn/licensing-plugin/server';
import type { InstallationStatus } from '../../../common/install_status';
import type { ProductDocInstallClient } from '../doc_install_status';
import {
INSTALL_ALL_TASK_ID,
scheduleInstallAllTask,
scheduleUninstallAllTask,
scheduleEnsureUpToDateTask,
getTaskStatus,
waitUntilTaskCompleted,
} from '../../tasks';
import { checkLicense } from './check_license';
import type {
DocumentationManagerAPI,
DocGetStatusResponse,
DocInstallOptions,
DocUninstallOptions,
DocUpdateOptions,
} from './types';
const TEN_MIN_IN_MS = 10 * 60 * 1000;
/**
* High-level installation service, handling product documentation
* installation as unary operations, abstracting away the fact
* that documentation is composed of multiple entities.
*/
export class DocumentationManager implements DocumentationManagerAPI {
private logger: Logger;
private taskManager: TaskManagerStartContract;
private licensing: LicensingPluginStart;
private docInstallClient: ProductDocInstallClient;
private auditService: CoreAuditService;
constructor({
logger,
taskManager,
licensing,
docInstallClient,
auditService,
}: {
logger: Logger;
taskManager: TaskManagerStartContract;
licensing: LicensingPluginStart;
docInstallClient: ProductDocInstallClient;
auditService: CoreAuditService;
}) {
this.logger = logger;
this.taskManager = taskManager;
this.licensing = licensing;
this.docInstallClient = docInstallClient;
this.auditService = auditService;
}
async install(options: DocInstallOptions = {}): Promise<void> {
const { request, force = false, wait = false } = options;
const { status } = await this.getStatus();
if (!force && status === 'installed') {
return;
}
const license = await this.licensing.getLicense();
if (!checkLicense(license)) {
throw new Error('Elastic documentation requires an enterprise license');
}
const taskId = await scheduleInstallAllTask({
taskManager: this.taskManager,
logger: this.logger,
});
if (request) {
this.auditService.asScoped(request).log({
message: `User is requesting installation of product documentation for AI Assistants. Task ID=[${taskId}]`,
event: {
action: 'product_documentation_create',
category: ['database'],
type: ['creation'],
outcome: 'unknown',
},
});
}
if (wait) {
await waitUntilTaskCompleted({
taskManager: this.taskManager,
taskId,
timeout: TEN_MIN_IN_MS,
});
}
}
async update(options: DocUpdateOptions = {}): Promise<void> {
const { request, wait = false } = options;
const taskId = await scheduleEnsureUpToDateTask({
taskManager: this.taskManager,
logger: this.logger,
});
if (request) {
this.auditService.asScoped(request).log({
message: `User is requesting update of product documentation for AI Assistants. Task ID=[${taskId}]`,
event: {
action: 'product_documentation_update',
category: ['database'],
type: ['change'],
outcome: 'unknown',
},
});
}
if (wait) {
await waitUntilTaskCompleted({
taskManager: this.taskManager,
taskId,
timeout: TEN_MIN_IN_MS,
});
}
}
async uninstall(options: DocUninstallOptions = {}): Promise<void> {
const { request, wait = false } = options;
const taskId = await scheduleUninstallAllTask({
taskManager: this.taskManager,
logger: this.logger,
});
if (request) {
this.auditService.asScoped(request).log({
message: `User is requesting deletion of product documentation for AI Assistants. Task ID=[${taskId}]`,
event: {
action: 'product_documentation_delete',
category: ['database'],
type: ['deletion'],
outcome: 'unknown',
},
});
}
if (wait) {
await waitUntilTaskCompleted({
taskManager: this.taskManager,
taskId,
timeout: TEN_MIN_IN_MS,
});
}
}
async getStatus(): Promise<DocGetStatusResponse> {
const taskStatus = await getTaskStatus({
taskManager: this.taskManager,
taskId: INSTALL_ALL_TASK_ID,
});
if (taskStatus !== 'not_scheduled') {
const status = convertTaskStatus(taskStatus);
if (status !== 'unknown') {
return { status };
}
}
const installStatus = await this.docInstallClient.getInstallationStatus();
const overallStatus = getOverallStatus(Object.values(installStatus).map((v) => v.status));
return { status: overallStatus };
}
}
const convertTaskStatus = (taskStatus: TaskStatus): InstallationStatus | 'unknown' => {
switch (taskStatus) {
case TaskStatus.Idle:
case TaskStatus.Claiming:
case TaskStatus.Running:
return 'installing';
case TaskStatus.Failed:
return 'error';
case TaskStatus.Unrecognized:
case TaskStatus.DeadLetter:
case TaskStatus.ShouldDelete:
default:
return 'unknown';
}
};
const getOverallStatus = (statuses: InstallationStatus[]): InstallationStatus => {
const statusOrder: InstallationStatus[] = ['error', 'installing', 'uninstalled', 'installed'];
for (const status of statusOrder) {
if (statuses.includes(status)) {
return status;
}
}
return 'installed';
};

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { DocumentationManager } from './doc_manager';
export type {
DocumentationManagerAPI,
DocUninstallOptions,
DocInstallOptions,
DocUpdateOptions,
DocGetStatusResponse,
} from './types';

View file

@ -0,0 +1,98 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { KibanaRequest } from '@kbn/core/server';
import type { InstallationStatus } from '../../../common/install_status';
/**
* APIs to manage the product documentation.
*/
export interface DocumentationManagerAPI {
/**
* Install the product documentation.
* By default, will only try to install if not already present.
* Can use the `force` option to forcefully reinstall.
*/
install(options?: DocInstallOptions): Promise<void>;
/**
* Update the product documentation to the latest version.
* No-op if the product documentation is not currently installed.
*/
update(options?: DocUpdateOptions): Promise<void>;
/**
* Uninstall the product documentation.
* No-op if the product documentation is not currently installed.
*/
uninstall(options?: DocUninstallOptions): Promise<void>;
/**
* Returns the overall installation status of the documentation.
*/
getStatus(): Promise<DocGetStatusResponse>;
}
/**
* Return type for {@link DocumentationManagerAPI.getStatus}
*/
export interface DocGetStatusResponse {
status: InstallationStatus;
}
/**
* Options for {@link DocumentationManagerAPI.install}
*/
export interface DocInstallOptions {
/**
* When the operation was requested by a user, the request that initiated it.
*
* If not provided, the call will be considered as being done on behalf of system.
*/
request?: KibanaRequest;
/**
* If true, will reinstall the documentation even if already present.
* Defaults to `false`
*/
force?: boolean;
/**
* If true, the returned promise will wait until the update task has completed before resolving.
* Defaults to `false`
*/
wait?: boolean;
}
/**
* Options for {@link DocumentationManagerAPI.uninstall}
*/
export interface DocUninstallOptions {
/**
* When the operation was requested by a user, the request that initiated it.
*
* If not provided, the call will be considered as being done on behalf of system.
*/
request?: KibanaRequest;
/**
* If true, the returned promise will wait until the update task has completed before resolving.
* Defaults to `false`
*/
wait?: boolean;
}
/**
* Options for {@link DocumentationManagerAPI.update}
*/
export interface DocUpdateOptions {
/**
* When the operation was requested by a user, the request that initiated it.
*
* If not provided, the call will be considered as being done on behalf of system.
*/
request?: KibanaRequest;
/**
* If true, the returned promise will wait until the update task has completed before resolving.
* Defaults to `false`
*/
wait?: boolean;
}

View file

@ -0,0 +1,58 @@
/*
* 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 { loggerMock, type MockedLogger } from '@kbn/logging-mocks';
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { InferenceEndpointManager } from './endpoint_manager';
jest.mock('./utils');
import { installElser, getModelInstallStatus, waitUntilModelDeployed } from './utils';
const installElserMock = installElser as jest.MockedFn<typeof installElser>;
const getModelInstallStatusMock = getModelInstallStatus as jest.MockedFn<
typeof getModelInstallStatus
>;
const waitUntilModelDeployedMock = waitUntilModelDeployed as jest.MockedFn<
typeof waitUntilModelDeployed
>;
describe('InferenceEndpointManager', () => {
let logger: MockedLogger;
let esClient: ReturnType<typeof elasticsearchServiceMock.createElasticsearchClient>;
let endpointManager: InferenceEndpointManager;
beforeEach(() => {
logger = loggerMock.create();
esClient = elasticsearchServiceMock.createElasticsearchClient();
endpointManager = new InferenceEndpointManager({ esClient, logger });
});
afterEach(() => {
installElserMock.mockReset();
getModelInstallStatusMock.mockReset();
waitUntilModelDeployedMock.mockReset();
});
describe('#ensureInternalElserInstalled', () => {
it('installs ELSER if not already installed', async () => {
getModelInstallStatusMock.mockResolvedValue({ installed: true });
await endpointManager.ensureInternalElserInstalled();
expect(installElserMock).not.toHaveBeenCalled();
expect(waitUntilModelDeployedMock).toHaveBeenCalledTimes(1);
});
it('does not install ELSER if already present', async () => {
getModelInstallStatusMock.mockResolvedValue({ installed: false });
await endpointManager.ensureInternalElserInstalled();
expect(installElserMock).toHaveBeenCalledTimes(1);
expect(waitUntilModelDeployedMock).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -0,0 +1,41 @@
/*
* 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 { ElasticsearchClient, Logger } from '@kbn/core/server';
import { internalElserInferenceId } from '../../../common/consts';
import { installElser, getModelInstallStatus, waitUntilModelDeployed } from './utils';
export class InferenceEndpointManager {
private readonly log: Logger;
private readonly esClient: ElasticsearchClient;
constructor({ logger, esClient }: { logger: Logger; esClient: ElasticsearchClient }) {
this.log = logger;
this.esClient = esClient;
}
async ensureInternalElserInstalled() {
const { installed } = await getModelInstallStatus({
inferenceId: internalElserInferenceId,
client: this.esClient,
log: this.log,
});
if (!installed) {
await installElser({
inferenceId: internalElserInferenceId,
client: this.esClient,
log: this.log,
});
}
await waitUntilModelDeployed({
modelId: internalElserInferenceId,
client: this.esClient,
log: this.log,
});
}
}

View file

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

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { InferenceEndpointManager } from './endpoint_manager';
export type InferenceEndpointManagerMock = jest.Mocked<InferenceEndpointManager>;
const createMock = (): InferenceEndpointManagerMock => {
return {
ensureInternalElserInstalled: jest.fn(),
} as unknown as InferenceEndpointManagerMock;
};
export const inferenceManagerMock = {
create: createMock,
};

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { InferenceTaskType } from '@elastic/elasticsearch/lib/api/types';
import type { ElasticsearchClient, Logger } from '@kbn/core/server';
export const getModelInstallStatus = async ({
inferenceId,
taskType = 'sparse_embedding',
client,
}: {
inferenceId: string;
taskType?: InferenceTaskType;
client: ElasticsearchClient;
log: Logger;
}) => {
const getInferenceRes = await client.inference.get(
{
task_type: taskType,
inference_id: inferenceId,
},
{ ignore: [404] }
);
const installed = (getInferenceRes.endpoints ?? []).some(
(endpoint) => endpoint.inference_id === inferenceId
);
return { installed };
};

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { waitUntilModelDeployed } from './wait_until_model_deployed';
export { getModelInstallStatus } from './get_model_install_status';
export { installElser } from './install_elser';

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ElasticsearchClient, Logger } from '@kbn/core/server';
export const installElser = async ({
inferenceId,
client,
log,
}: {
inferenceId: string;
client: ElasticsearchClient;
log: Logger;
}) => {
await client.inference.put(
{
task_type: 'sparse_embedding',
inference_id: inferenceId,
inference_config: {
service: 'elasticsearch',
service_settings: {
num_allocations: 1,
num_threads: 1,
model_id: '.elser_model_2',
},
task_settings: {},
},
},
{ requestTimeout: 5 * 60 * 1000 }
);
};

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ElasticsearchClient, Logger } from '@kbn/core/server';
export const waitUntilModelDeployed = async ({
modelId,
client,
log,
maxRetries = 20,
delay = 2000,
}: {
modelId: string;
client: ElasticsearchClient;
log: Logger;
maxRetries?: number;
delay?: number;
}) => {
for (let i = 0; i < maxRetries; i++) {
const statsRes = await client.ml.getTrainedModelsStats({
model_id: modelId,
});
const deploymentStats = statsRes.trained_model_stats[0]?.deployment_stats;
// @ts-expect-error wrong client types
if (!deploymentStats || deploymentStats.nodes.length === 0) {
log.debug(`ML model [${modelId}] was not deployed - attempt ${i + 1} of ${maxRetries}`);
await sleep(delay);
continue;
}
return;
}
throw new Error(`Timeout waiting for ML model ${modelId} to be deployed`);
};
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

View file

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

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const validateArtifactArchiveMock = jest.fn();
export const fetchArtifactVersionsMock = jest.fn();
export const createIndexMock = jest.fn();
export const populateIndexMock = jest.fn();
jest.doMock('./steps', () => {
const actual = jest.requireActual('./steps');
return {
...actual,
validateArtifactArchive: validateArtifactArchiveMock,
fetchArtifactVersions: fetchArtifactVersionsMock,
createIndex: createIndexMock,
populateIndex: populateIndexMock,
};
});
export const downloadToDiskMock = jest.fn();
export const openZipArchiveMock = jest.fn();
export const loadMappingFileMock = jest.fn();
jest.doMock('./utils', () => {
const actual = jest.requireActual('./utils');
return {
...actual,
downloadToDisk: downloadToDiskMock,
openZipArchive: openZipArchiveMock,
loadMappingFile: loadMappingFileMock,
};
});

View file

@ -0,0 +1,255 @@
/*
* 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 {
downloadToDiskMock,
createIndexMock,
populateIndexMock,
loadMappingFileMock,
openZipArchiveMock,
validateArtifactArchiveMock,
fetchArtifactVersionsMock,
} from './package_installer.test.mocks';
import {
getArtifactName,
getProductDocIndexName,
DocumentationProduct,
ProductName,
} from '@kbn/product-doc-common';
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { loggerMock, type MockedLogger } from '@kbn/logging-mocks';
import { installClientMock } from '../doc_install_status/service.mock';
import { inferenceManagerMock } from '../inference_endpoint/service.mock';
import type { ProductInstallState } from '../../../common/install_status';
import { PackageInstaller } from './package_installer';
const artifactsFolder = '/lost';
const artifactRepositoryUrl = 'https://repository.com';
const kibanaVersion = '8.16.3';
const callOrder = (fn: { mock: { invocationCallOrder: number[] } }): number => {
return fn.mock.invocationCallOrder[0];
};
describe('PackageInstaller', () => {
let logger: MockedLogger;
let esClient: ReturnType<typeof elasticsearchServiceMock.createElasticsearchClient>;
let productDocClient: ReturnType<typeof installClientMock.create>;
let endpointManager: ReturnType<typeof inferenceManagerMock.create>;
let packageInstaller: PackageInstaller;
beforeEach(() => {
logger = loggerMock.create();
esClient = elasticsearchServiceMock.createElasticsearchClient();
productDocClient = installClientMock.create();
endpointManager = inferenceManagerMock.create();
packageInstaller = new PackageInstaller({
artifactsFolder,
logger,
esClient,
productDocClient,
endpointManager,
artifactRepositoryUrl,
kibanaVersion,
});
});
afterEach(() => {
downloadToDiskMock.mockReset();
createIndexMock.mockReset();
populateIndexMock.mockReset();
loadMappingFileMock.mockReset();
openZipArchiveMock.mockReset();
validateArtifactArchiveMock.mockReset();
fetchArtifactVersionsMock.mockReset();
});
describe('installPackage', () => {
it('calls the steps with the right parameters', async () => {
const zipArchive = {
close: jest.fn(),
};
openZipArchiveMock.mockResolvedValue(zipArchive);
const mappings = Symbol('mappings');
loadMappingFileMock.mockResolvedValue(mappings);
await packageInstaller.installPackage({ productName: 'kibana', productVersion: '8.16' });
const artifactName = getArtifactName({
productName: 'kibana',
productVersion: '8.16',
});
const indexName = getProductDocIndexName('kibana');
expect(endpointManager.ensureInternalElserInstalled).toHaveBeenCalledTimes(1);
expect(downloadToDiskMock).toHaveBeenCalledTimes(1);
expect(downloadToDiskMock).toHaveBeenCalledWith(
`${artifactRepositoryUrl}/${artifactName}`,
`${artifactsFolder}/${artifactName}`
);
expect(openZipArchiveMock).toHaveBeenCalledTimes(1);
expect(openZipArchiveMock).toHaveBeenCalledWith(`${artifactsFolder}/${artifactName}`);
expect(loadMappingFileMock).toHaveBeenCalledTimes(1);
expect(loadMappingFileMock).toHaveBeenCalledWith(zipArchive);
expect(createIndexMock).toHaveBeenCalledTimes(1);
expect(createIndexMock).toHaveBeenCalledWith({
indexName,
mappings,
esClient,
log: logger,
});
expect(populateIndexMock).toHaveBeenCalledTimes(1);
expect(populateIndexMock).toHaveBeenCalledWith({
indexName,
archive: zipArchive,
esClient,
log: logger,
});
expect(productDocClient.setInstallationSuccessful).toHaveBeenCalledTimes(1);
expect(productDocClient.setInstallationSuccessful).toHaveBeenCalledWith('kibana', indexName);
expect(zipArchive.close).toHaveBeenCalledTimes(1);
expect(productDocClient.setInstallationFailed).not.toHaveBeenCalled();
});
it('executes the steps in the right order', async () => {
await packageInstaller.installPackage({ productName: 'kibana', productVersion: '8.16' });
expect(callOrder(endpointManager.ensureInternalElserInstalled)).toBeLessThan(
callOrder(downloadToDiskMock)
);
expect(callOrder(downloadToDiskMock)).toBeLessThan(callOrder(openZipArchiveMock));
expect(callOrder(openZipArchiveMock)).toBeLessThan(callOrder(loadMappingFileMock));
expect(callOrder(loadMappingFileMock)).toBeLessThan(callOrder(createIndexMock));
expect(callOrder(createIndexMock)).toBeLessThan(callOrder(populateIndexMock));
expect(callOrder(populateIndexMock)).toBeLessThan(
callOrder(productDocClient.setInstallationSuccessful)
);
});
it('closes the archive and calls setInstallationFailed if the installation fails', async () => {
const zipArchive = {
close: jest.fn(),
};
openZipArchiveMock.mockResolvedValue(zipArchive);
populateIndexMock.mockImplementation(async () => {
throw new Error('something bad');
});
await expect(
packageInstaller.installPackage({ productName: 'kibana', productVersion: '8.16' })
).rejects.toThrowError();
expect(productDocClient.setInstallationSuccessful).not.toHaveBeenCalled();
expect(zipArchive.close).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('Error during documentation installation')
);
expect(productDocClient.setInstallationFailed).toHaveBeenCalledTimes(1);
expect(productDocClient.setInstallationFailed).toHaveBeenCalledWith(
'kibana',
'something bad'
);
});
});
describe('installALl', () => {
it('installs all the packages to their latest version', async () => {
jest.spyOn(packageInstaller, 'installPackage');
fetchArtifactVersionsMock.mockResolvedValue({
kibana: ['8.15', '8.16'],
elasticsearch: ['8.15'],
});
await packageInstaller.installAll({});
expect(packageInstaller.installPackage).toHaveBeenCalledTimes(2);
expect(packageInstaller.installPackage).toHaveBeenCalledWith({
productName: 'kibana',
productVersion: '8.16',
});
expect(packageInstaller.installPackage).toHaveBeenCalledWith({
productName: 'elasticsearch',
productVersion: '8.15',
});
});
});
describe('ensureUpToDate', () => {
it('updates the installed packages to the latest version', async () => {
fetchArtifactVersionsMock.mockResolvedValue({
kibana: ['8.15', '8.16'],
security: ['8.15', '8.16'],
elasticsearch: ['8.15'],
});
productDocClient.getInstallationStatus.mockResolvedValue({
kibana: { status: 'installed', version: '8.15' },
security: { status: 'installed', version: '8.16' },
elasticsearch: { status: 'uninstalled' },
} as Record<ProductName, ProductInstallState>);
jest.spyOn(packageInstaller, 'installPackage');
await packageInstaller.ensureUpToDate({});
expect(packageInstaller.installPackage).toHaveBeenCalledTimes(1);
expect(packageInstaller.installPackage).toHaveBeenCalledWith({
productName: 'kibana',
productVersion: '8.16',
});
});
});
describe('uninstallPackage', () => {
it('performs the uninstall steps', async () => {
await packageInstaller.uninstallPackage({ productName: 'kibana' });
expect(esClient.indices.delete).toHaveBeenCalledTimes(1);
expect(esClient.indices.delete).toHaveBeenCalledWith(
{
index: getProductDocIndexName('kibana'),
},
expect.objectContaining({ ignore: [404] })
);
expect(productDocClient.setUninstalled).toHaveBeenCalledTimes(1);
expect(productDocClient.setUninstalled).toHaveBeenCalledWith('kibana');
});
});
describe('uninstallAll', () => {
it('calls uninstall for all packages', async () => {
jest.spyOn(packageInstaller, 'uninstallPackage');
await packageInstaller.uninstallAll();
expect(packageInstaller.uninstallPackage).toHaveBeenCalledTimes(
Object.keys(DocumentationProduct).length
);
Object.values(DocumentationProduct).forEach((productName) => {
expect(packageInstaller.uninstallPackage).toHaveBeenCalledWith({ productName });
});
});
});
});

View file

@ -0,0 +1,218 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Logger } from '@kbn/logging';
import type { ElasticsearchClient } from '@kbn/core/server';
import {
getArtifactName,
getProductDocIndexName,
DocumentationProduct,
type ProductName,
} from '@kbn/product-doc-common';
import type { ProductDocInstallClient } from '../doc_install_status';
import type { InferenceEndpointManager } from '../inference_endpoint';
import { downloadToDisk, openZipArchive, loadMappingFile, type ZipArchive } from './utils';
import { majorMinor, latestVersion } from './utils/semver';
import {
validateArtifactArchive,
fetchArtifactVersions,
createIndex,
populateIndex,
} from './steps';
interface PackageInstallerOpts {
artifactsFolder: string;
logger: Logger;
esClient: ElasticsearchClient;
productDocClient: ProductDocInstallClient;
endpointManager: InferenceEndpointManager;
artifactRepositoryUrl: string;
kibanaVersion: string;
}
export class PackageInstaller {
private readonly log: Logger;
private readonly artifactsFolder: string;
private readonly esClient: ElasticsearchClient;
private readonly productDocClient: ProductDocInstallClient;
private readonly endpointManager: InferenceEndpointManager;
private readonly artifactRepositoryUrl: string;
private readonly currentVersion: string;
constructor({
artifactsFolder,
logger,
esClient,
productDocClient,
endpointManager,
artifactRepositoryUrl,
kibanaVersion,
}: PackageInstallerOpts) {
this.esClient = esClient;
this.productDocClient = productDocClient;
this.artifactsFolder = artifactsFolder;
this.endpointManager = endpointManager;
this.artifactRepositoryUrl = artifactRepositoryUrl;
this.currentVersion = majorMinor(kibanaVersion);
this.log = logger;
}
/**
* Make sure that the currently installed doc packages are up to date.
* Will not upgrade products that are not already installed
*/
async ensureUpToDate({}: {}) {
const [repositoryVersions, installStatuses] = await Promise.all([
fetchArtifactVersions({
artifactRepositoryUrl: this.artifactRepositoryUrl,
}),
this.productDocClient.getInstallationStatus(),
]);
const toUpdate: Array<{
productName: ProductName;
productVersion: string;
}> = [];
Object.entries(installStatuses).forEach(([productName, productState]) => {
if (productState.status === 'uninstalled') {
return;
}
const availableVersions = repositoryVersions[productName as ProductName];
if (!availableVersions || !availableVersions.length) {
return;
}
const selectedVersion = selectVersion(this.currentVersion, availableVersions);
if (productState.version !== selectedVersion) {
toUpdate.push({
productName: productName as ProductName,
productVersion: selectedVersion,
});
}
});
for (const { productName, productVersion } of toUpdate) {
await this.installPackage({
productName,
productVersion,
});
}
}
async installAll({}: {}) {
const repositoryVersions = await fetchArtifactVersions({
artifactRepositoryUrl: this.artifactRepositoryUrl,
});
const allProducts = Object.values(DocumentationProduct) as ProductName[];
for (const productName of allProducts) {
const availableVersions = repositoryVersions[productName];
if (!availableVersions || !availableVersions.length) {
this.log.warn(`No version found for product [${productName}]`);
continue;
}
const selectedVersion = selectVersion(this.currentVersion, availableVersions);
await this.installPackage({
productName,
productVersion: selectedVersion,
});
}
}
async installPackage({
productName,
productVersion,
}: {
productName: ProductName;
productVersion: string;
}) {
this.log.info(
`Starting installing documentation for product [${productName}] and version [${productVersion}]`
);
productVersion = majorMinor(productVersion);
await this.uninstallPackage({ productName });
let zipArchive: ZipArchive | undefined;
try {
await this.productDocClient.setInstallationStarted({
productName,
productVersion,
});
await this.endpointManager.ensureInternalElserInstalled();
const artifactFileName = getArtifactName({ productName, productVersion });
const artifactUrl = `${this.artifactRepositoryUrl}/${artifactFileName}`;
const artifactPath = `${this.artifactsFolder}/${artifactFileName}`;
this.log.debug(`Downloading from [${artifactUrl}] to [${artifactPath}]`);
await downloadToDisk(artifactUrl, artifactPath);
zipArchive = await openZipArchive(artifactPath);
validateArtifactArchive(zipArchive);
const mappings = await loadMappingFile(zipArchive);
const indexName = getProductDocIndexName(productName);
await createIndex({
indexName,
mappings,
esClient: this.esClient,
log: this.log,
});
await populateIndex({
indexName,
archive: zipArchive,
esClient: this.esClient,
log: this.log,
});
await this.productDocClient.setInstallationSuccessful(productName, indexName);
this.log.info(
`Documentation installation successful for product [${productName}] and version [${productVersion}]`
);
} catch (e) {
this.log.error(
`Error during documentation installation of product [${productName}]/[${productVersion}] : ${e.message}`
);
await this.productDocClient.setInstallationFailed(productName, e.message);
throw e;
} finally {
zipArchive?.close();
}
}
async uninstallPackage({ productName }: { productName: ProductName }) {
const indexName = getProductDocIndexName(productName);
await this.esClient.indices.delete(
{
index: indexName,
},
{ ignore: [404] }
);
await this.productDocClient.setUninstalled(productName);
}
async uninstallAll() {
const allProducts = Object.values(DocumentationProduct);
for (const productName of allProducts) {
await this.uninstallPackage({ productName });
}
}
}
const selectVersion = (currentVersion: string, availableVersions: string[]): string => {
return availableVersions.includes(currentVersion)
? currentVersion
: latestVersion(availableVersions);
};

View file

@ -0,0 +1,84 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { MappingTypeMapping } from '@elastic/elasticsearch/lib/api/types';
import { loggerMock, type MockedLogger } from '@kbn/logging-mocks';
import type { ElasticsearchClient } from '@kbn/core/server';
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { createIndex } from './create_index';
import { internalElserInferenceId } from '../../../../common/consts';
describe('createIndex', () => {
let log: MockedLogger;
let esClient: ElasticsearchClient;
beforeEach(() => {
log = loggerMock.create();
esClient = elasticsearchServiceMock.createElasticsearchClient();
});
it('calls esClient.indices.create with the right parameters', async () => {
const mappings: MappingTypeMapping = {
properties: {},
};
const indexName = '.some-index';
await createIndex({
indexName,
mappings,
log,
esClient,
});
expect(esClient.indices.create).toHaveBeenCalledTimes(1);
expect(esClient.indices.create).toHaveBeenCalledWith({
index: indexName,
mappings,
settings: {
number_of_shards: 1,
auto_expand_replicas: '0-1',
},
});
});
it('rewrites the inference_id attribute of semantic_text fields in the mapping', async () => {
const mappings: MappingTypeMapping = {
properties: {
semantic: {
type: 'semantic_text',
inference_id: '.elser',
},
bool: {
type: 'boolean',
},
},
};
await createIndex({
indexName: '.some-index',
mappings,
log,
esClient,
});
expect(esClient.indices.create).toHaveBeenCalledWith(
expect.objectContaining({
mappings: {
properties: {
semantic: {
type: 'semantic_text',
inference_id: internalElserInferenceId,
},
bool: {
type: 'boolean',
},
},
},
})
);
});
});

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Logger } from '@kbn/logging';
import type { ElasticsearchClient } from '@kbn/core/server';
import type { MappingTypeMapping, MappingProperty } from '@elastic/elasticsearch/lib/api/types';
import { internalElserInferenceId } from '../../../../common/consts';
export const createIndex = async ({
esClient,
indexName,
mappings,
log,
}: {
esClient: ElasticsearchClient;
indexName: string;
mappings: MappingTypeMapping;
log: Logger;
}) => {
log.debug(`Creating index ${indexName}`);
overrideInferenceId(mappings, internalElserInferenceId);
await esClient.indices.create({
index: indexName,
mappings,
settings: {
number_of_shards: 1,
auto_expand_replicas: '0-1',
},
});
};
const overrideInferenceId = (mappings: MappingTypeMapping, inferenceId: string) => {
const recursiveOverride = (current: MappingTypeMapping | MappingProperty) => {
if ('type' in current && current.type === 'semantic_text') {
current.inference_id = inferenceId;
}
if ('properties' in current && current.properties) {
for (const prop of Object.values(current.properties)) {
recursiveOverride(prop);
}
}
};
recursiveOverride(mappings);
};

View file

@ -0,0 +1,129 @@
/*
* 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 fetch, { Response } from 'node-fetch';
import { fetchArtifactVersions } from './fetch_artifact_versions';
import { getArtifactName, DocumentationProduct, ProductName } from '@kbn/product-doc-common';
jest.mock('node-fetch');
const fetchMock = fetch as jest.MockedFn<typeof fetch>;
const createResponse = ({
artifactNames,
truncated = false,
}: {
artifactNames: string[];
truncated?: boolean;
}) => {
return `
<ListBucketResult xmlns="http://doc.s3.amazonaws.com/2006-03-01">
<Name>kibana-ai-assistant-kb-artifacts</Name>
<Prefix/>
<Marker/>
<IsTruncated>${truncated}</IsTruncated>
${artifactNames.map(
(artifactName) => `
<Contents>
<Key>${artifactName}</Key>
<Generation>1728486063097626</Generation>
<MetaGeneration>1</MetaGeneration>
<LastModified>2024-10-09T15:01:03.137Z</LastModified>
<ETag>"e0584955969eccf2a16b8829f768cb1f"</ETag>
<Size>36781438</Size>
</Contents>`
)}
</ListBucketResult>
`;
};
const artifactRepositoryUrl = 'https://lost.com';
const expectVersions = (
versions: Partial<Record<ProductName, string[]>>
): Record<ProductName, string[]> => {
const response = {} as Record<ProductName, string[]>;
Object.values(DocumentationProduct).forEach((productName) => {
response[productName] = [];
});
return {
...response,
...versions,
};
};
describe('fetchArtifactVersions', () => {
beforeEach(() => {
fetchMock.mockReset();
});
const mockResponse = (responseText: string) => {
const response = {
text: () => Promise.resolve(responseText),
};
fetchMock.mockResolvedValue(response as Response);
};
it('calls fetch with the right parameters', async () => {
mockResponse(createResponse({ artifactNames: [] }));
await fetchArtifactVersions({ artifactRepositoryUrl });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(`${artifactRepositoryUrl}?max-keys=1000`);
});
it('returns the list of versions from the repository', async () => {
const artifactNames = [
getArtifactName({ productName: 'kibana', productVersion: '8.16' }),
getArtifactName({ productName: 'elasticsearch', productVersion: '8.16' }),
];
mockResponse(createResponse({ artifactNames }));
const versions = await fetchArtifactVersions({ artifactRepositoryUrl });
expect(versions).toEqual(
expectVersions({
kibana: ['8.16'],
elasticsearch: ['8.16'],
})
);
});
it('retrieve all versions for each product', async () => {
const artifactNames = [
getArtifactName({ productName: 'kibana', productVersion: '8.15' }),
getArtifactName({ productName: 'kibana', productVersion: '8.16' }),
getArtifactName({ productName: 'kibana', productVersion: '8.17' }),
getArtifactName({ productName: 'elasticsearch', productVersion: '8.16' }),
getArtifactName({ productName: 'elasticsearch', productVersion: '9.0' }),
];
mockResponse(createResponse({ artifactNames }));
const versions = await fetchArtifactVersions({ artifactRepositoryUrl });
expect(versions).toEqual(
expectVersions({
kibana: ['8.15', '8.16', '8.17'],
elasticsearch: ['8.16', '9.0'],
})
);
});
it('throws an error if the response is truncated', async () => {
mockResponse(createResponse({ artifactNames: [], truncated: true }));
await expect(fetchArtifactVersions({ artifactRepositoryUrl })).rejects.toThrowError(
/bucket content is truncated/
);
});
it('throws an error if the response is not valid xml', async () => {
mockResponse('some plain text');
await expect(fetchArtifactVersions({ artifactRepositoryUrl })).rejects.toThrowError();
});
});

Some files were not shown because too many files have changed in this diff Show more