@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.
- package/CHANGELOG.md +52 -0
- package/dist/esm/bin/apply-metadata.js +15 -5
- package/dist/esm/bin/apply-metadata.js.map +1 -1
- package/dist/esm/bin/apply-schema.js +64 -0
- package/dist/esm/bin/apply-schema.js.map +1 -0
- package/dist/esm/bin/apply-schema.test.js +143 -0
- package/dist/esm/bin/apply-schema.test.js.map +1 -0
- package/dist/esm/bin/delete-removed-files.js +1 -1
- package/dist/esm/bin/delete-removed-files.js.map +1 -1
- package/dist/esm/bin/embed.js +188 -47
- package/dist/esm/bin/embed.js.map +1 -1
- package/dist/esm/create-command.js +186 -0
- package/dist/esm/create-command.js.map +1 -0
- package/dist/esm/delete-command.js +14 -2
- package/dist/esm/delete-command.js.map +1 -1
- package/dist/esm/delete-command.options.js +7 -31
- package/dist/esm/delete-command.options.js.map +1 -1
- package/dist/esm/delete-index-command.js +94 -0
- package/dist/esm/delete-index-command.js.map +1 -0
- package/dist/esm/embed-command.js +30 -0
- package/dist/esm/embed-command.js.map +1 -0
- package/dist/esm/embeddings-command.js +14 -17
- package/dist/esm/embeddings-command.js.map +1 -1
- package/dist/esm/embeddings-command.options.js +12 -43
- package/dist/esm/embeddings-command.options.js.map +1 -1
- package/dist/esm/index.js +12 -3
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/schema.js +41 -0
- package/dist/esm/schema.js.map +1 -0
- package/dist/esm/search-command.js +17 -5
- package/dist/esm/search-command.js.map +1 -1
- package/dist/esm/utils/embedding-dimensions.js +37 -0
- package/dist/esm/utils/embedding-dimensions.js.map +1 -0
- package/dist/esm/utils/zod-to-azure-fields.js +120 -0
- package/dist/esm/utils/zod-to-azure-fields.js.map +1 -0
- package/dist/esm/utils/zod-to-azure-fields.test.js +112 -0
- package/dist/esm/utils/zod-to-azure-fields.test.js.map +1 -0
- package/dist/esm/version.js +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/bin/apply-metadata.d.ts +2 -1
- package/dist/types/bin/apply-schema.d.ts +22 -0
- package/dist/types/bin/apply-schema.test.d.ts +1 -0
- package/dist/types/config.d.ts +14 -0
- package/dist/types/create-command.d.ts +6 -0
- package/dist/types/delete-command.options.d.ts +9 -23
- package/dist/types/delete-index-command.d.ts +6 -0
- package/dist/types/embed-command.d.ts +12 -0
- package/dist/types/embeddings-command.options.d.ts +9 -28
- package/dist/types/index.d.ts +1 -0
- package/dist/types/schema.d.ts +137 -0
- package/dist/types/utils/embedding-dimensions.d.ts +13 -0
- package/dist/types/utils/zod-to-azure-fields.d.ts +61 -0
- package/dist/types/utils/zod-to-azure-fields.test.d.ts +1 -0
- package/dist/types/version.d.ts +1 -1
- package/package.json +6 -6
- package/src/bin/apply-metadata.ts +20 -4
- package/src/bin/apply-schema.test.ts +170 -0
- package/src/bin/apply-schema.ts +86 -0
- package/src/bin/delete-removed-files.ts +1 -1
- package/src/bin/embed.ts +248 -76
- package/src/config.ts +15 -0
- package/src/create-command.ts +218 -0
- package/src/delete-command.options.ts +7 -37
- package/src/delete-command.ts +19 -2
- package/src/delete-index-command.ts +121 -0
- package/src/embed-command.ts +44 -0
- package/src/embeddings-command.options.ts +12 -50
- package/src/embeddings-command.ts +18 -18
- package/src/index.ts +12 -3
- package/src/schema.ts +149 -0
- package/src/search-command.ts +22 -5
- package/src/utils/embedding-dimensions.ts +39 -0
- package/src/utils/zod-to-azure-fields.test.ts +136 -0
- package/src/utils/zod-to-azure-fields.ts +177 -0
- 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
|
|
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
|
-
*
|
|
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
|
-
|
|
20
|
-
.string({ message: '
|
|
21
|
-
.min(1, '
|
|
22
|
-
|
|
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
|
|
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>;
|
package/src/delete-command.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
.
|
|
26
|
-
|
|
27
|
-
.
|
|
28
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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(
|
|
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
|
-
.
|
|
62
|
-
// Load
|
|
63
|
-
|
|
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
|
-
(
|
|
66
|
+
(opts.config as string) ?? 'fusion-ai.config',
|
|
66
67
|
{ baseDir: process.cwd() },
|
|
67
68
|
);
|
|
68
69
|
const indexConfig = config.index ?? {};
|
|
69
70
|
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
|
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.
|