@equinor/fusion-framework-cli-plugin-ai-index 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/esm/bin/apply-metadata.js +15 -5
  3. package/dist/esm/bin/apply-metadata.js.map +1 -1
  4. package/dist/esm/bin/apply-schema.js +64 -0
  5. package/dist/esm/bin/apply-schema.js.map +1 -0
  6. package/dist/esm/bin/apply-schema.test.js +143 -0
  7. package/dist/esm/bin/apply-schema.test.js.map +1 -0
  8. package/dist/esm/bin/delete-removed-files.js +1 -1
  9. package/dist/esm/bin/delete-removed-files.js.map +1 -1
  10. package/dist/esm/bin/embed.js +188 -47
  11. package/dist/esm/bin/embed.js.map +1 -1
  12. package/dist/esm/create-command.js +186 -0
  13. package/dist/esm/create-command.js.map +1 -0
  14. package/dist/esm/delete-command.js +14 -2
  15. package/dist/esm/delete-command.js.map +1 -1
  16. package/dist/esm/delete-command.options.js +7 -31
  17. package/dist/esm/delete-command.options.js.map +1 -1
  18. package/dist/esm/delete-index-command.js +94 -0
  19. package/dist/esm/delete-index-command.js.map +1 -0
  20. package/dist/esm/embed-command.js +30 -0
  21. package/dist/esm/embed-command.js.map +1 -0
  22. package/dist/esm/embeddings-command.js +14 -17
  23. package/dist/esm/embeddings-command.js.map +1 -1
  24. package/dist/esm/embeddings-command.options.js +12 -43
  25. package/dist/esm/embeddings-command.options.js.map +1 -1
  26. package/dist/esm/index.js +12 -3
  27. package/dist/esm/index.js.map +1 -1
  28. package/dist/esm/schema.js +41 -0
  29. package/dist/esm/schema.js.map +1 -0
  30. package/dist/esm/search-command.js +17 -5
  31. package/dist/esm/search-command.js.map +1 -1
  32. package/dist/esm/utils/embedding-dimensions.js +37 -0
  33. package/dist/esm/utils/embedding-dimensions.js.map +1 -0
  34. package/dist/esm/utils/zod-to-azure-fields.js +120 -0
  35. package/dist/esm/utils/zod-to-azure-fields.js.map +1 -0
  36. package/dist/esm/utils/zod-to-azure-fields.test.js +112 -0
  37. package/dist/esm/utils/zod-to-azure-fields.test.js.map +1 -0
  38. package/dist/esm/version.js +1 -1
  39. package/dist/tsconfig.tsbuildinfo +1 -1
  40. package/dist/types/bin/apply-metadata.d.ts +2 -1
  41. package/dist/types/bin/apply-schema.d.ts +22 -0
  42. package/dist/types/bin/apply-schema.test.d.ts +1 -0
  43. package/dist/types/config.d.ts +14 -0
  44. package/dist/types/create-command.d.ts +6 -0
  45. package/dist/types/delete-command.options.d.ts +9 -23
  46. package/dist/types/delete-index-command.d.ts +6 -0
  47. package/dist/types/embed-command.d.ts +12 -0
  48. package/dist/types/embeddings-command.options.d.ts +9 -28
  49. package/dist/types/index.d.ts +1 -0
  50. package/dist/types/schema.d.ts +137 -0
  51. package/dist/types/utils/embedding-dimensions.d.ts +13 -0
  52. package/dist/types/utils/zod-to-azure-fields.d.ts +61 -0
  53. package/dist/types/utils/zod-to-azure-fields.test.d.ts +1 -0
  54. package/dist/types/version.d.ts +1 -1
  55. package/package.json +6 -6
  56. package/src/bin/apply-metadata.ts +20 -4
  57. package/src/bin/apply-schema.test.ts +170 -0
  58. package/src/bin/apply-schema.ts +86 -0
  59. package/src/bin/delete-removed-files.ts +1 -1
  60. package/src/bin/embed.ts +248 -76
  61. package/src/config.ts +15 -0
  62. package/src/create-command.ts +218 -0
  63. package/src/delete-command.options.ts +7 -37
  64. package/src/delete-command.ts +19 -2
  65. package/src/delete-index-command.ts +121 -0
  66. package/src/embed-command.ts +44 -0
  67. package/src/embeddings-command.options.ts +12 -50
  68. package/src/embeddings-command.ts +18 -18
  69. package/src/index.ts +12 -3
  70. package/src/schema.ts +149 -0
  71. package/src/search-command.ts +22 -5
  72. package/src/utils/embedding-dimensions.ts +39 -0
  73. package/src/utils/zod-to-azure-fields.test.ts +136 -0
  74. package/src/utils/zod-to-azure-fields.ts +177 -0
  75. package/src/version.ts +1 -1
@@ -0,0 +1,218 @@
1
+ import { type Command, createCommand, createOption } from 'commander';
2
+
3
+ import { loadFusionAIConfig, setupFramework } from '@equinor/fusion-framework-cli-plugin-ai-base';
4
+ import {
5
+ withOptions as withAiOptions,
6
+ type AiOptions,
7
+ } from '@equinor/fusion-framework-cli-plugin-ai-base/command-options';
8
+
9
+ import type { FusionAIConfigWithIndex } from './config.js';
10
+ import { zodToAzureFields } from './utils/zod-to-azure-fields.js';
11
+ import { resolveEmbeddingDimensions } from './utils/embedding-dimensions.js';
12
+
13
+ /**
14
+ * CLI command: `ai index create`
15
+ *
16
+ * Generate and preview the Azure AI Search index schema derived from the
17
+ * Zod-based schema definition in the fusion-ai config.
18
+ *
19
+ * Reads the `index.schema` configuration, converts the Zod shape into
20
+ * Azure AI Search field definitions, and combines them with the base
21
+ * fields (`id`, `content`, `content_vector`, `metadata`) to produce
22
+ * the full index schema.
23
+ *
24
+ * Usage:
25
+ * $ ffc ai index create [options]
26
+ *
27
+ * Options:
28
+ * --config <config> Path to a config file (default: fusion-ai.config)
29
+ * --dry-run Preview the schema without creating the index
30
+ *
31
+ * Examples:
32
+ * $ ffc ai index create --dry-run
33
+ * $ ffc ai index create --config fusion-ai.config.eds.ts --dry-run
34
+ */
35
+ const _command = createCommand('create')
36
+ .description('Create an Azure AI Search index from the config schema definition')
37
+ .addOption(createOption('--config <config>', 'Path to a config file').default('fusion-ai.config'))
38
+ .addOption(
39
+ createOption('--dry-run', 'Preview the index schema without creating it').default(false),
40
+ )
41
+ .action(async function (
42
+ this: Command,
43
+ commandOptions: AiOptions & { config: string; dryRun: boolean },
44
+ ) {
45
+ const config = await loadFusionAIConfig<FusionAIConfigWithIndex>(commandOptions.config, {
46
+ baseDir: process.cwd(),
47
+ });
48
+
49
+ const indexConfig = config.index;
50
+ if (!indexConfig?.schema) {
51
+ console.error(
52
+ '❌ No schema defined in config. Add a `schema` property to `index` using defineIndexSchema().',
53
+ );
54
+ process.exit(1);
55
+ }
56
+
57
+ // Resolve index name from config
58
+ const indexName = indexConfig.name;
59
+
60
+ if (!indexName) {
61
+ console.error('❌ Index name is required. Set `name` in the index config.');
62
+ process.exit(1);
63
+ }
64
+
65
+ // Convert Zod shape to Azure AI Search field definitions
66
+ const schemaFields = zodToAzureFields(indexConfig.schema.shape);
67
+
68
+ // Guard against schema fields that collide with reserved base-schema names
69
+ const reservedFieldNames = ['id', 'content', 'content_vector', 'metadata'] as const;
70
+ const conflictingSchemaFields = schemaFields
71
+ .map((field) => field.name)
72
+ .filter((name) => reservedFieldNames.includes(name as (typeof reservedFieldNames)[number]));
73
+
74
+ if (conflictingSchemaFields.length > 0) {
75
+ const conflicts = [...new Set(conflictingSchemaFields)].sort().join(', ');
76
+ console.error(
77
+ `❌ Schema fields use reserved names: ${conflicts}. Reserved field names are: ${reservedFieldNames.join(', ')}. Rename these fields in \`index.schema\`.`,
78
+ );
79
+ process.exit(1);
80
+ }
81
+
82
+ // Resolve embedding vector dimensions from the model name or explicit config
83
+ const model = indexConfig.model ?? 'text-embedding-3-large';
84
+ let dimensions: number;
85
+ try {
86
+ dimensions = resolveEmbeddingDimensions(model, indexConfig.embedding?.dimensions);
87
+ } catch (error) {
88
+ console.error(`❌ ${error instanceof Error ? error.message : String(error)}`);
89
+ process.exit(1);
90
+ }
91
+
92
+ // Base Azure AI Search fields that every index requires
93
+ const baseFields = [
94
+ {
95
+ name: 'id',
96
+ type: 'Edm.String' as const,
97
+ key: true,
98
+ filterable: true,
99
+ sortable: false,
100
+ facetable: false,
101
+ searchable: false,
102
+ },
103
+ {
104
+ name: 'content',
105
+ type: 'Edm.String' as const,
106
+ filterable: false,
107
+ sortable: false,
108
+ facetable: false,
109
+ searchable: true,
110
+ },
111
+ {
112
+ name: 'content_vector',
113
+ type: 'Collection(Edm.Single)' as const,
114
+ filterable: false,
115
+ sortable: false,
116
+ facetable: false,
117
+ searchable: true,
118
+ dimensions,
119
+ vectorSearchProfile: 'default-vector-profile',
120
+ },
121
+ {
122
+ name: 'metadata',
123
+ type: 'Edm.ComplexType' as const,
124
+ fields: [
125
+ {
126
+ name: 'source',
127
+ type: 'Edm.String' as const,
128
+ filterable: true,
129
+ sortable: false,
130
+ facetable: false,
131
+ searchable: false,
132
+ },
133
+ {
134
+ name: 'attributes',
135
+ type: 'Collection(Edm.ComplexType)' as const,
136
+ fields: [
137
+ {
138
+ name: 'key',
139
+ type: 'Edm.String' as const,
140
+ filterable: true,
141
+ sortable: false,
142
+ facetable: false,
143
+ searchable: false,
144
+ },
145
+ {
146
+ name: 'value',
147
+ type: 'Edm.String' as const,
148
+ filterable: true,
149
+ sortable: false,
150
+ facetable: true,
151
+ searchable: false,
152
+ },
153
+ ],
154
+ },
155
+ ],
156
+ },
157
+ ];
158
+
159
+ const fullSchema = {
160
+ name: indexName,
161
+ fields: [...baseFields, ...schemaFields],
162
+ vectorSearch: {
163
+ algorithms: [{ name: 'default-hnsw', kind: 'hnsw' }],
164
+ profiles: [
165
+ {
166
+ name: 'default-vector-profile',
167
+ algorithm: 'default-hnsw',
168
+ },
169
+ ],
170
+ },
171
+ };
172
+
173
+ if (commandOptions.dryRun) {
174
+ console.log('📋 Index schema preview (dry-run):');
175
+ console.log(JSON.stringify(fullSchema, null, 2));
176
+ console.log(
177
+ `\n✅ Schema has ${baseFields.length} base fields + ${schemaFields.length} promoted fields`,
178
+ );
179
+ process.exit(0);
180
+ }
181
+
182
+ // Create or update the index via the Fusion AI proxy
183
+ const framework = await setupFramework(commandOptions);
184
+ const service = await framework.serviceDiscovery.resolveService('ai');
185
+ const baseUri = service.uri.replace(/\/+$/, '');
186
+ const scopes = service.scopes ?? service.defaultScopes ?? [];
187
+ const token = await framework.auth.acquireAccessToken({ request: { scopes } });
188
+
189
+ if (!token) {
190
+ console.error('❌ Failed to acquire access token for the AI service.');
191
+ process.exit(1);
192
+ }
193
+
194
+ const url = `${baseUri}/indexes/${encodeURIComponent(indexName)}?api-version=2024-07-01`;
195
+ const response = await fetch(url, {
196
+ method: 'PUT',
197
+ headers: {
198
+ 'Content-Type': 'application/json',
199
+ Authorization: `Bearer ${token}`,
200
+ },
201
+ body: JSON.stringify(fullSchema),
202
+ });
203
+
204
+ if (!response.ok) {
205
+ const body = await response.text();
206
+ console.error(`❌ Index creation failed (${response.status} ${response.statusText})`);
207
+ console.error(body);
208
+ process.exit(1);
209
+ }
210
+
211
+ console.log(`✅ Index "${indexName}" created/updated successfully.`);
212
+ });
213
+
214
+ /**
215
+ * The `ai index create` command with inherited AI base options for
216
+ * authentication and service discovery.
217
+ */
218
+ export const createIndexCommand: Command = withAiOptions(_command);
@@ -1,51 +1,21 @@
1
1
  import { z } from 'zod';
2
-
3
2
  import { AiOptionsSchema } from '@equinor/fusion-framework-cli-plugin-ai-base/command-options';
4
3
 
5
4
  /**
6
- * Zod schema for validating options of the `ai index remove` command.
7
- *
8
- * Extends the base AI options schema ({@link AiOptionsSchema}) to require
9
- * Azure Search credentials and the embedding deployment (needed to initialise
10
- * the vector store service for document removal).
5
+ * Zod schema for the `ai index remove` command.
11
6
  *
12
- * @example
13
- * ```ts
14
- * const validated = await DeleteOptionsSchema.parseAsync(rawOptions);
15
- * // validated.dryRun, validated.filter, validated.azureSearchEndpoint, etc.
16
- * ```
7
+ * Extends the base AI options schema making `indexName` required.
17
8
  */
18
9
  export const DeleteOptionsSchema = AiOptionsSchema.extend({
19
- openaiEmbeddingDeployment: z
20
- .string({ message: 'Embedding deployment name is required to initialise the vector store.' })
21
- .min(1, 'Embedding deployment name must be a non-empty string.')
22
- .describe('Azure OpenAI embedding deployment name'),
23
- azureSearchEndpoint: z
24
- .string({ message: 'Azure Search endpoint is required for deletion.' })
25
- .url('Azure Search endpoint must be a valid URL.')
26
- .min(1, 'Azure Search endpoint must be a non-empty string.')
27
- .describe('Azure Search endpoint URL'),
28
- azureSearchApiKey: z
29
- .string({ message: 'Azure Search API key is required for deletion.' })
30
- .min(1, 'Azure Search API key must be a non-empty string.')
31
- .describe('Azure Search API key'),
32
- azureSearchIndexName: z
33
- .string({ message: 'Azure Search index name is required for deletion.' })
34
- .min(1, 'Azure Search index name must be a non-empty string.')
35
- .describe('Azure Search index name'),
36
- dryRun: z
37
- .boolean({ message: 'dryRun must be a boolean value.' })
38
- .describe('Preview what would be deleted without making changes'),
10
+ indexName: z
11
+ .string({ message: 'Index name is required for deletion.' })
12
+ .min(1, 'Index name must be a non-empty string.'),
13
+ dryRun: z.boolean().describe('Preview what would be deleted without making changes'),
39
14
  filter: z
40
15
  .string()
41
- .min(1, 'Filter expression must be a non-empty string.')
16
+ .min(1)
42
17
  .optional()
43
18
  .describe('Raw OData filter expression for selecting documents to delete'),
44
19
  }).describe('Command options for the delete command');
45
20
 
46
- /**
47
- * Validated options for the `ai index remove` command.
48
- *
49
- * Inferred from {@link DeleteOptionsSchema}.
50
- */
51
21
  export type DeleteOptions = z.infer<typeof DeleteOptionsSchema>;
@@ -1,7 +1,8 @@
1
1
  import { createCommand, createOption } from 'commander';
2
2
 
3
- import { setupFramework } from '@equinor/fusion-framework-cli-plugin-ai-base';
3
+ import { loadFusionAIConfig, setupFramework } from '@equinor/fusion-framework-cli-plugin-ai-base';
4
4
  import { withOptions as withAiOptions } from '@equinor/fusion-framework-cli-plugin-ai-base/command-options';
5
+ import type { FusionAIConfigWithIndex } from './config.js';
5
6
 
6
7
  import { DeleteOptionsSchema, type DeleteOptions } from './delete-command.options.js';
7
8
 
@@ -59,6 +60,7 @@ function buildFilter(sources: string[], rawFilter?: string): string | undefined
59
60
  */
60
61
  const _command = createCommand('remove')
61
62
  .description('Remove documents from the search index by source path or OData filter')
63
+ .addOption(createOption('--config <config>', 'Path to a config file').default('fusion-ai.config'))
62
64
  .addOption(
63
65
  createOption('--dry-run', 'Preview matching documents without deleting them').default(false),
64
66
  )
@@ -69,6 +71,21 @@ const _command = createCommand('remove')
69
71
  ),
70
72
  )
71
73
  .argument('[source-paths...]', 'Relative file paths whose indexed chunks should be removed')
74
+ .hook('preAction', async (thisCommand) => {
75
+ const opts = thisCommand.opts();
76
+ const config = await loadFusionAIConfig<FusionAIConfigWithIndex>(
77
+ (opts.config as string) ?? 'fusion-ai.config',
78
+ { baseDir: process.cwd() },
79
+ );
80
+ const indexConfig = config.index ?? {};
81
+
82
+ if (indexConfig.name && !opts.indexName?.trim()) {
83
+ thisCommand.setOptionValue('indexName', indexConfig.name);
84
+ }
85
+ if (indexConfig.model && !opts.embedModel?.trim()) {
86
+ thisCommand.setOptionValue('embedModel', indexConfig.model);
87
+ }
88
+ })
72
89
  .action(async (sources: string[], commandOptions: DeleteOptions) => {
73
90
  const options = await DeleteOptionsSchema.parseAsync(commandOptions);
74
91
  const filterExpression = buildFilter(sources, options.filter);
@@ -95,7 +112,7 @@ const _command = createCommand('remove')
95
112
  }
96
113
 
97
114
  const framework = await setupFramework(options);
98
- const vectorStoreService = framework.ai.getService('search', options.azureSearchIndexName);
115
+ const vectorStoreService = framework.ai.useIndex(options.indexName);
99
116
  await vectorStoreService.deleteDocuments({
100
117
  filter: { filterExpression },
101
118
  });
@@ -0,0 +1,121 @@
1
+ import { type Command, createCommand, createOption } from 'commander';
2
+
3
+ import { loadFusionAIConfig, setupFramework } from '@equinor/fusion-framework-cli-plugin-ai-base';
4
+ import {
5
+ withOptions as withAiOptions,
6
+ type AiOptions,
7
+ } from '@equinor/fusion-framework-cli-plugin-ai-base/command-options';
8
+
9
+ import type { FusionAIConfigWithIndex } from './config.js';
10
+
11
+ /**
12
+ * CLI command: `ai index delete`
13
+ *
14
+ * Permanently deletes an Azure AI Search index and all its documents.
15
+ *
16
+ * This operation is irreversible — once deleted, the index definition and all
17
+ * indexed documents are permanently removed. Requires `Fusion.AI.Search.Manage`
18
+ * with a matching `index-name` scope, or the `Fusion.AI.Admin` role.
19
+ *
20
+ * Usage:
21
+ * $ ffc ai index delete [options]
22
+ *
23
+ * Options:
24
+ * --name <name> Index name to delete (overrides config)
25
+ * --config <config> Path to a config file (default: fusion-ai.config)
26
+ * --yes Skip the confirmation prompt
27
+ *
28
+ * Examples:
29
+ * $ ffc ai index delete
30
+ * $ ffc ai index delete --name my-index --yes
31
+ * $ ffc ai index delete --config fusion-ai.config.eds.ts
32
+ */
33
+ const _command = createCommand('delete')
34
+ .description('Permanently delete an Azure AI Search index and all its documents')
35
+ .addOption(createOption('--name <name>', 'Index name to delete (overrides config)'))
36
+ .addOption(createOption('--config <config>', 'Path to a config file').default('fusion-ai.config'))
37
+ .addOption(createOption('--yes', 'Skip the confirmation prompt').default(false))
38
+ .hook('preAction', async (thisCommand) => {
39
+ const opts = thisCommand.opts();
40
+ const config = await loadFusionAIConfig<FusionAIConfigWithIndex>(
41
+ (opts.config as string) ?? 'fusion-ai.config',
42
+ { baseDir: process.cwd() },
43
+ );
44
+ const indexConfig = config.index ?? {};
45
+
46
+ // --name flag takes priority, then fall back to config
47
+ if (!opts.name?.trim() && indexConfig.name) {
48
+ thisCommand.setOptionValue('name', indexConfig.name);
49
+ }
50
+ })
51
+ .action(async function (
52
+ this: Command,
53
+ commandOptions: AiOptions & { config: string; yes: boolean; name?: string },
54
+ ) {
55
+ const indexName = commandOptions.name?.trim();
56
+
57
+ if (!indexName) {
58
+ console.error('❌ Index name is required. Set `name` in the index config or pass --name.');
59
+ process.exit(1);
60
+ }
61
+
62
+ console.log(`\n🗑️ Target index: ${indexName}`);
63
+
64
+ // Guard against accidental deletion — require explicit confirmation
65
+ if (!commandOptions.yes) {
66
+ const { createInterface } = await import('node:readline');
67
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
68
+ const answer = await new Promise<string>((resolve) => {
69
+ rl.question(
70
+ `\n⚠️ This will permanently delete index "${indexName}" and ALL its documents.\n Type the index name to confirm: `,
71
+ resolve,
72
+ );
73
+ });
74
+ rl.close();
75
+
76
+ if (answer.trim() !== indexName) {
77
+ console.log('❌ Confirmation did not match. Aborting.');
78
+ process.exit(1);
79
+ }
80
+ }
81
+
82
+ const framework = await setupFramework(commandOptions);
83
+ const service = await framework.serviceDiscovery.resolveService('ai');
84
+ const baseUri = service.uri.replace(/\/+$/, '');
85
+ const scopes = service.scopes ?? service.defaultScopes ?? [];
86
+ const token = await framework.auth.acquireAccessToken({ request: { scopes } });
87
+
88
+ if (!token) {
89
+ console.error('❌ Failed to acquire access token for the AI service.');
90
+ process.exit(1);
91
+ }
92
+
93
+ const url = `${baseUri}/indexes/${encodeURIComponent(indexName)}?api-version=2024-07-01`;
94
+ const response = await fetch(url, {
95
+ method: 'DELETE',
96
+ headers: {
97
+ Authorization: `Bearer ${token}`,
98
+ },
99
+ });
100
+
101
+ // 204 No Content = successful deletion, 404 = index doesn't exist
102
+ if (response.status === 404) {
103
+ console.error(`❌ Index "${indexName}" not found.`);
104
+ process.exit(1);
105
+ }
106
+
107
+ if (!response.ok) {
108
+ const body = await response.text();
109
+ console.error(`❌ Index deletion failed (${response.status} ${response.statusText})`);
110
+ console.error(body);
111
+ process.exit(1);
112
+ }
113
+
114
+ console.log(`✅ Index "${indexName}" deleted successfully.`);
115
+ });
116
+
117
+ /**
118
+ * The `ai index delete` command with inherited AI base options for
119
+ * authentication and service discovery.
120
+ */
121
+ export const deleteIndexCommand: Command = withAiOptions(_command);
@@ -0,0 +1,44 @@
1
+ import { createCommand } from 'commander';
2
+
3
+ import { setupFramework } from '@equinor/fusion-framework-cli-plugin-ai-base';
4
+ import {
5
+ withOptions as withAiOptions,
6
+ type AiOptions,
7
+ } from '@equinor/fusion-framework-cli-plugin-ai-base/command-options';
8
+
9
+ type CommandOptions = AiOptions;
10
+
11
+ /**
12
+ * CLI command: `ai index embed <text>`
13
+ *
14
+ * Embeds a single text string and prints the resulting vector.
15
+ * Useful for verifying the embeddings endpoint and model are reachable.
16
+ *
17
+ * @example
18
+ * ```sh
19
+ * ffc ai index embed "hello world"
20
+ * ```
21
+ */
22
+ export const embedCommand = withAiOptions(
23
+ createCommand('embed')
24
+ .description('Embed a text string and print the resulting vector (for testing)')
25
+ .argument('<text>', 'Text to embed')
26
+ .action(async (text: string, options: CommandOptions) => {
27
+ const framework = await setupFramework(options);
28
+ const embedder = framework.ai.useEmbed(options.embedModel);
29
+
30
+ console.log(`Embedding model: ${options.embedModel ?? 'default'}`);
31
+ console.log(`Input: ${JSON.stringify(text)}`);
32
+
33
+ const vector = await embedder.embedQuery(text);
34
+
35
+ console.log(`Dimensions: ${vector.length}`);
36
+ console.log(
37
+ `Vector (first 8): [${vector
38
+ .slice(0, 8)
39
+ .map((v) => v.toFixed(6))
40
+ .join(', ')}, ...]`,
41
+ );
42
+ }),
43
+ { includeEmbedding: true },
44
+ );
@@ -1,65 +1,27 @@
1
1
  import { z } from 'zod';
2
-
3
2
  import { AiOptionsSchema } from '@equinor/fusion-framework-cli-plugin-ai-base/command-options';
4
3
 
5
4
  /**
6
- * Zod schema for validating command options for the `ai index add` command.
7
- *
8
- * Extends the base AI options schema ({@link AiOptionsSchema}) with
9
- * add-specific options such as `--dry-run`, `--diff`, `--config`,
10
- * `--base-ref`, and `--clean`.
11
- *
12
- * Azure Search and embedding options that are optional in the base schema
13
- * become **required** because the add command always writes to a
14
- * vector store.
5
+ * Zod schema for the `ai index add` command.
15
6
  *
16
- * @example
17
- * ```ts
18
- * const validated = await CommandOptionsSchema.parseAsync(rawOptions);
19
- * // validated.dryRun, validated.azureSearchEndpoint, etc.
20
- * ```
7
+ * Extends the base AI options schema making `embedModel` and `indexName` required.
21
8
  */
22
9
  export const CommandOptionsSchema = AiOptionsSchema.extend({
23
- // Override optional AI options to make them required for embeddings command
24
- openaiEmbeddingDeployment: z
25
- .string({ message: 'Embedding deployment name is required for embeddings command.' })
26
- .min(1, 'Embedding deployment name must be a non-empty string.')
27
- .describe('Azure OpenAI embedding deployment name'),
28
- azureSearchEndpoint: z
29
- .string({ message: 'Azure Search endpoint is required for embeddings command.' })
30
- .url('Azure Search endpoint must be a valid URL.')
31
- .min(1, 'Azure Search endpoint must be a non-empty string.')
32
- .describe('Azure Search endpoint URL'),
33
- azureSearchApiKey: z
34
- .string({ message: 'Azure Search API key is required for embeddings command.' })
35
- .min(1, 'Azure Search API key must be a non-empty string.')
36
- .describe('Azure Search API key'),
37
- azureSearchIndexName: z
38
- .string({ message: 'Azure Search index name is required for embeddings command.' })
39
- .min(1, 'Azure Search index name must be a non-empty string.')
40
- .describe('Azure Search index name'),
10
+ embedModel: z
11
+ .string({ message: 'Embedding model name is required for the index add command.' })
12
+ .min(1, 'Embedding model name must be a non-empty string.'),
13
+ indexName: z
14
+ .string({ message: 'Index name is required for the index add command.' })
15
+ .min(1, 'Index name must be a non-empty string.'),
41
16
 
42
17
  // Embeddings-specific options
43
- dryRun: z
44
- .boolean({ message: 'dryRun must be a boolean value.' })
45
- .describe('Show what would be processed without actually doing it'),
46
- config: z
47
- .string({ message: 'Config file path is required and must be a non-empty string.' })
48
- .min(1, 'Config file path must be a non-empty string.')
49
- .describe('Path to a config file'),
50
- diff: z
51
- .boolean({ message: 'diff must be a boolean value.' })
52
- .describe('Process only changed files (workflow mode)'),
18
+ dryRun: z.boolean().describe('Show what would be processed without actually doing it'),
19
+ config: z.string().min(1).describe('Path to a config file'),
20
+ diff: z.boolean().describe('Process only changed files (workflow mode)'),
53
21
  baseRef: z.string().min(1).optional().describe('Git reference to compare against'),
54
22
  clean: z
55
- .boolean({ message: 'clean must be a boolean value.' })
23
+ .boolean()
56
24
  .describe('Delete all existing documents from the vector store before processing'),
57
25
  }).describe('Command options for the embeddings command');
58
26
 
59
- /**
60
- * Validated options for the `ai index add` command.
61
- *
62
- * Inferred from {@link CommandOptionsSchema} and used as the single
63
- * source of truth for option types throughout the add/embeddings pipeline.
64
- */
65
27
  export type CommandOptions = z.infer<typeof CommandOptionsSchema>;
@@ -58,31 +58,31 @@ const _command = createCommand('add')
58
58
  ).default(false),
59
59
  )
60
60
  .argument('[glob-patterns...]', 'Glob patterns to match files (optional when using --diff)')
61
- .action(async function (this: Command, patterns: string[], commandOptions: CommandOptions) {
62
- // Load configuration before validation so config values can fill gaps
63
- const preOptions = commandOptions as Record<string, unknown>;
61
+ .hook('preAction', async (thisCommand) => {
62
+ // Load config early so index name and embed model are available
63
+ // before the withOptions validation hook fires.
64
+ const opts = thisCommand.opts();
64
65
  const config = await loadFusionAIConfig<FusionAIConfigWithIndex>(
65
- (preOptions.config as string) ?? 'fusion-ai.config',
66
+ (opts.config as string) ?? 'fusion-ai.config',
66
67
  { baseDir: process.cwd() },
67
68
  );
68
69
  const indexConfig = config.index ?? {};
69
70
 
70
- // Config file values override env-var defaults but not explicit CLI flags.
71
- // Commander merges env vars before the action runs, so we use
72
- // getOptionValueSource to distinguish "user passed --flag" from "came from env".
73
- const parentCommand = this.parent ?? this;
74
- if (indexConfig.name) {
75
- const source = parentCommand.getOptionValueSource('azureSearchIndexName');
76
- if (source !== 'cli') {
77
- preOptions.azureSearchIndexName = indexConfig.name;
78
- }
71
+ if (indexConfig.name && !opts.indexName?.trim()) {
72
+ thisCommand.setOptionValue('indexName', indexConfig.name);
79
73
  }
80
- if (indexConfig.model) {
81
- const source = parentCommand.getOptionValueSource('openaiEmbeddingDeployment');
82
- if (source !== 'cli') {
83
- preOptions.openaiEmbeddingDeployment = indexConfig.model;
84
- }
74
+ if (indexConfig.model && !opts.embedModel?.trim()) {
75
+ thisCommand.setOptionValue('embedModel', indexConfig.model);
85
76
  }
77
+ })
78
+ .action(async function (this: Command, patterns: string[], commandOptions: CommandOptions) {
79
+ // Config was already loaded in preAction; reload here for embed() usage.
80
+ const preOptions = commandOptions as Record<string, unknown>;
81
+ const config = await loadFusionAIConfig<FusionAIConfigWithIndex>(
82
+ (preOptions.config as string) ?? 'fusion-ai.config',
83
+ { baseDir: process.cwd() },
84
+ );
85
+ const indexConfig = config.index ?? {};
86
86
 
87
87
  const options = await CommandOptionsSchema.parseAsync(preOptions);
88
88
 
package/src/index.ts CHANGED
@@ -4,22 +4,31 @@ import { registerAiPlugin as registerAiPluginBase } from '@equinor/fusion-framew
4
4
  import { command as addCommand } from './embeddings-command.js';
5
5
  import { deleteCommand as removeCommand } from './delete-command.js';
6
6
  import { searchCommand } from './search-command.js';
7
+ import { embedCommand } from './embed-command.js';
8
+ import { createIndexCommand } from './create-command.js';
9
+ import { deleteIndexCommand } from './delete-index-command.js';
7
10
 
8
11
  export { FusionAIConfigWithIndex, IndexConfig } from './config.js';
12
+ export { defineIndexSchema, IndexSchemaConfig } from './schema.js';
9
13
 
10
14
  /**
11
15
  * Parent command for the `ai index` group.
12
16
  *
13
- * Owns three subcommands:
17
+ * Owns subcommands:
14
18
  * - `add` — index documents into the Azure AI Search vector store.
15
19
  * - `remove` — remove documents from the vector store.
16
20
  * - `search` — query the vector store for indexed documents.
21
+ * - `create` — create an index from the config schema definition.
22
+ * - `delete` — permanently delete an index and all its documents.
17
23
  */
18
24
  const indexCommand = createCommand('index')
19
- .description('Manage the AI search index (add, search, remove)')
25
+ .description('Manage the AI search index (add, search, remove, create, delete)')
20
26
  .addCommand(addCommand)
21
27
  .addCommand(removeCommand)
22
- .addCommand(searchCommand);
28
+ .addCommand(searchCommand)
29
+ .addCommand(embedCommand)
30
+ .addCommand(createIndexCommand)
31
+ .addCommand(deleteIndexCommand);
23
32
 
24
33
  /**
25
34
  * Registers the `ai index` command with the Fusion Framework CLI.