@equinor/fusion-framework-cli-plugin-ai-index 2.0.0 → 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 +64 -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 +7 -7
- 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
package/src/schema.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
import type { VectorStoreDocument } from '@equinor/fusion-framework-module-ai/lib';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Attribute map type used by {@link IndexSchemaConfig.prepareAttributes}.
|
|
6
|
+
*
|
|
7
|
+
* Combines the schema-declared field types (all optional, since
|
|
8
|
+
* attributes are built up incrementally) with a `Record<string, unknown>`
|
|
9
|
+
* base so non-promoted attributes are still accessible.
|
|
10
|
+
*
|
|
11
|
+
* @template T - Zod object schema from which attribute types are derived.
|
|
12
|
+
*/
|
|
13
|
+
export type SchemaAttributes<T extends z.ZodObject> = Partial<z.input<T>> & Record<string, unknown>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Configuration for a custom Azure AI Search index schema defined via a Zod
|
|
17
|
+
* object shape.
|
|
18
|
+
*
|
|
19
|
+
* Declares which metadata fields should be promoted to top-level Azure AI
|
|
20
|
+
* Search fields (instead of being stored in the generic `attributes` array)
|
|
21
|
+
* and how their values are resolved from each document.
|
|
22
|
+
*
|
|
23
|
+
* Promoted fields become filterable/facetable at the Azure Search level,
|
|
24
|
+
* eliminating the need for `any()` OData operators.
|
|
25
|
+
*
|
|
26
|
+
* @template T - Zod object schema type that defines the promoted field names and types.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```ts
|
|
30
|
+
* import { z } from 'zod';
|
|
31
|
+
* import { defineIndexSchema } from '@equinor/fusion-framework-cli-plugin-ai-index';
|
|
32
|
+
*
|
|
33
|
+
* const schema = defineIndexSchema({
|
|
34
|
+
* shape: z.object({
|
|
35
|
+
* pkg_name: z.string().optional(),
|
|
36
|
+
* type: z.string(),
|
|
37
|
+
* tags: z.array(z.string()).default([]),
|
|
38
|
+
* source_dir: z.string(),
|
|
39
|
+
* }),
|
|
40
|
+
* prepareAttributes: (attrs, doc) => {
|
|
41
|
+
* // attrs.tags is typed as string[] | undefined ✅
|
|
42
|
+
* attrs.tags ??= [];
|
|
43
|
+
* if (doc.metadata.source.includes('packages/')) {
|
|
44
|
+
* attrs.tags.push('package');
|
|
45
|
+
* }
|
|
46
|
+
* return attrs;
|
|
47
|
+
* },
|
|
48
|
+
* resolve: (doc) => ({
|
|
49
|
+
* pkg_name: doc.metadata.attributes?.pkg_name as string | undefined,
|
|
50
|
+
* type: (doc.metadata.attributes?.type as string) ?? 'unknown',
|
|
51
|
+
* tags: (doc.metadata.attributes?.tags as string[]) ?? [],
|
|
52
|
+
* source_dir: doc.metadata.source.split('/')[0],
|
|
53
|
+
* }),
|
|
54
|
+
* });
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export interface IndexSchemaConfig<T extends z.ZodObject = z.ZodObject> {
|
|
58
|
+
/**
|
|
59
|
+
* Zod object schema defining the promoted field names and their types.
|
|
60
|
+
*
|
|
61
|
+
* Each key becomes a top-level Azure AI Search field. The Zod type
|
|
62
|
+
* determines the Azure EDM field type:
|
|
63
|
+
* - `z.string()` → `Edm.String` (filterable, facetable)
|
|
64
|
+
* - `z.array(z.string())` → `Collection(Edm.String)` (filterable, facetable)
|
|
65
|
+
* - `z.number()` → `Edm.Double` (filterable, sortable)
|
|
66
|
+
* - `z.boolean()` → `Edm.Boolean` (filterable)
|
|
67
|
+
*/
|
|
68
|
+
shape: T;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Type-safe attribute processor that enriches document attributes before
|
|
72
|
+
* the schema resolver runs.
|
|
73
|
+
*
|
|
74
|
+
* Runs in addition to the untyped `metadata.attributeProcessor` callback
|
|
75
|
+
* when a schema is defined. The `attributes` parameter is typed from the
|
|
76
|
+
* Zod shape so that schema-declared fields (e.g. `tags`, `pkg_name`)
|
|
77
|
+
* have proper types while non-schema attributes remain accessible via
|
|
78
|
+
* the `Record<string, unknown>` base.
|
|
79
|
+
*
|
|
80
|
+
* Runs after git and package metadata enrichment and after
|
|
81
|
+
* `metadata.attributeProcessor`, before
|
|
82
|
+
* {@link IndexSchemaConfig.resolve | resolve}.
|
|
83
|
+
*
|
|
84
|
+
* @param attributes - The accumulated attributes for the document, typed
|
|
85
|
+
* from the schema shape. All schema fields are optional since they may
|
|
86
|
+
* not be populated yet.
|
|
87
|
+
* @param document - The vector-store document being processed.
|
|
88
|
+
* @returns The enriched attributes map.
|
|
89
|
+
*/
|
|
90
|
+
prepareAttributes?: (
|
|
91
|
+
attributes: SchemaAttributes<T>,
|
|
92
|
+
document: VectorStoreDocument,
|
|
93
|
+
) => SchemaAttributes<T>;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Per-document resolver that extracts or computes promoted field values.
|
|
97
|
+
*
|
|
98
|
+
* Runs after {@link IndexSchemaConfig.prepareAttributes | prepareAttributes}
|
|
99
|
+
* and metadata enrichment (git, package), so all enriched attributes are
|
|
100
|
+
* available on the document.
|
|
101
|
+
*
|
|
102
|
+
* @param document - The fully enriched vector-store document.
|
|
103
|
+
* @returns An object matching the Zod shape with resolved field values.
|
|
104
|
+
*/
|
|
105
|
+
resolve: (document: VectorStoreDocument) => z.output<T>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Type-safe factory for creating an {@link IndexSchemaConfig}.
|
|
110
|
+
*
|
|
111
|
+
* Infers `T` from the Zod shape and constrains both the
|
|
112
|
+
* `prepareAttributes` parameter types and the `resolve` return type,
|
|
113
|
+
* providing compile-time safety that attribute processing and resolution
|
|
114
|
+
* match the declared schema.
|
|
115
|
+
*
|
|
116
|
+
* @template T - Zod object schema type, inferred from `config.shape`.
|
|
117
|
+
* @param config - Schema configuration with a Zod shape, optional typed
|
|
118
|
+
* attribute processor, and a resolver function.
|
|
119
|
+
* @returns The same config object, narrowed to the inferred generic type.
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* ```ts
|
|
123
|
+
* import { z } from 'zod';
|
|
124
|
+
* import { defineIndexSchema } from '@equinor/fusion-framework-cli-plugin-ai-index';
|
|
125
|
+
*
|
|
126
|
+
* const schema = defineIndexSchema({
|
|
127
|
+
* shape: z.object({
|
|
128
|
+
* tags: z.array(z.string()).default([]),
|
|
129
|
+
* type: z.string(),
|
|
130
|
+
* }),
|
|
131
|
+
* prepareAttributes: (attrs, doc) => {
|
|
132
|
+
* attrs.tags ??= []; // string[] | undefined — type-safe ✅
|
|
133
|
+
* if (doc.metadata.source.includes('cookbooks/')) {
|
|
134
|
+
* attrs.tags.push('cookbook');
|
|
135
|
+
* }
|
|
136
|
+
* return attrs;
|
|
137
|
+
* },
|
|
138
|
+
* resolve: (doc) => ({
|
|
139
|
+
* tags: (doc.metadata.attributes?.tags as string[]) ?? [],
|
|
140
|
+
* type: (doc.metadata.attributes?.type as string) ?? 'raw',
|
|
141
|
+
* }),
|
|
142
|
+
* });
|
|
143
|
+
* ```
|
|
144
|
+
*/
|
|
145
|
+
export function defineIndexSchema<T extends z.ZodObject>(
|
|
146
|
+
config: IndexSchemaConfig<T>,
|
|
147
|
+
): IndexSchemaConfig<T> {
|
|
148
|
+
return config;
|
|
149
|
+
}
|
package/src/search-command.ts
CHANGED
|
@@ -2,11 +2,12 @@ import { createCommand, createOption } from 'commander';
|
|
|
2
2
|
import type { Document } from '@langchain/core/documents';
|
|
3
3
|
import { inspect } from 'node:util';
|
|
4
4
|
|
|
5
|
-
import { setupFramework } from '@equinor/fusion-framework-cli-plugin-ai-base';
|
|
5
|
+
import { loadFusionAIConfig, setupFramework } from '@equinor/fusion-framework-cli-plugin-ai-base';
|
|
6
6
|
import {
|
|
7
7
|
withOptions as withAiOptions,
|
|
8
8
|
type AiOptions,
|
|
9
9
|
} from '@equinor/fusion-framework-cli-plugin-ai-base/command-options';
|
|
10
|
+
import type { FusionAIConfigWithIndex } from './config.js';
|
|
10
11
|
import type { RetrieverOptions } from '@equinor/fusion-framework-module-ai/lib';
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -107,6 +108,7 @@ const normalizeMetadata = (metadata: Record<string, unknown>): Record<string, un
|
|
|
107
108
|
*/
|
|
108
109
|
const _command = createCommand('search')
|
|
109
110
|
.description('Search the vector store to validate embeddings and retrieve relevant documents')
|
|
111
|
+
.addOption(createOption('--config <config>', 'Path to a config file').default('fusion-ai.config'))
|
|
110
112
|
.addOption(
|
|
111
113
|
createOption('--limit <number>', 'Maximum number of results to return')
|
|
112
114
|
.default(10)
|
|
@@ -124,6 +126,21 @@ const _command = createCommand('search')
|
|
|
124
126
|
.addOption(createOption('--raw', 'Output raw metadata without normalization').default(false))
|
|
125
127
|
.addOption(createOption('--verbose', 'Enable verbose output').default(false))
|
|
126
128
|
.argument('<query>', 'Search query string')
|
|
129
|
+
.hook('preAction', async (thisCommand) => {
|
|
130
|
+
const opts = thisCommand.opts();
|
|
131
|
+
const config = await loadFusionAIConfig<FusionAIConfigWithIndex>(
|
|
132
|
+
(opts.config as string) ?? 'fusion-ai.config',
|
|
133
|
+
{ baseDir: process.cwd() },
|
|
134
|
+
);
|
|
135
|
+
const indexConfig = config.index ?? {};
|
|
136
|
+
|
|
137
|
+
if (indexConfig.name && !opts.indexName?.trim()) {
|
|
138
|
+
thisCommand.setOptionValue('indexName', indexConfig.name);
|
|
139
|
+
}
|
|
140
|
+
if (indexConfig.model && !opts.embedModel?.trim()) {
|
|
141
|
+
thisCommand.setOptionValue('embedModel', indexConfig.model);
|
|
142
|
+
}
|
|
143
|
+
})
|
|
127
144
|
.action(async (query: string, options: CommandOptions) => {
|
|
128
145
|
if (options.verbose) {
|
|
129
146
|
console.log('🔍 Initializing framework...');
|
|
@@ -131,13 +148,13 @@ const _command = createCommand('search')
|
|
|
131
148
|
|
|
132
149
|
const framework = await setupFramework(options);
|
|
133
150
|
|
|
134
|
-
if (!options.
|
|
135
|
-
throw new Error('
|
|
151
|
+
if (!options.indexName) {
|
|
152
|
+
throw new Error('Index name is required');
|
|
136
153
|
}
|
|
137
154
|
|
|
138
155
|
if (options.verbose) {
|
|
139
156
|
console.log('✅ Framework initialized successfully');
|
|
140
|
-
console.log(`📇 Index: ${options.
|
|
157
|
+
console.log(`📇 Index: ${options.indexName}`);
|
|
141
158
|
console.log(`🔎 Searching for: "${query}"`);
|
|
142
159
|
console.log(`📊 Limit: ${options.limit}`);
|
|
143
160
|
console.log(`🔍 Search type: ${options.searchType}`);
|
|
@@ -147,7 +164,7 @@ const _command = createCommand('search')
|
|
|
147
164
|
console.log('');
|
|
148
165
|
}
|
|
149
166
|
|
|
150
|
-
const vectorStoreService = framework.ai.
|
|
167
|
+
const vectorStoreService = framework.ai.useIndex(options.indexName);
|
|
151
168
|
|
|
152
169
|
try {
|
|
153
170
|
const filter = options.filter ? { filterExpression: options.filter } : undefined;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Known OpenAI embedding model names and their output vector dimensions.
|
|
3
|
+
*
|
|
4
|
+
* Used by the `ffc ai index create` command to set the `dimensions`
|
|
5
|
+
* property on the `content_vector` field in the Azure AI Search schema.
|
|
6
|
+
*
|
|
7
|
+
* @see https://platform.openai.com/docs/guides/embeddings
|
|
8
|
+
*/
|
|
9
|
+
const KNOWN_MODEL_DIMENSIONS: ReadonlyMap<string, number> = new Map([
|
|
10
|
+
['text-embedding-3-large', 3072],
|
|
11
|
+
['text-embedding-3-small', 1536],
|
|
12
|
+
['text-embedding-ada-002', 1536],
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the embedding vector dimensions for a given model name.
|
|
17
|
+
*
|
|
18
|
+
* Checks the known model→dimensions map first. Falls back to an explicit
|
|
19
|
+
* `dimensions` override from the config. Throws if neither is available.
|
|
20
|
+
*
|
|
21
|
+
* @param model - The embedding model name (e.g. `'text-embedding-3-large'`).
|
|
22
|
+
* @param configDimensions - Optional explicit dimensions from config, used
|
|
23
|
+
* when the model is not in the known map.
|
|
24
|
+
* @returns The number of dimensions for the embedding vector.
|
|
25
|
+
* @throws {Error} When the model is unknown and no explicit dimensions are configured.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveEmbeddingDimensions(model: string, configDimensions?: number): number {
|
|
28
|
+
const known = KNOWN_MODEL_DIMENSIONS.get(model);
|
|
29
|
+
if (known !== undefined) return known;
|
|
30
|
+
|
|
31
|
+
if (configDimensions !== undefined) return configDimensions;
|
|
32
|
+
|
|
33
|
+
const knownModels = [...KNOWN_MODEL_DIMENSIONS.keys()].join(', ');
|
|
34
|
+
throw new Error(
|
|
35
|
+
`Unknown embedding model "${model}". ` +
|
|
36
|
+
`Known models: ${knownModels}. ` +
|
|
37
|
+
'For custom models, set `index.embedding.dimensions` in the config.',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
import { zodToAzureFields } from './zod-to-azure-fields.js';
|
|
5
|
+
|
|
6
|
+
describe('zodToAzureFields', () => {
|
|
7
|
+
it('maps z.string() to Edm.String with filterable + facetable', () => {
|
|
8
|
+
const schema = z.object({ pkg_name: z.string() });
|
|
9
|
+
const fields = zodToAzureFields(schema);
|
|
10
|
+
|
|
11
|
+
expect(fields).toEqual([
|
|
12
|
+
{
|
|
13
|
+
name: 'pkg_name',
|
|
14
|
+
type: 'Edm.String',
|
|
15
|
+
filterable: true,
|
|
16
|
+
sortable: false,
|
|
17
|
+
facetable: true,
|
|
18
|
+
searchable: false,
|
|
19
|
+
},
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('maps z.number() to Edm.Double with filterable + sortable', () => {
|
|
24
|
+
const schema = z.object({ score: z.number() });
|
|
25
|
+
const fields = zodToAzureFields(schema);
|
|
26
|
+
|
|
27
|
+
expect(fields).toEqual([
|
|
28
|
+
{
|
|
29
|
+
name: 'score',
|
|
30
|
+
type: 'Edm.Double',
|
|
31
|
+
filterable: true,
|
|
32
|
+
sortable: true,
|
|
33
|
+
facetable: false,
|
|
34
|
+
searchable: false,
|
|
35
|
+
},
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('maps z.boolean() to Edm.Boolean with filterable', () => {
|
|
40
|
+
const schema = z.object({ active: z.boolean() });
|
|
41
|
+
const fields = zodToAzureFields(schema);
|
|
42
|
+
|
|
43
|
+
expect(fields).toEqual([
|
|
44
|
+
{
|
|
45
|
+
name: 'active',
|
|
46
|
+
type: 'Edm.Boolean',
|
|
47
|
+
filterable: true,
|
|
48
|
+
sortable: false,
|
|
49
|
+
facetable: false,
|
|
50
|
+
searchable: false,
|
|
51
|
+
},
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('maps z.array(z.string()) to Collection(Edm.String) with filterable + facetable', () => {
|
|
56
|
+
const schema = z.object({ tags: z.array(z.string()) });
|
|
57
|
+
const fields = zodToAzureFields(schema);
|
|
58
|
+
|
|
59
|
+
expect(fields).toEqual([
|
|
60
|
+
{
|
|
61
|
+
name: 'tags',
|
|
62
|
+
type: 'Collection(Edm.String)',
|
|
63
|
+
filterable: true,
|
|
64
|
+
sortable: false,
|
|
65
|
+
facetable: true,
|
|
66
|
+
searchable: false,
|
|
67
|
+
},
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('maps z.enum() to Edm.String', () => {
|
|
72
|
+
const schema = z.object({ status: z.enum(['draft', 'published']) });
|
|
73
|
+
const fields = zodToAzureFields(schema);
|
|
74
|
+
|
|
75
|
+
expect(fields[0]).toMatchObject({ name: 'status', type: 'Edm.String' });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('unwraps z.optional() to the inner type', () => {
|
|
79
|
+
const schema = z.object({ pkg_name: z.string().optional() });
|
|
80
|
+
const fields = zodToAzureFields(schema);
|
|
81
|
+
|
|
82
|
+
expect(fields[0]).toMatchObject({ name: 'pkg_name', type: 'Edm.String' });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('unwraps z.default() to the inner type', () => {
|
|
86
|
+
const schema = z.object({ tags: z.array(z.string()).default([]) });
|
|
87
|
+
const fields = zodToAzureFields(schema);
|
|
88
|
+
|
|
89
|
+
expect(fields[0]).toMatchObject({ name: 'tags', type: 'Collection(Edm.String)' });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('unwraps z.nullable() to the inner type', () => {
|
|
93
|
+
const schema = z.object({ name: z.string().nullable() });
|
|
94
|
+
const fields = zodToAzureFields(schema);
|
|
95
|
+
|
|
96
|
+
expect(fields[0]).toMatchObject({ name: 'name', type: 'Edm.String' });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('unwraps nested wrappers (optional + default)', () => {
|
|
100
|
+
const schema = z.object({ tags: z.array(z.string()).default([]).optional() });
|
|
101
|
+
const fields = zodToAzureFields(schema);
|
|
102
|
+
|
|
103
|
+
expect(fields[0]).toMatchObject({ name: 'tags', type: 'Collection(Edm.String)' });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('handles multiple fields in a single schema', () => {
|
|
107
|
+
const schema = z.object({
|
|
108
|
+
pkg_name: z.string().optional(),
|
|
109
|
+
type: z.string(),
|
|
110
|
+
tags: z.array(z.string()).default([]),
|
|
111
|
+
source_dir: z.string(),
|
|
112
|
+
});
|
|
113
|
+
const fields = zodToAzureFields(schema);
|
|
114
|
+
|
|
115
|
+
expect(fields).toHaveLength(4);
|
|
116
|
+
expect(fields.map((f) => f.name)).toEqual(['pkg_name', 'type', 'tags', 'source_dir']);
|
|
117
|
+
expect(fields.map((f) => f.type)).toEqual([
|
|
118
|
+
'Edm.String',
|
|
119
|
+
'Edm.String',
|
|
120
|
+
'Collection(Edm.String)',
|
|
121
|
+
'Edm.String',
|
|
122
|
+
]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('throws for unsupported Zod types (z.object)', () => {
|
|
126
|
+
const schema = z.object({ nested: z.object({ a: z.string() }) });
|
|
127
|
+
|
|
128
|
+
expect(() => zodToAzureFields(schema)).toThrow(/Unsupported Zod type/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('throws for unsupported array element types (z.array(z.number()))', () => {
|
|
132
|
+
const schema = z.object({ nums: z.array(z.number()) });
|
|
133
|
+
|
|
134
|
+
expect(() => zodToAzureFields(schema)).toThrow(/Unsupported array element type/);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type z,
|
|
3
|
+
ZodString,
|
|
4
|
+
ZodNumber,
|
|
5
|
+
ZodBoolean,
|
|
6
|
+
ZodArray,
|
|
7
|
+
ZodEnum,
|
|
8
|
+
ZodOptional,
|
|
9
|
+
ZodDefault,
|
|
10
|
+
ZodNullable,
|
|
11
|
+
type ZodType,
|
|
12
|
+
} from 'zod';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Azure AI Search EDM (Entity Data Model) type identifiers used in
|
|
16
|
+
* index field definitions.
|
|
17
|
+
*/
|
|
18
|
+
type AzureEdmType =
|
|
19
|
+
| 'Edm.String'
|
|
20
|
+
| 'Edm.Int32'
|
|
21
|
+
| 'Edm.Int64'
|
|
22
|
+
| 'Edm.Double'
|
|
23
|
+
| 'Edm.Boolean'
|
|
24
|
+
| 'Collection(Edm.String)';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Azure AI Search field definition matching the REST API schema for
|
|
28
|
+
* index creation.
|
|
29
|
+
*
|
|
30
|
+
* @see https://learn.microsoft.com/en-us/rest/api/searchservice/indexes/create
|
|
31
|
+
*/
|
|
32
|
+
export interface AzureSearchField {
|
|
33
|
+
/** Field name as it appears in the index schema. */
|
|
34
|
+
name: string;
|
|
35
|
+
/** Azure EDM type for the field. */
|
|
36
|
+
type: AzureEdmType;
|
|
37
|
+
/** Whether the field can be used in `$filter` expressions. */
|
|
38
|
+
filterable: boolean;
|
|
39
|
+
/** Whether the field can be used in `$orderby` expressions. */
|
|
40
|
+
sortable: boolean;
|
|
41
|
+
/** Whether the field supports faceted navigation. */
|
|
42
|
+
facetable: boolean;
|
|
43
|
+
/** Whether the field is included in full-text search. */
|
|
44
|
+
searchable: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Unwrap wrapper types (optional, default, nullable) to reach the
|
|
49
|
+
* underlying concrete Zod type.
|
|
50
|
+
*
|
|
51
|
+
* Uses public `instanceof` checks and `unwrap()` methods to avoid
|
|
52
|
+
* reliance on Zod's private `_zod.def` internals.
|
|
53
|
+
*
|
|
54
|
+
* @param schema - A Zod schema node, possibly wrapped.
|
|
55
|
+
* @returns The innermost non-wrapper schema.
|
|
56
|
+
*/
|
|
57
|
+
function unwrapSchema(schema: ZodType): ZodType {
|
|
58
|
+
let current: ZodType = schema;
|
|
59
|
+
// Walk through wrapper layers until we reach a concrete type
|
|
60
|
+
while (
|
|
61
|
+
current instanceof ZodOptional ||
|
|
62
|
+
current instanceof ZodDefault ||
|
|
63
|
+
current instanceof ZodNullable
|
|
64
|
+
) {
|
|
65
|
+
current = current.unwrap() as ZodType;
|
|
66
|
+
}
|
|
67
|
+
return current;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Map a concrete (unwrapped) Zod schema to its Azure EDM field type.
|
|
72
|
+
*
|
|
73
|
+
* Uses public `instanceof` checks against Zod's exported class hierarchy
|
|
74
|
+
* for forward-compatible type mapping.
|
|
75
|
+
*
|
|
76
|
+
* @param schema - The unwrapped Zod schema node.
|
|
77
|
+
* @returns The corresponding Azure EDM type string.
|
|
78
|
+
* @throws {Error} When the Zod type cannot be mapped to an Azure EDM type.
|
|
79
|
+
*/
|
|
80
|
+
function zodToEdmType(schema: ZodType): AzureEdmType {
|
|
81
|
+
if (schema instanceof ZodString || schema instanceof ZodEnum) {
|
|
82
|
+
return 'Edm.String';
|
|
83
|
+
}
|
|
84
|
+
if (schema instanceof ZodNumber) {
|
|
85
|
+
return 'Edm.Double';
|
|
86
|
+
}
|
|
87
|
+
if (schema instanceof ZodBoolean) {
|
|
88
|
+
return 'Edm.Boolean';
|
|
89
|
+
}
|
|
90
|
+
if (schema instanceof ZodArray) {
|
|
91
|
+
const elementSchema = unwrapSchema(schema.element as ZodType);
|
|
92
|
+
if (elementSchema instanceof ZodString || elementSchema instanceof ZodEnum) {
|
|
93
|
+
return 'Collection(Edm.String)';
|
|
94
|
+
}
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Unsupported array element type "${elementSchema.constructor.name}". Only string arrays are supported for Azure Search fields.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Unsupported Zod type "${schema.constructor.name}" for Azure Search field mapping. ` +
|
|
101
|
+
'Supported types: ZodString, ZodNumber, ZodBoolean, ZodEnum, ZodArray(ZodString).',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Derive default field capabilities from an Azure EDM type.
|
|
107
|
+
*
|
|
108
|
+
* All promoted fields are filterable. Strings and string collections are
|
|
109
|
+
* also facetable. Numbers are sortable.
|
|
110
|
+
*
|
|
111
|
+
* @param edmType - The Azure EDM type of the field.
|
|
112
|
+
* @returns Default capability flags for the field.
|
|
113
|
+
*/
|
|
114
|
+
function defaultCapabilities(
|
|
115
|
+
edmType: AzureEdmType,
|
|
116
|
+
): Pick<AzureSearchField, 'filterable' | 'sortable' | 'facetable' | 'searchable'> {
|
|
117
|
+
switch (edmType) {
|
|
118
|
+
case 'Edm.String':
|
|
119
|
+
return { filterable: true, sortable: false, facetable: true, searchable: false };
|
|
120
|
+
case 'Collection(Edm.String)':
|
|
121
|
+
return { filterable: true, sortable: false, facetable: true, searchable: false };
|
|
122
|
+
case 'Edm.Double':
|
|
123
|
+
case 'Edm.Int32':
|
|
124
|
+
case 'Edm.Int64':
|
|
125
|
+
return { filterable: true, sortable: true, facetable: false, searchable: false };
|
|
126
|
+
case 'Edm.Boolean':
|
|
127
|
+
return { filterable: true, sortable: false, facetable: false, searchable: false };
|
|
128
|
+
default:
|
|
129
|
+
return { filterable: true, sortable: false, facetable: false, searchable: false };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Convert a Zod object schema into an array of Azure AI Search field
|
|
135
|
+
* definitions.
|
|
136
|
+
*
|
|
137
|
+
* Walks the Zod shape, maps each field to its Azure EDM type, and assigns
|
|
138
|
+
* default capabilities (filterable, facetable, sortable). Used by the
|
|
139
|
+
* `ffc ai index create` command to generate the index schema.
|
|
140
|
+
*
|
|
141
|
+
* Uses public `instanceof` checks and `unwrap()` methods to avoid
|
|
142
|
+
* reliance on Zod's private `_zod.def` internals, ensuring compatibility
|
|
143
|
+
* across Zod versions.
|
|
144
|
+
*
|
|
145
|
+
* @param schema - A Zod object schema whose keys define the promoted fields.
|
|
146
|
+
* @returns An array of Azure AI Search field definitions.
|
|
147
|
+
* @throws {Error} When a field type cannot be mapped to an Azure EDM type.
|
|
148
|
+
*
|
|
149
|
+
* @example
|
|
150
|
+
* ```ts
|
|
151
|
+
* import { z } from 'zod';
|
|
152
|
+
* import { zodToAzureFields } from './zod-to-azure-fields.js';
|
|
153
|
+
*
|
|
154
|
+
* const fields = zodToAzureFields(
|
|
155
|
+
* z.object({
|
|
156
|
+
* pkg_name: z.string().optional(),
|
|
157
|
+
* tags: z.array(z.string()).default([]),
|
|
158
|
+
* }),
|
|
159
|
+
* );
|
|
160
|
+
* // [
|
|
161
|
+
* // { name: 'pkg_name', type: 'Edm.String', filterable: true, facetable: true, ... },
|
|
162
|
+
* // { name: 'tags', type: 'Collection(Edm.String)', filterable: true, facetable: true, ... },
|
|
163
|
+
* // ]
|
|
164
|
+
* ```
|
|
165
|
+
*/
|
|
166
|
+
export function zodToAzureFields(schema: z.ZodObject): AzureSearchField[] {
|
|
167
|
+
const shape = schema.shape as Record<string, ZodType>;
|
|
168
|
+
|
|
169
|
+
return Object.entries(shape).map(([name, fieldSchema]) => {
|
|
170
|
+
// Unwrap wrapper types to reach the concrete type
|
|
171
|
+
const innerSchema = unwrapSchema(fieldSchema);
|
|
172
|
+
const edmType = zodToEdmType(innerSchema);
|
|
173
|
+
const capabilities = defaultCapabilities(edmType);
|
|
174
|
+
|
|
175
|
+
return { name, type: edmType, ...capabilities };
|
|
176
|
+
});
|
|
177
|
+
}
|
package/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by genversion.
|
|
2
|
-
export const version = '2.
|
|
2
|
+
export const version = '2.1.0';
|